@optave/codegraph 2.1.1-dev.3c12b64 → 2.2.0

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.
@@ -170,7 +170,10 @@ export function extractRubySymbols(tree, _filePath) {
170
170
  }
171
171
  }
172
172
  } else {
173
- calls.push({ name: methodNode.text, line: node.startPosition.row + 1 });
173
+ const recv = node.childForFieldName('receiver');
174
+ const call = { name: methodNode.text, line: node.startPosition.row + 1 };
175
+ if (recv) call.receiver = recv.text;
176
+ calls.push(call);
174
177
  }
175
178
  }
176
179
  break;
@@ -135,10 +135,20 @@ export function extractRustSymbols(tree, _filePath) {
135
135
  calls.push({ name: fn.text, line: node.startPosition.row + 1 });
136
136
  } else if (fn.type === 'field_expression') {
137
137
  const field = fn.childForFieldName('field');
138
- if (field) calls.push({ name: field.text, line: node.startPosition.row + 1 });
138
+ if (field) {
139
+ const value = fn.childForFieldName('value');
140
+ const call = { name: field.text, line: node.startPosition.row + 1 };
141
+ if (value) call.receiver = value.text;
142
+ calls.push(call);
143
+ }
139
144
  } else if (fn.type === 'scoped_identifier') {
140
145
  const name = fn.childForFieldName('name');
141
- if (name) calls.push({ name: name.text, line: node.startPosition.row + 1 });
146
+ if (name) {
147
+ const path = fn.childForFieldName('path');
148
+ const call = { name: name.text, line: node.startPosition.row + 1 };
149
+ if (path) call.receiver = path.text;
150
+ calls.push(call);
151
+ }
142
152
  }
143
153
  }
144
154
  break;
package/src/index.js CHANGED
@@ -38,7 +38,12 @@ export { isNativeAvailable } from './native.js';
38
38
  export { getActiveEngine, parseFileAuto, parseFilesAuto } from './parser.js';
39
39
  // Query functions (data-returning)
