Skip to content

Commit fc648a2

Browse files
os-trumpclaude
andauthored
test(rest): judge every authored page that teaches the meta state route, not a hand-kept list of two (#14769)
* wip(rest): discovered teaching-site population Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza * wip(scripts): ci-filter-parity self-test pin for the new docs/ root Preserved from the previous agent's uncommitted working tree after a container restart. Kept or reverted together with the ci.yml change it pins — justification is the next step, not settled by this commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza * test(rest): correct the header's population measurement to the merged base Two corrections, both re-measured on this tree rather than inherited: - The cited base was 4d0d944 and the tree is now 7a17f3b; the population is re-derived there (721-file corpus, three files on the REST wire path). - The header read the triaged "six files" as if the file's own net produced it. It does not: that count comes from a LINE-scoped sweep, and the three extra files name a DIFFERENT `/meta/object/...` route on a line that also contains the word "state", with no `/state/<field>` tail. That is exactly the sixth PREDICATE_CASES entry, so the distinction is already pinned. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 8f9ef6f commit fc648a2

5 files changed

Lines changed: 342 additions & 27 deletions

File tree

.github/workflows/ci.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,18 @@ jobs:
187187
# the same suite declares are covered by entries the core filter
188188
# already carries).
189189
- 'sdui.manifest.json'
190+
# @objectstack/rest's meta-state-route-doc-spelling test discovers
191+
# its population instead of listing it (#14561): it asks git for
192+
# the authored-prose corpus under content/, docs/ and skills/ and
193+
# judges every file that mentions the route. Verbatim per that
194+
# declaration. `content/**` and `skills/**` above already cover
195+
# two of the three roots; this is the third, and the FIRST entry
196+
# to open the `docs/` root that the WIDTH note above records as
197+
# declined — declined then because no declaration reached past
198+
# `docs/audits/...`, and reached now because one does. The
199+
# narrower audits entry is left as the package that declared it
200+
# spelled it, exactly as the two narrower `skills/` entries were.
201+
- 'docs/**'
190202
191203
test:
192204
# Sharded 6-way BY PACKAGE: a core-touching PR ran the affected suite

packages/rest/src/meta-state-route-doc-spelling.test.ts

Lines changed: 274 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
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
@@ -18,31 +18,108 @@
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';
44121
import { readFileSync } from 'node:fs';
45-
import { dirname, resolve } from 'node:path';
122+
import { dirname, join, resolve } from 'node:path';
46123
import { fileURLToPath } from 'node:url';
47124
import { 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
*/
66148
const STATE_MACHINE_DOC = resolve(HERE, '../../../content/docs/protocol/objectql/state-machine.mdx');
67149
const 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+
74250
describe('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(/^\/api\/v1\/meta\/object\/:name\/state\/:field$/);
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

Comments
 (0)