pi-ast-sgrep 1.3.2 → 2.0.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.
@@ -0,0 +1,320 @@
1
+ /**
2
+ * Same-tick call coalescing + typed batch / sticky-serve dispatch.
3
+ *
4
+ * Amdahl: serial cost is process spawn + SQLite open. Sticky serve kills spawn
5
+ * for the whole Code Mode program; batch coalescing kills it per Promise.all wave.
6
+ */
7
+ import { mkdtemp, writeFile, rm } from "node:fs/promises";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ const MAX_WAVE = 32;
11
+ const MUTATING_TOOLS = new Set(["index_repo"]);
12
+ const abortError = () => Object.assign(new Error("codemode aborted"), { name: "AbortError" });
13
+ function rejectWave(wave, cause) {
14
+ for (const item of wave)
15
+ item.reject(cause);
16
+ }
17
+ function sharedBatchOptions(wave) {
18
+ const signal = wave[0]?.options?.signal;
19
+ return signal && wave.every((item) => item.options?.signal === signal) ? { signal } : undefined;
20
+ }
21
+ function isSharedAbort(cause, options) {
22
+ return options?.signal !== undefined
23
+ && (options.signal.aborted || (cause instanceof Error && cause.name === "AbortError"));
24
+ }
25
+ /**
26
+ * Wraps a host so Promise.all([asgrep.search, asgrep.defs, …]) collapses into
27
+ * one microtask wave. Prefers sticky serve → one-shot batch → overlapped spawn.
28
+ */
29
+ export function createCodemodeDispatcher(host) {
30
+ let pending = [];
31
+ let scheduled = false;
32
+ let stats = emptyStats();
33
+ const flush = async () => {
34
+ const wave = pending.filter((item) => !item.settled);
35
+ pending = [];
36
+ scheduled = false;
37
+ if (wave.length === 0)
38
+ return;
39
+ stats.waves += 1;
40
+ stats.calls += wave.length;
41
+ const waveStarted = Date.now();
42
+ try {
43
+ if (wave.length === 1) {
44
+ await settleOne(host, wave[0], stats);
45
+ return;
46
+ }
47
+ // Chunk oversized waves (batch max = 32).
48
+ for (let offset = 0; offset < wave.length; offset += MAX_WAVE) {
49
+ const chunk = wave.slice(offset, offset + MAX_WAVE).filter((item) => !item.settled);
50
+ if (chunk.length === 0)
51
+ continue;
52
+ await settleWave(host, chunk, stats);
53
+ }
54
+ }
55
+ finally {
56
+ stats.wallMs += Date.now() - waveStarted;
57
+ }
58
+ };
59
+ const enqueue = (item) => new Promise((resolve, reject) => {
60
+ const signal = item.options?.signal;
61
+ const cleanup = () => signal?.removeEventListener("abort", onAbort);
62
+ item.resolve = (value) => {
63
+ if (item.settled)
64
+ return;
65
+ item.settled = true;
66
+ cleanup();
67
+ resolve(value);
68
+ };
69
+ item.reject = (reason) => {
70
+ if (item.settled)
71
+ return;
72
+ item.settled = true;
73
+ cleanup();
74
+ reject(reason);
75
+ };
76
+ const onAbort = () => item.reject(abortError());
77
+ if (signal?.aborted) {
78
+ item.reject(abortError());
79
+ return;
80
+ }
81
+ signal?.addEventListener("abort", onAbort, { once: true });
82
+ pending.push(item);
83
+ if (!scheduled) {
84
+ scheduled = true;
85
+ queueMicrotask(() => {
86
+ void flush();
87
+ });
88
+ }
89
+ });
90
+ const dispatchHost = {
91
+ call(tool, args, context, options) {
92
+ const item = {
93
+ tool,
94
+ args,
95
+ context,
96
+ settled: false,
97
+ resolve: () => undefined,
98
+ reject: () => undefined,
99
+ };
100
+ if (options)
101
+ item.options = options;
102
+ return enqueue(item);
103
+ },
104
+ };
105
+ return {
106
+ host: dispatchHost,
107
+ stats: () => ({ ...stats }),
108
+ resetStats: () => {
109
+ stats = emptyStats();
110
+ },
111
+ };
112
+ }
113
+ async function settleOne(host, item, stats) {
114
+ try {
115
+ if (host.sticky) {
116
+ stats.stickyCalls += 1;
117
+ item.resolve(await host.sticky.call(item.tool, item.args, item.options));
118
+ return;
119
+ }
120
+ // N=1 without sticky: direct CLI (batch-of-1 is tempfile + protocol for no gain).
121
+ const args = argvFor(item.tool, item.args);
122
+ item.resolve(await host.run(args, item.context, item.options));
123
+ }
124
+ catch (err) {
125
+ item.reject(err);
126
+ }
127
+ }
128
+ async function settleWave(host, wave, stats) {
129
+ if (host.sticky) {
130
+ const transportOptions = sharedBatchOptions(wave);
131
+ try {
132
+ const calls = wave.map((item, index) => ({
133
+ id: String(index),
134
+ tool: item.tool,
135
+ args: item.args,
136
+ }));
137
+ const batch = await host.sticky.batch(calls, transportOptions);
138
+ stats.stickyCalls += wave.length;
139
+ settleFromBatch(wave, batch);
140
+ return;
141
+ }
142
+ catch (cause) {
143
+ if (isSharedAbort(cause, transportOptions)) {
144
+ rejectWave(wave, cause);
145
+ return;
146
+ }
147
+ if (wave.some((item) => MUTATING_TOOLS.has(item.tool))) {
148
+ // The worker may have committed a mutation before its transport died.
149
+ // Replaying the wave through another transport would be ambiguous.
150
+ rejectWave(wave, cause);
151
+ return;
152
+ }
153
+ // Sticky died mid-program — fall through to one-shot / spawn.
154
+ }
155
+ }
156
+ const batchWave = wave.filter((item) => !item.settled);
157
+ if (batchWave.length === 0)
158
+ return;
159
+ if (host.runBatch) {
160
+ const transportOptions = sharedBatchOptions(batchWave);
161
+ try {
162
+ const calls = batchWave.map((item, index) => ({
163
+ id: String(index),
164
+ tool: item.tool,
165
+ args: item.args,
166
+ }));
167
+ const batch = await host.runBatch(calls, batchWave[0].context, transportOptions);
168
+ stats.batchedCalls += batchWave.length;
169
+ settleFromBatch(batchWave, batch);
170
+ return;
171
+ }
172
+ catch (cause) {
173
+ if (isSharedAbort(cause, transportOptions)) {
174
+ rejectWave(batchWave, cause);
175
+ return;
176
+ }
177
+ if (batchWave.some((item) => MUTATING_TOOLS.has(item.tool))) {
178
+ rejectWave(batchWave, cause);
179
+ return;
180
+ }
181
+ // Transport failure only — do NOT re-run when per-call ok:false.
182
+ }
183
+ }
184
+ const spawnWave = batchWave.filter((item) => !item.settled);
185
+ stats.parallelSpawnCalls += spawnWave.length;
186
+ await Promise.all(spawnWave.map(async (item) => {
187
+ try {
188
+ const args = argvFor(item.tool, item.args);
189
+ item.resolve(await host.run(args, item.context, item.options));
190
+ }
191
+ catch (err) {
192
+ item.reject(err);
193
+ }
194
+ }));
195
+ }
196
+ function settleFromBatch(wave, batch) {
197
+ const byId = new Map(batch.results.map((r) => [r.id, r]));
198
+ for (let i = 0; i < wave.length; i++) {
199
+ const item = wave[i];
200
+ const result = byId.get(String(i));
201
+ if (!result) {
202
+ item.reject(new Error(`codemode-batch missing result id=${i}`));
203
+ continue;
204
+ }
205
+ if (!result.ok) {
206
+ item.reject(new Error(result.error ?? `codemode call ${i} failed`));
207
+ continue;
208
+ }
209
+ item.resolve(asEnvelope(result.value, item.tool));
210
+ }
211
+ }
212
+ function emptyStats() {
213
+ return {
214
+ waves: 0,
215
+ calls: 0,
216
+ batchedCalls: 0,
217
+ parallelSpawnCalls: 0,
218
+ stickyCalls: 0,
219
+ wallMs: 0,
220
+ };
221
+ }
222
+ const ARGV_SPEC = {
223
+ search: { form: "capsule", key: "query" },
224
+ semantic: { form: "semantic" },
225
+ chain: { form: "chain" },
226
+ defs: { form: "capsule", key: "symbol", prefix: "defs" },
227
+ callers: { form: "capsule", key: "symbol", prefix: "callers" },
228
+ imports: { form: "capsule", key: "module", prefix: "imports" },
229
+ index_status: { form: "status" },
230
+ index_repo: { form: "index_repo" },
231
+ };
232
+ function argStr(args, key) {
233
+ return String(args[key] ?? "");
234
+ }
235
+ export function argvFor(tool, args) {
236
+ const spec = ARGV_SPEC[tool];
237
+ if (!spec)
238
+ throw new Error(`codemode tool has no direct CLI fallback: ${tool}`);
239
+ if (spec.form === "status")
240
+ return ["status", ".", "--json"];
241
+ if (spec.form === "index_repo") {
242
+ const command = args.force === true ? "reindex" : "index";
243
+ const paths = Array.isArray(args.paths)
244
+ ? args.paths.filter((path) => typeof path === "string")
245
+ : [];
246
+ return [command, ".", "--json", ...paths.flatMap((path) => ["--path", path])];
247
+ }
248
+ const limit = num(args.limit, 8);
249
+ if (spec.form === "chain") {
250
+ return ["chain", argStr(args, "query"), ".", "--json", "--limit", String(limit)];
251
+ }
252
+ const excerpt = num(args.excerpt_lines ?? args.excerptLines, 0);
253
+ const capsule = ["--json", "--format", "agent-capsule", "--limit", String(limit), "--excerpt-lines", String(excerpt)];
254
+ if (spec.form === "semantic") {
255
+ return ["semantic", argStr(args, "query"), ".", ...capsule];
256
+ }
257
+ // capsule (+ optional prefix for defs/callers/imports)
258
+ const raw = argStr(args, spec.key);
259
+ const token = spec.prefix ? `${spec.prefix}:${raw}` : raw;
260
+ return [...capsule, token, "."];
261
+ }
262
+ function num(value, fallback) {
263
+ if (typeof value === "number" && Number.isFinite(value))
264
+ return Math.trunc(value);
265
+ return fallback;
266
+ }
267
+ export function asEnvelope(value, command) {
268
+ if (value &&
269
+ typeof value === "object" &&
270
+ value.tool === "asgrep" &&
271
+ typeof value.ok === "boolean") {
272
+ return value;
273
+ }
274
+ const record = value && typeof value === "object" ? value : { value };
275
+ // Overrides AFTER spread so tool/ok/schema cannot be clobbered by payload fields.
276
+ return {
277
+ ...record,
278
+ tool: "asgrep",
279
+ ...(command ? { command } : {}),
280
+ schema_version: "1.0.0",
281
+ ok: true,
282
+ };
283
+ }
284
+ /** One-shot batch via stdin (no tempfile) when spawn-with-stdin is available. */
285
+ export async function runNativeBatch(run, calls, context, options, writeBatch) {
286
+ const body = JSON.stringify({
287
+ root: context.cwd,
288
+ // Auto/serial warm by default — N parallel SQLite opens are usually slower.
289
+ parallel_mode: "auto",
290
+ calls,
291
+ });
292
+ if (writeBatch) {
293
+ const envelope = await writeBatch(body, context, options);
294
+ return envelopeToBatch(envelope);
295
+ }
296
+ // Fallback: tempfile + pi.exec (no stdin).
297
+ const dir = await mkdtemp(join(tmpdir(), "asgrep-codemode-"));
298
+ const requestsPath = join(dir, "requests.json");
299
+ try {
300
+ await writeFile(requestsPath, body, "utf8");
301
+ const envelope = await run(["codemode-batch", "--requests", requestsPath, "--json"], context, options);
302
+ return envelopeToBatch(envelope);
303
+ }
304
+ finally {
305
+ await rm(dir, { recursive: true, force: true }).catch(() => undefined);
306
+ }
307
+ }
308
+ function envelopeToBatch(envelope) {
309
+ const results = Array.isArray(envelope.results)
310
+ ? envelope.results
311
+ : [];
312
+ const out = { results };
313
+ if (typeof envelope.mode === "string")
314
+ out.mode = envelope.mode;
315
+ if (typeof envelope.wall_ms === "number")
316
+ out.wall_ms = envelope.wall_ms;
317
+ if (typeof envelope.all_ok === "boolean")
318
+ out.all_ok = envelope.all_ok;
319
+ return out;
320
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Code Mode for ast-sgrep in Pi.
3
+ *
4
+ * Pattern (Cloudflare / Anthropic PTC / OpenAI PTC / OpenCode):
5
+ * the model writes JavaScript that calls typed `asgrep.*` methods. Intermediate
6
+ * results stay in the program; only the shaped return value re-enters the model
7
+ * context. Parallel calls use `Promise.all` against one warm in-process session.
8
+ *
9
+ * Code Mode and MCP are sibling front ends on the same core — pick one per
10
+ * client. They never import each other. Do not install both for the same agent.
11
+ */
12
+ export { createAsgrepConnector, type AsgrepConnector, type ConnectorHost, type DispatchSurface, type ConnectorBundle, } from "./connector.js";
13
+ export { runCodemode, normalizeCode, type CodemodeRunResult, type CodemodeRunSuccess, type CodemodeRunFailure } from "./runner.js";
14
+ export { CODEMODE_TYPES_FOR_MODEL, type SearchArgs, type ChainArgs } from "./types.js";
15
+ export { createCodemodeDispatcher, runNativeBatch, argvFor, asEnvelope, type DispatchStats, type BatchCapableHost, type StickyWorker, type BatchResult, } from "./dispatch.js";
16
+ export { startStickyWorker, runBatchViaStdin } from "./worker.js";
17
+ export { NativeSessionPool, sharedNativePool } from "./session-pool.js";
18
+ export { loadCodemodeNative, nativeAvailable, resetNativeCache, type CodemodeNativeBinding, type NativeSession, } from "./native.js";
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Code Mode for ast-sgrep in Pi.
3
+ *
4
+ * Pattern (Cloudflare / Anthropic PTC / OpenAI PTC / OpenCode):
5
+ * the model writes JavaScript that calls typed `asgrep.*` methods. Intermediate
6
+ * results stay in the program; only the shaped return value re-enters the model
7
+ * context. Parallel calls use `Promise.all` against one warm in-process session.
8
+ *
9
+ * Code Mode and MCP are sibling front ends on the same core — pick one per
10
+ * client. They never import each other. Do not install both for the same agent.
11
+ */
12
+ export { createAsgrepConnector, } from "./connector.js";
13
+ export { runCodemode, normalizeCode } from "./runner.js";
14
+ export { CODEMODE_TYPES_FOR_MODEL } from "./types.js";
15
+ export { createCodemodeDispatcher, runNativeBatch, argvFor, asEnvelope, } from "./dispatch.js";
16
+ export { startStickyWorker, runBatchViaStdin } from "./worker.js";
17
+ export { NativeSessionPool, sharedNativePool } from "./session-pool.js";
18
+ export { loadCodemodeNative, nativeAvailable, resetNativeCache, } from "./native.js";
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Load the in-process Code Mode NAPI addon.
3
+ *
4
+ * Same model as MCP: Rust `CodeModeSession` runs inside the Node process.
5
+ * No `asgrep` CLI spawn on the hot path.
6
+ *
7
+ * Resolution order:
8
+ * 1. `ASGREP_CODEMODE_NAPI_PATH` (dev override)
9
+ * 2. `@ast-sgrep/<platform>/ast-sgrep-codemode.node` via launcher (release install)
10
+ * 3. Local `extension/native/` / cargo `target/release` (dev builds)
11
+ */
12
+ export declare const CODEMODE_BINDING_VERSION = "2.0.0";
13
+ export type NativeSessionConfig = {
14
+ root?: string;
15
+ indexPath?: string;
16
+ limit?: number;
17
+ useEmbed?: boolean;
18
+ };
19
+ export type NativeBatchCall = {
20
+ id: string;
21
+ tool: string;
22
+ args?: Record<string, unknown>;
23
+ };
24
+ export type NativeBatchResult = {
25
+ id: string;
26
+ ok: boolean;
27
+ value?: unknown;
28
+ error?: string;
29
+ };
30
+ export type NativeBatchResponse = {
31
+ allOk: boolean;
32
+ results: NativeBatchResult[];
33
+ callCount: number;
34
+ wallMs: number;
35
+ mode: string;
36
+ };
37
+ export type NativeSession = {
38
+ call(tool: string, args?: Record<string, unknown>, signal?: AbortSignal): Promise<unknown>;
39
+ /** Sync bounded metadata/symbol lookup; omitted on older addons. Throws if busy. */
40
+ callNow?(tool: string, args?: Record<string, unknown>): unknown;
41
+ batch(calls: NativeBatchCall[], signal?: AbortSignal): Promise<NativeBatchResponse>;
42
+ readonly callCount: number;
43
+ readonly root: string;
44
+ };
45
+ export type CodemodeNativeBinding = {
46
+ Session: new (config?: NativeSessionConfig) => NativeSession;
47
+ bindingVersion(): string;
48
+ isNative(): boolean;
49
+ asyncApiVersion(): number;
50
+ };
51
+ /** Load the NAPI binding once. Returns null if unavailable on this host. */
52
+ export declare function loadCodemodeNative(): CodemodeNativeBinding | null;
53
+ export declare function nativeAvailable(): boolean;
54
+ /** Reset cache (tests). */
55
+ export declare function resetNativeCache(): void;
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Load the in-process Code Mode NAPI addon.
3
+ *
4
+ * Same model as MCP: Rust `CodeModeSession` runs inside the Node process.
5
+ * No `asgrep` CLI spawn on the hot path.
6
+ *
7
+ * Resolution order:
8
+ * 1. `ASGREP_CODEMODE_NAPI_PATH` (dev override)
9
+ * 2. `@ast-sgrep/<platform>/ast-sgrep-codemode.node` via launcher (release install)
10
+ * 3. Local `extension/native/` / cargo `target/release` (dev builds)
11
+ */
12
+ import { createRequire } from "node:module";
13
+ import { existsSync } from "node:fs";
14
+ import { dirname, join } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+ export const CODEMODE_BINDING_VERSION = "2.0.0";
17
+ let cached;
18
+ function platformTriple() {
19
+ const { platform, arch } = process;
20
+ if (platform === "linux" && arch === "x64")
21
+ return "linux-x64-gnu";
22
+ if (platform === "linux" && arch === "arm64")
23
+ return "linux-arm64-gnu";
24
+ if (platform === "darwin" && arch === "arm64")
25
+ return "darwin-arm64";
26
+ if (platform === "darwin" && arch === "x64")
27
+ return "darwin-x64";
28
+ if (platform === "win32" && arch === "x64")
29
+ return "win32-x64-msvc";
30
+ return null;
31
+ }
32
+ function platformPackageAddon() {
33
+ const require = createRequire(import.meta.url);
34
+ try {
35
+ const launcher = require("ast-sgrep");
36
+ if (typeof launcher.resolveCodemodeAddon === "function") {
37
+ return launcher.resolveCodemodeAddon();
38
+ }
39
+ }
40
+ catch {
41
+ // Launcher may be unavailable in isolated unit tests.
42
+ }
43
+ return null;
44
+ }
45
+ function candidatePaths() {
46
+ const here = dirname(fileURLToPath(import.meta.url));
47
+ const triple = platformTriple();
48
+ const names = triple
49
+ ? [
50
+ `ast-sgrep-codemode.${triple}.node`,
51
+ `ast-sgrep-codemode.node`,
52
+ ]
53
+ : [`ast-sgrep-codemode.node`];
54
+ const dirs = [
55
+ // Built next to extension (dev / packaged)
56
+ join(here, "..", "..", "native"),
57
+ join(here, "..", "native"),
58
+ // Workspace release output
59
+ join(here, "..", "..", "..", "..", "target", "release"),
60
+ ];
61
+ const cargoTarget = process.env.CARGO_TARGET_DIR;
62
+ if (cargoTarget)
63
+ dirs.push(join(cargoTarget, "release"));
64
+ const out = [];
65
+ const override = process.env.ASGREP_CODEMODE_NAPI_PATH;
66
+ if (override)
67
+ out.push(override);
68
+ const packaged = platformPackageAddon();
69
+ if (packaged)
70
+ out.push(packaged);
71
+ for (const dir of dirs) {
72
+ for (const name of names)
73
+ out.push(join(dir, name));
74
+ // cargo cdylib name
75
+ out.push(join(dir, "libast_sgrep_codemode_napi.so"));
76
+ out.push(join(dir, "libast_sgrep_codemode_napi.dylib"));
77
+ out.push(join(dir, "ast_sgrep_codemode_napi.dll"));
78
+ }
79
+ return out;
80
+ }
81
+ /** Load the NAPI binding once. Returns null if unavailable on this host. */
82
+ export function loadCodemodeNative() {
83
+ if (cached !== undefined)
84
+ return cached;
85
+ // Force CLI sticky / argv path (unit tests, degraded installs).
86
+ if (process.env.ASGREP_CODEMODE_BACKEND === "cli") {
87
+ cached = null;
88
+ return null;
89
+ }
90
+ const require = createRequire(import.meta.url);
91
+ for (const path of candidatePaths()) {
92
+ if (!existsSync(path))
93
+ continue;
94
+ try {
95
+ const binding = require(path);
96
+ if (typeof binding?.isNative === "function" &&
97
+ binding.isNative() &&
98
+ typeof binding.bindingVersion === "function" &&
99
+ binding.bindingVersion() === CODEMODE_BINDING_VERSION &&
100
+ typeof binding.asyncApiVersion === "function" &&
101
+ binding.asyncApiVersion() === 1) {
102
+ cached = binding;
103
+ return cached;
104
+ }
105
+ }
106
+ catch {
107
+ // try next candidate
108
+ }
109
+ }
110
+ cached = null;
111
+ return null;
112
+ }
113
+ export function nativeAvailable() {
114
+ return loadCodemodeNative() !== null;
115
+ }
116
+ /** Reset cache (tests). */
117
+ export function resetNativeCache() {
118
+ cached = undefined;
119
+ }
@@ -0,0 +1,37 @@
1
+ import type { AsgrepConnector } from "./connector.js";
2
+ import type { DispatchStats } from "./dispatch.js";
3
+ /** Closed sum: success|failure — `ok:true` with `error` (or `ok:false` without) is unrepresentable. */
4
+ export type CodemodeRunSuccess = {
5
+ ok: true;
6
+ result: unknown;
7
+ logs: string[];
8
+ code: string;
9
+ stats?: DispatchStats;
10
+ wallMs: number;
11
+ };
12
+ export type CodemodeRunFailure = {
13
+ ok: false;
14
+ result: null;
15
+ error: string;
16
+ logs: string[];
17
+ code: string;
18
+ stats?: DispatchStats;
19
+ wallMs: number;
20
+ };
21
+ export type CodemodeRunResult = CodemodeRunSuccess | CodemodeRunFailure;
22
+ /** Strip markdown fences and normalize to an async IIFE expression. */
23
+ export declare function normalizeCode(raw: string): string;
24
+ /**
25
+ * Run model-generated JavaScript against the typed `asgrep` connector.
26
+ *
27
+ * Model-generated code is not trusted with the extension host's ambient Node
28
+ * authority. A dedicated worker contains CPU/microtask denial of service; its
29
+ * VM hides `process`, module loading, and host constructors, with a JSON bridge
30
+ * as the only exposed capability. This is not an OS sandbox, so deployments
31
+ * requiring adversarial-code isolation should still restrict the Pi process.
32
+ */
33
+ export declare function runCodemode(rawCode: string, asgrep: AsgrepConnector, options?: {
34
+ timeoutMs?: number;
35
+ signal?: AbortSignal;
36
+ stats?: () => DispatchStats;
37
+ }): Promise<CodemodeRunResult>;