5555 * This is the only check here that needs an input from outside this tree,
5656 * so it is the only one that can report NOT CHECKED — see below.
5757 *
58+ * ## With no pin: the in-tree registry (#25712)
59+ *
60+ * Once objectui's tree lives under `ui/` and `.objectui-sha` is gone (#25677),
61+ * there is no pin to build and no checkout to ask, so the oracle is the
62+ * registry in this tree. The mode is decided by `.objectui-sha` alone: with it
63+ * present, nothing above reads differently. Without it:
64+ *
65+ * 1–2. unchanged — the same shape and tamper checks, against a record keyed
66+ * to the in-tree producer (`IN_TREE_RECORD_FIELDS`: the pin-keyed
67+ * fields are not required, the `inTree` block is).
68+ * 3. STALENESS is a SOURCE DIGEST — `registrySourceDigest` over the
69+ * registry packages' `src/` as the index holds them must equal the
70+ * record's `inTree` reading. A registry source edit without
71+ * regeneration reds HERE, and the red prints the one command that
72+ * clears it: the generator, which builds the registry from this tree
73+ * and regenerates. Nothing is built in this gate — the digest is a
74+ * `git ls-files` over the index, with no dist and no node_modules read.
75+ * 4. none. The in-tree `@object-ui/core` version moves with every release
76+ * of the one fixed version group while the registry does not, so the
77+ * record carries it as provenance and the success line prints it.
78+ *
79+ * No pin AND no in-tree console is RED, naming both: a tree with neither has
80+ * nothing to be fresh against, which is a broken tree, not a skip.
81+ *
5882 * ## What this deliberately does NOT do, and where that risk is held
5983 *
6084 * No per-PR regeneration: that would put an objectui clone and build (network,
@@ -120,6 +144,7 @@ import { tmpdir } from 'node:os';
120144import { dirname , join , sep } from 'node:path' ;
121145import { fileURLToPath } from 'node:url' ;
122146
147+ import { gitFreeEnv } from './git-env.mjs' ;
123148import { isEntrypoint } from './invoked-as.mjs' ;
124149
125150const DEFAULT_ROOT = join ( dirname ( fileURLToPath ( import . meta. url ) ) , '..' ) ;
@@ -213,10 +238,13 @@ export const REGISTRY_MODULES = Object.freeze([
213238 */
214239const PRODUCER = 'scripts/gen-sdui-manifest-node.mjs' ;
215240
241+ /** The repo helpers the producer (and this gate, which it imports in-tree) import: copied beside it in the self-test's fixtures. */
242+ const PRODUCER_IMPORTS = [ 'scripts/git-env.mjs' , 'scripts/invoked-as.mjs' ] ;
243+
216244/**
217245 * The population this gate reads, declared for `scripts/pm/dispatch-gates.mjs`.
218246 *
219- * `checkTree` opens exactly three files. None of them was visible to the
247+ * With a pin, `checkTree` opens exactly three files. None of them was visible to the
220248 * derivation: it reads SOURCE TEXT, and a literal with no separator is refused
221249 * by `hintCovers` as too generic (`sdui.manifest.json`), while `.objectui-sha`
222250 * is not one of the dot-prefixed names the extractor admits and the record path
@@ -233,6 +261,10 @@ const PRODUCER = 'scripts/gen-sdui-manifest-node.mjs';
233261 * to nothing else.
234262 *
235263 * ⛔ Not a whole-tree marker, and not `scripts/**`: three files are three files.
264+ * Without a pin it also reads the in-tree console's manifest and the registry
265+ * packages under `ui/packages/` (#25712) — the subtree the digest walks, so a
266+ * UI package edit derives this gate; on a tree with no `ui/` both hints match
267+ * nothing and only display.
236268 *
237269 * The self-test derives the coupling from `READ_PATHS` — the same object
238270 * `checkTree` reads — so a moved or added read reds here rather than leaving the
@@ -560,6 +592,9 @@ export function registrySourceDigest(root) {
560592 let listing ;
561593 try {
562594 listing = execFileSync ( 'git' , [ '-C' , root , 'ls-files' , '-s' , '-z' , '--' , ...packages . map ( ( dir ) => `${ dir } /src` ) ] , {
595+ // #16644: `-C root` alone names the index read — the self-test hands this
596+ // mkdtemp fixtures, and an inherited GIT_DIR / GIT_INDEX_FILE outranks it.
597+ env : gitFreeEnv ( ) ,
563598 encoding : 'utf8' ,
564599 stdio : [ 'ignore' , 'pipe' , 'pipe' ] ,
565600 maxBuffer : 64 * 1024 * 1024 ,
@@ -629,6 +664,15 @@ const SELF_TEST_BATTERIES = Object.freeze({
629664 '--require-objectui turns an unreachable checkout RED' : 1 ,
630665 'a pinned commit with no packages/core/package.json is RED' : 1 ,
631666 'the producer, run under a TMPDIR behind a symlink, writes a tree this gate passes' : 1 ,
667+ 'no pin: an in-tree record over unchanged registry sources passes' : 1 ,
668+ 'no pin and no in-tree console is RED, naming both' : 1 ,
669+ 'no pin: a pin-mode record (no inTree block) is RED with the in-tree command' : 1 ,
670+ 'no pin: a staged registry source edit is RED with the in-tree command' : 1 ,
671+ 'no pin: an edit the index does not hold is not a registry change' : 1 ,
672+ 'no pin: a test file or a package the registry does not reach is not a registry change' : 1 ,
673+ 'no pin: a new workspace edge into the registry is RED' : 1 ,
674+ 'no pin: a hand-edited artefact is RED' : 1 ,
675+ 'no pin: the in-tree producer builds, then writes a tree this gate passes' : 1 ,
632676} ) ;
633677
634678// DELETING an entry silences that battery's floor exactly as effectively as
@@ -637,7 +681,7 @@ const SELF_TEST_BATTERIES = Object.freeze({
637681// the literal above, so the roster falls below this number; the table
638682// cross-check in the floor block is the other half, and names WHICH label
639683// collided.
640- const SELF_TEST_BATTERY_FLOOR = 13 ;
684+ const SELF_TEST_BATTERY_FLOOR = 22 ;
641685
642686function selfTest ( ) {
643687 const mk = ( mutate , { pin = 'a' . repeat ( 40 ) , version = '0.0.0-selftest' } = { } ) => {
@@ -739,7 +783,7 @@ function selfTest() {
739783 }
740784
741785 const producerSource = readFileSync ( join ( DEFAULT_ROOT , PRODUCER ) , 'utf8' ) ;
742- copyFileSync ( join ( DEFAULT_ROOT , PRODUCER ) , join ( root , PRODUCER ) ) ;
786+ for ( const file of [ PRODUCER , ... PRODUCER_IMPORTS ] ) copyFileSync ( join ( DEFAULT_ROOT , file ) , join ( root , file ) ) ;
743787 const parserDist = join ( root , 'packages' , 'sdui-parser' , 'dist' ) ;
744788 mkdirSync ( parserDist , { recursive : true } ) ;
745789 writeFileSync (
@@ -780,6 +824,116 @@ function selfTest() {
780824 } ;
781825 const produced = mkProduced ( ) ;
782826
827+ /**
828+ * The merged layout (#25712): no pin, an in-tree console, and one package
829+ * per registry name under `ui/packages/`, each with one source — plus
830+ * `@object-ui/types`, which `@object-ui/core` reaches through a dependency
831+ * (so the closure, not only the seed list, is under test), and
832+ * `@object-ui/unrelated`, which nothing reaches. A real git repository: the
833+ * digest reads the INDEX, so a stub would leave exactly that reading
834+ * unexercised. Every git child here runs with `gitFreeEnv()` (#16644).
835+ */
836+ const UI_FIXTURE_NAMES = [ REGISTRY_CORE , ...REGISTRY_MODULES , '@object-ui/types' , '@object-ui/unrelated' ] ;
837+ const pkgDir = ( name ) => join ( UI_PACKAGES , name . slice ( '@object-ui/' . length ) ) ;
838+ const gitIn = ( root ) => ( ...args ) =>
839+ execFileSync ( 'git' , [ '-C' , root , ...args ] , { encoding : 'utf8' , stdio : [ 'ignore' , 'pipe' , 'pipe' ] , env : gitFreeEnv ( ) } ) ;
840+ const mkUiSources = ( prefix ) => {
841+ const root = mkdtempSync ( join ( tmpdir ( ) , prefix ) ) ;
842+ const git = gitIn ( root ) ;
843+ git ( 'init' , '-q' ) ;
844+ writeFileSync ( join ( root , '.gitignore' ) , 'node_modules/\n' ) ;
845+ mkdirSync ( join ( root , UI_CONSOLE ) , { recursive : true } ) ;
846+ writeFileSync ( join ( root , READ_PATHS . console ) , JSON . stringify ( { name : '@object-ui/console' , private : true } , null , 2 ) ) ;
847+ for ( const name of UI_FIXTURE_NAMES ) {
848+ const dir = join ( root , pkgDir ( name ) ) ;
849+ mkdirSync ( join ( dir , 'src' ) , { recursive : true } ) ;
850+ const dependencies = name === REGISTRY_CORE ? { '@object-ui/types' : 'workspace:*' } : { } ;
851+ writeFileSync ( join ( dir , 'package.json' ) , JSON . stringify ( { name, version : '0.0.0-selftest' , dependencies } , null , 2 ) ) ;
852+ writeFileSync ( join ( dir , 'src' , 'index.ts' ) , `export const id = ${ JSON . stringify ( name ) } ;\n` ) ;
853+ }
854+ git ( 'add' , '-A' ) ;
855+ return { root, git } ;
856+ } ;
857+ const mkInTree = ( mutate ) => {
858+ const { root, git } = mkUiSources ( 'sdui-manifest-in-tree-' ) ;
859+ const manifest = { components : { flex : { type : 'flex' , inputs : [ ] } } } ;
860+ const raw = JSON . stringify ( manifest , null , 2 ) ;
861+ mkdirSync ( join ( root , 'scripts' ) , { recursive : true } ) ;
862+ writeFileSync ( join ( root , READ_PATHS . artefact ) , raw ) ;
863+ const digest = registrySourceDigest ( root ) ;
864+ writeFileSync (
865+ join ( root , READ_PATHS . record ) ,
866+ JSON . stringify (
867+ {
868+ source : RECORD_SOURCE ,
869+ sha256 : createHash ( 'sha256' ) . update ( raw ) . digest ( 'hex' ) ,
870+ components : 1 ,
871+ inTree : {
872+ modulesRoot : UI_CONSOLE ,
873+ objectuiWorkspaceVersion : '0.0.0-selftest' ,
874+ registryPackages : digest . packages ,
875+ registrySources : digest . files ,
876+ registrySourcesSha256 : digest . sha256 ,
877+ } ,
878+ } ,
879+ null ,
880+ 2 ,
881+ ) ,
882+ ) ;
883+ mutate ?. ( root , git ) ;
884+ return root ;
885+ } ;
886+ const editRecord = ( root , edit ) => {
887+ const p = join ( root , READ_PATHS . record ) ;
888+ writeFileSync ( p , JSON . stringify ( edit ( JSON . parse ( readFileSync ( p , 'utf8' ) ) ) , null , 2 ) ) ;
889+ } ;
890+
891+ /**
892+ * The PRODUCER's in-tree run (no pin): the real generator, this gate and the
893+ * two helpers they import, copied into the merged-layout fixture beside a
894+ * stub adapter and a stub `node_modules/.bin/turbo`. The stub is what writes
895+ * `@object-ui/core`'s dist into the console, so a producer that skipped its
896+ * build exits 1 on the missing dist and the row reads RED (missing artefact)
897+ * with the producer's stderr above it.
898+ */
899+ const mkProducedInTree = ( ) => {
900+ const { root } = mkUiSources ( 'sdui-manifest-in-tree-producer-' ) ;
901+ mkdirSync ( join ( root , 'scripts' ) , { recursive : true } ) ;
902+ for ( const file of [ PRODUCER , 'scripts/check-sdui-manifest.mjs' , ...PRODUCER_IMPORTS ] ) {
903+ copyFileSync ( join ( DEFAULT_ROOT , file ) , join ( root , file ) ) ;
904+ }
905+ const parserDist = join ( root , 'packages' , 'sdui-parser' , 'dist' ) ;
906+ mkdirSync ( parserDist , { recursive : true } ) ;
907+ writeFileSync (
908+ join ( parserDist , 'index.mjs' ) ,
909+ 'export const manifestFromConfigs = (configs) => ({ components: Object.fromEntries(configs.map((c) => [c.type, { type: c.type, inputs: [] }])) });\n' ,
910+ ) ;
911+ for ( const name of [ REGISTRY_CORE , ...REGISTRY_MODULES ] ) {
912+ const pkg = join ( root , UI_CONSOLE , 'node_modules' , ...name . split ( '/' ) ) ;
913+ mkdirSync ( join ( pkg , 'dist' ) , { recursive : true } ) ;
914+ writeFileSync (
915+ join ( pkg , 'package.json' ) ,
916+ JSON . stringify ( { name, version : '0.0.0-selftest' , type : 'module' , exports : { '.' : { import : './dist/index.js' } } } ) ,
917+ ) ;
918+ if ( name !== REGISTRY_CORE ) writeFileSync ( join ( pkg , 'dist' , 'index.js' ) , 'export {};\n' ) ;
919+ }
920+ const coreDist = `${ UI_CONSOLE } /node_modules/${ REGISTRY_CORE } /dist` ;
921+ mkdirSync ( join ( root , 'node_modules' , '.bin' ) , { recursive : true } ) ;
922+ writeFileSync (
923+ join ( root , 'node_modules' , '.bin' , 'turbo' ) ,
924+ '#!/bin/sh\n' +
925+ `mkdir -p '${ coreDist } ' && printf '%s\\n' "export const ComponentRegistry = { getPublicConfigs: () => [{ type: 'selftest_flex' }] };" > '${ coreDist } /index.js'\n` ,
926+ { mode : 0o755 } ,
927+ ) ;
928+ const run = spawnSync ( process . execPath , [ join ( root , PRODUCER ) ] , { encoding : 'utf8' , env : gitFreeEnv ( ) } ) ;
929+ if ( run . status !== 0 ) {
930+ console . error ( ` the in-tree producer exited ${ run . status ?? run . signal } :` ) ;
931+ for ( const line of String ( run . stderr ?? run . error ?? '' ) . trim ( ) . split ( '\n' ) ) console . error ( ` ${ line } ` ) ;
932+ }
933+ return root ;
934+ } ;
935+ const producedInTree = mkProducedInTree ( ) ;
936+
783937 const cases = [
784938 [ 'green fixture passes' , mk ( ) , 0 , blind ] ,
785939 [ 'missing artefact is RED' , mk ( ( r ) => rmSync ( join ( r , 'sdui.manifest.json' ) ) ) , 1 , blind ] ,
@@ -824,6 +978,44 @@ function selfTest() {
824978 // with the fixture's own objectui as the oracle, so the version leg cannot
825979 // read `unreachable` and pass by not running.
826980 [ 'the producer, run under a TMPDIR behind a symlink, writes a tree this gate passes' , produced . root , 0 , { objectuiRoot : produced . objectuiRoot , requireObjectui : true } ] ,
981+
982+ // ── No pin: the in-tree registry (#25712) ──────────────────────────────
983+ //
984+ // The fifth column is text every problem of a RED row must carry between
985+ // them: a red that does not name both missing oracles, or the one command
986+ // that clears it, is a red its reader cannot act on.
987+ [ 'no pin: an in-tree record over unchanged registry sources passes' , mkInTree ( ) , 0 , blind ] ,
988+ [ '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 ] ] ,
989+ // The tree the pin deletion lands on before it regenerates: the record is
990+ // the pin producer's, whole and intact, and still not this mode's.
991+ [ 'no pin: a pin-mode record (no inTree block) is RED with the in-tree command' , mkInTree ( ( r ) => editRecord ( r , ( { inTree, ...rest } ) => ( {
992+ objectuiSha : 'a' . repeat ( 40 ) ,
993+ ...rest ,
994+ modulesRoot : `.cache/objectui-${ 'a' . repeat ( 12 ) } /apps/console` ,
995+ objectuiWorkspaceVersion : '0.0.0-selftest' ,
996+ } ) ) ) , 1 , blind , [ '`inTree`' , IN_TREE_REGENERATE ] ] ,
997+ [ 'no pin: a staged registry source edit is RED with the in-tree command' , mkInTree ( ( r , git ) => {
998+ writeFileSync ( join ( r , pkgDir ( '@object-ui/plugin-grid' ) , 'src' , 'index.ts' ) , 'export const id = "moved";\n' ) ;
999+ git ( 'add' , '-A' ) ;
1000+ } ) , 1 , blind , [ IN_TREE_REGENERATE ] ] ,
1001+ [ 'no pin: an edit the index does not hold is not a registry change' , mkInTree ( ( r ) => {
1002+ writeFileSync ( join ( r , pkgDir ( '@object-ui/plugin-grid' ) , 'src' , 'index.ts' ) , 'export const id = "unstaged";\n' ) ;
1003+ writeFileSync ( join ( r , pkgDir ( '@object-ui/plugin-grid' ) , 'src' , 'untracked.ts' ) , 'export {};\n' ) ;
1004+ } ) , 0 , blind ] ,
1005+ [ 'no pin: a test file or a package the registry does not reach is not a registry change' , mkInTree ( ( r , git ) => {
1006+ mkdirSync ( join ( r , pkgDir ( '@object-ui/plugin-grid' ) , 'src' , '__tests__' ) , { recursive : true } ) ;
1007+ writeFileSync ( join ( r , pkgDir ( '@object-ui/plugin-grid' ) , 'src' , '__tests__' , 'grid.ts' ) , 'export {};\n' ) ;
1008+ writeFileSync ( join ( r , pkgDir ( '@object-ui/types' ) , 'src' , 'types.spec.tsx' ) , 'export {};\n' ) ;
1009+ writeFileSync ( join ( r , pkgDir ( '@object-ui/unrelated' ) , 'src' , 'more.ts' ) , 'export {};\n' ) ;
1010+ git ( 'add' , '-A' ) ;
1011+ } ) , 0 , blind ] ,
1012+ [ 'no pin: a new workspace edge into the registry is RED' , mkInTree ( ( r ) => {
1013+ const p = join ( r , pkgDir ( '@object-ui/plugin-grid' ) , 'package.json' ) ;
1014+ const manifest = JSON . parse ( readFileSync ( p , 'utf8' ) ) ;
1015+ writeFileSync ( p , JSON . stringify ( { ...manifest , peerDependencies : { '@object-ui/unrelated' : 'workspace:*' } } , null , 2 ) ) ;
1016+ } ) , 1 , blind , [ IN_TREE_REGENERATE ] ] ,
1017+ [ '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 ] ] ,
1018+ [ 'no pin: the in-tree producer builds, then writes a tree this gate passes' , producedInTree , 0 , blind ] ,
8271019 ] ;
8281020
8291021 // The ledger this self-test's floor is evaluated against (#13489).
@@ -833,13 +1025,18 @@ function selfTest() {
8331025 } ;
8341026
8351027 let failures = 0 ;
836- for ( const [ name , root , want , options ] of cases ) {
1028+ for ( const [ name , root , want , options , mustName = [ ] ] of cases ) {
8371029 registerCase ( name ) ;
8381030 const problems = checkTree ( root , options ) ;
8391031 const got = problems . length ? 1 : 0 ;
840- if ( got !== want ) {
1032+ const unnamed = got ? mustName . filter ( ( text ) => ! problems . join ( '\n' ) . includes ( text ) ) : [ ] ;
1033+ if ( got !== want || unnamed . length > 0 ) {
8411034 failures ++ ;
842- console . error ( `✗ self-test: ${ name } — expected ${ want ? 'RED' : 'GREEN' } , got ${ got ? 'RED' : 'GREEN' } ` ) ;
1035+ console . error (
1036+ got !== want
1037+ ? `✗ self-test: ${ name } — expected ${ want ? 'RED' : 'GREEN' } , got ${ got ? 'RED' : 'GREEN' } `
1038+ : `✗ self-test: ${ name } — RED, but no problem names ${ unnamed . map ( ( text ) => JSON . stringify ( text ) ) . join ( ', ' ) } ` ,
1039+ ) ;
8431040 for ( const p of problems ) console . error ( ` ${ p } ` ) ;
8441041 }
8451042 }
@@ -862,6 +1059,15 @@ function selfTest() {
8621059 declFail ( 'a repo-ROOT file is declared without the subtree suffix — a bare single-segment literal is '
8631060 + 'refused by hintCovers as too generic, so it would contribute no hint at all.' ) ;
8641061 }
1062+ // The digest is only a reading of what the producer enumerates while the two
1063+ // registry lists are one list. The producer refuses a mismatch on its own
1064+ // in-tree run; this holds it on every run, with or without a pin.
1065+ const producerList = / c o n s t R E G I S T R Y _ M O D U L E S = \[ ( [ ^ \] ] * ) \] ; / . exec ( readFileSync ( join ( DEFAULT_ROOT , PRODUCER ) , 'utf8' ) ) ;
1066+ const producerModules = producerList ? [ ...producerList [ 1 ] . matchAll ( / ' ( @ o b j e c t - u i \/ [ a - z 0 - 9 - ] + ) ' / g) ] . map ( ( m ) => m [ 1 ] ) : [ ] ;
1067+ if ( JSON . stringify ( producerModules ) !== JSON . stringify ( REGISTRY_MODULES ) ) {
1068+ declFail ( `REGISTRY_MODULES here and in ${ PRODUCER } disagree (or its list is unreadable) — the in-tree digest `
1069+ + 'would describe a different registry than the one the producer enumerates.' ) ;
1070+ }
8651071
8661072 // ── The floor: every declared row RAN, and ran its case (#13489) ───────
8671073 //
@@ -926,7 +1132,9 @@ function selfTest() {
9261132 console . log (
9271133 `✓ check-sdui-manifest self-test: ${ cases . length } cases behave (green passes; absence, tamper, moved pin, emptiness, a retired-route record, ` +
9281134 'a version the pin does not declare, and an oracle that cannot answer are RED; an absent oracle is reported, not scored; ' +
929- 'the producer, run under a symlinked TMPDIR, writes a tree that passes).' ,
1135+ 'the producer, run under a symlinked TMPDIR, writes a tree that passes; with no pin, the in-tree registry digest reds a staged ' +
1136+ 'source edit or a new workspace edge and ignores tests, unreached packages and unstaged bytes, no pin and no console is RED ' +
1137+ 'naming both, and the in-tree producer builds, then writes a tree that passes).' ,
9301138 ) ;
9311139
9321140 return SELF_TEST_VERDICT ;
0 commit comments