diff --git a/crates/ironclaw_architecture/tests/ratchet_support/mod.rs b/crates/ironclaw_architecture/tests/ratchet_support/mod.rs new file mode 100644 index 00000000000..325358a8648 --- /dev/null +++ b/crates/ironclaw_architecture/tests/ratchet_support/mod.rs @@ -0,0 +1,286 @@ +//! Shared scanner machinery for the §10 anti-slippage ratchets +//! (`reborn_inmemory_store_ratchet.rs`, `reborn_localdev_typename_ratchet.rs`). +//! +//! One hardened implementation of the walk/strip/match pipeline, so every +//! ratchet gets the same guarantees: comment/string stripping, restricted +//! visibility (`pub(crate)`/`pub(super)`/`pub(in …)`), optional `unsafe`/`auto` +//! modifiers, occurrence-preserving scans (same-file duplicates stay visible), +//! and a production-scoped walk (skips `target/`, `tests/`, `examples/`, +//! `benches/`). The scanners are line-based, not cfg-aware: a pub-visible +//! definition in an inline `#[cfg(test)]` module in src IS inventoried — keep +//! test doubles under `tests/` (or justify an allowlist entry in review). + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +/// One matched definition: where it was found and whether the definition line +/// sits under a `#[cfg(...)]` attribute (mutually exclusive compile branches — +/// e.g. the durable/no-durable alias pairs in composition's `factory.rs` — are +/// legitimate same-name definitions, not duplicate debt). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TypeDefOccurrence { + pub path: PathBuf, + pub cfg_gated: bool, +} + +pub fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(|path| path.parent()) + .expect("architecture crate must live under crates/ironclaw_architecture") + .to_path_buf() +} + +/// Names with more than one defining occurrence — a second same-named +/// definition elsewhere is new debt hiding behind an allowlist entry (§10) — +/// EXCEPT when every occurrence is `#[cfg(...)]`-gated (mutually exclusive +/// compile branches of the same type, the factory durable/no-durable pattern). +/// A mix of gated and ungated occurrences is still flagged. +pub fn duplicate_definitions( + found: &BTreeMap>, +) -> Vec<(&str, &Vec)> { + found + .iter() + .filter(|(_, occurrences)| { + occurrences.len() > 1 && !occurrences.iter().all(|occ| occ.cfg_gated) + }) + .map(|(name, occurrences)| (name.as_str(), occurrences)) + .collect() +} + +/// Walk `dir` recursively, scanning every production `.rs` file for pub-visible +/// type definitions introduced by one of `keywords` (e.g. `"struct "`, +/// `"type "`) whose identifier satisfies `matches`. Records every occurrence +/// (identifier → defining file, once per occurrence). Skips `target/`, +/// `tests/`, `examples/`, and `benches/` trees plus any file named in +/// `skip_files` (the ratchet files themselves, as defense in depth — their +/// fixtures are already excluded by string stripping). +pub fn collect_type_defs( + dir: &Path, + keywords: &[&str], + matches: &dyn Fn(&str) -> bool, + skip_files: &[&str], + out: &mut BTreeMap>, +) { + let entries = std::fs::read_dir(dir) + .unwrap_or_else(|err| panic!("failed to read {}: {err}", dir.display())); + for entry in entries { + let entry = entry.unwrap_or_else(|err| panic!("failed to read dir entry: {err}")); + let path = entry.path(); + if path.is_dir() { + let dir_name = path.file_name().and_then(|n| n.to_str()); + if matches!(dir_name, Some("target" | "tests" | "examples" | "benches")) { + continue; + } + collect_type_defs(&path, keywords, matches, skip_files, out); + continue; + } + if path.extension().and_then(|ext| ext.to_str()) != Some("rs") { + continue; + } + if let Some(name) = path.file_name().and_then(|n| n.to_str()) + && skip_files.contains(&name) + { + continue; + } + let contents = std::fs::read_to_string(&path) + .unwrap_or_else(|err| panic!("failed to read {}: {err}", path.display())); + for (ident, cfg_gated) in scan_type_defs(&contents, keywords, matches) { + out.entry(ident).or_default().push(TypeDefOccurrence { + path: path.clone(), + cfg_gated, + }); + } + } +} + +/// Extract the identifier from every pub-visible type definition introduced by +/// one of `keywords` — `pub`, `pub(crate)`, `pub(super)`, or `pub(in path)`, +/// with optional `unsafe`/`auto` modifiers (e.g. `pub unsafe trait …`). +/// Comments and string literals are stripped first, so definition-shaped text +/// inside them is not matched. Matches the definition form, not references. +/// Returns every occurrence in source order (no dedup) so same-file duplicate +/// definitions in different modules stay visible to the multiplicity check. +/// The `bool` per occurrence is whether the definition sits under a +/// (single-line) `#[cfg(...)]` attribute in its immediately preceding attribute +/// block — used to exempt mutually exclusive compile branches from the +/// duplicate check. +pub fn scan_type_defs( + source: &str, + keywords: &[&str], + matches: &dyn Fn(&str) -> bool, +) -> Vec<(String, bool)> { + let stripped = strip_comments_and_strings(source); + let mut out = Vec::new(); + // `#[...]` attributes immediately preceding the current line; reset by any + // other non-blank line. Multi-line attributes (rustfmt splits long + // `#[cfg(any(...))]` gates) are tracked by square-bracket balance — strings + // are already stripped, so bracket counting is safe. + let mut pending_attrs: Vec = Vec::new(); + let mut attr_bracket_depth: usize = 0; + for line in stripped.lines() { + let trimmed = line.trim_start(); + if attr_bracket_depth > 0 { + // Continuation of a multi-line attribute. + attr_bracket_depth = (attr_bracket_depth + trimmed.matches('[').count()) + .saturating_sub(trimmed.matches(']').count()); + continue; + } + if trimmed.starts_with("#[") { + pending_attrs.push(trimmed.to_string()); + attr_bracket_depth = trimmed + .matches('[') + .count() + .saturating_sub(trimmed.matches(']').count()); + continue; + } + if trimmed.is_empty() { + continue; + } + let cfg_gated = pending_attrs.iter().any(|attr| attr.starts_with("#[cfg(")); + pending_attrs.clear(); + let Some(after_pub) = trimmed.strip_prefix("pub") else { + continue; + }; + // Optional restricted-visibility qualifier: `(crate)`, `(super)`, `(in path)`. + let mut rest = match after_pub.trim_start().strip_prefix('(') { + Some(inner) => match inner.split_once(')') { + Some((_, tail)) => tail, + None => continue, + }, + None => after_pub, + } + .trim_start(); + // Optional declaration modifiers before the keyword. + loop { + let mut advanced = false; + for modifier in ["unsafe ", "auto "] { + if let Some(tail) = rest.strip_prefix(modifier) { + rest = tail.trim_start(); + advanced = true; + } + } + if !advanced { + break; + } + } + let Some(after_kw) = keywords.iter().find_map(|kw| rest.strip_prefix(kw)) else { + continue; + }; + let ident: String = after_kw + .trim_start() + .chars() + .take_while(|c| c.is_alphanumeric() || *c == '_') + .collect(); + if matches(&ident) { + out.push((ident, cfg_gated)); + } + } + out +} + +/// Replace line comments, block comments (nested), plain/raw string literal +/// contents, and char literals with blanks, preserving newlines so the +/// line-based matcher keeps operating on real code lines only. A minimal +/// lexer — good enough for rustfmt'd source; it intentionally errs on the side +/// of stripping (a mis-lex would surface loudly as a frozen-set mismatch). +pub fn strip_comments_and_strings(source: &str) -> String { + let chars: Vec = source.chars().collect(); + let mut out = String::with_capacity(source.len()); + let mut i = 0; + while i < chars.len() { + let c = chars[i]; + // Line comment. + if c == '/' && chars.get(i + 1) == Some(&'/') { + while i < chars.len() && chars[i] != '\n' { + i += 1; + } + continue; + } + // Block comment (Rust block comments nest). + if c == '/' && chars.get(i + 1) == Some(&'*') { + let mut depth = 1usize; + i += 2; + while i < chars.len() && depth > 0 { + if chars[i] == '/' && chars.get(i + 1) == Some(&'*') { + depth += 1; + i += 2; + } else if chars[i] == '*' && chars.get(i + 1) == Some(&'/') { + depth -= 1; + i += 2; + } else { + if chars[i] == '\n' { + out.push('\n'); + } + i += 1; + } + } + continue; + } + // Raw string literal: r"..." / r#"..."# (optionally b/c-prefixed). + if c == 'r' || ((c == 'b' || c == 'c') && chars.get(i + 1) == Some(&'r')) { + let hash_start = if c == 'r' { i + 1 } else { i + 2 }; + let mut j = hash_start; + while chars.get(j) == Some(&'#') { + j += 1; + } + if chars.get(j) == Some(&'"') { + let hashes = j - hash_start; + let mut k = j + 1; + while k < chars.len() { + if chars[k] == '"' && (0..hashes).all(|h| chars.get(k + 1 + h) == Some(&'#')) { + k += 1 + hashes; + break; + } + if chars[k] == '\n' { + out.push('\n'); + } + k += 1; + } + i = k; + continue; + } + } + // Plain string literal (handles escapes). + if c == '"' { + i += 1; + while i < chars.len() { + if chars[i] == '\\' { + i += 2; + continue; + } + if chars[i] == '"' { + i += 1; + break; + } + if chars[i] == '\n' { + out.push('\n'); + } + i += 1; + } + continue; + } + // Char literal vs lifetime: only consume when it closes as a literal. + if c == '\'' { + if chars.get(i + 1) == Some(&'\\') { + let mut k = i + 2; + while k < chars.len() && chars[k] != '\'' { + k += 1; + } + i = k + 1; + continue; + } + if chars.get(i + 2) == Some(&'\'') { + i += 3; + continue; + } + // A lifetime (`'a`) — emit and move on. + out.push(c); + i += 1; + continue; + } + out.push(c); + i += 1; + } + out +} diff --git a/crates/ironclaw_architecture/tests/reborn_inmemory_store_ratchet.rs b/crates/ironclaw_architecture/tests/reborn_inmemory_store_ratchet.rs index 4e03dc537eb..9db1113b0b8 100644 --- a/crates/ironclaw_architecture/tests/reborn_inmemory_store_ratchet.rs +++ b/crates/ironclaw_architecture/tests/reborn_inmemory_store_ratchet.rs @@ -8,26 +8,39 @@ //! //! - a **new** `InMemory*Store` (not in the allowlist) fails — the debt can only //! shrink, never grow; -//! - a **second definition** of a frozen name (in another module/crate) fails — -//! the set is keyed by identifier, so multiplicity is checked explicitly and -//! duplicate-name debt cannot hide behind an existing entry; +//! - a **second definition** of a frozen name (same file or another +//! module/crate) fails — occurrences are preserved and multiplicity is checked +//! explicitly, so duplicate-name debt cannot hide behind an existing entry; //! - **deleting** a store without removing it from [`FROZEN_INMEMORY_STORES`] //! also fails — so the allowlist is forced to shrink in lock-step as each //! domain lands, and a reviewer sees the list get shorter (§10: "compare set //! membership, never an aggregate count"). //! -//! The scanner strips comments and string literals before matching. It skips -//! `tests/`, `examples/`, and `benches/` trees (test doubles there are not §4.3 -//! debt) but is line-based, not cfg-aware: a pub-visible store defined in an -//! inline `#[cfg(test)]` module in src IS inventoried — keep test doubles under -//! `tests/` (or justify an allowlist entry in review). +//! Scanner semantics (shared with the other §10 ratchets — see +//! [`ratchet_support`]): comments/strings stripped before matching; skips +//! `tests/`, `examples/`, and `benches/` trees; line-based, not cfg-aware — a +//! pub-visible store in an inline `#[cfg(test)]` module in src IS inventoried, +//! so keep test doubles under `tests/` (or justify an allowlist entry in +//! review). //! //! Definition of done for this axis (§10): the allowlist reaches the empty set — //! every store is `Filesystem*Store` in tests. Until then this //! frozen set is the contract. +mod ratchet_support; + use std::collections::{BTreeMap, BTreeSet}; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; + +use ratchet_support::{ + TypeDefOccurrence, collect_type_defs, duplicate_definitions, scan_type_defs, workspace_root, +}; + +const KEYWORDS: &[&str] = &["struct "]; + +fn is_inmemory_store(ident: &str) -> bool { + ident.starts_with("InMemory") && ident.ends_with("Store") +} /// The frozen inventory of pub-visible `struct InMemory*Store` definitions under /// `crates/`, as of the run-state slice (§4.3, A4). Remove an entry in the same @@ -64,13 +77,22 @@ const FROZEN_INMEMORY_STORES: &[&str] = &[ #[test] fn reborn_inmemory_store_allowlist_is_frozen_and_only_shrinks() { let crates_dir = workspace_root().join("crates"); - let mut found: BTreeMap> = BTreeMap::new(); - collect_inmemory_store_defs(&crates_dir, &mut found); + let mut found: BTreeMap> = BTreeMap::new(); + collect_type_defs( + &crates_dir, + KEYWORDS, + &is_inmemory_store, + &[ + "reborn_inmemory_store_ratchet.rs", + "reborn_localdev_typename_ratchet.rs", + ], + &mut found, + ); let frozen: BTreeSet<&str> = FROZEN_INMEMORY_STORES.iter().copied().collect(); let found_refs: BTreeSet<&str> = found.keys().map(String::as_str).collect(); - let added: Vec<(&str, &Vec)> = found + let added: Vec<(&str, &Vec)> = found .iter() .filter(|(name, _)| !frozen.contains(name.as_str())) .map(|(name, paths)| (name.as_str(), paths)) @@ -102,12 +124,13 @@ fn reborn_inmemory_store_allowlist_is_frozen_and_only_shrinks() { ); } -/// Self-test for the scanner: it must extract exactly the pub-visible -/// (including `pub(crate)`/`pub(super)`/`pub(in ...)`) `struct InMemory*Store` -/// definitions and ignore private structs, non-`Store` structs, and — because -/// comments and strings are stripped before matching — definition-shaped text in -/// line comments, block comments (nested and multiline), plain string literals, -/// and raw string literals. +/// Self-test for the shared scanner as this ratchet configures it: it must +/// extract exactly the pub-visible (including `pub(crate)`/`pub(super)`/ +/// `pub(in ...)`) `struct InMemory*Store` definitions and ignore private +/// structs, non-`Store` structs, and — because comments and strings are +/// stripped before matching — definition-shaped text in line comments, block +/// comments (nested and multiline), plain string literals, and raw string +/// literals. #[test] fn inmemory_store_def_scanner_self_test() { let sample = r##" @@ -135,7 +158,10 @@ fn inmemory_store_def_scanner_self_test() { pub struct InMemoryCfgTestStore; } "##; - let got = scan_source_for_inmemory_store_defs(sample); + let got: Vec = scan_type_defs(sample, KEYWORDS, &is_inmemory_store) + .into_iter() + .map(|(ident, _)| ident) + .collect(); assert_eq!( got, vec![ @@ -154,10 +180,15 @@ fn inmemory_store_def_scanner_self_test() { /// files must be reported as a duplicate. #[test] fn inmemory_store_duplicate_detection_self_test() { - let mut found: BTreeMap> = BTreeMap::new(); + let mut found: BTreeMap> = BTreeMap::new(); for path in ["crate_a/src/lib.rs", "crate_b/src/lib.rs"] { - for ident in scan_source_for_inmemory_store_defs("pub struct InMemoryDupStore;") { - found.entry(ident).or_default().push(PathBuf::from(path)); + for (ident, cfg_gated) in + scan_type_defs("pub struct InMemoryDupStore;", KEYWORDS, &is_inmemory_store) + { + found.entry(ident).or_default().push(TypeDefOccurrence { + path: PathBuf::from(path), + cfg_gated, + }); } } let duplicated = duplicate_definitions(&found); @@ -183,19 +214,23 @@ fn inmemory_store_same_file_duplicate_detection_self_test() { pub struct InMemoryDupStore; } "#; - let occurrences = scan_source_for_inmemory_store_defs(sample); + let occurrences = scan_type_defs(sample, KEYWORDS, &is_inmemory_store); + let idents: Vec<&str> = occurrences + .iter() + .map(|(ident, _)| ident.as_str()) + .collect(); assert_eq!( - occurrences, + idents, vec!["InMemoryDupStore", "InMemoryDupStore"], "same-file duplicates must be preserved by the scan" ); - let mut found: BTreeMap> = BTreeMap::new(); - for ident in occurrences { - found - .entry(ident) - .or_default() - .push(PathBuf::from("crate_a/src/lib.rs")); + let mut found: BTreeMap> = BTreeMap::new(); + for (ident, cfg_gated) in occurrences { + found.entry(ident).or_default().push(TypeDefOccurrence { + path: PathBuf::from("crate_a/src/lib.rs"), + cfg_gated, + }); } let duplicated = duplicate_definitions(&found); assert_eq!( @@ -205,201 +240,3 @@ fn inmemory_store_same_file_duplicate_detection_self_test() { ); assert_eq!(duplicated[0].1.len(), 2); } - -fn duplicate_definitions(found: &BTreeMap>) -> Vec<(&str, &Vec)> { - found - .iter() - .filter(|(_, paths)| paths.len() > 1) - .map(|(name, paths)| (name.as_str(), paths)) - .collect() -} - -fn collect_inmemory_store_defs(dir: &Path, out: &mut BTreeMap>) { - let entries = std::fs::read_dir(dir) - .unwrap_or_else(|err| panic!("failed to read {}: {err}", dir.display())); - for entry in entries { - let entry = entry.unwrap_or_else(|err| panic!("failed to read dir entry: {err}")); - let path = entry.path(); - if path.is_dir() { - // Skip build artifacts and non-production trees: doubles defined - // under `tests/` (e.g. recording stores in tests/support/), - // `examples/`, or `benches/` are not the §4.3 production-store debt - // this ratchet inventories. NOTE: an inline `#[cfg(test)]` module in - // src IS still scanned — the scanner is line-based, not cfg-aware — - // so keep test doubles under `tests/` (or justify an allowlist - // entry in review). - let dir_name = path.file_name().and_then(|n| n.to_str()); - if matches!(dir_name, Some("target" | "tests" | "examples" | "benches")) { - continue; - } - collect_inmemory_store_defs(&path, out); - continue; - } - if path.extension().and_then(|ext| ext.to_str()) != Some("rs") { - continue; - } - // This ratchet file's own allowlist and self-test fixtures mention - // `InMemory*Store` names; string/comment stripping already excludes - // them, but skip the file entirely as defense in depth. - if path.file_name().and_then(|n| n.to_str()) == Some("reborn_inmemory_store_ratchet.rs") { - continue; - } - let contents = std::fs::read_to_string(&path) - .unwrap_or_else(|err| panic!("failed to read {}: {err}", path.display())); - for ident in scan_source_for_inmemory_store_defs(&contents) { - out.entry(ident).or_default().push(path.clone()); - } - } -} - -/// Extract the identifier from every pub-visible `struct InMemory*Store` -/// definition — `pub`, `pub(crate)`, `pub(super)`, or `pub(in path)` (a -/// restricted-visibility store is the same debt class, just crate-private). -/// Comments and string literals are stripped first, so definition-shaped text -/// inside them is not matched. Matches the definition form, not references. -/// Returns every occurrence in source order (no dedup) so same-file duplicate -/// definitions in different modules stay visible to the multiplicity check. -fn scan_source_for_inmemory_store_defs(source: &str) -> Vec { - let stripped = strip_comments_and_strings(source); - let mut out = Vec::new(); - for line in stripped.lines() { - let trimmed = line.trim_start(); - let Some(after_pub) = trimmed.strip_prefix("pub") else { - continue; - }; - // Optional restricted-visibility qualifier: `(crate)`, `(super)`, `(in path)`. - let after_vis = match after_pub.trim_start().strip_prefix('(') { - Some(rest) => match rest.split_once(')') { - Some((_, tail)) => tail, - None => continue, - }, - None => after_pub, - }; - let Some(rest) = after_vis.trim_start().strip_prefix("struct ") else { - continue; - }; - let ident: String = rest - .trim_start() - .chars() - .take_while(|c| c.is_alphanumeric() || *c == '_') - .collect(); - if ident.starts_with("InMemory") && ident.ends_with("Store") { - out.push(ident); - } - } - out -} - -/// Replace line comments, block comments (nested), plain/raw string literal -/// contents, and char literals with blanks, preserving newlines so the -/// line-based matcher keeps operating on real code lines only. A minimal -/// lexer — good enough for rustfmt'd source; it intentionally errs on the side -/// of stripping (a mis-lex would surface loudly as a frozen-set mismatch). -fn strip_comments_and_strings(source: &str) -> String { - let chars: Vec = source.chars().collect(); - let mut out = String::with_capacity(source.len()); - let mut i = 0; - while i < chars.len() { - let c = chars[i]; - // Line comment. - if c == '/' && chars.get(i + 1) == Some(&'/') { - while i < chars.len() && chars[i] != '\n' { - i += 1; - } - continue; - } - // Block comment (Rust block comments nest). - if c == '/' && chars.get(i + 1) == Some(&'*') { - let mut depth = 1usize; - i += 2; - while i < chars.len() && depth > 0 { - if chars[i] == '/' && chars.get(i + 1) == Some(&'*') { - depth += 1; - i += 2; - } else if chars[i] == '*' && chars.get(i + 1) == Some(&'/') { - depth -= 1; - i += 2; - } else { - if chars[i] == '\n' { - out.push('\n'); - } - i += 1; - } - } - continue; - } - // Raw string literal: r"..." / r#"..."# (optionally b/c-prefixed). - if c == 'r' || ((c == 'b' || c == 'c') && chars.get(i + 1) == Some(&'r')) { - let hash_start = if c == 'r' { i + 1 } else { i + 2 }; - let mut j = hash_start; - while chars.get(j) == Some(&'#') { - j += 1; - } - if chars.get(j) == Some(&'"') { - let hashes = j - hash_start; - let mut k = j + 1; - while k < chars.len() { - if chars[k] == '"' && (0..hashes).all(|h| chars.get(k + 1 + h) == Some(&'#')) { - k += 1 + hashes; - break; - } - if chars[k] == '\n' { - out.push('\n'); - } - k += 1; - } - i = k; - continue; - } - } - // Plain string literal (handles escapes). - if c == '"' { - i += 1; - while i < chars.len() { - if chars[i] == '\\' { - i += 2; - continue; - } - if chars[i] == '"' { - i += 1; - break; - } - if chars[i] == '\n' { - out.push('\n'); - } - i += 1; - } - continue; - } - // Char literal vs lifetime: only consume when it closes as a literal. - if c == '\'' { - if chars.get(i + 1) == Some(&'\\') { - let mut k = i + 2; - while k < chars.len() && chars[k] != '\'' { - k += 1; - } - i = k + 1; - continue; - } - if chars.get(i + 2) == Some(&'\'') { - i += 3; - continue; - } - // A lifetime (`'a`) — emit and move on. - out.push(c); - i += 1; - continue; - } - out.push(c); - i += 1; - } - out -} - -fn workspace_root() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .parent() - .and_then(|path| path.parent()) - .expect("architecture crate must live under crates/ironclaw_architecture") - .to_path_buf() -} diff --git a/crates/ironclaw_architecture/tests/reborn_localdev_typename_ratchet.rs b/crates/ironclaw_architecture/tests/reborn_localdev_typename_ratchet.rs new file mode 100644 index 00000000000..784e7f64228 --- /dev/null +++ b/crates/ironclaw_architecture/tests/reborn_localdev_typename_ratchet.rs @@ -0,0 +1,275 @@ +//! Anti-slippage ratchet for the deployment-mode-as-type axis (§4.4 / §10 of +//! `docs/reborn/2026-07-17-architecture-simplification-dto-dyn-local.md`). +//! +//! §4.4's rule: **a deployment mode is a config value, never a type the kernel or +//! a substrate names.** Today a whole `LocalDev*` shadow runtime encodes local-dev +//! as a type family (approval/capability/lease/mount/network/outbound policy, the +//! store aliases, the root filesystem, the turn-state store). Slice B collapses +//! all of it to a `DeploymentConfig` value. +//! +//! That migration is incremental, so this test **freezes the current set of +//! pub-visible `LocalDev*` type definitions** (struct/enum/trait/type alias) +//! and fails on any change: +//! +//! - a **new** `LocalDev*` type (not in the allowlist) fails — the deployment +//! mode must resolve to policy data at the composition edge, not grow another +//! type; +//! - a **second definition** of a frozen name (same file or another +//! module/crate) fails — occurrences are preserved and multiplicity is +//! checked explicitly; +//! - **deleting** one without trimming [`FROZEN_LOCALDEV_TYPES`] also fails — so +//! the allowlist shrinks in lock-step as Slice B lands (§10: compare set +//! membership, never a count), and reviewers watch it get shorter. +//! +//! Scanner semantics (shared with the other §10 ratchets — see +//! [`ratchet_support`]): comments/strings stripped before matching; covers +//! `pub`/`pub(crate)`/`pub(super)`/`pub(in …)` and `unsafe`/`auto` trait +//! modifiers; skips `tests/`, `examples/`, and `benches/` trees; line-based, +//! not cfg-aware — a pub-visible definition in an inline `#[cfg(test)]` module +//! in src IS inventoried (keep test doubles under `tests/`). +//! +//! Definition of done for this axis (§4.4/§10): the allowlist reaches the empty +//! set — no `LocalDev*` type remains; local-dev is one `DeploymentConfig` +//! constant. (Scoped to `LocalDev*` specifically: the broader §4.4 `Local*` / +//! `Hosted*` name audit — Bucket 2 renames like `LocalFilesystem`→`DiskFilesystem` +//! and Bucket 3 false positives like `Locale`, `HostedMcp*`, `LocalTraceSubmission*` +//! — is a separate concern, so this ratchet stays high-signal with a clean +//! empty-set goal.) + +mod ratchet_support; + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::PathBuf; + +use ratchet_support::{ + TypeDefOccurrence, collect_type_defs, duplicate_definitions, scan_type_defs, workspace_root, +}; + +const KEYWORDS: &[&str] = &["struct ", "enum ", "trait ", "type "]; + +fn is_localdev_type(ident: &str) -> bool { + ident.starts_with("LocalDev") +} + +/// The frozen inventory of `LocalDev*` type definitions under `crates/`, as of +/// the store-consolidation ratchet (§10). Every entry is a deployment-mode-as-type +/// leak that Slice B removes by resolving mode to a `DeploymentConfig` value. +/// Remove an entry in the same PR that deletes its type; never add one. +const FROZEN_LOCALDEV_TYPES: &[&str] = &[ + "LocalDevActiveExtensionAuthorityForTest", + "LocalDevApprovalDefaultsPolicy", + "LocalDevApprovalGatePolicy", + "LocalDevApprovalLeaseTermsProvider", + "LocalDevApprovalPolicyAction", + "LocalDevApprovalRequestStore", + "LocalDevAuthInteractionReadModel", + "LocalDevAutoApproveSettingStore", + "LocalDevCapabilityGrantPolicy", + "LocalDevCapabilityLeaseStore", + "LocalDevCapabilityPolicy", + "LocalDevCapabilityPolicyError", + "LocalDevCapabilityWiring", + "LocalDevConstraintPolicy", + "LocalDevDurableBackend", + "LocalDevExtensionSurface", + "LocalDevExtensionSurfaceSource", + "LocalDevMountProfile", + "LocalDevNetworkProfile", + "LocalDevOutboundStores", + "LocalDevOverride", + "LocalDevPersistentApprovalPolicyStore", + "LocalDevProviderPolicy", + "LocalDevRootFilesystem", + "LocalDevSelectableSkillContextSource", + "LocalDevSyntheticCapability", + "LocalDevSyntheticCapabilityDescriptor", + "LocalDevSyntheticCapabilityHandler", + "LocalDevSyntheticCapabilityInvocation", + "LocalDevToolPermissionOverrideStore", + "LocalDevTurnStateStore", +]; + +#[test] +fn reborn_localdev_typename_allowlist_is_frozen_and_only_shrinks() { + let crates_dir = workspace_root().join("crates"); + let mut found: BTreeMap> = BTreeMap::new(); + collect_type_defs( + &crates_dir, + KEYWORDS, + &is_localdev_type, + &[ + "reborn_inmemory_store_ratchet.rs", + "reborn_localdev_typename_ratchet.rs", + ], + &mut found, + ); + + let frozen: BTreeSet<&str> = FROZEN_LOCALDEV_TYPES.iter().copied().collect(); + let found_refs: BTreeSet<&str> = found.keys().map(String::as_str).collect(); + + let added: Vec<(&str, &Vec)> = found + .iter() + .filter(|(name, _)| !frozen.contains(name.as_str())) + .map(|(name, paths)| (name.as_str(), paths)) + .collect(); + assert!( + added.is_empty(), + "New `LocalDev*` type definitions are banned (arch-simplification §4.4/§10): a \ + deployment mode is a `DeploymentConfig` value, never a type. Offending new types: \ + {added:?}. Resolve the mode to policy data at the composition edge instead of \ + adding a type." + ); + + let duplicated = duplicate_definitions(&found); + assert!( + duplicated.is_empty(), + "Each frozen LocalDev* type name must have exactly one definition; a second \ + same-named definition elsewhere is new debt hiding behind an allowlist entry \ + (§10): {duplicated:?}" + ); + + let removed: Vec<&&str> = frozen.difference(&found_refs).collect(); + assert!( + removed.is_empty(), + "FROZEN_LOCALDEV_TYPES lists types that no longer exist: {removed:?}. A LocalDev* \ + type was deleted (good — Slice B progress!) — trim it from the allowlist in the \ + same PR so the ratchet keeps shrinking toward empty (§10)." + ); +} + +/// Self-test for the shared scanner as this ratchet configures it: all four +/// definition keywords, restricted visibility, `unsafe`/`auto` trait modifiers, +/// and — because comments and strings are stripped before matching — +/// definition-shaped text in comments and plain/raw string literals is ignored. +#[test] +fn localdev_type_def_scanner_self_test() { + let sample = r##" + pub struct LocalDevWidget { x: u8 } + pub(crate) type LocalDevAlias = u8; + pub enum LocalDevMode { A, B } + pub trait LocalDevPort {} + pub unsafe trait LocalDevUnsafePort {} // modifier tolerated + pub(crate) unsafe trait LocalDevScopedUnsafePort {} + struct LocalDevPrivate; // not pub-visible -> ignored + pub struct HostedWidget; // not LocalDev -> ignored + let x = "pub struct LocalDevStringLiteral"; // string literal -> ignored + // pub struct LocalDevLineCommented -> ignored + /* + pub enum LocalDevBlockCommented { A } + */ + let raw = r#" + pub type LocalDevRawString = u8; + "#; + fn build_local_dev_runtime() {} // fn, not a type -> ignored + "##; + let got: Vec = scan_type_defs(sample, KEYWORDS, &is_localdev_type) + .into_iter() + .map(|(ident, _)| ident) + .collect(); + assert_eq!( + got, + vec![ + "LocalDevWidget", + "LocalDevAlias", + "LocalDevMode", + "LocalDevPort", + "LocalDevUnsafePort", + "LocalDevScopedUnsafePort", + ], + "scanner must match pub-visible LocalDev* type definitions outside \ + comments and strings, in source order" + ); +} + +/// Same-file multiplicity: two same-named `LocalDev*` definitions in one file +/// (e.g. a struct in one module, a type alias in another) must be flagged. +#[test] +fn localdev_same_file_duplicate_detection_self_test() { + let sample = r#" + mod first { + pub struct LocalDevDupThing; + } + mod second { + pub type LocalDevDupThing = u8; + } + "#; + let occurrences = scan_type_defs(sample, KEYWORDS, &is_localdev_type); + let idents: Vec<&str> = occurrences + .iter() + .map(|(ident, _)| ident.as_str()) + .collect(); + assert_eq!( + idents, + vec!["LocalDevDupThing", "LocalDevDupThing"], + "same-file duplicates must be preserved by the scan" + ); + + let mut found: BTreeMap> = BTreeMap::new(); + for (ident, cfg_gated) in occurrences { + found.entry(ident).or_default().push(TypeDefOccurrence { + path: PathBuf::from("crate_a/src/lib.rs"), + cfg_gated, + }); + } + let duplicated = duplicate_definitions(&found); + assert_eq!( + duplicated.len(), + 1, + "a same-file duplicate must be flagged by the multiplicity check" + ); + assert_eq!(duplicated[0].1.len(), 2); +} + +/// Regression for the composition `factory.rs` pattern: a durable/no-durable +/// alias pair — the SAME name defined twice, each under a mutually exclusive +/// `#[cfg(...)]` — is legitimate and must NOT be flagged as a duplicate. A +/// mixed pair (only one occurrence gated) is still flagged. +#[test] +fn localdev_cfg_gated_alias_pair_is_not_a_duplicate() { + let sample = r#" + #[cfg(any( + not(feature = "inmemory-turn-state"), + any(feature = "libsql", feature = "postgres") + ))] + pub(crate) type LocalDevCfgPairStore = DurableImpl; + #[cfg(not(any(feature = "libsql", feature = "postgres")))] + pub(crate) type LocalDevCfgPairStore = VolatileImpl; + "#; + let occurrences = scan_type_defs(sample, KEYWORDS, &is_localdev_type); + assert_eq!(occurrences.len(), 2, "both cfg branches must be scanned"); + assert!( + occurrences.iter().all(|(_, cfg_gated)| *cfg_gated), + "both branch definitions must be marked cfg-gated" + ); + + let mut found: BTreeMap> = BTreeMap::new(); + for (ident, cfg_gated) in occurrences { + found.entry(ident).or_default().push(TypeDefOccurrence { + path: PathBuf::from("crate_a/src/factory.rs"), + cfg_gated, + }); + } + assert!( + duplicate_definitions(&found).is_empty(), + "an all-cfg-gated same-name pair is mutually exclusive, not duplicate debt" + ); + + // Mixed: one gated, one not — still duplicate debt. + let mixed_sample = r#" + #[cfg(feature = "libsql")] + pub(crate) type LocalDevMixedThing = A; + pub(crate) type LocalDevMixedThing = B; + "#; + let mut mixed: BTreeMap> = BTreeMap::new(); + for (ident, cfg_gated) in scan_type_defs(mixed_sample, KEYWORDS, &is_localdev_type) { + mixed.entry(ident).or_default().push(TypeDefOccurrence { + path: PathBuf::from("crate_a/src/factory.rs"), + cfg_gated, + }); + } + assert_eq!( + duplicate_definitions(&mixed).len(), + 1, + "a partially cfg-gated same-name pair must still be flagged" + ); +}