From 451b0d64994cf5e90370f06f820d5a706113faf4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 23:08:08 +0000 Subject: [PATCH 1/2] fix(scripts): check-issue-citations extracts every measured citation spelling, and pins them in one table The extractor hid real citations behind exclusions wider than anything they protected. Measured over the declared surfaces at 3693a1b50d against one enumerated board: - a hyphen after the number hid 58 `#N-word` sites and protected none; the one non-citation shape it covered repo-wide (a markdown in-page heading anchor) keeps a narrower `](` head row; - a `/` before the `#` hid 528 sites (523 slash-joined second numbers); it stays only on the candidate arm, where it keeps URL path fragments out, and a slash-joined continuation reads as its chain (qualifier or ordinal); - the `re-charter`, `acceptance`, `clause`, `option` and section-mark head rows each protected 0 sites repo-wide and hid board citations; retired; - the URL spelling (`https://github.com/OWNER/REPO/issues/N`, `/pull/N`) is now a citation, qualified by its own OWNER/REPO, counted once inside a `[#N](URL)` link. The self-test gains the one enumeration table (48 spellings, each "extracted as" or "not a citation, because"), completeness rules over the head rows and the required spellings, and three live-corpus floors for the new arms. Claude-Session: https://claude.ai/code/session_017VaLJnYwhPsanVCe9dMCJU Co-authored-by: Claude --- scripts/check-issue-citations.mjs | 251 ++++++++++++++++++++++++++---- 1 file changed, 221 insertions(+), 30 deletions(-) diff --git a/scripts/check-issue-citations.mjs b/scripts/check-issue-citations.mjs index 8b6cf12653d..3fa16ba5a0a 100644 --- a/scripts/check-issue-citations.mjs +++ b/scripts/check-issue-citations.mjs @@ -104,9 +104,9 @@ * #5`, `ADR §3.10 #2`) -- zero of them name an issue. * - Six digits maximum, and zero such tokens exist in the declared surfaces, * which is what keeps a six-digit hex colour out of the finding set. - * - `NON_CITATION_HEADS`: `Directive #14`, `batch #127`, `re-charter #26` and - * their siblings are ordinals in a numbering system that is not the board's. - * 405 sites in package source, measured. + * - `NON_CITATION_HEADS`: `Directive #14`, `batch #127`, `PKCS#11` and their + * siblings are ordinals in a numbering system that is not the board's; a + * row stays only while it protects a measured population (#20636 closeout). * * Source files are read through `scripts/symbol-anchors.mjs#commentProse`, so a * gate's own fixtures and any `#N` inside a string literal are blanked -- the @@ -444,31 +444,78 @@ const PROSE_QUALIFIER_RE = new RegExp( * `#12` cannot both match the same characters), group 2 is the optional * CANDIDATE qualifier -- `repositoryOf` decides whether it names a repository * -- and group 3 is the number. + * + * ⭐ THE EXTRACTOR-SPELLING CLOSEOUT (#20636). Two exclusions here were wider + * than anything they protected, and each hid real citations from the diff gate + * and the census alike. Measured on `3693a1b50d` (2026-09-30) over the declared + * surfaces, every arm judged against one enumerated board (frontier #20959): + * + * A hyphen after the number, `(?![\w-])`. It hid 58 `#N-word` sites + * (`#13398-class`, `#5347-A`, `ui#6206-B`), 1 of them dead, and protected + * NONE there: 0 numeric ranges, 0 slugs, 0 hex-like tokens. Repo-wide the + * one non-citation shape it covered is a markdown in-page heading anchor, + * `[13. Risks](#13-risks--mitigations)` (16 lines, all in `docs/design/**` + * and `skills/**`), which keeps a narrower guard: the `](` row of + * `NON_CITATION_HEADS`. Now `(?!\w)`, which still keeps `#12ab34` out. + * A `/` before the `#`. It hid 528 sites, 10 of them dead: 523 slash-joined + * second numbers (`#7737/#10629`) and 5 `TOKEN/#N` (`ADR-0049/#1888`), and + * protected no path and no URL fragment there. What it does protect is a + * CANDIDATE starting mid-path -- `https://host/docs/page#12` would read + * `page` as prose and judge #12 -- so it stays on that arm only, and there + * it yields to a `/` that joins onto a number (`#3076/objectui#2614`). + * + * A slash-joined continuation reads as its chain; see `extractCitations`. */ export const CITATION_RE = new RegExp( - '(^|[^\\w#/-])(?:([A-Za-z0-9][A-Za-z0-9._-]*(?:/[A-Za-z0-9][A-Za-z0-9._-]*)?)#|#)' - + `(\\d{${CITATION_MIN_DIGITS},${CITATION_MAX_DIGITS}})(?![\\w-])`, + '(^|[^\\w#-])(?:(? }} [opts] @@ -496,25 +557,42 @@ export function extractCitations(text, { projection = 'whole-file', onlyLines = projectedLines.forEach((line, ix) => { const lineNo = ix + 1; if (onlyLines && !onlyLines.has(lineNo)) return; + const context = (sourceLines[ix] ?? '').trim().slice(0, 160); + const found = []; const re = new RegExp(CITATION_RE.source, 'g'); + let chain = null; let m; while ((m = re.exec(line)) !== null) { /* Everything up to the `#`, a refused candidate included: `pre-` is prose, * and `PD` in `PD#12` is the head `NON_CITATION_HEADS` reads. */ - const before = line.slice(0, m.index + m[0].length - m[3].length - 1); - const joined = repositoryOf(m[2]) ? m[2] : null; - const prose = joined ? null : PROSE_QUALIFIER_RE.exec(before); - const qualifier = joined ?? prose?.[1] ?? null; - if (!qualifier && nonCitationHead(before)) continue; - const spelled = joined ?? (prose ? before.slice(prose.index + prose[0].indexOf(prose[1])) : ''); - out.push({ - number: Number(m[3]), - qualifier, - line: lineNo, - raw: `${spelled}#${m[3]}`, - context: (sourceLines[ix] ?? '').trim().slice(0, 160), - }); + const hashAt = m.index + m[0].length - m[3].length - 1; + const before = line.slice(0, hashAt); + const continues = m[1] === '/' && !m[2] && chain !== null && chain.end === m.index; + let reading = continues ? chain.reading : null; + if (!reading) { + const joined = repositoryOf(m[2]) ? m[2] : null; + const prose = joined ? null : PROSE_QUALIFIER_RE.exec(before); + const qualifier = joined ?? prose?.[1] ?? null; + reading = { + qualifier, + excused: !qualifier && nonCitationHead(before) !== null, + spelled: joined ?? (prose ? before.slice(prose.index + prose[0].indexOf(prose[1])) : ''), + }; + } + chain = { end: re.lastIndex, reading }; + if (reading.excused) continue; + found.push({ at: hashAt, cite: { number: Number(m[3]), qualifier: reading.qualifier, line: lineNo, raw: `${reading.spelled}#${m[3]}`, context } }); + } + for (const u of line.matchAll(new RegExp(CITATION_URL_RE.source, 'g'))) { + const number = Number(u[3]); + /* `[#15325](https://github.com/…/issues/15325)` is ONE citation: the link's + * text already carries it, so the target is not counted a second time. */ + const link = /\[[^\]]*\]\($/.exec(line.slice(0, u.index)); + if (link && found.some((f) => f.cite.number === number && f.at > link.index && f.at < u.index)) continue; + found.push({ at: u.index, cite: { number, qualifier: `${u[1]}/${u[2]}`, line: lineNo, raw: u[0], context } }); } + found.sort((a, b) => a.at - b.at); + for (const f of found) out.push(f.cite); }); return out; } @@ -1042,13 +1120,94 @@ const SELF_TEST_BATTERIES = Object.freeze({ transport: 7, 'scope-contract': 12, 'diff-scope': 13, - 'live-corpus': 4, + 'live-corpus': 7, 'proxy-rearm': 10, qualifier: 40, + spellings: 56, }); /** Deleting a roster entry silences its floor, so the roster's size is pinned too. */ -const SELF_TEST_BATTERY_FLOOR = 6; +const SELF_TEST_BATTERY_FLOOR = 9; + +/** + * ⭐ THE ONE ENUMERATION OF CITATION SPELLINGS (#20636). Every spelling this + * gate has been measured against, each asserted either "extracted as" the + * readings in `reads` -- `#N` is THIS repository's number, `owner/repo#N` + * another repository's -- or, with `reads: []`, "not a citation, because" the + * reason given. A grammar change that moves any row turns the `spellings` + * battery red instead of becoming another card: `#N-word`, `#A/#B`, `option #N`, + * `clause #N`, `re-charter #N` and the URL form were each a card's worth of + * blind spot before this table (the family's first was #20330, `pre-#N`). + * + * Two completeness rules ride on it: every `NON_CITATION_HEADS` row must excuse + * at least one row here (a head with no measured spelling is the retired rows' + * failure over again), and every spelling in `REQUIRED_SPELLINGS` must appear. + */ +const SPELLING_TABLE = Object.freeze([ + /* Extracted. */ + { spelling: '#N', text: 'see #12248 for the ruling', reads: ['#12248'] }, + { spelling: '(#N)', text: 'it landed (#12248), then', reads: ['#12248'] }, + { spelling: '#N.', text: 'as ruled in #12248.', reads: ['#12248'] }, + { spelling: 'PR #N', text: 'the PR #8546 landed', reads: ['#8546'] }, + { spelling: 'owner/repo#N', text: 'see objectstack-ai/cloud#861', reads: ['objectstack-ai/cloud#861'] }, + { spelling: 'owner/repo#N naming this repository', text: 'objectstack-ai/objectstack#12248 says', reads: ['#12248'] }, + { spelling: 'repo#N', text: 'see objectui#4356 there', reads: ['objectstack-ai/objectui#4356'] }, + { spelling: 'former-name#N', text: 'Framework#4443 said', reads: ['#4443'] }, + { spelling: 'repo PR #N', text: '(objectui PR #10264, merged)', reads: ['objectstack-ai/objectui#10264'] }, + { spelling: 'pre-#N', text: 'the pre-#12248 shape', reads: ['#12248'] }, + { spelling: 'post-#N', text: 'the post-#12248 shape', reads: ['#12248'] }, + { spelling: 'word#N', text: 'see foo#12248', reads: ['#12248'] }, + { spelling: '#N-word', text: 'a #13398-class blind spot', reads: ['#13398'] }, + { spelling: '#N-letter', text: 'the disposition #5347-A', reads: ['#5347'] }, + { spelling: 'repo#N-letter', text: 'the ui#6206-B family', reads: ['objectstack-ai/objectui#6206'] }, + { spelling: 'prefix-#N-word', text: 'Post-#11663-L4 the invariant', reads: ['#11663'] }, + { spelling: '#A-B', text: 'issues #712-714 point there', reads: ['#712'], + because: 'the second number of a range carries no `#`, and a `#`-less number is no citation spelling' }, + { spelling: '#A/#B', text: 'the same ruling #7737/#10629 made', reads: ['#7737', '#10629'] }, + { spelling: '#A/#B/#C', text: 'the #5611/#5775/#6276 rule', reads: ['#5611', '#5775', '#6276'] }, + { spelling: 'repo#A/#B', text: 'lists paginate (objectui#2711/#2722)', reads: ['objectstack-ai/objectui#2711', 'objectstack-ai/objectui#2722'], + because: 'a slash JOINED to the number continues the chain, which carries its repository' }, + { spelling: 'prefix-#A/#B', text: 'pre-#3912/#3994 databases', reads: ['#3912', '#3994'] }, + { spelling: '#A/repo#B', text: 'the #3076/objectui#2614 pair', reads: ['#3076', 'objectstack-ai/objectui#2614'] }, + { spelling: 'repo#A / #B', text: 'objectui#2715 / #12248', reads: ['objectstack-ai/objectui#2715', '#12248'], + because: 'a spaced slash stands alone, so the second number is read as written' }, + { spelling: 'repo#A + #B', text: 'objectui#6110 + #12248', reads: ['objectstack-ai/objectui#6110', '#12248'] }, + { spelling: 'TOKEN/#N', text: 'unscoped (ADR-0049/#1888)', reads: ['#1888'] }, + { spelling: 'option #N', text: 'the option #14088 gave', reads: ['#14088'] }, + { spelling: 'clause #N', text: 'the clause #18510 removed', reads: ['#18510'] }, + { spelling: 're-charter #N', text: 're-charter #13135 said', reads: ['#13135'] }, + { spelling: 'acceptance #N', text: 'the silent acceptance #6132 closed', reads: ['#6132'] }, + { spelling: '§ #N', text: "§6 #11176's decisions", reads: ['#11176'] }, + { spelling: 'URL', text: 'see https://github.com/objectstack-ai/objectstack/issues/17590 for it', reads: ['#17590'] }, + { spelling: 'URL /pull/', text: 'https://github.com/objectstack-ai/objectstack/pull/20554', reads: ['#20554'] }, + { spelling: 'URL, former name', text: 'https://github.com/objectstack-ai/framework/issues/4443', reads: ['#4443'] }, + { spelling: 'URL, another repository', text: 'https://github.com/objectstack-ai/objectui/issues/9048', reads: ['objectstack-ai/objectui#9048'] }, + { spelling: '[#N](URL)', text: '([#15325](https://github.com/objectstack-ai/objectstack/issues/15325))', reads: ['#15325'], + because: "the link's target is the citation its text already carries, counted once" }, + /* Not a citation. */ + { spelling: '#N, one digit', text: 'acceptance #5 and #7', reads: [], + because: 'below the two-digit floor: all 133 one-digit tokens measured were ordinals' }, + { spelling: '#N, seven digits', text: 'colour #1234567 here', reads: [], because: 'beyond six digits, where a hex colour lives' }, + { spelling: '#N followed by a letter', text: 'the colour #12ab34', reads: [], because: 'digits running into a letter are a hex colour' }, + { spelling: '##N', text: 'a ##12 heading', reads: [], because: 'a `#` before the `#` is a markdown heading marker' }, + { spelling: 'Prime Directive #N', text: 'Prime Directive #14 binds', reads: [], because: 'an AGENTS.md Prime Directive ordinal' }, + { spelling: 'Prefix-Directive-#N', text: 'a Prime-Directive-#12 shape', reads: [], because: 'a hyphen joining a head to its `#` is the same head' }, + { spelling: 'PD#N', text: 'which PD#12 rejects', reads: [], because: 'the abbreviated Prime Directive spelling' }, + { spelling: 'batch #N', text: 'decision batch #127 ruled', reads: [], because: 'a maintainer decision-batch ordinal (276 sites, none above #227)' }, + { spelling: 'OQ#N', text: 'ADR-0076 OQ#10 says', reads: [], because: "an ADR's open-question ordinal" }, + { spelling: 'PKCS#N', text: 'PKCS#11 HSMs', reads: [], because: "a standard's own numbering" }, + { spelling: '](#N-slug)', text: 'see [13. Risks](#13-risks--mitigations)', reads: [], + because: "a markdown link's in-page heading anchor -- the one non-citation the hyphen exclusion covered" }, + { spelling: 'path/page#N', text: 'https://example.com/docs/page#12', reads: [], + because: 'a qualifier candidate never starts mid-path, so a URL fragment is no citation' }, + { spelling: 'ordinal #A/#B', text: 'Prime Directive #12/#14', reads: [], because: 'a slash-joined continuation reads as its chain, here an ordinal' }, +]); + +/** The spellings the closeout card and its folded positions named; each must stay in the table. */ +const REQUIRED_SPELLINGS = Object.freeze([ + '#N', 'owner/repo#N', 'pre-#N', 'post-#N', '#N-word', '#A/#B', '(#N)', '#N.', + 'option #N', 'clause #N', 're-charter #N', 'URL', +]); /** Where an assertion lands when no battery is open — never a declared name. */ const UNATTRIBUTED_BATTERY = '(no battery open)'; @@ -1098,8 +1257,8 @@ export async function selfTest() { check(cites('colour #1234567 here').length === 0, 'a seven-digit token is outside the grammar'); check(cites('Prime Directive #14 binds').length === 0, 'Prime Directive ordinals are not citations'); check(cites('decision batch #127 ruled').length === 0, 'decision-batch ordinals are not citations'); - check(cites('re-charter #26 said').length === 0, 're-charter ordinals are not citations'); - check(cites('ADR §3.10 #12 says').length === 0, 'section ordinals are not citations'); + check(cites('re-charter #13135 said').join() === '#13135', 'a retired head reads no ordinal: `re-charter` measured only tracker numbers, and hid 26 dead ones'); + check(cites('see [13. Risks](#13-risks--mitigations)').length === 0, "a markdown link's in-page heading anchor is not a citation, hyphen or not"); check(cites('PR #8546 landed').join() === '#8546', 'a `PR #N` head is a citation, not an ordinal'); check(cites('the path a/b#12 is not a citation').join() === 'a/b#12', 'a slash-qualified token keeps its qualifier rather than reading as bare'); const prose = extractCitations('const s = "#11111";\n// a note about #22222\n', { projection: 'comment-prose' }); @@ -1264,6 +1423,14 @@ export async function selfTest() { check(live.rows.some((r) => r.qualifier), 'the live corpus must contain at least one cross-repo citation — the arm that must never be reported as a phantom'); check(live.rows.every((r) => !r.qualifier || repositoryOf(r.qualifier) !== null), 'every qualifier the live corpus keeps must name a repository — an unrecognised one kept is the open grammar back'); + /* The three arms the #20636 closeout opened each reach the tree -- the + * floor that tells "no such spelling left" from "the arm went blind". */ + check(live.rows.some((r) => new RegExp(`#${r.number}-[A-Za-z]`).test(r.context)), + 'the live corpus must yield a `#N-word` citation — the hyphen arm reaching the tree'); + check(live.rows.some((r) => new RegExp(`\\d/#${r.number}(?!\\d)`).test(r.context)), + 'the live corpus must yield a slash-joined `#A/#B` second number — the continuation reaching the tree'); + check(live.rows.some((r) => /^https?:\/\//.test(r.raw)), + 'the live corpus must yield a URL-spelled citation — the URL arm reaching the tree'); } /* 7. THE LOCAL ROUTE. Offline, over the imported plan: the arms that decide @@ -1361,6 +1528,30 @@ export async function selfTest() { 'every name for THIS repository resolves to its slug'); } + /* 9. THE SPELLINGS (#20636). The one enumeration table, read row by row, + * then held complete against the heads and the required spellings. */ + battery('spellings'); + { + const OWN = 'objectstack-ai/objectstack'; + const reads = (t) => extractCitations(t).map((c) => `${namesThisRepository(c, OWN) ? '' : repositoryOf(c.qualifier)}#${c.number}`); + const excusedBy = (t) => [...t.matchAll(new RegExp(CITATION_RE.source, 'g'))] + .map((m) => nonCitationHead(t.slice(0, m.index + m[0].length - m[3].length - 1))).filter(Boolean); + for (const row of SPELLING_TABLE) { + const got = reads(row.text); + check(got.join() === row.reads.join(), + `spelling \`${row.spelling}\` in ${JSON.stringify(row.text)} must read ${row.reads.length ? row.reads.join(', ') : 'as NOT a citation'}${row.because ? ` (${row.because})` : ''}; got ${got.length ? got.join(', ') : 'nothing'}`); + } + for (const head of NON_CITATION_HEADS) { + check(SPELLING_TABLE.some((row) => row.reads.length === 0 && excusedBy(row.text).includes(head)), + `the head row for ${head.why} must excuse at least one spelling in the table — a head with no measured spelling is a blind spot`); + } + const labels = new Set(SPELLING_TABLE.map((row) => row.spelling)); + check(REQUIRED_SPELLINGS.every((s) => labels.has(s)) && labels.size === SPELLING_TABLE.length, + `the table must carry every required spelling once (missing: ${REQUIRED_SPELLINGS.filter((s) => !labels.has(s)).join(', ') || 'none'})`); + check(SPELLING_TABLE.every((row) => row.reads.length > 0 || (typeof row.because === 'string' && row.because.length > 20)), + 'every "not a citation" row must say why'); + } + /* ── The floor: every declared battery RAN, and ran its cases ───────────── */ const floorMessages = []; const floorFailure = (m) => { floorMessages.push(m); }; @@ -1389,7 +1580,7 @@ export async function selfTest() { assert(!breached, floorMessages.join('\n ')); const total = [...batterySeen.values()].reduce((a, b) => a + b, 0); - console.log(`✅ check-issue-citations --self-test: grammar narrowed, qualifier a closed set of repositories, four 404 causes kept apart, both board strategies agree, diff scope red AND green, scope contract pinned (${total} cases, ${declared.length} batteries)`); + console.log(`✅ check-issue-citations --self-test: grammar narrowed, every spelling enumerated, qualifier a closed set of repositories, four 404 causes kept apart, both board strategies agree, diff scope red AND green, scope contract pinned (${total} cases, ${declared.length} batteries)`); return SELF_TEST_VERDICT; } From 25d96fcc0066f2e5a13e3d45c17b17a8632528f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 00:01:07 +0000 Subject: [PATCH 2/2] fix(scripts): the live-corpus floors for the new citation arms count a number spelled once on its line An ablation that restored the slash exclusion left the continuation floor green: a line citing the same number twice let a plain citation stand in for the slash-joined one. Each floor now counts only a row whose number appears once in its line. Claude-Session: https://claude.ai/code/session_017VaLJnYwhPsanVCe9dMCJU Co-authored-by: Claude --- scripts/check-issue-citations.mjs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/scripts/check-issue-citations.mjs b/scripts/check-issue-citations.mjs index 3fa16ba5a0a..c732ce2e21c 100644 --- a/scripts/check-issue-citations.mjs +++ b/scripts/check-issue-citations.mjs @@ -1424,10 +1424,13 @@ export async function selfTest() { check(live.rows.every((r) => !r.qualifier || repositoryOf(r.qualifier) !== null), 'every qualifier the live corpus keeps must name a repository — an unrecognised one kept is the open grammar back'); /* The three arms the #20636 closeout opened each reach the tree -- the - * floor that tells "no such spelling left" from "the arm went blind". */ - check(live.rows.some((r) => new RegExp(`#${r.number}-[A-Za-z]`).test(r.context)), + * floor that tells "no such spelling left" from "the arm went blind". A + * row counts only when its number is spelled ONCE on its line, so the same + * number cited elsewhere on that line cannot stand in for the arm. */ + const once = (r) => (r.context.match(new RegExp(`#${r.number}(?!\\d)`, 'g')) ?? []).length === 1; + check(live.rows.some((r) => once(r) && new RegExp(`#${r.number}-[A-Za-z]`).test(r.context)), 'the live corpus must yield a `#N-word` citation — the hyphen arm reaching the tree'); - check(live.rows.some((r) => new RegExp(`\\d/#${r.number}(?!\\d)`).test(r.context)), + check(live.rows.some((r) => once(r) && new RegExp(`\\d/#${r.number}(?!\\d)`).test(r.context)), 'the live corpus must yield a slash-joined `#A/#B` second number — the continuation reaching the tree'); check(live.rows.some((r) => /^https?:\/\//.test(r.raw)), 'the live corpus must yield a URL-spelled citation — the URL arm reaching the tree');