mcp-files-path-normalization.test.ts 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113
  1. /**
  2. * codegraph_files path-filter normalization (#426)
  3. *
  4. * Stored file paths are project-relative POSIX (e.g. "src/foo.ts"). Some
  5. * agents pass project-root variants like "/", ".", "./" or "" when they want
  6. * "the whole project", and Windows-style backslashes or leading "/" / "./"
  7. * prefixes when they want a subtree. The old filter used a plain
  8. * `startsWith(pathFilter)`, so any of those buried the agent at "no files
  9. * found" and pushed it back to Read/Glob — the exact opencode regression in
  10. * #426. These tests pin every branch of the normalization.
  11. */
  12. import { describe, it, expect, beforeEach, afterEach } from 'vitest';
  13. import * as fs from 'fs';
  14. import * as path from 'path';
  15. import * as os from 'os';
  16. import CodeGraph from '../src/index';
  17. import { ToolHandler } from '../src/mcp/tools';
  18. describe('codegraph_files path normalization', () => {
  19. let tempDir: string;
  20. let cg: CodeGraph;
  21. let handler: ToolHandler;
  22. beforeEach(async () => {
  23. tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-files-paths-'));
  24. fs.mkdirSync(path.join(tempDir, 'src', 'components'), { recursive: true });
  25. fs.mkdirSync(path.join(tempDir, 'tests'), { recursive: true });
  26. fs.writeFileSync(path.join(tempDir, 'src', 'index.ts'), `export const x = 1;\n`);
  27. fs.writeFileSync(
  28. path.join(tempDir, 'src', 'components', 'Button.ts'),
  29. `export const Button = () => 1;\n`
  30. );
  31. fs.writeFileSync(path.join(tempDir, 'tests', 'a.test.ts'), `export const t = 1;\n`);
  32. cg = await CodeGraph.init(tempDir, {
  33. config: { include: ['**/*.ts'], exclude: [] },
  34. });
  35. await cg.indexAll();
  36. handler = new ToolHandler(cg);
  37. });
  38. afterEach(() => {
  39. if (cg) cg.destroy();
  40. if (fs.existsSync(tempDir)) {
  41. fs.rmSync(tempDir, { recursive: true, force: true });
  42. }
  43. });
  44. async function listed(pathFilter: string | undefined): Promise<string> {
  45. const result = await handler.execute('codegraph_files', {
  46. ...(pathFilter !== undefined ? { path: pathFilter } : {}),
  47. format: 'flat',
  48. includeMetadata: false,
  49. });
  50. expect(result.isError).toBeFalsy();
  51. return result.content[0]!.text as string;
  52. }
  53. // Root-ish filters: every shape an agent might guess for "whole project"
  54. // must list the same files as no filter at all.
  55. for (const rootish of ['/', '.', './', '', '\\', '//', './/']) {
  56. it(`treats path=${JSON.stringify(rootish)} as project root`, async () => {
  57. const output = await listed(rootish);
  58. expect(output).toContain('src/index.ts');
  59. expect(output).toContain('src/components/Button.ts');
  60. expect(output).toContain('tests/a.test.ts');
  61. });
  62. }
  63. it('matches a real subdirectory prefix', async () => {
  64. const output = await listed('src');
  65. expect(output).toContain('src/index.ts');
  66. expect(output).toContain('src/components/Button.ts');
  67. expect(output).not.toContain('tests/a.test.ts');
  68. });
  69. it('tolerates a leading slash on a real subdirectory', async () => {
  70. const output = await listed('/src');
  71. expect(output).toContain('src/index.ts');
  72. expect(output).not.toContain('tests/a.test.ts');
  73. });
  74. it('tolerates a leading "./" on a real subdirectory', async () => {
  75. const output = await listed('./src');
  76. expect(output).toContain('src/index.ts');
  77. expect(output).not.toContain('tests/a.test.ts');
  78. });
  79. it('tolerates a trailing slash on a real subdirectory', async () => {
  80. const output = await listed('src/');
  81. expect(output).toContain('src/index.ts');
  82. expect(output).not.toContain('tests/a.test.ts');
  83. });
  84. it('normalizes Windows backslashes', async () => {
  85. const output = await listed('src\\components');
  86. expect(output).toContain('src/components/Button.ts');
  87. expect(output).not.toContain('src/index.ts');
  88. });
  89. // Old code matched on raw `startsWith`, so a filter "src" would also
  90. // return a sibling like "src-utils/...". The new code requires either an
  91. // exact match or a "<filter>/" boundary, so prefixes don't bleed.
  92. it('does not match sibling directories that share a prefix', async () => {
  93. fs.mkdirSync(path.join(tempDir, 'src-utils'), { recursive: true });
  94. fs.writeFileSync(path.join(tempDir, 'src-utils', 'helper.ts'), `export const h = 1;\n`);
  95. await cg.indexAll();
  96. const output = await listed('src');
  97. expect(output).toContain('src/index.ts');
  98. expect(output).not.toContain('src-utils/helper.ts');
  99. });
  100. });