diff --git a/scripts/check-issue-citations.mjs b/scripts/check-issue-citations.mjs index 8b6cf12653d..c732ce2e21c 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,17 @@ 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". 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) => 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'); } /* 7. THE LOCAL ROUTE. Offline, over the imported plan: the arms that decide @@ -1361,6 +1531,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 +1583,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; }