3939// marker in place. Both pin the WORDING, not the act — the flag takes either
4040// direction from whoever runs it.
4141//
42+ // That message also NAMES the one shape that reads as a new boundary and is not
43+ // one — a pure RELOCATION, where a split or move carried already-baselined
44+ // occurrences to a new path — and states the evidence a reviewer checks for it.
45+ // Wording only: a relocation is still reported as NEW-use rows plus a
46+ // ratchet-DOWN row, and the gate still does not net those halves against each
47+ // other. See `relocationClause()` for why that netting is REFUSED rather than
48+ // merely unimplemented.
49+ //
4250// Scope: content/docs (hand-written; its `references/` tree is generated from
4351// spec and excluded BY PATH — the spec source is the fix site there) and
4452// skills/, walked whole, `references/` included: that tree is published catalog
@@ -774,6 +782,98 @@ function ratchetRemedyCarriesAuthority(message) {
774782 return message . includes ( RATCHET_AUTHORITY_MARKER ) ;
775783}
776784
785+ /**
786+ * The ratchet-DOWN verdict's distinctive phrase, shared by the messages below
787+ * rather than re-typed in each.
788+ *
789+ * `relocationClause()` sends a relocating author looking for THIS row in the
790+ * same run, so the two strings have to move together: a rewording on one side
791+ * that left the other naming a sentence the gate no longer prints would send
792+ * that author hunting for a signal that is not there, and the message would
793+ * still read as correct. The self-test asserts the clause quotes it and that the
794+ * emitted ratchet-DOWN message really carries it.
795+ */
796+ const RATCHET_DOWN_PHRASE = 'baselined file is clean/gone' ;
797+
798+ /**
799+ * The relocation paragraph of the NEW-use verdict, named and pure for the same
800+ * reason the messages around it are: the wording IS the deliverable here, so the
801+ * self-test has to reach the exact sentences the author reads.
802+ *
803+ * ## The case it names
804+ *
805+ * The ledger is path-keyed, so a split or move re-addresses occurrences without
806+ * adding one, and the run reports the moved occurrences as NEW use at their new
807+ * paths AND a ratchet-DOWN at the old path. The only remedy offered for the
808+ * first half is the `⛔ MAINTAINER-ONLY` one — the loudest, most-gated path in
809+ * this gate's vocabulary, whose warning is written for a genuine new boundary.
810+ * Nothing in the message told a relocating author their case apart from that
811+ * one, so the argument that it is safe — the occurrences only changed address,
812+ * and the baseline diff touches nothing else — was re-derived by hand, by the
813+ * author and again by the reviewer, on every split.
814+ *
815+ * ## Why this is a MESSAGE and not a verdict
816+ *
817+ * Teaching the gate to net the two halves was considered and refused. A
818+ * total-preserving reading admits the case it cannot tell from a relocation: a
819+ * file that GAINS an occurrence while a sibling loses an unrelated one passes
820+ * it, so a real new use arrives green paired with an unrelated removal. The
821+ * ratchet's whole value is that it is keyed to WHERE, and this is a gate there
822+ * is structural reason to want quieter — a tolerance that makes a genuine
823+ * regression indistinguishable from a move must not be bought with "the total
824+ * did not move".
825+ *
826+ * ⛔ Which is also why this clause is GENERIC and interpolates nothing about the
827+ * run in hand. To name the actual old path and its counts the gate would have to
828+ * pair NEW-use rows with vanished baseline rows and sum across files — the
829+ * refused verdict arriving as advice, computed in the same place and believed
830+ * the same way. So the message states the TEST; the author states the answer, in
831+ * the diff, where a reviewer can check it and the ledger records what was
832+ * agreed.
833+ *
834+ * @returns {string }
835+ */
836+ function relocationClause ( ) {
837+ return (
838+ 'One shape reads as a new boundary and is NOT one: a pure RELOCATION, where a split or move '
839+ + 'carried already-baselined occurrences to a new path. You are in that case only if THIS '
840+ + `run also reports \`${ RATCHET_DOWN_PHRASE } \` (or an improved count) on the path they came `
841+ + 'from, and those rows account for every occurrence reported here. The gate will not net '
842+ + 'those two halves against each other, and is not being taught to: the ratchet is keyed to '
843+ + 'WHERE the word is, so a file that GAINS an occurrence while a sibling loses an unrelated '
844+ + 'one has to stay distinguishable from a move. So the path is still this one and still '
845+ + `${ RATCHET_AUTHORITY_MARKER } — what a relocation changes is only that the maintainer's `
846+ + 'question is cheap to answer, so answer it in the diff: re-baselining a pure move touches '
847+ + 'nothing but the paths involved, the old row removed and the new rows added summing back '
848+ + 'to it — `- "OLD_PATH": 2` replaced by `+ "NEW_PATH_A": 1` and `+ "NEW_PATH_B": 1`. Any '
849+ + 'OTHER row in that diff is `--update` re-baselining something else in the same stroke, '
850+ + 'which is the act the marker is on, and what you are showing is then not a relocation.'
851+ ) ;
852+ }
853+
854+ /**
855+ * The second half of the same convention: a message that hands the author the
856+ * baseline-expanding path must also name the one case that path is wrong for.
857+ *
858+ * A predicate rather than a substring compare at the assertion site, for the
859+ * reason `ratchetRemedyCarriesAuthority` is one — the self-test can then prove
860+ * it DISCRIMINATES, and a check that approved every long message would keep the
861+ * pin green with the clause gone.
862+ *
863+ * Keyed on the SAME offer detector as the authority marker, so the two rules
864+ * have one subject: a message offering no baseline-expanding path is unaffected,
865+ * and in particular the ratchet-DOWN messages — whose case this clause is about
866+ * — must not be forced to carry a paragraph addressed to someone reading a
867+ * NEW-use row.
868+ *
869+ * @param {string } message
870+ * @returns {boolean }
871+ */
872+ function newUseOfferNamesRelocation ( message ) {
873+ if ( ! RATCHET_EXPANSION_OFFER . test ( message ) ) return true ;
874+ return message . includes ( RATCHET_DOWN_PHRASE ) && message . includes ( 'pure RELOCATION' ) ;
875+ }
876+
777877/**
778878 * The NEW-use verdict's text, named and pure so the self-test can assert on the
779879 * exact string the author reads. A message built inline is a message no
@@ -797,7 +897,8 @@ function newUseMessage(file, count) {
797897 + 'is that ACT, not the file — `--update` rewrites the whole baseline from the current tree, '
798898 + 'so it admits your occurrence and re-baselines every other file in one stroke. The baseline '
799899 + 'is shrink-only, so this weakens a ratchet and needs a maintainer to agree the boundary is '
800- + 'genuine first — do not take this path to get CI green.'
900+ + 'genuine first — do not take this path to get CI green. '
901+ + relocationClause ( )
801902 ) ;
802903}
803904
@@ -828,6 +929,42 @@ function grewMessage(file, allowed, count) {
828929 ) ;
829930}
830931
932+ /**
933+ * The two ratchet-DOWN verdicts, named and pure for the reason `grewMessage` was
934+ * made so: a message built inline is a message no assertion can reach — and
935+ * these two are now QUOTED by `relocationClause()` as the signal a relocating
936+ * author is told to look for, which is a coupling only a reachable string can
937+ * pin. The text each emits is unchanged.
938+ *
939+ * ⛔ Neither offers the baseline-EXPANDING path, so neither carries the
940+ * maintainer-only marker and neither may acquire one: ratcheting down is
941+ * squarely the author's own remedy, and `RATCHET_EXPANSION_OFFER` is keyed so
942+ * that it does not reach these.
943+ *
944+ * @param {string } file
945+ * @param {number } allowed
946+ * @returns {string }
947+ */
948+ function cleanOrGoneMessage ( file , allowed ) {
949+ return (
950+ `${ file } : ${ RATCHET_DOWN_PHRASE } (was ${ allowed } ) — ratchet DOWN: run `
951+ + '`node scripts/check-role-word.mjs --update` and commit the baseline.'
952+ ) ;
953+ }
954+
955+ /**
956+ * @param {string } file
957+ * @param {number } allowed
958+ * @param {number } now
959+ * @returns {string }
960+ */
961+ function improvedMessage ( file , allowed , now ) {
962+ return (
963+ `${ file } : role-word count improved ${ allowed } → ${ now } — ratchet DOWN: run `
964+ + '`node scripts/check-role-word.mjs --update` and commit the baseline.'
965+ ) ;
966+ }
967+
831968// ── What a PASSING run tells the reader (#9910) ────────────────────────
832969//
833970// The green line used to be, in full:
@@ -1123,6 +1260,7 @@ let selfTestReachedVerdict = false;
11231260const SELF_TEST_BATTERIES = Object . freeze ( {
11241261 'The scan population: which `references/` the skip means (#15061)' : 5 ,
11251262 'The ratchet-remedy authority convention (#8435)' : 4 ,
1263+ 'The NEW-use message names the relocation case (#14659)' : 7 ,
11261264 'The green body reports what was READ (#9910)' : 5 ,
11271265 'A missing ROOT is REFUSED, per root (#9932)' : 8 ,
11281266 'The dispatch-gates declaration (#9964\'s pattern)' : 4 ,
@@ -1137,7 +1275,7 @@ const SELF_TEST_BATTERIES = Object.freeze({
11371275
11381276// DELETING an entry silences that battery's floor exactly as effectively as
11391277// zeroing it, so the roster's own size is pinned too.
1140- const SELF_TEST_BATTERY_FLOOR = 12 ;
1278+ const SELF_TEST_BATTERY_FLOOR = 13 ;
11411279
11421280// The key an assertion is filed under when no battery is open. It is not a
11431281// declared battery, so it reds by the same set difference rather than silently
@@ -1256,6 +1394,65 @@ function selfTest() {
12561394 + '(marking the improvement path maintainer-only would teach the opposite of the rule)' ,
12571395 ! RATCHET_EXPANSION_OFFER . test ( ratchetDown ) && ratchetRemedyCarriesAuthority ( ratchetDown ) ) ;
12581396
1397+ // ── The NEW-use message names the relocation case (#14659) ─────────────────
1398+ //
1399+ // Seven assertions over the clause the ledger's path-keying makes necessary. A
1400+ // split moves baselined occurrences to new paths, so the run reports them as
1401+ // NEW use and the old path as a ratchet-DOWN, and the author is routed down
1402+ // the maintainer-only path by a warning written for a different case.
1403+ //
1404+ // (3) is the load-bearing one, and it is not the obvious one. The risk this
1405+ // wording carries is not that it disappears — (1) and (2) cover that — but
1406+ // that a later edit SOFTENS it into an apology for the gate. The refused
1407+ // verdict (net the two halves out and call it a relocation) is exactly what a
1408+ // reader re-argues from a message that reads as tolerance, so the refusal and
1409+ // its reason have to be pinned as text, not left to the paragraph's tone.
1410+ battery ( 'The NEW-use message names the relocation case (#14659)' ) ;
1411+ const clause = relocationClause ( ) ;
1412+ expect ( '#14659 — the relocation clause is REACHED by the real NEW-use message (else every '
1413+ + 'assertion below is about a string no author is ever shown)' ,
1414+ real . includes ( clause ) ) ;
1415+ expect ( '#14659 — the NEW-use message satisfies newUseOfferNamesRelocation(): a message offering '
1416+ + 'the baseline-expanding path names the one case that path is wrong for' ,
1417+ newUseOfferNamesRelocation ( real ) ) ;
1418+
1419+ expect ( '#14659 — the clause REFUSES the netting in writing and gives the reason (keyed to '
1420+ + 'WHERE), so the wording cannot be read as the tolerance that was refused — a gained '
1421+ + 'occurrence paired with an unrelated loss must stay distinguishable from a move' ,
1422+ clause . includes ( 'will not net' ) && clause . includes ( 'not being taught to' )
1423+ && clause . includes ( 'keyed to WHERE' ) && clause . includes ( 'unrelated' ) ) ;
1424+
1425+ // The signal the clause sends the author looking for has to be one the gate
1426+ // really prints. Read off the EMITTED messages on both sides, so a rewording
1427+ // of either fails here rather than in a reader's terminal.
1428+ const emittedGone = cleanOrGoneMessage ( 'content/docs/example.mdx' , 2 ) ;
1429+ const emittedImproved = improvedMessage ( 'content/docs/example.mdx' , 4 , 2 ) ;
1430+ expect ( '#14659 — both ratchet-DOWN rows the clause tells the author to look for are rows the '
1431+ + 'gate actually emits (the recognition signal has to exist to be looked for)' ,
1432+ clause . includes ( RATCHET_DOWN_PHRASE ) && emittedGone . includes ( RATCHET_DOWN_PHRASE )
1433+ && clause . includes ( 'improved count' ) && emittedImproved . includes ( 'count improved' ) ) ;
1434+
1435+ // The evidence a reviewer checks, pinned as ARITHMETIC rather than as a
1436+ // quotation: an example edited into rows that do not sum would teach the wrong
1437+ // audit while every substring assertion above stayed green.
1438+ const removedRow = / ` - " [ A - Z _ ] + " : ( \d + ) ` / . exec ( clause ) ;
1439+ const addedRows = [ ...clause . matchAll ( / ` \+ " [ A - Z _ ] + " : ( \d + ) ` / g) ] ;
1440+ expect ( '#14659 — the clause carries the baseline-diff shape a pure relocation produces: ONE '
1441+ + 'removed row, MORE THAN ONE added row, and the added rows summing back to the removed one' ,
1442+ removedRow !== null && addedRows . length > 1
1443+ && addedRows . reduce ( ( sum , m ) => sum + Number ( m [ 1 ] ) , 0 ) === Number ( removedRow [ 1 ] ) ) ;
1444+
1445+ // Discrimination, the same shape as #8435's own (3) and for the same reason:
1446+ // without it, a predicate that approved everything would keep assertion (2)
1447+ // green with the clause gone. The fixture is the synthetic offer already built
1448+ // for #8435 — an offer carrying no relocation wording at all.
1449+ expect ( '#14659 — newUseOfferNamesRelocation() REJECTS a baseline-expanding offer that does not '
1450+ + 'name the case (proves the predicate discriminates rather than approving every message)' ,
1451+ ! newUseOfferNamesRelocation ( unmarkedOffer ) ) ;
1452+ expect ( '#14659 — newUseOfferNamesRelocation() leaves the ratchet-DOWN message alone: it is an '
1453+ + 'offer-scoped rule, not a vocabulary requirement on every message this gate prints' ,
1454+ newUseOfferNamesRelocation ( ratchetDown ) && newUseOfferNamesRelocation ( emittedGone ) ) ;
1455+
12591456 // ── The green body reports what was READ (#9910) ──────────────────────
12601457 //
12611458 // Interpolated counts again, so the source proves nothing about the rendered
@@ -2067,7 +2264,11 @@ function selfTest() {
20672264 + 'directions pinned from the WALK, never from a typed count, so a skip that empties the '
20682265 + 'published half and one that swallows the generated half each name themselves. '
20692266 + 'The NEW-use remedy marks baseline expansion as maintainer-only, the predicate '
2070- + 'rejects an unmarked offer, the ratchet-DOWN remedy stays the author\'s own, and both '
2267+ + 'rejects an unmarked offer, the ratchet-DOWN remedy stays the author\'s own, and that '
2268+ + 'same remedy NAMES the pure-relocation case, quotes a ratchet-DOWN row the gate really '
2269+ + 'prints as its recognition signal, carries a baseline diff whose rows are pinned to SUM, '
2270+ + 'and refuses the netting in writing — so the wording tells a relocating author apart from '
2271+ + 'a new boundary without the gate ever reading one row against another. Both '
20712272 + 'success texts report what was READ \u2014 so a scanned tree and an unscanned one cannot print '
20722273 + 'the same result once the ledger is empty. Every separator-less ROOT also declares the '
20732274 + 'subtree spelling dispatch-gates derives from, and declares nothing this gate does not '
@@ -2205,9 +2406,9 @@ for (const [file, count] of Object.entries(current)) {
22052406for ( const [ file , allowed ] of Object . entries ( baseline ) ) {
22062407 const now = current [ file ] ;
22072408 if ( now === undefined ) {
2208- errors . push ( ` ${ file } : baselined file is clean/gone (was ${ allowed } ) — ratchet DOWN: run \`node scripts/check-role-word.mjs --update\` and commit the baseline.` ) ;
2409+ errors . push ( cleanOrGoneMessage ( file , allowed ) ) ;
22092410 } else if ( now < allowed ) {
2210- errors . push ( ` ${ file } : role-word count improved ${ allowed } → ${ now } — ratchet DOWN: run \`node scripts/check-role-word.mjs --update\` and commit the baseline.` ) ;
2411+ errors . push ( improvedMessage ( file , allowed , now ) ) ;
22112412 }
22122413}
22132414
0 commit comments