Skip to content

Commit 4454c5b

Browse files
fix(turbo): hash the ui tree root build and test inputs in the root turbo.json; guards read the live config (#27571)
domain:devx#2 · os-justin · session_01QSeKYog9FzTNeBAndKHXTT Fixes #27283 Part of #26080 Clause-②: no Metadata-protocol: none Ruling-ref: 6513291766 (director#1 · os-bill; #27283 letter A, maintainer 「其他同意」) — the `ci.yml` authorisation. Quoted: "the six ui-root build inputs that PR #27571 adds to the root `turbo.json` … also enter `.github/workflows/ci.yml`'s `core:` path filter, in the same PR, so a diff confined to one of them schedules Build Core and Test Core on the PR instead of first in the merge queue, and `check-ci-filter-parity` is green by parity, not by blindness." Tier H: `.github/workflows/ci.yml` (ruled) and `.github/workflows/lint.yml` (one gate-forced line, seat authorisation 6513792547). ## 维护者速读 - 改了什么:根 `turbo.json` 的 `build`/`test` 任务现在把 ui 树根部被各 `@object-ui/*` 包真实读取的文件(`ui/tsconfig.json`、`ui/scripts/vite-*.ts`、vitest 配置与 setup 等)计入缓存键;守卫全部改读这份真实配置,删除无人读取的 `ui/turbo.objectui.json`。按裁决 A,这 6 个 ui 根目录构建输入同时写进 `ci.yml` 的 `core:` 过滤器;`lint.yml` 因此补一行,让门禁要求的 `ui/scripts/invoked-as.mjs --self-test` 有 workflow 来跑。 - 为什么改:此前改动这些文件不会让 turbo 缓存失效,CI 或发版可能回放旧的构建产物;而守卫读的是一份停用副本,所以一直是假绿。 - 风险与回滚:所有 `@objectstack/*` 与 `@object-ui/*` 的 build/test 缓存键一次性变化(253 个中 243 个),首轮 CI 冷构建;改动这 6 类文件的 PR 会多跑一次 Build Core 和 Test Core(按过去 30 天约 9 次)。碰 `ci.yml` 和 `lint.yml`,属 Tier H。只改配置与测试,不改运行时代码,回滚即 revert 本 PR。 - 先看:`.github/workflows/ci.yml` 的 `core:` 新增 6 行,以及 `lint.yml` 第 843 行旁新增的一行 → `turbo.json`(build/test 两段新增输入)→ `ui/scripts/__tests__/turbo-lint-inputs.test.ts` 与 `e2e-type-check.test.ts`(两个根任务守卫改为对照 ci.yml 步骤)→ `ui/scripts/__tests__/turbo-task-guard-coverage.test.ts`(任务范围收到本树)。 - 两分钟验证:`pnpm exec turbo run build --filter=@object-ui/components --dry=json`,在该任务的 inputs 里应能看到 `../../scripts/vite-dts-explicit-extensions.ts` 与 `../../tsconfig.json`。 - 需要你做的:Tier H,需要授权批准。os-zhuang 已批准(review 5707021648),推送不会让批准失效;同 head 达档契约复核 PASS 后,席位翻 ready 并挂自动合并。 ## What changes - **Root `turbo.json`, option A.** `build` gains the six ui-root files every `@object-ui/*` build reads (`ui/tsconfig.json`, `ui/scripts/vite-*.ts`, `build-plugin-stylesheet.mjs`, `check-dist-completeness.mjs`, `invoked-as.mjs`, `vitest-invocation-guard.mjs`). `test` gains the seven its Vitest configuration program reads (`ui/vitest.config.mts`, `ui/vitest.setup.*`, `vitest-invocation-guard.mjs`, `test-shard-*`, `vite-*.ts`, the Console's `vitest.config.ts` and `vite.config.ts`). Each entry is required by the derived guards, which refuse an entry no package program reads. - **Why A over B** (measured, below). B, a package-level `turbo.json` in each ui package, moves 82 of 253 build/test hashes against A's 243. But it needs 39 new files, each carrying the same list, plus a per-package config composer inside the guards. It also cannot cover the `ui` root package without a `ui/turbo.json`, which `workspace-root.mjs` would then read as the tree's own config. A keeps one list, in the file turbo reads, and the guards read it unchanged through `liveTurboConfig()`. A's recurring cost is that an edit to one of these files also invalidates the `@objectstack/*` entries. Over the last 30 days, 9 of 3513 commits on `main` touched these files. - **Guards retargeted, no assertion loosened.** `turbo-build-inputs`, `turbo-test-inputs`, `check-dist-completeness` (its build-input case), `turbo-build-outputs-cover-published-entries`, `turbo-task-guard-coverage` and `select-type-check-scope` now read the live config. `taskInputs()` and `rootAnchoredInputs()` now default to it, and `parkedTurboConfig()` is gone. - `turbo-task-guard-coverage` draws its partition over the tasks turbo runs on a package of this tree: a generic task some ui manifest declares a script for, or a `PKG#TASK` override for a ui package. That set is exactly what the parked copy declared. A new liveness case pins the four guarded tasks as the floor. - `ui/apps/console/turbo.json` now restates the root `build.outputs`, adding `json-schema/**` and `!.next/cache/**`. The outputs guard, read against the live root, found that it narrowed them. - **Root-task guards, rewritten against the `ci.yml` step that runs each task** (a read of `ci.yml`, no workflow edit): - `//#lint:root` is now a pin on `lint-ui`'s `pnpm --dir ui run lint:root` step: - The step exists, runs exactly the script, and has no `if:`. - The live `lint` task folds in no `//#` task's hash. - The ignored workspace groups are exactly the groups the turbo `lint` step filters in. - Every file ESLint walks at that scope sets the `ui` paths filter. - The step is the script itself, so no cache can replay it. - `//#type-check:e2e` gets the same pins on `type-check-ui`'s `pnpm --dir ui run type-check:e2e` step. The filter-coverage pin there is over the e2e program's files. - `select-type-check-scope`'s root-task case (rule item 4) runs on objectui's own shape of that task as a fixture, inputs verbatim. Under the live config there is no root task left for it to read. - **`.github/workflows/ci.yml`, ruled (6513291766):** six `core:` rows plus a four-line comment, after the BUILD INPUTS (#21202) rows. They are the same six ui-root build inputs. `check:ci-filter-parity` went from 5/120 failed to 120/120 green, and it demanded exactly these six, with no test inputs. - **`.github/workflows/lint.yml`, gate-forced (seat authorisation 6513792547):** one line, `run_self_test node ui/scripts/invoked-as.mjs --self-test`, beside the root twin at L843. `check-self-test-wired` counts the new `core:` row as CI naming that script, and its own remedy is to wire the script's self-test, which passes with 12 cases. No SELF_TEST_RUN_OTHERWISE row. - **Deleted:** `ui/turbo.objectui.json`, `PARKED_TURBO_CONFIG` and `uiTurboConfigPath()`, with its declaration and tests. After the retarget, `git grep` finds no reader. ## Tests - `pnpm --dir ui exec vitest run --maxWorkers=2 scripts/` on head `47d861189`, under `os-verify-lock`: `Test Files 169 passed | 2 skipped (171)`, `Tests 4900 passed | 2 skipped (4902)`, `VERDICT command-exit 0`. No failures, so no baseline-failure set. - The touched reader files, untouched `origin/main` (`ad3a640e3`) vs this branch: 324/324 → 323/323. Every file keeps its count except `turbo-task-guard-coverage`, which goes 15 → 14: - Retired: its two cases "`//#lint:root` / `//#type-check:e2e` is exempt because turbo never caches it". They are retired under the test-deletion rule. Their duplicates are "stays uncached: the step is the script itself" (`turbo-lint-inputs.test.ts`) and "is uncached: the step is the script itself" (`e2e-type-check.test.ts`). Both pin the property on the CI step that actually runs each task. - Added: one case, the partition-scope liveness. - `pnpm --dir ui run type-check:scripts`: exit 0, and `--listFilesOnly` shows all five changed test and helper files in that program. `eslint` over the changed `.ts`/`.mjs` files: exit 0. ## Acceptance notes - `ui/scripts/select-type-check-scope.mjs` has no caller in any root workflow: `helpers/root-ci.ts` records the selector as not ported. So its test holds a function nothing runs in the monorepo. Noted, not filed. - First `ablation-replace` attempt (teeth 2 below) was refused by the tool: the replacement text was already counted inside the anchor, so nothing ran and the restore was proven. It was re-run with `--delete`. ## Verification <details><summary>Reproduction on origin/main ad3a640: root-anchored inputs of @object-ui/components#build</summary> `pnpm exec turbo run build --filter=@object-ui/components --dry=json`: hash `587292e7374b7756`, out-of-package inputs only `../../../scripts/check-dts-emitted.mjs`, `../../../scripts/invoked-as.mjs`, `../../../scripts/tsup-drop-sources-content.mjs`. `ui/packages/components/vite.config.ts:14-15` imports `../../scripts/vite-dts-explicit-extensions.ts` and `../../scripts/vite-dts-fail-on-type-errors.ts`. After: hash `ba59da9212bafc37`. The same 3, plus `../../tsconfig.json`, `../../scripts/build-plugin-stylesheet.mjs`, `check-dist-completeness.mjs`, `invoked-as.mjs`, `vitest-invocation-guard.mjs` and the nine `../../scripts/vite-*.ts`. </details> <details><summary>Probe: the live config substituted for the parked one, on origin/main</summary> 144 assertions red in 8 files: `turbo-build-inputs` 37, `turbo-test-inputs` 39, `turbo-task-guard-coverage` 55, `turbo-lint-inputs` 5 (the `//#lint:root` block), `e2e-type-check` 4 (the wiring block), `select-type-check-scope` 2, `check-dist-completeness` 1, `turbo-build-outputs-cover-published-entries` 1 (Console narrowing the root outputs). The slice-2 reading was 147, against an older tree. On this branch: 0 red. </details> <details><summary>Blast radius: turbo run build test --dry=json, 253 tasks, before vs after</summary> - A (this PR): 243 changed. ui build 41/41, ui test 41/41, objectstack build 81/85, objectstack test 80/85 (the unchanged ones carry their own package-specific task entry), gen:migration-registry 0/1. - B (prototype: `$TURBO_EXTENDS$` package configs in 39 ui packages plus the Console's, measured and then deleted): 82 changed. ui build 41/41, ui test 41/41, objectstack 0. </details> <details><summary>Teeth: edit one newly hashed file, read the task hash, restore (blob equal to HEAD)</summary> | file edited | task | this branch | origin/main (control) | |:--|:--|:--|:--| | `ui/scripts/vite-dts-explicit-extensions.ts` | `@object-ui/components#build` | `ba59da92…` → `bbfca15d…` MOVED | `587292e7…` → `587292e7…` FROZEN | | `ui/tsconfig.json` | `@object-ui/core#build` | `53f30662…` → `55610587…` MOVED | `400845ca…` FROZEN | | `ui/vitest.config.mts` | `@object-ui/core#test` | `c2e38df8…` → `07fa7ffa…` MOVED | `91b1052e…` FROZEN | | `ui/scripts/vitest-invocation-guard.mjs` | `@object-ui/core#test` | `c2e38df8…` → `23c18cd8…` MOVED | `91b1052e…` FROZEN | Each probe appended one comment line, with the on-disk count of the probe line checked at 1. Each file was restored through a trap and proven by blob hash. </details> <details><summary>Guard teeth: ablation-replace on turbo.json, restored to the HEAD blob 87fdc79</summary> 1. Deleting the `build` entries `$TURBO_ROOT$/ui/scripts/vite-*.ts` and `build-plugin-stylesheet.mjs` (anchor 1 → 0) made `turbo-build-inputs` go `22 failed | 21 passed (43)`, naming the Console, components and fields programs. 2. Adding `"dependsOn": ["//#lint:root"]` to `lint` made `turbo-lint-inputs` go `1 failed | 51 passed (52)`, on the case "folds no root-task hash into any package `lint`". </details> <details><summary>Derived gates on d5f83e0 (current head)</summary> After merging origin/main (166 commits, clean) and adding the `ci.yml` and `lint.yml` lines: `dispatch-gates --commands` derived 68 commands, and all exit 0. `check:dual-build-cjs-loads` is now measured and green. `--ran` reports 0 NOT-MEASURED. `check-self-test-wired` (self-test and scan) and `check:ci-filter-parity` are both green. The full ui `scripts/` suite: 169 files, 4901 passed, 2 skipped. Addenda 6513780805 and 6514155626. </details> <details><summary>Derived gates on 47d8611 (first head, superseded)</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 31 commands. Each was run with its exit captured before any pipe: 30 exit 0, and `pnpm check:dual-build-cjs-loads` exits 3. That is PREREQUISITE NOT MET: it reads every package's built `dist/`, and only the seven ui-consumer closures were built here, so it is NOT MEASURED locally and CI owns it. `--ran`: `31 derived famil(ies) accounted for — 30 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3)`. `node scripts/check-platform-checklist.mjs --changed-from origin/main`: exit 0, 0 anchored files. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01QSeKYog9FzTNeBAndKHXTT)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9cd54fa commit 4454c5b

17 files changed

Lines changed: 367 additions & 368 deletions

‎.github/workflows/ci.yml‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,16 @@ jobs:
351351
- 'scripts/ts-parse.mjs'
352352
- 'scripts/tsup-drop-sources-content.mjs'
353353
- 'scripts/workspace-enumerator.mjs'
354+
# The ui tree's root build inputs (#27283): turbo.json's generic
355+
# `build` task hashes them for every `@object-ui/*` build, and the
356+
# same task runs for every package `pnpm build` builds, so a diff
357+
# confined to one moves Build Core's hashes too.
358+
- 'ui/tsconfig.json'
359+
- 'ui/scripts/vite-*.ts'
360+
- 'ui/scripts/build-plugin-stylesheet.mjs'
361+
- 'ui/scripts/check-dist-completeness.mjs'
362+
- 'ui/scripts/invoked-as.mjs'
363+
- 'ui/scripts/vitest-invocation-guard.mjs'
354364
# BUILD CORE'S GUARDS (#21341): every repository script the
355365
# build-core job's own steps run (named by a `run:` step or
356366
# reached through its `pnpm check:*` alias) and each one's

‎.github/workflows/lint.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -841,6 +841,7 @@ jobs:
841841
}
842842
run_self_test node scripts/ts-parse.mjs --self-test
843843
run_self_test node scripts/invoked-as.mjs --self-test
844+
run_self_test node ui/scripts/invoked-as.mjs --self-test
844845
run_self_test node scripts/js-comment-mask.mjs --self-test
845846
run_self_test node scripts/import-prerequisite.mjs --self-test
846847
if [ -n "$failed" ]; then