40
40
  export {
41
+ ALL_SYMBOL_KINDS,
42
+ contextData,
41
43
  diffImpactData,
44
+ explainData,
45
+ FALSE_POSITIVE_CALLER_THRESHOLD,
46
+ FALSE_POSITIVE_NAMES,
42
47
  fileDepsData,
43
48
  fnDepsData,
44
49
  fnImpactData,
@@ -46,6 +51,7 @@ export {
46
51
  moduleMapData,
47
52
  queryNameData,
48
53
  statsData,
54
+ whereData,
49
55
  } from './queries.js';
50
56
  // Registry (multi-repo)
51
57
  export {
package/src/journal.js ADDED
@@ -0,0 +1,109 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { debug, warn } from './logger.js';
4
+
5
+ export const JOURNAL_FILENAME = 'changes.journal';
6
+ const HEADER_PREFIX = '# codegraph-journal v1 ';
7
+
8
+ /**
9
+ * Read and validate the change journal.
10
+ * Returns { valid, timestamp, changed[], removed[] } or { valid: false }.
11
+ */
12
+ export function readJournal(rootDir) {
13
+ const journalPath = path.join(rootDir, '.codegraph', JOURNAL_FILENAME);
14
+ let content;
15
+ try {
16
+ content = fs.readFileSync(journalPath, 'utf-8');
17
+ } catch {
18
+ return { valid: false };
19
+ }
20
+
21
+ const lines = content.split('\n');
22
+ if (lines.length === 0 || !lines[0].startsWith(HEADER_PREFIX)) {
23
+ debug('Journal has malformed or missing header');
24
+ return { valid: false };
25
+ }
26
+
27
+ const timestamp = Number(lines[0].slice(HEADER_PREFIX.length).trim());
28
+ if (!Number.isFinite(timestamp) || timestamp <= 0) {
29
+ debug('Journal has invalid timestamp');
30
+ return { valid: false };
31
+ }
32
+
33
+ const changed = [];
34
+ const removed = [];
35
+ const seenChanged = new Set();
36
+ const seenRemoved = new Set();
37
+
38
+ for (let i = 1; i < lines.length; i++) {
39
+ const line = lines[i].trim();
40
+ if (!line || line.startsWith('#')) continue;
41
+
42
+ if (line.startsWith('DELETED ')) {
43
+ const filePath = line.slice(8);
44
+ if (filePath && !seenRemoved.has(filePath)) {
45
+ seenRemoved.add(filePath);
46
+ removed.push(filePath);
47
+ }
48
+ } else {
49
+ if (!seenChanged.has(line)) {
50
+ seenChanged.add(line);
51
+ changed.push(line);
52
+ }
53
+ }
54
+ }
55
+
56
+ return { valid: true, timestamp, changed, removed };
57
+ }
58
+
59
+ /**
60
+ * Append changed/deleted paths to the journal.
61
+ * Creates the journal with a header if it doesn't exist.
62
+ */
63
+ export function appendJournalEntries(rootDir, entries) {
64
+ const dir = path.join(rootDir, '.codegraph');
65
+ const journalPath = path.join(dir, JOURNAL_FILENAME);
66
+
67
+ if (!fs.existsSync(dir)) {
68
+ fs.mkdirSync(dir, { recursive: true });
69
+ }
70
+
71
+ // If journal doesn't exist, create with a placeholder header
72
+ if (!fs.existsSync(journalPath)) {
73
+ fs.writeFileSync(journalPath, `${HEADER_PREFIX}0\n`);
74
+ }
75
+
76
+ const lines = entries.map((e) => {
77
+ if (e.deleted) return `DELETED ${e.file}`;
78
+ return e.file;
79
+ });
80
+
81
+ fs.appendFileSync(journalPath, `${lines.join('\n')}\n`);
82
+ }
83
+
84
+ /**
85
+ * Write a fresh journal header after a successful build.
86
+ * Atomic: write to temp file then rename.
87
+ */
88
+ export function writeJournalHeader(rootDir, timestamp) {
89
+ const dir = path.join(rootDir, '.codegraph');
90
+ const journalPath = path.join(dir, JOURNAL_FILENAME);
91
+ const tmpPath = `${journalPath}.tmp`;
92
+
93
+ if (!fs.existsSync(dir)) {
94
+ fs.mkdirSync(dir, { recursive: true });
95
+ }
96
+
97
+ try {
98
+ fs.writeFileSync(tmpPath, `${HEADER_PREFIX}${timestamp}\n`);
99
+ fs.renameSync(tmpPath, journalPath);
100
+ } catch (err) {
101
+ warn(`Failed to write journal header: ${err.message}`);
102
+ // Clean up temp file if rename failed
103
+ try {
104
+ fs.unlinkSync(tmpPath);
105
+ } catch {
106
+ /* ignore */
107
+ }
108
+ }
109
+ }
package/src/mcp.js CHANGED
@@ -8,6 +8,7 @@
8
8
  import { createRequire } from 'node:module';
9
9
  import { findCycles } from './cycles.js';
10
10
  import { findDbPath } from './db.js';
11
+ import { ALL_SYMBOL_KINDS } from './queries.js';
11
12
 
12
13
  const REPO_PROP = {
13
14
  repo: {
@@ -29,6 +30,7 @@ const BASE_TOOLS = [
29
30
  description: 'Traversal depth for transitive callers',
30
31
  default: 2,
31
32
  },
33
+ no_tests: { type: 'boolean', description: 'Exclude test files', default: false },
32
34
  },
33
35
  required: ['name'],
34
36
  },
@@ -40,6 +42,7 @@ const BASE_TOOLS = [
40
42
  type: 'object',
41
43
  properties: {
42
44
  file: { type: 'string', description: 'File path (partial match supported)' },
45
+ no_tests: { type: 'boolean', description: 'Exclude test files', default: false },
43
46
  },
44
47
  required: ['file'],
45
48
  },
@@ -51,6 +54,7 @@ const BASE_TOOLS = [
51
54
  type: 'object',
52
55
  properties: {
53
56
  file: { type: 'string', description: 'File path to analyze' },
57
+ no_tests: { type: 'boolean', description: 'Exclude test files', default: false },
54
58
  },
55
59
  required: ['file'],
56
60
  },
@@ -70,6 +74,7 @@ const BASE_TOOLS = [
70
74
  type: 'object',
71
75
  properties: {
72
76
  limit: { type: 'number', description: 'Number of top files to show', default: 20 },
77
+ no_tests: { type: 'boolean', description: 'Exclude test files', default: false },
73
78
  },
74
79
  },
75
80
  },
@@ -81,6 +86,15 @@ const BASE_TOOLS = [
81
86
  properties: {
82
87
  name: { type: 'string', description: 'Function/method/class name (partial match)' },
83
88
  depth: { type: 'number', description: 'Transitive caller depth', default: 3 },
89
+ file: {
90
+ type: 'string',
91
+ description: 'Scope search to functions in this file (partial match)',
92
+ },
93
+ kind: {
94
+ type: 'string',
95
+ enum: ALL_SYMBOL_KINDS,
96
+ description: 'Filter to a specific symbol kind',
97
+ },
84
98
  no_tests: { type: 'boolean', description: 'Exclude test files', default: false },
85
99
  },
86
100
  required: ['name'],
@@ -95,11 +109,88 @@ const BASE_TOOLS = [
95
109
  properties: {
96
110
  name: { type: 'string', description: 'Function/method/class name (partial match)' },
97
111
  depth: { type: 'number', description: 'Max traversal depth', default: 5 },
112
+ file: {
113
+ type: 'string',
114
+ description: 'Scope search to functions in this file (partial match)',
115
+ },
116
+ kind: {
117
+ type: 'string',
118
+ enum: ALL_SYMBOL_KINDS,
119
+ description: 'Filter to a specific symbol kind',
120
+ },
121
+ no_tests: { type: 'boolean', description: 'Exclude test files', default: false },
122
+ },
123
+ required: ['name'],
124
+ },
125
+ },
126
+ {
127
+ name: 'context',
128
+ description:
129
+ 'Full context for a function: source code, dependencies with summaries, callers, signature, and related tests — everything needed to understand or modify a function in one call',
130
+ inputSchema: {
131
+ type: 'object',
132
+ properties: {
133
+ name: { type: 'string', description: 'Function/method/class name (partial match)' },
134
+ depth: {
135
+ type: 'number',
136
+ description: 'Include callee source up to N levels deep (0=no source, 1=direct)',
137
+ default: 0,
138
+ },
139
+ file: {
140
+ type: 'string',
141
+ description: 'Scope search to functions in this file (partial match)',
142
+ },
143
+ kind: {
144
+ type: 'string',
145
+ enum: ALL_SYMBOL_KINDS,
146
+ description: 'Filter to a specific symbol kind',
147
+ },
148
+ no_source: {
149
+ type: 'boolean',
150
+ description: 'Skip source extraction (metadata only)',
151
+ default: false,
152
+ },
98
153
  no_tests: { type: 'boolean', description: 'Exclude test files', default: false },
154
+ include_tests: {
155
+ type: 'boolean',
156
+ description: 'Include test file source code',
157
+ default: false,
158
+ },
99
159
  },
100
160
  required: ['name'],
101
161
  },
102
162
  },
163
+ {
164
+ name: 'explain',
165
+ description:
166
+ 'Structural summary of a file or function: public/internal API, data flow, dependencies. No LLM needed.',
167
+ inputSchema: {
168
+ type: 'object',
169
+ properties: {
170
+ target: { type: 'string', description: 'File path or function name' },
171
+ no_tests: { type: 'boolean', description: 'Exclude test files', default: false },
172
+ },
173
+ required: ['target'],
174
+ },
175
+ },
176
+ {
177
+ name: 'where',
178
+ description:
179
+ 'Find where a symbol is defined and used, or list symbols/imports/exports for a file. Minimal, fast lookup.',
180
+ inputSchema: {
181
+ type: 'object',
182
+ properties: {
183
+ target: { type: 'string', description: 'Symbol name or file path' },
184
+ file_mode: {
185
+ type: 'boolean',
186
+ description: 'Treat target as file path (list symbols/imports/exports)',
187
+ default: false,
188
+ },
189
+ no_tests: { type: 'boolean', description: 'Exclude test files', default: false },
190
+ },
191
+ required: ['target'],
192
+ },
193
+ },
103
194
  {
104
195
  name: 'diff_impact',
105
196
  description: 'Analyze git diff to find which functions changed and their transitive callers',
@@ -195,6 +286,7 @@ const BASE_TOOLS = [
195
286
  description: 'Rank files or directories',
196
287
  },
197
288
  limit: { type: 'number', description: 'Number of results to return', default: 10 },
289
+ no_tests: { type: 'boolean', description: 'Exclude test files', default: false },
198
290
  },
199
291
  },
