Skip to main content

speccoder_runtime/pipeline/
story.rs

1//! A story's oracle in a run: its acceptance tests, written once through
2//! the product and committed on the unit branch (`Oracle: US1`) before the
3//! story's first code task implements anything; the states of its
4//! scenarios on a tree; and the progress each task makes on them
5//! ([`speccoder_core::story::progress`]).
6
7use std::collections::BTreeMap;
8use std::path::Path;
9
10use speccoder_core::spec::ScenarioRef;
11use speccoder_core::story::{Progress, ScenarioState};
12use speccoder_core::verify::{CheckStatus, Verdict};
13
14use crate::oracle::FrozenTest;
15use crate::sandbox;
16
17/// The trailer naming the story an oracle commit is for.
18pub(crate) const ORACLE: &str = "Oracle";
19
20/// A story's oracle as a task is judged by it.
21#[derive(Debug, Clone)]
22pub(crate) struct StoryOracle {
23    /// The story (`US1`; for OpenSpec the change).
24    pub(crate) story: String,
25    /// Its scenarios, in the story's order.
26    pub(crate) scenarios: Vec<ScenarioRef>,
27    /// Each test, with its command and the scenarios it cites.
28    pub(crate) tests: Vec<FrozenTest>,
29    /// What each scenario must hold (`given → must hold`), by id.
30    pub(crate) contracts: BTreeMap<String, String>,
31    /// The `Oracle:` commit (empty until committed).
32    pub(crate) commit: String,
33    /// `speccoder run`, or the person who froze it at review.
34    pub(crate) decided_by: String,
35    /// Its sealed held-out set, run at the story's checkpoint only.
36    pub(crate) held_out: Option<speccoder_core::acceptance::SealedRef>,
37    /// What became of the held-out set when the oracle was written.
38    pub(crate) held_out_summary: Option<speccoder_core::oracle::HeldOutSummary>,
39    /// Frozen at review by a person: the record and its manifest digest
40    /// (the ticks name them).
41    pub(crate) reviewed: Option<(speccoder_core::acceptance::PreFrozenAcceptance, String)>,
42    /// Each scenario's state when the oracle was written.
43    pub(crate) at_oracle: BTreeMap<String, ScenarioState>,
44    /// What writing it now cost (its author calls); none when it was there.
45    pub(crate) usage: Option<serde_json::Value>,
46    /// The scenarios its author declared untestable (shown; never blocking
47    /// the checkpoint), with why.
48    pub(crate) untestable: BTreeMap<String, String>,
49}
50
51impl StoryOracle {
52    /// Every scenario was green before any work of the story: built
53    /// earlier (a Setup task, before the feature).
54    pub(crate) fn green_from_the_start(&self) -> bool {
55        !self.at_oracle.is_empty()
56            && self
57                .scenarios
58                .iter()
59                .filter(|s| !self.untestable.contains_key(&s.id))
60                .all(|s| self.at_oracle.get(&s.id) == Some(&ScenarioState::Green))
61    }
62}
63
64impl StoryOracle {
65    /// The scenario ids in order.
66    pub(crate) fn order(&self) -> Vec<String> {
67        self.scenarios.iter().map(|s| s.id.clone()).collect()
68    }
69}
70
71/// The latest `Oracle: <story>` commit on `HEAD` of `wt_root` — written
72/// only by speccoder's own commit (trailers of a model's commit are never
73/// read: the implementer never commits).
74pub(crate) fn commit_of(wt_root: &Path, story: &str) -> Result<Option<String>, String> {
75    let out = crate::git::Git::at(wt_root)
76        .line(&[
77            "log",
78            "--format=%H",
79            "-n1",
80            &format!("--grep=^{ORACLE}: {story}$"),
81            "HEAD",
82        ])
83        .map_err(|e| e.0)?;
84    Ok(Some(out).filter(|s| !s.is_empty()))
85}
86
87/// Whether a story with open tasks has its oracle on the unit branch at
88/// `wt_root`: a build its red tests break (a symbol a later task adds, in a
89/// compiled language) is that story's, not a broken check. Once its tasks
90/// are done, a broken check is a broken check again.
91pub(crate) fn open_oracle(
92    wt_root: &Path,
93    snapshot: &speccoder_core::snapshot::ProjectSnapshot,
94    format: &dyn crate::format::Adapter,
95) -> bool {
96    let whole_unit = format.kind() == speccoder_core::spec::FormatKind::Openspec;
97    let Some(f) = snapshot.feature.as_ref() else {
98        return false;
99    };
100    let tasks = f
101        .tasks
102        .as_ref()
103        .map(|t| t.tasks.as_slice())
104        .unwrap_or_default();
105    let mut open: Vec<String> = if whole_unit {
106        if tasks.iter().any(|t| !t.done) {
107            vec![f.feature.name.clone()]
108        } else {
109            Vec::new()
110        }
111    } else {
112        // A story's own open tasks, and an open gap judged by its oracle.
113        let reqs = format.oracle_requirements(snapshot);
114        tasks
115            .iter()
116            .filter(|t| !t.done)
117            .filter_map(|t| {
118                t.story.clone().or_else(|| {
119                    super::acceptance::linked_story(
120                        t,
121                        &format.scenarios(snapshot, &t.id),
122                        &f.feature.dir,
123                        &format.rules().judged,
124                        &reqs,
125                    )
126                    .map(|(r, _)| r.key)
127                })
128            })
129            .collect()
130    };
131    open.sort();
132    open.dedup();
133    open.iter()
134        .any(|story| matches!(commit_of(wt_root, story), Ok(Some(_))))
135}
136
137/// Whether `task`'s story still has its oracle to write (or amend) on the
138/// unit's worktree `wt_root`: such a task runs alone, never in a parallel
139/// lane — the oracle is written once, on the unit's branch, then read by
140/// every lane.
141pub(crate) fn oracle_pending(
142    wt_root: &Path,
143    plane_dir: &Path,
144    format: &dyn crate::format::Adapter,
145    snapshot: &speccoder_core::snapshot::ProjectSnapshot,
146    task: &str,
147) -> bool {
148    let Some(f) = snapshot.feature.as_ref() else {
149        return false;
150    };
151    let Some(t) = f
152        .tasks
153        .as_ref()
154        .and_then(|t| t.tasks.iter().find(|t| t.id == task))
155    else {
156        return false;
157    };
158    let key = match t.story.clone() {
159        Some(l) => l,
160        None if format.kind() == speccoder_core::spec::FormatKind::Openspec => {
161            f.feature.name.clone()
162        }
163        // A gap judged by one story's oracle writes it when it is missing.
164        None => match super::acceptance::linked_story(
165            t,
166            &format.scenarios(snapshot, task),
167            &f.feature.dir,
168            &format.rules().judged,
169            &format.oracle_requirements(snapshot),
170        ) {
171            Some((r, _)) => r.key,
172            None => return false,
173        },
174    };
175    if format.scenarios(snapshot, task).is_empty() {
176        return false;
177    }
178    !matches!(commit_of(wt_root, &key), Ok(Some(_)))
179        || !crate::acceptance_at::rulings_for(plane_dir, &f.feature.name, &key)
180            .unwrap_or_default()
181            .is_empty()
182}
183
184/// Everything the oracle step works with.
185pub(crate) struct Ensure<'a> {
186    /// The story (`US1`; the unit for OpenSpec).
187    pub(crate) key: &'a str,
188    /// Its scenarios, in the story's order.
189    pub(crate) scenarios: &'a [ScenarioRef],
190    pub(crate) feature: &'a str,
191    /// The project inside the task's worktree, and the worktree.
192    pub(crate) project: &'a Path,
193    pub(crate) wt_root: &'a Path,
194    /// speccoder's state directory, and the project's.
195    pub(crate) state: &'a Path,
196    pub(crate) plane_dir: &'a Path,
197    pub(crate) runner: &'a sandbox::Runner,
198    /// The run's runtime and plane: the author's calls are the run's.
199    pub(crate) rt: &'a tokio::runtime::Runtime,
200    pub(crate) plane: &'a crate::substrate::Plane,
201    pub(crate) options: &'a crate::RunOptions,
202    pub(crate) format: &'a dyn crate::format::Adapter,
203    pub(crate) snapshot: &'a speccoder_core::snapshot::ProjectSnapshot,
204    /// `--fresh-acceptance`: the story's tests are written anew.
205    pub(crate) fresh: bool,
206    /// An interrupted attempt resumes: the oracle is not amended under it.
207    pub(crate) resuming: bool,
208}
209
210/// `speccoder story check <story>`: the story's oracle written now (when
211/// it has none) and run once on the feature's branch — a story ticked
212/// before its oracle is then held, or not, scenario by scenario.
213#[must_use]
214pub fn check_story(
215    path: &Path,
216    feature: Option<&str>,
217    story: &str,
218    options: &crate::RunOptions,
219) -> speccoder_core::CommandResult {
220    super::feature::with_driver("story", path, feature, |state, provider, _| {
221        check_with(path, feature, story, state, provider, options)
222    })
223}
224
225/// [`check_story`] against `state`, with `provider` for the author's call.
226#[must_use]
227pub fn check_with(
228    path: &Path,
229    feature: Option<&str>,
230    story: &str,
231    state: &Path,
232    provider: &std::sync::Arc<dyn agentplane::model::ModelProvider>,
233    options: &crate::RunOptions,
234) -> speccoder_core::CommandResult {
235    use speccoder_core::{CommandResult, CommandStatus};
236    let c = match checked(path, feature, story, state, provider, options) {
237        Ok(c) => c,
238        Err(r) => return *r,
239    };
240    let (now, scenarios, finished, wrote) = (&c.states, &c.scenarios, c.finished, c.wrote);
241    let red: Vec<&str> = scenarios
242        .iter()
243        .filter(|sc| now.get(&sc.id) == Some(&ScenarioState::Red))
244        .map(|sc| sc.id.as_str())
245        .collect();
246    let green = scenarios
247        .iter()
248        .filter(|sc| now.get(&sc.id) == Some(&ScenarioState::Green))
249        .count();
250    let how = if wrote {
251        "its oracle written and run"
252    } else {
253        "its oracle run again"
254    };
255    let message = if red.is_empty() {
256        format!(
257            "{story}: {how} — {green} of {} scenario(s) hold",
258            scenarios.len()
259        )
260    } else {
261        format!(
262            "{story}: {how} — {green} of {} scenario(s) hold; {} {}",
263            scenarios.len(),
264            red.join(", "),
265            if finished {
266                "not held by the code"
267            } else {
268                "still to turn green"
269            }
270        )
271    };
272    let f = feature.map_or_else(String::new, |f| format!(" --feature {f}"));
273    let step = |command: String, why: &str| speccoder_core::next::NextStep {
274        command,
275        why: why.to_owned(),
276    };
277    let next = if red.is_empty() || !finished {
278        vec![step(
279            format!("speccoder report{f}"),
280            "each story on its oracle",
281        )]
282    } else {
283        // A test written after its code may be the wrong one: either way on.
284        let mut next = vec![step(
285            format!("speccoder converge{f}"),
286            "append the tasks that make the code hold them",
287        )];
288        if let Some(last) = &c.last {
289            next.push(step(
290                format!(
291                    "speccoder task rule {last}{f} --tests-wrong --scenario {} --reason \"…\"",
292                    red[0]
293                ),
294                "when a test asserts what the spec does not say",
295            ));
296        }
297        next
298    };
299    CommandResult::new("story", CommandStatus::Ok, &message).with_next(next)
300}
301
302/// A story's oracle on the feature's branch: ensured (written when it has
303/// none) and run once.
304pub(crate) struct Checked {
305    /// Each scenario's state now.
306    pub(crate) states: BTreeMap<String, ScenarioState>,
307    /// The scenarios its author called untestable.
308    pub(crate) untestable: Vec<String>,
309    /// The story's scenarios.
310    pub(crate) scenarios: Vec<speccoder_core::spec::ScenarioRef>,
311    /// Every task of the story is done.
312    pub(crate) finished: bool,
313    /// The story's last task.
314    pub(crate) last: Option<String>,
315    /// The oracle was written now.
316    pub(crate) wrote: bool,
317    /// What writing it cost (none when it was there).
318    pub(crate) usage: Option<serde_json::Value>,
319}
320
321/// Ensures `story`'s oracle on the feature's branch and runs it.
322pub(crate) fn checked(
323    path: &Path,
324    feature: Option<&str>,
325    story: &str,
326    state: &Path,
327    provider: &std::sync::Arc<dyn agentplane::model::ModelProvider>,
328    options: &crate::RunOptions,
329) -> Result<Checked, Box<speccoder_core::CommandResult>> {
330    use speccoder_core::{CommandData, CommandResult, CommandStatus};
331    let refuse = |status, m: String| CommandResult::new("story", status, &m);
332    let s = super::setup(path, feature, state, "story")?;
333    let Some(name) = s.snapshot.feature.as_ref().map(|f| f.feature.name.clone()) else {
334        return Err(Box::new(refuse(
335            CommandStatus::SelectionNeeded,
336            "no feature selected; pass --feature".to_owned(),
337        )));
338    };
339    // The feature's branch: where its ticks are, never the checkout.
340    let wt = super::workspace(path, Some(&name), state, options);
341    let Some(CommandData::Worktree(w)) = wt.data() else {
342        return Err(Box::new(CommandResult::carried("story", &wt)));
343    };
344    if wt.status() != CommandStatus::Ok {
345        return Err(Box::new(CommandResult::carried("story", &wt)));
346    }
347    let (Some(wt_path), Some(project_dir)) = (w.path.clone(), w.project_dir.clone()) else {
348        return Err(Box::new(refuse(
349            CommandStatus::Error,
350            "worktree without a path".to_owned(),
351        )));
352    };
353    let (project, wt_root) = (
354        std::path::PathBuf::from(project_dir),
355        std::path::PathBuf::from(wt_path),
356    );
357    let format = crate::format::at(&project);
358    let Some(snap) = format.inspect(&project, Some(&name)) else {
359        return Err(Box::new(refuse(
360            CommandStatus::NotAProject,
361            "the feature's branch holds no spec".to_owned(),
362        )));
363    };
364    let tasks: Vec<&speccoder_core::snapshot::Task> = snap
365        .feature
366        .as_ref()
367        .and_then(|f| f.tasks.as_ref())
368        .map(|ts| ts.tasks.iter().collect())
369        .unwrap_or_default();
370    let whole = format.kind() == speccoder_core::spec::FormatKind::Openspec && story == name;
371    let members: Vec<&&speccoder_core::snapshot::Task> = tasks
372        .iter()
373        .filter(|t| whole || t.story.as_deref() == Some(story))
374        .collect();
375    let Some(first) = members.first() else {
376        let mut known: Vec<&str> = tasks.iter().filter_map(|t| t.story.as_deref()).collect();
377        known.sort_unstable();
378        known.dedup();
379        return Err(Box::new(refuse(
380            CommandStatus::NotFound,
381            format!("{story} is no story of {name} ({})", known.join(", ")),
382        )));
383    };
384    let scenarios = format.scenarios(&snap, &first.id);
385    if scenarios.is_empty() {
386        return Err(Box::new(refuse(
387            CommandStatus::NotFound,
388            format!("{story} has no acceptance scenario to check"),
389        )));
390    }
391    let (runner, _) = match sandbox::runner_at(&wt_root) {
392        Ok(r) => r,
393        Err(e) => return Err(Box::new(refuse(CommandStatus::ConfigError, e))),
394    };
395    let rt = match super::tokio_runtime() {
396        Ok(rt) => rt,
397        Err(e) => return Err(Box::new(refuse(CommandStatus::RunFailed, e))),
398    };
399    let plane = match rt.block_on(async {
400        crate::substrate::Plane::open_with(
401            &s.plane_dir,
402            &s.model,
403            std::sync::Arc::clone(provider),
404            None,
405            crate::substrate::Hooks {
406                format: Some(format),
407                progress: options.progress.clone(),
408                ..crate::substrate::Hooks::default()
409            },
410        )
411    }) {
412        Ok(p) => p,
413        Err(e) => return Err(Box::new(refuse(CommandStatus::RunFailed, e.0))),
414    };
415    let e = Ensure {
416        key: story,
417        scenarios: &scenarios,
418        feature: &name,
419        project: &project,
420        wt_root: &wt_root,
421        state,
422        plane_dir: &s.plane_dir,
423        runner: &runner,
424        rt: &rt,
425        plane: &plane,
426        options,
427        format,
428        snapshot: &snap,
429        fresh: false,
430        resuming: false,
431    };
432    let (oracle, wrote, _) = ensure(&e)?;
433    let states = states(&project, &oracle, &runner);
434    Ok(Checked {
435        states,
436        untestable: oracle.untestable.keys().cloned().collect(),
437        scenarios,
438        finished: members.iter().all(|t| t.done),
439        last: members.last().map(|t| t.id.clone()),
440        wrote,
441        usage: oracle.usage.clone(),
442    })
443}
444
445fn stop(code: &str, message: String) -> Box<speccoder_core::CommandResult> {
446    Box::new(
447        speccoder_core::CommandResult::new(
448            "run",
449            speccoder_core::CommandStatus::NeedsHuman,
450            &message,
451        )
452        .with_code(code),
453    )
454}
455
456/// The oracle could not be had: writing the story's tests again is the way on.
457fn unwritten(story: &str, message: String) -> Box<speccoder_core::CommandResult> {
458    let mut res = stop("ORACLE-UNWRITTEN", message);
459    *res = res.with_fix(vec![format!("speccoder review tests {story} --write")]);
460    res
461}
462
463/// The story's oracle for a task: the one committed on the branch; else
464/// the one frozen at review, or — nobody having reviewed it — one the run
465/// writes now; either committed before the task implements anything.
466/// `true` when it was committed by this call (the tree changed); the
467/// notices to show (a lapsed or absent review oracle).
468pub(crate) fn ensure(
469    e: &Ensure<'_>,
470) -> Result<(StoryOracle, bool, Vec<String>), Box<speccoder_core::CommandResult>> {
471    let mut notices = Vec::new();
472    // The sealed held-out set beside the frozen record.
473    // A frozen record changed since it was written is never read as "none".
474    if let Err(t) = crate::pre_frozen::load(e.plane_dir, e.feature, e.key) {
475        return Err(stop(
476            "ORACLE-TAMPERED",
477            format!(
478                "{}'s frozen oracle changed since it was written ({}): the task stops for a person, nothing is ticked",
479                e.key, t.0
480            ),
481        ));
482    }
483    let sealed = || {
484        crate::pre_frozen::load(e.plane_dir, e.feature, e.key)
485            .ok()
486            .flatten()
487            .and_then(|(r, _)| r.held_out_sealed)
488    };
489    let oracle = |tests: Vec<FrozenTest>, contracts, commit: String, by: String| StoryOracle {
490        story: e.key.to_owned(),
491        scenarios: e.scenarios.to_vec(),
492        tests,
493        contracts,
494        commit,
495        decided_by: by,
496        held_out: sealed(),
497        held_out_summary: crate::oracle::load(e.plane_dir, e.feature, e.key)
498            .ok()
499            .flatten()
500            .map(|(d, _)| d.held_out),
501        reviewed: None,
502        at_oracle: BTreeMap::new(),
503        usage: None,
504        untestable: BTreeMap::new(),
505    };
506    if let Some(commit) = commit_of(e.wt_root, e.key).map_err(|m| unwritten(e.key, m))? {
507        // An oracle of no test: the author found nothing testable.
508        if tests_of(e.wt_root, &commit) == Some(0) {
509            return Ok((
510                oracle(
511                    Vec::new(),
512                    BTreeMap::new(),
513                    commit,
514                    "speccoder run".to_owned(),
515                ),
516                false,
517                notices,
518            ));
519        }
520        let Some(frozen) = crate::oracle::load_frozen(e.plane_dir, e.feature, e.key) else {
521            return Err(unwritten(
522                e.key,
523                format!(
524                    "{}'s oracle ({}) was written on another machine: the commands that run its tests are kept there",
525                    e.key,
526                    commit.get(..7).unwrap_or(&commit)
527                ),
528            ));
529        };
530        let w = written(e.wt_root, "HEAD", e.key);
531        let (contracts, by, at, untestable) = w.map_or_else(
532            || {
533                (
534                    BTreeMap::new(),
535                    "speccoder run".to_owned(),
536                    BTreeMap::new(),
537                    BTreeMap::new(),
538                )
539            },
540            |w| (w.contracts, w.by, w.states, w.untestable),
541        );
542        let mut current = oracle(frozen.tests, contracts, commit, by);
543        let gone = changed(e.project, &current);
544        if !gone.is_empty() {
545            return Err(stop(
546                "ORACLE-TAMPERED",
547                format!(
548                    "{}'s oracle tests on the branch are not the frozen ones: {} — the task stops for a person, nothing is ticked",
549                    e.key,
550                    gone.join(", ")
551                ),
552            ));
553        }
554        // As the oracle was first written: amendments change tests, not when
555        // the story's scenarios first held.
556        current.at_oracle = first_states(e.wt_root, e.key).unwrap_or(at);
557        current.untestable = untestable;
558        current.reviewed = crate::pre_frozen::load(e.plane_dir, e.feature, e.key)
559            .ok()
560            .flatten()
561            .filter(|(r, _)| r.producer == speccoder_core::acceptance::Producer::Review)
562            .map(|(r, _)| (r, frozen.manifest_sha256.clone()));
563        // A person ruled scenarios' tests wrong: those are written again,
564        // nothing else of the oracle.
565        let mut rulings = crate::acceptance_at::rulings_for(e.plane_dir, e.feature, e.key)
566            .map_err(|m| {
567                stop(
568                    "ORACLE-TAMPERED",
569                    format!("the rulings cannot be read: {m}"),
570                )
571            })?;
572        // Fresh tests asked for: every scenario is written again.
573        if e.fresh {
574            rulings = e
575                .scenarios
576                .iter()
577                .map(|s| crate::acceptance_at::Ruling {
578                    feature: e.feature.to_owned(),
579                    scenario: Some(s.id.clone()),
580                    story: e.key.to_owned(),
581                    task: String::new(),
582                    reason: "fresh tests were asked for".to_owned(),
583                    by: "you".to_owned(),
584                    via: speccoder_core::actions::Via::Terminal,
585                    at: 0,
586                })
587                .collect();
588        }
589        if rulings.is_empty() {
590            return Ok((current, false, notices));
591        }
592        // An interrupted attempt resumes against the oracle it began with;
593        // the ruling waits for the next fresh one.
594        if e.resuming {
595            notices.push(format!(
596                "a ruling on {}'s tests waits: the interrupted attempt resumes first",
597                e.key
598            ));
599            return Ok((current, false, notices));
600        }
601        return amend(e, current, &rulings).map(|o| (o, true, notices));
602    }
603    // Frozen at review, current: its tests, no author call.
604    let reviewed = match crate::oracle::for_task(
605        e.plane_dir,
606        e.project,
607        e.wt_root,
608        e.format,
609        e.snapshot,
610        e.scenarios,
611    ) {
612        Some(crate::oracle::TaskReuse::Ready {
613            record,
614            files,
615            manifest,
616            ..
617        }) => {
618            notices.push(format!(
619                "acceptance tests frozen at review by {} ({}) for {}: no author call",
620                record.decided.by, record.decided.via, record.requirement
621            ));
622            let tests = crate::oracle::load_frozen(e.plane_dir, e.feature, e.key)
623                .map(|f| f.tests)
624                .unwrap_or_default();
625            let contracts = crate::oracle::load(e.plane_dir, e.feature, e.key)
626                .ok()
627                .flatten()
628                .map(|(d, _)| contracts_of(&d.expectations.sample1))
629                .unwrap_or_default();
630            (!tests.is_empty()).then(|| {
631                let by = record.decided.by.clone();
632                (tests, files, contracts, by, Some((*record, manifest)))
633            })
634        }
635        Some(crate::oracle::TaskReuse::Lapsed {
636            req,
637            frozen,
638            current,
639        }) => {
640            if crate::oracle::note_lapse(e.plane_dir, e.feature, &req, &frozen)
641                && let Some(p) = &e.options.progress
642            {
643                p.event(&crate::progress::Event::Oracle {
644                    step: crate::progress::OracleEvent::Lapsed {
645                        unit: e.feature.to_owned(),
646                        requirement: req.clone(),
647                        frozen_wording_sha256: frozen,
648                        current_wording_sha256: current,
649                    },
650                });
651            }
652            notices.push(format!(
653                "{req}'s wording changed since its tests were frozen at review: the frozen oracle lapsed — authoring"
654            ));
655            None
656        }
657        Some(crate::oracle::TaskReuse::Elsewhere { req }) => {
658            notices.push(format!("{req}: frozen oracle not present here — authoring"));
659            None
660        }
661        Some(crate::oracle::TaskReuse::Tampered(why)) => {
662            return Err(stop(
663                "ORACLE-TAMPERED",
664                format!("{why}: the task stops for a person, nothing is ticked"),
665            ));
666        }
667        Some(crate::oracle::TaskReuse::Unreadable(why)) => {
668            return Err(stop(
669                "REVIEW-UNREADABLE",
670                format!(
671                    "{why}: whether its tests are frozen is unknown, so the task stops for a person, nothing is ticked"
672                ),
673            ));
674        }
675        _ => None,
676    };
677    let mut spent = None;
678    let (tests, files, contracts, by, reviewed) = match reviewed {
679        Some(r) => r,
680        None => {
681            let w = crate::oracle::write_for_run(
682                e.project,
683                e.wt_root,
684                e.feature,
685                e.state,
686                e.key,
687                e.runner.clone(),
688                (e.rt, e.plane),
689                (&[], &[]),
690                "",
691                e.options,
692            )?;
693            spent.clone_from(&w.usage);
694            (
695                w.tests,
696                w.contents,
697                contracts_of(&w.contracts),
698                "speccoder run".to_owned(),
699                None,
700            )
701        }
702    };
703    // A file at a test's path that is not the test (an implementer committed
704    // one there) is never overwritten: the oracle would judge by it.
705    for (path, content) in &files {
706        if let Ok(there) = std::fs::read_to_string(e.project.join(path))
707            && there != *content
708        {
709            return Err(stop(
710                "ORACLE-TAMPERED",
711                format!(
712                    "{path} exists and differs from the confirmed test: the task stops for a person, nothing is ticked"
713                ),
714            ));
715        }
716    }
717    for (path, content) in &files {
718        let full = e.project.join(path);
719        let written = full
720            .parent()
721            .map_or(Ok(()), std::fs::create_dir_all)
722            .and_then(|()| std::fs::write(&full, content));
723        if let Err(err) = written {
724            return Err(unwritten(e.key, format!("{path}: {err}")));
725        }
726    }
727    let mut o = oracle(tests, contracts, String::new(), by);
728    o.usage = spent;
729    o.reviewed = reviewed;
730    // What its author could not test, and why: shown, never blocking.
731    o.untestable = crate::oracle::load(e.plane_dir, e.feature, e.key)
732        .ok()
733        .flatten()
734        .map(|(d, _)| {
735            d.untestable
736                .sample1
737                .into_iter()
738                .filter(|u| e.scenarios.iter().any(|s| s.id == u.scenario))
739                .map(|u| (u.scenario, u.why))
740                .collect()
741        })
742        .unwrap_or_default();
743    let now = states(e.project, &o, e.runner);
744    o.at_oracle = now.clone();
745    let message = commit_message(&o, &now, None, None, None);
746    // Only the oracle's files (an oracle of no test is still recorded:
747    // later tasks ask no author).
748    let paths: Vec<String> = files
749        .iter()
750        .map(|(p, _)| {
751            e.project
752                .join(p)
753                .strip_prefix(e.wt_root)
754                .map_or_else(|_| p.clone(), |r| r.to_string_lossy().into_owned())
755        })
756        .collect();
757    o.commit = super::commit::commit_paths(e.wt_root, &message, &paths, true)
758        .map_err(|m| unwritten(e.key, format!("the oracle could not be committed: {m}")))?;
759    Ok((o, true, notices))
760}
761
762/// The oracle with the ruled scenarios' tests written again: every test
763/// citing a ruled scenario removed — and so every scenario such a test also
764/// cites written again with them — the new ones added, the frozen commands
765/// merged, committed (only those files) as an amendment naming the rulings.
766/// On any failure the old tests come back from the branch.
767fn amend(
768    e: &Ensure<'_>,
769    mut o: StoryOracle,
770    rulings: &[crate::acceptance_at::Ruling],
771) -> Result<StoryOracle, Box<speccoder_core::CommandResult>> {
772    let mut ruled: Vec<String> = rulings.iter().filter_map(|r| r.scenario.clone()).collect();
773    let (old, kept): (Vec<FrozenTest>, Vec<FrozenTest>) = std::mem::take(&mut o.tests)
774        .into_iter()
775        .partition(|t| t.scenarios.iter().any(|s| ruled.contains(s)));
776    for s in old.iter().flat_map(|t| &t.scenarios) {
777        if !ruled.contains(s) {
778            ruled.push(s.clone());
779        }
780    }
781    let told: Vec<String> = rulings
782        .iter()
783        .map(|r| {
784            format!(
785                "{} ruled {}'s tests wrong: {}",
786                r.by,
787                r.scenario.as_deref().unwrap_or_default(),
788                r.reason
789            )
790        })
791        .collect();
792    let rel = |path: &str| {
793        e.project
794            .join(path)
795            .strip_prefix(e.wt_root)
796            .map_or_else(|_| path.to_owned(), |p| p.to_string_lossy().into_owned())
797    };
798    let old_paths: Vec<String> = old.iter().map(|t| rel(&t.path)).collect();
799    let restore = |r: Box<speccoder_core::CommandResult>| {
800        let mut args = vec!["checkout", "HEAD", "--"];
801        args.extend(old_paths.iter().map(String::as_str));
802        let _ = crate::git::Git::at(e.wt_root).run(&args);
803        r
804    };
805    // Their paths are free for the new ones.
806    for t in &old {
807        let _ = std::fs::remove_file(e.project.join(&t.path));
808    }
809    let w = crate::oracle::write_for_run(
810        e.project,
811        e.wt_root,
812        e.feature,
813        e.state,
814        e.key,
815        e.runner.clone(),
816        (e.rt, e.plane),
817        (&ruled, &told),
818        "",
819        e.options,
820    )
821    .map_err(restore)?;
822    o.usage = crate::pipeline::commit::add_usage(o.usage.as_ref(), w.usage.as_ref());
823    for (path, content) in &w.contents {
824        let full = e.project.join(path);
825        let written = full
826            .parent()
827            .map_or(Ok(()), std::fs::create_dir_all)
828            .and_then(|()| std::fs::write(&full, content));
829        if let Err(err) = written {
830            return Err(restore(unwritten(e.key, format!("{path}: {err}"))));
831        }
832    }
833    let new_paths: Vec<String> = w.tests.iter().map(|t| rel(&t.path)).collect();
834    o.tests = kept.into_iter().chain(w.tests).collect();
835    for (id, c) in contracts_of(&w.contracts) {
836        o.contracts.insert(id, c);
837    }
838    for u in w.untestable.iter().filter(|u| ruled.contains(&u.scenario)) {
839        o.untestable.insert(u.scenario.clone(), u.why.clone());
840    }
841    crate::oracle::refreeze(e.plane_dir, e.feature, e.key, o.tests.clone())
842        .map_err(|m| restore(unwritten(e.key, m)))?;
843    let now = states(e.project, &o, e.runner);
844    let message = commit_message(
845        &o,
846        &now,
847        Some(
848            &rulings
849                .iter()
850                .filter_map(|r| r.scenario.as_deref())
851                .collect::<Vec<_>>()
852                .join(", "),
853        ),
854        None,
855        None,
856    );
857    let paths: Vec<String> = old_paths.iter().chain(&new_paths).cloned().collect();
858    o.commit = super::commit::commit_paths(e.wt_root, &message, &paths, true).map_err(|m| {
859        restore(unwritten(
860            e.key,
861            format!("the amendment could not be committed: {m}"),
862        ))
863    })?;
864    let acted: Vec<String> = rulings.iter().filter_map(|r| r.scenario.clone()).collect();
865    crate::acceptance_at::acted_on(e.plane_dir, e.feature, &acted)
866        .map_err(|m| unwritten(e.key, m))?;
867    Ok(o)
868}
869
870/// The scenario states the story's first oracle commit recorded.
871fn first_states(wt_root: &Path, story: &str) -> Option<BTreeMap<String, ScenarioState>> {
872    let out = crate::git::Git::at(wt_root)
873        .line(&[
874            "log",
875            "--reverse",
876            &format!("--grep=^{ORACLE}: {story}$"),
877            "--format=%(trailers:key=Oracle-Scenarios,valueonly,separator=%x2c)",
878            "HEAD",
879        ])
880        .ok()?;
881    out.lines().next().map(parse_states)
882}
883
884/// `US1-1=green,US1-2=red` → each scenario's state.
885fn parse_states(trailer: &str) -> BTreeMap<String, ScenarioState> {
886    trailer
887        .split(',')
888        .filter_map(|p| {
889            let (id, w) = p.trim().split_once('=')?;
890            let st = match w.trim() {
891                "green" => ScenarioState::Green,
892                "red" => ScenarioState::Red,
893                _ => ScenarioState::Untested,
894            };
895            Some((unesc(id.trim()), st))
896        })
897        .collect()
898}
899
900/// The `Oracle-Tests:` count an oracle commit records.
901fn tests_of(wt_root: &Path, commit: &str) -> Option<usize> {
902    crate::git::Git::at(wt_root)
903        .line(&[
904            "log",
905            "-n1",
906            "--format=%(trailers:key=Oracle-Tests,valueonly)",
907            commit,
908        ])
909        .ok()
910        .and_then(|v| v.trim().parse().ok())
911}
912
913/// `given → must hold`, by scenario: the first statement of each.
914fn contracts_of(e: &[speccoder_core::oracle::Expectation]) -> BTreeMap<String, String> {
915    let mut out = BTreeMap::new();
916    for x in e {
917        out.entry(x.scenario.clone())
918            .or_insert_with(|| format!("{} → {}", x.given.trim(), x.expect.trim()));
919    }
920    out
921}
922
923/// One oracle test's run on a tree.
924#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
925pub(crate) struct TestRun {
926    pub(crate) passed: bool,
927    pub(crate) class: speccoder_core::verify::RedClass,
928    /// The end of its output (what the red excerpt is cut from).
929    pub(crate) output: String,
930}
931
932/// The oracle's scenario states on a tree, with each test's run.
933#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
934pub(crate) struct Run {
935    pub(crate) states: BTreeMap<String, ScenarioState>,
936    /// By test path.
937    pub(crate) tests: BTreeMap<String, TestRun>,
938}
939
940/// The oracle's tests that are not, byte for byte, the frozen ones on the
941/// tree at `project`: changed, extended or gone.
942pub(crate) fn changed(project: &Path, oracle: &StoryOracle) -> Vec<String> {
943    oracle
944        .tests
945        .iter()
946        .filter(|t| {
947            std::fs::read(project.join(&t.path)).map_or(true, |b| {
948                format!("sha256:{}", crate::integrity::sha256_hex(&b)) != t.sha256
949            })
950        })
951        .map(|t| t.path.clone())
952        .collect()
953}
954
955/// Each scenario's state on the tree at `project`: green when every test
956/// citing it passes (each by its own command, in the sandbox), red when
957/// one fails, untested when none cites it.
958pub(crate) fn states(
959    project: &Path,
960    oracle: &StoryOracle,
961    runner: &sandbox::Runner,
962) -> BTreeMap<String, ScenarioState> {
963    run(project, oracle, runner).states
964}
965
966/// [`states`], with each test's run kept.
967pub(crate) fn run(project: &Path, oracle: &StoryOracle, runner: &sandbox::Runner) -> Run {
968    let tests: BTreeMap<String, TestRun> = oracle
969        .tests
970        .iter()
971        .map(|t| {
972            let (r, summary) = super::acceptance::acceptance_run(project, &t.argv, runner);
973            // Green is a test that ran and passed: a command that passes
974            // collecting nothing proves nothing.
975            let class = speccoder_core::verify::classify_red(&r, summary.as_ref());
976            let run = TestRun {
977                passed: r.status == CheckStatus::Passed
978                    && class == speccoder_core::verify::RedClass::Passed,
979                class,
980                output: format!("{}\n{}", r.stdout_tail, r.stderr_tail)
981                    .trim()
982                    .to_owned(),
983            };
984            (t.path.clone(), run)
985        })
986        .collect();
987    let passed: Vec<(&FrozenTest, bool)> = oracle
988        .tests
989        .iter()
990        .map(|t| (t, tests.get(&t.path).is_some_and(|r| r.passed)))
991        .collect();
992    let states = oracle
993        .scenarios
994        .iter()
995        .map(|s| {
996            let citing: Vec<bool> = passed
997                .iter()
998                .filter(|(t, _)| t.scenarios.contains(&s.id))
999                .map(|(_, ok)| *ok)
1000                .collect();
1001            let state = if citing.is_empty() {
1002                ScenarioState::Untested
1003            } else if citing.iter().all(|ok| *ok) {
1004                ScenarioState::Green
1005            } else {
1006                ScenarioState::Red
1007            };
1008            (s.id.clone(), state)
1009        })
1010        .collect();
1011    Run { states, tests }
1012}
1013
1014/// The evidence of a tick that turned scenarios green: the oracle's tests
1015/// of those scenarios, red before the attempt (their class and output) and
1016/// green after — the acceptance record attestations and the report read.
1017pub(crate) fn evidence(oracle: &StoryOracle, turned: &[String], before: &Run) -> serde_json::Value {
1018    let tests: Vec<&FrozenTest> = oracle
1019        .tests
1020        .iter()
1021        .filter(|t| t.scenarios.iter().any(|s| turned.contains(s)))
1022        .collect();
1023    let red: Vec<&TestRun> = tests
1024        .iter()
1025        .filter_map(|t| before.tests.get(&t.path))
1026        .filter(|r| r.class.is_red())
1027        .collect();
1028    // No run of its own before the attempt: red by the oracle's states,
1029    // its class not seen.
1030    let class = red
1031        .first()
1032        .map_or(speccoder_core::verify::RedClass::Unclassified, |r| r.class);
1033    serde_json::json!({
1034        // Frozen at review by a person, or written by the run.
1035        "source": if oracle.decided_by == "speccoder run" { "authored" } else { "review" },
1036        "oracle": oracle.commit,
1037        "files": tests.iter().map(|t| t.path.clone()).collect::<Vec<_>>(),
1038        "tests": tests
1039            .iter()
1040            .map(|t| serde_json::json!({ "path": t.path, "scenarios": t.scenarios, "argv": t.argv }))
1041            .collect::<Vec<_>>(),
1042        "argv": tests.first().map(|t| t.argv.clone()).unwrap_or_default(),
1043        "red": true,
1044        "red_class": class,
1045        "red_output": red.first().map(|r| r.output.clone()).unwrap_or_default(),
1046        "green": true,
1047        "pre_frozen": oracle.reviewed.as_ref().map(|(r, m)| serde_json::json!({
1048            "manifest": m,
1049            "approval": r.approval_digest,
1050            "wording": r.wording_digest,
1051            "by": r.decided.by,
1052            "via": r.decided.via,
1053        })),
1054    })
1055}
1056
1057/// A scenario id as a trailer or body word: `%`, `,`, `=`, `:`, spaces and
1058/// parentheses escaped (an OpenSpec scenario is named, not numbered).
1059fn esc(id: &str) -> String {
1060    let mut out = String::new();
1061    for c in id.chars() {
1062        match c {
1063            '%' | ',' | '=' | ':' | ' ' | '(' | ')' | '\n' | '\r' => {
1064                for b in c.to_string().bytes() {
1065                    out.push_str(&format!("%{b:02X}"));
1066                }
1067            }
1068            c => out.push(c),
1069        }
1070    }
1071    out
1072}
1073
1074/// [`esc`] undone.
1075fn unesc(word: &str) -> String {
1076    let b = word.as_bytes();
1077    let mut out = Vec::with_capacity(b.len());
1078    let mut i = 0;
1079    while i < b.len() {
1080        if b[i] == b'%'
1081            && let Some(h) = word.get(i + 1..i + 3)
1082            && let Ok(v) = u8::from_str_radix(h, 16)
1083        {
1084            out.push(v);
1085            i += 3;
1086        } else {
1087            out.push(b[i]);
1088            i += 1;
1089        }
1090    }
1091    String::from_utf8_lossy(&out).into_owned()
1092}
1093
1094/// `US1-1=red, US1-2=green, US1-3=untested`.
1095pub(crate) fn states_trailer(order: &[String], states: &BTreeMap<String, ScenarioState>) -> String {
1096    order
1097        .iter()
1098        .map(|id| {
1099            let word = match states.get(id).copied().unwrap_or(ScenarioState::Untested) {
1100                ScenarioState::Green => "green",
1101                ScenarioState::Red => "red",
1102                ScenarioState::Untested => "untested",
1103            };
1104            format!("{}={word}", esc(id))
1105        })
1106        .collect::<Vec<_>>()
1107        .join(", ")
1108}
1109
1110/// The oracle commit's message: what it is, each scenario's contract, and
1111/// its trailers.
1112pub(crate) fn commit_message(
1113    oracle: &StoryOracle,
1114    states: &BTreeMap<String, ScenarioState>,
1115    amends: Option<&str>,
1116    run: Option<&str>,
1117    usage: Option<&serde_json::Value>,
1118) -> String {
1119    let order = oracle.order();
1120    let red = order
1121        .iter()
1122        .filter(|id| states.get(*id) != Some(&ScenarioState::Green))
1123        .count();
1124    let title = match amends {
1125        Some(s) => format!("speccoder: oracle {} amended ({s})", oracle.story),
1126        None => format!(
1127            "speccoder: oracle {} — {} scenario(s), {red} to turn green",
1128            oracle.story,
1129            order.len()
1130        ),
1131    };
1132    let contracts: Vec<String> = order
1133        .iter()
1134        .filter_map(|id| {
1135            oracle
1136                .contracts
1137                .get(id)
1138                .map(|c| format!("{}: {}", esc(id), c.replace('\n', " ")))
1139        })
1140        .collect();
1141    let mut trailers = vec![
1142        format!("{ORACLE}: {}", oracle.story),
1143        format!("Oracle-Scenarios: {}", states_trailer(&order, states)),
1144        format!("Oracle-Tests: {}", oracle.tests.len()),
1145        format!("Oracle-By: {}", oracle.decided_by),
1146    ];
1147    for (id, why) in &oracle.untestable {
1148        trailers.push(format!(
1149            "Oracle-Untestable: {} ({})",
1150            esc(id),
1151            why.replace(['\n', '(', ')'], " ").trim()
1152        ));
1153    }
1154    if let Some(s) = amends {
1155        trailers.push(format!("Oracle-Amends: {s}"));
1156    }
1157    if let Some(r) = run.filter(|r| !r.is_empty()) {
1158        trailers.push(format!("Run: {r}"));
1159    }
1160    let usage = crate::attempts::usage_trailer(usage);
1161    if !usage.is_empty() {
1162        trailers.push(usage);
1163    }
1164    let body = if contracts.is_empty() {
1165        String::new()
1166    } else {
1167        format!("{}\n\n", contracts.join("\n"))
1168    };
1169    format!("{title}\n\n{body}{}\n", trailers.join("\n"))
1170}
1171
1172/// What an attempt did to its story, folded into the run report's
1173/// verdict: a regression, open links or an open story fail it (naming the
1174/// scenarios and their contracts); progress verifies it; none keeps it
1175/// regression-only. The checks' own verdict stands when it failed.
1176pub(crate) fn judge(
1177    oracle: &StoryOracle,
1178    before: &BTreeMap<String, ScenarioState>,
1179    after: &BTreeMap<String, ScenarioState>,
1180    links: &[String],
1181    checkpoint: bool,
1182    report: &mut speccoder_core::RunReport,
1183) {
1184    let untestable: Vec<String> = oracle.untestable.keys().cloned().collect();
1185    let p = speccoder_core::story::progress(
1186        &oracle.order(),
1187        before,
1188        after,
1189        links,
1190        checkpoint,
1191        &untestable,
1192    );
1193    let name = |ids: &[String]| -> Vec<String> {
1194        ids.iter()
1195            .map(|id| match oracle.contracts.get(id) {
1196                Some(c) => format!("{id} ({c})"),
1197                None => id.clone(),
1198            })
1199            .collect()
1200    };
1201    let failed = match &p {
1202        Progress::Regressed { scenarios } => Some(format!(
1203            "scenarios green before the attempt are red after it: {}",
1204            name(scenarios).join("; ")
1205        )),
1206        Progress::LinksOpen { scenarios } => Some(format!(
1207            "the scenarios this task names are not green: {}",
1208            name(scenarios).join("; ")
1209        )),
1210        Progress::StoryOpen { scenarios } => Some(format!(
1211            "the story's last task left scenarios red: {}",
1212            name(scenarios).join("; ")
1213        )),
1214        _ => None,
1215    };
1216    report.progress = Some(p.clone());
1217    match (report.verdict.take(), failed) {
1218        (Some(Verdict::Failed { mut reasons }), Some(why)) => {
1219            reasons.push(why);
1220            report.verdict = Some(Verdict::Failed { reasons });
1221        }
1222        (Some(v @ Verdict::Failed { .. }), None) => report.verdict = Some(v),
1223        (_, Some(why)) => report.verdict = Some(Verdict::Failed { reasons: vec![why] }),
1224        (v, None) => {
1225            report.verdict = match p {
1226                Progress::Verified { .. } => Some(Verdict::Verified),
1227                _ => v.map(|_| Verdict::RegressionOnly),
1228            };
1229        }
1230    }
1231}
1232
1233/// Whether the attempt's checks could hide a regression: a check failing
1234/// before and after it without per-test results.
1235pub(crate) fn unchecked(report: &speccoder_core::RunReport) -> bool {
1236    let Some(v) = report.verification.as_ref() else {
1237        return false;
1238    };
1239    let as_before = v
1240        .comparison
1241        .as_ref()
1242        .map(|c| c.failing_as_before.clone())
1243        .unwrap_or_default();
1244    v.checks
1245        .iter()
1246        .any(|c| as_before.contains(&c.name) && c.tests.is_none())
1247}
1248
1249/// The tasks of each story of a unit, in document order: by label (Spec
1250/// Kit), or the whole unit as one story (OpenSpec, `whole_unit`).
1251pub(crate) fn stories(
1252    tasks: &speccoder_core::snapshot::ParsedTasks,
1253    whole_unit: bool,
1254) -> Vec<(Option<&str>, Vec<&speccoder_core::snapshot::Task>)> {
1255    let mut out: Vec<(Option<&str>, Vec<&speccoder_core::snapshot::Task>)> = Vec::new();
1256    for t in &tasks.tasks {
1257        let label = if whole_unit { None } else { t.story.as_deref() };
1258        if !whole_unit && label.is_none() {
1259            continue;
1260        }
1261        match out.iter_mut().find(|(l, _)| *l == label) {
1262            Some((_, members)) => members.push(t),
1263            None => out.push((label, vec![t])),
1264        }
1265    }
1266    out
1267}
1268
1269/// The story's tasks as the rule sees them.
1270pub(crate) fn story_tasks<'a>(
1271    members: &[&'a speccoder_core::snapshot::Task],
1272    unit_dir: &str,
1273    judged: &dyn Fn(&str) -> bool,
1274) -> Vec<speccoder_core::story::StoryTask<'a>> {
1275    members
1276        .iter()
1277        .map(|t| speccoder_core::story::StoryTask {
1278            id: &t.id,
1279            checks_only: super::acceptance::checks_only(t, unit_dir, judged),
1280            done: t.done,
1281            links: &t.scenarios,
1282        })
1283        .collect()
1284}
1285
1286/// What an oracle commit on `rev` says: its short id, who froze it, each
1287/// scenario's state when it was written, and each scenario's contract.
1288struct Written {
1289    commit: String,
1290    by: String,
1291    states: BTreeMap<String, ScenarioState>,
1292    contracts: BTreeMap<String, String>,
1293    untestable: BTreeMap<String, String>,
1294}
1295
1296fn written(repo: &Path, rev: &str, story: &str) -> Option<Written> {
1297    let out = crate::git::Git::at(repo)
1298        .line(&[
1299            "log",
1300            "-n1",
1301            &format!("--grep=^{ORACLE}: {story}$"),
1302            "--format=%h%x1f%(trailers:key=Oracle-Scenarios,valueonly,separator=%x2c)%x1f%(trailers:key=Oracle-By,valueonly)%x1f%(trailers:key=Oracle-Untestable,valueonly,separator=%x1d)%x1f%b",
1303            rev,
1304            "--",
1305        ])
1306        .ok()?;
1307    let mut parts = out.splitn(5, '\u{1f}');
1308    let commit = parts.next()?.trim().to_owned();
1309    if commit.is_empty() {
1310        return None;
1311    }
1312    let states = parse_states(parts.next().unwrap_or_default());
1313    let by = parts.next().unwrap_or_default().trim().to_owned();
1314    let untestable = parts
1315        .next()
1316        .unwrap_or_default()
1317        .split('\u{1d}')
1318        .filter_map(|u| {
1319            let (id, why) = u.trim().split_once(' ')?;
1320            Some((
1321                unesc(id),
1322                why.trim_matches(|c| c == '(' || c == ')').to_owned(),
1323            ))
1324        })
1325        .collect();
1326    let contracts = parts
1327        .next()
1328        .unwrap_or_default()
1329        .lines()
1330        // The body's first paragraph: the contracts (a trailer read as one
1331        // names no scenario, so no lookup finds it).
1332        .take_while(|l| !l.trim().is_empty())
1333        .filter_map(|l| {
1334            let (id, c) = l.split_once(": ")?;
1335            Some((unesc(id), c.trim().to_owned()))
1336        })
1337        .collect();
1338    Some(Written {
1339        commit,
1340        by: if by.is_empty() {
1341            "speccoder run".to_owned()
1342        } else {
1343            by
1344        },
1345        states,
1346        contracts,
1347        untestable,
1348    })
1349}
1350
1351/// Who turned each scenario green last, by the unit branch's history read
1352/// newest first: a tick whose `Scenarios:` names it (by digest), unless an
1353/// oracle commit rewrote its tests after (`Oracle-Amends:`, or the oracle
1354/// written anew) — then nobody yet.
1355/// The scenarios a tick on the branch records a person accepted as
1356/// untestable (`Untestable-Accepted: US2-2 by …`).
1357pub(crate) fn accepted_on_branch(repo: &Path, rev: &str) -> Vec<String> {
1358    crate::git::Git::at(repo)
1359        .run(&[
1360            "log",
1361            "--first-parent",
1362            "--format=%(trailers:key=Untestable-Accepted,valueonly,separator=%x1e)",
1363            rev,
1364            "--",
1365        ])
1366        .map(|b| String::from_utf8_lossy(&b).into_owned())
1367        .unwrap_or_default()
1368        .split(['\u{1e}', '\n'])
1369        .filter_map(|v| v.split_whitespace().next().map(str::to_owned))
1370        .collect()
1371}
1372
1373fn turned_by(
1374    repo: &Path,
1375    rev: &str,
1376    feature: &str,
1377    story: &str,
1378    digests: &BTreeMap<String, String>,
1379) -> BTreeMap<String, String> {
1380    let log = crate::git::Git::at(repo)
1381        .run(&[
1382            "log",
1383            "--first-parent",
1384            "--format=%(trailers:key=Spec-Task,valueonly,separator=%x20)%x1f%(trailers:key=Scenarios,valueonly,separator=%x20)%x1f%(trailers:key=Oracle,valueonly)%x1f%(trailers:key=Oracle-Amends,valueonly,separator=%x2c)%x1e",
1385            rev,
1386            "--",
1387        ])
1388        .map(|b| String::from_utf8_lossy(&b).into_owned())
1389        .unwrap_or_default();
1390    let mut out = BTreeMap::new();
1391    let mut settled: Vec<String> = Vec::new();
1392    for record in log.split('\u{1e}') {
1393        let f: Vec<&str> = record.trim_start_matches('\n').split('\u{1f}').collect();
1394        let [task, marked, oracle, amends] = f[..] else {
1395            continue;
1396        };
1397        if oracle.trim() == story {
1398            let amended: Vec<String> = amends
1399                .split(',')
1400                .map(|a| a.trim().to_owned())
1401                .filter(|a| !a.is_empty())
1402                .collect();
1403            // Written anew (no amendment named): every scenario is settled.
1404            if amended.is_empty() {
1405                break;
1406            }
1407            settled.extend(amended);
1408            continue;
1409        }
1410        let Some(task) = task.trim().strip_prefix(&format!("{feature}/")) else {
1411            continue;
1412        };
1413        for token in marked.split(|c: char| c == ',' || c.is_whitespace()) {
1414            let d = token.rsplit('@').next().unwrap_or_default();
1415            if let Some((id, _)) = digests.iter().find(|(_, dg)| *dg == d)
1416                && !settled.contains(id)
1417                && !out.contains_key(id)
1418            {
1419                out.insert(id.clone(), task.to_owned());
1420            }
1421        }
1422    }
1423    out
1424}
1425
1426/// What the report shows of a unit's stories.
1427pub(crate) struct StoryView<'a> {
1428    pub(crate) format: &'a dyn crate::format::Adapter,
1429    pub(crate) snapshot: &'a speccoder_core::snapshot::ProjectSnapshot,
1430    pub(crate) feature: &'a str,
1431    pub(crate) unit_dir: &'a str,
1432    pub(crate) tasks: &'a speccoder_core::snapshot::ParsedTasks,
1433    /// Where the unit's ticks are read: the repository and the revision of
1434    /// its branch (or `HEAD` in place).
1435    pub(crate) repo: &'a Path,
1436    pub(crate) rev: &'a str,
1437}
1438
1439/// What judges each open task of a story, and each story's scenarios with
1440/// their states at the branch's tip: green when a tick turned it (worded
1441/// as it is now) or it was green when the oracle was written, else as the
1442/// oracle commit recorded it (red before any oracle).
1443pub(crate) fn story_view(
1444    v: &StoryView<'_>,
1445) -> (
1446    BTreeMap<String, speccoder_core::story::Judged>,
1447    Vec<speccoder_core::story::StoryScenarios>,
1448) {
1449    use speccoder_core::story::{OracleMark, ScenarioMark, StoryScenarios, checkpoint, judged};
1450    let whole_unit = v.format.kind() == speccoder_core::spec::FormatKind::Openspec;
1451    let rules = v.format.rules().judged;
1452    let mut out = BTreeMap::new();
1453    let mut shown = Vec::new();
1454    // Tasks outside any story that cite scenarios (convergence).
1455    if !whole_unit {
1456        for t in v.tasks.tasks.iter().filter(|t| t.story.is_none()) {
1457            let scenarios = v.format.scenarios(v.snapshot, &t.id);
1458            if let Some(j) = super::acceptance::outside_story(t, &scenarios, v.unit_dir, &rules) {
1459                out.insert(t.id.clone(), j);
1460            }
1461        }
1462    }
1463    for (label, members) in stories(v.tasks, whole_unit) {
1464        let Some(first) = members.first() else {
1465            continue;
1466        };
1467        let scenarios = v.format.scenarios(v.snapshot, &first.id);
1468        if scenarios.is_empty() {
1469            continue;
1470        }
1471        let key = label.map_or_else(|| v.feature.to_owned(), str::to_owned);
1472        let tasks = story_tasks(&members, v.unit_dir, &rules);
1473        let ids: Vec<String> = scenarios.iter().map(|s| s.id.clone()).collect();
1474        for t in &members {
1475            if let Some(j) = judged(&tasks, &t.id, &ids) {
1476                out.insert(t.id.clone(), j);
1477            }
1478        }
1479        let oracle = written(v.repo, v.rev, &key);
1480        let by_digest: BTreeMap<String, String> = scenarios
1481            .iter()
1482            .map(|s| (s.id.clone(), super::commit::scenario_digest(&s.id, &s.text)))
1483            .collect();
1484        let turned = turned_by(v.repo, v.rev, v.feature, &key, &by_digest);
1485        let marks: Vec<ScenarioMark> = scenarios
1486            .iter()
1487            .map(|s| {
1488                let turned_by = turned.get(&s.id).cloned();
1489                let at_oracle = oracle.as_ref().and_then(|o| o.states.get(&s.id).copied());
1490                let state = match (&turned_by, at_oracle) {
1491                    (Some(_), _) | (None, Some(ScenarioState::Green)) => ScenarioState::Green,
1492                    (None, Some(ScenarioState::Untested)) => ScenarioState::Untested,
1493                    _ => ScenarioState::Red,
1494                };
1495                ScenarioMark {
1496                    id: s.id.clone(),
1497                    state,
1498                    turned_by,
1499                    contract: oracle
1500                        .as_ref()
1501                        .and_then(|o| o.contracts.get(&s.id).cloned()),
1502                }
1503            })
1504            .collect();
1505        let done = members.iter().all(|t| t.done);
1506        // Proven only red→green by a tick: a scenario its oracle, written
1507        // after its code, found green holds — it was never shown to turn.
1508        let accepted = accepted_on_branch(v.repo, v.rev);
1509        let proven = done
1510            && marks
1511                .iter()
1512                .all(|m| m.turned_by.is_some() || accepted.contains(&m.id));
1513        let before_oracle = done && oracle.is_none();
1514        shown.push(StoryScenarios {
1515            story: key,
1516            scenarios: marks,
1517            checkpoint: checkpoint(&tasks).map(str::to_owned),
1518            oracle: oracle.map(|o| OracleMark {
1519                commit: o.commit,
1520                decided_by: o.by,
1521            }),
1522            proven,
1523            before_oracle,
1524            finished: done,
1525        });
1526    }
1527    (out, shown)
1528}
1529
1530#[cfg(test)]
1531mod tests {
1532    use super::*;
1533
1534    fn oracle() -> StoryOracle {
1535        StoryOracle {
1536            story: "US1".into(),
1537            scenarios: (1..=2)
1538                .map(|k| ScenarioRef {
1539                    id: format!("US1-{k}"),
1540                    text: String::new(),
1541                })
1542                .collect(),
1543            tests: Vec::new(),
1544            contracts: [(
1545                "US1-1".to_owned(),
1546                "a table on stdin → its CSV on stdout".to_owned(),
1547            )]
1548            .into(),
1549            commit: "abc".into(),
1550            decided_by: "speccoder run".into(),
1551            held_out: None,
1552            held_out_summary: None,
1553            usage: None,
1554            reviewed: None,
1555            at_oracle: BTreeMap::new(),
1556            untestable: BTreeMap::new(),
1557        }
1558    }
1559
1560    #[test]
1561    fn a_check_failing_before_and_after_without_per_test_results_hides_regressions() {
1562        use speccoder_core::verify::{CheckResult, Comparison, VerifyReport};
1563        let check =
1564            |name: &str, tests: Option<Vec<speccoder_core::verify::TestResult>>| CheckResult {
1565                name: name.to_owned(),
1566                status: CheckStatus::Failed,
1567                tests,
1568                ..serde_json::from_value(serde_json::json!({
1569                    "name": name, "argv": [], "status": "failed", "exit_code": 1, "duration_ms": 0,
1570                    "junit": false, "stdout_tail": "", "stderr_tail": "", "truncated": false
1571                }))
1572                .unwrap()
1573            };
1574        let report = |c: CheckResult| speccoder_core::RunReport {
1575            verification: Some(VerifyReport {
1576                root: String::new(),
1577                tier: "fast".to_owned(),
1578                checks: vec![c],
1579                comparison: Some(Comparison {
1580                    failing_as_before: vec!["test".to_owned()],
1581                    ..Comparison::default()
1582                }),
1583                passed: true,
1584            }),
1585            ..super::super::outcome::bare_report("001", "T001", "/w", "completed")
1586        };
1587        assert!(unchecked(&report(check("test", None))));
1588        assert!(!unchecked(&report(check("test", Some(Vec::new())))));
1589    }
1590
1591    #[test]
1592    fn the_oracle_commit_names_its_story_states_and_contracts() {
1593        let o = oracle();
1594        let st: BTreeMap<String, ScenarioState> = [
1595            ("US1-1".to_owned(), ScenarioState::Red),
1596            ("US1-2".to_owned(), ScenarioState::Untested),
1597        ]
1598        .into();
1599        let m = commit_message(&o, &st, None, Some("run_1"), None);
1600        assert!(m.starts_with("speccoder: oracle US1 — 2 scenario(s), 2 to turn green\n\nUS1-1: a table on stdin → its CSV on stdout\n\n"), "{m}");
1601        assert!(m.contains("\nOracle: US1\nOracle-Scenarios: US1-1=red, US1-2=untested\nOracle-Tests: 0\nOracle-By: speccoder run\nRun: run_1\n"), "{m}");
1602    }
1603
1604    #[test]
1605    fn a_named_scenario_survives_the_trailer() {
1606        let id = "Login: wrong password (3 times), 100% = locked";
1607        assert_eq!(unesc(&esc(id)), id);
1608        let read = parse_states(&format!("{}=green, US1-2=red", esc(id)));
1609        assert_eq!(read.get(id), Some(&ScenarioState::Green));
1610        assert_eq!(read.get("US1-2"), Some(&ScenarioState::Red));
1611    }
1612}