Skip to content

feat(gate): GENERATED_COVERED — a tsconfig include rooted in a generated path must be produced by the typecheck script, or declared as deliberately not produced - #10896

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-10880-typecheck-generated-include
Aug 21, 2026
Merged

feat(gate): GENERATED_COVERED — a tsconfig include rooted in a generated path must be produced by the typecheck script, or declared as deliberately not produced#10896
os-zhuang merged 1 commit into
mainfrom
claude/issue-10880-typecheck-generated-include

Conversation

@os-zhuang

@os-zhuangos-zhuang commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Fixes#10880

What

check:type-check-coverage gains a sixth invariant, GENERATED_COVERED: a tsconfig include entry rooted in a path the repo does not check in is either produced by the package's own typecheck script before tsc runs, or carries a row saying it is deliberately not produced, and why.

REAL already asks whether a typecheck script invokes tsc. This asks the next question — 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: with .next deleted it exited 0 having compiled none of the generated route types, while COVERED, REAL, TESTS_COVERED, SOURCES_COVERED and RUNNABLE all passed. Every one of those is a question about files that exist.

The failure direction is why it is an invariant and not a lint: a missing generated directory makes tsc read green over files that were never in the program. Every other way of getting the same config wrong (stale directory, failing generator) ends in a red somebody reads.

The design, and the two decisions worth reviewing

1. What counts as "generated" is git check-ignore, not a hard-coded list. One batched call for the whole workspace. The ignore rules are checked in, so the verdict is a property of the tree rather than of the machine — core.excludesFile is emptied on the command line so a developer's global ignore list cannot change what CI concludes, the index is consulted (a force-added directory is present in a clean checkout, so it reads as not-generated), and existence is never consulted, so the answer does not depend on whether anyone has run a build. A git that cannot answer throws; reading a failed spawn as "nothing here is generated" would retire the invariant silently.

Measured on the branch point: 101 include roots across the workspace, of which 3 are generated — all in apps/docs. The detector's live surface is fully enumerable.

2. "Declared, and deliberately not generated" is a passing state. The card is explicit that the remedy is not deleting the offending glob: Next owns that array and writeConfigurationDefaults re-adds .next/dev/types/**/*.ts on the next next dev / next build. So GENERATED_INCLUDE_ROOTS rows come in two kinds — generator: 'THE-COMMAND' (enforced: the script must run it, ahead of tsc) and generator: null (nothing a typecheck runs produces this, on purpose). For a null row the why carries the argument that its absence cannot make the check read green — the one claim here nothing mechanical can verify, which is exactly why it is written where a reviewer sees it. A row that simply omits the key is refused rather than defaulting to null, so the strict half cannot be switched off by a typo.

The table is keyed package-relative (.next/types, not apps/docs/.next/types), so one row holds for every Next app the workspace ever gains. It holds three rows, and the generator claims are read out of next@16.3.1 — the version apps/docs resolves — rather than inferred from the command name: dist/cli/next-typegen.js reaches writeAppTypeDeclarations (which writes next-env.d.ts) through verifyAndRunTypeScript, then writes routes.d.ts, validator.ts, cache-life.d.ts and root-params.d.ts into the dist types directory. Nothing there writes .next/dev/types.

