@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.
- package/README.md +7 -5
- package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +1 -1
- package/node_modules/@herbertgao/pi-cc-extensions/README.md +1 -1
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/compact-thinking.ts +63 -15
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/message-display.ts +78 -16
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/hover.ts +13 -0
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/interaction.ts +17 -6
- package/node_modules/@herbertgao/pi-cc-extensions/package.json +3 -3
- package/node_modules/@juicesharp/rpiv-ask-user-question/README.md +4 -4
- package/node_modules/@juicesharp/rpiv-ask-user-question/ask-user-question.ts +154 -96
- package/node_modules/@juicesharp/rpiv-ask-user-question/docs/hosts.md +3 -0
- package/node_modules/@juicesharp/rpiv-ask-user-question/docs/keyboard.md +10 -2
- package/node_modules/@juicesharp/rpiv-ask-user-question/docs/localization.md +7 -5
- package/node_modules/@juicesharp/rpiv-ask-user-question/docs/tool-schema.md +11 -5
- package/node_modules/@juicesharp/rpiv-ask-user-question/locales/en.json +3 -0
- package/node_modules/@juicesharp/rpiv-ask-user-question/package.json +2 -2
- package/node_modules/@juicesharp/rpiv-ask-user-question/state/key-router.ts +137 -96
- package/node_modules/@juicesharp/rpiv-ask-user-question/state/questionnaire-session.ts +44 -32
- package/node_modules/@juicesharp/rpiv-ask-user-question/state/selectors/projections.ts +22 -15
- package/node_modules/@juicesharp/rpiv-ask-user-question/state/state-reducer.ts +11 -1
- package/node_modules/@juicesharp/rpiv-ask-user-question/tool/response-envelope.ts +11 -0
- package/node_modules/@juicesharp/rpiv-ask-user-question/tool/types.ts +11 -0
- package/node_modules/@juicesharp/rpiv-ask-user-question/view/components/multi-select-view.ts +31 -17
- package/node_modules/@juicesharp/rpiv-ask-user-question/view/components/preview/preview-block-renderer.ts +15 -6
- package/node_modules/@juicesharp/rpiv-ask-user-question/view/components/wrapping-select.ts +15 -6
- package/node_modules/@juicesharp/rpiv-ask-user-question/view/dialog-builder.ts +56 -34
- package/node_modules/@juicesharp/rpiv-ask-user-question/view/tab-content-strategy.ts +50 -4
- package/node_modules/@narumitw/pi-btw/package.json +2 -2
- package/node_modules/@pi-plugins/fast-mode/dist/index.mjs +44 -1856
- package/node_modules/@pi-plugins/fast-mode/dist/index.mjs.map +1 -1
- package/node_modules/@pi-plugins/fast-mode/package.json +4 -6
- package/node_modules/pi-lens/CHANGELOG.md +90 -0
- package/node_modules/pi-lens/README.md +106 -0
- package/node_modules/pi-lens/dist/clients/bash-file-access.js +97 -6
- package/node_modules/pi-lens/dist/clients/blocker-past-eof.js +16 -6
- package/node_modules/pi-lens/dist/clients/bounded-telemetry.js +14 -0
- package/node_modules/pi-lens/dist/clients/cache-observability.js +353 -76
- package/node_modules/pi-lens/dist/clients/degradation-ledger.js +22 -1
- package/node_modules/pi-lens/dist/clients/demoted-finding-render.js +155 -0
- package/node_modules/pi-lens/dist/clients/deps/pi-tui.js +8 -2
- package/node_modules/pi-lens/dist/clients/deps/typebox.js +8 -3
- package/node_modules/pi-lens/dist/clients/dispatch/runners/detekt.js +9 -6
- package/node_modules/pi-lens/dist/clients/dispatch/runners/hadolint.js +9 -7
- package/node_modules/pi-lens/dist/clients/dispatch/runners/htmlhint.js +8 -6
- package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +8 -10
- package/node_modules/pi-lens/dist/clients/dispatch/runners/mypy.js +10 -10
- package/node_modules/pi-lens/dist/clients/dispatch/runners/oxlint.js +20 -5
- package/node_modules/pi-lens/dist/clients/dispatch/runners/phpstan.js +44 -11
- package/node_modules/pi-lens/dist/clients/dispatch/runners/spellcheck.js +10 -15
- package/node_modules/pi-lens/dist/clients/dispatch/runners/sqlfluff.js +19 -11
- package/node_modules/pi-lens/dist/clients/dispatch/runners/stylelint.js +35 -13
- package/node_modules/pi-lens/dist/clients/dispatch/runners/swiftlint.js +8 -8
- package/node_modules/pi-lens/dist/clients/dispatch/runners/taplo.js +110 -23
- package/node_modules/pi-lens/dist/clients/dispatch/runners/tflint.js +12 -7
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/tool-failure.js +117 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/vale.js +16 -14
- package/node_modules/pi-lens/dist/clients/dispatch/runners/yamllint.js +8 -9
- package/node_modules/pi-lens/dist/clients/finding-delivery-gate.js +22 -0
- package/node_modules/pi-lens/dist/clients/jscpd-client.js +5 -2
- package/node_modules/pi-lens/dist/clients/language-profile.js +7 -2
- package/node_modules/pi-lens/dist/clients/lsp/cascade-tier.js +27 -2
- package/node_modules/pi-lens/dist/clients/lsp/client.js +144 -5
- package/node_modules/pi-lens/dist/clients/lsp/index.js +151 -6
- package/node_modules/pi-lens/dist/clients/module-report.js +14 -1
- package/node_modules/pi-lens/dist/clients/ndjson-logger.js +114 -23
- package/node_modules/pi-lens/dist/clients/project-diagnostics/scanner.js +18 -9
- package/node_modules/pi-lens/dist/clients/project-report.js +62 -27
- package/node_modules/pi-lens/dist/clients/read-guard-logger.js +7 -2
- package/node_modules/pi-lens/dist/clients/read-guard.js +88 -19
- package/node_modules/pi-lens/dist/clients/review-graph/builder.js +194 -31
- package/node_modules/pi-lens/dist/clients/review-graph/revision-drift.js +21 -0
- package/node_modules/pi-lens/dist/clients/review-graph/workspace-modules.js +6 -3
- package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +44 -0
- package/node_modules/pi-lens/dist/clients/runtime-session.js +41 -0
- package/node_modules/pi-lens/dist/clients/runtime-turn.js +47 -2
- package/node_modules/pi-lens/dist/clients/session-event-guard.js +184 -0
- package/node_modules/pi-lens/dist/clients/source-filter.js +6 -2
- package/node_modules/pi-lens/dist/clients/startup-scan.js +5 -2
- package/node_modules/pi-lens/dist/clients/tree-sitter-logger.js +1 -0
- package/node_modules/pi-lens/dist/index.js +1224 -393
- package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +10 -3
- package/node_modules/pi-lens/package.json +18 -5
- package/node_modules/pi-lens/scripts/install-selftest.mjs +99 -11
- package/node_modules/pi-lens/scripts/lib/host-provided-deps.mjs +75 -0
- package/node_modules/pi-lens/scripts/lib/warm-loader-cache.mjs +285 -0
- package/node_modules/pi-lens/scripts/warm-loader-cache.mjs +233 -0
- package/node_modules/pi-web-access/CHANGELOG.md +14 -0
- package/node_modules/pi-web-access/README.md +6 -5
- package/node_modules/pi-web-access/chrome-cookies.ts +86 -15
- package/node_modules/pi-web-access/data-uri-sanitize.ts +406 -0
- package/node_modules/pi-web-access/extract.ts +12 -2
- package/node_modules/pi-web-access/firecrawl.ts +18 -2
- package/node_modules/pi-web-access/github-extract.ts +65 -36
- package/node_modules/pi-web-access/index.ts +19 -4
- package/node_modules/pi-web-access/openai-search.ts +17 -6
- package/node_modules/pi-web-access/package.json +1 -1
- package/node_modules/pi-web-access/pdf-extract.ts +18 -5
- package/node_modules/pi-web-access/ssrf-protection.ts +12 -2
- package/package.json +9 -9
|
@@ -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 {
|
|
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.
|
|
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
|
|
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
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
});
|
|
123
|
-
if (skipped)
|
|
124
|
-
return skipped;
|
|
125
|
-
const
|
|
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 {
|
|
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
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
const diagnostics =
|
|
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 {
|
|
7
|
+
import { parseToolRun } from "./utils/tool-failure.js";
|
|
8
8
|
const taplo = createAvailabilityChecker("taplo", ".exe");
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
18
|
-
column
|
|
19
|
-
severity
|
|
20
|
-
semantic: "blocking",
|
|
92
|
+
line,
|
|
93
|
+
column,
|
|
94
|
+
severity,
|
|
95
|
+
semantic: severity === "error" ? "blocking" : "warning",
|
|
21
96
|
tool: "taplo",
|
|
22
|
-
rule:
|
|
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
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
}
|
|
74
|
-
|
|
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
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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) ||
|
|
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 {
|
|
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
|
-
|
|
34
|
-
if (!files)
|
|
33
|
+
if (!parsed || typeof parsed !== "object")
|
|
35
34
|
return diagnostics;
|
|
36
|
-
for (const
|
|
37
|
-
if (!
|
|
35
|
+
for (const alerts of Object.values(parsed)) {
|
|
36
|
+
if (!Array.isArray(alerts))
|
|
38
37
|
continue;
|
|
39
|
-
for (const alert of
|
|
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.
|
|
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
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const
|
|
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 {
|
|
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
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
119
|
-
|
|
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
|
-
|
|
289
|
-
|
|
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
|