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,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSDoc Consistency Checker (AST-based)
|
|
3
|
+
* Validates JSDoc annotations against actual function signatures
|
|
4
|
+
*
|
|
5
|
+
* Checks:
|
|
6
|
+
* - Param count mismatch (JSDoc vs AST)
|
|
7
|
+
* - Param name mismatch
|
|
8
|
+
* - Missing @returns on functions with return statements
|
|
9
|
+
* - Type hint inconsistency (default value vs JSDoc type)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { readFileSync, readdirSync, statSync } from 'fs';
|
|
13
|
+
import { join, relative, resolve } from 'path';
|
|
14
|
+
import { parse } from '../vendor/acorn.mjs';
|
|
15
|
+
import * as walk from '../vendor/walk.mjs';
|
|
16
|
+
import { shouldExcludeDir, shouldExcludeFile, parseGitignore } from './filters.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {Object} JSDocIssue
|
|
20
|
+
* @property {string} file
|
|
21
|
+
* @property {number} line
|
|
22
|
+
* @property {string} name - Function or method name
|
|
23
|
+
* @property {'error'|'warning'} severity
|
|
24
|
+
* @property {string} message
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Find all JS files in directory
|
|
29
|
+
* @param {string} dir
|
|
30
|
+
* @param {string} rootDir
|
|
31
|
+
* @returns {string[]}
|
|
32
|
+
*/
|
|
33
|
+
function findJSFiles(dir, rootDir = dir) {
|
|
34
|
+
if (dir === rootDir) parseGitignore(rootDir);
|
|
35
|
+
const files = [];
|
|
36
|
+
try {
|
|
37
|
+
for (const entry of readdirSync(dir)) {
|
|
38
|
+
const fullPath = join(dir, entry);
|
|
39
|
+
const relativePath = relative(rootDir, fullPath);
|
|
40
|
+
const stat = statSync(fullPath);
|
|
41
|
+
if (stat.isDirectory()) {
|
|
42
|
+
if (!shouldExcludeDir(entry, relativePath)) {
|
|
43
|
+
files.push(...findJSFiles(fullPath, rootDir));
|
|
44
|
+
}
|
|
45
|
+
} else if (entry.endsWith('.js') && !entry.endsWith('.css.js') && !entry.endsWith('.tpl.js')) {
|
|
46
|
+
if (!shouldExcludeFile(entry, relativePath)) {
|
|
47
|
+
files.push(fullPath);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
} catch (e) { /* dir not found */ }
|
|
52
|
+
return files;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Extract JSDoc comments with positions
|
|
57
|
+
* @param {string} code
|
|
58
|
+
* @returns {Array<{text: string, endLine: number, params: Array<{name: string, type: string}>, hasReturns: boolean}>}
|
|
59
|
+
*/
|
|
60
|
+
function extractJSDocComments(code) {
|
|
61
|
+
const comments = [];
|
|
62
|
+
const regex = /\/\*\*[\s\S]*?\*\//g;
|
|
63
|
+
let match;
|
|
64
|
+
|
|
65
|
+
while ((match = regex.exec(code)) !== null) {
|
|
66
|
+
const text = match[0];
|
|
67
|
+
const endLine = code.slice(0, match.index + text.length).split('\n').length;
|
|
68
|
+
|
|
69
|
+
// Parse @param tags — handle nested braces in types like {Array<{text: string}>}
|
|
70
|
+
const params = [];
|
|
71
|
+
const paramStartRegex = /@param\s+\{/g;
|
|
72
|
+
let paramStart;
|
|
73
|
+
while ((paramStart = paramStartRegex.exec(text)) !== null) {
|
|
74
|
+
// Find matching closing brace (balanced)
|
|
75
|
+
let depth = 1;
|
|
76
|
+
let i = paramStart.index + paramStart[0].length;
|
|
77
|
+
while (i < text.length && depth > 0) {
|
|
78
|
+
if (text[i] === '{') depth++;
|
|
79
|
+
else if (text[i] === '}') depth--;
|
|
80
|
+
i++;
|
|
81
|
+
}
|
|
82
|
+
if (depth !== 0) continue;
|
|
83
|
+
const type = text.slice(paramStart.index + paramStart[0].length, i - 1);
|
|
84
|
+
// Extract param name after the closing brace
|
|
85
|
+
const afterType = text.slice(i);
|
|
86
|
+
const nameMatch = afterType.match(/^\s+(\[?\w+(?:\.\w+)*\]?)/);
|
|
87
|
+
if (!nameMatch) continue;
|
|
88
|
+
let name = nameMatch[1];
|
|
89
|
+
// Strip [] from optional params: [opts] → opts
|
|
90
|
+
if (name.startsWith('[')) name = name.slice(1);
|
|
91
|
+
if (name.endsWith(']')) name = name.slice(0, -1);
|
|
92
|
+
// Strip dotted paths: options.includeTests → skip (nested property)
|
|
93
|
+
if (name.includes('.')) continue;
|
|
94
|
+
params.push({ name, type });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const hasReturns = /@returns?\s/.test(text);
|
|
98
|
+
|
|
99
|
+
comments.push({ text, endLine, params, hasReturns });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return comments;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Find JSDoc comment before a target line
|
|
107
|
+
* @param {Array} comments
|
|
108
|
+
* @param {number} targetLine
|
|
109
|
+
* @returns {Object|null}
|
|
110
|
+
*/
|
|
111
|
+
function findJSDocBefore(comments, targetLine) {
|
|
112
|
+
for (const comment of comments) {
|
|
113
|
+
const gap = targetLine - comment.endLine;
|
|
114
|
+
if (gap >= 0 && gap <= 2) return comment;
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Extract parameter name from AST node
|
|
121
|
+
* @param {Object} param
|
|
122
|
+
* @returns {string}
|
|
123
|
+
*/
|
|
124
|
+
function extractParamName(param) {
|
|
125
|
+
if (param.type === 'Identifier') return param.name;
|
|
126
|
+
if (param.type === 'AssignmentPattern' && param.left.type === 'Identifier') return param.left.name;
|
|
127
|
+
if (param.type === 'RestElement' && param.argument.type === 'Identifier') return param.argument.name;
|
|
128
|
+
if (param.type === 'ObjectPattern') return 'options';
|
|
129
|
+
if (param.type === 'ArrayPattern') return 'args';
|
|
130
|
+
return 'param';
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Infer expected type from AST default value
|
|
135
|
+
* @param {Object} param
|
|
136
|
+
* @returns {string|null}
|
|
137
|
+
*/
|
|
138
|
+
function inferTypeFromDefault(param) {
|
|
139
|
+
if (param.type !== 'AssignmentPattern') return null;
|
|
140
|
+
const def = param.right;
|
|
141
|
+
if (def.type === 'Literal') {
|
|
142
|
+
if (typeof def.value === 'string') return 'string';
|
|
143
|
+
if (typeof def.value === 'number') return 'number';
|
|
144
|
+
if (typeof def.value === 'boolean') return 'boolean';
|
|
145
|
+
}
|
|
146
|
+
if (def.type === 'ArrayExpression') return 'Array';
|
|
147
|
+
if (def.type === 'ObjectExpression') return 'Object';
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Check if function body has return statements with values
|
|
153
|
+
* @param {Object} node - Function AST node
|
|
154
|
+
* @returns {boolean}
|
|
155
|
+
*/
|
|
156
|
+
function hasReturnValue(node) {
|
|
157
|
+
let found = false;
|
|
158
|
+
try {
|
|
159
|
+
walk.simple(node.body, {
|
|
160
|
+
ReturnStatement(ret) {
|
|
161
|
+
if (ret.argument) found = true;
|
|
162
|
+
},
|
|
163
|
+
// Don't recurse into nested functions
|
|
164
|
+
FunctionDeclaration() { },
|
|
165
|
+
FunctionExpression() { },
|
|
166
|
+
ArrowFunctionExpression() { },
|
|
167
|
+
});
|
|
168
|
+
} catch (e) { /* walk error */ }
|
|
169
|
+
return found;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Validate a function's JSDoc against its AST
|
|
174
|
+
* @param {Object} jsdoc - Parsed JSDoc
|
|
175
|
+
* @param {Object[]} astParams - AST param nodes
|
|
176
|
+
* @param {Object} funcNode - AST function node
|
|
177
|
+
* @param {string} name - Function name
|
|
178
|
+
* @param {string} file - File path
|
|
179
|
+
* @param {number} line - Line number
|
|
180
|
+
* @returns {JSDocIssue[]}
|
|
181
|
+
*/
|
|
182
|
+
function validateFunction(jsdoc, astParams, funcNode, name, file, line) {
|
|
183
|
+
const issues = [];
|
|
184
|
+
|
|
185
|
+
if (!jsdoc) return issues; // No JSDoc = handled by undocumented checker
|
|
186
|
+
|
|
187
|
+
const docParams = jsdoc.params;
|
|
188
|
+
|
|
189
|
+
// 1. Param count mismatch
|
|
190
|
+
if (docParams.length !== astParams.length) {
|
|
191
|
+
issues.push({
|
|
192
|
+
file, line, name,
|
|
193
|
+
severity: 'error',
|
|
194
|
+
message: `Param count mismatch: JSDoc has ${docParams.length}, function has ${astParams.length}`,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// 2. Param name mismatch
|
|
199
|
+
const minLen = Math.min(docParams.length, astParams.length);
|
|
200
|
+
for (let i = 0; i < minLen; i++) {
|
|
201
|
+
const docName = docParams[i].name;
|
|
202
|
+
const astName = extractParamName(astParams[i]);
|
|
203
|
+
|
|
204
|
+
if (docName !== astName && astName !== 'options' && astName !== 'args' && astName !== 'param') {
|
|
205
|
+
issues.push({
|
|
206
|
+
file, line, name,
|
|
207
|
+
severity: 'error',
|
|
208
|
+
message: `Param name mismatch at position ${i}: JSDoc says "${docName}", code has "${astName}"`,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// 3. Missing @returns on non-void functions
|
|
214
|
+
if (!jsdoc.hasReturns && hasReturnValue(funcNode)) {
|
|
215
|
+
issues.push({
|
|
216
|
+
file, line, name,
|
|
217
|
+
severity: 'warning',
|
|
218
|
+
message: 'Function returns a value but JSDoc has no @returns',
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// 4. Type hint inconsistency
|
|
223
|
+
for (let i = 0; i < minLen; i++) {
|
|
224
|
+
const docType = docParams[i].type;
|
|
225
|
+
const inferredType = inferTypeFromDefault(astParams[i]);
|
|
226
|
+
|
|
227
|
+
if (inferredType && docType && docType !== '*') {
|
|
228
|
+
let compatible = docType.includes(inferredType);
|
|
229
|
+
// Union types like 'a'|'b' are valid strings
|
|
230
|
+
if (!compatible && inferredType === 'string' && docType.includes("'") && docType.includes('|')) {
|
|
231
|
+
compatible = true;
|
|
232
|
+
}
|
|
233
|
+
// Type[] shorthand is a valid Array
|
|
234
|
+
if (!compatible && inferredType === 'Array' && docType.includes('[]')) {
|
|
235
|
+
compatible = true;
|
|
236
|
+
}
|
|
237
|
+
if (!compatible) {
|
|
238
|
+
issues.push({
|
|
239
|
+
file, line, name,
|
|
240
|
+
severity: 'warning',
|
|
241
|
+
message: `Type mismatch for "${docParams[i].name}": JSDoc says {${docType}}, default value suggests {${inferredType}}`,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return issues;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Check JSDoc consistency for a single file (per-file export for cache integration)
|
|
252
|
+
* @param {string} code
|
|
253
|
+
* @param {string} filePath
|
|
254
|
+
* @returns {JSDocIssue[]}
|
|
255
|
+
*/
|
|
256
|
+
export function checkJSDocFile(code, filePath) {
|
|
257
|
+
const issues = [];
|
|
258
|
+
|
|
259
|
+
let ast;
|
|
260
|
+
try {
|
|
261
|
+
ast = parse(code, { ecmaVersion: 'latest', sourceType: 'module', locations: true });
|
|
262
|
+
} catch (e) {
|
|
263
|
+
return issues;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const comments = extractJSDocComments(code);
|
|
267
|
+
|
|
268
|
+
walk.simple(ast, {
|
|
269
|
+
FunctionDeclaration(node) {
|
|
270
|
+
if (!node.id) return;
|
|
271
|
+
const jsdoc = findJSDocBefore(comments, node.loc.start.line);
|
|
272
|
+
if (jsdoc) {
|
|
273
|
+
issues.push(...validateFunction(jsdoc, node.params, node, node.id.name, filePath, node.loc.start.line));
|
|
274
|
+
}
|
|
275
|
+
},
|
|
276
|
+
|
|
277
|
+
// Exported arrow/const functions
|
|
278
|
+
VariableDeclaration(node) {
|
|
279
|
+
for (const decl of node.declarations) {
|
|
280
|
+
if (!decl.init) continue;
|
|
281
|
+
const func = decl.init.type === 'ArrowFunctionExpression' || decl.init.type === 'FunctionExpression'
|
|
282
|
+
? decl.init : null;
|
|
283
|
+
if (!func || !decl.id?.name) continue;
|
|
284
|
+
|
|
285
|
+
const jsdoc = findJSDocBefore(comments, node.loc.start.line);
|
|
286
|
+
if (jsdoc) {
|
|
287
|
+
issues.push(...validateFunction(jsdoc, func.params, func, decl.id.name, filePath, node.loc.start.line));
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
},
|
|
291
|
+
|
|
292
|
+
ClassDeclaration(node) {
|
|
293
|
+
const className = node.id?.name || 'Anonymous';
|
|
294
|
+
for (const element of node.body.body) {
|
|
295
|
+
if (element.type !== 'MethodDefinition') continue;
|
|
296
|
+
const methodName = element.key.name || element.key.value;
|
|
297
|
+
if (!methodName || methodName === 'constructor') continue;
|
|
298
|
+
if (element.kind !== 'method') continue;
|
|
299
|
+
|
|
300
|
+
const funcNode = element.value;
|
|
301
|
+
const jsdoc = findJSDocBefore(comments, element.loc.start.line);
|
|
302
|
+
if (jsdoc) {
|
|
303
|
+
issues.push(...validateFunction(jsdoc, funcNode.params, funcNode, `${className}.${methodName}`, filePath, element.loc.start.line));
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
},
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
return issues;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Check JSDoc consistency across a directory
|
|
314
|
+
* @param {string} dir
|
|
315
|
+
* @returns {{ issues: JSDocIssue[], summary: { total: number, errors: number, warnings: number, byFile: Object } }}
|
|
316
|
+
*/
|
|
317
|
+
export function checkJSDocConsistency(dir) {
|
|
318
|
+
const resolvedDir = resolve(dir);
|
|
319
|
+
const files = findJSFiles(dir);
|
|
320
|
+
const allIssues = [];
|
|
321
|
+
|
|
322
|
+
for (const file of files) {
|
|
323
|
+
let content;
|
|
324
|
+
try {
|
|
325
|
+
content = readFileSync(file, 'utf-8');
|
|
326
|
+
} catch (e) {
|
|
327
|
+
continue; // File deleted between findJSFiles and read
|
|
328
|
+
}
|
|
329
|
+
const relPath = relative(resolvedDir, file);
|
|
330
|
+
const issues = checkJSDocFile(content, relPath);
|
|
331
|
+
allIssues.push(...issues);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const errors = allIssues.filter(i => i.severity === 'error').length;
|
|
335
|
+
const warnings = allIssues.filter(i => i.severity === 'warning').length;
|
|
336
|
+
|
|
337
|
+
const byFile = {};
|
|
338
|
+
for (const issue of allIssues) {
|
|
339
|
+
byFile[issue.file] = (byFile[issue.file] || 0) + 1;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return {
|
|
343
|
+
issues: allIssues,
|
|
344
|
+
summary: {
|
|
345
|
+
total: allIssues.length,
|
|
346
|
+
errors,
|
|
347
|
+
warnings,
|
|
348
|
+
byFile,
|
|
349
|
+
},
|
|
350
|
+
};
|
|
351
|
+
}
|
package/src/jsdoc-generator.js
CHANGED
|
@@ -22,11 +22,9 @@ import { getWorkspaceRoot } from './workspace.js';
|
|
|
22
22
|
* Generate JSDoc for a single file
|
|
23
23
|
* @param {string} filePath - Absolute path to file
|
|
24
24
|
* @param {Object} [options]
|
|
25
|
-
* @param {boolean} [options.includeTests=true] - Include @test/@expect placeholders
|
|
26
25
|
* @returns {JSDocTemplate[]}
|
|
27
26
|
*/
|
|
28
27
|
export function generateJSDoc(filePath, options = {}) {
|
|
29
|
-
const includeTests = options.includeTests !== false;
|
|
30
28
|
const results = [];
|
|
31
29
|
|
|
32
30
|
const code = readFileSync(filePath, 'utf-8');
|
|
@@ -72,7 +70,6 @@ export function generateJSDoc(filePath, options = {}) {
|
|
|
72
70
|
name: node.id.name,
|
|
73
71
|
params: node.params,
|
|
74
72
|
async: node.async,
|
|
75
|
-
includeTests,
|
|
76
73
|
});
|
|
77
74
|
|
|
78
75
|
results.push({
|
|
@@ -102,7 +99,6 @@ export function generateJSDoc(filePath, options = {}) {
|
|
|
102
99
|
name: methodName,
|
|
103
100
|
params: funcNode.params,
|
|
104
101
|
async: funcNode.async,
|
|
105
|
-
includeTests,
|
|
106
102
|
});
|
|
107
103
|
|
|
108
104
|
results.push({
|
|
@@ -126,7 +122,6 @@ export function generateJSDoc(filePath, options = {}) {
|
|
|
126
122
|
* @param {string} info.name
|
|
127
123
|
* @param {Array} info.params
|
|
128
124
|
* @param {boolean} info.async
|
|
129
|
-
* @param {boolean} info.includeTests
|
|
130
125
|
* @returns {string}
|
|
131
126
|
*/
|
|
132
127
|
function buildJSDoc(info) {
|
|
@@ -145,12 +140,6 @@ function buildJSDoc(info) {
|
|
|
145
140
|
// Return type
|
|
146
141
|
lines.push(` * @returns {${info.async ? 'Promise<*>' : '*'}}`);
|
|
147
142
|
|
|
148
|
-
// Test annotations (Agentic Verification)
|
|
149
|
-
if (info.includeTests) {
|
|
150
|
-
lines.push(` * @test TODO: describe test scenario`);
|
|
151
|
-
lines.push(` * @expect TODO: expected result`);
|
|
152
|
-
}
|
|
153
|
-
|
|
154
143
|
lines.push(' */');
|
|
155
144
|
return lines.join('\n');
|
|
156
145
|
}
|
package/src/lang-sql.js
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQL Language Support for Project Graph
|
|
3
|
+
*
|
|
4
|
+
* Parses .sql files (schema dumps) and extracts SQL queries from code strings.
|
|
5
|
+
* Provides table/column extraction via regex (zero-dependency, ~80% accuracy).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @typedef {Object} TableInfo
|
|
10
|
+
* @property {string} name - Table name
|
|
11
|
+
* @property {{name: string, type: string}[]} columns - Column definitions
|
|
12
|
+
* @property {string} file - Source file
|
|
13
|
+
* @property {number} line - Line number
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @typedef {Object} SQLExtraction
|
|
18
|
+
* @property {string[]} reads - Tables read (SELECT/FROM/JOIN)
|
|
19
|
+
* @property {string[]} writes - Tables written (INSERT/UPDATE/DELETE)
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* SQL keywords that should NOT be treated as table names.
|
|
24
|
+
* Used to filter false positives from regex extraction.
|
|
25
|
+
*/
|
|
26
|
+
const SQL_KEYWORDS = new Set([
|
|
27
|
+
'select', 'from', 'where', 'and', 'or', 'not', 'in', 'on',
|
|
28
|
+
'as', 'join', 'left', 'right', 'inner', 'outer', 'cross', 'full',
|
|
29
|
+
'group', 'order', 'by', 'having', 'limit', 'offset', 'union',
|
|
30
|
+
'all', 'distinct', 'case', 'when', 'then', 'else', 'end',
|
|
31
|
+
'null', 'true', 'false', 'is', 'between', 'like', 'ilike',
|
|
32
|
+
'exists', 'any', 'some', 'set', 'values', 'into', 'table',
|
|
33
|
+
'create', 'alter', 'drop', 'index', 'primary', 'key', 'foreign',
|
|
34
|
+
'references', 'constraint', 'default', 'check', 'unique',
|
|
35
|
+
'if', 'begin', 'commit', 'rollback', 'transaction',
|
|
36
|
+
'returning', 'conflict', 'nothing', 'do', 'update',
|
|
37
|
+
'cascade', 'restrict', 'lateral', 'each', 'row',
|
|
38
|
+
'with', 'recursive', 'only',
|
|
39
|
+
// PostgreSQL data types (prevent false positives)
|
|
40
|
+
'integer', 'int', 'bigint', 'smallint', 'serial', 'bigserial',
|
|
41
|
+
'text', 'varchar', 'char', 'character', 'boolean', 'bool',
|
|
42
|
+
'timestamp', 'timestamptz', 'date', 'time', 'timetz', 'interval',
|
|
43
|
+
'numeric', 'decimal', 'real', 'float', 'double',
|
|
44
|
+
'json', 'jsonb', 'uuid', 'bytea', 'inet', 'cidr', 'macaddr',
|
|
45
|
+
'array', 'point', 'line', 'box', 'circle', 'polygon', 'path',
|
|
46
|
+
// Common false positives
|
|
47
|
+
'count', 'sum', 'avg', 'min', 'max', 'coalesce', 'cast',
|
|
48
|
+
'extract', 'now', 'current_timestamp', 'current_date',
|
|
49
|
+
'generate_series', 'unnest', 'string_agg', 'array_agg',
|
|
50
|
+
'row_number', 'rank', 'dense_rank', 'over', 'partition',
|
|
51
|
+
'asc', 'desc', 'nulls', 'first', 'last', 'filter',
|
|
52
|
+
// PostgreSQL system/meta identifiers
|
|
53
|
+
'columns', 'rows', 'tables', 'schema', 'schemas',
|
|
54
|
+
'information_schema', 'pg_catalog', 'pg_tables', 'pg_class',
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Check if a string looks like a SQL query.
|
|
59
|
+
* Requires SQL keyword anchor at the start to minimize false positives.
|
|
60
|
+
* @param {string} str
|
|
61
|
+
* @returns {boolean}
|
|
62
|
+
*/
|
|
63
|
+
export function isSQLString(str) {
|
|
64
|
+
if (!str || typeof str !== 'string') return false;
|
|
65
|
+
return /^\s*(SELECT|INSERT|UPDATE|DELETE|WITH|CREATE\s+TABLE)\b/i.test(str);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Check if identifier is a valid table name (not a SQL keyword or too short).
|
|
70
|
+
* @param {string} name
|
|
71
|
+
* @returns {boolean}
|
|
72
|
+
*/
|
|
73
|
+
function isValidTableName(name) {
|
|
74
|
+
if (!name || name.length < 2) return false;
|
|
75
|
+
if (SQL_KEYWORDS.has(name.toLowerCase())) return false;
|
|
76
|
+
// Must look like a valid identifier
|
|
77
|
+
if (!/^[a-zA-Z_]\w*$/.test(name)) return false;
|
|
78
|
+
// Reject ALL-UPPERCASE or PascalCase (real PG tables are snake_case/lowercase)
|
|
79
|
+
if (/^[A-Z][A-Z_]*$/.test(name)) return false; // SKIP, API, etc.
|
|
80
|
+
if (/^[A-Z][a-z]/.test(name)) return false; // Job, Organization, etc.
|
|
81
|
+
// Reject PostgreSQL built-in functions/types
|
|
82
|
+
if (/^(pg_|jsonb_|array_|string_|regexp_)/.test(name)) return false;
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Extract table names from a SQL string.
|
|
88
|
+
* Returns lists of tables being read and written.
|
|
89
|
+
* @param {string} sql
|
|
90
|
+
* @returns {SQLExtraction}
|
|
91
|
+
*/
|
|
92
|
+
export function extractSQLFromString(sql) {
|
|
93
|
+
if (!sql || typeof sql !== 'string') {
|
|
94
|
+
return { reads: [], writes: [] };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const reads = new Set();
|
|
98
|
+
const writes = new Set();
|
|
99
|
+
|
|
100
|
+
// Normalize: collapse whitespace, strip comments
|
|
101
|
+
const normalized = sql
|
|
102
|
+
.replace(/--[^\n]*/g, '') // single-line SQL comments
|
|
103
|
+
.replace(/\/\*[\s\S]*?\*\//g, '') // multi-line SQL comments
|
|
104
|
+
.replace(/\s+/g, ' ')
|
|
105
|
+
.trim();
|
|
106
|
+
|
|
107
|
+
// READ patterns
|
|
108
|
+
// FROM table [alias] — skip function calls: FROM func(...)
|
|
109
|
+
const fromRegex = /\bFROM\s+([a-zA-Z_]\w*(?:\.[a-zA-Z_]\w*)?)/gi;
|
|
110
|
+
let match;
|
|
111
|
+
while ((match = fromRegex.exec(normalized)) !== null) {
|
|
112
|
+
// Skip if followed by ( — it's a function call, not a table
|
|
113
|
+
const afterMatch = normalized.slice(match.index + match[0].length).trimStart();
|
|
114
|
+
if (afterMatch.startsWith('(')) continue;
|
|
115
|
+
const name = match[1].split('.').pop(); // handle schema.table
|
|
116
|
+
if (isValidTableName(name)) reads.add(name);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// JOIN table [alias] — same check for safety
|
|
120
|
+
const joinRegex = /\bJOIN\s+([a-zA-Z_]\w*(?:\.[a-zA-Z_]\w*)?)/gi;
|
|
121
|
+
while ((match = joinRegex.exec(normalized)) !== null) {
|
|
122
|
+
const afterMatch = normalized.slice(match.index + match[0].length).trimStart();
|
|
123
|
+
if (afterMatch.startsWith('(')) continue;
|
|
124
|
+
const name = match[1].split('.').pop();
|
|
125
|
+
if (isValidTableName(name)) reads.add(name);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// WRITE patterns
|
|
129
|
+
// INSERT INTO table
|
|
130
|
+
const insertRegex = /\bINSERT\s+INTO\s+([a-zA-Z_]\w*(?:\.[a-zA-Z_]\w*)?)/gi;
|
|
131
|
+
while ((match = insertRegex.exec(normalized)) !== null) {
|
|
132
|
+
const name = match[1].split('.').pop();
|
|
133
|
+
if (isValidTableName(name)) writes.add(name);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// UPDATE table
|
|
137
|
+
const updateRegex = /\bUPDATE\s+([a-zA-Z_]\w*(?:\.[a-zA-Z_]\w*)?)/gi;
|
|
138
|
+
while ((match = updateRegex.exec(normalized)) !== null) {
|
|
139
|
+
const name = match[1].split('.').pop();
|
|
140
|
+
if (isValidTableName(name)) writes.add(name);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// DELETE FROM table
|
|
144
|
+
const deleteRegex = /\bDELETE\s+FROM\s+([a-zA-Z_]\w*(?:\.[a-zA-Z_]\w*)?)/gi;
|
|
145
|
+
while ((match = deleteRegex.exec(normalized)) !== null) {
|
|
146
|
+
const name = match[1].split('.').pop();
|
|
147
|
+
if (isValidTableName(name)) writes.add(name);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Per peer review: remove DELETE primary target from reads to avoid double-counting.
|
|
151
|
+
// Only the primary mutation target is removed — subquery reads are preserved.
|
|
152
|
+
for (const w of writes) {
|
|
153
|
+
if (/\bDELETE\s+FROM\s+/i.test(normalized)) {
|
|
154
|
+
const deleteTargetMatch = normalized.match(/\bDELETE\s+FROM\s+([a-zA-Z_]\w*)/i);
|
|
155
|
+
if (deleteTargetMatch) {
|
|
156
|
+
const primaryTarget = deleteTargetMatch[1].split('.').pop();
|
|
157
|
+
if (w === primaryTarget) reads.delete(primaryTarget);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
reads: [...reads],
|
|
164
|
+
writes: [...writes],
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Parse a .sql file (schema dump / migration).
|
|
170
|
+
* Extracts CREATE TABLE statements with column definitions.
|
|
171
|
+
* @param {string} code - SQL file content
|
|
172
|
+
* @param {string} filename - File path
|
|
173
|
+
* @returns {Object} ParseResult-compatible + tables[]
|
|
174
|
+
*/
|
|
175
|
+
export function parseSQL(code = '', filename = '') {
|
|
176
|
+
const result = {
|
|
177
|
+
file: filename,
|
|
178
|
+
classes: [],
|
|
179
|
+
functions: [],
|
|
180
|
+
imports: [],
|
|
181
|
+
exports: [],
|
|
182
|
+
tables: [],
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
if (!code) return result;
|
|
186
|
+
|
|
187
|
+
// Match CREATE TABLE statements
|
|
188
|
+
// Handles: CREATE TABLE [IF NOT EXISTS] [schema.]name (columns...)
|
|
189
|
+
const createTableRegex = /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:[a-zA-Z_]\w*\.)?([a-zA-Z_]\w*)\s*\(([\s\S]*?)\);/gi;
|
|
190
|
+
let match;
|
|
191
|
+
|
|
192
|
+
while ((match = createTableRegex.exec(code)) !== null) {
|
|
193
|
+
const tableName = match[1];
|
|
194
|
+
const columnsBlock = match[2];
|
|
195
|
+
const line = code.substring(0, match.index).split('\n').length;
|
|
196
|
+
|
|
197
|
+
const columns = parseColumns(columnsBlock);
|
|
198
|
+
|
|
199
|
+
result.tables.push({
|
|
200
|
+
name: tableName,
|
|
201
|
+
columns,
|
|
202
|
+
file: filename,
|
|
203
|
+
line,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return result;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Parse column definitions from CREATE TABLE body.
|
|
212
|
+
* @param {string} block - The content between parentheses
|
|
213
|
+
* @returns {{name: string, type: string}[]}
|
|
214
|
+
*/
|
|
215
|
+
function parseColumns(block) {
|
|
216
|
+
const columns = [];
|
|
217
|
+
// Split by commas, but respect parentheses (for types like NUMERIC(10,2))
|
|
218
|
+
const parts = splitByTopLevelComma(block);
|
|
219
|
+
|
|
220
|
+
for (const part of parts) {
|
|
221
|
+
const trimmed = part.trim();
|
|
222
|
+
// Skip constraints (PRIMARY KEY, FOREIGN KEY, UNIQUE, CHECK, CONSTRAINT)
|
|
223
|
+
if (/^\s*(PRIMARY|FOREIGN|UNIQUE|CHECK|CONSTRAINT|EXCLUDE)\b/i.test(trimmed)) {
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Match: column_name TYPE [constraints...]
|
|
228
|
+
const colMatch = trimmed.match(/^([a-zA-Z_]\w*)\s+([A-Za-z]\w*(?:\s*\([^)]*\))?(?:\s*\[\])?)/);
|
|
229
|
+
if (colMatch) {
|
|
230
|
+
const name = colMatch[1];
|
|
231
|
+
const type = colMatch[2].trim();
|
|
232
|
+
// Skip if "name" is a SQL keyword (misparse)
|
|
233
|
+
if (!SQL_KEYWORDS.has(name.toLowerCase())) {
|
|
234
|
+
columns.push({ name, type });
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return columns;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Split string by commas, respecting parentheses depth.
|
|
244
|
+
* @param {string} str
|
|
245
|
+
* @returns {string[]}
|
|
246
|
+
*/
|
|
247
|
+
function splitByTopLevelComma(str) {
|
|
248
|
+
const parts = [];
|
|
249
|
+
let current = '';
|
|
250
|
+
let depth = 0;
|
|
251
|
+
|
|
252
|
+
for (let i = 0; i < str.length; i++) {
|
|
253
|
+
const ch = str[i];
|
|
254
|
+
if (ch === '(') depth++;
|
|
255
|
+
else if (ch === ')') depth--;
|
|
256
|
+
else if (ch === ',' && depth === 0) {
|
|
257
|
+
parts.push(current);
|
|
258
|
+
current = '';
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
current += ch;
|
|
262
|
+
}
|
|
263
|
+
if (current.trim()) parts.push(current);
|
|
264
|
+
|
|
265
|
+
return parts;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Extract SQL queries from raw source code (Python/Go pre-pass).
|
|
270
|
+
* Scans string literals and finds SQL patterns before stripStringsAndComments
|
|
271
|
+
* destroys the string contents.
|
|
272
|
+
*
|
|
273
|
+
* Returns aggregated reads/writes for the entire file.
|
|
274
|
+
* @param {string} code - Raw source code
|
|
275
|
+
* @returns {SQLExtraction}
|
|
276
|
+
*/
|
|
277
|
+
export function extractSQLFromCode(code) {
|
|
278
|
+
const allReads = new Set();
|
|
279
|
+
const allWrites = new Set();
|
|
280
|
+
|
|
281
|
+
if (!code) return { reads: [], writes: [] };
|
|
282
|
+
|
|
283
|
+
// Find all string literals that look like SQL
|
|
284
|
+
// Match: "...", '...', `...`, """...""", '''...'''
|
|
285
|
+
const stringPatterns = [
|
|
286
|
+
/"""([\s\S]*?)"""/g, // Python triple double-quote
|
|
287
|
+
/'''([\s\S]*?)'''/g, // Python triple single-quote
|
|
288
|
+
/`([\s\S]*?)`/g, // Go/JS backtick
|
|
289
|
+
/"((?:[^"\\]|\\.)*)"/g, // Double-quoted
|
|
290
|
+
/'((?:[^'\\]|\\.)*)'/g, // Single-quoted
|
|
291
|
+
];
|
|
292
|
+
|
|
293
|
+
for (const pattern of stringPatterns) {
|
|
294
|
+
let match;
|
|
295
|
+
while ((match = pattern.exec(code)) !== null) {
|
|
296
|
+
const content = match[1];
|
|
297
|
+
if (isSQLString(content)) {
|
|
298
|
+
const extraction = extractSQLFromString(content);
|
|
299
|
+
extraction.reads.forEach(t => allReads.add(t));
|
|
300
|
+
extraction.writes.forEach(t => allWrites.add(t));
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return {
|
|
306
|
+
reads: [...allReads],
|
|
307
|
+
writes: [...allWrites],
|
|
308
|
+
};
|
|
309
|
+
}
|