278278// instead. The direction is deliberate (projection is a SUBSET of source): a
279279// new entry not yet regenerated is `check:spec-changes`'s red, not this one's,
280280// so the two gates never double-report the same fact.
281- // * CONVENTION ROT -- at least one changeset in the current stock must match the
282- // breaking detector. If `**BREAKING**` / `major` / `feat!:` are ever reworded
283- // wholesale, this gate would match nothing and pass everything in silence.
281+ // * CONVENTION ROT -- a fixed set of synthetic control texts, one per spelling
282+ // `breakingDeclaration()` is specified to match plus shapes it must NOT
283+ // match, is driven through the detector on every invocation. If
284+ // `**BREAKING**` / `major` / `feat!:` are ever reworded wholesale, this gate
285+ // would match nothing and pass everything in silence; the controls surface
286+ // that as detector rot the moment convention and detector diverge. They
287+ // deliberately never read the live stock: "at least one breaking changeset
288+ // in stock" asserted repo PHASE -- false on every PR in the post-release
289+ // window, once `version packages` has consumed the breaking population --
290+ // not detector health (#8658).
284291// * VOCABULARY DRIFT (#8299) -- the categories `CATEGORIES` accepts and the ones
285292// ADR-0087 documents must be the SAME SET, checked both ways. A category this
286293// file accepts that the ADR never described is an exemption an author cannot
@@ -1367,11 +1374,27 @@ export function mergeBase(base, head, cwd) {
13671374 try { return git ( [ 'merge-base' , base , head ] , cwd ) . trim ( ) || null ; } catch { return null ; }
13681375}
13691376
1377+ /**
1378+ * Everything under `.changeset/` at a rev, split into "any entry at all" and
1379+ * "actual changesets", or `null` when the tree itself cannot be listed.
1380+ *
1381+ * The split is what lets `assertInputs` tell an EMPTY STOCK (zero pending
1382+ * changesets — the legitimate state of main right after a release cut, #8658)
1383+ * apart from a MISSING DIRECTORY (not even the tracked README.md/config.json
1384+ * found — unreadable input, still a #4690 refusal).
1385+ *
1386+ * @returns {{ entries: string[], changesets: string[] } | null }
1387+ */
1388+ function changesetDirAt ( rev , cwd ) {
1389+ let out ;
1390+ try { out = git ( [ 'ls-tree' , '-r' , '--name-only' , rev , '--' , '.changeset' ] , cwd ) ; } catch { return null ; }
1391+ const entries = out . split ( '\n' ) . map ( ( s ) => s . trim ( ) ) . filter ( Boolean ) ;
1392+ return { entries, changesets : entries . filter ( isChangesetFile ) } ;
1393+ }
1394+
13701395/** Every changeset path present at a rev. */
13711396function changesetsAt ( rev , cwd ) {
1372- let out ;
1373- try { out = git ( [ 'ls-tree' , '-r' , '--name-only' , rev , '--' , '.changeset' ] , cwd ) ; } catch { return [ ] ; }
1374- return out . split ( '\n' ) . map ( ( s ) => s . trim ( ) ) . filter ( ( p ) => p && isChangesetFile ( p ) ) ;
1397+ return changesetDirAt ( rev , cwd ) ?. changesets ?? [ ] ;
13751398}
13761399
13771400/**
@@ -1406,13 +1429,24 @@ export function ledgerAt(rev, cwd) {
14061429export function assertInputs ( { cwd, head } ) {
14071430 const problems = [ ] ;
14081431
1409- // (1) the subject matter exists at all
1410- const stock = changesetsAt ( head , cwd ) ;
1411- if ( stock . length === 0 ) {
1432+ // (1) the subject matter's DIRECTORY is readable at all. Its POPULATION is
1433+ // repo phase, not an input problem (#8658): right after a release cut,
1434+ // `version packages` has consumed the whole stock, and this gate's
1435+ // subject is the DIFF — a PR that introduces no changeset over an empty
1436+ // stock has nothing to judge and must be clean, not refused. What stays
1437+ // a refusal is the directory itself being missing or unlistable at HEAD
1438+ // (the #4690 posture, kept deliberately): `.changeset/README.md` and
1439+ // `config.json` are tracked, so a rev where `ls-tree` finds NOTHING
1440+ // under `.changeset/` is a rev this gate cannot trust — the directory
1441+ // moved, or the read failed.
1442+ const csDir = changesetDirAt ( head , cwd ) ;
1443+ if ( csDir === null || csDir . entries . length === 0 ) {
14121444 problems . push (
1413- 'no changesets found at HEAD (`.changeset/*.md` is empty or absent).\n' +
1414- ' This gate judges changesets; with none to read it would report success while checking\n' +
1415- ' nothing (#4690). If the changeset directory genuinely moved, this gate moves with it.' ,
1445+ '`.changeset/` is missing or unlistable at HEAD — not even its tracked README.md/config.json\n' +
1446+ ' were found. This gate judges changesets; a rev where their directory cannot be read would\n' +
1447+ ' report success while checking nothing (#4690). If the changeset directory genuinely moved,\n' +
1448+ ' this gate moves with it. (An EMPTY stock is NOT this failure: zero pending changesets is\n' +
1449+ ' the legitimate state of main right after a release cut — #8658.)' ,
14161450 ) ;
14171451 }
14181452
@@ -1478,18 +1512,52 @@ export function assertInputs({ cwd, head }) {
14781512
14791513 // (4) CONVENTION ROT -- if `major` / `**BREAKING` / `feat!:` are ever reworded
14801514 // wholesale, this gate matches nothing and passes everything in silence.
1481- let breakingInStock = 0 ;
1482- for ( const text of showManyOrNull ( head , stock , cwd ) . values ( ) ) {
1483- if ( breakingDeclaration ( parseChangeset ( text ) ) . breaking ) breakingInStock ++ ;
1515+ // Guarded by SYNTHETIC controls, never by the live stock (#8658): "at
1516+ // least one breaking changeset in the real stock" asserted repo PHASE,
1517+ // not detector health -- a release's `version packages` consumes exactly
1518+ // that population, so the old spelling went red on every post-cut PR
1519+ // while proving nothing about this checker. These fixtures pin each
1520+ // spelling `breakingDeclaration()` is specified to match (the three
1521+ // signals its doc comment names, including the #7004 comment/quoting
1522+ // dialects), plus shapes that must NOT match, and they run on EVERY
1523+ // invocation regardless of what the release train last did to
1524+ // `.changeset/`. A wholesale rewording of the convention now surfaces at
1525+ // the moment convention and detector are changed apart: whoever rewords
1526+ // `breakingDeclaration()` must reword these fixtures in the same edit,
1527+ // or this refusal names the divergence.
1528+ const MUST_MATCH_BREAKING = [
1529+ [ 'a `major` frontmatter bump' , "---\n'@objectstack/spec': major\n---\n\nan ordinary summary\n" ] ,
1530+ [ 'a quoted `major` bump with a trailing YAML comment (#7004)' , '---\n"@objectstack/spec": "major" # keep\n---\n\nan ordinary summary\n' ] ,
1531+ [ 'a `**BREAKING**` body marker' , "---\n'@objectstack/spec': minor\n---\n\na summary\n\n**BREAKING**: something changed\n" ] ,
1532+ [ 'a `BREAKING CHANGE:` body line' , "---\n'@objectstack/spec': minor\n---\n\na summary\n\nBREAKING CHANGE: something changed\n" ] ,
1533+ [ 'a conventional-commit `!` summary' , "---\n'@objectstack/spec': patch\n---\n\nfeat(spec)!: drop a key\n" ] ,
1534+ ] ;
1535+ const MUST_NOT_MATCH_BREAKING = [
1536+ [ 'a plain `patch` changeset' , "---\n'@objectstack/spec': patch\n---\n\nfix a typo\n" ] ,
1537+ [ 'a `minor` changeset whose prose merely contains the word breaking' , "---\n'@objectstack/spec': minor\n---\n\nnothing groundbreaking here\n" ] ,
1538+ ] ;
1539+ for ( const [ label , text ] of MUST_MATCH_BREAKING ) {
1540+ if ( ! breakingDeclaration ( parseChangeset ( text ) ) . breaking ) {
1541+ problems . push (
1542+ `CONVENTION ROT: ${ label } no longer matches the breaking-change detector.\n` +
1543+ ' This gate fires on declared-breaking changesets; a detector that misses a documented\n' +
1544+ ' spelling is a no-op reporting success on exactly those changesets (#4690). If the\n' +
1545+ ' breaking-change convention was deliberately reworded, this gate\'s contract changed with\n' +
1546+ ' it -- update breakingDeclaration() and these control fixtures together, rather than\n' +
1547+ ' leaving a green gate that checks nothing.' ,
1548+ ) ;
1549+ }
14841550 }
1485- if ( stock . length > 0 && breakingInStock === 0 ) {
1486- problems . push (
1487- `not one of ${ stock . length } changeset(s) in stock matches the breaking-change detector.\n` +
1488- ' This gate fires on declared-breaking changesets; with zero detectable it is a no-op\n' +
1489- ' reporting success (#4690). If the breaking-change convention was deliberately reworded,\n' +
1490- ' this gate\'s contract changed with it -- update breakingDeclaration() and this assertion\n' +
1491- ' together, rather than leaving a green gate that checks nothing.' ,
1492- ) ;
1551+ for ( const [ label , text ] of MUST_NOT_MATCH_BREAKING ) {
1552+ if ( breakingDeclaration ( parseChangeset ( text ) ) . breaking ) {
1553+ problems . push (
1554+ `CONVENTION ROT (inverted): ${ label } now matches the breaking-change detector.\n` +
1555+ ' An over-matching detector demands an ADR-0087 disposition of authors who declared\n' +
1556+ ' nothing breaking, which teaches them to write markers by rote -- the allow-list decay\n' +
1557+ ' this gate exists to avoid. Narrow breakingDeclaration() and these control fixtures\n' +
1558+ ' together.' ,
1559+ ) ;
1560+ }
14931561 }
14941562
14951563 // (5) VOCABULARY DRIFT (#8299) -- the categories this gate accepts and the ones
@@ -2376,7 +2444,7 @@ function selfTest() {
23762444
23772445 /**
23782446 * Build a two-commit repo: base carries the ledger + a breaking changeset in
2379- * stock (so the convention assertion is satisfied ), head adds `files`.
2447+ * stock (realistic mid-cycle stock ), head adds `files`.
23802448 *
23812449 * `baseFiles` puts extra files on the BASE commit, and a `null` in `files`
23822450 * deletes one at head. Together they are how a RENAME is expressed (#7045):
@@ -2405,7 +2473,9 @@ function selfTest() {
24052473 w ( LEDGER_SOURCES [ 1 ] , CONV ( [ 'a-conversion' ] ) ) ;
24062474 w ( SPEC_CHANGES , SPEC_CHANGES_JSON ( baseIds ) ) ;
24072475 w ( ADR_0087 , ADR_DOC ( ) ) ;
2408- // stock: one declared-breaking changeset so the convention-rot assertion holds
2476+ // stock: one declared-breaking changeset -- realistic mid-cycle stock for
2477+ // the inherited-changeset cases. (No longer needed to satisfy assertInputs:
2478+ // since #8658 its convention-rot control is synthetic and phase-independent.)
24092479 w ( '.changeset/stock-breaking.md' , CS ( { body : 'stock\n\n**BREAKING** something\n' } ) ) ;
24102480 for ( const [ name , p ] of Object . entries ( pkgs ?? { '@objectstack/spec' : { dir : 'packages/spec' , private : false } } ) ) {
24112481 w ( `${ p . dir } /package.json` , JSON . stringify ( { name, version : '1.0.0' , ...( p . private ? { private : true } : { } ) } ) ) ;
@@ -2984,7 +3054,13 @@ function selfTest() {
29843054 assert ( probs . some ( ( p ) => / p a r s e r d r i f t / . test ( p ) && / i n v i s i b l e - t o - t h e - p a r s e r / . test ( p ) ) , `I1: parser rot must be RED, got: ${ probs . join ( '|' ) } ` ) ;
29853055 }
29863056 {
2987- // convention rot: stock exists but nothing in it reads as breaking
3057+ // FLIPPED by #8658: a stock with nothing breaking in it is the legitimate
3058+ // shape of main between a release cut and the next breaking changeset
3059+ // landing -- repo PHASE, not an input problem, so it is GREEN now. The
3060+ // detector-rot guarantee the old red carried moved onto the synthetic
3061+ // controls inside assertInputs, which run here too (and on every other
3062+ // invocation in this self-test): rot breakingDeclaration() and every one
3063+ // of these fixtures refuses, this one included.
29883064 const dir = mkdtempSync ( join ( tmpdir ( ) , 'adr0087-conv-' ) ) ;
29893065 cleanup . push ( dir ) ;
29903066 const w = ( rel , text ) => { mkdirSync ( dirname ( join ( dir , rel ) ) , { recursive : true } ) ; writeFileSync ( join ( dir , rel ) , text ) ; } ;
@@ -2994,10 +3070,71 @@ function selfTest() {
29943070 w ( LEDGER_SOURCES [ 0 ] , REG ( [ 'seen-one' ] ) ) ;
29953071 w ( LEDGER_SOURCES [ 1 ] , CONV ( [ 'a-conversion' ] ) ) ;
29963072 w ( SPEC_CHANGES , SPEC_CHANGES_JSON ( [ 'seen-one' ] ) ) ;
3073+ w ( ADR_0087 , ADR_DOC ( ) ) ;
29973074 w ( '.changeset/quiet.md' , CS ( { bumps : [ [ '@objectstack/spec' , 'patch' ] ] , body : 'nothing breaking here\n' } ) ) ;
29983075 git ( [ 'add' , '-A' ] , dir ) ; git ( [ 'commit' , '-qm' , 'base' ] , dir ) ;
29993076 const probs = assertInputs ( { cwd : dir , head : 'HEAD' } ) ;
3000- assert ( probs . some ( ( p ) => / b r e a k i n g - c h a n g e d e t e c t o r / . test ( p ) ) , `I2: convention rot must be RED, got: ${ probs . join ( '|' ) } ` ) ;
3077+ assert ( probs . length === 0 , `I2 (#8658): a stock with no breaking changeset is repo phase, never an input problem -- got: ${ probs . join ( '|' ) } ` ) ;
3078+ }
3079+ {
3080+ // I2b (#8658): the post-cut window proper -- ZERO changesets in stock, only
3081+ // the tracked README/config. `version packages` produces exactly this state
3082+ // on main, so it must be judged (and, with a diff introducing nothing,
3083+ // found clean), never refused.
3084+ const dir = mkdtempSync ( join ( tmpdir ( ) , 'adr0087-postcut-' ) ) ;
3085+ cleanup . push ( dir ) ;
3086+ const w = ( rel , text ) => { mkdirSync ( dirname ( join ( dir , rel ) ) , { recursive : true } ) ; writeFileSync ( join ( dir , rel ) , text ) ; } ;
3087+ git ( [ 'init' , '-q' , '-b' , 'main' ] , dir ) ;
3088+ git ( [ 'config' , 'user.email' , 't@t' ] , dir ) ;
3089+ git ( [ 'config' , 'user.name' , 't' ] , dir ) ;
3090+ w ( LEDGER_SOURCES [ 0 ] , REG ( [ 'seen-one' ] ) ) ;
3091+ w ( LEDGER_SOURCES [ 1 ] , CONV ( [ 'a-conversion' ] ) ) ;
3092+ w ( SPEC_CHANGES , SPEC_CHANGES_JSON ( [ 'seen-one' ] ) ) ;
3093+ w ( ADR_0087 , ADR_DOC ( ) ) ;
3094+ w ( '.changeset/README.md' , '# Changesets\n\ndocumentation only\n' ) ;
3095+ w ( '.changeset/config.json' , '{}\n' ) ;
3096+ git ( [ 'add' , '-A' ] , dir ) ; git ( [ 'commit' , '-qm' , 'post-cut base' ] , dir ) ;
3097+ const probs = assertInputs ( { cwd : dir , head : 'HEAD' } ) ;
3098+ assert ( probs . length === 0 , `I2b (#8658): an EMPTY stock right after a release cut is a legal repo state -- got: ${ probs . join ( '|' ) } ` ) ;
3099+ const base = git ( [ 'rev-parse' , 'HEAD' ] , dir ) . trim ( ) ;
3100+ const res = scan ( { cwd : dir , base, head : 'HEAD' } ) ;
3101+ assert (
3102+ res . problems . length === 0 && res . judged . length === 0 ,
3103+ `I2b (#8658): ... and a diff introducing no changeset over it scans clean -- got ${ JSON . stringify ( res . problems ) } ` ,
3104+ ) ;
3105+ }
3106+ {
3107+ // I2c: the #4690 posture SURVIVES the #8658 repair -- a rev with no
3108+ // `.changeset/` at all (not even README/config) is unreadable input, and
3109+ // unreadable input is a refusal, never a pass.
3110+ const dir = mkdtempSync ( join ( tmpdir ( ) , 'adr0087-nodir-' ) ) ;
3111+ cleanup . push ( dir ) ;
3112+ const w = ( rel , text ) => { mkdirSync ( dirname ( join ( dir , rel ) ) , { recursive : true } ) ; writeFileSync ( join ( dir , rel ) , text ) ; } ;
3113+ git ( [ 'init' , '-q' , '-b' , 'main' ] , dir ) ;
3114+ git ( [ 'config' , 'user.email' , 't@t' ] , dir ) ;
3115+ git ( [ 'config' , 'user.name' , 't' ] , dir ) ;
3116+ w ( LEDGER_SOURCES [ 0 ] , REG ( [ 'seen-one' ] ) ) ;
3117+ w ( LEDGER_SOURCES [ 1 ] , CONV ( [ 'a-conversion' ] ) ) ;
3118+ w ( SPEC_CHANGES , SPEC_CHANGES_JSON ( [ 'seen-one' ] ) ) ;
3119+ w ( ADR_0087 , ADR_DOC ( ) ) ;
3120+ git ( [ 'add' , '-A' ] , dir ) ; git ( [ 'commit' , '-qm' , 'no changeset dir' ] , dir ) ;
3121+ const probs = assertInputs ( { cwd : dir , head : 'HEAD' } ) ;
3122+ assert (
3123+ probs . some ( ( p ) => / ` \. c h a n g e s e t \/ ` i s m i s s i n g o r u n l i s t a b l e / . test ( p ) ) ,
3124+ `I2c: a missing .changeset directory must still be RED (#4690) -- got: ${ probs . join ( '|' ) } ` ,
3125+ ) ;
3126+ }
3127+ {
3128+ // I2d: the synthetic convention-rot controls run on a repo whose stock DOES
3129+ // contain a breaking changeset too -- they are phase-independent in both
3130+ // directions, so a mid-cycle repo gains no problems from them either.
3131+ // (The rot direction itself -- a broken breakingDeclaration() making the
3132+ // controls refuse -- cannot be staged from here without mutating this
3133+ // module; it is verified by ablation: see the reverse-verification record
3134+ // on the PR that introduced the controls, #8658.)
3135+ const r = mk ( { files : { } } ) ;
3136+ const probs = assertInputs ( { cwd : r . dir , head : 'HEAD' } ) ;
3137+ assert ( probs . length === 0 , `I2d: the synthetic breaking-detector controls add no problems on a healthy detector -- got: ${ probs . join ( '|' ) } ` ) ;
30013138 }
30023139 {
30033140 // a missing ledger is a red, never a skip
0 commit comments