Skip to content

Commit 3099c2a

Browse files
committed
gap-ledger: reconcile GitHub issues from KNOWN-GAPS.md (idempotent bot)
The deterministic ledger is the source of truth; this projects it onto issues. test/gap-issues.ts: parse the ledger's JSON blocks, list open `gap-ledger`-labelled issues (fingerprint read from a `<!-- gap-ledger:<id> -->` body marker), then OPEN an issue for each ledger gap with no issue and CLOSE each open issue whose fingerprint left the ledger (the gap was fixed). The content-derived fingerprint is the stable key, so re-runs never duplicate and a fix auto-closes — the OSS-Fuzz model. `--dry-run` prints the plan and touches nothing. .github/workflows/gap-issues.yml runs it on a push that CHANGES KNOWN-GAPS.md (and on manual dispatch), with issues:write — so the ledger and the issue tracker stay in sync automatically as gaps appear and are fixed.
1 parent bae6620 commit 3099c2a

3 files changed

Lines changed: 134 additions & 1 deletion

File tree

.github/workflows/gap-issues.yml

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Reconcile GitHub issues from the gap ledger (KNOWN-GAPS.md → issues), idempotently.
2+
# Runs when the ledger CHANGES on master (a gap appeared or was fixed) and on manual dispatch.
3+
# The deterministic ledger is the source of truth; this only PROJECTS it: opens an issue for a new
4+
# gap, auto-closes the issue when its fingerprint leaves the ledger. See test/gap-issues.ts.
5+
name: gap-ledger-issues
6+
7+
on:
8+
push:
9+
branches: [master]
10+
paths: ['KNOWN-GAPS.md']
11+
workflow_dispatch: {}
12+
13+
permissions:
14+
issues: write
15+
contents: read
16+
17+
# Never run two reconciles at once (they'd race on the same issues).
18+
concurrency:
19+
group: gap-ledger-issues
20+
cancel-in-progress: false
21+
22+
jobs:
23+
reconcile:
24+
runs-on: ubuntu-latest
25+
steps:
26+
- uses: actions/checkout@v4
27+
- uses: actions/setup-node@v4
28+
with: { node-version: '24' }
29+
- run: node test/gap-issues.ts
30+
env:
31+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,9 @@
4747
"scope-gap:html": "node test/scope-gap-run.ts html",
4848
"scope-gap:yaml": "node test/scope-gap-run.ts yaml",
4949
"scope-gap:vue": "node test/scope-gap-run.ts vue",
50-
"coverage:table": "node test/coverage-table.ts --write"
50+
"coverage:table": "node test/coverage-table.ts --write",
51+
"gap-issues": "node test/gap-issues.ts",
52+
"gap-issues:dry": "node test/gap-issues.ts --dry-run"
5153
},
5254
"devDependencies": {
5355
"@types/node": "^25.9.1",

test/gap-issues.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
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

Comments
 (0)