Skip to content

feat: full spec lifecycle — statuses, transitions, guards, auto-promote, CI enforcement#197

Closed
corvid-agent wants to merge 10 commits intomainfrom
feat/lifecycle-phase1
Closed

feat: full spec lifecycle — statuses, transitions, guards, auto-promote, CI enforcement#197
corvid-agent wants to merge 10 commits intomainfrom
feat/lifecycle-phase1

Conversation

@corvid-agent
Copy link
Copy Markdown
Collaborator

Summary

  • Adds review and archived to the spec lifecycle: draft → review → active → stable → deprecated → archived
  • Implements per-status validation profiles (archived specs skip validation entirely, review specs skip API surface checks)
  • Adds global --exclude-status and --only-status CLI flags for filtering specs by lifecycle stage across check, score, stale, and report commands

Per-Status Validation Profiles

Status What's enforced
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

New CLI Flags

specsync check --exclude-status deprecated,archived
specsync check --only-status active,stable
specsync score --exclude-status draft
specsync stale --only-status active

Phase 1 of the full lifecycle feature — follow-up phases will add the specsync lifecycle command suite (promote/demote/status), staleness alerts, and the lifecycle dashboard.

Test plan

  • All 531 unit tests + 97 integration tests pass
  • --exclude-status draft correctly filters (54 → 24 specs on own repo)
  • --only-status active correctly filters
  • Archived specs return early from validation
  • Clean build, zero warnings
  • Manual: create a spec with status: review, verify sections checked but API surface skipped
  • Manual: create a spec with status: archived, verify it's excluded from check

🤖 Generated with Claude Code

corvid-agent and others added 9 commits April 11, 2026 07:57
…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>
@corvid-agent corvid-agent changed the title feat: lifecycle phase 1 — review/archived statuses + status filtering feat: full spec lifecycle — statuses, transitions, guards, auto-promote, CI enforcement Apr 11, 2026
@0xLeif 0xLeif requested a review from Copilot April 11, 2026 15:42
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 SpecStatus to draft → 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 for archived).
  • Add global --exclude-status / --only-status filters and wire them through check, score, stale, and report, plus introduce specsync lifecycle commands 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
Comment on lines +225 to +229
// 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());
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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) {

Copilot uses AI. Check for mistakes.
Comment on lines +342 to 355
// 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
}
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +152 to +155
// If we can't parse status, include the spec (let validation catch the error)
let status = match status {
Some(s) => s,
None => return true,
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
// 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(),

Copilot uses AI. Check for mistakes.
Comment on lines +147 to +151
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());

Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +23 to +31
if STATUS_LINE_RE.is_match(content) {
Some(
STATUS_LINE_RE
.replace(content, format!("status: {new_status}"))
.to_string(),
)
} else {
None
}
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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)

Copilot uses AI. Check for mistakes.
Comment on lines +1095 to +1104
/// 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(),
}
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +170 to +196
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})"
));
}
}
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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}"
)),

Copilot uses AI. Check for mistakes.
| `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 |
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
| `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 |

Copilot uses AI. Check for mistakes.
Comment on lines +73 to +79
| `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 |
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +198 to +234
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,
),
},
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
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>
@corvid-agent
Copy link
Copy Markdown
Collaborator Author

Closing in favor of #199 which contains all lifecycle + migrate work combined.

@corvid-agent corvid-agent deleted the feat/lifecycle-phase1 branch April 12, 2026 05:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants