Repository navigation
Description
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:593—FLAGS_STAMP := build/flags.stampMakefile:597-609—FLAGS_STAMP_BODY, the expandedCA65FLAGS/
LD65FLAGS/BACKEND/ archive setMakefile:611— the only guard:ifneq ($(MAKECMDGOALS),clean)Makefile:618—rm -f $(ALL_OBJS) $(PRG);, the deletionMakefile: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-onlyMakefile: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 -sbranch and
touches nothing. A dry run is safe exactly when it tells you nothing you did
not know. - T2 is safe by the
MAKECMDGOALSguard atMakefile:611, not by anything
to do with-n. - T6 is the
HTTPS_HOSTpath (Makefile:682) — it removes onlyboot.o,
http.oand the PRG, and it also leavesbuild/https_host.increwritten to
the new host. - T8 is the realistic accident. Someone on a uci tree types
make -nto see
what a build would do.BACKENDdefaults to ip65, so$(ALL_OBJS)at
Makefile:618expands 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 -npqis 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
- A query mutates state, silently and with exit 0.
-nand-qexist
precisely to be safe to run when you are unsure. Here, being unsure is what
costs you the build. build/is left internally inconsistent, and rigs read it.
tools/uci/_memory_policy.py'sbuild_policy_and_arbiter()parses
build/labels.txtfor scratch DMA addresses, and CLAUDE.md is explicit that
rigs must readcert_buf_sizefrom there rather than hardcode it. After T3
the PRG is gone butlabels.txt,.mapand.dbgremain, describing an
image that no longer exists, whileflags.stampdescribes a third flag set
that was never built. Nothing inbuild/agrees with anything else.- 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. - 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
- 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. - 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 silentexit 0into something a human can see. Weakest fix, but
the cheapest and it breaks nothing. - Guard the mutations under dry-run but still report them. Do the
cmp,
skip themvand therm, and emit a$(warning)saying what a real build
would delete. This is the only option that leaves-nboth non-destructive
and honest — with a plain guard,make -nafter 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.
Activity
docs: retire the "make clean after any flag change" rule where it sur…
make: find the option letters correctly, and pin the dangerous direction
test: pin the one invocation that separates the two wrong guards
Merge pull request #183 from JC-000/fix/174-dry-run-non-destructive
JC-000 commentedon Oct 1, 2026
Note from c64-x25519 #167 / #168, which fixed the same parse-time shape. Your guard covers the case #174 is about: under
-n/-q/-tnothing is deleted. Two residual cases turned up in our review. Neither has been measured on this tree (I readf20d484's Makefile but did not build it), so treat them as things to check, not findings:-qanswer. The dry branch compares and warns, but forces nothing out of date. In our tree, that made changed-knob-q <file>answer 0, somake -q X || make Xexited 0 holding the previous config's artifact. It only matters if anything runs-qagainst this Makefile as a gate. On 3.81, a file with no rule answers-q0 even with a phony prerequisite.-e.MAKE_OPT_LETTERSdrops--words. With-eand MAKEFLAGS in the environment, 3.81 obeys long options and their unambiguous prefixes verbatim:MAKEFLAGS='--touch' make -etouches, and so do--t,--drand--que. In our tree that took the real (deleting) path and then touch-created 0-byte targets under the new stamp. Contrived, but$(MFLAGS)(make's own decoding:--touch→-te) closed it for us.Our
-tcase on the unguarded block was worse than "destroys the tree": the next real build exited 0 with a 0-byte archive. Your guard already prevents that for the documented spellings.make: read the #174 dry-run letters from $(MFLAGS), not $(MAKEFLAGS)
Merge pull request #267 from JC-000/fix/make-dryrun-longform
JC-000 commentedon Oct 1, 2026
Thanks. Case 2 reproduced on master:
MAKEFLAGS=--dry-run make -edeleted the PRG and every object. Case 1 did not: a changed-knob-q <prg>returned 1. Your$(MFLAGS)suggestion is what #267 uses. Fixing it also surfaced a worse bug on GNU make 4.x: a plainmake VAR=value(e.g.HTTPS_HOST=…) was misread as a dry run and kept a stale PRG. #267 fixes both, tested on 3.81 and 4.4.1.Merge pull request #310 from JC-000/docs/m3-provenance-174