77 *
88 * node scripts/check-system-context-census.mjs
99 * node scripts/check-system-context-census.mjs --self-test
10- * node scripts/check-system-context-census.mjs --fix # nothing to repair -- see below
10+ * node scripts/check-system-context-census.mjs --fix # regenerates declared COUNTS; anchors still need a human -- see below
1111 *
1212 * That page declares itself "the authority" for every platform behaviour keyed off
1313 * `ExecutionContext.isSystem`, and says it is "built by census over the whole repo,
@@ -252,7 +252,7 @@ const SELF_TEST_BATTERIES = Object.freeze({
252252 'the same criterion, behaviourally, on one page' : 3 ,
253253 '⛔ and the half that must NOT have moved: the contract still reds' : 2 ,
254254 'absence is loud' : 1 ,
255- '⛔ --fix rewrites NOTHING, and says so' : 2 ,
255+ '⛔ --fix regenerates declared COUNTS only, never anchors, and says so' : 8 ,
256256 '⭐ THE RULED RED-FIRST PAIR: a symbol rename REDS, a pure line move does NOT' : 5 ,
257257 '⭐ the precision this trades away, pinned so nobody rediscovers it as a bug' : 2 ,
258258 'the refusal has to SHOW its work (both counts, the symbol, the file)' : 2 ,
@@ -1075,6 +1075,73 @@ export const DECLARED_COUNTS = [
10751075 } ,
10761076] ;
10771077
1078+ /**
1079+ * The WRITE half of `DECLARED_COUNTS` (#16919 direction 1).
1080+ *
1081+ * `evaluate()`'s COUNTS check (below) already computes, for every declared
1082+ * sentence, exactly the value it should hold — `declared.value(census, page)`
1083+ * — and only ever uses it to compare. Two branches independently hand-typing
1084+ * the same freshly-computed number into the same sentence text-merge clean and
1085+ * silently wrong on the SUM: main and a sibling PR each bumped the same seven
1086+ * sentences 106 → 107 for two different new reads, and the merged tree held
1087+ * 108. The fix in that shape is never "type the right number" — it is "stop
1088+ * typing it": this function performs the identical `pattern`/`value` lookup
1089+ * COUNTS already runs, and writes the result back instead of only comparing.
1090+ *
1091+ * Pure — a text in, a text out, plus what changed and what could not be
1092+ * derived. The caller decides whether to persist `text` or to refuse on a
1093+ * non-empty `errors`; nothing here touches the filesystem, so `--self-test`
1094+ * can drive it on a fixture exactly as it drives `evaluate()`.
1095+ *
1096+ * ⛔ Deliberately narrow, same rule as `check:generated --fix` elsewhere in
1097+ * this repo (see AGENTS.md, "Touched `packages/spec`?"): only a MISMATCHED
1098+ * count is rewritten, so a page where every count already agrees with the
1099+ * census comes back byte-identical — idempotent by construction, not merely
1100+ * in practice (`--self-test`'s IDEMPOTENCE case proves it on the real page).
1101+ * `UNENFORCED_TEXT_COUNTS` is deliberately NOT in scope: those six rows count
1102+ * whole-corpus TEXT, not the elevation contract, and are unenforced by design
1103+ * (see that export) — regenerating them would silently start enforcing a
1104+ * population this gate has already measured and rejected as noise.
1105+ *
1106+ * @param {{ pageText: string, census: object, declaredCounts?: typeof DECLARED_COUNTS } } args
1107+ * @returns {{ text: string, rewrites: {id: string, from: string, to: string}[], errors: string[] } }
1108+ */
1109+ export function regenerateDeclaredCounts ( { pageText, census, declaredCounts = DECLARED_COUNTS } ) {
1110+ let text = pageText ;
1111+ const rewrites = [ ] ;
1112+ const errors = [ ] ;
1113+ for ( const declared of declaredCounts ) {
1114+ // `d` adds match INDICES (Node >=16) so only the captured digits are
1115+ // spliced out -- never the surrounding sentence, which stays hand-written
1116+ // prose and must survive byte-for-byte on every run that changes nothing.
1117+ const flags = declared . pattern . flags . includes ( 'd' ) ? declared . pattern . flags : `${ declared . pattern . flags } d` ;
1118+ const re = new RegExp ( declared . pattern . source , flags ) ;
1119+ const match = re . exec ( text ) ;
1120+ if ( ! match ) {
1121+ errors . push (
1122+ `[count-pattern-unmatched] the page no longer carries the \`${ declared . id } \` sentence ` +
1123+ `(${ declared . why } ) -- nothing was rewritten for it. Update the pattern together with the wording.`
1124+ ) ;
1125+ continue ;
1126+ }
1127+ const actual = declared . value ( census , text ) ;
1128+ if ( ! Number . isInteger ( actual ) || actual < 0 ) {
1129+ errors . push (
1130+ `[count-underivable] \`${ declared . id } \` could not be derived (${ declared . why } ) -- the page ` +
1131+ 'structure it reads is gone; nothing was rewritten for it.'
1132+ ) ;
1133+ continue ;
1134+ }
1135+ const stated = match [ 1 ] ;
1136+ const replacement = String ( actual ) ;
1137+ if ( stated === replacement ) continue ;
1138+ const [ start , end ] = match . indices [ 1 ] ;
1139+ text = text . slice ( 0 , start ) + replacement + text . slice ( end ) ;
1140+ rewrites . push ( { id : declared . id , from : stated , to : replacement } ) ;
1141+ }
1142+ return { text, rewrites, errors } ;
1143+ }
1144+
10781145/**
10791146 * ⛔ The six numbers this gate deliberately does NOT hold to the census, listed
10801147 * here so that stays a decision instead of an omission.
@@ -1518,25 +1585,28 @@ function readFileAt(root) {
15181585}
15191586
15201587/**
1521- * ⛔ `--fix` has nothing to repair, and says so instead of exiting 0 in silence.
1588+ * ⛔ ANCHORS have nothing to repair, and `--fix` says so instead of exiting 0 in
1589+ * silence about that half.
15221590 *
15231591 * Before #15921 this rewrote line numbers after a pure shift, which was the
15241592 * common repair and a real one. Symbol anchors encode no position, so the shift
15251593 * that repair existed for cannot happen: an edit above a site moves nothing this
1526- * page writes. Every red is now a human edit -- a rename, an arrived read, a
1527- * vanished one -- and none of them is mechanically derivable from the tree.
1594+ * page writes. A remaining anchor red is a human edit -- a rename, an arrived
1595+ * read, a vanished one -- and none of them is mechanically derivable from the
1596+ * tree. COUNTS are the other half, and `run()` handles those separately below
1597+ * (#16919) -- this function is deliberately scoped to what stays a human edit.
15281598 *
15291599 * ⭐ The flag stays RECOGNISED on purpose. `gen:system-context-census` and
15301600 * `scripts/regen-artifacts.mjs` both name it, and a flag that silently became a
15311601 * no-op would leave both reading as a working regeneration path. This prints what
15321602 * it did not do, then returns the ordinary verdict.
15331603 */
1534- function reportNoFix ( ) {
1604+ function reportNoAnchorFix ( ) {
15351605 process . stdout . write (
1536- 'check-system-context-census --fix: nothing to rewrite — this page carries no line numbers. \n' +
1537- ' Anchors are `path#symbol` (scripts/symbol-anchors.mjs), so an unrelated edit above a site \n' +
1538- ' cannot rot one and there is no mechanical repair to apply. A red below is a human edit: \n' +
1539- ' a renamed symbol, a read that arrived, or a read that vanished.\n'
1606+ 'check-system-context-census --fix: anchors have nothing to rewrite — this page carries no line\n' +
1607+ ' numbers. Anchors are `path#symbol` (scripts/symbol-anchors.mjs), so an unrelated edit above a\n' +
1608+ ' site cannot rot one and there is no mechanical repair to apply there . A remaining anchor red is \n' +
1609+ ' a human edit: a renamed symbol, a read that arrived, or a read that vanished (add its row) .\n'
15401610 ) ;
15411611}
15421612
@@ -1549,7 +1619,6 @@ function run({ fix = false } = {}) {
15491619 process . stderr . write ( `::error::[unreadable-page] ${ PAGE } could not be read -- ${ error . message } \n` ) ;
15501620 return 1 ;
15511621 }
1552- if ( fix ) reportNoFix ( ) ;
15531622
15541623 const census = runCensus ( { root : ROOT } ) ;
15551624 /* RESOLUTION is the shared resolver's, run once, over this gate's corpus
@@ -1563,14 +1632,49 @@ function run({ fix = false } = {}) {
15631632 return 1 ;
15641633 }
15651634
1635+ if ( fix ) {
1636+ // ── COUNTS: the one half of `--fix` that IS mechanical (#16919 direction 1) ──
1637+ //
1638+ // Reuses the exact `DECLARED_COUNTS` computation the COUNTS check below
1639+ // runs, as a write instead of a comparison -- see `regenerateDeclaredCounts`.
1640+ // A count that cannot be derived or located refuses loudly (`errors`) rather
1641+ // than writing a partial page: a generator that writes six of seven numbers
1642+ // and silently skips the seventh is worse than one that writes none.
1643+ const { text, rewrites, errors } = regenerateDeclaredCounts ( { pageText, census } ) ;
1644+ if ( errors . length > 0 ) {
1645+ for ( const error of errors ) process . stderr . write ( `::error::${ error } \n` ) ;
1646+ process . stderr . write (
1647+ `\ncheck-system-context-census --fix: ${ errors . length } declared count(s) could not be ` +
1648+ 'regenerated -- the page wording changed out from under the pattern that reads it. Fix the ' +
1649+ 'wording and the pattern together (by hand), then re-run --fix.\n'
1650+ ) ;
1651+ return 1 ;
1652+ }
1653+ if ( rewrites . length > 0 ) {
1654+ writeFileSync ( join ( ROOT , PAGE ) , text , 'utf8' ) ;
1655+ pageText = text ;
1656+ process . stdout . write (
1657+ `check-system-context-census --fix: regenerated ${ rewrites . length } declared count(s) from the ` +
1658+ `census: ${ rewrites . map ( ( r ) => `${ r . id } ${ r . from } ->${ r . to } ` ) . join ( ', ' ) } .\n`
1659+ ) ;
1660+ } else {
1661+ process . stdout . write (
1662+ 'check-system-context-census --fix: every declared count already matches the census -- nothing ' +
1663+ 'to rewrite there.\n'
1664+ ) ;
1665+ }
1666+ reportNoAnchorFix ( ) ;
1667+ }
1668+
15661669 const { problems, stats } = evaluate ( { pageText, census, readFile, sweep } ) ;
15671670 for ( const problem of problems ) process . stderr . write ( `::error::${ problem } \n` ) ;
15681671 if ( problems . length > 0 ) {
15691672 process . stderr . write (
15701673 `\ncheck-system-context-census: ${ problems . length } problem(s) over ${ stats . anchors } anchors ` +
15711674 `and ${ stats . sites } census sites.\n` +
1572- 'Re-run the census with `node scripts/isystem-census.mjs --json`. ⛔ There is no mechanical ' +
1573- 'repair: every red here is a human edit.\n' +
1675+ 'Re-run the census with `node scripts/isystem-census.mjs --json`. A `[declared-count]` mismatch ' +
1676+ 'is mechanical -- `pnpm gen:system-context-census` (`--fix`) rewrites it from the census. ' +
1677+ '⛔ Everything else here is a human edit.\n' +
15741678 `\nThe anchor grammar:\n ${ ANCHOR_GRAMMAR } \n`
15751679 ) ;
15761680 return 1 ;
@@ -2192,14 +2296,17 @@ function selfTest() {
21922296 const noAnchors = run ( '---\ntitle: x\n---\n\nnothing here.\n' ) ;
21932297 t ( 'ABSENCE: a page with no anchors refuses' , noAnchors . problems . some ( ( p ) => p . startsWith ( '[no-anchors]' ) ) ) ;
21942298
2195- // ── ⛔ --fix rewrites NOTHING, and says so ─────────────────────────────── ───
2299+ // ── ⛔ --fix regenerates declared COUNTS only, never anchors, and says so ───
21962300 //
2197- // ⭐ The failure this pins is the QUIET one. `gen:system-context-census` and
2198- // `scripts/regen-artifacts.mjs` both invoke `--fix`; a flag that silently became
2199- // a no-op leaves both of them reading as a working regeneration path, and the
2200- // first person to hit a red would run it, see exit 0, and conclude the red was
2201- // spurious.
2202- battery ( '⛔ --fix rewrites NOTHING, and says so' ) ;
2301+ // ⭐ Two failures this pins, in opposite directions. `gen:system-context-census`
2302+ // and `scripts/regen-artifacts.mjs` both invoke `--fix`; a flag that silently
2303+ // went back to being a no-op (the pre-#16919 shape) leaves both reading as a
2304+ // working regeneration path while every count red still needs a human. And a
2305+ // `--fix` that goes the OTHER way -- resurrecting the retired line-shift
2306+ // repair, or writing the page on a count it could not actually derive -- is
2307+ // exactly the "mechanical repair" this gate explicitly does not offer for
2308+ // anchors. Both directions are pinned on the SAME source read.
2309+ battery ( '⛔ --fix regenerates declared COUNTS only, never anchors, and says so' ) ;
22032310 let ownSourceForFix = null ;
22042311 try {
22052312 ownSourceForFix = readFileSync ( join ( ROOT , 'scripts/check-system-context-census.mjs' ) , 'utf8' ) ;
@@ -2208,16 +2315,83 @@ function selfTest() {
22082315 }
22092316 if ( ownSourceForFix !== null ) {
22102317 t (
2211- '--fix: no anchor-rewriting code survives -- nothing in this gate writes the page' ,
2212- ! / w r i t e F i l e S y n c \( \s * j o i n \( R O O T , P A G E \) / . test ( ownSourceForFix ) && ! / \b f i x A n c h o r s \b / . test ( ownSourceForFix ) ,
2213- 'a rewriter is back in this file'
2318+ '--fix: no anchor line-shift repair survives -- symbol anchors still encode no position to fix' ,
2319+ ! / \b f i x A n c h o r s \b / . test ( ownSourceForFix ) ,
2320+ 'the retired line-shift repair is back in this file'
2321+ ) ;
2322+ t (
2323+ '--fix: the count-regenerating function is exported, not inlined where nothing else can reach it' ,
2324+ / e x p o r t f u n c t i o n r e g e n e r a t e D e c l a r e d C o u n t s \( / . test ( ownSourceForFix )
22142325 ) ;
22152326 t (
2216- '--fix: the flag is still RECOGNISED and still explains itself, so the wiring cannot go quiet' ,
2217- / a r g v \. i n c l u d e s \( ' - - f i x ' \) / . test ( ownSourceForFix ) && / n o t h i n g t o r e w r i t e / . test ( ownSourceForFix )
2327+ '--fix: the write is gated on there being something to write -- a no-op run must not touch the page' ,
2328+ / i f \( r e w r i t e s \. l e n g t h > 0 \) \{ / . test ( ownSourceForFix ) &&
2329+ / w r i t e F i l e S y n c \( \s * j o i n \( R O O T , P A G E \) / . test ( ownSourceForFix )
2330+ ) ;
2331+ t (
2332+ '--fix: the flag is still RECOGNISED and explains BOTH halves, so the wiring cannot go quiet' ,
2333+ / a r g v \. i n c l u d e s \( ' - - f i x ' \) / . test ( ownSourceForFix ) &&
2334+ / a n c h o r s h a v e n o t h i n g t o r e w r i t e / . test ( ownSourceForFix ) &&
2335+ / r e g e n e r a t e d \$ \{ r e w r i t e s \. l e n g t h \} d e c l a r e d c o u n t / . test ( ownSourceForFix )
22182336 ) ;
22192337 }
22202338
2339+ // ⭐ Behavioural, not textual: drive `regenerateDeclaredCounts` itself on the
2340+ // same fixtures the 'counts' battery already uses to pin the CHECK, so the
2341+ // WRITE can never define "correct" any differently than the comparison does.
2342+ const mismatched = fixturePage ( ) + '\nit is a single boolean read at **7\ndistinct sites across 1 packages**.\n' ;
2343+ const regenerated = regenerateDeclaredCounts ( { pageText : mismatched , census : FIXTURE_CENSUS , declaredCounts : FIXTURE_COUNTS } ) ;
2344+ t (
2345+ '--fix REGENERATES: a mismatched declared count is rewritten to the census value, and reported' ,
2346+ regenerated . errors . length === 0 &&
2347+ regenerated . rewrites . length === 1 &&
2348+ regenerated . rewrites [ 0 ] . id === 'headline-sites' &&
2349+ regenerated . rewrites [ 0 ] . from === '7' &&
2350+ regenerated . rewrites [ 0 ] . to === '1' ,
2351+ JSON . stringify ( regenerated . rewrites )
2352+ ) ;
2353+ t (
2354+ '--fix REGENERATES: the rewritten text carries the new digit and NOT the old one, surrounding prose untouched' ,
2355+ regenerated . text . includes ( 'read at **1\ndistinct sites' ) && ! regenerated . text . includes ( '**7\ndistinct sites' ) ,
2356+ regenerated . text
2357+ ) ;
2358+ t (
2359+ '⭐ IDEMPOTENCE: a page that already agrees with the census comes back BYTE-IDENTICAL, zero rewrites' ,
2360+ ( ( ) => {
2361+ const already = fixturePage ( ) + '\nit is a single boolean read at **1\ndistinct sites across 1 packages**.\n' ;
2362+ const first = regenerateDeclaredCounts ( { pageText : already , census : FIXTURE_CENSUS , declaredCounts : FIXTURE_COUNTS } ) ;
2363+ const second = regenerateDeclaredCounts ( { pageText : first . text , census : FIXTURE_CENSUS , declaredCounts : FIXTURE_COUNTS } ) ;
2364+ return (
2365+ first . rewrites . length === 0 &&
2366+ first . text === already &&
2367+ second . rewrites . length === 0 &&
2368+ second . text === first . text
2369+ ) ;
2370+ } ) ( )
2371+ ) ;
2372+ t (
2373+ '--fix REFUSES rather than writes a partial page: an unmatched or underivable count surfaces as an error' ,
2374+ ( ( ) => {
2375+ const unmatched = regenerateDeclaredCounts ( {
2376+ pageText : fixturePage ( ) ,
2377+ census : FIXTURE_CENSUS ,
2378+ declaredCounts : FIXTURE_COUNTS ,
2379+ } ) ;
2380+ const underivable = regenerateDeclaredCounts ( {
2381+ pageText : fixturePage ( ) + '\nhelper at `pkg/a.ts#isSystemObjectName`\n' ,
2382+ census : FIXTURE_CENSUS ,
2383+ declaredCounts : [
2384+ { id : 'x' , pattern : / h e l p e r a t ` p k g \/ a \. t s # ( \w + ) ` / , value : ( ) => carryOnwardRowCount ( 'gone' ) , why : 'fixture' } ,
2385+ ] ,
2386+ } ) ;
2387+ return (
2388+ unmatched . errors . some ( ( e ) => e . startsWith ( '[count-pattern-unmatched]' ) ) &&
2389+ unmatched . text === fixturePage ( ) &&
2390+ underivable . errors . some ( ( e ) => e . startsWith ( '[count-underivable]' ) )
2391+ ) ;
2392+ } ) ( )
2393+ ) ;
2394+
22212395 // ── ⭐ THE RULED RED-FIRST PAIR, on a real tree ─────────────────────────────
22222396 //
22232397 // The two headline behaviours the migration was ruled on, proved rather than
0 commit comments