diff --git a/scripts/check-sdui-lockstep.mjs b/scripts/check-sdui-lockstep.mjs index a271683a9c1..bc12d3a8b25 100644 --- a/scripts/check-sdui-lockstep.mjs +++ b/scripts/check-sdui-lockstep.mjs @@ -108,6 +108,26 @@ * given that YOU are moving the pin, has the parser parity been re-verified * against what you are moving it to. * + * ## With no pin: the in-tree oracle (#25712) + * + * objectui's tree is imported under `ui/` (#25677), and the pin goes away with + * it: the renderer that ships is then the copy at `ui/packages/sdui-parser/src`, + * built from the same commit as this one. So the gate picks its oracle by what + * the tree has (`oracleFor`), and a tree WITH `.objectui-sha` is judged exactly + * as above, record and messages unchanged: + * + * pin present → the vendored record, as described above; + * pin absent, `ui/` copy → that copy, read directly with the same reader + * `--update` records with (same file set, region, + * code set and containment predicate). The record is + * not consulted, and both directions are visible: the + * in-tree copy moving shows here too; + * neither → REFUSED, naming both. A tree with no renderer copy + * to agree with is an exit 1, never a pass. + * + * Retiring the lockstep itself (one parser, the record and this gate deleted) + * is #25712 item 3, not this mode. + * * ## The decomposition that keeps this from crying wolf on day one * * The same diagnostic code is a quoted literal in this repo and a CONSTANT @@ -138,7 +158,8 @@ import { createHash } from 'node:crypto'; import { execFileSync } from 'node:child_process'; -import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; import { dirname, join, relative, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -178,6 +199,16 @@ const RECORD_FILE = 'packages/sdui-parser/objectui-lockstep.json'; /** The objectui commit whose console build this platform ships. */ const PIN_FILE = '.objectui-sha'; +/** The directory objectui's tree is imported under (#25677), as a bare segment. */ +const UI_TREE = 'ui'; + +/** + * The renderer's copy in the merged layout — the in-tree oracle. Spelled as one + * literal so that `scripts/pm/dispatch-gates.mjs` derives this gate for a diff + * under it; the self-test holds it equal to `${UI_TREE}/${PARSER_SRC}`. + */ +const UI_PARSER_SRC = 'ui/packages/sdui-parser/src'; + /** Where `--update` looks for an objectui checkout, after `OBJECTUI_ROOT`. */ const OBJECTUI_SIBLING = '../objectui'; @@ -487,6 +518,81 @@ export function judge({ record, ours, livePin }) { return findings; // nothing below can be measured against a record that is not one } + findings.push(...unreadableHere(ours)); + if (findings.length > 0) return findings; + + if (record.recordedAgainstPin !== livePin) { + findings.push( + `[pin-moved] ${PIN_FILE} is ${livePin.slice(0, 12)}, and the parity record was taken while it was\n` + + ` ${record.recordedAgainstPin.slice(0, 12)}. The pin names the objectui build whose RENDERER ships, so a\n` + + ' bump is exactly when a parser divergence stops being latent and starts being shipped.\n' + + ' Re-record against the objectui checkout the bump already required: pnpm gen:sdui-lockstep', + ); + } + + const rev = record.objectui.rev.slice(0, 12); + findings.push(...driftFindings({ + ours, + theirs: { region: record.grammarRegion, codes: record.diagnosticCodes, containment: record.containmentPredicate }, + side: { + label: 'recorded', + region: `objectui's at\n ${rev}`, + containment: `objectui's copy\n runs at ${rev}`, + codes: `objectui's at ${rev}`, + only: 'only objectui', + }, + })); + return findings; +} + +/** + * The verdict with no pin (the merged layout): this copy against the renderer's + * copy under `ui/`, read whole by `fingerprintOf` — no record in between. Pure, + * like `judge`; the in-tree side is held to the refusals `--update` holds + * objectui's side to before it records, so an unreadable copy is never a match. + */ +export function judgeInTree({ ours, theirs }) { + const findings = []; + if (theirs.region === null) { + findings.push( + `[ui-region-unreachable] ${UI_TREE}/${REGION_FILE} does not contain the delimiter that opens the ported block:\n` + + ` ${REGION_DELIMITER}\n` + + ' Nothing was compared. The renderer\'s copy must carry the block this copy is held to.', + ); + } + if (theirs.unresolved.length > 0 || theirs.codes.length === 0) { + findings.push( + `[ui-code-set-unreadable] the diagnostic-code set under ${UI_PARSER_SRC} could not be read whole, so no\n` + + ' comparison was made:\n' + + (theirs.codes.length === 0 ? ' no code found at all\n' : '') + + theirs.unresolved.map((u) => ` unresolvable: ${u}`).join('\n'), + ); + } + if (theirs.containment?.predicate == null) { + findings.push( + `[ui-containment-unreachable] ${theirs.containment?.why ?? `no containment reading was taken of ${UI_TREE}/${CONTAINMENT_FILE}`}.\n` + + ' Nothing was compared.', + ); + } + findings.push(...unreadableHere(ours)); + if (findings.length > 0) return findings; + + return driftFindings({ + ours, + theirs: { region: theirs.region, codes: theirs.codes, containment: theirs.containment.predicate }, + side: { + label: 'in-tree ', + region: `the in-tree copy's\n (${UI_TREE}/${REGION_FILE})`, + containment: `the in-tree copy\n (${UI_TREE}/${CONTAINMENT_FILE}) runs`, + codes: `the in-tree copy's (${UI_PARSER_SRC})`, + only: 'only ui/ ', + }, + }); +} + +/** This copy's inputs, asserted before any comparison: each finding is a refusal, never a drift. */ +function unreadableHere(ours) { + const findings = []; if (ours.region === null) { findings.push( `[region-unreachable] ${REGION_FILE} does not contain the delimiter that opens the ported block:\n` @@ -515,48 +621,45 @@ export function judge({ record, ours, livePin }) { + ' and a copy whose predicate cannot be located is not a copy that agrees.', ); } - if (findings.length > 0) return findings; - - if (record.recordedAgainstPin !== livePin) { - findings.push( - `[pin-moved] ${PIN_FILE} is ${livePin.slice(0, 12)}, and the parity record was taken while it was\n` - + ` ${record.recordedAgainstPin.slice(0, 12)}. The pin names the objectui build whose RENDERER ships, so a\n` - + ' bump is exactly when a parser divergence stops being latent and starts being shipped.\n' - + ' Re-record against the objectui checkout the bump already required: pnpm gen:sdui-lockstep', - ); - } + return findings; +} - if (ours.region.blob !== record.grammarRegion.blob) { +/** + * The three drift comparisons against one reading of the renderer's copy — the + * record (pin) or `ui/` itself (in-tree). `side` carries only the words that + * name which, so both verdicts compare the same way. + */ +function driftFindings({ ours, theirs, side }) { + const findings = []; + if (ours.region.blob !== theirs.region.blob) { findings.push( - `[grammar-drift] the ported grammar region of ${REGION_FILE} is not byte-identical to objectui's at\n` - + ` ${record.objectui.rev.slice(0, 12)}.\n` + `[grammar-drift] the ported grammar region of ${REGION_FILE} is not byte-identical to ${side.region}.\n` + ` here ${ours.region.lines} line(s), blob ${ours.region.blob}\n` - + ` recorded ${record.grammarRegion.lines} line(s), blob ${record.grammarRegion.blob}\n` + + ` ${side.label} ${theirs.region.lines} line(s), blob ${theirs.region.blob}\n` + ' Either this copy was edited without the port, or the port was not byte-faithful. Diff the two\n' + ` copies' ${REGION_FILE} from the delimiter down.`, ); } - if (ours.containment.predicate.blob !== record.containmentPredicate.blob) { + if (ours.containment.predicate.blob !== theirs.containment.blob) { const indent = (t) => t.trimEnd().split('\n').map((l) => ` ${l}`).join('\n'); findings.push( - `[containment-drift] the \`${CONTAINMENT_CODE}\` predicate in ${CONTAINMENT_FILE} is not the one objectui's copy\n` - + ` runs at ${record.objectui.rev.slice(0, 12)}, so the save gate and the renderer disagree about which\n` + `[containment-drift] the \`${CONTAINMENT_CODE}\` predicate in ${CONTAINMENT_FILE} is not the one ${side.containment}, so the save gate and the renderer disagree about which\n` + ' components accept a child list.\n' + ` here blob ${ours.containment.predicate.blob}\n${indent(ours.containment.predicate.text)}\n` - + ` recorded blob ${record.containmentPredicate.blob}\n${indent(record.containmentPredicate.text)}`, + + ` ${side.label} blob ${theirs.containment.blob}\n${indent(theirs.containment.text)}`, ); } - const recorded = new Set(record.diagnosticCodes); + const recorded = new Set(theirs.codes); const mine = new Set(ours.codes); const onlyHere = ours.codes.filter((c) => !recorded.has(c)); - const onlyThere = record.diagnosticCodes.filter((c) => !mine.has(c)); + const onlyThere = theirs.codes.filter((c) => !mine.has(c)); if (onlyHere.length > 0 || onlyThere.length > 0) { findings.push( - `[code-drift] the diagnostic-code sets differ from objectui's at ${record.objectui.rev.slice(0, 12)}.\n` + `[code-drift] the diagnostic-code sets differ from ${side.codes}.\n` + (onlyHere.length ? ` only here ${onlyHere.join(', ')}\n` : '') - + (onlyThere.length ? ` only objectui ${onlyThere.join(', ')}\n` : '') + + (onlyThere.length ? ` ${side.only} ${onlyThere.join(', ')}\n` : '') + ' A code on one side only is a dialect: the save gate and the renderer disagree about what a page means.', ); } @@ -564,6 +667,37 @@ export function judge({ record, ours, livePin }) { return findings; } +// ───────────────────────────────────────────────────────────────────────────── +// Choosing the oracle + +/** + * Which copy of the renderer's parser this tree is judged against. The pin wins + * whenever it is present, so a tree that has both (the merged tree before the pin + * is deleted) is judged exactly as before the merge. + */ +export function oracleFor({ pinPresent, uiPresent }) { + if (pinPresent) return 'pin'; + return uiPresent ? 'in-tree' : 'none'; +} + +/** + * Gate a tree that has no pin: `{ refusal }` when it has no in-tree copy either, + * otherwise both fingerprints and `judgeInTree`'s findings. + */ +export function checkWithoutPin(root) { + if (oracleFor({ pinPresent: false, uiPresent: existsSync(join(root, UI_PARSER_SRC)) }) === 'none') { + return { + refusal: + `this tree has neither ${PIN_FILE} nor ${UI_PARSER_SRC}, so there is no renderer copy to compare\n` + + ` with. With the pin, the renderer's copy is judged through the vendored record (${RECORD_FILE});\n` + + ` without it, the renderer's copy is the in-tree one under ${UI_TREE}/, read directly. Nothing was compared.`, + }; + } + const ours = fingerprintOf(root); + const theirs = fingerprintOf(join(root, UI_TREE)); + return { ours, theirs, findings: judgeInTree({ ours, theirs }) }; +} + // ───────────────────────────────────────────────────────────────────────────── // Re-recording objectui's side @@ -847,11 +981,12 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'The generator will not record a tree the pin does not name': 7, 'The wiring this gate needs to be reachable at all': 12, 'The containment predicate (#19969)': 10, + 'The in-tree oracle (no pin: the merged layout)': 18, }); // DELETING an entry silences that battery's floor exactly as effectively as // zeroing it, so the roster's own size is pinned too. -const SELF_TEST_BATTERY_FLOOR = 7; +const SELF_TEST_BATTERY_FLOOR = 8; // The key an assertion is filed under when no battery is open. It is not a // declared battery, so it reds by the same set difference rather than silently @@ -1222,6 +1357,117 @@ export function selfTest() { })) === 'record-unusable', ); + // ── The in-tree oracle (no pin: the merged layout) ──────────────────────── + // + // Driven through `checkWithoutPin` over throwaway trees, so the path the + // production run takes — the presence test, `fingerprintOf` on both roots, + // the verdict — is the path asserted. The two ⭐ rows are the pair the mode + // owes: no pin with an agreeing `ui/` copy passes, no pin with no `ui/` copy + // REFUSES naming both. The `ui/` copy spells its code as a constant, the way + // objectui's does, so the decomposition is crossed between trees too. + battery('The in-tree oracle (no pin: the merged layout)'); + check('the pin wins when a tree has both', oracleFor({ pinPresent: true, uiPresent: true }) === 'pin'); + check('the pin alone is the pin oracle', oracleFor({ pinPresent: true, uiPresent: false }) === 'pin'); + check('no pin and a ui/ copy is the in-tree oracle', oracleFor({ pinPresent: false, uiPresent: true }) === 'in-tree'); + check('no pin and no ui/ copy is no oracle at all', oracleFor({ pinPresent: false, uiPresent: false }) === 'none'); + const PARSE_FIXTURE = `export const head = 1;\n${REGION_DELIMITER}\nexport const alpha = 2;\nexport const beta = 3;\n`; + const trees = []; + const inTree = ({ ui = {}, omitUi = false } = {}) => { + const root = mkdtempSync(join(tmpdir(), 'sdui-lockstep-tree-')); + trees.push(root); + const write = (dir, files) => { + mkdirSync(join(root, dir), { recursive: true }); + for (const [name, text] of Object.entries(files)) writeFileSync(join(root, dir, name), text); + }; + write(PARSER_SRC, { 'parse.ts': PARSE_FIXTURE, 'validate.ts': FIXTURE_CONTAINMENT_SLOT, 'codes.ts': FIXTURE_LITERAL_SIDE }); + if (!omitUi) { + write(UI_PARSER_SRC, { 'parse.ts': PARSE_FIXTURE, 'validate.ts': FIXTURE_CONTAINMENT_SLOT, 'codes.ts': FIXTURE_CONSTANT_SIDE, ...ui }); + } + // A throw is caught and named, so an ablated guard reds THIS row with its + // label rather than crashing the run before the rest report. + try { + return checkWithoutPin(root); + } catch (error) { + return { threw: String(error?.message ?? error).split('\n')[0] }; + } + }; + const treeKinds = (result) => { + if (result.threw) return `threw: ${result.threw}`; + return result.refusal ? 'refused' : kinds(result.findings); + }; + const agreeing = inTree(); + check( + '⭐ no pin + a ui/ copy that agrees: no finding, so the gate exits 0', + treeKinds(agreeing) === '', + treeKinds(agreeing) || '(none)', + ); + const noOracle = inTree({ omitUi: true }); + check('⭐ no pin + no ui/ copy: REFUSED, so the gate exits 1, never a pass', treeKinds(noOracle) === 'refused', treeKinds(noOracle)); + check( + 'and the refusal names BOTH oracles it looked for', + (noOracle.refusal ?? '').includes(PIN_FILE) && (noOracle.refusal ?? '').includes(UI_PARSER_SRC), + noOracle.refusal ?? '(no refusal)', + ); + const grammarDrift = inTree({ ui: { 'parse.ts': PARSE_FIXTURE.replace('beta', 'betb') } }); + check('a one-character grammar change in the ui/ copy is a finding', treeKinds(grammarDrift) === 'grammar-drift', treeKinds(grammarDrift)); + check( + 'and the finding names the in-tree copy, never a recorded objectui revision', + (grammarDrift.findings?.[0] ?? '').includes(`${UI_TREE}/${REGION_FILE}`) && !(grammarDrift.findings?.[0] ?? '').includes("objectui's"), + grammarDrift.findings?.[0] ?? '(no finding)', + ); + const codeDrift = inTree({ ui: { 'extra.ts': "export const later = { code: 'brand-new', message: 'x' };\n" } }); + check( + 'a code only the ui/ copy stamps is a finding — the direction the record could not see', + treeKinds(codeDrift) === 'code-drift', + treeKinds(codeDrift), + ); + const predicateDrift = inTree({ + ui: { 'validate.ts': FIXTURE_CONTAINMENT_SLOT.replace('!acceptsChildren(comp)', '!comp.isContainer') }, + }); + check('a changed containment predicate in the ui/ copy is a finding', treeKinds(predicateDrift) === 'containment-drift', treeKinds(predicateDrift)); + const noRegion = inTree({ ui: { 'parse.ts': 'export const head = 1;\n// no delimiter here\n' } }); + check( + 'a ui/ copy without the region delimiter refuses, never compares a shorter region', + treeKinds(noRegion) === 'ui-region-unreachable', + treeKinds(noRegion), + ); + const unresolvableThere = inTree({ ui: { 'extra.ts': FIXTURE_UNRESOLVABLE } }); + check( + 'an unresolvable code position in the ui/ copy refuses instead of comparing a short set', + treeKinds(unresolvableThere) === 'ui-code-set-unreadable', + treeKinds(unresolvableThere), + ); + const noPredicate = inTree({ ui: { 'validate.ts': 'export const x = 1;\n' } }); + check( + 'a ui/ copy whose predicate cannot be located refuses instead of comparing', + treeKinds(noPredicate) === 'ui-containment-unreachable', + treeKinds(noPredicate), + ); + const unresolvableHere = inTree(); + check( + 'this copy is held to its own refusals in this mode too', + kinds(judgeInTree({ ours: { ...unresolvableHere.ours, unresolved: ['x.ts:1: SOMEWHERE_ELSE'] }, theirs: unresolvableHere.theirs })) + === 'code-unresolvable', + ); + for (const root of trees) rmSync(root, { recursive: true, force: true }); + check('the in-tree path is spelled from the same two parts the reads join', UI_PARSER_SRC === `${UI_TREE}/${PARSER_SRC}`, UI_PARSER_SRC); + check( + `\`${UI_PARSER_SRC}\` is a path literal in this gate's code, so a diff under it derives this gate`, + inCode.includes(`'${UI_PARSER_SRC}'`), + ); + const mainOpen = inCode.indexOf('\nfunction main() {'); + const mainSrc = inCode.slice(mainOpen, inCode.indexOf('\n}\n', mainOpen)); + check( + 'main() sends a pin-less tree to the in-tree run BEFORE it reads the record', + mainOpen !== -1 && mainSrc.includes('mainWithoutPin();') + && mainSrc.indexOf('mainWithoutPin();') < mainSrc.indexOf('readFileSync(join(ROOT, RECORD_FILE)'), + ); + const noPinOpen = inCode.indexOf('\nfunction mainWithoutPin() {'); + check( + 'and the in-tree run judges through checkWithoutPin, so the rows above are not phantom', + noPinOpen !== -1 && inCode.slice(noPinOpen, inCode.indexOf('\n}\n', noPinOpen)).includes('checkWithoutPin(ROOT)'), + ); + // ── The floor: every declared battery RAN, and ran its cases (#13489) ─── // // Evaluated after every battery has had its chance and BEFORE the verdict, so @@ -1275,8 +1521,8 @@ export function selfTest() { console.log( 'check:sdui-lockstep --self-test passed (the constant-vs-literal decomposition and its unresolvable ' + 'direction, the region reader in both drift directions, all four refusal classes, the generator\'s ' - + 'HEAD-vs-pin refusal in both directions, the CI wiring, and the containment predicate in both ' - + 'directions)', + + 'HEAD-vs-pin refusal in both directions, the CI wiring, the containment predicate in both ' + + 'directions, and the in-tree oracle with no pin in both directions)', ); return SELF_TEST_VERDICT; @@ -1298,6 +1544,10 @@ function main() { update(); return; } + if (!existsSync(join(ROOT, PIN_FILE))) { + mainWithoutPin(); + return; + } let record = null; try { @@ -1346,6 +1596,38 @@ function main() { process.exit(1); } +/** `main()` for a tree with no pin: the in-tree oracle (see the header). */ +function mainWithoutPin() { + const { refusal, ours, theirs, findings } = checkWithoutPin(ROOT); + if (refusal) { + console.error(`\ncheck-sdui-lockstep: REFUSED — ${refusal}\n`); + process.exit(1); + } + + if (findings.length === 0) { + console.log( + `check:sdui-lockstep: OK (in-tree) — this copy is byte-identical to ${UI_PARSER_SRC} over ${ours.region.lines} ` + + `grammar line(s) [blob ${ours.region.blob.slice(0, 12)}], agrees on all ${ours.codes.length} diagnostic code(s) ` + + `and on the \`${CONTAINMENT_CODE}\` predicate [blob ${ours.containment.predicate.blob.slice(0, 12)}], ` + + `across ${ours.files.length} and ${theirs.files.length} non-test source(s).`, + ); + console.log( + ` No ${PIN_FILE} in this tree, so the renderer's copy is the in-tree one and ${RECORD_FILE} was not ` + + 'consulted. Both copies were read now, so either one moving shows here.', + ); + return; + } + + console.error(`\ncheck:sdui-lockstep: ${findings.length} finding(s) — the two sdui-parser copies have drifted\n`); + for (const finding of findings) console.error(` ${finding}\n`); + console.error( + ` Why this matters: the copy under ${UI_TREE}/ runs in the RENDERER and this one runs in the SAVE GATE. When\n` + + ' they disagree, a page saves clean and renders inert (#12719). Port the change to the other copy in the\n' + + ' same PR.\n', + ); + process.exit(1); +} + // Exports bindings, so an import for those exports alone must run nothing (#10667). if (isEntrypoint(import.meta.url)) { main(); diff --git a/scripts/check-sdui-manifest.mjs b/scripts/check-sdui-manifest.mjs index 98bddf0abf1..ff45b37773b 100644 --- a/scripts/check-sdui-manifest.mjs +++ b/scripts/check-sdui-manifest.mjs @@ -55,6 +55,30 @@ * 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. * + * ## With no pin: the in-tree registry (#25712) + * + * Once objectui's tree lives under `ui/` and `.objectui-sha` is gone (#25677), + * there is no pin to build and no checkout to ask, so the oracle is the + * registry in this tree. The mode is decided by `.objectui-sha` alone: with it + * present, nothing above reads differently. Without it: + * + * 1–2. unchanged — the same shape and tamper checks, against a record keyed + * to the in-tree producer (`IN_TREE_RECORD_FIELDS`: the pin-keyed + * fields are not required, the `inTree` block is). + * 3. STALENESS is a SOURCE DIGEST — `registrySourceDigest` over the + * registry packages' `src/` as the index holds them must equal the + * record's `inTree` reading. A registry source edit without + * regeneration reds HERE, and the red prints the one command that + * clears it: the generator, which builds the registry from this tree + * and regenerates. Nothing is built in this gate — the digest is a + * `git ls-files` over the index, with no dist and no node_modules read. + * 4. none. The in-tree `@object-ui/core` version moves with every release + * of the one fixed version group while the registry does not, so the + * record carries it as provenance and the success line prints it. + * + * No pin AND no in-tree console is RED, naming both: a tree with neither has + * nothing to be fresh against, which is a broken tree, not a skip. + * * ## What this deliberately does NOT do, and where that risk is held * * No per-PR regeneration: that would put an objectui clone and build (network, @@ -108,6 +132,7 @@ import { existsSync, mkdirSync, mkdtempSync, + readdirSync, readFileSync, realpathSync, renameSync, @@ -119,15 +144,31 @@ import { tmpdir } from 'node:os'; import { dirname, join, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { gitFreeEnv } from './git-env.mjs'; import { isEntrypoint } from './invoked-as.mjs'; const DEFAULT_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); -/** The three files `checkTree` opens, repo-relative. */ +/** The directory objectui's tree is imported under (#25677), as a bare segment. */ +const UI_TREE = 'ui'; + +/** The in-tree console the generator reads when the tree has no pin (#25712). */ +const UI_CONSOLE = 'ui/apps/console'; + +/** Where the `@object-ui/*` workspace packages live in the merged layout. */ +const UI_PACKAGES = 'ui/packages'; + +/** + * What `checkTree` reads, repo-relative: three files with a pin; without one, + * the same artefact and record plus the in-tree console's manifest (presence) + * and the registry packages (the digest). + */ const READ_PATHS = { artefact: 'sdui.manifest.json', record: join('scripts', 'sdui-manifest.record.json'), pin: '.objectui-sha', + console: join(UI_CONSOLE, 'package.json'), + packages: UI_PACKAGES, }; /** @@ -155,6 +196,40 @@ const OBJECTUI_VERSION_FILE = 'packages/core/package.json'; const RECORD_FIELDS = ['objectuiSha', 'source', 'modulesRoot', 'objectuiWorkspaceVersion', 'sha256', 'components']; const RECORD_SOURCE = 'built-tree'; +/** + * The same, for the in-tree producer (no pin): the pin-keyed fields name a pin + * and are not required, the `inTree` block (`IN_TREE_FIELDS`) is. The pin-keyed + * fields survive an in-tree regeneration only while the artefact they describe + * is byte-identical — see the generator. + */ +const IN_TREE_RECORD_FIELDS = ['source', 'sha256', 'components', 'inTree']; +const IN_TREE_FIELDS = ['modulesRoot', 'objectuiWorkspaceVersion', 'registryPackages', 'registrySources', 'registrySourcesSha256']; + +/** The one command that clears each mode's freshness reds. */ +const PIN_REGENERATE = 'pnpm objectui:build && node scripts/gen-sdui-manifest-node.mjs'; +const IN_TREE_REGENERATE = 'node scripts/gen-sdui-manifest-node.mjs'; + +/** The registry the generator enumerates, besides `@object-ui/core` itself — its `REGISTRY_MODULES`, in its order. */ +const REGISTRY_CORE = '@object-ui/core'; +export const REGISTRY_MODULES = Object.freeze([ + '@object-ui/components', + '@object-ui/plugin-grid', + '@object-ui/plugin-form', + '@object-ui/plugin-view', + '@object-ui/plugin-list', + '@object-ui/plugin-detail', + '@object-ui/plugin-dashboard', + '@object-ui/plugin-charts', + '@object-ui/plugin-kanban', + '@object-ui/plugin-calendar', + '@object-ui/plugin-gantt', + '@object-ui/plugin-timeline', + '@object-ui/plugin-map', + '@object-ui/plugin-markdown', + '@object-ui/plugin-report', + '@object-ui/plugin-tree', +]); + /** * The one producer, repo-relative. Read by the SELF-TEST only — copied into a * fixture tree and run there (see the header) — never by `checkTree`, so it is @@ -163,10 +238,13 @@ const RECORD_SOURCE = 'built-tree'; */ const PRODUCER = 'scripts/gen-sdui-manifest-node.mjs'; +/** The repo helpers the producer (and this gate, which it imports in-tree) import: copied beside it in the self-test's fixtures. */ +const PRODUCER_IMPORTS = ['scripts/git-env.mjs', 'scripts/invoked-as.mjs']; + /** * The population this gate reads, declared for `scripts/pm/dispatch-gates.mjs`. * - * `checkTree` opens exactly three files. None of them was visible to the + * With a pin, `checkTree` opens exactly three files. None of them was visible to the * derivation: it reads SOURCE TEXT, and a literal with no separator is refused * by `hintCovers` as too generic (`sdui.manifest.json`), while `.objectui-sha` * is not one of the dot-prefixed names the extractor admits and the record path @@ -183,6 +261,10 @@ const PRODUCER = 'scripts/gen-sdui-manifest-node.mjs'; * to nothing else. * * ⛔ Not a whole-tree marker, and not `scripts/**`: three files are three files. + * Without a pin it also reads the in-tree console's manifest and the registry + * packages under `ui/packages/` (#25712) — the subtree the digest walks, so a + * UI package edit derives this gate; on a tree with no `ui/` both hints match + * nothing and only display. * * The self-test derives the coupling from `READ_PATHS` — the same object * `checkTree` reads — so a moved or added read reds here rather than leaving the @@ -192,6 +274,8 @@ const ROOT_WATCH_HINTS = [ 'sdui.manifest.json/**', 'scripts/sdui-manifest.record.json', '.objectui-sha/**', + 'ui/apps/console/package.json', + 'ui/packages/**', ]; /** @@ -261,17 +345,78 @@ export function readPinnedDeclaredVersion(pin, { root = DEFAULT_ROOT, objectuiRo * callers that hold a checkout. */ export function checkTree(root, { objectuiRoot, requireObjectui = false, onVersionLeg } = {}) { + const pinPath = join(root, READ_PATHS.pin); + if (!existsSync(pinPath)) return checkTreeWithoutPin(root); + const problems = []; + const read = checkArtefactAndRecord(root, problems, { + fields: RECORD_FIELDS, + producerReads: "objectui's built tree at the pin", + regenerate: PIN_REGENERATE, + }); + if (read === null) return problems; + const { record } = read; + + const pin = readFileSync(pinPath, 'utf8').trim(); + + // Check 4's oracle, read once, about the LIVE pin. + const versionLeg = readPinnedDeclaredVersion(pin, { root, objectuiRoot }); + onVersionLeg?.(versionLeg); + + if (pin !== record.objectuiSha) { + 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). 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 + // problem would restate this one rather than add a finding. + return problems; + } + + // ── Check 4 (#18611) ──────────────────────────────────────────────────── + if (versionLeg.status === 'resolved' && versionLeg.version !== record.objectuiWorkspaceVersion) { + problems.push( + `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 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 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) { + problems.push( + `--require-objectui was passed and check 4 could not run: ${versionLeg.reason}\n` + + ' Point OBJECTUI_ROOT at a checkout that carries the pin, or drop the flag where no checkout exists.', + ); + } + return problems; +} + +/** + * Checks 1–2, the same in both modes: the artefact exists, parses and is a + * component manifest; the record exists, parses, carries `fields` and the one + * producer's `source`; the artefact's sha256 and component count are the + * record's. Pushes onto `problems`; returns `null` where today's checks stop + * early, otherwise `{ record }` for the mode's own freshness leg. + */ +function checkArtefactAndRecord(root, problems, { fields, producerReads, regenerate }) { const artefactPath = join(root, READ_PATHS.artefact); const recordPath = join(root, READ_PATHS.record); - const pinPath = join(root, READ_PATHS.pin); 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: pnpm objectui:build && node scripts/gen-sdui-manifest-node.mjs', + ` silently, so this gate is the thing that notices. Regenerate: ${regenerate}`, ); - return problems; // every later check reads it + return null; // every later check reads it } const raw = readFileSync(artefactPath, 'utf8'); @@ -280,12 +425,12 @@ export function checkTree(root, { objectuiRoot, requireObjectui = false, onVersi manifest = JSON.parse(raw); } catch (e) { problems.push(`sdui.manifest.json does not parse as JSON: ${e.message}`); - return problems; + return null; } const components = manifest?.components; if (!components || typeof components !== 'object' || Array.isArray(components)) { problems.push('sdui.manifest.json has no `components` object — not a component manifest.'); - return problems; + return null; } const keys = Object.keys(components); if (keys.length === 0) problems.push('sdui.manifest.json declares 0 components — an empty whitelist would red every page.'); @@ -298,16 +443,16 @@ export function checkTree(root, { objectuiRoot, requireObjectui = false, onVersi if (!existsSync(recordPath)) { problems.push('scripts/sdui-manifest.record.json is MISSING — provenance unknown. Regenerate to re-record.'); - return problems; + return null; } let record; try { record = JSON.parse(readFileSync(recordPath, 'utf8')); } catch (e) { problems.push(`scripts/sdui-manifest.record.json does not parse: ${e.message}`); - return problems; + return null; } - for (const field of RECORD_FIELDS) { + for (const field of fields) { if (record[field] === undefined) problems.push(`record is missing \`${field}\`.`); } if (record.source !== undefined && record.source !== RECORD_SOURCE) { @@ -315,70 +460,164 @@ export function checkTree(root, { objectuiRoot, requireObjectui = false, onVersi } 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', + ` The record is keyed to the one producer, which reads ${producerReads}. Regenerate:\n` + + ` ${regenerate}`, ); - return problems; + return null; } 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: pnpm objectui:build && node scripts/gen-sdui-manifest-node.mjs', + ` The artefact is generator-owned — never hand-edit it. Regenerate: ${regenerate}`, ); } if (keys.length !== record.components) { problems.push(`artefact has ${keys.length} components, record says ${record.components}.`); } + return { record }; +} - if (!existsSync(pinPath)) { - problems.push('.objectui-sha is MISSING — cannot judge freshness.'); +/** + * Gate a tree that has no pin (the merged layout, #25712). Checks 1–2 as + * above, keyed to the in-tree producer's record; check 3 becomes the registry + * sources' digest against `inTree.registrySourcesSha256`. There is no check 4 + * here: the in-tree `@object-ui/core` version moves with every release of the + * fixed group while the registry does not, so the record carries it as + * provenance and the success line prints both. No pin and no in-tree console + * is RED, naming both. + */ +function checkTreeWithoutPin(root) { + const problems = []; + if (!existsSync(join(root, UI_CONSOLE, 'package.json'))) { + problems.push( + `.objectui-sha is MISSING and there is no in-tree console (${UI_CONSOLE}) either — no oracle to judge freshness\n` + + ' against. With the pin, the manifest is held to objectui\'s build at that commit; without it, to the\n' + + ` registry sources under ${UI_TREE}/. A tree with neither cannot be called fresh.`, + ); return problems; } - const pin = readFileSync(pinPath, 'utf8').trim(); - - // Check 4's oracle, read once, about the LIVE pin. - const versionLeg = readPinnedDeclaredVersion(pin, { root, objectuiRoot }); - onVersionLeg?.(versionLeg); + const read = checkArtefactAndRecord(root, problems, { + fields: IN_TREE_RECORD_FIELDS, + producerReads: `the in-tree console build (${UI_CONSOLE})`, + regenerate: IN_TREE_REGENERATE, + }); + if (read === null) return problems; + const { record } = read; - if (pin !== record.objectuiSha) { - 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). 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 - // problem would restate this one rather than add a finding. + const missing = IN_TREE_FIELDS.filter((field) => record.inTree?.[field] === undefined); + if (missing.length > 0 || record.inTree?.modulesRoot !== UI_CONSOLE) { + for (const field of missing) problems.push(`record \`inTree\` is missing \`${field}\`.`); + if (!missing.includes('modulesRoot') && record.inTree?.modulesRoot !== UI_CONSOLE) { + problems.push(`record \`inTree.modulesRoot\` is ${JSON.stringify(record.inTree?.modulesRoot)}, not ${JSON.stringify(UI_CONSOLE)} — the in-tree producer reads only that.`); + } + problems.push(` Regenerate in-tree — one command builds what the generator reads and re-records:\n ${IN_TREE_REGENERATE}`); return problems; } - // ── Check 4 (#18611) ──────────────────────────────────────────────────── - if (versionLeg.status === 'resolved' && versionLeg.version !== record.objectuiWorkspaceVersion) { - problems.push( - `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 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 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) { + const now = registrySourceDigest(root); + if (now.problem) { + problems.push(`cannot digest the registry sources under ${UI_TREE}/: ${now.problem}`); + return problems; + } + const was = record.inTree; + if ( + now.sha256 !== was.registrySourcesSha256 + || now.files !== was.registrySources + || JSON.stringify(now.packages) !== JSON.stringify(was.registryPackages) + ) { problems.push( - `--require-objectui was passed and check 4 could not run: ${versionLeg.reason}\n` + - ' Point OBJECTUI_ROOT at a checkout that carries the pin, or drop the flag where no checkout exists.', + `the console registry sources under ${UI_TREE}/ have changed since sdui.manifest.json was generated:\n` + + ` now ${now.packages.length} package(s), ${now.files} file(s), sha256 ${now.sha256.slice(0, 12)}…\n` + + ` recorded ${Array.isArray(was.registryPackages) ? was.registryPackages.length : '?'} package(s), ${was.registrySources} file(s), sha256 ${String(was.registrySourcesSha256).slice(0, 12)}…\n` + + ' A registry change can change which components and inputs the shipped console declares, so the manifest\n' + + ' must follow it (同步一次就烂). One command builds what the generator reads and regenerates:\n' + + ` ${IN_TREE_REGENERATE}`, ); } return problems; } +/** Paths under a registry package's `src/` that never reach its build: tests and their mocks. */ +const TEST_SOURCE = /(^|\/)(__tests__|__mocks__)\/|\.(test|spec)\.[cm]?[jt]sx?$/; + +/** + * The in-tree freshness oracle: one sha256 over the registry's SOURCES as git + * has them. Which packages: every `@object-ui/*` workspace package under + * `ui/packages/` that `@object-ui/core` and the generator's `REGISTRY_MODULES` + * reach through `dependencies`, `peerDependencies` or `optionalDependencies` + * naming another member. Which files: each one's `src/`, tests excluded + * (`TEST_SOURCE`). Which bytes: the INDEX — `git ls-files -s` gives the + * path-sorted list and each file's blob id, so the reading is the committed + * content, never a working-tree edit, an untracked file or a build output. + * + * `{ packages, files, sha256 }`, or `{ problem }` naming what could not be + * read — a registry module that is not a package here, an unreadable manifest, + * a failed listing, or an empty one (zero sources is a broken read, not a + * fresh one). + */ +export function registrySourceDigest(root) { + const base = join(root, UI_PACKAGES); + if (!existsSync(base)) return { problem: `${UI_PACKAGES}/ does not exist` }; + const byName = new Map(); + for (const dir of readdirSync(base).sort()) { + const manifestPath = join(base, dir, 'package.json'); + if (!existsSync(manifestPath)) continue; + let manifest; + try { + manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); + } catch (e) { + return { problem: `${UI_PACKAGES}/${dir}/package.json does not parse: ${e.message}` }; + } + if (typeof manifest?.name === 'string') byName.set(manifest.name, { dir: `${UI_PACKAGES}/${dir}`, manifest }); + } + + const seeds = [REGISTRY_CORE, ...REGISTRY_MODULES]; + const missing = seeds.filter((name) => !byName.has(name)); + if (missing.length > 0) return { problem: `registry module(s) ${missing.join(', ')} are not packages under ${UI_PACKAGES}/` }; + const reached = new Set(); + const queue = [...seeds]; + while (queue.length > 0) { + const name = queue.shift(); + if (reached.has(name)) continue; + reached.add(name); + const { manifest } = byName.get(name); + for (const field of ['dependencies', 'peerDependencies', 'optionalDependencies']) { + for (const dep of Object.keys(manifest[field] ?? {})) if (byName.has(dep)) queue.push(dep); + } + } + const packages = [...reached].map((name) => byName.get(name).dir).sort(); + + let listing; + try { + listing = execFileSync('git', ['-C', root, 'ls-files', '-s', '-z', '--', ...packages.map((dir) => `${dir}/src`)], { + // #16644: `-C root` alone names the index read — the self-test hands this + // mkdtemp fixtures, and an inherited GIT_DIR / GIT_INDEX_FILE outranks it. + env: gitFreeEnv(), + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + maxBuffer: 64 * 1024 * 1024, + }); + } catch (e) { + return { problem: `git ls-files over ${packages.length} package src/ trees failed: ${String(e?.stderr || e?.message || e).trim().split('\n')[0]}` }; + } + const entries = listing + .split('\0') + .filter(Boolean) + .map((line) => { + const tab = line.indexOf('\t'); + return { path: line.slice(tab + 1), blob: line.slice(0, tab).split(' ')[1] }; + }) + .filter(({ path }) => !TEST_SOURCE.test(path)); + if (entries.length === 0) { + return { problem: `git lists no registry source under the ${packages.length} package src/ trees — zero sources is a broken read, not a fresh one` }; + } + const hash = createHash('sha256'); + for (const { path, blob } of entries) hash.update(`${blob} ${path}\n`); + return { packages, files: entries.length, sha256: hash.digest('hex') }; +} + // Returned by `selfTest()` only after its verdict is printed. The dispatch // refuses anything else: a `return` that leaves the function above that line // prints nothing and still exits 0 — a self-test that never finished, reported @@ -425,6 +664,15 @@ const SELF_TEST_BATTERIES = Object.freeze({ '--require-objectui turns an unreachable checkout RED': 1, 'a pinned commit with no packages/core/package.json is RED': 1, 'the producer, run under a TMPDIR behind a symlink, writes a tree this gate passes': 1, + 'no pin: an in-tree record over unchanged registry sources passes': 1, + 'no pin and no in-tree console is RED, naming both': 1, + 'no pin: a pin-mode record (no inTree block) is RED with the in-tree command': 1, + 'no pin: a staged registry source edit is RED with the in-tree command': 1, + 'no pin: an edit the index does not hold is not a registry change': 1, + 'no pin: a test file or a package the registry does not reach is not a registry change': 1, + 'no pin: a new workspace edge into the registry is RED': 1, + 'no pin: a hand-edited artefact is RED': 1, + 'no pin: the in-tree producer builds, then writes a tree this gate passes': 1, }); // DELETING an entry silences that battery's floor exactly as effectively as @@ -433,7 +681,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 = 13; +const SELF_TEST_BATTERY_FLOOR = 22; function selfTest() { const mk = (mutate, { pin = 'a'.repeat(40), version = '0.0.0-selftest' } = {}) => { @@ -535,7 +783,7 @@ function selfTest() { } const producerSource = readFileSync(join(DEFAULT_ROOT, PRODUCER), 'utf8'); - copyFileSync(join(DEFAULT_ROOT, PRODUCER), join(root, PRODUCER)); + for (const file of [PRODUCER, ...PRODUCER_IMPORTS]) copyFileSync(join(DEFAULT_ROOT, file), join(root, file)); const parserDist = join(root, 'packages', 'sdui-parser', 'dist'); mkdirSync(parserDist, { recursive: true }); writeFileSync( @@ -576,6 +824,116 @@ function selfTest() { }; const produced = mkProduced(); + /** + * The merged layout (#25712): no pin, an in-tree console, and one package + * per registry name under `ui/packages/`, each with one source — plus + * `@object-ui/types`, which `@object-ui/core` reaches through a dependency + * (so the closure, not only the seed list, is under test), and + * `@object-ui/unrelated`, which nothing reaches. A real git repository: the + * digest reads the INDEX, so a stub would leave exactly that reading + * unexercised. Every git child here runs with `gitFreeEnv()` (#16644). + */ + const UI_FIXTURE_NAMES = [REGISTRY_CORE, ...REGISTRY_MODULES, '@object-ui/types', '@object-ui/unrelated']; + const pkgDir = (name) => join(UI_PACKAGES, name.slice('@object-ui/'.length)); + const gitIn = (root) => (...args) => + execFileSync('git', ['-C', root, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], env: gitFreeEnv() }); + const mkUiSources = (prefix) => { + const root = mkdtempSync(join(tmpdir(), prefix)); + const git = gitIn(root); + git('init', '-q'); + writeFileSync(join(root, '.gitignore'), 'node_modules/\n'); + mkdirSync(join(root, UI_CONSOLE), { recursive: true }); + writeFileSync(join(root, READ_PATHS.console), JSON.stringify({ name: '@object-ui/console', private: true }, null, 2)); + for (const name of UI_FIXTURE_NAMES) { + const dir = join(root, pkgDir(name)); + mkdirSync(join(dir, 'src'), { recursive: true }); + const dependencies = name === REGISTRY_CORE ? { '@object-ui/types': 'workspace:*' } : {}; + writeFileSync(join(dir, 'package.json'), JSON.stringify({ name, version: '0.0.0-selftest', dependencies }, null, 2)); + writeFileSync(join(dir, 'src', 'index.ts'), `export const id = ${JSON.stringify(name)};\n`); + } + git('add', '-A'); + return { root, git }; + }; + const mkInTree = (mutate) => { + const { root, git } = mkUiSources('sdui-manifest-in-tree-'); + const manifest = { components: { flex: { type: 'flex', inputs: [] } } }; + const raw = JSON.stringify(manifest, null, 2); + mkdirSync(join(root, 'scripts'), { recursive: true }); + writeFileSync(join(root, READ_PATHS.artefact), raw); + const digest = registrySourceDigest(root); + writeFileSync( + join(root, READ_PATHS.record), + JSON.stringify( + { + source: RECORD_SOURCE, + sha256: createHash('sha256').update(raw).digest('hex'), + components: 1, + inTree: { + modulesRoot: UI_CONSOLE, + objectuiWorkspaceVersion: '0.0.0-selftest', + registryPackages: digest.packages, + registrySources: digest.files, + registrySourcesSha256: digest.sha256, + }, + }, + null, + 2, + ), + ); + mutate?.(root, git); + return root; + }; + const editRecord = (root, edit) => { + const p = join(root, READ_PATHS.record); + writeFileSync(p, JSON.stringify(edit(JSON.parse(readFileSync(p, 'utf8'))), null, 2)); + }; + + /** + * The PRODUCER's in-tree run (no pin): the real generator, this gate and the + * two helpers they import, copied into the merged-layout fixture beside a + * stub adapter and a stub `node_modules/.bin/turbo`. The stub is what writes + * `@object-ui/core`'s dist into the console, so a producer that skipped its + * build exits 1 on the missing dist and the row reads RED (missing artefact) + * with the producer's stderr above it. + */ + const mkProducedInTree = () => { + const { root } = mkUiSources('sdui-manifest-in-tree-producer-'); + mkdirSync(join(root, 'scripts'), { recursive: true }); + for (const file of [PRODUCER, 'scripts/check-sdui-manifest.mjs', ...PRODUCER_IMPORTS]) { + copyFileSync(join(DEFAULT_ROOT, file), join(root, file)); + } + const parserDist = join(root, 'packages', 'sdui-parser', 'dist'); + mkdirSync(parserDist, { recursive: true }); + writeFileSync( + join(parserDist, 'index.mjs'), + 'export const manifestFromConfigs = (configs) => ({ components: Object.fromEntries(configs.map((c) => [c.type, { type: c.type, inputs: [] }])) });\n', + ); + for (const name of [REGISTRY_CORE, ...REGISTRY_MODULES]) { + const pkg = join(root, UI_CONSOLE, 'node_modules', ...name.split('/')); + mkdirSync(join(pkg, 'dist'), { recursive: true }); + writeFileSync( + join(pkg, 'package.json'), + JSON.stringify({ name, version: '0.0.0-selftest', type: 'module', exports: { '.': { import: './dist/index.js' } } }), + ); + if (name !== REGISTRY_CORE) writeFileSync(join(pkg, 'dist', 'index.js'), 'export {};\n'); + } + const coreDist = `${UI_CONSOLE}/node_modules/${REGISTRY_CORE}/dist`; + mkdirSync(join(root, 'node_modules', '.bin'), { recursive: true }); + writeFileSync( + join(root, 'node_modules', '.bin', 'turbo'), + '#!/bin/sh\n' + + `mkdir -p '${coreDist}' && printf '%s\\n' "export const ComponentRegistry = { getPublicConfigs: () => [{ type: 'selftest_flex' }] };" > '${coreDist}/index.js'\n`, + { mode: 0o755 }, + ); + const run = spawnSync(process.execPath, [join(root, PRODUCER)], { encoding: 'utf8', env: gitFreeEnv() }); + if (run.status !== 0) { + console.error(` the in-tree producer exited ${run.status ?? run.signal}:`); + for (const line of String(run.stderr ?? run.error ?? '').trim().split('\n')) console.error(` ${line}`); + } + return root; + }; + const producedInTree = mkProducedInTree(); + const cases = [ ['green fixture passes', mk(), 0, blind], ['missing artefact is RED', mk((r) => rmSync(join(r, 'sdui.manifest.json'))), 1, blind], @@ -620,6 +978,44 @@ function selfTest() { // with the fixture's own objectui as the oracle, so the version leg cannot // read `unreachable` and pass by not running. ['the producer, run under a TMPDIR behind a symlink, writes a tree this gate passes', produced.root, 0, { objectuiRoot: produced.objectuiRoot, requireObjectui: true }], + + // ── No pin: the in-tree registry (#25712) ────────────────────────────── + // + // The fifth column is text every problem of a RED row must carry between + // them: a red that does not name both missing oracles, or the one command + // that clears it, is a red its reader cannot act on. + ['no pin: an in-tree record over unchanged registry sources passes', mkInTree(), 0, blind], + ['no pin and no in-tree console is RED, naming both', mkInTree((r) => rmSync(join(r, UI_CONSOLE), { recursive: true })), 1, blind, [READ_PATHS.pin, UI_CONSOLE]], + // The tree the pin deletion lands on before it regenerates: the record is + // the pin producer's, whole and intact, and still not this mode's. + ['no pin: a pin-mode record (no inTree block) is RED with the in-tree command', mkInTree((r) => editRecord(r, ({ inTree, ...rest }) => ({ + objectuiSha: 'a'.repeat(40), + ...rest, + modulesRoot: `.cache/objectui-${'a'.repeat(12)}/apps/console`, + objectuiWorkspaceVersion: '0.0.0-selftest', + }))), 1, blind, ['`inTree`', IN_TREE_REGENERATE]], + ['no pin: a staged registry source edit is RED with the in-tree command', mkInTree((r, git) => { + writeFileSync(join(r, pkgDir('@object-ui/plugin-grid'), 'src', 'index.ts'), 'export const id = "moved";\n'); + git('add', '-A'); + }), 1, blind, [IN_TREE_REGENERATE]], + ['no pin: an edit the index does not hold is not a registry change', mkInTree((r) => { + writeFileSync(join(r, pkgDir('@object-ui/plugin-grid'), 'src', 'index.ts'), 'export const id = "unstaged";\n'); + writeFileSync(join(r, pkgDir('@object-ui/plugin-grid'), 'src', 'untracked.ts'), 'export {};\n'); + }), 0, blind], + ['no pin: a test file or a package the registry does not reach is not a registry change', mkInTree((r, git) => { + mkdirSync(join(r, pkgDir('@object-ui/plugin-grid'), 'src', '__tests__'), { recursive: true }); + writeFileSync(join(r, pkgDir('@object-ui/plugin-grid'), 'src', '__tests__', 'grid.ts'), 'export {};\n'); + writeFileSync(join(r, pkgDir('@object-ui/types'), 'src', 'types.spec.tsx'), 'export {};\n'); + writeFileSync(join(r, pkgDir('@object-ui/unrelated'), 'src', 'more.ts'), 'export {};\n'); + git('add', '-A'); + }), 0, blind], + ['no pin: a new workspace edge into the registry is RED', mkInTree((r) => { + const p = join(r, pkgDir('@object-ui/plugin-grid'), 'package.json'); + const manifest = JSON.parse(readFileSync(p, 'utf8')); + writeFileSync(p, JSON.stringify({ ...manifest, peerDependencies: { '@object-ui/unrelated': 'workspace:*' } }, null, 2)); + }), 1, blind, [IN_TREE_REGENERATE]], + ['no pin: a hand-edited artefact is RED', mkInTree((r) => writeFileSync(join(r, READ_PATHS.artefact), '{"components":{"flex":{"type":"flex"}}}')), 1, blind, [IN_TREE_REGENERATE]], + ['no pin: the in-tree producer builds, then writes a tree this gate passes', producedInTree, 0, blind], ]; // The ledger this self-test's floor is evaluated against (#13489). @@ -629,13 +1025,18 @@ function selfTest() { }; let failures = 0; - for (const [name, root, want, options] of cases) { + for (const [name, root, want, options, mustName = []] of cases) { registerCase(name); const problems = checkTree(root, options); const got = problems.length ? 1 : 0; - if (got !== want) { + const unnamed = got ? mustName.filter((text) => !problems.join('\n').includes(text)) : []; + if (got !== want || unnamed.length > 0) { failures++; - console.error(`✗ self-test: ${name} — expected ${want ? 'RED' : 'GREEN'}, got ${got ? 'RED' : 'GREEN'}`); + console.error( + got !== want + ? `✗ self-test: ${name} — expected ${want ? 'RED' : 'GREEN'}, got ${got ? 'RED' : 'GREEN'}` + : `✗ self-test: ${name} — RED, but no problem names ${unnamed.map((text) => JSON.stringify(text)).join(', ')}`, + ); for (const p of problems) console.error(` ${p}`); } } @@ -658,6 +1059,15 @@ function selfTest() { declFail('a repo-ROOT file is declared without the subtree suffix — a bare single-segment literal is ' + 'refused by hintCovers as too generic, so it would contribute no hint at all.'); } + // The digest is only a reading of what the producer enumerates while the two + // registry lists are one list. The producer refuses a mismatch on its own + // in-tree run; this holds it on every run, with or without a pin. + const producerList = /const REGISTRY_MODULES = \[([^\]]*)\];/.exec(readFileSync(join(DEFAULT_ROOT, PRODUCER), 'utf8')); + const producerModules = producerList ? [...producerList[1].matchAll(/'(@object-ui\/[a-z0-9-]+)'/g)].map((m) => m[1]) : []; + if (JSON.stringify(producerModules) !== JSON.stringify(REGISTRY_MODULES)) { + declFail(`REGISTRY_MODULES here and in ${PRODUCER} disagree (or its list is unreadable) — the in-tree digest ` + + 'would describe a different registry than the one the producer enumerates.'); + } // ── The floor: every declared row RAN, and ran its case (#13489) ─────── // @@ -722,7 +1132,9 @@ function selfTest() { console.log( `✓ 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; ' + - 'the producer, run under a symlinked TMPDIR, writes a tree that passes).', + 'the producer, run under a symlinked TMPDIR, writes a tree that passes; with no pin, the in-tree registry digest reds a staged ' + + 'source edit or a new workspace edge and ignores tests, unreached packages and unstaged bytes, no pin and no console is RED ' + + 'naming both, and the in-tree producer builds, then writes a tree that passes).', ); return SELF_TEST_VERDICT; @@ -750,24 +1162,45 @@ if (isEntrypoint(import.meta.url)) { process.exit(1); } const record = JSON.parse(readFileSync(join(DEFAULT_ROOT, 'scripts', 'sdui-manifest.record.json'), 'utf8')); - console.log( - `✓ check-sdui-manifest: sdui.manifest.json is present, intact (sha256 ${String(record.sha256).slice(0, 12)}…, ` + - `${record.components} components) and recorded at the live objectui pin ${String(record.objectuiSha).slice(0, 12)}….`, - ); - // The claim is only ever as wide as the leg that ran (#18611): a run without - // an objectui checkout says so, in the same breath, rather than letting the - // line above be read as "fresh at the pin" in every sense. - if (versionLeg?.status === 'resolved') { + if (!existsSync(join(DEFAULT_ROOT, READ_PATHS.pin))) { + // No pin: checkTree ran the in-tree leg, which has no check 4 to report. + const { inTree } = record; + let declared = 'unreadable'; + try { + declared = JSON.stringify(JSON.parse(readFileSync(join(DEFAULT_ROOT, UI_PACKAGES, 'core', 'package.json'), 'utf8')).version); + } catch { + // display only — the verdict above did not read it + } + console.log( + `✓ check-sdui-manifest: sdui.manifest.json is present, intact (sha256 ${String(record.sha256).slice(0, 12)}…, ` + + `${record.components} components) and generated from the in-tree registry as it stands: ${inTree.registrySources} ` + + `source(s) in ${inTree.registryPackages.length} package(s) under ${UI_PACKAGES}/, sha256 ${String(inTree.registrySourcesSha256).slice(0, 12)}… ` + + '(no .objectui-sha in this tree).', + ); console.log( - ` @object-ui ${record.objectuiWorkspaceVersion} is the version objectui ${String(record.objectuiSha).slice(0, 12)}… declares ` + - `in ${OBJECTUI_VERSION_FILE} (read from ${versionLeg.where}).`, + ` Provenance, not a verdict: the generator read @object-ui/core ${JSON.stringify(inTree.objectuiWorkspaceVersion)}; ` + + `${UI_PACKAGES}/core declares ${declared} now — a release of the fixed group moves that without moving the registry.`, ); } else { console.log( - ` 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.', + `✓ check-sdui-manifest: sdui.manifest.json is present, intact (sha256 ${String(record.sha256).slice(0, 12)}…, ` + + `${record.components} components) and recorded at the live objectui pin ${String(record.objectuiSha).slice(0, 12)}….`, ); + // The claim is only ever as wide as the leg that ran (#18611): a run without + // an objectui checkout says so, in the same breath, rather than letting the + // line above be read as "fresh at the pin" in every sense. + if (versionLeg?.status === 'resolved') { + console.log( + ` @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 — 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 2df8285f06a..74396d04089 100644 --- a/scripts/gen-sdui-manifest-node.mjs +++ b/scripts/gen-sdui-manifest-node.mjs @@ -9,6 +9,8 @@ * pnpm objectui:build # build objectui at .objectui-sha into .cache/objectui-/ * node scripts/gen-sdui-manifest-node.mjs * + * node scripts/gen-sdui-manifest-node.mjs # no .objectui-sha: builds the in-tree registry, then regenerates + * * 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 @@ -63,15 +65,30 @@ * * The runner this spawns is written to a fresh temp dir and removed afterwards: * nothing is written into the objectui build tree. + * + * ## With no pin: the in-tree console (#25712) + * + * Once objectui's tree lives under `ui/` and `.objectui-sha` is gone (#25677), + * the modules root is `ui/apps/console` and the registry is this tree's own. + * The run is then one command with no prior step: it builds the registry + * closure with turbo (`buildInTree`, a cache hit when nothing moved), refusing + * registry sources the index does not hold, then enumerates exactly as above. + * The record gains an `inTree` block — the modules root, the version read off + * the build (provenance only), and the registry source digest + * `scripts/check-sdui-manifest.mjs` recomputes — and keeps the pin-keyed + * fields only while the artefact is byte-identical to the one they describe. + * With the pin present, nothing here runs differently. */ -import { execFileSync } from 'node:child_process'; +import { execFileSync, spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { pathToFileURL, fileURLToPath } from 'node:url'; +import { gitFreeEnv } from './git-env.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'); @@ -105,6 +122,106 @@ function fail(msg) { process.exit(1); } +/** The in-tree console this reads when the tree has no pin (#25712). */ +const UI_CONSOLE = 'ui/apps/console'; + +/** + * The record's `//` note for an in-tree run. A pinned run carries whatever note + * the record already has, exactly as before; that note names the pin's build + * route, which is false the moment there is no pin, so an in-tree run writes + * this one. + */ +const IN_TREE_NOTE = [ + 'Provenance record for the repo-root `sdui.manifest.json` (ADR-0080; #12924 wiring ruling 2026-08-29).', + "The artefact is the console's public-tier component manifest, generated from THIS tree's registry by", + 'scripts/gen-sdui-manifest-node.mjs, the one producer (ruling 丙 on #17735): it builds the registry packages under', + 'ui/packages/, then enumerates them from `inTree.modulesRoot` exactly as ui/apps/console/dev/manifest-dump.tsx', + 'does: eager-import the 16 registry modules, ComponentRegistry.getPublicConfigs(), manifestFromConfigs from', + "@objectstack/sdui-parser. `inTree.registrySourcesSha256` digests those packages' src/ as the index holds them;", + '`scripts/check-sdui-manifest.mjs` recomputes it, so a registry source edit without regeneration goes RED there.', + '`inTree.objectuiWorkspaceVersion` is provenance, not a verdict (#25712). Pin-keyed fields, where present, say the', + 'artefact is byte-identical to the one generated at that pin.', + 'Regenerate + re-record: node scripts/gen-sdui-manifest-node.mjs', +]; + +/** Lines of `git status --porcelain` under `dirs` that the INDEX does not hold: unstaged edits and untracked files. */ +function unindexed(dirs) { + const status = execFileSync('git', ['-C', ROOT, 'status', '--porcelain', '--untracked-files=all', '--', ...dirs], { + // #16644: `-C ROOT` alone names the repository — the gate's self-test runs + // this file inside a mkdtemp fixture, where an inherited GIT_DIR outranks it. + env: gitFreeEnv(), + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + maxBuffer: 64 * 1024 * 1024, + }); + return status.split('\n').filter((line) => line.length > 2 && line[1] !== ' '); +} + +/** + * No pin — the merged layout (#25712): build the registry's packages from this + * tree's own sources, then read that build. Returns the `inTree` block's + * reading of the sources the build was made from. + * + * The build is THIS script's step here, not the caller's: the record names the + * sources by digest (`registrySourceDigest` in scripts/check-sdui-manifest.mjs, + * which the gate recomputes), and only a build of exactly those sources makes + * the artefact describe them — a run over a stale dist would stamp a fresh + * digest on a stale manifest. Turbo's input hashing makes an up-to-date build a + * cache hit. Refused before anything is built: no in-tree console, a registry + * list that disagrees with the gate's, unreadable sources, or bytes under them + * the index does not hold (the digest reads the index, the build the working + * tree). Refused after: a build that moved them. + */ +async function buildInTree() { + if (!existsSync(join(ROOT, ...UI_CONSOLE.split('/'), 'package.json'))) { + fail( + `.objectui-sha is missing and there is no in-tree console (${UI_CONSOLE}) either — nothing to generate from.\n` + + ' With the pin this reads objectui\'s build at that commit; without it, the console under ui/.', + ); + } + const gate = await import(new URL('./check-sdui-manifest.mjs', import.meta.url).href); + if (JSON.stringify(gate.REGISTRY_MODULES) !== JSON.stringify(REGISTRY_MODULES)) { + fail( + 'REGISTRY_MODULES here and in scripts/check-sdui-manifest.mjs disagree, so the digest the gate recomputes\n' + + ' would describe a different registry than the one enumerated here. Make the two lists equal.', + ); + } + const before = gate.registrySourceDigest(ROOT); + if (before.problem) fail(`cannot digest the registry sources: ${before.problem}`); + const sources = before.packages.map((dir) => `${dir}/src`); + const notIndexed = unindexed(sources); + if (notIndexed.length > 0) { + fail( + 'the registry sources carry bytes the index does not hold, so the build would read what the digest does not name:\n' + + notIndexed.slice(0, 20).map((line) => ` ${line}`).join('\n') + + (notIndexed.length > 20 ? `\n … and ${notIndexed.length - 20} more` : '') + + '\n Stage or discard them, then re-run.', + ); + } + + const turbo = join(ROOT, 'node_modules', '.bin', 'turbo'); + if (!existsSync(turbo)) fail('no turbo at node_modules/.bin/turbo — run `pnpm install` first.'); + const filters = ['@object-ui/core', ...REGISTRY_MODULES].map((name) => `--filter=${name}...`); + console.error( + `→ building the registry from this tree: ${before.packages.length} package(s), ${before.files} source(s), ` + + `sha256 ${before.sha256.slice(0, 12)}… (turbo — a cache hit when nothing moved)`, + ); + const build = spawnSync(turbo, ['run', 'build', ...filters, '--filter=@objectstack/sdui-parser', '--output-logs=errors-only'], { + cwd: ROOT, + stdio: ['ignore', 2, 2], + }); + if (build.status !== 0) fail(`the in-tree build exited ${build.status ?? build.signal}; its output is above.`); + + const after = gate.registrySourceDigest(ROOT); + if (after.problem || after.sha256 !== before.sha256 || unindexed(sources).length > 0) { + fail('the build moved the registry sources it was meant to read (their digest or their untracked files changed). Nothing was written.'); + } + if (!existsSync(join(ROOT, ...UI_CONSOLE.split('/'), 'node_modules', '@object-ui', 'core', 'dist', 'index.js'))) { + fail(`${UI_CONSOLE}/node_modules/@object-ui/core/dist/index.js is missing after the build, so ${UI_CONSOLE} does not resolve the registry the build made.`); + } + return { registryPackages: before.packages, registrySources: before.files, registrySourcesSha256: before.sha256 }; +} + if (process.argv.length > 2) { fail( `takes no arguments (got: ${process.argv.slice(2).join(' ')}).\n` + @@ -114,44 +231,53 @@ if (process.argv.length > 2) { ); } -if (!existsSync(PARSER_DIST)) { - fail( - `packages/sdui-parser/dist is missing — the adapter is consumed as production consumes it.\n` + - ` Run: pnpm --filter @objectstack/sdui-parser build`, - ); -} +const PARSER_DIST_MISSING = + `packages/sdui-parser/dist is missing — the adapter is consumed as production consumes it.\n` + + ` Run: pnpm --filter @objectstack/sdui-parser build`; const pinPath = join(ROOT, '.objectui-sha'); -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)}).`); +const pinned = existsSync(pinPath); -// 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.'; +let pin = null; +let MODULES_ROOT_REL; +let inTree = null; +if (pinned) { + if (!existsSync(PARSER_DIST)) fail(PARSER_DIST_MISSING); -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}`, - ); + 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)}`; + MODULES_ROOT_REL = `${BUILD_TREE_REL}/apps/console`; + const BUILD_TREE = join(ROOT, ...BUILD_TREE_REL.split('/')); + const BUILT_SENTINEL = join(ROOT, ...MODULES_ROOT_REL.split('/'), '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}`, + ); + } +} else { + inTree = await buildInTree(); + if (!existsSync(PARSER_DIST)) fail(PARSER_DIST_MISSING); + MODULES_ROOT_REL = UI_CONSOLE; } +const MODULES_ROOT = join(ROOT, ...MODULES_ROOT_REL.split('/')); +const CORE_PKG = join(MODULES_ROOT, 'node_modules', '@object-ui', 'core', 'package.json'); let workspaceVersion; try { @@ -243,20 +369,36 @@ const manifest = JSON.parse(json); const count = Object.keys(manifest.components).length; writeFileSync(OUT_PATH, json); const sha256 = createHash('sha256').update(json).digest('hex'); +// Pinned, the record is exactly what it has always been. In-tree, the pin-keyed +// fields describe an ARTEFACT, so they are carried only while the regenerated +// one is byte-identical to the one they were written for (the same sha256) — +// then they still say something true about these bytes — and dropped the +// moment it is not. `undefined` keys are left out of the JSON. +const carried = !pinned && record.sha256 === sha256 ? record : {}; const nextRecord = { - '//': record['//'] ?? [], - objectuiSha: pin, + '//': pinned ? record['//'] ?? [] : IN_TREE_NOTE, + objectuiSha: pinned ? pin : carried.objectuiSha, source: SOURCE, - modulesRoot: MODULES_ROOT_REL, - objectuiWorkspaceVersion: workspaceVersion, + modulesRoot: pinned ? MODULES_ROOT_REL : carried.modulesRoot, + objectuiWorkspaceVersion: pinned ? workspaceVersion : carried.objectuiWorkspaceVersion, generator: 'scripts/gen-sdui-manifest-node.mjs', generatedAt: new Date().toISOString().slice(0, 10), sha256, components: count, + inTree: pinned ? undefined : { modulesRoot: MODULES_ROOT_REL, objectuiWorkspaceVersion: workspaceVersion, ...inTree }, }; 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)} — ${SOURCE} ${MODULES_ROOT_REL}, ` + - `@object-ui/core workspace version ${workspaceVersion}`, -); +if (pinned) { + console.error( + `✓ re-recorded scripts/sdui-manifest.record.json at pin ${pin.slice(0, 12)} — ${SOURCE} ${MODULES_ROOT_REL}, ` + + `@object-ui/core workspace version ${workspaceVersion}`, + ); +} else { + console.error( + `✓ re-recorded scripts/sdui-manifest.record.json in-tree — ${SOURCE} ${MODULES_ROOT_REL}, registry sources ` + + `${inTree.registrySources} file(s) in ${inTree.registryPackages.length} package(s), sha256 ` + + `${inTree.registrySourcesSha256.slice(0, 12)}…, @object-ui/core workspace version ${workspaceVersion}; ` + + (carried.objectuiSha ? `pin fields kept (artefact unchanged since pin ${carried.objectuiSha.slice(0, 12)})` : 'no pin fields'), + ); +}