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"])),
2,
"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"])),
2
);
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"])),
2,
"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"])), 2);
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()
),
3
);
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()
),
3
);
}
#[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");
}
#[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 ids: Vec<String> = serde_json::from_slice(&json.stdout).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 none: Vec<String> = serde_json::from_slice(&filtered_json.stdout).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), 3, "I/O error exits 3");
}
#[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), 3);
}
#[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), 2, "stale -> 2");
}
#[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), 3, "render without index -> 3");
let early_orphans = bin()
.arg("--repo")
.arg(tmp.path())
.args(["index", "orphans"])
.output()
.unwrap();
assert_eq!(code(&early_orphans), 3, "orphans without index -> 3");
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"])),
2,
"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), 2, "edited spec, stale shard -> 2");
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"])),
3,
"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"])),
2,
"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 035):\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["ok"], true, "{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_eq!(v["ok"], false);
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), 2, "{args:?} prose");
assert_eq!(code(&json), code(&prose), "{args:?} json");
let v = envelope(&json);
assert_eq!(v["ok"], false, "{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), 3);
let v = envelope(&out);
assert_eq!(v["verb"], "lint");
assert_eq!(v["exitCode"], 3);
assert_eq!(v["ok"], false);
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"], "config");
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["ok"], true);
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_eq!(v["ok"], false);
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_eq!(v["ok"], false);
assert_eq!(v["error"]["kind"], "config");
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!(v.get("schemaVersion").is_none(), "{v}");
assert_eq!(
v["ready"],
serde_json::json!([{ "id": "002-now", "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!({ "id": "002-now", "title": "T" }));
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 060 §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["ok"], true);
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\ntrue\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["ok"], true);
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["ok"], true);
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 050"
);
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"])),
2,
"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 v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
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"])),
2,
"the one condition exit 2 is for"
);
}