Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions docs/audits/2026-06-react-blocks-conformance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Spec ↔ frontend conformance — react blocks (2026-06)

**Question** (raised in review): we can't guarantee the frontend (objectui)
components actually implement the props the backend spec protocol declares —
should we confirm it?

**Answer**: confirmed — they diverge. Below is the first run of the conformance
check (`packages/spec/scripts/check-react-blocks-conformance.ts`), comparing the
spec schemas referenced by `REACT_BLOCKS` against the live objectui
registry-inputs manifest (`sdui.manifest.json`).

## How to read this

The registry `inputs` are the **designer palette** — a curated subset the visual
editor exposes — NOT the component's full prop surface. The component reads its
full config from the spec schema at render. So:

- **frontend-only** props (component declares an input the spec does not) are the
reliable, actionable divergence: the spec is missing them or they are an
undocumented extension. → fix the spec (or document the extension).
- **spec-only** props are a *softer* signal: mostly the palette being a subset of
the protocol (expected), not proof the component ignores the prop. A block with
**zero inputs** (the `record:*` family) declares no designer inputs at all.
- **matched** props appear in both.

## Findings (first run)

| block | matched | spec-only | frontend-only | notes |
|---|--:|--:|--:|---|
| `<ObjectForm>` (object-form) | 1 | 6 | **14** | frontend-only: formType, drawer*, modal*, split*, tab*, layout, columns, … — real component extensions absent from `FormViewSchema`. |
| `<ListView>` (list-view) | 1 | 44 | 2 | the palette exposes objectName/viewType/fields/filters/sort/options; the 44 `ListViewSchema` config props are read at render, not surfaced as inputs. |
| `<ObjectChart>` (object-chart) | 0 | 12 | 1 | the chart component's inputs (objectName/data/filter/aggregate) differ from `ChartConfigSchema` (title/series/axes/…). |
| `<RecordDetails>` (record:details) | 0 | 4 | 0 | **component declares zero inputs** — props live only in the spec. |
| `<RecordHighlights>` (record:highlights) | 0 | 2 | 0 | zero inputs. |
| `<RecordRelatedList>` (record:related_list) | 0 | 9 | 0 | zero inputs. |
| `<RecordPath>` (record:path) | 0 | 2 | 0 | zero inputs. |

**Summary**: 79 spec-only, 17 frontend-only, 0 blocks missing from the frontend.

## What this tells us (for an ADR)

1. There is **no single machine-readable "authoritative component prop surface"**
today: the spec is the protocol, the registry inputs are a designer palette
(subset), and the React prop types are the implementation. They are not kept in
lockstep by any test — which is exactly the gap this audit confirms.
2. **Recommendation**: treat the spec as the protocol source of truth; make the
registry inputs a faithful (documented) projection of it; add the genuine
**frontend-only** props (e.g. `object-form` formType/drawer*/modal*) to the
spec so the protocol covers what the component accepts; and run this check in
CI (with a console manifest dump) as a ratchet so new divergence is caught.
3. The `record:*` blocks declaring **zero inputs** means the visual designer can't
configure them — likely a real gap to close.

## Running it

