Skip to content

ci(turbo): derive the lint and build inputs guards from each package's real program (#4184, #4185) - #4200

Merged
yinlianghui merged 2 commits into
mainfrom
claude/issue-4184-4185-turbo-lint-build-inputs
Aug 10, 2026
Merged

ci(turbo): derive the lint and build inputs guards from each package's real program (#4184, #4185)#4200
yinlianghui merged 2 commits into
mainfrom
claude/issue-4184-4185-turbo-lint-build-inputs

Conversation

@yinlianghui

@yinlianghuiyinlianghui commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Fixes#4184
Fixes#4185

PR #4188 (the #4178 sibling) merged as 2b9428338 before this started, so this branches off main rather than stacking on claude/issue-4178-turbo-test-inputs.

Premise check first

Both cards hold, and both headline numbers reproduce exactly on main at 2b9428338.

$ npx turbo run lint --filter @object-ui/core --dry=json
baseline 79872f192ee4828c
after touching eslint.config.js 79872f192ee4828c <- frozen
after touching eslint-rules/index.js 79872f192ee4828c <- frozen
after touching packages/core/src/index.ts 5127842ced6c29b3 (control: moves)
$ npx turbo run build --filter @object-ui/core --dry=json
baseline 3f85d1417964dce6
after touching tsconfig.json 3f85d1417964dce6 <- frozen
after touching tsconfig.base.json 3f85d1417964dce6 <- frozen
after touching packages/core/src/index.ts f4900810f65a6471 (control: moves)

One number in #4185 needs a correction, and it is a correction to the value, not to the claim. The card measured @object-ui/console#build at e6c5943a3e062002; here the baseline is a17b12650e52ce28. The card measured on claude/issue-4178-turbo-test-inputs, and apps/console has changed on main since that branch point (#4180 and #4186, 11 files), so $TURBO_DEFAULT$ legitimately moved the baseline. The freeze — the thing the card actually asserts — reproduces exactly:

$ npx turbo run build --filter @object-ui/console --dry=json
baseline a17b12650e52ce28
after touching scripts/vite-crypto-stub.ts a17b12650e52ce28 <- frozen
after touching scripts/vite-maplibre-worker.ts
a17b12650e52ce28 <- frozen
after touching tsconfig.json a17b12650e52ce28 <- frozen

Two things the cards left as open questions, both answered by measuring rather than by guessing — and both are why this ships derivations rather than the suggested lists.

#4184's open question was files / ignores. They are not program files. A flat config's files / ignores are globs that SELECT what gets linted; the selected files are ESLint's subject, not its program, and they live inside the package where $TURBO_DEFAULT$ already hashes them. The root config's files: ['packages/types/src/objectql.ts'] block looks like a counter-example and is not one: when @object-ui/types is linted that path is inside the package, and for every other package the selector matches nothing. Treating a selector as a program file would make every package's lint program claim to read every other package's source.

#4185's open question was which build scripts drive tsc vs Vite. Neither, exclusively — the answer is five tools, which is why the build derivation is a union and not one walk:

tsc 14 packages apps/console also runs `tsc -p tsconfig.plugin.json` via `pnpm build:plugin`
vite build 22 packages only apps/console reaches outside its directory
tsup 4 packages its declaration step reads a tsconfig too, which is how
cli / create-plugin / data-objectstack earn the root entry
next build 1 app apps/site
node {script} 2 packages components / fields build-css.mjs
pnpm {script} 1 app apps/console

What ships