‎turbo.json‎

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,33 @@
1515
"$TURBO_DEFAULT$",
1616
"$TURBO_ROOT$/scripts/tsup-drop-sources-content.mjs",
1717
"$TURBO_ROOT$/scripts/check-dts-emitted.mjs",
18-
"$TURBO_ROOT$/scripts/invoked-as.mjs"
18+
"$TURBO_ROOT$/scripts/invoked-as.mjs",
19+
"$TURBO_ROOT$/ui/tsconfig.json",
20+
"$TURBO_ROOT$/ui/scripts/vite-*.ts",
21+
"$TURBO_ROOT$/ui/scripts/build-plugin-stylesheet.mjs",
22+
"$TURBO_ROOT$/ui/scripts/check-dist-completeness.mjs",
23+
"$TURBO_ROOT$/ui/scripts/invoked-as.mjs",
24+
"$TURBO_ROOT$/ui/scripts/vitest-invocation-guard.mjs"
1925
]
2026
},
2127
"test": {
2228
"dependsOn": ["^build"],
2329
"outputs": [],
2430
"env": ["OS_TEST_TIERS", "OS_TEST_SHARD"],
25-
"inputs": ["$TURBO_DEFAULT$", "!dist/**", "!coverage/**", "!.turbo/**", "$TURBO_ROOT$/vitest.preset.mts"]
31+
"inputs": [
32+
"$TURBO_DEFAULT$",
33+
"!dist/**",
34+
"!coverage/**",
35+
"!.turbo/**",
36+
"$TURBO_ROOT$/vitest.preset.mts",
37+
"$TURBO_ROOT$/ui/vitest.config.mts",
38+
"$TURBO_ROOT$/ui/vitest.setup.*",
39+
"$TURBO_ROOT$/ui/scripts/vitest-invocation-guard.mjs",
40+
"$TURBO_ROOT$/ui/scripts/test-shard-*",
41+
"$TURBO_ROOT$/ui/scripts/vite-*.ts",
42+
"$TURBO_ROOT$/ui/apps/console/vitest.config.ts",
43+
"$TURBO_ROOT$/ui/apps/console/vite.config.ts"
44+
]
2645
},
2746
"test:repo": {
2847
"dependsOn": ["^build"],

‎ui/apps/console/turbo.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
"build": {
66
"outputs": [
77
"dist/**",
8+
"json-schema/**",
89
".next/**",
10+
"!.next/cache/**",
911
"build/**",
1012
"**/*.tsbuildinfo",
1113
"!**/node_modules/**",

‎ui/scripts/__tests__/check-dist-completeness.test.ts‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from 'vitest';
2-
import { uiTurboConfigPath } from '../workspace-root.mjs';
2+
import { taskInputs } from './helpers/turbo-inputs';
33
import { UI_TSC_BUILT_PACKAGE_FLOOR } from './helpers/ui-package-population.mjs';
44
import fs from 'node:fs';
55
import os from 'node:os';
@@ -310,10 +310,11 @@ describe('this repository', () => {
310310
);
311311

312312
it('turbo treats the gate as an input of the builds that run it', () => {
313-
const turbo = JSON.parse(fs.readFileSync(uiTurboConfigPath(), 'utf8'));
314313
// Otherwise editing the gate does not invalidate a single cached build, and
315-
// the new rule is enforced only where nothing was cached.
316-
expect(turbo.tasks.build.inputs).toContain('$TURBO_ROOT$/scripts/check-dist-completeness.mjs');
314+
// the new rule is enforced only where nothing was cached. Read from the
315+
// config turbo runs (`liveTurboConfig()`, the root `turbo.json` narrowed to
316+
// this tree), so the entry is `$TURBO_ROOT$/ui/scripts/...` on disk.
317+
expect(taskInputs('build')).toContain('$TURBO_ROOT$/scripts/check-dist-completeness.mjs');
317318
});
318319

319320
it('has a root alias pointing at the script that exists', () => {

‎ui/scripts/__tests__/e2e-type-check.test.ts‎

Lines changed: 87 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,21 @@
11
import { describe, expect, it } from 'vitest';
2-
import { uiTurboConfigPath } from '../workspace-root.mjs';
32
import fs from 'node:fs';
43
import os from 'node:os';
54
import path from 'node:path';
65
import { fileURLToPath } from 'node:url';
76
import ts from 'typescript';
8-
import { globToRegExp, repoFilesOnDisk } from './helpers/turbo-inputs';
7+
import { liveTurboConfig } from './helpers/turbo-inputs';
8+
import {
9+
UI_JOBS,
10+
globMatches,
11+
repoPath,
12+
rootCiJob,
13+
rootCiPath,
14+
stepsRunning,
15+
uiFilterGlobs,
16+
rootCi,
17+
uiRun,
18+
} from './helpers/root-ci';
919

1020
/**
1121
* objectui#4471 — the 30 Playwright specs under `e2e/` were compiled by nothing.
@@ -61,9 +71,8 @@ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../
6171
const configPath = path.join(repoRoot, 'tsconfig.e2e.json');
6272
const e2eDir = path.join(repoRoot, 'e2e');
6373

64-
/** The root script a contributor runs, and the turbo root task that drives it. */
74+
/** The root script a contributor runs, and the root `ci.yml` step runs. */
6575
const SCRIPT_NAME = 'type-check:e2e';
66-
const TURBO_TASK = `//#${SCRIPT_NAME}`;
6776

6877
/**
6978
* The project as TypeScript itself resolves it: the same parse `tsc -p` does,
@@ -375,116 +384,103 @@ describe('tsconfig.e2e.json — coverage of the root playwright configs (objectu
375384
});
376385
});
377386

378-
describe('the gate is actually wired up (objectui#4471)', () => {
387+
/**
388+
* objectui#4471's wiring, as the monorepo runs it (objectstack#27283).
389+
*
390+
* Standalone, objectui made this gate the turbo ROOT task `//#type-check:e2e`
391+
* and every package's `type-check` `dependsOn` it, so `pnpm type-check` reached
392+
* `e2e/` with no workflow entry of its own. Under the monorepo `//` is the
393+
* objectstack root package and no turbo task can name objectui's root manifest,
394+
* so the root `ci.yml` runs the script as a step of `type-check-ui` instead
395+
* (objectstack#25697). Each property the root task carried is pinned here
396+
* against that step, read from the workflow GitHub runs:
397+
*
398+
* - "a turbo root task, so `pnpm type-check` reaches e2e/" -> the step exists
399+
* in the job that type-checks the ui packages, and runs exactly the script;
400+
* - "runs as part of `pnpm type-check`" -> the step has no
401+
* `if:` of its own, and every file of the e2e program sets the `ui` filter
402+
* that job's `if:` reads, so an e2e-only change still runs it;
403+
* - "hashes its own program, not the whole repository" -> no root task's
404+
* hash is folded into any package's `type-check`: the live `type-check`
405+
* `dependsOn` names no `//#` task, the stronger form of the same property;
406+
* - "is uncached" -> the step is the
407+
* script itself, not a `turbo run`, so no cache can replay its verdict.
408+
*/
409+
describe('the gate is actually wired up (objectui#4471, objectstack#27283)', () => {
379410
const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')) as {
380411
scripts?: Record<string, string>;
381412
};
382-
const turbo = JSON.parse(fs.readFileSync(uiTurboConfigPath(), 'utf8')) as {
383-
tasks: Record<string, { cache?: boolean; dependsOn?: string[]; inputs?: string[] }>;
384-
};
413+
const job = rootCiJob(UI_JOBS.typeCheck);
414+
const steps = stepsRunning(job, uiRun(SCRIPT_NAME));
385415

386416
it('is runnable locally, by name', () => {
387417
// A gate nobody can reproduce locally is a gate people learn to ignore.
388418
expect(pkg.scripts?.[SCRIPT_NAME], `package.json must define "${SCRIPT_NAME}"`).toBeDefined();
389419
expect(pkg.scripts?.[SCRIPT_NAME]).toContain('tsconfig.e2e.json');
390420
});
391421

392-
it('is a turbo root task, which is what makes `pnpm type-check` reach e2e/', () => {
393-
// Turbo resolves `//#x` to the ROOT package.json's `x` script. Declaring the
394-
// task without the script (or renaming the script out from under it) is a
395-
// hard turbo error rather than a silent skip, but pin the pairing anyway:
396-
// it is the whole reason the script above is not dead weight.
422+
it('is a step of the job that type-checks the ui packages, which is what makes CI reach e2e/', () => {
397423
expect(
398-
turbo.tasks[TURBO_TASK],
399-
`turbo.json must declare the root task \`${TURBO_TASK}\`. Without it, ` +
400-
`\`pnpm type-check\` (turbo run type-check) never reaches e2e/ and this config is ` +
401-
'CI-invisible unless someone adds a workflow step — which is the shape objectui#4456 ' +
402-
'moved away from.',
403-
).toBeDefined();
424+
steps,
425+
`${rootCiPath}'s \`${UI_JOBS.typeCheck}\` job must run \`${uiRun(SCRIPT_NAME)}\` as a step. ` +
426+
'Nothing else compiles e2e/: it is not a workspace package, so `turbo run type-check` cannot ' +
427+
'reach it, and the monorepo has no turbo root task that could (`//` is the objectstack root).',
428+
).toHaveLength(1);
404429
expect(
405-
pkg.scripts?.[SCRIPT_NAME],
406-
`\`${TURBO_TASK}\` resolves to the root package.json script "${SCRIPT_NAME}"`,
407-
).toBeDefined();
430+
job.steps.some((step) => /\bturbo run type-check\b/.test(step.run ?? '')),
431+
`the premise: \`${UI_JOBS.typeCheck}\` is the job that runs \`turbo run type-check\` over the ui packages`,
432+
).toBe(true);
408433
});
409434

410-
it('runs as part of `pnpm type-check`', () => {
435+
it('runs on every type-check of the ui tree, an e2e-only change included', () => {
411436
expect(
412-
turbo.tasks['type-check']?.dependsOn ?? [],
413-
`turbo.json's \`type-check\` task must \`dependsOn\` \`${TURBO_TASK}\`. That dependency is ` +
414-
'the entire wiring: ci.yml’s existing `Run type-check` step gains e2e/ coverage with ' +
415-
'no new workflow entry, exactly as `lint` gained `//#lint:root` in objectui#4456.',
416-
).toContain(TURBO_TASK);
417-
});
418-
419-
/**
420-
* objectui#12222. Turbo folds a dependency's hash into its dependent's, and
421-
* every package's `type-check` depends on this task — so this task's hash is
422-
* part of EVERY package's type-check hash. With no `inputs`, a root task
423-
* hashes every tracked file in the repository (turbo's default for the root
424-
* package), so any commit anywhere moved all of those hashes and the restored
425-
* `.turbo/cache` replayed nothing: `Type Check` re-ran every package on every
426-
* pull request and queue build. Measured on that card, before the fix: one
427-
* untracked file under `docs/` moved every `type-check` hash and no `build`
428-
* hash.
429-
*
430-
* The task stays uncached (below), so its `inputs` decide nothing but that
431-
* hash. They name its own program — what `tsconfig.e2e.json` includes, and
432-
* the config itself — so the hash moves when the e2e program does and not
433-
* when anything else in the repository does. Turbo adds the root
434-
* `package.json` and `turbo.json` on its own.
435-
*/
436-
it('hashes its own program, not the whole repository, because every type-check inherits its hash', () => {
437-
const inputs = turbo.tasks[TURBO_TASK]?.inputs ?? [];
438-
expect(
439-
inputs,
440-
`\`${TURBO_TASK}\` declares no \`inputs\`, so turbo hashes every tracked file in the ` +
441-
'repository for it — and every package’s `type-check` inherits that hash through ' +
442-
'`dependsOn`, so no commit can ever replay a cached type-check (objectui#12222).',
443-
).not.toHaveLength(0);
444-
expect(inputs, '`$TURBO_DEFAULT$` on a root task is the whole repository again').not.toContain(
445-
'$TURBO_DEFAULT$',
437+
steps[0]?.if,
438+
`the \`${uiRun(SCRIPT_NAME)}\` step must carry no \`if:\` of its own: as objectui's root task it ` +
439+
'ran on every `pnpm type-check`, and a condition here would let a run type-check the packages ' +
440+
'and skip e2e/.',
441+
).toBeUndefined();
442+
expect(job.if ?? '', 'the premise: the job runs when the `ui` paths filter is set').toMatch(
443+
/needs\.filter\.outputs\.ui\b/,
446444
);
447-
448-
const matchers = inputs.map((glob) => globToRegExp(glob));
449-
const program = [...programFiles(), 'tsconfig.e2e.json'];
450-
const unhashed = program.filter((file) => !matchers.some((re) => re.test(file)));
445+
const globs = uiFilterGlobs(rootCi());
446+
const program = [...programFiles(), 'tsconfig.e2e.json'].map(repoPath);
447+
expect(program.length, 'the e2e program resolved implausibly few files').toBeGreaterThan(5);
448+
const unfiltered = program.filter((file) => !globs.some((glob) => globMatches(glob, file)));
451449
expect(
452-
unhashed,
453-
`files of the e2e program that \`${TURBO_TASK}\`'s inputs do not name — its hash would not ` +
454-
'move when they change:\n' +
455-
unhashed.map((f) => ` - ${f}`).join('\n'),
450+
unfiltered,
451+
'files of the e2e program the `ui` paths filter does not match — a change to only these would ' +
452+
`not run \`${UI_JOBS.typeCheck}\`, so nothing would type-check it:\n` +
453+
unfiltered.map((f) => ` - ${f}`).join('\n'),
456454
).toEqual([]);
455+
});
457456

458-
const ownProgram = /^(?:e2e\/|playwright[^/]*\.config\.ts$|tsconfig\.e2e\.json$)/;
459-
const beyond = [...repoFilesOnDisk()].filter(
460-
(file) => matchers.some((re) => re.test(file)) && !ownProgram.test(file),
461-
);
457+
/**
458+
* objectui#12222. Turbo folds a dependency's hash into its dependent's, so
459+
* when every package's `type-check` depended on this root task, its hash was
460+
* part of every one of theirs, and with no `inputs` any commit anywhere moved
461+
* all of them. Measured on that card, before the fix: one untracked file under
462+
* `docs/` moved every `type-check` hash and no `build` hash. objectui scoped
463+
* the root task's inputs to its own program. Run as a step, the program
464+
* enters no turbo hash at all — provided no root task comes back into the
465+
* `type-check` graph, which is what this holds.
466+
*/
467+
it('folds no root-task hash into any package type-check, because every type-check would inherit it', () => {
468+
const typeCheck = liveTurboConfig().tasks?.['type-check'] as { dependsOn?: string[] } | undefined;
469+
expect(typeCheck, 'the root turbo.json must define `type-check`').toBeDefined();
462470
expect(
463-
beyond,
464-
`\`${TURBO_TASK}\`'s inputs reach files outside its own program, and each one moves every ` +
465-
'package’s type-check hash when it changes:\n' +
466-
beyond.map((f) => ` - ${f}`).join('\n'),
471+
(typeCheck?.dependsOn ?? []).filter((dep) => dep.startsWith('//#')),
472+
'the live `type-check` depends on a root task: its hash is now part of every package ' +
473+
'type-check hash, so it must declare inputs scoped to its own program (objectui#12222), ' +
474+
'and the step this file pins becomes a second run of the same program.',
467475
).toEqual([]);
468476
});
469477

470-
/**
471-
* The partition rule from `turbo-task-guard-coverage.test.ts`, restated where
472-
* it bites. That file requires every CACHEABLE task to have a
473-
* `turbo-<task>-inputs.test.ts` deriving its out-of-package reads — and for a
474-
* `//#`-prefixed task the derived name is not even a legal path, so a cached
475-
* root task cannot satisfy the family at all. The substantive reason is the
476-
* same one: tsc's real read set is the import closure (`@playwright/test`'s
477-
* declarations, `@types/node`), and the sibling guards deliberately narrow to
478-
* config root files. A cached verdict over that is replayable in a way nobody
479-
* derives, which is objectui#3514 with a type gate attached.
480-
*/
481-
it('is uncached, the side of the guard-coverage partition it belongs on', () => {
478+
it('is uncached: the step is the script itself, which no turbo cache stores', () => {
482479
expect(
483-
turbo.tasks[TURBO_TASK]?.cache,
484-
`\`${TURBO_TASK}\` must stay "cache": false. If it becomes cacheable, ` +
485-
'turbo-task-guard-coverage.test.ts starts demanding a derived-inputs guard for it — and ' +
486-
'the name that guard would have to carry (turbo-//#type-check:e2e-inputs.test.ts) is not ' +
487-
'a path. It costs a few seconds; leave it uncached.',
488-
).toBe(false);
480+
steps[0]?.run?.trim(),
481+
`the step must run \`${uiRun(SCRIPT_NAME)}\` directly. Routed through \`turbo run\` it would be ` +
482+
'cacheable, and a tsc program\'s real read set is its import closure (`@playwright/test`\'s ' +
483+
'declarations, `@types/node`), which no derived-inputs guard in this family narrows soundly.',
484+
).toBe(uiRun(SCRIPT_NAME));
489485
});
490486
});

‎ui/scripts/__tests__/helpers/root-ci.ts‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
*/
2323
import fs from 'node:fs';
2424
import path from 'node:path';
25+
import { parse as parseYaml } from 'yaml';
2526

2627
import { nestedPrefix, workspaceRoot } from '../../workspace-root.mjs';
2728

@@ -155,3 +156,41 @@ export function globMatches(glob: string, file: string): boolean {
155156
.join('.*')}$`,
156157
).test(file);
157158
}
159+
160+
/** One step of a root workflow job, as parsed. */
161+
export interface CiStep {
162+
readonly name?: string;
163+
readonly run?: string;
164+
readonly uses?: string;
165+
readonly if?: string;
166+
}
167+
168+
/** One root workflow job, as parsed: its condition and its steps. */
169+
export interface CiJob {
170+
readonly if?: string;
171+
readonly steps: CiStep[];
172+
}
173+
174+
/**
175+
* One job of the root `ci.yml`, parsed. Throws when the workflow defines no
176+
* such job or it parses to implausibly few steps, so a renamed job or a parse
177+
* that collapsed the list is a red pin, never a scan over nothing.
178+
*/
179+
export function rootCiJob(key: string): CiJob {
180+
const workflow = parseYaml(rootCi()) as { jobs?: Record<string, CiJob> };
181+
const job = workflow.jobs?.[key];
182+
if (!job) throw new Error(`${rootCiPath} defines no \`${key}\` job`);
183+
if (!Array.isArray(job.steps) || job.steps.length < 5) {
184+
throw new Error(`${rootCiPath}: the \`${key}\` job parsed to implausibly few steps`);
185+
}
186+
return job;
187+
}
188+
189+
/**
190+
* The steps of a root `ci.yml` job whose whole `run:` is the given command, e.g.
191+
* `uiRun('lint:root')`. Exact, so a step that merely mentions the alias inside
192+
* a longer script (or a comment) does not count as running it.
193+
*/
194+
export function stepsRunning(job: CiJob, command: string): CiStep[] {
195+
return job.steps.filter((step) => typeof step.run === 'string' && step.run.trim() === command);
196+
}

0 commit comments

Comments
 (0)