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/README.md +42 -12
- package/dist/code-mode.d.ts +18 -10
- package/dist/code-mode.js +126 -42
- package/dist/codemode/connector.d.ts +37 -9
- package/dist/codemode/connector.js +29 -23
- package/dist/codemode/dispatch.d.ts +2 -3
- package/dist/codemode/dispatch.js +109 -131
- package/dist/codemode/index.d.ts +8 -9
- package/dist/codemode/index.js +7 -8
- package/dist/codemode/native.d.ts +6 -9
- package/dist/codemode/native.js +10 -1
- package/dist/codemode/runner.d.ts +37 -0
- package/dist/codemode/runner.js +242 -0
- package/dist/codemode/sandbox-worker.d.ts +1 -0
- package/dist/codemode/sandbox-worker.js +204 -0
- package/dist/codemode/session-pool.d.ts +1 -0
- package/dist/codemode/session-pool.js +94 -29
- package/dist/codemode/types.d.ts +2 -2
- package/dist/codemode/types.js +2 -2
- package/dist/codemode/worker.d.ts +3 -0
- package/dist/codemode/worker.js +99 -42
- package/dist/index.d.ts +6 -1
- package/dist/index.js +192 -106
- package/dist/present.d.ts +67 -0
- package/dist/present.js +166 -0
- package/dist/runtime.d.ts +16 -2
- package/dist/runtime.js +399 -131
- package/package.json +6 -14
- package/dist/codemode/sandbox.d.ts +0 -25
- package/dist/codemode/sandbox.js +0 -192
- package/skills/ast-sgrep/SKILL.md +0 -73
- package/skills/ast-sgrep/references/query-guide.md +0 -22
package/dist/runtime.js
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
import {
|
|
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 = "
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
158
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
328
|
+
let matched = false;
|
|
185
329
|
for (const [root, state] of this.#states) {
|
|
186
|
-
if (pathContained(root, affected))
|
|
187
|
-
|
|
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 =
|
|
362
|
+
const canonical = canonicalizeRootPath(root);
|
|
192
363
|
const state = this.#states.get(canonical);
|
|
193
|
-
if (state)
|
|
194
|
-
state
|
|
195
|
-
|
|
196
|
-
|
|
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 = {
|
|
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
|
-
|
|
207
|
-
|
|
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
|
-
|
|
210
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
|
444
|
+
await runIndex(runtime, true, rootContext, sharedOptions);
|
|
246
445
|
}
|
|
247
|
-
else if (health === "missing" || !
|
|
248
|
-
|
|
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 (
|
|
254
|
-
|
|
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
|
-
|
|
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
|
-
|
|
268
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
307
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
608
|
+
const envelope = record(value);
|
|
609
|
+
if (!envelope)
|
|
341
610
|
throw new RuntimeError("MALFORMED_OUTPUT", "ast-sgrep returned a non-object JSON payload");
|
|
342
|
-
|
|
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
|
|
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
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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 (
|
|
426
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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). */
|