project-graph-mcp 1.3.0 → 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.
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Compact Code Mode Configuration
3
+ *
4
+ * Manages project-level mode selection for the compact code architecture.
5
+ * Reads/writes `.context/config.json` to configure how agents interact with code.
6
+ *
7
+ * Modes:
8
+ * 1 = Native Compact: code stored minified, agent edits directly
9
+ * 2 = Full Storage: code stored formatted, agent reads compressed view, edits via edit_compressed
10
+ * 3 = Future (IDE): compact storage with IDE virtual display (reserved)
11
+ */
12
+
13
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
14
+ import { join, dirname } from 'path';
15
+
16
+ const CONFIG_FILE = '.context/config.json';
17
+
18
+ /** Default configuration */
19
+ const DEFAULTS = {
20
+ mode: 2,
21
+ beautify: true,
22
+ autoValidate: false,
23
+ stripJSDoc: false,
24
+ };
25
+
26
+ /**
27
+ * Read project mode configuration from .context/config.json
28
+ * Returns defaults if file doesn't exist.
29
+ *
30
+ * @param {string} projectDir - Project root directory
31
+ * @returns {{ mode: number, beautify: boolean, autoValidate: boolean, stripJSDoc: boolean }}
32
+ */
33
+ export function getConfig(projectDir) {
34
+ const configPath = join(projectDir, CONFIG_FILE);
35
+
36
+ if (!existsSync(configPath)) {
37
+ return { ...DEFAULTS };
38
+ }
39
+
40
+ try {
41
+ const raw = readFileSync(configPath, 'utf-8');
42
+ const parsed = JSON.parse(raw);
43
+ return { ...DEFAULTS, ...parsed };
44
+ } catch {
45
+ return { ...DEFAULTS };
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Write project mode configuration to .context/config.json
51
+ *
52
+ * @param {string} projectDir - Project root directory
53
+ * @param {Object} config - Configuration to save (merged with existing)
54
+ * @returns {{ saved: boolean, path: string, config: Object }}
55
+ */
56
+ export function setConfig(projectDir, config) {
57
+ const configPath = join(projectDir, CONFIG_FILE);
58
+ const dir = dirname(configPath);
59
+
60
+ if (!existsSync(dir)) {
61
+ mkdirSync(dir, { recursive: true });
62
+ }
63
+
64
+ // Merge with existing
65
+ const existing = getConfig(projectDir);
66
+ const merged = { ...existing, ...config };
67
+
68
+ // Validate mode
69
+ if (![1, 2, 3].includes(merged.mode)) {
70
+ throw new Error(`Invalid mode: ${merged.mode}. Valid: 1 (compact), 2 (full), 3 (IDE)`);
71
+ }
72
+
73
+ writeFileSync(configPath, JSON.stringify(merged, null, 2) + '\n', 'utf-8');
74
+
75
+ return {
76
+ saved: true,
77
+ path: configPath,
78
+ config: merged,
79
+ };
80
+ }
81
+
82
+ /**
83
+ * Get human-readable description of current mode
84
+ * @param {number} mode
85
+ * @returns {string}
86
+ */
87
+ export function getModeDescription(mode) {
88
+ switch (mode) {
89
+ case 1: return 'Native Compact — code stored minified, agent edits directly';
90
+ case 2: return 'Full Storage — code stored formatted, agent uses get_compressed_file + edit_compressed';
91
+ case 3: return 'IDE Virtual — compact storage with IDE virtual display (future)';
92
+ default: return `Unknown mode: ${mode}`;
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Get recommended workflow for current mode
98
+ * @param {number} mode
99
+ * @returns {{ read: string, edit: string, docs: string, validate: string }}
100
+ */
101
+ export function getModeWorkflow(mode) {
102
+ switch (mode) {
103
+ case 1:
104
+ return {
105
+ read: 'Read .js files directly (already compact)',
106
+ edit: 'Edit .js files directly',
107
+ docs: 'Read .ctx files for types and descriptions',
108
+ validate: 'Run validate-ctx to check .ctx ↔ AST consistency',
109
+ };
110
+ case 2:
111
+ return {
112
+ read: 'Use get_compressed_file for token-efficient reading',
113
+ edit: 'Use edit_compressed(path, symbol, code) for AST-safe editing',
114
+ docs: 'Read .ctx files for types and descriptions',
115
+ validate: 'Run validate-ctx to check .ctx ↔ AST consistency',
116
+ };
117
+ case 3:
118
+ return {
119
+ read: 'IDE renders full view from compact storage',
120
+ edit: 'IDE handles bidirectional mapping',
121
+ docs: 'Managed by IDE plugin',
122
+ validate: 'Automatic via IDE integration',
123
+ };
124
+ default:
125
+ return { read: 'N/A', edit: 'N/A', docs: 'N/A', validate: 'N/A' };
126
+ }
127
+ }
@@ -152,6 +152,7 @@ function findJSFiles(dir, rootDir = dir) {
152
152
  /**
153
153
  * Analyze file for outdated patterns
154
154
  * @param {string} filePath
155
+ * @param {string} rootDir - Root directory for relative path calculation
155
156
  * @returns {PatternMatch[]}
156
157
  */
157
158
  function analyzeFilePatterns(filePath, rootDir) {
package/src/parser.js CHANGED
@@ -3,7 +3,7 @@
3
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';
@@ -64,12 +64,15 @@ export async function parseFile(code, filename) {
64
64
  exports: [],
65
65
  };
66
66
 
67
+ // Collect JSDoc comments for type extraction
68
+ const comments = [];
67
69
  let ast;
68
70
  try {
69
71
  ast = parse(code, {
70
72
  ecmaVersion: 'latest',
71
73
  sourceType: 'module',
72
74
  locations: true,
75
+ onComment: comments,
73
76
  });
74
77
  } catch (e) {
75
78
  // If parsing fails, return empty result
@@ -77,6 +80,9 @@ export async function parseFile(code, filename) {
77
80
  return result;
78
81
  }
79
82
 
83
+ // Build JSDoc type map: endLine → { params: [{name, type}], returns: string }
84
+ const jsdocMap = buildJSDocTypeMap(comments, code);
85
+
80
86
  // Track exported names
81
87
  const exportedNames = new Set();
82
88
 
@@ -158,9 +164,24 @@ export async function parseFile(code, filename) {
158
164
  // Standalone function declarations
159
165
  FunctionDeclaration(node) {
160
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
+
161
179
  const funcInfo = {
162
180
  name: node.id.name,
163
181
  exported: false, // Will be updated later
182
+ params: typedParams,
183
+ async: node.async || false,
184
+ returns: jsdoc?.returns || null,
164
185
  calls: [],
165
186
  dbReads: [],
166
187
  dbWrites: [],
@@ -338,12 +359,53 @@ function templateToString(tplNode) {
338
359
  return result;
339
360
  }
340
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
+
341
401
  /**
342
402
  * Parse all JS files in a directory
343
403
  * @param {string} dir
404
+ * @param {Object} [options={}]
405
+ * @param {boolean} [options.recursive=false]
344
406
  * @returns {Promise<ParseResult>}
345
407
  */
346
- export async function parseProject(dir) {
408
+ export async function parseProject(dir, options = {}) {
347
409
  const result = {
348
410
  files: [],
349
411
  classes: [],
@@ -357,17 +419,48 @@ export async function parseProject(dir) {
357
419
  const files = findJSFiles(dir);
358
420
 
359
421
  for (const file of files) {
360
- const content = readFileSync(file, 'utf-8');
361
- const relPath = relative(resolvedDir, file);
362
- const parsed = await parseFileByExtension(content, relPath);
363
-
364
- result.files.push(relPath);
365
- result.classes.push(...parsed.classes);
366
- result.functions.push(...parsed.functions);
367
- result.imports.push(...parsed.imports);
368
- result.exports.push(...parsed.exports);
369
- if (parsed.tables?.length) {
370
- result.tables.push(...parsed.tables);
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 */ }
371
464
  }
372
465
  }
373
466
 
@@ -450,3 +543,120 @@ export function findJSFiles(dir, rootDir = dir) {
450
543
 
451
544
  return files;
452
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) {