pi-supernova 0.6.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.
- package/README.md +27 -3
- package/docs/CHANGELOG.md +104 -0
- package/docs/TOKEN_COSTS.md +13 -5
- package/index.js +120 -79
- package/package.json +1 -1
- package/src/adapters/bash.js +73 -0
- package/src/adapters/edit.js +249 -0
- package/src/adapters/errors.js +31 -0
- package/src/adapters/index.js +31 -0
- package/src/adapters/list.js +102 -0
- package/src/adapters/read.js +805 -0
- package/src/adapters/refs.js +41 -0
- package/src/adapters/write.js +96 -0
- package/src/bridge/catalog.js +28 -222
- package/src/bridge/host-bridge.js +113 -1668
- package/src/bridge/invoke.js +35 -0
- package/src/bridge/native-tools.js +1 -198
- package/src/context/evidence.js +140 -76
- package/src/context/fuzzy.js +42 -24
- package/src/context/ledger.js +43 -24
- package/src/context/outline.js +23 -18
- package/src/context/repo-index.js +206 -170
- package/src/context/search.js +157 -77
- package/src/context/snap.js +240 -136
- package/src/context/spans.js +2 -1
- package/src/context/surface.js +14 -5
- package/src/contract/bash.js +31 -0
- package/src/contract/edit.js +95 -0
- package/src/contract/read.js +220 -0
- package/src/fs/check.js +12 -8
- package/src/fs/diff.js +18 -7
- package/src/fs/json-read.js +66 -35
- package/src/fs/patch.js +94 -50
- package/src/fs/source-window.js +82 -0
- package/src/fs/text-ops.js +512 -0
- package/src/fs/vfs.js +205 -175
- package/src/fs/workspace.js +119 -108
- package/src/output/bottleneck.js +195 -116
- package/src/output/format.js +101 -67
- package/src/runtime/guest-deny-imports.js +34 -0
- package/src/runtime/guest-worker.js +289 -292
- package/src/runtime/parallel.js +97 -64
- package/src/runtime/program-batch.js +178 -69
- package/src/runtime/reference.js +15 -14
- package/src/runtime/runtime.js +327 -187
- package/src/shared/decode.js +58 -36
- package/src/ui/omp-frame.js +59 -42
- package/src/ui/render-measure.js +51 -29
- package/src/ui/render.js +241 -145
|
@@ -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
|
@@ -118,14 +118,7 @@ function consumeQuoted(text, i, stack) {
|
|
|
118
118
|
return end < 0 ? { error: "unterminated string", at: i } : { end, prev: "value" };
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
-
|
|
122
|
-
function consumeLiteral(text, i, stack, prev) {
|
|
123
|
-
const c = text[i];
|
|
124
|
-
|
|
125
|
-
if (c === '"' || c === "'" || c === "`") return consumeQuoted(text, i, stack);
|
|
126
|
-
|
|
127
|
-
if (c !== "/") return null;
|
|
128
|
-
|
|
121
|
+
function consumeSlash(text, i, prev) {
|
|
129
122
|
if (text[i + 1] === "/" || text[i + 1] === "*") {
|
|
130
123
|
const end = skipComment(text, i);
|
|
131
124
|
|
|
@@ -140,6 +133,17 @@ function consumeLiteral(text, i, stack, prev) {
|
|
|
140
133
|
return end > 0 ? { end, prev: "value" } : null;
|
|
141
134
|
}
|
|
142
135
|
|
|
136
|
+
/** Try to consume a comment, string, template, or regex at i. Returns { end, prev } | { error, at } | null. */
|
|
137
|
+
function consumeLiteral(text, i, stack, prev) {
|
|
138
|
+
const c = text[i];
|
|
139
|
+
|
|
140
|
+
if (c === '"' || c === "'" || c === "`") return consumeQuoted(text, i, stack);
|
|
141
|
+
|
|
142
|
+
if (c !== "/") return null;
|
|
143
|
+
|
|
144
|
+
return consumeSlash(text, i, prev);
|
|
145
|
+
}
|
|
146
|
+
|
|
143
147
|
/** Push/pop a bracket; returns an error, a stop, or null to continue. */
|
|
144
148
|
function bracket(c, i, stack, stopDepth) {
|
|
145
149
|
if (OPEN[c]) {
|
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
|
|
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 +=
|
|
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:
|
|
63
|
-
removed:
|
|
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
|
}
|
package/src/fs/json-read.js
CHANGED
|
@@ -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
|
-
|
|
16
|
-
|
|
17
|
-
if (
|
|
18
|
-
|
|
19
|
-
|
|
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(
|
|
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
|
|
package/src/fs/patch.js
CHANGED
|
@@ -9,6 +9,36 @@ function parseHunkHeader(line) {
|
|
|
9
9
|
newStart: Number(match[3]), newLength: match[4] === undefined ? 1 : Number(match[4]), lines: [], noNewline: [] };
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
function applyNoNewlineMarker(current) {
|
|
13
|
+
if (!current.lines.length) throw new Error("newline marker requires a preceding hunk line");
|
|
14
|
+
current.noNewline.push(current.lines.length - 1);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function pushHunkLine(current, line, oldCount, newCount) {
|
|
18
|
+
if (oldCount === current.oldLength && newCount === current.newLength && /^--- |^\+\+\+ /.test(line)) {
|
|
19
|
+
throw new Error("apply_patch accepts one file at a time");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
current.lines.push(line);
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
oldCount: line[0] !== "+" ? oldCount + 1 : oldCount,
|
|
26
|
+
newCount: line[0] !== "-" ? newCount + 1 : newCount,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function consumeHunkLine(current, line, oldCount, newCount) {
|
|
31
|
+
if (line.startsWith("\")) {
|
|
32
|
+
applyNoNewlineMarker(current);
|
|
33
|
+
|
|
34
|
+
return { oldCount, newCount };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (!/^[+ -]/.test(line)) return { oldCount, newCount };
|
|
38
|
+
|
|
39
|
+
return pushHunkLine(current, line, oldCount, newCount);
|
|
40
|
+
}
|
|
41
|
+
|
|
12
42
|
export function parsePatchHunks(patchText) {
|
|
13
43
|
const hunks = [];
|
|
14
44
|
let current;
|
|
@@ -23,19 +53,8 @@ export function parsePatchHunks(patchText) {
|
|
|
23
53
|
hunks.push(current);
|
|
24
54
|
oldCount = 0;
|
|
25
55
|
newCount = 0;
|
|
26
|
-
} else if (current
|
|
27
|
-
|
|
28
|
-
current.noNewline.push(current.lines.length - 1);
|
|
29
|
-
} else if (current && /^[+ -]/.test(line)) {
|
|
30
|
-
if (oldCount === current.oldLength && newCount === current.newLength && /^--- |^\+\+\+ /.test(line)) {
|
|
31
|
-
throw new Error("apply_patch accepts one file at a time");
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
current.lines.push(line);
|
|
35
|
-
|
|
36
|
-
if (line[0] !== "+") oldCount++;
|
|
37
|
-
|
|
38
|
-
if (line[0] !== "-") newCount++;
|
|
56
|
+
} else if (current) {
|
|
57
|
+
({ oldCount, newCount } = consumeHunkLine(current, line, oldCount, newCount));
|
|
39
58
|
}
|
|
40
59
|
}
|
|
41
60
|
|
|
@@ -59,23 +78,73 @@ function splitFile(text) {
|
|
|
59
78
|
});
|
|
60
79
|
}
|
|
61
80
|
|
|
62
|
-
function findHunkMatch(fileLines, expectedOld, nominal) {
|
|
81
|
+
function findHunkMatch(fileLines, expectedOld, nominal, hunk, oldStart) {
|
|
63
82
|
const matchAt = index => index >= 0 && index + expectedOld.length <= fileLines.length
|
|
64
83
|
&& expectedOld.every((line, i) => fileLines[index + i].text === line);
|
|
65
84
|
|
|
66
|
-
if (matchAt(nominal)) return nominal;
|
|
85
|
+
if (matchAt(nominal)) return { index: nominal, relocated: 0 };
|
|
67
86
|
|
|
68
|
-
if (!expectedOld.length) return -1;
|
|
87
|
+
if (!expectedOld.length) return { index: -1, relocated: 0 };
|
|
69
88
|
|
|
70
89
|
const maxDrift = Math.min(Math.max(fileLines.length, 100), 200);
|
|
90
|
+
const hits = [];
|
|
71
91
|
|
|
72
92
|
for (let delta = 1; delta <= maxDrift; delta++) {
|
|
73
|
-
if (matchAt(nominal + delta))
|
|
93
|
+
if (matchAt(nominal + delta)) hits.push(nominal + delta);
|
|
74
94
|
|
|
75
|
-
if (matchAt(nominal - delta))
|
|
95
|
+
if (matchAt(nominal - delta)) hits.push(nominal - delta);
|
|
76
96
|
}
|
|
77
97
|
|
|
78
|
-
|
|
98
|
+
if (hits.length > 1) throw new Error("patch hunk " + hunk + " near line " + oldStart + " matches " + hits.length + " locations; add context lines to disambiguate");
|
|
99
|
+
|
|
100
|
+
if (hits.length === 1) return { index: hits[0], relocated: hits[0] - nominal };
|
|
101
|
+
|
|
102
|
+
return { index: -1, relocated: 0 };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function hunkLineText(line) {
|
|
106
|
+
return line.slice(1).replace(/\r$/, "");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function applyHunkLines(hunk, fileLines, matchIndex, ending) {
|
|
110
|
+
const replacement = [];
|
|
111
|
+
let oldIndex = matchIndex;
|
|
112
|
+
|
|
113
|
+
for (let i = 0; i < hunk.lines.length; i++) {
|
|
114
|
+
const line = hunk.lines[i];
|
|
115
|
+
|
|
116
|
+
if (line[0] === "+") {
|
|
117
|
+
replacement.push({ text: hunkLineText(line), ending: hunk.noNewline.includes(i) ? "" : ending });
|
|
118
|
+
} else {
|
|
119
|
+
const original = fileLines[oldIndex++];
|
|
120
|
+
|
|
121
|
+
if (hunk.noNewline.includes(i) && original.ending) throw new Error("patch newline marker does not match the file");
|
|
122
|
+
|
|
123
|
+
if (line[0] === " ") replacement.push(original);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return replacement;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function applyOneHunk(hunk, h, fileLines, offsetShift, relocationShift, ending) {
|
|
131
|
+
const expectedOld = hunk.lines.filter(line => line[0] !== "+").map(hunkLineText);
|
|
132
|
+
const newCount = hunk.lines.filter(line => line[0] !== "-").length;
|
|
133
|
+
|
|
134
|
+
if (expectedOld.length !== hunk.oldLength || newCount !== hunk.newLength) throw new Error("patch hunk " + (h + 1) + " length does not match its header");
|
|
135
|
+
// The new coordinate also handles BSD diff's -1,0 header at file start.
|
|
136
|
+
const nominal = hunk.oldLength === 0 ? hunk.newStart - 1 + relocationShift : hunk.oldStart - 1 + offsetShift;
|
|
137
|
+
const match = findHunkMatch(fileLines, expectedOld, nominal, h + 1, hunk.oldStart);
|
|
138
|
+
|
|
139
|
+
if (match.index < 0) throw new Error("patch hunk " + (h + 1) + " rejected at line " + hunk.oldStart + ": context did not match");
|
|
140
|
+
const replacement = applyHunkLines(hunk, fileLines, match.index, ending);
|
|
141
|
+
fileLines.splice(match.index, expectedOld.length, ...replacement);
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
relocationShift: relocationShift + match.relocated,
|
|
145
|
+
offsetShift: offsetShift + match.relocated + replacement.length - expectedOld.length,
|
|
146
|
+
relocated: match.relocated,
|
|
147
|
+
};
|
|
79
148
|
}
|
|
80
149
|
|
|
81
150
|
export function applyPatchToText(originalText, patchText) {
|
|
@@ -83,42 +152,17 @@ export function applyPatchToText(originalText, patchText) {
|
|
|
83
152
|
const hunks = parsePatchHunks(patchText);
|
|
84
153
|
const fileLines = splitFile(originalText);
|
|
85
154
|
const ending = fileLines.find(line => line.ending)?.ending ?? "\n";
|
|
155
|
+
const relocations = [];
|
|
86
156
|
let offsetShift = 0;
|
|
87
157
|
let relocationShift = 0;
|
|
88
158
|
|
|
89
159
|
for (let h = 0; h < hunks.length; h++) {
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
const newCount = hunk.lines.filter(line => line[0] !== "-").length;
|
|
94
|
-
|
|
95
|
-
if (expectedOld.length !== hunk.oldLength || newCount !== hunk.newLength) throw new Error("patch hunk " + (h + 1) + " length does not match its header");
|
|
96
|
-
// The new coordinate also handles BSD diff's -1,0 header at file start.
|
|
97
|
-
const nominal = hunk.oldLength === 0 ? hunk.newStart - 1 + relocationShift : hunk.oldStart - 1 + offsetShift;
|
|
98
|
-
const matchIndex = findHunkMatch(fileLines, expectedOld, nominal);
|
|
99
|
-
|
|
100
|
-
if (matchIndex < 0) throw new Error("patch hunk " + (h + 1) + " rejected at line " + hunk.oldStart + ": context did not match");
|
|
101
|
-
const replacement = [];
|
|
102
|
-
let oldIndex = matchIndex;
|
|
103
|
-
|
|
104
|
-
for (let i = 0; i < hunk.lines.length; i++) {
|
|
105
|
-
const line = hunk.lines[i];
|
|
106
|
-
|
|
107
|
-
if (line[0] === "+") {
|
|
108
|
-
replacement.push({ text: textOf(line), ending: hunk.noNewline.includes(i) ? "" : ending });
|
|
109
|
-
} else {
|
|
110
|
-
const original = fileLines[oldIndex++];
|
|
111
|
-
|
|
112
|
-
if (hunk.noNewline.includes(i) && original.ending) throw new Error("patch newline marker does not match the file");
|
|
113
|
-
|
|
114
|
-
if (line[0] === " ") replacement.push(original);
|
|
115
|
-
}
|
|
116
|
-
}
|
|
160
|
+
const applied = applyOneHunk(hunks[h], h, fileLines, offsetShift, relocationShift, ending);
|
|
161
|
+
offsetShift = applied.offsetShift;
|
|
162
|
+
relocationShift = applied.relocationShift;
|
|
117
163
|
|
|
118
|
-
|
|
119
|
-
relocationShift += matchIndex - nominal;
|
|
120
|
-
offsetShift += matchIndex - nominal + replacement.length - expectedOld.length;
|
|
164
|
+
if (applied.relocated !== 0) relocations.push({ hunk: h + 1, offset: applied.relocated });
|
|
121
165
|
}
|
|
122
166
|
|
|
123
|
-
return { resultText: fileLines.map(line => line.text + line.ending).join(""), hunkCount: hunks.length };
|
|
167
|
+
return { resultText: fileLines.map(line => line.text + line.ending).join(""), hunkCount: hunks.length, relocations };
|
|
124
168
|
}
|