diff --git a/docs/audits/2026-06-react-blocks-conformance.md b/docs/audits/2026-06-react-blocks-conformance.md index 9ac5ef33ac..344e181856 100644 --- a/docs/audits/2026-06-react-blocks-conformance.md +++ b/docs/audits/2026-06-react-blocks-conformance.md @@ -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 ` 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`. diff --git a/packages/spec/react-conformance.baseline.json b/packages/spec/react-conformance.baseline.json new file mode 100644 index 0000000000..d8e9723283 --- /dev/null +++ b/packages/spec/react-conformance.baseline.json @@ -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 --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 + } + } +} diff --git a/packages/spec/scripts/check-react-blocks-conformance.ts b/packages/spec/scripts/check-react-blocks-conformance.ts index cedb9bbd6c..5e4d419e16 100644 --- a/packages/spec/scripts/check-react-blocks-conformance.ts +++ b/packages/spec/scripts/check-react-blocks-conformance.ts @@ -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 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'; @@ -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 { @@ -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 = {}; + console.log('# Spec ↔ frontend conformance (react blocks)\n'); for (const b of REACT_BLOCKS) { if (!b.schema) continue; @@ -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); @@ -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 }; + 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 --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); diff --git a/scripts/build-console.sh b/scripts/build-console.sh index 397996ada5..a11ba1ea2e 100755 --- a/scripts/build-console.sh +++ b/scripts/build-console.sh @@ -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