Skip to main content

speccoder_runtime/format/kiro/
mod.rs

1//! Kiro (and cc-sdd): a spec in `.kiro/specs/<name>/` is the unit of work.
2//! Each `### Requirement N` is a story and its numbered EARS criteria
3//! (`N.M`) are its scenarios; a task's `_Requirements: 1.2, 1.3_` are the
4//! criteria that judge it. Parent tasks are closed by the engine with their
5//! last required sub-task; optional (`[ ]*`) tasks are left alone. A
6//! bugfix spec is a bug of the format's own bug structure.
7//! Reading and the writes are the `speccoder-kiro` crate's; this
8//! adapter is the only runtime code that knows Kiro.
9
10mod texts;
11
12use std::path::{Path, PathBuf};
13
14use speccoder_core::ChecklistStatus;
15use speccoder_core::snapshot::{Diagnostic, ParsedTasks, ProjectSnapshot, Severity};
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 Kiro adapter.
24#[derive(Debug)]
25pub struct Kiro;
26
27/// The one instance.
28pub static KIRO: Kiro = Kiro;
29
30impl Detect for Kiro {
31    fn kind(&self) -> FormatKind {
32        FormatKind::Kiro
33    }
34
35    fn root(&self, start: &Path) -> Option<PathBuf> {
36        speccoder_kiro::find_root(start)
37    }
38
39    fn adapter(&self) -> Option<&'static dyn Adapter> {
40        Some(&KIRO)
41    }
42}
43
44fn spec_name(snapshot: &ProjectSnapshot) -> Option<&str> {
45    snapshot.feature.as_ref().map(|f| f.feature.name.as_str())
46}
47
48impl Adapter for Kiro {
49    fn kind(&self) -> FormatKind {
50        FormatKind::Kiro
51    }
52
53    fn detect(&self, start: &Path) -> Option<PathBuf> {
54        Detect::root(self, start)
55    }
56
57    fn inspect(&self, start: &Path, feature: Option<&str>) -> Option<ProjectSnapshot> {
58        speccoder_kiro::inspect(start, feature).ok()
59    }
60
61    fn parse_tasks(&self, path: &str, text: &str) -> (ParsedTasks, Vec<Diagnostic>) {
62        let (t, d) = speccoder_kiro::parse::tasks(path, text);
63        (t.parsed, d)
64    }
65
66    fn rules(&self) -> &'static Rules {
67        &RULES
68    }
69
70    fn wording(&self) -> &'static Wording {
71        &WORDING
72    }
73
74    fn messages(&self) -> &'static Messages {
75        &MESSAGES
76    }
77
78    fn planning(&self) -> &'static Planning {
79        &PLANNING
80    }
81
82    fn bundle(&self, snapshot: &ProjectSnapshot, task_id: &str) -> Result<Bundle, BundleError> {
83        let feature = snapshot.feature.as_ref().ok_or(BundleError::NoFeature)?;
84        let tasks = feature.tasks.as_ref().ok_or(BundleError::NoTasks)?;
85        let task = tasks
86            .tasks
87            .iter()
88            .find(|t| t.id == task_id)
89            .ok_or_else(|| BundleError::NotFound(task_id.to_owned()))?;
90        let root = Path::new(&snapshot.root);
91        let spec = speccoder_kiro::load(root, &feature.feature.name);
92        // The requirements its criteria belong to; all of them when it
93        // cites none (setup, a checkpoint).
94        let cited = |r: &&speccoder_kiro::parse::Requirement| {
95            task.scenarios.is_empty() || r.criteria.iter().any(|c| task.scenarios.contains(&c.id))
96        };
97        let story = spec
98            .requirements
99            .iter()
100            .filter(cited)
101            .map(|r| r.text.trim_end().to_owned())
102            .collect::<Vec<_>>()
103            .join("\n\n");
104        let context = |t: &speccoder_core::snapshot::Task| DependencyContext {
105            id: t.id.clone(),
106            title: t.title.clone(),
107            done: t.done,
108        };
109        let dependencies = feature
110            .schedule
111            .as_ref()
112            .and_then(|s| s.tasks.iter().find(|t| t.id == task.id))
113            .map(|st| {
114                st.depends_on
115                    .iter()
116                    .filter_map(|d| tasks.tasks.iter().find(|t| t.id == d.id))
117                    .map(context)
118                    .collect()
119            })
120            .unwrap_or_default();
121        // The tasks on the same requirements.
122        let number = |c: &String| c.split('.').next().unwrap_or_default().to_owned();
123        let mine: std::collections::BTreeSet<String> = task.scenarios.iter().map(number).collect();
124        let story_tasks = tasks
125            .tasks
126            .iter()
127            .filter(|t| t.id != task.id && t.scenarios.iter().any(|c| mine.contains(&number(c))))
128            .map(context)
129            .collect();
130        Ok(Bundle {
131            feature: feature.feature.name.clone(),
132            constitution: cap(&speccoder_kiro::steering_text(root)),
133            plan: cap(spec.design.as_deref().unwrap_or_default()),
134            story: cap(&story),
135            task: TaskContext {
136                id: task.id.clone(),
137                title: task.title.clone(),
138                phase: task.phase.clone().unwrap_or_default(),
139                subsection: String::new(),
140                story: String::new(),
141                parallel: task.parallel,
142            },
143            dependencies,
144            story_tasks,
145        })
146    }
147
148    /// Every acceptance criterion of the spec, by its `N.M`; a bugfix
149    /// spec's statements, which its tasks cite the same way.
150    fn scenarios(&self, snapshot: &ProjectSnapshot, _task: &str) -> Vec<ScenarioRef> {
151        let Some(name) = spec_name(snapshot) else {
152            return Vec::new();
153        };
154        let root = Path::new(&snapshot.root);
155        let spec = speccoder_kiro::load(root, name);
156        if spec.bugfix
157            && let Ok(b) = speccoder_kiro::read_bugfix(root, name)
158        {
159            return [b.current, b.expected, b.unchanged]
160                .into_iter()
161                .flatten()
162                .map(|s| ScenarioRef {
163                    id: s.id,
164                    text: s.text,
165                })
166                .collect();
167        }
168        spec.requirements
169            .iter()
170            .flat_map(|r| {
171                r.criteria.iter().map(|c| ScenarioRef {
172                    id: c.id.clone(),
173                    text: c.text.clone(),
174                })
175            })
176            .collect()
177    }
178
179    /// Each requirement with its criteria: the story a task's citations
180    /// are judged in.
181    fn oracle_requirements(
182        &self,
183        snapshot: &ProjectSnapshot,
184    ) -> Vec<crate::format::OracleRequirement> {
185        let Some(name) = spec_name(snapshot) else {
186            return Vec::new();
187        };
188        speccoder_kiro::load(Path::new(&snapshot.root), name)
189            .requirements
190            .iter()
191            .map(|r| crate::format::OracleRequirement {
192                key: r.key(),
193                title: r.title.clone(),
194                text: r.text.clone(),
195                scenarios: r
196                    .criteria
197                    .iter()
198                    .map(|c| ScenarioRef {
199                        id: c.id.clone(),
200                        text: c.text.clone(),
201                    })
202                    .collect(),
203            })
204            .collect()
205    }
206
207    /// Requirements with acceptance criteria: the criteria are the examples.
208    fn acceptance_testable(&self, bundle: &Bundle) -> bool {
209        bundle.story.to_lowercase().contains("acceptance criteria")
210    }
211
212    /// A Convergence task, appended by converge.
213    fn is_convergence(&self, phase: &str) -> bool {
214        phase.to_lowercase().contains("convergence")
215    }
216
217    fn convergence_texts(&self, root: &Path, unit_dir: &Path) -> [String; 4] {
218        let read = |f: &str| std::fs::read_to_string(unit_dir.join(f)).unwrap_or_default();
219        [
220            speccoder_kiro::steering_text(root),
221            read("requirements.md"),
222            read("design.md"),
223            read("tasks.md"),
224        ]
225    }
226
227    /// Errors of the spec itself stop a run before any model call; a
228    /// selection problem is the selection's, a dependency problem blocks
229    /// only the tasks involved.
230    fn blocking<'s>(&self, snapshot: &'s ProjectSnapshot) -> Vec<&'s Diagnostic> {
231        snapshot
232            .diagnostics
233            .iter()
234            .filter(|d| d.severity == Severity::Error)
235            .filter(|d| !d.code.starts_with("select.") && !d.code.starts_with("graph."))
236            .collect()
237    }
238
239    fn crosscheck(
240        &self,
241        _snapshot: &ProjectSnapshot,
242        _runner: &dyn crate::verify::process::CommandRunner,
243    ) -> Vec<Diagnostic> {
244        Vec::new()
245    }
246
247    fn checklists(&self, _root: &Path, _unit_dir: &str) -> Vec<ChecklistStatus> {
248        Vec::new()
249    }
250
251    /// Kiro keeps each bug as a bugfix spec: the format's own bug
252    /// structure, written in Kiro (speccoder has no step to record one).
253    fn bug_unit(
254        &self,
255        _root: &Path,
256    ) -> Result<speccoder_core::bug::BugHome, crate::format::BugRefusal> {
257        Ok(speccoder_core::bug::BugHome {
258            kind: speccoder_core::bug::HomeKind::NativeKiroBugfix,
259            location: speccoder_kiro::SPECS.to_owned(),
260            reason: "a Kiro bugfix spec".to_owned(),
261        })
262    }
263
264    fn bug_dir(&self, home: &speccoder_core::bug::BugHome, slug: &str) -> Option<(String, String)> {
265        (home.kind == speccoder_core::bug::HomeKind::NativeKiroBugfix
266            && speccoder_core::bug::valid_slug(slug))
267        .then(|| (format!("{}/{slug}", speccoder_kiro::SPECS), slug.to_owned()))
268    }
269
270    /// `bugfix.md`'s statements: every Current one, joined, is the symptom.
271    fn read_bug(
272        &self,
273        root: &Path,
274        home: &speccoder_core::bug::BugHome,
275        key: &str,
276    ) -> Result<crate::format::ReadBug, crate::format::BugRefusal> {
277        let refuse = |m: String| crate::format::BugRefusal::new("BUG-ASSESSMENT-UNREADABLE", m);
278        let Some((dir, _)) = self.bug_dir(home, key) else {
279            return Err(refuse(format!("`{key}` is not a bugfix spec's name")));
280        };
281        let mut p = root.to_path_buf();
282        for part in dir.split('/').chain(["bugfix.md"]) {
283            p.push(part);
284            if std::fs::symlink_metadata(&p).is_ok_and(|m| m.file_type().is_symlink()) {
285                return Err(refuse(format!(
286                    "{dir} is reached through a symbolic link: it is not read"
287                )));
288            }
289        }
290        let b = speccoder_kiro::read_bugfix(root, key).map_err(refuse)?;
291        let texts = |s: &[speccoder_kiro::bugfix::Statement]| -> Vec<String> {
292            s.iter().map(|x| x.text.clone()).collect()
293        };
294        Ok(crate::format::ReadBug {
295            current: vec![texts(&b.current).join(" ")],
296            expected: texts(&b.expected),
297            unchanged: texts(&b.unchanged),
298            title: Some(speccoder_core::words::titled(key)),
299            file: format!("{dir}/bugfix.md"),
300        })
301    }
302
303    /// The selected spec, when it is a bugfix spec.
304    fn bug_spec(&self, snapshot: &ProjectSnapshot) -> Option<String> {
305        let name = spec_name(snapshot)?;
306        speccoder_kiro::load(Path::new(&snapshot.root), name)
307            .bugfix
308            .then(|| name.to_owned())
309    }
310
311    /// A verified fix closes the bugfix spec's task list.
312    fn close_bug(&self, dir: &Path) -> Result<Vec<String>, String> {
313        let tasks = dir.join("tasks.md");
314        if tasks.is_file() {
315            speccoder_kiro::write::close_all(&tasks)
316        } else {
317            Ok(Vec::new())
318        }
319    }
320
321    /// The requirements (or the bugfix) as the living spec, the design as
322    /// the plan.
323    fn documents(&self) -> &'static [(&'static str, &'static str)] {
324        &[
325            ("spec", "requirements.md"),
326            ("bugfix", "bugfix.md"),
327            ("plan", "design.md"),
328            ("tasks", "tasks.md"),
329        ]
330    }
331
332    fn tick(&self, tasks_file: &Path, task: &str, line_hash: &str) -> Result<(), String> {
333        speccoder_kiro::write::tick(tasks_file, task, line_hash)
334    }
335
336    fn append(
337        &self,
338        tasks_file: &Path,
339        file_hash: &str,
340        items: &[String],
341    ) -> Result<Vec<String>, String> {
342        speccoder_kiro::write::append_convergence(tasks_file, file_hash, items)
343    }
344
345    fn commit_precondition(
346        &self,
347        _root: &Path,
348        join: &dyn Fn(&str) -> String,
349        unit_dir: &str,
350    ) -> CommitPrecondition {
351        CommitPrecondition {
352            strict: vec![join(unit_dir)],
353            tracked: vec![join(".kiro/steering")],
354            runtime: |_| false,
355            essentials: vec![format!("{}/tasks.md", join(unit_dir))],
356            message: "the spec's Kiro files are not committed, so a worktree would not contain them",
357            hint: "commit them (git add .kiro && git commit), or run in place: speccoder run --in-place",
358            no_commits_hint: "commit the Kiro files first (git add .kiro && git commit)",
359        }
360    }
361}