pi-lens 3.8.72 → 3.8.74

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 (210) hide show
  1. package/CHANGELOG.md +1162 -0
  2. package/README.md +45 -28
  3. package/dist/clients/atomic-write.js +3 -3
  4. package/dist/clients/bus-events-logger.js +5 -1
  5. package/dist/clients/cache/rule-cache.js +43 -2
  6. package/dist/clients/cache-observability.js +211 -0
  7. package/dist/clients/call-graph.js +20 -0
  8. package/dist/clients/cascade-logger.js +5 -1
  9. package/dist/clients/complexity-client.js +4 -7
  10. package/dist/clients/dependency-checker.js +151 -20
  11. package/dist/clients/deps/js-yaml.js +1 -1
  12. package/dist/clients/diagnostic-dispositions.js +22 -2
  13. package/dist/clients/dispatch/auxiliary-lsp.js +34 -0
  14. package/dist/clients/dispatch/dispatcher.js +16 -1
  15. package/dist/clients/dispatch/fact-store.js +11 -0
  16. package/dist/clients/dispatch/facts/function-facts.js +14 -0
  17. package/dist/clients/dispatch/facts/import-facts.js +100 -101
  18. package/dist/clients/dispatch/facts/tree-sitter-facts.js +5 -8
  19. package/dist/clients/dispatch/facts/try-catch-facts.js +13 -0
  20. package/dist/clients/dispatch/integration.js +94 -15
  21. package/dist/clients/dispatch/rules/async-noise.js +1 -0
  22. package/dist/clients/dispatch/rules/unsafe-boundary.js +7 -0
  23. package/dist/clients/dispatch/runners/ast-grep-napi.js +84 -8
  24. package/dist/clients/dispatch/runners/psscriptanalyzer.js +10 -1
  25. package/dist/clients/dispatch/runners/tree-sitter.js +153 -166
  26. package/dist/clients/dispatch/runners/utils/runner-helpers.js +17 -18
  27. package/dist/clients/feature-hints.js +53 -7
  28. package/dist/clients/file-kinds.js +86 -31
  29. package/dist/clients/fix-worklog.js +14 -2
  30. package/dist/clients/generated-artifacts.js +21 -0
  31. package/dist/clients/gzip-stage-write.js +84 -0
  32. package/dist/clients/installer/index.js +358 -129
  33. package/dist/clients/instance-registry.js +12 -1
  34. package/dist/clients/knip-client.js +64 -1
  35. package/dist/clients/language-profile.js +61 -72
  36. package/dist/clients/latency-logger.js +6 -16
  37. package/dist/clients/lens-config.js +128 -165
  38. package/dist/clients/lens-engine.js +39 -5
  39. package/dist/clients/lens-flag-registry.js +285 -0
  40. package/dist/clients/lens-map.js +2 -7
  41. package/dist/clients/log-cleanup.js +6 -17
  42. package/dist/clients/lsp/cascade-tier.js +22 -2
  43. package/dist/clients/lsp/client.js +74 -10
  44. package/dist/clients/lsp/config.js +62 -9
  45. package/dist/clients/lsp/index.js +113 -31
  46. package/dist/clients/lsp/language.js +4 -1
  47. package/dist/clients/lsp/launch.js +8 -6
  48. package/dist/clients/lsp/server.js +84 -29
  49. package/dist/clients/lsp-document-symbols.js +112 -0
  50. package/dist/clients/mcp/analyze.js +5 -4
  51. package/dist/clients/mcp/review.js +36 -3
  52. package/dist/clients/module-report.js +100 -80
  53. package/dist/clients/ndjson-logger.js +51 -21
  54. package/dist/clients/path-keyed-map.js +76 -0
  55. package/dist/clients/path-utils.js +35 -0
  56. package/dist/clients/performance-report.js +327 -0
  57. package/dist/clients/pipeline.js +7 -0
  58. package/dist/clients/project-changes.js +77 -4
  59. package/dist/clients/project-diagnostics/extractors.js +26 -115
  60. package/dist/clients/project-diagnostics/fresh-fetch.js +149 -19
  61. package/dist/clients/project-diagnostics/runner-adapters/call-graph-impact.js +109 -0
  62. package/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +37 -5
  63. package/dist/clients/project-diagnostics/scanner.js +247 -127
  64. package/dist/clients/project-lens-config.js +53 -26
  65. package/dist/clients/project-report.js +39 -14
  66. package/dist/clients/project-scale.js +8 -10
  67. package/dist/clients/project-snapshot-persist-worker.js +6 -0
  68. package/dist/clients/project-snapshot.js +611 -7
  69. package/dist/clients/quiet-window.js +9 -1
  70. package/dist/clients/read-expansion.js +22 -12
  71. package/dist/clients/read-guard.js +78 -0
  72. package/dist/clients/redact/secrets.js +449 -0
  73. package/dist/clients/resource-sampler.js +133 -14
  74. package/dist/clients/reverse-deps.js +48 -0
  75. package/dist/clients/review-graph/builder.js +1522 -179
  76. package/dist/clients/review-graph/persist-worker.js +10 -0
  77. package/dist/clients/review-graph/query.js +5 -0
  78. package/dist/clients/review-graph/shared-extraction-ir.js +90 -0
  79. package/dist/clients/review-graph-logger.js +24 -0
  80. package/dist/clients/runtime-agent-end.js +4 -2
  81. package/dist/clients/runtime-coordinator.js +2 -2
  82. package/dist/clients/runtime-session.js +433 -59
  83. package/dist/clients/runtime-tool-call.js +46 -14
  84. package/dist/clients/runtime-turn.js +97 -26
  85. package/dist/clients/safe-spawn.js +102 -15
  86. package/dist/clients/session-start-observability.js +15 -0
  87. package/dist/clients/session-state-store.js +2 -1
  88. package/dist/clients/sessionstart-logger.js +18 -0
  89. package/dist/clients/sg-runner.js +48 -33
  90. package/dist/clients/source-filter.js +49 -25
  91. package/dist/clients/startup-marker.js +6 -0
  92. package/dist/clients/startup-timing.js +9 -0
  93. package/dist/clients/todo-scanner.js +30 -3
  94. package/dist/clients/tree-sitter-cache.js +147 -139
  95. package/dist/clients/tree-sitter-client.js +1340 -67
  96. package/dist/clients/tree-sitter-logger.js +43 -1
  97. package/dist/clients/tree-sitter-query-loader.js +110 -11
  98. package/dist/clients/tree-sitter-shared.js +43 -19
  99. package/dist/clients/tree-sitter-symbol-extractor.js +71 -4
  100. package/dist/clients/widget-state.js +50 -14
  101. package/dist/clients/word-index-logger.js +44 -0
  102. package/dist/clients/word-index.js +252 -35
  103. package/dist/clients/workspace-topology.js +11 -1
  104. package/dist/index.js +45976 -39387
  105. package/dist/mcp/analyze-cli.js +3 -0
  106. package/dist/mcp/cli.js +110 -0
  107. package/dist/mcp/server.js +63 -8
  108. package/dist/tools/lens-diagnostics.js +47 -5
  109. package/dist/tools/lsp-diagnostics.js +126 -9
  110. package/dist/tools/lsp-navigation.js +5 -28
  111. package/dist/tools/symbol-search.js +21 -2
  112. package/docs/agent-guide.md +247 -0
  113. package/docs/astplayground.md +3 -4
  114. package/docs/audit1.md +5 -5
  115. package/docs/environment-variables.md +153 -11
  116. package/docs/features.md +4 -3
  117. package/docs/globalconfig.md +56 -4
  118. package/docs/language-coverage.md +2 -2
  119. package/docs/mcp.md +17 -16
  120. package/docs/servercapabilities.md +5 -2
  121. package/docs/settings.md +181 -0
  122. package/docs/tools.md +14 -0
  123. package/docs/tree-sitter_rules_catalog.md +11 -31
  124. package/docs/usage.md +13 -0
  125. package/package.json +139 -139
  126. package/rules/ast-grep-rules/rule-tests/hardcoded-url-js-test.yml +1 -0
  127. package/rules/ast-grep-rules/rule-tests/hardcoded-url-test.yml +2 -0
  128. package/rules/ast-grep-rules/rule-tests/no-case-declarations-js-test.yml +10 -0
  129. package/rules/ast-grep-rules/rule-tests/no-case-declarations-test.yml +10 -0
  130. package/rules/ast-grep-rules/rule-tests/no-javascript-url-js-test.yml +10 -0
  131. package/rules/ast-grep-rules/rule-tests/no-javascript-url-test.yml +10 -0
  132. package/rules/ast-grep-rules/rule-tests/strict-equality-js-test.yml +3 -0
  133. package/rules/ast-grep-rules/rule-tests/strict-equality-test.yml +3 -0
  134. package/rules/ast-grep-rules/rule-tests/strict-inequality-js-test.yml +3 -0
  135. package/rules/ast-grep-rules/rule-tests/strict-inequality-test.yml +4 -0
  136. package/rules/ast-grep-rules/rules/hardcoded-url-js.yml +22 -4
  137. package/rules/ast-grep-rules/rules/hardcoded-url.yml +22 -4
  138. package/rules/ast-grep-rules/rules/no-case-declarations-js.yml +4 -3
  139. package/rules/ast-grep-rules/rules/no-case-declarations.yml +4 -3
  140. package/rules/ast-grep-rules/rules/no-console-except-error-js.yml +10 -0
  141. package/rules/ast-grep-rules/rules/no-console-except-error.yml +10 -0
  142. package/rules/ast-grep-rules/rules/no-javascript-url-js.yml +42 -6
  143. package/rules/ast-grep-rules/rules/no-javascript-url.yml +42 -6
  144. package/rules/ast-grep-rules/rules/strict-equality-js.yml +14 -1
  145. package/rules/ast-grep-rules/rules/strict-equality.yml +14 -1
  146. package/rules/ast-grep-rules/rules/strict-inequality-js.yml +14 -1
  147. package/rules/ast-grep-rules/rules/strict-inequality.yml +14 -1
  148. package/rules/tree-sitter-queries/cpp/no-memset-sensitive-data.yml +9 -3
  149. package/rules/tree-sitter-queries/cpp/no-scoped-lock-without-args.yml +6 -6
  150. package/rules/tree-sitter-queries/css/calc-spacing.yml +8 -6
  151. package/rules/tree-sitter-queries/go/go-mutex-copy.yml +21 -18
  152. package/rules/tree-sitter-queries/go/go-shared-map-write-goroutine.yml +3 -3
  153. package/rules/tree-sitter-queries/java/infinite-loop-java.yml +3 -3
  154. package/rules/tree-sitter-queries/java/junit-call-super.yml +6 -4
  155. package/rules/tree-sitter-queries/java/no-double-checked-locking.yml +10 -8
  156. package/rules/tree-sitter-queries/java/no-field-shadowing.yml +1 -1
  157. package/rules/tree-sitter-queries/java/prepared-statement-valid-indices.yml +2 -3
  158. package/rules/tree-sitter-queries/java/resources-closed.yml +2 -2
  159. package/rules/tree-sitter-queries/java/springboot-default-package.yml +6 -3
  160. package/rules/tree-sitter-queries/java/switch-fall-through.yml +6 -4
  161. package/rules/tree-sitter-queries/java/switch-non-case-labels.yml +4 -3
  162. package/rules/tree-sitter-queries/java/tests-include-assertions.yml +6 -5
  163. package/rules/tree-sitter-queries/javascript/switch-case-termination-js.yml +5 -8
  164. package/rules/tree-sitter-queries/javascript/switch-non-case-labels.yml +3 -2
  165. package/rules/tree-sitter-queries/kotlin/prepared-statement-indices.yml +9 -5
  166. package/rules/tree-sitter-queries/php/this-in-static-context.yml +5 -5
  167. package/rules/tree-sitter-queries/python/exit-signature-check.yml +2 -9
  168. package/rules/tree-sitter-queries/python/in-operator-unsupported.yml +3 -4
  169. package/rules/tree-sitter-queries/python/no-super-torchscript.yml +4 -8
  170. package/rules/tree-sitter-queries/python/notimplemented-boolean-context.yml +1 -5
  171. package/rules/tree-sitter-queries/python/python-empty-except.yml +1 -1
  172. package/rules/tree-sitter-queries/python/return-in-init.yml +0 -2
  173. package/rules/tree-sitter-queries/python/yield-return-outside-function.yml +0 -3
  174. package/rules/tree-sitter-queries/ruby/ruby-command-injection.yml +1 -12
  175. package/rules/tree-sitter-queries/ruby/ruby-eval.yml +1 -1
  176. package/rules/tree-sitter-queries/ruby/ruby-insecure-deserialization.yml +4 -4
  177. package/rules/tree-sitter-queries/ruby/ruby-insecure-random.yml +4 -11
  178. package/rules/tree-sitter-queries/ruby/ruby-open-struct.yml +1 -1
  179. package/rules/tree-sitter-queries/ruby/ruby-string-eval.yml +2 -3
  180. package/rules/tree-sitter-queries/ruby/ruby-weak-hash.yml +4 -4
  181. package/rules/tree-sitter-queries/rust/rust-lock-held-across-await.yml +4 -3
  182. package/rules/tree-sitter-queries/typescript/console-statement.yml +11 -0
  183. package/rules/tree-sitter-queries/typescript/duplicate-function-arg.yml +4 -9
  184. package/rules/tree-sitter-queries/typescript/empty-switch-case.yml +13 -9
  185. package/rules/tree-sitter-queries/typescript/infinite-loop.yml +2 -2
  186. package/rules/tree-sitter-queries/typescript/mixed-async-styles.yml +10 -1
  187. package/rules/tree-sitter-queries/typescript/no-equality-in-for-condition.yml +1 -0
  188. package/rules/tree-sitter-queries/typescript/no-jump-in-finally.yml +1 -0
  189. package/rules/tree-sitter-queries/typescript/switch-case-termination.yml +5 -8
  190. package/rules/tree-sitter-queries/typescript/ts-insecure-random.yml +7 -10
  191. package/rules/tree-sitter-queries/typescript/unsafe-regex.yml +4 -2
  192. package/rules/typos/_typos.toml +17 -0
  193. package/skills/pi-lens-write-tree-sitter-rule/SKILL.md +5 -0
  194. package/dist/clients/config-enabled-shape.js +0 -39
  195. package/docs/env_variables.md +0 -10
  196. package/rules/tree-sitter-queries/abap-disabled/delete-where.yml +0 -46
  197. package/rules/tree-sitter-queries/cpp/no-confused-move-forward.yml +0 -59
  198. package/rules/tree-sitter-queries/cpp/noexcept-functions.yml +0 -58
  199. package/rules/tree-sitter-queries/java/mockito-initialized.yml +0 -66
  200. package/rules/tree-sitter-queries/java/no-wait-notify-on-thread.yml +0 -58
  201. package/rules/tree-sitter-queries/java/short-circuit-logic.yml +0 -57
  202. package/rules/tree-sitter-queries/java/spring-session-attributes-setcomplete.yml +0 -75
  203. package/rules/tree-sitter-queries/plsql-disabled/delete-update-where.yml +0 -56
  204. package/rules/tree-sitter-queries/plsql-disabled/end-loop-semicolon.yml +0 -51
  205. package/rules/tree-sitter-queries/plsql-disabled/fetch-bulk-collect-limit.yml +0 -47
  206. package/rules/tree-sitter-queries/plsql-disabled/forallsave-exceptions.yml +0 -51
  207. package/rules/tree-sitter-queries/plsql-disabled/nchar-nvarchar2-bytes.yml +0 -54
  208. package/rules/tree-sitter-queries/plsql-disabled/not-null-initialization.yml +0 -59
  209. package/rules/tree-sitter-queries/plsql-disabled/raise-application-error-codes.yml +0 -50
  210. package/rules/tree-sitter-queries/typescript-disabled/ts-path-traversal.yml +0 -71
