pi-ast-sgrep 1.4.0 → 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.
@@ -8,6 +8,20 @@ import { mkdtemp, writeFile, rm } from "node:fs/promises";
8
8
  import { tmpdir } from "node:os";
9
9
  import { join } from "node:path";
10
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
+ }
11
25
  /**
12
26
  * Wraps a host so Promise.all([asgrep.search, asgrep.defs, …]) collapses into
13
27
  * one microtask wave. Prefers sticky serve → one-shot batch → overlapped spawn.
@@ -17,7 +31,7 @@ export function createCodemodeDispatcher(host) {
17
31
  let scheduled = false;
18
32
  let stats = emptyStats();
19
33
  const flush = async () => {
20
- const wave = pending;
34
+ const wave = pending.filter((item) => !item.settled);
21
35
  pending = [];
22
36
  scheduled = false;
23
37
  if (wave.length === 0)
@@ -32,7 +46,9 @@ export function createCodemodeDispatcher(host) {
32
46
  }
33
47
  // Chunk oversized waves (batch max = 32).
34
48
  for (let offset = 0; offset < wave.length; offset += MAX_WAVE) {
35
- const chunk = wave.slice(offset, offset + MAX_WAVE);
49
+ const chunk = wave.slice(offset, offset + MAX_WAVE).filter((item) => !item.settled);
50
+ if (chunk.length === 0)
51
+ continue;
36
52
  await settleWave(host, chunk, stats);
37
53
  }
38
54
  }
@@ -41,8 +57,28 @@ export function createCodemodeDispatcher(host) {
41
57
  }
42
58
  };
43
59
  const enqueue = (item) => new Promise((resolve, reject) => {
44
- item.resolve = resolve;
45
- item.reject = 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 });
46
82
  pending.push(item);
47
83
  if (!scheduled) {
48
84
  scheduled = true;
@@ -53,20 +89,11 @@ export function createCodemodeDispatcher(host) {
53
89
  });
54
90
  const dispatchHost = {
55
91
  call(tool, args, context, options) {
56
- const item = { tool, args, context, resolve: () => undefined, reject: () => undefined };
57
- if (options)
58
- item.options = options;
59
- return enqueue(item);
60
- },
61
- run(args, context, options) {
62
- // Legacy argv: still coalesce, but tool inference is best-effort only.
63
- const tool = toolFromArgs(args);
64
- const callArgs = argsObjectFromArgv(args);
65
92
  const item = {
66
93
  tool,
67
- args: callArgs,
68
- argv: args,
94
+ args,
69
95
  context,
96
+ settled: false,
70
97
  resolve: () => undefined,
71
98
  reject: () => undefined,
72
99
  };
@@ -91,7 +118,7 @@ async function settleOne(host, item, stats) {
91
118
  return;
92
119
  }
93
120
  // N=1 without sticky: direct CLI (batch-of-1 is tempfile + protocol for no gain).
94
- const args = item.argv ?? argvFor(item.tool, item.args);
121
+ const args = argvFor(item.tool, item.args);
95
122
  item.resolve(await host.run(args, item.context, item.options));
96
123
  }
97
124
  catch (err) {
@@ -100,43 +127,65 @@ async function settleOne(host, item, stats) {
100
127
  }
101
128
  async function settleWave(host, wave, stats) {
102
129
  if (host.sticky) {
130
+ const transportOptions = sharedBatchOptions(wave);
103
131
  try {
104
132
  const calls = wave.map((item, index) => ({
105
133
  id: String(index),
106
134
  tool: item.tool,
107
135
  args: item.args,
108
136
  }));
109
- const batch = await host.sticky.batch(calls, wave[0]?.options);
137
+ const batch = await host.sticky.batch(calls, transportOptions);
110
138
  stats.stickyCalls += wave.length;
111
139
  settleFromBatch(wave, batch);
112
140
  return;
113
141
  }
114
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
+ }
115
153
  // Sticky died mid-program — fall through to one-shot / spawn.
116
- void cause;
117
154
  }
118
155
  }
156
+ const batchWave = wave.filter((item) => !item.settled);
157
+ if (batchWave.length === 0)
158
+ return;
119
159
  if (host.runBatch) {
160
+ const transportOptions = sharedBatchOptions(batchWave);
120
161
  try {
121
- const calls = wave.map((item, index) => ({
162
+ const calls = batchWave.map((item, index) => ({
122
163
  id: String(index),
123
164
  tool: item.tool,
124
165
  args: item.args,
125
166
  }));
126
- const batch = await host.runBatch(calls, wave[0].context, wave[0].options);
127
- stats.batchedCalls += wave.length;
128
- settleFromBatch(wave, batch);
167
+ const batch = await host.runBatch(calls, batchWave[0].context, transportOptions);
168
+ stats.batchedCalls += batchWave.length;
169
+ settleFromBatch(batchWave, batch);
129
170
  return;
130
171
  }
131
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
+ }
132
181
  // Transport failure only — do NOT re-run when per-call ok:false.
133
- void cause;
134
182
  }
135
183
  }
136
- stats.parallelSpawnCalls += wave.length;
137
- await Promise.all(wave.map(async (item) => {
184
+ const spawnWave = batchWave.filter((item) => !item.settled);
185
+ stats.parallelSpawnCalls += spawnWave.length;
186
+ await Promise.all(spawnWave.map(async (item) => {
138
187
  try {
139
- const args = item.argv ?? argvFor(item.tool, item.args);
188
+ const args = argvFor(item.tool, item.args);
140
189
  item.resolve(await host.run(args, item.context, item.options));
141
190
  }
142
191
  catch (err) {
@@ -170,122 +219,51 @@ function emptyStats() {
170
219
  wallMs: 0,
171
220
  };
172
221
  }
173
- /** Build CLI argv for spawn fallback (typed path preferred). */
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
+ }
174
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
+ }
175
248
  const limit = num(args.limit, 8);
