Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions turbo.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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": {
Expand Down
6 changes: 5 additions & 1 deletion ui/apps/console/turbo.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
}
}
}
107 changes: 105 additions & 2 deletions ui/scripts/__tests__/helpers/turbo-inputs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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$", <one $TURBO_ROOT$ file>]`, 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 `<package>#<task>` 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 ` +
`<package>#<task> 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.
*
Expand Down
62 changes: 56 additions & 6 deletions ui/scripts/__tests__/turbo-build-inputs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import {
globToRegExp,
packagesWithScript,
rel,
packageOwnRootAnchoredInputs,
packageRootAnchoredInputs,
packageTaskInputs,
repoFilesOnDisk,
rootAnchoredInputs,
taskInputs,
Expand Down Expand Up @@ -79,7 +82,14 @@ const INPUTS_NOT_DERIVABLE: ReadonlyMap<string, string> = 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) }));

Expand All @@ -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([]);
});

Expand All @@ -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);
}
}
});

/**
Expand All @@ -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);
}
}
});

/**
Expand All @@ -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 <pkg> build` segment inside a `prebuild` is a task-GRAPH
* fact, not an inputs fact: the right answer to "this build needs that
Expand Down
128 changes: 128 additions & 0 deletions ui/scripts/__tests__/turbo-inputs-package-config.test.ts
Original file line number Diff line number Diff line change
@@ -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 <package>#<task> 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);
});
});
Loading
Loading