pi-ast-sgrep 2.0.2 → 2.2.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.
Files changed (53) hide show
  1. package/README.md +16 -19
  2. package/dist/code-mode.d.ts +1 -1
  3. package/dist/code-mode.js +1 -1
  4. package/dist/codemode/connector.d.ts +18 -3
  5. package/dist/codemode/connector.js +85 -31
  6. package/dist/codemode/dispatch.d.ts +13 -1
  7. package/dist/codemode/dispatch.js +87 -24
  8. package/dist/codemode/guest-api.d.ts +16 -0
  9. package/dist/codemode/guest-api.js +194 -0
  10. package/dist/codemode/guest-worker.mjs +287 -0
  11. package/dist/codemode/index.d.ts +4 -3
  12. package/dist/codemode/index.js +4 -3
  13. package/dist/codemode/native.d.ts +1 -1
  14. package/dist/codemode/native.js +1 -1
  15. package/dist/codemode/runner.d.ts +13 -9
  16. package/dist/codemode/runner.js +411 -213
  17. package/dist/codemode/session-pool.d.ts +6 -1
  18. package/dist/codemode/session-pool.js +125 -32
  19. package/dist/codemode/types.d.ts +42 -2
  20. package/dist/codemode/types.js +40 -15
  21. package/dist/codemode/worker.d.ts +1 -1
  22. package/dist/codemode/worker.js +25 -2
  23. package/dist/host/commands.d.ts +6 -0
  24. package/dist/host/commands.js +49 -0
  25. package/dist/host/results.d.ts +123 -0
  26. package/dist/host/results.js +126 -0
  27. package/dist/host/tools.d.ts +28 -0
  28. package/dist/host/tools.js +802 -0
  29. package/dist/index.d.ts +7 -34
  30. package/dist/index.js +5 -543
  31. package/dist/runtime/config.d.ts +36 -0
  32. package/dist/runtime/config.js +98 -0
  33. package/dist/runtime/freshness.d.ts +43 -0
  34. package/dist/runtime/freshness.js +446 -0
  35. package/dist/runtime/index-health.d.ts +16 -0
  36. package/dist/runtime/index-health.js +111 -0
  37. package/dist/runtime/runtime.d.ts +48 -0
  38. package/dist/runtime/runtime.js +265 -0
  39. package/dist/runtime/sqlite.d.ts +15 -0
  40. package/dist/runtime/sqlite.js +63 -0
  41. package/dist/runtime/types.d.ts +55 -0
  42. package/dist/runtime/types.js +25 -0
  43. package/dist/ui/card.d.ts +66 -0
  44. package/dist/ui/card.js +375 -0
  45. package/dist/ui/present.d.ts +89 -0
  46. package/dist/ui/present.js +391 -0
  47. package/package.json +8 -7
  48. package/dist/codemode/sandbox-worker.d.ts +0 -1
  49. package/dist/codemode/sandbox-worker.js +0 -204
  50. package/dist/present.d.ts +0 -70
  51. package/dist/present.js +0 -260
  52. package/dist/runtime.d.ts +0 -137
  53. package/dist/runtime.js +0 -799
