Skip to content

Commit e23b473

Browse files
committed
fix(changeset-gate): the level axis sees @objectstack/console when the diff moves the manifest its dist ships
Claude-Session: https://claude.ai/code/session_01Cw7JSJKE4EqGJ1uKPJZnSu Co-authored-by: Claude <noreply@anthropic.com>
1 parent 23419ba commit e23b473

1 file changed

Lines changed: 158 additions & 1 deletion

File tree

‎scripts/check-changeset-no-major.mjs‎

Lines changed: 158 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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 = /^\$\(cd "\$\(dirname "\$\{BASH_SOURCE\[0\]\}"\)\/\.\." && pwd\)$/;
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-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-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-Za-z_][A-Za-z0-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 = /^cp(?:\s+-[A-Za-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

Comments
 (0)