Skip to content

Commit f60f4ea

Browse files
ci(typecheck): graduate the workspace root from the type-check DEBT ledger and run typecheck:root in lint.yml (#26339)
<!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi95T4eukLFQZWx7jmjMoEvhv)_ Fixes #26236 Clause-②: no Metadata-protocol: none ⛔ **Tier H.** This diff touches `.github/workflows/lint.yml`, so it stays a draft until an authorized APPROVED review from a `GOVERNED_APPROVERS` account. No seat readies, queues or arms it before then. ## What this does Wanted 2 and 3 of the card. Wanted 1 (the root program at 0 errors) landed in #26280. 1. **The root graduates from the type-check DEBT ledger.** `scripts/check-type-check-coverage.mjs` deletes the `@objectstack/spec-monorepo` entry, so `DEBT` is now empty. A graduation paragraph is added in the ledger's own style, and the `ROOT_PROGRAM_COUPLED_SCRIPT` comment no longer cites the 26. 2. **The root gets `typecheck:root`** in `package.json`: `tsc --noEmit -p tsconfig.json`. 3. **`lint.yml` runs it.** A new step, "Type-check the workspace root program", sits in the `typecheck-source-gates` lane, right after the coverage meta-gate. Like every gate step in that lane, it has `continue-on-error: true` and an `id`, so `lane-verdict.mjs` reads its outcome. The root program reads no package `dist/`: `tsc --listFiles` over it lists zero paths under `packages/`, so the step needs no build. 4. **Dispatch briefs name the new gate.** In `scripts/pm/dispatch-gates.data.mjs`, the change kind for "adds or edits TypeScript in the ROOT tsc program" named `check:type-check-debt`. Once the root graduates, that gate no longer measures the root, so a card editing a root `.mts` would be sent to a gate that cannot red on it, and the error would first show in CI. The kind now names `typecheck:root`, and its `why` is rewritten to match. - For that name to resolve, the family parser in `scripts/pm/dispatch-gates.mjs` (`extractCheckInvocations`) reads `pnpm typecheck:root` as a family. This is the one non-`check:` name it admits. - Without that, the row renders STALE: "no workflow runs a gate under this name". - Self-test pins in `scripts/pm/dispatch-gates.self-test.mjs` follow the rename. Two parser cases are added: `typecheck:root` is read, and `typecheck`, `typecheck:rooted` and a filtered `run typecheck` are not. ## Why the three move together The coverage gate's root branch (around L2809) reports a finding when `typecheck:root` exists but `lint.yml` does not name it (`ciInvokesRoot`, around L4862). Without the script, deleting the entry reports "no `typecheck:root` script and no ledger entry". An entry of 0 is refused. Reverse check on this branch: with the entry deleted and the script added but no `lint.yml` step, `node scripts/check-type-check-coverage.mjs` exits 1 with ".github/workflows/lint.yml never invokes `typecheck:root`". With the step added, it exits 0. ## Acceptance All at head d750e1f unless stated, exit codes captured before any pipe. - `pnpm typecheck:root`: exit 0. - `node scripts/check-type-check-coverage.mjs --self-test`: exit 0 (58 semantic + 97 observation + 56 re-measure + 28 built-closure + 19 auto-lowering + 18 exit-code + 19 verify-lock cases). `node scripts/check-type-check-coverage.mjs`: exit 0, "0 in the DEBT ledger". - `pnpm check:type-check-debt` (self-test, then `--re-measure` under the verify lock): exit 0, "0 ledger entr(ies) re-measured in 540.7s, 0 raw tsc error(s) total". - Reverse check (see above): without the `lint.yml` step, the coverage gate exits 1. With it, the gate exits 0. - `node scripts/pm/check-dispatch-gates.mjs --slow` (both tiers): exit 0, "dispatch-gates self-test: 2166 cases pass" (fast 1901, slow 265). `pnpm check:pm-dispatch-gates` (fast tier) re-run at d750e1f: exit 0, 1901 cases. - Derived families (`dispatch-gates --commands`, unchanged between 24f2191 and d750e1f): 65 derived, 65 run, every one exit 0. `--ran` verdict: "65 derived famil(ies) accounted for — 65 run, 0 NOT-MEASURED (a DERIVED zero — all 65 recorded an exit code and none of them is 3)". `check:pm-dispatch-gates` first went red at 24f2191, on the pin "every zero-file family left is a pnpm workspace composite — UNEXPLAINED: typecheck:root". `typecheck:root` is a zero-file family by construction, so d750e1f names it in that pin with the reason. The other 64 families ran at 24f2191; the only file that differs at d750e1f is `scripts/pm/dispatch-gates.self-test.mjs`, which the re-run families cover. ## Acceptance notes - `scripts/check-exported-any-returns.mts` (L105–110) and the "Why the ROOT-program entry does not weaken the ledger's discipline" docblock in `scripts/pm/dispatch-gates.data.mjs` still tell the history of the root DEBT entry. They are historical prose, and no assertion reads them, so they are left as they are. - A card that edits only `.mjs` files under `scripts/` is not in the root program (no `allowJs`), so it does not derive `typecheck:root`. That is the existing, intended scope of the root-program kind. ## 维护者速读(草稿) **改了什么**:仓库根目录的 `scripts/` 程序在上一个 PR(#26280)里已经做到 0 个类型错误。这个 PR 把它从类型债账本里正式毕业:删掉账本条目,新增 `pnpm typecheck:root`,并在 lint 工作流的 Type Check 流水线里加一步运行它。派发工具对「改了根目录 TS 文件」的提示也改成指向这一步。 **为什么改**:之前根目录的脚本(门禁、PM 工具、发布脚本)没有任何 PR 检查会做类型检查,账本里那 26 条错误一直挂着,新脚本带进来的类型错误也拦不住。现在每个 PR 都会跑一遍,新错误当场变红。 **风险与代价(含回滚)**:新增的一步只跑一次 tsc,不需要构建,耗时几秒。风险是以后改根目录脚本的人会多遇到一种红灯,这正是要的效果。回滚就是 revert 这个 PR,账本条目、脚本和工作流这一步会一起回去。 **席位意见**: **你要做的**:因为改了 `.github/workflows/lint.yml`,这个 PR 需要你(GOVERNED_APPROVERS)批准,批准后由席位排队合并。 --- _Generated by [Claude Code](https://claude.ai/code/session_01ENjVvxdwZxosWSS4Fqazu1)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent df6dac8 commit f60f4ea

