Skip to main content

speccoder_runtime/bug/
keep.rs

1//! `speccoder bug keep <unit>/<task> --reason R`: a bug whose reproductions
2//! passed on the untouched code on every try (`already_satisfied`) is kept
3//! as a regression test — the reproductions the last try wrote are
4//! committed, and the task is accepted as `attested`, the commit naming the
5//! outcome. In a standing unit its task line is ticked; in the format's own
6//! bug structure (`bug:<slug>`) nothing of the format's is touched and the
7//! commit is the trailer-only task's evidence.
8
9use std::path::{Path, PathBuf};
10
11use serde_json::json;
12use speccoder_core::bug::{BugReport, Outcome, Stage};
13use speccoder_core::{CommandData, CommandResult, CommandStatus};
14
15use super::state;
16
17fn refuse(status: CommandStatus, message: &str) -> CommandResult {
18    CommandResult::new("bug", status, message)
19}
20
21/// [`keep_with`] with the environment's state directory.
22#[must_use]
23pub fn keep(path: &Path, key: &str, reason: &str, state: Option<&Path>) -> CommandResult {
24    match state
25        .map(Path::to_path_buf)
26        .or_else(crate::worktree::state_dir)
27    {
28        Some(s) => keep_with(path, key, reason, &s),
29        None => refuse(
30            CommandStatus::ConfigError,
31            "no state directory (set SPECCODER_STATE_DIR or HOME)",
32        )
33        .with_code("NO-STATE-DIR"),
34    }
35}
36
37/// Keeps bug `key`'s reproductions as a regression test, against `state`.
38#[must_use]
39pub fn keep_with(path: &Path, key: &str, reason: &str, state: &Path) -> CommandResult {
40    let reason = crate::controls::one_line(reason);
41    if reason.is_empty() {
42        return refuse(
43            CommandStatus::UsageError,
44            "a reason is needed: it is the acceptance's record",
45        )
46        .with_code("USAGE");
47    }
48    let opened = match crate::format::open(path, None) {
49        Ok(o) => o,
50        Err(r) => return refuse(r.status, &r.message),
51    };
52    let root = PathBuf::from(&opened.snapshot.root);
53    let plane = crate::worktree::project_state_dir(state, &root);
54    let Some(mut bug) = state::find(&plane, key) else {
55        return refuse(CommandStatus::NotFound, &format!("no bug {key}"));
56    };
57    if bug.outcome != Outcome::AlreadySatisfied || bug.commit.is_some() {
58        return refuse(
59            CommandStatus::UsageError,
60            &format!(
61                "{key} is {}{}: only a bug whose reproductions already pass is kept as a regression test",
62                bug.outcome.as_str(),
63                if bug.commit.is_some() { " and kept" } else { "" }
64            ),
65        )
66        .with_code("USAGE");
67    }
68    let paths: Vec<String> = bug.reproductions.iter().map(|r| r.file.clone()).collect();
69    let files = state::repro(&plane, &bug.unit, &bug.task, &paths);
70    if files.len() != paths.len() || !bug.reproductions.iter().any(|r| r.reproduces()) {
71        return refuse(
72            CommandStatus::NeedsHuman,
73            &format!(
74                "{key}'s reproductions are not all kept in the state directory: reproduce it again (speccoder bug run --task {key})"
75            ),
76        );
77    }
78
79    // The bug's worktree, clean.
80    let wt = if bug.home.kind.native() {
81        crate::commands::worktree_of(&opened.snapshot, &bug.unit, &bug.home.location, state)
82    } else {
83        crate::pipeline::workspace(path, Some(&bug.unit), state, &crate::RunOptions::default())
84    };
85    let Some(CommandData::Worktree(w)) = wt.data() else {
86        return CommandResult::carried("bug", &wt);
87    };
88    if wt.status() != CommandStatus::Ok {
89        return CommandResult::carried("bug", &wt);
90    }
91    let (Some(wt_path), Some(project_dir)) = (w.path.clone(), w.project_dir.clone()) else {
92        return refuse(CommandStatus::Error, "worktree without a path");
93    };
94    let wt_root = PathBuf::from(&wt_path);
95    let project = PathBuf::from(&project_dir);
96    if crate::worktree::require_clean(&wt_root).is_err() {
97        return refuse(
98            CommandStatus::GitPrecondition,
99            &format!(
100                "the worktree {wt_path} has uncommitted changes; the next `speccoder run` sets them aside, or commit them yourself"
101            ),
102        )
103        .with_code("WORKTREE-DIRTY");
104    }
105    let format = crate::format::at(&project);
106
107    // A standing unit's task line, before anything is written.
108    let task_line = if bug.home.kind.native() {
109        None
110    } else {
111        let Some(snap) = format.inspect(&project, Some(&bug.unit)) else {
112            return refuse(
113                CommandStatus::NotAProject,
114                format.messages().no_project_in_worktree,
115            );
116        };
117        let Some(tasks) = snap.feature.as_ref().and_then(|f| f.tasks.clone()) else {
118            return refuse(
119                CommandStatus::NotFound,
120                &format!("{} has no {}", bug.unit, format.messages().tasks_file),
121            );
122        };
123        let Some(t) = tasks.tasks.iter().find(|t| t.id == bug.task).cloned() else {
124            return refuse(
125                CommandStatus::NotFound,
126                &format!("no task {} in {}", bug.task, bug.unit),
127            );
128        };
129        if t.done {
130            return refuse(
131                CommandStatus::TaskNotReady,
132                &format!("{} is already done", bug.task),
133            )
134            .with_code("TASK-DONE");
135        }
136        let dir = snap
137            .feature
138            .as_ref()
139            .map(|f| f.feature.dir.clone())
140            .unwrap_or_default();
141        Some((t, project.join(&tasks.path), dir))
142    };
143
144    // The reproductions, written as they were shown to pass.
145    for (rel, content) in &files {
146        let full = project.join(rel);
147        match std::fs::read_to_string(&full) {
148            Ok(now) if now == *content => continue,
149            Ok(_) => {
150                return refuse(
151                    CommandStatus::GitPrecondition,
152                    &format!(
153                        "`{rel}` exists and differs from the reproduction; it is not overwritten"
154                    ),
155                );
156            }
157            Err(_) => {}
158        }
159        if let Some(parent) = full.parent() {
160            let _ = std::fs::create_dir_all(parent);
161        }
162        if let Err(e) = std::fs::write(&full, content) {
163            let _ = crate::git::Git::at(&wt_root).run(&["checkout", "--", "."]);
164            return refuse(CommandStatus::Error, &format!("`{rel}`: {e}"));
165        }
166    }
167    if let Some((t, file, _)) = &task_line
168        && let Err(e) = format.tick(file, &bug.task, &t.line_hash)
169    {
170        let _ = crate::git::Git::at(&wt_root).run(&["clean", "-fdq"]);
171        let _ = crate::git::Git::at(&wt_root).run(&["checkout", "--", "."]);
172        return refuse(CommandStatus::Error, &format!("tick refused: {e}"));
173    }
174    // A person's word does not pass the person's own gates (spec 095).
175    let gates = match crate::sandbox::runner_at(&project) {
176        Ok((runner, _)) => crate::hooks::gate::run(
177            &project,
178            &runner,
179            state,
180            crate::hooks::gate::Facts {
181                point: speccoder_core::hooks::Point::Tick,
182                feature: &bug.unit,
183                task: Some(&bug.task),
184                branch: None,
185            },
186        ),
187        Err(e) => return refuse(CommandStatus::ConfigError, &e),
188    };
189    if let Some(g) = speccoder_core::hooks::first_block(&gates) {
190        let _ = crate::git::Git::at(&wt_root).run(&["clean", "-fdq"]);
191        let _ = crate::git::Git::at(&wt_root).run(&["checkout", "--", "."]);
192        let code = g.code.clone().unwrap_or_else(|| "HOOK-BLOCKED".to_owned());
193        return refuse(
194            CommandStatus::NeedsHuman,
195            &format!("{}; {} is not kept", g.blocked_line(), bug.task),
196        )
197        .with_code(&code);
198    }
199    let gate_lines: String = gates
200        .iter()
201        .map(|g| format!("Gate: {} {}\n", g.name, g.outcome.as_str()))
202        .collect();
203    let by = crate::controls::person(&project);
204    let message = format!(
205        "speccoder: {task} kept as a regression test by {by}\n\n{title}\n\n{reason}\n\nSpec-Task: {unit}/{task}\nVerdict: attested\nAccepted-By: {by}\nReason: {reason}\nBug: already_satisfied ({home}) triad={triad}\n{gate_lines}",
206        task = bug.task,
207        title = bug.title,
208        unit = bug.unit,
209        home = bug.home.kind.as_str(),
210        triad = bug.triad.digest,
211    );
212    let commit = match crate::commit_attempt(&wt_root, &message) {
213        Ok(c) => c,
214        Err(e) => return refuse(CommandStatus::Error, &format!("commit failed: {e}")),
215    };
216    let attested = match &task_line {
217        Some((t, _, dir)) => crate::controls::attest_decision(
218            &wt_root,
219            &project,
220            t,
221            (&bug.unit, dir),
222            (&commit, "attested"),
223            (&by, &reason),
224        ),
225        None => attest_trailer_only(&wt_root, &project, &bug, &commit, (&by, &reason)),
226    };
227    bug.commit = Some(commit.clone());
228    bug.stage = Stage::Done;
229    bug.code = None;
230    let saved = state::save(&plane, &bug);
231    let mut said = format!(
232        "{} kept as a regression test by {by} (attested, not verified): the reproductions are committed in {}{attested}",
233        bug.key(),
234        commit.chars().take(7).collect::<String>()
235    );
236    if let Err(e) = &saved {
237        said.push_str(&format!("; the bug's state could not be updated: {e}"));
238    }
239    CommandResult::new(
240        "bug",
241        if saved.is_ok() {
242            CommandStatus::Ok
243        } else {
244            CommandStatus::Error
245        },
246        &said,
247    )
248    .with_next(super::handoff::next(&bug, bug.outcome))
249    .with_data(CommandData::Bug(Box::new(bug)))
250}
251
252/// The decision's attestation for a trailer-only task: anchored on the
253/// triad, as the verifier reads such a task. A line for the message.
254fn attest_trailer_only(
255    repo: &Path,
256    project: &Path,
257    bug: &BugReport,
258    commit: &str,
259    decider: (&str, &str),
260) -> String {
261    let config = crate::verify::find_config(project)
262        .and_then(|d| std::fs::read_to_string(d.join(crate::verify::config::FILE)).ok())
263        .unwrap_or_default();
264    let Ok(run_config) = crate::substrate::run_config::parse(&config) else {
265        return String::new();
266    };
267    if !run_config.attest.enabled {
268        return String::new();
269    }
270    let format = format!("{:?}", crate::format::at(project).kind()).to_lowercase();
271    let story = format!(
272        "A bug, in the reporter's words.\n\n{}",
273        bug.triad.labelled()
274    );
275    let tick = crate::attest::Tick {
276        repo,
277        config: &config,
278        attest: &run_config.attest,
279        format: &format,
280        feature: &bug.unit,
281        feature_dir: &bug.home.location,
282        task: &bug.task,
283        title: &bug.title,
284        line_hash: "",
285        section: None,
286        section_text: &story,
287        requirements: &[],
288        commit,
289        verdict: "attested",
290        sandbox: serde_json::Value::Null,
291        executor: serde_json::Value::Null,
292        model: serde_json::Value::Null,
293        bug: Some((
294            json!({
295                "home": bug.home.kind.as_str(),
296                "triad_sha256": bug.triad.digest,
297                "outcome": bug.outcome.as_str(),
298                "reproductions": bug.reproductions.iter().map(|r| json!({
299                    "statement": r.statement, "baseline": r.baseline, "final": r.final_, "revert": r.revert,
300                })).collect::<Vec<_>>(),
301            }),
302            json!({ "source": "kept_regression_test" }),
303        )),
304    };
305    let a = crate::attest::record_decision(&tick, decider.0, decider.1);
306    match (a["state"].as_str(), a["reason"].as_str()) {
307        (Some("signed"), _) => format!(
308            "; attested: signed ({})",
309            a["signer"].as_str().unwrap_or("?")
310        ),
311        (Some(state), Some(why)) => format!("; attestation {state}: {why}"),
312        (Some(state), None) => format!("; attestation {state}"),
313        _ => String::new(),
314    }
315}