Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,33 @@

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://<root>` and a file path is absolute and
under `<root>` (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.
- 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

- **`toolpath-convo`** (0.11.1): `derive_path` now guarantees the derived
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
144 changes: 144 additions & 0 deletions crates/path-cli/tests/relative_change_keys.rs
Original file line number Diff line number Diff line change
@@ -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\""));
}
94 changes: 94 additions & 0 deletions crates/toolpath-claude/tests/real_fixture_roundtrip.rs
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,100 @@ 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<String> = 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<String> {
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);

// 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!(
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());
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
Expand Down
7 changes: 4 additions & 3 deletions crates/toolpath-codex/src/derive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {}"),
Expand Down
Loading
Loading