speccoder_runtime/format/kiro/
mod.rs1mod 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#[derive(Debug)]
25pub struct Kiro;
26
27pub 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 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 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 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 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 fn acceptance_testable(&self, bundle: &Bundle) -> bool {
209 bundle.story.to_lowercase().contains("acceptance criteria")
210 }
211
212 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 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 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 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 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 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 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}