Skip to content

Commit e85ebd0

Browse files
andreinknvclaude
andcommitted
feat: PR #113 (issue-history) on top of refactors
Mines Fixes/Closes/Resolves #N commits and attributes them to symbols touched by each commit hunks. Lands as a registered IndexHook (issue-history). - Migration 005: symbol_issues table - src/issue-history/ (pure module): mineIssueHistory + parse-diff - src/index-hooks/issue-history.ts (registered hook) - CodeGraph public method: getIssuesForNode - codegraph_node MCP tool now surfaces issue history line - enableIssueHistory flag default true wired through config merge - Removed defensive ensureSymbolIssuesTable guard and its test: the v4-collision bug class is impossible under file-based migrations (PR #118 refactor); filenames collide on the filesystem instead. Tests: 470/471 pass (1 watcher flake under load, isolation OK). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 38887ee commit e85ebd0

16 files changed

Lines changed: 1046 additions & 4 deletions

__tests__/foundation.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -305,7 +305,7 @@ describe('Database Connection', () => {
305305

306306
const version = db.getSchemaVersion();
307307
expect(version).not.toBeNull();
308-
expect(version?.version).toBe(4);
308+
expect(version?.version).toBe(5);
309309

310310
db.close();
311311
});

__tests__/issue-history.test.ts

