Skip to content

Route ctx.index producers through one add-and-register funnel #395

Description

@typeless

ctx.index entries and their path_to_id registration are produced at nine
sites in src/cli/cmd_build.cpp, and seven of them must each remember to
register. There is no enforcement point: Index::add_file does not take the
map, and nothing stops a tenth producer appearing without a registration. The
invariant is conventional, not structural.

This is not hypothetical. #386 was exactly one arm having forgotten — the ghost
serialization arm wrote a non-empty path and never registered it — and it stayed
forgotten until a defect in a different subsystem exposed it.

The inventory (line refs as of the #391 merge)

# Producer .path written Registers
1 get_or_create_dir arm A :479-480 non-empty yes :480
2 get_or_create_dir arm B :500-501 non-empty yes :501
3 create_implicit_file :553-554 rel_path yes :554
4 serialize_graph_nodes File/Generated, empty-path arm :599 StringId::Empty, deliberately no
5 serialize_graph_nodes File/Generated :673-674 intern(node_path) yes :674
6 serialize_graph_nodes Directory/GeneratedDir :694-696 intern(node_path) yes :696, guarded on !node_path.empty()
7 serialize_graph_nodes Ghost :732-747 intern(node_path) yes :747, guarded on !node_path.empty() (#386)
8 serialize_graph_nodes Variable/Group/Root :766 StringId::Empty, deliberately no
9 merge_out_of_scope_commands' resolve_file :1325-1337 old_file->path yes :1337

Producers 4 and 8 keep a slot so ids stay dense; they are safe precisely because
they write an empty path.

The hinge

Producers 6 and 7 guard registration on node_path.empty() while writing the
field unconditionally as intern(node_path). Those two predicates agree only
because interning the empty string yields StringId::Empty — documented beside
is_empty and pinned by a unit test as of #391. Had intern("") minted a fresh
handle, the root directory entry would have a non-empty path handle and no
registration on every build.

A funnel folds that guard into one site and retires the hinge's load-bearing
role for registration entirely.

Why Index::add_file is the wrong enforcement point

The read side legitimately builds Index instances with no path_to_id and
must never register: read_index and read_prior_paths reconstruct the old
record, which is not the new build's namespace. A signature demanding the map
would make every read-side construction carry a lie.

The right shape is a writer-side funnel in the serialization TU: one
function through which every producer of a ctx.index entry routes — add the
entry, register iff the path is non-empty — so a producer cannot add without the
registration decision being made, at count=1.

Priority: low, and honestly so

Since #382 a record naming one path twice is refused on read, and since #391 the
fallback that used to absorb a forgotten registration in silence is gone. So a
violation today is already machine-caught: the first lookup that misses mints a
duplicate, and the next read reports the record unreadable. This fence buys
impossible over loud-one-build-later, which is a real improvement — the
current signal names unreadability rather than the producer that forgot — but it
is not closing an open hole.

Acceptance

  • Every ctx.index producer routes through one add-and-register funnel; the
    direct add_file calls in the writer TU are gone.
  • The is_empty guard exists at one site rather than being repeated per arm.
  • The funnel closes by construction the case a duplicate could otherwise evade
    the reader: a minted entry whose type falls outside the three lists
    prior_paths scans. That case is unreachable through today's merge, whose
    resolve_file only handles File/Generated/Ghost entries, so it is a
    fence-design requirement rather than a live defect.
  • The read side is untouched and still constructs Index without a map.

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