Summary
expand_glob_pattern (src/graph/builder.cpp:661) merges two sources of matches into one list: a filesystem scan, whose paths are source-relative, and a scan of Generated graph nodes, whose paths carry the build-root prefix (:697). Out-of-tree those are different path spaces, and everything downstream compares the merged list as plain strings:
- the sort (
:702)
- the dedup (
:703)
apply_exclusions (:711)
In-tree the two spaces coincide, which is why the entire E2E suite misses this: every fixture builds in-tree (test/e2e/e2e_fixture.cpp:158 runs a bare configure).
PR #180 introduced the merge and a follow-up commit normalized the ../ component lexically. That handled one axis; the build-root prefix is the other, and it was left.
Three consequences, all reproduced on a47fe41f5
1. %f order depends on the name of the build directory.
a/Tupfile: : src.in |> cp %f %o |> gen.dat
: *.dat |> cat %f > %o |> all.txt # a/keep.dat is checked in
putup -B AAA -> cat ../AAA/a/gen.dat keep.dat > ../AAA/a/all.txt all.txt: GEN\nKEEP
putup -B zz -> cat keep.dat ../zz/a/gen.dat > ../zz/a/all.txt all.txt: KEEP\nGEN
"../AAA/a/gen.dat" sorts before "keep.dat"; "../zz/a/gen.dat" sorts after. Same project, different artifact bytes, decided by a directory name. Link order, cat concatenation and archive member order all shift between an in-tree dev build and an out-of-tree CI build.
2. A generated file that shadows a same-named source appears twice.
a/Tupfile: : gen.src |> cp %f %o |> x.dat
: *.dat |> cat %f > %o |> all.txt # a/x.dat is also checked in
putup -B build -> cat ../build/a/x.dat ../build/a/x.dat > ../build/a/all.txt
all.txt: FROMRULE\nFROMRULE
The filesystem half yields a/x.dat, the graph half build/a/x.dat; the string dedup keeps both, and resolve_input_node maps both to the same Generated node. The checked-in source is silently never consumed. Second build is a no-op, so it stays wrong.
3. Input exclusions silently stop applying.
: keep.in |> cp %f %o |> keep.gen
: skip.in |> cp %f %o |> skip.gen
: *.gen !skip.gen |> echo %f > %o |> out.txt
in-tree: out.txt = keep.gen exclusion honoured
out-of-tree: out.txt = build/keep.gen build/skip.gen exclusion ignored
Exclusion strings are normalized source-relative and compared for equality, so they never match a prefixed generated path. This one predates #180 — the old fallback branch pushed the same prefixed paths — but #180 made the generated half contribute to every pattern rather than only to patterns the filesystem missed, so the exposure went from narrow to universal.
One fix
Emit the graph half in the stripped logical (source-relative) space at the merge point — match_path_sv is already computed at :695 for the match itself — and let resolve_input_node's BuildRoot-first lookup (:376) carry it back to the Generated node. One path space downstream makes the existing sort, dedup and exclusion comparison correct, and restores the ordering that PR #180 measured against tup, which serves both node kinds from one table in one space.
Test-suite gap
Worth fixing alongside: no E2E fixture builds out-of-tree, so no test in the suite can observe any of this. The three cases above are cheap to add as out-of-tree scenarios.
Provenance
Found by an adversarial review of the merged PRs in the incremental-correctness campaign; all three reproduced independently before filing.
🤖 Generated with Claude Code
https://claude.ai/code/session_01TLob4Ef3qyMd3Dnp9q3XmA
Summary
expand_glob_pattern(src/graph/builder.cpp:661) merges two sources of matches into one list: a filesystem scan, whose paths are source-relative, and a scan ofGeneratedgraph nodes, whose paths carry the build-root prefix (:697). Out-of-tree those are different path spaces, and everything downstream compares the merged list as plain strings::702):703)apply_exclusions(:711)In-tree the two spaces coincide, which is why the entire E2E suite misses this: every fixture builds in-tree (
test/e2e/e2e_fixture.cpp:158runs a bareconfigure).PR #180 introduced the merge and a follow-up commit normalized the
../component lexically. That handled one axis; the build-root prefix is the other, and it was left.Three consequences, all reproduced on
a47fe41f51.
%forder depends on the name of the build directory."../AAA/a/gen.dat"sorts before"keep.dat";"../zz/a/gen.dat"sorts after. Same project, different artifact bytes, decided by a directory name. Link order,catconcatenation and archive member order all shift between an in-tree dev build and an out-of-tree CI build.2. A generated file that shadows a same-named source appears twice.
The filesystem half yields
a/x.dat, the graph halfbuild/a/x.dat; the string dedup keeps both, andresolve_input_nodemaps both to the sameGeneratednode. The checked-in source is silently never consumed. Second build is a no-op, so it stays wrong.3. Input exclusions silently stop applying.
Exclusion strings are normalized source-relative and compared for equality, so they never match a prefixed generated path. This one predates #180 — the old fallback branch pushed the same prefixed paths — but #180 made the generated half contribute to every pattern rather than only to patterns the filesystem missed, so the exposure went from narrow to universal.
One fix
Emit the graph half in the stripped logical (source-relative) space at the merge point —
match_path_svis already computed at:695for the match itself — and letresolve_input_node's BuildRoot-first lookup (:376) carry it back to theGeneratednode. One path space downstream makes the existing sort, dedup and exclusion comparison correct, and restores the ordering that PR #180 measured against tup, which serves both node kinds from one table in one space.Test-suite gap
Worth fixing alongside: no E2E fixture builds out-of-tree, so no test in the suite can observe any of this. The three cases above are cheap to add as out-of-tree scenarios.
Provenance
Found by an adversarial review of the merged PRs in the incremental-correctness campaign; all three reproduced independently before filing.
🤖 Generated with Claude Code
https://claude.ai/code/session_01TLob4Ef3qyMd3Dnp9q3XmA