use std::{
ffi::OsStr,
path::{Path, PathBuf},
};
use anyhow::{Context, Result, bail};
use syn::{
Attribute, ImplItemFn, ItemFn, ItemMod, Meta, MetaList, Token, parse::Parser,
punctuated::Punctuated, visit::Visit,
};
use crate::asserter::{for_each_rs_file, read_allowlist, read_search_dirs};
const LEDGER_METHOD: &str = "on_rewind";
pub fn run(args: Vec<String>) -> Result<()> {
if let Some(arg) = args.first() {
bail!(
"check-atomic-ledger-encapsulation: unexpected argument '{arg}' (configured via env \
vars: HEDDLE_LEDGER_ENCAP_SEARCH_DIRS, HEDDLE_LEDGER_ENCAP_ALLOWLIST)"
);
}
check(
&read_search_dirs("HEDDLE_LEDGER_ENCAP_SEARCH_DIRS"),
&read_allowlist("HEDDLE_LEDGER_ENCAP_ALLOWLIST"),
)
}
fn check(search_dirs: &[PathBuf], allowlist: &[String]) -> Result<()> {
let mut hits: Vec<Hit> = Vec::new();
let mut files_scanned = 0usize;
for dir in search_dirs {
scan_dir(dir, &mut hits, &mut files_scanned)
.with_context(|| format!("scan {}", dir.display()))?;
}
let mut failed = 0usize;
for hit in &hits {
let key = format!("{}:{}", hit.path.display(), hit.line);
if allowlist.iter().any(|entry| entry == &key) {
println!("ok: exempt: {key} — {}", hit.snippet.trim());
continue;
}
eprintln!(
"::error::raw rewind-ledger registration at {key}: `{LEDGER_METHOD}` is called outside \
`crates/repo/src/atomic/` — {}",
hit.snippet.trim()
);
failed += 1;
}
if failed > 0 {
eprintln!(
"\n::error::Found {failed} out-of-module `{LEDGER_METHOD}` call site(s). The rewind \
ledger must be registered through the forward-first `Tx::step` combinator (or `Tx::enroll`), never \
the raw `{LEDGER_METHOD}` primitive, which has no ordering enforcement and lets a compensator be \
queued before its forward effect runs (heddle#355 cid 3330867774 / 3330867775). Replace the \
`{LEDGER_METHOD}` call with `tx.step(forward, inverse)`.\n\
\n\
If a site is a legitimate exception, add a `path:line` entry (of the call) to \
HEDDLE_LEDGER_ENCAP_ALLOWLIST with a one-line justification."
);
bail!("asserter failed");
}
println!(
"asserter clean: no out-of-module `{LEDGER_METHOD}` call sites in production code \
({files_scanned} file(s) scanned)"
);
Ok(())
}
#[derive(Debug)]
struct Hit {
path: PathBuf,
line: usize,
snippet: String,
}
fn scan_dir(dir: &Path, hits: &mut Vec<Hit>, files_scanned: &mut usize) -> Result<()> {
let skip = |path: &Path| is_atomic_module_path(path) || is_test_path(path);
for_each_rs_file(dir, files_scanned, skip, |path, source| {
let file = syn::parse_file(source).with_context(|| format!("parse {}", path.display()))?;
let lines: Vec<&str> = source.lines().collect();
let mut visitor = Finder {
path: path.to_path_buf(),
lines: &lines,
hits,
};
visitor.visit_file(&file);
Ok(())
})
}
fn is_atomic_module_path(path: &Path) -> bool {
let components: Vec<&OsStr> = path.iter().collect();
components.windows(3).any(|w| {
w[0] == OsStr::new("repo") && w[1] == OsStr::new("src") && w[2] == OsStr::new("atomic")
})
}
fn is_test_path(path: &Path) -> bool {
for component in path.components() {
if component.as_os_str() == "tests" {
return true;
}
}
path.file_name()
.and_then(OsStr::to_str)
.map(|name| name.ends_with("_tests.rs") || name == "tests.rs")
.unwrap_or(false)
}
fn is_cfg_test(attrs: &[Attribute]) -> bool {
attrs
.iter()
.any(|attr| attr.path().is_ident("cfg") && cfg_is_test_only(&attr.meta))
}
fn cfg_is_test_only(cfg_meta: &Meta) -> bool {
let Meta::List(list) = cfg_meta else {
return false;
};
if !list.path.is_ident("cfg") {
return false;
}
match syn::parse2::<Meta>(list.tokens.clone()) {
Ok(inner) => !pred_can_be_true_without_test(&inner),
Err(_) => false,
}
}
fn pred_can_be_true_without_test(meta: &Meta) -> bool {
match meta {
Meta::Path(path) => !path.is_ident("test"),
Meta::List(list) if list.path.is_ident("not") => match cfg_children(list) {
Some(children) if children.len() == 1 => pred_can_be_false_without_test(&children[0]),
_ => true,
},
Meta::List(list) if list.path.is_ident("all") => match cfg_children(list) {
Some(children) => children.iter().all(pred_can_be_true_without_test),
None => true,
},
Meta::List(list) if list.path.is_ident("any") => match cfg_children(list) {
Some(children) => children.iter().any(pred_can_be_true_without_test),
None => true,
},
_ => true,
}
}
fn pred_can_be_false_without_test(meta: &Meta) -> bool {
match meta {
Meta::Path(_) => true,
Meta::List(list) if list.path.is_ident("not") => match cfg_children(list) {
Some(children) if children.len() == 1 => pred_can_be_true_without_test(&children[0]),
_ => true,
},
Meta::List(list) if list.path.is_ident("all") => match cfg_children(list) {
Some(children) => children.iter().any(pred_can_be_false_without_test),
None => true,
},
Meta::List(list) if list.path.is_ident("any") => match cfg_children(list) {
Some(children) => children.iter().all(pred_can_be_false_without_test),
None => true,
},
_ => true,
}
}
fn cfg_children(list: &MetaList) -> Option<Vec<Meta>> {
Punctuated::<Meta, Token![,]>::parse_terminated
.parse2(list.tokens.clone())
.ok()
.map(|punctuated| punctuated.into_iter().collect())
}
struct Finder<'a> {
path: PathBuf,
lines: &'a [&'a str],
hits: &'a mut Vec<Hit>,
}
impl Finder<'_> {
fn record(&mut self, line: usize) {
let snippet = self
.lines
.get(line.saturating_sub(1))
.copied()
.unwrap_or("")
.to_string();
self.hits.push(Hit {
path: self.path.clone(),
line,
snippet,
});
}
}
impl<'ast> Visit<'ast> for Finder<'_> {
fn visit_item_mod(&mut self, node: &'ast ItemMod) {
if is_cfg_test(&node.attrs) {
return;
}
syn::visit::visit_item_mod(self, node);
}
fn visit_item_fn(&mut self, node: &'ast ItemFn) {
if is_cfg_test(&node.attrs) {
return;
}
syn::visit::visit_item_fn(self, node);
}
fn visit_impl_item_fn(&mut self, node: &'ast ImplItemFn) {
if is_cfg_test(&node.attrs) {
return;
}
syn::visit::visit_impl_item_fn(self, node);
}
fn visit_expr_method_call(&mut self, node: &'ast syn::ExprMethodCall) {
if node.method == LEDGER_METHOD {
self.record(node.method.span().start().line);
}
syn::visit::visit_expr_method_call(self, node);
}
fn visit_expr_call(&mut self, node: &'ast syn::ExprCall) {
if let syn::Expr::Path(expr_path) = node.func.as_ref()
&& let Some(segment) = expr_path.path.segments.last()
&& segment.ident == LEDGER_METHOD
{
self.record(segment.ident.span().start().line);
}
syn::visit::visit_expr_call(self, node);
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::*;
fn scan_source(src: &str) -> Vec<Hit> {
let file = syn::parse_file(src).expect("parse");
let lines: Vec<&str> = src.lines().collect();
let mut hits = Vec::new();
let mut v = Finder {
path: PathBuf::from("test.rs"),
lines: &lines,
hits: &mut hits,
};
v.visit_file(&file);
hits
}
#[test]
fn flags_on_rewind_call() {
let hits = scan_source(
"fn stage(tx: &mut Tx) -> Result<()> { \
tx.on_rewind(move || Ok(())); \
do_forward()?; \
Ok(()) }",
);
assert_eq!(
hits.len(),
1,
"an out-of-module on_rewind call must be flagged"
);
}
#[test]
fn ignores_step_combinator() {
let hits = scan_source(
"fn stage(tx: &mut Tx) -> Result<()> { \
tx.step(|| do_forward(), move || Ok(()))?; \
Ok(()) }",
);
assert!(hits.is_empty(), "the step combinator must not be flagged");
}
#[test]
fn ignores_inline_cfg_test_module() {
let hits = scan_source(
"fn prod() {} \
#[cfg(test)] \
mod tests { \
fn drives_primitive(tx: &mut Tx) { tx.on_rewind(|| Ok(())); } \
}",
);
assert!(
hits.is_empty(),
"inline #[cfg(test)] module must be skipped"
);
}
#[test]
fn ignores_string_literal() {
let hits = scan_source("fn f() { let s = \"tx.on_rewind(x)\"; let _ = s; }");
assert!(
hits.is_empty(),
"a string mentioning on_rewind is not a call"
);
}
#[test]
fn atomic_module_path_is_recognized() {
assert!(is_atomic_module_path(Path::new(
"crates/repo/src/atomic/tx.rs"
)));
assert!(is_atomic_module_path(Path::new(
"/work/crates/repo/src/atomic/tests.rs"
)));
assert!(!is_atomic_module_path(Path::new(
"crates/cli/src/atomic/x.rs"
)));
assert!(!is_atomic_module_path(Path::new(
"crates/cli/src/cli/commands/undo_apply.rs"
)));
}
const PLANTED: &str = "fn stage(tx: &mut Tx) -> Result<()> { \
tx.on_rewind(move || Ok(())); \
Ok(()) }";
#[test]
fn check_bails_on_planted_site_and_exempts_via_allowlist() {
let dir = tempfile::tempdir().unwrap();
let crate_src = dir.path().join("cli/src");
std::fs::create_dir_all(&crate_src).unwrap();
std::fs::write(crate_src.join("bypass.rs"), PLANTED).unwrap();
let dirs = vec![dir.path().to_path_buf()];
assert!(
check(&dirs, &[]).is_err(),
"a planted out-of-module on_rewind site must fail the check"
);
let key = format!("{}:1", crate_src.join("bypass.rs").display());
assert!(
check(&dirs, &[key]).is_ok(),
"an allowlisted site must pass the check"
);
}
#[test]
fn check_ignores_planted_site_inside_atomic_module() {
let dir = tempfile::tempdir().unwrap();
let atomic = dir.path().join("repo/src/atomic");
std::fs::create_dir_all(&atomic).unwrap();
std::fs::write(atomic.join("inner.rs"), PLANTED).unwrap();
assert!(
check(&[dir.path().to_path_buf()], &[]).is_ok(),
"on_rewind inside crates/repo/src/atomic/ is allowed"
);
}
#[test]
fn flags_cfg_not_test_on_rewind() {
let hits = scan_source(
"#[cfg(not(test))] \
fn prod(tx: &mut Tx) -> Result<()> { tx.on_rewind(move || Ok(())); Ok(()) }",
);
assert_eq!(
hits.len(),
1,
"on_rewind under #[cfg(not(test))] is production and must be flagged"
);
}
#[test]
fn still_skips_genuinely_test_only_cfg() {
assert!(
scan_source("#[cfg(test)] fn t(tx: &mut Tx) { tx.on_rewind(|| Ok(())); }").is_empty(),
"#[cfg(test)] item is test-only and must be skipped"
);
assert!(
scan_source("#[cfg(all(unix, test))] fn t(tx: &mut Tx) { tx.on_rewind(|| Ok(())); }")
.is_empty(),
"#[cfg(all(unix, test))] requires test → test-only → skipped"
);
}
#[test]
fn scans_any_test_or_feature_cfg() {
let hits = scan_source(
"#[cfg(any(test, feature = \"x\"))] \
fn prod(tx: &mut Tx) { tx.on_rewind(|| Ok(())); }",
);
assert_eq!(
hits.len(),
1,
"any(test, feature) is prod-reachable and must be flagged"
);
}
#[test]
fn flags_ufcs_on_rewind() {
let hits = scan_source(
"fn stage(tx: &mut Tx) -> Result<()> { Tx::on_rewind(tx, move || Ok(())); Ok(()) }",
);
assert_eq!(hits.len(), 1, "a UFCS Tx::on_rewind call must be flagged");
}
#[test]
fn check_bails_on_cfg_not_test_bypass() {
let dir = tempfile::tempdir().unwrap();
let crate_src = dir.path().join("cli/src");
std::fs::create_dir_all(&crate_src).unwrap();
std::fs::write(
crate_src.join("bypass.rs"),
"#[cfg(not(test))]\n\
fn prod(tx: &mut Tx) -> Result<()> { tx.on_rewind(move || Ok(())); Ok(()) }",
)
.unwrap();
assert!(
check(&[dir.path().to_path_buf()], &[]).is_err(),
"a #[cfg(not(test))] on_rewind bypass must fail the check"
);
}
#[test]
fn check_bails_on_ufcs_bypass() {
let dir = tempfile::tempdir().unwrap();
let crate_src = dir.path().join("cli/src");
std::fs::create_dir_all(&crate_src).unwrap();
std::fs::write(
crate_src.join("bypass.rs"),
"fn stage(tx: &mut Tx) -> Result<()> { Tx::on_rewind(tx, move || Ok(())); Ok(()) }",
)
.unwrap();
assert!(
check(&[dir.path().to_path_buf()], &[]).is_err(),
"a UFCS Tx::on_rewind bypass must fail the check"
);
}
#[test]
fn production_tree_has_no_external_ledger_use() {
let crates_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("devtools crate dir has a parent (the crates/ dir)")
.to_path_buf();
let mut hits = Vec::new();
let mut scanned = 0usize;
scan_dir(&crates_dir, &mut hits, &mut scanned).expect("scan crates/");
assert!(
scanned > 0,
"expected to scan some files under {crates_dir:?}"
);
assert!(
hits.is_empty(),
"out-of-module on_rewind call site(s) found: {:?}",
hits.iter()
.map(|h| format!("{}:{}", h.path.display(), h.line))
.collect::<Vec<_>>()
);
}
}