Skip to main content

speccoder_kiro/
lib.rs

1//! Kiro adapter for speccoder.
2//!
3//! Finds a Kiro (or cc-sdd) project and its specs in `.kiro/specs/<name>/`,
4//! reads a spec's `requirements.md` (each `### Requirement N` with its
5//! numbered EARS criteria `N.M`) or a bugfix spec's `bugfix.md`
6//! ([`bugfix`]), its `tasks.md` line by line (`[ ]*` is not a Markdown
7//! task), cc-sdd's approvals and the steering as Kiro includes it.
8//! Read-only except [`write::tick`], [`write::close_all`] and
9//! [`write::append_convergence`].
10
11pub mod bugfix;
12mod hash;
13pub mod parse;
14pub mod write;
15
16use std::path::{Path, PathBuf};
17
18use speccoder_core::snapshot::{
19    Artifacts, ConstitutionState, Diagnostic, FeatureRef, FeatureSnapshot, ProjectSnapshot,
20    Selection, SelectionReason, Severity, SpeckitMeta,
21};
22use speccoder_core::spec::FormatKind;
23
24pub use hash::sha256_hex;
25
26/// Where Kiro keeps its specs, relative to the project root.
27pub const SPECS: &str = ".kiro/specs";
28
29/// No Kiro spec was found from the start path upward.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct NotAProject;
32
33/// A spec directory speccoder can read: `tasks.md` beside its
34/// requirements, or a bugfix spec's `bugfix.md` (with or without tasks).
35fn is_spec(dir: &Path) -> bool {
36    (dir.join("tasks.md").is_file() && dir.join("requirements.md").is_file())
37        || dir.join("bugfix.md").is_file()
38}
39
40/// The specs under `root`, by name.
41#[must_use]
42pub fn specs(root: &Path) -> Vec<String> {
43    let mut names: Vec<String> = std::fs::read_dir(root.join(SPECS))
44        .map(|rd| {
45            rd.flatten()
46                .filter(|e| is_spec(&e.path()))
47                .map(|e| e.file_name().to_string_lossy().into_owned())
48                .collect()
49        })
50        .unwrap_or_default();
51    names.sort();
52    names
53}
54
55/// The nearest directory at or above `start` holding a Kiro spec.
56#[must_use]
57pub fn find_root(start: &Path) -> Option<PathBuf> {
58    let start = std::fs::canonicalize(start).ok()?;
59    start
60        .ancestors()
61        .find(|d| !specs(d).is_empty())
62        .map(Path::to_path_buf)
63}
64
65/// A spec's files, read.
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct Spec {
68    /// Its requirements, in order (none for a bugfix spec).
69    pub requirements: Vec<parse::Requirement>,
70    /// `design.md`, if present.
71    pub design: Option<String>,
72    /// A bugfix spec: `bugfix.md` and no `requirements.md`.
73    pub bugfix: bool,
74}
75
76/// Reads spec `name` of the project at `root`.
77#[must_use]
78pub fn load(root: &Path, name: &str) -> Spec {
79    let dir = root.join(SPECS).join(name);
80    let read = |f: &str| std::fs::read_to_string(dir.join(f)).ok();
81    Spec {
82        requirements: read("requirements.md")
83            .map(|t| parse::requirements(&t))
84            .unwrap_or_default(),
85        design: read("design.md"),
86        bugfix: !dir.join("requirements.md").is_file() && dir.join("bugfix.md").is_file(),
87    }
88}
89
90/// Bugfix spec `name`'s `bugfix.md`, read.
91///
92/// # Errors
93///
94/// The file is missing, or holds no Current or no Expected statement.
95pub fn read_bugfix(root: &Path, name: &str) -> Result<bugfix::Bugfix, String> {
96    let rel = format!("{SPECS}/{name}/bugfix.md");
97    let text = std::fs::read_to_string(root.join(&rel)).map_err(|_| format!("{rel} is missing"))?;
98    bugfix::read(&text).map_err(|e| format!("{rel}: {e}"))
99}
100
101/// cc-sdd's approval of spec `name`'s tasks (`spec.json`): `Some` with
102/// why it does not allow a run — the tasks are written not approved, or
103/// the file cannot be read. Kiro writes no `spec.json`: approved.
104#[must_use]
105pub fn unapproved(root: &Path, name: &str) -> Option<String> {
106    let text = std::fs::read_to_string(root.join(SPECS).join(name).join("spec.json")).ok()?;
107    match serde_json::from_str::<serde_json::Value>(&text) {
108        Err(e) => Some(format!(
109            "spec.json cannot be read ({e}), so its approval cannot be either"
110        )),
111        Ok(v) => (v.pointer("/approvals/tasks/approved") == Some(&serde_json::Value::Bool(false)))
112            .then(|| "cc-sdd has not approved this spec's tasks (spec.json)".to_owned()),
113    }
114}
115
116/// The spec's `tasks.md` read, a whole requirement (`3`) cited as its criteria.
117#[must_use]
118pub fn tasks_of(root: &Path, name: &str, spec: &Spec) -> Option<(parse::Tasks, Vec<Diagnostic>)> {
119    let rel = format!("{SPECS}/{name}/tasks.md");
120    let text = std::fs::read_to_string(root.join(&rel)).ok()?;
121    let (mut t, diags) = parse::tasks(&rel, &text);
122    for task in &mut t.parsed.tasks {
123        task.scenarios = task
124            .scenarios
125            .iter()
126            .flat_map(
127                |c| match spec.requirements.iter().find(|r| &r.number == c) {
128                    Some(r) => r.criteria.iter().map(|x| x.id.clone()).collect(),
129                    None => vec![c.clone()],
130                },
131            )
132            .collect();
133    }
134    Some((t, diags))
135}
136
137/// The file `rel` names, when it resolves inside `root`.
138fn inside(root: &Path, rel: &str) -> Option<PathBuf> {
139    let r = std::fs::canonicalize(root).ok()?;
140    let p = std::fs::canonicalize(root.join(rel)).ok()?;
141    p.starts_with(&r).then_some(p)
142}
143
144/// What `file:path`, `file:path:7`, `file:path:3-9` or `folder:path`
145/// stands for; `None` when it does not resolve inside `root`.
146fn reference(root: &Path, inner: &str) -> Option<String> {
147    if let Some(dir) = inner.strip_prefix("folder:") {
148        let mut names: Vec<String> = std::fs::read_dir(inside(root, dir.trim())?)
149            .ok()?
150            .flatten()
151            .map(|e| e.file_name().to_string_lossy().into_owned())
152            .collect();
153        names.sort();
154        return Some(names.join("\n"));
155    }
156    let spec = inner.strip_prefix("file:")?.trim();
157    let lines = |s: &str| -> Option<(usize, usize)> {
158        let (a, b) = s.split_once('-').unwrap_or((s, s));
159        Some((a.parse().ok()?, b.parse().ok()?))
160    };
161    let (path, range) = match spec.rsplit_once(':') {
162        Some((p, r)) if lines(r).is_some() => (p, lines(r)),
163        _ => (spec, None),
164    };
165    let text = std::fs::read_to_string(inside(root, path)?).ok()?;
166    Some(match range {
167        None => text.trim_end().to_owned(),
168        Some((a, b)) => text
169            .lines()
170            .skip(a.saturating_sub(1))
171            .take(b.saturating_sub(a) + 1)
172            .collect::<Vec<_>>()
173            .join("\n"),
174    })
175}
176
177/// `text` with each `#[[file:…]]` / `#[[folder:…]]` inlined from `root`,
178/// once (an inlined file's own references stay as written); the ones that
179/// do not resolve are kept visible and returned.
180fn inline(root: &Path, text: &str) -> (String, Vec<String>) {
181    let (mut out, mut unresolved, mut rest) = (String::new(), Vec::new(), text);
182    while let Some(i) = rest.find("#[[") {
183        out.push_str(&rest[..i]);
184        let after = &rest[i + 3..];
185        let Some(end) = after.find("]]") else {
186            break;
187        };
188        let whole = &rest[i..i + 3 + end + 2];
189        match reference(root, &after[..end]) {
190            Some(s) => out.push_str(&s),
191            None => {
192                unresolved.push(whole.to_owned());
193                out.push_str(whole);
194            }
195        }
196        rest = &after[end + 2..];
197    }
198    out.push_str(rest);
199    (out, unresolved)
200}
201
202/// The steering an attempt reads, as one text — each file under a heading
203/// that says when it applies — and what inspect says about it.
204#[must_use]
205pub fn steering_read(root: &Path) -> (String, Vec<Diagnostic>) {
206    let s = parse::steering(&root.join(".kiro/steering"));
207    let mut diags = Vec::new();
208    let quoted = |ps: &[String]| {
209        ps.iter()
210            .map(|p| format!("`{p}`"))
211            .collect::<Vec<_>>()
212            .join(" or ")
213    };
214    let text = s
215        .rules
216        .iter()
217        .map(|r| {
218            let when = match &r.when {
219                parse::When::Always => String::new(),
220                parse::When::Files(ps) => {
221                    format!(" — when you change files matching {}", quoted(ps))
222                }
223                parse::When::About(d) => format!(" — when the work is about: {d}"),
224            };
225            let (body, unresolved) = inline(root, &r.text);
226            if !unresolved.is_empty() {
227                diags.push(
228                    Diagnostic::new(
229                        Severity::Warning,
230                        "kiro.steering",
231                        format!(
232                            "{} does not resolve inside the project; the model reads it as written",
233                            unresolved.join(", ")
234                        ),
235                    )
236                    .at(format!(".kiro/steering/{}", r.name)),
237                );
238            }
239            format!("## .kiro/steering/{}{when}\n\n{}", r.name, body.trim_end())
240        })
241        .collect::<Vec<_>>()
242        .join("\n\n");
243    for (name, why) in &s.left_out {
244        diags.push(
245            Diagnostic::new(
246                Severity::Info,
247                "kiro.steering",
248                format!("{name} is not read ({why})"),
249            )
250            .at(format!(".kiro/steering/{name}")),
251        );
252    }
253    (text, diags)
254}
255
256/// The steering every task reads, as one text.
257#[must_use]
258pub fn steering_text(root: &Path) -> String {
259    steering_read(root).0
260}
261
262fn select(names: &[String], wanted: Option<&str>, diags: &mut Vec<Diagnostic>) -> Selection {
263    let none = Selection {
264        reason: SelectionReason::None,
265        feature: None,
266    };
267    match (wanted, names) {
268        (Some(w), _) if names.iter().any(|n| n == w) => Selection {
269            reason: SelectionReason::Argument,
270            feature: Some(w.to_owned()),
271        },
272        (Some(w), _) => {
273            diags.push(
274                Diagnostic::new(
275                    Severity::Error,
276                    "select.not-found",
277                    format!("no Kiro spec `{w}`"),
278                )
279                .hint(format!("specs: {}", names.join(", "))),
280            );
281            none
282        }
283        (None, [only]) => Selection {
284            reason: SelectionReason::OnlyFeature,
285            feature: Some(only.clone()),
286        },
287        (None, []) => none,
288        (None, many) => {
289            diags.push(
290                Diagnostic::new(
291                    Severity::Error,
292                    "select.ambiguous",
293                    format!("{} Kiro specs: {}", many.len(), many.join(", ")),
294                )
295                .hint("choose one with --feature <name>"),
296            );
297            none
298        }
299    }
300}
301
302/// Inspects the Kiro project containing `start`, selecting spec `feature`
303/// if given, else the only spec. A bugfix spec and an unreadable task list
304/// are among the diagnostics.
305///
306/// # Errors
307///
308/// No Kiro spec at or above `start`.
309pub fn inspect(start: &Path, feature: Option<&str>) -> Result<ProjectSnapshot, NotAProject> {
310    let root = find_root(start).ok_or(NotAProject)?;
311    let names = specs(&root);
312    let mut diags = Vec::new();
313    let selection = select(&names, feature, &mut diags);
314    let feature = selection.feature.as_deref().map(|name| {
315        let spec = load(&root, name);
316        let dir = format!("{SPECS}/{name}");
317        if spec.bugfix {
318            diags.push(match read_bugfix(&root, name) {
319                Ok(b) => {
320                    let ids = |letter: char, s: &[bugfix::Statement]| match s.len() {
321                        0 => String::new(),
322                        1 => format!(" ({letter}1)"),
323                        n => format!(" ({letter}1–{letter}{n})"),
324                    };
325                    let unchanged = if b.unchanged.is_empty() {
326                        "no Unchanged statement".to_owned()
327                    } else {
328                        format!(
329                            "Unchanged {}{}",
330                            bugfix::span(&b.unchanged),
331                            ids('U', &b.unchanged)
332                        )
333                    };
334                    Diagnostic::new(
335                        Severity::Info,
336                        "kiro.bugfix",
337                        format!(
338                            "a bugfix spec — Current {}, Expected {}{}, {unchanged}; it runs as a bug: reproduced, confirmed by you, fixed, the revert rejected",
339                            bugfix::span(&b.current),
340                            bugfix::span(&b.expected),
341                            ids('E', &b.expected),
342                        ),
343                    )
344                    .at(format!("{dir}/bugfix.md"))
345                    .hint(format!("speccoder run --feature {name}"))
346                }
347                Err(e) => Diagnostic::new(Severity::Error, "kiro.bugfix-unreadable", e)
348                    .at(format!("{dir}/bugfix.md"))
349                    .hint("write each statement under ### Current Behavior, ### Expected Behavior and ### Unchanged Behavior as `2.1 WHEN … THEN the system SHALL …`"),
350            });
351        }
352        if let Some(why) = unapproved(&root, name) {
353            diags.push(
354                Diagnostic::new(Severity::Error, "kiro.unapproved", why)
355                    .at(format!("{dir}/spec.json"))
356                    .hint(format!("approve them in cc-sdd (/kiro:spec-tasks {name} -y), then run again")),
357            );
358        }
359        let (tasks, optional) = match tasks_of(&root, name, &spec) {
360            Some((t, mut found)) => {
361                diags.append(&mut found);
362                (Some(t.parsed), t.optional)
363            }
364            None => (None, Vec::new()),
365        };
366        if !optional.is_empty() {
367            diags.push(
368                Diagnostic::new(
369                    Severity::Info,
370                    "kiro.optional",
371                    format!(
372                        "optional task{} {} {} not run",
373                        if optional.len() == 1 { "" } else { "s" },
374                        optional.join(", "),
375                        if optional.len() == 1 { "is" } else { "are" }
376                    ),
377                )
378                .at(format!("{dir}/tasks.md")),
379            );
380        }
381        let schedule = tasks.as_ref().map(|t| {
382            let (s, mut found) = speccoder_core::graph::schedule(t);
383            diags.append(&mut found);
384            s
385        });
386        FeatureSnapshot {
387            feature: FeatureRef {
388                dir: dir.clone(),
389                name: name.to_owned(),
390                prefix: String::new(),
391            },
392            artifacts: Artifacts {
393                spec: !spec.requirements.is_empty() || spec.bugfix,
394                plan: spec.design.is_some(),
395                tasks: tasks.is_some(),
396                ..Artifacts::default()
397            },
398            tasks,
399            schedule,
400        }
401    });
402    let (steering, mut found) = steering_read(&root);
403    diags.append(&mut found);
404    Ok(ProjectSnapshot {
405        constitution: if steering.is_empty() {
406            ConstitutionState::Absent
407        } else {
408            ConstitutionState::Present
409        },
410        root: root.display().to_string(),
411        speckit: SpeckitMeta::default(),
412        features: names
413            .iter()
414            .map(|n| FeatureRef {
415                dir: format!("{SPECS}/{n}"),
416                name: n.clone(),
417                prefix: String::new(),
418            })
419            .collect(),
420        selection,
421        feature,
422        diagnostics: diags,
423        format: FormatKind::Kiro,
424        openspec: None,
425    })
426}