Skip to content

Commit 58563be

Browse files
os-zhuangclaude
andauthored
fix(devx): read the ledger row recognizer through typeDeclRegions too (#10900)
* fix(devx): read the ledger row recognizer through typeDeclRegions too The recognizer read through codeOnly (#10683) but not through typeDeclRegions, which is the second of #10500's two exact discriminators for 'is this declaration in code position'. Type declarations are code, so a literal-union `route: 'GET /a' | 'GET /b'` TYPE member still minted a row — silently, for the same two reasons as the comment case: rows and routesDeclared moved together so the partial-read guard saw no gap, and outsideCode could not see it because the lead genuinely is in code position. The row loop and the first term of the denominator now skip the same region list declarationsIn has read since #10500, so the two scans answer the question the same way instead of drifting while both look right. Measured across all seven live ledgers, before and after: identical — 259 of 259 rows read, 221 of 221 client-bound, 176 unreachable, and the full --bridge-coverage --json byte-identical. 0 quoted route:/client: leads sit inside a type declaration today. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt * docs(devx): state that the row recognizer reads through both exclusions The README's type-declaration paragraph described the exclusion as the COUNTER's. After #10683 and #10793 the recognizer reads through both masks too, and a paragraph that stops at the counter reads as if the recognizer still parses prose. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 409077e commit 58563be

2 files changed

Lines changed: 165 additions & 7 deletions

File tree

scripts/docs-audit/README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,23 @@ sentence *"It never named a mounted route: the branch"*. Both exclusions are pin
163163
today's accurate ledgers red (`259 of 266`), which is why a bare "count every `route:`"
164164
check was never an option.
165165

166+
**The row recognizer reads through both of those exclusions too** — comments and string
167+
payloads since #10683, type declarations since #10793. Before each, the recognizer read a
168+
source the rest of the file had already ruled out, and a lead it should not have read did
169+
not merely mis-count: it became a **row**. Both were silent for the same reason — `rows`
170+
and the first term of `routesDeclared` moved together, so the partial-read verdict, which
171+
keys on the gap between them, had no gap to see. The type-declaration case is the one a
172+
quote test cannot catch on its own: a literal-union member (`route: 'GET /a' | 'GET /b'`)
173+
opens with the very quote the recognizer reads. Both moves were priced on a tree carrying
174+
no instance of the shape, and `259 of 259` / `221 of 221` / 176 unreachable came out
175+
byte-identical across each.
176+
177+
A skipped type member is reported **nowhere**, and that is the intended answer rather than
178+
a new silence: it is a correct declaration of a *type*, not a table row — the same rule
179+
under which the `route: string;` member of all seven entry interfaces has always produced
180+
nothing. What the prose case gets instead is `prose-quoted leads`, because a lead sitting
181+
where the mask says code is not is a would-be row and worth naming.
182+
166183
### What it cannot see is reported, never implied
167184

168185
`anchorlessChanges` lists changed files that yielded no anchor at all; a non-empty value

scripts/docs-audit/affected-docs.mjs

Lines changed: 148 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1477,6 +1477,15 @@ function declinedIn(s) {
14771477
* all seven ledgers. `outsideCode` is what keeps the fix from trading a phantom row for a
14781478
* new silence — it NAMES every lead the mask dropped, and carries no verdict.
14791479
*
1480+
* …AND THROUGH BOTH OF #10500'S DISCRIMINATORS, NOT ONE (#10793). `codeOnly` was only half
1481+
* of the answer: type declarations are CODE, so a literal-union `route: 'GET /a' | 'GET /b'`
1482+
* TYPE member still minted a row — the identical silent shape arriving through the other
1483+
* discriminator, with `rows` and `routesDeclared` moving together again so no verdict fired.
1484+
* The recognizer and the first term of its denominator now skip `typeDeclRegions` the way
1485+
* `declarationsIn` has since #10500, so the two scans answer "is this in code position?" the
1486+
* same way instead of drifting while both look right. Delta 0 on all seven ledgers again:
1487+
* none of them declares a quoted `route:` inside a type declaration.
1488+
*
14801489
* @returns {{rows: Array<{route: string, client: string|null}>, declined: Array<{key: string, line: number, text: string}>, routesDeclared: number, clientsDeclared: number, outsideCode: Array<{key: string, line: number, text: string}>}}
14811490
*/
14821491
function parseLedgerSource(text) {
@@ -1509,13 +1518,39 @@ function parseLedgerSource(text) {
15091518
// one tree where the move is provably free: no ledger quotes a `route:` in prose today,
15101519
// and `rows`, `routesDeclared`, `clientsDeclared` and the 176 UNREACHABLE rows are
15111520
// byte-identical across the change (all seven ledgers, delta 0).
1512-
// ⛔ ONE of #10500's two discriminators, not both. `typeDeclRegions` is still NOT applied
1513-
// here, so a literal-union `route: 'GET /a' | 'GET /b'` TYPE member is still read as a row
1514-
// — the same silent shape, through the other discriminator. Measured on this tree: 0 such
1515-
// members across all seven ledgers (every one declares `route: string;`). Applying it is a
1516-
// SECOND population move and wants its own before/after, which is why it is #10793 and not
1517-
// a rider here.
1521+
// BOTH OF #10500'S DISCRIMINATORS NOW, NOT ONE (#10793). `codeOnly` blanks comments and
1522+
// string CONTENTS; it does not blank TYPE DECLARATIONS, and has no reason to — they are
1523+
// code. So a literal-union `route: 'GET /a' | 'GET /b'` TYPE member was still read as a
1524+
// ROW: the identical silent shape the paragraph above describes, arriving through the
1525+
// other discriminator. It stayed silent for the same two reasons — `rows` and
1526+
// `routesDeclared` moved together, so the partial-read guard saw no gap, and `outsideCode`
1527+
// could not see it either, because the lead genuinely IS in code position. Measured on the
1528+
// tree this landed on, one file declaring ONE row:
1529+
//
1530+
// export interface Entry { route: 'GET /api/v1/gone' | 'GET /api/v1/meta'; client: string }
1531+
// export const L = [
1532+
// { route: 'GET /api/v1/meta', family: 'metadata', disposition: 'sdk', client: 'meta.getTypes' },
1533+
// ];
1534+
// ⇒ rows 2 · routesDeclared 2 · clientsDeclared 1 · declined 0 · outsideCode 0
1535+
//
1536+
// ⛔ A SKIPPED TYPE MEMBER IS SILENT ON PURPOSE, and that is NOT the silence `outsideCode`
1537+
// exists to break. A prose-quoted lead is a would-be row sitting where the mask says code
1538+
// is not, so naming it tells the reader something they can act on. A type member is a
1539+
// correct declaration of a TYPE: the `route: string;` member all seven entry interfaces
1540+
// declare produces nothing today under the same rule, `declarationsIn` has excluded the
1541+
// literal-union spelling since #10500, and `--self-test` pins BOTH directions — deleting
1542+
// the interface changes no count at all. This makes the recognizer agree with the position
1543+
// the rest of the file already held rather than inventing a second one.
1544+
//
1545+
// The population move was priced on the tree where it is provably free: 0 quoted `route:`
1546+
// or `client:` leads inside a type declaration across all seven ledgers, and
1547+
// `259 of 259` / `221 of 221` / 176 UNREACHABLE are byte-identical across the change.
15181548
const code = codeOnly(text);
1549+
// The SECOND discriminator, taken over the already-blanked source — the same call
1550+
// `declarationsIn` makes, reusing the same region list rather than growing a second idea
1551+
// of what a type member is.
1552+
const typeDecls = typeDeclRegions(code);
1553+
const inTypeDecl = (i) => typeDecls.some(([a, b]) => i >= a && i <= b);
15191554
// The value as WRITTEN. `codeOnly` blanks string CONTENTS, so a capture group taken off
15201555
// `code` is a run of spaces of the right length and never the route. Blanking preserves
15211556
// LENGTH, so cutting the raw text by the masked match's offsets is exact for any content
@@ -1525,6 +1560,9 @@ function parseLedgerSource(text) {
15251560
const routeRe = /route\s*:\s*'([^']+)'/g;
15261561
let m;
15271562
while ((m = routeRe.exec(code)) !== null) {
1563+
// …and never a TYPE member (#10793). A literal-union member opens with the very quote
1564+
// this regex reads, which is exactly why `typeDeclRegions` and not a spelling test.
1565+
if (inTypeDecl(m.index)) continue;
15281566
const rest = code.slice(m.index, routeRe.lastIndex + 1200);
15291567
const nextRoute = rest.slice(1).search(/route\s*:\s*'/);
15301568
const window = nextRoute === -1 ? rest : rest.slice(0, nextRoute + 1);
@@ -1590,7 +1628,13 @@ function parseLedgerSource(text) {
15901628
// denominator that counted prose-quoted leads while the numerator no longer read them
15911629
// would turn this migration into a permanently red ratio on the very files it fixed.
15921630
// Both terms move together, which is the invariant the block above insists on.
1593-
const routesDeclared = [...code.matchAll(/route\s*:\s*'/g)].length + declined.filter((d) => d.key === 'route').length;
1631+
//
1632+
// …and it skips TYPE MEMBERS on the same list the loop above does (#10793). Both terms
1633+
// move together or the partition `rows + declined === routesDeclared` breaks: a member
1634+
// counted here but skipped there would read as a row this parse declined to read, and
1635+
// fire a PARTIAL-read verdict on an accurate ledger.
1636+
const routesDeclared = [...code.matchAll(/route\s*:\s*'/g)].filter((d) => !inTypeDecl(d.index)).length
1637+
+ declined.filter((d) => d.key === 'route').length;
15941638
const clientsDeclared = rows.filter((r) => r.client).length + declined.filter((d) => d.key === 'client').length;
15951639
declined.sort((a, b) => a.line - b.line || a.key.localeCompare(b.key));
15961640
// THE REPORTING HALF (#10683). The mask closes the phantom-row hole by making a
@@ -2278,6 +2322,103 @@ function selfTest() {
22782322
check('parseLedgerSource', 'a `route:` inside a string PAYLOAD is not a row', 'row count', 1, payload.rows.length);
22792323
check('parseLedgerSource', 'and it is named, not silently dropped', 'outsideCode', 1, payload.outsideCode.length);
22802324

2325+
// ---- A LITERAL-UNION `route:` TYPE MEMBER IS NOT A ROW (#10793) ---------------
2326+
// The OTHER half of "is this in code position", and the half `codeOnly` cannot answer:
2327+
// type declarations ARE code, so a member spelled `route: 'GET /a' | 'GET /b'` opens with
2328+
// the very quote the recognizer reads and used to become a ROW. Silent in the same two
2329+
// ways as the comment case above — `rows` and `routesDeclared` moved together so the
2330+
// partial-read guard saw no gap, and `outsideCode` saw nothing either because the lead
2331+
// really IS in code position. `orphanSource` above pins this exact spelling on the
2332+
// `client:` key, written there because a literal-union member opens with that quote; this
2333+
// is the `route:` twin, which had nothing at all keeping it out of the row loop.
2334+
//
2335+
// ⚠️ PINNED IN BOTH DIRECTIONS, like the mask cases: a fix that reached the type member by
2336+
// swallowing the table AFTER it — a brace match that ran away — would pass a test asserting
2337+
// only that the phantom is gone, while silently dropping all 259 live rows.
2338+
const typeUnionSource = [
2339+
"export interface Entry { route: 'GET /api/v1/gone' | 'GET /api/v1/meta'; client: string }",
2340+
'export const L = [',
2341+
" { route: 'GET /api/v1/meta', family: 'metadata', disposition: 'sdk', client: 'meta.getTypes' },",
2342+
'];',
2343+
].join('\n');
2344+
const typeUnion = parseLedgerSource(typeUnionSource);
2345+
check('parseLedgerSource', 'a literal-union `route:` TYPE member is not a row', 'row count',
2346+
1, typeUnion.rows.length);
2347+
check('parseLedgerSource', 'and the row in CODE still is — carrying its VALUE, not the member\'s', 'row',
2348+
'GET /api/v1/meta → meta.getTypes', `${typeUnion.rows[0]?.route}${typeUnion.rows[0]?.client}`);
2349+
check('parseLedgerSource', 'the denominator drops it too, so no phantom gap opens', 'declared',
2350+
'1 route / 1 client', `${typeUnion.routesDeclared} route / ${typeUnion.clientsDeclared} client`);
2351+
check('parseLedgerSource', 'and nothing is billed as declined for it', 'declined',
2352+
0, typeUnion.declined.length);
2353+
// THE REJECT SIDE, ASSERTED POSITIVELY — `orphanSource`'s discipline on the other key. A
2354+
// type member must reach NO report: not `declined` (it is not an unread row) and not
2355+
// `outsideCode` (it is not prose). "Produces nothing" is correct here and only here,
2356+
// because it is a declaration of a TYPE and never a table row.
2357+
check('parseLedgerSource', 'a type member reaches no report at all — it is not an unread row', 'line 1 entries',
2358+
false, typeUnion.declined.some((d) => d.line === 1) || typeUnion.outsideCode.some((d) => d.line === 1));
2359+
const typeUnionNoInterface = parseLedgerSource(typeUnionSource.split('\n').slice(1).join('\n'));
2360+
check('parseLedgerSource', 'and deleting the interface changes NOTHING — it contributed no count',
2361+
'declared', '1 row / 1 route / 1 client / 0 declined',
2362+
`${typeUnionNoInterface.rows.length} row / ${typeUnionNoInterface.routesDeclared} route / ${typeUnionNoInterface.clientsDeclared} client / ${typeUnionNoInterface.declined.length} declined`);
2363+
2364+
// The expensive shape, as on the comment side: a member that unions a `client:` too used to
2365+
// mint a FULLY CLIENT-BOUND phantom, which then joined the UNREACHABLE population — no
2366+
// registrar tail can match a route nobody mounts — and inflated the 176 with no verdict.
2367+
const typeUnionClient = parseLedgerSource([
2368+
"export interface Entry { route: 'GET /api/v1/gone'; client: 'meta.getGone' }",
2369+
'export const L = [',
2370+
" { route: 'GET /api/v1/meta', family: 'metadata', disposition: 'sdk', client: 'meta.getTypes' },",
2371+
'];',
2372+
].join('\n'));
2373+
check('parseLedgerSource', 'a member unioning a `client:` too mints no client-bound phantom', 'client rows',
2374+
1, typeUnionClient.rows.filter((r) => r.client).length);
2375+
check('parseLedgerSource', 'and the surviving row keeps its OWN binding, never the member\'s', 'client',
2376+
'meta.getTypes', typeUnionClient.rows[0]?.client);
2377+
2378+
// `type X = { … }` is the other spelling `typeDeclRegions` recognises, and the recognizer
2379+
// reads the same region list rather than a second idea of what a type member is.
2380+
const typeAlias = parseLedgerSource([
2381+
"export type Entry = { route: 'GET /api/v1/gone' | 'GET /api/v1/meta'; client: string };",
2382+
'export const L = [',
2383+
" { route: 'GET /api/v1/meta', family: 'metadata', disposition: 'sdk', client: 'meta.getTypes' },",
2384+
'];',
2385+
].join('\n'));
2386+
check('parseLedgerSource', 'a `type X = { … }` member is skipped on the same list', 'row count',
2387+
1, typeAlias.rows.length);
2388+
check('parseLedgerSource', 'and its row still reads', 'row', 'GET /api/v1/meta → meta.getTypes',
2389+
`${typeAlias.rows[0]?.route}${typeAlias.rows[0]?.client}`);
2390+
2391+
// THE RUNAWAY-BRACE DIRECTION, from the other side: the region ends where the declaration's
2392+
// brace does, so a table BEFORE a type declaration is read whole and the member after it is
2393+
// still skipped. The failure this pins is a skip that starts at the member and never ends.
2394+
const typeUnionTrailing = parseLedgerSource([
2395+
'export const L = [',
2396+
" { route: 'GET /api/v1/meta', family: 'metadata', disposition: 'sdk', client: 'meta.getTypes' },",
2397+
" { route: 'GET /api/v1/docs', family: 'metadata', disposition: 'server-only' },",
2398+
'];',
2399+
"export interface Entry { route: 'GET /api/v1/gone' | 'GET /api/v1/meta'; client: string }",
2400+
].join('\n'));
2401+
check('parseLedgerSource', 'a table BEFORE a type declaration is read whole', 'row count',
2402+
2, typeUnionTrailing.rows.length);
2403+
check('parseLedgerSource', 'with both values and the one binding intact', 'rows',
2404+
'GET /api/v1/meta → meta.getTypes | GET /api/v1/docs → null',
2405+
typeUnionTrailing.rows.map((r) => `${r.route}${r.client}`).join(' | '));
2406+
check('parseLedgerSource', 'and the trailing member still contributes no count', 'declared',
2407+
'2 route / 1 client / 0 declined',
2408+
`${typeUnionTrailing.routesDeclared} route / ${typeUnionTrailing.clientsDeclared} client / ${typeUnionTrailing.declined.length} declined`);
2409+
2410+
// The verdict a reader actually sees carries none of it — a ledger whose entry interface
2411+
// spells its `route:` as a union is an ACCURATE ledger, and reddening CI over it would be
2412+
// the same false red the type-member exclusion exists to prevent.
2413+
const typeUnionCov = bridgeCoverageFrom([{ file: 'i-route-ledger.ts', ...typeUnion }], ['/api/v1/meta']);
2414+
check('bridgeCoverageFrom', 'a type-member lead carries NO broken-scan verdict', 'brokenScan',
2415+
0, typeUnionCov.brokenScan.length);
2416+
check('bridgeCoverageFrom', 'and the ratios it must not move are whole', 'read',
2417+
'1 of 1 route / 1 of 1 client',
2418+
`${typeUnionCov.rowsParsed} of ${typeUnionCov.routesDeclared} route / ${typeUnionCov.clientRows} of ${typeUnionCov.clientsDeclared} client`);
2419+
check('bridgeCoverageFrom', 'and it is not billed as a prose lead either', 'leadsOutsideCode',
2420+
0, typeUnionCov.leadsOutsideCode);
2421+
22812422
// ⛔ REPORTED, NEVER A VERDICT. A comment explaining a retired row by quoting its old path
22822423
// is legitimate prose; reddening CI over it is the false red the #9747 family declines.
22832424
const phantomCov = bridgeCoverageFrom([{ file: 'h-route-ledger.ts', ...phantom }], ['/api/v1/meta']);

0 commit comments

Comments
 (0)