diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 2ae73d9711..38b1ede96e 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -275,15 +275,22 @@ jobs: # arrives in 7.1, so typescript-eslint / type-coverage / Stryker stay on 6.x # (the hybrid is the officially documented pattern). Isolated npx on purpose: # installing an alias package could collide node_modules/.bin/tsc with 6.x. - # Promote to the blocking gate after ~1 week of parity with the step above. + # The full result stays advisory while #8484 has a backlog. The blocking + # base-relative ratchet immediately below rejects only diagnostics added by + # the PR, so existing release debt does not block unrelated work. - name: Typecheck (core) — TS7 native shadow (advisory) continue-on-error: true run: | RC=0 START=$(date +%s) - npx -y -p typescript@7 tsc --pretty false -p tsconfig.typecheck-core.json || RC=$? + npm exec --yes --package=typescript@7.0.2 -- tsc --pretty false -p tsconfig.typecheck-core.json || RC=$? echo "[ts7-shadow] exit=$RC elapsed=$(( $(date +%s) - START ))s — the 6.x step above stays authoritative" exit $RC + - name: Typecheck (core) — TS7 zero-new-diagnostics ratchet + if: ${{ github.event_name == 'pull_request' }} + env: + TS7_BASE_REF: ${{ github.event.pull_request.base.sha }} + run: npm run check:ts7-diagnostics-ratchet -- --base-ref "$TS7_BASE_REF" # TIA: build the impact map at runtime (gitignored, ~21MB) and run only the # unit tests impacted by this PR's changed files. On hub/unmapped changes the # selector returns __RUN_ALL__ — full-suite authority is the parallel diff --git a/package.json b/package.json index 7691c1a1c3..86008e9677 100644 --- a/package.json +++ b/package.json @@ -215,6 +215,7 @@ "typecheck:noimplicit:core": "tsc --pretty false -p tsconfig.typecheck-noimplicit-core.json", "check:dashboard-typecheck": "node scripts/check/check-dashboard-typecheck.mjs", "check:open-sse-typecheck": "node scripts/check/check-open-sse-typecheck.mjs", + "check:ts7-diagnostics-ratchet": "node scripts/check/check-ts7-diagnostics-ratchet.mjs", "backfill-aggregation": "node --import tsx src/scripts/backfillAggregation.ts", "env:sync": "node scripts/dev/sync-env.mjs", "test:integration": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 tests/integration/*.test.ts \"tests/integration/combo-matrix/*.test.ts\"", diff --git a/scripts/check/check-ts7-diagnostics-ratchet.mjs b/scripts/check/check-ts7-diagnostics-ratchet.mjs new file mode 100644 index 0000000000..67669607a2 --- /dev/null +++ b/scripts/check/check-ts7-diagnostics-ratchet.mjs @@ -0,0 +1,322 @@ +#!/usr/bin/env node +// Blocks TypeScript 7 diagnostic regressions without requiring the existing +// migration backlog to be clean. The PR base and checked-out head are compiled +// with the same compiler and tsconfig, then compared as duplicate-preserving +// multisets of: relative file | TS code | normalized message. + +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +const ROOT = process.cwd(); +const DEFAULT_TSCONFIG = "tsconfig.typecheck-core.json"; +const DEFAULT_COMPILER_VERSION = "7.0.2"; +const DIAGNOSTIC_START = /^(.+?)\((\d+),(\d+)\): error (TS\d+):\s*(.*)$/; +const GLOBAL_DIAGNOSTIC_START = /^error (TS\d+):\s*(.*)$/; + +function normalizeSlashes(value) { + return String(value).replaceAll("\\", "/"); +} + +function stripRoot(value, root) { + const normalizedValue = normalizeSlashes(value); + const normalizedRoot = normalizeSlashes(path.resolve(root)).replace(/\/$/, ""); + return normalizedValue === normalizedRoot + ? "." + : normalizedValue.startsWith(`${normalizedRoot}/`) + ? normalizedValue.slice(normalizedRoot.length + 1) + : normalizedValue; +} + +export function normalizeDiagnosticMessage(message, root = ROOT) { + const normalizedRoot = normalizeSlashes(path.resolve(root)).replace(/\/$/, ""); + return normalizeSlashes(message) + .replaceAll(normalizedRoot, "") + .replace(/((?:[A-Za-z]:)?[^()\s]+\.(?:[cm]?[jt]sx?|json))\(\d+,\d+\)/gi, "$1") + .replace(/\s+/g, " ") + .trim(); +} + +/** Parse complete `tsc --pretty false` diagnostic blocks. */ +export function parseTscDiagnostics(raw, { root = ROOT } = {}) { + const diagnostics = []; + let current = null; + + const flush = () => { + if (!current) return; + const message = normalizeDiagnosticMessage(current.messageLines.join("\n"), root); + diagnostics.push({ + file: current.file, + code: current.code, + message, + key: `${current.file}\u0000${current.code}\u0000${message}`, + }); + current = null; + }; + + for (const line of String(raw).split(/\r?\n/)) { + const located = DIAGNOSTIC_START.exec(line); + if (located) { + flush(); + current = { + file: stripRoot(located[1], root), + code: located[4], + messageLines: [located[5]], + }; + continue; + } + + const global = GLOBAL_DIAGNOSTIC_START.exec(line); + if (global) { + flush(); + current = { file: "", code: global[1], messageLines: [global[2]] }; + continue; + } + + if (current && /^\s/.test(line) && line.trim()) current.messageLines.push(line); + } + flush(); + return diagnostics; +} + +export function toDiagnosticMultiset(diagnostics) { + const counts = new Map(); + for (const diagnostic of diagnostics) { + const entry = counts.get(diagnostic.key) ?? { ...diagnostic, count: 0 }; + entry.count += 1; + counts.set(diagnostic.key, entry); + } + return counts; +} + +export function diffDiagnosticMultisets(baseDiagnostics, headDiagnostics) { + const base = toDiagnosticMultiset(baseDiagnostics); + const head = toDiagnosticMultiset(headDiagnostics); + const added = []; + const removed = []; + + for (const [key, entry] of head) { + const baseCount = base.get(key)?.count ?? 0; + if (entry.count > baseCount) { + added.push({ ...entry, baseCount, headCount: entry.count, delta: entry.count - baseCount }); + } + } + for (const [key, entry] of base) { + const headCount = head.get(key)?.count ?? 0; + if (entry.count > headCount) { + removed.push({ ...entry, baseCount: entry.count, headCount, delta: entry.count - headCount }); + } + } + + const order = (a, b) => a.key.localeCompare(b.key); + return { added: added.sort(order), removed: removed.sort(order) }; +} + +export function hasParserPrerequisite(diagnostics) { + return diagnostics.some((diagnostic) => diagnostic.code === "TS1005"); +} + +function argument(name, fallback = "") { + const index = process.argv.indexOf(name); + return index >= 0 && process.argv[index + 1] ? process.argv[index + 1] : fallback; +} + +function run(command, args, options = {}) { + return spawnSync(command, args, { + cwd: ROOT, + encoding: "utf8", + maxBuffer: 64 * 1024 * 1024, + ...options, + }); +} + +function resolveCommit(ref) { + const result = run("git", ["rev-parse", "--verify", `${ref}^{commit}`]); + if (result.status !== 0) { + throw new Error(`cannot resolve base ref ${ref}: ${result.stderr.trim()}`); + } + return result.stdout.trim(); +} + +function sameLockfile(baseRoot) { + const head = path.join(ROOT, "package-lock.json"); + const base = path.join(baseRoot, "package-lock.json"); + return ( + fs.existsSync(head) && + fs.existsSync(base) && + fs.readFileSync(head).equals(fs.readFileSync(base)) + ); +} + +function linkDependencies(baseRoot) { + const source = path.join(ROOT, "node_modules"); + const target = path.join(baseRoot, "node_modules"); + if (!fs.existsSync(source)) throw new Error("node_modules is missing; run npm ci first"); + + fs.mkdirSync(target); + for (const entry of fs.readdirSync(source)) { + if (entry === "@omniroute") continue; + fs.symlinkSync(path.join(source, entry), path.join(target, entry), "junction"); + } + + const scope = path.join(target, "@omniroute"); + fs.mkdirSync(scope); + fs.symlinkSync(path.join(baseRoot, "open-sse"), path.join(scope, "open-sse"), "junction"); + fs.symlinkSync( + path.join(baseRoot, "packages", "browser-pool"), + path.join(scope, "browser-pool"), + "junction" + ); +} + +function installBaseDependencies(baseRoot) { + const npm = process.platform === "win32" ? "npm.cmd" : "npm"; + const result = run( + npm, + ["ci", "--ignore-scripts", "--prefer-offline", "--no-audit", "--no-fund"], + { cwd: baseRoot, stdio: "inherit" } + ); + if (result.status !== 0) throw new Error(`npm ci for the base worktree exited ${result.status}`); +} + +function runTypeScript(root, tsconfig, compilerVersion) { + const npm = process.platform === "win32" ? "npm.cmd" : "npm"; + const result = run( + npm, + [ + "exec", + "--yes", + `--package=typescript@${compilerVersion}`, + "--", + "tsc", + "--pretty", + "false", + "--noEmit", + "-p", + tsconfig, + ], + { cwd: root } + ); + if (result.error) throw result.error; + const output = `${result.stdout ?? ""}${result.stderr ?? ""}`; + const diagnostics = parseTscDiagnostics(output, { root }); + if (result.status !== 0 && diagnostics.length === 0) { + throw new Error( + `TypeScript exited ${result.status} without a parseable diagnostic:\n${output}` + ); + } + return { diagnostics, status: result.status ?? 0 }; +} + +function formatEntry(entry) { + return `${entry.file} ${entry.code}: ${entry.message} (${entry.baseCount} -> ${entry.headCount})`; +} + +function appendSummary({ baseRef, baseCount, headCount, added, removed, skipped }) { + const summary = process.env.GITHUB_STEP_SUMMARY; + if (!summary) return; + const lines = [ + "## TypeScript 7 zero-new-diagnostics ratchet", + "", + `- Base: \`${baseRef}\` (${baseCount} diagnostics)`, + `- Head: ${headCount} diagnostics`, + `- Added: ${added.reduce((sum, entry) => sum + entry.delta, 0)}`, + `- Removed: ${removed.reduce((sum, entry) => sum + entry.delta, 0)}`, + ]; + if (skipped) lines.push("- Status: parser prerequisite unresolved; comparison is advisory"); + if (added.length) { + lines.push("", "### Added diagnostics", "", ...added.map((entry) => `- ${formatEntry(entry)}`)); + } + fs.appendFileSync(summary, `${lines.join("\n")}\n`); +} + +function main() { + const baseRef = argument("--base-ref", process.env.TS7_BASE_REF ?? ""); + const tsconfig = argument("--tsconfig", DEFAULT_TSCONFIG); + const compilerVersion = argument("--compiler-version", DEFAULT_COMPILER_VERSION); + if (!baseRef) { + console.log("[ts7-ratchet] SKIP — --base-ref is required outside a pull request"); + return 0; + } + if (!fs.existsSync(path.join(ROOT, tsconfig))) { + throw new Error(`tsconfig not found: ${tsconfig}`); + } + + const baseCommit = resolveCommit(baseRef); + const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-ts7-ratchet-")); + const baseRoot = path.join(temporaryRoot, "base"); + let worktreeAdded = false; + + try { + const add = run("git", ["worktree", "add", "--detach", baseRoot, baseCommit]); + if (add.status !== 0) throw new Error(`cannot create base worktree: ${add.stderr.trim()}`); + worktreeAdded = true; + + if (sameLockfile(baseRoot)) linkDependencies(baseRoot); + else installBaseDependencies(baseRoot); + + console.log( + `[ts7-ratchet] TypeScript ${compilerVersion}; base=${baseCommit}; config=${tsconfig}` + ); + const base = runTypeScript(baseRoot, tsconfig, compilerVersion); + const head = runTypeScript(ROOT, tsconfig, compilerVersion); + const { added, removed } = diffDiagnosticMultisets(base.diagnostics, head.diagnostics); + const parserBlocked = hasParserPrerequisite(base.diagnostics); + + console.log(`ts7DiagnosticsBase=${base.diagnostics.length}`); + console.log(`ts7DiagnosticsHead=${head.diagnostics.length}`); + console.log(`ts7DiagnosticsAdded=${added.reduce((sum, entry) => sum + entry.delta, 0)}`); + console.log(`ts7DiagnosticsRemoved=${removed.reduce((sum, entry) => sum + entry.delta, 0)}`); + + appendSummary({ + baseRef: baseCommit, + baseCount: base.diagnostics.length, + headCount: head.diagnostics.length, + added, + removed, + skipped: parserBlocked, + }); + + if (parserBlocked) { + console.warn( + "[ts7-ratchet] SKIP — the release base still has TS1005 parser diagnostics. " + + "Resolve #10094 before making this comparison blocking; those errors are not accepted as baseline." + ); + return 0; + } + + if (added.length) { + console.error( + `[ts7-ratchet] FAIL — the PR adds ${added.reduce((sum, entry) => sum + entry.delta, 0)} ` + + `normalized TypeScript 7 diagnostic(s):\n${added.map((entry) => ` ✗ ${formatEntry(entry)}`).join("\n")}` + ); + return 1; + } + + console.log( + `[ts7-ratchet] OK — no new normalized diagnostics; ` + + `${removed.reduce((sum, entry) => sum + entry.delta, 0)} removed.` + ); + return 0; + } finally { + if (worktreeAdded) { + const remove = run("git", ["worktree", "remove", "--force", baseRoot]); + if (remove.status !== 0) { + console.warn(`[ts7-ratchet] WARN — temporary worktree cleanup: ${remove.stderr.trim()}`); + } + run("git", ["worktree", "prune"]); + } + fs.rmSync(temporaryRoot, { recursive: true, force: true }); + } +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + try { + process.exitCode = main(); + } catch (error) { + console.error(`[ts7-ratchet] FAIL — ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 2; + } +} diff --git a/tests/unit/build/check-ts7-diagnostics-ratchet.test.ts b/tests/unit/build/check-ts7-diagnostics-ratchet.test.ts new file mode 100644 index 0000000000..f4ddb00910 --- /dev/null +++ b/tests/unit/build/check-ts7-diagnostics-ratchet.test.ts @@ -0,0 +1,93 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + diffDiagnosticMultisets, + hasParserPrerequisite, + parseTscDiagnostics, +} from "../../../scripts/check/check-ts7-diagnostics-ratchet.mjs"; + +test("line and column movement does not create a new diagnostic", () => { + const base = parseTscDiagnostics( + "src/example.ts(10,2): error TS2322: Type 'string' is not assignable to type 'number'.\n" + ); + const head = parseTscDiagnostics( + "src/example.ts(80,19): error TS2322: Type 'string' is not assignable to type 'number'.\n" + ); + + assert.deepEqual(diffDiagnosticMultisets(base, head), { added: [], removed: [] }); +}); + +test("the full normalized message distinguishes different diagnostics at one file and code", () => { + const base = parseTscDiagnostics( + "src/example.ts(1,1): error TS2339: Property 'old' does not exist on type 'Value'.\n" + ); + const head = parseTscDiagnostics( + "src/example.ts(1,1): error TS2339: Property 'new' does not exist on type 'Value'.\n" + ); + const diff = diffDiagnosticMultisets(base, head); + + assert.equal(diff.added.length, 1); + assert.equal(diff.removed.length, 1); + assert.match(diff.added[0].message, /Property 'new'/); +}); + +test("duplicate diagnostics are compared as a multiset, not a set", () => { + const line = "src/example.ts(1,1): error TS2304: Cannot find name 'missing'.\n"; + const base = parseTscDiagnostics(line); + const head = parseTscDiagnostics(line + line.replace("(1,1)", "(2,1)")); + const { added, removed } = diffDiagnosticMultisets(base, head); + + assert.equal(added.length, 1); + assert.equal(added[0].delta, 1); + assert.equal(added[0].baseCount, 1); + assert.equal(added[0].headCount, 2); + assert.equal(removed.length, 0); +}); + +test("multiline diagnostics normalize whitespace and nested coordinates", () => { + const base = parseTscDiagnostics( + "src/example.ts(1,1): error TS2345: Argument is invalid.\n" + + " The expected type comes from property 'value' declared at src/types.ts(12,4).\n" + ); + const head = parseTscDiagnostics( + "src/example.ts(99,8): error TS2345: Argument is invalid.\n" + + " The expected type comes from property 'value' declared at src/types.ts(20,9).\n" + ); + + assert.deepEqual(diffDiagnosticMultisets(base, head), { added: [], removed: [] }); +}); + +test("an absolute worktree root is normalized out of file paths and messages", () => { + const base = parseTscDiagnostics( + "/tmp/base/src/example.ts(1,1): error TS2307: Cannot find module '/tmp/base/src/missing'.\n", + { root: "/tmp/base" } + ); + const head = parseTscDiagnostics( + "/tmp/head/src/example.ts(2,3): error TS2307: Cannot find module '/tmp/head/src/missing'.\n", + { root: "/tmp/head" } + ); + + assert.deepEqual(diffDiagnosticMultisets(base, head), { added: [], removed: [] }); + assert.equal(base[0].file, "src/example.ts"); +}); + +test("reductions pass and report the removed multiplicity", () => { + const line = "src/example.ts(1,1): error TS2304: Cannot find name 'missing'.\n"; + const base = parseTscDiagnostics(line + line.replace("(1,1)", "(2,1)")); + const head = parseTscDiagnostics(line); + const { added, removed } = diffDiagnosticMultisets(base, head); + + assert.equal(added.length, 0); + assert.equal(removed.length, 1); + assert.equal(removed[0].delta, 1); +}); + +test("TS1005 makes the unresolved parser prerequisite explicit", () => { + const diagnostics = parseTscDiagnostics( + "open-sse/config/providers/gateways.ts(10,2): error TS1005: ',' expected.\n" + ); + + assert.equal(hasParserPrerequisite(diagnostics), true); + assert.equal(hasParserPrerequisite([]), false); +}); diff --git a/tests/unit/quality-rail-gate-membership.test.ts b/tests/unit/quality-rail-gate-membership.test.ts index 7ebde99616..e3e66603c0 100644 --- a/tests/unit/quality-rail-gate-membership.test.ts +++ b/tests/unit/quality-rail-gate-membership.test.ts @@ -55,6 +55,7 @@ test("fast-gates carries the deterministic ratchets and security scanners from t "secrets vuln-ratchet workflows openapi-breaking", "typecheck:core", "check:dashboard-typecheck", + "check:ts7-diagnostics-ratchet", ]) { assert.ok(block.includes(needle), `fast-gates must contain "${needle}"`); } @@ -87,6 +88,20 @@ test("the complexity ratchet stays on the release rail (G0's written validation ); }); +test("the TS7 shadow and zero-new-diagnostics ratchet use one pinned compiler and core scope", () => { + const block = jobBlock("fast-gates"); + assert.match(block, /typescript@7\.0\.2/, "TS7 diagnostics must use the reviewed compiler"); + assert.match( + block, + /TS7_BASE_REF:\s*\$\{\{ github\.event\.pull_request\.base\.sha \}\}/, + "the ratchet must compare against the exact pull-request base commit" + ); + assert.ok( + block.includes("tsconfig.typecheck-core.json"), + "the advisory shadow must use the same core scope as the blocking ratchet" + ); +}); + test("the new gates run on jobs that stay pinned to hosted runners", () => { // Complements tests/unit/vps-runner-variable-scope.test.ts: neither gate job // may pick up a USE_VPS_RUNNER switch (setup-node measured 20m06s on .113 vs 16s hosted).