diff --git a/turbo.json b/turbo.json index 53ebd237f70..7cf1f5dca96 100644 --- a/turbo.json +++ b/turbo.json @@ -21,8 +21,7 @@ "$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", - "$TURBO_ROOT$/ui/packages/mobile/src/serviceWorkerSource.ts" + "$TURBO_ROOT$/ui/scripts/vitest-invocation-guard.mjs" ] }, "test": { @@ -71,8 +70,7 @@ "$TURBO_ROOT$/ui/packages/cli/src/utils/issue-path.ts", "$TURBO_ROOT$/ui/packages/cli/src/utils/union-arm-diagnostics.ts", "$TURBO_ROOT$/ui/scripts/check-doc-component-types.mjs", - "$TURBO_ROOT$/ui/scripts/markdown-fence-scan.mjs", - "$TURBO_ROOT$/ui/packages/mobile/src/serviceWorkerSource.ts" + "$TURBO_ROOT$/ui/scripts/markdown-fence-scan.mjs" ] }, "lint": { diff --git a/ui/apps/console/turbo.json b/ui/apps/console/turbo.json index da0afc30fdc..fb7794c1b07 100644 --- a/ui/apps/console/turbo.json +++ b/ui/apps/console/turbo.json @@ -13,7 +13,11 @@ "!**/node_modules/**", "plugin.js", "plugin.d.ts" - ] + ], + "inputs": ["$TURBO_EXTENDS$", "$TURBO_ROOT$/ui/packages/mobile/src/serviceWorkerSource.ts"] + }, + "type-check": { + "inputs": ["$TURBO_EXTENDS$", "$TURBO_ROOT$/ui/packages/mobile/src/serviceWorkerSource.ts"] } } } diff --git a/ui/scripts/__tests__/helpers/turbo-inputs.ts b/ui/scripts/__tests__/helpers/turbo-inputs.ts index 2218714cb12..2e0ea073615 100644 --- a/ui/scripts/__tests__/helpers/turbo-inputs.ts +++ b/ui/scripts/__tests__/helpers/turbo-inputs.ts @@ -21,7 +21,12 @@ import fs from 'node:fs'; import path from 'node:path'; import { uiRoot } from '../../ui-root.mjs'; -import { uiScopedTurboConfig, workspaceGlobs } from '../../workspace-root.mjs'; +import { + narrowTurboConfig, + turboConfigPath, + uiScopedTurboConfig, + workspaceGlobs, +} from '../../workspace-root.mjs'; /** Repo root: the objectui tree, standalone or as the monorepo's `ui/`. */ export const repoRoot = uiRoot(); @@ -129,11 +134,109 @@ export function taskInputs(task: string, turbo: TurboTasks = liveTurboConfig()): * `$TURBO_ROOT$` exists at all. */ export function rootAnchoredInputs(task: string, turbo: TurboTasks = liveTurboConfig()): string[] { - return taskInputs(task, turbo) + return anchoredOnly(taskInputs(task, turbo)); +} + +function anchoredOnly(inputs: readonly string[]): string[] { + return inputs .filter((entry) => entry.startsWith('$TURBO_ROOT$/')) .map((entry) => entry.slice('$TURBO_ROOT$/'.length)); } +// ── A package's own turbo.json (objectstack#29361) ─────────────────────────── +// +// The root config is not the whole answer for a package that carries its own +// `turbo.json` (a turbo Package Configuration, `extends: ["//"]`). For each +// task it declares, turbo resolves that package's `inputs` like this: +// +// - no `inputs` key on the package's task: the root task's list, unchanged; +// - an `inputs` list holding `$TURBO_EXTENDS$`: the root task's list plus the +// package's other entries; +// - an `inputs` list WITHOUT it: the package's entries REPLACE the root's, +// `$TURBO_DEFAULT$` included. +// +// Measured with turbo 2.11.10 on `@object-ui/console#build` (`--dry=json`): +// 457 hashed files with `["$TURBO_EXTENDS$", ]`, the +// same 457 with the marker written last instead of first, and 3 with the +// marker dropped. A guard that read only the root list would wave a replacing +// list through and would red an extending one, so the guards read the list +// turbo resolves for the package they judge. + +/** Turbo's marker: "the root task's list, plus these". */ +export const TURBO_EXTENDS = '$TURBO_EXTENDS$'; + +/** + * A package's own `turbo.json`, narrowed to this tree like the root's + * (`$TURBO_ROOT$/ui/x` read as `$TURBO_ROOT$/x`), or `null` when it has none. + */ +export function packageTurboConfig(pkgDir: string): TurboTasks | null { + const file = path.join(pkgDir, 'turbo.json'); + if (!fs.existsSync(file)) return null; + const config = JSON.parse(fs.readFileSync(file, 'utf8')) as TurboTasks; + const prefix = path.relative(path.dirname(turboConfigPath(repoRoot)), repoRoot); + return narrowTurboConfig(config, prefix ? `${prefix.split(path.sep).join('/')}/` : ''); +} + +/** + * The `inputs` turbo hashes ONE package's task from: the root task's list, + * resolved through the package's own `turbo.json` as described above. + * + * A root `#` override for the package throws: turbo has a + * resolution rule for it too, and this reader does not implement it. Reading + * the generic task instead would judge the package against a list turbo does + * not hash, so teach this function first. + */ +export function packageTaskInputs( + task: string, + pkgDir: string, + turbo: TurboTasks = liveTurboConfig(), + own: TurboTasks | null = packageTurboConfig(pkgDir), +): string[] { + const manifest = path.join(pkgDir, 'package.json'); + const name = fs.existsSync(manifest) + ? (JSON.parse(fs.readFileSync(manifest, 'utf8')) as { name?: string }).name + : undefined; + if (name && turbo.tasks?.[`${name}#${task}`]) { + throw new Error( + `turbo.json declares \`${name}#${task}\`, and packageTaskInputs() does not resolve a root ` + + `# override. Teach it turbo's rule for that shape rather than reading the ` + + `generic \`${task}\` task in its place.`, + ); + } + const declared = own?.tasks?.[task]?.inputs; + if (declared === undefined) return taskInputs(task, turbo); + if (declared.includes(TURBO_EXTENDS)) { + return [...taskInputs(task, turbo), ...declared.filter((entry) => entry !== TURBO_EXTENDS)]; + } + if (declared.length === 0) { + throw new Error(`${rel(path.join(pkgDir, 'turbo.json'))} \`${task}\` declares an empty \`inputs\` list`); + } + return declared; +} + +/** `packageTaskInputs`, `$TURBO_ROOT$`-anchored entries only, as repo-relative globs. */ +export function packageRootAnchoredInputs( + task: string, + pkgDir: string, + turbo: TurboTasks = liveTurboConfig(), + own: TurboTasks | null = packageTurboConfig(pkgDir), +): string[] { + return anchoredOnly(packageTaskInputs(task, pkgDir, turbo, own)); +} + +/** + * The `$TURBO_ROOT$` entries a package's OWN `turbo.json` writes for a task, + * as repo-relative globs: the population the phantom-input and earned-input + * assertions hold to that package's program, beside the root task's list. + */ +export function packageOwnRootAnchoredInputs( + task: string, + pkgDir: string, + own: TurboTasks | null = packageTurboConfig(pkgDir), +): string[] { + return anchoredOnly(own?.tasks?.[task]?.inputs ?? []); +} + /** * A turbo input glob as a regular expression. * diff --git a/ui/scripts/__tests__/turbo-build-inputs.test.ts b/ui/scripts/__tests__/turbo-build-inputs.test.ts index f571f4c81ab..2af7eef5cee 100644 --- a/ui/scripts/__tests__/turbo-build-inputs.test.ts +++ b/ui/scripts/__tests__/turbo-build-inputs.test.ts @@ -5,6 +5,9 @@ import { globToRegExp, packagesWithScript, rel, + packageOwnRootAnchoredInputs, + packageRootAnchoredInputs, + packageTaskInputs, repoFilesOnDisk, rootAnchoredInputs, taskInputs, @@ -79,7 +82,14 @@ const INPUTS_NOT_DERIVABLE: ReadonlyMap = new Map(); // ── The derivation, computed once ─────────────────────────────────────────── const PACKAGES = packagesWithScript(TASK); -const DERIVED = PACKAGES.map((pkg) => ({ ...pkg, program: buildProgramFor(pkg) })); +const DERIVED = PACKAGES.map((pkg) => ({ + ...pkg, + program: buildProgramFor(pkg), + // What turbo hashes THIS package's task from: the root task's list, resolved + // through the package's own turbo.json when it has one (objectstack#29361). + inputs: packageRootAnchoredInputs(TASK, pkg.dir), + ownInputs: packageOwnRootAnchoredInputs(TASK, pkg.dir), +})); const ROOT_INPUTS = rootAnchoredInputs(TASK); const MATCHERS = ROOT_INPUTS.map((glob) => ({ glob, re: globToRegExp(glob) })); @@ -105,17 +115,17 @@ describe('turbo `build` inputs cover every out-of-package file (objectui#4185)', (pkg) => [pkg.name, pkg] as const, ), )('%s', (_name, pkg) => { - const uncovered = pkg.program.outside.filter( - (file) => !MATCHERS.some(({ re }) => re.test(file)), - ); + const matchers = pkg.inputs.map((glob) => globToRegExp(glob)); + const uncovered = pkg.program.outside.filter((file) => !matchers.some((re) => re.test(file))); expect( uncovered, `${pkg.name}'s build program reads ${uncovered.join(', ')} from outside ${rel(pkg.dir)}, ` + - `and turbo hashes ${TASK} from ${JSON.stringify(ROOT_INPUTS)} plus the package ` + + `and turbo hashes its ${TASK} from ${JSON.stringify(pkg.inputs)} plus the package ` + `directory. Turbo will replay a stale verdict — and hand the stale dist/ to every task ` + `that depends on it — when ${uncovered.length === 1 ? 'that file changes' : 'those files change'}. ` + `Add ${uncovered.map((file) => `"$TURBO_ROOT$/${file}"`).join(', ')} to turbo.json's ` + - `\`${TASK}\` inputs.`, + `\`${TASK}\` inputs, or to the package's own turbo.json \`${TASK}\` inputs beside ` + + `"$TURBO_EXTENDS$" when only this package reads it.`, ).toEqual([]); }); @@ -133,6 +143,16 @@ describe('turbo `build` inputs cover every out-of-package file (objectui#4185)', `hashing nothing while reading as coverage — fix the glob or delete the entry.`, ).toBe(true); } + for (const pkg of DERIVED) { + for (const glob of pkg.ownInputs) { + const re = globToRegExp(glob); + expect( + [...onDisk].some((file) => re.test(file)), + `${rel(pkg.dir)}/turbo.json \`${TASK}\` input "$TURBO_ROOT$/${glob}" matches no file in ` + + `the repo. It is hashing nothing while reading as coverage — fix the glob or delete the entry.`, + ).toBe(true); + } + } }); /** @@ -151,6 +171,18 @@ describe('turbo `build` inputs cover every out-of-package file (objectui#4185)', `INPUTS_NOT_DERIVABLE.`, ).not.toHaveLength(0); } + // A package's own entry is earned by THAT package's program: it hashes + // into no other package's task, so no other program can earn it. + for (const pkg of DERIVED) { + for (const glob of pkg.ownInputs) { + const re = globToRegExp(glob); + expect( + pkg.program.outside.filter((file) => re.test(file)), + `${rel(pkg.dir)}/turbo.json \`${TASK}\` input "$TURBO_ROOT$/${glob}" is not required by ` + + `${pkg.name}'s build program any more. Delete it.`, + ).not.toHaveLength(0); + } + } }); /** @@ -169,6 +201,24 @@ describe('turbo `build` inputs cover every out-of-package file (objectui#4185)', ).toContain('$TURBO_DEFAULT$'); }); + /** + * The same worry, one level down: a package's own turbo.json whose `build` + * inputs leave out "$TURBO_EXTENDS$" REPLACES the root list, and with it + * "$TURBO_DEFAULT$" (objectstack#29361 measured `@object-ui/console#build` + * fall from 457 hashed files to 3). + */ + it.each(PACKAGES.map((pkg) => [pkg.name, pkg] as const))( + "%s's resolved build inputs still carry $TURBO_DEFAULT$", + (_name, pkg) => { + expect( + packageTaskInputs(TASK, pkg.dir), + `${rel(pkg.dir)}/turbo.json replaces turbo.json's \`${TASK}\` inputs without ` + + `"$TURBO_DEFAULT$", so the package's own source stops being hashed. Add ` + + `"$TURBO_EXTENDS$" to its list.`, + ).toContain('$TURBO_DEFAULT$'); + }, + ); + /** * A `pnpm --filter build` segment inside a `prebuild` is a task-GRAPH * fact, not an inputs fact: the right answer to "this build needs that diff --git a/ui/scripts/__tests__/turbo-inputs-package-config.test.ts b/ui/scripts/__tests__/turbo-inputs-package-config.test.ts new file mode 100644 index 00000000000..26072885667 --- /dev/null +++ b/ui/scripts/__tests__/turbo-inputs-package-config.test.ts @@ -0,0 +1,128 @@ +import { afterAll, describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + TURBO_EXTENDS, + packageOwnRootAnchoredInputs, + packageRootAnchoredInputs, + packageTaskInputs, + repoRoot, + type TurboTasks, +} from './helpers/turbo-inputs'; + +/** + * objectstack#29361 — the turbo-input guards read the inputs turbo resolves + * for the package a row judges, not the root task's list alone. These pin the + * reader against fixtures for each of turbo's three resolutions, and against + * the live tree for the one package that carries its own turbo.json today. + */ + +const ROOT: TurboTasks = { + tasks: { + build: { inputs: ['$TURBO_DEFAULT$', '$TURBO_ROOT$/scripts/shared.ts'] }, + 'type-check': { inputs: ['$TURBO_DEFAULT$', '$TURBO_ROOT$/tsconfig.json'] }, + }, +}; +const OWN_ONLY = '$TURBO_ROOT$/packages/other/src/only-here.ts'; + +const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'turbo-inputs-package-config-')); +afterAll(() => fs.rmSync(scratch, { recursive: true, force: true })); + +/** A scratch package directory holding a `package.json` with this name. */ +function scratchPackage(name: string): string { + const dir = fs.mkdtempSync(path.join(scratch, 'pkg-')); + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name })); + return dir; +} + +describe('packageTaskInputs: turbo resolves a package task through its own turbo.json', () => { + it('a package with no turbo.json reads the root task list unchanged', () => { + const dir = scratchPackage('@fixture/plain'); + expect(packageTaskInputs('build', dir, ROOT, null)).toEqual(ROOT.tasks!.build.inputs); + }); + + it('a package task that declares no inputs inherits the root task list', () => { + const dir = scratchPackage('@fixture/outputs-only'); + const own: TurboTasks = { tasks: { build: {} } }; + expect(packageTaskInputs('build', dir, ROOT, own)).toEqual(ROOT.tasks!.build.inputs); + }); + + it('an input list holding $TURBO_EXTENDS$ is the root list plus the package entries', () => { + const dir = scratchPackage('@fixture/extends'); + const own: TurboTasks = { tasks: { build: { inputs: [TURBO_EXTENDS, OWN_ONLY] } } }; + expect(packageTaskInputs('build', dir, ROOT, own)).toEqual([ + '$TURBO_DEFAULT$', + '$TURBO_ROOT$/scripts/shared.ts', + OWN_ONLY, + ]); + expect(packageRootAnchoredInputs('build', dir, ROOT, own)).toEqual([ + 'scripts/shared.ts', + 'packages/other/src/only-here.ts', + ]); + expect(packageOwnRootAnchoredInputs('build', dir, own)).toEqual(['packages/other/src/only-here.ts']); + }); + + it('the marker extends wherever it sits in the list, as turbo reads it', () => { + const dir = scratchPackage('@fixture/extends-last'); + const own: TurboTasks = { tasks: { build: { inputs: [OWN_ONLY, TURBO_EXTENDS] } } }; + expect(packageRootAnchoredInputs('build', dir, ROOT, own)).toEqual([ + 'scripts/shared.ts', + 'packages/other/src/only-here.ts', + ]); + }); + + it('an input list without the marker replaces the root list: the root entries stop counting', () => { + const dir = scratchPackage('@fixture/replaces'); + const own: TurboTasks = { tasks: { build: { inputs: [OWN_ONLY] } } }; + expect(packageTaskInputs('build', dir, ROOT, own)).toEqual([OWN_ONLY]); + expect(packageRootAnchoredInputs('build', dir, ROOT, own)).not.toContain('scripts/shared.ts'); + }); + + it("one package's turbo.json leaves another package's row and its other tasks alone", () => { + const own: TurboTasks = { tasks: { build: { inputs: [TURBO_EXTENDS, OWN_ONLY] } } }; + const withOwn = scratchPackage('@fixture/with-own'); + const other = scratchPackage('@fixture/other'); + expect(packageRootAnchoredInputs('build', other, ROOT, null)).toEqual(['scripts/shared.ts']); + expect(packageRootAnchoredInputs('type-check', withOwn, ROOT, own)).toEqual(['tsconfig.json']); + }); + + it('an empty replacing list throws instead of reading as a list that covers nothing', () => { + const dir = scratchPackage('@fixture/empty'); + const own: TurboTasks = { tasks: { build: { inputs: [] } } }; + expect(() => packageTaskInputs('build', dir, ROOT, own)).toThrow(/empty `inputs` list/); + }); + + it('a root # override throws: the reader does not resolve that shape', () => { + const dir = scratchPackage('@fixture/overridden'); + const root: TurboTasks = { + tasks: { ...ROOT.tasks, '@fixture/overridden#build': { inputs: ['$TURBO_DEFAULT$'] } }, + }; + expect(() => packageTaskInputs('build', dir, root, null)).toThrow(/does not resolve a root/); + }); +}); + +describe('the live tree: the Service Worker generator hashes into the Console tasks only', () => { + const GENERATOR = 'packages/mobile/src/serviceWorkerSource.ts'; + const consoleDir = path.join(repoRoot, 'apps/console'); + const coreDir = path.join(repoRoot, 'packages/core'); + + it.each(['build', 'type-check'])( + "@object-ui/console's %s reads the root list plus the generator, narrowed to this tree", + (task) => { + const resolved = packageRootAnchoredInputs(task, consoleDir); + expect(resolved).toContain(GENERATOR); + expect(resolved).toContain('scripts/vite-*.ts'); + expect(packageTaskInputs(task, consoleDir)).toContain('$TURBO_DEFAULT$'); + }, + ); + + it.each(['build', 'type-check'])("another package's %s does not hash the generator", (task) => { + expect(packageRootAnchoredInputs(task, coreDir)).not.toContain(GENERATOR); + }); + + it("every package's test still hashes it: the root ui vitest config loads the Console's vite.config.ts", () => { + expect(packageRootAnchoredInputs('test', coreDir)).toContain(GENERATOR); + expect(packageRootAnchoredInputs('test', consoleDir)).toContain(GENERATOR); + }); +}); diff --git a/ui/scripts/__tests__/turbo-type-check-inputs.test.ts b/ui/scripts/__tests__/turbo-type-check-inputs.test.ts index 17e511cf7d1..0a1a552e0f3 100644 --- a/ui/scripts/__tests__/turbo-type-check-inputs.test.ts +++ b/ui/scripts/__tests__/turbo-type-check-inputs.test.ts @@ -5,6 +5,8 @@ import { rel, repoFilesOnDisk, liveTurboConfig, + packageOwnRootAnchoredInputs, + packageRootAnchoredInputs, rootAnchoredInputs, } from './helpers/turbo-inputs'; import { outOfPackageFiles } from './helpers/tsc-program'; @@ -131,7 +133,14 @@ const INPUTS_NOT_DERIVABLE: ReadonlyMap = new Map([ // ── The derivation, computed once ──────────────────────────────────────────── const PACKAGES = packagesWithScript(TASK); -const DERIVED = PACKAGES.map((pkg) => ({ ...pkg, outside: outOfPackageFiles(pkg.dir, pkg.script) })); +const DERIVED = PACKAGES.map((pkg) => ({ + ...pkg, + outside: outOfPackageFiles(pkg.dir, pkg.script), + // What turbo hashes THIS package's task from: the root task's list, resolved + // through the package's own turbo.json when it has one (objectstack#29361). + inputs: packageRootAnchoredInputs(TASK, pkg.dir), + ownInputs: packageOwnRootAnchoredInputs(TASK, pkg.dir), +})); const ROOT_INPUTS = rootAnchoredInputs(TASK, liveTurboConfig()); const MATCHERS = ROOT_INPUTS.map((glob) => ({ glob, re: globToRegExp(glob) })); @@ -155,14 +164,16 @@ describe('turbo `type-check` inputs cover every out-of-package file (objectui#35 it.each(DERIVED.filter((pkg) => pkg.outside.length > 0).map((pkg) => [pkg.name, pkg] as const))( '%s', (_name, pkg) => { - const uncovered = pkg.outside.filter((file) => !MATCHERS.some(({ re }) => re.test(file))); + const matchers = pkg.inputs.map((glob) => globToRegExp(glob)); + const uncovered = pkg.outside.filter((file) => !matchers.some((re) => re.test(file))); expect( uncovered, `${pkg.name}'s type-check program reads ${uncovered.join(', ')} from outside ` + - `${rel(pkg.dir)}, and turbo hashes ${TASK} from ${JSON.stringify(ROOT_INPUTS)} plus the ` + + `${rel(pkg.dir)}, and turbo hashes its ${TASK} from ${JSON.stringify(pkg.inputs)} plus the ` + `package directory. Turbo will replay a stale verdict when ${uncovered.length === 1 ? 'that file changes' : 'those files change'}. ` + `Add ${uncovered.map((file) => `"$TURBO_ROOT$/${file}"`).join(', ')} to turbo.json's ` + - `\`${TASK}\` inputs.`, + `\`${TASK}\` inputs, or to the package's own turbo.json \`${TASK}\` inputs beside ` + + `"$TURBO_EXTENDS$" when only this package reads it.`, ).toEqual([]); }, ); @@ -182,6 +193,16 @@ describe('turbo `type-check` inputs cover every out-of-package file (objectui#35 `hashing nothing while reading as coverage — fix the glob or delete the entry.`, ).toBe(true); } + for (const pkg of DERIVED) { + for (const glob of pkg.ownInputs) { + const re = globToRegExp(glob); + expect( + [...onDisk].some((file) => re.test(file)), + `${rel(pkg.dir)}/turbo.json \`${TASK}\` input "$TURBO_ROOT$/${glob}" matches no file in ` + + `the repo. It is hashing nothing while reading as coverage — fix the glob or delete the entry.`, + ).toBe(true); + } + } }); /** @@ -200,5 +221,17 @@ describe('turbo `type-check` inputs cover every out-of-package file (objectui#35 `INPUTS_NOT_DERIVABLE.`, ).not.toHaveLength(0); } + // A package's own entry is earned by THAT package's program: it hashes + // into no other package's task, so no other program can earn it. + for (const pkg of DERIVED) { + for (const glob of pkg.ownInputs) { + const re = globToRegExp(glob); + expect( + pkg.outside.filter((file) => re.test(file)), + `${rel(pkg.dir)}/turbo.json \`${TASK}\` input "$TURBO_ROOT$/${glob}" is not required by ` + + `${pkg.name}'s type-check program any more. Delete it.`, + ).not.toHaveLength(0); + } + } }); });