From 2a90d96c685efdd38605712bdb28128b75589b97 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Wed, 23 Sep 2026 23:54:13 +0000 Subject: [PATCH] A qualified path's first segment is a namespace root: the reference-derived producer no longer resolves it to a same-named declaration ExprVarClassification classify treated a dotted chain's head as a namespace segment only when no declaration anywhere shared its spelling, and reference_resolution_facts runs with no declaration index, so the head of every v2.std.* path fell to the free-reference default and resolved UniqueBare to test.claim.secret_rotation_witness fn v2(). A head whose chain names a declared module is now QualifiedChainHead; binders still outrank it and value chains keep their edges. Co-Authored-By: Claude Opus 5.5 (1M context) --- ...t_segment_resolves_as_a_bare_reference.dag | 22 +++++++ ...space_root_reference_edge_witness_test.dag | 44 +++++++++++++ fixtures/namespace_root_homonym/consumer.dag | 5 ++ fixtures/namespace_root_homonym/homonym.dag | 3 + fixtures/namespace_root_homonym/rows.dag | 5 ++ fixtures/namespace_root_homonym/target.dag | 3 + src/v1/stage0/src/cli_run.rs | 64 +++++++++++++++++-- src/v1/stage0/src/cli_run/entry_resolve.rs | 2 + 8 files changed, 142 insertions(+), 6 deletions(-) create mode 100644 dag/gunbc/recurring_failure_mode/a_namespace_root_segment_resolves_as_a_bare_reference.dag create mode 100644 dag/test/claim/namespace_root_reference_edge_witness_test.dag create mode 100644 fixtures/namespace_root_homonym/consumer.dag create mode 100644 fixtures/namespace_root_homonym/homonym.dag create mode 100644 fixtures/namespace_root_homonym/rows.dag create mode 100644 fixtures/namespace_root_homonym/target.dag diff --git a/dag/gunbc/recurring_failure_mode/a_namespace_root_segment_resolves_as_a_bare_reference.dag b/dag/gunbc/recurring_failure_mode/a_namespace_root_segment_resolves_as_a_bare_reference.dag new file mode 100644 index 00000000000..4344d621e49 --- /dev/null +++ b/dag/gunbc/recurring_failure_mode/a_namespace_root_segment_resolves_as_a_bare_reference.dag @@ -0,0 +1,22 @@ +module gunbc.recurring_failure_mode.a_namespace_root_segment_resolves_as_a_bare_reference + +import std.types { NonEmptyStr } +import gunbc.recurring_failure_mode { RecurringFailureMode } + +data a_namespace_root_segment_resolves_as_a_bare_reference: RecurringFailureMode = RecurringFailureMode { + identity: "a_namespace_root_segment_resolves_as_a_bare_reference" as NonEmptyStr, + + receipts: [ + "a namespace root segment resolves as a bare reference: the first segment of a qualified module path -- `v2` in `v2.std.node.Node` -- is classified as a free value reference, and because the pool happens to declare something with that spelling it resolves to that declaration. The reference-derived dependency producer then publishes an edge from the reading module into a module it never names. Nothing refuses: the edge is well formed, the closure it widens still resolves, and every consumer of the closure reads a larger population as the true one.", + + "SPECIMEN, found 2026-09-23 by the Pkg7e emitted-workspace plan (v2.workflow.emitted_crate_workspace), whose two-closure join refused with ClosuresDisagree: the live dependency DAG of v2.compiler.compile carried 214 modules, the seed's emission 190, and the 24 extra included test.claim.secret_rotation_witness. That test module declares `fn v2()`. Every import-free production module reading a `v2.std.*` path -- src/v2/std/artifact, src/v2/std/grounding, src/v2/std/cross_tree/resolution among them -- gained a UniqueBare edge into it, and its imports pulled in the rest.", + + "THE EARLIEST UNJUSTIFIED LINK (DESIGN section 6b), found by walking the producer from its output back: v1_compiler.cli_run ExprVarClassification classify decided that a dotted chain's head is a namespace segment only when NO declaration anywhere shares its spelling, and reference_resolution_facts runs with no declaration index at all, so that arm could never fire there and every head fell to the free-reference default. The dotted scan had already recorded the chain whole and resolved it Qualified by longest_declared_module_prefix; the head's role was a fact the producer held and did not consult. The repair classifies a head whose chain names a declared module as QualifiedChainHead, the member it always belonged to; only a binder outranks it, and a dotted read whose head names no module (a value row's field) keeps its edge.", + + "RUNG: mechanically preventable. test.claim.namespace_root_reference_edge_witness runs the real producer over fixtures/namespace_root_homonym, where nsroot_decl.homonym declares fn nsroot() beside the nsroot.pkg.* modules: the RED asserts no consumer edge to the homonym and FAILED on the unrepaired binary; the control asserts the qualified edge and a value-chain edge both survive. The corpus-scale control is the Pkg7e plan's closure join, which admits only when the live DAG and the emission are the same population.", + + "ATTAINABLE CEILING: structurally guaranteed. NEXT-RUNG TRIGGER, at capability grain: the namespace-cut terminal step (docs/plans/namespace-cut-replacement-plan.md), where dependencies are parse-then-derive from resolved container.member references rather than classified occurrences -- sufficient for a qualified path to reach the dependency graph only as a resolved module identity, so no segment of it is ever looked up as a bare name.", + ], + + evidence: [], +} diff --git a/dag/test/claim/namespace_root_reference_edge_witness_test.dag b/dag/test/claim/namespace_root_reference_edge_witness_test.dag new file mode 100644 index 00000000000..0bd4261a240 --- /dev/null +++ b/dag/test/claim/namespace_root_reference_edge_witness_test.dag @@ -0,0 +1,44 @@ +module test.claim.namespace_root_reference_edge_witness + +import std.types { Bool, List, String } +import v2.lens.module_graph { ImportResolutionFact, reference_derived_import_resolution_facts_live } +import v2.std.live_tree { LiveTreeDisposition, ReadsLiveTree } + +data live_tree_disposition: LiveTreeDisposition = ReadsLiveTree + +// ── A QUALIFIED PATH'S FIRST SEGMENT IS A NAMESPACE ROOT, NEVER A REFERENCE ──────────────────── +// +// The subject is the reference-derived dependency producer (v2.lens.module_graph +// reference_derived_import_resolution_facts_live, realized by the host's +// reference_resolution_facts), run for real over an import-free fixture pool: +// nsroot.pkg.consumer reads nsroot.pkg.target.target_value through the qualified path, and +// nsroot_decl.homonym declares fn nsroot() -- the same spelling as that path's first segment. +// Before the repair the chain head `nsroot` was classified as a bare reference and resolved +// UniqueBare to the homonym fn, so the consumer gained a dependency on a module it never names. +// On the live corpus that was test.claim.secret_rotation_witness's fn v2(): every v2.std.* path in +// an import-free production module grew an edge into a test claim. + +data nsroot_fixture_roots: List = ["fixtures/namespace_root_homonym"] + +fn nsroot_consumer_targets() -> List { + reference_derived_import_resolution_facts_live( + pool_roots: nsroot_fixture_roots, + importer_roots: nsroot_fixture_roots, + exclude_substrings: [] + ) + |> filter(f => ends_with(s: f.path, suffix: "consumer.dag")) + |> map(f => f.import_module) +} + +// RED: the homonym is not a dependency of the consumer. +test fn a_qualified_path_head_never_resolves_to_a_same_named_declaration() -> Bool { + !(nsroot_consumer_targets() |> any(t => t == "nsroot_decl.homonym")) +} + +// CONTROL, same run: the qualified path still yields its own edge, and a dotted read of a real +// value (ns_row.tag, whose head names no module) still yields the edge to the row's module, so the +// repair removes the homonym edge and nothing else. +test fn the_qualified_target_and_a_value_chain_keep_their_edges() -> Bool { + let targets = nsroot_consumer_targets() + (targets |> any(t => t == "nsroot.pkg.target")) && (targets |> any(t => t == "nsroot_decl.rows")) +} diff --git a/fixtures/namespace_root_homonym/consumer.dag b/fixtures/namespace_root_homonym/consumer.dag new file mode 100644 index 00000000000..44a5802b56c --- /dev/null +++ b/fixtures/namespace_root_homonym/consumer.dag @@ -0,0 +1,5 @@ +module nsroot.pkg.consumer + +fn read_target() -> Int { nsroot.pkg.target.target_value } + +fn read_row() -> Int { ns_row.tag } diff --git a/fixtures/namespace_root_homonym/homonym.dag b/fixtures/namespace_root_homonym/homonym.dag new file mode 100644 index 00000000000..88c6e153576 --- /dev/null +++ b/fixtures/namespace_root_homonym/homonym.dag @@ -0,0 +1,3 @@ +module nsroot_decl.homonym + +fn nsroot() -> Int { 0 } diff --git a/fixtures/namespace_root_homonym/rows.dag b/fixtures/namespace_root_homonym/rows.dag new file mode 100644 index 00000000000..667629a5dd9 --- /dev/null +++ b/fixtures/namespace_root_homonym/rows.dag @@ -0,0 +1,5 @@ +module nsroot_decl.rows + +type NsRow { tag: Int } + +data ns_row: NsRow = NsRow { tag: 2 } diff --git a/fixtures/namespace_root_homonym/target.dag b/fixtures/namespace_root_homonym/target.dag new file mode 100644 index 00000000000..19f060b67ae --- /dev/null +++ b/fixtures/namespace_root_homonym/target.dag @@ -0,0 +1,3 @@ +module nsroot.pkg.target + +data target_value: Int = 1 diff --git a/src/v1/stage0/src/cli_run.rs b/src/v1/stage0/src/cli_run.rs index 56d79857342..9a0c64bbe55 100644 --- a/src/v1/stage0/src/cli_run.rs +++ b/src/v1/stage0/src/cli_run.rs @@ -32648,6 +32648,22 @@ fn collect_module_decl_names(module: &Rc) -> Vec, +) -> Option> { + use crate::v1_std_core::ExprData; + let mut cur = node.children.get(0).cloned()?; + loop { + match &*cur.expr_data { + ExprData::ExprFieldAccess { .. } => cur = cur.children.get(0).cloned()?, + ExprData::ExprVar { .. } => return Some(cur), + _ => return None, + } + } +} + fn ref_field_chain(node: &Rc) -> Option> { use crate::v1_std_core::ExprData; let mut segs: Vec = vec![node.name.clone()]; @@ -32799,6 +32815,14 @@ struct ExprVarClassification<'a> { /// empty map, because an empty map would answer "no module declares this name" to every /// question and silently collect nothing: ⊥-as-answer standing in for ⊥-as-ignorance. decl_index: Option<&'a HashMap>>, + /// THE DECLARED MODULE NAMES, so a dotted chain can be asked whether its prefix IS a module + /// path. `None` where the caller holds no module set (the binder fixtures below). + module_names: Option<&'a std::collections::HashSet>, + /// The head `ExprVar` of every dotted chain whose prefix names a declared module, marked when + /// the chain is recorded. Such a head is the ROOT SEGMENT OF A MODULE PATH -- `v2` in + /// `v2.std.node.Node` -- and never a reference to a declaration that happens to share its + /// spelling anywhere in the pool. + module_path_heads: std::collections::HashSet<*const crate::v1_std_core::Node>, tally: &'a mut BTreeMap, unclassified: &'a mut Vec, /// The module currently being walked. ONE classification spans the whole index build so the @@ -32837,7 +32861,13 @@ impl ExprVarClassification<'_> { )); } - fn classify(&mut self, name: &str, bound_as: Option, chain_head: bool) -> bool { + fn classify( + &mut self, + name: &str, + bound_as: Option, + chain_head: bool, + module_path_head: bool, + ) -> bool { self.occurrences += 1; // THE CHAIN HEAD ARM IS ORDERED LAST, AND THE ORDER IS THE WHOLE CORRECTNESS ARGUMENT. // A binder answers first: `fn f(cron: Tab) { cron.List }` reads the parameter. A @@ -32845,12 +32875,21 @@ impl ExprVarClassification<'_> { // MODULE PATH — `some_data_row.field` is an ordinary value reference that merely looks // like one, and suppressing it here DELETES A REAL EDGE. Only a head that is neither // bound nor declared anywhere is the module-path segment this member is for. + // + // A HEAD WHOSE CHAIN NAMES A DECLARED MODULE IS A NAMESPACE ROOT, WHETHER OR NOT SOME + // DECLARATION SHARES ITS SPELLING. The declared-anywhere test above cannot see that case: + // `test.claim.secret_rotation_witness` declares `fn v2()`, so `v2` in `v2.std.node.Node` + // is "declared", and without this arm it became a bare reference that resolved to that + // test fn -- a dependency edge from production std modules into a test claim. The chain + // is already recorded whole and resolved Qualified by its module prefix; its head is that + // path's first segment. Only a binder outranks it, exactly as for the undeclared head. let head_is_undeclared = chain_head && bound_as.is_none() - && self - .decl_index - .map(|index| !index.contains_key(name)) - .unwrap_or(false); + && (module_path_head + || self + .decl_index + .map(|index| !index.contains_key(name)) + .unwrap_or(false)); if head_is_undeclared { *self .tally @@ -33019,6 +33058,13 @@ fn collect_node_refs_inner( let mut receiver_spine = false; if let ExprData::ExprFieldAccess { .. } = &*node.expr_data { if let Some(chain) = ref_field_chain(node) { + if let Some(module_names) = classify.module_names { + if longest_declared_module_prefix(&chain, module_names).is_some() { + if let Some(head) = ref_field_chain_head(node) { + classify.module_path_heads.insert(Rc::as_ptr(&head)); + } + } + } chains.push(chain); receiver_spine = true; } @@ -33042,7 +33088,9 @@ fn collect_node_refs_inner( .rev() .find(|(n, _)| n == &node.name) .map(|(_, class)| *class); - if classify.classify(&node.name, bound_as, chain_receiver) { + let module_path_head = + chain_receiver && classify.module_path_heads.contains(&Rc::as_ptr(node)); + if classify.classify(&node.name, bound_as, chain_receiver, module_path_head) { bare.insert(node.name.clone()); // A free `ExprVar` IS a value-position read: nothing binds it here, so // the interpreter resolves it through the file's declarations, then the @@ -42093,6 +42141,8 @@ pub(crate) fn build_reference_closure_index( let mut unclassified: Vec = Vec::new(); let mut classify = ExprVarClassification { decl_index: Some(&decl_index), + module_names: Some(&module_names), + module_path_heads: std::collections::HashSet::new(), tally: &mut class_tally, unclassified: &mut unclassified, module: String::new(), @@ -45252,6 +45302,8 @@ mod reference_collector_binder_fixtures { let mut unclassified: Vec = Vec::new(); let mut classify = ExprVarClassification { decl_index, + module_names: None, + module_path_heads: std::collections::HashSet::new(), tally: &mut tally, unclassified: &mut unclassified, module: "fixture".to_string(), diff --git a/src/v1/stage0/src/cli_run/entry_resolve.rs b/src/v1/stage0/src/cli_run/entry_resolve.rs index a5b78bde024..6e78fcd832e 100644 --- a/src/v1/stage0/src/cli_run/entry_resolve.rs +++ b/src/v1/stage0/src/cli_run/entry_resolve.rs @@ -2640,6 +2640,8 @@ pub fn reference_resolution_facts( let mut scratch_unclassified: Vec = Vec::new(); let mut classify = ExprVarClassification { decl_index: None, + module_names: Some(&module_names), + module_path_heads: std::collections::HashSet::new(), tally: &mut scratch_tally, unclassified: &mut scratch_unclassified, module: self_module.clone(),