1use 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
14pub 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
41pub 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}