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
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Runtime configuration: schema, legacy migration, env overlay, precedence.
3
+ * Leaf module (imports types only).
4
+ */
5
+ import { CONFIG_SCHEMA_VERSION, DEFAULT_MAX_OUTPUT_BYTES, DEFAULT_REFRESH_INTERVAL_MS, DEFAULT_TIMEOUT_MS, RuntimeError, } from "./types.js";
6
+ export function finitePositive(value, fallback, name) {
7
+ if (value === undefined)
8
+ return fallback;
9
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) {
10
+ throw new RuntimeError("INVALID_CONFIG", `${name} must be a positive integer`);
11
+ }
12
+ return value;
13
+ }
14
+ function sameSetting(current, legacy, currentName, legacyName) {
15
+ if (current !== undefined && legacy !== undefined && current !== legacy) {
16
+ throw new RuntimeError("CONFIG_MIGRATION_CONFLICT", `Conflicting ${currentName} and legacy ${legacyName} values`, { currentName, legacyName });
17
+ }
18
+ return current ?? legacy;
19
+ }
20
+ const LEGACY_NUMBER_FIELDS = [
21
+ ["timeoutMs", "timeout"],
22
+ ["maxOutputBytes", "maxOutput"],
23
+ ["refreshIntervalMs", "refreshInterval"],
24
+ ];
25
+ /** Convert schema 0/unversioned settings without mutating the rollback source. */
26
+ export function migrateConfig(input = {}) {
27
+ const value = { ...input };
28
+ const schema = value.schemaVersion ?? 0;
29
+ if (schema !== 0 && schema !== CONFIG_SCHEMA_VERSION) {
30
+ throw new RuntimeError("CONFIG_VERSION_MISMATCH", "Unsupported ast-sgrep configuration schema", { supported: [0, CONFIG_SCHEMA_VERSION], actual: schema, rollbackSafe: true });
31
+ }
32
+ if (schema === CONFIG_SCHEMA_VERSION)
33
+ return value;
34
+ const legacy = value;
35
+ const migrated = { ...legacy, schemaVersion: CONFIG_SCHEMA_VERSION };
36
+ for (const [currentName, legacyName] of LEGACY_NUMBER_FIELDS) {
37
+ const next = sameSetting(value[currentName], legacy[legacyName], currentName, legacyName);
38
+ if (next !== undefined)
39
+ migrated[currentName] = next;
40
+ }
41
+ for (const [, legacyName] of LEGACY_NUMBER_FIELDS) {
42
+ delete migrated[legacyName];
43
+ }
44
+ return migrated;
45
+ }
46
+ /** Serialize current settings for a schema-0 rollback without mutating the current value. */
47
+ export function rollbackConfig(input) {
48
+ const current = migrateConfig(input);
49
+ const legacy = { ...current, schemaVersion: 0 };
50
+ for (const [currentName, legacyName] of LEGACY_NUMBER_FIELDS) {
51
+ const value = current[currentName];
52
+ if (value !== undefined)
53
+ legacy[legacyName] = value;
54
+ delete legacy[currentName];
55
+ }
56
+ return legacy;
57
+ }
58
+ /** env var → config key. ASGREP_BIN also accepts the launcher's legacy alias. */
59
+ const ENV_VARS = [
60
+ ["root", "ASGREP_ROOT", (v) => v],
61
+ ["timeoutMs", "ASGREP_TIMEOUT_MS", Number],
62
+ ["maxOutputBytes", "ASGREP_MAX_OUTPUT_BYTES", Number],
63
+ ["refreshIntervalMs", "ASGREP_REFRESH_INTERVAL_MS", Number],
64
+ ];
65
+ function envConfig(env = {}) {
66
+ const result = {};
67
+ // Canonical: ASGREP_BIN; alias AST_SGREP_BINARY (launcher historical name).
68
+ const bin = env.ASGREP_BIN || env.AST_SGREP_BINARY;
69
+ if (bin)
70
+ result.binaryPath = bin;
71
+ for (const [key, name, parse] of ENV_VARS) {
72
+ const raw = env[name];
73
+ if (raw !== undefined && raw !== "") {
74
+ result[key] = parse(raw);
75
+ }
76
+ }
77
+ return result;
78
+ }
79
+ /** Merge each setting independently, from the documented lowest to highest priority. */
80
+ export function resolveConfig(sources = {}) {
81
+ const merged = {
82
+ timeoutMs: DEFAULT_TIMEOUT_MS,
83
+ maxOutputBytes: DEFAULT_MAX_OUTPUT_BYTES,
84
+ refreshIntervalMs: DEFAULT_REFRESH_INTERVAL_MS,
85
+ ...migrateConfig(sources.defaults),
86
+ ...envConfig(sources.environment),
87
+ ...migrateConfig(sources.globalSettings),
88
+ ...migrateConfig(sources.projectSettings),
89
+ ...migrateConfig(sources.explicitProjectConfig),
90
+ };
91
+ merged.timeoutMs = finitePositive(merged.timeoutMs, DEFAULT_TIMEOUT_MS, "timeoutMs");
92
+ merged.maxOutputBytes = finitePositive(merged.maxOutputBytes, DEFAULT_MAX_OUTPUT_BYTES, "maxOutputBytes");
93
+ merged.refreshIntervalMs = finitePositive(merged.refreshIntervalMs, DEFAULT_REFRESH_INTERVAL_MS, "refreshIntervalMs");
94
+ // Only explicit project configuration may relax project confinement.
95
+ merged.allowOutsideProject = migrateConfig(sources.explicitProjectConfig).allowOutsideProject === true;
96
+ merged.schemaVersion = CONFIG_SCHEMA_VERSION;
97
+ return merged;
98
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Index freshness coordination: dirty tracking, shared root-owned refreshes,
3
+ * bounded per-caller waits, filesystem watchers.
4
+ */
5
+ import { type FSWatcher } from "node:fs";
6
+ import { type MachineEnvelope, type RunOptions, type RuntimeContext } from "./types.js";
7
+ import { type IndexHealth } from "./index-health.js";
8
+ export { type IndexHealth } from "./index-health.js";
9
+ export interface FreshnessRuntime {
10
+ run(args: readonly string[], context: RuntimeContext, options?: RunOptions): Promise<MachineEnvelope>;
11
+ resolveRoot(context: RuntimeContext): Promise<string>;
12
+ inspectIndexCompatibility?(context: RuntimeContext): Promise<IndexHealth>;
13
+ rebuildIncompatibleIndex?(context: RuntimeContext, options?: RunOptions): Promise<MachineEnvelope>;
14
+ /** Absolute database path whose SQLite/derived writes are owned by this runtime. */
15
+ resolveIndexPath?(root: string): string;
16
+ /**
17
+ * Optional warm native call (session sticky pool). When present, freshness
18
+ * prefers this over cold `run` for status/index — same Searcher as Code Mode.
19
+ */
20
+ nativeCall?(tool: string, args: Record<string, unknown>, context: RuntimeContext, options?: RunOptions): Promise<MachineEnvelope>;
21
+ /** Enable low-latency external filesystem change detection for real runtimes. */
22
+ watchExternalChanges?: boolean;
23
+ }
24
+ export interface FreshnessCoordinatorOptions {
25
+ refreshIntervalMs?: number;
26
+ /** Cap on how long one caller waits for an in-flight refresh (serve-stale after). */
27
+ maxWaitMs?: number;
28
+ now?: () => number;
29
+ watchFactory?: FreshnessWatchFactory;
30
+ }
31
+ export type FreshnessWatchFactory = (root: string, options: {
32
+ recursive: true;
33
+ persistent: false;
34
+ encoding: "utf8";
35
+ }, listener: (eventType: "rename" | "change", filename: string | null) => void) => FSWatcher;
36
+ export declare class FreshnessCoordinator {
37
+ #private;
38
+ constructor(options?: FreshnessCoordinatorOptions);
39
+ markAffectedPath(path: string, cwd: string): void;
40
+ markRootDirty(root: string): void;
41
+ ensureFresh(runtime: FreshnessRuntime, context: RuntimeContext, options?: RunOptions): Promise<string>;
42
+ shutdown(): void;
43
+ }
@@ -0,0 +1,446 @@
1
+ /**
2
+ * Index freshness coordination: dirty tracking, shared root-owned refreshes,
3
+ * bounded per-caller waits, filesystem watchers.
4
+ */
5
+ import { existsSync, realpathSync, statSync, watch } from "node:fs";
6
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
7
+ import { DEFAULT_FRESHNESS_WAIT_MS, DEFAULT_REFRESH_INTERVAL_MS, RuntimeError, RESOLVED_ROOT, } from "./types.js";
8
+ import { finitePositive } from "./config.js";
9
+ import { indexCompletion, incompatibleStatusFailure, indexHealth, pathContained, } from "./index-health.js";
10
+ export {} from "./index-health.js";
11
+ const MAX_TARGETED_INDEX_PATHS = 1_024;
12
+ /** Probe compatibility hook then status; map incompat operational failures to health. */
13
+ async function probeIndexHealth(runtime, rootContext, options) {
14
+ const hinted = await runtime.inspectIndexCompatibility?.(rootContext);
15
+ if (hinted === "missing" || hinted === "incompatible")
16
+ return hinted;
17
+ try {
18
+ const status = runtime.nativeCall
19
+ ? await runtime.nativeCall("index_status", {}, rootContext, options)
20
+ : await runtime.run(["status", ".", "--json"], rootContext, options);
21
+ return indexHealth(status);
22
+ }
23
+ catch (cause) {
24
+ if (!incompatibleStatusFailure(cause))
25
+ throw cause;
26
+ return "incompatible";
27
+ }
28
+ }
29
+ /**
30
+ * Run index_repo via the host's native call (the extension routes it out of
31
+ * process — see host/tools.ts) or CLI argv. force=true → reindex.
32
+ *
33
+ * Implicit (freshness-driven) refreshes index lexical/AST rows only: neural
34
+ * embeddings for a cold repo took 36-60s on large trees before the first search
35
+ * could answer. Embeddings are built by the explicit index/reindex tool.
36
+ */
37
+ async function runIndex(runtime, force, rootContext, options) {
38
+ const response = runtime.nativeCall
39
+ ? await runtime.nativeCall("index_repo", { force, use_embed: false }, rootContext, options)
40
+ : await runtime.run([force ? "reindex" : "index", ".", "--json", "--no-embed"], rootContext, options);
41
+ const { failed, walkErrors } = indexCompletion(response, true);
42
+ if (failed > 0 || walkErrors) {
43
+ throw new RuntimeError("INDEX_UPDATE_INCOMPLETE", "ast-sgrep did not complete the full index reconciliation", { failed, walkErrors, force });
44
+ }
45
+ }
46
+ /** Update known changed paths without walking the repository. */
47
+ async function runTargetedIndex(runtime, paths, rootContext, options) {
48
+ for (let offset = 0; offset < paths.length; offset += MAX_TARGETED_INDEX_PATHS) {
49
+ const chunk = paths.slice(offset, offset + MAX_TARGETED_INDEX_PATHS);
50
+ const response = runtime.nativeCall
51
+ ? await runtime.nativeCall("index_repo", { paths: chunk, use_embed: false }, rootContext, options)
52
+ : await runtime.run(["index", ".", "--json", "--no-embed", ...chunk.flatMap((path) => ["--path", path])], rootContext, options);
53
+ const { failed } = indexCompletion(response, false);
54
+ if (failed > 0) {
55
+ throw new RuntimeError("INDEX_UPDATE_INCOMPLETE", `ast-sgrep failed to update ${failed} changed path${failed === 1 ? "" : "s"}`, { failed, pathCount: chunk.length });
56
+ }
57
+ }
58
+ }
59
+ function canonicalizeAffectedPath(path) {
60
+ const absolute = resolve(path);
61
+ const unresolved = [basename(absolute)];
62
+ let existing = dirname(absolute);
63
+ for (;;) {
64
+ try {
65
+ return resolve(realpathSync(existing), ...unresolved.reverse());
66
+ }
67
+ catch (cause) {
68
+ const code = cause.code;
69
+ const parent = dirname(existing);
70
+ if ((code !== "ENOENT" && code !== "ENOTDIR") || parent === existing)
71
+ return resolve(path);
72
+ unresolved.push(basename(existing));
73
+ existing = parent;
74
+ }
75
+ }
76
+ }
77
+ function canonicalizeRootPath(path) {
78
+ try {
79
+ return realpathSync(resolve(path));
80
+ }
81
+ catch {
82
+ return canonicalizeAffectedPath(path);
83
+ }
84
+ }
85
+ function changesIgnoreRules(path) {
86
+ const name = basename(path);
87
+ return name === ".gitignore" || name === ".ignore" || name === ".asgrepignore";
88
+ }
89
+ function ignoredIndexWrite(root, path, indexPath) {
90
+ const defaultIndexDirectory = join(root, ".asgrep");
91
+ if (pathContained(defaultIndexDirectory, path))
92
+ return true;
93
+ const indexDirectory = dirname(indexPath);
94
+ if (dirname(path) !== indexDirectory)
95
+ return false;
96
+ const name = basename(path);
97
+ const sqliteArtifact = (database) => {
98
+ const suffix = name.slice(database.length);
99
+ return name.startsWith(database) && (suffix === ""
100
+ || suffix === "-wal"
101
+ || suffix === "-shm"
102
+ || suffix === "-journal"
103
+ || suffix === ".reindex.lock"
104
+ || /^\.corrupt(?:\.\d+)?(?:-(?:wal|shm|journal))?$/u.test(suffix));
105
+ };
106
+ return sqliteArtifact(basename(indexPath))
107
+ || sqliteArtifact("lexical.db")
108
+ || name === "semantic.ivf"
109
+ || (name.startsWith(".semantic.ivf.") && name.endsWith(".tmp"));
110
+ }
111
+ function existingDirectory(path) {
112
+ try {
113
+ return statSync(path).isDirectory();
114
+ }
115
+ catch {
116
+ return false;
117
+ }
118
+ }
119
+ function markStatePathDirty(state, path) {
120
+ state.dirtyGeneration += 1;
121
+ if (changesIgnoreRules(path)) {
122
+ state.dirtyPaths.clear();
123
+ state.fullScanRequired = true;
124
+ }
125
+ else if (!state.fullScanRequired) {
126
+ if (!state.dirtyPaths.has(path) && state.dirtyPaths.size >= MAX_TARGETED_INDEX_PATHS) {
127
+ state.dirtyPaths.clear();
128
+ state.fullScanRequired = true;
129
+ }
130
+ else {
131
+ state.dirtyPaths.add(path);
132
+ }
133
+ }
134
+ }
135
+ function markStateFullScan(state) {
136
+ state.dirtyGeneration += 1;
137
+ state.dirtyPaths.clear();
138
+ state.fullScanRequired = true;
139
+ }
140
+ function cancelledRefreshWait() {
141
+ return new RuntimeError("CANCELLED", "ast-sgrep freshness wait was cancelled");
142
+ }
143
+ /**
144
+ * A cancellation that belongs to another caller's dead refresh, not to this
145
+ * caller. The last waiter's cancel aborts shared work (resource hygiene); a
146
+ * caller holding a live signal must never inherit that teardown as its own
147
+ * failure — it settles the dead refresh and owns a fresh one instead.
148
+ */
149
+ function isForeignRefreshCancel(cause, signal) {
150
+ if (signal?.aborted === true)
151
+ return false;
152
+ if (cause instanceof RuntimeError)
153
+ return cause.code === "CANCELLED";
154
+ if (cause instanceof Error && cause.name === "AbortError")
155
+ return true;
156
+ const message = cause instanceof Error ? cause.message : String(cause);
157
+ return /aborted|was cancelled/i.test(message);
158
+ }
159
+ /** Stop one caller waiting without transferring cancellation ownership to shared work. */
160
+ function waitForRefresh(refresh, signal, waitMs) {
161
+ if (!signal && (!waitMs || waitMs <= 0))
162
+ return refresh;
163
+ if (signal?.aborted)
164
+ return Promise.reject(cancelledRefreshWait());
165
+ return new Promise((resolveWait, rejectWait) => {
166
+ let timer;
167
+ const cleanup = () => {
168
+ signal?.removeEventListener("abort", onAbort);
169
+ if (timer)
170
+ clearTimeout(timer);
171
+ };
172
+ const onAbort = () => {
173
+ cleanup();
174
+ rejectWait(cancelledRefreshWait());
175
+ };
176
+ signal?.addEventListener("abort", onAbort, { once: true });
177
+ // A caller never spends its whole budget on freshness: after waitMs it
178
+ // serves whatever index state exists instead of dying on a 60s index.
179
+ if (waitMs && waitMs > 0) {
180
+ timer = setTimeout(() => {
181
+ cleanup();
182
+ rejectWait(new RuntimeError("TIMEOUT", "ast-sgrep freshness wait exceeded " + waitMs + "ms; serving the current index", { timeoutMs: waitMs }));
183
+ }, waitMs);
184
+ }
185
+ refresh.then(() => {
186
+ cleanup();
187
+ resolveWait();
188
+ }, (cause) => {
189
+ cleanup();
190
+ rejectWait(cause);
191
+ });
192
+ });
193
+ }
194
+ /** Shared refresh continues while other waiters remain; the last cancel stops it. */
195
+ function attachRefreshWaiter(state, refresh, signal, waitMs) {
196
+ state.waiterCount += 1;
197
+ let cancelledByWaiter = false;
198
+ const wait = waitForRefresh(refresh, signal, waitMs).catch((cause) => {
199
+ cancelledByWaiter = cause instanceof RuntimeError && cause.code === "CANCELLED" && signal?.aborted === true;
200
+ throw cause;
201
+ });
202
+ return wait.finally(() => {
203
+ state.waiterCount = Math.max(0, state.waiterCount - 1);
204
+ if (cancelledByWaiter && state.waiterCount === 0 && state.inFlight !== undefined) {
205
+ state.refreshAbort?.abort();
206
+ }
207
+ });
208
+ }
209
+ export class FreshnessCoordinator {
210
+ #states = new Map();
211
+ #pending = new Map();
212
+ #interval;
213
+ #maxWaitMs;
214
+ #now;
215
+ #watchFactory;
216
+ constructor(options = {}) {
217
+ this.#interval = finitePositive(options.refreshIntervalMs, DEFAULT_REFRESH_INTERVAL_MS, "refreshIntervalMs");
218
+ this.#maxWaitMs = finitePositive(options.maxWaitMs, DEFAULT_FRESHNESS_WAIT_MS, "maxWaitMs");
219
+ this.#now = options.now ?? Date.now;
220
+ this.#watchFactory = options.watchFactory ?? watch;
221
+ }
222
+ /** One caller's freshness budget: bounded, and never more than its own timeout. */
223
+ #waitBudget(options) {
224
+ const budget = this.#maxWaitMs;
225
+ return options.timeoutMs !== undefined ? Math.min(budget, options.timeoutMs) : budget;
226
+ }
227
+ markAffectedPath(path, cwd) {
228
+ const affected = canonicalizeAffectedPath(isAbsolute(path) ? path : resolve(canonicalizeAffectedPath(cwd), path));
229
+ let matched = false;
230
+ for (const [root, state] of this.#states) {
231
+ if (!pathContained(root, affected))
232
+ continue;
233
+ markStatePathDirty(state, affected);
234
+ matched = true;
235
+ }
236
+ if (!matched) {
237
+ const pendingRoot = canonicalizeRootPath(cwd);
238
+ // Before root resolution, the caller's cwd is the only trustworthy
239
+ // confinement boundary. Do not retain unrelated/escaping paths forever.
240
+ if (!pathContained(pendingRoot, affected))
241
+ return;
242
+ let pending = this.#pending.get(pendingRoot);
243
+ if (!pending) {
244
+ pending = { paths: new Set(), fullScanRequired: false, consumedFullScanRoots: new Set() };
245
+ this.#pending.set(pendingRoot, pending);
246
+ }
247
+ if (changesIgnoreRules(affected)) {
248
+ pending.paths.clear();
249
+ pending.fullScanRequired = true;
250
+ }
251
+ else if (!pending.fullScanRequired) {
252
+ if (!pending.paths.has(affected) && pending.paths.size >= MAX_TARGETED_INDEX_PATHS) {
253
+ pending.paths.clear();
254
+ pending.fullScanRequired = true;
255
+ }
256
+ else {
257
+ pending.paths.add(affected);
258
+ }
259
+ }
260
+ }
261
+ }
262
+ markRootDirty(root) {
263
+ const canonical = canonicalizeRootPath(root);
264
+ const state = this.#states.get(canonical);
265
+ if (state) {
266
+ markStateFullScan(state);
267
+ }
268
+ else {
269
+ this.#pending.set(canonical, {
270
+ paths: new Set(),
271
+ fullScanRequired: true,
272
+ consumedFullScanRoots: new Set(),
273
+ });
274
+ }
275
+ }
276
+ async ensureFresh(runtime, context, options = {}) {
277
+ const root = canonicalizeRootPath(await runtime.resolveRoot(context));
278
+ const rootContext = { cwd: root, [RESOLVED_ROOT]: true };
279
+ let state = this.#states.get(root);
280
+ if (!state) {
281
+ state = {
282
+ dirtyGeneration: 0,
283
+ cleanGeneration: 0,
284
+ dirtyPaths: new Set(),
285
+ fullScanRequired: false,
286
+ initialized: false,
287
+ lastRefreshAt: 0,
288
+ inFlight: undefined,
289
+ refreshAbort: undefined,
290
+ waiterCount: 0,
291
+ watcher: undefined,
292
+ };
293
+ this.#states.set(root, state);
294
+ }
295
+ if (runtime.watchExternalChanges && state.watcher === undefined) {
296
+ const indexPath = canonicalizeAffectedPath(runtime.resolveIndexPath?.(root) ?? join(root, ".asgrep", "index.db"));
297
+ this.#startWatcher(root, state, indexPath);
298
+ }
299
+ for (const [pendingRoot, pending] of this.#pending) {
300
+ if (!pathContained(pendingRoot, root) && !pathContained(root, pendingRoot))
301
+ continue;
302
+ if (pending.fullScanRequired) {
303
+ if (!pending.consumedFullScanRoots.has(root)) {
304
+ markStateFullScan(state);
305
+ pending.consumedFullScanRoots.add(root);
306
+ }
307
+ continue;
308
+ }
309
+ for (const path of pending.paths) {
310
+ if (!pathContained(root, path))
311
+ continue;
312
+ markStatePathDirty(state, path);
313
+ pending.paths.delete(path);
314
+ }
315
+ if (pending.paths.size === 0)
316
+ this.#pending.delete(pendingRoot);
317
+ }
318
+ if (state.inFlight) {
319
+ const shared = state.inFlight;
320
+ try {
321
+ await attachRefreshWaiter(state, shared, options.signal, this.#waitBudget(options));
322
+ }
323
+ catch (cause) {
324
+ if (!isForeignRefreshCancel(cause, options.signal))
325
+ throw cause;
326
+ // Another caller's cancel tore down the shared refresh. This caller is
327
+ // still alive: settle the dead promise, then decide for itself below.
328
+ await shared.catch(() => undefined);
329
+ }
330
+ return this.ensureFresh(runtime, rootContext, options);
331
+ }
332
+ if (options.signal?.aborted)
333
+ throw cancelledRefreshWait();
334
+ const now = this.#now();
335
+ const elapsed = now - state.lastRefreshAt;
336
+ // Lease expiry: initialized and interval elapsed (or clock went backwards).
337
+ // Expiry re-probes status (missing/incompatible) but must not walk a ready
338
+ // index. First search of a ready, clean index is the same: status only.
339
+ const expired = state.initialized && (elapsed < 0 || elapsed >= this.#interval);
340
+ if (state.initialized && state.cleanGeneration === state.dirtyGeneration && !expired)
341
+ return root;
342
+ const refreshGeneration = state.dirtyGeneration;
343
+ const refreshPaths = [...state.dirtyPaths];
344
+ const fullScanRequired = state.fullScanRequired;
345
+ // Correctness work belongs to the root, not to whichever request happened
346
+ // to start it. Individual callers may stop waiting, but cannot cancel the
347
+ // shared refresh while other callers still depend on it. The last waiter
348
+ // abort stops the in-flight index so Pi/tool cancel cannot leave rayon
349
+ // workers burning CPU.
350
+ const refreshAbort = new AbortController();
351
+ state.refreshAbort = refreshAbort;
352
+ const sharedOptions = { signal: refreshAbort.signal };
353
+ if (options.timeoutMs !== undefined)
354
+ sharedOptions.timeoutMs = options.timeoutMs;
355
+ if (options.env !== undefined)
356
+ sharedOptions.env = options.env;
357
+ const refresh = (async () => {
358
+ const health = await probeIndexHealth(runtime, rootContext, sharedOptions);
359
+ const dirty = refreshGeneration > state.cleanGeneration;
360
+ if (health === "incompatible") {
361
+ // Requisite variety: force rebuild path (hook or reindex).
362
+ if (runtime.rebuildIncompatibleIndex)
363
+ await runtime.rebuildIncompatibleIndex(rootContext, sharedOptions);
364
+ else
365
+ await runIndex(runtime, true, rootContext, sharedOptions);
366
+ }
367
+ else if (health === "missing") {
368
+ await runIndex(runtime, false, rootContext, sharedOptions);
369
+ }
370
+ else if (dirty && (fullScanRequired || refreshPaths.length === 0)) {
371
+ await runIndex(runtime, false, rootContext, sharedOptions);
372
+ }
373
+ else if (dirty) {
374
+ await runTargetedIndex(runtime, refreshPaths, rootContext, sharedOptions);
375
+ }
376
+ state.initialized = true;
377
+ state.cleanGeneration = refreshGeneration;
378
+ if (state.dirtyGeneration === refreshGeneration) {
379
+ state.dirtyPaths.clear();
380
+ state.fullScanRequired = false;
381
+ }
382
+ state.lastRefreshAt = this.#now();
383
+ })();
384
+ let tracked;
385
+ tracked = refresh.finally(() => {
386
+ if (state.inFlight === tracked) {
387
+ state.inFlight = undefined;
388
+ state.refreshAbort = undefined;
389
+ }
390
+ });
391
+ state.inFlight = tracked;
392
+ // If every waiter is cancelled, the root-owned refresh still needs a
393
+ // rejection handler while it finishes in the background.
394
+ void tracked.catch(() => undefined);
395
+ await attachRefreshWaiter(state, tracked, options.signal, this.#waitBudget(options));
396
+ if (state.cleanGeneration !== state.dirtyGeneration) {
397
+ return this.ensureFresh(runtime, rootContext, options);
398
+ }
399
+ return root;
400
+ }
401
+ shutdown() {
402
+ for (const state of this.#states.values())
403
+ state.watcher?.close();
404
+ this.#states.clear();
405
+ this.#pending.clear();
406
+ }
407
+ #startWatcher(root, state, indexPath) {
408
+ if (!existsSync(root)) {
409
+ state.watcher = null;
410
+ markStateFullScan(state);
411
+ return;
412
+ }
413
+ try {
414
+ const watcher = this.#watchFactory(root, { recursive: true, persistent: false, encoding: "utf8" }, (eventType, filename) => {
415
+ if (!filename) {
416
+ markStateFullScan(state);
417
+ return;
418
+ }
419
+ const affected = canonicalizeAffectedPath(join(root, filename));
420
+ if (ignoredIndexWrite(root, affected, indexPath))
421
+ return;
422
+ if (eventType === "rename" || existingDirectory(affected)) {
423
+ markStateFullScan(state);
424
+ return;
425
+ }
426
+ markStatePathDirty(state, affected);
427
+ });
428
+ watcher.on("error", () => {
429
+ watcher.close();
430
+ // Watcher errors (including backend overflow) make event history
431
+ // unknowable. Scan once, then rely on the periodic correctness lease;
432
+ // retrying a permanently broken watcher on every request hot-loops.
433
+ if (state.watcher === watcher)
434
+ state.watcher = null;
435
+ markStateFullScan(state);
436
+ });
437
+ state.watcher = watcher;
438
+ }
439
+ catch {
440
+ // Do one correctness scan now, then rely on periodic scans instead of
441
+ // retrying (and rescanning) on every query on unsupported filesystems.
442
+ state.watcher = null;
443
+ markStateFullScan(state);
444
+ }
445
+ }
446
+ }
@@ -0,0 +1,16 @@
1
+ import { type MachineEnvelope } from "./types.js";
2
+ export type IndexHealth = "ready" | "missing" | "incompatible";
3
+ export declare function pathContained(parent: string, child: string): boolean;
4
+ export declare function record(value: unknown): Record<string, unknown> | undefined;
5
+ export declare function indexHealth(status: MachineEnvelope): IndexHealth;
6
+ export declare function incompatibleStatusFailure(cause: unknown): boolean;
7
+ export declare function indexCompletion(response: MachineEnvelope, requireWalkErrors: boolean): {
8
+ failed: number;
9
+ walkErrors: boolean;
10
+ };
11
+ export declare function indexPathFor(root: string, env: NodeJS.ProcessEnv): string;
12
+ export declare function indexQuarantines(indexPath: string): string[];
13
+ /** Classify a rebuild failure and identify recovery copies made by this attempt. */
14
+ export declare function throwIndexRebuildFailed(cause: unknown, indexPath: string, quarantinesBefore: ReadonlySet<string>): never;
15
+ /** Read the on-disk index format marker. The binary is the authority on what it can read. */
16
+ export declare function inspectIndexFile(path: string): "missing" | "incompatible" | number;