Skip to main content

warble_eval_runner/
context.rs

1//! MDL-version reverify — golden lifecycle (roadmap Phase 1.4 step 6).
2//!
3//! A golden's `context_version` pins the Wren fixture context it was confirmed against. Goldens
4//! stamped before the dispatcher retired its knowledge-injection mode were confirmed against an
5//! embedded-rules context that no current dispatch reproduces; the pin records the MDL, not the
6//! injection mode, so those stamps stay comparable. When the bound MDL changes, the golden's ground truth may have silently rotted. This module
7//! computes the **git SHA of the bound MDL files** (host-side, content-addressed via
8//! `git hash-object` — no ContextLoader, Phase-2-independent), compares it to the
9//! golden's pin, and flags a mismatch as `stale`. Stale goldens can be re-stamped (accept the new
10//! MDL) or re-verified (re-run to see which cases the MDL change actually moved).
11//!
12//! Only the MDL semantics travel into the SHA (`*.yml`/`*.yaml`/`*.md` under the project), so a pure
13//! connection/credential edit that leaves the models untouched does not spuriously mark goldens stale.
14
15use serde::Serialize;
16use std::path::{Path, PathBuf};
17use std::process::Command;
18
19/// Whether a golden's pinned `context_version` still matches the bound MDL.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
21#[serde(rename_all = "lowercase")]
22pub enum Freshness {
23    /// Pin is a SHA and matches the current MDL SHA (exact or abbreviated prefix).
24    Fresh,
25    /// Pin is a SHA but the MDL has since changed — ground truth may have rotted.
26    Stale,
27    /// Pin is symbolic (not a SHA), so it can't be checked; stamp it to start tracking.
28    Unpinned,
29}
30
31#[derive(Debug, Clone, Serialize)]
32pub struct VerifyResult {
33    pub freshness: Freshness,
34    /// The pin recorded in the golden's `context_version` (the part after `@`, or the whole value).
35    pub pinned: String,
36    /// The dataset prefix (the part before `@`), when present.
37    pub dataset: Option<String>,
38    /// The freshly computed MDL SHA.
39    pub current_sha: String,
40    pub detail: String,
41}
42
43/// Split a `context_version` (`<dataset>@<pin>`) into its dataset prefix and pin. With no `@`, the
44/// whole value is the pin and there is no dataset.
45pub fn parse_context_version(cv: &str) -> (Option<String>, String) {
46    match cv.split_once('@') {
47        Some((dataset, pin)) => (Some(dataset.to_string()), pin.to_string()),
48        None => (None, cv.to_string()),
49    }
50}
51
52/// A pin looks like a git SHA when it is 7–64 hex chars (git's abbreviated..full range).
53pub fn is_sha_like(pin: &str) -> bool {
54    let len = pin.len();
55    (7..=64).contains(&len) && pin.chars().all(|c| c.is_ascii_hexdigit())
56}
57
58/// Classify a pin against a freshly computed MDL SHA.
59pub fn classify(pin: &str, current_sha: &str) -> Freshness {
60    if !is_sha_like(pin) {
61        Freshness::Unpinned
62    } else if current_sha == pin || current_sha.starts_with(pin) {
63        Freshness::Fresh
64    } else {
65        Freshness::Stale
66    }
67}
68
69/// Collect the MDL files under `project` (`*.yml`/`*.yaml`/`*.md`), sorted by relative path, skipping
70/// runtime dirs (`.git`, `.claude`, `.venv`) that are not part of the semantic definition.
71fn mdl_files(project: &Path) -> Result<Vec<PathBuf>, String> {
72    let mut out = Vec::new();
73    collect(project, &mut out)?;
74    out.sort();
75    Ok(out)
76}
77
78fn collect(dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), String> {
79    for entry in std::fs::read_dir(dir).map_err(|e| format!("read {}: {e}", dir.display()))? {
80        let entry = entry.map_err(|e| e.to_string())?;
81        let path = entry.path();
82        let name = entry.file_name();
83        let name = name.to_string_lossy();
84        if path.is_dir() {
85            if matches!(name.as_ref(), ".git" | ".claude" | ".venv" | "node_modules") {
86                continue;
87            }
88            collect(&path, out)?;
89        } else if matches!(
90            path.extension().and_then(|e| e.to_str()),
91            Some("yml") | Some("yaml") | Some("md")
92        ) {
93            out.push(path);
94        }
95    }
96    Ok(())
97}
98
99/// One `git hash-object <file>` blob SHA (content-addressed; works outside a repo, stable across
100/// machines — the property we want for a pin).
101fn blob_sha(file: &Path) -> Result<String, String> {
102    let output = Command::new("git")
103        .arg("hash-object")
104        .arg(file)
105        .output()
106        .map_err(|e| format!("git hash-object {}: {e}", file.display()))?;
107    if !output.status.success() {
108        return Err(format!(
109            "git hash-object {} failed: {}",
110            file.display(),
111            String::from_utf8_lossy(&output.stderr).trim()
112        ));
113    }
114    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
115}
116
117/// Hash an arbitrary string to a stable 40-hex git blob SHA (content-addressed; no repo needed).
118/// Written via a temp file rather than a `--stdin` pipe, matching [`blob_sha`]. Shared by the MDL
119/// SHA, the agent-artifact SHA, and the trace-cache key hash — same content → same hash on any
120/// machine, which is exactly the property a cache key wants.
121pub(crate) fn hash_str(s: &str) -> Result<String, String> {
122    let tmp = tempfile::NamedTempFile::new().map_err(|e| format!("tempfile: {e}"))?;
123    std::fs::write(tmp.path(), s).map_err(|e| format!("write hash input: {e}"))?;
124    blob_sha(tmp.path())
125}
126
127/// Build a `<relpath> <blob-sha>\n` manifest over `files` (already sorted), relative to `root`.
128fn file_manifest(root: &Path, files: &[PathBuf]) -> Result<String, String> {
129    let mut manifest = String::new();
130    for file in files {
131        let rel = file.strip_prefix(root).unwrap_or(file);
132        manifest.push_str(&format!("{} {}\n", rel.display(), blob_sha(file)?));
133    }
134    Ok(manifest)
135}
136
137/// Compute a single stable SHA over all MDL files: hash each file's content (git blob SHA), build a
138/// sorted `<relpath> <sha>` manifest, and hash the manifest. Any content or path change moves it.
139pub fn compute_mdl_sha(project: &Path) -> Result<String, String> {
140    let files = mdl_files(project)?;
141    if files.is_empty() {
142        return Err(format!(
143            "no MDL files (*.yml/*.yaml/*.md) under {}",
144            project.display()
145        ));
146    }
147    hash_str(&file_manifest(project, &files)?)
148}
149
150/// Compute a stable SHA over every file under `dir` (all extensions, recursive), the same way
151/// [`compute_mdl_sha`] hashes MDL files. Used for the **agent-artifact SHA**: the dispatched agent
152/// dir (`.claude/agents/*.md`, `settings.json`, `.mcp.json`, …) fully determines the agent's
153/// behavior, so hashing it gives the `agent_sha` component of the trace-cache key — a change to any
154/// emitted file (prompt, tier binding, settings) moves the SHA and correctly misses the cache.
155pub(crate) fn compute_dir_sha(dir: &Path) -> Result<String, String> {
156    let mut files = Vec::new();
157    all_files(dir, &mut files)?;
158    if files.is_empty() {
159        return Err(format!("no files under {}", dir.display()));
160    }
161    files.sort();
162    hash_str(&file_manifest(dir, &files)?)
163}
164
165/// Compute a single file's content SHA (git blob SHA) — the single-artifact analogue of
166/// [`compute_dir_sha`], used for the **`claude-agent-sdk` back-end's agent-artifact SHA**: that
167/// back-end has no emitted agent dir to hash (it dispatches straight from a compiled `ir.json`), so
168/// the IR file itself stands in as the artifact whose content determines the run.
169pub(crate) fn compute_file_sha(file: &Path) -> Result<String, String> {
170    blob_sha(file)
171}
172
173/// Collect every regular file under `dir` recursively, skipping the same runtime dirs as
174/// [`collect`] (`.git`, `.venv`, `node_modules`) so build/VCS noise stays out of the SHA.
175fn all_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), String> {
176    for entry in std::fs::read_dir(dir).map_err(|e| format!("read {}: {e}", dir.display()))? {
177        let entry = entry.map_err(|e| e.to_string())?;
178        let path = entry.path();
179        let name = entry.file_name();
180        let name = name.to_string_lossy();
181        if path.is_dir() {
182            if matches!(name.as_ref(), ".git" | ".venv" | "node_modules" | "target") {
183                continue;
184            }
185            all_files(&path, out)?;
186        } else {
187            out.push(path);
188        }
189    }
190    Ok(())
191}
192
193/// Verify a golden's `context_version` against the bound project's current MDL SHA.
194pub fn verify_context(
195    golden_context_version: Option<&str>,
196    project: &Path,
197) -> Result<VerifyResult, String> {
198    let current_sha = compute_mdl_sha(project)?;
199    let cv = golden_context_version.unwrap_or("");
200    let (dataset, pinned) = parse_context_version(cv);
201    let freshness = if cv.is_empty() {
202        Freshness::Unpinned
203    } else {
204        classify(&pinned, &current_sha)
205    };
206    let detail = match freshness {
207        Freshness::Fresh => format!("pin '{pinned}' matches current MDL SHA"),
208        Freshness::Stale => format!(
209            "pin '{pinned}' != current MDL SHA {current_sha} — MDL changed; ground truth may be stale"
210        ),
211        Freshness::Unpinned => {
212            if cv.is_empty() {
213                format!("golden has no context_version; current MDL SHA is {current_sha}")
214            } else {
215                format!("pin '{pinned}' is symbolic (not a SHA); current MDL SHA is {current_sha}")
216            }
217        }
218    };
219    Ok(VerifyResult {
220        freshness,
221        pinned,
222        dataset,
223        current_sha,
224        detail,
225    })
226}
227
228/// Rewrite a golden YAML's `context_version:` line to `<dataset>@<sha>`, preserving the dataset
229/// prefix when the golden already had one (else falling back to `fallback_dataset`). Line-based so
230/// comments and formatting survive. Returns the new document text.
231pub fn stamp_context_version(
232    golden_text: &str,
233    sha: &str,
234    fallback_dataset: Option<&str>,
235) -> Result<String, String> {
236    let mut lines: Vec<String> = golden_text.lines().map(str::to_string).collect();
237    let idx = lines
238        .iter()
239        .position(|l| l.trim_start().starts_with("context_version:"));
240
241    // Derive the dataset prefix from the existing value, else the golden's `dataset:`, else none.
242    let existing_dataset = idx.and_then(|i| {
243        let after = lines[i].split_once("context_version:")?.1.trim();
244        // strip trailing inline comment
245        let value = after
246            .split_once('#')
247            .map(|(v, _)| v.trim())
248            .unwrap_or(after);
249        parse_context_version(value).0
250    });
251    let dataset = existing_dataset.or_else(|| fallback_dataset.map(str::to_string));
252    let new_value = match &dataset {
253        Some(d) => format!("{d}@{sha}"),
254        None => sha.to_string(),
255    };
256    let new_line = format!(
257        "context_version: {new_value}      # stamped by `warble eval verify-context --stamp`"
258    );
259
260    match idx {
261        Some(i) => lines[i] = new_line,
262        None => {
263            // Insert after `dataset:` if present, else at the top.
264            let insert_at = lines
265                .iter()
266                .position(|l| l.trim_start().starts_with("dataset:"))
267                .map(|i| i + 1)
268                .unwrap_or(0);
269            lines.insert(insert_at, new_line);
270        }
271    }
272    Ok(format!("{}\n", lines.join("\n")))
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278    use std::fs;
279
280    #[test]
281    fn parse_splits_dataset_and_pin() {
282        assert_eq!(
283            parse_context_version("jaffle_shop@a1b2c3d"),
284            (Some("jaffle_shop".into()), "a1b2c3d".into())
285        );
286        assert_eq!(parse_context_version("bareword"), (None, "bareword".into()));
287    }
288
289    #[test]
290    fn sha_like_detection() {
291        assert!(is_sha_like("a1b2c3d")); // 7 hex
292        assert!(is_sha_like("ce013625030ba8dba906f756967f9e9ca394464a")); // 40 hex
293        assert!(!is_sha_like("frozen-poc")); // has '-'
294        assert!(!is_sha_like("abc")); // too short
295    }
296
297    #[test]
298    fn classify_fresh_stale_unpinned() {
299        let sha = "ce013625030ba8dba906f756967f9e9ca394464a";
300        assert_eq!(classify(sha, sha), Freshness::Fresh);
301        assert_eq!(
302            classify("ce01362", sha),
303            Freshness::Fresh,
304            "abbrev prefix matches"
305        );
306        assert_eq!(classify("deadbeef", sha), Freshness::Stale);
307        assert_eq!(classify("frozen-poc", sha), Freshness::Unpinned);
308    }
309
310    #[test]
311    fn mdl_change_moves_the_sha_and_marks_stale() {
312        // The deliverable: an MDL edit must move the SHA → stale detection fires.
313        let dir = tempfile::tempdir().unwrap();
314        fs::create_dir_all(dir.path().join("models")).unwrap();
315        fs::write(
316            dir.path().join("wren_project.yml"),
317            "catalog: c\nschema: s\n",
318        )
319        .unwrap();
320        fs::write(
321            dir.path().join("models/orders.yml"),
322            "name: orders\ncolumns: [id, amount]\n",
323        )
324        .unwrap();
325
326        let sha_before = compute_mdl_sha(dir.path()).unwrap();
327        assert_eq!(sha_before.len(), 40, "40-hex git sha");
328
329        // A pin equal to the pre-change SHA is Fresh…
330        assert_eq!(classify(&sha_before, &sha_before), Freshness::Fresh);
331
332        // …edit an MDL file…
333        fs::write(
334            dir.path().join("models/orders.yml"),
335            "name: orders\ncolumns: [id, amount, discount]\n",
336        )
337        .unwrap();
338        let sha_after = compute_mdl_sha(dir.path()).unwrap();
339        assert_ne!(sha_before, sha_after, "MDL change must move the SHA");
340
341        // …and the old pin is now Stale against the new MDL.
342        let verify = verify_context(Some(&format!("jaffle@{sha_before}")), dir.path()).unwrap();
343        assert_eq!(verify.freshness, Freshness::Stale);
344        assert_eq!(verify.current_sha, sha_after);
345    }
346
347    #[test]
348    fn adding_a_model_file_moves_the_sha() {
349        let dir = tempfile::tempdir().unwrap();
350        fs::write(dir.path().join("a.yml"), "x: 1\n").unwrap();
351        let before = compute_mdl_sha(dir.path()).unwrap();
352        fs::write(dir.path().join("b.yml"), "y: 2\n").unwrap();
353        let after = compute_mdl_sha(dir.path()).unwrap();
354        assert_ne!(before, after, "adding an MDL file changes the manifest SHA");
355    }
356
357    #[test]
358    fn context_report_or_prompt_change_moves_the_agent_sha() {
359        let dir = tempfile::tempdir().unwrap();
360        fs::create_dir_all(dir.path().join(".claude/agents")).unwrap();
361        fs::write(
362            dir.path().join(".claude/agents/answer.md"),
363            "Context injection mode: schema-only\n",
364        )
365        .unwrap();
366        fs::write(
367            dir.path().join("context-report.json"),
368            r#"{"mode":"schema-only"}"#,
369        )
370        .unwrap();
371        let schema_only = compute_dir_sha(dir.path()).unwrap();
372
373        fs::write(
374            dir.path().join(".claude/agents/answer.md"),
375            "Context injection mode: schema+knowledge\nRULE\n",
376        )
377        .unwrap();
378        fs::write(
379            dir.path().join("context-report.json"),
380            r#"{"mode":"schema+knowledge"}"#,
381        )
382        .unwrap();
383        let with_knowledge = compute_dir_sha(dir.path()).unwrap();
384
385        assert_ne!(
386            schema_only, with_knowledge,
387            "context identity must miss cache"
388        );
389    }
390
391    #[test]
392    fn stamp_rewrites_pin_preserving_dataset_and_comments() {
393        let golden = "# header\ndataset: jaffle_shop\ncontext_version: jaffle_shop@frozen-poc   # pin\ncases: []\n";
394        let stamped = stamp_context_version(golden, "abc1234def", None).unwrap();
395        assert!(stamped.contains("context_version: jaffle_shop@abc1234def"));
396        assert!(stamped.contains("# header"), "comments preserved");
397        assert!(stamped.contains("cases: []"));
398        assert!(!stamped.contains("frozen-poc"), "old pin replaced");
399    }
400
401    #[test]
402    fn stamp_inserts_when_absent_using_fallback_dataset() {
403        let golden = "dataset: shop\ncases: []\n";
404        let stamped = stamp_context_version(golden, "abc1234", Some("shop")).unwrap();
405        assert!(stamped.contains("context_version: shop@abc1234"));
406    }
407}