pi-lens 4.1.5 → 4.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 (219) hide show
  1. package/CHANGELOG.md +415 -0
  2. package/README.md +13 -8
  3. package/config/dependency-cruiser-eager-allowlist.json +6 -1
  4. package/dist/clients/actionable-warnings.js +30 -4
  5. package/dist/clients/agent-behavior-client.js +2 -0
  6. package/dist/clients/analysed-root.js +1 -0
  7. package/dist/clients/ast-grep-tool-logger.js +1 -1
  8. package/dist/clients/bash-file-access.js +26 -0
  9. package/dist/clients/biome-client.js +13 -2
  10. package/dist/clients/blocker-freshness.js +403 -35
  11. package/dist/clients/bootstrap.js +6 -1
  12. package/dist/clients/bounded-telemetry.js +2 -0
  13. package/dist/clients/cache-observability.js +122 -1
  14. package/dist/clients/cascade-format.js +200 -4
  15. package/dist/clients/code-quality-warnings.js +10 -2
  16. package/dist/clients/complexity-client.js +5 -0
  17. package/dist/clients/config-diagnostic-codes.js +2 -0
  18. package/dist/clients/config-resolve.js +6 -3
  19. package/dist/clients/config-schema.js +17 -0
  20. package/dist/clients/config-warn.js +2 -2
  21. package/dist/clients/dead-code-client.js +8 -1
  22. package/dist/clients/degradation-ledger.js +16 -1
  23. package/dist/clients/dependency-checker.js +5 -1
  24. package/dist/clients/diagnostic-dispositions.js +12 -1
  25. package/dist/clients/dispatch/ast-grep-catalog.js +65 -0
  26. package/dist/clients/dispatch/auxiliary-lsp.js +28 -0
  27. package/dist/clients/dispatch/dispatcher.js +47 -17
  28. package/dist/clients/dispatch/facts/function-facts.js +1 -1
  29. package/dist/clients/dispatch/facts/import-facts.js +5 -1
  30. package/dist/clients/dispatch/finding-policy.js +222 -0
  31. package/dist/clients/dispatch/indent-detect.js +389 -13
  32. package/dist/clients/dispatch/integration.js +175 -25
  33. package/dist/clients/dispatch/rule-ignores.js +62 -0
  34. package/dist/clients/dispatch/runners/actionlint.js +2 -1
  35. package/dist/clients/dispatch/runners/ast-grep-napi.js +21 -41
  36. package/dist/clients/dispatch/runners/biome-check.js +8 -4
  37. package/dist/clients/dispatch/runners/cpp-check.js +4 -3
  38. package/dist/clients/dispatch/runners/credo.js +2 -1
  39. package/dist/clients/dispatch/runners/cue-vet.js +2 -1
  40. package/dist/clients/dispatch/runners/dart-analyze.js +2 -1
  41. package/dist/clients/dispatch/runners/detekt.js +2 -1
  42. package/dist/clients/dispatch/runners/dotnet-build.js +2 -1
  43. package/dist/clients/dispatch/runners/elixir-check.js +2 -1
  44. package/dist/clients/dispatch/runners/eslint.js +2 -1
  45. package/dist/clients/dispatch/runners/fish-indent.js +2 -1
  46. package/dist/clients/dispatch/runners/gleam-check.js +2 -1
  47. package/dist/clients/dispatch/runners/go-vet.js +2 -1
  48. package/dist/clients/dispatch/runners/golangci-lint.js +2 -1
  49. package/dist/clients/dispatch/runners/hadolint.js +2 -1
  50. package/dist/clients/dispatch/runners/helm-lint.js +3 -1
  51. package/dist/clients/dispatch/runners/helm-render.js +2 -1
  52. package/dist/clients/dispatch/runners/htmlhint.js +2 -1
  53. package/dist/clients/dispatch/runners/javac.js +2 -1
  54. package/dist/clients/dispatch/runners/ktlint.js +2 -1
  55. package/dist/clients/dispatch/runners/lsp.js +19 -1
  56. package/dist/clients/dispatch/runners/markdownlint.js +3 -2
  57. package/dist/clients/dispatch/runners/mypy.js +2 -1
  58. package/dist/clients/dispatch/runners/oxlint.js +2 -1
  59. package/dist/clients/dispatch/runners/php-lint.js +2 -1
  60. package/dist/clients/dispatch/runners/phpstan.js +2 -1
  61. package/dist/clients/dispatch/runners/prisma-validate.js +2 -1
  62. package/dist/clients/dispatch/runners/psscriptanalyzer.js +12 -12
  63. package/dist/clients/dispatch/runners/pyright.js +2 -1
  64. package/dist/clients/dispatch/runners/rubocop.js +2 -1
  65. package/dist/clients/dispatch/runners/ruff.js +2 -2
  66. package/dist/clients/dispatch/runners/rust-clippy.js +12 -11
  67. package/dist/clients/dispatch/runners/shellcheck.js +2 -1
  68. package/dist/clients/dispatch/runners/shfmt.js +4 -13
  69. package/dist/clients/dispatch/runners/spellcheck.js +2 -2
  70. package/dist/clients/dispatch/runners/spotbugs.js +2 -1
  71. package/dist/clients/dispatch/runners/sqlfluff.js +3 -2
  72. package/dist/clients/dispatch/runners/stylelint.js +3 -2
  73. package/dist/clients/dispatch/runners/swiftlint.js +2 -1
  74. package/dist/clients/dispatch/runners/taplo.js +2 -1
  75. package/dist/clients/dispatch/runners/terragrunt.js +2 -1
  76. package/dist/clients/dispatch/runners/tflint.js +2 -1
  77. package/dist/clients/dispatch/runners/tree-sitter.js +2 -21
  78. package/dist/clients/dispatch/runners/trivy-config.js +2 -1
  79. package/dist/clients/dispatch/runners/utils/availability-policy.js +5 -0
  80. package/dist/clients/dispatch/runners/utils/candidate-probe.js +2 -2
  81. package/dist/clients/dispatch/runners/utils/runner-helpers.js +8 -2
  82. package/dist/clients/dispatch/runners/vale.js +2 -1
  83. package/dist/clients/dispatch/runners/yamllint.js +4 -3
  84. package/dist/clients/dispatch/runners/zig-check.js +2 -1
  85. package/dist/clients/dispatch/types.js +4 -1
  86. package/dist/clients/disposition-publish.js +1 -0
  87. package/dist/clients/effective-config.js +12 -2
  88. package/dist/clients/extension-log.js +2 -0
  89. package/dist/clients/file-kinds.js +26 -1
  90. package/dist/clients/file-role.js +23 -6
  91. package/dist/clients/file-utils.js +156 -10
  92. package/dist/clients/finding-delivery-gate.js +60 -9
  93. package/dist/clients/format-service.js +39 -21
  94. package/dist/clients/formatters.js +113 -200
  95. package/dist/clients/generation-guard.js +6 -2
  96. package/dist/clients/git-guard.js +11 -3
  97. package/dist/clients/gitleaks-client.js +42 -6
  98. package/dist/clients/govulncheck-client.js +48 -7
  99. package/dist/clients/indent-retarget.js +78 -8
  100. package/dist/clients/installer/index.js +524 -161
  101. package/dist/clients/instance-reaper-state.js +7 -0
  102. package/dist/clients/instance-reaper.js +20 -10
  103. package/dist/clients/jscpd-client.js +9 -1
  104. package/dist/clients/knip-client.js +9 -2
  105. package/dist/clients/language-profile.js +35 -3
  106. package/dist/clients/language-registry.js +5 -1
  107. package/dist/clients/latency-logger.js +6 -1
  108. package/dist/clients/lens-config.js +4 -0
  109. package/dist/clients/lens-engine.js +12 -3
  110. package/dist/clients/lens-events.js +2 -1
  111. package/dist/clients/lens-flag-registry.js +79 -0
  112. package/dist/clients/live-bus-emitter.js +2 -1
  113. package/dist/clients/lsp/client.js +25 -21
  114. package/dist/clients/lsp/config.js +36 -7
  115. package/dist/clients/lsp/diagnostic-binding.js +24 -1
  116. package/dist/clients/lsp/document-drift.js +78 -0
  117. package/dist/clients/lsp/index.js +288 -45
  118. package/dist/clients/lsp/launch.js +16 -4
  119. package/dist/clients/lsp/server.js +79 -15
  120. package/dist/clients/lsp/workspace-diagnostics-cache.js +39 -5
  121. package/dist/clients/lsp-mutation.js +5 -3
  122. package/dist/clients/map-with-concurrency.js +12 -8
  123. package/dist/clients/mcp/analyze.js +3 -0
  124. package/dist/clients/mcp/session.js +38 -4
  125. package/dist/clients/ndjson-logger.js +24 -7
  126. package/dist/clients/observed-mutation.js +40 -2
  127. package/dist/clients/opaque-mutation-scan.js +23 -3
  128. package/dist/clients/opengrep-client.js +111 -14
  129. package/dist/clients/package-manager.js +2 -1
  130. package/dist/clients/path-utils.js +123 -3
  131. package/dist/clients/performance-report.js +5 -1
  132. package/dist/clients/php-cs-fixer-config.js +8 -3
  133. package/dist/clients/pipeline.js +77 -7
  134. package/dist/clients/project-diagnostics/cache.js +37 -5
  135. package/dist/clients/project-diagnostics/fresh-fetch.js +173 -18
  136. package/dist/clients/project-diagnostics/runner-adapters/dead-code.js +4 -1
  137. package/dist/clients/project-diagnostics/runner-adapters/gitleaks.js +10 -9
  138. package/dist/clients/project-diagnostics/scanner.js +59 -3
  139. package/dist/clients/project-lens-config.js +48 -2
  140. package/dist/clients/read-guard-logger.js +2 -0
  141. package/dist/clients/read-guard.js +70 -11
  142. package/dist/clients/resource-sampler.js +147 -9
  143. package/dist/clients/review-graph-logger.js +2 -0
  144. package/dist/clients/ruff-client.js +18 -9
  145. package/dist/clients/runtime-agent-end.js +42 -5
  146. package/dist/clients/runtime-context.js +6 -2
  147. package/dist/clients/runtime-coordinator.js +82 -7
  148. package/dist/clients/runtime-session.js +33 -5
  149. package/dist/clients/runtime-tool-call.js +43 -12
  150. package/dist/clients/runtime-tool-result.js +334 -86
  151. package/dist/clients/runtime-turn.js +195 -18
  152. package/dist/clients/safe-spawn.js +258 -5
  153. package/dist/clients/scratch-tree-policy.js +6 -0
  154. package/dist/clients/security-scan-client.js +2 -2
  155. package/dist/clients/session-event-guard.js +119 -6
  156. package/dist/clients/sg-runner.js +25 -12
  157. package/dist/clients/sgconfig.js +79 -2
  158. package/dist/clients/situational-tool-telemetry.js +108 -0
  159. package/dist/clients/test-runner-client.js +258 -78
  160. package/dist/clients/test-runner-delivery.js +43 -0
  161. package/dist/clients/tool-agreement.js +423 -0
  162. package/dist/clients/tool-config.js +249 -0
  163. package/dist/clients/tool-cwd.js +270 -0
  164. package/dist/clients/tool-policy.js +163 -7
  165. package/dist/clients/tool-probe.js +53 -0
  166. package/dist/clients/tool-set-policy.js +42 -0
  167. package/dist/clients/tree-sitter-client.js +120 -0
  168. package/dist/clients/tree-sitter-query-loader.js +127 -189
  169. package/dist/clients/trivy-client.js +9 -1
  170. package/dist/clients/turn-context.js +52 -0
  171. package/dist/clients/widget-state.js +102 -3
  172. package/dist/clients/word-index.js +5 -2
  173. package/dist/clients/write-ordering-guard.js +7 -0
  174. package/dist/index.js +26968 -21741
  175. package/dist/mcp/analyze-cli.js +3 -2
  176. package/dist/mcp/server.js +140 -161
  177. package/dist/tools/activate-tools.js +10 -7
  178. package/dist/tools/ast-grep-outline.js +2 -13
  179. package/dist/tools/ast-grep-replace.js +8 -13
  180. package/dist/tools/ast-grep-search.js +83 -39
  181. package/dist/tools/effective-config.js +4 -3
  182. package/dist/tools/lens-diagnostic-mark.js +8 -11
  183. package/dist/tools/lens-diagnostics.js +516 -133
  184. package/dist/tools/lsp-diagnostics.js +265 -182
  185. package/dist/tools/lsp-navigation.js +20 -44
  186. package/dist/tools/module-report.js +5 -9
  187. package/dist/tools/project-report.js +9 -17
  188. package/dist/tools/render-compact.js +280 -1
  189. package/dist/tools/symbol-search.js +2 -2
  190. package/docs/agent-guide.md +3 -4
  191. package/docs/agent-tools.md +46 -22
  192. package/docs/audit1.md +5 -5
  193. package/docs/configuration.md +9 -0
  194. package/docs/dependencies.md +11 -0
  195. package/docs/dispositions.md +52 -0
  196. package/docs/environment-variables.md +2 -7
  197. package/docs/features.md +7 -7
  198. package/docs/globalconfig.md +14 -2
  199. package/docs/language-coverage.md +1 -0
  200. package/docs/mcp.md +13 -1
  201. package/docs/pi-lens-fixer.md +48 -18
  202. package/docs/pi-lens-investigator.md +38 -18
  203. package/docs/pi-lens-monitor.md +88 -0
  204. package/docs/pi-lens-retro.md +97 -0
  205. package/docs/pi-lens-reviewer.md +53 -31
  206. package/docs/pi-lens-subagent.md +12 -4
  207. package/docs/public-api-stability.md +1 -0
  208. package/docs/real-harness.md +46 -0
  209. package/docs/release-qa-baseline.md +27 -1
  210. package/docs/servercapabilities.md +1 -6
  211. package/docs/settings.md +44 -6
  212. package/docs/tools_improvement2.md +4 -4
  213. package/docs/usage.md +15 -3
  214. package/package.json +10 -1
  215. package/rules/tree-sitter-queries/typescript/sql-injection.yml +23 -7
  216. package/scripts/analyze-pi-lens-logs.mjs +40 -0
  217. package/skills/pi-lens-ast-grep/SKILL.md +23 -6
  218. package/skills/pi-lens-lsp-navigation/SKILL.md +26 -9
  219. package/dist/tools/ast-dump.js +0 -103
