Skip to content
Draft
5 changes: 3 additions & 2 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
},
/// List active changes
List,
Expand Down
53 changes: 47 additions & 6 deletions src/commands/change.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
Expand Down
25 changes: 20 additions & 5 deletions src/commands/check.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
"{}",
Expand All @@ -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(),
Expand Down Expand Up @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion src/commands/lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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})"
));
Expand Down
8 changes: 6 additions & 2 deletions src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
))
}
Expand Down Expand Up @@ -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(),
Expand Down
2 changes: 1 addition & 1 deletion src/commands/report.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
14 changes: 11 additions & 3 deletions src/commands/stale.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ pub fn cmd_stale(
threshold: usize,
exclude_status: &[String],
only_status: &[String],
enforcement: Option<types::EnforcementMode>,
) {
if !is_git_repo(root) {
match format {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
}
}
12 changes: 12 additions & 0 deletions src/git_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,18 @@ pub fn git_last_commit_hash(root: &Path, file: &str) -> Option<String> {
/// 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",
Expand Down
1 change: 1 addition & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@ fn run() {
threshold,
&cli.exclude_status,
&cli.only_status,
cli.enforcement,
),
Command::Report { stale_threshold } => commands::report::cmd_report(
&root,
Expand Down
Loading
Loading