200
292
  },
@@ -245,12 +337,15 @@ export { TOOLS, buildToolList };
245
337
  export async function startMCPServer(customDbPath, options = {}) {
246
338
  const { allowedRepos } = options;
247
339
  const multiRepo = options.multiRepo || !!allowedRepos;
248
- let Server, StdioServerTransport;
340
+ let Server, StdioServerTransport, ListToolsRequestSchema, CallToolRequestSchema;
249
341
  try {
250
342
  const sdk = await import('@modelcontextprotocol/sdk/server/index.js');
251
343
  Server = sdk.Server;
252
344
  const transport = await import('@modelcontextprotocol/sdk/server/stdio.js');
253
345
  StdioServerTransport = transport.StdioServerTransport;
346
+ const types = await import('@modelcontextprotocol/sdk/types.js');
347
+ ListToolsRequestSchema = types.ListToolsRequestSchema;
348
+ CallToolRequestSchema = types.CallToolRequestSchema;
254
349
  } catch {
255
350
  console.error(
256
351
  'MCP server requires @modelcontextprotocol/sdk.\n' +
@@ -267,6 +362,9 @@ export async function startMCPServer(customDbPath, options = {}) {
267
362
  fileDepsData,
268
363
  fnDepsData,
269
364
  fnImpactData,
365
+ contextData,
366
+ explainData,
367
+ whereData,
270
368
  diffImpactData,
271
369
  listFunctionsData,
272
370
  } = await import('./queries.js');
@@ -279,9 +377,11 @@ export async function startMCPServer(customDbPath, options = {}) {
279
377
  { capabilities: { tools: {} } },
280
378
  );
281
379
 
282
- server.setRequestHandler('tools/list', async () => ({ tools: buildToolList(multiRepo) }));
380
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
381
+ tools: buildToolList(multiRepo),
382
+ }));
283
383
 
