From ddd41bab97bd69e8f95e2fcff96337766077153c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 08:03:56 +0000 Subject: [PATCH] ci(types): run the root Vitest config's type program directly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repo-root `vitest.config.mts` had no runnable gate of its own (objectui#7328). A program did already contain it — `apps/console/tsconfig.node.json` has listed `../../vitest.config.mts` and `vitest.config.ts` since objectui#3476 — but the only invocation of that program was the console's own `type-check` script, reached through the task runner, whose `type-check` task waits on `^build`. So the cheapest compiler in the repo that reads the root Vitest config was reachable only behind a full workspace build, and no local gate union short of that could go red on it. PR #7291 is the measured cost: a conditionally spread `dist` project whose literal `extends: true` widened to `boolean` degraded the whole `projects` array to `never[]`; `type-check:scripts`, `type-check:vitest-setup` and the vitest runs were all green, and CI answered with three errors, two of them reported at `../../vitest.config.mts`. Adding the file to an existing root program was measured and is not available: `tsconfig.scripts.json` includes only `scripts/**/*.ts` and `tsconfig.vitest-setup.json` only `./vitest.setup*.ts(x)`, neither glob admits `.mts`, and the setup program deliberately ships no `@types/node` while the root config imports `path` and `url`. A NEW root program was measured too: its resolved source set is the same nine files as `apps/console/tsconfig.node.json`'s, so it would only add a second option set over seven shared files — the "green in one project, red in the other" hazard `tsconfig.scripts.json`'s header documents. So the program keeps its single home and gains a second, cheap runner: `type-check:vitest-config` invokes it directly, in the cheap half of the `Type Check` job beside `pnpm type-check:scripts`. Nothing in its program imports an `@object-ui/*` package (the 40 `@object-ui/*` occurrences in `vite.config.ts` are alias-map strings), so it needs the install and nothing built: 2.0s on a worktree with no `dist/` anywhere. `scripts/__tests__/vitest-config-type-check.test.ts` pins the behaviour — that every root `vitest.config.*` is in the resolved program, that the merging consumer is too, that `allowJs` stays off so the root config's `@ts-expect-error` remains live, and that CI runs the script before the build. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01MM7kaS4dPpYHV5BsMyu4tQ --- .github/workflows/ci.yml | 29 ++ content/docs/guide/ci-cd-pipeline.md | 2 +- package.json | 1 + .../vitest-config-type-check.test.ts | 283 ++++++++++++++++++ 4 files changed, 314 insertions(+), 1 deletion(-) create mode 100644 scripts/__tests__/vitest-config-type-check.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d259fe07f..467f4f27e4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -518,6 +518,35 @@ jobs: if: steps.relevant.outputs.should_run == 'true' run: pnpm type-check:scripts + # The repo-root `vitest.config.mts` had no RUNNABLE gate of its own + # (objectui#7328). A program did already contain it — + # `apps/console/tsconfig.node.json` lists `../../vitest.config.mts` and + # `vitest.config.ts` outright (objectui#3476) — but the only thing that + # ran that program was the console's own `type-check` script, which is + # `tsc --noEmit && tsc -b tsconfig.node.json --force` behind + # `turbo run type-check`, whose task dependsOn `^build`. So the cheapest + # compiler that reads the root Vitest config was reachable only through + # the most expensive job in the repo, and no local gate union short of a + # full workspace build could red on it. + # + # That is not hypothetical. PR #7291 pushed a conditionally spread `dist` + # project whose literal `extends: true` widened to `boolean`, degrading + # the whole `projects` array to `never[]`; every gate the author ran was + # green and CI reported three errors, two of them at + # `../../vitest.config.mts`. This step is the same compiler, invoked + # directly. + # + # Placed in the CHEAP half beside `pnpm type-check:scripts`, and for the + # same measured reason: nothing in this project's program imports an + # @object-ui/* package (its `@object-ui/*` occurrences are alias-map + # STRINGS in `vite.config.ts`), so it needs the install and nothing built + # — 2.0s on a worktree with no `dist/` anywhere. + # `scripts/__tests__/vitest-config-type-check.test.ts` pins that premise, + # this step's presence and its position. + - name: Type-check the root Vitest config + if: steps.relevant.outputs.should_run == 'true' + run: pnpm type-check:vitest-config + # ── Cache bookkeeping cannot void a recorded verdict (objectui#6577) ── # THE ORDERING, because it is the only thing a future reader needs in # order to judge whether this step may be touched: this job's verdict is diff --git a/content/docs/guide/ci-cd-pipeline.md b/content/docs/guide/ci-cd-pipeline.md index 128fff5cc2..cd01541b3a 100644 --- a/content/docs/guide/ci-cd-pipeline.md +++ b/content/docs/guide/ci-cd-pipeline.md @@ -209,7 +209,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:unreferenced-sources`, then `pnpm check:doc-example-readers`, then `pnpm check:handler-key-reads`, then `pnpm check:published-tsconfig-exclude`, then `pnpm check:side-effects-array`, then `pnpm check:element-data-source-declaration`, then `pnpm check:esm-specifiers`, then `pnpm check:spec-symbols`, then `pnpm check:action-forward-parity`, then `pnpm check:designer-field-key-parity`, then `pnpm check:icon-record-names`, then `pnpm check:i18n-keys`, then `pnpm check:i18n-drift`, then `pnpm type-check:scripts`, then `pnpm type-check`, then `pnpm type-check:vitest-setup`. The coverage guard runs first because turbo silently skips packages that have no `type-check` script, so a package without one would otherwise read as passing (#2911). `pnpm check:phantom-deps` fails when a released package imports a bare specifier its own `package.json` does not declare — a *phantom dependency*, invisible locally because the workspace root's `devDependencies` sit on the upward resolution path from every package directory and on no consumer's, so `require.resolve('react', { paths: ['packages/core/src'] })` succeeds while `@object-ui/core` declares react in no field at all ([#4394](https://github.com/objectstack-ai/objectui/issues/4394)). `pnpm check:self-import` runs next because it reuses that gate's parser: it fails when a file inside a package names its OWN package, a specifier that resolves through the package's `exports` map to `dist/` while `type-check` waits on `^build` — the *dependencies'* builds, never the package's own — so on a cold cache the declarations do not exist yet and the file fails with `TS2307`. Locally it is always green, because every local workflow builds before it type-checks and leaves a `dist/` behind; PR #4789's first run was red on exactly one such line ([#4801](https://github.com/objectstack-ai/objectui/issues/4801)). `pnpm check:unreferenced-sources` runs next, reusing the same parser again: it fails when a covered package ships a source file that nothing reaches — not the package's declared entry, and not its build config. Until [#7515](https://github.com/objectstack-ai/objectui/issues/7515) no gate here could see one: `check-dist-completeness` asks whether `dist/` holds what `tsc` emits, `check-readme-exports` compares documented exports against shipped ones, and a file that is in the tarball while being reachable from nothing is outside both — so the detection mechanism was a human reading unrelated code, which is how both instances found in one week were found ([#7319](https://github.com/objectstack-ai/objectui/issues/7319), [#7397](https://github.com/objectstack-ai/objectui/issues/7397)). The hazard is not the bytes: the file #7319 removed carried the same export name as a live engine one package over and evaluated no predicate, so name-completion alone could have wired a silently wrong renderer into a published package. Reachability has TWO roots, and the second is the whole difficulty — `packages/components` reaches its two `use-sync-external-store` shims only through `vite.config.ts` `resolve.alias` entries whose importer is a bundled dependency no source file names, so a walk that skips that leg reports exactly those two live files as dead on its first run, and a gate that cries wolf gets switched off rather than fixed. Scope is DECLARED per package in `COVERED_PACKAGES` and the uncovered remainder is printed as a count derived from the workspace on every run, because the alias mechanisms differ per package and a gate that covers one package correctly beats one that covers forty with false positives. An alias expression it cannot evaluate is a FINDING rather than a skip, since skipping one would make it accuse whatever file that alias points at. `pnpm check:doc-example-readers` runs next, on the same parser again: it fails when an exported symbol's own JSDoc `@example` hand-spells a resolution that its REAL call sites obtain by calling a shared reader. A doc comment is what the next call site is copied from, so prose that outlives the ruling it encoded re-seeds every later copy — measured at two cards and three copied call sites ([#7627](https://github.com/objectstack-ai/objectui/issues/7627), [#7638](https://github.com/objectstack-ai/objectui/issues/7638)), both closed by pointing the prose at `resolveRecordSourceObjectName`. Nothing here could see either one, and `check-spec-symbol-derivation` was credited with the class twice — in #7638's card body and then in the dispatch that repeated it — while its rule 4 judges `@objectstack/spec` citations at member granularity and says nothing about prose prescribing a LOCAL spelling ([#7652](https://github.com/objectstack-ai/objectui/issues/7652)). It fires on four conditions at once — the example calls the symbol it documents, a real call site fills the same argument slot by calling an exported single-`return` reader, the example does not, and what the example writes there is that reader's own return expression or one of the rungs it resolves between — which is what keeps it off the literals and placeholders an example legitimately carries. It does NOT judge whether a prescribed spelling is correct: on the day either card was filed the prose and every copy of it agreed, and no gate reading only the tree can know a ruling. What it catches is the state right after, when the call sites move and the prose does not. `pnpm check:handler-key-reads` runs next, on the same parser again: it fails when an `on*` handler key that a REGISTERED renderer reads off the authored document is not a declared member of the zod arm for the type it is registered under. `BaseSchema` is `.passthrough()`, so an undeclared key is not refused — it stops being judged and the value is KEPT, then reaches the renderer that reads it; measured on the built dist, `{ type: 'kanban', columns: [], onCardClick: { action: 'toast' } }` went from REFUSED to ACCEPTED with the object surviving into the parsed output ([#7664](https://github.com/objectstack-ai/objectui/issues/7664)). Every gate stayed green, because the [#6124](https://github.com/objectstack-ai/objectui/issues/6124) ledger's population is two hand-written arrays of tuples and that change re-keyed the arm by SUBSTITUTION — so its length assertion held, and a count ratchet would have been green too, which is why [#7753](https://github.com/objectstack-ai/objectui/issues/7753) rejected that option on the instance itself. This gate derives BOTH populations: the arms from every `type: z.literal(…)` in `packages/types/src/zod`, and the read sites from every real `ComponentRegistry.register(…)` call — read off the AST, because one types file NAMES that call in prose eleven times and registers nothing. It follows the document one component at a time rather than every JSX child, because most children are handed a DIFFERENT document (a dashboard's widgets each get their own), and the chain it must reach is four hops long: `register('kanban', ObjectKanbanRenderer)` names a component, that component is an HOC, the document arrives at `ObjectKanban` through a render-prop parameter and at `KanbanRenderer` through an object spread. It says nothing about keys that reach a renderer only through a `{...props}` spread onto a Radix root or a DOM listener slot — there is no read site to derive from — nor about the ledger's `?: never` tombstones, which have no read site by construction; `KNOWN_UNDECLARED_READS` is an exemption list that only shrinks, each row naming the card that owns the fix, and a row whose read site the gate can no longer find fails it. It lives in `scripts/` because the read sites are spread across `@object-ui/plugin-*` and `packages/components`, which `@object-ui/types` may not import — `check:phantom-deps` rejects it and it would close a cycle. `pnpm check:published-tsconfig-exclude` follows, config reads only: it fails when a published package's build `tsconfig.json` excludes tooling by FILE NAME (`*.test.ts`) without also excluding the tooling DIRECTORIES (`**/__tests__/**` and its two siblings, derived from `TOOLING_FILE` rather than retyped). A name-only exclude stops the files that happen to be named that way and nothing else, so the first shared helper added to a `__tests__/` directory becomes a program input and an emitting program writes it into the published `dist` — three times so far, each found by a human and never by a gate ([#4006](https://github.com/objectstack-ai/objectui/issues/4006), [#4836](https://github.com/objectstack-ai/objectui/issues/4836), [#6943](https://github.com/objectstack-ai/objectui/issues/6943), the third in the same package as the first). [#7212](https://github.com/objectstack-ai/objectui/issues/7212) measured the standing exposure — 29 published packages carrying the name form with ZERO offending files, green because nobody had added such a helper yet — and the gate landed together with their conversion so `main` was green on merge. It reads `exclude` arrays and nothing else: no build, no artifact, no emit model, which is the narrower scope that keeps it clear of the modelling [#4846](https://github.com/objectstack-ai/objectui/issues/4846) declined for the artifact-level gate. Six published packages are named carve-outs, each re-proving its own reason on every run: `cli`, `create-plugin` and `data-objectstack` emit from a `tsup` entry graph, `plugin-charts` keeps its tooling exclude in the `dts()` options, and `console` and `runner` are Vite applications with `noEmit: true` and no `dts()` plugin. `pnpm check:side-effects-array` runs next, sources only and no build: it fails when a package's `sideEffects` ARRAY and its module bodies disagree in either direction — a module that registers something at load time and is not named (a bundler drops it, and the registration is gone from a *consumer's* app with no error, no warning and exit 0), or a name whose module no longer registers anything. `@object-ui/app-shell` declares such an array because both simpler answers are measurably wrong for it: omitting the field makes the whole package unshakeable, and `"sideEffects": false` silently drops three live SDUI widget registrations to zero chunks ([#6535](https://github.com/objectstack-ai/objectui/issues/6535), [#6683](https://github.com/objectstack-ai/objectui/issues/6683)). The enumeration is re-derived from the module bodies on every run rather than listed, so there is no second copy to rot. The artifact half of the same contract — do those registrations survive a real bundler — cannot run in this job at all: it needs a built console, so it lives in the SDUI registration pin step of `performance-budget.yml`. `pnpm check:element-data-source-declaration` runs next, sources only and no build: it fails when a source that consumes `ElementDataSourceGate` does not also pass through `elementDataSourceBlock()`, the seam that declares the `dataSource` key the gate reads. A block that wraps the gate off-seam publishes an authoring surface missing the one key its own runtime honours, and the html tier reports that key with the same `unknown-prop` warning it gives the spellings that do nothing ([#6678](https://github.com/objectstack-ai/objectui/issues/6678)). `pnpm check:esm-specifiers` follows it for the same reason — sources only, no build: it fails when a published package whose build preserves import specifiers (a bare emitting `tsc`, which never rewrites them) writes a relative specifier with no file extension. Node's ESM resolver does not extension-search relative specifiers, so such a specifier makes the published entry unloadable outside a bundler; `@object-ui/react`'s entry died with `ERR_MODULE_NOT_FOUND` while every bundler-based consumer, the whole test suite and CI stayed green ([#4538](https://github.com/objectstack-ai/objectui/issues/4538)). The half that actually *imports* each built entry needs a full build and runs in `node-esm-load-gate.yml`. `pnpm check:action-forward-parity` fails when an action renderer's forward whitelist drops a key the action runtime reads — the class that shipped six times one key at a time, each time green, because the key parses and publishes while the payload is dropped one hop before the runner ([#4050](https://github.com/objectstack-ai/objectui/issues/4050)). `pnpm check:designer-field-key-parity` fails when one of the field designers' statically declared payload shapes (`FieldMetadataPayload`, `ServerFieldSchema`, `DesignerFieldDefinition`) declares a key the installed `@objectstack/spec` `FieldSchema` refuses by NAME. Such a key makes `PUT /api/v1/meta/object/:name` return a hard 422 `INVALID_METADATA` that blocks *every subsequent save* of that object, and the author cannot tell from the designer UI which key did it — the class had been filed three times, each closed with a per-key tombstone written after the instance was found in production, with nothing detecting the next one ([#4644](https://github.com/objectstack-ai/objectui/issues/4644) `indexed`, [#4687](https://github.com/objectstack-ai/objectui/issues/4687) `distance_metric`, [#4676](https://github.com/objectstack-ai/objectui/issues/4676) `placeholder`, gated by [#5761](https://github.com/objectstack-ai/objectui/issues/5761)). It reads the accept set off the schema itself rather than from a list, and it covers a deliberately documented *subset* of the write path: a key that reaches the payload only through a `patchDef` spread or an index signature is outside its reach, and the boundary is stated in the script's own docblock. Its draft-I/O half — the `readFields`/`writeFields` round-trip, which has no declared shape to read — runs in the test suite as `object-fields-io.spec-keys.test.ts`. Same placement rationale as the gates around it: it parses the sources with `typescript` and imports the installed spec, so it needs the install and nothing built. `pnpm check:icon-record-names` fails when an authored icon NAME that reaches a resolver reading lucide's runtime `icons` record is not a live key of that record. lucide retires a spelling by dropping it from that record while keeping it as a deprecated named export, so the retired name still imports, still type-checks and still renders wherever it is used as a *component* — `Edit === SquarePen` is true — and resolves to nothing wherever it is used as a *string*: nothing goes red in either direction, which is why the class was repaired twice in two packages before anyone gated it ([#5586](https://github.com/objectstack-ai/objectui/issues/5586), [#5622](https://github.com/objectstack-ai/objectui/issues/5622), [#5633](https://github.com/objectstack-ai/objectui/issues/5633)). It carries no list of retired spellings — the record itself is the judgement — and it re-discovers the resolver population from source on every run, which is how its first pass found four record-reading resolvers nobody had catalogued. It sits here because it parses the sources with `typescript` and reads the installed lucide: the install, and nothing built. The two locale gates sit in the middle because both parse the sources with `typescript`: they need the install and nothing built. `pnpm check:i18n-keys` fails when a `t()` call site asks for a key the `en` pack does not define ([#3530](https://github.com/objectstack-ai/objectui/issues/3530)); `pnpm check:i18n-drift` fails when a change to an `en` string is not accompanied by the nine translation packs ([#3650](https://github.com/objectstack-ai/objectui/issues/3650)), and it is why this job's checkout sets `fetch-depth: 0` — it diffs against the merge base, which a depth-1 clone cannot resolve. `pnpm type-check:scripts` (`tsconfig.scripts.json`) covers `scripts/**/*.ts`, which `pnpm type-check` cannot reach at all — `scripts/` has no package.json, so turbo never walks it, and the coverage guard decides coverage per *package*. Until [#3494](https://github.com/objectstack-ai/objectui/issues/3494) that left the pin tests in `scripts/__tests__/` — including the one pinning this very page — compiled by nothing. `pnpm type-check:vitest-setup` (`tsconfig.vitest-setup.json`) closes the same gap for the four repo-root `vitest.setup.*` files, uncovered until [#3515](https://github.com/objectstack-ai/objectui/issues/3515); it runs *last*, after `pnpm type-check`, because `vitest.setup.dom.tsx` side-effect-imports four `@object-ui/*` packages and resolves them through the declarations that turbo's `^build` produces. | Every run; on a PR the steps short-circuit when only ignored paths changed | +| `type-check` | Type Check | `scripts/check-type-check-coverage.mjs`, then `pnpm check:phantom-deps`, then `pnpm check:self-import`, then `pnpm check:unreferenced-sources`, then `pnpm check:doc-example-readers`, then `pnpm check:handler-key-reads`, 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:vitest-config`, then `pnpm type-check`, then `pnpm type-check:vitest-setup`. The coverage guard runs first because turbo silently skips packages that have no `type-check` script, so a package without one would otherwise read as passing (#2911). `pnpm check:phantom-deps` fails when a released package imports a bare specifier its own `package.json` does not declare — a *phantom dependency*, invisible locally because the workspace root's `devDependencies` sit on the upward resolution path from every package directory and on no consumer's, so `require.resolve('react', { paths: ['packages/core/src'] })` succeeds while `@object-ui/core` declares react in no field at all ([#4394](https://github.com/objectstack-ai/objectui/issues/4394)). `pnpm check:self-import` runs next because it reuses that gate's parser: it fails when a file inside a package names its OWN package, a specifier that resolves through the package's `exports` map to `dist/` while `type-check` waits on `^build` — the *dependencies'* builds, never the package's own — so on a cold cache the declarations do not exist yet and the file fails with `TS2307`. Locally it is always green, because every local workflow builds before it type-checks and leaves a `dist/` behind; PR #4789's first run was red on exactly one such line ([#4801](https://github.com/objectstack-ai/objectui/issues/4801)). `pnpm check:unreferenced-sources` runs next, reusing the same parser again: it fails when a covered package ships a source file that nothing reaches — not the package's declared entry, and not its build config. Until [#7515](https://github.com/objectstack-ai/objectui/issues/7515) no gate here could see one: `check-dist-completeness` asks whether `dist/` holds what `tsc` emits, `check-readme-exports` compares documented exports against shipped ones, and a file that is in the tarball while being reachable from nothing is outside both — so the detection mechanism was a human reading unrelated code, which is how both instances found in one week were found ([#7319](https://github.com/objectstack-ai/objectui/issues/7319), [#7397](https://github.com/objectstack-ai/objectui/issues/7397)). The hazard is not the bytes: the file #7319 removed carried the same export name as a live engine one package over and evaluated no predicate, so name-completion alone could have wired a silently wrong renderer into a published package. Reachability has TWO roots, and the second is the whole difficulty — `packages/components` reaches its two `use-sync-external-store` shims only through `vite.config.ts` `resolve.alias` entries whose importer is a bundled dependency no source file names, so a walk that skips that leg reports exactly those two live files as dead on its first run, and a gate that cries wolf gets switched off rather than fixed. Scope is DECLARED per package in `COVERED_PACKAGES` and the uncovered remainder is printed as a count derived from the workspace on every run, because the alias mechanisms differ per package and a gate that covers one package correctly beats one that covers forty with false positives. An alias expression it cannot evaluate is a FINDING rather than a skip, since skipping one would make it accuse whatever file that alias points at. `pnpm check:doc-example-readers` runs next, on the same parser again: it fails when an exported symbol's own JSDoc `@example` hand-spells a resolution that its REAL call sites obtain by calling a shared reader. A doc comment is what the next call site is copied from, so prose that outlives the ruling it encoded re-seeds every later copy — measured at two cards and three copied call sites ([#7627](https://github.com/objectstack-ai/objectui/issues/7627), [#7638](https://github.com/objectstack-ai/objectui/issues/7638)), both closed by pointing the prose at `resolveRecordSourceObjectName`. Nothing here could see either one, and `check-spec-symbol-derivation` was credited with the class twice — in #7638's card body and then in the dispatch that repeated it — while its rule 4 judges `@objectstack/spec` citations at member granularity and says nothing about prose prescribing a LOCAL spelling ([#7652](https://github.com/objectstack-ai/objectui/issues/7652)). It fires on four conditions at once — the example calls the symbol it documents, a real call site fills the same argument slot by calling an exported single-`return` reader, the example does not, and what the example writes there is that reader's own return expression or one of the rungs it resolves between — which is what keeps it off the literals and placeholders an example legitimately carries. It does NOT judge whether a prescribed spelling is correct: on the day either card was filed the prose and every copy of it agreed, and no gate reading only the tree can know a ruling. What it catches is the state right after, when the call sites move and the prose does not. `pnpm check:handler-key-reads` runs next, on the same parser again: it fails when an `on*` handler key that a REGISTERED renderer reads off the authored document is not a declared member of the zod arm for the type it is registered under. `BaseSchema` is `.passthrough()`, so an undeclared key is not refused — it stops being judged and the value is KEPT, then reaches the renderer that reads it; measured on the built dist, `{ type: 'kanban', columns: [], onCardClick: { action: 'toast' } }` went from REFUSED to ACCEPTED with the object surviving into the parsed output ([#7664](https://github.com/objectstack-ai/objectui/issues/7664)). Every gate stayed green, because the [#6124](https://github.com/objectstack-ai/objectui/issues/6124) ledger's population is two hand-written arrays of tuples and that change re-keyed the arm by SUBSTITUTION — so its length assertion held, and a count ratchet would have been green too, which is why [#7753](https://github.com/objectstack-ai/objectui/issues/7753) rejected that option on the instance itself. This gate derives BOTH populations: the arms from every `type: z.literal(…)` in `packages/types/src/zod`, and the read sites from every real `ComponentRegistry.register(…)` call — read off the AST, because one types file NAMES that call in prose eleven times and registers nothing. It follows the document one component at a time rather than every JSX child, because most children are handed a DIFFERENT document (a dashboard's widgets each get their own), and the chain it must reach is four hops long: `register('kanban', ObjectKanbanRenderer)` names a component, that component is an HOC, the document arrives at `ObjectKanban` through a render-prop parameter and at `KanbanRenderer` through an object spread. It says nothing about keys that reach a renderer only through a `{...props}` spread onto a Radix root or a DOM listener slot — there is no read site to derive from — nor about the ledger's `?: never` tombstones, which have no read site by construction; `KNOWN_UNDECLARED_READS` is an exemption list that only shrinks, each row naming the card that owns the fix, and a row whose read site the gate can no longer find fails it. It lives in `scripts/` because the read sites are spread across `@object-ui/plugin-*` and `packages/components`, which `@object-ui/types` may not import — `check:phantom-deps` rejects it and it would close a cycle. `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-config` runs `apps/console/tsconfig.node.json` directly — the program that already lists `../../vitest.config.mts` and `apps/console/vitest.config.ts` ([#3476](https://github.com/objectstack-ai/objectui/issues/3476)) — because until [#7328](https://github.com/objectstack-ai/objectui/issues/7328) the only thing that ran it was the console's own `type-check` script, reached through the task runner, whose `type-check` task waits on `^build`. (Named in prose rather than as a code span on purpose: the pin below reads this cell as this job's gate list, so spelling that invocation out would credit the job with a command it does not run.) The cheapest compiler that reads the root Vitest config was therefore reachable only through the most expensive job here, and PR #7291 paid for it: a conditionally spread `dist` project whose literal `extends: true` widened to `boolean` degraded the whole `projects` array to `never[]`, every gate its author ran was green, and CI reported three errors, two of them at `../../vitest.config.mts`. It sits in the cheap half beside `pnpm type-check:scripts` for the same measured reason — nothing in its program imports an `@object-ui/*` package, so it needs the install and nothing built. `pnpm type-check:vitest-setup` (`tsconfig.vitest-setup.json`) closes the same gap for the four repo-root `vitest.setup.*` files, uncovered until [#3515](https://github.com/objectstack-ai/objectui/issues/3515); it runs *last*, after `pnpm type-check`, because `vitest.setup.dom.tsx` side-effect-imports four `@object-ui/*` packages and resolves them through the declarations that turbo's `^build` produces. | Every run; on a PR the steps short-circuit when only ignored paths changed | | `test` | Test (shard N/4) | `pnpm test --shard=N/4` across a 4-runner matrix with `fail-fast: false`, so every shard reports its own failures. No coverage instrumentation — v8 adds 40–100% overhead. Then, **on shard 1 only**, `pnpm test:dist` — the built-artifact lane ([#7183](https://github.com/objectstack-ai/objectui/issues/7183)). It delegates to a turbo task scoped to the one package that holds built-artifact pins; that task depends on the package's OWN build (`dependsOn: ["build"]`, not `^build`), so the bundle exists before the pins read it, and then runs the `dist` vitest project, whose pins import a package's BUILT bundle instead of its `src` — a claim the source-aliased suite above is structurally unable to make, since the root config aliases every workspace package to `src`. It is deliberately not sharded and not repeated on the other three runners: the lane is a handful of files, and running it on all four would pay for the same build four times. | Pull requests and merge-queue builds (everything but `push`); steps short-circuit on a PR that changed only ignored paths | | `test-coverage` | Test (coverage shard N/4) | `pnpm test:coverage --reporter=blob --shard=N/4` across a 4-runner matrix with `fail-fast: false`. Each shard writes `.vitest-reports/blob-N-4.json` — raw coverage and test results in one file — and uploads it as an artifact even when the shard is red, which is what makes a failing coverage run diagnosable at all (vitest deletes `coverage/` on a red run unless `coverage.reportOnFailure` is set, [#5402](https://github.com/objectstack-ai/objectui/issues/5402)). The configured coverage thresholds are neutralised on the shard legs, because a quarter of the suite judged against a whole-suite threshold is not a defect signal; they are enforced once, on the merged report, by the job below ([#5403](https://github.com/objectstack-ai/objectui/issues/5403)). | **Push only** | | `coverage-report` | Test (coverage) | Downloads the four blob reports, refuses to continue unless all four arrived, merges them with `pnpm test:coverage --merge-reports` into one complete report — which is where the configured coverage thresholds are enforced, over the whole merged map, the shard legs having overridden them to zero — and publishes that report as the `coverage-report` artifact (kept 7 days, the same as the blobs it is derived from). Its last step runs on every path and states the outcome: the job is **red, with an error annotation**, whenever the gate did not run for the commit — before [#5403](https://github.com/objectstack-ai/objectui/issues/5403) the final step carried the implicit `success()` and was silently skipped by 311 of 373 coverage jobs, which is how four days of a 100%-failing coverage job went unnoticed. A breach of the thresholds is reported *separately* from a lane that never delivered, because the two call for opposite actions. ⛔ It never merges a report from fewer than four shards: a wrong coverage number is worse than a missing one. The Codecov upload this job used to carry was retired by [#5436](https://github.com/objectstack-ai/objectui/issues/5436) — `CODECOV_TOKEN` was never set, so it failed on every push; the trend dashboard and PR coverage comments are gone with it, the gate is not. | **Push only** | diff --git a/package.json b/package.json index 1f2fcd390d..b51c665351 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "type-check:e2e": "tsc -p tsconfig.e2e.json", "type-check:scripts": "tsc -p tsconfig.scripts.json", "type-check:vitest-setup": "tsc -p tsconfig.vitest-setup.json", + "type-check:vitest-config": "tsc -b apps/console/tsconfig.node.json --force", "check:spec-symbols": "node scripts/check-spec-symbol-derivation.mjs", "check:action-forward-parity": "node scripts/check-action-forward-parity.mjs", "check:designer-field-key-parity": "node scripts/check-designer-field-key-parity.mjs", diff --git a/scripts/__tests__/vitest-config-type-check.test.ts b/scripts/__tests__/vitest-config-type-check.test.ts new file mode 100644 index 0000000000..729585da40 --- /dev/null +++ b/scripts/__tests__/vitest-config-type-check.test.ts @@ -0,0 +1,283 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; + +/** + * objectui#7328 — the repo-root `vitest.config.mts` had no RUNNABLE gate. + * + * Not "no program": `apps/console/tsconfig.node.json` has listed + * `../../vitest.config.mts` and `vitest.config.ts` since objectui#3476, and that + * program is exactly the compiler that catches drift in the one file which + * merges two config objects. What was missing was a way to RUN it. The only + * invocation was the console's own `type-check` script — `tsc --noEmit && tsc -b + * tsconfig.node.json --force` — reached through `turbo run type-check`, whose + * task `dependsOn: ["^build"]`. So the cheapest compiler in the repo that reads + * the root Vitest config was reachable only behind a full workspace build, and + * every local gate union short of that was structurally unable to go red on it. + * + * PR #7291 is the measured cost: a conditionally spread `dist` project whose + * literal `extends: true` widened to `boolean` degraded the whole `projects` + * array to `never[]`; `type-check:scripts`, `type-check:vitest-setup` and the + * vitest runs were all green, and CI answered with three errors, two of them + * reported at `../../vitest.config.mts`. + * + * `type-check:vitest-config` closes it by giving that program its own runner. + * This file is what stops it reopening, and like its siblings + * `scripts-type-check.test.ts` and `vitest-setup-type-check.test.ts` it asserts + * BEHAVIOUR — which files the project really resolves, whether CI really runs + * it, and where — rather than the spelling of any config. A test that only read + * spellings would be satisfied by an `include` entry matching nothing, which is + * the objectui#3476 failure one door over: a literal, glob-less `include` entry + * that matches no file is silently ignored by TypeScript and reads as coverage + * that was never there. + * + * ⚠️ The `.mts` trap this card was triaged against belongs here too. Neither + * root program's globs admit the extension — `tsconfig.scripts.json` includes + * `scripts/**\/*.ts` and `tsconfig.vitest-setup.json` `./vitest.setup*.ts(x)` — + * so "add an include entry" was never the fix, and the assertion below asks + * TypeScript whether the `.mts` file is in the resolved program rather than + * whether some string names it. + */ +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const configPath = path.join(repoRoot, 'apps/console/tsconfig.node.json'); +const ciWorkflowPath = path.join(repoRoot, '.github/workflows/ci.yml'); + +/** The root script CI invokes, and the one a contributor can run locally. */ +const SCRIPT_NAME = 'type-check:vitest-config'; + +/** + * The project as TypeScript itself resolves it — the same parse `tsc` does, so + * `fileNames` is the real program root set rather than a re-implementation of + * TypeScript's `include`/`exclude` semantics. + */ +function parsedProject(): ts.ParsedCommandLine { + const read = ts.readConfigFile(configPath, ts.sys.readFile); + expect( + read.error && ts.flattenDiagnosticMessageText(read.error.messageText, ' '), + 'apps/console/tsconfig.node.json must parse as JSON with comments', + ).toBeFalsy(); + + return ts.parseJsonConfigFileContent(read.config, ts.sys, path.dirname(configPath), undefined, configPath); +} + +/** The program's root set, repo-relative and POSIX-separated. */ +function programFiles(): Set { + return new Set( + parsedProject().fileNames.map((f) => path.relative(repoRoot, f).split(path.sep).join('/')), + ); +} + +/** Every `vitest.config.*` source sitting at the repo root, repo-relative. */ +function rootVitestConfigsOnDisk(): string[] { + return fs + .readdirSync(repoRoot, { withFileTypes: true }) + .filter((e) => e.isFile() && /^vitest\.config\.[cm]?tsx?$/.test(e.name)) + .map((e) => e.name) + .sort(); +} + +/** + * Every `@object-ui/*` module specifier a source file actually imports or + * re-exports, read from the AST. The reasoning behind walking nodes instead of + * matching `from '…'` text — and the false positives that motivated it — is + * written once, in `scripts-type-check.test.ts` (objectui#4902); objectui#6996 + * swept this directory for suites that had restated it in their own words and + * found six describing a matcher that no longer existed, so this one points + * there rather than adding a seventh account. + */ +function workspaceImportSpecifiers(fileName: string, sourceText: string): string[] { + const sourceFile = ts.createSourceFile(fileName, sourceText, ts.ScriptTarget.Latest, false); + const specifiers: string[] = []; + + const visit = (node: ts.Node): void => { + if ( + (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && + node.moduleSpecifier !== undefined && + ts.isStringLiteral(node.moduleSpecifier) + ) { + specifiers.push(node.moduleSpecifier.text); + } else if ( + ts.isImportEqualsDeclaration(node) && + ts.isExternalModuleReference(node.moduleReference) && + ts.isStringLiteral(node.moduleReference.expression) + ) { + specifiers.push(node.moduleReference.expression.text); + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + + return specifiers.filter((specifier) => specifier.startsWith('@object-ui/')); +} + +describe('the root Vitest config is inside a resolvable program (objectui#7328)', () => { + it('parses with no diagnostics at all', () => { + // A tsconfig that fails to parse does not fail loudly: TypeScript falls back + // to defaults, and a project that then compiles the wrong file set still + // exits 0. `tsconfig.scripts.json` hit exactly that while being written. + const messages = parsedProject().errors.map((d) => + ts.flattenDiagnosticMessageText(d.messageText, ' '), + ); + expect(messages, 'apps/console/tsconfig.node.json emitted config diagnostics').toEqual([]); + }); + + it('resolves every root vitest.config.* source, with none left out', () => { + const onDisk = rootVitestConfigsOnDisk(); + + // Non-vacuity first. Every assertion below is satisfied by a project that + // resolves nothing, and "compiles nothing, exits 0" is the exact shape this + // gate exists to make impossible. + expect( + onDisk, + 'no vitest.config.* source found at the repo root — the scan is broken, or the root config ' + + 'was renamed and this gate is now measuring nothing', + ).toContain('vitest.config.mts'); + + const inProject = programFiles(); + const uncovered = onDisk.filter((f) => !inProject.has(f)); + + expect( + uncovered, + 'These repo-root Vitest configs are in no runnable tsc program:\n' + + uncovered.map((f) => ` - ${f}`).join('\n') + + '\n\nAdd each one to apps/console/tsconfig.node.json’s "include". ⚠️ Adding it to ' + + 'tsconfig.scripts.json or tsconfig.vitest-setup.json instead does NOT work: their globs ' + + 'are `scripts/**/*.ts` and `./vitest.setup*.ts(x)`, and neither admits `.mts` ' + + '(objectui#7328). A root Vitest config no tsc invocation reads is PR #7291 again — the ' + + 'author’s whole local gate union green, three errors on CI.', + ).toEqual([]); + }); + + it('covers the merging consumer too, not just the root config', () => { + // `apps/console/vitest.config.ts` is where the #7291 damage actually + // surfaced (`Argument of type 'UserConfig & …' is not assignable to + // parameter of type 'never'`): the root config degraded, and the merge line + // was the call that could not type. Covering the producer without the + // consumer would have caught two of those three errors and missed the one a + // reader would start from. + const inProject = programFiles(); + for (const file of ['vitest.config.mts', 'apps/console/vitest.config.ts']) { + expect(inProject, `${file} must be type-checked by apps/console/tsconfig.node.json`).toContain( + file, + ); + } + }); + + it('leaves allowJs off, so the root config’s @ts-expect-error stays live', () => { + // `vitest.config.mts` suppresses the untyped `./scripts/vitest-invocation-guard.mjs` + // import with a `@ts-expect-error` whose own comment names `allowJs: false`. + // Turning `allowJs` on here makes that directive unused and therefore an + // error of its own (TS2578) — the interaction `apps/console/tsconfig.node.json` + // documents and `tsconfig.scripts.json` deliberately paid for on its own + // file set. Pin it so it has to be a decision rather than an accident. + expect( + parsedProject().options.allowJs, + 'apps/console/tsconfig.node.json must not enable allowJs — vitest.config.mts’s ' + + '@ts-expect-error over the plain-JS invocation guard would become TS2578', + ).toBeFalsy(); + }); +}); + +describe('the gate is actually wired up (objectui#7328)', () => { + const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')) as { + scripts?: Record; + }; + const ciWorkflow = fs.readFileSync(ciWorkflowPath, 'utf8'); + + /** The `type-check:` job body, from its key up to the next job at the same indent. */ + function typeCheckJob(): string { + const start = ciWorkflow.search(/^ {2}type-check:[ \t]*$/m); + expect(start, 'ci.yml must still have a `type-check:` job').toBeGreaterThan(-1); + const rest = ciWorkflow.slice(start + 1); + const next = rest.search(/^ {2}[a-z0-9][a-z0-9-]*:[ \t]*$/m); + return next === -1 ? rest : rest.slice(0, next); + } + + it('is runnable locally, not CI-only', () => { + // A gate nobody can reproduce locally is a gate people learn to ignore — + // and "run the console's type-check before touching this file" was already + // written down as a lane convention when #7291 happened. The convention was + // not the missing part; a command was. + const script = pkg.scripts?.[SCRIPT_NAME]; + expect(script, `package.json must define "${SCRIPT_NAME}"`).toBeDefined(); + expect(script).toContain('apps/console/tsconfig.node.json'); + }); + + it('invokes the compiler directly, not through turbo', () => { + // The whole point is to skip `turbo run type-check`'s `^build`. Routing this + // script through turbo would restore the cost this card exists to remove + // while still reading like a cheap gate. + expect( + pkg.scripts?.[SCRIPT_NAME], + `"${SCRIPT_NAME}" must call tsc directly; going through turbo re-imposes the \`^build\` ` + + 'that made the root Vitest config expensive to check in the first place', + ).not.toContain('turbo'); + }); + + it('runs in ci.yml’s type-check job', () => { + expect( + typeCheckJob(), + `ci.yml’s type-check job must run \`pnpm ${SCRIPT_NAME}\`. Without it the script is a ` + + 'command nobody runs, and the root Vitest config is back to being covered only when the ' + + 'console’s own type-check happens to run.', + ).toContain(`pnpm ${SCRIPT_NAME}`); + }); + + it('runs after the install and BEFORE the build, in the cheap half of the job', () => { + const job = typeCheckJob(); + const install = job.indexOf('pnpm install --frozen-lockfile'); + const check = job.indexOf(`pnpm ${SCRIPT_NAME}`); + const build = job.indexOf('run: pnpm type-check\n'); + + expect(install, 'the type-check job must still install dependencies').toBeGreaterThan(-1); + expect(build, 'the type-check job must still run `pnpm type-check`').toBeGreaterThan(-1); + expect(check, `the type-check job must run \`pnpm ${SCRIPT_NAME}\``).toBeGreaterThan(-1); + + expect(check, `\`pnpm ${SCRIPT_NAME}\` needs tsc and the vite types from node_modules`).toBeGreaterThan( + install, + ); + expect( + check, + `\`pnpm ${SCRIPT_NAME}\` must come BEFORE \`pnpm type-check\`. Its value is that it fails ` + + 'without a workspace build; placed after the build it costs the same as the console ' + + 'type-check it replaces and answers the same question later.', + ).toBeLessThan(build); + }); + + it('still has a real reason to run before the build', () => { + // The premise behind the placement: nothing in this project's program + // imports an @object-ui/* package, so it needs no built declarations. The + // 40 `@object-ui/*` occurrences in `apps/console/vite.config.ts` are alias + // MAP VALUES — strings handed to Vite — which is exactly why this is asked + // of the AST and not of the file text. If a real import edge ever appears, + // this step has to move below `pnpm type-check` like the vitest-setup one. + const workspaceImports = parsedProject().fileNames.flatMap((f) => + workspaceImportSpecifiers(f, fs.readFileSync(f, 'utf8')), + ); + + expect( + [...new Set(workspaceImports)], + 'apps/console/tsconfig.node.json’s program now imports a workspace package (found via the ' + + 'AST, not a comment or an alias string), so it needs built declaration files. Move the ' + + 'ci.yml step below `pnpm type-check`, or drop the import.', + ).toEqual([]); + }); + + it('does not displace the console’s own type-check', () => { + // This gate is a second RUNNER for one program, not a replacement for the + // console's job. If the console ever stopped running the project, a change + // to `apps/console/tsconfig.node.json`'s option set would move this gate's + // verdict with nothing else watching. + const consolePkg = JSON.parse( + fs.readFileSync(path.join(repoRoot, 'apps/console/package.json'), 'utf8'), + ) as { scripts?: Record }; + + expect( + consolePkg.scripts?.['type-check'], + '@object-ui/console must still run tsconfig.node.json itself — this root script is an ' + + 'additional, cheaper runner for that program, not its owner', + ).toContain('tsconfig.node.json'); + }); +});