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.
@@ -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
+ }
@@ -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
  }
@@ -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
+ }