Skip to content

Commit 79f3ef7

Browse files
committed
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 <noreply@anthropic.com>
1 parent 02d6240 commit 79f3ef7

1 file changed

Lines changed: 70 additions & 9 deletions

File tree

scripts/check-published-files.mjs

Lines changed: 70 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,13 @@ import { join, posix, resolve } from 'node:path';
190190
// all of them -- a fifth private copy here would be outside that assertion. The
191191
// module is entry-guarded, so importing it runs no gate (its own I1 case).
192192
import { parseChangeset } from './check-adr-0087-registration.mjs';
193+
194+
// ⛔ Nothing in this file is `export`ed, deliberately. Its top level RUNS the
195+
// gate, and `check:entry-guard`'s second rule is that a `scripts/**` file which
196+
// exports a binding can be imported for it — whereupon this gate's `process.exit`
197+
// lands inside the importer. The self-test below is in the same module and calls
198+
// these helpers directly, so exporting them would buy nothing and owe an entry
199+
// guard around ~200 lines of top-level dispatch.
193200
import {
194201
readWorkspaceGlobs,
195202
selfTest as workspaceEnumeratorSelfTest,
@@ -522,7 +529,7 @@ function exportsVerdict(manifest) {
522529
* would read as "this map declared nothing", which is a different fact and one
523530
* `exportsVerdict` already refuses.
524531
*/
525-
export function exportSubpaths(map) {
532+
function exportSubpaths(map) {
526533
if (typeof map === 'string' || Array.isArray(map)) return new Set(['.']);
527534
if (!map || typeof map !== 'object') return new Set();
528535
const declared = Object.keys(map).filter((k) => k === '.' || k.startsWith('./'));
@@ -536,7 +543,7 @@ export function exportSubpaths(map) {
536543
* WIDENING and must not read as a removal. One `*`, prefix + suffix, which is
537544
* what Node's subpath-patterns are.
538545
*/
539-
export function mapResolves(map, subpath) {
546+
function mapResolves(map, subpath) {
540547
if (typeof map === 'string' || Array.isArray(map)) return subpath === '.';
541548
if (!map || typeof map !== 'object') return false;
542549
const declared = Object.keys(map).filter((k) => k === '.' || k.startsWith('./'));
@@ -561,7 +568,7 @@ export function mapResolves(map, subpath) {
561568
* @param {object} headManifest the manifest as it reads now
562569
* @returns {{ kind: 'born'|'retrofit'|'removal'|'unchanged'|'ungated-head'|'unreadable-base', lost: string[] }}
563570
*/
564-
export function narrowingVerdict(baseText, headManifest) {
571+
function narrowingVerdict(baseText, headManifest) {
565572
// Cell 1a: no manifest at the base -> the package is born with whatever it
566573
// declares. There is no published predecessor, so it seals nobody.
567574
if (baseText === null || baseText === undefined) return { kind: 'born', lost: [] };
@@ -607,7 +614,7 @@ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
607614
* The body is prose, so the trailing punctuation a sentence puts after a
608615
* specifier (`...console.js`, `` `...` ``) is trimmed off the captured path.
609616
*/
610-
export function deepSpecifiersNamed(body, pkg) {
617+
function deepSpecifiersNamed(body, pkg) {
611618
const found = new Set();
612619
const re = new RegExp(`${escapeRe(pkg)}\\/([A-Za-z0-9._*-]+(?:\\/[A-Za-z0-9._*-]+)*)`, 'g');
613620
let m;
@@ -629,7 +636,7 @@ export function deepSpecifiersNamed(body, pkg) {
629636
*
630637
* @returns {{ satisfied: boolean, reason: string, missing: string[] }}
631638
*/
632-
export function announcementVerdict({ pkg, narrowing, headExports, changesets }) {
639+
function announcementVerdict({ pkg, narrowing, headExports, changesets }) {
633640
const bumped = changesets.filter((c) =>
634641
parseChangeset(c.text).bumps.some((b) => b.pkg === pkg && (b.bump === 'minor' || b.bump === 'major')),
635642
);
@@ -640,7 +647,12 @@ export function announcementVerdict({ pkg, narrowing, headExports, changesets })
640647
// reach for: the map key (`./console`) or the specifier (`<pkg>/console`).
641648
const missing = narrowing.lost.filter((sub) => {
642649
const asSpecifier = sub === '.' ? pkg : `${pkg}${sub.slice(1)}`;
643-
return !bumped.some((c) => c.text.includes(sub) || c.text.includes(asSpecifier));
650+
// The BODY, not the whole file: the frontmatter names the package on every
651+
// changeset, so reading `c.text` would let the bump line answer the note.
652+
return !bumped.some((c) => {
653+
const body = parseChangeset(c.text).body;
654+
return body.includes(sub) || body.includes(asSpecifier);
655+
});
644656
});
645657
return missing.length > 0
646658
? { satisfied: false, reason: 'unnamed-removal', missing }
@@ -711,6 +723,25 @@ function showManyOrNull(rev, paths) {
711723
return found;
712724
}
713725

726+
/**
727+
* Which of the pending changesets did THIS change introduce or edit?
728+
*
729+
* ⚠️ Measured, not assumed: an earlier draft of this clause accepted any pending
730+
* changeset, and a real subpath removal went GREEN because an unrelated
731+
* changeset already on main happened to contain the string `./console` in a
732+
* sentence about a different release. The whole stock is ~1300 files of prose
733+
* about this repo's own packages, so "some changeset somewhere mentions this
734+
* path" is satisfied by accident constantly -- the boilerplate-answered gate
735+
* #15715 exists to avoid, arrived at from the other direction.
736+
*
737+
* The announcement has to come from the change that does the narrowing, so the
738+
* subject is the diff: a changeset absent at the merge base, or one whose text
739+
* differs from its base copy.
740+
*/
741+
function introducedChangesets(all, baseTexts) {
742+
return all.filter((c) => baseTexts.get(c.path) !== c.text);
743+
}
744+
714745
/** Pending changesets in the working tree, or `null` when the directory is unreadable. */
715746
function pendingChangesets() {
716747
const dir = join(ROOT, '.changeset');
@@ -737,7 +768,7 @@ function pendingChangesets() {
737768
* publishable population, so a read that finds fewer than half of them at the
738769
* base did not fail to find new packages: it failed.
739770
*/
740-
export function baseReadControl({ publishable, foundAtBase }) {
771+
function baseReadControl({ publishable, foundAtBase }) {
741772
if (publishable === 0) return { ok: true, lines: [] };
742773
if (foundAtBase >= Math.ceil(publishable / 2)) return { ok: true, lines: [] };
743774
return {
@@ -777,7 +808,7 @@ const SELF_TEST_BATTERIES = Object.freeze({
777808
'the dispatch-gates declaration (#10542)': 37,
778809
'GATED and its census floor (#12879)': 22,
779810
'ANNOUNCED: the four base-vs-HEAD cells (#15715)': 26,
780-
'ANNOUNCED: what satisfies the announcement (#15715)': 28,
811+
'ANNOUNCED: what satisfies the announcement (#15715)': 31,
781812
});
782813

783814
// DELETING an entry silences that battery's floor exactly as effectively as
@@ -1178,6 +1209,33 @@ function selfTest() {
11781209
expect(got === expected, `announcementVerdict — ${label}: got "${got}", expected "${expected}"`);
11791210
}
11801211

1212+
// Only what THIS change wrote can announce what it narrows. The case below is
1213+
// the one that was measured going wrong: an untouched changeset already on
1214+
// main, containing the removed subpath in unrelated prose, satisfied a real
1215+
// subpath removal and the gate went green.
1216+
const stock = [
1217+
{ path: '.changeset/untouched.md', text: 'mentions ./console in other prose' },
1218+
{ path: '.changeset/edited.md', text: 'new text' },
1219+
{ path: '.changeset/added.md', text: 'brand new' },
1220+
];
1221+
const stockAtBase = new Map([
1222+
['.changeset/untouched.md', 'mentions ./console in other prose'],
1223+
['.changeset/edited.md', 'the text it had at the base'],
1224+
]);
1225+
const introduced = introducedChangesets(stock, stockAtBase).map((c) => c.path).sort();
1226+
expect(
1227+
introduced.join(',') === '.changeset/added.md,.changeset/edited.md',
1228+
`introducedChangesets returns the ADDED and EDITED ones: got ${introduced.join(',')}`,
1229+
);
1230+
expect(
1231+
!introduced.includes('.changeset/untouched.md'),
1232+
'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',
1233+
);
1234+
expect(
1235+
introducedChangesets([], new Map()).length === 0,
1236+
'introducedChangesets over an empty stock is empty, not everything',
1237+
);
1238+
11811239
const namedCases = [
11821240
['a plain specifier', '@x/p/dist/a.js', './dist/a.js'],
11831241
['trailing sentence punctuation is trimmed', 'see @x/p/dist/a.js, and', './dist/a.js'],
@@ -1547,6 +1605,9 @@ let announcedRan = false;
15471605
if (mergeBase && changesets !== null) {
15481606
const basePaths = headManifests.map((h) => h.manifestPath);
15491607
const baseTexts = showManyOrNull(mergeBase, basePaths);
1608+
// Only what this change wrote can announce what this change narrows.
1609+
const baseChangesets = showManyOrNull(mergeBase, changesets.map((c) => c.path));
1610+
const announcing = introducedChangesets(changesets, baseChangesets);
15501611

15511612
const control = baseReadControl({ publishable, foundAtBase: baseTexts.size });
15521613
if (!control.ok) {
@@ -1574,7 +1635,7 @@ let announcedRan = false;
15741635
pkg: name,
15751636
narrowing,
15761637
headExports: manifest.exports,
1577-
changesets,
1638+
changesets: announcing,
15781639
});
15791640
if (announced.satisfied) continue;
15801641

0 commit comments

Comments
 (0)