From 25b8ddd78898e5a944a542449ff24178eecb9245 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 16:10:18 +0000 Subject: [PATCH] =?UTF-8?q?feat(gate):=20GENERATED=5FCOVERED=20=E2=80=94?= =?UTF-8?q?=20a=20tsconfig=20include=20rooted=20in=20a=20generated=20path?= =?UTF-8?q?=20must=20be=20produced=20by=20the=20typecheck=20script,=20or?= =?UTF-8?q?=20declared=20as=20deliberately=20not=20produced?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt --- scripts/check-type-check-coverage.mjs | 665 +++++++++++++++++++++++++- 1 file changed, 650 insertions(+), 15 deletions(-) diff --git a/scripts/check-type-check-coverage.mjs b/scripts/check-type-check-coverage.mjs index f927234cc3..7f46147fef 100644 --- a/scripts/check-type-check-coverage.mjs +++ b/scripts/check-type-check-coverage.mjs @@ -123,6 +123,47 @@ // invariant kept the two apart -- 54 root configs, 11 files in a // source directory. The exclusion is argued in full, both ways, // on UNCHECKED_SOURCE_DEBT below. +// GENERATED_COVERED +// a tsconfig `include` entry rooted in a GENERATED path -- one the +// repo's own ignore rules say is not checked in -- is either +// produced by the package's own `typecheck` script BEFORE tsc +// runs, or carries a row in GENERATED_INCLUDE_ROOTS below saying +// it is deliberately not produced, and why (#10880). +// +// One notch further along than REAL, and the distance is the +// whole point. REAL asks whether the script invokes tsc at all; +// this asks whether the PROGRAM tsc gets is the one the config +// advertises. `apps/docs/tsconfig.json` includes +// `.next/types/**/*.ts`, written by `next typegen`, and the wired +// script was a bare `tsc --noEmit`: measured on main @ 7d483e1e5f +// with `.next` deleted, it exited 0 having compiled none of the +// generated route types (`tsc --listFiles` 1225 -> 1231 once +// typegen ran, the difference including a 160-line `validator.ts` +// that checks 13 route entry points). An ablation gave a layout a +// `params` shape its route cannot supply: the bare script stayed +// at exit 0 / 0 errors and the typegen'd one failed TS2344. +// COVERED, REAL, TESTS_COVERED, SOURCES_COVERED and RUNNABLE all +// passed the whole time, because every one of them is a question +// about files that EXIST. +// +// The failure DIRECTION is what makes this an invariant rather +// than a lint. An absent generated directory makes tsc read GREEN +// over files that were never in the program -- silence that looks +// exactly like success. Every other way of getting the same +// config wrong (a stale directory, a generator that fails) ends +// in a red somebody reads. +// +// "Declared, and deliberately NOT generated" has to be a passing +// state, because deleting the offending glob is not available as +// a repair: Next owns that array and `writeConfigurationDefaults` +// re-adds `.next/dev/types/**/*.ts` on the next `next dev` / +// `next build` (#10879 measured it by running that routine +// against a narrowed copy -- the glob came straight back and the +// file was rewritten). A guard that only accepted "generated by +// the script" would leave the honest answer unsayable and push +// the next author toward a narrowing Next undoes. So a row may +// declare no generator at all -- with a reason, which is the part +// a human reviews and the part nothing mechanical can supply. // PINS_CHECKED // a test file containing a `@ts-expect-error` directive sits // inside a tsc program, or is listed in PHANTOM_PIN_DEBT below. @@ -399,6 +440,7 @@ const PIN_ISSUE = 'https://github.com/objectstack-ai/objectstack/issues/5286'; // finding later used ("the directory was never INCLUDED by anything"), which is // why the remedy names a file rather than describing a shape (#10756). const SPEC_SCRIPTS_PRECEDENT = 'packages/spec/tsconfig.scripts.json'; +const GENERATED_INCLUDE_ISSUE = 'https://github.com/objectstack-ai/objectstack/issues/10880'; // A path in the root program whose edits move the `@objectstack/spec-monorepo` // count below, declared as a bare, whole-literal path so the dispatch @@ -1042,6 +1084,67 @@ const UNCHECKED_SOURCE_DEBT = { 'packages/services/service-realtime/scripts': '`i18n-extract.config.ts`, 3 x TS2883. See #10868.', }; +/** + * GENERATED_COVERED's declared table (#10880) -- the generated `include` roots + * this workspace's tsconfigs name, each paired with the command that produces + * it, so the framework-specific knowledge sits in DATA and the invariant stays + * a question about scripts. + * + * Keyed by the root PACKAGE-RELATIVE (`.next/types`, not + * `apps/docs/.next/types`), so one row holds for every Next app the workspace + * ever gains rather than for the one that seeded it. + * + * Two row kinds, and the second is why this is a table rather than a hard-coded + * `next typegen` test: + * + * generator: '' the `typecheck` script must run it, and must run + * it BEFORE tsc. Enforced. + * generator: null nothing a typecheck runs produces this, ON + * PURPOSE. `why` then has to carry the argument + * that its absence cannot make the check read + * green -- the one claim here nothing mechanical + * can check, which is exactly why it is written + * down where a reviewer reads it rather than + * inferred. + * + * NOT a debt ledger, and deliberately not closed the way DEBT and + * UNCHECKED_SOURCE_DEBT are: a `generator` row is durable knowledge about a + * correct config, not a hole waiting to be filled. It is still RECONCILED in + * the other direction -- a row no `include` names any more is dead knowledge + * and has to go. + * + * The generator claims below are read out of next@16.3.1, the version + * `apps/docs` resolves (`dist/cli/next-typegen.js`), not inferred from the + * command name. + */ +const GENERATED_INCLUDE_ROOTS = { + 'next-env.d.ts': { + generator: 'next typegen', + why: 'Written by `writeAppTypeDeclarations`, which `next typegen` reaches through ' + + '`verifyAndRunTypeScript` (`dist/cli/next-typegen.js` -> `dist/lib/verify-typescript-setup.js`) ' + + 'before it generates a route type. Gitignored (`next-env.d.ts`, root .gitignore), so in a clean ' + + 'checkout the entry names nothing until that command has run.', + }, + '.next/types': { + generator: 'next typegen', + why: 'The route types themselves: `next typegen` writes `/types/routes.d.ts`, ' + + '`validator.ts`, `cache-life.d.ts` and `root-params.d.ts` into exactly this directory ' + + '(`dist/cli/next-typegen.js`). This is the root #10880 was filed about -- a bare `tsc --noEmit` ' + + 'read green over its absence and an ablation proved the missing files were load-bearing.', + }, + '.next/dev/types': { + generator: null, + why: 'Written only by `next dev`, and a typecheck must NOT run a dev server to satisfy a glob. Its ' + + "absence cannot make this check read green: it holds no route entry point of its own -- Next's " + + 'own build-mode type check FILTERS this directory out of the program (`getDevTypesPath`, ' + + '`lib/typescript/runTypeCheck.js`) "to prevent stale dev types from causing errors when routes ' + + 'have been deleted since the last dev session", so what it can produce locally is a false RED, ' + + 'never a false green (remedy: `rm -rf apps/docs/.next`). The glob cannot be deleted either -- ' + + '`writeConfigurationDefaults` re-adds it on the next `next dev` / `next build` (#10879). ' + + 'Declared, and deliberately not generated.', + }, +}; + /** * The `packages:` globs from pnpm-workspace.yaml. Blank lines and comments are * skipped rather than treated as the end of the list: stopping early would @@ -1078,6 +1181,23 @@ function workspaceGlobs() { * * @returns {{file: string, roots: string[], excludesTests: boolean}} */ +/** + * The fixed path prefix of an `include` glob -- everything before the first + * wildcard, with a trailing slash dropped (`src/**\/*` -> `src`, + * `.next/types/**\/*.ts` -> `.next/types`, `**\/*.ts` -> `''`, i.e. the whole + * package). A literal entry with no wildcard at all IS its own root + * (`next-env.d.ts`). + * + * Extracted rather than inlined twice because two invariants now read it and + * they must agree: TESTS_COVERED/SOURCES_COVERED decide what a program reads + * from these prefixes, and GENERATED_COVERED asks whether the same prefix is + * checked in. Two copies of this three-operation expression would be two + * chances to answer the same question differently (#10880). + */ +function includeRoot(glob) { + return glob.split('*')[0].replace(/\/$/, ''); +} + function readTsconfig(dir, file) { const raw = readFileSync(join(ROOT, dir, file), 'utf8').replace(/^\s*\/\/.*$/gm, ''); let parsed; @@ -1087,13 +1207,20 @@ function readTsconfig(dir, file) { throw new Error(`${dir}/${file} is not parseable, so its test coverage cannot be judged`, { cause }); } const include = parsed.include ?? null; - const roots = Array.isArray(include) && include.length > 0 - ? [...new Set(include.map((g) => g.split('*')[0].replace(/\/$/, '')).filter((p) => !p.includes('..')))] + const declared = Array.isArray(include) ? include.filter((g) => typeof g === 'string') : []; + const roots = declared.length > 0 + ? [...new Set(declared.map(includeRoot).filter((p) => !p.includes('..')))] : ['']; const exclude = parsed.exclude ?? []; return { file, roots, + // The `include` array AS AUTHORED, which `roots` cannot stand in for: it is + // deduped and star-stripped, so it can no longer say WHICH glob promised a + // given root. GENERATED_COVERED quotes the entry back at the author + // (#10880), and a message naming `.next/types` where the file says + // `.next/types/**/*.ts` sends them looking for a line that is not there. + includes: declared, excludesTests: exclude.some((pattern) => TEST_GLOB.test(pattern)), // The NON-test exclusions, as path prefixes, for SOURCES_COVERED (#10756). // TESTS_COVERED never needed these -- an `exclude` that hides a test file @@ -1117,26 +1244,157 @@ function configCovers(config, rel) { } /** - * Which tsconfig files does the `typecheck` script actually put in front of - * tsc? Expanded through same-package `pnpm