codesearch-mcp 0.0.16-alpha → 0.0.17-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/CHANGELOG.md +37 -1
  2. package/README.md +59 -36
  3. package/dist/app.d.ts +1 -1
  4. package/dist/app.js +67 -16
  5. package/dist/src/commands/BackfillCommand.js +15 -5
  6. package/dist/src/commands/ConfigCommand.d.ts +12 -0
  7. package/dist/src/commands/ConfigCommand.js +98 -0
  8. package/dist/src/commands/IndexCommand.js +1 -1
  9. package/dist/src/config-store.d.ts +53 -0
  10. package/dist/src/config-store.js +197 -0
  11. package/dist/src/config.d.ts +22 -8
  12. package/dist/src/config.js +103 -58
  13. package/dist/src/container.d.ts +1 -1
  14. package/dist/src/container.js +8 -7
  15. package/dist/src/db/index.d.ts +1 -1
  16. package/dist/src/db/index.js +2 -1
  17. package/dist/src/db/schema.d.ts +160 -0
  18. package/dist/src/db/schema.js +12 -1
  19. package/dist/src/help.js +28 -12
  20. package/dist/src/interfaces/IAstParser.d.ts +14 -1
  21. package/dist/src/interfaces/IEmbedder.d.ts +1 -0
  22. package/dist/src/project-selector.d.ts +14 -5
  23. package/dist/src/project-selector.js +74 -60
  24. package/dist/src/services/AstParserService.d.ts +5 -1
  25. package/dist/src/services/AstParserService.js +107 -16
  26. package/dist/src/services/DatabaseService.js +18 -0
  27. package/dist/src/services/IndexerService.d.ts +7 -0
  28. package/dist/src/services/IndexerService.js +160 -40
  29. package/dist/src/services/LoggerService.d.ts +5 -1
  30. package/dist/src/services/LoggerService.js +8 -5
  31. package/dist/src/services/McpServer.d.ts +1 -0
  32. package/dist/src/services/McpServer.js +72 -4
  33. package/dist/src/services/OllamaEmbedder.d.ts +2 -0
  34. package/dist/src/services/OllamaEmbedder.js +29 -0
  35. package/dist/src/services/SearcherService.d.ts +13 -0
  36. package/dist/src/services/SearcherService.js +118 -22
  37. package/dist/src/setup.js +57 -62
  38. package/package.json +3 -3
