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