1. scripts/__tests__/helpers/eslint-config-program.ts — the lint derivation. Each package's ESLint flat-config program: the config ESLint resolves for its lint script, plus that config's transitive relative imports. That second step is load-bearing rather than decorative — eslint.config.js imports ./eslint-rules/index.js, which imports the four local rule implementations. A rule body decides what the object-ui/* ratchets actually reject, and none of the five files was hashed. Config resolution mirrors findUp(FLAT_CONFIG_FILENAMES) from ConfigLoader.locateConfigFileToUse in eslint 10.8.0.

2. scripts/__tests__/helpers/build-program.ts — the build derivation, classifying each && segment by the tool it runs, per the table above. A segment running a tool it cannot classify throws rather than being skipped.

3. scripts/__tests__/helpers/config-program.ts — the walk itself (relative imports, key-directed designation, resolve-or-throw), lifted out of #4188's Vitest derivation now that three guards need it. Three hand-written copies of one walk is three chances for one to drift toward resolving FEWER files, and a derivation that quietly resolves fewer files is a guard that quietly stops requiring inputs. #4188's test count is unchanged at 28, which is the evidence the extraction was behaviour-preserving.

4. scripts/__tests__/helpers/tsc-program.ts#4176's tsconfig walker, lifted out for the same reason: build drives tsc too. #4176's test count is unchanged at 43.

5. scripts/__tests__/turbo-lint-inputs.test.ts / turbo-build-inputs.test.ts — the policy over each result, mirroring the family's assertion set, plus one new assertion each that both cards specifically asked for: the explicit list must still contain $TURBO_DEFAULT$. #4185 raised exactly this worry ("an explicit list must not accidentally narrow what is already covered"); this makes it mechanical instead of a review note, for the two tasks that had no list at all until now.

6. scripts/__tests__/turbo-task-guard-coverage.test.ts — the family's completeness statement, made mechanical. See the sweep section.

7. turbo.json — the five entries the guards' first run named:

 "lint": {
+ "$TURBO_DEFAULT$",
+ "$TURBO_ROOT$/eslint.config.js",
+ "$TURBO_ROOT$/eslint-rules/*.js"
"build": {
+ "$TURBO_DEFAULT$",
+ "$TURBO_ROOT$/tsconfig.json",
+ "$TURBO_ROOT$/tsconfig.base.json",
+ "$TURBO_ROOT$/scripts/vite-*.ts"

Measured after the fix

Every file behind those entries now moves the hash independently.

@object-ui/core#lint, baseline 59e4d3f652579c58: eslint.config.js to b215fd79877449fa, eslint-rules/index.js to 84f338fc4abaa1ad, no-synthetic-event-trigger.js to 7d6d40d5c8b7e709, no-try-catch-around-hook.js to 0ce406c9bb711b43, no-inline-spec-config.js to ca6e6ea3b8079a3c, no-dynamic-import-in-test-hook.js to 8f28175307530e1f.

@object-ui/core#build, baseline d3ed3d15ec3cfaa8: tsconfig.json to 338f9a9ba5c56531, tsconfig.base.json to 3d658ca0e83287d5. @object-ui/console#build, baseline 3c0abe51fa3ccecd: vite-crypto-stub.ts to 5b639849fe16fdd8, vite-maplibre-worker.ts to 82b7d6ffaeedcf24.

End to end, both halves

lint — subject @object-ui/permissions, defect a newly ADDED rule in the root flat config

This is the exact failure #4184 describes: a rule lands, and cached verdicts computed before it existed report green.

=== [1] PRE-FIX inputs, clean tree: warm the cache ===
Tasks: 1 successful, 1 total Cached: 0 cached, 1 total
exit=0
=== [3] PRE-FIX, defect present: re-run <-- RED BASELINE ===
@object-ui/permissions:lint: 26 problems (0 errors, 26 warnings)
Tasks: 1 successful, 1 total Cached: 1 cached, 1 total
Time: 49ms >>> FULL TURBO
exit=0

Not merely a stale exit code: turbo replayed the previous run's captured output, 0 errors and all, over a config that now errors on every file.

=== [4] control: same tree, cache bypassed (--force) ===
Tasks: 0 successful, 1 total Failed: @object-ui/permissions#lint
exit=1
=== [5] apply the fix (turbo.json), defect still present ===
Tasks: 0 successful, 1 total Cached: 0 cached, 1 total <- cache MISS
exit=1
=== [6] restore eslint.config.js, keep the fix: back to green ===
Tasks: 1 successful, 1 total
exit=0

build — subject @object-ui/types, defect a real TS2688 in the root tsconfig.json

=== [1] PRE-FIX inputs, clean tree: warm the cache ===
Tasks: 1 successful, 1 total
exit=0
=== [3] PRE-FIX, defect present: re-run <-- RED BASELINE ===
Tasks: 1 successful, 1 total Cached: 1 cached, 1 total
Time: 51ms >>> FULL TURBO
exit=0
=== [4] control: same tree, cache bypassed (--force) ===
Tasks: 0 successful, 1 total Failed: @object-ui/types#build
exit=2
=== [5] apply the fix (turbo.json), defect still present ===
Tasks: 0 successful, 1 total Cached: 0 cached, 1 total <- cache MISS
exit=2
=== [6] restore tsconfig.json, keep the fix: back to green ===
Tasks: 1 successful, 1 total
exit=0

In both halves the verdict depended on cache state rather than on the code, and after the fix it depends on the code.

Reverse verification

Each new entry removed in turn, $TURBO_DEFAULT$ removed, a phantom entry added, and — the two that matter most — a new root file introduced into a chain, one per half.

[1] drop lint $TURBO_ROOT$/eslint.config.js -> Tests 45 failed | 31 passed (76)
[2] drop lint $TURBO_ROOT$/eslint-rules/*.js -> Tests 45 failed | 31 passed (76)
"@object-ui/console's ESLint flat-config program reads eslint-rules/index.js,
no-dynamic-import-in-test-hook.js, no-inline-spec-config.js,
no-synthetic-event-trigger.js, no-try-catch-around-hook.js from outside apps/console"
[3] drop build $TURBO_ROOT$/tsconfig.json -> Tests 16 failed | 60 passed (76)
[4] drop build $TURBO_ROOT$/tsconfig.base.json -> Tests 1 failed | 75 passed (76)
"@object-ui/example-byo-backend-console's build program reads tsconfig.base.json"
[5] drop build $TURBO_ROOT$/scripts/vite-*.ts -> Tests 1 failed | 75 passed (76)
[6] drop lint $TURBO_DEFAULT$ -> Tests 1 failed | 75 passed (76)
"Without $TURBO_DEFAULT$ in the list the package's OWN files stop being hashed"
[7] drop build $TURBO_DEFAULT$ -> Tests 1 failed | 75 passed (76)
[8] add lint $TURBO_ROOT$/no-such-file-4184.js -> Tests 2 failed | 74 passed (76)
"matches no file in the repo. It is hashing nothing while reading as coverage"
"is not required by any package's ESLint flat-config program any more"
[9] add build $TURBO_ROOT$/no-such-file-4185.json -> Tests 2 failed | 74 passed (76)

The two that separate a derivation from a restatement:

[10] lint: a rule helper the shipped glob deliberately CANNOT match —
eslint-rules/helpers/probe-4184.js, imported by eslint-rules/index.js
-> 45 cases red, naming
"reads eslint-rules/helpers/probe-4184.js from outside apps/console ...
Add "$TURBO_ROOT$/eslint-rules/helpers/probe-4184.js""
[11] build: a new root script imported by a package deriving ZERO before —
scripts/build-probe-4185.ts, imported by packages/layout/vite.config.ts
-> Tests 1 failed | 76 passed (77)
"@object-ui/layout's build program reads scripts/build-probe-4185.ts
from outside packages/layout"

[10] proves the guard follows the import chain rather than trusting its own glob: eslint-rules/*.js does not match a path one directory deeper, so a restatement-style check would have stayed green. [11] is #4188's [8] in the build half — the case count goes 76 to 77 because it.each enumerates only packages with a non-empty set, so a package that reached nowhere before now has a case at all, and it names a file no entry, comment or fixture in this PR mentions.

The completeness gate reverse-verifies too: removing "cache": false from the clean task turns it red with "turbo.json caches the clean task ... There is no scripts/tests/turbo-clean-inputs.test.ts deriving what clean reads."

Sweep: every remaining turbo task

Both cards' sweeps were done by hand, by a reader who thought to ask. Three of the four instances of this class were found that way. That is a discovery process with a hole in it, so the answer is now mechanical — scripts/__tests__/turbo-task-guard-coverage.test.ts partitions turbo.json's tasks on the one property that matters (turbo caches by default, so the exemption must be spelled out):

taskstatewhy it is covered, or exempt
buildcachedguarded by turbo-build-inputs.test.ts (this PR, #4185)
testcachedguarded by turbo-test-inputs.test.ts (#4178)
lintcachedguarded by turbo-lint-inputs.test.ts (this PR, #4184)
type-checkcachedguarded by turbo-type-check-inputs.test.ts (#3514)
test:watchcache: false, persistentnever cached, so there is no stored verdict to replay
cleancache: falsesame
devcache: false, persistentsame

All four cacheable tasks now have derived coverage, and a fifth cacheable task cannot arrive without one: it goes red here naming itself. If a task ever loses its cache: false, the same assertion starts requiring a guard for it, which is the intended coupling.

Out of scope, filed

#4198 (finding, unqueued) — the repo-root postcss.config.mjs is a dormant fifth instance. Vite hands CSS to postcss-load-config, which searches upward, so a vite build package that processes CSS and has no postcss config of its own would read it. Measured: no package does both today, because every CSS-processing vite-build package carries its own postcss config, and the one package without one (packages/fields) has a CSS file that never enters the Vite graph — nothing imports it, scripts/build-css.mjs compiles it separately with an explicit plugin list. That is a coincidence of two facts rather than a structural guarantee, and this PR's derivation does not model postcss discovery, so the guard would not catch it. Recorded in build-program.ts's narrowings as a known blind spot rather than a safe one, and filed for triage rather than fixed inside this card's scope.

Gates

pnpm exec vitest run scripts/__tests__/turbo-lint-inputs.test.ts
scripts/__tests__/turbo-build-inputs.test.ts
scripts/__tests__/turbo-task-guard-coverage.test.ts
Test Files 3 passed (3) Tests 88 passed (88)
pnpm exec vitest run scripts/__tests__ (whole scripts suite)
Test Files 37 passed (37) Tests 795 passed (795)
pnpm type-check:scripts exit=0
node scripts/check-control-bytes.mjs OK (3880 tracked text files)
npx eslint (the three guards + the six helpers) exit=0
turbo run lint build --filter @object-ui/permissions --force cold-run green post-change
Tasks: 3 successful, 3 total

Changeset: none owed — check-changeset-presence.mjs arbitrates: "No source of a released package changed in this range, so no changeset is owed." No skip-changeset label per #3724.


Generated by Claude Code

…s real program (#4184, #4185)
`lint` and `build` both declared no `inputs` at all, so both ran on turbo's
default — which covers only files inside the package directory. The repo-root
`eslint.config.js` that IS the lint program, and the root tsconfigs plus
`scripts/vite-*.ts` that the build programs read, were unhashed: turbo replayed
the previous verdict instead of re-running.
Adds the third and fourth walkers on the #4176 / #4188 architecture — an ESLint
flat-config program and a build program (a union over tsc / vite / tsup / next /
node / pnpm segments) — plus the family's completeness gate, which asserts every
CACHEABLE turbo task has a guard so a fifth instance cannot arrive quietly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3
…on (#4185)
The repo-root postcss.config.mjs is outside every build program today only
because two facts coincide, not by construction. Written down where the
derivation's other narrowings live, so the next reader does not have to
re-measure it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3
@vercel

vercelBot commented Aug 10, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectuiIgnoredIgnoredAug 10, 2026 8:24pm

Request Review

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