package/dist/src/help.js CHANGED
@@ -5,28 +5,22 @@ function printHelp(command) {
5
5
  switch (command) {
6
6
  case 'rag-index':
7
7
  case 'index':
8
- console.log('Usage: rag-index [path] [options]');
9
- console.log(' rag-index backfill');
8
+ console.log('Usage: rag-index [options]');
10
9
  console.log('');
11
10
  console.log('Index a workspace of JS/TS projects into the vector database.');
12
- console.log('');
13
- console.log('Arguments:');
14
- console.log(' path Path to workspace (defaults to WORKSPACE_ROOT env var)');
11
+ console.log('Workspace is read from workspaceRoot (set via rag-setup or rag-config).');
15
12
  console.log('');
16
13
  console.log('Options:');
17
14
  console.log(' --reindex Force re-index all files (ignores cached mtimes)');
18
15
  console.log(' --select Interactively select which projects to index');
19
16
  console.log(' -h, --help Show this help message');
20
- console.log('');
21
- console.log('Subcommands:');
22
- console.log(' backfill Populate symbol index (code_symbol) for already-indexed files');
23
17
  break;
24
18
  case 'rag-mcp':
25
19
  case 'mcp':
26
20
  console.log('Usage: rag-mcp');
27
21
  console.log('');
28
22
  console.log('Start the MCP server for semantic code search.');
29
- console.log('Requires WORKSPACE_ROOT to be set (run rag-setup first).');
23
+ console.log('Requires workspaceRoot to be set (run rag-setup first).');
30
24
  console.log('');
31
25
  console.log('Options:');
32
26
  console.log(' -h, --help Show this help message');
@@ -35,17 +29,39 @@ function printHelp(command) {
35
29
  case 'setup':
36
30
  console.log('Usage: rag-setup');
37
31
  console.log('');
38
- console.log('Interactive setup: configure DB, Ollama, and WORKSPACE_ROOT.');
32
+ console.log('Interactive setup: configure DB, Ollama, and workspaceRoot.');
39
33
  console.log('Run once before using rag-index or rag-mcp.');
40
34
  console.log('');
41
35
  console.log('Options:');
42
36
  console.log(' -h, --help Show this help message');
43
37
  break;
38
+ case 'rag-config':
39
+ case 'config':
40
+ console.log('Usage: rag-config <subcommand>');
41
+ console.log('');
42
+ console.log('Manage settings stored in ~/.config/codesearch-mcp/config.json');
43
+ console.log('');
44
+ console.log('Subcommands:');
45
+ console.log(' show Print current config (password redacted)');
46
+ console.log(' get <key> Print a single value');
47
+ console.log(' set <key> <value> Set a value');
48
+ console.log(' reset <key> Remove key (falls back to built-in default)');
49
+ console.log('');
50
+ console.log('Keys:');
51
+ console.log(' db.host, db.port, db.user, db.password, db.name');
52
+ console.log(' ollama.host, ollama.model');
53
+ console.log(' indexing.chunkSize, indexing.chunkOverlap, indexing.embedBatchSize');
54
+ console.log(' search.similarityThreshold, search.defaultLimit, search.maxLimit');
55
+ console.log(' search.autoTranslate, search.translateModel');
56
+ console.log(' reranker.enabled, reranker.model, reranker.url, reranker.overfetch');
57
+ console.log(' logging.level, logging.dir');
58
+ console.log(' workspaceRoot');
59
+ break;
44
60
  default:
45
- console.log('Usage: rag-index [path] [--reindex] [--select]');
46
- console.log(' rag-index backfill');
61
+ console.log('Usage: rag-index [--reindex] [--select]');
47
62
  console.log(' rag-mcp');
48
63
  console.log(' rag-setup');
64
+ console.log(' rag-config <show|get|set|reset>');
49
65
  console.log('');
50
66
  console.log('Run any command with -h or --help for details.');
51
67
  }
@@ -16,15 +16,28 @@ export interface SymbolEntry {
16
16
  endLine: number | null;
17
17
  col: number | null;
18
18
  }
19
+ export interface CallEdge {
20
+ project: string;
21
+ fromFile: string;
22
+ fromSymbol: string | null;
23
+ calleeName: string;
24
+ calleeFile: string;
25
+ calleeSymbol: string | null;
26
+ line: number | null;
27
+ col: number | null;
28
+ }
19
29
  export interface FileAnalysis {
20
30
  edges: DepEdge[];
21
31
  symbols: SymbolEntry[];
32
+ calls: CallEdge[];
22
33
  }
23
34
  export interface IAstParser {
24
35
  /**
25
36
  * @deprecated Use analyzeProject instead for project-wide context.
26
37
  */
27
38
  analyzeFile(filePath: string, serviceName: string, projectRoot?: string): Promise<FileAnalysis>;
28
- analyzeProject(filePaths: string[], serviceName: string, projectRoot?: string): Promise<Map<string, FileAnalysis>>;
39
+ analyzeProject(filePaths: string[], serviceName: string, projectRoot?: string, opts?: {
40
+ skipDependencyResolution?: boolean;
41
+ }): Promise<Map<string, FileAnalysis>>;
29
42
  clearCache(projectRoot?: string): void;
30
43
  }
@@ -1,4 +1,5 @@
1
1
  export interface IEmbedder {
2
2
  embed(text: string): Promise<number[]>;
3
3
  translate?(text: string, model: string): Promise<string>;
4
+ rerank?(query: string, documents: string[], topN: number): Promise<number[]>;
4
5
  }
@@ -1,7 +1,16 @@
1
- export declare function selectProjects(allProjects: {
1
+ export interface Project {
2
2
  name: string;
3
3
  path: string;
4
- }[], workspaceDir: string, forceSelect: boolean): Promise<{
5
- name: string;
6
- path: string;
7
- }[]>;
4
+ }
5
+ export declare class ProjectSelector {
6
+ private readonly workspaceDir;
7
+ private included;
8
+ private skipped;
9
+ constructor(workspaceDir: string);
10
+ select(allProjects: Project[], forceSelect: boolean): Promise<Project[]>;
11
+ private printAlreadySelected;
12
+ private promptForSelection;
13
+ private promptForSkip;
14
+ private load;
15
+ private save;
16
+ }
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.selectProjects = selectProjects;
36
+ exports.ProjectSelector = void 0;
37
37
  const fs = __importStar(require("fs"));
38
38
  const path = __importStar(require("path"));
39
39
  const prompts_1 = require("@inquirer/prompts");
@@ -65,76 +65,90 @@ async function saveLines(filePath, names) {
65
65
  }
66
66
  }
67
67
  }
