From 7fdeff55a5465dbe414ed0c1126db284db62130b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 16:47:03 +0000 Subject: [PATCH] feat(scripts,components): gate unreferenced source files, and delete the one standing orphan (objectui#7515) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit None of the repo's 42 `scripts/check-*.mjs` gates could see a source file that nothing reaches. `check-dist-completeness` asks whether `dist/` holds what `tsc` emits, `check-readme-exports` compares documented exports against shipped ones, `check-i18n-dead-keys` covers message keys — a `.tsx` that is in the published tarball while being reachable from nothing is outside all three. Both instances found this week were found by a human reading unrelated code. `scripts/check-unreferenced-sources.mjs` walks the import graph from a covered package's declared entry AND from every build-config alias whose replacement is a file inside the package. That second leg is the whole difficulty: nothing in `packages/components` imports either `use-sync-external-store` shim, and both are alive only through `vite.config.ts` `resolve.alias` entries whose importer is a bundled dependency. A walk that skips it reports exactly those two live files as dead on its first run. Scope is DECLARED per package in `COVERED_PACKAGES` and the uncovered remainder is printed as a count derived from the workspace on every run. An alias expression the reader cannot evaluate is a FINDING, never a skip: skipping one would make the gate accuse whatever file that alias points at. `packages/components/src/ui/toast.tsx` is removed. It had no importer anywhere under `packages/components/src` and `ui/index.ts` never carried it, so the barrel's `export * from './ui'` did not reach it either; it contributed nothing to `dist/index.js` and nothing to the export surface, while shipping as `dist/ui/toast.d.ts`. `@radix-ui/react-toast` goes with it — the removed file was its only importer in the repository. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KbJQ1y1J12nZxYzFWhP8Q3 --- .changeset/7515-unreferenced-source-gate.md | 21 + .github/workflows/ci.yml | 29 + content/docs/guide/ci-cd-pipeline.md | 2 +- package.json | 1 + packages/components/package.json | 1 - packages/components/src/ui/toast.tsx | 137 --- pnpm-lock.yaml | 36 - .../check-unreferenced-sources.test.ts | 399 +++++++++ scripts/check-unreferenced-sources.mjs | 833 ++++++++++++++++++ 9 files changed, 1284 insertions(+), 175 deletions(-) create mode 100644 .changeset/7515-unreferenced-source-gate.md delete mode 100644 packages/components/src/ui/toast.tsx create mode 100644 scripts/__tests__/check-unreferenced-sources.test.ts create mode 100644 scripts/check-unreferenced-sources.mjs diff --git a/.changeset/7515-unreferenced-source-gate.md b/.changeset/7515-unreferenced-source-gate.md new file mode 100644 index 0000000000..f9bba023c3 --- /dev/null +++ b/.changeset/7515-unreferenced-source-gate.md @@ -0,0 +1,21 @@ +--- +"@object-ui/components": patch +--- + +Remove `src/ui/toast.tsx`, an unreferenced primitive, and the dependency only it imported + +The file was reachable from nothing: no importer anywhere under +`packages/components/src`, and `ui/index.ts` never carried it, so the barrel's +`export * from './ui'` did not reach it either. It shipped all the same — +`dist/ui/toast.d.ts` was in the published tarball — while contributing nothing to +`dist/index.js` and nothing to the package's export surface. `ui/sonner.tsx` +(`Toaster`) is the live implementation and is unaffected. + +`@radix-ui/react-toast` is dropped from `dependencies` in the same change: the +removed file was its only importer anywhere in the repository, so it would +otherwise have stayed a declared dependency of every install with nothing to +resolve it. + +No exported name changes. A consumer who was resolving `@radix-ui/react-toast` +through this package's dependency was relying on hoisting rather than on a +declaration, and should declare it directly. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index adbcab8f51..62c64b1416 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -263,6 +263,35 @@ jobs: if: steps.relevant.outputs.should_run == 'true' run: pnpm check:self-import + # A source file nothing imports and nothing exports sits in a PUBLISHED + # package indefinitely: `check-dist-completeness` asks whether `dist/` holds + # what `tsc` emits, `check-readme-exports` compares documented exports + # against shipped ones, and neither can see a file that is in the tarball + # while being reachable from nothing. Both instances found this week — + # objectui#7319 and objectui#7397 — were found by a human reading unrelated + # code, which is the detection mechanism this replaces. The hazard is not + # the bytes: the file objectui#7319 removed carried the same export name as + # a live engine one package over and evaluated no predicate, so + # name-completion alone could have wired a silently wrong renderer into a + # published package (objectui#7515). + # + # Reachability here has TWO roots. `packages/components` reaches two live + # files — the `use-sync-external-store` shims — only through + # `vite.config.ts` `resolve.alias`, whose importer is a bundled dependency + # no source file names; a walk that skips that leg reports exactly those two + # as dead on its first run, and a gate that cries wolf on live files gets + # switched off rather than fixed. Scope is DECLARED per package and the + # uncovered remainder is printed as a derived count on every run, because + # the alias mechanisms differ per package and a gate that covers one + # correctly beats one that covers forty with false positives. + # + # Parses sources with `typescript` through the sibling gate's scanner and + # reads build configs as text, so it needs the install and nothing built — + # same placement rationale as the two steps above. + - name: Verify no covered package ships a source file nothing reaches + if: steps.relevant.outputs.should_run == 'true' + run: pnpm check:unreferenced-sources + # A build tsconfig that excludes tooling by FILE NAME (`*.test.ts`) stops # the files that happen to be named that way and nothing else. The first # shared helper added to a `__tests__/` directory is then a program input, diff --git a/content/docs/guide/ci-cd-pipeline.md b/content/docs/guide/ci-cd-pipeline.md index a14d17fc3b..21c1195a12 100644 --- a/content/docs/guide/ci-cd-pipeline.md +++ b/content/docs/guide/ci-cd-pipeline.md @@ -208,7 +208,7 @@ it green — which is how two of `type-check`'s gates came to be missing from th | Job key | Appears as | What it runs | When | |---|---|---|---| | `changeset-check` | Changeset Fixed Group Check | `scripts/check-changeset-fixed.mjs` — every workspace package must be in the changeset `fixed` group or explicitly ignored. It checks group *membership*; it does **not** check whether the PR added a changeset. | Every run | -| `type-check` | Type Check | `scripts/check-type-check-coverage.mjs`, then `pnpm check:phantom-deps`, then `pnpm check:self-import`, then `pnpm check:published-tsconfig-exclude`, then `pnpm check:side-effects-array`, then `pnpm check:element-data-source-declaration`, then `pnpm check:esm-specifiers`, then `pnpm check:spec-symbols`, then `pnpm check:action-forward-parity`, then `pnpm check:designer-field-key-parity`, then `pnpm check:icon-record-names`, then `pnpm check:i18n-keys`, then `pnpm check:i18n-drift`, then `pnpm type-check:scripts`, then `pnpm type-check`, then `pnpm type-check:vitest-setup`. The coverage guard runs first because turbo silently skips packages that have no `type-check` script, so a package without one would otherwise read as passing (#2911). `pnpm check:phantom-deps` fails when a released package imports a bare specifier its own `package.json` does not declare — a *phantom dependency*, invisible locally because the workspace root's `devDependencies` sit on the upward resolution path from every package directory and on no consumer's, so `require.resolve('react', { paths: ['packages/core/src'] })` succeeds while `@object-ui/core` declares react in no field at all ([#4394](https://github.com/objectstack-ai/objectui/issues/4394)). `pnpm check:self-import` runs next because it reuses that gate's parser: it fails when a file inside a package names its OWN package, a specifier that resolves through the package's `exports` map to `dist/` while `type-check` waits on `^build` — the *dependencies'* builds, never the package's own — so on a cold cache the declarations do not exist yet and the file fails with `TS2307`. Locally it is always green, because every local workflow builds before it type-checks and leaves a `dist/` behind; PR #4789's first run was red on exactly one such line ([#4801](https://github.com/objectstack-ai/objectui/issues/4801)). `pnpm check:published-tsconfig-exclude` follows, config reads only: it fails when a published package's build `tsconfig.json` excludes tooling by FILE NAME (`*.test.ts`) without also excluding the tooling DIRECTORIES (`**/__tests__/**` and its two siblings, derived from `TOOLING_FILE` rather than retyped). A name-only exclude stops the files that happen to be named that way and nothing else, so the first shared helper added to a `__tests__/` directory becomes a program input and an emitting program writes it into the published `dist` — three times so far, each found by a human and never by a gate ([#4006](https://github.com/objectstack-ai/objectui/issues/4006), [#4836](https://github.com/objectstack-ai/objectui/issues/4836), [#6943](https://github.com/objectstack-ai/objectui/issues/6943), the third in the same package as the first). [#7212](https://github.com/objectstack-ai/objectui/issues/7212) measured the standing exposure — 29 published packages carrying the name form with ZERO offending files, green because nobody had added such a helper yet — and the gate landed together with their conversion so `main` was green on merge. It reads `exclude` arrays and nothing else: no build, no artifact, no emit model, which is the narrower scope that keeps it clear of the modelling [#4846](https://github.com/objectstack-ai/objectui/issues/4846) declined for the artifact-level gate. Six published packages are named carve-outs, each re-proving its own reason on every run: `cli`, `create-plugin` and `data-objectstack` emit from a `tsup` entry graph, `plugin-charts` keeps its tooling exclude in the `dts()` options, and `console` and `runner` are Vite applications with `noEmit: true` and no `dts()` plugin. `pnpm check:side-effects-array` runs next, sources only and no build: it fails when a package's `sideEffects` ARRAY and its module bodies disagree in either direction — a module that registers something at load time and is not named (a bundler drops it, and the registration is gone from a *consumer's* app with no error, no warning and exit 0), or a name whose module no longer registers anything. `@object-ui/app-shell` declares such an array because both simpler answers are measurably wrong for it: omitting the field makes the whole package unshakeable, and `"sideEffects": false` silently drops three live SDUI widget registrations to zero chunks ([#6535](https://github.com/objectstack-ai/objectui/issues/6535), [#6683](https://github.com/objectstack-ai/objectui/issues/6683)). The enumeration is re-derived from the module bodies on every run rather than listed, so there is no second copy to rot. The artifact half of the same contract — do those registrations survive a real bundler — cannot run in this job at all: it needs a built console, so it lives in the SDUI registration pin step of `performance-budget.yml`. `pnpm check:element-data-source-declaration` runs next, sources only and no build: it fails when a source that consumes `ElementDataSourceGate` does not also pass through `elementDataSourceBlock()`, the seam that declares the `dataSource` key the gate reads. A block that wraps the gate off-seam publishes an authoring surface missing the one key its own runtime honours, and the html tier reports that key with the same `unknown-prop` warning it gives the spellings that do nothing ([#6678](https://github.com/objectstack-ai/objectui/issues/6678)). `pnpm check:esm-specifiers` follows it for the same reason — sources only, no build: it fails when a published package whose build preserves import specifiers (a bare emitting `tsc`, which never rewrites them) writes a relative specifier with no file extension. Node's ESM resolver does not extension-search relative specifiers, so such a specifier makes the published entry unloadable outside a bundler; `@object-ui/react`'s entry died with `ERR_MODULE_NOT_FOUND` while every bundler-based consumer, the whole test suite and CI stayed green ([#4538](https://github.com/objectstack-ai/objectui/issues/4538)). The half that actually *imports* each built entry needs a full build and runs in `node-esm-load-gate.yml`. `pnpm check:action-forward-parity` fails when an action renderer's forward whitelist drops a key the action runtime reads — the class that shipped six times one key at a time, each time green, because the key parses and publishes while the payload is dropped one hop before the runner ([#4050](https://github.com/objectstack-ai/objectui/issues/4050)). `pnpm check:designer-field-key-parity` fails when one of the field designers' statically declared payload shapes (`FieldMetadataPayload`, `ServerFieldSchema`, `DesignerFieldDefinition`) declares a key the installed `@objectstack/spec` `FieldSchema` refuses by NAME. Such a key makes `PUT /api/v1/meta/object/:name` return a hard 422 `INVALID_METADATA` that blocks *every subsequent save* of that object, and the author cannot tell from the designer UI which key did it — the class had been filed three times, each closed with a per-key tombstone written after the instance was found in production, with nothing detecting the next one ([#4644](https://github.com/objectstack-ai/objectui/issues/4644) `indexed`, [#4687](https://github.com/objectstack-ai/objectui/issues/4687) `distance_metric`, [#4676](https://github.com/objectstack-ai/objectui/issues/4676) `placeholder`, gated by [#5761](https://github.com/objectstack-ai/objectui/issues/5761)). It reads the accept set off the schema itself rather than from a list, and it covers a deliberately documented *subset* of the write path: a key that reaches the payload only through a `patchDef` spread or an index signature is outside its reach, and the boundary is stated in the script's own docblock. Its draft-I/O half — the `readFields`/`writeFields` round-trip, which has no declared shape to read — runs in the test suite as `object-fields-io.spec-keys.test.ts`. Same placement rationale as the gates around it: it parses the sources with `typescript` and imports the installed spec, so it needs the install and nothing built. `pnpm check:icon-record-names` fails when an authored icon NAME that reaches a resolver reading lucide's runtime `icons` record is not a live key of that record. lucide retires a spelling by dropping it from that record while keeping it as a deprecated named export, so the retired name still imports, still type-checks and still renders wherever it is used as a *component* — `Edit === SquarePen` is true — and resolves to nothing wherever it is used as a *string*: nothing goes red in either direction, which is why the class was repaired twice in two packages before anyone gated it ([#5586](https://github.com/objectstack-ai/objectui/issues/5586), [#5622](https://github.com/objectstack-ai/objectui/issues/5622), [#5633](https://github.com/objectstack-ai/objectui/issues/5633)). It carries no list of retired spellings — the record itself is the judgement — and it re-discovers the resolver population from source on every run, which is how its first pass found four record-reading resolvers nobody had catalogued. It sits here because it parses the sources with `typescript` and reads the installed lucide: the install, and nothing built. The two locale gates sit in the middle because both parse the sources with `typescript`: they need the install and nothing built. `pnpm check:i18n-keys` fails when a `t()` call site asks for a key the `en` pack does not define ([#3530](https://github.com/objectstack-ai/objectui/issues/3530)); `pnpm check:i18n-drift` fails when a change to an `en` string is not accompanied by the nine translation packs ([#3650](https://github.com/objectstack-ai/objectui/issues/3650)), and it is why this job's checkout sets `fetch-depth: 0` — it diffs against the merge base, which a depth-1 clone cannot resolve. `pnpm type-check:scripts` (`tsconfig.scripts.json`) covers `scripts/**/*.ts`, which `pnpm type-check` cannot reach at all — `scripts/` has no package.json, so turbo never walks it, and the coverage guard decides coverage per *package*. Until [#3494](https://github.com/objectstack-ai/objectui/issues/3494) that left the pin tests in `scripts/__tests__/` — including the one pinning this very page — compiled by nothing. `pnpm type-check:vitest-setup` (`tsconfig.vitest-setup.json`) closes the same gap for the four repo-root `vitest.setup.*` files, uncovered until [#3515](https://github.com/objectstack-ai/objectui/issues/3515); it runs *last*, after `pnpm type-check`, because `vitest.setup.dom.tsx` side-effect-imports four `@object-ui/*` packages and resolves them through the declarations that turbo's `^build` produces. | Every run; on a PR the steps short-circuit when only ignored paths changed | +| `type-check` | Type Check | `scripts/check-type-check-coverage.mjs`, then `pnpm check:phantom-deps`, then `pnpm check:self-import`, then `pnpm check:unreferenced-sources`, then `pnpm check:published-tsconfig-exclude`, then `pnpm check:side-effects-array`, then `pnpm check:element-data-source-declaration`, then `pnpm check:esm-specifiers`, then `pnpm check:spec-symbols`, then `pnpm check:action-forward-parity`, then `pnpm check:designer-field-key-parity`, then `pnpm check:icon-record-names`, then `pnpm check:i18n-keys`, then `pnpm check:i18n-drift`, then `pnpm type-check:scripts`, then `pnpm type-check`, then `pnpm type-check:vitest-setup`. The coverage guard runs first because turbo silently skips packages that have no `type-check` script, so a package without one would otherwise read as passing (#2911). `pnpm check:phantom-deps` fails when a released package imports a bare specifier its own `package.json` does not declare — a *phantom dependency*, invisible locally because the workspace root's `devDependencies` sit on the upward resolution path from every package directory and on no consumer's, so `require.resolve('react', { paths: ['packages/core/src'] })` succeeds while `@object-ui/core` declares react in no field at all ([#4394](https://github.com/objectstack-ai/objectui/issues/4394)). `pnpm check:self-import` runs next because it reuses that gate's parser: it fails when a file inside a package names its OWN package, a specifier that resolves through the package's `exports` map to `dist/` while `type-check` waits on `^build` — the *dependencies'* builds, never the package's own — so on a cold cache the declarations do not exist yet and the file fails with `TS2307`. Locally it is always green, because every local workflow builds before it type-checks and leaves a `dist/` behind; PR #4789's first run was red on exactly one such line ([#4801](https://github.com/objectstack-ai/objectui/issues/4801)). `pnpm check:unreferenced-sources` runs next, reusing the same parser again: it fails when a covered package ships a source file that nothing reaches — not the package's declared entry, and not its build config. Until [#7515](https://github.com/objectstack-ai/objectui/issues/7515) no gate here could see one: `check-dist-completeness` asks whether `dist/` holds what `tsc` emits, `check-readme-exports` compares documented exports against shipped ones, and a file that is in the tarball while being reachable from nothing is outside both — so the detection mechanism was a human reading unrelated code, which is how both instances found in one week were found ([#7319](https://github.com/objectstack-ai/objectui/issues/7319), [#7397](https://github.com/objectstack-ai/objectui/issues/7397)). The hazard is not the bytes: the file #7319 removed carried the same export name as a live engine one package over and evaluated no predicate, so name-completion alone could have wired a silently wrong renderer into a published package. Reachability has TWO roots, and the second is the whole difficulty — `packages/components` reaches its two `use-sync-external-store` shims only through `vite.config.ts` `resolve.alias` entries whose importer is a bundled dependency no source file names, so a walk that skips that leg reports exactly those two live files as dead on its first run, and a gate that cries wolf gets switched off rather than fixed. Scope is DECLARED per package in `COVERED_PACKAGES` and the uncovered remainder is printed as a count derived from the workspace on every run, because the alias mechanisms differ per package and a gate that covers one package correctly beats one that covers forty with false positives. An alias expression it cannot evaluate is a FINDING rather than a skip, since skipping one would make it accuse whatever file that alias points at. `pnpm check:published-tsconfig-exclude` follows, config reads only: it fails when a published package's build `tsconfig.json` excludes tooling by FILE NAME (`*.test.ts`) without also excluding the tooling DIRECTORIES (`**/__tests__/**` and its two siblings, derived from `TOOLING_FILE` rather than retyped). A name-only exclude stops the files that happen to be named that way and nothing else, so the first shared helper added to a `__tests__/` directory becomes a program input and an emitting program writes it into the published `dist` — three times so far, each found by a human and never by a gate ([#4006](https://github.com/objectstack-ai/objectui/issues/4006), [#4836](https://github.com/objectstack-ai/objectui/issues/4836), [#6943](https://github.com/objectstack-ai/objectui/issues/6943), the third in the same package as the first). [#7212](https://github.com/objectstack-ai/objectui/issues/7212) measured the standing exposure — 29 published packages carrying the name form with ZERO offending files, green because nobody had added such a helper yet — and the gate landed together with their conversion so `main` was green on merge. It reads `exclude` arrays and nothing else: no build, no artifact, no emit model, which is the narrower scope that keeps it clear of the modelling [#4846](https://github.com/objectstack-ai/objectui/issues/4846) declined for the artifact-level gate. Six published packages are named carve-outs, each re-proving its own reason on every run: `cli`, `create-plugin` and `data-objectstack` emit from a `tsup` entry graph, `plugin-charts` keeps its tooling exclude in the `dts()` options, and `console` and `runner` are Vite applications with `noEmit: true` and no `dts()` plugin. `pnpm check:side-effects-array` runs next, sources only and no build: it fails when a package's `sideEffects` ARRAY and its module bodies disagree in either direction — a module that registers something at load time and is not named (a bundler drops it, and the registration is gone from a *consumer's* app with no error, no warning and exit 0), or a name whose module no longer registers anything. `@object-ui/app-shell` declares such an array because both simpler answers are measurably wrong for it: omitting the field makes the whole package unshakeable, and `"sideEffects": false` silently drops three live SDUI widget registrations to zero chunks ([#6535](https://github.com/objectstack-ai/objectui/issues/6535), [#6683](https://github.com/objectstack-ai/objectui/issues/6683)). The enumeration is re-derived from the module bodies on every run rather than listed, so there is no second copy to rot. The artifact half of the same contract — do those registrations survive a real bundler — cannot run in this job at all: it needs a built console, so it lives in the SDUI registration pin step of `performance-budget.yml`. `pnpm check:element-data-source-declaration` runs next, sources only and no build: it fails when a source that consumes `ElementDataSourceGate` does not also pass through `elementDataSourceBlock()`, the seam that declares the `dataSource` key the gate reads. A block that wraps the gate off-seam publishes an authoring surface missing the one key its own runtime honours, and the html tier reports that key with the same `unknown-prop` warning it gives the spellings that do nothing ([#6678](https://github.com/objectstack-ai/objectui/issues/6678)). `pnpm check:esm-specifiers` follows it for the same reason — sources only, no build: it fails when a published package whose build preserves import specifiers (a bare emitting `tsc`, which never rewrites them) writes a relative specifier with no file extension. Node's ESM resolver does not extension-search relative specifiers, so such a specifier makes the published entry unloadable outside a bundler; `@object-ui/react`'s entry died with `ERR_MODULE_NOT_FOUND` while every bundler-based consumer, the whole test suite and CI stayed green ([#4538](https://github.com/objectstack-ai/objectui/issues/4538)). The half that actually *imports* each built entry needs a full build and runs in `node-esm-load-gate.yml`. `pnpm check:action-forward-parity` fails when an action renderer's forward whitelist drops a key the action runtime reads — the class that shipped six times one key at a time, each time green, because the key parses and publishes while the payload is dropped one hop before the runner ([#4050](https://github.com/objectstack-ai/objectui/issues/4050)). `pnpm check:designer-field-key-parity` fails when one of the field designers' statically declared payload shapes (`FieldMetadataPayload`, `ServerFieldSchema`, `DesignerFieldDefinition`) declares a key the installed `@objectstack/spec` `FieldSchema` refuses by NAME. Such a key makes `PUT /api/v1/meta/object/:name` return a hard 422 `INVALID_METADATA` that blocks *every subsequent save* of that object, and the author cannot tell from the designer UI which key did it — the class had been filed three times, each closed with a per-key tombstone written after the instance was found in production, with nothing detecting the next one ([#4644](https://github.com/objectstack-ai/objectui/issues/4644) `indexed`, [#4687](https://github.com/objectstack-ai/objectui/issues/4687) `distance_metric`, [#4676](https://github.com/objectstack-ai/objectui/issues/4676) `placeholder`, gated by [#5761](https://github.com/objectstack-ai/objectui/issues/5761)). It reads the accept set off the schema itself rather than from a list, and it covers a deliberately documented *subset* of the write path: a key that reaches the payload only through a `patchDef` spread or an index signature is outside its reach, and the boundary is stated in the script's own docblock. Its draft-I/O half — the `readFields`/`writeFields` round-trip, which has no declared shape to read — runs in the test suite as `object-fields-io.spec-keys.test.ts`. Same placement rationale as the gates around it: it parses the sources with `typescript` and imports the installed spec, so it needs the install and nothing built. `pnpm check:icon-record-names` fails when an authored icon NAME that reaches a resolver reading lucide's runtime `icons` record is not a live key of that record. lucide retires a spelling by dropping it from that record while keeping it as a deprecated named export, so the retired name still imports, still type-checks and still renders wherever it is used as a *component* — `Edit === SquarePen` is true — and resolves to nothing wherever it is used as a *string*: nothing goes red in either direction, which is why the class was repaired twice in two packages before anyone gated it ([#5586](https://github.com/objectstack-ai/objectui/issues/5586), [#5622](https://github.com/objectstack-ai/objectui/issues/5622), [#5633](https://github.com/objectstack-ai/objectui/issues/5633)). It carries no list of retired spellings — the record itself is the judgement — and it re-discovers the resolver population from source on every run, which is how its first pass found four record-reading resolvers nobody had catalogued. It sits here because it parses the sources with `typescript` and reads the installed lucide: the install, and nothing built. The two locale gates sit in the middle because both parse the sources with `typescript`: they need the install and nothing built. `pnpm check:i18n-keys` fails when a `t()` call site asks for a key the `en` pack does not define ([#3530](https://github.com/objectstack-ai/objectui/issues/3530)); `pnpm check:i18n-drift` fails when a change to an `en` string is not accompanied by the nine translation packs ([#3650](https://github.com/objectstack-ai/objectui/issues/3650)), and it is why this job's checkout sets `fetch-depth: 0` — it diffs against the merge base, which a depth-1 clone cannot resolve. `pnpm type-check:scripts` (`tsconfig.scripts.json`) covers `scripts/**/*.ts`, which `pnpm type-check` cannot reach at all — `scripts/` has no package.json, so turbo never walks it, and the coverage guard decides coverage per *package*. Until [#3494](https://github.com/objectstack-ai/objectui/issues/3494) that left the pin tests in `scripts/__tests__/` — including the one pinning this very page — compiled by nothing. `pnpm type-check:vitest-setup` (`tsconfig.vitest-setup.json`) closes the same gap for the four repo-root `vitest.setup.*` files, uncovered until [#3515](https://github.com/objectstack-ai/objectui/issues/3515); it runs *last*, after `pnpm type-check`, because `vitest.setup.dom.tsx` side-effect-imports four `@object-ui/*` packages and resolves them through the declarations that turbo's `^build` produces. | Every run; on a PR the steps short-circuit when only ignored paths changed | | `test` | Test (shard N/4) | `pnpm test --shard=N/4` across a 4-runner matrix with `fail-fast: false`, so every shard reports its own failures. No coverage instrumentation — v8 adds 40–100% overhead. Then, **on shard 1 only**, `pnpm test:dist` — the built-artifact lane ([#7183](https://github.com/objectstack-ai/objectui/issues/7183)). It delegates to a turbo task scoped to the one package that holds built-artifact pins; that task depends on the package's OWN build (`dependsOn: ["build"]`, not `^build`), so the bundle exists before the pins read it, and then runs the `dist` vitest project, whose pins import a package's BUILT bundle instead of its `src` — a claim the source-aliased suite above is structurally unable to make, since the root config aliases every workspace package to `src`. It is deliberately not sharded and not repeated on the other three runners: the lane is a handful of files, and running it on all four would pay for the same build four times. | Pull requests and merge-queue builds (everything but `push`); steps short-circuit on a PR that changed only ignored paths | | `test-coverage` | Test (coverage shard N/4) | `pnpm test:coverage --reporter=blob --shard=N/4` across a 4-runner matrix with `fail-fast: false`. Each shard writes `.vitest-reports/blob-N-4.json` — raw coverage and test results in one file — and uploads it as an artifact even when the shard is red, which is what makes a failing coverage run diagnosable at all (vitest deletes `coverage/` on a red run unless `coverage.reportOnFailure` is set, [#5402](https://github.com/objectstack-ai/objectui/issues/5402)). The configured coverage thresholds are neutralised on the shard legs, because a quarter of the suite judged against a whole-suite threshold is not a defect signal; they are enforced once, on the merged report, by the job below ([#5403](https://github.com/objectstack-ai/objectui/issues/5403)). | **Push only** | | `coverage-report` | Test (coverage) | Downloads the four blob reports, refuses to continue unless all four arrived, merges them with `pnpm test:coverage --merge-reports` into one complete report — which is where the configured coverage thresholds are enforced, over the whole merged map, the shard legs having overridden them to zero — and publishes that report as the `coverage-report` artifact (kept 7 days, the same as the blobs it is derived from). Its last step runs on every path and states the outcome: the job is **red, with an error annotation**, whenever the gate did not run for the commit — before [#5403](https://github.com/objectstack-ai/objectui/issues/5403) the final step carried the implicit `success()` and was silently skipped by 311 of 373 coverage jobs, which is how four days of a 100%-failing coverage job went unnoticed. A breach of the thresholds is reported *separately* from a lane that never delivered, because the two call for opposite actions. ⛔ It never merges a report from fewer than four shards: a wrong coverage number is worse than a missing one. The Codecov upload this job used to carry was retired by [#5436](https://github.com/objectstack-ai/objectui/issues/5436) — `CODECOV_TOKEN` was never set, so it failed on every push; the trend dashboard and PR coverage comments are gone with it, the gate is not. | **Push only** | diff --git a/package.json b/package.json index 769ba7e69a..a20cc9c468 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "check:vi-mock-inherit": "node scripts/check-vi-mock-inherit.mjs", "check:shell-escape-residue": "node scripts/check-shell-escape-residue.mjs", "check:readme-exports": "node scripts/check-readme-exports.mjs", + "check:unreferenced-sources": "node scripts/check-unreferenced-sources.mjs", "cli": "node packages/cli/dist/cli.js", "objectui": "node packages/cli/dist/cli.js", "create-plugin": "node packages/create-plugin/dist/index.js", diff --git a/packages/components/package.json b/packages/components/package.json index 7c929de5c5..7455695be1 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -65,7 +65,6 @@ "@radix-ui/react-slot": "^1.3.3", "@radix-ui/react-switch": "^1.3.7", "@radix-ui/react-tabs": "^1.1.21", - "@radix-ui/react-toast": "^1.2.23", "@radix-ui/react-toggle": "^1.1.18", "@radix-ui/react-toggle-group": "^1.1.19", "@radix-ui/react-tooltip": "^1.2.16", diff --git a/packages/components/src/ui/toast.tsx b/packages/components/src/ui/toast.tsx deleted file mode 100644 index 456617bc59..0000000000 --- a/packages/components/src/ui/toast.tsx +++ /dev/null @@ -1,137 +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. - */ - -"use client" - -import * as React from "react" -import * as ToastPrimitives from "@radix-ui/react-toast" -import { cva, type VariantProps } from "class-variance-authority" -import { X } from "lucide-react" - -import { cn } from "../lib/utils" - -const ToastProvider = ToastPrimitives.Provider - -const ToastViewport = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -ToastViewport.displayName = ToastPrimitives.Viewport.displayName - -const toastVariants = cva( - "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", - { - variants: { - variant: { - default: "border bg-background text-foreground", - destructive: - "destructive group border-destructive bg-destructive text-destructive-foreground", - }, - }, - defaultVariants: { - variant: "default", - }, - } -) - -const Toast = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & - VariantProps ->(({ className, variant, ...props }, ref) => { - return ( - - ) -}) -Toast.displayName = ToastPrimitives.Root.displayName - -const ToastAction = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -ToastAction.displayName = ToastPrimitives.Action.displayName - -const ToastClose = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - - - -)) -ToastClose.displayName = ToastPrimitives.Close.displayName - -const ToastTitle = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -ToastTitle.displayName = ToastPrimitives.Title.displayName - -const ToastDescription = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -ToastDescription.displayName = ToastPrimitives.Description.displayName - -type ToastProps = React.ComponentPropsWithoutRef - -type ToastActionElement = React.ReactElement - -export { - type ToastProps, - type ToastActionElement, - ToastProvider, - ToastViewport, - Toast, - ToastTitle, - ToastDescription, - ToastClose, - ToastAction, -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ed985a61a2..df8fbcd77e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1080,9 +1080,6 @@ importers: '@radix-ui/react-tabs': specifier: ^1.1.21 version: 1.1.21(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-toast': - specifier: ^1.2.23 - version: 1.2.23(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-toggle': specifier: ^1.1.18 version: 1.1.18(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -4836,19 +4833,6 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-toast@1.2.23': - resolution: {integrity: sha512-ofhyAsYaocRGOs/n0XWdUOSVzEAG6BfrMVM8z0c0kLEWY38w/0WuMFPTJP/HVaZPYkMvHZoKIIhNcjbTCBILPg==} - peerDependencies: - '@types/react': 19.2.18 - '@types/react-dom': 19.2.4 - react: 19.2.8 - react-dom: 19.2.8 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - '@radix-ui/react-toggle-group@1.1.19': resolution: {integrity: sha512-OtnwuSVjd1Ofi+AdnvhsjQdyuhCDwYs1w9RyB5BN/OavXOVQo42SYqQjwUnbPnaiPFBpQ9aX70dWeee+v2oBLA==} peerDependencies: @@ -13354,26 +13338,6 @@ snapshots: '@types/react': 19.2.18 '@types/react-dom': 19.2.4(@types/react@19.2.18) - '@radix-ui/react-toast@1.2.23(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': - dependencies: - '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-visually-hidden': 1.2.11(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) - optionalDependencies: - '@types/react': 19.2.18 - '@types/react-dom': 19.2.4(@types/react@19.2.18) - '@radix-ui/react-toggle-group@1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.7 diff --git a/scripts/__tests__/check-unreferenced-sources.test.ts b/scripts/__tests__/check-unreferenced-sources.test.ts new file mode 100644 index 0000000000..78d5aa2e29 --- /dev/null +++ b/scripts/__tests__/check-unreferenced-sources.test.ts @@ -0,0 +1,399 @@ +import { afterAll, 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'; +import ts from 'typescript'; + +import { + COVERED_PACKAGES, + analyze, + applyAliasRule, + auditCoverage, + classifyAliases, + evaluatePath, + readBuildConfig, +} from '../check-unreferenced-sources.mjs'; + +/** + * objectui#7515 — a source file nothing reaches must not sit in a published + * package forever. + * + * Before this gate, none of the 42 `scripts/check-*.mjs` files could see one. + * `check-dist-completeness` asks whether `dist/` holds what `tsc` emits; + * `check-readme-exports` compares documented exports against shipped ones; + * `check-i18n-dead-keys` covers message keys. A `.tsx` that is in the tarball + * while being reachable from nothing is outside all three, and both instances + * found this week — objectui#7319 and objectui#7397 — were found by a human + * reading unrelated code. + * + * What this file pins, in the order the gate can go wrong: + * + * 1. **The alias leg, which is the whole difficulty.** `packages/components` + * reaches two live files only through `vite.config.ts` `resolve.alias` + * entries whose importer is a bundled dependency no source file names. A + * walk that skips that leg reports exactly those two as dead on its first + * run, and a gate that cries wolf on live files gets switched off rather + * than fixed. Pinned over throwaway trees AND on this repository. + * 2. **A true orphan is still reported** — the other direction, so a gate that + * passes because its loop body never runs cannot look correct. + * 3. **An alias replacement's ROLE is decided by the filesystem**, not by its + * spelling: a file is a graph ROOT, a directory is a RESOLUTION RULE. + * 4. **An unreadable config is a FINDING, never a skip.** An alias this gate + * cannot evaluate is an alias whose target it would otherwise accuse of + * being dead, so the safe direction is to fail on the config. + * 5. **`find` is read as a VALUE, not as source text.** The first run of this + * gate reported `packages/components`' own `@` alias as unmodelled, because + * `JSON.parse` cannot read a single-quoted TypeScript string. + * 6. **The coverage table is re-derived, never trusted** — an entry whose + * package has moved leaves the gate checking nothing while reporting a pass. + * 7. **The scope cannot collapse quietly.** No roots must be a finding, not a + * green run over an unwalkable package. + * 8. **This repository is green, and objectui#7515's orphan stays gone.** + * 9. **The gate is wired** where the sibling parse-based gates are. + */ +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const GATE = 'scripts/check-unreferenced-sources.mjs'; + +const fixtures: string[] = []; +afterAll(() => { + for (const dir of fixtures) fs.rmSync(dir, { recursive: true, force: true }); +}); + +/** + * A throwaway one-package repository. + * + * Named `@fixture/*` deliberately: an `@object-ui/*` assumption anywhere in the + * walk would flip every verdict below without any of them saying so. + */ +function fixtureRepo(label: string, files: Record, viteConfig: string): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), `unreferenced-${label}-`)); + fixtures.push(root); + const write = (rel: string, body: string): void => { + fs.mkdirSync(path.join(root, path.dirname(rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), body); + }; + write('package.json', JSON.stringify({ name: '@fixture/root' }, null, 2)); + write('packages/widget/package.json', JSON.stringify({ name: '@fixture/widget' }, null, 2)); + write('packages/widget/vite.config.ts', viteConfig); + for (const [rel, body] of Object.entries(files)) write(`packages/widget/src/${rel}`, body); + return root; +} + +const COVER = { 'packages/widget': { buildConfig: 'vite.config.ts', notes: 'fixture' } }; + +/** `reason :: subject` for every finding, sorted — the readable verdict. */ +const verdict = (root: string, covered = COVER): string[] => + analyze(root, covered) + .findings.map((f) => `${f.reason} :: ${f.file ?? f.pkg}`) + .sort(); + +/** A build config with an entry and whatever alias array the caller wants. */ +const config = (aliases = ''): string => ` +import { defineConfig } from 'vite'; +import { resolve } from 'path'; + +export default defineConfig({ + resolve: { alias: [${aliases}] }, + build: { lib: { entry: resolve(__dirname, 'src/index.ts') } }, +}); +`; + +// ── 1. the alias leg ───────────────────────────────────────────────────────── + +describe('a file reached only through a build-config alias is alive', () => { + const SHIM_ALIAS = + "{ find: /^use-sync-external-store\\/shim(\\.js)?$/, replacement: resolve(__dirname, 'src/lib/shim.ts') },"; + + it('stays silent on the alias target — the false positive that would disable the gate', () => { + const root = fixtureRepo( + 'alias-alive', + { + 'index.ts': "export { button } from './ui/button';\n", + 'ui/button.ts': 'export const button = 1;\n', + 'lib/shim.ts': "export { useSyncExternalStore } from 'react';\n", + }, + config(SHIM_ALIAS), + ); + expect(verdict(root)).toEqual([]); + }); + + it('reports that same file the moment the alias is gone — so the silence is CAUSED by the alias', () => { + const root = fixtureRepo( + 'alias-removed', + { + 'index.ts': "export { button } from './ui/button';\n", + 'ui/button.ts': 'export const button = 1;\n', + 'lib/shim.ts': "export { useSyncExternalStore } from 'react';\n", + }, + config(), + ); + expect(verdict(root)).toEqual(['unreferenced-source :: packages/widget/src/lib/shim.ts']); + }); + + it('walks THROUGH an alias target, so what the alias target imports is alive too', () => { + const root = fixtureRepo( + 'alias-transitive', + { + 'index.ts': 'export const nothing = 1;\n', + 'lib/shim.ts': "export { helper } from './shim-helper';\n", + 'lib/shim-helper.ts': 'export const helper = 1;\n', + }, + config(SHIM_ALIAS), + ); + expect(verdict(root)).toEqual([]); + }); +}); + +// ── 2. a true orphan is still reported ─────────────────────────────────────── + +describe('a file nothing reaches is reported', () => { + it('names the orphan and nothing else', () => { + const root = fixtureRepo( + 'orphan', + { + 'index.ts': "export { button } from './ui/button';\n", + 'ui/button.ts': 'export const button = 1;\n', + 'ui/orphan.ts': 'export const Orphan = 1;\n', + }, + config(), + ); + expect(verdict(root)).toEqual(['unreferenced-source :: packages/widget/src/ui/orphan.ts']); + }); + + it('a file reached only by a TEST is still unreachable from the entry, and says so', () => { + const root = fixtureRepo( + 'test-only', + { + 'index.ts': 'export const entry = 1;\n', + 'lib/helper.ts': 'export const helper = 1;\n', + '__tests__/helper.test.ts': "import { helper } from '../lib/helper';\n", + }, + config(), + ); + const { findings } = analyze(root, COVER); + expect(findings.map((f) => f.reason)).toEqual(['unreferenced-source']); + expect(findings[0].testImporters).toEqual(['packages/widget/src/__tests__/helper.test.ts']); + }); +}); + +// ── 3. the role of an alias is decided by the filesystem ───────────────────── + +describe('a file alias is a ROOT; a directory alias is a RESOLUTION RULE', () => { + it('a directory alias resolves specifiers without making anything a root', () => { + const root = fixtureRepo( + 'dir-alias', + { + 'index.ts': "export { button } from '@/ui/button';\n", + 'ui/button.ts': 'export const button = 1;\n', + 'ui/orphan.ts': 'export const Orphan = 1;\n', + }, + config("{ find: '@', replacement: resolve(__dirname, './src') },"), + ); + // `@/ui/button` resolved (so button is alive), and the directory alias did + // NOT bless the whole tree (so the orphan is still reported). + expect(verdict(root)).toEqual(['unreferenced-source :: packages/widget/src/ui/orphan.ts']); + }); + + it('classifyAliases splits the two by asking the disk, not the spelling', () => { + const root = fixtureRepo( + 'classify', + { 'index.ts': 'export const entry = 1;\n', 'lib/shim.ts': 'export const shim = 1;\n' }, + config(), + ); + const pkgDir = path.join(root, 'packages/widget'); + const srcDir = path.join(pkgDir, 'src'); + const { roots, rules, outside } = classifyAliases( + [ + { find: { kind: 'string', value: '@', text: '@' }, replacement: srcDir }, + { find: { kind: 'string', value: '~shim', text: '~shim' }, replacement: path.join(srcDir, 'lib/shim.ts') }, + { find: { kind: 'string', value: '@other', text: '@other' }, replacement: path.join(root, 'packages/other/src') }, + ], + srcDir, + ); + expect(roots.map((r) => path.relative(srcDir, r.file))).toEqual([path.join('lib', 'shim.ts')]); + expect(rules).toHaveLength(1); + expect(outside).toHaveLength(1); + }); +}); + +// ── 4. an unreadable config is a finding, never a skip ─────────────────────── + +describe('what this gate cannot read, it refuses to guess about', () => { + it('an alias replacement it cannot evaluate is a finding', () => { + const root = fixtureRepo( + 'unevaluatable', + { 'index.ts': 'export const entry = 1;\n' }, + config("{ find: '~x', replacement: someHelper(__dirname) },"), + ); + expect(verdict(root)).toEqual(['unevaluatable-alias :: packages/widget']); + }); + + it('a missing entry is a finding, and every file does NOT become an orphan on top of it', () => { + const root = fixtureRepo( + 'no-entry', + { 'index.ts': 'export const entry = 1;\n', 'lib/helper.ts': 'export const helper = 1;\n' }, + ` +import { defineConfig } from 'vite'; +export default defineConfig({ resolve: { alias: [] } }); +`, + ); + // `no-roots` short-circuits: reporting both source files as unreferenced + // would bury the real defect under noise it caused itself. + expect(verdict(root)).toEqual(['no-entry :: packages/widget', 'no-roots :: packages/widget']); + }); + + it('evaluatePath understands resolve/join over __dirname and refuses anything else', () => { + const source = `export default { a: resolve(__dirname, 'src/x.ts'), b: process.env.ENTRY };`; + const parsed = ts.createSourceFile('t.ts', source, ts.ScriptTarget.Latest, false, ts.ScriptKind.TS); + const object = (parsed.statements[0] as ts.ExportAssignment).expression as ts.ObjectLiteralExpression; + const [a, b] = object.properties as unknown as ts.PropertyAssignment[]; + expect(evaluatePath(a.initializer, '/pkg')).toBe(path.resolve('/pkg', 'src/x.ts')); + expect(evaluatePath(b.initializer, '/pkg')).toBeNull(); + }); +}); + +// ── 5. `find` is a value, not source text ──────────────────────────────────── + +describe("an alias `find` is read as a VALUE — the miss this gate's first run made", () => { + it('reads a single-quoted TypeScript string, which JSON.parse cannot', () => { + const root = fixtureRepo( + 'single-quoted', + { 'index.ts': "export { x } from '@/lib/x';\n", 'lib/x.ts': 'export const x = 1;\n' }, + config("{ find: '@', replacement: resolve(__dirname, './src') },"), + ); + const { aliases } = readBuildConfig(path.join(root, 'packages/widget'), 'vite.config.ts'); + expect(aliases[0].find).toEqual({ kind: 'string', value: '@', text: "'@'" }); + // and the rule it produces actually resolves the specifier + expect(applyAliasRule(aliases[0], '@/lib/x')).toBe(path.join(root, 'packages/widget/src', '/lib/x')); + }); + + it('reads a regex literal as a pattern, matching only what the pattern matches', () => { + const root = fixtureRepo( + 'regex-find', + { 'index.ts': 'export const entry = 1;\n', 'lib/shim.ts': 'export const shim = 1;\n' }, + config("{ find: /^shim(\\.js)?$/, replacement: resolve(__dirname, 'src/lib/shim.ts') },"), + ); + const { aliases } = readBuildConfig(path.join(root, 'packages/widget'), 'vite.config.ts'); + expect(aliases[0].find.kind).toBe('regex'); + expect(applyAliasRule(aliases[0], 'shim.js')).toBe(aliases[0].replacement); + expect(applyAliasRule(aliases[0], 'shim/deep')).toBeNull(); + }); +}); + +// ── 6/7. the coverage table, and the scope that must not collapse ──────────── + +describe('the coverage table is re-derived on every run', () => { + it('a covered package that has moved is a finding, not a silent pass', () => { + const root = fixtureRepo('stale', { 'index.ts': 'export const entry = 1;\n' }, config()); + expect(auditCoverage(root, { 'packages/gone': { buildConfig: 'vite.config.ts', notes: 'x' } })).toEqual([ + { reason: 'stale-coverage', pkg: 'packages/gone', detail: 'no src/ directory' }, + ]); + }); + + it('a coverage entry with no notes is an unreviewable claim and is rejected', () => { + const root = fixtureRepo('no-notes', { 'index.ts': 'export const entry = 1;\n' }, config()); + const findings = auditCoverage(root, { 'packages/widget': { buildConfig: 'vite.config.ts', notes: '' } }); + expect(findings.map((f) => f.detail)).toContain('no notes — an unreviewable coverage claim'); + }); + + it('the uncovered remainder is DERIVED from the workspace, not written down', () => { + const root = fixtureRepo('remainder', { 'index.ts': 'export const entry = 1;\n' }, config()); + expect(analyze(root, {}).uncovered).toEqual(['packages/widget']); + expect(analyze(root, COVER).uncovered).toEqual([]); + }); +}); + +// ── 8. this repository ─────────────────────────────────────────────────────── + +describe('this repository', () => { + const result = analyze(repoRoot); + + it('is green — every shipped source file in every covered package is reachable', () => { + expect(result.findings).toEqual([]); + }); + + it('covers packages/components, whose alias leg is the reason this gate exists', () => { + expect(Object.keys(COVERED_PACKAGES)).toContain('packages/components'); + expect(COVERED_PACKAGES['packages/components'].notes).not.toBe(''); + }); + + it('did not go green by walking nothing', () => { + // The failure mode objectui#7070 names: a gate that passes because its loop + // body never ran. Floors, not exact figures, so ordinary churn does not + // rewrite this file. + expect(result.counters.packages).toBeGreaterThanOrEqual(1); + expect(result.counters.files).toBeGreaterThan(150); + expect(result.counters.reached).toBe(result.counters.files); + expect(result.counters.specifiers).toBeGreaterThan(500); + expect(result.counters.entries).toBeGreaterThanOrEqual(1); + expect(result.counters.aliasRoots).toBeGreaterThanOrEqual(1); + }); + + it('still reports the two use-sync-external-store shims as ALIVE', () => { + // The two files the card named as the false positives any gate of this + // class produces. Nothing imports either one; both are reached only through + // vite.config.ts. + const src = path.join(repoRoot, 'packages/components/src'); + for (const shim of ['lib/use-sync-external-store-shim.ts', 'lib/use-sync-external-store-with-selector-shim.ts']) { + expect(fs.existsSync(path.join(src, shim)), `${shim} is gone — this pin no longer proves anything`).toBe(true); + } + expect( + result.findings.filter((f) => f.file?.includes('use-sync-external-store')), + ).toEqual([]); + }); + + it("objectui#7515's orphan stays deleted", () => { + expect(fs.existsSync(path.join(repoRoot, 'packages/components/src/ui/toast.tsx'))).toBe(false); + // It was never on the published surface: the barrel that carries `ui/*` + // never named it. Re-derived rather than asserted, so a re-added export + // fails here rather than in a consumer's bundle. + const barrel = fs.readFileSync(path.join(repoRoot, 'packages/components/src/ui/index.ts'), 'utf8'); + expect(barrel).not.toContain("from './toast'"); + expect(barrel).toContain("from './sonner'"); // control: the live implementation IS carried + }); + + it('no longer declares the dependency that only the orphan imported', () => { + const manifest = JSON.parse( + fs.readFileSync(path.join(repoRoot, 'packages/components/package.json'), 'utf8'), + ) as { dependencies: Record }; + expect(manifest.dependencies['@radix-ui/react-toast']).toBeUndefined(); + expect(manifest.dependencies['@radix-ui/react-dialog']).toBeDefined(); // control: live radix deps stay + }); +}); + +// ── 9. the wiring ──────────────────────────────────────────────────────────── + +describe('the gate is wired where the sibling gates are', () => { + const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')) as { + scripts: Record; + }; + const ci = fs.readFileSync(path.join(repoRoot, '.github/workflows/ci.yml'), 'utf8'); + + it('package.json exposes it as a named script', () => { + expect(pkg.scripts['check:unreferenced-sources']).toBe(`node ${GATE}`); + }); + + it('ci.yml runs it after the install it needs (it parses with typescript)', () => { + const install = ci.indexOf('pnpm install --frozen-lockfile'); + const step = ci.indexOf('run: pnpm check:unreferenced-sources'); + expect(step, 'ci.yml does not run `pnpm check:unreferenced-sources`').toBeGreaterThan(-1); + expect(step, 'the check runs before dependencies are installed').toBeGreaterThan(install); + }); + + it('runs in the `type-check` job, where the other parse-based gates run', () => { + const jobs = ci.slice(ci.search(/^jobs:[ \t]*$/m)); + const typeCheck = jobs.slice(jobs.search(/^ {2}type-check:[ \t]*$/m)); + const nextJob = typeCheck.slice(1).search(/^ {2}\S/m); + const block = nextJob === -1 ? typeCheck : typeCheck.slice(0, nextJob + 1); + expect(block).toContain('run: pnpm check:unreferenced-sources'); + }); + + it("shares the sibling gate's parser rather than growing a second one", () => { + // Two parsers disagreeing about what a module edge IS is how one of two + // gates quietly stops covering a form. + const gate = fs.readFileSync(path.join(repoRoot, GATE), 'utf8'); + expect(gate).toContain("from './check-phantom-dependencies.mjs'"); + expect(gate).toContain('moduleSpecifiers'); + }); +}); diff --git a/scripts/check-unreferenced-sources.mjs b/scripts/check-unreferenced-sources.mjs new file mode 100644 index 0000000000..8239517317 --- /dev/null +++ b/scripts/check-unreferenced-sources.mjs @@ -0,0 +1,833 @@ +#!/usr/bin/env node +/** + * Every non-test source file in a COVERED package must be reachable from that + * package's published entry, or be named directly by its build config. + * + * Run: node scripts/check-unreferenced-sources.mjs (also `pnpm check:unreferenced-sources`) + * Exit: 0 = every covered package's sources are reachable, 1 = at least one is + * not, or the coverage table has gone stale, or a build config grew a + * shape this gate cannot evaluate + * + * ## The gap this closes (objectui#7515) + * + * Before this file, none of the 42 `scripts/check-*.mjs` gates could see an + * unreferenced source file. The nearest neighbours each answer a DIFFERENT + * question and none of them is this one: + * + * - `check-dist-completeness` asks whether `dist/` holds every file `tsc` + * says it emits — a question about the artifact, downstream of the graph; + * - `check-readme-exports` compares DOCUMENTED exports against shipped ones — + * a file that exports nothing anybody ships is invisible to it; + * - `check-i18n-dead-keys` covers message keys, not modules. + * + * So a `.tsx` that nothing imports and nothing exports sits in a PUBLISHED + * package indefinitely, and the detection mechanism is a human happening to + * read unrelated code. That is not a hypothetical: objectui#7319 and + * objectui#7397 were both found exactly that way, in the same week. + * + * ## Why an orphan is worth a gate rather than a tidy-up + * + * An orphan file is cheap. An orphan file WEARING A LIVE NAME is a trap. The + * file objectui#7319 removed carried the same export name as a live engine one + * package over and evaluated no predicate — name-completion alone could have + * wired a silently wrong renderer into a published package. The cost of the + * class is not the bytes; it is that the dead copy is indistinguishable from + * the live one at the call site. + * + * ## The alias leg is not optional, and it is the whole difficulty + * + * A plain import-graph walk over `packages/components` reports THREE unreached + * files, and two of them are alive: + * + * packages/components/src/lib/use-sync-external-store-shim.ts + * packages/components/src/lib/use-sync-external-store-with-selector-shim.ts + * + * Nothing in the package imports either one. They are reached because + * `packages/components/vite.config.ts` names them as `resolve.alias` + * REPLACEMENTS: + * + * { find: /^use-sync-external-store\/shim(\.js)?$/, + * replacement: resolve(__dirname, 'src/lib/use-sync-external-store-shim.ts') } + * + * The importer is a bundled third-party module, and the edge exists only inside + * the bundler's resolver — an import-graph walk cannot see it from any source + * file in the repository. A gate that skips this leg reports those two live + * files as dead on its FIRST RUN, and a gate that cries wolf on live files gets + * switched off rather than fixed. + * + * So reachability here has two roots, not one: + * + * 1. the package's published ENTRY (`build.lib.entry`), walked transitively; + * 2. every build-config ALIAS whose replacement is a FILE inside the package + * — the bundler names it, so something can reach it by that name — also + * walked transitively. + * + * An alias whose replacement is a DIRECTORY (`{ find: '@', replacement: + * resolve(__dirname, './src') }`) is not a root: it is a RESOLUTION RULE, used + * while walking to turn `@/ui/button` into a file. Both kinds are read from the + * same array, and the distinction is made by asking the filesystem which one it + * is rather than by pattern-matching the spelling. + * + * ## What this gate refuses to guess + * + * Every alias and entry expression is evaluated by {@link evaluatePath}, which + * understands exactly `resolve(...)`/`join(...)` over `__dirname` and string + * literals. Anything else is a FINDING, never a skip. That direction is + * deliberate and it is the only one that stays safe: an alias this gate cannot + * read is an alias whose target it will report as dead, so the choice is + * between failing loudly on the config and accusing a live file. The same rule + * covers a missing entry and a missing package. + * + * ## Scope: covered packages are DECLARED, and the remainder is MEASURED + * + * {@link COVERED_PACKAGES} is small on purpose. A gate that covers one package + * correctly beats one that covers forty with false positives, and the alias + * mechanisms differ per package — other build configs, tsconfig `paths`, + * re-export barrels. Every run therefore prints how many workspace packages + * with a `src/` tree are NOT covered, DERIVED from the workspace rather than + * written down, so the remainder cannot rot into a stale claim. + * + * Adding a package is a deliberate act with a verification cost: read its build + * config, confirm this gate evaluates every alias in it, and confirm the run is + * green for the right reason rather than because the walk collapsed. + * + * ## Two things this gate does NOT claim + * + * **It does not prove an alias is USED.** An alias entry pointing at a file + * nobody imports makes that file reachable here, because the build config + * names it. Deciding whether a bundled dependency still imports + * `use-sync-external-store/shim` means walking `node_modules`, which is a + * different gate on a different input. + * + * **`reachable from the entry` is not `referenced by anything`.** A helper used + * only by tests is unreachable from the published entry and IS reported — it is + * dead weight in the published artifact even though it has importers. The + * finding says so explicitly (`referenced only by test files`) so the reader + * can tell the two apart instead of guessing. `packages/components` has no such + * file today; the count is printed on every run. + */ + +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; +import { dirname, join, relative, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import ts from 'typescript'; + +import { SKIP_DIRS, TOOLING_FILE, listSourceFiles, moduleSpecifiers } from './check-phantom-dependencies.mjs'; +import { isEntrypoint } from './invoked-as.mjs'; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); + +/** Workspace directories that hold one package per subdirectory. */ +export const PACKAGE_ROOTS = ['packages', 'apps']; + +/** + * The `find` half of an alias, as a value. A regex `find` matches a specifier + * whole; a string `find` is a PREFIX, which is what makes `'@'` resolve + * `@/ui/button`. + * + * @typedef {{ kind: 'string', value: string, text: string } + * | { kind: 'regex', source: string, flags: string, text: string }} AliasFind + * @typedef {{ find: AliasFind, replacement: string }} Alias + * @typedef {Alias & { file: string }} AliasRoot + * @typedef {{ reason: string, pkg?: string, file?: string, detail?: string, testImporters?: string[] }} Finding + */ + +/** + * The packages this gate has been VERIFIED against, and why each one is here. + * + * `buildConfig` is the file the entry and the aliases are read from. `notes` + * records what was checked by hand when the package was added — an entry + * without one is an unreviewable claim, so {@link auditCoverage} rejects it. + * + * @type {Record} + */ +export const COVERED_PACKAGES = { + 'packages/components': { + buildConfig: 'vite.config.ts', + notes: + 'objectui#7515. Entry `src/index.ts` from `build.lib.entry`. Ten `resolve.alias` entries: ' + + 'one directory alias inside the package (`@` -> `./src`, matching its tsconfig `paths`), six ' + + 'pointing at sibling packages (outside this population), and three regex aliases naming the two ' + + '`use-sync-external-store` shims, which no source file imports and which are alive only through ' + + 'this config.', + }, +}; + +/** Extensions tried, in order, when a specifier names no extension of its own. */ +export const RESOLVE_ORDER = ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs']; + +/** + * Turn a module specifier's target path into the file it actually names. + * + * Handles the three spellings a TypeScript source uses: the exact path, the + * extensionless path, and the directory whose `index.*` is meant. The `.js` + * rewrite is the fourth — a specifier written `./x.js` against `x.ts` on disk, + * which `moduleResolution: bundler` and every bundler here accept. + * + * @param {string} target absolute, extension optional + * @returns {string | null} + */ +export function resolveTarget(target) { + const isFile = (candidate) => existsSync(candidate) && statSync(candidate).isFile(); + if (isFile(target)) return target; + for (const ext of RESOLVE_ORDER) if (isFile(target + ext)) return target + ext; + for (const ext of RESOLVE_ORDER) if (isFile(join(target, `index${ext}`))) return join(target, `index${ext}`); + const withoutJs = target.match(/^(.*)\.([cm]?js)x?$/); + if (withoutJs) { + for (const ext of RESOLVE_ORDER) if (isFile(withoutJs[1] + ext)) return withoutJs[1] + ext; + } + return null; +} + +// ── reading the build config ───────────────────────────────────────────────── + +/** + * Evaluate a path expression from a build config, or refuse to. + * + * The grammar is deliberately tiny — `resolve(...)` / `join(...)` (bare or on + * `path`) over `__dirname` and string literals, plus a bare string literal. + * Everything else returns `null`, and every caller turns a `null` into a + * FINDING rather than a skip: see the module header for why that direction is + * the only safe one. + * + * @param {ts.Node} node + * @param {string} packageDir absolute; what `__dirname` means in this config + * @returns {string | null} an absolute path, or null when the shape is unknown + */ +export function evaluatePath(node, packageDir) { + if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text; + if (ts.isIdentifier(node) && node.text === '__dirname') return packageDir; + if (ts.isCallExpression(node)) { + const callee = ts.isPropertyAccessExpression(node.expression) + ? node.expression.name.text + : ts.isIdentifier(node.expression) + ? node.expression.text + : null; + if (callee !== 'resolve' && callee !== 'join') return null; + const parts = []; + for (const argument of node.arguments) { + const value = evaluatePath(argument, packageDir); + if (value === null) return null; + parts.push(value); + } + if (parts.length === 0) return null; + return callee === 'resolve' ? resolve(...parts) : join(...parts); + } + return null; +} + +/** The object literal a `defineConfig({...})` or a bare `{...}` default export carries. */ +function defaultExportObject(source) { + let found = null; + const visit = (node) => { + if (found) return; + if (ts.isExportAssignment(node)) { + let expression = node.expression; + if (ts.isCallExpression(expression) && expression.arguments.length > 0) [expression] = expression.arguments; + if (ts.isObjectLiteralExpression(expression)) found = expression; + return; + } + ts.forEachChild(node, visit); + }; + visit(source); + return found; +} + +/** The initializer of `name` on an object literal, or null. */ +function propertyOf(object, name) { + if (!object || !ts.isObjectLiteralExpression(object)) return null; + for (const member of object.properties) { + if (!ts.isPropertyAssignment(member)) continue; + const key = ts.isIdentifier(member.name) || ts.isStringLiteral(member.name) ? member.name.text : null; + if (key === name) return member.initializer; + } + return null; +} + +/** + * The `find` half of an alias entry, as a VALUE rather than as source text. + * + * Read structurally because the text is not parseable as JSON: TypeScript + * sources spell strings with single quotes, and the first version of this file + * stored `findNode.getText()` and then tried `JSON.parse` on it. That failed on + * every single-quoted alias in the repository and made + * {@link auditAliasMechanisms} report `packages/components`' own `@` alias as + * unmodelled — a false alarm on the one package this gate covers, caught on the + * first run (objectui#7515). + * + * @param {ts.Node} node + * @param {ts.SourceFile} source + * @returns {{ kind: 'string', value: string, text: string } | { kind: 'regex', source: string, flags: string, text: string } | null} + */ +export function readFind(node, source) { + const text = node.getText(source); + if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) { + return { kind: 'string', value: node.text, text }; + } + if (node.kind === ts.SyntaxKind.RegularExpressionLiteral) { + const match = text.match(/^\/(.*)\/([a-z]*)$/s); + if (!match) return null; + return { kind: 'regex', source: match[1], flags: match[2], text }; + } + return null; +} + +/** + * One package's declared entry and alias table, read from its build config. + * + * Returns `problems` alongside the data: an alias whose `replacement` this + * parser cannot evaluate, or a missing/unreadable entry. Callers report those; + * nothing here decides. + * + * @param {string} packageDir absolute + * @param {string} configName e.g. `vite.config.ts` + * @returns {{ entries: string[], aliases: Alias[], problems: Finding[] }} + */ +export function readBuildConfig(packageDir, configName) { + /** @type {Finding[]} */ + const problems = []; + const configPath = join(packageDir, configName); + if (!existsSync(configPath)) { + return { entries: [], aliases: [], problems: [{ reason: 'missing-build-config', detail: configName }] }; + } + const source = ts.createSourceFile( + configName, + readFileSync(configPath, 'utf8'), + ts.ScriptTarget.Latest, + /* setParentNodes */ false, + ts.ScriptKind.TS, + ); + const config = defaultExportObject(source); + if (!config) { + return { + entries: [], + aliases: [], + problems: [{ reason: 'unreadable-build-config', detail: `${configName}: no default-exported object literal` }], + }; + } + + // ── entry ── + const entries = []; + const entryNode = propertyOf(propertyOf(propertyOf(config, 'build'), 'lib'), 'entry'); + if (!entryNode) { + problems.push({ reason: 'no-entry', detail: `${configName}: build.lib.entry is absent` }); + } else { + const nodes = ts.isArrayLiteralExpression(entryNode) + ? [...entryNode.elements] + : ts.isObjectLiteralExpression(entryNode) + ? entryNode.properties.filter(ts.isPropertyAssignment).map((p) => p.initializer) + : [entryNode]; + for (const node of nodes) { + const value = evaluatePath(node, packageDir); + if (value === null) { + problems.push({ reason: 'unevaluatable-entry', detail: `${configName}: ${node.getText(source)}` }); + continue; + } + const file = resolveTarget(resolve(packageDir, value)); + if (!file) { + problems.push({ reason: 'entry-not-on-disk', detail: `${configName}: ${value}` }); + continue; + } + entries.push(file); + } + } + + // ── aliases ── + const aliases = []; + const aliasNode = propertyOf(propertyOf(config, 'resolve'), 'alias'); + if (aliasNode && ts.isArrayLiteralExpression(aliasNode)) { + for (const element of aliasNode.elements) { + if (!ts.isObjectLiteralExpression(element)) { + problems.push({ reason: 'unevaluatable-alias', detail: `${configName}: ${element.getText(source)}` }); + continue; + } + const findNode = propertyOf(element, 'find'); + const replacementNode = propertyOf(element, 'replacement'); + if (!findNode || !replacementNode) { + problems.push({ reason: 'unevaluatable-alias', detail: `${configName}: ${element.getText(source)}` }); + continue; + } + const replacement = evaluatePath(replacementNode, packageDir); + const find = readFind(findNode, source); + if (replacement === null || find === null) { + const offender = replacement === null ? replacementNode : findNode; + problems.push({ reason: 'unevaluatable-alias', detail: `${configName}: ${offender.getText(source)}` }); + continue; + } + aliases.push({ find, replacement: resolve(packageDir, replacement) }); + } + } else if (aliasNode && ts.isObjectLiteralExpression(aliasNode)) { + for (const member of aliasNode.properties) { + if (!ts.isPropertyAssignment(member)) { + problems.push({ reason: 'unevaluatable-alias', detail: `${configName}: ${member.getText(source)}` }); + continue; + } + const name = ts.isIdentifier(member.name) || ts.isStringLiteral(member.name) ? member.name.text : null; + const replacement = evaluatePath(member.initializer, packageDir); + if (name === null || replacement === null) { + problems.push({ reason: 'unevaluatable-alias', detail: `${configName}: ${member.getText(source)}` }); + continue; + } + aliases.push({ find: { kind: 'string', value: name, text: name }, replacement: resolve(packageDir, replacement) }); + } + } else if (aliasNode) { + problems.push({ reason: 'unevaluatable-alias', detail: `${configName}: resolve.alias is neither array nor object` }); + } + + return { entries, aliases, problems }; +} + +/** + * Split an alias table into the two roles the header describes. + * + * A replacement that IS a file on disk is a ROOT: the bundler names that module + * by that alias, so something outside the source graph can reach it. A + * replacement that is a DIRECTORY is a RESOLUTION RULE, applied to specifiers + * during the walk. The filesystem decides, not the spelling. + * + * Aliases pointing outside `srcDir` are neither — they belong to another + * package's population. + * + * @param {Alias[]} aliases + * @param {string} srcDir absolute + * @returns {{ roots: AliasRoot[], rules: Alias[], outside: Alias[] }} + */ +export function classifyAliases(aliases, srcDir) { + /** @type {AliasRoot[]} */ + const roots = []; + /** @type {Alias[]} */ + const rules = []; + /** @type {Alias[]} */ + const outside = []; + for (const alias of aliases) { + const inside = alias.replacement === srcDir || alias.replacement.startsWith(srcDir + sep); + if (!inside) { + outside.push(alias); + continue; + } + if (existsSync(alias.replacement) && statSync(alias.replacement).isDirectory()) { + rules.push(alias); + continue; + } + const file = resolveTarget(alias.replacement); + if (file) roots.push({ ...alias, file }); + else outside.push(alias); + } + return { roots, rules, outside }; +} + +/** + * A bare-specifier alias rule applied to a specifier, or null. + * + * Vite's string `find` is a PREFIX match with a literal replacement of that + * prefix, which is what makes `{ find: '@' }` resolve `@/ui/button`. The + * `find` text arrives here as it was written in the config, so a regex literal + * stays a regex and a quoted string stays a string. + * + * @param {Alias} rule + * @param {string} specifier + * @returns {string | null} absolute path, extension optional + */ +export function applyAliasRule(rule, specifier) { + if (rule.find.kind === 'regex') { + let pattern; + try { + pattern = new RegExp(rule.find.source, rule.find.flags); + } catch { + return null; + } + return pattern.test(specifier) ? rule.replacement : null; + } + if (!specifier.startsWith(rule.find.value)) return null; + return rule.replacement + specifier.slice(rule.find.value.length); +} + +// ── the walk ───────────────────────────────────────────────────────────────── + +/** + * Every file reachable from `roots`, following relative and alias specifiers. + * + * Bare specifiers that no alias rule matches are external and stop the walk; + * that is the whole reason the alias table is read at all. + * + * @param {string[]} roots absolute file paths + * @param {{ srcDir: string, rules: Alias[] }} options + * @returns {{ reached: Set, specifiers: number }} + */ +export function walkFrom(roots, { srcDir, rules }) { + const reached = new Set(); + const queue = [...roots]; + let specifiers = 0; + while (queue.length > 0) { + const file = queue.pop(); + if (reached.has(file)) continue; + reached.add(file); + const inPackage = file === srcDir || file.startsWith(srcDir + sep); + if (!inPackage) continue; // a sibling package's file: counted as reached, not walked + const text = readFileSync(file, 'utf8'); + if (!/\b(?:import|export|require)\b/.test(text)) continue; + for (const use of moduleSpecifiers(text, file)) { + specifiers += 1; + const { specifier } = use; + let target = null; + if (specifier.startsWith('.')) target = resolve(dirname(file), specifier); + else { + for (const rule of rules) { + const applied = applyAliasRule(rule, specifier); + if (applied !== null) { + target = applied; + break; + } + } + } + if (target === null) continue; + const resolved = resolveTarget(target); + if (resolved) queue.push(resolved); + } + } + return { reached, specifiers }; +} + +// ── the judgement ──────────────────────────────────────────────────────────── + +/** Files that ship: every source under `src/` that is not tooling and not a declaration. */ +export function population(srcDir) { + return listSourceFiles(srcDir).filter((file) => !TOOLING_FILE.test(file.split('\\').join('/'))); +} + +/** Test files under `src/`, used only to describe a finding, never to judge one. */ +export function toolingFiles(srcDir) { + /** @type {string[]} */ + const found = []; + const walk = (dir) => { + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { + if (entry.name.startsWith('.') || SKIP_DIRS.has(entry.name)) continue; + const full = join(dir, entry.name); + if (entry.isDirectory()) walk(full); + else if (TOOLING_FILE.test(full.split('\\').join('/')) && /\.[cm]?[jt]sx?$/.test(entry.name)) found.push(full); + } + }; + walk(srcDir); + return found; +} + +/** + * One covered package's verdict. + * + * @param {string} root repository root, absolute + * @param {string} pkgPath e.g. `packages/components` + * @param {{ buildConfig: string, notes: string }} coverage + */ +export function auditPackage(root, pkgPath, coverage) { + const packageDir = join(root, pkgPath); + const srcDir = join(packageDir, 'src'); + const rel = (file) => relative(root, file).split('\\').join('/'); + + const { entries, aliases, problems } = readBuildConfig(packageDir, coverage.buildConfig); + /** @type {Finding[]} */ + const findings = problems.map((problem) => ({ ...problem, pkg: pkgPath })); + + const { roots: aliasRoots, rules, outside } = classifyAliases(aliases, srcDir); + const roots = [...entries, ...aliasRoots.map((alias) => alias.file)]; + const files = population(srcDir); + + if (roots.length === 0) { + findings.push({ + reason: 'no-roots', + pkg: pkgPath, + detail: `${coverage.buildConfig} yielded no entry and no in-package alias target — every source file would read as unreachable`, + }); + return { + findings, + counters: { files: files.length, reached: 0, specifiers: 0, entries: entries.length, aliasRoots: 0, rules: 0, outside: outside.length }, + }; + } + + const { reached, specifiers } = walkFrom(roots, { srcDir, rules }); + const unreached = files.filter((file) => !reached.has(file)); + + // Only to DESCRIBE a finding: a file with test importers is dead weight in the + // artifact all the same, but the reader should not have to work out which kind + // of dead it is. + const testImporters = new Map(); + if (unreached.length > 0) { + const wanted = new Set(unreached); + for (const test of toolingFiles(srcDir)) { + const text = readFileSync(test, 'utf8'); + if (!/\b(?:import|export|require)\b/.test(text)) continue; + for (const use of moduleSpecifiers(text, test)) { + let target = null; + if (use.specifier.startsWith('.')) target = resolve(dirname(test), use.specifier); + else { + for (const rule of rules) { + const applied = applyAliasRule(rule, use.specifier); + if (applied !== null) { + target = applied; + break; + } + } + } + if (target === null) continue; + const resolved = resolveTarget(target); + if (resolved && wanted.has(resolved)) { + if (!testImporters.has(resolved)) testImporters.set(resolved, []); + testImporters.get(resolved).push(rel(test)); + } + } + } + } + + for (const file of unreached) { + findings.push({ + reason: 'unreferenced-source', + pkg: pkgPath, + file: rel(file), + testImporters: (testImporters.get(file) ?? []).sort(), + }); + } + + return { + findings, + counters: { + files: files.length, + reached: files.filter((file) => reached.has(file)).length, + specifiers, + entries: entries.length, + aliasRoots: aliasRoots.length, + rules: rules.length, + outside: outside.length, + }, + }; +} + +/** + * Coverage-table entries that no longer describe the repository. + * + * A covered package that has moved, lost its `src/` tree or lost its build + * config leaves this gate quietly checking nothing — the silent-widening + * direction every sibling gate here treats as worse than a red run. + */ +export function auditCoverage(root, covered = COVERED_PACKAGES) { + /** @type {Finding[]} */ + const findings = []; + for (const [pkgPath, entry] of Object.entries(covered)) { + if (!existsSync(join(root, pkgPath, 'src'))) { + findings.push({ reason: 'stale-coverage', pkg: pkgPath, detail: 'no src/ directory' }); + continue; + } + if (!existsSync(join(root, pkgPath, entry.buildConfig))) { + findings.push({ reason: 'stale-coverage', pkg: pkgPath, detail: `${entry.buildConfig} is gone` }); + } + if (!entry.notes || entry.notes.trim().length === 0) { + findings.push({ reason: 'stale-coverage', pkg: pkgPath, detail: 'no notes — an unreviewable coverage claim' }); + } + } + return findings; +} + +/** + * A covered package must not carry an alias mechanism this gate does not read. + * + * `packages/components` declares its `@` alias TWICE — once in `vite.config.ts` + * for the bundler, once in `tsconfig.json` `paths` for the type program — and + * this gate reads only the first. That is sound exactly as long as the second + * says nothing the first does not, so the claim is re-derived here on every run + * instead of being asserted in a comment. A `paths` key with no matching alias + * is a resolution route the walk would miss. + */ +export function auditAliasMechanisms(root, pkgPath, aliases) { + /** @type {Finding[]} */ + const findings = []; + const tsconfigPath = join(root, pkgPath, 'tsconfig.json'); + if (!existsSync(tsconfigPath)) return findings; + let paths; + try { + // Comment-tolerant: these tsconfigs carry `//` commentary. + const parsed = ts.parseConfigFileTextToJson(tsconfigPath, readFileSync(tsconfigPath, 'utf8')); + paths = parsed.config?.compilerOptions?.paths; + } catch { + return [{ reason: 'unreadable-tsconfig', pkg: pkgPath, detail: 'tsconfig.json could not be parsed' }]; + } + if (!paths) return findings; + const aliasFinds = new Set( + aliases.filter((alias) => alias.find.kind === 'string').map((alias) => alias.find.value), + ); + for (const key of Object.keys(paths)) { + const stem = key.endsWith('/*') ? key.slice(0, -2) : key; + if (!aliasFinds.has(stem) && !aliasFinds.has(key)) { + findings.push({ + reason: 'unmodelled-alias-mechanism', + pkg: pkgPath, + detail: `tsconfig.json paths declares '${key}', which ${pkgPath}'s build config does not`, + }); + } + } + return findings; +} + +/** + * Workspace packages with a `src/` tree that this gate does NOT cover. + * + * Derived every run. The remainder is the honest half of a scoped gate, and a + * written-down number would be a claim nothing recomputes. + */ +export function uncoveredPackages(root, covered = COVERED_PACKAGES) { + /** @type {string[]} */ + 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 pkgPath = `${scanRoot}/${entry.name}`; + if (!existsSync(join(root, pkgPath, 'package.json'))) continue; + if (!existsSync(join(root, pkgPath, 'src'))) continue; + if (covered[pkgPath]) continue; + found.push(pkgPath); + } + } + return found; +} + +/** + * The whole verdict for `root`. + * + * @param {string} root repository root + * @param {Record} covered + */ +export function analyze(root, covered = COVERED_PACKAGES) { + /** @type {Finding[]} */ + const findings = [...auditCoverage(root, covered)]; + const counters = { packages: 0, files: 0, reached: 0, specifiers: 0, entries: 0, aliasRoots: 0, rules: 0 }; + + for (const [pkgPath, entry] of Object.entries(covered)) { + if (findings.some((finding) => finding.reason === 'stale-coverage' && finding.pkg === pkgPath)) continue; + const { aliases } = readBuildConfig(join(root, pkgPath), entry.buildConfig); + findings.push(...auditAliasMechanisms(root, pkgPath, aliases)); + const result = auditPackage(root, pkgPath, entry); + findings.push(...result.findings); + counters.packages += 1; + counters.files += result.counters.files; + counters.reached += result.counters.reached; + counters.specifiers += result.counters.specifiers; + counters.entries += result.counters.entries; + counters.aliasRoots += result.counters.aliasRoots; + counters.rules += result.counters.rules; + } + + return { findings, counters, uncovered: uncoveredPackages(root, covered) }; +} + +const HINTS = { + 'unreferenced-source': + 'Nothing reaches this file — not the package entry, not a build-config alias. Delete it, or, if it ' + + 'is meant to ship, wire it into the barrel it belongs to. Before deleting, confirm separately that ' + + 'it contributes no PUBLIC export: unreferenced and not-exported are two questions, and an orphan ' + + 'wearing a live export name is the hazard objectui#7515 exists for.', + 'unevaluatable-alias': + 'This alias entry uses a shape scripts/check-unreferenced-sources.mjs cannot evaluate, so it cannot ' + + 'know which file the bundler names. Reported rather than skipped: skipping it would accuse whatever ' + + 'file it points at of being dead. Teach `evaluatePath` the shape, or spell the alias with ' + + '`resolve(__dirname, ...)`.', + 'unevaluatable-entry': 'Same as unevaluatable-alias, for `build.lib.entry`.', + 'entry-not-on-disk': 'The declared entry does not resolve to a file — the whole walk would start nowhere.', + 'no-entry': 'The build config declares no `build.lib.entry`, so this gate has no root to walk from.', + 'missing-build-config': 'COVERED_PACKAGES names a build config this package does not have.', + 'unreadable-build-config': 'The build config has no default-exported object literal to read.', + 'no-roots': 'Neither an entry nor an in-package alias target survived — a walk from nothing reports everything.', + 'stale-coverage': + 'An entry in COVERED_PACKAGES no longer describes the repository. Fix it or remove it — a coverage ' + + 'claim whose package has moved leaves this gate checking nothing while still reporting a pass.', + 'unmodelled-alias-mechanism': + "A covered package resolves modules through a mechanism this gate does not read (tsconfig `paths` " + + 'without a matching build-config alias). Until it is modelled, the walk can miss an edge and accuse ' + + 'a live file.', + 'unreadable-tsconfig': 'A covered package tsconfig could not be parsed, so its alias mechanisms are unknown.', +}; + +const invokedDirectly = isEntrypoint(import.meta.url); + +if (invokedDirectly) { + const argOf = (name) => { + const index = process.argv.indexOf(name); + return index > -1 ? process.argv[index + 1] : null; + }; + const root = resolve(argOf('--root') ?? resolve(scriptDir, '..')); + + let result; + try { + result = analyze(root); + } catch (error) { + console.error( + `x ${error.message}\n\n` + + ' Reported as a failure rather than a pass: this gate decides whether a package ships a file\n' + + ' nothing reaches, so losing an input means it cannot decide, and a green verdict would have\n' + + ' looked at nothing.', + ); + process.exit(1); + } + + const { findings, counters, uncovered } = result; + + // A refactor that quietly emptied the walk would satisfy every assertion in + // the test file while checking nothing — the same size guard the sibling + // gates open with. + if (counters.packages === 0 || counters.files < 20 || counters.specifiers < 200 || counters.entries === 0) { + console.error( + `The scan collapsed: ${counters.packages} covered package(s), ${counters.files} source file(s), ` + + `${counters.specifiers} module specifier(s), ${counters.entries} entry point(s). The population walk ` + + 'or the config reader is broken, and an empty comparison would pass while asserting nothing.', + ); + process.exit(1); + } + + console.log( + `Scanned ${counters.packages} covered package(s): ${counters.files} shipped source file(s), ` + + `${counters.reached} reached, ${counters.specifiers} module specifier(s) followed from ` + + `${counters.entries} declared entry point(s) and ${counters.aliasRoots} build-config alias target(s), ` + + `through ${counters.rules} alias resolution rule(s).`, + ); + console.log( + `Not covered by this gate: ${uncovered.length} workspace package(s) with a src/ tree ` + + `(${PACKAGE_ROOTS.join(', ')}). Adding one means verifying its alias mechanisms first — ` + + 'see COVERED_PACKAGES in scripts/check-unreferenced-sources.mjs.', + ); + + if (findings.length === 0) { + console.log('OK Every shipped source file in every covered package is reachable.'); + process.exit(0); + } + + console.error(`\nx ${findings.length} problem(s):\n`); + for (const finding of findings) { + if (finding.reason === 'unreferenced-source') { + const extra = + finding.testImporters.length > 0 + ? ` (referenced only by test files: ${finding.testImporters.join(', ')})` + : ''; + console.error(` ${finding.file} [unreferenced-source] nothing reaches this file${extra}`); + continue; + } + console.error(` ${finding.pkg} [${finding.reason}] ${finding.detail}`); + } + for (const reason of Object.keys(HINTS)) { + if (findings.some((finding) => finding.reason === reason)) console.error(`\n${reason}: ${HINTS[reason]}`); + } + console.error( + '\nTwo were found by a human reading unrelated code in one week (objectui#7319, objectui#7397), which ' + + 'is\nthe detection mechanism this gate replaces. See the header of ' + + 'scripts/check-unreferenced-sources.mjs (objectui#7515).', + ); + process.exit(1); +}