Skip to content
Merged
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
367 changes: 367 additions & 0 deletions src/workers/continuum-core/tests/generated_barrel_sync.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,367 @@
//! Ratchet test: `shared/generated/<module>/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/<module>/<Type>.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/<module>/` 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 `<workspace>/src/shared/generated/` from the test's
/// `CARGO_MANIFEST_DIR` (= `<workspace>/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<String> {
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<String> {
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<String> {
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<String>,
/// Names exported by the barrel but with no matching `.ts` file —
/// the dangling-export regression mode.
dangling_exports: BTreeSet<String>,
}

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<ModuleDrift> {
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<String> =
on_disk.difference(&referenced).cloned().collect();
let dangling_exports: BTreeSet<String> =
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 `./<name>` 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<String> = ["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<String> = ["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<String> = ["A", "C", "Renamed"]
.iter()
.map(|s| s.to_string())
.collect();
let missing: BTreeSet<String> = on_disk.difference(&referenced).cloned().collect();
let dangling: BTreeSet<String> = referenced.difference(&on_disk).cloned().collect();
assert_eq!(missing.iter().cloned().collect::<Vec<_>>(), vec!["B".to_string()]);
assert_eq!(dangling.iter().cloned().collect::<Vec<_>>(), 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<String> = 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."
);
}
Loading