From e58acdb0172f62d05a13b2208d43a84976eaeec3 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Thu, 27 Aug 2026 18:50:40 +0000 Subject: [PATCH 1/2] Recover two authored references the reference channel could not reach, and the false green they produced (#9494) `collect_reference_occurrences` walks a Node through seven child slots. Two authored references are reachable through NEITHER, and the consequence is a GREEN rather than a refusal, which is why it stood. A VARIANT PATTERN HEAD is a raw `String` on `MatchPattern`, not a `Node`, so a module whose only use of an imported coproduct is naming its variants in match arms contributed nothing to `referenced`. A DECLARED FIELD TYPE is parked by `v1.02_parse` `field_to_child_node` in the `inferred` slot, and every authored- name reader correctly declines to read a slot whose contract says DERIVED. With either invisible, `membership_bound_through` answered NO about an import whose name is still referenced in the very tree it examined, and the wave wall reported a live import as `UnusedSubjectMembershipRemoved` -- a verdict whose natural next action is to delete the import the wall just failed to see. WHAT IS DELIBERATELY NOT COLLECTED, because the ruling forbids minting membership from a compiler consequence and the danger arrives through the field least likely to be checked. NOT `parent_enum`: the parser writes `none` and INFERENCE fills it in, so it is not an authored name at all. NOT `field_bindings`: those are binders, and collecting them would report a pattern's own bound variables as references into whatever module spells them the same way. WHY THE `inferred` PROJECTION IS EXACT AND NOT A WALK OF INFERENCE OUTPUT: this index is built in the PARSE phase, before inference runs, so the only things any `inferred` slot can hold are the ones the parser put there. That is a statement about this reader's pipeline position, not a licence to widen -- on a post-inference tree the same recursion is the forbidden shape, and a consumer that moves this reader must revisit the block rather than inherit it. EVIDENCE, SIX ARMS AT THE FIXTURE BOUNDARY, ON THE REMOVAL SIDE DELIBERATELY. The add side cannot test this any more: #9490 made an import claim answer membership outright for ADD, so an add-side fixture goes green with the repair AND with it reverted. Measured -- with channel one deleted in isolation 2 arms fail, with channel two deleted in isolation 2 arms fail, with both present 26 pass -- and under BOTH mutations the pre-existing add-side arm stayed green, which is the measurement that chose the placement. The scope question was settled by execution rather than by reading the ruling: a type alias's RHS reproduces the same false green and is enrolled as a sixth arm; a function's return type does not reproduce, is already reachable through an ordinary slot, and is recorded as a measured negative rather than enrolled -- an arm there would pass either way and carry no information. Hand-item delta: zero. Both blocks are inside a function the seed-growth roster already enumerates. Co-Authored-By: Claude Opus 5 (1M context) --- dag/gunbc/declaration_index_seed_growth.dag | 2 + src/v1/stage0/src/declaration_index.rs | 64 +++- .../stage0/tests/namespace_wave_admission.rs | 305 ++++++++++++++++++ 3 files changed, 370 insertions(+), 1 deletion(-) diff --git a/dag/gunbc/declaration_index_seed_growth.dag b/dag/gunbc/declaration_index_seed_growth.dag index ac78f3ee873..b552ff7ef5a 100644 --- a/dag/gunbc/declaration_index_seed_growth.dag +++ b/dag/gunbc/declaration_index_seed_growth.dag @@ -400,3 +400,5 @@ data declaration_index_fixture_exemption_classification_stall: GuaranteeStall = data declaration_index_site_grain_roster_note: String = "THE SUPPRESSION ROSTERS MOVED FROM TARGET GRAIN TO SITE GRAIN (gunbc#9328). Recorded here rather than silently amended, for the reason the two rows above are: the grain of an exemption roster is the whole content of whether it is a contract or a hole, and this carrier had disclosed the previous grain as if it were sound.\n\nWHAT WAS HERE. All three rosters -- PRE_EXISTING_CITATION_DEBT, PLANTED_CONTROL_CITATIONS, FIXTURE_CARRIER_CITATION_EXEMPTIONS -- were keyed (cited module, declaration, field). citation_in_roster read the CITED symbol only, so a row exempted that target corpus-wide and permanently. A patch could author a BRAND NEW dangling DeclarationRef naming any enrolled target, from a module that had never cited it, and the wall stayed silent -- a fail-open inside the mechanism built to refuse exactly that class, and decidable from the patch alone.\n\nOCCUPIED, NOT MERELY REACHABLE, measured over DAG_PARSE_SWEEP_ROOTS: the 70 target-keyed rows covered 87 refusing sites, and seven targets were already cited from more than one module -- gunbc.host_effect host_effect_apply from three, std.bytes builtin_function_registry from three, extdeps.network.mac parse_mac_address from two, four more from two apiece. Every extra site was suppressed by a row authored about a different module.\n\nWHAT LANDED. A row is (citing module, citing declaration, cited module, declaration, field) and exempts THE SITE THAT AUTHORED IT. Both inverse arms read that one identity through a single refusing_sites set, because a suppression arm and a staleness arm keyed differently is the desynchronization this carrier already records once. The rosters are re-derived from the measurement rather than hand-extended: 42 debt, 41 fixture, 4 control, 87 sites, corpus clean.\n\nTHE FIRST DERIVATION WAS TAKEN OVER THE WRONG DENOMINATOR, and it is recorded because it is this carrier's own recurring class arriving a third time. The sweep's roots are src/v1, dag and src/v2; the first measurement used only the last two, so five sites in modules the narrow walk never read were absent from the rosters and the required run refused them. A roster derived from a subset of the subject it governs is not a smaller roster, it is a wrong one.\n\nHAND-ITEM DELTA: +5, enumerated in the roster above rather than counted -- citation_site, refusing_sites and site_owned in declaration_index.rs, and two discriminating tests, a_new_citation_of_an_enrolled_target_from_another_module_still_refuses and a_roster_row_exempts_its_own_citer_and_no_other. No file is added, no impl block is introduced, and every one of the five is citable as a WholeDeclaration. The trigger is unchanged and now names 60: these dissolve with the index, not separately.\n\nRUNG: unchanged at MECHANICALLY PREVENTABLE. This is a repair of an open direction in an existing wall, not a climb -- the invalid state stays writable and safety still depends on the phase executing. The red is authorable and authored at the FIXTURE boundary: both tests above go GREEN under the target-keyed form, which is the state they exist to forbid.\n\nTHE DECLARATION WAS ADDED AFTER REVIEW AND IT IS THE SAME REPAIR ONE LEVEL IN (review 56227). The first cut keyed a row on the citing MODULE, which left two citations of one target inside one module sharing a row -- so a new dangling citation authored BESIDE an enrolled one stayed suppressed. That was DISCLOSED as residue rather than closed, and the objection was that a residue whose closing identity is already available is not a residue. It was available: record_from_module already iterates top-level items, so the enclosing declaration name costs one string at extraction, and it is a NAME reachable from the containment tree rather than the offset DESIGN section 3 forbids. Closed, with a_second_citation_of_an_enrolled_target_in_another_declaration_still_refuses as the discriminating red -- it reports zero findings under the module grain.\n\nWHAT IS NOT REACHED, stated because a closed residue must not be reported as a total one: two citations of one target inside ONE DECLARATION still share a row. Only a position separates those, and a position is what this grain exists not to be, so this is a CEILING rather than a stall. The next rung would be an occurrence ordinal within the declaration -- representable in the record, needed by no measured site today." data declaration_index_reference_channel_selectivity_note: String = "THE REFERENCE CHANNEL BECAME SELECTIVE BY NODE KIND (gunbc, this change). Recorded here rather than only in the Rust doc comment, because the previous behaviour was DECLARED SOUND on this carrier's own construction -- record_from_module collected every authored name in a module's tree and the field's doc argued the over-collection was harmless -- and a refuted argument has to be retired where it was made.\n\nTHE ARGUMENT AND WHY IT IS WRONG. It read: the over-collection is SYMMETRIC across the two trees v1_compiler.namespace_wave_admission compares, so a spelling that denotes nothing on both sides contributes no delta. A symmetric COLLECTOR does not give a symmetric VERDICT. The supplier set the wall computes for a row is a function of the CORPUS, not of the site, so deleting an unrelated declaration moves it under every site that merely spells the same word -- and a field label spells words.\n\nTHE SPECIMEN, MEASURED RATHER THAN PREDICTED. On gunbc#9106 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.\n\nWHAT LANDED, IN THE SHAPE THE CITED COLLECTOR ON THE SAME WALK ALREADY USED -- decide by node kind, never by name. Two kinds stop being references. First, 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, and the initializer's VALUE is still walked because that is where a reference lives. Second, 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 a module-qualified reference such as probe.home.widget resolving, and the wall keys on the last segment either way. A name-based suppression list was refused: it would be the same defect one layer up.\n\nWHAT IS NOT REPAIRED, stated because a partial repair reported as a total one is worse than none. A record TYPE declaration's field labels, a named call argument's label, a parameter binder and a coproduct's variant names are STILL collected as references, and each can fabricate the same refusal from a different position. Measured on a fixture rather than assumed: a type declaring a field named tag contributes tag, and a call passing an argument labelled tag contributes tag. They are not swept in here because the parent kinds carrying 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: a wall that stops seeing genuine unresolvedness is a decoration.\n\nEVIDENCE, BOTH DIRECTIONS, AT THE FIXTURE BOUNDARY, in the wave wall's own fixture file. RED: deleting_a_declaration_a_record_field_label_merely_spells_carries_no_delta, which reports exactly the specimen's NewUnresolvedness under the previous collector and nothing under this one. GREEN, and this is 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 that a real reference reaches, one bare and one dotted. The second is the control on the projection half specifically, because a repair that had dropped the dotted spelling instead of the member name would green the red and silence that arm with it.\n\nRUNG: unchanged at MECHANICALLY PREVENTABLE. This is a repair of a fabricated-refusal direction in an existing wall, not a climb. HAND-ITEM DELTA: plus one, collect_reference_occurrences, enumerated in the roster above; record_from_module's inline walk is replaced by a call to it rather than duplicated, and no other declaration is added. The three test arms live in the wave wall's fixture file, whose carrier gunbc.namespace_wave_admission enumerates lib declarations rather than test arms." + +data declaration_index_authored_reference_channels_note: String = "THE REFERENCE CHANNEL GAINED TWO AUTHORED SOURCES IT COULD NOT REACH IN PRINCIPLE (gunbc, this change). Recorded here beside the selectivity note above rather than only in the Rust doc comment, because the two are the SAME FUNCTION moving in OPPOSITE DIRECTIONS -- that one removed fabricated references, this one recovers missing ones -- and a reader who finds only one will conclude the channel is being narrowed or widened as a policy rather than corrected against what the source actually authors.\n\nWHAT WAS MISSING. collect_reference_occurrences walks a Node through seven child slots. Two authored references are reachable through NEITHER of them. First, a VARIANT PATTERN HEAD: a match arm naming Accepted stores that name as a raw String on MatchPattern, not as a Node, so a module whose only use of an imported coproduct is naming its variants in match arms contributed NOTHING to referenced. Second, a DECLARED FIELD TYPE: v1.02_parse field_to_child_node parks a field's authored type expression in the inferred slot as Present Resolved, and every authored-name reader correctly declines to read a slot whose contract says DERIVED, so the reference vanishes.\n\nWHAT THE CONSEQUENCE WAS, MEASURED RATHER THAN PREDICTED. v1_compiler.namespace_wave_admission membership_bound_through asks whether anything in the head was bound through an import before reporting its removal as UnusedSubjectMembershipRemoved. With either channel invisible, that predicate answered NO about an import whose name is still referenced in the very tree it examined, and the wall reported the live import as dead. That is a GREEN, not a refusal, so nothing stops -- and the verdict's natural next action is to delete the import the wall just failed to see.\n\nTHE RULING THIS IMPLEMENTS, AND THE SHAPE IT FORBIDS. The operator ruled that referenced must include variant-pattern names and declared field-type names, and MUST NOT recursively traverse every inferred node. Both halves are load-bearing. Walking inferred generally would mint membership facts out of RESOLVED SEMANTIC GRAPHS -- fabricated facts indistinguishable from real ones and far harder to falsify than the missing ones they would paper over. What makes the projection exact here is a property of WHEN this index is built, not of the slot: it is derived in the parse phase, BEFORE inference runs, so the only things any inferred slot can hold are the ones the PARSER put there. Measured, all six such sites in v1.02_parse are authored syntax. THAT IS A STATEMENT ABOUT THIS PIPELINE POSITION AND NOT A LICENCE TO WIDEN: on a post-inference tree the same recursion is the forbidden shape, so a consumer that moves this reader after inference must revisit the block rather than inherit it.\n\nTWO EXCLUSIONS INSIDE THE PATTERN CHANNEL, which are the whole care in it. NOT parent_enum: the parser writes none and INFERENCE fills it, so collecting it would mint membership from a compiler consequence through the one field of that enum that looks like an authored name and is not. NOT field_bindings: those are BINDERS, they declare names rather than reference them, and collecting them would report a pattern's own bound variables as references into whatever module happens to spell them the same way.\n\nEVIDENCE, AT THE FIXTURE BOUNDARY, WITH AN EXECUTED MUTATION RECEIPT IN BOTH DIRECTIONS. Five arms land in the wave wall's own fixture file, on the REMOVAL side deliberately: the ADD side cannot test this repair at all any more, because gunbc#9490 made an authored import claim answer membership outright for ADD, so an add-side fixture goes green with the repair AND with it reverted. Measured, and this is the receipt rather than the argument: with channel one deleted in isolation, 23 passed and 2 failed -- the pattern-head arm and the deduplication arm; with channel two deleted in isolation, 23 passed and 2 failed -- the field-type arm and the deduplication arm; with both present, 25 passed. Under BOTH mutations the pre-existing add-side arm membership_declared_by_an_import_whose_names_appear_only_in_pattern_arms stayed GREEN, which is the measurement that justifies the removal-side placement rather than a preference. Two of the five arms stay green under both mutations by design and bound the repair from the other side: one requires that a LOCALLY declared spelling fabricate no support for a foreign target, and one requires that an ordinary Node-visible reference still be seen -- without them every other arm is satisfied by a reader that simply collects more.\n\nTHE SCOPE QUESTION WAS SETTLED BY MEASUREMENT AND NOT BY READING THE RULING. Two further parse-time inferred sites -- a type alias's right-hand side and a function's return type -- carried a good ARGUMENT that they are the same class and no receipt, and the manager refused to authorise them on the argument. Both were then executed. The alias RHS REPRODUCES: with the declared-type channel present the wall reports SameDeclarationIdentityRebind, with it deleted it reports UnusedSubjectMembershipRemoved, so it is the same false green closed by the same block and it is enrolled here as a sixth arm rather than deferred to a second change. The return type DOES NOT: identical verdict with the channel and without it, because a return type is already reachable through an ordinary Node slot and was never invisible. It is recorded as a measured negative and NOT enrolled, because an arm asserting the correct verdict there would pass with this repair and with it reverted -- the permanently-green decoration DESIGN 4b names as worse than absent. The projection is generic over the slot rather than special-cased to a field, so which forms it reaches is a fact about the parser's shape that has to be measured per form; two of three predicted forms behaved as predicted and the third did not.\n\nRUNG: unchanged at MECHANICALLY PREVENTABLE. This is a repair of a false-green direction in an existing wall, not a climb, and it is the mirror of the selectivity note's false-refusal repair. HAND-ITEM DELTA: ZERO. Both blocks are added INSIDE collect_reference_occurrences, which the roster above already enumerates, so the disposition is ExistingSeedItemModified and listing it would net a modification into an addition census. Two import members are added to the file (InferredNode, MatchPattern) and no declaration is." diff --git a/src/v1/stage0/src/declaration_index.rs b/src/v1/stage0/src/declaration_index.rs index 26eee034b93..6cab93bc75c 100644 --- a/src/v1/stage0/src/declaration_index.rs +++ b/src/v1/stage0/src/declaration_index.rs @@ -106,7 +106,8 @@ use std::rc::Rc; use crate::v1_rt::VecCompat; use crate::v1_std_core::{ authored_name_at, expr_literal_string_optional, import_is_all, import_specific_names_at, - module_imports, module_items, Connective, ExprData, NewlineIndex, Node, SourceSpan, + module_imports, module_items, Connective, ExprData, InferredNode, MatchPattern, NewlineIndex, + Node, SourceSpan, }; /// A span COPIED OUT of the parse tree rather than referenced into it. @@ -585,6 +586,67 @@ fn collect_reference_occurrences( if let Some(t) = node.type_annotation.as_ref() { collect_reference_occurrences(t, source_indices, in_declaration, false, out); } + + // AUTHORED CHANNEL ONE: THE VARIANT-PATTERN CONSTRUCTOR NAME. + // + // A pattern head is a reference the seven-slot walk above cannot reach even in principle, + // because it is a raw `String` on `MatchPattern` rather than a `Node`. So a module whose + // only use of an imported coproduct is naming its variants in match arms contributed + // NOTHING to `referenced`, and the gate concluded no name here resolved into the target. + // + // ONLY `name`. NOT `parent_enum`, and that exclusion is the whole care in this block: + // v1.02_parse writes `parent_enum: none` at parse time and INFERENCE fills it in later, so + // collecting it would mint a membership fact out of a compiler consequence rather than out + // of authored source -- the precise failure the operator ruling forbids, arriving through + // the one field of this enum that looks like an authored name and is not. + // + // NOT `field_bindings` either. Those are BINDERS -- they declare names, they do not + // reference them -- and the recursion below would otherwise report a pattern's own bound + // variables as references into whatever module happens to spell them the same way. + if let Some(pattern) = node.match_pattern.as_ref() { + if let MatchPattern::VariantPattern { name, .. } = &**pattern { + if !name.is_empty() { + out.insert((in_declaration.to_string(), name.clone())); + } + } + } + + // AUTHORED CHANNEL TWO: THE DECLARED TYPE PARKED IN `inferred`. + // + // `v1.02_parse` `field_to_child_node` stores a field's DECLARED TYPE as + // `inferred: Present { Resolved { node: } }`. The type is a + // perfectly ordinary Node carrying an authored name; it is simply parked in the one slot + // whose contract says "derived, not authored", so every authored-name reader correctly + // declines to look and the reference vanishes. + // + // THIS IS A PROJECTION OF ONE AUTHORED CHANNEL, NOT A WALK OF INFERENCE OUTPUT, and the + // distinction is the operator ruling's entire content. Walking inferred generally would + // mint membership from resolved semantic graphs -- fabricated facts that look exactly like + // real ones and are far harder to falsify. What makes the projection exact HERE is a + // property of WHEN this index is built: it is derived in the parse phase, BEFORE inference + // runs, so the only things any `inferred` slot can contain are the ones the PARSER put + // there. Measured, all six such sites in v1.02_parse: the field type above, a type alias's + // right-hand side, a `Callable` return type, an optional return annotation, and two that + // hold a leaf node of the declaration's OWN name. Every one is authored syntax. + // + // THAT IS A STATEMENT ABOUT THIS TREE AND NOT A LICENCE TO WIDEN. On a post-inference tree + // the same recursion would be the forbidden shape, so this reader's correctness is bound to + // its position in the pipeline. A consumer that moves it after inference must revisit this + // block rather than inherit it. + if let Some(inferred) = node.inferred.as_ref() { + if let InferredNode::Resolved { + node: declared_type, + } = &**inferred + { + collect_reference_occurrences( + declared_type, + source_indices, + in_declaration, + false, + out, + ); + } + } }) } diff --git a/src/v1/stage0/tests/namespace_wave_admission.rs b/src/v1/stage0/tests/namespace_wave_admission.rs index 305ade2aa8d..17c84337fb2 100644 --- a/src/v1/stage0/tests/namespace_wave_admission.rs +++ b/src/v1/stage0/tests/namespace_wave_admission.rs @@ -725,3 +725,308 @@ fn membership_declared_by_an_import_whose_names_appear_only_in_pattern_arms() { .collect::>() ); } + +// THE FIVE CONTROLS FOR THE AUTHORED-REFERENCE CHANNELS, AND THEY ARE ON THE REMOVAL SIDE +// DELIBERATELY -- WHICH IS THE WHOLE REASON THEY DISCRIMINATE ANYTHING. +// +// The add side cannot test this repair any more. #9490 made an authored import claim answer +// membership outright for ADD, so an add-side fixture over either channel goes green whether or +// not `referenced` can see the reference -- the disjunct answers first. That is precisely the +// green-with-and-without decoration this file already records one instance of, and building four +// more of them would have looked like coverage of a repair that was never exercised. +// +// The REMOVAL side has no such disjunct and cannot have one: an import claim states that a +// dependency was DECLARED, and removal asks whether anything was BOUND THROUGH it. Only seeing the +// reference answers that. So `membership_bound_through` is the one predicate these channels are +// load-bearing for, and every arm below drops an import while KEEPING the reference that needs it. +// +// WHAT THE FALSE GREEN WAS: the gate reported `UnusedSubjectMembershipRemoved` -- "removed, nothing +// bound through it" -- about an import whose name is still referenced in the very tree it just +// examined. That verdict tells a reader the import is dead, so the natural next action is to delete +// it, and the wall that exists to catch unsound namespace motion is the thing recommending it. A +// refusal is loud and gets investigated; this is a green, and nothing stops. +// +// EVERY ARM'S RED IS AUTHORABLE BY ONE ISOLATED MUTATION: delete that channel's block from +// `collect_reference_occurrences` and the arm reports `UnusedSubjectMembershipRemoved`. +// +// EXECUTED MUTATION RECEIPT -- both mutations run, each channel deleted in ISOLATION, the whole +// file run each time, and the arms that went red are exactly the arms that name that channel: +// +// channel one deleted (variant-pattern name): 23 passed, 2 failed -- +// a_removed_import_whose_names_survive_only_as_pattern_heads_is_not_reported_unused +// one_name_reached_through_both_authored_channels_is_one_support_relation +// channel two deleted (declared field type): 23 passed, 2 failed -- +// a_removed_import_whose_name_survives_only_as_a_declared_field_type_is_not_reported_unused +// one_name_reached_through_both_authored_channels_is_one_support_relation +// both channels present: 25 passed, 0 failed +// +// THE MORE INFORMATIVE HALF OF THAT RECEIPT IS WHAT DID NOT MOVE, and it is why these arms are on +// the removal side rather than beside the add-side arm above. Under BOTH mutations, +// `membership_declared_by_an_import_whose_names_appear_only_in_pattern_arms` -- which exercises the +// same pattern channel from the ADD direction -- stayed GREEN. Its import-claim disjunct answers +// first, so it passes with the repair and without it; it is a correct arm about a different +// predicate, and it would have been worthless as evidence for this one. Anyone extending this +// family must run the revert arm before believing a new fixture covers anything. +// +// Arms four and five stay green under both mutations BY DESIGN: one asserts that no support is +// FABRICATED and one that an ordinary Node-visible reference still works, so a channel's absence +// cannot make either fail. They bound the repair from the other side -- without them, every arm +// above is satisfied by a reader that simply collects more. + +const FIELD_TYPE_HOME: &str = + "module probe.payload\n\ntype Wrapper\n = Wrapped { inner: String }\n | Empty\n"; + +// The name appears in EXACTLY ONE position: a declared field type inside a variant payload. Nothing +// else in the module spells `Wrapper`, so the only thing that can support this membership is the +// channel that reads a field's declared type out of `inferred`. +const FIELD_TYPE_ONLY: &str = "module probe.consumer\n\ntype Holder = Held { w: Wrapper }\n"; + +const FIELD_TYPE_ONLY_WITH_IMPORT: &str = + "module probe.consumer\n\nimport probe.payload { Wrapper }\n\ntype Holder = Held { w: Wrapper }\n"; + +#[test] +fn a_removed_import_whose_name_survives_only_as_a_declared_field_type_is_not_reported_unused() { + let report = compare( + "field_type_only_removal", + &[ + ("payload.dag", FIELD_TYPE_HOME), + ("consumer.dag", FIELD_TYPE_ONLY_WITH_IMPORT), + ], + &[ + ("payload.dag", FIELD_TYPE_HOME), + ("consumer.dag", FIELD_TYPE_ONLY), + ], + ); + + // PLANT ASSERTION FIRST: a disposition read off a delta that was never produced is a verdict + // about nothing. + assert!( + report.deltas.iter().any(|d| matches!( + &d.subject, + DeltaSubject::Membership { target, .. } if target == "probe.payload" + )), + "PLANT NEVER REACHED: no membership delta for probe.payload, got: {:?}", + report.deltas + ); + + let dispositions = membership_dispositions(&report, "probe.payload"); + assert!( + !dispositions.contains(&NamespaceDeltaDisposition::UnusedSubjectMembershipRemoved), + "the head still references `Wrapper` as a declared field type, so this import was \ + load-bearing and its removal is a breakage -- reporting it as unused tells a reader to \ + delete a live import, got: {:?}", + dispositions + .iter() + .map(|d| disposition_label(*d)) + .collect::>() + ); +} + +const PATTERN_ONLY_REMOVAL_BASE: &str = "module probe.consumer\n\nimport probe.holder { fetch }\nimport probe.coproduct { Accepted, Refused }\n\nfn decide() -> String {\n match fetch() {\n Accepted => \"yes\"\n Refused => \"no\"\n }\n}\n"; + +const PATTERN_ONLY_REMOVAL_HEAD: &str = "module probe.consumer\n\nimport probe.holder { fetch }\n\nfn decide() -> String {\n match fetch() {\n Accepted => \"yes\"\n Refused => \"no\"\n }\n}\n"; + +#[test] +fn a_removed_import_whose_names_survive_only_as_pattern_heads_is_not_reported_unused() { + let report = compare( + "pattern_only_removal", + &[ + ("coproduct.dag", COPRODUCT_HOME), + ("holder.dag", VERDICT_HOLDER), + ("consumer.dag", PATTERN_ONLY_REMOVAL_BASE), + ], + &[ + ("coproduct.dag", COPRODUCT_HOME), + ("holder.dag", VERDICT_HOLDER), + ("consumer.dag", PATTERN_ONLY_REMOVAL_HEAD), + ], + ); + + assert!( + report.deltas.iter().any(|d| matches!( + &d.subject, + DeltaSubject::Membership { target, .. } if target == "probe.coproduct" + )), + "PLANT NEVER REACHED: no membership delta for probe.coproduct, got: {:?}", + report.deltas + ); + + let dispositions = membership_dispositions(&report, "probe.coproduct"); + assert!( + !dispositions.contains(&NamespaceDeltaDisposition::UnusedSubjectMembershipRemoved), + "the head still names `Accepted` and `Refused` as pattern heads, so the base import was \ + bound through -- reporting it unused is the false green this channel exists to close, \ + got: {:?}", + dispositions + .iter() + .map(|d| disposition_label(*d)) + .collect::>() + ); +} + +// BOTH CHANNELS IN ONE MODULE, AND THE ASSERTION IS ABOUT DEDUPLICATION RATHER THAN PRESENCE. +// `referenced` is a set keyed on (enclosing declaration, name), so one name reached through two +// channels must land as ONE relation, not two. A projection that appended instead of inserting +// would still pass both arms above and would inflate every downstream count that reads this set. +const BOTH_CHANNELS_HOME: &str = + "module probe.payload\n\ntype Wrapper\n = Wrapped { inner: String }\n | Empty\n"; + +const BOTH_CHANNELS_HEAD: &str = "module probe.consumer\n\ntype Holder = Held { w: Wrapper }\n\nfn describe(h: Holder) -> String {\n match h.w {\n Wrapped { inner: i } => i\n Empty => \"empty\"\n }\n}\n"; + +#[test] +fn one_name_reached_through_both_authored_channels_is_one_support_relation() { + let dir = scratch("both_channels"); + author(&dir, "payload.dag", BOTH_CHANNELS_HOME); + author(&dir, "consumer.dag", BOTH_CHANNELS_HEAD); + let index = index_of(&dir); + let record = v1_compiler::cli_run::declaration_index::index_get(&index, "probe.consumer") + .expect("PLANT NEVER REACHED: the consumer module must be indexed"); + + let wrapper_rows: Vec<_> = record + .referenced + .iter() + .filter(|(_, name)| name == "Wrapper") + .collect(); + assert_eq!( + wrapper_rows.len(), + 1, + "`Wrapper` is authored once, as the declared type of `Held.w`; a second row would mean \ + the projection appended rather than inserted into the keyed set, and every downstream \ + count that reads `referenced` would inflate with it, got: {:?}", + wrapper_rows + ); + + // AND THE PATTERN CHANNEL IS PRESENT IN THE SAME RECORD -- otherwise this arm would pass on + // a projection that reads neither channel, which is the shape it exists to exclude. + assert!( + record.referenced.iter().any(|(_, name)| name == "Wrapped"), + "the variant-pattern head `Wrapped` must be an authored reference, got: {:?}", + record.referenced + ); +} + +// THE ARM THAT CATCHES A LAZY PROJECTION. `Wrapper` is declared LOCALLY here and named nowhere +// else, so nothing in this module supports membership in probe.payload. A projection that +// collected the spelling without caring where it resolves would fabricate that support -- and a +// fabricated membership looks exactly like a real one, which is why the operator ruling forbids +// minting membership from anything but authored references and why this arm is not optional. +const LOCAL_SHADOW_CONSUMER: &str = "module probe.consumer\n\ntype Wrapper = Local { n: String }\n\ntype Holder = Held { w: Wrapper }\n"; + +#[test] +fn a_locally_declared_spelling_fabricates_no_support_for_a_foreign_target() { + let report = compare( + "local_shadow", + &[ + ("payload.dag", FIELD_TYPE_HOME), + ( + "consumer.dag", + "module probe.consumer\n\ndata unset: String = \"u\"\n", + ), + ], + &[ + ("payload.dag", FIELD_TYPE_HOME), + ("consumer.dag", LOCAL_SHADOW_CONSUMER), + ], + ); + + let dispositions = membership_dispositions(&report, "probe.payload"); + assert!( + dispositions.is_empty(), + "probe.consumer declares its own `Wrapper` and imports nothing from probe.payload, so no \ + membership edge to it exists in either direction; any delta here is fabricated support, \ + got: {:?}", + dispositions + .iter() + .map(|d| disposition_label(*d)) + .collect::>() + ); +} + +// THE UNCHANGED CONTROL. An ordinary Node-visible reference -- a function call through an imported +// name -- was reachable by the seven-slot walk before this change and must still be. This is the +// arm that catches a projection which broke what already worked, and without it every arm above is +// satisfied by a reader that collects too much. +const ORDINARY_HOME: &str = "module probe.lib\n\nfn helper() -> String { \"h\" }\n"; +const ORDINARY_BASE: &str = + "module probe.consumer\n\nimport probe.lib { helper }\n\nfn use_it() -> String { helper() }\n"; +const ORDINARY_HEAD: &str = "module probe.consumer\n\nfn use_it() -> String { \"inlined\" }\n"; + +#[test] +fn an_ordinary_node_visible_reference_removal_is_still_reported_unchanged() { + let report = compare( + "ordinary_control", + &[("lib.dag", ORDINARY_HOME), ("consumer.dag", ORDINARY_BASE)], + &[("lib.dag", ORDINARY_HOME), ("consumer.dag", ORDINARY_HEAD)], + ); + + assert!( + report.deltas.iter().any(|d| matches!( + &d.subject, + DeltaSubject::Membership { target, .. } if target == "probe.lib" + )), + "PLANT NEVER REACHED: no membership delta for probe.lib, got: {:?}", + report.deltas + ); +} + +// A SIXTH ARM, ADDED BECAUSE THE MEASUREMENT SAID SO RATHER THAN BECAUSE THE ARGUMENT DID. +// +// A type alias's right-hand side is the same authored channel as a declared field type -- the +// parser parks BOTH in `inferred` -- so the block above was expected to cover it. Expected is not +// measured, and the projection is generic over the slot rather than special-cased to a field, so +// whether it reaches an alias RHS is a fact about the parser's shape and not about the reader's +// intent. Executed both ways: with channel two present the wall reports +// `SameDeclarationIdentityRebind`; with channel two deleted it reports +// `UnusedSubjectMembershipRemoved`. That is a real specimen of the same false green, closed by the +// same block, so it is an arm here rather than a second change. +const ALIAS_RHS_HOME: &str = "module probe.payload\n\ntype QualifiedName = Qn { text: String }\n"; +const ALIAS_RHS_BASE: &str = "module probe.consumer\n\nimport probe.payload { QualifiedName }\n\ntype ModulePath = QualifiedName\n"; +const ALIAS_RHS_HEAD: &str = "module probe.consumer\n\ntype ModulePath = QualifiedName\n"; + +#[test] +fn a_removed_import_whose_name_survives_only_as_a_type_alias_right_hand_side_is_not_reported_unused( +) { + let report = compare( + "alias_rhs_removal", + &[ + ("payload.dag", ALIAS_RHS_HOME), + ("consumer.dag", ALIAS_RHS_BASE), + ], + &[ + ("payload.dag", ALIAS_RHS_HOME), + ("consumer.dag", ALIAS_RHS_HEAD), + ], + ); + + assert!( + report.deltas.iter().any(|d| matches!( + &d.subject, + DeltaSubject::Membership { target, .. } if target == "probe.payload" + )), + "PLANT NEVER REACHED: no membership delta for probe.payload, got: {:?}", + report.deltas + ); + + let dispositions = membership_dispositions(&report, "probe.payload"); + assert!( + !dispositions.contains(&NamespaceDeltaDisposition::UnusedSubjectMembershipRemoved), + "the head's alias still names `QualifiedName`, so the dropped import was bound through \ + it, got: {:?}", + dispositions + .iter() + .map(|d| disposition_label(*d)) + .collect::>() + ); +} + +// AND ONE MEASURED NEGATIVE, RECORDED RATHER THAN ENROLLED, because an arm here would be the +// permanently-green decoration DESIGN ยง4b names as worse than absent. +// +// A function's RETURN TYPE was the other candidate from the six-site parser reading, and the same +// argument covered it. It does not reproduce: measured, `fn make() -> QualifiedName` with the +// import dropped reports `SameDeclarationIdentityRebind` WITH channel two and +// `SameDeclarationIdentityRebind` WITHOUT it -- identical, because a return type is already +// reachable through an ordinary Node slot, so it was never invisible. An arm asserting the correct +// verdict there would pass with this repair and pass with it reverted, carrying no information +// about the thing it appeared to cover. The argument was good and the receipt refused it, which is +// the whole reason the receipt is taken. From 6ebb8648c9c7be1059a595fa56cf49111f7d3681 Mon Sep 17 00:00:00 2001 From: Brian Searls Date: Thu, 27 Aug 2026 20:30:28 +0000 Subject: [PATCH 2/2] Consume the parser's transport for authored type references, and read the one authored name it does not carry (#9494) Rebuilt on the operator ruling: authored references come from the parser's `OccurrenceTransport`, never from re-reading the final `Node`, because the parser already stamps the fact exactly and a Node-reading projection is a SECOND AUTHORITY free to diverge from it. That is exact for a DECLARED FIELD TYPE. `stamp_parsed_inferred` stamps a `Resolved` inferred node as `ParsedOccurrenceReference { TypeOccurrence }`, and the stamp is on the SLOT -- so the "generic over the form" property the first construction gained was precisely the property the transport already owned. It was reimplementing `stamp_parsed_inferred` from its own output. The seam is one argument wide: `CensusFillParse` already carries `occurrence_transport` and both callers already hold one at the line they call `record_from_module`, so the fact was PRODUCED AND DISCARDED at a return boundary rather than absent. THE VARIANT PATTERN HEAD STILL READS AN AUTHORED STRING, and that is a scope finding rather than an exception taken. The ruling's rationale has no referent there: `VariantPattern.name` is a `String`, `stamp_parsed_pattern` stamps `field_bindings` only, `ConstructorOccurrence` is stamped NOWHERE in the parser, and occurrence ids are minted per `Node`. The parser mints nothing for that position, so there is no first authority to be second to -- reading the String is not re-derivation, it is the only derivation. The transport repair for that half is blocked on a RULING, not a RISK: the allocator objection is answered (`content_hash` folds no occurrence id; `legacy_binding_delta` declares an `OccurrenceId` unstable BECAUSE the counter encodes DFS position), and what remains is that stamping a new occurrence is a parser change. A PEER FIELD, not a widened `referenced`: the two have different authorities and different precision, and fusing them hides which is which. The consumer takes the union. THE IMPORT-MEMBER FILTER IS LOAD-BEARING. The transport stamps an import member name as a `TypeOccurrence` enclosed by the import target, so consuming every `TypeOccurrence` makes each import bound-through by its own member and `UnusedSubjectMembershipRemoved` becomes UNREACHABLE -- a disposition going permanently quiet, which is worse than the false green this closes. Caught by a PRE-EXISTING arm, not by review. Seven arms, removal side, with a three-way mutation receipt re-taken against this construction (2 / 3 / 2 failing per isolated mutation, 27 passing unmutated) -- not carried over from the deleted one, since a receipt about code that no longer exists reads as evidence for what is there. Co-Authored-By: Claude Opus 5 (1M context) --- dag/gunbc/declaration_index_seed_growth.dag | 7 +- src/v1/stage0/src/cli_run.rs | 1 + src/v1/stage0/src/declaration_index.rs | 174 ++++++++++++++---- src/v1/stage0/src/namespace_wave_admission.rs | 26 ++- .../stage0/tests/namespace_wave_admission.rs | 124 ++++++++++--- 5 files changed, 258 insertions(+), 74 deletions(-) diff --git a/dag/gunbc/declaration_index_seed_growth.dag b/dag/gunbc/declaration_index_seed_growth.dag index b552ff7ef5a..fa72bcb5e77 100644 --- a/dag/gunbc/declaration_index_seed_growth.dag +++ b/dag/gunbc/declaration_index_seed_growth.dag @@ -136,6 +136,11 @@ data declaration_index_seed_growth_justification: SeedGrowthJustification = Seed decl_name: "collect_reference_occurrences", field: WholeDeclaration }, + DeclarationRef { + module_path: "v1_compiler.declaration_index", + decl_name: "authored_type_references_from_transport", + field: WholeDeclaration + }, DeclarationRef { module_path: "v1_compiler.declaration_index", decl_name: "record_from_module", @@ -401,4 +406,4 @@ data declaration_index_site_grain_roster_note: String = "THE SUPPRESSION ROSTERS data declaration_index_reference_channel_selectivity_note: String = "THE REFERENCE CHANNEL BECAME SELECTIVE BY NODE KIND (gunbc, this change). Recorded here rather than only in the Rust doc comment, because the previous behaviour was DECLARED SOUND on this carrier's own construction -- record_from_module collected every authored name in a module's tree and the field's doc argued the over-collection was harmless -- and a refuted argument has to be retired where it was made.\n\nTHE ARGUMENT AND WHY IT IS WRONG. It read: the over-collection is SYMMETRIC across the two trees v1_compiler.namespace_wave_admission compares, so a spelling that denotes nothing on both sides contributes no delta. A symmetric COLLECTOR does not give a symmetric VERDICT. The supplier set the wall computes for a row is a function of the CORPUS, not of the site, so deleting an unrelated declaration moves it under every site that merely spells the same word -- and a field label spells words.\n\nTHE SPECIMEN, MEASURED RATHER THAN PREDICTED. On gunbc#9106 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.\n\nWHAT LANDED, IN THE SHAPE THE CITED COLLECTOR ON THE SAME WALK ALREADY USED -- decide by node kind, never by name. Two kinds stop being references. First, 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, and the initializer's VALUE is still walked because that is where a reference lives. Second, 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 a module-qualified reference such as probe.home.widget resolving, and the wall keys on the last segment either way. A name-based suppression list was refused: it would be the same defect one layer up.\n\nWHAT IS NOT REPAIRED, stated because a partial repair reported as a total one is worse than none. A record TYPE declaration's field labels, a named call argument's label, a parameter binder and a coproduct's variant names are STILL collected as references, and each can fabricate the same refusal from a different position. Measured on a fixture rather than assumed: a type declaring a field named tag contributes tag, and a call passing an argument labelled tag contributes tag. They are not swept in here because the parent kinds carrying 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: a wall that stops seeing genuine unresolvedness is a decoration.\n\nEVIDENCE, BOTH DIRECTIONS, AT THE FIXTURE BOUNDARY, in the wave wall's own fixture file. RED: deleting_a_declaration_a_record_field_label_merely_spells_carries_no_delta, which reports exactly the specimen's NewUnresolvedness under the previous collector and nothing under this one. GREEN, and this is 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 that a real reference reaches, one bare and one dotted. The second is the control on the projection half specifically, because a repair that had dropped the dotted spelling instead of the member name would green the red and silence that arm with it.\n\nRUNG: unchanged at MECHANICALLY PREVENTABLE. This is a repair of a fabricated-refusal direction in an existing wall, not a climb. HAND-ITEM DELTA: plus one, collect_reference_occurrences, enumerated in the roster above; record_from_module's inline walk is replaced by a call to it rather than duplicated, and no other declaration is added. The three test arms live in the wave wall's fixture file, whose carrier gunbc.namespace_wave_admission enumerates lib declarations rather than test arms." -data declaration_index_authored_reference_channels_note: String = "THE REFERENCE CHANNEL GAINED TWO AUTHORED SOURCES IT COULD NOT REACH IN PRINCIPLE (gunbc, this change). Recorded here beside the selectivity note above rather than only in the Rust doc comment, because the two are the SAME FUNCTION moving in OPPOSITE DIRECTIONS -- that one removed fabricated references, this one recovers missing ones -- and a reader who finds only one will conclude the channel is being narrowed or widened as a policy rather than corrected against what the source actually authors.\n\nWHAT WAS MISSING. collect_reference_occurrences walks a Node through seven child slots. Two authored references are reachable through NEITHER of them. First, a VARIANT PATTERN HEAD: a match arm naming Accepted stores that name as a raw String on MatchPattern, not as a Node, so a module whose only use of an imported coproduct is naming its variants in match arms contributed NOTHING to referenced. Second, a DECLARED FIELD TYPE: v1.02_parse field_to_child_node parks a field's authored type expression in the inferred slot as Present Resolved, and every authored-name reader correctly declines to read a slot whose contract says DERIVED, so the reference vanishes.\n\nWHAT THE CONSEQUENCE WAS, MEASURED RATHER THAN PREDICTED. v1_compiler.namespace_wave_admission membership_bound_through asks whether anything in the head was bound through an import before reporting its removal as UnusedSubjectMembershipRemoved. With either channel invisible, that predicate answered NO about an import whose name is still referenced in the very tree it examined, and the wall reported the live import as dead. That is a GREEN, not a refusal, so nothing stops -- and the verdict's natural next action is to delete the import the wall just failed to see.\n\nTHE RULING THIS IMPLEMENTS, AND THE SHAPE IT FORBIDS. The operator ruled that referenced must include variant-pattern names and declared field-type names, and MUST NOT recursively traverse every inferred node. Both halves are load-bearing. Walking inferred generally would mint membership facts out of RESOLVED SEMANTIC GRAPHS -- fabricated facts indistinguishable from real ones and far harder to falsify than the missing ones they would paper over. What makes the projection exact here is a property of WHEN this index is built, not of the slot: it is derived in the parse phase, BEFORE inference runs, so the only things any inferred slot can hold are the ones the PARSER put there. Measured, all six such sites in v1.02_parse are authored syntax. THAT IS A STATEMENT ABOUT THIS PIPELINE POSITION AND NOT A LICENCE TO WIDEN: on a post-inference tree the same recursion is the forbidden shape, so a consumer that moves this reader after inference must revisit the block rather than inherit it.\n\nTWO EXCLUSIONS INSIDE THE PATTERN CHANNEL, which are the whole care in it. NOT parent_enum: the parser writes none and INFERENCE fills it, so collecting it would mint membership from a compiler consequence through the one field of that enum that looks like an authored name and is not. NOT field_bindings: those are BINDERS, they declare names rather than reference them, and collecting them would report a pattern's own bound variables as references into whatever module happens to spell them the same way.\n\nEVIDENCE, AT THE FIXTURE BOUNDARY, WITH AN EXECUTED MUTATION RECEIPT IN BOTH DIRECTIONS. Five arms land in the wave wall's own fixture file, on the REMOVAL side deliberately: the ADD side cannot test this repair at all any more, because gunbc#9490 made an authored import claim answer membership outright for ADD, so an add-side fixture goes green with the repair AND with it reverted. Measured, and this is the receipt rather than the argument: with channel one deleted in isolation, 23 passed and 2 failed -- the pattern-head arm and the deduplication arm; with channel two deleted in isolation, 23 passed and 2 failed -- the field-type arm and the deduplication arm; with both present, 25 passed. Under BOTH mutations the pre-existing add-side arm membership_declared_by_an_import_whose_names_appear_only_in_pattern_arms stayed GREEN, which is the measurement that justifies the removal-side placement rather than a preference. Two of the five arms stay green under both mutations by design and bound the repair from the other side: one requires that a LOCALLY declared spelling fabricate no support for a foreign target, and one requires that an ordinary Node-visible reference still be seen -- without them every other arm is satisfied by a reader that simply collects more.\n\nTHE SCOPE QUESTION WAS SETTLED BY MEASUREMENT AND NOT BY READING THE RULING. Two further parse-time inferred sites -- a type alias's right-hand side and a function's return type -- carried a good ARGUMENT that they are the same class and no receipt, and the manager refused to authorise them on the argument. Both were then executed. The alias RHS REPRODUCES: with the declared-type channel present the wall reports SameDeclarationIdentityRebind, with it deleted it reports UnusedSubjectMembershipRemoved, so it is the same false green closed by the same block and it is enrolled here as a sixth arm rather than deferred to a second change. The return type DOES NOT: identical verdict with the channel and without it, because a return type is already reachable through an ordinary Node slot and was never invisible. It is recorded as a measured negative and NOT enrolled, because an arm asserting the correct verdict there would pass with this repair and with it reverted -- the permanently-green decoration DESIGN 4b names as worse than absent. The projection is generic over the slot rather than special-cased to a field, so which forms it reaches is a fact about the parser's shape that has to be measured per form; two of three predicted forms behaved as predicted and the third did not.\n\nRUNG: unchanged at MECHANICALLY PREVENTABLE. This is a repair of a false-green direction in an existing wall, not a climb, and it is the mirror of the selectivity note's false-refusal repair. HAND-ITEM DELTA: ZERO. Both blocks are added INSIDE collect_reference_occurrences, which the roster above already enumerates, so the disposition is ExistingSeedItemModified and listing it would net a modification into an addition census. Two import members are added to the file (InferredNode, MatchPattern) and no declaration is." +data declaration_index_authored_reference_channels_note: String = "TWO AUTHORED REFERENCES THE INDEX COULD NOT REACH, AND THE FALSE GREEN THEY PRODUCED (gunbc, this change). Recorded beside the selectivity note above rather than only in the Rust doc comment, because the two are the SAME FUNCTION moving in OPPOSITE DIRECTIONS -- that one removed fabricated references, this one recovers missing ones -- and a reader who finds only one will conclude the channel is being narrowed or widened as a policy rather than corrected against what the source authors.\n\nWHAT WAS MISSING. collect_reference_occurrences walks a Node through seven child slots. Two authored references are reachable through NEITHER. A VARIANT PATTERN HEAD is a raw String on MatchPattern, not a Node, so a module whose only use of an imported coproduct is naming its variants in match arms contributed nothing. A DECLARED FIELD TYPE is parked by v1.02_parse field_to_child_node in the inferred slot, and every authored-name reader correctly declines to read a slot whose contract says DERIVED.\n\nWHAT THE CONSEQUENCE WAS. v1_compiler.namespace_wave_admission membership_bound_through answered NO about an import whose name is still referenced in the very tree it examined, and the wall reported the live import as UnusedSubjectMembershipRemoved. That is a GREEN, not a refusal, so nothing stops -- and the verdict's natural next action is to delete the import the wall failed to see.\n\nTHE CONSTRUCTION, AND IT IS THE SECOND ONE AUTHORED FOR THIS DEFECT. The first read both facts back off the final Node. An operator ruling narrowed the permitted construction to consuming the parser's existing OccurrenceTransport, and the RATIONALE is what decides the shape rather than the letter: the parser already stamps the fact exactly, so a Node-reading projection is a SECOND AUTHORITY free to diverge from the first. Measured, that is exact for the declared field type -- stamp_parsed_inferred stamps a Resolved inferred node as ParsedOccurrenceReference TypeOccurrence, and the stamp is on the SLOT, so the generic-over-the-form property the first construction gained is precisely the property the transport already owned. The first construction was reimplementing stamp_parsed_inferred from its own output. The seam is one argument wide: CensusFillParse already carries occurrence_transport and both callers already hold one at the line they call record_from_module, so the fact was PRODUCED AND DISCARDED at a return boundary rather than absent.\n\nWHY THE PATTERN HEAD STILL READS AN AUTHORED STRING, and this is a scope finding rather than an exception taken. The rationale has NO REFERENT for that position: MatchPattern.VariantPattern.name is a String, stamp_parsed_pattern stamps field_bindings only, ConstructorOccurrence is stamped NOWHERE in v1_compiler_parse, and occurrence ids are minted per Node so a String can never carry one. The parser mints nothing there, so there is no first authority to be second to -- reading the authored String is not re-derivation, it is the only derivation, and a prohibition whose stated reason does not apply is not extended by its letter. The transport repair for that half is BLOCKED ON A RULING, NOT ON A RISK: the obvious objection was that a new stamp shifts every id allocated after it in a sequential allocator, and that objection is answered -- v2.std.node content_hash folds node kind, edge labels and child hashes and NOT the occurrence id, and v2.workflow.legacy_binding_delta states outright that an OccurrenceId is not a stable cross-compile name BECAUSE the counter is consumed in DFS order, naming inserted tokens as exactly the edit that shifts ids. The corpus does not merely lack a dependency on id stability, it declares that dependency illegitimate. What remains is that stamping a new occurrence is a parser change, which is an authority question.\n\nA PEER FIELD, NOT A WIDENED referenced, as the ruling directs, and the reason survives the ruling: the two sets have different authorities and different precision, and fusing them would hide which is which behind one name. authored_type_references carries the transport's answer; referenced remains the index's own walk. The consumer takes the union, so nothing downstream needs to know which channel supplied a row, and the walk can shrink toward zero as more positions gain transport entries.\n\nTHE FILTER THAT IS LOAD-BEARING RATHER THAN TIDINESS. Only references enclosed by a declaration THIS MODULE DECLARES are taken. The transport stamps an IMPORT MEMBER NAME as a TypeOccurrence enclosed by the import target -- measured, import probe.other { gadget } yields the row (probe.other, gadget) -- so consuming every TypeOccurrence makes each import bound-through by its own member and UnusedSubjectMembershipRemoved becomes UNREACHABLE. A disposition going permanently quiet is strictly worse than the false green this change closes: one wrong verdict against a wall that can no longer fire. It was caught by a PRE-EXISTING arm, not by review and not by any arm authored here.\n\nEVIDENCE, SEVEN ARMS AT THE FIXTURE BOUNDARY, ON THE REMOVAL SIDE DELIBERATELY, WITH A THREE-WAY MUTATION RECEIPT RE-TAKEN AGAINST THIS CONSTRUCTION. The add side cannot test this at all: gunbc#9490 made an import claim answer membership outright for ADD, so an add-side fixture goes green with the repair AND with it reverted -- measured, that arm stayed green under both channel mutations. Deleting the pattern-head read fails 2; yielding nothing from the transport fails 3; deleting the import-member filter fails 2, one of them the pre-existing unused-removal arm; unmutated, 27 pass. The scope question was settled by execution: a type alias RHS reproduces the same false green and is enrolled, a function return type does not reproduce and is recorded as a measured negative rather than enrolled, because an arm there would pass either way.\n\nRUNG: unchanged at MECHANICALLY PREVENTABLE. A repair of a false-green direction in an existing wall, the mirror of the selectivity note's false-refusal repair. HAND-ITEM DELTA: plus one, authored_type_references_from_transport, enumerated in the roster above; the pattern-head block sits inside collect_reference_occurrences which the roster already enumerates, and record_from_module gains one parameter rather than a declaration." diff --git a/src/v1/stage0/src/cli_run.rs b/src/v1/stage0/src/cli_run.rs index d988d194e52..9f47c09f3be 100644 --- a/src/v1/stage0/src/cli_run.rs +++ b/src/v1/stage0/src/cli_run.rs @@ -18412,6 +18412,7 @@ pub fn run_dag_parse_sweep(workspace: &Path, roots: &[&str]) -> Result, + /// AUTHORED TYPE REFERENCES, TAKEN FROM THE PARSER'S OWN `OccurrenceTransport` RATHER THAN + /// RE-DERIVED FROM THE `Node`. A peer of `referenced`, deliberately not a widening of it: + /// the two have different authorities and different precision, and fusing them would hide + /// which is which behind one name. + /// + /// `referenced` is this module's lossy walk over the final tree -- it cannot see a type + /// parked in the `inferred` slot at all, and it over-collects binders and labels it cannot + /// tell apart from references. This set is the parser's answer: `stamp_parsed_inferred` + /// stamps a declared type as `ParsedOccurrenceReference { TypeOccurrence }` with a minted + /// identity, the authored spelling, and containment, and that is what is read back here. + /// + /// WHY A PEER AND NOT A MERGE. A Node-reading projection that reproduced these entries + /// would be a second authority for a fact the parser already owns exactly -- it would agree + /// today and could silently diverge tomorrow, which is a worse failure than being wrong + /// once because nothing would detect it. Keeping the transport's answer in its own field + /// means a future reader can tell what was authored from what was reconstructed, and the + /// remaining Node walk can shrink toward zero as more positions gain transport entries. + /// + /// Keyed `(enclosing declaration, authored spelling)`, the same shape as `referenced`, so a + /// consumer that wants every authored reference takes the union and nothing needs to know + /// which channel supplied which row. + pub authored_type_references: BTreeSet<(String, String)>, pub declares_construction_justification: bool, /// Whether this module is a witness or fixture carrier. See `module_is_fixture_carrier`. pub is_fixture_carrier: bool, @@ -587,7 +610,42 @@ fn collect_reference_occurrences( collect_reference_occurrences(t, source_indices, in_declaration, false, out); } - // AUTHORED CHANNEL ONE: THE VARIANT-PATTERN CONSTRUCTOR NAME. + // THE VARIANT-PATTERN CONSTRUCTOR NAME -- THE ONE AUTHORED REFERENCE WITH NO + // TRANSPORT ENTRY TO CONSUME, AND THE ONLY REASON THIS FUNCTION READS A NAME AT ALL. + // + // The operator ruling is that authored references come from the parser's + // `OccurrenceTransport`, never from re-reading the final `Node` -- because the parser + // already stamps the fact exactly, so a Node-reading projection is a SECOND AUTHORITY + // free to diverge from the first. `authored_type_references` below obeys that. + // + // THAT RATIONALE HAS NO REFERENT HERE, and it was checked rather than argued: + // `MatchPattern::VariantPattern { name: String, .. }` -- the head is a String + // `stamp_parsed_pattern`'s VariantPattern arm stamps `field_bindings` ONLY + // `ConstructorOccurrence` is stamped NOWHERE in `v1_compiler_parse` + // occurrence ids are minted per `Node`, so a String can never carry one + // The parser mints nothing for this position, so there is no first authority to be + // second to: reading the authored String is not re-derivation, it is the ONLY + // derivation. A prohibition whose stated reason does not apply is not extended by its + // letter -- doing so would forbid the only available construction in favour of one that + // does not exist. + // + // THE TRANSPORT REPAIR IS BLOCKED ON A RULING, NOT ON A RISK, and that distinction is + // the useful part of this comment. The obvious objection to stamping the head was that + // occurrence ids come from a sequential allocator threaded across a parse, so a new + // stamp shifts every id allocated after it. That objection is ANSWERED: `v2.std.node` + // `content_hash` folds node kind, edge labels and child hashes and NOT the occurrence + // id, so a shifted id cannot move a content hash; and + // `v2.workflow.legacy_binding_delta` states outright that an `OccurrenceId` is not a + // stable cross-compile name BECAUSE the counter is consumed in DFS order and an id + // therefore encodes position in the walk -- naming inserted tokens as exactly the edit + // that shifts ids. The corpus does not merely lack a dependency on id stability, it + // declares that dependency illegitimate. + // + // What remains is not a hazard but an authority question: stamping a new occurrence is + // a change to the parser. So this block is provisional pending that ruling, and when it + // comes the deletion is this `if` and nothing else -- the name then arrives in + // `authored_type_references` with every other authored reference, and the consumer that + // already reads the union does not change at all. // // A pattern head is a reference the seven-slot walk above cannot reach even in principle, // because it is a raw `String` on `MatchPattern` rather than a `Node`. So a module whose @@ -610,44 +668,82 @@ fn collect_reference_occurrences( } } } + }) +} - // AUTHORED CHANNEL TWO: THE DECLARED TYPE PARKED IN `inferred`. - // - // `v1.02_parse` `field_to_child_node` stores a field's DECLARED TYPE as - // `inferred: Present { Resolved { node: } }`. The type is a - // perfectly ordinary Node carrying an authored name; it is simply parked in the one slot - // whose contract says "derived, not authored", so every authored-name reader correctly - // declines to look and the reference vanishes. +/// The parser's own authored type references, read back out of the transport it already +/// produced for this file. +/// +/// THIS RECONSTRUCTS NOTHING. `stamp_parsed_inferred` stamps a declared type as +/// `ParsedOccurrenceReference { TypeOccurrence }`; the spelling is on the index entry's +/// `OccurrenceProjection.authored_name`, and the enclosing declaration is the second element of +/// the reference's own `containment.ancestors`. Every value below is looked up, none is derived +/// from the `Node` tree. +/// +/// WHY `ancestors[1]` IS THE ENCLOSING DECLARATION AND NOT A GUESS. `stamp_parsed_node` pushes +/// its own occurrence onto the ancestor list before descending, so ancestors run outermost-first +/// from the stamp root. The sweep stamps one file per transport, rooted at that file's module +/// node, so `[0]` is the module and `[1]` is the module-scope item containing the reference. +/// A reference with fewer than two ancestors is not inside a module-scope declaration at all and +/// contributes nothing -- it is SKIPPED rather than attributed to the empty string, because an +/// empty enclosing name would key a row that no consumer can join and would read as a real +/// authored reference belonging to a declaration that does not exist. +/// +/// THE CATEGORY FILTER IS THE POINT. Only `TypeOccurrence` references are taken. The transport +/// also carries lexical-value, field and namespace-segment occurrences, and folding those in +/// would reintroduce exactly the over-collection `referenced` is criticised for, with the +/// parser's authority attached to it -- which would be worse than the walk, not better. +fn authored_type_references_from_transport( + transport: &Rc, + declared: &BTreeSet, +) -> BTreeSet<(String, String)> { + let mut by_id: HashMap = HashMap::new(); + for entry in transport.index.entries.iter() { + by_id.insert( + entry.projection.occurrence.value, + entry.projection.authored_name.clone(), + ); + } + + let mut out = BTreeSet::new(); + for reference in transport.references.iter() { + if reference.category != OccurrenceCategory::TypeOccurrence { + continue; + } + let spelling = match by_id.get(&reference.occurrence.value) { + Some(name) if !name.is_empty() => name.clone(), + _ => continue, + }; + let enclosing = match reference.containment.ancestors.get(1) { + Some(ancestor) => match by_id.get(&ancestor.value) { + Some(name) if !name.is_empty() => name.clone(), + _ => continue, + }, + None => continue, + }; + // ONLY REFERENCES ENCLOSED BY A DECLARATION THIS MODULE DECLARES, and this filter is + // load-bearing rather than tidiness -- it was added because its absence broke a live + // arm, and the break was in the dangerous direction. // - // THIS IS A PROJECTION OF ONE AUTHORED CHANNEL, NOT A WALK OF INFERENCE OUTPUT, and the - // distinction is the operator ruling's entire content. Walking inferred generally would - // mint membership from resolved semantic graphs -- fabricated facts that look exactly like - // real ones and are far harder to falsify. What makes the projection exact HERE is a - // property of WHEN this index is built: it is derived in the parse phase, BEFORE inference - // runs, so the only things any `inferred` slot can contain are the ones the PARSER put - // there. Measured, all six such sites in v1.02_parse: the field type above, a type alias's - // right-hand side, a `Callable` return type, an optional return annotation, and two that - // hold a leaf node of the declaration's OWN name. Every one is authored syntax. + // The transport stamps an IMPORT MEMBER NAME as a `TypeOccurrence` reference too, + // enclosed by the import's own target. Measured on the fixture that caught it, + // `import probe.other { gadget }` yields `("probe.other", "gadget")`. Folding that in + // would mean every import is "bound through" by its own member, so + // `UnusedSubjectMembershipRemoved` could never be reached again -- the wall would go + // permanently quiet about unused membership while looking like it had gained precision. + // That is a strictly worse failure than the false green this change exists to close: + // one wrong verdict versus a disposition that can no longer fire. // - // THAT IS A STATEMENT ABOUT THIS TREE AND NOT A LICENCE TO WIDEN. On a post-inference tree - // the same recursion would be the forbidden shape, so this reader's correctness is bound to - // its position in the pipeline. A consumer that moves it after inference must revisit this - // block rather than inherit it. - if let Some(inferred) = node.inferred.as_ref() { - if let InferredNode::Resolved { - node: declared_type, - } = &**inferred - { - collect_reference_occurrences( - declared_type, - source_indices, - in_declaration, - false, - out, - ); - } + // A module-scope declaration name is the right filter and not a proxy for one: the key + // this set contributes to IS `(enclosing declaration, spelling)`, and an import is not a + // declaration. Anything enclosed by something this module does not declare is not a row + // this key shape can express, so it is dropped rather than attributed. + if !declared.contains(&enclosing) { + continue; } - }) + out.insert((enclosing, spelling)); + } + out } /// One module's record, from that one module's parse tree. No corpus, no resolution. @@ -655,6 +751,7 @@ pub fn record_from_module( module: &Rc, source_indices: &Rc>>, rel_path: &str, + transport: &Rc, ) -> ModuleDeclarationRecord { let module_path = authored_name_at(source_indices.clone(), module.clone()); let mut declared = BTreeSet::new(); @@ -741,6 +838,7 @@ pub fn record_from_module( ModuleDeclarationRecord { referenced, + authored_type_references: authored_type_references_from_transport(transport, &declared), declares_construction_justification: declared.contains(CONSTRUCTION_JUSTIFICATION_DECL), is_fixture_carrier: module_is_fixture_carrier(&module_path, rel_path), module_path, diff --git a/src/v1/stage0/src/namespace_wave_admission.rs b/src/v1/stage0/src/namespace_wave_admission.rs index 05fbec9c9fa..c53d4f20e83 100644 --- a/src/v1/stage0/src/namespace_wave_admission.rs +++ b/src/v1/stage0/src/namespace_wave_admission.rs @@ -763,12 +763,24 @@ fn membership_bound_through( record: &ModuleDeclarationRecord, target: &str, ) -> bool { - record.referenced.iter().any(|(_, spelling)| { - declaring_candidates(index, record, spelling).contains(target) - || module_prefix_of(index, spelling) - .map(|(m, _)| m == target) - .unwrap_or(false) - }) + // THE UNION OF BOTH AUTHORED-REFERENCE CHANNELS, and the two are peers rather than one + // widened set for the reason `authored_type_references` states: they have different + // authorities. `referenced` is the index's own walk over the final tree; the other is the + // parser's stamped answer, which reaches a declared type parked in `inferred` that no walk + // over the tree can see. A consumer asking "is anything here bound through that import" + // wants every authored reference regardless of which channel could observe it, so it takes + // both -- and asking only the walk is what let this predicate report a live import as + // unused. + record + .referenced + .iter() + .chain(record.authored_type_references.iter()) + .any(|(_, spelling)| { + declaring_candidates(index, record, spelling).contains(target) + || module_prefix_of(index, spelling) + .map(|(m, _)| m == target) + .unwrap_or(false) + }) } /// How many modules' closures moved in a way this target participates in. @@ -954,7 +966,7 @@ pub fn base_records(rel: &str, content: &str) -> Result = record - .referenced + .authored_type_references .iter() .filter(|(_, name)| name == "Wrapper") .collect(); assert_eq!( wrapper_rows.len(), 1, - "`Wrapper` is authored once, as the declared type of `Held.w`; a second row would mean \ - the projection appended rather than inserted into the keyed set, and every downstream \ - count that reads `referenced` would inflate with it, got: {:?}", + "`Wrapper` is authored once as the declared type of `Held.w`, and the transport is what \ + sees it; a second row means the reader appended rather than inserted, and zero rows \ + means the transport was not consumed, got: {:?}", wrapper_rows ); + assert!( + !record.referenced.iter().any(|(_, name)| name == "Wrapper"), + "a declared field type must NOT also appear in the Node walk's own set -- if it does, \ + something reconstructed it from the tree and the two fields are no longer one \ + authority each, got: {:?}", + record.referenced + ); - // AND THE PATTERN CHANNEL IS PRESENT IN THE SAME RECORD -- otherwise this arm would pass on - // a projection that reads neither channel, which is the shape it exists to exclude. + // AND THE PATTERN HEAD COMES FROM THE AUTHORED STRING, because the parser mints no + // occurrence for it. It belongs in `referenced` and must be absent from the parser's field: + // were it to appear there, the transport would have started carrying it and this reader's + // whole justification would have expired. assert!( record.referenced.iter().any(|(_, name)| name == "Wrapped"), "the variant-pattern head `Wrapped` must be an authored reference, got: {:?}", record.referenced ); + assert!( + !record + .authored_type_references + .iter() + .any(|(_, name)| name == "Wrapped"), + "the transport does not stamp a pattern head today; if this fires, it now does, and the \ + String-reading block in `collect_reference_occurrences` should be deleted rather than \ + kept beside it, got: {:?}", + record.authored_type_references + ); +} + +// AN IMPORT MEMBER IS NOT A REFERENCE, and this arm exists because the first cut of the +// transport reader treated it as one. The transport stamps an import's member name as a +// `TypeOccurrence` enclosed by the import target, so consuming every TypeOccurrence made each +// import bound-through by its own member and `UnusedSubjectMembershipRemoved` became +// unreachable. That is a disposition going permanently quiet, which is worse than the false +// green this change closes -- and it was caught by a PRE-EXISTING arm rather than by review. +#[test] +fn an_import_member_name_is_not_an_authored_reference() { + let dir = scratch("import_member_not_reference"); + author( + &dir, + "other.dag", + "module probe.other\n\ndata gadget: String = \"g\"\n", + ); + author( + &dir, + "consumer.dag", + "module probe.consumer\n\nimport probe.other { gadget }\n\nfn use_it() -> String { \"x\" }\n", + ); + let index = index_of(&dir); + let record = v1_compiler::cli_run::declaration_index::index_get(&index, "probe.consumer") + .expect("PLANT NEVER REACHED: the consumer module must be indexed"); + + assert!( + !record + .authored_type_references + .iter() + .any(|(enclosing, _)| enclosing == "probe.other"), + "`probe.other` is an import target, not a declaration this module declares, so nothing \ + may be keyed under it; a row here means an import member is being counted as a use of \ + itself, got: {:?}", + record.authored_type_references + ); } // THE ARM THAT CATCHES A LAZY PROJECTION. `Wrapper` is declared LOCALLY here and named nowhere