diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c3a9d006d..e2327fca2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -310,6 +310,30 @@ jobs: if: steps.relevant.outputs.should_run == 'true' run: pnpm check:doc-example-readers + # `BaseSchema` is `.passthrough()`, so an `on*` key that is not declared by + # its arm is NOT refused — it stops being judged and the value is KEPT, then + # reaches the renderer that reads it. objectui#7664 measured that on the built + # dist: `{ type: 'kanban', columns: [], onCardClick: { action: 'toast' } }` + # went from REFUSED to ACCEPTED with the object surviving into the parsed + # output, while every gate here stayed green. The objectui#6124 ledger could + # not see it — its population is two hand-written arrays, and that change + # re-keyed the arm by SUBSTITUTION, holding the length constant — so a count + # ratchet would have been green too (objectui#7753 rejected that option on + # exactly this measurement). + # + # This step derives BOTH populations instead: the arms from every + # `type: z.literal(…)` in `packages/types/src/zod`, and the read sites from + # every real `ComponentRegistry.register(…)` call, following the document + # into the components each registration hands it to. It has to live 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. Parses sources + # with `typescript` through the same scanner as the steps above, so it needs + # the install and nothing built. + - name: Verify every handler key a registered renderer reads is declared by its arm + if: steps.relevant.outputs.should_run == 'true' + run: pnpm check:handler-key-reads + # A build tsconfig that excludes tooling by FILE NAME (`*.test.ts`) stops # the files that happen to be named that way and nothing else. The first # shared helper added to a `__tests__/` directory is then a program input, diff --git a/content/docs/guide/ci-cd-pipeline.md b/content/docs/guide/ci-cd-pipeline.md index 381e30eb8..64ca0a908 100644 --- a/content/docs/guide/ci-cd-pipeline.md +++ b/content/docs/guide/ci-cd-pipeline.md @@ -208,7 +208,7 @@ it green — which is how two of `type-check`'s gates came to be missing from th | Job key | Appears as | What it runs | When | |---|---|---|---| | `changeset-check` | Changeset Fixed Group Check | `scripts/check-changeset-fixed.mjs` — every workspace package must be in the changeset `fixed` group or explicitly ignored. It checks group *membership*; it does **not** check whether the PR added a changeset. | Every run | -| `type-check` | Type Check | `scripts/check-type-check-coverage.mjs`, then `pnpm check:phantom-deps`, then `pnpm check:self-import`, then `pnpm check:unreferenced-sources`, then `pnpm check:doc-example-readers`, 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: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`, 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 | | `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 4d544cf30..445873d49 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ "check:readme-exports": "node scripts/check-readme-exports.mjs", "check:unreferenced-sources": "node scripts/check-unreferenced-sources.mjs", "check:doc-example-readers": "node scripts/check-doc-example-shared-reader.mjs", + "check:handler-key-reads": "node scripts/check-handler-key-read-sites.mjs", "cli": "node packages/cli/dist/cli.js", "objectui": "node packages/cli/dist/cli.js", "create-plugin": "node packages/create-plugin/dist/index.js", diff --git a/scripts/__tests__/check-handler-key-read-sites.test.ts b/scripts/__tests__/check-handler-key-read-sites.test.ts new file mode 100644 index 000000000..b67363099 --- /dev/null +++ b/scripts/__tests__/check-handler-key-read-sites.test.ts @@ -0,0 +1,501 @@ +import { afterAll, describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + KNOWN_UNDECLARED_READS, + analyze, + collectArms, + isHandlerKey, + registrationsIn, + parseSource, +} from '../check-handler-key-read-sites.mjs'; + +/** + * objectui#7753 — every `on*` key a registered renderer READS off the authored + * document must be a declared member of the arm for the type it is registered + * under. + * + * `BaseSchema` is `.passthrough()`, so a key that is not declared is not refused: + * it stops being judged and the value is KEPT. objectui#7664 measured that on the + * built dist — `{ type: 'kanban', columns: [], onCardClick: { action: 'toast' } }` + * went from REFUSED to ACCEPTED with the object surviving into the parsed output + * — while every gate stayed green, because the #6124 ledger's population is a + * literal and the change re-keyed the arm by SUBSTITUTION. + * + * What this file pins, in the order the gate can go wrong: + * + * 1. **The lit control, and a control ON that control.** Deleting a + * still-read key from an arm must go RED and name it; a deletion of + * comparable size from the same arm that is HARMLESS must stay GREEN. + * Without the second leg, "the plant reddens it" only proves the gate + * reacts to edits. + * 2. **The historical shape, rebuilt as a fixture.** The registration hands + * over a NAME, that name is an HOC, the document reaches the reader through + * a render-prop parameter and an object spread. Every hop in that chain is + * load-bearing: a walk that stops at any of them is GREEN on objectui#7664's + * own deletion, which is the one reading that would make this gate + * worthless. + * 3. **Each narrowing, named after the false positive it removed.** The first, + * coarser cut of this gate produced 36 findings on `main`; every one of the + * rules below is why a class of them is gone. + * 4. **A green is never "the walk found nothing."** Every fixture that passes + * asserts its own counters are non-zero, and so does the repository run. + * 5. **This repository is green**, with the ledger's rows all still live. + * 6. **The gate is wired** where the sibling parse-based gates run, and the + * page that inventories them names it. + */ +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + +const fixtures: string[] = []; +afterAll(() => { + for (const dir of fixtures) fs.rmSync(dir, { recursive: true, force: true }); +}); + +/** + * A throwaway tree in the shape the gate walks: `packages//src/`, + * with the zod mirrors at `packages/types/src/zod`. Written to disk rather than + * fed as strings because the population walk — which directories are read, which + * files are skipped as tooling — is half of what can go wrong. + */ +function tree(label: string, files: Record): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), `handler-reads-${label}-`)); + fixtures.push(root); + for (const [rel, contents] of Object.entries(files)) { + const full = path.join(root, rel); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, contents); + } + return root; +} + +/** `BaseSchema` is the base every arm extends; without it no arm resolves. */ +const BASE = ` +import { z } from 'zod'; +export const BaseSchema = z.object({ + id: z.string().optional(), + className: z.string().optional(), +}); +`; + +/** One arm, written the way the mirrors write one. */ +function arm(type: string, schemaName: string, members: string[]): string { + return ` +import { z } from 'zod'; +import { BaseSchema } from './base.zod'; +import { handlerKeyRefusal } from './tombstone.zod'; +export const ${schemaName} = BaseSchema.extend({ + type: z.literal('${type}'), +${members.map((m) => ` ${m},`).join('\n')} +}); +`; +} + +const RUNTIME_SLOT = (key: string) => `${key}: handlerKeyRefusal('${key}', 'runtime-slot', '${key} handler')`; +const RETIRED = (key: string) => `${key}: handlerKeyRefusal('${key}', 'retired', '${key} handler')`; + +/** Keys of the findings a run produced, in a form a test can read. */ +const keysOf = (root: string) => analyze(root).findings.map((finding) => finding.key).sort(); + +describe('check-handler-key-read-sites — the lit control, and the control on it', () => { + const board = (members: string[]) => ({ + 'packages/types/src/zod/base.zod.ts': BASE, + 'packages/types/src/zod/complex.zod.ts': arm('kanban', 'KanbanSchema', members), + 'packages/plugin-kanban/src/index.tsx': ` +import { ComponentRegistry } from '@object-ui/core'; +export const KanbanRenderer = ({ schema }: { schema: any }) => ( + +); +ComponentRegistry.register('kanban', KanbanRenderer, { namespace: 'view' }); +`, + }); + + const DECLARED = [RUNTIME_SLOT('onCardMove'), RUNTIME_SLOT('onCardClick'), "coverImageField: z.string().optional()"]; + + it('is green while both read keys are declared, and says so with real counts', () => { + const result = analyze(tree('lit-green', board(DECLARED))); + expect(result.findings).toEqual([]); + // A green that walked nothing is the failure this whole gate family exists + // to prevent, so the green above is only meaningful beside these. + expect(result.counters.arms).toBeGreaterThan(0); + expect(result.counters.armed).toBe(1); + expect(result.counters.reads).toBe(2); + expect(result.counters.judged).toBe(2); + }); + + it('goes RED and names the key when a still-read key is deleted from the arm', () => { + // objectui#7664's edit, verbatim in shape: the key leaves the arm, the + // renderer keeps forwarding it. + const root = tree('lit-red', board(DECLARED.filter((m) => !m.includes('onCardClick')))); + const result = analyze(root); + expect(result.findings.map((f) => f.key)).toEqual(['kanban::KanbanSchema.onCardClick']); + expect(result.findings[0].kind).toBe('undeclared'); + expect(result.findings[0].file).toBe('packages/plugin-kanban/src/index.tsx'); + }); + + it('stays GREEN on a deletion of the same size from the same arm that is harmless', () => { + // The control ON the control. Identical operation — one member removed from + // the same object literal in the same file — but the member is not read by + // any renderer, so nothing about the document's judgement changed. If this + // reddened, the red above would only mean "a file was edited". + const root = tree('lit-nearmiss', board(DECLARED.filter((m) => !m.includes('coverImageField')))); + const result = analyze(root); + expect(result.findings).toEqual([]); + expect(result.counters.judged).toBe(2); + }); + + it('goes RED when a still-read key is declared RETIRED rather than deleted', () => { + // The other direction of the same contract: a tombstone says nothing reads + // this key, and a renderer reading it contradicts that in the tree. + const root = tree('lit-retired', board([RUNTIME_SLOT('onCardMove'), RETIRED('onCardClick')])); + const result = analyze(root); + expect(result.findings.map((f) => `${f.kind} ${f.key}`)).toEqual([ + 'retired-but-read kanban::KanbanSchema.onCardClick', + ]); + }); +}); + +describe('check-handler-key-read-sites — the historical chain, hop by hop', () => { + /** + * objectui#7664's real shape, which is the reason this gate walks at all: + * + * register('kanban', ObjectKanbanRenderer) — a NAME, not a body + * ObjectKanbanRenderer = block(Inner) — an HOC + * Inner renders {(bound) => } + * ObjectKanban renders + * KanbanRenderer reads schema.onCardClick + */ + const chain = (members: string[]) => ({ + 'packages/types/src/zod/base.zod.ts': BASE, + 'packages/types/src/zod/complex.zod.ts': arm('kanban', 'KanbanSchema', members), + 'packages/plugin-kanban/src/index.tsx': ` +import { ComponentRegistry } from '@object-ui/core'; +import { ObjectKanban } from './ObjectKanban'; +export const KanbanRenderer = ({ schema }: { schema: any }) => ( + +); +export const ObjectKanbanRenderer = elementDataSourceBlock(({ schema, ...props }: any) => ( + + {(bound: any) => } + +)); +ComponentRegistry.register('kanban', ObjectKanbanRenderer, { namespace: 'view' }); +`, + 'packages/plugin-kanban/src/ObjectKanban.tsx': ` +import { KanbanRenderer } from './index'; +export const ObjectKanban = ({ schema }: { schema: any }) => { + const effectiveSchema = { ...schema, columns: [] }; + return ; +}; +`, + }); + + it('reaches the reader four hops away and is green when the arm declares the keys', () => { + const result = analyze(tree('chain-green', chain([RUNTIME_SLOT('onCardMove'), RUNTIME_SLOT('onCardClick')]))); + expect(result.findings).toEqual([]); + // Named, not counted: a walk that stopped at the registration identifier + // would also report zero findings. + expect(result.census.map((c) => `${c.type}.${c.key}`).sort()).toEqual(['kanban.onCardClick', 'kanban.onCardMove']); + }); + + it('goes RED on the real deletion, four hops from the registration', () => { + const root = tree('chain-red', chain([RUNTIME_SLOT('onCardMove')])); + expect(keysOf(root)).toEqual(['kanban::KanbanSchema.onCardClick']); + }); +}); + +describe('check-handler-key-read-sites — the narrowings, each named after what it removed', () => { + it('reads registrations off the AST, so prose naming the call registers nothing', () => { + // 13 of the coarse cut's 36 findings came from `packages/types/src/complex.ts`, + // which NAMES `ComponentRegistry.register('chatbot', ...)` in doc comments + // eleven times and registers nothing — the file's own interfaces then read as + // handler reads on three chatbot arms. + const root = tree('prose', { + 'packages/types/src/zod/base.zod.ts': BASE, + 'packages/types/src/zod/complex.zod.ts': arm('chatbot', 'ChatbotSchema', [RUNTIME_SLOT('onSend')]), + 'packages/types/src/complex.ts': ` +/** + * The registration is \`ComponentRegistry.register('chatbot', ChatbotRenderer)\`, + * which is where \`schema.onCardClick\` would be forwarded if it were. + */ +export interface ChatbotSchema { onCardClick?: () => void } +`, + }); + const result = analyze(root); + expect(result.counters.registrations).toBe(0); + expect(result.findings).toEqual([]); + }); + + it('scopes `props.onX` to the component\'s own parameters, not every nested arrow', () => { + // `'menubar'.onClick`, from the coarse cut: `items.map((child) => child.onClick?.())` + // is a MENU ITEM's handler, not the board's document. + const root = tree('nested-param', { + 'packages/types/src/zod/base.zod.ts': BASE, + 'packages/types/src/zod/complex.zod.ts': arm('menubar', 'MenubarSchema', ["menus: z.array(z.any()).optional()"]), + 'packages/components/src/menubar.tsx': ` +import { ComponentRegistry } from '@object-ui/core'; +ComponentRegistry.register('menubar', ({ schema, ...props }: any) => ( + + {schema.menus?.map((child: any) => child.onClick?.()} />)} + +), { namespace: 'ui' }); +`, + }); + expect(analyze(root).findings).toEqual([]); + }); + + it('does not follow a child handed a document the parent BUILT', () => { + // `'object-view'.onViewChange`, from the second cut: `ObjectView` composes + // `{ type: 'view-switcher', …, storageKey: \`view-pref-\${schema.objectName}\` }` + // and hands it to ``. It mentions `schema`, so a + // mention test called it the parent's document and reported `ViewSwitcher`'s + // read against `ObjectViewSchema` — an arm that is not even the one read. + const root = tree('new-document', { + 'packages/types/src/zod/base.zod.ts': BASE, + 'packages/types/src/zod/objectql.zod.ts': arm('object-view', 'ObjectViewSchema', ["objectName: z.string().optional()"]), + 'packages/plugin-view/src/index.tsx': ` +import { ComponentRegistry } from '@object-ui/core'; +import { ObjectView } from './ObjectView'; +ComponentRegistry.register('object-view', ObjectView, { namespace: 'view' }); +`, + 'packages/plugin-view/src/ObjectView.tsx': ` +import { ViewSwitcher } from './ViewSwitcher'; +export const ObjectView = ({ schema }: { schema: any }) => { + const viewSwitcherSchema = { type: 'view-switcher', storageKey: schema.objectName }; + return ; +}; +`, + 'packages/plugin-view/src/ViewSwitcher.tsx': ` +export const ViewSwitcher = ({ schema }: { schema: any }) =>
{schema.onViewChange}
; +`, + }); + expect(analyze(root).findings).toEqual([]); + }); + + it('does not follow a child handed a DIFFERENT document', () => { + // `'dashboard'.onRowClick`, from the second cut: a dashboard lays out widgets + // and each gets its own `schema`, so the widget's `schema.onRowClick` is a + // read of the WIDGET's document. + const root = tree('other-document', { + 'packages/types/src/zod/base.zod.ts': BASE, + 'packages/types/src/zod/complex.zod.ts': arm('dashboard', 'DashboardSchema', ["components: z.array(z.any()).optional()"]), + 'packages/plugin-dashboard/src/index.tsx': ` +import { ComponentRegistry } from '@object-ui/core'; +import { DashboardRenderer } from './DashboardRenderer'; +ComponentRegistry.register('dashboard', DashboardRenderer, { namespace: 'view' }); +`, + 'packages/plugin-dashboard/src/DashboardRenderer.tsx': ` +import { Widget } from './Widget'; +export const DashboardRenderer = ({ schema }: { schema: any }) => ( +
{schema.components.map((widget: any) => )}
+); +`, + 'packages/plugin-dashboard/src/Widget.tsx': ` +export const Widget = ({ schema }: { schema: any }) => ; +`, + }); + expect(analyze(root).findings).toEqual([]); + }); + + it('says nothing about a type that has no arm', () => { + // `'kanban-ui'`, `'kanban-enhanced'`, and the whole app-shell surface. An arm + // that does not exist cannot have lost a member. + const root = tree('no-arm', { + 'packages/types/src/zod/base.zod.ts': BASE, + 'packages/app-shell/src/notifications.tsx': ` +import { ComponentRegistry } from '@object-ui/core'; +ComponentRegistry.register('notifications', ({ schema }: any) => , {}); +`, + }); + const result = analyze(root); + expect(result.counters.registrations).toBe(1); + expect(result.counters.armed).toBe(0); + expect(result.findings).toEqual([]); + }); + + it('resolves a spread shape, so a member reached through `pick().shape` counts as declared', () => { + // Four of the first cut's findings were `chatbot-enhanced` / `chatbot-floating` + // slots that ARE declared — through `...ChatbotSharedMirrorShape`, which is + // `ChatbotSchema.pick({ … }).shape`. + const root = tree('spread', { + 'packages/types/src/zod/base.zod.ts': BASE, + 'packages/types/src/zod/complex.zod.ts': ` +import { z } from 'zod'; +import { BaseSchema } from './base.zod'; +import { handlerKeyRefusal } from './tombstone.zod'; +export const ChatbotSchema = BaseSchema.extend({ + type: z.literal('chatbot'), + ${RUNTIME_SLOT('onSend')}, + ${RUNTIME_SLOT('onError')}, +}); +const SharedShape = ChatbotSchema.pick({ onSend: true, onError: true }).shape; +export const ChatbotEnhancedSchema = BaseSchema.extend({ + type: z.literal('chatbot-enhanced'), + ...SharedShape, +}); +`, + 'packages/plugin-chatbot/src/renderer.tsx': ` +import { ComponentRegistry } from '@object-ui/core'; +ComponentRegistry.register('chatbot-enhanced', ({ schema }: any) => ( + +), { namespace: 'plugin' }); +`, + }); + const result = analyze(root); + expect(result.findings).toEqual([]); + expect(result.counters.judged).toBe(2); + }); + + it('refuses to judge a read on an arm whose shape spreads something it cannot follow', () => { + // The one direction a gate must never take is a red nobody can act on. An + // unresolved spread means the member list is not fully known, so "undeclared" + // is not a claim this reader is entitled to make. + const root = tree('unresolved', { + 'packages/types/src/zod/base.zod.ts': BASE, + 'packages/types/src/zod/objectql.zod.ts': ` +import { z } from 'zod'; +import { BaseSchema } from './base.zod'; +import { SpecFields } from '@objectstack/spec'; +export const ObjectGridSchema = BaseSchema.extend({ + type: z.literal('object-grid'), + ...SpecFields, +}); +`, + 'packages/plugin-grid/src/index.tsx': ` +import { ComponentRegistry } from '@object-ui/core'; +ComponentRegistry.register('object-grid', ({ schema }: any) => , {}); +`, + }); + const result = analyze(root); + expect(result.findings).toEqual([]); + expect(result.counters.unjudgeable).toBe(1); + expect(result.census[0].unjudgeable).toEqual(['SpecFields']); + }); + + it('resolves a bare alias base, so `BaseSchema = BaseSchemaCore` does not blank every arm', () => { + // This one is a scar. `export const BaseSchema = BaseSchemaCore;` is a plain + // alias; missing it made all 106 arms carry an unresolved base, which marked + // every read unjudgeable and turned the repository run into a green that had + // judged 23 of 62 read sites. + const root = tree('alias-base', { + 'packages/types/src/zod/base.zod.ts': ` +import { z } from 'zod'; +const BaseSchemaCore = z.object({ id: z.string().optional() }); +export const BaseSchema = BaseSchemaCore; +`, + 'packages/types/src/zod/form.zod.ts': arm('button', 'ButtonSchema', [RUNTIME_SLOT('onClick')]), + 'packages/components/src/button.tsx': ` +import { ComponentRegistry } from '@object-ui/core'; +ComponentRegistry.register('button', ({ schema }: any) => , {}); +`, + }); + const result = analyze(root); + expect(result.counters.unjudgeable).toBe(0); + expect(result.counters.judged).toBe(1); + expect(result.findings).toEqual([]); + }); + + it('judges only `on*` names', () => { + expect(isHandlerKey('onCardClick')).toBe(true); + expect(isHandlerKey('onclick')).toBe(false); + expect(isHandlerKey('on')).toBe(false); + expect(isHandlerKey('once')).toBe(false); + expect(isHandlerKey('columns')).toBe(false); + }); + + it('reads a registration\'s type off a string literal, and skips a computed one', () => { + const source = parseSource( + `ComponentRegistry.register('kanban', A, {});\nComponentRegistry.register(type, B, {});\nOther.register('x', C);`, + 'probe.tsx', + ); + expect(registrationsIn(source).map((r) => r.type)).toEqual(['kanban']); + }); +}); + +describe('check-handler-key-read-sites — this repository', () => { + const result = analyze(repoRoot); + + /** + * The size guard. Every assertion above runs on a throwaway tree, so a + * refactor that emptied this repository's walk would satisfy all of them while + * reporting a pass over nothing. + */ + it('walks a real population on both halves, and judges something in it', () => { + expect(result.counters.arms).toBeGreaterThan(50); + expect(result.counters.files).toBeGreaterThan(200); + expect(result.counters.registrations).toBeGreaterThan(100); + expect(result.counters.armed).toBeGreaterThan(10); + expect( + result.counters.judged, + 'no registered renderer in this repository reads a handler key off a document whose arm ' + + 'this gate could resolve, so its green says nothing', + ).toBeGreaterThan(20); + }); + + it('is green, with the objectui#7664 read sites among the ones it judged', () => { + expect( + result.findings.map((f) => f.key), + 'a registered renderer reads a handler key its arm does not declare', + ).toEqual([]); + + // Named rather than counted: these three are the reason this gate exists. + // They sit four hops from `ComponentRegistry.register('kanban', …)`, so a + // walk that stopped following the document would leave the green above + // intact while losing exactly the instance the card was filed for. + const judged = result.census.map((c) => `${c.type}.${c.key}`); + expect(judged).toContain('kanban.onCardClick'); + expect(judged).toContain('kanban.onCardMove'); + expect(judged).toContain('kanban.onQuickAdd'); + for (const key of ['onCardClick', 'onCardMove', 'onQuickAdd']) { + const row = result.census.find((c) => c.type === 'kanban' && c.key === key); + expect(row?.declared, `'kanban'.${key} must be a declared member`).toBe(true); + expect(row?.disposition, `'kanban'.${key} must carry the RUNTIME SLOT disposition`).toBe('runtime-slot'); + } + }); + + /** + * The ledger is an EXEMPTION list, never the population, and it only shrinks. + * Both directions are pinned: a row whose defect is gone reads as a live waiver + * for nothing, and a row with no card is indistinguishable from switching the + * gate off for that read site. + */ + it('keeps every exemption honest — no stale row, and a card on each', () => { + expect( + result.stale, + 'a KNOWN_UNDECLARED_READS row names a read site this gate no longer finds — the defect it ' + + 'waives is fixed, so the row is a live waiver for nothing. Delete it.', + ).toEqual([]); + + for (const [key, card] of KNOWN_UNDECLARED_READS) { + expect(card, `KNOWN_UNDECLARED_READS[${key}] must name the card that owns the fix`).toMatch(/objectui#\d+/); + } + }); + + it('is wired where the sibling parse-based gates run', () => { + const manifest = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')); + expect(manifest.scripts['check:handler-key-reads']).toBe('node scripts/check-handler-key-read-sites.mjs'); + + const ci = fs.readFileSync(path.join(repoRoot, '.github/workflows/ci.yml'), 'utf8'); + expect(ci, 'the gate must run in CI, next to the other source-parsing gates').toContain( + 'run: pnpm check:handler-key-reads', + ); + + // The page that inventories the gates is pinned by command (objectui#3653), so + // this would fail there too — asserted here as well because a reader looking + // for this gate looks at this file first. + const page = fs.readFileSync(path.join(repoRoot, 'content/docs/guide/ci-cd-pipeline.md'), 'utf8'); + expect(page).toContain('check:handler-key-reads'); + }); + + it('derives its arms from the mirrors, including the one the card is about', () => { + const { arms } = collectArms(repoRoot); + const kanban = arms.get('kanban'); + expect(kanban?.schema).toBe('KanbanSchema'); + expect(kanban?.file).toBe('complex.zod.ts'); + expect(kanban?.unresolved, 'the `kanban` arm must resolve completely, or its reads go unjudged').toEqual([]); + expect(kanban?.members.get('onCardClick')).toBe('runtime-slot'); + expect(kanban?.members.get('onColumnAdd')).toBe('retired'); + }); +}); diff --git a/scripts/check-handler-key-read-sites.mjs b/scripts/check-handler-key-read-sites.mjs new file mode 100644 index 000000000..8bd5e448c --- /dev/null +++ b/scripts/check-handler-key-read-sites.mjs @@ -0,0 +1,1095 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Every `on*` handler key a REGISTERED renderer reads off the authored document + * must be a DECLARED member of the zod arm for the type it is registered under. + * + * Run: node scripts/check-handler-key-read-sites.mjs (also `pnpm check:handler-key-reads`) + * node scripts/check-handler-key-read-sites.mjs --list (every read site this gate judged) + * Exit: 0 = every reachable `schema.onX` / `props.onX` read is a declared arm member, + * 1 = at least one is not, or the census collapsed. + * + * ## The gap this closes (objectui#7753, the class card of objectui#7664 / PR #7743) + * + * `BaseSchema` is `.passthrough()`. A key that LEAVES an arm is therefore not + * refused — it stops being judged and the value is KEPT. Measured on the built + * dist at the head that carried it: + * + * { type: 'kanban', columns: [], onCardClick: { action: 'toast' } } + * before the deletion : REFUSED + * after the deletion : ACCEPTED, with {"action":"toast"} surviving into + * the parsed output — and `KanbanRenderer` still + * forwarding `schema.onCardClick` into the board. + * + * Every gate stayed green. The #6124 ledger + * (`packages/types/src/__tests__/handler-keys-json-refusal-6124.test.ts`) is two + * hand-written arrays of `[file, schema, key, mirror]` tuples plus a length + * assertion, so its POPULATION IS A LITERAL: the change re-keyed the arm by + * SUBSTITUTION (an `onQuickAdd` tuple replacing the `onCardClick` one), which + * held `RUNTIME_SLOT` at 44 and `ALL_SITES` at 66. A count ratchet could not + * have seen it, and the type-level `KeepsFunction` / `RetiredIsNever` blocks are + * written per key, so a deleted pair simply stops being asserted. + * + * That file's own docblock warns about this hazard — but its counter-probe pins + * the passthrough BEHAVIOUR on a fixture. Nothing pinned the ledger's MEMBERSHIP + * to the renderers. This gate is that missing derivation, generalised off + * `packages/plugin-kanban/src/__tests__/kanban-handler-slots-7664.test.tsx`, + * whose suite 3 does exactly this for one arm and whose population is derived. + * + * ## Why it lives in `scripts/` and not in `@object-ui/types` + * + * The read-site half is spread across `@object-ui/plugin-*` and + * `packages/components`. `@object-ui/types` may not import them — + * `pnpm check:phantom-deps` rejects the dependency and it would close a cycle — + * so the only place that can read both halves at once is a repo-level script. + * + * ## The census, stated as a rule + * + * Both populations are DERIVED. Nothing here is a list a re-key can hold + * constant. + * + * ARMS every `type: z.literal('')` object literal reachable from an + * exported `const` in `packages/types/src/zod/*.zod.ts`, with the + * members it declares (unioned along `.extend()` bases resolved in + * the same directory) and, for members built by + * `handlerKeyRefusal(key, disposition, …)`, that disposition. + * READS for every real `ComponentRegistry.register('', C, …)` CALL in + * `packages//src`: the `schema.onX` / `.onX` property + * accesses inside `C`'s body, plus those inside every component `C` + * RENDERS that is declared in the same package (JSX element names, + * resolved through same-file declarations and static relative + * imports, transitively, with a visited set). + * FINDING a read key at a type that HAS an arm, where the arm does not + * declare that key (`undeclared`), or declares it with the RETIRED + * disposition while a renderer still reads it (`retired-but-read`). + * + * The transitive hop is not a flourish: it is the shape of the very instance + * this gate exists for. `'kanban'` registers `ObjectKanbanRenderer`, which + * renders `ObjectKanban`, which renders `KanbanRenderer` — and `KanbanRenderer` + * is where `schema.onCardClick` is read. A gate reading only the registered + * component's own body is green on objectui#7664's deletion, which is the one + * reading that would make it worthless. + * + * ## What it deliberately does NOT answer + * + * Each of these is a boundary, not an oversight: + * + * 1. **Keys that reach a renderer ONLY through a `{...props}` spread onto a + * Radix root or a DOM listener slot.** The seven `onOpenChange` overlays, + * `accordion` / `collapsible` / `toggle-group` / `tabs`, `button`'s + * `toFormControlDomProps` whitelist and `card`'s `` + * reach the element without ever naming the key. There is no read site to + * derive from, so this gate says nothing about them; the #6124 ledger and + * `check-action-forward-parity.mjs` are what cover that channel. + * 2. **The 22 RETIRED tombstones.** A tombstone exists precisely because + * nothing reads the key — it has no read site BY CONSTRUCTION, so it + * cannot be derived from one. What this gate does add there is the other + * direction: if a renderer ever starts reading a tombstoned key, that is a + * `retired-but-read` finding. + * 3. **Types with no zod arm.** `'kanban-ui'`, `'kanban-enhanced'`, + * `'notifications'`, `'approvals'` and the rest of the app-shell surface + * are registered without a mirror. An arm that does not exist cannot have + * lost a member, and inventing an obligation there would be a different + * card. + * 4. **Whether a declared key's TYPE is right.** That is the #6124 ledger's + * `KeepsFunction` / `RetiredIsNever` blocks, and it needs a type checker. + * 5. **Lazy chunks.** `React.lazy(() => import('./KanbanImpl'))` is a dynamic + * import, not a statically resolvable component reference. Following it + * would mean guessing at a module graph the type checker owns. + * + * ## Rollout + * + * `KNOWN_UNDECLARED_READS` below is an EXEMPTION list, never the population, and + * it only shrinks. Every row is a live defect with a card, not a waiver: a row + * naming a read site this gate can no longer find FAILS the gate, the same + * `KNOWN_HAND_SPELLINGS` rule PR #7789 landed. Its rows are the gate's own first + * findings — arms whose renderer reads a key the mirror never declared, which is + * the same passthrough exposure objectui#7753 names, standing on `main` before + * this gate could see it. They are ledgered rather than fixed here because every + * fix is a `packages//src` change and this card is Clause-② `no`. + */ + +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { dirname, join, relative, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import ts from 'typescript'; + +import { TOOLING_FILE, listSourceFiles } from './check-phantom-dependencies.mjs'; +import { isEntrypoint } from './invoked-as.mjs'; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); + +/** + * Read sites known to name a key their arm does not declare, each with the card + * that owns the fix. + * + * An entry is an admission that a document authoring that key is ACCEPTED and + * KEPT by the passthrough today — not a decision that it is fine — so it carries + * a card and it comes out when that card lands. The key is `type::Schema.key`. + */ +export const KNOWN_UNDECLARED_READS = new Map([ + // objectui#7804 — this gate's own first run. Every row is a key a registered + // renderer reads off the authored document that its arm never declared, so an + // authored `onX: { action: 'toast' }` parses GREEN today and is then handed to + // a call site expecting a function. `AlertDialogSchema.onAction` was exactly + // this shape until objectui#7104 declared it. Each fix is a `packages/PKG/src` + // change and each disposition has to be MEASURED per key, so they are ledgered + // here rather than guessed at in the change that adds the instrument. + ['button::ButtonSchema.onSuccess', 'objectui#7804'], + ['icon::IconSchema.onSuccess', 'objectui#7804'], + ['data-table::DataTableSchema.onAddRecord', 'objectui#7804'], + ['data-table::DataTableSchema.onBatchSave', 'objectui#7804'], + ['data-table::DataTableSchema.onCellChange', 'objectui#7804'], + ['data-table::DataTableSchema.onColumnResize', 'objectui#7804'], + ['data-table::DataTableSchema.onRowActionDef', 'objectui#7804'], + ['data-table::DataTableSchema.onRowClick', 'objectui#7804'], + ['data-table::DataTableSchema.onRowSave', 'objectui#7804'], + ['tree-view::TreeViewSchema.onNodeClick', 'objectui#7804'], + ['detail::DetailSchema.onAddComment', 'objectui#7804'], + ['detail::DetailSchema.onNavigate', 'objectui#7804'], + ['object-form::ObjectFormSchema.onCancel', 'objectui#7804'], + ['object-form::ObjectFormSchema.onError', 'objectui#7804'], + ['object-form::ObjectFormSchema.onOpenChange', 'objectui#7804'], + ['object-form::ObjectFormSchema.onStepChange', 'objectui#7804'], + ['object-form::ObjectFormSchema.onSuccess', 'objectui#7804'], + ['form::FormSchema.onError', 'objectui#7804'], + ['form::FormSchema.onOpenChange', 'objectui#7804'], + ['form::FormSchema.onStepChange', 'objectui#7804'], + ['form::FormSchema.onSuccess', 'objectui#7804'], + ['object-grid::ObjectGridSchema.onNavigate', 'objectui#7804'], + ['grid::GridSchema.onNavigate', 'objectui#7804'], + ['object-kanban::ObjectKanbanSchema.onCardClick', 'objectui#7804'], + ['object-kanban::ObjectKanbanSchema.onCardMove', 'objectui#7804'], + ['object-kanban::ObjectKanbanSchema.onQuickAdd', 'objectui#7804'], + ['list-view::ListViewSchema.onAddRecord', 'objectui#7804'], + ['list-view::ListViewSchema.onBulkAction', 'objectui#7804'], + ['list-view::ListViewSchema.onDensityChange', 'objectui#7804'], + ['list-view::ListViewSchema.onNavigate', 'objectui#7804'], + ['list-view::ListViewSchema.onPageSizeChange', 'objectui#7804'], + ['list::ListSchema.onAddRecord', 'objectui#7804'], + ['list::ListSchema.onBulkAction', 'objectui#7804'], + ['list::ListSchema.onDensityChange', 'objectui#7804'], + ['list::ListSchema.onNavigate', 'objectui#7804'], + ['list::ListSchema.onPageSizeChange', 'objectui#7804'], + ['object-gallery::ObjectGallerySchema.onCardClick', 'objectui#7804'], + ['object-gallery::ObjectGallerySchema.onRowClick', 'objectui#7804'], + ['object-view::ObjectViewSchema.onNavigate', 'objectui#7804'], +]); + +/** + * A ledger row naming a read site this gate no longer finds is worse than no + * ledger: it reads as a live waiver for a defect that is gone, and the next + * person to fix a real one cannot tell the two apart. So every row must still + * correspond to something the census reports, and `analyze` returns the stale + * ones for the CLI and the pin to fail on. + */ +export function staleExemptions(rawFindings) { + const live = new Set(rawFindings.map((finding) => finding.key)); + return [...KNOWN_UNDECLARED_READS.keys()].filter((key) => !live.has(key)); +} + +export function parseSource(text, fileName) { + return ts.createSourceFile(fileName, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); +} + +/** `onX` — the handler-key spelling objectui#6124 ledgers and this gate judges. */ +export function isHandlerKey(name) { + return /^on[A-Z][A-Za-z0-9]*$/.test(String(name)); +} + +/** + * Every arm in the zod mirrors, keyed by the `type` literal that selects it. + * + * The population is every `type: z.literal('…')` object literal reachable from + * an exported `const` — so an arm minted tomorrow is in the census the moment it + * is written, and one deleted leaves it. Members are unioned along `.extend()` + * bases resolved within the same directory, because an arm may inherit a + * declaration it does not restate. + */ +export function collectArms(root) { + const zodDir = resolve(root, 'packages/types/src/zod'); + let files; + try { + files = readdirSync(zodDir).filter((name) => name.endsWith('.zod.ts')).sort(); + } catch { + return { arms: new Map(), schemas: new Map(), constInits: new Map() }; + } + + /** Schema-const name -> the shape facts its initializer states. */ + const schemas = new Map(); + /** Every top-level `const X = ` in the directory, for spread resolution. */ + const constInits = new Map(); + + // Two passes over the same parse: every `const` is indexed BEFORE any shape is + // read, so a member built by a nullary helper (`onClear: chatbotOnClearArm()`) + // can be followed one hop to the `handlerKeyRefusal()` inside it. + const parsed = []; + for (const file of files) { + const abs = join(zodDir, file); + const sourceFile = parseSource(readFileSync(abs, 'utf8'), abs); + parsed.push({ file, sourceFile }); + for (const statement of sourceFile.statements) { + if (!ts.isVariableStatement(statement)) continue; + for (const declaration of statement.declarationList.declarations) { + if (!ts.isIdentifier(declaration.name) || !declaration.initializer) continue; + constInits.set(declaration.name.text, { file, node: declaration.initializer }); + } + } + } + + const context = { schemas, constInits }; + + for (const { file, sourceFile } of parsed) { + for (const statement of sourceFile.statements) { + if (!ts.isVariableStatement(statement)) continue; + for (const declaration of statement.declarationList.declarations) { + if (!ts.isIdentifier(declaration.name) || !declaration.initializer) continue; + const record = emptyRecord(file); + readShape(declaration.initializer, record, context); + if (record.own.size || record.bases.length || record.spreads.length) { + schemas.set(declaration.name.text, record); + } + } + } + } + const arms = new Map(); + for (const [name, record] of schemas) { + if (!record.typeLiteral) continue; + const resolved = membersOf(name, context, new Set()); + arms.set(record.typeLiteral, { + schema: name, + file: record.file, + members: resolved.members, + unresolved: resolved.unresolved, + }); + } + return { arms, schemas, constInits }; +} + +function emptyRecord(file) { + return { file, own: new Map(), bases: [], spreads: [], typeLiteral: null }; +} + +/** + * The shape facts one initializer states: the members its own literal writes, + * the schemas it `.extend()`s, and the shapes it spreads. + * + * Read along the CALL SPINE only — `BaseSchema.extend({ … })` and + * `z.object({ … })`, walking down the receiver — never into arbitrary nested + * arguments. A `z.object({ … })` nested inside a member's own definition + * describes that member's sub-shape, and folding its keys into the arm would + * make the arm look like it declares names it does not, which is the direction + * that produces a silent GREEN. + */ +function readShape(expression, record, context) { + let node = expression; + const seen = new Set(); + + // `export const BaseSchema = BaseSchemaCore;` — a bare alias. Missing this + // left `BaseSchema` unresolvable, which marked all 106 arms incomplete and + // turned the whole gate into a green that judged 23 of 62 read sites. Only the + // ROOT initializer counts: the spine of `z.object({ … })` also ends on an + // identifier, and treating `z` as a base would do the same damage. + if (ts.isIdentifier(node)) { + record.bases.push(node.text); + return; + } + + while (node && !seen.has(node)) { + seen.add(node); + if (ts.isCallExpression(node)) { + const callee = node.expression; + if (ts.isPropertyAccessExpression(callee)) { + const method = callee.name.text; + if (method === 'extend' || method === 'object' || method === 'merge') { + const [argument] = node.arguments; + if (argument && ts.isObjectLiteralExpression(argument)) collectMembers(argument, record, context); + } + if ((method === 'extend' || method === 'merge') && ts.isIdentifier(callee.expression)) { + record.bases.push(callee.expression.text); + } + node = callee.expression; + continue; + } + node = callee; + continue; + } + if (ts.isPropertyAccessExpression(node)) { + node = node.expression; + continue; + } + if (ts.isObjectLiteralExpression(node)) { + collectMembers(node, record, context); + break; + } + break; + } +} + +function collectMembers(objectLiteral, record, context) { + for (const property of objectLiteral.properties) { + if (ts.isSpreadAssignment(property)) { + record.spreads.push(ts.isIdentifier(property.expression) ? property.expression.text : null); + continue; + } + if (!ts.isPropertyAssignment(property)) continue; + const name = ts.isIdentifier(property.name) || ts.isStringLiteral(property.name) ? property.name.text : null; + if (!name) continue; + + if (name === 'type') { + const literal = literalOfTypeMember(property.initializer); + if (literal) record.typeLiteral = literal; + } + record.own.set(name, dispositionOf(property.initializer, context)); + } +} + +/** `z.literal('kanban')` -> `'kanban'`. Anything else selects no single arm. */ +function literalOfTypeMember(initializer) { + if (!ts.isCallExpression(initializer)) return null; + const callee = initializer.expression; + if (!ts.isPropertyAccessExpression(callee) || callee.name.text !== 'literal') return null; + const [argument] = initializer.arguments; + return argument && ts.isStringLiteral(argument) ? argument.text : null; +} + +/** + * `handlerKeyRefusal('onCardClick', 'runtime-slot', …)` -> `'runtime-slot'`. + * + * Read off the CALL, not off the rendered `description` string, so a reworded + * message cannot silently change what this gate believes about a key. A member + * built by a nullary helper (`chatbotOnClearArm()`) is followed one hop into + * that helper, which is how the two chatbot siblings spell their slots. + */ +function dispositionOf(initializer, context, depth = 0) { + let found = null; + const walk = (node) => { + if (found) return; + if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) { + const callee = node.expression.text; + if (callee === 'handlerKeyRefusal') { + const [, disposition] = node.arguments; + if (disposition && ts.isStringLiteral(disposition)) found = disposition.text; + return; + } + if (callee === 'retirementTombstone') { + found = 'retired'; + return; + } + const helper = depth < 2 ? context?.constInits.get(callee) : undefined; + if (helper) { + const inner = dispositionOf(helper.node, context, depth + 1); + if (inner) { + found = inner; + return; + } + } + } + ts.forEachChild(node, walk); + }; + walk(initializer); + return found; +} + +/** + * The declared members of a named schema: its `.extend()` bases, then the shapes + * it spreads, then its own literal, in that precedence. + * + * `unresolved` names every spread this reader could not follow — a shape from + * another package, say. An arm carrying one has an INCOMPLETE member set, so the + * census refuses to call anything on it undeclared rather than reporting a + * finding it cannot stand behind. + */ +export function membersOf(name, context, seen = new Set()) { + if (seen.has(name)) return { members: new Map(), unresolved: [] }; + seen.add(name); + const record = context.schemas.get(name); + if (!record) return { members: new Map(), unresolved: [name] }; + return resolveRecord(record, context, seen); +} + +function resolveRecord(record, context, seen) { + const members = new Map(); + const unresolved = []; + for (const base of record.bases) { + const resolved = membersOf(base, context, seen); + for (const [member, disposition] of resolved.members) members.set(member, disposition); + unresolved.push(...resolved.unresolved); + } + for (const spread of record.spreads) { + const resolved = spread === null ? null : spreadMembers(spread, context, seen); + if (!resolved) { + unresolved.push(spread ?? ''); + continue; + } + for (const [member, disposition] of resolved.members) members.set(member, disposition); + unresolved.push(...resolved.unresolved); + } + for (const [member, disposition] of record.own) members.set(member, disposition); + return { members, unresolved }; +} + +/** + * `...ChatbotSharedMirrorShape` -> the members that shape carries. + * + * The three forms this directory writes are a plain object literal, + * `X.shape`, and `X.pick({ … }).shape` / `X.omit({ … }).shape`. Anything else is + * left unresolved on purpose: guessing at a shape would put members into an arm + * that are not there, and this gate's failure direction has to be a false RED it + * can be told about, never a silent GREEN. + */ +function spreadMembers(name, context, seen) { + const entry = context.constInits.get(name); + if (!entry) return null; + const node = entry.node; + + if (ts.isObjectLiteralExpression(node)) { + const record = emptyRecord(entry.file); + collectMembers(node, record, context); + return resolveRecord(record, context, seen); + } + + if (ts.isPropertyAccessExpression(node) && node.name.text === 'shape') { + const inner = node.expression; + if (ts.isIdentifier(inner)) return membersOf(inner.text, context, seen); + if (ts.isCallExpression(inner) && ts.isPropertyAccessExpression(inner.expression)) { + const method = inner.expression.name.text; + const target = inner.expression.expression; + if (!ts.isIdentifier(target)) return null; + if (method !== 'pick' && method !== 'omit') return null; + const [argument] = inner.arguments; + if (!argument || !ts.isObjectLiteralExpression(argument)) return null; + const selected = new Set(); + for (const property of argument.properties) { + if (!ts.isPropertyAssignment(property)) continue; + const key = ts.isIdentifier(property.name) || ts.isStringLiteral(property.name) ? property.name.text : null; + if (key) selected.add(key); + } + const base = membersOf(target.text, context, seen); + const kept = new Map( + [...base.members].filter(([member]) => (method === 'pick' ? selected.has(member) : !selected.has(member))), + ); + return { members: kept, unresolved: base.unresolved }; + } + } + return null; +} + +/** Every `packages//src` file that ships, by package. */ +export function populationFiles(root) { + const packagesDir = resolve(root, 'packages'); + const byPackage = new Map(); + let entries; + try { + entries = readdirSync(packagesDir, { withFileTypes: true }); + } catch { + return byPackage; + } + for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { + if (!entry.isDirectory() || entry.name.startsWith('.')) continue; + const src = join(packagesDir, entry.name, 'src'); + try { + if (!statSync(src).isDirectory()) continue; + } catch { + continue; + } + const files = []; + for (const file of listSourceFiles(src)) { + const rel = relative(root, file).split(sep).join('/'); + if (TOOLING_FILE.test(rel)) continue; + if (rel.endsWith('.d.ts')) continue; + files.push(file); + } + if (files.length) byPackage.set(entry.name, files); + } + return byPackage; +} + +/** + * Every real `ComponentRegistry.register('', C, …)` call in a file. + * + * Read off the AST rather than the text, because `packages/types/src/complex.ts` + * NAMES that call in prose eleven times and registers nothing — a text scan + * attributes every `on*` mentioned in that file's interfaces to three chatbot + * arms, which was 13 of the 36 findings the first, coarser cut produced. + */ +export function registrationsIn(sourceFile) { + const found = []; + const visit = (node) => { + if (ts.isCallExpression(node)) { + const callee = node.expression; + if ( + ts.isPropertyAccessExpression(callee) && + callee.name.text === 'register' && + ts.isIdentifier(callee.expression) && + callee.expression.text === 'ComponentRegistry' + ) { + const [typeArgument, componentArgument] = node.arguments; + if (typeArgument && ts.isStringLiteral(typeArgument) && componentArgument) { + found.push({ type: typeArgument.text, component: componentArgument }); + } + } + } + ts.forEachChild(node, visit); + }; + ts.forEachChild(sourceFile, visit); + return found; +} + +/** + * Top-level component-ish declarations in a file, by name. + * + * "Component-ish" is deliberately loose — a `const X = elementDataSourceBlock(…)` + * is a component here — because the question this map answers is only "does this + * JSX element name resolve to something in this file whose body I should keep + * reading". + */ +export function declarationsIn(sourceFile) { + const declarations = new Map(); + const visit = (node) => { + if (ts.isFunctionDeclaration(node) && node.name && node.body) declarations.set(node.name.text, node.body); + if (ts.isVariableStatement(node)) { + for (const declaration of node.declarationList.declarations) { + if (ts.isIdentifier(declaration.name) && declaration.initializer) { + declarations.set(declaration.name.text, declaration.initializer); + } + } + } + ts.forEachChild(node, visit); + }; + ts.forEachChild(sourceFile, visit); + return declarations; +} + +/** Imported binding name -> the relative specifier it came from. */ +export function relativeImportsIn(sourceFile) { + const bindings = new Map(); + for (const statement of sourceFile.statements) { + if (!ts.isImportDeclaration(statement)) continue; + if (!ts.isStringLiteral(statement.moduleSpecifier)) continue; + const specifier = statement.moduleSpecifier.text; + if (!specifier.startsWith('.')) continue; + const clause = statement.importClause; + if (!clause || clause.isTypeOnly) continue; + if (clause.name) bindings.set(clause.name.text, specifier); + if (clause.namedBindings && ts.isNamedImports(clause.namedBindings)) { + for (const element of clause.namedBindings.elements) { + if (!element.isTypeOnly) bindings.set(element.name.text, specifier); + } + } + } + return bindings; +} + +/** + * The `schema.onX` / `.onX` property accesses inside one node. + * + * `schema` is the authored document as every renderer in this repository spells + * it; the second half is the props parameter's own name, so a renderer written + * `(props) => props.onChange(…)` counts and an unrelated local object does not. + */ +export function handlerReadsIn(node) { + const objects = new Set(['schema', ...propsParameterNames(node)]); + const reads = new Map(); + const walk = (current) => { + if ( + ts.isPropertyAccessExpression(current) && + ts.isIdentifier(current.expression) && + objects.has(current.expression.text) && + isHandlerKey(current.name.text) + ) { + const line = current.getSourceFile().getLineAndCharacterOfPosition(current.getStart()).line + 1; + if (!reads.has(current.name.text)) reads.set(current.name.text, line); + } + ts.forEachChild(current, walk); + }; + walk(node); + return reads; +} + +/** + * The `props` half: the parameter names of the component's OWN outermost + * functions — a plain `(props)` parameter and the `...props` rest of a + * destructured one — and nothing nested inside them. + * + * Scoping this to the outermost functions is the second narrowing this gate + * needed. Taking every nested arrow's parameters instead made `menubar`'s + * `{items.map((child) => … child.onClick …)}` read as a document read of + * `'menubar'.onClick`, which is a menu ITEM's handler and not the board's — a + * named false positive of the coarser cut. + */ +function propsParameterNames(node) { + const names = new Set(); + for (const fn of outermostFunctions(node)) { + for (const parameter of fn.parameters) { + if (ts.isIdentifier(parameter.name)) { + names.add(parameter.name.text); + continue; + } + if (ts.isObjectBindingPattern(parameter.name)) { + for (const element of parameter.name.elements) { + if (element.dotDotDotToken && ts.isIdentifier(element.name)) names.add(element.name.text); + } + } + } + } + return names; +} + +/** Functions reachable from `node` without crossing another function boundary. */ +function outermostFunctions(node) { + const functions = []; + const walk = (current) => { + if (ts.isFunctionDeclaration(current) || ts.isFunctionExpression(current) || ts.isArrowFunction(current)) { + functions.push(current); + return; + } + ts.forEachChild(current, walk); + }; + if (ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node) || ts.isArrowFunction(node)) return [node]; + walk(node); + return functions; +} + +/** `./ObjectKanban` from `/src/index.tsx` -> that file, if it is in the walk. */ +function resolveRelative(fromFile, specifier, filesInPackage) { + const base = resolve(dirname(fromFile), specifier); + for (const candidate of [ + `${base}.tsx`, + `${base}.ts`, + `${base}.jsx`, + `${base}.js`, + join(base, 'index.tsx'), + join(base, 'index.ts'), + ]) { + if (filesInPackage.has(candidate)) return candidate; + } + // `./index.js` is how ESM-correct sources name a `.ts` sibling. + const rewritten = base.replace(/\.(m?js)$/, ''); + for (const candidate of [`${rewritten}.tsx`, `${rewritten}.ts`]) { + if (candidate !== base && filesInPackage.has(candidate)) return candidate; + } + return null; +} + +/** + * Every handler key reachable from one registration: the registered component's + * own reads, plus those of every component it renders that is declared in the + * same package, transitively. + * + * Bounded by a visited set of `file::name`, so a cyclic import graph — which + * `plugin-kanban` genuinely has, `index.tsx` <-> `ObjectKanban.tsx` — terminates. + */ +export function reachableReads(startFile, startNode, packageIndex) { + const reads = new Map(); + const visited = new Set(); + const queue = []; + + const enqueue = (file, name) => { + const resolved = resolveComponent(file, name, packageIndex, visited); + if (resolved) queue.push(resolved); + }; + + // `ComponentRegistry.register('kanban', ObjectKanbanRenderer, …)` hands over a + // NAME, not a body. A walk that starts at the identifier finds nothing at all — + // the first cut of this gate did exactly that, reported ten read sites, and was + // GREEN on objectui#7664's own deletion. + if (ts.isIdentifier(startNode)) enqueue(startFile, startNode.text); + else queue.push({ file: startFile, node: startNode }); + + while (queue.length) { + const { file, node } = queue.shift(); + if (!packageIndex.get(file)) continue; + + for (const [key, line] of handlerReadsIn(node)) { + if (!reads.has(key)) reads.set(key, { file, line }); + } + + for (const name of documentCarryingChildren(node)) enqueue(file, name); + } + return reads; +} + +/** + * A component NAME, in the file that uses it, resolved to the body to keep + * reading — through same-file declarations, static relative imports, one-level + * aliases (`const X = Y`) and the HOC spelling (`const X = wrap(Inner)`). + * + * `visited` is keyed by resolved file and name, so the cyclic import + * `plugin-kanban/src/index.tsx` <-> `ObjectKanban.tsx` terminates. + */ +function resolveComponent(file, name, packageIndex, visited, hops = 0) { + if (hops > 4) return null; + const parsed = packageIndex.get(file); + if (!parsed) return null; + + let targetFile = file; + let node = parsed.declarations.get(name); + if (node === undefined) { + const specifier = parsed.imports.get(name); + if (!specifier) return null; + const resolvedFile = resolveRelative(file, specifier, packageIndex.filesInPackage.get(file)); + if (!resolvedFile) return null; + const remote = packageIndex.get(resolvedFile); + if (!remote || !remote.declarations.has(name)) return null; + targetFile = resolvedFile; + node = remote.declarations.get(name); + } + + const key = `${targetFile}::${name}`; + if (visited.has(key)) return null; + visited.add(key); + + // `const KanbanBoard = KanbanRenderer` — an alias is not a body. + if (ts.isIdentifier(node)) return resolveComponent(targetFile, node.text, packageIndex, visited, hops + 1); + // `const ObjectKanbanRenderer = elementDataSourceBlock(Inner)` — a wrapper whose + // body is named rather than written inline. An inline argument is already part + // of this node and needs no hop. + if (ts.isCallExpression(node)) { + for (const argument of node.arguments) { + if (ts.isIdentifier(argument) && /^[A-Z]/.test(argument.text)) { + const inner = resolveComponent(targetFile, argument.text, packageIndex, visited, hops + 1); + if (inner) return inner; + } + } + } + return { file: targetFile, node }; +} + +/** + * The child components this body hands THE SAME DOCUMENT to. + * + * This is the narrowing that makes a transitive walk sound. A renderer renders + * plenty of components, and most are handed a DIFFERENT document — the widgets a + * dashboard lays out each get their own `schema`, and `ObjectDataTable`'s + * `schema.onRowClick` is a read of the WIDGET's document, not the dashboard's. + * Following every JSX child attributed 46 reads to arms that never see them. + * + * So a hop is taken only when the document flows into the child: the child's + * `schema=` attribute must name the parent's own document, an object literal + * spreading it, a local `const` derived from it, or the parameter a + * document-carrying element hands its render-prop child. That last clause is not + * a special case for one plugin — it is how objectui#7664's own chain is + * spelled: + * + * ObjectKanbanRenderer {(bound) => + * ObjectKanban + * KanbanRenderer schema.onCardClick + */ +export function documentCarryingChildren(node) { + const documents = documentIdentifiers(node); + const names = new Set(); + const walk = (current) => { + if (ts.isJsxOpeningElement(current) || ts.isJsxSelfClosingElement(current)) { + const attribute = schemaAttributeOf(current); + if (attribute && carriesDocument(attribute, documents) && ts.isIdentifier(current.tagName)) { + if (/^[A-Z]/.test(current.tagName.text)) names.add(current.tagName.text); + } + } + ts.forEachChild(current, walk); + }; + walk(node); + return names; +} + +/** The expression a JSX element's `schema=` attribute is given, if any. */ +function schemaAttributeOf(element) { + for (const attribute of element.attributes.properties) { + if (!ts.isJsxAttribute(attribute)) continue; + if (attribute.name.getText() !== 'schema') continue; + const initializer = attribute.initializer; + if (initializer && ts.isJsxExpression(initializer) && initializer.expression) return initializer.expression; + } + return null; +} + +/** Is this expression the parent's own document, or built out of it? */ +function carriesDocument(expression, documents) { + if (ts.isIdentifier(expression)) return documents.has(expression.text); + if (ts.isParenthesizedExpression(expression)) return carriesDocument(expression.expression, documents); + if (ts.isObjectLiteralExpression(expression)) { + if (declaresOwnType(expression)) return false; + return expression.properties.some( + (property) => ts.isSpreadAssignment(property) && carriesDocument(property.expression, documents), + ); + } + if (ts.isBinaryExpression(expression) || ts.isConditionalExpression(expression)) { + return mentionsAny(expression, documents); + } + return false; +} + +/** + * Every local name that holds the document inside one body: `schema` itself, the + * `const`s derived from it, and the parameters a document-carrying element hands + * its render-prop child. Iterated to a fixpoint, because each of those can feed + * the next. + */ +function documentIdentifiers(node) { + const documents = new Set(['schema']); + for (let pass = 0; pass < 4; pass += 1) { + let changed = false; + const walk = (current) => { + if (ts.isVariableDeclaration(current) && ts.isIdentifier(current.name) && current.initializer) { + if ( + !documents.has(current.name.text) && + mentionsAny(current.initializer, documents) && + !constructsNewDocument(current.initializer) + ) { + documents.add(current.name.text); + changed = true; + } + } + if (ts.isJsxElement(current) && schemaAttributeOf(current.openingElement)) { + if (carriesDocument(schemaAttributeOf(current.openingElement), documents)) { + for (const child of current.children) { + if (!ts.isJsxExpression(child) || !child.expression) continue; + const callback = child.expression; + if (!ts.isArrowFunction(callback) && !ts.isFunctionExpression(callback)) continue; + for (const parameter of callback.parameters) { + if (ts.isIdentifier(parameter.name) && !documents.has(parameter.name.text)) { + documents.add(parameter.name.text); + changed = true; + } + } + } + } + } + ts.forEachChild(current, walk); + }; + walk(node); + if (!changed) break; + } + return documents; +} + +/** + * Does this expression BUILD a new document rather than carry the parent's? + * + * An object literal that writes its own `type` member is a new node, whatever it + * read out of the parent to build it. `ObjectView` composes + * `{ type: 'view-switcher', …, storageKey: `view-pref-${schema.objectName}` }` + * and hands it to ``; without this test the mention of + * `schema` inside made that read as the parent's document, and `ViewSwitcher`'s + * `schema.onViewChange` was reported against `ObjectViewSchema` — a named false + * positive, and one whose arm is not even the one being read. + */ +function constructsNewDocument(expression) { + let hit = false; + const walk = (current) => { + if (hit) return; + if (ts.isObjectLiteralExpression(current) && declaresOwnType(current)) { + hit = true; + return; + } + ts.forEachChild(current, walk); + }; + walk(expression); + return hit; +} + +function declaresOwnType(objectLiteral) { + return objectLiteral.properties.some( + (property) => + (ts.isPropertyAssignment(property) || ts.isShorthandPropertyAssignment(property)) && + (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) && + property.name.text === 'type', + ); +} + +/** Does this expression read any of these identifiers? */ +function mentionsAny(expression, names) { + let hit = false; + const walk = (current) => { + if (hit) return; + if (ts.isIdentifier(current) && names.has(current.text)) { + hit = true; + return; + } + ts.forEachChild(current, walk); + }; + walk(expression); + return hit; +} + +/** Parse every file once; the walk revisits components, never files. */ +function indexPackages(root) { + const byPackage = populationFiles(root); + const parsed = new Map(); + const filesInPackage = new Map(); + for (const [, files] of byPackage) { + const set = new Set(files); + for (const file of files) { + filesInPackage.set(file, set); + const sourceFile = parseSource(readFileSync(file, 'utf8'), file); + parsed.set(file, { + sourceFile, + declarations: declarationsIn(sourceFile), + imports: relativeImportsIn(sourceFile), + }); + } + } + return { + byPackage, + filesInPackage, + get: (file) => parsed.get(file), + size: parsed.size, + }; +} + +export function analyze(root) { + const { arms } = collectArms(root); + const packageIndex = indexPackages(root); + + const counters = { + arms: arms.size, + files: packageIndex.size, + registrations: 0, + armed: 0, + reads: 0, + judged: 0, + unjudgeable: 0, + }; + const census = []; + const raw = []; + const findings = []; + + for (const [, files] of packageIndex.byPackage) { + for (const file of files) { + const parsed = packageIndex.get(file); + if (!parsed) continue; + const registrations = registrationsIn(parsed.sourceFile); + if (!registrations.length) continue; + counters.registrations += registrations.length; + + for (const registration of registrations) { + const arm = arms.get(registration.type); + if (!arm) continue; + counters.armed += 1; + + const reads = reachableReads(file, registration.component, packageIndex); + for (const [key, where] of [...reads].sort((a, b) => a[0].localeCompare(b[0]))) { + counters.reads += 1; + const disposition = arm.members.has(key) ? arm.members.get(key) : undefined; + const declared = arm.members.has(key); + const rel = relative(root, where.file).split(sep).join('/'); + + // An arm whose shape spreads something this reader could not follow has + // an INCOMPLETE member set, so "not declared" would be a claim about a + // member list that is not fully known. Reporting it anyway is the one + // way this gate could produce a red nobody can act on, so it does not. + if (!declared && arm.unresolved.length) { + counters.unjudgeable += 1; + census.push({ + type: registration.type, + schema: arm.schema, + key, + declared: false, + disposition, + unjudgeable: arm.unresolved, + file: rel, + line: where.line, + }); + continue; + } + + counters.judged += 1; + census.push({ type: registration.type, schema: arm.schema, key, declared, disposition, file: rel, line: where.line }); + if (declared && disposition !== 'retired') continue; + + const finding = { + key: `${registration.type}::${arm.schema}.${key}`, + kind: declared ? 'retired-but-read' : 'undeclared', + type: registration.type, + schema: arm.schema, + armFile: arm.file, + member: key, + file: rel, + line: where.line, + registeredIn: relative(root, file).split(sep).join('/'), + }; + raw.push(finding); + if (!KNOWN_UNDECLARED_READS.has(finding.key)) findings.push(finding); + } + } + } + } + + return { findings, raw, stale: staleExemptions(raw), counters, census, arms }; +} + +const invokedDirectly = isEntrypoint(import.meta.url); + +if (invokedDirectly) { + const argOf = (name) => { + const index = process.argv.indexOf(name); + return index > -1 ? process.argv[index + 1] : null; + }; + const root = resolve(argOf('--root') ?? resolve(scriptDir, '..')); + const { findings, stale, counters, census } = analyze(root); + + if (process.argv.includes('--list')) { + for (const entry of census) { + const state = entry.unjudgeable + ? `UNJUDGEABLE (unresolved spread: ${entry.unjudgeable.join(', ')})` + : entry.declared + ? (entry.disposition ?? 'declared') + : 'UNDECLARED'; + console.log(`'${entry.type}' ${entry.schema}.${entry.key} [${state}] ${entry.file}:${entry.line}`); + } + } + + // A refactor that quietly emptied the census would satisfy every assertion in + // the pin while checking nothing — the same size guard the sibling gates open + // with, and the reason a green here always carries its counts. + if (counters.arms < 50 || counters.files < 200 || counters.armed < 10 || counters.reads < 10) { + console.error( + `The census collapsed: ${counters.arms} arm(s), ${counters.files} source file(s), ` + + `${counters.armed} registration(s) with an arm, ${counters.reads} handler read(s). ` + + 'An empty census would pass while asserting nothing.', + ); + process.exit(1); + } + + if (stale.length) { + console.error( + `x ${stale.length} KNOWN_UNDECLARED_READS row(s) name a read site this gate no longer finds:\n` + + stale.map((key) => ` ${key}`).join('\n') + + '\n\nThe defect the row waives is gone, so the row is now a live waiver for nothing. Delete it.', + ); + process.exit(1); + } + + if (!findings.length) { + console.log( + `OK ${counters.arms} arm(s), ${counters.registrations} registration(s) ` + + `(${counters.armed} with an arm), ${counters.reads} reachable handler read(s), ` + + `${counters.judged} judged, ${counters.unjudgeable} left unjudged on an arm with an ` + + `unresolved spread, ${KNOWN_UNDECLARED_READS.size} exempted by ledger — every judged read ` + + 'is a declared member of its arm.', + ); + process.exit(0); + } + + console.error(`x ${findings.length} handler key(s) a registered renderer reads are not declared by their arm:\n`); + for (const finding of findings) { + const why = + finding.kind === 'undeclared' + ? `${finding.schema} (${finding.armFile}) does not declare it` + : `${finding.schema} (${finding.armFile}) declares it RETIRED, but a renderer still reads it`; + console.error( + ` '${finding.type}'.${finding.member} read at ${finding.file}:${finding.line}\n` + + ` registered in ${finding.registeredIn}; ${why}.`, + ); + } + console.error( + '\n`BaseSchema` is .passthrough(), so a key that is not declared is not refused — it stops being\n' + + 'judged and the value is KEPT, then reaches the renderer that reads it (objectui#7664, objectui#7753).\n' + + 'Declare the key on its arm with handlerKeyRefusal(), or stop reading it — or, if the fix belongs to\n' + + 'another card, add the key to KNOWN_UNDECLARED_READS in\n' + + 'scripts/check-handler-key-read-sites.mjs with the card that owns it.', + ); + process.exit(1); +}