diff --git a/dag/gunbc/declaration_index_seed_growth.dag b/dag/gunbc/declaration_index_seed_growth.dag index 287c4d80d7f..ac78f3ee873 100644 --- a/dag/gunbc/declaration_index_seed_growth.dag +++ b/dag/gunbc/declaration_index_seed_growth.dag @@ -131,6 +131,11 @@ data declaration_index_seed_growth_justification: SeedGrowthJustification = Seed decl_name: "declaration_field_names", field: WholeDeclaration }, + DeclarationRef { + module_path: "v1_compiler.declaration_index", + decl_name: "collect_reference_occurrences", + field: WholeDeclaration + }, DeclarationRef { module_path: "v1_compiler.declaration_index", decl_name: "record_from_module", @@ -324,7 +329,7 @@ data declaration_index_seed_growth_justification: SeedGrowthJustification = Seed ], reason: "WHY RUST IS STILL NEEDED, and it is a REACHABILITY limit rather than a modeling gap: the subject is INGESTION -- the moment a .dag file is read off the filesystem and parsed -- and the only ingestion that executes today is host Rust. A .dag witness cannot reach it: run_required_floor's hermetic envelope refuses host effects during preparation, so a witness whose subject is a filesystem walk is counted executed while its assertion never runs. Asserting the index's behaviour from a mocked file tree would assert the mock, which is the specification-without-execution trap, so the construction and its discriminating evidence both have to live where the walk does.\n\nTHE CONSEQUENCE OF REFUSING IT, stated second because it is not the admitting argument: there is no second executing ingestion to re-home this to, so a refusal would leave both of DESIGN's next-rung triggers -- section 6's module-authorship trigger and section 3's cited-symbol restoration trigger -- undischargeable until v2 is the compiler.\n\nWHAT THE GROWTH BUYS, at identity grain rather than as a category: three walls on one construction, replacing one deleted corpus-walk census and two obligations that had no mechanism at all. It also NETS DOWN inside claim_executor, which loses the --required-cited-symbol mode and its three helper functions.", owning_dissolution_lane: "v1-hand-queue-drain" as RoadmapNodeId, - trigger: "Delete all 61 when INGESTION is itself modeled -- when the .dag source walk and the per-module record it derives are expressed as a .dag operation over a typed filesystem transport, the index becomes an ordinary substrate fold and these declarations move to the substrate. A PARTIAL migration IS admissible and is the expected shape: the moment the RECORD DERIVATION is modeled (record_from_module is a pure function of one parse tree and needs no host effect at all), the record builder and every finding function follow it, while the directory walk and the parse-clean plumbing stay behind for as long as the WALK is host-only; delete those on the second step. What does NOT dissolve them is the checks moving to another host entry point, and what does NOT dissolve them is a hermetic witness asserting a fabricated module tree -- that would retire the evidence while retiring nothing it guards.", + trigger: "Delete all 62 when INGESTION is itself modeled -- when the .dag source walk and the per-module record it derives are expressed as a .dag operation over a typed filesystem transport, the index becomes an ordinary substrate fold and these declarations move to the substrate. A PARTIAL migration IS admissible and is the expected shape: the moment the RECORD DERIVATION is modeled (record_from_module is a pure function of one parse tree and needs no host effect at all), the record builder and every finding function follow it, while the directory walk and the parse-clean plumbing stay behind for as long as the WALK is host-only; delete those on the second step. What does NOT dissolve them is the checks moving to another host entry point, and what does NOT dissolve them is a hermetic witness asserting a fabricated module tree -- that would retire the evidence while retiring nothing it guards.", current_boundary: "src/v1/stage0/src/declaration_index.rs; src/v1/stage0/src/cli_run.rs run_dag_parse_sweep; src/v1/stage0/tests/declaration_index_integrity.rs; src/v1/stage0/src/bin/claim_executor.rs; src/v1/stage0/src/bin/v1_src_dag_parse.rs; dag/gunbc/declaration_index_seed_growth.dag" } @@ -393,3 +398,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." diff --git a/src/v1/stage0/src/declaration_index.rs b/src/v1/stage0/src/declaration_index.rs index df2f9eecb3d..26eee034b93 100644 --- a/src/v1/stage0/src/declaration_index.rs +++ b/src/v1/stage0/src/declaration_index.rs @@ -184,17 +184,25 @@ pub struct ModuleDeclarationRecord { pub decl_fields: BTreeMap>, pub imports: Vec, pub cited: Vec, - /// Every authored NAME OCCURRENCE in this module's own tree, paired with the top-level - /// declaration whose subtree carries it: `(in_declaration, spelling)`. + /// The authored NAME OCCURRENCES in this module's own tree that name something the module + /// reaches, paired with the top-level declaration whose subtree carries it: + /// `(in_declaration, spelling)`. /// /// WHY A NAME OCCURRENCE AND NOT A SEMANTIC REFERENCE. This is derived by walking the /// parsed tree — parse-then-derive, the mechanism DESIGN prescribes after the raw-text /// scanner family was ruled a heuristic — but it is deliberately NOT a resolution: a - /// parameter name, a field name and a `let` binder all land here beside a genuine - /// reference, because telling them apart is the resolver's job and this index resolves - /// nothing across files. The over-collection is SYMMETRIC across two trees, which is the - /// only property its one consumer (`namespace_wave_admission`) needs: a spelling that - /// denotes nothing on both sides contributes no delta. + /// parameter name and a `let` binder still land here beside a genuine reference, because + /// telling THOSE apart is the resolver's job and this index resolves nothing across files. + /// + /// THE OVER-COLLECTION IS BOUNDED RATHER THAN UNLIMITED, and the earlier reasoning for + /// leaving it unbounded is refuted rather than merely narrowed. That reasoning was: the + /// over-collection is SYMMETRIC across the two trees the one consumer + /// (`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 is a function of the CORPUS, so deleting an unrelated + /// declaration moves it under every site that merely spells the same word. The measured + /// specimen and the two kinds now excluded are on `collect_reference_occurrences`, which is + /// also where the remaining members of that class are named. /// /// Dotted spellings are recorded WHOLE as well as by segment, so a reference to /// `v2.std.node.Hash` is observable as naming the module `v2.std.node` and not only as @@ -492,6 +500,94 @@ fn declaration_field_names( out } +/// One declaration's REFERENCE occurrences, recorded into `out` as `(in_declaration, spelling)`. +/// +/// WHY THIS IS SELECTIVE BY NODE KIND AND THE FIRST VERSION WAS NOT. The first version walked +/// every node and took its authored name with no filter, on the argument that over-collection +/// is harmless because it is SYMMETRIC across the two trees the wave wall compares — a spelling +/// that denotes nothing on both sides contributes no delta. THAT ARGUMENT IS REFUTED BY A +/// MEASURED SPECIMEN, and it is refuted in the one direction that matters: symmetry of the +/// COLLECTOR does not give symmetry of the VERDICT, because the supplier set the wall asks for +/// 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 labels never bound to the helper and need no supplier at all, yet each one was +/// collected as a reference, so each one reported base `{that module}` -> head `{}` and the wall +/// raised twelve `NewUnresolvedness` rows against a correct cut. A delta true about the +/// declaration and false about every site it names. +/// +/// THE FIX IS THE SHAPE THE `cited` COLLECTOR ON THE SAME TREE ALREADY USES — decide by node +/// kind, not by name — and exactly two kinds are excluded here: +/// +/// * 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 the value `f`; it does not +/// name a declaration `widget`. The SPELLING is not lost — `dotted_chain` still records +/// `f.widget` whole, which is what `module_prefix_of` needs to tell a module-qualified +/// reference (`probe.home.widget`) from an ordinary projection, and the wall keys on the +/// last segment either way, so a qualified reference keeps its leaf. +/// +/// WHAT THIS DELIBERATELY DOES NOT EXCLUDE, named rather than left to be discovered, because +/// each is the SAME CLASS reached from a different position and none of them is repaired here: +/// a record TYPE declaration's field labels, a named call argument's label, a parameter binder, +/// and a coproduct's variant names are all still collected as references. Measured on a fixture, +/// not predicted: `type Row { tag: String }` contributes `tag`, and `call_it(tag: "z")` +/// contributes `tag`. Each can fabricate the same refusal the record-literal case did, and each +/// needs its own structural discriminator — the parent kinds that carry them (`Connective::Conj`, +/// `ExprCall`) also carry children that ARE real references (a refinement's base type expression +/// is a `Conj` child with a real type name), so a parent-kind rule that covers them cannot be +/// lifted from this one and must be derived against its own fixture. Excluding them by guessing +/// would risk the opposite defect, which is worse: a wall that stops seeing genuine +/// unresolvedness is a decoration. +fn collect_reference_occurrences( + node: &Rc, + source_indices: &Rc>>, + in_declaration: &str, + ident_is_a_field_label: bool, + out: &mut BTreeSet<(String, String)>, +) { + stacker::maybe_grow(512 * 1024, 2 * 1024 * 1024, || { + let is_projection_member = matches!(&*node.expr_data, ExprData::ExprFieldAccess { .. }); + if !ident_is_a_field_label && !is_projection_member { + let name = authored_name_at(source_indices.clone(), node.clone()); + if !name.is_empty() { + out.insert((in_declaration.to_string(), name)); + } + } + if let Some(chain) = dotted_chain(node, source_indices) { + out.insert((in_declaration.to_string(), chain)); + } + let children_are_field_labels = is_record_literal(node); + for c in node.children.iter() { + collect_reference_occurrences( + c, + source_indices, + in_declaration, + children_are_field_labels, + out, + ); + } + for c in node.params.iter() { + collect_reference_occurrences(c, source_indices, in_declaration, false, out); + } + for c in node.properties.iter() { + collect_reference_occurrences(c, source_indices, in_declaration, false, out); + } + for c in node.uses.iter() { + collect_reference_occurrences(c, source_indices, in_declaration, false, out); + } + if let Some(b) = node.body.as_ref() { + collect_reference_occurrences(b, source_indices, in_declaration, false, out); + } + if let Some(t) = node.transport.as_ref() { + collect_reference_occurrences(t, source_indices, in_declaration, false, out); + } + if let Some(t) = node.type_annotation.as_ref() { + collect_reference_occurrences(t, source_indices, in_declaration, false, out); + } + }) +} + /// One module's record, from that one module's parse tree. No corpus, no resolution. pub fn record_from_module( module: &Rc, @@ -572,15 +668,13 @@ pub fn record_from_module( let mut referenced = BTreeSet::new(); for item in module_items(module.clone()).iter() { let in_declaration = authored_name_at(source_indices.clone(), item.clone()); - for_each_node(item, &mut |node| { - let name = authored_name_at(source_indices.clone(), node.clone()); - if !name.is_empty() { - referenced.insert((in_declaration.clone(), name)); - } - if let Some(chain) = dotted_chain(node, source_indices) { - referenced.insert((in_declaration.clone(), chain)); - } - }); + collect_reference_occurrences( + item, + source_indices, + &in_declaration, + false, + &mut referenced, + ); } ModuleDeclarationRecord { diff --git a/src/v1/stage0/tests/namespace_wave_admission.rs b/src/v1/stage0/tests/namespace_wave_admission.rs index cbae88fcd60..dc957dbdf35 100644 --- a/src/v1/stage0/tests/namespace_wave_admission.rs +++ b/src/v1/stage0/tests/namespace_wave_admission.rs @@ -558,3 +558,93 @@ fn a_rename_contributes_its_source_to_the_base_side_and_its_destination_to_the_h "scope is applied per side: a non-`.dag` path enters neither" ); } + +// ── THE FIELD-LABEL PAIR: what a name OCCURRENCE has to be before it can carry a verdict ── +// +// The reference channel this wall reads once collected EVERY authored name in a module's tree. +// A record literal's field label has an authored name, so `Row { widget: "x" }` contributed a +// reference to `widget` — and the supplier set a reference is asked for is a function of the +// CORPUS, so deleting an unrelated declaration of that spelling moved it to empty and the wall +// refused a correct cut. The specimen was gunbc#9106: twelve labels, twelve fabricated +// `NewUnresolvedness` rows. The two arms below are one mutation apart — the SAME deletion of +// the SAME spelling, reached once from a field label and once from a real reference — because +// collecting less is how a wall becomes a decoration, and only the pair can tell the repair +// from that. + +const HOME_ROW: &str = + "module probe.home\n\ntype Row { widget: String }\n\ndata other: String = \"o\"\n"; + +#[test] +fn deleting_a_declaration_a_record_field_label_merely_spells_carries_no_delta() { + let base = "module probe.consumer\n\nimport probe.home { Row }\n\ndata widget: String = \"w\"\n\ndata sample: Row = Row { widget: \"x\" }\n"; + let head = "module probe.consumer\n\nimport probe.home { Row }\n\ndata sample: Row = Row { widget: \"x\" }\n"; + let report = compare( + "field_label", + &[("home.dag", HOME_ROW), ("consumer.dag", base)], + &[("home.dag", HOME_ROW), ("consumer.dag", head)], + ); + // THE PLANT REACHED THE WALL: the record literal survives both sides and the comparison is + // non-empty, so the emptiness below is agreement rather than an index that saw nothing. + assert!( + report.population.binding_rows_compared > 0, + "no binding row was compared, so the absence below is ignorance rather than agreement" + ); + assert!( + dispositions_for(&report, "widget").is_empty(), + "`widget` here is a FIELD LABEL of `Row`, not a reference to the deleted `data widget`. \ + It never bound to that declaration and needs no supplier, so a delta about it is true \ + of the declaration and false of the site it names. got: {:?}", + report + .deltas + .iter() + .map(|d| (disposition_label(d.disposition), d.detail.clone())) + .collect::>() + ); +} + +#[test] +fn deleting_a_declaration_a_body_still_references_is_still_unresolvedness() { + // ONE MUTATION FROM THE ARM ABOVE: the same deletion of the same spelling, reached from a + // real reference instead of a label. If this arm ever goes quiet the repair above has + // stopped the wall seeing genuine unresolvedness, which is worse than the defect it fixed. + let base = "module probe.consumer\n\ndata widget: String = \"w\"\n\nfn use_it() -> String { widget }\n"; + let head = "module probe.consumer\n\nfn use_it() -> String { widget }\n"; + let report = compare( + "real_reference", + &[("consumer.dag", base)], + &[("consumer.dag", head)], + ); + assert_eq!( + dispositions_for(&report, "widget"), + vec![NamespaceDeltaDisposition::NewUnresolvedness], + "a body that names `widget` REFERENCES it; deleting its only declaration leaves the \ + reference denoting nothing and the wall must say so. got: {:?}", + report.deltas + ); +} + +#[test] +fn deleting_a_declaration_a_qualified_spelling_reaches_is_still_unresolvedness() { + // THE PROJECTION HALF OF THE REPAIR, controlled. A field projection's MEMBER name stopped + // being collected as a bare reference — but a module-qualified spelling is authored as the + // same field-access shape, and its whole dotted spelling is still recorded, so this arm is + // the one that fails if the repair took the spelling instead of the member. + let home_head = "module probe.home\n\ndata other: String = \"o\"\n"; + let report = compare( + "qualified_reference", + &[ + ("home.dag", HOME), + ("consumer.dag", CONSUMER_QUALIFIES_HOME), + ], + &[ + ("home.dag", home_head), + ("consumer.dag", CONSUMER_QUALIFIES_HOME), + ], + ); + assert_eq!( + dispositions_for(&report, "widget"), + vec![NamespaceDeltaDisposition::NewUnresolvedness], + "`probe.home.widget` reaches a declaration that no longer exists. got: {:?}", + report.deltas + ); +}