Skip to content

ci(turbo): derive the test inputs guard from each package's Vitest config program (#4178) - #4188

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-4178-turbo-test-inputs
Aug 10, 2026
Merged

ci(turbo): derive the test inputs guard from each package's Vitest config program (#4178)#4188
yinlianghui merged 1 commit into
mainfrom
claude/issue-4178-turbo-test-inputs

Conversation

@yinlianghui

@yinlianghui yinlianghui commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Fixes #4178

PR #4176 (the #3514 sibling) merged as eb5f8cea0 before this started, so this branches off main rather than stacking.

Premise check first

The card's premise holds and its headline number reproduces exactly. On main at eb5f8cea0, @object-ui/core#test:

baseline                                    2e2087e2c30ef125
after touching root vitest.config.mts       2e2087e2c30ef125     <- frozen
after touching root vitest.setup.base.ts    2e2087e2c30ef125     <- frozen
after touching root vitest.setup.dom.tsx    2e2087e2c30ef125     <- frozen
after touching scripts/vitest-invocation-guard.mjs
                                            2e2087e2c30ef125     <- frozen
after touching packages/core/src/index.ts   55923ba1954a7352     (control: moves)

Two things the card got wrong, both found by measuring instead of trusting the filing — which is why this ships a derivation and not the filing's list.

