The full fix for #355, split out of it now that PR #400 has landed the unit it needs. #355 stays open as the defect report; this is the design.
What
One dep-scan node per compile invocation, each carrying its own flags and its own sources, replacing today's single scan per command that folds every invocation's sources under the first invocation's flags.
That fold is #355: gcc -Ione -c a.c -o a.o && gcc -Itwo -c b.c -o b.o emits one scan gcc -M -Ione a.c b.c, so b.c is preprocessed under the other invocation's include path. Divergent -I does not narrow the header search, it redirects it — the recorded dep is one/x.h, the real one two/x.h is missed, and the object silently stales. Per-invocation scans remove the fold, and with it the defect.
The permanent precondition, which does not go away
A scan reproduces one invocation from the rule's directory with the rest of the command stripped. So invocation i is scannable only if putup knows the state it runs in — which it knows only if every invocation before it is a recognized compile. A cd sub, an env assignment, a shell builtin: anything unrecognized poisons everything downstream of it, under any design.
The most that is ever scannable is therefore the longest all-compile prefix, not "every compile in the command". PR #400's criterion (every_invocation_is_a_compile, src/graph/scanners/gcc.cpp:126) is that same precondition applied at whole-command granularity — the coarsest the current reporter permits. This issue generalizes it from whole command to prefix; it does not relax it.
What gates this: the reporter, not migration
Per rule, reporting is binary today — scanned, or reported unscanned — and the unscanned report keys on whether a scan node exists for the command (check_unscanned_compiles, src/cli/strict_checks.cpp:135). A scan node is treated as proof its parent is covered.
Scan a prefix and drop the tail and you produce a third state — partially scanned — that the report has no vocabulary for. The rule would look covered while the tail's headers went unrecorded: a silent incomplete answer replacing today's loud wrong one, which is exactly the trade the dep-scan area exists to refuse. The spec already forbids it in the reporting group preamble (spec/requirements/dep-scan.ears.md:66-69):
Per rule the reporting is binary — scanned, or reported unscanned — so no scan decision may create a state this group cannot express, such as a rule scanned in part; widening what a scan may cover means widening this group first.
So the reporter widening is the first task, not a follow-up. The open design questions are what putup parse names per invocation, and what "covered" means for a command carrying several scans. That is a design-gate question (new public reporting surface), not a patch.
The migration cost is zero — this is the re-price
An earlier consult held that per-invocation scan nodes re-key every scan node in every project (a fleet-wide one-shot churn), and that cost is why the full fix was deferred. That is refuted. No re-key happens, and no keying change is needed at all.
Correction, 2026-08-12. An earlier revision of this section priced the re-key at zero by proposing an invocation ordinal folded into the key when non-zero, and claimed reject_ambiguous_keys depends on that ordinal. Both claims are wrong, and the implementation carries neither. The ordinal was never necessary.
Two invocations in one command can render byte-identical scan text under the same parent, which compute_command_key maps to one key. That is not a collision to disambiguate — it is correct sharing: byte-identical scan text requires identical sources and identical flags, so the two scans have identical dependency sets, and one node serves both. An ordinal would have distinguished nodes that are semantically interchangeable.
Verified empirically this session, not by reading: gcc -c a.c -o a.o && gcc -c a.c -o b.o — two invocations rendering identical scan text under one parent — builds clean, converges to a null build on run 2, produces both objects, and reports neither unscanned. reject_ambiguous_keys (src/graph/builder.cpp:2610) does not fire.
Consequences, unchanged from the original conclusion but now resting on a sound reason:
- No index migration. No key changes, so no rebuild is forced on any project.
- No
INDEX_VERSION bump.
- No new keying mechanism — the ordinal, and the
reject_ambiguous_keys argument built on it, are withdrawn.
What this reuses from PR #400, and what it discards
Reused — this is why #400 landed first:
split_invocations and both separator notions (src/graph/scanners/dep_words.cpp:130-151) — invocation boundaries as a named unit
- per-invocation classification:
compiler_index / driver_index, one enforcement point per scanner
- the matcher/builder lockstep law and its tests
- nearly all eight tests from that cycle
Discarded:
Boundaries
Not the same defect as recognition narrowness — that a harmless prefix (echo … &&, FOO=1, an empty leading invocation) forfeits the scan for the whole command — which is #352 and is fixed by widening compiler_index, independently of this.
Related: #355 (the defect this closes), #352 (recognition narrowness), #357 (self-depfiling commands the reporter skips).
The full fix for #355, split out of it now that PR #400 has landed the unit it needs. #355 stays open as the defect report; this is the design.
What
One dep-scan node per compile invocation, each carrying its own flags and its own sources, replacing today's single scan per command that folds every invocation's sources under the first invocation's flags.
That fold is #355:
gcc -Ione -c a.c -o a.o && gcc -Itwo -c b.c -o b.oemits one scangcc -M -Ione a.c b.c, sob.cis preprocessed under the other invocation's include path. Divergent-Idoes not narrow the header search, it redirects it — the recorded dep isone/x.h, the real onetwo/x.his missed, and the object silently stales. Per-invocation scans remove the fold, and with it the defect.The permanent precondition, which does not go away
A scan reproduces one invocation from the rule's directory with the rest of the command stripped. So invocation i is scannable only if putup knows the state it runs in — which it knows only if every invocation before it is a recognized compile. A
cd sub, an env assignment, a shell builtin: anything unrecognized poisons everything downstream of it, under any design.The most that is ever scannable is therefore the longest all-compile prefix, not "every compile in the command". PR #400's criterion (
every_invocation_is_a_compile,src/graph/scanners/gcc.cpp:126) is that same precondition applied at whole-command granularity — the coarsest the current reporter permits. This issue generalizes it from whole command to prefix; it does not relax it.What gates this: the reporter, not migration
Per rule, reporting is binary today — scanned, or reported unscanned — and the unscanned report keys on whether a scan node exists for the command (
check_unscanned_compiles,src/cli/strict_checks.cpp:135). A scan node is treated as proof its parent is covered.Scan a prefix and drop the tail and you produce a third state — partially scanned — that the report has no vocabulary for. The rule would look covered while the tail's headers went unrecorded: a silent incomplete answer replacing today's loud wrong one, which is exactly the trade the dep-scan area exists to refuse. The spec already forbids it in the reporting group preamble (
spec/requirements/dep-scan.ears.md:66-69):So the reporter widening is the first task, not a follow-up. The open design questions are what
putup parsenames per invocation, and what "covered" means for a command carrying several scans. That is a design-gate question (new public reporting surface), not a patch.The migration cost is zero — this is the re-price
An earlier consult held that per-invocation scan nodes re-key every scan node in every project (a fleet-wide one-shot churn), and that cost is why the full fix was deferred. That is refuted. No re-key happens, and no keying change is needed at all.
Correction, 2026-08-12. An earlier revision of this section priced the re-key at zero by proposing an invocation ordinal folded into the key when non-zero, and claimed
reject_ambiguous_keysdepends on that ordinal. Both claims are wrong, and the implementation carries neither. The ordinal was never necessary.Two invocations in one command can render byte-identical scan text under the same parent, which
compute_command_keymaps to one key. That is not a collision to disambiguate — it is correct sharing: byte-identical scan text requires identical sources and identical flags, so the two scans have identical dependency sets, and one node serves both. An ordinal would have distinguished nodes that are semantically interchangeable.Verified empirically this session, not by reading:
gcc -c a.c -o a.o && gcc -c a.c -o b.o— two invocations rendering identical scan text under one parent — builds clean, converges to a null build on run 2, produces both objects, and reports neither unscanned.reject_ambiguous_keys(src/graph/builder.cpp:2610) does not fire.Consequences, unchanged from the original conclusion but now resting on a sound reason:
INDEX_VERSIONbump.reject_ambiguous_keysargument built on it, are withdrawn.What this reuses from PR #400, and what it discards
Reused — this is why #400 landed first:
split_invocationsand both separator notions (src/graph/scanners/dep_words.cpp:130-151) — invocation boundaries as a named unitcompiler_index/driver_index, one enforcement point per scannerDiscarded:
REQ-SCAN-FLAG-SOURCE(spec/requirements/dep-scan.ears.md:47-53), whose flags-from-the-first-invocation clause exists only to record the Dep scan folds a later invocation's sources under the first invocation's flags, silently staling the object #355 limitation and is deleted when the limitation isBoundaries
Not the same defect as recognition narrowness — that a harmless prefix (
echo … &&,FOO=1, an empty leading invocation) forfeits the scan for the whole command — which is #352 and is fixed by wideningcompiler_index, independently of this.Related: #355 (the defect this closes), #352 (recognition narrowness), #357 (self-depfiling commands the reporter skips).