Found during the #345 investigation. Rewritten 2026-08-12: the original title and body said the refusal was silent, and described the fix as skipping leading separators in a matcher that read word_ids[0]. Both are stale — see "What changed" below. The defect that remains is the narrowness of recognition, unchanged in substance since filing.
What remains
putup forfeits the dependency scan for an entire command when it cannot recognize every one of that command's invocations as a compile it can reproduce. A harmless prefix — one that provably cannot change what the compile reads — costs the rule all of its header dependencies.
The live shapes, all reproducible today:
: hidden.c |> echo building %f && gcc -c %f -o %o |> %B.o # test-pinned, see below
: foo.c |> FOO=1 gcc -c %f -o %o |> %B.o # env assignment before the compiler
: foo.c |> ; gcc -c %f -o %o |> %B.o # empty leading invocation
None of these can affect what the preprocessor reads, and all three forfeit the scan.
Current mechanism
every_invocation_is_a_compile (src/graph/scanners/gcc.cpp:126-134) requires compiler_index to succeed on every invocation. compiler_index (gcc.cpp:109-121) accepts the compiler at position 0, or behind exactly one recognized wrapper — nothing else. echo, FOO=1, and the empty span produced by a leading separator all fail it.
split_invocations (src/graph/scanners/dep_words.cpp:135-151) emits an empty invocation for an operator with nothing before it, and its comment asserts that this "is a command no scan can reproduce". That assertion is the narrow half of this issue stated as a code comment: an invocation that runs nothing cannot change the directory, the environment, or what a compile reads.
matches_clang_cl_compile carries the identical shape in driver_index (src/graph/scanners/clang_cl.cpp:121-134) and must move in lockstep.
The fix, as 276baf2 scoped it
The matcher fix — recognizing a compile behind a harmless prefix — is tracked in #352 and lands next, as an allowlist of prefix words that provably cannot change what the compile reads.
Still the right shape, with one update: the allowlist now belongs inside compiler_index, applied per invocation, rather than at the head of the whole command. The empty-invocation case is separable and simpler — an invocation with no words runs nothing, so it can be skipped rather than allowlisted.
The allowlist is convention-lagging by construction: a prefix nobody has added is a rule that still forfeits its scan. That is acceptable only because the lag is now loud (below), and the diagnostic is what measures it.
What changed since filing
The refusal is declared, not silent. 276baf2ee shipped the reporter. putup parse names each rule producing an object no scan covers —
no dependency scan for '<obj>': putup recognizes no compile it can reproduce in this command, so any header this rule reads goes unrecorded. Harmless if it preprocesses none — an assembler, a copy, a partial link.
— src/cli/strict_checks.cpp:195-198
— and a build prints a one-line count (src/cli/cmd_build.cpp:2206-2212). Pinned by Scenario: A compile-shaped rule with no dependency scan is reported (test/unit/test_e2e.cpp:11162), whose fixture uses the echo … && gcc -c shape above. So the 103 objects are declared, not hidden; the harm is the missing dependency edges, not the concealment.
The criterion moved and got stricter, not wider. PR #400 (2bf0cce0c, 0e7907602, closing #356) made invocations a nameable unit and asks the criterion once per invocation through one enforcement point. It deliberately narrowed what is scanned — a command is scanned only if all of its invocations are recognized compiles — because a scan reproduces one invocation from the rule's directory with the rest stripped, so anything it cannot account for could have changed the state the compile ran in. This issue's fix widens recognition within that criterion; it does not relax it.
The 103 figure is stale. It was measured before #400 tightened the criterion, so the current count is at least that and probably higher. Re-measure with putup parse before using it as a baseline.
Boundaries
Recognition narrowness (this issue) is not the same defect as scan granularity. #355 is that a recognized multi-invocation command folds every invocation's sources under the first invocation's flags, which redirects rather than narrows the header search. Its full fix — one scan node per compile invocation — is being filed separately as its own issue and is gated on widening the unscanned reporter, not on this.
Related: #355 (granularity), #356 (closed by #400, the criterion), #357 (self-depfiling commands the reporter skips).
Found during the #345 investigation. Rewritten 2026-08-12: the original title and body said the refusal was silent, and described the fix as skipping leading separators in a matcher that read
word_ids[0]. Both are stale — see "What changed" below. The defect that remains is the narrowness of recognition, unchanged in substance since filing.What remains
putup forfeits the dependency scan for an entire command when it cannot recognize every one of that command's invocations as a compile it can reproduce. A harmless prefix — one that provably cannot change what the compile reads — costs the rule all of its header dependencies.
The live shapes, all reproducible today:
None of these can affect what the preprocessor reads, and all three forfeit the scan.
Current mechanism
every_invocation_is_a_compile(src/graph/scanners/gcc.cpp:126-134) requirescompiler_indexto succeed on every invocation.compiler_index(gcc.cpp:109-121) accepts the compiler at position 0, or behind exactly one recognized wrapper — nothing else.echo,FOO=1, and the empty span produced by a leading separator all fail it.split_invocations(src/graph/scanners/dep_words.cpp:135-151) emits an empty invocation for an operator with nothing before it, and its comment asserts that this "is a command no scan can reproduce". That assertion is the narrow half of this issue stated as a code comment: an invocation that runs nothing cannot change the directory, the environment, or what a compile reads.matches_clang_cl_compilecarries the identical shape indriver_index(src/graph/scanners/clang_cl.cpp:121-134) and must move in lockstep.The fix, as 276baf2 scoped it
Still the right shape, with one update: the allowlist now belongs inside
compiler_index, applied per invocation, rather than at the head of the whole command. The empty-invocation case is separable and simpler — an invocation with no words runs nothing, so it can be skipped rather than allowlisted.The allowlist is convention-lagging by construction: a prefix nobody has added is a rule that still forfeits its scan. That is acceptable only because the lag is now loud (below), and the diagnostic is what measures it.
What changed since filing
The refusal is declared, not silent.
276baf2eeshipped the reporter.putup parsenames each rule producing an object no scan covers —— and a build prints a one-line count (
src/cli/cmd_build.cpp:2206-2212). Pinned byScenario: A compile-shaped rule with no dependency scan is reported(test/unit/test_e2e.cpp:11162), whose fixture uses theecho … && gcc -cshape above. So the 103 objects are declared, not hidden; the harm is the missing dependency edges, not the concealment.The criterion moved and got stricter, not wider. PR #400 (
2bf0cce0c,0e7907602, closing #356) made invocations a nameable unit and asks the criterion once per invocation through one enforcement point. It deliberately narrowed what is scanned — a command is scanned only if all of its invocations are recognized compiles — because a scan reproduces one invocation from the rule's directory with the rest stripped, so anything it cannot account for could have changed the state the compile ran in. This issue's fix widens recognition within that criterion; it does not relax it.The 103 figure is stale. It was measured before #400 tightened the criterion, so the current count is at least that and probably higher. Re-measure with
putup parsebefore using it as a baseline.Boundaries
Recognition narrowness (this issue) is not the same defect as scan granularity. #355 is that a recognized multi-invocation command folds every invocation's sources under the first invocation's flags, which redirects rather than narrows the header search. Its full fix — one scan node per compile invocation — is being filed separately as its own issue and is gated on widening the unscanned reporter, not on this.
Related: #355 (granularity), #356 (closed by #400, the criterion), #357 (self-depfiling commands the reporter skips).