@herbertgao/pi-extensions 2026.8.7 → 2026.8.9

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 (161) hide show
  1. package/README.md +8 -6
  2. package/node_modules/@czottmann/pi-automode/CHANGELOG.md +24 -0
  3. package/node_modules/@czottmann/pi-automode/README.md +78 -112
  4. package/node_modules/@czottmann/pi-automode/docs/GLOSSARY.md +18 -14
  5. package/node_modules/@czottmann/pi-automode/docs/adr/ADR-001-permission-precedence-and-trust-boundaries.md +44 -0
  6. package/node_modules/@czottmann/pi-automode/docs/adr/INDEX.md +5 -0
  7. package/node_modules/@czottmann/pi-automode/docs/automode-classifier-flow.md +198 -104
  8. package/node_modules/@czottmann/pi-automode/docs/configuration.md +155 -0
  9. package/node_modules/@czottmann/pi-automode/docs/defaults.md +55 -14
  10. package/node_modules/@czottmann/pi-automode/docs/diagnostics.md +90 -0
  11. package/node_modules/@czottmann/pi-automode/docs/observability-logging.md +61 -26
  12. package/node_modules/@czottmann/pi-automode/examples/automode.local.json +5 -0
  13. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/classifier.ts +172 -18
  14. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/config.ts +127 -30
  15. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/constants.ts +3 -0
  16. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/extension.ts +273 -31
  17. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/log.ts +60 -5
  18. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/paths.ts +92 -8
  19. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/permissions.ts +203 -13
  20. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/state.ts +1 -0
  21. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/types.ts +11 -0
  22. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/utils.ts +9 -1
  23. package/node_modules/@czottmann/pi-automode/package.json +9 -2
  24. package/node_modules/@czottmann/pi-automode/skills/automode-diagnostics/SKILL.md +63 -0
  25. package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +3 -3
  26. package/node_modules/@herbertgao/pi-cc-extensions/README.md +3 -3
  27. package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/panel.ts +8 -3
  28. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/compact-thinking.ts +63 -15
  29. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/context.ts +56 -29
  30. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/message-display.ts +78 -16
  31. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/hover.ts +13 -0
  32. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/interaction.ts +30 -17
  33. package/node_modules/@herbertgao/pi-cc-extensions/package.json +3 -3
  34. package/node_modules/@herbertgao/pi-handoff/CHANGELOG.md +29 -0
  35. package/node_modules/@herbertgao/pi-handoff/README.md +15 -18
  36. package/node_modules/@herbertgao/pi-handoff/package.json +11 -4
  37. package/node_modules/@herbertgao/pi-handoff/src/complete-text.ts +33 -0
  38. package/node_modules/@herbertgao/pi-handoff/src/index.ts +127 -232
  39. package/node_modules/@herbertgao/pi-handoff/src/session-query.ts +15 -30
  40. package/node_modules/@herbertgao/pi-mermaid-open/CHANGELOG.md +14 -0
  41. package/node_modules/@herbertgao/pi-mermaid-open/README.md +21 -4
  42. package/node_modules/@herbertgao/pi-mermaid-open/herdr-plugin/herdr-plugin.toml +11 -0
  43. package/node_modules/@herbertgao/pi-mermaid-open/herdr-plugin/viewer.mjs +305 -0
  44. package/node_modules/@herbertgao/pi-mermaid-open/package.json +15 -4
  45. package/node_modules/@herbertgao/pi-mermaid-open/src/index.ts +351 -101
  46. package/node_modules/@herbertgao/pi-preferred-thinking/CHANGELOG.md +12 -0
  47. package/node_modules/@herbertgao/pi-preferred-thinking/README.md +30 -14
  48. package/node_modules/@herbertgao/pi-preferred-thinking/package.json +7 -4
  49. package/node_modules/@herbertgao/pi-preferred-thinking/src/index.ts +140 -85
  50. package/node_modules/@herbertgao/pi-recap/CHANGELOG.md +12 -0
  51. package/node_modules/@herbertgao/pi-recap/README.md +5 -3
  52. package/node_modules/@herbertgao/pi-recap/package.json +9 -3
  53. package/node_modules/@herbertgao/pi-recap/src/index.ts +3 -7
  54. package/node_modules/@herbertgao/pi-recap/src/model-picker.ts +6 -19
  55. package/node_modules/@herbertgao/pi-recap/src/models.ts +9 -21
  56. package/node_modules/@herbertgao/pi-recap/src/tui.ts +1 -1
  57. package/node_modules/@herbertgao/pi-rename/CHANGELOG.md +49 -0
  58. package/node_modules/@herbertgao/pi-rename/README.md +13 -11
  59. package/node_modules/@herbertgao/pi-rename/package.json +13 -3
  60. package/node_modules/@herbertgao/pi-rename/src/index.ts +135 -59
  61. package/node_modules/@herbertgao/pi-rename/src/models.ts +5 -13
  62. package/node_modules/@herbertgao/pi-rename/src/naming.ts +2 -6
  63. package/node_modules/@herbertgao/pi-stash/package.json +3 -3
  64. package/node_modules/@herbertgao/pi-subagents/CHANGELOG.md +8 -0
  65. package/node_modules/@herbertgao/pi-subagents/README.md +16 -3
  66. package/node_modules/@herbertgao/pi-subagents/package.json +1 -1
  67. package/node_modules/@herbertgao/pi-subagents/src/cross-extension-rpc.ts +15 -4
  68. package/node_modules/@herbertgao/pi-subagents/src/index.ts +16 -0
  69. package/node_modules/@herbertgao/pi-subagents/src/ui/conversation-viewer.ts +5 -1
  70. package/node_modules/@juicesharp/rpiv-ask-user-question/README.md +4 -4
  71. package/node_modules/@juicesharp/rpiv-ask-user-question/ask-user-question.ts +154 -96
  72. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/hosts.md +3 -0
  73. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/keyboard.md +10 -2
  74. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/localization.md +7 -5
  75. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/tool-schema.md +11 -5
  76. package/node_modules/@juicesharp/rpiv-ask-user-question/locales/en.json +3 -0
  77. package/node_modules/@juicesharp/rpiv-ask-user-question/package.json +2 -2
  78. package/node_modules/@juicesharp/rpiv-ask-user-question/state/key-router.ts +137 -96
  79. package/node_modules/@juicesharp/rpiv-ask-user-question/state/questionnaire-session.ts +44 -32
  80. package/node_modules/@juicesharp/rpiv-ask-user-question/state/selectors/projections.ts +22 -15
  81. package/node_modules/@juicesharp/rpiv-ask-user-question/state/state-reducer.ts +11 -1
  82. package/node_modules/@juicesharp/rpiv-ask-user-question/tool/response-envelope.ts +11 -0
  83. package/node_modules/@juicesharp/rpiv-ask-user-question/tool/types.ts +11 -0
  84. package/node_modules/@juicesharp/rpiv-ask-user-question/view/components/multi-select-view.ts +31 -17
  85. package/node_modules/@juicesharp/rpiv-ask-user-question/view/components/preview/preview-block-renderer.ts +15 -6
  86. package/node_modules/@juicesharp/rpiv-ask-user-question/view/components/wrapping-select.ts +15 -6
  87. package/node_modules/@juicesharp/rpiv-ask-user-question/view/dialog-builder.ts +56 -34
  88. package/node_modules/@juicesharp/rpiv-ask-user-question/view/tab-content-strategy.ts +50 -4
  89. package/node_modules/@narumitw/pi-btw/package.json +2 -2
  90. package/node_modules/@pi-plugins/fast-mode/dist/index.mjs +44 -1856
  91. package/node_modules/@pi-plugins/fast-mode/dist/index.mjs.map +1 -1
  92. package/node_modules/@pi-plugins/fast-mode/package.json +4 -6
  93. package/node_modules/pi-lens/CHANGELOG.md +90 -0
  94. package/node_modules/pi-lens/README.md +106 -0
  95. package/node_modules/pi-lens/dist/clients/bash-file-access.js +97 -6
  96. package/node_modules/pi-lens/dist/clients/blocker-past-eof.js +16 -6
  97. package/node_modules/pi-lens/dist/clients/bounded-telemetry.js +14 -0
  98. package/node_modules/pi-lens/dist/clients/cache-observability.js +353 -76
  99. package/node_modules/pi-lens/dist/clients/degradation-ledger.js +22 -1
  100. package/node_modules/pi-lens/dist/clients/demoted-finding-render.js +155 -0
  101. package/node_modules/pi-lens/dist/clients/deps/pi-tui.js +8 -2
  102. package/node_modules/pi-lens/dist/clients/deps/typebox.js +8 -3
  103. package/node_modules/pi-lens/dist/clients/dispatch/runners/detekt.js +9 -6
  104. package/node_modules/pi-lens/dist/clients/dispatch/runners/hadolint.js +9 -7
  105. package/node_modules/pi-lens/dist/clients/dispatch/runners/htmlhint.js +8 -6
  106. package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +8 -10
  107. package/node_modules/pi-lens/dist/clients/dispatch/runners/mypy.js +10 -10
  108. package/node_modules/pi-lens/dist/clients/dispatch/runners/oxlint.js +20 -5
  109. package/node_modules/pi-lens/dist/clients/dispatch/runners/phpstan.js +44 -11
  110. package/node_modules/pi-lens/dist/clients/dispatch/runners/spellcheck.js +10 -15
  111. package/node_modules/pi-lens/dist/clients/dispatch/runners/sqlfluff.js +19 -11
  112. package/node_modules/pi-lens/dist/clients/dispatch/runners/stylelint.js +35 -13
  113. package/node_modules/pi-lens/dist/clients/dispatch/runners/swiftlint.js +8 -8
  114. package/node_modules/pi-lens/dist/clients/dispatch/runners/taplo.js +110 -23
  115. package/node_modules/pi-lens/dist/clients/dispatch/runners/tflint.js +12 -7
  116. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/tool-failure.js +117 -1
  117. package/node_modules/pi-lens/dist/clients/dispatch/runners/vale.js +16 -14
  118. package/node_modules/pi-lens/dist/clients/dispatch/runners/yamllint.js +8 -9
  119. package/node_modules/pi-lens/dist/clients/finding-delivery-gate.js +22 -0
  120. package/node_modules/pi-lens/dist/clients/jscpd-client.js +5 -2
  121. package/node_modules/pi-lens/dist/clients/language-profile.js +7 -2
  122. package/node_modules/pi-lens/dist/clients/lsp/cascade-tier.js +27 -2
  123. package/node_modules/pi-lens/dist/clients/lsp/client.js +144 -5
  124. package/node_modules/pi-lens/dist/clients/lsp/index.js +151 -6
  125. package/node_modules/pi-lens/dist/clients/module-report.js +14 -1
  126. package/node_modules/pi-lens/dist/clients/ndjson-logger.js +114 -23
  127. package/node_modules/pi-lens/dist/clients/project-diagnostics/scanner.js +18 -9
  128. package/node_modules/pi-lens/dist/clients/project-report.js +62 -27
  129. package/node_modules/pi-lens/dist/clients/read-guard-logger.js +7 -2
  130. package/node_modules/pi-lens/dist/clients/read-guard.js +88 -19
  131. package/node_modules/pi-lens/dist/clients/review-graph/builder.js +194 -31
  132. package/node_modules/pi-lens/dist/clients/review-graph/revision-drift.js +21 -0
  133. package/node_modules/pi-lens/dist/clients/review-graph/workspace-modules.js +6 -3
  134. package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +44 -0
  135. package/node_modules/pi-lens/dist/clients/runtime-session.js +41 -0
  136. package/node_modules/pi-lens/dist/clients/runtime-turn.js +47 -2
  137. package/node_modules/pi-lens/dist/clients/session-event-guard.js +184 -0
  138. package/node_modules/pi-lens/dist/clients/source-filter.js +6 -2
  139. package/node_modules/pi-lens/dist/clients/startup-scan.js +5 -2
  140. package/node_modules/pi-lens/dist/clients/tree-sitter-logger.js +1 -0
  141. package/node_modules/pi-lens/dist/index.js +1224 -393
  142. package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +10 -3
  143. package/node_modules/pi-lens/package.json +18 -5
  144. package/node_modules/pi-lens/scripts/install-selftest.mjs +99 -11
  145. package/node_modules/pi-lens/scripts/lib/host-provided-deps.mjs +75 -0
  146. package/node_modules/pi-lens/scripts/lib/warm-loader-cache.mjs +285 -0
  147. package/node_modules/pi-lens/scripts/warm-loader-cache.mjs +233 -0
  148. package/node_modules/pi-web-access/CHANGELOG.md +27 -0
  149. package/node_modules/pi-web-access/README.md +8 -7
  150. package/node_modules/pi-web-access/chrome-cookies.ts +96 -20
  151. package/node_modules/pi-web-access/data-uri-sanitize.ts +406 -0
  152. package/node_modules/pi-web-access/extract.ts +12 -2
  153. package/node_modules/pi-web-access/firecrawl.ts +18 -2
  154. package/node_modules/pi-web-access/gemini-search.ts +28 -10
  155. package/node_modules/pi-web-access/github-extract.ts +65 -36
  156. package/node_modules/pi-web-access/index.ts +43 -14
  157. package/node_modules/pi-web-access/openai-search.ts +17 -6
  158. package/node_modules/pi-web-access/package.json +1 -1
  159. package/node_modules/pi-web-access/pdf-extract.ts +18 -5
  160. package/node_modules/pi-web-access/ssrf-protection.ts +12 -2
  161. package/package.json +19 -18
