Skip to content

Commit e6745bd

Browse files
os-zhuangclaude
andauthored
chore(spec): ratchet react-block spec↔frontend conformance at console-build time (ADR-0081) (#2485)
Wire the spec↔frontend react-block conformance check in as a baseline ratchet at the one place the registry-inputs manifest is produced for free — `scripts/build-console.sh`, right after it dumps `sdui.manifest.json`. Running it on every framework PR isn't worth it (the manifest only exists at console-build time); the ratchet catches NEW divergence there at near-zero marginal cost. - check-react-blocks-conformance.ts gains `--baseline <path>` (compare, report only regressions: a component exposing a NEW undocumented prop or a previously-present block vanishing) and `--update` (accept current as the new baseline). The soft spec-only signal is not gated. `--strict` exits 1 on regression. - packages/spec/react-conformance.baseline.json captures the accepted state (ListView frontend-only fields/options, ObjectChart data; rest clean after #2113/#2484). - build-console.sh runs the ratchet warn-only after the dump — never fails the console build, surfaces new divergence at the release point. - audit doc documents the ratchet + how to re-accept the baseline. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 6ee4f04 commit e6745bd

4 files changed

Lines changed: 160 additions & 0 deletions

File tree

docs/audits/2026-06-react-blocks-conformance.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,29 @@ full config from the spec schema at render. So:
5858
MANIFEST=/path/to/sdui.manifest.json pnpm --filter @objectstack/spec check:react-conformance
5959
# add --strict to fail on divergence (once triaged).
6060
```
61+
62+
## Ratchet (implemented)
63+
64+
Running this on every framework PR isn't worth it — the manifest only exists at
65+
console-build time. So the conformance check is wired in as a **baseline ratchet**
66+
at the one place the manifest is produced for free: `scripts/build-console.sh`,
67+
right after it dumps `sdui.manifest.json` from the freshly-built console registry.
68+
69+
- The accepted state lives in `packages/spec/react-conformance.baseline.json`
70+
(per block: the frontend-only prop set + whether the block is missing).
71+
- `--baseline <path>` compares the current dump against it and reports **only
72+
regressions**: a component exposing a NEW undocumented prop, or a
73+
previously-present block vanishing. The soft `spec-only` signal is not gated.
74+
- In `build-console.sh` it runs **warn-only** (never fails the console build).
75+
Use `--strict` to gate intentionally (exit 1 on regression).
76+
77+
```
78+
# accept the current frontend state as the new baseline (after an intentional change):
79+
MANIFEST=/path/to/sdui.manifest.json \
80+
pnpm --filter @objectstack/spec check:react-conformance \
81+
--baseline react-conformance.baseline.json --update
82+
```
83+
84+
When the ratchet flags a new frontend-only prop, the fix is one of: declare it in
85+
the spec schema (`packages/spec/src/ui/*.zod.ts`), add it to the block overlay in
86+
`packages/spec/src/ui/react-blocks.ts`, or accept it via `--update`.
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
{
2+
"_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.",
3+
"blocks": {
4+
"ObjectForm": {
5+
"frontendOnly": [],
6+
"missing": false
7+
},
8+
"ListView": {
9+
"frontendOnly": [
10+
"fields",
11+
"options"
12+
],
13+
"missing": false
14+
},
15+
"ObjectChart": {
16+
"frontendOnly": [
17+
"data"
18+
],
19+
"missing": false
20+
},
21+
"RecordDetails": {
22+
"frontendOnly": [],
23+
"missing": false
24+
},
25+
"RecordHighlights": {
26+
"frontendOnly": [],
27+
"missing": false
28+
},
29+
"RecordRelatedList": {
30+
"frontendOnly": [],
31+
"missing": false
32+
},
33+
"RecordPath": {
34+
"frontendOnly": [],
35+
"missing": false
36+
}
37+
}
38+
}

packages/spec/scripts/check-react-blocks-conformance.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,20 @@
1717
// the html-tier gate).
1818
//
1919
// Run: MANIFEST=… pnpm --filter @objectstack/spec check:react-conformance
20+
//
21+
// Baseline ratchet (cheap CI posture). The full spec↔frontend divergence has an
22+
// accepted baseline (some props are designer-palette-curated, some spec-only are
23+
// soft). Running this on every PR is not worth it — the manifest only exists at
24+
// console-build time. So we instead RATCHET at that point: store the accepted
25+
// per-block frontend-only set, and warn/fail only on NEW divergence.
26+
//
27+
// --baseline <path> compare current state against a committed baseline and
28+
// report only regressions (a block exposes a NEW
29+
// undocumented prop, or a previously-present block vanished).
30+
// --update with --baseline, (re)write the baseline from the current
31+
// manifest instead of comparing. Run after an intentional
32+
// frontend change to accept the new state.
33+
// --strict exit 1 on divergence (plain mode) or regression (baseline).
2034

2135
process.env.OS_EAGER_SCHEMAS = '1';
2236

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

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

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

83+
// Per-block snapshot of the actionable signal we ratchet on: the frontend-only
84+
// prop set (component exposes, spec does not declare) and whether the block is
85+
// missing from the manifest entirely.
86+
type BlockState = { frontendOnly: string[]; missing: boolean };
87+
const current: Record<string, BlockState> = {};
88+
6189
console.log('# Spec ↔ frontend conformance (react blocks)\n');
6290
for (const b of REACT_BLOCKS) {
6391
if (!b.schema) continue;
@@ -66,6 +94,7 @@ for (const b of REACT_BLOCKS) {
6694
if (inputs === null) {
6795
console.log(`✗ <${b.tag}> (${b.schemaType}): NO component in the manifest — not registered or not public.`);
6896
totalMissingComp++;
97+
current[b.tag] = { frontendOnly: [], missing: true };
6998
continue;
7099
}
71100
const inputSet = new Set(inputs);
@@ -74,12 +103,66 @@ for (const b of REACT_BLOCKS) {
74103
const frontendOnly = [...inputSet].filter((p) => !spec.has(p) && !ov.has(p));
75104
const matched = [...spec].filter((p) => inputSet.has(p));
76105
totalSpecOnly += specOnly.length;
106+
current[b.tag] = { frontendOnly: frontendOnly.slice().sort(), missing: false };
77107
const status = specOnly.length === 0 ? '✓' : '⚠';
78108
console.log(`${status} <${b.tag}> (${b.schemaType}): ${matched.length} matched, ${specOnly.length} spec-only, ${frontendOnly.length} frontend-only`);
79109
if (specOnly.length) console.log(` spec declares but component lacks: ${specOnly.join(', ')}`);
80110
if (frontendOnly.length) console.log(` component exposes but spec lacks: ${frontendOnly.join(', ')}`);
81111
}
82112
console.log(`\nSummary: ${totalSpecOnly} spec-only divergences, ${totalMissingComp} blocks missing from the frontend.`);
113+
114+
// ── Baseline ratchet ─────────────────────────────────────────────────────────
115+
if (BASELINE) {
116+
type Baseline = { blocks: Record<string, BlockState> };
117+
if (UPDATE_BASELINE) {
118+
const out: Baseline = { blocks: current };
119+
fs.writeFileSync(
120+
BASELINE,
121+
JSON.stringify(
122+
{
123+
_comment:
124+
'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.',
125+
...out,
126+
},
127+
null,
128+
2,
129+
) + '\n',
130+
'utf8',
131+
);
132+
console.log(`\n✓ wrote conformance baseline → ${BASELINE} (${Object.keys(current).length} blocks)`);
133+
process.exit(0);
134+
}
135+
136+
if (!fs.existsSync(BASELINE)) {
137+
console.error(`\n✗ baseline not found: ${BASELINE} — generate it with --update first.`);
138+
process.exit(FAIL_ON_DIVERGENCE ? 1 : 0);
139+
}
140+
const baseline: Baseline = JSON.parse(fs.readFileSync(BASELINE, 'utf8'));
141+
const regressions: string[] = [];
142+
for (const [tag, state] of Object.entries(current)) {
143+
const base = baseline.blocks?.[tag];
144+
const baseFO = new Set(base?.frontendOnly ?? []);
145+
const newFO = state.frontendOnly.filter((p) => !baseFO.has(p));
146+
if (newFO.length) regressions.push(`<${tag}>: new frontend-only prop(s) not in baseline: ${newFO.join(', ')}`);
147+
if (state.missing && base && !base.missing) regressions.push(`<${tag}>: block vanished from the manifest (was present in baseline).`);
148+
}
149+
// A brand-new block in the registry that isn't in the baseline is fine (purely
150+
// additive coverage); we only ratchet against accepted blocks regressing.
151+
console.log('\n## Baseline ratchet');
152+
if (!regressions.length) {
153+
console.log('✓ no new divergence vs accepted baseline.');
154+
process.exit(0);
155+
}
156+
console.log('⚠ NEW divergence vs accepted baseline:');
157+
for (const r of regressions) console.log(` - ${r}`);
158+
console.log(
159+
'\n → If intentional (frontend added a prop / the spec is meant to follow), either declare it in the spec\n' +
160+
' schema, add it to the block overlay in packages/spec/src/ui/react-blocks.ts, or accept it by\n' +
161+
' rerunning with --update.',
162+
);
163+
process.exit(FAIL_ON_DIVERGENCE ? 1 : 0);
164+
}
165+
83166
if (FAIL_ON_DIVERGENCE && (totalSpecOnly > 0 || totalMissingComp > 0)) {
84167
console.error('Conformance check failed (--strict).');
85168
process.exit(1);

scripts/build-console.sh

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

0 commit comments

Comments
 (0)