diff --git a/changelog.d/fixes/7837-release-green-eslint-json.md b/changelog.d/fixes/7837-release-green-eslint-json.md new file mode 100644 index 0000000000..77af4754ad --- /dev/null +++ b/changelog.d/fixes/7837-release-green-eslint-json.md @@ -0,0 +1 @@ +- fix(quality): tolerate ESLint's trailing unpruned-suppressions stderr text in validate-release-green (#7837) diff --git a/scripts/quality/validate-release-green.mjs b/scripts/quality/validate-release-green.mjs index a14b80051d..e1adca9927 100644 --- a/scripts/quality/validate-release-green.mjs +++ b/scripts/quality/validate-release-green.mjs @@ -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 } ); diff --git a/tests/unit/validate-release-green.test.ts b/tests/unit/validate-release-green.test.ts index 7626975264..bd37c75ae5 100644 --- a/tests/unit/validate-release-green.test.ts +++ b/tests/unit/validate-release-green.test.ts @@ -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);