284
- server.setRequestHandler('tools/call', async (request) => {
384
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
285
385
  const { name, arguments: args } = request.params;
286
386
 
287
387
  try {
@@ -313,13 +413,13 @@ export async function startMCPServer(customDbPath, options = {}) {
313
413
  let result;
314
414
  switch (name) {
315
415
  case 'query_function':
316
- result = queryNameData(args.name, dbPath);
416
+ result = queryNameData(args.name, dbPath, { noTests: args.no_tests });
317
417
  break;
318
418
  case 'file_deps':
319
- result = fileDepsData(args.file, dbPath);
419
+ result = fileDepsData(args.file, dbPath, { noTests: args.no_tests });
320
420
  break;
321
421
  case 'impact_analysis':
322
- result = impactAnalysisData(args.file, dbPath);
422
+ result = impactAnalysisData(args.file, dbPath, { noTests: args.no_tests });
323
423
  break;
324
424
  case 'find_cycles': {
325
425
  const db = new Database(findDbPath(dbPath), { readonly: true });
@@ -329,17 +429,40 @@ export async function startMCPServer(customDbPath, options = {}) {
329
429
  break;
330
430
  }
331
431
  case 'module_map':
332
- result = moduleMapData(dbPath, args.limit || 20);
432
+ result = moduleMapData(dbPath, args.limit || 20, { noTests: args.no_tests });
333
433
  break;
334
434
  case 'fn_deps':
335
435
  result = fnDepsData(args.name, dbPath, {
336
436
  depth: args.depth,
437
+ file: args.file,
438
+ kind: args.kind,
337
439
  noTests: args.no_tests,
338
440
  });
339
441
  break;
340
442
  case 'fn_impact':
341
443
  result = fnImpactData(args.name, dbPath, {
342
444
  depth: args.depth,
445
+ file: args.file,
446
+ kind: args.kind,
447
+ noTests: args.no_tests,
448
+ });
449
+ break;
450
+ case 'context':
451
+ result = contextData(args.name, dbPath, {
452
+ depth: args.depth,
453
+ file: args.file,
454
+ kind: args.kind,
455
+ noSource: args.no_source,
456
+ noTests: args.no_tests,
457
+ includeTests: args.include_tests,
458
+ });
459
+ break;
460
+ case 'explain':
461
+ result = explainData(args.target, dbPath, { noTests: args.no_tests });
462
+ break;
463
+ case 'where':
464
+ result = whereData(args.target, dbPath, {
465
+ file: args.file_mode,
343
466
  noTests: args.no_tests,
344
467
  });
345
468
  break;
@@ -418,6 +541,7 @@ export async function startMCPServer(customDbPath, options = {}) {
418
541
  metric: args.metric,
419
542
  level: args.level,
420
543
  limit: args.limit,
544
+ noTests: args.no_tests,
421
545
  });
422
546
  break;
423
547
  }
package/src/parser.js CHANGED
@@ -101,6 +101,7 @@ function normalizeNativeSymbols(result) {
101
101
  name: c.name,
102
102
  line: c.line,
103
103
  dynamic: c.dynamic,
104
+ receiver: c.receiver,
104
105
  })),
105
106
  imports: (result.imports || []).map((i) => ({
106
107
  source: i.source,