249
+ if (spec.form === "chain") {
250
+ return ["chain", argStr(args, "query"), ".", "--json", "--limit", String(limit)];
251
+ }
176
252
  const excerpt = num(args.excerpt_lines ?? args.excerptLines, 0);
177
253
  const capsule = ["--json", "--format", "agent-capsule", "--limit", String(limit), "--excerpt-lines", String(excerpt)];
178
- switch (tool) {
179
- case "search":
180
- return [...capsule, String(args.query ?? ""), "."];
181
- case "semantic":
182
- return ["semantic", String(args.query ?? ""), ".", ...capsule];
183
- case "chain":
184
- return ["chain", String(args.query ?? ""), ".", "--json", "--limit", String(limit)];
185
- case "defs":
186
- return [...capsule, `defs:${String(args.symbol ?? "")}`, "."];
187
- case "callers":
188
- return [...capsule, `callers:${String(args.symbol ?? "")}`, "."];
189
- case "imports":
190
- return [...capsule, `imports:${String(args.module ?? "")}`, "."];
191
- case "index_status":
192
- return ["status", ".", "--json"];
193
- case "index_repo":
194
- return [args.force === true ? "reindex" : "index", ".", "--json"];
195
- case "catalog_search":
196
- case "catalog_describe":
197
- // No CLI equivalent — sticky/batch only.
198
- return ["status", ".", "--json"];
199
- default:
200
- return [...capsule, String(args.query ?? ""), "."];
254
+ if (spec.form === "semantic") {
255
+ return ["semantic", argStr(args, "query"), ".", ...capsule];
201
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, "."];
202
261
  }
203
262
  function num(value, fallback) {
204
263
  if (typeof value === "number" && Number.isFinite(value))
205
264
  return Math.trunc(value);
206
265
  return fallback;
207
266
  }
