pi-ast-sgrep 2.2.0 → 2.5.2
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/codemode/connector.d.ts +1 -1
- package/dist/codemode/connector.js +38 -2
- package/dist/codemode/fallback.d.ts +43 -0
- package/dist/codemode/fallback.js +303 -0
- package/dist/codemode/native.d.ts +5 -1
- package/dist/codemode/native.js +5 -1
- package/dist/host/tools.d.ts +1 -1
- package/dist/host/tools.js +14 -15
- package/dist/runtime/runtime.js +18 -2
- package/dist/runtime/types.d.ts +1 -1
- package/dist/runtime/types.js +1 -1
- package/dist/ui/present.d.ts +10 -2
- package/dist/ui/present.js +14 -3
- package/package.json +3 -3
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { MachineEnvelope } from "../runtime/runtime.js";
|
|
2
|
-
import type { ChainArgs, EditArgs, FindArgs, ReadArgs, SearchArgs } from "./types.js";
|
|
3
2
|
import { type BatchCapableHost, type DispatchCall, type DispatchStats } from "./dispatch.js";
|
|
3
|
+
import type { ChainArgs, EditArgs, FindArgs, ReadArgs, SearchArgs } from "./types.js";
|
|
4
4
|
/**
|
|
5
5
|
* Spawn/CLI transport. Hosts provide argv `run` only — never a typed twin.
|
|
6
6
|
* Typed entry lives solely on {@link DispatchSurface} (dispatcher output).
|
|
@@ -1,7 +1,15 @@
|
|
|
1
|
+
import { argvFor, asEnvelope, createCodemodeDispatcher, } from "./dispatch.js";
|
|
2
|
+
import { editFilesFallback, readWindowsFallback } from "./fallback.js";
|
|
1
3
|
import { coerceHostArgs } from "./guest-api.js";
|
|
2
4
|
import { defined } from "./types.js";
|
|
3
|
-
import { createCodemodeDispatcher, } from "./dispatch.js";
|
|
4
5
|
const DEFAULT_LIMIT = 8;
|
|
6
|
+
/** Native tools the extension serves itself when the launcher predates them. */
|
|
7
|
+
const NATIVE_FALLBACK_TOOLS = new Set(["read", "edit", "find"]);
|
|
8
|
+
/** Stable unknown-tool prefix, byte-identical in every native generation. */
|
|
9
|
+
function isUnknownToolError(cause) {
|
|
10
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
11
|
+
return message.includes("unknown tool:");
|
|
12
|
+
}
|
|
5
13
|
function clampLimit(limit) {
|
|
6
14
|
if (limit === undefined)
|
|
7
15
|
return DEFAULT_LIMIT;
|
|
@@ -58,7 +66,35 @@ export function createAsgrepConnector(host, context, options = {}) {
|
|
|
58
66
|
}
|
|
59
67
|
return { signal: combined };
|
|
60
68
|
};
|
|
61
|
-
|
|
69
|
+
// Unknown-tool fallback engages once per tool: after the first miss the
|
|
70
|
+
// native attempt is skipped for this bundle (a backend cannot gain tools
|
|
71
|
+
// mid-session). Any other error still propagates untouched, unmemoized.
|
|
72
|
+
const nativeMissing = new Set();
|
|
73
|
+
const fallbackCall = async (tool, args, signal) => {
|
|
74
|
+
if (tool === "find")
|
|
75
|
+
return host.run(argvFor("find", args), context, signal === undefined ? {} : { signal });
|
|
76
|
+
const value = tool === "read"
|
|
77
|
+
? await readWindowsFallback(context.cwd, args, signal)
|
|
78
|
+
: await editFilesFallback(context.cwd, args, signal);
|
|
79
|
+
return asEnvelope(value, tool);
|
|
80
|
+
};
|
|
81
|
+
const call = async (tool, args, signal) => {
|
|
82
|
+
const opts = callOptions(signal);
|
|
83
|
+
if (NATIVE_FALLBACK_TOOLS.has(tool) && nativeMissing.has(tool))
|
|
84
|
+
return fallbackCall(tool, args, opts.signal);
|
|
85
|
+
try {
|
|
86
|
+
return await dispatcher.host.call(tool, args, context, opts);
|
|
87
|
+
}
|
|
88
|
+
catch (cause) {
|
|
89
|
+
// No memo check here: concurrent same-tool misses must all fall back —
|
|
90
|
+
// the memo only skips future native attempts, never a fresh miss.
|
|
91
|
+
if (NATIVE_FALLBACK_TOOLS.has(tool) && isUnknownToolError(cause)) {
|
|
92
|
+
nativeMissing.add(tool);
|
|
93
|
+
return fallbackCall(tool, args, opts.signal);
|
|
94
|
+
}
|
|
95
|
+
throw cause;
|
|
96
|
+
}
|
|
97
|
+
};
|
|
62
98
|
const searchPayload = (method, input) => {
|
|
63
99
|
const scoped = coerceHostArgs(method, withScope({ ...input }));
|
|
64
100
|
const payload = {
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extension-side read/edit for launchers that predate the native tools.
|
|
3
|
+
*
|
|
4
|
+
* Severed-lane contract: when native answers `unknown tool: <name>` for
|
|
5
|
+
* read/edit/find, the connector serves the call here instead of failing, so
|
|
6
|
+
* an extension tracking main keeps working on an official launcher. Every
|
|
7
|
+
* shape, default, cap, jail rule, and error string mirrors
|
|
8
|
+
* crates/ast-sgrep-codemode/src/io.rs exactly — callers cannot distinguish
|
|
9
|
+
* this lane from a native one. Errors are plain Errors carrying the native
|
|
10
|
+
* text (the native lane surfaces them the same way); no new error codes.
|
|
11
|
+
*
|
|
12
|
+
* Deliberate divergences from native, both strictly safer:
|
|
13
|
+
* - Reads come from disk, never the index (the extension has no row reader
|
|
14
|
+
* here). Disk is fresher than a possibly stale index row.
|
|
15
|
+
* - Edits skip the native targeted reindex. A fallback edit is exactly like
|
|
16
|
+
* an external editor write, which the freshness layer already reconciles.
|
|
17
|
+
*/
|
|
18
|
+
/** Mirror read_windows (disk-backed; see module note). */
|
|
19
|
+
export declare function readWindowsFallback(configuredCwd: string, args: Record<string, unknown>, signal?: AbortSignal): Promise<{
|
|
20
|
+
ok: true;
|
|
21
|
+
count: number;
|
|
22
|
+
windows: Array<{
|
|
23
|
+
path: string;
|
|
24
|
+
ref: string;
|
|
25
|
+
start: number;
|
|
26
|
+
end: number;
|
|
27
|
+
truncated: boolean;
|
|
28
|
+
text: string;
|
|
29
|
+
}>;
|
|
30
|
+
}>;
|
|
31
|
+
/** Mirror edit_files (validate-all-then-write; no targeted reindex — freshness covers). */
|
|
32
|
+
export declare function editFilesFallback(configuredCwd: string, args: Record<string, unknown>, signal?: AbortSignal): Promise<{
|
|
33
|
+
ok: true;
|
|
34
|
+
changed: number;
|
|
35
|
+
edits: Array<{
|
|
36
|
+
path: string;
|
|
37
|
+
changed: boolean;
|
|
38
|
+
line: number;
|
|
39
|
+
removed: string[];
|
|
40
|
+
added: string[];
|
|
41
|
+
truncated?: true;
|
|
42
|
+
}>;
|
|
43
|
+
}>;
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extension-side read/edit for launchers that predate the native tools.
|
|
3
|
+
*
|
|
4
|
+
* Severed-lane contract: when native answers `unknown tool: <name>` for
|
|
5
|
+
* read/edit/find, the connector serves the call here instead of failing, so
|
|
6
|
+
* an extension tracking main keeps working on an official launcher. Every
|
|
7
|
+
* shape, default, cap, jail rule, and error string mirrors
|
|
8
|
+
* crates/ast-sgrep-codemode/src/io.rs exactly — callers cannot distinguish
|
|
9
|
+
* this lane from a native one. Errors are plain Errors carrying the native
|
|
10
|
+
* text (the native lane surfaces them the same way); no new error codes.
|
|
11
|
+
*
|
|
12
|
+
* Deliberate divergences from native, both strictly safer:
|
|
13
|
+
* - Reads come from disk, never the index (the extension has no row reader
|
|
14
|
+
* here). Disk is fresher than a possibly stale index row.
|
|
15
|
+
* - Edits skip the native targeted reindex. A fallback edit is exactly like
|
|
16
|
+
* an external editor write, which the freshness layer already reconciles.
|
|
17
|
+
*/
|
|
18
|
+
import { readFile, realpath, writeFile } from "node:fs/promises";
|
|
19
|
+
import { isAbsolute, join, relative } from "node:path";
|
|
20
|
+
import { pathContained } from "../runtime/index-health.js";
|
|
21
|
+
// Native caps (crates/ast-sgrep-codemode/src/io.rs + core limits).
|
|
22
|
+
const MAX_READ_REFS = 32;
|
|
23
|
+
const MAX_READ_CHARS = 100_000;
|
|
24
|
+
const MAX_EDITS = 16;
|
|
25
|
+
const MAX_LINE_CHARS = 2_000;
|
|
26
|
+
const MAX_INDEX_FILE_BYTES = 64 * 1024 * 1024;
|
|
27
|
+
const MAX_EXCERPT_LINES = 100;
|
|
28
|
+
const U32_MAX = 4_294_967_295;
|
|
29
|
+
/** Mirror serde as_u64 (integers only, then wrapping `as u32`). */
|
|
30
|
+
function u32(value) {
|
|
31
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 0 || value > Number.MAX_SAFE_INTEGER)
|
|
32
|
+
return undefined;
|
|
33
|
+
return value % (U32_MAX + 1);
|
|
34
|
+
}
|
|
35
|
+
/** Mirror serde as_u64 without narrowing (context/max budgets). */
|
|
36
|
+
function u64(value) {
|
|
37
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 0 || value > Number.MAX_SAFE_INTEGER)
|
|
38
|
+
return undefined;
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
/** First present key wins even when its value is unusable (or_else chain). */
|
|
42
|
+
function present(args, ...keys) {
|
|
43
|
+
for (const key of keys)
|
|
44
|
+
if (key in args)
|
|
45
|
+
return args[key];
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
/** Mirror str::lines: \n or \r\n separators, no phantom trailing line. */
|
|
49
|
+
function rustLines(text) {
|
|
50
|
+
if (text === "")
|
|
51
|
+
return [];
|
|
52
|
+
const lines = text.split("\n").map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line));
|
|
53
|
+
if (text.endsWith("\n"))
|
|
54
|
+
lines.pop();
|
|
55
|
+
return lines;
|
|
56
|
+
}
|
|
57
|
+
function charCount(text) {
|
|
58
|
+
return [...text].length;
|
|
59
|
+
}
|
|
60
|
+
function clampLine(line) {
|
|
61
|
+
if (charCount(line) <= MAX_LINE_CHARS)
|
|
62
|
+
return { line, clamped: false };
|
|
63
|
+
return { line: [...line].slice(0, MAX_LINE_CHARS).join(""), clamped: true };
|
|
64
|
+
}
|
|
65
|
+
/** Mirror session root_arg: configured root, optional contained override. */
|
|
66
|
+
async function jailRoot(configuredCwd, args) {
|
|
67
|
+
const configured = await realpath(configuredCwd).catch(() => {
|
|
68
|
+
throw new Error(`cannot resolve session root: ${configuredCwd}`);
|
|
69
|
+
});
|
|
70
|
+
const raw = args.root;
|
|
71
|
+
if (typeof raw !== "string")
|
|
72
|
+
return configured;
|
|
73
|
+
const candidate = isAbsolute(raw) ? raw : join(configured, raw);
|
|
74
|
+
const resolved = await realpath(candidate).catch(() => {
|
|
75
|
+
throw new Error(`cannot resolve requested root: ${candidate}`);
|
|
76
|
+
});
|
|
77
|
+
if (!pathContained(configured, resolved)) {
|
|
78
|
+
throw new Error(`requested root is outside the configured session root: ${resolved}`);
|
|
79
|
+
}
|
|
80
|
+
return resolved;
|
|
81
|
+
}
|
|
82
|
+
/** Mirror jail_rel_path: explicit '..' rejection, canonicalize, containment. */
|
|
83
|
+
async function jailPath(root, raw) {
|
|
84
|
+
if (raw.split(/[\\/]/u).includes(".."))
|
|
85
|
+
throw new Error("path must not contain '..'");
|
|
86
|
+
const candidate = isAbsolute(raw) ? raw : join(root, raw);
|
|
87
|
+
const abs = await realpath(candidate).catch(() => {
|
|
88
|
+
throw new Error(`cannot resolve path ${raw}`);
|
|
89
|
+
});
|
|
90
|
+
if (!pathContained(root, abs))
|
|
91
|
+
throw new Error(`path escapes session root: ${raw}`);
|
|
92
|
+
return { abs, display: relative(root, abs).replace(/\\/g, "/") };
|
|
93
|
+
}
|
|
94
|
+
function parseU32Strict(text) {
|
|
95
|
+
const digits = /^(?:\+)?(\d+)$/.exec(text)?.[1];
|
|
96
|
+
if (digits === undefined)
|
|
97
|
+
return undefined;
|
|
98
|
+
const value = Number(digits);
|
|
99
|
+
return Number.isSafeInteger(value) && value <= U32_MAX ? value : undefined;
|
|
100
|
+
}
|
|
101
|
+
function parseRefString(raw) {
|
|
102
|
+
const hash = raw.lastIndexOf("#L");
|
|
103
|
+
if (hash === -1)
|
|
104
|
+
return { path: raw, start: 1, end: 40 };
|
|
105
|
+
const rest = raw.slice(hash + 2).trim();
|
|
106
|
+
const dash = rest.indexOf("-L");
|
|
107
|
+
const startText = dash === -1 ? rest : rest.slice(0, dash);
|
|
108
|
+
const endText = dash === -1 ? rest : rest.slice(dash + 2);
|
|
109
|
+
const start = parseU32Strict(startText);
|
|
110
|
+
if (start === undefined)
|
|
111
|
+
throw new Error(`invalid ref start in ${raw}`);
|
|
112
|
+
const end = parseU32Strict(endText);
|
|
113
|
+
if (end === undefined)
|
|
114
|
+
throw new Error(`invalid ref end in ${raw}`);
|
|
115
|
+
if (start === 0 || end < start)
|
|
116
|
+
throw new Error(`invalid ref range in ${raw}`);
|
|
117
|
+
return { path: raw.slice(0, hash), start, end };
|
|
118
|
+
}
|
|
119
|
+
function parseRefValue(value) {
|
|
120
|
+
if (typeof value === "string")
|
|
121
|
+
return parseRefString(value);
|
|
122
|
+
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
123
|
+
throw new Error("ref must be a string or object");
|
|
124
|
+
const obj = value;
|
|
125
|
+
if (typeof obj.ref === "string")
|
|
126
|
+
return parseRefString(obj.ref);
|
|
127
|
+
const path = present(obj, "path", "file");
|
|
128
|
+
if (typeof path !== "string")
|
|
129
|
+
throw new Error("ref.path is required");
|
|
130
|
+
const start = u32(present(obj, "start", "line_start")) ?? 1;
|
|
131
|
+
const end = u32(present(obj, "end", "line_end")) ?? start;
|
|
132
|
+
return { path, start: Math.max(1, start), end: Math.max(start, end) };
|
|
133
|
+
}
|
|
134
|
+
function collectRefs(args) {
|
|
135
|
+
if (Array.isArray(args.refs))
|
|
136
|
+
return args.refs.map(parseRefValue);
|
|
137
|
+
if (args.ref !== undefined)
|
|
138
|
+
return [parseRefValue(args.ref)];
|
|
139
|
+
const path = present(args, "path", "file");
|
|
140
|
+
if (typeof path !== "string")
|
|
141
|
+
throw new Error("path is required");
|
|
142
|
+
const start = u32(present(args, "start", "line_start")) ?? 1;
|
|
143
|
+
const end = u32(present(args, "end", "line_end")) ?? start;
|
|
144
|
+
return [{ path, start: Math.max(1, start), end: Math.max(start, end) }];
|
|
145
|
+
}
|
|
146
|
+
function sliceWindow(numbered, start, end, maxChars) {
|
|
147
|
+
let out = "";
|
|
148
|
+
let actualStart = start;
|
|
149
|
+
let actualEnd = start;
|
|
150
|
+
let first = true;
|
|
151
|
+
let truncated = false;
|
|
152
|
+
let chars = 0;
|
|
153
|
+
for (const [no, content] of numbered) {
|
|
154
|
+
if (no < start)
|
|
155
|
+
continue;
|
|
156
|
+
if (no > end)
|
|
157
|
+
break;
|
|
158
|
+
const { line, clamped } = clampLine(content);
|
|
159
|
+
if (clamped)
|
|
160
|
+
truncated = true;
|
|
161
|
+
const add = (first ? 0 : 1) + charCount(line);
|
|
162
|
+
if (chars + add > maxChars) {
|
|
163
|
+
truncated = true;
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
if (first) {
|
|
167
|
+
actualStart = no;
|
|
168
|
+
first = false;
|
|
169
|
+
}
|
|
170
|
+
if (out !== "")
|
|
171
|
+
out += "\n";
|
|
172
|
+
out += line;
|
|
173
|
+
actualEnd = no;
|
|
174
|
+
chars += add;
|
|
175
|
+
}
|
|
176
|
+
if (first)
|
|
177
|
+
return { text: "", actualStart: start, actualEnd: start, truncated: false };
|
|
178
|
+
return { text: out, actualStart, actualEnd, truncated };
|
|
179
|
+
}
|
|
180
|
+
async function readFileCapped(abs, display, signal) {
|
|
181
|
+
const text = await readFile(abs, "utf8").catch(() => {
|
|
182
|
+
throw new Error(`cannot read ${display}`);
|
|
183
|
+
});
|
|
184
|
+
if (Buffer.byteLength(text) > MAX_INDEX_FILE_BYTES)
|
|
185
|
+
throw new Error(`${display} exceeds max ${MAX_INDEX_FILE_BYTES} bytes`);
|
|
186
|
+
signal?.throwIfAborted();
|
|
187
|
+
return text;
|
|
188
|
+
}
|
|
189
|
+
/** Mirror read_windows (disk-backed; see module note). */
|
|
190
|
+
export async function readWindowsFallback(configuredCwd, args, signal) {
|
|
191
|
+
signal?.throwIfAborted();
|
|
192
|
+
const root = await jailRoot(configuredCwd, args);
|
|
193
|
+
const contextLines = Math.min(u64(present(args, "context_lines", "contextLines")) ?? 0, MAX_EXCERPT_LINES);
|
|
194
|
+
const maxChars = Math.min(Math.max(u64(present(args, "max_chars", "maxChars")) ?? MAX_READ_CHARS, 1), MAX_READ_CHARS);
|
|
195
|
+
const refs = collectRefs(args);
|
|
196
|
+
if (refs.length === 0)
|
|
197
|
+
throw new Error("read requires path, ref, or refs");
|
|
198
|
+
if (refs.length > MAX_READ_REFS)
|
|
199
|
+
throw new Error(`read exceeds max ${MAX_READ_REFS} windows`);
|
|
200
|
+
const windows = [];
|
|
201
|
+
for (const spec of refs) {
|
|
202
|
+
signal?.throwIfAborted();
|
|
203
|
+
const { abs, display } = await jailPath(root, spec.path);
|
|
204
|
+
const text = await readFileCapped(abs, display, signal);
|
|
205
|
+
const numbered = rustLines(text).map((line, index) => [index + 1, line]);
|
|
206
|
+
const start = Math.max(1, spec.start - contextLines);
|
|
207
|
+
const end = Math.min(spec.end + contextLines, U32_MAX);
|
|
208
|
+
const slice = sliceWindow(numbered, start, end, maxChars);
|
|
209
|
+
windows.push({
|
|
210
|
+
path: display,
|
|
211
|
+
ref: `${display}#L${slice.actualStart}-L${slice.actualEnd}`,
|
|
212
|
+
start: slice.actualStart,
|
|
213
|
+
end: slice.actualEnd,
|
|
214
|
+
truncated: slice.truncated,
|
|
215
|
+
text: slice.text,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
return { ok: true, count: windows.length, windows };
|
|
219
|
+
}
|
|
220
|
+
function parseEditValue(value) {
|
|
221
|
+
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
222
|
+
throw new Error("edit must be an object");
|
|
223
|
+
const obj = value;
|
|
224
|
+
const path = present(obj, "path", "file");
|
|
225
|
+
if (typeof path !== "string")
|
|
226
|
+
throw new Error("path is required");
|
|
227
|
+
const oldText = present(obj, "oldText", "old_string", "old");
|
|
228
|
+
if (typeof oldText !== "string")
|
|
229
|
+
throw new Error("oldText is required");
|
|
230
|
+
const newText = present(obj, "newText", "new_string", "new");
|
|
231
|
+
if (typeof newText !== "string")
|
|
232
|
+
throw new Error("newText is required");
|
|
233
|
+
if (oldText === "")
|
|
234
|
+
throw new Error("oldText must not be empty");
|
|
235
|
+
return { path, oldText, newText };
|
|
236
|
+
}
|
|
237
|
+
function collectEdits(args) {
|
|
238
|
+
if (Array.isArray(args.edits))
|
|
239
|
+
return args.edits.map(parseEditValue);
|
|
240
|
+
if (typeof args.path === "string")
|
|
241
|
+
return [parseEditValue(args)];
|
|
242
|
+
return [];
|
|
243
|
+
}
|
|
244
|
+
function diffLines(text) {
|
|
245
|
+
const all = rustLines(text);
|
|
246
|
+
return { lines: all.slice(0, 24), more: all.length > 24 };
|
|
247
|
+
}
|
|
248
|
+
function matchLine(buffer, offset) {
|
|
249
|
+
return buffer.slice(0, offset).split("\n").length;
|
|
250
|
+
}
|
|
251
|
+
/** Mirror edit_files (validate-all-then-write; no targeted reindex — freshness covers). */
|
|
252
|
+
export async function editFilesFallback(configuredCwd, args, signal) {
|
|
253
|
+
signal?.throwIfAborted();
|
|
254
|
+
const root = await jailRoot(configuredCwd, args);
|
|
255
|
+
const edits = collectEdits(args);
|
|
256
|
+
if (edits.length === 0)
|
|
257
|
+
throw new Error("edit requires path+oldText+newText or edits[]");
|
|
258
|
+
if (edits.length > MAX_EDITS)
|
|
259
|
+
throw new Error(`edit exceeds max ${MAX_EDITS} replacements`);
|
|
260
|
+
// Phase 1: resolve, read, and compute every rewrite before any write.
|
|
261
|
+
const plan = new Map();
|
|
262
|
+
const applied = [];
|
|
263
|
+
for (const edit of edits) {
|
|
264
|
+
signal?.throwIfAborted();
|
|
265
|
+
const { abs, display } = await jailPath(root, edit.path);
|
|
266
|
+
let entry = plan.get(display);
|
|
267
|
+
if (!entry) {
|
|
268
|
+
const original = await readFileCapped(abs, display, signal);
|
|
269
|
+
entry = { abs, display, original, rewritten: original };
|
|
270
|
+
plan.set(display, entry);
|
|
271
|
+
}
|
|
272
|
+
const first = entry.rewritten.indexOf(edit.oldText);
|
|
273
|
+
if (first === -1)
|
|
274
|
+
throw new Error("oldText must match exactly once (found 0)");
|
|
275
|
+
if (entry.rewritten.indexOf(edit.oldText, first + edit.oldText.length) !== -1) {
|
|
276
|
+
throw new Error("oldText must match exactly once (found 2+)");
|
|
277
|
+
}
|
|
278
|
+
const line = matchLine(entry.rewritten, first);
|
|
279
|
+
const next = entry.rewritten.slice(0, first) + edit.newText + entry.rewritten.slice(first + edit.oldText.length);
|
|
280
|
+
const changed = next !== entry.rewritten;
|
|
281
|
+
entry.rewritten = next;
|
|
282
|
+
const removed = diffLines(edit.oldText);
|
|
283
|
+
const added = diffLines(edit.newText);
|
|
284
|
+
applied.push({
|
|
285
|
+
path: display,
|
|
286
|
+
changed,
|
|
287
|
+
line,
|
|
288
|
+
removed: removed.lines,
|
|
289
|
+
added: added.lines,
|
|
290
|
+
...(removed.more || added.more ? { truncated: true } : {}),
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
// Phase 2: every edit validated — write each touched file once.
|
|
294
|
+
for (const entry of plan.values()) {
|
|
295
|
+
if (entry.rewritten === entry.original)
|
|
296
|
+
continue;
|
|
297
|
+
signal?.throwIfAborted();
|
|
298
|
+
await writeFile(entry.abs, entry.rewritten).catch(() => {
|
|
299
|
+
throw new Error(`cannot write ${entry.display}`);
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
return { ok: true, changed: applied.filter((row) => row.changed).length, edits: applied };
|
|
303
|
+
}
|
|
@@ -8,8 +8,12 @@
|
|
|
8
8
|
* 1. `ASGREP_CODEMODE_NAPI_PATH` (dev override)
|
|
9
9
|
* 2. `@ast-sgrep/<platform>/ast-sgrep-codemode.node` via launcher (release install)
|
|
10
10
|
* 3. Local `extension/native/` / cargo `target/release` (dev builds)
|
|
11
|
+
*
|
|
12
|
+
* Severed-lane policy: the in-process binding requires an exact native
|
|
13
|
+
* generation (an ABI mismatch risks crashes, not errors). Older launchers
|
|
14
|
+
* serve Code Mode over CLI sticky/batch/spawn instead — same tools, slower.
|
|
11
15
|
*/
|
|
12
|
-
export declare const CODEMODE_BINDING_VERSION = "2.
|
|
16
|
+
export declare const CODEMODE_BINDING_VERSION = "2.5.2";
|
|
13
17
|
export type NativeSessionConfig = {
|
|
14
18
|
root?: string;
|
|
15
19
|
indexPath?: string;
|
package/dist/codemode/native.js
CHANGED
|
@@ -8,12 +8,16 @@
|
|
|
8
8
|
* 1. `ASGREP_CODEMODE_NAPI_PATH` (dev override)
|
|
9
9
|
* 2. `@ast-sgrep/<platform>/ast-sgrep-codemode.node` via launcher (release install)
|
|
10
10
|
* 3. Local `extension/native/` / cargo `target/release` (dev builds)
|
|
11
|
+
*
|
|
12
|
+
* Severed-lane policy: the in-process binding requires an exact native
|
|
13
|
+
* generation (an ABI mismatch risks crashes, not errors). Older launchers
|
|
14
|
+
* serve Code Mode over CLI sticky/batch/spawn instead — same tools, slower.
|
|
11
15
|
*/
|
|
12
16
|
import { createRequire } from "node:module";
|
|
13
17
|
import { existsSync } from "node:fs";
|
|
14
18
|
import { dirname, join } from "node:path";
|
|
15
19
|
import { fileURLToPath } from "node:url";
|
|
16
|
-
export const CODEMODE_BINDING_VERSION = "2.
|
|
20
|
+
export const CODEMODE_BINDING_VERSION = "2.5.2";
|
|
17
21
|
let cached;
|
|
18
22
|
function platformTriple() {
|
|
19
23
|
const { platform, arch } = process;
|
package/dist/host/tools.d.ts
CHANGED
|
@@ -13,7 +13,7 @@ export declare const DEFAULT_LIMIT = 8;
|
|
|
13
13
|
* session, or a host that drops the built-ins still gets them — but they are
|
|
14
14
|
* left out of the active set when the host already provides read+edit. Pi only
|
|
15
15
|
* sends ACTIVE tools (schema, snippet, guidelines) to the model, so this is the
|
|
16
|
-
* difference between ~
|
|
16
|
+
* difference between ~271 tokens per request and nothing.
|
|
17
17
|
*
|
|
18
18
|
* ASGREP_KEEP_FILE_TOOLS=1 pins them active regardless.
|
|
19
19
|
*/
|
package/dist/host/tools.js
CHANGED
|
@@ -4,22 +4,21 @@ import { Type } from "typebox";
|
|
|
4
4
|
import { createAsgrepConnector, runCodemode, runNativeBatch, runBatchViaStdin, CODEMODE_TYPES_FOR_MODEL, NativeSessionPool, argvFor, asEnvelope, applyQueryScope, warmCodemodeSandbox, resetCodemodeSandboxForTests, isClosedWorkerError, } from "../codemode/index.js";
|
|
5
5
|
import { AstSgrepRuntime, FreshnessCoordinator, RuntimeError } from "../runtime/runtime.js";
|
|
6
6
|
import { RESOLVED_ROOT } from "../runtime/types.js";
|
|
7
|
-
import { ASGREP_PROMPT_GUIDELINES, ASGREP_PROMPT_SNIPPET, formatCodemodeResult, } from "../ui/present.js";
|
|
7
|
+
import { ASGREP_PROMPT_GUIDELINES, ASGREP_PROMPT_GUIDELINES_HOST_FILES, ASGREP_PROMPT_SNIPPET, formatCodemodeResult, } from "../ui/present.js";
|
|
8
8
|
import { EMPTY_CALL, renderAsgrepResult } from "../ui/card.js";
|
|
9
9
|
import { bounded, errorDetails, failure, isFreshnessTimeout, extractInPath, report, success, } from "./results.js";
|
|
10
10
|
export const DEFAULT_LIMIT = 8;
|
|
11
11
|
const MAX_LIMIT = 100;
|
|
12
12
|
const MAX_EXCERPT_LINES = 100;
|
|
13
13
|
const searchParameters = Type.Object({
|
|
14
|
-
query: Type.String({ maxLength: 4_096
|
|
14
|
+
query: Type.String({ maxLength: 4_096 }),
|
|
15
15
|
mode: Type.Optional(Type.Unsafe({
|
|
16
16
|
type: "string",
|
|
17
17
|
enum: ["natural", "pattern", "defs", "callers", "chain", "semantic", "word", "literal", "regex", "imports"],
|
|
18
18
|
default: "natural",
|
|
19
|
-
description: "Search strategy",
|
|
20
19
|
})),
|
|
21
20
|
limit: Type.Optional(Type.Integer({ default: DEFAULT_LIMIT })),
|
|
22
|
-
excerptLines: Type.Optional(Type.Integer({ default: 0
|
|
21
|
+
excerptLines: Type.Optional(Type.Integer({ default: 0 })),
|
|
23
22
|
in: Type.Optional(Type.String({ maxLength: 512, description: "Bound to a directory or glob" })),
|
|
24
23
|
lang: Type.Optional(Type.String({ maxLength: 32, description: "Language filter (rs, ts, py)" })),
|
|
25
24
|
}, { additionalProperties: false });
|
|
@@ -27,9 +26,9 @@ const indexParameters = Type.Object({
|
|
|
27
26
|
force: Type.Optional(Type.Boolean({ default: false, description: "Rebuild the index from scratch" })),
|
|
28
27
|
}, { additionalProperties: false });
|
|
29
28
|
const editParameters = Type.Object({
|
|
30
|
-
path: Type.Optional(Type.String({ maxLength: 512
|
|
29
|
+
path: Type.Optional(Type.String({ maxLength: 512 })),
|
|
31
30
|
oldText: Type.Optional(Type.String({ description: "Exact text to replace (must match once)" })),
|
|
32
|
-
newText: Type.Optional(Type.String(
|
|
31
|
+
newText: Type.Optional(Type.String()),
|
|
33
32
|
edits: Type.Optional(Type.Array(Type.Object({
|
|
34
33
|
path: Type.Optional(Type.String({ minLength: 1, maxLength: 512 })),
|
|
35
34
|
oldText: Type.String({ minLength: 1 }),
|
|
@@ -37,7 +36,7 @@ const editParameters = Type.Object({
|
|
|
37
36
|
}), { maxItems: 64, description: "Multi-edit entries; top-level path is the default" })),
|
|
38
37
|
}, { additionalProperties: false });
|
|
39
38
|
const readParameters = Type.Object({
|
|
40
|
-
path: Type.Optional(Type.String({ maxLength: 512
|
|
39
|
+
path: Type.Optional(Type.String({ maxLength: 512 })),
|
|
41
40
|
ref: Type.Optional(Type.String({ description: "Hit ref path#L12-L40" })),
|
|
42
41
|
refs: Type.Optional(Type.Array(Type.String(), { maxItems: 24, description: "Several refs in one call" })),
|
|
43
42
|
start: Type.Optional(Type.Integer()),
|
|
@@ -49,7 +48,7 @@ const codemodeParameters = Type.Object({
|
|
|
49
48
|
code: Type.String({
|
|
50
49
|
minLength: 1,
|
|
51
50
|
maxLength: 32_000,
|
|
52
|
-
description: "JavaScript: async () => { ... } or a bare body with return.
|
|
51
|
+
description: "JavaScript: async () => { ... } or a bare body with return.",
|
|
53
52
|
}),
|
|
54
53
|
timeoutMs: Type.Optional(Type.Integer({ description: "Timeout ms (default 30000)" })),
|
|
55
54
|
}, { additionalProperties: false });
|
|
@@ -84,7 +83,7 @@ function withSearchLang(argv, lang) {
|
|
|
84
83
|
* session, or a host that drops the built-ins still gets them — but they are
|
|
85
84
|
* left out of the active set when the host already provides read+edit. Pi only
|
|
86
85
|
* sends ACTIVE tools (schema, snippet, guidelines) to the model, so this is the
|
|
87
|
-
* difference between ~
|
|
86
|
+
* difference between ~271 tokens per request and nothing.
|
|
88
87
|
*
|
|
89
88
|
* ASGREP_KEEP_FILE_TOOLS=1 pins them active regardless.
|
|
90
89
|
*/
|
|
@@ -518,10 +517,10 @@ export function registerAstSgrepTools(pi, runtime = new AstSgrepRuntime(pi), fre
|
|
|
518
517
|
name: "asgrep",
|
|
519
518
|
label: "asgrep",
|
|
520
519
|
promptSnippet: ASGREP_PROMPT_SNIPPET,
|
|
521
|
-
promptGuidelines: [...ASGREP_PROMPT_GUIDELINES],
|
|
520
|
+
promptGuidelines: [...(hostProvidesFileTools(pi) ? ASGREP_PROMPT_GUIDELINES_HOST_FILES : ASGREP_PROMPT_GUIDELINES)],
|
|
522
521
|
description: [
|
|
523
|
-
"Code search
|
|
524
|
-
"
|
|
522
|
+
"Code search: use it for any code lookup instead of grep.",
|
|
523
|
+
"The returned value is your result.",
|
|
525
524
|
CODEMODE_TYPES_FOR_MODEL,
|
|
526
525
|
"Example: async () => (await asgrep.search(\"auth\", { limit: 5 })).hits",
|
|
527
526
|
].join("\n"),
|
|
@@ -608,7 +607,7 @@ export function registerAstSgrepTools(pi, runtime = new AstSgrepRuntime(pi), fre
|
|
|
608
607
|
pi.registerTool({
|
|
609
608
|
name: "asgrep_search",
|
|
610
609
|
label: "asgrep search",
|
|
611
|
-
promptSnippet: "One-shot
|
|
610
|
+
promptSnippet: "One-shot search",
|
|
612
611
|
description: "One-shot search. Use asgrep (Code Mode) for anything multi-step, parallel, or filtered.",
|
|
613
612
|
parameters: searchParameters,
|
|
614
613
|
renderShell: "self",
|
|
@@ -675,7 +674,7 @@ export function registerAstSgrepTools(pi, runtime = new AstSgrepRuntime(pi), fre
|
|
|
675
674
|
name: "asgrep_edit",
|
|
676
675
|
label: "asgrep edit",
|
|
677
676
|
promptSnippet: "Exact-string edit",
|
|
678
|
-
description: "Edit by exact-string replace
|
|
677
|
+
description: "Edit by exact-string replace. edits[] applies many atomically.",
|
|
679
678
|
parameters: editParameters,
|
|
680
679
|
renderShell: "self",
|
|
681
680
|
renderCall() {
|
|
@@ -735,7 +734,7 @@ export function registerAstSgrepTools(pi, runtime = new AstSgrepRuntime(pi), fre
|
|
|
735
734
|
name: "asgrep_index",
|
|
736
735
|
label: "asgrep index",
|
|
737
736
|
promptSnippet: "Build or rebuild the index",
|
|
738
|
-
description: "Build or rebuild the index
|
|
737
|
+
description: "Build or rebuild the index.",
|
|
739
738
|
parameters: indexParameters,
|
|
740
739
|
renderShell: "self",
|
|
741
740
|
renderCall() {
|
package/dist/runtime/runtime.js
CHANGED
|
@@ -47,10 +47,26 @@ function getBinary(config, env, resolver) {
|
|
|
47
47
|
return binary;
|
|
48
48
|
}
|
|
49
49
|
function byteLength(value) { return Buffer.byteLength(value, "utf8"); }
|
|
50
|
-
/**
|
|
50
|
+
/**
|
|
51
|
+
* Binary-generation check. The extension tracks main ahead of official
|
|
52
|
+
* launcher releases (severed lane), so any binary of the same major is
|
|
53
|
+
* accepted: features gate on envelope content and the index negotiates its
|
|
54
|
+
* schema with the binary. A different major or an unparseable version still
|
|
55
|
+
* fails closed, as does a missing version when identity is required.
|
|
56
|
+
*/
|
|
57
|
+
function binaryMajor(version) {
|
|
58
|
+
if (typeof version !== "string")
|
|
59
|
+
return undefined;
|
|
60
|
+
const major = version.match(/^(\d+)\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/)?.[1];
|
|
61
|
+
return major === undefined ? undefined : Number(major);
|
|
62
|
+
}
|
|
63
|
+
// RUNTIME_VERSION is contract-pinned semver, so this is total. A malformed
|
|
64
|
+
// constant (-1) would reject every binary — fail closed.
|
|
65
|
+
const RUNTIME_MAJOR = binaryMajor(RUNTIME_VERSION) ?? -1;
|
|
66
|
+
/** Present-field version checks. Pass `requireIdentity` for version --json. */
|
|
51
67
|
function assertVersionTriple(envelope, requireIdentity = false) {
|
|
52
68
|
// Compound guards (same short-circuit as nested if): check only when required or field present.
|
|
53
|
-
if ((requireIdentity || envelope.version !== undefined) && envelope.version !==
|
|
69
|
+
if ((requireIdentity || envelope.version !== undefined) && binaryMajor(envelope.version) !== RUNTIME_MAJOR) {
|
|
54
70
|
throw new RuntimeError("VERSION_MISMATCH", "ast-sgrep binary version does not match the extension", { expected: RUNTIME_VERSION, actual: envelope.version });
|
|
55
71
|
}
|
|
56
72
|
if ((requireIdentity || envelope.machine_schema_version !== undefined) && envelope.machine_schema_version !== MACHINE_SCHEMA_VERSION) {
|
package/dist/runtime/types.d.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Shared leaf module: version constants, wire types, RuntimeError.
|
|
3
3
|
* Imports nothing from sibling modules — any file may depend on it.
|
|
4
4
|
*/
|
|
5
|
-
export declare const RUNTIME_VERSION = "2.
|
|
5
|
+
export declare const RUNTIME_VERSION = "2.5.2";
|
|
6
6
|
export declare const MACHINE_SCHEMA_VERSION = "1.0.0";
|
|
7
7
|
export declare const CONFIG_SCHEMA_VERSION: 1;
|
|
8
8
|
/** Index format this release ships. Must equal INDEX_SCHEMA_VERSION in crates/ast-sgrep-core (check-contract gates it). */
|
package/dist/runtime/types.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Shared leaf module: version constants, wire types, RuntimeError.
|
|
3
3
|
* Imports nothing from sibling modules — any file may depend on it.
|
|
4
4
|
*/
|
|
5
|
-
export const RUNTIME_VERSION = "2.
|
|
5
|
+
export const RUNTIME_VERSION = "2.5.2";
|
|
6
6
|
export const MACHINE_SCHEMA_VERSION = "1.0.0";
|
|
7
7
|
export const CONFIG_SCHEMA_VERSION = 1;
|
|
8
8
|
/** Index format this release ships. Must equal INDEX_SCHEMA_VERSION in crates/ast-sgrep-core (check-contract gates it). */
|
package/dist/ui/present.d.ts
CHANGED
|
@@ -27,8 +27,16 @@ export type EnvelopeLike = {
|
|
|
27
27
|
backend?: unknown;
|
|
28
28
|
[key: string]: unknown;
|
|
29
29
|
};
|
|
30
|
-
export declare const ASGREP_PROMPT_SNIPPET = "Code search by intent, symbol, defs, callers, pattern (
|
|
31
|
-
export declare const ASGREP_PROMPT_GUIDELINES: readonly ["Any code lookup
|
|
30
|
+
export declare const ASGREP_PROMPT_SNIPPET = "Code search by intent, symbol, defs, callers, pattern (use without being asked)";
|
|
31
|
+
export declare const ASGREP_PROMPT_GUIDELINES: readonly ["Any code lookup: call asgrep first; read code with asgrep_read, change it with asgrep_edit.", "Compose in Code Mode (search/defs/read, Promise.all, small shaped value); grep only exact strings/filenames. Bound with in:\"path\"; on 0 hits use suggested_next."];
|
|
32
|
+
/**
|
|
33
|
+
* Guideline variant for hosts that already ship read/edit built in: our
|
|
34
|
+
* one-shot file tools are inactive there, so naming them would point the
|
|
35
|
+
* model at tools it cannot see. The host's own builtins describe
|
|
36
|
+
* themselves; this keeps only the asgrep-first routing plus the shared
|
|
37
|
+
* composition guideline.
|
|
38
|
+
*/
|
|
39
|
+
export declare const ASGREP_PROMPT_GUIDELINES_HOST_FILES: readonly ["Any code lookup: call asgrep first.", "Compose in Code Mode (search/defs/read, Promise.all, small shaped value); grep only exact strings/filenames. Bound with in:\"path\"; on 0 hits use suggested_next."];
|
|
32
40
|
export declare function paint(theme: PresentTheme | undefined, role: string, text: string, bold?: boolean): string;
|
|
33
41
|
export declare function hitLocation(hit: HitLike): string;
|
|
34
42
|
export declare function hitLabel(hit: HitLike): string;
|
package/dist/ui/present.js
CHANGED
|
@@ -1,8 +1,19 @@
|
|
|
1
1
|
/** Neat asgrep tool chrome for the Pi TUI and the model-visible content. */
|
|
2
|
-
export const ASGREP_PROMPT_SNIPPET = "Code search by intent, symbol, defs, callers, pattern (
|
|
2
|
+
export const ASGREP_PROMPT_SNIPPET = "Code search by intent, symbol, defs, callers, pattern (use without being asked)";
|
|
3
3
|
export const ASGREP_PROMPT_GUIDELINES = [
|
|
4
|
-
"Any code lookup
|
|
5
|
-
"Compose in Code Mode (search/defs/read
|
|
4
|
+
"Any code lookup: call asgrep first; read code with asgrep_read, change it with asgrep_edit.",
|
|
5
|
+
"Compose in Code Mode (search/defs/read, Promise.all, small shaped value); grep only exact strings/filenames. Bound with in:\"path\"; on 0 hits use suggested_next.",
|
|
6
|
+
];
|
|
7
|
+
/**
|
|
8
|
+
* Guideline variant for hosts that already ship read/edit built in: our
|
|
9
|
+
* one-shot file tools are inactive there, so naming them would point the
|
|
10
|
+
* model at tools it cannot see. The host's own builtins describe
|
|
11
|
+
* themselves; this keeps only the asgrep-first routing plus the shared
|
|
12
|
+
* composition guideline.
|
|
13
|
+
*/
|
|
14
|
+
export const ASGREP_PROMPT_GUIDELINES_HOST_FILES = [
|
|
15
|
+
"Any code lookup: call asgrep first.",
|
|
16
|
+
ASGREP_PROMPT_GUIDELINES[1],
|
|
6
17
|
];
|
|
7
18
|
export function paint(theme, role, text, bold = false) {
|
|
8
19
|
const body = bold && theme ? theme.bold(text) : text;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-ast-sgrep",
|
|
3
|
-
"version": "2.2
|
|
3
|
+
"version": "2.5.2",
|
|
4
4
|
"description": "Native Code Mode, structural, graph, and semantic code search for Pi",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"scripts": {
|
|
54
54
|
"build": "tsc -p tsconfig.json && node scripts/copy-guest-worker.mjs",
|
|
55
55
|
"build:native": "cargo build -p ast-sgrep-codemode-napi --release && node ./scripts/copy-native.mjs",
|
|
56
|
-
"test": "ASGREP_CODEMODE_BACKEND=cli node --import tsx --test ../../../tests/pi/extension/code-mode.test.ts ../../../tests/pi/extension/codemode.test.ts ../../../tests/pi/extension/commands.test.ts ../../../tests/pi/extension/present.test.ts ../../../tests/pi/extension/runtime.test.ts ../../../tests/pi/extension/security.test.ts ../../../tests/pi/extension/session-pool.test.ts ../../../tests/pi/extension/skill-workflow.test.ts ../../../tests/pi/extension/sqlite.test.ts ../../../tests/pi/extension/../../../tests/pi/extension/token-budget.test.ts ../../../tests/pi/extension/tools.test.ts",
|
|
56
|
+
"test": "ASGREP_CODEMODE_BACKEND=cli node --import tsx --test ../../../tests/pi/extension/code-mode.test.ts ../../../tests/pi/extension/codemode.test.ts ../../../tests/pi/extension/codemode-fallback.test.ts ../../../tests/pi/extension/commands.test.ts ../../../tests/pi/extension/present.test.ts ../../../tests/pi/extension/runtime.test.ts ../../../tests/pi/extension/security.test.ts ../../../tests/pi/extension/session-pool.test.ts ../../../tests/pi/extension/skill-workflow.test.ts ../../../tests/pi/extension/sqlite.test.ts ../../../tests/pi/extension/../../../tests/pi/extension/token-budget.test.ts ../../../tests/pi/extension/tools.test.ts",
|
|
57
57
|
"test:native": "node --import tsx --test ../../../tests/pi/extension/native-inprocess.test.ts",
|
|
58
58
|
"test:all": "npm test && npm run test:native",
|
|
59
59
|
"prepack": "npm run build",
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
"node": ">=22.19.0"
|
|
64
64
|
},
|
|
65
65
|
"dependencies": {
|
|
66
|
-
"ast-sgrep": ">=2.
|
|
66
|
+
"ast-sgrep": ">=2.0.0 <3",
|
|
67
67
|
"typebox": "^1.0.0"
|
|
68
68
|
},
|
|
69
69
|
"peerDependencies": {
|