pi-lens 3.8.65 → 3.8.67

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 (96) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/README.md +0 -1
  3. package/dist/clients/actionable-warnings-logger.js +11 -36
  4. package/dist/clients/ast-grep-tool-logger.js +11 -36
  5. package/dist/clients/biome-client.js +10 -2
  6. package/dist/clients/bootstrap.js +1 -3
  7. package/dist/clients/cascade-logger.js +7 -12
  8. package/dist/clients/complexity-client.js +397 -623
  9. package/dist/clients/dead-code-logger.js +11 -22
  10. package/dist/clients/dependency-checker.js +15 -2
  11. package/dist/clients/deps/vscode-jsonrpc.js +1 -1
  12. package/dist/clients/diagnostic-logger.js +7 -31
  13. package/dist/clients/dispatch/auxiliary-lsp.js +38 -0
  14. package/dist/clients/dispatch/dispatcher.js +4 -39
  15. package/dist/clients/dispatch/fact-runner.js +9 -57
  16. package/dist/clients/dispatch/facts/comment-facts.js +12 -25
  17. package/dist/clients/dispatch/facts/function-facts.js +161 -132
  18. package/dist/clients/dispatch/facts/import-facts.js +164 -117
  19. package/dist/clients/dispatch/facts/tree-sitter-facts.js +34 -0
  20. package/dist/clients/dispatch/facts/try-catch-facts.js +62 -68
  21. package/dist/clients/dispatch/inline-suppressions.js +61 -0
  22. package/dist/clients/dispatch/integration.js +94 -32
  23. package/dist/clients/dispatch/plan.js +0 -49
  24. package/dist/clients/dispatch/priorities.js +0 -1
  25. package/dist/clients/dispatch/rules/cors-wildcard.js +54 -0
  26. package/dist/clients/dispatch/rules/high-import-coupling.js +37 -0
  27. package/dist/clients/dispatch/rules/no-commented-credentials.js +52 -0
  28. package/dist/clients/dispatch/runners/index.js +2 -7
  29. package/dist/clients/dispatch/runners/lsp.js +18 -6
  30. package/dist/clients/dispatch/runners/tree-sitter.js +11 -64
  31. package/dist/clients/dispatch/runners/utils/runner-helpers.js +28 -33
  32. package/dist/clients/dispatch/tool-profile.js +0 -1
  33. package/dist/clients/dispatch/types.js +1 -1
  34. package/dist/clients/formatters.js +10 -0
  35. package/dist/clients/git-guard.js +1 -1
  36. package/dist/clients/grammar-source.js +65 -2
  37. package/dist/clients/jscpd-client.js +8 -2
  38. package/dist/clients/language-policy.js +2 -2
  39. package/dist/clients/latency-logger.js +10 -15
  40. package/dist/clients/lsp/client.js +74 -11
  41. package/dist/clients/lsp/config.js +61 -3
  42. package/dist/clients/lsp/index.js +43 -3
  43. package/dist/clients/lsp/launch.js +3 -32
  44. package/dist/clients/mcp/session.js +0 -1
  45. package/dist/clients/module-report.js +16 -5
  46. package/dist/clients/ndjson-logger.js +150 -0
  47. package/dist/clients/package-manager.js +65 -0
  48. package/dist/clients/pipeline.js +25 -41
  49. package/dist/clients/project-diagnostics/extractors.js +85 -0
  50. package/dist/clients/project-diagnostics/runner-adapters/dead-code.js +44 -0
  51. package/dist/clients/project-diagnostics/runner-adapters/gitleaks.js +25 -0
  52. package/dist/clients/project-diagnostics/runner-adapters/govulncheck.js +34 -0
  53. package/dist/clients/project-diagnostics/runner-adapters/jscpd.js +38 -0
  54. package/dist/clients/project-diagnostics/runner-adapters/madge.js +47 -0
  55. package/dist/clients/project-diagnostics/runner-adapters/trivy.js +31 -0
  56. package/dist/clients/project-diagnostics/scanner.js +11 -10
  57. package/dist/clients/read-guard-logger.js +11 -36
  58. package/dist/clients/review-graph/builder.js +337 -27
  59. package/dist/clients/review-graph/git-identity.js +150 -0
  60. package/dist/clients/review-graph/service.js +3 -3
  61. package/dist/clients/runtime-coordinator.js +83 -0
  62. package/dist/clients/runtime-session.js +28 -2
  63. package/dist/clients/runtime-tool-result.js +10 -2
  64. package/dist/clients/runtime-turn.js +25 -7
  65. package/dist/clients/sg-runner.js +15 -0
  66. package/dist/clients/test-runner-client.js +12 -6
  67. package/dist/clients/tree-sitter-cache.js +47 -14
  68. package/dist/clients/tree-sitter-client.js +9 -1
  69. package/dist/clients/tree-sitter-logger.js +7 -12
  70. package/dist/clients/tree-sitter-query-loader.js +1 -0
  71. package/dist/clients/tree-sitter-shared.js +113 -0
  72. package/dist/clients/tree-sitter-symbol-extractor.js +19 -12
  73. package/dist/index.js +4 -22
  74. package/dist/tools/lens-diagnostics.js +254 -20
  75. package/grammars/tree-sitter-yaml.wasm +0 -0
  76. package/grammars/tree-sitter-yaml.wasm.json +3 -3
  77. package/package.json +4 -3
  78. package/rules/ast-grep-rules/rule-tests/no-init-return-test.yml +15 -0
  79. package/rules/ast-grep-rules/rules/no-init-return.yml +11 -5
  80. package/rules/tree-sitter-queries/python/python-assert-production.yml +4 -0
  81. package/scripts/download-grammars.js +29 -4
  82. package/scripts/grammars.lock.json +14 -2
  83. package/scripts/install-selftest.mjs +8 -7
  84. package/dist/clients/deps/typescript.js +0 -15
  85. package/dist/clients/dispatch/rules/quality-rules.js +0 -297
  86. package/dist/clients/dispatch/rules/sonar-rules.js +0 -493
  87. package/dist/clients/dispatch/runners/biome.js +0 -69
  88. package/dist/clients/dispatch/runners/python-slop.js +0 -106
  89. package/dist/clients/dispatch/runners/ts-lsp.js +0 -109
  90. package/dist/clients/runner-tracker.js +0 -153
  91. package/dist/clients/ts-service.js +0 -130
  92. package/dist/clients/type-coverage-client.js +0 -128
  93. package/dist/clients/typescript-client.js +0 -509
  94. package/dist/commands/booboo.js +0 -1412
  95. package/rules/python-slop-rules/.sgconfig.yml +0 -4
  96. package/rules/python-slop-rules/rules/slop-rules.yml +0 -647
