-
Notifications
You must be signed in to change notification settings - Fork 9
Expand file tree
/
Copy pathcodebase-map.test.ts
More file actions
391 lines (352 loc) · 15.9 KB
/
codebase-map.test.ts
File metadata and controls
391 lines (352 loc) · 15.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
import { describe, it, expect } from 'vitest';
import { promises as fs } from 'fs';
import os from 'os';
import path from 'path';
import { fileURLToPath } from 'url';
import { createProjectState } from '../src/project-state.js';
import { buildCodebaseMap, renderMapMarkdown, renderMapPretty } from '../src/core/codebase-map.js';
import { generateCodebaseIntelligence } from '../src/resources/codebase-intelligence.js';
import {
CODEBASE_CONTEXT_DIRNAME,
INTELLIGENCE_FILENAME,
KEYWORD_INDEX_FILENAME,
RELATIONSHIPS_FILENAME
} from '../src/constants/codebase-context.js';
// Resolve fixture path relative to this test file — portable across CWD setups.
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const FIXTURE_ROOT = path.join(__dirname, 'fixtures', 'map-fixture');
// ---------------------------------------------------------------------------
// buildCodebaseMap
// ---------------------------------------------------------------------------
describe('buildCodebaseMap', () => {
it('returns a CodebaseMapSummary with project name from rootPath', async () => {
const project = createProjectState(FIXTURE_ROOT);
const map = await buildCodebaseMap(project);
expect(map.project).toBe('map-fixture');
});
it('derives architecture layers from graph keys, sorted by count desc then alpha', async () => {
const project = createProjectState(FIXTURE_ROOT);
const map = await buildCodebaseMap(project);
// Use objectContaining — layers may now have hubFile/hubExports from enrichLayers
expect(map.architecture.layers).toHaveLength(3);
expect(map.architecture.layers[0]).toMatchObject({ name: 'src', fileCount: 5 });
expect(map.architecture.layers[1]).toMatchObject({ name: 'tests', fileCount: 2 });
expect(map.architecture.layers[2]).toMatchObject({ name: 'lib', fileCount: 1 });
});
it('derives entrypoints: files with imports but zero importers, excluding tests/scripts', async () => {
const project = createProjectState(FIXTURE_ROOT);
const map = await buildCodebaseMap(project);
expect(map.architecture.entrypoints).toEqual(['src/cli.ts', 'src/index.ts']);
});
it('derives hub files: top 5 by importedBy count, sorted count-desc then alpha', async () => {
const project = createProjectState(FIXTURE_ROOT);
const map = await buildCodebaseMap(project);
expect(map.architecture.hubFiles).toEqual([
'src/core/search.ts',
'src/utils/helpers.ts',
'lib/utils.ts'
]);
});
it('derives active patterns from intelligence.json, sorted by adoption desc', async () => {
const project = createProjectState(FIXTURE_ROOT);
const map = await buildCodebaseMap(project);
expect(map.activePatterns).toEqual([
{ name: 'Injectable', adoption: '100%', trend: 'Stable' },
{ name: 'RxJS', adoption: '72%', trend: 'Rising' },
{ name: 'Vitest', adoption: '45%', trend: 'Stable' }
]);
});
it('derives best examples from goldenFiles with dominant pattern as reason', async () => {
const project = createProjectState(FIXTURE_ROOT);
const map = await buildCodebaseMap(project);
expect(map.bestExamples).toEqual([
{ file: 'src/core/search.ts', score: 0.95, reason: 'Injectable' },
{ file: 'src/utils/helpers.ts', score: 0.87, reason: 'Injectable' }
]);
});
it('reads graph stats from relationships.json', async () => {
const project = createProjectState(FIXTURE_ROOT);
const map = await buildCodebaseMap(project);
expect(map.graphStats).toEqual({ files: 8, edges: 9, avgDependencies: 1.1 });
});
it('adds suggested next calls: split pattern + golden file + fallback', async () => {
const project = createProjectState(FIXTURE_ROOT);
const map = await buildCodebaseMap(project);
// Vitest at 45% triggers split-pattern suggestion
expect(map.suggestedNextCalls[0]).toEqual({
tool: 'get_team_patterns',
args: { category: 'vitest' },
why: 'Team is split on Vitest'
});
// Top golden file triggers search suggestion
expect(map.suggestedNextCalls[1]).toEqual({
tool: 'search_codebase',
args: { query: 'search' },
why: 'Explore the top-rated example'
});
// Fallback
expect(map.suggestedNextCalls[2]).toEqual({
tool: 'search_codebase',
args: { query: 'project architecture' },
why: 'Explore the codebase'
});
expect(map.suggestedNextCalls).toHaveLength(3);
});
it('degrades gracefully when intelligence.json is missing', async () => {
// Point at a non-existent dir — builder should return empty map, not throw
const project = createProjectState(path.join(FIXTURE_ROOT, 'nonexistent'));
const map = await buildCodebaseMap(project);
expect(map.architecture.layers).toEqual([]);
expect(map.architecture.entrypoints).toEqual([]);
expect(map.architecture.hubFiles).toEqual([]);
expect(map.architecture.keyInterfaces).toEqual([]);
expect(map.architecture.apiSurface).toEqual([]);
expect(map.architecture.hotspots).toEqual([]);
expect(map.activePatterns).toEqual([]);
expect(map.bestExamples).toEqual([]);
expect(map.graphStats).toEqual({ files: 0, edges: 0, avgDependencies: 0 });
// Should still have a fallback next call
expect(map.suggestedNextCalls.length).toBeGreaterThan(0);
});
it('caps suggested next calls at 3', async () => {
const project = createProjectState(FIXTURE_ROOT);
const map = await buildCodebaseMap(project);
expect(map.suggestedNextCalls.length).toBeLessThanOrEqual(3);
});
// --- Structural skeleton (Phase 13) ---
it('derives keyInterfaces from symbolAware chunks, sorted by importer count', async () => {
const project = createProjectState(FIXTURE_ROOT);
const map = await buildCodebaseMap(project);
// SearchOptions and CodebaseSearcher are both in src/core/search.ts (3 importers)
// SearchResult is in src/types.ts (0 importers)
// helperUtil is not symbolAware — excluded
expect(map.architecture.keyInterfaces.length).toBeGreaterThanOrEqual(2);
// Items with same importerCount: shorter content first → SearchOptions before CodebaseSearcher
expect(map.architecture.keyInterfaces[0].name).toBe('SearchOptions');
expect(map.architecture.keyInterfaces[0].importerCount).toBe(3);
expect(map.architecture.keyInterfaces[0].kind).toBe('interface');
expect(map.architecture.keyInterfaces[0].file).toBe('src/core/search.ts');
});
it('signatureHint strips trailing { and caps at 200 chars', async () => {
const project = createProjectState(FIXTURE_ROOT);
const map = await buildCodebaseMap(project);
for (const ki of map.architecture.keyInterfaces) {
expect(ki.signatureHint).not.toMatch(/\{$/);
expect(ki.signatureHint.length).toBeLessThanOrEqual(200);
}
});
it('signatureHint contains the symbol name', async () => {
const project = createProjectState(FIXTURE_ROOT);
const map = await buildCodebaseMap(project);
const iface = map.architecture.keyInterfaces.find((k) => k.name === 'SearchOptions')!;
expect(iface.signatureHint).toContain('SearchOptions');
});
it('derives apiSurface from entrypoints x graph.exports', async () => {
const project = createProjectState(FIXTURE_ROOT);
const map = await buildCodebaseMap(project);
// src/cli.ts and src/index.ts are entrypoints; both have exports in fixture
const cli = map.architecture.apiSurface.find((s) => s.file === 'src/cli.ts');
expect(cli).toBeDefined();
expect(cli!.exports).toContain('runCli');
expect(cli!.exports).toContain('parseArgs');
expect(cli!.exports.length).toBeLessThanOrEqual(5);
});
it('apiSurface excludes default exports', async () => {
const project = createProjectState(FIXTURE_ROOT);
const map = await buildCodebaseMap(project);
for (const surface of map.architecture.apiSurface) {
expect(surface.exports).not.toContain('default');
}
});
it('derives hotspots sorted by combined import + importer count', async () => {
const project = createProjectState(FIXTURE_ROOT);
const map = await buildCodebaseMap(project);
expect(map.architecture.hotspots.length).toBeLessThanOrEqual(5);
// src/core/search.ts: importedBy=3, imports=2 → combined=5 (highest)
expect(map.architecture.hotspots[0].file).toBe('src/core/search.ts');
expect(map.architecture.hotspots[0].combined).toBe(5);
// combined is always importerCount + importCount
for (const h of map.architecture.hotspots) {
expect(h.combined).toBe(h.importerCount + h.importCount);
}
});
it('enriches layers with hubFile from importedBy data', async () => {
const project = createProjectState(FIXTURE_ROOT);
const map = await buildCodebaseMap(project);
const srcLayer = map.architecture.layers.find((l) => l.name === 'src')!;
// src/core/search.ts has 3 importers — highest in the src layer
expect(srcLayer.hubFile).toBe('src/core/search.ts');
});
it('enriches layers with hubExports when graph.exports has data', async () => {
const project = createProjectState(FIXTURE_ROOT);
const map = await buildCodebaseMap(project);
// src/cli.ts has exports in fixture but is not the hub of the src layer
// src/index.ts has exports and is also in src — but search.ts (hub) has no exports in fixture
const srcLayer = map.architecture.layers.find((l) => l.name === 'src')!;
// search.ts has no exports in fixture → hubExports should be absent
expect(srcLayer.hubExports).toBeUndefined();
});
it('breaks equal layer hub-file ties alphabetically', async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'map-layer-tie-break-'));
try {
const ctxDir = path.join(tempRoot, CODEBASE_CONTEXT_DIRNAME);
await fs.mkdir(ctxDir, { recursive: true });
await fs.writeFile(
path.join(ctxDir, INTELLIGENCE_FILENAME),
JSON.stringify({}, null, 2),
'utf-8'
);
await fs.writeFile(
path.join(ctxDir, KEYWORD_INDEX_FILENAME),
JSON.stringify({ chunks: [] }, null, 2),
'utf-8'
);
await fs.writeFile(
path.join(ctxDir, RELATIONSHIPS_FILENAME),
JSON.stringify(
{
graph: {
importedBy: {
'src/a.ts': ['src/app.ts', 'src/root.ts'],
'src/b.ts': ['src/app.ts', 'src/root.ts']
},
exports: {
'src/a.ts': [{ name: 'alpha', type: 'function' }],
'src/b.ts': [{ name: 'beta', type: 'function' }]
}
}
},
null,
2
),
'utf-8'
);
const project = createProjectState(tempRoot);
const map = await buildCodebaseMap(project);
const srcLayer = map.architecture.layers.find((layer) => layer.name === 'src');
expect(srcLayer?.hubFile).toBe('src/a.ts');
expect(srcLayer?.hubExports).toEqual(['alpha']);
} finally {
await fs.rm(tempRoot, { recursive: true, force: true });
}
});
});
// ---------------------------------------------------------------------------
// renderMapMarkdown — snapshot test
// ---------------------------------------------------------------------------
describe('renderMapMarkdown', () => {
it('renders deterministic markdown from fixture — snapshot', async () => {
const project = createProjectState(FIXTURE_ROOT);
const map = await buildCodebaseMap(project);
const md = renderMapMarkdown(map);
expect(md).toMatchSnapshot();
});
it('includes all required section headers', async () => {
const project = createProjectState(FIXTURE_ROOT);
const map = await buildCodebaseMap(project);
const md = renderMapMarkdown(map);
expect(md).toContain('# Codebase Map');
expect(md).toContain('## Architecture Layers');
expect(md).toContain('## Entrypoints');
expect(md).toContain('## Hub Files');
expect(md).toContain('## Key Interfaces');
expect(md).toContain('## API Surface');
expect(md).toContain('## Dependency Hotspots');
expect(md).toContain('## Active Patterns');
expect(md).toContain('## Best Examples');
expect(md).toContain('## Graph Stats');
expect(md).toContain('## Suggested Next Calls');
});
it('renders empty map sections gracefully', () => {
const emptyMap = {
project: 'empty',
architecture: {
layers: [],
entrypoints: [],
hubFiles: [],
keyInterfaces: [],
apiSurface: [],
hotspots: []
},
activePatterns: [],
bestExamples: [],
graphStats: { files: 0, edges: 0, avgDependencies: 0 },
suggestedNextCalls: []
};
const md = renderMapMarkdown(emptyMap);
expect(md).toContain('_No index data available._');
expect(md).toContain('_None detected._');
expect(md).toContain('_No patterns detected._');
expect(md).toContain('_No examples available._');
expect(md).toContain('_No suggestions._');
});
});
// ---------------------------------------------------------------------------
// renderMapPretty
// ---------------------------------------------------------------------------
describe('renderMapPretty', () => {
it('renders box characters in default mode', async () => {
const project = createProjectState(FIXTURE_ROOT);
const map = await buildCodebaseMap(project);
const pretty = renderMapPretty(map);
expect(pretty).toContain('┌');
expect(pretty).toContain('│');
expect(pretty).toContain('└');
});
it('renders ASCII box chars when CODEBASE_CONTEXT_ASCII=1', async () => {
const original = process.env.CODEBASE_CONTEXT_ASCII;
process.env.CODEBASE_CONTEXT_ASCII = '1';
try {
const project = createProjectState(FIXTURE_ROOT);
const map = await buildCodebaseMap(project);
const pretty = renderMapPretty(map);
expect(pretty).toContain('+');
expect(pretty).toContain('-');
expect(pretty).not.toContain('┌');
} finally {
if (original === undefined) {
delete process.env.CODEBASE_CONTEXT_ASCII;
} else {
process.env.CODEBASE_CONTEXT_ASCII = original;
}
}
});
});
// ---------------------------------------------------------------------------
// generateCodebaseIntelligence — MCP resource integration (eval guard)
// ---------------------------------------------------------------------------
describe('generateCodebaseIntelligence (eval guard)', () => {
it('returns a non-empty markdown string from fixture', async () => {
const project = createProjectState(FIXTURE_ROOT);
const result = await generateCodebaseIntelligence(project);
expect(typeof result).toBe('string');
expect(result.length).toBeGreaterThan(0);
});
it('contains the # Codebase Map header', async () => {
const project = createProjectState(FIXTURE_ROOT);
const result = await generateCodebaseIntelligence(project);
expect(result).toContain('# Codebase Map');
});
it('contains key section markers from the map', async () => {
const project = createProjectState(FIXTURE_ROOT);
const result = await generateCodebaseIntelligence(project);
expect(result).toContain('## Architecture Layers');
expect(result).toContain('## Active Patterns');
expect(result).toContain('## Hub Files');
});
it('does not contain the error fallback text', async () => {
const project = createProjectState(FIXTURE_ROOT);
const result = await generateCodebaseIntelligence(project);
expect(result).not.toContain('Intelligence data not yet generated');
});
it('returns error fallback when index is missing', async () => {
const project = createProjectState(path.join(FIXTURE_ROOT, 'nonexistent'));
// With missing files the builder degrades but renderMapMarkdown still returns valid map
// The only way to get the error fallback is if renderMapMarkdown itself throws,
// which it won't — so we just assert the returned string is valid (no unhandled throw).
const result = await generateCodebaseIntelligence(project);
expect(typeof result).toBe('string');
expect(result.length).toBeGreaterThan(0);
});
});