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