diff --git a/.changeset/designer-field-key-parity-gate.md b/.changeset/designer-field-key-parity-gate.md new file mode 100644 index 0000000000..af9f06da57 --- /dev/null +++ b/.changeset/designer-field-key-parity-gate.md @@ -0,0 +1,12 @@ +--- +--- + +Tooling and tests only; no published behaviour changes. + +Adds `check:designer-field-key-parity`, a gate that compares the field designers' +statically declared payload shapes (`FieldMetadataPayload`, `ServerFieldSchema`, +`DesignerFieldDefinition`) against the accept set of the installed +`@objectstack/spec` `FieldSchema`, plus the draft-I/O round-trip half as a test in +`@object-ui/app-shell`. Both are new checks over existing code — nothing shipped +in a package changed, and no offending key was fixed: each one the gate surfaced is +filed as its own card and recorded in the gate's ledger. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be3d0e1d92..74961f4fff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -251,6 +251,25 @@ jobs: if: steps.relevant.outputs.should_run == 'true' run: pnpm check:action-forward-parity + # A field designer offering a control that writes a key `FieldSchema` + # refuses BY NAME is a save-blocking 422 (`INVALID_METADATA`) that blocks + # EVERY later save of the object, and the author cannot tell from the UI + # which key did it. It had been filed three times — objectui#4644 + # `indexed`, #4687 `distance_metric`, #4676 `placeholder` — each closed + # with a per-key tombstone written AFTER the instance was found in + # production, and nothing detected the next one (objectui#5761). This gate + # compares the designers' statically declared payload shapes against the + # installed `FieldSchema`'s own accept set. Reads sources with `typescript` + # and the installed spec, so it needs the install and nothing built — same + # placement rationale as the step above. It covers a documented SUBSET of + # the write path (keys reaching the payload only via a `patchDef` spread + # are outside its reach); the boundary is stated in the script's docblock, + # and its draft-I/O half runs in the test suite as + # `object-fields-io.spec-keys.test.ts`. + - name: Verify designer field payloads declare only keys FieldSchema accepts + if: steps.relevant.outputs.should_run == 'true' + run: pnpm check:designer-field-key-parity + # lucide retires a spelling by DROPPING IT FROM the runtime `icons` record # while keeping it as a deprecated named export. A retired name therefore # still imports, still type-checks and still renders wherever it is used as diff --git a/content/docs/guide/ci-cd-pipeline.md b/content/docs/guide/ci-cd-pipeline.md index d02a1792de..97dd71f0cc 100644 --- a/content/docs/guide/ci-cd-pipeline.md +++ b/content/docs/guide/ci-cd-pipeline.md @@ -173,7 +173,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:esm-specifiers`, then `pnpm check:spec-symbols`, then `pnpm check:action-forward-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: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: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: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: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. | 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 6bff07c96d..ba58821217 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "type-check:vitest-setup": "tsc -p tsconfig.vitest-setup.json", "check:spec-symbols": "node scripts/check-spec-symbol-derivation.mjs", "check:action-forward-parity": "node scripts/check-action-forward-parity.mjs", + "check:designer-field-key-parity": "node scripts/check-designer-field-key-parity.mjs", "check:icon-record-names": "node scripts/check-lucide-icon-record-names.mjs", "check:phantom-deps": "node scripts/check-phantom-dependencies.mjs", "check:self-import": "node scripts/check-package-self-import.mjs", diff --git a/packages/app-shell/src/views/metadata-admin/previews/object-fields-io.spec-keys.test.ts b/packages/app-shell/src/views/metadata-admin/previews/object-fields-io.spec-keys.test.ts new file mode 100644 index 0000000000..c6aa0f78a1 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/previews/object-fields-io.spec-keys.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from 'vitest'; +import { FieldSchema } from '@objectstack/spec/data'; +import { RETIRED_FIELD_KEYS, newField, readFields, writeFields } from './object-fields-io.js'; + +/** + * objectui#5761 — the draft-I/O half of the designer key-parity guard. + * + * `scripts/check-designer-field-key-parity.mjs` reads the statically DECLARED + * payload shapes (`FieldMetadataPayload`, `ServerFieldSchema`, + * `DesignerFieldDefinition`). This module has no declared shape to read: it + * carries field defs as `Record` and deliberately preserves + * arbitrary unknown properties, so the only way to check it is to run its real + * round-trip and parse the real output with the real schema. That is what this + * file does, and it is coverage note 3 of the gate's docblock made executable. + * + * The class being guarded (#5761): a field designer emits a key `FieldSchema` + * refuses BY NAME. The author sees the control work and the preview render it, + * then `PUT /api/v1/meta/object/:name` returns a hard 422 `INVALID_METADATA` + * that blocks EVERY subsequent save of that object. Three instances, three + * different correct resolutions — #4644 `indexed` (control retired + + * strip-on-load), #4687 `distance_metric` (declaration removed), #4676 + * `placeholder` (producer moved upstream, shipped in `@objectstack/spec` + * 17.1.0). + * + * ## Why the negative controls are the deliverable + * + * A green parity assertion proves nothing on its own. `FieldSchema` could be + * resolved to a look-alike, or loosened to a passthrough object, and this file + * would stay green while asserting nothing at all. So every positive assertion + * below is paired with a control that must FAIL, and those controls are + * assertions, not observations — if a control ever passes, the instrument is + * broken and the whole result is void. + * + * ## What this file does NOT cover + * + * The keys `ObjectFieldInspector` writes through conditional `patchDef({...})` + * calls. Those never appear in any declaration and are not enumerable from a + * round-trip fixture either — the honest limit #5761 states rather than hides. + * A def key reaching the payload only that way is outside BOTH halves of this + * guard. + */ + +/** The round-trip every draft read/write in the object designer goes through. */ +const roundTrip = (fieldsInput: unknown) => writeFields(readFields(fieldsInput)); + +const parseOne = (def: unknown) => FieldSchema.safeParse(def); + +const unrecognizedKeys = (result: ReturnType): string[] => + result.success + ? [] + : result.error.issues.filter((i) => i.code === 'unrecognized_keys').flatMap((i) => (i as { keys: string[] }).keys); + +describe('the instrument', () => { + it('is the installed spec schema and it is STRICT — unknown keys are refused, not stripped', () => { + // objectstack#4001 closed the silent-drop shape. Everything below depends + // on that: if `FieldSchema` stripped unknown keys instead of refusing them, + // every parity assertion in this file would be trivially green while the + // 422 it guards against still happened server-side. + const result = parseOne({ type: 'text', label: 'L', zzzDefinitelyNotAKey: 1 }); + expect(result.success).toBe(false); + expect(unrecognizedKeys(result)).toContain('zzzDefinitelyNotAKey'); + }); +}); + +describe('a legitimate designer draft round-trips into something FieldSchema accepts', () => { + it('parses the record shape, the probe #5761 measured working', () => { + const out = roundTrip({ nickname: { type: 'text', label: 'Nickname', placeholder: 'e.g. Jo' } }); + expect(out).toEqual({ nickname: { type: 'text', label: 'Nickname', placeholder: 'e.g. Jo' } }); + // `placeholder` is #4676's key: refused until the producer moved upstream, + // accepted since `@objectstack/spec` 17.1.0. Asserting it parses pins the + // resolution as well as the parity. + expect(parseOne((out as Record).nickname).success).toBe(true); + }); + + it('parses the array shape too — both draft shapes go through one read door', () => { + const out = roundTrip([{ name: 'nickname', type: 'text', label: 'Nickname', placeholder: 'e.g. Jo' }]); + const [entry] = out as Array>; + const { name, ...def } = entry; + expect(name).toBe('nickname'); + expect(parseOne(def).success).toBe(true); + }); + + it('parses every field `newField` creates, for every type it special-cases', () => { + // `newField` is the designer's own statically declared emit site: the one + // place in this module that names keys rather than carrying them through. + for (const type of ['text', 'select', 'multiselect', 'radio', 'checkboxes'] as const) { + const created = newField('nickname', type, 'Nickname'); + const out = roundTrip({ [created.name]: created.def }) as Record; + const result = parseOne(out.nickname); + expect(unrecognizedKeys(result), `newField('${type}') emitted a refused key`).toEqual([]); + } + }); +}); + +describe('negative controls — if either of these passes, the result above is void', () => { + it('control 1: an UN-STRIPPED `indexed` is refused, with `unrecognized_keys`', () => { + // Deliberately NOT routed through `readFields` — that would strip the key + // and the control would prove nothing. This is the raw def as a draft + // authored before #4644 carries it. + const result = parseOne({ type: 'text', label: 'Nickname', indexed: true }); + expect(result.success).toBe(false); + expect(unrecognizedKeys(result)).toEqual(['indexed']); + }); + + it('control 2: a bogus key is refused', () => { + const result = parseOne({ type: 'text', label: 'Nickname', zzzDefinitelyNotAKey: 'x' }); + expect(result.success).toBe(false); + expect(unrecognizedKeys(result)).toEqual(['zzzDefinitelyNotAKey']); + }); +}); + +describe('strip-on-load is what makes a pre-#4644 draft saveable again', () => { + it('a draft carrying `indexed` comes out of the round-trip parseable', () => { + // The two halves together are the real assertion: control 1 proves the key + // is genuinely fatal, this proves `readFields` removes it. Either alone is + // compatible with a broken strip. + const out = roundTrip({ nickname: { type: 'text', label: 'Nickname', indexed: true } }) as Record< + string, + unknown + >; + expect(out.nickname).not.toHaveProperty('indexed'); + expect(parseOne(out.nickname).success).toBe(true); + }); + + it('strips in the array shape as well', () => { + const out = roundTrip([{ name: 'nickname', type: 'text', label: 'Nickname', indexed: true }]) as Array< + Record + >; + expect(out[0]).not.toHaveProperty('indexed'); + const { name: _name, ...def } = out[0]; + expect(parseOne(def).success).toBe(true); + }); + + it('every key in RETIRED_FIELD_KEYS really is one the spec refuses', () => { + // Keeps the tombstone honest in the direction that would otherwise rot + // silently: if the spec ever starts accepting one of these (the #4676 + // resolution shape), stripping it becomes silent data loss rather than a + // rescue, and this is what says so. + for (const key of RETIRED_FIELD_KEYS) { + const result = parseOne({ type: 'text', label: 'L', [key]: true }); + expect(unrecognizedKeys(result), `\`${key}\` is no longer refused — stripping it now drops data`).toContain( + key, + ); + } + }); + + it('strips ONLY the retired keys — every other unknown key still survives the round-trip', () => { + // The module's contract is a keyed tombstone, never a blanket unknown-key + // purge. A purge would make this whole file green by construction and + // silently drop keys the designer does not render. + const out = roundTrip({ + nickname: { type: 'text', label: 'Nickname', indexed: true, zzzDefinitelyNotAKey: 'kept' }, + }) as Record>; + expect(out.nickname).not.toHaveProperty('indexed'); + expect(out.nickname.zzzDefinitelyNotAKey).toBe('kept'); + }); +}); diff --git a/scripts/__tests__/check-designer-field-key-parity.test.ts b/scripts/__tests__/check-designer-field-key-parity.test.ts new file mode 100644 index 0000000000..addfce874c --- /dev/null +++ b/scripts/__tests__/check-designer-field-key-parity.test.ts @@ -0,0 +1,275 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { FieldSchema } from '@objectstack/spec/data'; + +// Plain-JS CI helper. Its types are INFERRED from the .mjs source by +// `tsconfig.scripts.json` (`allowJs`), so no `@ts-expect-error` here. +import { + ExtractionError, + KNOWN_UNPARSEABLE_KEYS, + PAYLOAD_SHAPES, + analyze, + declaredKeys, + fieldSchemaAcceptSet, +} from '../check-designer-field-key-parity.mjs'; + +/** + * objectui#5761. The gate this file tests compares the keys a field designer's + * statically declared payload shapes can emit against the keys the INSTALLED + * `FieldSchema` accepts. Its whole value is that it goes red on a key the spec + * refuses by name — and a parity check is the one gate shape that can be + * catastrophically, invisibly vacuous: + * + * - resolve the wrong symbol (a local structural look-alike rather than the + * spec's schema) and everything passes; + * - resolve a LOOSENED schema (non-strict zod object, `.passthrough()`) and + * everything passes; + * - fail to find the interface in a renamed file and, without the throw, + * "zero declared keys" reads as "zero bad keys". + * + * All three produce a confident green over a broken instrument. So this file + * carries the negative controls as EXECUTABLE assertions rather than as prose, + * exactly as the card required: an un-stripped `indexed` must be reported with + * `unrecognized_keys`, a bogus key must be reported, and the resolved schema + * must be provably the installed `@objectstack/spec` one. + */ + +const here = path.dirname(new URL(import.meta.url).pathname); +const repoRoot = path.resolve(here, '..', '..'); + +/** Writes fixture sources to a throwaway dir and runs the REAL extractor over them. */ +async function withFixture(files: Record, run: (dir: string) => Promise): Promise { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'designer-field-parity-')); + try { + for (const [name, contents] of Object.entries(files)) { + const full = path.join(dir, name); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, contents); + } + return await run(dir); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +const WIRE_SHAPE = { + id: 'FixturePayload', + file: 'payload.ts', + interface: 'FixturePayload', + reach: 'wire' as const, + writer: 'fixture', +}; + +const fixtureShape = (body: string) => ({ + files: { 'payload.ts': `export interface FixturePayload {\n${body}\n}\n` }, + shapes: [WIRE_SHAPE], +}); + +describe('the instrument is the installed FieldSchema, not a look-alike', () => { + it('resolves the very same schema object `@objectstack/spec/data` exports', async () => { + // Reference identity, not a structural comparison. The card measured that + // `packages/plugin-designer` declares its own `ServerFieldSchema` subset + // type — a look-alike that is one of this gate's INPUTS and must never be + // mistaken for its oracle. A structural check could not tell them apart; + // `===` can. + // + // This assertion is also what keeps the gate on the ESM build. It failed + // when the gate resolved the spec through `createRequire`: `@objectstack/spec` + // is dual-package, so `require` and `import` hand back two different module + // instances of the same schema, and the gate was reading a build the app + // never bundles. Structural equality would have been green for that. + const { schema } = await fieldSchemaAcceptSet(); + expect(schema).toBe(FieldSchema); + }); + + it('reads the accept set off the schema itself, and it is a real subset of all strings', async () => { + const { accept } = await fieldSchemaAcceptSet(); + // Non-vacuity in the other direction: a schema that accepted everything + // would make every parity comparison trivially green. + expect(accept.has('label')).toBe(true); + expect(accept.has('placeholder')).toBe(true); + expect(accept.has('indexed')).toBe(false); + expect(accept.has('zzzDefinitelyNotAKey')).toBe(false); + expect(accept.size).toBeGreaterThan(20); + }); + + it('is a STRICT schema — it refuses unknown keys rather than stripping them', async () => { + // If `FieldSchema` ever became non-strict this gate would still be green + // while the thing it guards stopped being true, so pin the behaviour that + // makes key-level parity meaningful at all (objectstack#4001 closed the + // silent-drop shape). + const stripped = FieldSchema.safeParse({ type: 'text', label: 'L', zzzDefinitelyNotAKey: 1 }); + expect(stripped.success).toBe(false); + expect(stripped.error?.issues.map((i) => i.code)).toContain('unrecognized_keys'); + }); +}); + +describe('negative controls — the gate must go red on a refused key', () => { + it('reports an un-stripped `indexed`, the objectui#4644 instance', async () => { + const { files, shapes } = fixtureShape(' type?: string;\n label?: string;\n indexed?: boolean;'); + await withFixture(files, async (dir) => { + const { violations } = await analyze(dir, { shapes, ledger: {} }); + expect(violations.map((v) => v.key)).toEqual(['indexed']); + }); + }); + + it('the same payload really is what the spec refuses, with `unrecognized_keys`', async () => { + // The control above proves the GATE reports the key. This one proves the + // key is genuinely refused by the real schema — otherwise the gate could be + // red about something harmless. + const parsed = FieldSchema.safeParse({ type: 'text', label: 'L', indexed: true }); + expect(parsed.success).toBe(false); + const issue = parsed.error?.issues.find((i) => i.code === 'unrecognized_keys'); + expect(issue).toBeDefined(); + expect((issue as { keys: string[] }).keys).toContain('indexed'); + }); + + it('reports a bogus key', async () => { + const { files, shapes } = fixtureShape(' label?: string;\n zzzDefinitelyNotAKey?: string;'); + await withFixture(files, async (dir) => { + const { violations } = await analyze(dir, { shapes, ledger: {} }); + expect(violations.map((v) => v.key)).toEqual(['zzzDefinitelyNotAKey']); + }); + }); + + it('stays green on a shape whose every key the spec accepts', async () => { + const { files, shapes } = fixtureShape(' type?: string;\n label?: string;\n placeholder?: string;'); + await withFixture(files, async (dir) => { + expect((await analyze(dir, { shapes, ledger: {} })).violations).toEqual([]); + }); + }); +}); + +describe('reach classification — a UI-only key is not a wire violation', () => { + const UI_SHAPE = { id: 'FixtureUi', file: 'ui.ts', interface: 'FixtureUi', reach: 'ui' as const, writer: 'fixture' }; + + it('a refused key declared only on the UI model is reported as uiOnly, not as a violation', async () => { + await withFixture( + { + 'payload.ts': 'export interface FixturePayload {\n label?: string;\n}\n', + 'ui.ts': 'export interface FixtureUi {\n label?: string;\n zzzUiOnlyKey?: string;\n}\n', + }, + async (dir) => { + const { violations, uiOnly } = await analyze(dir, { shapes: [WIRE_SHAPE, UI_SHAPE], ledger: {} }); + expect(violations).toEqual([]); + expect(uiOnly.map((u) => u.key)).toEqual(['zzzUiOnlyKey']); + }, + ); + }); + + it('the SAME key becomes a violation the moment a wire shape declares it too', async () => { + // This is what makes the uiOnly bucket safe to have: it is recomputed every + // run from the wire shapes, never asserted once and trusted. + await withFixture( + { + 'payload.ts': 'export interface FixturePayload {\n label?: string;\n zzzUiOnlyKey?: string;\n}\n', + 'ui.ts': 'export interface FixtureUi {\n label?: string;\n zzzUiOnlyKey?: string;\n}\n', + }, + async (dir) => { + const { violations, uiOnly } = await analyze(dir, { shapes: [WIRE_SHAPE, UI_SHAPE], ledger: {} }); + expect(uiOnly).toEqual([]); + expect(violations.map((v) => `${v.shape}.${v.key}`)).toEqual([ + 'FixturePayload.zzzUiOnlyKey', + 'FixtureUi.zzzUiOnlyKey', + ]); + }, + ); + }); +}); + +describe('the ledger ratchets in both directions', () => { + const LEDGER = { zzzLedgeredKey: { card: 'objectui#0000', spec: null, note: 'fixture' } }; + + it('a ledgered key is not a violation', async () => { + const { files, shapes } = fixtureShape(' label?: string;\n zzzLedgeredKey?: string;'); + await withFixture(files, async (dir) => { + const { violations, staleLedger } = await analyze(dir, { shapes, ledger: LEDGER }); + expect(violations).toEqual([]); + expect(staleLedger).toEqual([]); + }); + }); + + it('an entry whose key no shape declares any more is itself red', async () => { + // Without this half the ledger becomes a place to hide: a key gets fixed, + // the entry survives, and the same spelling is silently re-admitted the + // next time someone declares it. + const { files, shapes } = fixtureShape(' label?: string;'); + await withFixture(files, async (dir) => { + const { staleLedger } = await analyze(dir, { shapes, ledger: LEDGER }); + expect(staleLedger).toEqual([ + { key: 'zzzLedgeredKey', reason: 'no payload shape declares it any more' }, + ]); + }); + }); + + it('an entry whose key the spec now accepts is red, naming that reason', async () => { + // The objectui#4676 resolution shape: `placeholder` was refused, the + // producer moved upstream, and the spec started accepting it. The entry + // must not outlive the refusal. + const { files, shapes } = fixtureShape(' label?: string;\n placeholder?: string;'); + await withFixture(files, async (dir) => { + const { staleLedger } = await analyze(dir, { + shapes, + ledger: { placeholder: { card: 'objectui#4676', spec: null, note: 'fixture' } }, + }); + expect(staleLedger).toEqual([{ key: 'placeholder', reason: '`FieldSchema` now accepts it' }]); + }); + }); +}); + +describe('extraction failure is an error, never a silent pass', () => { + it('throws when the shape file does not exist', async () => { + await withFixture({ 'unrelated.ts': '' }, async (dir) => { + await expect(analyze(dir, { shapes: [WIRE_SHAPE], ledger: {} })).rejects.toThrow(ExtractionError); + }); + }); + + it('throws when the interface was renamed away — zero keys must not read as zero bad keys', async () => { + await withFixture({ 'payload.ts': 'export interface SomethingElse {\n indexed?: boolean;\n}\n' }, async (dir) => { + await expect(analyze(dir, { shapes: [WIRE_SHAPE], ledger: {} })).rejects.toThrow(/not found in payload\.ts/); + }); + }); + + it('throws when the interface declares no properties', async () => { + await withFixture({ 'payload.ts': 'export interface FixturePayload {}\n' }, async (dir) => { + await expect(analyze(dir, { shapes: [WIRE_SHAPE], ledger: {} })).rejects.toThrow(/declares no properties/); + }); + }); +}); + +describe('the real shapes, on the real tree', () => { + it('finds every declared shape and reads a non-trivial key set from each', async () => { + for (const shape of PAYLOAD_SHAPES) { + const { keys } = declaredKeys(repoRoot, shape); + expect(keys.length, `${shape.id} declared no keys`).toBeGreaterThan(5); + expect(keys, `${shape.id} is missing \`label\``).toContain('label'); + } + }); + + it('sees the index signature on `ServerFieldSchema` — coverage note 2 must stay true', async () => { + // The gate's docblock claims a key reaching the payload only through a + // spread is outside its reach. That claim is only honest while the spread + // hole is real and visible; if the index signature ever goes away the + // docblock needs rewriting, not this assertion relaxing. + const server = PAYLOAD_SHAPES.find((s) => s.id === 'ServerFieldSchema'); + expect(server).toBeDefined(); + expect(declaredKeys(repoRoot, server!).indexSignature).toBe(true); + }); + + it('is green — every refused key on the tree is filed and ledgered', async () => { + const { violations, staleLedger } = await analyze(repoRoot); + expect(violations).toEqual([]); + expect(staleLedger).toEqual([]); + }); + + it('every ledger entry names the card that owns its resolution', async () => { + const entries = Object.entries(KNOWN_UNPARSEABLE_KEYS); + expect(entries.length).toBeGreaterThan(0); + for (const [key, entry] of entries) { + expect(entry.card, `${key} has no card`).toMatch(/^objectui#\d+$/); + expect(entry.note, `${key} has no note`).toBeTruthy(); + } + }); +}); diff --git a/scripts/check-designer-field-key-parity.mjs b/scripts/check-designer-field-key-parity.mjs new file mode 100644 index 0000000000..47b1df136b --- /dev/null +++ b/scripts/check-designer-field-key-parity.mjs @@ -0,0 +1,464 @@ +#!/usr/bin/env node +/** + * Every key a field designer's statically declared payload shape can emit must + * be a key the installed `FieldSchema` accepts by name. + * + * The failure class (objectui#5761): a field designer offers a control that + * writes a key `FieldSchema` refuses BY NAME. The author sees the control work + * — and, in metadata-admin, sees the preview render it — then + * `PUT /api/v1/meta/object/:name` returns a hard 422 `INVALID_METADATA` that + * blocks EVERY subsequent save of that object until the key is stripped. The + * author has no way to tell which key did it from the UI they were using. + * + * This repo has filed that same shape three times, and each instance took a + * DIFFERENT correct resolution — which is what makes it a class rather than a + * coincidence: + * + * indexed objectui#4644 — control retired + strip-on-load + * distance_metric objectui#4687 — declaration removed (zero readers/writers) + * placeholder objectui#4676 — producer moved: declared UPSTREAM, + * objectstack#9019 / PR objectstack#9113, shipped in + * `@objectstack/spec` 17.1.0 + * + * What existed before this gate was three per-key tombstones, each keyed to one + * literal: two independently maintained `RETIRED_FIELD_KEYS = ['indexed']` sets + * (`packages/app-shell/src/views/metadata-admin/previews/object-fields-io.ts` + * and `packages/plugin-designer/src/MetadataFieldsPage.tsx`) plus prose + * tombstones in four more files. Every one of them was written AFTER an + * instance was found in production. Nothing detected the next one, so instance + * four would again be found by a user hitting a save-blocking 422 rather than + * by CI. + * + * `scripts/check-spec-symbol-derivation.mjs` is NOT this guard and does not + * overlap it: that one checks symbol NAME collisions between a local + * declaration and a `@objectstack/spec` export. This one never looks at symbol + * names — it compares KEY SETS, and the shapes it reads are deliberately named + * nothing like the spec's. + * + * ── WHAT THIS GATE COVERS, AND WHAT IT DOES NOT ───────────────────────────── + * Read this section before concluding that a key is safe because the gate is + * green. The gate covers a SUBSET of the designer write path, and a guard that + * reads as complete while covering a subset is the same declared-≠-actual + * defect this file exists to close. + * + * COVERED — the statically declared payload shapes in {@link PAYLOAD_SHAPES}. + * A key is visible to this gate when it is written as a property signature on + * one of those interfaces. That is enough to have caught all three instances + * above: `indexed` was a declared property of the designer field definition, + * `distance_metric` a declared property, `placeholder` a declared property. + * + * NOT COVERED — four ways a key can reach the payload without ever appearing as + * a declared property: + * + * 1. `patchDef({...})` spreads. `ObjectFieldInspector` writes through many + * conditional `patchDef({ ... })` calls onto a `Record` + * def. A key that reaches the payload ONLY through such a spread is + * OUTSIDE THIS GATE'S REACH — nothing declares it, so there is no property + * signature to read. Enumerating that set is not mechanical, which is the + * honest limit objectui#5761 states rather than hides. + * 2. Index signatures. `ServerFieldSchema` declares `[key: string]: unknown` + * and `fromDesignerField` spreads `prev` verbatim to preserve unknown + * keys, so any key the SERVER sent round-trips back out untyped. The gate + * records the presence of an index signature (see `indexSignature` in the + * analysis) precisely so this hole is visible in its own output, but it + * cannot enumerate what flows through one. + * 3. Untyped `Record` field defs. `object-fields-io.ts`'s + * `readFields`/`writeFields` carry raw defs with no declared shape at all. + * Its round-trip is covered instead by an executable parity test — see + * `object-fields-io.field-schema-parity.test.ts`, which parses real + * round-tripped output through the real `FieldSchema` and carries the + * negative controls this gate's self-test carries. + * 4. Value-level rejections. This gate is a check on key NAMES only. A key in + * the accept set whose VALUE `FieldSchema` refuses (wrong type, failed + * refinement) is green here and still a 422 in production. + * + * ── The accept set is read from the schema, never listed here ─────────────── + * `FieldSchema` is a strict zod object: it refuses unknown keys with + * `unrecognized_keys` rather than stripping them (objectstack#4001 closed the + * silent-drop shape). Its accept set is read off the schema's own `shape` at + * run time. + * + * It is read through a dynamic `import()`, NOT `createRequire`, and that is + * load-bearing rather than stylistic. `@objectstack/spec` is a dual-package + * build: `require` lands on `dist/data/index.js`, `import` on + * `dist/data/index.mjs`. Those are two different module instances of the same + * schema, so a CJS-resolving gate cannot be proven — by identity — to be + * reading the build the app bundles against, and the two could drift with + * nothing to notice. Importing makes the self-test's `===` against a plain + * `import { FieldSchema } from '@objectstack/spec/data'` a real proof of + * provenance, which is the assertion that rules out the whole + * wrong-symbol failure mode. This is why the exported functions are async. A hardcoded copy would be the stale second definition this whole + * family of gates exists to prevent, and — worse for a parity check — a wrongly + * resolved or loosened schema produces a CONFIDENT GREEN over everything. So + * extraction failure is an ERROR, never a pass: {@link ExtractionError} is + * thrown when the spec cannot be resolved, when the shape cannot be walked, or + * when a shape file no longer declares the interface this gate reads. + * + * The self-test (`scripts/__tests__/check-designer-field-key-parity.test.ts`) + * carries the non-vacuity controls as executable assertions: a fixture + * declaring an un-stripped `indexed` must be reported, a fixture declaring a + * bogus key must be reported, and the resolved schema must be the INSTALLED + * `@objectstack/spec` `FieldSchema` rather than a local structural look-alike + * (`plugin-designer`'s own `ServerFieldSchema` is such a look-alike — it is one + * of this gate's INPUTS, never its oracle). + * + * ── The ledger, and why the gate is a ratchet rather than a bug report ────── + * The first run over `main` surfaced live offenders. Fixing them is NOT this + * gate's job and was explicitly out of scope when it was built: the three + * instances above took three different correct resolutions, so choosing one for + * a given key is an adjudication a tooling card does not carry. Each surfaced + * key is instead filed as its own card and recorded in + * {@link KNOWN_UNPARSEABLE_KEYS} with that card's number. + * + * The ledger ratchets in BOTH directions, which is what keeps it from becoming + * a place to hide: + * + * - a refused key on a wire-bound shape that is NOT in the ledger is red, so + * no NEW instance can land; + * - a ledger entry whose key is no longer refused, or no longer declared, is + * ALSO red, so a fixed key cannot leave a stale entry behind that would + * silently re-admit the same spelling later. + */ + +import ts from "typescript"; +import { createRequire } from "module"; +import { readFileSync, existsSync } from "fs"; +import { resolve, dirname } from "path"; +import { fileURLToPath } from "url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(HERE, ".."); + +/** A gate that cannot read its inputs. Never a pass — see the header. */ +export class ExtractionError extends Error { + constructor(message) { + super(message); + this.name = "ExtractionError"; + } +} + +const fail = (message) => { + throw new ExtractionError(message); +}; + +const readFile = (root, rel) => readFileSync(resolve(root, rel), "utf8"); +const parse = (root, rel) => + ts.createSourceFile(rel, readFile(root, rel), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + +/** + * The statically declared field payload shapes the designers write through. + * + * `reach` is what makes a finding actionable, and it is a claim about the + * CODE, not a severity dial: + * + * wire — the interface types a value that is handed to + * `client.meta.saveItem('object', …)`, i.e. it becomes the body of + * `PUT /api/v1/meta/object/:name`. A refused key declared here is a + * live 422 the moment the property is populated. + * ui — the designer's in-memory model. Its keys reach the wire only + * through converters (`toFieldPayload`, `fromDesignerField`) whose + * RETURN TYPE is one of the `wire` shapes above, so a key declared + * here and on no `wire` shape cannot reach the payload through any + * statically declared path. Such keys are reported as `uiOnly` — + * visible, but not a violation. The moment one of them is added to a + * `wire` shape the `wire` scan catches it, so the classification is + * computed on every run rather than asserted once. + */ +export const PAYLOAD_SHAPES = [ + { + id: "FieldMetadataPayload", + file: "packages/app-shell/src/services/MetadataService.ts", + interface: "FieldMetadataPayload", + reach: "wire", + // `toFieldPayload` builds it; `saveFields` PUTs `fields.map(toFieldPayload)` + // and `saveObject` PUTs it through `toObjectPayload`. + writer: "MetadataService.saveFields / saveObject", + }, + { + id: "ServerFieldSchema", + file: "packages/plugin-designer/src/MetadataFieldsPage.tsx", + interface: "ServerFieldSchema", + reach: "wire", + // `fromDesignerField` builds it; `MetadataFieldsPage` PUTs the assembled + // `fields` map. Carries an index signature — see coverage note 2. + writer: "MetadataFieldsPage.handleFieldsChange", + }, + { + id: "DesignerFieldDefinition", + file: "packages/types/src/designer.ts", + interface: "DesignerFieldDefinition", + reach: "ui", + writer: "FieldDesigner (in-memory model)", + }, +]; + +/** + * Keys this gate surfaced on the tree it landed on, each with the card that + * owns its resolution. NOT a suppression list: see the header's ratchet note — + * an entry that stops applying is as red as a key that is missing one. + * + * `spec` records the accepted spelling where the spec has one, because that is + * the fact a resolver needs first and the fact most likely to be wrong in a + * hurry. It is documentation for the card, never an instruction to rename: + * objectui#4687 shows that "delete the declaration" is sometimes the right + * answer even when a near-spelling exists. + */ +export const KNOWN_UNPARSEABLE_KEYS = { + referenceTo: { + card: "objectui#6041", + spec: "reference", + note: "LIVE. FieldDesigner renders a control for it on `type == 'lookup'` and both write paths populate it, so authoring a lookup field produces the 422.", + }, + formula: { + card: "objectui#6043", + spec: "expression (+ returnType)", + note: "LIVE. FieldDesigner renders a textarea for it on `type == 'formula'`. Not a rename: the spec's `expression` is CEL, so the key and the expression LANGUAGE move together.", + }, + isSystem: { + card: "objectui#6044", + spec: "system", + note: "Two defects. The READ (`toDesignerField` reads `raw.isSystem`) never matches what a spec-parsed server sends, so system fields present as ordinary editable ones; the WRITE can round-trip out through `carryOver`'s verbatim spread.", + }, + sortOrder: { + card: "objectui#6045", + spec: null, // the spec has `sortable` (a boolean), and no field-level ordering key + note: "Latent: declared and written by `toFieldPayload`, but nothing populates it, so JSON drops the undefined. One reorder feature away from live.", + }, +}; + +/** + * The keys the INSTALLED `FieldSchema` accepts, read off the schema itself. + * + * Resolved through `@objectstack/spec/data` — the published subpath, the same + * one the runtime parses with — so this is the schema that actually judges a + * `PUT`, not a local look-alike. Extraction failure throws; see the header. + */ +export async function fieldSchemaAcceptSet(importSpec = (id) => import(id)) { + let data; + try { + data = await importSpec("@objectstack/spec/data"); + } catch (err) { + fail( + "cannot resolve @objectstack/spec/data — run `pnpm install` first.\n" + + " This gate reads the spec's own schema; it has no hardcoded fallback by design.\n" + + ` (${err && err.message})` + ); + } + const schema = data.FieldSchema; + if (!schema) { + fail( + "@objectstack/spec/data no longer exports `FieldSchema` — the accept set cannot be derived.\n" + + " Re-point this gate at the schema that judges a field payload; do NOT hardcode a key list." + ); + } + const keys = shapeKeys(schema); + if (!keys || keys.length === 0) { + fail( + "could not resolve `FieldSchema`'s shape from @objectstack/spec/data.\n" + + " The schema's internal representation changed. Fix the walk — falling back to a\n" + + " hardcoded key list would make this gate the stale copy it exists to prevent." + ); + } + return { schema, accept: new Set(keys), origin: specOrigin() }; +} + +/** + * The file the accept set was read from, for the run log. `createRequire` is + * used ONLY here — `import.meta.resolve` is not available in every Node this + * repo runs on, and this string never feeds a comparison, only the output. + */ +function specOrigin() { + try { + return createRequire(import.meta.url).resolve("@objectstack/spec/package.json").replace(/package\.json$/, ""); + } catch { + return "(unresolved)"; + } +} + +/** Walk a zod schema's wrappers down to the object `shape` it carries. */ +function shapeKeys(node, depth = 0, seen = new Set()) { + if (!node || depth > 8 || seen.has(node)) return null; + seen.add(node); + const shapeOf = (v) => (v && typeof v === "object" ? Object.keys(v) : null); + if (node.shape) return shapeOf(node.shape); + const def = node._def ?? node.def ?? node._zod?.def; + if (!def) return null; + if (def.shape) return shapeOf(def.shape); + for (const key of ["in", "out", "innerType", "schema", "left", "right"]) { + const found = def[key] ? shapeKeys(def[key], depth + 1, seen) : null; + if (found) return found; + } + return null; +} + +/** + * Property-signature names declared on one interface, plus whether it carries + * an index signature (the untyped hole recorded in coverage note 2). + * + * The interface is searched for anywhere in the file, not only at top level: + * `ServerFieldSchema` is a module-local, non-exported declaration in a `.tsx`, + * and a top-level-statements-only walk would silently return nothing for it — + * which for a parity gate reads as "this shape declares no bad keys". + */ +export function declaredKeys(root, shape) { + const rel = shape.file; + if (!existsSync(resolve(root, rel))) { + fail( + `${rel} does not exist — the payload shape \`${shape.interface}\` moved or was deleted.\n` + + " Re-point this gate at it; a missing input is never a pass." + ); + } + const sf = parse(root, rel); + let decl = null; + const visit = (node) => { + if (ts.isInterfaceDeclaration(node) && node.name.text === shape.interface) decl = node; + if (!decl) ts.forEachChild(node, visit); + }; + visit(sf); + if (!decl) { + fail( + `\`interface ${shape.interface}\` not found in ${rel}.\n` + + " The payload shape moved or was renamed; re-point this gate at it." + ); + } + const keys = decl.members + .filter(ts.isPropertySignature) + .map((m) => (m.name && (ts.isIdentifier(m.name) || ts.isStringLiteral(m.name)) ? m.name.text : null)) + .filter((n) => n !== null); + if (keys.length === 0) { + fail(`\`${shape.interface}\` in ${rel} declares no properties — extraction failed.`); + } + const indexSignature = decl.members.some((m) => ts.isIndexSignatureDeclaration(m)); + return { keys, indexSignature }; +} + +/** + * Compare every declared payload key against the accept set. + * + * Returns `{ accept, shapes, violations, uiOnly, staleLedger }`. `violations` + * is what makes the gate red; `uiOnly` and `staleLedger` are reported too — + * `staleLedger` is red as well (see the header's both-directions ratchet). + */ +export async function analyze(root = REPO_ROOT, options = {}) { + const shapes = options.shapes ?? PAYLOAD_SHAPES; + const ledger = options.ledger ?? KNOWN_UNPARSEABLE_KEYS; + const { accept, origin } = options.acceptSet + ? { accept: options.acceptSet, origin: "(injected)" } + : await fieldSchemaAcceptSet(options.importSpec); + + const read = shapes.map((shape) => ({ shape, ...declaredKeys(root, shape) })); + const wireKeys = new Set(read.filter((r) => r.shape.reach === "wire").flatMap((r) => r.keys)); + + const violations = []; + const uiOnly = []; + const ledgered = new Set(); + + for (const { shape, keys } of read) { + for (const key of keys) { + if (accept.has(key)) continue; + if (shape.reach === "ui" && !wireKeys.has(key)) { + uiOnly.push({ shape: shape.id, file: shape.file, key }); + continue; + } + if (Object.prototype.hasOwnProperty.call(ledger, key)) { + ledgered.add(key); + continue; + } + violations.push({ shape: shape.id, file: shape.file, writer: shape.writer, key }); + } + } + + // Both-directions ratchet: an entry that no longer applies must not survive. + const declaredEverywhere = new Set(read.flatMap((r) => r.keys)); + const staleLedger = Object.keys(ledger) + .filter((key) => !ledgered.has(key)) + .map((key) => ({ + key, + reason: !declaredEverywhere.has(key) + ? "no payload shape declares it any more" + : accept.has(key) + ? "`FieldSchema` now accepts it" + : "it is no longer reachable from a wire-bound shape", + })); + + return { accept, origin, shapes: read, violations, uiOnly, staleLedger }; +} + +async function main() { + let result; + try { + result = await analyze(); + } catch (err) { + if (err instanceof ExtractionError) { + console.error("designer-field-key-parity: EXTRACTION FAILED\n"); + console.error(` ${err.message}\n`); + process.exit(1); + } + throw err; + } + + const { accept, origin, shapes, violations, uiOnly, staleLedger } = result; + console.log(`designer-field-key-parity: FieldSchema accepts ${accept.size} keys`); + console.log(` oracle: ${origin}`); + for (const { shape, keys, indexSignature } of shapes) { + console.log( + ` ${shape.id.padEnd(24)} ${String(keys.length).padStart(2)} declared [${shape.reach}]` + + (indexSignature ? " (+ index signature — see coverage note 2)" : "") + ); + } + if (uiOnly.length) { + console.log("\n UI-only keys (declared on no wire-bound shape, so out of reach of a PUT):"); + for (const u of uiOnly) console.log(` ${u.key} (${u.shape})`); + } + const ledgerKeys = Object.keys(KNOWN_UNPARSEABLE_KEYS); + if (ledgerKeys.length) { + console.log("\n Ledgered — refused, filed, resolution owned by its card:"); + for (const key of ledgerKeys) { + const e = KNOWN_UNPARSEABLE_KEYS[key]; + console.log(` ${key.padEnd(14)} ${e.card}` + (e.spec ? ` (spec spells it \`${e.spec}\`)` : " (no spec equivalent)")); + } + } + + if (staleLedger.length) { + console.error("\ndesigner-field-key-parity: STALE LEDGER ENTRIES\n"); + for (const s of staleLedger) { + console.error(` ${s.key} — ${s.reason}`); + } + console.error( + "\n Remove the entry. A ledger entry that no longer applies silently re-admits\n" + + " that spelling the next time someone declares it.\n" + ); + } + + if (violations.length) { + console.error("\ndesigner-field-key-parity: KEYS `FieldSchema` REFUSES BY NAME\n"); + // Grouped by KEY, not by site: one key declared on three shapes is one + // decision to make, and reading it three times obscures that. + const byKey = new Map(); + for (const v of violations) { + if (!byKey.has(v.key)) byKey.set(v.key, []); + byKey.get(v.key).push(v); + } + for (const [key, sites] of byKey) { + console.error(` ${key}`); + for (const v of sites) { + console.error(` declared on ${v.shape} (${v.file})`); + console.error(` written by ${v.writer}`); + } + } + console.error( + "\n Each of these makes `PUT /api/v1/meta/object/:name` return 422 INVALID_METADATA,\n" + + " which blocks EVERY subsequent save of the object until the key is cleared.\n" + + " The three prior instances took three different correct resolutions (retire the\n" + + " control, delete the declaration, move the producer upstream) — so file a card and\n" + + " record it in KNOWN_UNPARSEABLE_KEYS rather than picking one here.\n" + ); + } + + if (violations.length || staleLedger.length) process.exit(1); + console.log("\ndesigner-field-key-parity: OK"); +} + +if (resolve(process.argv[1] ?? "") === resolve(fileURLToPath(import.meta.url))) { + await main(); +}