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.
package/dist/runtime.js CHANGED
@@ -1,16 +1,16 @@
1
- import { mkdir, mkdtemp, realpath, rename, rm } from "node:fs/promises";
2
- import { constants, accessSync, existsSync, realpathSync } from "node:fs";
3
- import { randomUUID } from "node:crypto";
1
+ import { realpath } from "node:fs/promises";
2
+ import { constants, accessSync, existsSync, readdirSync, realpathSync, statSync, watch } from "node:fs";
4
3
  import { DatabaseSync } from "node:sqlite";
5
4
  import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
6
5
  import { resolveBinary } from "ast-sgrep";
7
- export const RUNTIME_VERSION = "1.4.0";
6
+ export const RUNTIME_VERSION = "2.0.0";
8
7
  export const MACHINE_SCHEMA_VERSION = "1.0.0";
9
8
  export const CONFIG_SCHEMA_VERSION = 1;
10
- export const INDEX_FORMAT_VERSION = 7;
9
+ export const INDEX_FORMAT_VERSION = 12;
11
10
  export const DEFAULT_TIMEOUT_MS = 30_000;
12
11
  export const DEFAULT_MAX_OUTPUT_BYTES = 4 * 1024 * 1024;
13
12
  export const DEFAULT_REFRESH_INTERVAL_MS = 30_000;
13
+ const MAX_TARGETED_INDEX_PATHS = 1_024;
14
14
  const RESOLVED_ROOT = Symbol("resolvedRoot");
15
15
  export class RuntimeError extends Error {
16
16
  code;
@@ -112,7 +112,8 @@ export function resolveConfig(sources = {}) {
112
112
  }
113
113
  function pathContained(parent, child) {
114
114
  const rel = relative(parent, child);
115
- return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
115
+ const first = rel.split(/[\\/]/u, 1)[0];
116
+ return rel === "" || (!isAbsolute(rel) && first !== "..");
116
117
  }
117
118
  export async function resolveRuntimeRoot(projectCwd, requestedRoot, allowOutsideProject = false) {
118
119
  let project;
@@ -132,7 +133,7 @@ export async function resolveRuntimeRoot(projectCwd, requestedRoot, allowOutside
132
133
  function record(value) {
133
134
  return value !== null && typeof value === "object" && !Array.isArray(value) ? value : undefined;
134
135
  }
135
- function indexHealth(status) {
136
+ function indexHealth(status, knownExisting = false) {
136
137
  const index = record(status.index);
137
138
  const state = typeof index?.status === "string" ? index.status :
138
139
  typeof status.index_status === "string" ? status.index_status : undefined;
@@ -143,7 +144,7 @@ function indexHealth(status) {
143
144
  if (state === "ready" || state === "current" || index?.exists === true || status.indexed === true)
144
145
  return "ready";
145
146
  if (typeof status.index_path === "string" && typeof status.file_count === "number") {
146
- return status.file_count === 0 ? "missing" : "ready";
147
+ return knownExisting || status.file_count > 0 ? "ready" : "missing";
147
148
  }
148
149
  throw new RuntimeError("INDEX_STATUS_UNKNOWN", "ast-sgrep status did not report index freshness", { index: status.index, index_status: status.index_status });
149
150
  }
@@ -153,9 +154,63 @@ function incompatibleStatusFailure(cause) {
153
154
  const text = `${cause.message} ${JSON.stringify(cause.details)}`;
154
155
  return /incompatib|unsupported.{0,24}schema|schema.{0,24}(version|mismatch)/i.test(text);
155
156
  }
157
+ /** Probe compatibility hook then status; map incompat operational failures to health. */
158
+ async function probeIndexHealth(runtime, rootContext, options) {
159
+ const hinted = await runtime.inspectIndexCompatibility?.(rootContext);
160
+ if (hinted === "missing" || hinted === "incompatible")
161
+ return hinted;
162
+ try {
163
+ const status = runtime.nativeCall
164
+ ? await runtime.nativeCall("index_status", {}, rootContext, options)
165
+ : await runtime.run(["status", ".", "--json"], rootContext, options);
166
+ return indexHealth(status, hinted === "ready");
167
+ }
168
+ catch (cause) {
169
+ if (!incompatibleStatusFailure(cause))
170
+ throw cause;
171
+ return "incompatible";
172
+ }
173
+ }
174
+ function indexCompletion(response, requireWalkErrors) {
175
+ const stats = record(response.stats) ?? response;
176
+ const failed = stats.files_failed;
177
+ const walkErrors = stats.walk_errors;
178
+ if (!Number.isSafeInteger(failed) || failed < 0
179
+ || (requireWalkErrors ? typeof walkErrors !== "boolean" : walkErrors !== undefined && typeof walkErrors !== "boolean")) {
180
+ throw new RuntimeError("INDEX_RESPONSE_INVALID", "ast-sgrep index response omitted valid completion status", { filesFailed: failed, walkErrors, requireWalkErrors });
181
+ }
182
+ return {
183
+ failed: failed,
184
+ walkErrors: walkErrors === true,
185
+ };
186
+ }
187
+ /** Run index_repo via native sticky pool or CLI argv. force=true → reindex. */
188
+ async function runIndex(runtime, force, rootContext, options) {
189
+ const response = runtime.nativeCall
190
+ ? await runtime.nativeCall("index_repo", { force }, rootContext, options)
191
+ : await runtime.run([force ? "reindex" : "index", ".", "--json"], rootContext, options);
192
+ const { failed, walkErrors } = indexCompletion(response, true);
193
+ if (failed > 0 || walkErrors) {
194
+ throw new RuntimeError("INDEX_UPDATE_INCOMPLETE", "ast-sgrep did not complete the full index reconciliation", { failed, walkErrors, force });
195
+ }
196
+ }
197
+ /** Update known changed paths without walking the repository. */
198
+ async function runTargetedIndex(runtime, paths, rootContext, options) {
199
+ for (let offset = 0; offset < paths.length; offset += MAX_TARGETED_INDEX_PATHS) {
200
+ const chunk = paths.slice(offset, offset + MAX_TARGETED_INDEX_PATHS);
201
+ const response = runtime.nativeCall
202
+ ? await runtime.nativeCall("index_repo", { paths: chunk }, rootContext, options)
203
+ : await runtime.run(["index", ".", "--json", ...chunk.flatMap((path) => ["--path", path])], rootContext, options);
204
+ const { failed } = indexCompletion(response, false);
205
+ if (failed > 0) {
206
+ throw new RuntimeError("INDEX_UPDATE_INCOMPLETE", `ast-sgrep failed to update ${failed} changed path${failed === 1 ? "" : "s"}`, { failed, pathCount: chunk.length });
207
+ }
208
+ }
209
+ }
156
210
  function canonicalizeAffectedPath(path) {
157
- const unresolved = [];
158
- let existing = resolve(path);
211
+ const absolute = resolve(path);
212
+ const unresolved = [basename(absolute)];
213
+ let existing = dirname(absolute);
159
214
  for (;;) {
160
215
  try {
161
216
  return resolve(realpathSync(existing), ...unresolved.reverse());
@@ -170,106 +225,296 @@ function canonicalizeAffectedPath(path) {
170
225
  }
171
226
  }
172
227
  }
228
+ function canonicalizeRootPath(path) {
229
+ try {
230
+ return realpathSync(resolve(path));
231
+ }
232
+ catch {
233
+ return canonicalizeAffectedPath(path);
234
+ }
235
+ }
236
+ function changesIgnoreRules(path) {
237
+ const name = basename(path);
238
+ return name === ".gitignore" || name === ".ignore" || name === ".asgrepignore";
239
+ }
240
+ function ignoredIndexWrite(root, path, indexPath) {
241
+ const defaultIndexDirectory = join(root, ".asgrep");
242
+ if (pathContained(defaultIndexDirectory, path))
243
+ return true;
244
+ const indexDirectory = dirname(indexPath);
245
+ if (dirname(path) !== indexDirectory)
246
+ return false;
247
+ const name = basename(path);
248
+ const sqliteArtifact = (database) => {
249
+ const suffix = name.slice(database.length);
250
+ return name.startsWith(database) && (suffix === ""
251
+ || suffix === "-wal"
252
+ || suffix === "-shm"
253
+ || suffix === "-journal"
254
+ || suffix === ".reindex.lock"
255
+ || /^\.corrupt(?:\.\d+)?(?:-(?:wal|shm|journal))?$/u.test(suffix));
256
+ };
257
+ return sqliteArtifact(basename(indexPath))
258
+ || sqliteArtifact("lexical.db")
259
+ || name === "semantic.ivf"
260
+ || (name.startsWith(".semantic.ivf.") && name.endsWith(".tmp"));
261
+ }
262
+ function existingDirectory(path) {
263
+ try {
264
+ return statSync(path).isDirectory();
265
+ }
266
+ catch {
267
+ return false;
268
+ }
269
+ }
270
+ function markStatePathDirty(state, path) {
271
+ state.dirtyGeneration += 1;
272
+ if (changesIgnoreRules(path)) {
273
+ state.dirtyPaths.clear();
274
+ state.fullScanRequired = true;
275
+ }
276
+ else if (!state.fullScanRequired) {
277
+ if (!state.dirtyPaths.has(path) && state.dirtyPaths.size >= MAX_TARGETED_INDEX_PATHS) {
278
+ state.dirtyPaths.clear();
279
+ state.fullScanRequired = true;
280
+ }
281
+ else {
282
+ state.dirtyPaths.add(path);
283
+ }
284
+ }
285
+ }
286
+ function markStateFullScan(state) {
287
+ state.dirtyGeneration += 1;
288
+ state.dirtyPaths.clear();
289
+ state.fullScanRequired = true;
290
+ }
291
+ function cancelledRefreshWait() {
292
+ return new RuntimeError("CANCELLED", "ast-sgrep freshness wait was cancelled");
293
+ }
294
+ /** Stop one caller waiting without transferring cancellation ownership to shared work. */
295
+ function waitForRefresh(refresh, signal) {
296
+ if (!signal)
297
+ return refresh;
298
+ if (signal.aborted)
299
+ return Promise.reject(cancelledRefreshWait());
300
+ return new Promise((resolveWait, rejectWait) => {
301
+ const onAbort = () => {
302
+ signal.removeEventListener("abort", onAbort);
303
+ rejectWait(cancelledRefreshWait());
304
+ };
305
+ signal.addEventListener("abort", onAbort, { once: true });
306
+ refresh.then(() => {
307
+ signal.removeEventListener("abort", onAbort);
308
+ resolveWait();
309
+ }, (cause) => {
310
+ signal.removeEventListener("abort", onAbort);
311
+ rejectWait(cause);
312
+ });
313
+ });
314
+ }
173
315
  export class FreshnessCoordinator {
174
316
  #states = new Map();
175
- #pendingPaths = new Set();
317
+ #pending = new Map();
176
318
  #interval;
177
319
  #now;
320
+ #watchFactory;
178
321
  constructor(options = {}) {
179
322
  this.#interval = finitePositive(options.refreshIntervalMs, DEFAULT_REFRESH_INTERVAL_MS, "refreshIntervalMs");
180
323
  this.#now = options.now ?? Date.now;
324
+ this.#watchFactory = options.watchFactory ?? watch;
181
325
  }
182
326
  markAffectedPath(path, cwd) {
183
327
  const affected = canonicalizeAffectedPath(isAbsolute(path) ? path : resolve(canonicalizeAffectedPath(cwd), path));
184
- this.#pendingPaths.add(affected);
328
+ let matched = false;
185
329
  for (const [root, state] of this.#states) {
186
- if (pathContained(root, affected))
187
- state.dirtyGeneration += 1;
330
+ if (!pathContained(root, affected))
331
+ continue;
332
+ markStatePathDirty(state, affected);
333
+ matched = true;
334
+ }
335
+ if (!matched) {
336
+ const pendingRoot = canonicalizeRootPath(cwd);
337
+ // Before root resolution, the caller's cwd is the only trustworthy
338
+ // confinement boundary. Do not retain unrelated/escaping paths forever.
339
+ if (!pathContained(pendingRoot, affected))
340
+ return;
341
+ let pending = this.#pending.get(pendingRoot);
342
+ if (!pending) {
343
+ pending = { paths: new Set(), fullScanRequired: false, consumedFullScanRoots: new Set() };
344
+ this.#pending.set(pendingRoot, pending);
345
+ }
346
+ if (changesIgnoreRules(affected)) {
347
+ pending.paths.clear();
348
+ pending.fullScanRequired = true;
349
+ }
350
+ else if (!pending.fullScanRequired) {
351
+ if (!pending.paths.has(affected) && pending.paths.size >= MAX_TARGETED_INDEX_PATHS) {
352
+ pending.paths.clear();
353
+ pending.fullScanRequired = true;
354
+ }
355
+ else {
356
+ pending.paths.add(affected);
357
+ }
358
+ }
188
359
  }
189
360
  }
190
361
  markRootDirty(root) {
191
- const canonical = canonicalizeAffectedPath(root);
362
+ const canonical = canonicalizeRootPath(root);
192
363
  const state = this.#states.get(canonical);
193
- if (state)
194
- state.dirtyGeneration += 1;
195
- else
196
- this.#pendingPaths.add(canonical);
364
+ if (state) {
365
+ markStateFullScan(state);
366
+ }
367
+ else {
368
+ this.#pending.set(canonical, {
369
+ paths: new Set(),
370
+ fullScanRequired: true,
371
+ consumedFullScanRoots: new Set(),
372
+ });
373
+ }
197
374
  }
198
375
  async ensureFresh(runtime, context, options = {}) {
199
376
  const root = await runtime.resolveRoot(context);
200
377
  const rootContext = { cwd: root, [RESOLVED_ROOT]: true };
201
378
  let state = this.#states.get(root);
202
379
  if (!state) {
203
- state = { dirtyGeneration: 0, cleanGeneration: 0, initialized: false, lastRefreshAt: 0, inFlight: undefined };
380
+ state = {
381
+ dirtyGeneration: 0,
382
+ cleanGeneration: 0,
383
+ dirtyPaths: new Set(),
384
+ fullScanRequired: false,
385
+ initialized: false,
386
+ lastRefreshAt: 0,
387
+ inFlight: undefined,
388
+ watcher: undefined,
389
+ };
204
390
  this.#states.set(root, state);
205
391
  }
206
- for (const path of this.#pendingPaths) {
207
- if (!pathContained(root, path))
392
+ if (runtime.watchExternalChanges && state.watcher === undefined) {
393
+ const indexPath = canonicalizeAffectedPath(runtime.resolveIndexPath?.(root) ?? join(root, ".asgrep", "index.db"));
394
+ this.#startWatcher(root, state, indexPath);
395
+ }
396
+ for (const [pendingRoot, pending] of this.#pending) {
397
+ if (!pathContained(pendingRoot, root) && !pathContained(root, pendingRoot))
208
398
  continue;
209
- state.dirtyGeneration += 1;
210
- this.#pendingPaths.delete(path);
399
+ if (pending.fullScanRequired) {
400
+ if (!pending.consumedFullScanRoots.has(root)) {
401
+ markStateFullScan(state);
402
+ pending.consumedFullScanRoots.add(root);
403
+ }
404
+ continue;
405
+ }
406
+ for (const path of pending.paths) {
407
+ if (!pathContained(root, path))
408
+ continue;
409
+ markStatePathDirty(state, path);
410
+ pending.paths.delete(path);
411
+ }
412
+ if (pending.paths.size === 0)
413
+ this.#pending.delete(pendingRoot);
211
414
  }
212
415
  if (state.inFlight) {
213
- await state.inFlight;
416
+ await waitForRefresh(state.inFlight, options.signal);
214
417
  return this.ensureFresh(runtime, rootContext, options);
215
418
  }
216
419
  const now = this.#now();
217
420
  const elapsed = now - state.lastRefreshAt;
421
+ // Lease expiry: initialized and interval elapsed (or clock went backwards).
218
422
  const expired = state.initialized && (elapsed < 0 || elapsed >= this.#interval);
219
423
  if (state.initialized && state.cleanGeneration === state.dirtyGeneration && !expired)
220
424
  return root;
221
425
  const refreshGeneration = state.dirtyGeneration;
222
- const wasInitialized = state.initialized;
426
+ const refreshPaths = [...state.dirtyPaths];
427
+ const fullScanRequired = state.fullScanRequired;
428
+ // Correctness work belongs to the root, not to whichever request happened
429
+ // to start it. Individual callers may stop waiting, but cannot cancel the
430
+ // shared refresh while other callers depend on it.
431
+ const sharedOptions = {};
432
+ if (options.timeoutMs !== undefined)
433
+ sharedOptions.timeoutMs = options.timeoutMs;
434
+ if (options.env !== undefined)
435
+ sharedOptions.env = options.env;
223
436
  const refresh = (async () => {
224
- let health = await runtime.inspectIndexCompatibility?.(rootContext);
225
- if (health !== "incompatible") {
226
- try {
227
- const status = runtime.nativeCall
228
- ? await runtime.nativeCall("index_status", {}, rootContext, options)
229
- : await runtime.run(["status", ".", "--json"], rootContext, options);
230
- health = indexHealth(status);
231
- }
232
- catch (cause) {
233
- if (!incompatibleStatusFailure(cause))
234
- throw cause;
235
- health = "incompatible";
236
- }
237
- }
437
+ const health = await probeIndexHealth(runtime, rootContext, sharedOptions);
238
438
  const dirty = refreshGeneration > state.cleanGeneration;
239
439
  if (health === "incompatible") {
440
+ // Requisite variety: force rebuild path (hook or reindex).
240
441
  if (runtime.rebuildIncompatibleIndex)
241
- await runtime.rebuildIncompatibleIndex(rootContext, options);
242
- else if (runtime.nativeCall)
243
- await runtime.nativeCall("index_repo", { force: true }, rootContext, options);
442
+ await runtime.rebuildIncompatibleIndex(rootContext, sharedOptions);
244
443
  else
245
- await runtime.run(["reindex", ".", "--json"], rootContext, options);
444
+ await runIndex(runtime, true, rootContext, sharedOptions);
246
445
  }
247
- else if (health === "missing" || !wasInitialized || dirty) {
248
- if (runtime.nativeCall)
249
- await runtime.nativeCall("index_repo", { force: false }, rootContext, options);
250
- else
251
- await runtime.run(["index", ".", "--json"], rootContext, options);
446
+ else if (health === "missing" || !state.initialized || expired || (dirty && (fullScanRequired || refreshPaths.length === 0))) {
447
+ await runIndex(runtime, false, rootContext, sharedOptions);
252
448
  }
253
- else if (expired) {
254
- // Lease expired without dirty marks: incremental index (not force reindex)
255
- // so external create/modify/delete are reconciled without rebuild thrash (5du.9).
256
- if (runtime.nativeCall)
257
- await runtime.nativeCall("index_repo", { force: false }, rootContext, options);
258
- else
259
- await runtime.run(["index", ".", "--json"], rootContext, options);
449
+ else if (dirty) {
450
+ await runTargetedIndex(runtime, refreshPaths, rootContext, sharedOptions);
260
451
  }
261
452
  state.initialized = true;
262
453
  state.cleanGeneration = refreshGeneration;
454
+ if (state.dirtyGeneration === refreshGeneration) {
455
+ state.dirtyPaths.clear();
456
+ state.fullScanRequired = false;
457
+ }
263
458
  state.lastRefreshAt = this.#now();
264
459
  })();
265
- state.inFlight = refresh;
460
+ let tracked;
461
+ tracked = refresh.finally(() => {
462
+ if (state.inFlight === tracked)
463
+ state.inFlight = undefined;
464
+ });
465
+ state.inFlight = tracked;
466
+ // If every waiter is cancelled, the root-owned refresh still needs a
467
+ // rejection handler while it finishes in the background.
468
+ void tracked.catch(() => undefined);
469
+ await waitForRefresh(tracked, options.signal);
470
+ if (state.cleanGeneration !== state.dirtyGeneration) {
471
+ return this.ensureFresh(runtime, rootContext, options);
472
+ }
473
+ return root;
474
+ }
475
+ shutdown() {
476
+ for (const state of this.#states.values())
477
+ state.watcher?.close();
478
+ this.#states.clear();
479
+ this.#pending.clear();
480
+ }
481
+ #startWatcher(root, state, indexPath) {
482
+ if (!existsSync(root)) {
483
+ state.watcher = null;
484
+ markStateFullScan(state);
485
+ return;
486
+ }
266
487
  try {
267
- await refresh;
268
- return root;
488
+ const watcher = this.#watchFactory(root, { recursive: true, persistent: false, encoding: "utf8" }, (eventType, filename) => {
489
+ if (!filename) {
490
+ markStateFullScan(state);
491
+ return;
492
+ }
493
+ const affected = canonicalizeAffectedPath(join(root, filename));
494
+ if (ignoredIndexWrite(root, affected, indexPath))
495
+ return;
496
+ if (eventType === "rename" || existingDirectory(affected)) {
497
+ markStateFullScan(state);
498
+ return;
499
+ }
500
+ markStatePathDirty(state, affected);
501
+ });
502
+ watcher.on("error", () => {
503
+ watcher.close();
504
+ // Watcher errors (including backend overflow) make event history
505
+ // unknowable. Scan once, then rely on the periodic correctness lease;
506
+ // retrying a permanently broken watcher on every request hot-loops.
507
+ if (state.watcher === watcher)
508
+ state.watcher = null;
509
+ markStateFullScan(state);
510
+ });
511
+ state.watcher = watcher;
269
512
  }
270
- finally {
271
- if (state.inFlight === refresh)
272
- state.inFlight = undefined;
513
+ catch {
514
+ // Do one correctness scan now, then rely on periodic scans instead of
515
+ // retrying (and rescanning) on every query on unsupported filesystems.
516
+ state.watcher = null;
517
+ markStateFullScan(state);
273
518
  }
274
519
  }
275
520
  }
@@ -297,38 +542,61 @@ function getBinary(config, env, resolver) {
297
542
  function byteLength(value) { return Buffer.byteLength(value, "utf8"); }
298
543
  /** Present-field version identity checks. Pass `requireIdentity` for version --json. */
299
544
  function assertVersionTriple(envelope, requireIdentity = false) {
300
- if (requireIdentity || envelope.version !== undefined) {
301
- if (envelope.version !== RUNTIME_VERSION) {
302
- throw new RuntimeError("VERSION_MISMATCH", "ast-sgrep binary version does not match the extension", { expected: RUNTIME_VERSION, actual: envelope.version });
303
- }
545
+ // Compound guards (same short-circuit as nested if): check only when required or field present.
546
+ if ((requireIdentity || envelope.version !== undefined) && envelope.version !== RUNTIME_VERSION) {
547
+ throw new RuntimeError("VERSION_MISMATCH", "ast-sgrep binary version does not match the extension", { expected: RUNTIME_VERSION, actual: envelope.version });
304
548
  }
305
- if (requireIdentity || envelope.machine_schema_version !== undefined) {
306
- if (envelope.machine_schema_version !== MACHINE_SCHEMA_VERSION) {
307
- throw new RuntimeError("PROTOCOL_MISMATCH", "ast-sgrep binary reports an incompatible machine protocol", { expected: MACHINE_SCHEMA_VERSION, actual: envelope.machine_schema_version });
549
+ if ((requireIdentity || envelope.machine_schema_version !== undefined) && envelope.machine_schema_version !== MACHINE_SCHEMA_VERSION) {
550
+ throw new RuntimeError("PROTOCOL_MISMATCH", "ast-sgrep binary reports an incompatible machine protocol", { expected: MACHINE_SCHEMA_VERSION, actual: envelope.machine_schema_version });
551
+ }
552
+ }
553
+ /**
554
+ * Nonzero CLI exit: prefer structured failed envelope (OPERATIONAL_ERROR), else PROCESS_FAILED.
555
+ * Always throws — error-path extract so parseEnvelope keeps success-path protocol field checks.
556
+ */
557
+ function throwNonzeroProcessFailure(result, code) {
558
+ try {
559
+ const value = record(JSON.parse(result.stdout));
560
+ // Wire-valid ok:false asgrep envelope → structured operational failure (not PROCESS_FAILED).
561
+ if (value && value.tool === "asgrep" && value.schema_version === MACHINE_SCHEMA_VERSION && value.ok === false) {
562
+ const failure = record(value.error);
563
+ const message = typeof failure?.message === "string" ? failure.message : "ast-sgrep reported an operational failure";
564
+ throw new RuntimeError("OPERATIONAL_ERROR", message, { command: value.command, error: failure, exitCode: code });
308
565
  }
309
566
  }
567
+ catch (cause) {
568
+ if (cause instanceof RuntimeError)
569
+ throw cause;
570
+ }
571
+ throw new RuntimeError("PROCESS_FAILED", `ast-sgrep exited with code ${code}`, {
572
+ exitCode: code,
573
+ signal: result.signal ?? undefined,
574
+ stderr: result.stderr.slice(0, 1024),
575
+ });
576
+ }
577
+ /** Map exec failures (abort / timeout / generic) to RuntimeError. Re-throws RuntimeError as-is. */
578
+ function rethrowExecFailure(cause, options, timeout) {
579
+ if (cause instanceof RuntimeError)
580
+ throw cause;
581
+ if (options.signal?.aborted || (cause instanceof Error && cause.name === "AbortError")) {
582
+ throw new RuntimeError("CANCELLED", "ast-sgrep execution was cancelled");
583
+ }
584
+ const message = cause instanceof Error ? cause.message : String(cause);
585
+ if (/timeout|timed out/i.test(message)) {
586
+ throw new RuntimeError("TIMEOUT", `ast-sgrep exceeded ${timeout}ms`, { timeoutMs: timeout });
587
+ }
588
+ throw new RuntimeError("EXEC_FAILED", "Unable to execute ast-sgrep", { cause: message });
310
589
  }
311
590
  function parseEnvelope(result, limit) {
312
591
  const stdoutBytes = byteLength(result.stdout);
313
592
  const stderrBytes = byteLength(result.stderr);
314
- if (stdoutBytes > limit || stderrBytes > limit || stdoutBytes + stderrBytes > limit) {
593
+ // Byte lengths are non-negative: sum > limit covers either-side overflow and combined cap.
594
+ if (stdoutBytes + stderrBytes > limit) {
315
595
  throw new RuntimeError("OUTPUT_LIMIT", "ast-sgrep output exceeded the configured limit", { limit, stdoutBytes, stderrBytes });
316
596
  }
317
597
  const code = result.exitCode ?? result.code ?? 0;
318
598
  if (code !== 0) {
319
- try {
320
- const value = JSON.parse(result.stdout);
321
- if (value && typeof value === "object" && value.tool === "asgrep" && value.schema_version === MACHINE_SCHEMA_VERSION && value.ok === false) {
322
- const failure = record(value.error);
323
- const message = typeof failure?.message === "string" ? failure.message : "ast-sgrep reported an operational failure";
324
- throw new RuntimeError("OPERATIONAL_ERROR", message, { command: value.command, error: failure, exitCode: code });
325
- }
326
- }
327
- catch (cause) {
328
- if (cause instanceof RuntimeError)
329
- throw cause;
330
- }
331
- throw new RuntimeError("PROCESS_FAILED", `ast-sgrep exited with code ${code}`, { exitCode: code, signal: result.signal ?? undefined, stderr: result.stderr.slice(0, 1024) });
599
+ throwNonzeroProcessFailure(result, code);
332
600
  }
333
601
  let value;
334
602
  try {
@@ -337,9 +605,10 @@ function parseEnvelope(result, limit) {
337
605
  catch (cause) {
338
606
  throw new RuntimeError("MALFORMED_OUTPUT", "ast-sgrep returned malformed JSON", { cause: cause instanceof Error ? cause.message : String(cause) });
339
607
  }
340
- if (!value || typeof value !== "object" || Array.isArray(value))
608
+ const envelope = record(value);
609
+ if (!envelope)
341
610
  throw new RuntimeError("MALFORMED_OUTPUT", "ast-sgrep returned a non-object JSON payload");
342
- const envelope = value;
611
+ // Protocol field varieties (Ashby Keep) — sequential wire-contract checks stay here.
343
612
  if (envelope.tool !== "asgrep")
344
613
  throw new RuntimeError("TOOL_MISMATCH", "Response is not from ast-sgrep", { actual: envelope.tool });
345
614
  if (envelope.schema_version !== MACHINE_SCHEMA_VERSION)
@@ -347,6 +616,7 @@ function parseEnvelope(result, limit) {
347
616
  if (typeof envelope.ok !== "boolean")
348
617
  throw new RuntimeError("MALFORMED_OUTPUT", "ast-sgrep response is missing boolean ok");
349
618
  if (!envelope.ok) {
619
+ // Preserve pre-extract failure shape: plain object check (arrays allowed as error bag).
350
620
  const failure = envelope.error && typeof envelope.error === "object" ? envelope.error : undefined;
351
621
  const message = typeof failure?.message === "string" ? failure.message : "ast-sgrep reported an operational failure";
352
622
  throw new RuntimeError("OPERATIONAL_ERROR", message, { command: envelope.command, error: failure });
@@ -361,6 +631,34 @@ function indexPathFor(root, env) {
361
631
  const resolved = resolve(root, configured);
362
632
  return extname(resolved) === ".db" ? resolved : join(resolved, "index.db");
363
633
  }
634
+ function indexQuarantines(indexPath) {
635
+ const quarantinePrefix = `${basename(indexPath)}.corrupt`;
636
+ try {
637
+ return readdirSync(dirname(indexPath), { withFileTypes: true })
638
+ .filter((entry) => entry.isFile() && (entry.name === quarantinePrefix || entry.name.startsWith(`${quarantinePrefix}.`)))
639
+ .map((entry) => join(dirname(indexPath), entry.name))
640
+ .sort();
641
+ }
642
+ catch {
643
+ return [];
644
+ }
645
+ }
646
+ /** Classify a rebuild failure and identify recovery copies made by this attempt. */
647
+ function throwIndexRebuildFailed(cause, indexPath, quarantinesBefore) {
648
+ const newQuarantines = indexQuarantines(indexPath).filter((path) => !quarantinesBefore.has(path));
649
+ const recoveryPaths = [
650
+ ...newQuarantines,
651
+ ...(existsSync(indexPath) ? [indexPath] : []),
652
+ ];
653
+ throw new RuntimeError("INDEX_REBUILD_FAILED", "Incompatible index rebuild failed; the prior index remains recoverable", {
654
+ indexPath,
655
+ recoveryPath: recoveryPaths[0] ?? indexPath,
656
+ recoveryPaths,
657
+ priorIndexPreserved: recoveryPaths.length > 0,
658
+ expectedIndexFormat: INDEX_FORMAT_VERSION,
659
+ cause: cause instanceof Error ? cause.message : String(cause),
660
+ });
661
+ }
364
662
  function inspectIndexFile(path) {
365
663
  if (!existsSync(path))
366
664
  return "missing";
@@ -389,6 +687,7 @@ function inspectIndexFile(path) {
389
687
  }
390
688
  export class AstSgrepRuntime {
391
689
  pi;
690
+ watchExternalChanges = true;
392
691
  config;
393
692
  #resolver;
394
693
  #environment;
@@ -403,6 +702,9 @@ export class AstSgrepRuntime {
403
702
  ? resolveRuntimeRoot(context.cwd)
404
703
  : resolveRuntimeRoot(context.cwd, this.config.root, this.config.allowOutsideProject);
405
704
  }
705
+ resolveIndexPath(root) {
706
+ return indexPathFor(root, { ...this.#environment, ...this.config.env });
707
+ }
406
708
  async inspectIndexCompatibility(context) {
407
709
  const root = await this.resolveRoot(context);
408
710
  return inspectIndexFile(indexPathFor(root, { ...this.#environment, ...this.config.env }));
@@ -411,50 +713,23 @@ export class AstSgrepRuntime {
411
713
  const root = await this.resolveRoot(context);
412
714
  const env = { ...this.#environment, ...this.config.env, ...options.env };
413
715
  const indexPath = indexPathFor(root, env);
414
- const parent = dirname(indexPath);
415
- await mkdir(parent, { recursive: true });
416
- const temporaryDirectory = await mkdtemp(join(parent, ".rebuild-"));
417
- const replacementPath = join(temporaryDirectory, "index.db");
418
- const backupPath = `${indexPath}.backup-${randomUUID()}`;
419
- let priorMoved = false;
716
+ const quarantinesBefore = new Set(indexQuarantines(indexPath));
420
717
  try {
421
- const response = await this.run(["--index-path", replacementPath, "index", ".", "--json"], { cwd: root }, options);
422
- if (inspectIndexFile(replacementPath) !== "ready") {
423
- throw new RuntimeError("INDEX_REBUILD_INVALID", "Replacement index has an incompatible format", { expected: INDEX_FORMAT_VERSION });
718
+ // Core reindex prepares files before opening one bulk transaction and
719
+ // commits rewrites plus stale-row pruning together. Keeping the same DB
720
+ // inode avoids stale warm NAPI sessions and removes rename crash windows.
721
+ const response = await this.run(["reindex", ".", "--json"], { cwd: root }, options);
722
+ const { failed, walkErrors } = indexCompletion(response, true);
723
+ if (failed > 0 || walkErrors) {
724
+ throw new RuntimeError("INDEX_UPDATE_INCOMPLETE", "ast-sgrep did not complete the incompatible-index rebuild", { failed, walkErrors, force: true });
424
725
  }
425
- if (existsSync(indexPath)) {
426
- await rename(indexPath, backupPath);
427
- priorMoved = true;
726
+ if (inspectIndexFile(indexPath) !== "ready") {
727
+ throw new RuntimeError("INDEX_REBUILD_INVALID", "Rebuilt index has an incompatible format", { expected: INDEX_FORMAT_VERSION });
428
728
  }
429
- try {
430
- await rename(replacementPath, indexPath);
431
- }
432
- catch (cause) {
433
- if (priorMoved)
434
- await rename(backupPath, indexPath);
435
- throw cause;
436
- }
437
- if (priorMoved)
438
- await rm(backupPath, { force: true });
439
729
  return response;
440
730
  }
441
731
  catch (cause) {
442
- let recoveryPath = indexPath;
443
- let priorIndexPreserved = existsSync(indexPath);
444
- if (priorMoved && !priorIndexPreserved && existsSync(backupPath)) {
445
- recoveryPath = backupPath;
446
- priorIndexPreserved = true;
447
- }
448
- throw new RuntimeError("INDEX_REBUILD_FAILED", "Incompatible index rebuild failed; the prior index remains recoverable", {
449
- indexPath,
450
- recoveryPath,
451
- priorIndexPreserved,
452
- expectedIndexFormat: INDEX_FORMAT_VERSION,
453
- cause: cause instanceof Error ? cause.message : String(cause),
454
- });
455
- }
456
- finally {
457
- await rm(temporaryDirectory, { recursive: true, force: true });
732
+ throwIndexRebuildFailed(cause, indexPath, quarantinesBefore);
458
733
  }
459
734
  }
460
735
  async run(args, context, options = {}) {
@@ -474,14 +749,7 @@ export class AstSgrepRuntime {
474
749
  return parseEnvelope(result, this.config.maxOutputBytes);
475
750
  }
476
751
  catch (cause) {
477
- if (cause instanceof RuntimeError)
478
- throw cause;
479
- if (options.signal?.aborted || (cause instanceof Error && cause.name === "AbortError"))
480
- throw new RuntimeError("CANCELLED", "ast-sgrep execution was cancelled");
481
- const message = cause instanceof Error ? cause.message : String(cause);
482
- if (/timeout|timed out/i.test(message))
483
- throw new RuntimeError("TIMEOUT", `ast-sgrep exceeded ${timeout}ms`, { timeoutMs: timeout });
484
- throw new RuntimeError("EXEC_FAILED", "Unable to execute ast-sgrep", { cause: message });
752
+ rethrowExecFailure(cause, options, timeout);
485
753
  }
486
754
  }
487
755
  /** Absolute path to the native binary (for sticky serve / stdin batch spawn). */