Skip to content

make -n deletes all objects and the PRG: the flag-stamp compare runs at parse time #174

Description

@JC-000

Symptom

make -n is not a dry run in this repo. Asking "what would this build?" with a
flag set that differs from build/flags.stamp deletes every object file and the
PRG, exits 0, and prints nothing about it.

The same is true of make -q and make --dry-run, and of the make -npq
idiom that shell tab-completion uses to enumerate targets.

Found during a supervised agent session on 2026-08-31, by running four make -n
probes to answer "which cfg does each profile link against". The probes destroyed
a working BACKEND=uci USE_NISTCURVES_ONCHIP=1 build in the primary checkout and
left build/labels.txt, .map and .dbg describing a PRG that no longer
existed.

Minimal repro

Verbatim, on master at 0b55c30, GNU Make 3.81 (macOS):

$ make clean && make BACKEND=uci USE_NISTCURVES_ONCHIP=1

$ shasum -a 256 build/c64-https.prg
5f9e9fb9e70edd2a15ab2249120f161d1826262140fa6ce6f3e01410a876b474  build/c64-https.prg
$ find build -name "*.o" | wc -l
      43

$ make -n BACKEND=uci USE_NISTCURVES_ONCHIP_COMB=1 >/dev/null   # DRY RUN
  exit=0

$ find build -name "*.o" | wc -l
       0
$ ls build/c64-https.prg
ls: build/c64-https.prg: No such file or directory

One -n invocation. No recipe ran. Exit 0, no diagnostic.

Mechanism

