pi-lens 4.1.0 → 4.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (206) hide show
  1. package/CHANGELOG.md +185 -0
  2. package/README.md +106 -0
  3. package/dist/clients/actionable-warnings-logger.js +2 -1
  4. package/dist/clients/actionable-warnings.js +14 -5
  5. package/dist/clients/advisory-provenance.js +48 -18
  6. package/dist/clients/ast-grep-client.js +1 -3
  7. package/dist/clients/atomic-write-staging.js +16 -4
  8. package/dist/clients/bash-file-access.js +99 -6
  9. package/dist/clients/biome-client.js +1 -3
  10. package/dist/clients/blocker-freshness.js +21 -8
  11. package/dist/clients/blocker-past-eof.js +16 -6
  12. package/dist/clients/bounded-cache.js +24 -8
  13. package/dist/clients/bounded-pid-file-lock.js +4 -3
  14. package/dist/clients/bounded-telemetry.js +14 -0
  15. package/dist/clients/cache-manager.js +6 -7
  16. package/dist/clients/cache-observability.js +782 -110
  17. package/dist/clients/call-graph.js +238 -63
  18. package/dist/clients/codebase-model.js +13 -5
  19. package/dist/clients/complexity-client.js +1 -3
  20. package/dist/clients/dead-code-client.js +19 -8
  21. package/dist/clients/dead-code-logger.js +1 -2
  22. package/dist/clients/deadline-utils.js +3 -1
  23. package/dist/clients/debug-heap.js +8 -1
  24. package/dist/clients/degradation-ledger.js +22 -1
  25. package/dist/clients/demoted-finding-render.js +155 -0
  26. package/dist/clients/dependency-checker.js +11 -7
  27. package/dist/clients/deps/pi-tui.js +8 -2
  28. package/dist/clients/deps/typebox.js +8 -3
  29. package/dist/clients/diagnostic-dispositions.js +39 -2
  30. package/dist/clients/diagnostic-line-freshness.js +5 -1
  31. package/dist/clients/dispatch/auxiliary-lsp.js +2 -3
  32. package/dist/clients/dispatch/dispatcher.js +13 -1
  33. package/dist/clients/dispatch/facts/function-facts.js +3 -1
  34. package/dist/clients/dispatch/facts/import-facts.js +2 -1
  35. package/dist/clients/dispatch/facts/tree-sitter-facts.js +1 -1
  36. package/dist/clients/dispatch/integration.js +9 -4
  37. package/dist/clients/dispatch/rules/error-swallowing.js +3 -1
  38. package/dist/clients/dispatch/rules/pass-through-wrappers.js +6 -2
  39. package/dist/clients/dispatch/rules/unsafe-boundary.js +31 -7
  40. package/dist/clients/dispatch/runners/actionlint.js +5 -2
  41. package/dist/clients/dispatch/runners/biome-check.js +10 -14
  42. package/dist/clients/dispatch/runners/cpp-check.js +5 -1
  43. package/dist/clients/dispatch/runners/detekt.js +9 -6
  44. package/dist/clients/dispatch/runners/dotnet-build.js +1 -1
  45. package/dist/clients/dispatch/runners/elixir-check.js +3 -1
  46. package/dist/clients/dispatch/runners/eslint.js +25 -6
  47. package/dist/clients/dispatch/runners/fish-indent.js +1 -1
  48. package/dist/clients/dispatch/runners/gleam-check.js +1 -1
  49. package/dist/clients/dispatch/runners/golangci-lint.js +3 -1
  50. package/dist/clients/dispatch/runners/hadolint.js +22 -15
  51. package/dist/clients/dispatch/runners/helm-lint.js +4 -1
  52. package/dist/clients/dispatch/runners/htmlhint.js +33 -16
  53. package/dist/clients/dispatch/runners/javac.js +1 -1
  54. package/dist/clients/dispatch/runners/ktlint.js +1 -1
  55. package/dist/clients/dispatch/runners/lsp.js +1 -1
  56. package/dist/clients/dispatch/runners/markdownlint.js +34 -12
  57. package/dist/clients/dispatch/runners/mypy.js +15 -17
  58. package/dist/clients/dispatch/runners/oxlint.js +231 -15
  59. package/dist/clients/dispatch/runners/php-lint.js +1 -1
  60. package/dist/clients/dispatch/runners/phpstan.js +52 -15
  61. package/dist/clients/dispatch/runners/psscriptanalyzer.js +10 -6
  62. package/dist/clients/dispatch/runners/pyright.js +23 -14
  63. package/dist/clients/dispatch/runners/rust-clippy.js +1 -1
  64. package/dist/clients/dispatch/runners/shellcheck.js +7 -9
  65. package/dist/clients/dispatch/runners/shfmt.js +5 -2
  66. package/dist/clients/dispatch/runners/spellcheck.js +10 -15
  67. package/dist/clients/dispatch/runners/sqlfluff.js +25 -17
  68. package/dist/clients/dispatch/runners/stylelint.js +40 -20
  69. package/dist/clients/dispatch/runners/swiftlint.js +8 -8
  70. package/dist/clients/dispatch/runners/taplo.js +110 -23
  71. package/dist/clients/dispatch/runners/terragrunt.js +13 -8
  72. package/dist/clients/dispatch/runners/tflint.js +25 -15
  73. package/dist/clients/dispatch/runners/tree-sitter.js +17 -3
  74. package/dist/clients/dispatch/runners/utils/candidate-probe.js +7 -1
  75. package/dist/clients/dispatch/runners/utils/runner-helpers.js +24 -1
  76. package/dist/clients/dispatch/runners/utils/tool-failure.js +152 -1
  77. package/dist/clients/dispatch/runners/vale.js +16 -14
  78. package/dist/clients/dispatch/runners/yaml-rule-parser.js +1 -3
  79. package/dist/clients/dispatch/runners/yamllint.js +13 -16
  80. package/dist/clients/dispatch/runners/zig-check.js +1 -1
  81. package/dist/clients/dispatch/suppress-writer.js +2 -1
  82. package/dist/clients/dispatch/types.js +6 -1
  83. package/dist/clients/dispatch/utils/lsp-diagnostics.js +6 -2
  84. package/dist/clients/extension-log.js +5 -3
  85. package/dist/clients/feature-hints.js +8 -1
  86. package/dist/clients/file-kinds.js +34 -129
  87. package/dist/clients/file-utils.js +17 -1
  88. package/dist/clients/finding-delivery-gate.js +79 -2
  89. package/dist/clients/formatters.js +144 -27
  90. package/dist/clients/freshness.js +38 -0
  91. package/dist/clients/git-guard.js +43 -13
  92. package/dist/clients/go-client.js +1 -3
  93. package/dist/clients/grammar-source.js +1 -1
  94. package/dist/clients/gzip-stage-write.js +30 -4
  95. package/dist/clients/host-edit-normalize.js +1 -3
  96. package/dist/clients/install-diagnostics.js +4 -2
  97. package/dist/clients/installer/index.js +86 -77
  98. package/dist/clients/instance-reaper.js +5 -2
  99. package/dist/clients/instance-registry.js +4 -2
  100. package/dist/clients/jscpd-client.js +11 -3
  101. package/dist/clients/knip-client.js +29 -15
  102. package/dist/clients/language-profile.js +11 -3
  103. package/dist/clients/latency-logger.js +13 -2
  104. package/dist/clients/lens-config.js +1 -1
  105. package/dist/clients/lens-map.js +8 -3
  106. package/dist/clients/live-bus-emitter.js +1 -1
  107. package/dist/clients/lsp/cascade-tier.js +27 -2
  108. package/dist/clients/lsp/client.js +144 -5
  109. package/dist/clients/lsp/config.js +4 -6
  110. package/dist/clients/lsp/edits.js +119 -32
  111. package/dist/clients/lsp/index.js +245 -41
  112. package/dist/clients/lsp/inferred-project.js +2 -1
  113. package/dist/clients/lsp/jvm-runtime.js +4 -1
  114. package/dist/clients/lsp/launch.js +11 -3
  115. package/dist/clients/lsp/pending-aux-coverage.js +152 -0
  116. package/dist/clients/lsp/server.js +19 -7
  117. package/dist/clients/lsp/tsserver-sync.js +10 -2
  118. package/dist/clients/lsp/workspace-diagnostics-cache.js +9 -4
  119. package/dist/clients/lsp-document-symbols.js +1 -3
  120. package/dist/clients/lsp-mutation.js +10 -20
  121. package/dist/clients/mcp/host-shim.js +1 -1
  122. package/dist/clients/memory-sampler.js +73 -7
  123. package/dist/clients/metrics-client.js +1 -3
  124. package/dist/clients/middle-man-analysis.js +8 -3
  125. package/dist/clients/module-report-lsp.js +7 -1
  126. package/dist/clients/module-report.js +28 -18
  127. package/dist/clients/ndjson-logger.js +117 -28
  128. package/dist/clients/opaque-mutation-scan.js +220 -0
  129. package/dist/clients/package-manager.js +6 -1
  130. package/dist/clients/pipeline.js +57 -9
  131. package/dist/clients/project-conventions.js +2 -8
  132. package/dist/clients/project-diagnostics/cache.js +10 -7
  133. package/dist/clients/project-diagnostics/runner-adapters/call-graph-impact.js +2 -1
  134. package/dist/clients/project-diagnostics/runner-adapters/gitleaks.js +3 -1
  135. package/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +13 -9
  136. package/dist/clients/project-diagnostics/runner-adapters/trivy.js +3 -1
  137. package/dist/clients/project-diagnostics/scanner.js +22 -10
  138. package/dist/clients/project-lens-config.js +12 -5
  139. package/dist/clients/project-report.js +62 -27
  140. package/dist/clients/project-snapshot-fingerprint.js +48 -0
  141. package/dist/clients/project-snapshot-persist-worker.js +15 -1
  142. package/dist/clients/project-snapshot.js +472 -66
  143. package/dist/clients/read-expansion.js +12 -2
  144. package/dist/clients/read-guard-logger.js +12 -6
  145. package/dist/clients/read-guard-tool-lines.js +5 -2
  146. package/dist/clients/read-guard.js +88 -19
  147. package/dist/clients/resource-sampler.js +1 -1
  148. package/dist/clients/reverse-deps.js +6 -5
  149. package/dist/clients/review-graph/builder.js +236 -48
  150. package/dist/clients/review-graph/git-identity.js +8 -2
  151. package/dist/clients/review-graph/import-resolvers.js +10 -1
  152. package/dist/clients/review-graph/query.js +2 -1
  153. package/dist/clients/review-graph/revision-drift.js +21 -0
  154. package/dist/clients/review-graph/tsconfig-paths.js +4 -2
  155. package/dist/clients/review-graph/workspace-modules.js +8 -5
  156. package/dist/clients/ruff-client.js +6 -5
  157. package/dist/clients/runtime-agent-end.js +63 -31
  158. package/dist/clients/runtime-context.js +12 -4
  159. package/dist/clients/runtime-coordinator.js +127 -4
  160. package/dist/clients/runtime-session.js +55 -0
  161. package/dist/clients/runtime-tool-call.js +59 -3
  162. package/dist/clients/runtime-tool-result.js +148 -34
  163. package/dist/clients/runtime-turn.js +194 -8
  164. package/dist/clients/rust-client.js +1 -3
  165. package/dist/clients/safe-spawn.js +150 -11
  166. package/dist/clients/security-scan-client.js +1 -3
  167. package/dist/clients/session-event-guard.js +184 -0
  168. package/dist/clients/session-summary.js +3 -1
  169. package/dist/clients/sg-runner.js +1 -3
  170. package/dist/clients/source-filter.js +16 -8
  171. package/dist/clients/source-walker.js +1 -1
  172. package/dist/clients/spawn-timeout-cooldown.js +96 -0
  173. package/dist/clients/startup-scan.js +9 -3
  174. package/dist/clients/tool-definition.js +7 -0
  175. package/dist/clients/tool-policy.js +26 -11
  176. package/dist/clients/tool-render.js +2 -1
  177. package/dist/clients/tree-sitter-client.js +10 -4
  178. package/dist/clients/tree-sitter-logger.js +2 -1
  179. package/dist/clients/tree-sitter-symbol-extractor.js +12 -5
  180. package/dist/clients/warm-attach.js +12 -5
  181. package/dist/clients/widget-state.js +27 -11
  182. package/dist/clients/word-index.js +3 -1
  183. package/dist/clients/workspace-topology.js +8 -2
  184. package/dist/clients/zizmor-config.js +9 -1
  185. package/dist/i18n.js +13 -5
  186. package/dist/index.js +15811 -12810
  187. package/dist/mcp/server.js +3 -1
  188. package/dist/tools/ast-grep-replace.js +68 -12
  189. package/dist/tools/lens-diagnostic-mark.js +21 -7
  190. package/dist/tools/lens-diagnostics.js +174 -38
  191. package/dist/tools/lsp-diagnostics.js +24 -7
  192. package/dist/tools/lsp-navigation.js +3 -1
  193. package/dist/tools/module-report.js +1 -1
  194. package/dist/tools/project-report.js +1 -1
  195. package/dist/tools/symbol-search.js +5 -2
  196. package/docs/durable-store-audit-1202.md +1 -1
  197. package/docs/servercapabilities.md +1 -1
  198. package/package.json +160 -143
  199. package/rules/typos/_typos.toml +2 -2
  200. package/scripts/analyze-pi-lens-logs.mjs +31 -13
  201. package/scripts/download-grammars.js +9 -3
  202. package/scripts/install-selftest.mjs +99 -11
  203. package/scripts/lib/host-provided-deps.mjs +75 -0
  204. package/scripts/lib/warm-loader-cache.mjs +285 -0
  205. package/scripts/rpc-load-check.mjs +30 -9
  206. package/scripts/warm-loader-cache.mjs +233 -0
@@ -13,6 +13,17 @@ import { safeSpawnAsync } from "../../safe-spawn.js";
13
13
  import { getJstsLintPolicyForCwd, hasVitePlusConfig, } from "../../tool-policy.js";
14
14
  import { PRIORITY } from "../priorities.js";
15
15
  import { resolveToolCommand, resolveToolCommandWithInstallFallback, } from "./utils/runner-helpers.js";
16
+ import { finishParsedRun, parseToolRun } from "./utils/tool-failure.js";
17
+ const OXLINT_NO_FILES = Symbol("oxlint-no-files");
18
+ const OXLINT_NO_FILES_UNCONFIRMED = Symbol("oxlint-no-files-unconfirmed");
19
+ const OXLINT_NO_FILES_BANNER = "No files found to lint. Please check your paths and ignore patterns.";
20
+ const OXLINT_NO_FILES_REPORT_KEYS = new Set([
21
+ "diagnostics",
22
+ "number_of_files",
23
+ "number_of_rules",
24
+ "threads_count",
25
+ "start_time",
26
+ ]);
16
27
  function resolveLocalVp(cwd) {
17
28
  const isWin = process.platform === "win32";
18
29
  for (const dir of walkUpDirs(cwd)) {
@@ -76,31 +87,234 @@ const oxlintRunner = {
76
87
  const result = await safeSpawnAsync(cmd, args, {
77
88
  timeout: 30000,
78
89
  });
79
- // Oxlint returns non-zero when issues found
80
- if (result.status === 0) {
81
- return { status: "succeeded", diagnostics: [], semantic: "none" };
82
- }
90
+ // Oxlint exits 0 whenever nothing at ERROR severity was found — that
91
+ // includes a run that found only warnings, its own default severity
92
+ // (#1947). A run that found nothing at all also exits 0, but still
93
+ // prints a report with an empty `diagnostics` array, so parsing
94
+ // unconditionally and branching on the parsed count (below) tells the
95
+ // two apart instead of the exit code discarding the warning case.
96
+ //
83
97
  // Parse JSON output. Fall back to the unix-format parser if JSON parsing
84
98
  // fails (older oxlint versions, malformed stderr noise, etc.) — keeps the
85
99
  // runner producing diagnostics even when the structured-fix metadata is
86
100
  // unavailable.
87
101
  const stdout = result.stdout ?? "";
88
102
  const stderr = result.stderr ?? "";
89
- let diagnostics = parseOxlintJson(stdout, ctx.filePath);
90
- if (diagnostics.length === 0 && stdout.length > 0) {
91
- diagnostics = parseOxlintUnix(stdout + stderr, ctx.filePath);
103
+ const parsedOutput = stdout + stderr;
104
+ const noFilesDecision = decideOxlintNoFiles(result, stdout, stderr);
105
+ // #1994's shared gate classifies invocation and parsed-output failures.
106
+ // The private markers preserve one legitimate nonzero/empty shape and
107
+ // force a status-0 lookalike away from clean. Neither escapes this runner.
108
+ // parseToolRun checks process failures before invoking this parser, so
109
+ // spawn/timeout/killed/signal precedence remains owned by #1994.
110
+ const parsedRun = parseToolRun("oxlint", { result, output: parsedOutput }, () => {
111
+ if (noFilesDecision.kind === "expected-no-files") {
112
+ return [OXLINT_NO_FILES];
113
+ }
114
+ if (noFilesDecision.kind === "unconfirmed-no-files" &&
115
+ result.status === 0) {
116
+ return [OXLINT_NO_FILES_UNCONFIRMED];
117
+ }
118
+ let parsed = parseOxlintJson(stdout, ctx.filePath);
119
+ if (parsed.length === 0 && stdout.length > 0) {
120
+ parsed = parseOxlintUnix(parsedOutput, ctx.filePath);
121
+ }
122
+ return parsed;
123
+ }, { parseOutput: parsedOutput });
124
+ if (parsedRun.skipped)
125
+ return parsedRun.skipped;
126
+ if (parsedRun.diagnostics.includes(OXLINT_NO_FILES)) {
127
+ // Real captured bytes show oxlint exits
128
+ // 1 with "No files found" + number_of_files: 0 when a config excludes
129
+ // the target, so "nonzero exit" alone cannot mean "unreadable report
130
+ // of problems" until the no-files shape is ruled out.
131
+ // Read BEFORE reporting "succeeded" — a config (root's or a nested
132
+ // one nearer the file, per oxlint's own discovery) that ignores this
133
+ // file reports the same empty diagnostics array a clean file does.
134
+ // "succeeded"/"none" would say "we checked, it's clean"; this file
135
+ // was never checked at all.
136
+ return {
137
+ status: "skipped",
138
+ diagnostics: [],
139
+ semantic: "none",
140
+ skipReason: "no-files-matched",
141
+ };
92
142
  }
93
- if (diagnostics.length === 0) {
94
- return { status: "succeeded", diagnostics: [], semantic: "none" };
143
+ if (parsedRun.diagnostics.includes(OXLINT_NO_FILES_UNCONFIRMED)) {
144
+ const reason = noFilesDecision.kind === "unconfirmed-no-files"
145
+ ? noFilesDecision.reason
146
+ : "status-zero";
147
+ return {
148
+ status: "failed",
149
+ diagnostics: [
150
+ {
151
+ id: `oxlint:no-files-unconfirmed:${reason}`,
152
+ message: `oxlint no-files report was not canonical (${reason})`,
153
+ filePath: ctx.filePath,
154
+ line: 1,
155
+ column: 1,
156
+ severity: "warning",
157
+ semantic: "warning",
158
+ tool: "oxlint",
159
+ },
160
+ ],
161
+ semantic: "warning",
162
+ failureKind: "unconfirmed_output",
163
+ failureMessage: `no-files-${reason}`,
164
+ };
95
165
  }
96
- const hasBlocking = diagnostics.some((d) => d.semantic === "blocking");
97
- return {
98
- status: "failed",
166
+ const diagnostics = parsedRun.diagnostics.filter((diagnostic) => diagnostic !== OXLINT_NO_FILES &&
167
+ diagnostic !== OXLINT_NO_FILES_UNCONFIRMED);
168
+ // A warning-only result on exit 0 is oxlint's normal outcome, not a
169
+ // failure: exit 0 means nothing hit ERROR severity. `status: "failed"`
170
+ // here would stop this arm from reporting "succeeded", which breaks two
171
+ // things downstream — plan.ts's ["eslint", "oxlint", "biome-check-json"]
172
+ // fallback group only stops at the first `status: "succeeded"` runner
173
+ // (dispatcher.ts's `runGroup`), so biome-check-json would run again on
174
+ // every warning-only save (extra spawns, a possible install, duplicate
175
+ // findings); and it would mismatch the sibling convention (biome-check,
176
+ // golangci-lint, rubocop) of keying `status` off blocking severity, not
177
+ // off the tool's raw exit code. The findings themselves still reach the
178
+ // delivery pipeline regardless of `status` — dispatcher.ts buckets by
179
+ // each diagnostic's own `semantic`, so a warning stays a warning.
180
+ return finishParsedRun({
181
+ tool: "oxlint",
182
+ ctx,
183
+ result,
99
184
  diagnostics,
100
- semantic: hasBlocking ? "blocking" : "warning",
101
- };
185
+ classify: (diagnostics) => {
186
+ const hasBlocking = diagnostics.some((d) => d.semantic === "blocking");
187
+ return {
188
+ status: !hasBlocking && result.status === 0 ? "succeeded" : "failed",
189
+ semantic: hasBlocking ? "blocking" : "warning",
190
+ };
191
+ },
192
+ });
102
193
  },
103
194
  };
195
+ function oxlintProcessState(result) {
196
+ const failureKind = result.spawnFailure?.kind;
197
+ if (result.signal || result.failure === "signal")
198
+ return "signal";
199
+ if (result.failure === "timeout" || failureKind === "timeout")
200
+ return "timeout";
201
+ if (result.failure === "aborted" || failureKind === "killed")
202
+ return "killed";
203
+ if (result.failure === "spawn" || result.error)
204
+ return "spawn";
205
+ if (result.outputTruncated)
206
+ return "truncated";
207
+ return "normal";
208
+ }
209
+ function isNonnegativeInteger(value) {
210
+ return Number.isInteger(value) && value >= 0;
211
+ }
212
+ function isPositiveInteger(value) {
213
+ return Number.isInteger(value) && value >= 1;
214
+ }
215
+ function isNonnegativeFinite(value) {
216
+ return typeof value === "number" && Number.isFinite(value) && value >= 0;
217
+ }
218
+ /**
219
+ * One decision seam for every oxlint no-files axis (#1998).
220
+ *
221
+ * Real oxlint 1.79.0 establishes the only expected shape: a normal completed
222
+ * process, exit 1, empty stderr, the exact no-files banner, and a JSON object
223
+ * containing exactly the five captured summary fields with canonical types
224
+ * and ranges. Diagnostics must be empty and `number_of_files` must be zero.
225
+ *
226
+ * Any process-control evidence wins before report bytes. Any no-files
227
+ * lookalike that differs on status, stderr, banner, JSON syntax/schema,
228
+ * diagnostics, or file count is unconfirmed rather than clean. A report with
229
+ * no no-files evidence remains an ordinary oxlint report for the normal parser.
230
+ */
231
+ export function decideOxlintNoFiles(result, stdout, stderr) {
232
+ const processState = oxlintProcessState(result);
233
+ if (processState !== "normal") {
234
+ return {
235
+ kind: "unconfirmed-no-files",
236
+ reason: `process-${processState}`,
237
+ };
238
+ }
239
+ const jsonStart = stdout.indexOf("{");
240
+ const bannerText = (jsonStart === -1 ? stdout : stdout.slice(0, jsonStart)).trim();
241
+ const banner = bannerText === OXLINT_NO_FILES_BANNER
242
+ ? "exact"
243
+ : /no files found.+lint/i.test(bannerText)
244
+ ? "near"
245
+ : "missing";
246
+ let report;
247
+ let jsonMalformed = jsonStart === -1;
248
+ if (jsonStart !== -1) {
249
+ try {
250
+ const parsed = JSON.parse(stdout.slice(jsonStart));
251
+ if (typeof parsed === "object" &&
252
+ parsed !== null &&
253
+ !Array.isArray(parsed)) {
254
+ report = parsed;
255
+ }
256
+ else {
257
+ jsonMalformed = true;
258
+ }
259
+ }
260
+ catch {
261
+ jsonMalformed = true;
262
+ }
263
+ }
264
+ const hasFileCount = report !== undefined &&
265
+ Object.prototype.hasOwnProperty.call(report, "number_of_files");
266
+ const fileCount = report?.number_of_files;
267
+ const hasNoFilesEvidence = banner !== "missing" ||
268
+ (hasFileCount && (fileCount === 0 || !isNonnegativeInteger(fileCount)));
269
+ if (!hasNoFilesEvidence)
270
+ return { kind: "ordinary-report" };
271
+ if (result.status == null) {
272
+ return { kind: "unconfirmed-no-files", reason: "status-null" };
273
+ }
274
+ if (result.status === 0) {
275
+ return { kind: "unconfirmed-no-files", reason: "status-zero" };
276
+ }
277
+ if (result.status !== 1) {
278
+ return { kind: "unconfirmed-no-files", reason: "status-other" };
279
+ }
280
+ if (stderr.trim().length > 0) {
281
+ return { kind: "unconfirmed-no-files", reason: "stderr-nonempty" };
282
+ }
283
+ if (banner === "near") {
284
+ return { kind: "unconfirmed-no-files", reason: "banner-near" };
285
+ }
286
+ if (banner === "missing") {
287
+ return { kind: "unconfirmed-no-files", reason: "banner-missing" };
288
+ }
289
+ if (jsonMalformed || !report) {
290
+ return { kind: "unconfirmed-no-files", reason: "json-malformed" };
291
+ }
292
+ const keys = Object.keys(report);
293
+ if (keys.some((key) => !OXLINT_NO_FILES_REPORT_KEYS.has(key))) {
294
+ return { kind: "unconfirmed-no-files", reason: "json-unknown-key" };
295
+ }
296
+ if (OXLINT_NO_FILES_REPORT_KEYS.size !== keys.length ||
297
+ [...OXLINT_NO_FILES_REPORT_KEYS].some((key) => !Object.prototype.hasOwnProperty.call(report, key))) {
298
+ return { kind: "unconfirmed-no-files", reason: "json-missing-key" };
299
+ }
300
+ if (!Array.isArray(report.diagnostics) ||
301
+ !isNonnegativeInteger(report.number_of_files) ||
302
+ !isNonnegativeInteger(report.number_of_rules) ||
303
+ !isPositiveInteger(report.threads_count) ||
304
+ !isNonnegativeFinite(report.start_time)) {
305
+ return {
306
+ kind: "unconfirmed-no-files",
307
+ reason: "json-known-field-invalid",
308
+ };
309
+ }
310
+ if (report.diagnostics.length > 0) {
311
+ return { kind: "unconfirmed-no-files", reason: "diagnostics-nonempty" };
312
+ }
313
+ if (report.number_of_files !== 0) {
314
+ return { kind: "unconfirmed-no-files", reason: "files-nonzero" };
315
+ }
316
+ return { kind: "expected-no-files" };
317
+ }
104
318
  // Oxlint codes look like "eslint(no-debugger)" or "oxc(approx-constant)".
105
319
  // Strip the plugin prefix so the rule lines up with what users expect.
106
320
  // indexOf-based extraction avoids a regex hot-spot Sonar flagged for
@@ -127,8 +341,10 @@ function parseOxlintJson(raw, filePath) {
127
341
  catch {
128
342
  return [];
129
343
  }
344
+ if (!Array.isArray(parsed.diagnostics))
345
+ return [];
130
346
  const diagnostics = [];
131
- for (const d of parsed.diagnostics ?? []) {
347
+ for (const d of parsed.diagnostics) {
132
348
  const rule = extractOxlintRule(d.code);
133
349
  const label = d.labels?.[0]?.span;
134
350
  const lineNum = label?.line ?? 1;
@@ -33,7 +33,7 @@ const phpLintRunner = {
33
33
  skipTestFiles: false,
34
34
  async run(ctx) {
35
35
  const cwd = ctx.cwd || process.cwd();
36
- if (!(await (php.isAvailableAsync(cwd)))) {
36
+ if (!(await php.isAvailableAsync(cwd))) {
37
37
  return { status: "skipped", diagnostics: [], semantic: "none" };
38
38
  }
39
39
  const cmd = php.getCommand(cwd);
@@ -3,6 +3,12 @@ import { safeSpawnAsync } from "../../safe-spawn.js";
3
3
  import { getLinterPolicyForCwd, hasPhpstanConfig } from "../../tool-policy.js";
4
4
  import { PRIORITY } from "../priorities.js";
5
5
  import { createAvailabilityChecker, resolveVendorToolCommand, } from "./utils/runner-helpers.js";
6
+ import { parseToolRun } from "./utils/tool-failure.js";
7
+ import { finishParsedRun } from "./utils/tool-failure.js";
8
+ // phpstan's documented exit codes: 0 = no errors, 1 = errors found, 2 = a
9
+ // fatal/internal error that stopped the analysis. Only 1 is a run that carries
10
+ // findings.
11
+ const PHPSTAN_EXIT_CODES = { ran: [1] };
6
12
  const phpstan = createAvailabilityChecker("phpstan", ".phar");
7
13
  // phpstan analyses the target file PLUS its dependency closure and keys errors
8
14
  // by the real file in `output.files`. Attribute each diagnostic to that key
@@ -19,7 +25,7 @@ export function parsePhpstanJson(raw, fallbackPath, cwd) {
19
25
  ? file
20
26
  : path.resolve(cwd, file)
21
27
  : fallbackPath;
22
- for (const err of fileErrors.errors ?? []) {
28
+ for (const err of fileErrors?.messages ?? []) {
23
29
  diagnostics.push({
24
30
  id: `phpstan:${err.line ?? 1}:${err.message.slice(0, 40)}`,
25
31
  message: err.message,
@@ -29,11 +35,38 @@ export function parsePhpstanJson(raw, fallbackPath, cwd) {
29
35
  severity: "error",
30
36
  semantic: "blocking",
31
37
  tool: "phpstan",
32
- rule: "phpstan",
38
+ rule: err.identifier || "phpstan",
33
39
  fixable: false,
34
40
  });
35
41
  }
36
42
  }
43
+ // #1937 round 2: the top-level `errors[]` array carries phpstan's
44
+ // FILE-INDEPENDENT findings — internal errors, and ignore patterns that
45
+ // matched nothing. A run can report `totals.errors: 1` with `files: {}`
46
+ // and exit 1, and reading only `files` turned that into zero
47
+ // diagnostics: the same defect class as the `errors`-vs-`messages` bug
48
+ // above, one level up. Pinned by
49
+ // tests/fixtures/runner-output/phpstan/top-level-errors.captured.json.
50
+ //
51
+ // These have no file or line, so they attach to the edited file at line
52
+ // 1. That is the honest placement: the reader needs to see them, and
53
+ // there is nowhere truer to put them.
54
+ for (const message of output.errors ?? []) {
55
+ if (typeof message !== "string" || !message.trim())
56
+ continue;
57
+ diagnostics.push({
58
+ id: `phpstan:global:${message.slice(0, 40)}`,
59
+ message,
60
+ filePath: fallbackPath,
61
+ line: 1,
62
+ column: 1,
63
+ severity: "error",
64
+ semantic: "blocking",
65
+ tool: "phpstan",
66
+ rule: "phpstan/analysis",
67
+ fixable: false,
68
+ });
69
+ }
37
70
  return diagnostics;
38
71
  }
39
72
  catch {
@@ -41,7 +74,7 @@ export function parsePhpstanJson(raw, fallbackPath, cwd) {
41
74
  }
42
75
  }
43
76
  async function resolvePhpstan(cwd) {
44
- if (await (phpstan.isAvailableAsync(cwd)))
77
+ if (await phpstan.isAvailableAsync(cwd))
45
78
  return phpstan.getCommand(cwd);
46
79
  return resolveVendorToolCommand(cwd, "phpstan", ".bat");
47
80
  }
@@ -66,18 +99,22 @@ const phpstanRunner = {
66
99
  return { status: "skipped", diagnostics: [], semantic: "none" };
67
100
  const absPath = path.resolve(cwd, ctx.filePath);
68
101
  const result = await safeSpawnAsync(cmd, ["analyse", "--error-format=json", "--no-progress", absPath], { timeout: 30000, cwd });
69
- // phpstan exits 0 = no errors, 1 = errors found, 2 = fatal
70
- if (result.status === 2 || result.error) {
71
- return { status: "skipped", diagnostics: [], semantic: "none" };
72
- }
73
- if (result.status === 0) {
74
- return { status: "succeeded", diagnostics: [], semantic: "none" };
75
- }
76
- const diagnostics = parsePhpstanJson(result.stdout ?? "", ctx.filePath, cwd);
77
- if (diagnostics.length === 0) {
78
- return { status: "succeeded", diagnostics: [], semantic: "none" };
79
- }
80
- return { status: "failed", diagnostics, semantic: "blocking" };
102
+ // phpstan exits 0 = no errors, 1 = errors found, 2 = fatal. The fatal
103
+ // case used to return `skipped` silently; routing it through the shared
104
+ // seam keeps that verdict and adds the bounded `runner-empty-result` row
105
+ // (#1816), and the exit-1 case now also records when the JSON report
106
+ // parses to nothing (#1948) — the shape the error-count-as-array bug had.
107
+ const run = parseToolRun("phpstan", { result, exitCodes: PHPSTAN_EXIT_CODES }, (out) => parsePhpstanJson(out, ctx.filePath, cwd), { parseOutput: result.stdout ?? "" });
108
+ if (run.skipped)
109
+ return run.skipped;
110
+ const diagnostics = run.diagnostics;
111
+ return finishParsedRun({
112
+ tool: "phpstan",
113
+ ctx,
114
+ result,
115
+ diagnostics,
116
+ classify: () => ({ status: "failed", semantic: "blocking" }),
117
+ });
81
118
  },
82
119
  };
83
120
  export default phpstanRunner;
@@ -244,7 +244,11 @@ function parsePSAnalyzerOutput(raw, filePath) {
244
244
  .filter((item) => item.Message && item.Line)
245
245
  .map((item) => {
246
246
  const sev = (item.Severity ?? "Warning").toLowerCase();
247
- const severity = (sev === "error" || sev === "parseerror") ? "error" : (sev === "information" ? "info" : "warning");
247
+ const severity = sev === "error" || sev === "parseerror"
248
+ ? "error"
249
+ : sev === "information"
250
+ ? "info"
251
+ : "warning";
248
252
  const rule = item.RuleName ?? "PSScriptAnalyzer";
249
253
  return {
250
254
  id: `psscriptanalyzer-${rule}-${item.Line}`,
@@ -295,8 +299,10 @@ const psScriptAnalyzerRunner = {
295
299
  const result = await spawnPs(cmd, [
296
300
  "-NoProfile",
297
301
  "-NonInteractive",
298
- "-File", tmpScript,
299
- "-FilePath", absPath,
302
+ "-File",
303
+ tmpScript,
304
+ "-FilePath",
305
+ absPath,
300
306
  ]);
301
307
  const hostStallMs = sampler.stop();
302
308
  const elapsedMs = Date.now() - startedAt;
@@ -396,9 +402,7 @@ const psScriptAnalyzerRunner = {
396
402
  // than latched. It must still never be read as a clean file
397
403
  // (#1598): empty/malformed stdout on a successful exit is a lost
398
404
  // or truncated write, not evidence of zero diagnostics.
399
- const unreadableReason = result.stdout.trim()
400
- ? "unparseable"
401
- : "empty";
405
+ const unreadableReason = result.stdout.trim() ? "unparseable" : "empty";
402
406
  logAvailabilityDecision({
403
407
  tool: "psscriptanalyzer-stdout",
404
408
  verdict: "unavailable",
@@ -11,6 +11,7 @@ import { getLSPService } from "../../lsp/index.js";
11
11
  import { safeSpawnAsync } from "../../safe-spawn.js";
12
12
  import { PRIORITY } from "../priorities.js";
13
13
  import { createAvailabilityChecker, resolveAvailableOrInstall, } from "./utils/runner-helpers.js";
14
+ import { finishParsedRun } from "./utils/tool-failure.js";
14
15
  const pyright = createAvailabilityChecker("pyright", ".exe");
15
16
  const pyrightRunner = {
16
17
  id: "pyright",
@@ -31,7 +32,7 @@ const pyrightRunner = {
31
32
  // Get pyright command - try multiple strategies
32
33
  let cmd = null;
33
34
  // Strategy 1: Check cached availability (fast path)
34
- if (await (pyright.isAvailableAsync(cwd))) {
35
+ if (await pyright.isAvailableAsync(cwd)) {
35
36
  cmd = pyright.getCommand(cwd);
36
37
  }
37
38
  // Strategy 2: use the shared availability taxonomy and install suppression.
@@ -64,24 +65,32 @@ const pyrightRunner = {
64
65
  }
65
66
  const output = (result.stdout || "").trim();
66
67
  if (!output) {
67
- return { status: "succeeded", diagnostics: [], semantic: "none" };
68
+ // Empty stdout with a nonzero exit and stderr chatter is an unreadable
69
+ // report of problems, never clean (#1839). The helper turns that into
70
+ // failed + parse-error; exit 0 stays clean.
71
+ return finishParsedRun({
72
+ tool: "pyright",
73
+ ctx,
74
+ result,
75
+ diagnostics: [],
76
+ });
68
77
  }
69
78
  try {
70
79
  const data = JSON.parse(output);
71
80
  const diagnostics = parsePyrightOutput(data, ctx.filePath);
72
- if (diagnostics.length === 0) {
73
- return { status: "succeeded", diagnostics: [], semantic: "none" };
74
- }
75
- const hasErrors = diagnostics.some((d) => d.severity === "error");
76
- return {
77
- status: hasErrors ? "failed" : "succeeded",
81
+ return finishParsedRun({
82
+ tool: "pyright",
83
+ ctx,
84
+ result,
78
85
  diagnostics,
79
- semantic: hasErrors
80
- ? "blocking"
81
- : diagnostics.length > 0
82
- ? "warning"
83
- : "none",
84
- };
86
+ classify: (diagnostics) => {
87
+ const hasErrors = diagnostics.some((d) => d.severity === "error");
88
+ return {
89
+ status: hasErrors ? "failed" : "succeeded",
90
+ semantic: hasErrors ? "blocking" : "warning",
91
+ };
92
+ },
93
+ });
85
94
  // pi-lens-ignore: missing-error-propagation
86
95
  }
87
96
  catch {
@@ -32,7 +32,7 @@ const makeClippyProbe = (cargoExe) => createCwdCachedProbe((cwd) => safeSpawnAsy
32
32
  }), { tool: "clippy", budgetMs: CLIPPY_PROBE_BUDGET_MS });
33
33
  const clippyAvailabilityByCargo = new Map();
34
34
  function getClippyProbe(cargoExe) {
35
- return clippyAvailabilityByCargo.get(cargoExe) ?? refreshClippyProbe(cargoExe);
35
+ return (clippyAvailabilityByCargo.get(cargoExe) ?? refreshClippyProbe(cargoExe));
36
36
  }
37
37
  /** Replace the cached probe so a post-install state is observed. */
38
38
  function refreshClippyProbe(cargoExe) {
@@ -24,6 +24,7 @@ import * as path from "node:path";
24
24
  import { safeSpawnAsync } from "../../safe-spawn.js";
25
25
  import { PRIORITY } from "../priorities.js";
26
26
  import { createAvailabilityChecker, lspPrimaryCoversFile, resolveAvailableOrInstall, } from "./utils/runner-helpers.js";
27
+ import { finishParsedRun } from "./utils/tool-failure.js";
27
28
  const shellcheck = createAvailabilityChecker("shellcheck", ".exe");
28
29
  function findShellcheckConfig(cwd) {
29
30
  const local = path.join(cwd, ".shellcheckrc");
@@ -122,7 +123,7 @@ const shellcheckRunner = {
122
123
  return { status: "skipped", diagnostics: [], semantic: "none" };
123
124
  }
124
125
  let cmd = null;
125
- if (await (shellcheck.isAvailableAsync(cwd))) {
126
+ if (await shellcheck.isAvailableAsync(cwd)) {
126
127
  cmd = shellcheck.getCommand(cwd);
127
128
  }
128
129
  else {
@@ -157,15 +158,12 @@ const shellcheckRunner = {
157
158
  // Parse diagnostics
158
159
  const raw = result.stdout + result.stderr;
159
160
  const diagnostics = parseShellcheckOutput(raw, ctx.filePath);
160
- if (diagnostics.length === 0) {
161
- return { status: "succeeded", diagnostics: [], semantic: "none" };
162
- }
163
- const hasBlocking = diagnostics.some((d) => d.semantic === "blocking");
164
- return {
165
- status: hasBlocking ? "failed" : "succeeded",
161
+ return finishParsedRun({
162
+ tool: "shellcheck",
163
+ ctx,
164
+ result,
166
165
  diagnostics,
167
- semantic: hasBlocking ? "blocking" : "warning",
168
- };
166
+ });
169
167
  },
170
168
  };
171
169
  export default shellcheckRunner;
@@ -34,7 +34,7 @@ const shfmtRunner = {
34
34
  async run(ctx) {
35
35
  const cwd = ctx.cwd || process.cwd();
36
36
  let cmd = null;
37
- if (await (shfmt.isAvailableAsync(cwd))) {
37
+ if (await shfmt.isAvailableAsync(cwd)) {
38
38
  cmd = shfmt.getCommand(cwd);
39
39
  }
40
40
  else {
@@ -79,7 +79,10 @@ const shfmtRunner = {
79
79
  // .editorconfig — otherwise this nags on every shell write against shfmt's
80
80
  // defaults (#211). Parse errors (above) are always reported.
81
81
  if (!hasEditorConfig(cwd)) {
82
- return { status: "succeeded", diagnostics: [], semantic: "none" };
82
+ // #211: without project opt-in, do not nag about formatting. But
83
+ // shfmt DID exit 1 — differences exist — so this is "chose not to
84
+ // check", reported as skipped, never as a clean result (#1839).
85
+ return { status: "skipped", diagnostics: [], semantic: "none" };
83
86
  }
84
87
  // Extract first changed line from diff if possible
85
88
  const diffOutput = result.stdout ?? result.stderr ?? "";
@@ -18,10 +18,10 @@
18
18
  * Install: cargo install typos-cli
19
19
  * Or: npm install -g typos-cli (if wrapped)
20
20
  */
21
- import { skipUnlessToolRan } from "./utils/tool-failure.js";
22
21
  import { safeSpawnAsync } from "../../safe-spawn.js";
23
22
  import { PRIORITY } from "../priorities.js";
24
23
  import { createAvailabilityChecker } from "./utils/runner-helpers.js";
24
+ import { parseToolRun } from "./utils/tool-failure.js";
25
25
  const typos = createAvailabilityChecker("typos", ".exe");
26
26
  // typos-cli exit codes: 0 = no typos, 2 = typos found, 1 = an error that
27
27
  // stopped the scan. Anything nonzero outside {2} is a rejected invocation.
@@ -98,20 +98,15 @@ const spellcheckRunner = {
98
98
  // (unreadable config, bad argument). The `status === 2 || stdout` test
99
99
  // below is false for exit 1 with an empty stdout, so a failed run was
100
100
  // reported as a clean file. Only 0 and 2 are runs.
101
- const skipped = skipUnlessToolRan("spellcheck", {
102
- result,
103
- exitCodes: TYPOS_EXIT_CODES,
104
- });
105
- if (skipped)
106
- return skipped;
107
- // typos-cli exits with code 2 if typos found, 0 if clean
108
- const hasTypos = result.status === 2 || result.stdout?.trim();
109
- if (!hasTypos) {
110
- return { status: "succeeded", diagnostics: [], semantic: "none" };
111
- }
112
- // Parse diagnostics
113
- const raw = result.stdout + result.stderr;
114
- const diagnostics = parseTyposOutput(raw, ctx.filePath);
101
+ //
102
+ // #1948: parse through the shared seam so an exit-2 run whose JSON lines
103
+ // yield nothing leaves a record. `parseOutput` keeps the historical
104
+ // split: the gate judges "did it run" on stdout, the parser reads both
105
+ // streams.
106
+ const run = parseToolRun("spellcheck", { result, exitCodes: TYPOS_EXIT_CODES }, (out) => parseTyposOutput(out, ctx.filePath), { parseOutput: `${result.stdout ?? ""}${result.stderr ?? ""}` });
107
+ if (run.skipped)
108
+ return run.skipped;
109
+ const diagnostics = run.diagnostics;
115
110
  if (diagnostics.length === 0) {
116
111
  return { status: "succeeded", diagnostics: [], semantic: "none" };
117
112
  }