project-graph-mcp 1.2.4 → 1.5.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.
package/src/parser.js CHANGED
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * AST Parser for JavaScript files using Acorn
3
- * Extracts classes, functions, methods, properties, imports, and calls
3
+ * Extracts classes, functions, methods, properties, imports, calls, and SQL queries
4
4
  */
5
5
 
6
- import { readFileSync, readdirSync, statSync } from 'fs';
6
+ import { readFileSync, readdirSync, statSync, existsSync } from 'fs';
7
7
  import { join, relative, resolve } from 'path';
8
8
  import { parse } from '../vendor/acorn.mjs';
9
9
  import * as walk from '../vendor/walk.mjs';
@@ -11,9 +11,10 @@ import { shouldExcludeDir, shouldExcludeFile, parseGitignore } from './filters.j
11
11
  import { parseTypeScript } from './lang-typescript.js';
12
12
  import { parsePython } from './lang-python.js';
13
13
  import { parseGo } from './lang-go.js';
14
+ import { parseSQL, extractSQLFromString, isSQLString } from './lang-sql.js';
14
15
 
15
16
  /** Supported source file extensions */
16
- const SOURCE_EXTENSIONS = ['.js', '.ts', '.tsx', '.py', '.go'];
17
+ const SOURCE_EXTENSIONS = ['.js', '.ts', '.tsx', '.py', '.go', '.sql'];
17
18
 
18
19
  /**
19
20
  * @typedef {Object} ClassInfo
@@ -22,6 +23,8 @@ const SOURCE_EXTENSIONS = ['.js', '.ts', '.tsx', '.py', '.go'];
22
23
  * @property {string[]} methods
23
24
  * @property {string[]} properties
24
25
  * @property {string[]} calls
26
+ * @property {string[]} [dbReads] - Tables read by SQL queries
27
+ * @property {string[]} [dbWrites] - Tables written by SQL queries
25
28
  * @property {string} file
26
29
  * @property {number} line
27
30
  */
@@ -31,6 +34,8 @@ const SOURCE_EXTENSIONS = ['.js', '.ts', '.tsx', '.py', '.go'];
31
34
  * @property {string} name
32
35
  * @property {boolean} exported
33
36
  * @property {string[]} calls
37
+ * @property {string[]} [dbReads] - Tables read by SQL queries
38
+ * @property {string[]} [dbWrites] - Tables written by SQL queries
34
39
  * @property {string} file
35
40
  * @property {number} line
36
41
  */
@@ -59,12 +64,15 @@ export async function parseFile(code, filename) {
59
64
  exports: [],
60
65
  };
61
66
 
67
+ // Collect JSDoc comments for type extraction
68
+ const comments = [];
62
69
  let ast;
