diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index e2ef6e1d63b..e25555975e3 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -5106,6 +5106,16 @@ jobs: continue-on-error: true run: pnpm check:type-check-coverage + # The workspace ROOT's own program (`tsconfig.json`: `scripts/` and the + # top-level configs) is outside every package's `typecheck`, so the turbo + # typecheck never reads it. This step does (#26236), and the coverage gate + # above requires it once `typecheck:root` exists. The program imports no + # built `dist/`, so it needs no build. Sub-second to a few seconds. + - name: Type-check the workspace root program + id: type-check-the-workspace-root-program + continue-on-error: true + run: pnpm typecheck:root + # Conformance meta-gate (#4363): `spec/src/data/*-conformance.ts` holds the # shared case-sets that exist so the independent drivers answer ONE # standard, and three separate changesets claimed a future driver "is held diff --git a/package.json b/package.json index ef4aaf89785..208b2b2614d 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "test": "VITEST_MAX_WORKERS=$(node scripts/vitest-worker-cap.mjs) turbo run test test:repo --concurrency=50%", "test:smoke": "turbo run test:smoke", "typecheck": "turbo run typecheck", + "typecheck:root": "tsc --noEmit -p tsconfig.json", "clean": "turbo run clean && rm -rf dist", "setup": "pnpm install && pnpm --filter @objectstack/spec build", "prepare": "node scripts/setup-git-hooks.mjs", diff --git a/scripts/check-type-check-coverage.mjs b/scripts/check-type-check-coverage.mjs index 5771361b471..b6d3bd06dad 100644 --- a/scripts/check-type-check-coverage.mjs +++ b/scripts/check-type-check-coverage.mjs @@ -663,12 +663,12 @@ const SPEC_SCRIPTS_PRECEDENT = 'packages/spec/tsconfig.scripts.json'; const TEST_ROOTDIR_PRECEDENT = '`packages/client/tsconfig.test.json` and `packages/cli/tsconfig.test.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 -// derivation can read it. It accounted for 29 of that entry's 80, and is 1 of -// the 26 standing now -- still the root script an ordinary card edits. The note on -// that entry carries the accounting and references this constant instead of -// spelling the path inside its sentence. +// A path in the root program, declared as a bare, whole-literal path so the +// dispatch derivation can read it. It accounted for 29 of the +// `@objectstack/spec-monorepo` DEBT entry's 80 errors, and for 1 of the 26 left +// when that entry graduated (#26236). The root program is now type-checked by the +// root's `typecheck:root` script, which lint.yml runs, and this is still the +// root script an ordinary card edits. // // Why the SHAPE and not just the mention: scripts/pm/dispatch-gates.mjs scans // each gate's own module body for path literals and accepts one only when the @@ -832,22 +832,17 @@ const ROOT_PROGRAM_COUPLED_SCRIPT = 'scripts/check-test-typecheck.mts'; // fixed the test config showed 4 more (TS2883 on the `vi.fn()` fields of // `src/__mocks__/runtime.ts`, annotated `Mock`) that the build config never shows. // No cast, no `@ts-expect-error`, no `src` signature widened. -const DEBT = { - '@objectstack/spec-monorepo': { - errors: 26, - note: 'the workspace root itself: config-tier 18 (TS2307 x17, TS2550 x1); noise 8 (TS7006 x7, ' - + 'TS6133 x1). The root `tsconfig.json` is the program for `scripts/` and the top-level configs and ' - + 'declares `types:["node"]`; packages extend `tsconfig.base.json` instead. Declaring the Node types ' - + 'moved this count by zero -- the TS2591 / TS2584 pile an older note blamed on their absence was ' - + 'already gone. What stands: the analytics-reconcile tree imports `@objectstack/*` and example ' - + 'sources the root program cannot resolve (the root package declares no workspace dependency), ' - + 'which is all 17 TS2307 and the 7 TS7006 they cascade into; ' + ROOT_PROGRAM_COUPLED_SCRIPT - + ' calls `Object.hasOwn` under lib ES2020 (TS2550); and `exclude` only drops files from the ' - + 'initial walk, so an example source a script imports is still compiled here -- the TS6133 is in ' - + 'examples/app-showcase/src, and this entry moves with the showcase as well as with scripts/. ' - + 'Graduating also needs a `typecheck:root` script and its step in .github/workflows/lint.yml.', - }, -}; +// +// `@objectstack/spec-monorepo` -- the workspace ROOT itself -- GRADUATED from this +// ledger (#26236; entry: 26 raw, repaired to 0). Its program is the root +// `tsconfig.json`: `scripts/` and the top-level configs. 24 of the 26 were the +// obsolete ADR-0021 analytics-reconcile harness, which was deleted (its imports hid +// 31 more errors on keys the spec had removed). The other two were `Object.hasOwn` +// under lib ES2020, fixed by giving the root program lib ES2022, and an unused +// const in an example source a script imported. Graduated the root's way: a +// `typecheck:root` script plus its step in .github/workflows/lint.yml, both of +// which the root branch of `evaluate` requires. +const DEBT = {}; // Package name -> why running tsc over it is not applicable at all. An EXEMPT // entry is a statement about the package's nature, not about its debt; if the diff --git a/scripts/pm/dispatch-gates.data.mjs b/scripts/pm/dispatch-gates.data.mjs index 5b9868fe1e5..9dac7991da6 100644 --- a/scripts/pm/dispatch-gates.data.mjs +++ b/scripts/pm/dispatch-gates.data.mjs @@ -759,9 +759,9 @@ export const ESCAPABLE_LITERAL_LEDGER_ROWS = Object.freeze([ * * ⚠ Its known limit, stated here rather than discovered later: `exclude` drops * files only from tsc's INITIAL WALK, so sources under an excluded directory - * that a root script IMPORTS are pulled into the program anyway — the ledger - * note for this entry records 4 of its 80 errors arriving exactly that way, - * from the showcase example. A path-shaped trigger cannot see an import graph, + * that a root script IMPORTS are pulled into the program anyway — the root's + * DEBT note recorded 4 of its 80 errors arriving exactly that way, from the + * showcase example, before the root graduated. A path-shaped trigger cannot see an import graph, * so a card editing only such a file is still not sent here. That is * deliberately NOT closed on this card: the direction is the safe one (this * entry under-covers rather than over-covers), and closing it means resolving @@ -978,8 +978,8 @@ export const CHANGE_KIND_ROWS = [ matches: 'root-ts-program', gates: [ { - name: 'check:type-check-debt', - why: 'the ROOT ledger entry (@objectstack/spec-monorepo) IS this program, so a file here moves its raw tsc count even though your diff touches no package — measured, one added bench file put it 19 over and cost a CI round. It is a shrink-only ratchet: the repair is to make the file typecheck, and raising the entry is maintainer-only — ⛔ MAINTAINER-ONLY under the #8435 convention — never the co-equal option. Most of this class is one missing setting rather than real breakage — the root config carries lib ES2020 and no types, so process and console are absent unless the file declares them ambiently. Needs the workspace closure BUILT — on an unbuilt worktree it refuses outright, and that throw means NOT MEASURED, never `not applicable to me`. Build first, exactly as lint.yml does: pnpm exec turbo run build --filter=./packages/* --filter=./packages/*/* (quote the filter values for your shell)', + name: 'typecheck:root', + why: 'nothing else type-checks this program: it belongs to no package, so the turbo typecheck never reads a file here, and the root has no ledger entry left to absorb an error. lint.yml runs this script in the Type Check source-gates lane, and one new error reds that lane. The repair is to make the file typecheck, never to add an exclude or bring back a DEBT entry. It needs no build: the program reads no package dist. The program carries lib ES2022 and types node, so `process`, `console` and the `node:*` builtins resolve without a reference line', }, ], }, diff --git a/scripts/pm/dispatch-gates.mjs b/scripts/pm/dispatch-gates.mjs index 500401e48a1..3f3229dfbb7 100644 --- a/scripts/pm/dispatch-gates.mjs +++ b/scripts/pm/dispatch-gates.mjs @@ -2223,7 +2223,10 @@ export function extractCheckInvocations(workflowText, workflowFile, { via = null // this card is about: a value that reaches the program only through // `process.env`, which no reader of the command line can see. const carriedEnv = envNamesNotSpelledInCommand(cmd, stepEnv); - for (const m of cmd.matchAll(/pnpm\s+(?:--filter\s+(\S+)\s+)?(?:run\s+)?(check:[\w:-]+)/g)) { + // `typecheck:root` is the one non-`check:` script read here: it is the ROOT + // program's only type check, and the root-program change kind names it, so it + // has to resolve to a family or that row renders STALE. + for (const m of cmd.matchAll(/pnpm\s+(?:--filter\s+(\S+)\s+)?(?:run\s+)?(check:[\w:-]+|typecheck:root(?![\w:-]))/g)) { out.push(withVia({ check: m[2], filter: m[1] ?? null, workflow: workflowFile, envVariables: carriedEnv })); } for (const m of cmd.matchAll(DIRECT_CHECK_INVOCATION)) { @@ -10779,8 +10782,9 @@ export function guardsADurabilityCriticalCall(path, readSource = readTrackedSour } /** - * Is this path inside the ROOT package's tsc program — the population behind - * the `@objectstack/spec-monorepo` entry of `check:type-check-debt`? + * Is this path inside the ROOT package's tsc program — the population the + * root's `typecheck:root` script checks (until the root graduated, the + * `@objectstack/spec-monorepo` entry of `check:type-check-debt`)? * * This is the one gate population in the tree that no path literal can ever * describe, in principle rather than by omission. The root program is declared diff --git a/scripts/pm/dispatch-gates.self-test.mjs b/scripts/pm/dispatch-gates.self-test.mjs index 30a0e7db053..bc933db9027 100644 --- a/scripts/pm/dispatch-gates.self-test.mjs +++ b/scripts/pm/dispatch-gates.self-test.mjs @@ -464,6 +464,14 @@ export function selfTest({ tier = 'full' } = {}) { + 'right names a file this ROOT does not hold, and every audit downstream then passes for the wrong reason', !pkgInvs.some((i) => String(i.script ?? i.check).startsWith('scripts/')), ); + t( + 'the root program\'s `typecheck:root` is read as a family, so the root change kind can name it', + extractCheckInvocations(' - run: pnpm typecheck:root\n', 'lint.yml').some((i) => i.check === 'typecheck:root' && !i.direct), + ); + t( + 'control — that exception is the one script name, not every `typecheck` spelling', + extractCheckInvocations(' - run: pnpm typecheck\n - run: pnpm typecheck:rooted\n - run: pnpm --filter ./examples/x run typecheck\n', 'x.yml').length === 0, + ); t( 'control — a CLIMBING spelling is refused rather than resolved: `..` is not a prefix segment, because ' + 'a path this ROOT cannot resolve is exactly how a phantom identity key gets minted', @@ -1488,7 +1496,12 @@ export function selfTest({ tier = 'full' } = {}) { // resolve. const rootScriptsMap = JSON.parse(readFileSync(nodePath.join(ROOT, 'package.json'), 'utf8')).scripts ?? {}; const zeroFile = tsLiveFamilies.filter(([, e]) => (e.files ?? []).length === 0); + // `typecheck:root` is the one other zero-file family, by construction: it is a + // tsc program over the root config, not a script file, and the cards it owes + // are named by the root-program change kind (`root-ts-program`), never by a + // path read out of a script. const unexplained = zeroFile + .filter(([check]) => check !== 'typecheck:root') .map(([check]) => [check, rootScriptsMap[check] ?? '']) .filter(([, cmd]) => !/\bpnpm\b[^&|]*?(?:--filter|--recursive|-r)\b[^&|]*?\brun\b/.test(cmd)) .map(([check, cmd]) => `${check} (${cmd || 'no root script'})`); @@ -4423,10 +4436,12 @@ export function selfTest({ tier = 'full' } = {}) { // rename, which is the one rot class the STALE branch exists to report. const rootKind = changeKindLines(['scripts/bench/runtime-publish-gate.bench.mts'], resolved); t('a root-program path emits the convention section', rootKind.length === 2 && rootKind[0].includes('ROOT tsc program')); - t('and it names the RATCHET half — the invocation that re-measures', rootKind.some((l) => l.includes('- pnpm check:type-check-debt —'))); - const rootLine = rootKind.find((l) => l.includes('- pnpm check:type-check-debt —')) ?? ''; - t('the root-program line refuses the baseline raise and states the real repair', /shrink-only/.test(rootLine) && /maintainer-only/.test(rootLine)); - t('and carries the built-closure prerequisite, like the other ratchet line', /closure BUILT/.test(rootLine) && rootLine.includes('turbo run build')); + // The root graduated from the DEBT ledger (#26236), so the gate that reads + // this program is its own `typecheck:root` script, which lint.yml runs. + t('and it names the root program\'s own type check', rootKind.some((l) => l.includes('- pnpm typecheck:root —'))); + const rootLine = rootKind.find((l) => l.includes('- pnpm typecheck:root —')) ?? ''; + t('the root-program line refuses an exclude or a returning ledger entry and states the real repair', /never to add an exclude/.test(rootLine) && /make the file typecheck/.test(rootLine)); + t('and says it needs no build, unlike the ratchet lines', /needs no build/.test(rootLine) && !/closure BUILT/.test(rootLine)); // Re-pointed rather than deleted (#12074). This case pinned one property — // a `.mjs` checker script is NOT in the ROOT tsc program, unlike the `.mts` // bench file above — and the gate-script kind below now makes the same path @@ -4436,7 +4451,7 @@ export function selfTest({ tier = 'full' } = {}) { // hiding inside a `length` this case no longer checks. const checkerKind = changeKindLines(['scripts/check-type-check-coverage.mjs'], resolved); t('a checker script is still outside the ROOT tsc program', !checkerKind.some((l) => l.includes('ROOT tsc program'))); - t('…and the root ratchet is not named for it', !checkerKind.some((l) => l.includes('- pnpm check:type-check-debt —'))); + t('…and the root type check is not named for it', !checkerKind.some((l) => l.includes('- pnpm typecheck:root —'))); t('…and the ONE section it does emit is the gate-script kind', checkerKind.length === 3 && checkerKind[0].includes('GATE SCRIPT')); // -- The GATE SCRIPT entry (#12074) -------------------------------------- @@ -5813,11 +5828,12 @@ export function selfTest({ tier = 'full' } = {}) { // Seven, not six, since #10542 added check:cross-package-test-inputs to the // test-file kind. `a.test.ts` is a root-level TypeScript file, so it is BOTH // a test file and inside the root tsc program and legitimately hits two - // kinds. The ratchet therefore renders twice, under a different `why` each - // time — pinned just below, because a bare count cannot tell that apart from - // one kind rotting away. + // kinds. Each kind renders its own gate as STALE (the ratchet under the test + // kind, `typecheck:root` under the root kind since the root graduated) — + // pinned just below, because a bare count cannot tell that apart from one + // kind rotting away. t('an undiscoverable gate renders as STALE', stale.filter((l) => l.includes('STALE')).length === 7); - t('a root-level test file hits both kinds, so the ratchet renders STALE under each', stale.filter((l) => l.includes('\u26a0 check:type-check-debt: STALE')).length === 2); + t('a root-level test file hits both kinds: the ratchet renders STALE under the test kind, the root type check under the root kind', stale.filter((l) => l.includes('\u26a0 check:type-check-debt: STALE')).length === 1 && stale.filter((l) => l.includes('\u26a0 typecheck:root: STALE')).length === 1); // Per NAME, anchored on both sides of the rendered name (`⚠ x: STALE`), so the // pair that shares one script is reported apart: a count alone stays green if // one of the two is dropped from the table and something else is added, and a