@@ -7,22 +7,14 @@
7
7
  import * as fs from "node:fs";
8
8
  import * as path from "node:path";
9
9
  import { RuleCache } from "../../cache/rule-cache.js";
10
+ import { isTestFile } from "../../file-utils.js";
10
11
  import { resolvePackagePath } from "../../package-root.js";
11
12
  import { buildOrUpdateGraph, computeImpactCascade, recordEntitySnapshotDiff, } from "../../review-graph/service.js";
12
- import { TreeSitterClient } from "../../tree-sitter-client.js";
13
+ import { getSharedTreeSitterClient, isTreeSitterWasmAborted, markTreeSitterWasmAborted, resolveTreeSitterLanguage, } from "../../tree-sitter-shared.js";
13
14
  import { logTreeSitter } from "../../tree-sitter-logger.js";
14
15
  import { isDisabledQueryFilePath, queryLoader, } from "../../tree-sitter-query-loader.js";
15
16
  import { classifyDefect } from "../diagnostic-taxonomy.js";
16
17
  import { PRIORITY } from "../priorities.js";
17
- // Module-level singleton: web-tree-sitter WASM must only be initialized once per process.
18
- // Creating a new TreeSitterClient() on every write resets TRANSFER_BUFFER (a module-level
19
- // WASM pointer) — concurrent writes race on _ts_init() and corrupt shared WASM state → crash.
20
- let _sharedClient = null;
21
- // Once the wasm runtime aborts, the entire module-level wasm heap is corrupted — no
22
- // recovery is possible within this process. Flag it and skip all further tree-sitter work
23
- // rather than re-invoking the dead runtime (which prints "Aborted()" on every call and
24
- // leaks memory on each retry).
25
- let _wasmAborted = false;
26
18
  const blastCooldownByFile = new Map();
27
19
  const BLAST_COOLDOWN_MS = 5_000;