63
70
  try {
64
71
  ast = parse(code, {
65
72
  ecmaVersion: 'latest',
66
73
  sourceType: 'module',
67
74
  locations: true,
75
+ onComment: comments,
68
76
  });
69
77
  } catch (e) {
70
78
  // If parsing fails, return empty result
@@ -72,6 +80,9 @@ export async function parseFile(code, filename) {
72
80
  return result;
73
81
  }
74
82
 
83
+ // Build JSDoc type map: endLine → { params: [{name, type}], returns: string }
84
+ const jsdocMap = buildJSDocTypeMap(comments, code);
85
+
75
86
  // Track exported names
76
87
  const exportedNames = new Set();
77
88
 
@@ -120,6 +131,8 @@ export async function parseFile(code, filename) {
120
131
  methods: [],
121
132
  properties: [],
122
133
  calls: [],
134
+ dbReads: [],
135
+ dbWrites: [],
123
136
  file: filename,
124
137
  line: node.loc.start.line,
125
138
  };
@@ -129,8 +142,8 @@ export async function parseFile(code, filename) {
129
142
  if (element.type === 'MethodDefinition' && element.key.name !== 'constructor') {
130
143
  classInfo.methods.push(element.key.name);
131
144
 
132
- // Extract calls from method body
133
- extractCalls(element.value.body, classInfo.calls);
145
+ // Extract calls and SQL from method body
146
+ extractCallsAndSQL(element.value.body, classInfo.calls, classInfo.dbReads, classInfo.dbWrites);
134
147
  } else if (element.type === 'PropertyDefinition') {
135
148
  const propName = element.key.name;
136
149
 
@@ -151,15 +164,32 @@ export async function parseFile(code, filename) {
151
164
  // Standalone function declarations
152
165
  FunctionDeclaration(node) {
153
166
  if (node.id) {
167
+ const rawParams = node.params.map(p => {
168
+ if (p.type === 'Identifier') return p.name;
169
+ if (p.type === 'AssignmentPattern' && p.left.type === 'Identifier') return p.left.name + '=';
170
+ if (p.type === 'RestElement' && p.argument.type === 'Identifier') return '...' + p.argument.name;
171
+ if (p.type === 'ObjectPattern') return 'options';
172
+ return '?';
173
+ });
174
+
175
+ // Enrich params with JSDoc types
176
+ const jsdoc = findJSDocForNode(jsdocMap, node.loc.start.line);
177
+ const typedParams = enrichParamsWithTypes(rawParams, jsdoc);
178
+
154
179
  const funcInfo = {
155
180
  name: node.id.name,
156
181
  exported: false, // Will be updated later
182
+ params: typedParams,
183
+ async: node.async || false,
184
+ returns: jsdoc?.returns || null,
157
185
  calls: [],
186
+ dbReads: [],
187
+ dbWrites: [],
158
188
  file: filename,
159
189
  line: node.loc.start.line,
160
190
  };
161
191
 
162
- extractCalls(node.body, funcInfo.calls);
192
+ extractCallsAndSQL(node.body, funcInfo.calls, funcInfo.dbReads, funcInfo.dbWrites);
163
193
  result.functions.push(funcInfo);
164
194
  }
165
195
  },
@@ -176,82 +206,262 @@ export async function parseFile(code, filename) {
176
206
  return result;
177
207
  }
178
208
 
209
+ /** DB client method names that accept SQL as first argument */
210
+ const DB_METHODS = new Set(['query', 'execute', 'raw', 'exec', 'queryFile', 'none', 'one', 'many', 'any', 'oneOrNone', 'manyOrNone', 'result']);
211
+
179
212
  /**
180
- * Extract method calls from AST node
213
+ * Extract method calls AND SQL queries from AST node in a single walk.
214
+ * Combines what was previously two separate walk.simple() calls.
181
215
  * @param {Object} node
182
216
  * @param {string[]} calls
217
+ * @param {string[]} [dbReads]
218
+ * @param {string[]} [dbWrites]
183
219
  */
184
- function extractCalls(node, calls) {
220
+ function extractCallsAndSQL(node, calls, dbReads, dbWrites) {
185
221
  if (!node) return;
186
222
 
187
223
  walk.simple(node, {
188
224
  CallExpression(callNode) {
189
225
  const callee = callNode.callee;
190
226
 
227
+ // === Call extraction ===
191
228
  if (callee.type === 'MemberExpression') {
192
- // obj.method() or this.method()
193
229
  const object = callee.object;
194
230
  const property = callee.property;
195
231
 
196
232
  if (property.type === 'Identifier') {
197
233
  if (object.type === 'Identifier') {
198
- // Class.method() or obj.method()
199
234
  const call = `${object.name}.${property.name}`;
200
- if (!calls.includes(call)) {
201
- calls.push(call);
202
- }
235
+ if (!calls.includes(call)) calls.push(call);
203
236
  } else if (object.type === 'MemberExpression' && object.property.type === 'Identifier') {
204
- // this.obj.method()
205
237
  const call = `${object.property.name}.${property.name}`;
206
- if (!calls.includes(call)) {
207
- calls.push(call);
208
- }
238
+ if (!calls.includes(call)) calls.push(call);
209
239
  } else if (object.type === 'ThisExpression') {
210
- // this.method() - internal call
211
240
  const call = property.name;
212
- if (!calls.includes(call)) {
213
- calls.push(call);
214
- }
241
+ if (!calls.includes(call)) calls.push(call);
215
242
  }
216
243
  }
217
244
  } else if (callee.type === 'Identifier') {
218
- // Direct function call: funcName()
219
245
  const call = callee.name;
220
- if (!calls.includes(call)) {
221
- calls.push(call);
246
+ if (!calls.includes(call)) calls.push(call);
247
+ }
248
+
249
+ // === SQL extraction from DB client calls ===
250
+ if (dbReads && dbWrites) {
251
+ const methodName = getCallMethodName(callNode);
252
+ if (methodName && DB_METHODS.has(methodName) && callNode.arguments.length > 0) {
253
+ const sqlStr = extractStringValue(callNode.arguments[0]);
254
+ if (sqlStr && isSQLString(sqlStr)) {
255
+ const ext = extractSQLFromString(sqlStr);
256
+ ext.reads.forEach(t => { if (!dbReads.includes(t)) dbReads.push(t); });
257
+ ext.writes.forEach(t => { if (!dbWrites.includes(t)) dbWrites.push(t); });
258
+ }
222
259
  }
223
260
  }
224
261
  },
262
+
263
+ // === SQL: Tagged templates ===
264
+ TaggedTemplateExpression(tagNode) {
265
+ if (!dbReads || !dbWrites) return;
266
+ const tagName = getTagName(tagNode.tag);
267
+ if (tagName && /sql/i.test(tagName)) {
268
+ const sqlStr = templateToString(tagNode.quasi);
269
+ if (sqlStr) {
270
+ const ext = extractSQLFromString(sqlStr);
271
+ ext.reads.forEach(t => { if (!dbReads.includes(t)) dbReads.push(t); });
272
+ ext.writes.forEach(t => { if (!dbWrites.includes(t)) dbWrites.push(t); });
273
+ }
274
+ }
275
+ },
276
+
277
+ // === SQL: Standalone template literals ===
278
+ TemplateLiteral(tplNode) {
279
+ if (!dbReads || !dbWrites) return;
280
+ const sqlStr = templateToString(tplNode);
281
+ if (sqlStr && isSQLString(sqlStr)) {
282
+ const ext = extractSQLFromString(sqlStr);
283
+ ext.reads.forEach(t => { if (!dbReads.includes(t)) dbReads.push(t); });
284
+ ext.writes.forEach(t => { if (!dbWrites.includes(t)) dbWrites.push(t); });
285
+ }
286
+ },
287
+
288
+ // === SQL: String literals ===
289
+ Literal(litNode) {
290
+ if (!dbReads || !dbWrites) return;
291
+ if (typeof litNode.value === 'string' && isSQLString(litNode.value)) {
292
+ const ext = extractSQLFromString(litNode.value);
293
+ ext.reads.forEach(t => { if (!dbReads.includes(t)) dbReads.push(t); });
294
+ ext.writes.forEach(t => { if (!dbWrites.includes(t)) dbWrites.push(t); });
295
+ }
296
+ },
225
297
  });
226
298
  }
227
299
 
300
+ /**
301
+ * Get tag name from tagged template expression.
302
+ * Handles: sql`...`, Prisma.sql`...`, db.sql`...`
303
+ * @param {Object} tag - AST node
304
+ * @returns {string|null}
305
+ */
306
+ function getTagName(tag) {
307
+ if (tag.type === 'Identifier') return tag.name;
308
+ if (tag.type === 'MemberExpression' && tag.property.type === 'Identifier') {
309
+ return tag.property.name;
310
+ }
311
+ return null;
312
+ }
313
+
314
+ /**
315
+ * Get method name from a CallExpression callee.
316
+ * @param {Object} callNode
317
+ * @returns {string|null}
318
+ */
319
+ function getCallMethodName(callNode) {
320
+ const callee = callNode.callee;
321
+ if (callee.type === 'MemberExpression' && callee.property.type === 'Identifier') {
322
+ return callee.property.name;
323
+ }
324
+ return null;
325
+ }
326
+
327
+ /**
328
+ * Extract string value from AST node (Literal or TemplateLiteral).
329
+ * For templates with expressions, substitutes $N placeholders.
330
+ * @param {Object} node
331
+ * @returns {string|null}
332
+ */
333
+ function extractStringValue(node) {
334
+ if (!node) return null;
335
+ if (node.type === 'Literal' && typeof node.value === 'string') {
336
+ return node.value;
337
+ }
338
+ if (node.type === 'TemplateLiteral') {
339
+ return templateToString(node);
340
+ }
341
+ return null;
342
+ }
343
+
344
+ /**
345
+ * Convert TemplateLiteral AST node to string.
346
+ * Expressions are replaced with $N placeholders.
347
+ * @param {Object} tplNode
348
+ * @returns {string}
349
+ */
350
+ function templateToString(tplNode) {
351
+ if (!tplNode || !tplNode.quasis) return '';
352
+ let result = '';
353
+ for (let i = 0; i < tplNode.quasis.length; i++) {
354
+ result += tplNode.quasis[i].value.cooked || tplNode.quasis[i].value.raw || '';
355
+ if (i < tplNode.expressions?.length) {
356
+ result += '$' + (i + 1);
357
+ }
358
+ }
359
+ return result;
360
+ }
361
+
362
+ /**
363
+ * Discover sub-projects in a monorepo directory structure
364
+ * @param {string} rootDir
365
+ * @returns {Array<{name: string, path: string, absolutePath: string}>}
366
+ */
367
+ export function discoverSubProjects(rootDir) {
368
+ const resolvedRoot = resolve(rootDir);
369
+ const subProjects = [];
370
+
371
+ // Known monorepo directory conventions
372
+ const MONO_DIRS = ['packages', 'apps', 'services', 'modules', 'libs', 'plugins'];
373
+
374
+ for (const monoDir of MONO_DIRS) {
375
+ const monoPath = join(resolvedRoot, monoDir);
376
+ if (!existsSync(monoPath)) continue;
377
+
378
+ try {
379
+ for (const entry of readdirSync(monoPath)) {
380
+ const entryPath = join(monoPath, entry);
381
+ const pkgPath = join(entryPath, 'package.json');
382
+ if (statSync(entryPath).isDirectory() && existsSync(pkgPath)) {
383
+ try {
384
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
385
+ subProjects.push({
386
+ name: pkg.name || entry,
387
+ path: relative(resolvedRoot, entryPath),
388
+ absolutePath: entryPath,
389
+ });
390
+ } catch {
391
+ subProjects.push({ name: entry, path: relative(resolvedRoot, entryPath), absolutePath: entryPath });
392
+ }
393
+ }
394
+ }
395
+ } catch { /* dir not readable */ }
396
+ }
397
+
398
+ return subProjects;
399
+ }
400
+
228
401
  /**
229
402
  * Parse all JS files in a directory
230
403
  * @param {string} dir
404
+ * @param {Object} [options={}]
405
+ * @param {boolean} [options.recursive=false]
231
406
  * @returns {Promise<ParseResult>}
232
407
  */
233
- export async function parseProject(dir) {
408
+ export async function parseProject(dir, options = {}) {
234
409
  const result = {
235
410
  files: [],
236
411
  classes: [],
237
412
  functions: [],
238
413
  imports: [],
239
414
  exports: [],
415
+ tables: [],
240
416
  };
241
417
 
242
418
  const resolvedDir = resolve(dir);
243
419
  const files = findJSFiles(dir);
244
420
 
245
421
  for (const file of files) {
246
- const content = readFileSync(file, 'utf-8');
247
- const relPath = relative(resolvedDir, file);
248
- const parsed = await parseFileByExtension(content, relPath);
249
-
250
- result.files.push(relPath);
251
- result.classes.push(...parsed.classes);
252
- result.functions.push(...parsed.functions);
253
- result.imports.push(...parsed.imports);
254
- result.exports.push(...parsed.exports);
422
+ try {
423
+ const content = readFileSync(file, 'utf-8');
424
+ const relPath = relative(resolvedDir, file);
425
+ const parsed = await parseFileByExtension(content, relPath);
426
+
427
+ result.files.push(relPath);
428
+ result.classes.push(...parsed.classes);
429
+ result.functions.push(...parsed.functions);
430
+ result.imports.push(...parsed.imports);
431
+ result.exports.push(...parsed.exports);
432
+ if (parsed.tables?.length) {
433
+ result.tables.push(...parsed.tables);
434
+ }
435
+ } catch (e) {
436
+ // Ignore unreadable files
437
+ }
438
+ }
439
+
440
+ // Recursive monorepo support
441
+ if (options.recursive) {
442
+ const subs = discoverSubProjects(dir);
443
+ result.subProjects = [];
444
+ for (const sub of subs) {
445
+ try {
446
+ const subResult = await parseProject(sub.absolutePath);
447
+ // Prefix all file paths with sub-project path
448
+ for (const f of subResult.files) {
449
+ result.files.push(join(sub.path, f));
450
+ }
451
+ for (const c of subResult.classes) {
452
+ c.file = join(sub.path, c.file);
453
+ result.classes.push(c);
454
+ }
455
+ for (const fn of subResult.functions) {
456
+ fn.file = join(sub.path, fn.file);
457
+ result.functions.push(fn);
458
+ }
459
+ result.imports.push(...subResult.imports);
460
+ result.exports.push(...subResult.exports);
461
+ if (subResult.tables?.length) result.tables.push(...subResult.tables);
462
+ result.subProjects.push({ name: sub.name, path: sub.path, files: subResult.files.length });
463
+ } catch { /* sub-project parse failure is non-fatal */ }
464
+ }
255
465
  }
256
466
 
257
467
  // Dedupe imports/exports
@@ -268,6 +478,9 @@ export async function parseProject(dir) {
268
478
  * @returns {Promise<ParseResult>}
269
479
  */
270
480
  async function parseFileByExtension(code, filename) {
481
+ if (filename.endsWith('.sql')) {
482
+ return parseSQL(code, filename);
483
+ }
271
484
  if (filename.endsWith('.py')) {
272
485
  return parsePython(code, filename);
273
486
  }
@@ -330,3 +543,120 @@ export function findJSFiles(dir, rootDir = dir) {
330
543
 
331
544
  return files;
332
545
  }
546
+
547
+ // ============================
548
+ // JSDoc Type Extraction
549
+ // ============================
550
+
551
+ /**
552
+ * Build a map of JSDoc comment end-lines to their extracted type info.
553
+ * @param {Array} comments - Acorn onComment array
554
+ * @param {string} code - Full source code
555
+ * @returns {Map<number, {params: Array<{name: string, type: string}>, returns: string|null}>}
556
+ */
557
+ function buildJSDocTypeMap(comments, code) {
558
+ const map = new Map();
559
+
560
+ for (const comment of comments) {
561
+ // Only process JSDoc blocks (/** ... */)
562
+ if (comment.type !== 'Block' || !comment.value.startsWith('*')) continue;
563
+
564
+ const text = '/*' + comment.value + '*/';
565
+ const endLine = code.slice(0, comment.end).split('\n').length;
566
+
567
+ // Parse @param tags with balanced brace matching
568
+ const params = [];
569
+ const paramStartRegex = /@param\s+\{/g;
570
+ let paramStart;
571
+ while ((paramStart = paramStartRegex.exec(text)) !== null) {
572
+ // Find matching closing brace (balanced — handles {Array<{text: string}>})
573
+ let depth = 1;
574
+ let i = paramStart.index + paramStart[0].length;
575
+ while (i < text.length && depth > 0) {
576
+ if (text[i] === '{') depth++;
577
+ else if (text[i] === '}') depth--;
578
+ i++;
579
+ }
580
+ if (depth !== 0) continue;
581
+ const type = text.slice(paramStart.index + paramStart[0].length, i - 1);
582
+ // Extract param name after the closing brace
583
+ const afterType = text.slice(i);
584
+ const nameMatch = afterType.match(/^\s+(\[?\w+(?:\.\w+)*\]?)/);
585
+ if (!nameMatch) continue;
586
+ let name = nameMatch[1];
587
+ // Strip [] from optional params: [opts] → opts
588
+ if (name.startsWith('[')) name = name.slice(1);
589
+ if (name.endsWith(']')) name = name.slice(0, -1);
590
+ // Skip dotted paths (options.x)
591
+ if (name.includes('.')) continue;
592
+ params.push({ name, type });
593
+ }
594
+
595
+ // Parse @returns {Type}
596
+ let returns = null;
597
+ const returnsMatch = text.match(/@returns?\s+\{([^}]+)\}/);
598
+ if (returnsMatch) {
599
+ returns = returnsMatch[1];
600
+ }
601
+
602
+ if (params.length > 0 || returns) {
603
+ map.set(endLine, { params, returns });
604
+ }
605
+ }
606
+
607
+ return map;
608
+ }
609
+
610
+ /**
611
+ * Find the JSDoc entry that applies to a function at the given line.
612
+ * JSDoc must end within 2 lines above the function declaration.
613
+ * @param {Map} jsdocMap
614
+ * @param {number} funcLine - Function start line
615
+ * @returns {{ params: Array<{name: string, type: string}>, returns: string|null }|null}
616
+ */
617
+ function findJSDocForNode(jsdocMap, funcLine) {
618
+ // JSDoc can end 1 or 2 lines above (direct or with blank line)
619
+ for (let offset = 1; offset <= 3; offset++) {
620
+ const entry = jsdocMap.get(funcLine - offset);
621
+ if (entry) return entry;
622
+ }
623
+ return null;
624
+ }
625
+
626
+ /**
627
+ * Enrich AST-extracted param names with types from JSDoc.
628
+ * Input: ['filePath', 'options='] + jsdoc.params: [{name:'filePath', type:'string'}, {name:'options', type:'Object'}]
629
+ * Output: ['filePath:string', 'options:Object=']
630
+ * @param {string[]} rawParams
631
+ * @param {Object|null} jsdoc
632
+ * @returns {string[]}
633
+ */
634
+ function enrichParamsWithTypes(rawParams, jsdoc) {
635
+ if (!jsdoc || jsdoc.params.length === 0) return rawParams;
636
+
637
+ // Build name→type lookup from JSDoc
638
+ const typeMap = new Map();
639
+ for (const p of jsdoc.params) {
640
+ typeMap.set(p.name, p.type);
641
+ }
642
+
643
+ return rawParams.map(param => {
644
+ // Parse: '...name', 'name=', 'name', 'options'
645
+ const isRest = param.startsWith('...');
646
+ const hasDefault = param.endsWith('=');
647
+ let cleanName = param;
648
+ if (isRest) cleanName = cleanName.slice(3);
649
+ if (hasDefault) cleanName = cleanName.slice(0, -1);
650
+
651
+ let type = typeMap.get(cleanName);
652
+ if (!type) return param; // No JSDoc type found
653
+
654
+ // Strip JSDoc rest indicator {...Type} — rest is already from AST
655
+ if (type.startsWith('...')) type = type.slice(3);
656
+
657
+ // Reconstruct: ...name:Type, name:Type=, name:Type
658
+ const prefix = isRest ? '...' : '';
659
+ const suffix = hasDefault ? '=' : '';
660
+ return `${prefix}${cleanName}:${type}${suffix}`;
661
+ });
662
+ }
@@ -63,6 +63,7 @@ function findJSFiles(dir, rootDir = dir) {
63
63
  /**
64
64
  * Extract function signatures from a file
65
65
  * @param {string} filePath
66
+ * @param {string} rootDir - Root directory for relative path calculation
66
67
  * @returns {FunctionSignature[]}
67
68
  */
68
69
  function extractSignatures(filePath, rootDir) {