6 files changed

Lines changed: 65 additions & 39 deletions

File tree

‎.github/workflows/lint.yml‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5106,6 +5106,16 @@ jobs:
51065106
continue-on-error: true
51075107
run: pnpm check:type-check-coverage
51085108

5109+
# The workspace ROOT's own program (`tsconfig.json`: `scripts/` and the
5110+
# top-level configs) is outside every package's `typecheck`, so the turbo
5111+
# typecheck never reads it. This step does (#26236), and the coverage gate
5112+
# above requires it once `typecheck:root` exists. The program imports no
5113+
# built `dist/`, so it needs no build. Sub-second to a few seconds.
5114+
- name: Type-check the workspace root program
5115+
id: type-check-the-workspace-root-program
5116+
continue-on-error: true
5117+
run: pnpm typecheck:root
5118+
51095119
# Conformance meta-gate (#4363): `spec/src/data/*-conformance.ts` holds the
51105120
# shared case-sets that exist so the independent drivers answer ONE
51115121
# standard, and three separate changesets claimed a future driver "is held

‎package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"test": "VITEST_MAX_WORKERS=$(node scripts/vitest-worker-cap.mjs) turbo run test test:repo --concurrency=50%",
1414
"test:smoke": "turbo run test:smoke",
1515
"typecheck": "turbo run typecheck",
16+
"typecheck:root": "tsc --noEmit -p tsconfig.json",
1617
"clean": "turbo run clean && rm -rf dist",
1718
"setup": "pnpm install && pnpm --filter @objectstack/spec build",
1819
"prepare": "node scripts/setup-git-hooks.mjs",

‎scripts/check-type-check-coverage.mjs‎

Lines changed: 17 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -663,12 +663,12 @@ const SPEC_SCRIPTS_PRECEDENT = 'packages/spec/tsconfig.scripts.json';
663663
const TEST_ROOTDIR_PRECEDENT = '`packages/client/tsconfig.test.json` and `packages/cli/tsconfig.test.json`';
664664
const GENERATED_INCLUDE_ISSUE = 'https://github.com/objectstack-ai/objectstack/issues/10880';
665665

