use std::fs;
use std::path::Path;
use std::process::Command;
fn bin() -> Command {
Command::new(env!("CARGO_BIN_EXE_spec-spine"))
}
fn write_spec(root: &Path, dir: &str, id: &str, status: &str) {
let spec_dir = root.join("specs").join(dir);
fs::create_dir_all(&spec_dir).unwrap();
let body = format!(
"---\nid: \"{id}\"\ntitle: \"T\"\nstatus: {status}\ncreated: \"2026-06-08\"\nsummary: \"s\"\n---\n# {id}\n"
);
fs::write(spec_dir.join("spec.md"), body).unwrap();
}
fn code(out: &std::process::Output) -> i32 {
out.status.code().unwrap_or(-1)
}
#[test]
fn index_slice_hashes_and_check() {
let tmp = tempfile::tempdir().unwrap();
write_spec(tmp.path(), "001-a", "001-a", "approved");
fs::create_dir_all(tmp.path().join("conf")).unwrap();
fs::write(tmp.path().join("conf/a.json"), "{\"a\":1}\n").unwrap();
fs::write(tmp.path().join("conf/b.json"), "{\"b\":2}\n").unwrap();
let run = |args: &[&str]| {
let out = bin().arg("--repo").arg(tmp.path()).args(args).output();
out.unwrap()
};
let slices_file = tmp.path().join(".derived/codebase-index/slices.json");
assert_eq!(code(&run(&["index"])), 0);
assert!(
!slices_file.exists(),
"no slices configured -> no slices.json sidecar"
);
assert_eq!(
code(&run(&["index", "check", "--slice", "agent-config"])),
3,
"unknown slice name -> 3"
);
fs::write(
tmp.path().join("spec-spine.toml"),
"[index.slices]\nzz-last = [\"conf/b.json\"]\nagent-config = [\"conf/a.json\", \"conf/missing.json\"]\n",
)
.unwrap();
assert_eq!(
code(&run(&["index", "check", "--slice", "agent-config"])),
1,
"an index predating the slice config is not vouching for it"
);
assert_eq!(code(&run(&["index"])), 0);
let raw = fs::read_to_string(&slices_file).unwrap();
assert!(
raw.find("agent-config").unwrap() < raw.find("zz-last").unwrap(),
"slice hash keys are sorted"
);
assert_eq!(
code(&run(&["index", "check", "--slice", "agent-config"])),
0
);
assert_eq!(code(&run(&["index", "check", "--slice", "zz-last"])), 0);
assert_eq!(code(&run(&["index", "check"])), 0);
fs::write(tmp.path().join("conf/a.json"), "{\"a\":99}\n").unwrap();
assert_eq!(code(&run(&["index", "check"])), 0, "global gate unaffected");
assert_eq!(
code(&run(&["index", "check", "--slice", "agent-config"])),
1
);
assert_eq!(code(&run(&["index", "check", "--slice", "zz-last"])), 0);
write_spec(tmp.path(), "001-a", "001-a", "draft");
assert_eq!(
code(&run(&["index", "check"])),
1,
"spec.md is global input"
);
assert_eq!(code(&run(&["index", "check", "--slice", "zz-last"])), 0);
assert_eq!(code(&run(&["index"])), 0);
fs::remove_file(tmp.path().join("conf/b.json")).unwrap();
assert_eq!(code(&run(&["index", "check", "--slice", "zz-last"])), 1);
assert_eq!(code(&run(&["index", "check", "--slice", "nope"])), 3);
}
#[test]
fn invalid_slice_config_exits_3() {
let tmp = tempfile::tempdir().unwrap();
write_spec(tmp.path(), "001-a", "001-a", "approved");
fs::write(
tmp.path().join("spec-spine.toml"),
"[index.slices]\n\"Bad_Name\" = [\"conf/*.json\"]\n",
)
.unwrap();
assert_eq!(
code(
&bin()
.arg("--repo")
.arg(tmp.path())
.arg("index")
.output()
.unwrap()
),
2
);
fs::write(
tmp.path().join("spec-spine.toml"),
"[index.slices]\nok = []\n",
)
.unwrap();
assert_eq!(
code(
&bin()
.arg("--repo")
.arg(tmp.path())
.arg("index")
.output()
.unwrap()
),
2
);
}
#[test]
fn compile_ok_then_queries() {
let tmp = tempfile::tempdir().unwrap();
write_spec(tmp.path(), "001-a", "001-a", "approved");
write_spec(tmp.path(), "002-b", "002-b", "approved");
let compile = bin()
.arg("--repo")
.arg(tmp.path())
.arg("compile")
.output()
.unwrap();
assert_eq!(code(&compile), 0, "clean compile exits 0");
assert!(
tmp.path()
.join(".derived/spec-registry/by-spec/001-a.json")
.is_file()
);
assert!(
!tmp.path()
.join(".derived/spec-registry/registry.json")
.exists()
);
let list = bin()
.arg("--repo")
.arg(tmp.path())
.args(["registry", "list"])
.output()
.unwrap();
assert_eq!(code(&list), 0);
assert!(String::from_utf8_lossy(&list.stdout).contains("001-a"));
let show_missing = bin()
.arg("--repo")
.arg(tmp.path())
.args(["registry", "show", "999-nope"])
.output()
.unwrap();
assert_eq!(code(&show_missing), 1, "not found exits 1");
}
fn sha256_hex(bytes: &[u8]) -> String {
use sha2::Digest;
sha2::Sha256::digest(bytes)
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}
#[test]
fn content_hash_is_path_framed_and_spec_source_hash_is_not() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
verdict_fixture(root);
let rel = "specs/001-a/spec.md";
let text = fs::read_to_string(root.join(rel)).unwrap();
let raw = format!("\u{feff}{}", text.replace('\n', "\r\n"));
fs::write(root.join(rel), &raw).unwrap();
for verb in ["compile", "index"] {
assert_eq!(code(&run_in(root, &[verb])), 0, "{verb}");
}
let normalized = text.as_bytes();
let mut framed = rel.as_bytes().to_vec();
framed.push(0);
framed.extend_from_slice(normalized);
let show = envelope(&run_in(root, &["registry", "show", "001-a", "--json"]));
let content_hash = show["contentHash"]
.as_str()
.expect("contentHash")
.to_string();
assert_eq!(content_hash, sha256_hex(&framed), "(1) framed by the path");
assert_ne!(
content_hash,
sha256_hex(normalized),
"(2) not the bare digest"
);
let attest = envelope(&run_in(root, &["attest", "--spec", "001-a", "--json"]));
assert_eq!(
attest["report"]["attestation"]["specSourceHash"],
sha256_hex(normalized),
"(3) specSourceHash is the unframed digest"
);
let prose = run_in(root, &["registry", "show", "001-a"]);
let stdout = String::from_utf8_lossy(&prose.stdout);
let line = stdout
.lines()
.find(|l| l.starts_with("contentHash:"))
.unwrap_or_else(|| panic!("a contentHash line: {stdout}"));
assert!(line.contains(&content_hash), "{line}");
assert!(
!line.contains("sha256 of this spec.md"),
"(4) the pre-096 gloss named the wrong construction: {line}"
);
assert!(
line.contains("path") && line.contains("NUL"),
"(4) names the framing: {line}"
);
}
#[test]
fn registry_list_ids_only_projection() {
let tmp = tempfile::tempdir().unwrap();
write_spec(tmp.path(), "001-a", "001-a", "approved");
write_spec(tmp.path(), "002-b", "002-b", "approved");
write_spec(tmp.path(), "003-c", "003-c", "draft");
let compiled = bin()
.arg("--repo")
.arg(tmp.path())
.arg("compile")
.output()
.unwrap();
assert_eq!(code(&compiled), 0);
let text = bin()
.arg("--repo")
.arg(tmp.path())
.args(["registry", "list", "--ids-only"])
.output()
.unwrap();
assert_eq!(code(&text), 0);
assert_eq!(
String::from_utf8_lossy(&text.stdout),
"001-a\n002-b\n003-c\n"
);
let json = bin()
.arg("--repo")
.arg(tmp.path())
.args(["registry", "list", "--ids-only", "--json"])
.output()
.unwrap();
assert_eq!(code(&json), 0);
let doc: serde_json::Value = serde_json::from_slice(&json.stdout).unwrap();
let ids: Vec<String> = serde_json::from_value(doc["items"].clone()).unwrap();
assert_eq!(ids, ["001-a", "002-b", "003-c"]);
let filtered = bin()
.arg("--repo")
.arg(tmp.path())
.args(["registry", "list", "--ids-only", "--status", "approved"])
.output()
.unwrap();
assert_eq!(code(&filtered), 0);
assert_eq!(String::from_utf8_lossy(&filtered.stdout), "001-a\n002-b\n");
let filtered_json = bin()
.arg("--repo")
.arg(tmp.path())
.args([
"registry",
"list",
"--ids-only",
"--status",
"retired",
"--json",
])
.output()
.unwrap();
assert_eq!(code(&filtered_json), 0);
let doc: serde_json::Value = serde_json::from_slice(&filtered_json.stdout).unwrap();
let none: Vec<String> = serde_json::from_value(doc["items"].clone()).unwrap();
assert!(none.is_empty());
let empty = bin()
.arg("--repo")
.arg(tmp.path())
.args(["registry", "list", "--ids-only", "--status", "retired"])
.output()
.unwrap();
assert_eq!(code(&empty), 0);
assert!(empty.stdout.is_empty());
}
#[test]
fn registry_status_report_nonzero_only_projection() {
let tmp = tempfile::tempdir().unwrap();
write_spec(tmp.path(), "001-a", "001-a", "approved");
write_spec(tmp.path(), "002-b", "002-b", "approved");
write_spec(tmp.path(), "003-c", "003-c", "draft");
let compiled = bin()
.arg("--repo")
.arg(tmp.path())
.arg("compile")
.output()
.unwrap();
assert_eq!(code(&compiled), 0);
let plain = bin()
.arg("--repo")
.arg(tmp.path())
.args(["registry", "status-report"])
.output()
.unwrap();
assert_eq!(code(&plain), 0);
assert_eq!(
String::from_utf8_lossy(&plain.stdout),
"total: 3\ndraft: 1\napproved: 2\nsuperseded: 0\nretired: 0\n"
);
let human = bin()
.arg("--repo")
.arg(tmp.path())
.args(["registry", "status-report", "--nonzero-only"])
.output()
.unwrap();
assert_eq!(code(&human), 0);
assert_eq!(
String::from_utf8_lossy(&human.stdout),
"total: 3\ndraft: 1\napproved: 2\n"
);
let json = bin()
.arg("--repo")
.arg(tmp.path())
.args(["registry", "status-report", "--nonzero-only", "--json"])
.output()
.unwrap();
assert_eq!(code(&json), 0);
let report: serde_json::Value = serde_json::from_slice(&json.stdout).unwrap();
assert_eq!(report["total"], 3);
assert_eq!(report["draft"], 1);
assert_eq!(report["approved"], 2);
assert!(report.get("superseded").is_none());
assert!(report.get("retired").is_none());
}
#[test]
fn compile_validation_failure_exits_1() {
let tmp = tempfile::tempdir().unwrap();
write_spec(tmp.path(), "001-folder", "001-mismatch", "approved");
let out = bin()
.arg("--repo")
.arg(tmp.path())
.arg("compile")
.output()
.unwrap();
assert_eq!(code(&out), 1, "validation failure exits 1");
}
#[test]
fn missing_specs_dir_exits_3() {
let tmp = tempfile::tempdir().unwrap();
let out = bin()
.arg("--repo")
.arg(tmp.path())
.arg("compile")
.output()
.unwrap();
assert_eq!(code(&out), 4, "I/O error exits 4");
}
#[test]
fn registry_query_before_compile_exits_3() {
let tmp = tempfile::tempdir().unwrap();
write_spec(tmp.path(), "001-a", "001-a", "approved");
let out = bin()
.arg("--repo")
.arg(tmp.path())
.args(["registry", "list"])
.output()
.unwrap();
assert_eq!(code(&out), 4);
}
#[test]
fn index_then_check_fresh_then_stale() {
let tmp = tempfile::tempdir().unwrap();
write_spec(tmp.path(), "001-a", "001-a", "approved");
let built = bin()
.arg("--repo")
.arg(tmp.path())
.arg("index")
.output()
.unwrap();
assert_eq!(code(&built), 0, "index writes -> 0");
assert!(
tmp.path()
.join(".derived/codebase-index/by-spec/001-a.json")
.is_file()
);
let fresh = bin()
.arg("--repo")
.arg(tmp.path())
.args(["index", "check"])
.output()
.unwrap();
assert_eq!(code(&fresh), 0, "fresh -> 0");
write_spec(tmp.path(), "001-a", "001-a", "draft");
let stale = bin()
.arg("--repo")
.arg(tmp.path())
.args(["index", "check"])
.output()
.unwrap();
assert_eq!(code(&stale), 1, "stale -> 1");
}
#[test]
fn index_render_and_orphans_projections() {
let tmp = tempfile::tempdir().unwrap();
let write_claiming_spec = |id: &str, target: &str| {
let dir = tmp.path().join("specs").join(id);
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join("spec.md"),
format!(
"---\nid: \"{id}\"\ntitle: \"T\"\nstatus: approved\ncreated: \"2026-06-08\"\nsummary: \"s\"\nestablishes:\n - \"{target}\"\n---\n# {id}\n"
),
)
.unwrap();
};
fs::create_dir_all(tmp.path().join("src")).unwrap();
fs::write(tmp.path().join("src/lib.rs"), "// Spec: 001-a\n").unwrap();
write_claiming_spec("001-a", "src/lib.rs");
write_claiming_spec("002-b", "src/missing.rs");
let early_render = bin()
.arg("--repo")
.arg(tmp.path())
.args(["index", "render"])
.output()
.unwrap();
assert_eq!(code(&early_render), 4, "render without index -> 4");
let early_orphans = bin()
.arg("--repo")
.arg(tmp.path())
.args(["index", "orphans"])
.output()
.unwrap();
assert_eq!(code(&early_orphans), 4, "orphans without index -> 4");
let built = bin()
.arg("--repo")
.arg(tmp.path())
.arg("index")
.output()
.unwrap();
assert_eq!(code(&built), 0);
let orphans_text = bin()
.arg("--repo")
.arg(tmp.path())
.args(["index", "orphans"])
.output()
.unwrap();
assert_eq!(code(&orphans_text), 0, "orphans is a query, not a gate");
let orphans_out = String::from_utf8_lossy(&orphans_text.stdout);
assert!(orphans_out.contains("in flight"), "{orphans_out}");
assert!(orphans_out.contains("002-b"), "{orphans_out}");
let orphans_json = bin()
.arg("--repo")
.arg(tmp.path())
.args(["index", "orphans", "--json"])
.output()
.unwrap();
assert_eq!(code(&orphans_json), 0);
let partitioned: serde_json::Value = serde_json::from_slice(&orphans_json.stdout).unwrap();
assert_eq!(partitioned["orphaned"], serde_json::json!([]));
assert_eq!(partitioned["inFlight"], serde_json::json!(["002-b"]));
let render = bin()
.arg("--repo")
.arg(tmp.path())
.args(["index", "render"])
.output()
.unwrap();
assert_eq!(code(&render), 0, "diagnostics do not fail a render");
let md = String::from_utf8_lossy(&render.stdout);
let positions: Vec<usize> = [
"# spec-spine codebase index",
"## Packages",
"## Traceability",
]
.iter()
.map(|s| md.find(s).unwrap_or_else(|| panic!("missing section {s}")))
.collect();
assert!(positions.windows(2).all(|w| w[0] < w[1]), "section order");
assert!(md.contains("### Orphaned specs"));
assert!(md.contains("- 002-b"));
assert!(md.ends_with('\n'));
fs::remove_dir_all(tmp.path().join("specs/002-b")).unwrap();
let rebuilt = bin()
.arg("--repo")
.arg(tmp.path())
.arg("index")
.output()
.unwrap();
assert_eq!(code(&rebuilt), 0);
let none = bin()
.arg("--repo")
.arg(tmp.path())
.args(["index", "orphans"])
.output()
.unwrap();
assert_eq!(code(&none), 0);
assert!(
none.stdout.is_empty(),
"{}",
String::from_utf8_lossy(&none.stdout)
);
}
#[test]
fn lint_fail_on_warn_gating() {
let tmp = tempfile::tempdir().unwrap();
write_spec(tmp.path(), "001-a", "001-a", "approved");
let lenient = bin()
.arg("--repo")
.arg(tmp.path())
.arg("lint")
.output()
.unwrap();
assert_eq!(code(&lenient), 0, "warnings alone do not fail");
let strict = bin()
.arg("--repo")
.arg(tmp.path())
.args(["lint", "--fail-on-warn"])
.output()
.unwrap();
assert_eq!(code(&strict), 1, "--fail-on-warn fails on a warning");
}
#[test]
fn compile_check_exit_contract() {
let tmp = tempfile::tempdir().unwrap();
write_spec(tmp.path(), "001-a", "001-a", "approved");
let run = |args: &[&str]| {
bin()
.arg("--repo")
.arg(tmp.path())
.args(args)
.output()
.unwrap()
};
assert_eq!(
code(&run(&["compile", "--check"])),
1,
"unbuilt -> stale (2)"
);
assert_eq!(code(&run(&["compile"])), 0);
assert_eq!(
code(&run(&["compile", "--check"])),
0,
"just compiled -> fresh (0)"
);
let meta = tmp.path().join(".derived/spec-registry/build-meta.json");
let meta_before = fs::read(&meta).unwrap();
assert_eq!(code(&run(&["compile", "--check"])), 0);
assert_eq!(
fs::read(&meta).unwrap(),
meta_before,
"--check must not restamp build-meta.json"
);
let spec_md = tmp.path().join("specs/001-a/spec.md");
let edited = fs::read_to_string(&spec_md).unwrap() + "\nmore body\n";
fs::write(&spec_md, edited).unwrap();
let stale = run(&["compile", "--check"]);
assert_eq!(code(&stale), 1, "edited spec, stale shard -> 1");
assert!(
String::from_utf8_lossy(&stale.stderr).contains("modified 001-a.json"),
"stale detail belongs on stderr: {}",
String::from_utf8_lossy(&stale.stderr)
);
fs::write(
&spec_md,
"---\nid: \"mismatched\"\ntitle: \"T\"\nstatus: approved\ncreated: \"2026-06-08\"\nsummary: \"s\"\n---\n",
)
.unwrap();
assert_eq!(
code(&run(&["compile", "--check"])),
1,
"validation outranks staleness"
);
}
#[test]
fn index_coverage_reports_and_gates() {
let tmp = tempfile::tempdir().unwrap();
let r = tmp.path();
let write = |rel: &str, content: &str| {
let p = r.join(rel);
fs::create_dir_all(p.parent().unwrap()).unwrap();
fs::write(p, content).unwrap();
};
write(
"Cargo.toml",
"[package]\nname = \"root\"\nversion = \"0.1.0\"\n",
);
write("src/lib.rs", "pub fn a() {}\n");
write("src/other.rs", "pub fn b() {}\n");
write(
"specs/001-a/spec.md",
"---\nid: \"001-a\"\ntitle: \"T\"\nstatus: approved\ncreated: \"2026-06-08\"\nsummary: \"s\"\nestablishes:\n - \"src/lib.rs\"\n---\n# 001-a\n",
);
let run = |args: &[&str]| bin().arg("--repo").arg(r).args(args).output().unwrap();
assert_eq!(
code(&run(&["index", "coverage"])),
4,
"no committed index -> artifact missing (3)"
);
assert_eq!(code(&run(&["index"])), 0);
let text = run(&["index", "coverage"]);
assert_eq!(code(&text), 0, "a report, not a gate");
let out = String::from_utf8_lossy(&text.stdout);
assert!(
out.contains(
"coverage: 1/2 source files specifically claimed (50.0%); 0 floor-only, 1 unclaimed"
),
"{out}"
);
assert!(
out.contains("unclaimed (no owning spec):\n src/other.rs"),
"{out}"
);
let json = run(&["index", "coverage", "--json"]);
assert_eq!(code(&json), 0);
let report: serde_json::Value = serde_json::from_slice(&json.stdout).unwrap();
assert_eq!(report["sourceFiles"], 2);
assert_eq!(report["claimedFiles"], 1);
assert_eq!(
report["unclaimedFiles"],
serde_json::json!(["src/other.rs"])
);
assert_eq!(
code(&run(&["index", "coverage", "--fail-on-untraced"])),
1,
"an untraced file fails the assertion"
);
write(
"specs/001-a/spec.md",
"---\nid: \"001-a\"\ntitle: \"T\"\nstatus: approved\ncreated: \"2026-06-08\"\nsummary: \"s\"\nestablishes:\n - \"src/\"\n---\n# 001-a\n",
);
assert_eq!(
code(&run(&["index", "coverage"])),
1,
"stale index -> 2, never a report over the wrong ledger"
);
assert_eq!(code(&run(&["index"])), 0);
let full = run(&["index", "coverage", "--fail-on-untraced"]);
assert_eq!(code(&full), 0, "{}", String::from_utf8_lossy(&full.stderr));
assert!(
String::from_utf8_lossy(&full.stdout)
.contains("coverage: 2/2 source files specifically claimed (100.0%)")
);
}
#[test]
fn closed_reader_exits_cleanly_rather_than_panicking() {
use std::io::Read;
use std::process::Stdio;
let tmp = tempfile::tempdir().unwrap();
let filler = "x".repeat(8192);
for i in 0..30 {
let id = format!("{i:03}-spec");
let dir = tmp.path().join("specs").join(&id);
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join("spec.md"),
format!(
"---\nid: \"{id}\"\ntitle: \"T\"\nstatus: approved\ncreated: \"2026-06-08\"\nsummary: \"{filler}\"\n---\n# {id}\n"
),
)
.unwrap();
}
let compiled = bin()
.arg("--repo")
.arg(tmp.path())
.arg("compile")
.output()
.unwrap();
assert_eq!(code(&compiled), 0, "fixture must compile");
let mut child = bin()
.arg("--repo")
.arg(tmp.path())
.args(["registry", "list", "--json"])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let mut stdout = child.stdout.take().unwrap();
let mut buf = [0u8; 32];
let _ = stdout.read(&mut buf);
drop(stdout);
let out = child.wait_with_output().unwrap();
let stderr = String::from_utf8_lossy(&out.stderr);
assert_ne!(
out.status.code(),
Some(101),
"a closed reader must not panic the process; stderr: {stderr}"
);
assert!(
!stderr.contains("panicked"),
"no panic should reach stderr; stderr: {stderr}"
);
assert_eq!(
out.status.code(),
Some(0),
"a reader that stops early is a normal end; stderr: {stderr}"
);
}
fn panicking_stdout_macros(line: &str) -> Vec<usize> {
let mut hits = Vec::new();
if line.trim_start().starts_with("//") {
return hits;
}
for mac in ["print!(", "println!("] {
let mut from = 0;
while let Some(rel) = line[from..].find(mac) {
let at = from + rel;
let is_stderr = at > 0 && line.as_bytes()[at - 1] == b'e';
if !is_stderr {
hits.push(at);
}
from = at + mac.len();
}
}
hits
}
#[test]
fn scanner_does_not_let_a_stderr_call_mask_a_stdout_one() {
assert!(panicking_stdout_macros(r#"eprintln!("x");"#).is_empty());
assert!(panicking_stdout_macros(r#"eprint!("x");"#).is_empty());
assert!(panicking_stdout_macros("// println!(\"a comment\");").is_empty());
assert!(!panicking_stdout_macros(r#"println!("x");"#).is_empty());
assert!(!panicking_stdout_macros(r#"print!("x");"#).is_empty());
assert!(!panicking_stdout_macros(r#"eprintln!("{}", x); println!("{}", y);"#).is_empty());
assert!(!panicking_stdout_macros(r#"eprint!("{}", x); print!("{}", y);"#).is_empty());
}
#[test]
fn no_panicking_stdout_macro_remains_in_the_cli() {
let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut offenders: Vec<String> = Vec::new();
let mut dirs = vec![src.clone()];
let mut files: Vec<std::path::PathBuf> = Vec::new();
while let Some(dir) = dirs.pop() {
for entry in fs::read_dir(&dir).unwrap() {
let p = entry.unwrap().path();
if p.is_dir() {
dirs.push(p);
} else {
files.push(p);
}
}
}
for path in files {
if path.extension().is_none_or(|e| e != "rs") {
continue;
}
let text = fs::read_to_string(&path).unwrap();
for (n, raw) in text.lines().enumerate() {
let line = raw.trim_start();
if !panicking_stdout_macros(line).is_empty() {
offenders.push(format!(
"{}:{}: {line}",
path.strip_prefix(&src).unwrap_or(&path).display(),
n + 1
));
}
}
}
assert!(
offenders.is_empty(),
"CLI stdout must go through out.rs, not a panicking macro (spec 032):\n{}",
offenders.join("\n")
);
}
fn verdict_fixture(root: &Path) {
let w = |rel: &str, content: &str| {
let p = root.join(rel);
fs::create_dir_all(p.parent().unwrap()).unwrap();
fs::write(p, content).unwrap();
};
w("Cargo.toml", "[workspace]\nmembers = [\"crate-a\"]\n");
w(
"crate-a/Cargo.toml",
"[package]\nname = \"crate-a\"\nversion = \"0.1.0\"\n\
[package.metadata.spec-spine]\nspec = \"001-a\"\n",
);
w("crate-a/src/lib.rs", "pub fn a() {}\n");
w(
"specs/001-a/spec.md",
"---\nid: \"001-a\"\ntitle: \"A\"\nstatus: approved\ncreated: \"2026-06-09\"\n\
summary: \"s\"\nestablishes:\n - \"crate-a/src/lib.rs\"\n---\n# 001-a\n## body\n",
);
for verb in ["compile", "index"] {
let out = bin().arg("--repo").arg(root).arg(verb).output().unwrap();
assert_eq!(code(&out), 0, "fixture {verb}: {:?}", out.status);
}
}
fn run_in(root: &Path, args: &[&str]) -> std::process::Output {
bin()
.arg("--repo")
.arg(root)
.args(args)
.output()
.expect("spawn spec-spine")
}
fn changed_paths(root: &Path, paths: &[&str]) -> std::path::PathBuf {
let p = root.join("changed.txt");
fs::write(&p, format!("{}\n", paths.join("\n"))).unwrap();
p
}
fn envelope(out: &std::process::Output) -> serde_json::Value {
let stdout = String::from_utf8_lossy(&out.stdout);
serde_json::from_str(&stdout).unwrap_or_else(|e| {
panic!(
"stdout is not one JSON document ({e}); stdout: {stdout}; stderr: {}",
String::from_utf8_lossy(&out.stderr)
)
})
}
#[test]
fn json_envelope_on_every_adjudicating_verb() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
verdict_fixture(root);
let paths = changed_paths(root, &["crate-a/src/lib.rs", "specs/001-a/spec.md"]);
let paths = paths.to_str().unwrap();
let cases: [(&str, Vec<&str>); 6] = [
("compile.check", vec!["compile", "--check", "--json"]),
("index.check", vec!["index", "check", "--json"]),
("lint", vec!["lint", "--json"]),
("couple", vec!["couple", "--paths-from", paths, "--json"]),
("attest", vec!["attest", "--json"]),
(
"verify-attestation",
vec!["verify-attestation", "--recompute", "--json"],
),
];
for (verb, args) in cases {
let out = run_in(root, &args);
assert_eq!(
code(&out),
0,
"{verb}: {:?}",
String::from_utf8_lossy(&out.stderr)
);
let v = envelope(&out);
assert_eq!(
v["schemaVersion"],
spec_spine_types::VERDICT_SCHEMA_VERSION,
"{verb}"
);
assert_eq!(v["verb"], verb);
assert_eq!(v["outcome"], "ok", "{verb}");
assert_eq!(v["exitCode"], 0, "{verb}");
assert!(v.get("report").is_some(), "{verb} must carry a report");
assert!(v.get("error").is_none(), "{verb} must carry no error");
}
}
#[test]
fn json_exit_codes_match_the_prose_form() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
verdict_fixture(root);
let paths = changed_paths(root, &["crate-a/src/lib.rs"]);
let paths = paths.to_str().unwrap();
let prose = run_in(root, &["couple", "--paths-from", paths]);
let json = run_in(root, &["couple", "--paths-from", paths, "--json"]);
assert_eq!(code(&prose), 1);
assert_eq!(code(&json), code(&prose), "couple drift");
let v = envelope(&json);
assert_eq!(v["exitCode"], 1);
assert_ne!(v["outcome"], "ok");
assert!(
!v["report"]["violations"].as_array().unwrap().is_empty(),
"the reasons ride in the report, not in prose"
);
let spec = root.join("specs/001-a/spec.md");
let body = fs::read_to_string(&spec).unwrap();
fs::write(&spec, body.replace("## body", "## body edited")).unwrap();
for args in [
["index", "check"].as_slice(),
["compile", "--check"].as_slice(),
] {
let prose = run_in(root, args);
let mut json_args = args.to_vec();
json_args.push("--json");
let json = run_in(root, &json_args);
assert_eq!(code(&prose), 1, "{args:?} prose");
assert_eq!(code(&json), code(&prose), "{args:?} json");
let v = envelope(&json);
assert_ne!(v["outcome"], "ok", "{args:?}");
assert_eq!(v["report"]["fresh"], false, "{args:?}");
assert!(
v["report"]["expected"].is_string(),
"{args:?}: the stale detail rides in the report"
);
}
}
#[test]
fn json_report_equals_the_facade_payload() {
use spec_spine_core::{
attest_json, check_freshness_json, check_registry_freshness_json, couple_json, lint_json,
verify_attestation_json,
};
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
verdict_fixture(root);
let repo = root.to_str().unwrap();
let parse = |s: String| serde_json::from_str::<serde_json::Value>(&s).unwrap();
let cases: Vec<(&str, Vec<String>, serde_json::Value)> = vec![
(
"lint",
vec!["lint".into(), "--json".into()],
parse(lint_json("{}", repo).unwrap()),
),
(
"index.check",
vec!["index".into(), "check".into(), "--json".into()],
parse(check_freshness_json("{}", repo).unwrap()),
),
(
"compile.check",
vec!["compile".into(), "--check".into(), "--json".into()],
parse(check_registry_freshness_json("{}", repo).unwrap()),
),
(
"attest",
vec!["attest".into(), "--json".into()],
parse(attest_json("{}", repo, false).unwrap()),
),
];
for (verb, args, expected) in cases {
let args: Vec<&str> = args.iter().map(String::as_str).collect();
let out = run_in(root, &args);
assert_eq!(envelope(&out)["report"], expected, "{verb}");
}
let paths = changed_paths(root, &["crate-a/src/lib.rs"]);
let out = run_in(
root,
&["couple", "--paths-from", paths.to_str().unwrap(), "--json"],
);
let request = serde_json::json!({
"repoRoot": repo,
"diff": { "files": [{ "path": "crate-a/src/lib.rs", "hunks": [], "deleted": false }] },
});
let expected = parse(couple_json(&request.to_string()).unwrap());
assert_eq!(envelope(&out)["report"], expected, "couple");
let attestation: serde_json::Value = serde_json::from_slice(
&fs::read(root.join(".derived/attestation/attestation.json")).unwrap(),
)
.unwrap();
let out = run_in(root, &["verify-attestation", "--recompute", "--json"]);
let request = serde_json::json!({ "repoRoot": repo, "attestation": attestation });
let expected = parse(verify_attestation_json(&request.to_string()).unwrap());
assert_eq!(envelope(&out)["report"], expected, "verify-attestation");
}
#[test]
fn json_error_path_is_an_envelope_on_stdout() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
verdict_fixture(root);
fs::write(root.join("spec-spine.toml"), "[layout\n").unwrap();
let out = run_in(root, &["lint", "--json"]);
assert_eq!(code(&out), 2);
let v = envelope(&out);
assert_eq!(v["verb"], "lint");
assert_eq!(v["exitCode"], 2);
assert_ne!(v["outcome"], "ok");
assert_eq!(v["error"]["kind"], "config");
assert!(v.get("report").is_none(), "error and report are exclusive");
assert!(
v["error"]["message"].as_str().unwrap().len() > 1,
"the message is human text, present but unpromised"
);
fs::remove_file(root.join("spec-spine.toml")).unwrap();
let out = run_in(root, &["compile", "--json"]);
assert_eq!(code(&out), 3);
assert_eq!(envelope(&out)["error"]["kind"], "usage");
fs::write(
root.join("specs/001-a/spec.md"),
"---\nid: \"999-mismatched\"\ntitle: \"A\"\nstatus: approved\n\
created: \"2026-06-09\"\nsummary: \"s\"\n---\n# x\n",
)
.unwrap();
let prose = run_in(root, &["compile", "--check"]);
let out = run_in(root, &["compile", "--check", "--json"]);
assert_eq!(code(&prose), 1, "id must match the directory name");
assert_eq!(code(&out), code(&prose), "the flag does not move the code");
let v = envelope(&out);
assert_eq!(v["error"]["kind"], "validation");
let violations = v["error"]["violations"].as_array().unwrap();
assert!(!violations.is_empty(), "{v}");
assert!(
violations[0]["code"].as_str().unwrap().starts_with("V-"),
"{v}"
);
assert!(
out.stderr.is_empty(),
"no second channel under --json: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
!prose.stderr.is_empty(),
"the prose form still reports each violation on stderr"
);
}
#[test]
fn json_survives_a_closed_reader_on_every_verb() {
use std::io::Read;
use std::process::Stdio;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
verdict_fixture(root);
let paths = changed_paths(root, &["crate-a/src/lib.rs"]);
let paths = paths.to_string_lossy().into_owned();
let cases: [Vec<&str>; 6] = [
vec!["compile", "--check", "--json"],
vec!["index", "check", "--json"],
vec!["lint", "--json"],
vec!["couple", "--paths-from", &paths, "--json"],
vec!["attest", "--json"],
vec!["verify-attestation", "--recompute", "--json"],
];
for args in cases {
let mut child = bin()
.arg("--repo")
.arg(root)
.args(&args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let mut stdout = child.stdout.take().unwrap();
let mut buf = [0u8; 8];
let _ = stdout.read(&mut buf);
drop(stdout);
let out = child.wait_with_output().unwrap();
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
!stderr.contains("panicked"),
"{args:?} panicked on a closed reader: {stderr}"
);
assert_ne!(out.status.code(), Some(101), "{args:?}");
}
}
#[test]
fn prose_output_is_unchanged_without_the_flag() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
verdict_fixture(root);
let compile = run_in(root, &["compile", "--check"]);
assert!(
String::from_utf8_lossy(&compile.stdout).starts_with("spec-registry is fresh:"),
"{}",
String::from_utf8_lossy(&compile.stdout)
);
let index = run_in(root, &["index", "check"]);
let index_out = String::from_utf8_lossy(&index.stdout);
assert_eq!(
index_out.lines().next(),
Some("index is fresh"),
"{index_out}"
);
assert!(index_out.contains("unwitnessed claims: 1"), "{index_out}");
let lint = run_in(root, &["lint"]);
assert!(
String::from_utf8_lossy(&lint.stdout).contains("lint: 0 error(s)"),
"{}",
String::from_utf8_lossy(&lint.stdout)
);
let paths = changed_paths(root, &["crate-a/src/lib.rs", "specs/001-a/spec.md"]);
let couple = run_in(root, &["couple", "--paths-from", paths.to_str().unwrap()]);
assert!(
String::from_utf8_lossy(&couple.stdout).contains("no drift"),
"{}",
String::from_utf8_lossy(&couple.stdout)
);
let attest = run_in(root, &["attest"]);
assert!(
String::from_utf8_lossy(&attest.stdout).contains("attestationHash:"),
"{}",
String::from_utf8_lossy(&attest.stdout)
);
let verify = run_in(root, &["verify-attestation", "--recompute"]);
assert!(
String::from_utf8_lossy(&verify.stdout).contains("recompute: MATCH"),
"{}",
String::from_utf8_lossy(&verify.stdout)
);
}
#[test]
fn json_verify_attestation_reports_the_signature_mode() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
verdict_fixture(root);
let seed = root.join("signing.key");
fs::write(&seed, [7u8; 32]).unwrap();
let signed = run_in(root, &["attest", "--sign", "--key", seed.to_str().unwrap()]);
assert_eq!(
code(&signed),
0,
"{}",
String::from_utf8_lossy(&signed.stderr)
);
let seal: serde_json::Value = serde_json::from_slice(
&fs::read(root.join(".derived/attestation/attestation.sig")).unwrap(),
)
.unwrap();
let key_id = seal["keyId"].as_str().unwrap().to_string();
let public = root.join("public.hex");
fs::write(&public, &key_id).unwrap();
let out = run_in(
root,
&[
"verify-attestation",
"--recompute",
"--signature",
"--public-key",
public.to_str().unwrap(),
"--json",
],
);
assert_eq!(code(&out), 0, "{}", String::from_utf8_lossy(&out.stderr));
let v = envelope(&out);
assert_eq!(v["outcome"], "ok");
assert_eq!(v["report"]["outcome"], "match");
assert_eq!(v["report"]["signature"]["valid"], true);
assert_eq!(v["report"]["signature"]["keyId"], key_id.as_str());
let out = run_in(
root,
&[
"verify-attestation",
"--signature",
"--public-key",
public.to_str().unwrap(),
"--json",
],
);
assert_eq!(code(&out), 0);
let v = envelope(&out);
assert!(v["report"].get("outcome").is_none(), "{v}");
assert_eq!(v["report"]["signature"]["valid"], true);
fs::write(&public, "00".repeat(32)).unwrap();
let out = run_in(
root,
&[
"verify-attestation",
"--signature",
"--public-key",
public.to_str().unwrap(),
"--json",
],
);
assert_eq!(code(&out), 1);
let v = envelope(&out);
assert_ne!(v["outcome"], "ok");
assert_eq!(v["exitCode"], 1);
assert_eq!(v["report"]["signature"]["valid"], false);
}
#[test]
fn json_verify_attestation_with_no_mode_is_an_error_envelope() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
verdict_fixture(root);
let out = run_in(root, &["verify-attestation", "--json"]);
assert_eq!(code(&out), 3);
let v = envelope(&out);
assert_ne!(v["outcome"], "ok");
assert_eq!(v["error"]["kind"], "usage");
assert!(v.get("report").is_none());
}
#[test]
fn registry_plan_partitions_the_corpus() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let spec = |id: &str, body: &str| {
let dir = root.join("specs").join(id);
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join("spec.md"),
format!(
"---\nid: \"{id}\"\ntitle: \"T\"\nstatus: approved\ncreated: \"2026-09-06\"\n\
summary: \"s\"\n{body}---\n# {id}\n"
),
)
.unwrap();
};
spec("001-done", "implementation: complete\n");
spec(
"002-now",
"implementation: pending\ndepends_on: [\"001-done\"]\n",
);
spec(
"003-later",
"implementation: pending\ndepends_on: [\"002-now\"]\n",
);
assert_eq!(code(&run_in(root, &["compile"])), 0);
let prose = run_in(root, &["registry", "plan"]);
assert_eq!(
code(&prose),
0,
"{}",
String::from_utf8_lossy(&prose.stderr)
);
let text = String::from_utf8_lossy(&prose.stdout);
assert!(text.contains("ready (1):"), "{text}");
assert!(text.contains("002-now T"), "{text}");
assert!(text.contains("blocked (1):"), "{text}");
assert!(text.contains("blocked by 002-now (pending)"), "{text}");
assert!(
text.contains("3 specs: 1 ready, 1 blocked, 1 not schedulable"),
"{text}"
);
assert!(
!text.contains("001-done"),
"a finished spec is not offered: {text}"
);
let out = run_in(root, &["registry", "plan", "--json"]);
assert_eq!(code(&out), 0);
let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(
v["schemaVersion"],
spec_spine_types::READ_SCHEMA_VERSION,
"{v}"
);
assert!(v.get("report").is_none() && v.get("ok").is_none(), "{v}");
assert_eq!(
v["ready"],
serde_json::json!([{ "id": "002-now", "status": "approved", "title": "T" }])
);
assert_eq!(
v["blocked"],
serde_json::json!([
{
"id": "003-later",
"title": "T",
"blockedBy": [{ "id": "002-now", "state": "pending" }]
}
])
);
assert_eq!(v["notSchedulable"], 1);
let next = run_in(root, &["registry", "plan", "--next", "--json"]);
assert_eq!(code(&next), 0);
let n: serde_json::Value = serde_json::from_slice(&next.stdout).unwrap();
assert_eq!(
n,
serde_json::json!({
"next": { "id": "002-now", "status": "approved", "title": "T" },
"schemaVersion": spec_spine_types::READ_SCHEMA_VERSION,
})
);
let empty = tempfile::tempdir().unwrap();
let dir = empty.path().join("specs/001-done");
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join("spec.md"),
"---\nid: \"001-done\"\ntitle: \"T\"\nstatus: approved\ncreated: \"2026-09-06\"\n\
summary: \"s\"\nimplementation: complete\n---\n# 001-done\n",
)
.unwrap();
assert_eq!(code(&run_in(empty.path(), &["compile"])), 0);
let out = run_in(empty.path(), &["registry", "plan"]);
assert_eq!(code(&out), 0);
let text = String::from_utf8_lossy(&out.stdout);
assert_eq!(
text.trim(),
"(nothing ready), blocked: 0\n\n1 specs: 0 ready, 0 blocked, 1 not schedulable",
"the `(nothing ready)` line is unchanged (spec 053 §3.1) and the \
remainder follows it: on a finished corpus that figure is the whole \
answer, and without it `blocked: 0` reads as though the specs vanished"
);
}
#[test]
fn attest_spec_writes_signs_and_verifies_one_spec() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
verdict_fixture(root);
let out = run_in(root, &["attest", "--spec", "001-a"]);
assert_eq!(code(&out), 0, "{}", String::from_utf8_lossy(&out.stderr));
let path = root.join(".derived/attestation/by-spec/001-a.json");
assert!(path.is_file(), "the payload lands under by-spec/");
assert!(!root.join(".derived/attestation/attestation.json").exists());
let payload: serde_json::Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
assert_eq!(payload["specId"], "001-a");
assert_eq!(payload["schemaVersion"], "0.1.0");
assert_eq!(payload["lifecycle"]["status"], "approved");
assert_eq!(payload["verdicts"]["resolution"]["ok"], true);
assert_eq!(
payload["units"][0]["unit"]["path"], "crate-a/src/lib.rs",
"the owning unit, with the hash of what it resolved to"
);
assert!(payload["units"][0]["contentHash"].is_string());
let seed = root.join("signing.key");
fs::write(&seed, [3u8; 32]).unwrap();
let signed = run_in(
root,
&[
"attest",
"--spec",
"001-a",
"--sign",
"--key",
seed.to_str().unwrap(),
],
);
assert_eq!(
code(&signed),
0,
"{}",
String::from_utf8_lossy(&signed.stderr)
);
let seal_path = root.join(".derived/attestation/by-spec/001-a.sig");
assert!(seal_path.is_file(), "the seal is the payload's sibling");
let seal: serde_json::Value = serde_json::from_slice(&fs::read(&seal_path).unwrap()).unwrap();
let public = root.join("public.hex");
fs::write(&public, seal["keyId"].as_str().unwrap()).unwrap();
let verified = run_in(
root,
&[
"verify-attestation",
"--spec",
"001-a",
"--recompute",
"--signature",
"--public-key",
public.to_str().unwrap(),
],
);
assert_eq!(
code(&verified),
0,
"{}",
String::from_utf8_lossy(&verified.stderr)
);
let text = String::from_utf8_lossy(&verified.stdout);
assert!(text.contains("recompute: MATCH"), "{text}");
assert!(text.contains("signature: VALID"), "{text}");
fs::write(
root.join("crate-a/src/lib.rs"),
"pub fn a() {}\npub fn b() {}\n",
)
.unwrap();
let stale = run_in(
root,
&["verify-attestation", "--spec", "001-a", "--recompute"],
);
assert_eq!(code(&stale), 1);
assert!(
String::from_utf8_lossy(&stale.stderr).contains("MISMATCH"),
"{}",
String::from_utf8_lossy(&stale.stderr)
);
}
#[test]
fn attest_exits_zero_on_a_false_verdict_in_both_scopes() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
verdict_fixture(root);
fs::write(
root.join("specs/001-a/spec.md"),
"---\nid: \"001-a\"\ntitle: \"A\"\nstatus: approved\ncreated: \"2026-06-09\"\n\
summary: \"s\"\nestablishes:\n - \"crate-a/src/lib.rs\"\n - \"crate-a/src/never.rs\"\n\
---\n# 001-a\n## body\n",
)
.unwrap();
assert_eq!(code(&run_in(root, &["compile"])), 0);
assert_eq!(code(&run_in(root, &["index"])), 0);
let scoped = run_in(root, &["attest", "--spec", "001-a"]);
assert_eq!(
code(&scoped),
0,
"a record is written whatever it says: {}",
String::from_utf8_lossy(&scoped.stderr)
);
let payload: serde_json::Value = serde_json::from_slice(
&fs::read(root.join(".derived/attestation/by-spec/001-a.json")).unwrap(),
)
.unwrap();
assert_eq!(
payload["verdicts"]["resolution"]["ok"], false,
"the false verdict is recorded, not suppressed"
);
let corpus = run_in(root, &["attest"]);
assert_eq!(
code(&corpus),
0,
"{}",
String::from_utf8_lossy(&corpus.stderr)
);
}
#[test]
fn attest_spec_refuses_an_unknown_id() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
verdict_fixture(root);
let out = run_in(root, &["attest", "--spec", "999-nope"]);
assert_eq!(code(&out), 1);
assert!(!root.join(".derived/attestation/by-spec").exists());
}
#[test]
fn attest_spec_json_rides_in_the_verdict_envelope() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
verdict_fixture(root);
let out = run_in(root, &["attest", "--spec", "001-a", "--json"]);
assert_eq!(code(&out), 0);
let v = envelope(&out);
assert_eq!(v["verb"], "attest");
assert_eq!(v["outcome"], "ok");
assert_eq!(v["report"]["attestation"]["specId"], "001-a");
assert!(v["report"]["attestationHash"].is_string());
assert!(
!root.join("spec-spine.toml").exists(),
"this comparison assumes the fixture is on the default config"
);
let expected: serde_json::Value = serde_json::from_str(
&spec_spine_core::attest_spec_json("{}", root.to_str().unwrap(), "001-a").unwrap(),
)
.unwrap();
assert_eq!(v["report"], expected, "one payload shape per verb");
}
#[test]
fn attest_refuses_with_coupling_scoped_to_one_spec() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
verdict_fixture(root);
let out = run_in(root, &["attest", "--spec", "001-a", "--with-coupling"]);
assert_eq!(code(&out), 3, "a mode that cannot run fails visibly");
let message = String::from_utf8_lossy(&out.stderr);
assert!(message.contains("--with-coupling"), "{message}");
assert!(message.contains("--spec"), "{message}");
assert!(
!root.join(".derived/attestation/by-spec").exists(),
"and writes nothing"
);
assert_eq!(code(&run_in(root, &["attest", "--spec", "001-a"])), 0);
assert_eq!(code(&run_in(root, &["attest", "--with-coupling"])), 0);
}
#[test]
fn verify_attestation_refuses_a_traversing_spec_id() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
verdict_fixture(root);
for id in ["../../etc/passwd", "..", "a/b", ""] {
let out = run_in(root, &["verify-attestation", "--spec", id, "--recompute"]);
assert_eq!(code(&out), 3, "id {id:?} must be refused");
assert!(
String::from_utf8_lossy(&out.stderr).contains("not a spec id"),
"id {id:?}: {}",
String::from_utf8_lossy(&out.stderr)
);
}
assert_eq!(code(&run_in(root, &["attest", "--spec", "001-a"])), 0);
assert_eq!(
code(&run_in(
root,
&["verify-attestation", "--spec", "001-a", "--recompute"]
)),
0
);
}
#[test]
fn the_seal_path_follows_the_attestation_it_signs() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
verdict_fixture(root);
let seed = root.join("signing.key");
fs::write(&seed, [5u8; 32]).unwrap();
let key = seed.to_str().unwrap();
assert_eq!(code(&run_in(root, &["attest", "--sign", "--key", key])), 0);
assert!(root.join(".derived/attestation/attestation.sig").is_file());
fs::write(root.join("crate-a/src/other.rs"), "pub fn b() {}\n").unwrap();
fs::create_dir_all(root.join("specs/002-b")).unwrap();
fs::write(
root.join("specs/002-b/spec.md"),
"---\nid: \"002-b\"\ntitle: \"B\"\nstatus: approved\ncreated: \"2026-06-09\"\n\
summary: \"s\"\nestablishes:\n - \"crate-a/src/other.rs\"\n---\n# 002-b\n## body\n",
)
.unwrap();
for id in ["001-a", "002-b"] {
assert_eq!(
code(&run_in(
root,
&["attest", "--spec", id, "--sign", "--key", key]
)),
0,
"sign {id}"
);
assert!(
root.join(format!(".derived/attestation/by-spec/{id}.sig"))
.is_file(),
"{id} seals beside its own payload"
);
}
}
fn write_verify_spec(root: &Path, dir: &str, section: &str) {
let spec_dir = root.join("specs").join(dir);
fs::create_dir_all(&spec_dir).unwrap();
let body = format!(
"---\nid: \"{dir}\"\ntitle: \"T\"\nstatus: approved\ncreated: \"2026-09-06\"\nsummary: \"s\"\n---\n# {dir}\n\n## Verification\n\n{section}\n"
);
fs::write(spec_dir.join("spec.md"), body).unwrap();
}
#[test]
fn verify_runs_commands_and_reports_outcomes() {
let tmp = tempfile::tempdir().unwrap();
write_verify_spec(tmp.path(), "001-pass", "```verify:cli\ntrue\ntrue\n```");
write_verify_spec(
tmp.path(),
"002-fail",
"```verify:cli\ntrue\nexit 7\ntrue\n```",
);
write_verify_spec(tmp.path(), "003-prose", "- a prose bullet only");
let run = |args: &[&str]| {
bin()
.arg("--repo")
.arg(tmp.path())
.args(args)
.output()
.unwrap()
};
let out = run(&["verify", "001-pass"]);
assert_eq!(code(&out), 0);
assert!(
String::from_utf8_lossy(&out.stdout).contains("passed (2 command(s))"),
"{}",
String::from_utf8_lossy(&out.stdout)
);
let out = run(&["verify", "002-fail"]);
assert_eq!(code(&out), 1, "a failing command is a drift-tier 1");
assert_eq!(code(&run(&["verify", "003-prose"])), 0);
assert_eq!(code(&run(&["verify", "404-gone"])), 1);
}
#[test]
fn verify_json_is_a_verdict_envelope_that_agrees_with_the_exit_code() {
let tmp = tempfile::tempdir().unwrap();
write_verify_spec(
tmp.path(),
"001-pass",
"```verify:cli\nprintf 'O%sT-F\\n' U; printf 'E%sR-F\\n' R >&2\n```",
);
write_verify_spec(
tmp.path(),
"002-fail",
"```verify:cli\ntrue\nexit 7\n```\n\n```verify:browser\nclick\n```",
);
write_verify_spec(tmp.path(), "003-prose", "- prose");
let json = |id: &str| -> (i32, serde_json::Value) {
let out = bin()
.arg("--repo")
.arg(tmp.path())
.args(["verify", id, "--json"])
.output()
.unwrap();
(
code(&out),
serde_json::from_slice(&out.stdout).expect("stdout is one JSON envelope"),
)
};
let (c, v) = json("001-pass");
assert_eq!(c, 0);
assert_eq!(v["verb"], "verify");
assert_eq!(v["schemaVersion"], spec_spine_types::VERDICT_SCHEMA_VERSION);
assert_eq!(v["outcome"], "ok");
assert_eq!(v["exitCode"], 0);
assert_eq!(v["report"]["outcome"], "passed");
assert_eq!(v["report"]["declared"], true);
assert_eq!(v["report"]["ran"], 1);
assert_eq!(v["report"]["total"], 1);
let (c, v) = json("002-fail");
assert_eq!(c, 1);
assert_eq!(v["exitCode"], 1, "the envelope never advertises 7");
assert_eq!(
v["report"]["failure"]["exitCode"], 7,
"but the payload keeps it"
);
assert_eq!(v["report"]["failure"]["index"], 2);
assert_eq!(v["report"]["failure"]["command"], "exit 7");
assert_eq!(v["report"]["ran"], 2);
assert_eq!(v["report"]["total"], 2);
assert_eq!(v["report"]["skipped"][0]["tag"], "verify:browser");
let (c, v) = json("003-prose");
assert_eq!(c, 0);
assert_eq!(v["outcome"], "ok");
assert_eq!(v["report"]["outcome"], "not-declared");
assert_eq!(v["report"]["declared"], false);
}
#[test]
fn verify_runs_from_the_repo_root_and_stops_at_the_first_failure() {
let tmp = tempfile::tempdir().unwrap();
write_verify_spec(
tmp.path(),
"001-order",
"```verify:cli\nprintf a >> log.txt\nfalse\nprintf c >> log.txt\n```",
);
let out = bin()
.arg("--repo")
.arg(tmp.path())
.args(["verify", "001-order"])
.output()
.unwrap();
assert_eq!(code(&out), 1);
let log = fs::read_to_string(tmp.path().join("log.txt")).unwrap();
assert_eq!(log, "a", "later commands must not run after a failure");
}
#[test]
fn verify_plan_reads_without_running() {
let tmp = tempfile::tempdir().unwrap();
write_verify_spec(
tmp.path(),
"001-p",
"```verify:cli\nprintf ran >> side_effect.txt\n# a comment\nsecond\n```",
);
let out = bin()
.arg("--repo")
.arg(tmp.path())
.args(["verify", "001-p", "--plan"])
.output()
.unwrap();
assert_eq!(code(&out), 0);
let stdout = String::from_utf8_lossy(&out.stdout);
assert_eq!(
stdout.lines().collect::<Vec<_>>(),
["printf ran >> side_effect.txt", "second"],
"comments are stripped, both commands listed"
);
assert!(
!tmp.path().join("side_effect.txt").exists(),
"--plan must run nothing"
);
}
#[test]
fn verify_refuses_to_re_enter_itself() {
let tmp = tempfile::tempdir().unwrap();
write_verify_spec(
tmp.path(),
"001-loop",
"```verify:cli\nSELF --repo REPO verify 001-loop\n```",
);
let spec = tmp.path().join("specs/001-loop/spec.md");
let body = fs::read_to_string(&spec)
.unwrap()
.replace("SELF", env!("CARGO_BIN_EXE_spec-spine"))
.replace("REPO", tmp.path().to_str().unwrap());
fs::write(&spec, body).unwrap();
let out = bin()
.arg("--repo")
.arg(tmp.path())
.args(["verify", "001-loop"])
.output()
.unwrap();
assert_eq!(code(&out), 1);
let out = bin()
.arg("--repo")
.arg(tmp.path())
.args(["verify", "001-loop"])
.env("SPEC_SPINE_VERIFY_STACK", "001-loop")
.output()
.unwrap();
assert_eq!(code(&out), 1);
assert!(
String::from_utf8_lossy(&out.stderr).contains("validation"),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let out = bin()
.arg("--repo")
.arg(tmp.path())
.args(["verify", "001-loop", "--plan"])
.env("SPEC_SPINE_VERIFY_STACK", "002-other")
.output()
.unwrap();
assert_eq!(code(&out), 0, "only a cycle is refused, not any depth");
}
fn write_unresolved_corpus(root: &Path) {
fs::create_dir_all(root.join("crates/a/src")).unwrap();
fs::write(
root.join("Cargo.toml"),
"[workspace]\nmembers = [\"crates/a\"]\n",
)
.unwrap();
fs::write(
root.join("crates/a/Cargo.toml"),
"[package]\nname = \"a\"\nversion = \"0.1.0\"\n\n[package.metadata.spec-spine]\nspec = \"001-flight\"\n",
)
.unwrap();
fs::write(
root.join("crates/a/src/lib.rs"),
"// Spec: specs/001-flight/spec.md\npub fn a() {}\n",
)
.unwrap();
let spec_dir = root.join("specs/001-flight");
fs::create_dir_all(&spec_dir).unwrap();
fs::write(
spec_dir.join("spec.md"),
"---\nid: \"001-flight\"\ntitle: \"T\"\nstatus: draft\ncreated: \"2026-09-06\"\nimplementation: pending\nsummary: \"s\"\nestablishes:\n - \"crates/a/src/lib.rs\"\n - \"crates/a/src/not_yet.rs\"\n---\n# 001\n",
)
.unwrap();
}
#[test]
fn index_check_reports_diagnostics_and_fails_only_when_asked() {
let tmp = tempfile::tempdir().unwrap();
write_unresolved_corpus(tmp.path());
let run = |args: &[&str]| {
bin()
.arg("--repo")
.arg(tmp.path())
.args(args)
.output()
.unwrap()
};
assert_eq!(code(&run(&["index"])), 0);
let out = run(&["index", "check"]);
assert_eq!(code(&out), 0, "a warning must not fail the default gate");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("is fresh"), "{stdout}");
assert!(stdout.contains("1 W-001"), "{stdout}");
let out = run(&["index", "check", "--fail-on-unresolved"]);
assert_eq!(code(&out), 1);
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stdout.contains("--fail-on-unresolved refuses"), "{stdout}");
assert!(
stderr.trim().is_empty(),
"the refusal belongs on one stream, got stderr: {stderr}"
);
assert!(
stdout.lines().all(|l| l.trim() != "index is fresh"),
"a bare pass line while exiting 1: {stdout}"
);
}
#[test]
fn a_clean_corpus_keeps_the_bare_verdict_line() {
let tmp = tempfile::tempdir().unwrap();
write_spec(tmp.path(), "001-a", "001-a", "approved");
let run = |args: &[&str]| {
bin()
.arg("--repo")
.arg(tmp.path())
.args(args)
.output()
.unwrap()
};
assert_eq!(code(&run(&["index"])), 0);
let out = run(&["index", "check"]);
assert_eq!(code(&out), 0);
assert_eq!(
String::from_utf8_lossy(&out.stdout).trim(),
"index is fresh",
"no diagnostics -> the line reads exactly as it did before spec 044"
);
assert_eq!(code(&run(&["index", "check", "--fail-on-unresolved"])), 0);
}
#[test]
fn staleness_outranks_unresolution() {
let tmp = tempfile::tempdir().unwrap();
write_unresolved_corpus(tmp.path());
let run = |args: &[&str]| {
bin()
.arg("--repo")
.arg(tmp.path())
.args(args)
.output()
.unwrap()
};
assert_eq!(code(&run(&["index"])), 0);
assert_eq!(code(&run(&["index", "check", "--fail-on-unresolved"])), 1);
let spec = tmp.path().join("specs/001-flight/spec.md");
let body = fs::read_to_string(&spec)
.unwrap()
.replace("\"T\"", "\"T2\"");
fs::write(&spec, body).unwrap();
assert_eq!(
code(&run(&["index", "check", "--fail-on-unresolved"])),
1,
"staleness must outrank unresolution"
);
}
#[test]
fn index_check_json_carries_counts_without_disturbing_the_freshness_shape() {
let tmp = tempfile::tempdir().unwrap();
write_unresolved_corpus(tmp.path());
let run = |args: &[&str]| {
bin()
.arg("--repo")
.arg(tmp.path())
.args(args)
.output()
.unwrap()
};
assert_eq!(code(&run(&["index"])), 0);
let out = run(&["index", "check", "--json"]);
let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(v["verb"], "index.check");
assert_eq!(
v["report"]["fresh"], true,
"the freshness member is untouched"
);
assert_eq!(v["report"]["diagnostics"]["warnings"], 1);
assert_eq!(v["report"]["diagnostics"]["errors"], 0);
assert_eq!(v["report"]["diagnostics"]["byCode"]["W-001"], 1);
assert_eq!(v["schemaVersion"], spec_spine_types::VERDICT_SCHEMA_VERSION);
let out = run(&["compile", "--check", "--json"]);
let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(v["verb"], "compile.check");
assert!(
v["report"].get("diagnostics").is_none(),
"the registry verdict must not carry index diagnostics: {}",
v["report"]
);
}
#[test]
fn index_diagnostics_lists_them_and_never_refuses() {
let tmp = tempfile::tempdir().unwrap();
write_unresolved_corpus(tmp.path());
let run = |args: &[&str]| {
bin()
.arg("--repo")
.arg(tmp.path())
.args(args)
.output()
.unwrap()
};
assert_eq!(code(&run(&["index"])), 0);
let out = run(&["index", "diagnostics", "--json"]);
assert_eq!(code(&out), 0);
let doc: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
let v = &doc["items"];
assert_eq!(v.as_array().unwrap().len(), 1);
assert_eq!(v[0]["code"], "W-001");
assert_eq!(v[0]["specId"], "001-flight", "attributed to its spec");
assert_eq!(v[0]["severity"], "warning");
let out = run(&["index", "diagnostics"]);
assert_eq!(code(&out), 0);
assert!(String::from_utf8_lossy(&out.stdout).contains("W-001"));
}
#[test]
fn a_usage_error_is_exit_three_not_stale() {
let tmp = tempfile::tempdir().unwrap();
for args in [
vec!["compile", "--no-such-flag"],
vec!["no-such-verb"],
vec!["registry", "show"], vec!["index", "check", "--slice"], ] {
let out = run_in(tmp.path(), &args);
assert_eq!(
code(&out),
3,
"{args:?} must be a usage error, not staleness: {}",
String::from_utf8_lossy(&out.stderr)
);
}
}
#[test]
fn a_missing_subcommand_is_a_usage_error() {
let tmp = tempfile::tempdir().unwrap();
let out = run_in(tmp.path(), &[]);
assert_eq!(code(&out), 3, "nothing was asked for, so nothing succeeded");
}
#[test]
fn help_and_version_stay_exit_zero_on_stdout() {
let tmp = tempfile::tempdir().unwrap();
for args in [vec!["--help"], vec!["--version"], vec!["compile", "--help"]] {
let out = run_in(tmp.path(), &args);
assert_eq!(code(&out), 0, "{args:?}");
assert!(!out.stdout.is_empty(), "{args:?} writes to stdout");
}
}
#[test]
fn exit_two_still_means_a_stale_ledger() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let dir = root.join("specs/001-a");
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join("spec.md"),
"---\nid: \"001-a\"\ntitle: \"T\"\nstatus: approved\ncreated: \"2026-09-07\"\n\
summary: \"s\"\nestablishes:\n - \"specs/001-a/spec.md\"\n---\n# 001-a\n## body\n",
)
.unwrap();
assert_eq!(code(&run_in(root, &["compile"])), 0);
fs::write(
dir.join("spec.md"),
"---\nid: \"001-a\"\ntitle: \"T2\"\nstatus: approved\ncreated: \"2026-09-07\"\n\
summary: \"s\"\nestablishes:\n - \"specs/001-a/spec.md\"\n---\n# 001-a\n## body\n",
)
.unwrap();
assert_eq!(
code(&run_in(root, &["compile", "--check"])),
1,
"the one condition exit 2 is for"
);
}
fn fresh_repo() -> tempfile::TempDir {
let tmp = tempfile::tempdir().unwrap();
write_spec(tmp.path(), "001-a", "001-a", "approved");
for verb in [&["compile"][..], &["index"][..]] {
let out = bin()
.arg("--repo")
.arg(tmp.path())
.args(verb)
.output()
.unwrap();
assert_eq!(code(&out), 0, "{}", String::from_utf8_lossy(&out.stderr));
}
tmp
}
fn check(root: &Path, args: &[&str]) -> std::process::Output {
bin()
.arg("--repo")
.arg(root)
.arg("check")
.args(args)
.output()
.unwrap()
}
#[test]
fn check_reports_both_trees_and_exits_zero_when_both_are_fresh() {
let tmp = fresh_repo();
let out = check(tmp.path(), &[]);
assert_eq!(code(&out), 0, "{}", String::from_utf8_lossy(&out.stderr));
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("spec-registry: fresh"), "{stdout}");
assert!(stdout.contains("codebase-index: fresh"), "{stdout}");
}
#[test]
fn check_never_writes_even_when_the_tree_is_stale() {
let tmp = fresh_repo();
write_spec(tmp.path(), "002-b", "002-b", "approved");
let derived = tmp.path().join(".derived");
let snapshot = |dir: &Path| -> Vec<(String, String)> {
let mut out = Vec::new();
let mut stack = vec![dir.to_path_buf()];
while let Some(d) = stack.pop() {
for e in fs::read_dir(&d).unwrap().filter_map(Result::ok) {
let p = e.path();
if p.is_dir() {
stack.push(p);
} else if p.file_name().unwrap() != "build-meta.json" {
out.push((
p.strip_prefix(dir).unwrap().display().to_string(),
fs::read_to_string(&p).unwrap_or_default(),
));
}
}
}
out.sort();
out
};
let before = snapshot(&derived);
assert!(!before.is_empty(), "the fixture must have committed shards");
let out = check(tmp.path(), &[]);
assert_eq!(code(&out), 1, "a stale tree is exit 1");
assert_eq!(
before,
snapshot(&derived),
"`check` repaired the tree it was asked to judge"
);
}
#[test]
fn check_attributes_staleness_to_the_tree_it_belongs_to() {
let tmp = fresh_repo();
write_spec(tmp.path(), "002-b", "002-b", "approved");
let out = check(tmp.path(), &[]);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("spec-registry: STALE"), "{stderr}");
assert!(
stderr.contains("002-b"),
"the drifted shard must still be named: {stderr}"
);
assert!(stderr.contains("codebase-index: STALE"), "{stderr}");
}
#[test]
fn check_reports_validation_failure_ahead_of_staleness() {
let tmp = fresh_repo();
write_spec(tmp.path(), "001-dup", "001-dup", "approved");
let out = check(tmp.path(), &[]);
assert_eq!(
code(&out),
1,
"validation failure must outrank staleness: {}",
String::from_utf8_lossy(&out.stderr)
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("spec-registry: INVALID"), "{stderr}");
}
#[test]
fn check_json_carries_both_halves_and_decides_nothing_differently() {
let tmp = fresh_repo();
write_spec(tmp.path(), "002-b", "002-b", "approved");
let plain = check(tmp.path(), &[]);
let json = check(tmp.path(), &["--json"]);
assert_eq!(
code(&plain),
code(&json),
"the flag must not change the exit code"
);
let v: serde_json::Value = serde_json::from_slice(&json.stdout).expect("an envelope");
assert_eq!(v["verb"], "check");
assert_eq!(v["exitCode"], 1);
assert_ne!(v["outcome"], "ok");
assert_eq!(v["report"]["registry"]["fresh"], false);
assert_eq!(v["report"]["registry"]["validationPassed"], true);
assert!(
v["report"]["registry"]["actual"]
.as_str()
.is_some_and(|s| s.contains("002-b")),
"the registry half keeps its stale report: {v}"
);
assert!(
v["report"]["index"]["diagnostics"].is_object(),
"the index half keeps the shape `index check` emits: {v}"
);
}
#[test]
fn check_forwards_fail_on_unresolved_to_the_index_half() {
let tmp = tempfile::tempdir().unwrap();
write_unresolved_corpus(tmp.path());
for verb in [&["compile"][..], &["index"][..]] {
assert_eq!(
code(
&bin()
.arg("--repo")
.arg(tmp.path())
.args(verb)
.output()
.unwrap()
),
0
);
}
assert_eq!(
code(&check(tmp.path(), &[])),
0,
"fresh, and silent by default"
);
assert_eq!(
code(&check(tmp.path(), &["--fail-on-unresolved"])),
1,
"an unresolved unit must refuse under the flag"
);
}
#[test]
fn fail_on_warn_refuses_a_warning_on_compile_and_check() {
let tmp = tempfile::tempdir().unwrap();
write_spec(tmp.path(), "001-a", "001-a", "approved");
let dir = tmp.path().join("specs/002-b");
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join("spec.md"),
"---\nid: \"002-b\"\ntitle: \"T\"\nstatus: approved\ncreated: \"2026-06-08\"\n\
depends_on: [\"099-absent\"]\nsummary: \"s\"\n---\n# 002-b\n",
)
.unwrap();
let run = |args: &[&str]| {
bin()
.arg("--repo")
.arg(tmp.path())
.args(args)
.output()
.unwrap()
};
assert_eq!(code(&run(&["compile"])), 0);
assert_eq!(code(&run(&["compile", "--fail-on-warn"])), 1);
let shard = tmp.path().join(".derived/spec-registry/by-spec/002-b.json");
let after_plain = fs::read(&shard).unwrap();
assert_eq!(code(&run(&["compile", "--fail-on-warn"])), 1);
assert_eq!(
after_plain,
fs::read(&shard).unwrap(),
"a refused compile must write the same bytes as an accepted one"
);
assert_eq!(code(&run(&["compile", "--check"])), 0);
assert_eq!(code(&run(&["compile", "--check", "--fail-on-warn"])), 1);
assert_eq!(code(&run(&["index"])), 0);
assert_eq!(code(&run(&["check"])), 0);
assert_eq!(code(&run(&["check", "--fail-on-warn"])), 1);
assert_eq!(code(&run(&["check", "--fail-on-unresolved"])), 0);
let plain = run(&["check", "--fail-on-warn"]);
let jsonic = run(&["check", "--fail-on-warn", "--json"]);
assert_eq!(code(&plain), code(&jsonic));
let v: serde_json::Value = serde_json::from_slice(&jsonic.stdout).unwrap();
assert_eq!(v["exitCode"], 1);
assert_ne!(v["outcome"], "ok");
assert_eq!(v["report"]["registry"]["warnings"], 1);
assert_eq!(v["report"]["registry"]["validationPassed"], true);
let clean = tempfile::tempdir().unwrap();
write_spec(clean.path(), "001-a", "001-a", "approved");
let run_clean = |args: &[&str]| {
bin()
.arg("--repo")
.arg(clean.path())
.args(args)
.output()
.unwrap()
};
assert_eq!(code(&run_clean(&["compile", "--fail-on-warn"])), 0);
assert_eq!(code(&run_clean(&["index"])), 0);
assert_eq!(code(&run_clean(&["check", "--fail-on-warn"])), 0);
}
#[test]
fn attest_spec_walks_a_claimed_subtree_instead_of_exiting_three() {
let tmp = tempfile::tempdir().unwrap();
let spec_dir = tmp.path().join("specs/001-t");
fs::create_dir_all(&spec_dir).unwrap();
fs::write(
spec_dir.join("spec.md"),
"---\nid: \"001-t\"\ntitle: \"T\"\nstatus: approved\ncreated: \"2026-09-10\"\nsummary: \"s\"\nestablishes:\n - \"sub/\"\n---\n# 001-t\n",
)
.unwrap();
fs::create_dir_all(tmp.path().join("sub/nested")).unwrap();
fs::write(tmp.path().join("sub/a.txt"), "a\n").unwrap();
fs::write(tmp.path().join("sub/nested/b.txt"), "b\n").unwrap();
let out = bin()
.arg("--repo")
.arg(tmp.path())
.args(["attest", "--spec", "001-t", "--json"])
.output()
.unwrap();
assert_eq!(code(&out), 0, "a claimed subtree must attest, not exit 3");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
!stdout.contains("\"contentHash\": null"),
"the resolved subtree must carry a hash: {stdout}"
);
assert!(
stdout.contains("\"ok\": true"),
"the envelope reports success: {stdout}"
);
}
fn git088(root: &Path, args: &[&str]) {
let out = Command::new("git")
.arg("-C")
.arg(root)
.args([
"-c",
"commit.gpgsign=false",
"-c",
"user.name=t",
"-c",
"user.email=t@t",
])
.args(args)
.output()
.unwrap();
assert!(
out.status.success(),
"git {args:?}: {}",
String::from_utf8_lossy(&out.stderr)
);
}
fn delta_repo(root: &Path) {
let w = |rel: &str, content: &[u8]| {
let p = root.join(rel);
fs::create_dir_all(p.parent().unwrap()).unwrap();
fs::write(p, content).unwrap();
};
w(
"specs/001-a/spec.md",
b"---\nid: \"001-a\"\ntitle: \"a\"\nstatus: approved\ncreated: \"2026-09-11\"\nimplementation: complete\nsummary: \"s\"\nestablishes:\n - \"src/\"\n---\n\n# a\n\n## Verification\n\n```verify:cli\ntest -f src/a.rs\n```\n",
);
w("src/a.rs", b"pub fn a() {}\n");
w("tools/x.sh", b"echo x\n");
w(".gitignore", b".derived/**/build-meta.json\n");
for verb in ["compile", "index"] {
assert_eq!(code(&run_in(root, &[verb])), 0, "fixture {verb}");
}
git088(root, &["init", "-q", "-b", "main"]);
git088(root, &["add", "-A"]);
git088(root, &["commit", "-q", "-m", "base"]);
}
fn delta_json_in(root: &Path, head: &str, tmpdir: &Path) -> std::process::Output {
bin()
.arg("--repo")
.arg(root)
.args(["delta", "--base", "main", "--head", head, "--json"])
.env("TMPDIR", tmpdir)
.output()
.unwrap()
}
fn change_classes(report: &serde_json::Value, path: &str) -> Vec<String> {
report["changes"]
.as_array()
.unwrap()
.iter()
.find(|c| c["path"] == path)
.unwrap_or_else(|| panic!("no change for {path}: {report:#}"))["classes"]
.as_array()
.unwrap()
.iter()
.map(|c| c.as_str().unwrap().to_string())
.collect()
}
#[test]
#[cfg_attr(
windows,
ignore = "WF-7: git on Windows checks committed shards out as CRLF, which the byte comparison reads as stale"
)]
fn delta_classifies_under_the_merge_base_not_the_checked_out_head() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("repo");
fs::create_dir_all(&root).unwrap();
let exports = tmp.path().join("tmp");
fs::create_dir_all(&exports).unwrap();
delta_repo(&root);
git088(&root, &["switch", "-qc", "policy"]);
fs::write(
root.join("spec-spine.toml"),
"[coupling]\nbypass_prefixes = [\"src/\", \"tools/\"]\n",
)
.unwrap();
fs::write(root.join("src/a.rs"), "pub fn a() { unreviewed(); }\n").unwrap();
fs::write(root.join("tools/x.sh"), "echo unreviewed\n").unwrap();
fs::write(root.join("src/blob.bin"), [0u8, 159, 146, 150, 0]).unwrap();
git088(&root, &["add", "-A"]);
git088(&root, &["commit", "-q", "-m", "policy"]);
let out = delta_json_in(&root, "HEAD", &exports);
assert_eq!(code(&out), 0, "{}", String::from_utf8_lossy(&out.stderr));
let v = envelope(&out);
assert_eq!(v["verb"], "delta");
assert_eq!(v["schemaVersion"], spec_spine_types::VERDICT_SCHEMA_VERSION);
assert_eq!(v["outcome"], "ok");
let report = &v["report"];
assert_eq!(
report["schemaVersion"],
spec_spine_types::DELTA_SCHEMA_VERSION
);
assert_eq!(report["classifiedUnder"], "base");
assert_eq!(change_classes(report, "spec-spine.toml"), ["policy"]);
assert_eq!(
change_classes(report, "src/a.rs"),
["implementation"],
"the candidate's own bypass prefix does not apply to its own diff"
);
assert_eq!(
change_classes(report, "tools/x.sh"),
["unowned"],
"under the candidate's configuration this path would read bypassed"
);
assert_eq!(change_classes(report, "src/blob.bin"), ["implementation"]);
assert_eq!(report["priorPolicy"]["required"], true);
let rev = |r: &str| {
let o = Command::new("git")
.arg("-C")
.arg(&root)
.args(["rev-parse", r])
.output()
.unwrap();
String::from_utf8_lossy(&o.stdout).trim().to_string()
};
assert_eq!(report["base"], rev("main"));
assert_eq!(report["mergeBase"], rev("main"));
assert_eq!(report["head"], rev("HEAD"));
let status = Command::new("git")
.arg("-C")
.arg(&root)
.args(["status", "--porcelain"])
.output()
.unwrap();
assert!(
status.stdout.is_empty(),
"{}",
String::from_utf8_lossy(&status.stdout)
);
assert_eq!(
fs::read_dir(&exports).unwrap().count(),
0,
"the exported trees are removed"
);
}
#[test]
#[cfg_attr(
windows,
ignore = "WF-7: git on Windows checks committed shards out as CRLF, which the byte comparison reads as stale"
)]
fn delta_prose_says_what_not_required_does_not_mean() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
delta_repo(root);
let out = run_in(root, &["delta", "--base", "main", "--head", "main"]);
assert_eq!(code(&out), 0);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("0 path(s) changed"), "{stdout}");
assert!(
stdout.contains("does not mean the change is safe, correct or approved"),
"{stdout}"
);
}
#[test]
fn delta_failures_keep_the_exit_code_contract() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("repo");
fs::create_dir_all(&root).unwrap();
delta_repo(&root);
let out = delta_json_in(&root, "no-such-ref", tmp.path());
assert_eq!(code(&out), 4);
let v = envelope(&out);
assert_eq!(v["verb"], "delta");
assert_eq!(v["error"]["kind"], "io");
git088(&root, &["switch", "-qc", "stale-base"]);
let spec = fs::read_to_string(root.join("specs/001-a/spec.md")).unwrap();
fs::write(
root.join("specs/001-a/spec.md"),
spec.replace("# a\n", "# a, edited\n"),
)
.unwrap();
git088(&root, &["commit", "-qam", "stale"]);
let out = bin()
.arg("--repo")
.arg(&root)
.args(["delta", "--base", "stale-base", "--head", "stale-base"])
.output()
.unwrap();
assert_eq!(code(&out), 1, "{}", String::from_utf8_lossy(&out.stderr));
}
fn index_shard_dir(root: &Path, which: &str) -> std::path::PathBuf {
root.join(".derived/codebase-index").join(which)
}
fn assert_judged_stale(root: &Path, named: &str) -> serde_json::Value {
for args in [&["index", "check"][..], &["check"][..]] {
let out = run_in(root, args);
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(
code(&out),
1,
"{args:?} must judge, not fail to read: {stderr}"
);
assert!(stderr.contains(named), "{args:?} names {named}: {stderr}");
}
let out = run_in(root, &["check", "--json"]);
assert_eq!(code(&out), 1, "{}", String::from_utf8_lossy(&out.stdout));
let v = envelope(&out);
assert_eq!(v["exitCode"], 1, "{v}");
assert_eq!(v["report"]["index"]["fresh"], false, "{v}");
v["report"]["index"].clone()
}
#[test]
fn an_unparseable_stray_is_orphaned_at_the_verbs() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
verdict_fixture(root);
fs::write(
index_shard_dir(root, "by-spec").join("999-stray.json"),
"{\"nope\": 1}\n",
)
.unwrap();
let index = assert_judged_stale(root, "orphaned by-spec/999-stray.json");
assert_eq!(index["skippedShards"], 1, "{index}");
let stderr = String::from_utf8_lossy(&run_in(root, &["check"]).stderr).into_owned();
assert!(
stderr.contains("orphaned by-spec/999-stray.json (unreadable"),
"the prose names the skipped file on its drift line: {stderr}"
);
let facade: serde_json::Value = serde_json::from_str(
&spec_spine_core::check_freshness_json("{}", root.to_str().unwrap()).unwrap(),
)
.unwrap();
let cli = envelope(&run_in(root, &["index", "check", "--json"]));
assert_eq!(cli["report"], facade, "index check and its facade agree");
assert_eq!(facade["skippedShards"], 1, "{facade}");
}
#[test]
fn a_parseable_stray_is_orphaned_and_nothing_is_skipped() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
verdict_fixture(root);
let dir = index_shard_dir(root, "by-spec");
fs::copy(dir.join("001-a.json"), dir.join("999-copy.json")).unwrap();
let index = assert_judged_stale(root, "orphaned by-spec/999-copy.json");
assert!(index.get("skippedShards").is_none(), "{index}");
}
#[test]
fn an_unparseable_stray_in_by_package_is_orphaned_at_the_verbs() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
verdict_fixture(root);
let dir = index_shard_dir(root, "by-package");
assert!(
fs::read_dir(&dir).unwrap().count() > 0,
"the fixture has a package shard, so by-package is a real tree"
);
fs::write(dir.join("zzz-stray.json"), "[]\n").unwrap();
let index = assert_judged_stale(root, "orphaned by-package/zzz-stray.json");
assert_eq!(index["skippedShards"], 1, "{index}");
}
#[test]
fn an_expected_shard_corrupted_in_place_is_modified_at_the_verbs() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
verdict_fixture(root);
fs::write(
index_shard_dir(root, "by-spec").join("001-a.json"),
"{ truncated by a bad merge",
)
.unwrap();
let index = assert_judged_stale(root, "modified by-spec/001-a.json");
assert_eq!(index["skippedShards"], 1, "{index}");
let stderr = String::from_utf8_lossy(&run_in(root, &["index", "check"]).stderr).into_owned();
assert!(
!stderr.contains("orphaned"),
"an expected path is not an orphan: {stderr}"
);
}
#[test]
fn a_fresh_tree_payload_gains_no_member() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
verdict_fixture(root);
for args in [&["index", "check", "--json"][..], &["check", "--json"][..]] {
let out = run_in(root, args);
assert_eq!(
code(&out),
0,
"{args:?}: {}",
String::from_utf8_lossy(&out.stdout)
);
}
let index = envelope(&run_in(root, &["index", "check", "--json"]))["report"].clone();
let keys: Vec<&str> = index
.as_object()
.unwrap()
.keys()
.map(String::as_str)
.collect();
assert_eq!(keys, vec!["diagnostics", "fresh", "unwitnessed"], "{index}");
let composed = envelope(&run_in(root, &["check", "--json"]))["report"]["index"].clone();
assert_eq!(composed, index, "both verbs carry the one index shape");
let stdout = String::from_utf8_lossy(&run_in(root, &["check", "--json"]).stdout).into_owned();
assert!(!stdout.contains("skippedShards"), "{stdout}");
}
#[test]
fn the_consumer_verbs_still_refuse_an_unparseable_stray() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
verdict_fixture(root);
fs::write(
index_shard_dir(root, "by-spec").join("999-stray.json"),
"{\"nope\": 1}\n",
)
.unwrap();
let owner = run_in(root, &["index", "owner", "crate-a/src/lib.rs"]);
assert_eq!(
code(&owner),
1,
"{}",
String::from_utf8_lossy(&owner.stderr)
);
let render = run_in(root, &["index", "render"]);
assert_eq!(
code(&render),
4,
"{}",
String::from_utf8_lossy(&render.stderr)
);
assert!(
String::from_utf8_lossy(&render.stderr).contains("999-stray.json"),
"{}",
String::from_utf8_lossy(&render.stderr)
);
}
fn sorted_object(label: &str, out: &std::process::Output) -> serde_json::Value {
assert_eq!(
code(out),
0,
"{label}: {}",
String::from_utf8_lossy(&out.stderr)
);
let text = String::from_utf8_lossy(&out.stdout).into_owned();
let v: serde_json::Value =
serde_json::from_str(&text).unwrap_or_else(|e| panic!("{label} is JSON ({e}): {text}"));
let obj = v
.as_object()
.unwrap_or_else(|| panic!("{label} is an object: {text}"));
let order: Vec<String> = text
.lines()
.filter(|l| l.starts_with(" \"") && !l.starts_with(" "))
.map(|l| l[3..l[3..].find('"').unwrap() + 3].to_string())
.collect();
assert_eq!(order.len(), obj.len(), "{label}: {text}");
let mut sorted = order.clone();
sorted.sort();
assert_eq!(order, sorted, "{label}: top-level keys in sorted order");
v
}
#[test]
fn every_read_document_is_a_sorted_versioned_object() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
verdict_fixture(root);
let version = spec_spine_types::READ_SCHEMA_VERSION;
let stamped: [(&str, &[&str]); 12] = [
("registry list", &["registry", "list", "--json"]),
(
"registry list --ids-only",
&["registry", "list", "--ids-only", "--json"],
),
("registry show", &["registry", "show", "001-a", "--json"]),
(
"registry status-report",
&["registry", "status-report", "--json"],
),
(
"registry status-report --nonzero-only",
&["registry", "status-report", "--nonzero-only", "--json"],
),
(
"registry relationships",
&["registry", "relationships", "001-a", "--json"],
),
("registry plan", &["registry", "plan", "--json"]),
(
"registry plan --next",
&["registry", "plan", "--next", "--json"],
),
(
"index owner",
&["index", "owner", "crate-a/src/lib.rs", "--json"],
),
("index coverage", &["index", "coverage", "--json"]),
("index diagnostics", &["index", "diagnostics", "--json"]),
("index orphans", &["index", "orphans", "--json"]),
];
for (label, args) in stamped {
let v = sorted_object(label, &run_in(root, args));
assert_eq!(v["schemaVersion"], version, "{label}: {v}");
assert!(
v.get("config_version").is_none(),
"{label}: one version member"
);
}
let v = sorted_object("config show", &run_in(root, &["config", "show", "--json"]));
assert!(v.get("config_version").is_some(), "{v}");
assert!(v.get("schemaVersion").is_none(), "{v}");
for args in [
&["registry", "list", "--json"][..],
&["registry", "list", "--ids-only", "--json"][..],
&["index", "diagnostics", "--json"][..],
] {
assert!(
envelope(&run_in(root, args))["items"].is_array(),
"{args:?}"
);
}
let ids = envelope(&run_in(root, &["registry", "list", "--ids-only", "--json"]));
assert_eq!(ids["items"], serde_json::json!(["001-a"]), "{ids}");
let records = envelope(&run_in(root, &["registry", "list", "--json"]));
assert_eq!(records["items"][0]["id"], "001-a", "{records}");
let registry_text = {
let cfg = spec_spine_types::Config::default();
spec_spine_core::compile(&cfg, root).unwrap().json
};
let query = |op: &str, extra: serde_json::Value| {
let mut req = serde_json::json!({ "registry": registry_text, "op": op });
req.as_object_mut()
.unwrap()
.extend(extra.as_object().unwrap().clone());
serde_json::from_str::<serde_json::Value>(
&spec_spine_core::query_json(&req.to_string()).unwrap(),
)
.unwrap()
};
assert_eq!(
query("list", serde_json::json!({ "idsOnly": true })),
ids,
"query_json list --ids-only"
);
assert_eq!(
query("plan", serde_json::json!({})),
envelope(&run_in(root, &["registry", "plan", "--json"]))
);
let coverage: serde_json::Value = serde_json::from_str(
&spec_spine_core::coverage_json("{}", root.to_str().unwrap()).unwrap(),
)
.unwrap();
assert_eq!(
coverage,
envelope(&run_in(root, &["index", "coverage", "--json"]))
);
}
#[test]
fn plan_next_on_an_empty_ready_set_is_a_present_null() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("specs/001-done");
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join("spec.md"),
"---\nid: \"001-done\"\ntitle: \"T\"\nstatus: approved\ncreated: \"2026-09-06\"\n\
summary: \"s\"\nimplementation: complete\n---\n# 001-done\n",
)
.unwrap();
assert_eq!(code(&run_in(tmp.path(), &["compile"])), 0);
let out = run_in(tmp.path(), &["registry", "plan", "--next", "--json"]);
let v = sorted_object("plan --next (empty)", &out);
assert_eq!(
v,
serde_json::json!({
"next": null,
"schemaVersion": spec_spine_types::READ_SCHEMA_VERSION,
})
);
}
#[test]
fn attest_snapshot_writes_seals_and_verifies() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
verdict_fixture(root);
let out = run_in(root, &["attest", "--snapshot", "--json"]);
assert_eq!(code(&out), 0, "{}", String::from_utf8_lossy(&out.stderr));
let v = envelope(&out);
assert_eq!(v["verb"], "attest");
let path = root.join(".derived/attestation/snapshot.json");
assert!(path.is_file(), "the payload lands at snapshot.json");
assert!(!root.join(".derived/attestation/attestation.json").exists());
let stored: serde_json::Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
assert_eq!(v["report"]["attestation"], stored);
assert_eq!(
stored["schemaVersion"],
spec_spine_types::SNAPSHOT_SCHEMA_VERSION
);
assert_eq!(stored["digest"], "frame/1");
assert_eq!(stored["committed"]["registry"]["matchesRecompute"], true);
assert_eq!(stored["committed"]["index"]["matchesRecompute"], true);
assert_eq!(stored["specs"][0]["id"], "001-a");
assert!(stored["specs"][0]["territoryDigest"].is_string());
assert!(stored["specs"][0]["specAttestationHash"].is_string());
let facade: serde_json::Value = serde_json::from_str(
&spec_spine_core::attest_snapshot_json("{}", root.to_str().unwrap()).unwrap(),
)
.unwrap();
assert_eq!(v["report"], facade, "one payload shape per verb");
let seed = root.join("signing.key");
fs::write(&seed, [5u8; 32]).unwrap();
let signed = run_in(
root,
&[
"attest",
"--snapshot",
"--sign",
"--key",
seed.to_str().unwrap(),
],
);
assert_eq!(
code(&signed),
0,
"{}",
String::from_utf8_lossy(&signed.stderr)
);
let seal_path = root.join(".derived/attestation/snapshot.sig");
assert!(seal_path.is_file(), "the seal is the payload's sibling");
let seal: serde_json::Value = serde_json::from_slice(&fs::read(&seal_path).unwrap()).unwrap();
let public = root.join("public.hex");
fs::write(&public, seal["keyId"].as_str().unwrap()).unwrap();
let verified = run_in(
root,
&[
"verify-attestation",
"--snapshot",
"--recompute",
"--signature",
"--public-key",
public.to_str().unwrap(),
],
);
assert_eq!(
code(&verified),
0,
"{}",
String::from_utf8_lossy(&verified.stderr)
);
let text = String::from_utf8_lossy(&verified.stdout);
assert!(text.contains("recompute: MATCH"), "{text}");
assert!(text.contains("signature: VALID"), "{text}");
let request = serde_json::json!({
"repoRoot": root.to_str().unwrap(),
"attestationText": fs::read_to_string(&path).unwrap(),
});
assert_eq!(
spec_spine_core::verify_snapshot_attestation_json(&request.to_string()).unwrap(),
"{\"outcome\":\"match\"}"
);
fs::write(
root.join("crate-a/src/lib.rs"),
"pub fn a() {}\npub fn b() {}\n",
)
.unwrap();
let stale = run_in(root, &["verify-attestation", "--snapshot", "--recompute"]);
assert_eq!(code(&stale), 1);
let err = String::from_utf8_lossy(&stale.stderr);
assert!(err.contains("CONTENT MISMATCH"), "{err}");
assert!(
err.contains("specs[001-a]"),
"the moved member is named: {err}"
);
}
#[test]
fn attest_snapshot_refuses_another_scope() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
verdict_fixture(root);
for other in [&["--spec", "001-a"][..], &["--with-coupling"][..]] {
let mut args = vec!["attest", "--snapshot"];
args.extend_from_slice(other);
let out = run_in(root, &args);
assert_eq!(code(&out), 3, "{args:?}");
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains("cannot combine"), "{args:?}: {err}");
assert!(!root.join(".derived/attestation/snapshot.json").exists());
}
let out = run_in(
root,
&[
"verify-attestation",
"--snapshot",
"--spec",
"001-a",
"--recompute",
],
);
assert_eq!(code(&out), 3);
assert!(String::from_utf8_lossy(&out.stderr).contains("cannot combine"));
}
#[test]
fn the_snapshot_join_hash_is_what_attest_spec_emits() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
verdict_fixture(root);
let per_spec = envelope(&run_in(root, &["attest", "--spec", "001-a", "--json"]));
let snap = envelope(&run_in(root, &["attest", "--snapshot", "--json"]));
assert_eq!(
snap["report"]["attestation"]["specs"][0]["specAttestationHash"],
per_spec["report"]["attestationHash"]
);
}
fn scope_repo(root: &Path) {
let w = |rel: &str, content: &str| {
let p = root.join(rel);
fs::create_dir_all(p.parent().unwrap()).unwrap();
fs::write(p, content).unwrap();
};
w("Cargo.toml", "[workspace]\nmembers = [\"crate-a\"]\n");
w(
"crate-a/Cargo.toml",
"[package]\nname = \"crate-a\"\nversion = \"0.1.0\"\n",
);
w("crate-a/src/lib.rs", "pub fn a() {}\n");
w("scripts/run.sh", "echo hi\n");
w(
"specs/001-a/spec.md",
"---\nid: \"001-a\"\ntitle: \"A\"\nstatus: approved\ncreated: \"2026-09-15\"\n\
summary: \"s\"\nestablishes:\n - \"crate-a/src/lib.rs\"\n---\n# a\n",
);
w(
"spec-spine.toml",
"[coverage]\ngoverned_scope = [\"scripts/*\"]\n",
);
for verb in ["compile", "index"] {
let out = run_in(root, &[verb]);
assert_eq!(
code(&out),
0,
"{verb}: {}",
String::from_utf8_lossy(&out.stderr)
);
}
}
fn git097(root: &Path, args: &[&str]) {
let out = Command::new("git")
.arg("-C")
.arg(root)
.args(["-c", "commit.gpgsign=false"])
.args(args)
.env("GIT_AUTHOR_NAME", "t")
.env("GIT_AUTHOR_EMAIL", "t@t")
.env("GIT_COMMITTER_NAME", "t")
.env("GIT_COMMITTER_EMAIL", "t@t")
.output()
.unwrap();
assert!(out.status.success(), "git {args:?}: {out:?}");
}
fn coverage_doc(root: &Path, extra: &[&str]) -> serde_json::Value {
let mut args = vec!["index", "coverage", "--json"];
args.extend_from_slice(extra);
let out = run_in(root, &args);
assert_eq!(code(&out), 0, "{}", String::from_utf8_lossy(&out.stderr));
envelope(&out)
}
#[test]
fn coverage_paths_from_is_the_supplied_inventory_and_matches_the_facade() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
scope_repo(root);
let list = root.join("inventory.txt");
fs::write(&list, "scripts/run.sh\n").unwrap();
let doc = coverage_doc(root, &["--paths-from", list.to_str().unwrap()]);
assert_eq!(doc["enumeration"], "supplied", "{doc}");
assert_eq!(
doc["declaredScopeFiles"],
serde_json::json!(["scripts/run.sh"])
);
assert_eq!(doc["unclaimedFiles"], serde_json::json!(["scripts/run.sh"]));
let request = serde_json::json!({
"repoRoot": root.to_str().unwrap(),
"config": { "coverage": { "governed_scope": ["scripts/*"] } },
"inventory": { "provenance": "supplied", "paths": ["scripts/run.sh"] },
});
let facade: serde_json::Value = serde_json::from_str(
&spec_spine_core::coverage_inventory_json(&request.to_string())
.unwrap_or_else(|e| panic!("facade: {e}")),
)
.unwrap();
assert_eq!(facade, doc, "the facade and the CLI answer one report");
fs::write(&list, "").unwrap();
let empty = coverage_doc(root, &["--paths-from", list.to_str().unwrap()]);
assert_eq!(empty["declaredScopeFiles"], serde_json::json!([]));
assert_eq!(empty["enumeration"], "supplied");
}
#[test]
fn coverage_git_failure_is_exit_3_not_a_walk() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
scope_repo(root);
let out = run_in(root, &["index", "coverage"]);
assert_eq!(code(&out), 4, "{}", String::from_utf8_lossy(&out.stdout));
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains("--paths-from"), "{err}");
assert!(!String::from_utf8_lossy(&out.stdout).contains("declared scope"));
}
#[test]
fn the_git_inventory_keeps_tracked_and_new_files_and_drops_ignored_and_missing() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
scope_repo(root);
let w = |rel: &str| fs::write(root.join(rel), "echo x\n").unwrap();
w("scripts/tracked_ignored.sh");
w("scripts/gone.sh");
git097(root, &["init", "-q"]);
git097(root, &["add", "-A"]);
git097(root, &["commit", "-q", "-m", "base"]);
fs::write(root.join(".gitignore"), "scripts/*ignored*.sh\n").unwrap();
w("scripts/untracked_ignored.sh");
w("scripts/new.sh");
fs::remove_file(root.join("scripts/gone.sh")).unwrap();
let doc = coverage_doc(root, &[]);
assert_eq!(doc["enumeration"], "tracked", "{doc}");
assert_eq!(
doc["declaredScopeFiles"],
serde_json::json!([
"scripts/new.sh",
"scripts/run.sh",
"scripts/tracked_ignored.sh"
]),
"{doc}"
);
}
#[test]
fn config_show_and_the_scaffold_carry_the_scope_keys() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let show = run_in(root, &["config", "show"]);
assert_eq!(code(&show), 0, "{}", String::from_utf8_lossy(&show.stderr));
let text = String::from_utf8_lossy(&show.stdout);
assert!(text.contains("[coverage]"), "{text}");
assert!(text.contains("governed_scope = []"), "{text}");
assert!(text.contains("governed_scope_exclusions = []"), "{text}");
let json = envelope(&run_in(root, &["config", "show", "--json"]));
assert_eq!(
json["coverage"]["governed_scope"],
serde_json::json!([]),
"{json}"
);
let scaffold = spec_spine_core::scaffold_init(&spec_spine_types::Config::default()).unwrap();
let toml = scaffold
.files
.iter()
.find(|f| f.rel_path == "spec-spine.toml")
.expect("the scaffold produces the config")
.contents
.clone();
assert!(toml.contains("[coverage]"), "{toml}");
assert!(toml.contains("# governed_scope = ["), "{toml}");
assert!(toml.contains("dir/**/*"), "the glob trap is named: {toml}");
let cfg = spec_spine_types::load_config(&toml).unwrap();
assert!(cfg.coverage.governed_scope.is_empty());
}
fn blocking_corpus(root: &Path, status: &str, implementation: &str) {
let dir = root.join("specs/001-missing");
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join("spec.md"),
format!(
"---\nid: \"001-missing\"\ntitle: \"T\"\nstatus: {status}\ncreated: \"2026-09-16\"\n\
implementation: {implementation}\nsummary: \"s\"\nestablishes:\n - \"src/gone.rs\"\n\
---\n\n# 001-missing\n"
),
)
.unwrap();
assert_eq!(code(&run_in(root, &["compile"])), 0);
assert_eq!(code(&run_in(root, &["index"])), 0);
}
fn stderr(out: &std::process::Output) -> String {
String::from_utf8_lossy(&out.stderr).to_string()
}
#[test]
fn check_reports_an_unresolved_claim_as_itself() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
blocking_corpus(root, "approved", "complete");
let out = run_in(root, &["check"]);
assert_eq!(code(&out), 1, "{}", stderr(&out));
let err = stderr(&out);
let index_line = err
.lines()
.find(|l| l.starts_with("codebase-index:"))
.expect("the index half is attributed to its tree (spec 062 §3.4)");
assert!(!index_line.contains("STALE"), "{index_line}");
assert!(!index_line.contains("spec-spine index"), "{index_line}");
assert!(err.contains("I-004"), "{err}");
assert!(err.contains("001-missing"), "{err}");
assert!(err.contains("src/gone.rs"), "{err}");
assert!(
err.contains("regenerating the index does not clear this"),
"{err}"
);
assert!(err.contains("`implementation: complete`"), "{err}");
assert!(!err.contains("planned: true"), "{err}");
assert!(
String::from_utf8_lossy(&out.stdout).contains("spec-registry: fresh"),
"{}",
String::from_utf8_lossy(&out.stdout)
);
let idx = run_in(root, &["index", "check"]);
assert_eq!(code(&idx), 1);
let ierr = stderr(&idx);
assert!(ierr.contains("I-004"), "{ierr}");
assert!(!ierr.contains("to refresh"), "{ierr}");
}
#[test]
fn regenerating_leaves_the_message_accurate() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
blocking_corpus(root, "approved", "complete");
assert_eq!(
code(&run_in(root, &["index"])),
0,
"the named remedy exits 0"
);
let out = run_in(root, &["check"]);
assert_eq!(code(&out), 1, "and the refusal stands (spec 080 §3.1)");
let err = stderr(&out);
assert!(err.contains("I-004"), "{err}");
assert!(
!err.lines()
.any(|l| l.starts_with("codebase-index:") && l.contains("STALE")),
"{err}"
);
}
#[test]
fn check_json_is_unchanged_by_the_message_fix() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
blocking_corpus(root, "approved", "complete");
let out = run_in(root, &["check", "--json"]);
assert_eq!(code(&out), 1);
let json = envelope(&out);
assert_eq!(json["schemaVersion"], "1.0.0", "{json}");
assert_eq!(json["exitCode"], 1, "{json}");
assert_ne!(json["outcome"], "ok", "{json}");
let members: Vec<&str> = json
.as_object()
.unwrap()
.keys()
.map(|k| k.as_str())
.collect();
assert_eq!(
members,
[
"exitCode",
"outcome",
"report",
"schemaVersion",
"summary",
"tool",
"verb"
],
"{json}"
);
let index = &json["report"]["index"];
let index_members: Vec<&str> = index
.as_object()
.unwrap()
.keys()
.map(|k| k.as_str())
.collect();
assert_eq!(
index_members,
["actual", "diagnostics", "expected", "fresh", "unwitnessed"],
"{json}"
);
assert_eq!(index["diagnostics"]["byCode"]["I-004"], 1, "{json}");
assert_eq!(
index["actual"], "1 stale shard(s):\n blocking-diagnostics by-spec/001-missing.json",
"the payload text is the one this spec deliberately does not move: {json}"
);
}
#[test]
fn a_stale_shard_still_reads_as_staleness() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write_spec(root, "001-a", "001-a", "approved");
assert_eq!(code(&run_in(root, &["index"])), 0);
write_spec(root, "001-a", "001-a", "draft");
let out = run_in(root, &["check"]);
assert_eq!(code(&out), 1);
let err = stderr(&out);
assert!(
err.contains("codebase-index: STALE (run `spec-spine index`)"),
"{err}"
);
assert!(err.contains("stale shard(s):"), "{err}");
assert!(!err.contains("I-004"), "{err}");
assert!(!err.contains("UNRESOLVED CLAIM"), "{err}");
let idx = run_in(root, &["index", "check"]);
assert_eq!(code(&idx), 1);
assert!(
stderr(&idx).contains("index is STALE (run `spec-spine index` to refresh)"),
"{}",
stderr(&idx)
);
}
#[test]
fn a_mixed_tree_names_both_halves_at_the_verbs() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write_spec(root, "002-b", "002-b", "approved");
blocking_corpus(root, "approved", "complete");
write_spec(root, "002-b", "002-b", "draft");
let out = run_in(root, &["check"]);
assert_eq!(code(&out), 1);
let err = stderr(&out);
assert!(
err.contains("codebase-index: STALE (run `spec-spine index`)"),
"{err}"
);
assert!(err.contains("modified by-spec/002-b.json"), "{err}");
assert!(err.contains("I-004"), "{err}");
assert!(
err.contains("regenerating addresses the stale shard(s) only, not the unresolved claim(s)"),
"{err}"
);
assert_eq!(code(&run_in(root, &["index"])), 0);
let after = stderr(&run_in(root, &["check"]));
assert!(after.contains("I-004"), "{after}");
assert!(
!after
.lines()
.any(|l| l.starts_with("codebase-index:") && l.contains("STALE")),
"{after}"
);
}
#[test]
fn a_spec_that_claims_no_completion_is_not_accused_of_one() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
blocking_corpus(root, "approved", "deferred");
let err = stderr(&run_in(root, &["check"]));
assert!(err.contains("I-004"), "{err}");
assert!(!err.contains("complete"), "{err}");
let tmp2 = tempfile::tempdir().unwrap();
let root2 = tmp2.path();
blocking_corpus(root2, "draft", "in-progress");
let out = run_in(root2, &["check"]);
assert_eq!(code(&out), 0, "{}", stderr(&out));
assert!(!stderr(&out).contains("I-004"), "{}", stderr(&out));
}
#[test]
fn spec101_check_exits_1_on_an_unresolved_claim() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
blocking_corpus(root, "approved", "complete");
let out = run_in(root, &["check"]);
assert_eq!(code(&out), 1, "{}", stderr(&out));
let err = stderr(&out);
assert!(err.contains("UNRESOLVED CLAIM"), "{err}");
let index_line = err
.lines()
.find(|l| l.starts_with("codebase-index:"))
.expect("the index half is attributed to its tree");
assert!(!index_line.contains("STALE"), "{index_line}");
assert!(!index_line.contains("spec-spine index"), "{index_line}");
}
#[test]
fn spec101_index_check_exits_1_on_an_unresolved_claim() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
blocking_corpus(root, "approved", "complete");
let out = run_in(root, &["index", "check"]);
assert_eq!(code(&out), 1, "{}", stderr(&out));
assert!(stderr(&out).contains("I-004"), "{}", stderr(&out));
}
#[test]
fn spec101_a_blocking_claim_and_a_stale_shard_exit_1() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write_spec(root, "002-b", "002-b", "approved");
blocking_corpus(root, "approved", "complete");
write_spec(root, "002-b", "002-b", "draft");
let out = run_in(root, &["check"]);
assert_eq!(code(&out), 1, "{}", stderr(&out));
let err = stderr(&out);
assert!(err.contains("STALE"), "{err}");
assert!(err.contains("UNRESOLVED CLAIM"), "{err}");
assert!(
err.contains("regenerating addresses the stale shard(s) only"),
"{err}"
);
let idx = run_in(root, &["index", "check"]);
assert_eq!(code(&idx), 1, "{}", stderr(&idx));
assert!(stderr(&idx).contains("I-004"), "{}", stderr(&idx));
}
#[test]
fn spec101_a_stale_shard_alone_still_exits_2() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write_spec(root, "001-a", "001-a", "approved");
assert_eq!(code(&run_in(root, &["index"])), 0);
write_spec(root, "001-a", "001-a", "draft");
let out = run_in(root, &["check"]);
assert_eq!(code(&out), 1, "{}", stderr(&out));
assert!(
stderr(&out).contains("codebase-index: STALE (run `spec-spine index`)"),
"{}",
stderr(&out)
);
assert_eq!(code(&run_in(root, &["index", "check"])), 1);
}
#[test]
fn spec101_json_carries_the_new_code_and_keeps_its_shape() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
blocking_corpus(root, "approved", "complete");
let out = run_in(root, &["check", "--json"]);
assert_eq!(code(&out), 1);
let json = envelope(&out);
assert_eq!(json["exitCode"], 1, "{json}");
assert_ne!(json["outcome"], "ok", "{json}");
assert_eq!(json["schemaVersion"], "1.0.0", "{json}");
let members: Vec<&str> = json
.as_object()
.unwrap()
.keys()
.map(|k| k.as_str())
.collect();
assert_eq!(
members,
[
"exitCode",
"outcome",
"report",
"schemaVersion",
"summary",
"tool",
"verb"
],
"{json}"
);
assert_eq!(
json["report"]["index"]["diagnostics"]["byCode"]["I-004"], 1,
"{json}"
);
assert_eq!(json["report"]["index"]["fresh"], false, "{json}");
}
#[test]
fn spec101_the_unresolved_flag_axis_is_unchanged() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
blocking_corpus(root, "draft", "in-progress");
assert_eq!(
code(&run_in(root, &["check"])),
0,
"{}",
stderr(&run_in(root, &["check"]))
);
assert_eq!(code(&run_in(root, &["check", "--fail-on-unresolved"])), 1);
}
fn couple102_repo(root: &Path) {
let w = |rel: &str, content: &[u8]| {
let p = root.join(rel);
fs::create_dir_all(p.parent().unwrap()).unwrap();
fs::write(p, content).unwrap();
};
w(
"specs/001-a/spec.md",
b"---\nid: \"001-a\"\ntitle: \"a\"\nstatus: approved\ncreated: \"2026-09-16\"\nimplementation: complete\nsummary: \"s\"\nestablishes:\n - \"src/\"\n---\n\n# a\n",
);
w("src/a.rs", b"pub fn a() {}\n");
w(
"specs/002-b/spec.md",
b"---\nid: \"002-b\"\ntitle: \"b\"\nstatus: approved\ncreated: \"2026-09-16\"\nimplementation: complete\nsummary: \"s\"\nestablishes:\n - \"lib/\"\n---\n\n# b\n",
);
w("lib/b.rs", b"pub fn b() {}\n");
w(".gitignore", b".derived/**/build-meta.json\n");
for verb in ["compile", "index"] {
assert_eq!(code(&run_in(root, &[verb])), 0, "fixture {verb}");
}
git088(root, &["init", "-q", "-b", "main"]);
git088(root, &["add", "-A"]);
git088(root, &["commit", "-q", "-m", "base"]);
}
fn couple102(root: &Path, extra: &[&str]) -> std::process::Output {
let mut args = vec!["couple", "--base", "main", "--head", "HEAD"];
args.extend_from_slice(extra);
run_in(root, &args)
}
#[test]
fn spec102_a_staged_edit_is_invisible_without_the_flag() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
couple102_repo(root);
fs::write(root.join("src/a.rs"), b"pub fn a() { /* drift */ }\n").unwrap();
git088(root, &["add", "-A"]);
let bare = couple102(root, &[]);
assert_eq!(code(&bare), 0, "{}", stderr(&bare));
assert!(
String::from_utf8_lossy(&bare.stdout).contains("0 path(s) checked"),
"the defect: a pass over nothing. {}",
String::from_utf8_lossy(&bare.stdout)
);
let out = couple102(root, &["--include-uncommitted"]);
assert_eq!(code(&out), 1, "{}", stderr(&out));
assert!(stderr(&out).contains("src/a.rs"), "{}", stderr(&out));
}
#[test]
fn spec102_an_unstaged_edit_is_judged_under_the_flag() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
couple102_repo(root);
fs::write(root.join("src/a.rs"), b"pub fn a() { /* drift */ }\n").unwrap();
assert_eq!(code(&couple102(root, &[])), 0);
assert_eq!(code(&couple102(root, &["--include-uncommitted"])), 1);
}
#[test]
fn spec102_a_path_in_both_views_is_checked_once() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
couple102_repo(root);
git088(root, &["switch", "-q", "-c", "feature"]);
fs::write(root.join("src/a.rs"), b"pub fn a() { /* one */ }\n").unwrap();
git088(root, &["add", "-A"]);
git088(root, &["commit", "-q", "-m", "one"]);
fs::write(root.join("src/a.rs"), b"pub fn a() { /* one and two */ }\n").unwrap();
let out = couple102(root, &["--include-uncommitted"]);
let err = stderr(&out);
assert_eq!(
err.matches("C-001 'src/a.rs'").count(),
1,
"one violation per path, not one per view: {err}"
);
}
#[test]
fn spec102_a_non_head_head_is_refused() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
couple102_repo(root);
git088(root, &["switch", "-q", "-c", "feature"]);
fs::write(root.join("src/a.rs"), b"pub fn a() { /* one */ }\n").unwrap();
git088(root, &["add", "-A"]);
git088(root, &["commit", "-q", "-m", "one"]);
let out = run_in(
root,
&[
"couple",
"--base",
"main",
"--head",
"main",
"--include-uncommitted",
],
);
assert_eq!(code(&out), 3, "{}", stderr(&out));
assert!(
stderr(&out).contains("--include-uncommitted"),
"{}",
stderr(&out)
);
}
#[test]
fn spec102_paths_from_and_the_flag_are_refused_together() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
couple102_repo(root);
let list = root.join("paths.txt");
fs::write(&list, b"src/a.rs\n").unwrap();
let out = run_in(
root,
&[
"couple",
"--base",
"main",
"--head",
"HEAD",
"--paths-from",
list.to_str().unwrap(),
"--include-uncommitted",
],
);
assert_eq!(code(&out), 3, "{}", stderr(&out));
}
#[test]
fn spec102_the_unflagged_verdict_ignores_the_working_tree() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
couple102_repo(root);
git088(root, &["switch", "-q", "-c", "feature"]);
fs::write(root.join("src/a.rs"), b"pub fn a() { /* one */ }\n").unwrap();
let spec = root.join("specs/001-a/spec.md");
let body = fs::read_to_string(&spec).unwrap();
fs::write(&spec, body.replace("# a\n", "# a\n\nAn authoring edit.\n")).unwrap();
for verb in ["compile", "index"] {
assert_eq!(code(&run_in(root, &[verb])), 0);
}
git088(root, &["add", "-A"]);
git088(root, &["commit", "-q", "-m", "authored"]);
let clean = couple102(root, &[]);
assert_eq!(
code(&clean),
0,
"{}",
String::from_utf8_lossy(&clean.stdout)
);
fs::write(root.join("lib/b.rs"), b"pub fn b() { /* unauthored */ }\n").unwrap();
let still = couple102(root, &[]);
assert_eq!(
code(&still),
0,
"a dirty working tree must not change an unflagged verdict: {}",
String::from_utf8_lossy(&still.stdout)
);
let flagged = couple102(root, &["--include-uncommitted"]);
assert_eq!(code(&flagged), 1, "{}", stderr(&flagged));
assert!(
stderr(&flagged).contains("lib/b.rs"),
"{}",
stderr(&flagged)
);
}
fn spec103_doc(id: &str, status: &str, extra: &str, command: &str) -> String {
format!(
"---\nid: \"{id}\"\ntitle: \"t\"\nstatus: {status}\ncreated: \"2026-09-16\"\n\
summary: \"s\"\nimplementation: complete\n{extra}---\n\n# {id}\n\n\
## Verification\n\n```verify:cli\n{command}\n```\n"
)
}
fn spec103_write(root: &Path, id: &str, body: &str) {
let dir = root.join("specs").join(id);
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("spec.md"), body).unwrap();
}
#[test]
fn spec103_amends_verification_outside_amends_is_v018() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
spec103_write(root, "093-a", &spec103_doc("093-a", "approved", "", "a"));
spec103_write(
root,
"103-b",
&spec103_doc(
"103-b",
"approved",
"amends_verification: [\"093-a\"]\n",
"b",
),
);
let out = run_in(root, &["compile"]);
assert_eq!(code(&out), 1, "{}", stderr(&out));
let err = stderr(&out);
assert!(err.contains("V-018"), "{err}");
assert!(err.contains("not in amends"), "{err}");
}
#[test]
fn spec103_two_specs_claiming_one_acceptance_is_v019() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
spec103_write(root, "093-a", &spec103_doc("093-a", "approved", "", "a"));
let extra = "amends: [\"093-a\"]\namends_verification: [\"093-a\"]\n";
spec103_write(root, "103-b", &spec103_doc("103-b", "approved", extra, "b"));
spec103_write(root, "104-c", &spec103_doc("104-c", "approved", extra, "c"));
let out = run_in(root, &["compile"]);
assert_eq!(code(&out), 1, "{}", stderr(&out));
assert!(stderr(&out).contains("V-019"), "{}", stderr(&out));
let withdrawn = format!("{extra}retirement_rationale: \"withdrawn\"\n");
spec103_write(
root,
"104-c",
&spec103_doc("104-c", "retired", &withdrawn, "c"),
);
let out = run_in(root, &["compile"]);
assert_eq!(code(&out), 0, "{}", stderr(&out));
}
#[test]
fn spec103_a_cycle_in_the_chain_is_v020() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
spec103_write(
root,
"093-a",
&spec103_doc(
"093-a",
"approved",
"amends: [\"103-b\"]\namends_verification: [\"103-b\"]\n",
"a",
),
);
spec103_write(
root,
"103-b",
&spec103_doc(
"103-b",
"approved",
"amends: [\"093-a\"]\namends_verification: [\"093-a\"]\n",
"b",
),
);
let out = run_in(root, &["compile"]);
assert_eq!(code(&out), 1, "{}", stderr(&out));
let err = stderr(&out);
assert!(err.contains("V-020"), "{err}");
assert_eq!(
err.matches("V-020").count(),
1,
"one cycle, one diagnostic: {err}"
);
assert!(err.contains("spec 082 3.3"), "{err}");
}
#[test]
fn spec103_verify_states_the_substitution() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
spec103_write(
root,
"093-a",
&spec103_doc("093-a", "approved", "", "false"),
);
spec103_write(
root,
"103-b",
&spec103_doc(
"103-b",
"approved",
"amends: [\"093-a\"]\namends_verification: [\"093-a\"]\n",
"true",
),
);
let out = run_in(root, &["verify", "093-a"]);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("acceptance amended by 103-b"),
"the substitution must never be silent: {stdout}"
);
assert_eq!(code(&out), 0, "{stdout}");
assert!(stdout.contains("$ true"), "{stdout}");
assert!(!stdout.contains("$ false"), "{stdout}");
let own = run_in(root, &["verify", "103-b"]);
assert!(
!String::from_utf8_lossy(&own.stdout).contains("acceptance amended by"),
"{}",
String::from_utf8_lossy(&own.stdout)
);
}
#[test]
fn spec103_registry_show_carries_amends_verification() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
spec103_write(root, "093-a", &spec103_doc("093-a", "approved", "", "a"));
spec103_write(
root,
"103-b",
&spec103_doc(
"103-b",
"approved",
"amends: [\"093-a\"]\namends_verification: [\"093-a\"]\n",
"b",
),
);
assert_eq!(code(&run_in(root, &["compile"])), 0);
let out = run_in(root, &["registry", "show", "103-b", "--json"]);
assert_eq!(code(&out), 0, "{}", stderr(&out));
let json: serde_json::Value =
serde_json::from_slice(&out.stdout).expect("registry show emits JSON");
assert_eq!(
json["amendsVerification"],
serde_json::json!(["093-a"]),
"{json}"
);
let shard: serde_json::Value = serde_json::from_str(
&fs::read_to_string(root.join(".derived/spec-registry/by-spec/103-b.json")).unwrap(),
)
.unwrap();
let version = shard["specVersion"].as_str().unwrap_or_default();
let (major, minor, _) = spec_spine_types::parse_semver(version)
.unwrap_or_else(|| panic!("specVersion {version:?} is not MAJOR.MINOR.PATCH"));
assert!(major == 1 && minor >= 3, "{shard}");
assert_eq!(
shard["record"]["amendsVerification"],
serde_json::json!(["093-a"]),
"{shard}"
);
}
#[test]
fn statecraft_derived_layout_compiles_indexes_and_is_judged() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
fs::write(
root.join("spec-spine.toml"),
"[layout]\nderived_dir = \".statecraft/derived\"\nstate_dir = \".statecraft/state\"\n",
)
.unwrap();
write_spec(root, "001-a", "001-a", "approved");
write_spec(root, "002-b", "002-b", "approved");
let run = |args: &[&str]| bin().arg("--repo").arg(root).args(args).output().unwrap();
assert_eq!(code(&run(&["compile"])), 0);
assert_eq!(code(&run(&["index"])), 0);
let shard = root.join(".statecraft/derived/spec-registry/by-spec/001-a.json");
assert!(
shard.is_file(),
"the registry shard landed at the configured path"
);
assert!(
root.join(".statecraft/derived/codebase-index/by-spec/001-a.json")
.is_file(),
"and so did the index shard"
);
assert!(
!root.join(".derived").exists(),
"nothing was written at the default path"
);
assert_eq!(code(&run(&["check"])), 0, "both trees are current");
let spec_md = root.join("specs/002-b/spec.md");
let body = fs::read_to_string(&spec_md).unwrap();
fs::write(
&spec_md,
body.replace("summary: \"s\"", "summary: \"changed\""),
)
.unwrap();
assert_eq!(code(&run(&["check"])), 1, "a stale relocated tree is stale");
assert_eq!(code(&run(&["compile"])), 0);
assert_eq!(code(&run(&["index"])), 0);
assert_eq!(code(&run(&["check"])), 0, "and recomputing clears it");
let missing = root.join(".statecraft/derived/spec-registry/by-spec/002-b.json");
fs::remove_file(&missing).unwrap();
assert_eq!(code(&run(&["check"])), 1, "a missing shard is staleness");
assert!(
!missing.exists(),
"the gate did not repair the tree it judged"
);
assert_eq!(code(&run(&["compile"])), 0);
assert!(missing.is_file(), "and a writing compile did");
write_spec(root, "003-c", "003-c", "approved");
assert_eq!(code(&run(&["compile"])), 0);
assert_eq!(code(&run(&["index"])), 0);
assert_eq!(code(&run(&["check"])), 0);
fs::remove_dir_all(root.join("specs/003-c")).unwrap();
let orphaned = run(&["check"]);
assert_eq!(
code(&orphaned),
1,
"a stray shard at the relocated path is refused, as it is at the default \
(spec 076: exit 2, named as orphaned)"
);
let report = format!(
"{}{}",
String::from_utf8_lossy(&orphaned.stdout),
String::from_utf8_lossy(&orphaned.stderr)
);
assert!(report.contains("003-c"), "the orphan is named: {report}");
fs::remove_file(root.join(".statecraft/derived/spec-registry/by-spec/003-c.json")).unwrap();
fs::remove_file(root.join(".statecraft/derived/codebase-index/by-spec/003-c.json")).unwrap();
assert_eq!(code(&run(&["check"])), 0);
let cfg = run(&["config", "show"]);
assert_eq!(code(&cfg), 0);
let text = String::from_utf8_lossy(&cfg.stdout);
assert!(
text.contains("derived_dir = \".statecraft/derived\""),
"{text}"
);
assert!(text.contains("state_dir = \".statecraft/state\""), "{text}");
assert!(
text.contains(".statecraft/derived/"),
"the bypass floor names the configured derived root: {text}"
);
}
fn planned_corpus(root: &Path, implementation: &str) {
let dir = root.join("specs/001-planned");
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join("spec.md"),
format!(
"---\nid: \"001-planned\"\ntitle: \"T\"\nstatus: approved\ncreated: \"2026-09-24\"\n\
implementation: {implementation}\nsummary: \"s\"\nestablishes:\n\
\x20 - {{ kind: file, path: \"crates/later/\", planned: true }}\n---\n\n# 001-planned\n"
),
)
.unwrap();
assert_eq!(code(&run_in(root, &["compile"])), 0);
assert_eq!(code(&run_in(root, &["index"])), 0);
}
#[test]
fn spec130_a_planned_claim_passes_the_gate_flags_before_completion() {
for implementation in ["pending", "in-progress"] {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
planned_corpus(root, implementation);
for args in [
&["check", "--fail-on-unresolved", "--fail-on-warn"][..],
&["index", "check", "--fail-on-unresolved"][..],
] {
let out = run_in(root, args);
assert_eq!(code(&out), 0, "{implementation} {args:?}: {}", stderr(&out));
}
}
}
#[test]
fn spec130_a_planned_claim_is_refused_once_complete() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
planned_corpus(root, "complete");
for args in [
&["check"][..],
&["check", "--fail-on-unresolved"][..],
&["index", "check"][..],
&["index", "check", "--fail-on-unresolved"][..],
] {
let out = run_in(root, args);
assert_eq!(code(&out), 1, "{args:?}: {}", stderr(&out));
assert!(stderr(&out).contains("I-004"), "{args:?}: {}", stderr(&out));
}
}