diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9bb2d31ddf..782e93e9f6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -770,6 +770,46 @@ jobs: pnpm type-check fi + # ── The repo-root programs' packages, built on the fast speed (objectui#12272) ── + # `Type-check repo-root vitest setup files` below resolves the workspace + # packages the root `vitest.setup.*` files import through their BUILT + # declarations. The full battery and the whole-workspace run build every + # one of them as the `^build` of the packages that depend on them; the + # affected run builds only the dependencies of what it selects, and that + # may hold none of them -- a change outside every package selects ZERO + # packages, so nothing built and that step failed with four TS2882 and a + # TS2307 (PR #12262; and a change to `packages/plugin-ai` alone selects + # one package and still leaves three of them unbuilt -- both measured on + # objectui#12272, readings of that day, not live counts). So this + # speed builds them here, whatever it selected. Which packages is read + # on every run out of that program's tsconfig `paths` by the selector, + # never copied. `turbo run build` replays each from the restored cache + # when its hash did not move; it is not `turbo run type-check` with + # their names added, because that task waits on `^build` -- the + # DEPENDENCIES' builds -- and builds no named package's own (measured: + # that spelling leaves `plugin-dashboard` and `plugin-grid` unbuilt). + # No run summary is asked for here, so `Report what the turbo cache + # replayed` still reads `Run type-check`'s; turbo's own `Cached:` line + # goes out as a notice instead, so the replay stays readable to anyone + # who cannot open this log. `push` and `schedule` skip this step: the + # full battery is unchanged. + - name: Build the packages the repo-root programs import + if: >- + steps.relevant.outputs.should_run == 'true' + && steps.tc-scope.outputs.scope == 'affected' + run: | + set -o pipefail + packages=$(node scripts/select-type-check-scope.mjs --root-builds) + filters=() + for p in $packages; do filters+=("--filter=$p"); done + if [ "${#filters[@]}" -eq 0 ]; then + echo 'The repo-root programs import no workspace package: nothing to build.' + exit 0 + fi + pnpm exec turbo run build "${filters[@]}" | tee "$RUNNER_TEMP/root-builds.out" + cached=$(grep -E '^ *Cached:' "$RUNNER_TEMP/root-builds.out" | tail -n 1 | tr -s ' ' || true) + echo "::notice title=Repo-root program builds::$(echo $packages) -- ${cached:-turbo printed no Cached line}" + # The four repo-root `vitest.setup.*` files were in ZERO tsc programs # (objectui#3515) — the same shape as `scripts/` before objectui#3494, one # directory over. They live outside every workspace package, so @@ -785,8 +825,10 @@ jobs: # @object-ui/components, /fields, /plugin-dashboard and /plugin-grid for # their registrations. `tsconfig.vitest-setup.json` resolves those through # each package's own `exports.types`, i.e. its built declarations — which - # exist only after the `^build` that `pnpm type-check` depends on. - # `scripts/__tests__/vitest-setup-type-check.test.ts` pins that ordering. + # exist only after the `^build` that `pnpm type-check` depends on, and on + # the affected speed after the step above (objectui#12272). + # `scripts/__tests__/vitest-setup-type-check.test.ts` pins that ordering, + # `scripts/__tests__/select-type-check-scope.test.ts` the step above. # # A direct step, deliberately: this is not a turbo task, so no cache key # decides its verdict. `pnpm type-check` is cached by turbo and its `inputs` diff --git a/content/docs/guide/ci-cd-pipeline.md b/content/docs/guide/ci-cd-pipeline.md index 164480136c..3e4e718dcc 100644 --- a/content/docs/guide/ci-cd-pipeline.md +++ b/content/docs/guide/ci-cd-pipeline.md @@ -250,7 +250,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:metadata-write-doors`, 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 check:i18n-designer-parity`, then `pnpm type-check:scripts`, then `pnpm type-check:vitest-config`, then `scripts/select-type-check-scope.mjs`, then `pnpm type-check`, then `pnpm type-check:vitest-setup`, then — bookkeeping only, after the verdict — `scripts/report-turbo-cache.mjs`. 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, both cards closed by pointing the prose at `resolveRecordSourceObjectName` (`b041b9c0c`, `2ce2612df`). Nothing here could see either one, and `check-spec-symbol-derivation` was credited with the class twice — in the second card's 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 `72498f257`, the commit that landed this gate, 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:metadata-write-doors` runs next, on the same parser again: it fails when an in-repo DOOR that can PUT an object-metadata document does not apply the object-metadata write guard. [#7714](https://github.com/objectstack-ai/objectui/issues/7714) ruled one client behaviour — a half-filled relationship is held client-side and the PUT body never carries one without a non-empty `reference` — and its PR implemented that ruling by ENUMERATING the writers it knew of, which were two. The identical defect was then reproduced on a THIRD writer neither guard covered, in that card's own required dogfood (recorded in `9662aca56`, the commit that landed this gate), and [#8676](https://github.com/objectstack-ai/objectui/issues/8676) swept and found nine more. The half that outlives the count is that the sweep the question is naturally asked in cannot see its own subject: `git grep 'client\.save('` returns ZERO over the file that third writer lives in, because the call is `client.save(type, …)` and the generic argument sits between the name and the paren — and a hand-rolled `fetch` PUT to `/api/v1/meta/object/:name` is not that spelling at all. So this gate enumerates DOORS, never writers: the writer set is OPEN and nobody has to announce a new member, while the transport set is CLOSED and this repository owns it, so guarding the doors covers every writer past and future without a list anywhere. It derives every call carrying a `method: 'PUT'` literal whose URL RESOLVES to a `/meta` path — resolved through templates, fields and helper return values rather than read, because the repo's central door spells its URL three hops from the call and says nothing at the call site — plus every call to the SDK's `meta.saveItem`, which lives in a package this repo does not own and so cannot be guarded from the inside. A door whose type is a string literal other than `object` is exempt; a door whose type is a runtime value is judged CAPABLE, which is the fail-closed direction. It answers COVERAGE only — whether a guarded door's body is correct is the guard's own pins — and it refuses to report OK unless it found at least one door of each kind and at least one guarded door, so a renamed transport turns it red rather than green. Same placement rationale as the gates around it: it parses the sources with `typescript`, so it needs the install and nothing built. `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), and a third in the same package as the first, all three recorded in `6aeba673c`, the commit that landed this gate). [#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 three locale gates sit in the middle because all of them 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 check:i18n-designer-parity` fails when the metadata-admin designer's own module-local string tables come apart — an `en` row with no `zh` row, or a shared row whose two values carry different `{placeholders}` ([#8834](https://github.com/objectstack-ai/objectui/issues/8834)). The two gates before it are blind to that file by construction — the first classifies the module `module-local table` by declaration and skips it, the second read only the ten locale packs and this table is not one of them — so the same card also gave `pnpm check:i18n-drift` that table as a SECOND population, which is the half that catches a changed `en` value whose `zh` row did not follow. `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. `scripts/select-type-check-scope.mjs` decides how much of the workspace `pnpm type-check` covers on a pull request or a queue build — the affected packages, or every package when a root file the package graph cannot see changed — as the two-speeds paragraph above describes; it fails open to the whole workspace, and `push` and `schedule` never ask it. `scripts/report-turbo-cache.mjs` checks nothing: `pnpm type-check` runs with `TURBO_RUN_SUMMARY=true`, and the script reads that run summary and prints, per task, what the restored `.turbo/cache` replayed and what re-ran — each re-run task with the tasks it depends on, since a task's hash folds theirs in — and emits the counts as one `::notice` annotation, which the check-runs API serves to readers who cannot open the job log ([#12222](https://github.com/objectstack-ai/objectui/issues/12222)). It runs after the last checking step, bounded and `continue-on-error`, like the cache save after it. On `push` and `schedule` only, the same script runs again with `--prune` and deletes every `.turbo/cache` entry the run did not use, and only those two events save the cache. On this job's first measured pull-request run the restore step's bare `turbo-Linux-` key matched another job's cache, saved minutes earlier on the same pull request (about 2 GB, most of it unused), and `actions/cache` tries every key on the run's own ref before it looks at `main` — so `main`'s type-check cache was never reached. The restore step now keeps only the job's own `turbo-Linux-type-check-` prefix, pull requests and queue builds save nothing, and the cache is written by the forced full battery alone, holding exactly the current verdicts and build outputs that every pull request and queue build restores ([#12222](https://github.com/objectstack-ai/objectui/issues/12222)). | Every run; on a PR the steps short-circuit when only ignored paths changed. `pnpm type-check` covers the affected packages (or, on a root change, the workspace) on pull requests and merge-queue builds, replaying unchanged ones from the cache; every package, nothing replayed, on `push` and the hourly `schedule` | +| `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:metadata-write-doors`, 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 check:i18n-designer-parity`, then `pnpm type-check:scripts`, then `pnpm type-check:vitest-config`, then `scripts/select-type-check-scope.mjs`, then `pnpm type-check`, then — on the affected speed only — `turbo run build` of the packages the root setup program imports, then `pnpm type-check:vitest-setup`, then — bookkeeping only, after the verdict — `scripts/report-turbo-cache.mjs`. 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, both cards closed by pointing the prose at `resolveRecordSourceObjectName` (`b041b9c0c`, `2ce2612df`). Nothing here could see either one, and `check-spec-symbol-derivation` was credited with the class twice — in the second card's 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 `72498f257`, the commit that landed this gate, 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:metadata-write-doors` runs next, on the same parser again: it fails when an in-repo DOOR that can PUT an object-metadata document does not apply the object-metadata write guard. [#7714](https://github.com/objectstack-ai/objectui/issues/7714) ruled one client behaviour — a half-filled relationship is held client-side and the PUT body never carries one without a non-empty `reference` — and its PR implemented that ruling by ENUMERATING the writers it knew of, which were two. The identical defect was then reproduced on a THIRD writer neither guard covered, in that card's own required dogfood (recorded in `9662aca56`, the commit that landed this gate), and [#8676](https://github.com/objectstack-ai/objectui/issues/8676) swept and found nine more. The half that outlives the count is that the sweep the question is naturally asked in cannot see its own subject: `git grep 'client\.save('` returns ZERO over the file that third writer lives in, because the call is `client.save(type, …)` and the generic argument sits between the name and the paren — and a hand-rolled `fetch` PUT to `/api/v1/meta/object/:name` is not that spelling at all. So this gate enumerates DOORS, never writers: the writer set is OPEN and nobody has to announce a new member, while the transport set is CLOSED and this repository owns it, so guarding the doors covers every writer past and future without a list anywhere. It derives every call carrying a `method: 'PUT'` literal whose URL RESOLVES to a `/meta` path — resolved through templates, fields and helper return values rather than read, because the repo's central door spells its URL three hops from the call and says nothing at the call site — plus every call to the SDK's `meta.saveItem`, which lives in a package this repo does not own and so cannot be guarded from the inside. A door whose type is a string literal other than `object` is exempt; a door whose type is a runtime value is judged CAPABLE, which is the fail-closed direction. It answers COVERAGE only — whether a guarded door's body is correct is the guard's own pins — and it refuses to report OK unless it found at least one door of each kind and at least one guarded door, so a renamed transport turns it red rather than green. Same placement rationale as the gates around it: it parses the sources with `typescript`, so it needs the install and nothing built. `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), and a third in the same package as the first, all three recorded in `6aeba673c`, the commit that landed this gate). [#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 three locale gates sit in the middle because all of them 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 check:i18n-designer-parity` fails when the metadata-admin designer's own module-local string tables come apart — an `en` row with no `zh` row, or a shared row whose two values carry different `{placeholders}` ([#8834](https://github.com/objectstack-ai/objectui/issues/8834)). The two gates before it are blind to that file by construction — the first classifies the module `module-local table` by declaration and skips it, the second read only the ten locale packs and this table is not one of them — so the same card also gave `pnpm check:i18n-drift` that table as a SECOND population, which is the half that catches a changed `en` value whose `zh` row did not follow. `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. On the affected speed that run may build none of them — a change outside every package selects no package at all — so a step between the two builds them with `turbo run build`, naming the packages `scripts/select-type-check-scope.mjs --root-builds` reads out of `tsconfig.vitest-setup.json`'s `paths` on every run, and replaying each from the restored cache when its hash did not move ([#12272](https://github.com/objectstack-ai/objectui/issues/12272)). `scripts/select-type-check-scope.mjs` decides how much of the workspace `pnpm type-check` covers on a pull request or a queue build — the affected packages, or every package when a root file the package graph cannot see changed — as the two-speeds paragraph above describes; it fails open to the whole workspace, and `push` and `schedule` never ask it. `scripts/report-turbo-cache.mjs` checks nothing: `pnpm type-check` runs with `TURBO_RUN_SUMMARY=true`, and the script reads that run summary and prints, per task, what the restored `.turbo/cache` replayed and what re-ran — each re-run task with the tasks it depends on, since a task's hash folds theirs in — and emits the counts as one `::notice` annotation, which the check-runs API serves to readers who cannot open the job log ([#12222](https://github.com/objectstack-ai/objectui/issues/12222)). It runs after the last checking step, bounded and `continue-on-error`, like the cache save after it. On `push` and `schedule` only, the same script runs again with `--prune` and deletes every `.turbo/cache` entry the run did not use, and only those two events save the cache. On this job's first measured pull-request run the restore step's bare `turbo-Linux-` key matched another job's cache, saved minutes earlier on the same pull request (about 2 GB, most of it unused), and `actions/cache` tries every key on the run's own ref before it looks at `main` — so `main`'s type-check cache was never reached. The restore step now keeps only the job's own `turbo-Linux-type-check-` prefix, pull requests and queue builds save nothing, and the cache is written by the forced full battery alone, holding exactly the current verdicts and build outputs that every pull request and queue build restores ([#12222](https://github.com/objectstack-ai/objectui/issues/12222)). | Every run; on a PR the steps short-circuit when only ignored paths changed. `pnpm type-check` covers the affected packages (or, on a root change, the workspace) on pull requests and merge-queue builds, replaying unchanged ones from the cache; every package, nothing replayed, on `push` and the hourly `schedule` | | `test` | Test (shard N/12) | When a pull request changed nothing outside the exclusion list, the decision step runs `scripts/markdown-test-inputs.mjs` before anything else, and the job runs in full when the answer is yes ([#8861](https://github.com/objectstack-ai/objectui/issues/8861)). The exclusions drop every markdown path, and a markdown document can be a TEST'S INPUT: [#8857](https://github.com/objectstack-ai/objectui/issues/8857) changed one package README, this job reported success in ten seconds having run nothing, and the merge-queue build then failed the same shard in 907 seconds and dequeued it. That script carries the derived class — which documents a test reads, and which test reads each — and audits itself against the tree. It is this job's stage only, because this is the job that runs those tests. [#9096](https://github.com/objectstack-ai/objectui/issues/9096) then added `scripts` to that scan, because the gate tests under `scripts/__tests__` read this repository's documentation as data and run in these same shards — measured by running all 46 of them under an fs trace, **every** tracked markdown file in the tree is opened by at least one of them. ⇒ for this job the markdown half of the exclusion list is now inert: **any markdown-only pull request runs the shards**. Priced over the 513 first-parent commits available then: 36 reached this stage, 21 already ran, and the widening moves 14 of the remaining 15 from skip to run. The rest of the exclusion list still pays — a non-markdown change under `content/**`, `docs/**` or `apps/site/**` skips as before. On a pull request or a queue build the job then runs `scripts/select-affected-tests.mjs` against the event's base sha and, when it answers `mode=affected`, `vitest run` over the filters it wrote, sharded the same way; a selector that cannot decide means the full suite, never a skip ([#12214](https://github.com/objectstack-ai/objectui/issues/12214)). Otherwise — and always on `push` and `schedule` — `pnpm test --shard=N/12` across a 12-runner matrix with `fail-fast: false`, so every shard reports its own failures. No coverage instrumentation — v8 adds 40–100% overhead. **4 → 8 shards, and the built-artifact pin step moved out of this job entirely, in [#9499](https://github.com/objectstack-ai/objectui/issues/9499).** At four shards this job had grown into its own `timeout-minutes: 20`: measured over the 14 most recent `merge_group` runs and the 14 most recent `pull_request` runs, the longest *succeeding* shard-1 job was 1199 s against the 1200 s ceiling — a one-second margin — and two runs crossed it and were **cancelled**, one of them dequeuing a pull request whose tests had passed, because the merge queue cannot tell `cancelled` from `failure`. The ceiling is **not** raised, no test is skipped and the exclusion list is not widened; the work is spread over twice the runners, and the one step that was never sharded at all is now the `test-dist-pins` job below. **8 → 12 shards in [#12208](https://github.com/objectstack-ai/objectui/issues/12208)** (its first pull request, folded into [#12214](https://github.com/objectstack-ai/objectui/issues/12214)'s): at eight the job was back against the same ceiling — 14.4 min median, 17.4 min p95 and 20.3 min max over the 1,728 shard jobs of one week, measured on that card — and the same three refusals hold: the ceiling is not raised, no test is skipped, the relevance gate is not widened. Both speeds use the same twelve legs. No shard is a required context any more — `test-aggregate` reports for all of them. **Which files a leg runs is decided by measured per-file duration** ([#12208](https://github.com/objectstack-ai/objectui/issues/12208)'s second pull request): vitest's own `--shard` hashed each path and cut equal *file counts*, so which leg drew the heavy files was luck. The root vitest config's `sequence.sequencer` (the `test-shard-sequencer` module beside this job's scripts, not a command this job runs) bin-packs the files heaviest first from the committed `scripts/test-shard-timings.json`; a file the dataset does not name is weighed at its median. On the full speed the job also passes `scripts/test-file-timings.mjs` as a third reporter (beside `default` and `github-actions`, which a CLI `--reporter` would otherwise displace), which records each file's whole time on its worker, and `push` and `schedule` runs upload it as the `test-file-timings-N` artifact that the weekly refresh reads. | Every event: the affected set on pull requests and merge-queue builds, the whole suite on `push` and the hourly `schedule`; steps short-circuit on a PR that changed only ignored paths | | `test-dist-pins` | Test (dist pins) | The same decision step as `test` above, `scripts/markdown-test-inputs.mjs` stage included — deliberately verbatim, because this lane used to run whenever that job's gate said run, and a narrower gate here would silently stop running the built-artifact pins on the markdown-only pull requests [#9096](https://github.com/objectstack-ai/objectui/issues/9096) brought into scope. Then `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`. **Why it is a job and not a step on shard 1** ([#9499](https://github.com/objectstack-ai/objectui/issues/9499)): it was never sharded, so it made shard 1 structurally the longest leg of a matrix bounded by a 20-minute ceiling, and in **both** observed ceiling kills the job died in this step with the test step already green — the tests passed and the check reported `cancelled`. Measured across 28 full CI runs it costs 31–58 s (median ≈53 s); a separate job pays for one more runner and takes the asymmetry out of the matrix without touching a budget. | Every event (the `push` guard went with the shard matrix's in [#12214](https://github.com/objectstack-ai/objectui/issues/12214)); steps short-circuit on a PR that changed only ignored paths | | `test-aggregate` | Test | `scripts/check-test-shard-results.mjs` — the **single required test context** ([#9499](https://github.com/objectstack-ai/objectui/issues/9499), director ruling letter C). It `needs` the shard matrix and `test-dist-pins`, and runs `if: always()` so a red, skipped or cancelled shard still produces a verdict here instead of skipping this job — a skipped required context counts as *success* in branch protection, which on the one context that speaks for the whole lane is the failure mode itself. ⛔ It does **not** gate on `needs.test.result`: a matrix job's `needs..result` is a single rollup over every leg, and a rollup reads `success` when some legs succeeded and the rest were **skipped**, so an aggregator built on that expression is a check reporting success having run nothing — [#8857](https://github.com/objectstack-ai/objectui/issues/8857) and [#3523](https://github.com/objectstack-ai/objectui/issues/3523) in one line of YAML. The gate reads this run's own job list from the Actions API instead (`actions: read`, nothing written) and asserts each shard **by name and by its own conclusion**; `skipped` is not a pass. It fails closed — an unreadable answer is exit 2 and a red context — and refuses any verdict over a job list that does not contain this very job, which is the control that the reading reached the right run at all. The dist-pin job is asserted from `needs` directly, since a non-matrix job's result is its own. **Why one name:** naming the legs made the shard *count* a member of a repository-settings surface no workflow edit can reach — widening the matrix renamed four live required contexts at once. Last, `scripts/shard-headroom.mjs` prints the longest shard's wall against the `test` job's `timeout-minutes` and the width this run's measured total implies at a 10-minute target per shard ([#12208](https://github.com/objectstack-ai/objectui/issues/12208)). It is **report-only**: `if: always()` and `continue-on-error: true`, it exits 0 whatever it reads, and an unreadable job list prints NOT MEASURED rather than a number. `scripts/__tests__/shard-headroom.test.ts` pins its `--shards` and `--cap-minutes` to the matrix and the job it reads. | Every event — on `push` and `schedule` it is the full battery's test verdict | diff --git a/scripts/__tests__/select-type-check-scope.test.ts b/scripts/__tests__/select-type-check-scope.test.ts index bb209dc11e..ca9f1107a1 100644 --- a/scripts/__tests__/select-type-check-scope.test.ts +++ b/scripts/__tests__/select-type-check-scope.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from 'vitest'; import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; import { parse as parseYaml } from 'yaml'; import { @@ -9,6 +11,7 @@ import { globToRegExp, main, rootChanges, + rootProgramPackages, rootRule, } from '../select-type-check-scope.mjs'; @@ -123,6 +126,62 @@ describe('select-type-check-scope — the root rule (objectui#12222)', () => { }); }); +/** + * objectui#12272 — the packages the repo-root `vitest.setup.*` files import, + * named outright: the non-vacuity anchor for the derivation below, which reads + * them out of `tsconfig.vitest-setup.json` on every run. + */ +const ROOT_PROGRAM_PACKAGES = [ + '@object-ui/components', + '@object-ui/fields', + '@object-ui/i18n', + '@object-ui/plugin-dashboard', + '@object-ui/plugin-grid', +]; + +describe('select-type-check-scope — the packages the repo-root programs need built (objectui#12272)', () => { + it('derives them from the root program’s tsconfig `paths`, a subpath wildcard included', () => { + expect(rootProgramPackages(repoRoot)).toEqual(ROOT_PROGRAM_PACKAGES); + }); + + it('matches what the root setup program really imports, so a new import is a new build', () => { + // The derivation reads `paths`; the program's files are what needs them. + // The root package.json declares no `@object-ui/*` dependency, so an import + // a `paths` entry does not map cannot resolve at all -- hold the two + // together. The files are the program's own root set, as `tsc -p` parses it. + const configPath = path.join(repoRoot, 'tsconfig.vitest-setup.json'); + const read = ts.readConfigFile(configPath, ts.sys.readFile); + const { fileNames } = ts.parseJsonConfigFileContent(read.config, ts.sys, repoRoot, undefined, configPath); + expect(fileNames.length, 'tsconfig.vitest-setup.json resolves no file — the parse is broken').toBeGreaterThanOrEqual(4); + const imported = new Set(); + for (const file of fileNames) { + const text = fs.readFileSync(file, 'utf8'); + for (const m of text.matchAll(/(?:from|import)\s+'(@object-ui\/[\w-]+)(?:\/[^']*)?'/g)) imported.add(m[1]); + } + expect(imported.size, 'no root setup file imports a workspace package — the scan is broken').toBeGreaterThan(0); + expect([...imported].sort()).toEqual(rootProgramPackages(repoRoot)); + }); + + it('throws on a `paths` target that sits in no workspace package, rather than dropping it', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tc-scope-')); + try { + fs.writeFileSync(path.join(dir, 'package.json'), '{"name":"root"}'); + fs.mkdirSync(path.join(dir, 'packages/a'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'packages/a/package.json'), '{"name":"@x/a"}'); + const write = (paths: Record) => + fs.writeFileSync(path.join(dir, 'tsconfig.root.json'), JSON.stringify({ compilerOptions: { paths } })); + + write({ '@x/a/*': ['./packages/a/dist/*'] }); + expect(rootProgramPackages(dir, ['tsconfig.root.json'])).toEqual(['@x/a']); + + write({ '@x/b': ['./nowhere/b'] }); + expect(() => rootProgramPackages(dir, ['tsconfig.root.json'])).toThrow(/sits in no workspace package/); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); + describe('select-type-check-scope — the verdict', () => { const run = (changed: string[], affected: () => string[] = () => ['@object-ui/plugin-kanban']) => { const lines: string[] = []; @@ -153,6 +212,31 @@ describe('select-type-check-scope — the verdict', () => { expect(none.lines.join('\n')).toContain('nothing to type-check'); }); + it('names the root programs’ packages on an EMPTY affected set, the PR #12262 case (objectui#12272)', () => { + const none = run(['.github/workflows/spec-main-shape-gate.yml', 'scripts/__tests__/spec-main-shape-gate.test.ts'], () => []); + expect(none.code).toBe(0); + expect(none.lines.at(-1)).toBe('scope=affected'); + const tail = none.lines.slice(none.lines.findIndex((l) => l.startsWith('The repo-root programs'))); + expect(tail.slice(1, -1)).toEqual(ROOT_PROGRAM_PACKAGES.map((p) => ` ${p}`)); + }); + + it('names them all the same on an affected set that lacks them (objectui#12272)', () => { + // `...[base]` over `packages/plugin-ai` alone selects that one package, and + // its `^build` closure holds none of fields, plugin-dashboard, plugin-grid. + const lacking = run(['packages/plugin-ai/src/index.ts'], () => ['@object-ui/plugin-ai']); + expect(lacking.lines.at(-1)).toBe('scope=affected'); + expect(lacking.lines).toContain(' @object-ui/plugin-ai'); + const tail = lacking.lines.slice(lacking.lines.findIndex((l) => l.startsWith('The repo-root programs'))); + expect(tail.slice(1, -1)).toEqual(ROOT_PROGRAM_PACKAGES.map((p) => ` ${p}`)); + }); + + it('prints exactly those packages, one per line, for `--root-builds` (objectui#12272)', () => { + const lines: string[] = []; + expect(main(['--root-builds'], { root: repoRoot, log: (l: string) => lines.push(l) })).toBe(0); + expect(lines).toEqual(ROOT_PROGRAM_PACKAGES); + expect(main(['--root-builds', '--base', 'abc123'], { log: () => {} })).toBe(2); + }); + it('reaches NO verdict when turbo cannot answer, which ci.yml reads as the whole workspace', () => { expect(() => run(['packages/plugin-kanban/src/x.ts'], () => { @@ -165,7 +249,7 @@ describe('select-type-check-scope — the verdict', () => { describe('ci.yml — the two speeds are wired the way the selector assumes', () => { const workflow = parseYaml(fs.readFileSync(path.join(repoRoot, '.github/workflows/ci.yml'), 'utf8')) as { - jobs: Record }> }>; + jobs: Record }> }>; }; const steps = workflow.jobs['type-check'].steps; const decide = steps.find((s) => s.id === 'tc-scope'); @@ -190,4 +274,32 @@ describe('ci.yml — the two speeds are wired the way the selector assumes', () expect(runStep?.run).toContain('if [ "$SCOPE" = \'affected\' ]; then'); expect(runStep?.run).toContain('pnpm type-check "--filter=...[$BASE]"'); }); + + /** + * objectui#12272: on the affected speed nothing else builds the packages the + * root setup program resolves, so this step must exist, run on that speed + * only, and sit between the turbo run and the root program. + */ + it('builds the root programs’ packages on the affected speed, after the turbo run and before the root program', () => { + const names = steps.map((s) => s.name); + const at = names.indexOf('Build the packages the repo-root programs import'); + expect(at, 'the type-check job no longer builds the root programs’ packages on the affected speed').toBeGreaterThan(-1); + expect(at).toBeGreaterThan(names.indexOf('Run type-check')); + expect(at).toBeLessThan(names.indexOf('Type-check repo-root vitest setup files')); + + const build = steps[at]; + expect(build.if).toContain("steps.relevant.outputs.should_run == 'true'"); + expect(build.if, 'the full battery is unchanged: this step runs on the affected speed only').toContain( + "steps.tc-scope.outputs.scope == 'affected'", + ); + expect(build.run).toContain('node scripts/select-type-check-scope.mjs --root-builds'); + // `turbo run build`, never `type-check` with the names added: that task + // waits on `^build` and builds no named package's own declarations. + expect(build.run).toContain('pnpm exec turbo run build "${filters[@]}"'); + expect(build.run, 'a failed build piped into `tee` must still fail the step').toContain('set -o pipefail'); + // `Report what the turbo cache replayed` reads the NEWEST run summary; this + // run must not write one, or it would replace `Run type-check`'s. + expect(build.env?.TURBO_RUN_SUMMARY).toBeUndefined(); + expect(build.run).not.toContain('TURBO_RUN_SUMMARY'); + }); }); diff --git a/scripts/select-type-check-scope.mjs b/scripts/select-type-check-scope.mjs index 937eec79bb..bfeb7daf36 100644 --- a/scripts/select-type-check-scope.mjs +++ b/scripts/select-type-check-scope.mjs @@ -8,6 +8,7 @@ * * node scripts/select-type-check-scope.mjs --base * node scripts/select-type-check-scope.mjs --base --head + * node scripts/select-type-check-scope.mjs --root-builds * * The LAST line on stdout is the verdict, one of: * @@ -60,6 +61,26 @@ * `inputs` reads every file in the repository, and then EVERY change is a * root change -- the rule degrades to the whole workspace, never to a skip. * + * ## The repo-root programs need their packages BUILT, whatever the set + * + * The job also compiles programs that sit in no package, after the turbo + * run: `pnpm type-check:vitest-setup` (`tsconfig.vitest-setup.json`) resolves + * the workspace packages the root `vitest.setup.*` files import through + * each package's built declarations. The full battery builds those as the + * `^build` of the packages that depend on them; the affected set need not + * contain them, or anything at all -- a change outside every package + * selects zero packages, so nothing builds and that program fails to + * resolve them (objectui#12272, measured on PR #12262: four TS2882 and one + * TS2307). So on the affected speed ci.yml builds them in a step of its + * own, and `--root-builds` prints which: one package name per line, read + * on every run out of the `paths` of each tsconfig in + * ROOT_PROGRAMS_AFTER_BUILD, never copied (the root package.json declares + * no `@object-ui/*` dependency, so a workspace import from the repo root + * resolves through those `paths` or not at all). The other root programs + * the job compiles import no workspace package: `tsconfig.scripts.json` + * and the root Vitest config each pin that premise in their own test, and + * the e2e program's `@object-ui/*` strings are stub-map keys, not imports. + * * ## What it does not see -- stated, not hidden * * An edge that is in neither the package graph nor a turbo input: a file a @@ -73,6 +94,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { execFileSync } from 'node:child_process'; +import { createRequire } from 'node:module'; import { fileURLToPath } from 'node:url'; import { isEntrypoint } from './invoked-as.mjs'; @@ -90,6 +112,13 @@ export const FIXED_ROOT_FILES = Object.freeze([ 'patches/**', ]); +/** + * The repo-root tsconfigs ci.yml compiles AFTER the turbo run, because their + * programs resolve workspace packages through those packages' built + * declarations. See "The repo-root programs need their packages BUILT". + */ +export const ROOT_PROGRAMS_AFTER_BUILD = Object.freeze(['tsconfig.vitest-setup.json']); + /** The turbo tasks whose `$TURBO_ROOT$` inputs decide a type-check verdict. */ export const HASHED_TASKS = Object.freeze(['type-check', 'build']); @@ -182,6 +211,40 @@ export function rootChanges(changed, rule) { return hits; } +/** + * The workspace packages a repo-root program resolves through its `paths`: + * each `paths` target, read with TypeScript's own config parser, walked up to + * the nearest directory below the repository root that holds a package.json + * (`./packages/i18n/dist/*` is `@object-ui/i18n`). A target that reaches no + * package throws: a mapping this cannot place is not one it may drop. + * + * @param {string} root + * @param {readonly string[]} [tsconfigs] + * @returns {string[]} package names, sorted, each once + */ +export function rootProgramPackages(root, tsconfigs = ROOT_PROGRAMS_AFTER_BUILD) { + const ts = createRequire(import.meta.url)('typescript'); + const names = new Set(); + for (const tsconfig of tsconfigs) { + const file = path.join(root, tsconfig); + const read = ts.readConfigFile(file, ts.sys.readFile); + if (read.error) throw new Error(`${tsconfig}: ${ts.flattenDiagnosticMessageText(read.error.messageText, ' ')}`); + const { options } = ts.parseJsonConfigFileContent(read.config, ts.sys, path.dirname(file), undefined, file); + const base = options.pathsBasePath ?? path.dirname(file); + for (const targets of Object.values(options.paths ?? {})) { + for (const target of targets) { + let dir = path.resolve(base, target.split('*')[0]); + while (dir.startsWith(root + path.sep) && !fs.existsSync(path.join(dir, 'package.json'))) dir = path.dirname(dir); + if (!dir.startsWith(root + path.sep)) { + throw new Error(`${tsconfig}: the paths target ${JSON.stringify(target)} sits in no workspace package`); + } + names.add(JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')).name); + } + } + } + return [...names].sort(); +} + /** `git diff --name-only `, or a thrown error the caller turns into "no verdict". */ export function changedFiles(root, base, head) { execFileSync('git', ['rev-parse', '--verify', '--quiet', `${base}^{commit}`], { cwd: root, stdio: 'ignore' }); @@ -206,13 +269,15 @@ export function affectedPackages(root, base) { } function parseArgs(argv) { - const out = { base: null, head: 'HEAD' }; + const out = { base: null, head: 'HEAD', rootBuilds: false }; for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; - if (arg === '--base' && argv[i + 1]) out.base = argv[++i]; + if (arg === '--root-builds') out.rootBuilds = true; + else if (arg === '--base' && argv[i + 1]) out.base = argv[++i]; else if (arg === '--head' && argv[i + 1]) out.head = argv[++i]; else return { error: `unknown or incomplete argument: ${arg}` }; } + if (out.rootBuilds) return out.base ? { error: '--root-builds takes no --base' } : out; if (!out.base) return { error: '--base is required' }; return out; } @@ -228,6 +293,10 @@ export function main(argv, deps = {}) { console.error(`select-type-check-scope: ${args.error}`); return 2; } + if (args.rootBuilds) { + for (const name of rootProgramPackages(root)) log(name); + return 0; + } const turbo = JSON.parse(fs.readFileSync(path.join(root, 'turbo.json'), 'utf8')); const rule = rootRule(turbo); const changed = deps.changed ?? changedFiles(root, args.base, args.head); @@ -248,6 +317,11 @@ export function main(argv, deps = {}) { log(`${packages.length} package(s) affected -- changed, or depending on one that changed:`); for (const p of packages) log(` ${p}`); } + // Whatever that set is -- empty included -- the repo-root programs still + // need theirs built; ci.yml builds them from `--root-builds` (objectui#12272). + const rootBuilds = rootProgramPackages(root); + log(`The repo-root programs resolve ${rootBuilds.length} package(s) through their builds, which ci.yml builds on this speed whatever the set above:`); + for (const p of rootBuilds) log(` ${p}`); log('scope=affected'); return 0; }