feat: full spec lifecycle — statuses, transitions, guards, auto-promote, CI enforcement#197
feat: full spec lifecycle — statuses, transitions, guards, auto-promote, CI enforcement#197corvid-agent wants to merge 10 commits intomainfrom
Conversation
…tion profiles, status filtering Add `review` and `archived` to the spec lifecycle (draft → review → active → stable → deprecated → archived). Per-status validation profiles: - draft: structure only (existing behavior) - review: structure + sections, skip API surface - active: full validation, warnings only - stable: full validation, strict mode - deprecated: lifecycle warning, skip coverage - archived: excluded from validation entirely (early return) New global CLI flags: - `--exclude-status deprecated,archived` — filter out specs by status - `--only-status active,stable` — include only matching specs Filtering applied to: check, score, stale, report commands. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Adds `specsync lifecycle` command suite: - promote: advance spec to next status in lifecycle order - demote: move spec back to previous status - set: explicitly set any status (with transition validation) - status: show lifecycle status of one or all specs Transition rules enforce single-step movement plus direct-to-deprecated. --force overrides validation. JSON output supported via --format json. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ction inputs Adds documentation for 13 previously undocumented commands (lifecycle, new, stale, rules, report, comment, deps, scaffold, import, wizard, issues, changelog, merge) across README.md and docs/cli.md. Adds comment/token inputs to GitHub Action docs with PR comment workflow examples. Documents all missing flags (--stale, --exclude-status, --only-status, --mermaid, --dot, --full, --all). Updates architecture section with new source files. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…history commands Configurable transition guards in specsync.json enforce quality gates before specs can be promoted. Guards support min score, required sections, and staleness checks. Transition history is automatically recorded in frontmatter. New commands: - lifecycle history <spec> — view transition audit log - lifecycle guard <spec> [target] — dry-run guard evaluation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Run cargo fmt fixes on lifecycle.rs and mod.rs (alphabetical ordering) - Collapse nested if in types.rs valid_transitions (clippy::collapsible_if) - Document LifecycleAction in cli_args spec - Document lifecycle submodule and filter_by_status in commands spec - Document 6 new SpecStatus lifecycle methods in types spec - Add cmd_lifecycle spec for lifecycle.rs Co-Authored-By: Corvid Agent <corvidagent@proton.me>
- Add GuardResult, evaluate_guards, cmd_history, cmd_guard to lifecycle spec - Add LifecycleConfig, TransitionGuard structs to types spec - Add from_str_loose, parsed_status to types spec - Update LifecycleAction to include History and Guard variants - Update Frontmatter description to include lifecycle_log Co-Authored-By: Corvid Agent <corvidagent@proton.me>
- Split long write_status() calls in lifecycle.rs for cargo fmt compliance - Add requirements.md companion for cmd_lifecycle spec Co-Authored-By: Corvid Agent <corvidagent@proton.me>
…detection - `lifecycle auto-promote`: scan all specs, promote any that pass guards (--dry-run) - `lifecycle enforce`: CI enforcement mode with --require-status, --max-age, --allowed, --all - Stale-status detection via configurable `lifecycle.maxAge` (days per status) - `lifecycle.allowedStatuses` config for restricting valid statuses - GitHub Action `lifecycle-enforce` input for CI integration - 5 new unit tests for date math and status age estimation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Fix 5 cargo fmt issues in lifecycle.rs (long lines from phase 4 code) - Document cmd_auto_promote and cmd_enforce in lifecycle spec - Update LifecycleAction variants in cli_args spec Co-Authored-By: Corvid Agent <corvidagent@proton.me>
There was a problem hiding this comment.
Pull request overview
This PR expands SpecSync’s spec lifecycle support and CLI capabilities by adding new lifecycle statuses, status-aware validation behavior, status-based filtering across commands, and a new specsync lifecycle command suite (plus CI/action wiring and docs/spec updates).
Changes:
- Extend
SpecStatustodraft → review → active → stable → deprecated → archived, including helper methods and lifecycle config/guard types. - Add status-aware validation behavior (skip certain checks for
draft/review, early-return behavior forarchived). - Add global
--exclude-status/--only-statusfilters and wire them throughcheck,score,stale, andreport, plus introducespecsync lifecyclecommands and GitHub Action support for lifecycle enforcement.
Reviewed changes
Copilot reviewed 20 out of 20 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| src/validator.rs | Adds lifecycle warnings and status-based validation/skip logic. |
| src/types.rs | Extends SpecStatus, adds lifecycle config + guard structs, adds lifecycle_log to frontmatter. |
| src/parser.rs | Parses lifecycle_log from frontmatter lists. |
| src/cli.rs | Adds global status filters and the lifecycle command/subcommands. |
| src/main.rs | Wires status filters into commands and routes lifecycle subcommands. |
| src/commands/mod.rs | Introduces filter_by_status helper used by multiple commands. |
| src/commands/check.rs | Applies status filtering in check. |
| src/commands/score.rs | Applies status filtering in score. |
| src/commands/stale.rs | Applies status filtering in stale. |
| src/commands/report.rs | Applies status filtering in report. |
| src/commands/lifecycle.rs | New lifecycle command implementation (promote/demote/set/status/history/guard/auto-promote/enforce) + tests. |
| specs/types/types.spec.md | Updates documented types/APIs for lifecycle additions. |
| specs/commands/commands.spec.md | Documents filter_by_status and lifecycle module addition. |
| specs/cmd_lifecycle/* | Adds spec + requirements for the new lifecycle command module. |
| specs/cli_args/cli_args.spec.md | Documents the new Lifecycle CLI command variant. |
| README.md | Updates CLI examples, flags list, and lifecycle guard/enforcement documentation. |
| docs/cli.md | Adds CLI documentation for lifecycle and related commands/flags. |
| docs/github-action.md | Documents PR comment support and token input. |
| action.yml | Adds lifecycle-enforce input and runs lifecycle enforcement in CI when enabled. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
src/validator.rs
Outdated
| // Archived specs: skip all further validation | ||
| if spec_status == Some(crate::types::SpecStatus::Archived) { | ||
| result | ||
| .warnings | ||
| .push("Spec is archived — excluded from validation".to_string()); |
There was a problem hiding this comment.
Archived specs are described as excluded from validation, but this branch adds a warning and returns early. In --strict mode, warnings are treated as errors (see compute_exit_code), so archived specs will still fail CI. Consider returning with no warnings/errors (or downgrading this to a non-warning informational output) so archived specs truly don’t affect validation outcomes.
| // Archived specs: skip all further validation | |
| if spec_status == Some(crate::types::SpecStatus::Archived) { | |
| result | |
| .warnings | |
| .push("Spec is archived — excluded from validation".to_string()); | |
| // Archived specs: skip all further validation without affecting validation outcomes | |
| if spec_status == Some(crate::types::SpecStatus::Archived) { |
| // Required markdown sections | ||
| // - draft: structure only, skip "Public API" | ||
| // - review: structure + sections, skip "Public API" | ||
| // - active/stable/deprecated: all sections required | ||
| let skip_public_api = matches!( | ||
| spec_status, | ||
| Some(crate::types::SpecStatus::Draft) | Some(crate::types::SpecStatus::Review) | ||
| ); | ||
| let is_draft = spec_status == Some(crate::types::SpecStatus::Draft); | ||
| let missing = get_missing_sections(body, &config.required_sections); | ||
| for section in &missing { | ||
| if is_draft && section == "Public API" { | ||
| continue; // drafts can skip Public API | ||
| if skip_public_api && section == "Public API" { | ||
| continue; // drafts and review specs can skip Public API | ||
| } |
There was a problem hiding this comment.
The PR description says review specs enforce required markdown sections (only API surface checks are skipped), but this change allows review to skip the Public API section requirement. Either remove Review from skip_public_api or update the PR/docs to match the intended behavior.
src/commands/mod.rs
Outdated
| // If we can't parse status, include the spec (let validation catch the error) | ||
| let status = match status { | ||
| Some(s) => s, | ||
| None => return true, |
There was a problem hiding this comment.
When --only-status is provided, specs with missing/invalid status are still included (None => return true). For commands like score/stale/report that don’t necessarily run validation, this makes --only-status ineffective and can produce unexpected results. Consider excluding unknown-status specs when only is non-empty (and optionally emitting a warning listing skipped files).
| // If we can't parse status, include the spec (let validation catch the error) | |
| let status = match status { | |
| Some(s) => s, | |
| None => return true, | |
| // If we can't parse status, only include the spec when no allowlist filter is active; | |
| // otherwise `--only-status` would be ineffective for malformed or missing statuses. | |
| let status = match status { | |
| Some(s) => s, | |
| None => return only_set.is_empty(), |
| let status = std::fs::read_to_string(path) | ||
| .ok() | ||
| .and_then(|content| parser::parse_frontmatter(&content.replace("\r\n", "\n"))) | ||
| .and_then(|parsed| parsed.frontmatter.parsed_status()); | ||
|
|
There was a problem hiding this comment.
filter_by_status reads and parses each spec file’s frontmatter, but the same files are typically read again later (validation, scoring, stale checks, etc.). This doubles disk I/O and parsing work for large spec sets. Consider threading parsed frontmatter/status through discovery, caching parsed results, or restructuring callers to reuse the already-read content.
src/commands/lifecycle.rs
Outdated
| if STATUS_LINE_RE.is_match(content) { | ||
| Some( | ||
| STATUS_LINE_RE | ||
| .replace(content, format!("status: {new_status}")) | ||
| .to_string(), | ||
| ) | ||
| } else { | ||
| None | ||
| } |
There was a problem hiding this comment.
update_status_in_content replaces the first ^status: line anywhere in the file, not necessarily within the YAML frontmatter block. If a spec body contains a line starting with status: (e.g., in an example/code block), this will corrupt the document. Restrict the replacement to the frontmatter region (between the first and second --- delimiters) before doing the regex replace.
| if STATUS_LINE_RE.is_match(content) { | |
| Some( | |
| STATUS_LINE_RE | |
| .replace(content, format!("status: {new_status}")) | |
| .to_string(), | |
| ) | |
| } else { | |
| None | |
| } | |
| if !content.starts_with("---\n") { | |
| return None; | |
| } | |
| let frontmatter_start = 4; | |
| let rest = &content[frontmatter_start..]; | |
| let frontmatter_end = rest.find("\n---\n")?; | |
| let frontmatter = &rest[..frontmatter_end]; | |
| if !STATUS_LINE_RE.is_match(frontmatter) { | |
| return None; | |
| } | |
| let updated_frontmatter = STATUS_LINE_RE | |
| .replace(frontmatter, format!("status: {new_status}")) | |
| .to_string(); | |
| let mut updated = String::with_capacity(content.len() - frontmatter.len() + updated_frontmatter.len()); | |
| updated.push_str(&content[..frontmatter_start]); | |
| updated.push_str(&updated_frontmatter); | |
| updated.push_str(&rest[frontmatter_end..]); | |
| Some(updated) |
src/commands/lifecycle.rs
Outdated
| /// Get today's date as YYYY-MM-DD string. | ||
| fn chrono_today() -> String { | ||
| // Use std::time to avoid a chrono dependency | ||
| let output = std::process::Command::new("date") | ||
| .args(["+%Y-%m-%d"]) | ||
| .output(); | ||
| match output { | ||
| Ok(o) => String::from_utf8_lossy(&o.stdout).trim().to_string(), | ||
| Err(_) => "unknown-date".to_string(), | ||
| } |
There was a problem hiding this comment.
chrono_today() shells out to the date command, which won’t work on Windows and may be unavailable in some minimal environments. Since the action explicitly supports Windows, lifecycle commands/tests relying on this will break there. Prefer computing the local date in Rust (e.g., via a small time/date dependency) or make this platform-aware with a Windows fallback.
src/commands/lifecycle.rs
Outdated
| let content = std::fs::read_to_string(spec_path).unwrap_or_default(); | ||
| let parsed = parser::parse_frontmatter(&content.replace("\r\n", "\n")); | ||
| if let Some(parsed) = &parsed { | ||
| let missing = parser::get_missing_sections(&parsed.body, &guard.require_sections); | ||
| if !missing.is_empty() { | ||
| failures.push(format!( | ||
| "guard: missing required sections: {}", | ||
| missing.join(", ") | ||
| )); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Check staleness | ||
| if guard.no_stale.unwrap_or(false) { | ||
| let threshold = guard.stale_threshold.unwrap_or(5); | ||
| let content = std::fs::read_to_string(spec_path).unwrap_or_default(); | ||
| let parsed = parser::parse_frontmatter(&content.replace("\r\n", "\n")); | ||
| if let Some(parsed) = &parsed { | ||
| for source_file in &parsed.frontmatter.files { | ||
| let commits = git_utils::git_commits_between(root, &rel, source_file); | ||
| if commits >= threshold { | ||
| failures.push(format!( | ||
| "guard: stale — {source_file} has {commits} commits since spec was last updated (threshold: {threshold})" | ||
| )); | ||
| } | ||
| } |
There was a problem hiding this comment.
Guard checks can be bypassed if the spec can’t be read or parsed: read_to_string(...).unwrap_or_default() + if let Some(parsed) means required-sections / staleness guards silently do nothing on errors, potentially allowing transitions that should be blocked. Treat read/parse failures as guard failures (and include an actionable failure message) so enforcement is reliable.
| let content = std::fs::read_to_string(spec_path).unwrap_or_default(); | |
| let parsed = parser::parse_frontmatter(&content.replace("\r\n", "\n")); | |
| if let Some(parsed) = &parsed { | |
| let missing = parser::get_missing_sections(&parsed.body, &guard.require_sections); | |
| if !missing.is_empty() { | |
| failures.push(format!( | |
| "guard: missing required sections: {}", | |
| missing.join(", ") | |
| )); | |
| } | |
| } | |
| } | |
| // Check staleness | |
| if guard.no_stale.unwrap_or(false) { | |
| let threshold = guard.stale_threshold.unwrap_or(5); | |
| let content = std::fs::read_to_string(spec_path).unwrap_or_default(); | |
| let parsed = parser::parse_frontmatter(&content.replace("\r\n", "\n")); | |
| if let Some(parsed) = &parsed { | |
| for source_file in &parsed.frontmatter.files { | |
| let commits = git_utils::git_commits_between(root, &rel, source_file); | |
| if commits >= threshold { | |
| failures.push(format!( | |
| "guard: stale — {source_file} has {commits} commits since spec was last updated (threshold: {threshold})" | |
| )); | |
| } | |
| } | |
| match std::fs::read_to_string(spec_path) { | |
| Ok(content) => { | |
| let normalized = content.replace("\r\n", "\n"); | |
| match parser::parse_frontmatter(&normalized) { | |
| Some(parsed) => { | |
| let missing = | |
| parser::get_missing_sections(&parsed.body, &guard.require_sections); | |
| if !missing.is_empty() { | |
| failures.push(format!( | |
| "guard: missing required sections: {}", | |
| missing.join(", ") | |
| )); | |
| } | |
| } | |
| None => failures.push( | |
| "guard: unable to parse spec for required-sections check; fix the spec frontmatter/body format" | |
| .to_string(), | |
| ), | |
| } | |
| } | |
| Err(err) => failures.push(format!( | |
| "guard: unable to read spec for required-sections check: {err}" | |
| )), | |
| } | |
| } | |
| // Check staleness | |
| if guard.no_stale.unwrap_or(false) { | |
| let threshold = guard.stale_threshold.unwrap_or(5); | |
| match std::fs::read_to_string(spec_path) { | |
| Ok(content) => { | |
| let normalized = content.replace("\r\n", "\n"); | |
| match parser::parse_frontmatter(&normalized) { | |
| Some(parsed) => { | |
| for source_file in &parsed.frontmatter.files { | |
| let commits = git_utils::git_commits_between(root, &rel, source_file); | |
| if commits >= threshold { | |
| failures.push(format!( | |
| "guard: stale — {source_file} has {commits} commits since spec was last updated (threshold: {threshold})" | |
| )); | |
| } | |
| } | |
| } | |
| None => failures.push( | |
| "guard: unable to parse spec for staleness check; fix the spec frontmatter/body format" | |
| .to_string(), | |
| ), | |
| } | |
| } | |
| Err(err) => failures.push(format!( | |
| "guard: unable to read spec for staleness check: {err}" | |
| )), |
| | `cmd_history` | `root: &Path, spec_filter: &str, format: OutputFormat` | `()` | Display lifecycle transition history for a spec | | ||
| | `cmd_guard` | `root: &Path, spec_filter: &str, target_str: Option<&str>, format: OutputFormat` | `()` | Evaluate and display guard results for a spec transition | | ||
| | `cmd_auto_promote` | `root: &Path, format: OutputFormat, dry_run: bool` | `()` | Scan all specs and promote any that pass guards automatically | | ||
| | `cmd_enforce` | `root: &Path, format: OutputFormat, require_status: bool, check_max_age: bool` | `()` | Enforce lifecycle policies — flag specs without status or stuck too long in a phase | |
There was a problem hiding this comment.
The cmd_enforce signature in this spec doesn’t match the implementation: src/commands/lifecycle.rs::cmd_enforce takes check_allowed as an additional boolean parameter. Update the spec’s Public API table to include this parameter (or adjust the code/spec so they match).
| | `cmd_enforce` | `root: &Path, format: OutputFormat, require_status: bool, check_max_age: bool` | `()` | Enforce lifecycle policies — flag specs without status or stuck too long in a phase | | |
| | `cmd_enforce` | `root: &Path, format: OutputFormat, require_status: bool, check_max_age: bool, check_allowed: bool` | `()` | Enforce lifecycle policies — flag specs without status or stuck too long in a phase | |
specs/types/types.spec.md
Outdated
| | `as_str` | `&self` | `&str` | String representation of the status | | ||
| | `parsed_status` | `s: &str` | `Option<Self>` | Parse status string into SpecStatus enum | | ||
| | `from_str_loose` | `s: &str` | `Option<Self>` | Parse status string into SpecStatus enum (case-insensitive) | | ||
| | `parsed_status` | `&self` | `Option<SpecStatus>` | Parse the Frontmatter status field into a typed enum | | ||
| | `all` | — | `&[Self]` | Returns all status variants in lifecycle order | | ||
| | `ordinal` | `&self` | `usize` | Numeric position in lifecycle order (0=draft, 5=archived) | | ||
| | `next` | `&self` | `Option<Self>` | Next status in linear lifecycle (draft→review→active→stable), None at end | | ||
| | `prev` | `&self` | `Option<Self>` | Previous status in linear lifecycle, None at start | |
There was a problem hiding this comment.
In this SpecStatus API table: (1) parsed_status is implemented on Frontmatter, not on SpecStatus. (2) The next/prev descriptions mention only draft→review→active→stable, but the enum includes deprecated/archived and next() advances through those as well. Update the spec so the documented APIs/behavior match src/types.rs.
| Command::Lifecycle { action } => match action { | ||
| LifecycleAction::Promote { spec, force } => { | ||
| commands::lifecycle::cmd_promote(&root, &spec, format, force) | ||
| } | ||
| LifecycleAction::Demote { spec, force } => { | ||
| commands::lifecycle::cmd_demote(&root, &spec, format, force) | ||
| } | ||
| LifecycleAction::Set { | ||
| spec, | ||
| status, | ||
| force, | ||
| } => commands::lifecycle::cmd_set(&root, &spec, &status, format, force), | ||
| LifecycleAction::Status { spec } => { | ||
| commands::lifecycle::cmd_status(&root, spec.as_deref(), format) | ||
| } | ||
| LifecycleAction::History { spec } => { | ||
| commands::lifecycle::cmd_history(&root, &spec, format) | ||
| } | ||
| LifecycleAction::Guard { spec, target } => { | ||
| commands::lifecycle::cmd_guard(&root, &spec, target.as_deref(), format) | ||
| } | ||
| LifecycleAction::AutoPromote { dry_run } => { | ||
| commands::lifecycle::cmd_auto_promote(&root, format, dry_run) | ||
| } | ||
| LifecycleAction::Enforce { | ||
| require_status, | ||
| max_age, | ||
| allowed, | ||
| all, | ||
| } => commands::lifecycle::cmd_enforce( | ||
| &root, | ||
| format, | ||
| require_status || all, | ||
| max_age || all, | ||
| allowed || all, | ||
| ), | ||
| }, |
There was a problem hiding this comment.
The PR description says the specsync lifecycle command suite will be added in follow-up phases, but this change wires a full Lifecycle command (promote/demote/set/status/history/guard/auto-promote/enforce) into the CLI. Please update the PR description (or scope) so it accurately reflects what’s being shipped in this PR.
Cherry-picked lifecycle-specific fixes from the migrate branch: - lifecycle.rs: chrono_today() pure Rust, enforce tip text, config serialization - validator.rs: lifecycle status filtering, validation profile fixes - config.rs: lifecycle TOML parsing, "lifecycle" in KNOWN_JSON_KEYS - types.rs: Default derive on TransitionGuard - specs: updated cli_args (28 variants), lifecycle spec, types spec - README: removed migrate row (not on this branch), fixed config resolution Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Closing in favor of #199 which contains all lifecycle + migrate work combined. |
Summary
reviewandarchivedto the spec lifecycle:draft → review → active → stable → deprecated → archived--exclude-statusand--only-statusCLI flags for filtering specs by lifecycle stage acrosscheck,score,stale, andreportcommandsPer-Status Validation Profiles
draftreviewactivestabledeprecatedarchivedNew CLI Flags
Phase 1 of the full lifecycle feature — follow-up phases will add the
specsync lifecyclecommand suite (promote/demote/status), staleness alerts, and the lifecycle dashboard.Test plan
--exclude-status draftcorrectly filters (54 → 24 specs on own repo)--only-status activecorrectly filtersstatus: review, verify sections checked but API surface skippedstatus: archived, verify it's excluded fromcheck🤖 Generated with Claude Code