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