Skip to content

Commit d45e040

Browse files
committed
feat: support per-project analyzer hints
1 parent 5f3a9ba commit d45e040

14 files changed

+608
-63
lines changed

docs/capabilities.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ HTTP defaults to `127.0.0.1:3100`. Override with `--port`, `CODEBASE_CONTEXT_POR
1515

1616
Config-registered project roots (from `~/.codebase-context/config.json`) are loaded at startup in both modes.
1717

18+
Per-project config overrides supported today:
19+
20+
- `projects[].excludePatterns`: merged with the built-in exclusion set for that project at index time
21+
- `projects[].analyzerHints.analyzer`: prefers a registered analyzer by name for that project and falls back safely when the name is missing or invalid
22+
- `projects[].analyzerHints.extensions`: adds project-local source extensions for indexing and auto-refresh watching without changing defaults for other projects
23+
1824
Copy-pasteable client config templates are shipped in the package:
1925

2026
- `templates/mcp/stdio/.mcp.json` — stdio setup for `.mcp.json`-style clients

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@
114114
"start": "node dist/index.js",
115115
"dev": "tsx src/index.ts",
116116
"watch": "tsc -w",
117-
"test": "vitest run",
117+
"test": "node scripts/run-vitest.mjs",
118118
"test:watch": "vitest",
119119
"lint": "eslint \"src/**/*.ts\"",
120120
"format": "prettier --write \"src/**/*.ts\"",

scripts/run-vitest.mjs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { spawn } from 'node:child_process';
2+
import { fileURLToPath } from 'node:url';
3+
4+
const forwardedArgs = process.argv.slice(2);
5+
const vitestArgs =
6+
forwardedArgs[0] === '--' ? forwardedArgs.slice(1) : forwardedArgs;
7+
8+
const vitestEntrypoint = fileURLToPath(
9+
new URL('../node_modules/vitest/vitest.mjs', import.meta.url)
10+
);
11+
12+
const child = spawn(process.execPath, [vitestEntrypoint, 'run', ...vitestArgs], {
13+
stdio: 'inherit',
14+
env: process.env
15+
});
16+
17+
child.on('exit', (code, signal) => {
18+
if (signal) {
19+
process.kill(process.pid, signal);
20+
return;
21+
}
22+
23+
process.exit(code ?? 1);
24+
});
25+
26+
child.on('error', (error) => {
27+
console.error('[test] Failed to start vitest:', error);
28+
process.exit(1);
29+
});

src/core/analyzer-registry.ts

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,14 @@
33
* Automatically selects the best analyzer based on file type and priority
44
*/
55

6+
import path from 'path';
67
import { FrameworkAnalyzer, AnalysisResult } from '../types/index.js';
78

9+
export interface AnalyzerSelectionOptions {
10+
preferredAnalyzer?: string;
11+
extraFileExtensions?: string[];
12+
}
13+
814
export class AnalyzerRegistry {
915
private analyzers: Map<string, FrameworkAnalyzer> = new Map();
1016
private sortedAnalyzers: FrameworkAnalyzer[] = [];
@@ -43,11 +49,42 @@ export class AnalyzerRegistry {
4349
return [...this.sortedAnalyzers];
4450
}
4551

52+
private isExtraExtension(filePath: string, extraFileExtensions?: string[]): boolean {
53+
if (!extraFileExtensions?.length) {
54+
return false;
55+
}
56+
57+
const extension = path.extname(filePath).toLowerCase();
58+
return extraFileExtensions.some((candidate) => {
59+
const normalized = candidate.trim().toLowerCase();
60+
if (!normalized) {
61+
return false;
62+
}
63+
return extension === (normalized.startsWith('.') ? normalized : `.${normalized}`);
64+
});
65+
}
66+
4667
/**
47-
* Find the best analyzer for a given file
48-
* Returns the analyzer with highest priority that can handle the file
68+
* Find the best analyzer for a given file.
69+
* Returns the preferred analyzer when configured and applicable, otherwise the
70+
* highest-priority analyzer that can handle the file.
4971
*/
50-
findAnalyzer(filePath: string, content?: string): FrameworkAnalyzer | null {
72+
findAnalyzer(
73+
filePath: string,
74+
content?: string,
75+
options?: AnalyzerSelectionOptions
76+
): FrameworkAnalyzer | null {
77+
if (options?.preferredAnalyzer) {
78+
const preferred = this.analyzers.get(options.preferredAnalyzer);
79+
if (
80+
preferred &&
81+
(preferred.canAnalyze(filePath, content) ||
82+
this.isExtraExtension(filePath, options.extraFileExtensions))
83+
) {
84+
return preferred;
85+
}
86+
}
87+
5188
for (const analyzer of this.sortedAnalyzers) {
5289
if (analyzer.canAnalyze(filePath, content)) {
5390
return analyzer;
@@ -66,8 +103,12 @@ export class AnalyzerRegistry {
66103
/**
67104
* Analyze a file using the best available analyzer
68105
*/
69-
async analyzeFile(filePath: string, content: string): Promise<AnalysisResult | null> {
70-
const analyzer = this.findAnalyzer(filePath, content);
106+
async analyzeFile(
107+
filePath: string,
108+
content: string,
109+
options?: AnalyzerSelectionOptions
110+
): Promise<AnalysisResult | null> {
111+
const analyzer = this.findAnalyzer(filePath, content, options);
71112

72113
if (!analyzer) {
73114
if (process.env.CODEBASE_CONTEXT_DEBUG) {
@@ -76,8 +117,6 @@ export class AnalyzerRegistry {
76117
return null;
77118
}
78119

79-
// console.error(`Analyzing ${filePath} with ${analyzer.name} analyzer`);
80-
81120
try {
82121
return await analyzer.analyze(filePath, content);
83122
} catch (error) {

src/core/file-watcher.ts

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,35 +7,37 @@ export interface FileWatcherOptions {
77
rootPath: string;
88
/** ms after last change before triggering. Default: 2000 */
99
debounceMs?: number;
10+
/** Additional source extensions tracked for this project only. */
11+
extraExtensions?: string[];
1012
/** Called once chokidar finishes initial scan and starts emitting change events */
1113
onReady?: () => void;
1214
/** Called once the debounce window expires after the last detected change */
1315
onChanged: () => void;
1416
}
1517

16-
const TRACKED_EXTENSIONS = new Set(
17-
getSupportedExtensions().map((extension) => extension.toLowerCase())
18-
);
19-
2018
const TRACKED_METADATA_FILES = new Set(['.gitignore']);
2119

22-
function isTrackedSourcePath(filePath: string): boolean {
20+
function isTrackedSourcePath(filePath: string, trackedExtensions: Set<string>): boolean {
2321
const basename = path.basename(filePath).toLowerCase();
2422
if (TRACKED_METADATA_FILES.has(basename)) return true;
23+
2524
const extension = path.extname(filePath).toLowerCase();
26-
return extension.length > 0 && TRACKED_EXTENSIONS.has(extension);
25+
return extension.length > 0 && trackedExtensions.has(extension);
2726
}
2827

2928
/**
3029
* Watch rootPath for source file changes and call onChanged (debounced).
3130
* Returns a stop() function that cancels the debounce timer and closes the watcher.
3231
*/
3332
export function startFileWatcher(opts: FileWatcherOptions): () => void {
34-
const { rootPath, debounceMs = 2000, onReady, onChanged } = opts;
33+
const { rootPath, debounceMs = 2000, extraExtensions, onReady, onChanged } = opts;
34+
const trackedExtensions = new Set(
35+
getSupportedExtensions(extraExtensions).map((extension) => extension.toLowerCase())
36+
);
3537
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
3638

3739
const trigger = (filePath: string) => {
38-
if (!isTrackedSourcePath(filePath)) return;
40+
if (!isTrackedSourcePath(filePath, trackedExtensions)) return;
3941
if (debounceTimer !== undefined) clearTimeout(debounceTimer);
4042
debounceTimer = setTimeout(() => {
4143
debounceTimer = undefined;

src/core/indexer.ts

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
IntelligenceData
2121
} from '../types/index.js';
2222
import { analyzerRegistry } from './analyzer-registry.js';
23+
import type { AnalyzerSelectionOptions } from './analyzer-registry.js';
2324
import { isCodeFile, isBinaryFile } from '../utils/language-detection.js';
2425
import {
2526
getEmbeddingProvider,
@@ -210,6 +211,7 @@ async function cleanupDirectory(dirPath: string): Promise<void> {
210211
export interface IndexerOptions {
211212
rootPath: string;
212213
config?: Partial<CodebaseConfig>;
214+
projectOptions?: AnalyzerSelectionOptions;
213215
onProgress?: (progress: IndexingProgress) => void;
214216
incrementalOnly?: boolean;
215217
}
@@ -224,13 +226,18 @@ interface PersistedIndexingStats {
224226
export class CodebaseIndexer {
225227
private rootPath: string;
226228
private config: CodebaseConfig;
229+
private projectOptions: AnalyzerSelectionOptions;
227230
private progress: IndexingProgress;
228231
private onProgressCallback?: (progress: IndexingProgress) => void;
229232
private incrementalOnly: boolean;
230233

231234
constructor(options: IndexerOptions) {
232235
this.rootPath = path.resolve(options.rootPath);
233236
this.config = this.mergeConfig(options.config);
237+
this.projectOptions = {
238+
preferredAnalyzer: options.projectOptions?.preferredAnalyzer,
239+
extraFileExtensions: options.projectOptions?.extraFileExtensions
240+
};
234241
this.onProgressCallback = options.onProgress;
235242
this.incrementalOnly = options.incrementalOnly ?? false;
236243

@@ -321,6 +328,45 @@ export class CodebaseIndexer {
321328
};
322329
}
323330

331+
private getProjectOptions(): AnalyzerSelectionOptions {
332+
const preferredAnalyzer = this.projectOptions.preferredAnalyzer?.trim();
333+
if (!preferredAnalyzer) {
334+
return this.projectOptions;
335+
}
336+
337+
if (!analyzerRegistry.get(preferredAnalyzer)) {
338+
console.warn(
339+
`[indexer] Preferred analyzer "${preferredAnalyzer}" is not registered. Falling back to default analyzer selection.`
340+
);
341+
return {
342+
...this.projectOptions,
343+
preferredAnalyzer: undefined
344+
};
345+
}
346+
347+
return {
348+
...this.projectOptions,
349+
preferredAnalyzer
350+
};
351+
}
352+
353+
private getIncludePatterns(): string[] {
354+
const includePatterns = this.config.include || ['**/*'];
355+
const extraFileExtensions = this.projectOptions.extraFileExtensions ?? [];
356+
357+
if (extraFileExtensions.length === 0) {
358+
return includePatterns;
359+
}
360+
361+
const extraPatterns = extraFileExtensions
362+
.map((extension) => extension.trim().toLowerCase())
363+
.filter((extension) => extension.length > 0)
364+
.map((extension) => (extension.startsWith('.') ? extension : `.${extension}`))
365+
.map((extension) => `**/*${extension}`);
366+
367+
return Array.from(new Set([...includePatterns, ...extraPatterns]));
368+
}
369+
324370
async index(): Promise<IndexingStats> {
325371
const startTime = Date.now();
326372
const stats: IndexingStats = {
@@ -357,6 +403,8 @@ export class CodebaseIndexer {
357403
analyzerRegistry.register(new GenericAnalyzer());
358404
}
359405

406+
const resolvedProjectOptions = this.getProjectOptions();
407+
360408
const buildId = randomUUID();
361409
const generatedAt = new Date().toISOString();
362410
const toolVersion = await getToolVersion();
@@ -529,7 +577,7 @@ export class CodebaseIndexer {
529577
// Normalize line endings to \n for consistent cross-platform output
530578
const rawContent = await fs.readFile(file, 'utf-8');
531579
const content = rawContent.replace(/\r\n/g, '\n');
532-
const result = await analyzerRegistry.analyzeFile(file, content);
580+
const result = await analyzerRegistry.analyzeFile(file, content, resolvedProjectOptions);
533581

534582
if (result) {
535583
const isFileChanged = !filesToProcessSet || filesToProcessSet.has(file);
@@ -1027,7 +1075,7 @@ export class CodebaseIndexer {
10271075
}
10281076

10291077
// Scan with glob
1030-
const includePatterns = this.config.include || ['**/*'];
1078+
const includePatterns = this.getIncludePatterns();
10311079
const excludePatterns = this.config.exclude || [];
10321080

10331081
for (const pattern of includePatterns) {
@@ -1053,7 +1101,7 @@ export class CodebaseIndexer {
10531101
}
10541102

10551103
// Check if it's a code file
1056-
if (!isCodeFile(file) || isBinaryFile(file)) {
1104+
if (!isCodeFile(file, this.projectOptions.extraFileExtensions) || isBinaryFile(file)) {
10571105
continue;
10581106
}
10591107

0 commit comments

Comments
 (0)