pi-supernova 0.5.0 → 0.7.0

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.
Files changed (50) hide show
  1. package/README.md +97 -11
  2. package/docs/CHANGELOG.md +150 -0
  3. package/docs/TOKEN_COSTS.md +71 -29
  4. package/index.js +126 -82
  5. package/package.json +2 -2
  6. package/src/adapters/bash.js +73 -0
  7. package/src/adapters/edit.js +249 -0
  8. package/src/adapters/errors.js +31 -0
  9. package/src/adapters/index.js +31 -0
  10. package/src/adapters/list.js +102 -0
  11. package/src/adapters/read.js +805 -0
  12. package/src/adapters/refs.js +41 -0
  13. package/src/adapters/write.js +96 -0
  14. package/src/bridge/catalog.js +30 -220
  15. package/src/bridge/host-bridge.js +142 -1032
  16. package/src/bridge/invoke.js +35 -0
  17. package/src/bridge/native-tools.js +1 -188
  18. package/src/context/evidence.js +142 -70
  19. package/src/context/fuzzy.js +61 -22
  20. package/src/context/ledger.js +43 -24
  21. package/src/context/outline.js +26 -12
  22. package/src/context/repo-index.js +242 -71
  23. package/src/context/search.js +189 -56
  24. package/src/context/snap.js +306 -150
  25. package/src/context/spans.js +2 -1
  26. package/src/context/surface.js +29 -14
  27. package/src/contract/bash.js +31 -0
  28. package/src/contract/edit.js +95 -0
  29. package/src/contract/read.js +220 -0
  30. package/src/fs/check.js +19 -7
  31. package/src/fs/diff.js +18 -7
  32. package/src/fs/json-read.js +66 -35
  33. package/src/fs/patch.js +97 -51
  34. package/src/fs/source-window.js +82 -0
  35. package/src/fs/text-ops.js +512 -0
  36. package/src/fs/vfs.js +289 -162
  37. package/src/fs/workspace.js +122 -105
  38. package/src/output/bottleneck.js +211 -107
  39. package/src/output/format.js +112 -63
  40. package/src/runtime/guest-deny-imports.js +34 -0
  41. package/src/runtime/guest-worker.js +306 -213
  42. package/src/runtime/parallel.js +99 -63
  43. package/src/runtime/program-batch.js +189 -69
  44. package/src/runtime/program-file.js +6 -3
  45. package/src/runtime/reference.js +13 -12
  46. package/src/runtime/runtime.js +327 -176
  47. package/src/shared/decode.js +61 -27
  48. package/src/ui/omp-frame.js +70 -46
  49. package/src/ui/render-measure.js +51 -29
  50. package/src/ui/render.js +242 -146
@@ -8,14 +8,20 @@ function scanPython(lines) {
8
8
  const line = lines[i];
9
9
  const match = /^([ \t]*)(def|class|async def)\s+([a-zA-Z0-9_]+)(\(.*?\))?:?/.exec(line);
10
10
 
11
- if (!match) continue;
12
- items.push({
13
- kind: match[2].includes("def") ? "function" : "class",
14
- name: match[3],
15
- signature: match[0].trim(),
16
- line: i + 1,
17
- depth: Math.floor(match[1].length / 4),
18
- });
11
+ if (match) {
12
+ items.push({
13
+ kind: match[2].includes("def") ? "function" : "class",
14
+ name: match[3],
15
+ signature: match[0].trim(),
16
+ line: i + 1,
17
+ depth: Math.floor(match[1].length / 4),
18
+ });
19
+ continue;
20
+ }
21
+
22
+ const constant = /^([ \t]*)([A-Z][A-Z0-9_]*)\s*(?::[^=\n]+)?=/.exec(line);
23
+
24
+ if (constant && constant[1].length === 0) items.push({ kind: "constant", name: constant[2], signature: constant[0].replace(/=\s*$/, "=").trim(), line: i + 1, depth: 0 });
19
25
  }
20
26
 
21
27
  return items;
@@ -110,15 +116,24 @@ function declarationItem(line, rawLine, lineNumber) {
110
116
  return null;
111
117
  }
112
118
 
