1use std::collections::{BTreeSet, HashMap};
8use std::fs;
9use std::path::{Path, PathBuf};
10
11use serde::{Deserialize, Serialize};
12use similar::{Algorithm, DiffTag, capture_diff_slices};
13use walkdir::{DirEntry, WalkDir};
14
15use crate::pipeline::error::PipelineError;
16use crate::pipeline::io::write_json;
17
18const BASELINE_DIR: &str = "diff-scope-baseline";
19const BASELINE_MANIFEST: &str = "manifest.json";
20const BASELINE_FILES: &str = "files";
21const RESULT_FILE: &str = "diff-scope.json";
22
23#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
24pub struct DiffScopeMetrics {
25 pub files_touched: u64,
26 pub lines_added: u64,
27 pub lines_removed: u64,
28 pub hunks: u64,
29}
30
31impl DiffScopeMetrics {
32 pub fn lines_changed(self) -> u64 {
33 self.lines_added.saturating_add(self.lines_removed)
34 }
35}
36
37#[derive(Debug, Serialize, Deserialize)]
38struct BaselineManifest {
39 preexisting_files: Vec<String>,
40}
41
42#[derive(Debug, Deserialize)]
43struct DispatchFile {
44 #[serde(default)]
45 tasks: Vec<DispatchTask>,
46}
47
48#[derive(Debug, Deserialize)]
49struct DispatchTask {
50 eval_id: String,
51 condition: String,
52 #[serde(default)]
53 run_index: Option<u32>,
54 eval_root: Option<String>,
55 run_record_path: String,
56}
57
58#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
59pub struct DiffScopeSummary {
60 pub measured: usize,
61 pub reused: usize,
62 pub missing_baseline: usize,
63 pub shared_environment: usize,
64}
65
66#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
67struct FileDiff {
68 lines_added: u64,
69 lines_removed: u64,
70 hunks: u64,
71}
72
73fn diff_bytes(old: &[u8], new: &[u8]) -> FileDiff {
74 let old_lines: Vec<&[u8]> = old.split_inclusive(|byte| *byte == b'\n').collect();
75 let new_lines: Vec<&[u8]> = new.split_inclusive(|byte| *byte == b'\n').collect();
76 let mut result = FileDiff::default();
77 let mut in_hunk = false;
78
79 for operation in capture_diff_slices(Algorithm::Myers, &old_lines, &new_lines) {
80 let (tag, old_range, new_range) = operation.as_tag_tuple();
81 match tag {
82 DiffTag::Equal => in_hunk = false,
83 DiffTag::Delete => {
84 if !in_hunk {
85 result.hunks += 1;
86 in_hunk = true;
87 }
88 result.lines_removed += old_range.len() as u64;
89 }
90 DiffTag::Insert => {
91 if !in_hunk {
92 result.hunks += 1;
93 in_hunk = true;
94 }
95 result.lines_added += new_range.len() as u64;
96 }
97 DiffTag::Replace => {
98 if !in_hunk {
99 result.hunks += 1;
100 in_hunk = true;
101 }
102 result.lines_removed += old_range.len() as u64;
103 result.lines_added += new_range.len() as u64;
104 }
105 }
106 }
107 result
108}
109
110pub fn capture_iteration_baselines(iteration_dir: &Path) -> Result<(), PipelineError> {
111 let dispatch_path = iteration_dir.join("dispatch.json");
112 let dispatch: DispatchFile = serde_json::from_str(&fs::read_to_string(&dispatch_path)?)?;
113 for task in dispatch.tasks {
114 let eval_root = task.eval_root.ok_or_else(|| {
115 PipelineError::Message(format!(
116 "dispatch task in {} has no eval_root for diff-scope capture",
117 dispatch_path.display()
118 ))
119 })?;
120 let run_dir = Path::new(&task.run_record_path).parent().ok_or_else(|| {
121 PipelineError::Message(format!(
122 "diff-scope task has no run directory in run_record_path: {}",
123 task.run_record_path
124 ))
125 })?;
126 fs::create_dir_all(run_dir)?;
127 capture_task_baseline(Path::new(&eval_root), run_dir)?;
128 }
129 Ok(())
130}
131
132pub fn measure_iteration_diff_scopes(
133 iteration_dir: &Path,
134) -> Result<DiffScopeSummary, PipelineError> {
135 let dispatch_path = iteration_dir.join("dispatch.json");
136 if !dispatch_path.exists() {
140 return Ok(DiffScopeSummary::default());
141 }
142 let dispatch: DispatchFile = serde_json::from_str(&fs::read_to_string(&dispatch_path)?)?;
143 let root_counts: HashMap<String, usize> = dispatch
144 .tasks
145 .iter()
146 .filter_map(|task| task.eval_root.as_deref())
147 .fold(HashMap::new(), |mut counts, root| {
148 *counts.entry(root.to_string()).or_default() += 1;
149 counts
150 });
151 let mut summary = DiffScopeSummary::default();
152
153 for task in dispatch.tasks {
154 let run_record_path = Path::new(&task.run_record_path);
155 let run_dir = run_record_path.parent().ok_or_else(|| {
156 PipelineError::Message(format!(
157 "diff-scope task has no run directory in run_record_path: {}",
158 task.run_record_path
159 ))
160 })?;
161 if !run_record_path.exists() {
162 continue;
163 }
164 let result_path = run_dir.join(RESULT_FILE);
165 if result_path.exists() {
166 let value = serde_json::from_str(&fs::read_to_string(&result_path)?)?;
167 crate::validation::validate_against_schema::<DiffScopeMetrics>(
168 crate::validation::SchemaName::DiffScope,
169 &value,
170 &result_path.to_string_lossy(),
171 )?;
172 summary.reused += 1;
173 continue;
174 }
175 let run_label = task
176 .run_index
177 .map(|run| format!("/run-{run}"))
178 .unwrap_or_default();
179 let Some(eval_root) = task.eval_root.as_deref() else {
180 eprintln!(
181 "warn: {}/{}{run_label} has no eval_root — diff-scope unavailable; rebuild the iteration to capture metrics",
182 task.eval_id, task.condition
183 );
184 summary.missing_baseline += 1;
185 continue;
186 };
187 if root_counts.get(eval_root).copied().unwrap_or_default() != 1 {
188 eprintln!(
189 "warn: {}/{}{run_label} shares eval_root with another task — diff-scope unavailable; rebuild the iteration for task-scoped environments",
190 task.eval_id, task.condition
191 );
192 summary.shared_environment += 1;
193 continue;
194 }
195 if !run_dir.join(BASELINE_DIR).join(BASELINE_MANIFEST).exists() {
196 eprintln!(
197 "warn: {}/{}{run_label} has no pre-dispatch baseline — diff-scope unavailable; rebuild the iteration to capture metrics",
198 task.eval_id, task.condition
199 );
200 summary.missing_baseline += 1;
201 continue;
202 }
203 if run_dir.join("command-checks").exists() {
204 return Err(PipelineError::Message(format!(
205 "cannot capture diff scope for {}/{}{run_label} after command checks have run; rebuild the iteration",
206 task.eval_id, task.condition
207 )));
208 }
209
210 let metrics = measure_task_diff(Path::new(eval_root), run_dir)?;
211 crate::validation::validate_against_schema::<DiffScopeMetrics>(
212 crate::validation::SchemaName::DiffScope,
213 &serde_json::to_value(metrics)?,
214 &result_path.to_string_lossy(),
215 )?;
216 write_json(&result_path, &metrics)?;
217 summary.measured += 1;
218 }
219 Ok(summary)
220}
221
222fn capture_task_baseline(eval_root: &Path, run_dir: &Path) -> Result<(), PipelineError> {
223 let baseline_dir = run_dir.join(BASELINE_DIR);
224 if baseline_dir.exists() {
225 fs::remove_dir_all(&baseline_dir)?;
226 }
227 let file_snapshot = baseline_dir.join(BASELINE_FILES);
228 fs::create_dir_all(&file_snapshot)?;
229
230 let excluded_roots = [
231 eval_root.join(".eval-magic-outputs"),
232 eval_root.join(".git"),
233 ];
234 let mut preexisting_paths = walk_files(eval_root, &excluded_roots)?;
235 preexisting_paths.sort();
236 let preexisting_files = preexisting_paths
237 .iter()
238 .map(|path| relative_key(eval_root, path))
239 .collect::<Result<Vec<_>, _>>()?;
240 write_json(
241 &baseline_dir.join(BASELINE_MANIFEST),
242 &BaselineManifest { preexisting_files },
243 )?;
244
245 for source in preexisting_paths {
246 let relative = source.strip_prefix(eval_root).map_err(|_| {
247 PipelineError::Message(format!(
248 "diff-scope baseline path {} is outside {}",
249 source.display(),
250 eval_root.display()
251 ))
252 })?;
253 copy_entry(&source, &file_snapshot.join(relative))?;
254 }
255 Ok(())
256}
257
258fn measure_task_diff(eval_root: &Path, run_dir: &Path) -> Result<DiffScopeMetrics, PipelineError> {
259 let baseline_dir = run_dir.join(BASELINE_DIR);
260 let manifest: BaselineManifest =
261 serde_json::from_str(&fs::read_to_string(baseline_dir.join(BASELINE_MANIFEST))?)?;
262 let file_snapshot = baseline_dir.join(BASELINE_FILES);
263 let mut candidates = BTreeSet::new();
264
265 for relative in manifest.preexisting_files {
266 if fs::symlink_metadata(file_snapshot.join(&relative)).is_err() {
267 return Err(PipelineError::Message(format!(
268 "diff-scope baseline is incomplete: missing snapshot for {relative}"
269 )));
270 }
271 candidates.insert(relative);
272 }
273 let excluded_roots = [
274 eval_root.join(".eval-magic-outputs"),
275 eval_root.join(".git"),
276 ];
277 for path in walk_files(eval_root, &excluded_roots)? {
278 candidates.insert(relative_key(eval_root, &path)?);
279 }
280
281 let mut metrics = DiffScopeMetrics::default();
282 for relative in candidates {
283 let before = file_snapshot.join(&relative);
284 let after = eval_root.join(&relative);
285 let old = file_content(&before)?;
286 let new = file_content(&after)?;
287 if old == new {
288 continue;
289 }
290 metrics.files_touched += 1;
291 let diff = match (old, new) {
292 (FileContent::Regular(old), FileContent::Regular(new)) => Some(diff_bytes(&old, &new)),
293 (FileContent::Missing, FileContent::Regular(new)) => Some(diff_bytes(&[], &new)),
294 (FileContent::Regular(old), FileContent::Missing) => Some(diff_bytes(&old, &[])),
295 _ => None,
296 };
297 if let Some(diff) = diff {
298 metrics.lines_added += diff.lines_added;
299 metrics.lines_removed += diff.lines_removed;
300 metrics.hunks += diff.hunks;
301 }
302 }
303 Ok(metrics)
304}
305
306#[derive(Debug, PartialEq, Eq)]
307enum FileContent {
308 Missing,
309 Regular(Vec<u8>),
310 Symlink(PathBuf),
311}
312
313fn file_content(path: &Path) -> Result<FileContent, PipelineError> {
314 let metadata = match fs::symlink_metadata(path) {
315 Ok(metadata) => metadata,
316 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
317 return Ok(FileContent::Missing);
318 }
319 Err(error) => return Err(error.into()),
320 };
321 if metadata.file_type().is_symlink() {
322 return Ok(FileContent::Symlink(fs::read_link(path)?));
323 }
324 if metadata.is_file() {
325 return Ok(FileContent::Regular(fs::read(path)?));
326 }
327 Ok(FileContent::Missing)
328}
329
330fn walk_files(root: &Path, excluded_roots: &[PathBuf]) -> Result<Vec<PathBuf>, PipelineError> {
331 if !root.exists() {
332 return Ok(Vec::new());
333 }
334 WalkDir::new(root)
335 .follow_links(false)
336 .into_iter()
337 .filter_entry(|entry| !is_excluded(entry, excluded_roots))
338 .filter_map(|entry| match entry {
339 Ok(entry) if entry.file_type().is_file() || entry.file_type().is_symlink() => {
340 Some(Ok(entry.into_path()))
341 }
342 Ok(_) => None,
343 Err(error) => Some(Err(PipelineError::Message(format!(
344 "could not walk diff-scope path under {}: {error}",
345 root.display()
346 )))),
347 })
348 .collect()
349}
350
351fn is_excluded(entry: &DirEntry, excluded_roots: &[PathBuf]) -> bool {
352 excluded_roots
353 .iter()
354 .any(|excluded| entry.path().starts_with(excluded))
355}
356
357fn relative_key(root: &Path, path: &Path) -> Result<String, PipelineError> {
358 let relative = path.strip_prefix(root).map_err(|_| {
359 PipelineError::Message(format!(
360 "diff-scope path {} is outside {}",
361 path.display(),
362 root.display()
363 ))
364 })?;
365 Ok(relative
366 .components()
367 .map(|component| component.as_os_str().to_string_lossy())
368 .collect::<Vec<_>>()
369 .join("/"))
370}
371
372fn copy_entry(source: &Path, destination: &Path) -> Result<(), PipelineError> {
373 let metadata = fs::symlink_metadata(source)?;
374 if metadata.file_type().is_symlink() {
375 if let Some(parent) = destination.parent() {
376 fs::create_dir_all(parent)?;
377 }
378 let target = fs::read_link(source)?;
379 #[cfg(unix)]
380 std::os::unix::fs::symlink(target, destination)?;
381 #[cfg(windows)]
382 if source.metadata().is_ok_and(|metadata| metadata.is_dir()) {
383 std::os::windows::fs::symlink_dir(target, destination)?;
384 } else {
385 std::os::windows::fs::symlink_file(target, destination)?;
386 }
387 } else if metadata.is_dir() {
388 fs::create_dir_all(destination)?;
389 for entry in fs::read_dir(source)? {
390 let entry = entry?;
391 copy_entry(&entry.path(), &destination.join(entry.file_name()))?;
392 }
393 } else {
394 if let Some(parent) = destination.parent() {
395 fs::create_dir_all(parent)?;
396 }
397 fs::copy(source, destination)?;
398 }
399 Ok(())
400}
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405 use std::fs;
406
407 #[test]
408 fn byte_line_diff_counts_changes_and_zero_context_hunks() {
409 let diff = diff_bytes(b"old\nsame\nbefore\n", b"new\nsame\nafter\n");
410 assert_eq!(diff.lines_added, 2);
411 assert_eq!(diff.lines_removed, 2);
412 assert_eq!(diff.hunks, 2);
413 }
414
415 #[test]
416 fn byte_line_diff_handles_empty_trailing_newline_and_non_utf8_inputs() {
417 assert_eq!(diff_bytes(b"", b""), FileDiff::default());
418
419 let trailing = diff_bytes(b"value", b"value\n");
420 assert_eq!(trailing.lines_added, 1);
421 assert_eq!(trailing.lines_removed, 1);
422 assert_eq!(trailing.hunks, 1);
423
424 let binary = diff_bytes(&[0xff, b'\n'], &[0xfe, b'\n']);
425 assert_eq!(binary.lines_added, 1);
426 assert_eq!(binary.lines_removed, 1);
427 assert_eq!(binary.hunks, 1);
428 }
429
430 #[test]
431 fn lines_changed_saturates_untrusted_artifact_totals() {
432 let metrics = DiffScopeMetrics {
433 lines_added: u64::MAX,
434 lines_removed: 1,
435 ..DiffScopeMetrics::default()
436 };
437 assert_eq!(metrics.lines_changed(), u64::MAX);
438 }
439
440 #[test]
441 fn baseline_measurement_counts_all_task_changes_except_framework_outputs() {
442 let temp = tempfile::TempDir::new().unwrap();
443 let eval_root = temp.path().join("env");
444 let run_dir = temp.path().join("run");
445 let outputs_dir = eval_root.join(".eval-magic-outputs/eval-e1/with_skill");
446 fs::create_dir_all(eval_root.join("src")).unwrap();
447 fs::create_dir_all(&outputs_dir).unwrap();
448 fs::write(eval_root.join("src/changed.txt"), "old\nsame\n").unwrap();
449 fs::write(eval_root.join("src/deleted.txt"), "gone\n").unwrap();
450 fs::write(eval_root.join("framework.txt"), "before\n").unwrap();
451
452 capture_task_baseline(&eval_root, &run_dir).unwrap();
453
454 fs::write(eval_root.join("src/changed.txt"), "new\nsame\n").unwrap();
455 fs::remove_file(eval_root.join("src/deleted.txt")).unwrap();
456 fs::write(eval_root.join("framework.txt"), "after\n").unwrap();
457 fs::write(eval_root.join("notes.txt"), "one\ntwo\n").unwrap();
458 fs::write(outputs_dir.join("final-message.md"), "ignored\n").unwrap();
459 fs::write(
460 eval_root.join(".eval-magic-outputs/agent-created.txt"),
461 "also ignored\n",
462 )
463 .unwrap();
464
465 let metrics = measure_task_diff(&eval_root, &run_dir).unwrap();
466 assert_eq!(
467 metrics,
468 DiffScopeMetrics {
469 files_touched: 4,
470 lines_added: 4,
471 lines_removed: 3,
472 hunks: 4,
473 }
474 );
475 }
476
477 #[test]
478 fn baseline_ignores_only_runner_owned_root_git_metadata() {
479 let temp = tempfile::TempDir::new().unwrap();
480 let eval_root = temp.path().join("env");
481 let run_dir = temp.path().join("run");
482 fs::create_dir_all(eval_root.join(".git")).unwrap();
483 fs::create_dir_all(eval_root.join("vendor/.git")).unwrap();
484 fs::write(eval_root.join(".git/config"), "root-before\n").unwrap();
485 fs::write(eval_root.join("vendor/.git/config"), "nested-before\n").unwrap();
486 fs::write(eval_root.join("source.txt"), "before\n").unwrap();
487
488 capture_task_baseline(&eval_root, &run_dir).unwrap();
489
490 fs::write(eval_root.join(".git/config"), "root-after\n").unwrap();
491 fs::write(eval_root.join("vendor/.git/config"), "nested-after\n").unwrap();
492 fs::write(eval_root.join("source.txt"), "after\n").unwrap();
493
494 assert_eq!(
495 measure_task_diff(&eval_root, &run_dir).unwrap(),
496 DiffScopeMetrics {
497 files_touched: 2,
498 lines_added: 2,
499 lines_removed: 2,
500 hunks: 2,
501 }
502 );
503 }
504}