28
20
  function runBlastRadiusInBackground(cwd, filePath, languageId, facts) {
@@ -286,14 +278,6 @@ function isLineInModifiedRanges(line, ranges) {
286
278
  return true;
287
279
  return ranges.some((r) => line >= r.start && line <= r.end);
288
280
  }
289
- function getSharedClient() {
290
- if (_wasmAborted)
291
- return null;
292
- if (!_sharedClient) {
293
- _sharedClient = new TreeSitterClient();
294
- }
295
- return _sharedClient;
296
- }
297
281
  /**
298
282
  * Calculate total lines changed in modified ranges.
299
283
  * Used to skip expensive entity extraction for trivial changes.
@@ -305,46 +289,6 @@ function getTotalLinesChanged(ranges) {
305
289
  }
306
290
  /** Threshold: skip entity extraction for changes under 5 lines */
307
291
  const ENTITY_EXTRACTION_LINE_THRESHOLD = 5;
308
- function resolveTreeSitterLanguage(filePath) {
309
- const ext = path.extname(filePath).toLowerCase();
310
- const EXT_TO_LANG = {
311
- ".ts": "typescript",
312
- ".mts": "typescript",
313
- ".cts": "typescript",
314
- ".tsx": "tsx",
315
- ".js": "javascript",
316
- ".mjs": "javascript",
317
- ".cjs": "javascript",
318
- ".jsx": "javascript",
319
- ".py": "python",
320
- ".go": "go",
321
- ".rs": "rust",
322
- ".rb": "ruby",
323
- ".c": "c",
324
- ".h": "c",
325
- ".cc": "cpp",
326
- ".cpp": "cpp",
327
- ".cxx": "cpp",
328
- ".c++": "cpp",
329
- ".hh": "cpp",
330
- ".hpp": "cpp",
331
- ".hxx": "cpp",
332
- ".inl": "cpp",
333
- ".ipp": "cpp",
334
- ".tpp": "cpp",
335
- ".txx": "cpp",
336
- ".cu": "cpp",
337
- ".hip": "cpp",
338
- ".cs": "csharp",
339
- ".php": "php",
340
- ".phtml": "php",
341
- ".php3": "php",
342
- ".php4": "php",
343
- ".php5": "php",
344
- ".css": "css",
345
- };
346
- return EXT_TO_LANG[ext];
347
- }
348
292
  const treeSitterRunner = {
349
293
  id: "tree-sitter",
350
294
  appliesTo: ["jsts", "python", "go", "rust", "ruby", "cxx", "csharp", "php", "css"],
@@ -353,13 +297,13 @@ const treeSitterRunner = {
353
297
  skipTestFiles: false, // Run on test files too (structural issues matter there)
354
298
  async run(ctx) {
355
299
  // Use singleton client — WASM must never be re-initialized after first call
356
- const client = getSharedClient();
300
+ const client = getSharedTreeSitterClient();
357
301
  logTreeSitter({ phase: "runner_start", filePath: ctx.filePath });
358
302
  if (!client || !client.isAvailable()) {
359
303
  logTreeSitter({
360
304
  phase: "runner_skip",
361
305
  filePath: ctx.filePath,
362
- reason: _wasmAborted ? "wasm_aborted" : "client_unavailable",
306
+ reason: isTreeSitterWasmAborted() ? "wasm_aborted" : "client_unavailable",
363
307
  status: "skipped",
364
308
  });
365
309
  return { status: "skipped", diagnostics: [], semantic: "none" };
@@ -460,9 +404,13 @@ const treeSitterRunner = {
460
404
  // Run all queries regardless of blockingOnly — warning-tier results are logged
461
405
  // for diagnostic history but filtered from agent output by the dispatcher.
462
406
  // Only skip "review" tier queries on write (too noisy / expensive).
463
- const effectiveQueries = ctx.blockingOnly
407
+ // Per-rule test-file carve-out (#440): rules that are noise in tests (e.g.
408
+ // python-assert-production — `assert` is the idiomatic test assertion) opt
409
+ // out via `skip_test_files` while the runner otherwise runs on test files.
410
+ const fileIsTest = isTestFile(filePath);
411
+ const effectiveQueries = (ctx.blockingOnly
464
412
  ? languageQueries.filter((q) => q.inline_tier !== "review")
465
- : languageQueries;
413
+ : languageQueries).filter((q) => !(fileIsTest && q.skip_test_files));
466
414
  logTreeSitter({
467
415
  phase: "queries_loaded",
468
416
  filePath,
@@ -547,8 +495,7 @@ const treeSitterRunner = {
547
495
  // Emscripten abort() corrupts the entire module-level wasm heap.
548
496
  // Poison the singleton so no further queries attempt to use the dead runtime.
549
497
  if (msg.includes("Aborted") || msg.includes("abort()")) {
550
- _wasmAborted = true;
551
- _sharedClient = null;
498
+ markTreeSitterWasmAborted();
552
499
  logTreeSitter({
553
500
  phase: "query_error",
554
501
  filePath,
@@ -12,6 +12,7 @@ import { fileURLToPath } from "node:url";
12
12
  import { getGlobalPiLensDir } from "../../../file-utils.js";
13
13
  import { ensureTool } from "../../../installer/index.js";
14
14
  import { getServersForFileWithConfig, isServerDisabled, } from "../../../lsp/config.js";
15
+ import { findGlobalBinary } from "../../../package-manager.js";
15
16
  import { safeSpawnAsync } from "../../../safe-spawn.js";
16
17
  import { getToolCommandSpec, shouldAutoInstallTool, } from "../../../tool-policy.js";
17
18
  /**
@@ -257,34 +258,6 @@ export async function resolveAvailableOrInstall(checker, toolId, cwd) {
257
258
  return installed ?? null;
258
259
  }
259
260
  // =============================================================================
260
- // CONFIG FILE FINDER FACTORY
261
- // =============================================================================
262
- /**
263
- * Create a config file finder for rule directories.
264
- * Common pattern used by slop runners and similar tools.
265
- */
266
- export function createConfigFinder(ruleDirName) {
267
- return (cwd) => {
268
- // Check for local config first
269
- const localPath = path.join(cwd, "rules", ruleDirName, ".sgconfig.yml");
270
- if (fs.existsSync(localPath)) {
271
- return localPath;
272
- }
273
- // Fall back to extension rules
274
- const extensionPaths = [
275
- `rules/${ruleDirName}/.sgconfig.yml`,
276
- `../rules/${ruleDirName}/.sgconfig.yml`,
277
- ];
278
- for (const candidate of extensionPaths) {
279
- const fullPath = path.resolve(cwd, candidate);
280
- if (fs.existsSync(fullPath)) {
281
- return fullPath;
282
- }
283
- }
284
- return undefined;
285
- };
286
- }
287
- // =============================================================================
288
261
  // SHARED AST-GREP AVAILABILITY
289
262
  // =============================================================================
290
263
  // Shared ast-grep availability cache across all slop runners
@@ -354,6 +327,17 @@ export async function isSgAvailableAsync() {
354
327
  return true;
355
328
  }
356
329
  }
330
+ // 2b. Any package manager's global bin dir (npm/pnpm/yarn/bun) — catches
331
+ // `pnpm add -g @ast-grep/cli` installs whose bin dir is off PATH (#375).
332
+ for (const name of ["ast-grep", "sg"]) {
333
+ const globalBin = await findGlobalBinary(name);
334
+ if (globalBin && (await probeAstGrepCommandAsync(globalBin))) {
335
+ sgCmd = globalBin;
336
+ sgCmdArgs = [];
337
+ sgAvailable = true;
338
+ return true;
339
+ }
340
+ }
357
341
  // 3. npx --no (cache-only, no silent download).
358
342
  if (await probeAstGrepCommandAsync("npx", ["--no", "--", "ast-grep"])) {
359
343
  sgCmd = "npx";
@@ -378,10 +362,14 @@ export function getSgCommand() {
378
362
  // LOCAL-FIRST BINARY RESOLUTION
379
363
  // =============================================================================
380
364
  /**
381
- * Find a tool binary preferring local node_modules/.bin over global PATH.
382
- * Only falls back to npx as a last resort (avoids silent network downloads).
365
+ * Find a tool binary preferring local node_modules/.bin, then any installed
366
+ * package manager's global bin dir (npm/pnpm/yarn/bun), then global PATH. Only
367
+ * falls back to `npx --no` as a last resort — the universal cache-only exec
368
+ * (npx ships with node and never silently downloads), so this stays
369
+ * manager-agnostic without risking a surprise `dlx` fetch on pnpm/yarn/bun.
383
370
  *
384
- * Returns: { cmd, args } where args may include ["npx", toolName] preamble.
371
+ * Returns: { cmd, args } where args may include the `["--no", toolName]` npx
372
+ * preamble.
385
373
  */
386
374
  export async function resolveLocalFirstAsync(toolName, cwd, windowsExt = ".cmd") {
387
375
  const isWin = process.platform === "win32";
@@ -390,14 +378,21 @@ export async function resolveLocalFirstAsync(toolName, cwd, windowsExt = ".cmd")
390
378
  const local = path.join(cwd, "node_modules", ".bin", binName);
391
379
  if (fs.existsSync(local))
392
380
  return { cmd: local, args: [] };
393
- // 2. Global PATH (already installed system-wide)
381
+ // 2. Global bin dir of ANY installed manager (npm/pnpm/yarn/bun) — direct
382
+ // file lookup, so it finds tools installed via `pnpm add -g` / `bun add -g`
383
+ // (whose bin dirs are often off PATH) and survives PATH staleness after an
384
+ // `install -g`. No spawn.
385
+ const globalBin = await findGlobalBinary(toolName, windowsExt);
386
+ if (globalBin)
387
+ return { cmd: globalBin, args: [] };
388
+ // 3. Global PATH (already installed system-wide, on PATH)
394
389
  const globalCheck = await safeSpawnAsync(toolName, ["--version"], {
395
390
  timeout: 3000,
396
391
  });
397
392
  if (!globalCheck.error && globalCheck.status === 0) {
398
393
  return { cmd: toolName, args: [] };
399
394
  }
400
- // 3. npx fallback — only for already-cached packages (no silent download)
395
+ // 4. npx --no fallback — universal cache-only exec (no silent download)
401
396
  return { cmd: "npx", args: ["--no", toolName] };
402
397
  }
403
398
  // =============================================================================
@@ -5,7 +5,6 @@ const DEFAULT_TOOL_PROFILE = {
5
5
  const TOOL_PROFILE_MAP = {
6
6
  "tree-sitter:silent-error": { dedupPriority: 200, lintLike: false },
7
7
  lsp: { dedupPriority: 120, lintLike: false },
8
- "ts-lsp": { dedupPriority: 120, lintLike: false },
9
8
  eslint: { dedupPriority: 110, lintLike: true },
10
9
  biome: { dedupPriority: 100, lintLike: true },
11
10
  "biome-check-json": { dedupPriority: 100, lintLike: true },
@@ -2,7 +2,7 @@
2
2
  * Redesigned Dispatch Types for pi-lens
3
3
  *
4
4
  * Key insight: Different clients have different OUTPUT SEMANTICS:
5
- * - BLOCKING: Errors that stop the agent (architect, ts-lsp errors)
5
+ * - BLOCKING: Errors that stop the agent (architect, lsp errors)
6
6
  * - WARNING: Non-blocking issues (biome warnings, type-safety)
7
7
  * - FIXABLE: Issues with auto-fix available
8
8
  * - SILENT: Metrics tracked but not shown (complexity)
@@ -11,6 +11,7 @@
11
11
  import * as fs from "node:fs/promises";
12
12
  import * as path from "node:path";
13
13
  import { logLatency } from "./latency-logger.js";
14
+ import { findGlobalBinary } from "./package-manager.js";
14
15
  import { safeSpawnAsync } from "./safe-spawn.js";
15
16
  import { getAutoInstallToolIdForFormatter, getFormatterPolicyForFile, getSmartDefaultFormatterName, hasBiomeConfig, hasBlackConfig, hasClangFormatConfig, hasCljfmtConfig, hasCmakeFormatConfig, hasGoogleJavaFormatConfig, hasKtfmtConfig, hasNearestPackageJsonDependency, hasNearestPackageJsonField, hasOcamlformatConfig, hasOxfmtConfig, hasPhpCsFixerConfig, hasPrettierConfig, hasRubocopConfig, hasRuffConfig, hasSqlfluffConfig, hasStandardrbConfig, hasStyluaConfig, hasVitePlusConfig, } from "./tool-policy.js";
16
17
  const _lazyInstallAttempts = new Set();
@@ -251,6 +252,11 @@ export const biomeFormatter = {
251
252
  const local = await findInNodeModules("biome", cwd);
252
253
  if (local)
253
254
  return [local, "format", "--write", ...editorConfigFlag, filePath];
255
+ // Any package manager's global bin dir (npm/pnpm/yarn/bun) before we
256
+ // auto-install — catches a `pnpm add -g @biomejs/biome` PATH misses (#375).
257
+ const global = await findGlobalBinary("biome");
258
+ if (global)
259
+ return [global, "format", "--write", ...editorConfigFlag, filePath];
254
260
  const toolId = getAutoInstallToolIdForFormatter("biome");
255
261
  if (!toolId)
256
262
  return null;
@@ -291,6 +297,10 @@ export const prettierFormatter = {
291
297
  const local = await findInNodeModules("prettier", cwd);
292
298
  if (local)
293
299
  return [local, "--write", filePath];
300
+ // Global bin of any manager (npm/pnpm/yarn/bun) before auto-install (#375).
301
+ const global = await findGlobalBinary("prettier");
302
+ if (global)
303
+ return [global, "--write", filePath];
294
304
  return resolveManagedSmartDefaultCommand("prettier", filePath, ["--write"]);
295
305
  },
296
306
  extensions: [
@@ -27,6 +27,6 @@ export function evaluateGitGuard(runtime, cacheManager, cwd) {
27
27
  : "";
28
28
  return {
29
29
  block: true,
30
- reason: `🔴 COMMIT BLOCKED (--lens-guard): unresolved blockers must be fixed before commit/push.${details}\nRun /lens-booboo for full details, then commit again.`,
30
+ reason: `🔴 COMMIT BLOCKED (--lens-guard): unresolved blockers must be fixed before commit/push.${details}\nRun lens_diagnostics mode=all for full details, then commit again.`,
31
31
  };
32
32
  }
@@ -15,6 +15,22 @@ import * as path from "node:path";
15
15
  export const TREE_SITTER_WASMS_VERSION = "0.1.13";
16
16
  /** unpkg mirror of the tree-sitter-wasms artifacts. */
17
17
  export const GRAMMAR_CDN_BASE = `https://unpkg.com/tree-sitter-wasms@${TREE_SITTER_WASMS_VERSION}/out`;
18
+ export const GRAMMAR_SOURCE_OVERRIDES = {
19
+ "tree-sitter-lua.wasm": {
20
+ package: "@tree-sitter-grammars/tree-sitter-lua",
21
+ version: "0.4.1",
22
+ url: "https://unpkg.com/@tree-sitter-grammars/tree-sitter-lua@0.4.1/tree-sitter-lua.wasm",
23
+ },
24
+ "tree-sitter-yaml.wasm": {
25
+ package: "@tree-sitter-grammars/tree-sitter-yaml",
26
+ version: "0.7.1",
27
+ url: "https://unpkg.com/@tree-sitter-grammars/tree-sitter-yaml@0.7.1/tree-sitter-yaml.wasm",
28
+ },
29
+ };
30
+ /** The URL a grammar wasm is fetched from (override if any, else the aggregator). */
31
+ export function grammarSourceUrl(filename) {
32
+ return GRAMMAR_SOURCE_OVERRIDES[filename]?.url ?? `${GRAMMAR_CDN_BASE}/${filename}`;
33
+ }
18
34
  /** Language id → grammar wasm filename. */
19
35
  export const LANGUAGE_TO_GRAMMAR = {
20
36
  typescript: "tree-sitter-typescript.wasm",
@@ -48,15 +64,62 @@ export const LANGUAGE_TO_GRAMMAR = {
48
64
  export const GRAMMAR_FILES = [
49
65
  ...new Set(Object.values(LANGUAGE_TO_GRAMMAR)),
50
66
  ];
67
+ /**
68
+ * Grammars that FATALLY crash the host runtime on specific engines/versions and
69
+ * therefore must NOT be loaded there. The crash is an uncatchable process abort
70
+ * (V8 Turboshaft WASM OOM — `Fatal process out of memory: Zone`), so it cannot be
71
+ * degraded in-process with try/catch; the only safe option is to skip the load
72
+ * entirely and degrade the feature (no structural symbols for that language).
73
+ *
74
+ * Membership is GUARD-DRIVEN, not hand-maintained: the grammar-health nightly
75
+ * (`scripts/check-grammar-load.mjs`) is what proves a grammar crashes and thus
76
+ * belongs here, and what proves a future build is safe enough to remove it.
77
+ *
78
+ * tree-sitter-swift crashes on Node >= 24 across all OSes under memory pressure
79
+ * (#423/#432); building from source (an earlier vendoring attempt, #426) does not
80
+ * reliably dodge it. bun (JavaScriptCore) and Node 20/22 are unaffected, so the
81
+ * block is gated on V8 + Node major.
82
+ */
83
+ export const BLOCKED_GRAMMARS = {
84
+ "tree-sitter-swift.wasm": {
85
+ blocked: ({ isV8, nodeMajor }) => isV8 && nodeMajor >= 24,
86
+ reason: "tree-sitter-swift crashes the runtime on Node >= 24 (V8 Turboshaft WASM OOM, #423/#432); skipped so the process degrades gracefully instead of aborting.",
87
+ },
88
+ };
89
+ /** Runtime signals for the currently-running process. */
90
+ export function currentGrammarRuntime() {
91
+ const m = /^v?(\d+)/.exec(process.versions?.node ?? "");
92
+ return {
93
+ nodeMajor: m ? Number(m[1]) : 0,
94
+ isV8: !process.versions.bun && Boolean(process.versions.v8),
95
+ platform: process.platform,
96
+ };
97
+ }
98
+ /**
99
+ * The block reason if `filename` must NOT be loaded on `rt` (default: the running
100
+ * process), else null. Callers skip the load and degrade to "grammar unavailable".
101
+ */
102
+ export function grammarBlockReason(filename, rt = currentGrammarRuntime()) {
103
+ // Diagnostic escape hatch (grammar-health probe only): force-load a blocked
104
+ // grammar to test whether a fresh build / newer runtime survives — the signal
105
+ // that a block can be LIFTED. Never set in normal operation: it disables the
106
+ // crash protection and can abort the process.
107
+ if (process.env.PILENS_UNSAFE_FORCE_GRAMMAR_LOAD === "1")
108
+ return null;
109
+ const block = BLOCKED_GRAMMARS[filename];
110
+ return block?.blocked(rt) ? block.reason : null;
111
+ }
51
112
  /**
52
113
  * Fetch one grammar wasm into `destDir` (atomic via a temp file). Returns true
53
114
  * on success. Never throws — a failed fetch (offline, 4xx) degrades to "grammar
54
- * unavailable" so callers can decide how to handle it.
115
+ * unavailable" so callers can decide how to handle it. A grammar that crashes the
116
+ * runtime is protected at LOAD time (BLOCKED_GRAMMARS / grammarBlockReason), not
117
+ * by refusing to download it.
55
118
  */
56
119
  export async function downloadGrammar(destDir, filename) {
57
120
  try {
58
121
  fs.mkdirSync(destDir, { recursive: true });
59
- const res = await fetch(`${GRAMMAR_CDN_BASE}/${filename}`);
122
+ const res = await fetch(grammarSourceUrl(filename));
60
123
  if (!res.ok)
61
124
  throw new Error(`HTTP ${res.status}`);
62
125
  const data = Buffer.from(await res.arrayBuffer());
@@ -12,6 +12,7 @@ import { mkdtempSync } from "node:fs";
12
12
  import * as os from "node:os";
13
13
  import * as path from "node:path";
14
14
  import { getExcludedDirGlobs, getProjectIgnoreGlobs, getProjectIgnoreMatcher, isExcludedDirName, } from "./file-utils.js";
15
+ import { findNodeToolBinary } from "./package-manager.js";
15
16
  import { safeSpawnAsync } from "./safe-spawn.js";
16
17
  const EMPTY_RESULT = {
17
18
  success: false,
@@ -192,8 +193,13 @@ export class JscpdClient {
192
193
  }
193
194
  const ignorePattern = baseIgnores.join(",");
194
195
  try {
195
- const result = await safeSpawnAsync("npx", [
196
- "jscpd",
196
+ // Prefer a local/global-installed jscpd (any manager) over npx (#375).
197
+ const bin = await findNodeToolBinary("jscpd", cwd);
198
+ const { cmd, prefix } = bin
199
+ ? { cmd: bin, prefix: [] }
200
+ : { cmd: "npx", prefix: ["jscpd"] };
201
+ const result = await safeSpawnAsync(cmd, [
202
+ ...prefix,
197
203
  ".",
198
204
  "--min-lines",
199
205
  String(minLines),
@@ -67,7 +67,7 @@ export const LANGUAGE_POLICY = {
67
67
  const PRIMARY_DISPATCH_GROUPS = {
68
68
  jsts: {
69
69
  mode: "fallback",
70
- runnerIds: ["lsp", "ts-lsp"],
70
+ runnerIds: ["lsp"],
71
71
  filterKinds: ["jsts"],
72
72
  },
73
73
  python: {
@@ -212,7 +212,7 @@ export function getPrimaryDispatchGroup(kind, lspEnabled) {
212
212
  return undefined;
213
213
  const ids = lspEnabled
214
214
  ? [...base.runnerIds]
215
- : base.runnerIds.filter((id) => id !== "lsp" && id !== "ts-lsp");
215
+ : base.runnerIds.filter((id) => id !== "lsp");
216
216
  if (ids.length === 0)
217
217
  return undefined;
218
218
  return {
@@ -2,27 +2,23 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { isTestMode } from "./env-utils.js";
4
4
  import { getGlobalPiLensDir } from "./file-utils.js";
5
+ import { createNdjsonLogger } from "./ndjson-logger.js";
5
6
  const LATENCY_LOG_DIR = getGlobalPiLensDir();
6
7
  const LATENCY_LOG_FILE = path.join(LATENCY_LOG_DIR, "latency.log");
7
- try {
8
- if (!fs.existsSync(LATENCY_LOG_DIR)) {
9
- fs.mkdirSync(LATENCY_LOG_DIR, { recursive: true });
10
- }
11
- }
12
- catch { }
8
+ const writer = createNdjsonLogger({ filePath: LATENCY_LOG_FILE });
13
9
  export function logLatency(entry) {
14
10
  if (isTestMode()) {
15
11
  return;
16
12
  }
17
- const line = `${JSON.stringify({ ts: new Date().toISOString(), ...entry })}\n`;
18
- try {
19
- fs.appendFileSync(LATENCY_LOG_FILE, line);
20
- }
21
- catch { }
13
+ writer.log({ ts: new Date().toISOString(), ...entry });
22
14
  }
23
15
  export function getLatencyLogPath() {
24
16
  return LATENCY_LOG_FILE;
25
17
  }
18
+ /** Resolve once all enqueued latency writes are on disk (tests/shutdown). */
19
+ export function flushLatencyLog() {
20
+ return writer.flush();
21
+ }
26
22
  export function readLatencyLog(limit = 100) {
27
23
  try {
28
24
  const content = fs.readFileSync(LATENCY_LOG_FILE, "utf-8");
@@ -37,8 +33,7 @@ export function readLatencyLog(limit = 100) {
37
33
  }
38
34
  }
39
35
  export function clearLatencyLog() {
40
- try {
41
- fs.writeFileSync(LATENCY_LOG_FILE, "");
42
- }
43
- catch { }
36
+ // Enqueue the truncate in the same serialized queue so a clear cannot race a
37
+ // pending drain. Await flushLatencyLog() if you need the file empty on disk.
38
+ writer.truncate();
44
39
  }
@@ -15,7 +15,8 @@ import { withTimeout } from "../deadline-utils.js";
15
15
  import { logLatency } from "../latency-logger.js";
16
16
  // vscode-jsonrpc v9 ships an `exports` map exposing the Node entry as the
17
17
  // `./node` subpath (no `.js`); the old `/node.js` file path no longer resolves.
18
- import { createMessageConnection, StreamMessageReader, StreamMessageWriter, } from "../deps/vscode-jsonrpc.js";
18
+ import { CancellationTokenSource, createMessageConnection, StreamMessageReader, StreamMessageWriter, } from "../deps/vscode-jsonrpc.js";
19
+ import { getAmbientAbortSignal } from "../safe-spawn.js";
19
20
  import { applyWorkspaceEdit } from "./edits.js";
20
21
  import { normalizeMapKey, uriToPath } from "./path-utils.js";
21
22
  import { ADVERTISED_POSITION_ENCODINGS, convertCharacterOffset, lineTextAt, negotiatePositionEncoding, } from "./position-encoding.js";
@@ -789,14 +790,19 @@ export async function navRequest(state, method, params,
789
790
  // When provided, the request is dropped if the document's version advances
790
791
  // (an edit landed) between send and response. Omit for non-single-file
791
792
  // requests (workspaceSymbol, call-hierarchy follow-ups) that have no version.
792
- staleCheckPath, timeoutMs = NAV_REQUEST_TIMEOUT_MS) {
793
+ staleCheckPath, timeoutMs = NAV_REQUEST_TIMEOUT_MS,
794
+ // Cancels the in-flight request (LSP `$/cancelRequest`) when the turn is
795
+ // abandoned. Defaults to the ambient abort signal set around dispatch/tool
796
+ // handling, so callers get cancellation for free without a signature change
797
+ // (#238 Item 1). Pass explicitly in tests.
798
+ signal = getAmbientAbortSignal()) {
793
799
  if (!isClientAlive(state))
794
800
  return null;
795
801
  const normalizedPath = staleCheckPath !== undefined ? normalizeMapKey(staleCheckPath) : undefined;
796
802
  const requestVersion = normalizedPath !== undefined
797
803
  ? state.documentVersions.get(normalizedPath)
798
804
  : undefined;
799
- const result = (await withTimeout(safeSendRequest(state.connection, method, params), timeoutMs).catch((err) => {
805
+ const result = (await withTimeout(safeSendRequest(state.connection, method, params, signal), timeoutMs).catch((err) => {
800
806
  if (err instanceof Error && err.message.startsWith("Timeout after")) {
801
807
  return undefined;
802
808
  }
@@ -1297,17 +1303,74 @@ async function safeSendNotification(connection, method, params) {
1297
1303
  }
1298
1304
  }
1299
1305
  // Helper to safely send requests - catches stream destruction
1300
- async function safeSendRequest(connection, method, params) {
1301
- try {
1302
- return (await connection.sendRequest(method, params));
1306
+ async function safeSendRequest(connection, method, params,
1307
+ // When provided, aborting the signal cancels the in-flight request via
1308
+ // vscode-jsonrpc's CancellationToken → an LSP `$/cancelRequest` notification,
1309
+ // so a server stops computing a result the agent has already abandoned (#238
1310
+ // Item 1). The rejection that follows is swallowed (treated as `undefined`).
1311
+ signal) {
1312
+ // Already abandoned before we even sent — don't bother the server.
1313
+ if (signal?.aborted)
1314
+ return undefined;
1315
+ let tokenSource;
1316
+ let onAbort;
1317
+ if (signal) {
1318
+ tokenSource = new CancellationTokenSource();
1319
+ onAbort = () => tokenSource?.cancel();
1320
+ signal.addEventListener("abort", onAbort, { once: true });
1303
1321
  }
1304
- catch (err) {
1305
- if (isStreamError(err)) {
1306
- // Silently ignore - stream was destroyed
1307
- return undefined;
1322
+ // Only pass a token when cancellation is wired, so the call shape is unchanged
1323
+ // for the (many) requests without a signal.
1324
+ const send = () => tokenSource
1325
+ ? connection.sendRequest(method, params, tokenSource.token)
1326
+ : connection.sendRequest(method, params);
1327
+ try {
1328
+ // One safe retry on ContentModified (-32801): the document changed under
1329
+ // us, so the server discarded the request. A single retry beats returning
1330
+ // empty — correctness-under-edit is pi-lens's whole hot path (#238 Item 2).
1331
+ const MAX_ATTEMPTS = 2;
1332
+ for (let attempt = 1;; attempt++) {
1333
+ try {
1334
+ return (await send());
1335
+ }
1336
+ catch (err) {
1337
+ if (isStreamError(err) || isCancellationError(err)) {
1338
+ // Stream destroyed, or we cancelled the request on abort — either
1339
+ // way there is no result to return.
1340
+ return undefined;
1341
+ }
1342
+ if (isContentModifiedError(err)) {
1343
+ // Retry once (unless we've since been aborted); if it's still
1344
+ // ContentModified after that, return empty rather than throwing a
1345
+ // code callers don't understand. RequestFailed (-32803) and other
1346
+ // codes are permanent and fall through to the rethrow below.
1347
+ if (attempt < MAX_ATTEMPTS && !signal?.aborted)
1348
+ continue;
1349
+ return undefined;
1350
+ }
1351
+ throw err;
1352
+ }
1308
1353
  }
1309
- throw err;
1310
1354
  }
1355
+ finally {
1356
+ if (signal && onAbort)
1357
+ signal.removeEventListener("abort", onAbort);
1358
+ tokenSource?.dispose();
1359
+ }
1360
+ }
1361
+ // vscode-jsonrpc rejects a token-cancelled request with a `ResponseError` whose
1362
+ // code is `RequestCancelled` (-32800) or `ServerCancelled` (-32802). Treat both
1363
+ // as "no result" rather than a failure. (isStreamError also matches the
1364
+ // "cancelled" message text; this adds the structured error-code path.)
1365
+ function isCancellationError(err) {
1366
+ const code = err?.code;
1367
+ return code === -32800 || code === -32802;
1368
+ }
1369
+ // `ContentModified` (-32801): the document changed while the request was in
1370
+ // flight, so the server couldn't answer against a consistent state. Retryable —
1371
+ // the only LSP error code worth a second attempt on the edit hot path (#238).
1372
+ function isContentModifiedError(err) {
1373
+ return err?.code === -32801;
1311
1374
  }
1312
1375
  // Helper to detect stream destruction / connection disposal errors.
1313
1376
  // vscode-jsonrpc throws these when the LSP server process exits while