@@ -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).
192192import { 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.
193200import {
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. */
715746function 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