diff --git a/src/cli.rs b/src/cli.rs index 68e30105..bf77c5df 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -353,9 +353,10 @@ pub enum ChangeAction { /// Canonical owner module for this obligation #[arg(long = "spec")] module: String, - /// Full `specsync.acceptance-entry.v1` predecessor digest + /// Full `specsync.acceptance-entry.v1` predecessor digest (resolved + /// automatically from the predecessor's signed acceptance entry when omitted) #[arg(long)] - digest: String, + digest: Option, }, /// List active changes List, diff --git a/src/commands/change.rs b/src/commands/change.rs index 1931ea02..240a1616 100644 --- a/src/commands/change.rs +++ b/src/commands/change.rs @@ -52,11 +52,20 @@ pub fn cmd_change(root: &Path, action: ChangeAction, format: OutputFormat) { path, module, digest, - } => change::add_supersedes_obligation(root, &id, &predecessor, &path, &module, &digest) - .and_then(|record| print_record(root, &record, format, false)), + } => { + let digest = match digest { + Some(digest) => Ok(digest), + None => change::resolve_predecessor_entry_digest(root, &predecessor, &path), + }; + digest.and_then(|digest| { + change::add_supersedes_obligation(root, &id, &predecessor, &path, &module, &digest) + .and_then(|record| print_record(root, &record, format, false)) + }) + } ChangeAction::List => { - print_records(root, &change::list_changes(root), format); - Ok(()) + let listing = change::list_changes_with_errors(root); + print_records(root, &listing.records, format); + report_listing_errors(&listing) } ChangeAction::Show { id } => change::load_change(root, &id) .and_then(|record| print_record(root, &record, format, true)), @@ -65,8 +74,9 @@ pub fn cmd_change(root: &Path, action: ChangeAction, format: OutputFormat) { change::load_change(root, &id) .and_then(|record| print_record(root, &record, format, false)) } else { - print_records(root, &change::list_changes(root), format); - Ok(()) + let listing = change::list_changes_with_errors(root); + print_records(root, &listing.records, format); + report_listing_errors(&listing) } } ChangeAction::Approve { @@ -316,6 +326,9 @@ fn print_record( println!("{} {}", record.id.bold(), record.title); println!(" State: {}", record.state.as_str()); println!(" Next: {}", summary.next_action); + if !record.dependencies.is_empty() { + println!(" Dependencies: {}", record.dependencies.join(", ")); + } if !corrections.is_empty() { println!(" Corrections:"); for correction in &corrections { @@ -374,9 +387,37 @@ fn print_record( } } } + if summary + .terminal_evidence + .as_ref() + .is_some_and(|evidence| { + evidence.validity == change::TerminalEvidenceValidity::CorruptHistory + }) + { + return Err(format!( + "{} has corrupt archived evidence; inspect the evidence reason above and restore the committed evidence or reopen the change", + record.id + )); + } Ok(()) } +/// Surface one error row per malformed workspace and fail the command so a +/// corrupt change is never indistinguishable from an empty project. +fn report_listing_errors(listing: &change::ChangeListing) -> Result<(), String> { + for error in &listing.errors { + eprintln!("error: {error}"); + } + if listing.errors.is_empty() { + Ok(()) + } else { + Err(format!( + "{} change workspace(s) are unreadable; repair or remove them and retry", + listing.errors.len() + )) + } +} + fn print_records(root: &Path, records: &[ChangeRecord], format: OutputFormat) { let summaries: Vec<_> = records .iter() diff --git a/src/commands/check.rs b/src/commands/check.rs index d089c5f6..119c730f 100644 --- a/src/commands/check.rs +++ b/src/commands/check.rs @@ -94,13 +94,18 @@ pub fn cmd_check( // message under --format json). Default warn mode still exits 0 there. let (config, all_spec_files) = load_and_discover(root, true); let sdd_report = crate::change::check_project(root); + // `--enforcement warn` is non-blocking by definition: SDD lifecycle + // violations then surface as warnings through the normal channels (and a + // zero exit) instead of hard-failing. An unset flag keeps the historical + // exit-1 behavior. + let sdd_warn_only = enforcement == Some(types::EnforcementMode::Warn); if sdd_report.enabled { for warning in &sdd_report.warnings { if matches!(format, Text) { eprintln!("{} {warning}", "warning:".yellow().bold()); } } - if !sdd_report.errors.is_empty() { + if !sdd_report.errors.is_empty() && !sdd_warn_only { match format { Json => println!( "{}", @@ -122,7 +127,7 @@ pub fn cmd_check( } } process::exit(1); - } else if matches!(format, Text) { + } else if sdd_report.errors.is_empty() && matches!(format, Text) { println!( "{} SDD lifecycle valid ({} active change(s))\n", "✓".green(), @@ -390,7 +395,7 @@ pub fn cmd_check( } let collect = !matches!(format, Text); - let (total_errors, total_warnings, passed, total, all_errors, all_warnings, all_notices) = + let (total_errors, total_warnings, passed, total, all_errors, mut all_warnings, all_notices) = run_validation( root, &specs_to_validate, @@ -402,6 +407,16 @@ pub fn cmd_check( explain, &ignore_rules, ); + // In `--enforcement warn` mode, SDD lifecycle violations surface as + // warnings through the normal channels instead of the hard failure above. + if sdd_warn_only && !sdd_report.errors.is_empty() { + for error in &sdd_report.errors { + if matches!(format, Text) { + eprintln!("{} {error}", "warning:".yellow().bold()); + } + all_warnings.push(error.clone()); + } + } // Git-based staleness detection (--stale flag) let stale_threshold = stale.map(|opt| opt.unwrap_or(5)); let mut git_stale_warnings: usize = 0; @@ -442,13 +457,13 @@ pub fn cmd_check( continue; } let behind = git_utils::git_commits_since(root, &spec_commit, source_file); - if behind >= threshold { + if behind > 0 && behind >= threshold { drifted_files.push((source_file.clone(), behind)); } max_behind = max_behind.max(behind); } - if max_behind >= threshold { + if max_behind > 0 && max_behind >= threshold { git_stale_warnings += 1; if matches!(format, types::OutputFormat::Text) { let module = fm.module.as_deref().unwrap_or(&rel_spec); diff --git a/src/commands/lifecycle.rs b/src/commands/lifecycle.rs index e9156ae6..a3ee1b73 100644 --- a/src/commands/lifecycle.rs +++ b/src/commands/lifecycle.rs @@ -222,7 +222,7 @@ pub fn evaluate_guards( &spec_commit, source_file, ); - if commits >= threshold { + if commits > 0 && commits >= threshold { failures.push(format!( "guard: stale — {source_file} has {commits} commits since spec was last updated (threshold: {threshold})" )); diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 1b976378..a1b80df5 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -88,12 +88,13 @@ pub fn validate_module_name(module_name: &str) -> Result<(), String> { let clean = !module_name.contains('/') && !module_name.contains('\\') && !module_name.chars().any(char::is_control); - if single_normal_segment && clean { + let trimmed = module_name.trim() == module_name; + if single_normal_segment && clean && trimmed { return Ok(()); } Err(format!( "invalid module name `{}`: use a single plain name — no path separators (`/`, `\\`), \ - `.`/`..`, drive prefixes, absolute paths, or control characters", + `.`/`..`, drive prefixes, absolute paths, control characters, or leading/trailing whitespace", module_name.escape_default() )) } @@ -888,6 +889,9 @@ mod tests { "evil\nversion: 99", // newline → frontmatter injection "tab\tname", "null\0byte", + " spaced ", // padded names would create literal whitespace directories + " spaced", + "spaced ", ] { assert!( validate_module_name(name).is_err(), diff --git a/src/commands/report.rs b/src/commands/report.rs index b14cca39..c96282b4 100644 --- a/src/commands/report.rs +++ b/src/commands/report.rs @@ -81,7 +81,7 @@ pub fn cmd_report( continue; } let behind = git_commits_since(root, &spec_commit, source_file); - if behind >= stale_threshold { + if behind > 0 && behind >= stale_threshold { stale = true; max_behind = max_behind.max(behind); } diff --git a/src/commands/stale.rs b/src/commands/stale.rs index 87ad9012..0741924e 100644 --- a/src/commands/stale.rs +++ b/src/commands/stale.rs @@ -14,6 +14,7 @@ pub fn cmd_stale( threshold: usize, exclude_status: &[String], only_status: &[String], + enforcement: Option, ) { if !is_git_repo(root) { match format { @@ -95,7 +96,10 @@ pub fn cmd_stale( max_behind = max_behind.max(behind); } - let is_stale = max_behind >= threshold; + // A fully in-sync spec (0 commits behind) is never stale, even at + // `--threshold 0`: the threshold gates how much drift is tolerated, + // not whether drift exists at all. + let is_stale = max_behind > 0 && max_behind >= threshold; if is_stale { stale_specs.push(StaleInfo { spec_path: rel_spec, @@ -229,8 +233,12 @@ pub fn cmd_stale( } } - // Exit with non-zero if stale specs found - if stale_count > 0 { + // Exit with non-zero if stale specs found — unless `--enforcement warn` + // was passed explicitly, which is non-blocking by definition (the help + // text promises warn always exits 0). An unset flag keeps the historical + // exit-1-on-stale behavior regardless of config defaults. + let warn_only = enforcement == Some(types::EnforcementMode::Warn); + if stale_count > 0 && !warn_only { std::process::exit(1); } } diff --git a/src/git_utils.rs b/src/git_utils.rs index 14fdf8a9..41bf3968 100644 --- a/src/git_utils.rs +++ b/src/git_utils.rs @@ -18,6 +18,18 @@ pub fn git_last_commit_hash(root: &Path, file: &str) -> Option { /// callers iterating over a spec's source files spawn one `git rev-list` per /// file instead of also re-resolving the spec's commit each time. pub fn git_commits_since(root: &Path, spec_commit: &str, source_file: &str) -> usize { + // Content first: add-then-revert commit pairs leave the file byte-identical + // to its state at `spec_commit`, and pure commit counting would report + // phantom drift. If the working-tree content matches the content at + // `spec_commit`, there is nothing to catch up on. + if let Ok(diff) = Command::new("git") + .args(["diff", "--quiet", spec_commit, "--", source_file]) + .current_dir(root) + .status() + && diff.success() + { + return 0; + } let output = match Command::new("git") .args([ "rev-list", diff --git a/src/main.rs b/src/main.rs index 495b7c25..cb89eb27 100644 --- a/src/main.rs +++ b/src/main.rs @@ -210,6 +210,7 @@ fn run() { threshold, &cli.exclude_status, &cli.only_status, + cli.enforcement, ), Command::Report { stale_threshold } => commands::report::cmd_report( &root, diff --git a/tests/integration/change.rs b/tests/integration/change.rs index 4da9eb2e..a4bdf942 100644 --- a/tests/integration/change.rs +++ b/tests/integration/change.rs @@ -548,6 +548,199 @@ fn no_spec_change_completes_full_cli_lifecycle() { ); } +#[test] +fn change_list_surfaces_malformed_workspaces_and_exits_nonzero() { + let temp = TempDir::new().unwrap(); + let root = temp.path(); + fs::create_dir_all(root.join(".specsync")).unwrap(); + fs::write( + root.join(".specsync/sdd.json"), + r#"{ + "version": 1, + "enabled": true, + "require_change_for_meaningful_files": false, + "meaningful_paths": [], + "ignored_paths": [], + "verification_commands": [], + "custom_artifacts": {}, + "principles_file": null +} +"#, + ) + .unwrap(); + fs::write(root.join("README.md"), "Docs.\n").unwrap(); + specsync() + .args([ + "--root", + root.to_str().unwrap(), + "change", + "new", + "Update docs", + "--kind", + "documentation", + "--path", + "README.md", + "--no-spec-change", + "--rationale", + "Documentation-only change", + ]) + .assert() + .success(); + let bogus = root.join(".specsync/changes/CHG-9999-bogus"); + fs::create_dir_all(&bogus).unwrap(); + fs::write(bogus.join("state.json"), b"{ not json\n").unwrap(); + // The healthy change is still listed, the corrupt workspace is named on + // stderr with a remediation, and the exit code is non-zero. + specsync() + .args(["--root", root.to_str().unwrap(), "change", "list"]) + .assert() + .failure() + .stdout(predicate::str::contains("CHG-0001-update-docs")) + .stderr(predicate::str::contains("CHG-9999-bogus")) + .stderr(predicate::str::contains("repair or remove")); +} + +#[test] +fn change_status_prints_dependencies() { + let temp = TempDir::new().unwrap(); + let root = temp.path(); + fs::create_dir_all(root.join(".specsync")).unwrap(); + fs::write( + root.join(".specsync/sdd.json"), + r#"{ + "version": 1, + "enabled": true, + "require_change_for_meaningful_files": false, + "meaningful_paths": [], + "ignored_paths": [], + "verification_commands": [], + "custom_artifacts": {}, + "principles_file": null +} +"#, + ) + .unwrap(); + fs::write(root.join("README.md"), "Docs.\n").unwrap(); + specsync() + .args([ + "--root", + root.to_str().unwrap(), + "change", + "new", + "Update docs", + "--kind", + "documentation", + "--path", + "README.md", + "--no-spec-change", + "--rationale", + "Documentation-only change", + ]) + .assert() + .success(); + let id = "CHG-0001-update-docs"; + let state_path = root.join(".specsync/changes").join(id).join("state.json"); + let mut state: Value = + serde_json::from_str(&fs::read_to_string(&state_path).unwrap()).unwrap(); + state["dependencies"] = serde_json::json!(["CHG-0000-predecessor"]); + fs::write(&state_path, serde_json::to_string_pretty(&state).unwrap()).unwrap(); + specsync() + .args(["--root", root.to_str().unwrap(), "change", "status", id]) + .assert() + .success() + .stdout(predicate::str::contains( + "Dependencies: CHG-0000-predecessor", + )); +} + +#[test] +fn change_status_fails_on_corrupt_archived_evidence() { + let temp = TempDir::new().unwrap(); + let root = temp.path(); + let git = |args: &[&str]| { + assert!( + Command::new("git") + .args(args) + .current_dir(root) + .status() + .unwrap() + .success() + ); + }; + git(&["init", "-b", "main"]); + git(&["config", "user.email", "test@example.com"]); + git(&["config", "user.name", "Test"]); + git(&["commit", "--allow-empty", "-m", "base"]); + fs::write(root.join("README.md"), "Docs.\n").unwrap(); + specsync() + .args([ + "--root", + root.to_str().unwrap(), + "change", + "new", + "Update docs", + "--kind", + "documentation", + "--path", + "README.md", + "--no-spec-change", + "--rationale", + "Documentation-only change", + ]) + .assert() + .success(); + let id = "CHG-0001-update-docs"; + for (question, answer) in [ + ("acceptance_criteria", "Docs stay accurate"), + ("public_contract", "no"), + ("architecture_risk", "no"), + ] { + specsync() + .args([ + "--root", + root.to_str().unwrap(), + "change", + "answer", + id, + question, + answer, + ]) + .assert() + .success(); + } + let dir = root.join(".specsync/changes").join(id); + fs::write(dir.join("context.md"), "# Context\n\nDocs.\n").unwrap(); + fs::write(dir.join("docs.md"), "# Docs\n\nDocs.\n").unwrap(); + for command in ["approve", "start", "verify", "accept"] { + specsync() + .args(["--root", root.to_str().unwrap(), "change", command, id]) + .assert() + .success(); + } + git(&["add", "."]); + git(&["commit", "-m", "record accepted lifecycle evidence"]); + git(&["update-ref", "refs/remotes/origin/main", "HEAD"]); + specsync() + .args(["--root", root.to_str().unwrap(), "change", "archive", id]) + .assert() + .success(); + let archive_dir = root + .join(".specsync/archive/changes") + .read_dir() + .unwrap() + .map(|entry| entry.unwrap().path()) + .find(|path| path.file_name().unwrap().to_string_lossy().ends_with(id)) + .unwrap(); + // Corrupt the authenticated accepted-state snapshot: status must fail + // loudly instead of rendering the archive as healthy. + fs::write(archive_dir.join("accepted-state.json"), b"{}\n").unwrap(); + specsync() + .args(["--root", root.to_str().unwrap(), "change", "status", id]) + .assert() + .failure() + .stderr(predicate::str::contains("corrupt archived evidence")); +} + #[test] fn change_supersede_persists_an_exact_predecessor_obligation_through_the_cli() { let temp = TempDir::new().unwrap(); @@ -843,7 +1036,7 @@ fn stale_accepted_change_reopens_through_cli_with_deterministic_audit_json() { "accepted change verification is stale for current delivery inputs", )) .stderr(predicate::str::contains( - "exact-only delivery input `README.md` changed after acceptance and requires an audited reopen; run `specsync change reopen CHG-0001-update-review-instructions` to re-verify the accepted change", + "exact-only delivery input `README.md` changed after acceptance and requires an audited reopen; run `specsync change reopen CHG-0001-update-review-instructions --actor --reason ` to re-verify the accepted change", )); let output = specsync() .args([ @@ -1664,3 +1857,182 @@ fn migrate_5_0_backfills_reopening_digest_fields_idempotently() { .stdout(predicate::str::contains("nothing to migrate")); assert_eq!(fs::read(&approvals_path).unwrap(), before); } + +#[test] +fn change_supersede_resolves_the_entry_digest_automatically() { + let temp = TempDir::new().unwrap(); + let root = temp.path(); + let git = |args: &[&str]| { + assert!( + Command::new("git") + .args(args) + .current_dir(root) + .status() + .unwrap() + .success() + ); + }; + git(&["init", "-b", "main"]); + git(&["config", "user.email", "test@example.com"]); + git(&["config", "user.name", "Test"]); + fs::create_dir_all(root.join(".specsync")).unwrap(); + fs::create_dir_all(root.join("specs/auth")).unwrap(); + fs::create_dir_all(root.join("src")).unwrap(); + fs::write( + root.join(".specsync/sdd.json"), + r#"{ + "version": 1, + "enabled": true, + "require_change_for_meaningful_files": false, + "meaningful_paths": [], + "ignored_paths": [], + "verification_commands": [], + "custom_artifacts": {}, + "principles_file": null +} +"#, + ) + .unwrap(); + fs::write( + root.join("src/auth.rs"), + "pub fn authenticate() -> bool { true }\n", + ) + .unwrap(); + fs::write( + root.join("specs/auth/auth.spec.md"), + "---\nmodule: auth\nversion: 1.0.0\nstatus: stable\nfiles:\n - src/auth.rs\n---\n\n# Auth\n\n## Purpose\n\nAuthentication.\n\n## Public API\n\n| Name | Description |\n|------|-------------|\n| `authenticate` | Return whether authentication succeeds |\n\n## Invariants\n\nAuthentication is available.\n\n## Behavioral Examples\n\nValid users authenticate.\n\n## Error Cases\n\nInvalid users fail.\n\n## Dependencies\n\nNone.\n\n## Legacy Notes\n\nNone.\n\n## Change Log\n\n| Date | Change |\n|------|--------|\n| 2026-01-01 | Initial |\n", + ) + .unwrap(); + fs::write( + root.join("specs/auth/requirements.md"), + "---\nspec: auth.spec.md\n---\n\n# Requirements\n\n### REQ-auth-001\n\nAuthentication SHALL remain available.\n", + ) + .unwrap(); + git(&["add", "."]); + git(&["commit", "-m", "base"]); + + specsync() + .args([ + "--root", + root.to_str().unwrap(), + "change", + "new", + "Govern authentication", + "--kind", + "bug-fix", + "--spec", + "auth", + "--path", + "src/auth.rs", + ]) + .assert() + .success(); + let predecessor = "CHG-0001-govern-authentication"; + for (question, answer) in [ + ("acceptance_criteria", "Authentication remains governed"), + ("public_contract", "yes"), + ("architecture_risk", "no"), + ] { + specsync() + .args([ + "--root", + root.to_str().unwrap(), + "change", + "answer", + predecessor, + question, + answer, + ]) + .assert() + .success(); + } + let predecessor_dir = root.join(".specsync/changes").join(predecessor); + let state: Value = + serde_json::from_str(&fs::read_to_string(predecessor_dir.join("state.json")).unwrap()) + .unwrap(); + for artifact in state["selected_artifacts"].as_array().unwrap() { + let name = artifact.as_str().unwrap(); + let content = if name == "tasks" { + "# Tasks\n\n- [x] Complete predecessor preparation.\n" + } else { + "# Complete\n\nReviewed predecessor evidence.\n" + }; + fs::write(predecessor_dir.join(format!("{name}.md")), content).unwrap(); + } + fs::write( + predecessor_dir.join("deltas/auth.md"), + "## MODIFIED\n### SPEC SECTION Invariants\n\nAuthentication remains governed.\n", + ) + .unwrap(); + for command in ["approve", "start", "verify", "accept"] { + specsync() + .args([ + "--root", + root.to_str().unwrap(), + "change", + command, + predecessor, + ]) + .assert() + .success(); + } + git(&["add", "."]); + git(&["commit", "-m", "accept predecessor"]); + git(&["update-ref", "refs/remotes/origin/main", "HEAD"]); + let verification: Value = serde_json::from_str( + &fs::read_to_string(predecessor_dir.join("verification.json")).unwrap(), + ) + .unwrap(); + let digest = verification["acceptance_manifest"]["entries"] + .as_array() + .unwrap() + .iter() + .find(|entry| entry["path"] == "src/auth.rs") + .unwrap()["entry_digest"] + .as_str() + .unwrap() + .to_string(); + + specsync() + .args([ + "--root", + root.to_str().unwrap(), + "change", + "new", + "Evolve authentication", + "--kind", + "bug-fix", + "--spec", + "auth", + "--path", + "src/auth.rs", + ]) + .assert() + .success(); + let successor = "CHG-0002-evolve-authentication"; + // No --digest: the exact entry digest is resolved from the predecessor. + let output = specsync() + .args([ + "--root", + root.to_str().unwrap(), + "--json", + "change", + "supersede", + successor, + predecessor, + "--path", + "src/auth.rs", + "--spec", + "auth", + ]) + .assert() + .success() + .get_output() + .stdout + .clone(); + let persisted: Value = serde_json::from_slice(&output).unwrap(); + assert_eq!( + persisted["change"]["supersedes"][0]["obligations"][0]["predecessor_entry_digest"], + digest + ); +} diff --git a/tests/integration/check.rs b/tests/integration/check.rs index 9c0c853c..30078585 100644 --- a/tests/integration/check.rs +++ b/tests/integration/check.rs @@ -2045,3 +2045,161 @@ fn check_require_coverage_gate_fails_on_warm_cache() { .assert() .failure(); } + +// ─── stale subcommand drift semantics (#445) ──────────────────────────── + +fn setup_stale_fixture(root: &std::path::Path) { + write_config(root, "specs", &["src"]); + fs::create_dir_all(root.join("src/auth")).unwrap(); + fs::create_dir_all(root.join("specs/auth")).unwrap(); + fs::write(root.join("src/auth/auth.ts"), "export const a = 1;\n").unwrap(); + let spec = valid_spec("auth", &["src/auth/auth.ts"]); + fs::write(root.join("specs/auth/auth.spec.md"), spec).unwrap(); + let git = |args: &[&str]| { + let output = std::process::Command::new("git") + .args(args) + .current_dir(root) + .output() + .unwrap(); + assert!( + output.status.success(), + "git {} failed: {}", + args.join(" "), + String::from_utf8_lossy(&output.stderr) + ); + }; + git(&["init", "-b", "main"]); + git(&["config", "user.email", "test@example.com"]); + git(&["config", "user.name", "Test"]); + git(&["add", "."]); + git(&["commit", "-m", "base"]); +} + +#[test] +fn stale_threshold_zero_passes_when_in_sync_and_fails_on_real_drift() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path().to_path_buf(); + setup_stale_fixture(&root); + + // Fully in-sync: 0 commits behind must not trip --threshold 0. + specsync() + .args(["stale", "--threshold", "0"]) + .arg("--root") + .arg(&root) + .assert() + .success(); + + // Real drift still fails at threshold 0. + fs::write(root.join("src/auth/auth.ts"), "export const a = 2;\n").unwrap(); + std::process::Command::new("git") + .args(["add", "."]) + .current_dir(&root) + .status() + .unwrap(); + std::process::Command::new("git") + .args(["commit", "-m", "drift"]) + .current_dir(&root) + .status() + .unwrap(); + specsync() + .args(["stale", "--threshold", "0"]) + .arg("--root") + .arg(&root) + .assert() + .failure(); +} + +#[test] +fn stale_warn_enforcement_exits_zero_despite_drift() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path().to_path_buf(); + setup_stale_fixture(&root); + fs::write(root.join("src/auth/auth.ts"), "export const a = 2;\n").unwrap(); + let git = |args: &[&str]| { + assert!( + std::process::Command::new("git") + .args(args) + .current_dir(&root) + .status() + .unwrap() + .success() + ); + }; + git(&["add", "."]); + git(&["commit", "-m", "drift"]); + + // Default behavior preserved: stale findings exit 1. + specsync() + .args(["stale", "--threshold", "1"]) + .arg("--root") + .arg(&root) + .assert() + .failure(); + // --enforcement warn is non-blocking by definition. + specsync() + .args(["--enforcement", "warn", "stale", "--threshold", "1"]) + .arg("--root") + .arg(&root) + .assert() + .success(); +} + +#[test] +fn stale_ignores_add_then_revert_commit_pairs() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path().to_path_buf(); + setup_stale_fixture(&root); + let git = |args: &[&str], msg: &str| { + assert!( + std::process::Command::new("git") + .args(args) + .current_dir(&root) + .status() + .unwrap() + .success(), + "{msg}" + ); + }; + // Add a line, then revert it: two commits, byte-identical content. + fs::write( + root.join("src/auth/auth.ts"), + "export const a = 1;\nexport const b = 2;\n", + ) + .unwrap(); + git(&["add", "."], "add"); + git(&["commit", "-m", "add b"], "add"); + fs::write(root.join("src/auth/auth.ts"), "export const a = 1;\n").unwrap(); + git(&["add", "."], "revert"); + git(&["commit", "-m", "revert b"], "revert"); + + specsync() + .args(["stale", "--threshold", "1"]) + .arg("--root") + .arg(&root) + .assert() + .success(); +} + +#[test] +fn sdd_errors_honor_enforcement_warn_exit_semantics() { + let tmp = TempDir::new().unwrap(); + let root = setup_minimal_project(&tmp); + fs::create_dir_all(root.join(".specsync")).unwrap(); + fs::write(root.join(".specsync/sdd.json"), "{").unwrap(); + + // Default behavior preserved: SDD errors exit 1. + specsync() + .arg("check") + .arg("--root") + .arg(&root) + .assert() + .failure(); + // Explicit --enforcement warn reports the violation but exits 0. + specsync() + .args(["--enforcement", "warn", "check"]) + .arg("--root") + .arg(&root) + .assert() + .success() + .stderr(predicate::str::contains("warning:")); +}