Skip to main content

speccoder_runtime/format/openspec/
mod.rs

1//! OpenSpec: a change in `openspec/changes/<id>/` is the unit of
2//! work, its spec deltas' scenarios are the acceptance examples, its
3//! `tasks.md` is a chain. Parsing and preflight are the `speccoder-openspec`
4//! crate's; this adapter is the only runtime code that knows OpenSpec.
5
6#[cfg(feature = "engine")]
7pub mod archive;
8mod bug;
9pub mod cli;
10mod texts;
11
12use std::path::{Path, PathBuf};
13
14use speccoder_core::ChecklistStatus;
15use speccoder_core::snapshot::{
16    Diagnostic, FeatureSnapshot, ParsedTasks, ProjectSnapshot, Severity,
17};
18use speccoder_core::spec::{FormatKind, ScenarioRef};
19
20pub use texts::{MESSAGES, PLANNING, RULES, WORDING};
21
22use crate::context::{Bundle, BundleError, DependencyContext, TaskContext, cap};
23use crate::format::{Adapter, CommitPrecondition, Detect, Messages, Planning, Rules, Wording};
24
25/// The OpenSpec adapter.
26#[derive(Debug)]
27pub struct OpenSpec;
28
29/// The one instance.
30pub static OPENSPEC: OpenSpec = OpenSpec;
31
32impl Detect for OpenSpec {
33    fn kind(&self) -> FormatKind {
34        FormatKind::Openspec
35    }
36
37    fn root(&self, start: &Path) -> Option<PathBuf> {
38        speccoder_openspec::discover::find_root(start)
39    }
40
41    fn adapter(&self) -> Option<&'static dyn Adapter> {
42        Some(&OPENSPEC)
43    }
44}
45
46/// The change's proposal and design, as the plan.
47fn plan_text(c: &speccoder_openspec::Change) -> String {
48    [("proposal.md", &c.proposal), ("design.md", &c.design)]
49        .iter()
50        .filter_map(|(name, text)| {
51            text.as_ref()
52                .map(|t| format!("## {}/{name}\n\n{}", c.dir, t.trim_end()))
53        })
54        .collect::<Vec<_>>()
55        .join("\n\n")
56}
57
58/// Every spec delta of the change, whole, as the intent of every task: the
59/// format does not link a task to a requirement.
60fn deltas_text(c: &speccoder_openspec::Change) -> String {
61    c.deltas
62        .iter()
63        .map(|d| format!("## {}\n\n{}", d.path, d.text.trim_end()))
64        .collect::<Vec<_>>()
65        .join("\n\n")
66}
67
68impl Adapter for OpenSpec {
69    fn tool(&self) -> Option<(&'static str, &'static str)> {
70        Some(("openspec", "npm install -g @fission-ai/openspec"))
71    }
72
73    fn kind(&self) -> FormatKind {
74        FormatKind::Openspec
75    }
76
77    fn detect(&self, start: &Path) -> Option<PathBuf> {
78        Detect::root(self, start)
79    }
80
81    fn inspect(&self, start: &Path, feature: Option<&str>) -> Option<ProjectSnapshot> {
82        speccoder_openspec::inspect(start, feature).ok()
83    }
84
85    fn parse_tasks(&self, path: &str, text: &str) -> (ParsedTasks, Vec<Diagnostic>) {
86        speccoder_openspec::tasks::parse(path, text)
87    }
88
89    fn rules(&self) -> &'static Rules {
90        &RULES
91    }
92
93    fn wording(&self) -> &'static Wording {
94        &WORDING
95    }
96
97    fn messages(&self) -> &'static Messages {
98        &MESSAGES
99    }
100
101    fn planning(&self) -> &'static Planning {
102        &PLANNING
103    }
104
105    fn bundle(&self, snapshot: &ProjectSnapshot, task_id: &str) -> Result<Bundle, BundleError> {
106        let feature: &FeatureSnapshot = snapshot.feature.as_ref().ok_or(BundleError::NoFeature)?;
107        let tasks = feature.tasks.as_ref().ok_or(BundleError::NoTasks)?;
108        let task = tasks
109            .tasks
110            .iter()
111            .find(|t| t.id == task_id)
112            .ok_or_else(|| BundleError::NotFound(task_id.to_owned()))?;
113        let root = Path::new(&snapshot.root);
114        let schema = snapshot
115            .openspec
116            .as_ref()
117            .map_or(speccoder_openspec::discover::SCHEMA, |m| m.schema.as_str());
118        let (change, _) = speccoder_openspec::load_change(root, &feature.feature.name, schema);
119        let context = snapshot
120            .openspec
121            .as_ref()
122            .and_then(|m| m.context.clone())
123            .unwrap_or_default();
124        let dependencies = feature
125            .schedule
126            .as_ref()
127            .and_then(|s| s.tasks.iter().find(|t| t.id == task.id))
128            .map(|st| {
129                st.depends_on
130                    .iter()
131                    .filter_map(|d| tasks.tasks.iter().find(|t| t.id == d.id))
132                    .map(|t| DependencyContext {
133                        id: t.id.clone(),
134                        title: t.title.clone(),
135                        done: t.done,
136                    })
137                    .collect()
138            })
139            .unwrap_or_default();
140        Ok(Bundle {
141            feature: feature.feature.name.clone(),
142            constitution: cap(&context),
143            plan: cap(&plan_text(&change)),
144            story: cap(&deltas_text(&change)),
145            task: TaskContext {
146                id: task.id.clone(),
147                title: task.title.clone(),
148                phase: task.phase.clone().unwrap_or_default(),
149                subsection: String::new(),
150                story: String::new(),
151                parallel: false,
152            },
153            dependencies,
154        })
155    }
156
157    /// Every scenario of the change's ADDED and MODIFIED requirements: the
158    /// format does not link a task to a requirement.
159    fn scenarios(&self, snapshot: &ProjectSnapshot, _task: &str) -> Vec<ScenarioRef> {
160        let Some(f) = snapshot.feature.as_ref() else {
161            return Vec::new();
162        };
163        let schema = snapshot
164            .openspec
165            .as_ref()
166            .map_or(speccoder_openspec::discover::SCHEMA, |m| m.schema.as_str());
167        let (change, _) =
168            speccoder_openspec::load_change(Path::new(&snapshot.root), &f.feature.name, schema);
169        change
170            .deltas
171            .iter()
172            .flat_map(|d| {
173                d.requirements
174                    .iter()
175                    .filter(|r| {
176                        matches!(
177                            r.operation,
178                            speccoder_openspec::delta::Operation::Added
179                                | speccoder_openspec::delta::Operation::Modified
180                        )
181                    })
182                    .flat_map(move |r| {
183                        r.scenarios.iter().map(move |sc| ScenarioRef {
184                            id: format!("{}: {} / {}", d.capability, r.name, sc.name),
185                            text: sc.raw.clone(),
186                        })
187                    })
188            })
189            .collect()
190    }
191
192    /// Each ADDED or MODIFIED delta requirement with its scenarios, keyed
193    /// `<capability>: <requirement>`.
194    fn oracle_requirements(
195        &self,
196        snapshot: &ProjectSnapshot,
197    ) -> Vec<crate::format::OracleRequirement> {
198        let Some(f) = snapshot.feature.as_ref() else {
199            return Vec::new();
200        };
201        let schema = snapshot
202            .openspec
203            .as_ref()
204            .map_or(speccoder_openspec::discover::SCHEMA, |m| m.schema.as_str());
205        let (change, _) =
206            speccoder_openspec::load_change(Path::new(&snapshot.root), &f.feature.name, schema);
207        change
208            .deltas
209            .iter()
210            .flat_map(|d| {
211                d.requirements
212                    .iter()
213                    .filter(|r| {
214                        matches!(
215                            r.operation,
216                            speccoder_openspec::delta::Operation::Added
217                                | speccoder_openspec::delta::Operation::Modified
218                        )
219                    })
220                    .map(move |r| crate::format::OracleRequirement {
221                        key: format!("{}: {}", d.capability, r.name),
222                        title: r.name.clone(),
223                        text: r.text.clone(),
224                        scenarios: r
225                            .scenarios
226                            .iter()
227                            .map(|sc| ScenarioRef {
228                                id: format!("{}: {} / {}", d.capability, r.name, sc.name),
229                                text: sc.raw.clone(),
230                            })
231                            .collect(),
232                    })
233            })
234            .collect()
235    }
236
237    /// The standing bug-fix change.
238    fn standing_units(&self, _root: &Path) -> Vec<(String, speccoder_core::ladder::Rung)> {
239        vec![("bugfixes".to_owned(), speccoder_core::ladder::Rung::Bug)]
240    }
241
242    /// A delta with a `#### Scenario:`: the scenarios are the examples.
243    fn acceptance_testable(&self, bundle: &Bundle) -> bool {
244        bundle.story.contains("#### Scenario:")
245    }
246
247    /// A Convergence group, appended by converge.
248    fn is_convergence(&self, phase: &str) -> bool {
249        phase.to_lowercase().contains("convergence")
250    }
251
252    fn convergence_texts(&self, root: &Path, unit_dir: &Path) -> [String; 4] {
253        let id = unit_dir
254            .file_name()
255            .map(|n| n.to_string_lossy().into_owned())
256            .unwrap_or_default();
257        let mut diags = Vec::new();
258        let config = speccoder_openspec::discover::config(root, &mut diags);
259        let (change, _) = speccoder_openspec::load_change(root, &id, &config.schema);
260        [
261            config.context.unwrap_or_default(),
262            deltas_text(&change),
263            plan_text(&change),
264            std::fs::read_to_string(unit_dir.join("tasks.md")).unwrap_or_default(),
265        ]
266    }
267
268    /// Errors of the change itself — preflight, task list, configuration —
269    /// stop a run before any model call. A selection problem is the
270    /// selection's; a dependency problem blocks only the tasks involved.
271    fn blocking<'s>(&self, snapshot: &'s ProjectSnapshot) -> Vec<&'s Diagnostic> {
272        snapshot
273            .diagnostics
274            .iter()
275            .filter(|d| d.severity == Severity::Error)
276            .filter(|d| !d.code.starts_with("select.") && !d.code.starts_with("graph."))
277            .collect()
278    }
279
280    fn crosscheck(
281        &self,
282        snapshot: &ProjectSnapshot,
283        runner: &dyn crate::verify::process::CommandRunner,
284    ) -> Vec<Diagnostic> {
285        cli::crosscheck(snapshot, runner)
286    }
287
288    fn checklists(&self, _root: &Path, _unit_dir: &str) -> Vec<ChecklistStatus> {
289        Vec::new()
290    }
291
292    fn bug_unit(
293        &self,
294        _root: &Path,
295    ) -> Result<speccoder_core::bug::BugHome, crate::format::BugRefusal> {
296        Ok(bug::unit())
297    }
298
299    fn record_bug(
300        &self,
301        root: &Path,
302        _home: &speccoder_core::bug::BugHome,
303        triad: &speccoder_core::bug::Triad,
304    ) -> Result<crate::format::RecordedBug, crate::format::BugRefusal> {
305        bug::record(root, triad)
306    }
307
308    fn read_bug(
309        &self,
310        root: &Path,
311        _home: &speccoder_core::bug::BugHome,
312        key: &str,
313    ) -> Result<crate::format::ReadBug, crate::format::BugRefusal> {
314        bug::read(root, key)
315    }
316
317    fn tick(&self, tasks_file: &Path, task: &str, line_hash: &str) -> Result<(), String> {
318        speccoder_openspec::write::tick(tasks_file, task, line_hash)
319    }
320
321    fn append(
322        &self,
323        tasks_file: &Path,
324        file_hash: &str,
325        items: &[String],
326    ) -> Result<Vec<String>, String> {
327        speccoder_openspec::write::append_convergence(tasks_file, file_hash, items)
328    }
329
330    fn commit_precondition(
331        &self,
332        root: &Path,
333        join: &dyn Fn(&str) -> String,
334        unit_dir: &str,
335    ) -> CommitPrecondition {
336        // The main specs the change's deltas touch: what archive will rewrite,
337        // and what the tests' intent is measured against.
338        let touched = speccoder_openspec::discover::spec_files(&root.join(unit_dir).join("specs"))
339            .into_iter()
340            .map(|(cap, _)| join(&format!("openspec/specs/{cap}/spec.md")));
341        CommitPrecondition {
342            strict: vec![join(unit_dir)],
343            tracked: std::iter::once(join("openspec/config.yaml"))
344                .chain(touched)
345                .collect(),
346            runtime: |_| false,
347            essentials: vec![format!("{}/tasks.md", join(unit_dir))],
348            message: "the change's OpenSpec files are not committed, so a worktree would not contain them",
349            hint: "commit them (git add openspec && git commit), or run in place: speccoder run --in-place",
350            no_commits_hint: "commit the OpenSpec files first (git add openspec && git commit)",
351        }
352    }
353}