Skip to content

graph: fold each command's own tool into its identity - #483

Merged
typeless merged 1 commit into
mainfrom
fix/482-command-tool-identity
Sep 20, 2026
Merged

typeless merged 1 commit into
mainfrom
fix/482-command-tool-identity

Conversation

@typeless

Copy link
Copy Markdown
Owner

Closes #482.

The gap

CONFIG_TRACKED_TOOLS is empty by default, so in a default configuration a PATH change that resolved a command's tool to a different binary produced a stale build and no diagnostic — putup reported the project up to date while the compiler it would run had changed.

#480 decided putup would not follow tup here: tup makes PATH a sticky env node of every command, so any PATH change re-runs everything (a cost tup.1 documents), and a recorded PATH string cannot tell a change that shadows a tool from one that does not. That left the shadowing case reachable only through CONFIG_TRACKED_TOOLS, which nothing sets by default. This PR is that residue.

What it does

create_command_node resolves each command's first word — the first that is not a NAME=value assignment, skipped entirely when it contains / — and folds that binary's path:size:mtime into the command's identity, reusing the append_tool_stat and ensure_env_var_node machinery CONFIG_TRACKED_TOOLS already uses. Resolution is cached through imported_env_var_nodes, so the PATH walk runs once per distinct tool rather than once per command, and the seeding sits inside create_command_node so ordinary rules and generated rules are both covered from one point.

On putup's own build this seeds exactly g++ ×115, gcc ×2, cp, ar, and one echo.

Two designs were killed on evidence, not argument

A PATH witness node + forced re-runs. Record the PATH commands ran under as an edgeless node, and on a difference force every command whose text names a basename the two PATH values resolve differently. Killed: out-of-scope commands in a scoped build are carried forward from the old index and are not graph nodes (cmd_build.cpp:1333-1340), so nothing could force them, while the witness advanced to the new PATH — making the staleness permanent and undetectable. The identity route recovers by construction, because a merged command keeps its recorded signature until it is next in scope.

Seeding the first word of each invocation rather than of the command. A census of all 534 rule bodies in the repo shows it would additionally seed gcc ×3 and mv ×1 — every one either a fixture written to be compound, or an mv following a bison that is already seeded — at a cost of 32 commands depending on /usr/bin/echo's mtime, which the shell runs as a builtin and never executes.

Not covered

A tool reached past a shell operator (cd build && gcc ...), through sh -c, or through a wrapper script or compiler driver is not the first word; and on Windows every bare name records <missing>, because append_tool_stat does not consult PATHEXT. CONFIG_TRACKED_TOOLS remains the route for those, and all of it is stated in REQ-ENV-COMMAND-TOOL's reference: rather than left for a reader to discover.

Spec

  • REQ-ENV-COMMAND-TOOL added to command-record.ears.md's env-values group.
  • REQ-ENV-FORWARDED-IDENTITY's guard amended — its condition no longer named the only case in which a PATH change re-runs a command.
  • spec-check: 21 areas, 201 requirements, 0 gaps.

Verification

Red-then-green: the new scenario failed with Nothing to do (up to date) — the issue's exact symptom — before the change.

$ make check
make check rc=0
all tests passed (182436 assertions in 947 test cases, 32 e2e shards)
spec-check: 21 areas, 201 requirements, 0 gaps

One cost

The first build after this lands re-runs every command once, because each gains a sticky input. No INDEX_VERSION bump — nothing in the on-disk format changes.

🤖 Generated with Claude Code

https://claude.ai/code/session_01StgwMENEyfBnEoe4pvAdtQ

CONFIG_TRACKED_TOOLS is empty by default, so in a default configuration a
PATH change that resolved a command's tool to a different binary produced a
stale build and no diagnostic: putup reported the project up to date while
the compiler it would run had changed. The reproduction is a project whose
only rule is `: |> mytool > %o |> out.txt`, with two directories each holding
a different `mytool`; prepending the second to PATH re-ran nothing.

#480 decided putup would not follow tup here. tup makes PATH a sticky env
node of every command, so any PATH change re-runs everything, a cost tup.1
documents; a recorded PATH string cannot tell a change that shadows a tool
from one that does not, and PATH varies between shells on one machine.
That left the shadowing case reachable only through CONFIG_TRACKED_TOOLS,
which nothing sets by default. This is that residue.

create_command_node now resolves each command's first word — the first that
is not a NAME=value assignment, skipped entirely when it contains '/' — and
folds that binary's path, size and mtime into the command's identity, using
the append_tool_stat and ensure_env_var_node machinery CONFIG_TRACKED_TOOLS
already uses. The resolution is cached through imported_env_var_nodes, so
the PATH walk runs once per distinct tool rather than once per command, and
the seeding sits inside create_command_node so both call sites, ordinary
rules and generated rules, are covered from one point.