The set is 10 files, not 5. Beyond the root config and the four vitest.setup.*, the program also reaches scripts/vitest-invocation-guard.mjs (the root config calls assertCanonicalVitestInvocation at load — the thing that decides whether a run is refused at all), apps/console/vitest.config.ts (named in the root config's projects), apps/console/vite.config.ts, and the two scripts/vite-*.ts plugins that one imports.

"Every per-package vitest.config.ts literally re-exports the root" is false. 16 of 39 packages derive zero out-of-package files — packages/plugin-grid/vitest.config.ts and friends are self-contained. Had the entries been pinned from the filing's prose, the guard would have asserted a shape the repo does not have.

What ships

1. scripts/__tests__/helpers/vitest-config-program.ts — the derivation, as its own module. A mechanism, not a policy, and a derivation only exercisable through the assertions it feeds is one nobody can probe. It assembles each package's Vitest configuration program:

  • the config file Vitest resolves for that package's test script, following the script's own --root / --config and then Vitest's upward search. That search is mirrored from vitest/dist/chunks/constants.*.js (CONFIG_NAMES x CONFIG_EXTENSIONS) and the any() helper in createVitest — walk up from the root, first name in list order that exists wins. Verified against the real binary: packages/app-shell has no config of its own and npx vitest list there is refused by the root config's guard, so the upward walk is real, not assumed;
  • every file that program imports by relative specifier, transitively;
  • every file it designates through a file-valued option (setupFiles, globalSetup, projects, workspace), themselves walked.

That third step is load-bearing rather than decorative: @object-ui/plugin-editor and @object-ui/plugin-markdown reach outside their directories only through setupFiles: ['../../vitest.setup.tsx']. A pure import-chain walk — which is what the card proposed — would have missed both.

Narrowings are stated in the module's docblock rather than left implicit: the configuration program, not the alias closure of the tests (that closure is most of the repo, and is what dependsOn: ["^build"] plus $TURBO_DEFAULT$ already answer); designation is key-directed, so the ~45 concrete test paths in domTsTests / heavyDomTests are include globs and not program files. Both narrowings err toward requiring more and toward throwing on a shape not understood.

2. scripts/__tests__/turbo-test-inputs.test.ts — the policy over that result, mirroring #4176's assertion set: every out-of-package file must be matched by a $TURBO_ROOT$ input; two assertions police the reverse direction; one pins the sweep's liveness. Two more pin the config-resolution premise the whole sweep rests on (the upward walk, and vitest.config.* winning over vite.config.* in the same directory) — without those, a future Vitest that stopped walking up would empty every configless package's derived set and this guard would go quiet with no red test.

3. scripts/__tests__/helpers/turbo-inputs.ts — the turbo-side plumbing (workspace discovery, $TURBO_ROOT$ extraction, glob matching) lifted out of #4176's guard, now shared. Only the derivations differ between the two guards, and they differ in kind. A second hand-written copy of a glob matcher whose entire doctrine is "never approximate toward a match" is exactly the drift neither guard would survive. #4176's own test count is unchanged at 43, which is the evidence the extraction was behaviour-preserving.

4. turbo.json — the six entries the guard's first run named:

+        "$TURBO_ROOT$/vitest.config.mts",
+        "$TURBO_ROOT$/vitest.setup.*",
+        "$TURBO_ROOT$/scripts/vitest-invocation-guard.mjs",
+        "$TURBO_ROOT$/scripts/vite-*.ts",
+        "$TURBO_ROOT$/apps/console/vitest.config.ts",
+        "$TURBO_ROOT$/apps/console/vite.config.ts",

Measured end to end

Subject: @object-ui/runner, the one package whose test task both succeeds under turbo and runs through the root config (its script is vitest run --root ../.. packages/runner/, so the invocation guard passes). Defect planted: an unconditional throw appended to the root vitest.setup.base.ts.

=== [1] PRE-FIX, clean tree: warm the cache ===
 Tasks:    15 successful, 15 total
Cached:    10 cached, 15 total
@object-ui/runner:test:  Test Files  3 passed (3)

=== [3] PRE-FIX, defect present: re-run  <-- RED BASELINE ===
 Tasks:    15 successful, 15 total
Cached:    15 cached, 15 total
  Time:    61ms >>> FULL TURBO
exit=0

Not merely a stale exit code — turbo replayed the previous run's captured output verbatim, Start at 17:58:30 and all, over a setup file that now throws on import.

=== [4] control: same tree, cache bypassed (--force) ===
@object-ui/runner:test: Error: objectui-4178: root vitest setup was mutated
@object-ui/runner:test:  Test Files  3 failed (3)
 Tasks:    14 successful, 15 total
Cached:    0 cached, 15 total
Failed:    @object-ui/runner#test

The verdict depended on cache state, not on the code. With the fix, same tree, same defect:

=== [5] apply the fix (turbo.json), defect still present ===
@object-ui/runner:test: Error: objectui-4178: root vitest setup was mutated
@object-ui/runner:test:  Test Files  3 failed (3)
Cached:    14 cached, 15 total          <- the test task is the one MISS
Failed:    @object-ui/runner#test

=== [6] restore vitest.setup.base.ts, keep the fix: back to green ===
@object-ui/runner:test:  Test Files  3 passed (3)
 Tasks:    15 successful, 15 total

And each of the nine files behind the six entries now moves the hash independently (@object-ui/core#test, baseline 47146abf3e1fbf25): vitest.config.mts to 5c8802c4cf88c84f, vitest.setup.base.ts to 070d14e80dc8a673, vitest.setup.dom.tsx to 92265af76c689c45, vitest.setup.tsx to d809128224b22750, vitest.setup.dom-light.tsx to 2edc9e09c9a8cdac, vitest-invocation-guard.mjs to 083f717d6a07d292, vite-crypto-stub.ts to 7a1265fb5e8730b5, apps/console/vitest.config.ts to d3b74eda29c8b7e4, apps/console/vite.config.ts to b751da4988a97a08.

Reverse verification

Each new entry removed in turn, a phantom entry added, and — the one that matters most — a new root file introduced into a chain that previously had none:

[1] drop $TURBO_ROOT$/vitest.config.mts       -> Tests  21 failed | 7 passed (28)
    "reads vitest.config.mts from outside apps/console"
[2] drop $TURBO_ROOT$/vitest.setup.*          -> Tests  23 failed | 5 passed (28)
    "reads vitest.setup.base.ts, vitest.setup.dom-light.tsx,
     vitest.setup.dom.tsx, vitest.setup.tsx from outside apps/console"
[3] drop $TURBO_ROOT$/scripts/vitest-invocation-guard.mjs
                                              -> Tests  21 failed | 7 passed (28)
[4] drop $TURBO_ROOT$/scripts/vite-*.ts       -> Tests  21 failed | 7 passed (28)
    "reads scripts/vite-crypto-stub.ts, scripts/vite-maplibre-worker.ts ..."
[5] drop $TURBO_ROOT$/apps/console/vitest.config.ts -> Tests  20 failed | 8 passed (28)
[6] drop $TURBO_ROOT$/apps/console/vite.config.ts   -> Tests  20 failed | 8 passed (28)

[7] add $TURBO_ROOT$/no-such-file-4178.ts     -> Tests  2 failed | 26 passed (28)
    "matches no file in the repo. It is hashing nothing while reading as coverage"
    "is not required by any package's Vitest configuration program any more"

[8] new root file enters a chain: add '../../vitest.probe-4178.ts' to
    packages/plugin-view's setupFiles (a package deriving ZERO before)
                                              -> Tests  1 failed | 28 passed (29)
    "@object-ui/plugin-view's Vitest configuration program reads
     vitest.probe-4178.ts from outside packages/plugin-view"

[8] is the one that separates a derivation from a restatement: the case count goes 28 to 29 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.

Sweep — the other cached tasks, measured

The card asked for a hash-freeze probe per task rather than a guess. build and lint are the two remaining cached tasks, and both declare no inputs at all. Both are frozen:

$ npx turbo run lint --filter @object-ui/core --dry=json
  baseline                             79872f192ee4828c
  after touching eslint.config.js      79872f192ee4828c     <- frozen

$ 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

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

Filed, not fixed here — this PR is scoped to test: #4184 (lint, the root flat config that is the lint program) and #4185 (build, root tsconfigs plus the Vite plugins, and the highest-stakes of the three since dist/ is what every other task consumes).

Gates

pnpm exec vitest run scripts/__tests__/turbo-test-inputs.test.ts
   Tests  28 passed (28)

pnpm exec vitest run scripts/__tests__          (whole scripts suite)
   Test Files  34 passed (34)    Tests  707 passed (707)

pnpm type-check:scripts                          exit=0
node scripts/check-control-bytes.mjs             OK (3854 tracked text files)
npx eslint (the two guards + the two helpers)    exit=0

turbo run test --filter @object-ui/runner        cold-run green post-change
   Tasks: 15 successful, 15 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.

One thing worth flagging for triage, since it narrows this card's blast radius without touching its premise: ci.yml deliberately does not run turbo run test (it runs the canonical root Vitest once, sharded, with a comment explaining why). So the stale-replay this closes bites local turbo run test and any future CI use of it, rather than today's CI test job — unlike #4184 and #4185, where lint.yml and ci.yml do run the cached turbo task with .turbo/cache persisted.

Out of scope, noticed while measuring

@object-ui/plugin-editor's test task is already red on main, independently of this change: its vite.config.ts aliases @object-ui/fields to source but not @object-ui/providers, so the heavy setup graph fails to resolve (Failed to resolve import "@object-ui/providers" from "../fields/src/widgets/FileField.tsx"). Not filed as part of this card's sweep since it is a different mechanism; flagging it here for the PM to decide whether it wants its own issue.


Generated by Claude Code

…nfig program (#4178)

turbo's `test` task hashed nothing outside the package directory, so every
root file that decides how a package's tests run — the shared
`vitest.config.mts`, the four `vitest.setup.*`, the invocation guard the root
config calls, and the console project config it pulls in — was invisible to
the cache key. Measured on main: `@object-ui/core#test` frozen at
2e2087e2c30ef125 across all of them.

Adds the six derived `$TURBO_ROOT$` entries, plus a guard that derives the
requirement from each package's Vitest configuration program (config
resolution incl. Vitest's upward search, transitive relative imports, and
files designated through file-valued options) rather than restating it.

The turbo-side plumbing shared with the #3514 guard moves to
scripts/__tests__/helpers/turbo-inputs.ts so the two cannot drift.

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 7:03pm

Request Review

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

turbo test inputs have the same out-of-package hole #3514 closed for type-check — the root vitest.config.mts every package re-exports is not hashed

2 participants