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.stamp
Makefile:597-609 — FLAGS_STAMP_BODY, the expanded CA65FLAGS /
LD65FLAGS / BACKEND / archive set
Makefile:611 — the only guard: ifneq ($(MAKECMDGOALS),clean)
Makefile:618 — rm -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
- 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.
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.
- 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 silent exit 0 into 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 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.
Symptom
make -nis not a dry run in this repo. Asking "what would this build?" with aflag set that differs from
build/flags.stampdeletes every object file and thePRG, exits 0, and prints nothing about it.
The same is true of
make -qandmake --dry-run, and of themake -npqidiom that shell tab-completion uses to enumerate targets.
Found during a supervised agent session on 2026-08-31, by running four
make -nprobes to answer "which cfg does each profile link against". The probes destroyed
a working
BACKEND=uci USE_NISTCURVES_ONCHIP=1build in the primary checkout andleft
build/labels.txt,.mapand.dbgdescribing a PRG that no longerexisted.
Minimal repro
Verbatim, on
masterat0b55c30, GNU Make 3.81 (macOS):One
-ninvocation. No recipe ran. Exit 0, no diagnostic.Mechanism
build/flags.stamp(#159) is written and compared by a$(shell …)inside arecursive variable assignment, which GNU make evaluates while parsing the
makefile — before it builds the dependency graph, and therefore before
-n,-qor-tcan suppress anything.-nsuppresses recipes; it does notsuppress
$(shell).Makefile:611-619: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 timerather than a prerequisite (see "Fixes to avoid" below)
Makefile:627—$(ALL_OBJS): | $(FLAGS_STAMP), order-onlyMakefile:637-639— the recovery rule for that edgeThere is a second, narrower instance for the target strings —
Makefile:674-684, deleting atMakefile:682: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 objectand the PRG at parse time." Nothing says a dry run is a parse.
Blast radius
Measured, each from a restored 43-object + PRG baseline:
make -n BACKEND=uci USE_NISTCURVES_ONCHIP=1(matches the stamp)make -n cleanmake -n BACKEND=uci USE_NISTCURVES_ONCHIP_COMB=1make -q BACKEND=uci USE_NISTCURVES_ONCHIP_COMB=1make --dry-run BACKEND=uci USE_NISTCURVES_ONCHIP_COMB=1make -n BACKEND=uci USE_NISTCURVES_ONCHIP=1 HTTPS_HOST=example.commake -npq BACKEND=uci USE_NISTCURVES_ONCHIP_COMB=1make -non a uci tree (defaults toBACKEND=ip65)Reading these:
cmp -sbranch andtouches nothing. A dry run is safe exactly when it tells you nothing you did
not know.
MAKECMDGOALSguard atMakefile:611, not by anythingto do with
-n.HTTPS_HOSTpath (Makefile:682) — it removes onlyboot.o,http.oand the PRG, and it also leavesbuild/https_host.increwritten tothe new host.
make -nto seewhat a build would do.
BACKENDdefaults to ip65, so$(ALL_OBJS)atMakefile:618expands to the ip65 object list — the deletion removes theshared objects and leaves the three uci-only ones
(
build/net/uci/{net,uci_cmd,net_manifest}.o) as rubble.make -npqis the target-enumerationidiom 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
-nand-qexistprecisely 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()parsesbuild/labels.txtfor scratch DMA addresses, and CLAUDE.md is explicit thatrigs must read
cert_buf_sizefrom there rather than hardcode it. After T3the PRG is gone but
labels.txt,.mapand.dbgremain, describing animage that no longer exists, while
flags.stampdescribes a third flag setthat was never built. Nothing in
build/agrees with anything else."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.
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:$(firstword $(MAKEFLAGS))makemake -nnmake --dry-runnmake -qqmake -npqqpnSo a guard on
nalone missesmake -q, which T4 proves is destructive. Anyguard must test for
n,qandt, against$(firstword $(MAKEFLAGS))(later words carry variable assignments and would produce false positives).
Suppressing the deletion is also not enough on its own: the
mvatMakefile:617must be suppressed too, or a dry run leaves a stamp asserting aflag 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_PATHexemption frommake cleanrests on thebuild/https_host.inccompare atMakefile:674-684running at parse time anddeleting
boot.o,http.oand the PRG. That exemption only requires thedeletion to happen on a real build. Guarding
-n/-q/-tleaves everynon-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 thenruns
make -n BACKEND=ip65, asserting that the PRG andbuild/tls13.oarealready gone. Its docstring is explicit:
It uses
-ndeliberately, to exercise the ip65 flip without linking ip65 —because
.incbinresolves against the CWD and the test runs in a symlink farm(
Farm,tools/test_build_flags_stamp.py:84-96, atempfile.mkdtemptree, sothe 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
-n/-q/-t, and rework the test. Cleanest for humans. The testwould 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.
$(warning …)on the taken branch naming what was just deleted and why, anddocument it where people look. This preserves the invariant and the test, and
converts a silent
exit 0into something a human can see. Weakest fix, butthe cheapest and it breaks nothing.
cmp,skip the
mvand therm, and emit a$(warning)saying what a real buildwould delete. This is the only option that leaves
-nboth non-destructiveand 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: anormal 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 aworthwhile improvement even if the guard is judged not worth the test rework.