From e855f935ec5ad7fc6fe991e16d93d4aa79726c4a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 18:14:26 +0000 Subject: [PATCH] feat(build): require the directory form of the tooling exclude in every published build tsconfig 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 then becomes a program input, and an emitting program writes it into the published `dist`. That has happened three times and been caught by a gate zero times: objectui#4006 (73 `*.test.d.ts` published from two packages), objectui#4836 (9 more, one of them an emitted module whose first statement imports `vitest`) and objectui#6943 (the same package as the first, because that fix wrote the name form). Each repair was correct and local, and each left the trap armed everywhere else. objectui#7212 measured the standing exposure instead of another instance: 29 published packages carried the name form with ZERO offending files. Adds `scripts/check-published-tsconfig-tooling-exclude.mjs` (`pnpm check:published-tsconfig-exclude`), wired into ci.yml's Type Check job, and converts 28 build tsconfigs in the same commit so main is green on merge. The gate reads `exclude` arrays and nothing else: no build, no artifact, no emit model. That is the narrower scope which keeps it clear of the modelling objectui#4846 declined for the artifact-level gate, which is untouched and stays the second line of defence. Six published packages are named carve-outs whose emitter never builds its program from this file list, each re-proving its own reason on every run: cli, create-plugin and data-objectstack (tsup entry graph), plugin-charts (its tooling exclude lives in the `dts()` options), console and runner (Vite applications, `noEmit: true`, no `dts()` plugin). create-plugin and runner were found at authoring time and were inside the ruling's initial red set. The conversion is emit-neutral, measured rather than assumed: every enforced package's build program file list was resolved with TypeScript's own config resolver before and after, and all 33 came back identical. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC --- ...lished-tsconfig-tooling-exclude-ratchet.md | 41 ++ .github/workflows/ci.yml | 27 ++ content/docs/guide/ci-cd-pipeline.md | 9 +- package.json | 1 + packages/app-shell/tsconfig.json | 17 +- packages/auth/tsconfig.json | 17 +- packages/collaboration/tsconfig.json | 17 +- packages/components/tsconfig.json | 15 +- packages/i18n/tsconfig.json | 15 +- packages/layout/tsconfig.json | 17 +- packages/mobile/tsconfig.json | 17 +- packages/permissions/tsconfig.json | 17 +- packages/plugin-ai/tsconfig.json | 17 +- packages/plugin-calendar/tsconfig.json | 17 +- packages/plugin-chatbot/tsconfig.json | 17 +- packages/plugin-dashboard/tsconfig.json | 17 +- packages/plugin-detail/tsconfig.json | 17 +- packages/plugin-editor/tsconfig.json | 10 +- packages/plugin-form/tsconfig.json | 17 +- packages/plugin-gantt/tsconfig.json | 17 +- packages/plugin-kanban/tsconfig.json | 17 +- packages/plugin-list/tsconfig.json | 17 +- packages/plugin-map/tsconfig.json | 17 +- packages/plugin-markdown/tsconfig.json | 17 +- packages/plugin-report/tsconfig.json | 17 +- packages/plugin-timeline/tsconfig.json | 17 +- packages/plugin-tree/tsconfig.json | 17 +- packages/providers/tsconfig.json | 17 +- packages/react-runtime/tsconfig.json | 15 +- packages/react/tsconfig.json | 17 +- packages/sdui-parser/tsconfig.json | 15 +- packages/types/tsconfig.json | 18 +- ...published-tsconfig-tooling-exclude.test.ts | 383 +++++++++++++++ ...eck-published-tsconfig-tooling-exclude.mjs | 447 ++++++++++++++++++ 34 files changed, 1341 insertions(+), 29 deletions(-) create mode 100644 .changeset/7212-published-tsconfig-tooling-exclude-ratchet.md create mode 100644 scripts/__tests__/check-published-tsconfig-tooling-exclude.test.ts create mode 100644 scripts/check-published-tsconfig-tooling-exclude.mjs diff --git a/.changeset/7212-published-tsconfig-tooling-exclude-ratchet.md b/.changeset/7212-published-tsconfig-tooling-exclude-ratchet.md new file mode 100644 index 0000000000..b3c6f63412 --- /dev/null +++ b/.changeset/7212-published-tsconfig-tooling-exclude-ratchet.md @@ -0,0 +1,41 @@ +--- +--- + +Repo tooling and build config only — no published package changes, so no version bump. + +Published build tsconfigs must now exclude tooling DIRECTORIES, not just tooling FILE NAMES, +and a new per-PR gate enforces it: `pnpm check:published-tsconfig-exclude` +(`scripts/check-published-tsconfig-tooling-exclude.mjs`, wired into `ci.yml`'s Type Check job). + +The same defect had been repaired three times and gated never — objectui#4006 (73 `*.test.d.ts` +published from two packages), objectui#4836 (9 more, one of them an emitted module whose first +statement imports `vitest`) and objectui#6943 (the same package as the first, because that fix +wrote the name form and the directory form was never generalised). objectui#7212 measured the +standing exposure instead of another instance: 29 published packages carried the name form with +ZERO offending files, green because nobody had yet added a shared helper to a `__tests__/` +directory, not because the config would stop one. The maintainer ruled for the ratchet on +2026-09-02. + +The gate reads `exclude` arrays and nothing else — no build, no artifact, no emit model. That +narrowness is the point: objectui#4846 measured the cheap static gate "no build tsconfig program +may contain a tooling file" and rejected it, because deciding whether a tooling file in a program +is a defect means reimplementing three third-party emit semantics. Asking only DOES THE CONFIG +NAME THE DIRECTORY needs none of that. The artifact-level `pnpm check:published-dist` is +untouched and stays the second line of defence — it is the only criterion that cannot be wrong +about what actually ships. + +28 build tsconfigs were converted in the same commit so `main` is green on merge. The conversion +is emit-neutral, and that was measured rather than assumed: each package's build program file +list was resolved with TypeScript's own config resolver before and after, and all 33 enforced +packages came back byte-identical (0 files added, 0 removed). Identical program input means +identical emit, so no published artifact and no bundle moves. + +Six published packages are named carve-outs rather than converted, because their emitter never +builds its program from this file list, and each re-proves its own reason on every run instead of +being trusted: `@object-ui/cli`, `@object-ui/create-plugin` and `@object-ui/data-objectstack` emit +from a `tsup` entry graph; `@object-ui/plugin-charts` keeps its tooling exclude in the `dts()` +options and carries no `exclude` key in its tsconfig; `@object-ui/console` and `@object-ui/runner` +are Vite applications with `noEmit: true` and no `dts()` plugin, so they write no declarations at +all. Two of those six — `create-plugin` and `runner` — were found at authoring time and were +inside the ruling's initial red set; requiring a shape in a config their emitter never consults +would have been a check that is green about nothing. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e5e9d0f398..adbcab8f51 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -263,6 +263,33 @@ jobs: if: steps.relevant.outputs.should_run == 'true' run: pnpm check:self-import + # 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, + # and an emitting program writes it into the published `dist`. That has + # happened three times — objectui#4006 (73 `*.test.d.ts` across two + # packages), objectui#4836 (9 more, one of them an emitted module whose + # first statement imports `vitest`) and objectui#6943 (the same package as + # the first, because that fix wrote the name form) — and every one was + # found by a human, never by a gate. objectui#7212 measured the standing + # exposure: 29 published packages carried the name form with ZERO offending + # files, green because nobody had added such a helper yet. This gate reads + # `exclude` ARRAYS only: no build, no artifact, no emit model, which is what + # keeps it clear of the modelling objectui#4846 declined for the + # artifact-level gate. That gate stays as the second line of defence — it + # is the only criterion that cannot be wrong about what actually ships, and + # it lives in `published-dist-gate.yml` because it needs a full-repo build. + # Its runnable alias is deliberately NOT named anywhere in this file: + # `check-published-dist-tooling.test.ts` asserts by substring that no + # per-PR workflow mentions it, which is how the ruling that kept a + # full-repo build off the PR path is held — prose naming it would read the + # same to that scan as a step running it. Config + # reads plus the sibling gate's `typescript`-importing scanner, so it needs + # the install and nothing built, same as the two steps above. + - name: Verify published build tsconfigs exclude tooling DIRECTORIES, not just names + if: steps.relevant.outputs.should_run == 'true' + run: pnpm check:published-tsconfig-exclude + # `sideEffects` is a PUBLISHED CONTRACT, and an ARRAY form of it fails in # the one direction nothing can witness: an INCOMPLETE array drops a # registration inside a CONSUMER's bundle, with no error, no warning and diff --git a/content/docs/guide/ci-cd-pipeline.md b/content/docs/guide/ci-cd-pipeline.md index cbde54abf3..97ef2f6e7f 100644 --- a/content/docs/guide/ci-cd-pipeline.md +++ b/content/docs/guide/ci-cd-pipeline.md @@ -206,7 +206,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: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: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: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 | | `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** | @@ -1299,6 +1299,13 @@ Three things about it are easy to get wrong and are written down in the script's has none per PR: `ci.yml`'s **Build & E2E** builds only `@object-ui/console`, and **Type Check** gets only the dependency closure from turbo's `dependsOn: ["^build"]`, so leaf packages are never built there. The blocking copy runs on the publish path instead — see below. +- **A per-PR sibling now covers the CONFIG half.** `pnpm check:published-tsconfig-exclude` + ([#7212](https://github.com/objectstack-ai/objectui/issues/7212)) runs in **Type Check** and + fails when a published package's build tsconfig names tooling by file name without naming the + tooling directories. It is not a replacement and does not weaken anything here: it checks + config shape, never emit semantics, so a package can satisfy it and still ship tooling + material for some other reason. This gate stays the second line of defence and the only + criterion that cannot be wrong about what actually ships. ### Spec Range Floors (`spec-range-floors.yml`) diff --git a/package.json b/package.json index 1bb6f8fcca..1c03da7c42 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "census:body-dialect": "node scripts/body-dialect-census.mjs", "check:control-bytes": "node scripts/check-control-bytes.mjs", "check:published-dist": "node scripts/check-published-dist-tooling.mjs", + "check:published-tsconfig-exclude": "node scripts/check-published-tsconfig-tooling-exclude.mjs", "check:spec-floors": "node scripts/check-spec-range-floors.mjs", "check:i18n-keys": "node scripts/check-i18n-call-site-keys.mjs", "check:i18n-drift": "node scripts/check-i18n-en-drift.mjs", diff --git a/packages/app-shell/tsconfig.json b/packages/app-shell/tsconfig.json index 59a0c8322d..379321e699 100644 --- a/packages/app-shell/tsconfig.json +++ b/packages/app-shell/tsconfig.json @@ -30,7 +30,22 @@ "moduleResolution": "nodenext" }, "include": ["src"], - "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test.tsx"], + // Tooling is excluded by DIRECTORY, not just by file NAME. A helper under + // `__tests__/` that is not itself called `*.test.ts` is otherwise a program + // input, and an EMITTING program writes it into the published `dist` + // (objectui#4006 / #4836 / #6943 -- the same defect three times, each found + // by hand). Enforced repo-wide by `pnpm check:published-tsconfig-exclude` + // (objectui#7212), which derives these directory names from `TOOLING_FILE` + // in scripts/check-phantom-dependencies.mjs. + "exclude": [ + "node_modules", + "dist", + "**/__tests__/**", + "**/__mocks__/**", + "**/__benchmarks__/**", + "**/*.test.ts", + "**/*.test.tsx" + ], "references": [ { "path": "../types" }, { "path": "../core" }, diff --git a/packages/auth/tsconfig.json b/packages/auth/tsconfig.json index 8129f23aef..6bacbb312a 100644 --- a/packages/auth/tsconfig.json +++ b/packages/auth/tsconfig.json @@ -34,5 +34,20 @@ "skipLibCheck": true }, "include": ["src"], - "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test.tsx"] + // Tooling is excluded by DIRECTORY, not just by file NAME. A helper under + // `__tests__/` that is not itself called `*.test.ts` is otherwise a program + // input, and an EMITTING program writes it into the published `dist` + // (objectui#4006 / #4836 / #6943 -- the same defect three times, each found + // by hand). Enforced repo-wide by `pnpm check:published-tsconfig-exclude` + // (objectui#7212), which derives these directory names from `TOOLING_FILE` + // in scripts/check-phantom-dependencies.mjs. + "exclude": [ + "node_modules", + "dist", + "**/__tests__/**", + "**/__mocks__/**", + "**/__benchmarks__/**", + "**/*.test.ts", + "**/*.test.tsx" + ] } diff --git a/packages/collaboration/tsconfig.json b/packages/collaboration/tsconfig.json index 8129f23aef..6bacbb312a 100644 --- a/packages/collaboration/tsconfig.json +++ b/packages/collaboration/tsconfig.json @@ -34,5 +34,20 @@ "skipLibCheck": true }, "include": ["src"], - "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test.tsx"] + // Tooling is excluded by DIRECTORY, not just by file NAME. A helper under + // `__tests__/` that is not itself called `*.test.ts` is otherwise a program + // input, and an EMITTING program writes it into the published `dist` + // (objectui#4006 / #4836 / #6943 -- the same defect three times, each found + // by hand). Enforced repo-wide by `pnpm check:published-tsconfig-exclude` + // (objectui#7212), which derives these directory names from `TOOLING_FILE` + // in scripts/check-phantom-dependencies.mjs. + "exclude": [ + "node_modules", + "dist", + "**/__tests__/**", + "**/__mocks__/**", + "**/__benchmarks__/**", + "**/*.test.ts", + "**/*.test.tsx" + ] } diff --git a/packages/components/tsconfig.json b/packages/components/tsconfig.json index 9bdf087641..a871ab9ccc 100644 --- a/packages/components/tsconfig.json +++ b/packages/components/tsconfig.json @@ -14,5 +14,18 @@ "skipLibCheck": true }, "include": ["src"], - "exclude": ["src/__tests__", "**/*.test.ts", "**/*.test.tsx"] + // Tooling is excluded by DIRECTORY, not just by file NAME. A helper under + // `__tests__/` that is not itself called `*.test.ts` is otherwise a program + // input, and an EMITTING program writes it into the published `dist` + // (objectui#4006 / #4836 / #6943 -- the same defect three times, each found + // by hand). Enforced repo-wide by `pnpm check:published-tsconfig-exclude` + // (objectui#7212), which derives these directory names from `TOOLING_FILE` + // in scripts/check-phantom-dependencies.mjs. + "exclude": [ + "**/__tests__/**", + "**/__mocks__/**", + "**/__benchmarks__/**", + "**/*.test.ts", + "**/*.test.tsx" + ] } diff --git a/packages/i18n/tsconfig.json b/packages/i18n/tsconfig.json index 0278cf6d36..fbf207e8bb 100644 --- a/packages/i18n/tsconfig.json +++ b/packages/i18n/tsconfig.json @@ -10,7 +10,20 @@ "lib": ["ES2020", "DOM"] }, "include": ["src"], - "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"], + // Tooling is excluded by DIRECTORY, not just by file NAME. A helper under + // `__tests__/` that is not itself called `*.test.ts` is otherwise a program + // input, and an EMITTING program writes it into the published `dist` + // (objectui#4006 / #4836 / #6943 -- the same defect three times, each found + // by hand). Enforced repo-wide by `pnpm check:published-tsconfig-exclude` + // (objectui#7212), which derives these directory names from `TOOLING_FILE` + // in scripts/check-phantom-dependencies.mjs. + "exclude": [ + "**/__tests__/**", + "**/__mocks__/**", + "**/__benchmarks__/**", + "src/**/*.test.ts", + "src/**/*.test.tsx" + ], // `utils/number-display.ts` re-exports `@object-ui/core` (objectui#4576). // The root tsconfig's `paths` map that specifier at the SOURCE tree // (`packages/core/src`), which would make those files program inputs under diff --git a/packages/layout/tsconfig.json b/packages/layout/tsconfig.json index b81f80b3c3..a74eb388fb 100644 --- a/packages/layout/tsconfig.json +++ b/packages/layout/tsconfig.json @@ -13,5 +13,20 @@ "skipLibCheck": true }, "include": ["src"], - "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test.tsx"] + // Tooling is excluded by DIRECTORY, not just by file NAME. A helper under + // `__tests__/` that is not itself called `*.test.ts` is otherwise a program + // input, and an EMITTING program writes it into the published `dist` + // (objectui#4006 / #4836 / #6943 -- the same defect three times, each found + // by hand). Enforced repo-wide by `pnpm check:published-tsconfig-exclude` + // (objectui#7212), which derives these directory names from `TOOLING_FILE` + // in scripts/check-phantom-dependencies.mjs. + "exclude": [ + "node_modules", + "dist", + "**/__tests__/**", + "**/__mocks__/**", + "**/__benchmarks__/**", + "**/*.test.ts", + "**/*.test.tsx" + ] } diff --git a/packages/mobile/tsconfig.json b/packages/mobile/tsconfig.json index 8129f23aef..6bacbb312a 100644 --- a/packages/mobile/tsconfig.json +++ b/packages/mobile/tsconfig.json @@ -34,5 +34,20 @@ "skipLibCheck": true }, "include": ["src"], - "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test.tsx"] + // Tooling is excluded by DIRECTORY, not just by file NAME. A helper under + // `__tests__/` that is not itself called `*.test.ts` is otherwise a program + // input, and an EMITTING program writes it into the published `dist` + // (objectui#4006 / #4836 / #6943 -- the same defect three times, each found + // by hand). Enforced repo-wide by `pnpm check:published-tsconfig-exclude` + // (objectui#7212), which derives these directory names from `TOOLING_FILE` + // in scripts/check-phantom-dependencies.mjs. + "exclude": [ + "node_modules", + "dist", + "**/__tests__/**", + "**/__mocks__/**", + "**/__benchmarks__/**", + "**/*.test.ts", + "**/*.test.tsx" + ] } diff --git a/packages/permissions/tsconfig.json b/packages/permissions/tsconfig.json index 8129f23aef..6bacbb312a 100644 --- a/packages/permissions/tsconfig.json +++ b/packages/permissions/tsconfig.json @@ -34,5 +34,20 @@ "skipLibCheck": true }, "include": ["src"], - "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test.tsx"] + // Tooling is excluded by DIRECTORY, not just by file NAME. A helper under + // `__tests__/` that is not itself called `*.test.ts` is otherwise a program + // input, and an EMITTING program writes it into the published `dist` + // (objectui#4006 / #4836 / #6943 -- the same defect three times, each found + // by hand). Enforced repo-wide by `pnpm check:published-tsconfig-exclude` + // (objectui#7212), which derives these directory names from `TOOLING_FILE` + // in scripts/check-phantom-dependencies.mjs. + "exclude": [ + "node_modules", + "dist", + "**/__tests__/**", + "**/__mocks__/**", + "**/__benchmarks__/**", + "**/*.test.ts", + "**/*.test.tsx" + ] } diff --git a/packages/plugin-ai/tsconfig.json b/packages/plugin-ai/tsconfig.json index df437b5426..1d17300582 100644 --- a/packages/plugin-ai/tsconfig.json +++ b/packages/plugin-ai/tsconfig.json @@ -14,5 +14,20 @@ "skipLibCheck": true }, "include": ["src"], - "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test.tsx"] + // Tooling is excluded by DIRECTORY, not just by file NAME. A helper under + // `__tests__/` that is not itself called `*.test.ts` is otherwise a program + // input, and an EMITTING program writes it into the published `dist` + // (objectui#4006 / #4836 / #6943 -- the same defect three times, each found + // by hand). Enforced repo-wide by `pnpm check:published-tsconfig-exclude` + // (objectui#7212), which derives these directory names from `TOOLING_FILE` + // in scripts/check-phantom-dependencies.mjs. + "exclude": [ + "node_modules", + "dist", + "**/__tests__/**", + "**/__mocks__/**", + "**/__benchmarks__/**", + "**/*.test.ts", + "**/*.test.tsx" + ] } diff --git a/packages/plugin-calendar/tsconfig.json b/packages/plugin-calendar/tsconfig.json index 22195355dd..dd77a93991 100644 --- a/packages/plugin-calendar/tsconfig.json +++ b/packages/plugin-calendar/tsconfig.json @@ -14,5 +14,20 @@ "skipLibCheck": true }, "include": ["src"], - "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test.tsx"] + // Tooling is excluded by DIRECTORY, not just by file NAME. A helper under + // `__tests__/` that is not itself called `*.test.ts` is otherwise a program + // input, and an EMITTING program writes it into the published `dist` + // (objectui#4006 / #4836 / #6943 -- the same defect three times, each found + // by hand). Enforced repo-wide by `pnpm check:published-tsconfig-exclude` + // (objectui#7212), which derives these directory names from `TOOLING_FILE` + // in scripts/check-phantom-dependencies.mjs. + "exclude": [ + "node_modules", + "dist", + "**/__tests__/**", + "**/__mocks__/**", + "**/__benchmarks__/**", + "**/*.test.ts", + "**/*.test.tsx" + ] } diff --git a/packages/plugin-chatbot/tsconfig.json b/packages/plugin-chatbot/tsconfig.json index 22195355dd..dd77a93991 100644 --- a/packages/plugin-chatbot/tsconfig.json +++ b/packages/plugin-chatbot/tsconfig.json @@ -14,5 +14,20 @@ "skipLibCheck": true }, "include": ["src"], - "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test.tsx"] + // Tooling is excluded by DIRECTORY, not just by file NAME. A helper under + // `__tests__/` that is not itself called `*.test.ts` is otherwise a program + // input, and an EMITTING program writes it into the published `dist` + // (objectui#4006 / #4836 / #6943 -- the same defect three times, each found + // by hand). Enforced repo-wide by `pnpm check:published-tsconfig-exclude` + // (objectui#7212), which derives these directory names from `TOOLING_FILE` + // in scripts/check-phantom-dependencies.mjs. + "exclude": [ + "node_modules", + "dist", + "**/__tests__/**", + "**/__mocks__/**", + "**/__benchmarks__/**", + "**/*.test.ts", + "**/*.test.tsx" + ] } diff --git a/packages/plugin-dashboard/tsconfig.json b/packages/plugin-dashboard/tsconfig.json index 27878bceab..6afb0ae62c 100644 --- a/packages/plugin-dashboard/tsconfig.json +++ b/packages/plugin-dashboard/tsconfig.json @@ -15,5 +15,20 @@ "skipLibCheck": true }, "include": ["src"], - "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test.tsx"] + // Tooling is excluded by DIRECTORY, not just by file NAME. A helper under + // `__tests__/` that is not itself called `*.test.ts` is otherwise a program + // input, and an EMITTING program writes it into the published `dist` + // (objectui#4006 / #4836 / #6943 -- the same defect three times, each found + // by hand). Enforced repo-wide by `pnpm check:published-tsconfig-exclude` + // (objectui#7212), which derives these directory names from `TOOLING_FILE` + // in scripts/check-phantom-dependencies.mjs. + "exclude": [ + "node_modules", + "dist", + "**/__tests__/**", + "**/__mocks__/**", + "**/__benchmarks__/**", + "**/*.test.ts", + "**/*.test.tsx" + ] } diff --git a/packages/plugin-detail/tsconfig.json b/packages/plugin-detail/tsconfig.json index 22195355dd..dd77a93991 100644 --- a/packages/plugin-detail/tsconfig.json +++ b/packages/plugin-detail/tsconfig.json @@ -14,5 +14,20 @@ "skipLibCheck": true }, "include": ["src"], - "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test.tsx"] + // Tooling is excluded by DIRECTORY, not just by file NAME. A helper under + // `__tests__/` that is not itself called `*.test.ts` is otherwise a program + // input, and an EMITTING program writes it into the published `dist` + // (objectui#4006 / #4836 / #6943 -- the same defect three times, each found + // by hand). Enforced repo-wide by `pnpm check:published-tsconfig-exclude` + // (objectui#7212), which derives these directory names from `TOOLING_FILE` + // in scripts/check-phantom-dependencies.mjs. + "exclude": [ + "node_modules", + "dist", + "**/__tests__/**", + "**/__mocks__/**", + "**/__benchmarks__/**", + "**/*.test.ts", + "**/*.test.tsx" + ] } diff --git a/packages/plugin-editor/tsconfig.json b/packages/plugin-editor/tsconfig.json index 2b3802a5b0..2741a57bec 100644 --- a/packages/plugin-editor/tsconfig.json +++ b/packages/plugin-editor/tsconfig.json @@ -22,5 +22,13 @@ // Their type coverage did not go away with them: it moved to the // `tsconfig.test.json` chained off this package's `type-check` script, which // is what scripts/check-type-check-coverage.mjs verifies. - "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test.tsx"] + "exclude": [ + "node_modules", + "dist", + "**/__tests__/**", + "**/__mocks__/**", + "**/__benchmarks__/**", + "**/*.test.ts", + "**/*.test.tsx" + ] } diff --git a/packages/plugin-form/tsconfig.json b/packages/plugin-form/tsconfig.json index 22195355dd..dd77a93991 100644 --- a/packages/plugin-form/tsconfig.json +++ b/packages/plugin-form/tsconfig.json @@ -14,5 +14,20 @@ "skipLibCheck": true }, "include": ["src"], - "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test.tsx"] + // Tooling is excluded by DIRECTORY, not just by file NAME. A helper under + // `__tests__/` that is not itself called `*.test.ts` is otherwise a program + // input, and an EMITTING program writes it into the published `dist` + // (objectui#4006 / #4836 / #6943 -- the same defect three times, each found + // by hand). Enforced repo-wide by `pnpm check:published-tsconfig-exclude` + // (objectui#7212), which derives these directory names from `TOOLING_FILE` + // in scripts/check-phantom-dependencies.mjs. + "exclude": [ + "node_modules", + "dist", + "**/__tests__/**", + "**/__mocks__/**", + "**/__benchmarks__/**", + "**/*.test.ts", + "**/*.test.tsx" + ] } diff --git a/packages/plugin-gantt/tsconfig.json b/packages/plugin-gantt/tsconfig.json index 22195355dd..dd77a93991 100644 --- a/packages/plugin-gantt/tsconfig.json +++ b/packages/plugin-gantt/tsconfig.json @@ -14,5 +14,20 @@ "skipLibCheck": true }, "include": ["src"], - "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test.tsx"] + // Tooling is excluded by DIRECTORY, not just by file NAME. A helper under + // `__tests__/` that is not itself called `*.test.ts` is otherwise a program + // input, and an EMITTING program writes it into the published `dist` + // (objectui#4006 / #4836 / #6943 -- the same defect three times, each found + // by hand). Enforced repo-wide by `pnpm check:published-tsconfig-exclude` + // (objectui#7212), which derives these directory names from `TOOLING_FILE` + // in scripts/check-phantom-dependencies.mjs. + "exclude": [ + "node_modules", + "dist", + "**/__tests__/**", + "**/__mocks__/**", + "**/__benchmarks__/**", + "**/*.test.ts", + "**/*.test.tsx" + ] } diff --git a/packages/plugin-kanban/tsconfig.json b/packages/plugin-kanban/tsconfig.json index 25a6bc3547..e95fb2975a 100644 --- a/packages/plugin-kanban/tsconfig.json +++ b/packages/plugin-kanban/tsconfig.json @@ -15,5 +15,20 @@ "skipLibCheck": true }, "include": ["src"], - "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test.tsx"] + // Tooling is excluded by DIRECTORY, not just by file NAME. A helper under + // `__tests__/` that is not itself called `*.test.ts` is otherwise a program + // input, and an EMITTING program writes it into the published `dist` + // (objectui#4006 / #4836 / #6943 -- the same defect three times, each found + // by hand). Enforced repo-wide by `pnpm check:published-tsconfig-exclude` + // (objectui#7212), which derives these directory names from `TOOLING_FILE` + // in scripts/check-phantom-dependencies.mjs. + "exclude": [ + "node_modules", + "dist", + "**/__tests__/**", + "**/__mocks__/**", + "**/__benchmarks__/**", + "**/*.test.ts", + "**/*.test.tsx" + ] } diff --git a/packages/plugin-list/tsconfig.json b/packages/plugin-list/tsconfig.json index 22195355dd..dd77a93991 100644 --- a/packages/plugin-list/tsconfig.json +++ b/packages/plugin-list/tsconfig.json @@ -14,5 +14,20 @@ "skipLibCheck": true }, "include": ["src"], - "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test.tsx"] + // Tooling is excluded by DIRECTORY, not just by file NAME. A helper under + // `__tests__/` that is not itself called `*.test.ts` is otherwise a program + // input, and an EMITTING program writes it into the published `dist` + // (objectui#4006 / #4836 / #6943 -- the same defect three times, each found + // by hand). Enforced repo-wide by `pnpm check:published-tsconfig-exclude` + // (objectui#7212), which derives these directory names from `TOOLING_FILE` + // in scripts/check-phantom-dependencies.mjs. + "exclude": [ + "node_modules", + "dist", + "**/__tests__/**", + "**/__mocks__/**", + "**/__benchmarks__/**", + "**/*.test.ts", + "**/*.test.tsx" + ] } diff --git a/packages/plugin-map/tsconfig.json b/packages/plugin-map/tsconfig.json index 22195355dd..dd77a93991 100644 --- a/packages/plugin-map/tsconfig.json +++ b/packages/plugin-map/tsconfig.json @@ -14,5 +14,20 @@ "skipLibCheck": true }, "include": ["src"], - "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test.tsx"] + // Tooling is excluded by DIRECTORY, not just by file NAME. A helper under + // `__tests__/` that is not itself called `*.test.ts` is otherwise a program + // input, and an EMITTING program writes it into the published `dist` + // (objectui#4006 / #4836 / #6943 -- the same defect three times, each found + // by hand). Enforced repo-wide by `pnpm check:published-tsconfig-exclude` + // (objectui#7212), which derives these directory names from `TOOLING_FILE` + // in scripts/check-phantom-dependencies.mjs. + "exclude": [ + "node_modules", + "dist", + "**/__tests__/**", + "**/__mocks__/**", + "**/__benchmarks__/**", + "**/*.test.ts", + "**/*.test.tsx" + ] } diff --git a/packages/plugin-markdown/tsconfig.json b/packages/plugin-markdown/tsconfig.json index 25a6bc3547..e95fb2975a 100644 --- a/packages/plugin-markdown/tsconfig.json +++ b/packages/plugin-markdown/tsconfig.json @@ -15,5 +15,20 @@ "skipLibCheck": true }, "include": ["src"], - "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test.tsx"] + // Tooling is excluded by DIRECTORY, not just by file NAME. A helper under + // `__tests__/` that is not itself called `*.test.ts` is otherwise a program + // input, and an EMITTING program writes it into the published `dist` + // (objectui#4006 / #4836 / #6943 -- the same defect three times, each found + // by hand). Enforced repo-wide by `pnpm check:published-tsconfig-exclude` + // (objectui#7212), which derives these directory names from `TOOLING_FILE` + // in scripts/check-phantom-dependencies.mjs. + "exclude": [ + "node_modules", + "dist", + "**/__tests__/**", + "**/__mocks__/**", + "**/__benchmarks__/**", + "**/*.test.ts", + "**/*.test.tsx" + ] } diff --git a/packages/plugin-report/tsconfig.json b/packages/plugin-report/tsconfig.json index df437b5426..1d17300582 100644 --- a/packages/plugin-report/tsconfig.json +++ b/packages/plugin-report/tsconfig.json @@ -14,5 +14,20 @@ "skipLibCheck": true }, "include": ["src"], - "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test.tsx"] + // Tooling is excluded by DIRECTORY, not just by file NAME. A helper under + // `__tests__/` that is not itself called `*.test.ts` is otherwise a program + // input, and an EMITTING program writes it into the published `dist` + // (objectui#4006 / #4836 / #6943 -- the same defect three times, each found + // by hand). Enforced repo-wide by `pnpm check:published-tsconfig-exclude` + // (objectui#7212), which derives these directory names from `TOOLING_FILE` + // in scripts/check-phantom-dependencies.mjs. + "exclude": [ + "node_modules", + "dist", + "**/__tests__/**", + "**/__mocks__/**", + "**/__benchmarks__/**", + "**/*.test.ts", + "**/*.test.tsx" + ] } diff --git a/packages/plugin-timeline/tsconfig.json b/packages/plugin-timeline/tsconfig.json index 22195355dd..dd77a93991 100644 --- a/packages/plugin-timeline/tsconfig.json +++ b/packages/plugin-timeline/tsconfig.json @@ -14,5 +14,20 @@ "skipLibCheck": true }, "include": ["src"], - "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test.tsx"] + // Tooling is excluded by DIRECTORY, not just by file NAME. A helper under + // `__tests__/` that is not itself called `*.test.ts` is otherwise a program + // input, and an EMITTING program writes it into the published `dist` + // (objectui#4006 / #4836 / #6943 -- the same defect three times, each found + // by hand). Enforced repo-wide by `pnpm check:published-tsconfig-exclude` + // (objectui#7212), which derives these directory names from `TOOLING_FILE` + // in scripts/check-phantom-dependencies.mjs. + "exclude": [ + "node_modules", + "dist", + "**/__tests__/**", + "**/__mocks__/**", + "**/__benchmarks__/**", + "**/*.test.ts", + "**/*.test.tsx" + ] } diff --git a/packages/plugin-tree/tsconfig.json b/packages/plugin-tree/tsconfig.json index 22195355dd..dd77a93991 100644 --- a/packages/plugin-tree/tsconfig.json +++ b/packages/plugin-tree/tsconfig.json @@ -14,5 +14,20 @@ "skipLibCheck": true }, "include": ["src"], - "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test.tsx"] + // Tooling is excluded by DIRECTORY, not just by file NAME. A helper under + // `__tests__/` that is not itself called `*.test.ts` is otherwise a program + // input, and an EMITTING program writes it into the published `dist` + // (objectui#4006 / #4836 / #6943 -- the same defect three times, each found + // by hand). Enforced repo-wide by `pnpm check:published-tsconfig-exclude` + // (objectui#7212), which derives these directory names from `TOOLING_FILE` + // in scripts/check-phantom-dependencies.mjs. + "exclude": [ + "node_modules", + "dist", + "**/__tests__/**", + "**/__mocks__/**", + "**/__benchmarks__/**", + "**/*.test.ts", + "**/*.test.tsx" + ] } diff --git a/packages/providers/tsconfig.json b/packages/providers/tsconfig.json index 8e7db2cb2a..f970667bf4 100644 --- a/packages/providers/tsconfig.json +++ b/packages/providers/tsconfig.json @@ -29,7 +29,22 @@ "composite": true }, "include": ["src"], - "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test.tsx"], + // Tooling is excluded by DIRECTORY, not just by file NAME. A helper under + // `__tests__/` that is not itself called `*.test.ts` is otherwise a program + // input, and an EMITTING program writes it into the published `dist` + // (objectui#4006 / #4836 / #6943 -- the same defect three times, each found + // by hand). Enforced repo-wide by `pnpm check:published-tsconfig-exclude` + // (objectui#7212), which derives these directory names from `TOOLING_FILE` + // in scripts/check-phantom-dependencies.mjs. + "exclude": [ + "node_modules", + "dist", + "**/__tests__/**", + "**/__mocks__/**", + "**/__benchmarks__/**", + "**/*.test.ts", + "**/*.test.tsx" + ], "references": [ { "path": "../types" } ] diff --git a/packages/react-runtime/tsconfig.json b/packages/react-runtime/tsconfig.json index 4373aeac35..29f8fe82bd 100644 --- a/packages/react-runtime/tsconfig.json +++ b/packages/react-runtime/tsconfig.json @@ -2,5 +2,18 @@ "extends": "../../tsconfig.json", "compilerOptions": { "outDir": "dist", "rootDir": "src", "noEmit": false, "declaration": true, "composite": true, "jsx": "react-jsx", "lib": ["ES2020", "DOM"] }, "include": ["src"], - "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"] + // Tooling is excluded by DIRECTORY, not just by file NAME. A helper under + // `__tests__/` that is not itself called `*.test.ts` is otherwise a program + // input, and an EMITTING program writes it into the published `dist` + // (objectui#4006 / #4836 / #6943 -- the same defect three times, each found + // by hand). Enforced repo-wide by `pnpm check:published-tsconfig-exclude` + // (objectui#7212), which derives these directory names from `TOOLING_FILE` + // in scripts/check-phantom-dependencies.mjs. + "exclude": [ + "**/__tests__/**", + "**/__mocks__/**", + "**/__benchmarks__/**", + "src/**/*.test.ts", + "src/**/*.test.tsx" + ] } diff --git a/packages/react/tsconfig.json b/packages/react/tsconfig.json index 2924645150..6d8c329590 100644 --- a/packages/react/tsconfig.json +++ b/packages/react/tsconfig.json @@ -29,7 +29,22 @@ "moduleResolution": "nodenext" }, "include": ["src"], - "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test.tsx"], + // Tooling is excluded by DIRECTORY, not just by file NAME. A helper under + // `__tests__/` that is not itself called `*.test.ts` is otherwise a program + // input, and an EMITTING program writes it into the published `dist` + // (objectui#4006 / #4836 / #6943 -- the same defect three times, each found + // by hand). Enforced repo-wide by `pnpm check:published-tsconfig-exclude` + // (objectui#7212), which derives these directory names from `TOOLING_FILE` + // in scripts/check-phantom-dependencies.mjs. + "exclude": [ + "node_modules", + "dist", + "**/__tests__/**", + "**/__mocks__/**", + "**/__benchmarks__/**", + "**/*.test.ts", + "**/*.test.tsx" + ], "references": [ { "path": "../types" }, { "path": "../core" } diff --git a/packages/sdui-parser/tsconfig.json b/packages/sdui-parser/tsconfig.json index c6349a462a..f8da00652e 100644 --- a/packages/sdui-parser/tsconfig.json +++ b/packages/sdui-parser/tsconfig.json @@ -9,5 +9,18 @@ "lib": ["ES2020"] }, "include": ["src"], - "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"] + // Tooling is excluded by DIRECTORY, not just by file NAME. A helper under + // `__tests__/` that is not itself called `*.test.ts` is otherwise a program + // input, and an EMITTING program writes it into the published `dist` + // (objectui#4006 / #4836 / #6943 -- the same defect three times, each found + // by hand). Enforced repo-wide by `pnpm check:published-tsconfig-exclude` + // (objectui#7212), which derives these directory names from `TOOLING_FILE` + // in scripts/check-phantom-dependencies.mjs. + "exclude": [ + "**/__tests__/**", + "**/__mocks__/**", + "**/__benchmarks__/**", + "src/**/*.test.ts", + "src/**/*.test.tsx" + ] } diff --git a/packages/types/tsconfig.json b/packages/types/tsconfig.json index 3d94566f93..79222d4366 100644 --- a/packages/types/tsconfig.json +++ b/packages/types/tsconfig.json @@ -13,5 +13,21 @@ "paths": {} }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts", "examples"] + // Tooling is excluded by DIRECTORY, not just by file NAME. A helper under + // `__tests__/` that is not itself called `*.test.ts` is otherwise a program + // input, and an EMITTING program writes it into the published `dist` + // (objectui#4006 / #4836 / #6943 -- the same defect three times, each found + // by hand). Enforced repo-wide by `pnpm check:published-tsconfig-exclude` + // (objectui#7212), which derives these directory names from `TOOLING_FILE` + // in scripts/check-phantom-dependencies.mjs. + "exclude": [ + "node_modules", + "dist", + "**/__tests__/**", + "**/__mocks__/**", + "**/__benchmarks__/**", + "**/*.test.ts", + "**/*.spec.ts", + "examples" + ] } diff --git a/scripts/__tests__/check-published-tsconfig-tooling-exclude.test.ts b/scripts/__tests__/check-published-tsconfig-tooling-exclude.test.ts new file mode 100644 index 0000000000..1e16e4d3d3 --- /dev/null +++ b/scripts/__tests__/check-published-tsconfig-tooling-exclude.test.ts @@ -0,0 +1,383 @@ +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 { TOOLING_FILE } from '../check-phantom-dependencies.mjs'; +import { MIN_PACKAGES, toolingConventionFrom } from '../check-published-dist-tooling.mjs'; +import { + ACCEPTED_FORMS, + CANONICAL_FORM, + EMITTER_CARVE_OUTS, + TOOLING_DIRECTORIES, + analyze, + dtsOptionsBody, + emitterFactsFor, + isDirectoryForm, + missingDirectories, + stripJsonComments, +} from '../check-published-tsconfig-tooling-exclude.mjs'; + +/** + * objectui#7212 — every published package's build tsconfig must exclude tooling + * DIRECTORIES, not just tooling FILE NAMES. + * + * The defect has been repaired three times and gated never: objectui#4006, + * objectui#4836, objectui#6943 (the third in the same package as the first). + * Each repair was correct and local; the trap stayed armed in 29 other + * published packages, all with ZERO offending files — green because nobody had + * added a shared `__tests__/` helper yet, not because the config would stop + * one. So what this file mostly pins is the ways the new gate could be green + * about nothing: + * + * 1. **The convention is derived from `TOOLING_FILE`, never retyped.** A + * fourth tooling directory added to `check-phantom-dependencies.mjs` must + * become required here in the same commit. + * 2. **The three historical name-only shapes are RED**, and the accepted + * directory spellings are GREEN — the red-then-green property stated as + * cases rather than trusted by reading. + * 3. **The bare-directory spelling is RED although `tsc` honours it**, because + * it is position-anchored: `src/__tests__` does not cover + * `src/renderers/__tests__/`. That was `@object-ui/components`' real + * spelling, and it covered one of the three directories. + * 4. **A collapsed scan is RED, never green.** "Nothing to look at, therefore + * clean" is the verdict this gate family must never return (objectui#4846). + * 5. **Every carve-out re-proves itself on each run**, so an exemption cannot + * outlive the emitter that justified it — and a carve-out for a package npm + * no longer receives is a failure, not a no-op. + * 6. **The gate is wired** into `package.json` and into a PER-PR job, which is + * what distinguishes it from the artifact-level sibling that deliberately + * is not (objectui#4846 comment 5307574139). + * 7. **This repository is green**, which is the ratchet half of the ruling: + * the gate landed together with the conversion of its initial red set. + */ +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const GATE = 'scripts/check-published-tsconfig-tooling-exclude.mjs'; + +interface Finding { + reason: string; + pkg?: string; + detail?: string; + missing?: string[]; +} + +// ── fixture workspaces ─────────────────────────────────────────────────────── + +const fixtures: string[] = []; +afterAll(() => { + for (const dir of fixtures) fs.rmSync(dir, { recursive: true, force: true }); +}); + +/** + * A throwaway workspace: a `.changeset/config.json` naming every package, plus + * a manifest and (optionally) a tsconfig and a vite config per package. + */ +function makeWorkspace( + packages: Record< + string, + { manifest?: Record; tsconfig?: Record | string; vite?: string } + >, + { padTo = MIN_PACKAGES } = {}, +): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'tsconfig-tooling-exclude-')); + fixtures.push(root); + const names: string[] = []; + + const write = (dir: string, name: string, spec: (typeof packages)[string]) => { + fs.mkdirSync(path.join(root, dir), { recursive: true }); + fs.writeFileSync( + path.join(root, dir, 'package.json'), + JSON.stringify({ name, ...(spec.manifest ?? {}) }), + ); + if (spec.tsconfig !== undefined) { + fs.writeFileSync( + path.join(root, dir, 'tsconfig.json'), + typeof spec.tsconfig === 'string' ? spec.tsconfig : JSON.stringify(spec.tsconfig), + ); + } + if (spec.vite !== undefined) fs.writeFileSync(path.join(root, dir, 'vite.config.ts'), spec.vite); + names.push(name); + }; + + for (const [dir, spec] of Object.entries(packages)) { + write(dir, `@fixture/${dir.split('/').pop()}`, spec); + } + // Filler so the scan clears its own floor; the subject packages are the ones + // above. Each filler is already compliant, so it contributes no finding. + for (let i = names.length; i < padTo; i += 1) { + write(`packages/filler-${i}`, `@fixture/filler-${i}`, { + tsconfig: { exclude: TOOLING_DIRECTORIES.map((d) => `**/${d}/**`) }, + }); + } + + fs.mkdirSync(path.join(root, '.changeset'), { recursive: true }); + fs.writeFileSync(path.join(root, '.changeset/config.json'), JSON.stringify({ fixed: [names] })); + return root; +} + +const run = (root: string, options = {}) => analyze(root, options) as { findings: Finding[]; counters: Record }; +const reasons = (findings: Finding[]) => findings.map((f) => f.reason); +const forPkg = (findings: Finding[], name: string) => findings.filter((f) => f.pkg === name); + +// ── 1. the convention is derived, never retyped ────────────────────────────── + +describe('the tooling convention comes out of TOOLING_FILE', () => { + it('reads the same directories the sibling gates classify as tooling', () => { + expect(TOOLING_DIRECTORIES).toEqual(toolingConventionFrom(TOOLING_FILE.source).directories.split('|')); + expect(TOOLING_DIRECTORIES).toEqual(['__tests__', '__mocks__', '__benchmarks__']); + }); + + it('requires EVERY tooling directory, so a fourth one propagates in the same commit', () => { + // The drift this rules out is invisible: both gates stay green while one + // silently stops covering a directory. + for (const directory of TOOLING_DIRECTORIES) { + const exclude = TOOLING_DIRECTORIES.filter((d) => d !== directory).map((d) => `**/${d}/**`); + expect(missingDirectories(exclude), `dropping ${directory} must be noticed`).toEqual([directory]); + } + expect(missingDirectories(TOOLING_DIRECTORIES.map((d) => `**/${d}/**`))).toEqual([]); + }); +}); + +// ── 2. red-then-green, as cases ────────────────────────────────────────────── + +describe('the name form is red and the directory form is green', () => { + it('accepts every anchored spelling in ACCEPTED_FORMS', () => { + for (const form of ACCEPTED_FORMS) { + for (const directory of TOOLING_DIRECTORIES) { + expect(isDirectoryForm(form.replace('DIR', directory), directory), form).toBe(true); + } + } + expect(ACCEPTED_FORMS).toContain(CANONICAL_FORM); + }); + + it('rejects the three name-only shapes this repository actually carried', () => { + // Verbatim from the packages objectui#7212 measured: the plugin/app-shell + // shape, the i18n/react-runtime shape, and the types shape. + for (const exclude of [ + ['node_modules', 'dist', '**/*.test.ts', '**/*.test.tsx'], + ['src/**/*.test.ts', 'src/**/*.test.tsx'], + ['node_modules', 'dist', '**/*.test.ts', '**/*.spec.ts', 'examples'], + ]) { + expect(missingDirectories(exclude)).toEqual(TOOLING_DIRECTORIES); + } + }); + + it('rejects the BARE-DIRECTORY spelling, which is honoured by tsc but position-anchored', () => { + // `@object-ui/components` carried exactly this. `tsc` excludes the named + // directory recursively, so the config is not wrong — it just does not + // cover `src/renderers/__tests__/`, which is the same "green until someone + // adds a file" shape this gate exists to end. And it named ONE of three. + expect(isDirectoryForm('src/__tests__', '__tests__')).toBe(false); + expect(missingDirectories(['src/__tests__', '**/*.test.ts', '**/*.test.tsx'])).toEqual(TOOLING_DIRECTORIES); + }); + + it('does not accept a glob that merely mentions the directory name', () => { + for (const glob of ['**/__tests__', '**/__tests__/*', 'src/__tests__/helpers/**', '**/not__tests__/**']) { + expect(isDirectoryForm(glob, '__tests__'), glob).toBe(false); + } + }); + + it('fails a name-only package end to end, and names the globs to add', () => { + const root = makeWorkspace({ + 'packages/offender': { tsconfig: { exclude: ['node_modules', 'dist', '**/*.test.ts'] } }, + }); + const { findings } = run(root, { carveOuts: {} }); + const finding = forPkg(findings, '@fixture/offender')[0]; + expect(finding?.reason).toBe('name-only-tooling-exclude'); + expect(finding?.missing).toEqual(TOOLING_DIRECTORIES); + for (const directory of TOOLING_DIRECTORIES) { + expect(finding?.detail).toContain(`"**/${directory}/**"`); + } + }); + + it('passes the same package once the directory form is written', () => { + const root = makeWorkspace({ + 'packages/fixed': { + tsconfig: { + exclude: ['node_modules', 'dist', ...TOOLING_DIRECTORIES.map((d) => `**/${d}/**`), '**/*.test.ts'], + }, + }, + }); + const { findings, counters } = run(root, { carveOuts: {} }); + expect(findings).toEqual([]); + expect(counters.compliant).toBe(counters.enforced); + }); + + it('reads the exclude through JSONC comments, which every real tsconfig here has', () => { + const root = makeWorkspace({ + 'packages/commented': { + tsconfig: `{ + // a leading comment, and one containing a "quoted \\" brace {" + /* and a block comment */ + "include": ["src"], + "exclude": [${TOOLING_DIRECTORIES.map((d) => `"**/${d}/**"`).join(', ')}] +}`, + }, + }); + expect(run(root, { carveOuts: {} }).findings).toEqual([]); + }); +}); + +// ── 3. the vacuous verdicts are RED ────────────────────────────────────────── + +describe('the verdicts this gate must never return', () => { + it('reds when the scan collapses below the floor instead of passing on nothing', () => { + const root = makeWorkspace({ 'packages/only': { tsconfig: { exclude: [] } } }, { padTo: 1 }); + const { findings } = run(root, { carveOuts: {} }); + expect(reasons(findings)).toContain('scan-collapsed'); + expect(findings[0]?.detail).toContain(String(MIN_PACKAGES)); + }); + + it('treats a missing build tsconfig as a finding, not a skip', () => { + const root = makeWorkspace({ 'packages/naked': {} }); + expect(reasons(run(root, { carveOuts: {} }).findings)).toContain('no-build-tsconfig'); + }); + + it('treats an absent `exclude` key as red, not as "nothing to check"', () => { + const root = makeWorkspace({ 'packages/silent': { tsconfig: { include: ['src'] } } }); + expect(reasons(run(root, { carveOuts: {} }).findings)).toContain('name-only-tooling-exclude'); + }); + + it('reports an unparseable tsconfig rather than skipping the package', () => { + const root = makeWorkspace({ 'packages/broken': { tsconfig: '{ "exclude": [ ' } }); + expect(reasons(run(root, { carveOuts: {} }).findings)).toContain('unreadable-tsconfig'); + }); +}); + +// ── 4. scope: the same set the sibling gate publishes ──────────────────────── + +describe('scope', () => { + it('skips `private` packages and covers `apps/`', () => { + // Padded one above the floor because the private package is subtracted + // from the published set — at exactly the floor this fixture would collapse + // the scan instead, and the collapse verdict would mask what it is testing. + const root = makeWorkspace( + { + 'packages/secret': { manifest: { private: true }, tsconfig: { exclude: [] } }, + 'apps/shipped': { tsconfig: { exclude: ['**/*.test.ts'] } }, + }, + { padTo: MIN_PACKAGES + 1 }, + ); + const { findings } = run(root, { carveOuts: {} }); + expect(forPkg(findings, '@fixture/secret')).toEqual([]); + expect(reasons(forPkg(findings, '@fixture/shipped'))).toEqual(['name-only-tooling-exclude']); + }); +}); + +// ── 5. carve-outs re-prove themselves ──────────────────────────────────────── + +describe('an emitter carve-out cannot outlive its reason', () => { + const carveOuts = { + '@fixture/bundled': { + reason: 'tsup emits from its entry graph', + requires: (facts: { usesTsup: boolean; build: string }) => + facts.usesTsup ? null : `its build script is now \`${facts.build}\``, + }, + }; + + it('exempts the package while the stated fact still holds', () => { + const root = makeWorkspace({ + 'packages/bundled': { manifest: { scripts: { build: 'tsup' } }, tsconfig: { exclude: ['**/*.test.ts'] } }, + }); + const { findings, counters } = run(root, { carveOuts }); + expect(findings).toEqual([]); + expect(counters.exempt).toBe(1); + }); + + it('reds the moment the emitter changes under it', () => { + const root = makeWorkspace({ + 'packages/bundled': { manifest: { scripts: { build: 'tsc' } }, tsconfig: { exclude: ['**/*.test.ts'] } }, + }); + const finding = forPkg(run(root, { carveOuts }).findings, '@fixture/bundled')[0]; + expect(finding?.reason).toBe('carve-out-no-longer-holds'); + expect(finding?.detail).toContain('tsup'); + }); + + it('reds on a carve-out naming a package that is no longer published', () => { + const root = makeWorkspace({ 'packages/present': { tsconfig: { exclude: [] } } }); + const findings = run(root, { carveOuts: { '@fixture/departed': { reason: 'x', requires: () => null } } }).findings; + expect(reasons(findings)).toContain('stale-carve-out'); + }); +}); + +// ── 6. the carve-out list this repository actually ships ───────────────────── + +describe('the shipped carve-outs, against the emitters they claim', () => { + it('names exactly the published packages whose emitter ignores this file list', () => { + // The ruling named four and carried a confidence gap into implementation: + // whether they were exhaustive was to be verified here. They were not — + // `create-plugin` is a THIRD tsup package and `runner` is a SECOND Vite + // application, and both sat inside the ruling's "29 name-only" red set. + expect(Object.keys(EMITTER_CARVE_OUTS).sort()).toEqual([ + '@object-ui/cli', + '@object-ui/console', + '@object-ui/create-plugin', + '@object-ui/data-objectstack', + '@object-ui/plugin-charts', + '@object-ui/runner', + ]); + }); + + it('re-derives each carve-out against the package on disk', () => { + const { packages } = analyze(repoRoot) as { packages: { name: string; dir: string; manifest: object }[] }; + for (const [name, carveOut] of Object.entries(EMITTER_CARVE_OUTS)) { + const pkg = packages.find((p) => p.name === name); + expect(pkg, `${name} must still be a published package`).toBeDefined(); + expect(carveOut.requires(emitterFactsFor(repoRoot, pkg!)), `${name}: ${carveOut.reason}`).toBeNull(); + } + }); + + it('records that `console` carries a live non-test helper behind its single guard', () => { + // Required by the ruling: `console` keeps its `noEmit` protection and is + // listed WITH the note, because it is the one carve-out that is not merely + // latent — the file exists today. + expect(fs.existsSync(path.join(repoRoot, 'apps/console/src/__tests__/helpers/preview-page-sources.ts'))).toBe(true); + expect(EMITTER_CARVE_OUTS['@object-ui/console'].reason).toContain('preview-page-sources.ts'); + }); + + it('reads a `dts()` options block by balancing braces, not by regexing prose', () => { + expect(dtsOptionsBody('plugins: [dts({ include: ["src"], exclude: ["**/*.test.ts"] })]')).toContain('exclude'); + // A nested object must not end the block early, and a bare `dts()` is not + // the same as `dts({})`. + expect(dtsOptionsBody('dts({ compilerOptions: { paths: {} }, exclude: ["a"] })')).toContain('exclude'); + expect(dtsOptionsBody('dts()')).toBe(''); + expect(dtsOptionsBody('plugins: [react()]')).toBeNull(); + }); + + it('strips comments without being fooled by braces or slashes inside strings', () => { + expect(JSON.parse(stripJsonComments('{"a": "http://x{//}", /* c */ "b": 1 // trailing\n}'))) + .toEqual({ a: 'http://x{//}', b: 1 }); + }); +}); + +// ── 7. wiring, and this repository's own state ─────────────────────────────── + +describe('wiring', () => { + it('is exposed as `pnpm check:published-tsconfig-exclude`', () => { + const manifest = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')); + expect(manifest.scripts['check:published-tsconfig-exclude']).toBe(`node ${GATE}`); + }); + + it('runs PER PULL REQUEST — the half objectui#4846 could not have', () => { + // The artifact-level sibling needs a full-repo build and therefore runs on + // the publish path and nightly. This one reads config text, so it can run + // where the defect is authored. + const ci = fs.readFileSync(path.join(repoRoot, '.github/workflows/ci.yml'), 'utf8'); + expect(ci).toContain('run: pnpm check:published-tsconfig-exclude'); + }); + + it('leaves the artifact-level gate in place as the second line of defence', () => { + const manifest = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')); + expect(manifest.scripts['check:published-dist']).toBe('node scripts/check-published-dist-tooling.mjs'); + const publishGate = fs.readFileSync(path.join(repoRoot, '.github/workflows/published-dist-gate.yml'), 'utf8'); + expect(publishGate).toContain('check:published-dist'); + }); + + it('is GREEN on this repository — the ratchet landed with its red set converted', () => { + const { findings, counters } = run(repoRoot); + expect(findings).toEqual([]); + expect(counters.enforced).toBeGreaterThanOrEqual(MIN_PACKAGES); + expect(counters.compliant).toBe(counters.enforced); + }); +}); diff --git a/scripts/check-published-tsconfig-tooling-exclude.mjs b/scripts/check-published-tsconfig-tooling-exclude.mjs new file mode 100644 index 0000000000..e101610c38 --- /dev/null +++ b/scripts/check-published-tsconfig-tooling-exclude.mjs @@ -0,0 +1,447 @@ +#!/usr/bin/env node +/** + * Every published package's build tsconfig must exclude tooling DIRECTORIES, + * not just tooling FILE NAMES. + * + * Run: node scripts/check-published-tsconfig-tooling-exclude.mjs + * (also `pnpm check:published-tsconfig-exclude`) + * Exit: 0 = every published package either carries the directory form or is a + * named, still-valid carve-out, + * 1 = at least one names tooling by file name only, OR a carve-out's + * stated reason no longer holds, OR the scan collapsed. + * + * ## The defect this closes, and why it needed a gate rather than a fourth fix + * + * Three times a published package shipped tooling material because its build + * tsconfig excluded `*.test.ts` by NAME and nothing under `__tests__/`: + * objectui#4006 (`fields`, `plugin-editor` -- 73 `*.test.d.ts` in two published + * `dist/`), objectui#4836 (`core`, `plugin-designer`, `plugin-grid`, + * `plugin-view` -- 9 more, one of them `dist/__benchmarks__/core.bench.js`, a + * real emitted module whose first statement imports `vitest`), and + * objectui#6943 (`fields` AGAIN, `dist/__tests__/numberInputBrowserReadings.d.ts` + * -- the same package as the first instance, because the first fix wrote the + * name form and the directory form was never generalised). + * + * Each repair was correct and local, and each left the trap armed everywhere + * else. objectui#7212 measured the standing exposure rather than an instance of + * it: 29 published packages excluded tooling by name only, ALL of them with + * ZERO offending files -- green because nobody had yet added a shared helper to + * a `__tests__/` directory, not because the config would stop one. The + * recurrence interval is what argued the altitude was wrong, and the maintainer + * ruled for this gate on 2026-09-02 (objectui#7212 comment 5508046691). + * + * ## CONFIG SHAPE ONLY -- the line that keeps this clear of objectui#4846 + * + * This gate reads `exclude` arrays. It never asks what an emitter would + * produce, never builds, and never looks at an artifact. That narrowness is + * load-bearing: objectui#4846 measured the cheap static gate "no build tsconfig + * program may contain a TOOLING_FILE" and REJECTED it, because deciding whether + * a tooling file in a program is a defect means reimplementing three + * third-party emit semantics (bare `tsc` / `tsup`'s entry graph / unplugin-dts + * merging its own globs with the tsconfig's). So the artifact-level + * `check-published-dist-tooling.mjs` stays exactly as it is -- it is the second + * line of defence and the only criterion that cannot be wrong about what ships. + * This gate asks a strictly smaller question that needs no emit model at all: + * DOES THE CONFIG NAME THE DIRECTORY. A package can satisfy it and still ship + * tooling material for some other reason; that is the sibling gate's job. + * + * ## The convention is DERIVED, never retyped + * + * The tooling directories come out of `TOOLING_FILE` in + * `check-phantom-dependencies.mjs` through the sibling gate's own + * `toolingConventionFrom`, so all three gates cannot disagree about what + * tooling material IS. Add a fourth tooling directory there and this gate + * starts requiring it in the same commit. + * + * ## Why the bare-directory spelling is REJECTED although `tsc` honours it + * + * TypeScript treats an `exclude` entry naming a directory (`"src/__tests__"`) + * as excluding it recursively, so that spelling is not wrong -- it is + * POSITION-ANCHORED, which is a different property from the one this gate is + * about. `"src/__tests__"` does not cover `src/renderers/__tests__/`, so a + * package carrying it is protected only for as long as every tooling directory + * stays at the top of `src/`. That is the same "green until someone adds a + * file" shape the gate exists to end, one level over. `@object-ui/components` + * carried exactly that spelling when this landed (and covered only ONE of the + * three directories with it). The accepted forms are the anchored globs listed + * in `ACCEPTED_FORMS`, and the failure message prints them. + * + * ## Carve-outs are NAMED, and they re-prove themselves on every run + * + * The maintainer's ruling requires the emitter carve-outs to be named rather + * than inferred, and carried a confidence gap into implementation: whether the + * four it listed were exhaustive was to be verified at authoring time. It was, + * mechanically, against each package's actual build script and vite config -- + * and they were not. Two more published packages have an emitter that never + * reads this file list, and both were inside the ruling's "29 name-only" red + * set: + * + * - `@object-ui/create-plugin` is a THIRD `tsup` package, alongside the `cli` + * and `data-objectstack` the ruling names. Same `tsup.config.ts` shape, + * same `dts: true`, same entry-graph emit. + * - `@object-ui/runner` is a SECOND Vite APPLICATION, alongside + * `@object-ui/console`. Its `vite.config.ts` loads no `dts()` plugin at all + * and its tsconfig sets `noEmit: true`, so it writes no declarations; its + * published `dist` is a bundle built from the Rollup entry graph. + * + * Requiring the directory form in either would be asserting something about a + * config the emitter does not consult -- a phantom check, green about nothing, + * which is the failure this repository treats as worse than no check. + * + * So every entry below states the FACTS that make it a carve-out, and `requires` + * re-checks them on every run (the technique `HOST_PROVIDED` in + * `check-phantom-dependencies.mjs` uses). The day `create-plugin` stops using + * `tsup`, or `runner` gains a `dts()` plugin, the carve-out stops holding and + * this gate goes red -- rather than silently exempting a package that now emits + * declarations from a config nobody is checking. + */ + +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { TOOLING_FILE } from './check-phantom-dependencies.mjs'; +import { + MIN_PACKAGES, + discoverPublishedPackages, + toolingConventionFrom, +} from './check-published-dist-tooling.mjs'; +import { isEntrypoint } from './invoked-as.mjs'; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); + +/** The tooling directories, read out of the sibling gate's regex. */ +export const TOOLING_DIRECTORIES = toolingConventionFrom(TOOLING_FILE.source).directories.split('|'); + +/** + * The `exclude` spellings that cover a tooling directory wherever it sits. + * + * All four are anchored globs ending `/**`, which is what makes them + * position-independent; see the header for why the bare-directory spelling is + * not among them. + */ +export const ACCEPTED_FORMS = ['**/DIR/**', 'src/**/DIR/**', 'src/DIR/**', 'DIR/**']; + +/** The spelling this gate tells an author to write. */ +export const CANONICAL_FORM = '**/DIR/**'; + +/** The accepted `exclude` entries for one tooling directory. */ +export const acceptedFormsFor = (dir) => ACCEPTED_FORMS.map((f) => f.replace('DIR', dir)); + +/** Does this `exclude` entry cover `dir` wherever it sits in the package? */ +export const isDirectoryForm = (glob, dir) => + acceptedFormsFor(dir).includes(String(glob).split('\\').join('/')); + +/** The tooling directories an `exclude` array does NOT cover. */ +export function missingDirectories(exclude, directories = TOOLING_DIRECTORIES) { + const entries = Array.isArray(exclude) ? exclude : []; + return directories.filter((dir) => !entries.some((glob) => isDirectoryForm(glob, dir))); +} + +// -- the workspace facts a carve-out is allowed to rest on --------------------- + +/** JSONC -> JSON: strip `//` and block comments that are not inside a string. */ +export function stripJsonComments(text) { + let out = ''; + let i = 0; + let inString = false; + let escaped = false; + while (i < text.length) { + const c = text[i]; + if (inString) { + out += c; + if (escaped) escaped = false; + else if (c === '\\') escaped = true; + else if (c === '"') inString = false; + i += 1; + continue; + } + if (c === '"') { + inString = true; + out += c; + i += 1; + continue; + } + if (c === '/' && text[i + 1] === '/') { + while (i < text.length && text[i] !== '\n') i += 1; + continue; + } + if (c === '/' && text[i + 1] === '*') { + i += 2; + while (i < text.length && !(text[i] === '*' && text[i + 1] === '/')) i += 1; + i += 2; + continue; + } + out += c; + i += 1; + } + return out; +} + +/** The `dts()` plugin options object in a vite config, brace-balanced. */ +export function dtsOptionsBody(source) { + const clean = stripJsonComments(source); + const call = clean.indexOf('dts('); + if (call < 0) return null; + const open = clean.indexOf('{', call); + const close = clean.indexOf(')', call); + if (open < 0 || (close >= 0 && open > close)) return ''; + let depth = 0; + let i = open; + for (; i < clean.length; i += 1) { + if (clean[i] === '{') depth += 1; + else if (clean[i] === '}') { + depth -= 1; + if (depth === 0) { + i += 1; + break; + } + } + } + return clean.slice(open, i); +} + +/** + * What a package's build actually is, read off disk rather than assumed. + * + * @returns {{ build: string, usesTsup: boolean, usesViteBuild: boolean, + * hasDtsPlugin: boolean, dtsHasOwnExclude: boolean, + * tsconfigNoEmit: unknown, exclude: unknown, hasTsconfig: boolean }} + */ +export function emitterFactsFor(root, pkg) { + const dir = join(root, pkg.dir); + const build = String(pkg.manifest?.scripts?.build ?? ''); + const viteConfig = ['vite.config.ts', 'vite.config.mts', 'vite.config.js'] + .map((f) => join(dir, f)) + .find((f) => existsSync(f)); + const dtsBody = viteConfig ? dtsOptionsBody(readFileSync(viteConfig, 'utf8')) : null; + const tsconfigPath = join(dir, 'tsconfig.json'); + const hasTsconfig = existsSync(tsconfigPath); + let tsconfig = null; + if (hasTsconfig) { + try { + tsconfig = JSON.parse(stripJsonComments(readFileSync(tsconfigPath, 'utf8'))); + } catch (error) { + throw new Error(`${pkg.dir}/tsconfig.json is not parseable as JSONC: ${error.message}`); + } + } + return { + build, + usesTsup: /(^|[\s&|;])tsup([\s&|;]|$)/.test(build), + usesViteBuild: /vite\s+build/.test(build), + hasDtsPlugin: dtsBody !== null, + dtsHasOwnExclude: typeof dtsBody === 'string' && /(^|[\s{,])exclude\s*:/.test(dtsBody), + tsconfigNoEmit: tsconfig?.compilerOptions?.noEmit, + exclude: tsconfig ? (tsconfig.exclude ?? null) : null, + hasExcludeKey: Boolean(tsconfig) && Object.hasOwn(tsconfig, 'exclude'), + hasTsconfig, + }; +} + +/** + * The packages whose emitter does not build its program from this tsconfig, so + * requiring a shape in it would assert nothing. + * + * Each entry states the facts it rests on, and `requires` re-checks them every + * run so an entry cannot outlive the package's claim to it. SHRINK-ONLY: a + * carve-out naming a package that is no longer published is a failure, not a + * no-op. + */ +export const EMITTER_CARVE_OUTS = { + '@object-ui/cli': { + reason: + '`tsup` emits from its entry graph only, so the tsconfig file list does not decide what is ' + + 'written; having the tests in the program is exactly HOW they get their `tsc --noEmit` ' + + 'coverage (objectui#4846)', + requires: (facts) => (facts.usesTsup ? null : `its build script is now \`${facts.build}\`, not tsup`), + }, + '@object-ui/create-plugin': { + reason: + 'the THIRD `tsup` package, found at authoring time rather than named in the ruling ' + + '(objectui#7212): same `tsup.config.ts` shape and same `dts: true` entry-graph emit as ' + + '`cli` and `data-objectstack`', + requires: (facts) => (facts.usesTsup ? null : `its build script is now \`${facts.build}\`, not tsup`), + }, + '@object-ui/data-objectstack': { + reason: '`tsup` emits from its entry graph only (objectui#4846)', + requires: (facts) => (facts.usesTsup ? null : `its build script is now \`${facts.build}\`, not tsup`), + }, + '@object-ui/plugin-charts': { + reason: + 'named by the ruling: its build tsconfig carries no `exclude` key at all and its tooling ' + + 'protection lives in the `exclude` passed to `dts()` in `vite.config.ts` (objectui#4846 ' + + 'measured 31 tooling files in its program, none emitted)', + requires: (facts) => { + if (!facts.hasDtsPlugin) return 'its vite config no longer loads the `dts()` plugin'; + if (!facts.dtsHasOwnExclude) return 'its `dts()` options no longer pass their own `exclude`'; + if (facts.hasExcludeKey) + return 'its build tsconfig now HAS an `exclude` key, so the directory form belongs in it'; + return null; + }, + }, + '@object-ui/console': { + reason: + 'a Vite APPLICATION: `noEmit: true` and no `dts()` plugin, so it writes no declarations and ' + + 'its published bundle comes from the Rollup entry graph. Worth recording rather than just ' + + 'exempting -- it is the one package that DOES carry a non-test helper under a tooling ' + + 'directory (`src/__tests__/helpers/preview-page-sources.ts`), live behind this single guard', + requires: (facts) => { + if (facts.hasDtsPlugin) return 'its vite config now loads a `dts()` plugin, so it emits declarations'; + if (facts.tsconfigNoEmit !== true) return 'its tsconfig no longer sets `noEmit: true`'; + return null; + }, + }, + '@object-ui/runner': { + reason: + 'the SECOND Vite application, found at authoring time rather than named in the ruling ' + + '(objectui#7212): same shape as `console` -- `noEmit: true`, no `dts()` plugin, published ' + + '`dist` is a bundle from the Rollup entry graph', + requires: (facts) => { + if (facts.hasDtsPlugin) return 'its vite config now loads a `dts()` plugin, so it emits declarations'; + if (facts.tsconfigNoEmit !== true) return 'its tsconfig no longer sets `noEmit: true`'; + return null; + }, + }, +}; + +// -- the scan ----------------------------------------------------------------- + +/** + * @returns {{ findings: object[], counters: object, packages: object[] }} + */ +export function analyze(root, { carveOuts = EMITTER_CARVE_OUTS, minPackages = MIN_PACKAGES } = {}) { + const packages = discoverPublishedPackages(root); + const findings = []; + const counters = { published: packages.length, enforced: 0, exempt: 0, compliant: 0 }; + + // A collapsed scan asserts nothing while exiting 0 -- the one verdict this + // family of gates must never return (objectui#4846). + if (packages.length < minPackages) { + findings.push({ + reason: 'scan-collapsed', + detail: + `found ${packages.length} published packages, below the floor of ${minPackages}. The ` + + 'release group, the manifest walk or the workspace layout broke; an empty comparison ' + + 'would pass while reading nothing.', + }); + return { findings, counters, packages }; + } + + const byName = new Map(packages.map((p) => [p.name, p])); + + // SHRINK-ONLY: a carve-out for a package npm no longer receives is stale. + for (const name of Object.keys(carveOuts)) { + if (!byName.has(name)) { + findings.push({ + reason: 'stale-carve-out', + pkg: name, + detail: + 'is named as an emitter carve-out but is not a published package any more. Delete the ' + + 'entry -- a carve-out list that outlives its packages is how the next one gets added ' + + 'without anyone re-reading why.', + }); + } + } + + for (const pkg of packages) { + let facts; + try { + facts = emitterFactsFor(root, pkg); + } catch (error) { + findings.push({ reason: 'unreadable-tsconfig', pkg: pkg.name, detail: error.message }); + continue; + } + + const carveOut = carveOuts[pkg.name]; + if (carveOut) { + const broken = carveOut.requires(facts); + if (broken) { + findings.push({ + reason: 'carve-out-no-longer-holds', + pkg: pkg.name, + detail: + `is exempt because ${carveOut.reason} -- but ${broken}. Re-derive the exemption ` + + 'against the build it has NOW, or delete the entry and give the package the ' + + 'directory form.', + }); + } + counters.exempt += 1; + continue; + } + + counters.enforced += 1; + + if (!facts.hasTsconfig) { + findings.push({ + reason: 'no-build-tsconfig', + pkg: pkg.name, + detail: + `has no ${pkg.dir}/tsconfig.json, so there is no build config to check. Reported as a ` + + 'finding rather than a skip: "nothing to look at, therefore clean" is the verdict this ' + + 'gate must never return.', + }); + continue; + } + + const missing = missingDirectories(facts.exclude, TOOLING_DIRECTORIES); + if (missing.length) { + findings.push({ + reason: 'name-only-tooling-exclude', + pkg: pkg.name, + dir: pkg.dir, + missing, + detail: + `its build tsconfig does not exclude ${missing.map((d) => `\`${d}/\``).join(', ')} as a ` + + `DIRECTORY. Current \`exclude\`: ${JSON.stringify(facts.exclude)}. Add ` + + `${missing.map((d) => `"${CANONICAL_FORM.replace('DIR', d)}"`).join(', ')}.`, + }); + continue; + } + + counters.compliant += 1; + } + + return { findings, counters, packages }; +} + +// -- reporting ---------------------------------------------------------------- + +export function report({ findings, counters }, log = console.log, logError = console.error) { + log('Published build tsconfigs -- tooling directories must be excluded by DIRECTORY, not by name'); + log( + ` scanned ${counters.published} published package(s): ` + + `${counters.enforced} enforced, ${counters.exempt} named emitter carve-out(s)`, + ); + log(` tooling directories (derived from TOOLING_FILE): ${TOOLING_DIRECTORIES.join(', ')}`); + + if (!findings.length) { + log(`OK -- all ${counters.compliant} enforced package(s) carry the directory form.`); + return 0; + } + + logError(''); + for (const finding of findings) { + logError(`FAIL [${finding.reason}] ${finding.pkg ?? ''}`.trimEnd()); + logError(` ${finding.detail}`); + } + logError(''); + logError( + `${findings.length} finding(s). A tooling exclude written as a 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 emitted into the package's published `dist` " + + '(objectui#4006 / #4836 / #6943 -- three times, each caught by a human).', + ); + logError( + 'Accepted spellings for a tooling directory DIR (substitute the directory name for DIR): ' + + `${ACCEPTED_FORMS.join(', ')} -- anchored globs, so they cover the directory wherever it sits.`, + ); + return 1; +} + +export function main(root = join(scriptDir, '..'), log = console.log, logError = console.error) { + return report(analyze(root), log, logError); +} + +if (isEntrypoint(import.meta.url)) { + process.exit(main()); +}