@@ -3,7 +3,7 @@ import { safeSpawnAsync } from "../../safe-spawn.js";
3
3
  import { getLinterPolicyForCwd, hasStylelintConfig, } 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 stylelint = createAvailabilityChecker("stylelint", ".cmd");
8
8
  // stylelint exit codes (its "Exit codes" docs page): 0 = no problems, 1 = a
9
9
  // fatal error, 2 = lint problems found, 78 = a configuration error.
@@ -12,11 +12,34 @@ const stylelint = createAvailabilityChecker("stylelint", ".cmd");
12
12
  // NOT a live stylelint: it is this repo's own hand-written fixture
13
13
  // (tests/clients/dispatch/runners/stylelint-fixable.test.ts), which reports
14
14
  // findings under exit 1. Rather than rewrite a fixture whose provenance is
15
- // unknown, the table keeps 1 permissive. That is safe because a genuinely
16
- // fatal stylelint under `--formatter json` writes nothing to stdout, so the
17
- // nothing-to-parse rule still catches it. 78 is the rejection this table
15
+ // unknown, the table keeps 1 permissive. 78 is the rejection this table
18
16
  // exists to name.
17
+ //
18
+ // #1937 corrected the safety argument that used to sit here: it claimed a
19
+ // fatal stylelint "writes nothing to stdout", which is true but useless,
20
+ // because a stylelint that DID find problems writes nothing to stdout either.
21
+ // `stylelintReport` below is what now separates the two.
19
22
  const STYLELINT_EXIT_CODES = { ran: [1, 2] };
23
+ /**
24
+ * The JSON report out of a stylelint run, whichever stream carried it (#1937).
25
+ *
26
+ * stylelint 16+ writes the formatted report to STDERR whenever the run
27
+ * "errored" — which is every run that found an error-severity warning, i.e.
28
+ * every run with something to say. Reading stdout alone parsed nothing and
29
+ * called the stylesheet clean, the #1933 vale shape. Verified against
30
+ * stylelint 17.8.0 in tests/fixtures/runner-output/stylelint/.
31
+ *
32
+ * Only a stream that actually starts a JSON array counts. A crash's stack
33
+ * trace on stderr must stay "nothing to parse" so #1816's empty-result skip
34
+ * still fires instead of reporting a clean file.
35
+ */
36
+ export function stylelintReport(stdout, stderr) {
37
+ for (const stream of [stdout, stderr]) {
38
+ if (stream?.trimStart().startsWith("["))
39
+ return stream;
40
+ }
41
+ return "";
42
+ }
20
43
  // Stylelint's standard JSON output reports only aggregate fixableErrorCount /
21
44
  // fixableWarningCount per file — there is no per-warning fix flag in the CLI
22
45
  // surface. To route the actionable warnings here without a tool rewrite, we
@@ -104,7 +127,7 @@ const stylelintRunner = {
104
127
  ctx.log("stylelint: no config detected, running with default rules");
105
128
  }
106
129
  let cmd = null;
