From 2ec7d6594d7a64a277b72f86bfd04cbde04ab8c0 Mon Sep 17 00:00:00 2001 From: Alex Kesling Date: Wed, 8 Jul 2026 13:53:05 -0400 Subject: [PATCH 1/7] fix(convo): relativize file change keys against path.base --- CHANGELOG.md | 18 ++++ Cargo.lock | 2 +- Cargo.toml | 2 +- crates/toolpath-codex/src/derive.rs | 7 +- crates/toolpath-codex/tests/fidelity.rs | 27 +++++- crates/toolpath-convo/Cargo.toml | 2 +- crates/toolpath-convo/src/derive.rs | 111 ++++++++++++++++++++++-- crates/toolpath-convo/src/extract.rs | 38 +++++++- crates/toolpath-convo/src/lib.rs | 6 +- crates/toolpath-cursor/src/derive.rs | 8 +- crates/toolpath-opencode/src/derive.rs | 6 +- site/_data/crates.json | 2 +- 12 files changed, 203 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ce2d196..2b6e456e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ All notable changes to the Toolpath workspace are documented here. +## Derive: relativize file change keys against `path.base` — 2026-07-08 + +- **`toolpath-convo`** (0.11.2): `derive_path` now stores file-change + artifact keys base-relative, per the RFC ("bare paths are relative to + `path.base`"). Previously Claude emitted absolute keys + (`/Users/x/proj/src/main.rs`) while Codex/Cursor emitted workspace-relative + ones, so derived documents mixed key styles depending on the harness. When + the resolved `base.uri` is `file://` and a file path is absolute and + under `` (matched on a component boundary), the key is stored + base-relative with no leading slash; paths outside the base, already-relative + paths, and non-`file://` bases pass through verbatim. Applies to both + `file.write` change keys and the `files_changed` list in `meta.extra`. The + conversation self-key (`provider://id`) is never touched. On the reverse + path, `extract_conversation` resolves a base-relative key back to an absolute + path against `view.base.working_dir`, so `FileMutation.path` and + `files_changed` round-trip to their original absolute forms and + derive→extract→derive is stable. + ## Derive: resolve duplicate step ids — 2026-07-01 - **`toolpath-convo`** (0.11.1): `derive_path` now guarantees the derived diff --git a/Cargo.lock b/Cargo.lock index a799d3c7..cfb3faf3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3924,7 +3924,7 @@ dependencies = [ [[package]] name = "toolpath-convo" -version = "0.11.1" +version = "0.11.2" dependencies = [ "chrono", "jsonschema", diff --git a/Cargo.toml b/Cargo.toml index cf3d6237..f0dfbde5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,7 +24,7 @@ license = "Apache-2.0" [workspace.dependencies] toolpath = { version = "0.7.0", path = "crates/toolpath" } -toolpath-convo = { version = "0.11.1", path = "crates/toolpath-convo" } +toolpath-convo = { version = "0.11.2", path = "crates/toolpath-convo" } toolpath-git = { version = "0.6.0", path = "crates/toolpath-git" } toolpath-claude = { version = "0.12.0", path = "crates/toolpath-claude", default-features = false } toolpath-gemini = { version = "0.6.0", path = "crates/toolpath-gemini", default-features = false } diff --git a/crates/toolpath-codex/src/derive.rs b/crates/toolpath-codex/src/derive.rs index e48cbb13..69a28c5e 100644 --- a/crates/toolpath-codex/src/derive.rs +++ b/crates/toolpath-codex/src/derive.rs @@ -139,13 +139,14 @@ mod tests { let session = mgr.read_session(&id).unwrap(); let path = derive_path(&session, &DeriveConfig::default()); // The assistant turn that ran `apply_patch` carries a sibling - // `file.write` entry keyed by the file path. + // `file.write` entry keyed by the file path, relativized against + // `path.base` (file:///tmp/proj) to a bare RFC-compliant key. let file_step = path .steps .iter() - .find(|s| s.change.contains_key("/tmp/proj/a.rs")) + .find(|s| s.change.contains_key("a.rs")) .expect("no step carries the file artifact"); - let change = &file_step.change["/tmp/proj/a.rs"]; + let change = &file_step.change["a.rs"]; assert!(change.raw.is_some(), "raw perspective must be populated"); assert!( change.raw.as_ref().unwrap().contains("+fn main() {}"), diff --git a/crates/toolpath-codex/tests/fidelity.rs b/crates/toolpath-codex/tests/fidelity.rs index 0441c73c..7877efc7 100644 --- a/crates/toolpath-codex/tests/fidelity.rs +++ b/crates/toolpath-codex/tests/fidelity.rs @@ -310,16 +310,37 @@ fn patch_apply_files_all_surface_as_artifacts() { .flat_map(|s| s.change.keys().map(|k| k.as_str())) .collect(); + // Change keys are relativized against `path.base` (RFC: bare keys are + // base-relative), so relativize each source path the same way before + // asserting membership. + let base_root: Option = path + .path + .base + .as_ref() + .and_then(|b| b.uri.strip_prefix("file://")) + .map(|r| r.trim_end_matches('/').to_string()); + let relativize = |p: &str| -> String { + match &base_root { + Some(root) if !root.is_empty() && p.starts_with('/') => match p.strip_prefix(root) { + Some(rest) if rest.starts_with('/') => rest[1..].to_string(), + _ => p.to_string(), + }, + _ => p.to_string(), + } + }; + for line in &s.lines { if let RolloutItem::EventMsg(toolpath_codex::EventMsg::PatchApplyEnd(patch)) = line.item() { if !patch.success { continue; } for file_path in patch.changes.keys() { + let expected = relativize(file_path); assert!( - artifact_keys.contains(file_path.as_str()), - "file {} from successful patch_apply_end not found in derived artifacts", - file_path + artifact_keys.contains(expected.as_str()), + "file {} (key {}) from successful patch_apply_end not found in derived artifacts", + file_path, + expected ); } } diff --git a/crates/toolpath-convo/Cargo.toml b/crates/toolpath-convo/Cargo.toml index 43652540..e78e99e6 100644 --- a/crates/toolpath-convo/Cargo.toml +++ b/crates/toolpath-convo/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath-convo" -version = "0.11.1" +version = "0.11.2" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/crates/toolpath-convo/src/derive.rs b/crates/toolpath-convo/src/derive.rs index b0d13266..20a6fb10 100644 --- a/crates/toolpath-convo/src/derive.rs +++ b/crates/toolpath-convo/src/derive.rs @@ -99,6 +99,11 @@ pub fn derive_path(view: &ConversationView, config: &DeriveConfig) -> Path { }) }); + // Base URI used to relativize absolute file-change keys against + // `path.base` (RFC: bare artifact keys are base-relative). Only a + // `file://` base triggers stripping; other schemes pass through. + let base_file_uri = base.as_ref().map(|b| b.uri.clone()); + let conv_artifact_key = format!("{}://{}", provider, view.id); let mut steps: Vec = Vec::with_capacity(view.turns.len()); @@ -295,7 +300,7 @@ pub fn derive_path(view: &ConversationView, config: &DeriveConfig) -> Path { ); } step.change.insert( - fm.path.clone(), + relativize_key(&fm.path, base_file_uri.as_deref()), ArtifactChange { raw: fm.raw_diff.clone(), structural: Some(StructuralChange { @@ -322,7 +327,7 @@ pub fn derive_path(view: &ConversationView, config: &DeriveConfig) -> Path { serde_json::Value::String(tool.id.clone()), ); step.change.insert( - path, + relativize_key(&path, base_file_uri.as_deref()), ArtifactChange { raw, structural: Some(StructuralChange { @@ -440,10 +445,15 @@ pub fn derive_path(view: &ConversationView, config: &DeriveConfig) -> Path { meta.actors = Some(actors); } - if !view.files_changed.is_empty() - && let Ok(v) = serde_json::to_value(&view.files_changed) - { - meta.extra.insert("files_changed".to_string(), v); + if !view.files_changed.is_empty() { + let relativized: Vec = view + .files_changed + .iter() + .map(|f| relativize_key(f, base_file_uri.as_deref())) + .collect(); + if let Ok(v) = serde_json::to_value(&relativized) { + meta.extra.insert("files_changed".to_string(), v); + } } // Carry `vcs_remote` (not representable on `Base`) under meta.extra. @@ -562,6 +572,24 @@ fn record_actor( actors.insert(actor.to_string(), def); } +/// Strip a `file://` base prefix from an absolute path, yielding a +/// base-relative artifact key (the RFC keys bare paths relative to +/// `path.base`). Paths outside the base, already-relative paths, and +/// non-file bases pass through verbatim. +pub(crate) fn relativize_key(path: &str, base_uri: Option<&str>) -> String { + let Some(root) = base_uri.and_then(|u| u.strip_prefix("file://")) else { + return path.to_string(); + }; + let root = root.trim_end_matches('/'); + if root.is_empty() || !path.starts_with('/') { + return path.to_string(); + } + match path.strip_prefix(root) { + Some(rest) if rest.starts_with('/') => rest[1..].to_string(), + _ => path.to_string(), + } +} + fn extract_file_path(tool: &ToolInvocation) -> Option { for field in &["file_path", "path", "filename", "file"] { if let Some(v) = tool.input.get(*field) @@ -845,6 +873,21 @@ mod tests { ); } + #[test] + fn relativize_key_strips_file_base_on_component_boundary() { + assert_eq!( + relativize_key("/a/b/src/main.rs", Some("file:///a/b")), + "src/main.rs" + ); + assert_eq!(relativize_key("/a/bc/x.rs", Some("file:///a/b")), "/a/bc/x.rs"); // not under base + assert_eq!(relativize_key("src/main.rs", Some("file:///a/b")), "src/main.rs"); // already relative + assert_eq!(relativize_key("/a/b/x.rs", None), "/a/b/x.rs"); + assert_eq!( + relativize_key("/a/b/x.rs", Some("https://example.com")), + "/a/b/x.rs" + ); + } + #[test] fn test_empty_view() { let view = view_with(vec![]); @@ -1678,6 +1721,62 @@ mod tests { ); } + #[test] + fn file_change_keys_relativized_against_base_and_round_trip() { + // A turn whose base is /proj with two file mutations: one under the + // base (relativized to a bare key) and one outside (kept absolute). + let mut turn = base_turn("t1", Role::Assistant); + turn.model = Some("m".into()); + turn.environment = Some(EnvironmentSnapshot { + working_dir: Some("/proj".into()), + ..Default::default() + }); + turn.file_mutations = vec![ + crate::FileMutation { + path: "/proj/src/lib.rs".into(), + tool_id: None, + operation: Some("update".into()), + raw_diff: None, + before: None, + after: Some("pub fn f() {}".into()), + rename_to: None, + }, + crate::FileMutation { + path: "/elsewhere/x.rs".into(), + tool_id: None, + operation: Some("update".into()), + raw_diff: None, + before: None, + after: Some("fn g() {}".into()), + rename_to: None, + }, + ]; + let mut view = view_with(vec![turn]); + view.files_changed = vec!["/proj/src/lib.rs".into(), "/elsewhere/x.rs".into()]; + + let path = derive_path(&view, &DeriveConfig::default()); + // Under-base key is relative; outside-base key stays absolute. + assert!(path.steps[0].change.contains_key("src/lib.rs")); + assert!(path.steps[0].change.contains_key("/elsewhere/x.rs")); + assert!(!path.steps[0].change.contains_key("/proj/src/lib.rs")); + + // meta.extra["files_changed"] is relativized the same way. + assert_eq!( + path.meta.as_ref().unwrap().extra["files_changed"], + serde_json::json!(["src/lib.rs", "/elsewhere/x.rs"]) + ); + + // extract resolves the keys back to the absolute originals. + let view2 = crate::extract::extract_conversation(&path); + let mut paths: Vec = view2.turns[0] + .file_mutations + .iter() + .map(|fm| fm.path.clone()) + .collect(); + paths.sort(); + assert_eq!(paths, vec!["/elsewhere/x.rs", "/proj/src/lib.rs"]); + } + #[test] fn test_serde_roundtrip() { let mut t1 = base_turn("t1", Role::User); diff --git a/crates/toolpath-convo/src/extract.rs b/crates/toolpath-convo/src/extract.rs index c1d83800..96fb99ef 100644 --- a/crates/toolpath-convo/src/extract.rs +++ b/crates/toolpath-convo/src/extract.rs @@ -71,6 +71,11 @@ pub fn extract_conversation(path: &Path) -> ConversationView { view.producer = Some(p); } + // Workspace root recovered from `path.base`, used to resolve + // base-relative file-change keys back to absolute paths (inverting + // `derive_path`'s relativization). Only an absolute root resolves. + let working_dir: Option = view.base.as_ref().and_then(|b| b.working_dir.clone()); + // Map from step ID → index into view.turns, for parent lookups. let mut step_to_turn: HashMap<&str, usize> = HashMap::new(); // Track files_changed for dedup in insertion order. @@ -89,7 +94,7 @@ pub fn extract_conversation(path: &Path) -> ConversationView { continue; } let fm = FileMutation { - path: key.clone(), + path: resolve_key(key, working_dir.as_deref()), tool_id: s .extra .get("tool_id") @@ -198,9 +203,11 @@ pub fn extract_conversation(path: &Path) -> ConversationView { let category = parse_category(structural.extra.get("category")); if category == Some(ToolCategory::FileWrite) && !artifact_key.starts_with("agent://") - && files_seen.insert(artifact_key.clone()) { - view.files_changed.push(artifact_key.clone()); + let resolved = resolve_key(artifact_key, working_dir.as_deref()); + if files_seen.insert(resolved.clone()) { + view.files_changed.push(resolved); + } } // Attach to parent turn. @@ -530,6 +537,20 @@ fn role_from_actor(actor: &str) -> Role { } } +/// Resolve a base-relative artifact key back to an absolute path using +/// the view's working dir, inverting `derive_path`'s relativization. +pub(crate) fn resolve_key(key: &str, working_dir: Option<&str>) -> String { + if key.starts_with('/') || key.contains("://") { + return key.to_string(); + } + match working_dir { + Some(wd) if wd.starts_with('/') => { + format!("{}/{}", wd.trim_end_matches('/'), key) + } + _ => key.to_string(), + } +} + fn add_opt(a: Option, b: Option) -> Option { match (a, b) { (Some(x), Some(y)) => Some(x + y), @@ -624,6 +645,17 @@ mod tests { .collect() } + #[test] + fn resolve_key_joins_relative_keys_with_base() { + assert_eq!(resolve_key("src/main.rs", Some("/a/b")), "/a/b/src/main.rs"); + assert_eq!(resolve_key("/abs/x.rs", Some("/a/b")), "/abs/x.rs"); + assert_eq!( + resolve_key("claude-code://sess", Some("/a/b")), + "claude-code://sess" + ); + assert_eq!(resolve_key("src/main.rs", None), "src/main.rs"); + } + #[test] fn test_empty_path() { let path = make_path(vec![]); diff --git a/crates/toolpath-convo/src/lib.rs b/crates/toolpath-convo/src/lib.rs index dcf3c3e2..13e659f8 100644 --- a/crates/toolpath-convo/src/lib.rs +++ b/crates/toolpath-convo/src/lib.rs @@ -118,8 +118,10 @@ pub struct SessionBase { /// snapshot diffs between turns). #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct FileMutation { - /// File path (relative to `view.base.working_dir` if relative, or - /// `file://`/absolute). + /// File path. Absolute when the workspace root is known: `extract` + /// resolves base-relative change keys against `view.base.working_dir`, + /// and `derive` relativizes them back to bare keys under `path.base`. + /// May be relative when no absolute root is available. pub path: String, /// `ToolInvocation::id` of the tool call that produced this mutation, /// when the provider can attribute it. diff --git a/crates/toolpath-cursor/src/derive.rs b/crates/toolpath-cursor/src/derive.rs index 8642f0c3..937f4e50 100644 --- a/crates/toolpath-cursor/src/derive.rs +++ b/crates/toolpath-cursor/src/derive.rs @@ -111,12 +111,14 @@ mod tests { let (_t, mgr) = setup(); let session = mgr.read_session("c1").unwrap(); let path = derive_path(&session, &DeriveConfig::default()); + // Key is relativized against `path.base` (file:///p) to a bare + // RFC-compliant key. let file_step = path .steps .iter() - .find(|s| s.change.contains_key("/p/x.rs")) - .expect("no step carries /p/x.rs"); - let change = &file_step.change["/p/x.rs"]; + .find(|s| s.change.contains_key("x.rs")) + .expect("no step carries x.rs"); + let change = &file_step.change["x.rs"]; let structural = change.structural.as_ref().unwrap(); assert_eq!(structural.change_type, "file.write"); assert_eq!(structural.extra["operation"], "add"); diff --git a/crates/toolpath-opencode/src/derive.rs b/crates/toolpath-opencode/src/derive.rs index 1468bd2c..4f2b9857 100644 --- a/crates/toolpath-opencode/src/derive.rs +++ b/crates/toolpath-opencode/src/derive.rs @@ -198,12 +198,14 @@ mod tests { ); // The assistant turn's `write` tool produces a sibling `file.write` // entry via the tool-input fallback (no snapshot repo on disk). + // Key is relativized against `path.base` (file:///tmp/proj) to a + // bare RFC-compliant key. let file_step = p .steps .iter() - .find(|s| s.change.contains_key("/tmp/proj/main.cpp")) + .find(|s| s.change.contains_key("main.cpp")) .expect("no step carries the file artifact"); - let change = &file_step.change["/tmp/proj/main.cpp"]; + let change = &file_step.change["main.cpp"]; let structural = change.structural.as_ref().unwrap(); assert_eq!(structural.change_type, "file.write"); assert_eq!(structural.extra["operation"], "add"); diff --git a/site/_data/crates.json b/site/_data/crates.json index 6b730be1..4e0ca012 100644 --- a/site/_data/crates.json +++ b/site/_data/crates.json @@ -9,7 +9,7 @@ }, { "name": "toolpath-convo", - "version": "0.11.1", + "version": "0.11.2", "description": "Provider-agnostic conversation types, traits, and Toolpath-Path derivation", "docs": "https://docs.rs/toolpath-convo", "crate": "https://crates.io/crates/toolpath-convo", From c4902f5b6c35911fe8ea3890ee541d7e8586097f Mon Sep 17 00:00:00 2001 From: Alex Kesling Date: Thu, 9 Jul 2026 12:38:20 -0400 Subject: [PATCH 2/7] docs(convo): state FileMutation.path as a round-trip contract, not the derive/extract mechanism --- crates/toolpath-convo/src/lib.rs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/crates/toolpath-convo/src/lib.rs b/crates/toolpath-convo/src/lib.rs index 13e659f8..18d95a17 100644 --- a/crates/toolpath-convo/src/lib.rs +++ b/crates/toolpath-convo/src/lib.rs @@ -118,10 +118,17 @@ pub struct SessionBase { /// snapshot diffs between turns). #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct FileMutation { - /// File path. Absolute when the workspace root is known: `extract` - /// resolves base-relative change keys against `view.base.working_dir`, - /// and `derive` relativizes them back to bare keys under `path.base`. - /// May be relative when no absolute root is available. + /// The file this mutation targets, in the provider's original + /// coordinate space: absolute when the session's workspace root is + /// known (the common case), otherwise workspace-relative. + /// + /// Contract: this value is stable across a `derive` → `extract` + /// round-trip. On the serialized `Path` the change is keyed relative + /// to `path.base` (bare paths per the RFC), but that key + /// representation is internal to `derive`/`extract` — every IR + /// consumer reads the same path here regardless of how the document + /// was serialized. Resolve a relative value against + /// `EnvironmentSnapshot::working_dir` or the path's base. pub path: String, /// `ToolInvocation::id` of the tool call that produced this mutation, /// when the provider can attribute it. From 905af8345f23e30fe28bed6e8ae7c6e1f7561831 Mon Sep 17 00:00:00 2001 From: Alex Kesling Date: Thu, 9 Jul 2026 13:06:30 -0400 Subject: [PATCH 3/7] test(convo): round-trip, edge-case, and backward-compat coverage for relativized change keys #124 relativized file-change artifact keys against path.base but only had one integration test and no property coverage. Adds: a 9-case derive->JSON->extract->re-derive identity/idempotency table (nested, exact-root, outside-base, trailing-slash base, deep nesting, unicode, no-base absolute/relative, prefix-not-boundary); relativize_key/resolve_key edge-case units (exact root, trailing-slash base, empty root, case sensitivity, URI passthrough); a files_changed-consistency test; and a backward-compat characterization pinning that documents shared before relativization (absolute keys) still extract unchanged. --- CHANGELOG.md | 9 ++ crates/toolpath-convo/src/derive.rs | 181 +++++++++++++++++++++++++++ crates/toolpath-convo/src/extract.rs | 67 ++++++++++ 3 files changed, 257 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b6e456e..3aa860b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,15 @@ All notable changes to the Toolpath workspace are documented here. path against `view.base.working_dir`, so `FileMutation.path` and `files_changed` round-trip to their original absolute forms and derive→extract→derive is stable. +- Test-only follow-up (no version bump): added a `derive`→JSON→`extract`→ + re-`derive` property table covering nine relativization scenarios (nested, + exact-root, outside-base, trailing-slash base, deep nesting, unicode paths, + no-base absolute/relative, prefix-not-boundary), `relativize_key`/ + `resolve_key` edge-case units, a backward-compat characterization pinning + pre-relativization absolute-keyed documents, and key-stability coverage + (no absolute-under-base leak, re-derive idempotency) across all six + conversation providers (claude, gemini, pi, codex, opencode, cursor) plus + `path p validate`/`p merge` characterization of mixed-key-era documents. ## Derive: resolve duplicate step ids — 2026-07-01 diff --git a/crates/toolpath-convo/src/derive.rs b/crates/toolpath-convo/src/derive.rs index 20a6fb10..134f0e83 100644 --- a/crates/toolpath-convo/src/derive.rs +++ b/crates/toolpath-convo/src/derive.rs @@ -888,6 +888,38 @@ mod tests { ); } + #[test] + fn relativize_key_edge_cases() { + // Path exactly equal to the base root: `strip_prefix` yields an + // empty remainder, which does NOT start with '/', so this + // deliberately falls through to the passthrough branch rather than + // producing a bare empty-string key. + assert_eq!(relativize_key("/proj", Some("file:///proj")), "/proj"); + + // A trailing slash on the base URI is normalized away before + // stripping, so it doesn't change the relativized result. + assert_eq!(relativize_key("/proj/a.rs", Some("file:///proj/")), "a.rs"); + + // `file:///` (root filesystem as base) trims to an empty root, + // which is explicitly rejected (`root.is_empty()` guard) rather + // than stripping every leading '/' off every key. + assert_eq!(relativize_key("/proj/a.rs", Some("file:///")), "/proj/a.rs"); + + // Component matching is byte-exact / case-sensitive: differing case + // is a different path, not a prefix match. + assert_eq!( + relativize_key("/Proj/a.rs", Some("file:///proj")), + "/Proj/a.rs" + ); + + // Sibling directory sharing a string prefix but not a path + // component boundary must not be treated as "under base". + assert_eq!( + relativize_key("/projext/a.rs", Some("file:///proj")), + "/projext/a.rs" + ); + } + #[test] fn test_empty_view() { let view = view_with(vec![]); @@ -1777,6 +1809,155 @@ mod tests { assert_eq!(paths, vec!["/elsewhere/x.rs", "/proj/src/lib.rs"]); } + /// Derive a view with one file mutation, round-trip it through the + /// canonical single-path `Graph` JSON envelope, extract it back, and + /// re-derive. Asserts: + /// 1. round-trip identity: `FileMutation.path` recovered by + /// `extract_conversation` equals the original `file_path` (the + /// contract documented on `FileMutation::path`), and + /// 2. idempotency: re-deriving from the extracted view produces the + /// identical set of `file.write` change keys as the first derive. + /// + /// Returns the single non-conversation change key so callers can also + /// pin its exact serialized form. + fn check(working_dir: Option<&str>, file_path: &str) -> String { + let mut turn = base_turn("t1", Role::Assistant); + turn.model = Some("m".into()); + turn.file_mutations = vec![crate::FileMutation { + path: file_path.to_string(), + operation: Some("update".into()), + raw_diff: Some("".into()), + ..Default::default() + }]; + let mut view = view_with(vec![turn]); + if let Some(wd) = working_dir { + view.base = Some(crate::SessionBase { + working_dir: Some(wd.to_string()), + ..Default::default() + }); + } + + let path1 = derive_path(&view, &DeriveConfig::default()); + let file_write_keys = |p: &Path| -> std::collections::BTreeSet { + p.steps[0] + .change + .iter() + .filter(|(_, ch)| { + ch.structural + .as_ref() + .is_some_and(|s| s.change_type == "file.write") + }) + .map(|(k, _)| k.clone()) + .collect() + }; + let keys1 = file_write_keys(&path1); + assert_eq!( + keys1.len(), + 1, + "expected exactly one file.write change for working_dir={working_dir:?} file_path={file_path:?}, got {keys1:?}" + ); + let key = keys1.iter().next().unwrap().clone(); + + // Round-trip through the canonical single-path Graph JSON envelope + // (there is no bare-`Path` document root per RFC.md's "Document + // Root" section -- Graph is the sole JSON root type). + let graph = toolpath::v1::Graph::from_path(path1.clone()); + let json = serde_json::to_string(&graph).unwrap(); + let graph2: toolpath::v1::Graph = serde_json::from_str(&json).unwrap(); + let path_back = graph2 + .into_single_path() + .expect("single inline path survives the JSON round-trip"); + + let view2 = crate::extract::extract_conversation(&path_back); + let recovered = &view2.turns[0].file_mutations[0].path; + assert_eq!( + recovered, file_path, + "round-trip identity failed for working_dir={working_dir:?} file_path={file_path:?}" + ); + + // Idempotency: deriving again from the already-round-tripped view + // must yield the identical file.write key set -- derive/extract is + // a fixed point once a document has already been relativized. + let path2 = derive_path(&view2, &DeriveConfig::default()); + let keys2 = file_write_keys(&path2); + assert_eq!( + keys1, keys2, + "re-derive key set changed for working_dir={working_dir:?} file_path={file_path:?}" + ); + + key + } + + #[test] + fn file_mutation_key_relativization_round_trips_across_scenarios() { + // 1. Under base, nested path -> relativized to a bare key. + assert_eq!(check(Some("/proj"), "/proj/src/main.rs"), "src/main.rs"); + // 2. Path exactly equal to the base root documents the no-strip + // edge case (see `relativize_key_edge_cases`). + assert_eq!(check(Some("/proj"), "/proj"), "/proj"); + // 3. Outside the base entirely -> stays absolute. + assert_eq!(check(Some("/proj"), "/elsewhere/x.rs"), "/elsewhere/x.rs"); + // 4. Trailing slash on the base working_dir is normalized away. + assert_eq!(check(Some("/proj/"), "/proj/src/x.rs"), "src/x.rs"); + // 5. Deep nesting relativizes the whole sub-path. + assert_eq!(check(Some("/proj"), "/proj/a/b/c/d.rs"), "a/b/c/d.rs"); + // 6. Spaces and non-ASCII path components survive relativization. + assert_eq!( + check(Some("/proj"), "/proj/té st/naïve.rs"), + "té st/naïve.rs" + ); + // 7. No base recorded at all -> absolute path passes through. + assert_eq!(check(None, "/abs/x.rs"), "/abs/x.rs"); + // 8. No base, and the path is already relative. + assert_eq!(check(None, "src/x.rs"), "src/x.rs"); + // 9. Sibling directory sharing a string prefix with the base, but + // not a path-component boundary, must not be stripped. + assert_eq!(check(Some("/proj"), "/projext/x.rs"), "/projext/x.rs"); + } + + #[test] + fn files_changed_relativized_consistently_with_change_keys_and_round_trips() { + // meta.extra["files_changed"] must be relativized identically to + // the sibling file.write change key for the same path, and that + // relativized value must survive a JSON round-trip unchanged. + let mut turn = base_turn("t1", Role::Assistant); + turn.model = Some("m".into()); + turn.file_mutations = vec![crate::FileMutation { + path: "/proj/src/lib.rs".into(), + operation: Some("update".into()), + raw_diff: Some("".into()), + ..Default::default() + }]; + let mut view = view_with(vec![turn]); + view.base = Some(crate::SessionBase { + working_dir: Some("/proj".into()), + ..Default::default() + }); + view.files_changed = vec!["/proj/src/lib.rs".into()]; + + let path = derive_path(&view, &DeriveConfig::default()); + let change_key = path.steps[0] + .change + .keys() + .find(|k| !k.contains("://")) + .cloned() + .expect("file.write change key present"); + assert_eq!(change_key, "src/lib.rs"); + assert_eq!( + path.meta.as_ref().unwrap().extra["files_changed"], + serde_json::json!([change_key]), + "files_changed must relativize identically to the file.write change key" + ); + + let json = serde_json::to_string(&path).unwrap(); + let back: Path = serde_json::from_str(&json).unwrap(); + assert_eq!( + back.meta.unwrap().extra["files_changed"], + serde_json::json!(["src/lib.rs"]), + "relativized files_changed survives a JSON round-trip" + ); + } + #[test] fn test_serde_roundtrip() { let mut t1 = base_turn("t1", Role::User); diff --git a/crates/toolpath-convo/src/extract.rs b/crates/toolpath-convo/src/extract.rs index 96fb99ef..ae1da700 100644 --- a/crates/toolpath-convo/src/extract.rs +++ b/crates/toolpath-convo/src/extract.rs @@ -656,6 +656,21 @@ mod tests { assert_eq!(resolve_key("src/main.rs", None), "src/main.rs"); } + #[test] + fn resolve_key_passes_through_any_uri_scheme_unchanged() { + // Any key containing "://" is an artifact URI (conversation or + // otherwise), never a bare relative path -- it must never be + // joined onto the working dir, regardless of scheme. + assert_eq!( + resolve_key("https://example.com/x", Some("/proj")), + "https://example.com/x" + ); + assert_eq!( + resolve_key("claude-code://sess", Some("/proj")), + "claude-code://sess" + ); + } + #[test] fn test_empty_path() { let path = make_path(vec![]); @@ -1389,4 +1404,56 @@ mod tests { assert_eq!(view.turns[0].text, "hello"); assert_eq!(view.events[0].event_type, "system"); } + + // ── Backward compatibility ───────────────────────────────────────── + + #[test] + fn backward_compat_absolute_key_document_extracts_unchanged() { + // Characterizes the read side of a document written by a producer + // that predates key relativization (pre-#124): file-change keys + // were the verbatim absolute path, not a base-relative bare key. + // `resolve_key` passes any key starting with '/' straight through, + // so a document shared before this change must keep reading back + // identically -- this pins that behavior against regression. + let json = r#"{ + "path": { + "id": "p1", + "base": {"uri": "file:///old/proj"}, + "head": "s1" + }, + "steps": [ + { + "step": { + "id": "s1", + "actor": "human:user", + "timestamp": "2026-01-01T00:00:00Z" + }, + "change": { + "legacy://sess1": { + "structural": { + "type": "conversation.append", + "role": "user", + "text": "hi" + } + }, + "/old/proj/src/legacy.rs": { + "raw": "--- a/src/legacy.rs\n+++ b/src/legacy.rs\n", + "structural": { + "type": "file.write", + "operation": "update" + } + } + } + } + ] + }"#; + let path: Path = serde_json::from_str(json).expect("valid pre-relativization document"); + + let view = extract_conversation(&path); + assert_eq!(view.turns.len(), 1); + assert_eq!( + view.turns[0].file_mutations[0].path, "/old/proj/src/legacy.rs", + "an absolute key from a pre-relativization document must resolve unchanged" + ); + } } From 80a3e5f8a98c0d7e3a81525742fcaef456831976 Mon Sep 17 00:00:00 2001 From: Alex Kesling Date: Thu, 9 Jul 2026 13:06:42 -0400 Subject: [PATCH 4/7] test(providers): key-stability and no-leak invariant across all six conversation providers Extends each provider's real-fixture test with a ground-truth check for #124's key relativization: for every derived file.write change, the key is relative iff its original path genuinely sat under path.base at a path-component boundary (independently recomputed via std::path, not by calling derive.rs's own relativize_key) -- catching both an absolute-under-base leak and a wrongly-relativized outside-base key -- plus extract -> re-derive idempotency of the file.write key set. claude and cursor fixtures carry real absolute file mutations under a recorded base, so stripping is exercised directly. gemini and pi's fixtures record already-relative tool-call paths, so a synthetic absolute mutation/tool-call is injected to genuinely exercise stripping alongside the fixture's own untouched relative paths. codex already had a relativize-and-assert-membership test in fidelity.rs; this adds the leak/idempotency half next to it. opencode's plain to_view() (no snapshot resolver) still runs the tool-input fallback unconditionally, so its real fixture's absolute paths exercise stripping directly. cursor gets a new real_fixture_roundtrip.rs (it previously only had a live-DB-gated sanity test and a cross-harness projection test, neither of which exercised this fixture's key relativization). --- .../tests/real_fixture_roundtrip.rs | 84 +++++++++++ crates/toolpath-codex/tests/fidelity.rs | 75 +++++++++- .../tests/real_fixture_roundtrip.rs | 121 +++++++++++++++ .../tests/real_fixture_roundtrip.rs | 109 ++++++++++++++ .../tests/real_fixture_roundtrip.rs | 91 +++++++++++ .../tests/real_fixture_roundtrip.rs | 141 ++++++++++++++++++ 6 files changed, 620 insertions(+), 1 deletion(-) create mode 100644 crates/toolpath-cursor/tests/real_fixture_roundtrip.rs diff --git a/crates/toolpath-claude/tests/real_fixture_roundtrip.rs b/crates/toolpath-claude/tests/real_fixture_roundtrip.rs index db9a55db..3fcc15e7 100644 --- a/crates/toolpath-claude/tests/real_fixture_roundtrip.rs +++ b/crates/toolpath-claude/tests/real_fixture_roundtrip.rs @@ -239,6 +239,90 @@ fn roundtrip_preserves_total_token_usage_when_present() { } } +/// Ground-truth invariant for #124 (relativized file-change keys) run +/// against a real recorded session: derive `view`, then for every +/// pre-derive `FileMutation::path` assert (a) it produced a relativized +/// key iff it actually sat under `path.base` on a path-component boundary +/// -- no absolute-under-base leak, and no wrongly-relativized outside-base +/// key -- and (b) extracting and re-deriving reproduces the identical +/// `file.write` key set (idempotency). +/// +/// "Under base" is independently recomputed here via `std::path::Path` +/// component stripping rather than by calling `toolpath_convo`'s own +/// (private) `relativize_key`, so this exercises its output rather than +/// re-asserting its internals. +fn assert_file_write_keys_match_base(view: &ConversationView) { + let path = derive_path(view, &DeriveConfig::default()); + let base_root: Option = path + .path + .base + .as_ref() + .and_then(|b| b.uri.strip_prefix("file://")) + .map(|s| s.trim_end_matches('/').to_string()); + + let ground_truth: Vec<&str> = view + .turns + .iter() + .flat_map(|t| t.file_mutations.iter().map(|fm| fm.path.as_str())) + .collect(); + assert!( + !ground_truth.is_empty(), + "fixture must exercise at least one file mutation for this test to be meaningful" + ); + + let file_write_keys = |p: &toolpath::v1::Path| -> BTreeSet { + p.steps + .iter() + .flat_map(|s| s.change.iter()) + .filter(|(_, ch)| { + ch.structural + .as_ref() + .is_some_and(|s| s.change_type == "file.write") + }) + .map(|(k, _)| k.clone()) + .collect() + }; + let derived_keys = file_write_keys(&path); + + for gt in &ground_truth { + let under_base = base_root.as_deref().is_some_and(|root| { + std::path::Path::new(gt) + .strip_prefix(root) + .is_ok_and(|rest| rest != std::path::Path::new("")) + }); + if under_base { + let root = base_root.as_deref().unwrap(); + let expected_relative = gt.strip_prefix(root).unwrap().trim_start_matches('/'); + assert!( + derived_keys.contains(expected_relative), + "expected relativized key {expected_relative:?} for {gt:?} under base {root:?}, got {derived_keys:?}" + ); + assert!( + !derived_keys.contains(*gt), + "absolute-under-base leak: {gt:?} should have been relativized but the absolute form is still a key" + ); + } else { + assert!( + derived_keys.contains(*gt), + "expected {gt:?} to remain an absolute (or opaque) key outside the base, got {derived_keys:?}" + ); + } + } + + let view2 = extract_conversation(&path); + let path2 = derive_path(&view2, &DeriveConfig::default()); + assert_eq!( + derived_keys, + file_write_keys(&path2), + "re-derive must reproduce the identical file.write key set" + ); +} + +#[test] +fn file_write_keys_relativized_with_no_leak_and_stable_on_re_derive() { + assert_file_write_keys_match_base(&load_fixture_view()); +} + /// Reading + re-projecting a real fixture must preserve every JSONL line. /// /// This is the bluntest possible UX-loss check: count source lines, count diff --git a/crates/toolpath-codex/tests/fidelity.rs b/crates/toolpath-codex/tests/fidelity.rs index 7877efc7..c47fa7fb 100644 --- a/crates/toolpath-codex/tests/fidelity.rs +++ b/crates/toolpath-codex/tests/fidelity.rs @@ -16,7 +16,7 @@ use std::path::PathBuf; use toolpath_codex::provider::to_view; use toolpath_codex::{ResponseItem, RolloutItem, RolloutReader, derive}; -use toolpath_convo::Role; +use toolpath_convo::{DeriveConfig, Role, extract_conversation}; fn fixture_path() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/sample-codex-python.jsonl") @@ -346,3 +346,76 @@ fn patch_apply_files_all_surface_as_artifacts() { } } } + +/// Extends `patch_apply_files_all_surface_as_artifacts` with the two +/// invariants that test doesn't cover: (a) a path that DID get +/// relativized must not ALSO leave its absolute form as a key +/// (no absolute-under-base leak), and (b) extracting the derived path +/// and re-deriving from it reproduces the identical `file.write` key +/// set (idempotency). +#[test] +fn patch_apply_file_write_keys_no_leak_and_stable_on_re_derive() { + let s = session(); + let path = derive::derive_path(&s, &derive::DeriveConfig::default()); + + let base_root: Option = path + .path + .base + .as_ref() + .and_then(|b| b.uri.strip_prefix("file://")) + .map(|r| r.trim_end_matches('/').to_string()); + + let file_write_keys = |p: &toolpath::v1::Path| -> HashSet { + p.steps + .iter() + .flat_map(|s| s.change.iter()) + .filter(|(_, ch)| { + ch.structural + .as_ref() + .is_some_and(|sc| sc.change_type == "file.write") + }) + .map(|(k, _)| k.clone()) + .collect() + }; + let keys1 = file_write_keys(&path); + + let mut checked_any = false; + for line in &s.lines { + if let RolloutItem::EventMsg(toolpath_codex::EventMsg::PatchApplyEnd(patch)) = line.item() { + if !patch.success { + continue; + } + for file_path in patch.changes.keys() { + checked_any = true; + let under_base = base_root.as_deref().is_some_and(|root| { + std::path::Path::new(file_path) + .strip_prefix(root) + .is_ok_and(|rest| rest != std::path::Path::new("")) + }); + if under_base { + assert!( + !keys1.contains(file_path.as_str()), + "absolute-under-base leak: {file_path:?} should have been relativized but its absolute form is still a key" + ); + } else { + assert!( + keys1.contains(file_path.as_str()), + "expected {file_path:?} to remain an absolute key outside the base" + ); + } + } + } + } + assert!( + checked_any, + "fixture must exercise at least one patch_apply_end file for this test to be meaningful" + ); + + let view2 = extract_conversation(&path); + let path2 = toolpath_convo::derive_path(&view2, &DeriveConfig::default()); + assert_eq!( + keys1, + file_write_keys(&path2), + "re-derive must reproduce the identical file.write key set" + ); +} diff --git a/crates/toolpath-cursor/tests/real_fixture_roundtrip.rs b/crates/toolpath-cursor/tests/real_fixture_roundtrip.rs new file mode 100644 index 00000000..daff45d6 --- /dev/null +++ b/crates/toolpath-cursor/tests/real_fixture_roundtrip.rs @@ -0,0 +1,121 @@ +//! Real-fixture invariants for #124 (relativized file-change keys). +//! +//! Loads the shared real-world fixture at `test-fixtures/cursor/convo.json` +//! (refreshed via `scripts/capture-elicit-fixtures.sh`) and derives it +//! through the full `CursorSession` -> `ConversationView` -> `Path` +//! pipeline. Complements `tests/projection_roundtrip.rs`'s synthetic +//! minimum-shape coverage by running on production-shape input, and +//! `crates/path-cli/tests/cross_harness_matrix.rs::CursorHarness`'s +//! generic cross-provider projection roundtrip with a key-relativization +//! invariant specific to this fixture. + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; + +use toolpath_convo::{ConversationView, DeriveConfig, derive_path, extract_conversation}; +use toolpath_cursor::{CursorSession, session_to_view}; + +fn fixture_path() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("test-fixtures") + .join("cursor") + .join("convo.json") +} + +fn load_fixture_view() -> ConversationView { + let json = std::fs::read_to_string(fixture_path()).expect("read cursor fixture"); + let session: CursorSession = serde_json::from_str(&json).expect("parse cursor fixture"); + session_to_view(&session) +} + +#[test] +fn fixture_loads() { + let view = load_fixture_view(); + assert!( + !view.turns.is_empty(), + "cursor fixture should produce a non-empty view" + ); +} + +/// Ground-truth invariant for #124 (relativized file-change keys) run +/// against a real recorded session: derive `view`, then for every +/// pre-derive `FileMutation::path` assert (a) it produced a relativized +/// key iff it actually sat under `path.base` on a path-component boundary +/// -- no absolute-under-base leak, and no wrongly-relativized outside-base +/// key -- and (b) extracting and re-deriving reproduces the identical +/// `file.write` key set (idempotency). +/// +/// "Under base" is independently recomputed here via `std::path::Path` +/// component stripping rather than by calling `toolpath_convo`'s own +/// (private) `relativize_key`, so this exercises its output rather than +/// re-asserting its internals. +#[test] +fn file_write_keys_relativized_with_no_leak_and_stable_on_re_derive() { + let view = load_fixture_view(); + let path = derive_path(&view, &DeriveConfig::default()); + let base_root: Option = path + .path + .base + .as_ref() + .and_then(|b| b.uri.strip_prefix("file://")) + .map(|s| s.trim_end_matches('/').to_string()); + + let ground_truth: Vec<&str> = view + .turns + .iter() + .flat_map(|t| t.file_mutations.iter().map(|fm| fm.path.as_str())) + .collect(); + assert!( + !ground_truth.is_empty(), + "fixture must exercise at least one file mutation for this test to be meaningful" + ); + + let file_write_keys = |p: &toolpath::v1::Path| -> BTreeSet { + p.steps + .iter() + .flat_map(|s| s.change.iter()) + .filter(|(_, ch)| { + ch.structural + .as_ref() + .is_some_and(|s| s.change_type == "file.write") + }) + .map(|(k, _)| k.clone()) + .collect() + }; + let derived_keys = file_write_keys(&path); + + for gt in &ground_truth { + let under_base = base_root.as_deref().is_some_and(|root| { + std::path::Path::new(gt) + .strip_prefix(root) + .is_ok_and(|rest| rest != std::path::Path::new("")) + }); + if under_base { + let root = base_root.as_deref().unwrap(); + let expected_relative = gt.strip_prefix(root).unwrap().trim_start_matches('/'); + assert!( + derived_keys.contains(expected_relative), + "expected relativized key {expected_relative:?} for {gt:?} under base {root:?}, got {derived_keys:?}" + ); + assert!( + !derived_keys.contains(*gt), + "absolute-under-base leak: {gt:?} should have been relativized but the absolute form is still a key" + ); + } else { + assert!( + derived_keys.contains(*gt), + "expected {gt:?} to remain an absolute (or opaque) key outside the base, got {derived_keys:?}" + ); + } + } + + let view2 = extract_conversation(&path); + let path2 = derive_path(&view2, &DeriveConfig::default()); + assert_eq!( + derived_keys, + file_write_keys(&path2), + "re-derive must reproduce the identical file.write key set" + ); +} diff --git a/crates/toolpath-gemini/tests/real_fixture_roundtrip.rs b/crates/toolpath-gemini/tests/real_fixture_roundtrip.rs index e335ecd5..4c8f1237 100644 --- a/crates/toolpath-gemini/tests/real_fixture_roundtrip.rs +++ b/crates/toolpath-gemini/tests/real_fixture_roundtrip.rs @@ -259,3 +259,112 @@ fn projector_output_is_re_parseable_by_reader() { std::fs::write(tmp.path(), &json).expect("write tempfile"); ConversationReader::read_chat_file(tmp.path()).expect("re-read projected ChatFile"); } + +/// Ground-truth invariant for #124 (relativized file-change keys) run +/// against a real recorded session: derive `view` under `config`, then for +/// every pre-derive `FileMutation::path` assert (a) it produced a +/// relativized key iff it actually sat under `path.base` on a path- +/// component boundary -- no absolute-under-base leak, and no wrongly- +/// relativized outside-base key -- and (b) extracting and re-deriving +/// reproduces the identical `file.write` key set (idempotency). +/// +/// "Under base" is independently recomputed here via `std::path::Path` +/// component stripping rather than by calling `toolpath_convo`'s own +/// (private) `relativize_key`, so this exercises its output rather than +/// re-asserting its internals. +fn assert_file_write_keys_match_base(view: &ConversationView, config: &DeriveConfig) { + let path = derive_path(view, config); + let base_root: Option = path + .path + .base + .as_ref() + .and_then(|b| b.uri.strip_prefix("file://")) + .map(|s| s.trim_end_matches('/').to_string()); + + let ground_truth: Vec<&str> = view + .turns + .iter() + .flat_map(|t| t.file_mutations.iter().map(|fm| fm.path.as_str())) + .collect(); + assert!( + !ground_truth.is_empty(), + "fixture must exercise at least one file mutation for this test to be meaningful" + ); + + let file_write_keys = |p: &toolpath::v1::Path| -> BTreeSet { + p.steps + .iter() + .flat_map(|s| s.change.iter()) + .filter(|(_, ch)| { + ch.structural + .as_ref() + .is_some_and(|s| s.change_type == "file.write") + }) + .map(|(k, _)| k.clone()) + .collect() + }; + let derived_keys = file_write_keys(&path); + + for gt in &ground_truth { + let under_base = base_root.as_deref().is_some_and(|root| { + std::path::Path::new(gt) + .strip_prefix(root) + .is_ok_and(|rest| rest != std::path::Path::new("")) + }); + if under_base { + let root = base_root.as_deref().unwrap(); + let expected_relative = gt.strip_prefix(root).unwrap().trim_start_matches('/'); + assert!( + derived_keys.contains(expected_relative), + "expected relativized key {expected_relative:?} for {gt:?} under base {root:?}, got {derived_keys:?}" + ); + assert!( + !derived_keys.contains(*gt), + "absolute-under-base leak: {gt:?} should have been relativized but the absolute form is still a key" + ); + } else { + assert!( + derived_keys.contains(*gt), + "expected {gt:?} to remain an absolute (or opaque) key outside the base, got {derived_keys:?}" + ); + } + } + + let view2 = extract_conversation(&path); + let path2 = derive_path(&view2, &DeriveConfig::default()); + assert_eq!( + derived_keys, + file_write_keys(&path2), + "re-derive must reproduce the identical file.write key set" + ); +} + +#[test] +fn file_write_keys_relativized_with_no_leak_and_stable_on_re_derive() { + // The captured fixture's `to_view()` conversation carries no + // `directories`/`project_path`, AND Gemini's own tool-call args + // record `file_path` as an already-relative string (e.g. "notes.md") + // -- there's no absolute path anywhere in this session to + // relativize, so the no-leak invariant would hold trivially without + // exercising real stripping. Inject one synthetic absolute mutation + // under a chosen base to genuinely exercise stripping, alongside the + // fixture's own (already-relative, therefore untouched) mutations. + let mut view = load_fixture_view(); + let turn = view + .turns + .iter_mut() + .find(|t| !t.file_mutations.is_empty()) + .expect("fixture has at least one turn with file mutations"); + turn.file_mutations.push(toolpath_convo::FileMutation { + path: "/synthetic-base/nested/synth.rs".to_string(), + operation: Some("update".into()), + raw_diff: Some("".into()), + ..Default::default() + }); + + let config = DeriveConfig { + base_uri: Some("file:///synthetic-base".to_string()), + ..Default::default() + }; + assert_file_write_keys_match_base(&view, &config); +} diff --git a/crates/toolpath-opencode/tests/real_fixture_roundtrip.rs b/crates/toolpath-opencode/tests/real_fixture_roundtrip.rs index c4d20073..6aefe0fe 100644 --- a/crates/toolpath-opencode/tests/real_fixture_roundtrip.rs +++ b/crates/toolpath-opencode/tests/real_fixture_roundtrip.rs @@ -367,3 +367,94 @@ fn projected_session_is_json_serde_symmetric() { let json = serde_json::to_string(&session).expect("serialize Session"); let _back: Session = serde_json::from_str(&json).expect("re-parse Session"); } + +/// `Builder::compute_turn_mutations` runs unconditionally (even under +/// plain `to_view`, which opens no snapshot repo) -- when there's no +/// snapshot diff to draw from, it falls back to a tool-input-derived +/// `FileMutation` per `FileWrite` tool call via opencode's own +/// `tool_input_file_path` (which recognizes the `filePath` field +/// opencode's tools actually use). So, unlike pi/gemini, ground truth +/// for this provider lives in `Turn::file_mutations`. +fn ground_truth_paths(view: &ConversationView) -> Vec<&str> { + view.turns + .iter() + .flat_map(|t| t.file_mutations.iter().map(|fm| fm.path.as_str())) + .collect() +} + +/// Ground-truth invariant for #124 (relativized file-change keys) run +/// against a real recorded session: derive `view`, then for every +/// pre-derive file-write path assert (a) it produced a relativized key +/// iff it actually sat under `path.base` on a path-component boundary -- +/// no absolute-under-base leak, and no wrongly-relativized outside-base +/// key -- and (b) extracting and re-deriving reproduces the identical +/// `file.write` key set (idempotency). +/// +/// "Under base" is independently recomputed here via `std::path::Path` +/// component stripping rather than by calling `toolpath_convo`'s own +/// (private) `relativize_key`, so this exercises its output rather than +/// re-asserting its internals. +#[test] +fn file_write_keys_relativized_with_no_leak_and_stable_on_re_derive() { + let view = load_fixture_view(); + let path = derive_path(&view, &DeriveConfig::default()); + let base_root: Option = path + .path + .base + .as_ref() + .and_then(|b| b.uri.strip_prefix("file://")) + .map(|s| s.trim_end_matches('/').to_string()); + + let ground_truth = ground_truth_paths(&view); + assert!( + !ground_truth.is_empty(), + "fixture must exercise at least one file write for this test to be meaningful" + ); + + let file_write_keys = |p: &toolpath::v1::Path| -> BTreeSet { + p.steps + .iter() + .flat_map(|s| s.change.iter()) + .filter(|(_, ch)| { + ch.structural + .as_ref() + .is_some_and(|s| s.change_type == "file.write") + }) + .map(|(k, _)| k.clone()) + .collect() + }; + let derived_keys = file_write_keys(&path); + + for gt in &ground_truth { + let under_base = base_root.as_deref().is_some_and(|root| { + std::path::Path::new(gt) + .strip_prefix(root) + .is_ok_and(|rest| rest != std::path::Path::new("")) + }); + if under_base { + let root = base_root.as_deref().unwrap(); + let expected_relative = gt.strip_prefix(root).unwrap().trim_start_matches('/'); + assert!( + derived_keys.contains(expected_relative), + "expected relativized key {expected_relative:?} for {gt:?} under base {root:?}, got {derived_keys:?}" + ); + assert!( + !derived_keys.contains(*gt), + "absolute-under-base leak: {gt:?} should have been relativized but the absolute form is still a key" + ); + } else { + assert!( + derived_keys.contains(*gt), + "expected {gt:?} to remain an absolute (or opaque) key outside the base, got {derived_keys:?}" + ); + } + } + + let view2 = extract_conversation(&path); + let path2 = derive_path(&view2, &DeriveConfig::default()); + assert_eq!( + derived_keys, + file_write_keys(&path2), + "re-derive must reproduce the identical file.write key set" + ); +} diff --git a/crates/toolpath-pi/tests/real_fixture_roundtrip.rs b/crates/toolpath-pi/tests/real_fixture_roundtrip.rs index e82c67ba..c25ae844 100644 --- a/crates/toolpath-pi/tests/real_fixture_roundtrip.rs +++ b/crates/toolpath-pi/tests/real_fixture_roundtrip.rs @@ -236,3 +236,144 @@ fn projector_output_is_re_parseable_by_reader() { std::fs::write(tmp.path(), lines.join("\n")).expect("write tempfile"); reader::read_session_from_file(tmp.path()).expect("re-read projected JSONL"); } + +/// Pi never populates `Turn::file_mutations` (its provider always leaves it +/// `Vec::new()` -- see `crates/toolpath-pi/src/provider.rs`); every file +/// write instead falls through `derive_path`'s `FileWrite`-category +/// `tool_uses` fallback, which pulls the path out of the tool's raw JSON +/// `input` (`file_path`/`path`/`filename`/`file`, first match wins -- the +/// same field priority as `derive.rs`'s private `extract_file_path`, +/// reimplemented here since ground truth for this provider lives in +/// `tool_uses`, not `file_mutations`). +fn ground_truth_paths(view: &ConversationView) -> Vec { + view.turns + .iter() + .flat_map(|t| { + t.tool_uses.iter().filter_map(|tool| { + if tool.category != Some(toolpath_convo::ToolCategory::FileWrite) { + return None; + } + ["file_path", "path", "filename", "file"].iter().find_map(|field| { + tool.input + .get(*field) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + }) + }) + }) + .collect() +} + +/// Ground-truth invariant for #124 (relativized file-change keys) run +/// against a real recorded session: derive `view`, then for every +/// pre-derive file-write path assert (a) it produced a relativized key +/// iff it actually sat under `path.base` on a path-component boundary -- +/// no absolute-under-base leak, and no wrongly-relativized outside-base +/// key -- and (b) extracting and re-deriving reproduces the identical +/// `file.write` key set (idempotency). +/// +/// "Under base" is independently recomputed here via `std::path::Path` +/// component stripping rather than by calling `toolpath_convo`'s own +/// (private) `relativize_key`, so this exercises its output rather than +/// re-asserting its internals. +fn assert_file_write_keys_match_base(view: &ConversationView) { + let path = derive_path(view, &DeriveConfig::default()); + let base_root: Option = path + .path + .base + .as_ref() + .and_then(|b| b.uri.strip_prefix("file://")) + .map(|s| s.trim_end_matches('/').to_string()); + + let ground_truth = ground_truth_paths(view); + assert!( + !ground_truth.is_empty(), + "fixture must exercise at least one file write for this test to be meaningful" + ); + + let file_write_keys = |p: &toolpath::v1::Path| -> BTreeSet { + p.steps + .iter() + .flat_map(|s| s.change.iter()) + .filter(|(_, ch)| { + ch.structural + .as_ref() + .is_some_and(|s| s.change_type == "file.write") + }) + .map(|(k, _)| k.clone()) + .collect() + }; + let derived_keys = file_write_keys(&path); + + for gt in &ground_truth { + let under_base = base_root.as_deref().is_some_and(|root| { + std::path::Path::new(gt) + .strip_prefix(root) + .is_ok_and(|rest| rest != std::path::Path::new("")) + }); + if under_base { + let root = base_root.as_deref().unwrap(); + let expected_relative = gt.strip_prefix(root).unwrap().trim_start_matches('/'); + assert!( + derived_keys.contains(expected_relative), + "expected relativized key {expected_relative:?} for {gt:?} under base {root:?}, got {derived_keys:?}" + ); + assert!( + !derived_keys.contains(gt.as_str()), + "absolute-under-base leak: {gt:?} should have been relativized but the absolute form is still a key" + ); + } else { + assert!( + derived_keys.contains(gt.as_str()), + "expected {gt:?} to remain an absolute (or opaque) key outside the base, got {derived_keys:?}" + ); + } + } + + let view2 = extract_conversation(&path); + let path2 = derive_path(&view2, &DeriveConfig::default()); + assert_eq!( + derived_keys, + file_write_keys(&path2), + "re-derive must reproduce the identical file.write key set" + ); +} + +#[test] +fn file_write_keys_relativized_with_no_leak_and_stable_on_re_derive() { + // The captured fixture's tool-call file paths are already + // provider-relative strings (e.g. "notes.md", "count.sh") -- + // `extract_file_path` returns them verbatim, with no cwd-join -- so + // there's no absolute path in this session to relativize, and the + // no-leak invariant would hold trivially without exercising real + // stripping. Inject one synthetic absolute `FileWrite` tool call under + // the fixture's own recorded working_dir to genuinely exercise + // stripping against a real base, alongside the fixture's own + // (already-relative, therefore untouched) tool calls. + let mut view = load_fixture_view(); + let base_dir = view + .base + .as_ref() + .and_then(|b| b.working_dir.clone()) + .expect("fixture records a working_dir"); + let turn = view + .turns + .iter_mut() + .find(|t| { + t.tool_uses + .iter() + .any(|tool| tool.category == Some(toolpath_convo::ToolCategory::FileWrite)) + }) + .expect("fixture has at least one FileWrite tool call"); + turn.tool_uses.push(toolpath_convo::ToolInvocation { + id: "synthetic-tool-call".to_string(), + name: "write".to_string(), + input: serde_json::json!({ + "path": format!("{}/synthetic-nested/synth.rs", base_dir.trim_end_matches('/')), + }), + result: None, + category: Some(toolpath_convo::ToolCategory::FileWrite), + }); + + assert_file_write_keys_match_base(&view); +} From d59208a1f1fec6fb05e82cc586120bdd7a31c8f8 Mon Sep 17 00:00:00 2001 From: Alex Kesling Date: Thu, 9 Jul 2026 13:06:49 -0400 Subject: [PATCH 5/7] test(cli): characterize validate/merge behavior for mixed-key-era documents path p validate keeps accepting a pre-#124 document whose file.write keys are verbatim absolute paths -- relativization is a derive_path write-side behavior, not a schema requirement, so old documents stay valid forever. path p merge concatenates paths verbatim with no cross-document key unification (see cmd_merge.rs::merge_into_graph), so two documents that touch the "same" file under different key conventions -- one absolute, one base-relative -- merge with both keys surviving as distinct artifacts. Pins this as the intended (if unglamorous) cross-version behavior rather than a bug to fix. Neither test needs the $TOOLPATH_CONFIG_DIR/TEST_ENV_LOCK sandbox the resume/share/import suites use: validate and merge operate purely on the file paths given to them and never touch $HOME or the cache. --- crates/path-cli/tests/relative_change_keys.rs | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 crates/path-cli/tests/relative_change_keys.rs diff --git a/crates/path-cli/tests/relative_change_keys.rs b/crates/path-cli/tests/relative_change_keys.rs new file mode 100644 index 00000000..0fa79a70 --- /dev/null +++ b/crates/path-cli/tests/relative_change_keys.rs @@ -0,0 +1,144 @@ +//! Characterization tests for #124 (`toolpath-convo::derive_path` now +//! stores file-change artifact keys base-relative rather than verbatim). +//! +//! These exercise the CLI's read side against documents that predate that +//! change (absolute keys) as well as the mixed-key transition period where +//! one document uses old-style absolute keys and another uses new-style +//! relative ones. Both `p validate` and `p merge` operate purely on the +//! file paths/stdin given to them -- neither touches `$TOOLPATH_CONFIG_DIR` +//! or the cache -- so, unlike the `resume`/`share`/`import` suites, no +//! `$HOME`/`$TOOLPATH_CONFIG_DIR` sandboxing or env lock is needed here. + +use assert_cmd::Command; +use predicates::prelude::*; +use std::io::Write; + +fn cmd() -> Command { + Command::cargo_bin("path").unwrap() +} + +fn write_temp(content: &str) -> tempfile::NamedTempFile { + let mut f = tempfile::Builder::new().suffix(".json").tempfile().unwrap(); + f.write_all(content.as_bytes()).unwrap(); + f.flush().unwrap(); + f +} + +/// A pre-#124 producer wrote file-change keys as the verbatim absolute +/// path. `p validate` must keep accepting such a document unchanged -- +/// relativization is a `derive_path` write-side behavior, not a schema +/// requirement, so old documents stay valid forever. +#[test] +fn validate_accepts_pre_relativization_absolute_key_document() { + let json = r#"{ + "graph": {"id": "g1"}, + "paths": [ + { + "path": { + "id": "p1", + "base": {"uri": "file:///old/proj"}, + "head": "s1" + }, + "steps": [ + { + "step": { + "id": "s1", + "actor": "human:alex", + "timestamp": "2026-01-01T00:00:00Z" + }, + "change": { + "/old/proj/src/legacy.rs": { + "raw": "@@ -1,1 +1,1 @@\n-old\n+new" + } + } + } + ] + } + ] + }"#; + let f = write_temp(json); + + cmd() + .args(["p", "validate"]) + .arg("--input") + .arg(f.path()) + .assert() + .success() + .stdout(predicate::str::contains("Valid")); +} + +/// Characterizes the intended cross-version behavior during the +/// transition: `p merge` concatenates `paths` verbatim (see +/// `crates/path-cli/src/cmd_merge.rs::merge_into_graph`) with no +/// cross-document key unification. Two documents that both touch the +/// "same" file -- one keyed by the pre-#124 absolute form, the other by +/// the post-#124 base-relative form -- merge into a graph where BOTH keys +/// survive as distinct artifacts. This is not a bug: reconciling absolute +/// and relative keys for the same underlying file across independently +/// authored documents is out of scope for `merge`, which only concatenates. +#[test] +fn merge_does_not_unify_absolute_and_relative_keys_for_the_same_file() { + let absolute_keyed = r#"{ + "graph": {"id": "g-absolute"}, + "paths": [ + { + "path": { + "id": "p-absolute", + "base": {"uri": "file:///proj"}, + "head": "s1" + }, + "steps": [ + { + "step": { + "id": "s1", + "actor": "human:alex", + "timestamp": "2026-01-01T00:00:00Z" + }, + "change": { + "/proj/f.rs": { + "raw": "@@ -1,1 +1,1 @@\n-old\n+new" + } + } + } + ] + } + ] + }"#; + let relative_keyed = r#"{ + "graph": {"id": "g-relative"}, + "paths": [ + { + "path": { + "id": "p-relative", + "base": {"uri": "file:///proj"}, + "head": "s1" + }, + "steps": [ + { + "step": { + "id": "s1", + "actor": "human:alex", + "timestamp": "2026-01-01T00:00:00Z" + }, + "change": { + "f.rs": { + "raw": "@@ -1,1 +1,1 @@\n-old\n+new" + } + } + } + ] + } + ] + }"#; + let f1 = write_temp(absolute_keyed); + let f2 = write_temp(relative_keyed); + + cmd() + .args(["p", "merge"]) + .arg(f1.path()) + .arg(f2.path()) + .assert() + .success() + .stdout(predicate::str::contains("\"/proj/f.rs\"")) + .stdout(predicate::str::contains("\"f.rs\"")); +} From e4f1aa98561eaa4f136ad768c4e86d803414546c Mon Sep 17 00:00:00 2001 From: Alex Kesling Date: Thu, 9 Jul 2026 13:04:38 -0400 Subject: [PATCH 6/7] docs(convo): trim FileMutation.path doc to the essentials --- crates/toolpath-convo/src/lib.rs | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/crates/toolpath-convo/src/lib.rs b/crates/toolpath-convo/src/lib.rs index 18d95a17..36c8e640 100644 --- a/crates/toolpath-convo/src/lib.rs +++ b/crates/toolpath-convo/src/lib.rs @@ -118,17 +118,9 @@ pub struct SessionBase { /// snapshot diffs between turns). #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct FileMutation { - /// The file this mutation targets, in the provider's original - /// coordinate space: absolute when the session's workspace root is - /// known (the common case), otherwise workspace-relative. - /// - /// Contract: this value is stable across a `derive` → `extract` - /// round-trip. On the serialized `Path` the change is keyed relative - /// to `path.base` (bare paths per the RFC), but that key - /// representation is internal to `derive`/`extract` — every IR - /// consumer reads the same path here regardless of how the document - /// was serialized. Resolve a relative value against - /// `EnvironmentSnapshot::working_dir` or the path's base. + /// The file this mutation targets: absolute when the session's + /// workspace root is known (the common case), otherwise relative to + /// it — resolve a relative value against `EnvironmentSnapshot::working_dir`. pub path: String, /// `ToolInvocation::id` of the tool call that produced this mutation, /// when the provider can attribute it. From 1a32826d4d1595c3e976cb600ef6ff242aa4cf50 Mon Sep 17 00:00:00 2001 From: Alex Kesling Date: Thu, 9 Jul 2026 13:25:43 -0400 Subject: [PATCH 7/7] test(providers): factor no-leak helper and guarantee under-base coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up on the #124 provider key-stability tests. Finding 1: codex, opencode, and cursor had the derive+assert block inlined in the #[test] body while claude/gemini/pi factored it into a local assert_file_write_keys_match_base helper. Extract each into a matching per-crate local helper (no shared test-util crate — the cross-crate sameness is accepted). codex now sources ground truth from the view's FileMutation paths (Codex populates these from patch_apply_end) and uses BTreeSet like the others instead of HashSet. Finding 2: the no-leak assertion could silently no-op — a fixture with no absolute-under-base path never enters the `if under_base` branch, so the test would pass without ever exercising the exact case #124 relativizes. All six helpers now track a saw_under_base bool and assert it fired at least once. Verified per provider: claude, codex, opencode, and cursor real fixtures already carry absolute paths under their recorded base, so the branch fires on real data with no injection; gemini and pi record provider-relative tool paths (and derive no usable base), so they keep the synthetic absolute-under-base FileMutation/tool-call injection that makes the branch genuinely run. --- .../tests/real_fixture_roundtrip.rs | 10 ++ crates/toolpath-codex/tests/fidelity.rs | 121 +++++++++++------- .../tests/real_fixture_roundtrip.rs | 24 +++- .../tests/real_fixture_roundtrip.rs | 10 ++ .../tests/real_fixture_roundtrip.rs | 26 +++- .../tests/real_fixture_roundtrip.rs | 10 ++ 6 files changed, 146 insertions(+), 55 deletions(-) diff --git a/crates/toolpath-claude/tests/real_fixture_roundtrip.rs b/crates/toolpath-claude/tests/real_fixture_roundtrip.rs index 3fcc15e7..3f927b13 100644 --- a/crates/toolpath-claude/tests/real_fixture_roundtrip.rs +++ b/crates/toolpath-claude/tests/real_fixture_roundtrip.rs @@ -284,6 +284,11 @@ fn assert_file_write_keys_match_base(view: &ConversationView) { }; let derived_keys = file_write_keys(&path); + // Track that the under-base branch -- the exact case #124 relativizes -- + // actually fires at least once, so a fixture (or a regression) with no + // absolute-under-base path can't let this test silently no-op the + // invariant it exists to guard. + let mut saw_under_base = false; for gt in &ground_truth { let under_base = base_root.as_deref().is_some_and(|root| { std::path::Path::new(gt) @@ -291,6 +296,7 @@ fn assert_file_write_keys_match_base(view: &ConversationView) { .is_ok_and(|rest| rest != std::path::Path::new("")) }); if under_base { + saw_under_base = true; let root = base_root.as_deref().unwrap(); let expected_relative = gt.strip_prefix(root).unwrap().trim_start_matches('/'); assert!( @@ -308,6 +314,10 @@ fn assert_file_write_keys_match_base(view: &ConversationView) { ); } } + assert!( + saw_under_base, + "test must exercise at least one absolute-under-base key -- the invariant #124 changed" + ); let view2 = extract_conversation(&path); let path2 = derive_path(&view2, &DeriveConfig::default()); diff --git a/crates/toolpath-codex/tests/fidelity.rs b/crates/toolpath-codex/tests/fidelity.rs index c47fa7fb..9a9dec42 100644 --- a/crates/toolpath-codex/tests/fidelity.rs +++ b/crates/toolpath-codex/tests/fidelity.rs @@ -11,12 +11,12 @@ //! the source carried a real one. The old tests only asserted counts //! and totals, so the drop went undetected. -use std::collections::HashSet; +use std::collections::{BTreeSet, HashSet}; use std::path::PathBuf; use toolpath_codex::provider::to_view; use toolpath_codex::{ResponseItem, RolloutItem, RolloutReader, derive}; -use toolpath_convo::{DeriveConfig, Role, extract_conversation}; +use toolpath_convo::{ConversationView, DeriveConfig, Role, derive_path, extract_conversation}; fn fixture_path() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/sample-codex-python.jsonl") @@ -347,25 +347,39 @@ fn patch_apply_files_all_surface_as_artifacts() { } } -/// Extends `patch_apply_files_all_surface_as_artifacts` with the two -/// invariants that test doesn't cover: (a) a path that DID get -/// relativized must not ALSO leave its absolute form as a key -/// (no absolute-under-base leak), and (b) extracting the derived path -/// and re-deriving from it reproduces the identical `file.write` key -/// set (idempotency). -#[test] -fn patch_apply_file_write_keys_no_leak_and_stable_on_re_derive() { - let s = session(); - let path = derive::derive_path(&s, &derive::DeriveConfig::default()); - +/// Ground-truth invariant for #124 (relativized file-change keys) run +/// against a real recorded session: derive `view`, then for every +/// pre-derive `FileMutation::path` (Codex populates these from +/// `patch_apply_end` events) assert (a) it produced a relativized key +/// iff it actually sat under `path.base` on a path-component boundary -- +/// no absolute-under-base leak, and no wrongly-relativized outside-base +/// key -- and (b) extracting and re-deriving reproduces the identical +/// `file.write` key set (idempotency). +/// +/// "Under base" is independently recomputed here via `std::path::Path` +/// component stripping rather than by calling `toolpath_convo`'s own +/// (private) `relativize_key`, so this exercises its output rather than +/// re-asserting its internals. +fn assert_file_write_keys_match_base(view: &ConversationView) { + let path = derive_path(view, &DeriveConfig::default()); let base_root: Option = path .path .base .as_ref() .and_then(|b| b.uri.strip_prefix("file://")) - .map(|r| r.trim_end_matches('/').to_string()); + .map(|s| s.trim_end_matches('/').to_string()); + + let ground_truth: Vec<&str> = view + .turns + .iter() + .flat_map(|t| t.file_mutations.iter().map(|fm| fm.path.as_str())) + .collect(); + assert!( + !ground_truth.is_empty(), + "fixture must exercise at least one file mutation for this test to be meaningful" + ); - let file_write_keys = |p: &toolpath::v1::Path| -> HashSet { + let file_write_keys = |p: &toolpath::v1::Path| -> BTreeSet { p.steps .iter() .flat_map(|s| s.change.iter()) @@ -377,45 +391,60 @@ fn patch_apply_file_write_keys_no_leak_and_stable_on_re_derive() { .map(|(k, _)| k.clone()) .collect() }; - let keys1 = file_write_keys(&path); - - let mut checked_any = false; - for line in &s.lines { - if let RolloutItem::EventMsg(toolpath_codex::EventMsg::PatchApplyEnd(patch)) = line.item() { - if !patch.success { - continue; - } - for file_path in patch.changes.keys() { - checked_any = true; - let under_base = base_root.as_deref().is_some_and(|root| { - std::path::Path::new(file_path) - .strip_prefix(root) - .is_ok_and(|rest| rest != std::path::Path::new("")) - }); - if under_base { - assert!( - !keys1.contains(file_path.as_str()), - "absolute-under-base leak: {file_path:?} should have been relativized but its absolute form is still a key" - ); - } else { - assert!( - keys1.contains(file_path.as_str()), - "expected {file_path:?} to remain an absolute key outside the base" - ); - } - } + let derived_keys = file_write_keys(&path); + + // Track that the under-base branch -- the exact case #124 relativizes -- + // actually fires at least once, so a fixture (or a regression) with no + // absolute-under-base path can't let this test silently no-op the + // invariant it exists to guard. + let mut saw_under_base = false; + for gt in &ground_truth { + let under_base = base_root.as_deref().is_some_and(|root| { + std::path::Path::new(gt) + .strip_prefix(root) + .is_ok_and(|rest| rest != std::path::Path::new("")) + }); + if under_base { + saw_under_base = true; + let root = base_root.as_deref().unwrap(); + let expected_relative = gt.strip_prefix(root).unwrap().trim_start_matches('/'); + assert!( + derived_keys.contains(expected_relative), + "expected relativized key {expected_relative:?} for {gt:?} under base {root:?}, got {derived_keys:?}" + ); + assert!( + !derived_keys.contains(*gt), + "absolute-under-base leak: {gt:?} should have been relativized but the absolute form is still a key" + ); + } else { + assert!( + derived_keys.contains(*gt), + "expected {gt:?} to remain an absolute (or opaque) key outside the base, got {derived_keys:?}" + ); } } assert!( - checked_any, - "fixture must exercise at least one patch_apply_end file for this test to be meaningful" + saw_under_base, + "test must exercise at least one absolute-under-base key -- the invariant #124 changed" ); let view2 = extract_conversation(&path); - let path2 = toolpath_convo::derive_path(&view2, &DeriveConfig::default()); + let path2 = derive_path(&view2, &DeriveConfig::default()); assert_eq!( - keys1, + derived_keys, file_write_keys(&path2), "re-derive must reproduce the identical file.write key set" ); } + +/// Extends `patch_apply_files_all_surface_as_artifacts` with the two +/// invariants that test doesn't cover: no absolute-under-base leak, and +/// extract -> re-derive idempotency of the `file.write` key set. Codex's +/// real fixture records absolute patch paths under the session's recorded +/// cwd, so the under-base branch fires on the real data with no synthetic +/// injection needed. +#[test] +fn patch_apply_file_write_keys_no_leak_and_stable_on_re_derive() { + let view = to_view(&session()); + assert_file_write_keys_match_base(&view); +} diff --git a/crates/toolpath-cursor/tests/real_fixture_roundtrip.rs b/crates/toolpath-cursor/tests/real_fixture_roundtrip.rs index daff45d6..1f573eb7 100644 --- a/crates/toolpath-cursor/tests/real_fixture_roundtrip.rs +++ b/crates/toolpath-cursor/tests/real_fixture_roundtrip.rs @@ -51,10 +51,8 @@ fn fixture_loads() { /// component stripping rather than by calling `toolpath_convo`'s own /// (private) `relativize_key`, so this exercises its output rather than /// re-asserting its internals. -#[test] -fn file_write_keys_relativized_with_no_leak_and_stable_on_re_derive() { - let view = load_fixture_view(); - let path = derive_path(&view, &DeriveConfig::default()); +fn assert_file_write_keys_match_base(view: &ConversationView) { + let path = derive_path(view, &DeriveConfig::default()); let base_root: Option = path .path .base @@ -86,6 +84,11 @@ fn file_write_keys_relativized_with_no_leak_and_stable_on_re_derive() { }; let derived_keys = file_write_keys(&path); + // Track that the under-base branch -- the exact case #124 relativizes -- + // actually fires at least once, so a fixture (or a regression) with no + // absolute-under-base path can't let this test silently no-op the + // invariant it exists to guard. + let mut saw_under_base = false; for gt in &ground_truth { let under_base = base_root.as_deref().is_some_and(|root| { std::path::Path::new(gt) @@ -93,6 +96,7 @@ fn file_write_keys_relativized_with_no_leak_and_stable_on_re_derive() { .is_ok_and(|rest| rest != std::path::Path::new("")) }); if under_base { + saw_under_base = true; let root = base_root.as_deref().unwrap(); let expected_relative = gt.strip_prefix(root).unwrap().trim_start_matches('/'); assert!( @@ -110,6 +114,10 @@ fn file_write_keys_relativized_with_no_leak_and_stable_on_re_derive() { ); } } + assert!( + saw_under_base, + "test must exercise at least one absolute-under-base key -- the invariant #124 changed" + ); let view2 = extract_conversation(&path); let path2 = derive_path(&view2, &DeriveConfig::default()); @@ -119,3 +127,11 @@ fn file_write_keys_relativized_with_no_leak_and_stable_on_re_derive() { "re-derive must reproduce the identical file.write key set" ); } + +#[test] +fn file_write_keys_relativized_with_no_leak_and_stable_on_re_derive() { + // cursor's real fixture records absolute file-mutation paths under the + // composer's workspace root, so the under-base branch fires on the real + // data with no synthetic injection needed. + assert_file_write_keys_match_base(&load_fixture_view()); +} diff --git a/crates/toolpath-gemini/tests/real_fixture_roundtrip.rs b/crates/toolpath-gemini/tests/real_fixture_roundtrip.rs index 4c8f1237..77eef563 100644 --- a/crates/toolpath-gemini/tests/real_fixture_roundtrip.rs +++ b/crates/toolpath-gemini/tests/real_fixture_roundtrip.rs @@ -305,6 +305,11 @@ fn assert_file_write_keys_match_base(view: &ConversationView, config: &DeriveCon }; let derived_keys = file_write_keys(&path); + // Track that the under-base branch -- the exact case #124 relativizes -- + // actually fires at least once, so a fixture (or a regression) with no + // absolute-under-base path can't let this test silently no-op the + // invariant it exists to guard. + let mut saw_under_base = false; for gt in &ground_truth { let under_base = base_root.as_deref().is_some_and(|root| { std::path::Path::new(gt) @@ -312,6 +317,7 @@ fn assert_file_write_keys_match_base(view: &ConversationView, config: &DeriveCon .is_ok_and(|rest| rest != std::path::Path::new("")) }); if under_base { + saw_under_base = true; let root = base_root.as_deref().unwrap(); let expected_relative = gt.strip_prefix(root).unwrap().trim_start_matches('/'); assert!( @@ -329,6 +335,10 @@ fn assert_file_write_keys_match_base(view: &ConversationView, config: &DeriveCon ); } } + assert!( + saw_under_base, + "test must exercise at least one absolute-under-base key -- the invariant #124 changed" + ); let view2 = extract_conversation(&path); let path2 = derive_path(&view2, &DeriveConfig::default()); diff --git a/crates/toolpath-opencode/tests/real_fixture_roundtrip.rs b/crates/toolpath-opencode/tests/real_fixture_roundtrip.rs index 6aefe0fe..04052871 100644 --- a/crates/toolpath-opencode/tests/real_fixture_roundtrip.rs +++ b/crates/toolpath-opencode/tests/real_fixture_roundtrip.rs @@ -394,10 +394,8 @@ fn ground_truth_paths(view: &ConversationView) -> Vec<&str> { /// component stripping rather than by calling `toolpath_convo`'s own /// (private) `relativize_key`, so this exercises its output rather than /// re-asserting its internals. -#[test] -fn file_write_keys_relativized_with_no_leak_and_stable_on_re_derive() { - let view = load_fixture_view(); - let path = derive_path(&view, &DeriveConfig::default()); +fn assert_file_write_keys_match_base(view: &ConversationView) { + let path = derive_path(view, &DeriveConfig::default()); let base_root: Option = path .path .base @@ -405,7 +403,7 @@ fn file_write_keys_relativized_with_no_leak_and_stable_on_re_derive() { .and_then(|b| b.uri.strip_prefix("file://")) .map(|s| s.trim_end_matches('/').to_string()); - let ground_truth = ground_truth_paths(&view); + let ground_truth = ground_truth_paths(view); assert!( !ground_truth.is_empty(), "fixture must exercise at least one file write for this test to be meaningful" @@ -425,6 +423,11 @@ fn file_write_keys_relativized_with_no_leak_and_stable_on_re_derive() { }; let derived_keys = file_write_keys(&path); + // Track that the under-base branch -- the exact case #124 relativizes -- + // actually fires at least once, so a fixture (or a regression) with no + // absolute-under-base path can't let this test silently no-op the + // invariant it exists to guard. + let mut saw_under_base = false; for gt in &ground_truth { let under_base = base_root.as_deref().is_some_and(|root| { std::path::Path::new(gt) @@ -432,6 +435,7 @@ fn file_write_keys_relativized_with_no_leak_and_stable_on_re_derive() { .is_ok_and(|rest| rest != std::path::Path::new("")) }); if under_base { + saw_under_base = true; let root = base_root.as_deref().unwrap(); let expected_relative = gt.strip_prefix(root).unwrap().trim_start_matches('/'); assert!( @@ -449,6 +453,10 @@ fn file_write_keys_relativized_with_no_leak_and_stable_on_re_derive() { ); } } + assert!( + saw_under_base, + "test must exercise at least one absolute-under-base key -- the invariant #124 changed" + ); let view2 = extract_conversation(&path); let path2 = derive_path(&view2, &DeriveConfig::default()); @@ -458,3 +466,11 @@ fn file_write_keys_relativized_with_no_leak_and_stable_on_re_derive() { "re-derive must reproduce the identical file.write key set" ); } + +#[test] +fn file_write_keys_relativized_with_no_leak_and_stable_on_re_derive() { + // opencode's real fixture records absolute tool-input paths under the + // session's recorded working_dir, so the under-base branch fires on + // the real data with no synthetic injection needed. + assert_file_write_keys_match_base(&load_fixture_view()); +} diff --git a/crates/toolpath-pi/tests/real_fixture_roundtrip.rs b/crates/toolpath-pi/tests/real_fixture_roundtrip.rs index c25ae844..79c06b2f 100644 --- a/crates/toolpath-pi/tests/real_fixture_roundtrip.rs +++ b/crates/toolpath-pi/tests/real_fixture_roundtrip.rs @@ -305,6 +305,11 @@ fn assert_file_write_keys_match_base(view: &ConversationView) { }; let derived_keys = file_write_keys(&path); + // Track that the under-base branch -- the exact case #124 relativizes -- + // actually fires at least once, so a fixture (or a regression) with no + // absolute-under-base path can't let this test silently no-op the + // invariant it exists to guard. + let mut saw_under_base = false; for gt in &ground_truth { let under_base = base_root.as_deref().is_some_and(|root| { std::path::Path::new(gt) @@ -312,6 +317,7 @@ fn assert_file_write_keys_match_base(view: &ConversationView) { .is_ok_and(|rest| rest != std::path::Path::new("")) }); if under_base { + saw_under_base = true; let root = base_root.as_deref().unwrap(); let expected_relative = gt.strip_prefix(root).unwrap().trim_start_matches('/'); assert!( @@ -329,6 +335,10 @@ fn assert_file_write_keys_match_base(view: &ConversationView) { ); } } + assert!( + saw_under_base, + "test must exercise at least one absolute-under-base key -- the invariant #124 changed" + ); let view2 = extract_conversation(&path); let path2 = derive_path(&view2, &DeriveConfig::default());