Skip to content

A record field label is not a reference: make the declaration index's reference channel selective by node kind - #9430

Merged
briansrls merged 1 commit into
mainfrom
session/loyal-wren-526
Aug 27, 2026
Merged

briansrls merged 1 commit into
mainfrom
session/loyal-wren-526

Conversation

@gunbai-bot

@gunbai-bot gunbai-bot Bot commented Aug 27, 2026 •

Copy link
Copy Markdown
Contributor

The defect

ModuleDeclarationRecord::referenced — the channel the namespace wave-admission wall reads — collected every authored name in a module's parse tree with no filter on node kind. A record literal's field label has an authored name, so Row { widget: "x" } contributed a reference to widget.

The field's own doc declared this sound: the over-collection is symmetric across the two trees the wall compares, so a spelling that denotes nothing on both sides contributes no delta.

That argument is refuted. A symmetric collector does not give a symmetric verdict, because the supplier set the wall computes for a row is a function of the corpus, not of the site. Deleting an unrelated declaration moves it under every site that merely spells the same word — and a field label spells words.

The specimen (gunbc#9106, not touched by this PR): a witness module deleted a helper fn live_tree_declined_entries and kept twelve record field labels of that spelling. Twelve labels, twelve enclosing declarations, twelve NewUnresolvedness rows, one-to-one, against a correct cut. The delta was true about the declaration and false about every site it named — a label binds to nothing and needs no supplier at all.

The repair

The shape the cited collector on the same walk already used: decide by node kind, never by name. A name-based suppression list would be the same defect one layer up. Two kinds stop being references:

  • A record literal's field labels. ExprRecordLit's children are its field initializers and nothing else, so the label is decidable from the parent's kind with no guessing. The initializer's value is still walked, because that is where a reference lives.
  • A field projection's member name. f.widget names a field of a value, not a declaration. The whole dotted spelling is still recorded, which is what module_prefix_of needs to keep probe.home.widget resolving, and the wall keys on the last segment either way.

Evidence, both directions, at the fixture boundary

The RED direction is measured, not assumed: the new field-label test FAILS against the pre-fix collector. One remote dispatch ran the suite with the fix, reverted the fix in place with git apply -R, and ran the identical suite again:

arm with fix fix reverted
deleting_a_declaration_a_record_field_label_merely_spells_carries_no_delta (RED) ok FAILED
deleting_a_declaration_a_body_still_references_is_still_unresolvedness (GREEN) ok ok
deleting_a_declaration_a_qualified_spelling_reaches_is_still_unresolvedness (GREEN) ok ok
the 16 pre-existing arms ok ok

The reverted arm's failure text is the specimen's own disposition, not a generic mismatch:

`widget` here is a FIELD LABEL of `Row`, not a reference to the deleted `data widget`. ...
got: [("NewUnresolvedness", "base {probe.consumer} -> head {}")]

A test that passes after the fix and would also have passed before it is a decoration; this one goes red on exactly the state it forbids.

The two GREEN arms are the half that matters, because collecting less is how a wall becomes a decoration: both require the wall to keep refusing a deletion a real reference reaches — one bare, one dotted. The dotted one is the control on the projection half specifically: a repair that had dropped the spelling instead of the member name would green the red and silence that arm with it. No existing witness was deleted or weakened.

declaration_index_integrity (25 arms) also passes on the head commit.

Corpus-wide effect: none, measured

The brief flagged that referenced might feed the cited-symbol census and move citations= / debt=. It does not — referenced has exactly three readers, all in namespace_wave_admission.rs. A/B over the full sweep (4135 files), same dispatch, fix applied then reverted:

WITH FIX  modules=4135 declared=79775 import_members=83506 citations=1765 debt=42 in_fixtures=175 outside_index=201 kernel_named=2072 lens_modules=70
REVERTED  modules=4135 declared=79775 import_members=83506 citations=1765 debt=42 in_fixtures=175 outside_index=201 kernel_named=2072 lens_modules=70

Byte-identical, zero findings either way. The debt roster does not shrink. The head commit reads declared=79776 citations=1766 outside_index=202 — that is this PR's own carrier note and its one authored DeclarationRef naming v1_compiler.declaration_index, not a movement in the measured population, and it was predicted before it was measured.

What is NOT repaired

Named rather than left to be discovered, because a partial repair reported as a total one is worse than none. Record type declaration field labels, named call argument labels, parameter binders and coproduct variant names are still collected as references, and each can fabricate the same refusal from a different position. Measured on a fixture, not predicted: type Row { tag: String } contributes tag, and call_it(tag: "z") contributes tag.

They are not swept in here because the parent kinds that carry them also carry children that are real references — a refinement's base type expression is a Connective::Conj child with a real type name — so a parent-kind rule for them cannot be lifted from this one and needs its own fixture. Excluding them by guessing would risk the opposite defect, which is strictly worse.

Scope

v1 seed, admitted by PURPOSE under gunbc.v1_maintenance_standing: a defect repair in a required-CI wall that is currently blocking a correct cut. Classified against all five refused classes — no new language behavior (the compile pipeline is byte-untouched), no compatibility obligation, no escape hatch (the wall keeps refusing, it just stops fabricating), no seed feature completion, no public surface growth (declaration_index.rs is a #[path] mod inside cli_run.rs). Hand-item delta +1 (collect_reference_occurrences), enumerated in gunbc.declaration_index_seed_growth's roster with the trigger count updated 61 → 62; record_from_module's inline walk is replaced by a call to it rather than duplicated. Rung unchanged at mechanically preventable.

This PR does not touch gunbc#9106.

🤖 Generated with Claude Code

… reference channel selective by node kind

The namespace wave-admission wall reads `ModuleDeclarationRecord::referenced`,
which collected EVERY authored name in a module's parse tree with no filter on
node kind. A record literal's field label has an authored name, so
`Row { widget: "x" }` contributed a reference to `widget`.

The field's own doc argued this was harmless because the over-collection is
SYMMETRIC across the two trees the wall compares. That argument is refuted by a
measured specimen: a symmetric COLLECTOR does not give a symmetric VERDICT,
because the supplier set the wall computes is a function of the CORPUS, not of
the site. On gunbc#9106 a witness module deleted a helper
`fn live_tree_declined_entries` and kept twelve record field labels spelling the
same word; the wall raised twelve NewUnresolvedness rows against a correct cut —
true about the declaration, false about every site it named.

The repair is the shape the `cited` collector on the same walk already used:
decide by node kind, never by name. Two kinds stop being references — a record
literal's field labels (ExprRecordLit's children are its field initializers and
nothing else; the initializer's value is still walked) and a field projection's
member name (the whole dotted spelling is still recorded, so a module-qualified
reference keeps resolving and the wall keeps its leaf).

Both directions are enrolled at the fixture boundary. RED:
deleting_a_declaration_a_record_field_label_merely_spells_carries_no_delta —
reports exactly the specimen's NewUnresolvedness under the previous collector,
nothing under this one. GREEN, the half that matters:
deleting_a_declaration_a_body_still_references_is_still_unresolvedness and
deleting_a_declaration_a_qualified_spelling_reaches_is_still_unresolvedness both
require the wall to KEEP refusing a deletion a real reference reaches, bare and
dotted.

Not repaired, and named rather than left to be discovered: type-declaration
field labels, named argument labels, parameter binders and variant names are
still collected. Each needs its own structural discriminator against its own
fixture, because the parent kinds carrying them also carry real references.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant