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.
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.3.2";
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 = 5;
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;
@@ -36,6 +36,11 @@ function sameSetting(current, legacy, currentName, legacyName) {
36
36
  }
37
37
  return current ?? legacy;
38
38
  }
39
+ const LEGACY_NUMBER_FIELDS = [
40
+ ["timeoutMs", "timeout"],
41
+ ["maxOutputBytes", "maxOutput"],
42
+ ["refreshIntervalMs", "refreshInterval"],
43
+ ];
39
44
  /** Convert schema 0/unversioned settings without mutating the rollback source. */
40
45
  export function migrateConfig(input = {}) {
41
46
  const value = { ...input };
@@ -47,33 +52,26 @@ export function migrateConfig(input = {}) {
47
52
  return value;
48
53
  const legacy = value;
49
54
  const migrated = { ...legacy, schemaVersion: CONFIG_SCHEMA_VERSION };
50
- const timeoutMs = sameSetting(value.timeoutMs, legacy.timeout, "timeoutMs", "timeout");
51
- const maxOutputBytes = sameSetting(value.maxOutputBytes, legacy.maxOutput, "maxOutputBytes", "maxOutput");
52
- const refreshIntervalMs = sameSetting(value.refreshIntervalMs, legacy.refreshInterval, "refreshIntervalMs", "refreshInterval");
53
- if (timeoutMs !== undefined)
54
- migrated.timeoutMs = timeoutMs;
55
- if (maxOutputBytes !== undefined)
56
- migrated.maxOutputBytes = maxOutputBytes;
57
- if (refreshIntervalMs !== undefined)
58
- migrated.refreshIntervalMs = refreshIntervalMs;
59
- delete migrated.timeout;
60
- delete migrated.maxOutput;
61
- delete migrated.refreshInterval;
55
+ for (const [currentName, legacyName] of LEGACY_NUMBER_FIELDS) {
56
+ const next = sameSetting(value[currentName], legacy[legacyName], currentName, legacyName);
57
+ if (next !== undefined)
58
+ migrated[currentName] = next;
59
+ }
60
+ for (const [, legacyName] of LEGACY_NUMBER_FIELDS) {
61
+ delete migrated[legacyName];
62
+ }
62
63
  return migrated;
63
64
  }
64
65
  /** Serialize current settings for a schema-0 rollback without mutating the current value. */
65
66
  export function rollbackConfig(input) {
66
67
  const current = migrateConfig(input);
67
68
  const legacy = { ...current, schemaVersion: 0 };
68
- if (current.timeoutMs !== undefined)
69
- legacy.timeout = current.timeoutMs;
70
- if (current.maxOutputBytes !== undefined)
71
- legacy.maxOutput = current.maxOutputBytes;
72
- if (current.refreshIntervalMs !== undefined)
73
- legacy.refreshInterval = current.refreshIntervalMs;
74
- delete legacy.timeoutMs;
75
- delete legacy.maxOutputBytes;
76
- delete legacy.refreshIntervalMs;
69
+ for (const [currentName, legacyName] of LEGACY_NUMBER_FIELDS) {
70
+ const value = current[currentName];
71
+ if (value !== undefined)
72
+ legacy[legacyName] = value;
73
+ delete legacy[currentName];
74
+ }
77
75
  return legacy;
78
76
  }
79
77
  function envConfig(env = {}) {
@@ -112,9 +110,10 @@ export function resolveConfig(sources = {}) {
112
110
  merged.schemaVersion = CONFIG_SCHEMA_VERSION;
113
111
  return merged;
114
112
  }
115
- function isContained(parent, child) {
113
+ function pathContained(parent, child) {
116
114
  const rel = relative(parent, child);
117
- return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
115
+ const first = rel.split(/[\\/]/u, 1)[0];
116
+ return rel === "" || (!isAbsolute(rel) && first !== "..");
118
117
  }
119
118
  export async function resolveRuntimeRoot(projectCwd, requestedRoot, allowOutsideProject = false) {
120
119
  let project;
@@ -126,7 +125,7 @@ export async function resolveRuntimeRoot(projectCwd, requestedRoot, allowOutside
126
125
  catch (cause) {
127
126
  throw new RuntimeError("INVALID_ROOT", "Project or requested root does not exist", { projectCwd, requestedRoot, cause: cause instanceof Error ? cause.message : String(cause) });
128
127
  }
129
- if (!allowOutsideProject && !isContained(project, candidate)) {
128
+ if (!allowOutsideProject && !pathContained(project, candidate)) {
130
129
  throw new RuntimeError("ROOT_OUTSIDE_PROJECT", "Requested root resolves outside the project", { project, requestedRoot, resolvedRoot: candidate });
131
130
  }
132
131
  return candidate;
@@ -134,7 +133,7 @@ export async function resolveRuntimeRoot(projectCwd, requestedRoot, allowOutside
134
133
  function record(value) {
135
134
  return value !== null && typeof value === "object" && !Array.isArray(value) ? value : undefined;
136
135
  }
137
- function indexHealth(status) {
136
+ function indexHealth(status, knownExisting = false) {
138
137
  const index = record(status.index);
139
138
  const state = typeof index?.status === "string" ? index.status :
140
139
  typeof status.index_status === "string" ? status.index_status : undefined;
@@ -145,7 +144,7 @@ function indexHealth(status) {
145
144
  if (state === "ready" || state === "current" || index?.exists === true || status.indexed === true)
146
145
  return "ready";
147
146
  if (typeof status.index_path === "string" && typeof status.file_count === "number") {
148
- return status.file_count === 0 ? "missing" : "ready";
147
+ return knownExisting || status.file_count > 0 ? "ready" : "missing";
149
148
  }
150
149
  throw new RuntimeError("INDEX_STATUS_UNKNOWN", "ast-sgrep status did not report index freshness", { index: status.index, index_status: status.index_status });
151
150
  }
@@ -155,13 +154,63 @@ function incompatibleStatusFailure(cause) {
155
154
  const text = `${cause.message} ${JSON.stringify(cause.details)}`;
156
155
  return /incompatib|unsupported.{0,24}schema|schema.{0,24}(version|mismatch)/i.test(text);
157
156
  }
158
- function pathContained(root, path) {
159
- const rel = relative(root, path);
160
- return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
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
+ }
161
209
  }
162
210
  function canonicalizeAffectedPath(path) {
163
- const unresolved = [];
164
- let existing = resolve(path);
211
+ const absolute = resolve(path);
212
+ const unresolved = [basename(absolute)];
213
+ let existing = dirname(absolute);
165
214
  for (;;) {
166
215
  try {
167
216
  return resolve(realpathSync(existing), ...unresolved.reverse());
@@ -176,96 +225,296 @@ function canonicalizeAffectedPath(path) {
176
225
  }
177
226
  }
178
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
+ }
179
315
  export class FreshnessCoordinator {
180
316
  #states = new Map();
181
- #pendingPaths = new Set();
317
+ #pending = new Map();
182
318
  #interval;
183
319
  #now;
320
+ #watchFactory;
184
321
  constructor(options = {}) {
185
322
  this.#interval = finitePositive(options.refreshIntervalMs, DEFAULT_REFRESH_INTERVAL_MS, "refreshIntervalMs");
186
323
  this.#now = options.now ?? Date.now;
324
+ this.#watchFactory = options.watchFactory ?? watch;
187
325
  }
188
326
  markAffectedPath(path, cwd) {
189
327
  const affected = canonicalizeAffectedPath(isAbsolute(path) ? path : resolve(canonicalizeAffectedPath(cwd), path));
190
- this.#pendingPaths.add(affected);
328
+ let matched = false;
191
329
  for (const [root, state] of this.#states) {
192
- if (pathContained(root, affected))
193
- 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
+ }
194
359
  }
195
360
  }
196
361
  markRootDirty(root) {
197
- const canonical = canonicalizeAffectedPath(root);
362
+ const canonical = canonicalizeRootPath(root);
198
363
  const state = this.#states.get(canonical);
199
- if (state)
200
- state.dirtyGeneration += 1;
201
- else
202
- 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
+ }
203
374
  }
204
375
  async ensureFresh(runtime, context, options = {}) {
205
376
  const root = await runtime.resolveRoot(context);
206
377
  const rootContext = { cwd: root, [RESOLVED_ROOT]: true };
207
378
  let state = this.#states.get(root);
208
379
  if (!state) {
209
- 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
+ };
210
390
  this.#states.set(root, state);
211
391
  }
212
- for (const path of this.#pendingPaths) {
213
- 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))
398
+ continue;
399
+ if (pending.fullScanRequired) {
400
+ if (!pending.consumedFullScanRoots.has(root)) {
401
+ markStateFullScan(state);
402
+ pending.consumedFullScanRoots.add(root);
403
+ }
214
404
  continue;
215
- state.dirtyGeneration += 1;
216
- this.#pendingPaths.delete(path);
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);
217
414
  }
218
415
  if (state.inFlight) {
219
- await state.inFlight;
416
+ await waitForRefresh(state.inFlight, options.signal);
220
417
  return this.ensureFresh(runtime, rootContext, options);
221
418
  }
222
419
  const now = this.#now();
223
420
  const elapsed = now - state.lastRefreshAt;
421
+ // Lease expiry: initialized and interval elapsed (or clock went backwards).
224
422
  const expired = state.initialized && (elapsed < 0 || elapsed >= this.#interval);
225
423
  if (state.initialized && state.cleanGeneration === state.dirtyGeneration && !expired)
226
424
  return root;
227
425
  const refreshGeneration = state.dirtyGeneration;
228
- 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;
229
436
  const refresh = (async () => {
230
- let health = await runtime.inspectIndexCompatibility?.(rootContext);
231
- if (health !== "incompatible") {
232
- try {
233
- const status = await runtime.run(["status", ".", "--json"], rootContext, options);
234
- health = indexHealth(status);
235
- }
236
- catch (cause) {
237
- if (!incompatibleStatusFailure(cause))
238
- throw cause;
239
- health = "incompatible";
240
- }
241
- }
437
+ const health = await probeIndexHealth(runtime, rootContext, sharedOptions);
242
438
  const dirty = refreshGeneration > state.cleanGeneration;
243
439
  if (health === "incompatible") {
440
+ // Requisite variety: force rebuild path (hook or reindex).
244
441
  if (runtime.rebuildIncompatibleIndex)
245
- await runtime.rebuildIncompatibleIndex(rootContext, options);
442
+ await runtime.rebuildIncompatibleIndex(rootContext, sharedOptions);
246
443
  else
247
- await runtime.run(["reindex", ".", "--json"], rootContext, options);
444
+ await runIndex(runtime, true, rootContext, sharedOptions);
248
445
  }
249
- else if (health === "missing" || !wasInitialized || dirty) {
250
- 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);
251
448
  }
252
- else if (expired) {
253
- // Lease expired without dirty marks: incremental index (not force reindex)
254
- // so external create/modify/delete are reconciled without rebuild thrash (5du.9).
255
- await runtime.run(["index", ".", "--json"], rootContext, options);
449
+ else if (dirty) {
450
+ await runTargetedIndex(runtime, refreshPaths, rootContext, sharedOptions);
256
451
  }
257
452
  state.initialized = true;
258
453
  state.cleanGeneration = refreshGeneration;
454
+ if (state.dirtyGeneration === refreshGeneration) {
455
+ state.dirtyPaths.clear();
456
+ state.fullScanRequired = false;
457
+ }
259
458
  state.lastRefreshAt = this.#now();
260
459
  })();
261
- 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
+ }
262
487
  try {
263
- await refresh;
264
- 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;
265
512
  }
266
- finally {
267
- if (state.inFlight === refresh)
268
- 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);
269
518
  }
270
519
  }
271
520
  }
@@ -291,27 +540,63 @@ function getBinary(config, env, resolver) {
291
540
  return binary;
292
541
  }
293
542
  function byteLength(value) { return Buffer.byteLength(value, "utf8"); }
543
+ /** Present-field version identity checks. Pass `requireIdentity` for version --json. */
544
+ function assertVersionTriple(envelope, requireIdentity = false) {
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 });
548
+ }
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 });
565
+ }
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 });
589
+ }
294
590
  function parseEnvelope(result, limit) {
295
591
  const stdoutBytes = byteLength(result.stdout);
296
592
  const stderrBytes = byteLength(result.stderr);
297
- 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) {
298
595
  throw new RuntimeError("OUTPUT_LIMIT", "ast-sgrep output exceeded the configured limit", { limit, stdoutBytes, stderrBytes });
299
596
  }
