From f5af7dfff448e7e8ca536c9997ed4f0c4761d345 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 12:52:28 +0000 Subject: [PATCH 1/6] fix(turbo): hash the ui tree's root build and test inputs in the root turbo.json Every @object-ui/* build and test reads ui-root files (ui/tsconfig.json, ui/scripts/vite-*.ts, the vitest config and setup set, ...) that the live root config never hashed, so an edit to one could replay a stale build. Claude-Session: https://claude.ai/code/session_01QSeKYog9FzTNeBAndKHXTT Co-authored-by: Claude --- turbo.json | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/turbo.json b/turbo.json index 6d3dece1ae4..87fdc791458 100644 --- a/turbo.json +++ b/turbo.json @@ -15,14 +15,33 @@ "$TURBO_DEFAULT$", "$TURBO_ROOT$/scripts/tsup-drop-sources-content.mjs", "$TURBO_ROOT$/scripts/check-dts-emitted.mjs", - "$TURBO_ROOT$/scripts/invoked-as.mjs" + "$TURBO_ROOT$/scripts/invoked-as.mjs", + "$TURBO_ROOT$/ui/tsconfig.json", + "$TURBO_ROOT$/ui/scripts/vite-*.ts", + "$TURBO_ROOT$/ui/scripts/build-plugin-stylesheet.mjs", + "$TURBO_ROOT$/ui/scripts/check-dist-completeness.mjs", + "$TURBO_ROOT$/ui/scripts/invoked-as.mjs", + "$TURBO_ROOT$/ui/scripts/vitest-invocation-guard.mjs" ] }, "test": { "dependsOn": ["^build"], "outputs": [], "env": ["OS_TEST_TIERS", "OS_TEST_SHARD"], - "inputs": ["$TURBO_DEFAULT$", "!dist/**", "!coverage/**", "!.turbo/**", "$TURBO_ROOT$/vitest.preset.mts"] + "inputs": [ + "$TURBO_DEFAULT$", + "!dist/**", + "!coverage/**", + "!.turbo/**", + "$TURBO_ROOT$/vitest.preset.mts", + "$TURBO_ROOT$/ui/vitest.config.mts", + "$TURBO_ROOT$/ui/vitest.setup.*", + "$TURBO_ROOT$/ui/scripts/vitest-invocation-guard.mjs", + "$TURBO_ROOT$/ui/scripts/test-shard-*", + "$TURBO_ROOT$/ui/scripts/vite-*.ts", + "$TURBO_ROOT$/ui/apps/console/vitest.config.ts", + "$TURBO_ROOT$/ui/apps/console/vite.config.ts" + ] }, "test:repo": { "dependsOn": ["^build"], From fa8111d0c1b726c6b37bb4bc75f60b515f64e061 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 12:55:40 +0000 Subject: [PATCH 2/6] test(ui): point the build, test, dist and outputs guards at the live turbo config Claude-Session: https://claude.ai/code/session_01QSeKYog9FzTNeBAndKHXTT Co-authored-by: Claude --- ui/apps/console/turbo.json | 2 + .../__tests__/check-dist-completeness.test.ts | 9 +-- ui/scripts/__tests__/helpers/turbo-inputs.ts | 20 ++---- ...ld-outputs-cover-published-entries.test.ts | 7 +- .../turbo-task-guard-coverage.test.ts | 68 ++++++++++++++++--- 5 files changed, 77 insertions(+), 29 deletions(-) diff --git a/ui/apps/console/turbo.json b/ui/apps/console/turbo.json index ae6d38c2725..da0afc30fdc 100644 --- a/ui/apps/console/turbo.json +++ b/ui/apps/console/turbo.json @@ -5,7 +5,9 @@ "build": { "outputs": [ "dist/**", + "json-schema/**", ".next/**", + "!.next/cache/**", "build/**", "**/*.tsbuildinfo", "!**/node_modules/**", diff --git a/ui/scripts/__tests__/check-dist-completeness.test.ts b/ui/scripts/__tests__/check-dist-completeness.test.ts index 3a50724a399..4a6b9ad07e3 100644 --- a/ui/scripts/__tests__/check-dist-completeness.test.ts +++ b/ui/scripts/__tests__/check-dist-completeness.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { uiTurboConfigPath } from '../workspace-root.mjs'; +import { taskInputs } from './helpers/turbo-inputs'; import { UI_TSC_BUILT_PACKAGE_FLOOR } from './helpers/ui-package-population.mjs'; import fs from 'node:fs'; import os from 'node:os'; @@ -310,10 +310,11 @@ describe('this repository', () => { ); it('turbo treats the gate as an input of the builds that run it', () => { - const turbo = JSON.parse(fs.readFileSync(uiTurboConfigPath(), 'utf8')); // Otherwise editing the gate does not invalidate a single cached build, and - // the new rule is enforced only where nothing was cached. - expect(turbo.tasks.build.inputs).toContain('$TURBO_ROOT$/scripts/check-dist-completeness.mjs'); + // the new rule is enforced only where nothing was cached. Read from the + // config turbo runs (`liveTurboConfig()`, the root `turbo.json` narrowed to + // this tree), so the entry is `$TURBO_ROOT$/ui/scripts/...` on disk. + expect(taskInputs('build')).toContain('$TURBO_ROOT$/scripts/check-dist-completeness.mjs'); }); it('has a root alias pointing at the script that exists', () => { diff --git a/ui/scripts/__tests__/helpers/turbo-inputs.ts b/ui/scripts/__tests__/helpers/turbo-inputs.ts index 654badbad68..2218714cb12 100644 --- a/ui/scripts/__tests__/helpers/turbo-inputs.ts +++ b/ui/scripts/__tests__/helpers/turbo-inputs.ts @@ -21,7 +21,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { uiRoot } from '../../ui-root.mjs'; -import { uiScopedTurboConfig, uiTurboConfigPath, workspaceGlobs } from '../../workspace-root.mjs'; +import { uiScopedTurboConfig, workspaceGlobs } from '../../workspace-root.mjs'; /** Repo root: the objectui tree, standalone or as the monorepo's `ui/`. */ export const repoRoot = uiRoot(); @@ -36,23 +36,13 @@ export interface TurboTasks { * (`$TURBO_ROOT$/ui/x` read as `$TURBO_ROOT$/x`, the monorepo's own entries * dropped) by `uiScopedTurboConfig`. Under the monorepo that is the root * `turbo.json`; no turbo invocation reads anything else (objectstack#26080). - * A guard whose property the root config carries reads this one. + * Every guard in this family reads this one: the parked pre-merge copy is gone + * (objectstack#27283), so a guard can only go green on the config turbo hashes. */ export function liveTurboConfig(): TurboTasks { return uiScopedTurboConfig(); } -/** - * objectui's OWN task declaration: `turbo.json` at the repo root, or the copy - * the cutover parks as `turbo.objectui.json`. ⚠️ No turbo run reads the parked - * copy (objectstack#26080 measured every invocation); it is still the default - * here only for the guards whose property the root config does not carry yet - * (`build` and `test` inputs), so none of them is loosened by a retarget. - */ -export function parkedTurboConfig(): TurboTasks { - return JSON.parse(fs.readFileSync(uiTurboConfigPath(), 'utf8')) as TurboTasks; -} - /** POSIX-separated, repo-relative. The one path spelling these guards compare in. */ export function rel(absolute: string): string { return path.relative(repoRoot, absolute).split(path.sep).join('/'); @@ -123,7 +113,7 @@ export function packagesWithScript(script: string): WorkspacePackage[] { * turbo's default, which is the defect all four guards exist to close, so the * absence throws rather than reading as an empty list. */ -export function taskInputs(task: string, turbo: TurboTasks = parkedTurboConfig()): string[] { +export function taskInputs(task: string, turbo: TurboTasks = liveTurboConfig()): string[] { const definition = turbo.tasks?.[task]; if (!definition) throw new Error(`turbo.json must define a \`${task}\` task`); const inputs = definition.inputs ?? []; @@ -138,7 +128,7 @@ export function taskInputs(task: string, turbo: TurboTasks = parkedTurboConfig() * outside the package directory — turbo has no `..` escape, which is why * `$TURBO_ROOT$` exists at all. */ -export function rootAnchoredInputs(task: string, turbo: TurboTasks = parkedTurboConfig()): string[] { +export function rootAnchoredInputs(task: string, turbo: TurboTasks = liveTurboConfig()): string[] { return taskInputs(task, turbo) .filter((entry) => entry.startsWith('$TURBO_ROOT$/')) .map((entry) => entry.slice('$TURBO_ROOT$/'.length)); diff --git a/ui/scripts/__tests__/turbo-build-outputs-cover-published-entries.test.ts b/ui/scripts/__tests__/turbo-build-outputs-cover-published-entries.test.ts index ebfcd20650a..a3870d2998f 100644 --- a/ui/scripts/__tests__/turbo-build-outputs-cover-published-entries.test.ts +++ b/ui/scripts/__tests__/turbo-build-outputs-cover-published-entries.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { uiTurboConfigPath } from '../workspace-root.mjs'; +import { turboConfigPath } from '../workspace-root.mjs'; import fs from 'node:fs'; import path from 'node:path'; import { execFileSync } from 'node:child_process'; @@ -142,7 +142,10 @@ function declaredOutputs(configPath: string): string[] | undefined { return config.tasks?.[TASK]?.outputs; } -const ROOT_OUTPUTS = declaredOutputs(uiTurboConfigPath()); +// The config turbo runs: the nearest `turbo.json` upward, which under the +// monorepo is the root one. `outputs` are package-relative, so no narrowing to +// this tree applies to them. +const ROOT_OUTPUTS = declaredOutputs(turboConfigPath()); if (!ROOT_OUTPUTS || ROOT_OUTPUTS.length === 0) { throw new Error(`turbo.json must declare \`${TASK}\` outputs`); } diff --git a/ui/scripts/__tests__/turbo-task-guard-coverage.test.ts b/ui/scripts/__tests__/turbo-task-guard-coverage.test.ts index 830a27683bf..5ab768a5426 100644 --- a/ui/scripts/__tests__/turbo-task-guard-coverage.test.ts +++ b/ui/scripts/__tests__/turbo-task-guard-coverage.test.ts @@ -1,8 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { uiTurboConfigPath } from '../workspace-root.mjs'; import fs from 'node:fs'; import path from 'node:path'; -import { repoRoot } from './helpers/turbo-inputs'; +import { liveTurboConfig, repoRoot, workspacePackageDirs } from './helpers/turbo-inputs'; /** * The completeness statement of the `turbo.json` inputs-guard family @@ -34,8 +33,32 @@ import { repoRoot } from './helpers/turbo-inputs'; * test:watch cache: false, persistent nothing to replay * clean cache: false nothing to replay * dev cache: false, persistent nothing to replay - * //#lint:root cache: false nothing to replay - * //#type-check:e2e cache: false nothing to replay + * + * ## Which config, and which of its tasks (objectstack#27283) + * + * The config turbo RUNS: the root `turbo.json`, read through `liveTurboConfig()` + * like every guard in this family. Its tasks are the monorepo's, and most of + * them (`typecheck`, `test:repo`, the `@objectstack/#` overrides) + * never run on a package of this tree; their guards belong to the repository + * that owns them. So the partition is drawn over the tasks turbo runs HERE: a + * generic task some package of this tree (a workspace package or the tree's own + * root manifest) declares a script for, and a `#` override whose + * package is one of them. That is the set objectui's pre-merge `turbo.json` + * declared, now read from the file turbo hashes rather than a parked copy no + * run read. + * + * objectui's two former ROOT tasks are not in it. In the monorepo `//` is the + * objectstack root package, so `//#lint:root` (objectui#4456) and + * `//#type-check:e2e` (objectui#4471) are not turbo tasks at all: the root + * `ci.yml` runs each as a plain step (`pnpm --dir ui run lint:root` in + * `lint-ui`, `pnpm --dir ui run type-check:e2e` in `type-check-ui`), which no + * turbo cache stores and whose hash no package task inherits. What this file + * used to say about each, that it is uncached and why, is pinned against those + * steps: the `//#lint:root` block of `turbo-lint-inputs.test.ts` and the wiring + * block of `e2e-type-check.test.ts`. + * + * Why each of them is uncached by design, kept because the reasons outlive the + * spelling: * * `//#lint:root` (objectui#4456) is the root task that lints everything OUTSIDE * the workspace packages — `e2e/`, `scripts/`, `eslint-rules/`, the root config @@ -77,13 +100,29 @@ interface TurboTask { readonly inputs?: string[]; } -const turbo = JSON.parse(fs.readFileSync(uiTurboConfigPath(), 'utf8')) as { - tasks: Record; +const turbo = liveTurboConfig() as { tasks: Record }; + +/** Every package of this tree: its own root manifest and each workspace package. */ +const TREE_MANIFESTS = [repoRoot, ...workspacePackageDirs()].map( + (dir) => + JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')) as { + name?: string; + scripts?: Record; + }, +); +const TREE_NAMES = new Set(TREE_MANIFESTS.map((manifest) => manifest.name)); + +/** A task turbo runs on some package of this tree (see the docblock). */ +const runsHere = (name: string): boolean => { + const at = name.lastIndexOf('#'); + if (at === -1) return TREE_MANIFESTS.some((manifest) => Boolean(manifest.scripts?.[name])); + return TREE_NAMES.has(name.slice(0, at)); }; +const TASKS = Object.entries(turbo.tasks).filter(([name]) => runsHere(name)); /** Turbo caches unless a task opts out, so a missing `cache` key means cached. */ -const CACHEABLE = Object.entries(turbo.tasks).filter(([, task]) => task.cache !== false); -const UNCACHED = Object.entries(turbo.tasks).filter(([, task]) => task.cache === false); +const CACHEABLE = TASKS.filter(([, task]) => task.cache !== false); +const UNCACHED = TASKS.filter(([, task]) => task.cache === false); /** The guard each cacheable task's out-of-package reads are derived by. */ const guardFor = (task: string): string => `turbo-${task}-inputs.test.ts`; @@ -94,6 +133,19 @@ describe('every cacheable turbo task has a derived inputs guard', () => { expect(UNCACHED.map(([name]) => name)).not.toHaveLength(0); }); + /** + * The scope's own liveness. Drawing the partition over "the tasks that run + * here" is only as good as that predicate, and one that dropped a task this + * tree runs would take its guard requirement with it while both branches + * above stayed non-empty. The four guarded tasks are the floor. + */ + it('scopes the partition to the tasks this tree runs, the four guarded ones included', () => { + expect(TREE_MANIFESTS.length, 'the tree sweep read implausibly few packages').toBeGreaterThan(20); + expect(CACHEABLE.map(([name]) => name)).toEqual( + expect.arrayContaining(['build', 'test', 'lint', 'type-check']), + ); + }); + it.each(CACHEABLE.map(([name]) => [name] as const))('`%s` is guarded', (task) => { const guard = path.join(repoRoot, 'scripts/__tests__', guardFor(task)); expect( From 925fba71d1301e9195e00350a4f67d72e6582669 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 12:57:50 +0000 Subject: [PATCH 3/6] test(ui): pin objectui's former root tasks against the ci.yml steps that run them //#lint:root and //#type-check:e2e have no turbo counterpart in the monorepo; the root ci.yml runs each as a plain step. Their guards now read that step and the live turbo config instead of the parked copy. Claude-Session: https://claude.ai/code/session_01QSeKYog9FzTNeBAndKHXTT Co-authored-by: Claude --- ui/scripts/__tests__/e2e-type-check.test.ts | 178 +++++++++--------- ui/scripts/__tests__/helpers/root-ci.ts | 39 ++++ .../__tests__/select-type-check-scope.test.ts | 46 +++-- .../__tests__/turbo-lint-inputs.test.ts | 160 +++++++++------- 4 files changed, 244 insertions(+), 179 deletions(-) diff --git a/ui/scripts/__tests__/e2e-type-check.test.ts b/ui/scripts/__tests__/e2e-type-check.test.ts index 5783c0becee..e94b057a119 100644 --- a/ui/scripts/__tests__/e2e-type-check.test.ts +++ b/ui/scripts/__tests__/e2e-type-check.test.ts @@ -1,11 +1,21 @@ import { describe, expect, it } from 'vitest'; -import { uiTurboConfigPath } from '../workspace-root.mjs'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import ts from 'typescript'; -import { globToRegExp, repoFilesOnDisk } from './helpers/turbo-inputs'; +import { liveTurboConfig } from './helpers/turbo-inputs'; +import { + UI_JOBS, + globMatches, + repoPath, + rootCiJob, + rootCiPath, + stepsRunning, + uiFilterGlobs, + rootCi, + uiRun, +} from './helpers/root-ci'; /** * 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)), '../ const configPath = path.join(repoRoot, 'tsconfig.e2e.json'); const e2eDir = path.join(repoRoot, 'e2e'); -/** The root script a contributor runs, and the turbo root task that drives it. */ +/** The root script a contributor runs, and the root `ci.yml` step runs. */ const SCRIPT_NAME = 'type-check:e2e'; -const TURBO_TASK = `//#${SCRIPT_NAME}`; /** * The project as TypeScript itself resolves it: the same parse `tsc -p` does, @@ -375,13 +384,34 @@ describe('tsconfig.e2e.json — coverage of the root playwright configs (objectu }); }); -describe('the gate is actually wired up (objectui#4471)', () => { +/** + * objectui#4471's wiring, as the monorepo runs it (objectstack#27283). + * + * Standalone, objectui made this gate the turbo ROOT task `//#type-check:e2e` + * and every package's `type-check` `dependsOn` it, so `pnpm type-check` reached + * `e2e/` with no workflow entry of its own. Under the monorepo `//` is the + * objectstack root package and no turbo task can name objectui's root manifest, + * so the root `ci.yml` runs the script as a step of `type-check-ui` instead + * (objectstack#25697). Each property the root task carried is pinned here + * against that step, read from the workflow GitHub runs: + * + * - "a turbo root task, so `pnpm type-check` reaches e2e/" -> the step exists + * in the job that type-checks the ui packages, and runs exactly the script; + * - "runs as part of `pnpm type-check`" -> the step has no + * `if:` of its own, and every file of the e2e program sets the `ui` filter + * that job's `if:` reads, so an e2e-only change still runs it; + * - "hashes its own program, not the whole repository" -> no root task's + * hash is folded into any package's `type-check`: the live `type-check` + * `dependsOn` names no `//#` task, the stronger form of the same property; + * - "is uncached" -> the step is the + * script itself, not a `turbo run`, so no cache can replay its verdict. + */ +describe('the gate is actually wired up (objectui#4471, objectstack#27283)', () => { const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')) as { scripts?: Record; }; - const turbo = JSON.parse(fs.readFileSync(uiTurboConfigPath(), 'utf8')) as { - tasks: Record; - }; + const job = rootCiJob(UI_JOBS.typeCheck); + const steps = stepsRunning(job, uiRun(SCRIPT_NAME)); it('is runnable locally, by name', () => { // A gate nobody can reproduce locally is a gate people learn to ignore. @@ -389,102 +419,68 @@ describe('the gate is actually wired up (objectui#4471)', () => { expect(pkg.scripts?.[SCRIPT_NAME]).toContain('tsconfig.e2e.json'); }); - it('is a turbo root task, which is what makes `pnpm type-check` reach e2e/', () => { - // Turbo resolves `//#x` to the ROOT package.json's `x` script. Declaring the - // task without the script (or renaming the script out from under it) is a - // hard turbo error rather than a silent skip, but pin the pairing anyway: - // it is the whole reason the script above is not dead weight. + it('is a step of the job that type-checks the ui packages, which is what makes CI reach e2e/', () => { expect( - turbo.tasks[TURBO_TASK], - `turbo.json must declare the root task \`${TURBO_TASK}\`. Without it, ` + - `\`pnpm type-check\` (turbo run type-check) never reaches e2e/ and this config is ` + - 'CI-invisible unless someone adds a workflow step — which is the shape objectui#4456 ' + - 'moved away from.', - ).toBeDefined(); + steps, + `${rootCiPath}'s \`${UI_JOBS.typeCheck}\` job must run \`${uiRun(SCRIPT_NAME)}\` as a step. ` + + 'Nothing else compiles e2e/: it is not a workspace package, so `turbo run type-check` cannot ' + + 'reach it, and the monorepo has no turbo root task that could (`//` is the objectstack root).', + ).toHaveLength(1); expect( - pkg.scripts?.[SCRIPT_NAME], - `\`${TURBO_TASK}\` resolves to the root package.json script "${SCRIPT_NAME}"`, - ).toBeDefined(); + job.steps.some((step) => /\bturbo run type-check\b/.test(step.run ?? '')), + `the premise: \`${UI_JOBS.typeCheck}\` is the job that runs \`turbo run type-check\` over the ui packages`, + ).toBe(true); }); - it('runs as part of `pnpm type-check`', () => { + it('runs on every type-check of the ui tree, an e2e-only change included', () => { expect( - turbo.tasks['type-check']?.dependsOn ?? [], - `turbo.json's \`type-check\` task must \`dependsOn\` \`${TURBO_TASK}\`. That dependency is ` + - 'the entire wiring: ci.yml’s existing `Run type-check` step gains e2e/ coverage with ' + - 'no new workflow entry, exactly as `lint` gained `//#lint:root` in objectui#4456.', - ).toContain(TURBO_TASK); - }); - - /** - * objectui#12222. Turbo folds a dependency's hash into its dependent's, and - * every package's `type-check` depends on this task — so this task's hash is - * part of EVERY package's type-check hash. With no `inputs`, a root task - * hashes every tracked file in the repository (turbo's default for the root - * package), so any commit anywhere moved all of those hashes and the restored - * `.turbo/cache` replayed nothing: `Type Check` re-ran every package on every - * pull request and queue build. Measured on that card, before the fix: one - * untracked file under `docs/` moved every `type-check` hash and no `build` - * hash. - * - * The task stays uncached (below), so its `inputs` decide nothing but that - * hash. They name its own program — what `tsconfig.e2e.json` includes, and - * the config itself — so the hash moves when the e2e program does and not - * when anything else in the repository does. Turbo adds the root - * `package.json` and `turbo.json` on its own. - */ - it('hashes its own program, not the whole repository, because every type-check inherits its hash', () => { - const inputs = turbo.tasks[TURBO_TASK]?.inputs ?? []; - expect( - inputs, - `\`${TURBO_TASK}\` declares no \`inputs\`, so turbo hashes every tracked file in the ` + - 'repository for it — and every package’s `type-check` inherits that hash through ' + - '`dependsOn`, so no commit can ever replay a cached type-check (objectui#12222).', - ).not.toHaveLength(0); - expect(inputs, '`$TURBO_DEFAULT$` on a root task is the whole repository again').not.toContain( - '$TURBO_DEFAULT$', + steps[0]?.if, + `the \`${uiRun(SCRIPT_NAME)}\` step must carry no \`if:\` of its own: as objectui's root task it ` + + 'ran on every `pnpm type-check`, and a condition here would let a run type-check the packages ' + + 'and skip e2e/.', + ).toBeUndefined(); + expect(job.if ?? '', 'the premise: the job runs when the `ui` paths filter is set').toMatch( + /needs\.filter\.outputs\.ui\b/, ); - - const matchers = inputs.map((glob) => globToRegExp(glob)); - const program = [...programFiles(), 'tsconfig.e2e.json']; - const unhashed = program.filter((file) => !matchers.some((re) => re.test(file))); + const globs = uiFilterGlobs(rootCi()); + const program = [...programFiles(), 'tsconfig.e2e.json'].map(repoPath); + expect(program.length, 'the e2e program resolved implausibly few files').toBeGreaterThan(5); + const unfiltered = program.filter((file) => !globs.some((glob) => globMatches(glob, file))); expect( - unhashed, - `files of the e2e program that \`${TURBO_TASK}\`'s inputs do not name — its hash would not ` + - 'move when they change:\n' + - unhashed.map((f) => ` - ${f}`).join('\n'), + unfiltered, + 'files of the e2e program the `ui` paths filter does not match — a change to only these would ' + + `not run \`${UI_JOBS.typeCheck}\`, so nothing would type-check it:\n` + + unfiltered.map((f) => ` - ${f}`).join('\n'), ).toEqual([]); + }); - const ownProgram = /^(?:e2e\/|playwright[^/]*\.config\.ts$|tsconfig\.e2e\.json$)/; - const beyond = [...repoFilesOnDisk()].filter( - (file) => matchers.some((re) => re.test(file)) && !ownProgram.test(file), - ); + /** + * objectui#12222. Turbo folds a dependency's hash into its dependent's, so + * when every package's `type-check` depended on this root task, its hash was + * part of every one of theirs, and with no `inputs` any commit anywhere moved + * all of them. Measured on that card, before the fix: one untracked file under + * `docs/` moved every `type-check` hash and no `build` hash. objectui scoped + * the root task's inputs to its own program. Run as a step, the program + * enters no turbo hash at all — provided no root task comes back into the + * `type-check` graph, which is what this holds. + */ + it('folds no root-task hash into any package type-check, because every type-check would inherit it', () => { + const typeCheck = liveTurboConfig().tasks?.['type-check'] as { dependsOn?: string[] } | undefined; + expect(typeCheck, 'the root turbo.json must define `type-check`').toBeDefined(); expect( - beyond, - `\`${TURBO_TASK}\`'s inputs reach files outside its own program, and each one moves every ` + - 'package’s type-check hash when it changes:\n' + - beyond.map((f) => ` - ${f}`).join('\n'), + (typeCheck?.dependsOn ?? []).filter((dep) => dep.startsWith('//#')), + 'the live `type-check` depends on a root task: its hash is now part of every package ' + + 'type-check hash, so it must declare inputs scoped to its own program (objectui#12222), ' + + 'and the step this file pins becomes a second run of the same program.', ).toEqual([]); }); - /** - * The partition rule from `turbo-task-guard-coverage.test.ts`, restated where - * it bites. That file requires every CACHEABLE task to have a - * `turbo--inputs.test.ts` deriving its out-of-package reads — and for a - * `//#`-prefixed task the derived name is not even a legal path, so a cached - * root task cannot satisfy the family at all. The substantive reason is the - * same one: tsc's real read set is the import closure (`@playwright/test`'s - * declarations, `@types/node`), and the sibling guards deliberately narrow to - * config root files. A cached verdict over that is replayable in a way nobody - * derives, which is objectui#3514 with a type gate attached. - */ - it('is uncached, the side of the guard-coverage partition it belongs on', () => { + it('is uncached: the step is the script itself, which no turbo cache stores', () => { expect( - turbo.tasks[TURBO_TASK]?.cache, - `\`${TURBO_TASK}\` must stay "cache": false. If it becomes cacheable, ` + - 'turbo-task-guard-coverage.test.ts starts demanding a derived-inputs guard for it — and ' + - 'the name that guard would have to carry (turbo-//#type-check:e2e-inputs.test.ts) is not ' + - 'a path. It costs a few seconds; leave it uncached.', - ).toBe(false); + steps[0]?.run?.trim(), + `the step must run \`${uiRun(SCRIPT_NAME)}\` directly. Routed through \`turbo run\` it would be ` + + 'cacheable, and a tsc program\'s real read set is its import closure (`@playwright/test`\'s ' + + 'declarations, `@types/node`), which no derived-inputs guard in this family narrows soundly.', + ).toBe(uiRun(SCRIPT_NAME)); }); }); diff --git a/ui/scripts/__tests__/helpers/root-ci.ts b/ui/scripts/__tests__/helpers/root-ci.ts index 924e5b8228a..2501ec19ecb 100644 --- a/ui/scripts/__tests__/helpers/root-ci.ts +++ b/ui/scripts/__tests__/helpers/root-ci.ts @@ -22,6 +22,7 @@ */ import fs from 'node:fs'; import path from 'node:path'; +import { parse as parseYaml } from 'yaml'; import { nestedPrefix, workspaceRoot } from '../../workspace-root.mjs'; @@ -155,3 +156,41 @@ export function globMatches(glob: string, file: string): boolean { .join('.*')}$`, ).test(file); } + +/** One step of a root workflow job, as parsed. */ +export interface CiStep { + readonly name?: string; + readonly run?: string; + readonly uses?: string; + readonly if?: string; +} + +/** One root workflow job, as parsed: its condition and its steps. */ +export interface CiJob { + readonly if?: string; + readonly steps: CiStep[]; +} + +/** + * One job of the root `ci.yml`, parsed. Throws when the workflow defines no + * such job or it parses to implausibly few steps, so a renamed job or a parse + * that collapsed the list is a red pin, never a scan over nothing. + */ +export function rootCiJob(key: string): CiJob { + const workflow = parseYaml(rootCi()) as { jobs?: Record }; + const job = workflow.jobs?.[key]; + if (!job) throw new Error(`${rootCiPath} defines no \`${key}\` job`); + if (!Array.isArray(job.steps) || job.steps.length < 5) { + throw new Error(`${rootCiPath}: the \`${key}\` job parsed to implausibly few steps`); + } + return job; +} + +/** + * The steps of a root `ci.yml` job whose whole `run:` is the given command, e.g. + * `uiRun('lint:root')`. Exact, so a step that merely mentions the alias inside + * a longer script (or a comment) does not count as running it. + */ +export function stepsRunning(job: CiJob, command: string): CiStep[] { + return job.steps.filter((step) => typeof step.run === 'string' && step.run.trim() === command); +} diff --git a/ui/scripts/__tests__/select-type-check-scope.test.ts b/ui/scripts/__tests__/select-type-check-scope.test.ts index a187b2080f2..674f4f787bd 100644 --- a/ui/scripts/__tests__/select-type-check-scope.test.ts +++ b/ui/scripts/__tests__/select-type-check-scope.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { uiTurboConfigPath } from '../workspace-root.mjs'; +import { uiScopedTurboConfig } from '../workspace-root.mjs'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -28,10 +28,9 @@ import { * added to turbo.json is a root change the day it lands. */ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); -const turbo = JSON.parse(fs.readFileSync(uiTurboConfigPath(), 'utf8')) as { - globalDependencies?: string[]; - tasks: Record; -}; +// The config turbo runs, narrowed to this tree: what the production selector +// reads (`main` below), so the rule these cases hold is the rule it applies. +const turbo = uiScopedTurboConfig(); const RULE = rootRule(turbo); const caught = (files: string[]): string[] => rootChanges(files, RULE).map((h: { file: string }) => h.file); @@ -48,14 +47,30 @@ describe('select-type-check-scope — the root rule (objectui#12222)', () => { expect(derived.filter((g) => !globs.has(g))).toEqual([]); }); + /** + * Rule item 4. objectui's own `turbo.json` made `type-check` depend on the + * root task `//#type-check:e2e`; the monorepo's root config cannot name + * objectui's root manifest (`//` is the objectstack root), so it has no root + * task and the e2e program is compiled by a `type-check-ui` step instead + * (`e2e-type-check.test.ts` pins that step and that `type-check` folds in no + * root task). The selector still carries the rule for a config that has one, + * so the case holds it on objectui's own shape of that task, verbatim. + */ it('takes the root task’s own program, because it runs only as a dependency of a selected package', () => { - const rootDeps = (turbo.tasks['type-check'].dependsOn ?? []).filter((d) => d.startsWith('//#')); - expect(rootDeps, '`type-check` no longer depends on a root task — this case has nothing to hold').not.toHaveLength(0); - for (const dep of rootDeps) { - for (const glob of turbo.tasks[dep].inputs ?? []) { - expect(RULE.map((r: { glob: string }) => r.glob)).toContain(glob); - } - } + const withRootTask = { + tasks: { + 'type-check': { inputs: ['$TURBO_DEFAULT$'], dependsOn: ['^build', '//#type-check:e2e'] }, + build: { inputs: ['$TURBO_DEFAULT$'] }, + '//#type-check:e2e': { inputs: ['e2e/**', 'playwright*.config.ts', 'tsconfig.e2e.json'] }, + }, + }; + const rule = rootRule(withRootTask); + const globs = rule.map((r: { glob: string }) => r.glob); + for (const glob of withRootTask.tasks['//#type-check:e2e'].inputs) expect(globs).toContain(glob); + const e2eProgram = ['e2e/smoke.spec.ts', 'playwright.config.ts', 'tsconfig.e2e.json']; + expect(rootChanges(e2eProgram, rule).map((h: { file: string }) => h.file)).toEqual(e2eProgram); + // …and the root task's own inputs are all it adds: a package file stays the package graph's. + expect(rootChanges(['packages/plugin-kanban/src/x.ts'], rule)).toEqual([]); }); it('widens to the whole workspace for every root file the package graph cannot see', () => { @@ -69,9 +84,10 @@ describe('select-type-check-scope — the root rule (objectui#12222)', () => { 'patches/some-dep@1.0.0.patch', 'scripts/vite-crypto-stub.ts', 'vitest.config.mts', - 'e2e/smoke.spec.ts', - 'playwright.config.ts', - 'tsconfig.e2e.json', + // `e2e/smoke.spec.ts`, `playwright.config.ts` and `tsconfig.e2e.json` were + // caught by rule item 4, through objectui's root task. The monorepo has no + // such task (the next case holds item 4 on its shape); the e2e program runs + // as a step of every `type-check-ui` run instead (e2e-type-check.test.ts). // A `type-check` input that sits INSIDE a package: every package's // type-check hash reads it, while the package graph would only select its // owner. (The `build` specimen, `packages/sdui-parser/src/parse.ts`, left diff --git a/ui/scripts/__tests__/turbo-lint-inputs.test.ts b/ui/scripts/__tests__/turbo-lint-inputs.test.ts index 24300b36eda..6bb11036411 100644 --- a/ui/scripts/__tests__/turbo-lint-inputs.test.ts +++ b/ui/scripts/__tests__/turbo-lint-inputs.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it } from 'vitest'; -import { uiTurboConfigPath } from '../workspace-root.mjs'; import fs from 'node:fs'; import path from 'node:path'; import ts from 'typescript'; @@ -15,6 +14,17 @@ import { taskInputs, } from './helpers/turbo-inputs'; import { walkedFiles } from '../check-lint-rule-coverage.mjs'; +import { + UI_JOBS, + globMatches, + repoPath, + rootCi, + rootCiJob, + rootCiPath, + stepsRunning, + uiFilterGlobs, + uiRun, +} from './helpers/root-ci'; import { FLAT_CONFIG_FILENAMES, configFilesFor, @@ -250,106 +260,110 @@ describe('turbo `lint` inputs cover every out-of-package file (objectui#4184)', }); /** - * objectui#12276 — the root task `lint` depends on hashes what it LINTS, not the - * repository. + * objectui#12276 — the root task `lint` depended on hashed what it LINTS, not + * the repository; under the monorepo, a CI step that no hash inherits + * (objectstack#27283). + * + * Standalone, every package's `lint` dependsOn `//#lint:root` (objectui#4456), + * and turbo folds a dependency's hash into its dependents'. The root task stayed + * uncached, so its `inputs` decided nothing but that inherited hash, and with + * none turbo hashed every file in the repository for it. Measured on that card, + * before the fix: one untracked file under `docs/` moved 46 of 46 package `lint` + * hashes, so the cache `Lint` restores could never replay a single package. + * objectui derived the inputs from the `lint:root` script's `--ignore-pattern`s. * - * Every package's `lint` dependsOn `//#lint:root` (objectui#4456), and turbo - * folds a dependency's hash into its dependents'. The root task stays uncached - * (`turbo-task-guard-coverage.test.ts` says why), so its `inputs` decide nothing - * but that inherited hash — and with none, turbo hashed every file in the - * repository for it. Measured on that card, before the fix: one untracked file - * under `docs/` moved 46 of 46 package `lint` hashes, so the cache `Lint` - * restores could never replay a single package. After it, on the same probe: 0 - * of 46; an untracked `.changeset/` or `content/` file, 0; a file in one - * package, that package's alone; a root-level linted file (`scripts/`, `e2e/`) - * or `eslint.config.js`, all 46 — readings of that day, not live counts. + * Under the monorepo `//` is the objectstack root package, so no turbo task can + * name objectui's root manifest: the root `ci.yml` runs the script as a step of + * `lint-ui` (objectstack#25697), and the live `lint` task depends on no root + * task. Each property the root task carried is pinned against that step, read + * from the workflow GitHub runs: * - * The inputs are the extensions ESLint lints at that scope, minus exactly the - * trees the `lint:root` script hands ESLint as `--ignore-pattern`, so they - * follow the script rather than a list of root directories that a new one would - * outdate. Plus one exclusion that is turbo's, not ESLint's: under explicit - * `inputs` turbo hashes git-ignored files as well (measured on that card: 64008 - * inputs, 63598 of them under the root `node_modules`), which `$TURBO_DEFAULT$` - * never does. + * - "what every package `lint` depends on" -> a step of the job that + * lints the ui packages, running exactly the script; + * - "declares inputs, not `$TURBO_DEFAULT$`" -> no root-task hash is + * folded into any package `lint` at all, the stronger form of the property + * (no unrelated repository file can move every package's lint hash); + * - "excludes exactly the trees the script ignores" -> each ignored workspace + * group is one the job's turbo `lint` step lints, so the two steps partition + * the tree and `--ignore-pattern` never means "linted by nothing"; + * - "hashes every file ESLint lints" -> every file ESLint walks + * at that scope sets the `ui` paths filter the job's `if:` reads, so a change + * to any of them runs the step; + * - "stays uncached" -> the step is the script + * itself, not a `turbo run`, so no cache can replay its verdict. * - * ⚠️ Bound, said so it is not read as coverage: turbo adds the root - * `package.json` and `turbo.json` to a root task's inputs on its own, so an - * edit to either still moves every package's `lint` hash. And a git-ignored - * output directory with linted extensions outside the four trees (a local - * `coverage/`, say) is hashed too; none exists when `Lint` runs `pnpm lint`. + * The old "inputs reach no extension `lint:root` never lints" half had one job, + * keeping unlinted files out of the hash every package inherited; with no such + * hash, the second case below holds it. */ -describe('`//#lint:root` hashes the files it lints, never the whole repository (objectui#12276)', () => { - const ROOT_TASK = '//#lint:root'; - const turbo = JSON.parse(fs.readFileSync(uiTurboConfigPath(), 'utf8')) as { - tasks: Record; - }; - const inputs = turbo.tasks[ROOT_TASK]?.inputs ?? []; - const include = inputs.filter((glob) => !glob.startsWith('!')).map((glob) => globToRegExp(glob)); - const exclude = inputs.filter((glob) => glob.startsWith('!')).map((glob) => globToRegExp(glob.slice(1))); - /** Turbo's rule: a file is an input when some glob names it and no `!` glob removes it. */ - const hashed = (file: string): boolean => include.some((re) => re.test(file)) && !exclude.some((re) => re.test(file)); +describe('`lint:root` runs as a CI step no package lint hash inherits (objectui#12276, objectstack#27283)', () => { + const SCRIPT = 'lint:root'; + const job = rootCiJob(UI_JOBS.lint); + const steps = stepsRunning(job, uiRun(SCRIPT)); const script = ( JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')) as { scripts: Record } - ).scripts['lint:root']; + ).scripts[SCRIPT]; const ignorePatterns = [...(script ?? '').matchAll(/--ignore-pattern\s+'([^']+)'/g)].map((m) => m[1]); - it('is what every package `lint` depends on, so its hash is in all of theirs', () => { - expect(turbo.tasks.lint?.dependsOn ?? [], 'the premise of this block').toContain(ROOT_TASK); + it('is a step of the job that lints the ui packages, running exactly the script', () => { expect(script, 'the root package.json must still define `lint:root`').toMatch(/^eslint \. /); expect(ignorePatterns, 'the `lint:root` ignore-pattern parse came back empty').toContain('docs/**'); + expect( + steps, + `${rootCiPath}'s \`${UI_JOBS.lint}\` job must run \`${uiRun(SCRIPT)}\` as a step: nothing else ` + + 'lints the files outside the workspace packages (`e2e/`, `scripts/`, `eslint-rules/`, the root ' + + 'configs), which no package `lint` script reaches (objectui#4456).', + ).toHaveLength(1); + expect(steps[0]?.if, `the \`${uiRun(SCRIPT)}\` step must carry no \`if:\` of its own`).toBeUndefined(); }); - it('declares inputs, and `$TURBO_DEFAULT$` is not one of them', () => { + it('folds no root-task hash into any package `lint`, so no unrelated file moves all of them', () => { + const lint = liveTurboConfig().tasks?.lint as { dependsOn?: string[] } | undefined; + expect(lint, 'the root turbo.json must define `lint`').toBeDefined(); expect( - inputs, - `\`${ROOT_TASK}\` declares no \`inputs\`, so turbo hashes every file in the repository for it — ` + - 'and every package’s `lint` inherits that hash, so no commit can replay a cached lint.', - ).not.toHaveLength(0); - expect(inputs, '`$TURBO_DEFAULT$` on a root task is the whole repository again').not.toContain( - '$TURBO_DEFAULT$', - ); + (lint?.dependsOn ?? []).filter((dep) => dep.startsWith('//#')), + 'the live `lint` depends on a root task: its hash is now part of every package lint hash, so ' + + 'it must declare inputs scoped to what it lints (objectui#12276) — or no commit can replay a ' + + 'cached lint.', + ).toEqual([]); }); - it('excludes exactly the trees the `lint:root` script ignores, and node_modules', () => { - const negations = inputs.filter((glob) => glob.startsWith('!')).map((glob) => glob.slice(1)); + it('ignores only trees the job lints elsewhere, so the two steps partition the tree', () => { + const packageLint = job.steps.find((step) => /\bturbo run lint\b/.test(step.run ?? '')); + expect(packageLint, `the premise: \`${UI_JOBS.lint}\` runs \`turbo run lint\` over the ui packages`).toBeDefined(); + const filters = [...(packageLint?.run ?? '').matchAll(/--filter='\.\/ui\/([^']+)'/g)].map((m) => m[1]); + const groups = ignorePatterns.filter((glob) => glob !== 'docs/**').map((glob) => glob.replace(/\/\*\*$/, '')); + expect(groups.sort(), 'the `--ignore-pattern`s other than docs/** are the workspace groups').toEqual( + ['apps/*', 'examples/*', 'packages/*'], + ); expect( - negations.sort(), - 'the `!` inputs must be the `lint:root` script’s `--ignore-pattern` values, so a file in a tree ' + - 'that script never lints cannot move every package’s lint hash, plus `**/node_modules/**`, ' + - 'which turbo would otherwise hash', - ).toEqual([...ignorePatterns, '**/node_modules/**'].sort()); - for (const probe of ['node_modules/x/index.js', 'scripts/node_modules/y/index.mjs', 'docs/z.ts']) { - expect(hashed(probe), `${probe} must not be a \`${ROOT_TASK}\` input`).toBe(false); - } + groups.filter((group) => !filters.includes(group)), + 'a tree `lint:root` ignores that the turbo `lint` step does not filter in is linted by nothing', + ).toEqual([]); }); - it('hashes every file ESLint lints at the `lint:root` scope', async () => { + it('runs whenever a file it lints changes', async () => { const eslint = new ESLint({ cwd: repoRoot, ignorePatterns }); const walked = await walkedFiles(repoRoot, eslint); expect(walked.length, 'the `lint:root` walk found implausibly few files').toBeGreaterThan(100); expect(walked, 'the walk must reach the root config and a root script').toEqual( expect.arrayContaining(['eslint.config.js', 'scripts/report-turbo-cache.mjs']), ); - const unhashed = walked.filter((file) => !hashed(file)); - expect( - unhashed, - `files \`lint:root\` lints that \`${ROOT_TASK}\`'s inputs do not name — an edit to them would ` + - 'not move the hash its dependents inherit:\n' + - unhashed.map((f) => ` - ${f}`).join('\n'), - ).toEqual([]); - - const linted = new Set(walked.map((file) => path.extname(file))); - const beyond = [...repoFilesOnDisk()].filter((file) => hashed(file) && !linted.has(path.extname(file))); + expect(job.if ?? '', 'the premise: the job runs when the `ui` paths filter is set').toMatch( + /needs\.filter\.outputs\.ui\b/, + ); + const globs = uiFilterGlobs(rootCi()); + const unfiltered = walked.map(repoPath).filter((file) => !globs.some((glob) => globMatches(glob, file))); expect( - beyond, - `\`${ROOT_TASK}\`'s inputs reach files of an extension \`lint:root\` never lints, and each one ` + - 'moves every package’s lint hash when it changes:\n' + - beyond.map((f) => ` - ${f}`).join('\n'), + unfiltered, + `files \`lint:root\` lints that the \`ui\` paths filter does not match — a change to only these ` + + `would not run \`${UI_JOBS.lint}\`, so nothing would lint it:\n` + + unfiltered.map((f) => ` - ${f}`).join('\n'), ).toEqual([]); }); - it('stays uncached, so its inputs scope a hash and never a replayed verdict', () => { - expect(turbo.tasks[ROOT_TASK]?.cache).toBe(false); + it('stays uncached: the step is the script itself, which no turbo cache stores', () => { + expect(steps[0]?.run?.trim()).toBe(uiRun(SCRIPT)); }); }); From a1d7b86dd902d72cd4c04a535a8fd5ecb014b083 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 12:58:38 +0000 Subject: [PATCH 4/6] chore(ui): delete the parked turbo.objectui.json and its reader No run read it, and no guard reads it any more. Claude-Session: https://claude.ai/code/session_01QSeKYog9FzTNeBAndKHXTT Co-authored-by: Claude --- ui/scripts/__tests__/workspace-root.test.ts | 14 +-- ui/scripts/select-type-check-scope.mjs | 4 +- ui/scripts/workspace-root.d.mts | 3 - ui/scripts/workspace-root.mjs | 36 ++---- ui/turbo.objectui.json | 115 -------------------- 5 files changed, 14 insertions(+), 158 deletions(-) delete mode 100644 ui/turbo.objectui.json diff --git a/ui/scripts/__tests__/workspace-root.test.ts b/ui/scripts/__tests__/workspace-root.test.ts index 47c8aa72bf1..f6638ea6fbd 100644 --- a/ui/scripts/__tests__/workspace-root.test.ts +++ b/ui/scripts/__tests__/workspace-root.test.ts @@ -25,7 +25,6 @@ import { rootManifestPath, turboConfigPath, uiScopedTurboConfig, - uiTurboConfigPath, virtualStorePath, workspaceGlobs, workspaceManifestPath, @@ -88,9 +87,6 @@ const NESTED: Files = { }, }), 'mono/ui/package.json': JSON.stringify({ name: 'fixture-ui' }), - 'mono/ui/turbo.objectui.json': JSON.stringify({ - tasks: { build: { inputs: ['$TURBO_DEFAULT$', '$TURBO_ROOT$/scripts/check-dist.mjs'] } }, - }), }; describe('workspaceRoot() -- rule (a): the tree, else its parent, else a throw', () => { @@ -182,24 +178,21 @@ describe('parseWorkspaceGlobs() -- the one parser, with the union of the three r }); }); -describe('turbo.json -- the nearest one for what turbo hashes, the tree’s own for its task shapes', () => { +describe('turbo.json -- the nearest one, what turbo hashes, narrowed to the tree', () => { it('standalone: every answer is `/turbo.json` and the config comes back unnarrowed', () => { withTree(STANDALONE, (tmp) => { const ws = path.join(tmp, 'ws'); expect(turboConfigPath(ws)).toBe(path.join(ws, 'turbo.json')); - expect(uiTurboConfigPath(ws)).toBe(path.join(ws, 'turbo.json')); expect(uiScopedTurboConfig(ws)).toEqual(JSON.parse(fs.readFileSync(path.join(ws, 'turbo.json'), 'utf8'))); }); }); - it('nested: the parent’s turbo.json is nearest, the parked copy carries the tree’s own shapes', () => { + it('nested: the parent’s turbo.json is nearest, with no config of the tree’s own', () => { withTree(NESTED, (tmp) => { const mono = path.join(tmp, 'mono'); const ui = path.join(mono, 'ui'); + expect(fs.existsSync(path.join(ui, 'turbo.json'))).toBe(false); expect(turboConfigPath(ui)).toBe(path.join(mono, 'turbo.json')); - expect(uiTurboConfigPath(ui)).toBe(path.join(ui, 'turbo.objectui.json')); - fs.rmSync(path.join(ui, 'turbo.objectui.json')); - expect(() => uiTurboConfigPath(ui)).toThrow(/neither turbo\.json nor turbo\.objectui\.json/); }); }); @@ -235,7 +228,6 @@ describe('this checkout', () => { expect(fs.existsSync(path.join(uiRoot(), glob.slice(0, -2))), glob).toBe(true); } expect(fs.existsSync(turboConfigPath())).toBe(true); - expect(fs.existsSync(uiTurboConfigPath())).toBe(true); expect(uiScopedTurboConfig().tasks.build.inputs).toContain('$TURBO_DEFAULT$'); }); diff --git a/ui/scripts/select-type-check-scope.mjs b/ui/scripts/select-type-check-scope.mjs index cfebdcd1818..7db3df71ebe 100644 --- a/ui/scripts/select-type-check-scope.mjs +++ b/ui/scripts/select-type-check-scope.mjs @@ -65,8 +65,8 @@ * Which turbo.json: the nearest one upward, through `uiScopedTurboConfig` in * `scripts/workspace-root.mjs` (objectstack#25723). Standalone that is this * repository's own. Under the objectstack monorepo the objectui tree is `ui/` - * and its turbo.json is parked (a nested one must `extends` the root), so the - * root's is read and narrowed to the objectui SUBSET: its `$TURBO_ROOT$/ui/x` + * and has no turbo.json of its own (a nested one must `extends` the root), so + * the root's is read and narrowed to the objectui SUBSET: its `$TURBO_ROOT$/ui/x` * inputs are the re-rooted spelling of `$TURBO_ROOT$/x` and are read as such, * and every other `$TURBO_ROOT$` input and `globalDependencies` entry is the * monorepo's own and is dropped -- read whole, the rule would widen every diff --git a/ui/scripts/workspace-root.d.mts b/ui/scripts/workspace-root.d.mts index ee6a559ca93..b9dff97d082 100644 --- a/ui/scripts/workspace-root.d.mts +++ b/ui/scripts/workspace-root.d.mts @@ -11,7 +11,6 @@ export declare const LOCKFILE: 'pnpm-lock.yaml'; export declare const ROOT_MANIFEST: 'package.json'; export declare const TURBO_CONFIG: 'turbo.json'; export declare const CHANGESET_CONFIG: '.changeset/config.json'; -export declare const PARKED_TURBO_CONFIG: 'turbo.objectui.json'; export declare const TURBO_ROOT_PREFIX: '$TURBO_ROOT$/'; export declare const VIRTUAL_STORE: '.pnpm'; @@ -39,8 +38,6 @@ export declare function workspaceGlobs(root?: string): string[]; /** The nearest `turbo.json` upward: what turbo hashes from. */ export declare function turboConfigPath(root?: string): string; export declare function changesetConfigPath(root?: string): string; -/** objectui's own task declaration: `turbo.json` at `root`, else the parked copy. */ -export declare function uiTurboConfigPath(root?: string): string; export interface TurboConfigLike { globalDependencies?: string[]; diff --git a/ui/scripts/workspace-root.mjs b/ui/scripts/workspace-root.mjs index 7ad238a44a8..a763dff485f 100644 --- a/ui/scripts/workspace-root.mjs +++ b/ui/scripts/workspace-root.mjs @@ -20,9 +20,11 @@ * - the root `package.json`'s `pnpm` block (overrides, patchedDependencies) * moved to the workspace root's manifest and * workspace file - * - `turbo.json` parked as `turbo.objectui.json`; the parent's - * carries objectui's tasks with their repo-root - * inputs re-rooted to `$TURBO_ROOT$/ui/...` + * - `turbo.json` deleted; the parent's carries objectui's tasks + * with their repo-root inputs re-rooted to + * `$TURBO_ROOT$/ui/...` (the parked pre-merge copy, + * `turbo.objectui.json`, went with objectstack#27283: + * no run read it) * * Every reader that joined `uiRoot()` with one of those names was the first red * in the merged tree's CI (objectstack#25723). This module is where that join @@ -45,10 +47,10 @@ * the objectui root package's own declared dependencies, and the store * every installed copy lives in is the workspace root's (objectstack#25733). * (d) `turboConfigPath()` is the nearest `turbo.json` upward (what turbo - * hashes from); `uiTurboConfigPath()` is objectui's OWN task declaration, - * `turbo.json` at `uiRoot()` or the parked `turbo.objectui.json`; and - * `uiScopedTurboConfig()` is the nearest config narrowed to the objectui - * subset (`$TURBO_ROOT$/ui/x` read as `$TURBO_ROOT$/x`, the rest dropped). + * hashes from), and `uiScopedTurboConfig()` is that config narrowed to + * the objectui subset (`$TURBO_ROOT$/ui/x` read as `$TURBO_ROOT$/x`, the + * rest dropped). There is no second, tree-own task declaration to read: + * a guard that pins objectui's task shapes reads the narrowed config. * * Every function takes an optional explicit root for fixtures: a test that * writes its own `pnpm-workspace.yaml` into a scratch directory is a workspace @@ -115,8 +117,6 @@ export const ROOT_MANIFEST = 'package.json'; export const TURBO_CONFIG = 'turbo.json'; /** The Changesets config, relative to the root that holds it. */ export const CHANGESET_CONFIG = '.changeset/config.json'; -/** Where the cutover parks objectui's own `turbo.json` (a nested one must `extends` the root). */ -export const PARKED_TURBO_CONFIG = 'turbo.objectui.json'; export const TURBO_ROOT_PREFIX = '$TURBO_ROOT$/'; /** pnpm's virtual store directory, inside the workspace root's `node_modules`. */ export const VIRTUAL_STORE = '.pnpm'; @@ -315,24 +315,6 @@ export function changesetConfigPath(root = uiRoot()) { throw new Error(`no ${CHANGESET_CONFIG} at ${own} or at ${parent} (objectstack#25711).`); } -/** - * objectui's OWN task declaration, for a test that pins objectui's task shapes - * (`$TURBO_ROOT$` inputs relative to the objectui tree, `//#` root tasks of its - * own manifest): `turbo.json` at `root`, else the parked `turbo.objectui.json`, - * else a throw. - * - * @param {string} [root] - * @returns {string} - */ -export function uiTurboConfigPath(root = uiRoot()) { - const own = path.resolve(root); - for (const name of [TURBO_CONFIG, PARKED_TURBO_CONFIG]) { - const file = path.join(own, name); - if (fs.existsSync(file)) return file; - } - throw new Error(`neither ${TURBO_CONFIG} nor ${PARKED_TURBO_CONFIG} at ${own} (objectstack#25723).`); -} - /** * A turbo config narrowed to one nested tree: every `$TURBO_ROOT$/x` * input becomes `$TURBO_ROOT$/x`, every other `$TURBO_ROOT$/...` input (the diff --git a/ui/turbo.objectui.json b/ui/turbo.objectui.json deleted file mode 100644 index 589f5fce5ac..00000000000 --- a/ui/turbo.objectui.json +++ /dev/null @@ -1,115 +0,0 @@ -{ - "$schema": "https://turbo.build/schema.json", - "globalEnv": ["NODE_ENV"], - "tasks": { - "build": { - "dependsOn": ["^build"], - "outputs": [ - "dist/**", - ".next/**", - "build/**", - "**/*.tsbuildinfo", - "!**/node_modules/**" - ], - "env": ["NODE_ENV", "VITE_BASE_PATH"], - "inputs": [ - "$TURBO_DEFAULT$", - "$TURBO_ROOT$/tsconfig.json", - "$TURBO_ROOT$/scripts/vite-*.ts", - "$TURBO_ROOT$/scripts/build-plugin-stylesheet.mjs", - "$TURBO_ROOT$/scripts/check-dist-completeness.mjs", - "$TURBO_ROOT$/scripts/invoked-as.mjs", - "$TURBO_ROOT$/scripts/vitest-invocation-guard.mjs" - ] - }, - "test": { - "dependsOn": ["^build"], - "outputs": ["coverage/**"], - "cache": true, - "inputs": [ - "src/**", - "test/**", - "tests/**", - "vitest.config.*", - "vitest.setup.*", - "tsconfig*.json", - "package.json", - "$TURBO_DEFAULT$", - "$TURBO_ROOT$/vitest.config.mts", - "$TURBO_ROOT$/vitest.setup.*", - "$TURBO_ROOT$/scripts/vitest-invocation-guard.mjs", - "$TURBO_ROOT$/scripts/test-shard-*", - "$TURBO_ROOT$/scripts/vite-*.ts", - "$TURBO_ROOT$/apps/console/vitest.config.ts", - "$TURBO_ROOT$/apps/console/vite.config.ts", - "!**/*.md", - "!**/CHANGELOG.md" - ] - }, - "test:dist": { - "dependsOn": ["build"], - "cache": false - }, - "test:watch": { - "cache": false, - "persistent": true - }, - "lint": { - "outputs": [], - "cache": true, - "dependsOn": ["//#lint:root"], - "inputs": [ - "$TURBO_DEFAULT$", - "$TURBO_ROOT$/eslint.config.js", - "$TURBO_ROOT$/eslint-rules/*.js" - ] - }, - "//#lint:root": { - "outputs": [], - "cache": false, - "inputs": [ - "**/*.js", - "**/*.cjs", - "**/*.mjs", - "**/*.ts", - "**/*.tsx", - "!packages/*/**", - "!examples/*/**", - "!apps/*/**", - "!docs/**", - "!**/node_modules/**" - ] - }, - "type-check": { - "dependsOn": ["^build", "//#type-check:e2e"], - "outputs": [], - "cache": true, - "inputs": [ - "$TURBO_DEFAULT$", - "$TURBO_ROOT$/scripts/vite-*.ts", - "$TURBO_ROOT$/vitest.config.mts", - "$TURBO_ROOT$/tsconfig.json", - "$TURBO_ROOT$/apps/console/src/lib/auth-preflight.ts", - "$TURBO_ROOT$/eslint-rules/no-unprefixed-query-params.js", - "$TURBO_ROOT$/packages/cli/src/commands/generate.ts", - "$TURBO_ROOT$/packages/cli/src/utils/authoring-face.ts", - "$TURBO_ROOT$/packages/cli/src/utils/issue-path.ts", - "$TURBO_ROOT$/packages/cli/src/utils/union-arm-diagnostics.ts", - "$TURBO_ROOT$/scripts/check-doc-component-types.mjs", - "$TURBO_ROOT$/scripts/markdown-fence-scan.mjs" - ] - }, - "//#type-check:e2e": { - "outputs": [], - "cache": false, - "inputs": ["e2e/**", "playwright*.config.ts", "tsconfig.e2e.json"] - }, - "clean": { - "cache": false - }, - "dev": { - "cache": false, - "persistent": true - } - } -} From e8915589a7b4356221155f76b3112eac2c2b2d70 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 02:18:53 +0000 Subject: [PATCH 5/6] ci: schedule core jobs on a diff confined to the ui tree's root build inputs The six ui-root files turbo.json's build task now hashes enter ci.yml's core: paths filter, as check-ci-filter-parity derives (ruling 6513291766). Claude-Session: https://claude.ai/code/session_01QSeKYog9FzTNeBAndKHXTT Co-authored-by: Claude --- .github/workflows/ci.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f5c338e0417..41c5a3755bc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -351,6 +351,16 @@ jobs: - 'scripts/ts-parse.mjs' - 'scripts/tsup-drop-sources-content.mjs' - 'scripts/workspace-enumerator.mjs' + # The ui tree's root build inputs (#27283): turbo.json's generic + # `build` task hashes them for every `@object-ui/*` build, and the + # same task runs for every package `pnpm build` builds, so a diff + # confined to one moves Build Core's hashes too. + - 'ui/tsconfig.json' + - 'ui/scripts/vite-*.ts' + - 'ui/scripts/build-plugin-stylesheet.mjs' + - 'ui/scripts/check-dist-completeness.mjs' + - 'ui/scripts/invoked-as.mjs' + - 'ui/scripts/vitest-invocation-guard.mjs' # BUILD CORE'S GUARDS (#21341): every repository script the # build-core job's own steps run (named by a `run:` step or # reached through its `pnpm check:*` alias) and each one's From d5f83e05ee70d2f396cca7980cc2c51dcf0d0eb6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 02:55:07 +0000 Subject: [PATCH 6/6] ci(lint): run ui/scripts/invoked-as.mjs's self-test beside its root twin ci.yml's core: filter now names the script, so check-self-test-wired requires CI to run its --self-test. Claude-Session: https://claude.ai/code/session_01QSeKYog9FzTNeBAndKHXTT Co-authored-by: Claude --- .github/workflows/lint.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index d616d33a552..996f62765f6 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -841,6 +841,7 @@ jobs: } run_self_test node scripts/ts-parse.mjs --self-test run_self_test node scripts/invoked-as.mjs --self-test + run_self_test node ui/scripts/invoked-as.mjs --self-test run_self_test node scripts/js-comment-mask.mjs --self-test run_self_test node scripts/import-prerequisite.mjs --self-test if [ -n "$failed" ]; then