Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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: [],
}
44 changes: 44 additions & 0 deletions dag/test/claim/namespace_root_reference_edge_witness_test.dag
Original file line number Diff line number Diff line change
@@ -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<String> = ["fixtures/namespace_root_homonym"]

fn nsroot_consumer_targets() -> List<String> {
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"))
}
5 changes: 5 additions & 0 deletions fixtures/namespace_root_homonym/consumer.dag
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module nsroot.pkg.consumer

fn read_target() -> Int { nsroot.pkg.target.target_value }

fn read_row() -> Int { ns_row.tag }
3 changes: 3 additions & 0 deletions fixtures/namespace_root_homonym/homonym.dag
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module nsroot_decl.homonym

fn nsroot() -> Int { 0 }
5 changes: 5 additions & 0 deletions fixtures/namespace_root_homonym/rows.dag
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module nsroot_decl.rows

type NsRow { tag: Int }

data ns_row: NsRow = NsRow { tag: 2 }
3 changes: 3 additions & 0 deletions fixtures/namespace_root_homonym/target.dag
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module nsroot.pkg.target

data target_value: Int = 1
64 changes: 58 additions & 6 deletions src/v1/stage0/src/cli_run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32648,6 +32648,22 @@ fn collect_module_decl_names(module: &Rc<crate::v1_std_core::Node>) -> Vec<Strin
/// Reconstruct a qualified-name segment list from a `FieldAccess` chain (`A.B.c` → `[A, B, c]`).
/// `None` when the base is not a plain identifier (e.g. a call result `f(x).field` — that is a
/// value field access, not a module-qualified name).
/// The head `ExprVar` of a dotted chain: the node `ref_field_chain` stops at, along the same
/// receiver spine.
fn ref_field_chain_head(
node: &Rc<crate::v1_std_core::Node>,
) -> Option<Rc<crate::v1_std_core::Node>> {
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<crate::v1_std_core::Node>) -> Option<Vec<String>> {
use crate::v1_std_core::ExprData;
let mut segs: Vec<String> = vec![node.name.clone()];
Expand Down Expand Up @@ -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<String, std::collections::BTreeSet<String>>>,
/// 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<String>>,
/// 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<ExprVarClass, usize>,
unclassified: &'a mut Vec<String>,
/// The module currently being walked. ONE classification spans the whole index build so the
Expand Down Expand Up @@ -32837,20 +32861,35 @@ impl ExprVarClassification<'_> {
));
}

fn classify(&mut self, name: &str, bound_as: Option<ExprVarClass>, chain_head: bool) -> bool {
fn classify(
&mut self,
name: &str,
bound_as: Option<ExprVarClass>,
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
// DECLARATION answers second, because the dotted scan owns only chains whose prefix is a
// 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
Expand Down Expand Up @@ -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;
}
Expand All @@ -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
Expand Down Expand Up @@ -42093,6 +42141,8 @@ pub(crate) fn build_reference_closure_index(
let mut unclassified: Vec<String> = 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(),
Expand Down Expand Up @@ -45252,6 +45302,8 @@ mod reference_collector_binder_fixtures {
let mut unclassified: Vec<String> = 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(),
Expand Down
2 changes: 2 additions & 0 deletions src/v1/stage0/src/cli_run/entry_resolve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2640,6 +2640,8 @@ pub fn reference_resolution_facts(
let mut scratch_unclassified: Vec<String> = 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(),
Expand Down