```
# produce a manifest from the live registry (objectui), then:
MANIFEST=/path/to/sdui.manifest.json pnpm --filter @objectstack/spec check:react-conformance
# add --strict to fail on divergence (once triaged).
```
3 changes: 2 additions & 1 deletion packages/spec/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,8 @@
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"check:liveness": "tsx scripts/liveness/check-liveness.mts",
"gen:react-blocks": "tsx scripts/build-react-blocks-contract.ts"
"gen:react-blocks": "tsx scripts/build-react-blocks-contract.ts",
"check:react-conformance": "tsx scripts/check-react-blocks-conformance.ts"
},
"keywords": [
"objectstack",
Expand Down
86 changes: 86 additions & 0 deletions packages/spec/scripts/check-react-blocks-conformance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// Spec ↔ frontend conformance report (ADR-0081 follow-up). Confirms the
// objectui components ACTUALLY implement the props the spec protocol declares
// for each curated react block. The spec is the protocol; the frontend must
// conform. This surfaces (and can ratchet) the divergence.
//
// - spec-only : the spec schema declares a prop the component does NOT expose
// as a registry input → frontend hasn't implemented the protocol.
// - frontend-only: the component exposes an input the spec does NOT declare →
// undocumented extension (or the spec is behind).
//
// The frontend side is the objectui registry-inputs manifest (sdui.manifest.json,
// produced from the live registry — see objectui scripts/dump-public-manifest.mjs).
// Provide it with MANIFEST=/path/to/sdui.manifest.json. Without it, the check
// reports "manifest unavailable" and exits 0 (same manifest-optional posture as
// the html-tier gate).
//
// Run: MANIFEST=… pnpm --filter @objectstack/spec check:react-conformance

process.env.OS_EAGER_SCHEMAS = '1';

import fs from 'fs';
import { z } from 'zod';
import { REACT_BLOCKS } from '../src/ui/react-blocks';

const MANIFEST = process.env.MANIFEST;
const FAIL_ON_DIVERGENCE = process.argv.includes('--strict');

function specProps(schema: any): string[] {
try {
let js: any = z.toJSONSchema(schema, { unrepresentable: 'any' } as any);
if (js?.$ref && js?.$defs) js = js.$defs[String(js.$ref).split('/').pop()!] ?? js;
return Object.keys(js?.properties ?? {}).filter((k) => !['aria', 'type', 'id', 'className', 'style'].includes(k));
} catch {
return [];
}
}

function manifestInputs(manifest: any, schemaType: string): string[] | null {
const comps = manifest?.components ?? manifest ?? {};
// keys may be bare ('object-form') or namespaced ('plugin-form:object-form').
const entry =
comps[schemaType] ??
Object.entries(comps).find(([k]) => k === schemaType || k.endsWith(`:${schemaType}`))?.[1];
if (!entry) return null;
const inputs = (entry as any).inputs ?? [];
return inputs.map((i: any) => i?.name).filter(Boolean);
}

if (!MANIFEST || !fs.existsSync(MANIFEST)) {
console.log('⚠ react-blocks conformance: manifest unavailable (set MANIFEST=…) — skipping.');
process.exit(0);
}

const manifest = JSON.parse(fs.readFileSync(MANIFEST, 'utf8'));
let totalSpecOnly = 0;
let totalMissingComp = 0;
const overlay = (b: (typeof REACT_BLOCKS)[number]) => new Set(b.interactions.map((i) => i.name));

console.log('# Spec ↔ frontend conformance (react blocks)\n');
for (const b of REACT_BLOCKS) {
if (!b.schema) continue;
const spec = new Set(specProps(b.schema));
const inputs = manifestInputs(manifest, b.schemaType);
if (inputs === null) {
console.log(`✗ <${b.tag}> (${b.schemaType}): NO component in the manifest — not registered or not public.`);
totalMissingComp++;
continue;
}
const inputSet = new Set(inputs);
const ov = overlay(b);
const specOnly = [...spec].filter((p) => !inputSet.has(p) && !ov.has(p));
const frontendOnly = [...inputSet].filter((p) => !spec.has(p) && !ov.has(p));
const matched = [...spec].filter((p) => inputSet.has(p));
totalSpecOnly += specOnly.length;
const status = specOnly.length === 0 ? '✓' : '⚠';
console.log(`${status} <${b.tag}> (${b.schemaType}): ${matched.length} matched, ${specOnly.length} spec-only, ${frontendOnly.length} frontend-only`);
if (specOnly.length) console.log(` spec declares but component lacks: ${specOnly.join(', ')}`);
if (frontendOnly.length) console.log(` component exposes but spec lacks: ${frontendOnly.join(', ')}`);
}
console.log(`\nSummary: ${totalSpecOnly} spec-only divergences, ${totalMissingComp} blocks missing from the frontend.`);
if (FAIL_ON_DIVERGENCE && (totalSpecOnly > 0 || totalMissingComp > 0)) {
console.error('Conformance check failed (--strict).');
process.exit(1);
}