From 6c1af7d27a91c6af90b6df4437a91c081293c75e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 19:02:31 +0000 Subject: [PATCH] ci(turbo): derive the test inputs guard from each package's Vitest config program (#4178) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit turbo's `test` task hashed nothing outside the package directory, so every root file that decides how a package's tests run — the shared `vitest.config.mts`, the four `vitest.setup.*`, the invocation guard the root config calls, and the console project config it pulls in — was invisible to the cache key. Measured on main: `@object-ui/core#test` frozen at 2e2087e2c30ef125 across all of them. Adds the six derived `$TURBO_ROOT$` entries, plus a guard that derives the requirement from each package's Vitest configuration program (config resolution incl. Vitest's upward search, transitive relative imports, and files designated through file-valued options) rather than restating it. The turbo-side plumbing shared with the #3514 guard moves to scripts/__tests__/helpers/turbo-inputs.ts so the two cannot drift. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3 --- scripts/__tests__/helpers/turbo-inputs.ts | 193 ++++++++++++ .../helpers/vitest-config-program.ts | 298 ++++++++++++++++++ scripts/__tests__/turbo-test-inputs.test.ts | 206 ++++++++++++ .../__tests__/turbo-type-check-inputs.test.ts | 174 ++-------- turbo.json | 6 + 5 files changed, 722 insertions(+), 155 deletions(-) create mode 100644 scripts/__tests__/helpers/turbo-inputs.ts create mode 100644 scripts/__tests__/helpers/vitest-config-program.ts create mode 100644 scripts/__tests__/turbo-test-inputs.test.ts diff --git a/scripts/__tests__/helpers/turbo-inputs.ts b/scripts/__tests__/helpers/turbo-inputs.ts new file mode 100644 index 000000000..f4f69f283 --- /dev/null +++ b/scripts/__tests__/helpers/turbo-inputs.ts @@ -0,0 +1,193 @@ +/** + * Shared plumbing for the `turbo.json` `inputs` guards. + * + * There are two of them now, and they guard the same structural defect on two + * different tasks: + * + * - `turbo-type-check-inputs.test.ts` (objectui#3514) derives each package's + * **tsc program** and asserts `type-check`'s inputs cover it. + * - `turbo-test-inputs.test.ts` (objectui#4178) derives each package's + * **Vitest configuration program** and asserts `test`'s inputs cover it. + * + * The derivations are different in kind and deliberately live apart. What is + * NOT different is everything around them: which directories are workspace + * packages, how a `$TURBO_ROOT$` entry is read out of `turbo.json`, and how a + * turbo input glob is matched against a path. Those three are subtle enough + * that a second hand-written copy would drift from the first — and a guard that + * drifts toward *matching more* silently waves through the very files it exists + * to require. One implementation, used by both. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** Repo root: this file sits at `scripts/__tests__/helpers/`. */ +export const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); + +const turboConfigPath = path.join(repoRoot, 'turbo.json'); +const workspaceConfigPath = path.join(repoRoot, 'pnpm-workspace.yaml'); + +/** 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('/'); +} + +/** + * Package directories, derived from `pnpm-workspace.yaml` rather than a + * hardcoded group list. + * + * Only the two entry shapes the file actually uses are understood (`dir/*` and + * a bare `dir`); anything else throws. A workspace layout a guard silently + * failed to walk would quietly shrink its own coverage back to nothing, which + * is the failure mode both guards exist to prevent. + */ +export function workspacePackageDirs(): string[] { + const yaml = fs.readFileSync(workspaceConfigPath, 'utf8'); + const patterns: string[] = []; + let inPackages = false; + for (const line of yaml.split('\n')) { + if (/^packages:\s*$/.test(line)) { + inPackages = true; + continue; + } + if (inPackages) { + const item = line.match(/^\s+-\s*['"]?([^'"#]+?)['"]?\s*$/); + if (item) { + patterns.push(item[1]); + continue; + } + if (line.trim() !== '') break; + } + } + if (patterns.length === 0) { + throw new Error('pnpm-workspace.yaml must declare at least one package pattern'); + } + + const dirs: string[] = []; + for (const pattern of patterns) { + if (pattern.endsWith('/*')) { + const group = path.join(repoRoot, pattern.slice(0, -2)); + if (!fs.existsSync(group)) continue; + for (const entry of fs.readdirSync(group, { withFileTypes: true })) { + const dir = path.join(group, entry.name); + if (entry.isDirectory() && fs.existsSync(path.join(dir, 'package.json'))) dirs.push(dir); + } + continue; + } + if (!pattern.includes('*')) { + const dir = path.join(repoRoot, pattern); + if (fs.existsSync(path.join(dir, 'package.json'))) dirs.push(dir); + continue; + } + throw new Error( + `pnpm-workspace.yaml pattern ${JSON.stringify(pattern)} is a glob shape this guard does ` + + `not understand. Teach workspacePackageDirs() about it — do not let the sweep skip it.`, + ); + } + return dirs.sort(); +} + +/** A workspace package that declares the named script, i.e. one turbo's task runs for. */ +export interface WorkspacePackage { + readonly name: string; + readonly dir: string; + readonly script: string; +} + +/** Every workspace package whose `package.json` declares the given script. */ +export function packagesWithScript(script: string): WorkspacePackage[] { + const out: WorkspacePackage[] = []; + for (const dir of workspacePackageDirs()) { + const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')) as { + name?: string; + scripts?: Record; + }; + const declared = manifest.scripts?.[script]; + if (declared) out.push({ name: manifest.name ?? rel(dir), dir, script: declared }); + } + return out; +} + +/** + * The `$TURBO_ROOT$`-anchored `inputs` of a task, as repo-relative globs. + * + * Everything else in the list is package-relative and therefore cannot reach + * outside the package directory — turbo has no `..` escape, which is why + * `$TURBO_ROOT$` exists at all. + */ +export function rootAnchoredInputs(task: string): string[] { + const turbo = JSON.parse(fs.readFileSync(turboConfigPath, 'utf8')) as { + tasks?: Record; + }; + const definition = turbo.tasks?.[task]; + if (!definition) throw new Error(`turbo.json must define a \`${task}\` task`); + const inputs = definition.inputs ?? []; + if (inputs.length === 0) throw new Error(`turbo.json \`${task}\` must declare \`inputs\``); + + return inputs + .filter((entry) => entry.startsWith('$TURBO_ROOT$/')) + .map((entry) => entry.slice('$TURBO_ROOT$/'.length)); +} + +/** + * A turbo input glob as a regular expression. + * + * Deliberately narrow — `**`, `*` and `?` only. Any richer syntax (brace + * alternation, character classes, `!` negation) throws instead of being + * approximated, because an approximated match that comes out TRUE is a file + * the guards would wave through while turbo does not hash it. Wrong-and-red is + * recoverable; wrong-and-green is the bug. + */ +export function globToRegExp(glob: string): RegExp { + if (/[!{}[\]()+@]/.test(glob)) { + throw new Error( + `turbo input ${JSON.stringify(glob)} uses glob syntax this guard does not implement. ` + + `Teach globToRegExp() about it rather than letting it guess.`, + ); + } + let pattern = ''; + for (let i = 0; i < glob.length; i += 1) { + const char = glob[i]; + if (char === '*') { + if (glob[i + 1] === '*') { + // `**/` spans zero or more directories; a trailing `**` spans the rest. + if (glob[i + 2] === '/') { + pattern += '(?:[^/]+/)*'; + i += 2; + } else { + pattern += '.*'; + i += 1; + } + } else { + pattern += '[^/]*'; + } + continue; + } + if (char === '?') { + pattern += '[^/]'; + continue; + } + pattern += char.replace(/[.^$|\\]/g, '\\$&'); + } + return new RegExp(`^${pattern}$`); +} + +/** + * Every tracked-ish file in the repo, repo-relative — dotted directories and + * `node_modules` skipped. Used by the phantom-input assertion both guards + * carry: a `$TURBO_ROOT$` entry matching nothing on disk reads as coverage + * while hashing nothing. + */ +export function repoFilesOnDisk(): Set { + const files = new Set(); + const walk = (dir: string): void => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue; + const abs = path.join(dir, entry.name); + if (entry.isDirectory()) walk(abs); + else files.add(rel(abs)); + } + }; + walk(repoRoot); + return files; +} diff --git a/scripts/__tests__/helpers/vitest-config-program.ts b/scripts/__tests__/helpers/vitest-config-program.ts new file mode 100644 index 000000000..baa518efd --- /dev/null +++ b/scripts/__tests__/helpers/vitest-config-program.ts @@ -0,0 +1,298 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import ts from 'typescript'; +import { rel, repoRoot, type WorkspacePackage } from './turbo-inputs'; + +/** + * Derives a workspace package's **Vitest configuration program** — the set of + * files Vitest reads in order to know how that package's tests run. + * + * Extracted as its own module rather than folded into the guard that consumes + * it (`../turbo-test-inputs.test.ts`, objectui#4178) for two reasons. It is a + * mechanism, not a policy: the guard decides what must be true of the result, + * this file decides only what the result IS. And a derivation that can only be + * exercised through the assertions it feeds is a derivation nobody can probe — + * which is how a sweep quietly degrades to returning nothing while every + * assertion over it stays green. + * + * ## The program + * + * 1. The config file Vitest resolves for the package's `test` script, + * following the script's own `--root` / `--config` flags and then Vitest's + * upward search. + * 2. Every file that program statically imports by RELATIVE specifier, + * transitively. + * 3. Every file it DESIGNATES through a file-valued Vitest option + * (`setupFiles`, `globalSetup`, `projects`, `workspace`) — themselves + * walked as program files, which is how `vitest.setup.tsx` pulls in the + * `vitest.setup.dom.tsx` it imports, and how the root config's `projects` + * entry pulls in `apps/console/vitest.config.ts` and the two + * `scripts/vite-*.ts` plugins that one imports. + * + * ## Narrowings, stated so they are visible rather than assumed + * + * - THE CONFIGURATION PROGRAM, not the module closure of the tests. Vitest + * resolves `@object-ui/*` through the root config's alias table straight to + * other packages' `src/`, so the true read set of any package's test run is + * most of the repository. Requiring that is not a stricter version of this + * derivation, it is a different (and wrong) one — turbo's + * `dependsOn: ["^build"]` and per-package `$TURBO_DEFAULT$` already answer + * source. The failure being guarded is "change the SHARED TEST HARNESS, get + * a stale verdict", and the harness is exactly this program. + * - DESIGNATION IS KEY-DIRECTED, not every string in the file. A literal + * counts as a designated path only inside one of the file-valued options + * above. The root config also holds ~45 concrete test-file paths in + * `domTsTests` / `heavyDomTests`; those are `include` / `exclude` inputs to + * project definitions, covered by their own packages' inputs — not files + * this program reads. A literal under a designating key that LOOKS like a + * concrete source path and does not resolve throws rather than being + * skipped, so a renamed setup file cannot quietly leave the program. + * - BARE SPECIFIERS ARE NOT FOLLOWED. `vitest.setup.dom.tsx` imports + * `@object-ui/components` for its registration side effects; that is the + * alias closure of the first point, not a config file. + * + * Every narrowing errs the same way: toward requiring MORE files, and toward + * throwing on a shape not understood. An approximation that comes out + * "covered" is a file waved through while turbo does not hash it — + * wrong-and-red is recoverable, wrong-and-green is the bug. + */ + +// ── Which config file Vitest actually loads ───────────────────────────────── + +/** + * Vitest's config candidates, in its own precedence order. + * + * Mirrors `CONFIG_NAMES` x `CONFIG_EXTENSIONS` from `vitest/dist/chunks/ + * constants.*.js`. The order is load-bearing: `packages/components` has BOTH a + * `vitest.config.ts` and a `vite.config.ts`, and only the first is the config + * Vitest reads (the second arrives as one of its imports, which is a different + * fact). + */ +export const CONFIG_NAMES = ['vitest.config', 'vite.config']; +export const CONFIG_EXTENSIONS = ['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs']; +export const CONFIG_FILES = CONFIG_NAMES.flatMap((name) => + CONFIG_EXTENSIONS.map((extension) => name + extension), +); + +/** Extensions tried, in order, when a relative specifier carries none. */ +export const RESOLVE_EXTENSIONS = ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs']; + +/** Vitest options whose value names FILES rather than globs or data. */ +export const FILE_VALUED_OPTIONS = new Set(['setupFiles', 'globalSetup', 'projects', 'workspace']); + +export interface VitestInvocation { + /** The directory Vitest treats as its root (`--root`, else the package dir). */ + readonly root: string; + /** An explicit `--config`, already resolved; `null` means "search". */ + readonly config: string | null; +} + +/** + * The Vitest invocation a package's `test` script performs. + * + * Every shape in the repo today is a single `vitest run [flags] [filters]`. + * A script that runs a test command this parser cannot read throws rather than + * being skipped: an unparsed script is an unswept program, and this guard's + * whole value is that it cannot quietly sweep nothing. + */ +export function invocationFor(pkgDir: string, script: string): VitestInvocation { + const commands = script + .split('&&') + .map((segment) => segment.trim()) + .filter((command) => /(?:^|\s)vitest(?:\s|$)/.test(command)); + + if (commands.length !== 1) { + throw new Error( + `${rel(pkgDir)}: \`${script}\` does not run exactly one \`vitest\` command ` + + `(found ${commands.length}). Teach invocationFor() how to read it — do not let the ` + + `sweep skip this package.`, + ); + } + + const tokens = commands[0].split(/\s+/); + let root: string | null = null; + let config: string | null = null; + for (let i = 0; i < tokens.length; i += 1) { + const token = tokens[i]; + const [flag, inlineValue] = token.includes('=') + ? [token.slice(0, token.indexOf('=')), token.slice(token.indexOf('=') + 1)] + : [token, null]; + if (flag === '--root' || flag === '-r') root = inlineValue ?? tokens[++i]; + else if (flag === '--config' || flag === '-c') config = inlineValue ?? tokens[++i]; + } + + const resolvedRoot = path.resolve(pkgDir, root ?? '.'); + return { root: resolvedRoot, config: config === null ? null : path.resolve(resolvedRoot, config) }; +} + +/** + * The config file Vitest loads for an invocation. + * + * Reproduces `any(configFiles, { cwd: root })` — Vitest walks UP from its root, + * and in each directory takes the first name in `CONFIG_FILES` order that + * exists. This is why `packages/app-shell`, which has no config of its own, + * still runs under the repo-root `vitest.config.mts`; verified against the real + * binary, which refuses that invocation with the root config's own guard + * message rather than running configless. + * + * The walk stops at the repo root: a config above it is not this repo's. + */ +export function resolveConfigFile(invocation: VitestInvocation): string | null { + if (invocation.config !== null) { + if (!fs.existsSync(invocation.config)) { + throw new Error(`--config names ${rel(invocation.config)}, which does not exist.`); + } + return invocation.config; + } + let dir = invocation.root; + for (;;) { + for (const name of CONFIG_FILES) { + const candidate = path.join(dir, name); + if (fs.existsSync(candidate)) return candidate; + } + if (dir === repoRoot) return null; + const parent = path.dirname(dir); + if (parent === dir) return null; + dir = parent; + } +} + +// ── Walking the configuration program ─────────────────────────────────────── + +/** Resolve a relative specifier to a file on disk, trying Vitest's extensions. */ +function resolveRelative(fromFile: string, specifier: string): string | null { + const base = path.resolve(path.dirname(fromFile), specifier); + const candidates = [ + base, + ...RESOLVE_EXTENSIONS.map((extension) => base + extension), + ...RESOLVE_EXTENSIONS.map((extension) => path.join(base, `index${extension}`)), + ]; + for (const candidate of candidates) { + if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) return candidate; + } + return null; +} + +/** Does this literal look like a concrete source path rather than a glob or a name? */ +function looksLikeSourcePath(literal: string): boolean { + if (/[*?{}[\]]/.test(literal)) return false; + return RESOLVE_EXTENSIONS.some((extension) => literal.endsWith(extension)); +} + +/** + * Every relative module specifier a source file imports, and every path it + * designates through a file-valued Vitest option. + * + * Parsed with TypeScript's own parser rather than regexes — `.mts`, `.tsx` and + * plain `.mjs` all land here, and a specifier inside a comment or a string must + * not count. + */ +export function programEdges(file: string): { imports: string[]; designated: string[] } { + const source = ts.createSourceFile( + file, + fs.readFileSync(file, 'utf8'), + ts.ScriptTarget.Latest, + /* setParentNodes */ true, + file.endsWith('x') ? ts.ScriptKind.TSX : undefined, + ); + + const imports: string[] = []; + const designated: string[] = []; + + const literalValue = (node: ts.Node): string | null => + ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node) ? node.text : null; + + /** Collect every literal under a file-valued option's value. */ + const collectDesignated = (node: ts.Node): void => { + const value = literalValue(node); + if (value !== null) { + designated.push(value); + return; + } + node.forEachChild(collectDesignated); + }; + + const visit = (node: ts.Node): void => { + if ( + (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && + node.moduleSpecifier !== undefined + ) { + const value = literalValue(node.moduleSpecifier); + if (value !== null) imports.push(value); + } else if ( + ts.isCallExpression(node) && + (node.expression.kind === ts.SyntaxKind.ImportKeyword || + (ts.isIdentifier(node.expression) && node.expression.text === 'require')) && + node.arguments.length > 0 + ) { + const value = literalValue(node.arguments[0]); + if (value !== null) imports.push(value); + } else if ( + ts.isPropertyAssignment(node) && + (ts.isIdentifier(node.name) || ts.isStringLiteral(node.name)) && + FILE_VALUED_OPTIONS.has(node.name.text) + ) { + collectDesignated(node.initializer); + } + node.forEachChild(visit); + }; + visit(source); + + return { imports, designated }; +} + +/** + * Every file a package's Vitest configuration program reads from outside the + * package directory, repo-relative and sorted. + */ +export function outOfPackageFiles(pkg: WorkspacePackage): string[] { + const entry = resolveConfigFile(invocationFor(pkg.dir, pkg.script)); + if (entry === null) return []; + + const found = new Set(); + const seen = new Set(); + const queue = [entry]; + + while (queue.length > 0) { + const file = queue.shift()!; + if (seen.has(file)) continue; + seen.add(file); + if (path.relative(pkg.dir, file).startsWith('..')) found.add(rel(file)); + + const { imports, designated } = programEdges(file); + + for (const specifier of imports) { + // Bare specifiers are node_modules or workspace packages — the alias + // closure this guard deliberately does not follow (see the docblock). + if (!specifier.startsWith('.')) continue; + const resolved = resolveRelative(file, specifier); + if (resolved === null) { + throw new Error( + `${rel(file)} imports ${JSON.stringify(specifier)}, which resolves to no file on disk. ` + + `Teach resolveRelative() about it rather than letting the program shrink silently.`, + ); + } + queue.push(resolved); + } + + for (const literal of designated) { + // A designated path is spelled either relative to the designating file + // (`'../../vitest.setup.tsx'`) or as `path.resolve(, …)` + // — `__dirname` / `import.meta.dirname`, which IS that directory. Both + // resolve the same way, so the base is the file, not the cwd. + const resolved = resolveRelative(file, literal); + if (resolved !== null) { + queue.push(resolved); + continue; + } + if (looksLikeSourcePath(literal)) { + throw new Error( + `${rel(file)} designates ${JSON.stringify(literal)} through a file-valued Vitest ` + + `option, and it resolves to no file on disk. A renamed setup file must go red here, ` + + `not drop out of the program.`, + ); + } + } + } + return [...found].sort(); +} diff --git a/scripts/__tests__/turbo-test-inputs.test.ts b/scripts/__tests__/turbo-test-inputs.test.ts new file mode 100644 index 000000000..63e6ec995 --- /dev/null +++ b/scripts/__tests__/turbo-test-inputs.test.ts @@ -0,0 +1,206 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { + globToRegExp, + packagesWithScript, + rel, + repoFilesOnDisk, + rootAnchoredInputs, +} from './helpers/turbo-inputs'; +import { + CONFIG_FILES, + invocationFor, + outOfPackageFiles, + resolveConfigFile, +} from './helpers/vitest-config-program'; + +/** + * objectui#4178 — turbo's `test` task has the same out-of-package `inputs` hole + * that objectui#3514 closed for `type-check`, and it has it worse. + * + * Turbo hashes a task from its `inputs`. `$TURBO_DEFAULT$` covers only files + * inside the package directory, and `globalDependencies` is unset — so any file + * the task reads from elsewhere in the repo is invisible to the cache key. When + * such a file changes, turbo does not re-run the task; it REPLAYS the previous + * verdict. Before this guard, EVERY entry in the `test` task's list was + * package-relative: + * + * "inputs": ["src/**", "test/**", "tests/**", "vitest.config.*", + * "vitest.setup.*", "tsconfig*.json", "package.json", + * "$TURBO_DEFAULT$", and two negated markdown globs] + * + * (The two negations are spelled out in `turbo.json`, not here: a `!` glob of + * that shape carries the character pair that ENDS a block comment, and a + * docblock that terminates early takes the rest of itself down with it — + * `tsconfig.scripts.json` carries the same warning for the same reason.) + * + * `vitest.config.*` and `vitest.setup.*` read as if they covered the Vitest + * configuration. They do not: they resolve inside the package, while the files + * that actually decide how a package's tests run live at the repo root. Every + * package reaches them — `packages/core/vitest.config.ts` is two lines that + * re-export `../../vitest.config.mts`, and a package with no config of its own + * (`packages/app-shell`) resolves upward to that same root file, because Vitest + * looks for `vitest.config.*` / `vite.config.*` in each directory from its root + * upward and takes the first hit. + * + * Measured on `main` at eb5f8cea0, `@object-ui/core#test`: + * + * baseline 2e2087e2c30ef125 + * after touching vitest.config.mts 2e2087e2c30ef125 <- frozen + * after touching vitest.setup.base.ts 2e2087e2c30ef125 <- frozen + * after touching vitest.setup.dom.tsx 2e2087e2c30ef125 <- frozen + * after touching scripts/vitest-invocation-guard.mjs + * 2e2087e2c30ef125 <- frozen + * after touching packages/core/src/index.ts 55923ba1954a7352 (control) + * + * Worse than the `type-check` instance in two ways. A package's `type-check` + * reaches out only where a tsconfig says so, whereas every package's test run + * goes through the shared Vitest configuration by construction. And the root + * config is not passive data: it calls `assertCanonicalVitestInvocation` from + * `scripts/vitest-invocation-guard.mjs`, which decides whether a run is + * REFUSED at all (objectui#3378 / objectui#3288). A stale replay of a `test` + * task is a replay of that judgement too. + * + * So this defect DERIVES its requirement rather than restating it, the way the + * `type-check` sibling does — but from a different program, and by a different + * mechanism. The derivation lives in `./helpers/vitest-config-program.ts`, + * which assembles each package's Vitest configuration program: the config file + * Vitest resolves for its `test` script, that program's transitive relative + * imports, and the files it designates through file-valued Vitest options. + * That module's docblock carries the mechanism and its stated narrowings; this + * file carries only the policy over the result. + * + * The policy: every file in that program that lands outside the package + * directory must be matched by a `$TURBO_ROOT$` input, or this test reds naming + * the package, the file, and the entry to add. Two assertions police the + * reverse direction (an entry matching nothing on disk, an entry no program + * requires any more), one pins the derivation's own liveness, and two pin the + * config-resolution premise the whole sweep rests on. + * + * The turbo-side plumbing is shared with the sibling guard via + * `./helpers/turbo-inputs.ts`; only the derivation differs, and it differs in + * kind. + */ + +/** The turbo task this guard is about. */ +const TASK = 'test'; + +/** + * `$TURBO_ROOT$` inputs for `test` that are deliberately NOT derivable from any + * package's Vitest configuration program, each with the reason it cannot be. + * + * Empty, and worth keeping that way: an entry nothing derives is an entry + * nobody can verify, and a glob that matches nothing at all reads as coverage + * while providing none. + */ +const INPUTS_NOT_DERIVABLE: ReadonlyMap = new Map(); + +// ── The derivation, computed once ─────────────────────────────────────────── + +const PACKAGES = packagesWithScript(TASK); +const DERIVED = PACKAGES.map((pkg) => ({ ...pkg, outside: outOfPackageFiles(pkg) })); +const ROOT_INPUTS = rootAnchoredInputs(TASK); +const MATCHERS = ROOT_INPUTS.map((glob) => ({ glob, re: globToRegExp(glob) })); + +describe('turbo `test` inputs cover every out-of-package file (objectui#4178)', () => { + /** + * The guard's own liveness. Every assertion below is vacuously true if the + * sweep found no packages or no out-of-package files — and a sweep that + * silently degrades to nothing is precisely the failure this file exists to + * make impossible. + */ + it('sweeps the whole workspace and finds a non-empty out-of-package set', () => { + expect(PACKAGES.length).toBeGreaterThan(20); + const reaching = DERIVED.filter((pkg) => pkg.outside.length > 0); + expect( + reaching.map((pkg) => pkg.name), + 'no package configuration program reaches outside its directory — the derivation has ' + + 'stopped working, because at minimum packages/core/vitest.config.ts is a one-line ' + + 're-export of ../../vitest.config.mts', + ).not.toHaveLength(0); + }); + + 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))); + expect( + uncovered, + `${pkg.name}'s Vitest configuration program reads ${uncovered.join(', ')} from outside ` + + `${rel(pkg.dir)}, and turbo hashes ${TASK} from ${JSON.stringify(ROOT_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.`, + ).toEqual([]); + }, + ); + + /** + * The other direction. A `$TURBO_ROOT$` entry matching nothing is not + * harmless: it reads as coverage, survives review, and quietly stops covering + * the file it was written for the moment that file is renamed. + */ + it('every $TURBO_ROOT$ input matches at least one file on disk', () => { + const onDisk = repoFilesOnDisk(); + for (const { glob, re } of MATCHERS) { + expect( + [...onDisk].some((file) => re.test(file)), + `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); + } + }); + + /** + * And the entry is not merely non-empty but actually earned: some package's + * program reads a file it matches. This is what keeps the list SHRINKING — + * when a program stops reaching out, its input entry has to go with it. + */ + it('every $TURBO_ROOT$ input is required by some package program', () => { + const derivedFiles = [...new Set(DERIVED.flatMap((pkg) => pkg.outside))]; + for (const { glob, re } of MATCHERS) { + if (INPUTS_NOT_DERIVABLE.has(glob)) continue; + expect( + derivedFiles.filter((file) => re.test(file)), + `turbo.json \`${TASK}\` input "$TURBO_ROOT$/${glob}" is not required by any package's ` + + `Vitest configuration program any more. Delete it, or record why it cannot be derived ` + + `in INPUTS_NOT_DERIVABLE.`, + ).not.toHaveLength(0); + } + }); + + /** + * The derivation's own premise, pinned against the real Vitest. + * + * Everything above rests on "a package with no config of its own resolves + * upward to the repo-root one". That is not documented API — it is + * `any(configFiles, { cwd: root })` in Vitest's `createVitest`. If a future + * Vitest stops walking up, every configless package's derived set silently + * becomes empty and this guard goes quiet without a single red test. Pin the + * two halves that would break: the candidate ORDER (a package holding both + * `vitest.config.ts` and `vite.config.ts` must derive from the former) and + * the upward walk itself. + */ + it('resolves a configless package upward to the repo-root config', () => { + const configless = PACKAGES.find( + (pkg) => !CONFIG_FILES.some((name) => fs.existsSync(path.join(pkg.dir, name))), + ); + expect(configless, 'no configless package left to pin the upward walk with').toBeTruthy(); + expect(rel(resolveConfigFile(invocationFor(configless!.dir, configless!.script))!)).toBe( + 'vitest.config.mts', + ); + }); + + it('prefers vitest.config.* over vite.config.* in the same directory', () => { + const both = PACKAGES.find( + (pkg) => + fs.existsSync(path.join(pkg.dir, 'vitest.config.ts')) && + fs.existsSync(path.join(pkg.dir, 'vite.config.ts')), + ); + expect(both, 'no package holds both config spellings any more').toBeTruthy(); + expect(rel(resolveConfigFile(invocationFor(both!.dir, both!.script))!)).toBe( + `${rel(both!.dir)}/vitest.config.ts`, + ); + }); +}); diff --git a/scripts/__tests__/turbo-type-check-inputs.test.ts b/scripts/__tests__/turbo-type-check-inputs.test.ts index d0754a0bb..df85aec6d 100644 --- a/scripts/__tests__/turbo-type-check-inputs.test.ts +++ b/scripts/__tests__/turbo-type-check-inputs.test.ts @@ -1,8 +1,14 @@ import { describe, expect, it } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; -import { fileURLToPath } from 'node:url'; import ts from 'typescript'; +import { + globToRegExp, + packagesWithScript, + rel, + repoFilesOnDisk, + rootAnchoredInputs, +} from './helpers/turbo-inputs'; /** * objectui#3514 — turbo's `type-check` `inputs` list is hand-maintained, and it @@ -60,10 +66,16 @@ import ts from 'typescript'; * out-of-package file rather than waved through on the assumption that * turbo's `dependsOn: ["^build"]` covers it — `^build` covers declared * DEPENDENCIES, and a tsconfig reference is not required to be one. + * + * The turbo-side plumbing this file used to carry inline — workspace discovery, + * reading `$TURBO_ROOT$` entries out of `turbo.json`, and matching an input + * glob — now lives in `./helpers/turbo-inputs.ts`, shared with the sibling + * guard `turbo-test-inputs.test.ts` (objectui#4178, the same defect on the + * `test` task). Only the DERIVATION differs between the two, and it differs in + * kind: tsc programs here, Vitest configuration programs there. Two copies of a + * glob matcher whose whole doctrine is "never approximate toward a match" is + * exactly the drift neither guard would survive. */ -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); -const turboConfigPath = path.join(repoRoot, 'turbo.json'); -const workspaceConfigPath = path.join(repoRoot, 'pnpm-workspace.yaml'); /** The turbo task this guard is about. */ const TASK = 'type-check'; @@ -79,67 +91,6 @@ const TASK = 'type-check'; */ const INPUTS_NOT_DERIVABLE: ReadonlyMap = new Map(); -/** POSIX-separated, repo-relative. The one path spelling this file compares in. */ -function rel(absolute: string): string { - return path.relative(repoRoot, absolute).split(path.sep).join('/'); -} - -// ── Workspace discovery ────────────────────────────────────────────────────── - -/** - * Package directories, derived from `pnpm-workspace.yaml` rather than a - * hardcoded group list. - * - * Only the two entry shapes the file actually uses are understood (`dir/*` and - * a bare `dir`); anything else throws. A workspace layout this guard silently - * failed to walk would quietly shrink its own coverage back to nothing, which - * is the failure mode it exists to prevent. - */ -function workspacePackageDirs(): string[] { - const yaml = fs.readFileSync(workspaceConfigPath, 'utf8'); - const patterns: string[] = []; - let inPackages = false; - for (const line of yaml.split('\n')) { - if (/^packages:\s*$/.test(line)) { - inPackages = true; - continue; - } - if (inPackages) { - const item = line.match(/^\s+-\s*['"]?([^'"#]+?)['"]?\s*$/); - if (item) { - patterns.push(item[1]); - continue; - } - if (line.trim() !== '') break; - } - } - expect(patterns.length, 'pnpm-workspace.yaml must declare at least one package pattern'). - toBeGreaterThan(0); - - const dirs: string[] = []; - for (const pattern of patterns) { - if (pattern.endsWith('/*')) { - const group = path.join(repoRoot, pattern.slice(0, -2)); - if (!fs.existsSync(group)) continue; - for (const entry of fs.readdirSync(group, { withFileTypes: true })) { - const dir = path.join(group, entry.name); - if (entry.isDirectory() && fs.existsSync(path.join(dir, 'package.json'))) dirs.push(dir); - } - continue; - } - if (!pattern.includes('*')) { - const dir = path.join(repoRoot, pattern); - if (fs.existsSync(path.join(dir, 'package.json'))) dirs.push(dir); - continue; - } - throw new Error( - `pnpm-workspace.yaml pattern ${JSON.stringify(pattern)} is a glob shape this guard does ` + - `not understand. Teach workspacePackageDirs() about it — do not let the sweep skip it.`, - ); - } - return dirs.sort(); -} - // ── The type-check program, as the scripts actually drive it ───────────────── interface TscInvocation { @@ -242,91 +193,13 @@ function outOfPackageFiles(pkgDir: string, script: string): string[] { return [...found].sort(); } -/** Every workspace package that turbo's `type-check` task actually runs for. */ -function packagesWithTypeCheck(): { name: string; dir: string; script: string }[] { - const out: { name: string; dir: string; script: string }[] = []; - for (const dir of workspacePackageDirs()) { - const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')) as { - name?: string; - scripts?: Record; - }; - const script = manifest.scripts?.[TASK]; - if (script) out.push({ name: manifest.name ?? rel(dir), dir, script }); - } - return out; -} - // ── turbo.json inputs ──────────────────────────────────────────────────────── -/** - * The `$TURBO_ROOT$`-anchored `inputs` of the task, as repo-relative globs. - * - * Everything else in the list is package-relative and therefore cannot reach - * outside the package directory — turbo has no `..` escape, which is why - * `$TURBO_ROOT$` exists at all. - */ -function rootAnchoredInputs(): string[] { - const turbo = JSON.parse(fs.readFileSync(turboConfigPath, 'utf8')) as { - tasks?: Record; - }; - const task = turbo.tasks?.[TASK]; - expect(task, `turbo.json must define a \`${TASK}\` task`).toBeTruthy(); - const inputs = task?.inputs ?? []; - expect(inputs, `turbo.json \`${TASK}\` must declare \`inputs\``).not.toHaveLength(0); - - return inputs - .filter((entry) => entry.startsWith('$TURBO_ROOT$/')) - .map((entry) => entry.slice('$TURBO_ROOT$/'.length)); -} - -/** - * A turbo input glob as a regular expression. - * - * Deliberately narrow — `**`, `*` and `?` only. Any richer syntax (brace - * alternation, character classes, `!` negation) throws instead of being - * approximated, because an approximated match that comes out TRUE is a file - * this guard would wave through while turbo does not hash it. Wrong-and-red is - * recoverable; wrong-and-green is the bug. - */ -function globToRegExp(glob: string): RegExp { - if (/[!{}[\]()+@]/.test(glob)) { - throw new Error( - `turbo input ${JSON.stringify(glob)} uses glob syntax this guard does not implement. ` + - `Teach globToRegExp() about it rather than letting it guess.`, - ); - } - let pattern = ''; - for (let i = 0; i < glob.length; i += 1) { - const char = glob[i]; - if (char === '*') { - if (glob[i + 1] === '*') { - // `**/` spans zero or more directories; a trailing `**` spans the rest. - if (glob[i + 2] === '/') { - pattern += '(?:[^/]+/)*'; - i += 2; - } else { - pattern += '.*'; - i += 1; - } - } else { - pattern += '[^/]*'; - } - continue; - } - if (char === '?') { - pattern += '[^/]'; - continue; - } - pattern += char.replace(/[.^$|\\]/g, '\\$&'); - } - return new RegExp(`^${pattern}$`); -} - // ── The derivation, computed once ──────────────────────────────────────────── -const PACKAGES = packagesWithTypeCheck(); +const PACKAGES = packagesWithScript(TASK); const DERIVED = PACKAGES.map((pkg) => ({ ...pkg, outside: outOfPackageFiles(pkg.dir, pkg.script) })); -const ROOT_INPUTS = rootAnchoredInputs(); +const ROOT_INPUTS = rootAnchoredInputs(TASK); const MATCHERS = ROOT_INPUTS.map((glob) => ({ glob, re: globToRegExp(glob) })); describe('turbo `type-check` inputs cover every out-of-package file (objectui#3514)', () => { @@ -367,16 +240,7 @@ describe('turbo `type-check` inputs cover every out-of-package file (objectui#35 * the file it was written for the moment that file is renamed. */ it('every $TURBO_ROOT$ input matches at least one file on disk', () => { - const onDisk = new Set(); - const walk = (dir: string): void => { - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue; - const abs = path.join(dir, entry.name); - if (entry.isDirectory()) walk(abs); - else onDisk.add(rel(abs)); - } - }; - walk(repoRoot); + const onDisk = repoFilesOnDisk(); for (const { glob, re } of MATCHERS) { expect( diff --git a/turbo.json b/turbo.json index 30d487624..eceefbe0c 100644 --- a/turbo.json +++ b/turbo.json @@ -26,6 +26,12 @@ "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/vite-*.ts", + "$TURBO_ROOT$/apps/console/vitest.config.ts", + "$TURBO_ROOT$/apps/console/vite.config.ts", "!**/*.md", "!**/CHANGELOG.md" ]