@herbertgao/pi-extensions 2026.8.7 → 2026.8.8

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 (99) hide show
  1. package/README.md +7 -5
  2. package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +1 -1
  3. package/node_modules/@herbertgao/pi-cc-extensions/README.md +1 -1
  4. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/compact-thinking.ts +63 -15
  5. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/message-display.ts +78 -16
  6. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/hover.ts +13 -0
  7. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/interaction.ts +17 -6
  8. package/node_modules/@herbertgao/pi-cc-extensions/package.json +3 -3
  9. package/node_modules/@juicesharp/rpiv-ask-user-question/README.md +4 -4
  10. package/node_modules/@juicesharp/rpiv-ask-user-question/ask-user-question.ts +154 -96
  11. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/hosts.md +3 -0
  12. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/keyboard.md +10 -2
  13. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/localization.md +7 -5
  14. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/tool-schema.md +11 -5
  15. package/node_modules/@juicesharp/rpiv-ask-user-question/locales/en.json +3 -0
  16. package/node_modules/@juicesharp/rpiv-ask-user-question/package.json +2 -2
  17. package/node_modules/@juicesharp/rpiv-ask-user-question/state/key-router.ts +137 -96
  18. package/node_modules/@juicesharp/rpiv-ask-user-question/state/questionnaire-session.ts +44 -32
  19. package/node_modules/@juicesharp/rpiv-ask-user-question/state/selectors/projections.ts +22 -15
  20. package/node_modules/@juicesharp/rpiv-ask-user-question/state/state-reducer.ts +11 -1
  21. package/node_modules/@juicesharp/rpiv-ask-user-question/tool/response-envelope.ts +11 -0
  22. package/node_modules/@juicesharp/rpiv-ask-user-question/tool/types.ts +11 -0
  23. package/node_modules/@juicesharp/rpiv-ask-user-question/view/components/multi-select-view.ts +31 -17
  24. package/node_modules/@juicesharp/rpiv-ask-user-question/view/components/preview/preview-block-renderer.ts +15 -6
  25. package/node_modules/@juicesharp/rpiv-ask-user-question/view/components/wrapping-select.ts +15 -6
  26. package/node_modules/@juicesharp/rpiv-ask-user-question/view/dialog-builder.ts +56 -34
  27. package/node_modules/@juicesharp/rpiv-ask-user-question/view/tab-content-strategy.ts +50 -4
  28. package/node_modules/@narumitw/pi-btw/package.json +2 -2
  29. package/node_modules/@pi-plugins/fast-mode/dist/index.mjs +44 -1856
  30. package/node_modules/@pi-plugins/fast-mode/dist/index.mjs.map +1 -1
  31. package/node_modules/@pi-plugins/fast-mode/package.json +4 -6
  32. package/node_modules/pi-lens/CHANGELOG.md +90 -0
  33. package/node_modules/pi-lens/README.md +106 -0
  34. package/node_modules/pi-lens/dist/clients/bash-file-access.js +97 -6
  35. package/node_modules/pi-lens/dist/clients/blocker-past-eof.js +16 -6
  36. package/node_modules/pi-lens/dist/clients/bounded-telemetry.js +14 -0
  37. package/node_modules/pi-lens/dist/clients/cache-observability.js +353 -76
  38. package/node_modules/pi-lens/dist/clients/degradation-ledger.js +22 -1
  39. package/node_modules/pi-lens/dist/clients/demoted-finding-render.js +155 -0
  40. package/node_modules/pi-lens/dist/clients/deps/pi-tui.js +8 -2
  41. package/node_modules/pi-lens/dist/clients/deps/typebox.js +8 -3
  42. package/node_modules/pi-lens/dist/clients/dispatch/runners/detekt.js +9 -6
  43. package/node_modules/pi-lens/dist/clients/dispatch/runners/hadolint.js +9 -7
  44. package/node_modules/pi-lens/dist/clients/dispatch/runners/htmlhint.js +8 -6
  45. package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +8 -10
  46. package/node_modules/pi-lens/dist/clients/dispatch/runners/mypy.js +10 -10
  47. package/node_modules/pi-lens/dist/clients/dispatch/runners/oxlint.js +20 -5
  48. package/node_modules/pi-lens/dist/clients/dispatch/runners/phpstan.js +44 -11
  49. package/node_modules/pi-lens/dist/clients/dispatch/runners/spellcheck.js +10 -15
  50. package/node_modules/pi-lens/dist/clients/dispatch/runners/sqlfluff.js +19 -11
  51. package/node_modules/pi-lens/dist/clients/dispatch/runners/stylelint.js +35 -13
  52. package/node_modules/pi-lens/dist/clients/dispatch/runners/swiftlint.js +8 -8
  53. package/node_modules/pi-lens/dist/clients/dispatch/runners/taplo.js +110 -23
  54. package/node_modules/pi-lens/dist/clients/dispatch/runners/tflint.js +12 -7
  55. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/tool-failure.js +117 -1
  56. package/node_modules/pi-lens/dist/clients/dispatch/runners/vale.js +16 -14
  57. package/node_modules/pi-lens/dist/clients/dispatch/runners/yamllint.js +8 -9
  58. package/node_modules/pi-lens/dist/clients/finding-delivery-gate.js +22 -0
  59. package/node_modules/pi-lens/dist/clients/jscpd-client.js +5 -2
  60. package/node_modules/pi-lens/dist/clients/language-profile.js +7 -2
  61. package/node_modules/pi-lens/dist/clients/lsp/cascade-tier.js +27 -2
  62. package/node_modules/pi-lens/dist/clients/lsp/client.js +144 -5
  63. package/node_modules/pi-lens/dist/clients/lsp/index.js +151 -6
  64. package/node_modules/pi-lens/dist/clients/module-report.js +14 -1
  65. package/node_modules/pi-lens/dist/clients/ndjson-logger.js +114 -23
  66. package/node_modules/pi-lens/dist/clients/project-diagnostics/scanner.js +18 -9
  67. package/node_modules/pi-lens/dist/clients/project-report.js +62 -27
  68. package/node_modules/pi-lens/dist/clients/read-guard-logger.js +7 -2
  69. package/node_modules/pi-lens/dist/clients/read-guard.js +88 -19
  70. package/node_modules/pi-lens/dist/clients/review-graph/builder.js +194 -31
  71. package/node_modules/pi-lens/dist/clients/review-graph/revision-drift.js +21 -0
  72. package/node_modules/pi-lens/dist/clients/review-graph/workspace-modules.js +6 -3
  73. package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +44 -0
  74. package/node_modules/pi-lens/dist/clients/runtime-session.js +41 -0
  75. package/node_modules/pi-lens/dist/clients/runtime-turn.js +47 -2
  76. package/node_modules/pi-lens/dist/clients/session-event-guard.js +184 -0
  77. package/node_modules/pi-lens/dist/clients/source-filter.js +6 -2
  78. package/node_modules/pi-lens/dist/clients/startup-scan.js +5 -2
  79. package/node_modules/pi-lens/dist/clients/tree-sitter-logger.js +1 -0
  80. package/node_modules/pi-lens/dist/index.js +1224 -393
  81. package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +10 -3
  82. package/node_modules/pi-lens/package.json +18 -5
  83. package/node_modules/pi-lens/scripts/install-selftest.mjs +99 -11
  84. package/node_modules/pi-lens/scripts/lib/host-provided-deps.mjs +75 -0
  85. package/node_modules/pi-lens/scripts/lib/warm-loader-cache.mjs +285 -0
  86. package/node_modules/pi-lens/scripts/warm-loader-cache.mjs +233 -0
  87. package/node_modules/pi-web-access/CHANGELOG.md +14 -0
  88. package/node_modules/pi-web-access/README.md +6 -5
  89. package/node_modules/pi-web-access/chrome-cookies.ts +86 -15
  90. package/node_modules/pi-web-access/data-uri-sanitize.ts +406 -0
  91. package/node_modules/pi-web-access/extract.ts +12 -2
  92. package/node_modules/pi-web-access/firecrawl.ts +18 -2
  93. package/node_modules/pi-web-access/github-extract.ts +65 -36
  94. package/node_modules/pi-web-access/index.ts +19 -4
  95. package/node_modules/pi-web-access/openai-search.ts +17 -6
  96. package/node_modules/pi-web-access/package.json +1 -1
  97. package/node_modules/pi-web-access/pdf-extract.ts +18 -5
  98. package/node_modules/pi-web-access/ssrf-protection.ts +12 -2
  99. package/package.json +9 -9
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Body degradation for a DEMOTED finding (#1944).
3
+ *
4
+ * The demotion gates (#1631 dependency-drift, #1641 past-EOF) move a record
5
+ * out of the authoritative blocker channel and into the advisory channel.
6
+ * Until this module existed they changed the CHANNEL and nothing else: the
7
+ * advisory embedded the blocker body verbatim, so the agent still read
8
+ * "🔴 STOP — 11 issue(s) must be fixed" with dead line numbers under two
9
+ * hedge lines it ignored. Live evidence (session 01a0234c, 2026-08-21): the
10
+ * agent spent four re-run detours and about 7.4k tokens on a record whose
11
+ * coordinates no longer existed, then explained an unrelated REAL blocker
12
+ * away as "mid-edit state" in the same session.
13
+ *
14
+ * The rule this module owns, once, for every gated surface: a demoted body
15
+ * loses its authority vocabulary and its dead coordinates, and it says so in
16
+ * its own words. `clients/finding-delivery-gate.ts` documents the surrounding
17
+ * contract; this is the one implementation of its "Demotion rendering" arm
18
+ * for surfaces that carry a RENDERED BODY rather than a per-diagnostic row.
19
+ * Per-row surfaces (`widget-state.ts`, `tools/lens-diagnostics.ts`) already
20
+ * swap the coordinate for `PAST_EOF_STALE_MARKER` at render time and have no
21
+ * body to degrade.
22
+ *
23
+ * Pure and leaf: no store, no coordinator, no I/O. The caller supplies the
24
+ * dead lines it already computed while deciding to demote, so this module
25
+ * never re-derives a verdict from rendered prose (the
26
+ * re-derivation-vs-correlation screen).
27
+ */
28
+ /** Suffix that replaces a cited coordinate the file no longer has. */
29
+ export const DEAD_LINE_ANNOTATION = "(line no longer exists)";
30
+ /**
31
+ * Authority vocabulary a demoted body must not carry, with its degraded
32
+ * replacement. Ordered: the full banner rewrite runs first so the narrower
33
+ * fallbacks only ever see text the banner rule did not already cover.
34
+ *
35
+ * This table is the single source of truth for the rule. A surface must not
36
+ * hand-roll its own "strip the STOP" pass — that is how #1631, #1641, and
37
+ * #1664 each ended up with a different answer to the same question.
38
+ *
39
+ * POSITION-ANCHORED (#1944 review F1). These patterns run over the BANNER
40
+ * region only, never the whole body. Diagnostic messages are arbitrary text
41
+ * from arbitrary runners, and they legitimately contain both tokens: the
42
+ * reviewer's probe turned ts(2304) `Cannot find name 'STOP'` into `Cannot
43
+ * find name 'Previously flagged'`, and mangled an English "must be fixed"
44
+ * inside a rule message. Rewriting a finding's own words is a worse defect
45
+ * than the banner it was cleaning up. See `splitBannerRegion`.
46
+ */
47
+ const AUTHORITY_REWRITES = [
48
+ [
49
+ /[^\S\n]*(?:🔴|⛔|❌)?[^\S\n]*STOP[^\S\n]*[—–-][^\S\n]*(\d+)[^\S\n]+issue\(s\)[^\S\n]+must be fixed:?/g,
50
+ "$1 issue(s) were flagged before this file changed; the coordinates below may no longer exist:",
51
+ ],
52
+ [/(?:🔴|⛔|❌)[^\S\n]*STOP\b[^\S\n]*[—–-]?/g, "Previously flagged —"],
53
+ [/\bSTOP\b/g, "Previously flagged"],
54
+ [/\bmust be fixed\b/g, "were flagged"],
55
+ ];
56
+ /** ` L310: message` / `L310 message` at the head of a rendered row. */
57
+ const CITED_LINE_RE = /^([^\S\n]*)L(\d+)\b/;
58
+ /**
59
+ * Split a rendered body into its BANNER region and its CONTENT region
60
+ * (#1944 review F1).
61
+ *
62
+ * The body this module degrades is `InlineBlockerRecord.summary`, and its one
63
+ * producer is `formatDiagnostics(inlineBlockers, "blocking")`
64
+ * (`clients/dispatch/utils/format-utils.ts`), reaching the store as
65
+ * `dispatchResult.blockerOutput` -> `PipelineResult.inlineBlockerSummary` ->
66
+ * `recordInlineBlockers`. (`buildEnrichedBlockerOutput` in
67
+ * `clients/pipeline.ts` renders the same shape with code snippets, but it
68
+ * feeds the TOOL-RESULT output shown inline during the write, not this
69
+ * stored summary. #1944 review round: naming it here as a producer was
70
+ * wrong.) The shape is a header, then one ` L<n>: <message>` row per
71
+ * diagnostic, then indented continuations (`💡 Fix: …`) and an optional
72
+ * `… and N more` tail. So the first cited-line row is the boundary:
73
+ * everything above it is ours to rewrite, everything from it down is the
74
+ * findings' own words.
75
+ *
76
+ * A body with no cited row at all — `formatDiagnostics` omits `L<n>` for a
77
+ * lineless diagnostic — falls back to the first NON-BLANK line. Leading
78
+ * blanks must be skipped rather than counted (#1944 verify round, Edge-D):
79
+ * `formatDiagnostics` opens its output with a newline, so a naive "first
80
+ * line is the banner" rule degrades an empty string and leaves the real
81
+ * header at full authority. Production is currently saved from that only by
82
+ * an undocumented `.trim()` at `clients/pipeline.ts` (`inlineBlockerSummary`),
83
+ * and this module claims to own the rule, so it must hold on its own input.
84
+ */
85
+ function splitBannerRegion(lines) {
86
+ const firstCitedRow = lines.findIndex((line) => CITED_LINE_RE.test(line));
87
+ if (firstCitedRow !== -1)
88
+ return { bannerEnd: firstCitedRow };
89
+ const firstNonBlank = lines.findIndex((line) => line.trim() !== "");
90
+ return { bannerEnd: firstNonBlank === -1 ? 0 : firstNonBlank + 1 };
91
+ }
92
+ /**
93
+ * Degrade one demoted finding's rendered body.
94
+ *
95
+ * Two independent transforms, deliberately separable so each can be proven by
96
+ * its own test (and so deleting either one reds a different assertion):
97
+ *
98
+ * 1. Authority vocabulary: the STOP banner and the "must be fixed"
99
+ * imperative are rewritten to past-tense, non-directive wording.
100
+ * 2. Dead coordinates: every cited `L<n>` whose line the file no longer has
101
+ * renders as `L<n> (line no longer exists)`. The message text stays —
102
+ * demote, never drop (#1419).
103
+ */
104
+ export function degradeDemotedFindingBody(summary, options = {}) {
105
+ const deadLines = new Set(options.deadLines ?? []);
106
+ let authorityMarkersRemoved = 0;
107
+ const sourceLines = summary.split("\n");
108
+ const { bannerEnd } = splitBannerRegion(sourceLines);
109
+ const degradedLines = sourceLines.map((line, index) => {
110
+ // Content rows keep every character. Only the banner region is ours.
111
+ if (index >= bannerEnd)
112
+ return line;
113
+ let rewritten = line;
114
+ for (const [pattern, replacement] of AUTHORITY_REWRITES) {
115
+ rewritten = rewritten.replace(pattern, (...args) => {
116
+ authorityMarkersRemoved += 1;
117
+ // Expand `$n` against this match's capture groups by hand. A nested
118
+ // `String.replace` with the SAME global regex would clobber the
119
+ // outer call's `lastIndex` mid-iteration.
120
+ return replacement.replace(/\$(\d)/g, (_, digit) => String(args[Number(digit)] ?? ""));
121
+ });
122
+ }
123
+ return rewritten;
124
+ });
125
+ let body = degradedLines.join("\n");
126
+ const deadLinesAnnotated = [];
127
+ if (deadLines.size > 0) {
128
+ body = degradedLines
129
+ .map((line) => {
130
+ const hit = CITED_LINE_RE.exec(line);
131
+ if (!hit)
132
+ return line;
133
+ const cited = Number.parseInt(hit[2] ?? "", 10);
134
+ if (!Number.isFinite(cited) || !deadLines.has(cited))
135
+ return line;
136
+ deadLinesAnnotated.push(cited);
137
+ return `${hit[1] ?? ""}L${cited} ${DEAD_LINE_ANNOTATION}${line.slice(hit[0].length)}`;
138
+ })
139
+ .join("\n");
140
+ }
141
+ return { body, authorityMarkersRemoved, deadLinesAnnotated };
142
+ }
143
+ /**
144
+ * The one-line note a surface appends when it RETIRES a demoted finding after
145
+ * its single degraded delivery. Rendered inside the advisory itself, so an
146
+ * empty advisory section can only ever mean "nothing to say" — if something
147
+ * was dropped, this line is in the payload (#1944 AC3).
148
+ */
149
+ export function formatRetirementNote(deadLines) {
150
+ const cited = deadLines.length > 0
151
+ ? ` (cited ${deadLines.length === 1 ? "line" : "lines"} ${deadLines.join(", ")})`
152
+ : "";
153
+ return (`Retired after this delivery${cited}: the file no longer has these lines, ` +
154
+ "so this finding cannot be re-confirmed and will not be served again.");
155
+ }
@@ -1,6 +1,12 @@
1
1
  /**
2
- * Centralized accessor for `@earendil-works/pi-tui`. See ./typescript.ts for the
3
- * rationale. (pi-tui is a pi-bundled core package, host-provided at runtime.)
2
+ * Centralized accessor for `@earendil-works/pi-tui`, routed through here for a
3
+ * uniform dep surface. pi-tui is a pi-bundled core package: the host resolves
4
+ * the bare specifier from its own runtime, so pi-lens declares it as an OPTIONAL
5
+ * peer plus a devDependency and never as a runtime dependency. A runtime
6
+ * dependency makes `npm install --omit=dev` vendor a private second copy that
7
+ * Node evaluates at import — ~97ms of the 838ms git-install module import in
8
+ * #1926. `scripts/lib/host-provided-deps.mjs` holds the list, and
9
+ * `tests/packaging.test.ts` pins the declaration.
4
10
  *
5
11
  * Re-export named bindings, not `export *`: with the package kept external, a
6
12
  * wildcard re-export leaves the namespace undefined at runtime under the bundle.
@@ -1,7 +1,12 @@
1
1
  /**
2
- * Centralized accessor for `typebox`. See ./typescript.ts for the rationale.
3
- * (typebox is a pi-bundled core package, so it resolves from the host at
4
- * runtime -- but it's still routed through here for a uniform dep surface.)
2
+ * Centralized accessor for `typebox`, routed through here for a uniform dep
3
+ * surface. typebox is a pi-bundled core package: the host resolves the bare
4
+ * specifier from its own runtime, so pi-lens declares it as an OPTIONAL peer
5
+ * plus a devDependency and never as a runtime dependency. A runtime dependency
6
+ * makes `npm install --omit=dev` vendor a private second copy that Node
7
+ * evaluates at import — 688 files, ~400ms of the 838ms git-install module
8
+ * import in #1926. `scripts/lib/host-provided-deps.mjs` holds the list, and
9
+ * `tests/packaging.test.ts` pins the declaration.
5
10
  *
6
11
  * Re-export named bindings, not `export *`: with the package kept external, a
7
12
  * wildcard re-export leaves the namespace undefined at runtime under the bundle.
@@ -4,7 +4,7 @@ import { safeSpawnAsync } from "../../safe-spawn.js";
4
4
  import { getLinterPolicyForCwd } from "../../tool-policy.js";
5
5
  import { PRIORITY } from "../priorities.js";
6
6
  import { createAvailabilityChecker } from "./utils/runner-helpers.js";
7
- import { spawnFailedWithNoOutput } from "./utils/spawn-outcome.js";
7
+ import { parseToolRun } from "./utils/tool-failure.js";
8
8
  const detekt = createAvailabilityChecker("detekt", ".bat");
9
9
  const DETEKT_CONFIG_CANDIDATES = [
10
10
  "detekt.yml",
@@ -146,11 +146,14 @@ const detektRunner = {
146
146
  return { status: "skipped", diagnostics: [], semantic: "none" };
147
147
  const absPath = path.resolve(cwd, ctx.filePath);
148
148
  const result = await safeSpawnAsync(cmd, ["--input", absPath, "--config", configPath], { cwd, timeout: 60000 });
149
+ // #1948: detekt reports across both streams, so the gate and the parser
150
+ // read the same concatenated string. A nonzero exit that yields zero
151
+ // findings out of real output is a parser break, not clean Kotlin.
149
152
  const raw = `${result.stdout ?? ""}${result.stderr ?? ""}`;
150
- if (spawnFailedWithNoOutput(result, raw)) {
151
- return { status: "skipped", diagnostics: [], semantic: "none" };
152
- }
153
- const diagnostics = parseDetektOutput(raw, ctx.filePath);
153
+ const run = parseToolRun("detekt", { result, output: raw }, (out) => parseDetektOutput(out, ctx.filePath));
154
+ if (run.skipped)
155
+ return run.skipped;
156
+ const diagnostics = run.diagnostics;
154
157
  if (diagnostics.length === 0) {
155
158
  return { status: "succeeded", diagnostics: [], semantic: "none" };
156
159
  }
@@ -163,4 +166,4 @@ const detektRunner = {
163
166
  },
164
167
  };
165
168
  export default detektRunner;
166
- export { parseDetektOutput, DETEKT_FIXABLE_RULES };
169
+ export { DETEKT_FIXABLE_RULES, parseDetektOutput };
@@ -3,7 +3,7 @@ import { safeSpawnAsync } from "../../safe-spawn.js";
3
3
  import { getLinterPolicyForCwd } from "../../tool-policy.js";
4
4
  import { PRIORITY } from "../priorities.js";
5
5
  import { createAvailabilityChecker, resolveToolCommandWithInstallFallback, } from "./utils/runner-helpers.js";
6
- import { spawnFailedWithNoOutput } from "./utils/spawn-outcome.js";
6
+ import { parseToolRun } from "./utils/tool-failure.js";
7
7
  const hadolint = createAvailabilityChecker("hadolint", ".exe");
8
8
  function parseHadolintOutput(raw, filePath) {
9
9
  try {
@@ -43,7 +43,7 @@ const hadolintRunner = {
43
43
  return { status: "skipped", diagnostics: [], semantic: "none" };
44
44
  }
45
45
  let cmd = null;
46
- if (await (hadolint.isAvailableAsync(cwd))) {
46
+ if (await hadolint.isAvailableAsync(cwd)) {
47
47
  cmd = hadolint.getCommand(cwd);
48
48
  }
49
49
  else {
@@ -53,11 +53,13 @@ const hadolintRunner = {
53
53
  return { status: "skipped", diagnostics: [], semantic: "none" };
54
54
  }
55
55
  const result = await safeSpawnAsync(cmd, ["--format", "json", "--no-fail", path.resolve(cwd, ctx.filePath)], { cwd });
56
- if (spawnFailedWithNoOutput(result)) {
57
- return { status: "skipped", diagnostics: [], semantic: "none" };
58
- }
59
- const output = result.stdout || "";
60
- const diagnostics = parseHadolintOutput(output, ctx.filePath);
56
+ // #1948: hadolint runs with `--no-fail`, so it exits 0 even when it finds
57
+ // something. A nonzero exit therefore means hadolint itself failed, and
58
+ // zero parsed diagnostics out of whatever it printed is a parser break.
59
+ const run = parseToolRun("hadolint", { result }, (out) => parseHadolintOutput(out, ctx.filePath));
60
+ if (run.skipped)
61
+ return run.skipped;
62
+ const diagnostics = run.diagnostics;
61
63
  if (diagnostics.length === 0) {
62
64
  return { status: "succeeded", diagnostics: [], semantic: "none" };
63
65
  }
@@ -3,7 +3,7 @@ import { safeSpawnAsync } from "../../safe-spawn.js";
3
3
  import { getLinterPolicyForCwd } from "../../tool-policy.js";
4
4
  import { PRIORITY } from "../priorities.js";
5
5
  import { createAvailabilityChecker, resolveToolCommandWithInstallFallback, } from "./utils/runner-helpers.js";
6
- import { spawnFailedWithNoOutput } from "./utils/spawn-outcome.js";
6
+ import { parseToolRun } from "./utils/tool-failure.js";
7
7
  const htmlhint = createAvailabilityChecker("htmlhint");
8
8
  const HTMLHINT_RULES = {
9
9
  "tag-pair": true,
@@ -55,7 +55,7 @@ const htmlhintRunner = {
55
55
  return { status: "skipped", diagnostics: [], semantic: "none" };
56
56
  }
57
57
  let cmd = null;
58
- if (await (htmlhint.isAvailableAsync(cwd))) {
58
+ if (await htmlhint.isAvailableAsync(cwd)) {
59
59
  cmd = htmlhint.getCommand(cwd);
60
60
  }
61
61
  else {
@@ -72,11 +72,13 @@ const htmlhintRunner = {
72
72
  "unix",
73
73
  path.resolve(cwd, ctx.filePath),
74
74
  ], { cwd });
75
+ // #1948: htmlhint exits 1 when it finds errors and prints them in `unix`
76
+ // format on stdout. Zero parsed out of a nonzero exit is a parser break.
75
77
  const output = result.stdout || result.stderr || "";
76
- if (spawnFailedWithNoOutput(result, output)) {
77
- return { status: "skipped", diagnostics: [], semantic: "none" };
78
- }
79
- const diagnostics = parseHtmlhintOutput(output, ctx.filePath);
78
+ const run = parseToolRun("htmlhint", { result, output }, (out) => parseHtmlhintOutput(out, ctx.filePath));
79
+ if (run.skipped)
80
+ return run.skipped;
81
+ const diagnostics = run.diagnostics;
80
82
  if (diagnostics.length === 0) {
81
83
  return { status: "succeeded", diagnostics: [], semantic: "none" };
82
84
  }
@@ -2,7 +2,7 @@ import { safeSpawnAsync } from "../../safe-spawn.js";
2
2
  import { getLinterPolicyForCwd, markdownlintConfigArgs, } from "../../tool-policy.js";
3
3
  import { PRIORITY } from "../priorities.js";
4
4
  import { createAvailabilityChecker, resolveToolCommandWithInstallFallback, } from "./utils/runner-helpers.js";
5
- import { skipUnlessToolRan } from "./utils/tool-failure.js";
5
+ import { parseToolRun } from "./utils/tool-failure.js";
6
6
  const markdownlint = createAvailabilityChecker("markdownlint-cli2", ".cmd");
7
7
  // markdownlint-cli2 exit codes (its README's "Exit Codes" section): 0 = no
8
8
  // findings, 1 = lint findings, 2 = unexpected error (unreadable config, bad
@@ -98,7 +98,7 @@ const markdownlintRunner = {
98
98
  return { status: "skipped", diagnostics: [], semantic: "none" };
99
99
  }
100
100
  let cmd = null;
101
- if (await (markdownlint.isAvailableAsync(cwd))) {
101
+ if (await markdownlint.isAvailableAsync(cwd)) {
102
102
  cmd = markdownlint.getCommand(cwd);
103
103
  }
104
104
  else {
@@ -118,14 +118,12 @@ const markdownlintRunner = {
118
118
  // reported as a clean Markdown file. An empty result must distinguish
119
119
  // clean from errored.
120
120
  const raw = `${result.stdout ?? ""}${result.stderr ?? ""}`;
121
- const skipped = skipUnlessToolRan("markdownlint", {
122
- result,
123
- output: raw,
124
- exitCodes: MARKDOWNLINT_EXIT_CODES,
125
- });
126
- if (skipped)
127
- return skipped;
128
- const diagnostics = parseMarkdownlintOutput(raw, ctx.filePath);
121
+ // #1948: one seam for both gates — the tool produced nothing, and the
122
+ // tool produced something the parser could not read.
123
+ const run = parseToolRun("markdownlint", { result, output: raw, exitCodes: MARKDOWNLINT_EXIT_CODES }, (out) => parseMarkdownlintOutput(out, ctx.filePath));
124
+ if (run.skipped)
125
+ return run.skipped;
126
+ const diagnostics = run.diagnostics;
129
127
  if (diagnostics.length === 0) {
130
128
  return { status: "succeeded", diagnostics: [], semantic: "none" };
131
129
  }
@@ -3,7 +3,7 @@ import { safeSpawnAsync } from "../../safe-spawn.js";
3
3
  import { hasMypyConfig } from "../../tool-policy.js";
4
4
  import { PRIORITY } from "../priorities.js";
5
5
  import { createAvailabilityChecker, resolveToolCommandWithInstallFallback, } from "./utils/runner-helpers.js";
6
- import { skipUnlessToolRan } from "./utils/tool-failure.js";
6
+ import { parseToolRun } from "./utils/tool-failure.js";
7
7
  const mypy = createAvailabilityChecker("mypy", "");
8
8
  // mypy exit codes: 0 = no type errors, 1 = type errors found, 2 = a blocking
9
9
  // error. 2 is NOT a rejected invocation on its own — mypy reports a source
@@ -67,7 +67,7 @@ const mypyRunner = {
67
67
  return { status: "skipped", diagnostics: [], semantic: "none" };
68
68
  }
69
69
  let cmd = null;
70
- if (await (mypy.isAvailableAsync(cwd))) {
70
+ if (await mypy.isAvailableAsync(cwd)) {
71
71
  cmd = mypy.getCommand(cwd);
72
72
  }
73
73
  else {
@@ -81,14 +81,14 @@ const mypyRunner = {
81
81
  // stdout only (see MYPY_EXIT_CODES) so an exit-2 SYNTAX error, which
82
82
  // does write a diagnostic there, still reaches the parser.
83
83
  const raw = `${result.stdout ?? ""}${result.stderr ?? ""}`;
84
- const skipped = skipUnlessToolRan("mypy", {
85
- result,
86
- output: result.stdout,
87
- exitCodes: MYPY_EXIT_CODES,
88
- });
89
- if (skipped)
90
- return skipped;
91
- const diagnostics = parseMypyOutput(raw, ctx.filePath, cwd);
84
+ // #1948: `parseOutput` keeps the split this runner already had — the gate
85
+ // judges "did it run" on stdout alone, the parser still reads both
86
+ // streams — so adding the parsed-nothing record does not widen the
87
+ // did-it-run verdict.
88
+ const run = parseToolRun("mypy", { result, output: result.stdout, exitCodes: MYPY_EXIT_CODES }, (out) => parseMypyOutput(out, ctx.filePath, cwd), { parseOutput: raw });
89
+ if (run.skipped)
90
+ return run.skipped;
91
+ const diagnostics = run.diagnostics;
92
92
  if (diagnostics.length === 0) {
93
93
  return { status: "succeeded", diagnostics: [], semantic: "none" };
94
94
  }
@@ -76,10 +76,13 @@ const oxlintRunner = {
76
76
  const result = await safeSpawnAsync(cmd, args, {
77
77
  timeout: 30000,
78
78
  });
79
- // Oxlint returns non-zero when issues found
80
- if (result.status === 0) {
81
- return { status: "succeeded", diagnostics: [], semantic: "none" };
82
- }
79
+ // Oxlint exits 0 whenever nothing at ERROR severity was found — that
80
+ // includes a run that found only warnings, its own default severity
81
+ // (#1947). A run that found nothing at all also exits 0, but still
82
+ // prints a report with an empty `diagnostics` array, so parsing
83
+ // unconditionally and branching on the parsed count (below) tells the
84
+ // two apart instead of the exit code discarding the warning case.
85
+ //
83
86
  // Parse JSON output. Fall back to the unix-format parser if JSON parsing
84
87
  // fails (older oxlint versions, malformed stderr noise, etc.) — keeps the
85
88
  // runner producing diagnostics even when the structured-fix metadata is
@@ -94,8 +97,20 @@ const oxlintRunner = {
94
97
  return { status: "succeeded", diagnostics: [], semantic: "none" };
95
98
  }
96
99
  const hasBlocking = diagnostics.some((d) => d.semantic === "blocking");
100
+ // A warning-only result on exit 0 is oxlint's normal outcome, not a
101
+ // failure: exit 0 means nothing hit ERROR severity. `status: "failed"`
102
+ // here would stop this arm from reporting "succeeded", which breaks two
103
+ // things downstream — plan.ts's ["eslint", "oxlint", "biome-check-json"]
104
+ // fallback group only stops at the first `status: "succeeded"` runner
105
+ // (dispatcher.ts's `runGroup`), so biome-check-json would run again on
106
+ // every warning-only save (extra spawns, a possible install, duplicate
107
+ // findings); and it would mismatch the sibling convention (biome-check,
108
+ // golangci-lint, rubocop) of keying `status` off blocking severity, not
109
+ // off the tool's raw exit code. The findings themselves still reach the
110
+ // delivery pipeline regardless of `status` — dispatcher.ts buckets by
111
+ // each diagnostic's own `semantic`, so a warning stays a warning.
97
112
  return {
98
- status: "failed",
113
+ status: !hasBlocking && result.status === 0 ? "succeeded" : "failed",
99
114
  diagnostics,
100
115
  semantic: hasBlocking ? "blocking" : "warning",
101
116
  };
@@ -3,6 +3,11 @@ 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
+ // phpstan's documented exit codes: 0 = no errors, 1 = errors found, 2 = a
8
+ // fatal/internal error that stopped the analysis. Only 1 is a run that carries
9
+ // findings.
10
+ const PHPSTAN_EXIT_CODES = { ran: [1] };
6
11
  const phpstan = createAvailabilityChecker("phpstan", ".phar");
7
12
  // phpstan analyses the target file PLUS its dependency closure and keys errors
8
13
  // by the real file in `output.files`. Attribute each diagnostic to that key
@@ -19,7 +24,7 @@ export function parsePhpstanJson(raw, fallbackPath, cwd) {
19
24
  ? file
20
25
  : path.resolve(cwd, file)
21
26
  : fallbackPath;
22
- for (const err of fileErrors.errors ?? []) {
27
+ for (const err of fileErrors?.messages ?? []) {
23
28
  diagnostics.push({
24
29
  id: `phpstan:${err.line ?? 1}:${err.message.slice(0, 40)}`,
25
30
  message: err.message,
@@ -29,11 +34,38 @@ export function parsePhpstanJson(raw, fallbackPath, cwd) {
29
34
  severity: "error",
30
35
  semantic: "blocking",
31
36
  tool: "phpstan",
32
- rule: "phpstan",
37
+ rule: err.identifier || "phpstan",
33
38
  fixable: false,
34
39
  });
35
40
  }
36
41
  }
42
+ // #1937 round 2: the top-level `errors[]` array carries phpstan's
43
+ // FILE-INDEPENDENT findings — internal errors, and ignore patterns that
44
+ // matched nothing. A run can report `totals.errors: 1` with `files: {}`
45
+ // and exit 1, and reading only `files` turned that into zero
46
+ // diagnostics: the same defect class as the `errors`-vs-`messages` bug
47
+ // above, one level up. Pinned by
48
+ // tests/fixtures/runner-output/phpstan/top-level-errors.captured.json.
49
+ //
50
+ // These have no file or line, so they attach to the edited file at line
51
+ // 1. That is the honest placement: the reader needs to see them, and
52
+ // there is nowhere truer to put them.
53
+ for (const message of output.errors ?? []) {
54
+ if (typeof message !== "string" || !message.trim())
55
+ continue;
56
+ diagnostics.push({
57
+ id: `phpstan:global:${message.slice(0, 40)}`,
58
+ message,
59
+ filePath: fallbackPath,
60
+ line: 1,
61
+ column: 1,
62
+ severity: "error",
63
+ semantic: "blocking",
64
+ tool: "phpstan",
65
+ rule: "phpstan/analysis",
66
+ fixable: false,
67
+ });
68
+ }
37
69
  return diagnostics;
38
70
  }
39
71
  catch {
@@ -41,7 +73,7 @@ export function parsePhpstanJson(raw, fallbackPath, cwd) {
41
73
  }
42
74
  }
43
75
  async function resolvePhpstan(cwd) {
44
- if (await (phpstan.isAvailableAsync(cwd)))
76
+ if (await phpstan.isAvailableAsync(cwd))
45
77
  return phpstan.getCommand(cwd);
46
78
  return resolveVendorToolCommand(cwd, "phpstan", ".bat");
47
79
  }
@@ -66,14 +98,15 @@ const phpstanRunner = {
66
98
  return { status: "skipped", diagnostics: [], semantic: "none" };
67
99
  const absPath = path.resolve(cwd, ctx.filePath);
68
100
  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);
101
+ // phpstan exits 0 = no errors, 1 = errors found, 2 = fatal. The fatal
102
+ // case used to return `skipped` silently; routing it through the shared
103
+ // seam keeps that verdict and adds the bounded `runner-empty-result` row
104
+ // (#1816), and the exit-1 case now also records when the JSON report
105
+ // parses to nothing (#1948) — the shape the error-count-as-array bug had.
106
+ const run = parseToolRun("phpstan", { result, exitCodes: PHPSTAN_EXIT_CODES }, (out) => parsePhpstanJson(out, ctx.filePath, cwd), { parseOutput: result.stdout ?? "" });
107
+ if (run.skipped)
108
+ return run.skipped;
109
+ const diagnostics = run.diagnostics;
77
110
  if (diagnostics.length === 0) {
78
111
  return { status: "succeeded", diagnostics: [], semantic: "none" };
79
112
  }
@@ -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
  }
@@ -2,7 +2,7 @@ import { safeSpawnAsync } from "../../safe-spawn.js";
2
2
  import { getLinterPolicyForCwd, hasSqlfluffConfig } from "../../tool-policy.js";
3
3
  import { PRIORITY } from "../priorities.js";
4
4
  import { createAvailabilityChecker, resolveToolCommandWithInstallFallback, } from "./utils/runner-helpers.js";
5
- import { skipUnlessToolRan } from "./utils/tool-failure.js";
5
+ import { parseToolRun } from "./utils/tool-failure.js";
6
6
  const sqlfluff = createAvailabilityChecker("sqlfluff", ".exe");
7
7
  // sqlfluff exit codes (its CLI docs): 0 = clean, 1 = violations found, 2 = a
8
8
  // user error that stopped the lint (unknown dialect, unreadable config).
@@ -127,7 +127,7 @@ const sqlfluffRunner = {
127
127
  ctx.log("sqlfluff: no config detected, using ANSI dialect defaults");
128
128
  }
129
129
  let cmd = null;
130
- if (await (sqlfluff.isAvailableAsync(cwd))) {
130
+ if (await sqlfluff.isAvailableAsync(cwd)) {
131
131
  cmd = sqlfluff.getCommand(cwd);
132
132
  }
133
133
  else {
@@ -135,10 +135,19 @@ const sqlfluffRunner = {
135
135
  }
136
136
  if (!cmd)
137
137
  return { status: "skipped", diagnostics: [], semantic: "none" };
138
- const args = ["lint", "--format", "json", ctx.filePath];
138
+ // #1937: this used to be `args.splice(2, 0, "--dialect", "ansi")` on
139
+ // `["lint", "--format", "json", file]`, which inserts BETWEEN the flag
140
+ // and its value and yields `lint --format --dialect ansi json file`.
141
+ // sqlfluff rejects `--dialect` as the value of `--format` and exits 2,
142
+ // so every SQL project WITHOUT a .sqlfluff — exactly the case this
143
+ // dialect default exists to serve — was never linted. Built in order
144
+ // rather than patched by index, and pinned by the argv-tie assertion
145
+ // in runners/captured-real-output.test.ts.
146
+ const args = ["lint", "--format", "json"];
139
147
  if (!hasConfig) {
140
- args.splice(2, 0, "--dialect", "ansi");
148
+ args.push("--dialect", "ansi");
141
149
  }
150
+ args.push(ctx.filePath);
142
151
  const result = await safeSpawnAsync(cmd, args, {
143
152
  timeout: 20000,
144
153
  cwd,
@@ -146,13 +155,12 @@ const sqlfluffRunner = {
146
155
  // #1816: this runner read `result.status` zero times, so a user error
147
156
  // fell through `parseSqlfluffOutput`'s JSON catch to zero violations and
148
157
  // reported the SQL file as clean.
149
- const skipped = skipUnlessToolRan("sqlfluff", {
150
- result,
151
- exitCodes: SQLFLUFF_EXIT_CODES,
152
- });
153
- if (skipped)
154
- return skipped;
155
- const diagnostics = parseSqlfluffOutput(result.stdout ?? "", ctx.filePath);
158
+ // #1948: sqlfluff exit 1 with a JSON report we read nothing out of is a
159
+ // parser break, not a clean file.
160
+ const run = parseToolRun("sqlfluff", { result, exitCodes: SQLFLUFF_EXIT_CODES }, (out) => parseSqlfluffOutput(out, ctx.filePath), { parseOutput: result.stdout ?? "" });
161
+ if (run.skipped)
162
+ return run.skipped;
163
+ const diagnostics = run.diagnostics;
156
164
  if (diagnostics.length === 0) {
157
165
  return { status: "succeeded", diagnostics: [], semantic: "none" };
158
166
  }