Skip to main content

speccoder_openspec/
preflight.rs

1//! What `openspec archive` would refuse, found before any model call:
2//! `validate --strict` passes a MODIFIED requirement that does
3//! not exist, and only `archive` fails on it — after the work is done.
4
5use std::collections::{BTreeMap, BTreeSet};
6use std::path::Path;
7
8use speccoder_core::snapshot::{Diagnostic, Severity};
9
10use crate::Change;
11use crate::delta::{Operation, normalise};
12use crate::discover::{Config, DIR, SCHEMA};
13
14/// Problems of the project as a whole.
15pub fn project(root: &Path, config: &Config, diags: &mut Vec<Diagnostic>) {
16    let dir = root.join(DIR);
17    if !config.present && (dir.join("project.md").is_file() || dir.join("AGENTS.md").is_file()) {
18        diags.push(
19            Diagnostic::new(
20                Severity::Error,
21                "legacy_root",
22                "openspec/ has the pre-1.0 layout (project.md, no config.yaml)",
23            )
24            .at("openspec")
25            .hint("run `openspec update` to migrate the project"),
26        );
27    }
28    if config.store {
29        diags.push(
30            Diagnostic::new(
31                Severity::Error,
32                "store_root",
33                "openspec/config.yaml uses a shared store, which speccoder does not run",
34            )
35            .at("openspec/config.yaml")
36            .hint("keep the change in the repository's own openspec/ directory"),
37        );
38    }
39}
40
41/// Problems of one change, given the main specs' requirement names.
42pub fn change(c: &Change, main: &BTreeMap<String, BTreeSet<String>>, diags: &mut Vec<Diagnostic>) {
43    if c.schema != SCHEMA {
44        diags.push(
45            Diagnostic::new(
46                Severity::Error,
47                "unsupported_schema",
48                format!(
49                    "change {} uses schema `{}`; speccoder runs `{SCHEMA}`",
50                    c.id, c.schema
51                ),
52            )
53            .at(&c.dir)
54            .hint("use the default schema for changes speccoder implements"),
55        );
56    }
57    if c.deltas.is_empty() {
58        diags.push(
59            Diagnostic::new(
60                Severity::Error,
61                "no_deltas",
62                format!(
63                    "change {} has no spec deltas: nothing to derive tests from",
64                    c.id
65                ),
66            )
67            .at(&c.dir)
68            .hint("add specs/<capability>/spec.md with ADDED or MODIFIED requirements"),
69        );
70    }
71    for d in &c.deltas {
72        let existing = main.get(&d.capability);
73        let exists = |name: &str| existing.is_some_and(|e| e.contains(&normalise(name)));
74        let renamed_from: BTreeSet<String> = d.renames.iter().map(|r| normalise(&r.from)).collect();
75        let renamed_to: BTreeSet<String> = d.renames.iter().map(|r| normalise(&r.to)).collect();
76        let at = |line: u32| (d.path.clone(), line);
77        let mut push =
78            |severity, code: &str, (path, line): (String, u32), message: String, hint: &str| {
79                diags.push(
80                    Diagnostic::new(severity, code, message)
81                        .at(path)
82                        .line(line)
83                        .hint(hint),
84                );
85            };
86        for r in &d.requirements {
87            match r.operation {
88                Operation::Modified | Operation::Removed => {
89                    let name = normalise(&r.name);
90                    if renamed_from.contains(&name) {
91                        push(
92                            Severity::Error,
93                            "renamed_conflict",
94                            at(r.line),
95                            format!(
96                                "{} {} `{}`, which this change also renames",
97                                d.capability,
98                                r.operation.name().to_uppercase(),
99                                r.name
100                            ),
101                            "refer to the requirement by its new name",
102                        );
103                    } else if !exists(&r.name) && !renamed_to.contains(&name) {
104                        push(
105                            Severity::Error,
106                            "missing_target",
107                            at(r.line),
108                            format!(
109                                "{} {} `{}`: no such requirement in openspec/specs/{}/spec.md; archive would refuse this delta",
110                                d.capability,
111                                r.operation.name().to_uppercase(),
112                                r.name,
113                                d.capability
114                            ),
115                            "use the requirement's exact name, or ADD it instead",
116                        );
117                    }
118                }
119                Operation::Added | Operation::Renamed => {}
120            }
121            if matches!(r.operation, Operation::Added | Operation::Modified)
122                && r.scenarios.is_empty()
123            {
124                push(
125                    Severity::Error,
126                    "missing_scenario",
127                    at(r.line),
128                    format!("requirement `{}` has no `#### Scenario:`", r.name),
129                    "add at least one scenario: it is what the tests are derived from",
130                );
131            }
132            if r.operation == Operation::Removed && !(r.reason && r.migration) {
133                push(
134                    Severity::Warning,
135                    "removed_without_reason",
136                    at(r.line),
137                    format!("REMOVED `{}` has no **Reason** or **Migration**", r.name),
138                    "say why it goes and what replaces it",
139                );
140            }
141        }
142        for n in &d.renames {
143            if !exists(&n.from) {
144                push(
145                    Severity::Error,
146                    "missing_target",
147                    at(n.line),
148                    format!(
149                        "{} RENAMED `{}`: no such requirement in openspec/specs/{}/spec.md; archive would refuse this delta",
150                        d.capability, n.from, d.capability
151                    ),
152                    "use the requirement's exact current name after FROM:",
153                );
154            }
155        }
156        if existing.is_none() && !d.purpose {
157            push(
158                Severity::Warning,
159                "purpose_missing",
160                (d.path.clone(), 1),
161                format!(
162                    "new capability `{}` has no `## Purpose`; archive will write a TBD placeholder",
163                    d.capability
164                ),
165                "add a `## Purpose` section to the delta",
166            );
167        }
168    }
169}