1
0

same-name-disambiguation.test.ts 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141
  1. /**
  2. * Same-named symbols across monorepo apps (#764).
  3. *
  4. * A NestJS-style monorepo has one `UserService` (and friends) per app. The
  5. * graph keeps them as distinct nodes (import + proximity resolution), but the
  6. * MCP tools used to AGGREGATE them: callers/callees returned one merged list
  7. * and impact merged both blast radii — the conflation agents warned about.
  8. *
  9. * Now: multiple DISTINCT definitions (different file/qualified-name) render
  10. * one section per definition, and `file` narrows to a single definition.
  11. * Same-file overloads still merge (that's the overload feature).
  12. */
  13. import { describe, it, expect, beforeAll, afterAll } from 'vitest';
  14. import * as fs from 'fs';
  15. import * as path from 'path';
  16. import * as os from 'os';
  17. import { CodeGraph } from '../src';
  18. import { ToolHandler } from '../src/mcp/tools';
  19. import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
  20. let tmpDir: string;
  21. let cg: CodeGraph;
  22. let handler: ToolHandler;
  23. const text = async (tool: string, args: Record<string, unknown>): Promise<string> => {
  24. const res = await handler.execute(tool, args);
  25. return res.content?.[0]?.text ?? '';
  26. };
  27. beforeAll(async () => {
  28. await initGrammars();
  29. await loadAllGrammars();
  30. tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-764-'));
  31. const mk = (rel: string, content: string) => {
  32. const p = path.join(tmpDir, rel);
  33. fs.mkdirSync(path.dirname(p), { recursive: true });
  34. fs.writeFileSync(p, content);
  35. };
  36. for (const app of ['billing', 'admin']) {
  37. mk(
  38. `apps/${app}/src/users/user.service.ts`,
  39. [
  40. "import { UserRepository } from './user.repository';",
  41. 'export class UserService {',
  42. ' constructor(private readonly repo: UserRepository) {}',
  43. ' findAll(): string[] {',
  44. ` return this.repo.load_${app}();`,
  45. ' }',
  46. '}',
  47. ].join('\n')
  48. );
  49. mk(
  50. `apps/${app}/src/users/user.repository.ts`,
  51. `export class UserRepository {\n load_${app}(): string[] { return []; }\n}\n`
  52. );
  53. mk(
  54. `apps/${app}/src/users/user.controller.ts`,
  55. [
  56. "import { UserService } from './user.service';",
  57. 'export class UserController {',
  58. ' constructor(private readonly users: UserService) {}',
  59. ' list(): string[] { return this.users.findAll(); }',
  60. '}',
  61. ].join('\n')
  62. );
  63. }
  64. cg = CodeGraph.initSync(tmpDir);
  65. await cg.indexAll();
  66. handler = new ToolHandler(cg);
  67. }, 120_000);
  68. afterAll(() => {
  69. cg?.destroy();
  70. if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
  71. });
  72. describe('same-named symbols across apps (#764)', () => {
  73. it('graph keeps the apps apart: no cross-app edges at all', () => {
  74. const billing = new Set(
  75. cg.getNodesByName('findAll').filter((n) => n.filePath.includes('billing')).map((n) => n.id)
  76. );
  77. for (const id of billing) {
  78. for (const e of cg.getIncomingEdges(id)) {
  79. const src = cg.getNode(e.source);
  80. expect(src?.filePath.includes('admin')).toBe(false);
  81. }
  82. }
  83. });
  84. it('callers: one section per distinct definition, each with only its own callers', async () => {
  85. const out = await text('codegraph_callers', { symbol: 'findAll' });
  86. expect(out).toContain('2 distinct definitions');
  87. // Section per definition…
  88. expect(out).toContain('apps/admin/src/users/user.service.ts');
  89. expect(out).toContain('apps/billing/src/users/user.service.ts');
  90. // …and the billing section must list the billing controller, not admin's.
  91. const billingSection = out.slice(out.indexOf('apps/billing/src/users/user.service.ts'));
  92. // The next definition heading is a line-start bold label (issue #778: ATX `###`
  93. // headings became `**…**`); billingSection starts mid-heading, so `\n**` finds it.
  94. const nextDef = billingSection.indexOf('\n**');
  95. const billingBody = billingSection.slice(0, nextDef > 0 ? nextDef : undefined);
  96. expect(billingBody).toContain('apps/billing/src/users/user.controller.ts');
  97. expect(billingBody).not.toContain('apps/admin/src/users/user.controller.ts');
  98. });
  99. it('callers: `file` narrows to one definition (flat list, no stale aggregation note)', async () => {
  100. const out = await text('codegraph_callers', {
  101. symbol: 'findAll',
  102. file: 'apps/billing/src/users/user.service.ts',
  103. });
  104. expect(out).not.toContain('distinct definitions');
  105. expect(out).toContain('apps/billing/src/users/user.controller.ts');
  106. expect(out).not.toContain('apps/admin/');
  107. expect(out).not.toContain('Aggregated results');
  108. });
  109. it('callers: a non-matching `file` falls back to all definitions with a note', async () => {
  110. const out = await text('codegraph_callers', { symbol: 'findAll', file: 'apps/nonexistent/x.ts' });
  111. expect(out).toContain('no definition of "findAll" matches file');
  112. expect(out).toContain('2 distinct definitions');
  113. });
  114. it('impact: separate blast radius per definition, never a merged one', async () => {
  115. const out = await text('codegraph_impact', { symbol: 'UserService' });
  116. expect(out).toContain('2 distinct definitions');
  117. // Each section's count covers ONE app (service + ctor + findAll +
  118. // controller side), not the union of both.
  119. const counts = [...out.matchAll(/affects (\d+) symbols/g)].map((m) => Number(m[1]));
  120. expect(counts).toHaveLength(2);
  121. for (const c of counts) expect(c).toBeLessThanOrEqual(7);
  122. });
  123. it('callees: grouped the same way', async () => {
  124. const out = await text('codegraph_callees', { symbol: 'list' });
  125. expect(out).toContain('2 distinct definitions');
  126. });
  127. });