Skip to main content

speccoder_runtime/format/speckit/
mod.rs

1//! GitHub Spec Kit, the reference format: `.specify/` and `specs/NNN-name/`
2//! with `spec.md`, `plan.md` and `tasks.md`. Parsing is the
3//! `speccoder-speckit` crate's; this adapter is the only runtime code that
4//! knows Spec Kit's paths and words.
5
6#[cfg(feature = "engine")]
7pub mod bench;
8pub mod bug;
9pub mod extensions;
10pub mod hooks;
11mod quick;
12mod texts;
13pub mod workflow;
14
15use std::path::{Path, PathBuf};
16
17use speccoder_core::ChecklistStatus;
18use speccoder_core::snapshot::{Diagnostic, FeatureSnapshot, ParsedTasks, ProjectSnapshot};
19use speccoder_core::spec::{FormatKind, ScenarioRef};
20
21pub use speccoder_speckit::stories::story_section;
22pub use speccoder_speckit::workspace::Workspace;
23pub use texts::{MESSAGES, PLANNING, RULES, WORDING};
24
25use crate::context::{Bundle, BundleError, DependencyContext, TaskContext, cap};
26use crate::format::{Adapter, CommitPrecondition, Detect, Messages, Planning, Rules, Wording};
27
28/// The Spec Kit adapter.
29#[derive(Debug)]
30pub struct SpecKit;
31
32/// The one instance.
33pub static SPECKIT: SpecKit = SpecKit;
34
35impl Detect for SpecKit {
36    fn kind(&self) -> FormatKind {
37        FormatKind::Speckit
38    }
39
40    fn root(&self, start: &Path) -> Option<PathBuf> {
41        speccoder_speckit::workspace::find_root(start).map(|ws| ws.root().to_path_buf())
42    }
43
44    fn adapter(&self) -> Option<&'static dyn Adapter> {
45        Some(&SPECKIT)
46    }
47}
48
49impl Adapter for SpecKit {
50    fn tool(&self) -> Option<(&'static str, &'static str)> {
51        Some((
52            "specify",
53            "uv tool install specify-cli --from git+https://github.com/github/spec-kit.git",
54        ))
55    }
56
57    fn kind(&self) -> FormatKind {
58        FormatKind::Speckit
59    }
60
61    fn detect(&self, start: &Path) -> Option<PathBuf> {
62        Detect::root(self, start)
63    }
64
65    fn inspect(&self, start: &Path, feature: Option<&str>) -> Option<ProjectSnapshot> {
66        speccoder_speckit::inspect(start, feature).ok()
67    }
68
69    fn open(&self, start: &Path, feature: Option<&str>) -> Option<ProjectSnapshot> {
70        let env = std::env::var(texts::FEATURE_ENV)
71            .ok()
72            .filter(|v| !v.is_empty());
73        speccoder_speckit::inspect_with(start, feature, env.as_deref()).ok()
74    }
75
76    fn parse_tasks(&self, path: &str, text: &str) -> (ParsedTasks, Vec<Diagnostic>) {
77        speccoder_speckit::tasks::parse(path, text)
78    }
79
80    fn rules(&self) -> &'static Rules {
81        &RULES
82    }
83
84    fn wording(&self) -> &'static Wording {
85        &WORDING
86    }
87
88    fn messages(&self) -> &'static Messages {
89        &MESSAGES
90    }
91
92    fn planning(&self) -> &'static Planning {
93        &PLANNING
94    }
95
96    fn current_pointer(&self) -> Option<&'static str> {
97        Some(texts::POINTER)
98    }
99
100    fn set_current(&self, root: &Path, unit_dir: Option<&str>) -> Result<String, String> {
101        // Spec Kit's own per-checkout pointer (git-ignored by `.specify/.gitignore`).
102        let file = root.join(texts::POINTER);
103        match unit_dir {
104            Some(dir) => {
105                let text =
106                    serde_json::to_string_pretty(&serde_json::json!({ "feature_directory": dir }))
107                        .map_err(|e| e.to_string())?;
108                std::fs::write(&file, format!("{text}\n"))
109                    .map_err(|e| format!("{}: {e}", file.display()))?;
110            }
111            None => match std::fs::remove_file(&file) {
112                Ok(()) => {}
113                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
114                Err(e) => return Err(format!("{}: {e}", file.display())),
115            },
116        }
117        Ok(texts::POINTER.to_owned())
118    }
119
120    fn bundle(&self, snapshot: &ProjectSnapshot, task_id: &str) -> Result<Bundle, BundleError> {
121        let feature: &FeatureSnapshot = snapshot.feature.as_ref().ok_or(BundleError::NoFeature)?;
122        let tasks = feature.tasks.as_ref().ok_or(BundleError::NoTasks)?;
123        let task = tasks
124            .tasks
125            .iter()
126            .find(|t| t.id == task_id)
127            .ok_or_else(|| BundleError::NotFound(task_id.to_owned()))?;
128        let ws = Workspace::at(Path::new(&snapshot.root));
129        let read = |rel: &str| {
130            ws.as_ref()
131                .and_then(|ws| ws.read_text(rel).ok())
132                .map(|t| cap(&t))
133                .unwrap_or_default()
134        };
135        let dir = &feature.feature.dir;
136        let spec = read(&format!("{dir}/spec.md"));
137
138        let dependencies = feature
139            .schedule
140            .as_ref()
141            .and_then(|s| s.tasks.iter().find(|t| t.id == task.id))
142            .map(|st| {
143                st.depends_on
144                    .iter()
145                    .filter_map(|d| tasks.tasks.iter().find(|t| t.id == d.id))
146                    .map(|t| DependencyContext {
147                        id: t.id.clone(),
148                        title: t.title.clone(),
149                        done: t.done,
150                    })
151                    .collect()
152            })
153            .unwrap_or_default();
154
155        Ok(Bundle {
156            feature: feature.feature.name.clone(),
157            constitution: read(texts::CONSTITUTION),
158            plan: read(&format!("{dir}/plan.md")),
159            // A Convergence task closes a gap found against the whole spec; it
160            // gets the whole spec, so its acceptance tests have a source.
161            story: match task.story.as_deref() {
162                // A labelled task whose `### User Story N` section is missing
163                // (a spec that left the template's headings) still has the spec
164                // as its intent: acceptance tests are derived from all of it.
165                Some(label) => {
166                    let section = story_section(&spec, label);
167                    if section.trim().is_empty() {
168                        spec.clone()
169                    } else {
170                        section
171                    }
172                }
173                None if self.is_convergence(task.phase.as_deref().unwrap_or_default()) => {
174                    spec.clone()
175                }
176                None => String::new(),
177            },
178            task: TaskContext {
179                id: task.id.clone(),
180                title: task.title.clone(),
181                phase: task.phase.clone().unwrap_or_default(),
182                subsection: task.subsection.clone().unwrap_or_default(),
183                story: task.story.clone().unwrap_or_default(),
184                parallel: task.parallel,
185            },
186            dependencies,
187        })
188    }
189
190    fn scenarios(&self, snapshot: &ProjectSnapshot, task: &str) -> Vec<ScenarioRef> {
191        let Some(f) = snapshot.feature.as_ref() else {
192            return Vec::new();
193        };
194        let label = f
195            .tasks
196            .as_ref()
197            .and_then(|t| t.tasks.iter().find(|t| t.id == task))
198            .and_then(|t| t.story.clone());
199        let spec = Workspace::at(Path::new(&snapshot.root))
200            .and_then(|ws| ws.read_text(&format!("{}/spec.md", f.feature.dir)).ok())
201            .unwrap_or_default();
202        // A labelled task whose story is missing has the whole spec as its
203        // intent, as in its bundle.
204        let own = speccoder_speckit::stories::scenario_refs(&spec, label.as_deref());
205        if own.is_empty() && label.is_some() {
206            speccoder_speckit::stories::scenario_refs(&spec, None)
207        } else {
208            own
209        }
210    }
211
212    fn acceptance_testable(&self, bundle: &Bundle) -> bool {
213        bundle.story.to_lowercase().contains("acceptance")
214    }
215
216    /// Each user story with its scenarios, keyed `USn`, citing `USn-k`.
217    fn oracle_requirements(
218        &self,
219        snapshot: &ProjectSnapshot,
220    ) -> Vec<crate::format::OracleRequirement> {
221        let Some(f) = snapshot.feature.as_ref() else {
222            return Vec::new();
223        };
224        let spec = Workspace::at(Path::new(&snapshot.root))
225            .and_then(|ws| ws.read_text(&format!("{}/spec.md", f.feature.dir)).ok())
226            .unwrap_or_default();
227        let (stories, _) = speccoder_speckit::stories::requirements("spec.md", &spec);
228        stories
229            .into_iter()
230            .map(|r| crate::format::OracleRequirement {
231                scenarios: speccoder_speckit::stories::scenario_refs(&spec, Some(&r.key)),
232                key: r.key,
233                title: r.title,
234                text: r.text,
235            })
236            .collect()
237    }
238
239    /// `NNN-quick-changes` and `NNN-bugfixes`.
240    fn standing_units(&self, root: &Path) -> Vec<(String, speccoder_core::ladder::Rung)> {
241        use speccoder_core::ladder::Rung;
242        let mut out: Vec<(String, Rung)> =
243            std::fs::read_dir(root.join(speccoder_speckit::features::SPECS))
244                .into_iter()
245                .flatten()
246                .flatten()
247                .map(|e| e.file_name().to_string_lossy().into_owned())
248                .filter(|n| speccoder_speckit::features::prefix(n).is_some())
249                .filter_map(|n| {
250                    let rung = if n.ends_with("-quick-changes") {
251                        Rung::Quick
252                    } else if n.ends_with("-bugfixes") {
253                        Rung::Bug
254                    } else {
255                        return None;
256                    };
257                    Some((n, rung))
258                })
259                .collect();
260        out.push((".specify/bugs".to_owned(), Rung::Bug));
261        out
262    }
263
264    /// A Convergence phase, appended by converge.
265    fn is_convergence(&self, phase: &str) -> bool {
266        phase.to_lowercase().contains("convergence")
267    }
268
269    fn convergence_texts(&self, root: &Path, unit_dir: &Path) -> [String; 4] {
270        let read = |p: PathBuf| std::fs::read_to_string(p).unwrap_or_default();
271        [
272            read(root.join(texts::CONSTITUTION)),
273            read(unit_dir.join("spec.md")),
274            read(unit_dir.join("plan.md")),
275            read(unit_dir.join("tasks.md")),
276        ]
277    }
278
279    fn checklists(&self, root: &Path, unit_dir: &str) -> Vec<ChecklistStatus> {
280        speccoder_speckit::checklists::status(root, unit_dir)
281    }
282
283    fn requirements_file(&self) -> Option<&'static str> {
284        Some("spec.md")
285    }
286
287    fn record_change(
288        &self,
289        root: &Path,
290        request: &str,
291        checks: &[String],
292    ) -> Result<super::Recorded, String> {
293        quick::record(root, request, checks)
294    }
295
296    fn extensions(&self, root: &Path) -> Option<crate::format::Inventory> {
297        extensions::inventory(root)
298    }
299
300    fn bug_unit(
301        &self,
302        root: &Path,
303    ) -> Result<speccoder_core::bug::BugHome, crate::format::BugRefusal> {
304        bug::unit(root)
305    }
306
307    fn record_bug(
308        &self,
309        root: &Path,
310        home: &speccoder_core::bug::BugHome,
311        triad: &speccoder_core::bug::Triad,
312    ) -> Result<crate::format::RecordedBug, crate::format::BugRefusal> {
313        bug::record(root, home, triad)
314    }
315
316    fn bug_step(&self) -> Option<crate::format::BugStep> {
317        Some(bug::STEP)
318    }
319
320    fn bug_dir(&self, home: &speccoder_core::bug::BugHome, slug: &str) -> Option<(String, String)> {
321        bug::dir(home, slug)
322    }
323
324    fn bug_slug(&self, root: &Path, _home: &speccoder_core::bug::BugHome, title: &str) -> String {
325        bug::slug(root, title)
326    }
327
328    fn read_bug(
329        &self,
330        root: &Path,
331        home: &speccoder_core::bug::BugHome,
332        key: &str,
333    ) -> Result<crate::format::ReadBug, crate::format::BugRefusal> {
334        bug::read(root, home, key)
335    }
336
337    fn requirements_in(&self, text: &str) -> Vec<(String, String)> {
338        // `- **FR-001**: …` and `- **SC-001**: …` define the identifiers.
339        text.lines()
340            .filter_map(|line| {
341                let rest = line.trim_start().strip_prefix("- **")?;
342                let id = rest.split("**").next()?;
343                let (kind, number) = id.split_once('-')?;
344                (matches!(kind, "FR" | "SC")
345                    && !number.is_empty()
346                    && number.chars().all(|c| c.is_ascii_digit()))
347                .then(|| (id.to_owned(), line.trim_end().to_owned()))
348            })
349            .collect()
350    }
351
352    fn tick(&self, tasks_file: &Path, task: &str, line_hash: &str) -> Result<(), String> {
353        speccoder_speckit::mark_task_complete(tasks_file, task, line_hash)
354    }
355
356    fn append(
357        &self,
358        tasks_file: &Path,
359        file_hash: &str,
360        items: &[String],
361    ) -> Result<Vec<String>, String> {
362        speccoder_speckit::append_convergence(tasks_file, file_hash, items)
363    }
364
365    fn commit_precondition(
366        &self,
367        _root: &Path,
368        join: &dyn Fn(&str) -> String,
369        unit_dir: &str,
370    ) -> CommitPrecondition {
371        precondition(&join(texts::MARKER), &join(unit_dir))
372    }
373}
374
375/// What a Spec Kit worktree needs committed, given the repository-relative
376/// `.specify` and feature directories.
377#[must_use]
378pub fn precondition(specify_dir: &str, feature_dir: &str) -> CommitPrecondition {
379    CommitPrecondition {
380        // The feature directory: nothing untracked, ignored or modified.
381        strict: vec![feature_dir.to_owned()],
382        // .specify: Spec Kit ignores its own per-checkout files (feature.json);
383        // those are fine. Untracked or modified files are not.
384        tracked: vec![specify_dir.to_owned()],
385        runtime,
386        essentials: vec![
387            format!("{specify_dir}/init-options.json"),
388            format!("{feature_dir}/spec.md"),
389        ],
390        message: "the feature's Spec Kit artifacts are not committed, so a worktree would not contain them",
391        hint: "commit them (git add .specify specs/<feature> && git commit), or run in place: speccoder run --in-place",
392        no_commits_hint: "commit the Spec Kit artifacts first (git add .specify specs && git commit)",
393    }
394}
395
396/// Runtime state is not an artifact: Spec Kit's own workflow runs
397/// (`.specify/workflows/runs/`, dirty while `specify workflow run` drives
398/// speccoder) and Python bytecode caches of installed step types.
399fn runtime(p: &str) -> bool {
400    p.contains("/workflows/runs/")
401        || p.starts_with(".specify/workflows/runs/")
402        || p.split('/').any(|c| c == "__pycache__")
403}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408
409    #[test]
410    fn requirement_ids_are_the_bold_fr_and_sc_items() {
411        let dir = tempfile::tempdir().unwrap();
412        std::fs::create_dir_all(dir.path().join("specs/001-x")).unwrap();
413        std::fs::write(
414            dir.path().join("specs/001-x/spec.md"),
415            "## Requirements\n\n- **FR-001**: System MUST parse.\n- **FR-002**: System MUST render.\n  - **FR-X**: not one\n- **Key**: not one\n\n## Success\n\n- **SC-001**: Under a second.\n",
416        )
417        .unwrap();
418        let ids: Vec<String> = SpecKit
419            .requirement_ids(dir.path(), "specs/001-x")
420            .into_iter()
421            .map(|(id, hash)| {
422                assert_eq!(hash.len(), 64);
423                id
424            })
425            .collect();
426        assert_eq!(ids, ["FR-001", "FR-002", "SC-001"]);
427    }
428}