|
| 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