@@ -347,6 +347,32 @@ const ADR_0087 = 'docs/adr/0087-metadata-protocol-upgrade-contract.md';
347347/** `.changeset/README.md` is documentation, never a changeset. */
348348const isChangesetFile = ( p ) => p . startsWith ( '.changeset/' ) && p . endsWith ( '.md' ) && ! p . endsWith ( '/README.md' ) ;
349349
350+ /**
351+ * `.changeset/pre/NAME.md` is a CONSUMED prerelease changeset, not pending stock.
352+ *
353+ * New in `@changesets/cli` v3 (changesets#2190): a pre-mode `changeset version`
354+ * MOVES every changeset it consumes out of `.changeset/` and into
355+ * `.changeset/pre/`, verbatim, instead of leaving it in place and recording it in
356+ * `pre.json` the way v2 did. Measured on a v3 cut of this repo (209 pending
357+ * changesets, 17.0.0 -> 17.1.0-rc.0): all 209 move, git scores every one `R100`,
358+ * and `git ls-tree -r ... -- .changeset` at the cut lists all 209 under `pre/`.
359+ *
360+ * That `-r` is why this predicate exists. `changesetDirAt` below lists the
361+ * directory RECURSIVELY, so from the first v3 cut onwards the entire consumed
362+ * stock of the open RC window would flow into `--list` and `--audit-stock` — two
363+ * surfaces whose whole subject is the PENDING stock ("this gate judges diffs, not
364+ * stock"), and whose numbers a reader takes as the size of the backlog. Every one
365+ * of those changesets was judged by the diff scan on the PR that introduced it;
366+ * re-listing them daily for the length of a window is noise that grows.
367+ *
368+ * WHERE THIS DELIBERATELY DOES NOT APPLY: the enforcing scan. It reads the diff
369+ * through `isChangesetFile`, NOT through this predicate, so a changeset ADDED
370+ * under `.changeset/pre/` in a PR is still judged like any other. Filtering the
371+ * audit surfaces is a readability fix; filtering the verdict would be a bypass,
372+ * and `PRE2` in the self-test pins the difference.
373+ */
374+ const isConsumedPrerelease = ( p ) => p . startsWith ( '.changeset/pre/' ) ;
375+
350376/**
351377 * Split a changeset into its frontmatter bump entries and its body.
352378 *
@@ -1376,24 +1402,36 @@ export function mergeBase(base, head, cwd) {
13761402
13771403/**
13781404 * 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.
1405+ * "actual PENDING changesets", or `null` when the tree itself cannot be listed.
13801406 *
13811407 * The split is what lets `assertInputs` tell an EMPTY STOCK (zero pending
13821408 * changesets — the legitimate state of main right after a release cut, #8658)
13831409 * apart from a MISSING DIRECTORY (not even the tracked README.md/config.json
13841410 * found — unreadable input, still a #4690 refusal).
13851411 *
1412+ * `entries` stays UNFILTERED on purpose: it answers "is this directory readable
1413+ * at all", and a tree whose changesets have all moved into `.changeset/pre/` is a
1414+ * perfectly readable one. Narrowing it would turn the ordinary post-cut state of
1415+ * main into a #4690 refusal — the exact confusion #8658 removed. `changesets` is
1416+ * the narrowed half (see `isConsumedPrerelease`).
1417+ *
13861418 * @returns {{ entries: string[], changesets: string[] } | null }
13871419 */
13881420function changesetDirAt ( rev , cwd ) {
13891421 let out ;
13901422 try { out = git ( [ 'ls-tree' , '-r' , '--name-only' , rev , '--' , '.changeset' ] , cwd ) ; } catch { return null ; }
13911423 const entries = out . split ( '\n' ) . map ( ( s ) => s . trim ( ) ) . filter ( Boolean ) ;
1392- return { entries, changesets : entries . filter ( isChangesetFile ) } ;
1424+ return { entries, changesets : entries . filter ( ( p ) => isChangesetFile ( p ) && ! isConsumedPrerelease ( p ) ) } ;
13931425}
13941426
1395- /** Every changeset path present at a rev. */
1396- function changesetsAt ( rev , cwd ) {
1427+ /**
1428+ * Every PENDING changeset path at a rev — the consumed prerelease stock under
1429+ * `.changeset/pre/` excluded (`isConsumedPrerelease`).
1430+ *
1431+ * Exported for `--self-test` S6/S8: the two consumers below print counts, and a
1432+ * count is the one thing an exit code cannot tell apart from "nothing ran".
1433+ */
1434+ export function changesetsAt ( rev , cwd ) {
13971435 return changesetDirAt ( rev , cwd ) ?. changesets ?? [ ] ;
13981436}
13991437
@@ -3475,6 +3513,63 @@ function selfTest() {
34753513 assert ( cls ( CS ( { body : TABLE_PRESCRIPTION } ) ) === 'residue' , 'S7: THE #6497 SHAPE -- published break + arrowless rewrite TABLE -- is RESIDUE' ) ;
34763514 }
34773515
3516+ // ---- PRE1-PRE3: `.changeset/pre/` is CONSUMED stock, not pending stock -----
3517+ //
3518+ // `@changesets/cli` v3 MOVES every changeset it consumes into `.changeset/pre/`
3519+ // at the cut that consumes it, and `changesetDirAt` lists `.changeset`
3520+ // RECURSIVELY (`ls-tree -r`). Without `isConsumedPrerelease` the entire consumed
3521+ // stock of an open RC window flows into `--list` and `--audit-stock`, two
3522+ // surfaces whose subject is the PENDING backlog. Measured on a real v3 cut of
3523+ // this repo's stock: 209 consumed changesets, every one of them returned by
3524+ // `git ls-tree -r --name-only <cut> -- .changeset`.
3525+ //
3526+ // PRE2 is the half that must NOT hold, and it is why the filter lives in
3527+ // `changesetsAt` rather than in `isChangesetFile`: the enforcing scan reads the
3528+ // diff through `isChangesetFile`, so a changeset ADDED under `.changeset/pre/`
3529+ // by a PR is judged like any other. Filter the verdict instead of the audit and
3530+ // `.changeset/pre/` becomes the place to put a breaking change you do not want
3531+ // read.
3532+ {
3533+ const { dir } = mk ( {
3534+ files : {
3535+ '.changeset/pre/consumed-one.md' : CS ( { body : 'consumed at the last cut\n\n**BREAKING** something\n' } ) ,
3536+ '.changeset/pre/consumed-two.md' : CS ( { body : 'consumed at the last cut\n\n**BREAKING** something else\n' } ) ,
3537+ } ,
3538+ } ) ;
3539+ const stock = changesetsAt ( 'HEAD' , dir ) ;
3540+ assert (
3541+ stock . length === 1 && stock [ 0 ] === '.changeset/stock-breaking.md' ,
3542+ `PRE1: the audit surfaces read PENDING stock only -- two consumed changesets under .changeset/pre/ must not appear -- got ${ JSON . stringify ( stock ) } ` ,
3543+ ) ;
3544+ }
3545+
3546+ red ( 'PRE2 a breaking changeset ADDED under .changeset/pre/ is still judged' , run ( mk ( {
3547+ files : { '.changeset/pre/smuggled.md' : SIX048 } ,
3548+ pkgs : { '@objectstack/runtime' : { dir : 'packages/runtime' , private : false } , '@objectstack/spec' : { dir : 'packages/spec' , private : false } } ,
3549+ } ) ) , [ / s m u g g l e d / , / n o ` a d r - 0 0 8 7 : ` d i s p o s i t i o n m a r k e r / ] ) ;
3550+
3551+ {
3552+ // PRE3: `entries` stays UNFILTERED. A tree whose changesets have all moved
3553+ // into `.changeset/pre/` is the ordinary state of main mid-window; narrowing
3554+ // the readability probe as well would turn it into a #4690 refusal and undo
3555+ // #8658 by a different route. Pending stock is legitimately zero there.
3556+ const { dir } = mk ( {
3557+ files : {
3558+ '.changeset/stock-breaking.md' : null ,
3559+ '.changeset/pre/consumed.md' : CS ( { body : 'consumed\n\n**BREAKING** something\n' } ) ,
3560+ } ,
3561+ } ) ;
3562+ const probs = assertInputs ( { cwd : dir , head : 'HEAD' } ) ;
3563+ assert (
3564+ probs . length === 0 ,
3565+ `PRE3: a tree holding ONLY consumed changesets under .changeset/pre/ is readable input, not a #4690 refusal -- got: ${ probs . join ( '|' ) } ` ,
3566+ ) ;
3567+ assert (
3568+ changesetsAt ( 'HEAD' , dir ) . length === 0 ,
3569+ `PRE3: ...and its PENDING stock is legitimately zero -- got ${ JSON . stringify ( changesetsAt ( 'HEAD' , dir ) ) } ` ,
3570+ ) ;
3571+ }
3572+
34783573 assert ( breakingDeclaration ( parseChangeset ( CS ( { body : 'feat(spec)!: x\n' } ) ) ) . breaking , 'P6: a conventional-commit bang is a declaration' ) ;
34793574 assert ( ! breakingDeclaration ( parseChangeset ( CS ( { bumps : [ [ 'a' , 'patch' ] ] , body : 'plain\n' } ) ) ) . breaking , 'P7: a plain patch is not' ) ;
34803575
0 commit comments