pi-ast-sgrep 2.0.0 → 2.0.2

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 CHANGED
@@ -132,9 +132,9 @@ Code Mode and `ast-sgrep-mcp` are separate front ends over the same Rust search
132
132
 
133
133
  Start Pi in the repository you want to search. The first search validates the index and lazily creates `<project-root>/.asgrep/` when needed. Run `/asgrep-index` if you want to build it before searching.
134
134
 
135
- After a successful Pi `write` or `edit`, the extension marks the affected path dirty and updates only known changed paths before the next search. It also watches the project for external filesystem changes: known file changes receive the same targeted update, while renames, directory changes, ignore-file edits, watcher errors, and ambiguous events trigger a correctness scan. `.asgrep` writes are excluded so indexing cannot dirty itself. If recursive watching is unavailable, an immediate scan plus the periodic full scan preserve correctness. Concurrent searches for the same root share one in-flight refresh.
135
+ After a successful Pi `write` or `edit`, the extension marks the affected path dirty and updates only known changed paths before the next search. It also watches the project for external filesystem changes: known file changes receive the same targeted update, while renames, directory changes, ignore-file edits, watcher errors, and ambiguous events trigger a correctness scan. `.asgrep` writes are excluded so indexing cannot dirty itself. If recursive watching is unavailable, an immediate scan on first use preserves correctness. A ready index is not walked on first search or on the refresh interval; the interval only re-checks index health. Concurrent searches for the same root share one in-flight refresh. The last cancelled waiter aborts that shared index so Pi cannot leave workers running after a search is cancelled. In-process indexing uses the host's available parallelism by default.
136
136
 
137
- The periodic interval forces a full incremental reconciliation even when the watcher reports nothing, covering dropped or coalesced filesystem events. Run `/asgrep-index` when you need freshness immediately after a large external operation; use `/asgrep-reindex` only for an incompatible or corrupt index, or when you explicitly need a strict full rebuild.
137
+ The refresh interval does not force a full tree walk. Run `/asgrep-index` when you need freshness immediately after a large external operation the watcher did not see; use `/asgrep-reindex` only for an incompatible or corrupt index, or when you explicitly need a strict full rebuild.
138
138
 
139
139
  The package never edits `.gitignore`. Add this entry yourself if index data must stay untracked:
140
140
 
@@ -209,6 +209,7 @@ Defaults are a 30-second operation timeout, 4 MiB output limit, and 30-second fr
209
209
  | `ASGREP_TIMEOUT_MS` | Set the native operation timeout. |
210
210
  | `ASGREP_MAX_OUTPUT_BYTES` | Bound native output. |
211
211
  | `ASGREP_REFRESH_INTERVAL_MS` | Set the idle freshness-check interval. |
212
+ | `ASGREP_INDEX_THREADS` | Cap in-process Code Mode indexing threads (default: host parallelism). |
212
213
  | `ASGREP_BIN` | Override the packaged binary for development. |
213
214
 
