fix(quality): tolerate ESLint's trailing unpruned-suppressions text in validate-release-green (#7837) (#7962)

Root cause: validate-release-green.mjs's ESLint gate runs
`npx eslint . --format json --suppressions-location ...` without
--pass-on-unpruned-suppressions. ESLint 9.x prints the valid JSON report
to stdout first, then (if the suppressions file has any stale entries)
appends 'There are suppressions left that do not occur anymore...' to
stderr and exits 2. The gate concatenates stdout+stderr, so
parseEslintJson() received valid JSON immediately followed by that
sentence, JSON.parse() threw, and the caller reported the generic
"could not parse eslint json" HARD failure instead of the real
(harmless) unpruned-suppressions housekeeping condition.

Fix: add --pass-on-unpruned-suppressions to the gate's eslint invocation
(unpruned suppressions are release-time housekeeping, not a contributor
defect), and harden parseEslintJson() with a bracket-depth scan so it
recovers the JSON array even when trailing non-JSON text is glued on.

Regression test: tests/unit/validate-release-green.test.ts — 'parseEslintJson
tolerates ESLint's trailing unpruned-suppressions stderr sentence (#7837)',
feeding parseEslintJson() the exact byte shape ESLint's own cli.js produces
in this scenario. Confirmed RED before the fix (parsed === null), GREEN after.

Gates run: node --import tsx/esm --test tests/unit/validate-release-green.test.ts (20/20 pass),
npm run typecheck:core (clean), eslint --suppressions-location on changed files (clean),
check-complexity.mjs + check-cognitive-complexity.mjs (OK, no regression),
check-mutation-test-coverage.mjs --strict (no drift), check-changelog-integrity.mjs (OK),
check-file-size.mjs (OK, no frozen files grown).

Closes #7837
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-21 08:39:26 -03:00
committed by GitHub
parent fe94529306
commit 25499bf94d
3 changed files with 65 additions and 4 deletions

View File

@@ -0,0 +1 @@
- fix(quality): tolerate ESLint's trailing unpruned-suppressions stderr text in validate-release-green (#7837)

View File

@@ -113,15 +113,55 @@ export function eslintCounts(parsed) {
return { errors, warnings };
}
/** Parse the eslint JSON array out of mixed stdout (tolerates a leading banner). */
/**
* Parse the eslint JSON array out of mixed stdout (tolerates a leading banner AND trailing
* non-JSON text, e.g. ESLint 9.x's `--suppressions-location` "unpruned suppressions" stderr
* sentence glued onto the report when stdout+stderr are concatenated — #7837).
*/
export function parseEslintJson(out) {
const start = String(out || "").indexOf("[");
const str = String(out || "");
const start = str.indexOf("[");
if (start < 0) return null;
// Fast path: the whole remainder is valid JSON (no trailing text).
try {
return JSON.parse(String(out).slice(start));
return JSON.parse(str.slice(start));
} catch {
return null;
// fall through to bracket-depth scan below
}
// Slow path: find the matching closing "]" for the array that starts at `start`, tolerating
// any non-JSON text appended after it. Depth-tracks brackets while skipping over string
// literals (so a "]" or "[" inside a message string doesn't miscount).
let depth = 0;
let inString = false;
let escaped = false;
for (let i = start; i < str.length; i++) {
const ch = str[i];
if (inString) {
if (escaped) {
escaped = false;
} else if (ch === "\\") {
escaped = true;
} else if (ch === '"') {
inString = false;
}
continue;
}
if (ch === '"') {
inString = true;
} else if (ch === "[") {
depth++;
} else if (ch === "]") {
depth--;
if (depth === 0) {
try {
return JSON.parse(str.slice(start, i + 1));
} catch {
return null;
}
}
}
}
return null;
}
/** Pull the cognitive-complexity violation count from the gate's output. */
@@ -368,6 +408,11 @@ async function main() {
"json",
"--suppressions-location",
"config/quality/eslint-suppressions.json",
// An "unpruned" suppression means a previously-frozen violation was legitimately
// fixed — release-time housekeeping (same bucket as ratchet drift), never a
// contributor-blocking defect. Without this flag ESLint 9.x exits 2 for that
// reason alone, which used to mask the real `--format json` report (#7837).
"--pass-on-unpruned-suppressions",
],
{ timeout: 30 * 60 * 1000 }
);

View File

@@ -36,6 +36,21 @@ test("parseEslintJson tolerates a leading non-JSON banner", () => {
assert.equal(parseEslintJson("no json here"), null);
});
test("parseEslintJson tolerates ESLint's trailing unpruned-suppressions stderr sentence (#7837)", () => {
// ESLint 9.x's `--suppressions-location` feature prints the valid `--format json` report to
// stdout first, then — if the suppressions file has stale/"unpruned" entries — appends this
// exact sentence to stderr and exits 2. The gate concatenates stdout+stderr, so
// parseEslintJson() must recover the JSON report even with this trailing text glued on.
const eslintJsonReport = JSON.stringify([
{ filePath: "open-sse/executors/example.ts", errorCount: 0, warningCount: 0, messages: [] },
]);
const stderrTail =
"There are suppressions left that do not occur anymore. Consider re-running the command with `--prune-suppressions`.\n";
assert.deepEqual(parseEslintJson(eslintJsonReport + stderrTail), [
{ filePath: "open-sse/executors/example.ts", errorCount: 0, warningCount: 0, messages: [] },
]);
});
test("parseCognitiveCount reads the gate's count (en + pt)", () => {
assert.equal(parseCognitiveCount("[cognitive-complexity] 797 function(s) exceed the threshold (15)."), 797);
assert.equal(parseCognitiveCount("[cognitive-complexity] REGRESSÃO — 801 violações > baseline 797"), 801);