|
| 1 | +// ───────────────────────────────────────────────────────────────────────────── |
| 2 | +// gap-issues.ts — RECONCILE GitHub issues from the gap ledger (KNOWN-GAPS.md). |
| 3 | +// |
| 4 | +// The deterministic ledger (test/gap-ledger.ts) is the SOURCE OF TRUTH — a committed, |
| 5 | +// fingerprinted list of valid-input flat-highlighter divergences the generative |
| 6 | +// scope≡role check found. This bot projects it onto GitHub issues, IDEMPOTENTLY: |
| 7 | +// |
| 8 | +// • a ledger gap with NO open issue → OPEN one (body carries `<!-- gap-ledger:<id> -->`, |
| 9 | +// the stable fingerprint key, so re-runs never duplicate). |
| 10 | +// • an open `gap-ledger` issue whose fingerprint is NO LONGER in the ledger → CLOSE it |
| 11 | +// (the gap left the ledger — the highlighter was fixed). |
| 12 | +// • a gap already issued → leave it (no-op). |
| 13 | +// |
| 14 | +// Because the fingerprint is content-derived and the ledger is deterministic, this is a |
| 15 | +// pure function of (ledger, open issues) → it never spams and auto-closes on fix — the |
| 16 | +// OSS-Fuzz model. The CI workflow (.github/workflows/gap-issues.yml) runs it on a push |
| 17 | +// that changes KNOWN-GAPS.md (and on manual dispatch). |
| 18 | +// |
| 19 | +// Run: node test/gap-issues.ts # reconcile live (needs `gh` auth / GH_TOKEN) |
| 20 | +// node test/gap-issues.ts --dry-run # print the plan, touch nothing |
| 21 | +// ───────────────────────────────────────────────────────────────────────────── |
| 22 | +import { readFileSync } from 'node:fs'; |
| 23 | +import { execFileSync } from 'node:child_process'; |
| 24 | + |
| 25 | +const DRY = process.argv.includes('--dry-run'); |
| 26 | +const LEDGER = 'KNOWN-GAPS.md'; |
| 27 | +const LABEL = 'gap-ledger'; |
| 28 | +const marker = (id: string) => `<!-- gap-ledger:${id} -->`; |
| 29 | +const MARKER_RE = /<!-- gap-ledger:([0-9a-f]+) -->/; |
| 30 | + |
| 31 | +interface Gap { id: string; language: string; kind: string; repro: string; tokenType: string; tokenText: string; expected: string; got: string; gotScope: string } |
| 32 | + |
| 33 | +// ── parse the ledger's machine-readable JSON blocks (the same objects gap-ledger.ts emits) ── |
| 34 | +function readGaps(): Gap[] { |
| 35 | + const md = readFileSync(LEDGER, 'utf8'); |
| 36 | + const out: Gap[] = []; |
| 37 | + const re = /```json\n([\s\S]*?)\n```/g; |
| 38 | + let m: RegExpExecArray | null; |
| 39 | + while ((m = re.exec(md))) { |
| 40 | + try { const g = JSON.parse(m[1]); if (g && g.id && g.language) out.push(g); } catch { /* skip a malformed block */ } |
| 41 | + } |
| 42 | + return out; |
| 43 | +} |
| 44 | + |
| 45 | +function gh(args: string[]): string { |
| 46 | + return execFileSync('gh', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); |
| 47 | +} |
| 48 | +// open `gap-ledger`-labelled issues → their fingerprint (from the body marker) → issue number |
| 49 | +function openIssues(): Map<string, number> { |
| 50 | + const raw = gh(['issue', 'list', '--label', LABEL, '--state', 'open', '--json', 'number,body', '--limit', '500']); |
| 51 | + const arr: { number: number; body: string }[] = JSON.parse(raw || '[]'); |
| 52 | + const byId = new Map<string, number>(); |
| 53 | + for (const it of arr) { const mm = MARKER_RE.exec(it.body ?? ''); if (mm) byId.set(mm[1], it.number); } |
| 54 | + return byId; |
| 55 | +} |
| 56 | + |
| 57 | +function title(g: Gap): string { return `[gap-ledger] ${g.language}: ${g.repro.replace(/\n/g, '\\n').slice(0, 50)}`; } |
| 58 | +function body(g: Gap): string { |
| 59 | + return [ |
| 60 | + 'Auto-filed by the **gap ledger** (`test/gap-ledger.ts` → `KNOWN-GAPS.md`). The generative scope≡role', |
| 61 | + 'check found a flat-highlighter divergence from the Monogram parser on **valid input** — the floor-blind', |
| 62 | + 'class the corpus-bound scope-gap metric is blind to. **This issue auto-CLOSES when the gap leaves the', |
| 63 | + 'ledger** (i.e. when the highlighter is fixed and the next ledger regen drops it).', |
| 64 | + '', |
| 65 | + `- **Language:** ${g.language}`, |
| 66 | + `- **Minimal repro:** \`${g.repro.replace(/\n/g, '\\n')}\``, |
| 67 | + `- **Divergent token:** \`${g.tokenText.replace(/\n/g, '\\n')}\` (parser token \`${g.tokenType}\`)`, |
| 68 | + `- **Role vs scope:** want **${g.expected}**, got **${g.got}** (highlighter scope \`${g.gotScope}\`)`, |
| 69 | + `- **Fingerprint:** \`${g.id}\``, |
| 70 | + '', |
| 71 | + marker(g.id), |
| 72 | + ].join('\n'); |
| 73 | +} |
| 74 | + |
| 75 | +// ── reconcile ── |
| 76 | +const gaps = readGaps(); |
| 77 | +const ledgerIds = new Set(gaps.map((g) => g.id)); |
| 78 | + |
| 79 | +let existing: Map<string, number>; |
| 80 | +try { |
| 81 | + existing = openIssues(); |
| 82 | +} catch (e: any) { |
| 83 | + if (DRY) { existing = new Map(); console.error('(dry-run: `gh` unavailable/unauthed — assuming no existing issues)'); } |
| 84 | + else { console.error('gap-issues: `gh` is required to reconcile live. Authenticate (`gh auth login`) or run with --dry-run.\n' + (e?.message ?? e)); process.exit(1); } |
| 85 | +} |
| 86 | + |
| 87 | +const toOpen = gaps.filter((g) => !existing!.has(g.id)); |
| 88 | +const toClose = [...existing!.entries()].filter(([id]) => !ledgerIds.has(id)); |
| 89 | + |
| 90 | +console.log(`gap ledger: ${gaps.length} gap(s) · open issues: ${existing!.size} · to open: ${toOpen.length} · to close: ${toClose.length}${DRY ? ' [DRY-RUN]' : ''}`); |
| 91 | + |
| 92 | +for (const g of toOpen) { |
| 93 | + console.log(` + OPEN ${g.id} ${title(g)}`); |
| 94 | + if (!DRY) { const url = gh(['issue', 'create', '--title', title(g), '--body', body(g), '--label', LABEL]).trim(); console.log(` → ${url}`); } |
| 95 | +} |
| 96 | +for (const [id, num] of toClose) { |
| 97 | + console.log(` - CLOSE #${num} (gap ${id} no longer in the ledger — resolved)`); |
| 98 | + if (!DRY) gh(['issue', 'close', String(num), '--comment', `Resolved — gap \`${id}\` is no longer in the ledger (\`KNOWN-GAPS.md\`); the highlighter no longer diverges here. Auto-closed by \`test/gap-issues.ts\`.`]); |
| 99 | +} |
| 100 | +if (!toOpen.length && !toClose.length) console.log(' (in sync — nothing to do)'); |
0 commit comments