From 02d62402817a860266af2221a56e645cebe26ece Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 23:29:11 +0000 Subject: [PATCH 1/2] feat(devx): check:published-files gates an exports map retrofitted onto an already-published package GATED asserts an `exports` map exists; it is silent on the TRANSITION, and the transition is where the damage is. 17.3.0 added a map to `@objectstack/cli`, which had published without one, and every deep path a consumer reached through `dist/` stopped resolving at once -- found after publish, by the consumer, twice (#13662, #15325). ANNOUNCED is the second half of #15589, ruled B1 on #15715: a base-vs-HEAD comparison, the same shape `check-adr-0087-registration --base` already uses, and deliberately not `git log -S` archaeology. A `package.json` absent at the merge base -- or present but private/unnamed there -- is born-sealed and passes, because it has no published predecessor whose consumers could be deep-importing it. Measured when #15715 was filed: 69 publishable packages declare a map from 51 introducing commits, but 56 were born with it and only 13 retrofitted, from 7 commits. Gating all 51 would demand a consumer note 44 times from packages that had no consumers, every one of them on a new-package PR -- and a gate answered by boilerplate 44 times out of 51 has stopped being read by the 7th time it matters. What the announcement must say is checked against the head map rather than for a form of words: a `minor`-or-greater changeset on the package whose body names a deep specifier the new map does NOT resolve. Naming a path that still resolves tells a consumer nothing and does not satisfy it. Absence is never a pass (#4690). Every way the base read can fail -- no `origin/main`, no merge base, an unreadable `.changeset/` -- makes every package look born-sealed and the clause vacuously green, so each is a refusal that names itself, and the read carries its own census control (BASE_READ_FLOOR) exactly as GATED carries EXPORTS_CENSUS_FLOOR. In CI the base comes from `Lint & Repo Gates` checking out at `fetch-depth: 0`. Claude-Session: https://claude.ai/code/session_01PU9zBGbH2s2ZtxSyu963M3 Co-authored-by: Claude --- scripts/check-published-files.mjs | 728 +++++++++++++++++++++++++++++- 1 file changed, 725 insertions(+), 3 deletions(-) diff --git a/scripts/check-published-files.mjs b/scripts/check-published-files.mjs index 932e67bdb2..538afeaabe 100644 --- a/scripts/check-published-files.mjs +++ b/scripts/check-published-files.mjs @@ -50,6 +50,11 @@ // GATED `exports` exists and names something (#12879). `files` decides // what SHIPS; `exports` decides what a consumer may RESOLVE of // what shipped, and without it those two are the same set. +// ANNOUNCED a package whose resolvable surface NARROWS relative to the +// merge base -- a map added to a manifest that already published +// without one, or a subpath dropped from an existing map -- says +// so in a `minor` changeset naming the deep paths that stop +// resolving (#15715, #15589 option B as ruled B1). // // GATED, and the census control that keeps it honest (#12879) // ---------------------------------------------------------- @@ -101,8 +106,90 @@ // accident is worse than one with a stated boundary. What ships from dist/ is // the build's business; this guard is about source leaking past it. +// ANNOUNCED, and why it is the only clause here that reads history (#15715) +// ------------------------------------------------------------------------ +// +// GATED above asserts the map EXISTS. It is silent on the transition, and the +// transition is where the damage is: 17.3.0 added a map to `@objectstack/cli`, +// which had published without one, and every deep path a consumer had reached +// through `dist/` stopped resolving at once. Two out-of-repo repos found out +// after publish, during an upgrade -- cloud's `objectos-runtime` (#13662) and +// hotcrm's hook-body harness (#15325). Nothing here could have gone red: an +// `exports` map is a PACKAGING contract, and inside this monorepo nothing is +// sealed, because every in-repo consumer reaches any file through a relative +// import, a vitest alias or a `paths` entry. +// +// #15589 left two independent halves. Option A imports the missing knowledge +// from outside -- `packages/qa/downstream-contract/consumer-specifiers.ledger.json` +// records the specifiers NAMED out-of-repo consumers import, and a test resolves +// each from a packed tarball. This is the other half, and its whole value is +// that it does NOT depend on that ledger being complete: it fires on the PR +// that narrows the surface, whether or not anyone has written the consumer +// down. A red here is not "you broke a listed consumer" -- it is "you are +// about to publish a narrowing nobody announced". +// +// BORN-SEALED IS NOT A NARROWING, and that distinction is the whole clause. +// A package born with a map seals nobody: there is no published predecessor +// whose consumers could have been deep-importing it. Measured over this repo +// when #15715 was filed: 69 publishable packages declare a map, introduced by +// 51 commits -- but 56 of those packages were born with it and only 13 were +// retrofitted, from 7 commits. A clause that fires on all 51 would demand a +// consumer note 44 times from packages that had no consumers and no deep paths +// that stopped resolving, and every one of those 44 lands on a new-package PR, +// where the demand is least likely to be read and most likely to be discharged +// with boilerplate. A gate answered by boilerplate 44 times out of 51 has +// stopped being read by the 7th time it matters. So the clause discriminates, +// and the ruling on #15715 (B1) is what it implements. +// +// THE DISCRIMINATOR IS A BASE-vs-HEAD COMPARISON, deliberately -- the same +// shape `check-adr-0087-registration --base` already uses here, and ⛔ NOT +// `git log -S` archaeology, which would make the verdict depend on history +// this gate has no business reading. It looks at exactly two trees: +// +// package.json absent at the merge base -> BORN, passes +// present but private/unnamed at the merge base -> BORN, passes +// (no published predecessor either -- a package going public for the +// first time seals nobody, and gating it is the 44-false-positive shape) +// present and publishable, no map there, map here -> RETROFIT, gated +// subpath in the base map, absent here -> REMOVAL, gated +// anything else (unchanged, or WIDENED) -> passes +// +// WHAT THE ANNOUNCEMENT MUST SAY. A `minor`-or-greater changeset on that +// package, whose body NAMES deep paths that stop resolving -- checked against +// the head map rather than for a form of words, which is what keeps it from +// being dischargeable with boilerplate. For a removal, every dropped subpath +// must be named. For a retrofit, at least one deep specifier of the package +// must be named that the new map genuinely does NOT resolve: writing +// `@objectstack/cli/console` when `./console` is in the map does not satisfy +// it, because that path still resolves and naming it tells a consumer nothing. +// The specifier a consumer actually wrote (`@objectstack/cli/dist/utils/console.js`) +// does satisfy it, and is the sentence #13662 needed and never got. +// +// ⚠️ ABSENCE IS NEVER A PASS (#4690). This clause reads the base through git, +// and every way that read can fail -- no `origin/main`, no merge base, an +// unreadable `.changeset/` -- makes EVERY package look born-sealed and the +// whole clause vacuously green. That is the failure this repo keeps paying +// for, so each of those is a REFUSAL that names itself, never a skip, and the +// base read carries its own census control (BASE_READ_FLOOR) exactly as GATED +// carries EXPORTS_CENSUS_FLOOR. In CI the base comes from `Lint & Repo Gates` +// checking out at `fetch-depth: 0` (.github/workflows/lint.yml) -- that is +// what makes actions/checkout fetch `+refs/heads/*:refs/remotes/origin/*` so +// `origin/main` exists at all, and on a `pull_request` event the merge base +// with the merge-ref HEAD lands exactly on the PR's branch point. +// +// The HEAD side is read from the WORKING TREE, not from a rev, so the clause +// fires on an uncommitted retrofit too -- before the commit rather than after +// the push. In CI the two are the same tree. + +import { execFileSync } from 'node:child_process'; import { existsSync, readFileSync, readdirSync } from 'node:fs'; import { join, posix, resolve } from 'node:path'; +// The changeset frontmatter parser is REUSED, never re-typed. Four readers of +// one block must agree on what counts as a declaration (#7004), and +// check-empty-changeset's self-test asserts that agreement byte-for-byte across +// all of them -- a fifth private copy here would be outside that assertion. The +// module is entry-guarded, so importing it runs no gate (its own I1 case). +import { parseChangeset } from './check-adr-0087-registration.mjs'; import { readWorkspaceGlobs, selfTest as workspaceEnumeratorSelfTest, @@ -418,6 +505,254 @@ function exportsVerdict(manifest) { return { ok: true }; } +// -- ANNOUNCED: the base-vs-HEAD discriminator (#15715) ---------------------- +// +// Every function in this section is PURE -- it takes the two manifests and the +// changeset texts and returns a verdict. The git reads that supply them are the +// section after it, kept apart on purpose: the four cells the ruling names are +// then testable without a repository, and the one thing that needs a real one +// (does the base read actually reach the base tree) is testable on its own. + +/** + * The subpath keys a map DECLARES, as `"."` / `"./x"` strings. + * + * A bare string target, a fallback array and a conditions-only object all + * declare the root and nothing else -- Node resolves no subpath through any of + * them -- so all three answer `{'.'}` rather than an empty set. An empty set + * would read as "this map declared nothing", which is a different fact and one + * `exportsVerdict` already refuses. + */ +export function exportSubpaths(map) { + if (typeof map === 'string' || Array.isArray(map)) return new Set(['.']); + if (!map || typeof map !== 'object') return new Set(); + const declared = Object.keys(map).filter((k) => k === '.' || k.startsWith('./')); + return new Set(declared.length > 0 ? declared : ['.']); +} + +/** + * Does `map` resolve `subpath` (spelled `"."` or `"./x"`)? + * + * Pattern keys are honoured, because generalising `"./console"` to `"./*"` is a + * WIDENING and must not read as a removal. One `*`, prefix + suffix, which is + * what Node's subpath-patterns are. + */ +export function mapResolves(map, subpath) { + if (typeof map === 'string' || Array.isArray(map)) return subpath === '.'; + if (!map || typeof map !== 'object') return false; + const declared = Object.keys(map).filter((k) => k === '.' || k.startsWith('./')); + if (declared.length === 0) return subpath === '.'; + if (declared.includes(subpath)) return true; + for (const key of declared) { + const star = key.indexOf('*'); + if (star === -1) continue; + const prefix = key.slice(0, star); + const suffix = key.slice(star + 1); + if (subpath.length < prefix.length + suffix.length) continue; + if (subpath.startsWith(prefix) && subpath.endsWith(suffix)) return true; + } + return false; +} + +/** + * The four cells, decided from exactly two manifests (#15715, ruled B1). + * + * @param {string|null} baseText `package.json` at the merge base, or null when + * the path does not exist there + * @param {object} headManifest the manifest as it reads now + * @returns {{ kind: 'born'|'retrofit'|'removal'|'unchanged'|'ungated-head'|'unreadable-base', lost: string[] }} + */ +export function narrowingVerdict(baseText, headManifest) { + // Cell 1a: no manifest at the base -> the package is born with whatever it + // declares. There is no published predecessor, so it seals nobody. + if (baseText === null || baseText === undefined) return { kind: 'born', lost: [] }; + let base; + try { + base = JSON.parse(baseText); + } catch { + return { kind: 'unreadable-base', lost: [] }; + } + if (!base || typeof base !== 'object') return { kind: 'unreadable-base', lost: [] }; + // Cell 1b: present at the base but PRIVATE or unnamed -- npm never published + // it, so it has no consumers either. Gating a package on the PR that first + // makes it public is the same false positive as gating a new one, and it is + // the shape a `private: true` scaffold takes on its way to release. + if (!base.name || base.private === true) return { kind: 'born', lost: [] }; + + const headGated = exportsVerdict(headManifest).ok; + // No usable map at HEAD is GATED's finding, not this clause's: reporting both + // would tell one author two different things about one manifest. + if (!headGated) return { kind: 'ungated-head', lost: [] }; + + // Cell 2: publishable at the base with no usable map there, gated here. Every + // deep path a consumer reached through `dist/` stops resolving. + if (!exportsVerdict(base).ok) return { kind: 'retrofit', lost: [] }; + + // Cell 3: a subpath the base map declared that the head map no longer + // resolves. Judged by RESOLUTION, not by key equality, so a widening to a + // pattern key is not mistaken for a removal. + const lost = [...exportSubpaths(base.exports)] + .filter((sub) => !mapResolves(headManifest.exports, sub)) + .sort(); + if (lost.length > 0) return { kind: 'removal', lost }; + + // Cell 4: unchanged, or widened. + return { kind: 'unchanged', lost: [] }; +} + +const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +/** + * Every deep specifier of `pkg` a body names, returned as `"./x"` subpaths. + * + * The body is prose, so the trailing punctuation a sentence puts after a + * specifier (`...console.js`, `` `...` ``) is trimmed off the captured path. + */ +export function deepSpecifiersNamed(body, pkg) { + const found = new Set(); + const re = new RegExp(`${escapeRe(pkg)}\\/([A-Za-z0-9._*-]+(?:\\/[A-Za-z0-9._*-]+)*)`, 'g'); + let m; + while ((m = re.exec(body)) !== null) { + const path = m[1].replace(/[.,;:]+$/, ''); + if (path) found.add(`./${path}`); + } + return found; +} + +/** + * Is the narrowing ANNOUNCED -- a `minor`-or-greater changeset on this package + * whose body names deep paths that genuinely stop resolving? + * + * The second half is checked against the head map rather than against a form of + * words. That is the whole anti-boilerplate property: a note that names a path + * the new map still resolves has told a consumer nothing, and reads here as + * unsatisfied rather than as a note. + * + * @returns {{ satisfied: boolean, reason: string, missing: string[] }} + */ +export function announcementVerdict({ pkg, narrowing, headExports, changesets }) { + const bumped = changesets.filter((c) => + parseChangeset(c.text).bumps.some((b) => b.pkg === pkg && (b.bump === 'minor' || b.bump === 'major')), + ); + if (bumped.length === 0) return { satisfied: false, reason: 'no-bump', missing: narrowing.lost }; + + if (narrowing.kind === 'removal') { + // Every dropped subpath must be named, in either spelling an author would + // reach for: the map key (`./console`) or the specifier (`/console`). + const missing = narrowing.lost.filter((sub) => { + const asSpecifier = sub === '.' ? pkg : `${pkg}${sub.slice(1)}`; + return !bumped.some((c) => c.text.includes(sub) || c.text.includes(asSpecifier)); + }); + return missing.length > 0 + ? { satisfied: false, reason: 'unnamed-removal', missing } + : { satisfied: true, reason: 'announced', missing: [] }; + } + + // Retrofit: at least one named deep specifier the new map does NOT resolve. + for (const c of bumped) { + for (const sub of deepSpecifiersNamed(parseChangeset(c.text).body, pkg)) { + if (!mapResolves(headExports, sub)) return { satisfied: true, reason: 'announced', missing: [] }; + } + } + return { satisfied: false, reason: 'no-dead-path-named', missing: [] }; +} + +// -- ANNOUNCED: the base read, and the control that keeps it honest (#15715) - +// +// This is the only history-dependent read in this gate, so it is also the only +// place it can go vacuously green: every failure mode of the read -- a missing +// `origin/main`, an absent merge base, a `cat-file` that returns nothing -- +// makes every package look BORN and the clause silently unanimous. So the read +// is controlled the way GATED's census is, in its own words. + +function git(args) { + // stderr piped, not inherited: the base read legitimately probes paths that + // do not exist at the merge base, and git's "does not exist" on the terminal + // would read as though the gate had failed while it is answering its question. + return execFileSync('git', args, { + cwd: ROOT, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'], + }); +} + +function resolveCommit(ref) { + try { return git(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]).trim() || null; } catch { return null; } +} + +function mergeBaseOf(base, head) { + try { return git(['merge-base', base, head]).trim() || null; } catch { return null; } +} + +/** + * Every path at one rev in a SINGLE `git cat-file --batch`, absent from the map + * when it does not exist there. ~80 manifests is ~80 process spawns done the + * naive way, on a gate that runs on every PR. + */ +function showManyOrNull(rev, paths) { + const found = new Map(); + if (paths.length === 0) return found; + let out; + try { + out = execFileSync('git', ['cat-file', '--batch'], { + cwd: ROOT, input: `${paths.map((p) => `${rev}:${p}`).join('\n')}\n`, + maxBuffer: 512 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'], + }); + } catch { return found; } + // ` \n\n` per hit; ` missing\n` per miss. + let off = 0; + for (const path of paths) { + const nl = out.indexOf(0x0a, off); + if (nl < 0) break; + const header = out.subarray(off, nl).toString('utf8'); + if (header.endsWith(' missing')) { off = nl + 1; continue; } + const size = Number(header.split(' ')[2]); + if (!Number.isFinite(size)) break; + found.set(path, out.subarray(nl + 1, nl + 1 + size).toString('utf8')); + off = nl + 1 + size + 1; + } + return found; +} + +/** Pending changesets in the working tree, or `null` when the directory is unreadable. */ +function pendingChangesets() { + const dir = join(ROOT, '.changeset'); + if (!existsSync(dir)) return null; + const out = []; + let entries; + try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return null; } + for (const e of entries) { + // Non-recursive, so the consumed prerelease stock under `.changeset/pre/` + // is skipped by construction, as is README.md (documentation, never a + // changeset) and config.json. + if (!e.isFile() || !e.name.endsWith('.md') || e.name === 'README.md') continue; + try { out.push({ path: `.changeset/${e.name}`, text: readFileSync(join(dir, e.name), 'utf8') }); } catch { /* unreadable file */ } + } + return out; +} + +/** + * The base read's census control: did the read actually reach the base tree? + * + * RELATIVE rather than absolute, unlike EXPORTS_CENSUS_FLOOR, because the + * legitimate reason for a package to be missing at the base is that the PR adds + * it -- and a PR may add several. What no PR does is more than double the + * publishable population, so a read that finds fewer than half of them at the + * base did not fail to find new packages: it failed. + */ +export function baseReadControl({ publishable, foundAtBase }) { + if (publishable === 0) return { ok: true, lines: [] }; + if (foundAtBase >= Math.ceil(publishable / 2)) return { ok: true, lines: [] }; + return { + ok: false, + lines: [ + `read a \`package.json\` at the merge base for ${foundAtBase} of ${publishable} publishable`, + 'package(s), under half. Every package the base read MISSES reads as born-sealed and', + 'passes ANNOUNCED, so a broken read makes this clause unanimously green while checking', + 'nothing (#4690, #15715) -- and no PR adds more publishable packages than the repo', + 'already had.', + 'Fix: repair the read. This is the control, not a threshold to tune.', + ], + }; +} + /** * The pattern semantics above are the one part of this guard that can be wrong * without any package being wrong -- a matcher that over-matches turns MINIMAL @@ -441,11 +776,13 @@ function exportsVerdict(manifest) { const SELF_TEST_BATTERIES = Object.freeze({ 'the dispatch-gates declaration (#10542)': 37, 'GATED and its census floor (#12879)': 22, + 'ANNOUNCED: the four base-vs-HEAD cells (#15715)': 26, + 'ANNOUNCED: what satisfies the announcement (#15715)': 28, }); // 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 = 2; +const SELF_TEST_BATTERY_FLOOR = 4; // 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 @@ -657,6 +994,232 @@ function selfTest() { failures.push(...enumeratorFailures); failures.push(...workspaceEnumeratorFloorFailures()); + // ── ANNOUNCED: the four base-vs-HEAD cells (#15715) ─────────────────────── + battery('ANNOUNCED: the four base-vs-HEAD cells (#15715)'); + // + // The ruling names four cells and each is pinned in BOTH directions, because + // the way this clause fails is by answering "born" to everything: a reader + // that returns nothing, a base that does not resolve, a verdict that falls + // through. Every case below flips when the clause is ablated, which is the + // property #15410 asks for -- a self-test that cannot fail is the 179th one + // nobody wanted. + const HEAD_MAP = { name: '@x/p', exports: { '.': './dist/index.js', './console': './dist/console.js' } }; + const cellCases = [ + // Cell 1: born-sealed passes. No manifest at the base at all. + ['cell 1 — absent at base is BORN', narrowingVerdict(null, HEAD_MAP).kind, 'born'], + [ + 'cell 1b — PRIVATE at base is BORN (npm never published it, so it seals nobody)', + narrowingVerdict('{"name":"@x/p","private":true}', HEAD_MAP).kind, + 'born', + ], + [ + 'cell 1b — UNNAMED at base is BORN', + narrowingVerdict('{"version":"1.0.0"}', HEAD_MAP).kind, + 'born', + ], + // Cell 2: retrofit is gated. Publishable at the base, no map there. + [ + 'cell 2 — publishable at base with no map, gated here, is a RETROFIT', + narrowingVerdict('{"name":"@x/p","version":"1.0.0"}', HEAD_MAP).kind, + 'retrofit', + ], + [ + 'cell 2 — an EMPTY map at base is not a map: still a RETROFIT', + narrowingVerdict('{"name":"@x/p","exports":{}}', HEAD_MAP).kind, + 'retrofit', + ], + // Cell 3: subpath removal is gated. + [ + 'cell 3 — a subpath declared at base and unresolvable here is a REMOVAL', + narrowingVerdict('{"name":"@x/p","exports":{".":"./d.js","./console":"./c.js","./hook":"./h.js"}}', HEAD_MAP).kind, + 'removal', + ], + [ + 'cell 3 — the REMOVAL names exactly the lost subpath', + narrowingVerdict('{"name":"@x/p","exports":{".":"./d.js","./console":"./c.js","./hook":"./h.js"}}', HEAD_MAP).lost.join(','), + './hook', + ], + // Cell 4: unchanged passes -- and so does WIDENING, in both spellings. + [ + 'cell 4 — an identical map is UNCHANGED', + narrowingVerdict(JSON.stringify(HEAD_MAP), HEAD_MAP).kind, + 'unchanged', + ], + [ + 'cell 4 — ADDING a subpath is a widening, not a narrowing', + narrowingVerdict('{"name":"@x/p","exports":{".":"./dist/index.js"}}', HEAD_MAP).kind, + 'unchanged', + ], + [ + 'cell 4 — generalising a subpath to a PATTERN is a widening, not a removal', + narrowingVerdict( + '{"name":"@x/p","exports":{".":"./d.js","./console":"./c.js"}}', + { name: '@x/p', exports: { '.': './d.js', './*': './dist/*.js' } }, + ).kind, + 'unchanged', + ], + // The two verdicts that are deliberately NOT this clause's finding. + [ + 'a HEAD with no usable map is GATED’s finding, not this one’s', + narrowingVerdict('{"name":"@x/p","version":"1.0.0"}', { name: '@x/p' }).kind, + 'ungated-head', + ], + [ + 'a base manifest that does not parse is refused, never read as born', + narrowingVerdict('{ not json', HEAD_MAP).kind, + 'unreadable-base', + ], + ]; + for (const [label, actual, expected] of cellCases) { + expect(actual === expected, `ANNOUNCED ${label}: got "${actual}", expected "${expected}"`); + } + + // `mapResolves` decides cell 3, so its own semantics are pinned apart from it. + const resolveCases = [ + ['exact subpath', { '.': './d.js', './console': './c.js' }, './console', true], + ['absent subpath', { '.': './d.js' }, './console', false], + ['pattern covers it', { './*': './dist/*.js' }, './console', true], + ['pattern prefix+suffix', { './dist/*.js': './dist/*.js' }, './dist/a.js', true], + ['pattern does not cover a different suffix', { './dist/*.js': './x' }, './dist/a.ts', false], + ['a bare string target resolves the root only', './dist/index.js', '.', true], + ['a bare string target resolves no subpath', './dist/index.js', './console', false], + ['a conditions-only object is root-only', { types: './d.ts', default: './d.js' }, '.', true], + ['a conditions-only object resolves no subpath', { types: './d.ts', default: './d.js' }, './console', false], + ['a fallback array resolves the root only', ['./dist/index.js'], '.', true], + ]; + for (const [label, map, sub, expected] of resolveCases) { + expect(mapResolves(map, sub) === expected, `mapResolves(${label}, "${sub}") !== ${expected}`); + } + + const subpathCases = [ + ['an object map lists its subpath keys', { '.': 'a', './x': 'b' }, '.,./x'], + ['a conditions-only object is the root', { types: 'a', default: 'b' }, '.'], + ['a bare string is the root', './dist/index.js', '.'], + ['a fallback array is the root', ['./a.js'], '.'], + ]; + for (const [label, map, expected] of subpathCases) { + expect([...exportSubpaths(map)].sort().join(',') === expected, `exportSubpaths: ${label}`); + } + + // ── ANNOUNCED: what satisfies the announcement (#15715) ──────────────────── + battery('ANNOUNCED: what satisfies the announcement (#15715)'); + // + // The anti-boilerplate half. A `minor` bump alone never satisfies it; what + // satisfies it is a body naming a path the NEW map does not resolve, checked + // against the map rather than against a form of words. + const cs = (text) => [{ path: '.changeset/x.md', text }]; + const RETROFIT = { kind: 'retrofit', lost: [] }; + const REMOVAL = { kind: 'removal', lost: ['./hook'] }; + const HEAD_EXPORTS = HEAD_MAP.exports; + const verdictOf = (narrowing, changesets) => + announcementVerdict({ pkg: '@x/p', narrowing, headExports: HEAD_EXPORTS, changesets }).reason; + + const announceCases = [ + ['no changeset at all', RETROFIT, [], 'no-bump'], + ['a PATCH changeset is not an announcement', RETROFIT, cs('---\n"@x/p": patch\n---\n\nfix: x\n'), 'no-bump'], + [ + 'a minor changeset for a DIFFERENT package does not answer for this one', + RETROFIT, + cs('---\n"@x/other": minor\n---\n\nnames @x/p/dist/gone.js\n'), + 'no-bump', + ], + [ + 'a minor bump whose body names NO dead path is boilerplate, not a note', + RETROFIT, + cs('---\n"@x/p": minor\n---\n\nSealed the package behind an exports map.\n'), + 'no-dead-path-named', + ], + [ + 'naming a path the new map STILL resolves tells a consumer nothing', + RETROFIT, + cs('---\n"@x/p": minor\n---\n\nConsumers should use @x/p/console.\n'), + 'no-dead-path-named', + ], + [ + 'naming a genuinely dead deep path IS the announcement', + RETROFIT, + cs('---\n"@x/p": minor\n---\n\n@x/p/dist/utils/console.js no longer resolves; use @x/p/console.\n'), + 'announced', + ], + [ + 'a MAJOR bump counts as at least minor', + RETROFIT, + cs('---\n"@x/p": major\n---\n\n@x/p/dist/utils/console.js stops resolving.\n'), + 'announced', + ], + ['a removal with no changeset', REMOVAL, [], 'no-bump'], + [ + 'a removal whose body names nothing', + REMOVAL, + cs('---\n"@x/p": minor\n---\n\nTidied the exports map.\n'), + 'unnamed-removal', + ], + [ + 'a removal named by its MAP KEY is announced', + REMOVAL, + cs('---\n"@x/p": minor\n---\n\nDropped "./hook" from the map.\n'), + 'announced', + ], + [ + 'a removal named by its SPECIFIER is announced', + REMOVAL, + cs('---\n"@x/p": minor\n---\n\n@x/p/hook no longer resolves.\n'), + 'announced', + ], + [ + 'a removal of TWO subpaths is not answered by naming one', + { kind: 'removal', lost: ['./hook', './other'] }, + cs('---\n"@x/p": minor\n---\n\n@x/p/hook no longer resolves.\n'), + 'unnamed-removal', + ], + ]; + for (const [label, narrowing, changesets, expected] of announceCases) { + const got = verdictOf(narrowing, changesets); + expect(got === expected, `announcementVerdict — ${label}: got "${got}", expected "${expected}"`); + } + + const namedCases = [ + ['a plain specifier', '@x/p/dist/a.js', './dist/a.js'], + ['trailing sentence punctuation is trimmed', 'see @x/p/dist/a.js, and', './dist/a.js'], + ['a backticked specifier', 'use `@x/p/console` instead', './console'], + ]; + for (const [label, body, expected] of namedCases) { + expect(deepSpecifiersNamed(body, '@x/p').has(expected), `deepSpecifiersNamed — ${label}`); + } + expect(deepSpecifiersNamed('@x/other/dist/a.js', '@x/p').size === 0, 'deepSpecifiersNamed ignores another package'); + + // The base-read control, in both directions. + const controlCases = [ + ['a full read passes', { publishable: 69, foundAtBase: 69 }, true], + ['a read that found half passes (a PR may add packages)', { publishable: 69, foundAtBase: 35 }, true], + ['a read that found NOTHING is the instrument breaking', { publishable: 69, foundAtBase: 0 }, false], + ['a read that found a third is the instrument breaking', { publishable: 69, foundAtBase: 23 }, false], + ['an empty workspace is not a failure', { publishable: 0, foundAtBase: 0 }, true], + ]; + for (const [label, args, expected] of controlCases) { + expect(baseReadControl(args).ok === expected, `baseReadControl — ${label}`); + } + expect( + baseReadControl({ publishable: 69, foundAtBase: 0 }).lines.some((l) => l.startsWith('Fix:')), + 'baseReadControl refuses with a Fix: line', + ); + + // ⭐ The reader itself, against THIS repository. Everything above is pure, and + // a pure battery cannot tell a working `git cat-file` reader from one that + // returns nothing for every path — which is the single shape that makes this + // whole clause vacuously green, since every unread manifest reads as born. + // So the reader is exercised for real, in both directions, at a rev that + // always exists. + const probe = showManyOrNull('HEAD', ['package.json', 'scripts/no-such-file.probe.json']); + expect(probe.has('package.json'), 'the base reader finds a path that EXISTS at HEAD (a reader that finds nothing makes every package read as born-sealed)'); + expect(!probe.has('scripts/no-such-file.probe.json'), 'the base reader reports a path that does NOT exist at HEAD as absent'); + let probeName = null; + try { probeName = JSON.parse(probe.get('package.json') ?? 'null')?.name ?? null; } catch { probeName = null; } + expect(probeName !== null, 'the base reader returns PARSEABLE content, not a truncated or empty blob'); + expect(mergeBaseOf('HEAD', 'HEAD') !== null, 'mergeBaseOf resolves a rev against itself (the merge-base helper is wired)'); + expect(resolveCommit('HEAD') !== null, 'resolveCommit resolves HEAD'); + expect(resolveCommit('refs/heads/no-such-branch-xyz') === null, 'resolveCommit answers null for a ref that does not exist, so an unresolvable --base cannot read as resolved'); + // -- The floor: every declared battery RAN, and ran its cases (#13489) ----- // // Evaluated after every battery has had its chance and BEFORE the verdict, so @@ -713,7 +1276,10 @@ function selfTest() { `${forbidden.length} classification case(s), ${declarationCases.length} ` + `population-declaration case(s), ${verdictCases.length} \`exports\` verdict case(s), ` + `${floorCases.length} census-floor case(s) (floor ${EXPORTS_CENSUS_FLOOR} vs ` + - `${liveDeclaring} of ${livePublishable} live) and the shared workspace enumerator's own ` + + `${liveDeclaring} of ${livePublishable} live), ${cellCases.length} ANNOUNCED cell case(s), ` + + `${resolveCases.length} subpath-resolution case(s), ${announceCases.length} announcement ` + + `case(s) and ${controlCases.length} base-read-control case(s) (plus the base reader itself, ` + + 'exercised against this repository), and the shared workspace enumerator\'s own ' + `assertions, over ${liveGlobs.length} live workspace glob(s).`, ); @@ -734,6 +1300,10 @@ if (process.argv.includes('--self-test')) { const problems = []; const declaredExtrasByPackage = new Map(); +// The publishable manifests as HEAD reads them, kept for the ANNOUNCED clause +// below: it needs the same population GATED just counted, paired with the path +// to read at the merge base. +const headManifests = []; let members = 0; let publishable = 0; let exportsDeclaring = 0; @@ -752,6 +1322,7 @@ for (const dir of workspaceDirs()) { publishable++; const name = manifest.name; + headManifests.push({ dir, name, manifest, manifestPath }); const lines = []; // --- GATED --------------------------------------------------------------- @@ -914,6 +1485,153 @@ if (exportsDeclaring < EXPORTS_CENSUS_FLOOR) { }); } +// --- ANNOUNCED, the base-vs-HEAD clause ------------------------------------- +// Ruled B1 on #15715. Every refusal below is a #4690 refusal: it names itself +// and reds, because the alternative -- reading an unavailable base as "no +// package narrowed anything" -- is the vacuous green this clause exists inside +// a gate that already documents that failure mode for GATED. +let announcedRan = false; +{ + const flagAt = process.argv.indexOf('--base'); + const requested = flagAt === -1 ? null : process.argv[flagAt + 1]; + const refuse = (lines) => problems.push({ dir: SELF, name: 'ANNOUNCED base read', lines }); + + let baseRef = null; + if (requested) { + baseRef = resolveCommit(requested); + if (!baseRef) { + refuse([ + `\`--base ${requested}\` does not resolve to a commit in this checkout.`, + 'A base that cannot be resolved is a failure, never a pass (#4690): every package would', + 'read as born-sealed and ANNOUNCED would pass unanimously without comparing anything.', + 'Fix: pass a ref this checkout has, or omit --base to use origin/main.', + ]); + } + } else { + const ref = ['origin/main', 'main'].find((r) => resolveCommit(r)); + baseRef = ref ? resolveCommit(ref) : null; + if (!baseRef) { + refuse([ + 'neither `origin/main` nor `main` resolves in this checkout, so the ANNOUNCED clause has', + 'no base to compare against and would pass every package by default (#4690).', + 'In CI this ref comes from `Lint & Repo Gates` checking out at `fetch-depth: 0`', + '(.github/workflows/lint.yml), which is what makes actions/checkout fetch', + '`+refs/heads/*:refs/remotes/origin/*` so the base branch exists locally at all.', + 'Fix: `git fetch origin main`, or pass one explicitly: --base .', + ]); + } + } + + const mergeBase = baseRef ? mergeBaseOf(baseRef, 'HEAD') : null; + if (baseRef && !mergeBase) { + refuse([ + `\`${requested ?? 'origin/main'}\` and HEAD have no merge base in this checkout, so this`, + 'clause has no trustworthy starting point. Refusing to fall back to the raw base, and', + 'refusing to pass by default (#4690) -- a shallow clone whose graft floor is newer than', + 'the branch point produces exactly this, and answers "nothing narrowed" for every package.', + 'Fix: deepen the checkout (`git fetch --deepen ` / `--unshallow`), or pass a --base', + ' this checkout can reach.', + ]); + } + + const changesets = pendingChangesets(); + if (mergeBase && changesets === null) { + refuse([ + '`.changeset/` is missing or unreadable, and this clause judges changesets -- a tree where', + 'they cannot be read would report every narrowing as unannounced, or (worse) be quietly', + 'lowered to reporting none (#4690).', + 'Fix: restore the directory, or teach this clause where it moved.', + ]); + } + + if (mergeBase && changesets !== null) { + const basePaths = headManifests.map((h) => h.manifestPath); + const baseTexts = showManyOrNull(mergeBase, basePaths); + + const control = baseReadControl({ publishable, foundAtBase: baseTexts.size }); + if (!control.ok) { + problems.push({ dir: SELF, name: 'ANNOUNCED base-read census control', lines: control.lines }); + } else { + announcedRan = true; + for (const { dir, name, manifest, manifestPath } of headManifests) { + const baseText = baseTexts.has(manifestPath) ? baseTexts.get(manifestPath) : null; + const narrowing = narrowingVerdict(baseText, manifest); + if (narrowing.kind === 'unreadable-base') { + problems.push({ + dir, + name, + lines: [ + `has a \`package.json\` at the merge base (${mergeBase.slice(0, 9)}) that does not parse as`, + 'JSON, so whether this PR narrows its resolvable surface cannot be decided. Absence of', + 'an answer is not a pass (#4690).', + ], + }); + continue; + } + if (narrowing.kind !== 'retrofit' && narrowing.kind !== 'removal') continue; + + const announced = announcementVerdict({ + pkg: name, + narrowing, + headExports: manifest.exports, + changesets, + }); + if (announced.satisfied) continue; + + const what = + narrowing.kind === 'retrofit' + ? [ + 'declares an `exports` map that its `package.json` at the merge base did not, so this', + 'package was PUBLISHED without one. Every deep path a consumer reached through it --', + `\`${name}/dist/...\` and anything else outside the new map -- stops resolving on the`, + 'next release, with no deprecation and no error the consumer can act on until they', + 'upgrade (#13662, #15325 — both found after publish, by the consumer).', + ] + : [ + `no longer resolves ${narrowing.lost.map((l) => `"${l}"`).join(', ')}, which its \`exports\` map`, + 'declared at the merge base. A subpath removed from a published map is a break for', + 'whoever imports it, and nothing in this monorepo can observe that: every in-repo', + 'consumer reaches the file through a relative import, a vitest alias or a `paths`', + 'entry, so the packaging contract is only exercised off-repo.', + ]; + + const fix = + announced.reason === 'no-bump' + ? [ + `Fix: add a changeset declaring "${name}": minor, and say in its body which deep paths`, + ' stop resolving. That text ships to consumers as CHANGELOG.md inside the tarball', + ' and is what an upgrading agent greps after ERR_PACKAGE_PATH_NOT_EXPORTED.', + ] + : announced.reason === 'unnamed-removal' + ? [ + `Fix: name the dropped subpath(s) in that changeset's body — ${announced.missing.join(', ')}`, + ` — either as the map key or as \`${name}\`. A minor bump alone does not`, + ' tell a consumer which import to change.', + ] + : [ + `Fix: name at least one deep specifier of \`${name}\` that the new map does NOT resolve`, + ` (e.g. \`${name}/dist/...\`, the shape a consumer actually wrote). The body names`, + ' only paths the map still resolves, which tells a consumer nothing — that is the', + ' boilerplate answer this clause exists to refuse.', + ]; + + problems.push({ + dir, + name, + lines: [ + ...what, + ...fix, + ` If a named out-of-repo consumer imports one of them, ratify it in`, + ' packages/qa/downstream-contract/consumer-specifiers.ledger.json as well (#15589', + ' option A) — that ledger records WHO imports it; this clause only asks that the', + ' narrowing be announced at all.', + ], + }); + } + } + } +} + if (problems.length > 0) { const plural = problems.length === 1 ? 'package publishes' : 'packages publish'; console.error(`✗ check:published-files — ${problems.length} ${plural} the wrong thing (#4248)\n`); @@ -939,5 +1657,9 @@ console.log( `and admits no test, test-harness config or build script; ${withExtras} publish more ` + 'than dist/ + README.md + CHANGELOG.md, each with a registered reason; ' + `${exportsDeclaring} declare an \`exports\` map gating what of that is resolvable ` + - `(census control: floor ${EXPORTS_CENSUS_FLOOR}).`, + `(census control: floor ${EXPORTS_CENSUS_FLOOR}); ` + + (announcedRan + ? 'none narrows its resolvable surface against the merge base without a `minor` changeset ' + + 'naming the deep paths that stop resolving.' + : 'ANNOUNCED DID NOT RUN.'), ); From 79f3ef721cef7703c72be7ab634a5f7e4fc774ec Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 23:33:21 +0000 Subject: [PATCH 2/2] fix(devx): only the changesets a change introduces can announce what it narrows Measured while ablating the clause: a real `./console` subpath removal went GREEN because an unrelated changeset already on main contained the string `./console` in a sentence about a different release. The pending stock is ~1300 files of prose about these same packages, so "some changeset mentions this path" is satisfied by accident constantly -- the boilerplate-answered gate this clause exists to avoid, reached from the other direction. The subject is now the diff: a changeset absent at the merge base, or one whose text differs from its base copy. The removal half also reads the changeset BODY rather than the whole file, since the frontmatter names the package on every changeset and would otherwise let the bump line answer the note. Nothing in the file is exported any more either. `check:entry-guard`'s second rule is that a `scripts/**` file exporting a binding can be imported for it, whereupon this gate's top-level dispatch -- and its `process.exit` -- runs inside the importer; the self-test is in the same module and needs no exports. Claude-Session: https://claude.ai/code/session_01PU9zBGbH2s2ZtxSyu963M3 Co-authored-by: Claude --- scripts/check-published-files.mjs | 79 +++++++++++++++++++++++++++---- 1 file changed, 70 insertions(+), 9 deletions(-) diff --git a/scripts/check-published-files.mjs b/scripts/check-published-files.mjs index 538afeaabe..3f9b1be5cb 100644 --- a/scripts/check-published-files.mjs +++ b/scripts/check-published-files.mjs @@ -190,6 +190,13 @@ import { join, posix, resolve } from 'node:path'; // all of them -- a fifth private copy here would be outside that assertion. The // module is entry-guarded, so importing it runs no gate (its own I1 case). import { parseChangeset } from './check-adr-0087-registration.mjs'; + +// ⛔ Nothing in this file is `export`ed, deliberately. Its top level RUNS the +// gate, and `check:entry-guard`'s second rule is that a `scripts/**` file which +// exports a binding can be imported for it — whereupon this gate's `process.exit` +// lands inside the importer. The self-test below is in the same module and calls +// these helpers directly, so exporting them would buy nothing and owe an entry +// guard around ~200 lines of top-level dispatch. import { readWorkspaceGlobs, selfTest as workspaceEnumeratorSelfTest, @@ -522,7 +529,7 @@ function exportsVerdict(manifest) { * would read as "this map declared nothing", which is a different fact and one * `exportsVerdict` already refuses. */ -export function exportSubpaths(map) { +function exportSubpaths(map) { if (typeof map === 'string' || Array.isArray(map)) return new Set(['.']); if (!map || typeof map !== 'object') return new Set(); const declared = Object.keys(map).filter((k) => k === '.' || k.startsWith('./')); @@ -536,7 +543,7 @@ export function exportSubpaths(map) { * WIDENING and must not read as a removal. One `*`, prefix + suffix, which is * what Node's subpath-patterns are. */ -export function mapResolves(map, subpath) { +function mapResolves(map, subpath) { if (typeof map === 'string' || Array.isArray(map)) return subpath === '.'; if (!map || typeof map !== 'object') return false; const declared = Object.keys(map).filter((k) => k === '.' || k.startsWith('./')); @@ -561,7 +568,7 @@ export function mapResolves(map, subpath) { * @param {object} headManifest the manifest as it reads now * @returns {{ kind: 'born'|'retrofit'|'removal'|'unchanged'|'ungated-head'|'unreadable-base', lost: string[] }} */ -export function narrowingVerdict(baseText, headManifest) { +function narrowingVerdict(baseText, headManifest) { // Cell 1a: no manifest at the base -> the package is born with whatever it // declares. There is no published predecessor, so it seals nobody. if (baseText === null || baseText === undefined) return { kind: 'born', lost: [] }; @@ -607,7 +614,7 @@ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); * The body is prose, so the trailing punctuation a sentence puts after a * specifier (`...console.js`, `` `...` ``) is trimmed off the captured path. */ -export function deepSpecifiersNamed(body, pkg) { +function deepSpecifiersNamed(body, pkg) { const found = new Set(); const re = new RegExp(`${escapeRe(pkg)}\\/([A-Za-z0-9._*-]+(?:\\/[A-Za-z0-9._*-]+)*)`, 'g'); let m; @@ -629,7 +636,7 @@ export function deepSpecifiersNamed(body, pkg) { * * @returns {{ satisfied: boolean, reason: string, missing: string[] }} */ -export function announcementVerdict({ pkg, narrowing, headExports, changesets }) { +function announcementVerdict({ pkg, narrowing, headExports, changesets }) { const bumped = changesets.filter((c) => parseChangeset(c.text).bumps.some((b) => b.pkg === pkg && (b.bump === 'minor' || b.bump === 'major')), ); @@ -640,7 +647,12 @@ export function announcementVerdict({ pkg, narrowing, headExports, changesets }) // reach for: the map key (`./console`) or the specifier (`/console`). const missing = narrowing.lost.filter((sub) => { const asSpecifier = sub === '.' ? pkg : `${pkg}${sub.slice(1)}`; - return !bumped.some((c) => c.text.includes(sub) || c.text.includes(asSpecifier)); + // The BODY, not the whole file: the frontmatter names the package on every + // changeset, so reading `c.text` would let the bump line answer the note. + return !bumped.some((c) => { + const body = parseChangeset(c.text).body; + return body.includes(sub) || body.includes(asSpecifier); + }); }); return missing.length > 0 ? { satisfied: false, reason: 'unnamed-removal', missing } @@ -711,6 +723,25 @@ function showManyOrNull(rev, paths) { return found; } +/** + * Which of the pending changesets did THIS change introduce or edit? + * + * ⚠️ Measured, not assumed: an earlier draft of this clause accepted any pending + * changeset, and a real subpath removal went GREEN because an unrelated + * changeset already on main happened to contain the string `./console` in a + * sentence about a different release. The whole stock is ~1300 files of prose + * about this repo's own packages, so "some changeset somewhere mentions this + * path" is satisfied by accident constantly -- the boilerplate-answered gate + * #15715 exists to avoid, arrived at from the other direction. + * + * The announcement has to come from the change that does the narrowing, so the + * subject is the diff: a changeset absent at the merge base, or one whose text + * differs from its base copy. + */ +function introducedChangesets(all, baseTexts) { + return all.filter((c) => baseTexts.get(c.path) !== c.text); +} + /** Pending changesets in the working tree, or `null` when the directory is unreadable. */ function pendingChangesets() { const dir = join(ROOT, '.changeset'); @@ -737,7 +768,7 @@ function pendingChangesets() { * publishable population, so a read that finds fewer than half of them at the * base did not fail to find new packages: it failed. */ -export function baseReadControl({ publishable, foundAtBase }) { +function baseReadControl({ publishable, foundAtBase }) { if (publishable === 0) return { ok: true, lines: [] }; if (foundAtBase >= Math.ceil(publishable / 2)) return { ok: true, lines: [] }; return { @@ -777,7 +808,7 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'the dispatch-gates declaration (#10542)': 37, 'GATED and its census floor (#12879)': 22, 'ANNOUNCED: the four base-vs-HEAD cells (#15715)': 26, - 'ANNOUNCED: what satisfies the announcement (#15715)': 28, + 'ANNOUNCED: what satisfies the announcement (#15715)': 31, }); // DELETING an entry silences that battery's floor exactly as effectively as @@ -1178,6 +1209,33 @@ function selfTest() { expect(got === expected, `announcementVerdict — ${label}: got "${got}", expected "${expected}"`); } + // Only what THIS change wrote can announce what it narrows. The case below is + // the one that was measured going wrong: an untouched changeset already on + // main, containing the removed subpath in unrelated prose, satisfied a real + // subpath removal and the gate went green. + const stock = [ + { path: '.changeset/untouched.md', text: 'mentions ./console in other prose' }, + { path: '.changeset/edited.md', text: 'new text' }, + { path: '.changeset/added.md', text: 'brand new' }, + ]; + const stockAtBase = new Map([ + ['.changeset/untouched.md', 'mentions ./console in other prose'], + ['.changeset/edited.md', 'the text it had at the base'], + ]); + const introduced = introducedChangesets(stock, stockAtBase).map((c) => c.path).sort(); + expect( + introduced.join(',') === '.changeset/added.md,.changeset/edited.md', + `introducedChangesets returns the ADDED and EDITED ones: got ${introduced.join(',')}`, + ); + expect( + !introduced.includes('.changeset/untouched.md'), + 'an untouched changeset already on main cannot announce this change — it was written about something else, and the stock is ~1300 files of prose about these same packages', + ); + expect( + introducedChangesets([], new Map()).length === 0, + 'introducedChangesets over an empty stock is empty, not everything', + ); + const namedCases = [ ['a plain specifier', '@x/p/dist/a.js', './dist/a.js'], ['trailing sentence punctuation is trimmed', 'see @x/p/dist/a.js, and', './dist/a.js'], @@ -1547,6 +1605,9 @@ let announcedRan = false; if (mergeBase && changesets !== null) { const basePaths = headManifests.map((h) => h.manifestPath); const baseTexts = showManyOrNull(mergeBase, basePaths); + // Only what this change wrote can announce what this change narrows. + const baseChangesets = showManyOrNull(mergeBase, changesets.map((c) => c.path)); + const announcing = introducedChangesets(changesets, baseChangesets); const control = baseReadControl({ publishable, foundAtBase: baseTexts.size }); if (!control.ok) { @@ -1574,7 +1635,7 @@ let announcedRan = false; pkg: name, narrowing, headExports: manifest.exports, - changesets, + changesets: announcing, }); if (announced.satisfied) continue;