214
215
  Explicit project configuration can opt into `allowOutsideProject`; global settings and environment variables cannot relax the default project boundary. See the [complete package guide](https://github.com/AdityaVG13/ast-sgrep/blob/main/docs/pi-package.md) for schema and precedence details.
package/dist/present.d.ts CHANGED
@@ -45,6 +45,9 @@ export declare function formatSearchResult(response: EnvelopeLike, meta: {
45
45
  }, theme?: PresentTheme): string;
46
46
  export declare function formatStatusResult(response: EnvelopeLike, theme?: PresentTheme): string;
47
47
  export declare function formatIndexResult(command: string, response: EnvelopeLike, theme?: PresentTheme): string;
48
+ /** Local stand-in so we do not take a pi-tui dependency. Over-counts wide glyphs rather than under-count. */
49
+ export declare function visibleWidth(text: string): number;
50
+ export declare function truncateToWidth(text: string, maxWidth: number, ellipsis?: string): string;
48
51
  export declare function formatCodemodeResult(value: unknown, meta?: {
49
52
  stats?: {
50
53
  calls: number;
@@ -62,6 +65,6 @@ export declare class AsgrepText {
62
65
  constructor(text?: string);
63
66
  setText(text: string): void;
64
67
  invalidate(): void;
65
- render(_width: number): string[];
68
+ render(width: number): string[];
66
69
  }
67
70
  export declare function presentText(formatted: string, last: unknown): AsgrepText;
package/dist/present.js CHANGED
@@ -84,6 +84,97 @@ export function formatIndexResult(command, response, theme) {
84
84
  const tail = count === undefined ? "done" : `${count} file${count === 1 ? "" : "s"}`;
85
85
  return header(theme, command, [tail]);
86
86
  }
87
+ function ansiLengthAt(text, index) {
88
+ if (text.charCodeAt(index) !== 0x1b)
89
+ return 0;
90
+ const next = text[index + 1];
91
+ if (next === "[") {
92
+ let cursor = index + 2;
93
+ while (cursor < text.length) {
94
+ const code = text.charCodeAt(cursor);
95
+ if (code >= 0x40 && code <= 0x7e)
96
+ return cursor - index + 1;
97
+ cursor += 1;
98
+ }
99
+ return text.length - index;
100
+ }
101
+ if (next === "]") {
102
+ let cursor = index + 2;
103
+ while (cursor < text.length) {
104
+ if (text.charCodeAt(cursor) === 0x07)
105
+ return cursor - index + 1;
106
+ if (text.charCodeAt(cursor) === 0x1b && text[cursor + 1] === "\\")
107
+ return cursor - index + 2;
108
+ cursor += 1;
109
+ }
110
+ return text.length - index;
111
+ }
112
+ if (next === "P" || next === "X" || next === "^" || next === "_") {
113
+ let cursor = index + 2;
114
+ while (cursor < text.length) {
115
+ if (text.charCodeAt(cursor) === 0x1b && text[cursor + 1] === "\\")
116
+ return cursor - index + 2;
117
+ cursor += 1;
118
+ }
119
+ return text.length - index;
120
+ }
121
+ return Math.min(2, text.length - index);
122
+ }
123
+ function cellWidthAt(text, index) {
124
+ const code = text.charCodeAt(index);
125
+ if (code === 0x09)
126
+ return { width: 3, length: 1 };
127
+ if (code >= 0xd800 && code <= 0xdbff)
128
+ return { width: 2, length: 2 };
129
+ if (code <= 0x1f || (code >= 0x7f && code <= 0x9f))
130
+ return { width: 0, length: 1 };
131
+ if (code <= 0x7e)
132
+ return { width: 1, length: 1 };
133
+ return { width: 2, length: 1 };
134
+ }
135
+ /** Local stand-in so we do not take a pi-tui dependency. Over-counts wide glyphs rather than under-count. */
136
+ export function visibleWidth(text) {
137
+ let width = 0;
138
+ for (let index = 0; index < text.length;) {
139
+ const ansi = ansiLengthAt(text, index);
140
+ if (ansi > 0) {
141
+ index += ansi;
142
+ continue;
143
+ }
144
+ const cell = cellWidthAt(text, index);
145
+ width += cell.width;
146
+ index += cell.length;
147
+ }
148
+ return width;
149
+ }
150
+ export function truncateToWidth(text, maxWidth, ellipsis = "...") {
151
+ const limit = Math.max(0, maxWidth);
152
+ if (limit <= 0)
153
+ return "";
154
+ if (visibleWidth(text) <= limit)
155
+ return text;
156
+ const ellipsisWidth = visibleWidth(ellipsis);
157
+ if (ellipsisWidth >= limit)
158
+ return ellipsis.slice(0, limit);
159
+ const budget = limit - ellipsisWidth;
160
+ let kept = "";
161
+ let width = 0;
162
+ for (let index = 0; index < text.length;) {
163
+ const ansi = ansiLengthAt(text, index);
164
+ if (ansi > 0) {
165
+ kept += text.slice(index, index + ansi);
166
+ index += ansi;
167
+ continue;
168
+ }
169
+ const cell = cellWidthAt(text, index);
170
+ if (width + cell.width > budget)
171
+ break;
172
+ kept += text.slice(index, index + cell.length);
173
+ width += cell.width;
174
+ index += cell.length;
175
+ }
176
+ return `${kept}${ellipsis}`;
177
+ }
87
178
  function compactValue(value) {
88
179
  if (value === null || value === undefined)
89
180
  return String(value);
@@ -149,8 +240,11 @@ export class AsgrepText {
149
240
  this.#text = text;
150
241
  }
151
242
  invalidate() { }
152
- render(_width) {
153
- return this.#text.length === 0 ? [""] : this.#text.split("\n");
243
+ render(width) {
244
+ const maxWidth = Math.max(1, width);
245
+ if (this.#text.length === 0)
246
+ return [""];
247
+ return this.#text.split("\n").map((line) => truncateToWidth(line, maxWidth));
154
248
  }
155
249
  }
156
250
  export function presentText(formatted, last) {
package/dist/runtime.js CHANGED
@@ -312,6 +312,21 @@ function waitForRefresh(refresh, signal) {
312
312
  });
313
313
  });
314
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
+ }
315
330
  export class FreshnessCoordinator {
316
331
  #states = new Map();
317
332
  #pending = new Map();
@@ -373,7 +388,7 @@ export class FreshnessCoordinator {
373
388
  }
374
389
  }
375
390
  async ensureFresh(runtime, context, options = {}) {
376
- const root = await runtime.resolveRoot(context);
391
+ const root = canonicalizeRootPath(await runtime.resolveRoot(context));
377
392
  const rootContext = { cwd: root, [RESOLVED_ROOT]: true };
378
393
  let state = this.#states.get(root);
379
394
  if (!state) {
@@ -385,6 +400,8 @@ export class FreshnessCoordinator {
385
400
  initialized: false,
386
401
  lastRefreshAt: 0,
387
402
  inFlight: undefined,
403
+ refreshAbort: undefined,
404
+ waiterCount: 0,
388
405
  watcher: undefined,
389
406
  };
390
407
  this.#states.set(root, state);
@@ -413,12 +430,16 @@ export class FreshnessCoordinator {
413
430
  this.#pending.delete(pendingRoot);
414
431
  }
415
432
  if (state.inFlight) {
416
- await waitForRefresh(state.inFlight, options.signal);
433
+ await attachRefreshWaiter(state, state.inFlight, options.signal);
417
434
  return this.ensureFresh(runtime, rootContext, options);
418
435
  }
436
+ if (options.signal?.aborted)
437
+ throw cancelledRefreshWait();
419
438
  const now = this.#now();
420
439
  const elapsed = now - state.lastRefreshAt;
421
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.
422
443
  const expired = state.initialized && (elapsed < 0 || elapsed >= this.#interval);
423
444
  if (state.initialized && state.cleanGeneration === state.dirtyGeneration && !expired)
424
445
  return root;
@@ -427,8 +448,12 @@ export class FreshnessCoordinator {
427
448
  const fullScanRequired = state.fullScanRequired;
428
449
  // Correctness work belongs to the root, not to whichever request happened
429
450
  // to start it. Individual callers may stop waiting, but cannot cancel the
430
- // shared refresh while other callers depend on it.
431
- const sharedOptions = {};
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 };
432
457
  if (options.timeoutMs !== undefined)
433
458
  sharedOptions.timeoutMs = options.timeoutMs;
434
459
  if (options.env !== undefined)
@@ -443,7 +468,10 @@ export class FreshnessCoordinator {
443
468
  else
444
469
  await runIndex(runtime, true, rootContext, sharedOptions);
445
470
  }
446
- else if (health === "missing" || !state.initialized || expired || (dirty && (fullScanRequired || refreshPaths.length === 0))) {
471
+ else if (health === "missing") {
472
+ await runIndex(runtime, false, rootContext, sharedOptions);
473
+ }
474
+ else if (dirty && (fullScanRequired || refreshPaths.length === 0)) {
447
475
  await runIndex(runtime, false, rootContext, sharedOptions);
448
476
  }
449
477
  else if (dirty) {
@@ -459,14 +487,16 @@ export class FreshnessCoordinator {
459
487
  })();
460
488
  let tracked;
461
489
  tracked = refresh.finally(() => {
462
- if (state.inFlight === tracked)
490
+ if (state.inFlight === tracked) {
463
491
  state.inFlight = undefined;
492
+ state.refreshAbort = undefined;
493
+ }
464
494
  });
465
495
  state.inFlight = tracked;
466
496
  // If every waiter is cancelled, the root-owned refresh still needs a
467
497
  // rejection handler while it finishes in the background.
468
498
  void tracked.catch(() => undefined);
469
- await waitForRefresh(tracked, options.signal);
499
+ await attachRefreshWaiter(state, tracked, options.signal);
470
500
  if (state.cleanGeneration !== state.dirtyGeneration) {
471
501
  return this.ensureFresh(runtime, rootContext, options);
472
502
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ast-sgrep",
3
- "version": "2.0.0",
3
+ "version": "2.0.2",
4
4
  "description": "Native Code Mode, structural, graph, and semantic code search for Pi",
5
5
  "type": "module",
6
6
  "license": "MIT",