Skip to content

One dep scan per compile invocation, each with its own flags and sources #401

Description

@typeless

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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions