From 8870cd1bc707e9c1ca5db76b9f0b41bd63d4f3c1 Mon Sep 17 00:00:00 2001 From: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Date: Wed, 2 Sep 2026 01:46:29 -0300 Subject: [PATCH] fix(quality): validate typecheck baseline schema --- scripts/check/check-api-typecheck.mjs | 48 +------ scripts/check/check-open-sse-typecheck.mjs | 69 +--------- scripts/check/typecheckBaseline.mjs | 91 +++++++++++++ tests/unit/build/check-api-typecheck.test.ts | 128 +++++++++++++++++++ 4 files changed, 231 insertions(+), 105 deletions(-) create mode 100644 scripts/check/typecheckBaseline.mjs diff --git a/scripts/check/check-api-typecheck.mjs b/scripts/check/check-api-typecheck.mjs index 441e87834e..1a3e487009 100644 --- a/scripts/check/check-api-typecheck.mjs +++ b/scripts/check/check-api-typecheck.mjs @@ -19,53 +19,15 @@ import { execFileSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; +import { diffAgainstBaseline, parseTscOutput } from "./typecheckBaseline.mjs"; + +export { diffAgainstBaseline, parseTscOutput } from "./typecheckBaseline.mjs"; const ROOT = process.cwd(); const TSCONFIG = path.join(ROOT, "tsconfig.typecheck-api.json"); const BASELINE_PATH = path.join(ROOT, "config/quality/api-typecheck-baseline.json"); const UPDATE = process.argv.includes("--update"); -const TSC_ERROR_LINE = /^(.+?)\((\d+),(\d+)\): error (TS\d+):/; - -export function parseTscOutput(raw) { - const counts = {}; - for (const line of String(raw).split("\n")) { - const match = TSC_ERROR_LINE.exec(line); - if (!match) continue; - const [, file, , , code] = match; - if (!counts[file]) counts[file] = {}; - counts[file][code] = (counts[file][code] || 0) + 1; - } - return counts; -} - -export function diffAgainstBaseline(live, baseline) { - const regressions = []; - const improvements = []; - - for (const [file, codes] of Object.entries(live)) { - for (const [code, liveCount] of Object.entries(codes)) { - const baselineCount = (baseline[file] && baseline[file][code]) || 0; - if (liveCount > baselineCount) { - regressions.push({ file, code, liveCount, baselineCount }); - } else if (liveCount < baselineCount) { - improvements.push({ file, code, liveCount, baselineCount }); - } - } - } - - for (const [file, codes] of Object.entries(baseline)) { - for (const [code, baselineCount] of Object.entries(codes)) { - const liveCount = (live[file] && live[file][code]) || 0; - if (liveCount === 0 && baselineCount > 0) { - improvements.push({ file, code, liveCount: 0, baselineCount }); - } - } - } - - return { regressions, improvements }; -} - function runTsc() { try { return execFileSync( @@ -117,7 +79,9 @@ function main() { `[api-typecheck] ${improvements.length} baselined error(s) no longer present ` + `— run 'node scripts/check/check-api-typecheck.mjs --update' to ratchet the baseline down:\n` + improvements - .map((i) => ` - ${i.file} ${i.code} (baseline ${i.baselineCount} -> live ${i.liveCount})`) + .map( + (i) => ` - ${i.file} ${i.code} (baseline ${i.baselineCount} -> live ${i.liveCount})` + ) .join("\n") ); } diff --git a/scripts/check/check-open-sse-typecheck.mjs b/scripts/check/check-open-sse-typecheck.mjs index d18c588538..ad03e5a5b5 100644 --- a/scripts/check/check-open-sse-typecheck.mjs +++ b/scripts/check/check-open-sse-typecheck.mjs @@ -22,74 +22,15 @@ import { execFileSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; +import { diffAgainstBaseline, parseTscOutput } from "./typecheckBaseline.mjs"; + +export { diffAgainstBaseline, parseTscOutput } from "./typecheckBaseline.mjs"; const ROOT = process.cwd(); const TSCONFIG = path.join(ROOT, "open-sse", "tsconfig.json"); const BASELINE_PATH = path.join(ROOT, "config/quality/open-sse-typecheck-baseline.json"); const UPDATE = process.argv.includes("--update"); -// Matches tsc --pretty false output lines, e.g.: -// src/app/api/v1/chat/route.ts(12,7): error TS2304: Cannot find name 'bar'. -// open-sse/handlers/chatCore.ts(45,3): error TS7053: Element implicitly has an 'any'... -const TSC_ERROR_LINE = /^(.+?)\((\d+),(\d+)\): error (TS\d+):/; - -/** - * Parses raw `tsc --pretty false` stdout into a nested count map: - * { "": { "": } } - * - * Pure/exported for unit testing against synthetic tsc output — no child - * process involved here. - */ -export function parseTscOutput(raw) { - const counts = {}; - const lines = String(raw).split("\n"); - for (const line of lines) { - const match = TSC_ERROR_LINE.exec(line); - if (!match) continue; - const [, file, , , code] = match; - if (!counts[file]) counts[file] = {}; - counts[file][code] = (counts[file][code] || 0) + 1; - } - return counts; -} - -/** - * Compares live (file, TS code) error counts against a frozen baseline. - * Returns `{ regressions, improvements }`: - * - regressions: entries where live count > baselined count (or the pair is - * entirely new/unbaselined) — these fail the gate. - * - improvements: entries where live count < baselined count — informational, - * do not fail (use --update to ratchet the baseline down). - * - * Exported for unit testing. - */ -export function diffAgainstBaseline(live, baseline) { - const regressions = []; - const improvements = []; - - for (const [file, codes] of Object.entries(live)) { - for (const [code, liveCount] of Object.entries(codes)) { - const baselineCount = (baseline[file] && baseline[file][code]) || 0; - if (liveCount > baselineCount) { - regressions.push({ file, code, liveCount, baselineCount }); - } else if (liveCount < baselineCount) { - improvements.push({ file, code, liveCount, baselineCount }); - } - } - } - - for (const [file, codes] of Object.entries(baseline)) { - for (const [code, baselineCount] of Object.entries(codes)) { - const liveCount = (live[file] && live[file][code]) || 0; - if (liveCount === 0 && baselineCount > 0) { - improvements.push({ file, code, liveCount: 0, baselineCount }); - } - } - } - - return { regressions, improvements }; -} - function runTsc() { try { const stdout = execFileSync( @@ -143,7 +84,9 @@ function main() { `[open-sse-typecheck] ${improvements.length} baselined error(s) no longer present ` + `— run 'node scripts/check/check-open-sse-typecheck.mjs --update' to ratchet the baseline down:\n` + improvements - .map((i) => ` - ${i.file} ${i.code} (baseline ${i.baselineCount} -> live ${i.liveCount})`) + .map( + (i) => ` - ${i.file} ${i.code} (baseline ${i.baselineCount} -> live ${i.liveCount})` + ) .join("\n") ); } diff --git a/scripts/check/typecheckBaseline.mjs b/scripts/check/typecheckBaseline.mjs new file mode 100644 index 0000000000..c627d7984c --- /dev/null +++ b/scripts/check/typecheckBaseline.mjs @@ -0,0 +1,91 @@ +// Shared parsing and frozen-baseline comparison for the scoped TypeScript gates. + +const TSC_ERROR_LINE = /^(.+?)\((\d+),(\d+)\): error (TS\d+):/; +const TS_CODE = /^TS\d+$/; +const UNSAFE_PROPERTY_KEYS = new Set(["__proto__", "constructor", "prototype"]); + +function isPlainObject(value) { + if (value === null || typeof value !== "object" || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function normalizeDiagnosticCounts(value, label) { + if (!isPlainObject(value)) { + throw new TypeError(`${label} must be a plain object`); + } + + const normalized = Object.create(null); + for (const [file, codes] of Object.entries(value)) { + if (UNSAFE_PROPERTY_KEYS.has(file)) { + throw new TypeError(`${label} contains unsupported property key "${file}"`); + } + if (file.startsWith("_")) continue; + if (!isPlainObject(codes)) { + throw new TypeError(`${label} entry "${file}" must be a plain object`); + } + + const normalizedCodes = Object.create(null); + for (const [code, count] of Object.entries(codes)) { + if (!TS_CODE.test(code)) { + throw new TypeError(`${label} entry "${file}" has invalid TypeScript code "${code}"`); + } + if (!Number.isFinite(count) || !Number.isInteger(count) || count < 0) { + throw new TypeError( + `${label} entry "${file}" code "${code}" must be a finite nonnegative integer` + ); + } + normalizedCodes[code] = count; + } + normalized[file] = normalizedCodes; + } + return normalized; +} + +/** Parse `tsc --pretty false` output into per-file/per-code diagnostic counts. */ +export function parseTscOutput(raw) { + const counts = {}; + for (const line of String(raw).split("\n")) { + const match = TSC_ERROR_LINE.exec(line); + if (!match) continue; + const [, file, , , code] = match; + if (!counts[file]) counts[file] = {}; + counts[file][code] = (counts[file][code] || 0) + 1; + } + return counts; +} + +/** + * Compare live diagnostic counts with a frozen baseline. + * + * Underscore-prefixed top-level keys are reserved for baseline metadata and + * never participate in the diagnostic comparison. + */ +export function diffAgainstBaseline(live, baseline) { + const liveCounts = normalizeDiagnosticCounts(live, "live diagnostics"); + const baselineCounts = normalizeDiagnosticCounts(baseline, "typecheck baseline"); + const regressions = []; + const improvements = []; + + for (const [file, codes] of Object.entries(liveCounts)) { + for (const [code, liveCount] of Object.entries(codes)) { + const baselineCount = baselineCounts[file]?.[code] ?? 0; + if (liveCount > baselineCount) { + regressions.push({ file, code, liveCount, baselineCount }); + } else if (liveCount < baselineCount) { + improvements.push({ file, code, liveCount, baselineCount }); + } + } + } + + for (const [file, codes] of Object.entries(baselineCounts)) { + for (const [code, baselineCount] of Object.entries(codes)) { + const liveCodes = liveCounts[file]; + if (!Object.hasOwn(liveCodes ?? {}, code) && baselineCount > 0) { + improvements.push({ file, code, liveCount: 0, baselineCount }); + } + } + } + + return { regressions, improvements }; +} diff --git a/tests/unit/build/check-api-typecheck.test.ts b/tests/unit/build/check-api-typecheck.test.ts index f7129c0cd4..5c584c24ae 100644 --- a/tests/unit/build/check-api-typecheck.test.ts +++ b/tests/unit/build/check-api-typecheck.test.ts @@ -7,6 +7,7 @@ import { parseTscOutput, diffAgainstBaseline, } from "../../../scripts/check/check-api-typecheck.mjs"; +import { diffAgainstBaseline as diffOpenSseAgainstBaseline } from "../../../scripts/check/check-open-sse-typecheck.mjs"; test("parseTscOutput: parses an API-route TS2554 regression", () => { const raw = @@ -85,3 +86,130 @@ test("diffAgainstBaseline: reports a disappeared diagnostic as an improvement", assert.equal(improvements[0].liveCount, 0); assert.equal(improvements[0].baselineCount, 2); }); + +test("diffAgainstBaseline: ignores underscore-prefixed baseline metadata", () => { + const baseline = { + _relax_velocity_2026_08_30: + "per-file TS diagnostic counts raised by 20% (289 -> 455); velocity phase", + "src/app/api/foo/route.ts": { TS2339: 1 }, + }; + const live = { "src/app/api/foo/route.ts": { TS2339: 1 } }; + + for (const compare of [diffAgainstBaseline, diffOpenSseAgainstBaseline]) { + assert.deepEqual(compare(live, baseline), { + regressions: [], + improvements: [], + }); + } +}); + +test("diffAgainstBaseline: rejects a string in place of a real file diagnostic map", () => { + const malformedBaseline = { + "src/app/api/foo/route.ts": "TS2339: 1", + }; + + assert.throws( + () => diffAgainstBaseline({}, malformedBaseline), + /src\/app\/api\/foo\/route\.ts.*plain object/ + ); + assert.throws( + () => diffOpenSseAgainstBaseline({}, malformedBaseline), + /src\/app\/api\/foo\/route\.ts.*plain object/ + ); +}); + +test("diffAgainstBaseline: rejects non-plain roots and file maps", () => { + const inheritedRoot = Object.create({ + "src/app/api/inherited/route.ts": { TS2339: 1 }, + }); + const inheritedFileMap = Object.create({ TS2339: 1 }); + + for (const malformedBaseline of [[], "not an object", null, inheritedRoot]) { + assert.throws( + () => diffAgainstBaseline({}, malformedBaseline), + /typecheck baseline must be a plain object/ + ); + } + for (const malformedFileMap of [[], null, inheritedFileMap]) { + assert.throws( + () => + diffAgainstBaseline( + {}, + { + "src/app/api/foo/route.ts": malformedFileMap, + } + ), + /src\/app\/api\/foo\/route\.ts.*plain object/ + ); + } +}); + +test("diffAgainstBaseline: rejects prototype property keys", () => { + const malformedBaseline = JSON.parse('{"__proto__":{"TS2339":1}}'); + + assert.throws( + () => diffAgainstBaseline({}, malformedBaseline), + /unsupported property key "__proto__"/ + ); +}); + +test("diffAgainstBaseline: rejects non-TypeScript diagnostic keys", () => { + for (const code of ["2339", "TSX2339", "TS23x", "constructor"]) { + assert.throws( + () => + diffAgainstBaseline( + {}, + { + "src/app/api/foo/route.ts": { [code]: 1 }, + } + ), + /invalid TypeScript code/ + ); + } +}); + +test("diffAgainstBaseline: rejects invalid diagnostic counts", () => { + for (const count of [Number.NaN, Number.POSITIVE_INFINITY, -1, 1.5, "1"]) { + assert.throws( + () => + diffAgainstBaseline( + {}, + { + "src/app/api/foo/route.ts": { TS2339: count }, + } + ), + /finite nonnegative integer/ + ); + } +}); + +test("diffAgainstBaseline: validates live diagnostics with the same schema", () => { + assert.throws( + () => + diffAgainstBaseline( + { "src/app/api/foo/route.ts": { TS2339: -1 } }, + { "src/app/api/foo/route.ts": { TS2339: 1 } } + ), + /live diagnostics.*finite nonnegative integer/ + ); +}); + +test("diffAgainstBaseline: accepts zero counts without fabricating a second improvement", () => { + assert.deepEqual( + diffAgainstBaseline( + { "src/app/api/foo/route.ts": { TS2339: 0 } }, + { "src/app/api/foo/route.ts": { TS2339: 1 } } + ), + { + regressions: [], + improvements: [ + { + file: "src/app/api/foo/route.ts", + code: "TS2339", + liveCount: 0, + baselineCount: 1, + }, + ], + } + ); +});