68
- async function loadConfig(workspaceDir) {
69
- const [included, skipped] = await Promise.all([
70
- loadLines(path.join(workspaceDir, '.ragprojects')),
71
- loadLines(path.join(workspaceDir, '.ragskip')),
72
- ]);
73
- return { included, skipped };
74
- }
75
- async function saveConfig(workspaceDir, config) {
76
- await Promise.all([
77
- saveLines(path.join(workspaceDir, '.ragprojects'), config.included),
78
- saveLines(path.join(workspaceDir, '.ragskip'), config.skipped),
79
- ]);
80
- }
81
- async function selectProjects(allProjects, workspaceDir, forceSelect) {
82
- const config = await loadConfig(workspaceDir);
83
- const newProjects = allProjects.filter(p => !config.included.has(p.name) && !config.skipped.has(p.name));
84
- // Nothing to ask — return previously saved selection silently
85
- if (newProjects.length === 0 && !forceSelect) {
86
- const selected = allProjects.filter(p => config.included.has(p.name));
87
- if (selected.length === 0) {
88
- console.log('No projects selected. Run with --select to choose projects.');
68
+ class ProjectSelector {
69
+ workspaceDir;
70
+ included = new Set();
71
+ skipped = new Set();
72
+ constructor(workspaceDir) {
73
+ this.workspaceDir = workspaceDir;
74
+ }
75
+ async select(allProjects, forceSelect) {
76
+ await this.load();
77
+ const newProjects = allProjects.filter(p => !this.included.has(p.name) && !this.skipped.has(p.name));
78
+ const previouslySelected = allProjects.filter(p => this.included.has(p.name));
79
+ // Nothing new and something already selected — return silently
80
+ if (newProjects.length === 0 && !forceSelect && previouslySelected.length > 0) {
81
+ return previouslySelected;
89
82
  }
83
+ // No previous selection — treat as implicit --select so the user picks projects now
84
+ const autoSelect = !forceSelect && previouslySelected.length === 0;
85
+ // In --select / auto-select mode show all non-skipped projects; otherwise only new ones
86
+ const toShow = (forceSelect || autoSelect)
87
+ ? allProjects.filter(p => !this.skipped.has(p.name))
88
+ : newProjects;
89
+ if (toShow.length === 0) {
90
+ console.log('All discovered projects are marked as skipped. Run with --select to reconfigure.');
91
+ return [];
92
+ }
93
+ if (!forceSelect && !autoSelect)
94
+ this.printAlreadySelected(allProjects);
95
+ await this.promptForSelection(toShow, forceSelect || autoSelect, newProjects.length);
96
+ await this.promptForSkip(toShow);
97
+ await this.save();
98
+ const selected = allProjects.filter(p => this.included.has(p.name));
99
+ console.log(`\nSelected: ${selected.map(p => p.name).join(', ') || '(none)'}`);
90
100
  return selected;
91
101
  }
92
- // In --select mode show all non-skipped projects; otherwise only new ones
93
- const toShow = forceSelect
94
- ? allProjects.filter(p => !config.skipped.has(p.name))
95
- : newProjects;
96
- const alreadyIncluded = allProjects.filter(p => config.included.has(p.name));
97
- if (!forceSelect && alreadyIncluded.length > 0) {
98
- console.log(`Already selected (will be re-indexed): ${alreadyIncluded.map(p => p.name).join(', ')}`);
99
- console.log('Run with --select to change the full selection.\n');
100
- }
101
- const message = forceSelect
102
- ? 'Select projects to index'
103
- : `Found ${newProjects.length} new project(s) — select which to add`;
104
- // Checkbox: pre-check previously included projects
105
- const chosen = await (0, prompts_1.checkbox)({
106
- message,
107
- choices: toShow.map(p => ({
108
- name: p.name,
109
- value: p.name,
110
- checked: config.included.has(p.name),
111
- })),
112
- pageSize: 15,
113
- });
114
- const chosenSet = new Set(chosen);
115
- // Update included
116
- for (const p of toShow) {
117
- if (chosenSet.has(p.name)) {
118
- config.included.add(p.name);
119
- config.skipped.delete(p.name);
102
+ printAlreadySelected(allProjects) {
103
+ const alreadyIncluded = allProjects.filter(p => this.included.has(p.name));
104
+ if (alreadyIncluded.length > 0) {
105
+ console.log(`Already selected (will be re-indexed): ${alreadyIncluded.map(p => p.name).join(', ')}`);
106
+ console.log('Run with --select to change the full selection.\n');
120
107
  }
121
- else {
122
- config.included.delete(p.name);
108
+ }
109
+ async promptForSelection(toShow, showAll, newCount) {
110
+ const message = showAll
111
+ ? 'Select projects to index'
112
+ : `Found ${newCount} new project(s) — select which to add`;
113
+ const chosen = await (0, prompts_1.checkbox)({
114
+ message,
115
+ choices: toShow.map(p => ({ name: p.name, value: p.name, checked: this.included.has(p.name) })),
116
+ pageSize: 15,
117
+ });
118
+ const chosenSet = new Set(chosen);
119
+ for (const p of toShow) {
120
+ if (chosenSet.has(p.name)) {
121
+ this.included.add(p.name);
122
+ this.skipped.delete(p.name);
123
+ }
124
+ else {
125
+ this.included.delete(p.name);
126
+ }
123
127
  }
124
128
  }
125
- // Ask which unchecked projects to always skip
126
- const unchecked = toShow.filter(p => !chosenSet.has(p.name) && !config.skipped.has(p.name));
127
- if (unchecked.length > 0) {
129
+ async promptForSkip(toShow) {
130
+ const unchecked = toShow.filter(p => !this.included.has(p.name) && !this.skipped.has(p.name));
131
+ if (unchecked.length === 0)
132
+ return;
128
133
  const toSkip = await (0, prompts_1.checkbox)({
129
134
  message: 'Always skip (never ask again)?',
130
135
  choices: unchecked.map(p => ({ name: p.name, value: p.name, checked: false })),
131
136
  pageSize: 15,
132
137
  });
133
138
  for (const name of toSkip)
134
- config.skipped.add(name);
139
+ this.skipped.add(name);
140
+ }
141
+ async load() {
142
+ [this.included, this.skipped] = await Promise.all([
143
+ loadLines(path.join(this.workspaceDir, '.ragprojects')),
144
+ loadLines(path.join(this.workspaceDir, '.ragskip')),
145
+ ]);
146
+ }
147
+ async save() {
148
+ await Promise.all([
149
+ saveLines(path.join(this.workspaceDir, '.ragprojects'), this.included),
150
+ saveLines(path.join(this.workspaceDir, '.ragskip'), this.skipped),
151
+ ]);
135
152
  }
136
- await saveConfig(workspaceDir, config);
137
- const selected = allProjects.filter(p => config.included.has(p.name));
138
- console.log(`\nSelected: ${selected.map(p => p.name).join(', ') || '(none)'}`);
139
- return selected;
140
153
  }
154
+ exports.ProjectSelector = ProjectSelector;
@@ -4,7 +4,9 @@ export declare class AstParserService implements IAstParser {
4
4
  private logger;
5
5
  private morphProjects;
6
6
  constructor(logger?: LoggerService);
7
- analyzeProject(filePaths: string[], serviceName: string, projectRoot?: string): Promise<Map<string, FileAnalysis>>;
7
+ analyzeProject(filePaths: string[], serviceName: string, projectRoot?: string, opts?: {
8
+ skipDependencyResolution?: boolean;
9
+ }): Promise<Map<string, FileAnalysis>>;
8
10
  clearCache(projectRoot?: string): void;
9
11
  analyzeFile(filePath: string, serviceName: string, projectRoot?: string): Promise<FileAnalysis>;
10
12
  extractImportEdges(filePath: string, serviceName: string, projectRoot?: string): Promise<DepEdge[]>;
@@ -14,4 +16,6 @@ export declare class AstParserService implements IAstParser {
14
16
  private resolveFromSymbol;
15
17
  private buildUsageMap;
16
18
  private extractSymbols;
19
+ private extractCalls;
20
+ private getContainerName;
17
21
  }
@@ -51,13 +51,15 @@ class AstParserService {
51
51
  this.logger = logger;
52
52
  }
53
53
  // ── public interface ────────────────────────────────────────────────────────
54
- async analyzeProject(filePaths, serviceName, projectRoot = process.cwd()) {
54
+ async analyzeProject(filePaths, serviceName, projectRoot = process.cwd(), opts = {}) {
55
55
  const t0 = Date.now();
56
+ const skipDependencyResolution = opts.skipDependencyResolution ?? false;
57
+ this.logger.debug('AstParser: analyzeProject start', { serviceName, projectRoot, files: filePaths.length, skipDependencyResolution });
56
58
  const analysisMap = new Map();
57
- const morphProject = this.createMorphProject(projectRoot);
59
+ const morphProject = this.createMorphProject(projectRoot, skipDependencyResolution);
58
60
  for (const filePath of filePaths) {
59
61
  if (!/\.(ts|tsx|js|jsx)$/.test(filePath) || /\.d\.ts$/.test(filePath)) {
60
- analysisMap.set(filePath, { edges: [], symbols: [] });
62
+ analysisMap.set(filePath, { edges: [], symbols: [], calls: [] });
61
63
  continue;
62
64
  }
63
65
  try {
@@ -65,7 +67,7 @@ class AstParserService {
65
67
  }
66
68
  catch (err) {
67
69
  this.logger.warn('AstParser: failed to add source file', { filePath, reason: err.message });
68
- analysisMap.set(filePath, { edges: [], symbols: [] });
70
+ analysisMap.set(filePath, { edges: [], symbols: [], calls: [] });
69
71
  }
70
72
  }
71
73
  for (const sourceFile of morphProject.getSourceFiles()) {
@@ -73,11 +75,12 @@ class AstParserService {
73
75
  try {
74
76
  const edges = this.extractEdges(sourceFile, filePath, serviceName);
75
77
  const symbols = this.extractSymbols(sourceFile, filePath, serviceName);
76
- analysisMap.set(filePath, { edges, symbols });
78
+ const calls = this.extractCalls(sourceFile, filePath, serviceName);
79
+ analysisMap.set(filePath, { edges, symbols, calls });
77
80
  }
78
81
  catch (err) {
79
82
  this.logger.warn('AstParser: failed to analyze file', { filePath, reason: err.message });
80
- analysisMap.set(filePath, { edges: [], symbols: [] });
83
+ analysisMap.set(filePath, { edges: [], symbols: [], calls: [] });
81
84
  }
82
85
  }
83
86
  // Release source file memory before returning
@@ -85,7 +88,7 @@ class AstParserService {
85
88
  morphProject.removeSourceFile(sourceFile);
86
89
  }
87
90
  this.logger.debug('AstParser: analyzeProject done', {
88
- projectRoot, total: filePaths.length, parsed: analysisMap.size, ms: Date.now() - t0,
91
+ serviceName, projectRoot, total: filePaths.length, parsed: analysisMap.size, skipDependencyResolution, ms: Date.now() - t0,
89
92
  });
90
93
  return analysisMap;
91
94
  }
@@ -101,8 +104,11 @@ class AstParserService {
101
104
  }
102
105
  }
103
106
  async analyzeFile(filePath, serviceName, projectRoot = path.dirname(filePath)) {
107
+ const t0 = Date.now();
108
+ this.logger.debug('AstParser: analyzeFile start', { filePath, serviceName, projectRoot });
104
109
  if (!/\.(ts|tsx|js|jsx)$/.test(filePath) || /\.d\.ts$/.test(filePath)) {
105
- return { edges: [], symbols: [] };
110
+ this.logger.debug('AstParser: analyzeFile skip (non-TS)', { filePath, ms: Date.now() - t0 });
111
+ return { edges: [], symbols: [], calls: [] };
106
112
  }
107
113
  const morphProject = this.getMorphProject(projectRoot);
108
114
  const existing = morphProject.getSourceFile(filePath);
@@ -112,13 +118,16 @@ class AstParserService {
112
118
  try {
113
119
  sourceFile = morphProject.addSourceFileAtPath(filePath);
114
120
  }
115
- catch {
116
- return { edges: [], symbols: [] };
121
+ catch (err) {
122
+ this.logger.warn('AstParser: analyzeFile failed to add source file', { filePath, reason: err.message, ms: Date.now() - t0 });
123
+ return { edges: [], symbols: [], calls: [] };
117
124
  }
118
125
  const edges = this.extractEdges(sourceFile, filePath, serviceName);
119
126
  const symbols = this.extractSymbols(sourceFile, filePath, serviceName);
127
+ const calls = this.extractCalls(sourceFile, filePath, serviceName);
120
128
  morphProject.removeSourceFile(sourceFile);
121
- return { edges, symbols };
129
+ this.logger.debug('AstParser: analyzeFile done', { filePath, edges: edges.length, symbols: symbols.length, calls: calls.length, ms: Date.now() - t0 });
130
+ return { edges, symbols, calls };
122
131
  }
123
132
  // Kept for test compatibility (extract-import-edges.test.ts).
124
133
  async extractImportEdges(filePath, serviceName, projectRoot) {
@@ -126,30 +135,35 @@ class AstParserService {
126
135
  return edges;
127
136
  }
128
137
  // ── private: MorphProject cache (LRU, max MORPH_CACHE_MAX entries) ──────────
129
- createMorphProject(projectRoot) {
138
+ createMorphProject(projectRoot, skipFileDependencyResolution = false) {
139
+ const t0 = Date.now();
130
140
  const tsconfigPath = path.join(projectRoot, 'tsconfig.json');
131
141
  const hasTsconfig = fs.existsSync(tsconfigPath);
132
- this.logger.debug('AstParser: tsconfig lookup', { projectRoot, found: hasTsconfig });
142
+ this.logger.debug('AstParser: tsconfig lookup', { projectRoot, found: hasTsconfig, skipFileDependencyResolution });
133
143
  let project;
134
144
  try {
135
145
  const opts = hasTsconfig
136
- ? { tsConfigFilePath: tsconfigPath, skipAddingFilesFromTsConfig: true, skipFileDependencyResolution: false }
137
- : FALLBACK_OPTS;
146
+ ? { tsConfigFilePath: tsconfigPath, skipAddingFilesFromTsConfig: true, skipFileDependencyResolution }
147
+ : { ...FALLBACK_OPTS, skipFileDependencyResolution };
138
148
  project = new ts_morph_1.Project(opts);
139
149
  if (hasTsconfig) {
140
150
  const co = project.getCompilerOptions();
141
151
  const pathsMap = co.paths ?? {};
142
152
  const pathCount = Object.keys(pathsMap).length;
143
- this.logger.debug('AstParser: tsconfig loaded', { pathCount });
153
+ this.logger.debug('AstParser: tsconfig loaded', { pathCount, ms: Date.now() - t0 });
144
154
  if (pathCount > 0) {
145
155
  this.logger.debug('AstParser: path aliases', { aliases: Object.keys(pathsMap) });
146
156
  }
147
157
  }
158
+ else {
159
+ this.logger.debug('AstParser: MorphProject created (fallback opts)', { ms: Date.now() - t0 });
160
+ }
148
161
  }
149
162
  catch (err) {
150
163
  this.logger.warn('AstParser: tsconfig load failed, using fallback', {
151
164
  tsconfigPath,
152
165
  reason: err.message,
166
+ ms: Date.now() - t0,
153
167
  });
154
168
  project = new ts_morph_1.Project(FALLBACK_OPTS);
155
169
  }
@@ -161,6 +175,7 @@ class AstParserService {
161
175
  const cached = this.morphProjects.get(projectRoot);
162
176
  this.morphProjects.delete(projectRoot);
163
177
  this.morphProjects.set(projectRoot, cached);
178
+ this.logger.debug('AstParser: getMorphProject cache hit', { projectRoot, cacheSize: this.morphProjects.size });
164
179
  return cached;
165
180
  }
166
181
  if (this.morphProjects.size >= MORPH_CACHE_MAX) {
@@ -169,6 +184,7 @@ class AstParserService {
169
184
  this.morphProjects.delete(lruKey);
170
185
  this.logger.debug('AstParser: evicted LRU project', { lruKey });
171
186
  }
187
+ this.logger.debug('AstParser: getMorphProject cache miss', { projectRoot, cacheSize: this.morphProjects.size });
172
188
  const project = this.createMorphProject(projectRoot);
173
189
  this.morphProjects.set(projectRoot, project);
174
190
  return project;
@@ -337,5 +353,80 @@ class AstParserService {
337
353
  }
338
354
  return symbols;
339
355
  }
356
+ // ── private: call graph extraction ────────────────────────────────────────
357
+ extractCalls(sourceFile, filePath, serviceName) {
358
+ const calls = [];
359
+ for (const callExpr of sourceFile.getDescendantsOfKind(ts_morph_1.SyntaxKind.CallExpression)) {
360
+ const expr = callExpr.getExpression();
361
+ let calleeName = null;
362
+ let calleeFile = null;
363
+ let calleeSymbol = null;
364
+ try {
365
+ if (ts_morph_1.Node.isPropertyAccessExpression(expr)) {
366
+ calleeName = expr.getName();
367
+ const sym = expr.getNameNode().getSymbol();
368
+ const decl = sym?.getDeclarations()?.[0];
369
+ const declPath = decl?.getSourceFile().getFilePath();
370
+ if (declPath && !declPath.includes('/node_modules/') && declPath !== filePath) {
371
+ calleeFile = declPath;
372
+ calleeSymbol = calleeName;
373
+ }
374
+ }
375
+ else if (ts_morph_1.Node.isIdentifier(expr)) {
376
+ calleeName = expr.getText();
377
+ const sym = expr.getSymbol();
378
+ if (sym) {
379
+ // Imported identifiers resolve to ImportSpecifier (same file); follow the alias.
380
+ const firstDecl = sym.getDeclarations()?.[0];
381
+ const resolved = firstDecl && ts_morph_1.Node.isImportSpecifier(firstDecl)
382
+ ? (sym.getAliasedSymbol() ?? sym)
383
+ : sym;
384
+ const decl = resolved.getDeclarations()?.[0];
385
+ const declPath = decl?.getSourceFile().getFilePath();
386
+ if (declPath && !declPath.includes('/node_modules/') && declPath !== filePath) {
387
+ calleeFile = declPath;
388
+ calleeSymbol = calleeName;
389
+ }
390
+ }
391
+ }
392
+ }
393
+ catch {
394
+ // type resolution failures are normal — skip this call
395
+ }
396
+ if (!calleeName || !calleeFile)
397
+ continue;
398
+ const fromSymbol = this.getContainerName(callExpr);
399
+ const pos = sourceFile.compilerNode
400
+ .getLineAndCharacterOfPosition(callExpr.getStart());
401
+ calls.push({
402
+ project: serviceName,
403
+ fromFile: filePath,
404
+ fromSymbol,
405
+ calleeName,
406
+ calleeFile,
407
+ calleeSymbol,
408
+ line: pos.line + 1,
409
+ col: pos.character + 1,
410
+ });
411
+ }
412
+ return calls;
413
+ }
414
+ getContainerName(node) {
415
+ let current = node.getParent();
416
+ while (current) {
417
+ if (ts_morph_1.Node.isMethodDeclaration(current) || ts_morph_1.Node.isFunctionDeclaration(current)) {
418
+ return current.getName() ?? null;
419
+ }
420
+ if (ts_morph_1.Node.isArrowFunction(current) || ts_morph_1.Node.isFunctionExpression(current)) {
421
+ const parent = current.getParent();
422
+ if (ts_morph_1.Node.isVariableDeclaration(parent))
423
+ return parent.getName();
424
+ if (ts_morph_1.Node.isPropertyDeclaration(parent))
425
+ return parent.getName();
426
+ }
427
+ current = current.getParent();
428
+ }
429
+ return null;
430
+ }
340
431
  }
341
432
  exports.AstParserService = AstParserService;
@@ -61,6 +61,7 @@ class DatabaseService {
61
61
  const { host, port, username, password, database } = this.config.db;
62
62
  this.pool = new pg_1.Pool({ host, port, user: username, password, database });
63
63
  this._db = (0, node_postgres_1.drizzle)(this.pool, { schema });
64
+ await this.pool.query('SELECT 1');
64
65
  }
65
66
  async initDbWithSchema() {
66
67
  await this.ensureDatabaseExists();
@@ -141,6 +142,23 @@ class DatabaseService {
141
142
  await this.query(`CREATE INDEX IF NOT EXISTS idx_symbol_name ON code_symbol (name, project)`);
142
143
  await this.query(`CREATE INDEX IF NOT EXISTS idx_symbol_file ON code_symbol (project, "filePath")`);
143
144
  await this.query(`CREATE UNIQUE INDEX IF NOT EXISTS idx_symbol_unique ON code_symbol (project, "filePath", name, kind)`);
145
+ // code_call table
146
+ await this.query(`
147
+ CREATE TABLE IF NOT EXISTS code_call (
148
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
149
+ project TEXT NOT NULL,
150
+ "fromFile" TEXT NOT NULL,
151
+ "fromSymbol" TEXT,
152
+ "calleeName" TEXT NOT NULL,
153
+ "calleeFile" TEXT NOT NULL,
154
+ "calleeSymbol" TEXT,
155
+ line INT,
156
+ col INT
157
+ )
158
+ `);
159
+ await this.query(`CREATE INDEX IF NOT EXISTS idx_call_callee ON code_call (project, "calleeName")`);
160
+ await this.query(`CREATE INDEX IF NOT EXISTS idx_call_callee_file ON code_call (project, "calleeFile")`);
161
+ await this.query(`CREATE INDEX IF NOT EXISTS idx_call_from ON code_call (project, "fromFile")`);
144
162
  }
145
163
  async ensureDatabaseExists() {
146
164
  const { host, port, username, password, database } = this.config.db;
@@ -27,6 +27,12 @@ export declare class IndexerService {
27
27
  skipped: number;
28
28
  failed: number;
29
29
  }>;
30
+ backfillCalls(signal?: AbortSignal): Promise<{
31
+ total: number;
32
+ processed: number;
33
+ skipped: number;
34
+ failed: number;
35
+ }>;
30
36
  discoverProjects(rootDir: string): {
31
37
  name: string;
32
38
  path: string;
@@ -34,6 +40,7 @@ export declare class IndexerService {
34
40
  private writeDepEdges;
35
41
  scanAndIndex(dir: string, serviceName: string, force?: boolean, signal?: AbortSignal, preCollectedFiles?: string[]): Promise<IndexStats>;
36
42
  private getSplitter;
43
+ private static inferRole;
37
44
  private getEnrichedPrompt;
38
45
  private embedText;
39
46
  private static hashContent;