From c77331c9b4322f44b2d8a1896ba5c285248f9b45 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 18:40:02 +0000 Subject: [PATCH 1/4] wip(sdui-manifest): built-tree producer, re-keyed record, parity gate unwired Generator reads objectui's built tree at the pin; the npm-install default is gone. check-sdui-manifest re-keyed to source/modulesRoot/objectuiWorkspaceVersion. The react declaration-parity script entry and lint step are removed; the check:generated EXTERNAL_INPUT_REQUIRED bucket is emptied. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr --- .github/workflows/lint.yml | 15 +- ...082-react-component-contract-governance.md | 2 + packages/spec/package.json | 1 - .../scripts/check-generated-ledger.test.ts | 26 +- packages/spec/scripts/check-generated.ts | 43 +-- scripts/check-sdui-manifest.mjs | 140 ++++++---- scripts/gen-sdui-manifest-node.mjs | 250 +++++++++--------- scripts/sdui-manifest.record.json | 12 +- 8 files changed, 242 insertions(+), 247 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 7431c47e09..c168ee6d44 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -902,7 +902,8 @@ jobs: # is the thing that notices. Offline record checks only (existence, shape, # sha256 vs the provenance record, record pin vs `.objectui-sha`): the # regenerate-and-compare half runs where a regeneration can (the Node - # generator needs the published @object-ui set — see + # generator reads objectui's BUILT tree at the pin, which only + # `pnpm objectui:build` materialises — see # scripts/gen-sdui-manifest-node.mjs), and a pin bump goes red HERE until # it has been re-run — the same moment the lockstep step above forces the # parser parity re-verification, with an objectui checkout guaranteed in @@ -6712,18 +6713,6 @@ jobs: - name: Check docs YAML examples parse against live spec schemas run: pnpm --filter @objectstack/spec run check:yaml-examples - # ADR-0082 D4's spec↔registry react-block declaration-parity ratchet, fed - # by the checked-in repo-root manifest (#12924 execution point 1: the same - # artefact resolves this gate's EXTERNAL_INPUT_REQUIRED standing — the - # input it always lacked now lives in the tree, held fresh by the - # check-sdui-manifest step in the lint job). Baseline-ratcheted: only a - # divergence NEW since packages/spec/react-declaration-parity.baseline.json - # reds, so wiring it per-PR promotes the cut-rc-only run without - # manufacturing a standing red. Reads spec src/ through tsx like its - # neighbours — no dist. ~5 s. - - name: Ratchet spec-vs-registry react declaration parity against the checked-in manifest - run: MANIFEST="$PWD/sdui.manifest.json" pnpm --filter @objectstack/spec check:react-declaration-parity --baseline react-declaration-parity.baseline.json --strict - # Same anti-drift class as the gates above, for the generated translation # bundles in packages/platform-objects/src/apps/translations/. Nothing # regenerated them either, so they rotted three ways at once (#3670): diff --git a/docs/adr/0082-react-component-contract-governance.md b/docs/adr/0082-react-component-contract-governance.md index d650244c0f..8f441c0d4c 100644 --- a/docs/adr/0082-react-component-contract-governance.md +++ b/docs/adr/0082-react-component-contract-governance.md @@ -57,6 +57,8 @@ These are declared in the `react-blocks.ts` overlay with a `kind` of `binding`/` ### 4. Declaration parity is a build-time baseline ratchet, not a per-PR gate +> **Status: retired 2026-09-18 by maintainer ruling on #17735; the artefact stays for the CLI witness and the freshness gate.** + > **Corrected by #4472 — see addendum 1.** This decision was written and implemented as "conformance": the check was named `check:react-conformance`, and its script header claimed it confirmed the components "ACTUALLY implement" the spec props. It does not and never did — it compares **two declarations**, and it was **warn-only** besides. The mechanism below is real and kept; the words for it are now `check:react-declaration-parity`, and the gate now runs `--strict`. > > **Corrected by #5960 — see addendum 2.** This section shipped saying the ratchet runs *inside `build-console.sh`*. That was true when it was written and stopped being true at #4472, which moved it into `scripts/gen-sdui-manifest.sh`; the file names below are corrected in place, because a reader who followed this ADR opened the wrong file. #5960 also answers the question this decision left open — "not every PR" never said *when*, and the answer is **at the objectui pin bump**. diff --git a/packages/spec/package.json b/packages/spec/package.json index b1a0efc7a0..96c8bc1035 100644 --- a/packages/spec/package.json +++ b/packages/spec/package.json @@ -293,7 +293,6 @@ "check:strictness-ledger": "tsx scripts/check-strictness-ledger.mts", "gen:react-blocks": "tsx scripts/build-react-blocks-contract.ts", "check:react-blocks": "tsx scripts/build-react-blocks-contract.ts --check", - "check:react-declaration-parity": "tsx scripts/check-react-blocks-declaration-parity.ts", "check:skill-examples": "tsx scripts/check-skill-examples.ts --self-test && tsx scripts/check-skill-examples.ts", "check:yaml-examples": "tsx scripts/check-yaml-examples.ts --self-test && tsx scripts/check-yaml-examples.ts", "check:template-manifests": "tsx scripts/check-template-manifests.ts --self-test && tsx scripts/check-template-manifests.ts", diff --git a/packages/spec/scripts/check-generated-ledger.test.ts b/packages/spec/scripts/check-generated-ledger.test.ts index 48383f8bec..e7ba5a0b05 100644 --- a/packages/spec/scripts/check-generated-ledger.test.ts +++ b/packages/spec/scripts/check-generated-ledger.test.ts @@ -84,25 +84,17 @@ describe('check:generated --reconcile-only', () => { expect(output).toContain('1 explicit manual-only generators'); }); - it('files the gate whose input this repo cannot produce as EXTERNAL_INPUT_REQUIRED (#4690)', () => { - // NO_GENERATOR would say "runnable, deliberately not run here" — which is what - // `check:react-declaration-parity` said while it was wired into no workflow and - // skipping by default, i.e. while running nowhere at all. The classification has - // to carry the two facts a reader needs instead: WHICH input is missing, and WHO - // supplies it. + it('carries an EMPTY EXTERNAL_INPUT_REQUIRED bucket once its only gate retired (#4690, ADR-0082 D4)', () => { + // The bucket's one entry, `check:react-declaration-parity`, retired with + // ADR-0082 D4 (maintainer ruling on #17735). The script is gone from + // package.json, so the reconciliation would red on a surviving ledger row + // ("names `…`, which package.json no longer has") — this pins both halves + // where the reading is legible: no script, and the bucket reports zero. + expect(scripts['check:react-declaration-parity']).toBeUndefined(); const { status, output } = runReconcile(); expect(status, output).toBe(0); - expect(output).toContain('1 needing an external input'); - expect(output).toContain('cannot run here: check:react-declaration-parity'); - expect(output).toContain('MANIFEST'); - expect(output).toContain('scripts/gen-sdui-manifest.sh'); - // And the claim is not free: `runBy` must still invoke the gate. The - // reconciliation fails otherwise (a gate classified as "runs elsewhere" while - // running nowhere is the hole this category exists to make visible), so this - // asserts the same fact where the failure message is legible. - const runner = path.resolve(SPEC, '..', '..', 'scripts/gen-sdui-manifest.sh'); - expect(fs.existsSync(runner)).toBe(true); - expect(fs.readFileSync(runner, 'utf8')).toContain('check:react-declaration-parity'); + expect(output).toContain('0 needing an external input'); + expect(output).not.toContain('cannot run here:'); }); it('covers the test-layer typecheck gate and its writer (#5286)', () => { diff --git a/packages/spec/scripts/check-generated.ts b/packages/spec/scripts/check-generated.ts index ab3c2e9ad8..e78a500095 100644 --- a/packages/spec/scripts/check-generated.ts +++ b/packages/spec/scripts/check-generated.ts @@ -349,20 +349,21 @@ const NO_GENERATOR: ReadonlyArray<{ check: string; why: string }> = [ /** * Source audits THIS AGGREGATE cannot run, because each needs an input handed to * it on the command line and this aggregate hands none over. That, and only that, - * is the claim — ⛔ NOT that the input is unavailable here: for the entry below it - * is TRACKED in this repo and CI feeds it to the gate on every PR (that entry's - * `why` carries the reading). + * is the claim — ⛔ NOT that the input is unavailable here. * * A separate bucket from `NO_GENERATOR` because the two say different things to a * reader, and #4690 is what conflating them cost. `NO_GENERATOR` means "runnable * as it stands, deliberately not run in this aggregate — run it yourself and it * will answer". This one means "running it takes an input this aggregate does not * pass, so here is that input and who does pass it" — still runnable by hand, and - * the `why` says with what. Sitting in the first list, `check:react-declaration-parity` - * read as the former for the entire time it was the latter: it was wired into no - * workflow, and a manual run without `MANIFEST` printed a `⚠` and exited 0, so no - * path existed on which the gate could go red. Whoever read "deliberately not run" - * reasonably assumed someone, somewhere, was running it. + * the `why` says with what. A gate filed in the first list while it belonged in + * this one read as "someone, somewhere, runs it" while running nowhere, and no + * path existed on which it could go red. + * + * EMPTY since ADR-0082 D4's declaration-parity ratchet (`check:react-declaration-parity`, + * the bucket's only entry) was retired by maintainer ruling on #17735 (2026-09-18). + * The bucket and its reconciliation below stay: the next gate that needs an + * input this aggregate does not pass is filed here, not in `NO_GENERATOR`. * * Encoding WHY in the ledger follows EXPLICIT_GENERATORS (#5807/#5358): a * classification that records only a name is a classification the next reader has @@ -377,29 +378,7 @@ const EXTERNAL_INPUT_REQUIRED: ReadonlyArray<{ input: string; runBy: string; why: string; -}> = [ - { - check: 'check:react-declaration-parity', - input: 'MANIFEST= — objectui\'s registry-inputs dump', - runBy: 'scripts/gen-sdui-manifest.sh', - why: - 'compares the spec schema props against the registry-declared inputs (two declarations, no renderer: #4472). ' + - 'It reads its manifest from MANIFEST= and THIS AGGREGATE PASSES NONE — that, and only that, is what ' + - '"cannot run here" means for this entry. The input is NOT unavailable in the repo: since #13446 a dump is ' + - 'TRACKED at the repo root as sdui.manifest.json, and lint.yml runs the gate --strict against it on every PR ' + - '(MANIFEST="$PWD/sdui.manifest.json"), so the gate is neither unrun nor unrunnable — it is unrun BY THIS ' + - 'AGGREGATE. Nor does producing one require a browser: scripts/gen-sdui-manifest-node.mjs regenerates the ' + - 'tracked artefact under plain Node from the PUBLISHED @object-ui/* packages (the browser-only claim was ' + - 'measured false on 2026-08-29, re-measured 2026-08-30 against published 17.6.0, and reproduced ' + - 'byte-identically in review of #18608), and scripts/check-sdui-manifest.mjs holds artefact, record and pin ' + - 'together. `pnpm sdui:manifest` (runBy) is the other producer, and it does NOT build objectui: it REQUIRES ' + - 'a checkout already vendored at .cache/objectui- by `pnpm objectui:build`, exits 1 telling you to run ' + - 'that first (gen-sdui-manifest.sh 507-516), then serves that tree with a vite dev server and dumps the ' + - 'registry from a real browser, running this ratchet against THAT. ⚠️ The two producers read two ' + - 'different registries — published packages vs the pinned checkout\'s source — and do not agree today (#17735). ' + - 'Without a MANIFEST the gate exits 1 rather than skipping (#4690)', - }, -]; +}> = []; /** * Generators whose output NOTHING verifies. Recorded rather than ignored: each @@ -856,7 +835,7 @@ export function checkGenerated( // "deliberately not run" invites the reader to run it AS IT STANDS, which for // these does not work: each needs an input on the command line that this // aggregate does not pass. Say what that input is and who does pass it, so the - // reader can run it by hand — for the entry below the input is tracked here. + // reader can run it by hand. Prints nothing while the bucket is empty. if (EXTERNAL_INPUT_REQUIRED.length) { io.log(`Needs an input this aggregate does not pass (${EXTERNAL_INPUT_REQUIRED.length} source audit(s)):`); for (const e of EXTERNAL_INPUT_REQUIRED) { diff --git a/scripts/check-sdui-manifest.mjs b/scripts/check-sdui-manifest.mjs index dcd40184d1..b6484613ce 100644 --- a/scripts/check-sdui-manifest.mjs +++ b/scripts/check-sdui-manifest.mjs @@ -16,16 +16,23 @@ * * ## What rots here, and which check catches it * - * The artefact is a SYNC of objectui's public-tier registry (generated from the - * published `@object-ui/*` packages at the version `.objectui-sha` ships — see - * `scripts/gen-sdui-manifest-node.mjs`). Checked-in syncs rot; the ruling's - * words for the failure mode are 「同步一次就烂」. Offline, per-PR: + * The artefact is a SYNC of objectui's public-tier registry, generated from + * objectui's BUILT tree at the commit `.objectui-sha` pins + * (`.cache/objectui-/apps/console`, materialised by `pnpm objectui:build` + * — see `scripts/gen-sdui-manifest-node.mjs`, the one producer since ruling 丙 on + * #17735). Checked-in syncs rot; the ruling's words for the failure mode are + * 「同步一次就烂」. Offline, per-PR: * * 1. ABSENCE / SHAPE — the artefact exists at the repo root, parses, and * carries a non-empty `components` map whose entries agree with their * keys. An absent artefact silently reverts `validateJsxPages` to * parse-only (`resolveSduiManifest()` degrades by design), so absence - * here is the loudest failure, never a skip. Same for the record file. + * here is the loudest failure, never a skip. Same for the record file, + * whose fields are keyed to that one producer: `source` must read + * `built-tree`, and `modulesRoot` and `objectuiWorkspaceVersion` must be + * present. A record from the retired npm-install route (it carried + * `objectuiPackagesVersion` and no `source`) is RED here, never read as + * the same claim under an older name. * 2. TAMPER — sha256(artefact) equals the record. The manifest is * generator-owned; a hand edit is invisible to every consumer (the * resolver JSON.parses whatever is there), so the hash is the only @@ -36,39 +43,36 @@ * — the same moment `check-sdui-lockstep` already forces a parser parity * re-verification, and the moment an objectui checkout is guaranteed to * exist (the bump required one). - * 4. VERSION ↔ PIN CORRESPONDENCE — the record's `objectuiPackagesVersion` is - * the version `packages/core/package.json` DECLARES at the pinned commit. - * Checks 1-3 reach both fields and relate neither: the version was read - * as a presence check only, so the record could name pin C and version V - * with nothing asking whether V is C's version at all (#18611). The - * generator's own default does not write such a record — `--objectui-version` - * unset reads the version off the PIN and REFUSES when the pin cannot be - * read — but an explicit wrong version, a hand edit, or a record written - * before that default was fixed still produce one, and checks 1-3 stay + * 4. VERSION ↔ PIN CORRESPONDENCE — the record's `objectuiWorkspaceVersion` + * is the version `packages/core/package.json` DECLARES at the pinned + * commit. Checks 1-3 reach both fields and relate neither: the version is + * otherwise read as a presence check only, so the record could name pin C + * and version V with nothing asking whether V is C's version at all + * (#18611). The generator does not write such a record — it reads the + * version off the built tree AT the pin, and refuses a tree whose HEAD is + * not the pin — but a hand edit still produces one, and checks 1-3 stay * green throughout. * This is the only check here that needs an input from outside this tree, * so it is the only one that can report NOT CHECKED — see below. * * ## What this deliberately does NOT do, and where that risk is held * - * No per-PR regeneration: that would put an npm-registry network dependency - * inside a required lint job — the exact shape `check-sdui-lockstep`'s header - * declines, for the same reasons. Under an unchanged pin the published inputs - * are immutable, so content drift per-PR is not a live axis. The residual - * axis — this repo's OWN adapter (`manifestFromConfigs`) changing while the - * pin stands still — is held by `packages/sdui-parser`'s lockstep gate and - * unit suite, and by regeneration being byte-deterministic (measured: two runs - * from the same install, identical sha256), so the remedy this gate prints - * always converges. + * No per-PR regeneration: that would put an objectui clone and build (network, + * minutes) inside a required lint job — the shape `check-sdui-lockstep`'s + * header declines, for the same reasons. Under an unchanged pin the pinned + * commit's registry is immutable, so content drift per-PR is not a live axis. + * The residual axis — this repo's OWN adapter (`manifestFromConfigs`) changing + * while the pin stands still — is held by `packages/sdui-parser`'s lockstep + * gate and unit suite, and by regeneration being byte-deterministic (two runs + * over one built tree, identical bytes), so the remedy this gate prints always + * converges. * - * Check 4 does NOT prove that the published `@object-ui` tarballs at that - * version were BUILT from the pinned commit. They are not: objectui bumps its - * version only at release, so the version a mid-release commit declares names a - * tarball built from an earlier commit. That gap is a PRODUCER question - * (#17735 — which mechanism writes the artefact) and is not ruled here. What - * check 4 asserts is exactly the relation the record claims and nothing wider: - * the two fields it carries describe ONE objectui commit. The claim printed on - * success is kept as narrow as what ran. + * Check 4 relates two fields of the record; it does not re-run the producer. + * That the artefact describes the pinned SOURCE (rather than a tarball objectui + * published from an earlier commit under the same version string) is the + * producer's guarantee, not this gate's: the generator reads only the built + * tree at the pin (ruling 丙 on #17735). The claim printed on success is kept as + * narrow as what ran. * * Check 4's oracle is an objectui CHECKOUT, which a required lint job does not * have and must not clone (network in a required job — the shape declined @@ -120,6 +124,14 @@ const OBJECTUI_SIBLING = '../objectui'; */ const OBJECTUI_VERSION_FILE = 'packages/core/package.json'; +/** + * The record's fields, keyed to the one producer (`scripts/gen-sdui-manifest-node.mjs`, + * which reads objectui's built tree at the pin): absence of any is RED, and so is + * a `source` other than `RECORD_SOURCE`. + */ +const RECORD_FIELDS = ['objectuiSha', 'source', 'modulesRoot', 'objectuiWorkspaceVersion', 'sha256', 'components']; +const RECORD_SOURCE = 'built-tree'; + /** * The population this gate reads, declared for `scripts/pm/dispatch-gates.mjs`. * @@ -226,7 +238,7 @@ export function checkTree(root, { objectuiRoot, requireObjectui = false, onVersi if (!existsSync(artefactPath)) { problems.push( 'sdui.manifest.json is MISSING at the repo root. `resolveSduiManifest()` degrades to parse-only\n' + - ' silently, so this gate is the thing that notices. Regenerate: node scripts/gen-sdui-manifest-node.mjs', + ' silently, so this gate is the thing that notices. Regenerate: pnpm objectui:build && node scripts/gen-sdui-manifest-node.mjs', ); return problems; // every later check reads it } @@ -264,16 +276,25 @@ export function checkTree(root, { objectuiRoot, requireObjectui = false, onVersi problems.push(`scripts/sdui-manifest.record.json does not parse: ${e.message}`); return problems; } - for (const field of ['objectuiSha', 'objectuiPackagesVersion', 'sha256', 'components']) { + for (const field of RECORD_FIELDS) { if (record[field] === undefined) problems.push(`record is missing \`${field}\`.`); } - if (problems.length) return problems; + if (record.source !== undefined && record.source !== RECORD_SOURCE) { + problems.push(`record \`source\` is ${JSON.stringify(record.source)}, not ${JSON.stringify(RECORD_SOURCE)} — no producer in this repo writes that.`); + } + if (problems.length) { + problems.push( + ' The record is keyed to the one producer, which reads objectui\'s built tree at the pin. Regenerate:\n' + + ' pnpm objectui:build && node scripts/gen-sdui-manifest-node.mjs', + ); + return problems; + } const sha256 = createHash('sha256').update(raw).digest('hex'); if (sha256 !== record.sha256) { problems.push( `sdui.manifest.json sha256 ${sha256.slice(0, 12)}… does not match the record ${String(record.sha256).slice(0, 12)}…\n` + - ' The artefact is generator-owned — never hand-edit it. Regenerate: node scripts/gen-sdui-manifest-node.mjs', + ' The artefact is generator-owned — never hand-edit it. Regenerate: pnpm objectui:build && node scripts/gen-sdui-manifest-node.mjs', ); } if (keys.length !== record.components) { @@ -286,10 +307,7 @@ export function checkTree(root, { objectuiRoot, requireObjectui = false, onVersi } const pin = readFileSync(pinPath, 'utf8').trim(); - // Check 4's oracle, read once. It is asked about the LIVE pin, so when the pin - // has moved the version it yields is the one the regeneration must install — - // which is what the moved-pin remedy below has always asked the operator to go - // and read by hand. + // Check 4's oracle, read once, about the LIVE pin. const versionLeg = readPinnedDeclaredVersion(pin, { root, objectuiRoot }); onVersionLeg?.(versionLeg); @@ -297,12 +315,9 @@ export function checkTree(root, { objectuiRoot, requireObjectui = false, onVersi problems.push( `.objectui-sha has moved to ${pin.slice(0, 12)}… but sdui.manifest.json was generated at ${String(record.objectuiSha).slice(0, 12)}…\n` + ' A pin bump changes which registry the shipped console runs; the manifest must follow it (同步一次就烂 is\n' + - ' the failure mode this gate exists for). Regenerate against the new pin:\n' + - (versionLeg.status === 'resolved' - ? ` node scripts/gen-sdui-manifest-node.mjs --objectui-version ${versionLeg.version}\n` + - ` (${versionLeg.version} is what the new pin's ${OBJECTUI_VERSION_FILE} declares, read from ${versionLeg.where}.)` - : ' node scripts/gen-sdui-manifest-node.mjs --objectui-version {the @object-ui version the new pin ships}\n' + - " (read it from the objectui checkout's packages/core/package.json — the bump already required that checkout)."), + ' the failure mode this gate exists for). Build objectui at the new pin, then regenerate from that tree:\n' + + ' pnpm objectui:build && node scripts/gen-sdui-manifest-node.mjs\n' + + ` (the generator reads .cache/objectui-${pin.slice(0, 12)}/apps/console — it takes no version and no path).`, ); // The version leg is NOT reported on top of this one: with the pin moved, // the record describes a different commit by construction, so a second @@ -311,19 +326,17 @@ export function checkTree(root, { objectuiRoot, requireObjectui = false, onVersi } // ── Check 4 (#18611) ──────────────────────────────────────────────────── - if (versionLeg.status === 'resolved' && versionLeg.version !== record.objectuiPackagesVersion) { + if (versionLeg.status === 'resolved' && versionLeg.version !== record.objectuiWorkspaceVersion) { problems.push( - `the record's two objectui fields describe different commits: objectuiPackagesVersion is ${JSON.stringify(record.objectuiPackagesVersion)},\n` + + `the record's two objectui fields describe different commits: objectuiWorkspaceVersion is ${JSON.stringify(record.objectuiWorkspaceVersion)},\n` + ` but objectui ${pin.slice(0, 12)}… — the pin the record itself names — declares ${JSON.stringify(versionLeg.version)} in ${OBJECTUI_VERSION_FILE}\n` + - ` (read from ${versionLeg.where}). The manifest was generated from the published ${record.objectuiPackagesVersion} packages, so it\n` + - ' describes a registry this pin does not name. The generator reads this version from the PIN, never\n' + - ' from this record, so a disagreement came from outside that default: a version named explicitly on\n' + - ' some other run, a hand edit, or a record written before the default was fixed to read the pin.\n' + - ` Regenerate against the pin: node scripts/gen-sdui-manifest-node.mjs --objectui-version ${versionLeg.version}`, + ` (read from ${versionLeg.where}). The generator reads this version off the built tree AT the pin and refuses a\n` + + ' tree checked out anywhere else, so a disagreement came from outside it: a hand edit.\n' + + ' Regenerate against the pin: pnpm objectui:build && node scripts/gen-sdui-manifest-node.mjs', ); } else if (versionLeg.status === 'unsupported') { problems.push( - `cannot judge objectuiPackagesVersion against the pin, with an objectui checkout in hand: ${versionLeg.reason}\n` + + `cannot judge objectuiWorkspaceVersion against the pin, with an objectui checkout in hand: ${versionLeg.reason}\n` + ' An oracle that is present and cannot answer is a failure, never a skip.', ); } else if (versionLeg.status === 'unreachable' && requireObjectui) { @@ -374,6 +387,7 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'moved pin is RED': 1, 'empty components is RED': 1, 'missing record is RED': 1, + 'a record from the retired npm-install route is RED': 1, 'pin-declared version agreeing with the record passes': 1, 'pin-declared version disagreeing with the record is RED': 1, 'an unreachable objectui checkout is not a version verdict': 1, @@ -387,7 +401,7 @@ const SELF_TEST_BATTERIES = Object.freeze({ // the literal above, so the roster falls below this number; the table // cross-check in the floor block is the other half, and names WHICH label // collided. -const SELF_TEST_BATTERY_FLOOR = 11; +const SELF_TEST_BATTERY_FLOOR = 12; function selfTest() { const mk = (mutate, { pin = 'a'.repeat(40), version = '0.0.0-selftest' } = {}) => { @@ -404,7 +418,9 @@ function selfTest() { JSON.stringify( { objectuiSha: pin, - objectuiPackagesVersion: version, + source: RECORD_SOURCE, + modulesRoot: `.cache/objectui-${pin.slice(0, 12)}/apps/console`, + objectuiWorkspaceVersion: version, sha256: createHash('sha256').update(raw).digest('hex'), components: 1, }, @@ -467,6 +483,14 @@ function selfTest() { writeFileSync(join(r, 'scripts', 'sdui-manifest.record.json'), JSON.stringify(rec)); }), 1, blind], ['missing record is RED', mk((r) => rmSync(join(r, 'scripts', 'sdui-manifest.record.json'))), 1, blind], + // The re-keying (ruling 丙 on #17735): a record shaped by the retired npm + // route — `objectuiPackagesVersion`, no `source`, no `modulesRoot` — is not + // this producer's record under an older name, whatever its hash says. + ['a record from the retired npm-install route is RED', mk((r) => { + const p = join(r, 'scripts', 'sdui-manifest.record.json'); + const { source, modulesRoot, objectuiWorkspaceVersion, ...rest } = JSON.parse(readFileSync(p, 'utf8')); + writeFileSync(p, JSON.stringify({ ...rest, objectuiPackagesVersion: objectuiWorkspaceVersion }, null, 2)); + }), 1, blind], // ── Check 4: the version the PIN declares (#18611) ──────────────────── // @@ -581,7 +605,7 @@ function selfTest() { process.exit(1); } console.log( - `✓ check-sdui-manifest self-test: ${cases.length} cases behave (green passes; absence, tamper, moved pin, emptiness, ` + + `✓ check-sdui-manifest self-test: ${cases.length} cases behave (green passes; absence, tamper, moved pin, emptiness, a retired-route record, ` + 'a version the pin does not declare, and an oracle that cannot answer are RED; an absent oracle is reported, not scored).', ); @@ -619,12 +643,12 @@ if (isEntrypoint(import.meta.url)) { // line above be read as "fresh at the pin" in every sense. if (versionLeg?.status === 'resolved') { console.log( - ` @object-ui ${record.objectuiPackagesVersion} is the version objectui ${String(record.objectuiSha).slice(0, 12)}… declares ` + + ` @object-ui ${record.objectuiWorkspaceVersion} is the version objectui ${String(record.objectuiSha).slice(0, 12)}… declares ` + `in ${OBJECTUI_VERSION_FILE} (read from ${versionLeg.where}).`, ); } else { console.log( - ` NOT CHECKED — objectuiPackagesVersion ${JSON.stringify(record.objectuiPackagesVersion)} was not compared with what the pin declares: ` + + ` NOT CHECKED — objectuiWorkspaceVersion ${JSON.stringify(record.objectuiWorkspaceVersion)} was not compared with what the pin declares: ` + `${versionLeg?.reason ?? 'the version leg did not run.'}\n` + ' Re-run with an objectui checkout (OBJECTUI_ROOT=… or a ../objectui sibling); add --require-objectui to make this gap an exit 1.', ); diff --git a/scripts/gen-sdui-manifest-node.mjs b/scripts/gen-sdui-manifest-node.mjs index c9713d25f3..794c608361 100644 --- a/scripts/gen-sdui-manifest-node.mjs +++ b/scripts/gen-sdui-manifest-node.mjs @@ -3,11 +3,16 @@ /** * gen-sdui-manifest-node — regenerate the repo-root `sdui.manifest.json` from - * objectui's PUBLISHED registry packages, without a browser. + * objectui's registry AT THE PIN, read out of the pin's BUILT tree, without a + * browser. The one producer of the tracked artefact (ruling 丙 on #17735). * - * node scripts/gen-sdui-manifest-node.mjs # temp npm install (network); version from the PIN - * node scripts/gen-sdui-manifest-node.mjs --modules-root DIR # use a preinstalled node_modules parent - * node scripts/gen-sdui-manifest-node.mjs --objectui-version V # override the version to install + * pnpm objectui:build # build objectui at .objectui-sha into .cache/objectui-/ + * node scripts/gen-sdui-manifest-node.mjs + * + * No flags. The modules root is DERIVED from `.objectui-sha` — + * `.cache/objectui-/apps/console`, the directory `scripts/build-console.sh` + * materialises — and the version recorded is READ from the tree, so neither can + * be named by hand and disagree with the pin. * * ## What this produces, and from what * @@ -21,81 +26,60 @@ * the hoisted, lockstep-pinned copy of objectui's adapter * (`pnpm check:sdui-lockstep` holds the two copies byte-equal). * - * ## Why plain Node is a valid producer (the browser-only claim is expired) - * - * `packages/spec/CHANGELOG.md` records (twice, byte-identically) that only a - * real browser can enumerate the registry. Measured false on 2026-08-29 and - * re-measured on 2026-08-30 against published `@object-ui/*` 17.6.0: all 16 - * modules import under plain Node once `.css` imports resolve to an empty - * module — the ONLY failure without the hook is - * `ERR_UNKNOWN_FILE_EXTENSION .css` on plugin-dashboard/plugin-map, a loader - * limitation, not a browser API. 57 configs, 0 lazy stubs, ~4.5 s, no - * Playwright, no objectui build. Reproduced independently in review of #18608: - * the 17 published `@object-ui/*` 17.6.0 packages installed from npm, - * `sdui-parser` bundled, this generator run under plain Node — `sha256 - * 49211fee7792` / `git hash-object 78f870e42fe9`, `cmp` byte-identical to the - * tracked artefact. The browser route (`pnpm sdui:manifest`) still exists for - * operators holding an objectui checkout; both routes serialize the same - * registry through the same adapter. + * `apps/console/node_modules/@object-ui/` in the built tree carries every + * registry package as a workspace symlink whose `exports["."].import` is + * `./dist/index.js`, so stock resolution from that directory lands on the + * BUILT dist of the pinned SOURCE and keeps one `@object-ui/core` registry + * instance. The objectui repo root is NOT a valid modules root: its + * `node_modules/@object-ui/` holds none of the registry packages. * - * ⛔ Do not put an objectui issue number back on the 2026-08-29 measurement. - * The citation this paragraph used to carry resolves to nothing — 404 for both - * the issue and the PR at that number, while seven neighbouring objectui - * numbers answer 200 as controls, so the zero is that NUMBER, not the endpoint - * and not the token — and the readings above stand without one. #18608 dropped - * the same citation from the two sibling gate scripts (check-generated.ts, - * check-react-blocks-declaration-parity.ts) and left the same number-free - * tombstone there; this header is what both of them now send readers to for - * the readings. + * ## Why the built tree, and never an npm install * - * ## Versioning contract — ONE oracle, shared with the gate + * `.objectui-sha` pins a COMMIT. The `@object-ui` version that commit's + * `packages/core/package.json` declares names a tarball built from an EARLIER + * commit, because objectui bumps its version only at release — so installing + * "the version the pin declares" from npm described a registry up to one + * release behind the pin (measured on #17735: 18 of 57 components differed, + * every divergence landed in objectui inside that window). This script used to + * default to exactly that install; the default is deleted, and there is no + * other input route. * - * The manifest must describe the registry the SHIPPED console runs — i.e. the - * `@object-ui/*` version that `.objectui-sha` ships. This script installs that - * version from npm. Which version that is has exactly one source: - * `--objectui-version` when it is passed, otherwise what - * `packages/core/package.json` DECLARES at the pinned commit — read through - * `check-sdui-manifest.mjs`'s exported `readPinnedDeclaredVersion`, the SAME - * function its check 4 judges the written record with, imported rather than - * re-typed. + * ## Plain Node is a valid producer * - * ⛔ The version is NOT defaulted from `scripts/sdui-manifest.record.json`. - * That default is what this file was fixed for: `objectuiSha` below is re-read - * from the LIVE pin while `objectuiPackagesVersion` fell back to the value - * already in the record, so the two fields were read at two different moments. - * A regeneration after a pin bump therefore wrote the NEW pin under the OLD - * version string — a record whose two objectui fields describe two different - * commits, which check 4 reds. The person it reds is the one who followed the - * documented bump procedure, so the producer is where it is fixed. Two readers - * consulting two oracles is precisely where that drift came from; there is now - * one oracle and both read it. - * - * When the oracle cannot answer — no objectui checkout, or one that does not - * carry the pinned commit — this script REFUSES. An unread version is never - * converted into a version string (Route & surface ownership §3: prefer failing - * to falling back). Point `OBJECTUI_ROOT` at a checkout that carries the pin, - * or pass `--objectui-version` explicitly. + * The registry imports under plain Node once `.css` side-effect imports resolve + * to an empty module — the only failure without that hook is + * `ERR_UNKNOWN_FILE_EXTENSION .css` (plugin-dashboard, plugin-map), a loader + * limitation, not a browser API. 57 configs, 0 lazy stubs, a few seconds, no + * Playwright. Regeneration is byte-deterministic (two runs over one built tree + * `cmp` identical). * * ## Preconditions (all loud) * - * `packages/sdui-parser/dist` must exist (`pnpm --filter @objectstack/sdui-parser build`): - * the adapter is consumed exactly as production consumes it. Absence exits 1. + * - `packages/sdui-parser/dist` exists (`pnpm --filter @objectstack/sdui-parser build`): + * the adapter is consumed exactly as production consumes it. + * - `.cache/objectui-/` is a checkout AT the pin (its HEAD equals + * `.objectui-sha`), and it is BUILT: `apps/console/node_modules/@object-ui/core/dist/index.js` + * exists. Either missing ⇒ exit 1 naming `pnpm objectui:build`. + * + * The runner this spawns is written to a fresh temp dir and removed afterwards: + * nothing is written into the objectui build tree. */ import { execFileSync } from 'node:child_process'; import { createHash } from 'node:crypto'; -import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { pathToFileURL, fileURLToPath } from 'node:url'; -import { readPinnedDeclaredVersion } from './check-sdui-manifest.mjs'; - const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); const RECORD_PATH = join(ROOT, 'scripts', 'sdui-manifest.record.json'); const OUT_PATH = join(ROOT, 'sdui.manifest.json'); const PARSER_DIST = join(ROOT, 'packages', 'sdui-parser', 'dist', 'index.mjs'); +/** What the record's `source` says for every artefact this script writes. */ +const SOURCE = 'built-tree'; + /** The registration set, in objectui's `apps/console/dev/manifest-dump.tsx` order. */ const REGISTRY_MODULES = [ '@object-ui/components', @@ -121,9 +105,13 @@ function fail(msg) { process.exit(1); } -function arg(name) { - const i = process.argv.indexOf(name); - return i > -1 ? process.argv[i + 1] : undefined; +if (process.argv.length > 2) { + fail( + `takes no arguments (got: ${process.argv.slice(2).join(' ')}).\n` + + ' The modules root is derived from .objectui-sha and the version is read from the built tree;\n' + + ' the retired --modules-root / --objectui-version routes are what let the artefact describe a\n' + + ' registry the pin does not name.', + ); } if (!existsSync(PARSER_DIST)) { @@ -134,8 +122,46 @@ if (!existsSync(PARSER_DIST)) { } const pinPath = join(ROOT, '.objectui-sha'); -if (!existsSync(pinPath)) fail('.objectui-sha is missing — cannot record provenance.'); +if (!existsSync(pinPath)) fail('.objectui-sha is missing — cannot derive the objectui build tree.'); const pin = readFileSync(pinPath, 'utf8').trim(); +if (!/^[0-9a-f]{40}$/.test(pin)) fail(`.objectui-sha does not hold a 40-character commit sha (got ${JSON.stringify(pin)}).`); + +// Repo-relative, POSIX-separated: this string is what the record carries. +const BUILD_TREE_REL = `.cache/objectui-${pin.slice(0, 12)}`; +const MODULES_ROOT_REL = `${BUILD_TREE_REL}/apps/console`; +const BUILD_TREE = join(ROOT, ...BUILD_TREE_REL.split('/')); +const MODULES_ROOT = join(ROOT, ...MODULES_ROOT_REL.split('/')); +const CORE_PKG = join(MODULES_ROOT, 'node_modules', '@object-ui', 'core', 'package.json'); +const BUILT_SENTINEL = join(MODULES_ROOT, 'node_modules', '@object-ui', 'core', 'dist', 'index.js'); +const REMEDY = ' Run `pnpm objectui:build` first — it builds objectui at the pin into that directory.'; + +if (!existsSync(BUILD_TREE)) { + fail(`no objectui build tree at ${BUILD_TREE_REL}/ for pin ${pin.slice(0, 12)}….\n${REMEDY}`); +} +let head = ''; +try { + head = execFileSync('git', ['-C', BUILD_TREE, 'rev-parse', 'HEAD'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); +} catch (e) { + fail(`${BUILD_TREE_REL}/ is not a git checkout (${String(e?.stderr || e?.message || e).trim().split('\n')[0]}).\n${REMEDY}`); +} +if (head !== pin) { + fail(`${BUILD_TREE_REL}/ is checked out at ${head.slice(0, 12)}…, not at the pin ${pin.slice(0, 12)}….\n${REMEDY}`); +} +if (!existsSync(BUILT_SENTINEL)) { + fail( + `objectui at ${pin.slice(0, 12)}… is checked out but NOT BUILT: ${MODULES_ROOT_REL}/node_modules/@object-ui/core/dist/index.js is missing.\n${REMEDY}`, + ); +} + +let workspaceVersion; +try { + workspaceVersion = JSON.parse(readFileSync(CORE_PKG, 'utf8'))?.version; +} catch (e) { + fail(`cannot read ${MODULES_ROOT_REL}/node_modules/@object-ui/core/package.json: ${e.message}`); +} +if (typeof workspaceVersion !== 'string' || workspaceVersion === '') { + fail(`${MODULES_ROOT_REL}/node_modules/@object-ui/core/package.json declares no string \`version\`.`); +} let record = {}; if (existsSync(RECORD_PATH)) { @@ -146,59 +172,27 @@ if (existsSync(RECORD_PATH)) { } } -// The version this run installs AND records. `--objectui-version` wins; the -// default is what the PIN declares, through check 4's own oracle — never the -// version already sitting in the record (see the versioning contract above). -const explicitVersion = arg('--objectui-version'); -let version = explicitVersion; -let versionSource = '--objectui-version, as passed'; -if (!version) { - const leg = readPinnedDeclaredVersion(pin, { root: ROOT }); - if (leg.status !== 'resolved') { - fail( - `no --objectui-version, and the pinned commit's declared version could not be read: ${leg.reason}\n` + - ' ⛔ This script does NOT fall back to the version in scripts/sdui-manifest.record.json: after a pin\n' + - ' bump that re-records the NEW pin under the OLD version string, and check-sdui-manifest.mjs check 4\n' + - ' reds exactly that record. Either:\n' + - ` - point OBJECTUI_ROOT at an objectui checkout carrying ${pin.slice(0, 12)}…, or\n` + - ' - pass --objectui-version {the @object-ui version that pin ships} explicitly.', - ); - } - version = leg.version; - versionSource = `packages/core/package.json at objectui ${pin.slice(0, 12)}… (read from ${leg.where})`; -} - -let modulesRoot = arg('--modules-root'); -if (modulesRoot) { - if (!existsSync(join(modulesRoot, 'node_modules'))) { - fail(`--modules-root ${modulesRoot} has no node_modules/ — point it at a directory whose install carries the @object-ui set.`); - } -} else { - modulesRoot = mkdtempSync(join(tmpdir(), 'sdui-manifest-gen-')); - const deps = Object.fromEntries(REGISTRY_MODULES.concat('@object-ui/core').map((m) => [m, version])); - deps.react = '18.3.1'; - deps['react-dom'] = '18.3.1'; - writeFileSync( - join(modulesRoot, 'package.json'), - JSON.stringify({ name: 'sdui-manifest-gen', private: true, type: 'module', dependencies: deps }, null, 2), - ); - console.error(`→ installing @object-ui/* ${version} into ${modulesRoot} (npm, network)...`); - execFileSync('npm', ['install', '--no-audit', '--no-fund', '--loglevel=error'], { - cwd: modulesRoot, - stdio: ['ignore', 'inherit', 'inherit'], - }); -} - -// The one loader accommodation plain Node needs: `.css` side-effect imports -// (plugin-dashboard, plugin-map) resolve to an empty module. Everything else -// is stock resolution from the install above. +// The runner lives in its own temp dir, so bare specifiers are re-anchored to +// the modules root by the resolve hook below: resolution is exactly what it +// would be for a file sitting in `apps/console/`, and the build tree gets no +// file written into it. The one other accommodation plain Node needs: `.css` +// side-effect imports (plugin-dashboard, plugin-map) resolve to an empty module. +const runnerDir = mkdtempSync(join(tmpdir(), 'sdui-manifest-runner-')); +const runnerPath = join(runnerDir, 'sdui-manifest-runner.mjs'); +const runnerUrl = pathToFileURL(runnerPath).href; +const anchorUrl = pathToFileURL(join(MODULES_ROOT, 'package.json')).href; +const hooks = + `const RUNNER = ${JSON.stringify(runnerUrl)};\n` + + `const ANCHOR = ${JSON.stringify(anchorUrl)};\n` + + 'const BARE = (s) => !/^(\\.{0,2}\\/|[a-z][a-z0-9+.-]*:)/i.test(s);\n' + + 'export async function resolve(s, c, n) {\n' + + ' if (s.endsWith(".css")) return { url: "data:text/javascript,", shortCircuit: true };\n' + + ' if (c.parentURL === RUNNER && BARE(s)) return n(s, { ...c, parentURL: ANCHOR });\n' + + ' return n(s, c);\n' + + '}\n'; const runner = ` import { register } from 'node:module'; -register('data:text/javascript,' + encodeURIComponent( - 'export async function resolve(s, c, n) {' + - ' if (s.endsWith(".css")) return { url: "data:text/javascript,", shortCircuit: true };' + - ' return n(s, c);' + - '}'), import.meta.url); +register('data:text/javascript,' + encodeURIComponent(${JSON.stringify(hooks)}), import.meta.url); const MODULES = ${JSON.stringify(REGISTRY_MODULES)}; const failures = []; for (const m of MODULES) { @@ -219,10 +213,22 @@ if (!Object.keys(manifest.components).length) { console.error('manifestFromConfi // Same serialization as objectui's dump (JSON.stringify(manifest, null, 2), no trailing newline). process.stdout.write(JSON.stringify(manifest, null, 2)); `; -const runnerPath = join(modulesRoot, 'sdui-manifest-runner.mjs'); -writeFileSync(runnerPath, runner); -console.error(`→ enumerating the registry (${REGISTRY_MODULES.length} modules)...`); -const json = execFileSync(process.execPath, [runnerPath], { cwd: modulesRoot, maxBuffer: 64 * 1024 * 1024 }).toString(); + +let json; +let enumerationError; +try { + writeFileSync(runnerPath, runner); + console.error(`→ enumerating the registry (${REGISTRY_MODULES.length} modules) from ${MODULES_ROOT_REL}/...`); + json = execFileSync(process.execPath, [runnerPath], { cwd: MODULES_ROOT, maxBuffer: 64 * 1024 * 1024 }).toString(); +} catch (e) { + enumerationError = e; +} finally { + // Before any exit: `process.exit` inside the catch would skip this. + rmSync(runnerDir, { recursive: true, force: true }); +} +if (enumerationError) { + fail(`the registry enumeration failed (exit ${enumerationError?.status ?? '?'}); its stderr is above.`); +} const manifest = JSON.parse(json); const count = Object.keys(manifest.components).length; @@ -231,7 +237,9 @@ const sha256 = createHash('sha256').update(json).digest('hex'); const nextRecord = { '//': record['//'] ?? [], objectuiSha: pin, - objectuiPackagesVersion: version, + source: SOURCE, + modulesRoot: MODULES_ROOT_REL, + objectuiWorkspaceVersion: workspaceVersion, generator: 'scripts/gen-sdui-manifest-node.mjs', generatedAt: new Date().toISOString().slice(0, 10), sha256, @@ -240,6 +248,6 @@ const nextRecord = { writeFileSync(RECORD_PATH, JSON.stringify(nextRecord, null, 2) + '\n'); console.error(`✓ wrote sdui.manifest.json (${count} components, ${Buffer.byteLength(json)} bytes, sha256 ${sha256.slice(0, 12)}…)`); console.error( - `✓ re-recorded scripts/sdui-manifest.record.json at pin ${pin.slice(0, 12)} / @object-ui ${version}\n` + - ` (version from ${versionSource})`, + `✓ re-recorded scripts/sdui-manifest.record.json at pin ${pin.slice(0, 12)} — ${SOURCE} ${MODULES_ROOT_REL}, ` + + `@object-ui/core workspace version ${workspaceVersion}`, ); diff --git a/scripts/sdui-manifest.record.json b/scripts/sdui-manifest.record.json index 080c31e0a5..1f7f4a0d9b 100644 --- a/scripts/sdui-manifest.record.json +++ b/scripts/sdui-manifest.record.json @@ -1,13 +1,15 @@ { "//": [ "Provenance record for the repo-root `sdui.manifest.json` (ADR-0080; #12924 wiring ruling 2026-08-29).", - "The artefact is objectui's public-tier component manifest, generated from the PUBLISHED", - "@object-ui/* packages at the version `.objectui-sha` ships, via scripts/gen-sdui-manifest-node.mjs", - "(same enumeration as objectui's apps/console/dev/manifest-dump.tsx: eager-import the 16 registry", - "modules, ComponentRegistry.getPublicConfigs(), manifestFromConfigs from @objectstack/sdui-parser).", + "The artefact is objectui's public-tier component manifest, generated from objectui's BUILT tree at the", + "commit `.objectui-sha` pins (`modulesRoot` below, materialised by `pnpm objectui:build`) by", + "scripts/gen-sdui-manifest-node.mjs, the one producer (ruling 丙 on #17735). Same enumeration as objectui's", + "apps/console/dev/manifest-dump.tsx: eager-import the 16 registry modules, ComponentRegistry.getPublicConfigs(),", + "manifestFromConfigs from @objectstack/sdui-parser. `objectuiWorkspaceVersion` is read from that tree's", + "@object-ui/core/package.json — the pinned commit's own declaration, never an npm tarball's.", "`scripts/check-sdui-manifest.mjs` holds this record equal to the artefact and to the live pin:", "a pin bump without regeneration goes RED there — that is the anti-rot half of the freshness gate.", - "Regenerate + re-record: node scripts/gen-sdui-manifest-node.mjs (see its header for the offline mode)." + "Regenerate + re-record: pnpm objectui:build && node scripts/gen-sdui-manifest-node.mjs" ], "objectuiSha": "62597c588072636e9c30ea35b3d89b1e46fd765d", "objectuiPackagesVersion": "17.6.0", From f211ae3bfe7c21564c0da00f5deed5a5b8ad2ee2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 18:52:38 +0000 Subject: [PATCH 2/4] wip(sdui-manifest): retire the parity ratchet and the browser dump; console ships the tracked manifest The node generator's output over objectui's built tree at the pin and the browser dump over the same tree are cmp-identical, so the dump retires: build-console.sh copies the tracked sdui.manifest.json into the console dist. The declaration-parity gate, its test, baseline and helper are deleted, with the lint and cut-rc steps. The manifest is regenerated at the pin and the JSX witness ledger drops its three rows that went stale with it. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr --- .claude/hooks/guard-process-kill.selftest.sh | 4 +- .claude/hooks/guard-process-kill.sh | 6 +- .github/workflows/cut-rc.yml | 41 +- AGENTS.md | 24 +- .../2026-06-react-blocks-conformance.md | 5 + docs/releases-maintenance.md | 143 ++-- package.json | 1 - packages/lint/src/sdui-jsx-baseline.json | 15 +- .../react-declaration-parity.baseline.json | 56 -- .../scripts/build-schemas-check-mode.test.ts | 3 +- ...ck-react-blocks-declaration-parity.test.ts | 624 ---------------- .../check-react-blocks-declaration-parity.ts | 518 ------------- .../scripts/gen-sdui-manifest-cleanup.test.ts | 240 ------ .../gen-sdui-manifest-collision.test.ts | 459 ------------ .../gen-sdui-manifest-write-target.test.ts | 238 ------ .../spec/scripts/manifest-prescription.ts | 111 --- .../published-projection-choke-point.test.ts | 19 +- packages/spec/src/ui/react-blocks.ts | 3 +- packages/spec/vitest.repo-tests.json | 4 - scripts/build-console.sh | 56 +- scripts/bump-objectui.sh | 77 +- scripts/check-pnpm-filter-targets.mjs | 6 +- scripts/check-sdui-lockstep.mjs | 6 +- scripts/cross-package-test-inputs.mjs | 1 - scripts/gen-sdui-manifest.sh | 689 ------------------ scripts/objectui-changeset-digest.mjs | 25 +- scripts/pm/os-verify-lock.sh | 5 +- scripts/pnpm-filter-targets.mjs | 4 +- scripts/publish-smoke.sh | 11 +- scripts/sdui-manifest.record.json | 8 +- sdui.manifest.json | 308 +++++++- turbo.json | 1 - 32 files changed, 468 insertions(+), 3243 deletions(-) delete mode 100644 packages/spec/react-declaration-parity.baseline.json delete mode 100644 packages/spec/scripts/check-react-blocks-declaration-parity.test.ts delete mode 100644 packages/spec/scripts/check-react-blocks-declaration-parity.ts delete mode 100644 packages/spec/scripts/gen-sdui-manifest-cleanup.test.ts delete mode 100644 packages/spec/scripts/gen-sdui-manifest-collision.test.ts delete mode 100644 packages/spec/scripts/gen-sdui-manifest-write-target.test.ts delete mode 100644 packages/spec/scripts/manifest-prescription.ts delete mode 100755 scripts/gen-sdui-manifest.sh diff --git a/.claude/hooks/guard-process-kill.selftest.sh b/.claude/hooks/guard-process-kill.selftest.sh index 42729cb262..34983a6ce4 100755 --- a/.claude/hooks/guard-process-kill.selftest.sh +++ b/.claude/hooks/guard-process-kill.selftest.sh @@ -124,8 +124,8 @@ echo "== the teardown AGENTS.md itself prescribes: the port is one YOU picked == expect allow 'kill $(lsof -ti tcp:38421)' expect allow 'kill $(lsof -ti tcp:3000) 2>/dev/null' -echo "== pgrep -P / -s select by a handle the caller owns — live in two tracked scripts ==" -# scripts/publish-smoke.sh kill_tree() and scripts/gen-sdui-manifest.sh sdui_live_pids(). +echo "== pgrep -P / -s select by a handle the caller owns — legitimate teardown shapes ==" +# scripts/publish-smoke.sh kill_tree() uses -P; -s was the retired gen-sdui-manifest.sh's. # A rule that reddened these would be routed around within the hour. expect allow 'for child in $(pgrep -P "$pid"); do kill "$child"; done' expect allow 'pgrep -s "$leader" | xargs kill' diff --git a/.claude/hooks/guard-process-kill.sh b/.claude/hooks/guard-process-kill.sh index 6f2a09927a..561a0e3d88 100755 --- a/.claude/hooks/guard-process-kill.sh +++ b/.claude/hooks/guard-process-kill.sh @@ -53,8 +53,8 @@ # you picked, not a name # pgrep -P "$pid" / pgrep -s "$leader" — even piped into a kill: the selector is a parent # or session handle the caller owns, and both are -# live in scripts/publish-smoke.sh and -# scripts/gen-sdui-manifest.sh +# legitimate teardown shapes (scripts/publish-smoke.sh +# uses the first) # pkill -P "$pid" / pkill -s "$sid" — the same selectors on pkill, with NO pattern # operand. Add a pattern and it is blocked again. # pgrep -f foo / ps aux | grep node — a READ. Nothing dies; look all you like. But a @@ -243,7 +243,7 @@ check_killall() { # --- does this segment select processes by NAME? (pgrep with a bare pattern operand) ----- # Returns 0 = yes, this is a name-pattern selector. `pgrep -s "$leader"` and # `pgrep -P "$pid"` consume their argument and leave no operand behind, so they answer 1 — -# which is what keeps scripts/publish-smoke.sh and scripts/gen-sdui-manifest.sh legal. +# which is what keeps scripts/publish-smoke.sh's `pgrep -P` teardown legal. is_name_selector() { [ "$HEAD" = "pgrep" ] || return 1 local n=${#W[@]} i=$((HEAD_I + 1)) diff --git a/.github/workflows/cut-rc.yml b/.github/workflows/cut-rc.yml index 791d112a0a..c54cdc11c4 100644 --- a/.github/workflows/cut-rc.yml +++ b/.github/workflows/cut-rc.yml @@ -405,42 +405,11 @@ jobs: - name: Verify the Console dist stamp matches the pin run: pnpm check:console-sha - # ──────────────────────────────────────────────────────────────────────── - # ADR-0082 D4 declaration-parity ratchet — the mandatory SECOND HALF of - # every pin move (#5960), run here against the pin as COMMITTED. - # - # ⚠️ Since #10134 this lane no longer moves the pin, so this is no longer - # "the second half of a move this run performed" — the move, and the - # ratchet that belongs to it, happen on the manual bump PR. Kept anyway, - # and deliberately: the ratchet is an on-demand gate by decision (ADR-0082 - # addendum), which means "unrun" is its live failure mode, and a bump PR - # that skipped it leaves a divergence that reaches a release unnoticed. - # This is the last place before publish that can still say so. It writes to - # a gitignored path and asserts against the committed baseline, so it adds - # no file to the version commit; on a pin that already ratcheted it simply - # passes. - # - # Installing a Playwright browser is fine HERE. The #5960 ruling that keeps - # the ratchet off CI is about not putting an objectui build plus a browser - # download on every matching PR; this workflow runs a handful of times a - # month, on purpose, and has already built the console two steps above. - # ──────────────────────────────────────────────────────────────────────── - - name: Install a Playwright browser for the manifest dump - run: | - # The dump runs INSIDE objectui's build tree (gen-sdui-manifest.sh does - # `pushd $BUILD_ROOT` and drives objectui's own playwright), so the - # browser must be installed against THAT workspace. `pnpm exec - # playwright` from the framework root resolves nothing — playwright is - # not a framework dependency. - BUILD_ROOT=".cache/objectui-${OBJECTUI_SHA:0:12}" - if [ ! -d "$BUILD_ROOT" ]; then - echo "::error::expected objectui build tree at ${BUILD_ROOT} (created by 'pnpm objectui:build'). Cannot install the browser the ratchet needs." - exit 1 - fi - pnpm --dir "$BUILD_ROOT" exec playwright install chromium-headless-shell - - - name: 'Declaration-parity ratchet at the committed pin (ADR-0082 D4)' - run: pnpm sdui:manifest + # No SDUI manifest step follows. The console dist built above already + # carries the TRACKED repo-root sdui.manifest.json (build-console.sh copies + # it), whose freshness against the pin the required lint job holds on every + # PR; the ADR-0082 D4 declaration-parity ratchet that used to run here, and + # the Playwright browser it needed, are retired. # ──────────────────────────────────────────────────────────────────────── # PRE-VERSION GATES. Every one of these reads `.changeset/*`, so all of them diff --git a/AGENTS.md b/AGENTS.md index b2721b2cc4..f6d9a32e04 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -155,11 +155,11 @@ pull its build into `packages/console/`. Other scripts: `objectui:bump` (pull only), `objectui:build`, `objectui:clean`. ⛔ Never hand-edit `packages/console/dist/` or `.cache/objectui-*/` — regenerated. -**Moving the pin has a second half: regenerate the committed manifest** — `node scripts/gen-sdui-manifest-node.mjs ---objectui-version {the @object-ui version the new pin ships}`; `scripts/check-sdui-manifest.mjs` reds until you do. -ADR-0082 D4's spec↔registry declaration-parity ratchet reads that tracked artefact, so it gates **every PR**, not a -pin bump alone. `pnpm sdui:manifest` is the separate browser dump of objectui's own registry (needs Playwright -chromium); `objectui:bump` and `objectui:refresh` print it. Full procedure: `docs/releases-maintenance.md`. +**Moving the pin has a second half: regenerate the committed manifest** — `pnpm objectui:build`, then +`node scripts/gen-sdui-manifest-node.mjs`, which reads objectui's BUILT tree at the pin (never an npm install, whose +version string names an older commit) and takes no arguments; `scripts/check-sdui-manifest.mjs` reds until you do. +`objectui:bump` and `objectui:refresh` print the step, and the console build ships that same tracked file in +`packages/console/dist/`. Full procedure: `docs/releases-maintenance.md`. **Fast iteration on `../objectui` src (no commit/refresh loop):** run objectui's own console dev server — `cd ../objectui && pnpm --filter @object-ui/console dev` (Vite on **:5180**, HMR). Its `/api` proxy targets @@ -755,18 +755,6 @@ Principles the wrapper encodes (its own output is the authority on detail): a name, with accepted cases in the shrink-only, hand-edited `dual-source-exports.baseline.json`. -**`check:react-declaration-parity` compares two DECLARATIONS, not a declaration against an -implementation** — the props the spec zod schema declares vs the inputs the objectui -registry config declares. Its `spec-only` / `registry-only` / `missing` signals are real; -just don't read it as proof anything renders. Its right-hand side is the **tracked -repo-root `sdui.manifest.json`** and its record `scripts/sdui-manifest.record.json`, which -the required lint job's `scripts/check-sdui-manifest.mjs` checks OFFLINE only — existence, -shape, sha256 vs the record, record pin vs `.objectui-sha`; its version-vs-pin leg runs -only where an objectui checkout is in hand, so lint prints `NOT CHECKED` by design. -`lint.yml` runs THIS gate `--strict` against it on every PR; it still **exits 1** with no -usable manifest and `check:generated` files it `EXTERNAL_INPUT_REQUIRED` because that -aggregate hands it none. ⛔ Do not "fix" a red by re-adding a skip. - Two generators have **no** gate at all — `gen:openapi` and `gen:sbom`. Nothing verifies their output is current; the wrapper reports that each run rather than staying silent. @@ -1100,7 +1088,7 @@ Both non-handshake shapes, and how to classify and probe your own: so a removal or rename the pinned sibling still imports turns `main` red for every PR in the repo the moment it merges — "retire the surface" and "leave the sibling untouched" cannot both hold. A ruling that authorizes such a removal therefore implicitly authorizes the objectui-side fix and the pin bump as part of the same landing (the - bump's `sdui:manifest` second half included — see the Frontend section). Pre-merge check for any removal or rename + bump's manifest-regeneration second half included — see the Frontend section). Pre-merge check for any removal or rename of an exported surface: does the pinned sibling import what you are removing? `git grep` it in `../objectui` at the pinned SHA before merging. 5. **Touched `packages/spec`? Regenerate and commit its artifacts before pushing** — § *Touched `packages/spec`* diff --git a/docs/audits/2026-06-react-blocks-conformance.md b/docs/audits/2026-06-react-blocks-conformance.md index dde9ccf3b8..5c795bf1ad 100644 --- a/docs/audits/2026-06-react-blocks-conformance.md +++ b/docs/audits/2026-06-react-blocks-conformance.md @@ -20,6 +20,11 @@ > "frontend-only" as "the registry declared an input the spec did not", not as > "the component accepts it". Evidence about the render path comes from > objectui's `public-block-binding-reach.test.tsx`; see the ADR-0082 addendum. +> +> **Retired (2026-09-18, maintainer ruling on #17735).** `check:react-declaration-parity`, +> its script, test and baseline, and the `pnpm sdui:manifest` browser dump no longer +> exist; the commands below are a record of how this audit ran, not something to +> run. The status line on ADR-0082 decision 4 carries the ruling. **Question** (raised in review): we can't guarantee the frontend (objectui) components actually implement the props the backend spec protocol declares — diff --git a/docs/releases-maintenance.md b/docs/releases-maintenance.md index 59980f23b9..563195acb2 100644 --- a/docs/releases-maintenance.md +++ b/docs/releases-maintenance.md @@ -73,9 +73,8 @@ commit, and this script stays usable offline. It never fetches for you — it pr the `git fetch origin main` you may want. The hard gate lives at the chokepoint that must fail closed: `.github/workflows/cut-rc.yml` re-asks the same question against a fresh full clone and **refuses to cut** (#9450). Until an RC is cut, -though, a bad pin merges and `pnpm sdui:manifest` below would ratchet -spec↔registry parity against a tree that is not on main — which is why the -producer half warns at all. +though, a bad pin merges and the committed SDUI manifest below is regenerated +against a tree that is not on main — which is why the producer half warns at all. #### A commit object it cannot read is refused, not warned about (#10797) @@ -108,98 +107,49 @@ job) pins this: it asserts the file's **bytes** across a refused run, because th broken and the fixed script both exit non-zero here and only the file tells them apart. -#### After the pin moves: regenerate the committed manifest (#5960, #12924) +#### After the pin moves: regenerate the committed manifest (#5960, #12924, #17735) The bump has a second half, and it is not optional: ```bash -node scripts/gen-sdui-manifest-node.mjs \ - --objectui-version {the @object-ui version the new pin ships} +pnpm objectui:build # objectui BUILT at the new pin, in .cache/objectui-/ +node scripts/gen-sdui-manifest-node.mjs # no arguments ``` -Read that version from the objectui checkout's `packages/core/package.json` — the bump -already required that checkout. **That command writes the two tracked files, and nothing -else writes them:** the repo-root `sdui.manifest.json` and its provenance record -`scripts/sdui-manifest.record.json`. It installs the published `@object-ui/*` packages -into a temp dir and serializes the registry through `@objectstack/sdui-parser`, so it -needs neither a browser nor an objectui build (it does need -`packages/sdui-parser/dist` — `pnpm --filter @objectstack/sdui-parser build`). - -Two required lint steps read that tracked pair on **every PR**, so skipping this step -reds the bump PR rather than leaving it quietly uncovered: - -- `node scripts/check-sdui-manifest.mjs` — shape, `sha256` against the record, and the - record's `objectuiSha` against the live `.objectui-sha`. Moving the pin is what makes - the last one fail, and its failure text prints the exact regeneration call. -- ADR-0082 D4's spec↔registry **declaration-parity ratchet**, which `lint.yml` runs - `--strict` with `MANIFEST="$PWD/sdui.manifest.json"`. It is baseline-ratcheted, so only - divergence new since `packages/spec/react-declaration-parity.baseline.json` reds. - -**`pnpm sdui:manifest` is a different command and does not do this.** It -(`scripts/gen-sdui-manifest.sh`) builds objectui at the pin, drives a Playwright chromium -over the built console to read `window.__MANIFEST`, writes -`packages/console/dist/sdui.manifest.json` — **gitignored** — and runs the same parity -ratchet against that untracked copy. It is an independent read of the registry straight -from a browser, and `cut-rc.yml` runs it once per release cut; what it never does is -touch the tracked pair, so it cannot clear either gate above. Note the browser dump lives -in `scripts/gen-sdui-manifest.sh`, *not* `scripts/build-console.sh`, which the ADR named -until #5960 corrected it. - -Keeping that browser dump **out of per-PR CI** is a maintainer ruling (2026-08-07): it -would put a full objectui build plus a browser download on every matching PR. #12924 -changed the ratchet's input, not that ruling — the manifest is checked in now, so the -ratchet runs per-PR off the tracked artefact with no browser anywhere. Neither gate has -an unearned green: since #4690 a missing, unreadable, malformed or empty manifest -**exits 1** rather than skipping. +`pnpm objectui:refresh` (bump, then build) leaves exactly the tree the second command +reads, so after a refresh only the generator is left to run. **That command writes the +two tracked files, and nothing else writes them:** the repo-root `sdui.manifest.json` +and its provenance record `scripts/sdui-manifest.record.json`. It derives its modules +root from `.objectui-sha` (`.cache/objectui-/apps/console`), refuses a tree that +is not checked out at the pin or is not built (`@object-ui/core/dist/index.js` absent) +with "run `pnpm objectui:build` first", enumerates objectui's public-tier registry out +of that built tree under plain Node, and serializes it through `@objectstack/sdui-parser` +(it needs `packages/sdui-parser/dist` — `pnpm --filter @objectstack/sdui-parser build`). +The record says so: `source: built-tree`, the `modulesRoot`, and the +`objectuiWorkspaceVersion` read from that tree's `@object-ui/core/package.json`. + +⛔ **There is no version to look up and no npm install.** The version string a pinned +commit declares names a tarball objectui published from an EARLIER commit — objectui +bumps its version only at release — so installing "the version the pin ships" produced +a manifest up to one release behind the pin. That route is deleted (ruling 丙 on #17735). + +One required lint step reads the tracked pair on **every PR**, so skipping this step reds +the bump PR rather than leaving it quietly uncovered: `node scripts/check-sdui-manifest.mjs` +— shape, `sha256` against the record, the record's `objectuiSha` against the live +`.objectui-sha`, and (with an objectui checkout in hand) the recorded version against +what the pin declares. Moving the pin is what makes the pin leg fail, and its failure +text prints the regeneration call. + +**One producer, one artefact.** `scripts/build-console.sh` copies the tracked +`sdui.manifest.json` into `packages/console/dist/`, so the published console carries the +same bytes CI gates. The browser dump that used to write a gitignored copy there +(`pnpm sdui:manifest`, a vite dev server plus Playwright chromium over the built console) +was measured `cmp`-identical to the node output over one built tree before it retired, +together with the ADR-0082 D4 declaration-parity ratchet it ran (see ADR-0082's decision 4 +status line). `scripts/bump-objectui.sh` and `scripts/build-console.sh` both print the reminder when -they finish. When the ratchet fires, the fix is a spec/overlay edit or an explicit -`--update` to re-accept the baseline — see ADR-0082 D4 and its addendum 2. The browser -dump additionally needs a Playwright browser -(`pnpm exec playwright install chromium-headless-shell`). - -#### If the dispatch container's Playwright browser doesn't match the revision - -`pnpm exec playwright install chromium-headless-shell` — the remedy this ratchet and -`scripts/gen-sdui-manifest.sh` both print — is **not runnable in an agent dispatch -container**: `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1` is set there and agents are instructed -not to override it. That collides with objectui pin bumps, because each bump can pull a -newer `playwright` that expects a newer chromium **build revision** than the container -pre-populated. Measured on PR #7308 (2026-08-10, pin `09987b680d53` → `8aad9fd50b16`): -the container ships `chromium_headless_shell-1194` laid out the old way -(`chromium_headless_shell-1194/chrome-linux/headless_shell`), while the newly-pulled -`playwright@1.62.1` looks for build `1234` at the newer per-browser layout -(`chromium_headless_shell-1234/chrome-headless-shell-linux64/chrome-headless-shell`) and -fails with `browserType.launch: Executable doesn't exist at ...`. - -This is a **path/revision lookup gap, not a missing browser-capability**: chromium -141.0.7390.37 (the binary backing build 1194) driven by playwright 1.62.1 ran the ratchet -cleanly once it could be found — 57 public blocks written, no new DECLARATION divergence -vs the accepted baseline. The fix is to make the already-installed binary discoverable -under the revision-named path playwright expects, without touching anything -container-shared. Build a symlink tree inside your own scratchpad (never under -`/opt/pw-browsers`, which every parallel agent in the container shares) that mimics the -new layout and points at the old binaries, then point `PLAYWRIGHT_BROWSERS_PATH` at it -for this one invocation only: - -```bash -D=$SCRATCH/pw/chromium_headless_shell-1234 -mkdir -p "$D/chrome-headless-shell-linux64" -touch "$D/INSTALLATION_COMPLETE" "$D/DEPENDENCIES_VALIDATED" -SRC=/opt/pw-browsers/chromium_headless_shell-1194/chrome-linux -for f in "$SRC"/*; do ln -sfn "$f" "$D/chrome-headless-shell-linux64/$(basename "$f")"; done -ln -sfn "$SRC/headless_shell" "$D/chrome-headless-shell-linux64/chrome-headless-shell" - -PLAYWRIGHT_BROWSERS_PATH=$SCRATCH/pw pnpm sdui:manifest -``` - -Replace `1234` / `1194` with whatever revisions your run actually reports (playwright -prints the build it wants in the "Executable doesn't exist" error; the container's -installed build is whatever directory name sits under `/opt/pw-browsers`). If the -revisions ever line up on their own, this step is a no-op — try `pnpm sdui:manifest` -unmodified first. See objectstack#7315 for the full writeup, including why this is -recorded here rather than made the script's own fallback (options 3/4 there cross a -repo/image boundary and are deliberately not taken by this note). +they finish. ## 3. Platform layer — `content/docs/releases/vN.mdx` (curated) @@ -276,12 +226,11 @@ So the process is: declared changesets (`scripts/objectui-changeset-digest.mjs`). This is the mechanism that keeps the release record honest, and it is the whole of it. 4. **Then the committed manifest is regenerated in that same PR.** - `node scripts/gen-sdui-manifest-node.mjs --objectui-version {the @object-ui version - the new pin ships}` rewrites the tracked `sdui.manifest.json` and + `pnpm objectui:build`, then `node scripts/gen-sdui-manifest-node.mjs`, which reads + objectui's built tree at the new pin and rewrites the tracked `sdui.manifest.json` and `scripts/sdui-manifest.record.json`; `scripts/check-sdui-manifest.mjs` reds the bump - PR until it does, and ADR-0082 D4's declaration-parity ratchet reads that same tracked - artefact. `pnpm sdui:manifest` writes neither file — it dumps to the gitignored - `packages/console/dist/`. See "After the pin moves" above. + PR until it does, and the console build ships that same tracked file. See "After the + pin moves" above. 5. **Releases build against the pin as committed.** Both `cut-rc.yml` and `release.yml` read `.objectui-sha` and build the Console SPA at it. Neither resolves objectui `main`; neither moves the pin. @@ -386,12 +335,10 @@ What it does, in order: for the objectui revision; it does **not** resolve objectui `main`. Nothing downstream re-reads either repo's `main`, so **both repositories may keep moving for the whole run**. -2. Clones objectui at the committed pin, builds the vendored Console there, and - runs the ADR-0082 D4 declaration-parity ratchet (`pnpm sdui:manifest`) against - it. ⛔ **No pin bump happens** — the pin is committed input (#10134). The - ratchet still runs because it is an on-demand gate whose live failure mode is - "unrun": this is the last place before publish that can catch a bump PR that - skipped it. +2. Clones objectui at the committed pin and builds the vendored Console there; the + dist carries the tracked `sdui.manifest.json`, whose freshness against the pin the + required lint job already held on the PR that moved it. ⛔ **No pin bump happens** + — the pin is committed input (#10134). 3. Runs the gates that read `.changeset/*`, before versioning consumes it. 4. Runs `pnpm run version` — the repo script, never a bare `changeset version` — and **fails unless the computed version equals the one you typed**. diff --git a/package.json b/package.json index 8f3a5b0cfb..12b674b3e2 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,6 @@ "docs:start": "pnpm --filter @objectstack/docs start", "os": "node packages/cli/bin/run.js", "objectui:build": "bash scripts/build-console.sh", - "sdui:manifest": "bash scripts/gen-sdui-manifest.sh", "gen:sdui-lockstep": "node scripts/check-sdui-lockstep.mjs --update", "objectui:bump": "bash scripts/bump-objectui.sh", "objectui:refresh": "bash scripts/bump-objectui.sh && bash scripts/build-console.sh", diff --git a/packages/lint/src/sdui-jsx-baseline.json b/packages/lint/src/sdui-jsx-baseline.json index 6eefe57235..908453e0b2 100644 --- a/packages/lint/src/sdui-jsx-baseline.json +++ b/packages/lint/src/sdui-jsx-baseline.json @@ -12,27 +12,26 @@ "The ledger only shrinks. Zero rows = delete the file and the census assertion inverts", "to 'wired run is clean'.", "", - "Why these 232 exist at all: the public tier deliberately declares no intrinsic HTML", - "tags (57 curated blocks; div/a/p absent; `html` is the tier-C escape hatch), while the", + "Why these 200 exist at all: the public tier deliberately declares no intrinsic HTML", + "tags (59 curated blocks; div/a/p absent; `html` is the tier-C escape hatch), while the", "three shipped html pages author with div/a — every error below is a plain HTML tag,", "none is a real authoring mistake (measured 2026-08-29, re-measured 2026-08-30). The", - "not-a-container rows are objectui#6740 (flex registers without isContainer). What the", - "html tier's legal vocabulary IS remains a producer-side question (objectui); this", - "ledger keeps the gate live and honest while that question settles." + "32 jsx-not-a-container warnings (objectui#6740, flex registered without isContainer)", + "went stale when the manifest was regenerated from objectui's built tree at the pin", + "(flex declares isContainer there) and were deleted, 2026-09-23. What the html tier's", + "legal vocabulary IS remains a producer-side question (objectui); this ledger keeps", + "the gate live and honest while that question settles." ], "recordedAt": "2026-08-30", "findings": [ { "page": "showcase_capability_map", "rule": "jsx-forbidden-tag", "severity": "error", "tag": "a", "count": 17 }, { "page": "showcase_capability_map", "rule": "jsx-forbidden-tag", "severity": "error", "tag": "div", "count": 23 }, - { "page": "showcase_capability_map", "rule": "jsx-not-a-container", "severity": "warning", "tag": "flex", "count": 14 }, { "page": "showcase_capability_map", "rule": "jsx-unknown-component", "severity": "error", "tag": "a", "count": 17 }, { "page": "showcase_capability_map", "rule": "jsx-unknown-component", "severity": "error", "tag": "div", "count": 23 }, { "page": "showcase_command_center_jsx", "rule": "jsx-forbidden-tag", "severity": "error", "tag": "div", "count": 29 }, - { "page": "showcase_command_center_jsx", "rule": "jsx-not-a-container", "severity": "warning", "tag": "flex", "count": 12 }, { "page": "showcase_command_center_jsx", "rule": "jsx-unknown-component", "severity": "error", "tag": "div", "count": 29 }, { "page": "showcase_start_here", "rule": "jsx-forbidden-tag", "severity": "error", "tag": "a", "count": 4 }, { "page": "showcase_start_here", "rule": "jsx-forbidden-tag", "severity": "error", "tag": "div", "count": 27 }, - { "page": "showcase_start_here", "rule": "jsx-not-a-container", "severity": "warning", "tag": "flex", "count": 6 }, { "page": "showcase_start_here", "rule": "jsx-unknown-component", "severity": "error", "tag": "a", "count": 4 }, { "page": "showcase_start_here", "rule": "jsx-unknown-component", "severity": "error", "tag": "div", "count": 27 } ] diff --git a/packages/spec/react-declaration-parity.baseline.json b/packages/spec/react-declaration-parity.baseline.json deleted file mode 100644 index 0c4691269c..0000000000 --- a/packages/spec/react-declaration-parity.baseline.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "_comment": "Accepted spec↔registry DECLARATION-PARITY baseline (react blocks). Per block: the registry-only input set (the registry config declares it, the spec does not) and whether the block is missing. Regenerate with: MANIFEST=… check:react-declaration-parity --baseline --update. The ratchet flags only NEW registry-only inputs or newly-missing blocks. It compares two declarations and inspects no renderer, so a prop both sides declare and nothing reads records as agreement here (#4413/#4472).", - "_acceptedReasons": { - "_note": "Hand-maintained record of WHY each accepted registry-only input is deliberately not declared in spec (maintainer ruling 2026-08-18 on #9392). `--update` rewrites this file and drops this block — re-add it (updated) whenever the accepted set changes; an entry with no reason here is an accept nobody ruled.", - "ObjectForm.initialData": "Alias spelling of `initialValues` (the drawer/modal presentations read it first). Aliases are not promoted into spec, per the alias-convergence direction.", - "ObjectForm.mobile": "Internal presentation override, not an authoring surface.", - "ObjectForm.navigateOnSuccess": "Explicitly parked pending the 'action success navigation' family ruling (#9474/#9566, objectui#5034) — declaring it now risks freezing a spelling that family is shaping; revisit when `onSuccess` lands. The family landed on 2026-08-18 (PR #9601); the revisit is tracked on #9392.", - "ListView.objectName": "TEMPORARY, with a stated expiry (#14791, maintainer ruling 2026-09-07, option B): the react-tier contract retired the ListView `objectName` / `viewType` overlay aliases with no deprecation window, so objectui plugin-list's designer `inputs` (`objectName` required, `viewType`) are registry-only until objectui#8510 removes them from the list-view registration (the metadata host stamps `objectName`, and `normalizeListViewSchema` supplies it from `data` at pin a472b071). Accepted only until objectui#8510 lands and the pin moves past it — DELETE this entry and the two inputs from the `ListView.registryOnly` row then; declaring the props back in spec or on the overlay is not an exit, it undoes the ruling.", - "ListView.viewType": "TEMPORARY, with a stated expiry (#14791, maintainer ruling 2026-09-07, option B): the react-tier contract retired the ListView `objectName` / `viewType` overlay aliases with no deprecation window, so objectui plugin-list's designer `inputs` (`objectName` required, `viewType`) are registry-only until objectui#8510 removes them from the list-view registration (the metadata host stamps `objectName`, and `normalizeListViewSchema` supplies it from `data` at pin a472b071). Accepted only until objectui#8510 lands and the pin moves past it — DELETE this entry and the two inputs from the `ListView.registryOnly` row then; declaring the props back in spec or on the overlay is not an exit, it undoes the ruling." - }, - "blocks": { - "ObjectForm": { - "registryOnly": [ - "initialData", - "mobile", - "navigateOnSuccess" - ], - "missing": false - }, - "ListView": { - "registryOnly": [ - "objectName", - "viewType" - ], - "missing": false - }, - "ObjectChart": { - "registryOnly": [], - "missing": false - }, - "object-calendar": { - "registryOnly": [], - "missing": false - }, - "object-form": { - "registryOnly": [], - "missing": false - }, - "object-grid": { - "registryOnly": [], - "missing": false - }, - "object-kanban": { - "registryOnly": [], - "missing": false - }, - "object-master-detail-form": { - "registryOnly": [], - "missing": false - }, - "object-metric": { - "registryOnly": [], - "missing": false - } - } -} diff --git a/packages/spec/scripts/build-schemas-check-mode.test.ts b/packages/spec/scripts/build-schemas-check-mode.test.ts index 326a8c9466..124c4122cb 100644 --- a/packages/spec/scripts/build-schemas-check-mode.test.ts +++ b/packages/spec/scripts/build-schemas-check-mode.test.ts @@ -163,8 +163,7 @@ function writeDefaultsShards(dir: string, entries: readonly string[]): string { /** * Every run loads the entire spec surface and emits ~1700 JSON Schemas (~7s * alone, more under turbo's parallel test load). A timeout here should mean - * "the script hung", not "the runner was busy" — cf. the same note in - * check-react-blocks-declaration-parity.test.ts. + * "the script hung", not "the runner was busy". */ const SPAWN_TIMEOUT_MS = 180_000; diff --git a/packages/spec/scripts/check-react-blocks-declaration-parity.test.ts b/packages/spec/scripts/check-react-blocks-declaration-parity.test.ts deleted file mode 100644 index f53bfe5340..0000000000 --- a/packages/spec/scripts/check-react-blocks-declaration-parity.test.ts +++ /dev/null @@ -1,624 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// -// Pins WHAT `check-react-blocks-declaration-parity` claims, not only what it -// computes (#4472). -// -// The script's defect was never its arithmetic — the set difference it prints -// was always right. The defect was the sentence wrapped around it: it opened by -// saying it "confirms the objectui components ACTUALLY implement the props the -// spec protocol declares", which is a statement about renderers, and it has -// never read a renderer. It reads two DECLARATIONS: the spec zod schema on one -// side, and on the other the objectui registry config's `inputs` — copied -// verbatim into the manifest by `manifestFromConfigs`, so also a declaration. -// -// A claim that outruns the capability is worse than no claim: with no gate a -// human checks by hand, and #4413's four dead `record:*` blocks were in fact -// found by hand — while this check reported `{ frontendOnly: [], missing: false }` -// for every one of them, for that defect's entire lifetime, because both -// declarations agreed and neither was lying. Only the renderer was, and the -// renderer is not in scope here. -// -// So these tests assert four things in one process run: -// 1. the signals it CAN see, in both directions (spec-only / registry-only); -// 2. the scope caveat rides along with EVERY report, success included — -// whoever forms a belief from this gate is reading a CI log, not a header; -// 3. the implementation claim stays gone. This is the executable half of -// Prime Directive #10 ("never advertise a capability the runtime doesn't -// deliver"): the wording that caused #4472 fails a test if it comes back. -// 4. THE GATE CAN GO RED (#4690). The three above all read the report text, -// which a gate that exits 0 on everything would still produce. #4690 is -// that shape: no workflow ran this gate, and a manual run without MANIFEST -// printed a `⚠` and exited 0 — no path existed on which it could fail, and -// two other issues (#4804/#4777) had started citing it as the negative -// example of a gate nobody can prove catches anything. The last describe -// block therefore asserts EXIT CODES against fabricated violations: a -// registry-only input and a vanished block must each exit non-zero naming -// themselves, an absent/unusable manifest must exit non-zero as "did not -// run", and an accepted state must still exit 0 so the red is discriminating. - -import { describe, it, expect } from 'vitest'; -import { execFileSync, spawnSync } from 'node:child_process'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { - REPO_MANIFEST_RELATIVE, - manifestPrescription, - repoManifestIsCheckedIn, - repoManifestPath, -} from './manifest-prescription'; - -const HERE = path.dirname(fileURLToPath(import.meta.url)); -const PKG = path.resolve(HERE, '..'); -const SCRIPT = path.join(HERE, 'check-react-blocks-declaration-parity.ts'); -const TSX = path.join(PKG, 'node_modules', '.bin', 'tsx'); - -type ManifestInput = { name: string }; -type Manifest = { components: Record }; - -/** Build a manifest declaring exactly `inputs` for `type`. */ -const manifestFor = (type: string, inputs: string[]): Manifest => ({ - components: { [type]: { type, inputs: inputs.map((name) => ({ name })) } }, -}); - -/** - * Every `run()` spawns a fresh tsx process that loads the whole spec schema - * surface — ~4.5s alone and 5–7s under turbo's parallel test load, which is - * ON the 5s vitest default. Which test crossed the line varied run to run: - * three consecutive `pnpm test` sweeps failed a different `it` of this file - * each time, every one a timeout, while the file alone stayed green. A - * timeout here should mean "the script hung", not "the runner was busy". - */ -const SPAWN_TIMEOUT_MS = 60_000; - -/** Run the real script against a synthetic manifest; return stdout+stderr. */ -function run(manifest: Manifest, args: string[] = []): string { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'react-parity-')); - const file = path.join(dir, 'sdui.manifest.json'); - fs.writeFileSync(file, JSON.stringify(manifest), 'utf8'); - try { - return execFileSync(TSX, [SCRIPT, ...args], { - cwd: PKG, - env: { ...process.env, MANIFEST: file }, - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - }); - } catch (e: any) { - // Non-zero exit (e.g. --strict on divergence) still carries the report. - return `${e?.stdout ?? ''}${e?.stderr ?? ''}`; - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } -} - -/** The committed baseline's shape, per block, as the ratchet reads it. */ -type BaselineFile = { blocks: Record }; - -/** - * Same script, same spawn — but the EXIT CODE is the assertion. - * - * `run()` above deliberately swallows a non-zero exit to return the report text; - * every test using it would pass unchanged against a script that always exited 0, - * which is precisely the #4690 failure mode. So the red-path tests go through this - * helper instead: it never throws, and hands back `status` alongside the output. - * - * `manifest` is written to a tmp file and passed as MANIFEST (a string is written - * verbatim, so malformed JSON can be exercised); `manifestPath` sets MANIFEST to a - * literal path instead; passing neither leaves MANIFEST unset — the case that used - * to skip. MANIFEST is stripped from the inherited env so an ambient one (a - * developer who exported it, `pnpm sdui:manifest` in the same shell) cannot make - * the "no manifest" test silently exercise a real manifest. - */ -function runExit(opts: { - manifest?: Manifest | string; - manifestPath?: string; - baseline?: BaselineFile; - args?: string[]; -}): { status: number | null; output: string } { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'react-parity-exit-')); - try { - const env: NodeJS.ProcessEnv = { ...process.env }; - delete env.MANIFEST; - if (opts.manifest !== undefined) { - const file = path.join(dir, 'sdui.manifest.json'); - fs.writeFileSync(file, typeof opts.manifest === 'string' ? opts.manifest : JSON.stringify(opts.manifest), 'utf8'); - env.MANIFEST = file; - } else if (opts.manifestPath !== undefined) { - env.MANIFEST = opts.manifestPath; - } - const argv = [...(opts.args ?? [])]; - if (opts.baseline) { - const baselineFile = path.join(dir, 'baseline.json'); - fs.writeFileSync(baselineFile, JSON.stringify(opts.baseline), 'utf8'); - argv.push('--baseline', baselineFile); - } - const res = spawnSync(TSX, [SCRIPT, ...argv], { cwd: PKG, env, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }); - if (res.error) throw res.error; - return { status: res.status, output: `${res.stdout ?? ''}${res.stderr ?? ''}` }; - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } -} - -// `object-form` is a real REACT_BLOCKS entry backed by FormViewSchema. Two of -// its schema props are enough to exercise both directions; the rest of the -// schema simply shows up as spec-only, which is the soft signal. -const SCHEMA_PROP = 'layout'; -const NOT_A_SCHEMA_PROP = 'zzzNotInTheFormViewSchema'; - -describe('check:react-declaration-parity — the signals it can see', () => { - it('reports a registry-declared input the spec does not declare as registry-only', { timeout: SPAWN_TIMEOUT_MS }, () => { - const out = run(manifestFor('object-form', [SCHEMA_PROP, NOT_A_SCHEMA_PROP])); - expect(out).toMatch(/registry declares, spec does not: .*zzzNotInTheFormViewSchema/); - }); - - it('reports a spec-declared prop the registry does not declare as spec-only', { timeout: SPAWN_TIMEOUT_MS }, () => { - const out = run(manifestFor('object-form', [])); - expect(out).toMatch(new RegExp(`spec declares, registry does not: .*${SCHEMA_PROP}`)); - }); - - it('reports a block absent from the manifest as missing', { timeout: SPAWN_TIMEOUT_MS }, () => { - const out = run(manifestFor('something-else', [])); - expect(out).toMatch(/ \(object-form\): NO component in the manifest/); - }); -}); - -describe('check:react-declaration-parity — the blind spot is stated, every run (#4413/#4472)', () => { - /** - * The #4413 shape, reconstructed: both sides declare the same prop, so the - * report is clean. Nothing in this run touched a renderer — if `object-form`'s - * renderer stopped reading `layout` tomorrow, this output would not move. That - * is precisely how four `record:*` blocks that rendered "bind a record to - * preview" sat behind a green ratchet. - * - * The assertion is therefore not "it catches this" (it cannot) but "it says so - * while reporting the agreement". - */ - it('calls agreeing declarations agreement — and prints the caveat alongside it', { timeout: SPAWN_TIMEOUT_MS }, () => { - const out = run(manifestFor('object-form', [SCHEMA_PROP])); - expect(out).toMatch(/declared by both/); - expect(out).not.toMatch(/registry declares, spec does not/); - expect(out).toMatch(/compares two DECLARATIONS/); - expect(out).toMatch(/No renderer is inspected/); - expect(out).toMatch(/#4413/); - }); - - it('carries the caveat on a clean baseline ratchet too, where it is easiest to over-read', { timeout: SPAWN_TIMEOUT_MS }, () => { - const baseline = path.join(PKG, 'react-declaration-parity.baseline.json'); - // Every baselined block must be present, or the run reports them vanished - // instead of clean. Each declares only spec props, so registry-only is empty - // — the committed baseline's accepted state. Since #9392's `--update` the - // committed baseline also carries the SDUI `object-*` blocks (#7751 put - // them in `current`, so an accept snapshots them too), hence the derived - // tail: a future baselined block is covered here the day it lands instead - // of reporting as vanished. - const committed: BaselineFile = JSON.parse(fs.readFileSync(baseline, 'utf8')); - const manifest: Manifest = { - components: { - // The react blocks are keyed by PascalCase tag in the baseline but by - // schemaType in the manifest — spell those three out. - ...manifestFor('object-form', [SCHEMA_PROP]).components, - ...manifestFor('list-view', []).components, - ...manifestFor('object-chart', []).components, - ...Object.fromEntries( - Object.keys(committed.blocks) - .filter((k) => k.startsWith('object-') && k !== 'object-form') - .map((type) => Object.entries(manifestFor(type, []).components)[0]), - ), - }, - }; - const out = run(manifest, ['--baseline', baseline]); - expect(out).toMatch(/no new DECLARATION divergence/); - expect(out).toMatch(/compares two DECLARATIONS/); - }); -}); - -describe('check:react-declaration-parity — the retired claim stays retired (Prime Directive #10)', () => { - /** - * Guards the wording, in the script AND in what it prints. "conformance", - * "implements", "honors" all assert something about the render path; this gate - * observes none of it. If a future edit reaches for them again, #4472 recurs — - * a gate whose name promises more than it checks, trusted accordingly. - * - * Scoped to the prose the reader forms a belief from: the file's own - * explanations of what it cannot do are allowed to name the retired claim (and - * do), so the check runs against the report, plus the script's leading header - * block minus the lines that quote the old claim to correct it. - */ - const CLAIM_WORDS = /\b(actually implements?|conforms? to|conformance)\b/i; - - it('the report never claims the frontend implements anything', { timeout: SPAWN_TIMEOUT_MS }, () => { - const out = run(manifestFor('object-form', [SCHEMA_PROP, NOT_A_SCHEMA_PROP])); - expect(out).not.toMatch(CLAIM_WORDS); - }); - - it('the script advertises its scope before its first line of code', () => { - const src = fs.readFileSync(SCRIPT, 'utf8'); - const header = src.slice(0, src.indexOf('process.env.OS_EAGER_SCHEMAS')); - expect(header).toMatch(/DECLARATION PARITY/); - expect(header).toMatch(/It never looks at a renderer|never (?:reads|inspects) a renderer/i); - // The one blind spot, named in the header rather than left to be rediscovered. - expect(header).toMatch(/BOTH SIDES declare and NO RENDERER READS/); - }); -}); - -describe('check:react-declaration-parity — the gate CAN go red (#4690)', () => { - // `ObjectForm` is the baseline KEY for the `object-form` block (baselines are - // keyed by tag, not schemaType). If the tag ever changes, these tests fail — which - // is the right outcome: the committed baseline would need the same rename. - const TAG = 'ObjectForm'; - const ACCEPTED: BaselineFile = { blocks: { [TAG]: { registryOnly: [], missing: false } } }; - const FABRICATED = 'zzzFabricatedRegistryOnlyInput'; - - /** - * The self-proof #4804/#4777 ask for: a gate that is only ever green on the - * current `main` has not shown it catches anything. Each of these fabricates a - * violation the gate claims to see and asserts a non-zero exit that NAMES it. - */ - it('exits non-zero naming a fabricated registry-only input', { timeout: SPAWN_TIMEOUT_MS }, () => { - const { status, output } = runExit({ - manifest: manifestFor('object-form', [SCHEMA_PROP, FABRICATED]), - baseline: ACCEPTED, - args: ['--strict'], - }); - expect(output).toContain(`new registry-only input(s) not in baseline: ${FABRICATED}`); - expect(status, output).toBe(1); - }); - - it('exits non-zero naming a block that vanished from the manifest', { timeout: SPAWN_TIMEOUT_MS }, () => { - const { status, output } = runExit({ - manifest: manifestFor('something-else', []), - baseline: ACCEPTED, - args: ['--strict'], - }); - expect(output).toContain(`<${TAG}>: block vanished from the manifest`); - expect(status, output).toBe(1); - }); - - /** - * The control. Without it the two reds above could be red for any reason — a - * broken spawn, a bad path — and the suite would still look like proof. - */ - it('exits 0 when the manifest matches the accepted baseline', { timeout: SPAWN_TIMEOUT_MS }, () => { - const { status, output } = runExit({ - manifest: manifestFor('object-form', [SCHEMA_PROP]), - baseline: ACCEPTED, - args: ['--strict'], - }); - expect(output).toMatch(/no new DECLARATION divergence/); - expect(status, output).toBe(0); - }); - - /** - * `--strict` decides what a DIVERGENCE costs. The four cases below are the other - * thing entirely — no comparison happened at all — so they must fail WITHOUT - * `--strict` too. Tying "could not run" to the flag would rebuild #4690's hole one - * flag deeper: every one of these exited 0 before, and the first was the shipped - * default of the manual run. - */ - it('a missing MANIFEST is a failure, not a skip', { timeout: SPAWN_TIMEOUT_MS }, () => { - const { status, output } = runExit({}); - expect(status, output).toBe(1); - expect(output).toMatch(/did NOT run/); - expect(output).not.toMatch(/skipping/); - // The refusal has to be actionable. WHICH prescription is actionable depends on - // the repository the script is standing in, so this only asserts the branch that - // is true HERE — a manifest is checked in at the root — and the prescription's - // own tests below cover both. Asserting the dump path unconditionally is how - // #16715 stayed invisible: the assertion agreed with the prose, and both were - // stale in the same direction. - expect(output).toContain('MANIFEST="$PWD/sdui.manifest.json"'); - expect(output).toContain('pnpm --filter @objectstack/spec check:react-declaration-parity'); - }); - - it('a MANIFEST path that does not exist fails loudly, naming the path', { timeout: SPAWN_TIMEOUT_MS }, () => { - const { status, output } = runExit({ manifestPath: '/nonexistent/sdui.manifest.json' }); - expect(status, output).toBe(1); - expect(output).toContain('/nonexistent/sdui.manifest.json does not exist'); - }); - - it('an unreadable manifest fails as a diagnosis, not as an uncaught throw', { timeout: SPAWN_TIMEOUT_MS }, () => { - const { status, output } = runExit({ manifest: '{ not json' }); - expect(status, output).toBe(1); - expect(output).toMatch(/is not readable JSON/); - }); - - it('an empty registry dump is refused rather than compared against nothing', { timeout: SPAWN_TIMEOUT_MS }, () => { - // Every block would report "missing" — catastrophic under --strict, vacuous - // without it. objectui's dumper refuses to WRITE one; this refuses to READ one. - const { status, output } = runExit({ manifest: { components: {} } }); - expect(status, output).toBe(1); - expect(output).toMatch(/declares no components/); - }); -}); - -/** - * The SDUI `object-*` blocks ride this gate as of #7751 (maintainer ruling - * 2026-08-12, direction A: the parity burden goes through THIS machinery, no - * new gate). Same comparison, same ratchet, baseline-keyed by the TYPE itself - * (`object-grid`) rather than a PascalCase tag. These tests prove the new - * iteration source with the same fixture harness — including that it can go - * red — because the real manifest only exists at pin-bump time and cannot be - * produced here (#4690's constraint, unchanged). - */ -describe('check:react-declaration-parity — SDUI object-* blocks (#7751)', () => { - it('compares an object-* ComponentPropsMap entry against the manifest (both directions)', { timeout: SPAWN_TIMEOUT_MS }, () => { - // `filter` IS declared by ObjectGridPropsSchema; the fabricated name is not. - const out = run(manifestFor('object-grid', ['objectName', 'filter', 'zzzNotInTheGridSchema'])); - expect(out).toMatch(/# Spec ↔ registry declaration parity \(SDUI object-\* blocks, #7751\)/); - expect(out).toMatch(/object-grid: 2 declared by both/); - expect(out).toMatch(/registry declares, spec does not: .*zzzNotInTheGridSchema/); - // The declared-set surplus (renderer-read keys the palette does not offer) - // is the SOFT signal, present and named: - expect(out).toMatch(/⚠ object-grid: .*spec-only/); - }); - - it('reports an object-* block absent from the manifest as missing', { timeout: SPAWN_TIMEOUT_MS }, () => { - const out = run(manifestFor('something-else', [])); - expect(out).toMatch(/✗ object-grid: NO component in the manifest/); - expect(out).toMatch(/✗ object-metric: NO component in the manifest/); - }); - - it('ratchets a fabricated registry-only input on an object-* block — non-zero, named', { timeout: SPAWN_TIMEOUT_MS }, () => { - const { status, output } = runExit({ - manifest: manifestFor('object-kanban', ['groupBy', 'zzzFabricatedKanbanInput']), - baseline: { blocks: { 'object-kanban': { registryOnly: [], missing: false } } }, - args: ['--strict'], - }); - expect(output).toContain('new registry-only input(s) not in baseline: zzzFabricatedKanbanInput'); - expect(status, output).toBe(1); - }); - - it('a block not yet in the baseline is additive, not a regression (pin-bump onboarding path)', { timeout: SPAWN_TIMEOUT_MS }, () => { - // The committed baseline predates #7751's blocks; the first pin-bump run - // must not go red because coverage GREW. (`--update` then records them.) - const { status, output } = runExit({ - manifest: manifestFor('object-grid', ['objectName', 'columns', 'filter']), - baseline: { blocks: { ObjectForm: { registryOnly: [], missing: true } } }, - args: ['--strict'], - }); - expect(output).toMatch(/no new DECLARATION divergence/); - expect(status, output).toBe(0); - }); -}); - -/** - * THE ACCEPTED SET IS THE FULL NODE CONTRACT — AND STAYS CALIBRATED (#13192). - * - * The gate used to judge a registry input against `ComponentPropsMap[type]` - * alone: the PER-BLOCK half of what a page node may carry. The other half is - * declared once on `PageComponentSchema` and applies to every component, so a - * NODE-LEVEL key read to this gate as an invented input and the complaint was - * FALSE — not merely noisy. Measured on the ref that fixed it: - * - * PageComponentSchema.safeParse({ type: 'list-view', dataSource: {…} }) - * → success: true, output KEEPS `dataSource` - * ComponentPropsMap['object-grid'] → 37 keys, none of them `dataSource` - * - * objectui PR #6767 landed 17 renderers across 12 packages that declare - * `dataSource` at the `ElementDataSourceGate` wrapping seam, so this was the - * INVERSE of the usual gate defect: not "should be red and isn't" but - * "shouldn't be red and is about to be", which blocks a correct change with a - * false reason. - * - * ⭐ THE HALF THAT MATTERS MOST HERE IS THE NEGATIVE ONE. Widening an accepted - * set is one edit away from making a gate vacuous, and a vacuous gate is worse - * than the false failure it replaced because nothing ever says so again. These - * pins therefore assert BOTH directions in the same shape of run: `dataSource` - * and `className` accepted, while `viewName`, a retired node-level key, and an - * invented name still go red and NAME themselves. - */ -describe('check:react-declaration-parity — the node contract, and its calibration (#13192)', () => { - const CLEAN: BaselineFile = { blocks: { 'object-grid': { registryOnly: [], missing: false } } }; - /** A real `object-grid` prop, so every fixture below also exercises the props half. */ - const BLOCK_PROP = 'objectName'; - - /** - * The derived set itself, read off the report. - * - * It is printed on every run for the same reason the scope note is (#4472): a - * widening nobody can see in the CI log cannot be audited. Pinning the printed - * line is what makes "derived, not listed" checkable — the set must come from - * `PageComponentSchema`'s key shape, and a future node-level key must appear - * here the day it lands without anyone editing this file. - */ - it('prints the node-level accepted set it derived, and derives it from the schema shape', { timeout: SPAWN_TIMEOUT_MS }, () => { - const out = run(manifestFor('object-grid', [BLOCK_PROP])); - expect(out).toMatch(/DERIVED from PageComponentSchema's own key shape/); - const line = out.split('\n').find((l) => l.includes('aria, className')) ?? ''; - const derived = line.trim().split(/,\s*/); - // ⭐ ACCEPTED — the two keys the card names. - expect(derived).toContain('dataSource'); - expect(derived).toContain('className'); - // ⭐ REFUSED — `objectName` and `viewName` are NOT node-level. `objectName` gets - // no behavioural leg below because it is accepted on every block for a - // legitimate PER-BLOCK reason (measured: it is a prop of all six `object-*` - // schemas and an overlay prop of all three react blocks), so the discrimination - // that can actually go wrong — it being swept in as node-level — is exactly this. - expect(derived).not.toContain('objectName'); - expect(derived).not.toContain('viewName'); - // ⭐ REFUSED — a `retiredKey()` tombstone is IN the shape but accepts nothing. - expect(derived).not.toContain('responsive'); - }); - - it('accepts a node-level key the block\'s own props schema does not declare (dataSource)', { timeout: SPAWN_TIMEOUT_MS }, () => { - const { status, output } = runExit({ - manifest: manifestFor('object-grid', [BLOCK_PROP, 'dataSource']), - baseline: CLEAN, - args: ['--strict'], - }); - // Accepted, and SAID SO rather than silently dropped. - expect(output).toMatch(/spec accepts at NODE level \(not a per-block prop\): dataSource/); - expect(output).not.toMatch(/registry declares, spec does not: .*dataSource/); - expect(output).toMatch(/no new DECLARATION divergence/); - expect(status, output).toBe(0); - }); - - it('accepts the other node-level key the card checked (className)', { timeout: SPAWN_TIMEOUT_MS }, () => { - const { status, output } = runExit({ - manifest: manifestFor('object-grid', [BLOCK_PROP, 'className']), - baseline: CLEAN, - args: ['--strict'], - }); - expect(output).toMatch(/spec accepts at NODE level \(not a per-block prop\): className/); - expect(status, output).toBe(0); - }); - - /** - * The calibration proper: a key that is neither a node-level key nor a prop of - * this block must still go red — IN THE SAME RUN in which a node-level key is - * accepted, so the two cannot be confused for one lenient mode. - */ - it.each([ - ['viewName', 'a plausible-looking key that is on no half of the contract'], - ['responsive', 'a node-level key the shape RETIRES — the tombstone must not be swept in'], - ['zzzInventedRegistryInput', 'an outright invented input'], - ])('still refuses %s (%s) while accepting dataSource in the same run', (key, _why) => { - const { status, output } = runExit({ - manifest: manifestFor('object-grid', [BLOCK_PROP, 'dataSource', key]), - baseline: CLEAN, - args: ['--strict'], - }); - expect(output).toContain(`new registry-only input(s) not in baseline: ${key}`); - // The accept and the refusal are simultaneous — this is the discrimination. - expect(output).toMatch(/spec accepts at NODE level \(not a per-block prop\): dataSource/); - expect(status, output).toBe(1); - }, SPAWN_TIMEOUT_MS); - - /** - * The react-block half of the gate takes the same fix. `list-view`'s schema - * declares 49 props and `dataSource` is not among them, so before #13192 a - * `` declaring it was reported as a new registry-only input. - */ - it('applies to the react-block half too, not only the SDUI object-* half', { timeout: SPAWN_TIMEOUT_MS }, () => { - const { status, output } = runExit({ - manifest: manifestFor('list-view', ['dataSource']), - baseline: { blocks: { ListView: { registryOnly: [], missing: false } } }, - args: ['--strict'], - }); - expect(output).toMatch(/ \(list-view\):.*node-level/); - expect(output).toMatch(/spec accepts at NODE level \(not a per-block prop\): dataSource/); - expect(status, output).toBe(0); - }); -}); - -/** - * THE REFUSAL'S PRESCRIPTION IS PROBED, AND BOTH WORLDS ARE PINNED (#16715). - * - * The gate's refusal text used to be a constant asserting "this repository contains - * no copy of it" and sending the reader to build objectui and dump one in a browser. - * That was false from #13446 onward — `sdui.manifest.json` is checked in at the root - * and `lint.yml` runs this gate against it — and it is read at the exact moment - * someone is deciding whether the gate can run at all. Twice measured (#16489 / PR - * #16697, and PR #16777) a dev believed it and filed a locally-runnable gate as - * `EXTERNAL_INPUT_REQUIRED` / NOT MEASURED; one of those declarations reached a - * deliverable, on a head where setting one variable gave exit 0. - * - * ⭐ THE ONE-BRANCH SHAPE IS THE DEFECT, so testing one branch would rebuild it. The - * probe is a real filesystem check against a temp root here — both answers are - * producible — and the wiring (that the script probes at all, and prints the - * paste-ready command in THIS repository) is asserted through a real spawn above and - * below. Neither branch may assert the other's world: that is what each `not` pins. - */ -describe('check:react-declaration-parity — the prescription is probed, not asserted (#16715)', () => { - /** A root that has a manifest, and one that does not — the two worlds, on disk. */ - function withRoots(fn: (roots: { withManifest: string; without: string }) => void): void { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'react-parity-prescription-')); - try { - const withManifest = path.join(dir, 'has-one'); - const without = path.join(dir, 'has-none'); - fs.mkdirSync(withManifest); - fs.mkdirSync(without); - fs.writeFileSync(path.join(withManifest, REPO_MANIFEST_RELATIVE), '{"components":{}}', 'utf8'); - fn({ withManifest, without }); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } - } - - it('the probe answers from the filesystem, both ways', () => { - withRoots(({ withManifest, without }) => { - expect(repoManifestIsCheckedIn(withManifest)).toBe(true); - expect(repoManifestIsCheckedIn(without)).toBe(false); - expect(repoManifestPath(withManifest)).toBe(path.join(withManifest, 'sdui.manifest.json')); - }); - }); - - it('BRANCH: manifest checked in — hands over a paste-ready command, not a production errand', () => { - withRoots(({ withManifest }) => { - const text = manifestPrescription({ repoRoot: withManifest, checkedIn: true }); - // The command CI runs, spelled as CI spells it, plus the `cd` that makes `$PWD` - // true — so the block works pasted from any working directory. - expect(text).toContain('MANIFEST="$PWD/sdui.manifest.json"'); - expect(text).toContain(`cd ${withManifest}`); - expect(text).toContain('pnpm --filter @objectstack/spec check:react-declaration-parity'); - expect(text).toContain('--baseline react-declaration-parity.baseline.json --strict'); - expect(text).toContain(path.join(withManifest, 'sdui.manifest.json')); - // ⭐ The negative half: the false sentence, and the errand it justified, are gone. - expect(text).not.toContain('contains no copy of it'); - expect(text).not.toContain('pnpm objectui:build'); - expect(text).not.toContain('OBJECTUI_ROOT=../objectui'); - // ⭐ And it must name the RIGHT regenerator. `pnpm sdui:manifest` is - // scripts/gen-sdui-manifest.sh, whose TARGET is packages/console/dist — it never - // writes the root artefact or scripts/sdui-manifest.record.json. - // `node scripts/gen-sdui-manifest-node.mjs` owns both, which is what the record's - // own `generator` field and check-sdui-manifest.mjs both say. Sending a reader to - // the wrong one is this card's defect class again: a confident false claim about - // the tree, in the text consulted when deciding what to run. - expect(text).toContain('gen-sdui-manifest-node.mjs'); - // The wrong tool may appear ONLY inside the correction that names it wrong — - // nowhere else, command line or prose. The LOOKAHEAD is what enforces the whole of - // that sentence, and it was measured before it was written: the original defective - // claim ('`pnpm sdui:manifest` rewrites it when .objectui-sha moves') was itself - // MID-LINE, so a line-anchored form does not match it at all and would have left - // this comment promising more than the code delivers — the same over-claim, one - // layer up, in a card about exactly that. A flat `not.toContain` is not available - // either: it would forbid the correction itself. This assertion belongs to the - // present branch alone — the absent branch legitimately opens command lines with - // that spelling, so it must not be hoisted out of this leg. - expect(text).not.toMatch(/pnpm sdui:manifest(?!` does NOT rewrite)/); - expect(text).toContain('`pnpm sdui:manifest` does NOT rewrite this file'); - // …and it says, in words, what the two devs got wrong. - expect(text).toMatch(/NOT MEASURED/); - }); - }); - - it('BRANCH: no manifest — still hands over the whole production path', () => { - withRoots(({ without }) => { - const text = manifestPrescription({ repoRoot: without, checkedIn: false }); - expect(text).toContain('pnpm objectui:build'); - expect(text).toContain('pnpm sdui:manifest'); - expect(text).toContain('OBJECTUI_ROOT=../objectui'); - expect(text).toContain('MANIFEST=/path/to/sdui.manifest.json'); - // It names the path it actually looked at, so "no copy" is a reading and not a - // claim about repositories in general — the exact over-reach that caused #16715. - expect(text).toContain(path.join(without, 'sdui.manifest.json')); - // ⭐ The negative half: it must not tell a reader a file is there when it is not. - expect(text).not.toContain('IS checked in'); - expect(text).not.toContain('MANIFEST="$PWD/sdui.manifest.json"'); - }); - }); - - it('the two branches are genuinely different text, not one string with a toggle', () => { - withRoots(({ withManifest }) => { - const present = manifestPrescription({ repoRoot: withManifest, checkedIn: true }); - const absent = manifestPrescription({ repoRoot: withManifest, checkedIn: false }); - expect(present).not.toEqual(absent); - }); - }); - - /** - * The wiring, end to end: the script must PROBE, and in this repository the probe - * must find the committed manifest. The unit tests above would all pass against a - * script that never called the function. - */ - it('the real gate prints the checked-in branch in THIS repository', { timeout: SPAWN_TIMEOUT_MS }, () => { - const { status, output } = runExit({}); - expect(status, output).toBe(1); - expect(output).toContain('IS checked in at this repository\'s root'); - expect(output).toContain('MANIFEST="$PWD/sdui.manifest.json"'); - expect(output).not.toContain('contains no copy of it'); - expect(output).not.toContain('OBJECTUI_ROOT=../objectui'); - }); -}); diff --git a/packages/spec/scripts/check-react-blocks-declaration-parity.ts b/packages/spec/scripts/check-react-blocks-declaration-parity.ts deleted file mode 100644 index 6f6ccc1adf..0000000000 --- a/packages/spec/scripts/check-react-blocks-declaration-parity.ts +++ /dev/null @@ -1,518 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// -// Spec ↔ registry DECLARATION PARITY for the curated react blocks (ADR-0081/ -// ADR-0082 follow-up; corrected by #4472). -// -// ⚠️ READ THIS BEFORE TRUSTING A GREEN RUN — what this does NOT do. -// -// This compares TWO DECLARATIONS. It never looks at a renderer: -// -// left the props the SPEC zod schema declares for a block (`z.toJSONSchema`) -// right the inputs the objectui REGISTRY CONFIG declares for the same block -// — read out of `sdui.manifest.json`, which objectui builds with -// `manifestFromConfigs(getPublicConfigs())`; that function copies -// `config.inputs` verbatim, so the right-hand side is a *declaration* -// too, not evidence that anything consumes the prop. -// -// So a prop that BOTH SIDES declare and NO RENDERER READS is, to this script, -// perfect agreement. Neither declaration is lying on its own; the lie is one -// layer below, where the render happens — and that layer is outside this -// script's field of view entirely. -// -// That blind spot is not hypothetical. This file used to open by claiming it -// "confirms the objectui components ACTUALLY implement the props the spec -// protocol declares". It never could. #4413 is what that cost: four blocks -// (`record:details` / `record:highlights` / `record:related_list` / -// `record:path`) published `objectName` / `recordId` that no renderer read — -// those renderers take the record from the record page's shared context — so -// the blocks rendered a "bind a record to preview" placeholder on a -// `kind:'react'` page. Both declarations agreed the whole time, so the baseline -// recorded `{ registryOnly: [], missing: false }` for all four and this check -// stayed green for #4413's entire lifetime. The defect was found by a human -// reading the objectui renderers, not by this script. #4472 is the correction. -// -// WHAT IT DOES SEE (real signals — don't discount them): -// -// - spec-only : the spec schema declares a prop the registry config does -// NOT declare as an input → the designer palette can't -// configure it. A soft signal per ADR-0082 §2 (the palette -// is deliberately a subset), reported but never ratcheted. -// - registry-only : the registry config declares an input the spec does NOT -// declare → undocumented extension, or the spec is behind. -// This is what the baseline ratchets on. -// - missing : no component for this block in the manifest at all → not -// registered, or not public. Real signal. -// -// WHAT IT CANNOT SEE is exactly one class: both sides declare it, nothing reads -// it. Catching that needs evidence from the render path (a behavioural probe or -// a renderer-side usage pass), which lives in objectui, not here. -// `check-react-blocks-declaration-parity.test.ts` pins the limitation with a -// fixture that stays green while its "renderer" ignores everything, so the -// capability cannot be re-assumed by the next reader. -// -// THE SPEC SIDE IS THE WHOLE NODE CONTRACT, NOT ONLY THE PROPS HALF (#13192). -// -// `ComponentPropsMap[type]` is the PER-BLOCK half of what a page node may carry. -// The other half is declared ONCE, on `PageComponentSchema` itself — `type`, -// `id`, `className`, `dataSource`, `visibleWhen`, … — and applies to every -// component whatever its block. Judging registry inputs against the props half -// alone makes a node-level key read as an INVENTED input, and that complaint is -// FALSE rather than merely inconvenient: the spec does accept the key. -// -// The two readings that establish it, re-measured on this ref before the fix: -// -// PageComponentSchema.safeParse({ type: 'list-view', dataSource: {…} }) -// → success: true, and the parsed output KEEPS `dataSource` -// ComponentPropsMap['object-grid'] → 37 keys, `dataSource` among none of them -// -// So the accepted set per block is node-level keys ∪ per-block props ∪ overlay. -// -// WHERE THE MANIFEST COMES FROM — TWO PRODUCERS, TWO REGISTRIES (#4690, #17735). -// -// The right-hand side is objectui's registry-inputs manifest (sdui.manifest.json). -// It has TWO producers, and they do not read the same registry — which is the whole -// of #17735, so do not reason from either one alone: -// -// browser objectui's `scripts/dump-public-manifest.mjs` drives a real browser -// (Playwright chromium) at `dev/manifest-dump.html` and reads -// `window.__MANIFEST`. `pnpm sdui:manifest` -// (scripts/gen-sdui-manifest.sh) is the wrapper — and it does NOT -// build objectui. It REQUIRES a checkout already vendored at -// `.cache/objectui-` by `pnpm objectui:build`, and exits 1 -// telling you to run that first (gen-sdui-manifest.sh 507-516). It -// then serves THAT tree with a vite dev server, dumps, and runs THIS -// gate on the result — so what it reads is the pinned SOURCE. -// node `scripts/gen-sdui-manifest-node.mjs` writes the TRACKED repo-root -// artefact under plain Node — no browser, no objectui build. It -// enumerates the PUBLISHED `@object-ui/*` packages at the version -// `--objectui-version` names, else the one the PINNED commit's -// `packages/core/package.json` declares — and REFUSES when neither -// answers. `scripts/sdui-manifest.record.json` is what that run -// WRITES, never where it reads the version from. -// -// ⚠️ "The registry is a browser app so nothing enumerates it from Node" was true -// when this header was written and is now FALSE — measured 2026-08-29, -// re-measured 2026-08-30 against published `@object-ui/*` 17.6.0, and reproduced -// byte-identically in review of #18608; the node producer's header carries the -// readings. ⛔ Do not put an issue number back on this line: the objectui -// citation this used to carry resolves to nothing — 404 for both the issue and -// the PR at that number, with three neighbouring objectui numbers answering 200 -// as controls — and the readings above stand without one. -// -// ⚠️ The two producers disagree TODAY, and the reason is the input, not the code: -// objectui bumps its `version` only at release, so the version read off the pinned -// commit's `packages/core/package.json` names the PREVIOUS release's tarball. The -// node route therefore describes the registry of the last PUBLISHED release, while -// the browser route describes the PINNED SOURCE — and `check-sdui-manifest.mjs` -// cannot see the gap, because it verifies the record's sha and pin, never that the -// version installed corresponds to the pinned commit (#17735). -// -// NOTHING HERE PRODUCES one — but since #13446 one is CHECKED IN. The production -// half is unchanged and still measured: `packages/console/dist/` is gitignored (the -// package tracks 4 files, none of them a dist), `scripts/build-console.sh` -// deliberately does not produce one — it must not drag a browser into the console -// build, and says so — and the published `@objectstack/console` tarball has none -// either (16.1.0: 513 files, zero `sdui` matches; its `dist/manifest.json` is the PWA -// manifest), so even the CLI's `@objectstack/console/dist/sdui.manifest.json` -// fallback resolves to nothing. -// -// What DID change is the fallback: `sdui.manifest.json` is committed at the -// repository root, `scripts/sdui-manifest.record.json` pins it to the `.objectui-sha` -// it was dumped from, and `lint.yml` runs this gate against it on every PR -// (`MANIFEST="$PWD/sdui.manifest.json"`). ⛔ So do not read a non-zero exit here as -// "no input available": the input is in the tree, and the refusal text now probes for -// it rather than asserting its absence (#16715 — `./manifest-prescription.ts`, which -// carries what that stale assertion cost). -// -// Therefore "no manifest" never means "nothing to check" here. It means THIS GATE -// DID NOT RUN — reported as exit 1, not as a `⚠` and exit 0. Until #4690 the two -// were confused: the gate was in no workflow at all, and a manual run without a -// MANIFEST skipped and exited 0, so no path existed on which it could ever go red. -// That is the sample AGENTS.md's "Absence must be loud" describes — a verifier that -// silently degrades is worse than no verifier, because it reports success. -// -// Run: MANIFEST=… pnpm --filter @objectstack/spec check:react-declaration-parity -// (or `pnpm sdui:manifest`, which produces the manifest and runs this for you) -// -// Baseline ratchet (cheap CI posture). The full spec↔registry 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 registry-only set, and fail only on NEW divergence. -// -// --baseline compare current state against a committed baseline and -// report only regressions (a block declares a NEW -// undocumented input, or a previously-present block vanished). -// --update with --baseline, (re)write the baseline from the current -// manifest instead of comparing. Run after an intentional -// registry change to accept the new state. -// --strict exit 1 on divergence (plain mode) or regression (baseline). - -process.env.OS_EAGER_SCHEMAS = '1'; - -import fs from 'fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { z } from 'zod'; -import { manifestPrescription, repoManifestIsCheckedIn } from './manifest-prescription'; -import { REACT_BLOCKS } from '../src/ui/react-blocks'; -import { ComponentPropsMap } from '../src/ui/component.zod'; -import { PageComponentSchema } from '../src/ui/page.zod'; - -/** - * The SDUI `object-*` page blocks whose props schemas entered - * `ComponentPropsMap` at #7751 (maintainer ruling 2026-08-12, direction A). - * The ruling's third point assigned their spec↔objectui parity burden to THIS - * existing gate rather than a new one, so they ride the same comparison and - * the same baseline ratchet as the react blocks: spec side from the zod - * schema, registry side from the manifest, `registry-only` ratcheted, - * `spec-only` soft (their declared sets are renderer-read supersets of the - * registry `inputs`, so surplus is expected — ADR-0082 §2). - * - * Derived from the map rather than restated, so an `object-*` entry added to - * the spec is covered by this gate the day it lands. Baseline keys use the - * TYPE itself (`object-grid`), which cannot collide with the react blocks' - * PascalCase tags. - */ -const SDUI_OBJECT_BLOCK_TYPES = Object.keys(ComponentPropsMap) - .filter((t) => t.startsWith('object-')) - .sort(); - -/** - * Exit loudly because the NODE-LEVEL half of the accepted set could not be read. - * - * Same posture as {@link cannotRun} and for the same reason (#4690): a derivation - * that silently comes back empty does not make this gate lenient, it makes it - * catastrophically strict — every node-level key on every block would report as an - * invented registry input, i.e. the exact false failure #13192 exists to remove, - * restored by a shrug. Independent of `--strict`: no comparison can be trusted. - */ -function cannotDeriveNodeContract(reason: string): never { - console.error(`✗ react-blocks declaration parity: ${reason}`); - console.error(' This gate did NOT run. That is a failure, not a skip (#4690/#13192).'); - console.error( - [ - '', - " The accepted set per block is the FULL node contract — PageComponentSchema's own", - " node-level keys UNION the block's ComponentPropsMap props — and the node-level half", - ' is derived from the shape, never listed (#13192).', - '', - ' PageComponentSchema ends in `.transform(normalizeVisibleWhen)`, so it is a ZodPipe and', - ' `z.toJSONSchema` of the pipe itself yields NO properties; the authorable key shape is', - ' the pipe INPUT side, `_def.in`. If zod moved that internal, re-point the reader in', - ' `deriveNodeContractKeys()` below.', - '', - ' ⛔ Do NOT "fix" this by restoring a hand-listed key set. The list this derivation', - ' replaced named five of the twelve keys the shape accepts, and each of the other', - ' seven was one registry declaration away from the same false failure.', - ].join('\n'), - ); - process.exit(1); -} - -/** - * A JSON-Schema entry that accepts NOTHING — `{ not: {} }`, which is how - * `z.toJSONSchema` spells `z.never()`. - * - * That is what a key retired with `retiredKey()` looks like: the tombstone stays - * in the shape (so `z.input` keeps typing it `never` and the parse keeps the - * removal's prescription) while the spec refuses every value. `responsive` is one - * today. ⚠️ A tombstone must NOT join the accepted set — the spec does not accept - * the key, so a registry still declaring it as an input is a REAL finding and has - * to stay red. Sweeping the whole shape in without this discrimination is how a - * widening turns a gate vacuous. - */ -function acceptsNothing(entry: unknown): boolean { - if (!entry || typeof entry !== 'object') return false; - const not = (entry as { not?: unknown }).not; - return !!not && typeof not === 'object' && Object.keys(not as object).length === 0; -} - -/** - * The node-level half of the accepted set, read off `PageComponentSchema`'s own - * key shape — DERIVED, never listed, so the node-level key that lands next month - * is covered the day it lands rather than after it has produced a false failure. - * - * Measured on this ref: twelve keys — `aria`, `className`, `dataSource`, `events`, - * `id`, `label`, `properties`, `responsiveStyles`, `style`, `type`, `visibility`, - * `visibleWhen` — plus the `responsive` tombstone, which {@link acceptsNothing} - * drops. The literal this replaced listed five of those twelve. - */ -function deriveNodeContractKeys(): Set { - const pipe = PageComponentSchema as unknown as { _def?: { in?: unknown } }; - const shapeSource = pipe?._def?.in ?? PageComponentSchema; - let js: any; - try { - js = z.toJSONSchema(shapeSource as any, { unrepresentable: 'any' } as any); - } catch (err) { - cannotDeriveNodeContract( - `PageComponentSchema's key shape is unreadable — z.toJSONSchema threw: ${(err as Error).message}`, - ); - } - if (js?.$ref && js?.$defs) js = js.$defs[String(js.$ref).split('/').pop()!] ?? js; - const props = js?.properties; - if (!props || typeof props !== 'object' || Object.keys(props).length === 0) { - cannotDeriveNodeContract( - "PageComponentSchema's key shape read as EMPTY — no node-level key could be derived.", - ); - } - return new Set(Object.keys(props).filter((k) => !acceptsNothing(props[k]))); -} - -const NODE_CONTRACT_KEYS = deriveNodeContractKeys(); - -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'); - -/** - * The scope caveat the report carries on EVERY run, success included. - * - * Deliberately in the OUTPUT and not only in this header: whoever forms a belief - * about what "✓ no new divergence" means is reading a CI log, not this file. - * #4472's finding is that the claim travelled further than the capability, so - * the correction has to travel with the result. - */ -const SCOPE_NOTE = - 'Scope: compares two DECLARATIONS — spec zod schema props vs registry-declared inputs.\n' + - ' No renderer is inspected. A prop BOTH sides declare and NO renderer reads counts\n' + - ' as agreement here; that blind spot is what #4413 shipped through (see #4472).'; - -/** How an accepted node-level input is named in the per-block detail lines. */ -const NODE_LEVEL_ACCEPT_LABEL = 'registry declares, spec accepts at NODE level (not a per-block prop)'; - -/** - * What the gate accepted at node level, printed on EVERY run. - * - * Same reason the scope note is in the output rather than only in this header - * (#4472): a widening that is invisible in the CI log cannot be audited, and the - * whole risk of widening an accepted set is that it quietly becomes everything. - * Naming the twelve keys — and saying that tombstones are excluded — makes the - * calibration readable from the log a person actually forms a belief from. - */ -const NODE_CONTRACT_NOTE = - `Node contract: the accepted set per block is node-level keys ∪ this block's props ∪ overlay.\n` + - ` ${NODE_CONTRACT_KEYS.size} node-level keys, DERIVED from PageComponentSchema's own key shape (#13192):\n` + - ` ${[...NODE_CONTRACT_KEYS].sort().join(', ')}\n` + - ' Keys the shape RETIRES (`z.never()` tombstones) are excluded, so a registry input\n' + - ' naming one is still reported — a widening that swept those in would be vacuous.'; - -/** - * The PER-BLOCK half of the accepted set: the props this block's own schema - * declares, minus anything the node-level contract already carries. - * - * The subtraction used to be a five-name literal (`aria`, `type`, `id`, - * `className`, `style`); it is now {@link NODE_CONTRACT_KEYS}, the same derived - * set the registry side is judged against — one source for "what is node-level", - * because two would be the same drift kept in two places (#13192). - */ -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) => !NODE_CONTRACT_KEYS.has(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); -} - -/** - * The repository root, resolved from THIS FILE's own location. - * - * Not from `process.cwd()` and not from an env var: the prescription's whole job is - * to tell a reader where the manifest is, and a cwd-derived answer would be wrong for - * exactly the reader who is lost. `packages/spec/scripts/` → three levels up. - */ -const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); - -/** - * The prescription every "could not run" exit carries — probed, not asserted. - * - * Text and probe live in `./manifest-prescription.ts` so both branches (a manifest - * checked in at the root, and none) are unit-testable without a repository that has - * to be in two states at once. #16715 is what a single unprobed branch cost. - */ -const MANIFEST_PRESCRIPTION = manifestPrescription({ - repoRoot: REPO_ROOT, - checkedIn: repoManifestIsCheckedIn(REPO_ROOT), -}); - -/** - * Exit loudly because the gate could not run — never because it ran and disagreed. - * - * Deliberately independent of `--strict`: that flag decides what a DIVERGENCE costs, - * and this is the other thing entirely — no comparison happened at all. Making it - * conditional would re-create #4690's hole one flag deeper. - */ -function cannotRun(reason: string): never { - console.error(`✗ react-blocks declaration parity: ${reason}`); - console.error(' This gate did NOT run. That is a failure, not a skip (#4690).'); - console.error(MANIFEST_PRESCRIPTION); - process.exit(1); -} - -if (!MANIFEST) cannotRun('MANIFEST is not set — there is no registry side to compare against.'); -if (!fs.existsSync(MANIFEST)) cannotRun(`MANIFEST=${MANIFEST} does not exist.`); - -let manifest: any; -try { - manifest = JSON.parse(fs.readFileSync(MANIFEST, 'utf8')); -} catch (err) { - cannotRun(`MANIFEST=${MANIFEST} is not readable JSON — ${(err as Error).message}`); -} -if (!manifest || typeof manifest !== 'object') { - cannotRun(`MANIFEST=${MANIFEST} is not an object — expected { components: { : { inputs: [...] } } }.`); -} -// An empty dump is the same defect wearing a manifest: every block would report as -// "missing", which under --strict reads as a catastrophic regression and without it -// as a green run that compared against nothing. objectui's dumper already refuses to -// write one ("empty manifest — registry not populated"); refuse to read one too. -if (Object.keys((manifest.components ?? manifest) as Record).length === 0) { - cannotRun(`MANIFEST=${MANIFEST} declares no components — an empty registry dump compares against nothing.`); -} -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 registry-only -// input set (the registry config declares it, the spec does not) and whether the -// block is missing from the manifest entirely. -type BlockState = { registryOnly: string[]; missing: boolean }; -const current: Record = {}; - -console.log('# Spec ↔ registry declaration parity (react blocks)\n'); -console.log(SCOPE_NOTE + '\n'); -console.log(NODE_CONTRACT_NOTE + '\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++; - current[b.tag] = { registryOnly: [], missing: true }; - continue; - } - const inputSet = new Set(inputs); - const ov = overlay(b); - const specOnly = [...spec].filter((p) => !inputSet.has(p) && !ov.has(p)); - const nodeLevel = [...inputSet].filter((p) => NODE_CONTRACT_KEYS.has(p) && !spec.has(p) && !ov.has(p)); - const registryOnly = [...inputSet].filter((p) => !spec.has(p) && !ov.has(p) && !NODE_CONTRACT_KEYS.has(p)); - const declaredByBoth = [...spec].filter((p) => inputSet.has(p)); - totalSpecOnly += specOnly.length; - current[b.tag] = { registryOnly: registryOnly.slice().sort(), missing: false }; - const status = specOnly.length === 0 ? '✓' : '⚠'; - console.log( - `${status} <${b.tag}> (${b.schemaType}): ${declaredByBoth.length} declared by both, ${specOnly.length} spec-only, ${registryOnly.length} registry-only, ${nodeLevel.length} node-level`, - ); - if (specOnly.length) console.log(` spec declares, registry does not: ${specOnly.join(', ')}`); - if (nodeLevel.length) console.log(` ${NODE_LEVEL_ACCEPT_LABEL}: ${nodeLevel.sort().join(', ')}`); - if (registryOnly.length) console.log(` registry declares, spec does not: ${registryOnly.join(', ')}`); -} -// ── SDUI object-* blocks (#7751) — same comparison, same ratchet ───────────── -console.log('\n# Spec ↔ registry declaration parity (SDUI object-* blocks, #7751)\n'); -for (const type of SDUI_OBJECT_BLOCK_TYPES) { - const schema = (ComponentPropsMap as Record)[type]; - const spec = new Set(specProps(schema)); - const inputs = manifestInputs(manifest, type); - if (inputs === null) { - console.log(`✗ ${type}: NO component in the manifest — not registered or not public.`); - totalMissingComp++; - current[type] = { registryOnly: [], missing: true }; - continue; - } - const inputSet = new Set(inputs); - const specOnly = [...spec].filter((p) => !inputSet.has(p)); - const nodeLevel = [...inputSet].filter((p) => NODE_CONTRACT_KEYS.has(p) && !spec.has(p)); - const registryOnly = [...inputSet].filter((p) => !spec.has(p) && !NODE_CONTRACT_KEYS.has(p)); - const declaredByBoth = [...spec].filter((p) => inputSet.has(p)); - totalSpecOnly += specOnly.length; - current[type] = { registryOnly: registryOnly.slice().sort(), missing: false }; - const status = specOnly.length === 0 ? '✓' : '⚠'; - console.log( - `${status} ${type}: ${declaredByBoth.length} declared by both, ${specOnly.length} spec-only, ${registryOnly.length} registry-only, ${nodeLevel.length} node-level`, - ); - if (specOnly.length) console.log(` spec declares, registry does not: ${specOnly.join(', ')}`); - if (nodeLevel.length) console.log(` ${NODE_LEVEL_ACCEPT_LABEL}: ${nodeLevel.sort().join(', ')}`); - if (registryOnly.length) console.log(` registry declares, spec does not: ${registryOnly.join(', ')}`); -} - -console.log( - `\nSummary: ${totalSpecOnly} spec-only divergences, ${totalMissingComp} blocks missing from the registry.`, -); -console.log(' "declared by both" is a statement about the two declarations, not about the renderer.'); - -// ── Baseline ratchet ───────────────────────────────────────────────────────── -if (BASELINE) { - type Baseline = { blocks: Record }; - if (UPDATE_BASELINE) { - const out: Baseline = { blocks: current }; - fs.writeFileSync( - BASELINE, - JSON.stringify( - { - _comment: - 'Accepted spec↔registry DECLARATION-PARITY baseline (react blocks). Per block: the registry-only input set (the registry config declares it, the spec does not) and whether the block is missing. Regenerate with: MANIFEST=… check:react-declaration-parity --baseline --update. The ratchet flags only NEW registry-only inputs or newly-missing blocks. It compares two declarations and inspects no renderer, so a prop both sides declare and nothing reads records as agreement here (#4413/#4472).', - ...out, - }, - null, - 2, - ) + '\n', - 'utf8', - ); - console.log(`\n✓ wrote declaration-parity 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 baseRO = new Set(base?.registryOnly ?? []); - const newRO = state.registryOnly.filter((p) => !baseRO.has(p)); - if (newRO.length) regressions.push(`<${tag}>: new registry-only input(s) not in baseline: ${newRO.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 DECLARATION divergence vs accepted baseline (see the scope note above).'); - process.exit(0); - } - console.log('⚠ NEW declaration divergence vs accepted baseline:'); - for (const r of regressions) console.log(` - ${r}`); - console.log( - '\n → If intentional (the registry added an input / the spec is meant to follow), either declare it\n' + - ' in the spec schema, add it to the block overlay in packages/spec/src/ui/react-blocks.ts, or\n' + - ' accept it by rerunning with --update.', - ); - process.exit(FAIL_ON_DIVERGENCE ? 1 : 0); -} - -if (FAIL_ON_DIVERGENCE && (totalSpecOnly > 0 || totalMissingComp > 0)) { - console.error('Declaration-parity check failed (--strict).'); - process.exit(1); -} diff --git a/packages/spec/scripts/gen-sdui-manifest-cleanup.test.ts b/packages/spec/scripts/gen-sdui-manifest-cleanup.test.ts deleted file mode 100644 index 42500933aa..0000000000 --- a/packages/spec/scripts/gen-sdui-manifest-cleanup.test.ts +++ /dev/null @@ -1,240 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// -// Pins the cleanup contract of `scripts/gen-sdui-manifest.sh`. -// -// WHY THIS IS WORTH AN EXECUTED TEST AND NOT A SOURCE-LEVEL ASSERTION. -// -// The defect this guards was a cleanup that REPORTED success and did nothing: -// the script armed `trap 'kill "$DUMP_DEV_PID"' EXIT`, the trap ran, the script -// printed its own tidy failure message — and the dev server was measured still -// alive 20 minutes later, holding the container's shared heavy-verify flock, so -// every later agent's build queued out at exit 99 with no signal at all. A -// grep-level test ("does the file mention setsid?") would have passed against -// the broken version too, because the broken version also mentioned `kill`. The -// only assertion that distinguishes them is running the trap path and looking -// at what is still breathing afterwards. -// -// The three properties below are each independently load-bearing; the incident -// needed only one of them to be false. -// -// 1. The server runs in its OWN session. Not cosmetic: a background job in a -// non-interactive shell inherits the SCRIPT's process group, and under the -// agent heavy-verify discipline that group is led by the wrapping `flock` -// itself. So `kill -- -$PGID` on the inherited group would kill the -// caller's lock holder and the script. `setsid` is what makes a group kill -// bounded, and this asserts the boundary actually exists. -// -// 2. Nothing in that session holds a descriptor on the caller's lock file. -// `flock(1)` holds its lock on an open fd and background children inherit -// open fds, which is the single step that converts "a leaked dev server" -// into "this container is closed for business". This half matters even -// when the kill works, because it is what makes a MISSED kill survivable. -// -// WHAT THIS COUNT MUST BE ORDERED AFTER, AND WHY THE PIDFILE IS NOT IT. -// `sdui_spawn_detached` returns as soon as the pidfile is non-empty, and -// its runner writes that pid on its FIRST line -- before the loop that -// closes the inherited descriptors. Measured on this tree: the pid is -// published 151-297us before the close completes, and the ONLY thing that -// kept a pidfile-ordered reading green was the caller's unrelated 50ms -// polling sleep (margin 10-54ms, from a 0.2ms critical section). So the -// pidfile is a barrier for "the leader exists", never for "the leader has -// let go of my descriptors", and a count ordered on it reads a state the -// spawn legitimately passes through -- observed once in CI as LOCKFDS=1 -// with LEADER_ALIVE_AT_COUNT=yes and the leader still `bash`, reproduced -// here by lagging the runner between those two steps. The barrier below is -// the spawned command's OWN post-exec report instead: `exec` replaces the -// runner that does the closing, so that line cannot exist until the close -// has run. We wait for the REPORT, never for a value -- the stub prints -// whatever it counts, so a real leak still arrives as a red. -// -// 3. After the trap, nothing from that session survives — specifically -// including a process that has been reparented to init. That is the case -// `kill "$!"` provably cannot reach, and the test asserts the orphan -// really existed before cleanup (see ORPHAN_BEFORE below) so that a green -// line here can never mean "nothing was ever spawned". -// -// The stub deliberately does not involve vite or pnpm: this pins the lifecycle -// the script owns, and a real console build is neither available nor relevant -// to whether the trap reaps what it started. - -import { describe, it, expect } from 'vitest'; -import { execFileSync } from 'node:child_process'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const HERE = path.dirname(fileURLToPath(import.meta.url)); -const SCRIPT = path.resolve(HERE, '..', '..', '..', 'scripts', 'gen-sdui-manifest.sh'); - -function have(bin: string): boolean { - try { - execFileSync('sh', ['-c', `command -v ${bin}`], { stdio: 'ignore' }); - return true; - } catch { - return false; - } -} - -// Linux-only by construction: the failure being pinned is an agent-container -// one, and the harness reads /proc to see which descriptors a child inherited. -const RUNNABLE = process.platform === 'linux' && ['setsid', 'flock', 'pgrep', 'fuser'].every(have); - -describe.skipIf(!RUNNABLE)('gen-sdui-manifest.sh cleanup contract', () => { - it('reaps the whole session it started, holds no caller fd, and leaves nothing behind', () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sdui-cleanup-')); - const lock = path.join(dir, 'caller.lock'); - const harness = path.join(dir, 'harness.sh'); - const devlog = path.join(dir, 'dev.log'); - const release = path.join(dir, 'release'); - fs.writeFileSync(lock, ''); - - // A stub server that reproduces the shape that defeats `kill "$!"`: it - // spawns a helper and then exits, leaving the helper reparented to init - // while still inside the session the script created. - // - // It also takes the in-child reading of property (2) and publishes the two - // facts the harness needs to order itself: this code runs only after `exec` - // replaced the shell that closes the inherited descriptors, so every line - // it prints is causally after that close. It exits on a release file rather - // than a wall-clock timer, so "was the leader alive when we counted?" stops - // being a race against node's boot time; the 60s cap is a self-destruct for - // the case where the harness dies without reaping, never a wait anyone - // relies on. - const stub = [ - 'const fs = require("node:fs");', - 'const { spawn } = require("node:child_process");', - 'const LOCK = fs.realpathSync(process.argv[1]); const RELEASE = process.argv[2];', - 'const mine = fs.readdirSync("/proc/self/fd").filter((n) => {', - ' try { return fs.readlinkSync("/proc/self/fd/" + n) === LOCK; } catch { return false; }', - '}).length;', - 'const helper = spawn(process.execPath, ["-e", "setInterval(() => {}, 1e9)"], { stdio: "ignore" });', - 'fs.writeSync(1, "CHILD_LOCKFDS=" + mine + "\\nHELPER=" + helper.pid + "\\nEXECED=1\\n");', - 'setInterval(() => { if (fs.existsSync(RELEASE)) process.exit(0); }, 25);', - 'setTimeout(() => process.exit(0), 60000);', - ].join(''); - - fs.writeFileSync( - harness, - [ - '#!/usr/bin/env bash', - 'set -euo pipefail', - // Sourcing runs no generation — the script returns right after defining - // its lifecycle helpers, so this exercises the REAL functions. - `source ${JSON.stringify(SCRIPT)}`, - `DEVLOG=${JSON.stringify(devlog)}`, - `RELEASE=${JSON.stringify(release)}`, - `LOCK=${JSON.stringify(lock)}`, - `LOCKBASE=${JSON.stringify(path.basename(lock))}`, - 'DUMP_PID_FILE="$(mktemp "${TMPDIR:-/tmp}/sdui-dump-pid.XXXXXX")"', - `trap 'sdui_stop_detached "$DUMP_PID_FILE"' EXIT`, - 'sdui_spawn_detached "$DUMP_PID_FILE" "$DEVLOG" \\', - ` "$(command -v node)" -e ${JSON.stringify(stub)} "$LOCK" "$RELEASE"`, - 'LEADER="$(cat "$DUMP_PID_FILE")"', - 'echo "LEADER=$LEADER"', - 'echo "LEADER_SID=$(ps -o sid= -p "$LEADER" | tr -d " ")"', - 'echo "SCRIPT_PGID=$(ps -o pgid= -p $$ | tr -d " ")"', - // Barrier 1 — the spawned command's own post-exec report. NOT the - // pidfile: see the note on property (2) in this file's header. This - // waits for the reading to become TAKEABLE, never for it to become - // zero. - 'CHILD_REPORTED=no', - 'for _i in $(seq 1 600); do', - ' if grep -q "^EXECED=1$" "$DEVLOG" 2>/dev/null; then CHILD_REPORTED=yes; break; fi', - ' sleep 0.05', - 'done', - 'echo "CHILD_REPORTED=$CHILD_REPORTED"', - 'CHILD_LOCKFDS="$(sed -n "s/^CHILD_LOCKFDS=//p" "$DEVLOG" 2>/dev/null || true)"', - 'HELPER="$(sed -n "s/^HELPER=//p" "$DEVLOG" 2>/dev/null || true)"', - 'echo "CHILD_LOCKFDS=$CHILD_LOCKFDS"', - 'echo "HELPER=$HELPER"', - // Barrier 2 — the helper has joined the session, so the session-wide - // count below covers both members instead of whoever happened to exist. - 'HELPER_IN_SESSION=no', - 'for _i in $(seq 1 200); do', - ' MEMB="$(pgrep -s "$LEADER" 2>/dev/null | tr "\\n" " " || true)"', - ' case " $MEMB " in *" $HELPER "*) HELPER_IN_SESSION=yes ;; esac', - ' if [ "$HELPER_IN_SESSION" = yes ]; then break; fi', - ' sleep 0.05', - 'done', - 'echo "HELPER_IN_SESSION=$HELPER_IN_SESSION"', - // The session-wide count, now ordered after both barriers. Naming the - // holders turns a future red from "1, expected 0" into a process. - 'FDS=0', - 'HOLDERS=""', - 'for p in $(pgrep -s "$LEADER"); do', - ' n=$(ls -l "/proc/$p/fd" 2>/dev/null | grep -c "$LOCKBASE" || true)', - ' if [ "$n" != 0 ]; then HOLDERS="$HOLDERS $p($(tr -d "\\0\\n" < "/proc/$p/comm" 2>/dev/null || echo unknown))"; fi', - ' FDS=$((FDS + n))', - 'done', - 'echo "LOCKFDS=$FDS"', - 'echo "LOCKFD_HOLDERS=[$HOLDERS]"', - `echo "LEADER_ALIVE_AT_COUNT=$(kill -0 "$LEADER" 2>/dev/null && echo yes || echo no)"`, - // Now release the stub, so its helper is reparented to init before - // cleanup, and wait for that to have HAPPENED rather than sleeping a - // guessed interval. A timeout leaves ORPHAN_BEFORE empty, which the - // precondition assertion reads as a red. - ': > "$RELEASE"', - 'ORPHAN=""', - 'MEMBERS=""', - 'for _i in $(seq 1 400); do', - ' MEMBERS="$(pgrep -s "$LEADER" 2>/dev/null | tr "\\n" " " || true)"', - ' for p in $MEMBERS; do', - ' if [ "$(ps -o ppid= -p "$p" 2>/dev/null | tr -d " ")" = "1" ]; then ORPHAN="$p"; fi', - ' done', - ' if [ -n "$ORPHAN" ]; then break; fi', - ' sleep 0.05', - 'done', - 'echo "MEMBERS=$MEMBERS"', - 'echo "ORPHAN_BEFORE=$ORPHAN"', - // Falling off the end fires the EXIT trap, which is the path under test. - ].join('\n'), - { mode: 0o755 }, - ); - - const out = execFileSync( - 'flock', - ['-w', '120', lock, '-c', `bash ${JSON.stringify(harness)}`], - { encoding: 'utf8', timeout: 120_000 }, - ); - const field = (k: string): string => (out.match(new RegExp(`^${k}=(.*)$`, 'm'))?.[1] ?? '').trim(); - - const leader = field('LEADER'); - expect(leader, out).toMatch(/^\d+$/); - - // (1) its own session — so the group kill cannot reach the caller's flock. - expect(field('LEADER_SID'), out).toBe(leader); - expect(field('SCRIPT_PGID'), out).not.toBe(leader); - - // The barriers themselves: a timed-out barrier must arrive as a named red, - // not as a count taken at some other moment. - expect(field('CHILD_REPORTED'), out).toBe('yes'); - expect(field('HELPER_IN_SESSION'), out).toBe('yes'); - - // (3, precondition) the orphan the naive `kill "$!"` cannot reach really - // existed. Without this, the survivor assertion below could pass vacuously. - expect(field('ORPHAN_BEFORE'), out).toMatch(/^\d+$/); - - // (2) nothing in the session inherited the caller's lock descriptor. Two - // distinct facts: the surviving-capable process's reading of ITSELF, taken - // after `exec` and therefore after the close; and the session-wide count, - // taken while the leader was still alive and after both barriers. - expect(field('CHILD_LOCKFDS'), out).toBe('0'); - expect(field('LEADER_ALIVE_AT_COUNT'), out).toBe('yes'); - expect(field('LOCKFDS'), out).toBe('0'); - - // (3) the trap reaped the session, orphan included. - let survivors = ''; - try { - survivors = execFileSync('pgrep', ['-s', leader], { encoding: 'utf8' }).trim(); - } catch { - survivors = ''; // pgrep exits 1 when nothing matches — the passing case. - } - expect(survivors, `processes survived cleanup:\n${survivors}`).toBe(''); - - // and the caller's lock is free again. - expect(() => execFileSync('flock', ['-w', '5', lock, '-c', 'true'])).not.toThrow(); - - fs.rmSync(dir, { recursive: true, force: true }); - }, 120_000); -}); diff --git a/packages/spec/scripts/gen-sdui-manifest-collision.test.ts b/packages/spec/scripts/gen-sdui-manifest-collision.test.ts deleted file mode 100644 index bd934b167c..0000000000 --- a/packages/spec/scripts/gen-sdui-manifest-collision.test.ts +++ /dev/null @@ -1,459 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// -// Pins the CONCURRENT-RUN contract of `scripts/gen-sdui-manifest.sh` — the half -// that decides whether the manifest this script writes describes THIS tree. -// -// ## What was measured, and why the obvious fix is only half of one -// -// Agent dispatch containers run several agents against one filesystem and one -// network namespace. The script used to name a fixed port (5180) and a fixed log -// path, so two overlapping runs shared both. Measured against the pinned vite -// (8.2.1) with the requested port already held: -// -// $ vite dev --port 5390 -// Port 5390 is in use, trying another one... -// ➜ Local: http://localhost:5391/ -// -// vite AUTO-INCREMENTS. So run B's server came up on 5391 while run B's wait -// loop and BASE_URL still named 5390 — run B curled run A's server, got 200, -// and dumped A's manifest as its own with exit 0. The output is a real manifest -// and nothing in the run says which tree it describes, which is what makes it -// worse than no manifest: it feeds the ADR-0082 declaration-parity ratchet. -// -// `--strictPort` alone does NOT fix that, and this was measured too rather than -// reasoned: with it, run B's vite exits (`Error: Port 5390 is already in use`) -// into run B's OWN log, while run A keeps answering on the port. A probe that -// asks only "does the port answer?" still gets its 200 and still dumps A's tree. -// So the flag is necessary — it makes "the port we asked for" and "the port we -// got" the same port or no port — but the probe has to ALSO require that the -// server answering is the one this run spawned. Both halves, or neither works. -// -// ## Why these are executed assertions and not greps -// -// A grep for `--strictPort` passes against a file that only mentions the flag in -// a comment, and a grep for "liveness" passes against a check that runs in the -// wrong order. So the flag is asserted through `sdui_dev_server_cmd`, the -// function the script itself builds its argv from, and the refusal is asserted -// by standing up a real neighbouring server on the port and watching the real -// wait function turn it down. -// -// The vacuity guards matter as much as the assertions. `NEIGHBOUR_BODY` proves -// the neighbour was genuinely reachable at the same `http://localhost:/` -// spelling the script probes, and `OUR_LEADER_ALIVE` proves our stand-in server -// was genuinely gone. Without those two lines a green "refused" could mean -// nothing was listening and nothing was checked — the phantom-assertion failure -// the cleanup test's header records paying for once already. -// -// ## The port helper had to become a RESERVATION, and that is measured here too -// -// "picked at run time by the script's own helper" was not enough, and the way -// it failed is worth keeping. The helper used to bind a probe socket, CLOSE it, -// and report the port free; the caller bound it afterwards. The scan is -// deterministic from the base upward, so concurrent callers did not diverge — -// they were handed the SAME port, every time, and the first one every time. -// Measured on this tree with the reservation removed, eight concurrent callers -// from base 5180: -// -// DISTINCT_PORTS=1 of 8 # all eight got 5180 -// BIND_OK=2 BIND_ERR=6 # six lost the follow-up bind: -// # Error: listen EADDRINUSE 127.0.0.1:5180 -// -// That is what dequeued a PR from the merge queue, and it presented HERE — this -// file draws from 5180 three times, and the write-target test beside it drives -// the real path for a fourth, concurrently, in one package's test run. -// -// The subtle half is how it presented. The occupier below lost its OWN bind to -// a concurrent scanner and died, so the following pick returned the occupied -// number quite legitimately — the port really was free by then — and the -// failure read as "the picker does not skip a busy port". It was not that. So -// BUSY_HELD is asserted before PICKED_WITH_BUSY is believed: a precondition -// that can evaporate silently is a test that accuses the wrong function. -// -// ## The precondition needed an OWNED port, not a picked one — measured -// -// That paragraph was still only half of it, and the other half reddened three -// unrelated PRs in one afternoon, byte-identical every time: -// -// BUSY_HELD=no -// BUSY_PORT=5180 -// PICKED_WITH_BUSY=5181 -// -// `PICKED_WITH_BUSY=5181` is the picker answering CORRECTLY — asked to avoid -// 5180 it returned 5181. `BUSY_HELD=no` alone is the failure, and the thief was -// this file. The occupier used to bind the port `sdui_pick_free_port` had just -// handed it, and a RESERVATION IS NOT A BIND: the port stays takeable in the gap -// between the pick returning and the occupier binding. `port_held` below binds -// and closes that exact port, and it is spawned within milliseconds of the -// occupier. Measured, 80 trials on an idle container: -// -// probe won the bind 39/80 # the two genuinely race -// occupier died 1/80 # its bind landed inside the probe's hold -// -// Nothing outside this file has to hold 5180 for that to fire, which is why no -// one could name the process that did. So the occupier binds `:0` and reports -// back the port the kernel gave it: it is already holding that port when it -// names it, which is the property BUSY_HELD asserts, and no gap is left. -// -// ## Why `set +e` follows the `source`, and why the harness ends `exit 0` -// -// `scripts/gen-sdui-manifest.sh` is `set -euo pipefail` at its top, so SOURCING -// it turns errexit back ON here, overriding the `set -uo pipefail` written one -// line earlier. Measured rather than read: `case "$-" in *e*)` after the source -// reports `e` set. That is what truncated the three captures above. When the -// occupier had already exited, the cleanup `kill` returned 1, errexit aborted -// the harness on that line, `execFileSync` threw in the `describe` body, and -// vitest reported `0 test` and a bare `Error: Command failed: bash -// /tmp/sdui-collision-*/harness.sh` — every assertion that would have named the -// problem pre-empted, the diagnosis surviving only because stdout rides along -// on the serialized error. Reproduced here: forcing the occupier to lose its -// bind stopped the harness dead after `PICKED_WITH_BUSY`, exit 1, exactly the -// captured shape. So errexit is turned off again AFTER the source, and the -// harness exits 0 unconditionally: its exit status is not a measurement. Every -// measurement is a printed KEY=VALUE line and the assertions below grade those, -// so a precondition that fails now fails AS AN ASSERTION, printing the whole -// `seen` map with it. -// -// No vite and no console build: the contract under test is one the shell script -// owns, and the ports are picked at run time by the script's own helper so this -// test cannot collide with a concurrent agent — which would be a poor look here. - -import { describe, it, expect } from 'vitest'; -import { execFileSync } from 'node:child_process'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const HERE = path.dirname(fileURLToPath(import.meta.url)); -const SCRIPT = path.resolve(HERE, '..', '..', '..', 'scripts', 'gen-sdui-manifest.sh'); - -function have(bin: string): boolean { - try { - execFileSync('sh', ['-c', `command -v ${bin}`], { stdio: 'ignore' }); - return true; - } catch { - return false; - } -} - -// Linux-only by construction, like the cleanup test beside it: the failure being -// pinned is an agent-container one, and the helpers read process sessions. -const RUNNABLE = - process.platform === 'linux' && ['setsid', 'pgrep', 'curl', 'node'].every(have); - -/** A tiny HTTP server on $SDUI_TEST_PORT, as a `node -e` program. */ -const HTTP_STUB = [ - 'const http = require("node:http");', - 'const s = http.createServer((_q, r) => { r.writeHead(200); r.end("SERVER"); });', - // Losing the bind must EXIT, not throw: an unhandled 'error' event kills the - // harness with a stack trace where a vacuity guard would have named the - // problem. See OWNED_LISTENER below for the same reasoning. - 's.once("error", () => process.exit(1));', - 's.listen(Number(process.env.SDUI_TEST_PORT), "127.0.0.1");', -].join(''); - -/** - * A bare TCP listener that binds an EPHEMERAL port and prints the one it got. - * - * Bind first, name second. Asking the picker for a port and binding it - * afterwards leaves a window in which anything at all — including `port_held` - * below, measured — can take it, and losing there evaporates the precondition - * the BUSY/STEAL assertions rest on. Port 0 closes the window by construction: - * the number is written from inside the `listening` callback, so by the time - * the harness can read it the socket is already held. - * - * The error guard stays. It is near-unreachable on `:0`, but the unguarded form - * of this line is what made the merge-queue failure this file pins so hard to - * read: node threw `Unhandled 'error' event ... EADDRINUSE 127.0.0.1:5180`, the - * harness died mid-measurement, and the report was a stack trace instead of - * "the port this test meant to occupy was never occupied". - */ -const OWNED_LISTENER = [ - 'const s = require("node:net").createServer();', - 's.once("error", () => process.exit(1));', - 's.listen(0, "127.0.0.1", () => console.log(s.address().port));', -].join(''); - -function runHarness(): Record { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sdui-collision-')); - const harness = path.join(dir, 'harness.sh'); - - fs.writeFileSync( - harness, - [ - '#!/usr/bin/env bash', - // Not `set -e`: several steps below are expected to fail, and their exit - // codes are the measurement. - 'set -uo pipefail', - // Sourcing runs no generation — the script returns right after defining - // its helpers, so this exercises the REAL functions. - `source ${JSON.stringify(SCRIPT)}`, - // …but the file just sourced opens with `set -euo pipefail`, so the source - // turns errexit back ON here and silently overrides the line above. That - // is what turned a failed precondition into a harness crash and a `0 test` - // report; see the header. Off again, after the source, where it sticks. - 'set +e', - `DIR=${JSON.stringify(dir)}`, - 'NODE_BIN="$(command -v node)"', - // "is $1 held right now?" — the same probe shape the script uses, so a - // yes here and a skip there are answers to the same question. - 'port_held() {', - ' "$NODE_BIN" -e \'const n=require("node:net");const s=n.createServer();s.once("error",()=>{console.log("yes");process.exit(0)});s.once("listening",()=>s.close(()=>{console.log("no");process.exit(0)}));s.listen(Number(process.argv[1]),"127.0.0.1")\' "$1"', - '}', - '', - '# ── 1. the argv the script actually spawns ──────────────────────────', - 'readarray -t ARGV < <(sdui_dev_server_cmd 4321)', - 'printf "DEV_ARGV=%s\\n" "${ARGV[*]}"', - '', - '# ── 2. the free-port search skips a port that is taken right now ────', - // The busy port is the one the occupier BOUND, not one the picker handed - // it: a reservation is not a bind, and the gap between the two is what - // `port_held` walked through. See the header for the 80-trial count. - 'BUSY_FILE="$DIR/busy.port"', - `"$NODE_BIN" -e ${JSON.stringify(OWNED_LISTENER)} > "$BUSY_FILE" &`, - 'BUSY_PID=$!', - // disown: otherwise bash prints its own "Killed" job notice when this is - // reaped below, which reads like a test failure in the vitest output. - 'disown "$BUSY_PID" 2>/dev/null || true', - // The port appears only once the socket is bound, so waiting for the - // number IS waiting for the hold — there is no second thing to wait for. - 'BUSY=""', - 'for _ in $(seq 1 40); do BUSY="$(tr -d "[:space:]" < "$BUSY_FILE" 2>/dev/null || true)"; [ -n "$BUSY" ] && break; sleep 0.25; done', - // Vacuity guard, and the one this file was missing when it ejected a PR: - // if the occupier never took the port, the pick below returns it and the - // green/red says nothing about the picker. Nothing can lose the port to a - // racer any more; the guard stays because it is what would make the next - // way of losing it legible instead of an accusation of the picker. - 'printf "BUSY_HELD=%s\\n" "$(port_held "$BUSY")"', - 'printf "BUSY_PORT=%s\\n" "$BUSY"', - // An ephemeral base does not risk scanning off the end of the port space: - // the first candidate is the port we hold, the second is free, and the - // scan returns there rather than walking its 200-port span upward. - 'printf "PICKED_WITH_BUSY=%s\\n" "$(sdui_pick_free_port "$BUSY")"', - 'kill -KILL "$BUSY_PID" 2>/dev/null', - '', - '# ── 2b. a third sequential pick from the shared base ────────────────', - // Case 2 used to contribute one of these as a by-product, back when its - // occupier took its port from the picker. It owns an ephemeral port now, - // so the third pick is taken explicitly: without it the "each pick within - // one run its own port" assertion compares two picked ports against one - // ephemeral one, and those differ whatever the registry does — a guard - // that would go green for a reason unrelated to what it guards. - 'RPORT="$(sdui_pick_free_port 5180)"', - 'printf "RPORT=%s\\n" "$RPORT"', - '', - '# ── 3. a NEIGHBOUR answering on our port is refused, not accepted ───', - 'NPORT="$(sdui_pick_free_port 5180)"', - 'printf "NPORT=%s\\n" "$NPORT"', - 'export SDUI_TEST_PORT="$NPORT"', - `"$NODE_BIN" -e ${JSON.stringify(HTTP_STUB)} &`, - 'NEIGHBOUR_PID=$!', - 'disown "$NEIGHBOUR_PID" 2>/dev/null || true', - 'for _ in $(seq 1 40); do curl -sf "http://localhost:$NPORT/" > /dev/null 2>&1 && break; sleep 0.25; done', - // Vacuity guard: the neighbour must really be answering, at the exact - // spelling the script probes, or "refused" below proves nothing. - 'printf "NEIGHBOUR_BODY=%s\\n" "$(curl -sf "http://localhost:$NPORT/" || echo NONE)"', - // Stands in for `vite --strictPort` losing the race: our own server exits - // immediately, leaving the neighbour holding the port. - 'PF="$DIR/lost.pid"', - 'sdui_spawn_detached "$PF" "$DIR/lost.log" "$NODE_BIN" -e "process.exit(1)"', - 'sleep 1', - // Vacuity guard: ours must really be gone before the refusal means anything. - 'printf "OUR_LEADER_ALIVE=%s\\n" "$([ -n "$(sdui_live_pids "$(cat "$PF")")" ] && echo yes || echo no)"', - 'START=$SECONDS', - 'if sdui_wait_for_own_server "$PF" "$NPORT" 20; then', - ' printf "ACCEPTED_NEIGHBOUR=yes\\n"', - 'else', - ' printf "ACCEPTED_NEIGHBOUR=no\\n"', - 'fi', - 'printf "REFUSAL_SECONDS=%s\\n" "$((SECONDS - START))"', - 'kill -KILL "$NEIGHBOUR_PID" 2>/dev/null', - '', - '# ── 4. our OWN server is accepted (the check is not just "always no") ─', - 'OPORT="$(sdui_pick_free_port 5180)"', - 'printf "OPORT=%s\\n" "$OPORT"', - 'export SDUI_TEST_PORT="$OPORT"', - 'PF2="$DIR/own.pid"', - `sdui_spawn_detached "$PF2" "$DIR/own.log" "$NODE_BIN" -e ${JSON.stringify(HTTP_STUB)}`, - 'if sdui_wait_for_own_server "$PF2" "$OPORT" 20; then', - ' printf "ACCEPTED_OWN=yes\\n"', - 'else', - ' printf "ACCEPTED_OWN=no\\n"', - 'fi', - 'sdui_stop_detached "$PF2"', - '', - '# ── 5. no fixed per-run state left in the file ──────────────────────', - // A spelling pin, and labelled as one: the behaviour above is what the - // suite really asserts. This exists so a fixed path cannot be reintroduced - // quietly, since nothing else in the run would notice until two agents - // overlapped again. - `printf "FIXED_LOG_LITERALS=%s\\n" "$(grep -c '/tmp/sdui-dump-dev\\.log' ${JSON.stringify(SCRIPT)} || true)"`, - // ── 5b. every mktemp template is portable: X's terminal, no suffix ─── - // - // A spelling pin like 5 above, and for a defect from the same family: a - // per-run temp path that is not actually per-run. `mktemp - // "…dump-dev.XXXXXX.log"` reads as per-run and is per-run on GNU - // coreutils, which splits the trailing `.log` off as an implied - // `--suffix` and substitutes the X's anyway. That is a coreutils - // extension. BSD mktemp(1) wraps mkstemp(3), which only ever replaces a - // TRAILING run of X's, so on stock macOS nothing is substituted: the - // first run creates the file called literally `sdui-dump-dev.XXXXXX.log` - // and every later run dies there with `File exists`. Reported from a - // real mac; measured here only at the libc layer, where glibc's - // mkstemp("./x.XXXXXX.log") refuses with EINVAL outright. - // - // CI is Linux, so CI can never see this — which is precisely why it wants - // a pin rather than a run. The counted shape is the ASSIGNMENT form, not - // the word `mktemp`: the script's own comments quote the broken spelling - // in order to explain it, and a pattern that read those would be red on - // the fixed tree. - // - // TERMINAL_X is the vacuity guard, in the same spirit as - // STEAL_CLAIM_ON_PICK above: SUFFIXED=0 is also what a file with no - // mktemp templates left in it looks like, so the count of GOOD templates - // is asserted beside the count of bad ones. - `printf "SUFFIXED_MKTEMP_TEMPLATES=%s\\n" "$(grep -cE '^[[:space:]]*[A-Za-z_]+="\\$\\(mktemp "[^"]*X{3,}[^"X][^"]*"' ${JSON.stringify(SCRIPT)} || true)"`, - `printf "TERMINAL_X_MKTEMP_TEMPLATES=%s\\n" "$(grep -cE '^[[:space:]]*[A-Za-z_]+="\\$\\(mktemp "[^"]*X{3,}"' ${JSON.stringify(SCRIPT)} || true)"`, - '', - '# ── 6. concurrent callers from ONE base get DISTINCT ports ──────────', - // The card, as an executed assertion. Eight subshells, one base, at - // once: before the reservation every one of them was handed the same - // number — not sometimes, by construction. Eight makes a regression - // certain to show rather than likely to. - 'mkdir -p "$DIR/picks"', - 'for i in $(seq 1 8); do ( sdui_pick_free_port 5180 > "$DIR/picks/$i" 2>/dev/null; echo >> "$DIR/picks/$i" ) & done', - 'wait', - // grep -c . rather than wc -l: a pick that failed leaves an empty line, - // and counting it would let TOTAL and DISTINCT agree at the wrong value. - 'printf "CONCURRENT_TOTAL=%s\\n" "$(cat "$DIR/picks"/* | grep -c .)"', - 'printf "CONCURRENT_DISTINCT=%s\\n" "$(cat "$DIR/picks"/* | sort -u | grep -c .)"', - '', - '# ── 7. a port held from OUTSIDE the registry is skipped, and the ────', - '# claim taken on it is handed back rather than hoarded ──────────', - // The honest limit of a reservation: it binds only callers that share - // the registry. Against everything else the probe is still the answer, - // and losing there must not leak the claim — a hoarded claim would cost - // a port on every future run in this container. - // Bound, then named — the same precondition case 2 needs, and the same - // way of guaranteeing it. STEAL_HELD is not a weaker assertion than - // BUSY_HELD and had no business resting on a weaker mechanism. - 'STEAL_FILE="$DIR/steal.port"', - `"$NODE_BIN" -e ${JSON.stringify(OWNED_LISTENER)} > "$STEAL_FILE" &`, - 'STEAL_PID=$!', - 'disown "$STEAL_PID" 2>/dev/null || true', - 'SPORT=""', - 'for _ in $(seq 1 40); do SPORT="$(tr -d "[:space:]" < "$STEAL_FILE" 2>/dev/null || true)"; [ -n "$SPORT" ] && break; sleep 0.25; done', - 'printf "STEAL_HELD=%s\\n" "$(port_held "$SPORT")"', - // A registry of its own, so the claim files the two lines below read are - // written by this leg and nothing else. The holder is foreign to that - // registry either way, and more plainly than before: a port bound - // directly from the ephemeral range was never claimed in any registry, - // which is exactly the non-participant this case exists to cover. - 'RESV="$DIR/resv"', - 'STEAL_PICK="$( (export SDUI_PORT_RESERVATION_DIR="$RESV"; sdui_pick_free_port "$SPORT") )"', - 'printf "STEAL_PORT=%s\\n" "$SPORT"', - 'printf "STEAL_PICK=%s\\n" "$STEAL_PICK"', - 'printf "STEAL_CLAIM_RELEASED=%s\\n" "$([ -e "$RESV/$SPORT" ] && echo no || echo yes)"', - // Positive control for the line above. "No claim file for $SPORT" is - // also what a registry that does not exist at all looks like, so on its - // own that line goes green against the version this test was written to - // fail — measured. The claim on the port actually HANDED OUT is the - // half that can only be true when the registry is real. - 'printf "STEAL_CLAIM_ON_PICK=%s\\n" "$([ -e "$RESV/$STEAL_PICK" ] && echo yes || echo no)"', - 'kill -KILL "$STEAL_PID" 2>/dev/null', - '', - // Grading belongs to the assertions, not to whatever the last cleanup - // returned. Reaping an occupier that already exited fails, and under the - // errexit this file used to inherit that failure ended the harness where - // it stood; `execFileSync` then threw in the `describe` body and vitest - // reported `0 test` against a bare "Command failed". Exit 0 and let the - // KEY=VALUE lines above be the evidence — a red then names its assertion - // and prints the whole `seen` map beside it. - 'exit 0', - ].join('\n'), - { mode: 0o755 }, - ); - - const out = execFileSync('bash', [harness], { encoding: 'utf8', timeout: 120_000 }); - const parsed: Record = {}; - for (const line of out.split('\n')) { - const m = /^([A-Z_]+)=(.*)$/.exec(line.trim()); - if (m) parsed[m[1]] = m[2]; - } - fs.rmSync(dir, { recursive: true, force: true }); - return parsed; -} - -describe.skipIf(!RUNNABLE)('gen-sdui-manifest.sh concurrent-run contract', () => { - const seen = RUNNABLE ? runHarness() : ({} as Record); - - it('asks vite for a port it must actually get, or none at all', () => { - // Read off the function the script builds its real argv from, so this cannot - // pass against a file that merely mentions the flag. - expect(seen.DEV_ARGV).toContain('--strictPort'); - expect(seen.DEV_ARGV).toContain('--port 4321'); - }); - - it('picks a per-run port, skipping one that is already taken', () => { - // Precondition before conclusion: the occupier must really hold the port. - // Without this line an occupier that lost its own bind reads as a picker - // that ignores busy ports, which is how this file accused the wrong - // function while ejecting a PR from the merge queue. - expect(seen.BUSY_HELD, JSON.stringify(seen)).toBe('yes'); - expect(seen.BUSY_PORT).toMatch(/^\d+$/); - expect(seen.PICKED_WITH_BUSY).toMatch(/^\d+$/); - expect(seen.PICKED_WITH_BUSY).not.toBe(seen.BUSY_PORT); - }); - - it("refuses a neighbouring run's server answering on this run's port", () => { - // Both guards first: without them a green here can mean "nothing was - // listening and nothing was checked". - expect(seen.NEIGHBOUR_BODY).toBe('SERVER'); - expect(seen.OUR_LEADER_ALIVE).toBe('no'); - - expect(seen.ACCEPTED_NEIGHBOUR).toBe('no'); - // And it says so at once rather than after the full 90s wait, because the - // answer never depended on waiting longer. - expect(Number(seen.REFUSAL_SECONDS)).toBeLessThan(10); - }); - - it('accepts the server this run started', () => { - expect(seen.ACCEPTED_OWN).toBe('yes'); - }); - - it('hands concurrent callers scanning one base distinct ports', () => { - // Vacuity: all eight callers must have produced a port at all. - expect(seen.CONCURRENT_TOTAL, JSON.stringify(seen)).toBe('8'); - expect(seen.CONCURRENT_DISTINCT, JSON.stringify(seen)).toBe('8'); - }); - - it('gives each pick within one run its own port', () => { - // Three ports the PICKER handed out, from one base, in one run. BUSY_PORT - // is deliberately not among them any more: the occupier binds an ephemeral - // port of its own, and an ephemeral port differs from the 5180 band however - // the registry behaves, so counting it here would be free. - const picked = [seen.NPORT, seen.OPORT, seen.RPORT]; - expect(picked.every((p) => /^\d+$/.test(p ?? '')), picked.join(',')).toBe(true); - expect(new Set(picked).size, picked.join(',')).toBe(3); - }); - - it('skips a port held from outside the registry and releases the claim', () => { - expect(seen.STEAL_HELD, JSON.stringify(seen)).toBe('yes'); - expect(seen.STEAL_PICK).toMatch(/^\d+$/); - expect(seen.STEAL_PICK).not.toBe(seen.STEAL_PORT); - // The claim it took before probing must not survive a lost probe — and - // the positive control first, so "released" cannot mean "never taken". - expect(seen.STEAL_CLAIM_ON_PICK, JSON.stringify(seen)).toBe('yes'); - expect(seen.STEAL_CLAIM_RELEASED, JSON.stringify(seen)).toBe('yes'); - }); - - it('keeps no fixed dev-server log path (spelling pin)', () => { - expect(seen.FIXED_LOG_LITERALS).toBe('0'); - }); - - it("keeps every mktemp template portable — X's terminal, no suffix (spelling pin)", () => { - // Vacuity first: zero bad templates is also what zero templates looks like. - expect(seen.TERMINAL_X_MKTEMP_TEMPLATES, JSON.stringify(seen)).toBe('3'); - expect(seen.SUFFIXED_MKTEMP_TEMPLATES, JSON.stringify(seen)).toBe('0'); - }); -}); diff --git a/packages/spec/scripts/gen-sdui-manifest-write-target.test.ts b/packages/spec/scripts/gen-sdui-manifest-write-target.test.ts deleted file mode 100644 index 1783a07cd2..0000000000 --- a/packages/spec/scripts/gen-sdui-manifest-write-target.test.ts +++ /dev/null @@ -1,238 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// -// Pins two contracts of `scripts/gen-sdui-manifest.sh` that were measured -// failing together, in one run, on a tree whose console build was broken. -// -// ## What was measured -// -// `packages/console/dist/` is gitignored and exists only after a successful -// `scripts/build-console.sh`. Nothing created it, and objectui's dumper calls -// `writeFileSync` straight out, so with the directory absent the run died: -// -// [dump] enumerated registry over http://localhost:5180 (HTTP 200) -// Error: ENOENT: no such file or directory, open '.../sdui.manifest.json' -// ✗ manifest generation failed (exit 1). -// If Playwright reported a missing browser, install it and retry: -// pnpm exec playwright install chromium-headless-shell -// -// Two separate defects in those five lines: -// -// 1. The ENOENT arrived AFTER a vite dev server and a chromium launch had -// been paid for, and the ratchet never needed the built dist in the first -// place — this script drives a vite DEV server over `.cache/objectui-*`, -// and `dist/` is only where the manifest lands. So a broken console build -// made the ADR-0082 D4 ratchet unrunnable for want of one `mkdir`. -// -// 2. The remedy printed was Playwright's, for a failure in which the browser -// had already done its job. Following it costs a round on the wrong layer, -// and inside an agent dispatch container it cannot even be followed — -// `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1` is set there. -// -// ## Why these are executed assertions and not greps -// -// A grep for `mkdir -p` passes against one written after the dump, which fixes -// nothing about (1) — the cost is in the ORDER, so the order is what is -// measured: the failing-precondition case asserts the dev server was spawned -// ZERO times. And a grep for the remedy string cannot tell a remedy that is -// printed always from one printed on evidence, which is the whole of (2), so -// the real classifier is sourced and fed real failure text. -// -// The vacuity guards carry as much weight as the assertions. `DUMP_REACHED_ -// SERVER` proves the stub dumper genuinely talked to the server the script -// spawned (otherwise "the run completed" could mean nothing ran), `DIST_EXISTED_ -// BEFORE` proves the directory was really absent, and the Playwright case proves -// the classifier still CAN print the install remedy — without it, "never prints -// Playwright advice" would be green for a script that prints no advice at all. -// -// No vite, no chromium and no console build: vite stands in as a one-line http -// server and the dumper as a node stub that writes OUT with `writeFileSync` -// exactly as objectui's does. The script is COPIED into a temp tree, which is -// what relocates it — `FRAMEWORK_ROOT` is derived from the script's own path, -// so the copy moves `TARGET` into the temp tree and this test cannot write into -// the real `packages/console/dist/`. Ports come from the script's own picker, -// so this cannot collide with a concurrent agent. - -import { describe, it, expect, afterAll } from 'vitest'; -import { execFileSync, spawnSync } from 'node:child_process'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const HERE = path.dirname(fileURLToPath(import.meta.url)); -const SCRIPT = path.resolve(HERE, '..', '..', '..', 'scripts', 'gen-sdui-manifest.sh'); - -function have(bin: string): boolean { - try { - execFileSync('sh', ['-c', `command -v ${bin}`], { stdio: 'ignore' }); - return true; - } catch { - return false; - } -} - -// Linux-only by construction, like the cleanup and collision tests beside it: -// the failure being pinned is an agent-container one, and the script's lifecycle -// helpers read process sessions. -const RUNNABLE = - process.platform === 'linux' && ['setsid', 'pgrep', 'curl', 'node'].every(have); - -const FAKE_SHA = '0123456789abcdef0123456789abcdef01234567'; -const trees: string[] = []; - -/** Stands in for objectui's chromium dumper: reach the dev server, then write OUT. */ -const DUMPER_STUB = [ - "import { writeFileSync } from 'node:fs';", - 'const res = await fetch(process.env.BASE_URL);', - 'console.log(`[dump] enumerated registry over ${process.env.BASE_URL} (HTTP ${res.status})`);', - "writeFileSync(process.env.OUT, JSON.stringify({ blocks: [] }));", -].join('\n'); - -/** Stands in for `pnpm --filter @object-ui/console exec vite dev --port N --strictPort`. */ -const PNPM_STUB = [ - '#!/usr/bin/env bash', - 'port=""; prev=""', - 'for a in "$@"; do [[ "$prev" == "--port" ]] && port="$a"; prev="$a"; done', - 'if [[ -n "$port" ]]; then', - ' printf "spawned %s\\n" "$port" >> "$SDUI_TEST_SPAWN_LOG"', - ' exec node -e \'require("node:http").createServer((_q,r)=>{r.writeHead(200);r.end("DEV")}).listen(Number(process.argv[1]),"127.0.0.1")\' "$port"', - 'fi', - 'exit 0', -].join('\n'); - -interface Run { - exit: number; - output: string; - spawns: number; - manifest: boolean; - distExistedBefore: boolean; -} - -/** - * Build an isolated framework root around a COPY of the real script and run it. - * `blockTarget` makes `TARGET`'s parent a regular FILE, so `mkdir -p` cannot - * succeed even for root — chmod would prove nothing in a container running as - * root, which is where this script's failures are diagnosed. - */ -function runScript(opts: { blockTarget?: boolean } = {}): Run { - const T = fs.mkdtempSync(path.join(os.tmpdir(), 'sdui-write-target-')); - trees.push(T); - - fs.mkdirSync(path.join(T, 'scripts'), { recursive: true }); - fs.mkdirSync(path.join(T, 'bin'), { recursive: true }); - fs.copyFileSync(SCRIPT, path.join(T, 'scripts', 'gen-sdui-manifest.sh')); - fs.writeFileSync(path.join(T, '.objectui-sha'), `${FAKE_SHA}\n`); - - const build = path.join(T, '.cache', `objectui-${FAKE_SHA.slice(0, 12)}`); - fs.mkdirSync(path.join(build, 'apps', 'console', 'dev'), { recursive: true }); - fs.mkdirSync(path.join(build, 'scripts'), { recursive: true }); - fs.writeFileSync(path.join(build, 'apps', 'console', 'dev', 'manifest-dump.html'), ''); - fs.writeFileSync(path.join(build, 'scripts', 'dump-public-manifest.mjs'), DUMPER_STUB); - - const pnpm = path.join(T, 'bin', 'pnpm'); - fs.writeFileSync(pnpm, PNPM_STUB, { mode: 0o755 }); - - if (opts.blockTarget) { - fs.mkdirSync(path.join(T, 'packages'), { recursive: true }); - fs.writeFileSync(path.join(T, 'packages', 'console'), 'not a directory'); - } - - const manifest = path.join(T, 'packages', 'console', 'dist', 'sdui.manifest.json'); - const spawnLog = path.join(T, 'spawned.log'); - fs.writeFileSync(spawnLog, ''); - const distExistedBefore = fs.existsSync(path.dirname(manifest)); - - const res = spawnSync('bash', [path.join(T, 'scripts', 'gen-sdui-manifest.sh')], { - encoding: 'utf8', - timeout: 120_000, - env: { - ...process.env, - PATH: `${path.join(T, 'bin')}:${process.env.PATH ?? ''}`, - SDUI_TEST_SPAWN_LOG: spawnLog, - }, - }); - - return { - exit: res.status ?? -1, - output: `${res.stdout ?? ''}${res.stderr ?? ''}`, - spawns: fs.readFileSync(spawnLog, 'utf8').split('\n').filter(Boolean).length, - manifest: fs.existsSync(manifest), - distExistedBefore, - }; -} - -/** Source the real script and run its real classifier over `text`. */ -function advice(text: string, out: string): string { - const T = fs.mkdtempSync(path.join(os.tmpdir(), 'sdui-advice-')); - trees.push(T); - const log = path.join(T, 'dump.log'); - fs.writeFileSync(log, text); - return execFileSync( - 'bash', - [ - '-c', - `source ${JSON.stringify(SCRIPT)}; sdui_dump_failure_advice ${JSON.stringify(log)} ${JSON.stringify(out)}`, - ], - { encoding: 'utf8', timeout: 30_000 }, - ); -} - -afterAll(() => { - for (const t of trees) fs.rmSync(t, { recursive: true, force: true }); -}); - -const INSTALL_REMEDY = 'playwright install chromium-headless-shell'; -const OUT_PATH = '/tmp/whatever/packages/console/dist/sdui.manifest.json'; - -describe.skipIf(!RUNNABLE)('gen-sdui-manifest.sh output-directory precondition', () => { - const ok = RUNNABLE ? runScript() : ({} as Run); - - it('creates its output directory instead of dying on it', () => { - // Vacuity first: absent beforehand, and the dump really reached the server. - expect(ok.distExistedBefore).toBe(false); - expect(ok.output).toContain('[dump] enumerated registry over'); - - expect(ok.exit).toBe(0); - expect(ok.manifest).toBe(true); - expect(ok.output).not.toContain('ENOENT'); - }); - - it('fails the precondition BEFORE paying for a dev server and a browser', () => { - const blocked = runScript({ blockTarget: true }); - expect(blocked.exit).not.toBe(0); - expect(blocked.output).toContain('could not create the manifest output directory'); - // The point of the whole card: the cost came before the diagnosis. - expect(blocked.spawns).toBe(0); - // And the successful run above did spawn one, so `0` here is a measurement - // rather than a harness that never spawns anything. - expect(ok.spawns).toBe(1); - }); -}); - -describe.skipIf(!RUNNABLE)('gen-sdui-manifest.sh dump-failure diagnosis', () => { - it('prints the Playwright remedy when Playwright is what failed', () => { - const text = [ - "browserType.launch: Executable doesn't exist at", - '/opt/pw-browsers/chromium_headless_shell-1234/chrome-headless-shell-linux64/chrome-headless-shell', - ].join(' '); - expect(advice(text, OUT_PATH)).toContain(INSTALL_REMEDY); - }); - - it('does not blame Playwright for a write failure', () => { - const text = [ - '[dump] enumerated registry over http://localhost:5180 (HTTP 200)', - `Error: ENOENT: no such file or directory, open '${OUT_PATH}'`, - ' at writeFileSync (node:fs:2430:20)', - ].join('\n'); - const said = advice(text, OUT_PATH); - expect(said).not.toContain(INSTALL_REMEDY); - expect(said).toContain('could not WRITE its output'); - expect(said).toContain(path.dirname(OUT_PATH)); - }); - - it('says so plainly when it recognises neither, rather than defaulting to Playwright', () => { - const said = advice("TypeError: Cannot read properties of undefined (reading 'blocks')", OUT_PATH); - expect(said).not.toContain(INSTALL_REMEDY); - expect(said).toContain('NOT identified as a missing Playwright browser'); - }); -}); diff --git a/packages/spec/scripts/manifest-prescription.ts b/packages/spec/scripts/manifest-prescription.ts deleted file mode 100644 index 11642cb73f..0000000000 --- a/packages/spec/scripts/manifest-prescription.ts +++ /dev/null @@ -1,111 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// -// The prescription every "could not run" exit of `check:react-declaration-parity` -// carries — and the PROBE that decides which prescription is true here. -// -// A refusal is only better than a skip if the reader can act on it. The manifest's -// provenance is two repos away from whoever hits this — it is dumped from objectui's -// registry in a browser — so the exit that replaced the skip has to hand over the -// whole path, not just the missing variable's name. -// -// WHY THIS IS A FUNCTION OF A PROBE AND NOT A CONSTANT (#16715). -// -// The single constant this file replaced asserted, unconditionally, that "this -// repository contains no copy of it" and sent the reader off to build objectui and -// dump one in a browser. That stopped being true at #13446, when `sdui.manifest.json` -// was checked in at the repository root and `lint.yml` began running this gate -// against it with `MANIFEST="$PWD/sdui.manifest.json"`. -// -// The prose kept its old certainty, and it is read at the exact moment a reader is -// deciding whether the gate can run at all — the shape that gets believed. Measured -// cost, twice: on #16489 / PR #16697 and again on PR #16777 a dev read it, reported -// the gate as `EXTERNAL_INPUT_REQUIRED` / NOT MEASURED, and one of those declarations -// reached a deliverable — on a head where setting that one variable and re-running -// gave exit 0 and "no new DECLARATION divergence vs accepted baseline". -// -// So: neither branch may assert the other's world. When the file is there the reader -// gets a command to paste; only when it is genuinely absent does the objectui dump -// path appear. Both branches are pinned in -// `check-react-blocks-declaration-parity.test.ts` — one branch tested is how this -// defect was built in the first place. - -import fs from 'node:fs'; -import path from 'node:path'; - -/** Where the manifest is committed, relative to the repository root. */ -export const REPO_MANIFEST_RELATIVE = 'sdui.manifest.json'; - -/** - * Is a manifest checked in at `repoRoot`? - * - * A filesystem probe and nothing else: no env var, no config key, no build state. - * The question is "can the reader point MANIFEST at a file that exists right now", - * and only the filesystem answers that. - */ -export function repoManifestPath(repoRoot: string): string { - return path.join(repoRoot, REPO_MANIFEST_RELATIVE); -} - -export function repoManifestIsCheckedIn(repoRoot: string): boolean { - return fs.existsSync(repoManifestPath(repoRoot)); -} - -/** - * The prescription text, for whichever world the probe found. - * - * `repoRoot` is printed as a `cd` so the block is paste-ready from any working - * directory while still spelling the variable the way CI does — `$PWD` is only - * correct once you are standing at the root, so the command that sets it says how - * to get there. - */ -export function manifestPrescription(opts: { repoRoot: string; checkedIn: boolean }): string { - const { repoRoot, checkedIn } = opts; - - if (checkedIn) { - return [ - '', - " The registry side of this comparison is objectui's sdui.manifest.json, and a copy", - ` IS checked in at this repository's root (${repoManifestPath(repoRoot)}) — the very`, - ' artefact CI feeds this gate. Nothing has to be produced. Run it as CI runs it:', - '', - ` cd ${repoRoot}`, - ' MANIFEST="$PWD/sdui.manifest.json" \\', - ' pnpm --filter @objectstack/spec check:react-declaration-parity \\', - ' --baseline react-declaration-parity.baseline.json --strict', - '', - ' That is a complete local run: no browser, no objectui checkout, no dump step. So a', - ' non-zero exit from THIS gate is a reading, not an unavailable input — do not report', - ' it as EXTERNAL_INPUT_REQUIRED or NOT MEASURED (#16715).', - '', - ' The committed manifest tracks the objectui pin: when .objectui-sha moves, regenerate it', - ' and its record with `node scripts/gen-sdui-manifest-node.mjs` (check-sdui-manifest.mjs', - ' prints the exact call). ⛔ `pnpm sdui:manifest` does NOT rewrite this file: it dumps to', - ' packages/console/dist/, not to the root.', - ].join('\n'); - } - - return [ - '', - " The registry side of this comparison is objectui's sdui.manifest.json, and no copy of", - ` it is checked in at ${repoManifestPath(repoRoot)}. Nothing here can produce one:`, - ' packages/console/dist/ is gitignored, the console build deliberately does not produce', - ' one (it must not pull in a browser), and the published @objectstack/console ships none', - ' either. Produce one, then re-run:', - '', - ` cd ${repoRoot}`, - ' pnpm objectui:build # build + vendor the console at the pinned .objectui-sha', - ' pnpm sdui:manifest # dump the manifest in a browser AND run this ratchet', - '', - ' Against a sibling objectui checkout, point the build at it first:', - '', - ' OBJECTUI_ROOT=../objectui pnpm objectui:build && pnpm sdui:manifest', - '', - ' Or, with a manifest already in hand:', - '', - ' MANIFEST=/path/to/sdui.manifest.json \\', - ' pnpm --filter @objectstack/spec check:react-declaration-parity \\', - ' --baseline react-declaration-parity.baseline.json --strict', - '', - ' (the dump needs a browser: pnpm exec playwright install chromium-headless-shell)', - ].join('\n'); -} diff --git a/packages/spec/scripts/published-projection-choke-point.test.ts b/packages/spec/scripts/published-projection-choke-point.test.ts index b5bc8e04cd..2d6aacb4ad 100644 --- a/packages/spec/scripts/published-projection-choke-point.test.ts +++ b/packages/spec/scripts/published-projection-choke-point.test.ts @@ -27,15 +27,12 @@ * * ## Why an allowance table and not a flat zero * - * Three files in this tree hold calls that are legitimately direct (four calls + * Two files in this tree hold calls that are legitimately direct (two calls * in all), and each is a different reason rather than one exemption repeated: * * - `union-branch-projection.ts`'s `projectsUnderStrictMode` asks zod whether * a node is representable at all and DISCARDS the result — a yes/no * question, not a projection; - * - `check-react-blocks-declaration-parity.ts` reads a schema's accepted KEY - * set twice, for a declaration-parity gate; the override emits keywords, - * never keys, and the gate publishes nothing; * - `build-react-blocks-contract.ts` renders a markdown prop table into the * governed `skills/**` catalog — a published artifact, but not a published * JSON Schema, and rewriting it is a governed-surface decision of its own. @@ -90,11 +87,10 @@ function collectScriptSources(): Map { /** * Direct `z.toJSONSchema(` calls in CODE — comments and string literals both - * masked. Masking literals as well as comments is not tidiness: two files here - * name `z.toJSONSchema()` inside text they PRINT — `openapi-self-consistency.ts` - * tells an author it "threw for these", `check-react-blocks-declaration-parity.ts` - * explains the pipe in a help paragraph. Counted as calls, those earn rows in - * the allowance table for prose, which is the rot the table exists to prevent. + * masked. Masking literals as well as comments is not tidiness: a file here can + * name `z.toJSONSchema()` inside text it PRINTS — `openapi-self-consistency.ts` + * tells an author it "threw for these". Counted as calls, such text earns a row + * in the allowance table for prose, which is the rot the table exists to prevent. */ function directCallCount(source: string): number { return (maskCommentsAndLiterals(source).match(/\bz\.toJSONSchema\s*\(/g) ?? []).length; @@ -117,11 +113,6 @@ const DECLARED_DIRECT_CALLS: ReadonlyArray<{ file: string; count: number; why: s count: 1, why: 'projectsUnderStrictMode asks zod whether a node is representable and DISCARDS the result; nothing it produces is published', }, - { - file: 'check-react-blocks-declaration-parity.ts', - count: 2, - why: "deriveNodeContractKeys() and specProps() read a schema's accepted KEY set for a declaration-parity gate; it publishes nothing, and the override emits keywords, never keys", - }, { file: 'build-react-blocks-contract.ts', count: 1, diff --git a/packages/spec/src/ui/react-blocks.ts b/packages/spec/src/ui/react-blocks.ts index 348b7d8f85..b8883033de 100644 --- a/packages/spec/src/ui/react-blocks.ts +++ b/packages/spec/src/ui/react-blocks.ts @@ -261,7 +261,8 @@ export const REACT_BLOCKS: ReactBlockDef[] = [ // `` above: FormViewSchema is also what a // declarative form VIEW parses, and only the react/registry tier // measurably reads these. Three registry inputs are deliberately NOT - // declared (baselined in react-declaration-parity.baseline.json): + // declared (accepted as registry-only by the since-retired ADR-0082 D4 + // declaration-parity baseline): // `initialData` (alias spelling of `initialValues` — aliases are not // promoted into spec), `mobile` (internal override, not an authoring // surface), `navigateOnSuccess` (parked pending the action-success- diff --git a/packages/spec/vitest.repo-tests.json b/packages/spec/vitest.repo-tests.json index 59cf0def51..f735a56a0d 100644 --- a/packages/spec/vitest.repo-tests.json +++ b/packages/spec/vitest.repo-tests.json @@ -1,16 +1,12 @@ [ "scripts/build-schemas-check-mode.test.ts", "scripts/category-title.test.ts", - "scripts/check-generated-ledger.test.ts", "scripts/def-key-collisions.test.ts", "scripts/dist-freshness-adoption.test.ts", "scripts/dist-freshness.test.ts", "scripts/escape-mdx.test.ts", "scripts/export-list.test.ts", "scripts/file-description.test.ts", - "scripts/gen-sdui-manifest-cleanup.test.ts", - "scripts/gen-sdui-manifest-collision.test.ts", - "scripts/gen-sdui-manifest-write-target.test.ts", "scripts/liveness/evidence.test.ts", "scripts/liveness/proof-registry.test.ts", "scripts/publish-smoke-boot-failure.test.ts", diff --git a/scripts/build-console.sh b/scripts/build-console.sh index 896c2f08c8..50a43619fd 100755 --- a/scripts/build-console.sh +++ b/scripts/build-console.sh @@ -1,7 +1,9 @@ #!/usr/bin/env bash # Build @object-ui/console at the SHA pinned in .objectui-sha and copy # its dist/ into packages/console/ so @objectstack/console can publish -# a version-matched, prebuilt Console SPA alongside the framework. +# a version-matched, prebuilt Console SPA alongside the framework. The +# tracked repo-root sdui.manifest.json ships in that dist too (see the +# block near the end). # # Resolution order for the objectui source tree: # 1. $OBJECTUI_ROOT (if set and a git repo) — explicit override @@ -271,29 +273,43 @@ node "${FRAMEWORK_ROOT}/scripts/assert-console-spec-injection.mjs" \ --vendored "${BUILD_ROOT}/node_modules/@objectstack/spec" \ --assets "${TARGET}/assets" +# ── Ship the TRACKED SDUI manifest (ADR-0080) ──────────────────────── +# One producer, one artefact: the repo-root sdui.manifest.json is written by +# scripts/gen-sdui-manifest-node.mjs from THIS built tree at the pin, and held +# against .objectui-sha (plus its sha256 record) by scripts/check-sdui-manifest.mjs +# in the required lint job. The console dist ships that file as-is. Measured +# before the switch: the browser dump this replaces (the retired +# scripts/gen-sdui-manifest.sh, a vite dev server plus Playwright chromium over +# this same tree) and the node output were `cmp`-identical, so the copy loses +# nothing and the console build gains no browser dependency. Nothing here +# regenerates the manifest: a pin that moved without regeneration is reported +# below, and lint is red on it until the regeneration lands. +MANIFEST_SRC="${FRAMEWORK_ROOT}/sdui.manifest.json" +MANIFEST_RECORD="${FRAMEWORK_ROOT}/scripts/sdui-manifest.record.json" +if [[ ! -f "$MANIFEST_SRC" ]]; then + echo "✗ ${MANIFEST_SRC} is missing — the console dist ships the tracked SDUI manifest." + echo " Regenerate it from the tree this run just built: node scripts/gen-sdui-manifest-node.mjs" + exit 1 +fi +cp "$MANIFEST_SRC" "${TARGET}/sdui.manifest.json" +RECORDED_SHA="$(node -e 'process.stdout.write(String(JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).objectuiSha ?? ""))' "$MANIFEST_RECORD" 2>/dev/null || true)" +if [[ "$RECORDED_SHA" == "$PINNED_SHA" ]]; then + echo "✓ Shipped the tracked sdui.manifest.json (recorded at objectui@${PINNED_SHA:0:12}, this pin)." +else + echo "⚠ Shipped the tracked sdui.manifest.json, but its record names objectui@${RECORDED_SHA:0:12}, not this pin ${PINNED_SHA:0:12}." + echo " Moved the pin? Regenerate from the tree this run just built, then re-run this build to ship it:" + echo " node scripts/gen-sdui-manifest-node.mjs" + echo " scripts/check-sdui-manifest.mjs keeps the required lint job red until the regeneration lands." +fi + BYTES="$(du -sk "$TARGET" 2>/dev/null | awk '{print $1}')" echo "✓ @objectstack/console dist ready (${BYTES} KB) from objectui@${PINNED_SHA:0:12}" -# ADR-0080/0081: neither SDUI manifest is generated here, and the spec↔registry -# react-block declaration-parity ratchet is not run here either. Two different files -# wear that name, and the difference is what the reminder below exists to carry: -# -# sdui.manifest.json (repo root) + scripts/sdui-manifest.record.json — TRACKED, -# written only by `node scripts/gen-sdui-manifest-node.mjs`, which installs the -# published @object-ui packages (no browser). `scripts/check-sdui-manifest.mjs` -# in the required lint job reds once the pin moves and they have not followed, -# and ADR-0082 D4's ratchet reads the tracked manifest on every PR (#12924). -# packages/console/dist/sdui.manifest.json — GITIGNORED, written by -# `pnpm sdui:manifest` (scripts/gen-sdui-manifest.sh), which needs a real browser -# to enumerate the console registry. Nothing gates it; the console build must not -# drag in a browser dependency, so it stays on demand (#5960). -# # The reminder names the TRIGGER, not just the command (#5960): `pnpm objectui:refresh` # runs bump-objectui.sh and then this script, so this is the last output an operator # sees while moving the pin. bump-objectui.sh prints the same step; this repeats it # because that one has scrolled past a whole console build by now. -echo "ℹ SDUI manifest + declaration-parity ratchet are decoupled from the console build." -echo " Moved the objectui pin? Regenerate the TRACKED manifest — the required lint gate:" -echo " node scripts/gen-sdui-manifest-node.mjs --objectui-version {the version the new pin ships}" -echo " It writes sdui.manifest.json + scripts/sdui-manifest.record.json. 'pnpm sdui:manifest'" -echo " writes neither: it is the browser dump to the gitignored packages/console/dist/." +echo "ℹ Moved the objectui pin? Regenerate the TRACKED SDUI manifest — the required lint gate:" +echo " node scripts/gen-sdui-manifest-node.mjs" +echo " It reads the tree this script just built (.cache/objectui-${PINNED_SHA:0:12}/apps/console) and" +echo " rewrites sdui.manifest.json + scripts/sdui-manifest.record.json; no browser, no npm install." diff --git a/scripts/bump-objectui.sh b/scripts/bump-objectui.sh index 002fe71e61..aa467d85c8 100755 --- a/scripts/bump-objectui.sh +++ b/scripts/bump-objectui.sh @@ -8,23 +8,18 @@ # scripts/bump-objectui.sh --no-changeset # skip the @objectstack/console changeset # # After the bump — the second half of the pin-update procedure (#5960, #12924): -# node scripts/gen-sdui-manifest-node.mjs \ -# --objectui-version {the @object-ui version the new pin ships} +# pnpm objectui:build # builds objectui at the NEW pin into .cache/objectui-/ +# node scripts/gen-sdui-manifest-node.mjs # reads that built tree; no arguments # # That call rewrites the two TRACKED files, and nothing else writes them: the # repo-root `sdui.manifest.json` and its provenance record # `scripts/sdui-manifest.record.json`. The required lint job reds the bump PR until # it has run — `scripts/check-sdui-manifest.mjs` asserts the record's pin equals -# `.objectui-sha` — and ADR-0082 D4's spec↔registry declaration-parity ratchet reads -# that same tracked artefact on every PR. No browser and no objectui build; it -# installs the published @object-ui packages into a temp dir. This script prints the -# reminder on its way out. -# -# ⛔ `pnpm sdui:manifest` is a DIFFERENT command and does not clear that gate: it -# dumps objectui's registry from a real browser to the gitignored -# `packages/console/dist/sdui.manifest.json` and ratchets against that untracked -# copy. An independent read of the registry, worth running when you hold an objectui -# checkout and a Playwright chromium — but it writes neither tracked file. +# `.objectui-sha`. No browser and no npm install: it enumerates the registry out of +# the pin's BUILT tree, the one producer (ruling 丙 on #17735), and the console build +# ships that same tracked file in its dist. This script prints the reminder on its +# way out; `pnpm objectui:refresh` (this script, then the build) leaves the tree the +# generator needs. # # The pin must name a commit that is on objectui MAIN. This script WARNS — it # does not refuse — when the revision being pinned is not reachable from the @@ -423,8 +418,8 @@ report_objectui_reachability() { echo " this question is yours to judge.)" echo " Pinning it anyway. Downstream: the release cut re-asks this against a fresh" echo " full clone and REFUSES to cut (#9450), so a bad pin surfaces at RC time at" - echo " the earliest; until then 'pnpm sdui:manifest' would ratchet spec↔registry" - echo " declaration parity against a tree that is not on main (ADR-0082 D4)." + echo " the earliest; until then the committed SDUI manifest regenerated at this pin" + echo " describes a registry that is not on main." echo } >&2 return 0 @@ -700,50 +695,32 @@ fi # --- The other half of the pin-update procedure (#5960, #12924) -------------- # The pin bump is what makes the committed manifest stale, and regenerating it is -# what this prints. Which command writes what is the whole of it: +# what this prints: # +# pnpm objectui:build -> objectui BUILT at the new pin, in +# .cache/objectui-/ # node scripts/gen-sdui-manifest-node.mjs -> sdui.manifest.json (repo root) and # scripts/sdui-manifest.record.json # — both TRACKED, and gated per PR -# pnpm sdui:manifest (gen-sdui-manifest.sh) -> packages/console/dist/sdui.manifest.json -# — GITIGNORED, and gated nowhere -# -# So only the first one can turn the bump PR green. `scripts/check-sdui-manifest.mjs` -# in the required lint job reds while the record's pin trails `.objectui-sha`, and -# ADR-0082 D4's declaration-parity ratchet runs `--strict` against the tracked -# manifest on every PR (#12924 checked it in; before that the ratchet had no -# automatic run at all, which is the world the reminder here used to describe). -# -# Producing the BROWSER dump in this repo's per-PR CI was considered and REJECTED -# (#5960) — a full objectui build plus a chromium download on every matching PR — -# and that ruling stands; what changed is the ratchet's input, not the ruling. -# -# ⚠️ Corrected 2026-08-30 (#13091): this comment used to read "measured on -# origin/main, no workflow runs `pnpm sdui:manifest`, no workflow installs -# Playwright for it". Both halves stopped being true on 2026-08-10, when -# `.github/workflows/cut-rc.yml` landed doing both, one step apart, as the last -# check before publish — a `workflow_dispatch`-only lane a human starts by typing -# the version. "No workflow" was an absolute about the whole workflow set, and that -# is the shape that rotted. Read a workflow's `on:` block rather than a file count. -# ADR-0082 addendum 2 carries the full correction. -# -# Deliberately a REMINDER, not a hard gate: a machine that cannot reach npm must -# still be able to move the pin, and hard-failing here would be CI cost wearing a -# local disguise. Nothing is lost by that, because the gate cannot go falsely green -# — since #4690 a missing or unusable manifest exits 1 instead of skipping, and -# since #12924 the bump PR itself reds until the regeneration lands. Printed on -# BOTH exits below: --no-commit still moved the pin. +# +# `scripts/check-sdui-manifest.mjs` in the required lint job reds while the record's +# pin trails `.objectui-sha`, so only the regeneration turns the bump PR green. It is +# the one producer (ruling 丙 on #17735): it reads the pin's BUILT tree — never an npm +# install, whose version string names a tarball built from an EARLIER commit — and the +# console build ships the same tracked file in packages/console/dist/. +# +# Deliberately a REMINDER, not a hard gate: a machine that cannot build objectui +# must still be able to move the pin, and hard-failing here would be CI cost wearing +# a local disguise. Nothing is lost by that, because the bump PR itself reds in lint +# until the regeneration lands. Printed on BOTH exits below: --no-commit still moved +# the pin. print_sdui_next_step() { echo echo "→ NEXT STEP — regenerate the committed SDUI manifest (required lint gate):" - echo " node scripts/gen-sdui-manifest-node.mjs \\" - echo " --objectui-version {the @object-ui version the new pin ships}" - echo " Read that version from the objectui checkout's packages/core/package.json." + echo " pnpm objectui:build # skip if objectui:refresh already built this pin" + echo " node scripts/gen-sdui-manifest-node.mjs # reads .cache/objectui-/apps/console" echo " It rewrites the tracked sdui.manifest.json and scripts/sdui-manifest.record.json;" - echo " scripts/check-sdui-manifest.mjs reds this bump PR until it does, and ADR-0082 D4's" - echo " declaration-parity ratchet reads the same tracked artefact. No browser needed." - echo " ⛔ 'pnpm sdui:manifest' does NOT write those files — it is the separate browser" - echo " dump to the gitignored packages/console/dist/ (needs Playwright chromium)." + echo " scripts/check-sdui-manifest.mjs reds this bump PR until it does. No browser needed." echo " Procedure: docs/releases-maintenance.md → 'After the pin moves'." } diff --git a/scripts/check-pnpm-filter-targets.mjs b/scripts/check-pnpm-filter-targets.mjs index 6ee15e1367..5ddd697c96 100644 --- a/scripts/check-pnpm-filter-targets.mjs +++ b/scripts/check-pnpm-filter-targets.mjs @@ -64,9 +64,9 @@ * * ## Foreign workspaces are a declared exemption, not a hole * - * `scripts/build-console.sh` and `scripts/gen-sdui-manifest.sh` filter - * `@object-ui/console`, which is not in this workspace and never will be -- they - * run those commands inside a checkout of `objectstack-ai/objectui`. That scope + * `scripts/build-console.sh` filters `@object-ui/console`, which is not in this + * workspace and never will be -- it runs those commands inside a checkout of + * `objectstack-ai/objectui`. That scope * is declared in the resolver's `FOREIGN_SCOPES`, with the reason. A new * foreign scope has to be added there deliberately. */ diff --git a/scripts/check-sdui-lockstep.mjs b/scripts/check-sdui-lockstep.mjs index 06cc0483bc..d125790b49 100644 --- a/scripts/check-sdui-lockstep.mjs +++ b/scripts/check-sdui-lockstep.mjs @@ -50,9 +50,9 @@ * so it cannot record "whatever we have now" as accepted. Run it after a port * and one of two things happens: the port was byte-faithful and the gate goes * green, or it was not and the gate stays RED naming the difference. That is - * the opposite of the accepted-divergence baselines elsewhere in this repo - * (`packages/spec/react-declaration-parity.baseline.json` records a diff and - * ratchets it); this record holds ONE SIDE, so there is no diff in it to bless. + * the opposite of an accepted-divergence baseline (the retired + * `packages/spec/react-declaration-parity.baseline.json` recorded a diff and + * ratcheted it); this record holds ONE SIDE, so there is no diff in it to bless. * * ## And why `--update` REFUSES off the pin * diff --git a/scripts/cross-package-test-inputs.mjs b/scripts/cross-package-test-inputs.mjs index b790c2e0c5..830ab47d04 100644 --- a/scripts/cross-package-test-inputs.mjs +++ b/scripts/cross-package-test-inputs.mjs @@ -1412,7 +1412,6 @@ export const CROSS_PACKAGE_TEST_INPUTS = { 'scripts/git-env.mjs', '.github/workflows/scaffold-e2e.yml', 'packages/cli/src/commands/serve.ts', - 'scripts/gen-sdui-manifest.sh', 'scripts/publish-smoke.sh', 'packages/cli/src/commands/init.ts', ], diff --git a/scripts/gen-sdui-manifest.sh b/scripts/gen-sdui-manifest.sh deleted file mode 100755 index 5233551907..0000000000 --- a/scripts/gen-sdui-manifest.sh +++ /dev/null @@ -1,689 +0,0 @@ -#!/usr/bin/env bash -# ADR-0080/0081: generate the public-tier SDUI component manifest and ratchet the -# spec↔frontend react-block conformance. -# -# This is deliberately SEPARATE from build-console.sh and is NOT part of the -# default build. The console component registry is a browser app (plugin-map / -# charts pull browser-only deps), so the only reliable way to enumerate it is to -# load the built `manifest-dump.html` in a real browser and read -# `window.__MANIFEST`. We do not want every console rebuild to drag in a -# Playwright browser dependency, so this step is opt-in / on-demand: -# -# pnpm objectui:build # (re)build + vendor the console dist at .objectui-sha -# pnpm sdui:manifest # then dump the manifest + ratchet conformance -# -# Requires a matching Playwright browser. If it complains the executable is -# missing, install it: -# -# pnpm exec playwright install chromium-headless-shell -# -# Output: packages/console/dist/sdui.manifest.json (consumed by the os-build -# JSX gate for full component/prop validation; absent -> gate falls back to -# parse-level). - -set -euo pipefail - -# --------------------------------------------------------------------------- -# Background dev-server lifecycle. -# -# WHY THIS IS NOT JUST `cmd & ...; trap 'kill $!' EXIT`. -# -# That is what this script used to do, and it was measured failing: the trap -# ran, and the backgrounded process was still alive 20 minutes later, holding -# the container's shared heavy-verify flock. Three separate properties of the -# naive form are wrong, and each one is enough on its own: -# -# 1. `$!` is the `pnpm` wrapper, and killing it does not reap the tree. -# Measured here: SIGTERM to the wrapper leaves descendants reparented to -# init (PPID 1) that no `kill "$!"` can ever reach. Signalling the process -# GROUP is what reaches them. -# -# 2. You cannot signal the group without `setsid` first. A background job in -# a non-interactive shell does NOT get its own process group — it inherits -# the script's. Measured under agent discipline, the backgrounded server's -# PGID was the PID of the wrapping `flock` itself, so the "obvious" fix, -# `kill -- -$PGID`, would have killed the caller's flock and this script. -# `setsid` gives the server its own session so the group kill is bounded. -# -# 3. Background children inherit open descriptors, and `flock(1)` holds its -# lock on an open fd. Measured: the backgrounded wrapper carried the -# caller's lock fd (`fuser -v` listed it as a holder), which is what turns -# "a leaked dev server" into "every later agent in this container queues -# out at exit 99 with no signal". Closing inherited fds in the child means -# a kill that misses is merely untidy instead of a container-wide outage. -# -# A note on what was NOT determined: the exact reason the original SIGTERM -# failed to reap that particular run is not reproducible from here (it needs a -# real vite under a real console build tree). So the cleanup below is -# deliberately cause-agnostic — it escalates TERM -> KILL, it VERIFIES, and it -# says so loudly when it cannot finish the job, rather than assuming any one -# diagnosis. A cleanup that can fail silently is the whole defect. -# --------------------------------------------------------------------------- - -SDUI_HAVE_SETSID=0 -if command -v setsid > /dev/null 2>&1; then SDUI_HAVE_SETSID=1; fi - -# Every pid in the session led by $1 (setsid path), or $1 plus its descendants -# (fallback path). Prints nothing when the tree is gone. -sdui_live_pids() { - local leader="$1" out="" - if [[ "$SDUI_HAVE_SETSID" == 1 ]]; then - out="$(pgrep -s "$leader" 2>/dev/null || true)" - else - kill -0 "$leader" 2>/dev/null && out="$leader" - local frontier="$leader" next="" - while [[ -n "$frontier" ]]; do - next="$(pgrep -P "${frontier// /,}" 2>/dev/null || true)" - [[ -z "$next" ]] && break - out="${out}${out:+ }${next//$'\n'/ }" - frontier="${next//$'\n'/ }" - done - fi - printf '%s' "$out" | tr '\n' ' ' | tr -s ' ' -} - -# Start "$@" detached: its own session (so the group is safe to signal) and no -# inherited descriptors above stderr (so an orphan cannot hold a caller's lock). -# Writes the leader pid to $1. -sdui_spawn_detached() { - local pidfile="$1" logfile="$2"; shift 2 - : > "$pidfile" - - # Runs as the new session leader, before exec'ing the real command. - local runner=' -pidfile="$1"; shift -printf "%s\n" "$$" > "$pidfile" -if [ -d "/proc/$$/fd" ]; then - for fd in /proc/$$/fd/*; do - n=${fd##*/} - # 0/1/2 are this job'"'"'s own stdio; 255 is reserved by bash itself. - case "$n" in 0|1|2|255) continue ;; esac - eval "exec ${n}>&-" 2>/dev/null || true - done -fi -exec "$@" -' - if [[ "$SDUI_HAVE_SETSID" == 1 ]]; then - setsid bash -c "$runner" sdui-dump "$pidfile" "$@" < /dev/null > "$logfile" 2>&1 & - else - bash -c "$runner" sdui-dump "$pidfile" "$@" < /dev/null > "$logfile" 2>&1 & - fi - - local _i - for _i in $(seq 1 100); do - [[ -s "$pidfile" ]] && return 0 - sleep 0.05 - done - echo "⚠ dev server did not report its pid within 5s — cleanup may be incomplete." >&2 - return 0 -} - -# Stop what sdui_spawn_detached started, and VERIFY. Idempotent. -sdui_stop_detached() { - local pidfile="${1:-}" leader="" sig="" left="" _i - [[ -n "$pidfile" && -s "$pidfile" ]] && leader="$(tr -d '[:space:]' < "$pidfile" 2>/dev/null || true)" - [[ -n "$pidfile" ]] && rm -f "$pidfile" 2>/dev/null || true - case "$leader" in ''|*[!0-9]*) return 0 ;; esac - - for sig in TERM KILL; do - left="$(sdui_live_pids "$leader")" - [[ -z "${left// /}" ]] && return 0 - if [[ "$SDUI_HAVE_SETSID" == 1 ]]; then - kill -"$sig" -- "-$leader" 2>/dev/null || true - else - # shellcheck disable=SC2086 - kill -"$sig" $left 2>/dev/null || true - fi - for _i in $(seq 1 40); do - left="$(sdui_live_pids "$leader")" - [[ -z "${left// /}" ]] && return 0 - sleep 0.25 - done - done - - left="$(sdui_live_pids "$leader")" - if [[ -n "${left// /}" ]]; then - echo "✗ could not stop the SDUI dump dev server; these processes survived SIGKILL:" >&2 - # shellcheck disable=SC2086 - ps -o pid,ppid,pgid,etime,args -p $left 2>/dev/null >&2 || true - echo " They may hold descriptors inherited from this run (e.g. a flock held by the caller)." >&2 - fi - return 0 -} - -# --------------------------------------------------------------------------- -# Collision safety between CONCURRENT RUNS. -# -# Agent dispatch containers run several agents against one filesystem and one -# network namespace, so a fixed port and a fixed log path are shared mutable -# state. Measured on the pinned vite (8.2.1, objectui's lockfile at the SHA in -# .objectui-sha) with another server already holding the requested port: -# -# $ vite dev --port 5390 -# Port 5390 is in use, trying another one... -# ➜ Local: http://localhost:5391/ -# -# So run B's server comes up on 5391 while run B's probe and BASE_URL still name -# 5390: run B curls run A's server, succeeds, and dumps A's manifest as its own -# with exit 0 and no diagnostic. That output feeds the ADR-0082 declaration -# parity ratchet below, which is why a wrong-but-plausible manifest is worse -# here than no manifest at all. -# -# `--strictPort` is necessary but NOT sufficient, and that was measured too. With -# it, run B's vite exits — `Error: Port 5390 is already in use` — into run B's OWN -# log, while run A keeps answering on 5390. A probe that asks only "does the port -# answer?" still gets 200, and still dumps A's tree. Both halves are required: -# -# 1. `--strictPort`, so the port we ASKED for is the port we GOT, or no server -# of ours exists at all. Without it `--port` is a request vite may silently -# decline, and "where I asked the server to be" stops meaning "where it is". -# 2. a probe that requires the session THIS RUN spawned to still be alive -# before it accepts an answer on that port. Given (1), "our leader is alive" -# leaves no third possibility: what answers on $DUMP_PORT is ours. -# -# That pair is what makes $DUMP_PORT single-valued in the sense that matters. -# Deriving the probe URL and BASE_URL from one variable was ALREADY true when -# this defect was filed and did not prevent it — the divergence was never between -# two literals in this file, it was between the port requested and the port bound. -# --------------------------------------------------------------------------- - -# Print a TCP port on 127.0.0.1 that is free right now AND reserved for this -# caller, searching upward from $1. -# -# WHY A RESERVATION AND NOT JUST A PROBE. -# -# The probe below used to be the whole of this helper: bind a socket, CLOSE it, -# and report the port free. Between that close and the caller's own bind the -# port is unowned, and the scan is deterministic from $base upward, so every -# concurrent caller starting at the same base was handed the same number. A -# check-then-use race whose "use" is in another process — and one that collides -# by construction rather than by bad luck. Measured on this tree with the -# reservation removed, eight concurrent callers scanning from 5180: -# -# DISTINCT_PORTS=1 of 8 # every one of them was handed 5180 -# BIND_OK=2 BIND_ERR=6 # six lost the follow-up bind: -# # Error: listen EADDRINUSE 127.0.0.1:5180 -# -# That is the shape that dequeued a PR from the merge queue. The contention is -# not hypothetical: the collision test beside this script draws from 5180 three -# times, the write-target test drives this real path for a fourth, and they run -# concurrently inside one package's test run. -# -# So a port is CLAIMED before it is probed, in a registry every caller on this -# host shares, and the claim outlives this function — it is released when the -# claiming process dies, not when this function returns. Two cooperating callers -# can no longer be handed the same port at all. The probe stays, because a claim -# says nothing about processes that never heard of this registry. -# -# STILL ADVISORY against those. Nothing here can stop a process outside the -# registry from taking the port between this function and the caller's bind; -# `--strictPort` plus `sdui_wait_for_own_server` is what turns losing that race -# into a loud failure instead of a silent redirect onto a neighbour's server. -# What the claim removes is the collision this script's own callers cause each -# other, which is every collision anyone has actually measured here. -# -# 127.0.0.1 is the interface vite's dev server binds. A wildcard listener on the -# same port collides with a loopback bind too, so this probe sees a neighbour -# whichever way the neighbour bound. -sdui_pick_free_port() { - local base="${1:-5180}" span="${2:-200}" - local dir="${SDUI_PORT_RESERVATION_DIR:-${TMPDIR:-/tmp}/sdui-port-reservations}" - - # `O_EXCL` alone already gives exactly one winner per port, so the lock is not - # what makes a claim exclusive — it makes the SWEEP of abandoned claims safe, - # which is the one step that unlinks a file another scanner may be creating. - # Missing `flock` therefore degrades to "sound, minus the sweep's tie-break", - # never to a hard failure: this helper runs on developer machines too. - # - # The descriptor is opened by a subshell that exits before anything is - # spawned, so nothing this script starts can inherit it — see the cleanup - # contract above, where an inherited lock fd is what closed a container. - if command -v flock > /dev/null 2>&1; then - ( - flock -w 30 9 2> /dev/null || true - sdui_scan_and_reserve_port "$base" "$span" - ) 9> "${dir}.lock" - else - sdui_scan_and_reserve_port "$base" "$span" - fi -} - -# The scan itself: claim, then probe, then hand the port over. Split out from -# `sdui_pick_free_port` only so the lock above wraps one named thing. -sdui_scan_and_reserve_port() { - local base="$1" span="$2" - node - "$base" "$span" "$$" << 'SDUI_PICK_FREE_PORT' -const net = require('node:net'); -const fs = require('node:fs'); -const os = require('node:os'); -const path = require('node:path'); - -const base = Number(process.argv[2]); -const span = Number(process.argv[3]); -// The CALLER's pid, not this node process's: the caller is what will hold the -// port, and its death is what makes the claim collectable. -const owner = Number(process.argv[4]); - -// One registry per host, shared by every caller that sources this script — -// sharing it IS the fix, so the tests that measure contention deliberately do -// not override this. The override exists for tests of the protocol itself. -const dir = - process.env.SDUI_PORT_RESERVATION_DIR || - path.join(process.env.TMPDIR || os.tmpdir(), 'sdui-port-reservations'); - -// A claim is collectable once its owner is gone. The floor keeps a claim -// written moments ago out of the sweep whatever its pid says; the ceiling is a -// backstop for the case pid liveness cannot see — a dead owner whose pid number -// has since been reused by something unrelated. -const SWEEP_FLOOR_MS = 10_000; -const SWEEP_CEILING_MS = 12 * 60 * 60 * 1000; - -const alive = (pid) => { - try { - process.kill(pid, 0); - return true; - } catch (err) { - return err.code === 'EPERM'; // it exists, it is simply not ours to signal - } -}; - -const sweep = () => { - let names = []; - try { - names = fs.readdirSync(dir); - } catch { - return; - } - const now = Date.now(); - for (const name of names) { - if (!/^\d+$/.test(name)) continue; - const file = path.join(dir, name); - let holder; - let age; - try { - holder = Number(String(fs.readFileSync(file, 'utf8')).trim()); - age = now - fs.statSync(file).mtimeMs; - } catch { - continue; // vanished under us — someone else already collected it - } - if (age < SWEEP_FLOOR_MS) continue; - if (age < SWEEP_CEILING_MS && Number.isInteger(holder) && holder > 0 && alive(holder)) continue; - try { - fs.unlinkSync(file); - } catch { - /* already gone */ - } - } -}; - -// `wx` is O_CREAT|O_EXCL: exactly one creator wins, and the losers are told so -// HERE rather than discovering it at bind time in another process. -const claim = (port) => { - try { - fs.writeFileSync(path.join(dir, String(port)), `${owner}\n`, { flag: 'wx' }); - return true; - } catch { - return false; - } -}; - -const release = (port) => { - try { - fs.unlinkSync(path.join(dir, String(port))); - } catch { - /* already gone */ - } -}; - -const isFree = (port) => - new Promise((resolve) => { - const probe = net.createServer(); - probe.once('error', () => resolve(false)); - probe.once('listening', () => probe.close(() => resolve(true))); - probe.listen(port, '127.0.0.1'); - }); - -(async () => { - fs.mkdirSync(dir, { recursive: true }); - sweep(); - for (let port = base; port < base + span; port += 1) { - // Claimed by a live caller — including by an earlier call from THIS caller, - // which is why two picks in one run cannot return one port. - if (!claim(port)) continue; - if (await isFree(port)) { - process.stdout.write(String(port)); - return; - } - // Held by something outside the registry. Hand the claim back rather than - // hoarding a port we never got, and keep scanning. - release(port); - } - process.stderr.write(`no free TCP port in [${base}, ${base + span})\n`); - process.exitCode = 1; -})(); -SDUI_PICK_FREE_PORT -} - -# The dev-server argv for port $1, one word per line. -# -# A function rather than an inline command line so the `--strictPort` half of the -# fix is assertable by SOURCING this file, instead of only by grepping it. A grep -# assertion would also pass against a version that merely mentions the flag in a -# comment, which is the failure mode the cleanup test's header warns about. -sdui_dev_server_cmd() { - local port="$1" - printf '%s\n' pnpm --filter @object-ui/console exec vite dev --port "$port" --strictPort -} - -# Wait until the dev server THIS RUN spawned answers on port $2, or fail. -# $1 pidfile written by sdui_spawn_detached $2 port $3 timeout seconds -# -# The liveness check is ordered BEFORE the curl deliberately: a neighbouring run's -# server on the same port answers 200 exactly like ours, so "the port answers" is -# no evidence by itself. Reading our own leader first — and again after a -# successful curl, since ours can exit between the two — is what makes a green -# here mean "our server". It also turns a collision into a fast, explicit failure -# instead of a 90-second wait ending in a confident wrong answer. -# -# `curl`, not a socket table: `ss`/`netstat` are absent from the agent dispatch -# containers, and `ss -ltn | grep :$PORT` prints nothing there whether or not -# anything is listening (docs/qa/platform-checklist/RUNNER.md). -sdui_wait_for_own_server() { - local pidfile="${1:-}" port="${2:-}" timeout="${3:-90}" leader="" _i - for _i in $(seq 1 "$timeout"); do - leader="$(tr -d '[:space:]' < "$pidfile" 2>/dev/null || true)" - case "$leader" in '' | *[!0-9]*) leader="" ;; esac - if [[ -n "$leader" && -z "$(sdui_live_pids "$leader")" ]]; then - echo "✗ the dev server this run started is no longer running." >&2 - return 1 - fi - if curl -sf "http://localhost:${port}/" > /dev/null 2>&1; then - if [[ -n "$leader" && -z "$(sdui_live_pids "$leader")" ]]; then - echo "✗ the dev server this run started exited while its port was being probed." >&2 - return 1 - fi - return 0 - fi - sleep 1 - done - echo "✗ the dev server this run started never answered on port ${port} within ${timeout}s." >&2 - return 1 -} - -sdui_on_signal() { - local sig="$1" - sdui_stop_detached "${DUMP_PID_FILE:-}" - trap - "$sig" EXIT - kill -"$sig" "$$" -} - -# --------------------------------------------------------------------------- -# The remedy for a failed dump is CHOSEN from what failed, not fixed in advance. -# -# WHY. Every failure of the dump below used to print one remedy — `playwright -# install chromium-headless-shell` — because a missing browser was the failure -# the author had in hand. Measured with `packages/console/dist/` absent: the -# dump itself SUCCEEDED (the browser launched, the registry was enumerated) and -# only the final `writeFileSync` failed — -# -# Error: ENOENT: no such file or directory, open '.../sdui.manifest.json' -# -# — and the run still printed the Playwright remedy. A reader who follows it -# reinstalls a browser that was never the problem; in an agent dispatch -# container they cannot even do that, because `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1` -# is set there and agents are instructed not to override it (see -# docs/releases-maintenance.md, "If the dispatch container's Playwright browser -# doesn't match the revision"). Advice that names the wrong layer with -# confidence costs MORE than no advice, because it is followed — that round was -# spent on the Playwright trail while the defect was one absent directory. -# -# So: the Playwright remedy is printed only on Playwright EVIDENCE, and the -# fallback is "unclassified", never "Playwright". The direction of that default -# is the whole fix; a classifier that guesses Playwright when it recognises -# nothing is the same defect wearing a conditional. -# -# The signatures are the ones this repo has actually measured, not invented: -# `browserType.launch: Executable doesn't exist at ...` is verbatim what the -# container's revision mismatch produced (docs/releases-maintenance.md), and the -# install banner is what playwright prints beside it. -# -# Reads the log as a FILE rather than piping text into grep: `grep -q` exits on -# the first match, and under `set -o pipefail` the SIGPIPE'd producer upstream -# would then set the pipeline non-zero — turning a match into a miss for exactly -# the long outputs that need classifying most. -# -# $1 file holding the dump's combined output -# $2 the OUT path the dump was given -sdui_dump_failure_advice() { - local log="${1:-}" out="${2:-}" - - if [[ -n "$log" && -r "$log" ]] && - grep -qaE "browserType\.launch|Executable doesn't exist|playwright install|download new browsers" -- "$log"; then - echo " Playwright could not start a browser — that IS this failure. Install the" - echo " matching one and retry:" - echo " pnpm exec playwright install chromium-headless-shell" - echo " Can't install here (e.g. an agent dispatch container)? See docs/releases-maintenance.md" - echo " 'If the dispatch container's Playwright browser doesn't match the revision'." - return 0 - fi - - # A write failure names the path it could not open, so requiring BOTH the - # errno and this run's own OUT path keeps an unrelated ENOENT elsewhere in the - # output from being read as one. - if [[ -n "$log" && -r "$log" && -n "$out" ]] && - grep -qaE '(ENOENT|EACCES|EROFS|ENOSPC|EISDIR|EPERM)' -- "$log" && - grep -qaF -- "$out" "$log"; then - echo " The dump could not WRITE its output. The browser side is not implicated" - echo " and 'playwright install' would change nothing here." - echo " output file: ${out}" - echo " its directory: $(dirname -- "$out")" - echo " Check that directory exists and is writable, then re-run." - return 0 - fi - - echo " This failure was NOT identified as a missing Playwright browser, so" - echo " 'pnpm exec playwright install ...' is not the indicated remedy for it." - echo " Read the dump's own output above first." - if [[ -n "$log" ]]; then echo " It is saved at ${log}"; fi - return 0 -} - -# Sourced rather than executed: publish the lifecycle helpers above and do -# nothing else, so the cleanup contract can be exercised without a console -# build. `${BASH_SOURCE[0]}` differs from `$0` exactly when this file is sourced. -if [[ "${BASH_SOURCE[0]}" != "$0" ]]; then - return 0 -fi - -FRAMEWORK_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -SHA_FILE="${FRAMEWORK_ROOT}/.objectui-sha" - -if [[ ! -f "$SHA_FILE" ]]; then - echo "✗ ${SHA_FILE} is missing — cannot determine which objectui commit to use." - exit 1 -fi -PINNED_SHA="$(tr -d '[:space:]' < "$SHA_FILE")" - -BUILD_ROOT="${FRAMEWORK_ROOT}/.cache/objectui-${PINNED_SHA:0:12}" -TARGET="${FRAMEWORK_ROOT}/packages/console/dist" -DUMP_PAGE="${BUILD_ROOT}/apps/console/dev/manifest-dump.html" -DUMP_SCRIPT="${BUILD_ROOT}/scripts/dump-public-manifest.mjs" - -if [[ ! -d "$BUILD_ROOT" ]]; then - echo "✗ objectui build tree not found at ${BUILD_ROOT}" - echo " Run 'pnpm objectui:build' first to vendor the console at the pinned SHA." - exit 1 -fi -if [[ ! -f "$DUMP_PAGE" || ! -f "$DUMP_SCRIPT" ]]; then - echo "ℹ manifest dump tooling not present at objectui@${PINNED_SHA:0:12} — nothing to do." - echo " (bump .objectui-sha to >=96b1293 to enable full JSX validation)" - exit 0 -fi - -# The output directory, created HERE — with the other preconditions, before the -# dev server and the browser, rather than implicitly at write time. -# -# `packages/console/dist/` is gitignored and exists only after a successful -# `scripts/build-console.sh`, so on a tree whose console build is broken it is -# simply ABSENT. Nothing created it and objectui's dumper calls `writeFileSync` -# straight out, so the run died `ENOENT ... sdui.manifest.json` — after it had -# already paid for a vite dev server and a chromium launch. That cost is why -# this is a precondition and not a `mkdir` beside the write: a precondition that -# can only fail cheaply is one nobody has to debug at the end of a long run. -# -# It also removes a false coupling. The ratchet does not need the built dist at -# all — this script drives a vite DEV server over `.cache/objectui-`, and -# `dist/` is only where the manifest LANDS — so refusing to run for want of a -# directory left the ADR-0082 D4 ratchet unrunnable exactly on the trees that -# most want an independent read on the registry. -if ! mkdir -p "$TARGET"; then - echo "✗ could not create the manifest output directory: ${TARGET}" - exit 1 -fi - -echo "→ Generating SDUI public-tier manifest (ADR-0080) from objectui@${PINNED_SHA:0:12}..." -pushd "$BUILD_ROOT" > /dev/null - -# Per-run port. `SDUI_DUMP_PORT` pins one explicitly and is honoured exactly — -# no search — because an explicit request that quietly lands somewhere else is -# the defect this block exists to remove; `--strictPort` makes a busy pinned port -# fail loudly, which is what an explicit request should do. Unset, the search -# starts at 5180 so an uncontended run still lands where the docs say it does. -if [[ -n "${SDUI_DUMP_PORT:-}" ]]; then - DUMP_PORT="$SDUI_DUMP_PORT" -else - DUMP_PORT="$(sdui_pick_free_port 5180 || true)" -fi -# Never let an empty or non-numeric value through: the dump consumer defaults to -# `http://localhost:5180` when BASE_URL is absent, and that default is precisely -# the shared port this run is trying not to use. -case "$DUMP_PORT" in - '' | *[!0-9]*) - echo "✗ could not find a free TCP port for this run's dev server." >&2 - exit 1 - ;; -esac - -# Per-run log. The fixed path this replaced was truncated by whichever run -# started last, so a diagnosing reader could be reading another run's output — -# and the failure branches below point readers straight at it. mktemp, matching -# the pidfile beside it. -# -# THE X'S ARE TERMINAL, AND NO SUFFIX FOLLOWS THEM — THE BSD/macOS FLOOR. -# `mktemp "…dev.XXXXXX.log"` is a GNU coreutils spelling: coreutils splits the -# trailing `.log` off as an implied `--suffix` (`--suffix=SUFF … is implied if -# TEMPLATE does not end in X`, mktemp --help, coreutils 9.4) and substitutes the -# X's anyway. BSD mktemp(1) is a thin wrapper over mkstemp(3), which takes the -# template as-is and only ever replaces a TRAILING run of X's; measured here on -# glibc, `mkstemp("./x.XXXXXX.log")` fails outright with EINVAL while -# `mkstemp("./x.XXXXXX")` succeeds. On stock macOS the reported symptom is the -# other branch of the same refusal: nothing is substituted, the literal file -# `sdui-dump-dev.XXXXXX.log` is created on the first run, and every later run -# dies at this line with `mkstemp failed on …: File exists` — the script -# manufacturing its own blocking condition, one manual `rm` per pin bump. -# -# So: terminal X's, and the `.log` extension is simply given up. `--suffix` is -# itself the GNU extension and is not the portable answer; a rename after the -# fact would buy the extension back at the price of a second path and a window -# in which the two disagree, and nothing reads these files by extension — they -# are echoed to the operator and classified by content, never globbed. -DUMP_DEV_LOG="$(mktemp "${TMPDIR:-/tmp}/sdui-dump-dev.XXXXXX")" -DUMP_PID_FILE="$(mktemp "${TMPDIR:-/tmp}/sdui-dump-pid.XXXXXX")" - -echo " dev server: port ${DUMP_PORT}, log ${DUMP_DEV_LOG}" - -# Armed BEFORE the spawn: everything from here on must be able to fail without -# leaving a server behind. EXIT alone is not enough — bash runs no EXIT trap -# when the script is itself signalled, and being signalled is exactly how an -# agent container reclaims a run. -trap 'sdui_stop_detached "${DUMP_PID_FILE:-}"' EXIT -trap 'sdui_on_signal INT' INT -trap 'sdui_on_signal TERM' TERM -trap 'sdui_on_signal HUP' HUP - -# READ LOOP, NOT `readarray` — THE BASH 3.2 FLOOR. `readarray`/`mapfile` are -# bash 4 builtins and `/usr/bin/env bash` is bash 3.2.57 on macOS (Apple ships -# no bash 4+, for licensing reasons). This is the sharpest site in the repo for -# that defect: `pnpm sdui:manifest` is the literal `→ NEXT STEP` that -# `scripts/bump-objectui.sh` prints on its way out, and per the comment at the -# head of this file the ratchet is an on-demand gate by decision (#5960) — the -# only routine CI invocation is `cut-rc.yml`'s pre-publish run, a handful of -# times a month on Linux. So the ONE host this line is normally executed on is -# the operator's laptop, and on a Mac it aborted there. Measured with the -# builtin disabled (`enable -n mapfile readarray` via `BASH_ENV`, which -# reproduces the macOS symptom byte for byte): `readarray: command not found`, -# status 127 under `set -e` — and it aborts HERE, after the EXIT/INT/TERM traps -# are armed but before any server is spawned, so the operator is handed a bare -# builtin error at the exact step the pin bump just told them to run. -# -# Keep this loop bash-3.2-clean: no `mapfile`, no `readarray`, no `declare -A`, -# no `${x^^}`/`${x,,}`. Two details are load-bearing: `DUMP_DEV_ARGV=()` before -# the loop (under `set -u` a loop that appends nothing never creates the array, -# and the expansion below would abort with `unbound variable`), and the `if` -# rather than a trailing `[[ … ]] &&` (with the `&&` form the `while` takes -# status 1 whenever the last line read fails the test, which under `set -e` -# kills the caller as soon as such a loop sits last in a function). -DUMP_DEV_ARGV=() -dump_argv_line='' -while IFS= read -r dump_argv_line; do - if [[ -n "$dump_argv_line" ]]; then DUMP_DEV_ARGV+=("$dump_argv_line"); fi -done < <(sdui_dev_server_cmd "$DUMP_PORT") -sdui_spawn_detached "$DUMP_PID_FILE" "$DUMP_DEV_LOG" "${DUMP_DEV_ARGV[@]}" - -# Failing here is a REFUSAL TO GUESS, not an inconvenience: the alternative is -# dumping whatever else happens to answer on this port. See the collision-safety -# block above for what the two halves buy. -if ! sdui_wait_for_own_server "$DUMP_PID_FILE" "$DUMP_PORT" 90; then - echo " Its log for this run is at ${DUMP_DEV_LOG}" - echo " A port taken by a concurrent run reports 'Port ${DUMP_PORT} is already in use' there;" - echo " re-run, or pin a port with SDUI_DUMP_PORT=." - popd > /dev/null - exit 1 -fi - -# The dump's combined output goes to a per-run file as well as to the terminal: -# the failure branch classifies that text, and a remedy chosen from what -# actually happened is the point (see sdui_dump_failure_advice above). -# Terminal X's, no suffix after them — see the DUMP_DEV_LOG block above for the -# BSD/macOS floor this shape is keeping. -DUMP_OUT_LOG="$(mktemp "${TMPDIR:-/tmp}/sdui-dump-out.XXXXXX")" - -# `${PIPESTATUS[0]}`, never `$?`: after a pipeline `$?` is TEE's status, and tee -# does not fail, so `$?` here would read every failure of the dump as a success. -set +e -BASE_URL="http://localhost:${DUMP_PORT}" OUT="${TARGET}/sdui.manifest.json" \ - node scripts/dump-public-manifest.mjs 2>&1 | tee "$DUMP_OUT_LOG" -dump_status="${PIPESTATUS[0]}" -set -e - -if [[ "$dump_status" -eq 0 ]]; then - echo "✓ wrote ${TARGET}/sdui.manifest.json" - rm -f "$DUMP_OUT_LOG" -else - echo "✗ manifest generation failed (exit ${dump_status})." - sdui_dump_failure_advice "$DUMP_OUT_LOG" "${TARGET}/sdui.manifest.json" - echo " The dev server log for THIS run is at ${DUMP_DEV_LOG}" - popd > /dev/null - exit "$dump_status" -fi -popd > /dev/null - -# ADR-0081/0082: ratchet the spec↔registry react-block DECLARATION PARITY against -# the committed baseline. -# -# `--strict`, i.e. this now GATES. It used to run without it and swallow the exit -# code behind a `⚠`, so "divergence recorded" and "divergence stopped" were two -# very different things wearing the same green build (#4472, secondary finding 1). -# The ratchet only fires on divergence NEW since the accepted baseline, so a -# failure here is a deliberate registry change that needs either a spec/overlay -# edit or an explicit `--update` to accept — never pre-existing noise. -# -# Scope, since a green line here is easy to over-read: this compares two -# DECLARATIONS (spec zod props vs registry-declared inputs) and inspects no -# renderer. See the header of check-react-blocks-declaration-parity.ts. -if [[ -f "${FRAMEWORK_ROOT}/packages/spec/react-declaration-parity.baseline.json" ]]; then - echo "→ Ratcheting spec↔registry react-block declaration parity (ADR-0082)..." - ( cd "${FRAMEWORK_ROOT}" && MANIFEST="${TARGET}/sdui.manifest.json" \ - pnpm --filter @objectstack/spec check:react-declaration-parity \ - --baseline react-declaration-parity.baseline.json --strict ) -fi diff --git a/scripts/objectui-changeset-digest.mjs b/scripts/objectui-changeset-digest.mjs index dc399f6374..6a3d32ec8c 100644 --- a/scripts/objectui-changeset-digest.mjs +++ b/scripts/objectui-changeset-digest.mjs @@ -1767,15 +1767,18 @@ function selfTest() { env: { ...gitFreeEnv(), OBJECTUI_ROOT: ui }, }, ); - // #5960: the pin bump is the ONLY trigger of ADR-0082 D4's declaration-parity - // ratchet — no workflow produces `sdui.manifest.json`, by ruling — so the - // procedure's second half lives in this script's output. Prose alone is - // deletable in silence; pinning it here is what makes "the procedure gained a - // line" a fact a gate can lose. Asserted on the `--no-commit` path on purpose: - // that path moved the pin too, and it is the one that returns early. - check( - '#5960 a bump prints the `pnpm sdui:manifest` step (the ratchet has no other trigger)', - bumpStdout.includes('pnpm sdui:manifest') && bumpStdout.includes('NEXT STEP'), + // #5960: the pin bump is what makes the committed `sdui.manifest.json` stale, + // and no workflow regenerates it — so the procedure's second half lives in + // this script's output. Prose alone is deletable in silence; pinning it here + // is what makes "the procedure gained a line" a fact a gate can lose. The + // step it names is the one producer, which reads the pin's BUILT tree (ruling + // 丙 on #17735). Asserted on the `--no-commit` path on purpose: that path moved + // the pin too, and it is the one that returns early. + check( + '#5960 a bump prints the manifest-regeneration step (gen-sdui-manifest-node.mjs over the built tree)', + bumpStdout.includes('node scripts/gen-sdui-manifest-node.mjs') + && bumpStdout.includes('pnpm objectui:build') + && bumpStdout.includes('NEXT STEP'), bumpStdout, ); const written = join(fwRun, '.changeset', `console-${head.slice(0, 12)}.md`); @@ -3030,8 +3033,8 @@ function selfTest() { // fresh --no-tags clone of objectui, 2026-08-21: 941 remote branches, 118 // branch tips not reachable from main, 291 commits present and not on main. // The release cut re-asks the question and fails closed (#9450 / PR #10494), - // but only at RC time — so in between a non-main pin merges and - // `pnpm sdui:manifest` ratchets spec↔registry parity against it. + // but only at RC time — so in between a non-main pin merges and the + // committed SDUI manifest is regenerated against it. // // The producer half is a WARNING, not a gate: the script is deliberately // usable offline and `origin/main` is only as fresh as the last fetch. So diff --git a/scripts/pm/os-verify-lock.sh b/scripts/pm/os-verify-lock.sh index 8ad1813fef..0173e7c8cd 100755 --- a/scripts/pm/os-verify-lock.sh +++ b/scripts/pm/os-verify-lock.sh @@ -50,8 +50,9 @@ # except another agent going and looking with `fuser`. # # 3. ORPHANED FD HOLDERS — a backgrounded child inherited the caller's lock -# fd and kept the lock long after the caller was gone (see the lifecycle -# block in scripts/gen-sdui-manifest.sh, where that was diagnosed). +# fd and kept the lock long after the caller was gone (diagnosed in the +# dev-server lifecycle block of the since-retired scripts/gen-sdui-manifest.sh; +# scripts/publish-smoke.sh carries the same discipline). # # A declared cap that nothing enforces is not a convention when its violators # win. So the cap moves from prose into the call site: this entry point takes no diff --git a/scripts/pnpm-filter-targets.mjs b/scripts/pnpm-filter-targets.mjs index 5d813c5367..9805893866 100644 --- a/scripts/pnpm-filter-targets.mjs +++ b/scripts/pnpm-filter-targets.mjs @@ -113,8 +113,8 @@ export const FOREIGN_SCOPES = [ { scope: '@object-ui/', reason: - 'the objectui repo (objectstack-ai/objectui); scripts/build-console.sh and ' - + 'scripts/gen-sdui-manifest.sh run these filters inside that checkout, not this workspace', + 'the objectui repo (objectstack-ai/objectui); scripts/build-console.sh runs these ' + + 'filters inside that checkout, not this workspace', }, ]; diff --git a/scripts/publish-smoke.sh b/scripts/publish-smoke.sh index e3f285d7e8..4f7eed3d7e 100644 --- a/scripts/publish-smoke.sh +++ b/scripts/publish-smoke.sh @@ -320,12 +320,11 @@ const owner = Number(process.argv[4]); // not override this. The override exists for tests of the protocol itself. // // The name is deliberately NOT script-specific. scripts/gen-sdui-manifest.sh -// runs the same protocol against its own directory today; the two draw from -// disjoint bases (3210 here, 5180 there), so they cannot collide with each -// other and nothing is lost by that split right now. Converging both onto this -// neutral default — and then onto one shared helper — is the follow-up, and it -// is a pure rename on that side because the on-disk format is identical: -// filename is the port, contents are the owner pid. +// ran the same protocol against its own directory until it retired (the SDUI +// manifest now has one producer that spawns no server); the two drew from +// disjoint bases (3210 here, 5180 there), so they never collided. The +// on-disk format stays generic for the next caller: filename is the port, +// contents are the owner pid. const dir = process.env.SMOKE_PORT_RESERVATION_DIR || path.join(process.env.TMPDIR || os.tmpdir(), 'objectstack-port-reservations'); diff --git a/scripts/sdui-manifest.record.json b/scripts/sdui-manifest.record.json index 1f7f4a0d9b..4848a78638 100644 --- a/scripts/sdui-manifest.record.json +++ b/scripts/sdui-manifest.record.json @@ -12,9 +12,11 @@ "Regenerate + re-record: pnpm objectui:build && node scripts/gen-sdui-manifest-node.mjs" ], "objectuiSha": "62597c588072636e9c30ea35b3d89b1e46fd765d", - "objectuiPackagesVersion": "17.6.0", + "source": "built-tree", + "modulesRoot": ".cache/objectui-62597c588072/apps/console", + "objectuiWorkspaceVersion": "17.6.0", "generator": "scripts/gen-sdui-manifest-node.mjs", "generatedAt": "2026-09-23", - "sha256": "49211fee7792cf51174930dc2c1be2169d5175f77a83491f68f90b3f0c19e69d", - "components": 57 + "sha256": "22c83c9ec70163559c2bd8f7b1613174d8660ad9949d15ed483a4189b7857c9a", + "components": 59 } diff --git a/sdui.manifest.json b/sdui.manifest.json index 78f870e42f..32492523bc 100644 --- a/sdui.manifest.json +++ b/sdui.manifest.json @@ -32,11 +32,12 @@ { "name": "pagination", "type": "object", - "description": "Pagination config, `{ pageSize, pageSizeOptions, … }`. Its presence is what enables paging; prefer it over the deprecated flat `pageSize` / `showPagination` pair." + "description": "Pagination config, `{ pageSize, pageSizeOptions, … }`. Presence enables paging with the object's settings, and an explicit off wins — the deprecated flat `showPagination: false` turns paging off even beside this object, because this object declares no off switch of its own. Prefer it over the deprecated flat `pageSize` / `showPagination` pair." }, { "name": "searchableFields", "type": "array", + "of": "string", "description": "Fields the toolbar search box queries. A non-empty list is what enables search — prefer it over the deprecated boolean `showSearch`, which cannot say WHICH fields to search." }, { @@ -99,7 +100,7 @@ { "name": "selection", "type": "object", - "description": "Selection config, `{ type: \"none\" | \"single\" | \"multiple\" }`. The canonical spelling — the deprecated boolean/string `selectable` is only read when this is absent." + "description": "Selection config, `{ type: \"none\" | \"single\" | \"multiple\" }`. Presence enables selection — an object with no `type` selects multiple rows — and an explicit off wins: `type: \"none\"` turns selection off even beside a declared `bulkActions`. The canonical spelling — the deprecated boolean/string `selectable` is only read when this is absent." }, { "name": "rowActions", @@ -144,7 +145,13 @@ { "name": "exportOptions", "type": "object", - "description": "Export config, `{ formats, maxRecords, includeHeaders, fileNamePrefix }`. Needs `operations.export` to be reachable from the toolbar." + "description": "Export config, `{ formats, maxRecords, includeHeaders, fileNamePrefix, streaming }`. `streaming` (default true) picks server-side streaming vs browser-side assembly for the export — a behaviour fork, not decoration; set it to `false` to force browser-side assembly. Needs `operations.export` to be reachable from the toolbar." + }, + { + "name": "dataSource", + "type": "object", + "binding": "object", + "description": "Per-element data binding: { object, view, filter, sort, limit }. Overrides the page-level object context for this block; `view` names a saved view of `object`, and `filter` is ANDed with the view’s own rather than replacing it." } ] }, @@ -188,6 +195,14 @@ { "label": "Map", "value": "map" + }, + { + "label": "Chart", + "value": "chart" + }, + { + "label": "Tree", + "value": "tree" } ] }, @@ -206,6 +221,12 @@ { "name": "options", "type": "object" + }, + { + "name": "dataSource", + "type": "object", + "binding": "object", + "description": "Per-element data binding: { object, view, filter, sort, limit }. Overrides the page-level object context for this block; `view` names a saved view of `object`, and `filter` is ANDed with the view’s own rather than replacing it." } ] }, @@ -220,7 +241,8 @@ }, { "name": "fields", - "type": "array" + "type": "array", + "description": "Bare field names to show, in order (each looked up in the object schema; `{ name }` is tolerated). NOT the same vocabulary as `sections[].fields`, which also accepts the spec `FormFieldSchema` object (identity key `field`, e.g. `{ field: \"note\", colSpan: 2 }`) — that shape resolves to no name HERE and is silently skipped (SimpleObjectForm in ObjectForm.tsx; buildFlatFields in flatFields.ts for the drawer/modal presentations)." }, { "name": "mode", @@ -369,7 +391,7 @@ { "name": "initialData", "type": "object", - "description": "Alternate spelling of `initialValues` that the drawer/modal presentations read FIRST (`schema.initialData || schema.initialValues`). Prefer `initialValues` in new schemas." + "description": "Alternate spelling of `initialValues` that the drawer/modal presentations read FIRST — PER MEMBER (`{ ...initialValues, ...initialData }`), so a member this key says nothing about keeps its `initialValues` value. Prefer `initialValues` in new schemas." }, { "name": "readOnly", @@ -430,6 +452,12 @@ "name": "mobile", "type": "object", "description": "Phone-only presentation overrides, e.g. `{ stepper: \"auto\", stepperMinFields: 8, fullscreenLongText: true }`." + }, + { + "name": "dataSource", + "type": "object", + "binding": "object", + "description": "Per-element data binding: { object, view, filter, sort, limit }. Overrides the page-level object context for this block; `view` names a saved view of `object`, and `filter` is ANDed with the view’s own rather than replacing it." } ] }, @@ -457,11 +485,18 @@ }, { "name": "fields", - "type": "array" + "type": "array", + "description": "Bare field names to show, in order (each looked up in the object schema; `{ name }` is tolerated). NOT the same vocabulary as `sections[].fields`, which also accepts the spec `FormFieldSchema` object (identity key `field`, e.g. `{ field: \"note\", colSpan: 2 }`) — that shape resolves to no name HERE and is silently skipped (`EmbeddableForm` passes this array straight through to `` with no `sections`, so it renders through the same `SimpleObjectForm` as `object-form` above — see its `fields` description)." }, { "name": "allowMultiple", "type": "boolean" + }, + { + "name": "dataSource", + "type": "object", + "binding": "object", + "description": "Per-element data binding: { object, view, filter, sort, limit }. Overrides the page-level object context for this block; `view` names a saved view of `object`, and `filter` is ANDed with the view’s own rather than replacing it." } ] }, @@ -498,13 +533,17 @@ }, { "name": "formType", - "type": "string", + "type": "enum", + "enum": [ + "simple", + "tabbed" + ], "description": "How the PARENT half of the form is presented. The detail grids below it are unaffected." }, { "name": "fields", "type": "array", - "description": "Which parent fields to show, in order. Ignored when `sections` is given — sections carry their own field lists." + "description": "Which parent fields to show, in order — and it is NOT ignored when `sections` is given: the two INTERSECT. The parent field pool is built from this key first and every section then resolves its own members against that pool, so a section member this key does not list is dropped from the rendered form, and a section that loses EVERY member that way disappears with its heading. Each such drop is reported once via `console.warn` (objectui#9884); it is not repaired, because this key is also the parent pool for values, create defaults and the submitted set. Author one or the other, or list every section member here too. Members are bare field names (`{ name }` tolerated); NOT the spec `FormFieldSchema` object `sections[].fields` accepts (identity key `field`) — that shape resolves to no name here and is silently skipped (the parent form renders through the same `ObjectForm` / `SimpleObjectForm` as `object-form` — see its `fields` description)." }, { "name": "title", @@ -531,12 +570,18 @@ { "name": "initialData", "type": "object", - "description": "Alternate spelling of `initialValues` the renderer also reads (MasterDetailForm.tsx:602). Prefer `initialValues` in new schemas." + "description": "Alternate spelling of `initialValues` the renderer also reads: the `parentSchema` memo carries both keys onto the parent form, which merges them PER MEMBER with this one winning. Prefer `initialValues` in new schemas." }, { "name": "taxRateField", "type": "string", "description": "Name of the field ON THE CHILD object that holds each line’s tax rate. Feeds the line-items totals row; leave unset when the detail rows carry no tax." + }, + { + "name": "dataSource", + "type": "object", + "binding": "object", + "description": "Per-element data binding: { object, view, filter, sort, limit }. Overrides the page-level object context for this block; `view` names a saved view of `object`, and `filter` is ANDed with the view’s own rather than replacing it." } ] }, @@ -552,6 +597,67 @@ { "name": "columns", "type": "array" + }, + { + "name": "filter", + "type": "array", + "description": "Filter criteria in JSON-rules form, narrowing the records the board fetches. Lowered to `$filter` on the query." + }, + { + "name": "limit", + "type": "number", + "description": "Row cap — the most records the board fetches, lowered to the query’s top-level `$top` (renderer default 100). The board renders every fetched record into a lane and offers no pagination, so this is the author’s window on the object rather than a page size. PRECEDENCE: a node-level `dataSource` binding’s own `limit` wins outright; the `pagination.pageSize` of a view that binding names fills this key only when the node leaves it unset." + }, + { + "name": "groupBy", + "type": "string", + "description": "Record field whose value buckets cards into lanes. Its picklist options become the lanes when `columns` is absent, and a drag between lanes writes the target lane’s value back to the record. A value matching no lane lands in the trailing “Uncategorized” lane rather than disappearing." + }, + { + "name": "cardTitle", + "type": "string", + "description": "Record field rendered as the card title. Read AHEAD of `titleField`, which is the legacy spelling of the same choice; when neither yields a value the shared record-display resolver names the card." + }, + { + "name": "titleField", + "type": "string", + "description": "Legacy spelling of `cardTitle` — the record field rendered as the card title. `cardTitle` wins when both are authored." + }, + { + "name": "swimlaneField", + "type": "string", + "description": "Record field that splits the board into horizontal swimlanes. When absent the board falls back to `grouping.fields[0].field`." + }, + { + "name": "coverImageField", + "type": "string", + "description": "Record field holding a card cover image — a URL string, or a file object carrying a `url`. Any other value leaves the card without a cover." + }, + { + "name": "data", + "type": "array", + "description": "Inline records to render instead of fetching. Authoring it SUPPRESSES the board’s own query entirely. Members are records: the board reads `id` (or `_id`) as the card identity, the `groupBy` field’s value as the lane, the card-title field, `coverImageField`, and every `cardFields` entry. Records handed down by a parent view and a `bind` expression both take priority over it." + }, + { + "name": "cardFields", + "type": "array", + "description": "Record field NAMES rendered as cells on each card, in the order written. Members are bare names, not entry objects. An explicit list wins over the object’s `highlightFields` role; unlike that fallback it is NOT filtered against the object definition, so a name the object no longer declares simply renders no cell. An empty array reads as omitted." + }, + { + "name": "grouping", + "type": "object", + "description": "Only `grouping.fields[0].field` is read, and only as the FALLBACK for `swimlaneField`: it names the record field that splits the board into horizontal swimlanes when no `swimlaneField` is authored. An explicit `swimlaneField` wins. Every other position inside `grouping`, later `fields` entries included, is inert on this board." + }, + { + "name": "conditionalFormatting", + "type": "array", + "description": "Per-card style rules, each evaluated against that card’s own record. Two member dialects are accepted: the native `{ field, operator, value, backgroundColor?, borderColor? }` and the spec CEL `{ condition, backgroundColor?, borderColor? }`. A matching rule colours that card alone. A rule comparing a relation field sees the stored foreign key rather than the expanded record." + }, + { + "name": "dataSource", + "type": "object", + "binding": "object", + "description": "Per-element data binding: { object, view, filter, sort, limit }. Overrides the page-level object context for this block; `view` names a saved view of `object`, and `filter` is ANDed with the view’s own rather than replacing it." } ] }, @@ -568,6 +674,52 @@ "name": "calendar", "type": "object", "description": "startDateField, endDateField, titleField, colorField" + }, + { + "name": "filter", + "type": "array", + "description": "Filter criteria in JSON-rules form, narrowing the records the calendar fetches. Lowered to `$filter` on the query." + }, + { + "name": "sort", + "type": "array", + "description": "Sort order in `[{ field, order }]` form, ordering the records the calendar fetches. Lowered to `$orderby` on the query." + }, + { + "name": "defaultView", + "type": "enum", + "enum": [ + "month", + "week", + "day" + ], + "description": "The grid the calendar opens on. A narrow viewport downgrades `day` to `month`; the author’s choice is otherwise honoured on first render." + }, + { + "name": "locale", + "type": "string", + "description": "BCP-47 tag used to format the dates and times this calendar renders. A tag `Intl` refuses is dropped — the same answer as an absent key — rather than passed through to throw out of render." + }, + { + "name": "data", + "type": "array", + "description": "Pre-fetched records drawn as-is, IN PLACE OF the calendar’s own query — `objectName`, `filter` and `sort` are then unused and `staticData` is never reached. Each member is a RECORD, read for the fields the `calendar` config names plus its `id`; a member with no value in the start field is counted in the unscheduled area rather than dropped." + }, + { + "name": "staticData", + "type": "array", + "description": "Inline records, read SECOND on the record-source ladder: an authored `data` wins and this key is then never reached, while `objectName` is read AFTER it, so a calendar carrying both draws these rows and issues no query. Members are records read exactly as `data`’s are." + }, + { + "name": "loading", + "type": "boolean", + "description": "External loading state, honoured ONLY alongside an array `data`: it then replaces the calendar with its loading placeholder. On a calendar fed by `staticData` or by `objectName` it is dropped, because that calendar owns its own loading state." + }, + { + "name": "dataSource", + "type": "object", + "binding": "object", + "description": "Per-element data binding: { object, view, filter, sort, limit }. Overrides the page-level object context for this block; `view` names a saved view of `object`, and `filter` is ANDed with the view’s own rather than replacing it." } ] }, @@ -584,6 +736,12 @@ "name": "gantt", "type": "object", "description": "startDateField, endDateField, titleField, progressField, percentageField, colorField, dependenciesField" + }, + { + "name": "dataSource", + "type": "object", + "binding": "object", + "description": "Per-element data binding: { object, view, filter, sort, limit }. Overrides the page-level object context for this block; `view` names a saved view of `object`, and `filter` is ANDed with the view’s own rather than replacing it." } ] }, @@ -604,6 +762,12 @@ "horizontal", "gantt" ] + }, + { + "name": "dataSource", + "type": "object", + "binding": "object", + "description": "Per-element data binding: { object, view, filter, sort, limit }. Overrides the page-level object context for this block; `view` names a saved view of `object`, and `filter` is ANDed with the view’s own rather than replacing it." } ] }, @@ -620,6 +784,28 @@ "name": "map", "type": "object", "description": "latitudeField, longitudeField, titleField" + }, + { + "name": "dataSource", + "type": "object", + "binding": "object", + "description": "Per-element data binding: { object, view, filter, sort, limit }. Overrides the page-level object context for this block; `view` names a saved view of `object`, and `filter` is ANDed with the view’s own rather than replacing it." + } + ] + }, + "object-tree": { + "type": "object-tree", + "namespace": "plugin-tree", + "inputs": [ + { + "name": "objectName", + "type": "string", + "required": true + }, + { + "name": "tree", + "type": "object", + "description": "parentField, labelField, fields, defaultExpandedDepth" } ] }, @@ -728,6 +914,12 @@ "name": "drillDown", "type": "object", "description": "Click-through config that opens the records behind the number." + }, + { + "name": "dataSource", + "type": "object", + "binding": "object", + "description": "Per-element data binding: { object, view, filter, sort, limit }. Overrides the page-level object context for this block; `view` names a saved view of `object`, and `filter` is ANDed with the view’s own rather than replacing it." } ] }, @@ -758,12 +950,18 @@ "name": "drillDown", "type": "object", "description": "Segment drill config: { enabled?, filter?, title?, target?: 'drawer' | 'dialog', columns?, maxRows? }. Present = on; {} is enough. Clicking a segment opens the underlying records filtered by the clicked category." + }, + { + "name": "dataSource", + "type": "object", + "binding": "object", + "description": "Per-element data binding: { object, view, filter, sort, limit }. Overrides the page-level object context for this block; `view` names a saved view of `object`, and `filter` is ANDed with the view’s own rather than replacing it." } ] }, "dashboard": { "type": "dashboard", - "namespace": "view", + "namespace": "plugin-dashboard", "inputs": [ { "name": "widgets", @@ -802,7 +1000,7 @@ "description": "Built-in date-range filter: { field?, defaultRange?, allowCustomRange? }. `defaultRange` takes the spec’s date presets plus \"custom\"; the bound field defaults to created_at." }, { - "name": "refreshInterval", + "name": "refreshIntervalSeconds", "type": "number", "description": "Auto-refresh period in seconds. Zero or a negative value disables the timer, and it only runs when the host wires an onRefresh handler." }, @@ -889,6 +1087,12 @@ { "name": "format", "type": "string" + }, + { + "name": "dataSource", + "type": "object", + "binding": "object", + "description": "Per-element data binding: { object, view, filter, sort, limit }. Overrides the page-level object context for this block; `view` names a saved view of `object`, and `filter` is ANDed with the view’s own rather than replacing it." } ] }, @@ -910,16 +1114,19 @@ { "name": "sections", "type": "array", - "description": "Field groups rendered as the detail body, in order. Every entry is an OBJECT — `{ name?, label?, columns?, fields }` — a bare section-id string is NOT accepted (the spec retired that spelling in objectstack#5611, and the renderer reads name/label/fields off each entry, so a string entry renders no fields at all). `fields` (required) are the field names shown in this section, in order. `label` is the section heading; omit it for an untitled, borderless section. `name` is a stable snake_case identifier and the i18n anchor — the heading resolves through objects.._sections..label, so a section without a name shows its authored label in every locale. `columns` (1-4) is THIS section's field-grid width; omit it and the renderer derives the width. Authoring `sections` at all makes it the only source of the detail body; omit it and the body falls back to the object's highlightFields." + "of": "object", + "description": "Field groups rendered as the detail body, in order. Every entry is an OBJECT — `{ name?, label?, columns?, fields }` — a bare section-id string is NOT accepted (the spec retired that spelling in objectstack#5611, and the renderer reads name/label/fields off each entry, so a string entry renders no fields at all). `fields` are the field names shown in this section, in order — required unless `group` supplies the members instead (the spec refuses a section carrying neither, and refuses one carrying both). `label` is the section heading; omit it for an untitled, borderless section. `name` is a stable snake_case identifier and the i18n anchor — the heading resolves through objects.._sections..label, so a section without a name shows its authored label in every locale. `columns` (1-4) is THIS section's field-grid width; omit it and the renderer derives the width. Authoring `sections` at all makes it the only source of the detail body; omit it and the body falls back to the object's highlightFields. @objectstack/spec 17.3.0 declares eight more member keys on an entry, and this renderer honours all eight: `icon` (a Lucide name on the section header), `description` (sub-heading copy under the heading), `collapsible` and `defaultCollapsed` (a foldable section and its initial state), `showBorder` (force the Card wrapper on or off, overriding the heading-derived default) and `headerColor` (a header tint from the shared palette) through DetailSection, plus `group` — the ADR-0085 §5 REFERENCE form, the alternative to enumerating `fields`. `{ group: 'contact_info' }` inherits the object's `fieldGroups` entry with that key: its members (every visible field pointing at it, in declaration order) and its presentation (label, icon, description, collapse) all come from the group, so the section restates none of it and the spec refuses those keys beside `group`; `columns`, `showBorder` and `headerColor` stay yours because they are how THIS page lays the section out. A `group` naming no declared group renders nothing and is reported to the console (`@objectstack/lint` flags it as `page-section-group-unknown`). `hideEmpty` is the eighth, and it decides whether an ALL-empty section exists: it defaults to on, so a section whose fields are every one of them empty renders nothing at all — no heading, no skeleton — and `hideEmpty: false` is the spelling that keeps that heading and its label skeleton on a brand-new record. It decides ONLY the all-empty case; the empty rows of a section that still has a filled one belong to DetailSection's auto-hide heuristic plus the reader's show-empty toggle, which no authored value overrides in either polarity (objectui#7129 Q2-C), and inline-edit mode renders the section either way so its fields stay reachable. Restored under objectui#8603 (maintainer 2026-09-15) after objectui#7129 retired it on the premise, since falsified upstream, that the spec refused the key. None of the eight has a designer control yet; they are authorable in source mode only, tracked as a deferred feature." }, { "name": "fields", "type": "array", + "of": "string", "description": "Explicit field list (overrides highlightFields)" }, { "name": "hideFields", "type": "array", + "of": "string", "description": "Field names to omit from the body — applied to the top-level `fields` list AND to every section's `fields`. Bare field names only. Authors rarely need it: the synth pipeline fills it with the fields already shown in `record:highlights`, and hand-authored pages get the same dedup live from HighlightFieldsContext, so its purpose is suppressing a field you do not want repeated (the page H1 title field is dropped for you too). Hiding every field of a section leaves that section out entirely." }, { @@ -942,7 +1149,7 @@ "name": "fields", "type": "array", "required": true, - "description": "Key fields to highlight (1-7), bare names or {name,label?,icon?,type?,readonly?}. Set readonly: true on an entry to render that chip read-only — it suppresses the inline-edit affordance and the HeaderHighlight editability gate enforces it. Use it for hook/automation-maintained columns that must not be hand-edited from the record header; marking the OBJECT field readonly instead would also strip the hook's own write-back." + "description": "Key fields to highlight (1-7), bare names or {name,label?,type?,readonly?}. Set readonly: true on an entry to render that chip read-only — it suppresses the inline-edit affordance and the HeaderHighlight editability gate enforces it. Use it for hook/automation-maintained columns that must not be hand-edited from the record header; marking the OBJECT field readonly instead would also strip the hook's own write-back." }, { "name": "layout", @@ -979,6 +1186,7 @@ { "name": "columns", "type": "array", + "of": "string", "required": true, "description": "Fields to display in the related list" }, @@ -994,6 +1202,7 @@ { "name": "filter", "type": "array", + "of": "object", "description": "Additional filter criteria, as spec `ViewFilterRule` entries (`[{ field, operator, value }]`). AND-combined with the parent relationship condition, never a replacement for it: it can only narrow this record's children. Also the key a per-element `dataSource` binding's composed filter lands on." }, { @@ -1007,12 +1216,19 @@ { "name": "actions", "type": "array", + "of": "string", "description": "Action IDs available for related records" }, { "name": "add", "type": "object", "description": "Adds an \"Add\" button that assigns EXISTING records instead of creating one — the m2m/junction case. Shape: `{ picker: { object, valueField?, labelField?, filter? }, linkField?, label? }`. `picker.object` (required) is the object whose records the dialog offers. `picker.valueField` is the field of the picked record used as the link value (default \"id\"); `picker.labelField` is the column shown in the picker rows (default \"name\", and the other columns are derived from that object's schema). With `linkField` set, selecting records CREATES rows in this list's own object as `{ [relationshipField]: parentValue, [linkField]: pickedId }` — the junction case; omit `linkField` and the picked child is RE-PARENTED instead, by setting its own `relationshipField` to this parent. `label` is the button text (default \"Add\", localizable inline). Setting `add` also enables generic link removal on rows when no host delete handler is wired. `picker.filter` restricts which records the dialog offers — a list of `{ field, operator, value }` rules in the same vocabulary as this list's own `filter`, applied as a hard constraint the user cannot widen (it never appears as an editable filter row)." + }, + { + "name": "dataSource", + "type": "object", + "binding": "object", + "description": "Per-element data binding: { object, view, filter, sort, limit }. Overrides the page-level object context for this block; `view` names a saved view of `object`, and `filter` is ANDed with the view’s own rather than replacing it." } ] }, @@ -1029,6 +1245,7 @@ { "name": "stages", "type": "array", + "of": "object", "description": "Explicit stage definitions [{ value, label }] (else derived from field metadata)" } ] @@ -1059,6 +1276,12 @@ { "name": "amountField", "type": "string" + }, + { + "name": "dataSource", + "type": "object", + "binding": "object", + "description": "Per-element data binding: { object, view, filter, sort, limit }. Overrides the page-level object context for this block; `view` names a saved view of `object`, and `filter` is ANDed with the view’s own rather than replacing it." } ] }, @@ -1069,6 +1292,7 @@ { "name": "types", "type": "array", + "of": "string", "description": "Allow-list of feed item types to show (comment, field_change, task, event, email, call, note, file, record_create, record_delete, approval, sharing, system). Omit for all; unrecognised entries are ignored." }, { @@ -1159,7 +1383,7 @@ { "name": "feed", "type": "object", - "description": "Activity-feed config nested inside the panel — same shape as record:activity" + "description": "Activity-feed configuration nested inside the panel — the same shape as record:activity. The spec declares this key as RecordActivityProps (RecordChatterProps.feed), so its members are the inputs record:activity declares; see that block for what each one does." } ] }, @@ -1191,11 +1415,13 @@ { "name": "actionNames", "type": "array", + "of": "string", "description": "Action names to expose, in order — resolved from the actions declared on the object. With no names (and no host-supplied actions) nothing is looked up and the bar renders its empty placeholder" }, { "name": "requiredPermissions", "type": "array", + "of": "string", "description": "Hide the whole bar unless the user holds these permissions" }, { @@ -1280,8 +1506,12 @@ }, { "name": "visible", - "type": "string", - "description": "Expression gating the banner against the current record" + "type": [ + "boolean", + "string", + "object" + ], + "description": "Gates the banner against the current record: `true`/`false`, a bare CEL expression, or the `{ dialect: 'cel', source }` envelope" }, { "name": "icon", @@ -1312,6 +1542,7 @@ { "name": "items", "type": "array", + "of": "object", "required": true, "description": "Tab definitions [{ label, value?, icon?, count?, visibleWhen?, children }] — value is the stable ?tab= URL token, count auto-derives from record:related_list descendants when omitted" }, @@ -1375,6 +1606,7 @@ { "name": "items", "type": "array", + "of": "object", "required": true, "description": "Panel definitions [{ label, icon?, collapsed?, children }] — collapsed: false opens a panel by default" }, @@ -1653,7 +1885,7 @@ "type": "string" }, { - "name": "type", + "name": "actionType", "type": "enum", "enum": [ "script", @@ -1796,7 +2028,7 @@ "type": "string" }, { - "name": "type", + "name": "actionType", "type": "enum", "enum": [ "script", @@ -1830,6 +2062,7 @@ "flex": { "type": "flex", "namespace": "ui", + "isContainer": true, "inputs": [ { "name": "direction", @@ -2112,6 +2345,17 @@ } ] }, + "box": { + "type": "box", + "namespace": "ui", + "isContainer": true, + "inputs": [ + { + "name": "className", + "type": "string" + } + ] + }, "page:header": { "type": "page:header", "namespace": "page", @@ -2135,7 +2379,8 @@ { "name": "actions", "type": "array", - "description": "Action buttons rendered in the header, before any host-injected system actions" + "of": "string", + "description": "Action IDS — the names of actions declared on the object's own metadata — rendered in the header before any host-injected system actions. An id whose action declares neither record_header nor record_more in its locations renders nowhere." }, { "name": "breadcrumb", @@ -2174,6 +2419,31 @@ "name": "content", "type": "string", "required": true + }, + { + "name": "variant", + "type": "enum", + "enum": [ + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "body", + "caption", + "overline" + ] + }, + { + "name": "align", + "type": "enum", + "enum": [ + "left", + "center", + "right", + "justify" + ] } ] }, diff --git a/turbo.json b/turbo.json index 4deb60b4fb..9b149402f9 100644 --- a/turbo.json +++ b/turbo.json @@ -505,7 +505,6 @@ "$TURBO_ROOT$/scripts/git-env.mjs", "$TURBO_ROOT$/.github/workflows/scaffold-e2e.yml", "$TURBO_ROOT$/packages/cli/src/commands/serve.ts", - "$TURBO_ROOT$/scripts/gen-sdui-manifest.sh", "$TURBO_ROOT$/scripts/publish-smoke.sh", "$TURBO_ROOT$/packages/cli/src/commands/init.ts" ] From bd8ae9798ec27f3244e80cd10aa1d2a648f1bf15 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 18:53:39 +0000 Subject: [PATCH 3/4] chore(changeset): @objectstack/console patch for the shipped SDUI manifest Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr --- .changeset/sdui-manifest-one-producer.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .changeset/sdui-manifest-one-producer.md diff --git a/.changeset/sdui-manifest-one-producer.md b/.changeset/sdui-manifest-one-producer.md new file mode 100644 index 0000000000..f6b55f41eb --- /dev/null +++ b/.changeset/sdui-manifest-one-producer.md @@ -0,0 +1,20 @@ +--- +'@objectstack/console': patch +--- + +The prebuilt Console dist now ships `dist/sdui.manifest.json`: the ADR-0080 public-tier +component manifest of the objectui registry at the pinned commit. + +It is the same file the framework repository tracks at its root and gates on every pull +request. `scripts/build-console.sh` copies it in, and one producer writes it: +`scripts/gen-sdui-manifest-node.mjs`, which reads objectui's built tree at the pin. No +earlier published `@objectstack/console` carried this file. The RC cut used to write a +browser-dumped copy into `dist/`, but the release build replaced `dist/` before packing, so +none reached a tarball (17.0.0, 17.3.0 and 17.4.0 each list 0 matches). That browser dump is +retired. It was byte-identical to the tracked file over the same built tree. + +For now the file is only present in the tarball. This package's `exports` map exposes +`./package.json` and nothing else, so resolving `@objectstack/console/dist/sdui.manifest.json` +through `exports` fails with `ERR_PACKAGE_PATH_NOT_EXPORTED`. Anything that resolves through +`exports` cannot read the file yet. That includes the CLI's JSX-page manifest fallback, which +catches the error and keeps parse-level validation, as before. From b7d0b3693a5b09f4e9a2a565b0f54af661bd2a66 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 19:15:08 +0000 Subject: [PATCH 4/4] docs(agents): keep the rewritten pin-bump clause inside the 120-byte line budget Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index f6d9a32e04..0311b73bad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1088,7 +1088,7 @@ Both non-handshake shapes, and how to classify and probe your own: so a removal or rename the pinned sibling still imports turns `main` red for every PR in the repo the moment it merges — "retire the surface" and "leave the sibling untouched" cannot both hold. A ruling that authorizes such a removal therefore implicitly authorizes the objectui-side fix and the pin bump as part of the same landing (the - bump's manifest-regeneration second half included — see the Frontend section). Pre-merge check for any removal or rename + bump's manifest regeneration included — see the Frontend section). Pre-merge check for any removal or rename of an exported surface: does the pinned sibling import what you are removing? `git grep` it in `../objectui` at the pinned SHA before merging. 5. **Touched `packages/spec`? Regenerate and commit its artifacts before pushing** — § *Touched `packages/spec`*