@optave/codegraph 3.1.0 → 3.1.1

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.
Files changed (47) hide show
  1. package/README.md +5 -5
  2. package/grammars/tree-sitter-go.wasm +0 -0
  3. package/package.json +8 -9
  4. package/src/ast-analysis/rules/csharp.js +201 -0
  5. package/src/ast-analysis/rules/go.js +182 -0
  6. package/src/ast-analysis/rules/index.js +82 -0
  7. package/src/ast-analysis/rules/java.js +175 -0
  8. package/src/ast-analysis/rules/javascript.js +246 -0
  9. package/src/ast-analysis/rules/php.js +219 -0
  10. package/src/ast-analysis/rules/python.js +196 -0
  11. package/src/ast-analysis/rules/ruby.js +204 -0
  12. package/src/ast-analysis/rules/rust.js +173 -0
  13. package/src/ast-analysis/shared.js +223 -0
  14. package/src/ast.js +15 -28
  15. package/src/audit.js +4 -5
  16. package/src/boundaries.js +1 -1
  17. package/src/branch-compare.js +84 -79
  18. package/src/builder.js +0 -5
  19. package/src/cfg.js +106 -338
  20. package/src/check.js +3 -3
  21. package/src/cli.js +99 -179
  22. package/src/cochange.js +1 -1
  23. package/src/communities.js +13 -16
  24. package/src/complexity.js +196 -1239
  25. package/src/cycles.js +1 -1
  26. package/src/dataflow.js +269 -694
  27. package/src/db/connection.js +88 -0
  28. package/src/db/migrations.js +312 -0
  29. package/src/db/query-builder.js +280 -0
  30. package/src/db/repository.js +134 -0
  31. package/src/db.js +19 -399
  32. package/src/embedder.js +145 -141
  33. package/src/export.js +1 -1
  34. package/src/flow.js +161 -162
  35. package/src/index.js +34 -1
  36. package/src/kinds.js +49 -0
  37. package/src/manifesto.js +3 -8
  38. package/src/mcp.js +37 -20
  39. package/src/owners.js +132 -132
  40. package/src/queries-cli.js +866 -0
  41. package/src/queries.js +1323 -2267
  42. package/src/result-formatter.js +21 -0
  43. package/src/sequence.js +177 -182
  44. package/src/structure.js +200 -199
  45. package/src/test-filter.js +7 -0
  46. package/src/triage.js +120 -162
  47. package/src/viewer.js +1 -1
