From 789edfe73cccf077a518f3a174c96c03f3f206f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 08:00:54 +0000 Subject: [PATCH 1/3] fix(build): fail the build when tsc leaves an incomplete dist Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GgDDqh6YnkXqsnVTCa7wHk --- package.json | 1 + packages/app-shell/package.json | 2 +- packages/auth/package.json | 4 +- packages/collaboration/package.json | 4 +- packages/core/package.json | 2 +- packages/i18n/package.json | 2 +- packages/mobile/package.json | 4 +- packages/permissions/package.json | 4 +- packages/providers/package.json | 2 +- packages/react-runtime/package.json | 2 +- packages/react/package.json | 2 +- packages/sdui-parser/package.json | 2 +- packages/types/package.json | 4 +- scripts/check-dist-completeness.mjs | 389 ++++++++++++++++++++++++++++ turbo.json | 1 + 15 files changed, 408 insertions(+), 17 deletions(-) create mode 100644 scripts/check-dist-completeness.mjs diff --git a/package.json b/package.json index e6d01978b..f6919e741 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "check:icon-record-names": "node scripts/check-lucide-icon-record-names.mjs", "check:phantom-deps": "node scripts/check-phantom-dependencies.mjs", "check:self-import": "node scripts/check-package-self-import.mjs", + "check:dist-completeness": "node scripts/check-dist-completeness.mjs --all", "check:esm-specifiers": "node scripts/check-node-esm-load.mjs --specifiers-only", "check:node-esm-load": "node scripts/check-node-esm-load.mjs", "check:control-bytes": "node scripts/check-control-bytes.mjs", diff --git a/packages/app-shell/package.json b/packages/app-shell/package.json index 7c4c3ed9a..00c529357 100644 --- a/packages/app-shell/package.json +++ b/packages/app-shell/package.json @@ -54,7 +54,7 @@ "./styles.css": "./src/styles.css" }, "scripts": { - "build": "tsc", + "build": "tsc && node ../../scripts/check-dist-completeness.mjs", "test": "vitest run", "type-check": "tsc --noEmit && tsc -p tsconfig.test.json", "lint": "eslint ." diff --git a/packages/auth/package.json b/packages/auth/package.json index 195443c69..0f93225ff 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -27,8 +27,8 @@ "LICENSE" ], "scripts": { - "build": "tsc", - "clean": "rm -rf dist", + "build": "tsc && node ../../scripts/check-dist-completeness.mjs", + "clean": "rm -rf dist tsconfig.tsbuildinfo", "test": "vitest run", "type-check": "tsc --noEmit && tsc -p tsconfig.test.json", "lint": "eslint ." diff --git a/packages/collaboration/package.json b/packages/collaboration/package.json index cacb880af..65b547f94 100644 --- a/packages/collaboration/package.json +++ b/packages/collaboration/package.json @@ -27,8 +27,8 @@ "LICENSE" ], "scripts": { - "build": "tsc", - "clean": "rm -rf dist", + "build": "tsc && node ../../scripts/check-dist-completeness.mjs", + "clean": "rm -rf dist tsconfig.tsbuildinfo", "test": "vitest run", "type-check": "tsc --noEmit && tsc -p tsconfig.test.json", "lint": "eslint ." diff --git a/packages/core/package.json b/packages/core/package.json index 28d15940f..eba4ca486 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -25,7 +25,7 @@ } }, "scripts": { - "build": "tsc", + "build": "tsc && node ../../scripts/check-dist-completeness.mjs", "test": "vitest run", "type-check": "tsc --noEmit && tsc -p tsconfig.test.json", "lint": "eslint ." diff --git a/packages/i18n/package.json b/packages/i18n/package.json index a1173f659..762a797df 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -30,7 +30,7 @@ } }, "scripts": { - "build": "tsc", + "build": "tsc && node ../../scripts/check-dist-completeness.mjs", "test": "vitest run", "type-check": "tsc --noEmit && tsc -p tsconfig.test.json", "lint": "eslint ." diff --git a/packages/mobile/package.json b/packages/mobile/package.json index 11bf8a8e9..8a1184bfd 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -27,8 +27,8 @@ "LICENSE" ], "scripts": { - "build": "tsc", - "clean": "rm -rf dist", + "build": "tsc && node ../../scripts/check-dist-completeness.mjs", + "clean": "rm -rf dist tsconfig.tsbuildinfo", "test": "vitest run", "type-check": "tsc --noEmit && tsc -p tsconfig.test.json", "lint": "eslint ." diff --git a/packages/permissions/package.json b/packages/permissions/package.json index 5c457f527..7c1655079 100644 --- a/packages/permissions/package.json +++ b/packages/permissions/package.json @@ -27,8 +27,8 @@ "LICENSE" ], "scripts": { - "build": "tsc", - "clean": "rm -rf dist", + "build": "tsc && node ../../scripts/check-dist-completeness.mjs", + "clean": "rm -rf dist tsconfig.tsbuildinfo", "test": "vitest run", "type-check": "tsc --noEmit && tsc -p tsconfig.test.json", "lint": "eslint ." diff --git a/packages/providers/package.json b/packages/providers/package.json index 2c698bed7..07d281063 100644 --- a/packages/providers/package.json +++ b/packages/providers/package.json @@ -24,7 +24,7 @@ } }, "scripts": { - "build": "tsc", + "build": "tsc && node ../../scripts/check-dist-completeness.mjs", "test": "vitest run", "type-check": "tsc --noEmit && tsc -p tsconfig.test.json", "lint": "eslint ." diff --git a/packages/react-runtime/package.json b/packages/react-runtime/package.json index f2f6f79ac..80a883b6d 100644 --- a/packages/react-runtime/package.json +++ b/packages/react-runtime/package.json @@ -19,7 +19,7 @@ "dist" ], "scripts": { - "build": "tsc", + "build": "tsc && node ../../scripts/check-dist-completeness.mjs", "test": "vitest run", "type-check": "tsc --noEmit && tsc -p tsconfig.test.json", "lint": "eslint ." diff --git a/packages/react/package.json b/packages/react/package.json index 780971cbe..440411da5 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -24,7 +24,7 @@ } }, "scripts": { - "build": "tsc", + "build": "tsc && node ../../scripts/check-dist-completeness.mjs", "test": "vitest run", "type-check": "tsc --noEmit && tsc -p tsconfig.test.json", "lint": "eslint ." diff --git a/packages/sdui-parser/package.json b/packages/sdui-parser/package.json index 756e4ee6a..fbbc74133 100644 --- a/packages/sdui-parser/package.json +++ b/packages/sdui-parser/package.json @@ -25,7 +25,7 @@ "dist" ], "scripts": { - "build": "tsc", + "build": "tsc && node ../../scripts/check-dist-completeness.mjs", "test": "vitest run", "type-check": "tsc --noEmit && tsc -p tsconfig.test.json", "lint": "eslint ." diff --git a/packages/types/package.json b/packages/types/package.json index b4d320b13..177ca96d1 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -64,8 +64,8 @@ "LICENSE" ], "scripts": { - "build": "tsc", - "clean": "rm -rf dist", + "build": "tsc && node ../../scripts/check-dist-completeness.mjs", + "clean": "rm -rf dist tsconfig.tsbuildinfo", "type-check": "tsc --noEmit && tsc -p tsconfig.examples.json && tsc -p tsconfig.test.json", "lint": "eslint ." }, diff --git a/scripts/check-dist-completeness.mjs b/scripts/check-dist-completeness.mjs new file mode 100644 index 000000000..c7f10d42f --- /dev/null +++ b/scripts/check-dist-completeness.mjs @@ -0,0 +1,389 @@ +#!/usr/bin/env node +/** + * A `tsc` package's `dist/` must hold every file `tsc` says it emits. + * + * node scripts/check-dist-completeness.mjs # the package in cwd + * node scripts/check-dist-completeness.mjs --all # every tsc-built package + * node scripts/check-dist-completeness.mjs --list # what is expected, and what is there + * + * Exit: 0 = every expected output is present (or the package was never built), + * 1 = a package reports a built `dist/` that is missing files it must have. + * + * ## The failure this closes (objectui#6703) + * + * `packages/types/dist` was observed holding 4 of its 40 top-level `.d.ts` + * files while the build that produced it **exited 0**. Downstream, + * `packages/permissions` and `packages/mobile` then reported TypeScript errors + * that read exactly like ordinary type errors in those packages — so the + * developer's next move is to "fix" a type error that does not exist, in a + * package nobody touched. + * + * The producer reporting success on a partial write is the whole defect. This + * gate is the missing half: after `tsc` runs, something has to count. + * + * ## The mechanism, measured rather than assumed + * + * All twelve `"build": "tsc"` packages here set `composite: true`, and + * `composite` implies `incremental`. So each writes a `tsconfig.tsbuildinfo` + * recording what it has already emitted — and TypeScript resolves that file + * NEXT TO `tsconfig.json`, **outside the `outDir` it describes**: + * + * packages/types/tsconfig.tsbuildinfo <- the record + * packages/types/dist/ <- what it is a record OF + * + * Those two can therefore disagree, and when they do `tsc` believes the record. + * It does not stat its own outputs. Measured on this tree at 4357ec7 — a + * complete build, then `rm -rf dist` alone, then `tsc` again: + * + * $ rm -rf packages/types/dist && ls packages/types/tsconfig.tsbuildinfo + * packages/types/tsconfig.tsbuildinfo + * $ cd packages/types && ../../node_modules/.bin/tsc ; echo $? + * 0 + * $ ls dist + * ls: cannot access 'dist': No such file or directory + * + * Zero of 40, exit 0, three seconds instead of seven. The same run truncated to + * 4 of 40 instead of 0 reproduces the reported shape exactly: `tsc` exits 0 and + * repairs nothing. + * + * `rm -rf dist` is not a hypothetical way into that state. It is what six of + * these packages have literally spelled as their `clean` script, so + * `pnpm clean && pnpm build` **was** a two-command recipe for an empty `dist/` + * and a green exit code. That is fixed alongside this gate — `clean` now takes + * the buildinfo with it — but the desync has other doors (an interrupted emit, + * a concurrent tree operation, a restored cache), which is why the fix to + * `clean` is not a substitute for counting. + * + * ## Why this reaches further than one developer's afternoon + * + * `turbo.json` gives the `build` task an `outputs` list covering `dist` and every + * `.tsbuildinfo` (spelled there as globs, which cannot be quoted inside a block + * comment without ending it — the trap `check-package-self-import.mjs` records), and + * caches it. A `tsc` that short-circuits still exits 0, so turbo records that + * run as a **successful build** and stores whatever `dist/` happened to be + * there. Measured here, the resulting cache entry for `@object-ui/types:build` + * held exactly two paths: + * + * packages/types/tsconfig.tsbuildinfo + * packages/types/.turbo/turbo-build.log + * + * No `dist/` at all — and it replays as `cache hit … >>> FULL TURBO`, exit 0. + * Worse, the entry is a fixed point: restoring it puts the buildinfo back + * WITHOUT a `dist/`, which is precisely the desync that makes the next `tsc` + * short-circuit again. `.github/workflows/lint.yml` already carries an + * independent sighting of the replay half ("an entry recorded with an empty + * output set replays as … FULL TURBO while writing no `dist/` at all"); this + * gate names where such an entry comes from. + * + * Turbo also announces `using shared worktree cache` — that store is keyed off + * the common `.git` directory, so one poisoned entry is visible to every + * worktree of the repository at once. This is the concurrency dependence the + * report describes: not two writers racing inside one `dist/`, but one + * short-circuited build recording a green, empty artifact that every other + * checkout then replays. + * + * Because turbo only caches a task that exited 0, running this check INSIDE the + * build script is what matters: it stops the poisoned entry from ever being + * recorded, rather than detecting it afterwards. + * + * ## Why the expectation is derived and never written down + * + * The report asks whether "40" is stable enough to assert against. It is not, + * and it is also not the real number: 40 is the count of `packages/types/src/*.ts` + * at depth one, while a complete build is 118 files (59 inputs x `.js` + `.d.ts`). + * Any literal here would be wrong the first time a file is added. + * + * So nothing is hardcoded. The expected set comes from TypeScript's own API — + * `parseJsonConfigFileContent` for the input set the package's own tsconfig + * selects, then `getOutputFileNames` for what each input emits. That is the + * same computation `tsc --build` uses to decide whether a project is up to + * date, so the gate cannot disagree with the compiler about what "complete" + * means, and it follows `declaration`, `declarationMap`, `sourceMap`, `jsx` and + * `outDir` per package without being told about any of them. + * + * Verified against a full build of all twelve packages at 4357ec7: + * 1,586 expected outputs derived, 0 missing. The derivation has no false + * positives on a tree that is actually built. + * + * ## What "never built" means here, and why it is not a pass in disguise + * + * A package with neither a `dist/` nor a buildinfo has not been built, which is + * an ordinary state (a fresh clone) and not this defect — those are skipped and + * counted in the summary. A buildinfo with no `dist/` is NOT skipped: that is + * the desync itself, and it is the state the empty cache entry restores. + * + * In the placement that matters — appended to each package's own `build` — + * neither branch is reachable as an excuse, because `tsc` has just run. + */ + +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { dirname, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import ts from 'typescript'; + +import { isEntrypoint } from './invoked-as.mjs'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + +/** Where a workspace package can live. */ +export const PACKAGE_ROOTS = ['packages', 'apps']; + +/** + * Does this manifest build by running `tsc` directly? + * + * Keyed on the build command rather than on "has a tsconfig with an outDir", + * because a vite-built package has one of those too and emits a bundle whose + * shape has nothing to do with `getOutputFileNames`. The `&&` split is what + * keeps the answer `true` after this very gate is appended to the script. + * + * @param {any} manifest a parsed package.json + * @returns {boolean} + */ +export function buildsWithTsc(manifest) { + const build = manifest?.scripts?.build; + if (typeof build !== 'string') return false; + return build.split('&&')[0].trim() === 'tsc'; +} + +/** + * Every workspace package whose `build` is `tsc`. + * + * Throws on an empty result rather than returning one: a walk that finds no + * packages would pass every assertion below while looking at nothing, which is + * the one thing a gate must never do. + * + * @param {string} [root] + * @returns {{ name: string, dir: string, manifest: any }[]} + */ +export function discoverTscPackages(root = ROOT) { + const found = []; + for (const scanRoot of PACKAGE_ROOTS) { + let entries; + try { + entries = readdirSync(join(root, scanRoot), { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { + if (!entry.isDirectory()) continue; + const dir = join(root, scanRoot, entry.name); + const manifestPath = join(dir, 'package.json'); + if (!existsSync(manifestPath)) continue; + let manifest; + try { + manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); + } catch (error) { + throw new Error(`cannot read ${relative(root, manifestPath)}: ${error.message}`); + } + if (!manifest.name || !buildsWithTsc(manifest)) continue; + found.push({ name: manifest.name, dir, manifest }); + } + } + if (found.length === 0) { + throw new Error( + `no workspace package with a "build": "tsc" script was found under ${PACKAGE_ROOTS.join(', ')} in ${root} — ` + + 'the walk is broken, and an empty scan would pass while checking nothing', + ); + } + return found; +} + +/** + * What `tsc` will emit for the project at `dir`, asked of TypeScript itself. + * + * @param {string} dir a package directory holding a tsconfig.json + * @returns {{ inputs: string[], outputs: string[], buildInfoPath: string | undefined, outDir: string | undefined }} + */ +export function expectedEmit(dir) { + const configPath = join(dir, 'tsconfig.json'); + const read = ts.readConfigFile(configPath, ts.sys.readFile); + if (read.error) { + throw new Error( + `cannot read ${configPath}: ${ts.flattenDiagnosticMessageText(read.error.messageText, ' ')}`, + ); + } + const parsed = ts.parseJsonConfigFileContent(read.config, ts.sys, dir, undefined, configPath); + const blocking = parsed.errors.filter((d) => d.category === ts.DiagnosticCategory.Error); + if (blocking.length > 0) { + throw new Error( + `${configPath} does not parse: ${blocking + .map((d) => ts.flattenDiagnosticMessageText(d.messageText, ' ')) + .join('; ')}`, + ); + } + + const outputs = []; + if (!parsed.options.noEmit) { + for (const input of parsed.fileNames) { + outputs.push(...ts.getOutputFileNames(parsed, input, false)); + } + } + return { + inputs: parsed.fileNames, + outputs, + buildInfoPath: ts.getTsBuildInfoEmitOutputFilePath?.(parsed.options), + outDir: parsed.options.outDir, + }; +} + +/** + * @typedef {object} PackageAudit + * @property {string} name + * @property {string} dir + * @property {'complete' | 'incomplete' | 'never-built' | 'no-emit'} state + * @property {string[]} missing paths, relative to the repository root + * @property {number} expected how many outputs `tsc` says it emits + * @property {string | undefined} buildInfoPath + * @property {boolean} buildInfoPresent + */ + +/** + * Judge one package. + * + * @param {{ name: string, dir: string }} pkg + * @param {{ root?: string }} [options] + * @returns {PackageAudit} + */ +export function auditPackage(pkg, { root = ROOT } = {}) { + const { outputs, buildInfoPath, outDir } = expectedEmit(pkg.dir); + const rel = (p) => relative(root, p); + const buildInfoPresent = Boolean(buildInfoPath && existsSync(buildInfoPath)); + + if (outputs.length === 0) { + return { ...pkg, state: 'no-emit', missing: [], expected: 0, buildInfoPath, buildInfoPresent }; + } + + const distPresent = Boolean(outDir && existsSync(outDir)); + if (!distPresent && !buildInfoPresent) { + return { + ...pkg, + state: 'never-built', + missing: [], + expected: outputs.length, + buildInfoPath, + buildInfoPresent, + }; + } + + const missing = outputs.filter((o) => !existsSync(o)).map(rel); + return { + ...pkg, + state: missing.length === 0 ? 'complete' : 'incomplete', + missing, + expected: outputs.length, + buildInfoPath, + buildInfoPresent, + }; +} + +/** + * Render the audits and decide the exit code. + * + * Split from the walk and from `process.exit` so the tests can assert on the + * judgement and on the words a developer actually reads. + * + * @param {PackageAudit[]} audits + * @param {{ root?: string, list?: boolean }} [options] + * @returns {{ exitCode: number, lines: string[] }} + */ +export function report(audits, { root = ROOT, list = false } = {}) { + const lines = []; + const broken = audits.filter((a) => a.state === 'incomplete'); + + if (list) { + for (const a of audits) { + lines.push(`${a.name}: ${a.state} — ${a.expected} expected, ${a.missing.length} missing`); + } + } + + for (const a of broken) { + const shown = a.missing.slice(0, 10); + lines.push(''); + lines.push(`✗ ${a.name} reports a built dist/ that is missing ${a.missing.length} of ${a.expected} files tsc emits:`); + for (const m of shown) lines.push(` ${m}`); + if (a.missing.length > shown.length) lines.push(` … and ${a.missing.length - shown.length} more`); + if (a.buildInfoPresent && a.buildInfoPath) { + lines.push(''); + lines.push( + ` ${relative(root, a.buildInfoPath)} still records those files as emitted, so tsc will NOT`, + ); + lines.push(' re-emit them and will keep exiting 0. Repair:'); + lines.push(''); + lines.push(` rm -f ${relative(root, a.buildInfoPath)}`); + lines.push(` pnpm --filter ${a.name} build`); + } + } + + if (broken.length > 0) { + lines.push(''); + lines.push( + 'A dist/ that is incomplete does not fail where it is broken. It fails in the packages that', + ); + lines.push( + 'IMPORT it, as ordinary-looking type errors in files nobody touched — see the header of', + ); + lines.push('scripts/check-dist-completeness.mjs (objectui#6703).'); + return { exitCode: 1, lines }; + } + + const complete = audits.filter((a) => a.state === 'complete'); + const unbuilt = audits.filter((a) => a.state === 'never-built'); + const noEmit = audits.filter((a) => a.state === 'no-emit'); + const files = complete.reduce((n, a) => n + a.expected, 0); + // The two skip reasons are reported apart on purpose. "never built" is a + // tree state and says nothing about the package; "no emit" is a permanent + // property of its tsconfig (it type-checks with `tsc` and emits with vite). + // Collapsing them would let a package that stopped emitting read as one that + // merely had not been built yet. + const notes = []; + if (unbuilt.length > 0) notes.push(`${unbuilt.length} not built yet`); + if (noEmit.length > 0) notes.push(`${noEmit.length} type-check-only (tsc --noEmit)`); + lines.push( + `✓ dist completeness: ${complete.length} package(s) complete (${files} emitted files verified)` + + (notes.length > 0 ? `; ${notes.join(', ')}` : ''), + ); + return { exitCode: 0, lines }; +} + +/** + * The whole check. + * + * @param {{ root?: string, all?: boolean, cwd?: string, list?: boolean }} [options] + * @returns {{ exitCode: number, lines: string[], audits: PackageAudit[] }} + */ +export function analyze({ root = ROOT, all = false, cwd = process.cwd(), list = false } = {}) { + let packages; + if (all) { + packages = discoverTscPackages(root); + } else { + const dir = resolve(cwd); + const manifestPath = join(dir, 'package.json'); + if (!existsSync(manifestPath)) { + throw new Error(`no package.json in ${dir} — run this from a package directory, or pass --all`); + } + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); + if (!buildsWithTsc(manifest)) { + return { + exitCode: 0, + lines: [`· ${manifest.name ?? dir} does not build with tsc — nothing to verify`], + audits: [], + }; + } + packages = [{ name: manifest.name, dir, manifest }]; + } + + const audits = packages.map((p) => auditPackage(p, { root })); + const { exitCode, lines } = report(audits, { root, list }); + return { exitCode, lines, audits }; +} + +if (isEntrypoint(import.meta.url)) { + const argv = process.argv.slice(2); + const { exitCode, lines } = analyze({ + all: argv.includes('--all'), + list: argv.includes('--list'), + }); + for (const line of lines) (exitCode === 0 ? console.log : console.error)(line); + process.exit(exitCode); +} diff --git a/turbo.json b/turbo.json index 6d87e5b05..10d5d3211 100644 --- a/turbo.json +++ b/turbo.json @@ -18,6 +18,7 @@ "$TURBO_ROOT$/tsconfig.base.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" ] }, From cd90cd1aa1758611a3902eef5e29757f5ac6864a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 08:19:32 +0000 Subject: [PATCH 2/3] test(build): pin the dist-completeness gate and its wiring Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GgDDqh6YnkXqsnVTCa7wHk --- .changeset/6703-dist-completeness-gate.md | 13 + .../__tests__/check-dist-completeness.test.ts | 321 ++++++++++++++++++ 2 files changed, 334 insertions(+) create mode 100644 .changeset/6703-dist-completeness-gate.md create mode 100644 scripts/__tests__/check-dist-completeness.test.ts diff --git a/.changeset/6703-dist-completeness-gate.md b/.changeset/6703-dist-completeness-gate.md new file mode 100644 index 000000000..1a1a38017 --- /dev/null +++ b/.changeset/6703-dist-completeness-gate.md @@ -0,0 +1,13 @@ +--- +--- + +Build tooling only — no package source changes, so nothing is released here. + +`packages/*` built by plain `tsc` are `composite`, and TypeScript resolves their +`tsconfig.tsbuildinfo` next to `tsconfig.json` rather than inside the `dist/` it +describes. Once those two disagree, `tsc` believes the record: it emits nothing, +repairs nothing and exits 0, and the damage surfaces as ordinary-looking type +errors in whichever packages import the truncated artifact (objectui#6703). + +Each such build now ends by asserting that every file `tsc` says it emits is on +disk, and each `clean` takes the buildinfo with the `dist/` it describes. diff --git a/scripts/__tests__/check-dist-completeness.test.ts b/scripts/__tests__/check-dist-completeness.test.ts new file mode 100644 index 000000000..c85537575 --- /dev/null +++ b/scripts/__tests__/check-dist-completeness.test.ts @@ -0,0 +1,321 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// Plain-JS CI helper. Its types are INFERRED from the .mjs source by +// `tsconfig.scripts.json` (`allowJs`), so no `@ts-expect-error` here — +// re-adding one is now itself an error (TS2578). See objectui#3494. +import { + analyze, + auditPackage, + buildsWithTsc, + discoverTscPackages, + expectedEmit, + report, +} from '../check-dist-completeness.mjs'; + +/** + * objectui#6703. `packages/types/dist` was seen holding 4 of its 40 top-level + * `.d.ts` files while the build that wrote it exited 0, and the damage surfaced + * as ordinary-looking type errors in `permissions` and `mobile`. + * + * The mechanism, measured rather than assumed: every `"build": "tsc"` package + * here is `composite`, so `tsc` records what it emitted in a + * `tsconfig.tsbuildinfo` that TypeScript resolves NEXT TO `tsconfig.json` — + * outside the `dist/` it describes. Delete the `dist/` and leave that file, and + * `tsc` believes the record, emits nothing and exits 0. It never stats its own + * outputs. + * + * Two locks live here. The unit cases below pin the judgement on real + * directories built in a tmpdir, because the defect IS a question about files + * on disk and a mocked `fs` would answer it the way the mock was written. The + * repo-state cases at the bottom pin the WIRING — a gate that no build script + * invokes is indistinguishable from one that passes, and the wiring is the + * whole reason this check runs before turbo can cache a green empty build. + */ +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + +/** A minimal real tsc project: N sources, a tsconfig that emits to `dist/`. */ +function makeProject(dir: string, sources: string[]): void { + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + for (const s of sources) { + fs.writeFileSync(path.join(dir, 'src', s), `export const ${s.replace(/\W/g, '_')} = 1;\n`); + } + fs.writeFileSync( + path.join(dir, 'package.json'), + `${JSON.stringify({ name: '@fixture/pkg', scripts: { build: 'tsc' } }, null, 2)}\n`, + ); + fs.writeFileSync( + path.join(dir, 'tsconfig.json'), + `${JSON.stringify( + { + compilerOptions: { + outDir: './dist', + rootDir: './src', + declaration: true, + composite: true, + module: 'ESNext', + moduleResolution: 'bundler', + }, + include: ['src/**/*'], + }, + null, + 2, + )}\n`, + ); +} + +/** Pretend `tsc` ran: write the outputs the gate should expect, or some of them. */ +function emit(dir: string, outputs: string[]): void { + for (const o of outputs) { + fs.mkdirSync(path.dirname(o), { recursive: true }); + fs.writeFileSync(o, ''); + } + fs.writeFileSync(path.join(dir, 'tsconfig.tsbuildinfo'), '{}'); +} + +function withTmp(fn: (dir: string) => T): T { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dist-completeness-')); + try { + return fn(dir); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +describe('buildsWithTsc', () => { + it('recognises a bare tsc build', () => { + expect(buildsWithTsc({ scripts: { build: 'tsc' } })).toBe(true); + }); + + it('still recognises the build after this very gate is appended to it', () => { + // The wiring below rewrites every one of these scripts. If the predicate + // stopped matching its own output, `--all` would silently scan nothing. + expect( + buildsWithTsc({ scripts: { build: 'tsc && node ../../scripts/check-dist-completeness.mjs' } }), + ).toBe(true); + }); + + it('does not claim a vite build', () => { + expect(buildsWithTsc({ scripts: { build: 'vite build' } })).toBe(false); + expect(buildsWithTsc({ scripts: {} })).toBe(false); + expect(buildsWithTsc({})).toBe(false); + }); +}); + +describe('expectedEmit — derived from TypeScript, never written down', () => { + it('asks the compiler what each input emits rather than counting files', () => { + withTmp((dir) => { + makeProject(dir, ['a.ts', 'b.ts', 'c.ts']); + const { inputs, outputs, buildInfoPath } = expectedEmit(dir); + + expect(inputs).toHaveLength(3); + // declaration:true, no maps -> exactly .js + .d.ts per input. + expect(outputs.map((o: string) => path.relative(dir, o)).sort()).toEqual([ + 'dist/a.d.ts', + 'dist/a.js', + 'dist/b.d.ts', + 'dist/b.js', + 'dist/c.d.ts', + 'dist/c.js', + ]); + // The buildinfo sits OUTSIDE the outDir it is a record of. That is the + // whole defect: `rm -rf dist` cannot take it, so the two desync. + expect(path.relative(dir, buildInfoPath as string)).toBe('tsconfig.tsbuildinfo'); + }); + }); + + it('follows the tsconfig it is given instead of a fixed output shape', () => { + withTmp((dir) => { + makeProject(dir, ['a.ts']); + const cfg = JSON.parse(fs.readFileSync(path.join(dir, 'tsconfig.json'), 'utf8')); + cfg.compilerOptions.declaration = false; + cfg.compilerOptions.composite = false; + cfg.compilerOptions.sourceMap = true; + fs.writeFileSync(path.join(dir, 'tsconfig.json'), JSON.stringify(cfg, null, 2)); + + expect(expectedEmit(dir).outputs.map((o: string) => path.relative(dir, o)).sort()).toEqual([ + 'dist/a.js', + 'dist/a.js.map', + ]); + }); + }); +}); + +describe('auditPackage', () => { + it('passes a dist that holds every file tsc emits', () => { + withTmp((dir) => { + makeProject(dir, ['a.ts', 'b.ts']); + emit(dir, expectedEmit(dir).outputs); + + const audit = auditPackage({ name: '@fixture/pkg', dir }, { root: dir }); + expect(audit.state).toBe('complete'); + expect(audit.missing).toEqual([]); + expect(audit.expected).toBe(4); + }); + }); + + it('fails the reported shape: some outputs present, the buildinfo still claiming all of them', () => { + withTmp((dir) => { + makeProject(dir, ['a.ts', 'b.ts', 'c.ts']); + const { outputs } = expectedEmit(dir); + emit(dir, outputs.slice(0, 2)); + + const audit = auditPackage({ name: '@fixture/pkg', dir }, { root: dir }); + expect(audit.state).toBe('incomplete'); + expect(audit.missing).toHaveLength(4); + expect(audit.buildInfoPresent).toBe(true); + }); + }); + + it('fails a buildinfo with NO dist at all — the state an empty turbo cache entry restores', () => { + withTmp((dir) => { + makeProject(dir, ['a.ts']); + fs.writeFileSync(path.join(dir, 'tsconfig.tsbuildinfo'), '{}'); + + const audit = auditPackage({ name: '@fixture/pkg', dir }, { root: dir }); + expect(audit.state).toBe('incomplete'); + expect(audit.missing).toEqual(['dist/a.js', 'dist/a.d.ts']); + }); + }); + + it('skips a package that was simply never built — no dist AND no buildinfo', () => { + withTmp((dir) => { + makeProject(dir, ['a.ts']); + const audit = auditPackage({ name: '@fixture/pkg', dir }, { root: dir }); + expect(audit.state).toBe('never-built'); + expect(audit.missing).toEqual([]); + }); + }); + + it('reports a type-check-only project apart from an unbuilt one', () => { + withTmp((dir) => { + makeProject(dir, ['a.ts']); + const cfg = JSON.parse(fs.readFileSync(path.join(dir, 'tsconfig.json'), 'utf8')); + cfg.compilerOptions.noEmit = true; + cfg.compilerOptions.composite = false; + fs.writeFileSync(path.join(dir, 'tsconfig.json'), JSON.stringify(cfg, null, 2)); + + // `fields` and `apps/console` are real instances: their `build` opens with + // `tsc` as a CHECK and emits through vite. Collapsing this into + // "never built" would let a package that stopped emitting read as one + // that had merely not been built yet. + expect(auditPackage({ name: '@fixture/pkg', dir }, { root: dir }).state).toBe('no-emit'); + }); + }); +}); + +describe('report', () => { + it('exits 1 and names the missing files, the stale record, and the repair', () => { + withTmp((dir) => { + makeProject(dir, ['a.ts', 'b.ts']); + const { outputs } = expectedEmit(dir); + emit(dir, outputs.slice(0, 1)); + + const audit = auditPackage({ name: '@fixture/pkg', dir }, { root: dir }); + const { exitCode, lines } = report([audit], { root: dir }); + const text = lines.join('\n'); + + expect(exitCode).toBe(1); + expect(text).toContain('@fixture/pkg'); + expect(text).toContain('tsconfig.tsbuildinfo'); + expect(text).toContain('rm -f tsconfig.tsbuildinfo'); + // The cascade is the expensive half: without this sentence the reader + // goes looking for the type error in the package that reported it. + expect(text).toContain('It fails in the packages that'); + }); + }); + + it('exits 0 with a count, so a run that verified nothing cannot read as a pass', () => { + withTmp((dir) => { + makeProject(dir, ['a.ts', 'b.ts']); + emit(dir, expectedEmit(dir).outputs); + + const audit = auditPackage({ name: '@fixture/pkg', dir }, { root: dir }); + const { exitCode, lines } = report([audit], { root: dir }); + expect(exitCode).toBe(0); + expect(lines.join('\n')).toContain('4 emitted files verified'); + }); + }); +}); + +describe('analyze', () => { + it('says so and passes when the package in cwd does not build with tsc', () => { + withTmp((dir) => { + fs.writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ name: '@fixture/vite', scripts: { build: 'vite build' } }), + ); + const { exitCode, lines, audits } = analyze({ root: dir, cwd: dir }); + expect(exitCode).toBe(0); + expect(audits).toEqual([]); + expect(lines.join('\n')).toContain('does not build with tsc'); + }); + }); + + it('throws rather than passing when the walk finds no packages', () => { + withTmp((dir) => { + // An empty scan satisfies every assertion while looking at nothing, which + // is the one outcome a gate must never report as success. + expect(() => analyze({ root: dir, all: true })).toThrow(/no workspace package/); + }); + }); +}); + +describe('this repository', () => { + const tscPackages = discoverTscPackages(repoRoot) as Array<{ name: string; dir: string }>; + + it('finds the tsc-built packages', () => { + expect(tscPackages.length).toBeGreaterThanOrEqual(12); + }); + + it.each(tscPackages.map((p) => [p.name, p.dir] as const))( + 'every emitting tsc package runs this check in its own build: %s', + (name, dir) => { + const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')); + if (expectedEmit(dir).outputs.length === 0) return; // type-check-only; nothing to verify + + // Placement is load-bearing, not a preference. turbo caches a task that + // exits 0, so a `tsc` that short-circuits gets its empty `dist/` RECORDED + // as a successful build — and that entry then replays as `cache hit … + // FULL TURBO` into every worktree sharing the store. Checking inside the + // build is what stops the entry from being written at all. + expect(manifest.scripts.build).toContain('check-dist-completeness.mjs'); + }, + ); + + it.each(tscPackages.map((p) => [p.name, p.dir] as const))( + 'a clean script takes the buildinfo with the dist it describes: %s', + (name, dir) => { + const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')); + const clean = manifest.scripts?.clean; + if (typeof clean !== 'string') return; + // Scoped to projects that actually WRITE a buildinfo. `fields` opens its + // build with `tsc` but is `noEmit` with neither `composite` nor + // `incremental`, so TypeScript resolves no buildinfo path for it and + // there is nothing for its `clean` to desync from. + if (!expectedEmit(dir).buildInfoPath) return; + + // `rm -rf dist` alone leaves the record of what was emitted behind, so + // `pnpm clean && pnpm build` produced an EMPTY dist and exit 0 — measured + // on 4357ec7 before this was fixed. + expect(clean).toContain('tsconfig.tsbuildinfo'); + }, + ); + + it('turbo treats the gate as an input of the builds that run it', () => { + const turbo = JSON.parse(fs.readFileSync(path.join(repoRoot, 'turbo.json'), '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'); + }); + + it('has a root alias pointing at the script that exists', () => { + const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')); + const alias = pkg.scripts['check:dist-completeness']; + expect(alias).toBe('node scripts/check-dist-completeness.mjs --all'); + expect(fs.existsSync(path.join(repoRoot, 'scripts/check-dist-completeness.mjs'))).toBe(true); + }); +}); From 25af4a843593dd3013e57a3e577deb0c3db96121 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 08:39:06 +0000 Subject: [PATCH 3/3] test(types): read the build script as a chain, not one string The exports-map pin asserted `scripts.build === 'tsc'` to mean 'no bundler that could emit a second format'. Appending a non-emitting post-build assertion turned it red while the CJS question was untouched. The emit step must still be bare `tsc`; every later step is enumerated in the test, so a bundler still cannot arrive without editing it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GgDDqh6YnkXqsnVTCa7wHk --- .../package-exports-manifest.test.ts | 31 +++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/packages/types/src/__tests__/package-exports-manifest.test.ts b/packages/types/src/__tests__/package-exports-manifest.test.ts index cc1cfd94d..7a9ff641c 100644 --- a/packages/types/src/__tests__/package-exports-manifest.test.ts +++ b/packages/types/src/__tests__/package-exports-manifest.test.ts @@ -37,10 +37,15 @@ * be vacuously absent-or-red on a cold CI cache, not a meaningful signal. * The artifact-level claim (a clean `tsc` build never emits `.cjs`) was * verified by hand for this fix and is not expected to regress silently: the - * package's `"build": "tsc"` script and lack of any bundler/dual-emit step + * package's bare-`tsc` EMIT step and lack of any bundler/dual-emit step * are exactly the two other things this test pins, so a future edit that * reintroduces a `require` condition without ALSO reintroducing the emit step * would still need to touch this file's expectations to pass CI. + * + * The build script is read as a chain rather than as one string: its first + * segment is the emit and must be bare `tsc`, and every later segment is + * enumerated in the test, so non-emitting post-build checks can be added + * without weakening any of the above. See that case for why. */ import { readFileSync } from 'node:fs'; import { createRequire } from 'node:module'; @@ -64,7 +69,29 @@ describe('@object-ui/types package.json exports map (objectui#4896)', () => { // If this ever changes to a bundler/dual-emit build, the `require`-less // exports map below should be revisited rather than assumed to still be // correct. - expect(pkg.scripts?.build).toBe('tsc'); + // + // What that sentence pins is that NOTHING IN THE BUILD EMITS A SECOND + // FORMAT. `toBe('tsc')` conflated it with the literal string, so appending + // a post-build assertion that emits nothing at all (objectui#6703) turned + // this red while the CJS question was untouched — a spelling too strict for + // its own stated meaning. + // + // The chain is therefore split instead of compared whole. The EMITTING step + // must be bare `tsc`; every other step is enumerated right here, so a + // bundler — or any new step whatsoever — still cannot arrive without + // editing this expectation and justifying it. That is exactly what the + // header above asks for, and it is no weaker than the old spelling: both + // fail on any change, this one just fails for the right reason. + // + // The first assertion is the same question `buildsWithTsc()` asks in + // `scripts/check-dist-completeness.mjs`, RESTATED rather than imported: + // this package's test program sets `allowJs: false` and includes only + // `src/**/*.test.ts`, so importing that `.mjs` fails with TS7016 (measured, + // not assumed). The duplication is structural — change one, change the + // other. + const steps = (pkg.scripts?.build ?? '').split('&&').map((step) => step.trim()); + expect(steps[0]).toBe('tsc'); + expect(steps.slice(1)).toEqual(['node ../../scripts/check-dist-completeness.mjs']); }); it('the root "." export carries exactly {types, import} — no "require" condition', () => {