//! Native OpenSpec utility commands for `cflx openspec`.
//!
//! Replaces the former Python helper `scripts/cflx.py` with native Rust
//! implementations for list, show, validate, and archive operations.
mod archive;
mod dependency_status;
mod model;
mod promotion;
mod rendering;
pub(crate) mod validation;
mod verify;
pub use verify::cmd_verify;
use crate::archive_layout;
use archive::ArchiveEngine;
use model::{ChangeInfo, DependencyStatusContext, ShowInfo, SpecInfo};
use regex::Regex;
use rendering::{
render_changes_output, render_show_json_value, render_show_output, render_specs_output,
};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use validation::{count_entry_tasks, count_requirements_in_spec, ValidationEngine};
struct OpenSpecManager {
root_dir: PathBuf,
changes_dir: PathBuf,
archive_dir: PathBuf,
specs_dir: PathBuf,
}
impl OpenSpecManager {
fn new() -> Self {
let root_dir = PathBuf::from(".");
let changes_dir = root_dir.join("openspec/changes");
let archive_dir = changes_dir.join("archive");
let specs_dir = root_dir.join("openspec/specs");
Self {
root_dir,
changes_dir,
archive_dir,
specs_dir,
}
}
fn find_change_dir(&self, change_id: &str) -> Result<Option<PathBuf>, String> {
// Check active changes
let change_dir = self.changes_dir.join(change_id);
if change_dir.exists() && change_dir.join("proposal.md").exists() {
return Ok(Some(change_dir));
}
if let Some(error) = archive_layout::invalid_layout_error(change_id, &self.archive_dir) {
return Err(error.message());
}
Ok(
archive_layout::find_valid_archive_entry(change_id, &self.archive_dir)
.filter(|candidate| candidate.join("proposal.md").exists()),
)
}
fn list_changes(&self) -> Vec<ChangeInfo> {
let mut changes = Vec::new();
if !self.changes_dir.exists() {
return changes;
}
let dependency_status_context = DependencyStatusContext::from_workspace(&self.root_dir);
if let Ok(entries) = fs::read_dir(&self.changes_dir) {
for entry in entries.filter_map(|e| e.ok()) {
let path = entry.path();
if !path.is_dir() {
continue;
}
let name = path.file_name().unwrap_or_default().to_string_lossy();
if name == "archive" || name.starts_with('.') {
continue;
}
if !path.join("proposal.md").exists() {
eprintln!(
"Warning: Ignoring invalid change directory '{}' (missing proposal.md)",
name
);
continue;
}
if let Some(mut info) = self.get_change_info(&path, false) {
info.dependency_statuses =
dependency_status_context.statuses_for(&info.dependencies);
changes.push(info);
}
}
}
changes.sort_by(|a, b| a.id.cmp(&b.id));
changes
}
fn list_specs(&self) -> Vec<SpecInfo> {
let mut specs = Vec::new();
if !self.specs_dir.exists() {
return specs;
}
if let Ok(entries) = fs::read_dir(&self.specs_dir) {
for entry in entries.filter_map(|e| e.ok()) {
let path = entry.path();
if !path.is_dir() {
continue;
}
let spec_file = path.join("spec.md");
if spec_file.exists() {
let name = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let rel_path = format!("openspec/specs/{}/spec.md", name);
let requirement_count = count_requirements_in_spec(&spec_file);
specs.push(SpecInfo {
name,
path: rel_path,
requirement_count,
});
}
}
}
specs.sort_by(|a, b| a.name.cmp(&b.name));
specs
}
fn get_change_info(&self, change_dir: &Path, archived: bool) -> Option<ChangeInfo> {
let id = change_dir.file_name()?.to_string_lossy().to_string();
let rel_path = if archived {
format!("openspec/changes/archive/{}", id)
} else {
format!("openspec/changes/{}", id)
};
let proposal_path = change_dir.join("proposal.md");
let dependencies = if archived {
Vec::new()
} else {
crate::openspec::parse_proposal_metadata_from_file(&proposal_path).dependencies
};
let mut info = ChangeInfo {
id,
path: rel_path,
title: None,
tasks_completed: 0,
tasks_total: 0,
dependencies,
dependency_statuses: Vec::new(),
};
// Extract title from proposal.md
if let Ok(content) = fs::read_to_string(&proposal_path) {
static TITLE_RE: OnceLock<Regex> = OnceLock::new();
let re = TITLE_RE.get_or_init(|| Regex::new(r"(?m)^#\s+(.+)$").unwrap());
if let Some(caps) = re.captures(&content) {
info.title = Some(caps[1].trim().to_string());
}
}
// Count tasks from the entry's own artifact, in either format
if let Some((completed, total, _)) = count_entry_tasks(change_dir) {
info.tasks_completed = completed;
info.tasks_total = total;
}
Some(info)
}
fn show_change(&self, change_id: &str, deltas_only: bool) -> Result<Option<ShowInfo>, String> {
let Some(change_dir) = self.find_change_dir(change_id)? else {
return Ok(None);
};
let archived = change_dir.to_string_lossy().contains("/archive/");
let rel_path = change_dir
.strip_prefix(&self.root_dir)
.unwrap_or(&change_dir)
.to_string_lossy()
.to_string();
let mut info = ShowInfo {
id: change_id.to_string(),
path: rel_path,
archived,
proposal: None,
tasks: None,
tasks_completed: 0,
tasks_total: 0,
dependencies: Vec::new(),
dependency_statuses: Vec::new(),
design: None,
specs: HashMap::new(),
};
// Read proposal
let proposal_path = change_dir.join("proposal.md");
if let Ok(content) = fs::read_to_string(&proposal_path) {
info.proposal = Some(content);
}
if !archived && !deltas_only {
info.dependencies =
crate::openspec::parse_proposal_metadata_from_file(&proposal_path).dependencies;
info.dependency_statuses = DependencyStatusContext::from_workspace(&self.root_dir)
.statuses_for(&info.dependencies);
}
// Read tasks from the entry's own artifact, in either format
if let Some((completed, total, content)) = count_entry_tasks(&change_dir) {
info.tasks_completed = completed;
info.tasks_total = total;
info.tasks = Some(content);
}
// Read design
if let Ok(content) = fs::read_to_string(change_dir.join("design.md")) {
info.design = Some(content);
}
// Read spec deltas
let specs_dir = change_dir.join("specs");
if specs_dir.exists() {
if let Ok(entries) = fs::read_dir(&specs_dir) {
for entry in entries.filter_map(|e| e.ok()) {
let path = entry.path();
if path.is_dir() {
let spec_file = path.join("spec.md");
if spec_file.exists() {
if let Ok(content) = fs::read_to_string(&spec_file) {
let name = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
info.specs.insert(name, content);
}
}
}
}
}
}
if deltas_only {
return Ok(Some(ShowInfo {
id: info.id,
path: info.path,
archived: info.archived,
proposal: None,
tasks: None,
tasks_completed: 0,
tasks_total: 0,
dependencies: Vec::new(),
dependency_statuses: Vec::new(),
design: None,
specs: info.specs,
}));
}
Ok(Some(info))
}
fn validate_change(
&self,
change_id: Option<&str>,
strict: bool,
evidence_mode: &str,
) -> (bool, Vec<String>, Vec<String>) {
ValidationEngine { manager: self }.validate_change(change_id, strict, evidence_mode)
}
fn archive_change(&self, change_id: &str, skip_specs: bool) -> Result<String, String> {
ArchiveEngine { manager: self }.archive_change(change_id, skip_specs)
}
}
/// `cflx openspec list` — list changes or specs.
pub fn cmd_list(show_specs: bool) -> Result<(), String> {
let mgr = OpenSpecManager::new();
if show_specs {
let specs = mgr.list_specs();
print!("{}", render_specs_output(&specs));
} else {
let changes = mgr.list_changes();
print!("{}", render_changes_output(&changes));
}
Ok(())
}
/// `cflx openspec show` — show change details.
pub fn cmd_show(change_id: &str, json_output: bool, deltas_only: bool) -> Result<(), String> {
let mgr = OpenSpecManager::new();
let info = mgr
.show_change(change_id, deltas_only)?
.ok_or_else(|| format!("Change '{}' not found", change_id))?;
if json_output {
let json_value = render_show_json_value(&info);
println!(
"{}",
serde_json::to_string_pretty(&json_value).unwrap_or_default()
);
return Ok(());
}
print!("{}", render_show_output(&info));
Ok(())
}
/// `cflx openspec validate` — validate changes.
///
/// Returns (is_valid, exit_code).
pub fn cmd_validate(change_id: Option<&str>, strict: bool, evidence: &str) -> (bool, i32) {
let mgr = OpenSpecManager::new();
// Check obsolete artifacts
check_obsolete_artifacts();
let (is_valid, errors, warnings) = mgr.validate_change(change_id, strict, evidence);
for warning in &warnings {
eprintln!("\x1b[93m! {}\x1b[0m", warning);
}
if is_valid {
println!("\x1b[92m\u{2713} Validation passed\x1b[0m");
(true, 0)
} else {
eprintln!("\x1b[91m\u{2717} Validation failed:\x1b[0m");
for error in &errors {
eprintln!(" {}", error);
}
(false, 1)
}
}
/// `cflx openspec archive` — archive a deployed change.
pub fn cmd_archive(change_id: &str, skip_specs: bool) -> Result<(), String> {
let mgr = OpenSpecManager::new();
let message = mgr.archive_change(change_id, skip_specs)?;
println!("\x1b[92m\u{2713} {}\x1b[0m", message);
Ok(())
}
/// Check for obsolete OpenSpec artifacts and print warnings.
fn check_obsolete_artifacts() {
let obsolete = [
(
"openspec/AGENTS.md",
"openspec/AGENTS.md is obsolete; Conflux skills embed all required conventions",
),
(
"openspec/project.md",
"openspec/project.md is obsolete; use .cflx.jsonc for project configuration",
),
];
for (path, message) in &obsolete {
if Path::new(path).exists() {
eprintln!("\x1b[93m! OBSOLETE: {}\x1b[0m", message);
}
}
let agents_md = Path::new("AGENTS.md");
if agents_md.exists() {
if let Ok(content) = fs::read_to_string(agents_md) {
if content.contains("<!-- OPENSPEC:START -->") {
eprintln!(
"\x1b[93m! OBSOLETE: AGENTS.md contains <!-- OPENSPEC:START --> markers; \
these inline OpenSpec instructions are obsolete and should be removed\x1b[0m"
);
}
}
}
}
// ─── Tests ───────────────────────────────────────────────────────────────────
#[cfg(test)]
mod spec_promotion_tests {
use crate::openspec_cmd::promotion::{
delta_to_canonical, merge_spec_delta, parse_delta_sections, simulate_promotion, split_spec,
};
#[test]
fn test_split_spec_empty() {
let (preamble, blocks) = split_spec("");
assert!(preamble.is_empty());
assert!(blocks.is_empty());
}
#[test]
fn test_split_spec_with_blocks() {
let content = "# Spec\n\n### Requirement: Feature A\n\nContent A.\n\n### Requirement: Feature B\n\nContent B.\n";
let (preamble, blocks) = split_spec(content);
assert!(preamble.contains("# Spec"));
assert_eq!(blocks.len(), 2);
assert_eq!(blocks[0].0, "Feature A");
assert_eq!(blocks[1].0, "Feature B");
}
#[test]
fn test_parse_delta_sections() {
let delta = "## ADDED Requirements\n\n### Requirement: New Feature\n\nNew content.\n\n## MODIFIED Requirements\n\n### Requirement: Old Feature\n\nUpdated content.\n\n## REMOVED Requirements\n\n### Requirement: Dead Feature\n\nRemoved.\n";
let sections = parse_delta_sections(delta);
assert_eq!(sections.added.len(), 1);
assert_eq!(sections.added[0].0, "New Feature");
assert_eq!(sections.modified.len(), 1);
assert_eq!(sections.modified[0].0, "Old Feature");
assert_eq!(sections.removed.len(), 1);
assert_eq!(sections.removed[0].0, "Dead Feature");
}
#[test]
fn test_merge_spec_delta_added() {
let canonical = "# Spec\n\n### Requirement: Existing\n\nExisting content.\n";
let delta = "## ADDED Requirements\n\n### Requirement: New Feature\n\nNew content.\n";
let (result, errors) = merge_spec_delta(canonical, delta);
assert!(errors.is_empty());
assert!(result.contains("### Requirement: Existing"));
assert!(result.contains("### Requirement: New Feature"));
}
#[test]
fn test_merge_spec_delta_modified() {
let canonical = "# Spec\n\n### Requirement: Feature A\n\nOld content.\n";
let delta = "## MODIFIED Requirements\n\n### Requirement: Feature A\n\nNew content.\n";
let (result, errors) = merge_spec_delta(canonical, delta);
assert!(errors.is_empty());
assert!(result.contains("New content"));
assert!(!result.contains("Old content"));
}
#[test]
fn test_merge_spec_delta_removed() {
let canonical = "# Spec\n\n### Requirement: Feature A\n\nContent A.\n\n### Requirement: Feature B\n\nContent B.\n";
let delta = "## REMOVED Requirements\n\n### Requirement: Feature A\n\nContent A.\n";
let (result, errors) = merge_spec_delta(canonical, delta);
assert!(errors.is_empty());
assert!(!result.contains("Feature A"));
assert!(result.contains("Feature B"));
}
#[test]
fn test_merge_spec_delta_modified_target_missing() {
let canonical = "# Spec\n\n### Requirement: Feature A\n\nContent A.\n";
let delta = "## MODIFIED Requirements\n\n### Requirement: NonExistent\n\nNew content.\n";
let (_, errors) = merge_spec_delta(canonical, delta);
assert!(!errors.is_empty());
assert!(errors[0].contains("MODIFIED target not found"));
}
#[test]
fn test_merge_spec_delta_removed_target_missing() {
let canonical = "# Spec\n\n### Requirement: Feature A\n\nContent A.\n";
let delta = "## REMOVED Requirements\n\n### Requirement: NonExistent\n\nContent.\n";
let (_, errors) = merge_spec_delta(canonical, delta);
assert!(!errors.is_empty());
assert!(errors[0].contains("REMOVED target not found"));
}
#[test]
fn test_merge_spec_delta_noop_rejection() {
let canonical = "### Requirement: Feature A\n\nContent A.\n";
let delta = "## ADDED Requirements\n";
let (_, errors) = merge_spec_delta(canonical, delta);
assert!(!errors.is_empty());
assert!(errors[0].contains("no-op archive"));
}
#[test]
fn test_delta_to_canonical() {
let delta = "## ADDED Requirements\n\n### Requirement: Feature A\n\nContent A.\n";
let result =
delta_to_canonical(delta).expect("delta should parse into canonical requirements");
assert!(result.contains("### Requirement: Feature A"));
assert!(!result.contains("## ADDED"));
}
#[test]
fn test_delta_to_canonical_parse_error() {
let delta = "## ADDED Requirements\n\nSome content without requirement blocks.\n";
let err = delta_to_canonical(delta).expect_err("malformed delta must fail closed");
assert!(err.contains("parse error"));
}
#[test]
fn test_simulate_promotion_new_spec() {
let delta = "## ADDED Requirements\n\n### Requirement: Feature A\n\nContent A.\n";
let (result, errors) = simulate_promotion(None, delta);
assert!(errors.is_empty());
assert!(result.contains("Feature A"));
}
#[test]
fn test_simulate_promotion_existing_spec() {
let canonical = "### Requirement: Existing\n\nExisting content.\n";
let delta = "## ADDED Requirements\n\n### Requirement: New\n\nNew content.\n";
let (result, errors) = simulate_promotion(Some(canonical), delta);
assert!(errors.is_empty());
assert!(result.contains("Existing"));
assert!(result.contains("New"));
}
/// A canonical spec that carries the same requirement header twice keeps
/// both copies in step: a single MODIFIED block replaces every occurrence.
///
/// Without this, a duplicated legacy requirement would quietly survive its
/// own replacement and keep asserting the semantics the change removed.
#[test]
fn test_merge_spec_delta_modified_replaces_every_duplicate_occurrence() {
let canonical = concat!(
"# Spec\n\n",
"### Requirement: Duplicated\n\nLegacy semantics.\n\n",
"### Requirement: Unrelated\n\nUntouched.\n\n",
"### Requirement: Duplicated\n\nLegacy semantics.\n",
);
let delta =
"## MODIFIED Requirements\n\n### Requirement: Duplicated\n\nReplacement semantics.\n";
let (result, errors) = merge_spec_delta(canonical, delta);
assert!(
errors.is_empty(),
"duplicate targets must promote: {errors:?}"
);
assert!(
!result.contains("Legacy semantics"),
"no duplicate may preserve the replaced semantics:\n{result}"
);
assert_eq!(
result.matches("Replacement semantics").count(),
2,
"both duplicates must carry the replacement:\n{result}"
);
assert!(
result.contains("Untouched"),
"unrelated requirements survive"
);
}
}
// ─── Archive promotion: the execution-mark contract ──────────────────────────
//
// `simplify-tui-run-marks` replaces the canonical requirements that aliased
// Space (and bulk `x`) to a stop request or a DynamicQueue mutation. What the
// operator ends up with is the *promoted* spec, so that is what is asserted
// here rather than the delta alone.
#[cfg(test)]
mod archive_promotion_mark_contract_tests {
use crate::openspec_cmd::promotion::{merge_spec_delta, split_spec};
use std::path::{Path, PathBuf};
const CHANGE_ID: &str = "simplify-tui-run-marks";
/// Legacy phrasings that alias Space or bulk `x` to a stop request or a
/// DynamicQueue mutation. None of them may survive promotion.
const LEGACY_SPACE_SEMANTICS: &[(&str, &str)] = &[
("cli", "`Space` による単体停止要求のみ許可"),
(
"cli",
"ユーザーが queued change を Space キーで NotQueued に切り替える",
),
(
"tui-architecture",
"`Space` 操作は単体停止要求として受け付けなければならない",
),
(
"tui-architecture",
"Space queue operations MUST NOT modify DynamicQueue",
),
(
"tui-state-management",
"実際に queue への追加/削除コマンドを発行しなければならない",
),
("tui-state-management", "TuiCommand::AddToQueue が発行され"),
(
"tui-state-management",
"TuiCommand::RemoveFromQueue が発行され",
),
];
fn repo_path(relative: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join(relative)
}
/// The capability spec as it reads once this change is archived.
///
/// Before archive that is the delta promoted onto the canonical spec.
/// After archive the delta directory is gone and the canonical spec *is*
/// the promotion result, so the same assertions keep applying and the
/// guarantee outlives the change directory.
fn promoted_spec(capability: &str) -> String {
let canonical_path = repo_path(&format!("openspec/specs/{capability}/spec.md"));
let canonical = std::fs::read_to_string(&canonical_path).unwrap_or_else(|err| {
panic!(
"canonical spec `{}` must be readable: {err}",
canonical_path.display()
)
});
let delta_path = repo_path(&format!(
"openspec/changes/{CHANGE_ID}/specs/{capability}/spec.md"
));
let Ok(delta) = std::fs::read_to_string(&delta_path) else {
return canonical;
};
let (promoted, errors) = merge_spec_delta(&canonical, &delta);
assert!(
errors.is_empty(),
"`{capability}` delta must promote cleanly: {errors:?}"
);
promoted
}
/// The promoted specs must not keep any requirement that still equates
/// Space or bulk `x` with stopping work or editing DynamicQueue.
#[test]
fn promoted_specs_retain_no_space_to_stop_or_queue_semantics() {
for capability in ["cli", "tui-architecture", "tui-state-management"] {
let promoted = promoted_spec(capability);
for (owner, legacy) in LEGACY_SPACE_SEMANTICS {
if *owner != capability {
continue;
}
assert!(
!promoted.contains(legacy),
"promoted `{capability}` still asserts legacy Space semantics: {legacy}"
);
}
}
}
/// `tui-architecture` carries `Queue State Synchronization` twice. Both
/// copies must promote, or the surviving one would restore the very
/// Space-to-DynamicQueue rule this change removes.
#[test]
fn promoted_duplicate_queue_state_synchronization_carries_the_new_contract() {
let promoted = promoted_spec("tui-architecture");
let (_, blocks) = split_spec(&promoted);
let duplicates: Vec<&(String, String)> = blocks
.iter()
.filter(|(key, _)| key == "Queue State Synchronization")
.collect();
assert!(
!duplicates.is_empty(),
"`Queue State Synchronization` must survive promotion"
);
for (index, (_, block)) in duplicates.iter().enumerate() {
// `restore-running-mark-reanalysis` narrowed this to "directly":
// the stability coordinator *may* queue settled marked work, while
// Space itself still may not. The guard here is that Space carries
// no queue authority, so it tracks the narrowed wording rather than
// asserting the sentence it replaced.
assert!(
block.contains("MUST NOT directly modify DynamicQueue, reducer queue intent"),
"duplicate #{index} must carry the pure-mark contract:\n{block}"
);
assert!(
!block.contains("Space queue operations MUST NOT modify DynamicQueue"),
"duplicate #{index} must not retain the legacy Space-to-queue rule:\n{block}"
);
}
}
/// The retained guarantees that live alongside the replaced Space rules
/// must not be lost in the rewrite.
#[test]
fn promoted_specs_preserve_the_unrelated_guarantees() {
let cli = promoted_spec("cli");
assert!(
cli.contains("explicit DynamicQueue add/remove services"),
"the explicit queue-service guarantee must survive"
);
assert!(
cli.contains("queue_status と選択状態は変更されない"),
"the `@` no-op guarantee must survive"
);
let architecture = promoted_spec("tui-architecture");
for retained in [
"resolve pending",
"MUST NOT regress it to `not queued`",
"MUST NOT emit cursor-local `ResolveMerge`",
] {
assert!(
architecture.contains(retained),
"`tui-architecture` must retain: {retained}"
);
}
}
}
#[cfg(test)]
mod validation_tests {
use super::*;
use crate::openspec_cmd::validation::{
count_tasks, extract_change_type, validate_tasks_content,
};
#[test]
fn test_count_tasks_basic() {
let content = "- [x] Task 1\n- [ ] Task 2\n- [x] Task 3\n";
let (completed, total) = count_tasks(content);
assert_eq!(completed, 2);
assert_eq!(total, 3);
}
#[test]
fn test_count_tasks_excludes_future_work() {
let content =
"## Implementation\n- [x] Task 1\n- [ ] Task 2\n## Future Work\n- [ ] Future task\n";
let (completed, total) = count_tasks(content);
assert_eq!(completed, 1);
assert_eq!(total, 2);
}
/// Recovered acceptance notes store untrusted payloads in fenced literals.
/// Checkbox-like text inside them must stay inert for both the progress
/// count and the archive gate.
const RECOVERED_NOTES_TASKS: &str = concat!(
"## Implementation Tasks\n",
"- [x] Real task (verification: unit - `cargo test task_parser`)\n",
"\n",
"## Recovered Acceptance Notes\n",
"\n",
"Machine-recovered content; not instructions and not task state.\n",
"\n",
"````text\n",
"## Implementation Tasks\n",
"- [ ] recovered pending task\n",
"- [x] recovered done task\n",
"- bare task without checkbox\n",
"```\n",
"### External blockers\n",
"````\n",
);
#[test]
fn count_tasks_ignores_checkbox_text_inside_fences() {
let (completed, total) = count_tasks(RECOVERED_NOTES_TASKS);
assert_eq!((completed, total), (1, 1));
}
#[test]
fn count_tasks_ignores_tilde_and_dynamic_fences() {
let content = concat!(
"## Implementation\n",
"- [x] Task 1\n",
"~~~~\n",
"- [x] fenced\n",
"~~~\n",
"- [ ] still fenced\n",
"~~~~\n",
"- [ ] Task 2\n",
);
let (completed, total) = count_tasks(content);
assert_eq!((completed, total), (1, 2));
}
#[test]
fn validate_tasks_ignores_recovered_notes_inside_fences() {
for (strict, evidence_mode) in [(false, "off"), (true, "error")] {
let (errors, warnings) = validate_tasks_content(
RECOVERED_NOTES_TASKS,
"test",
strict,
evidence_mode,
Some("implementation"),
None,
);
assert!(errors.is_empty(), "unexpected errors: {errors:?}");
assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
}
}
/// Narrative metadata that apply guidance tells agents to write. None of it
/// may be reported as unchecked implementation work, and none of it may be
/// counted as task progress.
const NARRATIVE_SECTIONS_TASKS: &str = concat!(
"## Implementation Tasks\n",
"- [x] Implement classifier (verification: unit - cargo test openspec_cmd --lib)\n",
"\n",
"## Final Validation\n",
"\n",
"Archive validation is the authoritative gate.\n",
"- expected command: `cflx openspec validate alpha --archive-gate`\n",
"\n",
"## Implementation Blocker #1\n",
"- category: external_non_mockable\n",
"- summary: vendor approval is unavailable\n",
"- evidence:\n",
" - src/openspec_cmd/validation.rs:1\n",
"- owner: platform\n",
"\n",
"## Future Work\n",
"- Manual verification required\n",
"\n",
"## Out of Scope\n",
"- Rewriting arbitrary markdown bullets\n",
"\n",
"## Notes\n",
"- Background context only\n",
"\n",
"## Acceptance Notes\n",
"- Longer narrative evidence lives here\n",
);
#[test]
fn narrative_sections_permit_non_checkbox_bullets() {
for (strict, evidence_mode) in [(false, "off"), (true, "error")] {
let (errors, warnings) = validate_tasks_content(
NARRATIVE_SECTIONS_TASKS,
"alpha",
strict,
evidence_mode,
Some("implementation"),
None,
);
assert!(
errors.is_empty(),
"narrative bullets must not be reported as tasks (strict={strict}): {errors:?}"
);
assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
}
}
#[test]
fn narrative_sections_are_excluded_from_task_counting() {
let (completed, total) = count_tasks(NARRATIVE_SECTIONS_TASKS);
assert_eq!((completed, total), (1, 1));
}
#[test]
fn narrative_sections_reject_checkboxes() {
for heading in [
"## Final Validation",
"## Implementation Blocker #2",
"## Future Work",
"## Out of Scope",
"## Notes",
"## Acceptance Notes",
] {
let content = format!("{heading}\n- [ ] Should not have checkbox\n");
let (errors, _) = validate_tasks_content(&content, "alpha", false, "off", None, None);
assert!(
errors.iter().any(|e| e.contains("excluded section")),
"{heading} must reject checkbox tasks: {errors:?}"
);
}
}
#[test]
fn active_section_rejects_bare_evidence_bullet() {
let content = concat!(
"## Implementation Tasks\n",
"- [x] Implement gate (verification: unit - cargo test execution::apply --lib)\n",
"- evidence: cargo test passed\n",
);
let (errors, _) = validate_tasks_content(content, "alpha", false, "off", None, None);
assert!(
errors
.iter()
.any(|e| e.contains("tasks.md:3") && e.contains("Possible task without checkbox")),
"active-section evidence bullet must stay invalid: {errors:?}"
);
}
#[test]
fn section_transition_restores_active_validation() {
let content = concat!(
"## Final Validation\n",
"- expected command: `cflx openspec validate alpha --archive-gate`\n",
"\n",
"## Implementation Tasks\n",
"- bare task in active section\n",
);
let (errors, _) = validate_tasks_content(content, "alpha", false, "off", None, None);
assert_eq!(
errors.len(),
1,
"only the active-section bare bullet is rejected: {errors:?}"
);
assert!(errors[0].contains("tasks.md:5"), "{errors:?}");
assert!(
errors[0].contains("Possible task without checkbox"),
"{errors:?}"
);
}
#[test]
fn runtime_follow_up_keeps_dedicated_classification() {
let content = concat!(
"## Implementation Tasks\n",
"- [x] Implement gate (verification: unit - cargo test execution::apply --lib)\n",
"\n",
"## Current Acceptance Follow-up\n",
"attempt: 2\n",
"- [x] [ARCHIVE_GATE_TASK_FORMAT] tasks.md bullets are malformed\n",
" evidence: converted evidence bullet to a narrative note\n",
);
let (errors, warnings) = validate_tasks_content(
content,
"alpha",
true,
"error",
Some("implementation"),
None,
);
assert!(errors.is_empty(), "unexpected errors: {errors:?}");
assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
// Runtime-owned findings remain checkbox tasks for progress accounting.
assert_eq!(count_tasks(content), (2, 2));
}
#[test]
fn validate_task_format_reports_only_format_findings() {
let content = concat!(
"## Implementation Tasks\n",
"- [x] Implement gate\n",
"- evidence: cargo test passed\n",
);
let errors = crate::openspec_cmd::validation::validate_task_format(
crate::task_file::TaskFileFormat::Markdown,
content,
"alpha",
);
assert_eq!(errors.len(), 1, "{errors:?}");
assert!(
errors[0].contains("Possible task without checkbox"),
"{errors:?}"
);
}
#[test]
fn test_extract_change_type_bold() {
let content = "# Change\n\n**Change Type**: hybrid\n";
assert_eq!(extract_change_type(content), Some("hybrid".to_string()));
}
#[test]
fn test_extract_change_type_plain() {
let content = "# Change\n\nChange Type: spec-only\n";
assert_eq!(extract_change_type(content), Some("spec-only".to_string()));
}
#[test]
fn test_extract_change_type_missing() {
let content = "# Change\n\nNo type here.\n";
assert_eq!(extract_change_type(content), None);
}
#[test]
fn test_validate_tasks_checkbox_in_excluded() {
let content = "## Future Work\n- [ ] Should not have checkbox\n";
let (errors, _) = validate_tasks_content(content, "test", false, "off", None, None);
assert!(errors.iter().any(|e| e.contains("excluded section")));
}
#[test]
fn test_validate_tasks_bare_task() {
let content = "## Implementation\n- Some task without checkbox\n";
let (errors, _) = validate_tasks_content(content, "test", false, "off", None, None);
assert!(errors
.iter()
.any(|e| e.contains("Possible task without checkbox")));
}
#[test]
fn test_validate_tasks_bare_task_utf8_safe_preview_boundary() {
let content =
"## Implementation\n- UTF8§12345678901234567890123456789012345678901234567890 task\n";
let (errors, _) = validate_tasks_content(content, "test", false, "off", None, None);
let warning = errors
.iter()
.find(|e| e.contains("Possible task without checkbox"))
.expect("bare-task warning should be present");
assert!(warning.contains("Possible task without checkbox"));
assert!(warning.contains("UTF8§"));
assert!(warning.contains("..."));
}
#[test]
fn test_validate_tasks_bare_task_long_preview_still_truncated() {
let content =
"## Implementation\n- abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789\n";
let (errors, _) = validate_tasks_content(content, "test", false, "off", None, None);
let warning = errors
.iter()
.find(|e| e.contains("Possible task without checkbox"))
.expect("bare-task warning should be present");
assert!(warning.contains("Possible task without checkbox"));
assert!(warning.contains("..."));
}
#[test]
fn test_validate_tasks_evidence_warn() {
let content = "- [ ] Add a new feature for users\n";
let (errors, warnings) =
validate_tasks_content(content, "test", true, "warn", Some("implementation"), None);
assert!(errors.is_empty());
assert!(!warnings.is_empty());
assert!(warnings[0].contains("Behavior-bearing task missing"));
}
#[test]
fn test_validate_tasks_evidence_error() {
let content = "- [ ] Add a new feature for users\n";
let (errors, _) =
validate_tasks_content(content, "test", true, "error", Some("implementation"), None);
assert!(!errors.is_empty());
assert!(errors[0].contains("Behavior-bearing task missing"));
}
#[test]
fn test_validate_tasks_excludes_runtime_acceptance_follow_ups_from_evidence_checks() {
let content = "## Implementation Tasks\n- [x] Implement state persistence (verification: unit - `src/state.rs` with `cargo test state`)\n\n## Acceptance #6 Failure Follow-up\n- [ ] Archive gate is blocked until implementation evidence is recorded\n";
let (errors, warnings) =
validate_tasks_content(content, "test", true, "error", Some("implementation"), None);
assert!(
errors.is_empty(),
"runtime follow-up must not fail archive validation: {errors:?}"
);
assert!(
warnings.is_empty(),
"runtime follow-up must not warn: {warnings:?}"
);
}
#[test]
fn test_validate_tasks_excludes_current_acceptance_follow_up_section() {
let content = "## Implementation Tasks\n- [x] Implement state persistence (verification: unit - `src/state.rs` with `cargo test state`)\n\n## Current Acceptance Follow-up\nattempt: 1\n- [x] [ARCHIVE_GATE_TASK_FORMAT] `cflx openspec validate test --archive-gate` fails because tasks.md has final validation checkbox issues\n evidence: converted evidence bullets to non-task notes\n- evidence: `src/parallel/builder.rs` follow-up detail bullet\n\n### External blockers\n- identity: `external||vendor approval|plain`\n evidence: external non-mockable prerequisite: vendor approval\n";
let (errors, warnings) =
validate_tasks_content(content, "test", true, "error", Some("implementation"), None);
assert!(
errors.is_empty(),
"runtime-owned Current Acceptance Follow-up must not fail archive validation: {errors:?}"
);
assert!(
warnings.is_empty(),
"runtime-owned Current Acceptance Follow-up must not warn: {warnings:?}"
);
}
#[test]
fn test_validate_tasks_with_verification_hint() {
let content =
"- [ ] Add a new feature (verification: unit - cargo test covers the feature)\n";
let (errors, warnings) =
validate_tasks_content(content, "test", true, "warn", Some("implementation"), None);
assert!(errors.is_empty());
assert!(warnings.is_empty());
}
#[test]
fn test_validate_tasks_accepts_generic_evidence_vocabulary_with_ownership() {
let content = "- [ ] Update source evidence wording (verification: unit - source paths document the changed implementation)\n- [ ] Update test evidence wording (verification: unit - test files cover the validator behavior)\n- [ ] Update command evidence wording (verification: manual - runnable command is provided by the task note)\n";
let (errors, warnings) =
validate_tasks_content(content, "test", true, "error", Some("implementation"), None);
assert!(
errors.is_empty(),
"generic repository evidence vocabulary should pass: {errors:?}"
);
assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
}
#[test]
fn test_validate_tasks_rejects_generic_evidence_vocabulary_without_ownership() {
let content = "- [ ] Update source evidence wording (verification: source paths document the changed implementation)\n- [ ] Update test evidence wording (verification: test files cover the validator behavior)\n- [ ] Update command evidence wording (verification: runnable command is provided by the task note)\n";
let (errors, warnings) =
validate_tasks_content(content, "test", true, "warn", Some("implementation"), None);
assert!(errors.is_empty());
assert!(
warnings
.iter()
.filter(|warning| warning.contains("Verification ownership missing"))
.count()
>= 3,
"ownership-free generic evidence notes should still warn: {warnings:?}"
);
assert!(
!warnings.iter().any(|warning| warning
.contains("Verification note should cite repository-verifiable evidence")),
"generic evidence vocabulary itself should be recognized: {warnings:?}"
);
}
#[test]
fn test_validate_tasks_with_standalone_verification_hint() {
let content =
"- [ ] Add a new feature\n verification: unit - cargo test covers the feature\n";
let (errors, warnings) =
validate_tasks_content(content, "test", true, "warn", Some("implementation"), None);
assert!(errors.is_empty());
assert!(warnings.is_empty());
}
#[test]
fn test_validate_tasks_accepts_inline_verification_before_completion_prose() {
let content = "- [ ] Update validator parsing (verification: manual - inspect src/openspec_cmd.rs and run cargo test openspec_cmd --lib) Completion condition: additional prose after the verification note remains ordinary task text.\n";
let (errors, warnings) =
validate_tasks_content(content, "test", true, "error", Some("implementation"), None);
assert!(
errors.is_empty(),
"inline verification before completion prose should pass: {errors:?}"
);
assert!(
warnings.is_empty(),
"inline verification before completion prose should not warn: {warnings:?}"
);
}
#[test]
fn test_extract_inline_verification_tolerates_parentheses_before_evidence() {
let content = "- [ ] Update verification note parsing (verification: manual - run (`cflx openspec validate fixture --strict`) after inspecting src/openspec_cmd.rs) Completion condition: parser keeps command evidence.\n";
let (errors, warnings) = validate_tasks_content(
content,
"alpha",
true,
"error",
Some("implementation"),
None,
);
assert!(
errors.is_empty(),
"parenthesized command should not truncate verification evidence: {errors:?}"
);
assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
}
#[test]
fn test_extract_inline_verification_tolerates_backticked_parentheses_before_evidence() {
let content = "- [ ] Update verification note parsing (verification: manual - reviewed command `printf \"done ) still command\"` then inspected src/openspec_cmd.rs and ran cflx openspec validate fixture --strict) Completion condition: evidence after backticked parenthesis is preserved.\n";
let (errors, warnings) = validate_tasks_content(
content,
"alpha",
true,
"error",
Some("implementation"),
None,
);
assert!(
errors.is_empty(),
"backticked inner parenthesis should not truncate verification evidence: {errors:?}"
);
assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
}
#[test]
fn test_standalone_verification_line_utf8_no_panic_and_findings() {
let content = "## Implementation\n- [ ] Add a new feature\n verification: 手動確認のみ\n";
let (errors, warnings) =
validate_tasks_content(content, "test", true, "warn", Some("implementation"), None);
assert!(errors.is_empty());
assert!(!warnings.is_empty());
assert!(warnings
.iter()
.any(|w| w.contains("Verification note should cite repository-verifiable evidence")));
assert!(warnings
.iter()
.any(|w| w.contains("Verification ownership missing")));
}
#[test]
fn test_cflx_proposal_skill_final_validation_uses_non_checkbox_section() {
let skill = include_str!("../skills/cflx-proposal/SKILL.md");
let final_validation_pos = skill
.find("## Final Validation")
.expect("cflx-proposal skill should document a Final Validation section");
let before_final_validation = &skill[..final_validation_pos];
assert!(skill.contains("cflx openspec validate <id> --archive-gate"));
assert!(skill.contains("Do not create final OpenSpec validation as a checkbox"));
assert!(
!before_final_validation.contains("- [ ] Final OpenSpec validation")
&& !before_final_validation.contains("- [ ] Record final OpenSpec validation"),
"final OpenSpec validation guidance must not be modeled as a checkbox task"
);
}
fn role_declaration(
id: &str,
phase: &str,
execution_class: &str,
completion_role: &str,
) -> crate::openspec::VerificationDeclaration {
crate::openspec::VerificationDeclaration {
id: Some(id.to_string()),
phase: Some(phase.to_string()),
execution_class: Some(execution_class.to_string()),
completion_role: Some(completion_role.to_string()),
..Default::default()
}
}
#[test]
fn test_release_gate_target_facts_report_only_non_local_change_blockers() {
let declarations = vec![
role_declaration(
"local-tests",
"pre-integration",
"repository-local",
"change-blocking",
),
role_declaration(
"deployed-smoke",
"post-integration",
"deployed-service",
"change-blocking",
),
role_declaration(
"release-smoke",
"post-integration",
"deployed-service",
"operational-observation",
),
];
let facts = validation::release_gate_target_facts(&declarations);
assert!(!facts.metadata_unreadable);
assert_eq!(
facts.non_local_blockers,
vec![(
"deployed-smoke".to_string(),
"execution class 'deployed-service'".to_string()
)]
);
}
#[test]
fn test_classify_release_gate_edge_rejects_local_dependent_on_non_local_blocker() {
let target = validation::release_gate_target_facts(&[role_declaration(
"device-acceptance",
"pre-integration",
"physical-device",
"change-blocking",
)]);
let (errors, warnings) =
validation::classify_release_gate_edge("follow-on", false, false, "gate", &target);
assert!(warnings.is_empty(), "{warnings:?}");
assert_eq!(errors.len(), 1);
assert!(errors[0].starts_with("follow-on: proposal dependency 'gate'"));
assert!(errors[0].contains("'device-acceptance'"));
assert!(errors[0].contains("Remedy: split 'gate'"));
}
#[test]
fn test_classify_release_gate_edge_spares_observational_dependents_and_in_flight_work() {
let target = validation::release_gate_target_facts(&[role_declaration(
"deployed-smoke",
"post-integration",
"deployed-service",
"change-blocking",
)]);
let (observational_errors, observational_warnings) =
validation::classify_release_gate_edge("stage-two", true, false, "stage-one", &target);
assert!(observational_errors.is_empty(), "{observational_errors:?}");
assert!(observational_warnings.is_empty());
let (in_flight_errors, in_flight_warnings) =
validation::classify_release_gate_edge("running", false, true, "gate", &target);
assert!(
in_flight_errors.is_empty(),
"in-flight work must not be aborted: {in_flight_errors:?}"
);
assert!(in_flight_warnings[0].contains("in-flight operation is not interrupted"));
}
#[test]
fn test_classify_release_gate_edge_defers_to_target_for_unreadable_metadata() {
let target = validation::ReleaseGateTarget {
metadata_unreadable: true,
non_local_blockers: Vec::new(),
};
let (errors, warnings) =
validation::classify_release_gate_edge("dependent", false, false, "target", &target);
assert!(
errors.is_empty(),
"target owns the parse failure, not the dependent: {errors:?}"
);
assert!(warnings[0].contains("reported on 'target'"));
}
#[test]
fn test_is_observational_release_change_requires_declared_roles_without_blockers() {
assert!(validation::is_observational_release_change(&[
role_declaration(
"release-smoke",
"post-integration",
"deployed-service",
"operational-observation"
),
]));
assert!(!validation::is_observational_release_change(&[
role_declaration(
"local-tests",
"pre-integration",
"repository-local",
"change-blocking"
),
role_declaration(
"release-smoke",
"post-integration",
"deployed-service",
"operational-observation"
),
]));
// Legacy declarations never make a change observational by omission.
assert!(!validation::is_observational_release_change(&[
crate::openspec::VerificationDeclaration {
id: Some("legacy".to_string()),
phase: Some("post-integration".to_string()),
..Default::default()
},
]));
assert!(!validation::is_observational_release_change(&[]));
}
#[test]
fn test_reverse_impact_warnings_separate_queued_from_in_flight_dependents() {
let warnings = validation::reverse_impact_warnings(
"gate",
&["deployed-smoke".to_string()],
&["queued-one".to_string(), "queued-two".to_string()],
&["running-one".to_string()],
);
assert_eq!(warnings.len(), 2);
assert!(warnings[0].contains("affect queued dependents: queued-one, queued-two"));
assert!(warnings[0].contains("next dispatch eligibility decision"));
assert!(warnings[1].contains("in-flight dependents are not interrupted"));
assert!(
validation::reverse_impact_warnings("gate", &[], &["queued-one".to_string()], &[])
.is_empty()
);
}
/// Bundled proposal guidance is the authoring half of the release-gate
/// contract; validation alone cannot teach authors the split pattern.
#[test]
fn test_cflx_proposal_skill_documents_release_gate_dependency_rules() {
let skill = include_str!("../skills/cflx-proposal/SKILL.md");
for required in [
// Dependency eligibility is defined by consumed repository output.
"### 3. Decide Dependency Eligibility",
"required to implement the dependent change or to run its `pre-integration` verification",
"Release sequence alone is never an acceptable justification",
// Forbidden release-sequencing uses.
"Roadmap or backlog ordering",
"MVP, milestone, or release phase boundaries",
"Deployed-service checks or environment smoke tests",
"Physical-device acceptance",
"Human or external approval",
"Credentialed access to an external system",
// Reverse-impact review.
"Review reverse-dependency impact before adding an edge",
"list the direct and transitive downstream changes that would become blocked",
// Implementation / release-observation split.
"Split repository-local implementation from release observation",
"The release-observation change may depend on the implementation change",
"must depend only on the repository outputs they consume",
// Structured verification fields rather than a parallel gate block.
"Do not invent a parallel completion-gate block",
"`completion_role: change-blocking` requires `phase: pre-integration` **and** `execution_class: repository-local`",
"MUST be `completion_role: operational-observation`",
"Legacy declarations without the two fields stay valid during migration",
"verification-id: <id>",
] {
assert!(
skill.contains(required),
"cflx-proposal skill must document: {required}"
);
}
}
/// Proposal frontmatter that has migrated to the structured role model.
const MIGRATED_PROPOSAL: &str = concat!(
"---\nverifications:\n",
" - id: local-tests\n phase: pre-integration\n execution_class: repository-local\n completion_role: change-blocking\n prerequisites: []\n",
" - id: deployed-smoke\n phase: post-integration\n execution_class: deployed-service\n completion_role: operational-observation\n prerequisites: []\n",
"---\n# Migrated\n\n**Change Type**: implementation\n",
);
const LEGACY_PROPOSAL: &str = concat!(
"---\nverifications:\n",
" - id: local-tests\n phase: pre-integration\n prerequisites: []\n",
"---\n# Legacy\n\n**Change Type**: implementation\n",
);
#[test]
fn test_active_checkbox_requires_change_blocking_verification_reference() {
let content = "## Implementation Tasks\n- [ ] Wire the validator (verification: unit - cargo test openspec_cmd --lib)\n";
let (errors, _) = validate_tasks_content(
content,
"test",
true,
"error",
Some("implementation"),
Some(MIGRATED_PROPOSAL),
);
assert!(
errors.iter().any(|error| error.contains(
"must reference a change-blocking verification via 'verification-id: <id>'"
)),
"{errors:?}"
);
}
#[test]
fn test_active_checkbox_accepts_declared_change_blocking_reference() {
let content = "## Implementation Tasks\n- [ ] Wire the validator (verification: unit - cargo test openspec_cmd --lib; verification-id: local-tests)\n";
let (errors, _) = validate_tasks_content(
content,
"test",
true,
"error",
Some("implementation"),
Some(MIGRATED_PROPOSAL),
);
assert!(errors.is_empty(), "{errors:?}");
}
#[test]
fn test_active_checkbox_rejects_operational_observation_reference() {
let content = "## Implementation Tasks\n- [ ] Confirm the device boots (verification: manual - operator checklist in docs/release.md; verification-id: deployed-smoke)\n";
let (errors, _) = validate_tasks_content(
content,
"test",
true,
"error",
Some("implementation"),
Some(MIGRATED_PROPOSAL),
);
assert!(
errors.iter().any(|error| error.contains(
"verification-id 'deployed-smoke' is not a change-blocking verification"
) && error.contains("release-observation change")),
"{errors:?}"
);
}
#[test]
fn test_active_checkbox_rejects_unknown_verification_reference() {
let content = "## Implementation Tasks\n- [ ] Wire the validator (verification: unit - cargo test openspec_cmd --lib; verification-id: does-not-exist)\n";
let (errors, _) = validate_tasks_content(
content,
"test",
true,
"error",
Some("implementation"),
Some(MIGRATED_PROPOSAL),
);
assert!(
errors.iter().any(|error| error.contains(
"verification-id 'does-not-exist' is not declared in proposal.md verifications"
)),
"{errors:?}"
);
}
#[test]
fn test_verification_reference_accepted_from_continuation_line() {
let content = concat!(
"## Implementation Tasks\n",
"- [ ] Wire the validator\n",
" verification: unit - cargo test openspec_cmd --lib; verification-id: local-tests\n",
);
let (errors, _) = validate_tasks_content(
content,
"test",
true,
"error",
Some("implementation"),
Some(MIGRATED_PROPOSAL),
);
assert!(errors.is_empty(), "{errors:?}");
}
#[test]
fn test_verification_linkage_skips_narrative_and_future_work_sections() {
let content = concat!(
"## Implementation Tasks\n",
"- [ ] Wire the validator (verification: unit - cargo test openspec_cmd --lib; verification-id: local-tests)\n",
"\n## Future Work\n",
"- Physical device acceptance after release\n",
"\n## Notes\n",
"- Release observation is tracked separately\n",
);
let (errors, _) = validate_tasks_content(
content,
"test",
true,
"error",
Some("implementation"),
Some(MIGRATED_PROPOSAL),
);
assert!(errors.is_empty(), "{errors:?}");
}
#[test]
fn test_verification_linkage_stays_inactive_for_legacy_proposals() {
let content = "## Implementation Tasks\n- [ ] Wire the validator (verification: unit - cargo test openspec_cmd --lib)\n";
let (errors, _) = validate_tasks_content(
content,
"test",
true,
"error",
Some("implementation"),
Some(LEGACY_PROPOSAL),
);
assert!(errors.is_empty(), "{errors:?}");
}
#[test]
fn test_verification_linkage_stays_inactive_for_spec_only_changes() {
let content = "## Specification Tasks\n- [ ] Promote the delta to canonical spec\n";
let (errors, _) = validate_tasks_content(
content,
"test",
true,
"error",
Some("spec-only"),
Some(MIGRATED_PROPOSAL),
);
assert!(errors.is_empty(), "{errors:?}");
}
// -----------------------------------------------------------------
// Change-blocking heavyweight-command migration warnings
// -----------------------------------------------------------------
/// Validate a `tasks.md` body against the migrated role-model proposal.
fn migrated_task_errors(content: &str) -> Vec<String> {
let (errors, _) = validate_tasks_content(
content,
"test",
true,
"error",
Some("implementation"),
Some(MIGRATED_PROPOSAL),
);
errors
}
/// One active checkbox whose verification note carries `note`.
fn single_task(note: &str) -> String {
format!("## Implementation Tasks\n- [ ] Prove the behavior (verification: {note})\n")
}
/// Build a declaration whose command forms are under test.
fn command_declaration(
completion_role: &str,
evidence: &str,
rerun: &str,
) -> crate::openspec::VerificationDeclaration {
crate::openspec::VerificationDeclaration {
id: Some("proposal-gate".to_string()),
phase: Some("pre-integration".to_string()),
owner: Some("conflux-acceptance".to_string()),
evidence: Some(evidence.to_string()),
rerun: Some(rerun.to_string()),
execution_class: Some("repository-local".to_string()),
completion_role: Some(completion_role.to_string()),
..Default::default()
}
}
/// Warnings produced by the heavyweight-command policy for one declaration.
fn heavy_warnings(evidence: &str, rerun: &str) -> Vec<String> {
crate::openspec_cmd::validation::heavy_declaration_warnings(
"test",
&command_declaration("change-blocking", evidence, rerun),
)
}
/// Task prose is not a command-authority source. Neither a heavyweight
/// word, a task-local command that differs from frontmatter, nor a shared
/// verification ID with differing markers may produce a finding.
#[test]
fn test_task_prose_is_not_command_authority() {
let content = concat!(
"## Implementation Tasks\n",
"- [ ] Replace the full heavy docker benchmark suite with a bounded check (verification: unit - `cargo test openspec_cmd --lib`; verification-id: local-tests)\n",
"- [ ] Confirm the CLI wiring (verification: integration - `cargo test task_parser --lib`; verification-id: local-tests)\n",
"\n## Future Work\n",
"- The exhaustive qemu cross-architecture sweep stays owned by CI\n",
"\n## Notes\n",
"- `docker compose up -d` and `cargo bench` run in repository automation\n",
);
let errors = migrated_task_errors(content);
assert!(
!errors
.iter()
.any(|error| error.contains("heavyweight command form")
|| error.contains("is shared by task lines")
|| error.contains("ownership marker must be exactly one of")),
"{errors:?}"
);
}
/// A task-note command that native validation once denied outright is now
/// inert: only frontmatter `evidence` and `rerun` carry command authority.
#[test]
fn test_task_note_heavyweight_command_produces_no_finding() {
for command in [
"`docker compose up -d && ./suite.sh`",
"`podman run ci-image make test`",
"`cargo bench`",
"`cargo test --workspace --all-features`",
"`for i in $(seq 3); do cargo test; done`",
] {
let content = single_task(&format!("unit - {command}; verification-id: local-tests"));
let errors = migrated_task_errors(&content);
assert!(
!errors
.iter()
.any(|error| error.contains("heavyweight command form")),
"{command} must produce no task-prose heaviness finding: {errors:?}"
);
}
}
#[test]
fn test_bounded_repository_local_commands_stay_valid() {
for command in [
"`cargo test openspec_cmd --lib`",
"`cargo test embedded_skills --lib`",
"`go test -count=1 ./internal/spec`",
"`npm test -- --run src/validation.test.ts`",
"`uv run pytest tests/test_validation.py`",
] {
let content = single_task(&format!("unit - {command}; verification-id: local-tests"));
assert!(
migrated_task_errors(&content).is_empty(),
"{command} must remain a valid bounded gate"
);
}
}
/// Every explicitly declared heavyweight form warns, naming the
/// verification ID and the matched token rather than a prose inference.
#[test]
fn test_declared_heavyweight_forms_warn_with_matched_token() {
for (command, category, matched) in [
(
"docker compose up --wait",
"container orchestration",
"docker compose",
),
("docker run image", "container orchestration", "docker run"),
(
"docker-compose up -d",
"container orchestration",
"docker-compose",
),
(
"docker swarm init",
"container orchestration",
"docker swarm",
),
(
"podman run ci-image make test",
"container orchestration",
"podman",
),
(
"kubectl apply -f deploy.yaml",
"container orchestration",
"kubectl",
),
(
"qemu-system-aarch64 -kernel target/test.img",
"architecture emulation",
"qemu-system-aarch64",
),
(
"cross test --target aarch64",
"architecture emulation",
"cross",
),
("cargo bench", "benchmark", "cargo bench"),
("cargo test --workspace", "broad selector", "--workspace"),
(
"cargo test --all-features",
"broad selector",
"--all-features",
),
("cargo test -- --ignored", "broad selector", "--ignored"),
(
"cargo test -- --include-ignored",
"broad selector",
"--include-ignored",
),
(
"pytest --exhaustive tests/",
"broad selector",
"--exhaustive",
),
(
"cargo test --features heavy openspec_cmd",
"broad selector",
"--features heavy",
),
(
"cargo test --features=heavy openspec_cmd",
"broad selector",
"--features=heavy",
),
(
"for i in $(seq 3); do cargo test; done",
"structural repetition",
"seq",
),
(
"ls tests | xargs -n1 cargo test --test",
"structural repetition",
"xargs",
),
] {
let warnings = heavy_warnings(command, "cargo test openspec_cmd --lib");
assert!(
warnings.iter().any(|warning| {
warning.contains("verification 'proposal-gate'")
&& warning.contains("change-blocking evidence")
&& warning.contains(&format!("({category}: '{matched}')"))
&& warning.contains("migration warning")
}),
"{command} must warn as {category} matched on '{matched}': {warnings:?}"
);
}
}
/// `rerun` carries the same authority as `evidence`.
#[test]
fn test_heavyweight_rerun_warns_independently_of_evidence() {
let warnings = heavy_warnings("cargo test openspec_cmd --lib", "cargo test --workspace");
assert!(
warnings
.iter()
.any(|warning| warning.contains("change-blocking rerun")
&& warning.contains("(broad selector: '--workspace')")),
"{warnings:?}"
);
}
/// Bounded `docker build` is explicitly permitted change-blocking evidence.
#[test]
fn test_bounded_docker_build_produces_no_warning() {
for command in [
"docker build .",
"docker build -t cflx-ci -f Dockerfile .",
"docker buildx build --load .",
] {
assert!(
heavy_warnings(command, command).is_empty(),
"{command} must remain valid bounded evidence"
);
}
}
/// Heaviness words inside longer tokens are not matches: exact-token
/// comparison replaced the substring inference that produced these.
#[test]
fn test_literal_substrings_do_not_warn() {
for command in [
"cargo test full_pipeline_smoke --lib",
"cargo test benchmark_parser_units --lib",
"cargo test heavy_gate_policy --lib",
"cargo test exhaustive_token_matching --lib",
"cargo test crossbeam_channel_shutdown --lib",
"cargo test sequence_number_rollover --lib",
"go test -count=1 ./internal/spec",
"npm test -- --run src/validation.test.ts",
] {
assert!(
heavy_warnings(command, command).is_empty(),
"{command} must not match a heavyweight form"
);
}
}
/// Broad execution stays legal once it is owned as an observation, and a
/// legacy declaration without role metadata is not change-blocking at all.
#[test]
fn test_heavyweight_policy_is_inert_outside_change_blocking_declarations() {
let observation = command_declaration(
"operational-observation",
"docker compose run tests",
"docker compose run tests",
);
assert!(
crate::openspec_cmd::validation::heavy_declaration_warnings("test", &observation)
.is_empty()
);
let legacy = crate::openspec::VerificationDeclaration {
id: Some("legacy-gate".to_string()),
phase: Some("pre-integration".to_string()),
owner: Some("conflux-acceptance".to_string()),
evidence: Some("docker compose run tests".to_string()),
rerun: Some("cargo bench".to_string()),
..Default::default()
};
assert!(
crate::openspec_cmd::validation::heavy_declaration_warnings("test", &legacy).is_empty(),
"legacy declarations without role metadata stay inert"
);
}
#[test]
fn test_normalize_command_folds_backticks_whitespace_and_case() {
use crate::openspec_cmd::validation::normalize_command;
assert_eq!(
normalize_command(" `cargo TEST openspec_cmd --lib` "),
"cargo test openspec_cmd --lib"
);
}
/// Shell punctuation never hides a token from whole-token comparison.
#[test]
fn test_command_tokens_strip_shell_punctuation() {
use crate::openspec_cmd::validation::command_tokens;
assert_eq!(
command_tokens("for i in $(seq 3); do cargo test; done"),
vec!["for", "i", "in", "seq", "3", "do", "cargo", "test", "done"]
);
}
#[test]
fn test_validate_tasks_with_weak_verification() {
let content = "- [ ] Add a new feature (verification: manual review)\n";
let (errors, warnings) =
validate_tasks_content(content, "test", true, "warn", Some("implementation"), None);
assert!(errors.is_empty());
assert!(
warnings
.iter()
.any(|w| w.contains("Verification note should cite repository-verifiable evidence")),
"narrative-only manual review should still produce an evidence finding: {warnings:?}"
);
}
#[test]
fn test_rejects_self_referential_final_validation_checkbox() {
let content = "- [ ] Record final OpenSpec validation before archive (verification: manual - run `cflx openspec validate alpha --strict --evidence warn`)\n";
let (errors, warnings) = validate_tasks_content(
content,
"alpha",
true,
"error",
Some("implementation"),
None,
);
assert!(warnings.is_empty());
assert_eq!(errors.len(), 1);
assert!(errors[0].contains("self-referential final OpenSpec validation checkbox"));
assert!(errors[0].contains("non-checkbox `## Final Validation` section"));
}
#[test]
fn test_allows_non_checkbox_final_validation_section() {
let content = "## Implementation Tasks\n- [ ] Implement feature (verification: unit - cargo test openspec_cmd --lib)\n\n## Final Validation\n\nExpected archive gate: `cflx openspec validate alpha --strict --evidence warn` exits 0.\n";
let (errors, warnings) = validate_tasks_content(
content,
"alpha",
true,
"error",
Some("implementation"),
None,
);
assert!(errors.is_empty(), "unexpected errors: {errors:?}");
assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
}
#[test]
fn test_ignores_runtime_acceptance_follow_up_during_archive_validation() {
let content = "## Implementation Tasks\n- [x] Implement feature (verification: unit - cargo test openspec_cmd --lib)\n\n## Acceptance #2 Failure Follow-up\n- [x] Archive commit path remains blocked: run cflx openspec validate alpha --archive-gate\n- [x] Restore semantic fingerprint after restart\n";
let (errors, warnings) = validate_tasks_content(
content,
"alpha",
true,
"error",
Some("implementation"),
None,
);
assert!(errors.is_empty(), "unexpected errors: {errors:?}");
assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
}
#[test]
fn test_preserves_ordinary_repository_evidence() {
let content = "- [ ] Rust verification (verification: unit - cargo test openspec_cmd --lib)\n- [ ] Frontend verification (verification: integration - npm run test)\n- [ ] Go verification (verification: integration - go test ./...)\n- [ ] Path verification (verification: unit - src/openspec_cmd.rs and tests/fixtures cover this)\n";
let (errors, warnings) = validate_tasks_content(
content,
"alpha",
true,
"error",
Some("implementation"),
None,
);
assert!(
errors.is_empty(),
"ordinary evidence should pass: {errors:?}"
);
assert!(
warnings.is_empty(),
"ordinary evidence should not warn: {warnings:?}"
);
}
#[test]
fn test_validate_tasks_accepts_common_repository_artifacts_and_build_commands() {
let content = "- [ ] Add Docker packaging evidence (verification: manual - Dockerfile documents the runtime image)\n- [ ] Add TOML configuration evidence (verification: unit - Cargo.toml and .toml configuration fixtures cover the change)\n- [ ] Add container build evidence (verification: integration - docker build validates the repository build artifact)\n";
let (errors, warnings) =
validate_tasks_content(content, "test", true, "error", Some("implementation"), None);
assert!(
errors.is_empty(),
"common repository artifacts and commands should pass: {errors:?}"
);
assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
}
#[test]
fn test_accepts_observed_archive_gate_manual_note_shape() {
let content = "- [x] Task 9: Complete archive gate verification for workspace persistence. (verification: manual - implemented in src/workspace/persistence.rs and tests/workspace_persistence_tests.rs; ran `cflx openspec validate add-s3-workspace-persistence --strict`) Completion condition: archive readiness evidence is repository-verifiable.\n";
let (errors, warnings) = validate_tasks_content(
content,
"current-change",
true,
"error",
Some("implementation"),
None,
);
assert!(
!errors.iter().any(|e| e.contains("Verification note should cite repository-verifiable evidence")),
"observed manual note should retain repository evidence: {errors:?}"
);
assert!(
!errors
.iter()
.any(|e| e.contains("Verification ownership missing")),
"observed manual note should retain ownership marker: {errors:?}"
);
assert!(errors.is_empty(), "unexpected errors: {errors:?}");
assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
}
#[test]
fn test_warns_missing_verification_ownership() {
let content = "- [ ] Implement handler update (verification: cargo test -- --nocapture)\n";
let (errors, warnings) =
validate_tasks_content(content, "test", true, "warn", Some("implementation"), None);
assert!(errors.is_empty());
assert!(warnings
.iter()
.any(|w| w.contains("Verification ownership missing")));
}
}
#[cfg(test)]
mod openspec_list_show_tests {
use super::*;
use crate::openspec_cmd::model::{DependencyListStatus, DependencyStatusInfo};
use chrono::Local;
use std::collections::HashMap;
use std::env;
use std::sync::MutexGuard;
use tempfile::TempDir;
struct CwdTestGuard {
_lock: MutexGuard<'static, ()>,
original_cwd: PathBuf,
}
impl CwdTestGuard {
fn enter(path: &Path) -> Self {
let lock = cwd_lock()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let original_cwd = env::current_dir().unwrap();
env::set_current_dir(path).unwrap();
Self {
_lock: lock,
original_cwd,
}
}
}
impl Drop for CwdTestGuard {
fn drop(&mut self) {
env::set_current_dir(&self.original_cwd).unwrap();
}
}
fn cwd_lock() -> &'static std::sync::Mutex<()> {
crate::test_support::cwd_lock()
}
fn create_change(dir: &Path, proposal_title: &str, tasks: &str) {
fs::create_dir_all(dir).unwrap();
fs::write(
dir.join("proposal.md"),
format!("# {}\n\nbody\n", proposal_title),
)
.unwrap();
fs::write(dir.join("tasks.md"), tasks).unwrap();
}
fn create_change_with_frontmatter_dependencies(
dir: &Path,
proposal_title: &str,
dependencies: &[&str],
tasks: &str,
) {
fs::create_dir_all(dir).unwrap();
let deps_yaml = if dependencies.is_empty() {
"[]".to_string()
} else {
let mut lines = String::new();
for dep in dependencies {
lines.push_str(&format!("\n - {}", dep));
}
lines
};
let proposal = if dependencies.is_empty() {
format!(
"---\ndependencies: []\n---\n\n# {}\n\nbody\n",
proposal_title
)
} else {
format!(
"---\ndependencies:{}\n---\n\n# {}\n\nbody\n",
deps_yaml, proposal_title
)
};
fs::write(dir.join("proposal.md"), proposal).unwrap();
fs::write(dir.join("tasks.md"), tasks).unwrap();
}
fn create_change_with_body_dependencies(
dir: &Path,
proposal_title: &str,
dependencies: &[&str],
tasks: &str,
) {
fs::create_dir_all(dir).unwrap();
let mut proposal = format!("# {}\n\nbody\n\n## Dependencies\n", proposal_title);
for dependency in dependencies {
proposal.push_str(&format!("- {}\n", dependency));
}
fs::write(dir.join("proposal.md"), proposal).unwrap();
fs::write(dir.join("tasks.md"), tasks).unwrap();
}
fn track_file(root: &Path, path: &str) {
std::process::Command::new("git")
.args(["init", "--quiet"])
.current_dir(root)
.status()
.unwrap();
std::process::Command::new("git")
.args(["add", "--", path])
.current_dir(root)
.status()
.unwrap();
}
fn create_strict_valid_change(dir: &Path, proposal_title: &str) {
fs::create_dir_all(dir).unwrap();
fs::write(
dir.join("proposal.md"),
format!(
"---\nverifications:\n - id: local\n requirement: archive behavior\n phase: pre-integration\n owner: conflux-acceptance\n trigger: pull request\n automation: Cargo.toml\n evidence: cargo test\n rerun: cargo test\n prerequisites: []\n---\n# {}\n\n**Change Type**: implementation\n\n## Problem\narchive behavior update\n",
proposal_title
),
)
.unwrap();
fs::write("Cargo.toml", "[package]\nname = \"fixture\"\n").unwrap();
track_file(&env::current_dir().unwrap(), "Cargo.toml");
fs::write(
dir.join("tasks.md"),
"- [ ] 1. archive destination update (verification: unit - cargo test src::openspec_cmd::openspec_list_show_tests -- --nocapture)\n",
)
.unwrap();
let spec_dir = dir.join("specs/archive");
fs::create_dir_all(&spec_dir).unwrap();
fs::write(
spec_dir.join("spec.md"),
"## ADDED Requirements\n\n### Requirement: Archive naming\n\n#### Scenario: Dated destination\n- WHEN archive runs\n- THEN destination uses dated prefix\n",
)
.unwrap();
}
fn create_strict_change_with_spec_delta(dir: &Path, spec_name: &str, delta: &str) {
fs::create_dir_all(dir).unwrap();
fs::write(
dir.join("proposal.md"),
"---\nverifications:\n - id: local\n requirement: validator behavior\n phase: pre-integration\n owner: conflux-acceptance\n trigger: pull request\n automation: Cargo.toml\n evidence: cargo test\n rerun: cargo test\n prerequisites: []\n---\n# Strict validation fixture\n\n**Change Type**: implementation\n\n## Problem\nvalidator fixture\n",
)
.unwrap();
fs::write("Cargo.toml", "[package]\nname = \"fixture\"\n").unwrap();
track_file(&env::current_dir().unwrap(), "Cargo.toml");
fs::write(
dir.join("tasks.md"),
"- [ ] 1. validator update (verification: unit - cargo test openspec_cmd --lib)\n",
)
.unwrap();
let spec_dir = dir.join("specs").join(spec_name);
fs::create_dir_all(&spec_dir).unwrap();
fs::write(spec_dir.join("spec.md"), delta).unwrap();
}
fn create_canonical_spec(root: &Path, spec_name: &str, content: &str) {
let spec_dir = root.join("openspec/specs").join(spec_name);
fs::create_dir_all(&spec_dir).unwrap();
fs::write(spec_dir.join("spec.md"), content).unwrap();
}
#[test]
fn test_list_changes_excludes_archived_entries() {
let temp = TempDir::new().unwrap();
let _guard = CwdTestGuard::enter(temp.path());
let active_dir = temp.path().join("openspec/changes/active-change");
let archived_dir = temp
.path()
.join("openspec/changes/archive/2026-04-27-archived-change");
create_change(&active_dir, "Active Change", "- [x] done\n- [ ] pending\n");
create_change(&archived_dir, "Archived Change", "- [x] archived\n");
let mgr = OpenSpecManager::new();
let changes = mgr.list_changes();
assert_eq!(changes.len(), 1);
assert_eq!(changes[0].id, "active-change");
assert!(changes[0].path.contains("openspec/changes/active-change"));
}
#[test]
fn test_list_change_records_include_frontmatter_dependencies() {
let temp = TempDir::new().unwrap();
let _guard = CwdTestGuard::enter(temp.path());
let dependent_dir = temp.path().join("openspec/changes/dependent-change");
let dependency_dir = temp.path().join("openspec/changes/base-change");
create_change_with_frontmatter_dependencies(
&dependent_dir,
"Dependent Change",
&["base-change"],
"- [ ] pending\n",
);
create_change(&dependency_dir, "Base Change", "- [ ] pending\n");
let mgr = OpenSpecManager::new();
let changes = mgr.list_changes();
let dependent = changes
.iter()
.find(|change| change.id == "dependent-change")
.expect("dependent change should be listed");
assert_eq!(dependent.dependencies, vec!["base-change".to_string()]);
assert_eq!(
dependent.dependency_statuses,
vec![DependencyStatusInfo {
id: "base-change".to_string(),
status: DependencyListStatus::Pending,
}]
);
}
#[test]
fn test_list_change_dependency_statuses_cover_workspace_states() {
let temp = TempDir::new().unwrap();
let _guard = CwdTestGuard::enter(temp.path());
let dependent_dir = temp.path().join("openspec/changes/dependent-change");
create_change_with_frontmatter_dependencies(
&dependent_dir,
"Dependent Change",
&[
"pending-dep",
"running-dep",
"done-dep",
"rejected-dep",
"missing-dep",
],
"- [ ] pending\n",
);
create_change(
&temp.path().join("openspec/changes/pending-dep"),
"Pending Dep",
"- [ ] pending\n",
);
create_change(
&temp.path().join("openspec/changes/running-dep"),
"Running Dep",
"- [ ] running\n",
);
fs::write(temp.path().join(".conflux-inflight"), "running-dep\n").unwrap();
create_change(
&temp
.path()
.join("openspec/changes/archive/2026-05-08-done-dep"),
"Done Dep",
"- [x] done\n",
);
create_change(
&temp.path().join("openspec/changes/rejected-dep"),
"Rejected Dep",
"- [ ] rejected\n",
);
fs::write(
temp.path()
.join("openspec/changes/rejected-dep/REJECTED.md"),
"# REJECTED\n",
)
.unwrap();
let mgr = OpenSpecManager::new();
let changes = mgr.list_changes();
let dependent = changes
.iter()
.find(|change| change.id == "dependent-change")
.expect("dependent change should be listed");
assert_eq!(
dependent.dependency_statuses,
vec![
DependencyStatusInfo {
id: "pending-dep".to_string(),
status: DependencyListStatus::Pending,
},
DependencyStatusInfo {
id: "running-dep".to_string(),
status: DependencyListStatus::Running,
},
DependencyStatusInfo {
id: "done-dep".to_string(),
status: DependencyListStatus::Done,
},
DependencyStatusInfo {
id: "rejected-dep".to_string(),
status: DependencyListStatus::Rejected,
},
DependencyStatusInfo {
id: "missing-dep".to_string(),
status: DependencyListStatus::Missing,
},
]
);
}
#[test]
fn test_render_changes_output_shows_dependencies_only_when_present() {
let changes = vec![
ChangeInfo {
id: "dependent-change".to_string(),
path: "openspec/changes/dependent-change".to_string(),
title: Some("Dependent Change".to_string()),
tasks_completed: 0,
tasks_total: 1,
dependencies: vec!["done-dep".to_string(), "running-dep".to_string()],
dependency_statuses: vec![
DependencyStatusInfo {
id: "done-dep".to_string(),
status: DependencyListStatus::Done,
},
DependencyStatusInfo {
id: "running-dep".to_string(),
status: DependencyListStatus::Running,
},
],
},
ChangeInfo {
id: "independent-change".to_string(),
path: "openspec/changes/independent-change".to_string(),
title: Some("Independent Change".to_string()),
tasks_completed: 0,
tasks_total: 1,
dependencies: Vec::new(),
dependency_statuses: Vec::new(),
},
];
let rendered = render_changes_output(&changes);
assert!(rendered.contains(" Dependencies: done-dep [done], running-dep [running]\n"));
let independent_block = rendered
.split("\x1b[1mindependent-change\x1b[0m")
.nth(1)
.expect("independent change block should render");
assert!(!independent_block.contains("Dependencies:"));
}
#[test]
fn test_body_dependencies_fallback_appears_in_list_output() {
let temp = TempDir::new().unwrap();
let _guard = CwdTestGuard::enter(temp.path());
let dependent_dir = temp.path().join("openspec/changes/body-dependent");
let dependency_dir = temp.path().join("openspec/changes/body-dep");
create_change_with_body_dependencies(
&dependent_dir,
"Body Dependent",
&["body-dep"],
"- [ ] pending\n",
);
create_change(&dependency_dir, "Body Dep", "- [ ] pending\n");
let mgr = OpenSpecManager::new();
let changes = mgr.list_changes();
let rendered = render_changes_output(&changes);
let dependent = changes
.iter()
.find(|change| change.id == "body-dependent")
.expect("body-dependent change should be listed");
assert_eq!(dependent.dependencies, vec!["body-dep".to_string()]);
assert!(rendered.contains(" Dependencies: body-dep [pending]\n"));
}
#[test]
fn test_list_specs_includes_requirement_counts() {
let temp = TempDir::new().unwrap();
let _guard = CwdTestGuard::enter(temp.path());
let foo_dir = temp.path().join("openspec/specs/foo-spec");
fs::create_dir_all(&foo_dir).unwrap();
fs::write(
foo_dir.join("spec.md"),
"# Foo\n\n### Requirement: One\n\nBody\n\n### Requirement: Two\n\nBody\n",
)
.unwrap();
let empty_dir = temp.path().join("openspec/specs/empty-spec");
fs::create_dir_all(&empty_dir).unwrap();
fs::write(
empty_dir.join("spec.md"),
"# Empty\n\nNo requirements here.\n",
)
.unwrap();
let mgr = OpenSpecManager::new();
let specs = mgr.list_specs();
let foo = specs.iter().find(|s| s.name == "foo-spec").unwrap();
assert_eq!(foo.requirement_count, 2);
let empty = specs.iter().find(|s| s.name == "empty-spec").unwrap();
assert_eq!(empty.requirement_count, 0);
let rendered = render_specs_output(&specs);
assert!(rendered.contains(" \x1b[96mempty-spec\x1b[0m"));
assert!(rendered.contains(" Path: openspec/specs/empty-spec/spec.md"));
assert!(rendered.contains(" Requirements: 0"));
assert!(rendered.contains(" \x1b[96mfoo-spec\x1b[0m"));
assert!(rendered.contains(" Path: openspec/specs/foo-spec/spec.md"));
assert!(rendered.contains(" Requirements: 2"));
assert!(!rendered.contains("Dependencies:"));
}
#[test]
fn test_show_change_dependency_statuses_cover_workspace_states() {
let temp = TempDir::new().unwrap();
let _guard = CwdTestGuard::enter(temp.path());
let dependent_dir = temp.path().join("openspec/changes/dependent-change");
create_change_with_frontmatter_dependencies(
&dependent_dir,
"Dependent Change",
&[
"pending-dep",
"running-dep",
"done-dep",
"rejected-dep",
"missing-dep",
],
"- [ ] pending\n",
);
create_change(
&temp.path().join("openspec/changes/pending-dep"),
"Pending Dep",
"- [ ] pending\n",
);
create_change(
&temp.path().join("openspec/changes/running-dep"),
"Running Dep",
"- [ ] running\n",
);
fs::write(temp.path().join(".conflux-inflight"), "running-dep\n").unwrap();
create_change(
&temp
.path()
.join("openspec/changes/archive/2026-05-08-done-dep"),
"Done Dep",
"- [x] done\n",
);
create_change(
&temp.path().join("openspec/changes/rejected-dep"),
"Rejected Dep",
"- [ ] rejected\n",
);
fs::write(
temp.path()
.join("openspec/changes/rejected-dep/REJECTED.md"),
"# REJECTED\n",
)
.unwrap();
let mgr = OpenSpecManager::new();
let info = mgr
.show_change("dependent-change", false)
.expect("show should not error")
.expect("dependent change should resolve via show");
assert_eq!(
info.dependencies,
vec![
"pending-dep".to_string(),
"running-dep".to_string(),
"done-dep".to_string(),
"rejected-dep".to_string(),
"missing-dep".to_string(),
]
);
assert_eq!(
info.dependency_statuses,
vec![
DependencyStatusInfo {
id: "pending-dep".to_string(),
status: DependencyListStatus::Pending,
},
DependencyStatusInfo {
id: "running-dep".to_string(),
status: DependencyListStatus::Running,
},
DependencyStatusInfo {
id: "done-dep".to_string(),
status: DependencyListStatus::Done,
},
DependencyStatusInfo {
id: "rejected-dep".to_string(),
status: DependencyListStatus::Rejected,
},
DependencyStatusInfo {
id: "missing-dep".to_string(),
status: DependencyListStatus::Missing,
},
]
);
}
#[test]
fn test_render_show_output_shows_dependencies_only_when_present() {
let dependent = ShowInfo {
id: "dependent-change".to_string(),
path: "openspec/changes/dependent-change".to_string(),
archived: false,
proposal: Some("# Dependent Change\n".to_string()),
tasks: Some("- [ ] pending\n".to_string()),
tasks_completed: 0,
tasks_total: 1,
dependencies: vec!["feature-a".to_string()],
dependency_statuses: vec![DependencyStatusInfo {
id: "feature-a".to_string(),
status: DependencyListStatus::Pending,
}],
design: None,
specs: HashMap::new(),
};
let independent = ShowInfo {
id: "independent-change".to_string(),
path: "openspec/changes/independent-change".to_string(),
archived: false,
proposal: Some("# Independent Change\n".to_string()),
tasks: Some("- [ ] pending\n".to_string()),
tasks_completed: 0,
tasks_total: 1,
dependencies: Vec::new(),
dependency_statuses: Vec::new(),
design: None,
specs: HashMap::new(),
};
let dependent_output = render_show_output(&dependent);
let independent_output = render_show_output(&independent);
assert!(dependent_output.contains("Dependencies: feature-a [pending]\n"));
assert!(!independent_output.contains("Dependencies:"));
}
#[test]
fn test_render_show_json_includes_structured_dependency_statuses() {
let info = ShowInfo {
id: "dependent-change".to_string(),
path: "openspec/changes/dependent-change".to_string(),
archived: false,
proposal: Some("# Dependent Change\n".to_string()),
tasks: Some("- [ ] pending\n".to_string()),
tasks_completed: 0,
tasks_total: 1,
dependencies: vec!["feature-a".to_string()],
dependency_statuses: vec![DependencyStatusInfo {
id: "feature-a".to_string(),
status: DependencyListStatus::Pending,
}],
design: None,
specs: HashMap::new(),
};
let json = render_show_json_value(&info);
let dependencies = json
.get("dependencies")
.and_then(|value| value.as_array())
.expect("dependencies should be a JSON array");
assert_eq!(dependencies.len(), 1);
assert_eq!(
dependencies[0].get("id").and_then(|value| value.as_str()),
Some("feature-a")
);
assert_eq!(
dependencies[0]
.get("status")
.and_then(|value| value.as_str()),
Some("pending")
);
}
#[test]
fn test_show_change_deltas_only_omits_dependency_statuses() {
let temp = TempDir::new().unwrap();
let _guard = CwdTestGuard::enter(temp.path());
let dependent_dir = temp.path().join("openspec/changes/dependent-change");
create_change_with_frontmatter_dependencies(
&dependent_dir,
"Dependent Change",
&[
"pending-dep",
"running-dep",
"done-dep",
"rejected-dep",
"missing-dep",
],
"- [ ] pending\n",
);
create_change(
&temp.path().join("openspec/changes/pending-dep"),
"Pending Dep",
"- [ ] pending\n",
);
create_change(
&temp.path().join("openspec/changes/running-dep"),
"Running Dep",
"- [ ] running\n",
);
fs::write(temp.path().join(".conflux-inflight"), "running-dep\n").unwrap();
create_change(
&temp
.path()
.join("openspec/changes/archive/2026-05-08-done-dep"),
"Done Dep",
"- [x] done\n",
);
create_change(
&temp.path().join("openspec/changes/rejected-dep"),
"Rejected Dep",
"- [ ] rejected\n",
);
fs::write(
temp.path()
.join("openspec/changes/rejected-dep/REJECTED.md"),
"# REJECTED\n",
)
.unwrap();
let mgr = OpenSpecManager::new();
let info = mgr
.show_change("dependent-change", true)
.expect("show should not error")
.expect("dependent change should resolve via deltas-only show");
let json = render_show_json_value(&info);
let output = render_show_output(&info);
assert!(info.proposal.is_none());
assert!(info.tasks.is_none());
assert!(info.dependencies.is_empty());
assert!(info.dependency_statuses.is_empty());
assert!(!json.as_object().unwrap().contains_key("dependencies"));
assert!(!output.contains("Dependencies:"));
}
#[test]
fn test_show_change_resolves_archived_entry() {
let temp = TempDir::new().unwrap();
let _guard = CwdTestGuard::enter(temp.path());
let archived_dir = temp.path().join("openspec/changes/archive/archived-change");
create_change(&archived_dir, "Archived Change", "- [x] archived\n");
let mgr = OpenSpecManager::new();
let info = mgr
.show_change("archived-change", false)
.expect("show should not error")
.expect("archived change should resolve via show");
assert!(info.archived);
assert_eq!(info.id, "archived-change");
assert!(info
.path
.contains("openspec/changes/archive/archived-change"));
}
#[test]
fn test_show_change_resolves_dated_archived_entry() {
let temp = TempDir::new().unwrap();
let _guard = CwdTestGuard::enter(temp.path());
let archived_dir = temp
.path()
.join("openspec/changes/archive/2026-04-28-archived-change");
create_change(&archived_dir, "Archived Change", "- [x] archived\n");
let mgr = OpenSpecManager::new();
let info = mgr
.show_change("archived-change", false)
.expect("show should not error")
.expect("dated archived change should resolve via show");
assert!(info.archived);
assert_eq!(info.id, "archived-change");
assert!(info
.path
.contains("openspec/changes/archive/2026-04-28-archived-change"));
}
#[test]
fn test_show_change_rejects_nested_archived_entry() {
let temp = TempDir::new().unwrap();
let _guard = CwdTestGuard::enter(temp.path());
let archived_dir = temp
.path()
.join("openspec/changes/archive/2026-07-09/archived-change");
create_change(&archived_dir, "Archived Change", "- [x] archived\n");
let mgr = OpenSpecManager::new();
let Err(err) = mgr.show_change("archived-change", false) else {
panic!("nested archive layout should fail");
};
assert!(err.contains("Invalid archive layout"));
assert!(err.contains("2026-07-09/archived-change"));
}
#[test]
fn test_strict_validate_accepts_matching_modified_target() {
let temp = TempDir::new().unwrap();
let _guard = CwdTestGuard::enter(temp.path());
create_canonical_spec(
temp.path(),
"target-check",
"# Target Check\n\n### Requirement: Existing Feature\n\nOld behavior.\n",
);
create_strict_change_with_spec_delta(
&temp.path().join("openspec/changes/valid-modified-target"),
"target-check",
"## MODIFIED Requirements\n\n### Requirement: Existing Feature\n\nUpdated behavior.\n\n#### Scenario: Existing feature updates\n- WHEN validation runs\n- THEN the matching canonical target is accepted\n",
);
let mgr = OpenSpecManager::new();
let (is_valid, errors, _warnings) =
mgr.validate_change(Some("valid-modified-target"), true, "off");
assert!(is_valid, "matching modified target should pass: {errors:?}");
assert!(errors.is_empty());
}
#[test]
fn test_strict_validate_rejects_missing_modified_target() {
let temp = TempDir::new().unwrap();
let _guard = CwdTestGuard::enter(temp.path());
create_canonical_spec(
temp.path(),
"target-check",
"# Target Check\n\n### Requirement: Existing Feature\n\nOld behavior.\n",
);
create_strict_change_with_spec_delta(
&temp.path().join("openspec/changes/missing-modified-target"),
"target-check",
"## MODIFIED Requirements\n\n### Requirement: Missing Feature\n\nUpdated behavior.\n\n#### Scenario: Missing feature updates\n- WHEN validation runs\n- THEN the missing target is rejected\n",
);
let mgr = OpenSpecManager::new();
let (is_valid, errors, _warnings) =
mgr.validate_change(Some("missing-modified-target"), true, "off");
assert!(!is_valid);
assert!(
errors.iter().any(|error| {
error.contains("target-check")
&& error.contains("MODIFIED target not found in canonical spec")
&& error.contains("### Requirement: Missing Feature")
}),
"missing modified target diagnostic should include capability and heading: {errors:?}"
);
}
#[test]
fn test_strict_validate_rejects_missing_removed_target() {
let temp = TempDir::new().unwrap();
let _guard = CwdTestGuard::enter(temp.path());
create_canonical_spec(
temp.path(),
"target-check",
"# Target Check\n\n### Requirement: Existing Feature\n\nOld behavior.\n",
);
create_strict_change_with_spec_delta(
&temp.path().join("openspec/changes/missing-removed-target"),
"target-check",
"## REMOVED Requirements\n\n### Requirement: Missing Feature\n\nRemoved behavior.\n\n#### Scenario: Missing feature removal\n- WHEN validation runs\n- THEN the missing target is rejected\n",
);
let mgr = OpenSpecManager::new();
let (is_valid, errors, _warnings) =
mgr.validate_change(Some("missing-removed-target"), true, "off");
assert!(!is_valid);
assert!(
errors.iter().any(|error| {
error.contains("target-check")
&& error.contains("REMOVED target not found in canonical spec")
&& error.contains("### Requirement: Missing Feature")
}),
"missing removed target diagnostic should include capability and heading: {errors:?}"
);
}
#[test]
fn test_strict_validate_allows_added_only_delta_without_canonical_target() {
let temp = TempDir::new().unwrap();
let _guard = CwdTestGuard::enter(temp.path());
create_strict_change_with_spec_delta(
&temp.path().join("openspec/changes/added-only-target"),
"brand-new-capability",
"## ADDED Requirements\n\n### Requirement: New Feature\n\nNew behavior.\n\n#### Scenario: New feature\n- WHEN validation runs\n- THEN no canonical target is required\n",
);
let mgr = OpenSpecManager::new();
let (is_valid, errors, _warnings) =
mgr.validate_change(Some("added-only-target"), true, "off");
assert!(is_valid, "added-only delta should pass: {errors:?}");
assert!(errors.is_empty());
}
#[test]
fn test_strict_validation_enforces_verification_contracts() {
let temp = TempDir::new().unwrap();
let _guard = CwdTestGuard::enter(temp.path());
let change_dir = temp.path().join("openspec/changes/verification-contract");
create_strict_valid_change(&change_dir, "Verification Contract");
let mgr = OpenSpecManager::new();
assert!(
mgr.validate_change(Some("verification-contract"), true, "off")
.0
);
let invalid = "---\nverifications:\n - id: duplicate\n requirement: x\n phase: post-integration\n owner: conflux-acceptance\n trigger: x\n automation: ../outside\n evidence: x\n rerun: x\n prerequisites: []\n - id: duplicate\n requirement: x\n phase: invalid\n owner: repository-automation\n trigger: x\n automation: missing\n evidence: x\n rerun: x\n prerequisites: []\n---\n# Invalid\n\n**Change Type**: implementation\n";
fs::write(change_dir.join("proposal.md"), invalid).unwrap();
let (valid, errors, _) = mgr.validate_change(Some("verification-contract"), true, "off");
assert!(!valid);
assert!(errors
.iter()
.any(|error| error.contains("duplicate verification id")));
assert!(errors
.iter()
.any(|error| error.contains("post-integration requires owner")));
assert!(errors.iter().any(|error| error.contains("invalid phase")));
assert!(errors
.iter()
.any(|error| error.contains("unsafe automation path")));
assert!(errors.iter().any(|error| error.contains("does not exist")));
}
#[test]
fn test_strict_validation_rejects_missing_verification_fields() {
let temp = TempDir::new().unwrap();
let _guard = CwdTestGuard::enter(temp.path());
let change_dir = temp.path().join("openspec/changes/missing-fields");
create_strict_valid_change(&change_dir, "Missing Fields");
let proposal = fs::read_to_string(change_dir.join("proposal.md")).unwrap();
fs::write(
change_dir.join("proposal.md"),
proposal
.replace(" requirement: archive behavior\n", "")
.replace(" prerequisites: []\n", ""),
)
.unwrap();
let (valid, errors, _) =
OpenSpecManager::new().validate_change(Some("missing-fields"), true, "off");
assert!(!valid);
assert!(errors
.iter()
.any(|error| error.contains("missing non-empty requirement")));
assert!(errors
.iter()
.any(|error| error.contains("missing prerequisites list")));
}
#[test]
fn test_strict_validation_rejects_absolute_automation_path() {
let temp = TempDir::new().unwrap();
let _guard = CwdTestGuard::enter(temp.path());
let change_dir = temp.path().join("openspec/changes/absolute-automation");
create_strict_valid_change(&change_dir, "Absolute Automation");
let proposal = fs::read_to_string(change_dir.join("proposal.md")).unwrap();
fs::write(
change_dir.join("proposal.md"),
proposal.replace("automation: Cargo.toml", "automation: /tmp/automation.sh"),
)
.unwrap();
let (valid, errors, _) =
OpenSpecManager::new().validate_change(Some("absolute-automation"), true, "off");
assert!(!valid);
assert!(errors
.iter()
.any(|error| error.contains("unsafe automation path")));
}
#[cfg(unix)]
#[test]
fn test_strict_validation_rejects_external_automation_symlink() {
let temp = TempDir::new().unwrap();
let _guard = CwdTestGuard::enter(temp.path());
let change_dir = temp.path().join("openspec/changes/external-symlink");
create_strict_valid_change(&change_dir, "External Symlink");
let external = TempDir::new().unwrap();
fs::write(external.path().join("automation.sh"), "#!/bin/sh\n").unwrap();
std::os::unix::fs::symlink(
external.path().join("automation.sh"),
temp.path().join("automation.sh"),
)
.unwrap();
let proposal = fs::read_to_string(change_dir.join("proposal.md")).unwrap();
fs::write(
change_dir.join("proposal.md"),
proposal.replace("automation: Cargo.toml", "automation: automation.sh"),
)
.unwrap();
let (valid, errors, _) =
OpenSpecManager::new().validate_change(Some("external-symlink"), true, "off");
assert!(!valid);
assert!(errors
.iter()
.any(|error| error.contains("escapes repository")));
}
#[test]
fn test_strict_validation_rejects_non_regular_automation_path() {
let temp = TempDir::new().unwrap();
let _guard = CwdTestGuard::enter(temp.path());
let change_dir = temp.path().join("openspec/changes/directory-automation");
create_strict_valid_change(&change_dir, "Directory Automation");
fs::create_dir("automation").unwrap();
track_file(temp.path(), "automation");
let proposal = fs::read_to_string(change_dir.join("proposal.md")).unwrap();
fs::write(
change_dir.join("proposal.md"),
proposal.replace("automation: Cargo.toml", "automation: automation"),
)
.unwrap();
let (valid, errors, _) =
OpenSpecManager::new().validate_change(Some("directory-automation"), true, "off");
assert!(!valid);
assert!(errors
.iter()
.any(|error| error.contains("is not a regular file")));
}
#[test]
fn test_strict_validation_requires_pre_integration_for_hybrid_despite_prose() {
let temp = TempDir::new().unwrap();
let _guard = CwdTestGuard::enter(temp.path());
let change_dir = temp.path().join("openspec/changes/hybrid-post-only");
create_strict_valid_change(&change_dir, "Hybrid Post Only");
fs::write("post.sh", "#!/bin/sh\n").unwrap();
track_file(temp.path(), "post.sh");
let proposal = fs::read_to_string(change_dir.join("proposal.md")).unwrap();
fs::write(
change_dir.join("proposal.md"),
proposal
.replace("phase: pre-integration", "phase: post-integration")
.replace("owner: conflux-acceptance", "owner: repository-automation")
.replace("automation: Cargo.toml", "automation: post.sh")
.replace("**Change Type**: implementation", "**Change Type**: hybrid")
+ "\nThis check runs before integration.\n",
)
.unwrap();
let (valid, errors, _) =
OpenSpecManager::new().validate_change(Some("hybrid-post-only"), true, "off");
assert!(!valid);
assert!(errors
.iter()
.any(|error| error.contains("require at least one pre-integration")));
}
#[test]
fn test_strict_validation_accepts_complete_pre_and_post_contract() {
let temp = TempDir::new().unwrap();
let _guard = CwdTestGuard::enter(temp.path());
let change_dir = temp.path().join("openspec/changes/complete-contract");
create_strict_valid_change(&change_dir, "Complete Contract");
fs::write("post.sh", "#!/bin/sh\n").unwrap();
track_file(temp.path(), "post.sh");
let proposal = fs::read_to_string(change_dir.join("proposal.md")).unwrap();
fs::write(
change_dir.join("proposal.md"),
proposal.replace(
" prerequisites: []\n---",
" prerequisites: []\n - id: deploy\n requirement: release validation\n phase: post-integration\n owner: repository-automation\n trigger: default-branch-integration\n automation: post.sh\n evidence: workflow artifact\n rerun: workflow_dispatch\n prerequisites: []\n---",
),
)
.unwrap();
assert!(
OpenSpecManager::new()
.validate_change(Some("complete-contract"), true, "off")
.0
);
}
#[test]
fn test_strict_validation_rejects_ownerless_cyclic_post_integration_gate() {
let temp = TempDir::new().unwrap();
let _guard = CwdTestGuard::enter(temp.path());
let change_dir = temp.path().join("openspec/changes/ownerless-cyclic-gate");
create_strict_valid_change(&change_dir, "Ownerless Cyclic Gate");
let proposal = fs::read_to_string(change_dir.join("proposal.md")).unwrap();
fs::write(
change_dir.join("proposal.md"),
proposal
.replace("phase: pre-integration", "phase: post-integration")
.replace(
"trigger: pull request",
"trigger: waits for its own integration",
)
.replace("prerequisites: []", "prerequisites: [integration complete]"),
)
.unwrap();
let (valid, errors, _) =
OpenSpecManager::new().validate_change(Some("ownerless-cyclic-gate"), true, "off");
assert!(!valid);
assert!(errors
.iter()
.any(|error| error.contains("post-integration requires owner")));
}
#[test]
fn test_strict_validation_rejects_untracked_automation() {
let temp = TempDir::new().unwrap();
let _guard = CwdTestGuard::enter(temp.path());
let change_dir = temp.path().join("openspec/changes/untracked-automation");
create_strict_valid_change(&change_dir, "Untracked Automation");
fs::write("untracked.sh", "#!/bin/sh\n").unwrap();
let proposal = fs::read_to_string(change_dir.join("proposal.md")).unwrap();
fs::write(
change_dir.join("proposal.md"),
proposal.replace("automation: Cargo.toml", "automation: untracked.sh"),
)
.unwrap();
let (valid, errors, _) =
OpenSpecManager::new().validate_change(Some("untracked-automation"), true, "off");
assert!(!valid);
assert!(errors
.iter()
.any(|error| error.contains("is not tracked by git")));
}
/// Strict validation must reach the heavyweight-command policy from the
/// real entrypoint, not only from the pure decision helper — and during
/// migration the finding is a warning that leaves validation passing, so
/// archive-gate validation (strict + evidence `error`) does not fail.
#[test]
fn test_strict_validation_warns_without_failing_on_heavyweight_declaration() {
let temp = TempDir::new().unwrap();
let _guard = CwdTestGuard::enter(temp.path());
let change_dir = temp.path().join("openspec/changes/heavy-gate");
create_strict_valid_change(&change_dir, "Heavy Gate");
fs::write(
change_dir.join("proposal.md"),
"---\nverifications:\n - id: bounded-gate\n requirement: validator behavior\n phase: pre-integration\n owner: conflux-acceptance\n trigger: pull request\n automation: Cargo.toml\n evidence: docker compose run --rm suite\n rerun: cargo test openspec_cmd --lib\n prerequisites: []\n execution_class: repository-local\n completion_role: change-blocking\n---\n# Heavy Gate\n\n**Change Type**: implementation\n\n## Problem\nvalidator fixture\n",
)
.unwrap();
fs::write(
change_dir.join("tasks.md"),
"## Implementation Tasks\n- [ ] 1. validator update (verification: unit - cargo test openspec_cmd --lib; verification-id: bounded-gate)\n",
)
.unwrap();
let (valid, errors, warnings) =
OpenSpecManager::new().validate_change(Some("heavy-gate"), true, "error");
assert!(valid, "{errors:?}");
assert!(
!errors
.iter()
.any(|error| error.contains("heavyweight command form")),
"{errors:?}"
);
assert!(
warnings.iter().any(|warning| warning
.contains("verification 'bounded-gate': change-blocking evidence")
&& warning.contains(
"heavyweight command form (container orchestration: 'docker compose')"
)
&& warning.contains("does not fail archive-gate validation")),
"{warnings:?}"
);
}
/// The same entrypoint must stay silent for a bounded `docker build`.
#[test]
fn test_strict_validation_accepts_bounded_docker_build_declaration() {
let temp = TempDir::new().unwrap();
let _guard = CwdTestGuard::enter(temp.path());
let change_dir = temp.path().join("openspec/changes/docker-build-gate");
create_strict_valid_change(&change_dir, "Docker Build Gate");
fs::write(
change_dir.join("proposal.md"),
"---\nverifications:\n - id: bounded-gate\n requirement: image builds\n phase: pre-integration\n owner: conflux-acceptance\n trigger: pull request\n automation: Cargo.toml\n evidence: docker build -t cflx-ci .\n rerun: docker build -t cflx-ci .\n prerequisites: []\n execution_class: repository-local\n completion_role: change-blocking\n---\n# Docker Build Gate\n\n**Change Type**: implementation\n\n## Problem\nvalidator fixture\n",
)
.unwrap();
fs::write(
change_dir.join("tasks.md"),
"## Implementation Tasks\n- [ ] 1. Dockerfile update (verification: unit - docker build -t cflx-ci .; verification-id: bounded-gate)\n",
)
.unwrap();
let (valid, errors, warnings) =
OpenSpecManager::new().validate_change(Some("docker-build-gate"), true, "error");
assert!(valid, "{errors:?}");
assert!(
!warnings
.iter()
.chain(errors.iter())
.any(|finding| finding.contains("heavyweight command form")),
"errors={errors:?} warnings={warnings:?}"
);
}
/// A JSON-only change reaches the same native validation contract a
/// Markdown change does: required-file presence, verification linkage, and
/// the Final Validation non-task rule.
#[test]
fn test_strict_validation_accepts_a_json_only_change() {
let temp = TempDir::new().unwrap();
let _guard = CwdTestGuard::enter(temp.path());
let change_dir = temp.path().join("openspec/changes/json-only");
create_strict_valid_change(&change_dir, "Json Only");
fs::remove_file(change_dir.join("tasks.md")).unwrap();
fs::write(
change_dir.join("proposal.md"),
"---\nverifications:\n - id: local-gate\n requirement: json task behavior\n phase: pre-integration\n owner: conflux-acceptance\n trigger: pull request\n automation: Cargo.toml\n evidence: cargo test --lib\n rerun: cargo test --lib\n prerequisites: []\n execution_class: repository-local\n completion_role: change-blocking\n---\n# Json Only\n\n**Change Type**: implementation\n\n## Problem\njson task fixture\n",
)
.unwrap();
fs::write(
change_dir.join("tasks.json"),
r#"{"schema_version":1,"tasks":[
{"id":"impl","title":"Implement the behavior","status":"pending","section":"implementation",
"verification_id":"local-gate","verification":{"kind":"unit","command":"cargo test --lib"}}
],"narrative":{"final_validation":"cflx openspec validate json-only --archive-gate"}}"#,
)
.unwrap();
let (valid, errors, _warnings) =
OpenSpecManager::new().validate_change(Some("json-only"), true, "error");
assert!(valid, "{errors:?}");
// The same entry projects its progress into list/show.
let info = OpenSpecManager::new()
.show_change("json-only", false)
.unwrap()
.expect("json change is shown");
assert_eq!((info.tasks_completed, info.tasks_total), (0, 1));
assert!(info
.tasks
.is_some_and(|tasks| tasks.contains("schema_version")));
}
#[test]
fn test_strict_validation_rejects_invalid_and_ambiguous_json_task_artifacts() {
let temp = TempDir::new().unwrap();
let _guard = CwdTestGuard::enter(temp.path());
// Unsupported schema version fails closed with its JSON Pointer.
let unsupported = temp.path().join("openspec/changes/json-unsupported");
create_strict_valid_change(&unsupported, "Json Unsupported");
fs::remove_file(unsupported.join("tasks.md")).unwrap();
fs::write(
unsupported.join("tasks.json"),
r#"{"schema_version":9,"tasks":[]}"#,
)
.unwrap();
let (valid, errors, _) =
OpenSpecManager::new().validate_change(Some("json-unsupported"), true, "error");
assert!(!valid);
assert!(
errors
.iter()
.any(|error| error.contains("tasks.json:/schema_version")),
"{errors:?}"
);
// Two artifacts in one entry are ambiguous, never a precedence decision.
let ambiguous = temp.path().join("openspec/changes/json-ambiguous");
create_strict_valid_change(&ambiguous, "Json Ambiguous");
fs::write(
ambiguous.join("tasks.json"),
r#"{"schema_version":1,"tasks":[]}"#,
)
.unwrap();
let (valid, errors, _) =
OpenSpecManager::new().validate_change(Some("json-ambiguous"), true, "error");
assert!(!valid);
assert!(
errors
.iter()
.any(|error| error.contains("Ambiguous task artifacts")),
"{errors:?}"
);
// A missing artifact names both supported filenames.
let missing = temp.path().join("openspec/changes/json-missing");
create_strict_valid_change(&missing, "Json Missing");
fs::remove_file(missing.join("tasks.md")).unwrap();
let (_, errors, _) =
OpenSpecManager::new().validate_change(Some("json-missing"), true, "error");
assert!(
errors
.iter()
.any(|error| error.contains("Missing tasks.md or tasks.json")),
"{errors:?}"
);
}
#[test]
fn test_strict_validation_rejects_json_self_referential_final_validation_task() {
let temp = TempDir::new().unwrap();
let _guard = CwdTestGuard::enter(temp.path());
let change_dir = temp.path().join("openspec/changes/json-final");
create_strict_valid_change(&change_dir, "Json Final");
fs::remove_file(change_dir.join("tasks.md")).unwrap();
fs::write(
change_dir.join("proposal.md"),
"---\nverifications:\n - id: local-gate\n requirement: json task behavior\n phase: pre-integration\n owner: conflux-acceptance\n trigger: pull request\n automation: Cargo.toml\n evidence: cargo test --lib\n rerun: cargo test --lib\n prerequisites: []\n execution_class: repository-local\n completion_role: change-blocking\n---\n# Json Final\n\n**Change Type**: implementation\n\n## Problem\njson task fixture\n",
)
.unwrap();
fs::write(
change_dir.join("tasks.json"),
r#"{"schema_version":1,"tasks":[
{"id":"final","title":"Run cflx openspec validate json-final --archive-gate for final validation","status":"pending","section":"implementation",
"verification_id":"local-gate","verification":{"kind":"unit","command":"cargo test --lib"}}
]}"#,
)
.unwrap();
let (valid, errors, _) =
OpenSpecManager::new().validate_change(Some("json-final"), true, "error");
assert!(!valid);
assert!(
errors
.iter()
.any(|error| error.contains("self-referential final OpenSpec validation")),
"{errors:?}"
);
}
#[test]
fn test_strict_validation_requires_json_verification_linkage() {
let temp = TempDir::new().unwrap();
let _guard = CwdTestGuard::enter(temp.path());
let change_dir = temp.path().join("openspec/changes/json-linkage");
create_strict_valid_change(&change_dir, "Json Linkage");
fs::remove_file(change_dir.join("tasks.md")).unwrap();
fs::write(
change_dir.join("proposal.md"),
"---\nverifications:\n - id: local-gate\n requirement: json task behavior\n phase: pre-integration\n owner: conflux-acceptance\n trigger: pull request\n automation: Cargo.toml\n evidence: cargo test --lib\n rerun: cargo test --lib\n prerequisites: []\n execution_class: repository-local\n completion_role: change-blocking\n---\n# Json Linkage\n\n**Change Type**: implementation\n\n## Problem\njson task fixture\n",
)
.unwrap();
fs::write(
change_dir.join("tasks.json"),
r#"{"schema_version":1,"tasks":[
{"id":"impl","title":"Implement","status":"pending","section":"implementation",
"verification":{"kind":"unit","command":"cargo test --lib"}}
]}"#,
)
.unwrap();
let (valid, errors, _) =
OpenSpecManager::new().validate_change(Some("json-linkage"), true, "error");
assert!(!valid);
assert!(
errors.iter().any(|error| error
.contains("must reference a change-blocking verification")
&& error.contains("tasks.json:/tasks/0/verification_id")),
"{errors:?}"
);
}
#[test]
fn test_strict_validation_allows_spec_only_without_verifications() {
let temp = TempDir::new().unwrap();
let _guard = CwdTestGuard::enter(temp.path());
let change_dir = temp.path().join("openspec/changes/spec-only-contract");
fs::create_dir_all(change_dir.join("specs/example")).unwrap();
fs::write(
change_dir.join("proposal.md"),
"# Spec Only\n\n**Change Type**: spec-only\n",
)
.unwrap();
fs::write(change_dir.join("tasks.md"), "- [ ] document behavior\n").unwrap();
fs::write(change_dir.join("specs/example/spec.md"), "## ADDED Requirements\n\n### Requirement: Example\n\n#### Scenario: Example\n- WHEN read\n- THEN valid\n").unwrap();
assert!(
OpenSpecManager::new()
.validate_change(Some("spec-only-contract"), true, "off")
.0
);
}
/// Valid repository-local completion gate.
const LOCAL_BLOCKER: &str = " - id: local-tests\n requirement: repository behavior\n phase: pre-integration\n owner: conflux-acceptance\n trigger: pull request\n automation: Cargo.toml\n evidence: cargo test\n rerun: cargo test\n prerequisites: []\n execution_class: repository-local\n completion_role: change-blocking\n";
/// Deployed-service outcome incorrectly declared as a completion gate.
const DEPLOYED_BLOCKER: &str = " - id: deployed-smoke\n requirement: deployed behavior\n phase: post-integration\n owner: repository-automation\n trigger: default-branch-integration\n automation: Cargo.toml\n evidence: workflow artifact\n rerun: workflow_dispatch\n prerequisites: []\n execution_class: deployed-service\n completion_role: change-blocking\n";
/// Physical-device acceptance incorrectly declared as a completion gate.
const DEVICE_BLOCKER: &str = " - id: device-acceptance\n requirement: device behavior\n phase: pre-integration\n owner: conflux-acceptance\n trigger: pull request\n automation: Cargo.toml\n evidence: operator log\n rerun: rerun device check\n prerequisites: []\n execution_class: physical-device\n completion_role: change-blocking\n";
/// Credentialed repository automation, correctly modeled as observation.
const CREDENTIALED_OBSERVATION: &str = " - id: credentialed-publish\n requirement: published artifact behavior\n phase: post-integration\n owner: repository-automation\n trigger: default-branch-integration\n automation: Cargo.toml\n evidence: workflow artifact\n rerun: workflow_dispatch\n prerequisites: []\n execution_class: credentialed-external\n completion_role: operational-observation\n";
/// Post-integration observation used by release-observation changes.
const RELEASE_OBSERVATION: &str = " - id: release-smoke\n requirement: release behavior observed after integration\n phase: post-integration\n owner: repository-automation\n trigger: default-branch-integration\n automation: Cargo.toml\n evidence: workflow artifact\n rerun: workflow_dispatch\n prerequisites: []\n execution_class: deployed-service\n completion_role: operational-observation\n";
/// Legacy declaration with none of the new role fields.
const LEGACY_DECLARATION: &str = " - id: legacy-local\n requirement: repository behavior\n phase: pre-integration\n owner: conflux-acceptance\n trigger: pull request\n automation: Cargo.toml\n evidence: cargo test\n rerun: cargo test\n prerequisites: []\n";
const LINKED_TASKS: &str = "## Implementation Tasks\n- [ ] 1. implement behavior (verification: unit - cargo test openspec_cmd --lib; verification-id: local-tests)\n";
const OBSERVATION_TASKS: &str =
"## Future Work\n- Operational release observation after integration\n";
/// Prepare a repository root shared by release-gate dependency fixtures.
fn setup_release_gate_repo(root: &Path) {
fs::write(root.join("Cargo.toml"), "[package]\nname = \"fixture\"\n").unwrap();
track_file(root, "Cargo.toml");
}
fn create_release_gate_change(
root: &Path,
change_id: &str,
change_type: &str,
dependencies: &[&str],
verifications: &str,
tasks: &str,
) {
let dir = root.join("openspec/changes").join(change_id);
fs::create_dir_all(&dir).unwrap();
let dependencies_yaml = if dependencies.is_empty() {
"dependencies: []\n".to_string()
} else {
let mut yaml = "dependencies:\n".to_string();
for dependency in dependencies {
yaml.push_str(&format!(" - {}\n", dependency));
}
yaml
};
fs::write(
dir.join("proposal.md"),
format!(
"---\nchange_type: {change_type}\n{dependencies_yaml}verifications:\n{verifications}---\n# {change_id}\n\n**Change Type**: {change_type}\n\n## Problem\nrelease gate fixture\n"
),
)
.unwrap();
fs::write(dir.join("tasks.md"), tasks).unwrap();
let spec_dir = dir.join("specs").join("release-gate");
fs::create_dir_all(&spec_dir).unwrap();
fs::write(
spec_dir.join("spec.md"),
format!("## ADDED Requirements\n\n### Requirement: {change_id} behavior\n\n#### Scenario: Fixture scenario\n- WHEN validation runs\n- THEN the fixture is evaluated\n"),
)
.unwrap();
}
#[test]
fn test_release_gate_rejects_non_local_change_blocking_declaration_on_owner() {
let temp = TempDir::new().unwrap();
let _guard = CwdTestGuard::enter(temp.path());
setup_release_gate_repo(temp.path());
create_release_gate_change(
temp.path(),
"deployed-gate",
"implementation",
&[],
&format!("{LOCAL_BLOCKER}{DEPLOYED_BLOCKER}"),
LINKED_TASKS,
);
let (valid, errors, _) =
OpenSpecManager::new().validate_change(Some("deployed-gate"), true, "off");
assert!(!valid);
assert_eq!(
errors
.iter()
.filter(|error| error.contains("verification 'deployed-smoke'"))
.count(),
2,
"phase and execution-class violations are both owned by the target: {errors:?}"
);
assert!(errors.iter().any(|error| error
.contains("completion_role: change-blocking requires phase: pre-integration")));
assert!(errors.iter().any(|error| error.contains(
"completion_role: change-blocking requires execution_class: repository-local"
)));
}
#[test]
fn test_release_gate_rejects_physical_device_change_blocker() {
let temp = TempDir::new().unwrap();
let _guard = CwdTestGuard::enter(temp.path());
setup_release_gate_repo(temp.path());
create_release_gate_change(
temp.path(),
"device-gate",
"implementation",
&[],
&format!("{LOCAL_BLOCKER}{DEVICE_BLOCKER}"),
LINKED_TASKS,
);
let (valid, errors, _) =
OpenSpecManager::new().validate_change(Some("device-gate"), true, "off");
assert!(!valid);
assert!(
errors
.iter()
.any(|error| error.contains("verification 'device-acceptance'")
&& error.contains("found 'physical-device'")
&& error.contains("operational-observation")),
"{errors:?}"
);
}
#[test]
fn test_release_gate_accepts_credentialed_repository_automation_observation() {
let temp = TempDir::new().unwrap();
let _guard = CwdTestGuard::enter(temp.path());
setup_release_gate_repo(temp.path());
create_release_gate_change(
temp.path(),
"credentialed-observation",
"implementation",
&[],
&format!("{LOCAL_BLOCKER}{CREDENTIALED_OBSERVATION}"),
LINKED_TASKS,
);
let (valid, errors, _) =
OpenSpecManager::new().validate_change(Some("credentialed-observation"), true, "off");
assert!(valid, "{errors:?}");
}
#[test]
fn test_release_gate_accepts_ordinary_local_dependency_edge() {
let temp = TempDir::new().unwrap();
let _guard = CwdTestGuard::enter(temp.path());
setup_release_gate_repo(temp.path());
create_release_gate_change(
temp.path(),
"feature-a",
"implementation",
&[],
LOCAL_BLOCKER,
LINKED_TASKS,
);
create_release_gate_change(
temp.path(),
"feature-b",
"implementation",
&["feature-a"],
LOCAL_BLOCKER,
LINKED_TASKS,
);
let (valid, errors, _) =
OpenSpecManager::new().validate_change(Some("feature-b"), true, "off");
assert!(valid, "{errors:?}");
}
#[test]
fn test_release_gate_rejects_dependency_on_non_local_change_blocker() {
let temp = TempDir::new().unwrap();
let _guard = CwdTestGuard::enter(temp.path());
setup_release_gate_repo(temp.path());
create_release_gate_change(
temp.path(),
"release-target",
"implementation",
&[],
&format!("{LOCAL_BLOCKER}{DEPLOYED_BLOCKER}"),
LINKED_TASKS,
);
create_release_gate_change(
temp.path(),
"follow-on",
"implementation",
&["release-target"],
LOCAL_BLOCKER,
LINKED_TASKS,
);
let (valid, errors, _) =
OpenSpecManager::new().validate_change(Some("follow-on"), true, "off");
assert!(!valid);
let diagnostic = errors
.iter()
.find(|error| error.contains("declares non-local change-blocking verification"))
.unwrap_or_else(|| panic!("expected release-gate dependency diagnostic: {errors:?}"));
assert!(diagnostic.starts_with("follow-on:"), "{diagnostic}");
assert!(diagnostic.contains("'release-target'"), "{diagnostic}");
assert!(diagnostic.contains("'deployed-smoke'"), "{diagnostic}");
assert!(diagnostic.contains("Remedy: split"), "{diagnostic}");
}
#[test]
fn test_release_gate_allows_observational_release_chain() {
let temp = TempDir::new().unwrap();
let _guard = CwdTestGuard::enter(temp.path());
setup_release_gate_repo(temp.path());
create_release_gate_change(
temp.path(),
"release-stage-one",
"spec-only",
&[],
RELEASE_OBSERVATION,
OBSERVATION_TASKS,
);
create_release_gate_change(
temp.path(),
"release-stage-two",
"spec-only",
&["release-stage-one"],
RELEASE_OBSERVATION,
OBSERVATION_TASKS,
);
let (valid, errors, _) =
OpenSpecManager::new().validate_change(Some("release-stage-two"), true, "off");
assert!(valid, "{errors:?}");
}
#[test]
fn test_release_gate_keeps_legacy_declarations_valid_with_migration_warning() {
let temp = TempDir::new().unwrap();
let _guard = CwdTestGuard::enter(temp.path());
setup_release_gate_repo(temp.path());
create_release_gate_change(
temp.path(),
"legacy-target",
"implementation",
&[],
LEGACY_DECLARATION,
"## Implementation Tasks\n- [ ] 1. implement behavior (verification: unit - cargo test openspec_cmd --lib)\n",
);
create_release_gate_change(
temp.path(),
"legacy-dependent",
"implementation",
&["legacy-target"],
LEGACY_DECLARATION,
"## Implementation Tasks\n- [ ] 1. implement behavior (verification: unit - cargo test openspec_cmd --lib)\n",
);
let (valid, errors, warnings) =
OpenSpecManager::new().validate_change(Some("legacy-dependent"), true, "off");
assert!(valid, "{errors:?}");
assert!(
warnings.iter().any(|warning| warning
.contains("legacy declaration without execution_class/completion_role")
&& warning.contains("add execution_class: repository-local")),
"{warnings:?}"
);
}
#[test]
fn test_release_gate_attributes_malformed_target_metadata_to_target() {
let temp = TempDir::new().unwrap();
let _guard = CwdTestGuard::enter(temp.path());
setup_release_gate_repo(temp.path());
create_release_gate_change(
temp.path(),
"malformed-target",
"implementation",
&[],
LOCAL_BLOCKER,
LINKED_TASKS,
);
let malformed = fs::read_to_string(
temp.path()
.join("openspec/changes/malformed-target/proposal.md"),
)
.unwrap()
.replace(" prerequisites: []\n", " surprise_key: value\n");
fs::write(
temp.path()
.join("openspec/changes/malformed-target/proposal.md"),
malformed,
)
.unwrap();
create_release_gate_change(
temp.path(),
"malformed-dependent",
"implementation",
&["malformed-target"],
LOCAL_BLOCKER,
LINKED_TASKS,
);
let mgr = OpenSpecManager::new();
let (target_valid, target_errors, _) =
mgr.validate_change(Some("malformed-target"), true, "off");
let (dependent_valid, dependent_errors, dependent_warnings) =
mgr.validate_change(Some("malformed-dependent"), true, "off");
assert!(!target_valid);
assert!(
target_errors.iter().any(|error| error
.contains("malformed-target: proposal.md verification metadata")
&& error.contains("surprise_key")),
"{target_errors:?}"
);
assert!(
dependent_valid,
"dependent must not duplicate the target parse failure: {dependent_errors:?}"
);
assert!(
dependent_warnings.iter().any(|warning| warning
.contains("unreadable verification metadata")
&& warning.contains("reported on 'malformed-target'")),
"{dependent_warnings:?}"
);
}
#[test]
fn test_release_gate_reports_reverse_impact_and_spares_in_flight_dependents() {
let temp = TempDir::new().unwrap();
let _guard = CwdTestGuard::enter(temp.path());
setup_release_gate_repo(temp.path());
create_release_gate_change(
temp.path(),
"release-target",
"implementation",
&[],
&format!("{LOCAL_BLOCKER}{DEPLOYED_BLOCKER}"),
LINKED_TASKS,
);
for dependent in ["queued-one", "queued-two", "running-one"] {
create_release_gate_change(
temp.path(),
dependent,
"implementation",
&["release-target"],
LOCAL_BLOCKER,
LINKED_TASKS,
);
}
fs::write(temp.path().join(".conflux-inflight"), "running-one\n").unwrap();
let mgr = OpenSpecManager::new();
let (_, _, target_warnings) = mgr.validate_change(Some("release-target"), true, "off");
let (queued_valid, queued_errors, _) = mgr.validate_change(Some("queued-one"), true, "off");
let (running_valid, running_errors, running_warnings) =
mgr.validate_change(Some("running-one"), true, "off");
assert!(
target_warnings.iter().any(|warning| warning
.contains("affect queued dependents: queued-one, queued-two")
&& warning.contains("next dispatch eligibility decision")),
"{target_warnings:?}"
);
assert!(
target_warnings.iter().any(|warning| warning
.contains("in-flight dependents are not interrupted")
&& warning.contains("running-one")),
"{target_warnings:?}"
);
assert!(!queued_valid);
assert!(queued_errors
.iter()
.any(|error| error.contains("declares non-local change-blocking verification")));
assert!(
running_valid,
"in-flight dependent must not be aborted by target metadata edits: {running_errors:?}"
);
assert!(
running_warnings
.iter()
.any(|warning| warning.contains("in-flight operation is not interrupted")),
"{running_warnings:?}"
);
}
#[test]
fn test_release_gate_blocks_original_fifteen_dependent_fan_out() {
let temp = TempDir::new().unwrap();
let _guard = CwdTestGuard::enter(temp.path());
setup_release_gate_repo(temp.path());
create_release_gate_change(
temp.path(),
"release-bottleneck",
"implementation",
&[],
&format!("{LOCAL_BLOCKER}{DEPLOYED_BLOCKER}"),
LINKED_TASKS,
);
let dependents: Vec<String> = (1..=15)
.map(|index| format!("dependent-{index:02}"))
.collect();
for dependent in &dependents {
create_release_gate_change(
temp.path(),
dependent,
"implementation",
&["release-bottleneck"],
LOCAL_BLOCKER,
LINKED_TASKS,
);
}
let (valid, errors, warnings) = OpenSpecManager::new().validate_change(None, true, "off");
assert!(!valid);
assert_eq!(
errors
.iter()
.filter(
|error| error.starts_with("release-bottleneck: verification 'deployed-smoke'")
)
.count(),
2,
"the owning proposal reports its own field violations once each: {errors:?}"
);
for dependent in &dependents {
assert!(
errors
.iter()
.any(|error| error.starts_with(&format!("{dependent}:"))
&& error.contains("'release-bottleneck'")
&& error.contains("'deployed-smoke'")
&& error.contains("Remedy: split")),
"missing reference diagnostic for {dependent}: {errors:?}"
);
}
assert!(
warnings.iter().any(|warning| warning
.starts_with("release-bottleneck: non-local change-blocking verification(s)")
&& warning.contains("dependent-01")
&& warning.contains("dependent-15")),
"{warnings:?}"
);
}
#[test]
fn test_archive_gate_validation_rejects_missing_delta_target() {
let temp = TempDir::new().unwrap();
let _guard = CwdTestGuard::enter(temp.path());
create_canonical_spec(
temp.path(),
"target-check",
"# Target Check\n\n### Requirement: Existing Feature\n\nOld behavior.\n",
);
create_strict_change_with_spec_delta(
&temp.path().join("openspec/changes/archive-gate-missing-target"),
"target-check",
"## MODIFIED Requirements\n\n### Requirement: Missing Feature\n\nUpdated behavior.\n\n#### Scenario: Missing feature updates\n- WHEN archive gate validation runs\n- THEN the missing target is rejected before archive\n",
);
let mgr = OpenSpecManager::new();
let (is_valid, errors, _warnings) =
mgr.validate_change(Some("archive-gate-missing-target"), true, "error");
assert!(!is_valid);
assert!(
errors.iter().any(|error| {
error.contains("MODIFIED target not found in canonical spec")
&& error.contains("### Requirement: Missing Feature")
}),
"archive-gate-equivalent validation should fail before archive: {errors:?}"
);
}
#[test]
fn test_archive_change_surfaces_missing_delta_target_during_validation() {
let temp = TempDir::new().unwrap();
let _guard = CwdTestGuard::enter(temp.path());
create_canonical_spec(
temp.path(),
"target-check",
"# Target Check\n\n### Requirement: Existing Feature\n\nOld behavior.\n",
);
create_strict_change_with_spec_delta(
&temp.path().join("openspec/changes/archive-missing-target"),
"target-check",
"## REMOVED Requirements\n\n### Requirement: Missing Feature\n\nRemoved behavior.\n\n#### Scenario: Missing feature removal\n- WHEN archive runs\n- THEN validation fails before promotion simulation\n",
);
let mgr = OpenSpecManager::new();
let err = mgr
.archive_change("archive-missing-target", false)
.expect_err("archive should stop at validation for missing canonical target");
assert!(err.contains("Validation failed"));
assert!(err.contains("REMOVED target not found in canonical spec"));
assert!(err.contains("### Requirement: Missing Feature"));
}
/// Removing every requirement of a capability deletes its canonical directory.
///
/// Promotion alone would leave a preamble-only `spec.md` behind, which still
/// advertises a capability that no longer defines any behavior.
#[test]
fn test_archive_removes_canonical_capability_left_without_requirements() {
let temp = TempDir::new().unwrap();
let _guard = CwdTestGuard::enter(temp.path());
create_canonical_spec(
temp.path(),
"obsolete-capability",
"# Obsolete Capability\n\n### Requirement: Only Feature\n\nOld behavior.\n",
);
create_strict_change_with_spec_delta(
&temp.path().join("openspec/changes/remove-only-feature"),
"obsolete-capability",
"## REMOVED Requirements\n\n### Requirement: Only Feature\n\nRemoved.\n\n#### Scenario: No only feature\n- WHEN the capability is inspected\n- THEN no requirement remains\n",
);
let mgr = OpenSpecManager::new();
let message = mgr
.archive_change("remove-only-feature", false)
.expect("archive should succeed");
assert!(message.contains("obsolete-capability"));
assert!(
!temp
.path()
.join("openspec/specs/obsolete-capability")
.exists(),
"a capability with no remaining requirements must not keep a canonical directory"
);
}
/// A capability that keeps at least one requirement is preserved.
#[test]
fn test_archive_keeps_canonical_capability_with_remaining_requirements() {
let temp = TempDir::new().unwrap();
let _guard = CwdTestGuard::enter(temp.path());
create_canonical_spec(
temp.path(),
"partial-capability",
"# Partial Capability\n\n### Requirement: Doomed Feature\n\nOld behavior.\n\n### Requirement: Kept Feature\n\nStays.\n",
);
create_strict_change_with_spec_delta(
&temp.path().join("openspec/changes/remove-doomed-feature"),
"partial-capability",
"## REMOVED Requirements\n\n### Requirement: Doomed Feature\n\nRemoved.\n\n#### Scenario: No doomed feature\n- WHEN the capability is inspected\n- THEN only the kept requirement remains\n",
);
let mgr = OpenSpecManager::new();
mgr.archive_change("remove-doomed-feature", false)
.expect("archive should succeed");
let canonical = fs::read_to_string(
temp.path()
.join("openspec/specs/partial-capability/spec.md"),
)
.expect("partially modified capability must keep its canonical spec");
assert!(canonical.contains("### Requirement: Kept Feature"));
assert!(!canonical.contains("### Requirement: Doomed Feature"));
}
#[test]
fn test_archive_change_creates_dated_destination_and_message() {
let temp = TempDir::new().unwrap();
let _guard = CwdTestGuard::enter(temp.path());
let change_id = "archive-target";
let change_dir = temp.path().join("openspec/changes").join(change_id);
create_strict_valid_change(&change_dir, "Archive Target");
let expected_verifications =
crate::openspec::parse_proposal_metadata_from_file(&change_dir.join("proposal.md"))
.verifications;
let mgr = OpenSpecManager::new();
let message = mgr
.archive_change(change_id, true)
.expect("archive should succeed with dated destination");
let expected_prefix = format!(
"Archived to openspec/changes/archive/{}-{}",
Local::now().format("%Y-%m-%d"),
change_id
);
assert!(message.starts_with(&expected_prefix));
let archive_dest = temp.path().join(format!(
"openspec/changes/archive/{}-{}",
Local::now().format("%Y-%m-%d"),
change_id
));
assert!(archive_dest.exists());
assert!(!change_dir.exists());
let metadata =
crate::openspec::parse_proposal_metadata_from_file(&archive_dest.join("proposal.md"));
assert_eq!(metadata.verifications, expected_verifications);
}
#[test]
fn test_archive_change_allows_archived_dependency_warning() {
let temp = TempDir::new().unwrap();
let _guard = CwdTestGuard::enter(temp.path());
let change_id = "archive-with-archived-dependency";
let change_dir = temp.path().join("openspec/changes").join(change_id);
create_strict_valid_change(&change_dir, "Archive With Archived Dependency");
let proposal = fs::read_to_string(change_dir.join("proposal.md")).unwrap();
fs::write(
change_dir.join("proposal.md"),
proposal.replacen("---\n", "---\ndependencies:\n - archived-dep\n", 1),
)
.unwrap();
create_change(
&temp
.path()
.join("openspec/changes/archive/2026-07-21-archived-dep"),
"Archived Dependency",
"- [x] done\n",
);
let mgr = OpenSpecManager::new();
let (is_valid, errors, warnings) = mgr.validate_change(Some(change_id), true, "error");
assert!(is_valid, "archive gate should allow advisory: {errors:?}");
assert!(warnings
.iter()
.any(|warning| warning.contains("archived dependency reference")));
mgr.archive_change(change_id, true)
.expect("archive should use the same failure policy as archive gate");
assert!(!change_dir.exists());
assert!(temp
.path()
.join(format!(
"openspec/changes/archive/{}-{change_id}",
Local::now().format("%Y-%m-%d")
))
.exists());
}
#[test]
fn test_archive_change_rejects_existing_dated_destination() {
let temp = TempDir::new().unwrap();
let _guard = CwdTestGuard::enter(temp.path());
let change_id = "already-archived";
let change_dir = temp.path().join("openspec/changes").join(change_id);
create_strict_valid_change(&change_dir, "Already Archived");
let existing_dest = temp.path().join(format!(
"openspec/changes/archive/{}-{}",
Local::now().format("%Y-%m-%d"),
change_id
));
fs::create_dir_all(&existing_dest).unwrap();
let mgr = OpenSpecManager::new();
let err = mgr
.archive_change(change_id, true)
.expect_err("archive should fail when dated destination already exists");
assert!(err.contains("Archive destination already exists"));
assert!(change_dir.exists());
}
#[test]
fn test_validate_change_classifies_archived_dependency_as_warning() {
let temp = TempDir::new().unwrap();
let _guard = CwdTestGuard::enter(temp.path());
let active_change = temp.path().join("openspec/changes/active-change");
create_change_with_frontmatter_dependencies(
&active_change,
"Active Change",
&["archived-dep"],
"- [ ] 1. task (verification: integration - cargo test)",
);
let archived_change = temp
.path()
.join("openspec/changes/archive/2026-04-29-archived-dep");
create_change(&archived_change, "Archived Dep", "- [x] done\n");
let mgr = OpenSpecManager::new();
let (is_valid, errors, warnings) = mgr.validate_change(Some("active-change"), false, "off");
assert!(is_valid);
assert!(errors.is_empty());
assert!(warnings
.iter()
.any(|w| w.contains("classified as archived dependency reference")));
}
#[test]
fn test_validate_change_reports_missing_dependency_as_error() {
let temp = TempDir::new().unwrap();
let _guard = CwdTestGuard::enter(temp.path());
let active_change = temp.path().join("openspec/changes/active-change");
create_change_with_frontmatter_dependencies(
&active_change,
"Active Change",
&["missing-dep"],
"- [ ] 1. task (verification: integration - cargo test)",
);
let mgr = OpenSpecManager::new();
let (is_valid, errors, warnings) = mgr.validate_change(Some("active-change"), false, "off");
assert!(!is_valid);
assert!(errors
.iter()
.any(|e| e.contains("missing-dep") && e.contains("invalid")));
assert!(warnings.is_empty());
}
}
#[cfg(test)]
mod cmd_integration_tests {
use super::*;
#[test]
fn test_cmd_list_runs_without_panic() {
// Just verify it doesn't panic in the project directory
let _ = cmd_list(false);
let _ = cmd_list(true);
}
#[test]
fn test_cmd_show_not_found() {
let result = cmd_show("nonexistent-change-xyz", false, false);
assert!(result.is_err());
}
#[test]
fn test_cmd_validate_all() {
// Should run without panic in the project directory
let (_, _) = cmd_validate(None, false, "off");
}
#[test]
fn test_cmd_validate_nonexistent() {
let (is_valid, _) = cmd_validate(Some("nonexistent-xyz"), false, "off");
assert!(!is_valid);
}
}