300
597
  const code = result.exitCode ?? result.code ?? 0;
301
598
  if (code !== 0) {
302
- try {
303
- const value = JSON.parse(result.stdout);
304
- if (value && typeof value === "object" && value.tool === "asgrep" && value.schema_version === MACHINE_SCHEMA_VERSION && value.ok === false) {
305
- const failure = record(value.error);
306
- const message = typeof failure?.message === "string" ? failure.message : "ast-sgrep reported an operational failure";
307
- throw new RuntimeError("OPERATIONAL_ERROR", message, { command: value.command, error: failure, exitCode: code });
308
- }
309
- }
310
- catch (cause) {
311
- if (cause instanceof RuntimeError)
312
- throw cause;
313
- }
314
- 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);
315
600
  }
316
601
  let value;
317
602
  try {
@@ -320,9 +605,10 @@ function parseEnvelope(result, limit) {
320
605
  catch (cause) {
321
606
  throw new RuntimeError("MALFORMED_OUTPUT", "ast-sgrep returned malformed JSON", { cause: cause instanceof Error ? cause.message : String(cause) });
322
607
  }
323
- if (!value || typeof value !== "object" || Array.isArray(value))
608
+ const envelope = record(value);
609
+ if (!envelope)
324
610
  throw new RuntimeError("MALFORMED_OUTPUT", "ast-sgrep returned a non-object JSON payload");
325
- const envelope = value;
611
+ // Protocol field varieties (Ashby Keep) — sequential wire-contract checks stay here.
326
612
  if (envelope.tool !== "asgrep")
327
613
  throw new RuntimeError("TOOL_MISMATCH", "Response is not from ast-sgrep", { actual: envelope.tool });
328
614
  if (envelope.schema_version !== MACHINE_SCHEMA_VERSION)
@@ -330,14 +616,12 @@ function parseEnvelope(result, limit) {
330
616
  if (typeof envelope.ok !== "boolean")
331
617
  throw new RuntimeError("MALFORMED_OUTPUT", "ast-sgrep response is missing boolean ok");
332
618
  if (!envelope.ok) {
619
+ // Preserve pre-extract failure shape: plain object check (arrays allowed as error bag).
333
620
  const failure = envelope.error && typeof envelope.error === "object" ? envelope.error : undefined;
334
621
  const message = typeof failure?.message === "string" ? failure.message : "ast-sgrep reported an operational failure";
335
622
  throw new RuntimeError("OPERATIONAL_ERROR", message, { command: envelope.command, error: failure });
336
623
  }
337
- if (envelope.version !== undefined && envelope.version !== RUNTIME_VERSION)
338
- throw new RuntimeError("VERSION_MISMATCH", "ast-sgrep binary version does not match the extension", { expected: RUNTIME_VERSION, actual: envelope.version });
339
- if (envelope.machine_schema_version !== undefined && envelope.machine_schema_version !== MACHINE_SCHEMA_VERSION)
340
- throw new RuntimeError("PROTOCOL_MISMATCH", "ast-sgrep binary reports an incompatible machine protocol", { expected: MACHINE_SCHEMA_VERSION, actual: envelope.machine_schema_version });
624
+ assertVersionTriple(envelope);
341
625
  return envelope;
342
626
  }
343
627
  function indexPathFor(root, env) {
@@ -347,6 +631,34 @@ function indexPathFor(root, env) {
347
631
  const resolved = resolve(root, configured);
348
632
  return extname(resolved) === ".db" ? resolved : join(resolved, "index.db");
349
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
+ }
350
662
  function inspectIndexFile(path) {
351
663
  if (!existsSync(path))
352
664
  return "missing";
@@ -375,6 +687,7 @@ function inspectIndexFile(path) {
375
687
  }
376
688
  export class AstSgrepRuntime {
377
689
  pi;
690
+ watchExternalChanges = true;
378
691
  config;
379
692
  #resolver;
380
693
  #environment;
@@ -389,6 +702,9 @@ export class AstSgrepRuntime {
389
702
  ? resolveRuntimeRoot(context.cwd)
390
703
  : resolveRuntimeRoot(context.cwd, this.config.root, this.config.allowOutsideProject);
391
704
  }
705
+ resolveIndexPath(root) {
706
+ return indexPathFor(root, { ...this.#environment, ...this.config.env });
707
+ }
392
708
  async inspectIndexCompatibility(context) {
393
709
  const root = await this.resolveRoot(context);
394
710
  return inspectIndexFile(indexPathFor(root, { ...this.#environment, ...this.config.env }));
@@ -397,50 +713,23 @@ export class AstSgrepRuntime {
397
713
  const root = await this.resolveRoot(context);
398
714
  const env = { ...this.#environment, ...this.config.env, ...options.env };
399
715
  const indexPath = indexPathFor(root, env);
400
- const parent = dirname(indexPath);
401
- await mkdir(parent, { recursive: true });
402
- const temporaryDirectory = await mkdtemp(join(parent, ".rebuild-"));
403
- const replacementPath = join(temporaryDirectory, "index.db");
404
- const backupPath = `${indexPath}.backup-${randomUUID()}`;
405
- let priorMoved = false;
716
+ const quarantinesBefore = new Set(indexQuarantines(indexPath));
406
717
  try {
407
- const response = await this.run(["--index-path", replacementPath, "index", ".", "--json"], { cwd: root }, options);
408
- if (inspectIndexFile(replacementPath) !== "ready") {
409
- throw new RuntimeError("INDEX_REBUILD_INVALID", "Replacement index has an incompatible format", { expected: INDEX_FORMAT_VERSION });
410
- }
411
- if (existsSync(indexPath)) {
412
- await rename(indexPath, backupPath);
413
- priorMoved = true;
414
- }
415
- try {
416
- await rename(replacementPath, indexPath);
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 });
417
725
  }
418
- catch (cause) {
419
- if (priorMoved)
420
- await rename(backupPath, indexPath);
421
- throw cause;
726
+ if (inspectIndexFile(indexPath) !== "ready") {
727
+ throw new RuntimeError("INDEX_REBUILD_INVALID", "Rebuilt index has an incompatible format", { expected: INDEX_FORMAT_VERSION });
422
728
  }
423
- if (priorMoved)
424
- await rm(backupPath, { force: true });
425
729
  return response;
426
730
  }
427
731
  catch (cause) {
428
- let recoveryPath = indexPath;
429
- let priorIndexPreserved = existsSync(indexPath);
430
- if (priorMoved && !priorIndexPreserved && existsSync(backupPath)) {
431
- recoveryPath = backupPath;
432
- priorIndexPreserved = true;
433
- }
434
- throw new RuntimeError("INDEX_REBUILD_FAILED", "Incompatible index rebuild failed; the prior index remains recoverable", {
435
- indexPath,
436
- recoveryPath,
437
- priorIndexPreserved,
438
- expectedIndexFormat: INDEX_FORMAT_VERSION,
439
- cause: cause instanceof Error ? cause.message : String(cause),
440
- });
441
- }
442
- finally {
443
- await rm(temporaryDirectory, { recursive: true, force: true });
732
+ throwIndexRebuildFailed(cause, indexPath, quarantinesBefore);
444
733
  }
445
734
  }
446
735
  async run(args, context, options = {}) {
@@ -460,22 +749,21 @@ export class AstSgrepRuntime {
460
749
  return parseEnvelope(result, this.config.maxOutputBytes);
461
750
  }
462
751
  catch (cause) {
463
- if (cause instanceof RuntimeError)
464
- throw cause;
465
- if (options.signal?.aborted || (cause instanceof Error && cause.name === "AbortError"))
466
- throw new RuntimeError("CANCELLED", "ast-sgrep execution was cancelled");
467
- const message = cause instanceof Error ? cause.message : String(cause);
468
- if (/timeout|timed out/i.test(message))
469
- throw new RuntimeError("TIMEOUT", `ast-sgrep exceeded ${timeout}ms`, { timeoutMs: timeout });
470
- throw new RuntimeError("EXEC_FAILED", "Unable to execute ast-sgrep", { cause: message });
752
+ rethrowExecFailure(cause, options, timeout);
471
753
  }
472
754
  }
755
+ /** Absolute path to the native binary (for sticky serve / stdin batch spawn). */
756
+ resolveBinaryPath(options = {}) {
757
+ const env = { ...this.#environment, ...this.config.env, ...options.env, NO_COLOR: "1" };
758
+ return getBinary(this.config, env, this.#resolver);
759
+ }
760
+ /** Merged process env for native Code Mode workers. */
761
+ nativeEnv(options = {}) {
762
+ return { ...this.#environment, ...this.config.env, ...options.env, NO_COLOR: "1" };
763
+ }
473
764
  async checkCompatibility(context, options = {}) {
474
765
  const value = await this.run(["version", "--json"], context, options);
475
- if (value.version !== RUNTIME_VERSION)
476
- throw new RuntimeError("VERSION_MISMATCH", "ast-sgrep binary version does not match the extension", { expected: RUNTIME_VERSION, actual: value.version });
477
- if (value.machine_schema_version !== MACHINE_SCHEMA_VERSION)
478
- throw new RuntimeError("PROTOCOL_MISMATCH", "ast-sgrep binary reports an incompatible machine protocol", { expected: MACHINE_SCHEMA_VERSION, actual: value.machine_schema_version });
766
+ assertVersionTriple(value, true);
479
767
  return value;
480
768
  }
481
769
  }