pi-lens 4.2.0 → 4.3.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 (157) hide show
  1. package/CHANGELOG.md +543 -0
  2. package/README.md +45 -2
  3. package/dist/clients/actionable-warnings.js +44 -16
  4. package/dist/clients/advisory-provenance.js +187 -51
  5. package/dist/clients/cache-manager.js +64 -5
  6. package/dist/clients/cascade-format.js +24 -0
  7. package/dist/clients/child-unref.js +17 -8
  8. package/dist/clients/config-diagnostic-codes.js +2 -0
  9. package/dist/clients/config-locations.js +237 -9
  10. package/dist/clients/config-resolve.js +7 -1
  11. package/dist/clients/degradation-ledger.js +33 -3
  12. package/dist/clients/dependency-checker.js +87 -64
  13. package/dist/clients/diagnostic-dispositions.js +39 -3
  14. package/dist/clients/dispatch/finding-policy.js +118 -1
  15. package/dist/clients/dispatch/indent-detect.js +8 -2
  16. package/dist/clients/dispatch/inline-suppressions.js +23 -4
  17. package/dist/clients/dispatch/rule-ignores.js +14 -7
  18. package/dist/clients/dispatch/runners/actionlint.js +26 -28
  19. package/dist/clients/dispatch/runners/biome-check.js +38 -25
  20. package/dist/clients/dispatch/runners/cpp-check.js +32 -49
  21. package/dist/clients/dispatch/runners/credo.js +16 -14
  22. package/dist/clients/dispatch/runners/cue-vet.js +25 -7
  23. package/dist/clients/dispatch/runners/dart-analyze.js +28 -47
  24. package/dist/clients/dispatch/runners/detekt.js +11 -4
  25. package/dist/clients/dispatch/runners/dotnet-build.js +27 -22
  26. package/dist/clients/dispatch/runners/elixir-check.js +31 -46
  27. package/dist/clients/dispatch/runners/eslint.js +29 -46
  28. package/dist/clients/dispatch/runners/gleam-check.js +91 -11
  29. package/dist/clients/dispatch/runners/go-vet.js +16 -2
  30. package/dist/clients/dispatch/runners/golangci-lint.js +38 -23
  31. package/dist/clients/dispatch/runners/hadolint.js +27 -15
  32. package/dist/clients/dispatch/runners/htmlhint.js +17 -8
  33. package/dist/clients/dispatch/runners/javac.js +31 -21
  34. package/dist/clients/dispatch/runners/ktlint.js +34 -60
  35. package/dist/clients/dispatch/runners/lsp.js +6 -5
  36. package/dist/clients/dispatch/runners/markdownlint.js +12 -5
  37. package/dist/clients/dispatch/runners/mypy.js +2 -1
  38. package/dist/clients/dispatch/runners/oxlint.js +22 -6
  39. package/dist/clients/dispatch/runners/php-lint.js +39 -16
  40. package/dist/clients/dispatch/runners/phpstan.js +1 -1
  41. package/dist/clients/dispatch/runners/rubocop.js +27 -18
  42. package/dist/clients/dispatch/runners/ruff.js +18 -19
  43. package/dist/clients/dispatch/runners/rust-clippy.js +6 -1
  44. package/dist/clients/dispatch/runners/shellcheck.js +6 -2
  45. package/dist/clients/dispatch/runners/spellcheck.js +10 -3
  46. package/dist/clients/dispatch/runners/sqlfluff.js +11 -2
  47. package/dist/clients/dispatch/runners/stylelint.js +10 -2
  48. package/dist/clients/dispatch/runners/swiftlint.js +11 -2
  49. package/dist/clients/dispatch/runners/taplo.js +12 -6
  50. package/dist/clients/dispatch/runners/tflint.js +30 -15
  51. package/dist/clients/dispatch/runners/tree-sitter.js +23 -0
  52. package/dist/clients/dispatch/runners/trivy-config.js +8 -2
  53. package/dist/clients/dispatch/runners/utils/diagnostic-parsers.js +11 -1
  54. package/dist/clients/dispatch/runners/vale.js +15 -3
  55. package/dist/clients/dispatch/runners/yamllint.js +12 -2
  56. package/dist/clients/dispatch/runners/zig-check.js +27 -39
  57. package/dist/clients/dispatch/suppress-writer.js +13 -3
  58. package/dist/clients/effective-config.js +3 -1
  59. package/dist/clients/extension-log.js +48 -1
  60. package/dist/clients/finding-delivery-gate.js +53 -23
  61. package/dist/clients/formatters.js +49 -11
  62. package/dist/clients/git-guard.js +247 -34
  63. package/dist/clients/gradle-ktfmt-style.js +121 -25
  64. package/dist/clients/inline-blocker-dispositions.js +186 -0
  65. package/dist/clients/install-diagnostics.js +19 -6
  66. package/dist/clients/installer/index.js +281 -32
  67. package/dist/clients/jscpd-client.js +8 -0
  68. package/dist/clients/latency-logger.js +8 -0
  69. package/dist/clients/lens-config.js +66 -8
  70. package/dist/clients/lsp/cascade-tier.js +3 -0
  71. package/dist/clients/lsp/client.js +139 -11
  72. package/dist/clients/lsp/config.js +9 -7
  73. package/dist/clients/lsp/content-limits.js +41 -0
  74. package/dist/clients/lsp/index.js +42 -8
  75. package/dist/clients/lsp/launch.js +23 -2
  76. package/dist/clients/lsp/server.js +106 -25
  77. package/dist/clients/lsp/sync-kind.js +14 -0
  78. package/dist/clients/lsp/wait-policy/strategies.js +105 -16
  79. package/dist/clients/mcp/analyze.js +9 -2
  80. package/dist/clients/mcp/ipc.js +232 -44
  81. package/dist/clients/mcp/review.js +53 -7
  82. package/dist/clients/opengrep-client.js +11 -6
  83. package/dist/clients/path-utils.js +51 -9
  84. package/dist/clients/persistent-reverify.js +271 -0
  85. package/dist/clients/pipeline.js +136 -67
  86. package/dist/clients/project-diagnostics/fresh-fetch.js +21 -3
  87. package/dist/clients/project-lens-config.js +11 -3
  88. package/dist/clients/review-graph/builder.js +34 -26
  89. package/dist/clients/review-graph/git-identity.js +16 -10
  90. package/dist/clients/ruff-client.js +31 -4
  91. package/dist/clients/runtime-coordinator.js +38 -4
  92. package/dist/clients/runtime-tool-call.js +16 -5
  93. package/dist/clients/runtime-tool-result.js +27 -12
  94. package/dist/clients/runtime-turn.js +571 -287
  95. package/dist/clients/safe-spawn.js +256 -65
  96. package/dist/clients/skills-resolver.js +38 -2
  97. package/dist/clients/spawn-output-cap.js +84 -1
  98. package/dist/clients/tree-sitter-client.js +119 -58
  99. package/dist/clients/turn-end/lane.js +51 -0
  100. package/dist/clients/turn-end/lanes/govulncheck.js +112 -0
  101. package/dist/clients/turn-end/lanes/secrets.js +204 -0
  102. package/dist/clients/warm-attach.js +59 -0
  103. package/dist/clients/widget-state.js +294 -17
  104. package/dist/clients/workspace-topology.js +57 -3
  105. package/dist/clients/zizmor-config.js +26 -13
  106. package/dist/index.js +6342 -3995
  107. package/dist/mcp/analyze-cli.js +4 -0
  108. package/dist/mcp/server.js +33 -21
  109. package/dist/scripts/lib/web-tree-sitter-dir.mjs +123 -0
  110. package/dist/tools/lens-diagnostic-mark.js +14 -2
  111. package/dist/tools/lens-diagnostics.js +127 -33
  112. package/dist/tools/lsp-diagnostics.js +150 -12
  113. package/docs/adr/0001-stale-advisory-live-arm.md +29 -0
  114. package/docs/adr/0002-workspace-hash-rendezvous.md +30 -0
  115. package/docs/adr/0003-git-guard-latch-writer.md +203 -0
  116. package/docs/adr/0004-disposition-policy-seam.md +38 -0
  117. package/docs/adr/0005-tool-availability-enforcement-seam.md +29 -0
  118. package/docs/adr/0006-derived-state-benchmark-first.md +31 -0
  119. package/docs/adr/0007-end-to-end-witness-per-seam-slice.md +38 -0
  120. package/docs/adr/0008-turn-end-lane-interface.md +165 -0
  121. package/docs/adr/0009-reported-path-attribution.md +114 -0
  122. package/docs/adr/README.md +26 -0
  123. package/docs/agent-tools.md +18 -1
  124. package/docs/ast-grep_rules_catalog.md +4 -3
  125. package/docs/configuration.md +55 -1
  126. package/docs/engineering-principles.md +202 -0
  127. package/docs/environment-variables.md +61 -4
  128. package/docs/features.md +2 -2
  129. package/docs/globalconfig.md +6 -1
  130. package/docs/language-coverage.md +17 -0
  131. package/docs/lsp-capability-matrix.md +98 -31
  132. package/docs/pi-lens-fixer.md +57 -3
  133. package/docs/pi-lens-investigator.md +9 -0
  134. package/docs/pi-lens-reviewer.md +16 -0
  135. package/docs/pi-lens-subagent.md +9 -0
  136. package/docs/public-api-stability.md +1 -0
  137. package/docs/release-qa-baseline.md +2 -0
  138. package/docs/servercapabilities.md +21 -11
  139. package/docs/settings.md +12 -3
  140. package/docs/subagent-compat.md +1 -1
  141. package/package.json +5 -3
  142. package/rules/ast-grep-rules/rule-tests/no-assert-tuple-test.yml +13 -0
  143. package/rules/ast-grep-rules/rule-tests/no-boolean-in-except-test.yml +26 -0
  144. package/rules/ast-grep-rules/rule-tests/no-http-headers-bracket-access-test.yml +10 -0
  145. package/rules/ast-grep-rules/rule-tests/unchecked-numeric-parse-python-test.yml +18 -0
  146. package/rules/ast-grep-rules/rule-tests/unchecked-throwing-call-python-test.yml +4 -3
  147. package/rules/ast-grep-rules/rules/go-no-fmt-println.yml +2 -2
  148. package/rules/ast-grep-rules/rules/no-assert-tuple.yml +23 -13
  149. package/rules/ast-grep-rules/rules/no-boolean-in-except.yml +7 -3
  150. package/rules/ast-grep-rules/rules/no-console-except-error-js.yml +2 -2
  151. package/rules/ast-grep-rules/rules/no-console-except-error.yml +2 -2
  152. package/rules/ast-grep-rules/rules/no-http-headers-bracket-access.yml +12 -7
  153. package/rules/ast-grep-rules/rules/unchecked-numeric-parse-python.yml +44 -0
  154. package/rules/ast-grep-rules/rules/unchecked-throwing-call-python.yml +5 -3
  155. package/rules/tree-sitter-queries/typescript/console-statement.yml +2 -2
  156. package/scripts/install-selftest.mjs +21 -17
  157. package/scripts/lib/web-tree-sitter-dir.mjs +123 -0
@@ -1,21 +1,29 @@
1
1
  import * as path from "node:path";
2
+ import { pathsEqual } from "../../path-utils.js";
2
3
  import { safeSpawnAsync } from "../../safe-spawn.js";
3
4
  import { resolveRunnerCwd } from "../../tool-cwd.js";
4
5
  import { hasJavaBuildDescriptor } from "../../tool-policy.js";
5
6
  import { PRIORITY } from "../priorities.js";
6
7
  import { createAvailabilityChecker } from "./utils/runner-helpers.js";
8
+ import { finishParsedRun, parseToolRun } from "./utils/tool-failure.js";
7
9
  const javac = createAvailabilityChecker("javac", ".exe");
8
- function parseJavacOutput(raw, filePath) {
10
+ function parseJavacOutput(raw, filePath, cwd) {
9
11
  const diagnostics = [];
10
12
  const lines = raw.split(/\r?\n/);
13
+ const absTarget = path.resolve(cwd, filePath);
11
14
  for (const line of lines) {
12
15
  const match = line.match(/^(.*?\.java):(\d+):\s+(error|warning):\s+(.+)$/i);
13
16
  if (!match)
14
17
  continue;
15
18
  const [, reportedFile, lineStr, severityLabel, message] = match;
16
- const resolvedReported = path.resolve(reportedFile.trim());
17
- const resolvedTarget = path.resolve(filePath);
18
- if (resolvedReported !== resolvedTarget)
19
+ // Is this reported diagnostic about the file we dispatched for? ONE seam
20
+ // answers that for every runner (#3278): resolve the tool's spelling against
21
+ // the cwd the tool RAN in — never `process.cwd()`, which is the extension's,
22
+ // not the runner's — and compare through `pathsEqual`, the repo's on-disk
23
+ // identity predicate. A bare `===` treats a spelling that differs only in
24
+ // case (the same file on Windows and on a case-folding POSIX mount) as a
25
+ // different file and drops every finding for the edited file (#209, #3277).
26
+ if (!pathsEqual(path.resolve(cwd, reportedFile.trim()), absTarget))
19
27
  continue;
20
28
  const severity = severityLabel.toLowerCase() === "error" ? "error" : "warning";
21
29
  const lineNum = Number.parseInt(lineStr, 10) || 1;
@@ -66,24 +74,26 @@ const javacRunner = {
66
74
  timeout: 30000,
67
75
  });
68
76
  const raw = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim();
69
- if (result.status === 0 && !raw) {
70
- return { status: "succeeded", diagnostics: [], semantic: "none" };
71
- }
72
- const diagnostics = parseJavacOutput(raw, ctx.filePath);
73
- if (diagnostics.length === 0) {
74
- return {
75
- status: result.status === 0 ? "succeeded" : "failed",
76
- diagnostics: [],
77
+ const parsed = parseToolRun("javac", {
78
+ result,
79
+ output: raw,
80
+ // EXIT TABLE (javac 21 docs https://docs.oracle.com/en/java/javase/21/docs/specs/man/javac.html): 0 clean; 1 findings; 2 error; other nonzero rejected.
81
+ exitCodes: { ran: [1, 2] },
82
+ }, (output) => parseJavacOutput(output, ctx.filePath, cwd));
83
+ if (parsed.skipped)
84
+ return parsed.skipped;
85
+ return finishParsedRun({
86
+ tool: "javac",
87
+ ctx,
88
+ result,
89
+ diagnostics: parsed.diagnostics,
90
+ classify: (diagnostics) => ({
91
+ status: diagnostics.some((d) => d.severity === "error")
92
+ ? "failed"
93
+ : "succeeded",
77
94
  semantic: "warning",
78
- rawOutput: raw,
79
- };
80
- }
81
- const hasErrors = diagnostics.some((d) => d.severity === "error");
82
- return {
83
- status: hasErrors ? "failed" : "succeeded",
84
- diagnostics,
85
- semantic: "warning",
86
- };
95
+ }),
96
+ });
87
97
  },
88
98
  };
89
99
  export default javacRunner;
@@ -1,10 +1,21 @@
1
1
  import * as path from "node:path";
2
+ import { pathsEqual } from "../../path-utils.js";
2
3
  import { safeSpawnAsync } from "../../safe-spawn.js";
3
4
  import { resolveRunnerCwd } from "../../tool-cwd.js";
4
5
  import { getAutofixCapability, getLinterPolicyForCwd, } from "../../tool-policy.js";
5
6
  import { PRIORITY } from "../priorities.js";
6
7
  import { createAvailabilityChecker, resolveToolCommandWithInstallFallback, } from "./utils/runner-helpers.js";
8
+ import { finishParsedRun, parseToolRun } from "./utils/tool-failure.js";
7
9
  const ktlint = createAvailabilityChecker("ktlint", ".exe");
10
+ // KtLint's exit contract, declared HERE rather than in the shared classifier:
11
+ // an admission is a property of this tool, and #3291 round 2 shipped it through
12
+ // a shared `parseToolRun` option that silently erased every OTHER runner's
13
+ // table. KtLint 1.8.0's CLI page documents the JSON reporter and a nonzero exit
14
+ // on violations, but does not enumerate numeric classes; 1, 2 and 3 are the
15
+ // statuses this runner observes carrying a valid reporter payload, and a valid
16
+ // payload is the evidence that analysis was reached. Any other nonzero status
17
+ // stays a rejected invocation.
18
+ const KTLINT_EXIT_CODES = { ran: [1, 2, 3] };
8
19
  function normalizeKtlintResults(parsed) {
9
20
  if (Array.isArray(parsed)) {
10
21
  return parsed;
@@ -16,14 +27,19 @@ function normalizeKtlintResults(parsed) {
16
27
  }
17
28
  return null;
18
29
  }
19
- function parseKtlintOutput(raw, filePath) {
30
+ function parseKtlintOutput(raw, filePath, cwd) {
20
31
  try {
21
32
  const parsed = normalizeKtlintResults(JSON.parse(raw));
22
33
  if (!parsed)
23
34
  return null;
24
35
  const autofix = getAutofixCapability("ktlint");
25
36
  const diagnostics = [];
37
+ const absTarget = path.resolve(cwd, filePath);
26
38
  for (const result of parsed) {
39
+ // #3295: ktlint's JSON reporter is an array of FILES; `.editorconfig`
40
+ // globs put more than the argv in it.
41
+ if (result.file && !pathsEqual(path.resolve(cwd, result.file), absTarget))
42
+ continue;
27
43
  for (const err of result.errors ?? []) {
28
44
  diagnostics.push({
29
45
  id: `ktlint-${err.ruleId}-${err.line}-${err.col}`,
@@ -47,12 +63,6 @@ function parseKtlintOutput(raw, filePath) {
47
63
  return null;
48
64
  }
49
65
  }
50
- function firstOutputLine(result) {
51
- return (result.stderr || result.stdout || "")
52
- .trim()
53
- .split(/\r?\n/, 1)[0]
54
- .slice(0, 200);
55
- }
56
66
  const ktlintRunner = {
57
67
  id: "ktlint",
58
68
  appliesTo: ["kotlin"],
@@ -78,59 +88,23 @@ const ktlintRunner = {
78
88
  cwd,
79
89
  timeout: 30000,
80
90
  });
81
- // Ktlint exits non-zero when issues are found, so only treat a total lack
82
- // of output as a hard skip. Any non-empty but unparseable output should
83
- // surface as runner failure instead of a false clean result.
84
- if (result.error && !result.stdout) {
85
- return { status: "skipped", diagnostics: [], semantic: "none" };
86
- }
87
- const diagnostics = parseKtlintOutput(result.stdout || "", ctx.filePath);
88
- if (diagnostics === null) {
89
- const detail = firstOutputLine(result) || "Unknown ktlint output";
90
- return {
91
- status: "failed",
92
- diagnostics: [
93
- {
94
- id: "ktlint-output-unparseable",
95
- message: `Unable to parse ktlint output: ${detail}`,
96
- filePath: ctx.filePath,
97
- severity: "warning",
98
- semantic: "warning",
99
- tool: "ktlint",
100
- fixable: false,
101
- autoFixAvailable: false,
102
- },
103
- ],
104
- semantic: "warning",
105
- };
106
- }
107
- if (diagnostics.length === 0) {
108
- if (result.status && result.status !== 0) {
109
- return {
110
- status: "failed",
111
- diagnostics: [
112
- {
113
- id: "ktlint-nonzero-no-diagnostics",
114
- message: firstOutputLine(result) ||
115
- "ktlint exited non-zero without JSON diagnostics",
116
- filePath: ctx.filePath,
117
- severity: "warning",
118
- semantic: "warning",
119
- tool: "ktlint",
120
- fixable: false,
121
- autoFixAvailable: false,
122
- },
123
- ],
124
- semantic: "warning",
125
- };
126
- }
127
- return { status: "succeeded", diagnostics: [], semantic: "none" };
128
- }
129
- return {
130
- status: result.status && result.status !== 0 ? "failed" : "succeeded",
131
- diagnostics,
132
- semantic: "warning",
133
- };
91
+ // `output` is what the classifier judges AND what the parser reads: ktlint
92
+ // writes its reporter payload to stdout and its errors to stderr, so both
93
+ // are one wire here. The exit table rides in this same per-runner input,
94
+ // never in a shared option (#3291 r3).
95
+ const run = parseToolRun("ktlint", {
96
+ result,
97
+ output: `${result.stdout ?? ""}\n${result.stderr ?? ""}`,
98
+ exitCodes: KTLINT_EXIT_CODES,
99
+ }, (output) => parseKtlintOutput(output, ctx.filePath, cwd) ?? []);
100
+ if (run.skipped)
101
+ return run.skipped;
102
+ return finishParsedRun({
103
+ tool: "ktlint",
104
+ ctx,
105
+ result,
106
+ diagnostics: run.diagnostics,
107
+ });
134
108
  },
135
109
  };
136
110
  export default ktlintRunner;
@@ -13,6 +13,7 @@
13
13
  */
14
14
  import { logExtension } from "../../extension-log.js";
15
15
  import { getLspCapableKinds } from "../../language-policy.js";
16
+ import { exceedsLspSyncLimits } from "../../lsp/content-limits.js";
16
17
  import { touchCoverageGap } from "../../lsp/diagnostic-binding.js";
17
18
  import { getLSPService } from "../../lsp/index.js";
18
19
  import { LSP_SERVERS } from "../../lsp/server.js";
@@ -25,8 +26,6 @@ import { enabledAuxiliaryLspServerIds, retagAuxiliaryDiagnostics, } from "../aux
25
26
  import { readFileContent } from "./utils.js";
26
27
  import { tryWarmAttachedCodeActions, tryWarmAttachedDiagnostics, } from "../../warm-attach.js";
27
28
  import { contentHash, WARM_CODE_ACTION_LOOKUP_LIMIT } from "../../mcp/ipc.js";
28
- const LSP_MAX_FILE_BYTES = RUNTIME_CONFIG.pipeline.lspMaxFileBytes;
29
- const LSP_MAX_FILE_LINES = RUNTIME_CONFIG.pipeline.lspMaxFileLines;
30
29
  const LSP_SPAWN_BUDGET_MS = RUNTIME_CONFIG.pipeline.lspSpawnBudgetMs;
31
30
  // Diagnostics-wait cap for the dispatch lsp-runner. Bounded so a slow LSP
32
31
  // (typescript-language-server on large monorepos has been observed >7 s)
@@ -133,9 +132,11 @@ const lspRunner = {
133
132
  if (!content) {
134
133
  return { status: "skipped", diagnostics: [], semantic: "none" };
135
134
  }
136
- const sizeBytes = Buffer.byteLength(content, "utf-8");
137
- const lineCount = content.split("\n").length;
138
- if (sizeBytes > LSP_MAX_FILE_BYTES || lineCount > LSP_MAX_FILE_LINES) {
135
+ // #3405 r2: was an inline second copy of the pipeline's byte/line
136
+ // predicate. One module owns the bound now (M3406-1's root cause was that
137
+ // a third writer could be added with neither copy); the SKIP is still this
138
+ // runner's own policy.
139
+ if (exceedsLspSyncLimits(content).tooLarge) {
139
140
  return { status: "skipped", diagnostics: [], semantic: "none" };
140
141
  }
141
142
  // Cross-cutting auxiliary scanners (opengrep, …) attach alongside the
@@ -1,3 +1,5 @@
1
+ import * as path from "node:path";
2
+ import { pathsEqual } from "../../path-utils.js";
1
3
  import { safeSpawnAsync } from "../../safe-spawn.js";
2
4
  import { logRunnerAdvisoryOnce, resolveRunnerCwd } from "../../tool-cwd.js";
3
5
  import { getLinterPolicyForCwd, markdownlintConfigArgs, } from "../../tool-policy.js";
@@ -10,7 +12,7 @@ const markdownlint = createAvailabilityChecker("markdownlint-cli2", ".cmd", [
10
12
  "--no-globs",
11
13
  "-",
12
14
  ]);
13
- // markdownlint-cli2 exit codes (its README's "Exit Codes" section): 0 = no
15
+ // EXIT TABLE (markdownlint-cli2 0.17 README https://github.com/DavidAnson/markdownlint-cli2#exit-codes): 0 = no
14
16
  // findings, 1 = lint findings, 2 = unexpected error (unreadable config, bad
15
17
  // glob, crash). Only 2 is a rejected invocation.
16
18
  const MARKDOWNLINT_EXIT_CODES = { ran: [1] };
@@ -59,18 +61,23 @@ const MARKDOWNLINT_FIXABLE_RULES = new Set([
59
61
  // 2. some rules carry MULTIPLE slash-separated names (e.g.
60
62
  // `MD041/first-line-heading/first-line-h1`).
61
63
  // The severity token is optional so older/relative-path output still parses.
62
- function parseMarkdownlintOutput(raw, filePath) {
64
+ function parseMarkdownlintOutput(raw, filePath, cwd) {
63
65
  const diagnostics = [];
66
+ const absTarget = path.resolve(cwd, filePath);
64
67
  for (const line of raw.split(/\r?\n/)) {
65
68
  if (!line.trim())
66
69
  continue;
67
70
  // Rule code is MD### followed by one or more slash-joined names. Use a
68
71
  // single char class (`[\w/-]+`) rather than a nested quantifier
69
72
  // (`(?:/[\w-]+)+`) so there's no super-linear backtracking (S5852).
70
- const match = line.match(/^.*?:(\d+)(?::(\d+))?\s+(?:error|warning)?\s*(MD\d+\/[\w/-]+)\s+(.+)$/);
73
+ const match = line.match(/^(.*?):(\d+)(?::(\d+))?\s+(?:error|warning)?\s*(MD\d+\/[\w/-]+)\s+(.+)$/);
71
74
  if (!match)
72
75
  continue;
73
- const [, lineNum, col, ruleCode, message] = match;
76
+ const [, reported, lineNum, col, ruleCode, message] = match;
77
+ // #3295: the leading `.*?` this parser discarded IS markdownlint-cli2's
78
+ // reported path, and its config globs can widen the run past the argv.
79
+ if (reported && !pathsEqual(path.resolve(cwd, reported), absTarget))
80
+ continue;
74
81
  const ruleName = ruleCode.split("/")[0];
75
82
  const fixable = MARKDOWNLINT_FIXABLE_RULES.has(ruleName);
76
83
  diagnostics.push({
@@ -144,7 +151,7 @@ const markdownlintRunner = {
144
151
  const raw = `${result.stdout ?? ""}${result.stderr ?? ""}`;
145
152
  // #1948: one seam for both gates — the tool produced nothing, and the
146
153
  // tool produced something the parser could not read.
147
- const run = parseToolRun("markdownlint", { result, output: raw, exitCodes: MARKDOWNLINT_EXIT_CODES }, (out) => parseMarkdownlintOutput(out, ctx.filePath));
154
+ const run = parseToolRun("markdownlint", { result, output: raw, exitCodes: MARKDOWNLINT_EXIT_CODES }, (out) => parseMarkdownlintOutput(out, ctx.filePath, cwd));
148
155
  if (run.skipped)
149
156
  return run.skipped;
150
157
  const diagnostics = run.diagnostics;
@@ -14,7 +14,8 @@ const mypy = createAvailabilityChecker("mypy", "");
14
14
  // [syntax]`). A bad flag or unreadable config also exits 2, but writes its
15
15
  // message to STDERR and leaves stdout empty.
16
16
  //
17
- // So the exit code cannot separate the two, and the STREAM does: the gate below
17
+ // EXIT TABLE (mypy 1.11 docs https://mypy.readthedocs.io/en/stable/command_line.html): 0 clean; 1 findings; 2 findings/error; other
18
+ // nonzero rejected. The exit code cannot separate the two, and the STREAM does:
18
19
  // judges "nothing to parse" on stdout alone, while the parser still reads both.
19
20
  // The table's remaining job is to reject any OTHER nonzero status.
20
21
  const MYPY_EXIT_CODES = { ran: [1, 2] };
@@ -6,7 +6,9 @@
6
6
  *
7
7
  * Requires: oxlint (npm install -g oxlint)
8
8
  */
9
+ import * as path from "node:path";
9
10
  import { findLocalBinUpwards } from "../../package-manager.js";
11
+ import { pathsEqual } from "../../path-utils.js";
10
12
  import { safeSpawnAsync } from "../../safe-spawn.js";
11
13
  import { resolveRunnerCwd } from "../../tool-cwd.js";
12
14
  import { truncatedByOutputCap } from "../../spawn-output-cap.js";
@@ -111,7 +113,12 @@ const oxlintRunner = {
111
113
  // force a status-0 lookalike away from clean. Neither escapes this runner.
112
114
  // parseToolRun checks process failures before invoking this parser, so
113
115
  // spawn/timeout/killed/signal precedence remains owned by #1994.
114
- const parsedRun = parseToolRun("oxlint", { result, output: parsedOutput }, () => {
116
+ const parsedRun = parseToolRun("oxlint", {
117
+ result,
118
+ output: parsedOutput,
119
+ // EXIT TABLE (oxlint 0.8 measured fixture): 0 clean; 1 findings; 2 error; other nonzero rejected.
120
+ exitCodes: { ran: [1, 2] },
121
+ }, () => {
115
122
  if (noFilesDecision.kind === "expected-no-files") {
116
123
  return [OXLINT_NO_FILES];
117
124
  }
@@ -119,9 +126,9 @@ const oxlintRunner = {
119
126
  result.status === 0) {
120
127
  return [OXLINT_NO_FILES_UNCONFIRMED];
121
128
  }
122
- let parsed = parseOxlintJson(stdout, ctx.filePath);
129
+ let parsed = parseOxlintJson(stdout, ctx.filePath, cwd);
123
130
  if (parsed.length === 0 && stdout.length > 0) {
124
- parsed = parseOxlintUnix(parsedOutput, ctx.filePath);
131
+ parsed = parseOxlintUnix(parsedOutput, ctx.filePath, cwd);
125
132
  }
126
133
  return parsed;
127
134
  }, { parseOutput: parsedOutput });
@@ -334,7 +341,7 @@ function extractOxlintRule(code) {
334
341
  return code;
335
342
  return code.slice(open + 1, close);
336
343
  }
337
- function parseOxlintJson(raw, filePath) {
344
+ function parseOxlintJson(raw, filePath, cwd) {
338
345
  const trimmed = raw.trim();
339
346
  if (!trimmed.startsWith("{"))
340
347
  return [];
@@ -348,7 +355,11 @@ function parseOxlintJson(raw, filePath) {
348
355
  if (!Array.isArray(parsed.diagnostics))
349
356
  return [];
350
357
  const diagnostics = [];
358
+ const absTarget = path.resolve(cwd, filePath);
351
359
  for (const d of parsed.diagnostics) {
360
+ if (!d.filename || !pathsEqual(path.resolve(cwd, d.filename), absTarget)) {
361
+ continue;
362
+ }
352
363
  const rule = extractOxlintRule(d.code);
353
364
  const label = d.labels?.[0]?.span;
354
365
  const lineNum = label?.line ?? 1;
@@ -374,13 +385,18 @@ function parseOxlintJson(raw, filePath) {
374
385
  }
375
386
  return diagnostics;
376
387
  }
377
- function parseOxlintUnix(raw, filePath) {
388
+ function parseOxlintUnix(raw, filePath, cwd) {
378
389
  const diagnostics = [];
390
+ const absTarget = path.resolve(cwd, filePath);
379
391
  for (const line of raw.split("\n")) {
380
392
  // Parse: file:line:column: message (rule)
381
393
  const match = line.match(/^(.+):(\d+):(\d+):\s*(.+?)\s*\(([^)]+)\)$/);
382
394
  if (match) {
383
- const [, _file, lineStr, _col, message, rule] = match;
395
+ const [, reportedPath, lineStr, _col, message, rule] = match;
396
+ if (!reportedPath || !lineStr || !message || !rule)
397
+ continue;
398
+ if (!pathsEqual(path.resolve(cwd, reportedPath), absTarget))
399
+ continue;
384
400
  diagnostics.push({
385
401
  id: `oxlint-${rule}-${lineStr}`,
386
402
  message: `${message} (${rule})`,
@@ -1,16 +1,33 @@
1
1
  import * as path from "node:path";
2
+ import { pathsEqual } from "../../path-utils.js";
2
3
  import { safeSpawnAsync } from "../../safe-spawn.js";
3
4
  import { resolveRunnerCwd } from "../../tool-cwd.js";
4
5
  import { PRIORITY } from "../priorities.js";
5
6
  import { createAvailabilityChecker } from "./utils/runner-helpers.js";
7
+ import { finishParsedRun, parseToolRun } from "./utils/tool-failure.js";
8
+ // PHP's `-l` exit contract, declared HERE rather than in the shared classifier
9
+ // (#3291 r3): an admission is a property of this tool. The manual documents
10
+ // `-l` as syntax-check-only with a nonzero return on failure; the captured
11
+ // PHP 8.3.32 wire exits 1 in the usual CLI path and 255 (the shell reading of
12
+ // PHP's documented -1) for the parse-error wire. Both are completed analyses
13
+ // whose output must reach the parser; any other nonzero status stays a
14
+ // rejected invocation.
15
+ const PHP_LINT_EXIT_CODES = { ran: [1, 255] };
6
16
  const php = createAvailabilityChecker("php", ".exe");
7
- function parsePhpLintOutput(raw, filePath) {
17
+ function parsePhpLintOutput(raw, filePath, cwd) {
8
18
  const output = raw.trim();
9
- if (!output)
19
+ if (!output || !/(?:PHP )?Parse error:/i.test(output))
10
20
  return [];
11
21
  const lineMatch = output.match(/on line (\d+)/i);
12
- const messageMatch = output.match(/PHP Parse error:\s*(.+?)(?:\s+in\s+.+?\s+on line \d+)?$/im) ??
13
- output.match(/Parse error:\s*(.+?)(?:\s+in\s+.+?\s+on line \d+)?$/im);
22
+ const messageMatch = output.match(/PHP Parse error:\s*(.+?)(?:\s+in\s+(.+?)\s+on line \d+)?$/im) ??
23
+ output.match(/Parse error:\s*(.+?)(?:\s+in\s+(.+?)\s+on line \d+)?$/im);
24
+ // #3295: PHP names the file it could not parse in the same sentence. `php -l`
25
+ // follows no includes today, so this drops nothing under our argv — it pins
26
+ // the attribution the parser was asserting without asking.
27
+ const reported = messageMatch?.[2]?.trim();
28
+ if (reported &&
29
+ !pathsEqual(path.resolve(cwd, reported), path.resolve(cwd, filePath)))
30
+ return [];
14
31
  return [
15
32
  {
16
33
  id: `php-lint:${lineMatch?.[1] ?? "1"}`,
@@ -45,18 +62,24 @@ const phpLintRunner = {
45
62
  timeout: 15000,
46
63
  cwd,
47
64
  });
48
- if (result.status === 0) {
49
- return { status: "succeeded", diagnostics: [], semantic: "none" };
50
- }
51
- const diagnostics = parsePhpLintOutput(`${result.stdout ?? ""}\n${result.stderr ?? ""}`, ctx.filePath);
52
- if (diagnostics.length === 0) {
53
- return { status: "skipped", diagnostics: [], semantic: "none" };
54
- }
55
- return {
56
- status: "failed",
57
- diagnostics,
58
- semantic: "blocking",
59
- };
65
+ // `output` is what the classifier judges AND what the parser reads: the
66
+ // parse error arrives on stderr, so both streams are one wire here. The
67
+ // exit table rides in this same per-runner input, never in a shared
68
+ // option (#3291 r3).
69
+ const run = parseToolRun("php-lint", {
70
+ result,
71
+ output: `${result.stdout ?? ""}\n${result.stderr ?? ""}`,
72
+ exitCodes: PHP_LINT_EXIT_CODES,
73
+ }, (output) => parsePhpLintOutput(output, ctx.filePath, cwd));
74
+ if (run.skipped)
75
+ return run.skipped;
76
+ return finishParsedRun({
77
+ tool: "php-lint",
78
+ ctx,
79
+ result,
80
+ diagnostics: run.diagnostics,
81
+ classify: () => ({ status: "failed", semantic: "blocking" }),
82
+ });
60
83
  },
61
84
  };
62
85
  export default phpLintRunner;
@@ -6,7 +6,7 @@ import { PRIORITY } from "../priorities.js";
6
6
  import { createAvailabilityChecker, resolveVendorToolCommand, } from "./utils/runner-helpers.js";
7
7
  import { parseToolRun } from "./utils/tool-failure.js";
8
8
  import { finishParsedRun } from "./utils/tool-failure.js";
9
- // phpstan's documented exit codes: 0 = no errors, 1 = errors found, 2 = a
9
+ // EXIT TABLE (PHPStan 1.12 docs https://phpstan.org/user-guide/command-line-usage): 0 = no errors, 1 = errors found, 2 = a
10
10
  // fatal/internal error that stopped the analysis. Only 1 is a run that carries
11
11
  // findings.
12
12
  const PHPSTAN_EXIT_CODES = { ran: [1] };
@@ -7,11 +7,14 @@
7
7
  *
8
8
  * Supports bundle exec (preferred in Bundler projects).
9
9
  */
10
+ import * as path from "node:path";
11
+ import { pathsEqual } from "../../path-utils.js";
10
12
  import { safeSpawnAsync } from "../../safe-spawn.js";
11
13
  import { resolveRunnerCwd } from "../../tool-cwd.js";
12
14
  import { getAutofixCapability, getLinterPolicyForCwd, getRubocopCommand, } from "../../tool-policy.js";
13
15
  import { PRIORITY } from "../priorities.js";
14
16
  import { resolveCommandArgsWithInstallFallback } from "./utils/runner-helpers.js";
17
+ import { finishParsedRun, parseToolRun } from "./utils/tool-failure.js";
15
18
  const SEVERITY_MAP = {
16
19
  fatal: "error",
17
20
  error: "error",
@@ -19,12 +22,17 @@ const SEVERITY_MAP = {
19
22
  convention: "warning",
20
23
  refactor: "info",
21
24
  };
22
- function parseRubocopJson(raw, filePath) {
25
+ function parseRubocopJson(raw, filePath, cwd) {
23
26
  try {
24
27
  const output = JSON.parse(raw);
25
28
  const autofix = getAutofixCapability("rubocop");
26
29
  const diagnostics = [];
30
+ const absTarget = path.resolve(cwd, filePath);
27
31
  for (const file of output.files) {
32
+ // #3295: `.rubocop.yml` `Include:`/`inherit_from` can widen the run past
33
+ // the argv, and each result carries its own `path`.
34
+ if (file.path && !pathsEqual(path.resolve(cwd, file.path), absTarget))
35
+ continue;
28
36
  for (const offense of file.offenses) {
29
37
  const severity = SEVERITY_MAP[offense.severity] ?? "warning";
30
38
  diagnostics.push({
@@ -68,23 +76,24 @@ const rubocopRunner = {
68
76
  const { cmd, args } = resolved;
69
77
  // Lint only — no auto-correct (formatter handles that)
70
78
  const result = await safeSpawnAsync(cmd, [...args, "--format", "json", "--no-color", ctx.filePath], { timeout: 30000, cwd });
71
- // rubocop exits 0 = no offenses, 1 = offenses found, 2 = fatal error
72
- if (result.status === 2) {
73
- return { status: "skipped", diagnostics: [], semantic: "none" };
74
- }
75
- if (result.status === 0) {
76
- return { status: "succeeded", diagnostics: [], semantic: "none" };
77
- }
78
- const diagnostics = parseRubocopJson(result.stdout, ctx.filePath);
79
- if (diagnostics.length === 0) {
80
- return { status: "succeeded", diagnostics: [], semantic: "none" };
81
- }
82
- const hasErrors = diagnostics.some((d) => d.semantic === "blocking");
83
- return {
84
- status: hasErrors ? "failed" : "succeeded",
85
- diagnostics,
86
- semantic: hasErrors ? "blocking" : "warning",
87
- };
79
+ // EXIT TABLE (RuboCop 1.66 docs https://docs.rubocop.org/rubocop/usage/basic_usage.html): 0 = clean, 1 = offenses/findings, 2 = fatal/error or rejected
80
+ // invocation. A nonzero exit with valid JSON remains findings; a nonzero
81
+ // exit with empty or unparsable JSON is never clean (#1816).
82
+ const run = parseToolRun("rubocop", {
83
+ result,
84
+ // Classify both streams so stderr-only failures reach the parse-error
85
+ // path, while parseOutput keeps RuboCop's native JSON parser on stdout.
86
+ output: `${result.stdout}${result.stderr}`,
87
+ exitCodes: { ran: [1, 2] },
88
+ }, (output) => parseRubocopJson(output, ctx.filePath, cwd), { parseOutput: result.stdout });
89
+ if (run.skipped)
90
+ return run.skipped;
91
+ return finishParsedRun({
92
+ tool: "rubocop",
93
+ ctx,
94
+ result,
95
+ diagnostics: run.diagnostics,
96
+ });
88
97
  },
89
98
  };
90
99
  export default rubocopRunner;
@@ -10,6 +10,7 @@ import { safeSpawnAsync } from "../../safe-spawn.js";
10
10
  import { stripAnsi } from "../../sanitize.js";
11
11
  import { getAutofixCapability, getLinterPolicyForCwd, ruffConfigArgs, } from "../../tool-policy.js";
12
12
  import { PRIORITY } from "../priorities.js";
13
+ import { finishParsedRun, parseToolRun } from "./utils/tool-failure.js";
13
14
  import { parseRuffOutput } from "./utils/diagnostic-parsers.js";
14
15
  import { createAvailabilityChecker, resolveAvailableOrInstall, resolveRunnerCwd, } from "./utils/runner-helpers.js";
15
16
  const ruff = createAvailabilityChecker("ruff", ".exe");
@@ -65,25 +66,23 @@ const ruffRunner = {
65
66
  // Step 1: Capture diagnostics (before fixing) — teaching signal for the agent
66
67
  const checkResult = await safeSpawnAsync(cmd, ["check", "--output-format", "json", ...configArgs, ctx.filePath], { cwd, timeout: 30000 });
67
68
  const raw = stripAnsi(checkResult.stdout + checkResult.stderr);
68
- const diagnostics = parseRuffJson(checkResult.stdout || "", ctx.filePath);
69
- const parsedDiagnostics = diagnostics.length > 0 ? diagnostics : parseRuffOutput(raw, ctx.filePath);
70
- if (checkResult.status === 0 && parsedDiagnostics.length === 0) {
71
- return { status: "succeeded", diagnostics: [], semantic: "none" };
72
- }
73
- if (parsedDiagnostics.length === 0) {
74
- return {
75
- status: "failed",
76
- diagnostics: [],
77
- semantic: "warning",
78
- rawOutput: raw.slice(0, 500),
79
- };
80
- }
81
- const hasErrors = parsedDiagnostics.some((d) => d.severity === "error");
82
- return {
83
- status: hasErrors ? "failed" : "succeeded",
84
- diagnostics: parsedDiagnostics,
85
- semantic: hasErrors ? "blocking" : "warning",
86
- };
69
+ // EXIT TABLE (Ruff 0.6 docs https://docs.astral.sh/ruff/linter/): 0 = clean, 1 = findings, 2 = findings/error or
70
+ // tool error. A nonzero exit with valid findings stays findings; empty or
71
+ // unparsable output must never become clean (#1816).
72
+ const run = parseToolRun("ruff", { result: checkResult, output: raw, exitCodes: { ran: [1, 2] } }, (output) => {
73
+ const diagnostics = parseRuffJson(output, ctx.filePath);
74
+ return diagnostics.length > 0
75
+ ? diagnostics
76
+ : parseRuffOutput(output, ctx.filePath, cwd);
77
+ });
78
+ if (run.skipped)
79
+ return run.skipped;
80
+ return finishParsedRun({
81
+ tool: "ruff",
82
+ ctx,
83
+ result: checkResult,
84
+ diagnostics: run.diagnostics,
85
+ });
87
86
  },
88
87
  };
89
88
  export default ruffRunner;
@@ -4,6 +4,7 @@
4
4
  * Runs `cargo clippy` for Rust files to catch common mistakes.
5
5
  */
6
6
  import { isAbsolute, resolve } from "node:path";
7
+ import { pathsEqual } from "../../path-utils.js";
7
8
  import { rustClient } from "../../rust-client.js";
8
9
  import { safeSpawnAsync } from "../../safe-spawn.js";
9
10
  import { stripAnsi } from "../../sanitize.js";
@@ -125,8 +126,12 @@ const rustClippyRunner = {
125
126
  // a crate-mate's pre-existing diagnostic must NOT fail the edited file's
126
127
  // turn. Filter to the edited file like golangci-lint — if the edited file
127
128
  // is clean, this turn succeeds even when siblings carry warnings/errors.
129
+ // #3278: one seam for reported-path attribution — see javac.ts. This
130
+ // member already had the right BASE (`parseClippyOutput` resolves
131
+ // `span.file` against `cargoDir`); what it lacked was the on-disk
132
+ // identity predicate.
128
133
  const absEdited = resolve(ctx.filePath);
129
- const diagnostics = allDiagnostics.filter((d) => resolve(d.filePath) === absEdited);
134
+ const diagnostics = allDiagnostics.filter((d) => pathsEqual(resolve(cargoDir, d.filePath), absEdited));
130
135
  const hasErrors = diagnostics.some((d) => d.semantic === "blocking");
131
136
  return {
132
137
  status: hasErrors ? "failed" : "succeeded",