Lines changed: 390 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,390 @@
1+
/**
2+
* Issue → symbol attribution: parser unit tests + end-to-end mining
3+
* against synthetic git repos.
4+
*/
5+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
6+
import * as fs from 'fs';
7+
import * as os from 'os';
8+
import * as path from 'path';
9+
import { execFileSync } from 'child_process';
10+
import {
11+
extractSymbolFromContext,
12+
extractDeclaration,
13+
} from '../src/issue-history/parse-diff';
14+
import {
15+
mineIssueCommits,
16+
mineIssueHistory,
17+
ISSUE_REGEX,
18+
LAST_MINED_ISSUES_HEAD_KEY,
19+
} from '../src/issue-history';
20+
import CodeGraph from '../src/index';
21+
22+
let HAS_GIT = true;
23+
try {
24+
execFileSync('git', ['--version'], { stdio: 'ignore' });
25+
} catch {
26+
HAS_GIT = false;
27+
}
28+
29+
let testDir: string;
30+
let cg: CodeGraph | null = null;
31+
32+
function git(...args: string[]): string {
33+
return execFileSync('git', args, {
34+
cwd: testDir,
35+
encoding: 'utf-8',
36+
env: {
37+
...process.env,
38+
GIT_AUTHOR_NAME: 'Test',
39+
GIT_AUTHOR_EMAIL: 'test@example.com',
40+
GIT_COMMITTER_NAME: 'Test',
41+
GIT_COMMITTER_EMAIL: 'test@example.com',
42+
GIT_AUTHOR_DATE: process.env.GIT_AUTHOR_DATE,
43+
GIT_COMMITTER_DATE: process.env.GIT_COMMITTER_DATE,
44+
},
45+
stdio: ['pipe', 'pipe', 'pipe'],
46+
}).trim();
47+
}
48+
49+
function commitAt(date: string, files: Record<string, string>, message: string) {
50+
for (const [rel, content] of Object.entries(files)) {
51+
const abs = path.join(testDir, rel);
52+
fs.mkdirSync(path.dirname(abs), { recursive: true });
53+
fs.writeFileSync(abs, content);
54+
}
55+
git('add', '-A');
56+
process.env.GIT_AUTHOR_DATE = date;
57+
process.env.GIT_COMMITTER_DATE = date;
58+
git('commit', '-m', message);
59+
delete process.env.GIT_AUTHOR_DATE;
60+
delete process.env.GIT_COMMITTER_DATE;
61+
}
62+
63+
beforeEach(() => {
64+
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-issues-'));
65+
});
66+
67+
afterEach(() => {
68+
delete process.env.GIT_AUTHOR_DATE;
69+
delete process.env.GIT_COMMITTER_DATE;
70+
if (cg) {
71+
cg.destroy();
72+
cg = null;
73+
}
74+
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
75+
});
76+
77+
// ============================================================================
78+
// Pure parser unit tests
79+
// ============================================================================
80+
81+
describe('ISSUE_REGEX', () => {
82+
it('matches all canonical Fixes/Closes/Resolves verbs', () => {
83+
const cases = [
84+
'Fix #1', 'Fixes #2', 'Fixed #3',
85+
'Close #4', 'Closes #5', 'Closed #6',
86+
'Resolve #7', 'Resolves #8', 'Resolved #9',
87+
];
88+
for (const s of cases) {
89+
ISSUE_REGEX.lastIndex = 0;
90+
expect(ISSUE_REGEX.test(s)).toBe(true);
91+
}
92+
});
93+
94+
it('matches multiple issues in a single body', () => {
95+
ISSUE_REGEX.lastIndex = 0;
96+
const matches = [...'Fixes #1, closes #2 and resolves #3'.matchAll(ISSUE_REGEX)];
97+
expect(matches.map((m) => m[1])).toEqual(['1', '2', '3']);
98+
});
99+
100+
it('is case-insensitive', () => {
101+
ISSUE_REGEX.lastIndex = 0;
102+
expect(ISSUE_REGEX.test('FIXES #42')).toBe(true);
103+
});
104+
105+
it('does NOT match `#N` without a verb', () => {
106+
ISSUE_REGEX.lastIndex = 0;
107+
// Match in body of message that mentions #99 but with no verb prefix.
108+
expect(ISSUE_REGEX.test('See #99 for context')).toBe(false);
109+
});
110+
111+
it('v1 limitation: `Fixes #1, #2` only captures #1', () => {
112+
// Documented behavior — the second issue lacks a verb prefix and
113+
// is silently dropped. Authors who care can write `Fixes #1, fixes #2`.
114+
ISSUE_REGEX.lastIndex = 0;
115+
const matches = [...'Fixes #1, #2'.matchAll(ISSUE_REGEX)];
116+
expect(matches.map((m) => m[1])).toEqual(['1']);
117+
});
118+
});
119+
120+
describe('extractSymbolFromContext', () => {
121+
it('pulls function name from a TS function context', () => {
122+
expect(extractSymbolFromContext('function processOrder(order: Order) {')).toBe('processOrder');
123+
});
124+
it('pulls class name', () => {
125+
expect(extractSymbolFromContext('class UserService {')).toBe('UserService');
126+
});
127+
it('pulls Python def', () => {
128+
expect(extractSymbolFromContext('def compute_score(items):')).toBe('compute_score');
129+
});
130+
it('pulls Go func', () => {
131+
expect(extractSymbolFromContext('func ProcessOrder(o *Order) error {')).toBe('ProcessOrder');
132+
});
133+
it('pulls method-style ` async foo(`', () => {
134+
expect(extractSymbolFromContext(' async foo(args: string) {')).toBe('foo');
135+
});
136+
it('rejects keyword-only contexts', () => {
137+
expect(extractSymbolFromContext(' if (x) {')).toBeNull();
138+
});
139+
it('returns null on empty input', () => {
140+
expect(extractSymbolFromContext('')).toBeNull();
141+
});
142+
});
143+
144+
describe('extractDeclaration', () => {
145+
it('captures + function decl', () => {
146+
expect(extractDeclaration('+function helper() {')).toEqual({ name: 'helper', sign: '+' });
147+
});
148+
it('captures - class decl', () => {
149+
expect(extractDeclaration('-export class Old {')).toEqual({ name: 'Old', sign: '-' });
150+
});
151+
it('captures Python def', () => {
152+
expect(extractDeclaration('+def my_helper(x):')).toEqual({ name: 'my_helper', sign: '+' });
153+
});
154+
it('captures Go func with receiver', () => {
155+
expect(extractDeclaration('+func (s *Service) DoThing() error {')).toEqual({
156+
name: 'DoThing',
157+
sign: '+',
158+
});
159+
});
160+
it('skips file-marker `+++` and `---` lines', () => {
161+
expect(extractDeclaration('+++ b/src/foo.ts')).toBeNull();
162+
expect(extractDeclaration('--- a/src/foo.ts')).toBeNull();
163+
});
164+
it('skips keywords like `+if`', () => {
165+
expect(extractDeclaration('+ if (x) return;')).toBeNull();
166+
});
167+
it('returns null on context lines (no +/-)', () => {
168+
expect(extractDeclaration(' some body line')).toBeNull();
169+
});
170+
});
171+
172+
// ============================================================================
173+
// Git mining: synthetic repo
174+
// ============================================================================
175+
176+
describe.skipIf(!HAS_GIT)('mineIssueCommits', () => {
177+
beforeEach(() => {
178+
git('init', '-q', '-b', 'main');
179+
git('config', 'commit.gpgsign', 'false');
180+
});
181+
182+
it('finds commits with `Fixes #N` in the subject', () => {
183+
commitAt('2025-01-01T00:00:00Z', { 'a.ts': 'a' }, 'feat: add a (no issue)');
184+
commitAt('2025-01-02T00:00:00Z', { 'a.ts': 'a2' }, 'fix: bug. Fixes #42');
185+
const commits = mineIssueCommits(testDir, null);
186+
expect(commits.length).toBe(1);
187+
expect(commits[0]!.issues).toEqual([42]);
188+
});
189+
190+
it('parses multi-issue subjects', () => {
191+
commitAt('2025-01-01T00:00:00Z', { 'a.ts': 'a' }, 'fix: triple. Fixes #1, closes #2, resolves #3');
192+
const [c] = mineIssueCommits(testDir, null);
193+
expect(c?.issues).toEqual([1, 2, 3]);
194+
});
195+
196+
it('ignores commits with no issue ref', () => {
197+
commitAt('2025-01-01T00:00:00Z', { 'a.ts': 'a' }, 'plain message');
198+
expect(mineIssueCommits(testDir, null).length).toBe(0);
199+
});
200+
201+
it('returns [] when not in a git repo', () => {
202+
const nonGit = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-nogit-'));
203+
try {
204+
expect(mineIssueCommits(nonGit, null)).toEqual([]);
205+
} finally {
206+
fs.rmSync(nonGit, { recursive: true, force: true });
207+
}
208+
});
209+
});
210+
211+
// ============================================================================
212+
// End-to-end through CodeGraph
213+
// ============================================================================
214+
215+
describe.skipIf(!HAS_GIT)('CodeGraph issue history', () => {
216+
beforeEach(() => {
217+
git('init', '-q', '-b', 'main');
218+
git('config', 'commit.gpgsign', 'false');
219+
});
220+
221+
it('attributes a Fixes #N commit to the modified function', async () => {
222+
commitAt('2025-01-01T00:00:00Z', {
223+
'src/a.ts': `export function foo() { return 1; }\n`,
224+
}, 'feat: add foo');
225+
226+
commitAt('2025-02-01T00:00:00Z', {
227+
'src/a.ts': `export function foo() {\n // changed\n return 2;\n}\n`,
228+
}, 'fix: bug. Fixes #42');
229+
230+
cg = CodeGraph.initSync(testDir, { config: { include: ['**/*.ts'], exclude: [] } });
231+
await cg.indexAll();
232+
233+
const node = cg.getNodesInFile('src/a.ts').find((n) => n.name === 'foo')!;
234+
expect(node).toBeDefined();
235+
const issues = cg.getIssuesForNode(node.id);
236+
expect(issues.length).toBeGreaterThan(0);
237+
expect(issues.some((i) => i.issueNumber === 42)).toBe(true);
238+
});
239+
240+
it('tracks the agent-usable multi-issue signal', async () => {
241+
// Simulate the codegraph history pattern: `loadGrammarsForLanguages`
242+
// touched by every language-add issue (#54, #82, #83, #85).
243+
commitAt('2025-01-01T00:00:00Z', {
244+
'src/grammar.ts': `export function loadGrammarsForLanguages() { return []; }\n`,
245+
}, 'feat: add grammar loader');
246+
247+
commitAt('2025-01-02T00:00:00Z', {
248+
'src/grammar.ts': `export function loadGrammarsForLanguages() {\n // R support\n return [];\n}\n`,
249+
}, 'feat: add R support. Fixes #82');
250+
251+
commitAt('2025-01-03T00:00:00Z', {
252+
'src/grammar.ts': `export function loadGrammarsForLanguages() {\n // R + HCL support\n return [];\n}\n`,
253+
}, 'feat: add HCL. Fixes #83');
254+
255+
commitAt('2025-01-04T00:00:00Z', {
256+
'src/grammar.ts': `export function loadGrammarsForLanguages() {\n // R + HCL + SQL\n return [];\n}\n`,
257+
}, 'feat: add SQL. Fixes #85');
258+
259+
cg = CodeGraph.initSync(testDir, { config: { include: ['**/*.ts'], exclude: [] } });
260+
await cg.indexAll();
261+
262+
const node = cg.getNodesByKind("function").find((n) => n.name === 'loadGrammarsForLanguages')!;
263+
expect(node).toBeDefined();
264+
const issues = cg.getIssuesForNode(node.id);
265+
const issueNumbers = [...new Set(issues.map((i) => i.issueNumber))].sort((a, b) => a - b);
266+
expect(issueNumbers).toEqual([82, 83, 85]);
267+
});
268+
269+
it('records `added` kind for symbols introduced in a Fixes commit', async () => {
270+
commitAt('2025-01-01T00:00:00Z', {
271+
'src/a.ts': `export function existing() { return 1; }\n`,
272+
}, 'init');
273+
274+
commitAt('2025-02-01T00:00:00Z', {
275+
'src/a.ts': `export function existing() { return 1; }\nexport function brandNew() { return 2; }\n`,
276+
}, 'feat: add brandNew. Fixes #100');
277+
278+
cg = CodeGraph.initSync(testDir, { config: { include: ['**/*.ts'], exclude: [] } });
279+
await cg.indexAll();
280+
281+
const node = cg.getNodesByKind("function").find((n) => n.name === 'brandNew')!;
282+
const issues = cg.getIssuesForNode(node.id);
283+
expect(issues.some((i) => i.issueNumber === 100 && i.kind === 'added')).toBe(true);
284+
});
285+
286+
it('drops attributions for symbols that no longer exist', async () => {
287+
// Symbol added then removed in two separate `Fixes` commits. The
288+
// current index has no node for it, so attributions for the removed
289+
// symbol must not appear (FK + drop-on-resolve).
290+
commitAt('2025-01-01T00:00:00Z', {
291+
'src/a.ts': `export function staysHere() { return 1; }\nexport function temporary() { return 99; }\n`,
292+
}, 'feat: add. Fixes #1');
293+
294+
commitAt('2025-02-01T00:00:00Z', {
295+
'src/a.ts': `export function staysHere() { return 1; }\n`,
296+
}, 'fix: drop temporary. Fixes #2');
297+
298+
cg = CodeGraph.initSync(testDir, { config: { include: ['**/*.ts'], exclude: [] } });
299+
await cg.indexAll();
300+
301+
// staysHere should have at least the #1 attribution (added).
302+
const node = cg.getNodesByKind("function").find((n) => n.name === 'staysHere')!;
303+
const issues = cg.getIssuesForNode(node.id);
304+
expect(issues.some((i) => i.issueNumber === 1)).toBe(true);
305+
306+
// No node should exist named `temporary`, and no attribution to
307+
// issue #2 should reference a node that doesn't exist.
308+
expect(cg.getNodesByKind("function").find((n) => n.name === 'temporary')).toBeUndefined();
309+
});
310+
311+
it('survives indexAll outside a git repo (table empty, no errors)', async () => {
312+
fs.rmSync(path.join(testDir, '.git'), { recursive: true, force: true });
313+
fs.writeFileSync(path.join(testDir, 'a.ts'), `export function x() { return 1; }\n`);
314+
cg = CodeGraph.initSync(testDir, { config: { include: ['**/*.ts'], exclude: [] } });
315+
await cg.indexAll();
316+
const nodes = cg.getNodesInFile('a.ts');
317+
expect(nodes.length).toBeGreaterThan(0);
318+
for (const n of nodes) expect(cg.getIssuesForNode(n.id)).toEqual([]);
319+
});
320+
321+
it('respects enableIssueHistory=false', async () => {
322+
commitAt('2025-01-01T00:00:00Z', {
323+
'src/a.ts': `export function foo() { return 1; }\n`,
324+
}, 'init');
325+
commitAt('2025-01-02T00:00:00Z', {
326+
'src/a.ts': `export function foo() { return 2; }\n`,
327+
}, 'fix: foo. Fixes #1');
328+
329+
cg = CodeGraph.initSync(testDir, {
330+
config: { include: ['**/*.ts'], exclude: [], enableIssueHistory: false },
331+
});
332+
await cg.indexAll();
333+
const node = cg.getNodesInFile('src/a.ts').find((n) => n.name === 'foo')!;
334+
expect(cg.getIssuesForNode(node.id)).toEqual([]);
335+
});
336+
337+
it('incrementally picks up new Fixes commits on sync', async () => {
338+
commitAt('2025-01-01T00:00:00Z', {
339+
'src/a.ts': `export function foo() { return 1; }\n`,
340+
}, 'init');
341+
342+
cg = CodeGraph.initSync(testDir, { config: { include: ['**/*.ts'], exclude: [] } });
343+
await cg.indexAll();
344+
const node = cg.getNodesInFile('src/a.ts').find((n) => n.name === 'foo')!;
345+
expect(cg.getIssuesForNode(node.id).length).toBe(0);
346+
347+
commitAt('2025-02-01T00:00:00Z', {
348+
'src/a.ts': `export function foo() { return 2; }\n`,
349+
}, 'fix: foo. Fixes #50');
350+
await cg.sync();
351+
352+
const issues = cg.getIssuesForNode(node.id);
353+
expect(issues.some((i) => i.issueNumber === 50)).toBe(true);
354+
});
355+
356+
// (Removed: a defensive test for the v4-migration-collision bug class.
357+
// With file-based migrations (NNN-name.ts), two migrations claiming
358+
// the same version produces a filesystem-level conflict — the silent
359+
// skip the defensive guard protected against can no longer happen.)
360+
361+
it('recovers from an unreachable last_mined_issues_head', async () => {
362+
commitAt('2025-01-01T00:00:00Z', {
363+
'src/a.ts': `export function foo() { return 1; }\n`,
364+
}, 'init');
365+
commitAt('2025-02-01T00:00:00Z', {
366+
'src/a.ts': `export function foo() { return 2; }\n`,
367+
}, 'fix: foo. Fixes #1');
368+
369+
cg = CodeGraph.initSync(testDir, { config: { include: ['**/*.ts'], exclude: [] } });
370+
await cg.indexAll();
371+
const node = cg.getNodesInFile('src/a.ts').find((n) => n.name === 'foo')!;
372+
expect(
373+
[...new Set(cg.getIssuesForNode(node.id).map((i) => i.issueNumber))]
374+
).toEqual([1]);
375+
376+
// Simulate force-push / gc by storing an unreachable SHA.
377+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
378+
(cg as any).queries.setMetadata(LAST_MINED_ISSUES_HEAD_KEY, '0'.repeat(40));
379+
380+
commitAt('2025-03-01T00:00:00Z', {
381+
'src/a.ts': `export function foo() { return 3; }\n`,
382+
}, 'fix: foo again. Fixes #2');
383+
await cg.sync();
384+
385+
const issueNums = [
386+
...new Set(cg.getIssuesForNode(node.id).map((i) => i.issueNumber)),
387+
].sort((a, b) => a - b);
388+
expect(issueNums).toEqual([1, 2]);
389+
});
390+
});

0 commit comments

Comments
 (0)