diff --git a/dsl/gunbc/compiler.dag b/dsl/gunbc/compiler.dag index d2bb3ff366f..390cae05cb6 100644 --- a/dsl/gunbc/compiler.dag +++ b/dsl/gunbc/compiler.dag @@ -245,42 +245,35 @@ data test_package: NonEmptyStr = "v2-compiler-tests" // shrinks one lane at a time. The SG program (SG-0..SG-7) sequences // that dissolution. Each lane's PR ports a group of .rs files to // .dag authority AND removes those .rs files as authority in the -// same PR — deleted, generated, or reduced to a narrow host shim -// that carries a generated header. No dual-authority period. +// same PR — deleted, generated, or reduced to a narrow host shim. // -// This section is the STRUCTURAL authority for the census: the -// directories that count against the inventory, and the marker that -// separates generated files from hand-authored ones. The ENFORCED -// authority — the concrete list of hand-authored .rs paths that -// must only shrink over time — lives with the ratchet test at -// src/v3/compiler/tests/integration/sg0_census_test.rs. The two -// agree today by discipline; when the v3 pipeline is self-hosting -// enough, the test's constant can be derived from this file. - -// Directories the census walks for .rs files. The walk is recursive -// within each root; nested build artifacts (target/) are skipped by -// the walker. One root today (`src/v3/compiler`) is enough to reach -// every v3-compiler .rs file on disk — the crate sources, bin -// targets, the emit subtree, the integration test tree, and the -// crate's `build.rs`. Additional census roots land here if v3 -// ever grows Rust sources outside this crate. -type V3CensusRoot { - path: FilePath -} - -data v3_census_roots: List = [ - { path: "src/v3/compiler" } -] - -// First non-blank line prefix on every generated .rs file. Every -// regen driver (src/v3/compiler/src/bin/regen_*.rs and the crate's -// build.rs) prepends a header beginning with this string; the -// census classifies any file whose first non-blank line begins with -// it as generated-by-construction and drops it from the hand- -// authored count. Hand-editing a generated file is still forbidden -// by feedback_no_generated_code_on_disk — this marker is the -// discrimination protocol, not a permission. -data v3_generated_marker_prefix: NonEmptyStr = "// AUTO-GENERATED" +// **What "narrow host shim" means.** A retired-to-shim .rs file's +// ONLY hand-authored content is (a) `use` imports, (b) a `#[path]` +// or `include!` directive pulling in generated code, or (c) `pub +// use` re-exports that surface generated symbols under the original +// module path. Any function body, match arm, or control-flow +// statement keeps the file counted as hand-authored. The shim rule +// is structural — a lens will eventually check it — but today it +// is enforced by review. +// +// **No dual-authority in merged/main.** By the time an SG PR lands, +// the old .rs is gone as authority. In-PR intermediate commits may +// temporarily hold both old and new authorities while parity is +// being established; the rule bites at merge time, not at every +// commit. +// +// **Where the authority lives today.** SG-0 is a Rust-side ratchet: +// `src/v3/compiler/build.rs` declares the producer-owned manifest +// `REGEN_OUTPUTS` (the set of .rs files every codegen authority is +// allowed to write under src/v3/compiler); `src/v3/compiler/tests/ +// integration/sg0_census_test.rs` reads that manifest to partition +// generated vs hand-authored, and ratchets the hand-authored set +// down. No structural data lives in this file yet — declaring the +// census roots or generated marker here would be parallel- +// representation debt (`feedback_parallel_representation_debt`) +// because the test would not consume it. When v3 self-hosts enough +// to compile this file (SG-6 cutover), the manifest migrates here +// as first-class `.dag` data and `build.rs` is retired as authority. // ── Command derivation ────────────────────────────────────────────── // Every builder takes a resolved cargo/grep/diff/cp/rm path instead of diff --git a/src/v3/compiler/build.rs b/src/v3/compiler/build.rs index acbaecc0859..c21f5da17fa 100644 --- a/src/v3/compiler/build.rs +++ b/src/v3/compiler/build.rs @@ -178,4 +178,47 @@ fn main() { let compiler_out = out_dir.join("v3_compiler_files.rs"); fs::write(&compiler_out, compiler_generated) .unwrap_or_else(|e| panic!("failed to write {}: {}", compiler_out.display(), e)); + + // SG-0 producer-owned generated manifest. The census at + // `tests/integration/sg0_census_test.rs` reads this list as the + // sole authority for "which .rs files under src/v3/compiler are + // generated." Every codegen driver (the `regen_*` binaries plus + // the ignored `emit_lens_provenance_snapshot` test) imports the + // same manifest and asserts its output path is in it before + // writing — so a typo in a regen output path fails loud rather + // than silently writing to an uncounted path. + // + // The manifest lives only in OUT_DIR — it is ephemeral, + // reconstructed every build from this list. A hand-edited + // manifest file is clobbered on the next `cargo build`; the + // spoof surface collapses to this `REGEN_OUTPUTS` literal, + // reviewed alongside any real producer change. + // + // Paths are workspace-relative and sorted for determinism. They + // match exactly what the regen drivers write to disk as + // committed Rust. + const REGEN_OUTPUTS: &[&str] = &[ + "src/v3/compiler/src/infer_helpers_generated.rs", + "src/v3/compiler/src/lens_cost_generated.rs", + "src/v3/compiler/src/lens_cost_symbolic_generated.rs", + "src/v3/compiler/src/lens_provenance_generated.rs", + "src/v3/compiler/src/lens_structural_resolution_generated.rs", + "src/v3/compiler/src/lens_unused_parameters_generated.rs", + ]; + let mut manifest = String::from( + "// AUTO-GENERATED by build.rs. Do not edit.\n\ + //\n\ + // Paths (workspace-relative) of every .rs file under\n\ + // src/v3/compiler that is produced by a codegen authority\n\ + // (regen_* binaries today; build.rs-driven include_str!\n\ + // projections if that surface ever grows into src/).\n\ + pub static GENERATED_FILES: &[&str] = &[\n", + ); + for path in REGEN_OUTPUTS { + manifest.push_str(&format!(" \"{path}\",\n")); + } + manifest.push_str("];\n"); + let manifest_out = out_dir.join("v3_generated_files.rs"); + fs::write(&manifest_out, manifest) + .unwrap_or_else(|e| panic!("failed to write {}: {}", manifest_out.display(), e)); } diff --git a/src/v3/compiler/src/bin/regen_infer_helpers.rs b/src/v3/compiler/src/bin/regen_infer_helpers.rs index 8e1c01bba38..c5ac6285cfd 100644 --- a/src/v3/compiler/src/bin/regen_infer_helpers.rs +++ b/src/v3/compiler/src/bin/regen_infer_helpers.rs @@ -42,6 +42,12 @@ fn main() { assert!(output.status.success(), "rustfmt failed"); let formatted = String::from_utf8(output.stdout).expect("utf8"); + const OUT_REL: &str = "src/v3/compiler/src/infer_helpers_generated.rs"; + assert!( + v3_compiler::generated_files::GENERATED_FILES.contains(&OUT_REL), + "SG-0 producer manifest mismatch: `{OUT_REL}` is not in GENERATED_FILES — \ + add it to REGEN_OUTPUTS in src/v3/compiler/build.rs." + ); let out_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("src") .join("infer_helpers_generated.rs"); diff --git a/src/v3/compiler/src/bin/regen_lens_cost.rs b/src/v3/compiler/src/bin/regen_lens_cost.rs index f981f2049ff..df2f7838cb3 100644 --- a/src/v3/compiler/src/bin/regen_lens_cost.rs +++ b/src/v3/compiler/src/bin/regen_lens_cost.rs @@ -36,6 +36,12 @@ fn main() { assert!(output.status.success(), "rustfmt failed"); let formatted = String::from_utf8(output.stdout).expect("utf8"); + const OUT_REL: &str = "src/v3/compiler/src/lens_cost_generated.rs"; + assert!( + v3_compiler::generated_files::GENERATED_FILES.contains(&OUT_REL), + "SG-0 producer manifest mismatch: `{OUT_REL}` is not in GENERATED_FILES — \ + add it to REGEN_OUTPUTS in src/v3/compiler/build.rs." + ); let out_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("src") .join("lens_cost_generated.rs"); diff --git a/src/v3/compiler/src/bin/regen_lens_cost_symbolic.rs b/src/v3/compiler/src/bin/regen_lens_cost_symbolic.rs index 9c7852081d7..721d8e72d42 100644 --- a/src/v3/compiler/src/bin/regen_lens_cost_symbolic.rs +++ b/src/v3/compiler/src/bin/regen_lens_cost_symbolic.rs @@ -42,6 +42,12 @@ fn main() { assert!(output.status.success(), "rustfmt failed"); let formatted = String::from_utf8(output.stdout).expect("utf8"); + const OUT_REL: &str = "src/v3/compiler/src/lens_cost_symbolic_generated.rs"; + assert!( + v3_compiler::generated_files::GENERATED_FILES.contains(&OUT_REL), + "SG-0 producer manifest mismatch: `{OUT_REL}` is not in GENERATED_FILES — \ + add it to REGEN_OUTPUTS in src/v3/compiler/build.rs." + ); let out_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("src") .join("lens_cost_symbolic_generated.rs"); diff --git a/src/v3/compiler/src/bin/regen_lens_structural_resolution.rs b/src/v3/compiler/src/bin/regen_lens_structural_resolution.rs index 783b14d906c..cff93ed66b8 100644 --- a/src/v3/compiler/src/bin/regen_lens_structural_resolution.rs +++ b/src/v3/compiler/src/bin/regen_lens_structural_resolution.rs @@ -36,6 +36,12 @@ fn main() { assert!(output.status.success(), "rustfmt failed"); let formatted = String::from_utf8(output.stdout).expect("utf8"); + const OUT_REL: &str = "src/v3/compiler/src/lens_structural_resolution_generated.rs"; + assert!( + v3_compiler::generated_files::GENERATED_FILES.contains(&OUT_REL), + "SG-0 producer manifest mismatch: `{OUT_REL}` is not in GENERATED_FILES — \ + add it to REGEN_OUTPUTS in src/v3/compiler/build.rs." + ); let out_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("src") .join("lens_structural_resolution_generated.rs"); diff --git a/src/v3/compiler/src/bin/regen_lens_unused_parameters.rs b/src/v3/compiler/src/bin/regen_lens_unused_parameters.rs index 255157ba146..4a0f1242a4e 100644 --- a/src/v3/compiler/src/bin/regen_lens_unused_parameters.rs +++ b/src/v3/compiler/src/bin/regen_lens_unused_parameters.rs @@ -36,6 +36,12 @@ fn main() { assert!(output.status.success(), "rustfmt failed"); let formatted = String::from_utf8(output.stdout).expect("utf8"); + const OUT_REL: &str = "src/v3/compiler/src/lens_unused_parameters_generated.rs"; + assert!( + v3_compiler::generated_files::GENERATED_FILES.contains(&OUT_REL), + "SG-0 producer manifest mismatch: `{OUT_REL}` is not in GENERATED_FILES — \ + add it to REGEN_OUTPUTS in src/v3/compiler/build.rs." + ); let out_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("src") .join("lens_unused_parameters_generated.rs"); diff --git a/src/v3/compiler/src/lib.rs b/src/v3/compiler/src/lib.rs index 65e70074d6d..33e7b88c018 100644 --- a/src/v3/compiler/src/lib.rs +++ b/src/v3/compiler/src/lib.rs @@ -17,6 +17,20 @@ pub mod dag; pub mod diagnostics; + +/// SG-0 producer-owned generated-file manifest. +/// +/// `GENERATED_FILES` is the workspace-relative path list of every +/// `.rs` file under `src/v3/compiler` that is produced by a codegen +/// authority. The list is emitted by `build.rs` at build time; the +/// literal is reviewed there. Two consumers today: the `regen_*` +/// binaries (they assert their output path is in the list before +/// writing) and the SG-0 census test (it uses the list as the sole +/// generated/hand-authored partition — no content-marker scanning). +pub mod generated_files { + include!(concat!(env!("OUT_DIR"), "/v3_generated_files.rs")); +} + pub mod emit; pub mod emit_go; pub mod emit_python; diff --git a/src/v3/compiler/tests/integration/m2_lens_provenance_migration_test.rs b/src/v3/compiler/tests/integration/m2_lens_provenance_migration_test.rs index 499f80afe89..f8bc2da0b45 100644 --- a/src/v3/compiler/tests/integration/m2_lens_provenance_migration_test.rs +++ b/src/v3/compiler/tests/integration/m2_lens_provenance_migration_test.rs @@ -183,6 +183,12 @@ fn render_handwritten_oracle(program_source: &str, file_name: &str) -> String { #[test] #[ignore] fn emit_lens_provenance_snapshot() { + const OUT_REL: &str = "src/v3/compiler/src/lens_provenance_generated.rs"; + assert!( + v3_compiler::generated_files::GENERATED_FILES.contains(&OUT_REL), + "SG-0 producer manifest mismatch: `{OUT_REL}` is not in GENERATED_FILES — \ + add it to REGEN_OUTPUTS in src/v3/compiler/build.rs." + ); let fresh = emit_lens_module(); let out_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("src") diff --git a/src/v3/compiler/tests/integration/sg0_census_test.rs b/src/v3/compiler/tests/integration/sg0_census_test.rs index f5338ba6389..701e7b7e747 100644 --- a/src/v3/compiler/tests/integration/sg0_census_test.rs +++ b/src/v3/compiler/tests/integration/sg0_census_test.rs @@ -1,37 +1,40 @@ //! SG-0 — v3 Rust authority census + ratchet. //! -//! Enumerates every `.rs` file under `src/v3/compiler` and classifies -//! each as generated-by-construction (first non-blank line begins with -//! `// AUTO-GENERATED`) or hand-authored. The hand-authored set is -//! compared against [`EXPECTED_HAND_AUTHORED`] below — the declared -//! ratchet. Drift in either direction fails: +//! Enumerates every `.rs` file under `src/v3/compiler` and partitions +//! it into **generated** (listed in the producer-owned manifest at +//! [`v3_compiler::generated_files::GENERATED_FILES`]) versus +//! **hand-authored** (everything else). The hand-authored set is +//! compared against [`EXPECTED_HAND_AUTHORED`] below — the ratchet. +//! Drift in either direction fails: //! -//! - **new hand-authored file**: a contributor added a .rs file -//! without porting logic to .dag. The PR should port the logic and -//! remove the file, reduce the file to a shim with a generated -//! header, or (last resort) extend `EXPECTED_HAND_AUTHORED` with -//! director sign-off. -//! - **missing expected file**: an SG lane retired a file. Remove +//! - **new hand-authored file**: a contributor added a `.rs` without +//! porting the logic to `.dag`. The PR should port the logic and +//! remove the file, reduce it to a narrow host shim (see `compiler.dag` +//! for the shim rule), or (last resort) extend `EXPECTED_HAND_AUTHORED` +//! with director sign-off. +//! - **missing expected file**: an SG lane retired the file. Remove //! the entry from `EXPECTED_HAND_AUTHORED` — this is the normal -//! shrinkage path and the primary success condition for -//! SG-1..SG-7. +//! shrinkage path and the primary success condition for SG-1..SG-7. //! -//! The structural authority for the census *shape* (roots to walk, -//! marker prefix) lives in `dsl/gunbc/compiler.dag` under "v3 source -//! authority inventory (SG program)". This test is the enforcement -//! side; `EXPECTED_HAND_AUTHORED` is the ratchet value. Keep the two -//! consistent. +//! **Producer-owned partition.** A `.rs` file counts as generated iff +//! its workspace-relative path is a member of `GENERATED_FILES`, which +//! is emitted by `src/v3/compiler/build.rs` on every build from the +//! reviewed `REGEN_OUTPUTS` literal. Every codegen driver (the +//! `regen_*` binaries plus the ignored `emit_lens_provenance_snapshot` +//! test) imports the same manifest and asserts its output path is in +//! the list before writing — so a new generated file can only land if +//! `build.rs` names it. File contents do not participate: a hand-authored +//! `.rs` that begins with `// AUTO-GENERATED` does not slip through. use std::collections::BTreeSet; use std::ffi::OsStr; use std::fs; use std::path::{Path, PathBuf}; -// Mirrors `v3_generated_marker_prefix` in `dsl/gunbc/compiler.dag`. -const GENERATED_MARKER_PREFIX: &str = "// AUTO-GENERATED"; +use v3_compiler::generated_files::GENERATED_FILES; -// Mirrors the single entry of `v3_census_roots` in -// `dsl/gunbc/compiler.dag`. Relative to workspace root. +// Relative to workspace root; mirrors the single census root +// informally named in `dsl/gunbc/compiler.dag`. const CENSUS_ROOT: &str = "src/v3/compiler"; // All .rs files under `src/v3/compiler` that are currently @@ -40,29 +43,14 @@ const CENSUS_ROOT: &str = "src/v3/compiler"; // Removing an entry means the owning lane has retired the file; // adding an entry is forbidden outside SG-0 without director // sign-off. -// SG-4 prep — two ratchet bumps pay for the first .dag-authority -// slice of `infer.rs`. The extracted helper lives in -// `src/v3/lenses/infer_helpers.dag` and renders into -// `src/v3/compiler/src/infer_helpers_generated.rs` (excluded from the -// census via the `// AUTO-GENERATED` marker). The two new -// hand-authored entries below mirror the lens-migration precedent -// 1:1: // -// - `bin/regen_infer_helpers.rs` — the per-helper regen binary -// (one per generated module, same as the four `regen_lens_*` -// binaries already in the list). -// - `tests/integration/sg4_prep_infer_helpers_freshness_test.rs` — -// the per-helper regenerate→diff-empty ratchet that converts the -// `.dag → committed *_generated.rs` link from convention to -// enforcement (added in response to codex BLOCKING review on PR -// #562 SHA `90939487a`; mirrors -// `unused_parameters_generated_module_matches_checked_in_snapshot` -// in the lens-migration tests). -// -// SG-6 owns the dissolution: when the lens-style regen drivers and -// per-file freshness tests fold into a single generic regen target + -// generic snapshot gate, both new entries collapse along with the -// four existing `regen_lens_*` lines. Director sign-off +// SG-4 prep carries two entries that SG-6 will retire together: +// - `bin/regen_infer_helpers.rs` (the per-helper regen binary) +// - `tests/integration/sg4_prep_infer_helpers_freshness_test.rs` +// (the per-helper regenerate→diff-empty ratchet) +// When SG-6 folds all regen drivers and snapshot gates into a +// single generic target, both entries collapse alongside the four +// `regen_lens_*` lines above. Director sign-off // (clever-swift-141 brief, 2026-04-19) covers the temporary +2. const EXPECTED_HAND_AUTHORED: &[&str] = &[ "src/v3/compiler/build.rs", @@ -183,15 +171,6 @@ fn walk_rs(root: &Path, ws: &Path, out: &mut BTreeSet) { } } -fn is_generated(abs_path: &Path) -> bool { - let contents = - fs::read_to_string(abs_path).unwrap_or_else(|e| panic!("read {}: {e}", abs_path.display())); - let Some(first) = contents.lines().find(|line| !line.trim().is_empty()) else { - return false; - }; - first.trim_start().starts_with(GENERATED_MARKER_PREFIX) -} - #[test] fn sg0_v3_hand_authored_census() { let ws = workspace_root(); @@ -205,13 +184,8 @@ fn sg0_v3_hand_authored_census() { let mut all_rs: BTreeSet = BTreeSet::new(); walk_rs(&census_root, &ws, &mut all_rs); - let mut hand_authored: BTreeSet = BTreeSet::new(); - for rel in &all_rs { - let abs = ws.join(rel); - if !is_generated(&abs) { - hand_authored.insert(rel.clone()); - } - } + let generated: BTreeSet = GENERATED_FILES.iter().map(|p| (*p).to_string()).collect(); + let hand_authored: BTreeSet = all_rs.difference(&generated).cloned().collect(); let expected: BTreeSet = EXPECTED_HAND_AUTHORED .iter() @@ -243,7 +217,9 @@ fn sg0_v3_hand_authored_census() { } msg.push_str( "\nFix: port the logic to .dag and remove the .rs, or reduce the file\n\ - to a shim whose first non-blank line begins with `// AUTO-GENERATED`.\n\ + to a narrow host shim and add its path to REGEN_OUTPUTS in\n\ + src/v3/compiler/build.rs (the shim must be written by a producer —\n\ + a hand-authored `// AUTO-GENERATED` header does not count).\n\ Last resort: add the path to EXPECTED_HAND_AUTHORED with a\n\ director-approved receipt.\n\n", ); @@ -277,3 +253,97 @@ fn sg0_expected_list_is_sorted_and_unique() { prev = Some(p); } } + +static PROBE_COUNTER: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + +struct TempDirGuard(PathBuf); + +impl Drop for TempDirGuard { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +#[test] +fn sg0_generated_partition_is_producer_owned() { + // Soundness: the manifest is the sole authority for the + // generated/hand-authored partition. This test plants a + // handwritten file whose first non-blank line is the most + // convincing possible spoof — the exact string + // `// AUTO-GENERATED from ...` that every real regen driver + // emits — into an isolated temp tree, then runs the real + // `walk_rs` + manifest-membership classification over it. + // Because the probe's path is not in `GENERATED_FILES`, it must + // land in the hand-authored set. If a future change reintroduces + // content-based filtering (either inside `walk_rs` or in the + // partition step), the probe would wrongly classify as generated + // and this assertion flips — catching the regression loudly. + // + // Isolation: the probe lives under `std::env::temp_dir()`, not + // inside `src/v3/compiler/`. The live `sg0_v3_hand_authored_census` + // walker never sees it, so the tests are safe to run in parallel. + + let tmp = std::env::temp_dir().join(format!( + "sg0_soundness_probe_{}_{}", + std::process::id(), + PROBE_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + )); + let _guard = TempDirGuard(tmp.clone()); + fs::create_dir_all(&tmp).unwrap_or_else(|e| panic!("create probe dir: {e}")); + + let probe_path = tmp.join("spoofed_header.rs"); + fs::write( + &probe_path, + "// AUTO-GENERATED from `src/v3/lenses/fake.dag` via `emit_rust_module`.\n\ + // Hand-authored file masquerading as generated — must still\n\ + // classify as hand-authored because the manifest is authority.\n\ + pub fn spoof() {}\n", + ) + .unwrap_or_else(|e| panic!("write probe: {e}")); + + let mut walk_results: BTreeSet = BTreeSet::new(); + walk_rs(&tmp, &tmp, &mut walk_results); + + let generated: BTreeSet = GENERATED_FILES.iter().map(|p| (*p).to_string()).collect(); + let hand_authored: BTreeSet = walk_results.difference(&generated).cloned().collect(); + + let probe_rel = probe_path + .strip_prefix(&tmp) + .expect("probe is inside tmp") + .to_string_lossy() + .replace('\\', "/"); + + assert!( + hand_authored.contains(&probe_rel), + "probe file with `// AUTO-GENERATED` first-line header must be \ + classified as hand-authored (the path is not in GENERATED_FILES). \ + The manifest is the sole authority; content headers must not \ + participate. If this fails, content-based classification has \ + regressed into walk_rs or the partition logic." + ); +} + +#[test] +fn sg0_every_generated_file_is_present_on_disk() { + // The manifest is the authority for "which files are generated"; + // it must stay in lockstep with what producers actually write. + // If a manifest entry points at a missing file, either the + // producer hasn't run yet (fresh checkout + `cargo test` without + // regen) or the file was deleted without updating build.rs. + // Either way the census would silently shrink and this ratchet + // would be unsound — fail loud here. + let ws = workspace_root(); + let mut missing: Vec<&str> = Vec::new(); + for rel in GENERATED_FILES { + if !ws.join(rel).is_file() { + missing.push(rel); + } + } + assert!( + missing.is_empty(), + "GENERATED_FILES references paths not present on disk: {missing:?} — \ + the producer (a regen_* binary or build.rs entry) did not write them. \ + Update REGEN_OUTPUTS in src/v3/compiler/build.rs, or run the relevant \ + regen driver to populate the committed output." + ); +}