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
26 changes: 26 additions & 0 deletions docs/audits/2026-06-react-blocks-conformance.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,29 @@ full config from the spec schema at render. So:
MANIFEST=/path/to/sdui.manifest.json pnpm --filter @objectstack/spec check:react-conformance
# add --strict to fail on divergence (once triaged).
```

## Ratchet (implemented)

Running this on every framework PR isn't worth it — the manifest only exists at
console-build time. So the conformance check is wired in as a **baseline ratchet**
at the one place the manifest is produced for free: `scripts/build-console.sh`,
right after it dumps `sdui.manifest.json` from the freshly-built console registry.

- The accepted state lives in `packages/spec/react-conformance.baseline.json`
(per block: the frontend-only prop set + whether the block is missing).
- `--baseline <path>` compares the current dump against it and reports **only
regressions**: a component exposing a NEW undocumented prop, or a
previously-present block vanishing. The soft `spec-only` signal is not gated.
- In `build-console.sh` it runs **warn-only** (never fails the console build).
Use `--strict` to gate intentionally (exit 1 on regression).

```
# accept the current frontend state as the new baseline (after an intentional change):
MANIFEST=/path/to/sdui.manifest.json \
pnpm --filter @objectstack/spec check:react-conformance \
--baseline react-conformance.baseline.json --update
```

When the ratchet flags a new frontend-only prop, the fix is one of: declare it in
the spec schema (`packages/spec/src/ui/*.zod.ts`), add it to the block overlay in
`packages/spec/src/ui/react-blocks.ts`, or accept it via `--update`.
38 changes: 38 additions & 0 deletions packages/spec/react-conformance.baseline.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"_comment": "Accepted spec↔frontend conformance baseline (react blocks). Per block: the frontend-only prop set (component exposes, spec does not declare) and whether the block is missing. Regenerate with: MANIFEST=… check:react-conformance --baseline <this> --update. The ratchet flags only NEW frontend-only props or newly-missing blocks.",
"blocks": {
"ObjectForm": {
"frontendOnly": [],
"missing": false
},
"ListView": {
"frontendOnly": [
"fields",
"options"
],
"missing": false
},
"ObjectChart": {
"frontendOnly": [
"data"
],
"missing": false
},
"RecordDetails": {
"frontendOnly": [],
"missing": false
},
"RecordHighlights": {
"frontendOnly": [],
"missing": false
},
"RecordRelatedList": {
"frontendOnly": [],
"missing": false
},
"RecordPath": {
"frontendOnly": [],
"missing": false
}
}
}
83 changes: 83 additions & 0 deletions packages/spec/scripts/check-react-blocks-conformance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,20 @@
// the html-tier gate).
//
// Run: MANIFEST=… pnpm --filter @objectstack/spec check:react-conformance
//
// Baseline ratchet (cheap CI posture). The full spec↔frontend divergence has an
// accepted baseline (some props are designer-palette-curated, some spec-only are
// soft). Running this on every PR is not worth it — the manifest only exists at
// console-build time. So we instead RATCHET at that point: store the accepted
// per-block frontend-only set, and warn/fail only on NEW divergence.
//
// --baseline <path> compare current state against a committed baseline and
// report only regressions (a block exposes a NEW
// undocumented prop, or a previously-present block vanished).
// --update with --baseline, (re)write the baseline from the current
// manifest instead of comparing. Run after an intentional
// frontend change to accept the new state.
// --strict exit 1 on divergence (plain mode) or regression (baseline).

process.env.OS_EAGER_SCHEMAS = '1';

Expand All @@ -26,6 +40,14 @@ import { REACT_BLOCKS } from '../src/ui/react-blocks';

const MANIFEST = process.env.MANIFEST;
const FAIL_ON_DIVERGENCE = process.argv.includes('--strict');
const UPDATE_BASELINE = process.argv.includes('--update');
function argValue(flag: string): string | undefined {
const i = process.argv.indexOf(flag);
if (i >= 0 && process.argv[i + 1] && !process.argv[i + 1].startsWith('--')) return process.argv[i + 1];
const inline = process.argv.find((a) => a.startsWith(`${flag}=`));
return inline ? inline.slice(flag.length + 1) : undefined;
}
const BASELINE = argValue('--baseline');

function specProps(schema: any): string[] {
try {
Expand Down Expand Up @@ -58,6 +80,12 @@ let totalSpecOnly = 0;
let totalMissingComp = 0;
const overlay = (b: (typeof REACT_BLOCKS)[number]) => new Set(b.interactions.map((i) => i.name));

// Per-block snapshot of the actionable signal we ratchet on: the frontend-only
// prop set (component exposes, spec does not declare) and whether the block is
// missing from the manifest entirely.
type BlockState = { frontendOnly: string[]; missing: boolean };
const current: Record<string, BlockState> = {};

console.log('# Spec ↔ frontend conformance (react blocks)\n');
for (const b of REACT_BLOCKS) {
if (!b.schema) continue;
Expand All @@ -66,6 +94,7 @@ for (const b of REACT_BLOCKS) {
if (inputs === null) {
console.log(`✗ <${b.tag}> (${b.schemaType}): NO component in the manifest — not registered or not public.`);
totalMissingComp++;
current[b.tag] = { frontendOnly: [], missing: true };
continue;
}
const inputSet = new Set(inputs);
Expand All @@ -74,12 +103,66 @@ for (const b of REACT_BLOCKS) {
const frontendOnly = [...inputSet].filter((p) => !spec.has(p) && !ov.has(p));
const matched = [...spec].filter((p) => inputSet.has(p));
totalSpecOnly += specOnly.length;
current[b.tag] = { frontendOnly: frontendOnly.slice().sort(), missing: false };
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.`);

// ── Baseline ratchet ─────────────────────────────────────────────────────────
if (BASELINE) {
type Baseline = { blocks: Record<string, BlockState> };
if (UPDATE_BASELINE) {
const out: Baseline = { blocks: current };
fs.writeFileSync(
BASELINE,
JSON.stringify(
{
_comment:
'Accepted spec↔frontend conformance baseline (react blocks). Per block: the frontend-only prop set (component exposes, spec does not declare) and whether the block is missing. Regenerate with: MANIFEST=… check:react-conformance --baseline <this> --update. The ratchet flags only NEW frontend-only props or newly-missing blocks.',
...out,
},
null,
2,
) + '\n',
'utf8',
);
console.log(`\n✓ wrote conformance baseline → ${BASELINE} (${Object.keys(current).length} blocks)`);
process.exit(0);
}

if (!fs.existsSync(BASELINE)) {
console.error(`\n✗ baseline not found: ${BASELINE} — generate it with --update first.`);
process.exit(FAIL_ON_DIVERGENCE ? 1 : 0);
}
const baseline: Baseline = JSON.parse(fs.readFileSync(BASELINE, 'utf8'));
const regressions: string[] = [];
for (const [tag, state] of Object.entries(current)) {
const base = baseline.blocks?.[tag];
const baseFO = new Set(base?.frontendOnly ?? []);
const newFO = state.frontendOnly.filter((p) => !baseFO.has(p));
if (newFO.length) regressions.push(`<${tag}>: new frontend-only prop(s) not in baseline: ${newFO.join(', ')}`);
if (state.missing && base && !base.missing) regressions.push(`<${tag}>: block vanished from the manifest (was present in baseline).`);
}
// A brand-new block in the registry that isn't in the baseline is fine (purely
// additive coverage); we only ratchet against accepted blocks regressing.
console.log('\n## Baseline ratchet');
if (!regressions.length) {
console.log('✓ no new divergence vs accepted baseline.');
process.exit(0);
}
console.log('⚠ NEW divergence vs accepted baseline:');
for (const r of regressions) console.log(` - ${r}`);
console.log(
'\n → If intentional (frontend added a prop / the spec is meant to follow), either declare it in the spec\n' +
' schema, add it to the block overlay in packages/spec/src/ui/react-blocks.ts, or accept it by\n' +
' rerunning with --update.',
);
process.exit(FAIL_ON_DIVERGENCE ? 1 : 0);
}

if (FAIL_ON_DIVERGENCE && (totalSpecOnly > 0 || totalMissingComp > 0)) {
console.error('Conformance check failed (--strict).');
process.exit(1);
Expand Down
13 changes: 13 additions & 0 deletions scripts/build-console.sh
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,19 @@ if [[ -f "$DUMP_PAGE" && -f "$DUMP_SCRIPT" ]]; then
for _ in $(seq 1 90); do curl -sf "http://localhost:5180/" > /dev/null 2>&1 && break; sleep 1; done
if BASE_URL="http://localhost:5180" OUT="${TARGET}/sdui.manifest.json" node scripts/dump-public-manifest.mjs; then
echo "✓ wrote ${TARGET}/sdui.manifest.json"
# ADR-0081: ratchet the spec↔frontend react-block conformance against the
# committed baseline while the freshly-dumped manifest is here for free. This
# is the ONLY place the manifest exists, so it is the cheapest place to catch
# NEW divergence (a component exposing an undocumented prop, or a block
# vanishing). Warn-only — never fails the console build; run check:react-conformance
# --strict locally to gate intentionally.
if [[ -f "${FRAMEWORK_ROOT}/packages/spec/react-conformance.baseline.json" ]]; then
echo "→ Ratcheting spec↔frontend react-block conformance (ADR-0081)..."
( cd "${FRAMEWORK_ROOT}" && MANIFEST="${TARGET}/sdui.manifest.json" \
pnpm --filter @objectstack/spec check:react-conformance \
--baseline react-conformance.baseline.json ) || \
echo "⚠ conformance ratchet reported new divergence (non-fatal) — see output above"
fi
else
echo "⚠ manifest generation failed — os-build JSX gate falls back to parse-level (non-fatal)"
fi
Expand Down