frontload-hook.test.ts 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130
  1. /**
  2. * Front-load hook project resolution (#964).
  3. *
  4. * The Claude `UserPromptSubmit` front-load hook must inject CodeGraph context
  5. * for the RIGHT project — including the monorepo case where the agent's cwd is
  6. * an un-indexed workspace root and the index lives in a sub-project. These test
  7. * `planFrontload` / `findIndexedSubprojectRoots` directly (the hook's decision
  8. * logic), since the end-to-end hook is validated by a live agent run, not a
  9. * unit test.
  10. */
  11. import { describe, it, expect, beforeEach, afterEach } from 'vitest';
  12. import * as fs from 'fs';
  13. import * as os from 'os';
  14. import * as path from 'path';
  15. import { planFrontload, findIndexedSubprojectRoots } from '../src/directory';
  16. /** Make `dir` look indexed (isInitialized needs `.codegraph/codegraph.db`). */
  17. function mkIndexed(dir: string): string {
  18. fs.mkdirSync(path.join(dir, '.codegraph'), { recursive: true });
  19. fs.writeFileSync(path.join(dir, '.codegraph', 'codegraph.db'), '');
  20. return dir;
  21. }
  22. /** A workspace-root manifest so the down-scan gate (looksLikeProjectRoot) passes. */
  23. function mkWorkspaceRoot(dir: string): string {
  24. fs.mkdirSync(dir, { recursive: true });
  25. fs.writeFileSync(path.join(dir, 'package.json'), '{"private":true,"workspaces":["packages/*"]}');
  26. return dir;
  27. }
  28. describe('planFrontload — front-load hook project resolution (#964)', () => {
  29. let tmp: string;
  30. beforeEach(() => { tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'cg-frontload-'))); });
  31. afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); });
  32. it('cwd is itself indexed → front-load cwd (the common single-project case)', () => {
  33. mkIndexed(tmp);
  34. const plan = planFrontload(tmp, 'how does login work');
  35. expect(plan.exploreRoot).toBe(tmp);
  36. expect(plan.viaSubScan).toBe(false);
  37. expect(plan.nudgeProjects).toEqual([]);
  38. });
  39. it('a nested file under an indexed project resolves up to that project', () => {
  40. mkIndexed(tmp);
  41. const nested = path.join(tmp, 'src', 'deep');
  42. fs.mkdirSync(nested, { recursive: true });
  43. expect(planFrontload(nested, 'trace the flow').exploreRoot).toBe(tmp);
  44. });
  45. it('un-indexed workspace root with ONE indexed sub-project → front-load it (the #964 case)', () => {
  46. mkWorkspaceRoot(tmp);
  47. const api = mkIndexed(path.join(tmp, 'packages', 'api'));
  48. const plan = planFrontload(tmp, 'how does the request get handled');
  49. expect(plan.exploreRoot).toBe(api);
  50. expect(plan.viaSubScan).toBe(true);
  51. expect(plan.nudgeProjects).toEqual([]);
  52. });
  53. it('multiple indexed sub-projects, prompt names one by path → front-load it, nudge the rest', () => {
  54. mkWorkspaceRoot(tmp);
  55. const api = mkIndexed(path.join(tmp, 'packages', 'api'));
  56. const web = mkIndexed(path.join(tmp, 'packages', 'web'));
  57. const plan = planFrontload(tmp, 'in packages/api, how does the handler validate the token?');
  58. expect(plan.exploreRoot).toBe(api);
  59. expect(plan.viaSubScan).toBe(true);
  60. expect(plan.nudgeProjects).toEqual([web]);
  61. });
  62. it('multiple indexed sub-projects, prompt names one by package name → front-load it', () => {
  63. mkWorkspaceRoot(tmp);
  64. mkIndexed(path.join(tmp, 'packages', 'api'));
  65. const web = mkIndexed(path.join(tmp, 'packages', 'web'));
  66. const plan = planFrontload(tmp, 'how does the web frontend render the dashboard?');
  67. expect(plan.exploreRoot).toBe(web);
  68. });
  69. it('multiple indexed sub-projects, NO clear match → nudge the full list, do not guess', () => {
  70. mkWorkspaceRoot(tmp);
  71. const api = mkIndexed(path.join(tmp, 'packages', 'api'));
  72. const web = mkIndexed(path.join(tmp, 'packages', 'web'));
  73. const plan = planFrontload(tmp, 'how does authentication work end to end?');
  74. expect(plan.exploreRoot).toBeNull();
  75. expect(plan.viaSubScan).toBe(true);
  76. expect(plan.nudgeProjects.sort()).toEqual([api, web].sort());
  77. });
  78. it('un-indexed dir that is NOT a workspace root → no-op (guards $HOME-style crawls)', () => {
  79. // Indexed project exists below, but cwd has no manifest, so the down-scan is skipped.
  80. mkIndexed(path.join(tmp, 'some', 'project'));
  81. const plan = planFrontload(tmp, 'how does it work');
  82. expect(plan.exploreRoot).toBeNull();
  83. expect(plan.nudgeProjects).toEqual([]);
  84. });
  85. it('nothing indexed anywhere → no-op', () => {
  86. mkWorkspaceRoot(tmp);
  87. fs.mkdirSync(path.join(tmp, 'packages', 'api'), { recursive: true });
  88. const plan = planFrontload(tmp, 'how does it work');
  89. expect(plan.exploreRoot).toBeNull();
  90. expect(plan.nudgeProjects).toEqual([]);
  91. });
  92. });
  93. describe('findIndexedSubprojectRoots', () => {
  94. let tmp: string;
  95. beforeEach(() => { tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'cg-subscan-'))); });
  96. afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); });
  97. it('finds indexed projects a couple levels down and skips node_modules/.git', () => {
  98. mkIndexed(path.join(tmp, 'packages', 'api'));
  99. mkIndexed(path.join(tmp, 'services', 'auth'));
  100. // Decoys that must NOT be scanned into.
  101. mkIndexed(path.join(tmp, 'node_modules', 'dep'));
  102. mkIndexed(path.join(tmp, '.git', 'x'));
  103. const found = findIndexedSubprojectRoots(tmp).map((p) => path.relative(tmp, p)).sort();
  104. expect(found).toEqual([path.join('packages', 'api'), path.join('services', 'auth')].sort());
  105. });
  106. it('does not descend INTO an indexed project (a project\'s sub-dirs are not separate projects)', () => {
  107. const api = mkIndexed(path.join(tmp, 'packages', 'api'));
  108. mkIndexed(path.join(api, 'submodule')); // nested index under an already-indexed project
  109. const found = findIndexedSubprojectRoots(tmp);
  110. expect(found).toEqual([api]);
  111. });
  112. it('respects the depth bound', () => {
  113. mkIndexed(path.join(tmp, 'a', 'b', 'c', 'd', 'e', 'deep'));
  114. expect(findIndexedSubprojectRoots(tmp, { maxDepth: 2 })).toEqual([]);
  115. });
  116. });