Departures from the card, and one addition

  • The card predates [finding] A package can pass check:type-check-coverage with a whole source directory outside tsc — packages/objectql/scripts/** is invisible to its own typecheck #10756: the file now carries a fifth invariant, SOURCES_COVERED. The new one is written as the sixth and reuses that work rather than the three-invariant shape the card quotes.
  • The card said the table "would hold one row". It holds three, because the gitignore detector reaches all three of apps/docs's generated include entries — next-env.d.ts is gitignored and named by include too, so it needs an honest row rather than a silent exemption on a file-vs-directory technicality.
  • Added beyond the card's two halves: the generator must run before tsc. typecheck: "tsc --noEmit && next typegen" satisfies "the script contains the command" and still checks the pre-generator program. This is decided only inside a single script body, where text order is shell order; a generator reached through pnpm SCRIPTNAME indirection has no knowable position relative to a tsc in a different body, so it abstains there rather than guessing. That abstention is why typecheckScriptChain returns a list of script bodies instead of the joined blob configsNamedByTypecheck used to build — a blob would red the correct config typecheck: 'pnpm gen && tsc' + gen: 'next typegen'.

Machinery reused, not re-implemented

readTsconfig, configCovers, configsNamedByTypecheck and the invoked-config scoping are all existing. The only refactor is typecheckScriptChain (extracted from configsNamedByTypecheck, which now calls it) and includeRoot (extracted so the two invariants that read include prefixes cannot answer the same question differently). Scoped to packages that declare a typecheck script and to the configs that script invokes, exactly as SOURCES_COVERED is.

The workspace root package is not asked: giving it this observation would mean walking the whole repo through testCoverage. No root-level include names a generated path today (same measurement). Stated in the code as a limit, not left as an oversight.

Verification — self-test, and three ablations with predictions stated first

--self-test grows 9 semantic cases and 21 observation cases: 47 semantic case(s) + 59 observation case(s) + 29 re-measure case(s) + 28 built-closure case(s) + 19 auto-lowering case(s) hold.

The positive control cannot be "the real workspace reds" — #10879 already repaired the measured instance, so the fixtures are literal. Each ablation states its prediction, confirms the mutation landed on disk (grep counts on the text actually changed, never an editor's exit code), and has a restore leg proved with git diff --quiet.

#MutationPredictionResult
Aapps/docstypecheck back to bare tsc --noEmitexit 1, exactly 2 problems, both never runs that command; .next/dev/types silentexact match
Bfumadocs-mdx && tsc --noEmit && next typegenexit 1, exactly 2 problems, both AFTER tsc in the same commandexact match
Cthe whole GENERATED_COVERED loop neutered--self-test exit 1, exactly 5 failures (no-row / never-runs / after / no-reason / no-generator); RECONCILED unaffectedexact match

Ablation C also shows why the fixtures are load-bearing: with the invariant disabled, the real workspace run stayed green (exit 0). Only the literal fixtures can fail it.

The self-test deliberately does not call git, so the battery stays hermetic. What guards the detector on the real tree is GENERATED_COVERED's own RECONCILED half: if gitIgnoredPaths ever silently answered "nothing is generated", all three table rows would instantly be rows no include names, and the gate reds naming them. A broken detector cannot be a quiet one.

Gates run locally, all at 25b8ddd788 (the final commit, clean tree)

Derived with node scripts/pm/dispatch-gates.mjs (no paths — it takes the change set from the merge base itself), which resolved 1 path and 7 families:

  • check:type-check-coverage — self-test + real pass, both exit 0. Real verdict: check-type-check-coverage: OK — 64/77 workspace packages type-checked … with the new fourth summary line, generated layer: 3 include entr(ies) across 1 package(s) name a path this repo does not check in -- 2 produced by their own typecheck before tsc, 1 declared deliberately ungenerated.
  • check:entry-guard✓ check:entry-guard: 129 scripts/ file(s) — every entry guard goes through invoked-as.mjs (self-test: 52 cases pass).
  • check:parse-guard✓ check:parse-guard: 128 scripts/ file(s) — every TypeScript parse goes through ts-parse.mjs. (self-test: 46 cases pass).
  • check:cross-package-test-inputs — exit 0.
  • check-ci-filter-parity.mjsOK: all 82 declared cross-package glob(s) (71 unique) are covered by core or crosspkg ….
  • check:nul-bytesOK (scanned 6296 text file(s) … no raw ASCII control bytes).

Declared narrowing:check:type-check-debt (--re-measure) was not run locally. It runs a real tsc --noEmit per ledger entry and needs a full workspace install plus a dependency-closure build; this container has no installs at all. This diff touches no measurement code path — only the structural pass and its fixtures — and CI runs that family after its build step.

origin/main moved from 7c02a4529c to 9faa9bc51d during the work (#10882, packages/cli). No overlap with this file; the diff here is one file, +650/−15, confirmed by merge-base rather than by a two-dot git diff.

Release

skip-changeset: this PR edits one CI-internal gate script and publishes nothing. That is this workflow's own prescription for the case — lint.yml states it in those words ("this PR edits a CI-internal script is the textbook skip-changeset case — such a PR releases nothing"). Note the corollary the same comment records: the changeset-family self-tests run unconditionally in lint.yml precisely so a skip-changeset PR cannot skip them.

Generated by Claude Code

…ted path must be produced by the typecheck script, or declared as deliberately not produced
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt
@os-zhuangos-zhuang added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 21, 2026 — with Claude
@os-zhuang
os-zhuang marked this pull request as ready for review August 21, 2026 16:30
@os-zhuang
os-zhuang added this pull request to the merge queueAug 21, 2026
Merged via the queue into main with commit c9636deAug 21, 2026
34 of 35 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-10880-typecheck-generated-include branch August 21, 2026 16:46
@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 32503331743 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Console Pin Gate — 失败步骤: Build the Console SPA at the pinned objectui SHA

    ✗ Build failed in 6.28s
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

跨 PR 相同签名(24h,按失败测试文件聚合):

  • ⚠️本次没有可用的聚合签名(日志里没有能解析出测试文件名的 FAIL 行)—— 这不是「没有同签名的其他 PR」,是这一轮没测到。跨 PR 聚合本次不可用,请手工比对其他 PR 的同类评论。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 35 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/lskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

1 participant

@os-zhuang