@@ -94,6 +94,7 @@ pub fn all_builtins() -> Vec<(String, Value)> {
9494 builtin("splitOn", 2, b_split_on),
9595 builtin("replay", 2, b_replay),
9696 builtin("unresolved", 1, b_unresolved),
97+ builtin("touchedPaths", 1, b_touched_paths),
9798 builtin("blob", 1, b_blob),
9899 builtin("text", 1, b_text),
99100 builtin("by", 2, b_by),
@@ -512,6 +513,30 @@ fn b_unresolved(_i: &mut Interp, args: &[Value]) -> BResult {
512513 }
513514}
514515
516+/// the paths whose content differs between `from` and `to` snapshots (§7.3):
517+/// one pass over both path-keyed maps, comparing blobs by content id — never
518+/// reading file bytes. Replaces the quadratic `entryAt`-per-path scan.
519+fn b_touched_paths(_i: &mut Interp, args: &[Value]) -> BResult {
520+ let ch = &args[0];
521+ let from = crate::domain::snapshot_map(ch.field("from")?.as_list()?)?;
522+ let to = crate::domain::snapshot_map(ch.field("to")?.as_list()?)?;
523+ let mut paths: std::collections::BTreeSet<Vec<String>> = std::collections::BTreeSet::new();
524+ paths.extend(from.keys().cloned());
525+ paths.extend(to.keys().cloned());
526+ let mut out = Vec::new();
527+ for p in paths {
528+ let changed = match (from.get(&p), to.get(&p)) {
529+ (None, Some(_)) | (Some(_), None) => true,
530+ (Some(a), Some(b)) => !crate::value::value_eq(a, b)?,
531+ (None, None) => false,
532+ };
533+ if changed {
534+ out.push(Value::list(p.into_iter().map(Value::text).collect()));
535+ }
536+ }
537+ Ok(Value::list(out))
538+}
539+
515540fn b_blob(_i: &mut Interp, args: &[Value]) -> BResult {
516541 let t = args[0].as_text()?;
517542 Ok(Value::Blob(Rc::new(BlobVal {