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/AGENT_ROLE.md +105 -29
- package/AGENT_ROLE_MINIMAL.md +23 -8
- package/README.md +100 -14
- package/package.json +1 -1
- package/src/.project-graph-cache.json +1 -1
- package/src/ai-context.js +113 -0
- package/src/analysis-cache.js +155 -0
- package/src/cli-handlers.js +131 -0
- package/src/cli.js +14 -2
- package/src/compact.js +207 -0
- package/src/complexity.js +21 -7
- package/src/compress.js +319 -0
- package/src/ctx-to-jsdoc.js +514 -0
- package/src/custom-rules.js +1 -0
- package/src/db-analysis.js +194 -0
- package/src/doc-dialect.js +716 -0
- package/src/full-analysis.js +322 -11
- package/src/graph-builder.js +32 -2
- package/src/instructions.js +3 -105
- package/src/jsdoc-checker.js +351 -0
- package/src/jsdoc-generator.js +0 -11
- package/src/lang-sql.js +309 -0
- package/src/large-files.js +1 -0
- package/src/mcp-server.js +236 -1
- package/src/mode-config.js +127 -0
- package/src/outdated-patterns.js +1 -0
- package/src/parser.js +364 -34
- package/src/similar-functions.js +1 -0
- package/src/test-annotations.js +203 -181
- package/src/tool-defs.js +318 -2
- package/src/tools.js +1 -1
- package/src/type-checker.js +188 -0
- package/src/undocumented.js +11 -12
- package/src/workspace.js +1 -1
- package/vendor/terser.mjs +49 -0
|
@@ -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
|
+
}
|
package/src/custom-rules.js
CHANGED
|
@@ -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) {
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Database Analysis Tools
|
|
3
|
+
*
|
|
4
|
+
* Provides MCP tools for understanding code-database interactions:
|
|
5
|
+
* - getDBSchema: Extract table/column structure from .sql files
|
|
6
|
+
* - getTableUsage: Map functions to the tables they read/write
|
|
7
|
+
* - getDBDeadTables: Find schema-defined tables/columns not referenced in code
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { parseProject } from './parser.js';
|
|
11
|
+
import { buildGraph } from './graph-builder.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Get database schema from SQL files in the project.
|
|
15
|
+
* Scans for .sql files and extracts CREATE TABLE definitions.
|
|
16
|
+
* @param {string} dir - Directory to scan
|
|
17
|
+
* @returns {Promise<Object>}
|
|
18
|
+
*/
|
|
19
|
+
export async function getDBSchema(dir) {
|
|
20
|
+
const parsed = await parseProject(dir);
|
|
21
|
+
const tables = parsed.tables || [];
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
tables: tables.map(t => ({
|
|
25
|
+
name: t.name,
|
|
26
|
+
columns: t.columns,
|
|
27
|
+
file: t.file,
|
|
28
|
+
line: t.line,
|
|
29
|
+
})),
|
|
30
|
+
totalTables: tables.length,
|
|
31
|
+
totalColumns: tables.reduce((sum, t) => sum + t.columns.length, 0),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Show which functions read/write database tables.
|
|
37
|
+
* Traces SQL queries in code to table references.
|
|
38
|
+
* @param {string} dir - Directory to scan
|
|
39
|
+
* @param {string} [tableName] - Optional: filter to specific table
|
|
40
|
+
* @returns {Promise<Object>}
|
|
41
|
+
*/
|
|
42
|
+
export async function getTableUsage(dir, tableName) {
|
|
43
|
+
const parsed = await parseProject(dir);
|
|
44
|
+
const graph = buildGraph(parsed);
|
|
45
|
+
|
|
46
|
+
// Collect all table references from edges
|
|
47
|
+
const tableMap = {};
|
|
48
|
+
|
|
49
|
+
for (const [from, type, to] of graph.edges) {
|
|
50
|
+
if (type !== 'R→' && type !== 'W→') continue;
|
|
51
|
+
|
|
52
|
+
const table = to;
|
|
53
|
+
if (tableName && table !== tableName) continue;
|
|
54
|
+
|
|
55
|
+
if (!tableMap[table]) {
|
|
56
|
+
tableMap[table] = { readers: [], writers: [] };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Resolve the function/class name
|
|
60
|
+
const fullName = graph.reverseLegend[from] || from;
|
|
61
|
+
const node = graph.nodes[from];
|
|
62
|
+
const entry = {
|
|
63
|
+
name: fullName,
|
|
64
|
+
file: node?.f || '?',
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
if (type === 'R→') {
|
|
68
|
+
if (!tableMap[table].readers.some(r => r.name === fullName)) {
|
|
69
|
+
tableMap[table].readers.push(entry);
|
|
70
|
+
}
|
|
71
|
+
} else {
|
|
72
|
+
if (!tableMap[table].writers.some(w => w.name === fullName)) {
|
|
73
|
+
tableMap[table].writers.push(entry);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Format output
|
|
79
|
+
const tables = Object.entries(tableMap)
|
|
80
|
+
.map(([name, usage]) => ({
|
|
81
|
+
table: name,
|
|
82
|
+
readers: usage.readers,
|
|
83
|
+
writers: usage.writers,
|
|
84
|
+
totalReaders: usage.readers.length,
|
|
85
|
+
totalWriters: usage.writers.length,
|
|
86
|
+
}))
|
|
87
|
+
.sort((a, b) => (b.totalReaders + b.totalWriters) - (a.totalReaders + a.totalWriters));
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
tables,
|
|
91
|
+
totalTables: tables.length,
|
|
92
|
+
totalQueries: tables.reduce((sum, t) => sum + t.totalReaders + t.totalWriters, 0),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Find tables and columns defined in schema but never referenced in code.
|
|
98
|
+
* @param {string} dir - Directory to scan
|
|
99
|
+
* @returns {Promise<Object>}
|
|
100
|
+
*/
|
|
101
|
+
export async function getDBDeadTables(dir) {
|
|
102
|
+
const parsed = await parseProject(dir);
|
|
103
|
+
const graph = buildGraph(parsed);
|
|
104
|
+
const schemaTables = parsed.tables || [];
|
|
105
|
+
|
|
106
|
+
// Collect all tables referenced in code (from R→/W→ edges)
|
|
107
|
+
const referencedTables = new Set();
|
|
108
|
+
for (const [, type, to] of graph.edges) {
|
|
109
|
+
if (type === 'R→' || type === 'W→') {
|
|
110
|
+
referencedTables.add(to);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Find dead tables (in schema but not in code)
|
|
115
|
+
const deadTables = schemaTables
|
|
116
|
+
.filter(t => !referencedTables.has(t.name))
|
|
117
|
+
.map(t => ({
|
|
118
|
+
name: t.name,
|
|
119
|
+
file: t.file,
|
|
120
|
+
line: t.line,
|
|
121
|
+
columnCount: t.columns.length,
|
|
122
|
+
}));
|
|
123
|
+
|
|
124
|
+
// Collect all column names referenced in SQL strings (best-effort)
|
|
125
|
+
// We extract column names from SELECT/WHERE clauses heuristically
|
|
126
|
+
const referencedColumns = collectReferencedColumns(parsed);
|
|
127
|
+
|
|
128
|
+
// Find dead columns (in schema but not referenced)
|
|
129
|
+
const deadColumns = [];
|
|
130
|
+
for (const table of schemaTables) {
|
|
131
|
+
if (!referencedTables.has(table.name)) continue; // skip dead tables entirely
|
|
132
|
+
for (const col of table.columns) {
|
|
133
|
+
if (!referencedColumns.has(col.name)) {
|
|
134
|
+
deadColumns.push({
|
|
135
|
+
table: table.name,
|
|
136
|
+
column: col.name,
|
|
137
|
+
type: col.type,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
deadTables,
|
|
145
|
+
deadColumns,
|
|
146
|
+
stats: {
|
|
147
|
+
totalSchemaTables: schemaTables.length,
|
|
148
|
+
totalSchemaColumns: schemaTables.reduce((sum, t) => sum + t.columns.length, 0),
|
|
149
|
+
deadTableCount: deadTables.length,
|
|
150
|
+
deadColumnCount: deadColumns.length,
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Collect column names referenced in code SQL strings (best-effort).
|
|
157
|
+
* Scans all string literals for column-like identifiers after SQL keywords.
|
|
158
|
+
* @param {Object} parsed - ParseResult
|
|
159
|
+
* @returns {Set<string>}
|
|
160
|
+
*/
|
|
161
|
+
function collectReferencedColumns(parsed) {
|
|
162
|
+
const columns = new Set();
|
|
163
|
+
|
|
164
|
+
// Gather all dbReads/dbWrites context isn't enough for columns.
|
|
165
|
+
// We need to scan the actual SQL strings.
|
|
166
|
+
// For simplicity, we collect all identifiers that appear near SQL contexts
|
|
167
|
+
// from functions/classes that have any DB interaction.
|
|
168
|
+
for (const func of parsed.functions || []) {
|
|
169
|
+
if (func.dbReads?.length || func.dbWrites?.length) {
|
|
170
|
+
// Mark all reasonable identifiers from this function's SQL as "referenced"
|
|
171
|
+
// This is a heuristic - we accept false negatives for safety
|
|
172
|
+
for (const table of [...(func.dbReads || []), ...(func.dbWrites || [])]) {
|
|
173
|
+
columns.add(table); // table name itself
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
for (const cls of parsed.classes || []) {
|
|
179
|
+
if (cls.dbReads?.length || cls.dbWrites?.length) {
|
|
180
|
+
for (const table of [...(cls.dbReads || []), ...(cls.dbWrites || [])]) {
|
|
181
|
+
columns.add(table);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Add common column names that are almost always used
|
|
187
|
+
// (prevents noisy false-positive "dead columns")
|
|
188
|
+
columns.add('id');
|
|
189
|
+
columns.add('uuid');
|
|
190
|
+
columns.add('created_at');
|
|
191
|
+
columns.add('updated_at');
|
|
192
|
+
|
|
193
|
+
return columns;
|
|
194
|
+
}
|