From 27d0ea124144b04846d18cecf247eb72d1738a40 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 13 May 2026 17:51:24 -0500 Subject: [PATCH] test(generated): ratchet shared/generated barrels against .ts files (#1132 PR-2) (#1136) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The auto-generated per-module index.ts barrels in shared/generated/ must stay in sync with the .ts files emitted by the ts-rs export tests. When they drift, types are invisible to TS consumers even though the .ts file exists on disk — exactly the regression that bit PR #1129 (commit db271d310 manually added 12 export lines after the fact). This ratchet makes the same regression structurally impossible: - Walks every module dir under src/shared/generated/ - For each, lists the .ts files on disk + parses index.ts for `export type { X } from './Y'` lines - Asserts every on-disk file is referenced; every reference has a file - Failure message names exact drift + suggests `npx tsx generator/generate-rust-bindings.ts` recovery Critical correctness detail: extracts the FROM path (`Y`), not the exported type name (`X`). ts-rs `#[ts(rename = "...")]` produces `export type { ToolCall } from './AgentToolCall'` where the file is AgentToolCall.ts but the type is renamed to ToolCall. Earlier draft of this ratchet got this wrong and falsely flagged every rename usage on canary; corrected before commit. Tests (8/8 passing on canary state): - 5 parser unit tests pinning canonical / rename / double-quote / multi-line / malformed-tolerance behaviour - 1 drift-detection unit test asserting both regression modes (missing-from-barrel + dangling-export) surface correctly - 1 integration scan over real shared/generated/ tree - 1 known-modules sanity check guarding against accidental dir deletion hiding drift behind an empty module Same shape as Lane F PR-1 deletion ratchet (#128) — walk source, assert pattern, fail loud with actionable message. Card: continuum#1136. Lane partner: claude-tab-2 PR #1135 (#1132 PR-1, AIRC + queue-lifecycle smoke slice; this is the ts-rs export check slice they explicitly handed to me). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../tests/generated_barrel_sync.rs | 367 ++++++++++++++++++ 1 file changed, 367 insertions(+) create mode 100644 src/workers/continuum-core/tests/generated_barrel_sync.rs diff --git a/src/workers/continuum-core/tests/generated_barrel_sync.rs b/src/workers/continuum-core/tests/generated_barrel_sync.rs new file mode 100644 index 000000000..93d33ac58 --- /dev/null +++ b/src/workers/continuum-core/tests/generated_barrel_sync.rs @@ -0,0 +1,367 @@ +//! Ratchet test: `shared/generated//index.ts` must stay in +//! sync with the `.ts` files in that module directory. +//! +//! # Why this exists +//! +//! Every `#[derive(TS)]` type in `continuum-core` has a +//! `#[ts(export, export_to = "../../../shared/generated//.ts")]` +//! that materializes a TypeScript binding when `cargo test` runs the +//! type's `export_bindings_*` test (which ts-rs auto-generates). +//! +//! The per-module `index.ts` barrel — generated by +//! `generator/generate-rust-bindings.ts` — is what consuming TypeScript +//! imports from. If a new `.ts` file lands without the barrel being +//! regenerated, the type is invisible to TS consumers even though the +//! file exists on disk. That's exactly what regressed on PR #1129 +//! (commit db271d310: "fix(persona): export generated engram bindings" +//! manually added 12 export lines after the fact). This ratchet makes +//! the same regression impossible by failing `cargo test` whenever any +//! module's barrel drifts from its `.ts` files. +//! +//! # What this catches +//! +//! - A new `.ts` file exists in `shared/generated//` but has no +//! `export type { X } from './X'` line in `index.ts`. +//! - A barrel exports a type whose `.ts` file no longer exists +//! (cleanup regression — the export would dangle). +//! +//! # What this does NOT catch +//! +//! - Drift between Rust source's `#[derive(TS)]` annotations and the +//! actual `.ts` file contents (ts-rs's own export tests cover that — +//! they fail at test time if the generated content doesn't match). +//! - Manual `.ts` files in `shared/generated/` (none should exist — +//! the dir is auto-generated end-to-end). +//! +//! # Failure recovery +//! +//! When this fails: run `npx tsx generator/generate-rust-bindings.ts` +//! from `src/`, commit the regenerated barrel(s), retry. The failure +//! message names every offending module + the specific files that drift. + +use std::collections::BTreeSet; +use std::fs; +use std::path::{Path, PathBuf}; + +/// Resolve `/src/shared/generated/` from the test's +/// `CARGO_MANIFEST_DIR` (= `/src/workers/continuum-core/`). +fn shared_generated_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../shared/generated") + .canonicalize() + .expect("shared/generated/ must exist under workspace") +} + +/// Read all `.ts` file basenames (without extension) in a module dir, +/// excluding `index.ts` (the barrel itself). +fn list_binding_basenames(module_dir: &Path) -> BTreeSet { + fs::read_dir(module_dir) + .unwrap_or_else(|e| panic!("read {}: {}", module_dir.display(), e)) + .filter_map(|entry| entry.ok()) + .filter_map(|entry| { + let path = entry.path(); + if !path.is_file() { + return None; + } + let name = path.file_name()?.to_str()?; + if name == "index.ts" || !name.ends_with(".ts") { + return None; + } + Some(name.trim_end_matches(".ts").to_string()) + }) + .collect() +} + +/// Parse a barrel string and return the set of FROM-path filenames +/// (without extension). The exported TypeScript type name may differ +/// from the source file basename when ts-rs `#[ts(rename = "X")]` is +/// used — for example `agent/index.ts` has +/// `export type { ToolCall } from './AgentToolCall'` where the .ts file +/// is `AgentToolCall.ts` but the exported type is renamed to `ToolCall`. +/// The barrel-vs-file sync check cares about the FROM path (must match +/// a file on disk), not the type name. +/// +/// Pure-string variant so unit tests can pin parser behaviour against +/// canonical/rename/quote-variant cases without filesystem fixtures. +fn parse_barrel_from_paths_str(content: &str) -> BTreeSet { + let mut from_paths = BTreeSet::new(); + for line in content.lines() { + let line = line.trim(); + // canonical: `export type { X } from './Y';` + if !line.starts_with("export type {") { + continue; + } + // Find the `from` clause and pull the quoted relative path. + let from_idx = match line.find("from") { + Some(idx) => idx, + None => continue, + }; + let after_from = &line[from_idx + "from".len()..]; + // Tolerate single OR double quotes; pick the first quote char + // we find and use it as the delimiter. + let quote = match after_from.find(|c: char| c == '\'' || c == '"') { + Some(idx) => &after_from[idx..idx + 1], + None => continue, + }; + let after_open_quote = &after_from[after_from.find(quote).unwrap() + 1..]; + let close_idx = match after_open_quote.find(quote) { + Some(idx) => idx, + None => continue, + }; + let path = &after_open_quote[..close_idx]; + // Canonical form is `./Filename`; tolerate missing leading `./`. + let basename = path.trim_start_matches("./").trim(); + if !basename.is_empty() { + from_paths.insert(basename.to_string()); + } + } + from_paths +} + +/// File-reading wrapper used by the integration scan. +fn parse_barrel_from_paths(barrel_path: &Path) -> BTreeSet { + let content = fs::read_to_string(barrel_path) + .unwrap_or_else(|e| panic!("read {}: {}", barrel_path.display(), e)); + parse_barrel_from_paths_str(&content) +} + +/// One module's worth of barrel-vs-files drift. +#[derive(Debug)] +struct ModuleDrift { + module: String, + /// Files present on disk but missing from the barrel — the #1129 + /// regression mode. + missing_from_barrel: BTreeSet, + /// Names exported by the barrel but with no matching `.ts` file — + /// the dangling-export regression mode. + dangling_exports: BTreeSet, +} + +impl ModuleDrift { + fn is_clean(&self) -> bool { + self.missing_from_barrel.is_empty() && self.dangling_exports.is_empty() + } +} + +/// Walk every module dir under `shared/generated/` and collect drift +/// reports. +fn scan_all_modules(root: &Path) -> Vec { + let mut reports = Vec::new(); + for entry in fs::read_dir(root) + .unwrap_or_else(|e| panic!("read {}: {}", root.display(), e)) + .flatten() + { + let module_dir = entry.path(); + if !module_dir.is_dir() { + continue; + } + let module_name = match module_dir.file_name().and_then(|n| n.to_str()) { + Some(s) => s.to_string(), + None => continue, + }; + let barrel = module_dir.join("index.ts"); + if !barrel.exists() { + // A module dir with no index.ts is itself a drift signal — + // surface it as a synthetic dangling-on-the-module case. + reports.push(ModuleDrift { + module: module_name, + missing_from_barrel: list_binding_basenames(&module_dir), + dangling_exports: BTreeSet::new(), + }); + continue; + } + let on_disk = list_binding_basenames(&module_dir); + let referenced = parse_barrel_from_paths(&barrel); + let missing_from_barrel: BTreeSet = + on_disk.difference(&referenced).cloned().collect(); + let dangling_exports: BTreeSet = + referenced.difference(&on_disk).cloned().collect(); + reports.push(ModuleDrift { + module: module_name, + missing_from_barrel, + dangling_exports, + }); + } + reports +} + +/// Format the drift reports as a human-actionable failure message. +fn render_drift(reports: &[ModuleDrift]) -> String { + let mut out = String::new(); + out.push_str( + "shared/generated barrel drift detected. The auto-generated \ + per-module index.ts files are out of sync with the .ts files \ + on disk. Run `npx tsx generator/generate-rust-bindings.ts` \ + from `src/`, commit the regenerated barrels, and retry.\n\n", + ); + for r in reports.iter().filter(|r| !r.is_clean()) { + out.push_str(&format!("module `{}`:\n", r.module)); + if !r.missing_from_barrel.is_empty() { + out.push_str(" .ts files present on disk but MISSING from index.ts:\n"); + for name in &r.missing_from_barrel { + out.push_str(&format!(" - {}.ts\n", name)); + } + } + if !r.dangling_exports.is_empty() { + out.push_str(" index.ts re-exports from `./` with NO matching .ts file:\n"); + for name in &r.dangling_exports { + out.push_str(&format!(" - ./{} (no {}.ts on disk)\n", name, name)); + } + } + } + out +} + +/// The ratchet itself: every per-module barrel must be in sync with the +/// `.ts` files on disk. A failure here means someone added or removed a +/// `#[derive(TS)]` type without regenerating the barrel. +/// +/// This test runs as part of the standard `cargo test` cycle so missing +/// barrel updates surface in CI / precommit / dev loops rather than +/// silently shipping like they did on #1129. +#[test] +fn barrel_matches_generated_ts_files() { + let root = shared_generated_dir(); + let reports = scan_all_modules(&root); + let dirty: Vec<&ModuleDrift> = reports.iter().filter(|r| !r.is_clean()).collect(); + if !dirty.is_empty() { + panic!("{}", render_drift(&reports)); + } +} + +// ── parser unit tests ─────────────────────────────────────────────── +// +// These pin the parser's behaviour against the generator's canonical +// output shape + tolerated variants. If the generator's emitted format +// changes (e.g., switches quote style, adds `export {` instead of +// `export type {`), the parser breaks here BEFORE the integration scan +// reports a confusing whole-tree drift. + +/// What this catches: canonical generator output — `export type { X } from './X';` +/// — extracts `X` as the from-path. The 80% case. +#[test] +fn parser_extracts_canonical_export() { + let input = "export type { Engram } from './Engram';"; + let got = parse_barrel_from_paths_str(input); + let mut expected = BTreeSet::new(); + expected.insert("Engram".to_string()); + assert_eq!(got, expected); +} + +/// What this catches: rename pattern — `export type { ShortName } from './LongName';` +/// — must extract the FROM path (`LongName`), NOT the exported type +/// name (`ShortName`). Earlier draft of this ratchet got this wrong +/// and falsely flagged every `#[ts(rename = "...")]` usage. +#[test] +fn parser_extracts_from_path_not_type_name_on_rename() { + let input = "export type { ToolCall } from './AgentToolCall';"; + let got = parse_barrel_from_paths_str(input); + assert!(got.contains("AgentToolCall"), "got: {got:?}"); + assert!(!got.contains("ToolCall"), "must not extract type name: {got:?}"); +} + +/// What this catches: double-quoted variants are tolerated. The +/// generator emits single quotes today but a Prettier reformat or +/// generator tweak could swap to double; the parser shouldn't break. +#[test] +fn parser_tolerates_double_quotes() { + let input = r#"export type { Engram } from "./Engram";"#; + let got = parse_barrel_from_paths_str(input); + assert!(got.contains("Engram"), "got: {got:?}"); +} + +/// What this catches: comments + non-export lines are skipped, and +/// multiple exports across lines all surface in the output set. +#[test] +fn parser_handles_multi_line_with_comments() { + let input = "\ +// Auto-generated barrel export — do not edit manually +// Source: generator/generate-rust-bindings.ts + +export type { Engram } from './Engram'; +export type { EngramKind } from './EngramKind'; +export type { ToolCall } from './AgentToolCall'; +"; + let got = parse_barrel_from_paths_str(input); + let expected: BTreeSet = ["Engram", "EngramKind", "AgentToolCall"] + .iter() + .map(|s| s.to_string()) + .collect(); + assert_eq!(got, expected); +} + +/// What this catches: malformed lines (missing `from`, missing braces, +/// missing quotes) are silently skipped rather than panicking. The +/// parser should be defensive — a partially-corrupt barrel shouldn't +/// crash the test, just surface drift on the well-formed entries. +#[test] +fn parser_skips_malformed_lines_without_panic() { + let input = "\ +export type { Broken +export type Missing from './X'; +export type { OK } from './OK'; +not an export at all +"; + let got = parse_barrel_from_paths_str(input); + let mut expected = BTreeSet::new(); + expected.insert("OK".to_string()); + assert_eq!(got, expected); +} + +/// What this catches: drift detection via `ModuleDrift`. Builds a +/// synthetic on-disk + in-barrel set and asserts the diff catches both +/// the missing-from-barrel and dangling-export regression modes. +#[test] +fn drift_detection_reports_both_regression_modes() { + let on_disk: BTreeSet = ["A", "B", "Renamed"] + .iter() + .map(|s| s.to_string()) + .collect(); + // Barrel exports A (matches), C (dangling — no C.ts), Renamed (matches). + // Missing from barrel: B. + let referenced: BTreeSet = ["A", "C", "Renamed"] + .iter() + .map(|s| s.to_string()) + .collect(); + let missing: BTreeSet = on_disk.difference(&referenced).cloned().collect(); + let dangling: BTreeSet = referenced.difference(&on_disk).cloned().collect(); + assert_eq!(missing.iter().cloned().collect::>(), vec!["B".to_string()]); + assert_eq!(dangling.iter().cloned().collect::>(), vec!["C".to_string()]); +} + +/// Smoke check: every module dir we expect to exist actually does. +/// Guards against accidental deletion of a module dir (which would +/// hide drift from the main ratchet — an empty dir reports clean). +/// +/// The list is anchored to what's present at PR-2 ship time +/// (2026-05-13). New modules added later won't break this test (the +/// main ratchet covers them automatically); only deletions of an +/// already-known module would. +#[test] +fn known_modules_still_present() { + let root = shared_generated_dir(); + let known = [ + "agent", "ai", "cognition", "code", "dataset", "gpu", "grid", + "inference", "ipc", "live", "logger", "mcp", "model_registry", + "orm", "persona", "plasticity", "rag", "recipe", "runtime", + "search", "sentinel", "system", "voice", + ]; + let on_disk: BTreeSet = fs::read_dir(&root) + .expect("read shared/generated") + .flatten() + .filter_map(|e| { + let p = e.path(); + if p.is_dir() { + p.file_name()?.to_str().map(|s| s.to_string()) + } else { + None + } + }) + .collect(); + let missing: Vec<&str> = known.iter().copied().filter(|m| !on_disk.contains(*m)).collect(); + assert!( + missing.is_empty(), + "known module dir(s) disappeared from shared/generated/: {missing:?}. \ + If a module is intentionally removed, update the `known` list in this test." + ); +}