package/dist/runtime.js DELETED
@@ -1,799 +0,0 @@
1
- import { realpath } from "node:fs/promises";
2
- import { constants, accessSync, existsSync, readdirSync, realpathSync, statSync, watch } from "node:fs";
3
- import { DatabaseSync } from "node:sqlite";
4
- import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
5
- import { resolveBinary } from "ast-sgrep";
6
- export const RUNTIME_VERSION = "2.0.0";
7
- export const MACHINE_SCHEMA_VERSION = "1.0.0";
8
- export const CONFIG_SCHEMA_VERSION = 1;
9
- export const INDEX_FORMAT_VERSION = 12;
10
- export const DEFAULT_TIMEOUT_MS = 30_000;
11
- export const DEFAULT_MAX_OUTPUT_BYTES = 4 * 1024 * 1024;
12
- export const DEFAULT_REFRESH_INTERVAL_MS = 30_000;
13
- const MAX_TARGETED_INDEX_PATHS = 1_024;
14
- const RESOLVED_ROOT = Symbol("resolvedRoot");
15
- export class RuntimeError extends Error {
16
- code;
17
- details;
18
- constructor(code, message, details = {}) {
19
- super(message);
20
- this.code = code;
21
- this.details = details;
22
- this.name = "AstSgrepRuntimeError";
23
- }
24
- }
25
- function finitePositive(value, fallback, name) {
26
- if (value === undefined)
27
- return fallback;
28
- if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) {
29
- throw new RuntimeError("INVALID_CONFIG", `${name} must be a positive integer`);
30
- }
31
- return value;
32
- }
33
- function sameSetting(current, legacy, currentName, legacyName) {
34
- if (current !== undefined && legacy !== undefined && current !== legacy) {
35
- throw new RuntimeError("CONFIG_MIGRATION_CONFLICT", `Conflicting ${currentName} and legacy ${legacyName} values`, { currentName, legacyName });
36
- }
37
- return current ?? legacy;
38
- }
39
- const LEGACY_NUMBER_FIELDS = [
40
- ["timeoutMs", "timeout"],
41
- ["maxOutputBytes", "maxOutput"],
42
- ["refreshIntervalMs", "refreshInterval"],
43
- ];
44
- /** Convert schema 0/unversioned settings without mutating the rollback source. */
45
- export function migrateConfig(input = {}) {
46
- const value = { ...input };
47
- const schema = value.schemaVersion ?? 0;
48
- if (schema !== 0 && schema !== CONFIG_SCHEMA_VERSION) {
49
- throw new RuntimeError("CONFIG_VERSION_MISMATCH", "Unsupported ast-sgrep configuration schema", { supported: [0, CONFIG_SCHEMA_VERSION], actual: schema, rollbackSafe: true });
50
- }
51
- if (schema === CONFIG_SCHEMA_VERSION)
52
- return value;
53
- const legacy = value;
54
- const migrated = { ...legacy, schemaVersion: CONFIG_SCHEMA_VERSION };
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
- }
63
- return migrated;
64
- }
65
- /** Serialize current settings for a schema-0 rollback without mutating the current value. */
66
- export function rollbackConfig(input) {
67
- const current = migrateConfig(input);
68
- const legacy = { ...current, schemaVersion: 0 };
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
- }
75
- return legacy;
76
- }
77
- function envConfig(env = {}) {
78
- const result = {};
79
- // Canonical: ASGREP_BIN; alias AST_SGREP_BINARY (launcher historical name).
80
- const bin = env.ASGREP_BIN || env.AST_SGREP_BINARY;
81
- if (bin)
82
- result.binaryPath = bin;
83
- if (env.ASGREP_ROOT)
84
- result.root = env.ASGREP_ROOT;
85
- if (env.ASGREP_TIMEOUT_MS)
86
- result.timeoutMs = Number(env.ASGREP_TIMEOUT_MS);
87
- if (env.ASGREP_MAX_OUTPUT_BYTES)
88
- result.maxOutputBytes = Number(env.ASGREP_MAX_OUTPUT_BYTES);
89
- if (env.ASGREP_REFRESH_INTERVAL_MS)
90
- result.refreshIntervalMs = Number(env.ASGREP_REFRESH_INTERVAL_MS);
91
- return result;
92
- }
93
- /** Merge each setting independently, from the documented lowest to highest priority. */
94
- export function resolveConfig(sources = {}) {
95
- const merged = {
96
- timeoutMs: DEFAULT_TIMEOUT_MS,
97
- maxOutputBytes: DEFAULT_MAX_OUTPUT_BYTES,
98
- refreshIntervalMs: DEFAULT_REFRESH_INTERVAL_MS,
99
- ...migrateConfig(sources.defaults),
100
- ...envConfig(sources.environment),
101
- ...migrateConfig(sources.globalSettings),
102
- ...migrateConfig(sources.projectSettings),
103
- ...migrateConfig(sources.explicitProjectConfig),
104
- };
105
- merged.timeoutMs = finitePositive(merged.timeoutMs, DEFAULT_TIMEOUT_MS, "timeoutMs");
106
- merged.maxOutputBytes = finitePositive(merged.maxOutputBytes, DEFAULT_MAX_OUTPUT_BYTES, "maxOutputBytes");
107
- merged.refreshIntervalMs = finitePositive(merged.refreshIntervalMs, DEFAULT_REFRESH_INTERVAL_MS, "refreshIntervalMs");
108
- // Only explicit project configuration may relax project confinement.
109
- merged.allowOutsideProject = migrateConfig(sources.explicitProjectConfig).allowOutsideProject === true;
110
- merged.schemaVersion = CONFIG_SCHEMA_VERSION;
111
- return merged;
112
- }
113
- function pathContained(parent, child) {
114
- const rel = relative(parent, child);
115
- const first = rel.split(/[\\/]/u, 1)[0];
116
- return rel === "" || (!isAbsolute(rel) && first !== "..");
117
- }
118
- export async function resolveRuntimeRoot(projectCwd, requestedRoot, allowOutsideProject = false) {
119
- let project;
120
- let candidate;
121
- try {
122
- project = await realpath(resolve(projectCwd));
123
- candidate = await realpath(resolve(project, requestedRoot ?? "."));
124
- }
125
- catch (cause) {
126
- throw new RuntimeError("INVALID_ROOT", "Project or requested root does not exist", { projectCwd, requestedRoot, cause: cause instanceof Error ? cause.message : String(cause) });
127
- }
128
- if (!allowOutsideProject && !pathContained(project, candidate)) {
129
- throw new RuntimeError("ROOT_OUTSIDE_PROJECT", "Requested root resolves outside the project", { project, requestedRoot, resolvedRoot: candidate });
130
- }
131
- return candidate;
132
- }
133
- function record(value) {
134
- return value !== null && typeof value === "object" && !Array.isArray(value) ? value : undefined;
135
- }
136
- function indexHealth(status, knownExisting = false) {
137
- const index = record(status.index);
138
- const state = typeof index?.status === "string" ? index.status :
139
- typeof status.index_status === "string" ? status.index_status : undefined;
140
- if (state === "incompatible" || index?.compatible === false || status.index_compatible === false)
141
- return "incompatible";
142
- if (state === "missing" || index?.exists === false || status.indexed === false)
143
- return "missing";
144
- if (state === "ready" || state === "current" || index?.exists === true || status.indexed === true)
145
- return "ready";
146
- if (typeof status.index_path === "string" && typeof status.file_count === "number") {
147
- return knownExisting || status.file_count > 0 ? "ready" : "missing";
148
- }
149
- throw new RuntimeError("INDEX_STATUS_UNKNOWN", "ast-sgrep status did not report index freshness", { index: status.index, index_status: status.index_status });
150
- }
151
- function incompatibleStatusFailure(cause) {
152
- if (!(cause instanceof RuntimeError) || (cause.code !== "OPERATIONAL_ERROR" && cause.code !== "PROCESS_FAILED"))
153
- return false;
154
- const text = `${cause.message} ${JSON.stringify(cause.details)}`;
155
- return /incompatib|unsupported.{0,24}schema|schema.{0,24}(version|mismatch)/i.test(text);
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
- }
210
- function canonicalizeAffectedPath(path) {
211
- const absolute = resolve(path);
212
- const unresolved = [basename(absolute)];
213
- let existing = dirname(absolute);
214
- for (;;) {
215
- try {
216
- return resolve(realpathSync(existing), ...unresolved.reverse());
217
- }
218
- catch (cause) {
219
- const code = cause.code;
220
- const parent = dirname(existing);
221
- if ((code !== "ENOENT" && code !== "ENOTDIR") || parent === existing)
222
- return resolve(path);
223
- unresolved.push(basename(existing));
224
- existing = parent;
225
- }
226
- }
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
- }
315
- /** Shared refresh continues while other waiters remain; the last cancel stops it. */
316
- function attachRefreshWaiter(state, refresh, signal) {
317
- state.waiterCount += 1;
318
- let cancelledByWaiter = false;
319
- const wait = waitForRefresh(refresh, signal).catch((cause) => {
320
- cancelledByWaiter = cause instanceof RuntimeError && cause.code === "CANCELLED" && signal?.aborted === true;
321
- throw cause;
322
- });
323
- return wait.finally(() => {
324
- state.waiterCount = Math.max(0, state.waiterCount - 1);
325
- if (cancelledByWaiter && state.waiterCount === 0 && state.inFlight !== undefined) {
326
- state.refreshAbort?.abort();
327
- }
328
- });
329
- }
330
- export class FreshnessCoordinator {
331
- #states = new Map();
332
- #pending = new Map();
333
- #interval;
334
- #now;
335
- #watchFactory;
336
- constructor(options = {}) {
337
- this.#interval = finitePositive(options.refreshIntervalMs, DEFAULT_REFRESH_INTERVAL_MS, "refreshIntervalMs");
338
- this.#now = options.now ?? Date.now;
339
- this.#watchFactory = options.watchFactory ?? watch;
340
- }
341
- markAffectedPath(path, cwd) {
342
- const affected = canonicalizeAffectedPath(isAbsolute(path) ? path : resolve(canonicalizeAffectedPath(cwd), path));
343
- let matched = false;
344
- for (const [root, state] of this.#states) {
345
- if (!pathContained(root, affected))
346
- continue;
347
- markStatePathDirty(state, affected);
348
- matched = true;
349
- }
350
- if (!matched) {
351
- const pendingRoot = canonicalizeRootPath(cwd);
352
- // Before root resolution, the caller's cwd is the only trustworthy
353
- // confinement boundary. Do not retain unrelated/escaping paths forever.
354
- if (!pathContained(pendingRoot, affected))
355
- return;
356
- let pending = this.#pending.get(pendingRoot);
357
- if (!pending) {
358
- pending = { paths: new Set(), fullScanRequired: false, consumedFullScanRoots: new Set() };
359
- this.#pending.set(pendingRoot, pending);
360
- }
361
- if (changesIgnoreRules(affected)) {
362
- pending.paths.clear();
363
- pending.fullScanRequired = true;
364
- }
365
- else if (!pending.fullScanRequired) {
366
- if (!pending.paths.has(affected) && pending.paths.size >= MAX_TARGETED_INDEX_PATHS) {
367
- pending.paths.clear();
368
- pending.fullScanRequired = true;
369
- }
370
- else {
371
- pending.paths.add(affected);
372
- }
373
- }
374
- }
375
- }
376
- markRootDirty(root) {
377
- const canonical = canonicalizeRootPath(root);
378
- const state = this.#states.get(canonical);
379
- if (state) {
380
- markStateFullScan(state);
381
- }
382
- else {
383
- this.#pending.set(canonical, {
384
- paths: new Set(),
385
- fullScanRequired: true,
386
- consumedFullScanRoots: new Set(),
387
- });
388
- }
389
- }
390
- async ensureFresh(runtime, context, options = {}) {
391
- const root = canonicalizeRootPath(await runtime.resolveRoot(context));
392
- const rootContext = { cwd: root, [RESOLVED_ROOT]: true };
393
- let state = this.#states.get(root);
394
- if (!state) {
395
- state = {
396
- dirtyGeneration: 0,
397
- cleanGeneration: 0,
398
- dirtyPaths: new Set(),
399
- fullScanRequired: false,
400
- initialized: false,
401
- lastRefreshAt: 0,
402
- inFlight: undefined,
403
- refreshAbort: undefined,
404
- waiterCount: 0,
405
- watcher: undefined,
406
- };
407
- this.#states.set(root, state);
408
- }
409
- if (runtime.watchExternalChanges && state.watcher === undefined) {
410
- const indexPath = canonicalizeAffectedPath(runtime.resolveIndexPath?.(root) ?? join(root, ".asgrep", "index.db"));
411
- this.#startWatcher(root, state, indexPath);
412
- }
413
- for (const [pendingRoot, pending] of this.#pending) {
414
- if (!pathContained(pendingRoot, root) && !pathContained(root, pendingRoot))
415
- continue;
416
- if (pending.fullScanRequired) {
417
- if (!pending.consumedFullScanRoots.has(root)) {
418
- markStateFullScan(state);
419
- pending.consumedFullScanRoots.add(root);
420
- }
421
- continue;
422
- }
423
- for (const path of pending.paths) {
424
- if (!pathContained(root, path))
425
- continue;
426
- markStatePathDirty(state, path);
427
- pending.paths.delete(path);
428
- }
429
- if (pending.paths.size === 0)
430
- this.#pending.delete(pendingRoot);
431
- }
432
- if (state.inFlight) {
433
- await attachRefreshWaiter(state, state.inFlight, options.signal);
434
- return this.ensureFresh(runtime, rootContext, options);
435
- }
436
- if (options.signal?.aborted)
437
- throw cancelledRefreshWait();
438
- const now = this.#now();
439
- const elapsed = now - state.lastRefreshAt;
440
- // Lease expiry: initialized and interval elapsed (or clock went backwards).
441
- // Expiry re-probes status (missing/incompatible) but must not walk a ready
442
- // index. First search of a ready, clean index is the same: status only.
443
- const expired = state.initialized && (elapsed < 0 || elapsed >= this.#interval);
444
- if (state.initialized && state.cleanGeneration === state.dirtyGeneration && !expired)
445
- return root;
446
- const refreshGeneration = state.dirtyGeneration;
447
- const refreshPaths = [...state.dirtyPaths];
448
- const fullScanRequired = state.fullScanRequired;
449
- // Correctness work belongs to the root, not to whichever request happened
450
- // to start it. Individual callers may stop waiting, but cannot cancel the
451
- // shared refresh while other callers still depend on it. The last waiter
452
- // abort stops the in-flight index so Pi/tool cancel cannot leave rayon
453
- // workers burning CPU.
454
- const refreshAbort = new AbortController();
455
- state.refreshAbort = refreshAbort;
456
- const sharedOptions = { signal: refreshAbort.signal };
457
- if (options.timeoutMs !== undefined)
458
- sharedOptions.timeoutMs = options.timeoutMs;
459
- if (options.env !== undefined)
460
- sharedOptions.env = options.env;
461
- const refresh = (async () => {
462
- const health = await probeIndexHealth(runtime, rootContext, sharedOptions);
463
- const dirty = refreshGeneration > state.cleanGeneration;
464
- if (health === "incompatible") {
465
- // Requisite variety: force rebuild path (hook or reindex).
466
- if (runtime.rebuildIncompatibleIndex)
467
- await runtime.rebuildIncompatibleIndex(rootContext, sharedOptions);
468
- else
469
- await runIndex(runtime, true, rootContext, sharedOptions);
470
- }
471
- else if (health === "missing") {
472
- await runIndex(runtime, false, rootContext, sharedOptions);
473
- }
474
- else if (dirty && (fullScanRequired || refreshPaths.length === 0)) {
475
- await runIndex(runtime, false, rootContext, sharedOptions);
476
- }
477
- else if (dirty) {
478
- await runTargetedIndex(runtime, refreshPaths, rootContext, sharedOptions);
479
- }
480
- state.initialized = true;
481
- state.cleanGeneration = refreshGeneration;
482
- if (state.dirtyGeneration === refreshGeneration) {
483
- state.dirtyPaths.clear();
484
- state.fullScanRequired = false;
485
- }
486
- state.lastRefreshAt = this.#now();
487
- })();
488
- let tracked;
489
- tracked = refresh.finally(() => {
490
- if (state.inFlight === tracked) {
491
- state.inFlight = undefined;
492
- state.refreshAbort = undefined;
493
- }
494
- });
495
- state.inFlight = tracked;
496
- // If every waiter is cancelled, the root-owned refresh still needs a
497
- // rejection handler while it finishes in the background.
498
- void tracked.catch(() => undefined);
499
- await attachRefreshWaiter(state, tracked, options.signal);
500
- if (state.cleanGeneration !== state.dirtyGeneration) {
501
- return this.ensureFresh(runtime, rootContext, options);
502
- }
503
- return root;
504
- }
505
- shutdown() {
506
- for (const state of this.#states.values())
507
- state.watcher?.close();
508
- this.#states.clear();
509
- this.#pending.clear();
510
- }
511
- #startWatcher(root, state, indexPath) {
512
- if (!existsSync(root)) {
513
- state.watcher = null;
514
- markStateFullScan(state);
515
- return;
516
- }
517
- try {
518
- const watcher = this.#watchFactory(root, { recursive: true, persistent: false, encoding: "utf8" }, (eventType, filename) => {
519
- if (!filename) {
520
- markStateFullScan(state);
521
- return;
522
- }
523
- const affected = canonicalizeAffectedPath(join(root, filename));
524
- if (ignoredIndexWrite(root, affected, indexPath))
525
- return;
526
- if (eventType === "rename" || existingDirectory(affected)) {
527
- markStateFullScan(state);
528
- return;
529
- }
530
- markStatePathDirty(state, affected);
531
- });
532
- watcher.on("error", () => {
533
- watcher.close();
534
- // Watcher errors (including backend overflow) make event history
535
- // unknowable. Scan once, then rely on the periodic correctness lease;
536
- // retrying a permanently broken watcher on every request hot-loops.
537
- if (state.watcher === watcher)
538
- state.watcher = null;
539
- markStateFullScan(state);
540
- });
541
- state.watcher = watcher;
542
- }
543
- catch {
544
- // Do one correctness scan now, then rely on periodic scans instead of
545
- // retrying (and rescanning) on every query on unsupported filesystems.
546
- state.watcher = null;
547
- markStateFullScan(state);
548
- }
549
- }
550
- }
551
- function getBinary(config, env, resolver) {
552
- let binary;
553
- try {
554
- const options = config.binaryPath ? { binaryPath: config.binaryPath, env } : { env };
555
- binary = resolver(options);
556
- }
557
- catch (cause) {
558
- const message = cause instanceof Error ? cause.message : String(cause);
559
- if (config.binaryPath) {
560
- throw new RuntimeError("BINARY_NOT_FOUND", `Configured ast-sgrep binary is unavailable: ${config.binaryPath}`, { binaryPath: config.binaryPath, cause: message });
561
- }
562
- throw new RuntimeError("BINARY_RESOLUTION_FAILED", "Unable to resolve an ast-sgrep binary for this platform", { cause: message });
563
- }
564
- try {
565
- accessSync(binary, constants.X_OK);
566
- }
567
- catch (cause) {
568
- throw new RuntimeError("BINARY_NOT_EXECUTABLE", `ast-sgrep binary is not executable: ${binary}`, { binaryPath: binary, cause: cause instanceof Error ? cause.message : String(cause) });
569
- }
570
- return binary;
571
- }
572
- function byteLength(value) { return Buffer.byteLength(value, "utf8"); }
573
- /** Present-field version identity checks. Pass `requireIdentity` for version --json. */
574
- function assertVersionTriple(envelope, requireIdentity = false) {
575
- // Compound guards (same short-circuit as nested if): check only when required or field present.
576
- if ((requireIdentity || envelope.version !== undefined) && envelope.version !== RUNTIME_VERSION) {
577
- throw new RuntimeError("VERSION_MISMATCH", "ast-sgrep binary version does not match the extension", { expected: RUNTIME_VERSION, actual: envelope.version });
578
- }
579
- if ((requireIdentity || envelope.machine_schema_version !== undefined) && envelope.machine_schema_version !== MACHINE_SCHEMA_VERSION) {
580
- throw new RuntimeError("PROTOCOL_MISMATCH", "ast-sgrep binary reports an incompatible machine protocol", { expected: MACHINE_SCHEMA_VERSION, actual: envelope.machine_schema_version });
581
- }
582
- }
583
- /**
584
- * Nonzero CLI exit: prefer structured failed envelope (OPERATIONAL_ERROR), else PROCESS_FAILED.
585
- * Always throws — error-path extract so parseEnvelope keeps success-path protocol field checks.
586
- */
587
- function throwNonzeroProcessFailure(result, code) {
588
- try {
589
- const value = record(JSON.parse(result.stdout));
590
- // Wire-valid ok:false asgrep envelope → structured operational failure (not PROCESS_FAILED).
591
- if (value && value.tool === "asgrep" && value.schema_version === MACHINE_SCHEMA_VERSION && value.ok === false) {
592
- const failure = record(value.error);
593
- const message = typeof failure?.message === "string" ? failure.message : "ast-sgrep reported an operational failure";
594
- throw new RuntimeError("OPERATIONAL_ERROR", message, { command: value.command, error: failure, exitCode: code });
595
- }
596
- }
597
- catch (cause) {
598
- if (cause instanceof RuntimeError)
599
- throw cause;
600
- }
601
- throw new RuntimeError("PROCESS_FAILED", `ast-sgrep exited with code ${code}`, {
602
- exitCode: code,
603
- signal: result.signal ?? undefined,
604
- stderr: result.stderr.slice(0, 1024),
605
- });
606
- }
607
- /** Map exec failures (abort / timeout / generic) to RuntimeError. Re-throws RuntimeError as-is. */
608
- function rethrowExecFailure(cause, options, timeout) {
609
- if (cause instanceof RuntimeError)
610
- throw cause;
611
- if (options.signal?.aborted || (cause instanceof Error && cause.name === "AbortError")) {
612
- throw new RuntimeError("CANCELLED", "ast-sgrep execution was cancelled");
613
- }
614
- const message = cause instanceof Error ? cause.message : String(cause);
615
- if (/timeout|timed out/i.test(message)) {
616
- throw new RuntimeError("TIMEOUT", `ast-sgrep exceeded ${timeout}ms`, { timeoutMs: timeout });
617
- }
618
- throw new RuntimeError("EXEC_FAILED", "Unable to execute ast-sgrep", { cause: message });
619
- }
620
- function parseEnvelope(result, limit) {
621
- const stdoutBytes = byteLength(result.stdout);
622
- const stderrBytes = byteLength(result.stderr);
623
- // Byte lengths are non-negative: sum > limit covers either-side overflow and combined cap.
624
- if (stdoutBytes + stderrBytes > limit) {
625
- throw new RuntimeError("OUTPUT_LIMIT", "ast-sgrep output exceeded the configured limit", { limit, stdoutBytes, stderrBytes });
626
- }
627
- const code = result.exitCode ?? result.code ?? 0;
628
- if (code !== 0) {
629
- throwNonzeroProcessFailure(result, code);
630
- }
631
- let value;
632
- try {
633
- value = JSON.parse(result.stdout);
634
- }
635
- catch (cause) {
636
- throw new RuntimeError("MALFORMED_OUTPUT", "ast-sgrep returned malformed JSON", { cause: cause instanceof Error ? cause.message : String(cause) });
637
- }
638
- const envelope = record(value);
639
- if (!envelope)
640
- throw new RuntimeError("MALFORMED_OUTPUT", "ast-sgrep returned a non-object JSON payload");
641
- // Protocol field varieties (Ashby Keep) — sequential wire-contract checks stay here.
642
- if (envelope.tool !== "asgrep")
643
- throw new RuntimeError("TOOL_MISMATCH", "Response is not from ast-sgrep", { actual: envelope.tool });
644
- if (envelope.schema_version !== MACHINE_SCHEMA_VERSION)
645
- throw new RuntimeError("PROTOCOL_MISMATCH", "Unsupported ast-sgrep machine protocol", { expected: MACHINE_SCHEMA_VERSION, actual: envelope.schema_version });
646
- if (typeof envelope.ok !== "boolean")
647
- throw new RuntimeError("MALFORMED_OUTPUT", "ast-sgrep response is missing boolean ok");
648
- if (!envelope.ok) {
649
- // Preserve pre-extract failure shape: plain object check (arrays allowed as error bag).
650
- const failure = envelope.error && typeof envelope.error === "object" ? envelope.error : undefined;
651
- const message = typeof failure?.message === "string" ? failure.message : "ast-sgrep reported an operational failure";
652
- throw new RuntimeError("OPERATIONAL_ERROR", message, { command: envelope.command, error: failure });
653
- }
654
- assertVersionTriple(envelope);
655
- return envelope;
656
- }
657
- function indexPathFor(root, env) {
658
- const configured = env.ASGREP_INDEX_PATH;
659
- if (!configured)
660
- return join(root, ".asgrep", "index.db");
661
- const resolved = resolve(root, configured);
662
- return extname(resolved) === ".db" ? resolved : join(resolved, "index.db");
663
- }
664
- function indexQuarantines(indexPath) {
665
- const quarantinePrefix = `${basename(indexPath)}.corrupt`;
666
- try {
667
- return readdirSync(dirname(indexPath), { withFileTypes: true })
668
- .filter((entry) => entry.isFile() && (entry.name === quarantinePrefix || entry.name.startsWith(`${quarantinePrefix}.`)))
669
- .map((entry) => join(dirname(indexPath), entry.name))
670
- .sort();
671
- }
672
- catch {
673
- return [];
674
- }
675
- }
676
- /** Classify a rebuild failure and identify recovery copies made by this attempt. */
677
- function throwIndexRebuildFailed(cause, indexPath, quarantinesBefore) {
678
- const newQuarantines = indexQuarantines(indexPath).filter((path) => !quarantinesBefore.has(path));
679
- const recoveryPaths = [
680
- ...newQuarantines,
681
- ...(existsSync(indexPath) ? [indexPath] : []),
682
- ];
683
- throw new RuntimeError("INDEX_REBUILD_FAILED", "Incompatible index rebuild failed; the prior index remains recoverable", {
684
- indexPath,
685
- recoveryPath: recoveryPaths[0] ?? indexPath,
686
- recoveryPaths,
687
- priorIndexPreserved: recoveryPaths.length > 0,
688
- expectedIndexFormat: INDEX_FORMAT_VERSION,
689
- cause: cause instanceof Error ? cause.message : String(cause),
690
- });
691
- }
692
- function inspectIndexFile(path) {
693
- if (!existsSync(path))
694
- return "missing";
695
- let database;
696
- try {
697
- database = new DatabaseSync(path, { readOnly: true });
698
- const row = database.prepare("PRAGMA user_version").get();
699
- const version = Number(Object.values(row ?? {})[0]);
700
- if (version > INDEX_FORMAT_VERSION) {
701
- throw new RuntimeError("INDEX_VERSION_TOO_NEW", "Index schema is newer than this ast-sgrep runtime", {
702
- actual: version,
703
- supported: INDEX_FORMAT_VERSION,
704
- rollbackSafe: true,
705
- });
706
- }
707
- return version === INDEX_FORMAT_VERSION ? "ready" : "incompatible";
708
- }
709
- catch (cause) {
710
- if (cause instanceof RuntimeError)
711
- throw cause;
712
- return "incompatible";
713
- }
714
- finally {
715
- database?.close();
716
- }
717
- }
718
- export class AstSgrepRuntime {
719
- pi;
720
- watchExternalChanges = true;
721
- config;
722
- #resolver;
723
- #environment;
724
- constructor(pi, sources = {}, dependencies = {}) {
725
- this.pi = pi;
726
- this.#environment = sources.environment ?? process.env;
727
- this.config = resolveConfig({ ...sources, environment: this.#environment });
728
- this.#resolver = dependencies.resolveBinary ?? resolveBinary;
729
- }
730
- async resolveRoot(context) {
731
- return context[RESOLVED_ROOT]
732
- ? resolveRuntimeRoot(context.cwd)
733
- : resolveRuntimeRoot(context.cwd, this.config.root, this.config.allowOutsideProject);
734
- }
735
- resolveIndexPath(root) {
736
- return indexPathFor(root, { ...this.#environment, ...this.config.env });
737
- }
738
- async inspectIndexCompatibility(context) {
739
- const root = await this.resolveRoot(context);
740
- return inspectIndexFile(indexPathFor(root, { ...this.#environment, ...this.config.env }));
741
- }
742
- async rebuildIncompatibleIndex(context, options = {}) {
743
- const root = await this.resolveRoot(context);
744
- const env = { ...this.#environment, ...this.config.env, ...options.env };
745
- const indexPath = indexPathFor(root, env);
746
- const quarantinesBefore = new Set(indexQuarantines(indexPath));
747
- try {
748
- // Core reindex prepares files before opening one bulk transaction and
749
- // commits rewrites plus stale-row pruning together. Keeping the same DB
750
- // inode avoids stale warm NAPI sessions and removes rename crash windows.
751
- const response = await this.run(["reindex", ".", "--json"], { cwd: root }, options);
752
- const { failed, walkErrors } = indexCompletion(response, true);
753
- if (failed > 0 || walkErrors) {
754
- throw new RuntimeError("INDEX_UPDATE_INCOMPLETE", "ast-sgrep did not complete the incompatible-index rebuild", { failed, walkErrors, force: true });
755
- }
756
- if (inspectIndexFile(indexPath) !== "ready") {
757
- throw new RuntimeError("INDEX_REBUILD_INVALID", "Rebuilt index has an incompatible format", { expected: INDEX_FORMAT_VERSION });
758
- }
759
- return response;
760
- }
761
- catch (cause) {
762
- throwIndexRebuildFailed(cause, indexPath, quarantinesBefore);
763
- }
764
- }
765
- async run(args, context, options = {}) {
766
- if (!Array.isArray(args) || args.some((arg) => typeof arg !== "string"))
767
- throw new RuntimeError("INVALID_ARGUMENTS", "Arguments must be a string array");
768
- if (options.signal?.aborted)
769
- throw new RuntimeError("CANCELLED", "ast-sgrep execution was cancelled");
770
- const root = await this.resolveRoot(context);
771
- const timeout = finitePositive(options.timeoutMs, this.config.timeoutMs, "timeoutMs");
772
- const env = { ...this.#environment, ...this.config.env, ...options.env, NO_COLOR: "1" };
773
- const binary = getBinary(this.config, env, this.#resolver);
774
- try {
775
- const execOptions = { cwd: root, env, timeout };
776
- if (options.signal)
777
- execOptions.signal = options.signal;
778
- const result = await this.pi.exec(binary, Object.freeze([...args]), execOptions);
779
- return parseEnvelope(result, this.config.maxOutputBytes);
780
- }
781
- catch (cause) {
782
- rethrowExecFailure(cause, options, timeout);
783
- }
784
- }
785
- /** Absolute path to the native binary (for sticky serve / stdin batch spawn). */
786
- resolveBinaryPath(options = {}) {
787
- const env = { ...this.#environment, ...this.config.env, ...options.env, NO_COLOR: "1" };
788
- return getBinary(this.config, env, this.#resolver);
789
- }
790
- /** Merged process env for native Code Mode workers. */
791
- nativeEnv(options = {}) {
792
- return { ...this.#environment, ...this.config.env, ...options.env, NO_COLOR: "1" };
793
- }
794
- async checkCompatibility(context, options = {}) {
795
- const value = await this.run(["version", "--json"], context, options);
796
- assertVersionTriple(value, true);
797
- return value;
798
- }
799
- }