Skip to main content

speccoder_runtime/
held_out.rs

1//! Held-out acceptance tests: written by a second author call
2//! after the visible ones, kept in the state directory — never in the
3//! worktree, never in an implementer's input — red-checked on the
4//! checkpoint, and run over the final tree after an attempt that passed
5//! visibly. Only the scenario IDs of a failure reach a retry.
6
7use std::collections::BTreeSet;
8use std::path::{Path, PathBuf};
9
10use serde::{Deserialize, Serialize};
11use speccoder_core::verify::{CheckResult, RunnerSummary};
12
13use crate::git::Git;
14use crate::integrity::{JudgeSnapshot, sha256_hex};
15use crate::tools::policy::Policy;
16use crate::{sandbox, worktree};
17
18/// Held-out files per task, composition tests included.
19pub const LIMIT: usize = 4;
20
21/// One kept held-out test file.
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23pub struct HeldOutFile {
24    /// Workspace-relative path it is written to for a run.
25    pub path: String,
26    /// SHA-256 of its content.
27    pub sha256: String,
28    /// The scenarios it covers.
29    pub scenarios: Vec<String>,
30    /// The command that runs only it.
31    pub argv: Vec<String>,
32    /// It exercises several scenarios together.
33    pub composition: bool,
34    /// Tests its red run collected: its green run must collect as many.
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub red_tests: Option<usize>,
37}
38
39/// A file the author wrote that was not kept, and why.
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
41pub struct Discarded {
42    /// Its path as given.
43    pub path: String,
44    /// `unknown_scenario`, `not_covered_by_visible`, `path`, `command`,
45    /// `not_red`, `collision` or `limit`.
46    pub reason: String,
47}
48
49/// What judged a task, stored beside the files' contents.
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51pub struct Manifest {
52    /// Format version.
53    pub version: u32,
54    /// The feature (or change).
55    pub feature: String,
56    /// The task.
57    pub task: String,
58    /// The author run.
59    pub author_run: String,
60    /// The tree the red runs used.
61    pub checkpoint: String,
62    /// Scenarios this task's acceptance tests cover.
63    pub visible_scenarios: Vec<String>,
64    /// Kept files.
65    pub files: Vec<HeldOutFile>,
66    /// Files not kept.
67    pub discarded: Vec<Discarded>,
68}
69
70impl Manifest {
71    /// SHA-256 of the canonical JSON: what the ticking commit names.
72    #[must_use]
73    pub fn sha256(&self) -> String {
74        sha256_hex(&serde_json::to_vec(self).unwrap_or_default())
75    }
76}
77
78/// Where a task's held-out set lives: `<project state>/held-out/<feature>/<task>`.
79#[must_use]
80pub fn dir(project_state: &Path, feature: &str, task: &str) -> PathBuf {
81    project_state.join("held-out").join(feature).join(task)
82}
83
84/// Stores the manifest and the kept files' contents; returns the
85/// manifest's hash. A previous set for the task is replaced.
86pub fn store(
87    dir: &Path,
88    manifest: &Manifest,
89    contents: &[(String, String)],
90) -> Result<String, String> {
91    let _ = std::fs::remove_dir_all(dir);
92    for (path, content) in contents {
93        let file = dir.join("files").join(path);
94        if let Some(parent) = file.parent() {
95            std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
96        }
97        std::fs::write(&file, content).map_err(|e| format!("{}: {e}", file.display()))?;
98    }
99    std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
100    let text = serde_json::to_string_pretty(manifest).map_err(|e| e.to_string())?;
101    std::fs::write(dir.join("manifest.json"), text).map_err(|e| e.to_string())?;
102    Ok(manifest.sha256())
103}
104
105/// Where a sealed set lives: `<project state>/held-out/<unit>/req-<key>/`
106/// (run-time task sets stay at `<unit>/<task>/`).
107#[must_use]
108pub fn sealed_dir(project_state: &Path, unit: &str, key: &str) -> PathBuf {
109    project_state
110        .join("held-out")
111        .join(unit)
112        .join(format!("req-{}", key.replace([':', '/'], "-")))
113}
114
115/// Seals a held-out set keyed by `(unit, requirement)` — the entry point
116/// for sets decided before a run (a bug's reproduction variants, a
117/// review's oracle): the one store protocol, each manifest entry citing
118/// its statements or scenarios.
119pub fn seal(
120    project_state: &Path,
121    unit: &str,
122    key: &str,
123    manifest: &Manifest,
124    contents: &[(String, String)],
125) -> Result<speccoder_core::acceptance::SealedRef, String> {
126    let sha = store(&sealed_dir(project_state, unit, key), manifest, contents)?;
127    Ok(speccoder_core::acceptance::SealedRef {
128        key: key.to_owned(),
129        manifest_sha256: sha,
130        count: manifest.files.len(),
131    })
132}
133
134/// A sealed set, checked against its reference.
135pub fn open_sealed(
136    project_state: &Path,
137    unit: &str,
138    sealed: &speccoder_core::acceptance::SealedRef,
139) -> Result<(Manifest, Vec<(String, String)>), Tampered> {
140    load_verified(
141        &sealed_dir(project_state, unit, &sealed.key),
142        &sealed.manifest_sha256,
143    )
144}
145
146/// A stored set that no longer matches what was recorded.
147#[derive(Debug, Clone, PartialEq, Eq)]
148pub struct Tampered(pub String);
149
150/// The stored set, checked: the manifest must hash to `expected` and every
151/// file to its recorded hash.
152pub fn load_verified(
153    dir: &Path,
154    expected: &str,
155) -> Result<(Manifest, Vec<(String, String)>), Tampered> {
156    let text = std::fs::read_to_string(dir.join("manifest.json"))
157        .map_err(|e| Tampered(format!("the held-out manifest is unreadable: {e}")))?;
158    let manifest: Manifest = serde_json::from_str(&text)
159        .map_err(|e| Tampered(format!("the held-out manifest is not valid: {e}")))?;
160    if manifest.sha256() != expected {
161        return Err(Tampered(
162            "the held-out manifest changed since it was written".to_owned(),
163        ));
164    }
165    let mut contents = Vec::new();
166    for f in &manifest.files {
167        let content = std::fs::read_to_string(dir.join("files").join(&f.path))
168            .map_err(|_| Tampered(format!("held-out file {} is missing", f.path)))?;
169        if sha256_hex(content.as_bytes()) != f.sha256 {
170            return Err(Tampered(format!("held-out file {} changed", f.path)));
171        }
172        contents.push((f.path.clone(), content));
173    }
174    Ok((manifest, contents))
175}
176
177/// One held-out file's run.
178#[derive(Debug, Clone)]
179pub struct Ran {
180    /// Its path.
181    pub path: String,
182    /// The run, or `None` when its path already existed (not overwritten).
183    pub result: Option<(CheckResult, Option<RunnerSummary>)>,
184    /// The same command on the tree without any held-out file, when asked
185    /// for ([`overlay_red`]): what the command fails anyway is not this
186    /// file's red.
187    pub without: Option<(CheckResult, Option<RunnerSummary>)>,
188}
189
190/// The marker a worktree carries while held-out files are overlaid on it:
191/// in its git admin directory (outside the tree), naming each overlaid
192/// path by its SHA-256 only — so a view of the worktree can hide them
193/// without the marker telling anyone what they are.
194const OVERLAY_MARKER: &str = "speccoder-held-out-overlay.json";
195
196fn overlay_marker(wt_root: &Path) -> Option<PathBuf> {
197    crate::git::Git::at(wt_root)
198        .line(&["rev-parse", "--absolute-git-dir"])
199        .ok()
200        .filter(|d| !d.is_empty())
201        .map(|d| PathBuf::from(d).join(OVERLAY_MARKER))
202}
203
204/// The digests of the worktree-relative paths overlaid on `wt_root` now:
205/// `Some(empty)` when nothing is; `None` when a marker exists that cannot
206/// be read (then nothing untracked may be shown).
207#[must_use]
208pub fn overlaid(wt_root: &Path) -> Option<Vec<String>> {
209    let Some(marker) = overlay_marker(wt_root) else {
210        return Some(Vec::new());
211    };
212    match std::fs::read_to_string(&marker) {
213        Ok(t) => serde_json::from_str(&t).ok(),
214        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Some(Vec::new()),
215        Err(_) => None,
216    }
217}
218
219/// The SHA-256 a worktree-relative path is named by in the marker.
220#[must_use]
221pub fn overlay_key(rel: &str) -> String {
222    sha256_hex(rel.as_bytes())
223}
224
225/// Removes the marker when the overlay ends, however it ends.
226struct OverlayMarker(Option<PathBuf>);
227
228impl Drop for OverlayMarker {
229    fn drop(&mut self) {
230        if let Some(m) = &self.0 {
231            let _ = std::fs::remove_file(m);
232        }
233    }
234}
235
236/// Writes each held-out file over the worktree, runs its command, and puts
237/// the tree back exactly as it was: no file of the set, nothing a test wrote,
238/// no changed judge input survives — a later attempt can read none of it.
239/// While it runs, the worktree's marker hides the files from its views.
240pub fn overlay_run(
241    wt_root: &Path,
242    project: &Path,
243    tmp: &Path,
244    files: &[(HeldOutFile, String)],
245    runner: &sandbox::Runner,
246    policy: &Policy,
247) -> Result<Vec<Ran>, String> {
248    overlay(wt_root, project, tmp, files, runner, policy, false)
249}
250
251/// One command's run: its result and the runner's summary, if read.
252type Outcome = (CheckResult, Option<RunnerSummary>);
253
254/// [`overlay_run`] for a red check: each distinct command also runs once on
255/// the tree without any held-out file, so a file is red only by the failing
256/// tests it adds ([`speccoder_core::verify::classify_red_added`]).
257pub fn overlay_red(
258    wt_root: &Path,
259    project: &Path,
260    tmp: &Path,
261    files: &[(HeldOutFile, String)],
262    runner: &sandbox::Runner,
263    policy: &Policy,
264) -> Result<Vec<Ran>, String> {
265    overlay(wt_root, project, tmp, files, runner, policy, true)
266}
267
268/// The red class of one held-out run: by what its file adds when the run
269/// without it is known, else by the run alone.
270#[must_use]
271pub fn red_class(ran: &Ran) -> speccoder_core::verify::RedClass {
272    use speccoder_core::verify::{RedClass, classify_red, classify_red_added};
273    match (&ran.result, &ran.without) {
274        (None, _) => RedClass::Unclassified,
275        (Some((r, s)), Some((o, os))) => classify_red_added((r, s.as_ref()), (o, os.as_ref())),
276        (Some((r, s)), None) => classify_red(r, s.as_ref()),
277    }
278}
279
280fn overlay(
281    wt_root: &Path,
282    project: &Path,
283    tmp: &Path,
284    files: &[(HeldOutFile, String)],
285    runner: &sandbox::Runner,
286    policy: &Policy,
287    baseline: bool,
288) -> Result<Vec<Ran>, String> {
289    let before = worktree::checkpoint(wt_root, tmp).map_err(|e| e.0)?;
290    let judged = JudgeSnapshot::take(wt_root, policy);
291    let prefix = {
292        let canonical = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
293        canonical(project)
294            .strip_prefix(canonical(wt_root))
295            .map(|r| r.to_string_lossy().replace('\\', "/"))
296            .unwrap_or_default()
297    };
298    // Each file and each directory above it (a new directory is listed
299    // untracked as a whole).
300    let mut keys: Vec<String> = Vec::new();
301    for (f, _) in files {
302        let rel = if prefix.is_empty() {
303            f.path.clone()
304        } else {
305            format!("{prefix}/{}", f.path)
306        };
307        let mut at = rel.as_str();
308        loop {
309            let key = overlay_key(at);
310            if !keys.contains(&key) {
311                keys.push(key);
312            }
313            match at.rfind('/') {
314                Some(i) => at = &at[..i],
315                None => break,
316            }
317        }
318    }
319    let marker = OverlayMarker(overlay_marker(wt_root));
320    if let Some(m) = &marker.0 {
321        std::fs::write(m, serde_json::to_string(&keys).unwrap_or_default())
322            .map_err(|e| format!("the held-out overlay marker: {e}"))?;
323    }
324    // Each distinct command once on the tree as it is, before any file.
325    let mut without: Vec<(Vec<String>, Outcome)> = Vec::new();
326    if baseline {
327        for (f, _) in files {
328            if !without.iter().any(|(argv, _)| *argv == f.argv) {
329                let ran = crate::pipeline::acceptance_run(project, &f.argv, runner);
330                without.push((f.argv.clone(), ran));
331            }
332        }
333    }
334    let without_of = |argv: &[String]| {
335        without
336            .iter()
337            .find(|(a, _)| a.as_slice() == argv)
338            .map(|(_, r)| r.clone())
339    };
340    let mut out = Vec::new();
341    for (f, content) in files {
342        let full = project.join(&f.path);
343        if full.exists() {
344            out.push(Ran {
345                path: f.path.clone(),
346                result: None,
347                without: None,
348            });
349            continue;
350        }
351        if let Some(parent) = full.parent() {
352            let _ = std::fs::create_dir_all(parent);
353        }
354        std::fs::write(&full, content).map_err(|e| format!("{}: {e}", f.path))?;
355        let ran = crate::pipeline::acceptance_run(project, &f.argv, runner);
356        let _ = std::fs::remove_file(&full);
357        out.push(Ran {
358            path: f.path.clone(),
359            result: Some(ran),
360            without: without_of(&f.argv),
361        });
362    }
363    worktree::restore(wt_root, tmp, &before).map_err(|e| e.0)?;
364    // What a test wrote to its private scratch would outlive the run.
365    if let Some(scratch) = runner.scratch() {
366        let _ = std::fs::remove_dir_all(&scratch);
367    }
368    let after = worktree::checkpoint(wt_root, tmp).map_err(|e| e.0)?;
369    if after != before {
370        return Err("the held-out run left changes in the worktree".to_owned());
371    }
372    let drift = judged.drift(
373        &JudgeSnapshot::take(wt_root, policy),
374        policy,
375        &BTreeSet::new(),
376    );
377    if let Some(d) = drift.first() {
378        return Err(format!("the held-out run changed judge input {}", d.path));
379    }
380    Ok(out)
381}
382
383/// Literals a copied test would carry: quoted strings of 6+ characters and
384/// numbers with 4+ significant digits.
385#[must_use]
386pub fn literals(text: &str) -> BTreeSet<String> {
387    let mut out = BTreeSet::new();
388    for quote in ['"', '\''] {
389        let mut parts = text.split(quote);
390        parts.next();
391        while let (Some(inside), Some(_)) = (parts.next(), parts.next().or(Some(""))) {
392            if inside.chars().count() >= 6 && !inside.contains('\n') {
393                out.insert(inside.to_owned());
394            }
395        }
396    }
397    let mut number = String::new();
398    for c in text.chars().chain(std::iter::once(' ')) {
399        if c.is_ascii_digit() || (c == '.' && !number.is_empty()) {
400            number.push(c);
401        } else {
402            let n = number.trim_end_matches('.');
403            let significant = n
404                .trim_start_matches(['0', '.'])
405                .chars()
406                .filter(char::is_ascii_digit)
407                .count();
408            if significant >= 4 {
409                out.insert(n.to_owned());
410            }
411            number.clear();
412        }
413    }
414    out
415}
416
417/// Held-out literals in the attempt's added lines that it could only have
418/// copied: not in the spec, the visible tests or the tree it started from
419///. For a native attempt this never fires; for an external
420/// agent, which can read the state directory, it is the mechanical check.
421#[must_use]
422pub fn leaked(
423    held_out: &[(HeldOutFile, String)],
424    known: &str,
425    wt_root: &Path,
426    start_tree: &worktree::Checkpoint,
427    patch: &str,
428) -> Vec<String> {
429    let added: String = patch
430        .lines()
431        .filter(|l| l.starts_with('+') && !l.starts_with("+++"))
432        .collect::<Vec<_>>()
433        .join("\n");
434    let git = Git::at(wt_root);
435    held_out
436        .iter()
437        .flat_map(|(_, content)| literals(content))
438        .collect::<BTreeSet<_>>()
439        .into_iter()
440        .filter(|lit| added.contains(lit.as_str()) && !known.contains(lit.as_str()))
441        .filter(|lit| {
442            !git.succeeds(&["grep", "-q", "-F", "-e", lit, &start_tree.0])
443                .unwrap_or(true)
444        })
445        .collect()
446}
447
448/// Where a unit's published held-out sets live.
449pub const REF_PREFIX: &str = "refs/speccoder/held-out";
450
451/// The held-out sets a publication carries: exactly those whose manifest
452/// digest a task closed on the pushed head names in its evidence.
453#[derive(Debug, Clone, Default, PartialEq, Eq)]
454pub struct Selection {
455    /// `(store key, its directory)` of every set carried.
456    pub sets: Vec<(String, PathBuf)>,
457    /// The closed tasks whose sets are carried, in order.
458    pub tasks: Vec<String>,
459    /// Stored sets no closed task names (open tasks'): they stay local.
460    pub withheld: usize,
461    /// Named sets that no longer match their digest: left out, by key.
462    pub tampered: Vec<String>,
463}
464
465/// The digests a task commit's evidence names: the signed `held_out`
466/// phase, else the commit's `Held-Out:` trailer.
467#[must_use]
468pub fn named_digests(repo: &Path, commit: &str) -> Vec<String> {
469    let mut out: Vec<String> = crate::evidence::statements(repo, commit)
470        .iter()
471        .filter(|s| s["predicate"]["configuration"][0]["name"] == "speccoder:phase/held_out")
472        .filter_map(|s| {
473            s["predicate"]["configuration"][0]["digest"]["sha256"]
474                .as_str()
475                .map(str::to_owned)
476        })
477        .collect();
478    let trailer = Git::at(repo)
479        .line(&[
480            "log",
481            "-1",
482            "--format=%(trailers:key=Held-Out,valueonly)",
483            commit,
484        ])
485        .unwrap_or_default();
486    out.extend(
487        trailer
488            .split_whitespace()
489            .filter_map(|w| w.strip_prefix("manifest=sha256:"))
490            .map(str::to_owned),
491    );
492    out.sort();
493    out.dedup();
494    out
495}
496
497/// Selects, from every set stored for `unit` (whatever its key: a task's
498/// today, a sealed set's once the store holds them — one protocol), those
499/// whose manifest digest a closed task names. `closed` is `(task, digests
500/// its evidence names)`. Each selected set is checked against its digest
501/// first; a tampered one is never published.
502#[must_use]
503pub fn select_closed(
504    project_state: &Path,
505    unit: &str,
506    closed: &[(String, Vec<String>)],
507) -> Selection {
508    let base = project_state.join("held-out").join(unit);
509    let mut stored: Vec<(String, PathBuf)> = std::fs::read_dir(&base)
510        .into_iter()
511        .flatten()
512        .flatten()
513        .filter(|e| e.path().join("manifest.json").is_file())
514        .map(|e| (e.file_name().to_string_lossy().into_owned(), e.path()))
515        .collect();
516    stored.sort();
517    let mut out = Selection::default();
518    for (key, dir) in stored {
519        let digest = std::fs::read_to_string(dir.join("manifest.json"))
520            .ok()
521            .and_then(|t| serde_json::from_str::<Manifest>(&t).ok())
522            .map(|m| m.sha256());
523        let named_by = digest.as_ref().and_then(|d| {
524            closed
525                .iter()
526                .find(|(_, digests)| digests.contains(d))
527                .map(|(task, _)| task.clone())
528        });
529        match (named_by, digest) {
530            (Some(task), Some(d)) => {
531                if load_verified(&dir, &d).is_ok() {
532                    if !out.tasks.contains(&task) {
533                        out.tasks.push(task);
534                    }
535                    out.sets.push((key, dir));
536                } else {
537                    out.tampered.push(key);
538                }
539            }
540            _ => out.withheld += 1,
541        }
542    }
543    out
544}
545
546/// The private repository publications are built in: a bare repository
547/// in the project's state directory that borrows the project's objects
548/// (`objects/info/alternates`), so the tip a publication is parented on
549/// resolves there while the held-out tests themselves never enter the
550/// project's repository. Every attempt can read that repository's refs
551/// and objects (`git show`, `git fsck --unreachable`, a script a check
552/// runs); none can read the state directory.
553///
554/// # Errors
555///
556/// The repository cannot be created, or the project's objects not found.
557pub fn publication_store(project_state: &Path, repo: &Path) -> Result<PathBuf, String> {
558    let dir = project_state.join("held-out.git");
559    if !dir.join("HEAD").is_file() {
560        std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
561        Git::at(&dir)
562            .run(&["init", "-q", "--bare"])
563            .map_err(|e| e.0)?;
564    }
565    let common = Git::at(repo)
566        .line(&["rev-parse", "--path-format=absolute", "--git-common-dir"])
567        .map_err(|e| e.0)?;
568    let info = dir.join("objects/info");
569    std::fs::create_dir_all(&info).map_err(|e| format!("{}: {e}", info.display()))?;
570    std::fs::write(
571        info.join("alternates"),
572        format!("{}\n", Path::new(&common).join("objects").display()),
573    )
574    .map_err(|e| format!("{}: {e}", info.display()))?;
575    // A publication an earlier version left in the project moves here:
576    // later pushes still fast-forward it, and no attempt finds it by name.
577    let project = Git::at(repo);
578    let legacy = project
579        .line(&[
580            "for-each-ref",
581            "--format=%(refname) %(objectname)",
582            REF_PREFIX,
583        ])
584        .map_err(|e| e.0)?;
585    let private = Git::at(&dir);
586    for line in legacy.lines() {
587        let Some((name, commit)) = line.split_once(' ') else {
588            continue;
589        };
590        if !private
591            .succeeds(&["rev-parse", "--verify", "-q", name])
592            .unwrap_or(false)
593        {
594            private
595                .run(&["update-ref", name, commit])
596                .map_err(|e| e.0)?;
597        }
598        project.run(&["update-ref", "-d", name]).map_err(|e| e.0)?;
599    }
600    Ok(dir)
601}
602
603/// Pushes `commit` (built in `store`) to `remote`'s
604/// `refs/speccoder/held-out/<unit>` from `repo`, reading the store's
605/// objects as alternates: nothing is written to `repo`, no local ref
606/// either.
607///
608/// # Errors
609///
610/// The push failed (not a fast-forward, no access).
611pub fn push(
612    repo: &Path,
613    store: &Path,
614    remote: &str,
615    unit: &str,
616    commit: &str,
617) -> Result<(), String> {
618    let objects = store.join("objects");
619    Git::at(repo)
620        .run_with_env(
621            &[
622                "push",
623                "--quiet",
624                remote,
625                &format!("{commit}:{REF_PREFIX}/{unit}"),
626            ],
627            &[("GIT_ALTERNATE_OBJECT_DIRECTORIES", objects.as_os_str())],
628        )
629        .map(|_| ())
630        .map_err(|e| e.0)
631}
632
633/// Publishes `selection` as one commit under
634/// `refs/speccoder/held-out/<unit>` of the private `store`
635/// ([`publication_store`]):
636/// `<key>/manifest.json` and `<key>/files/…` of every set, parented on
637/// `tip` (the pushed branch) and on the ref's previous commit, so the ref
638/// only grows and every push of it is a fast-forward. `Ok(None)` when
639/// nothing is selected.
640pub fn publish_closed(
641    store: &Path,
642    unit: &str,
643    tip: &str,
644    selection: &Selection,
645    tmp: &Path,
646) -> Result<Option<String>, String> {
647    let mut files: Vec<(String, PathBuf)> = Vec::new();
648    for (key, dir) in &selection.sets {
649        let mut stack = vec![dir.clone()];
650        while let Some(d) = stack.pop() {
651            let Ok(entries) = std::fs::read_dir(&d) else {
652                continue;
653            };
654            for e in entries.flatten() {
655                let p = e.path();
656                if p.is_dir() {
657                    stack.push(p);
658                } else if let Ok(rel) = p.strip_prefix(dir) {
659                    let rel = rel.to_string_lossy().replace('\\', "/");
660                    files.push((format!("{key}/{rel}"), p.clone()));
661                }
662            }
663        }
664    }
665    if files.is_empty() {
666        return Ok(None);
667    }
668    files.sort();
669    let git = Git::at(store);
670    std::fs::create_dir_all(tmp).map_err(|e| e.to_string())?;
671    let index = tmp.join(format!("held-out-index-{}", std::process::id()));
672    let _ = std::fs::remove_file(&index);
673    for (rel, path) in &files {
674        let blob = git
675            .line(&["hash-object", "-w", "--", &path.to_string_lossy()])
676            .map_err(|e| e.0)?;
677        git.run_with_index(
678            &index,
679            &[
680                "update-index",
681                "--add",
682                "--cacheinfo",
683                &format!("100644,{blob},{rel}"),
684            ],
685            None,
686        )
687        .map_err(|e| e.0)?;
688    }
689    let tree = git
690        .run_with_index(&index, &["write-tree"], None)
691        .map_err(|e| e.0)?;
692    let _ = std::fs::remove_file(&index);
693    let tree = String::from_utf8_lossy(&tree).trim().to_owned();
694    let reference = format!("{REF_PREFIX}/{unit}");
695    let previous = git
696        .line(&[
697            "rev-parse",
698            "--verify",
699            "-q",
700            &format!("{reference}^{{commit}}"),
701        ])
702        .ok()
703        .filter(|c| !c.is_empty());
704    // Nothing new since the last publication of this tip: keep it.
705    if let Some(prev) = &previous
706        && git
707            .line(&["rev-parse", &format!("{prev}^{{tree}}")])
708            .ok()
709            .as_deref()
710            == Some(&tree)
711        && git
712            .succeeds(&["merge-base", "--is-ancestor", tip, prev])
713            .unwrap_or(false)
714    {
715        return Ok(Some(prev.clone()));
716    }
717    let message = format!(
718        "speccoder: held-out tests of {unit}\n\nThe tests that judged the tasks closed on {tip}: {}.\nEach <key>/manifest.json hashes to the digest its task's evidence names.\n\nSpec-Feature: {unit}\n",
719        selection.tasks.join(", ")
720    );
721    let mut args = crate::attempts::IDENTITY.to_vec();
722    args.extend(["commit-tree", &tree, "-p", tip]);
723    if let Some(prev) = &previous {
724        args.extend(["-p", prev]);
725    }
726    args.extend(["-m", &message]);
727    let commit = git.line(&args).map_err(|e| e.0)?;
728    git.run(&["update-ref", &reference, &commit])
729        .map_err(|e| e.0)?;
730    Ok(Some(commit))
731}
732
733/// Publishes a finished feature's held-out sets in the private store
734/// (every task is closed then): [`publish_closed`] over the tasks the
735/// branch tip closes. Never pushed here; `speccoder pr` pushes it.
736/// `Ok(None)` when no closed task names a stored set.
737pub fn publish(
738    repo: &Path,
739    project_state: &Path,
740    feature: &str,
741    tmp: &Path,
742) -> Result<Option<String>, String> {
743    let branch = format!("speccoder/{feature}");
744    let tip = Git::at(repo)
745        .line(&["rev-parse", "--verify", &format!("{branch}^{{commit}}")])
746        .map_err(|e| e.0)?;
747    let closed = closed_on(repo, &tip, feature);
748    let selection = select_closed(project_state, feature, &closed);
749    if selection.sets.is_empty() {
750        return Ok(None);
751    }
752    publish_closed(
753        &publication_store(project_state, repo)?,
754        feature,
755        &tip,
756        &selection,
757        tmp,
758    )
759}
760
761/// The tasks of `unit` closed on `head` (ticked, with the commit carrying
762/// their verdict — the verifier's discovery), each with the held-out
763/// digests its evidence names.
764#[must_use]
765pub fn closed_on(repo: &Path, head: &str, unit: &str) -> Vec<(String, Vec<String>)> {
766    crate::attest::branch::closed(repo, head, unit)
767        .into_iter()
768        .map(|(task, commit)| {
769            let digests = named_digests(repo, &commit);
770            (task, digests)
771        })
772        .collect()
773}
774
775#[cfg(test)]
776mod tests {
777    use super::*;
778
779    #[test]
780    fn literals_are_long_strings_and_precise_numbers() {
781        let l = literals(
782            "assert_eq!(slug(\"Hello, World\"), \"hello-world\"); x(3.14159); y(42); z('ab')",
783        );
784        assert!(l.contains("Hello, World") && l.contains("hello-world"));
785        assert!(l.contains("3.14159"));
786        assert!(!l.contains("42") && !l.contains("ab"));
787    }
788
789    #[test]
790    fn a_changed_file_or_manifest_is_tampering() {
791        let d = tempfile::tempdir().unwrap();
792        let content = "fn t() {}\n".to_owned();
793        let m = Manifest {
794            version: 1,
795            feature: "f".to_owned(),
796            task: "T1".to_owned(),
797            author_run: "r".to_owned(),
798            checkpoint: "c".to_owned(),
799            visible_scenarios: vec!["US1-1".to_owned()],
800            files: vec![HeldOutFile {
801                path: "tests/h.rs".to_owned(),
802                sha256: sha256_hex(content.as_bytes()),
803                scenarios: vec!["US1-1".to_owned()],
804                argv: vec!["true".to_owned()],
805                composition: false,
806                red_tests: None,
807            }],
808            discarded: Vec::new(),
809        };
810        let sha = store(d.path(), &m, &[("tests/h.rs".to_owned(), content)]).unwrap();
811        assert_eq!(load_verified(d.path(), &sha).unwrap().1.len(), 1);
812        assert!(load_verified(d.path(), "other").is_err());
813        std::fs::write(d.path().join("files/tests/h.rs"), "fn t() { changed }").unwrap();
814        assert!(
815            load_verified(d.path(), &sha)
816                .unwrap_err()
817                .0
818                .contains("changed")
819        );
820        std::fs::remove_file(d.path().join("files/tests/h.rs")).unwrap();
821        assert!(
822            load_verified(d.path(), &sha)
823                .unwrap_err()
824                .0
825                .contains("missing")
826        );
827    }
828}