22
33/**
44 * The published prose that teaches the ADR-0020 D3.3 legal-next-state
5- * introspection route spells it the way the REST ledger does (#10178).
5+ * introspection route spells it the way the REST ledger does (#10178, #14561 ).
66 *
77 * WHY THIS EXISTS (measured, not argued). #9180 step ② retired the plural
88 * `/api/v1/meta/objects/:name/state/:field` registration and moved the SDK to
1818 * become a second, hand-copied statement of the canonical spelling — exactly
1919 * the disease the two-site drift is an instance of. Change the ledger row and
2020 * this test asks the docs to follow; change a doc line to a non-canonical
21- * spelling and it reddens naming the file.
21+ * spelling and it reddens naming the file. #14561 extended that derivation from
22+ * the SPELLING to the POPULATION: both regexes below are built out of the
23+ * ledger row's own segments, so neither the expected path nor the net that
24+ * finds candidates is written down a second time.
2225 *
23- * PRESENCE, NOT ABSENCE. The assertion is that the canonical path IS THERE.
24- * "no doc contains the plural" would pass on a page that stopped mentioning
25- * the route at all, which is the same silence this guard exists to break — the
26- * doc-authoring gate under `scripts/` carries the repo's standing statement of
27- * why an evaporated corpus must not read as a clean one.
26+ * PRESENCE, NOT ABSENCE. The two named sites below are asserted to CONTAIN the
27+ * canonical path. "no doc contains the plural" would pass on a page that
28+ * stopped mentioning the route at all, which is the same silence this guard
29+ * exists to break — the doc-authoring gate under `scripts/` carries the repo's
30+ * standing statement of why an evaporated corpus must not read as a clean one.
31+ *
32+ * ── CONDITIONAL PRESENCE over a DISCOVERED population (#14561) ──────────────
33+ *
34+ * A two-file `TEACHING_SITES` list is the same shape as the drift it catches:
35+ * a hand-maintained stand-in for a fact already on disk, going stale in
36+ * silence as the corpus grows. It had already gone stale. Measured on
37+ * `origin/main` 7a17f3bf, over a 721-file corpus: THREE files carry this
38+ * route on the REST wire path, two of them are the list below, and the third
39+ * — the QA platform-checklist area file for the API/backend surface — carries
40+ * it three times over, with concrete values rather than placeholders. It
41+ * spells it canonically. That was LUCK, not enforcement: nothing here would
42+ * have reddened had it been written with the retired plural, and the rollback
43+ * arm of this card's verification confirmed exactly that: the pre-#14561 file
44+ * passes over a corpus in which that page teaches the retired plural.
45+ *
46+ * (A looser, LINE-scoped sweep reports SIX files — the number this card was
47+ * triaged on. The other three are not mentions of this route at all: each
48+ * names a DIFFERENT `/meta/object/...` route — an OWD probe, a field-order
49+ * check — on a line that also happens to contain the word "state", with no
50+ * `/state/<field>` tail anywhere. That is the sixth `PREDICATE_CASES` entry,
51+ * pinned so this distinction cannot quietly move.)
52+ *
53+ * So the population is discovered rather than declared. Every authored-prose
54+ * file that mentions this route AT ALL is judged, and a page that stops
55+ * mentioning it drops out of the population instead of passing vacuously —
56+ * which is why the silence objection above does not apply to this half, while
57+ * the two positive assertions keep doing their own job of proving the canonical
58+ * spelling is taught SOMEWHERE. Both properties are needed and neither implies
59+ * the other.
60+ *
61+ * ⛔ NOT a discovered-corpus NEGATIVE scan ("no page contains the plural"),
62+ * which is the shape this deliberately is not; see the next block for why that
63+ * one would gate against a spelling the platform still answers on purpose.
2864 *
2965 * ⛔ WHAT THIS DOES NOT SAY. The plural is NOT universally dead and this test
3066 * must never be read as saying it is: the legacy if-chain branch in the
3167 * runtime's `/meta` dispatcher domain still matches BOTH literals, so
3268 * `/meta/objects/:name/state/:field` is refused by a REST-fronted deployment
3369 * and still ANSWERED wherever `dispatch()` is the front door. That asymmetry is
3470 * deliberate (maintainer re-weigh of the #9180 ruling, 2026-08-17 item 3) and
35- * is pinned by that domain's own `meta-state-plural-tolerance` suite. This file
36- * is about the spelling the docs TEACH, nothing else.
71+ * is pinned by that domain's own `meta-state-plural-tolerance` suite.
72+ *
73+ * That is not prose here, it is the POPULATION BOUNDARY, and it is the reason
74+ * the discovery net is anchored on the WHOLE ledger route including its
75+ * versioned API prefix rather than on the `/meta/...` tail. A mention carrying
76+ * the prefix is a claim about the REST wire path, which is the door this
77+ * package's ledger row governs and the door where the plural really is
78+ * refused. A mention without it is the dispatcher spelling, which this file has
79+ * no opinion about and must not acquire one about — so it is not discovered at
80+ * all. `PREDICATE_CASES` pins both directions, because a boundary that lives
81+ * only in a comment is a boundary the next regex tweak can move by accident.
82+ *
83+ * WHAT HOLDS THE DERIVATION HONEST. Deriving the population from the same
84+ * regex that judges it costs the pin the independence a hand-written list gave
85+ * it for free: a detector that matched nothing would empty the population and
86+ * leave every assertion below green. Two answers, both mechanical.
87+ * `PREDICATE_CASES` is a set of whole tiny inputs whose verdict is known by
88+ * construction — one per spelling this net must catch, one per shape it must
89+ * NOT claim. And the discovered population is required to contain every named
90+ * site below, so a net that goes blind reddens on the two files this guard has
91+ * always known about.
92+ *
93+ * ── The corpus, and the input radius it declares (#14561) ───────────────────
94+ *
95+ * The corpus is git's answer, not a filesystem crawl: tracked plus authored-
96+ * but-untracked files under the authored-prose roots, with ignored paths
97+ * excluded. A `readdirSync` crawl over these roots would be both slower and
98+ * NONDETERMINISTIC inside merge-queue builds, where generated artifacts land
99+ * under the same trees mid-run — the reasoning `packages/core`'s
100+ * operation-private-key pin records in full, reached there by the same route.
101+ *
102+ * That is a real widening of this package's cross-package input radius and it
103+ * is declared deliberately rather than inherited by accident: `content/**`,
104+ * `docs/**` and `skills/**` are the roots the scan reads, so all three are
105+ * declared in `CROSS_PACKAGE_TEST_INPUTS` and hashed by `turbo.json`. The two
106+ * per-file globs stay alongside them: the named sites below are read BY NAME
107+ * and the gate's roster holds those two paths, while the discovered corpus is
108+ * a `git ls-files` result whose members this package's detector cannot name —
109+ * so the `docs/**` root is declared with a `heldBy` witness naming this test,
110+ * which is the mechanism that table publishes for exactly this case. Price,
111+ * stated because it is charged to every docs PR: a diff under any of those
112+ * roots now schedules and re-runs this package's suite.
37113 *
38- * Those two modules are CITED, never read — spelling them as repo paths here
39- * would make `check-cross-package-test-inputs` demand a declared radius over
40- * `packages/ runtime/**` , claiming an input dependency this test does not have
41- * and invalidating this package's test cache on every runtime change.
114+ * Those two runtime modules are CITED, never read — spelling them as repo
115+ * paths here would make `check-cross-package-test-inputs` demand a declared
116+ * radius over the runtime package , claiming an input dependency this test does
117+ * not have and invalidating this package's test cache on every runtime change.
42118 */
43119
120+ import { execFileSync } from 'node:child_process' ;
44121import { readFileSync } from 'node:fs' ;
45- import { dirname , resolve } from 'node:path' ;
122+ import { dirname , join , resolve } from 'node:path' ;
46123import { fileURLToPath } from 'node:url' ;
47124import { describe , expect , it } from 'vitest' ;
48125
@@ -62,6 +139,11 @@ const HERE = dirname(fileURLToPath(import.meta.url));
62139 * or nested in an object literal — would hold a real input radius while naming
63140 * nothing its roster can check. Both files are declared for `@objectstack/rest`
64141 * in `CROSS_PACKAGE_TEST_INPUTS` and hashed by `turbo.json`.
142+ *
143+ * ⛔ This list is no longer the population — it is the floor. Adding a file
144+ * here is NOT how a new teaching site gets covered (the discovered population
145+ * covers it the moment it is written); a name belongs here only when the site
146+ * must be asserted to keep teaching the route even if its text is rewritten.
65147 */
66148const STATE_MACHINE_DOC = resolve ( HERE , '../../../content/docs/protocol/objectql/state-machine.mdx' ) ;
67149const AUTOMATION_SKILL = resolve ( HERE , '../../../skills/objectstack-automation/SKILL.md' ) ;
@@ -71,6 +153,100 @@ const TEACHING_SITES = [
71153 { label : 'skills/objectstack-automation/SKILL.md' , path : AUTOMATION_SKILL } ,
72154] as const ;
73155
156+ /**
157+ * The authored-prose roots the discovery scan reads. Bare directory names, and
158+ * pathspecs for `git ls-files` rather than paths this file resolves — the
159+ * radius they hold is declared in `CROSS_PACKAGE_TEST_INPUTS`, which is where a
160+ * reader can check it against the code.
161+ */
162+ const CORPUS_ROOTS = [ 'content' , 'docs' , 'skills' ] as const ;
163+
164+ /**
165+ * What a single path SEGMENT's value looks like where prose writes it: a
166+ * placeholder (`:name`), a real id (`showcase_task`), or one carrying a query
167+ * (`status?from=in_review`). Everything that ends a URL in running prose,
168+ * markdown, or a JSON string is excluded, and BOTH regexes below share it — so
169+ * a mention and its canonical judgement can never disagree about where the
170+ * route stops.
171+ */
172+ const SEGMENT_VALUE = '[^/\\s`\'"),;\\]]+' ;
173+
174+ const escapeForRegExp = ( literal : string ) : string => literal . replace ( / [ . * + ? ^ $ { } ( ) | [ \] \\ ] / g, '\\$&' ) ;
175+
176+ /**
177+ * Every mention this file reports, with the line it sits on, so a failure names
178+ * a place a reader can open rather than a file to go searching in.
179+ */
180+ interface Mention {
181+ readonly text : string ;
182+ readonly line : number ;
183+ }
184+
185+ function mentionsIn ( text : string , pattern : RegExp ) : Mention [ ] {
186+ const found : Mention [ ] = [ ] ;
187+ const lines = text . split ( '\n' ) ;
188+ for ( let i = 0 ; i < lines . length ; i ++ ) {
189+ for ( const m of lines [ i ] ! . matchAll ( pattern ) ) found . push ( { text : m [ 0 ] , line : i + 1 } ) ;
190+ }
191+ return found ;
192+ }
193+
194+ /**
195+ * Inputs whose verdict is known by construction — the independence a derived
196+ * population costs (#14561). Each is a whole tiny "page": the first three are
197+ * spellings the net MUST catch, the last four are shapes it must NOT claim,
198+ * and the dispatcher case is the 2026-08-17 asymmetry made checkable.
199+ */
200+ const PREDICATE_CASES : readonly {
201+ readonly label : string ;
202+ readonly text : string ;
203+ readonly mentioned : boolean ;
204+ readonly canonical : boolean ;
205+ } [ ] = [
206+ {
207+ label : 'the canonical wire path, placeholders, as the protocol page writes it' ,
208+ text : 'Over HTTP, `GET /api/v1/meta/object/:name/state/:field?from=:state` returns the legal next list.' ,
209+ mentioned : true ,
210+ canonical : true ,
211+ } ,
212+ {
213+ label : 'the canonical wire path with concrete values, as a checklist step writes it' ,
214+ text : '"fire the meta state route: GET /api/v1/meta/object/showcase_task/state/status?from=in_review",' ,
215+ mentioned : true ,
216+ canonical : true ,
217+ } ,
218+ {
219+ label : 'the retired PLURAL object segment on the REST wire path' ,
220+ text : 'Call `GET /api/v1/meta/objects/showcase_task/state/status` to read the legal next states.' ,
221+ mentioned : true ,
222+ canonical : false ,
223+ } ,
224+ {
225+ label : 'the third spelling the original sweep found — `metadata` plus the plural' ,
226+ text : 'Call `GET /api/v1/metadata/objects/:name/state/:field` to read the legal next states.' ,
227+ mentioned : true ,
228+ canonical : false ,
229+ } ,
230+ {
231+ label : 'the DISPATCHER spelling, no versioned prefix — deliberately out of the population' ,
232+ text : 'Embedded, `dispatch()` answers `/meta/objects/:name/state/:field` as well as the singular form.' ,
233+ mentioned : false ,
234+ canonical : false ,
235+ } ,
236+ {
237+ label : 'a different meta route that shares the object segment but has no state tail' ,
238+ text : '"server reject: PUT /api/v1/meta/object/qa_owd_probe with sharingModel private — capture the 4xx"' ,
239+ mentioned : false ,
240+ canonical : false ,
241+ } ,
242+ {
243+ label : 'prose about state machines that names no route at all' ,
244+ text : 'A `state_machine` rule declares the legal transitions out of each state of a picklist field.' ,
245+ mentioned : false ,
246+ canonical : false ,
247+ } ,
248+ ] ;
249+
74250describe ( 'meta state-introspection route — docs spell it the way the ledger does' , ( ) => {
75251 const rows = REST_ROUTE_LEDGER . filter ( ( r ) => r . client === 'meta.getLegalNextStates' ) ;
76252
@@ -89,6 +265,33 @@ describe('meta state-introspection route — docs spell it the way the ledger do
89265 expect ( canonicalPath ) . toMatch ( / ^ \/ a p i \/ v 1 \/ m e t a \/ o b j e c t \/ : n a m e \/ s t a t e \/ : f i e l d $ / ) ;
90266 } ) ;
91267
268+ // Both nets, one derivation. A literal segment of the ledger row is exact in
269+ // the canonical net and TOLERANT of a longer word in the discovery net —
270+ // which is what turns `object` into `objects` and `meta` into `metadata`
271+ // without either retired spelling being written down here. A placeholder
272+ // segment is a free value in both.
273+ const segments = canonicalPath . split ( '/' ) . filter ( Boolean ) ;
274+ const canonicalSource = `/${ segments
275+ . map ( ( seg ) => ( seg . startsWith ( ':' ) ? SEGMENT_VALUE : escapeForRegExp ( seg ) ) )
276+ . join ( '/' ) } `;
277+ const mentionSource = `/${ segments
278+ . map ( ( seg ) => ( seg . startsWith ( ':' ) ? SEGMENT_VALUE : `${ escapeForRegExp ( seg ) } [A-Za-z0-9_]*` ) )
279+ . join ( '/' ) } `;
280+ const MENTION = new RegExp ( mentionSource , 'g' ) ;
281+ const CANONICAL = new RegExp ( `^${ canonicalSource } $` ) ;
282+
283+ it ( 'the discovery net is derived from the ledger row and is wider than the canonical one' , ( ) => {
284+ // A net narrower than the thing it is meant to contain would drop the
285+ // canonical spelling itself out of the population, and every file would
286+ // then pass by never being looked at.
287+ expect ( mentionsIn ( canonicalPath , MENTION ) . map ( ( m ) => m . text ) ) . toEqual ( [ canonicalPath ] ) ;
288+ expect ( CANONICAL . test ( canonicalPath ) ) . toBe ( true ) ;
289+ // One tolerance group per LITERAL segment: a derivation that stopped
290+ // deriving would show up here as a count that no longer tracks the row.
291+ const literals = segments . filter ( ( seg ) => ! seg . startsWith ( ':' ) ) ;
292+ expect ( mentionSource . split ( '[A-Za-z0-9_]*' ) . length - 1 ) . toBe ( literals . length ) ;
293+ } ) ;
294+
92295 for ( const site of TEACHING_SITES ) {
93296 it ( `${ site . label } teaches the canonical path` , ( ) => {
94297 // readFileSync throws on a moved/renamed file rather than passing
@@ -104,4 +307,61 @@ describe('meta state-introspection route — docs spell it the way the ledger do
104307 ) . toBe ( true ) ;
105308 } ) ;
106309 }
310+
311+ it ( 'the detector classifies each known spelling the way its construction says' , ( ) => {
312+ const verdicts = PREDICATE_CASES . map ( ( c ) => {
313+ const found = mentionsIn ( c . text , MENTION ) ;
314+ return {
315+ label : c . label ,
316+ mentioned : found . length > 0 ,
317+ canonical : found . length > 0 && found . every ( ( m ) => CANONICAL . test ( m . text ) ) ,
318+ } ;
319+ } ) ;
320+ expect ( verdicts ) . toEqual (
321+ PREDICATE_CASES . map ( ( c ) => ( { label : c . label , mentioned : c . mentioned , canonical : c . canonical } ) ) ,
322+ ) ;
323+ } ) ;
324+
325+ // The corpus, read once. git's answer rather than a crawl — see the header.
326+ const repoRoot = execFileSync ( 'git' , [ 'rev-parse' , '--show-toplevel' ] , {
327+ cwd : HERE ,
328+ encoding : 'utf8' ,
329+ } ) . trim ( ) ;
330+ const corpus = execFileSync (
331+ 'git' ,
332+ [ 'ls-files' , '-z' , '--cached' , '--others' , '--exclude-standard' , '--' , ...CORPUS_ROOTS ] ,
333+ { cwd : repoRoot , encoding : 'utf8' , maxBuffer : 64 * 1024 * 1024 } ,
334+ )
335+ . split ( '\0' )
336+ . filter ( Boolean ) ;
337+ const population = corpus
338+ . map ( ( relPath ) => ( { relPath, found : mentionsIn ( readFileSync ( join ( repoRoot , relPath ) , 'utf8' ) , MENTION ) } ) )
339+ . filter ( ( entry ) => entry . found . length > 0 ) ;
340+
341+ it ( 'the corpus and the population it yields are both real' , ( ) => {
342+ // Two ways this whole half can evaporate into a vacuous green: git
343+ // hands back nothing, or the net matches nothing. Neither is silent.
344+ expect ( corpus . length , 'the authored-prose roots yielded no files at all' ) . toBeGreaterThan ( 100 ) ;
345+ expect (
346+ population . map ( ( p ) => p . relPath ) ,
347+ 'the discovered population no longer contains the sites this guard has always known about, '
348+ + 'so the detector — not the corpus — is what changed' ,
349+ ) . toEqual ( expect . arrayContaining ( TEACHING_SITES . map ( ( s ) => s . label ) ) ) ;
350+ } ) ;
351+
352+ it ( 'every authored page that mentions this route spells it the way the ledger row does' , ( ) => {
353+ const offenders = population . flatMap ( ( { relPath, found } ) =>
354+ found . filter ( ( m ) => ! CANONICAL . test ( m . text ) ) . map ( ( m ) => `${ relPath } :${ m . line } ${ m . text } ` ) ,
355+ ) ;
356+ expect (
357+ offenders ,
358+ `these pages mention the \`meta.getLegalNextStates\` route on the REST wire path and do not `
359+ + `spell it \`${ canonicalPath } \`, the path this package's ledger row declares:\n`
360+ + `${ offenders . join ( '\n' ) } \n`
361+ + 'Update the prose to the ledger spelling (or, if the route itself moved, update the ledger '
362+ + 'first and let this follow). ⛔ Do not "fix" this by narrowing the net: the plural is still '
363+ + 'answered wherever `dispatch()` is the front door, which is why only mentions carrying the '
364+ + 'versioned REST prefix are judged here at all.' ,
365+ ) . toEqual ( [ ] ) ;
366+ } ) ;
107367} ) ;
0 commit comments