666-
// A path in the root program whose edits move the `@objectstack/spec-monorepo`
667-
// count below, declared as a bare, whole-literal path so the dispatch
668-
// derivation can read it. It accounted for 29 of that entry's 80, and is 1 of
669-
// the 26 standing now -- still the root script an ordinary card edits. The note on
670-
// that entry carries the accounting and references this constant instead of
671-
// spelling the path inside its sentence.
666+
// A path in the root program, declared as a bare, whole-literal path so the
667+
// dispatch derivation can read it. It accounted for 29 of the
668+
// `@objectstack/spec-monorepo` DEBT entry's 80 errors, and for 1 of the 26 left
669+
// when that entry graduated (#26236). The root program is now type-checked by the
670+
// root's `typecheck:root` script, which lint.yml runs, and this is still the
671+
// root script an ordinary card edits.
672672
//
673673
// Why the SHAPE and not just the mention: scripts/pm/dispatch-gates.mjs scans
674674
// 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';
832832
// fixed the test config showed 4 more (TS2883 on the `vi.fn()` fields of
833833
// `src/__mocks__/runtime.ts`, annotated `Mock`) that the build config never shows.
834834
// No cast, no `@ts-expect-error`, no `src` signature widened.
835-
const DEBT = {
836-
'@objectstack/spec-monorepo': {
837-
errors: 26,
838-
note: 'the workspace root itself: config-tier 18 (TS2307 x17, TS2550 x1); noise 8 (TS7006 x7, '
839-
+ 'TS6133 x1). The root `tsconfig.json` is the program for `scripts/` and the top-level configs and '
840-
+ 'declares `types:["node"]`; packages extend `tsconfig.base.json` instead. Declaring the Node types '
841-
+ 'moved this count by zero -- the TS2591 / TS2584 pile an older note blamed on their absence was '
842-
+ 'already gone. What stands: the analytics-reconcile tree imports `@objectstack/*` and example '
843-
+ 'sources the root program cannot resolve (the root package declares no workspace dependency), '
844-
+ 'which is all 17 TS2307 and the 7 TS7006 they cascade into; ' + ROOT_PROGRAM_COUPLED_SCRIPT
845-
+ ' calls `Object.hasOwn` under lib ES2020 (TS2550); and `exclude` only drops files from the '
846-
+ 'initial walk, so an example source a script imports is still compiled here -- the TS6133 is in '
847-
+ 'examples/app-showcase/src, and this entry moves with the showcase as well as with scripts/. '
848-
+ 'Graduating also needs a `typecheck:root` script and its step in .github/workflows/lint.yml.',
849-
},
850-
};
835+
//
836+
// `@objectstack/spec-monorepo` -- the workspace ROOT itself -- GRADUATED from this
837+
// ledger (#26236; entry: 26 raw, repaired to 0). Its program is the root
838+
// `tsconfig.json`: `scripts/` and the top-level configs. 24 of the 26 were the
839+
// obsolete ADR-0021 analytics-reconcile harness, which was deleted (its imports hid
840+
// 31 more errors on keys the spec had removed). The other two were `Object.hasOwn`
841+
// under lib ES2020, fixed by giving the root program lib ES2022, and an unused
842+
// const in an example source a script imported. Graduated the root's way: a
843+
// `typecheck:root` script plus its step in .github/workflows/lint.yml, both of
844+
// which the root branch of `evaluate` requires.
845+
const DEBT = {};
851846

852847
// Package name -> why running tsc over it is not applicable at all. An EXEMPT
853848
// entry is a statement about the package's nature, not about its debt; if the

