@@ -986,6 +986,194 @@ function list(cwd, head) {
986986 console . log ( 'Every one of the above is EXEMPT for any PR that does not touch it -- this gate judges diffs, not stock.' ) ;
987987}
988988
989+ // ---------------------------------------------------------------------------
990+ // `--audit-stock` -- the ONE-OFF backfill audit (#6350)
991+ //
992+ // ## What this is, and what it is deliberately NOT
993+ //
994+ // The gate above is FORWARD-ONLY: it judges the PR's own diff, for the #6129
995+ // reason its sibling `check-empty-changeset.mjs` documents at length. That
996+ // direction is correct and this mode does not overturn it -- judging the stock on
997+ // every PR would turn the whole repo red on adoption day and would hold the
998+ // current author answerable for changesets that landed on main months before their
999+ // branch existed.
1000+ //
1001+ // The cost of forward-only is that the STOCK was never compared even once. At the
1002+ // time #6350 was filed the v17 train carried 227 declared-breaking changesets, none
1003+ // of which any version of this logic had ever looked at, and hand-sampling had
1004+ // already turned up 2 suspected ledger misses of exactly the #6011 shape. This mode
1005+ // runs the gate's OWN judging functions -- `breakingDeclaration`, `readDisposition`,
1006+ // `findMigrationPrescription`, `workspacePackagesAt` -- once over that stock, so the
1007+ // audit cannot drift from the gate: there is no second copy of the logic here, only
1008+ // a different population fed to it.
1009+ //
1010+ // It is NOT wired into CI, and must not be. `package.json`'s
1011+ // `check:adr-0087-registration` and the Check Changeset workflow both invoke the
1012+ // gate with no flag; this mode is reachable only by typing it. It is `--list`'s
1013+ // neighbour -- a standing audit surface an operator runs on purpose -- not a
1014+ // second gate.
1015+ //
1016+ // ## How it narrows 238 rows to a hand-checkable residue
1017+ //
1018+ // A stock changeset carries no disposition marker (nothing ever asked it for one),
1019+ // so replaying the gate verbatim would report every one of them and say nothing.
1020+ // What is informative is which disposition the changeset WOULD have been ENTITLED
1021+ // to, judged mechanically:
1022+ //
1023+ // answered -- it already carries a marker (landed after the gate).
1024+ // exempt-unpublished -- every package it bumps is `private: true`, so nothing
1025+ // it breaks reaches a consumer. Fully mechanical.
1026+ // exempt-no-prescription-- its body frames no rewrite, so the catch-all is open
1027+ // to it. This is the gate's own contradiction check,
1028+ // run in the direction that grants rather than refuses.
1029+ // RESIDUE -- a published break whose body ships instructions for
1030+ // rewriting a consumer's code. Both mechanical
1031+ // exemptions are closed to it, so the only honest
1032+ // dispositions left are `registered` /
1033+ // `already-registered` -- and BOTH require a ledger
1034+ // entry that covers the surface. Whether one does is a
1035+ // judgment about meaning, which no script makes.
1036+ //
1037+ // For each residue row the audit adds the one further MECHANICAL fact that
1038+ // separates "the author registered it" from "nobody ever asked": did any commit
1039+ // that touched this changeset also touch an ADR-0087 registry source? That is the
1040+ // signal #6350's hand-sampling used, computed for the whole population instead of
1041+ // three spot checks.
1042+ //
1043+ // The output is a worklist for a human, not a verdict. It exits 0 whatever it
1044+ // finds -- a non-zero exit here would be a gate, and this is not one.
1045+ // ---------------------------------------------------------------------------
1046+
1047+ /**
1048+ * Did any commit that touched `path` also touch an ADR-0087 registry source?
1049+ *
1050+ * Every commit in the changeset's history is considered, not just the one that
1051+ * added it: a changeset can be edited into breaking after the fact, and the
1052+ * registration may ride either commit. Cheap because it only runs on the residue.
1053+ *
1054+ * ⚠️ Unusable in a SHALLOW clone, and the failure is silent-and-plausible rather
1055+ * than loud, so it is refused rather than approximated. In a graft-truncated
1056+ * history every pre-graft file reads as "added by the graft commit", and that
1057+ * commit contains the whole tree -- including both ledger sources. Measured on the
1058+ * default CI-shaped checkout of this repo (104 commits, grafted at 2bc187641,
1059+ * 5978 files in that one commit): 91 of 92 residue rows came back "the author
1060+ * registered something", every one of them wrong, and the answer looks entirely
1061+ * reasonable on the page. `git fetch --unshallow` (a `--filter=blob:none` partial
1062+ * fetch is enough -- this reads trees, never blobs) is the fix.
1063+ *
1064+ * @returns {{ available: boolean, shas: string[] } }
1065+ */
1066+ function ledgerTouchingCommits ( path , head , cwd ) {
1067+ let shas ;
1068+ try {
1069+ shas = git ( [ 'log' , '--format=%h' , head , '--' , path ] , cwd ) . split ( '\n' ) . map ( ( s ) => s . trim ( ) ) . filter ( Boolean ) ;
1070+ } catch { return { available : true , shas : [ ] } ; }
1071+ const hits = [ ] ;
1072+ for ( const sha of shas ) {
1073+ let names ;
1074+ try { names = git ( [ 'show' , '--name-only' , '--format=' , sha ] , cwd ) ; } catch { continue ; }
1075+ if ( LEDGER_SOURCES . some ( ( src ) => names . includes ( src ) ) ) hits . push ( sha ) ;
1076+ }
1077+ return { available : true , shas : hits } ;
1078+ }
1079+
1080+ /** Is this checkout shallow? A shallow history makes the ledger-touch signal a lie. */
1081+ function isShallow ( cwd ) {
1082+ try { return git ( [ 'rev-parse' , '--is-shallow-repository' ] , cwd ) . trim ( ) === 'true' ; } catch { return false ; }
1083+ }
1084+
1085+ /**
1086+ * Classify one stock changeset by which disposition it would be ENTITLED to.
1087+ *
1088+ * Exported so the self-test can pin it: this classifier decides which rows a human
1089+ * ever looks at, so a silent widening of `exempt-*` would shrink the worklist
1090+ * invisibly -- the #4690 failure mode wearing an audit's clothes.
1091+ *
1092+ * @param {ReturnType<typeof parseChangeset> } parsed
1093+ * @param {Map<string, {private: boolean, file: string}> } pkgs
1094+ * @returns {{ klass: 'answered'|'exempt-unpublished'|'exempt-no-prescription'|'residue', detail?: string, prescription?: ReturnType<typeof findMigrationPrescription> } }
1095+ */
1096+ export function classifyStockChangeset ( parsed , pkgs ) {
1097+ const d = readDisposition ( parsed . body ) ;
1098+ if ( d . ok ) {
1099+ return { klass : 'answered' , detail : d . verdict === 'registered' ? `registered ${ d . ids . join ( ',' ) } ` : `not-required (${ d . category } )` } ;
1100+ }
1101+ // `unpublished` first: a private package ships nothing, so the prescription in
1102+ // its body reaches no consumer of a published artifact and the question the
1103+ // ledger answers does not arise. A changeset declaring NO package cannot claim
1104+ // it -- the gate refuses that too ("nothing to verify").
1105+ if ( parsed . bumps . length > 0 && parsed . bumps . every ( ( b ) => pkgs . get ( b . pkg ) ?. private === true ) ) {
1106+ return { klass : 'exempt-unpublished' , detail : parsed . bumps . map ( ( b ) => b . pkg ) . join ( ', ' ) } ;
1107+ }
1108+ const prescription = findMigrationPrescription ( parsed . body ) ;
1109+ if ( ! prescription ) return { klass : 'exempt-no-prescription' } ;
1110+ return { klass : 'residue' , prescription } ;
1111+ }
1112+
1113+ /** `--audit-stock`: run the gate's judging logic once over the whole stock (#6350). */
1114+ function auditStock ( cwd , head ) {
1115+ const stock = changesetsAt ( head , cwd ) ;
1116+ const texts = showManyOrNull ( head , stock , cwd ) ;
1117+ const pkgs = workspacePackagesAt ( head , cwd ) ;
1118+
1119+ const buckets = { answered : [ ] , 'exempt-unpublished' : [ ] , 'exempt-no-prescription' : [ ] , residue : [ ] } ;
1120+ let breaking = 0 ;
1121+ for ( const path of stock ) {
1122+ const text = texts . get ( path ) ;
1123+ if ( text === undefined ) continue ;
1124+ const parsed = parseChangeset ( text ) ;
1125+ const decl = breakingDeclaration ( parsed ) ;
1126+ if ( ! decl . breaking ) continue ;
1127+ breaking ++ ;
1128+ const c = classifyStockChangeset ( parsed , pkgs ) ;
1129+ buckets [ c . klass ] . push ( { path, signals : decl . signals . join ( '+' ) , ...c } ) ;
1130+ }
1131+
1132+ console . log ( `ADR-0087 stock backfill audit (#6350) -- ${ stock . length } changeset(s) in stock, ${ breaking } declared breaking.\n` ) ;
1133+ for ( const [ klass , label ] of [
1134+ [ 'answered' , 'ALREADY ANSWERED -- carries an adr-0087 marker (landed after the gate)' ] ,
1135+ [ 'exempt-unpublished' , 'EXEMPT (unpublished) -- every bumped package is private; nothing ships' ] ,
1136+ [ 'exempt-no-prescription' , 'EXEMPT (no-migration-prescription) -- the body frames no rewrite' ] ,
1137+ ] ) {
1138+ console . log ( `${ label } : ${ buckets [ klass ] . length } ` ) ;
1139+ for ( const r of buckets [ klass ] ) {
1140+ if ( klass !== 'exempt-no-prescription' ) console . log ( ` ${ r . path } ${ r . detail ? ` [${ r . detail } ]` : '' } ` ) ;
1141+ }
1142+ console . log ( '' ) ;
1143+ }
1144+
1145+ console . log ( `RESIDUE -- published break + migration prescription, so only \`registered\` /` ) ;
1146+ console . log ( `\`already-registered\` remain, and both need a ledger entry: ${ buckets . residue . length } \n` ) ;
1147+ const shallow = isShallow ( cwd ) ;
1148+ if ( shallow ) {
1149+ console . log (
1150+ ' ⚠️ SHALLOW CLONE -- the ledger-touch signal is SUPPRESSED, not approximated. Every pre-graft\n' +
1151+ ' file reads as "added by the graft commit", which carries the whole tree including both\n' +
1152+ ' ledger sources, so the signal would report a plausible-looking "registered" for almost\n' +
1153+ ' every row and be wrong on almost every row. Run `git fetch --unshallow --filter=blob:none`\n' +
1154+ ' (trees are enough; no blobs are read) and re-run.\n' ,
1155+ ) ;
1156+ }
1157+ for ( const r of buckets . residue ) {
1158+ const touching = shallow ? null : ledgerTouchingCommits ( r . path , head , cwd ) ;
1159+ const mark = touching === null ? '?' : touching . shas . length ? '~' : '!' ;
1160+ console . log ( ` ${ mark } ${ r . path } [${ r . signals } ]` ) ;
1161+ console . log ( ` prescription (${ r . prescription . branch } ): ${ r . prescription . line . slice ( 0 , 120 ) } ` ) ;
1162+ if ( touching === null ) {
1163+ console . log ( ' ledger-touch: UNAVAILABLE (shallow clone)' ) ;
1164+ } else if ( touching . shas . length ) {
1165+ console . log ( ` ledger touched by: ${ touching . shas . join ( ', ' ) } -- the author did register something; check it COVERS this surface` ) ;
1166+ } else {
1167+ console . log ( ' ledger NEVER touched by any commit that touched this changeset -- the #6011 shape' ) ;
1168+ }
1169+ }
1170+ console . log (
1171+ '\nThis is a WORKLIST, not a verdict: `!` rows are candidates for a missing registration, and only a\n' +
1172+ 'reader deciding what the surface means can say whether one is owed. This mode exits 0 whatever it\n' +
1173+ 'finds -- it audits stock, which the gate deliberately never does (#6129).' ,
1174+ ) ;
1175+ }
1176+
9891177// ---------------------------------------------------------------------------
9901178// Self-test -- pins the RED paths so the gate cannot rot into a no-op.
9911179//
@@ -1321,6 +1509,44 @@ function selfTest() {
13211509 assert ( findMigrationPrescription ( '### 迁移:FROM → TO\n' ) ?. branch === 'from-to-label' , 'P20: the placeholder label reports the label branch' ) ;
13221510 assert ( findMigrationPrescription ( 'nothing here\n' ) === null , 'P21: a body with no prescription reports null, not a shrug' ) ;
13231511
1512+ // ---- S1-S5: the `--audit-stock` classifier (#6350) ------------------------
1513+ //
1514+ // The stock audit's classifier decides which rows a human ever reads, so a
1515+ // silent widening of either `exempt-*` arm shrinks the worklist invisibly --
1516+ // #4690's failure mode wearing an audit's clothes. These pin all four arms plus
1517+ // the one precedence rule between them. They live in the self-test (which CI
1518+ // runs) even though the audit itself is operator-invoked, because the classifier
1519+ // reuses the gate's judging functions and would rot with them.
1520+ {
1521+ const PKGS = new Map ( [
1522+ [ '@objectstack/spec' , { private : false , file : 'packages/spec/package.json' } ] ,
1523+ [ '@objectstack/example-showcase' , { private : true , file : 'examples/showcase/package.json' } ] ,
1524+ ] ) ;
1525+ const cls = ( text ) => classifyStockChangeset ( parseChangeset ( text ) , PKGS ) . klass ;
1526+ const PRESCRIPTION = '**BREAKING** x\n\n## 迁移\n\n- `a.b` → `a.c`\n' ;
1527+ assert (
1528+ cls ( CS ( { body : `${ PRESCRIPTION } \n<!-- adr-0087: not-required (unpublished) whatever it says, a marker means the question was answered -->\n` } ) ) === 'answered' ,
1529+ 'S1: a changeset carrying a parseable marker is ANSWERED, whatever else it contains' ,
1530+ ) ;
1531+ assert (
1532+ cls ( CS ( { bumps : [ [ '@objectstack/example-showcase' , 'major' ] ] , body : PRESCRIPTION } ) ) === 'exempt-unpublished' ,
1533+ 'S2: an all-private bump is exempt even carrying a prescription -- nothing it breaks ships' ,
1534+ ) ;
1535+ assert (
1536+ cls ( CS ( { body : '**BREAKING** an internal error string changed; no key or symbol moves\n' } ) ) === 'exempt-no-prescription' ,
1537+ 'S3: a published break that frames no rewrite is exempt' ,
1538+ ) ;
1539+ assert ( cls ( CS ( { body : PRESCRIPTION } ) ) === 'residue' , 'S4: THE #6011 SHAPE -- published break + prescription -- is RESIDUE' ) ;
1540+ assert (
1541+ cls ( `---\n---\n\n${ PRESCRIPTION } ` ) === 'residue' ,
1542+ 'S5: a changeset declaring NO package cannot buy the unpublished exemption (the gate refuses it too)' ,
1543+ ) ;
1544+ assert (
1545+ cls ( CS ( { bumps : [ [ '@objectstack/spec' , 'major' ] , [ '@objectstack/example-showcase' , 'major' ] ] , body : PRESCRIPTION } ) ) === 'residue' ,
1546+ 'S6: ONE published package among the bumps is enough to close the unpublished exemption' ,
1547+ ) ;
1548+ }
1549+
13241550 assert ( breakingDeclaration ( parseChangeset ( CS ( { body : 'feat(spec)!: x\n' } ) ) ) . breaking , 'P6: a conventional-commit bang is a declaration' ) ;
13251551 assert ( ! breakingDeclaration ( parseChangeset ( CS ( { bumps : [ [ 'a' , 'patch' ] ] , body : 'plain\n' } ) ) ) . breaking , 'P7: a plain patch is not' ) ;
13261552 assert ( extractIds ( " id: 'object-titleFormat-to-nameField',\n" ) . length === 1 , 'P8: an id with a capital letter must be extracted' ) ;
@@ -1349,6 +1575,8 @@ if (argv.includes('--self-test')) {
13491575 selfTest ( ) ;
13501576} else if ( argv . includes ( '--list' ) ) {
13511577 list ( REPO_ROOT , 'HEAD' ) ;
1578+ } else if ( argv . includes ( '--audit-stock' ) ) {
1579+ auditStock ( REPO_ROOT , readFlag ( '--head' ) ?? 'HEAD' ) ;
13521580} else {
13531581 const head = readFlag ( '--head' ) ?? 'HEAD' ;
13541582 const requested = readFlag ( '--base' ) ;
0 commit comments