add_file_node discarded the bool from both of its registrations. Neither
is an insert-or-fail: SortedPairVec::insert overwrites the value on an
existing key and returns false. A second node at an occupied key therefore
won silently, and the node that was there became unreachable by path while
still sitting in the graph - the shape behind #484 (putup's own tool
fingerprints in the Tupfile export/import namespace) and #486 (a config
variable hiding a same-named source file), both of which were found by
accident days after the stale build they caused.
The check consults both maps before minting the id, because the two key on
different functions: path_to_node on intern(parent node's path_id, name),
dir_children on (parent node index, name). Guarding only path_to_node would
leave dir_children as a second, independently keyed resolution leg in
ensure_file_node, and the losing node would still bind under a different
PathId - one file resolving to two nodes depending on spelling, which is
worse than today's consistent-but-wrong. set_build_root_name is the state
where the two genuinely disagree, and the new test reaches the second leg
through it; deleting the leg fails that section.
Checking before graph.next_file_id++ leaves the graph untouched on the
error path. SortedPairVec::insert keeps its upsert contract: VarDb::set and
VarDb::append depend on last-wins, which is what Tupfile VAR = and VAR +=
mean, and test_sorted_id_vec pins it. Upstream tup makes the same
uniqueness structural rather than conventional - unique(dir, name) on the
node table, every insert through tup_db_node_insert_tent_display with a
checked step, and tupid_tree_insert / string_tree_insert failing on a
duplicate while a separate tupid_tree_add_dup opts into tolerance.
The error is expected to be dead code on every project that builds today.
Nine adversarial fixtures - a file literally named <g> against a group of
that name, a source directory named $ against the env pseudo-directory, an
in-source -B . build, a source subdirectory named like the build root, and
a config variable against a generated output - failed to reach the branch
from the CLI, and the four blast-radius shapes behave exactly as they did
before the change. That is a failure to reach, not a proof of
unreachability, which is the argument for the error over a diagnostic
nobody would ever see.
Verified red-then-green: the new test case fails on the pre-change binary
(add_file_node returns the second id) and passes after. make check rc=0,
182526 assertions across 956 test cases.
Not taken here: folding path_to_node and dir_children into one binding
table. It removes the disagreement structurally rather than checking for
it, but find_by_dir_name would then read a table that deliberately holds
alias entries, and that equivalence is undischarged in exactly the variant
identity paths that account for most of the incremental-correctness bugs.
Left as its own issue with the surface-compare ledger attached.
Ref: #487
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
add_file_nodethrew away the bool from both of its registrations. Neither is an insert-or-fail —SortedPairVec::insertoverwrites the value on an existing key and returnsfalse— so a second node at an occupied key won silently and the node that was there became unreachable by path while still sitting in the graph.That is the shape behind both shared-namespace bugs found this week: #484 (putup's own tool fingerprints sharing the Tupfile
export/importkey space) and #486 (a config variable hiding a same-named source file). Both surfaced days later as a stale build, and both were found by accident.Closes #487.
The fix
The check runs before
graph.next_file_id++, so the error path leaves the graph untouched:Why both maps
They key on different functions of the same node:
path_to_nodeintern(parent node's path_id, name)dir_children(parent node index, name)Guarding only
path_to_nodewould leavedir_childrenas a second, independently keyed resolution leg inensure_file_node, and the losing node would still bind under a differentPathId— one file resolving to two nodes depending on how it is spelled, which is worse than today's consistent-but-wrong.set_build_root_nameis the one state where the two genuinely disagree: it writesdir_children[0][<build root name>] -> BUILD_ROOT_IDwith nopath_to_nodecounterpart. The new test reaches the second leg through it — delete the leg and that section fails:What is deliberately not changed
SortedPairVec::insertkeeps its upsert contract.VarDb::setandVarDb::appenddepend on last-wins — that is what TupfileVAR =andVAR +=mean — andtest_sorted_id_vecpins it. Making the container insert-or-fail, or markinginsert[[nodiscard]], would push a cast onto every legitimate upsert site to fix one call site that can check for itself.Upstream
tup makes the same uniqueness structural rather than conventional:
unique(dir, name)on the node table, every insert throughtup_db_node_insert_tent_displaywith a checked step, andtupid_tree_insert/string_tree_insertfailing on a duplicate while a separatetupid_tree_add_dupopts into tolerance. A duplicate aborts the parse.Reachability, stated honestly
The error is expected to be dead code on every project that builds today. Nine adversarial fixtures failed to reach the branch from the CLI:
<g>against a group of that name$against the env pseudo-directory$holding a file namedFOO=bar, withexport FOOVariablewrites neither map-B .$caseThat is a failure to reach, not a proof of unreachability — which is the argument for an error over a diagnostic nobody would ever see. The four blast-radius shapes (
<g>,$,-B .,-B buildwith a sourcebuild/) behave exactly as they did before the change; none produces a newDuplicateNode.Verification
REQUIRE_FALSE(second.has_value())→!true) and passes after.make checkrc=0. 182526 assertions across 956 test cases, up from 182495 / 955 atdddfb5620.///doc headers conforming to the conventionlink_role/names_node_type/is_path_addressablealready follow intypes.hpp. 0 deleted.dir_childrenleg and a message that named the same path twice — are fixed in this branch.Not taken, and residuals
Folding the two maps into one binding table. A surface-compare verdict preferred it: it removes the disagreement structurally instead of checking for it, and
dir_childrenhas exactly one reader. It is not taken here becausefind_by_dir_namewould then read a table that deliberately holds alias entries, and that equivalence is undischarged in precisely the variant-identity paths behind most of the incremental-correctness campaign's bugs. Filed as #493 with the ledger.No spec requirement.
spec/requirements/has no sentence describingadd_file_node's behaviour on a second node at one(dir, name), so nothing is altered andmake spec-checkis not triggered.Found while investigating, filed separately, and not closed by this PR:
Group-stays-path-addressable residual from A config variable aliases a same-named source file in the config directory, and edits to that file become invisible #486.add_file_nodere-roots a node whose parent it cannot resolve instead of refusing it. It's latent, with no production trigger found. This PR turns one consequence into aDuplicateNodethat names the wrong path.path_to_nodeanddir_childrenmust agree, and no code enforces that. This is the fold-into-one-table follow-up, with the surface-compare ledger.Correction: an earlier version of this description listed
set_build_root_name's one-sided write as a separate defect. It isn't one. Both callers run it on a fresh graph before parsing, and it's the deliberate aliasing that makes a source-relativebuild/...spelling reach the build root. It's the main example in #493, and it's still the state this PR's second lookup leg covers.🤖 Generated with Claude Code
https://claude.ai/code/session_01StgwMENEyfBnEoe4pvAdtQ