@@ -94,6 +94,7 @@ pub fn all_builtins() -> Vec<(String, Value)> {
94 builtin("splitOn", 2, b_split_on),
94 builtin("splitOn", 2, b_split_on),
95 builtin("replay", 2, b_replay),
95 builtin("replay", 2, b_replay),
96 builtin("unresolved", 1, b_unresolved),
96 builtin("unresolved", 1, b_unresolved),
97+ builtin("touchedPaths", 1, b_touched_paths),
97 builtin("blob", 1, b_blob),
98 builtin("blob", 1, b_blob),
98 builtin("text", 1, b_text),
99 builtin("text", 1, b_text),
99 builtin("by", 2, b_by),
100 builtin("by", 2, b_by),
@@ -512,6 +513,30 @@ fn b_unresolved(_i: &mut Interp, args: &[Value]) -> BResult {
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 {
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();
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,
534+ out.push(Value::list(p.into_iter().map(Value::text).collect()));
537+ Ok(Value::list(out))
515fn b_blob(_i: &mut Interp, args: &[Value]) -> BResult {
540fn b_blob(_i: &mut Interp, args: &[Value]) -> BResult {
516 let t = args[0].as_text()?;
541 let t = args[0].as_text()?;
517 Ok(Value::Blob(Rc::new(BlobVal {
542 Ok(Value::Blob(Rc::new(BlobVal {