-
Notifications
You must be signed in to change notification settings - Fork 14
fix: class-scope field annotation typeMap keys to prevent cross-class collision #1495
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
3892e7d
ef8ea4f
a372b82
9a52c7c
85a26df
184d221
909e1df
66fc899
84e1a5f
d07b358
3db5d8c
8b3aa3d
fd4ffd1
498ee21
61a9839
5f5d4d2
bd5292e
cff36c6
06a911e
84f6c7c
76a4380
1bb912a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,111 @@ | ||
| /** | ||
| * Integration test for #1458: cross-class field annotation typeMap collision. | ||
| * | ||
| * Two classes with identically-named fields (`repo`) caused the later class's | ||
| * annotation to overwrite the earlier one's bare typeMap key. `this.repo.save()` | ||
| * inside UserService would resolve to OrderRepository instead of UserRepository. | ||
| * | ||
| * Fix: handleFieldDefTypeMap seeds `ClassName.field` at confidence 0.9 as the | ||
| * primary key; the resolver checks the class-scoped key before bare fallback keys | ||
| * for `this.` receivers so the correct type is always chosen. | ||
| */ | ||
|
|
||
| import fs from 'node:fs'; | ||
| import os from 'node:os'; | ||
| import path from 'node:path'; | ||
| import Database from 'better-sqlite3'; | ||
| import { afterAll, beforeAll, describe, expect, it } from 'vitest'; | ||
| import { buildGraph } from '../../src/domain/graph/builder.js'; | ||
|
|
||
| const FIXTURE = { | ||
| 'services.ts': ` | ||
| class OrderRepository { | ||
| save(order: unknown) {} | ||
| } | ||
| class UserRepository { | ||
| save(user: unknown) {} | ||
| } | ||
| class OrderService { | ||
| private repo: OrderRepository; | ||
| run() { this.repo.save({}); } | ||
| } | ||
| class UserService { | ||
| private repo: UserRepository; | ||
| run() { this.repo.save({}); } | ||
| } | ||
| `, | ||
| }; | ||
|
|
||
| let tmpDir: string; | ||
|
|
||
| beforeAll(async () => { | ||
| tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-1458-')); | ||
| for (const [rel, content] of Object.entries(FIXTURE)) { | ||
| fs.writeFileSync(path.join(tmpDir, rel), content); | ||
| } | ||
| await buildGraph(tmpDir, { engine: 'wasm', incremental: false, skipRegistry: true }); | ||
| }); | ||
|
|
||
| afterAll(() => { | ||
| fs.rmSync(tmpDir, { recursive: true, force: true }); | ||
| }); | ||
|
|
||
| function readCallEdges(dbPath: string) { | ||
| const db = new Database(dbPath, { readonly: true }); | ||
| try { | ||
| return db | ||
| .prepare( | ||
| `SELECT n1.name AS src, n2.name AS tgt | ||
| FROM edges e | ||
| JOIN nodes n1 ON e.source_id = n1.id | ||
| JOIN nodes n2 ON e.target_id = n2.id | ||
| WHERE e.kind = 'calls' | ||
| ORDER BY n1.name, n2.name`, | ||
| ) | ||
| .all() as Array<{ src: string; tgt: string }>; | ||
| } finally { | ||
| db.close(); | ||
| } | ||
| } | ||
|
|
||
| describe('cross-class field annotation typeMap collision (#1458)', () => { | ||
| it('resolves this.repo.save() inside OrderService.run to OrderRepository.save', () => { | ||
| const dbPath = path.join(tmpDir, '.codegraph', 'graph.db'); | ||
| const edges = readCallEdges(dbPath); | ||
| const edge = edges.find( | ||
| (e) => e.src === 'OrderService.run' && e.tgt === 'OrderRepository.save', | ||
| ); | ||
| expect( | ||
| edge, | ||
| 'OrderService.run → OrderRepository.save edge missing; cross-class collision may be present', | ||
| ).toBeDefined(); | ||
| }); | ||
|
|
||
| it('resolves this.repo.save() inside UserService.run to UserRepository.save', () => { | ||
| const dbPath = path.join(tmpDir, '.codegraph', 'graph.db'); | ||
| const edges = readCallEdges(dbPath); | ||
| const edge = edges.find((e) => e.src === 'UserService.run' && e.tgt === 'UserRepository.save'); | ||
| expect( | ||
| edge, | ||
| 'UserService.run → UserRepository.save edge missing; cross-class collision may be present', | ||
| ).toBeDefined(); | ||
| }); | ||
|
|
||
| it('does not emit a false edge from OrderService.run to UserRepository.save', () => { | ||
| const dbPath = path.join(tmpDir, '.codegraph', 'graph.db'); | ||
| const edges = readCallEdges(dbPath); | ||
| const falseEdge = edges.find( | ||
| (e) => e.src === 'OrderService.run' && e.tgt === 'UserRepository.save', | ||
| ); | ||
| expect(falseEdge).toBeUndefined(); | ||
| }); | ||
|
|
||
| it('does not emit a false edge from UserService.run to OrderRepository.save', () => { | ||
| const dbPath = path.join(tmpDir, '.codegraph', 'graph.db'); | ||
| const edges = readCallEdges(dbPath); | ||
| const falseEdge = edges.find( | ||
| (e) => e.src === 'UserService.run' && e.tgt === 'OrderRepository.save', | ||
| ); | ||
| expect(falseEdge).toBeUndefined(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -217,15 +217,43 @@ describe('JavaScript parser', () => { | |
| expect(symbols.typeMap.get('res')).toEqual({ type: 'Response', confidence: 0.9 }); | ||
| }); | ||
|
|
||
| it('extracts class field annotations into typeMap with confidence 0.9', () => { | ||
| it('extracts class field annotations into class-scoped typeMap key (issue #1458)', () => { | ||
| const symbols = parseTS(` | ||
| class UserService { | ||
| private repo: Repository; | ||
| run() { this.repo.save(); } | ||
| } | ||
| `); | ||
| expect(symbols.typeMap.get('repo')).toEqual({ type: 'Repository', confidence: 0.9 }); | ||
| expect(symbols.typeMap.get('this.repo')).toEqual({ type: 'Repository', confidence: 0.9 }); | ||
| // Primary: class-scoped key at 0.9 — prevents cross-class collision. | ||
| expect(symbols.typeMap.get('UserService.repo')).toEqual({ | ||
| type: 'Repository', | ||
| confidence: 0.9, | ||
| }); | ||
| // Fallback bare keys at lower confidence for single-class files. | ||
| expect(symbols.typeMap.get('repo')).toEqual({ type: 'Repository', confidence: 0.6 }); | ||
| expect(symbols.typeMap.get('this.repo')).toEqual({ type: 'Repository', confidence: 0.6 }); | ||
| }); | ||
|
|
||
| it('prevents cross-class collision for same-named fields (issue #1458)', () => { | ||
| const symbols = parseTS(` | ||
| class OrderService { | ||
| private repo: OrderRepository; | ||
| } | ||
| class UserService { | ||
| private repo: UserRepository; | ||
| } | ||
| `); | ||
| // Each class gets its own scoped key — no collision. | ||
| expect(symbols.typeMap.get('OrderService.repo')).toEqual({ | ||
| type: 'OrderRepository', | ||
| confidence: 0.9, | ||
| }); | ||
| expect(symbols.typeMap.get('UserService.repo')).toEqual({ | ||
| type: 'UserRepository', | ||
| confidence: 0.9, | ||
| }); | ||
| // Bare "repo" key should hold the first class's type at 0.6 (second write is same confidence, no overwrite). | ||
| expect(symbols.typeMap.get('repo')?.confidence).toBe(0.6); | ||
| }); | ||
|
Comment on lines
+237
to
257
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The new
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed. The resolver (call-resolver.ts) now checks the class-scoped key ( |
||
|
|
||
| it('returns empty typeMap when no annotations', () => { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The TS test file gets two new cases for the collision scenario, but no parallel Rust unit test is added. The existing tests in
javascript.rsverify theClassName.fieldkey is seeded (e.g., line 3671A.t, line 3722C.helper), but none cover the multi-class scenario where two classes define the same field name. A minimal test mirroring the new TSprevents cross-class collisioncase would confirm parity between the two extractors.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added
field_annotation_multi_class_seeds_separate_scoped_keysRust unit test incrates/codegraph-core/src/extractors/javascript.rs. It parses two classes with identically-named fields and assertsOrderService.repo → OrderRepository @ 0.9andUserService.repo → UserRepository @ 0.9are both present as separate entries — mirroring the TS collision test.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The test was using parse_js() (tree-sitter-javascript) but the fixture uses TypeScript-specific syntax (private field declarations with type annotations). The JS grammar doesn't support type annotations so the type_map came back empty, causing the assertion to fail on CI. Fixed by adding parse_ts() using LANGUAGE_TYPESCRIPT and switching the test to it. All 396 Rust unit tests now pass locally.