A word containing '/' is skipped because append_tool_stat joins such a name
to the source root while a command runs in its Tupfile's directory, so the
two disagree and the stat would be a wrong, stable <missing>. Words are
screened for '=' rather than by is_env_assignment_word: for
`VAR=hello; echo $VAR > %o` that predicate rejects the glued `VAR=hello;`
as hiding shell syntax and would seed it, where the shell runs `echo`.

Rejected: recording PATH as a witness node and forcing commands whose text
names a basename the two PATH values resolve differently. It was larger and
wrong under scoped builds — out-of-scope commands are carried forward from
the old index and are not graph nodes, so nothing could force them, while
the witness was refreshed to the new PATH, making the staleness permanent.
Folding into identity recovers by construction, because a merged command
keeps its recorded signature until it is next in scope. Also rejected:
seeding the first word of each invocation rather than of the command. Across
all 534 rule bodies in the repo it would additionally seed gcc three times
and mv once — all in fixtures written to be compound, or after a `bison`
that is already seeded — at the cost of 32 dependencies on /usr/bin/echo,
which the shell runs as a builtin.

Not covered, and stated in REQ-ENV-COMMAND-TOOL's reference: a tool reached
past a shell operator, through `sh -c`, or through a wrapper or driver, and
every bare name on Windows, where append_tool_stat does not consult PATHEXT.
CONFIG_TRACKED_TOOLS remains the route for those.

Verified red-then-green on the new scenario, which failed with
"Nothing to do (up to date)" before the change. [envdep] 54 assertions and
[incremental] 1250 assertions pass; spec-check reports 201 requirements and
0 gaps. Every existing index re-runs once on the first build after this
lands, because each command gains a sticky input; no INDEX_VERSION bump,
since nothing in the on-disk format changes.

Ref: #482

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

PR metrics

Performance (gcc example, Linux)

Workload Instructions CPU time Page faults D1 miss LL miss Wall Peak RSS
parse 1757 M (+4.3%) 0.61 s 15.2 k (+0.7%) 0.7% (+0.2pp) 0.1% (+0.1pp) 0.622 s 35.1 MB (+0.4MB)
dry-run 2481 M (+6.0%) 0.7 s 16.8 k (+1.2%) 0.8% (+0.2pp) 0% 0.716 s 41.5 MB (+1.0MB)

Deterministic signals: instructions (cachegrind-simulated instruction reads — exact across runs, no PMU needed), page faults, peak RSS, and the cachegrind D1/LL miss rates. CPU time is user+sys from time(1).

Internal statistics (gcc example, up-to-date dry run)

Metric Value
Tupfiles parsed 24
Commands 3545
Commands scheduled 0
Files checked 5834
Files changed 0
Files in index 6188 (+0.2%)
Graph edges 384927 (+0.9%)
Index size (bytes) 7869804 (+0.7%)
Implicit deps 344126
Hash computations 197
Hashes skipped (stat cache) 5636
Stat calls 5885
Parse time (ms) 559.1
Total time (ms) 721.3
Runner CPU AMD EPYC 7763 64-Core Processor

Counters from putup -n --stat on the fully-built gcc example (up-to-date dry run): deterministic work measures — a jump in commands scheduled, hash computations, or stat calls is a real behavior change, not noise. Timings are the minimum over repeated runs, compared only against a baseline from the same CPU model; the counters are the regression signal.
Timing deltas suppressed: baseline ran on different hardware (AMD EPYC 9V45 96-Core Processor).

Binary size (Linux)

Binary .text .data .bss File
putup 600.6 KB (+0.2%) 2.3 KB 98.8 KB 711.7 KB

Code churn (whole codebase, last 30d)

Files Lines written Still present Churned Churn rate
31 2376 2047 329 13.8%

Of the lines written across the codebase in the last 30 days, how many are already gone — work that was written and then discarded or rewritten inside the same window. This is the state of the tree including this PR, not a measure of the PR itself. Only code we write is counted: tests, examples, vendored and generated files, CI plumbing and prose are excluded. 1592 lines were deleted in the window in total, most of them older than it.

Where the churn is
File Lines written then discarded
src/parser/eval.cpp 77
src/graph/builder.cpp 69
src/graph/dag.cpp 53
src/index/entry.cpp 44
include/pup/core/token_list.hpp 23
include/pup/parser/eval.hpp 16
src/cli/cmd_build.cpp 8
src/index/reader.cpp 8
include/pup/core/instruction.hpp 7
src/core/instruction.cpp 7

Test coverage (lines)

Overall Median file Min file Max file
88.9% (+0.1pp) 96.9% 14.7% include/pup/parser/token.hpp 100.0% include/pup/core/arena.hpp

105 files · 17850/20090 lines covered

Deltas vs main@8dd8a5a6b.

Updated for f442d43

@typeless
typeless merged commit 2987923 into main Sep 20, 2026
13 checks passed
@typeless
typeless deleted the fix/482-command-tool-identity branch September 20, 2026 14:49
typeless added a commit that referenced this pull request Sep 21, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A PATH change that swaps a tool goes unnoticed unless CONFIG_TRACKED_TOOLS names it, which nothing does by default

1 participant