@@ -1290,9 +1290,146 @@ export function publishedSourceOwners(path, manifestOf) {
12901290 return owners ;
12911291}
12921292
1293+ // ── LEG 3: a repo file a package's build COPIES into what it packs (#15204) ─
1294+ //
1295+ // Legs 1 and 2 both read a path UNDER the package. `@objectstack/console` has
1296+ // neither: its tracked files are a manifest, a README, a CHANGELOG and a
1297+ // `.gitignore`, and everything it publishes is `dist/`, which
1298+ // `scripts/build-console.sh` writes. One file in that `dist` comes from THIS
1299+ // repository rather than from the objectui build — the repo-root
1300+ // `sdui.manifest.json`, copied byte for byte to `dist/sdui.manifest.json`, and
1301+ // read at runtime by `@objectstack/cli` (the JSX gate of `os validate` /
1302+ // `compile` / `lint`, and the html-page save gate `serve` registers). So a PR
1303+ // that widens what those gates accept moves exactly that one file, grades
1304+ // `@objectstack/console` `minor`, declares `Clause-②: yes (widening)` — and
1305+ // before this leg the axis saw no package at all, and told the author to grade
1306+ // `minor` a package that did not grow (PR #22812, CI job 114433350057).
1307+ //
1308+ // ⛔ The mapping is READ from the build script at the head under judgement, not
1309+ // written here. What this file names is WHICH script carries copies into a
1310+ // packed output — one path — and never which files it copies: the day the
1311+ // console build ships a second tracked file, this leg sees it with no edit
1312+ // here, and the day it stops copying the manifest, the leg stops claiming it.
1313+ // `scripts/sdui-manifest.record.json` is measured OUT for that reason rather
1314+ // than by choice: the build reads it for a warning line and does not copy it,
1315+ // so it is not part of the shipped artefact.
1316+ //
1317+ // ⚠️ Residual, stated rather than implied: the reader understands the plain
1318+ // shell the script is written in — `NAME="value"` assignments, `$NAME` /
1319+ // `${NAME}` expansion, and `cp [flags] SRC DEST` — and nothing else. A copy
1320+ // whose source does not resolve to one fixed repo path (the objectui build's
1321+ // own `dist`, under a cache dir named by the pin) is not a tracked file and is
1322+ // skipped. A script that is THERE but yields no copy at all is reported in
1323+ // `unreadable` rather than read as "copies nothing", and the self-test drives
1324+ // this repository's real `build-console.sh` so a respelling of the copy reds in
1325+ // lint instead of silently retiring the leg.
1326+ //
1327+ // ⚠️ And what it does NOT add: `.objectui-sha`. The pin decides which objectui
1328+ // tree the console `dist` is BUILT from, but the build does not copy it, and a
1329+ // pin bump that moves an accept set moves the manifest too — which is the
1330+ // reading this leg makes.
1331+ export const PACKED_COPY_BUILD_SCRIPTS = Object . freeze ( [ 'scripts/build-console.sh' ] ) ;
1332+
1333+ // The value a build script assigns to the variable that names the repository
1334+ // root: its own directory's parent, which is the repo root because every script
1335+ // in `PACKED_COPY_BUILD_SCRIPTS` lives in `scripts/`. Read, not named, so the
1336+ // variable's NAME is the script's business.
1337+ const SCRIPT_PARENT_DIR_VALUE = / ^ \$ \( c d " \$ \( d i r n a m e " \$ \{ B A S H _ S O U R C E \[ 0 \] \} " \) \/ \. \. " & & p w d \) $ / ;
1338+ const REPO_ROOT_MARK = 'repo-root::' ;
1339+
1340+ /**
1341+ * Every copy a build script makes from a FIXED repo path into the repo, as
1342+ * repo-relative `{ source, destination }` pairs, in script order.
1343+ *
1344+ * Assignments are evaluated in order, so a variable reassigned later is read at
1345+ * the value it has on the `cp` line. A source or destination that keeps an
1346+ * unexpanded `$` — a command substitution, a parameter operator such as
1347+ * `${PINNED_SHA:0:12}`, a variable never assigned — is not one fixed path, and
1348+ * the copy is skipped rather than guessed at.
1349+ *
1350+ * @param {string } text a shell script's content
1351+ * @returns {{ source: string, destination: string }[] }
1352+ */
1353+ export function packedCopiesIn ( text ) {
1354+ const vars = new Map ( ) ;
1355+ const unquote = ( word ) => ( word . length >= 2 && word . startsWith ( '"' ) && word . endsWith ( '"' ) ? word . slice ( 1 , - 1 ) : word ) ;
1356+ const expand = ( value ) =>
1357+ value . replace ( / \$ \{ ( [ A - Z a - z _ ] [ A - Z a - z 0 - 9 _ ] * ) \} | \$ ( [ A - Z a - z _ ] [ A - Z a - z 0 - 9 _ ] * ) / g, ( whole , a , b ) => ( vars . has ( a ?? b ) ? vars . get ( a ?? b ) : whole ) ) ;
1358+ const repoPath = ( word ) => {
1359+ const value = expand ( unquote ( word ) ) ;
1360+ if ( ! value . startsWith ( `${ REPO_ROOT_MARK } /` ) || value . includes ( '$' ) ) return null ;
1361+ const rel = value . slice ( REPO_ROOT_MARK . length + 1 ) . replace ( / \/ + / g, '/' ) . replace ( / \/ + $ / , '' ) ;
1362+ return rel === '' || rel . split ( '/' ) . includes ( '..' ) ? null : rel ;
1363+ } ;
1364+ const copies = [ ] ;
1365+ for ( const raw of text . split ( '\n' ) ) {
1366+ const line = raw . trim ( ) ;
1367+ const assignment = / ^ ( [ A - Z a - z _ ] [ A - Z a - z 0 - 9 _ ] * ) = " ( .* ) " $ / . exec ( line ) ;
1368+ if ( assignment ) {
1369+ const [ , name , value ] = assignment ;
1370+ vars . set ( name , SCRIPT_PARENT_DIR_VALUE . test ( value ) ? REPO_ROOT_MARK : expand ( value ) ) ;
1371+ continue ;
1372+ }
1373+ const copy = / ^ c p (?: \s + - [ A - Z a - z ] + ) * \s + ( " [ ^ " ] * " | [ ^ \s " ] + ) \s + ( " [ ^ " ] * " | [ ^ \s " ] + ) $ / . exec ( line ) ;
1374+ if ( ! copy ) continue ;
1375+ const source = repoPath ( copy [ 1 ] ) ;
1376+ const destination = repoPath ( copy [ 2 ] ) ;
1377+ if ( source !== null && destination !== null ) copies . push ( { source, destination } ) ;
1378+ }
1379+ return copies ;
1380+ }
1381+
1382+ /**
1383+ * Whether a manifest's `files` packs this package-relative path. An absent
1384+ * `files` packs everything, which is npm's rule; an entry covers the path
1385+ * itself and everything under it. Glob entries are not expanded — a reader, not
1386+ * a packer — so a path only a glob would cover reads as not packed.
1387+ *
1388+ * @param {object } manifest a parsed package.json
1389+ * @param {string } rel a package-relative path
1390+ */
1391+ function filesPack ( manifest , rel ) {
1392+ const files = /** @type {{ files?: unknown } } */ ( manifest ) . files ;
1393+ if ( ! Array . isArray ( files ) ) return true ;
1394+ return files . some ( ( entry ) => {
1395+ if ( typeof entry !== 'string' ) return false ;
1396+ const e = entry . replace ( / \/ + / g, '/' ) . replace ( / ^ (?: \. \/ ) + / , '' ) . replace ( / \/ + $ / , '' ) ;
1397+ return e !== '' && ( rel === e || rel . startsWith ( `${ e } /` ) ) ;
1398+ } ) ;
1399+ }
1400+
1401+ /**
1402+ * The package directory a copy's DESTINATION is published by — the NEAREST
1403+ * ancestor under `packages/` with a manifest — or none.
1404+ *
1405+ * Nearest rather than shallowest, unlike leg 1, because the question is which
1406+ * manifest's `files` decides whether the copied file ships, and that is the
1407+ * nearest one. `false` (a manifest that is there and will not parse) is
1408+ * returned as a candidate that cannot be named, so the caller reports it as
1409+ * unreadable (#4690). A destination its package does not pack ships nothing,
1410+ * and owns nothing.
1411+ *
1412+ * @param {string } destination a repo-relative path
1413+ * @param {(dir: string) => object | false | null } manifestOf
1414+ * @returns {string | null }
1415+ */
1416+ export function packedCopyOwner ( destination , manifestOf ) {
1417+ const segments = destination . split ( '/' ) ;
1418+ if ( segments [ 0 ] !== 'packages' ) return null ;
1419+ for ( let i = segments . length - 1 ; i >= 2 ; i -= 1 ) {
1420+ const dir = segments . slice ( 0 , i ) . join ( '/' ) ;
1421+ const manifest = manifestOf ( dir ) ;
1422+ if ( manifest === null ) continue ;
1423+ if ( manifest === false ) return dir ;
1424+ return filesPack ( manifest , segments . slice ( i ) . join ( '/' ) ) ? dir : null ;
1425+ }
1426+ return null ;
1427+ }
1428+
12931429/**
12941430 * The workspace package names whose PUBLISHED surface this diff moves — its
1295- * `src/**` or a `bin` target it packs — read from the HEAD tree rather than
1431+ * `src/**`, a `bin` target it packs, or a repo file its build copies into what
1432+ * it packs (leg 3, above) — read from the HEAD tree rather than
12961433 * from the working directory, because the self-test and the acceptance run both
12971434 * drive commits that are not checked out.
12981435 *
@@ -1358,7 +1495,27 @@ export function packagesTouched({ cwd, from, head }) {
13581495
13591496 const packages = new Set ( ) ;
13601497 const unreadable = new Set ( ) ;
1498+
1499+ // LEG 3 (#15204): the copies each build script makes into the repo, read
1500+ // from THIS head. A script that is absent copies nothing; one that is there
1501+ // and yields no copy is a reading this leg could not make, and is reported.
1502+ const copiedTo = new Map ( ) ;
1503+ for ( const script of PACKED_COPY_BUILD_SCRIPTS ) {
1504+ const text = showOrNull ( head , script , cwd ) ;
1505+ if ( text === null ) continue ;
1506+ const copies = packedCopiesIn ( text ) ;
1507+ if ( copies . length === 0 ) unreadable . add ( script ) ;
1508+ for ( const { source, destination } of copies ) copiedTo . set ( source , [ ...( copiedTo . get ( source ) ?? [ ] ) , destination ] ) ;
1509+ }
1510+
13611511 for ( const line of out . split ( '\n' ) ) {
1512+ for ( const destination of copiedTo . get ( line . trim ( ) ) ?? [ ] ) {
1513+ const dir = packedCopyOwner ( destination , manifestOf ) ;
1514+ if ( dir === null ) continue ;
1515+ const name = nameFor ( dir ) ;
1516+ if ( name ) packages . add ( name ) ;
1517+ else unreadable . add ( dir ) ;
1518+ }
13621519 const owners = publishedSourceOwners ( line . trim ( ) , manifestOf ) ;
13631520 if ( owners . length === 0 ) continue ;
13641521 const owner = owners . find ( ( dir ) => nameFor ( dir ) !== null ) ;
0 commit comments