Compare commits

...

2 Commits

Author SHA1 Message Date
diegosouzapw
abb65b42da Merge release/v3.8.51 into fix/v3851-typecheck-baseline-schema 2026-09-02 08:38:55 -03:00
diegosouzapw
8870cd1bc7 fix(quality): validate typecheck baseline schema 2026-09-02 01:46:29 -03:00
4 changed files with 231 additions and 105 deletions

View File

@@ -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")
);
}

View File

@@ -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:
* { "<relative file path>": { "<TS code>": <count> } }
*
* 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")
);
}

View File

@@ -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 };
}

View File

@@ -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,
},
],
}
);
});