Skip to content

Commit 0551a0a

Browse files
committed
test: decision card output shape and scope-prefixed snippets
1 parent 03964b3 commit 0551a0a

3 files changed

Lines changed: 537 additions & 1 deletion

File tree

src/analyzers/generic/index.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,10 @@ export class GenericAnalyzer implements FrameworkAnalyzer {
498498
const fileName = path.basename(chunk.filePath);
499499
const { language, componentType, content } = chunk;
500500

501+
if (!content) {
502+
return `${language} ${componentType || 'code'} in ${fileName}`;
503+
}
504+
501505
// Try to extract meaningful information
502506
const firstComment = this.extractFirstComment(content);
503507
if (firstComment) {
@@ -526,7 +530,9 @@ export class GenericAnalyzer implements FrameworkAnalyzer {
526530
return `${language} code in ${fileName}: ${firstLine ? firstLine.trim().slice(0, 60) + '...' : 'code definition'}`;
527531
}
528532

529-
private extractFirstComment(content: string): string {
533+
private extractFirstComment(content: string | null | undefined): string {
534+
if (!content) return '';
535+
530536
// Try JSDoc style
531537
const jsdocMatch = content.match(/\/\*\*\s*\n?\s*\*\s*(.+?)(?:\n|\*\/)/);
532538
if (jsdocMatch) return jsdocMatch[1].trim();

tests/search-decision-card.test.ts

Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
1+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2+
import { promises as fs } from 'fs';
3+
import os from 'os';
4+
import path from 'path';
5+
import { CodebaseIndexer } from '../src/core/indexer.js';
6+
7+
describe('Search Decision Card (Edit Intent)', () => {
8+
let tempRoot: string | null = null;
9+
10+
beforeEach(async () => {
11+
vi.resetModules();
12+
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'search-decision-card-test-'));
13+
process.env.CODEBASE_ROOT = tempRoot;
14+
process.argv[2] = tempRoot;
15+
16+
// Create mock codebase with patterns and relationships
17+
const srcDir = path.join(tempRoot, 'src');
18+
await fs.mkdir(srcDir, { recursive: true });
19+
20+
// Main service
21+
await fs.writeFile(
22+
path.join(srcDir, 'auth.service.ts'),
23+
`
24+
/**
25+
* Authentication service for token management
26+
*/
27+
export class AuthService {
28+
getToken(): string {
29+
return 'token';
30+
}
31+
32+
refreshToken(): void {
33+
// Refresh token logic
34+
}
35+
36+
validateToken(token: string): boolean {
37+
return token.length > 0;
38+
}
39+
}
40+
`
41+
);
42+
43+
// Dependent file 1
44+
await fs.writeFile(
45+
path.join(srcDir, 'api.interceptor.ts'),
46+
`
47+
import { AuthService } from './auth.service';
48+
49+
export class ApiInterceptor {
50+
constructor(private auth: AuthService) {}
51+
52+
intercept() {
53+
const token = this.auth.getToken();
54+
return token;
55+
}
56+
}
57+
`
58+
);
59+
60+
// Dependent file 2
61+
await fs.writeFile(
62+
path.join(srcDir, 'user.service.ts'),
63+
`
64+
import { AuthService } from './auth.service';
65+
66+
export class UserService {
67+
constructor(private auth: AuthService) {}
68+
69+
getCurrentUser() {
70+
return this.auth.validateToken('token');
71+
}
72+
}
73+
`
74+
);
75+
76+
// Dependent file 3
77+
await fs.writeFile(
78+
path.join(srcDir, 'profile.service.ts'),
79+
`
80+
import { AuthService } from './auth.service';
81+
82+
export class ProfileService {
83+
constructor(private auth: AuthService) {}
84+
85+
loadProfile() {
86+
if (this.auth.validateToken('token')) {
87+
return { name: 'User' };
88+
}
89+
}
90+
}
91+
`
92+
);
93+
94+
// Index the project
95+
const indexer = new CodebaseIndexer({
96+
rootPath: tempRoot,
97+
config: { skipEmbedding: true }
98+
});
99+
await indexer.index();
100+
});
101+
102+
afterEach(async () => {
103+
if (tempRoot) {
104+
await fs.rm(tempRoot, { recursive: true, force: true });
105+
tempRoot = null;
106+
}
107+
delete process.env.CODEBASE_ROOT;
108+
});
109+
110+
it('intent="edit" with multiple results returns full decision card with ready field', async () => {
111+
if (!tempRoot) throw new Error('tempRoot not initialized');
112+
113+
const { server } = await import('../src/index.js');
114+
const handler = (server as any)._requestHandlers.get('tools/call');
115+
116+
const response = await handler({
117+
jsonrpc: '2.0',
118+
id: 1,
119+
method: 'tools/call',
120+
params: {
121+
name: 'search_codebase',
122+
arguments: {
123+
query: 'getToken',
124+
intent: 'edit'
125+
}
126+
}
127+
});
128+
129+
expect(response.content).toBeDefined();
130+
expect(response.content.length).toBeGreaterThan(0);
131+
const content = response.content[0];
132+
expect(content.type).toBe('text');
133+
134+
const parsed = JSON.parse(content.text);
135+
expect(parsed.results).toBeDefined();
136+
expect(parsed.results.length).toBeGreaterThan(0);
137+
138+
const preflight = parsed.preflight;
139+
expect(preflight).toBeDefined();
140+
expect(preflight.ready).toBeDefined();
141+
expect(typeof preflight.ready).toBe('boolean');
142+
});
143+
144+
it('decision card has all expected fields when returned', async () => {
145+
if (!tempRoot) throw new Error('tempRoot not initialized');
146+
147+
const { server } = await import('../src/index.js');
148+
const handler = (server as any)._requestHandlers.get('tools/call');
149+
150+
const response = await handler({
151+
jsonrpc: '2.0',
152+
id: 1,
153+
method: 'tools/call',
154+
params: {
155+
name: 'search_codebase',
156+
arguments: {
157+
query: 'AuthService',
158+
intent: 'edit'
159+
}
160+
}
161+
});
162+
163+
const content = response.content[0];
164+
const parsed = JSON.parse(content.text);
165+
const preflight = parsed.preflight;
166+
167+
// preflight should have ready as minimum
168+
expect(preflight.ready).toBeDefined();
169+
expect(typeof preflight.ready).toBe('boolean');
170+
171+
// Optional fields can be present
172+
if (preflight.nextAction) {
173+
expect(typeof preflight.nextAction).toBe('string');
174+
}
175+
if (preflight.patterns) {
176+
expect(typeof preflight.patterns).toBe('object');
177+
}
178+
if (preflight.warnings) {
179+
expect(Array.isArray(preflight.warnings)).toBe(true);
180+
}
181+
if (preflight.bestExample) {
182+
expect(typeof preflight.bestExample).toBe('string');
183+
}
184+
if (preflight.impact) {
185+
expect(typeof preflight.impact).toBe('object');
186+
}
187+
if (preflight.whatWouldHelp) {
188+
expect(Array.isArray(preflight.whatWouldHelp)).toBe(true);
189+
}
190+
});
191+
192+
it('intent="explore" returns lightweight preflight', async () => {
193+
if (!tempRoot) throw new Error('tempRoot not initialized');
194+
195+
const { server } = await import('../src/index.js');
196+
const handler = (server as any)._requestHandlers.get('tools/call');
197+
198+
const response = await handler({
199+
jsonrpc: '2.0',
200+
id: 1,
201+
method: 'tools/call',
202+
params: {
203+
name: 'search_codebase',
204+
arguments: {
205+
query: 'AuthService',
206+
intent: 'explore'
207+
}
208+
}
209+
});
210+
211+
const content = response.content[0];
212+
const parsed = JSON.parse(content.text);
213+
const preflight = parsed.preflight;
214+
215+
// For explore intent, preflight should be lite: { ready, reason? }
216+
if (preflight) {
217+
expect(preflight.ready).toBeDefined();
218+
expect(typeof preflight.ready).toBe('boolean');
219+
// Should NOT have full decision card fields for explore
220+
}
221+
});
222+
223+
it('includes snippet field when includeSnippets=true', async () => {
224+
if (!tempRoot) throw new Error('tempRoot not initialized');
225+
226+
const { server } = await import('../src/index.js');
227+
const handler = (server as any)._requestHandlers.get('tools/call');
228+
229+
const response = await handler({
230+
jsonrpc: '2.0',
231+
id: 1,
232+
method: 'tools/call',
233+
params: {
234+
name: 'search_codebase',
235+
arguments: {
236+
query: 'getToken',
237+
includeSnippets: true
238+
}
239+
}
240+
});
241+
242+
const content = response.content[0];
243+
const parsed = JSON.parse(content.text);
244+
245+
expect(parsed.results).toBeDefined();
246+
expect(parsed.results.length).toBeGreaterThan(0);
247+
248+
// At least some results should have a snippet
249+
const withSnippets = parsed.results.filter((r: any) => r.snippet);
250+
expect(withSnippets.length).toBeGreaterThan(0);
251+
});
252+
253+
it('does not include snippet field when includeSnippets=false', async () => {
254+
if (!tempRoot) throw new Error('tempRoot not initialized');
255+
256+
const { server } = await import('../src/index.js');
257+
const handler = (server as any)._requestHandlers.get('tools/call');
258+
259+
const response = await handler({
260+
jsonrpc: '2.0',
261+
id: 1,
262+
method: 'tools/call',
263+
params: {
264+
name: 'search_codebase',
265+
arguments: {
266+
query: 'getToken',
267+
includeSnippets: false
268+
}
269+
}
270+
});
271+
272+
const content = response.content[0];
273+
const parsed = JSON.parse(content.text);
274+
275+
expect(parsed.results).toBeDefined();
276+
// All results should not have snippet field
277+
parsed.results.forEach((r: any) => {
278+
expect(r.snippet).toBeUndefined();
279+
});
280+
});
281+
282+
it('scope header starts snippet when includeSnippets=true', async () => {
283+
if (!tempRoot) throw new Error('tempRoot not initialized');
284+
285+
const { server } = await import('../src/index.js');
286+
const handler = (server as any)._requestHandlers.get('tools/call');
287+
288+
const response = await handler({
289+
jsonrpc: '2.0',
290+
id: 1,
291+
method: 'tools/call',
292+
params: {
293+
name: 'search_codebase',
294+
arguments: {
295+
query: 'getToken',
296+
includeSnippets: true
297+
}
298+
}
299+
});
300+
301+
const content = response.content[0];
302+
const parsed = JSON.parse(content.text);
303+
304+
const withSnippet = parsed.results.find((r: any) => r.snippet);
305+
if (withSnippet && withSnippet.snippet) {
306+
// Scope header should be a comment line
307+
const firstLine = withSnippet.snippet.split('\n')[0].trim();
308+
expect(firstLine).toMatch(/^\/\//);
309+
}
310+
});
311+
});

0 commit comments

Comments
 (0)