@miller-tech/uap 1.61.2 → 1.61.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.tsbuildinfo +1 -1
- package/dist/self-harness/middleware/path-normalizer.d.ts +2 -9
- package/dist/self-harness/middleware/path-normalizer.d.ts.map +1 -1
- package/dist/self-harness/middleware/path-normalizer.js +11 -38
- package/dist/self-harness/middleware/path-normalizer.js.map +1 -1
- package/package.json +1 -1
- package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
- package/tools/agents/scripts/toolcall_path_normalizer.py +62 -76
- package/tools/agents/tests/test_path_normalizer_hardened.py +144 -0
|
@@ -20,20 +20,13 @@ export interface NormalizeResult {
|
|
|
20
20
|
/** Why it was changed, or null if unchanged. */
|
|
21
21
|
reason: string | null;
|
|
22
22
|
}
|
|
23
|
-
export interface NormalizeOptions {
|
|
24
|
-
/**
|
|
25
|
-
* Max Levenshtein distance (on the basename) for the typo fallback to fire.
|
|
26
|
-
* Defaults to max(2, 30% of basename length).
|
|
27
|
-
*/
|
|
28
|
-
maxEditDistance?: number;
|
|
29
|
-
}
|
|
30
23
|
/**
|
|
31
24
|
* Snap `proposed` to the nearest real file in `workdirFiles` (paths relative to
|
|
32
25
|
* the workdir). Returns `proposed` unchanged if it already resolves or no
|
|
33
26
|
* confident match exists — the normalizer never invents a target, so a genuinely
|
|
34
27
|
* new file (a legitimate create) is left alone.
|
|
35
28
|
*/
|
|
36
|
-
export declare function normalizeToolPath(proposed: string, workdirFiles: string[]
|
|
29
|
+
export declare function normalizeToolPath(proposed: string, workdirFiles: string[]): NormalizeResult;
|
|
37
30
|
/** Tool-call argument keys that carry a file path, by Claude Code tool name. */
|
|
38
31
|
export declare const PATH_ARG_KEYS: readonly ["file_path", "path", "filePath", "notebook_path"];
|
|
39
32
|
/**
|
|
@@ -41,7 +34,7 @@ export declare const PATH_ARG_KEYS: readonly ["file_path", "path", "filePath", "
|
|
|
41
34
|
* Returns the corrections made (empty when nothing changed). Pure w.r.t. the
|
|
42
35
|
* filesystem — `workdirFiles` is supplied by the caller (the hook).
|
|
43
36
|
*/
|
|
44
|
-
export declare function normalizeToolInput(input: Record<string, unknown>, workdirFiles: string[]
|
|
37
|
+
export declare function normalizeToolInput(input: Record<string, unknown>, workdirFiles: string[]): {
|
|
45
38
|
input: Record<string, unknown>;
|
|
46
39
|
corrections: {
|
|
47
40
|
key: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"path-normalizer.d.ts","sourceRoot":"","sources":["../../../src/self-harness/middleware/path-normalizer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,MAAM,WAAW,eAAe;IAC9B,8CAA8C;IAC9C,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,OAAO,CAAC;IACjB,gDAAgD;IAChD,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;CACvB;
|
|
1
|
+
{"version":3,"file":"path-normalizer.d.ts","sourceRoot":"","sources":["../../../src/self-harness/middleware/path-normalizer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,MAAM,WAAW,eAAe;IAC9B,8CAA8C;IAC9C,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,OAAO,CAAC;IACjB,gDAAgD;IAChD,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;CACvB;AAkCD;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAC/B,QAAQ,EAAE,MAAM,EAChB,YAAY,EAAE,MAAM,EAAE,GACrB,eAAe,CAwCjB;AAED,gFAAgF;AAChF,eAAO,MAAM,aAAa,6DAA8D,CAAC;AAEzF;;;;GAIG;AACH,wBAAgB,kBAAkB,CAChC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC9B,YAAY,EAAE,MAAM,EAAE,GACrB;IAAE,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAAC,WAAW,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,EAAE,CAAA;CAAE,CAa9G"}
|
|
@@ -36,30 +36,11 @@ function parentDir(p) {
|
|
|
36
36
|
function dirCompatible(proposed, candidate) {
|
|
37
37
|
const cp = parentDir(candidate);
|
|
38
38
|
if (cp === '')
|
|
39
|
-
return true; // bare
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const n = b.length;
|
|
45
|
-
if (m === 0)
|
|
46
|
-
return n;
|
|
47
|
-
if (n === 0)
|
|
48
|
-
return m;
|
|
49
|
-
const prev = new Array(n + 1);
|
|
50
|
-
const cur = new Array(n + 1);
|
|
51
|
-
for (let j = 0; j <= n; j++)
|
|
52
|
-
prev[j] = j;
|
|
53
|
-
for (let i = 1; i <= m; i++) {
|
|
54
|
-
cur[0] = i;
|
|
55
|
-
for (let j = 1; j <= n; j++) {
|
|
56
|
-
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
57
|
-
cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost);
|
|
58
|
-
}
|
|
59
|
-
for (let j = 0; j <= n; j++)
|
|
60
|
-
prev[j] = cur[j];
|
|
61
|
-
}
|
|
62
|
-
return prev[n];
|
|
39
|
+
return true; // bare workdir-relative file — safe to snap to
|
|
40
|
+
// EXACT parent match only. A previous version compared squash(parent), which
|
|
41
|
+
// collapsed punctuation/case and let writes relocate between genuinely
|
|
42
|
+
// different directories (e.g. `s-space-shooter` vs `s space-shooter`).
|
|
43
|
+
return parentDir(proposed) === cp;
|
|
63
44
|
}
|
|
64
45
|
/**
|
|
65
46
|
* Snap `proposed` to the nearest real file in `workdirFiles` (paths relative to
|
|
@@ -67,7 +48,7 @@ function editDistance(a, b) {
|
|
|
67
48
|
* confident match exists — the normalizer never invents a target, so a genuinely
|
|
68
49
|
* new file (a legitimate create) is left alone.
|
|
69
50
|
*/
|
|
70
|
-
export function normalizeToolPath(proposed, workdirFiles
|
|
51
|
+
export function normalizeToolPath(proposed, workdirFiles) {
|
|
71
52
|
const unchanged = { path: proposed, changed: false, reason: null };
|
|
72
53
|
if (!proposed || workdirFiles.length === 0)
|
|
73
54
|
return unchanged;
|
|
@@ -98,17 +79,9 @@ export function normalizeToolPath(proposed, workdirFiles, opts = {}) {
|
|
|
98
79
|
hit = unique(safe(workdirFiles.filter((f) => squash(basename(f)) === sb)));
|
|
99
80
|
if (hit)
|
|
100
81
|
return snap(hit, 'extension/punctuation-normalized to the real filename');
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
|
|
104
|
-
const ranked = safe(workdirFiles)
|
|
105
|
-
.map((f) => ({ f, d: editDistance(base.toLowerCase(), basename(f).toLowerCase()) }))
|
|
106
|
-
.sort((x, y) => x.d - y.d);
|
|
107
|
-
if (ranked.length > 0 &&
|
|
108
|
-
ranked[0].d <= maxD &&
|
|
109
|
-
(ranked.length < 2 || ranked[1].d > ranked[0].d)) {
|
|
110
|
-
return snap(ranked[0].f, `nearest filename by edit distance (${ranked[0].d})`);
|
|
111
|
-
}
|
|
82
|
+
// No edit-distance fallback: guessing the "nearest" filename produced wrong
|
|
83
|
+
// snaps (e.g. `oct` -> `octop`). A garble we cannot resolve by exact basename /
|
|
84
|
+
// case / punctuation match is left unchanged so it fails loud and self-corrects.
|
|
112
85
|
return unchanged;
|
|
113
86
|
}
|
|
114
87
|
/** Tool-call argument keys that carry a file path, by Claude Code tool name. */
|
|
@@ -118,14 +91,14 @@ export const PATH_ARG_KEYS = ['file_path', 'path', 'filePath', 'notebook_path'];
|
|
|
118
91
|
* Returns the corrections made (empty when nothing changed). Pure w.r.t. the
|
|
119
92
|
* filesystem — `workdirFiles` is supplied by the caller (the hook).
|
|
120
93
|
*/
|
|
121
|
-
export function normalizeToolInput(input, workdirFiles
|
|
94
|
+
export function normalizeToolInput(input, workdirFiles) {
|
|
122
95
|
const corrections = [];
|
|
123
96
|
const out = { ...input };
|
|
124
97
|
for (const key of PATH_ARG_KEYS) {
|
|
125
98
|
const v = out[key];
|
|
126
99
|
if (typeof v !== 'string')
|
|
127
100
|
continue;
|
|
128
|
-
const r = normalizeToolPath(v, workdirFiles
|
|
101
|
+
const r = normalizeToolPath(v, workdirFiles);
|
|
129
102
|
if (r.changed) {
|
|
130
103
|
out[key] = r.path;
|
|
131
104
|
corrections.push({ key, from: v, to: r.path, reason: r.reason ?? '' });
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"path-normalizer.js","sourceRoot":"","sources":["../../../src/self-harness/middleware/path-normalizer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;
|
|
1
|
+
{"version":3,"file":"path-normalizer.js","sourceRoot":"","sources":["../../../src/self-harness/middleware/path-normalizer.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
Binary file
|
|
@@ -15,6 +15,7 @@ TS reference at src/self-harness/middleware/path-normalizer.ts.
|
|
|
15
15
|
See docs/design/SELF_HARNESS.md §4 (P2).
|
|
16
16
|
"""
|
|
17
17
|
|
|
18
|
+
import os
|
|
18
19
|
import re
|
|
19
20
|
|
|
20
21
|
_PATH_ARG_KEYS = ("file_path", "path", "filePath", "notebook_path")
|
|
@@ -24,92 +25,77 @@ def _squash(s: str) -> str:
|
|
|
24
25
|
return re.sub(r"[^a-z0-9]", "", s.lower())
|
|
25
26
|
|
|
26
27
|
|
|
27
|
-
def
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
def normalize_tool_path(proposed: str, known_paths=None, max_edit_distance=None):
|
|
29
|
+
"""Conservatively correct a garbled file path by snapping ONLY the filename to
|
|
30
|
+
a real sibling that already exists in the SAME, on-disk directory. Returns
|
|
31
|
+
``(path, changed, reason)``.
|
|
32
|
+
|
|
33
|
+
Hardened (2026-06-24) after the heuristic version silently RELOCATED writes
|
|
34
|
+
across projects/worktrees (e.g. ``octopus_invaders/js/config.js`` ->
|
|
35
|
+
``octopus-invader/space-shooter/js/config.js``), turning a loud,
|
|
36
|
+
self-correcting "no such file" failure into a silent wrong-write that
|
|
37
|
+
clobbered unrelated files. The rules below make a wrong-directory snap
|
|
38
|
+
structurally impossible:
|
|
39
|
+
|
|
40
|
+
* absolute paths only — the proxy cannot verify a relative path's cwd;
|
|
41
|
+
* never touch a path that already exists (it is correct);
|
|
42
|
+
* the directory must already exist on disk and is used VERBATIM — we never
|
|
43
|
+
alter, squash, case-fold or cross a directory segment, so a garbled
|
|
44
|
+
directory simply fails loud (as it did before the normalizer existed);
|
|
45
|
+
* snap only when EXACTLY ONE real file in that directory matches the
|
|
46
|
+
basename case-insensitively or punctuation/extension-squashed;
|
|
47
|
+
* no edit-distance guessing.
|
|
48
|
+
|
|
49
|
+
``known_paths`` is accepted for signature/back-compat but the on-disk
|
|
50
|
+
directory is the source of truth. Anything that cannot be verified on disk is
|
|
51
|
+
returned unchanged (fail safe to a clean failure).
|
|
52
|
+
"""
|
|
53
|
+
if not proposed:
|
|
54
|
+
return proposed, False, None
|
|
31
55
|
|
|
32
|
-
|
|
33
|
-
parts = [x for x in p.split("/") if x]
|
|
34
|
-
return "/".join(parts[:-1])
|
|
56
|
+
trimmed = proposed.strip()
|
|
35
57
|
|
|
58
|
+
# Absolute paths only: the proxy has no reliable cwd for relative paths.
|
|
59
|
+
if not os.path.isabs(trimmed):
|
|
60
|
+
return proposed, False, None
|
|
36
61
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
cp = _parent_dir(candidate)
|
|
43
|
-
if cp == "":
|
|
44
|
-
return True
|
|
45
|
-
return _squash(_parent_dir(proposed)) == _squash(cp)
|
|
62
|
+
# Already real? Nothing to fix (covers files, directories, trailing slashes).
|
|
63
|
+
if os.path.exists(trimmed):
|
|
64
|
+
if trimmed != proposed:
|
|
65
|
+
return trimmed, True, "trimmed surrounding whitespace"
|
|
66
|
+
return proposed, False, None
|
|
46
67
|
|
|
68
|
+
parent = os.path.dirname(trimmed)
|
|
69
|
+
base = os.path.basename(trimmed)
|
|
70
|
+
# A wrong/garbled DIRECTORY must never be guessed — only fix the filename
|
|
71
|
+
# within a directory that provably exists.
|
|
72
|
+
if not base or not os.path.isdir(parent):
|
|
73
|
+
return proposed, False, None
|
|
47
74
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
for i in range(1, m + 1):
|
|
56
|
-
cur = [i] + [0] * n
|
|
57
|
-
for j in range(1, n + 1):
|
|
58
|
-
cost = 0 if a[i - 1] == b[j - 1] else 1
|
|
59
|
-
cur[j] = min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost)
|
|
60
|
-
prev = cur
|
|
61
|
-
return prev[n]
|
|
75
|
+
try:
|
|
76
|
+
entries = [
|
|
77
|
+
e for e in os.listdir(parent)
|
|
78
|
+
if os.path.isfile(os.path.join(parent, e))
|
|
79
|
+
]
|
|
80
|
+
except OSError:
|
|
81
|
+
return proposed, False, None
|
|
62
82
|
|
|
83
|
+
# Same-directory basename repair only.
|
|
84
|
+
matches = [e for e in entries if e.lower() == base.lower()]
|
|
85
|
+
reason = "case-normalized to the real filename in the same directory"
|
|
86
|
+
if not matches:
|
|
87
|
+
sb = _squash(base)
|
|
88
|
+
matches = [e for e in entries if _squash(e) == sb]
|
|
89
|
+
reason = "extension/punctuation-normalized to the real filename in the same directory"
|
|
63
90
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
correctly earlier). Returns (path, changed, reason). Never invents a target:
|
|
67
|
-
an unmatched path (a legitimate new file) is returned unchanged.
|
|
68
|
-
"""
|
|
69
|
-
known = list(dict.fromkeys(known_paths)) # de-dupe, preserve order
|
|
70
|
-
if not proposed or not known:
|
|
91
|
+
# 0 matches = nothing to snap to; >1 = ambiguous -> leave alone (fail safe).
|
|
92
|
+
if len(matches) != 1:
|
|
71
93
|
return proposed, False, None
|
|
72
94
|
|
|
73
|
-
|
|
74
|
-
if
|
|
75
|
-
if trimmed != proposed:
|
|
76
|
-
return trimmed, True, "trimmed surrounding whitespace"
|
|
95
|
+
target = os.path.join(parent, matches[0])
|
|
96
|
+
if target == proposed:
|
|
77
97
|
return proposed, False, None
|
|
78
|
-
|
|
79
|
-
base = _basename(trimmed)
|
|
80
|
-
|
|
81
|
-
def snap(target, reason):
|
|
82
|
-
return (target, True, reason) if target != proposed else (proposed, False, None)
|
|
83
|
-
|
|
84
|
-
def unique(cands):
|
|
85
|
-
return cands[0] if len(cands) == 1 else None
|
|
86
|
-
|
|
87
|
-
def safe(cands):
|
|
88
|
-
return [k for k in cands if _dir_compatible(trimmed, k)]
|
|
89
|
-
|
|
90
|
-
# 1) exact basename (stray dirs)
|
|
91
|
-
hit = unique(safe([k for k in known if _basename(k) == base]))
|
|
92
|
-
if hit:
|
|
93
|
-
return snap(hit, "stray path components removed")
|
|
94
|
-
# 2) case-insensitive basename
|
|
95
|
-
hit = unique(safe([k for k in known if _basename(k).lower() == base.lower()]))
|
|
96
|
-
if hit:
|
|
97
|
-
return snap(hit, "case-normalized to the real filename")
|
|
98
|
-
# 3) punctuation/extension-squashed basename
|
|
99
|
-
sb = _squash(base)
|
|
100
|
-
hit = unique(safe([k for k in known if _squash(_basename(k)) == sb]))
|
|
101
|
-
if hit:
|
|
102
|
-
return snap(hit, "extension/punctuation-normalized to the real filename")
|
|
103
|
-
# 4) edit-distance fallback (only when clearly closest)
|
|
104
|
-
max_d = max_edit_distance if max_edit_distance is not None else max(2, len(base) // 3)
|
|
105
|
-
ranked = sorted(
|
|
106
|
-
((k, _edit_distance(base.lower(), _basename(k).lower())) for k in safe(known)),
|
|
107
|
-
key=lambda kv: kv[1],
|
|
108
|
-
)
|
|
109
|
-
if ranked and ranked[0][1] <= max_d and (len(ranked) < 2 or ranked[1][1] > ranked[0][1]):
|
|
110
|
-
return snap(ranked[0][0], f"nearest filename by edit distance ({ranked[0][1]})")
|
|
111
|
-
|
|
112
|
-
return proposed, False, None
|
|
98
|
+
return target, True, reason
|
|
113
99
|
|
|
114
100
|
|
|
115
101
|
def extract_known_paths(anthropic_messages) -> list:
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Tests for the hardened (filesystem-verified, same-directory-only) tool-call
|
|
3
|
+
path normalizer.
|
|
4
|
+
|
|
5
|
+
The heuristic predecessor silently RELOCATED writes across projects/worktrees
|
|
6
|
+
(e.g. octopus_invaders/js/config.js -> octopus-invader/space-shooter/js/config.js),
|
|
7
|
+
turning a loud self-correcting failure into a silent wrong-write. These tests
|
|
8
|
+
pin the conservative contract: only a filename may be repaired, only inside a
|
|
9
|
+
directory that already exists on disk, only when exactly one real sibling
|
|
10
|
+
matches — and a wrong/garbled directory or any ambiguity is left untouched.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import importlib.util
|
|
14
|
+
import os
|
|
15
|
+
import tempfile
|
|
16
|
+
import unittest
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _load():
|
|
21
|
+
p = Path(__file__).resolve().parents[1] / "scripts" / "toolcall_path_normalizer.py"
|
|
22
|
+
spec = importlib.util.spec_from_file_location("toolcall_path_normalizer", p)
|
|
23
|
+
m = importlib.util.module_from_spec(spec)
|
|
24
|
+
spec.loader.exec_module(m)
|
|
25
|
+
return m
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
norm = _load()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _touch(path):
|
|
32
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
33
|
+
with open(path, "w") as f:
|
|
34
|
+
f.write("x")
|
|
35
|
+
return path
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class TestHardenedNormalizer(unittest.TestCase):
|
|
39
|
+
def setUp(self):
|
|
40
|
+
self._tmp = tempfile.TemporaryDirectory()
|
|
41
|
+
self.root = self._tmp.name
|
|
42
|
+
|
|
43
|
+
def tearDown(self):
|
|
44
|
+
self._tmp.cleanup()
|
|
45
|
+
|
|
46
|
+
# --- the repairs it SHOULD make (filename only, same real dir) ---
|
|
47
|
+
|
|
48
|
+
def test_fixes_case_in_same_directory(self):
|
|
49
|
+
real = _touch(os.path.join(self.root, "proj", "config.js"))
|
|
50
|
+
proposed = os.path.join(self.root, "proj", "Config.js") # wrong case, doesn't exist
|
|
51
|
+
path, changed, _ = norm.normalize_tool_path(proposed)
|
|
52
|
+
self.assertTrue(changed)
|
|
53
|
+
self.assertEqual(path, real)
|
|
54
|
+
|
|
55
|
+
def test_fixes_dropped_extension_in_same_directory(self):
|
|
56
|
+
real = _touch(os.path.join(self.root, "proj", "config.js"))
|
|
57
|
+
proposed = os.path.join(self.root, "proj", "configjs") # squash-match
|
|
58
|
+
path, changed, _ = norm.normalize_tool_path(proposed)
|
|
59
|
+
self.assertTrue(changed)
|
|
60
|
+
self.assertEqual(path, real)
|
|
61
|
+
|
|
62
|
+
def test_trims_whitespace_on_real_path(self):
|
|
63
|
+
real = _touch(os.path.join(self.root, "proj", "a.js"))
|
|
64
|
+
path, changed, reason = norm.normalize_tool_path(f" {real} ")
|
|
65
|
+
self.assertTrue(changed)
|
|
66
|
+
self.assertEqual(path, real)
|
|
67
|
+
self.assertIn("trimmed", reason)
|
|
68
|
+
|
|
69
|
+
# --- the corruption it MUST NOT cause anymore ---
|
|
70
|
+
|
|
71
|
+
def test_does_not_relocate_to_a_different_directory(self):
|
|
72
|
+
# config.js exists only in projA; a (non-existent) write to projB/config.js
|
|
73
|
+
# must NOT be snapped into projA — the live octopus-style corruption.
|
|
74
|
+
_touch(os.path.join(self.root, "projA", "config.js"))
|
|
75
|
+
os.makedirs(os.path.join(self.root, "projB")) # real but has no config.js
|
|
76
|
+
proposed = os.path.join(self.root, "projB", "config.js")
|
|
77
|
+
path, changed, _ = norm.normalize_tool_path(proposed)
|
|
78
|
+
self.assertFalse(changed)
|
|
79
|
+
self.assertEqual(path, proposed)
|
|
80
|
+
|
|
81
|
+
def test_garbled_directory_is_never_guessed(self):
|
|
82
|
+
# The real dir is 'octopus-invader/space-shooter/js'; the model wrote to a
|
|
83
|
+
# DIFFERENT, non-existent dir 'octopus_invaders/js'. Must be a no-op.
|
|
84
|
+
_touch(os.path.join(self.root, "octopus-invader", "space-shooter", "js", "config.js"))
|
|
85
|
+
proposed = os.path.join(self.root, "octopus_invaders", "js", "config.js")
|
|
86
|
+
path, changed, _ = norm.normalize_tool_path(proposed)
|
|
87
|
+
self.assertFalse(changed)
|
|
88
|
+
self.assertEqual(path, proposed)
|
|
89
|
+
|
|
90
|
+
def test_punctuation_only_directory_difference_is_not_crossed(self):
|
|
91
|
+
# 's-space-shooter' vs 's space-shooter' are DIFFERENT real dirs; the old
|
|
92
|
+
# squash() guard treated them as the same. Must not relocate.
|
|
93
|
+
_touch(os.path.join(self.root, "s space-shooter", "css", "styles.css"))
|
|
94
|
+
os.makedirs(os.path.join(self.root, "s-space-shooter", "css"))
|
|
95
|
+
proposed = os.path.join(self.root, "s-space-shooter", "css", "styles.css")
|
|
96
|
+
path, changed, _ = norm.normalize_tool_path(proposed)
|
|
97
|
+
self.assertFalse(changed)
|
|
98
|
+
self.assertEqual(path, proposed)
|
|
99
|
+
|
|
100
|
+
def test_ambiguous_match_is_left_alone(self):
|
|
101
|
+
# Two files squash-match the garbled basename -> ambiguous -> no-op.
|
|
102
|
+
_touch(os.path.join(self.root, "proj", "config.js"))
|
|
103
|
+
_touch(os.path.join(self.root, "proj", "config.ts"))
|
|
104
|
+
proposed = os.path.join(self.root, "proj", "configjs?") # squashes to 'configjs'/'configts'?
|
|
105
|
+
# Make it genuinely ambiguous: 'config' squashes both 'config.js' and 'config.ts'.
|
|
106
|
+
proposed = os.path.join(self.root, "proj", "config")
|
|
107
|
+
path, changed, _ = norm.normalize_tool_path(proposed)
|
|
108
|
+
self.assertFalse(changed)
|
|
109
|
+
self.assertEqual(path, proposed)
|
|
110
|
+
|
|
111
|
+
def test_legitimate_new_file_passes_through(self):
|
|
112
|
+
# Creating a brand-new file in a real dir with no sibling match -> unchanged.
|
|
113
|
+
os.makedirs(os.path.join(self.root, "proj"))
|
|
114
|
+
proposed = os.path.join(self.root, "proj", "brand_new_helper.js")
|
|
115
|
+
path, changed, _ = norm.normalize_tool_path(proposed)
|
|
116
|
+
self.assertFalse(changed)
|
|
117
|
+
self.assertEqual(path, proposed)
|
|
118
|
+
|
|
119
|
+
def test_already_correct_path_unchanged(self):
|
|
120
|
+
real = _touch(os.path.join(self.root, "proj", "a.js"))
|
|
121
|
+
path, changed, _ = norm.normalize_tool_path(real)
|
|
122
|
+
self.assertFalse(changed)
|
|
123
|
+
self.assertEqual(path, real)
|
|
124
|
+
|
|
125
|
+
def test_relative_path_is_not_touched(self):
|
|
126
|
+
# No reliable cwd at the proxy -> relative paths pass through untouched.
|
|
127
|
+
path, changed, _ = norm.normalize_tool_path("src/Config.js")
|
|
128
|
+
self.assertFalse(changed)
|
|
129
|
+
self.assertEqual(path, "src/Config.js")
|
|
130
|
+
|
|
131
|
+
# --- integration through the public entry point ---
|
|
132
|
+
|
|
133
|
+
def test_normalize_tool_uses_applies_and_reports(self):
|
|
134
|
+
real = _touch(os.path.join(self.root, "proj", "config.js"))
|
|
135
|
+
wrong = os.path.join(self.root, "proj", "CONFIG.JS")
|
|
136
|
+
tool_uses = [{"type": "tool_use", "id": "t1", "input": {"file_path": wrong, "content": "y"}}]
|
|
137
|
+
corrections = norm.normalize_tool_uses(tool_uses, known_paths=[])
|
|
138
|
+
self.assertEqual(tool_uses[0]["input"]["file_path"], real)
|
|
139
|
+
self.assertEqual(tool_uses[0]["input"]["content"], "y") # non-path arg untouched
|
|
140
|
+
self.assertEqual(len(corrections), 1)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
if __name__ == "__main__":
|
|
144
|
+
unittest.main()
|