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

@yinlianghui yinlianghui 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):

task state why it is covered, or exempt
build cached guarded by turbo-build-inputs.test.ts (this PR, #4185)
test cached guarded by turbo-test-inputs.test.ts (#4178)
lint cached guarded by turbo-lint-inputs.test.ts (this PR, #4184)
type-check cached guarded by turbo-type-check-inputs.test.ts (#3514)
test:watch cache: false, persistent never cached, so there is no stored verdict to replay
clean cache: false same
dev cache: false, persistent same

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

claude added 2 commits August 10, 2026 20:21
…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

vercel Bot commented Aug 10, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectui Ignored Ignored Aug 10, 2026 8:24pm

Request Review

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