‎scripts/pm/dispatch-gates.data.mjs‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -759,9 +759,9 @@ export const ESCAPABLE_LITERAL_LEDGER_ROWS = Object.freeze([
759759
*
760760
* ⚠ Its known limit, stated here rather than discovered later: `exclude` drops
761761
* files only from tsc's INITIAL WALK, so sources under an excluded directory
762-
* that a root script IMPORTS are pulled into the program anyway — the ledger
763-
* note for this entry records 4 of its 80 errors arriving exactly that way,
764-
* from the showcase example. A path-shaped trigger cannot see an import graph,
762+
* that a root script IMPORTS are pulled into the program anyway — the root's
763+
* DEBT note recorded 4 of its 80 errors arriving exactly that way, from the
764+
* showcase example, before the root graduated. A path-shaped trigger cannot see an import graph,
765765
* so a card editing only such a file is still not sent here. That is
766766
* deliberately NOT closed on this card: the direction is the safe one (this
767767
* entry under-covers rather than over-covers), and closing it means resolving
@@ -978,8 +978,8 @@ export const CHANGE_KIND_ROWS = [
978978
matches: 'root-ts-program',
979979
gates: [
980980
{
981-
name: 'check:type-check-debt',
982-
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)',
981+
name: 'typecheck:root',
982+
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',
983983
},
984984
],
985985
},

‎scripts/pm/dispatch-gates.mjs‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2223,7 +2223,10 @@ export function extractCheckInvocations(workflowText, workflowFile, { via = null
22232223
// this card is about: a value that reaches the program only through
22242224
// `process.env`, which no reader of the command line can see.
22252225
const carriedEnv = envNamesNotSpelledInCommand(cmd, stepEnv);
2226-
for (const m of cmd.matchAll(/pnpm\s+(?:--filter\s+(\S+)\s+)?(?:run\s+)?(check:[\w:-]+)/g)) {
2226+
// `typecheck:root` is the one non-`check:` script read here: it is the ROOT
2227+
// program's only type check, and the root-program change kind names it, so it
2228+
// has to resolve to a family or that row renders STALE.
2229+
for (const m of cmd.matchAll(/pnpm\s+(?:--filter\s+(\S+)\s+)?(?:run\s+)?(check:[\w:-]+|typecheck:root(?![\w:-]))/g)) {
22272230
out.push(withVia({ check: m[2], filter: m[1] ?? null, workflow: workflowFile, envVariables: carriedEnv }));
22282231
}
22292232
for (const m of cmd.matchAll(DIRECT_CHECK_INVOCATION)) {
@@ -10779,8 +10782,9 @@ export function guardsADurabilityCriticalCall(path, readSource = readTrackedSour
1077910782
}
1078010783

1078110784
/**
10782-
* Is this path inside the ROOT package's tsc program — the population behind
10783-
* the `@objectstack/spec-monorepo` entry of `check:type-check-debt`?
10785+
* Is this path inside the ROOT package's tsc program — the population the
10786+
* root's `typecheck:root` script checks (until the root graduated, the
10787+
* `@objectstack/spec-monorepo` entry of `check:type-check-debt`)?
1078410788
*
1078510789
* This is the one gate population in the tree that no path literal can ever
1078610790
* describe, in principle rather than by omission. The root program is declared

‎scripts/pm/dispatch-gates.self-test.mjs‎

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,14 @@ export function selfTest({ tier = 'full' } = {}) {
464464
+ 'right names a file this ROOT does not hold, and every audit downstream then passes for the wrong reason',
465465
!pkgInvs.some((i) => String(i.script ?? i.check).startsWith('scripts/')),
466466
);
467+
t(
468+
'the root program\'s `typecheck:root` is read as a family, so the root change kind can name it',
469+
extractCheckInvocations(' - run: pnpm typecheck:root\n', 'lint.yml').some((i) => i.check === 'typecheck:root' && !i.direct),
470+
);
471+
t(
472+
'control — that exception is the one script name, not every `typecheck` spelling',
473+
extractCheckInvocations(' - run: pnpm typecheck\n - run: pnpm typecheck:rooted\n - run: pnpm --filter ./examples/x run typecheck\n', 'x.yml').length === 0,
474+
);
467475
t(
468476
'control — a CLIMBING spelling is refused rather than resolved: `..` is not a prefix segment, because '
469477
+ 'a path this ROOT cannot resolve is exactly how a phantom identity key gets minted',
@@ -1488,7 +1496,12 @@ export function selfTest({ tier = 'full' } = {}) {
14881496
// resolve.
14891497
const rootScriptsMap = JSON.parse(readFileSync(nodePath.join(ROOT, 'package.json'), 'utf8')).scripts ?? {};
14901498
const zeroFile = tsLiveFamilies.filter(([, e]) => (e.files ?? []).length === 0);
1499+
// `typecheck:root` is the one other zero-file family, by construction: it is a
1500+
// tsc program over the root config, not a script file, and the cards it owes
1501+
// are named by the root-program change kind (`root-ts-program`), never by a
1502+
// path read out of a script.
14911503
const unexplained = zeroFile
1504+
.filter(([check]) => check !== 'typecheck:root')
14921505
.map(([check]) => [check, rootScriptsMap[check] ?? ''])
14931506
.filter(([, cmd]) => !/\bpnpm\b[^&|]*?(?:--filter|--recursive|-r)\b[^&|]*?\brun\b/.test(cmd))
14941507
.map(([check, cmd]) => `${check} (${cmd || 'no root script'})`);
@@ -4423,10 +4436,12 @@ export function selfTest({ tier = 'full' } = {}) {
44234436
// rename, which is the one rot class the STALE branch exists to report.
44244437
const rootKind = changeKindLines(['scripts/bench/runtime-publish-gate.bench.mts'], resolved);
44254438
t('a root-program path emits the convention section', rootKind.length === 2 && rootKind[0].includes('ROOT tsc program'));
4426-
t('and it names the RATCHET half — the invocation that re-measures', rootKind.some((l) => l.includes('- pnpm check:type-check-debt —')));
4427-
const rootLine = rootKind.find((l) => l.includes('- pnpm check:type-check-debt —')) ?? '';
4428-
t('the root-program line refuses the baseline raise and states the real repair', /shrink-only/.test(rootLine) && /maintainer-only/.test(rootLine));
4429-
t('and carries the built-closure prerequisite, like the other ratchet line', /closure BUILT/.test(rootLine) && rootLine.includes('turbo run build'));
4439+
// The root graduated from the DEBT ledger (#26236), so the gate that reads
4440+
// this program is its own `typecheck:root` script, which lint.yml runs.
4441+
t('and it names the root program\'s own type check', rootKind.some((l) => l.includes('- pnpm typecheck:root —')));
4442+
const rootLine = rootKind.find((l) => l.includes('- pnpm typecheck:root —')) ?? '';
4443+
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));
4444+
t('and says it needs no build, unlike the ratchet lines', /needs no build/.test(rootLine) && !/closure BUILT/.test(rootLine));
44304445
// Re-pointed rather than deleted (#12074). This case pinned one property —
44314446
// a `.mjs` checker script is NOT in the ROOT tsc program, unlike the `.mts`
44324447
// bench file above — and the gate-script kind below now makes the same path
@@ -4436,7 +4451,7 @@ export function selfTest({ tier = 'full' } = {}) {
44364451
// hiding inside a `length` this case no longer checks.
44374452
const checkerKind = changeKindLines(['scripts/check-type-check-coverage.mjs'], resolved);
44384453
t('a checker script is still outside the ROOT tsc program', !checkerKind.some((l) => l.includes('ROOT tsc program')));
4439-
t('…and the root ratchet is not named for it', !checkerKind.some((l) => l.includes('- pnpm check:type-check-debt —')));
4454+
t('…and the root type check is not named for it', !checkerKind.some((l) => l.includes('- pnpm typecheck:root —')));
44404455
t('…and the ONE section it does emit is the gate-script kind', checkerKind.length === 3 && checkerKind[0].includes('GATE SCRIPT'));
44414456

44424457
// -- The GATE SCRIPT entry (#12074) --------------------------------------
@@ -5813,11 +5828,12 @@ export function selfTest({ tier = 'full' } = {}) {
58135828
// Seven, not six, since #10542 added check:cross-package-test-inputs to the
58145829
// test-file kind. `a.test.ts` is a root-level TypeScript file, so it is BOTH
58155830
// a test file and inside the root tsc program and legitimately hits two
5816-
// kinds. The ratchet therefore renders twice, under a different `why` each
5817-
// time — pinned just below, because a bare count cannot tell that apart from
5818-
// one kind rotting away.
5831+
// kinds. Each kind renders its own gate as STALE (the ratchet under the test
5832+
// kind, `typecheck:root` under the root kind since the root graduated) —
5833+
// pinned just below, because a bare count cannot tell that apart from one
5834+
// kind rotting away.
58195835
t('an undiscoverable gate renders as STALE', stale.filter((l) => l.includes('STALE')).length === 7);
5820-
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);
5836+
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);
58215837
// Per NAME, anchored on both sides of the rendered name (`⚠ x: STALE`), so the
58225838
// pair that shares one script is reported apart: a count alone stays green if
58235839
// one of the two is dropped from the table and something else is added, and a

0 commit comments

Comments
 (0)