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.
- package/AGENT_ROLE.md +87 -30
- package/AGENT_ROLE_MINIMAL.md +23 -8
- package/README.md +100 -14
- package/package.json +1 -1
- package/src/.project-graph-cache.json +1 -0
- 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/doc-dialect.js +716 -0
- package/src/full-analysis.js +307 -11
- package/src/instructions.js +3 -105
- package/src/jsdoc-checker.js +351 -0
- package/src/jsdoc-generator.js +0 -11
- package/src/large-files.js +1 -0
- package/src/mcp-server.js +208 -1
- package/src/mode-config.js +127 -0
- package/src/outdated-patterns.js +1 -0
- package/src/parser.js +223 -13
- package/src/similar-functions.js +1 -0
- package/src/test-annotations.js +203 -181
- package/src/tool-defs.js +270 -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) {
|