119
+ function skipJsNoise(line) {
120
+ return !line || line.startsWith("//") || line.startsWith("/*") || line.startsWith("*");
121
+ }
122
+
123
+ function jsItemAt(lines, i) {
124
+ const line = lines[i].trim();
125
+
126
+ if (skipJsNoise(line)) return null;
127
+ const indent = lines[i].length - lines[i].trimStart().length;
128
+
129
+ return declarationItem(line, lines[i], i + 1) || (indent > 0 && indent <= 8 ? methodItem(line, i + 1, Math.max(1, Math.floor(indent / 2))) : null);
130
+ }
131
+
113
132
  function scanJavaScript(lines) {
114
133
  const items = [];
115
134
 
116
135
  for (let i = 0; i < lines.length; i++) {
117
- const line = lines[i].trim();
118
-
119
- if (!line || line.startsWith("//") || line.startsWith("/*") || line.startsWith("*")) continue;
120
- const indent = lines[i].length - lines[i].trimStart().length;
121
- const item = declarationItem(line, lines[i], i + 1) || (indent > 0 && indent <= 8 ? methodItem(line, i + 1, Math.max(1, Math.floor(indent / 2))) : null);
136
+ const item = jsItemAt(lines, i);
122
137
 
123
138
  if (item) items.push(item);
124
139
  }
@@ -141,7 +156,7 @@ const SCANNERS = {
141
156
  export function extractStructuralSurface(code, extension = "js") {
142
157
  if (!isString(code) || !code.trim()) return { items: [], lineCount: 0 };
143
158
  const lines = code.split("\n");
144
- const ext = extension.replace(/^\./, "").toLowerCase();
159
+ const ext = String(extension ?? "").replace(/^\./, "").toLowerCase();
145
160
  const scanner = SCANNERS[ext] || SCANNERS.js;
146
161
  const items = scanner(lines);
147
162
 
@@ -0,0 +1,31 @@
1
+ import { isString, isObject } from "../shared/decode.js";
2
+
3
+ const ARGV_ERROR = "bash argv requires a command string and an array of string args";
4
+
5
+ function quoteShellArg(value) {
6
+ return "'" + String(value).replaceAll("'", "'\\''") + "'";
7
+ }
8
+
9
+ /** Normalize guest bash(command, opts) / bash({command, args}) into host args. */
10
+ function normalizeArgv(args) {
11
+ if (args.args === undefined) return;
12
+ if (!isString(args.command) || !Array.isArray(args.args)) throw new Error(ARGV_ERROR);
13
+
14
+ for (let i = 0; i < args.args.length; i++) if (!isString(args.args[i])) throw new Error(ARGV_ERROR);
15
+ args.args = args.args.map(String);
16
+
17
+ if (process.platform === "win32") {
18
+ delete args._directArgv;
19
+ args.command = [args.command, ...args.args].map(quoteShellArg).join(" ");
20
+ delete args.args;
21
+ } else args._directArgv = true;
22
+ }
23
+
24
+ export function normalizeBash(command, opts) {
25
+ const args = isObject(command) ? { ...opts, ...command } : { command, ...opts };
26
+ normalizeArgv(args);
27
+
28
+ if (args.timeout !== undefined && args.timeoutMs === undefined) args.timeoutMs = args.timeout * 1000;
29
+
30
+ return args;
31
+ }
@@ -0,0 +1,95 @@
1
+ import { isString, isObject, isFunction, isNumber } from "../shared/decode.js";
2
+
3
+ export const EDIT_USAGE = 'invalid edit signature; use edit(path,oldText,newText), edit({path,edits:[{oldText,newText}]}), or edit({path,patch:"@@ -1 +1 @@\n-old\n+new\n"})';
4
+
5
+ function spanStart(value) {
6
+ return isNumber(value.start) ? value.start : Array.isArray(value.lines) ? value.lines[0] : value.line;
7
+ }
8
+
9
+ function spanEnd(value, start) {
10
+ return isNumber(value.end) ? value.end : Array.isArray(value.lines) && value.lines.length > 1 ? value.lines[1] : start;
11
+ }
12
+
13
+ export function viewSpan(value) {
14
+ const start = spanStart(value);
15
+ const end = spanEnd(value, start);
16
+
17
+ if (!isNumber(start) || !isNumber(end) || start < 1 || end < start) return null;
18
+
19
+ return { start: Math.floor(start), end: Math.floor(end) };
20
+ }
21
+
22
+ export function isEditView(value) {
23
+ return isObject(value) && !Array.isArray(value) && isString(value.path) && value.path.trim() && isString(value.text) && (value.status === undefined || value.status === "found") && viewSpan(value);
24
+ }
25
+
26
+ function classifyViewEdit(p, oldText, newText) {
27
+ if (isNumber(p.nextOffset)) throw new Error("edit view is incomplete");
28
+ const span = viewSpan(p);
29
+ const args = { path: p.path, viewStart: span.start, viewEnd: span.end, viewText: p.text, newText: newText === undefined ? oldText : newText };
30
+
31
+ if (newText !== undefined) args.oldText = oldText;
32
+
33
+ return { kind: "view", command: "edit", args };
34
+ }
35
+
36
+ function namedEditObject(p, oldText, newText) {
37
+ if (isObject(p) && (Array.isArray(p) || oldText !== undefined || newText !== undefined)) throw new Error(EDIT_USAGE);
38
+
39
+ return p;
40
+ }
41
+
42
+ function namedEditPositional(p, oldText, newText) {
43
+ if (isObject(oldText) && !Array.isArray(oldText)) throw new Error(EDIT_USAGE);
44
+
45
+ return Array.isArray(oldText) ? { path: p, edits: oldText } : { path: p, oldText, newText };
46
+ }
47
+
48
+ function normalizeEditArgs(p, oldText, newText) {
49
+ return isObject(p) ? namedEditObject(p, oldText, newText) : namedEditPositional(p, oldText, newText);
50
+ }
51
+
52
+ function namedEditArgs(p, oldText, newText) {
53
+ const args = normalizeEditArgs(p, oldText, newText);
54
+
55
+ if (!isString(args.path) || !args.path.trim()) throw new Error(EDIT_USAGE);
56
+
57
+ return args;
58
+ }
59
+
60
+ function assertNamedEditMode(args, oldText, newText) {
61
+ const modes = Number(args.patch !== undefined) + Number(args.edits !== undefined) + Number(args.oldText !== undefined || args.newText !== undefined);
62
+
63
+ if (modes !== 1 || (Array.isArray(oldText) && newText !== undefined)) throw new Error(EDIT_USAGE);
64
+ }
65
+
66
+ function classifyPatch(args) {
67
+ if (!isString(args.patch) || !args.patch.trim()) throw new Error(EDIT_USAGE);
68
+
69
+ return { kind: "patch", command: "apply_patch", args };
70
+ }
71
+
72
+ function classifyReplacements(args) {
73
+ const edits = args.edits === undefined ? [args] : args.edits;
74
+
75
+ if (!Array.isArray(edits) || !edits.length) throw new Error(EDIT_USAGE);
76
+
77
+ for (const e of edits) if (!isString(e?.oldText) || !e.oldText.length || !isString(e?.newText)) throw new Error(EDIT_USAGE + "; replacements require non-empty oldText and string newText");
78
+
79
+ return { kind: "edits", command: "edit", args };
80
+ }
81
+
82
+ function classifyNamedEdit(p, oldText, newText) {
83
+ const args = namedEditArgs(p, oldText, newText);
84
+ assertNamedEditMode(args, oldText, newText);
85
+
86
+ return args.patch !== undefined ? classifyPatch(args) : classifyReplacements(args);
87
+ }
88
+
89
+ /** Guest signature → { command, args } for one host call. */
90
+ export function classifyEdit(p, oldText, newText) {
91
+ if (isFunction(p)) return { kind: "checkpoint", fn: p };
92
+ if (isEditView(p) && isString(oldText) && (newText === undefined || isString(newText))) return classifyViewEdit(p, oldText, newText);
93
+
94
+ return classifyNamedEdit(p, oldText, newText);
95
+ }
@@ -0,0 +1,220 @@
1
+ import { isString, isObject, isNumber, looksLikePath } from "../shared/decode.js";
2
+ import { sessionJsonArgs, validateJsonRead } from "../fs/json-read.js";
3
+
4
+ export const SESSION_URI = /^(?:agent|artifact):\/\//i;
5
+
6
+ const BOOL_KEYS = ["resolve", "complete", "outline", "evidence"];
7
+
8
+ export function isSessionUri(value) {
9
+ return isString(value) && SESSION_URI.test(value);
10
+ }
11
+
12
+ /** Guest call shape → one options object. */
13
+ export function gatherReadArgs(p, a, b) {
14
+ if (isObject(p) && !Array.isArray(p)) {
15
+ const args = { ...p, path: p.path ?? p.target ?? p.query };
16
+
17
+ if (p.path === undefined && p.target !== undefined) delete args.target;
18
+
19
+ return args;
20
+ }
21
+
22
+ return isObject(a) && !Array.isArray(a) ? { path: p, ...a } : { path: p, offset: a, limit: b };
23
+ }
24
+
25
+ export function assertReadPaths(targetParam) {
26
+ if (!Array.isArray(targetParam)) return;
27
+ if (targetParam.length > 64) throw new Error("read accepts at most 64 paths per batch");
28
+
29
+ for (const item of targetParam) if (!isString(item) || !item.trim()) throw new Error("read paths must be non-empty strings");
30
+ }
31
+
32
+ function assertReadFlags(args) {
33
+ for (const key of BOOL_KEYS) {
34
+ if (args[key] !== undefined && args[key] !== true && args[key] !== false) throw new Error("read " + key + " must be a boolean");
35
+ }
36
+
37
+ if (args.about !== undefined && !isString(args.about)) throw new Error("read about must be a string");
38
+ if (args.query !== undefined && !isString(args.query)) throw new Error("read query must be a string");
39
+ }
40
+
41
+ function assertExclusiveRead(args) {
42
+ const focusModes = [args.about !== undefined, args.query !== undefined, args.outline === true].filter(Boolean).length;
43
+
44
+ if (focusModes > 1 || (args.outline === true && args.evidence === true)) throw new Error("read accepts only one of about, query, outline, or evidence");
45
+ if (args.resolve === true && args.complete === true) throw new Error("read accepts either resolve or complete, not both");
46
+ if ((focusModes === 1 || args.evidence === true) && args.complete === true) throw new Error("complete:true requires a raw file read, not a source view");
47
+ }
48
+
49
+ function autoResolve(args) {
50
+ if (!isString(args.path) || args.resolve !== undefined || args.complete === true || args.json !== undefined) return args;
51
+ if (args.about !== undefined || args.query !== undefined || looksLikePath(args.path) || isSessionUri(args.path)) return args;
52
+
53
+ return { ...args, resolve: true };
54
+ }
55
+
56
+ /** Exclusive-mode + JSON + auto-resolve. Same rules for guest and host. */
57
+ export function normalizeRead(params) {
58
+ if (!isObject(params)) throw new Error("read requires an options object");
59
+ if (params.path !== undefined && params.target !== undefined && params.path !== params.target) {
60
+ throw new Error("read accepts either path or target, not both");
61
+ }
62
+
63
+ const args = sessionJsonArgs({ ...params, path: params.path ?? params.target });
64
+ validateJsonRead(args);
65
+ assertReadFlags(args);
66
+ assertExclusiveRead(args);
67
+ assertReadPaths(args.path);
68
+
69
+ return autoResolve(args);
70
+ }
71
+
72
+ export function needsProbe(params) {
73
+ if (isSessionUri(params.path)) return false;
74
+ if (params.evidence === true) return false;
75
+ if (isString(params.query)) return false;
76
+ if (params.outline === true) return false;
77
+
78
+ return true;
79
+ }
80
+
81
+ /**
82
+ * Kind of read after exclusive modes are already validated.
83
+ * `existing` is the probe result, or null/undefined when needsProbe is false or the path is missing.
84
+ */
85
+ function classifyExisting(params, existing) {
86
+ if (existing.directory) {
87
+ if (params.json !== undefined) throw new Error("JSON read requires a file, not a directory");
88
+
89
+ return isString(params.about)
90
+ ? { kind: "snap", query: params.about, scoped: true, existing }
91
+ : { kind: "dir", existing };
92
+ }
93
+
94
+ if (params.resolve === true && isString(params.about)) {
95
+ throw new Error("resolve:true cannot combine with about on a file; use about for a focused outline or resolve for source text");
96
+ }
97
+
98
+ if (isString(params.about) && existing.size > 512 * 1024) return { kind: "focus", existing, about: params.about };
99
+
100
+ return { kind: params.resolve ? "open" : "file", existing };
101
+ }
102
+
103
+ function classifySession(params) {
104
+ if (params.about !== undefined || params.query !== undefined || params.outline === true || params.evidence === true) {
105
+ throw new Error("session resources do not support about/query/outline/evidence views");
106
+ }
107
+
108
+ return { kind: "session" };
109
+ }
110
+
111
+ function classifyEvidence(params, target) {
112
+ const query = params.about ?? params.query ?? target;
113
+ const scope = target !== query || looksLikePath(target) ? target : undefined;
114
+
115
+ return { kind: "evidence", query, scope };
116
+ }
117
+
118
+ function classifyBarePath(params, target) {
119
+ if (params.json === undefined && !looksLikePath(target)) {
120
+ return { kind: "snap", query: isString(params.about) ? params.about : target, scoped: isString(params.about) };
121
+ }
122
+
123
+ if (params.resolve === true && params.complete !== true) return { kind: "missing" };
124
+
125
+ return { kind: "file", existing: null };
126
+ }
127
+
128
+ export function classifyRead(params, existing) {
129
+ const target = params.path;
130
+
131
+ if (isSessionUri(target)) return classifySession(params);
132
+ if (params.evidence === true) return classifyEvidence(params, target);
133
+ if (isString(params.query)) return { kind: "snap", query: params.query, scoped: Boolean(target && target !== params.query) };
134
+ if (params.outline === true) return { kind: "outline" };
135
+ if (existing) return classifyExisting(params, existing);
136
+
137
+ return classifyBarePath(params, target);
138
+ }
139
+
140
+ function jsonSelectorNote(args) {
141
+ return args.json === undefined ? "" : " (" + (Array.isArray(args.json) ? args.json.join(", ") : String(args.json)) + ")";
142
+ }
143
+
144
+ export const ROUTING_STATUS = "too_large";
145
+
146
+ const ROUTING_PREFIX = '{"status":"too_large",';
147
+
148
+ export const ROUTING_KEYS_MAX = 32;
149
+
150
+ export function isRoutingPayload(value) {
151
+ return isString(value) && value.startsWith(ROUTING_PREFIX);
152
+ }
153
+
154
+ function isRoutingObject(parsed) {
155
+ return isObject(parsed) && parsed.status === ROUTING_STATUS && isString(parsed.path)
156
+ && isNumber(parsed.chars) && (Array.isArray(parsed.keys) || isNumber(parsed.length));
157
+ }
158
+
159
+ /** Shape of an over-bound JSON document for in-band routing. Throws when text is not JSON. */
160
+ export function buildJsonRouting(rel, text) {
161
+ const document = JSON.parse(text);
162
+ const base = { status: ROUTING_STATUS, path: rel, chars: text.length };
163
+
164
+ if (Array.isArray(document)) return { ...base, length: document.length };
165
+
166
+ if (isObject(document)) {
167
+ const keys = Object.keys(document);
168
+
169
+ return keys.length > ROUTING_KEYS_MAX
170
+ ? { ...base, keys: keys.slice(0, ROUTING_KEYS_MAX), keysTruncated: true }
171
+ : { ...base, keys };
172
+ }
173
+
174
+ return base;
175
+ }
176
+
177
+ export function routingText(routing) {
178
+ const text = JSON.stringify(routing);
179
+
180
+ if (!isRoutingPayload(text)) throw new Error("routing payload must start with the shared marker");
181
+
182
+ return text;
183
+ }
184
+
185
+ /** Shape of one over-budget selection for in-band routing; value is already parsed. */
186
+ export function buildSelectionRouting(rel, selector, value, chars) {
187
+ const base = { status: ROUTING_STATUS, path: rel, selector, chars };
188
+
189
+ if (Array.isArray(value)) return { ...base, length: value.length };
190
+
191
+ if (isObject(value)) {
192
+ const keys = Object.keys(value);
193
+
194
+ return keys.length > ROUTING_KEYS_MAX
195
+ ? { ...base, keys: keys.slice(0, ROUTING_KEYS_MAX), keysTruncated: true }
196
+ : { ...base, keys };
197
+ }
198
+
199
+ return base;
200
+ }
201
+
202
+ function decodeByArgs(args, value) {
203
+ return (args.resolve || args.json !== undefined || args.outline || args.evidence) && isString(value);
204
+ }
205
+
206
+ export function decodeReadValue(args, value) {
207
+ const sniffed = isRoutingPayload(value);
208
+
209
+ if (!sniffed && !decodeByArgs(args, value)) return value;
210
+
211
+ try {
212
+ const parsed = JSON.parse(value);
213
+
214
+ return sniffed && !isRoutingObject(parsed) ? value : parsed;
215
+ } catch (error) {
216
+ if (sniffed && !decodeByArgs(args, value)) return value;
217
+
218
+ throw new Error("JSON read failed for " + String(args.path ?? args.target ?? "resource") + jsonSelectorNote(args) + ": " + (error instanceof Error ? error.message : String(error)));
219
+ }
220
+ }
package/src/fs/check.js CHANGED
@@ -60,7 +60,7 @@ function skipComment(text, i) {
60
60
 
61
61
  const end = text.indexOf("*/", i + 2);
62
62
 
63
- return end < 0 ? text.length : end + 2;
63
+ return end < 0 ? -1 : end + 2;
64
64
  }
65
65
 
66
66
  function skipRegex(text, i) {
@@ -118,6 +118,21 @@ function consumeQuoted(text, i, stack) {
118
118
  return end < 0 ? { error: "unterminated string", at: i } : { end, prev: "value" };
119
119
  }
120
120
 
121
+ function consumeSlash(text, i, prev) {
122
+ if (text[i + 1] === "/" || text[i + 1] === "*") {
123
+ const end = skipComment(text, i);
124
+
125
+ if (end < 0) return { error: "unterminated comment", at: i };
126
+
127
+ return { end, prev };
128
+ }
129
+
130
+ if (prev !== "" && !REGEX_PRECEDERS.has(prev)) return null;
131
+ const end = skipRegex(text, i);
132
+
133
+ return end > 0 ? { end, prev: "value" } : null;
134
+ }
135
+
121
136
  /** Try to consume a comment, string, template, or regex at i. Returns { end, prev } | { error, at } | null. */
122
137
  function consumeLiteral(text, i, stack, prev) {
123
138
  const c = text[i];
@@ -126,12 +141,7 @@ function consumeLiteral(text, i, stack, prev) {
126
141
 
127
142
  if (c !== "/") return null;
128
143
 
129
- if (text[i + 1] === "/" || text[i + 1] === "*") return { end: skipComment(text, i), prev };
130
-
131
- if (prev !== "" && !REGEX_PRECEDERS.has(prev)) return null;
132
- const end = skipRegex(text, i);
133
-
134
- return end > 0 ? { end, prev: "value" } : null;
144
+ return consumeSlash(text, i, prev);
135
145
  }
136
146
 
137
147
  /** Push/pop a bracket; returns an error, a stop, or null to continue. */
@@ -190,6 +200,8 @@ const CODE_EXT = new Set([".js", ".mjs", ".cjs", ".jsx", ".ts", ".tsx", ".mts",
190
200
 
191
201
  /** { ok: true } | { ok: false, message }; message names the problem and line. */
192
202
  export function quickCheck(text, ext) {
203
+ ext = String(ext ?? "").toLowerCase();
204
+
193
205
  if (ext === ".json") {
194
206
  try {
195
207
  JSON.parse(text);
package/src/fs/diff.js CHANGED
@@ -1,6 +1,9 @@
1
1
 
2
2
  import { isString } from "../shared/decode.js";
3
3
 
4
+ /** Receipts render at most this many matches; totals still cover every match. */
5
+ export const MAX_DIFF_MATCHES = 32;
6
+
4
7
  export function buildEditDiff(filePath, originalText, oldText, newText) {
5
8
  const fileLines = contentLines(originalText);
6
9
  const idx = isString(originalText) ? originalText.indexOf(oldText) : -1;
@@ -39,7 +42,8 @@ export function buildEditDiff(filePath, originalText, oldText, newText) {
39
42
  }
40
43
 
41
44
  export function buildMultiEditDiff(filePath, originalText, replacements) {
42
- const parts = replacements.map(({ oldText, newText }) =>
45
+ const rendered = replacements.slice(0, MAX_DIFF_MATCHES);
46
+ const parts = rendered.map(({ oldText, newText }) =>
43
47
  buildEditDiff(filePath, originalText, oldText, newText),
44
48
  );
45
49
 
@@ -53,15 +57,16 @@ export function buildMultiEditDiff(filePath, originalText, replacements) {
53
57
  : { ...line, newLineNum: Math.max(1, line.lineNum + shift) });
54
58
  }
55
59
 
56
- shift += replacements[index].newText.split("\n").length - replacements[index].oldText.split("\n").length;
60
+ shift += rendered[index].newText.split("\n").length - rendered[index].oldText.split("\n").length;
57
61
  }
58
62
 
59
63
  return {
60
64
  path: filePath,
61
65
  op: "edit",
62
- added: parts.reduce((sum, part) => sum + part.added, 0),
63
- removed: parts.reduce((sum, part) => sum + part.removed, 0),
66
+ added: replacements.reduce((sum, r) => sum + contentLines(r.newText).length, 0),
67
+ removed: replacements.reduce((sum, r) => sum + contentLines(r.oldText).length, 0),
64
68
  lines,
69
+ omittedMatches: replacements.length - rendered.length,
65
70
  };
66
71
  }
67
72
 
@@ -71,22 +76,28 @@ function classifyPatchLine(line) {
71
76
  return PATCH_LINE_KINDS[line[0]] || null;
72
77
  }
73
78
 
74
- export function buildPatchDiff(filePath, patchText) {
79
+ export function buildPatchDiff(filePath, patchText, relocations = []) {
75
80
  const patchLines = isString(patchText) ? patchText.replace(/\r\n/g, "\n").split("\n") : [];
81
+ const shiftByHunk = new Map(relocations.map(entry => [entry.hunk, entry.offset]));
76
82
  const lines = [];
77
83
  let added = 0;
78
84
  let removed = 0;
79
85
  let oldLineNum = 1;
80
86
  let newLineNum = 1;
81
87
  let inHunk = false;
88
+ let hunkIndex = 0;
89
+ let relocatedBy = 0;
82
90
 
83
91
  for (const patchLine of patchLines) {
84
92
  const headerMatch = (/^@@\s+-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?/).exec(patchLine);
85
93
 
86
94
  if (headerMatch) {
95
+ // Headers already carry length deltas, so only drift relocates the receipt.
96
+ hunkIndex += 1;
97
+ relocatedBy += shiftByHunk.get(hunkIndex) ?? 0;
87
98
  // A zero-length range names the line before the insertion/deletion point.
88
- oldLineNum = Number(headerMatch[1]) + Number(headerMatch[2] === "0");
89
- newLineNum = Number(headerMatch[3]) + Number(headerMatch[4] === "0");
99
+ oldLineNum = Number(headerMatch[1]) + Number(headerMatch[2] === "0") + relocatedBy;
100
+ newLineNum = Number(headerMatch[3]) + Number(headerMatch[4] === "0") + relocatedBy;
90
101
  inHunk = true;
91
102
  continue;
92
103
  }
@@ -2,7 +2,37 @@ import { isString, isObject } from "../shared/decode.js";
2
2
 
3
3
  export const MAX_JSON_BYTES = 16 * 1024 * 1024;
4
4
 
5
- const SELECTOR_HELP = 'JSON selector supports .field, .nested[0], .items[0:3], .["quoted.key"], or . (whole value); not full jq';
5
+ const SELECTOR_HELP = 'JSON selector supports .field, .nested[0], .items[0:3], .items.length, .["quoted.key"], or . (whole value); not full jq';
6
+
7
+ function parseIdentStep(rest, first) {
8
+ const match = (first ? /^([A-Za-z_$][\w$]*)/ : /^\.([A-Za-z_$][\w$]*)/).exec(rest);
9
+
10
+ return match ? { step: { key: match[1] }, match } : null;
11
+ }
12
+
13
+ function parseQuotedStep(rest) {
14
+ const match = /^\[("(?:[^"\\]|\\.)*")\]/.exec(rest);
15
+
16
+ if (!match) return null;
17
+
18
+ try { return { step: { key: JSON.parse(match[1]) }, match }; } catch { throw new Error(SELECTOR_HELP); }
19
+ }
20
+
21
+ function parseIndexBounds(start, end) {
22
+ if (!Number.isSafeInteger(start) || (end !== undefined && (!Number.isSafeInteger(end) || end < start))) throw new Error(SELECTOR_HELP);
23
+
24
+ return end === undefined ? { index: start } : { start, end };
25
+ }
26
+
27
+ function parseIndexStep(rest) {
28
+ const match = /^\[(\d+)(?::(\d+))?\]/.exec(rest);
29
+
30
+ if (!match) return null;
31
+ const start = Number(match[1]);
32
+ const end = match[2] === undefined ? undefined : Number(match[2]);
33
+
34
+ return { step: parseIndexBounds(start, end), match };
35
+ }
6
36
 
7
37
  /** Parse a small, non-evaluating selector language. No dynamic code or prototype lookup. */
8
38
  function parseSelector(selector) {
@@ -12,25 +42,46 @@ function parseSelector(selector) {
12
42
  let first = true;
13
43
 
14
44
  while (rest) {
15
- let match;
16
-
17
- if ((match = (first ? /^([A-Za-z_$][\w$]*)/ : /^\.([A-Za-z_$][\w$]*)/).exec(rest))) {
18
- steps.push({ key: match[1] });
19
- } else if ((match = /^\[("(?:[^"\\]|\\.)*")\]/.exec(rest))) {
20
- try { steps.push({ key: JSON.parse(match[1]) }); } catch { throw new Error(SELECTOR_HELP); }
21
- } else if ((match = /^\[(\d+)(?::(\d+))?\]/.exec(rest))) {
22
- const start = Number(match[1]), end = match[2] === undefined ? undefined : Number(match[2]);
23
-
24
- if (!Number.isSafeInteger(start) || (end !== undefined && (!Number.isSafeInteger(end) || end < start))) throw new Error(SELECTOR_HELP);
25
- steps.push(end === undefined ? { index: start } : { start, end });
26
- } else throw new Error(SELECTOR_HELP);
27
- rest = rest.slice(match[0].length);
45
+ const parsed = parseIdentStep(rest, first) || parseQuotedStep(rest) || parseIndexStep(rest);
46
+
47
+ if (!parsed) throw new Error(SELECTOR_HELP);
48
+ steps.push(parsed.step);
49
+ rest = rest.slice(parsed.match[0].length);
28
50
  first = false;
29
51
  }
30
52
 
31
53
  return steps;
32
54
  }
33
55
 
56
+ function missingJsonField(value, key) {
57
+ const keys = isObject(value) ? Object.keys(value) : [];
58
+ const preview = keys.length ? "; available keys: " + keys.slice(0, 24).map(key => JSON.stringify(key)).join(", ") + (keys.length > 24 ? ", …" : "") : "";
59
+
60
+ return new Error("JSON field not found: " + JSON.stringify(key) + preview);
61
+ }
62
+
63
+ function selectKey(value, key) {
64
+ if (Array.isArray(value) && key === "length") return value.length;
65
+
66
+ if (!isObject(value) || !Object.hasOwn(value, key)) throw missingJsonField(value, key);
67
+
68
+ return value[key];
69
+ }
70
+
71
+ function selectStep(value, step) {
72
+ if (step.key !== undefined) return selectKey(value, step.key);
73
+
74
+ if (!Array.isArray(value)) throw new Error("JSON index requires an array");
75
+
76
+ if (step.index !== undefined) {
77
+ if (step.index >= value.length) throw new Error("JSON index out of range: " + step.index);
78
+
79
+ return value[step.index];
80
+ }
81
+
82
+ return value.slice(step.start, step.end);
83
+ }
84
+
34
85
  export function jsonProjector(json) {
35
86
  const many = Array.isArray(json);
36
87
  const selectors = many ? json : [json === true ? "." : json];
@@ -41,27 +92,7 @@ export function jsonProjector(json) {
41
92
  // Yield one selection at a time so the caller can budget it before the next
42
93
  // slice allocation. Eagerly mapping 64 large slices can exhaust the host heap.
43
94
  return function* (root) {
44
- for (const steps of plans) yield steps.reduce((value, step) => {
45
- if (step.key !== undefined) {
46
- if (!isObject(value) || !Object.hasOwn(value, step.key)) {
47
- const keys = isObject(value) ? Object.keys(value) : [];
48
- const preview = keys.length ? "; available keys: " + keys.slice(0, 24).map(key => JSON.stringify(key)).join(", ") + (keys.length > 24 ? ", …" : "") : "";
49
- throw new Error("JSON field not found: " + JSON.stringify(step.key) + preview);
50
- }
51
-
52
- return value[step.key];
53
- }
54
-
55
- if (!Array.isArray(value)) throw new Error("JSON index requires an array");
56
-
57
- if (step.index !== undefined) {
58
- if (step.index >= value.length) throw new Error("JSON index out of range: " + step.index);
59
-
60
- return value[step.index];
61
- }
62
-
63
- return value.slice(step.start, step.end);
64
- }, root);
95
+ for (const steps of plans) yield steps.reduce(selectStep, root);
65
96
  };
66
97
  }
67
98