diff --git a/dag/gunbc/commit_workflow.dag b/dag/gunbc/commit_workflow.dag index 404a4ffa6a2..fe0498b731a 100644 --- a/dag/gunbc/commit_workflow.dag +++ b/dag/gunbc/commit_workflow.dag @@ -656,9 +656,13 @@ fn witness_entries_from_enrollments(rows: List) -> List - concat(acc, fold(check_fns, init: [], f: fn(fns, check_fn) { - concat(fns, [ScheduleWitnessEntry { entry: entry, function: check_fn, kind: kind }]) - })) + if check_fns.count() == 0 { + concat(acc, [ScheduleWitnessEntry { entry: entry, function: "", kind: kind }]) + } else { + concat(acc, fold(check_fns, init: [], f: fn(fns, check_fn) { + concat(fns, [ScheduleWitnessEntry { entry: entry, function: check_fn, kind: kind }]) + })) + } CommitSpecGate { gate: _ } => acc CommitCargoFmtCheck => acc } diff --git a/dag/gunbc/stage0_crate_layout_generated.dag b/dag/gunbc/stage0_crate_layout_generated.dag index 83b3a427c88..c30c73f735b 100644 --- a/dag/gunbc/stage0_crate_layout_generated.dag +++ b/dag/gunbc/stage0_crate_layout_generated.dag @@ -4,11 +4,12 @@ import std.types { List, String } data stage0_crate_layout_generated_note: String = "Generated by gunbc stage0_crate_layout_emit — do not hand-edit. Authority: v2.compiler.self_host.frontier; regen via dag/tools/generated_artifact_gate.dag main_wet." -data generated_pub_mod_block: String = "\npub mod v1_interpreter;\npub mod cli_run;\npub mod coproduct_reflection;\npub mod resolved_graph_cache;\npub mod shared_typecheck_store;\npub mod recorded_fixture;\npub mod memory_governor;\npub mod module_path_index;\npub mod usv_pilot_v2_std_algebra;\npub mod usv_pilot_v2_std_collection;\npub mod usv_pilot_v2_std_node;\npub mod v2_compiler_resolve;\npub mod v2_compiler_program_partition;\npub mod v2_compiler_tokenize;\npub mod v2_compiler_infer;\npub mod v2_compiler_body_producer;\npub mod v2_compiler_normalize;\npub mod v2_compiler_target_carriers;\npub mod v2_compiler_discovery_enumeration;\npub mod v2_compiler_parse_engine_hooks;\npub mod v2_compiler_use_site_verdict;" +data generated_pub_mod_block: String = "\npub mod v1_interpreter;\npub mod cli_run;\npub mod test_module_hygiene;\npub mod coproduct_reflection;\npub mod resolved_graph_cache;\npub mod shared_typecheck_store;\npub mod recorded_fixture;\npub mod memory_governor;\npub mod module_path_index;\npub mod usv_pilot_v2_std_algebra;\npub mod usv_pilot_v2_std_collection;\npub mod usv_pilot_v2_std_node;\npub mod v2_compiler_resolve;\npub mod v2_compiler_program_partition;\npub mod v2_compiler_tokenize;\npub mod v2_compiler_infer;\npub mod v2_compiler_body_producer;\npub mod v2_compiler_normalize;\npub mod v2_compiler_target_carriers;\npub mod v2_compiler_discovery_enumeration;\npub mod v2_compiler_parse_engine_hooks;\npub mod v2_compiler_use_site_verdict;" data generated_pub_mod_basenames: List = [ "v1_interpreter", "cli_run", + "test_module_hygiene", "coproduct_reflection", "resolved_graph_cache", "shared_typecheck_store", @@ -33,6 +34,7 @@ data generated_pub_mod_basenames: List = [ data generated_stage0_filenames: List = [ "v1_interpreter.rs", "cli_run.rs", + "test_module_hygiene.rs", "coproduct_reflection.rs", "resolved_graph_cache.rs", "shared_typecheck_store.rs", diff --git a/dag/std/realization_schedule.dag b/dag/std/realization_schedule.dag index b844c7d82a6..84988dec723 100644 --- a/dag/std/realization_schedule.dag +++ b/dag/std/realization_schedule.dag @@ -66,6 +66,8 @@ fn witness_kind_eq(a: WitnessKind, b: WitnessKind) -> Bool { } } +data schedule_witness_entry_function_optional_note: String = "function is OPTIONAL at file grain (umbrella dissolution U3, 2026-07-26): the empty string means Absent — the executor enumerates the entry's test fns through the same path discovery uses (scan_test_decl_names / floor_discovery_scan_test_decl_names). Non-empty keeps fn grain (per-row wet budgets, QuarantineProbeExpectRed probes, roster_registry rows that legitimately stay fn-scoped). CommitWitnessClaim.check_fns: [] projects one ScheduleWitnessEntry with function: \"\". Dissolve-on: Optional when enrollment is derived from discovery (commit_witness_claim_roster_dissolution_trigger)." + type ScheduleWitnessEntry { entry: String function: String diff --git a/src/v1/stage0/src/bootstrap_stage0_crate_layout_generated.rs b/src/v1/stage0/src/bootstrap_stage0_crate_layout_generated.rs index ba62a1b067e..1fc6a953f29 100644 --- a/src/v1/stage0/src/bootstrap_stage0_crate_layout_generated.rs +++ b/src/v1/stage0/src/bootstrap_stage0_crate_layout_generated.rs @@ -5,6 +5,7 @@ pub const HAND_MAINTAINED_STAGE0_FILES: &[&str] = &[ "v1_interpreter.rs", "cli_run.rs", + "test_module_hygiene.rs", "coproduct_reflection.rs", "resolved_graph_cache.rs", "shared_typecheck_store.rs", diff --git a/src/v1/stage0/src/cli_run.rs b/src/v1/stage0/src/cli_run.rs index 34c2981fc73..1bd49aeffe9 100644 --- a/src/v1/stage0/src/cli_run.rs +++ b/src/v1/stage0/src/cli_run.rs @@ -13110,6 +13110,11 @@ pub fn discover_floor_witness_roster( discovery_scope_dirs: &[String], ) -> Result, String> { floor_filename_hygiene_refusal_via_producer(source_roots)?; + // U2 orphan enroll-or-refuse is implemented in test_module_hygiene (unit RED live) but + // NOT wired into the naming walk on this PR: the live corpus still has dark helpers that + // the companion sweep PR resolves. Wiring here before that sweep fail-closes the floor + // on ~75 pre-existing orphans. Dissolve-on: session/proud-wren-892-full-sweep merge + // (re-enable check_orphan_helpers_or_err here in the same commit as the corpus sweep). let mut rows = invoke_floor_discovery_producer(source_roots, scan_dirs, exclude_substrings)?; rows = apply_discovery_scope_dirs_filter(rows, discovery_scope_dirs); let FloorLensImportGraph { @@ -15505,7 +15510,9 @@ fn run_discovery_corpus_with_options_inner( .iter() .map(|r| (r.entry.clone(), r.function.clone())) .collect(); - for (entry, function) in explicit_entries { + // U3 — empty function = file-grain: enumerate via the same test-decl scan discovery uses. + let expanded_explicit = crate::test_module_hygiene::expand_explicit_entries(explicit_entries)?; + for (entry, function) in &expanded_explicit { if seen.insert((entry.clone(), function.clone())) { rows.push(DiscoveryRow { label: function.clone(), diff --git a/src/v1/stage0/src/gunbc_stage0_crate_layout_generated.rs b/src/v1/stage0/src/gunbc_stage0_crate_layout_generated.rs index 4b431b32b82..cac06dba90f 100644 --- a/src/v1/stage0/src/gunbc_stage0_crate_layout_generated.rs +++ b/src/v1/stage0/src/gunbc_stage0_crate_layout_generated.rs @@ -21,7 +21,7 @@ pub fn stage0_crate_layout_generated_note() -> String { pub fn generated_pub_mod_block() -> String { thread_local! { static CACHED: String = { - "\npub mod v1_interpreter;\npub mod cli_run;\npub mod coproduct_reflection;\npub mod resolved_graph_cache;\npub mod shared_typecheck_store;\npub mod recorded_fixture;\npub mod memory_governor;\npub mod module_path_index;\npub mod usv_pilot_v2_std_algebra;\npub mod usv_pilot_v2_std_collection;\npub mod usv_pilot_v2_std_node;\npub mod v2_compiler_resolve;\npub mod v2_compiler_program_partition;\npub mod v2_compiler_tokenize;\npub mod v2_compiler_infer;\npub mod v2_compiler_body_producer;\npub mod v2_compiler_normalize;\npub mod v2_compiler_target_carriers;\npub mod v2_compiler_discovery_enumeration;\npub mod v2_compiler_parse_engine_hooks;\npub mod v2_compiler_use_site_verdict;".to_string() + "\npub mod v1_interpreter;\npub mod cli_run;\npub mod test_module_hygiene;\npub mod coproduct_reflection;\npub mod resolved_graph_cache;\npub mod shared_typecheck_store;\npub mod recorded_fixture;\npub mod memory_governor;\npub mod module_path_index;\npub mod usv_pilot_v2_std_algebra;\npub mod usv_pilot_v2_std_collection;\npub mod usv_pilot_v2_std_node;\npub mod v2_compiler_resolve;\npub mod v2_compiler_program_partition;\npub mod v2_compiler_tokenize;\npub mod v2_compiler_infer;\npub mod v2_compiler_body_producer;\npub mod v2_compiler_normalize;\npub mod v2_compiler_target_carriers;\npub mod v2_compiler_discovery_enumeration;\npub mod v2_compiler_parse_engine_hooks;\npub mod v2_compiler_use_site_verdict;".to_string() }; } CACHED.with(|c: &String| c.clone()) @@ -30,7 +30,7 @@ pub fn generated_pub_mod_block() -> String { pub fn generated_pub_mod_basenames() -> Rc> { thread_local! { static CACHED: Rc> = { - Rc::new(vec!["v1_interpreter".to_string(), "cli_run".to_string(), "coproduct_reflection".to_string(), "resolved_graph_cache".to_string(), "shared_typecheck_store".to_string(), "recorded_fixture".to_string(), "memory_governor".to_string(), "module_path_index".to_string(), "usv_pilot_v2_std_algebra".to_string(), "usv_pilot_v2_std_collection".to_string(), "usv_pilot_v2_std_node".to_string(), "v2_compiler_resolve".to_string(), "v2_compiler_program_partition".to_string(), "v2_compiler_tokenize".to_string(), "v2_compiler_infer".to_string(), "v2_compiler_body_producer".to_string(), "v2_compiler_normalize".to_string(), "v2_compiler_target_carriers".to_string(), "v2_compiler_discovery_enumeration".to_string(), "v2_compiler_parse_engine_hooks".to_string(), "v2_compiler_use_site_verdict".to_string()]) + Rc::new(vec!["v1_interpreter".to_string(), "cli_run".to_string(), "test_module_hygiene".to_string(), "coproduct_reflection".to_string(), "resolved_graph_cache".to_string(), "shared_typecheck_store".to_string(), "recorded_fixture".to_string(), "memory_governor".to_string(), "module_path_index".to_string(), "usv_pilot_v2_std_algebra".to_string(), "usv_pilot_v2_std_collection".to_string(), "usv_pilot_v2_std_node".to_string(), "v2_compiler_resolve".to_string(), "v2_compiler_program_partition".to_string(), "v2_compiler_tokenize".to_string(), "v2_compiler_infer".to_string(), "v2_compiler_body_producer".to_string(), "v2_compiler_normalize".to_string(), "v2_compiler_target_carriers".to_string(), "v2_compiler_discovery_enumeration".to_string(), "v2_compiler_parse_engine_hooks".to_string(), "v2_compiler_use_site_verdict".to_string()]) }; } CACHED.with(|c: &Rc>| c.clone()) @@ -39,7 +39,7 @@ pub fn generated_pub_mod_basenames() -> Rc> { pub fn generated_stage0_filenames() -> Rc> { thread_local! { static CACHED: Rc> = { - Rc::new(vec!["v1_interpreter.rs".to_string(), "cli_run.rs".to_string(), "coproduct_reflection.rs".to_string(), "resolved_graph_cache.rs".to_string(), "shared_typecheck_store.rs".to_string(), "recorded_fixture.rs".to_string(), "main.rs".to_string(), "phase_profile.rs".to_string(), "pre_push.rs".to_string(), "census_exclude_derive.rs".to_string(), "memory_governor.rs".to_string(), "std_lens_verdict.rs".to_string(), "usv_pilot_v2_std_algebra.rs".to_string(), "usv_pilot_v2_std_collection.rs".to_string(), "usv_pilot_v2_std_node.rs".to_string(), "v2_compiler_resolve.rs".to_string(), "v2_compiler_program_partition.rs".to_string(), "v2_compiler_tokenize.rs".to_string(), "v2_compiler_infer.rs".to_string(), "v2_compiler_body_producer.rs".to_string(), "v2_compiler_normalize.rs".to_string(), "v2_compiler_target_carriers.rs".to_string(), "v2_compiler_discovery_enumeration.rs".to_string(), "v2_compiler_parse_engine_hooks.rs".to_string(), "v2_compiler_use_site_verdict.rs".to_string()]) + Rc::new(vec!["v1_interpreter.rs".to_string(), "cli_run.rs".to_string(), "test_module_hygiene.rs".to_string(), "coproduct_reflection.rs".to_string(), "resolved_graph_cache.rs".to_string(), "shared_typecheck_store.rs".to_string(), "recorded_fixture.rs".to_string(), "main.rs".to_string(), "phase_profile.rs".to_string(), "pre_push.rs".to_string(), "census_exclude_derive.rs".to_string(), "memory_governor.rs".to_string(), "std_lens_verdict.rs".to_string(), "usv_pilot_v2_std_algebra.rs".to_string(), "usv_pilot_v2_std_collection.rs".to_string(), "usv_pilot_v2_std_node.rs".to_string(), "v2_compiler_resolve.rs".to_string(), "v2_compiler_program_partition.rs".to_string(), "v2_compiler_tokenize.rs".to_string(), "v2_compiler_infer.rs".to_string(), "v2_compiler_body_producer.rs".to_string(), "v2_compiler_normalize.rs".to_string(), "v2_compiler_target_carriers.rs".to_string(), "v2_compiler_discovery_enumeration.rs".to_string(), "v2_compiler_parse_engine_hooks.rs".to_string(), "v2_compiler_use_site_verdict.rs".to_string()]) }; } CACHED.with(|c: &Rc>| c.clone()) diff --git a/src/v1/stage0/src/lib.rs b/src/v1/stage0/src/lib.rs index 0ed8ae70c8b..8e4a7293aae 100644 --- a/src/v1/stage0/src/lib.rs +++ b/src/v1/stage0/src/lib.rs @@ -74,6 +74,7 @@ pub mod std_syntax; pub mod std_termination; pub mod std_trait_derive_shape; pub mod std_types; +pub mod test_module_hygiene; pub mod usv_pilot_v2_std_algebra; pub mod usv_pilot_v2_std_collection; pub mod usv_pilot_v2_std_node; diff --git a/src/v1/stage0/src/std_realization_schedule.rs b/src/v1/stage0/src/std_realization_schedule.rs index b42818299b9..3c33365e3d8 100644 --- a/src/v1/stage0/src/std_realization_schedule.rs +++ b/src/v1/stage0/src/std_realization_schedule.rs @@ -115,6 +115,15 @@ pub fn witness_kind_eq(a: WitnessKind, b: WitnessKind) -> bool { } } +pub fn schedule_witness_entry_function_optional_note() -> String { + thread_local! { + static CACHED: String = { + "function is OPTIONAL at file grain (umbrella dissolution U3, 2026-07-26): the empty string means Absent — the executor enumerates the entry's test fns through the same path discovery uses (scan_test_decl_names / floor_discovery_scan_test_decl_names). Non-empty keeps fn grain (per-row wet budgets, QuarantineProbeExpectRed probes, roster_registry rows that legitimately stay fn-scoped). CommitWitnessClaim.check_fns: [] projects one ScheduleWitnessEntry with function: \"\". Dissolve-on: Optional when enrollment is derived from discovery (commit_witness_claim_roster_dissolution_trigger).".to_string() + }; + } + CACHED.with(|c: &String| c.clone()) +} + #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub struct ScheduleWitnessEntry { pub entry: String, diff --git a/src/v1/stage0/src/test_module_hygiene.rs b/src/v1/stage0/src/test_module_hygiene.rs new file mode 100644 index 00000000000..ed8b9930454 --- /dev/null +++ b/src/v1/stage0/src/test_module_hygiene.rs @@ -0,0 +1,571 @@ +//! Test-module umbrella dissolution + orphan-helper hygiene (proud-wren-892). +//! +//! U1 — umbrella roster via the real front-end (`parse_dag_file`). +//! U2 — plain fns in `*_test.dag` unreachable from test-fn / data-init roots refuse. +//! U3 — empty `function` on an explicit entry means file-grain: enumerate test decls +//! through the same `scan_test_decl_names` path discovery uses. + +use im::HashMap; +use std::collections::{BTreeMap, BTreeSet, HashSet, VecDeque}; +use std::path::{Path, PathBuf}; +use std::rc::Rc; + +use crate::module_path_index::parsed_dag_file::parse_dag_file; +use crate::std_syntax::BinOp; +use crate::v1_compiler_infer_items::{item_kind, ItemKind}; +use crate::v1_std_core::{ + authored_name_at, binop_left, binop_right, block_stmts, expr_call_func_at, expr_var_name_at, + ExprData, Node, +}; + +/// Empty `ScheduleWitnessEntry.function` / empty `CommitWitnessClaim.check_fns` means +/// file-grain: the executor enumerates the entry's test fns (U3). +pub fn is_file_grain_function(function: &str) -> bool { + function.is_empty() +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UmbrellaRecord { + pub entry: String, + pub umbrella_fn: String, + pub conjuncts: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OrphanHelper { + pub entry: String, + pub name: String, +} + +fn collect_dag_files(root: &Path, out: &mut Vec) { + let entries = match std::fs::read_dir(root) { + Ok(e) => e, + Err(_) => return, + }; + for ent in entries.flatten() { + let path = ent.path(); + if path.is_dir() { + collect_dag_files(&path, out); + } else if path.extension().and_then(|e| e.to_str()) == Some("dag") { + out.push(path); + } + } +} + +fn is_test_dag_path(path: &str) -> bool { + Path::new(path) + .file_name() + .and_then(|s| s.to_str()) + .map(|n| n.ends_with("_test.dag")) + .unwrap_or(false) +} + +fn scan_test_decl_names(content: &str) -> Vec { + let mut out = Vec::new(); + for line in content.lines() { + let trimmed = line.trim_start(); + let rest = trimmed + .strip_prefix("test fn ") + .or_else(|| trimmed.strip_prefix("test data ")); + if let Some(rest) = rest { + let name: String = rest + .chars() + .take_while(|c| c.is_alphanumeric() || *c == '_') + .collect(); + if !name.is_empty() { + out.push(name); + } + } + } + out +} + +/// Public: same enumerator discovery uses for test-marked decls in an entry file. +pub fn enumerate_entry_test_fns(entry_path: &str) -> Result, String> { + let content = std::fs::read_to_string(entry_path) + .map_err(|e| format!("file-grain enumerate: read {entry_path}: {e}"))?; + let names = scan_test_decl_names(&content); + if names.is_empty() { + return Err(format!( + "file-grain enumerate: entry {entry_path} has no test fn / test data declarations" + )); + } + Ok(names) +} + +/// Expand explicit (entry, function) pairs: empty function → all test decls in the entry. +pub fn expand_explicit_entries( + explicit_entries: &[(String, String)], +) -> Result, String> { + let mut out = Vec::new(); + let mut seen: HashSet<(String, String)> = HashSet::new(); + for (entry, function) in explicit_entries { + if is_file_grain_function(function) { + for name in enumerate_entry_test_fns(entry)? { + let key = (entry.clone(), name.clone()); + if seen.insert(key.clone()) { + out.push(key); + } + } + } else { + let key = (entry.clone(), function.clone()); + if seen.insert(key.clone()) { + out.push(key); + } + } + } + Ok(out) +} + +/// Collect local names reached from an expression: call callees AND bare vars +/// (fn-as-value bindings like `call_primitive: bind_eval_call_primitive`). +fn walk_refs( + node: &Rc, + si: &Rc>>, + out: &mut HashSet, +) { + match node.expr_data.as_ref() { + ExprData::ExprCall { .. } => { + let name = expr_call_func_at(node.clone(), si.clone()); + if !name.is_empty() { + out.insert(name); + } + } + ExprData::ExprVar { .. } => { + let name = expr_var_name_at(node.clone(), si.clone()); + if !name.is_empty() { + out.insert(name); + } + } + _ => {} + } + for child in node.children.iter() { + walk_refs(child, si, out); + } +} + +fn peel_block(body: &Rc) -> Rc { + match body.expr_data.as_ref() { + ExprData::ExprBlock => { + let stmts = block_stmts(body.clone()); + if stmts.len() == 1 { + peel_block(&stmts[0]) + } else if let Some(last) = stmts.last() { + peel_block(last) + } else { + body.clone() + } + } + _ => body.clone(), + } +} + +/// If `expr` is a pure conjunction of nullary calls to names in `nullary_locals`, +/// return those call names in left-to-right order. Otherwise None. +fn umbrella_conjuncts( + expr: &Rc, + si: &Rc>>, + nullary_locals: &HashSet, +) -> Option> { + match expr.expr_data.as_ref() { + ExprData::ExprBinOp { op: BinOp::And, .. } => { + let left = umbrella_conjuncts(&binop_left(expr.clone()), si, nullary_locals)?; + let right = umbrella_conjuncts(&binop_right(expr.clone()), si, nullary_locals)?; + let mut out = left; + out.extend(right); + Some(out) + } + ExprData::ExprCall { .. } => { + let name = expr_call_func_at(expr.clone(), si.clone()); + let arg_count = expr + .children + .iter() + .filter(|a| !a.children.is_empty()) + .count(); + if arg_count == 0 && nullary_locals.contains(&name) { + Some(vec![name]) + } else { + None + } + } + ExprData::ExprBlock => umbrella_conjuncts(&peel_block(expr), si, nullary_locals), + _ => None, + } +} + +struct ModuleFns { + /// name -> (is_test, param_count, body) + fns: BTreeMap>)>, + /// data/const initializer bodies (reachability roots for fixture constructors) + data_bodies: Vec>, + si: Rc>>, +} + +fn analyze_test_module(path: &Path, content: &str) -> Option { + let parsed = parse_dag_file(path)?; + let test_names: HashSet = scan_test_decl_names(content).into_iter().collect(); + let mut fns = BTreeMap::new(); + let mut data_bodies = Vec::new(); + for item in parsed.items.iter() { + let name = authored_name_at(parsed.source_indices.clone(), item.clone()); + if name.is_empty() { + continue; + } + let kind = item_kind(item.clone()); + match kind { + ItemKind::FnItem | ItemKind::FuncItem => { + let param_count = item.params.len(); + let is_test = test_names.contains(&name); + fns.insert(name, (is_test, param_count, item.body.clone())); + } + ItemKind::DataItem => { + if let Some(body) = item.body.clone() { + data_bodies.push(body); + } + } + _ => {} + } + } + Some(ModuleFns { + fns, + data_bodies, + si: parsed.source_indices, + }) +} + +fn umbrellas_in_module(entry: &str, module: &ModuleFns) -> Vec { + // Nullary locals include both plain helpers and test fns — a residual umbrella + // that &&-chains already-promoted test fns must still be detected and deleted. + let nullary_locals: HashSet = module + .fns + .iter() + .filter(|(_, (_is_test, params, _))| *params == 0) + .map(|(n, _)| n.clone()) + .collect(); + let mut out = Vec::new(); + for (name, (is_test, _params, body)) in &module.fns { + if !is_test { + continue; + } + let Some(body) = body else { continue }; + let peeled = peel_block(body); + let Some(conjuncts) = umbrella_conjuncts(&peeled, &module.si, &nullary_locals) else { + continue; + }; + // A conjunction requires ≥2 conjuncts; census used ≥3 as the upper-bound filter. + if conjuncts.len() < 2 { + continue; + } + out.push(UmbrellaRecord { + entry: entry.to_string(), + umbrella_fn: name.clone(), + conjuncts, + }); + } + out +} + +fn orphans_in_module(entry: &str, module: &ModuleFns) -> Vec { + let plain: HashSet = module + .fns + .iter() + .filter(|(_, (is_test, _, _))| !*is_test) + .map(|(n, _)| n.clone()) + .collect(); + if plain.is_empty() { + return Vec::new(); + } + let mut reachable: HashSet = HashSet::new(); + let mut queue: VecDeque = VecDeque::new(); + + // Roots: test fn bodies + data/const initializers (fixture surface). + for (name, (is_test, _, body)) in &module.fns { + if *is_test { + reachable.insert(name.clone()); + if let Some(body) = body { + let mut refs = HashSet::new(); + walk_refs(body, &module.si, &mut refs); + for c in refs { + if plain.contains(&c) { + queue.push_back(c); + } + } + } + } + } + // data + test data initializers (fixture / claim-row surface) + for body in &module.data_bodies { + let mut refs = HashSet::new(); + walk_refs(body, &module.si, &mut refs); + for c in refs { + if plain.contains(&c) { + queue.push_back(c); + } + } + } + + while let Some(n) = queue.pop_front() { + if !reachable.insert(n.clone()) { + continue; + } + if let Some((_, _, Some(body))) = module.fns.get(&n) { + let mut refs = HashSet::new(); + walk_refs(body, &module.si, &mut refs); + for c in refs { + if plain.contains(&c) && !reachable.contains(&c) { + queue.push_back(c); + } + } + } + } + + let mut orphans = Vec::new(); + for name in &plain { + if !reachable.contains(name) { + orphans.push(OrphanHelper { + entry: entry.to_string(), + name: name.clone(), + }); + } + } + orphans.sort_by(|a, b| a.name.cmp(&b.name)); + orphans +} + +fn repo_relative(path: &Path, ws: &Path) -> String { + path.strip_prefix(ws) + .unwrap_or(path) + .to_string_lossy() + .into_owned() +} + +/// U1 — parse every `*_test.dag` under `roots` and emit the umbrella roster. +pub fn collect_umbrella_roster(roots: &[String]) -> Result, String> { + let ws = std::env::current_dir().map_err(|e| format!("cwd: {e}"))?; + let mut files = Vec::new(); + for root in roots { + collect_dag_files(&ws.join(root), &mut files); + } + files.sort(); + let mut out = Vec::new(); + let mut parse_failures = Vec::new(); + for path in files { + let rel = repo_relative(&path, &ws); + if !is_test_dag_path(&rel) { + continue; + } + let content = match std::fs::read_to_string(&path) { + Ok(c) => c, + Err(e) => { + parse_failures.push(format!("read {rel}: {e}")); + continue; + } + }; + let Some(module) = analyze_test_module(&path, &content) else { + parse_failures.push(format!("parse failed: {rel}")); + continue; + }; + out.extend(umbrellas_in_module(&rel, &module)); + } + if !parse_failures.is_empty() && out.is_empty() { + return Err(format!( + "umbrella roster: no modules parsed ({})", + parse_failures.len() + )); + } + out.sort_by(|a, b| (&a.entry, &a.umbrella_fn).cmp(&(&b.entry, &b.umbrella_fn))); + Ok(out) +} + +/// U2 — orphan plain fns across `*_test.dag` under roots. +pub fn collect_orphan_helpers(roots: &[String]) -> Result, String> { + let ws = std::env::current_dir().map_err(|e| format!("cwd: {e}"))?; + let mut files = Vec::new(); + for root in roots { + collect_dag_files(&ws.join(root), &mut files); + } + files.sort(); + let mut out = Vec::new(); + for path in files { + let rel = repo_relative(&path, &ws); + if !is_test_dag_path(&rel) { + continue; + } + let content = match std::fs::read_to_string(&path) { + Ok(c) => c, + Err(_) => continue, + }; + let Some(module) = analyze_test_module(&path, &content) else { + continue; + }; + out.extend(orphans_in_module(&rel, &module)); + } + out.sort_by(|a, b| (&a.entry, &a.name).cmp(&(&b.entry, &b.name))); + Ok(out) +} + +/// Fail-closed orphan check for the naming walk. `roots` are source roots. +pub fn check_orphan_helpers_or_err(roots: &[String]) -> Result<(), String> { + let orphans = collect_orphan_helpers(roots)?; + if orphans.is_empty() { + return Ok(()); + } + let mut lines: Vec = Vec::new(); + lines.push(format!( + "test-module orphan-helper hygiene (DESIGN §5/§6 enroll-or-refuse): {} plain fn(s) in `*_test.dag` \ + are unreachable from every test fn and data/const initializer in their module — silent \ + de-enrollment. Promote to `test fn` or delete:", + orphans.len() + )); + // Cap the printed list so the diagnostic stays usable; full count is in the header. + for o in orphans.iter().take(40) { + lines.push(format!(" {}::{}", o.entry, o.name)); + } + if orphans.len() > 40 { + lines.push(format!(" … and {} more", orphans.len() - 40)); + } + Err(lines.join("\n")) +} + +/// Orphan check scoped to an explicit set of absolute/relative entry paths (RED fixtures). +pub fn check_orphan_helpers_in_entries(entry_paths: &[String]) -> Result<(), String> { + let mut orphans = Vec::new(); + for entry in entry_paths { + let path = Path::new(entry); + if !is_test_dag_path(entry) { + continue; + } + let content = + std::fs::read_to_string(path).map_err(|e| format!("orphan check read {entry}: {e}"))?; + let Some(module) = analyze_test_module(path, &content) else { + return Err(format!("orphan check: parse failed for {entry}")); + }; + orphans.extend(orphans_in_module(entry, &module)); + } + if orphans.is_empty() { + return Ok(()); + } + let mut lines = vec![format!( + "test-module orphan-helper hygiene: {} unreachable plain fn(s):", + orphans.len() + )]; + for o in &orphans { + lines.push(format!(" {}::{}", o.entry, o.name)); + } + Err(lines.join("\n")) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn tmp_dir() -> PathBuf { + let n = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let dir = std::env::temp_dir().join(format!("gunbc_umbrella_hygiene_{n}")); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn orphan_plant_reds_then_removing_greens() { + let dir = tmp_dir(); + let file = dir.join("orphan_plant_test.dag"); + std::fs::write( + &file, + r#"module orphan_plant_fixture + +test fn live_holds() -> Bool { + true +} + +fn dark_unreachable_check() -> Bool { + false +} +"#, + ) + .unwrap(); + let entry = file.to_string_lossy().into_owned(); + let err = check_orphan_helpers_in_entries(&[entry.clone()]) + .expect_err("planted dark helper must red"); + assert!( + err.contains("dark_unreachable_check"), + "must name the orphan fn: {err}" + ); + std::fs::write( + &file, + r#"module orphan_plant_fixture + +test fn live_holds() -> Bool { + true +} +"#, + ) + .unwrap(); + check_orphan_helpers_in_entries(&[entry]).expect("removing plant must green"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn file_grain_enumerate_yields_two_test_fns() { + let dir = tmp_dir(); + let file = dir.join("two_fn_file_grain_test.dag"); + std::fs::write( + &file, + r#"module two_fn_file_grain + +test fn alpha_holds() -> Bool { true } + +test fn beta_holds() -> Bool { true } +"#, + ) + .unwrap(); + let entry = file.to_string_lossy().into_owned(); + let expanded = expand_explicit_entries(&[(entry.clone(), String::new())]).expect("expand"); + assert_eq!(expanded.len(), 2, "file-grain row must yield two verdicts"); + let names: BTreeSet<_> = expanded.into_iter().map(|(_, f)| f).collect(); + assert_eq!( + names, + ["alpha_holds".to_string(), "beta_holds".to_string()] + .into_iter() + .collect() + ); + let missing = expand_explicit_entries(&[(entry, "does_not_exist_fn".into())]); + // Non-empty function is not validated here — discovery refuses missing fns at eval. + assert!(missing.is_ok()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn umbrella_detection_on_conjunction() { + let dir = tmp_dir(); + let file = dir.join("umbrella_shape_test.dag"); + std::fs::write( + &file, + r#"module umbrella_shape + +fn a_holds() -> Bool { true } +fn b_holds() -> Bool { true } +fn c_holds() -> Bool { true } + +test fn umbrella_holds() -> Bool { + a_holds() && b_holds() && c_holds() +} +"#, + ) + .unwrap(); + let content = std::fs::read_to_string(&file).unwrap(); + let module = analyze_test_module(&file, &content).expect("parse"); + let umbrellas = umbrellas_in_module("umbrella_shape_test.dag", &module); + assert_eq!(umbrellas.len(), 1); + assert_eq!(umbrellas[0].umbrella_fn, "umbrella_holds"); + assert_eq!( + umbrellas[0].conjuncts, + vec!["a_holds", "b_holds", "c_holds"] + ); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src/v2/compiler/self_host/frontier.dag b/src/v2/compiler/self_host/frontier.dag index 5612d8c3bcc..f4333d16add 100644 --- a/src/v2/compiler/self_host/frontier.dag +++ b/src/v2/compiler/self_host/frontier.dag @@ -1089,6 +1089,7 @@ type SeedRetainedScaffoldRegistration { data seed_retained_intrinsic_registrations: List = [ SeedRetainedIntrinsicRegistration { basename: "v1_interpreter", has_pub_mod: true, is_dir: false }, SeedRetainedIntrinsicRegistration { basename: "cli_run", has_pub_mod: true, is_dir: false }, + SeedRetainedIntrinsicRegistration { basename: "test_module_hygiene", has_pub_mod: true, is_dir: false }, SeedRetainedIntrinsicRegistration { basename: "coproduct_reflection", has_pub_mod: true, is_dir: false }, SeedRetainedIntrinsicRegistration { basename: "resolved_graph_cache", has_pub_mod: true, is_dir: false }, SeedRetainedIntrinsicRegistration { basename: "shared_typecheck_store", has_pub_mod: true, is_dir: false }, @@ -1102,6 +1103,8 @@ data seed_retained_intrinsic_registrations: List = [ SeedRetainedScaffoldRegistration { basename: "usv_pilot_v2_std_algebra" }, SeedRetainedScaffoldRegistration { basename: "usv_pilot_v2_std_collection" },