A Tupfile line as ordinary as `export TUP_TOOLCHAIN` silently disabled
tool-change detection for the whole build: the PATH swap that should have
re-run every command reported "Nothing to do (up to date)" and left the
stale output in place, with no diagnostic. `export TUP_TOOL_<name>` did the
same to the per-command tool stat added in #483, which is to say it reopened
#482 through a name.
Root cause: `ensure_env_var_node` keys every `NodeType::Variable` node by
bare variable name in one map, and a find-by-name hit is treated as "same
variable, new value" - it overwrites the node's name and content hash. That
is right for a Tupfile variable and wrong for a node putup owns. Four
producers share the key space: `process_export` and `process_import` on the
user's side, the `TUP_TOOLCHAIN` fingerprint and the `TUP_TOOL_<name>` tool
stat on putup's. Whichever runs second wins.
putup's two now go through `ensure_internal_var_node`, which prefixes the key
with `@`. No Tupfile can spell a name starting with `@`: `parse_export` and
`parse_import` accept one Identifier or Text token, and `@` lexes as
`TokenType::At` - so the two key spaces are disjoint by construction, not by
a reserved-name list. Callers pass the bare name and never see the prefix,
so a future synthetic node kind cannot forget to apply it.
Two alternatives were measured against this one and rejected. Rejecting a
reserved name at parse keeps two lists - the reserved names and the names
internal producers actually emit - equal by hand, leaves the same hazard
standing for every name not yet on the list, and adds a user-visible error
where none is needed. A second map keyed by bare name leaves two further
illegal states reachable: `add_file_node` discards `SortedPairVec::insert`'s
duplicate-key signal, so a user node whose `NAME=VALUE` equals an internal
one silently aliases it in `path_to_node`; and `cached_env_vars` would still
serve the fingerprint to a later `import`.
Nothing is reserved after this. `export TUP_TOOL_gcc` is an ordinary
environment variable with the semantics it should always have had. A third
scenario pins that, and passes with or without this change - it is a
coexistence pin against a future fix that reserves the names instead, not
part of the red.
`show index` now prints the fingerprints as `$/@TUP_TOOLCHAIN=...` and
`$/@TUP_TOOL_<name>=...`. No test snapshots those names and no document
mentions them.
Residual: the prefixed nodes still enter `cached_env_vars` in
`src/cli/context.cpp`. No `import` can match an unspellable key, so the
entries are inert; filtering them would put the prefix in a second file.
Node names changed, so INDEX_VERSION goes 26 to 27 and the first build after
this re-runs every command once.
Verified red-then-green: both reported shapes fail with "Nothing to do (up to
date)" and a stale output on a control built from the parent commit with the
tests and version bump present and only the builder change reverted, and pass
here. The
unspellability law is exhaustive over all 255 leading bytes against both
keywords, and was mutation-checked - asserting `P` instead of `@` fails it
at byte 80.
Ref: #484
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes #484.
A Tupfile line as ordinary as
export TUP_TOOLCHAINsilently disabled tool-change detection for the whole build. The PATH swap that should have re-run every command printedNothing to do (up to date)and left the stale output in place, with no diagnostic.export TUP_TOOL_<name>did the same to the per-command tool stat #483 added, reopening #482 through a name.Cause
ensure_env_var_nodekeys everyNodeType::Variablenode by bare variable name in one map, and a find-by-name hit is treated as same variable, new value — it overwrites the node's name and content hash. That is right for a Tupfile variable and wrong for a node putup owns. Four producers share the key space:process_exportandprocess_importon the user's side, theTUP_TOOLCHAINfingerprint and theTUP_TOOL_<name>tool stat on putup's. Whichever runs second wins.Fix
putup's two producers go through a new
ensure_internal_var_node, which prefixes the key with@. No Tupfile can spell a name starting with@:parse_exportandparse_importaccept exactly oneIdentifierorTexttoken, and@lexes asTokenType::At. The two key spaces are therefore disjoint by construction rather than by a reserved-name list, and callers pass the bare name and never see the prefix — a future synthetic node kind cannot forget to apply it.On a live build the two nodes now coexist:
Nothing is reserved after this:
export TUP_TOOL_gccis an ordinary environment variable with the semantics it should always have had. That is whydocs/reference.mdis untouched — there is no rule for a Tupfile author to learn.Alternatives rejected
Both were weighed on a full test-surface comparison against this one.
Reject reserved names at parse time. Keeps two lists — the reserved names, and the names internal producers actually emit — equal by hand, leaves the same hazard standing for every name not yet on the list, and adds a user-visible error where none is needed.
A second map keyed by bare name. Leaves two further illegal states reachable:
add_file_nodediscardsSortedPairVec::insert's duplicate-key signal, so a user node whoseNAME=VALUEequals an internal one silently aliases it inpath_to_node; andcached_env_varswould still serve the fingerprint to a laterimport.A separate parent directory plus its own map (the shape
config_var_nodesalready uses) was also compared. It avoids the forced rebuild, since identity hashes a Variable node's name rather than its path — but it makes a second invariant rest on the unshared"$/"string literal thatsrc/cli/context.cppandsrc/graph/builder.cppeach spell separately, moving the check to the reader.The comparison also recommended expressing the choice as a sum-typed key (
User{name}|Internal{name}) onensure_env_var_noderather than as a second function. I did not take that:User{"TUP_TOOLCHAIN"}compiles just as readily as calling the wrong function does, so the type buys the same guarantee as two names at the cost of a new type and four touched call sites.Costs and residuals
INDEX_VERSIONgoes 26 → 27 and the first build after this re-runs every command once.show indexnow prints the fingerprints as$/@TUP_TOOLCHAIN=…and$/@TUP_TOOL_<name>=…. No test snapshots those names and no document mentions them.cached_env_varsinsrc/cli/context.cpp. Noimportcan match an unspellable key, so the entries are inert; filtering them would put the prefix in a second file.Verification
Red-then-green, both shapes. On the parent commit each reports
Nothing to do (up to date)with a stale output; both pass here. A third scenario pins that an exported variable named after a tool still reaches the command and still re-runs it when its value changes — it passes with or without the fix, and exists to catch the fix breakingexport.The unspellability claim is discharged by a test exhaustive over all 255 leading bytes against both keywords, mutation-checked: asserting
Pinstead of@fails it at byte 80.make check:rc=0, 182606 assertions in 951 test cases across 32 e2e shards.🤖 Generated with Claude Code
https://claude.ai/code/session_01StgwMENEyfBnEoe4pvAdtQ