From 0884dd3c73a3dab3c577047dc1cfd5952213e706 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 03:07:13 +0000 Subject: [PATCH 1/2] test(scripts): repo-level sideEffects declaration/load-time consistency gate (#3943) Co-authored-by: Claude --- .../readme-registration-keys.test.ts | 7 +- .../__tests__/side-effects-manifest.test.ts | 375 ------ .../check-package-self-import.test.ts | 22 +- ...de-effects-declaration-consistency.test.ts | 1133 +++++++++++++++++ 4 files changed, 1155 insertions(+), 382 deletions(-) delete mode 100644 packages/layout/src/__tests__/side-effects-manifest.test.ts create mode 100644 scripts/__tests__/side-effects-declaration-consistency.test.ts diff --git a/packages/layout/src/__tests__/readme-registration-keys.test.ts b/packages/layout/src/__tests__/readme-registration-keys.test.ts index 136aba66d0..7f25496baf 100644 --- a/packages/layout/src/__tests__/readme-registration-keys.test.ts +++ b/packages/layout/src/__tests__/readme-registration-keys.test.ts @@ -19,7 +19,9 @@ * by `guide-layout-sidebar-nav-doc.test.ts` (objectui#4840); * - `src/index.ts` itself — the keys' existence — held by * `app-shell-not-a-component-key.test.tsx` (objectui#4841, one key) and - * `side-effects-manifest.test.ts` (a count FLOOR, not a census); + * the repo-level `scripts/__tests__/side-effects-declaration-consistency.test.ts` + * (a count FLOOR, not a census — objectui#3943 converged the former + * `side-effects-manifest.test.ts` into it); * - this README's `## Registration` paragraph — nothing. * * The asymmetry was measured, not theorised. objectui#4841 deregistered @@ -89,7 +91,8 @@ * The reader below is this repo's fourth copy of the same three-line * `ComponentRegistry.register` regex (the others are in * `guide-layout-sidebar-nav-doc.test.ts`, `app-shell-not-a-component-key.test.tsx` - * and `side-effects-manifest.test.ts`). Extracting a shared helper is raised as a + * and `scripts/__tests__/side-effects-declaration-consistency.test.ts`). + * Extracting a shared helper is raised as a * side option on objectui#4860 and is deliberately NOT taken here: it would edit * three pin files to serve one, and each of those files is self-contained on * purpose — a pin that imports its own reader from a shared module can be diff --git a/packages/layout/src/__tests__/side-effects-manifest.test.ts b/packages/layout/src/__tests__/side-effects-manifest.test.ts deleted file mode 100644 index 3277e9728b..0000000000 --- a/packages/layout/src/__tests__/side-effects-manifest.test.ts +++ /dev/null @@ -1,375 +0,0 @@ -/** - * ObjectUI - * Copyright (c) 2024-present ObjectStack Inc. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/** - * This package registers its components as a MODULE LOAD SIDE EFFECT, and its - * manifest has to say so (objectui#3899). - * - * `src/index.ts` ends with a bare `try { registerLayout(); } catch {}`, which is - * the only thing that puts `page-header`, `page:card`, `responsive-grid`, - * `navigation-renderer` and `app-schema-renderer` into the `ComponentRegistry`. - * (There was a sixth, `app-shell`, until objectui#4841 deregistered it.) The - * manifest used to declare `"sideEffects": false` — - * a promise to bundlers that no module here does anything on evaluation, so any - * module whose exports go unused may be dropped whole. - * - * Both statements cannot be true. Measured on main@230ffd875 by bundling - * `import '@object-ui/layout';` (the side-effect-only import, i.e. the - * documented "import it to register" pattern) with the repo's own bundler: - * - * sideEffects: false -> bundle is 0 bytes, zero registrations - * this array -> bundle keeps every `ComponentRegistry.register` call - * - * Zero bytes, exit code 0, no warning. The failure surfaces far away as a red - * `Unknown component type` panel (OBJUI-001) on a green build. Nobody was bitten - * only because every consumer today also imports a NAMED export, which forces - * evaluation regardless — coincidence, not design. objectui#3787 hit the hazard - * and routed around it by calling `registerLayout()` explicitly. - * - * ## What this file pins, and what it deliberately does not - * - * It pins the MANIFEST as a bundling contract: the declaration keeps naming - * every module form a bundler can resolve this package to, and a real bundler - * run confirms each of those forms actually survives a side-effect-only import. - * - * The spelling is NOT what the probes are sensitive to, which is worth writing - * down because it is the natural guess. Measured on vite 8 / rolldown 1.2.1, - * `./dist/index.js`, `dist/index.js`, `dist/*.js` and `**\/index.js` all match - * the same file. So a red probe means the path is not covered AT ALL, or the - * bundler changed how it honours the field — never a leading-`./` nit. - * - * It does NOT pin the auto-registration itself. Replacing it with an explicit - * registration API is a separate, deliberately-not-taken direction (breaking for - * any consumer relying on load-time registration) that the issue leaves to the - * maintainer. If that lands, `declaresLoadTimeRegistration` below turns red — on - * purpose. That test is not defending the side effect, it is defending the - * INVARIANT: the manifest and the module body must agree. Remove the side effect - * and the honest manifest is `false` again, so both change together or neither - * does. A red test here means "update the other half", never "put it back". - */ - -import { describe, it, expect } from 'vitest'; -import { build } from 'vite'; -import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { dirname, join, resolve } from 'node:path'; -import { tmpdir } from 'node:os'; - -/** `packages/layout` — two levels up from `src/__tests__`. */ -const PKG_DIR = resolve(__dirname, '../..'); -const SRC_DIR = join(PKG_DIR, 'src'); - -interface Manifest { - name: string; - main?: string; - module?: string; - sideEffects?: unknown; - exports?: Record | string>; -} - -const manifest: Manifest = JSON.parse(readFileSync(join(PKG_DIR, 'package.json'), 'utf8')); - -/** `"dist/index.js"` and `"./dist/index.js"` name one file; compare on this. */ -const normalize = (p: string): string => (p.startsWith('./') ? p.slice(2) : p); - -/** - * Every module path a bundler can resolve `@object-ui/layout` to, derived from - * the manifest rather than hardcoded — a renamed `fileName` in `vite.config.ts` - * or a new `exports` subpath then shows up here as a missing declaration instead - * of drifting silently. - * - * `src/index.ts` is in the list even though `files` does not publish it, because - * two in-repo consumers bundle the source tree directly by alias - * (`apps/console/vite.config.ts` and `examples/console-starter/vite.config.ts` - * both map the specifier at `packages/layout/src`), and the bundler reads THIS - * manifest for those files too — measured, not assumed: with the published - * paths alone declared, the console's alias shape still produced a 0-byte - * bundle. Publishing is not the only consumption surface. - */ -function resolvableEntryPaths(): string[] { - const found = new Set(); - for (const field of [manifest.main, manifest.module]) { - if (typeof field === 'string') found.add(normalize(field)); - } - for (const target of Object.values(manifest.exports ?? {})) { - const conditions = typeof target === 'string' ? { default: target } : target; - for (const [condition, file] of Object.entries(conditions)) { - // `types` points at a `.d.ts`; type declarations are erased and never - // carry a side effect, so they are not a bundling surface. - if (condition === 'types' || typeof file !== 'string') continue; - found.add(normalize(file)); - } - } - found.add('src/index.ts'); - return [...found].sort(); -} - -const declaredSideEffects = (): string[] => { - const declared = manifest.sideEffects; - expect( - Array.isArray(declared), - 'packages/layout/package.json must declare `sideEffects` as an ARRAY naming the modules that ' + - 'register components at load time (objectui#3899). `false` is the lie this file exists to catch; ' + - '`true` would be honest but hands the whole package to every bundler as unshakeable.', - ).toBe(true); - return declared as string[]; -}; - -/** The bare specifier the probes resolve, so each probe picks its own target. */ -const SPECIFIER = '@object-ui/layout'; - -/** Marker that only survives if the bundler kept the module body. */ -const MARKER = '__objectui_3899_load_time_side_effect__'; - -/** - * Bundle `import '@object-ui/layout';` — nothing else, no named import — with - * `target` supplying the module, and return the emitted code. - * - * Every bare specifier except the package under test is external, so the bundle - * holds this package's own graph and nothing more. `write: false` keeps it in - * memory; measured at ~250ms cold and ~40ms warm, so this is a real build and - * still cheap enough to be an ordinary unit test. - */ -async function bundleSideEffectOnlyImport(target: string): Promise { - const dir = mkdtempSync(join(tmpdir(), 'objectui-3899-entry-')); - try { - const entry = join(dir, 'entry.mjs'); - writeFileSync(entry, `import ${JSON.stringify(SPECIFIER)};\n`); - - const result: unknown = await build({ - configFile: false, - logLevel: 'silent', - resolve: { alias: { [SPECIFIER]: target } }, - build: { - write: false, - minify: false, - lib: { entry, formats: ['es'], fileName: 'bundle' }, - rollupOptions: { - external: (id: string) => !/^[./]/.test(id) && !id.startsWith('/') && id !== SPECIFIER, - }, - }, - }); - - const bundles = Array.isArray(result) ? result : [result]; - const output = (bundles[0] as { output?: Array<{ type: string; code?: string }> } | undefined)?.output; - if (!output) { - throw new Error( - 'The probe build returned no output. It must produce an in-memory bundle to inspect — a watcher ' + - 'or an empty result means this helper needs updating, not that the assertion below passed.', - ); - } - return output - .filter((chunk) => chunk.type === 'chunk') - .map((chunk) => chunk.code ?? '') - .join('\n'); - } finally { - rmSync(dir, { recursive: true, force: true }); - } -} - -/** - * A copy of this package whose manifest is byte-for-byte the real one except for - * `sideEffects`, used to run the same probe under a different declaration. - * - * Copying rather than mutating the real `package.json`: a test that edits its own - * package manifest leaves a dirty tree behind when it fails, and two of these run - * in the same worker. - * - * `entryPaths` get a marker module written at exactly the manifest's own relative - * paths, which is what lets the published `dist/*` forms be probed in an unbuilt - * worktree. Those probes therefore prove that the DECLARED GLOBS MATCH the - * published paths in the bundler's matcher — not that `dist` holds the - * registration. The latter is what `declaresLoadTimeRegistration` (the source - * side) and the package build (the artifact side) are for. - */ -function mirrorPackage(sideEffects: unknown, entryPaths: string[] = []): string { - const dir = mkdtempSync(join(tmpdir(), 'objectui-3899-mirror-')); - writeFileSync(join(dir, 'package.json'), JSON.stringify({ ...manifest, sideEffects }, null, 2)); - - for (const rel of entryPaths) { - const file = join(dir, normalize(rel)); - mkdirSync(dirname(file), { recursive: true }); - // The marker is a global assignment: a side effect no bundler can prove - // away, so its absence means the MODULE was dropped, never that the - // statement was optimised out. - writeFileSync( - file, - `globalThis[${JSON.stringify(MARKER)}] = ${JSON.stringify(rel)};\n` + - (rel.endsWith('.cjs') ? 'module.exports = { unused: 1 };\n' : 'export const unused = 1;\n'), - ); - } - return dir; -} - -/** The source tree, copied so the same probe can run under a different manifest. */ -function mirrorSourceTree(sideEffects: unknown): string { - const dir = mirrorPackage(sideEffects); - cpSync(SRC_DIR, join(dir, 'src'), { - recursive: true, - filter: (src) => !src.includes('__tests__'), - }); - return join(dir, 'src'); -} - -describe('@object-ui/layout declares its load-time registration (objectui#3899)', () => { - it('names every module form a bundler can resolve, and nothing that is not one', () => { - const declared = declaredSideEffects().map(normalize); - const required = resolvableEntryPaths(); - - const missing = required.filter((p) => !declared.includes(p)); - expect( - missing, - [ - 'A module form of @object-ui/layout is not covered by `sideEffects`, so a bundler resolving the', - 'package that way may drop the registration entirely — silently, on a green build (objectui#3899).', - '', - 'Add each path below to `sideEffects` in packages/layout/package.json, spelled with a leading', - `"./" to match how \`exports\` spells the same paths: ${missing.join(', ')}`, - ].join('\n'), - ).toEqual([]); - - // The other direction. An entry naming nothing real is not harmless: it - // reads as "this module has side effects" to the next author and makes the - // list look maintained when it is not. - const phantom = declared.filter((p) => !required.includes(p)); - expect( - phantom, - [ - '`sideEffects` names a path that is not a module form of this package.', - 'If a NEW module genuinely has load-time side effects, teach resolvableEntryPaths() where it', - 'comes from (an `exports` subpath, a build output, a source alias) so the derivation stays the', - `authority. Otherwise delete it: ${phantom.join(', ')}`, - ].join('\n'), - ).toEqual([]); - - // Anti-vacuity: a derivation that silently produced [] would make both - // assertions above pass over nothing. These are the three forms measured on - // main@230ffd875 — the ESM and UMD published entries plus the source alias. - expect(required).toEqual(['dist/index.js', 'dist/index.umd.cjs', 'src/index.ts']); - }); - - it('declaresLoadTimeRegistration: the barrel still registers on evaluation', () => { - const barrel = readFileSync(join(SRC_DIR, 'index.ts'), 'utf8'); - - // A bare `registerLayout()` call at column 0 — module body, not inside the - // function declaration (whose own body is indented). - expect( - /^\s{0,2}registerLayout\(\);/m.test(barrel), - [ - 'src/index.ts no longer calls `registerLayout()` at load time, so the `sideEffects` array in', - 'package.json is now claiming a side effect that does not happen.', - '', - 'This test does NOT ask for the call back. It asks the two halves to agree: if the registration', - 'became an explicit API the consumer calls (the direction objectui#3899 left to the maintainer),', - 'then the honest manifest is `sideEffects: false` and this file should be deleted with the same', - 'change. What it forbids is exactly one of the two moving.', - ].join('\n'), - ).toBe(true); - }); - - it('keeps a side-effect-only import alive through the workspace source alias', async () => { - // The in-place, real-tree probe: the actual src/, read through the actual - // manifest, in the shape apps/console and examples/console-starter bundle. - const code = await bundleSideEffectOnlyImport(SRC_DIR); - - expect( - code, - 'Bundling `import "@object-ui/layout";` through the workspace src alias dropped the component ' + - 'registrations. This is objectui#3899 exactly: a bundler took the manifest at its word. Check ' + - 'that `sideEffects` names "./src/index.ts".', - ).toContain('page-header'); - - // Every key the barrel registers, so losing all but one cannot pass on the - // strength of that one. Read out of the source rather than listed here: - // objectui#3899's own prose named a `sidebar-nav` key that this package has - // never registered, and a hardcoded list is how that kind of mistake becomes - // a test asserting a component that does not exist. - // - // The parenthetical that used to sit in that sentence — "the SidebarNav - // component reaches the registry under `navigation-renderer`" — was the same - // mistake one step further on, and is corrected here (objectui#3999): - // `SidebarNav` reaches the registry under NO key at all. `navigation-renderer` - // is a DIFFERENT component in a different file (`NavigationRenderer.tsx`, - // which never mentions `SidebarNav`); `src/index.ts` imports `SidebarNav` - // only to re-export it. It is consumed as a plain React component in JSX, - // which is why nothing below asserts a key for it and why the README - // documents it as JSX rather than as a JSON node type. - const registeredKeys = [...readFileSync(join(SRC_DIR, 'index.ts'), 'utf8').matchAll( - /ComponentRegistry\.register\(\s*'([^']+)'/g, - )].map((match) => match[1]); - - // A floor, not a census: it exists so a regex that stops matching cannot - // turn the loop below into a no-op. It read `6` until objectui#4841 - // deregistered `app-shell` (ADR-0049 remove side), which is the one way this - // number is allowed to move — DOWN, with a register call deleted in the same - // commit. It is not a licence to delete registrations: the keys themselves - // are pinned by name in `guide-layout-sidebar-nav-doc.test.ts`, against the - // list the guide publishes. - expect( - registeredKeys.length, - 'No `ComponentRegistry.register` call found in src/index.ts — the loop below would assert nothing.', - ).toBeGreaterThanOrEqual(5); - - for (const key of registeredKeys) { - expect(code, `the \`${key}\` registration must survive a side-effect-only import`).toContain(key); - } - }); - - it.each(['./dist/index.js', './dist/index.umd.cjs'])( - 'keeps a side-effect-only import alive through the published entry %s', - async (entryPath) => { - const mirror = mirrorPackage(manifest.sideEffects, [entryPath]); - try { - const code = await bundleSideEffectOnlyImport(join(mirror, normalize(entryPath))); - expect( - code, - `The declared \`sideEffects\` globs do not cover ${entryPath}, so a consumer resolving the ` + - 'published package that way loses the registration. Add the path — this is not a spelling ' + - 'nit: rolldown matches it with or without the leading "./", and through `dist/*.js` too.', - ).toContain(MARKER); - } finally { - rmSync(mirror, { recursive: true, force: true }); - } - }, - ); - - it.each(['./dist/index.js', './dist/index.umd.cjs'])( - 'and the declaration is what keeps it: `sideEffects: false` drops %s', - async (entryPath) => { - // The control. Without it every assertion above could be green because the - // bundler never shakes anything here, and the pin would prove nothing — - // the exact shape of "green for an empty reason". This is objectui#3899's - // reverse verification, run every time rather than once by hand. - const mirror = mirrorPackage(false, [entryPath]); - try { - const code = await bundleSideEffectOnlyImport(join(mirror, normalize(entryPath))); - expect( - code, - `A package declaring \`sideEffects: false\` did NOT lose ${entryPath} on a side-effect-only ` + - 'import. The bundler no longer honours the flag the way this pin assumes, so the probes above ' + - 'have stopped measuring anything — fix the probe before trusting them again.', - ).not.toContain(MARKER); - } finally { - rmSync(mirror, { recursive: true, force: true }); - } - }, - ); - - it('and the same control holds for the source tree', async () => { - const mirrored = mirrorSourceTree(false); - try { - const code = await bundleSideEffectOnlyImport(mirrored); - expect( - code, - 'A copy of this package declaring `sideEffects: false` kept its registrations, so the source-alias ' + - 'probe above is not measuring the manifest at all.', - ).not.toContain('page-header'); - } finally { - rmSync(resolve(mirrored, '..'), { recursive: true, force: true }); - } - }); -}); diff --git a/scripts/__tests__/check-package-self-import.test.ts b/scripts/__tests__/check-package-self-import.test.ts index 25300dde51..12ccd2c7cc 100644 --- a/scripts/__tests__/check-package-self-import.test.ts +++ b/scripts/__tests__/check-package-self-import.test.ts @@ -141,12 +141,24 @@ describe('a package name that is not a module edge is not a finding', () => { // Named rather than left to the repo-wide green assertion, because these are // the files a regex-based rewrite of this gate would break first, and the // breakage would read as a real finding. - for (const file of [ - 'packages/i18n/src/__tests__/perm-home-namespace-3546.test.tsx', - 'packages/layout/src/__tests__/side-effects-manifest.test.ts', - ]) { + // The layout specimen used to be `side-effects-manifest.test.ts`, whose + // `SPECIFIER` constant held `'@object-ui/layout'`. objectui#3943 converged + // that package-scoped pin into the repo-level + // `side-effects-declaration-consistency.test.ts`, and with it gone NO file + // under `packages/layout/src` carries the literal any more — so re-pointing + // this entry at the new file would be wrong twice over: it lives outside + // any package, and a file outside a package cannot import itself. + // + // Replaced rather than dropped, and with a JSDoc specimen from production + // source rather than another test, which is the stronger case: a regex + // rewrite of this gate breaks on a documented `import … from '@object-ui/core'` + // example exactly as it would on a test constant. + const specimens: Record = { + 'packages/i18n/src/__tests__/perm-home-namespace-3546.test.tsx': '@object-ui/i18n', + 'packages/core/src/utils/freeze-schema.ts': '@object-ui/core', + }; + for (const [file, owner] of Object.entries(specimens)) { const body = fs.readFileSync(path.join(repoRoot, file), 'utf8'); - const owner = file.startsWith('packages/i18n') ? '@object-ui/i18n' : '@object-ui/layout'; expect(body, `${file} no longer carries the literal this pin is about`).toContain(`'${owner}'`); } expect((analyze(repoRoot, { exemptions: {} }).findings as Finding[]).map((f) => f.file)).toEqual([]); diff --git a/scripts/__tests__/side-effects-declaration-consistency.test.ts b/scripts/__tests__/side-effects-declaration-consistency.test.ts new file mode 100644 index 0000000000..2312132421 --- /dev/null +++ b/scripts/__tests__/side-effects-declaration-consistency.test.ts @@ -0,0 +1,1133 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import ts from 'typescript'; +import { build } from 'vite'; +import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; + +/** + * objectui#3943: nothing in this repo ever checked that a package's + * `sideEffects` declaration agrees with what its modules actually do when they + * are evaluated. + * + * `sideEffects: false` is a PROMISE to bundlers: "no module in this package + * does anything on evaluation, so drop any whose exports go unused". A bundler + * that believes it does exactly that — silently. There is no error, no warning, + * and exit code 0, because the bundler thinks it is executing your declaration + * rather than discovering a defect. + * + * objectui#3899 is the specimen. `packages/layout` declared `"sideEffects": + * false` while `src/index.ts` put six component keys into the `ComponentRegistry` + * as a load-time side effect. Measured on main@230ffd875 by bundling the + * documented side-effect-only import `import '@object-ui/layout';`: + * + * sideEffects: false -> bundle is 0 bytes, zero registrations + * the honest array -> every `ComponentRegistry.register` call survives + * + * The failure surfaced far away, as a red `Unknown component type` panel + * (OBJUI-001) on a green build. Nobody was bitten only because every consumer + * that day ALSO imported a named export, which forces evaluation regardless — + * coincidence, not design (objectui#3787 hit the hazard and routed around it by + * calling `registerLayout()` explicitly). + * + * PR #3940 fixed layout and pinned it, but that pin was package-scoped: it read + * `packages/layout/package.json` and probed layout's own entry forms. The next + * package to repeat the mistake would turn nothing red. This file is that pin + * generalised over the workspace — the same move objectui#3663 made for `files` + * (`package-files-exist.test.ts`) after the drift was caught by hand twice in + * one week. + * + * ## The two halves, and why both directions are checked + * + * A declaration is a claim about module bodies, so it can be wrong in two ways + * and this gate rejects both: + * + * - `sideEffects: false` + a load-time side effect -> the objectui#3899 lie. + * The registration is dropped and the app breaks far from the cause. + * - `sideEffects: [...]` naming a module that has NO load-time side effect + * -> a phantom claim. It costs bundle size for every consumer and, worse, + * reads to the next author as "this module registers something", which is + * how a real entry gets deleted as noise. + * + * Same rule stated once: the manifest and the module bodies must agree, and + * whichever half moves, the other moves with it. A red test here NEVER means + * "put the side effect back" — it means the two halves disagree, and the fix is + * whichever one is currently false. + * + * ## Why the population is derived from reachability, not from a file glob + * + * `sideEffects: false` licenses the bundler to drop ANY module of the package + * whose exports go unused — not just the barrel. So the honest scan population + * is the barrel plus every module transitively reachable from it by relative + * import: exactly the set a bundler can drop, derived rather than listed. + * + * That also makes a test-file exclusion list unnecessary, which matters more + * than it looks. A `src/**` glob would sweep in the colocated `*.test.ts` files + * (28 top-level `describe(...)` calls in `packages/core/src` alone — measured), + * every one of them a top-level call expression and none of them a bundling + * hazard, because nothing imports them. Excluding them by NAME would then put a + * hole in the gate shaped exactly like a filename. Reachability has no such + * hole: a module the barrel cannot reach cannot be dropped from a barrel + * import, and a module it can reach is scanned whatever it is called. + * + * ## What a real bundler is, and is not, used for here + * + * It is NOT used as the side-effect detector. That was measured before this + * gate was written, by bundling each package's real source under a mirrored + * `sideEffects: true` (which forces the module to be retained, so whatever + * survives is what the bundler considers impure): + * + * @object-ui/i18n -> 1.28 MB retained (`Object.freeze(Object.keys({...}))` + * over the locale tables — module-local, harmless) + * @object-ui/types -> 261 B retained (`new Set(Object.values({...}))`) + * @object-ui/react-runtime-> 33 B retained (bare imports of external deps) + * @object-ui/sdui-parser -> 0 B + * + * Rollup's purity analysis is deliberately conservative, so "the bundler kept + * something" does not mean "this package has an externally-visible side + * effect". Reading those numbers as verdicts would have made this gate red on + * three honest packages on day one. The static scan below asks the narrower, + * decidable question instead: does a module body reach OUT — a bare call, a + * write through a member expression, a side-effect-only import? + * + * The bundler IS used for the one question static analysis cannot answer: does + * the declaration actually change what a bundler emits? Every probe below comes + * with its `sideEffects: false` control, so a bundler that stopped honouring + * the field would fail the control loudly instead of leaving every other + * assertion green over nothing (PR #3940's anti-vacuity discipline, lifted). + * + * ## The workspace-alias trap + * + * The published paths are NOT the only consumption surface. Three in-repo + * bundler configs alias `@object-ui/*` specifiers at a package's `src` + * (`apps/console/vite.config.ts`, `examples/console-starter/vite.config.ts`, + * `packages/fields/vite.config.ts`), and a bundler reads the SAME + * `package.json` for those source files. PR #3940 measured it: with only the + * `dist/*` forms declared, the console's alias shape still produced a 0-byte + * bundle. A gate that skipped alias entries would hand a green light to a + * package that still breaks, so the alias tables are parsed and folded into the + * entry forms below. + * + * The root `vitest.config.mts` has a fourth such table and is deliberately NOT + * read: Vitest transforms and executes modules, it does not tree-shake them, so + * `sideEffects` has no effect on that path. It is not a bundling surface. + * + * ## What this gate deliberately does NOT prove + * + * That a top-level `const x = f()` is pure. A call in a variable INITIALIZER is + * not flagged: module-local consts are the overwhelming majority (225 of them + * in `packages/types` alone), a bundler drops the unused ones, and flagging + * them would bury the real signal. So `const _ = registerLayout();` would slip + * past — a contrived spelling with no instance in the tree today (verified + * across all 157 reachable modules of the five `sideEffects: false` packages), + * and named here rather than papered over. + */ + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + +/** `"dist/index.js"` and `"./dist/index.js"` name one file; compare on this. */ +const normalize = (p: string): string => (p.startsWith('./') ? p.slice(2) : p); + +interface Manifest { + name?: string; + main?: unknown; + module?: unknown; + sideEffects?: unknown; + exports?: unknown; +} + +interface DeclaringPackage { + name: string; + /** Repo-relative, forward-slashed, e.g. `packages/layout`. */ + dir: string; + manifest: Manifest; + /** `false`, or the declared array normalised (no leading `./`). */ + declared: false | string[]; +} + +/** + * The `packages:` globs from `pnpm-workspace.yaml`, read rather than hardcoded + * so a new workspace root is covered the day it is added. Understands only the + * two shapes the file uses (`dir/*` and a bare `dir`); anything else throws + * rather than being skipped, because a guard that silently stops looking at + * part of the workspace goes on reporting success over a shrinking surface. + * (Same derivation as `package-files-exist.test.ts`.) + */ +function workspaceGlobs(): string[] { + const yaml = fs.readFileSync(path.join(repoRoot, 'pnpm-workspace.yaml'), 'utf8'); + const lines = yaml.split('\n'); + const start = lines.findIndex((l) => /^packages:\s*$/.test(l)); + expect(start, '`pnpm-workspace.yaml` must still declare a top-level `packages:` key').toBeGreaterThan(-1); + + const globs: string[] = []; + for (const line of lines.slice(start + 1)) { + if (/^\s*(#.*)?$/.test(line)) continue; + if (!/^\s/.test(line)) break; + const match = line.match(/^\s*-\s*['"]?([^'"#\s]+)['"]?\s*(#.*)?$/); + if (!match) { + throw new Error( + `Unparsed entry in pnpm-workspace.yaml \`packages:\`: ${JSON.stringify(line)} — teach this guard the new syntax.`, + ); + } + globs.push(match[1]); + } + return globs; +} + +function workspacePackageDirs(): string[] { + const dirs: string[] = []; + for (const glob of workspaceGlobs()) { + if (glob.endsWith('/*')) { + const parent = path.join(repoRoot, glob.slice(0, -2)); + if (!fs.existsSync(parent)) continue; + for (const d of fs.readdirSync(parent)) { + const full = path.join(parent, d); + if (fs.statSync(full).isDirectory()) dirs.push(full); + } + } else if (!glob.includes('*')) { + dirs.push(path.join(repoRoot, glob)); + } else { + throw new Error(`Unsupported workspace glob ${JSON.stringify(glob)} — teach this guard how to expand it.`); + } + } + return dirs; +} + +/** + * Every workspace package that DECLARES `sideEffects` as `false` or an array. + * + * `true` is excluded because it is unfalsifiable in the direction that hurts: + * it hands the whole package to every bundler as unshakeable, which is + * conservative and can never lose a registration. An omitted field is the same + * safe default (a bundler assumes side effects), which is why every + * `plugin-*` package — the ones `PluginLoader` dynamically imports — has never + * been exposed to this hazard. + */ +function readDeclaringPackages(): DeclaringPackage[] { + const found: DeclaringPackage[] = []; + for (const dir of workspacePackageDirs()) { + const pkgPath = path.join(dir, 'package.json'); + if (!fs.existsSync(pkgPath)) continue; + const manifest: Manifest = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + if (!manifest.name) continue; + const declared = manifest.sideEffects; + if (declared !== false && !Array.isArray(declared)) continue; + if (Array.isArray(declared) && declared.some((e) => typeof e !== 'string')) { + throw new Error(`${manifest.name} declares a non-string \`sideEffects\` entry — teach this guard that shape.`); + } + found.push({ + name: manifest.name, + dir: path.relative(repoRoot, dir).split(path.sep).join('/'), + manifest, + declared: declared === false ? false : (declared as string[]).map(normalize), + }); + } + return found.sort((a, b) => a.name.localeCompare(b.name)); +} + +const declaringPackages = readDeclaringPackages(); + +/* -------------------------------------------------------------------------- */ +/* Workspace alias tables — the consumption surface that is not `exports`. */ +/* -------------------------------------------------------------------------- */ + +interface AliasEntry { + /** The config that declares it, repo-relative. */ + config: string; + /** The aliased specifier, e.g. `@object-ui/layout`. */ + specifier: string; + /** Absolute path the alias resolves to (a directory or a file). */ + target: string; +} + +/** + * Bundler configs, by strict basename. + * + * The strictness is load-bearing rather than tidy: a `vite.config.*` glob also + * matches `packages/plugin-editor/vite.config.ts.timestamp-*.mjs`, a Vite + * temp-config artifact that is git-tracked even though `.gitignore:99` names + * the pattern (objectui#4540). It is a stale CI-runner snapshot pinned to vite + * 5.4.21 and reading it would feed this guard a table from another era. + */ +const BUNDLER_CONFIG_RE = /^vite\.config\.(ts|mts|cts|js|mjs|cjs)$/; + +/** + * Alias tables parsed out of every bundler config in the workspace, as TEXT. + * + * Read as source rather than imported, for the reason + * `vitest-config-alias-targets-3944.test.ts` gives: these configs pull in the + * whole Vite plugin surface at module scope, and a guard that only needs a list + * of strings should not boot that. + * + * Whole comment lines are stripped first so a commented-out entry cannot be + * counted (and `examples/console-starter/vite.config.ts` documents that it + * deliberately avoids spelling a quoted specifier inside a comment, because + * that decoy already fooled one scan). + */ +function readAliasEntries(): AliasEntry[] { + const entries: AliasEntry[] = []; + for (const dir of workspacePackageDirs()) { + if (!fs.existsSync(dir)) continue; + for (const name of fs.readdirSync(dir)) { + if (!BUNDLER_CONFIG_RE.test(name)) continue; + const configPath = path.join(dir, name); + const source = fs + .readFileSync(configPath, 'utf8') + .split('\n') + .filter((line) => !/^\s*\/\//.test(line) && !/^\s*\/\*.*\*\/\s*$/.test(line)) + .join('\n'); + + const re = /'(@object-ui\/[^']+)':\s*path\.resolve\(\s*(?:import\.meta\.dirname|__dirname)\s*,\s*'([^']+)'\s*\)/g; + for (const match of source.matchAll(re)) { + entries.push({ + config: path.relative(repoRoot, configPath).split(path.sep).join('/'), + specifier: match[1], + target: path.resolve(dir, match[2]), + }); + } + } + } + return entries; +} + +const aliasEntries = readAliasEntries(); + +/** Extensions a bundler tries, in Vite's own order. `.tsx` is why this matters. */ +const RESOLVE_EXTENSIONS = ['.mjs', '.js', '.mts', '.ts', '.jsx', '.tsx']; + +/** + * The module an alias target names: a file is itself, a directory is the barrel + * inside it. + * + * The directory case is the one in the tree today, and resolving it here rather + * than assuming `index.ts` is what makes `@object-ui/react-runtime` (whose + * barrel is `index.tsx`) come out right — the exact spelling objectui#3943 + * warns a naive `index.ts` glob would miss. + */ +function resolveAliasModule(target: string): string | undefined { + if (fs.existsSync(target) && fs.statSync(target).isFile()) return target; + if (fs.existsSync(target) && fs.statSync(target).isDirectory()) { + for (const ext of RESOLVE_EXTENSIONS) { + const candidate = path.join(target, `index${ext}`); + if (fs.existsSync(candidate)) return candidate; + } + } + return undefined; +} + +/** + * Every module path a bundler can resolve a package to, package-relative. + * + * Lifted from PR #3940's `resolvableEntryPaths()` and generalised: derived from + * the manifest and the alias tables rather than hardcoded, so a renamed + * `fileName` in a `vite.config.ts`, a new `exports` subpath, or a new alias + * table shows up here as a missing declaration instead of drifting silently. + */ +function resolvableEntryPaths(pkg: DeclaringPackage): string[] { + const found = new Set(); + + for (const field of [pkg.manifest.main, pkg.manifest.module]) { + if (typeof field === 'string') found.add(normalize(field)); + } + + const walk = (node: unknown): void => { + if (node === null || node === undefined) return; + if (typeof node === 'string') { + if (node.startsWith('./') || !node.includes('/')) found.add(normalize(node)); + return; + } + if (typeof node !== 'object') return; + for (const [key, value] of Object.entries(node as Record)) { + // `types` points at a `.d.ts`; type declarations are erased and never + // carry a side effect, so they are not a bundling surface. + if (key === 'types') continue; + walk(value); + } + }; + walk(pkg.manifest.exports); + + for (const alias of aliasEntries) { + if (alias.specifier !== pkg.name && !alias.specifier.startsWith(`${pkg.name}/`)) continue; + const module = resolveAliasModule(alias.target); + if (module === undefined) continue; + const rel = path.relative(path.join(repoRoot, pkg.dir), module).split(path.sep).join('/'); + if (!rel.startsWith('..')) found.add(rel); + } + + return [...found].filter((p) => !p.includes('*')).sort(); +} + +/** Entry forms that are source files in this tree (as opposed to build output). */ +const isSourceEntry = (pkg: DeclaringPackage, entry: string): boolean => + fs.existsSync(path.join(repoRoot, pkg.dir, entry)); + +/* -------------------------------------------------------------------------- */ +/* The static scan: top-level statements that reach OUT of the module. */ +/* -------------------------------------------------------------------------- */ + +interface SideEffectStatement { + /** Repo-relative file. */ + file: string; + line: number; + /** `top-level call`, `top-level write`, `side-effect-only import`, ... */ + kind: string; + /** The first line of the statement, for the failure message. */ + text: string; +} + +/** + * Whether a node contains something whose evaluation can be observed outside + * this module: a call, a construction, or a write through a member expression + * (`globalThis.x = …`, `Registry.foo = …`). + * + * A call is treated as impure without trying to prove otherwise. That is the + * safe direction for a gate — `registerLayout()` and `console.log()` are + * indistinguishable here, and a package promising `sideEffects: false` should + * be doing neither at load time. + */ +function impureNode(node: ts.Node): ts.Node | undefined { + let hit: ts.Node | undefined; + const walk = (n: ts.Node): void => { + if (hit) return; + if (ts.isCallExpression(n) || ts.isNewExpression(n)) hit = n; + else if ( + ts.isBinaryExpression(n) && + n.operatorToken.kind === ts.SyntaxKind.EqualsToken && + (ts.isPropertyAccessExpression(n.left) || ts.isElementAccessExpression(n.left)) + ) + hit = n; + else ts.forEachChild(n, walk); + }; + walk(node); + return hit; +} + +/** Statement kinds that RUN when the module is evaluated (as opposed to declaring). */ +function isExecutedStatement(stmt: ts.Statement): boolean { + return ( + ts.isIfStatement(stmt) || + ts.isForStatement(stmt) || + ts.isForOfStatement(stmt) || + ts.isForInStatement(stmt) || + ts.isWhileStatement(stmt) || + ts.isDoStatement(stmt) || + // `try { registerLayout(); } catch {}` — objectui#3899's actual shape, and + // the reason this gate cannot just look for ExpressionStatements. + ts.isTryStatement(stmt) || + ts.isSwitchStatement(stmt) || + ts.isBlock(stmt) || + ts.isLabeledStatement(stmt) || + ts.isThrowStatement(stmt) + ); +} + +interface ModuleScan { + effects: SideEffectStatement[]; + /** Relative specifiers this module imports or re-exports from. */ + relativeSpecifiers: string[]; +} + +function scanModule(absFile: string): ModuleScan { + const source = ts.createSourceFile( + absFile, + fs.readFileSync(absFile, 'utf8'), + ts.ScriptTarget.Latest, + /* setParentNodes */ true, + ); + const rel = path.relative(repoRoot, absFile).split(path.sep).join('/'); + const at = (n: ts.Node): number => source.getLineAndCharacterOfPosition(n.getStart(source)).line + 1; + const firstLine = (n: ts.Node): string => n.getText(source).split('\n')[0].trim().slice(0, 120); + + const effects: SideEffectStatement[] = []; + const relativeSpecifiers: string[] = []; + + for (const stmt of source.statements) { + if (ts.isImportDeclaration(stmt) || ts.isExportDeclaration(stmt)) { + const specifier = stmt.moduleSpecifier; + if (specifier && ts.isStringLiteral(specifier)) { + if (specifier.text.startsWith('.')) relativeSpecifiers.push(specifier.text); + // `import './register';` / `import './styles.css';` — the module is + // pulled in for its evaluation alone, which is precisely what + // `sideEffects: false` tells a bundler it may skip. + if (ts.isImportDeclaration(stmt) && !stmt.importClause) { + effects.push({ file: rel, line: at(stmt), kind: 'side-effect-only import', text: firstLine(stmt) }); + } + } + continue; + } + + if (ts.isExpressionStatement(stmt)) { + // A bare string is a directive prologue (`'use client'`), not a statement + // anything can observe. + if (ts.isStringLiteral(stmt.expression)) continue; + const hit = impureNode(stmt); + if (hit) { + effects.push({ + file: rel, + line: at(stmt), + kind: ts.isBinaryExpression(hit) ? 'top-level write' : 'top-level call', + text: firstLine(stmt), + }); + } + continue; + } + + if (isExecutedStatement(stmt) && impureNode(stmt)) { + effects.push({ + file: rel, + line: at(stmt), + kind: `top-level ${ts.SyntaxKind[stmt.kind].replace(/Statement$/, '').toLowerCase()}`, + text: firstLine(stmt), + }); + } + } + + return { effects, relativeSpecifiers }; +} + +/** + * Resolve a relative specifier the way this repo's TypeScript sources spell + * them: ESM-style, with a `.js` extension naming the `.ts` file next to it. + * Getting this wrong does not fail loudly — it silently truncates the reachable + * set, so an unresolved specifier is reported by the caller rather than + * skipped. + */ +function resolveRelative(fromFile: string, specifier: string): string | undefined { + const rewritten = specifier + .replace(/\.js$/, '.ts') + .replace(/\.jsx$/, '.tsx') + .replace(/\.mjs$/, '.mts'); + for (const candidate of [rewritten, specifier]) { + const abs = path.resolve(path.dirname(fromFile), candidate); + if (fs.existsSync(abs) && fs.statSync(abs).isFile()) return abs; + } + const base = path.resolve(path.dirname(fromFile), specifier.replace(/\.(js|jsx|mjs)$/, '')); + for (const ext of RESOLVE_EXTENSIONS) { + if (fs.existsSync(base + ext)) return base + ext; + } + if (fs.existsSync(base) && fs.statSync(base).isDirectory()) { + for (const ext of RESOLVE_EXTENSIONS) { + const index = path.join(base, `index${ext}`); + if (fs.existsSync(index)) return index; + } + } + return undefined; +} + +interface EntryGraph { + /** Every module reachable from the entry, absolute paths. */ + modules: string[]; + effects: SideEffectStatement[]; + /** Specifiers that could not be resolved — reported, never silently dropped. */ + unresolved: string[]; +} + +/** + * The barrel plus every module reachable from it by relative import: the set a + * bundler may drop when the package declares `sideEffects: false`, and + * therefore the set that declaration is a promise about. + * + * Bare specifiers stop the walk. `@object-ui/core` imported from + * `@object-ui/layout` is core's own manifest's problem, and core is scanned in + * its own right when it declares the field. + */ +function walkEntryGraph(entryFile: string): EntryGraph { + const seen = new Set(); + const effects: SideEffectStatement[] = []; + const unresolved: string[] = []; + const stack = [entryFile]; + + while (stack.length > 0) { + const file = stack.pop()!; + if (seen.has(file)) continue; + seen.add(file); + if (!/\.(ts|tsx|mts|js|jsx|mjs)$/.test(file) || /\.d\.ts$/.test(file)) continue; + + const { effects: found, relativeSpecifiers } = scanModule(file); + effects.push(...found); + for (const specifier of relativeSpecifiers) { + const resolved = resolveRelative(file, specifier); + if (resolved) stack.push(resolved); + else unresolved.push(`${path.relative(repoRoot, file)} -> ${specifier}`); + } + } + + return { modules: [...seen], effects, unresolved }; +} + +/** + * The source barrel of a package: the module its workspace alias resolves to. + * + * Derived from the alias tables rather than from a filename convention, so + * `@object-ui/react-runtime`'s `src/index.tsx` is found by the same code path + * as everyone else's `src/index.ts`. + */ +function sourceBarrel(pkg: DeclaringPackage): string | undefined { + const entries = resolvableEntryPaths(pkg).filter((entry) => isSourceEntry(pkg, entry)); + const barrel = entries.find((entry) => /(^|\/)index\.(ts|tsx|mts|js|jsx|mjs)$/.test(entry)); + return barrel === undefined ? undefined : path.join(repoRoot, pkg.dir, barrel); +} + +/* -------------------------------------------------------------------------- */ +/* The real-bundler probes (PR #3940's, hoisted and generalised). */ +/* -------------------------------------------------------------------------- */ + +/** The bare specifier the probes resolve, so each probe picks its own target. */ +const SPECIFIER = '@objectui-3943/probe'; + +/** A global write: a side effect no bundler can prove away, so its absence + * means the MODULE was dropped, never that the statement was optimised out. */ +const MARKER = '__objectui_3943_load_time_side_effect__'; + +/** + * Bundle `import '';` — nothing else, no named import — with + * `target` supplying the module, and return the emitted code. + * + * Every bare specifier except the package under test is external, so the bundle + * holds that package's own graph and nothing more. `write: false` keeps it in + * memory; measured at ~250ms cold and ~40ms warm, so this is a real build and + * still cheap enough to be an ordinary unit test. + */ +async function bundleSideEffectOnlyImport(target: string): Promise { + const dir = mkdtempSync(path.join(tmpdir(), 'objectui-3943-entry-')); + try { + const entry = path.join(dir, 'entry.mjs'); + writeFileSync(entry, `import ${JSON.stringify(SPECIFIER)};\n`); + + const result: unknown = await build({ + configFile: false, + logLevel: 'silent', + resolve: { alias: { [SPECIFIER]: target } }, + build: { + write: false, + minify: false, + lib: { entry, formats: ['es'], fileName: 'bundle' }, + rollupOptions: { + external: (id: string) => !/^[./]/.test(id) && !id.startsWith('/') && id !== SPECIFIER, + }, + }, + }); + + const bundles = Array.isArray(result) ? result : [result]; + const output = (bundles[0] as { output?: Array<{ type: string; code?: string }> } | undefined)?.output; + if (!output) { + throw new Error( + 'The probe build returned no output. It must produce an in-memory bundle to inspect — a watcher ' + + 'or an empty result means this helper needs updating, not that the assertion below passed.', + ); + } + return output + .filter((chunk) => chunk.type === 'chunk') + .map((chunk) => chunk.code ?? '') + .join('\n'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +/** + * A copy of a package whose manifest is byte-for-byte the real one except for + * `sideEffects`, with a marker module written at each of `entryPaths`. + * + * Copying rather than mutating the real `package.json`: a test that edits a + * package manifest leaves a dirty tree behind when it fails, and several of + * these run in the same worker. + * + * Writing markers at the manifest's own relative paths is what lets the + * published `dist/*` forms be probed in an UNBUILT worktree. Those probes + * therefore prove that the declared globs MATCH the published paths in the + * bundler's matcher — not that `dist` holds the registration. The static scan + * above is the source side of that; the package build is the artifact side. + */ +function mirrorPackage(pkg: DeclaringPackage, sideEffects: unknown, entryPaths: string[]): string { + const dir = mkdtempSync(path.join(tmpdir(), 'objectui-3943-mirror-')); + writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ ...pkg.manifest, sideEffects }, null, 2)); + + for (const rel of entryPaths) { + const file = path.join(dir, normalize(rel)); + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync( + file, + `globalThis[${JSON.stringify(MARKER)}] = ${JSON.stringify(rel)};\n` + + (rel.endsWith('.cjs') ? 'module.exports = { unused: 1 };\n' : 'export const unused = 1;\n'), + ); + } + return dir; +} + +/** A package's real source tree, copied so the same probe can run under a + * different manifest. Returns the mirrored `src` directory. */ +function mirrorSourceTree(pkg: DeclaringPackage, sideEffects: unknown): string { + const dir = mirrorPackage(pkg, sideEffects, []); + cpSync(path.join(repoRoot, pkg.dir, 'src'), path.join(dir, 'src'), { + recursive: true, + filter: (src) => !src.includes('__tests__'), + }); + return path.join(dir, 'src'); +} + +/* -------------------------------------------------------------------------- */ +/* Derived populations. */ +/* -------------------------------------------------------------------------- */ + +const falsePackages = declaringPackages.filter((p) => p.declared === false); +const arrayPackages = declaringPackages.filter((p) => p.declared !== false); + +const entryForms = new Map(declaringPackages.map((p) => [p.name, resolvableEntryPaths(p)])); + +const entryGraphs = new Map( + declaringPackages.flatMap((pkg) => { + const barrel = sourceBarrel(pkg); + return barrel === undefined ? [] : [[pkg.name, walkEntryGraph(barrel)] as [string, EntryGraph]]; + }), +); + +interface ProbeCase { + pkg: DeclaringPackage; + name: string; + entry: string; +} + +/** Every entry form of every ARRAY package: the array's whole content is the claim. */ +const arrayEntryProbes: ProbeCase[] = arrayPackages.flatMap((pkg) => + (entryForms.get(pkg.name) ?? []).map((entry) => ({ pkg, name: pkg.name, entry })), +); + +/** + * The source barrel of every `sideEffects: false` package. + * + * Only the barrel, and the reason is a boundary rather than a shortcut: a + * `false` package's `dist/*` forms are COMPILED FROM the source this gate + * statically scanned, so the source verdict transfers to them. What still needs + * a bundler is that the flag is honoured at all, and that the barrel path + * derived above is really the module a bundler picks — including the `index.tsx` + * spelling, which is the one objectui#3943 warns a naive glob would miss. + */ +const falseBarrelProbes: ProbeCase[] = falsePackages.flatMap((pkg) => { + const barrel = sourceBarrel(pkg); + if (barrel === undefined) return []; + const entry = path.relative(path.join(repoRoot, pkg.dir), barrel).split(path.sep).join('/'); + return [{ pkg, name: pkg.name, entry }]; +}); + +/* -------------------------------------------------------------------------- */ + +describe('`sideEffects` declarations match load-time behaviour (objectui#3943)', () => { + it('discovers the declaring packages (guard cannot pass by finding nothing)', () => { + // The hazard here is a SILENT one, so a guard that inspected an empty list + // would reproduce it exactly: green output, nothing checked. These floors + // sit at the census measured on main@97da1b0d6 — 8 packages declare the + // field, 5 of them `false` and 1 an array — so a lost workspace root, a + // broken glob parser or a renamed field is a failure and not a quiet pass. + expect(declaringPackages.length).toBeGreaterThanOrEqual(6); + expect(falsePackages.length).toBeGreaterThanOrEqual(5); + expect(arrayPackages.length).toBeGreaterThanOrEqual(1); + + // The census by name. `components`/`fields` declare `true` and are excluded + // on purpose: `true` is the conservative claim and can never lose a + // registration, so it is not falsifiable in the direction that hurts. + expect(declaringPackages.map((p) => p.name)).toEqual([ + '@object-ui/core', + '@object-ui/i18n', + '@object-ui/layout', + '@object-ui/react-runtime', + '@object-ui/sdui-parser', + '@object-ui/types', + ]); + + // The specimen must be in scope BY NAME and still be the array case: if + // layout drops out, this gate has stopped watching the exact spot that + // motivated it (objectui#3899 / PR #3940). + const layout = declaringPackages.find((p) => p.name === '@object-ui/layout'); + expect(layout, '@object-ui/layout must still be scanned').toBeDefined(); + expect(Array.isArray(layout!.declared), '@object-ui/layout must still declare the ARRAY form').toBe(true); + }); + + it('parses the workspace alias tables (the surface `exports` cannot see)', () => { + // Anti-vacuity for the alias derivation specifically. If the regex stops + // matching — a config reformatted, `import.meta.dirname` swapped for + // something else — every alias entry silently disappears from the entry + // forms, and this gate goes back to blessing exactly the shape PR #3940 + // measured as still-broken. + const configs = [...new Set(aliasEntries.map((a) => a.config))].sort(); + expect(configs).toEqual([ + 'apps/console/vite.config.ts', + 'examples/console-starter/vite.config.ts', + 'packages/fields/vite.config.ts', + ]); + expect(aliasEntries.length).toBeGreaterThanOrEqual(60); + + // The trap, named: `@object-ui/layout` aliased at `packages/layout/src` is + // the entry form that made the console's bundle 0 bytes while every + // published path was declared. + const layoutAliases = aliasEntries.filter((a) => a.specifier === '@object-ui/layout'); + expect(layoutAliases.map((a) => a.config).sort()).toEqual([ + 'apps/console/vite.config.ts', + 'examples/console-starter/vite.config.ts', + ]); + for (const alias of layoutAliases) { + expect(alias.target).toBe(path.join(repoRoot, 'packages/layout/src')); + } + }); + + it('resolves a directory alias to the real barrel, `.tsx` spelling included', () => { + // The `.ts`/`.tsx` half of the issue, asserted on the derivation rather + // than left to the packages that happen to exist. `@object-ui/react-runtime` + // is the live `.tsx` specimen; a derivation hardcoded to `index.ts` would + // find nothing for it and silently scan an empty entry graph. + const runtime = declaringPackages.find((p) => p.name === '@object-ui/react-runtime')!; + expect(sourceBarrel(runtime)).toBe(path.join(repoRoot, 'packages/react-runtime/src/index.tsx')); + + const core = declaringPackages.find((p) => p.name === '@object-ui/core')!; + expect(sourceBarrel(core)).toBe(path.join(repoRoot, 'packages/core/src/index.ts')); + + for (const pkg of declaringPackages) { + expect(sourceBarrel(pkg), `${pkg.name}: no source barrel resolved, so nothing would be scanned`).toBeDefined(); + } + }); + + it('derives every entry form, including the workspace-alias one', () => { + // The derivation pinned against the three forms PR #3940 measured for the + // specimen — the ESM and UMD published entries plus the source alias. A + // derivation that silently produced [] would make the coverage assertions + // below pass over nothing. + expect(entryForms.get('@object-ui/layout')).toEqual(['dist/index.js', 'dist/index.umd.cjs', 'src/index.ts']); + + for (const pkg of declaringPackages) { + const forms = entryForms.get(pkg.name) ?? []; + expect(forms.length, `${pkg.name}: no entry forms derived`).toBeGreaterThan(0); + expect( + forms.some((entry) => isSourceEntry(pkg, entry)), + `${pkg.name}: no entry form resolves to a file in this tree, so the source side is unverifiable`, + ).toBe(true); + } + }); + + it('the entry-graph walk resolves every relative specifier it meets', () => { + // An unresolved specifier does not fail loudly on its own — it just + // truncates the reachable set, which SHRINKS the population the scan below + // examines. That is the quiet way this gate would stop working, so it is + // reported as its own failure. (The repo spells relative imports ESM-style, + // `./x.js` naming `./x.ts`; missing that rewrite left 40 of core's modules + // unreachable while the scan still reported green.) + const unresolved = [...entryGraphs.entries()].flatMap(([name, graph]) => + graph.unresolved.map((u) => `${name}: ${u}`), + ); + expect( + unresolved, + [ + 'A relative import could not be resolved, so the module it names was never scanned.', + 'Teach resolveRelative() the spelling — do not leave the reachable set truncated.', + '', + ...unresolved, + ].join('\n'), + ).toEqual([]); + + // And the walk must actually reach a meaningful number of modules. + // Measured on main@97da1b0d6: core 87, i18n 25, types 39, sdui-parser 5, + // react-runtime 1, layout 8. + const total = [...entryGraphs.values()].reduce((sum, g) => sum + g.modules.length, 0); + expect(total, 'the entry graphs collapsed to almost nothing — the walk is not walking').toBeGreaterThanOrEqual(150); + }); + + it('no `sideEffects: false` package has a load-time side effect', () => { + // THE objectui#3899 DIRECTION. A package promising `false` while a module in + // its entry graph reaches out on evaluation is the lie that bundles to 0 + // bytes with exit code 0. + const violations = falsePackages.flatMap((pkg) => + (entryGraphs.get(pkg.name)?.effects ?? []).map( + (effect) => `${pkg.name} declares "sideEffects": false, but ${effect.file}:${effect.line} is a ` + + `${effect.kind}: ${effect.text}`, + ), + ); + + expect( + violations, + [ + 'A package promises bundlers that nothing happens when its modules are evaluated, and then', + 'something happens. A bundler takes the promise at its word: the module is dropped whole, the', + 'side effect never runs, and the build is green with no warning (objectui#3899/#3943).', + '', + 'Fix it in whichever direction is true — never by deleting this finding:', + ' - the side effect is intended -> declare `sideEffects` as an ARRAY naming every entry form', + ' (see packages/layout/package.json; the array must cover the', + ' workspace-alias `src` form too, not just dist/*)', + ' - the side effect is a bug -> move it into an exported function the consumer calls', + '', + ...violations, + ].join('\n'), + ).toEqual([]); + }); + + it('every `sideEffects` array covers all of its package’s entry forms', () => { + // PR #3940's assertion, generalised. A form the array does not name may be + // dropped whole by a bundler resolving the package that way. + const missing = arrayPackages.flatMap((pkg) => { + const declared = pkg.declared as string[]; + return (entryForms.get(pkg.name) ?? []) + .filter((entry) => !declared.includes(entry)) + .map((entry) => `${pkg.name}: "${entry}" is a resolvable module form and is not in \`sideEffects\``); + }); + + expect( + missing, + [ + 'A module form is not covered by `sideEffects`, so a bundler resolving the package that way may', + 'drop the side effect entirely — silently, on a green build (objectui#3899/#3943).', + '', + 'Add each path below to that package.json\'s `sideEffects`, spelled with a leading "./" to match', + 'how `exports` spells the same paths. Note the spelling is NOT what a bundler is sensitive to', + '(measured on vite 8 / rolldown: "./dist/index.js", "dist/index.js" and "dist/*.js" all match the', + 'same file), so a finding here means the path is not covered AT ALL.', + '', + ...missing, + ].join('\n'), + ).toEqual([]); + + // The other direction. An entry naming nothing real is not harmless: it + // reads as "this module has side effects" to the next author and makes the + // list look maintained when it is not. + const phantom = arrayPackages.flatMap((pkg) => { + const forms = entryForms.get(pkg.name) ?? []; + return (pkg.declared as string[]) + .filter((entry) => !forms.includes(entry)) + .map((entry) => `${pkg.name}: "${entry}" is not a module form of this package`); + }); + + expect( + phantom, + [ + '`sideEffects` names a path that is not a resolvable module form of its package.', + 'If a NEW module genuinely has load-time side effects, teach resolvableEntryPaths() where it comes', + 'from (an `exports` subpath, a build output, a bundler alias) so the derivation stays the', + 'authority. Otherwise delete it.', + '', + ...phantom, + ].join('\n'), + ).toEqual([]); + }); + + it('every declared source entry really does have a load-time side effect', () => { + // The converse claim, and the generalised form of PR #3940's + // `declaresLoadTimeRegistration`. An array entry is a promise that this + // module DOES something on evaluation; when that stops being true the + // honest manifest is `false` again. + // + // Only the source entries can be checked statically — `dist/*` is build + // output, absent in an unbuilt worktree and compiled from the source that + // IS checked here. + const phantomClaims = arrayPackages.flatMap((pkg) => + (pkg.declared as string[]) + .filter((entry) => isSourceEntry(pkg, entry)) + .filter((entry) => scanModule(path.join(repoRoot, pkg.dir, entry)).effects.length === 0) + .map((entry) => `${pkg.name}: "${entry}" is declared in \`sideEffects\` but has no top-level side effect`), + ); + + expect( + phantomClaims, + [ + 'A `sideEffects` array names a module that does nothing when it is evaluated.', + '', + 'This test does NOT ask for the side effect back. It asks the two halves to agree: if a load-time', + 'registration became an explicit API the consumer calls (the direction objectui#3899 left to the', + 'maintainer), then the honest manifest is `sideEffects: false` and the entry should go with it.', + 'What is forbidden is exactly one of the two moving.', + '', + ...phantomClaims, + ].join('\n'), + ).toEqual([]); + + // Anti-vacuity: the loop must have something to walk. The specimen's + // `src/index.ts` is the one source entry any array declares today, and its + // `try { registerLayout(); } catch {}` is the effect being detected. + const sourceEntries = arrayPackages.flatMap((pkg) => + (pkg.declared as string[]).filter((entry) => isSourceEntry(pkg, entry)).map((entry) => `${pkg.name}/${entry}`), + ); + expect(sourceEntries).toContain('@object-ui/layout/src/index.ts'); + }); + + it('no array package declares a wildcard entry (this guard resolves literal paths)', () => { + // `exports` may use `*` patterns and `@object-ui/i18n` does (`./locales/*`). + // A pattern resolves to no single file, so it is dropped from the derived + // forms — harmless while only `false` packages have one, and a false + // "phantom" report the day an array package grows one. Naming it as its own + // failure means the next author gets "teach the guard" instead of a baffling + // verdict on a path they know is fine. + const wildcards = arrayPackages.flatMap((pkg) => + (pkg.declared as string[]) + .filter((entry) => entry.includes('*')) + .map((entry) => `${pkg.name}: "${entry}"`), + ); + expect( + wildcards, + ['A `sideEffects` array entry uses a glob, which this guard does not expand.', ...wildcards].join('\n'), + ).toEqual([]); + }); +}); + +/** + * The probes. Everything above is static: it reads manifests and parses source. + * None of it can answer the question the whole hazard turns on — does the + * declaration actually change what a bundler emits? + * + * Each probe therefore comes in a PAIR: the real declaration must keep the + * module, and `sideEffects: false` must drop the identical mirror. Without the + * control every positive probe could be green because the bundler never shakes + * anything here, which is the exact shape of "green for an empty reason" + * (PR #3940 wrote this discipline down; this is it applied per package). + */ +describe('a real bundler honours the declarations (objectui#3943)', () => { + it('has probes to run (the pairs below cannot pass by being empty)', () => { + // `it.each([])` is a collection-time error rather than a failure, so the + // populations are asserted here where the message can say why. + expect(arrayEntryProbes.length, 'no array entry forms to probe').toBeGreaterThanOrEqual(3); + expect(falseBarrelProbes.length, 'no `sideEffects: false` barrels to probe').toBeGreaterThanOrEqual(5); + }); + + it.each(arrayEntryProbes)('$name declares $entry, so a side-effect-only import keeps it', async ({ pkg, entry }) => { + const mirror = mirrorPackage(pkg, pkg.manifest.sideEffects, [entry]); + try { + const code = await bundleSideEffectOnlyImport(path.join(mirror, entry)); + expect( + code, + `The declared \`sideEffects\` of ${pkg.name} do not cover ${entry}, so a consumer resolving the ` + + 'package that way loses whatever that module does on evaluation. Add the path — this is not a ' + + 'spelling nit: rolldown matches it with or without the leading "./", and through `dist/*.js` too.', + ).toContain(MARKER); + } finally { + rmSync(mirror, { recursive: true, force: true }); + } + }); + + it.each(arrayEntryProbes)('and the declaration is what keeps it: `false` drops $name $entry', async ({ pkg, entry }) => { + const mirror = mirrorPackage(pkg, false, [entry]); + try { + const code = await bundleSideEffectOnlyImport(path.join(mirror, entry)); + expect( + code, + `A package declaring \`sideEffects: false\` did NOT lose ${entry} on a side-effect-only import. ` + + 'The bundler no longer honours the flag the way these probes assume, so every assertion above ' + + 'has stopped measuring anything — fix the probe before trusting it again.', + ).not.toContain(MARKER); + } finally { + rmSync(mirror, { recursive: true, force: true }); + } + }); + + it.each(falseBarrelProbes)('$name: $entry is the module a bundler resolves the alias to', async ({ pkg, entry }) => { + // The derivation's own probe. Aliasing to the DIRECTORY (which is how + // apps/console and examples/console-starter spell it) and declaring only + // this one path means the marker survives if — and only if — the bundler + // picks exactly the barrel this gate scanned. A `.tsx` barrel that the + // derivation had guessed as `.ts` fails right here instead of leaving an + // empty scan reported as green. + const mirror = mirrorPackage(pkg, [`./${entry}`], [entry]); + try { + const code = await bundleSideEffectOnlyImport(path.dirname(path.join(mirror, entry))); + expect( + code, + `Aliasing ${pkg.name} at its source directory did not resolve to ${entry}, the module this gate ` + + 'statically scans. The scan and the bundler disagree about which file the barrel is, so the ' + + 'scan is guarding a module nobody loads.', + ).toContain(MARKER); + } finally { + rmSync(mirror, { recursive: true, force: true }); + } + }); + + it.each(falseBarrelProbes)('$name: `sideEffects: false` drops $entry, which is why the scan matters', async ({ pkg, entry }) => { + // The control that gives the static scan its teeth: it measures that a + // `false` declaration really does license a bundler to drop this package's + // barrel. If it ever stops being true, the scan above is guarding against a + // consequence that no longer follows. + const mirror = mirrorPackage(pkg, false, [entry]); + try { + const code = await bundleSideEffectOnlyImport(path.dirname(path.join(mirror, entry))); + expect( + code, + `${pkg.name} declares \`sideEffects: false\`, but a side-effect-only import of its barrel KEPT the ` + + 'module. Either the bundler stopped honouring the field or this probe stopped resolving — until ' + + 'that is fixed, "no load-time side effect" is an unenforced claim here.', + ).not.toContain(MARKER); + } finally { + rmSync(mirror, { recursive: true, force: true }); + } + }); +}); + +/** + * The objectui#3899 specimen, converged here from + * `packages/layout/src/__tests__/side-effects-manifest.test.ts` (PR #3940). + * + * Everything else in that file is now derived above and applies to every + * package: the entry-form derivation, the array coverage in both directions, + * the "the declaration is what keeps it" controls, and the + * `declaresLoadTimeRegistration` invariant (generalised as "a declared source + * entry really does have a load-time side effect"). + * + * What could NOT be generalised is this: the marker probes prove that SOME + * module survives at each declared path, but layout's actual payload is a set + * of registry keys, and only a bundle of the REAL source can show those + * specific keys surviving. That is the assertion objectui#3787 needed and the + * one OBJUI-001 reports the absence of, so it is carried over verbatim rather + * than folded into a marker. + */ +describe('objectui#3899 specimen: @object-ui/layout registers through the source alias', () => { + const layout = declaringPackages.find((p) => p.name === '@object-ui/layout')!; + const layoutSrc = path.join(repoRoot, 'packages/layout/src'); + + it('keeps a side-effect-only import alive through the workspace source alias', async () => { + // The in-place, real-tree probe: the actual src/, read through the actual + // manifest, in the shape apps/console and examples/console-starter bundle. + const code = await bundleSideEffectOnlyImport(layoutSrc); + + expect( + code, + 'Bundling `import "@object-ui/layout";` through the workspace src alias dropped the component ' + + 'registrations. This is objectui#3899 exactly: a bundler took the manifest at its word. Check ' + + 'that `sideEffects` names "./src/index.ts".', + ).toContain('page-header'); + + // Every key the barrel registers, so losing all but one cannot pass on the + // strength of that one. Read out of the source rather than listed here: + // objectui#3899's own prose named a `sidebar-nav` key that this package has + // never registered, and a hardcoded list is how that kind of mistake becomes + // a test asserting a component that does not exist. (`SidebarNav` reaches + // the registry under NO key at all — it is consumed as a plain React + // component in JSX, corrected in objectui#3999.) + const registeredKeys = [ + ...fs.readFileSync(path.join(layoutSrc, 'index.ts'), 'utf8').matchAll(/ComponentRegistry\.register\(\s*'([^']+)'/g), + ].map((match) => match[1]); + + // A floor, not a census: it exists so a regex that stops matching cannot + // turn the loop below into a no-op. It read `6` until objectui#4841 + // deregistered `app-shell` (ADR-0049 remove side), which is the one way this + // number is allowed to move — DOWN, with a register call deleted in the same + // commit. The keys themselves are pinned by name in + // `packages/layout/src/__tests__/guide-layout-sidebar-nav-doc.test.ts`. + expect( + registeredKeys.length, + 'No `ComponentRegistry.register` call found in src/index.ts — the loop below would assert nothing.', + ).toBeGreaterThanOrEqual(5); + + for (const key of registeredKeys) { + expect(code, `the \`${key}\` registration must survive a side-effect-only import`).toContain(key); + } + }); + + it('and the same control holds for the source tree', async () => { + const mirrored = mirrorSourceTree(layout, false); + try { + const code = await bundleSideEffectOnlyImport(mirrored); + expect( + code, + 'A copy of this package declaring `sideEffects: false` kept its registrations, so the source-alias ' + + 'probe above is not measuring the manifest at all.', + ).not.toContain('page-header'); + } finally { + rmSync(path.resolve(mirrored, '..'), { recursive: true, force: true }); + } + }); +}); From e08d541d58ad6762b5fad738a31ee9784afb3af9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 03:51:59 +0000 Subject: [PATCH 2/2] test(scripts): key source-vs-build-output on git index, add changeset (#3943) Co-authored-by: Claude --- ...ideeffects-declaration-consistency-gate.md | 32 ++++++++++++ .../check-package-self-import.test.ts | 8 ++- ...de-effects-declaration-consistency.test.ts | 49 +++++++++++++++++-- 3 files changed, 84 insertions(+), 5 deletions(-) create mode 100644 .changeset/sideeffects-declaration-consistency-gate.md diff --git a/.changeset/sideeffects-declaration-consistency-gate.md b/.changeset/sideeffects-declaration-consistency-gate.md new file mode 100644 index 0000000000..c66ab776ec --- /dev/null +++ b/.changeset/sideeffects-declaration-consistency-gate.md @@ -0,0 +1,32 @@ +--- +--- + +Test-only (objectui#3943). A repo-level gate now holds every workspace package's +`sideEffects` declaration to what its modules actually do at load time — +`scripts/__tests__/side-effects-declaration-consistency.test.ts`, in the shape +`package-files-exist.test.ts` established for `files`. + +`sideEffects: false` is a promise to bundlers that no module does anything on +evaluation. When it is false, a bundler drops the module whole: 0 bytes, exit +code 0, no warning, and the failure surfaces far away as a red +`Unknown component type` panel. objectui#3899 was that defect in +`packages/layout`; PR #3940 fixed it and pinned it, but the pin only ever read +`packages/layout/package.json`, so the next package to repeat it would turn +nothing red. + +The gate checks both directions — a `false` package must have no load-time side +effect anywhere in its barrel's reachable module graph, and every entry in a +`sideEffects` array must name a real module form that really does have one. Entry +forms are derived from `main`/`module`/`exports` **and** from the in-repo bundler +alias tables, because a package aliased at its `src` is bundled through the same +manifest (PR #3940 measured a 0-byte console bundle with only the `dist/*` paths +declared). Every probe runs against a real bundler and carries its +`sideEffects: false` control, so a bundler that stopped honouring the field fails +loudly instead of leaving the gate green over nothing. + +`packages/layout/src/__tests__/side-effects-manifest.test.ts` is converged into +the new gate: everything in it is now derived for all packages, except the +assertion that layout's actual registry keys survive a side-effect-only import, +which is carried over as the gate's named specimen. + +No published behaviour changes — no package's runtime source was touched. diff --git a/scripts/__tests__/check-package-self-import.test.ts b/scripts/__tests__/check-package-self-import.test.ts index 12ccd2c7cc..914c47afbe 100644 --- a/scripts/__tests__/check-package-self-import.test.ts +++ b/scripts/__tests__/check-package-self-import.test.ts @@ -151,8 +151,12 @@ describe('a package name that is not a module edge is not a finding', () => { // // Replaced rather than dropped, and with a JSDoc specimen from production // source rather than another test, which is the stronger case: a regex - // rewrite of this gate breaks on a documented `import … from '@object-ui/core'` - // example exactly as it would on a test constant. + // rewrite of this gate breaks on a documented import example that quotes + // its own package name exactly as it would on a test constant. + // + // (Spelled without the quoted specifier on purpose. `scripts-type-check.test.ts` + // greps every file in the scripts tsconfig program for that literal shape and + // cannot tell a comment from an import — see objectui#4902.) const specimens: Record = { 'packages/i18n/src/__tests__/perm-home-namespace-3546.test.tsx': '@object-ui/i18n', 'packages/core/src/utils/freeze-schema.ts': '@object-ui/core', diff --git a/scripts/__tests__/side-effects-declaration-consistency.test.ts b/scripts/__tests__/side-effects-declaration-consistency.test.ts index 2312132421..b7bbc7d30b 100644 --- a/scripts/__tests__/side-effects-declaration-consistency.test.ts +++ b/scripts/__tests__/side-effects-declaration-consistency.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; +import { spawnSync } from 'node:child_process'; import ts from 'typescript'; import { build } from 'vite'; import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; @@ -335,7 +336,10 @@ function resolvableEntryPaths(pkg: DeclaringPackage): string[] { const walk = (node: unknown): void => { if (node === null || node === undefined) return; if (typeof node === 'string') { - if (node.startsWith('./') || !node.includes('/')) found.add(normalize(node)); + // Only a relative target names a file in this package; a bare specifier + // is a re-export of a dependency and is that package's manifest's + // problem, not this one's. + if (node.startsWith('./')) found.add(normalize(node)); return; } if (typeof node !== 'object') return; @@ -359,9 +363,48 @@ function resolvableEntryPaths(pkg: DeclaringPackage): string[] { return [...found].filter((p) => !p.includes('*')).sort(); } -/** Entry forms that are source files in this tree (as opposed to build output). */ +/** + * Every path git has in its index. Fails loudly if git is not usable: returning + * an empty set would make every entry look like build output, which quietly + * SHRINKS what this gate examines. + */ +function gitTrackedPaths(): Set { + const result = spawnSync('git', ['ls-files', '-z'], { + cwd: repoRoot, + encoding: 'utf8', + maxBuffer: 1024 * 1024 * 64, + }); + if (result.status !== 0) { + throw new Error( + `\`git ls-files\` failed in ${repoRoot} (status ${result.status}): ${result.stderr}\n` + + 'This guard separates source from build output using git, so it fails closed rather than ' + + 'passing over an unknown tree.', + ); + } + return new Set(result.stdout.split('\0').filter(Boolean)); +} + +const trackedPaths = gitTrackedPaths(); + +/** + * Whether an entry form is SOURCE in this repo, as opposed to build output. + * + * Keyed on git's index rather than on `fs.existsSync`, and the difference is the + * whole point: `dist/index.js` exists in a built tree and not in a fresh clone, + * so an existence check answers differently depending on whether anyone has run + * a build. That is not hypothetical here — `packages/layout/dist` was populated + * by a `turbo type-check` run (which `dependsOn: ^build`) while this gate was + * being written, which would have flipped `dist/index.js` into the statically + * scanned population on one machine and not another. + * + * `package-files-exist.test.ts` learned the same lesson the same way in + * objectui#4059: its producibility check was first written against `onDisk` and + * passed on the very state it exists to reject, because a local build had + * already put the file there. Git's index does not move when you run + * `pnpm build`. + */ const isSourceEntry = (pkg: DeclaringPackage, entry: string): boolean => - fs.existsSync(path.join(repoRoot, pkg.dir, entry)); + trackedPaths.has(path.posix.join(pkg.dir, entry)); /* -------------------------------------------------------------------------- */ /* The static scan: top-level statements that reach OUT of the module. */