build/flags.stamp (#159) is written and compared by a $(shell …) inside a
recursive variable assignment, which GNU make evaluates while parsing the
makefile — before it builds the dependency graph, and therefore before -n,
-q or -t can suppress anything. -n suppresses recipes; it does not
suppress $(shell).

Makefile:611-619:

ifneq ($(MAKECMDGOALS),clean)
_ := $(shell mkdir -p build; \
             $(FLAGS_STAMP_BODY) > $(FLAGS_STAMP).tmp; \
             if cmp -s $(FLAGS_STAMP).tmp $(FLAGS_STAMP); then \
                 rm -f $(FLAGS_STAMP).tmp; \
             else \
                 mv $(FLAGS_STAMP).tmp $(FLAGS_STAMP); \
                 rm -f $(ALL_OBJS) $(PRG); \
             fi)
  • Makefile:593FLAGS_STAMP := build/flags.stamp
  • Makefile:597-609FLAGS_STAMP_BODY, the expanded CA65FLAGS /
    LD65FLAGS / BACKEND / archive set
  • Makefile:611 — the only guard: ifneq ($(MAKECMDGOALS),clean)
  • Makefile:618rm -f $(ALL_OBJS) $(PRG);, the deletion
  • Makefile:621-626 — the comment explaining why the deletion is at parse time
    rather than a prerequisite (see "Fixes to avoid" below)
  • Makefile:627$(ALL_OBJS): | $(FLAGS_STAMP), order-only
  • Makefile:637-639 — the recovery rule for that edge

There is a second, narrower instance for the target strings —
Makefile:674-684, deleting at Makefile:682:

                 rm -f build/boot.o build/http.o $(PRG); \

The behaviour is documented, but only as a property of builds. The stamp's own
second line (Makefile:599) reads "A change to any line deletes every object
and the PRG at parse time."
Nothing says a dry run is a parse.

Blast radius

Measured, each from a restored 43-object + PRG baseline:

# invocation objects left PRG
T1 make -n BACKEND=uci USE_NISTCURVES_ONCHIP=1 (matches the stamp) 43 present
T2 make -n clean 43 present
T3 make -n BACKEND=uci USE_NISTCURVES_ONCHIP_COMB=1 0 gone
T4 make -q BACKEND=uci USE_NISTCURVES_ONCHIP_COMB=1 0 gone
T5 make --dry-run BACKEND=uci USE_NISTCURVES_ONCHIP_COMB=1 0 gone
T6 make -n BACKEND=uci USE_NISTCURVES_ONCHIP=1 HTTPS_HOST=example.com 41 gone
T7 make -npq BACKEND=uci USE_NISTCURVES_ONCHIP_COMB=1 0 gone
T8 bare make -n on a uci tree (defaults to BACKEND=ip65) 3 gone

Reading these:

  • T1 is the only safe case: a matching flag set takes the cmp -s branch and
    touches nothing. A dry run is safe exactly when it tells you nothing you did
    not know.
  • T2 is safe by the MAKECMDGOALS guard at Makefile:611, not by anything
    to do with -n.
  • T6 is the HTTPS_HOST path (Makefile:682) — it removes only boot.o,
    http.o and the PRG, and it also leaves build/https_host.inc rewritten to
    the new host.
  • T8 is the realistic accident. Someone on a uci tree types make -n to see
    what a build would do. BACKEND defaults to ip65, so $(ALL_OBJS) at
    Makefile:618 expands to the ip65 object list — the deletion removes the
    shared objects and leaves the three uci-only ones
    (build/net/uci/{net,uci_cmd,net_manifest}.o) as rubble.
  • T7 matters because nobody types it. make -npq is the target-enumeration
    idiom in shell completion; an IDE or a tab-press can trigger this with no
    command in the user's history to explain the missing build.

-t (--touch) is the third state-mutating mode and was not tested.

Why it matters

  1. A query mutates state, silently and with exit 0. -n and -q exist
    precisely to be safe to run when you are unsure. Here, being unsure is what
    costs you the build.
  2. build/ is left internally inconsistent, and rigs read it.
    tools/uci/_memory_policy.py's build_policy_and_arbiter() parses
    build/labels.txt for scratch DMA addresses, and CLAUDE.md is explicit that
    rigs must read cert_buf_size from there rather than hardcode it. After T3
    the PRG is gone but labels.txt, .map and .dbg remain, describing an
    image that no longer exists, while flags.stamp describes a third flag set
    that was never built. Nothing in build/ agrees with anything else.
  3. The repo's own build-verification rule cannot be applied. CLAUDE.md says
    "compare the PRG's sha256" — neither exit code nor file size proves a
    build. A dry run that deletes the PRG removes the only evidence the rule asks
    for.
  4. Rebuilds are expensive here. A comb profile costs a full assemble plus a
    boot precompute on the device side; destroying one to answer a question about
    a cfg path is a poor trade.

What it is not: it does not produce a silently-wrong PRG. The deletion errs
toward deleting, and the next real build sees the stamp differ again and rebuilds
from scratch. Every state above is self-healing. The cost is destroyed work and a
window in which build/ lies to anything that reads it — not a corrupt image.

Fix discussion

The obvious guard is not sufficient as stated

The natural fix is to skip the deletion when make is in a no-execute mode, keyed
on MAKEFLAGS. Measured on GNU Make 3.81:

invocation $(firstword $(MAKEFLAGS))
make (empty)
make -n n
make --dry-run n
make -q q
make -npq qpn

So a guard on n alone misses make -q, which T4 proves is destructive. Any
guard must test for n, q and t, against $(firstword $(MAKEFLAGS))
(later words carry variable assignments and would produce false positives).

Suppressing the deletion is also not enough on its own: the mv at
Makefile:617 must be suppressed too, or a dry run leaves a stamp asserting a
flag set that was never built — which is how you get a later real build to skip
an invalidation it needed.

Does a guard weaken the #128 exemption? No.

CLAUDE.md's HTTPS_HOST/HTTPS_PATH exemption from make clean rests on the
build/https_host.inc compare at Makefile:674-684 running at parse time and
deleting boot.o, http.o and the PRG. That exemption only requires the
deletion to happen on a real build. Guarding -n/-q/-t leaves every
non-dry-run invocation byte-for-byte unchanged, so the exemption stands exactly
as documented. The same guard would apply to both $(shell) blocks.

The real obstacle: a test depends on this behaviour

tools/test_build_flags_stamp.py:212-241,
test_backend_flip_removes_the_other_backends_prg, builds a UCI PRG and then
runs make -n BACKEND=ip65, asserting that the PRG and build/tls13.o are
already gone. Its docstring is explicit:

The invalidation happens during parse, so it is visible here with make -n:
no recipe runs, yet the stale PRG and objects must already be gone.

It uses -n deliberately, to exercise the ip65 flip without linking ip65
because .incbin resolves against the CWD and the test runs in a symlink farm
(Farm, tools/test_build_flags_stamp.py:84-96, a tempfile.mkdtemp tree, so
the test itself never touches the real build/).

So the destructive dry run is currently a tested property, not an oversight.
Any guard breaks that test, and the test cannot simply switch to a real build
without doing the ip65 link it exists to avoid.

Three options, none free

  1. Guard -n/-q/-t, and rework the test. Cleanest for humans. The test
    would need another way to observe the parse-time invalidation — e.g. an
    internal opt-out variable the test sets, which is a knob that exists only for
    the test.
  2. Keep the behaviour, remove the surprise. Leave the deletion, add a
    $(warning …) on the taken branch naming what was just deleted and why, and
    document it where people look. This preserves the invariant and the test, and
    converts a silent exit 0 into something a human can see. Weakest fix, but
    the cheapest and it breaks nothing.
  3. Guard the mutations under dry-run but still report them. Do the cmp,
    skip the mv and the rm, and emit a $(warning) saying what a real build
    would delete. This is the only option that leaves -n both non-destructive
    and honest — with a plain guard, make -n after a flag change would report
    "nothing to be done", which is true of the tree and false as an answer to
    "what would a build do?". Still needs the test reworked as in (1).

Fixes to avoid

Moving the deletion out of parse time into a recipe on a normal prerequisite is
the obvious-looking fix and is explicitly rejected at Makefile:621-626: a
normal prerequisite makes every object depend on the stamp's mtime, a
1-second-resolution comparison on macOS GNU Make 3.81, which is the failure mode
this design exists to eliminate. Whatever is done here must keep the deletion
unconditional and immediate for real builds.

Suggested severity

Low impact, high annoyance, and cheap to hit. Nothing ships wrong; work is lost
and build/ becomes untrustworthy without saying so. Option 2 alone would be a
worthwhile improvement even if the guard is judged not worth the test rework.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions