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,10 +1,12 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { getProjectDataDir } from "../../file-utils.js";
4
+ import { pathsEqual } from "../../path-utils.js";
4
5
  import { safeSpawnAsync } from "../../safe-spawn.js";
5
6
  import { resolveRunnerCwd } from "../../tool-cwd.js";
6
7
  import { PRIORITY } from "../priorities.js";
7
8
  import { createAvailabilityChecker } from "./utils/runner-helpers.js";
9
+ import { finishParsedRun, parseToolRun } from "./utils/tool-failure.js";
8
10
  // Per-cwd cached `--version` probes (#120). Before this, each dispatch
9
11
  // invocation fired a fresh `safeSpawnAsync` per command — once per Elixir
10
12
  // file save.
@@ -34,15 +36,15 @@ function parseElixirOutput(raw, filePath, cwd = process.cwd()) {
34
36
  const lines = raw.split(/\r?\n/);
35
37
  // elixirc reports paths RELATIVE to its cwd (e.g. `bad.ex`, not the absolute
36
38
  // path we passed), so resolve the reported path against the runner cwd — not
37
- // process.cwd(). Elixir 1.16+ also normalizes to a lowercase drive letter and
38
- // forward slashes (`c:/...`), which never string-equals `C:\...` on Windows,
39
- // so compare case-insensitively there.
40
- const matchesTarget = (sourcePath) => {
41
- const resolved = path.resolve(cwd, sourcePath.trim());
42
- return process.platform === "win32"
43
- ? resolved.toLowerCase() === resolvedTarget.toLowerCase()
44
- : resolved === resolvedTarget;
45
- };
39
+ // process.cwd(). Two resolved spellings of ONE file still differ in ways
40
+ // only the filesystem can settle — Elixir 1.16+ normalizes to a lowercase
41
+ // drive letter and forward slashes (`c:/...`), which never string-equals
42
+ // `C:\...` on Windows — so ask `pathsEqual`, the repo's on-disk identity
43
+ // seam, instead of hand-rolling a win32 case fold here. It folds case
44
+ // exactly where the filesystem does and nowhere else, which a
45
+ // `process.platform` test cannot express; `terragrunt.ts:90` asks the same
46
+ // question the same way (#1193).
47
+ const matchesTarget = (sourcePath) => pathsEqual(path.resolve(cwd, sourcePath.trim()), resolvedTarget);
46
48
  for (let index = 0; index < lines.length; index++) {
47
49
  const line = lines[index];
48
50
  // Defense-in-depth: cap line length before regex matching. The input is
@@ -130,12 +132,6 @@ function parseElixirOutput(raw, filePath, cwd = process.cwd()) {
130
132
  }
131
133
  return diagnostics;
132
134
  }
133
- function firstOutputLine(result) {
134
- return `${result.stderr || ""}\n${result.stdout || ""}`
135
- .trim()
136
- .split(/\r?\n/, 1)[0]
137
- .slice(0, 200);
138
- }
139
135
  const elixirCheckRunner = {
140
136
  id: "elixir-check",
141
137
  appliesTo: ["elixir"],
@@ -163,47 +159,36 @@ const elixirCheckRunner = {
163
159
  cwd,
164
160
  timeout: 30000,
165
161
  });
166
- if (result.error && !result.stdout && !result.stderr) {
167
- return { status: "skipped", diagnostics: [], semantic: "none" };
168
- }
169
162
  const raw = `${result.stderr || ""}\n${result.stdout || ""}`;
170
163
  const hasProjectContext = command === "mix";
171
- const diagnostics = parseElixirOutput(raw, ctx.filePath, cwd).map((d) => hasProjectContext
164
+ const parsed = parseToolRun("elixir-check", {
165
+ result,
166
+ output: raw,
167
+ // EXIT TABLE (Elixir 1.16 measured fixture): 0 clean; 1 findings; 2 error; other nonzero rejected.
168
+ exitCodes: { ran: [1, 2] },
169
+ }, (out) => parseElixirOutput(out, ctx.filePath, cwd).map((d) => hasProjectContext
172
170
  ? d
173
171
  : {
174
172
  ...d,
175
173
  // A direct elixirc invocation cannot resolve Mix dependencies.
176
174
  // Standalone findings inform the agent, but cannot block it.
177
175
  semantic: "warning",
178
- });
179
- if (diagnostics.length === 0) {
180
- if (result.status && result.status !== 0) {
176
+ }));
177
+ if (parsed.skipped)
178
+ return parsed.skipped;
179
+ return finishParsedRun({
180
+ tool: "elixir-check",
181
+ ctx,
182
+ result,
183
+ diagnostics: parsed.diagnostics,
184
+ classify: (diagnostics) => {
185
+ const hasBlocking = diagnostics.some((d) => d.semantic === "blocking");
181
186
  return {
182
- status: "failed",
183
- diagnostics: [
184
- {
185
- id: "elixir-check-nonzero-no-diagnostics",
186
- message: firstOutputLine(result) ||
187
- `${command} exited non-zero without structured diagnostics`,
188
- filePath: ctx.filePath,
189
- severity: "error",
190
- semantic: hasProjectContext ? "blocking" : "warning",
191
- tool: "elixir-check",
192
- rule: command,
193
- fixable: false,
194
- },
195
- ],
196
- semantic: hasProjectContext ? "blocking" : "warning",
187
+ status: hasBlocking ? "failed" : "succeeded",
188
+ semantic: hasBlocking ? "blocking" : "warning",
197
189
  };
198
- }
199
- return { status: "succeeded", diagnostics: [], semantic: "none" };
200
- }
201
- const hasBlocking = diagnostics.some((d) => d.semantic === "blocking");
202
- return {
203
- status: hasBlocking ? "failed" : "succeeded",
204
- diagnostics,
205
- semantic: hasBlocking ? "blocking" : "warning",
206
- };
190
+ },
191
+ });
207
192
  },
208
193
  };
209
194
  export default elixirCheckRunner;
@@ -6,11 +6,14 @@
6
6
  *
7
7
  * Gate: skips when no ESLint config is detected (project uses Biome/OxLint instead).
8
8
  */
9
+ import * as path from "node:path";
10
+ import { pathsEqual } from "../../path-utils.js";
9
11
  import { safeSpawnAsync } from "../../safe-spawn.js";
10
12
  import { resolveRunnerCwd } from "../../tool-cwd.js";
11
13
  import { getAutofixCapability, hasEslintConfig } from "../../tool-policy.js";
12
14
  import { PRIORITY } from "../priorities.js";
13
15
  import { createCwdCachedProbe, resolveToolCommand, } from "./utils/runner-helpers.js";
16
+ import { finishParsedRun, parseToolRun } from "./utils/tool-failure.js";
14
17
  const ESLINT_PROBE_BUDGET_MS = 5000;
15
18
  // Per-cwd cached eslint `--version` verification (#120). Before this, every
16
19
  // dispatch invocation ran a fresh `safeSpawnAsync(cmd, ["--version"])` after
@@ -40,12 +43,18 @@ function getEslintProbe(cmd) {
40
43
  eslintAvailabilityByCmd.set(cmd, created);
41
44
  return created;
42
45
  }
43
- function parseEslintJson(raw, filePath) {
46
+ function parseEslintJson(raw, filePath, cwd) {
44
47
  try {
45
48
  const results = JSON.parse(raw);
46
49
  const autofix = getAutofixCapability("eslint");
47
50
  const diagnostics = [];
51
+ const absTarget = path.resolve(cwd, filePath);
48
52
  for (const fileResult of results) {
53
+ // #3295: flat config `files`/`ignores` and a directory argv both put a
54
+ // SECOND result in this array; each names its own `filePath`.
55
+ if (fileResult.filePath &&
56
+ !pathsEqual(path.resolve(cwd, fileResult.filePath), absTarget))
57
+ continue;
49
58
  for (const msg of fileResult.messages) {
50
59
  const severity = msg.severity === 2 ? "error" : "warning";
51
60
  diagnostics.push({
@@ -92,11 +101,11 @@ const eslintRunner = {
92
101
  return { status: "skipped", diagnostics: [], semantic: "none" };
93
102
  }
94
103
  const result = await safeSpawnAsync(cmd, ["--format", "json", "--no-error-on-unmatched-pattern", ctx.filePath], { timeout: 30000, cwd });
95
- // ESLint exits 2 on fatal/config errors — nothing was linted, so this
96
- // is an unavailable run, not a clean or failed one.
97
- if (result.status === 2) {
98
- return { status: "skipped", diagnostics: [], semantic: "none" };
99
- }
104
+ // Exit table: 0 is clean-or-findings, and 1/2 are ran outcomes whose
105
+ // JSON parser decides whether findings or a parse/config error reached the
106
+ // user. In particular, status 2 carries file-local fatal parse messages
107
+ // as JSON and must not be discarded before parsing.
108
+ //
100
109
  // ESLint exits 0 whenever nothing reached ERROR severity — that
101
110
  // includes a run that found only warnings (#1954), which also prints a
102
111
  // full JSON report. So parse stdout unconditionally and branch on the
@@ -109,46 +118,20 @@ const eslintRunner = {
109
118
  if (raw.length === 0 && result.status !== 0) {
110
119
  raw = result.stderr || "";
111
120
  }
112
- const parsed = parseEslintJson(raw, ctx.filePath);
113
- if (parsed.parseError && raw.trim().length > 0) {
114
- const preview = raw.replace(/\s+/g, " ").slice(0, 160);
115
- return {
116
- status: "failed",
117
- diagnostics: [
118
- {
119
- id: "eslint:parse-error:1",
120
- message: "ESLint JSON parse failed: " +
121
- parsed.parseError +
122
- (preview ? " (output preview: " + preview + ")" : ""),
123
- filePath: ctx.filePath,
124
- line: 1,
125
- column: 1,
126
- severity: "warning",
127
- semantic: "warning",
128
- tool: "eslint",
129
- },
130
- ],
131
- semantic: "warning",
132
- };
133
- }
134
- const diagnostics = parsed.diagnostics;
135
- if (diagnostics.length === 0) {
136
- return { status: "succeeded", diagnostics: [], semantic: "none" };
137
- }
138
- const hasErrors = diagnostics.some((d) => d.semantic === "blocking");
139
- // A warning-only result on exit 0 is ESLint's normal outcome, not a
140
- // failure: exit 0 means nothing hit ERROR severity. Keying status off
141
- // blocking severity plus exit code (the sibling convention from oxlint,
142
- // biome-check, golangci-lint, rubocop) keeps plan.ts's fallback group
143
- // ["eslint", "oxlint", "biome-check-json"] stopping at eslint instead
144
- // of re-running oxlint and biome-check-json on every warning-only save.
145
- // The findings reach delivery regardless of status — dispatcher.ts
146
- // buckets by each diagnostic's own `semantic`.
147
- return {
148
- status: !hasErrors && result.status === 0 ? "succeeded" : "failed",
149
- diagnostics,
150
- semantic: hasErrors ? "blocking" : "warning",
151
- };
121
+ const parsed = parseToolRun("eslint", {
122
+ result,
123
+ output: raw,
124
+ // EXIT TABLE (ESLint 9.10 docs https://eslint.org/docs/latest/use/command-line-interface): 0 clean; 1 findings; 2 fatal findings/error; other nonzero rejected.
125
+ exitCodes: { ran: [1, 2] },
126
+ }, (rawOutput) => parseEslintJson(rawOutput, ctx.filePath, cwd).diagnostics);
127
+ if (parsed.skipped)
128
+ return parsed.skipped;
129
+ return finishParsedRun({
130
+ tool: "eslint",
131
+ ctx,
132
+ result,
133
+ diagnostics: parsed.diagnostics,
134
+ });
152
135
  },
153
136
  };
154
137
  export default eslintRunner;
@@ -1,28 +1,108 @@
1
+ import * as path from "node:path";
2
+ import { pathsEqual } from "../../path-utils.js";
1
3
  import { safeSpawnAsync } from "../../safe-spawn.js";
4
+ import { stripAnsi } from "../../sanitize.js";
2
5
  import { resolveRunnerCwd } from "../../tool-cwd.js";
3
6
  import { createAvailabilityChecker } from "./utils/runner-helpers.js";
4
7
  import { PRIORITY } from "../priorities.js";
5
8
  const gleam = createAvailabilityChecker("gleam", ".exe");
6
- function parseGleamOutput(raw, filePath) {
9
+ /**
10
+ * gleam renders every located diagnostic through
11
+ * `codespan_reporting::term::emit` (v1.18.1
12
+ * `compiler-core/src/diagnostic.rs:92-133`, unchanged since v1.6.3), so its
13
+ * location line is codespan's LOCUS line, not a bare `path:line:col`:
14
+ *
15
+ * ```text
16
+ * error: Type mismatch
17
+ * ┌─ /abs/proj/src/app.gleam:1:8
18
+ * ```
19
+ *
20
+ * `<outer gutter spaces> ┌─ <name>:<line>:<column>` —
21
+ * codespan-reporting 0.13.1 `src/term/renderer.rs:379-396` (the gutter, then
22
+ * `chars().snippet_start`, then one space, then the locus) and `:869-878`
23
+ * (`{name}:{line_number}:{column_number}`), with `snippet_start` = `┌─` in the
24
+ * default box-drawing set (`src/term/config.rs:419-429`, reached because gleam
25
+ * passes `Config::default()`, whose display style is Rich).
26
+ *
27
+ * `name` is `location.path` verbatim (`diagnostic.rs:92-94`,
28
+ * `files.add(main_location_path, …)`), and the CLI builds that path by walking
29
+ * UP from the absolute `std::env::current_dir()` to the nearest `gleam.toml`
30
+ * (`compiler-cli/src/fs.rs:32-62`) and joining `src`/`test`
31
+ * (`compiler-core/src/paths.rs:42-48`) before enumerating the directory — so it
32
+ * is ABSOLUTE, with host-native separators. Resolving it against the cwd the
33
+ * tool ran in is therefore a no-op today and the ADR 0009 shape for the
34
+ * hypothetical relative spelling.
35
+ *
36
+ * Capturing the path REQUIRES matching the gutter: the pre-#3285 regex
37
+ * `/^(.+?):(\d+):(\d+)$/` captured ` ┌─ /abs/proj/src/app.gleam`, gutter
38
+ * included, and only an `endsWith` compare tolerated that. An equality
39
+ * predicate (the seam every other runner now uses) drops every gleam diagnostic
40
+ * unless the gutter is out of the capture — which is why PR #3284 reverted its
41
+ * own fold here and filed #3285.
42
+ */
43
+ const GLEAM_LOCUS = /^\s*┌─\s+(.+?):(\d+):(\d+)$/;
44
+ const GLEAM_HEADER = /^\s*(error|warning):\s*(.+?)\s*$/;
45
+ const GLEAM_LABEL = /^\s*│\s*\^+\s*(.*?)\s*$/;
46
+ /**
47
+ * codespan writes ONE title line per diagnostic and then everything that
48
+ * belongs to it — locus, borders, snippet, labels, notes — before the next
49
+ * title (`codespan-reporting` 0.13.1 `src/term/renderer.rs:141-210`). Splitting
50
+ * on the title is therefore the diagnostic boundary, and it is what keeps an
51
+ * adjacent diagnostic's label out of its predecessor's message. Lines before
52
+ * the first title are gleam's own progress output (`Compiling demo`), which
53
+ * belongs to no diagnostic.
54
+ */
55
+ function gleamBlocks(lines) {
56
+ const blocks = [];
57
+ for (const line of lines) {
58
+ const header = line.match(GLEAM_HEADER);
59
+ if (header)
60
+ blocks.push({ header, body: [] });
61
+ else
62
+ blocks.at(-1)?.body.push(line);
63
+ }
64
+ return blocks;
65
+ }
66
+ function gleamMessage(title, body) {
67
+ const labels = body.flatMap((line) => line.match(GLEAM_LABEL)?.[1]?.trim() || []);
68
+ return labels.length > 0 ? `${title} — ${labels.join(" ")}` : title;
69
+ }
70
+ function parseGleamOutput(raw, filePath, cwd) {
7
71
  const diagnostics = [];
8
- const lines = raw.split(/\r?\n/);
9
- for (let i = 0; i < lines.length; i++) {
10
- const location = lines[i].match(/^(.+?):(\d+):(\d+)$/);
72
+ const absTarget = path.resolve(cwd, filePath);
73
+ // `stripAnsi` (the seam `ruff.ts`, `go-vet.ts` and `rust-clippy.ts` already
74
+ // use): gleam colours the gutter whenever `FORCE_COLOR` is set non-empty,
75
+ // whatever stderr is (`compiler-cli/src/cli.rs:194-207` — `ColorChoice::Never`
76
+ // only when it is neither forced nor a terminal), and the SGR bytes sit
77
+ // between the line start and `┌─`. The old suffix compare never saw the
78
+ // prefix; an anchored capture would drop every diagnostic in that
79
+ // environment.
80
+ const lines = stripAnsi(raw).split(/\r?\n/);
81
+ for (const { header, body } of gleamBlocks(lines)) {
82
+ const severity = header[1] === "warning" ? "warning" : "error";
83
+ const title = `${header[1]}: ${header[2]}`;
84
+ // A Gleam diagnostic with NO locus describes the whole project: `check`
85
+ // receives no file argument (`Diagnostic::write_title`, no codespan emit).
86
+ // Leave it for the nonzero-without-diagnostics fallback instead of
87
+ // charging it to whichever file happened to trigger dispatch — ADR 0009:
88
+ // without a reported path there is no evidence it is about this file.
89
+ const location = body
90
+ .find((line) => GLEAM_LOCUS.test(line))
91
+ ?.match(GLEAM_LOCUS);
11
92
  if (!location)
12
93
  continue;
13
94
  const [, sourcePath, lineStr, colStr] = location;
14
- if (!sourcePath.replace(/\\/g, "/").endsWith(filePath.replace(/\\/g, "/"))) {
95
+ // #3278: one seam for reported-path attribution — see javac.ts.
96
+ if (!pathsEqual(path.resolve(cwd, sourcePath.trim()), absTarget))
15
97
  continue;
16
- }
17
- const message = lines.slice(i + 1).find((line) => line.trim().length > 0);
18
98
  diagnostics.push({
19
99
  id: `gleam-check-${lineStr}-${colStr}`,
20
- message: message?.trim() || "gleam check reported an error",
100
+ message: gleamMessage(title, body),
21
101
  filePath,
22
102
  line: Number.parseInt(lineStr, 10) || 1,
23
103
  column: Number.parseInt(colStr, 10) || 1,
24
- severity: "error",
25
- semantic: "blocking",
104
+ severity,
105
+ semantic: severity === "error" ? "blocking" : "warning",
26
106
  tool: "gleam",
27
107
  rule: "gleam-check",
28
108
  fixable: false,
@@ -54,7 +134,7 @@ const gleamCheckRunner = {
54
134
  if (result.error && !result.stdout && !result.stderr) {
55
135
  return { status: "skipped", diagnostics: [], semantic: "none" };
56
136
  }
57
- const diagnostics = parseGleamOutput(`${result.stderr || ""}\n${result.stdout || ""}`, ctx.filePath);
137
+ const diagnostics = parseGleamOutput(`${result.stderr || ""}\n${result.stdout || ""}`, ctx.filePath, cwd);
58
138
  if (diagnostics.length === 0) {
59
139
  if (result.status && result.status !== 0) {
60
140
  return {
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import { relative, resolve, sep, posix } from "node:path";
7
7
  import { goClient } from "../../go-client.js";
8
+ import { pathsEqual } from "../../path-utils.js";
8
9
  import { safeSpawnAsync } from "../../safe-spawn.js";
9
10
  import { resolveRunnerCwd } from "../../tool-cwd.js";
10
11
  import { stripAnsi } from "../../sanitize.js";
@@ -71,15 +72,28 @@ const goVetRunner = {
71
72
  // cwd before comparing, so go's path FORM (a leading `./` for the
72
73
  // module-root package, an absolute path, `.` segments) can't cause the
73
74
  // edited file's OWN diagnostics to be silently dropped.
75
+ //
76
+ // #3277: resolving is not enough — the two sides are SPELLED by
77
+ // different actors. `ctx.filePath` is `normalizeMapKey`-canonical
78
+ // (#2016) while go prints its own spelling: `cmd/go` rewrites the
79
+ // package-dir prefix to a cwd-relative one only when the rewrite
80
+ // matches byte-for-byte (`replacePrefix`), even though the check that
81
+ // authorized it (`filepath.Rel`) compares case-insensitively on
82
+ // Windows, so a differently-cased ABSOLUTE path reaches this filter.
83
+ // A bare `===` then drops every line for the edited file and the run
84
+ // is reported clean — the #209 defect. `pathsEqual` asks the
85
+ // filesystem instead of a platform list, so it is also right on a
86
+ // case-folding POSIX mount. It does NOT resolve symlinks on POSIX
87
+ // (`clients/path-utils.ts:181`); nothing here relies on that.
74
88
  const absTarget = resolve(ctx.filePath);
75
89
  const relevant = raw
76
90
  .split("\n")
77
91
  .filter((line) => {
78
92
  const m = line.match(/^(.+?):(\d+):(\d+):\s*(.+)/);
79
- return m != null && resolve(cwd, m[1].trim()) === absTarget;
93
+ return m != null && pathsEqual(resolve(cwd, m[1].trim()), absTarget);
80
94
  })
81
95
  .join("\n");
82
- const diagnostics = parseGoVetOutput(relevant, ctx.filePath);
96
+ const diagnostics = parseGoVetOutput(relevant, ctx.filePath, cwd);
83
97
  // Edited file clean → succeeded: a sibling-file error no longer flags
84
98
  // the edited file's turn (it surfaces when that file is itself edited).
85
99
  return diagnostics.length > 0
@@ -11,10 +11,12 @@
11
11
  */
12
12
  import * as path from "node:path";
13
13
  import { safeSpawnAsync } from "../../safe-spawn.js";
14
+ import { pathsEqual } from "../../path-utils.js";
14
15
  import { resolveRunnerCwd } from "../../tool-cwd.js";
15
16
  import { getLinterPolicyForCwd, hasGolangciConfig } from "../../tool-policy.js";
16
17
  import { PRIORITY } from "../priorities.js";
17
18
  import { createAvailabilityChecker, resolveAvailableOrInstall, } from "./utils/runner-helpers.js";
19
+ import { finishParsedRun, parseToolRun } from "./utils/tool-failure.js";
18
20
  const golangci = createAvailabilityChecker("golangci-lint", ".exe");
19
21
  /**
20
22
  * Summarize a golangci-lint Replacement into a single-line fixSuggestion.
@@ -39,13 +41,21 @@ function describeReplacement(replacement) {
39
41
  }
40
42
  return "Apply golangci-lint suggested fix";
41
43
  }
42
- function parseGolangciJson(raw, filePath) {
44
+ function parseGolangciJson(raw, filePath, cwd) {
43
45
  try {
44
46
  const output = JSON.parse(raw);
45
47
  if (!output.Issues)
46
48
  return [];
47
- const absFile = path.resolve(filePath);
48
- return output.Issues.filter((issue) => path.resolve(issue.Pos.Filename) === absFile).map((issue) => {
49
+ const absFile = path.resolve(cwd, filePath);
50
+ // #3278: one seam for reported-path attribution — see javac.ts. The base
51
+ // matters here more than anywhere else in the family: golangci-lint's
52
+ // `PathPrettifier` OVERWRITES `Pos.Filename` with `filepath.Rel(basePath,
53
+ // …)` before the JSON printer sees it (v1.64.8,
54
+ // `pkg/result/processors/path_prettifier.go:31` +
55
+ // `path_relativity.go:43`), so the spelling is relative to the child's
56
+ // base path — the `cwd` we spawned it in — and `path.resolve` with no
57
+ // base resolved it against the EXTENSION's cwd instead.
58
+ return output.Issues.filter((issue) => pathsEqual(path.resolve(cwd, issue.Pos.Filename), absFile)).map((issue) => {
49
59
  const severity = issue.Severity === "error" ? "error" : "warning";
50
60
  // golangci-lint's --out-format=json emits a Replacement object per
51
61
  // issue when `golangci-lint run --fix` would deterministically
@@ -95,26 +105,31 @@ const golangciRunner = {
95
105
  return { status: "skipped", diagnostics: [], semantic: "none" };
96
106
  // Run on the specific file. golangci-lint accepts file paths directly.
97
107
  const result = await safeSpawnAsync(cmd, ["run", "--out-format=json", ctx.filePath], { timeout: 60000, cwd });
98
- if (result.status === 0) {
99
- return { status: "succeeded", diagnostics: [], semantic: "none" };
100
- }
101
- const diagnostics = parseGolangciJson(result.stdout, ctx.filePath);
102
- let semantic = "none";
103
- if (diagnostics.some((d) => d.semantic === "blocking")) {
104
- semantic = "blocking";
105
- }
106
- else if (diagnostics.length > 0) {
107
- semantic = "warning";
108
- }
109
- if (semantic === "none") {
110
- // Non-zero exit but no parseable issues — likely a config/tool error
111
- return { status: "skipped", diagnostics: [], semantic };
112
- }
113
- return {
114
- status: semantic === "blocking" ? "failed" : "succeeded",
115
- diagnostics,
116
- semantic,
117
- };
108
+ // Exit table: 0 is clean-or-findings; 1 means issues, 3 failure,
109
+ // 4 timeout, and 5 missing config. Every nonzero code is a ran outcome
110
+ // whose parser decides: JSON issues remain findings, while emitted
111
+ // non-JSON text becomes a parse-error diagnostic and no output stays
112
+ // skipped. Keep stderr as the parse input when stdout is absent so tool
113
+ // errors cannot be mistaken for a clean file.
114
+ const raw = (result.stdout ?? "").length > 0
115
+ ? (result.stdout ?? "")
116
+ : result.status !== 0
117
+ ? result.stderr || ""
118
+ : "";
119
+ const parsed = parseToolRun("golangci-lint", {
120
+ result,
121
+ output: raw,
122
+ // EXIT TABLE (golangci-lint 1.60 docs https://golangci-lint.run/docs/welcome/quick-start/): 0 clean; 1 findings; 2 error; 3 error; 4 error; 5 error; other nonzero rejected.
123
+ exitCodes: { ran: [1, 2, 3, 4, 5] },
124
+ }, (raw) => parseGolangciJson(raw, ctx.filePath, cwd));
125
+ if (parsed.skipped)
126
+ return parsed.skipped;
127
+ return finishParsedRun({
128
+ tool: "golangci-lint",
129
+ ctx,
130
+ result,
131
+ diagnostics: parsed.diagnostics,
132
+ });
118
133
  },
119
134
  };
120
135
  export default golangciRunner;
@@ -1,4 +1,5 @@
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 { getLinterPolicyForCwd } from "../../tool-policy.js";
@@ -7,25 +8,32 @@ import { createAvailabilityChecker, resolveToolCommandWithInstallFallback, } fro
7
8
  import { parseToolRun } from "./utils/tool-failure.js";
8
9
  import { finishParsedRun } from "./utils/tool-failure.js";
9
10
  const hadolint = createAvailabilityChecker("hadolint", ".exe");
10
- function parseHadolintOutput(raw, filePath) {
11
+ function parseHadolintOutput(raw, filePath, cwd) {
11
12
  try {
12
13
  const parsed = JSON.parse(raw);
13
14
  if (!Array.isArray(parsed))
14
15
  return [];
15
- return parsed.map((item) => {
16
+ const absTarget = path.resolve(cwd, filePath);
17
+ return parsed.flatMap((item) => {
18
+ // #3295: hadolint takes a LIST of Dockerfiles and names each finding's
19
+ // own `file`.
20
+ if (item.file && !pathsEqual(path.resolve(cwd, item.file), absTarget))
21
+ return [];
16
22
  const severity = item.level === "error" ? "error" : "warning";
17
- return {
18
- id: `hadolint-${item.code}-${item.line}`,
19
- message: `[${item.code}] ${item.message}`,
20
- filePath,
21
- line: item.line,
22
- column: item.column ?? 1,
23
- severity,
24
- semantic: severity === "error" ? "blocking" : "warning",
25
- tool: "hadolint",
26
- rule: item.code,
27
- fixable: false,
28
- };
23
+ return [
24
+ {
25
+ id: `hadolint-${item.code}-${item.line}`,
26
+ message: `[${item.code}] ${item.message}`,
27
+ filePath,
28
+ line: item.line,
29
+ column: item.column ?? 1,
30
+ severity,
31
+ semantic: severity === "error" ? "blocking" : "warning",
32
+ tool: "hadolint",
33
+ rule: item.code,
34
+ fixable: false,
35
+ },
36
+ ];
29
37
  });
30
38
  }
31
39
  catch {
@@ -57,7 +65,11 @@ const hadolintRunner = {
57
65
  // #1948: hadolint runs with `--no-fail`, so it exits 0 even when it finds
58
66
  // something. A nonzero exit therefore means hadolint itself failed, and
59
67
  // zero parsed diagnostics out of whatever it printed is a parser break.
60
- const run = parseToolRun("hadolint", { result }, (out) => parseHadolintOutput(out, ctx.filePath));
68
+ const run = parseToolRun("hadolint", {
69
+ result,
70
+ // EXIT TABLE (Hadolint 2.12 measured fixture): 0 clean; 1 findings; 2 error; other nonzero rejected.
71
+ exitCodes: { ran: [1, 2] },
72
+ }, (out) => parseHadolintOutput(out, ctx.filePath, cwd));
61
73
  if (run.skipped)
62
74
  return run.skipped;
63
75
  const diagnostics = run.diagnostics;
@@ -1,4 +1,5 @@
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 { getLinterPolicyForCwd } from "../../tool-policy.js";
@@ -15,19 +16,22 @@ const HTMLHINT_RULES = {
15
16
  "spec-char-escape": true,
16
17
  "id-unique": true,
17
18
  };
18
- function parseHtmlhintOutput(raw, filePath) {
19
+ function parseHtmlhintOutput(raw, filePath, cwd) {
19
20
  const diagnostics = [];
21
+ const absTarget = path.resolve(cwd, filePath);
20
22
  // unix format: "file:line:col: message [severity/rule]"
21
- const lineRe = /^.+?:(\d+):(\d+): (.+?) \[(error|warning)\/([^\]]+)\]/;
23
+ const lineRe = /^(.+?):(\d+):(\d+): (.+?) \[(error|warning)\/([^\]]+)\]/;
22
24
  for (const line of raw.split("\n")) {
23
25
  const match = line.match(lineRe);
24
26
  if (!match)
25
27
  continue;
26
- const lineNum = parseInt(match[1], 10);
27
- const col = parseInt(match[2], 10);
28
- const message = match[3].trim();
29
- const level = match[4];
30
- const rule = match[5].trim();
28
+ if (!pathsEqual(path.resolve(cwd, match[1]), absTarget))
29
+ continue;
30
+ const lineNum = parseInt(match[2], 10);
31
+ const col = parseInt(match[3], 10);
32
+ const message = match[4].trim();
33
+ const level = match[5];
34
+ const rule = match[6].trim();
31
35
  const severity = level === "error" ? "error" : "warning";
32
36
  diagnostics.push({
33
37
  id: `htmlhint-${rule}-${lineNum}`,
@@ -86,7 +90,12 @@ const htmlhintRunner = {
86
90
  // #1948: htmlhint exits 1 when it finds errors and prints them in `unix`
87
91
  // format on stdout. Zero parsed out of a nonzero exit is a parser break.
88
92
  const output = result.stdout || result.stderr || "";
89
- const run = parseToolRun("htmlhint", { result, output }, (out) => parseHtmlhintOutput(out, ctx.filePath));
93
+ const run = parseToolRun("htmlhint", {
94
+ result,
95
+ output,
96
+ // EXIT TABLE (HTMLHint 1.1 measured fixture): 0 clean; 1 findings; 2 error; other nonzero rejected.
97
+ exitCodes: { ran: [1, 2] },
98
+ }, (out) => parseHtmlhintOutput(out, ctx.filePath, cwd));
90
99
  if (run.skipped)
91
100
  return run.skipped;
92
101
  const diagnostics = run.diagnostics;