@@ -0,0 +1,88 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import Database from 'better-sqlite3';
4
+ import { warn } from '../logger.js';
5
+
6
+ function isProcessAlive(pid) {
7
+ try {
8
+ process.kill(pid, 0);
9
+ return true;
10
+ } catch {
11
+ return false;
12
+ }
13
+ }
14
+
15
+ function acquireAdvisoryLock(dbPath) {
16
+ const lockPath = `${dbPath}.lock`;
17
+ try {
18
+ if (fs.existsSync(lockPath)) {
19
+ const content = fs.readFileSync(lockPath, 'utf-8').trim();
20
+ const pid = Number(content);
21
+ if (pid && pid !== process.pid && isProcessAlive(pid)) {
22
+ warn(`Another process (PID ${pid}) may be using this database. Proceeding with caution.`);
23
+ }
24
+ }
25
+ } catch {
26
+ /* ignore read errors */
27
+ }
28
+ try {
29
+ fs.writeFileSync(lockPath, String(process.pid), 'utf-8');
30
+ } catch {
31
+ /* best-effort */
32
+ }
33
+ }
34
+
35
+ function releaseAdvisoryLock(lockPath) {
36
+ try {
37
+ const content = fs.readFileSync(lockPath, 'utf-8').trim();
38
+ if (Number(content) === process.pid) {
39
+ fs.unlinkSync(lockPath);
40
+ }
41
+ } catch {
42
+ /* ignore */
43
+ }
44
+ }
45
+
46
+ export function openDb(dbPath) {
47
+ const dir = path.dirname(dbPath);
48
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
49
+ acquireAdvisoryLock(dbPath);
50
+ const db = new Database(dbPath);
51
+ db.pragma('journal_mode = WAL');
52
+ db.pragma('busy_timeout = 5000');
53
+ db.__lockPath = `${dbPath}.lock`;
54
+ return db;
55
+ }
56
+
57
+ export function closeDb(db) {
58
+ db.close();
59
+ if (db.__lockPath) releaseAdvisoryLock(db.__lockPath);
60
+ }
61
+
62
+ export function findDbPath(customPath) {
63
+ if (customPath) return path.resolve(customPath);
64
+ let dir = process.cwd();
65
+ while (true) {
66
+ const candidate = path.join(dir, '.codegraph', 'graph.db');
67
+ if (fs.existsSync(candidate)) return candidate;
68
+ const parent = path.dirname(dir);
69
+ if (parent === dir) break;
70
+ dir = parent;
71
+ }
72
+ return path.join(process.cwd(), '.codegraph', 'graph.db');
73
+ }
74
+
75
+ /**
76
+ * Open a database in readonly mode, with a user-friendly error if the DB doesn't exist.
77
+ */
78
+ export function openReadonlyOrFail(customPath) {
79
+ const dbPath = findDbPath(customPath);
80
+ if (!fs.existsSync(dbPath)) {
81
+ console.error(
82
+ `No codegraph database found at ${dbPath}.\n` +
83
+ `Run "codegraph build" first to analyze your codebase.`,
84
+ );
85
+ process.exit(1);
86
+ }
87
+ return new Database(dbPath, { readonly: true });
88
+ }
@@ -0,0 +1,312 @@
1
+ import { debug } from '../logger.js';
2
+
3
+ // ─── Schema Migrations ─────────────────────────────────────────────────
4
+ export const MIGRATIONS = [
5
+ {
6
+ version: 1,
7
+ up: `
8
+ CREATE TABLE IF NOT EXISTS nodes (
9
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
10
+ name TEXT NOT NULL,
11
+ kind TEXT NOT NULL,
12
+ file TEXT NOT NULL,
13
+ line INTEGER,
14
+ end_line INTEGER,
15
+ UNIQUE(name, kind, file, line)
16
+ );
17
+ CREATE TABLE IF NOT EXISTS edges (
18
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
19
+ source_id INTEGER NOT NULL,
20
+ target_id INTEGER NOT NULL,
21
+ kind TEXT NOT NULL,
22
+ confidence REAL DEFAULT 1.0,
23
+ dynamic INTEGER DEFAULT 0,
24
+ FOREIGN KEY(source_id) REFERENCES nodes(id),
25
+ FOREIGN KEY(target_id) REFERENCES nodes(id)
26
+ );
27
+ CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
28
+ CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file);
29
+ CREATE INDEX IF NOT EXISTS idx_nodes_kind ON nodes(kind);
30
+ CREATE INDEX IF NOT EXISTS idx_edges_source ON edges(source_id);
31
+ CREATE INDEX IF NOT EXISTS idx_edges_target ON edges(target_id);
32
+ CREATE INDEX IF NOT EXISTS idx_edges_kind ON edges(kind);
33
+ CREATE TABLE IF NOT EXISTS node_metrics (
34
+ node_id INTEGER PRIMARY KEY,
35
+ line_count INTEGER,
36
+ symbol_count INTEGER,
37
+ import_count INTEGER,
38
+ export_count INTEGER,
39
+ fan_in INTEGER,
40
+ fan_out INTEGER,
41
+ cohesion REAL,
42
+ file_count INTEGER,
43
+ FOREIGN KEY(node_id) REFERENCES nodes(id)
44
+ );
45
+ CREATE INDEX IF NOT EXISTS idx_node_metrics_node ON node_metrics(node_id);
46
+ `,
47
+ },
48
+ {
49
+ version: 2,
50
+ up: `
51
+ CREATE INDEX IF NOT EXISTS idx_nodes_name_kind_file ON nodes(name, kind, file);
52
+ CREATE INDEX IF NOT EXISTS idx_nodes_file_kind ON nodes(file, kind);
53
+ CREATE INDEX IF NOT EXISTS idx_edges_source_kind ON edges(source_id, kind);
54
+ CREATE INDEX IF NOT EXISTS idx_edges_target_kind ON edges(target_id, kind);
55
+ `,
56
+ },
57
+ {
58
+ version: 3,
59
+ up: `
60
+ CREATE TABLE IF NOT EXISTS file_hashes (
61
+ file TEXT PRIMARY KEY,
62
+ hash TEXT NOT NULL,
63
+ mtime INTEGER NOT NULL
64
+ );
65
+ `,
66
+ },
67
+ {
68
+ version: 4,
69
+ up: `ALTER TABLE file_hashes ADD COLUMN size INTEGER DEFAULT 0;`,
70
+ },
71
+ {
72
+ version: 5,
73
+ up: `
74
+ CREATE TABLE IF NOT EXISTS co_changes (
75
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
76
+ file_a TEXT NOT NULL,
77
+ file_b TEXT NOT NULL,
78
+ commit_count INTEGER NOT NULL,
79
+ jaccard REAL NOT NULL,
80
+ last_commit_epoch INTEGER,
81
+ UNIQUE(file_a, file_b)
82
+ );
83
+ CREATE INDEX IF NOT EXISTS idx_co_changes_file_a ON co_changes(file_a);
84
+ CREATE INDEX IF NOT EXISTS idx_co_changes_file_b ON co_changes(file_b);
85
+ CREATE INDEX IF NOT EXISTS idx_co_changes_jaccard ON co_changes(jaccard DESC);
86
+ CREATE TABLE IF NOT EXISTS co_change_meta (
87
+ key TEXT PRIMARY KEY,
88
+ value TEXT NOT NULL
89
+ );
90
+ `,
91
+ },
92
+ {
93
+ version: 6,
94
+ up: `
95
+ CREATE TABLE IF NOT EXISTS file_commit_counts (
96
+ file TEXT PRIMARY KEY,
97
+ commit_count INTEGER NOT NULL DEFAULT 0
98
+ );
99
+ `,
100
+ },
101
+ {
102
+ version: 7,
103
+ up: `
104
+ CREATE TABLE IF NOT EXISTS build_meta (
105
+ key TEXT PRIMARY KEY,
106
+ value TEXT NOT NULL
107
+ );
108
+ `,
109
+ },
110
+ {
111
+ version: 8,
112
+ up: `
113
+ CREATE TABLE IF NOT EXISTS function_complexity (
114
+ node_id INTEGER PRIMARY KEY,
115
+ cognitive INTEGER NOT NULL,
116
+ cyclomatic INTEGER NOT NULL,
117
+ max_nesting INTEGER NOT NULL,
118
+ FOREIGN KEY(node_id) REFERENCES nodes(id)
119
+ );
120
+ CREATE INDEX IF NOT EXISTS idx_fc_cognitive ON function_complexity(cognitive DESC);
121
+ CREATE INDEX IF NOT EXISTS idx_fc_cyclomatic ON function_complexity(cyclomatic DESC);
122
+ `,
123
+ },
124
+ {
125
+ version: 9,
126
+ up: `
127
+ ALTER TABLE function_complexity ADD COLUMN loc INTEGER DEFAULT 0;
128
+ ALTER TABLE function_complexity ADD COLUMN sloc INTEGER DEFAULT 0;
129
+ ALTER TABLE function_complexity ADD COLUMN comment_lines INTEGER DEFAULT 0;
130
+ ALTER TABLE function_complexity ADD COLUMN halstead_n1 INTEGER DEFAULT 0;
131
+ ALTER TABLE function_complexity ADD COLUMN halstead_n2 INTEGER DEFAULT 0;
132
+ ALTER TABLE function_complexity ADD COLUMN halstead_big_n1 INTEGER DEFAULT 0;
133
+ ALTER TABLE function_complexity ADD COLUMN halstead_big_n2 INTEGER DEFAULT 0;
134
+ ALTER TABLE function_complexity ADD COLUMN halstead_vocabulary INTEGER DEFAULT 0;
135
+ ALTER TABLE function_complexity ADD COLUMN halstead_length INTEGER DEFAULT 0;
136
+ ALTER TABLE function_complexity ADD COLUMN halstead_volume REAL DEFAULT 0;
137
+ ALTER TABLE function_complexity ADD COLUMN halstead_difficulty REAL DEFAULT 0;
138
+ ALTER TABLE function_complexity ADD COLUMN halstead_effort REAL DEFAULT 0;
139
+ ALTER TABLE function_complexity ADD COLUMN halstead_bugs REAL DEFAULT 0;
140
+ ALTER TABLE function_complexity ADD COLUMN maintainability_index REAL DEFAULT 0;
141
+ CREATE INDEX IF NOT EXISTS idx_fc_mi ON function_complexity(maintainability_index ASC);
142
+ `,
143
+ },
144
+ {
145
+ version: 10,
146
+ up: `
147
+ CREATE TABLE IF NOT EXISTS dataflow (
148
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
149
+ source_id INTEGER NOT NULL,
150
+ target_id INTEGER NOT NULL,
151
+ kind TEXT NOT NULL,
152
+ param_index INTEGER,
153
+ expression TEXT,
154
+ line INTEGER,
155
+ confidence REAL DEFAULT 1.0,
156
+ FOREIGN KEY(source_id) REFERENCES nodes(id),
157
+ FOREIGN KEY(target_id) REFERENCES nodes(id)
158
+ );
159
+ CREATE INDEX IF NOT EXISTS idx_dataflow_source ON dataflow(source_id);
160
+ CREATE INDEX IF NOT EXISTS idx_dataflow_target ON dataflow(target_id);
161
+ CREATE INDEX IF NOT EXISTS idx_dataflow_kind ON dataflow(kind);
162
+ CREATE INDEX IF NOT EXISTS idx_dataflow_source_kind ON dataflow(source_id, kind);
163
+ `,
164
+ },
165
+ {
166
+ version: 11,
167
+ up: `
168
+ ALTER TABLE nodes ADD COLUMN parent_id INTEGER REFERENCES nodes(id);
169
+ CREATE INDEX IF NOT EXISTS idx_nodes_parent ON nodes(parent_id);
170
+ CREATE INDEX IF NOT EXISTS idx_nodes_kind_parent ON nodes(kind, parent_id);
171
+ `,
172
+ },
173
+ {
174
+ version: 12,
175
+ up: `
176
+ CREATE TABLE IF NOT EXISTS cfg_blocks (
177
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
178
+ function_node_id INTEGER NOT NULL,
179
+ block_index INTEGER NOT NULL,
180
+ block_type TEXT NOT NULL,
181
+ start_line INTEGER,
182
+ end_line INTEGER,
183
+ label TEXT,
184
+ FOREIGN KEY(function_node_id) REFERENCES nodes(id),
185
+ UNIQUE(function_node_id, block_index)
186
+ );
187
+ CREATE INDEX IF NOT EXISTS idx_cfg_blocks_fn ON cfg_blocks(function_node_id);
188
+
189
+ CREATE TABLE IF NOT EXISTS cfg_edges (
190
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
191
+ function_node_id INTEGER NOT NULL,
192
+ source_block_id INTEGER NOT NULL,
193
+ target_block_id INTEGER NOT NULL,
194
+ kind TEXT NOT NULL,
195
+ FOREIGN KEY(function_node_id) REFERENCES nodes(id),
196
+ FOREIGN KEY(source_block_id) REFERENCES cfg_blocks(id),
197
+ FOREIGN KEY(target_block_id) REFERENCES cfg_blocks(id)
198
+ );
199
+ CREATE INDEX IF NOT EXISTS idx_cfg_edges_fn ON cfg_edges(function_node_id);
200
+ CREATE INDEX IF NOT EXISTS idx_cfg_edges_src ON cfg_edges(source_block_id);
201
+ CREATE INDEX IF NOT EXISTS idx_cfg_edges_tgt ON cfg_edges(target_block_id);
202
+ `,
203
+ },
204
+ {
205
+ version: 13,
206
+ up: `
207
+ CREATE TABLE IF NOT EXISTS ast_nodes (
208
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
209
+ file TEXT NOT NULL,
210
+ line INTEGER NOT NULL,
211
+ kind TEXT NOT NULL,
212
+ name TEXT NOT NULL,
213
+ text TEXT,
214
+ receiver TEXT,
215
+ parent_node_id INTEGER,
216
+ FOREIGN KEY(parent_node_id) REFERENCES nodes(id)
217
+ );
218
+ CREATE INDEX IF NOT EXISTS idx_ast_kind ON ast_nodes(kind);
219
+ CREATE INDEX IF NOT EXISTS idx_ast_name ON ast_nodes(name);
220
+ CREATE INDEX IF NOT EXISTS idx_ast_file ON ast_nodes(file);
221
+ CREATE INDEX IF NOT EXISTS idx_ast_parent ON ast_nodes(parent_node_id);
222
+ CREATE INDEX IF NOT EXISTS idx_ast_kind_name ON ast_nodes(kind, name);
223
+ `,
224
+ },
225
+ {
226
+ version: 14,
227
+ up: `
228
+ ALTER TABLE nodes ADD COLUMN exported INTEGER DEFAULT 0;
229
+ CREATE INDEX IF NOT EXISTS idx_nodes_exported ON nodes(exported);
230
+ `,
231
+ },
232
+ ];
233
+
234
+ export function getBuildMeta(db, key) {
235
+ try {
236
+ const row = db.prepare('SELECT value FROM build_meta WHERE key = ?').get(key);
237
+ return row ? row.value : null;
238
+ } catch {
239
+ return null;
240
+ }
241
+ }
242
+
243
+ export function setBuildMeta(db, entries) {
244
+ const upsert = db.prepare('INSERT OR REPLACE INTO build_meta (key, value) VALUES (?, ?)');
245
+ const tx = db.transaction(() => {
246
+ for (const [key, value] of Object.entries(entries)) {
247
+ upsert.run(key, String(value));
248
+ }
249
+ });
250
+ tx();
251
+ }
252
+
253
+ export function initSchema(db) {
254
+ db.exec(`CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL DEFAULT 0)`);
255
+
256
+ const row = db.prepare('SELECT version FROM schema_version').get();
257
+ let currentVersion = row ? row.version : 0;
258
+
259
+ if (!row) {
260
+ db.prepare('INSERT INTO schema_version (version) VALUES (0)').run();
261
+ }
262
+
263
+ for (const migration of MIGRATIONS) {
264
+ if (migration.version > currentVersion) {
265
+ debug(`Running migration v${migration.version}`);
266
+ db.exec(migration.up);
267
+ db.prepare('UPDATE schema_version SET version = ?').run(migration.version);
268
+ currentVersion = migration.version;
269
+ }
270
+ }
271
+
272
+ try {
273
+ db.exec('ALTER TABLE nodes ADD COLUMN end_line INTEGER');
274
+ } catch {
275
+ /* already exists */
276
+ }
277
+ try {
278
+ db.exec('ALTER TABLE edges ADD COLUMN confidence REAL DEFAULT 1.0');
279
+ } catch {
280
+ /* already exists */
281
+ }
282
+ try {
283
+ db.exec('ALTER TABLE edges ADD COLUMN dynamic INTEGER DEFAULT 0');
284
+ } catch {
285
+ /* already exists */
286
+ }
287
+ try {
288
+ db.exec('ALTER TABLE nodes ADD COLUMN role TEXT');
289
+ } catch {
290
+ /* already exists */
291
+ }
292
+ try {
293
+ db.exec('CREATE INDEX IF NOT EXISTS idx_nodes_role ON nodes(role)');
294
+ } catch {
295
+ /* already exists */
296
+ }
297
+ try {
298
+ db.exec('ALTER TABLE nodes ADD COLUMN parent_id INTEGER REFERENCES nodes(id)');
299
+ } catch {
300
+ /* already exists */
301
+ }
302
+ try {
303
+ db.exec('CREATE INDEX IF NOT EXISTS idx_nodes_parent ON nodes(parent_id)');
304
+ } catch {
305
+ /* already exists */
306
+ }
307
+ try {
308
+ db.exec('CREATE INDEX IF NOT EXISTS idx_nodes_kind_parent ON nodes(kind, parent_id)');
309
+ } catch {
310
+ /* already exists */
311
+ }
312
+ }
@@ -0,0 +1,280 @@
1
+ import { EVERY_EDGE_KIND } from '../kinds.js';
2
+
3
+ // ─── Validation Helpers ─────────────────────────────────────────────
4
+
5
+ const SAFE_ALIAS_RE = /^[a-z_][a-z0-9_]*$/i;
6
+ const SAFE_COLUMN_RE = /^[a-z_][a-z0-9_]*(?:\.[a-z_][a-z0-9_]*)?$/i;
7
+ // Matches: column, table.column, column ASC, table.column DESC
8
+ const SAFE_ORDER_TERM_RE = /^[a-z_][a-z0-9_]*(?:\.[a-z_][a-z0-9_]*)?\s*(?:asc|desc)?$/i;
9
+ // Matches safe SELECT expressions: column refs, *, table.*, COALESCE(...) AS alias
10
+ const SAFE_SELECT_TOKEN_RE =
11
+ /^(?:[a-z_][a-z0-9_]*(?:\.[a-z_*][a-z0-9_]*)?\s*(?:as\s+[a-z_][a-z0-9_]*)?|[a-z_]+\([^)]*\)\s*(?:as\s+[a-z_][a-z0-9_]*)?)$/i;
12
+
13
+ function validateAlias(alias) {
14
+ if (!SAFE_ALIAS_RE.test(alias)) {
15
+ throw new Error(`Invalid SQL alias: ${alias}`);
16
+ }
17
+ }
18
+
19
+ function validateColumn(column) {
20
+ if (!SAFE_COLUMN_RE.test(column)) {
21
+ throw new Error(`Invalid SQL column: ${column}`);
22
+ }
23
+ }
24
+
25
+ function validateOrderBy(clause) {
26
+ const terms = clause.split(',').map((t) => t.trim());
27
+ for (const term of terms) {
28
+ if (!SAFE_ORDER_TERM_RE.test(term)) {
29
+ throw new Error(`Invalid ORDER BY term: ${term}`);
30
+ }
31
+ }
32
+ }
33
+
34
+ function splitTopLevelCommas(str) {
35
+ const parts = [];
36
+ let depth = 0;
37
+ let start = 0;
38
+ for (let i = 0; i < str.length; i++) {
39
+ if (str[i] === '(') depth++;
40
+ else if (str[i] === ')') depth--;
41
+ else if (str[i] === ',' && depth === 0) {
42
+ parts.push(str.slice(start, i).trim());
43
+ start = i + 1;
44
+ }
45
+ }
46
+ parts.push(str.slice(start).trim());
47
+ return parts;
48
+ }
49
+
50
+ function validateSelectCols(cols) {
51
+ const tokens = splitTopLevelCommas(cols);
52
+ for (const token of tokens) {
53
+ if (!SAFE_SELECT_TOKEN_RE.test(token)) {
54
+ throw new Error(`Invalid SELECT expression: ${token}`);
55
+ }
56
+ }
57
+ }
58
+
59
+ function validateEdgeKind(edgeKind) {
60
+ if (!EVERY_EDGE_KIND.includes(edgeKind)) {
61
+ throw new Error(
62
+ `Invalid edge kind: ${edgeKind} (expected one of ${EVERY_EDGE_KIND.join(', ')})`,
63
+ );
64
+ }
65
+ }
66
+
67
+ // ─── Standalone Helpers ──────────────────────────────────────────────
68
+
69
+ /**
70
+ * Return a SQL AND clause that excludes test/spec/stories files.
71
+ * Returns empty string when disabled.
72
+ * @param {string} [column='n.file'] - Column to filter on
73
+ * @param {boolean} [enabled=true] - No-op when false
74
+ */
75
+ export function testFilterSQL(column = 'n.file', enabled = true) {
76
+ if (!enabled) return '';
77
+ validateColumn(column);
78
+ return `AND ${column} NOT LIKE '%.test.%'
79
+ AND ${column} NOT LIKE '%.spec.%'
80
+ AND ${column} NOT LIKE '%__test__%'
81
+ AND ${column} NOT LIKE '%__tests__%'
82
+ AND ${column} NOT LIKE '%.stories.%'`;
83
+ }
84
+
85
+ /**
86
+ * Build IN (?, ?, ?) placeholders and params array for a kind filter.
87
+ * @param {string[]} kinds
88
+ * @returns {{ placeholders: string, params: string[] }}
89
+ */
90
+ export function kindInClause(kinds) {
91
+ return {
92
+ placeholders: kinds.map(() => '?').join(', '),
93
+ params: [...kinds],
94
+ };
95
+ }
96
+
97
+ /**
98
+ * Return a LEFT JOIN subquery for fan-in (incoming edge count).
99
+ * @param {string} [edgeKind='calls'] - Edge kind to count
100
+ * @param {string} [alias='fi'] - Subquery alias
101
+ */
102
+ export function fanInJoinSQL(edgeKind = 'calls', alias = 'fi') {
103
+ validateEdgeKind(edgeKind);
104
+ validateAlias(alias);
105
+ return `LEFT JOIN (
106
+ SELECT target_id, COUNT(*) AS cnt FROM edges WHERE kind = '${edgeKind}' GROUP BY target_id
107
+ ) ${alias} ON ${alias}.target_id = n.id`;
108
+ }
109
+
110
+ /**
111
+ * Return a LEFT JOIN subquery for fan-out (outgoing edge count).
112
+ * @param {string} [edgeKind='calls'] - Edge kind to count
113
+ * @param {string} [alias='fo'] - Subquery alias
114
+ */
115
+ export function fanOutJoinSQL(edgeKind = 'calls', alias = 'fo') {
116
+ validateEdgeKind(edgeKind);
117
+ validateAlias(alias);
118
+ return `LEFT JOIN (
119
+ SELECT source_id, COUNT(*) AS cnt FROM edges WHERE kind = '${edgeKind}' GROUP BY source_id
120
+ ) ${alias} ON ${alias}.source_id = n.id`;
121
+ }
122
+
123
+ // ─── NodeQuery Fluent Builder ────────────────────────────────────────
124
+
125
+ /**
126
+ * Fluent builder for the common `SELECT ... FROM nodes n WHERE ...` pattern.
127
+ * Not an ORM — complex queries (BFS, correlated subqueries) stay as raw SQL.
128
+ */
129
+ export class NodeQuery {
130
+ #selectCols = 'n.*';
131
+ #joins = [];
132
+ #conditions = [];
133
+ #params = [];
134
+ #orderByClause = '';
135
+ #limitValue = null;
136
+
137
+ /** Set SELECT columns (default: `n.*`). */
138
+ select(cols) {
139
+ validateSelectCols(cols);
140
+ this.#selectCols = cols;
141
+ return this;
142
+ }
143
+
144
+ /** WHERE n.kind IN (?, ?, ...) */
145
+ kinds(kindArray) {
146
+ if (!kindArray || kindArray.length === 0) return this;
147
+ const { placeholders, params } = kindInClause(kindArray);
148
+ this.#conditions.push(`n.kind IN (${placeholders})`);
149
+ this.#params.push(...params);
150
+ return this;
151
+ }
152
+
153
+ /** Add 5 NOT LIKE conditions to exclude test files. No-op when enabled is falsy. */
154
+ excludeTests(enabled) {
155
+ if (!enabled) return this;
156
+ this.#conditions.push(
157
+ `n.file NOT LIKE '%.test.%'`,
158
+ `n.file NOT LIKE '%.spec.%'`,
159
+ `n.file NOT LIKE '%__test__%'`,
160
+ `n.file NOT LIKE '%__tests__%'`,
161
+ `n.file NOT LIKE '%.stories.%'`,
162
+ );
163
+ return this;
164
+ }
165
+
166
+ /** WHERE n.file LIKE ? (no-op if falsy). */
167
+ fileFilter(file) {
168
+ if (!file) return this;
169
+ this.#conditions.push('n.file LIKE ?');
170
+ this.#params.push(`%${file}%`);
171
+ return this;
172
+ }
173
+
174
+ /** WHERE n.kind = ? (no-op if falsy). */
175
+ kindFilter(kind) {
176
+ if (!kind) return this;
177
+ this.#conditions.push('n.kind = ?');
178
+ this.#params.push(kind);
179
+ return this;
180
+ }
181
+
182
+ /** WHERE n.role = ? (no-op if falsy). */
183
+ roleFilter(role) {
184
+ if (!role) return this;
185
+ this.#conditions.push('n.role = ?');
186
+ this.#params.push(role);
187
+ return this;
188
+ }
189
+
190
+ /** WHERE n.name LIKE ? (no-op if falsy). */
191
+ nameLike(pattern) {
192
+ if (!pattern) return this;
193
+ this.#conditions.push('n.name LIKE ?');
194
+ this.#params.push(`%${pattern}%`);
195
+ return this;
196
+ }
197
+
198
+ /** Raw WHERE condition escape hatch. */
199
+ where(sql, ...params) {
200
+ this.#conditions.push(sql);
201
+ this.#params.push(...params);
202
+ return this;
203
+ }
204
+
205
+ /** Add fan-in LEFT JOIN subquery. */
206
+ withFanIn(edgeKind = 'calls') {
207
+ return this._join(fanInJoinSQL(edgeKind));
208
+ }
209
+
210
+ /** Add fan-out LEFT JOIN subquery. */
211
+ withFanOut(edgeKind = 'calls') {
212
+ return this._join(fanOutJoinSQL(edgeKind));
213
+ }
214
+
215
+ /** LEFT JOIN function_complexity. */
216
+ withComplexity() {
217
+ return this._join('LEFT JOIN function_complexity fc ON fc.node_id = n.id');
218
+ }
219
+
220
+ /** LEFT JOIN file_commit_counts. */
221
+ withChurn() {
222
+ return this._join('LEFT JOIN file_commit_counts fcc ON n.file = fcc.file');
223
+ }
224
+
225
+ /** @private Raw JOIN — internal use only; external callers should use withFanIn/withFanOut/withComplexity/withChurn. */
226
+ _join(sql) {
227
+ this.#joins.push(sql);
228
+ return this;
229
+ }
230
+
231
+ /** ORDER BY clause. */
232
+ orderBy(clause) {
233
+ validateOrderBy(clause);
234
+ this.#orderByClause = clause;
235
+ return this;
236
+ }
237
+
238
+ /** LIMIT ?. */
239
+ limit(n) {
240
+ if (n == null) return this;
241
+ this.#limitValue = n;
242
+ return this;
243
+ }
244
+
245
+ /** Build the SQL and params without executing. */
246
+ build() {
247
+ const joins = this.#joins.length > 0 ? `\n ${this.#joins.join('\n ')}` : '';
248
+ const where =
249
+ this.#conditions.length > 0 ? `\n WHERE ${this.#conditions.join(' AND ')}` : '';
250
+ const orderBy = this.#orderByClause ? `\n ORDER BY ${this.#orderByClause}` : '';
251
+
252
+ let limitClause = '';
253
+ const params = [...this.#params];
254
+ if (this.#limitValue != null) {
255
+ limitClause = '\n LIMIT ?';
256
+ params.push(this.#limitValue);
257
+ }
258
+
259
+ const sql = `SELECT ${this.#selectCols}\n FROM nodes n${joins}${where}${orderBy}${limitClause}`;
260
+ return { sql, params };
261
+ }
262
+
263
+ /** Execute and return all rows. */
264
+ all(db) {
265
+ const { sql, params } = this.build();
266
+ return db.prepare(sql).all(...params);
267
+ }
268
+
269
+ /** Execute and return first row. */
270
+ get(db) {
271
+ const { sql, params } = this.build();
272
+ return db.prepare(sql).get(...params);
273
+ }
274
+
275
+ /** Execute and return an iterator. */
276
+ iterate(db) {
277
+ const { sql, params } = this.build();
278
+ return db.prepare(sql).iterate(...params);
279
+ }
280
+ }