@@ -19,7 +19,7 @@ import * as nodeFs from "node:fs";
19
19
  import * as nodePath from "node:path";
20
20
  import { fileURLToPath } from "node:url";
21
21
  import { CASCADE_NEIGHBOUR_BUDGET, deriveCascadeNeighbourBudget, } from "../cascade-budget.js";
22
- import { formatCascadeNeighborDiagnostics } from "../cascade-format.js";
22
+ import { applyCascadeDisplayPolicy, formatCascadeNeighborDiagnostics, recordCascadeFindingPolicy, } from "../cascade-format.js";
23
23
  import { logCascade } from "../cascade-logger.js";
24
24
  import { getDiagnosticTracker } from "../diagnostic-tracker.js";
25
25
  import { classifyCascadeWaitTier, isTierAwareCascadeEnabled, recordOutstandingCascadeTouch, } from "../lsp/cascade-tier.js";
@@ -434,6 +434,25 @@ function ensureCascadeTurnScope(turnSeq) {
434
434
  }
435
435
  const MAX_PER_FILE = RUNTIME_CONFIG.pipeline.cascadeMaxDiagnosticsPerFile;
436
436
  const MAX_FILES = RUNTIME_CONFIG.pipeline.cascadeMaxFiles;
437
+ /**
438
+ * How many of ONE neighbour's ERRORs the display policy may look at (#3157
439
+ * round 2, F1). `MAX_PER_FILE` is the DISPLAY cap and now applies to the
440
+ * survivors, so this is the separate, deliberately generous bound on the
441
+ * policy's INPUT — four full display caps of headroom, so a neighbour would
442
+ * need 80 suppressed findings before a genuine one could fall off the end
443
+ * again.
444
+ *
445
+ * It exists because the policy's cost is linear in the finding count once a
446
+ * project has ANY disposition mark (the zero-I/O hoist in
447
+ * `finding-policy.ts` stops firing and every finding expands into its
448
+ * candidate identities): measured on 40 real neighbours, 1.31 ms/neighbour at
449
+ * 20 findings, 4.74 ms at 80, and 28.29 ms at 500 — that last one is 1131
450
+ * ms/edit across a full 40-neighbour walk, a quarter of the `cascadeSettleWaitMs`
451
+ * window, for a neighbour nobody can read anyway. Truncation here is COUNTED
452
+ * (`inputTruncated` on the `cascade_finding_policy` row), because an input
453
+ * bound whose loss is invisible is exactly the defect F1 found.
454
+ */
455
+ const MAX_POLICY_INPUT_PER_FILE = MAX_PER_FILE * 4;
437
456
  /**
438
457
  * The genuine language-server ERROR diagnostics from a cascade neighbor
439
458
  * re-check, ready to reconcile into the footer widget (#1093).
@@ -455,8 +474,14 @@ const MAX_FILES = RUNTIME_CONFIG.pipeline.cascadeMaxFiles;
455
474
  * entries untouched. This keeps `isLspErrorEntry`'s "tool === 'lsp' uniquely
456
475
  * identifies a genuine language-server entry" contract true for what we write.
457
476
  *
458
- * The DISPLAY list (`diags` at each call site) is deliberately left as-is —
459
- * that is pre-existing cascade output behavior, out of scope for #1093.
477
+ * The DISPLAY list is no longer "left as-is" as it was for #1093: since #3157
478
+ * every display site runs `cascadeDisplay` → `applyCascadeDisplayPolicy`, which
479
+ * applies the finding-policy stack AND keeps the auxiliary retag's own drop set
480
+ * — so the display drops natively-suppressed aux findings while still RENDERING
481
+ * the aux findings this widget writer excludes wholesale. The two remain
482
+ * deliberately different: this one protects `isLspErrorEntry`'s "tool === 'lsp'"
483
+ * contract for PERSISTED widget state, which the ephemeral display list never
484
+ * touches.
460
485
  */
