From 2b9fb2a71e8569205d0e0413e101a4220019a645 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 08:38:08 +0000 Subject: [PATCH] fix(app-shell): actually compile `spec-symbol-parity.test.ts`'s type assertions (#3181) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The file's `type _X = Assert>` lines were never read by any compiler. `packages/app-shell/tsconfig.json` is the BUILD config and excludes `**/*.test.ts`; CI's only type gate drives that config (`pnpm type-check` -> turbo -> per-package `tsc --noEmit`), eslint is not type-aware, and vitest erases types before it runs. A provably-false `Assert>` appended to the file passed `pnpm type-check` at exit 0 — the same "declared != enforced" landmine the file's own header cites #3009 for. Adds `packages/app-shell/tsconfig.typetests.json`: an emit-free project with an EXPLICIT include list (not a glob), chained from the package's `type-check` script so it runs in the existing CI `Type Check` job. The list is explicit because app-shell's wider test tree still has a pre-existing error backlog, already declared as TEST_DEBT — a glob would sweep that in and the project would get deleted rather than fixed. That backlog stays tracked in #3181. `scripts/check-type-check-coverage.mjs` grows the matching ratchet: a `tsconfig.typetests.json` that `type-check` does not chain, that emits, or that includes no test file is now a hard failure, so this gate cannot go quiet the same way the assertions did. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012C2cd7tL8QDoZ2QKN3djJ5 --- packages/app-shell/package.json | 3 +- .../src/__tests__/spec-symbol-parity.test.ts | 24 +++++++- packages/app-shell/tsconfig.typetests.json | 48 +++++++++++++++ scripts/check-type-check-coverage.mjs | 61 ++++++++++++++++++- 4 files changed, 133 insertions(+), 3 deletions(-) create mode 100644 packages/app-shell/tsconfig.typetests.json diff --git a/packages/app-shell/package.json b/packages/app-shell/package.json index ef46233b3..3f8b0855b 100644 --- a/packages/app-shell/package.json +++ b/packages/app-shell/package.json @@ -27,7 +27,8 @@ "scripts": { "build": "tsc", "test": "vitest run", - "type-check": "tsc --noEmit", + "type-check": "tsc --noEmit && tsc -p tsconfig.typetests.json", + "type-check:typetests": "tsc -p tsconfig.typetests.json", "lint": "eslint ." }, "dependencies": { diff --git a/packages/app-shell/src/__tests__/spec-symbol-parity.test.ts b/packages/app-shell/src/__tests__/spec-symbol-parity.test.ts index 906dcd262..2e52c9357 100644 --- a/packages/app-shell/src/__tests__/spec-symbol-parity.test.ts +++ b/packages/app-shell/src/__tests__/spec-symbol-parity.test.ts @@ -37,6 +37,24 @@ * subpath's `.d.ts` through the TypeScript checker, exactly as * `scripts/check-spec-symbol-derivation.mjs` does, and gets types and values * alike. + * + * ## What compiles the `type _X = Assert<…>` lines below (objectui#3181) + * + * Nothing did, for the first stretch of this file's life. `tsconfig.json` here + * is the package BUILD config and excludes `**\/*.test.ts`; CI's only type gate + * drives that config (`pnpm type-check` -> turbo -> per-package `tsc --noEmit`), + * and types are erased before vitest ever runs. Appending a provably-false + * `Assert>` to this file therefore passed `pnpm type-check` at exit + * 0 — the type half of this "tripwire" was, literally, commentary. Which is the + * same landmine this file's own header cites objectui#3009 for. + * + * They are now compiled by `packages/app-shell/tsconfig.typetests.json`, chained + * from this package's `type-check` script, i.e. run by the CI `Type Check` job. + * That project lists its files EXPLICITLY (app-shell's wider test tree still has + * a pre-existing error backlog, tracked as TEST_DEBT), so **a new + * type-assertion test file is unchecked until it is added to that include + * list** — and `scripts/check-type-check-coverage.mjs` fails if the chaining is + * ever dropped, so the gate cannot go quiet again the way it did here. */ import { describe, it, expect } from 'vitest'; @@ -193,7 +211,11 @@ describe('re-exported values are the spec binding itself', () => { /* divergence cannot silently grow and cannot silently outlive its reason. */ /* -------------------------------------------------------------------------- */ -/** Compile-time assertions. A violation is a `tsc` error, not a runtime failure. */ +/** + * Compile-time assertions. A violation is a `tsc` error, not a runtime failure — + * so it surfaces only under `tsconfig.typetests.json` (see the file header), not + * under vitest. + */ type Assert = T; type Extends = [A] extends [B] ? true : false; type IsAny = 0 extends 1 & T ? true : false; diff --git a/packages/app-shell/tsconfig.typetests.json b/packages/app-shell/tsconfig.typetests.json new file mode 100644 index 000000000..1fe55d434 --- /dev/null +++ b/packages/app-shell/tsconfig.typetests.json @@ -0,0 +1,48 @@ +{ + // Compiles the test files whose ENTIRE value is compile-time type assertions, + // so that those assertions are actually checked by CI (objectui#3181). + // + // Why this exists as a THIRD project rather than as `tsconfig.test.json`: + // + // - `tsconfig.json` is the package BUILD (`tsc` -> dist, "rootDir": "src", + // "composite", "declaration"). It excludes `**/*.test.ts` correctly — test + // files would otherwise emit into the published dist. + // - `tsconfig.test.json` is the repo's name for "this package compiles ALL of + // its tests" (see packages/types). app-shell cannot claim that yet: its + // test tree still has a large pre-existing error backlog, declared as + // TEST_DEBT in scripts/check-type-check-coverage.mjs. Naming this file + // `tsconfig.test.json` would tell that guard the debt is paid and make it + // demand the entry be deleted — trading one false "checked" claim for + // another. + // + // So: a narrow, explicitly-listed project that compiles only files which are + // ALREADY clean and whose assertions are load-bearing. It is chained from the + // package's `type-check` script, which is what the CI `Type Check` job runs + // (`pnpm type-check` -> `turbo run type-check`), and that chaining is enforced + // by scripts/check-type-check-coverage.mjs — a config nothing runs is exactly + // the objectui#3009 / objectui#3181 failure this file is fixing. + // + // Adding a file here is a one-line change; the bar is that it compiles clean + // today. Do NOT switch this to a glob: a glob would sweep in the backlog and + // the first agent to hit it would "fix" that by deleting the whole project. + "extends": "../../tsconfig.json", + "compilerOptions": { + // A checking project, never an emitting one — and explicitly not part of + // the build graph, so it cannot leak test output into dist. + "noEmit": true, + "composite": false, + "declaration": false, + "lib": ["ES2020", "DOM"], + // `spec-symbol-parity.test.ts` resolves `@objectstack/spec`'s own `.d.ts` + // files off disk (createRequire / readFileSync / node:path) to read the + // spec's export names through the TypeScript checker. + "types": ["node"], + // Same reason as tsconfig.json: drop the root tsconfig's source-tree `paths` + // so `@objectstack/spec` and the `@object-ui/*` workspace deps resolve + // through the real dependency graph rather than through sibling `src/`. + "paths": {} + }, + // Explicit list, not a glob. Every entry is a file whose type assertions are + // the point of the file. + "include": ["src/__tests__/spec-symbol-parity.test.ts"] +} diff --git a/scripts/check-type-check-coverage.mjs b/scripts/check-type-check-coverage.mjs index 557c9ee8f..2f7f44319 100644 --- a/scripts/check-type-check-coverage.mjs +++ b/scripts/check-type-check-coverage.mjs @@ -89,6 +89,10 @@ const CHECKED_BY_OWN_BUILD = { // type to silence it. const TEST_DEBT = { "@object-ui/core": { errors: 72, issue: 4118, note: "TS2741x32, TS2322x17 — mostly the input-vs-output fixture confusion" }, + // Partially covered already: `tsconfig.typetests.json` compiles the test files + // whose whole value is compile-time type assertions (objectui#3181). The entry + // stays because the REST of the test tree is still unchecked — the number below + // is that remainder, not the whole package. "@object-ui/app-shell": { errors: 53, issue: 4118, note: "TS2339x24 — implementation wider than the type" }, "@object-ui/components": { errors: 31, issue: 4118, note: "TS7006x12, TS7031x12 — untyped test callback params" }, "@object-ui/react": { errors: 27, issue: 4118, note: "TS2769x9 — overload mismatch on render helpers" }, @@ -158,6 +162,12 @@ function collect() { } catch { /* no test config */ } + let typeTestsConfig = null; + try { + typeTestsConfig = readFileSync(resolve(root, dir, "tsconfig.typetests.json"), "utf8"); + } catch { + /* no type-tests config */ + } const typeCheck = pkg.scripts?.["type-check"] ?? ""; out.push({ name: pkg.name, @@ -176,6 +186,12 @@ function collect() { // The config existing is not the same as anything running it — that gap // IS objectui#3009. `type-check` has to chain it. chainsTestConfig: /-p\s+tsconfig\.test\.json/.test(typeCheck), + hasTypeTestsConfig: typeTestsConfig !== null, + typeTestsConfig, + // Same question, same answer, for the narrow type-assertion project + // (objectui#3181): `type-check` is what CI runs, so `type-check` — not + // some sibling script CI never invokes — has to be the thing that runs it. + chainsTypeTestsConfig: /-p\s+tsconfig\.typetests\.json/.test(typeCheck), }); } } @@ -324,6 +340,47 @@ for (const pkg of packages) { } } +// ── 5½. The narrow type-assertion project, if a package has one ────────────── +// Some test files exist ONLY for their COMPILE-TIME assertions (`Assert>`). Types are erased at runtime, so vitest proves nothing about those; only +// `tsc` does. That is objectui#3181: app-shell's `spec-symbol-parity.test.ts` +// carried a header calling its assertions a tripwire, while a provably-false +// `Assert>` appended to it passed `pnpm type-check` at exit 0. +// +// A package whose test tree is still in TEST_DEBT can rescue those specific +// files with a `tsconfig.typetests.json` that lists them explicitly, instead of +// waiting for the whole backlog. This section keeps that project honest — it is +// worth exactly as much as the gate that runs it, and nothing at all otherwise. +for (const pkg of packages) { + if (!pkg.hasTypeTestsConfig) continue; + + if (!pkg.chainsTypeTestsConfig) { + errors.push( + `${pkg.name} (${pkg.dir}) has a tsconfig.typetests.json that its "type-check" script never\n` + + ` runs, so its compile-time assertions are erased at runtime and compiled by nothing —\n` + + ` the exact state objectui#3181 fixed. CI runs \`pnpm type-check\` (turbo -> the package's\n` + + ` own "type-check"), so that is the script that has to chain it:\n` + + ` "type-check": "tsc --noEmit && tsc -p tsconfig.typetests.json"` + ); + continue; + } + + if (!/"noEmit"\s*:\s*true/.test(pkg.typeTestsConfig)) { + errors.push( + `${pkg.name} (${pkg.dir}): tsconfig.typetests.json must set "noEmit": true — it is a checking\n` + + ` project, and emitting would put test output in the published dist.` + ); + } + + if (!/\.test\.tsx?"/.test(pkg.typeTestsConfig)) { + errors.push( + `${pkg.name} (${pkg.dir}): tsconfig.typetests.json does not "include" any test file, so it\n` + + ` compiles nothing and passes vacuously. List the files whose type assertions it exists\n` + + ` to check, e.g. "include": ["src/__tests__/spec-symbol-parity.test.ts"]` + ); + } +} + // 6. Ratchet — a declared test gap that has been closed must leave the list. for (const [name, spec] of Object.entries(TEST_DEBT)) { const pkg = byName.get(name); @@ -363,6 +420,7 @@ const byBuild = Object.keys(CHECKED_BY_OWN_BUILD).length; const withTests = packages.filter((p) => p.hasScript && p.testFiles > 0); const testsChecked = withTests.filter(testsCovered).length; const testDebtErrors = Object.values(TEST_DEBT).reduce((sum, d) => sum + d.errors, 0); +const typeTestProjects = packages.filter((p) => p.hasTypeTestsConfig && p.chainsTypeTestsConfig).length; if (errors.length === 0) { console.log( @@ -373,7 +431,8 @@ if (errors.length === 0) { ); console.log( `✅ test type-check coverage: ${testsChecked}/${withTests.length} packages compile their tests, ` + - `${Object.keys(TEST_DEBT).length} declared debt (${testDebtErrors} errors outstanding).` + `${Object.keys(TEST_DEBT).length} declared debt (${testDebtErrors} errors outstanding), ` + + `${typeTestProjects} with a narrow type-assertion project.` ); process.exit(0); }