Skip to content

Commit 4e0f72e

Browse files
fix(spec): project the react-blocks contract through projectPublishedJsonSchema (#20304)
Fixes #19100 Clause-②: no ## What this does `packages/spec/scripts/build-react-blocks-contract.ts` read each block's spec schema through a bare `z.toJSONSchema(schema, { unrepresentable: 'any' })`. That call sat outside the published-projection choke point, so the refinement override that every published JSON Schema artifact carries was not applied to the schemas behind `skills/objectstack-ui/references/react-blocks.md`. The generator now projects through `projectPublishedJsonSchema(schema, { unrepresentable: 'any' })`. Its allowance row in `published-projection-choke-point.test.ts` and the prose naming it are removed. **Measured outcome: branch (A), byte-identical.** The shipped `react-blocks.md` is not in this diff, and no `skills/**` path is touched. ## Measurement (taken before any repair, on `3f86dc52f2`) ### The shipped file, regenerated with the package's own generator (`pnpm --filter @objectstack/spec gen:react-blocks`) ``` blob before: 573f51b blob after : 573f51b cmp before after: exit 0 $ git status --porcelain (after gen:react-blocks, generator edited) M packages/spec/scripts/build-react-blocks-contract.ts $ git diff --stat packages/spec/scripts/build-react-blocks-contract.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) ``` The shipped file has no hunk. `check:react-blocks` on the final tree: `1 generated files in sync with packages/spec`. ### JSON projection of the 3 spec-backed block schemas, bare call vs `projectPublishedJsonSchema` | block | schema | before | after | identical | |---|---|---|---|---| | ObjectForm | `FormViewSchema` | 17923 B, sha256 `caec4524119dbe87` | 17923 B, sha256 `caec4524119dbe87` | yes | | ListView | `ListViewSchema` | 93656 B, sha256 `7dad3d8b8b3dd51c` | 94666 B, sha256 `773bae792a7d435e` | **no** | | ObjectChart | `ChartConfigSchema` | 13619 B, sha256 `b1974a1cf703a528` | 13619 B, sha256 `b1974a1cf703a528` | yes | The fourth block (`Block`) has no spec schema; it is overlay only. **The divergent schema is `ListViewSchema`, at exactly two JSON paths:** - `properties/conditionalFormatting/items/properties/condition/anyOf/1` - `properties/bulkActionDefs/items/properties/visible/anyOf/1` Both are the object branch of `EvaluatedExpressionInputSchema`, which is `EvaluatedExpressionSchema`. The bare call dropped two declared refinements there: 1. `NON_BLANK_STRING` on `source` (`packages/spec/src/shared/expression.zod.ts:172`). Through the override it projects as `"minLength": 1, "pattern": "\\S"`. 2. `requiredOneOf(['source', 'ast'])` on `ExpressionSchema` (`expression.zod.ts:105`), which `EvaluatedExpressionSchema` inherits via `safeExtend`. Through the override it projects as `allOf: [{ anyOf: [{ required: ["source"] }, { required: ["ast"] }] }]`. ObjectForm and ObjectChart are identical because their projections reach no Expression envelope at all: zero `dialect` and zero `source` nodes, so the override has nothing to write. **Why the markdown does not move:** the generator keeps only the props named in the block's `dataProps` allow-list (`build-react-blocks-contract.ts:93`). ListView's list (`packages/spec/src/ui/react-blocks.ts:305`) is `type, data, columns, sort, searchableFields, userFilters, pagination, grouping, rowHeight, selection, rowActions, inlineEdit`. It names neither `conditionalFormatting` nor `bulkActionDefs`, so both divergent subtrees are filtered out before any row is rendered. So no sentence on the AI-facing page is wrong today. The change makes the generator project the way the other published producers do; it does not alter what the page says. Full ListView projection diff (only these hunks): ```diff @@ -1861,7 +1861,9 @@ (conditionalFormatting[].condition, object branch) "source": { - "type": "string" + "type": "string", + "minLength": 1, + "pattern": "\\S" }, @@ -1881,7 +1883,23 @@ - "additionalProperties": false + "additionalProperties": false, + "allOf": [ + { "anyOf": [ { "required": ["source"] }, { "required": ["ast"] } ] } + ] @@ -1933,7 +1951,9 @@ (bulkActionDefs[].visible, object branch: same two additions) @@ -1953,7 +1973,23 @@ ``` (The `allOf` body is shown compacted; the emitted JSON is the same structure, pretty-printed.) ## The diff - `packages/spec/scripts/build-react-blocks-contract.ts`: import `projectPublishedJsonSchema`, drop the `zod` import, route the one projection through the helper, and update the header comment. - `packages/spec/scripts/published-projection-choke-point.test.ts`: - remove the `build-react-blocks-contract.ts` row from `DECLARED_DIRECT_CALLS`, so the allowance goes from 3 rows to 2 (the choke point itself plus the representability probe); - rewrite the docblock that named it; - add the generator to `PUBLISHED_PRODUCERS`, so the pin also asserts that it imports the helper, the same hold `build-schemas.ts` and `build-openapi.ts` are under. - `packages/spec/scripts/lib/refinement-projection.ts`, **comment only**: the choke point's docblock listed this generator as a direct `z.toJSONSchema` caller outside the helper, which this change makes false. It is now producer 5 of 5, and the "outside" bullet names only the CLI's `os generate`. ## Governed-surface routing `node scripts/pm/check-governed-merges.mjs --branch claude/issue-19100-react-blocks-published-projection` gives exit 0: `0 of 3 path(s) hit the register ... NOT governed`, size 58 changed lines. No `skills/**` path is in the diff. About the generated-artifact exception: the same predicate, run with `skills/objectstack-ui/references/react-blocks.md` added to the list, gives exit 3, and the exception does **not** lift the path: `the tree under test modifies the generator this exception trusts — the path stays governed ... land the generator change and the artifact regeneration as separate PRs`. So if the bytes had changed, this PR would have been Tier H, with no exception route available. They did not change, so the question does not arise. ## Changeset None. `skip-changeset` applies because nothing here ships: `@objectstack/spec`'s `files[]` is `dist, json-schema, liveness, prompts, llms.txt, README.md, src/**/*.zod.ts, CHANGELOG.md, api-surface, spec-changes.json`, and `scripts/` is not in it. ## Verification (final tree `c64a07ed94`) - `pnpm --filter @objectstack/spec exec vitest run --project repo scripts/published-projection-choke-point.test.ts`: 6/6 passed, VERDICT command-exit 0. - `vitest run --project local` on `scripts/refinement-projection.test.ts` and `src/ui/react-blocks.test.ts`: 2 files, 90 tests passed. - `pnpm --filter @objectstack/spec typecheck` (`tsc --noEmit`, `check:scripts-typecheck`, `check:test-typecheck`): VERDICT command-exit 0. - `pnpm --filter @objectstack/spec test` (`--project local`): 551 files passed and 1 skipped; 16253 tests passed, 1 skipped, 1 todo; VERDICT command-exit 0. - `check:react-blocks`: exit 0, in sync. - Derived gate set (`node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands`, 61 commands): 56 exit 0, including `check:react-blocks`, `check:authorable-surface`, `check:liveness`, `check:pm-governed-merges`, `check:pm-dispatch-gates`, `check:nul-bytes`, `check:comment-mask-adoption` and `check:test-source-alias`. 5 are **NOT MEASURED** (exit 3, PREREQUISITE NOT MET): `check:dts-closure`, `check:dual-build-cjs-loads`, `check:lean-entry-closure`, `check:sourcemap-no-sources-content` and `check:type-check-debt`. Each needs a whole-workspace `dist/` build, which this diff cannot move, because no changed file ships. They are declared to CI. `dispatch-gates --ran` reconciliation: 61 derived, 56 run, 5 NOT-MEASURED (derived from the recorded exit 3), 0 unrun. - Targeted lint (`eslint --no-inline-config --format json` on the 3 changed files): 3 files linted, 0 errors, 0 warnings. The narrowing excludes nothing: the ESLint config never enables type-aware linting (no `parserOptions.project`, no typed rules, as stated at `eslint.config.mjs:326-328`), so this diff cannot change the verdict on any untouched file. The full `pnpm lint` run is CI's. - NOT MEASURED locally: the rest of the spec `repo` vitest project (32 other files). It is outside the package's `pnpm test` script, and my one local attempt was cut off by my own timeout after a 256 s lock wait. Its one file related to this diff, the choke-point pin, ran green on its own (above). ### Reverse verification (one-time, not kept as test files) Both runs used `node scripts/ablation-replace.mjs` against the committed tree `c64a07ed94`. Each restore was proven by blob equality with HEAD (`224f9f42639e`) and an empty `git diff HEAD`. 1. Replacing the helper call with the old bare call makes the pin go red as predicted: `no producer in scripts/ reaches z.toJSONSchema directly` fails with `"build-react-blocks-contract.ts: 1 direct call(s), declared 0"` (1 failed, 5 passed). 2. Deleting the helper import makes the pin go red as predicted: `every published producer imports the helper it is required to project through` fails on `build-react-blocks-contract.ts` (1 failed, 5 passed). ## Acceptance notes - ADR-0082 (line 39) describes the generator as reading the spec schemas through `z.toJSONSchema`. That is still true at the mechanism level, because the helper is the call that reaches it, so the ADR is not edited here. It is a governed surface and outside this card. - The ListView divergence is real but has no rendered effect today. If `conditionalFormatting` or `bulkActionDefs` ever join ListView's `dataProps`, the row type still renders from the top-level node (`object[]`), which the override does not change. - PR #20262 also regenerates one row of `react-blocks.md`. This PR does not touch that file, so the two cannot conflict. --- _Generated by [Claude Code](https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6ac33a5 commit 4e0f72e

3 files changed

Lines changed: 30 additions & 28 deletions

File tree

‎packages/spec/scripts/build-react-blocks-contract.ts‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22
//
33
// Generates the react-tier component contract from packages/spec/src/ui/
44
// react-blocks.ts: the `data` (config) props are read from each block's SPEC
5-
// zod schema via z.toJSONSchema (single source — no re-authoring); the
6-
// binding/controlled/callback props come from the hand-authored interaction
7-
// overlay. Emits ONE artifact:
5+
// zod schema via projectPublishedJsonSchema (single source — no re-authoring;
6+
// the same projection, refinement override included, that every published
7+
// JSON Schema artifact goes through); the binding/controlled/callback props
8+
// come from the hand-authored interaction overlay. Emits ONE artifact:
89
// - skills/objectstack-ui/references/react-blocks.md (AI-facing)
910
//
1011
// It used to emit a second, machine-readable rendering of the same table at
@@ -20,9 +21,9 @@
2021
process.env.OS_EAGER_SCHEMAS = '1';
2122

2223
import path from 'path';
23-
import { z } from 'zod';
2424
import { REACT_BLOCKS, type ReactInteractionProp } from '../src/ui/react-blocks';
2525
import { createSink } from './lib/generated-output';
26+
import { projectPublishedJsonSchema } from './lib/refinement-projection';
2627

2728
const REPO = path.resolve(__dirname, '../../..');
2829
const OUT_MD = path.join(REPO, 'skills/objectstack-ui/references/react-blocks.md');
@@ -72,7 +73,7 @@ interface Prop {
7273
function dataProps(schema: any, allow?: string[]): Prop[] {
7374
let js: any;
7475
try {
75-
js = resolveRoot(z.toJSONSchema(schema, { unrepresentable: 'any' } as any));
76+
js = resolveRoot(projectPublishedJsonSchema(schema, { unrepresentable: 'any' }));
7677
} catch {
7778
return [];
7879
}

‎packages/spec/scripts/lib/refinement-projection.ts‎

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -294,7 +294,7 @@ export interface ProjectionOverrideContext {
294294

295295
/**
296296
* ⭐ The ONE call through which `z.toJSONSchema` is reached anywhere
297-
* **`packages/spec` writes a published JSON Schema artifact**. Four producers,
297+
* **`packages/spec` writes a published JSON Schema artifact**. Five producers,
298298
* named because the claim is only worth as much as its enumeration:
299299
*
300300
* 1. `build-schemas.ts` — the generator's three attempts, writing
@@ -307,11 +307,16 @@ export interface ProjectionOverrideContext {
307307
* `json-schema/openapi.json`. That file ships in the tarball (`files[]`
308308
* carries `json-schema`) and is exported as `./openapi.json`, so it is a
309309
* published projection like any other; it reached this list late, and the
310-
* shape of the gap is the point — see below.
310+
* shape of the gap is the point — see below;
311+
* 5. `build-react-blocks-contract.ts` — the react-tier contract generator,
312+
* rendering its block schemas' projection as markdown prop tables into
313+
* the published skills catalog (`skills/objectstack-ui/references/
314+
* react-blocks.md`). Not a JSON Schema file, but a published description
315+
* of the same schemas, so it projects the way (1) does.
311316
*
312317
* ## ⛔ What is NOT behind it — stated so the next reader need not re-derive it
313318
*
314-
* This helper governs the projection CALL for the four producers above. It is
319+
* This helper governs the projection CALL for the five producers above. It is
315320
* ⛔ not a repo-wide guarantee, and three populations sit deliberately outside
316321
* it. Naming them is the difference between a claim and a slogan; each was
317322
* measured, not assumed:
@@ -323,12 +328,10 @@ export interface ProjectionOverrideContext {
323328
* question.
324329
* - **Producers outside `packages/spec`'s own artifacts** — the CLI's
325330
* `os generate` writes a JSON Schema of `ObjectStackDefinitionSchema` into
326-
* an author's project, and `build-react-blocks-contract.ts` renders block
327-
* prop tables into the published skills catalog. Both call
328-
* `z.toJSONSchema` directly, and the first was measured DIVERGENT from this
329-
* projection at seven declared sites. They are a separate decision about
330-
* how wide the published-projection guarantee reaches, ⛔ not an oversight
331-
* to be silently swept in here.
331+
* an author's project. It calls `z.toJSONSchema` directly and was measured
332+
* DIVERGENT from this projection at seven declared sites. That is a
333+
* separate decision about how wide the published-projection guarantee
334+
* reaches, ⛔ not an oversight to be silently swept in here.
332335
* - **Runtime derivations** (`packages/metadata-protocol`) project schemas to
333336
* SERVE them, not to publish an artifact; they are governed by their own
334337
* contracts.

‎packages/spec/scripts/published-projection-choke-point.test.ts‎

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -27,17 +27,14 @@
2727
*
2828
* ## Why an allowance table and not a flat zero
2929
*
30-
* Two files in this tree hold calls that are legitimately direct (two calls
31-
* in all), and each is a different reason rather than one exemption repeated:
30+
* Beside the choke point itself, one file in this tree holds a call that is
31+
* legitimately direct, for a reason of its own rather than an exemption:
3232
*
3333
* - `union-branch-projection.ts`'s `projectsUnderStrictMode` asks zod whether
3434
* a node is representable at all and DISCARDS the result — a yes/no
35-
* question, not a projection;
36-
* - `build-react-blocks-contract.ts` renders a markdown prop table into the
37-
* governed `skills/**` catalog — a published artifact, but not a published
38-
* JSON Schema, and rewriting it is a governed-surface decision of its own.
35+
* question, not a projection.
3936
*
40-
* Each is pinned at its exact count, so a SECOND call in any of those files
37+
* Each row is pinned at its exact count, so a SECOND call in either file
4138
* fails too, and the reason travels with the row.
4239
*
4340
* ## Why the controls are not decoration
@@ -113,15 +110,16 @@ const DECLARED_DIRECT_CALLS: ReadonlyArray<{ file: string; count: number; why: s
113110
count: 1,
114111
why: 'projectsUnderStrictMode asks zod whether a node is representable and DISCARDS the result; nothing it produces is published',
115112
},
116-
{
117-
file: 'build-react-blocks-contract.ts',
118-
count: 1,
119-
why: 'writes a markdown prop table into the governed skills catalog, not a JSON Schema artifact — measured DIVERGENT from this projection for 1 of its 3 block schemas, filed separately; routing it rewrites a skills/** file and is its own decision',
120-
},
121113
];
122114

123-
/** The producers whose output is published and must reach it through the helper. */
124-
const PUBLISHED_PRODUCERS = ['build-schemas.ts', 'build-openapi.ts'] as const;
115+
/**
116+
* The producers whose output is published and must reach it through the helper.
117+
* `build-react-blocks-contract.ts` renders its projection as markdown prop tables
118+
* into the published skills catalog rather than as a JSON Schema file, and is
119+
* held to the same projection so the two published descriptions of one schema
120+
* cannot disagree.
121+
*/
122+
const PUBLISHED_PRODUCERS = ['build-schemas.ts', 'build-openapi.ts', 'build-react-blocks-contract.ts'] as const;
125123

126124
describe('published projection choke point', () => {
127125
const sources = collectScriptSources();

0 commit comments

Comments
 (0)