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,514 @@
1
+ /**
2
+ * CTX-to-JSDoc Generator
3
+ *
4
+ * Generates JSDoc blocks from .ctx contract files and injects them
5
+ * into source code. Also supports stripping JSDoc from source.
6
+ *
7
+ * This is a BUILD STEP for IDE IntelliSense support when working
8
+ * in Compact Code Mode (where documentation lives in .ctx files only).
9
+ */
10
+
11
+ import { readFileSync, writeFileSync, readdirSync, statSync, existsSync } from 'fs';
12
+ import { join, extname, relative } from 'path';
13
+ import { parse } from '../vendor/acorn.mjs';
14
+ import { simple as walk } from '../vendor/walk.mjs';
15
+
16
+ const SUPPORTED = new Set(['.js', '.mjs']);
17
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'vendor', '.context', 'dev-docs', '.agent', '.agents']);
18
+
19
+ /**
20
+ * Parse a .ctx file into structured signature data
21
+ * @param {string} ctxContent - Content of .ctx file
22
+ * @returns {{ file: string|null, functions: Array<{name: string, params: string, exported: boolean, description: string}> }}
23
+ */
24
+ export function parseCtxFile(ctxContent) {
25
+ const lines = ctxContent.split('\n');
26
+ const result = { file: null, functions: [] };
27
+
28
+ for (const line of lines) {
29
+ // File header: --- src/workspace.js ---
30
+ const fileMatch = line.match(/^--- (.+) ---$/);
31
+ if (fileMatch) {
32
+ result.file = fileMatch[1];
33
+ continue;
34
+ }
35
+
36
+ // Function signature: [export] name(params)[→ReturnType][→calls]|description
37
+ const funcMatch = line.match(/^(export\s+)?(\w+)\(([^)]*)\)((?:→[^→|]+)*)(?:\|(.*))?$/);
38
+ if (funcMatch) {
39
+ const [, exp, name, params, arrowParts, desc] = funcMatch;
40
+
41
+ // Parse arrow parts: →ReturnType→call1,call2 or just →call1,call2
42
+ let returns = '';
43
+ if (arrowParts) {
44
+ const parts = arrowParts.split('→').filter(Boolean);
45
+ // First part is return type if it doesn't contain commas (calls always have commas or known names)
46
+ // Heuristic: if first part looks like a type (capitalized or has <>), treat as return type
47
+ if (parts.length > 0 && /^[A-Z]|^Promise|^Array|^Object|^string|^number|^boolean|^void|^null/.test(parts[0])) {
48
+ returns = parts[0];
49
+ }
50
+ }
51
+
52
+ // Skip {DESCRIBE} markers
53
+ const description = (desc && desc !== '{DESCRIBE}') ? desc.trim() : '';
54
+ result.functions.push({
55
+ name,
56
+ params: params || '',
57
+ exported: !!exp,
58
+ description,
59
+ returns,
60
+ });
61
+ continue;
62
+ }
63
+
64
+ // Class signature
65
+ const classMatch = line.match(/^class\s+(\w+)/);
66
+ if (classMatch) {
67
+ // Classes tracked but not JSDoc-injected here (complex)
68
+ continue;
69
+ }
70
+ }
71
+
72
+ return result;
73
+ }
74
+
75
+ /**
76
+ * Build a JSDoc block from ctx signature data
77
+ * @param {{ name: string, params: string, exported: boolean, description: string }} funcInfo
78
+ * @returns {string} JSDoc block
79
+ */
80
+ function buildJSDocBlock(funcInfo) {
81
+ const lines = ['/**'];
82
+
83
+ // Description
84
+ if (funcInfo.description) {
85
+ lines.push(` * ${funcInfo.description}`);
86
+ } else {
87
+ lines.push(` * ${funcInfo.name}`);
88
+ }
89
+
90
+ // Parameters
91
+ if (funcInfo.params) {
92
+ const params = funcInfo.params.split(',').map(p => p.trim()).filter(Boolean);
93
+ for (const param of params) {
94
+ // Handle typed params: name:Type
95
+ const typedMatch = param.match(/^(\.\.\.)?(\w+)(?::(\w+(?:<[^>]+>)?))?(=)?$/);
96
+ if (typedMatch) {
97
+ const [, rest, name, type, optional] = typedMatch;
98
+ const paramType = type || '*';
99
+ const prefix = rest || '';
100
+ if (optional) {
101
+ lines.push(` * @param {${paramType}} [${prefix}${name}]`);
102
+ } else {
103
+ lines.push(` * @param {${paramType}} ${prefix}${name}`);
104
+ }
105
+ }
106
+ }
107
+ }
108
+
109
+ // Return type
110
+ if (funcInfo.returns) {
111
+ lines.push(` * @returns {${funcInfo.returns}}`);
112
+ }
113
+ lines.push(' */');
114
+ return lines.join('\n');
115
+ }
116
+
117
+ /**
118
+ * Find .ctx file for a given source file
119
+ * @param {string} sourceFile - Relative path like 'src/workspace.js'
120
+ * @param {string} projectRoot
121
+ * @returns {string|null} Path to .ctx file or null
122
+ */
123
+ function findCtxFile(sourceFile, projectRoot) {
124
+ const base = sourceFile.replace(/\.[^.]+$/, '.ctx');
125
+
126
+ // Check .context/ directory first
127
+ const contextPath = join(projectRoot, '.context', base);
128
+ if (existsSync(contextPath)) return contextPath;
129
+
130
+ // Check colocated
131
+ const colocated = join(projectRoot, base);
132
+ if (existsSync(colocated)) return colocated;
133
+
134
+ return null;
135
+ }
136
+
137
+ /**
138
+ * Inject JSDoc blocks from .ctx files into source code
139
+ * @param {string} dir - Directory to process
140
+ * @param {Object} [options]
141
+ * @param {boolean} [options.dryRun=false] - Preview without writing
142
+ * @returns {{ files: number, injected: number, skipped: number, details: Array }}
143
+ */
144
+ export function injectJSDoc(dir, options = {}) {
145
+ const { dryRun = false } = options;
146
+ const projectRoot = dir;
147
+ const files = walkJSFiles(dir);
148
+ let totalInjected = 0;
149
+ let totalSkipped = 0;
150
+ const details = [];
151
+
152
+ for (const filePath of files) {
153
+ const relPath = relative(projectRoot, filePath);
154
+ const ctxPath = findCtxFile(relPath, projectRoot);
155
+
156
+ if (!ctxPath) {
157
+ totalSkipped++;
158
+ continue;
159
+ }
160
+
161
+ const ctxContent = readFileSync(ctxPath, 'utf-8');
162
+ const ctxData = parseCtxFile(ctxContent);
163
+
164
+ if (ctxData.functions.length === 0) {
165
+ totalSkipped++;
166
+ continue;
167
+ }
168
+
169
+ let source = readFileSync(filePath, 'utf-8');
170
+ let modified = false;
171
+ let injectedCount = 0;
172
+
173
+ // Parse AST to find function locations
174
+ let ast;
175
+ try {
176
+ ast = parse(source, { ecmaVersion: 'latest', sourceType: 'module', locations: true });
177
+ } catch {
178
+ totalSkipped++;
179
+ continue;
180
+ }
181
+
182
+ // Collect insertion points (reverse order to preserve line numbers)
183
+ const insertions = [];
184
+
185
+ // Find the real start position (including export keyword if present)
186
+ function findExportStart(funcNode) {
187
+ // Check if this function is inside an ExportNamedDeclaration
188
+ for (const bodyNode of ast.body) {
189
+ if (bodyNode.type === 'ExportNamedDeclaration' && bodyNode.declaration === funcNode) {
190
+ return bodyNode.start;
191
+ }
192
+ }
193
+ return funcNode.start;
194
+ }
195
+
196
+ walk(ast, {
197
+ FunctionDeclaration(node) {
198
+ if (!node.id) return;
199
+ const funcName = node.id.name;
200
+ const ctxFunc = ctxData.functions.find(f => f.name === funcName);
201
+ if (!ctxFunc) return;
202
+
203
+ const realStart = findExportStart(node);
204
+
205
+ // Check if JSDoc already exists — scan backwards, skipping blank lines
206
+ const textBefore = source.slice(0, realStart).trimEnd();
207
+ if (textBefore.endsWith('*/')) return; // Already has JSDoc
208
+
209
+ const jsdoc = buildJSDocBlock(ctxFunc);
210
+ insertions.push({ position: realStart, jsdoc });
211
+ injectedCount++;
212
+ },
213
+ });
214
+
215
+ // Apply insertions in reverse order
216
+ insertions.sort((a, b) => b.position - a.position);
217
+ for (const { position, jsdoc } of insertions) {
218
+ // Find the line start for proper indentation
219
+ const before = source.slice(0, position);
220
+ const lineStart = before.lastIndexOf('\n') + 1;
221
+ const indent = source.slice(lineStart, position).match(/^(\s*)/)?.[1] || '';
222
+
223
+ const indentedJSDoc = jsdoc.split('\n').map(l => indent + l).join('\n') + '\n';
224
+ source = source.slice(0, position) + indentedJSDoc + source.slice(position);
225
+ modified = true;
226
+ }
227
+
228
+ if (modified && !dryRun) {
229
+ writeFileSync(filePath, source, 'utf-8');
230
+ }
231
+
232
+ if (injectedCount > 0) {
233
+ totalInjected += injectedCount;
234
+ details.push({ file: relPath, injected: injectedCount });
235
+ }
236
+ }
237
+
238
+ return {
239
+ files: files.length,
240
+ injected: totalInjected,
241
+ skipped: totalSkipped,
242
+ dryRun,
243
+ details,
244
+ };
245
+ }
246
+
247
+ /**
248
+ * Strip all JSDoc blocks from source files
249
+ * @param {string} dir - Directory to process
250
+ * @param {Object} [options]
251
+ * @param {boolean} [options.dryRun=false]
252
+ * @returns {{ files: number, stripped: number, savedBytes: number }}
253
+ */
254
+ export function stripJSDoc(dir, options = {}) {
255
+ const { dryRun = false } = options;
256
+ const files = walkJSFiles(dir);
257
+ let totalStripped = 0;
258
+ let savedBytes = 0;
259
+ const details = [];
260
+
261
+ for (const filePath of files) {
262
+ const source = readFileSync(filePath, 'utf-8');
263
+ // Use AST to find JSDoc comment ranges, avoiding false matches inside strings
264
+ const comments = [];
265
+ let parsedOk = false;
266
+ try {
267
+ parse(source, {
268
+ ecmaVersion: 'latest',
269
+ sourceType: 'module',
270
+ onComment: comments,
271
+ });
272
+ parsedOk = true;
273
+ } catch {
274
+ // Fallback to regex for unparseable files
275
+ }
276
+
277
+ let stripped;
278
+ if (parsedOk) {
279
+ // Remove JSDoc comments found by parser (safe — ignores strings)
280
+ const jsdocRanges = comments
281
+ .filter(c => c.type === 'Block' && c.value.startsWith('*'))
282
+ .sort((a, b) => b.start - a.start); // reverse order
283
+
284
+ stripped = source;
285
+ for (const { start, end } of jsdocRanges) {
286
+ // Also remove trailing newline
287
+ let trimEnd = end;
288
+ while (trimEnd < stripped.length && (stripped[trimEnd] === '\n' || stripped[trimEnd] === '\r')) trimEnd++;
289
+ stripped = stripped.slice(0, start) + stripped.slice(trimEnd);
290
+ }
291
+ } else {
292
+ // Regex fallback (less safe but works for broken files)
293
+ stripped = source.replace(/\/\*\*[\s\S]*?\*\/\s*\n?/g, '');
294
+ }
295
+ // Clean up excessive blank lines
296
+ const cleaned = stripped.replace(/\n{3,}/g, '\n\n');
297
+
298
+ const bytesSaved = source.length - cleaned.length;
299
+ if (bytesSaved > 0) {
300
+ totalStripped++;
301
+ savedBytes += bytesSaved;
302
+ details.push({ file: relative(dir, filePath), saved: bytesSaved });
303
+ if (!dryRun) {
304
+ writeFileSync(filePath, cleaned, 'utf-8');
305
+ }
306
+ }
307
+ }
308
+
309
+ return {
310
+ files: files.length,
311
+ stripped: totalStripped,
312
+ savedBytes,
313
+ dryRun,
314
+ details,
315
+ };
316
+ }
317
+
318
+ /**
319
+ * Walk directory for JS files
320
+ * @param {string} dir
321
+ * @returns {string[]}
322
+ */
323
+ function walkJSFiles(dir) {
324
+ const results = [];
325
+ try {
326
+ for (const entry of readdirSync(dir)) {
327
+ if (entry.startsWith('.') && entry !== '.') continue;
328
+ const full = join(dir, entry);
329
+ const stat = statSync(full);
330
+ if (stat.isDirectory()) {
331
+ if (!SKIP_DIRS.has(entry)) {
332
+ results.push(...walkJSFiles(full));
333
+ }
334
+ } else if (SUPPORTED.has(extname(entry).toLowerCase())) {
335
+ results.push(full);
336
+ }
337
+ }
338
+ } catch { /* skip unreadable */ }
339
+ return results;
340
+ }
341
+
342
+ /**
343
+ * Split parameter string at top-level commas only.
344
+ * Respects: {}, <>, () balanced delimiters — won't split inside compound types.
345
+ * Example: "a:string,b:{x:number, y:string}" → ["a:string", "b:{x:number, y:string}"]
346
+ *
347
+ * @param {string} paramStr
348
+ * @returns {string[]}
349
+ */
350
+ function splitTopLevelParams(paramStr) {
351
+ const params = [];
352
+ let depth = 0;
353
+ let current = '';
354
+
355
+ for (const ch of paramStr) {
356
+ if (ch === '{' || ch === '<' || ch === '(') depth++;
357
+ else if (ch === '}' || ch === '>' || ch === ')') depth--;
358
+
359
+ if (ch === ',' && depth === 0) {
360
+ const trimmed = current.trim();
361
+ if (trimmed) params.push(trimmed);
362
+ current = '';
363
+ } else {
364
+ current += ch;
365
+ }
366
+ }
367
+
368
+ const trimmed = current.trim();
369
+ if (trimmed) params.push(trimmed);
370
+ return params;
371
+ }
372
+
373
+ // ============================
374
+ // CTX Contract Validator
375
+ // ============================
376
+
377
+ /**
378
+ * Validate .ctx contracts against actual AST of source files.
379
+ * Zero-dependency alternative to tsc — checks contract consistency.
380
+ *
381
+ * @param {string} dir - Project root directory
382
+ * @param {Object} [options]
383
+ * @param {boolean} [options.strict=false] - Also warn about missing .ctx entries
384
+ * @returns {{ files: number, violations: Array<{file: string, severity: string, message: string}>, summary: {errors: number, warnings: number} }}
385
+ */
386
+ export function validateCtxContracts(dir, options = {}) {
387
+ const strict = options.strict || false;
388
+ const jsFiles = walkJSFiles(dir);
389
+ const violations = [];
390
+ let filesChecked = 0;
391
+
392
+ for (const jsFile of jsFiles) {
393
+ const relPath = relative(dir, jsFile);
394
+ const ctxPath = findCtxFile(relPath, dir);
395
+ if (!ctxPath) continue; // No .ctx — skip
396
+
397
+ filesChecked++;
398
+
399
+ const ctxContent = readFileSync(ctxPath, 'utf-8');
400
+ const ctxData = parseCtxFile(ctxContent);
401
+
402
+ // Parse source AST to get actual signatures
403
+ let source;
404
+ try {
405
+ source = readFileSync(jsFile, 'utf-8');
406
+ } catch { continue; }
407
+
408
+ let ast;
409
+ try {
410
+ ast = parse(source, { ecmaVersion: 'latest', sourceType: 'module', locations: true });
411
+ } catch { continue; }
412
+
413
+ // Extract actual function info from AST
414
+ const astFunctions = new Map();
415
+ walk(ast, {
416
+ FunctionDeclaration(node) {
417
+ if (!node.id) return;
418
+ astFunctions.set(node.id.name, {
419
+ paramCount: node.params.length,
420
+ params: node.params.map(p => {
421
+ if (p.type === 'Identifier') return p.name;
422
+ if (p.type === 'AssignmentPattern' && p.left?.name) return p.left.name;
423
+ if (p.type === 'RestElement' && p.argument?.name) return p.argument.name;
424
+ if (p.type === 'ObjectPattern') return 'options';
425
+ return '?';
426
+ }),
427
+ async: node.async || false,
428
+ line: node.loc.start.line,
429
+ });
430
+ },
431
+ });
432
+
433
+ // Check exported status from AST
434
+ const exportedNames = new Set();
435
+ walk(ast, {
436
+ ExportNamedDeclaration(node) {
437
+ if (node.declaration?.id) exportedNames.add(node.declaration.id.name);
438
+ if (node.specifiers) {
439
+ for (const s of node.specifiers) exportedNames.add(s.exported.name);
440
+ }
441
+ },
442
+ });
443
+
444
+ // Validate each .ctx function against AST
445
+ for (const ctxFunc of ctxData.functions) {
446
+ const astFunc = astFunctions.get(ctxFunc.name);
447
+
448
+ if (!astFunc) {
449
+ violations.push({
450
+ file: relPath,
451
+ severity: 'error',
452
+ message: `Function "${ctxFunc.name}" in .ctx not found in source`,
453
+ });
454
+ continue;
455
+ }
456
+
457
+ // Param count check — balanced split (handles {a: string, b: number} as one param)
458
+ const ctxParams = ctxFunc.params ? splitTopLevelParams(ctxFunc.params) : [];
459
+ if (ctxParams.length !== astFunc.paramCount) {
460
+ violations.push({
461
+ file: relPath,
462
+ severity: 'error',
463
+ message: `"${ctxFunc.name}": .ctx has ${ctxParams.length} params, AST has ${astFunc.paramCount}`,
464
+ });
465
+ }
466
+
467
+ // Param name check (strip types for comparison)
468
+ for (let i = 0; i < Math.min(ctxParams.length, astFunc.params.length); i++) {
469
+ const ctxName = ctxParams[i].replace(/^\.\.\./, '').replace(/:.*/, '').replace(/=$/, '');
470
+ const astName = astFunc.params[i];
471
+ if (ctxName !== astName && ctxName !== '?' && astName !== '?') {
472
+ violations.push({
473
+ file: relPath,
474
+ severity: 'warning',
475
+ message: `"${ctxFunc.name}" param ${i}: .ctx="${ctxName}", AST="${astName}"`,
476
+ });
477
+ }
478
+ }
479
+
480
+ // Export status check
481
+ const astExported = exportedNames.has(ctxFunc.name);
482
+ if (ctxFunc.exported !== astExported) {
483
+ violations.push({
484
+ file: relPath,
485
+ severity: 'warning',
486
+ message: `"${ctxFunc.name}": .ctx says ${ctxFunc.exported ? 'exported' : 'private'}, AST says ${astExported ? 'exported' : 'private'}`,
487
+ });
488
+ }
489
+
490
+ // Remove from astFunctions to track unmatched
491
+ astFunctions.delete(ctxFunc.name);
492
+ }
493
+
494
+ // Strict mode: report functions in AST but not in .ctx
495
+ if (strict && astFunctions.size > 0) {
496
+ for (const [name] of astFunctions) {
497
+ violations.push({
498
+ file: relPath,
499
+ severity: 'info',
500
+ message: `Function "${name}" in source (line ${astFunctions.get(name)?.line}) not documented in .ctx`,
501
+ });
502
+ }
503
+ }
504
+ }
505
+
506
+ const errors = violations.filter(v => v.severity === 'error').length;
507
+ const warnings = violations.filter(v => v.severity === 'warning').length;
508
+
509
+ return {
510
+ files: filesChecked,
511
+ violations,
512
+ summary: { errors, warnings },
513
+ };
514
+ }
@@ -248,6 +248,7 @@ function isWithinContext(lines, lineIndex, contextTag) {
248
248
  * Check file against rule
249
249
  * @param {string} filePath
250
250
  * @param {Rule} rule
251
+ * @param {string} rootDir - Root directory for relative path calculation
251
252
  * @returns {Violation[]}
252
253
  */
253
254
  function checkFileAgainstRule(filePath, rule, rootDir) {