@@ -12,7 +12,7 @@
12
12
  */
13
13
  import * as fs from "node:fs";
14
14
  import * as path from "node:path";
15
- import { firstChildOfType, parseTreeSitterRoot, resolveTreeSitterLanguage, walk, } from "./tree-sitter-shared.js";
15
+ import { firstChildOfType, withTreeSitterRoot, resolveTreeSitterLanguage, walk, } from "./tree-sitter-shared.js";
16
16
  // JS/TS/JSX share one grammar shape.
17
17
  const JSTS = {
18
18
  functionLike: new Set([
@@ -387,21 +387,18 @@ export class ComplexityClient {
387
387
  if (!nodes)
388
388
  return null;
389
389
  let content;
390
- let root;
391
390
  try {
392
391
  if (!fs.existsSync(absolutePath))
393
392
  return null;
394
393
  content = fs.readFileSync(absolutePath, "utf-8");
395
- root = await parseTreeSitterRoot(absolutePath, content);
396
394
  }
397
395
  catch (err) {
398
- this.log(`Read/parse error for ${filePath}: ${err.message}`);
396
+ this.log(`Read error for ${filePath}: ${err.message}`);
399
397
  return null;
400
398
  }
401
- if (!root)
402
- return null;
403
399
  try {
404
- return this.computeMetrics(absolutePath, content, root, nodes);
400
+ const parsed = await withTreeSitterRoot(absolutePath, content, (root) => this.computeMetrics(absolutePath, content, root, nodes));
401
+ return parsed.parsed ? parsed.value : null;
405
402
  }
406
403
  catch (err) {
407
404
  this.log(`Analysis error for ${filePath}: ${err.message}`);
@@ -60,6 +60,29 @@ export function parseMadgeSkips(stderr) {
60
60
  const local = specifiers.filter((s) => s.startsWith(".") || s.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(s));
61
61
  return { total, local };
62
62
  }
63
+ /**
64
+ * Defensive cap on concurrent madge spawns for a single `checkFilesBatch`
65
+ * call. A turn usually only touches 1-3 import-changed files, so this rarely
66
+ * binds — it just guards against a pathological turn (bulk rename/move) from
67
+ * fork-bombing subprocesses.
68
+ */
69
+ const MADGE_BATCH_CONCURRENCY = 6;
70
+ /** Run `mapper` over `items` with at most `concurrency` in flight at once. */
71
+ async function mapWithConcurrency(items, concurrency, mapper) {
72
+ if (items.length === 0)
73
+ return;
74
+ let nextIndex = 0;
75
+ const workerCount = Math.max(1, Math.min(concurrency, items.length));
76
+ const workers = Array.from({ length: workerCount }, async () => {
77
+ while (true) {
78
+ const index = nextIndex++;
79
+ if (index >= items.length)
80
+ return;
81
+ await mapper(items[index]);
82
+ }
83
+ });
84
+ await Promise.all(workers);
85
+ }
63
86
  // --- Client ---
64
87
  export class DependencyChecker {
65
88
  available = null;
@@ -258,6 +281,33 @@ export class DependencyChecker {
258
281
  return promise;
259
282
  }
260
283
  async runCheckFile(normalized, projectRoot) {
284
+ const spawnResult = await this.runMadgeSpawn(normalized, projectRoot);
285
+ if (!spawnResult.ok) {
286
+ return {
287
+ hasCircular: false,
288
+ circular: [],
289
+ checked: false,
290
+ cacheHit: false,
291
+ };
292
+ }
293
+ this.lastCircular = spawnResult.circular;
294
+ this.circularFiles = spawnResult.circularFiles;
295
+ return {
296
+ hasCircular: spawnResult.circular.length > 0,
297
+ circular: spawnResult.circular.filter((d) => d.file === normalized || d.path.includes(normalized)),
298
+ checked: true,
299
+ cacheHit: false,
300
+ localSkips: spawnResult.localSkips,
301
+ };
302
+ }
303
+ /**
304
+ * Run madge on a single file and parse its cycle output. Pure: unlike
305
+ * `runCheckFile`, this does NOT mutate `lastCircular`/`circularFiles` — it
306
+ * hands the parsed result back so callers can apply the shared-state update
307
+ * themselves (`runCheckFile` does so immediately; `checkFilesBatch` defers
308
+ * it so concurrent spawns can't clobber each other's writes, see #766).
309
+ */
310
+ async runMadgeSpawn(normalized, projectRoot) {
261
311
  this.log(`Imports changed for ${path.basename(normalized)}, checking dependencies...`);
262
312
  // Run madge on the specific file (fast)
263
313
  try {
@@ -268,12 +318,7 @@ export class DependencyChecker {
268
318
  });
269
319
  if (result.error) {
270
320
  this.log(`Check error: ${result.error.message}`);
271
- return {
272
- hasCircular: false,
273
- circular: [],
274
- checked: false,
275
- cacheHit: false,
276
- };
321
+ return { ok: false };
277
322
  }
278
323
  const output = result.stdout || "[]";
279
324
  const parsed = JSON.parse(output);
@@ -291,29 +336,115 @@ export class DependencyChecker {
291
336
  path: resolvedPaths,
292
337
  });
293
338
  }
294
- this.lastCircular = circular;
295
- this.circularFiles = circularFiles;
296
339
  const skips = parseMadgeSkips(result.stderr || "");
297
340
  if (skips.local.length > 0) {
298
341
  this.log(`madge skipped ${skips.local.length} local file(s) (possible silent cycle-miss): ${skips.local.slice(0, 5).join(", ")}`);
299
342
  }
300
- return {
301
- hasCircular: circular.length > 0,
302
- circular: circular.filter((d) => d.file === normalized || d.path.includes(normalized)),
303
- checked: true,
304
- cacheHit: false,
305
- localSkips: skips.local.length,
306
- };
343
+ return { ok: true, circular, circularFiles, localSkips: skips.local.length };
307
344
  }
308
345
  catch (err) {
309
346
  this.log(`Check error: ${err.message}`);
310
- return {
311
- hasCircular: false,
312
- circular: [],
313
- checked: false,
347
+ return { ok: false };
348
+ }
349
+ }
350
+ /** Build the cache-hit `DepCheckResult` for `normalized` from current shared state. */
351
+ buildCachedResult(normalized) {
352
+ return {
353
+ hasCircular: this.circularFiles.has(normalized),
354
+ circular: this.lastCircular.filter((d) => d.file === normalized || d.path.includes(normalized)),
355
+ checked: true,
356
+ cacheHit: true,
357
+ };
358
+ }
359
+ /**
360
+ * Batch-check multiple files for circular deps in one turn-end pass,
361
+ * running the madge subprocess spawns concurrently (bounded by
362
+ * `MADGE_BATCH_CONCURRENCY`) instead of one at a time (#766).
363
+ *
364
+ * Equivalence with the sequential `for…await checkFile(file)` loop it
365
+ * replaces:
366
+ * - Classification (existence + `importsChanged`) runs synchronously in
367
+ * original array order first — identical to what each sequential
368
+ * `checkFile()` call would do, including the `importCache` side effect.
369
+ * - Only the actual madge spawns for import-changed ("miss") files run
370
+ * concurrently; each one's parsed result stays LOCAL (no shared-state
371
+ * write) until every spawn has settled.
372
+ * - The shared `lastCircular`/`circularFiles` state is then folded by
373
+ * replaying the miss results in ORIGINAL array order (not completion
374
+ * order), so the final state — and each cache-hit file's result, which
375
+ * reads the state as of its position in the array — is byte-for-byte
376
+ * what the sequential last-write-wins loop would have produced. No
377
+ * concurrent write can clobber another's, because none are applied
378
+ * until after `Promise.all` resolves.
379
+ */
380
+ async checkFilesBatch(filePaths, cwd) {
381
+ const projectRoot = path.resolve(cwd || process.cwd());
382
+ const results = new Map();
383
+ const entries = [];
384
+ for (const file of filePaths) {
385
+ const normalized = path.resolve(projectRoot, file);
386
+ if (!fs.existsSync(normalized)) {
387
+ entries.push({ kind: "missing", file });
388
+ continue;
389
+ }
390
+ entries.push(this.importsChanged(normalized)
391
+ ? { kind: "miss", file, normalized }
392
+ : { kind: "hit", file, normalized });
393
+ }
394
+ const missEntries = entries.filter((e) => e.kind === "miss");
395
+ const notAvailableResult = {
396
+ hasCircular: false,
397
+ circular: [],
398
+ checked: false,
399
+ cacheHit: false,
400
+ };
401
+ if (missEntries.length > 0 && !(await this.ensureAvailable())) {
402
+ // madge unavailable: mirrors checkFile()'s "not available" branch for
403
+ // every miss; hits still read whatever shared state already exists.
404
+ for (const entry of entries) {
405
+ if (entry.kind === "hit") {
406
+ results.set(entry.file, this.buildCachedResult(entry.normalized));
407
+ }
408
+ else {
409
+ results.set(entry.file, notAvailableResult);
410
+ }
411
+ }
412
+ return results;
413
+ }
414
+ // Run the concurrent (bounded) madge spawns for the miss set only. Each
415
+ // result stays local — keyed by normalized path — until folded below.
416
+ const spawnResults = new Map();
417
+ await mapWithConcurrency(missEntries, MADGE_BATCH_CONCURRENCY, async (entry) => {
418
+ spawnResults.set(entry.normalized, await this.runMadgeSpawn(entry.normalized, projectRoot));
419
+ });
420
+ // Fold in original order: each miss overwrites the shared state exactly
421
+ // as the sequential loop would, and each hit is resolved against the
422
+ // state as folded up to (but not past) its own position.
423
+ for (const entry of entries) {
424
+ if (entry.kind === "missing") {
425
+ results.set(entry.file, notAvailableResult);
426
+ continue;
427
+ }
428
+ if (entry.kind === "hit") {
429
+ results.set(entry.file, this.buildCachedResult(entry.normalized));
430
+ continue;
431
+ }
432
+ const spawnResult = spawnResults.get(entry.normalized);
433
+ if (!spawnResult || !spawnResult.ok) {
434
+ results.set(entry.file, notAvailableResult);
435
+ continue;
436
+ }
437
+ this.lastCircular = spawnResult.circular;
438
+ this.circularFiles = spawnResult.circularFiles;
439
+ results.set(entry.file, {
440
+ hasCircular: spawnResult.circular.length > 0,
441
+ circular: spawnResult.circular.filter((d) => d.file === entry.normalized || d.path.includes(entry.normalized)),
442
+ checked: true,
314
443
  cacheHit: false,
315
- };
444
+ localSkips: spawnResult.localSkips,
445
+ });
316
446
  }
447
+ return results;
317
448
  }
318
449
  /**
319
450
  * Format circular dependency warning for LLM
@@ -3,7 +3,7 @@
3
3
  * Exposes both the default export (callers that do `import yaml from …`) and the
4
4
  * named `dump`/`load` helpers, sourced from the one import.
5
5
  */
6
- import yaml from "js-yaml";
6
+ import * as yaml from "js-yaml";
7
7
  export default yaml;
8
8
  export const dump = yaml.dump;
9
9
  export const load = yaml.load;
@@ -73,9 +73,29 @@ const deferredThisSession = new Set();
73
73
  export function normalizeMessage(message) {
74
74
  return message.replace(/\s+/g, " ").trim().toLowerCase();
75
75
  }
76
+ // Anchor derivation chokepoint (#1024, #210 class): the `dd:`/`ddw:` id builders
77
+ // (computeStrictAnchor/computeWeakAnchor) both derive their path component here,
78
+ // so a mark and its later lookup diverge whenever the two callers pass different
79
+ // path FORMS of the same file. That is exactly the bug: the mark tool
80
+ // (lens-diagnostic-mark.ts) passes a RAW cwd / `path.resolve(cwd, arg)`, while
81
+ // the dispatch read side (dispatcher.ts createDispatchContext) passes
82
+ // `normalizeMapKey`-canonicalized cwd/filePath — so a Windows drive/segment
83
+ // case, symlink/realpath, or slash difference between the two forms silently
84
+ // orphans the agent's own false-positive/flagged mark (a #533 dropped-signal).
85
+ // Canonicalize BOTH inputs through `normalizeMapKey` (the SAME normalizer the
86
+ // read side already relies on — realpathSync.native on Windows) BEFORE computing
87
+ // the relative path, so write and read produce identical anchors regardless of
88
+ // the form the caller held. `normalizeMapKey` is idempotent, so the already-
89
+ // canonicalized read side is unaffected; the realpath I/O is acceptable here
90
+ // because dispositions are marked/applied far less often than the per-write
91
+ // widget hot path, and the read side already pays exactly this cost. Semantics
92
+ // are unchanged: the `..`-escape fallback still returns the canonical filePath,
93
+ // only now in the same canonical form the non-escape branch uses.
76
94
  function relativeFile(filePath, cwd) {
77
- const rel = path.relative(cwd, filePath).replace(/\\/g, "/");
78
- return rel && !rel.startsWith("..") ? rel : normalizeMapKey(filePath);
95
+ const canonicalCwd = normalizeMapKey(cwd);
96
+ const canonicalFile = normalizeMapKey(filePath);
97
+ const rel = path.relative(canonicalCwd, canonicalFile).replace(/\\/g, "/");
98
+ return rel && !rel.startsWith("..") ? rel : canonicalFile;
79
99
  }
80
100
  /** Site-specific anchor — see module doc. Used only for false-positive. */
81
101
  export function computeStrictAnchor(args) {
@@ -58,6 +58,35 @@ export function isNosemgrepSuppressed(d, content) {
58
58
  return (checkLine(lines[startLine], false) ||
59
59
  checkLine(lines[startLine - 1], true));
60
60
  }
61
+ /**
62
+ * zizmor's own native inline suppression: `# zizmor: ignore[audit-id[,audit-id]]`
63
+ * on the finding's own line (https://docs.zizmor.sh/usage/#ignoring-results —
64
+ * zizmor calls this "inline ignores"). Honoring it here mirrors
65
+ * `isNosemgrepSuppressed` for Opengrep — a per-finding suppression path a repo
66
+ * can reach for even without its own `zizmor.yml` (#971): e.g. a `# zizmor:
67
+ * ignore[artipacked]` on a checkout-only job's `actions/checkout` step, or
68
+ * `# zizmor: ignore[adhoc-packages]` on a `npm pack` tarball's local install
69
+ * line, both cases the audit has no way to tell "checkout-only, no artifact
70
+ * upload" or "this is testing our own just-built tarball" apart from a
71
+ * remote-package install without this local, human-authored signal.
72
+ */
73
+ const ZIZMOR_IGNORE_RE = /#\s*zizmor:\s*ignore\[([^\]]+)\]/i;
74
+ export function isZizmorIgnoreSuppressed(d, content) {
75
+ const startLine = d.range?.start?.line; // 0-based
76
+ if (startLine == null)
77
+ return false;
78
+ const line = content.split("\n")[startLine];
79
+ if (!line)
80
+ return false;
81
+ const match = ZIZMOR_IGNORE_RE.exec(line);
82
+ if (!match)
83
+ return false;
84
+ const ruleId = String(d.code ?? "").toLowerCase();
85
+ return match[1]
86
+ .split(",")
87
+ .map((s) => s.trim().toLowerCase())
88
+ .includes(ruleId);
89
+ }
61
90
  // #277 R7: every profile below used this exact same rule (ERROR-severity
62
91
  // findings block only when the workspace opted into curated/authored rules;
63
92
  // everything else stays advisory) — shared here instead of copy-pasted per
@@ -118,6 +147,11 @@ export const AUXILIARY_LSP_PROFILES = [
118
147
  allowBlocking: (cwd) => Boolean(findLocalZizmorConfig(cwd)),
119
148
  semantic: blockOnErrorWhenAllowed,
120
149
  defectClass: (d) => classifyDefect(String(d.code ?? ""), "zizmor", d.message ?? ""),
150
+ // Honor zizmor's own native per-finding suppression (#971) — a documented
151
+ // escape hatch for a workflow-context judgment call the audit itself has no
152
+ // way to make (checkout-only vs. artifact-uploading job; a locally-built
153
+ // tarball install vs. an arbitrary remote package).
154
+ isSuppressed: isZizmorIgnoreSuppressed,
121
155
  },
122
156
  {
123
157
  serverId: "typos",
@@ -22,6 +22,7 @@ import { isTestFile } from "../file-utils.js";
22
22
  import { getPrimaryDispatchGroup } from "../language-policy.js";
23
23
  import { resolveLanguageRootForFile } from "../language-profile.js";
24
24
  import { logLatency } from "../latency-logger.js";
25
+ import { isSpawnableCommand } from "../installer/index.js";
25
26
  import { normalizeMapKey } from "../path-utils.js";
26
27
  import { loadPiLensProjectConfig } from "../project-lens-config.js";
27
28
  import { RUNTIME_CONFIG, getRunnerTimeoutFloorMs } from "../runtime-config.js";
@@ -79,6 +80,12 @@ async function checkToolAvailability(command, facts) {
79
80
  if (cached !== undefined) {
80
81
  return cached;
81
82
  }
83
+ // A command that isn't even on disk can't pass a --version probe; the ~μs
84
+ // stat/PATH walk saves a guaranteed-to-fail spawn round-trip per cold tool.
85
+ if (!(await isSpawnableCommand(command))) {
86
+ facts.setSessionFact(key, false);
87
+ return false;
88
+ }
82
89
  try {
83
90
  const result = await safeSpawnAsync(command, ["--version"], {
84
91
  timeout: 5000,
@@ -563,7 +570,15 @@ export async function dispatchForFile(ctx, groups, registry, onRunnerResult) {
563
570
  // #690: agent/user disposition layer — drop false-positive/suppress marks
564
571
  // and anything deferred this session. flagged marks are left in place;
565
572
  // lens_diagnostics tags them at render time via the same anchor.
566
- const dispositionFiltered = applyDispositions(inlineSuppressed, ctx.cwd, ctx.filePath, fileContent);
573
+ //
574
+ // #1030: anchor + store MUST key off the PROJECT ROOT, not ctx.cwd. In a
575
+ // monorepo ctx.cwd is the nested language root (resolveLanguageRootForFile,
576
+ // used for tool/config resolution), but the mark tool writes dispositions
577
+ // under runtime.projectRoot. Keying the read on ctx.cwd computed a different
578
+ // anchor AND opened a different diagnostic-dispositions.json (getProjectDataDir
579
+ // is keyed on cwd), so every false-positive/flagged/defer on a file under a
580
+ // nested marker silently no-op'd. Read from the project root to match the write.
581
+ const dispositionFiltered = applyDispositions(inlineSuppressed, ctx.projectRoot ?? ctx.cwd, ctx.filePath, fileContent);
567
582
  let visibleDiagnostics = dispositionFiltered;
568
583
  let resolvedCount = 0;
569
584
  if (ctx.deltaMode && previousBaseline) {
@@ -19,6 +19,17 @@ export class FactStore {
19
19
  hasFileFact(filePath, factId) {
20
20
  return this.fileFacts.get(normalizeMapKey(filePath))?.has(factId) ?? false;
21
21
  }
22
+ /** Drop one file fact without disturbing the file's derived facts.
23
+ * Removes the per-file entry too when the deleted fact was its last value. */
24
+ deleteFileFact(filePath, factId) {
25
+ const key = normalizeMapKey(filePath);
26
+ const facts = this.fileFacts.get(key);
27
+ if (!facts)
28
+ return;
29
+ facts.delete(factId);
30
+ if (facts.size === 0)
31
+ this.fileFacts.delete(key);
32
+ }
22
33
  /** Clear facts for one specific file only. Use at the start of each per-file dispatch call.
23
34
  * Preserves facts for other files computed in the same turn.
24
35
  * Normalizes filePath internally — callers pass raw paths. */
@@ -217,6 +217,19 @@ function collectReceiverTypes(body, params) {
217
217
  });
218
218
  return types;
219
219
  }
220
+ /**
221
+ * Does this function/method declaration have an explicit `: Promise<...>`
222
+ * return type? The `type_annotation` for a return type is a DIRECT child of
223
+ * the function node itself (after `formal_parameters`, before the body) —
224
+ * distinct from a parameter's own `type_annotation`, which is nested inside
225
+ * `formal_parameters` and therefore invisible to `firstChildOfType` here.
226
+ */
227
+ function hasExplicitPromiseReturnType(node) {
228
+ const returnType = firstChildOfType(node, "type_annotation");
229
+ if (!returnType)
230
+ return false;
231
+ return /:\s*Promise\b/.test(returnType.text);
232
+ }
220
233
  function hasAwaitInNode(node) {
221
234
  let found = false;
222
235
  walk(node, (n) => {
@@ -283,6 +296,7 @@ export const functionFactProvider = {
283
296
  isPassThroughWrapper: passThrough.pass,
284
297
  passThroughTarget: passThrough.target,
285
298
  isBoundaryWrapper,
299
+ hasExplicitPromiseReturnType: hasExplicitPromiseReturnType(node),
286
300
  cyclomaticComplexity: calcCyclomaticComplexity(body),
287
301
  maxNestingDepth: calcMaxNestingDepth(body),
288
302
  outgoingCalls: collectOutgoingCalls(body),
@@ -1,5 +1,5 @@
1
1
  import { logLatency } from "../../latency-logger.js";
2
- import { childrenOfType, firstChildOfType, parseFactTree, walk, } from "./tree-sitter-facts.js";
2
+ import { childrenOfType, firstChildOfType, withFactTree, walk, } from "./tree-sitter-facts.js";
3
3
  // JS/TS extensions this provider handles (→ tree-sitter typescript/tsx/javascript
4
4
  // grammars via resolveTreeSitterLanguage). Ported off the `typescript` compiler API
5
5
  // onto tree-sitter (#402) — the parse is served from the shared, cached client.
@@ -96,115 +96,114 @@ export const importFactProvider = {
96
96
  }
97
97
  // Grammar unavailable / parse failed / wasm aborted — degrade to empty
98
98
  // (there is no typescript-compiler fallback by design, #402).
99
- const root = await parseFactTree(ctx.filePath, content);
100
- if (!root) {
101
- setEmpty();
102
- return;
103
- }
104
- // --- module-type detection + static import/re-export node collection ---
105
- let hasEsm = false;
106
- let hasCjs = false;
107
- const importNodes = [];
108
- const reExportNodes = [];
109
- for (const child of root.children ?? []) {
110
- if (!child)
111
- continue;
112
- if (child.type === "import_statement") {
113
- hasEsm = true;
114
- importNodes.push(child);
115
- }
116
- else if (child.type === "export_statement") {
117
- const isReExport = Boolean(firstChildOfType(child, "string"));
118
- // `export { x }` / `export * from` / `export { x } from` are ESM
119
- // declarations (like TS's isExportDeclaration); `export const x` /
120
- // `export default` (a wrapped declaration) is NOT counted here.
121
- if (isReExport || firstChildOfType(child, "export_clause")) {
99
+ const parsed = await withFactTree(ctx.filePath, content, (root) => {
100
+ // --- module-type detection + static import/re-export node collection ---
101
+ let hasEsm = false;
102
+ let hasCjs = false;
103
+ const importNodes = [];
104
+ const reExportNodes = [];
105
+ for (const child of root.children ?? []) {
106
+ if (!child)
107
+ continue;
108
+ if (child.type === "import_statement") {
122
109
  hasEsm = true;
110
+ importNodes.push(child);
111
+ }
112
+ else if (child.type === "export_statement") {
113
+ const isReExport = Boolean(firstChildOfType(child, "string"));
114
+ // `export { x }` / `export * from` / `export { x } from` are ESM
115
+ // declarations (like TS's isExportDeclaration); `export const x` /
116
+ // `export default` (a wrapped declaration) is NOT counted here.
117
+ if (isReExport || firstChildOfType(child, "export_clause")) {
118
+ hasEsm = true;
119
+ }
120
+ if (isReExport)
121
+ reExportNodes.push(child);
123
122
  }
124
- if (isReExport)
125
- reExportNodes.push(child);
126
123
  }
127
- }
128
- // --- dynamic import() / require() / module.exports (anywhere in the tree) ---
129
- const dynamicRaw = [];
130
- walk(root, (node) => {
131
- if (node.type === "call_expression") {
132
- const callee = node.children?.[0];
133
- const args = firstChildOfType(node, "arguments");
134
- const strArg = args ? firstChildOfType(args, "string") : undefined;
135
- if (!strArg)
136
- return; // non-string arg (e.g. template literal) — skip
137
- const source = stripQuotes(strArg.text);
138
- if (callee?.type === "import") {
139
- dynamicRaw.push({ source, kind: "import" });
124
+ // --- dynamic import() / require() / module.exports (anywhere in the tree) ---
125
+ const dynamicRaw = [];
126
+ walk(root, (node) => {
127
+ if (node.type === "call_expression") {
128
+ const callee = node.children?.[0];
129
+ const args = firstChildOfType(node, "arguments");
130
+ const strArg = args ? firstChildOfType(args, "string") : undefined;
131
+ if (!strArg)
132
+ return; // non-string arg (e.g. template literal) — skip
133
+ const source = stripQuotes(strArg.text);
134
+ if (callee?.type === "import") {
135
+ dynamicRaw.push({ source, kind: "import" });
136
+ }
137
+ else if (callee?.type === "identifier" && callee.text === "require") {
138
+ dynamicRaw.push({ source, kind: "require" });
139
+ hasCjs = true;
140
+ }
140
141
  }
141
- else if (callee?.type === "identifier" && callee.text === "require") {
142
- dynamicRaw.push({ source, kind: "require" });
143
- hasCjs = true;
142
+ else if (node.type === "member_expression") {
143
+ // module.exports = ...
144
+ const obj = node.children?.[0];
145
+ if (obj?.type === "identifier" && obj.text === "module") {
146
+ const prop = firstChildOfType(node, "property_identifier");
147
+ if (prop?.text === "exports")
148
+ hasCjs = true;
149
+ }
144
150
  }
151
+ });
152
+ const moduleType = hasEsm && !hasCjs
153
+ ? "esm"
154
+ : hasCjs && !hasEsm
155
+ ? "cjs"
156
+ : hasEsm || hasCjs
157
+ ? "esm" // mixed — static imports present, treat as ESM
158
+ : "unknown";
159
+ // --- build entries ---
160
+ const imports = [];
161
+ for (const node of importNodes) {
162
+ const entry = parseStaticImport(node, moduleType);
163
+ if (entry)
164
+ imports.push(entry);
145
165
  }
146
- else if (node.type === "member_expression") {
147
- // module.exports = ...
148
- const obj = node.children?.[0];
149
- if (obj?.type === "identifier" && obj.text === "module") {
150
- const prop = firstChildOfType(node, "property_identifier");
151
- if (prop?.text === "exports")
152
- hasCjs = true;
166
+ let dynamicCount = 0;
167
+ for (const d of dynamicRaw) {
168
+ if (d.kind === "import") {
169
+ imports.push({
170
+ source: d.source,
171
+ names: [],
172
+ isDynamic: true,
173
+ moduleType,
174
+ });
175
+ }
176
+ else {
177
+ imports.push({ source: d.source, names: [], moduleType: "cjs" });
153
178
  }
179
+ dynamicCount++;
154
180
  }
155
- });
156
- const moduleType = hasEsm && !hasCjs
157
- ? "esm"
158
- : hasCjs && !hasEsm
159
- ? "cjs"
160
- : hasEsm || hasCjs
161
- ? "esm" // mixed — static imports present, treat as ESM
162
- : "unknown";
163
- // --- build entries ---
164
- const imports = [];
165
- for (const node of importNodes) {
166
- const entry = parseStaticImport(node, moduleType);
167
- if (entry)
168
- imports.push(entry);
169
- }
170
- let dynamicCount = 0;
171
- for (const d of dynamicRaw) {
172
- if (d.kind === "import") {
173
- imports.push({
174
- source: d.source,
175
- names: [],
176
- isDynamic: true,
177
- moduleType,
178
- });
181
+ store.setFileFact(ctx.filePath, "file.imports", imports);
182
+ const reexports = [];
183
+ for (const node of reExportNodes) {
184
+ const r = parseReExport(node);
185
+ if (r)
186
+ reexports.push(r);
179
187
  }
180
- else {
181
- imports.push({ source: d.source, names: [], moduleType: "cjs" });
188
+ store.setFileFact(ctx.filePath, "file.reexports", reexports);
189
+ // Telemetry: log when a file has dynamic imports or re-exports so we can
190
+ // measure coverage and validate the implementation across real projects.
191
+ if (dynamicCount > 0 || reexports.length > 0) {
192
+ logLatency({
193
+ type: "call_graph_facts",
194
+ filePath: ctx.filePath,
195
+ durationMs: 0,
196
+ metadata: {
197
+ moduleType,
198
+ staticImports: imports.length - dynamicCount,
199
+ dynamicImports: dynamicCount,
200
+ reexports: reexports.length,
201
+ starReexports: reexports.filter((r) => r.names.length === 0).length,
202
+ },
203
+ });
182
204
  }
183
- dynamicCount++;
184
- }
185
- store.setFileFact(ctx.filePath, "file.imports", imports);
186
- const reexports = [];
187
- for (const node of reExportNodes) {
188
- const r = parseReExport(node);
189
- if (r)
190
- reexports.push(r);
191
- }
192
- store.setFileFact(ctx.filePath, "file.reexports", reexports);
193
- // Telemetry: log when a file has dynamic imports or re-exports so we can
194
- // measure coverage and validate the implementation across real projects.
195
- if (dynamicCount > 0 || reexports.length > 0) {
196
- logLatency({
197
- type: "call_graph_facts",
198
- filePath: ctx.filePath,
199
- durationMs: 0,
200
- metadata: {
201
- moduleType,
202
- staticImports: imports.length - dynamicCount,
203
- dynamicImports: dynamicCount,
204
- reexports: reexports.length,
205
- starReexports: reexports.filter((r) => r.names.length === 0).length,
206
- },
207
- });
208
- }
205
+ });
206
+ if (!parsed.parsed)
207
+ setEmpty();
209
208
  },
210
209
  };