208
- function toolFromArgs(args) {
209
- if (args[0] === "semantic")
210
- return "semantic";
211
- if (args[0] === "chain")
212
- return "chain";
213
- if (args[0] === "status")
214
- return "index_status";
215
- if (args[0] === "index" || args[0] === "reindex")
216
- return "index_repo";
217
- if (args[0] === "codemode-batch" || args[0] === "codemode-serve")
218
- return "search";
219
- const query = args.length >= 2 ? args[args.length - 2] : "";
220
- // Only classify prefix forms when the whole query is a navigator, so
221
- // search({ query: "defs: auth in login" }) stays search.
222
- if (/^defs:\s*\S+$/.test(query))
223
- return "defs";
224
- if (/^callers:\s*\S+$/.test(query))
225
- return "callers";
226
- if (/^imports:\s*\S+$/.test(query))
227
- return "imports";
228
- return "search";
229
- }
230
- function argsObjectFromArgv(args) {
231
- if (args[0] === "status")
232
- return {};
233
- if (args[0] === "index")
234
- return { force: false };
235
- if (args[0] === "reindex")
236
- return { force: true };
237
- if (args[0] === "semantic" || args[0] === "chain") {
238
- const query = args[1] ?? "";
239
- const limit = readFlag(args, "--limit");
240
- const excerptLines = readFlag(args, "--excerpt-lines");
241
- const out = { query };
242
- if (limit !== undefined)
243
- out.limit = limit;
244
- if (excerptLines !== undefined)
245
- out.excerpt_lines = excerptLines;
246
- return out;
247
- }
248
- const query = args.length >= 2 ? args[args.length - 2] : "";
249
- const limit = readFlag(args, "--limit");
250
- const excerptLines = readFlag(args, "--excerpt-lines");
251
- if (/^defs:\s*/.test(query)) {
252
- const out = { symbol: query.replace(/^defs:\s*/, "").trim() };
253
- if (limit !== undefined)
254
- out.limit = limit;
255
- if (excerptLines !== undefined)
256
- out.excerpt_lines = excerptLines;
257
- return out;
258
- }
259
- if (/^callers:\s*/.test(query)) {
260
- const out = { symbol: query.replace(/^callers:\s*/, "").trim() };
261
- if (limit !== undefined)
262
- out.limit = limit;
263
- if (excerptLines !== undefined)
264
- out.excerpt_lines = excerptLines;
265
- return out;
266
- }
267
- if (/^imports:\s*/.test(query)) {
268
- const out = { module: query.replace(/^imports:\s*/, "").trim() };
269
- if (limit !== undefined)
270
- out.limit = limit;
271
- if (excerptLines !== undefined)
272
- out.excerpt_lines = excerptLines;
273
- return out;
274
- }
275
- const out = { query, format: "capsule" };
276
- if (limit !== undefined)
277
- out.limit = limit;
278
- if (excerptLines !== undefined)
279
- out.excerpt_lines = excerptLines;
280
- return out;
281
- }
282
- function readFlag(args, flag) {
283
- const idx = args.indexOf(flag);
284
- if (idx < 0 || idx + 1 >= args.length)
285
- return undefined;
286
- const n = Number(args[idx + 1]);
287
- return Number.isFinite(n) ? n : undefined;
288
- }
289
267
  export function asEnvelope(value, command) {
290
268
  if (value &&
291
269
  typeof value === "object" &&
@@ -1,17 +1,16 @@
1
1
  /**
2
2
  * Code Mode for ast-sgrep in Pi.
3
3
  *
4
- * Pattern (Cloudflare / Anthropic PTC / OpenAI PTC):
5
- * the model writes JavaScript that calls typed `asgrep.*` methods inside a
6
- * restricted executor. Intermediate results stay in the sandbox; only the
7
- * shaped return value re-enters the model context. Parallel calls use
8
- * `Promise.all` and are coalesced into sticky serve / one warm batch process.
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.
9
8
  *
10
- * This package is intentionally independent of MCP. Both MCP and Code Mode
11
- * sit on the native ast-sgrep binary / core; they never import each other.
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.
12
11
  */
13
- export { createAsgrepConnector, type AsgrepConnector, type ConnectorHost, type ConnectorBundle, } from "./connector.js";
14
- export { runCodemode, normalizeCode, type CodemodeRunResult } from "./sandbox.js";
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";
15
14
  export { CODEMODE_TYPES_FOR_MODEL, type SearchArgs, type ChainArgs } from "./types.js";
16
15
  export { createCodemodeDispatcher, runNativeBatch, argvFor, asEnvelope, type DispatchStats, type BatchCapableHost, type StickyWorker, type BatchResult, } from "./dispatch.js";
17
16
  export { startStickyWorker, runBatchViaStdin } from "./worker.js";
@@ -1,17 +1,16 @@
1
1
  /**
2
2
  * Code Mode for ast-sgrep in Pi.
3
3
  *
4
- * Pattern (Cloudflare / Anthropic PTC / OpenAI PTC):
5
- * the model writes JavaScript that calls typed `asgrep.*` methods inside a
6
- * restricted executor. Intermediate results stay in the sandbox; only the
7
- * shaped return value re-enters the model context. Parallel calls use
8
- * `Promise.all` and are coalesced into sticky serve / one warm batch process.
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.
9
8
  *
10
- * This package is intentionally independent of MCP. Both MCP and Code Mode
11
- * sit on the native ast-sgrep binary / core; they never import each other.
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.
12
11
  */
13
12
  export { createAsgrepConnector, } from "./connector.js";
14
- export { runCodemode, normalizeCode } from "./sandbox.js";
13
+ export { runCodemode, normalizeCode } from "./runner.js";
15
14
  export { CODEMODE_TYPES_FOR_MODEL } from "./types.js";
16
15
  export { createCodemodeDispatcher, runNativeBatch, argvFor, asEnvelope, } from "./dispatch.js";
17
16
  export { startStickyWorker, runBatchViaStdin } from "./worker.js";
@@ -9,6 +9,7 @@
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
11
  */
12
+ export declare const CODEMODE_BINDING_VERSION = "2.0.0";
12
13
  export type NativeSessionConfig = {
13
14
  root?: string;
14
15
  indexPath?: string;
@@ -34,22 +35,18 @@ export type NativeBatchResponse = {
34
35
  mode: string;
35
36
  };
36
37
  export type NativeSession = {
37
- call(tool: string, args?: Record<string, unknown>): unknown;
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>;
38
42
  readonly callCount: number;
39
43
  readonly root: string;
40
44
  };
41
45
  export type CodemodeNativeBinding = {
42
46
  Session: new (config?: NativeSessionConfig) => NativeSession;
43
- batch(request: {
44
- root?: string;
45
- indexPath?: string;
46
- useEmbed?: boolean;
47
- limit?: number;
48
- parallelMode?: string;
49
- calls: NativeBatchCall[];
50
- }): NativeBatchResponse;
51
47
  bindingVersion(): string;
52
48
  isNative(): boolean;
49
+ asyncApiVersion(): number;
53
50
  };
54
51
  /** Load the NAPI binding once. Returns null if unavailable on this host. */
55
52
  export declare function loadCodemodeNative(): CodemodeNativeBinding | null;
@@ -13,6 +13,7 @@ import { createRequire } from "node:module";
13
13
  import { existsSync } from "node:fs";
14
14
  import { dirname, join } from "node:path";
15
15
  import { fileURLToPath } from "node:url";
16
+ export const CODEMODE_BINDING_VERSION = "2.0.0";
16
17
  let cached;
17
18
  function platformTriple() {
18
19
  const { platform, arch } = process;
@@ -57,6 +58,9 @@ function candidatePaths() {
57
58
  // Workspace release output
58
59
  join(here, "..", "..", "..", "..", "target", "release"),
59
60
  ];
61
+ const cargoTarget = process.env.CARGO_TARGET_DIR;
62
+ if (cargoTarget)
63
+ dirs.push(join(cargoTarget, "release"));
60
64
  const out = [];
61
65
  const override = process.env.ASGREP_CODEMODE_NAPI_PATH;
62
66
  if (override)
@@ -89,7 +93,12 @@ export function loadCodemodeNative() {
89
93
  continue;
90
94
  try {
91
95
  const binding = require(path);
92
- if (typeof binding?.isNative === "function" && binding.isNative()) {
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) {
93
102
  cached = binding;
94
103
  return cached;
95
104
  }
@@ -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>;