107
- if (await (stylelint.isAvailableAsync(cwd))) {
130
+ if (await stylelint.isAvailableAsync(cwd)) {
108
131
  cmd = stylelint.getCommand(cwd);
109
132
  }
110
133
  else {
@@ -116,14 +139,13 @@ const stylelintRunner = {
116
139
  // #1816: this runner read `result.status` zero times, so an exit-78
117
140
  // config error (or an exit-1 crash) fell through parseStylelintJson's
118
141
  // catch to zero diagnostics and reported a clean stylesheet.
119
- const skipped = skipUnlessToolRan("stylelint", {
120
- result,
121
- exitCodes: STYLELINT_EXIT_CODES,
122
- });
123
- if (skipped)
124
- return skipped;
125
- const raw = result.stdout ?? "";
126
- const diagnostics = parseStylelintJson(raw, ctx.filePath);
142
+ const raw = stylelintReport(result.stdout, result.stderr);
143
+ // #1948: the stdout-vs-stderr report bug landed here as exit 2 plus a
144
+ // full JSON report and zero parsed warnings. That now leaves a record.
145
+ const run = parseToolRun("stylelint", { result, output: raw, exitCodes: STYLELINT_EXIT_CODES }, (out) => parseStylelintJson(out, ctx.filePath));
146
+ if (run.skipped)
147
+ return run.skipped;
148
+ const diagnostics = run.diagnostics;
127
149
  if (diagnostics.length === 0) {
128
150
  return { status: "succeeded", diagnostics: [], semantic: "none" };
129
151
  }
@@ -2,7 +2,7 @@ import * as path from "node:path";
2
2
  import { safeSpawnAsync } from "../../safe-spawn.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 swiftlint = createAvailabilityChecker("swiftlint", ".exe");
7
7
  // SwiftLint rules whose rule class declares a `corrector` — i.e. rules that
8
8
  // `swiftlint --fix` rewrites deterministically. The JSON reporter does not
@@ -118,7 +118,7 @@ const swiftlintRunner = {
118
118
  async run(ctx) {
119
119
  const cwd = ctx.cwd || process.cwd();
120
120
  let cmd = null;
121
- if (await (swiftlint.isAvailableAsync(cwd))) {
121
+ if (await swiftlint.isAvailableAsync(cwd)) {
122
122
  cmd = swiftlint.getCommand(cwd);
123
123
  }
124
124
  else {
@@ -133,12 +133,12 @@ const swiftlintRunner = {
133
133
  // stably enough to call any of them a rejection, so classification
134
134
  // rests on the conservative rule — a nonzero exit with nothing to parse
135
135
  // never ran.
136
- const skipped = skipUnlessToolRan("swiftlint", { result });
137
- if (skipped)
138
- return skipped;
139
- // SwiftLint exits non-zero on violations — stdout still has the JSON
140
- const raw = result.stdout || "";
141
- const diagnostics = parseSwiftLintOutput(raw, ctx.filePath);
136
+ // SwiftLint exits non-zero on violations — stdout still has the JSON, so
137
+ // a nonzero exit whose JSON yields nothing is a parser break (#1948).
138
+ const run = parseToolRun("swiftlint", { result }, (out) => parseSwiftLintOutput(out, ctx.filePath));
139
+ if (run.skipped)
140
+ return run.skipped;
141
+ const diagnostics = run.diagnostics;
142
142
  if (diagnostics.length === 0) {
143
143
  return { status: "succeeded", diagnostics: [], semantic: "none" };
144
144
  }
@@ -4,28 +4,101 @@ 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, lspPrimaryCoversFile, resolveToolCommandWithInstallFallback, } from "./utils/runner-helpers.js";
7
- import { spawnFailedWithNoOutput } from "./utils/spawn-outcome.js";
7
+ import { parseToolRun } from "./utils/tool-failure.js";
8
8
  const taplo = createAvailabilityChecker("taplo", ".exe");
9
- function parseTaploOutput(raw, filePath) {
10
- try {
11
- const parsed = JSON.parse(raw);
12
- const errors = parsed.errors ?? [];
13
- return errors.map((err, idx) => ({
14
- id: `taplo-${err.kind}-${err.range?.start.line ?? idx}`,
15
- message: `[${err.kind}] ${err.message}`,
9
+ // Verified against taplo 0.10.0: 0 = clean, 1 = the file is invalid (lint
10
+ // findings, or a schema it could not load), 2 = clap rejected the invocation.
11
+ //
12
+ // #1937 round 2: the parser fix alone turned the old silent-clean into a false
13
+ // BLOCKING diagnostic. clap prints `error: unexpected argument '--output'
14
+ // found`, which starts with the same lowercase `error:` a real taplo
15
+ // diagnostic does, so a mistyped flag would have reddened every valid TOML
16
+ // file. Both readings are wrong; the exit code is what separates them.
17
+ const TAPLO_EXIT_CODES = { ran: [1] };
18
+ /** `error: <summary>` or `warning: <summary>` at the head of a codespan block. */
19
+ const HEADER_PATTERN = /^\s*(error|warning):(.*)$/;
20
+ /** The box-drawing lead taplo puts on a codespan location line. */
21
+ const LOCATION_LEAD = "┌─";
22
+ /** The `:line:column` tail of a codespan location line. */
23
+ const LOCATION_PATTERN = /:(\d+):(\d+)$/;
24
+ /**
25
+ * Parse `taplo lint` output (#1937).
26
+ *
27
+ * This parser used to `JSON.parse` a `{ errors: [{ range, message, kind }] }`
28
+ * envelope that taplo has never emitted, fed by a `--output=json` flag taplo
29
+ * has never accepted. Real taplo rejected the flag with exit 2 and a usage
30
+ * error, the JSON parse threw, the catch returned [], and malformed TOML was
31
+ * reported clean. Same shape as the vale parser in #1933.
32
+ *
33
+ * Real taplo 0.10.0 writes codespan diagnostics to STDERR, interleaved with
34
+ * uppercase tracing lines:
35
+ *
36
+ * error: invalid TOML
37
+ * ┌─ /path/to/bad.toml:2:9
38
+ * │
39
+ * 2 │ [package
40
+ *
41
+ * Verified against tests/fixtures/runner-output/taplo/real.captured.json.
42
+ *
43
+ * A header alone is NOT enough to emit a diagnostic. The codespan location
44
+ * line must follow it. clap's own failures start with the same lowercase
45
+ * `error:` and carry no location, so without that requirement a rejected
46
+ * invocation becomes a blocking finding on line 1 of a perfectly valid file
47
+ * (#1937 round 2). The exit-code table in the runner is the primary guard;
48
+ * this is the structural one, and each covers a case the other does not.
49
+ */
50
+ export function parseTaploOutput(raw, filePath) {
51
+ const diagnostics = [];
52
+ const lines = (raw ?? "").split(/\r?\n/);
53
+ for (let i = 0; i < lines.length; i++) {
54
+ // Lowercase `error:`/`warning:` starts a diagnostic. taplo's tracing
55
+ // lines are uppercase (`ERROR taplo:lint_files: ...`), so a
56
+ // case-sensitive match keeps them out.
57
+ //
58
+ // Both patterns below trim in code rather than with a trailing `\s*`,
59
+ // and the location one tests for its box-drawing lead separately
60
+ // instead of skipping to it with `.*`. Either shape backtracks
61
+ // super-linearly on a long line, and this parser reads bytes a third
62
+ // party wrote.
63
+ const header = HEADER_PATTERN.exec(lines[i].trimEnd());
64
+ if (!header)
65
+ continue;
66
+ const severityWord = header[1];
67
+ const summary = header[2].trim();
68
+ if (!summary)
69
+ continue;
70
+ // The location arrives on a following `┌─ file:line:col` line. Scan a
71
+ // short window rather than assuming adjacency: taplo puts a gutter line
72
+ // between them in some layouts.
73
+ let line = null;
74
+ let column = 1;
75
+ for (let j = i + 1; j < Math.min(i + 4, lines.length); j++) {
76
+ if (!lines[j].includes(LOCATION_LEAD))
77
+ continue;
78
+ const location = LOCATION_PATTERN.exec(lines[j].trimEnd());
79
+ if (!location)
80
+ continue;
81
+ line = Number.parseInt(location[1], 10) || 1;
82
+ column = Number.parseInt(location[2], 10) || 1;
83
+ break;
84
+ }
85
+ if (line === null)
86
+ continue;
87
+ const severity = severityWord === "warning" ? "warning" : "error";
88
+ diagnostics.push({
89
+ id: `taplo-${severity}-${line}-${column}`,
90
+ message: summary,
16
91
  filePath,
17
- line: (err.range?.start.line ?? 0) + 1,
18
- column: (err.range?.start.col ?? 0) + 1,
19
- severity: "error",
20
- semantic: "blocking",
92
+ line,
93
+ column,
94
+ severity,
95
+ semantic: severity === "error" ? "blocking" : "warning",
21
96
  tool: "taplo",
22
- rule: err.kind,
97
+ rule: `taplo/${severity}`,
23
98
  fixable: false,
24
- }));
25
- }
26
- catch {
27
- return [];
99
+ });
28
100
  }
101
+ return diagnostics;
29
102
  }
30
103
  const taploRunner = {
31
104
  id: "taplo",
@@ -57,7 +130,7 @@ const taploRunner = {
57
130
  // different extension, so it stays as the checker's fallback only.
58
131
  let cmd = findLocalBinUpwards("taplo", cwd) ?? null;
59
132
  if (!cmd) {
60
- if (await (taplo.isAvailableAsync(cwd))) {
133
+ if (await taplo.isAvailableAsync(cwd)) {
61
134
  cmd = taplo.getCommand(cwd);
62
135
  }
63
136
  else {
@@ -67,11 +140,25 @@ const taploRunner = {
67
140
  if (!cmd)
68
141
  return { status: "skipped", diagnostics: [], semantic: "none" };
69
142
  const absPath = path.resolve(cwd, ctx.filePath);
70
- const result = await safeSpawnAsync(cmd, ["check", "--output=json", absPath], { cwd, timeout: 15000 });
71
- if (spawnFailedWithNoOutput(result)) {
72
- return { status: "skipped", diagnostics: [], semantic: "none" };
73
- }
74
- const diagnostics = parseTaploOutput(result.stdout || "", ctx.filePath);
143
+ // #1937: `--output=json` is not a taplo flag. taplo rejected it with a
144
+ // usage error, so this runner never linted anything. `--colors never`
145
+ // keeps ANSI escapes out of the bytes the parser reads.
146
+ const result = await safeSpawnAsync(cmd, ["check", "--colors", "never", absPath], { cwd, timeout: 15000 });
147
+ // taplo reports on stderr and leaves stdout empty, so the
148
+ // "nothing to parse" test must read the same string the parser gets —
149
+ // otherwise every real run looks like a failed spawn.
150
+ const raw = result.stderr?.trim() ? result.stderr : (result.stdout ?? "");
151
+ //
152
+ // taplo exits 1 only when a file is INVALID, and it does not always draw
153
+ // a codespan block when it does: a schema it could not load is reported
154
+ // through its tracing output alone. Claiming that file clean would be an
155
+ // outright lie, so taplo opts into `skipWhenParsedNothing` (#1948). This
156
+ // replaces a hand-rolled per-runner copy of the same rule, which wrote
157
+ // its own `runner-empty-result` row with its own wording.
158
+ const run = parseToolRun("taplo", { result, output: raw, exitCodes: TAPLO_EXIT_CODES }, (out) => parseTaploOutput(out, ctx.filePath), { skipWhenParsedNothing: true });
159
+ if (run.skipped)
160
+ return run.skipped;
161
+ const diagnostics = run.diagnostics;
75
162
  if (diagnostics.length === 0) {
76
163
  return { status: "succeeded", diagnostics: [], semantic: "none" };
77
164
  }
@@ -2,9 +2,9 @@ import * as path from "node:path";
2
2
  import { safeSpawnAsync } from "../../safe-spawn.js";
3
3
  import { getLinterPolicyForCwd } from "../../tool-policy.js";
4
4
  import { findNearestDirWithAnyBasename } from "../../workspace-topology.js";
5
- import { createAvailabilityChecker, resolveAvailableOrInstall, } from "./utils/runner-helpers.js";
6
- import { spawnFailedWithNoOutput } from "./utils/spawn-outcome.js";
7
5
  import { PRIORITY } from "../priorities.js";
6
+ import { createAvailabilityChecker, resolveAvailableOrInstall, } from "./utils/runner-helpers.js";
7
+ import { parseToolRun } from "./utils/tool-failure.js";
8
8
  const tflint = createAvailabilityChecker("tflint", ".exe");
9
9
  const TFLINT_CONFIG = ".tflint.hcl";
10
10
  /**
@@ -58,7 +58,7 @@ const tflintRunner = {
58
58
  return { status: "skipped", diagnostics: [], semantic: "none" };
59
59
  }
60
60
  let cmd = null;
61
- if (await (tflint.isAvailableAsync(cwd))) {
61
+ if (await tflint.isAvailableAsync(cwd)) {
62
62
  cmd = tflint.getCommand(cwd);
63
63
  }
64
64
  else {
@@ -82,10 +82,15 @@ const tflintRunner = {
82
82
  cwd: fileDir,
83
83
  timeout: 30000,
84
84
  });
85
- if (spawnFailedWithNoOutput(result)) {
86
- return { status: "skipped", diagnostics: [], semantic: "none" };
87
- }
88
- const diagnostics = parseTflintOutput(result.stdout || "", ctx.filePath);
85
+ // #1948: tflint exits nonzero and writes its JSON report to stdout, so a
86
+ // nonzero exit whose report yields zero diagnostics is a parser break,
87
+ // not a clean file. No exit-code table: tflint's nonzero codes are not
88
+ // verified against a real binary here, so the conservative
89
+ // nothing-to-parse rule stays the only discriminator.
90
+ const run = parseToolRun("tflint", { result }, (out) => parseTflintOutput(out, ctx.filePath));
91
+ if (run.skipped)
92
+ return run.skipped;
93
+ const diagnostics = run.diagnostics;
89
94
  if (diagnostics.length === 0) {
90
95
  return { status: "succeeded", diagnostics: [], semantic: "none" };
91
96
  }
@@ -29,7 +29,9 @@ export function formatToolFailure(input) {
29
29
  ? "did not run (spawn failure)"
30
30
  : `exited ${input.status}`;
31
31
  const missing = input.reportMissing ? "no report file" : "no output";
32
- const detail = firstOutputLine(input.stderr) || firstOutputLine(input.stdout) || "no stderr";
32
+ const detail = firstOutputLine(input.stderr) ||
33
+ firstOutputLine(input.stdout) ||
34
+ "no stderr";
33
35
  return truncateForLedger(`${identity} ${what} with ${missing}: ${detail}`);
34
36
  }
35
37
  /**
@@ -70,3 +72,117 @@ export function skipUnlessToolRan(tool, input, fields) {
70
72
  });
71
73
  return { status: "skipped", diagnostics: [], semantic: "none" };
72
74
  }
75
+ /**
76
+ * The record a runner leaves when its parser read NOTHING out of output the
77
+ * tool did produce (#1948).
78
+ *
79
+ * `skipUnlessToolRan` covers the adjacent case: the tool produced no output at
80
+ * all. The hole it leaves is the one that hid five parser bugs for months —
81
+ * vale (#1933), taplo, stylelint, phpstan (#1946), sqlfluff. Each received a
82
+ * real report, extracted zero diagnostics from it, and reported "succeeded, 0
83
+ * diagnostics", which is byte-for-byte what a genuinely clean file records.
84
+ * A reader could not answer "is this file clean, or did the parser fail?".
85
+ *
86
+ * THE RULE: record only when the exit status is NONZERO, the output handed to
87
+ * the parser is non-empty, and the parse yielded zero diagnostics. A nonzero
88
+ * exit is the tool asserting something is wrong; extracting nothing from that
89
+ * assertion means our reader disagrees with the tool, and one of the two is
90
+ * broken.
91
+ *
92
+ * Exit 0 with zero parsed is deliberately NOT recorded, even with non-empty
93
+ * output: that is the overwhelmingly common clean save. Linters print summary
94
+ * banners, empty JSON arrays, and progress noise on a clean run, so a row per
95
+ * clean save would drown the ledger and bury the real signal.
96
+ *
97
+ * FALSE NEGATIVES this rule accepts, stated plainly:
98
+ * - A tool that reports findings under exit 0. `swiftlint` is the live
99
+ * example among the adopters: a warning-only run exits 0 and still writes
100
+ * a full JSON array, so a swiftlint parser break that never trips a
101
+ * nonzero exit stays invisible here. (`vale` also exits 0 on findings by
102
+ * default, but its actual #1933 break exited 1, so it is not the example.)
103
+ * This gap is deliberate and is NOT closed by adding an output-length
104
+ * threshold for exit 0. That class belongs to the captured-fixture lane —
105
+ * `tests/clients/dispatch/runners/captured-real-output.test.ts` pins each
106
+ * parser against real recorded binary output, which catches a broken
107
+ * parser directly instead of guessing from output size. A length
108
+ * threshold here would fire on every clean run of every tool that prints
109
+ * a summary banner.
110
+ * - A tool whose parser breaks only for a SUBSET of findings still reports
111
+ * the ones it can read, so `parsedCount` is nonzero and nothing records.
112
+ *
113
+ * FALSE POSITIVES it accepts: a runner that legitimately parses zero
114
+ * diagnostics out of a failing run — `go-vet` exits nonzero for a SIBLING
115
+ * file's problem and then filters to the edited file, and `terragrunt` does
116
+ * the same across a unit directory. Such runners stay off this helper rather
117
+ * than being special-cased inside it; see the runner-family registries in
118
+ * `tests/clients/dispatch/runners/parsed-nothing-sweep.test.ts`.
119
+ *
120
+ * Bounded by `incrementDegradationCount`: one latest-reason entry per
121
+ * (kind, subject) with an exact tally, so a permanently broken parser costs
122
+ * ONE ledger row and not one per dispatched file. `subject` is the tool id
123
+ * because a parser break is a property of the tool and our reader of it, not
124
+ * of the project it ran in; aggregating on the tool keeps exactly the identity
125
+ * a reader needs ("which tool is stuck") while staying bounded across every
126
+ * workspace in the session.
127
+ *
128
+ * @returns true when a row was recorded.
129
+ */
130
+ export function recordParsedNothing(input) {
131
+ const { tool, status, output, parsedCount } = input;
132
+ // Three conjuncts, one per clause of the rule above. Each is separately
133
+ // load-bearing and separately tested: `parsedCount` keeps a run that DID
134
+ // yield findings silent, `status` keeps the clean-save case silent (0 and a
135
+ // never-arrived null alike — neither is a tool asserting a problem), and
136
+ // `output` keeps "the tool said nothing at all" with `runner-empty-result`
137
+ // rather than double-recording it here.
138
+ if (parsedCount > 0)
139
+ return false;
140
+ if (!status)
141
+ return false;
142
+ const text = output ?? "";
143
+ if (!text.trim())
144
+ return false;
145
+ const entries = Object.entries(input.fields ?? {}).filter(([, value]) => value !== undefined && value !== "");
146
+ const identity = entries.length
147
+ ? `${tool} (${entries.map(([key, value]) => `${key}=${value}`).join(", ")})`
148
+ : tool;
149
+ incrementDegradationCount({
150
+ kind: "runner-parsed-nothing",
151
+ subject: tool,
152
+ reason: truncateForLedger(`${identity} exited ${status} with ${text.length} chars of output but the parser read 0 diagnostics: ${firstOutputLine(text) || "no first line"}`),
153
+ });
154
+ return true;
155
+ }
156
+ /**
157
+ * The one seam a runner uses to go from a spawn result to diagnostics (#1948).
158
+ *
159
+ * It composes the two gates that must BOTH hold before a runner may claim a
160
+ * clean file: `skipUnlessToolRan` (the tool produced nothing — #1816) and
161
+ * `recordParsedNothing` (the tool produced something the parser could not
162
+ * read). Runners call this instead of calling `skipUnlessToolRan` and then
163
+ * parsing on their own, so the second gate cannot be forgotten by whoever
164
+ * writes runner 53. `tests/clients/dispatch/runners/parsed-nothing-sweep.test.ts`
165
+ * enforces that.
166
+ */
167
+ export function parseToolRun(tool, input, parse, options = {}) {
168
+ const skipped = skipUnlessToolRan(tool, input, options.fields);
169
+ if (skipped)
170
+ return { skipped, diagnostics: [], parsedNothing: false };
171
+ const output = options.parseOutput ?? input.output ?? input.result.stdout ?? "";
172
+ const diagnostics = [...parse(output)];
173
+ const parsedNothing = recordParsedNothing({
174
+ tool,
175
+ status: input.result.status ?? null,
176
+ output,
177
+ parsedCount: diagnostics.length,
178
+ fields: options.fields,
179
+ });
180
+ if (parsedNothing && options.skipWhenParsedNothing) {
181
+ return {
182
+ skipped: { status: "skipped", diagnostics: [], semantic: "none" },
183
+ diagnostics: [],
184
+ parsedNothing,
185
+ };
186
+ }
187
+ return { skipped: null, diagnostics, parsedNothing };
188
+ }
@@ -3,7 +3,7 @@ import * as path from "node:path";
3
3
  import { safeSpawnAsync } from "../../safe-spawn.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 vale = createAvailabilityChecker("vale", ".exe");
8
8
  /**
9
9
  * Check for a .vale.ini config file in cwd or parent dirs.
@@ -24,19 +24,18 @@ function findValeConfig(cwd) {
24
24
  }
25
25
  return undefined;
26
26
  }
27
- function parseValeOutput(raw, filePath) {
27
+ export function parseValeOutput(raw, filePath) {
28
28
  const diagnostics = [];
29
29
  if (!raw.trim())
30
30
  return diagnostics;
31
31
  try {
32
32
  const parsed = JSON.parse(raw);
33
- const files = parsed?.Data?.Files;
34
- if (!files)
33
+ if (!parsed || typeof parsed !== "object")
35
34
  return diagnostics;
36
- for (const file of files) {
37
- if (!file.Alerts)
35
+ for (const alerts of Object.values(parsed)) {
36
+ if (!Array.isArray(alerts))
38
37
  continue;
39
- for (const alert of file.Alerts) {
38
+ for (const alert of alerts) {
40
39
  if (!alert.Message)
41
40
  continue;
42
41
  const severityMap = {
@@ -51,7 +50,7 @@ function parseValeOutput(raw, filePath) {
51
50
  message: `[${alert.Check ?? "vale"}] ${alert.Message}`,
52
51
  filePath,
53
52
  line: alert.Line ?? 1,
54
- column: alert.Column ?? 1,
53
+ column: alert.Span?.[0] ?? 1,
55
54
  severity,
56
55
  semantic: severity === "error" ? "blocking" : "warning",
57
56
  tool: "vale",
@@ -80,7 +79,7 @@ const valeRunner = {
80
79
  return { status: "skipped", diagnostics: [], semantic: "none" };
81
80
  }
82
81
  let cmd = null;
83
- if (await (vale.isAvailableAsync(cwd))) {
82
+ if (await vale.isAvailableAsync(cwd)) {
84
83
  cmd = vale.getCommand(cwd);
85
84
  }
86
85
  else {
@@ -95,11 +94,14 @@ const valeRunner = {
95
94
  // zero alerts, and reported clean prose. No exit-code table: Vale's
96
95
  // nonzero codes vary with --minAlertLevel, so the conservative
97
96
  // nothing-to-parse rule is the only safe discriminator here.
98
- const skipped = skipUnlessToolRan("vale", { result });
99
- if (skipped)
100
- return skipped;
101
- const raw = result.stdout || "";
102
- const diagnostics = parseValeOutput(raw, ctx.filePath);
97
+ //
98
+ // #1948: `parseToolRun` adds the second gate. Vale's `Data.Files`
99
+ // envelope bug produced exactly this shape — exit 1, a full JSON
100
+ // report on stdout, zero alerts parsed — and left no record.
101
+ const run = parseToolRun("vale", { result }, (raw) => parseValeOutput(raw, ctx.filePath));
102
+ if (run.skipped)
103
+ return run.skipped;
104
+ const diagnostics = run.diagnostics;
103
105
  if (diagnostics.length === 0) {
104
106
  return { status: "succeeded", diagnostics: [], semantic: "none" };
105
107
  }
@@ -2,7 +2,7 @@ import { safeSpawnAsync } from "../../safe-spawn.js";
2
2
  import { getLinterPolicyForCwd, hasYamllintConfig } 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 yamllint = createAvailabilityChecker("yamllint", ".exe");
7
7
  export { hasYamllintConfig };
8
8
  function parseYamllintParsable(raw, filePath) {
@@ -45,7 +45,7 @@ const yamllintRunner = {
45
45
  ctx.log("yamllint: no config detected, running with default rules");
46
46
  }
47
47
  let cmd = null;
48
- if (await (yamllint.isAvailableAsync(cwd))) {
48
+ if (await yamllint.isAvailableAsync(cwd)) {
49
49
  cmd = yamllint.getCommand(cwd);
50
50
  }
51
51
  else {
@@ -66,13 +66,12 @@ const yamllintRunner = {
66
66
  // and tracebacks. So the gate judges "nothing to parse" on stdout alone,
67
67
  // while the parser still reads both streams.
68
68
  const raw = `${result.stdout ?? ""}${result.stderr ?? ""}`;
69
- const skipped = skipUnlessToolRan("yamllint", {
70
- result,
71
- output: result.stdout,
72
- });
73
- if (skipped)
74
- return skipped;
75
- const diagnostics = parseYamllintParsable(raw, ctx.filePath);
69
+ // #1948: classify on stdout as before, parse both streams as before, and
70
+ // record when a failing run yields nothing.
71
+ const run = parseToolRun("yamllint", { result, output: result.stdout }, (out) => parseYamllintParsable(out, ctx.filePath), { parseOutput: raw });
72
+ if (run.skipped)
73
+ return run.skipped;
74
+ const diagnostics = run.diagnostics;
76
75
  if (diagnostics.length === 0) {
77
76
  return { status: "succeeded", diagnostics: [], semantic: "none" };
78
77
  }
@@ -77,6 +77,28 @@
77
77
  * rendering convention every gated surface reuses — a store must not invent
78
78
  * its own "stale" wording.
79
79
  *
80
+ * #1944 completes the rule: demotion must change the BODY, not only the
81
+ * channel and the coordinate. A demoted body carries no STOP banner and no
82
+ * "must be fixed" imperative, and a cited line the file no longer has renders
83
+ * as `L<n> (line no longer exists)`. `clients/demoted-finding-render.ts`
84
+ * (`degradeDemotedFindingBody`) is the one implementation for surfaces that
85
+ * deliver a RENDERED BODY — the turn-end advisory tier. Per-row surfaces
86
+ * (`clients/widget-state.ts`, `tools/lens-diagnostics.ts`) have no body to
87
+ * degrade; they satisfy the same rule by dropping the authority marker
88
+ * alongside the coordinate.
89
+ *
90
+ * ── Retirement (#1944) ────────────────────────────────────────────────────
91
+ * Demote-not-drop assumes the agent CAN re-run to confirm. A past-EOF
92
+ * demotion breaks that assumption: the file shrank past the cited lines, so
93
+ * no re-run can ever speak to them, and the record re-served on every turn
94
+ * end for the life of the session. Such a record is delivered ONCE, degraded,
95
+ * then retired (`RuntimeCoordinator.retireDemotedPastEofBlocker`). The
96
+ * suppression is recorded in the degradation ledger under
97
+ * `demoted-finding-retired`, whose subject keeps the store and file, and the
98
+ * delivered payload says so in its own words — so an empty advisory section
99
+ * can only mean "nothing to say". Dependency-drift demotions keep in-bounds
100
+ * coordinates the agent can act on and are NOT retired.
101
+ *
80
102
  * ── Explicit lag labels (the non-gated escape hatch) ──────────────────────
81
103
  * A store that cannot afford the freshness/disposition stack — because it
82
104
  * has no cited per-file path to stat (a package-pinned finding like a trivy
@@ -115,11 +115,14 @@ export class JscpdClient {
115
115
  }
116
116
  if (!entry.isFile())
117
117
  return "skip";
118
- if (ignoreMatcher.isIgnored(fullPath, false))
119
- return "skip";
118
+ // #1974: extension gate before isIgnored — the regex test is cheap
119
+ // per-call; isIgnored recompiles minimatch patterns per ancestor dir
120
+ // and should only run for files that already look like source.
120
121
  if (/\.(ts|tsx|js|jsx|mjs|cjs|py|pyi|java|go|rs|rb|php|swift|kt|kts|dart|lua|scala|c|h|cpp|cc|cxx|hpp|hxx|cs|m|mm)$/.test(entry.name)) {
121
122
  if (entry.name.endsWith(".d.ts"))
122
123
  return "skip";
124
+ if (ignoreMatcher.isIgnored(fullPath, false))
125
+ return "skip";
123
126
  return "stop";
124
127
  }
125
128
  return "skip";
@@ -285,11 +285,16 @@ export async function collectSourceFilesForWarmup(rootDir, maxFiles = MAX_WARMUP
285
285
  stack.push(fullPath);
286
286
  }
287
287
  else if (entry.isFile()) {
288
- if (ignoreMatcher.isIgnored(fullPath, false))
289
- continue;
288
+ // #1974: cheap extension gate before the ignore matcher — isIgnored is
289
+ // O(ancestorDirs x patterns) with a per-call minimatch regex compile
290
+ // (0.68-0.93ms/file, measured), so pay it only for files that already
291
+ // pass the extension check. A large ignored-but-not-dir-prunable pile
292
+ // (e.g. wal/*.log) otherwise pays the full isIgnored cost per file.
290
293
  const ext = path.extname(entry.name).toLowerCase();
291
294
  if (!WARMUP_SOURCE_EXTS.has(ext))
292
295
  continue;
296
+ if (ignoreMatcher.isIgnored(fullPath, false))
297
+ continue;
293
298
  matchedSeen += 1;
294
299
  const bucket = WARMUP_CODE_EXTS.has(ext) ? codeOut : nonCodeOut;
295
300
  // Per-category hard cap — language detection only needs