461
486
  function cascadeReconcilableLspErrors(rawDiags, neighborPath) {
462
487
  return convertLspDiagnostics(rawDiags
@@ -732,7 +757,7 @@ export async function computeCascadeForFile(filePath, cwd, options = {}) {
732
757
  // time that elapsed before turn_end began waiting.
733
758
  const cascadeStart = Date.now();
734
759
  try {
735
- const { hasBlockers = false, dbg, turnSeq = 0, writeSeq, seqState, turnEndCascadeSettleStart, fileContent, wordIndex, onWordIndexUpdated, } = options;
760
+ const { hasBlockers = false, dbg, turnSeq = 0, writeSeq, projectRoot, seqState, turnEndCascadeSettleStart, fileContent, wordIndex, onWordIndexUpdated, } = options;
736
761
  ensureCascadeTurnScope(turnSeq);
737
762
  if (hasBlockers) {
738
763
  logCascade({
@@ -1311,6 +1336,64 @@ export async function computeCascadeForFile(filePath, cwd, options = {}) {
1311
1336
  let passiveSnapshotHits = 0;
1312
1337
  let noLspConfigured = 0;
1313
1338
  let touchFailures = 0;
1339
+ // #3157: this run's AGGREGATE display-policy drops, across every neighbour
1340
+ // the four display sites below rendered. Aggregate, not per neighbour: a
1341
+ // cascade walks up to `CASCADE_NEIGHBOUR_BUDGET` (40) neighbours per edit,
1342
+ // so a record inside the loop would be per-occurrence logging on a per-edit
1343
+ // path — one bounded row per RUN instead (AGENTS.md "bounded observability").
1344
+ // `policyElapsedMs` accumulates ONLY the stack's own time (round 2, F2): a
1345
+ // clock started here would have spanned the whole `touchFile` fan-out and
1346
+ // reported the neighbour WALK as the policy phase — hundreds of ms against
1347
+ // the sub-millisecond cost AC 3 measured, under the same phase literal the
1348
+ // quiet-window lane writes.
1349
+ let policyElapsedMs = 0;
1350
+ const policyCounts = {
1351
+ suppressed: 0,
1352
+ auxSuppressed: 0,
1353
+ total: 0,
1354
+ inputTruncated: 0,
1355
+ };
1356
+ /**
1357
+ * The ONE seam every in-lane cascade DISPLAY list goes through (#3157).
1358
+ *
1359
+ * Before this, all four sites open-coded `convertLspDiagnostics(...)` and
1360
+ * rendered the result raw: `grep applyFindingPolicy clients/dispatch/
1361
+ * integration.ts` returned nothing, so a neighbour ERROR the agent had
1362
+ * marked `false-positive` was hidden by `mode=delta`/`mode=full`/the
1363
+ * per-edit dispatcher/the probe lane/the quiet-window cascade run and STILL
1364
+ * re-reported here on every edit that cascaded to it. The severity filter
1365
+ * and the `MAX_PER_FILE` display cap were the fourth copy of the same three
1366
+ * lines and fold in with it.
1367
+ *
1368
+ * `neighborContent` is passed by the ONE caller that has already read the
1369
+ * neighbour (the active touch reads it to feed `touchFile`) — those are the
1370
+ * exact bytes the diagnostics were computed against, so it is both free and
1371
+ * more faithful than a re-read. The other three sites let the shared helper
1372
+ * read once, and only once a neighbour actually has an ERROR to render.
1373
+ *
1374
+ * Stage order (round 2, F1): severity filter → input bound → convert →
1375
+ * retag → policy → DISPLAY cap. `MAX_PER_FILE` is applied to the survivors
1376
+ * inside the helper; applying it here, before the policy, let policy drops
1377
+ * consume display slots and rendered a false clean.
1378
+ */
1379
+ const cascadeDisplay = (rawDiags, neighborPath, neighborContent) => {
1380
+ const started = Date.now();
1381
+ const errors = rawDiags.filter((d) => d.severity === 1);
1382
+ const policyInput = errors.slice(0, MAX_POLICY_INPUT_PER_FILE);
1383
+ const result = applyCascadeDisplayPolicy(policyInput, {
1384
+ cwd,
1385
+ policyRoot: projectRoot ?? cwd,
1386
+ filePath: neighborPath,
1387
+ displayCap: MAX_PER_FILE,
1388
+ ...(neighborContent !== undefined && { content: neighborContent }),
1389
+ });
1390
+ policyCounts.suppressed += result.suppressed;
1391
+ policyCounts.auxSuppressed += result.auxSuppressed;
1392
+ policyCounts.total += result.total;
1393
+ policyCounts.inputTruncated += errors.length - policyInput.length;
1394
+ policyElapsedMs += Date.now() - started;
1395
+ return result.diagnostics;
1396
+ };
1314
1397
  if (sortedNeighbors.length > 0) {
1315
1398
  const snapshotPaths = sortedNeighbors.filter(shouldReadCascadeFromSnapshot);
1316
1399
  const activePaths = sortedNeighbors.filter((n) => !shouldReadCascadeFromSnapshot(n));
@@ -1371,7 +1454,8 @@ export async function computeCascadeForFile(filePath, cwd, options = {}) {
1371
1454
  // cascade neighbor diagnostics are ephemeral display-only output
1372
1455
  // (never reconciled into persisted widget/dedup state), so the label
1373
1456
  // had no remaining purpose and is simply dropped rather than migrated.
1374
- const diags = convertLspDiagnostics(entry.diags.filter((d) => d.severity === 1).slice(0, MAX_PER_FILE), neighborPath);
1457
+ // #3157 site 1 of 4: the passive cold-snapshot hit.
1458
+ const diags = cascadeDisplay(entry.diags, neighborPath);
1375
1459
  producedLspData = true;
1376
1460
  passiveSnapshotHits++;
1377
1461
  const durationMs = Date.now() - neighborStart;
@@ -1623,12 +1707,12 @@ export async function computeCascadeForFile(filePath, cwd, options = {}) {
1623
1707
  // doc comment on the sibling call above) — dropped rather than
1624
1708
  // migrated to `scanOrigin` since cascade output never touches
1625
1709
  // persisted widget/dedup state.
1626
- // #1179: `.filter()` here operates on `rawDiags.diags`; the
1627
- // `inconclusive`/`binding` flags read above stay on the `rawDiags`
1628
- // wrapper and are unaffected by this copy (the shape-5 fix).
1629
- const diags = convertLspDiagnostics(rawDiags.diags
1630
- .filter((d) => d.severity === 1)
1631
- .slice(0, MAX_PER_FILE), neighborPath);
1710
+ // #1179: the severity filter inside `cascadeDisplay` operates on
1711
+ // `rawDiags.diags`; the `inconclusive`/`binding` flags read above
1712
+ // stay on the `rawDiags` wrapper and are unaffected (the shape-5 fix).
1713
+ // #3157 site 2 of 4: the fresh in-lane touch. `content` is the copy
1714
+ // this touch was computed against — no second read.
1715
+ const diags = cascadeDisplay(rawDiags.diags, neighborPath, content);
1632
1716
  const durationMs = Date.now() - neighborStart;
1633
1717
  // #1899: the A5 same-write cache write stood here.
1634
1718
  if (diags.length === 0) {
@@ -1755,10 +1839,10 @@ export async function computeCascadeForFile(filePath, cwd, options = {}) {
1755
1839
  // #692: `source: "cascade"` dropped (see the doc comment above the
1756
1840
  // first cascade call site in this file) — no longer affects `rule`
1757
1841
  // and cascade output never touches persisted widget/dedup state.
1842
+ // #3157 site 3 of 4: the touch-error fallback. The touch failed, so
1843
+ // no content was read for this neighbour — the helper reads it.
1758
1844
  const diags = ttlFresh && !bindingRejected
1759
- ? convertLspDiagnostics(entry.diags
1760
- .filter((d) => d.severity === 1)
1761
- .slice(0, MAX_PER_FILE), neighborPath)
1845
+ ? cascadeDisplay(entry.diags, neighborPath)
1762
1846
  : [];
1763
1847
  neighbors.push({
1764
1848
  filePath: neighborPath,
@@ -1774,12 +1858,12 @@ export async function computeCascadeForFile(filePath, cwd, options = {}) {
1774
1858
  // candidates that had no configured LSP may use passive fallback; broadening
1775
1859
  // to unrelated cache entries would make the partial active result misleading.
1776
1860
  if (producedLspData && noLspCandidatePaths.size > 0) {
1777
- const bindingRejected = appendFallbackNeighbors(neighbors, allDiags, normalizedFileKey, cwd, filePath, noLspCandidatePaths);
1861
+ const bindingRejected = appendFallbackNeighbors(neighbors, allDiags, normalizedFileKey, cwd, filePath, cascadeDisplay, noLspCandidatePaths);
1778
1862
  if (bindingRejected)
1779
1863
  fallbackBindingRejected = true;
1780
1864
  }
1781
1865
  else if (!producedLspData) {
1782
- const bindingRejected = appendFallbackNeighbors(neighbors, allDiags, normalizedFileKey, cwd, filePath);
1866
+ const bindingRejected = appendFallbackNeighbors(neighbors, allDiags, normalizedFileKey, cwd, filePath, cascadeDisplay);
1783
1867
  if (bindingRejected)
1784
1868
  fallbackBindingRejected = true;
1785
1869
  if (neighbors.some((n) => n.reason === "fallback")) {
@@ -1791,8 +1875,23 @@ export async function computeCascadeForFile(filePath, cwd, options = {}) {
1791
1875
  });
1792
1876
  }
1793
1877
  }
1878
+ // #3157: ONE row per cascade run, covering every neighbour the four display
1879
+ // sites filtered — never one per neighbour. `durationMs` is the stack's own
1880
+ // accumulated time (the content reads the fold added included), NOT the
1881
+ // neighbour walk it runs inside, so this row stays comparable with the
1882
+ // quiet-window lane's row under the same phase literal (round 2, F2).
1883
+ // Silent when nothing was dropped or truncated; gated inside
1884
+ // `recordCascadeFindingPolicy`.
1885
+ recordCascadeFindingPolicy({
1886
+ filePath,
1887
+ durationMs: policyElapsedMs,
1888
+ suppressed: policyCounts.suppressed,
1889
+ total: policyCounts.total,
1890
+ auxSuppressed: policyCounts.auxSuppressed,
1891
+ inputTruncated: policyCounts.inputTruncated,
1892
+ });
1794
1893
  const visibleNeighbors = applyCascadeDeltaBaselines(neighbors);
1795
- const formatted = formatCascadeResult(cwd, impact, visibleNeighbors, impact.neighborFiles.length);
1894
+ const formatted = formatCascadeResult(cwd, impact, visibleNeighbors, impact.neighborFiles.length, policyCounts.suppressed, policyCounts.inputTruncated);
1796
1895
  // #1104 HONESTY: filtering a bound-false display candidate must not turn a
1797
1896
  // degraded/indeterminate cascade into a clean-looking one (same doctrine as
1798
1897
  // #1023's graph-degraded marker). If every candidate the degraded-fallback
@@ -1977,7 +2076,10 @@ function applyCascadeDeltaBaselines(neighbors) {
1977
2076
  * because its content binding was rejected (#1104) — the caller folds this
1978
2077
  * into the run-level `fallbackBindingRejected` flag for the honesty check.
1979
2078
  */
1980
- function appendFallbackNeighbors(neighbors, allDiags, normalizedFileKey, cwd, filePath, allowedPaths) {
2079
+ function appendFallbackNeighbors(neighbors, allDiags, normalizedFileKey, cwd, filePath,
2080
+ /** The run's shared display-policy seam (#3157) — see `cascadeDisplay` in
2081
+ * `computeCascadeForFile`. */
2082
+ cascadeDisplay, allowedPaths) {
1981
2083
  const now = Date.now();
1982
2084
  const seen = new Set(neighbors.map((n) => normalizeMapKey(n.filePath)));
1983
2085
  let bindingRejected = false;
@@ -2022,8 +2124,10 @@ function appendFallbackNeighbors(neighbors, allDiags, normalizedFileKey, cwd, fi
2022
2124
  continue;
2023
2125
  }
2024
2126
  // #692: `source: "cascade"` dropped — see the doc comment above the
2025
- // first cascade `convertLspDiagnostics` call site in this file.
2026
- const errors = convertLspDiagnostics(diags.filter((d) => d.severity === 1).slice(0, MAX_PER_FILE), diagPath);
2127
+ // first cascade display call site in this file.
2128
+ // #3157 site 4 of 4: the degraded fallback. `cascadeDisplay` is passed in
2129
+ // rather than re-derived so this run's aggregate drop counters see it too.
2130
+ const errors = cascadeDisplay(diags, diagPath);
2027
2131
  if (errors.length === 0)
2028
2132
  continue;
2029
2133
  neighbors.push({
@@ -2048,17 +2152,36 @@ function neighborReason(importerSet, callerSet, neighborPath) {
2048
2152
  return "calls";
2049
2153
  return "references";
2050
2154
  }
2051
- function formatCascadeResult(cwd, impact, neighbors, totalNeighbors) {
2155
+ function formatCascadeResult(cwd, impact, neighbors, totalNeighbors,
2156
+ /** This run's aggregate policy-stack drops (#3157). */
2157
+ policySuppressed,
2158
+ /**
2159
+ * This run's aggregate `MAX_POLICY_INPUT_PER_FILE` truncation (#3157 round
2160
+ * 3, N1) — ERRORs the policy never LOOKED at.
2161
+ */
2162
+ policyInputTruncated) {
2052
2163
  const diagnosticsBlock = formatCascadeNeighborDiagnostics(cwd, neighbors, {
2053
2164
  noun: "neighbor",
2054
2165
  includeReason: true,
2055
2166
  });
2056
- if (!diagnosticsBlock)
2167
+ // #3157 round 3, N1: an empty block is silence, and silence is a CLEAN
2168
+ // verdict to the agent. That is honest only when everything was looked at.
2169
+ // `policyInputTruncated > 0` means this run's own cost bound declined to
2170
+ // evaluate findings that exist — the #1459 coverage-gap case, not a verdict —
2171
+ // so it must speak even with nothing to render. `policySuppressed` does NOT
2172
+ // get the same treatment on purpose: those findings WERE evaluated and every
2173
+ // drop is the agent's own mark or its project's own rule policy, and the
2174
+ // quiet-window lane deliberately says nothing at all for that case
2175
+ // (`buildResolvedFoundCascadeRun` returns `undefined`). Speaking here and not
2176
+ // there would re-open the two-lane divergence #3157 exists to close.
2177
+ if (!diagnosticsBlock && policyInputTruncated === 0)
2057
2178
  return "";
2058
2179
  const impactHeader = formatImpactCascade(impact, RUNTIME_CONFIG.pipeline.cascadeMaxFiles);
2059
- let out = impactHeader
2060
- ? `${impactHeader}\n${diagnosticsBlock}`
2061
- : diagnosticsBlock;
2180
+ let out = !diagnosticsBlock
2181
+ ? ""
2182
+ : impactHeader
2183
+ ? `${impactHeader}\n${diagnosticsBlock}`
2184
+ : diagnosticsBlock;
2062
2185
  // A10: include truncated filenames so agent knows which files were cut
2063
2186
  const truncated = totalNeighbors - neighbors.length;
2064
2187
  if (truncated > 0) {
@@ -2071,6 +2194,33 @@ function formatCascadeResult(cwd, impact, neighbors, totalNeighbors) {
2071
2194
  : `${truncated} more dependent file(s)`;
2072
2195
  out += `\n... and ${moreLabel}`;
2073
2196
  }
2197
+ // #1616 / #3157: a delivery that still has something to say states what it
2198
+ // dropped, once per delivery — the same sentence the quiet-window cascade run
2199
+ // (`buildResolvedFoundCascadeRun`) and the late-auxiliary advisory render, so
2200
+ // the two cascade lanes do not disagree about whether a mark is worth
2201
+ // mentioning. Policy drops only: an aux drop is the file's own suppression
2202
+ // comment, which the per-edit dispatch path honours silently too, and it stays
2203
+ // in the `cascade_finding_policy` record. A run with NOTHING left returned ""
2204
+ // above and says nothing at all — silence is not a claim that a neighbour is
2205
+ // clean.
2206
+ // #3157 round 3, N1: the same shape and the same closing clause as the
2207
+ // "did not cover" line this file's renderer already uses for a neighbour no
2208
+ // scanner looked at (`clients/cascade-format.ts`) — the agent must not read
2209
+ // an empty or short block as a clean verdict when the bound cut findings
2210
+ // nobody evaluated.
2211
+ if (policyInputTruncated > 0) {
2212
+ if (out)
2213
+ out += "\n";
2214
+ out +=
2215
+ `⚠️ Cascade did not evaluate ${policyInputTruncated} finding(s) — the per-neighbor ` +
2216
+ `policy input is bounded at ${MAX_POLICY_INPUT_PER_FILE}; no findings does NOT mean clean here.`;
2217
+ }
2218
+ // Rides along whenever anything is rendered at all: when the block is empty
2219
+ // and the coverage line above is speaking, this is what explains WHY it is
2220
+ // empty. The empty-and-nothing-truncated case returned above.
2221
+ if (policySuppressed > 0) {
2222
+ out += `\nsuppressed by disposition: ${policySuppressed} finding(s) (marked false-positive or won't-fix).`;
2223
+ }
2074
2224
  return out;
2075
2225
  }
2076
2226
  /**
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Per-rule path carve-outs (`ignores:` in a rule's own YAML, #965).
3
+ *
4
+ * ONE matcher, shared by every surface that decides whether a rule may fire on
5
+ * a path: the NAPI ast-grep runner, the tree-sitter runner (`ignore_paths`),
6
+ * and — since #3041 — the LSP output seam (`applyAuxiliarySuppressions`).
7
+ *
8
+ * #3041, measured against ast-grep 0.45.3: `ast-grep scan` applies a rule's
9
+ * `ignores` globs during its own project walk, but `ast-grep lsp` publishes
10
+ * per-document diagnostics WITHOUT applying them — the same rule on the same
11
+ * file is filtered by the CLI and unfiltered over LSP. So every pi-lens path
12
+ * that delivers ast-grep's LSP diagnostics has to apply the carve-out itself;
13
+ * the runner-side matcher below is what it applies.
14
+ */
15
+ import * as path from "node:path";
16
+ import { minimatch } from "../deps/minimatch.js";
17
+ import { buildEffectiveAstGrepCatalog } from "./ast-grep-catalog.js";
18
+ /**
19
+ * True when `filePath` is carved out of a rule by one of its glob `patterns`.
20
+ *
21
+ * The glob is matched against `filePath` relative to `root`, forward-slashed.
22
+ * Falls back to the absolute (slash-normalized) path when `filePath` isn't
23
+ * under `root` (e.g. an out-of-tree temp file), so a glob like `scripts/**`
24
+ * simply never matches rather than throwing.
25
+ */
26
+ export function isRuleIgnoredForPath(filePath, root, patterns) {
27
+ if (!patterns || patterns.length === 0)
28
+ return false;
29
+ const relative = path.relative(root, filePath);
30
+ const displayPath = (relative.startsWith("..") ? filePath : relative)
31
+ .split(path.sep)
32
+ .join("/");
33
+ return patterns.some((pattern) => minimatch(displayPath, pattern, { dot: true }));
34
+ }
35
+ /**
36
+ * Rule id → its `ignores` globs, for the effective ast-grep catalog at `root`.
37
+ *
38
+ * #3053: the catalog walk and its first-source-wins precedence used to be
39
+ * reimplemented here; both this function and the NAPI runner now read
40
+ * `buildEffectiveAstGrepCatalog` (clients/dispatch/ast-grep-catalog.ts), so
41
+ * the LSP seam carves out exactly the paths the runner carves out by
42
+ * construction rather than by two hand-aligned copies — see that module's
43
+ * docstring for the precedence rule and the within-source-duplicate decision
44
+ * this function inherits from it. The catalog's own loaders are cached, so
45
+ * this is ~0.09 ms per call on pi-lens's own catalog (17 rules with
46
+ * `ignores`, measured) — cheap enough to call per file rather than thread a
47
+ * preloaded map through every sweep.
48
+ *
49
+ * Keyed by the EXACT rule id, matching the runner: a `-js` twin (e.g.
50
+ * `no-console-except-error-js`) carries its own `ignores` in its own document,
51
+ * so normalizing the suffix here would apply one document's carve-out to the
52
+ * other's findings.
53
+ */
54
+ export function loadRuleIgnorePatterns(root) {
55
+ const patterns = new Map();
56
+ for (const [id, { rule }] of buildEffectiveAstGrepCatalog(root)
57
+ .effectiveRules) {
58
+ if (rule.ignores?.length)
59
+ patterns.set(id, rule.ignores);
60
+ }
61
+ return patterns;
62
+ }
@@ -1,5 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { safeSpawnAsync } from "../../safe-spawn.js";
3
+ import { resolveRunnerCwd } from "../../tool-cwd.js";
3
4
  import { PRIORITY } from "../priorities.js";
4
5
  import { createAvailabilityChecker, resolveToolCommandWithInstallFallback, } from "./utils/runner-helpers.js";
5
6
  const actionlint = createAvailabilityChecker("actionlint", ".exe");
@@ -66,7 +67,7 @@ const actionlintRunner = {
66
67
  return isGitHubWorkflowFile(ctx.filePath);
67
68
  },
68
69
  async run(ctx) {
69
- const cwd = ctx.cwd || process.cwd();
70
+ const cwd = resolveRunnerCwd(ctx, "actionlint");
70
71
  let cmd = null;
71
72
  if (await actionlint.isAvailableAsync(cwd)) {
72
73
  cmd = actionlint.getCommand(cwd);
@@ -12,16 +12,16 @@ import * as fs from "node:fs";
12
12
  import * as path from "node:path";
13
13
  import { incrementDegradationCount, recordDegradationOnce, } from "../../degradation-ledger.js";
14
14
  import { loadAstGrepNapi, } from "../../deps/ast-grep-napi.js";
15
- import { minimatch } from "../../deps/minimatch.js";
15
+ import { buildEffectiveAstGrepCatalog } from "../ast-grep-catalog.js";
16
+ import { isRuleIgnoredForPath } from "../rule-ignores.js";
16
17
  import { logLatency } from "../../latency-logger.js";
17
18
  import { hasAuxiliaryLspPublishedForRoot } from "../../lsp/index.js";
18
19
  import { clearPendingAuxiliaryCoverage, hasPendingAuxiliaryCoverage, recordNapiFallbackCoverage, } from "../../lsp/pending-aux-coverage.js";
19
- import { getAstGrepRuleSources, } from "../../sgconfig.js";
20
20
  import { hasEslintConfig } from "../../tool-policy.js";
21
21
  import { enabledAuxiliaryLspServerIds } from "../auxiliary-lsp.js";
22
22
  import { classifyDefect } from "../diagnostic-taxonomy.js";
23
23
  import { PRIORITY } from "../priorities.js";
24
- import { calculateRuleComplexity, isOverlyBroadPattern, isStructuredRule, loadYamlRules, loadYamlRulesFresh, MAX_BLOCKING_RULE_COMPLEXITY, } from "./yaml-rule-parser.js";
24
+ import { calculateRuleComplexity, isOverlyBroadPattern, isStructuredRule, MAX_BLOCKING_RULE_COMPLEXITY, } from "./yaml-rule-parser.js";
25
25
  const defaultUnsupportedLanguageLog = new Set();
26
26
  const UNSUPPORTED_RULE_ID_SAMPLE_SIZE = 5;
27
27
  /** Clear per-session unsupported-language telemetry dedupe. */
@@ -366,22 +366,6 @@ function explicitRuleFixSuggestion(rule) {
366
366
  function normalizeRuleId(ruleId) {
367
367
  return ruleId.replace(/-js$/, "");
368
368
  }
369
- /**
370
- * `filePath` relative to `root`, forward-slashed, for matching a rule's
371
- * `ignores` globs (#965). Falls back to the absolute (slash-normalized) path
372
- * when `filePath` isn't under `root` (e.g. an out-of-tree temp file), so a
373
- * glob like `scripts/**` simply never matches rather than throwing.
374
- */
375
- function relativeForIgnoreGlob(filePath, root) {
376
- const rel = path.relative(root, filePath);
377
- return (rel.startsWith("..") ? filePath : rel).split(path.sep).join("/");
378
- }
379
- function matchesRuleIgnores(filePath, root, patterns) {
380
- if (!patterns || patterns.length === 0)
381
- return false;
382
- const rel = relativeForIgnoreGlob(filePath, root);
383
- return patterns.some((pattern) => minimatch(rel, pattern, { dot: true }));
384
- }
385
369
  export function canHandle(filePath) {
386
370
  return BINDING_BY_EXTENSION.has(path.extname(filePath).toLowerCase());
387
371
  }
@@ -660,7 +644,7 @@ function duplicateRuleIds(rules) {
660
644
  .map(([id]) => id)
661
645
  .sort((a, b) => a.localeCompare(b));
662
646
  }
663
- function appendDuplicateRuleDiagnostics(diagnostics, seenRuleIds, duplicateIds, source, filePath, maxTotalDiagnostics) {
647
+ function appendDuplicateRuleDiagnostics(diagnostics, duplicateIds, source, filePath, maxTotalDiagnostics) {
664
648
  const sourceLabel = `${source.origin} ${source.tier} rules`;
665
649
  for (const ruleId of duplicateIds) {
666
650
  diagnostics.push({
@@ -678,7 +662,6 @@ function appendDuplicateRuleDiagnostics(diagnostics, seenRuleIds, duplicateIds,
678
662
  autoFixAvailable: false,
679
663
  fixSuggestion: `Give every rule in ${sourceLabel} a unique id`,
680
664
  });
681
- seenRuleIds.add(ruleId);
682
665
  if (diagnostics.length >= maxTotalDiagnostics)
683
666
  return true;
684
667
  }
@@ -723,7 +706,6 @@ export function evaluateAstGrepRules(filePath, rootNode, cwd, kind, options = {}
723
706
  const log = options.log;
724
707
  const unsupportedLanguageLog = options.unsupportedLanguageLog ?? defaultUnsupportedLanguageLog;
725
708
  const diagnostics = [];
726
- const seenRuleIds = new Set();
727
709
  const suppressLinterOverlap = kind === "jsts" && hasEslintConfig(cwd);
728
710
  const fileLang = ruleLanguageForFile(filePath);
729
711
  // Embedded `<script>` coverage (#2347): on an HTML file, every script body
@@ -811,23 +793,14 @@ export function evaluateAstGrepRules(filePath, rootNode, cwd, kind, options = {}
811
793
  });
812
794
  newlyUnsupported.clear();
813
795
  };
814
- // Shared with the raw sgconfig materializer so both surfaces walk the same
815
- // workspace-rooted sources in the same precedence order.
796
+ // Same walk and precedence the LSP-seam matcher uses
797
+ // (clients/dispatch/rule-ignores.ts), folded onto one derivation by #3053
798
+ // after #3046 shipped them as two independently-drifting copies.
816
799
  const ignoreRoot = options.projectRoot ?? cwd;
817
- const ruleSources = getAstGrepRuleSources(ignoreRoot);
818
- for (const source of ruleSources) {
819
- let rules;
820
- try {
821
- // Project rules are mutable during a session, so their cache fingerprints
822
- // relative paths and contents. Bundled catalogs are immutable per install.
823
- const loader = source.origin === "project" ? loadYamlRulesFresh : loadYamlRules;
824
- rules = loader(source.dir);
825
- }
826
- catch {
827
- continue;
828
- }
800
+ const catalog = buildEffectiveAstGrepCatalog(ignoreRoot);
801
+ for (const { source, rules } of catalog.sources) {
829
802
  const duplicates = duplicateRuleIds(rules);
830
- if (appendDuplicateRuleDiagnostics(diagnostics, seenRuleIds, duplicates, source, filePath, maxTotalDiagnostics)) {
803
+ if (appendDuplicateRuleDiagnostics(diagnostics, duplicates, source, filePath, maxTotalDiagnostics)) {
831
804
  flushUnsupportedRuleSkips();
832
805
  return diagnostics;
833
806
  }
@@ -835,16 +808,23 @@ export function evaluateAstGrepRules(filePath, rootNode, cwd, kind, options = {}
835
808
  for (const rule of rules) {
836
809
  if (duplicateSet.has(rule.id))
837
810
  continue;
838
- // Cross-layer collisions keep the first (higher-precedence) source.
839
- if (seenRuleIds.has(rule.id))
811
+ // Cross-layer collisions keep the first (higher-precedence) source —
812
+ // resolved once by `buildEffectiveAstGrepCatalog`, not per-rule here.
813
+ // Compares the winning DOCUMENT (object identity), not merely its
814
+ // source: comparing `.source` alone can't tell a within-source
815
+ // duplicate's two copies apart (they share one source), so it gave
816
+ // no defense if `duplicateSet.has` above were ever the only guard
817
+ // standing (#3053 round 2 F1). Every `rule` iterated here is the
818
+ // same object instance the catalog recorded, since both read the
819
+ // identical `rules` arrays off `catalog.sources`.
820
+ if (catalog.effectiveRules.get(rule.id)?.rule !== rule)
840
821
  continue;
841
- seenRuleIds.add(rule.id);
842
822
  if (blockingOnly && rule.severity !== "error")
843
823
  continue;
844
824
  // Per-rule path carve-out (#965): a rule that's noise on CLI scripts or
845
825
  // a project's own logging sink (e.g. no-console-except-error firing
846
826
  // inside scripts/** or lib/logger.ts) opts out via `ignores`.
847
- if (matchesRuleIgnores(filePath, ignoreRoot, rule.ignores))
827
+ if (isRuleIgnoredForPath(filePath, ignoreRoot, rule.ignores))
848
828
  continue;
849
829
  if (suppressLinterOverlap &&
850
830
  LINTER_OVERLAP.has(normalizeRuleId(rule.id)) &&
@@ -5,13 +5,12 @@
5
5
  * Autofix is handled earlier by the post-write pipeline to avoid
6
6
  * mutating files mid-dispatch after LSP sync has already happened.
7
7
  */
8
- import * as path from "node:path";
9
8
  import { incrementDegradationCount } from "../../degradation-ledger.js";
10
9
  import { mapWithConcurrency } from "../../dependency-checker.js";
11
10
  import { safeSpawnAsync } from "../../safe-spawn.js";
12
11
  import { biomeConfigArgs, getAutofixCapability, getJstsLintPolicyForCwd, } from "../../tool-policy.js";
13
12
  import { PRIORITY } from "../priorities.js";
14
- import { resolveToolCommandWithInstallFallback } from "./utils/runner-helpers.js";
13
+ import { resolveRunnerCwd, resolveToolCommandWithInstallFallback, } from "./utils/runner-helpers.js";
15
14
  import { finishParsedRun } from "./utils/tool-failure.js";
16
15
  /**
17
16
  * Map biome's own severity vocabulary onto the four-tier `Diagnostic.severity`
@@ -198,12 +197,17 @@ const biomeCheckJsonRunner = {
198
197
  appliesTo: ["jsts"],
199
198
  priority: PRIORITY.FORMAT_AND_LINT_PRIMARY,
200
199
  async run(ctx) {
201
- const cwd = ctx.cwd || path.dirname(ctx.filePath);
200
+ const cwd = resolveRunnerCwd(ctx, "biome");
202
201
  const policy = getJstsLintPolicyForCwd(cwd);
203
202
  // Defer to ESLint/oxlint if the project has explicitly configured one —
204
203
  // biome runs as the default linter only when no alternative is present.
205
204
  if (!policy.hasBiomeConfig && policy.hasExplicitNonBiomeLinter) {
206
- return { status: "skipped", diagnostics: [], semantic: "none" };
205
+ return {
206
+ status: "skipped",
207
+ diagnostics: [],
208
+ semantic: "none",
209
+ skipReason: "configured-non-biome-linter",
210
+ };
207
211
  }
208
212
  const cmd = await resolveToolCommandWithInstallFallback(cwd, "biome");
209
213
  if (!cmd) {
@@ -1,6 +1,8 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { safeSpawnAsync } from "../../safe-spawn.js";
4
+ import { probeToolAsync } from "../../tool-probe.js";
5
+ import { resolveRunnerCwd } from "../../tool-cwd.js";
4
6
  import { PRIORITY } from "../priorities.js";
5
7
  import { createAvailabilityChecker } from "./utils/runner-helpers.js";
6
8
  // Per-compiler availability checkers — each one caches per-cwd and
@@ -100,8 +102,7 @@ async function resolveCompiler(absPath, cwd) {
100
102
  // Mirrors the deliberate global-PATH probes in utils/runner-helpers.ts:
101
103
  // this never resolves a config file or a target path, so there is no cwd
102
104
  // for it to get wrong.
103
- // cwd-exempt: presence probe only -- `cl` with no args and no target file, used solely to detect whether MSVC is on PATH
104
- const clProbe = await safeSpawnAsync("cl", [], { timeout: 5000 });
105
+ const clProbe = await probeToolAsync("cl", [], { timeout: 5000 });
105
106
  if (!clProbe.error && clProbe.status !== null) {
106
107
  return {
107
108
  command: "cl",
@@ -181,7 +182,7 @@ const cppCheckRunner = {
181
182
  priority: PRIORITY.GENERAL_ANALYSIS,
182
183
  skipTestFiles: false,
183
184
  async run(ctx) {
184
- const cwd = ctx.cwd || process.cwd();
185
+ const cwd = resolveRunnerCwd(ctx, "cpp-check");
185
186
  const absPath = path.resolve(cwd, ctx.filePath);
186
187
  const compiler = await resolveCompiler(absPath, cwd);
187
188
  if (!compiler) {
@@ -1,6 +1,7 @@
1
1
  import * as path from "node:path";
2
2
  import * as fs from "node:fs";
3
3
  import { safeSpawnAsync } from "../../safe-spawn.js";
4
+ import { resolveRunnerCwd } from "../../tool-cwd.js";
4
5
  import { PRIORITY } from "../priorities.js";
5
6
  import { createCwdCachedProbe } from "./utils/runner-helpers.js";
6
7
  const CREDO_PROBE_BUDGET_MS = 10_000;
@@ -50,7 +51,7 @@ const credoRunner = {
50
51
  priority: PRIORITY.GENERAL_ANALYSIS,
51
52
  skipTestFiles: false,
52
53
  async run(ctx) {
53
- const cwd = ctx.cwd || process.cwd();
54
+ const cwd = resolveRunnerCwd(ctx, "credo");
54
55
  if (!hasMixExs(cwd)) {
55
56
  return { status: "skipped", diagnostics: [], semantic: "none" };
56
57
  }
@@ -104,6 +104,7 @@
104
104
  import * as fs from "node:fs";
105
105
  import * as path from "node:path";
106
106
  import { safeSpawnAsync } from "../../safe-spawn.js";
107
+ import { resolveRunnerCwd } from "../../tool-cwd.js";
107
108
  import { createAvailabilityChecker, resolveAvailableOrInstall, } from "./utils/runner-helpers.js";
108
109
  import { spawnFailedWithNoOutput } from "./utils/spawn-outcome.js";
109
110
  import { PRIORITY } from "../priorities.js";
@@ -281,7 +282,7 @@ const cueVetRunner = {
281
282
  skipTestFiles: false,
282
283
  timeoutMs: 30_000,
283
284
  async run(ctx) {
284
- const cwd = ctx.cwd || process.cwd();
285
+ const cwd = resolveRunnerCwd(ctx, "cue-vet");
285
286
  let cmd = null;
286
287
  if (await cue.isAvailableAsync(cwd)) {
287
288
  cmd = cue.getCommand(cwd);
@@ -1,5 +1,6 @@
1
1
  import * as path from "node:path";
2
2
  import { safeSpawnAsync } from "../../safe-spawn.js";
3
+ import { resolveRunnerCwd } from "../../tool-cwd.js";
3
4
  import { createAvailabilityChecker } from "./utils/runner-helpers.js";
4
5
  import { PRIORITY } from "../priorities.js";
5
6
  const dart = createAvailabilityChecker("dart", ".exe");
@@ -147,7 +148,7 @@ const dartAnalyzeRunner = {
147
148
  priority: PRIORITY.GENERAL_ANALYSIS,
148
149
  skipTestFiles: false,
149
150
  async run(ctx) {
150
- const cwd = ctx.cwd || process.cwd();
151
+ const cwd = resolveRunnerCwd(ctx, "dart-analyze");
151
152
  const absPath = path.resolve(cwd, ctx.filePath);
152
153
  const dartAvailable = await dart.isAvailableAsync(cwd);
153
154
  const flutterAvailable = !dartAvailable && (await flutter.isAvailableAsync(cwd));
@@ -1,6 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { safeSpawnAsync } from "../../safe-spawn.js";
4
+ import { resolveRunnerCwd } from "../../tool-cwd.js";
4
5
  import { getLinterPolicyForCwd } from "../../tool-policy.js";
5
6
  import { PRIORITY } from "../priorities.js";
6
7
  import { createAvailabilityChecker } from "./utils/runner-helpers.js";
@@ -129,7 +130,7 @@ const detektRunner = {
129
130
  timeoutMs: 90_000,
130
131
  skipTestFiles: false,
131
132
  async run(ctx) {
132
- const cwd = ctx.cwd || process.cwd();
133
+ const cwd = resolveRunnerCwd(ctx, "detekt");
133
134
  const policy = getLinterPolicyForCwd(ctx.filePath, cwd);
134
135
  if (policy && !policy.preferredRunners.includes("detekt")) {
135
136
  return { status: "skipped", diagnostics: [], semantic: "none" };