Skip to content

Commit 1d654c4

Browse files
committed
fix(json-parse): scope code-context guard to candidate path suffix
Narrows the looksLikeWindowsPathPrefix code-context rejection from scanning the entire 160-character tail to checking only the candidate drive-path match and its immediate predecessor: 1. Reject when the non-alphanumeric separator before the drive letter is a bracket or brace — real paths never start with (X: or {X:. 2. Reject when a block-opening keyword (as, if, in, for, def, etc.) immediately precedes the candidate — 'as f:', 'if r:', 'in d:' are code colons, not drive letters. This preserves Windows path repair when code punctuation appears earlier in the same string value (e.g. python -c 'x'; C:\new). The keyword boundary check uses (?:^|[^A-Za-z0-9]|\\[nrt]) instead of \b because stringValuePrefix tracks prior escapes in their literal textual form — a decoded \n is stored as the two characters \ and n, so keywords starting a new line (like 'if' after a newline) are preceded by 'n' (alphanumeric), not whitespace. Adds regression coverage for code-before-path ordering. Refs #93139 Signed-off-by: Jakub Rusz <[email protected]>
1 parent e58153b commit 1d654c4

2 files changed

Lines changed: 54 additions & 8 deletions

File tree

packages/ai/src/utils/json-parse.ts

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -110,16 +110,47 @@ export function parseJsonWithRepair(json: string): unknown {
110110

111111
function looksLikeWindowsPathPrefix(prefix: string): boolean {
112112
const tail = prefix.slice(-160);
113-
// Reject obvious code-context markers (parentheses, braces, equals,
114-
// semicolons, single-quotes, commas, shell redirects/pipes). A Windows
115-
// path tail will not contain these, but a Python/JS/shell block opener
116-
// like "if x:" or "for x:" or "def foo(x):" will — and without this
117-
// guard we treat the next \n as a path separator and emit a literal
118-
// backslash-n instead of a real newline. See issue #93139.
119-
if (/[()=;{}',<>|]/.test(tail)) {
113+
const m = tail.match(/(^|[^A-Za-z0-9])([A-Za-z]):(?:[\\/][^"\\/:*?<>|\r\n]*)*$/);
114+
if (!m) {
120115
return false;
121116
}
122-
return /(?:^|[^A-Za-z0-9])[A-Za-z]:(?:[\\/][^"\\/:*?<>|\r\n]*)*$/.test(tail);
117+
118+
// The Windows-path heuristic matches any tail ending in
119+
// `<non-alphanum><letter>:` — which also matches Python/shell block
120+
// openers like `if x:`, `for x:`, `try:`, `def foo(x):`. Without a
121+
// guard we'd treat the next \n as a path separator and emit a literal
122+
// backslash-n instead of a real newline. The guard is scoped to the
123+
// matched candidate and its immediate predecessor (not the whole tail)
124+
// so that code punctuation appearing earlier in the same string — e.g.
125+
// `python -c 'x'; C:\new` — doesn't cause a real Windows path later in
126+
// the tail to be rejected. See issue #93139.
127+
128+
// (1) Reject if the separator character before the drive letter is a
129+
// bracket or brace — real paths never start with (X: or {X:
130+
const sep = m[1] ?? "";
131+
if (sep && /[(){}]/.test(sep)) {
132+
return false;
133+
}
134+
135+
// (2) Reject if a block-opening keyword immediately precedes the
136+
// candidate — "as f:", "if r:", "in d:" are code, not paths. The prefix
137+
// tracks prior escapes in their literal textual form (a decoded \n is
138+
// stored as the two characters \ and n, not an actual newline), so a
139+
// keyword that starts a new line is preceded by a trailing letter
140+
// (n/r/t) from that escape, not real whitespace. Treat \n, \r, and \t
141+
// as boundaries too, in addition to \b, so "if"/"in"/etc. are still
142+
// recognized right after a converted newline/tab/carriage-return.
143+
const matchIndex = m.index ?? 0;
144+
const beforeCandidate = tail.slice(0, matchIndex + sep.length);
145+
if (
146+
/(?:^|[^A-Za-z0-9]|\\[nrt])(?:as|if|in|for|def|not|try|else|elif|while|class|with|return|except|finally|match|case)\s*$/.test(
147+
beforeCandidate,
148+
)
149+
) {
150+
return false;
151+
}
152+
153+
return true;
123154
}
124155

125156
function asStreamingJsonRecord(value: unknown): Record<string, unknown> {

src/llm/utils/json-parse.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,12 @@ describe("json-parse repairJson Windows-path false positives (issue #93139)", ()
8181
'{"command": "for f in *.py:\\n echo $f"}',
8282
"for f in *.py:\n echo $f",
8383
],
84+
["Python match statement", '{"command": "match x:\\n pass"}', "match x:\n pass"],
85+
[
86+
"Python case clause",
87+
'{"command": "match x:\\n case 1:\\n pass"}',
88+
"match x:\n case 1:\n pass",
89+
],
8490
])("preserves real newlines after Python/shell block openers: %s", (_name, input, expected) => {
8591
expect(parseJsonWithRepair(input)).toEqual({ command: expected });
8692
expect(parseStreamingJson(input)).toEqual({ command: expected });
@@ -94,4 +100,13 @@ describe("json-parse repairJson Windows-path false positives (issue #93139)", ()
94100
text: "see C:\\Users for path; if x:\n do_stuff()",
95101
});
96102
});
103+
104+
it("preserves a Windows path after code-like command text", () => {
105+
// Regression: code punctuation appears before a legitimate Windows
106+
// path in the same string. The guard must not reject the whole tail.
107+
const input = '{"cmd": "python -c \'x\'; C:\\\\new\\\\file.txt"}';
108+
expect(parseJsonWithRepair(input)).toEqual({
109+
cmd: "python -c 'x'; C:\\new\\file.txt",
110+
});
111+
});
97112
});

0 commit comments

Comments
 (0)