@optave/codegraph 2.5.1 → 3.0.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/src/queries.js CHANGED
@@ -1,11 +1,14 @@
1
1
  import { execFileSync } from 'node:child_process';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
+ import { evaluateBoundaries } from './boundaries.js';
4
5
  import { coChangeForFiles } from './cochange.js';
6
+ import { loadConfig } from './config.js';
5
7
  import { findCycles } from './cycles.js';
6
8
  import { findDbPath, openReadonlyOrFail } from './db.js';
7
9
  import { debug } from './logger.js';
8
- import { paginateResult } from './paginate.js';
10
+ import { ownersForFiles } from './owners.js';
11
+ import { paginateResult, printNdjson } from './paginate.js';
9
12
  import { LANGUAGE_REGISTRY } from './parser.js';
10
13
 
11
14
  /**
@@ -56,7 +59,9 @@ export const FALSE_POSITIVE_NAMES = new Set([
56
59
  export const FALSE_POSITIVE_CALLER_THRESHOLD = 20;
57
60
 
58
61
  const FUNCTION_KINDS = ['function', 'method', 'class'];
59
- export const ALL_SYMBOL_KINDS = [
62
+
63
+ // Original 10 kinds — used as default query scope
64
+ export const CORE_SYMBOL_KINDS = [
60
65
  'function',
61
66
  'method',
62
67
  'class',
@@ -69,6 +74,39 @@ export const ALL_SYMBOL_KINDS = [
69
74
  'module',
70
75
  ];
71
76
 
77
+ // Sub-declaration kinds (Phase 1)
78
+ export const EXTENDED_SYMBOL_KINDS = [
79
+ 'parameter',
80
+ 'property',
81
+ 'constant',
82
+ // Phase 2 (reserved, not yet extracted):
83
+ // 'constructor', 'namespace', 'decorator', 'getter', 'setter',
84
+ ];
85
+
86
+ // Full set for --kind validation and MCP enum
87
+ export const EVERY_SYMBOL_KIND = [...CORE_SYMBOL_KINDS, ...EXTENDED_SYMBOL_KINDS];
88
+
89
+ // Backward compat: ALL_SYMBOL_KINDS stays as the core 10
90
+ export const ALL_SYMBOL_KINDS = CORE_SYMBOL_KINDS;
91
+
92
+ // ── Edge kind constants ─────────────────────────────────────────────
93
+ // Core edge kinds — coupling and dependency relationships
94
+ export const CORE_EDGE_KINDS = [
95
+ 'imports',
96
+ 'imports-type',
97
+ 'reexports',
98
+ 'calls',
99
+ 'extends',
100
+ 'implements',
101
+ 'contains',
102
+ ];
103
+
104
+ // Structural edge kinds — parent/child and type relationships
105
+ export const STRUCTURAL_EDGE_KINDS = ['parameter_of', 'receiver'];
106
+
107
+ // Full set for MCP enum and validation
108
+ export const EVERY_EDGE_KIND = [...CORE_EDGE_KINDS, ...STRUCTURAL_EDGE_KINDS];
109
+
72
110
  export const VALID_ROLES = ['entry', 'core', 'utility', 'adapter', 'dead', 'leaf'];
73
111
 
74
112
  /**
@@ -187,6 +225,12 @@ export function kindIcon(kind) {
187
225
  return 'I';
188
226
  case 'type':
189
227
  return 'T';
228
+ case 'parameter':
229
+ return 'p';
230
+ case 'property':
231
+ return '.';
232
+ case 'constant':
233
+ return 'C';
190
234
  default:
191
235
  return '-';
192
236
  }
@@ -204,6 +248,7 @@ export function queryNameData(name, customDbPath, opts = {}) {
204
248
  return { query: name, results: [] };
205
249
  }
206
250
 
251
+ const hc = new Map();
207
252
  const results = nodes.map((node) => {
208
253
  let callees = db
209
254
  .prepare(`
@@ -227,10 +272,7 @@ export function queryNameData(name, customDbPath, opts = {}) {
227
272
  }
228
273
 
229
274
  return {
230
- name: node.name,
231
- kind: node.kind,
232
- file: node.file,
233
- line: node.line,
275
+ ...normalizeSymbol(node, db, hc),
234
276
  callees: callees.map((c) => ({
235
277
  name: c.name,
236
278
  kind: c.kind,
@@ -324,12 +366,12 @@ export function moduleMapData(customDbPath, limit = 20, opts = {}) {
324
366
  const nodes = db
325
367
  .prepare(`
326
368
  SELECT n.*,
327
- (SELECT COUNT(*) FROM edges WHERE source_id = n.id AND kind != 'contains') as out_edges,
328
- (SELECT COUNT(*) FROM edges WHERE target_id = n.id AND kind != 'contains') as in_edges
369
+ (SELECT COUNT(*) FROM edges WHERE source_id = n.id AND kind NOT IN ('contains', 'parameter_of', 'receiver')) as out_edges,
370
+ (SELECT COUNT(*) FROM edges WHERE target_id = n.id AND kind NOT IN ('contains', 'parameter_of', 'receiver')) as in_edges
329
371
  FROM nodes n
330
372
  WHERE n.kind = 'file'
331
373
  ${testFilter}
332
- ORDER BY (SELECT COUNT(*) FROM edges WHERE target_id = n.id AND kind != 'contains') DESC
374
+ ORDER BY (SELECT COUNT(*) FROM edges WHERE target_id = n.id AND kind NOT IN ('contains', 'parameter_of', 'receiver')) DESC
333
375
  LIMIT ?
334
376
  `)
335
377
  .all(limit);
@@ -391,13 +433,15 @@ export function fileDepsData(file, customDbPath, opts = {}) {
391
433
  });
392
434
 
393
435
  db.close();
394
- return { file, results };
436
+ const base = { file, results };
437
+ return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset });
395
438
  }
396
439
 
397
440
  export function fnDepsData(name, customDbPath, opts = {}) {
398
441
  const db = openReadonlyOrFail(customDbPath);
399
442
  const depth = opts.depth || 3;
400
443
  const noTests = opts.noTests || false;
444
+ const hc = new Map();
401
445
 
402
446
  const nodes = findMatchingNodes(db, name, { noTests, file: opts.file, kind: opts.kind });
403
447
  if (nodes.length === 0) {
@@ -489,10 +533,7 @@ export function fnDepsData(name, customDbPath, opts = {}) {
489
533
  }
490
534
 
491
535
  return {
492
- name: node.name,
493
- kind: node.kind,
494
- file: node.file,
495
- line: node.line,
536
+ ...normalizeSymbol(node, db, hc),
496
537
  callees: filteredCallees.map((c) => ({
497
538
  name: c.name,
498
539
  kind: c.kind,
@@ -511,13 +552,15 @@ export function fnDepsData(name, customDbPath, opts = {}) {
511
552
  });
512
553
 
513
554
  db.close();
514
- return { name, results };
555
+ const base = { name, results };
556
+ return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset });
515
557
  }
516
558
 
517
559
  export function fnImpactData(name, customDbPath, opts = {}) {
518
560
  const db = openReadonlyOrFail(customDbPath);
519
561
  const maxDepth = opts.depth || 5;
520
562
  const noTests = opts.noTests || false;
563
+ const hc = new Map();
521
564
 
522
565
  const nodes = findMatchingNodes(db, name, { noTests, file: opts.file, kind: opts.kind });
523
566
  if (nodes.length === 0) {
@@ -525,7 +568,7 @@ export function fnImpactData(name, customDbPath, opts = {}) {
525
568
  return { name, results: [] };
526
569
  }
527
570
 
528
- const results = nodes.slice(0, 3).map((node) => {
571
+ const results = nodes.map((node) => {
529
572
  const visited = new Set([node.id]);
530
573
  const levels = {};
531
574
  let frontier = [node.id];
@@ -554,17 +597,15 @@ export function fnImpactData(name, customDbPath, opts = {}) {
554
597
  }
555
598
 
556
599
  return {
557
- name: node.name,
558
- kind: node.kind,
559
- file: node.file,
560
- line: node.line,
600
+ ...normalizeSymbol(node, db, hc),
561
601
  levels,
562
602
  totalDependents: visited.size - 1,
563
603
  };
564
604
  });
565
605
 
566
606
  db.close();
567
- return { name, results };
607
+ const base = { name, results };
608
+ return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset });
568
609
  }
569
610
 
570
611
  export function pathData(from, to, customDbPath, opts = {}) {
@@ -998,20 +1039,60 @@ export function diffImpactData(customDbPath, opts = {}) {
998
1039
  /* co_changes table doesn't exist — skip silently */
999
1040
  }
1000
1041
 
1042
+ // Look up CODEOWNERS for changed + affected files
1043
+ let ownership = null;
1044
+ try {
1045
+ const allFilePaths = [...new Set([...changedRanges.keys(), ...affectedFiles])];
1046
+ const ownerResult = ownersForFiles(allFilePaths, repoRoot);
1047
+ if (ownerResult.affectedOwners.length > 0) {
1048
+ ownership = {
1049
+ owners: Object.fromEntries(ownerResult.owners),
1050
+ affectedOwners: ownerResult.affectedOwners,
1051
+ suggestedReviewers: ownerResult.suggestedReviewers,
1052
+ };
1053
+ }
1054
+ } catch {
1055
+ /* CODEOWNERS missing or unreadable — skip silently */
1056
+ }
1057
+
1058
+ // Check boundary violations scoped to changed files
1059
+ let boundaryViolations = [];
1060
+ let boundaryViolationCount = 0;
1061
+ try {
1062
+ const config = loadConfig(repoRoot);
1063
+ const boundaryConfig = config.manifesto?.boundaries;
1064
+ if (boundaryConfig) {
1065
+ const result = evaluateBoundaries(db, boundaryConfig, {
1066
+ scopeFiles: [...changedRanges.keys()],
1067
+ noTests,
1068
+ });
1069
+ boundaryViolations = result.violations;
1070
+ boundaryViolationCount = result.violationCount;
1071
+ }
1072
+ } catch {
1073
+ /* boundary check failed — skip silently */
1074
+ }
1075
+
1001
1076
  db.close();
1002
- return {
1077
+ const base = {
1003
1078
  changedFiles: changedRanges.size,
1004
1079
  newFiles: [...newFiles],
1005
1080
  affectedFunctions: functionResults,
1006
1081
  affectedFiles: [...affectedFiles],
1007
1082
  historicallyCoupled,
1083
+ ownership,
1084
+ boundaryViolations,
1085
+ boundaryViolationCount,
1008
1086
  summary: {
1009
1087
  functionsChanged: affectedFunctions.length,
1010
1088
  callersAffected: allAffected.size,
1011
1089
  filesAffected: affectedFiles.size,
1012
1090
  historicallyCoupledCount: historicallyCoupled.length,
1091
+ ownersAffected: ownership ? ownership.affectedOwners.length : 0,
1092
+ boundaryViolationCount,
1013
1093
  },
1014
1094
  };
1095
+ return paginateResult(base, 'affectedFunctions', { limit: opts.limit, offset: opts.offset });
1015
1096
  }
1016
1097
 
1017
1098
  export function diffImpactMermaid(customDbPath, opts = {}) {
@@ -1148,17 +1229,158 @@ export function listFunctionsData(customDbPath, opts = {}) {
1148
1229
 
1149
1230
  let rows = db
1150
1231
  .prepare(
1151
- `SELECT name, kind, file, line, role FROM nodes WHERE ${conditions.join(' AND ')} ORDER BY file, line`,
1232
+ `SELECT name, kind, file, line, end_line, role FROM nodes WHERE ${conditions.join(' AND ')} ORDER BY file, line`,
1152
1233
  )
1153
1234
  .all(...params);
1154
1235
 
1155
1236
  if (noTests) rows = rows.filter((r) => !isTestFile(r.file));
1156
1237
 
1238
+ const hc = new Map();
1239
+ const functions = rows.map((r) => normalizeSymbol(r, db, hc));
1157
1240
  db.close();
1158
- const base = { count: rows.length, functions: rows };
1241
+ const base = { count: functions.length, functions };
1159
1242
  return paginateResult(base, 'functions', { limit: opts.limit, offset: opts.offset });
1160
1243
  }
1161
1244
 
1245
+ /**
1246
+ * Generator: stream functions one-by-one using .iterate() for memory efficiency.
1247
+ * @param {string} [customDbPath]
1248
+ * @param {object} [opts]
1249
+ * @param {boolean} [opts.noTests]
1250
+ * @param {string} [opts.file]
1251
+ * @param {string} [opts.pattern]
1252
+ * @yields {{ name: string, kind: string, file: string, line: number, role: string|null }}
1253
+ */
1254
+ export function* iterListFunctions(customDbPath, opts = {}) {
1255
+ const db = openReadonlyOrFail(customDbPath);
1256
+ try {
1257
+ const noTests = opts.noTests || false;
1258
+ const kinds = ['function', 'method', 'class'];
1259
+ const placeholders = kinds.map(() => '?').join(', ');
1260
+
1261
+ const conditions = [`kind IN (${placeholders})`];
1262
+ const params = [...kinds];
1263
+
1264
+ if (opts.file) {
1265
+ conditions.push('file LIKE ?');
1266
+ params.push(`%${opts.file}%`);
1267
+ }
1268
+ if (opts.pattern) {
1269
+ conditions.push('name LIKE ?');
1270
+ params.push(`%${opts.pattern}%`);
1271
+ }
1272
+
1273
+ const stmt = db.prepare(
1274
+ `SELECT name, kind, file, line, end_line, role FROM nodes WHERE ${conditions.join(' AND ')} ORDER BY file, line`,
1275
+ );
1276
+ for (const row of stmt.iterate(...params)) {
1277
+ if (noTests && isTestFile(row.file)) continue;
1278
+ yield {
1279
+ name: row.name,
1280
+ kind: row.kind,
1281
+ file: row.file,
1282
+ line: row.line,
1283
+ endLine: row.end_line ?? null,
1284
+ role: row.role ?? null,
1285
+ };
1286
+ }
1287
+ } finally {
1288
+ db.close();
1289
+ }
1290
+ }
1291
+
1292
+ /**
1293
+ * Generator: stream role-classified symbols one-by-one.
1294
+ * @param {string} [customDbPath]
1295
+ * @param {object} [opts]
1296
+ * @param {boolean} [opts.noTests]
1297
+ * @param {string} [opts.role]
1298
+ * @param {string} [opts.file]
1299
+ * @yields {{ name: string, kind: string, file: string, line: number, endLine: number|null, role: string }}
1300
+ */
1301
+ export function* iterRoles(customDbPath, opts = {}) {
1302
+ const db = openReadonlyOrFail(customDbPath);
1303
+ try {
1304
+ const noTests = opts.noTests || false;
1305
+ const conditions = ['role IS NOT NULL'];
1306
+ const params = [];
1307
+
1308
+ if (opts.role) {
1309
+ conditions.push('role = ?');
1310
+ params.push(opts.role);
1311
+ }
1312
+ if (opts.file) {
1313
+ conditions.push('file LIKE ?');
1314
+ params.push(`%${opts.file}%`);
1315
+ }
1316
+
1317
+ const stmt = db.prepare(
1318
+ `SELECT name, kind, file, line, end_line, role FROM nodes WHERE ${conditions.join(' AND ')} ORDER BY role, file, line`,
1319
+ );
1320
+ for (const row of stmt.iterate(...params)) {
1321
+ if (noTests && isTestFile(row.file)) continue;
1322
+ yield {
1323
+ name: row.name,
1324
+ kind: row.kind,
1325
+ file: row.file,
1326
+ line: row.line,
1327
+ endLine: row.end_line ?? null,
1328
+ role: row.role ?? null,
1329
+ };
1330
+ }
1331
+ } finally {
1332
+ db.close();
1333
+ }
1334
+ }
1335
+
1336
+ /**
1337
+ * Generator: stream symbol lookup results one-by-one.
1338
+ * @param {string} target - Symbol name to search for (partial match)
1339
+ * @param {string} [customDbPath]
1340
+ * @param {object} [opts]
1341
+ * @param {boolean} [opts.noTests]
1342
+ * @yields {{ name: string, kind: string, file: string, line: number, role: string|null, exported: boolean, uses: object[] }}
1343
+ */
1344
+ export function* iterWhere(target, customDbPath, opts = {}) {
1345
+ const db = openReadonlyOrFail(customDbPath);
1346
+ try {
1347
+ const noTests = opts.noTests || false;
1348
+ const placeholders = ALL_SYMBOL_KINDS.map(() => '?').join(', ');
1349
+ const stmt = db.prepare(
1350
+ `SELECT * FROM nodes WHERE name LIKE ? AND kind IN (${placeholders}) ORDER BY file, line`,
1351
+ );
1352
+ const crossFileCallersStmt = db.prepare(
1353
+ `SELECT COUNT(*) as cnt FROM edges e JOIN nodes n ON e.source_id = n.id
1354
+ WHERE e.target_id = ? AND e.kind = 'calls' AND n.file != ?`,
1355
+ );
1356
+ const usesStmt = db.prepare(
1357
+ `SELECT n.name, n.file, n.line FROM edges e JOIN nodes n ON e.source_id = n.id
1358
+ WHERE e.target_id = ? AND e.kind = 'calls'`,
1359
+ );
1360
+ for (const node of stmt.iterate(`%${target}%`, ...ALL_SYMBOL_KINDS)) {
1361
+ if (noTests && isTestFile(node.file)) continue;
1362
+
1363
+ const crossFileCallers = crossFileCallersStmt.get(node.id, node.file);
1364
+ const exported = crossFileCallers.cnt > 0;
1365
+
1366
+ let uses = usesStmt.all(node.id);
1367
+ if (noTests) uses = uses.filter((u) => !isTestFile(u.file));
1368
+
1369
+ yield {
1370
+ name: node.name,
1371
+ kind: node.kind,
1372
+ file: node.file,
1373
+ line: node.line,
1374
+ role: node.role || null,
1375
+ exported,
1376
+ uses: uses.map((u) => ({ name: u.name, file: u.file, line: u.line })),
1377
+ };
1378
+ }
1379
+ } finally {
1380
+ db.close();
1381
+ }
1382
+ }
1383
+
1162
1384
  export function statsData(customDbPath, opts = {}) {
1163
1385
  const db = openReadonlyOrFail(customDbPath);
1164
1386
  const noTests = opts.noTests || false;
@@ -1553,8 +1775,7 @@ export function queryName(name, customDbPath, opts = {}) {
1553
1775
  offset: opts.offset,
1554
1776
  });
1555
1777
  if (opts.ndjson) {
1556
- if (data._pagination) console.log(JSON.stringify({ _meta: data._pagination }));
1557
- for (const r of data.results) console.log(JSON.stringify(r));
1778
+ printNdjson(data, 'results');
1558
1779
  return;
1559
1780
  }
1560
1781
  if (opts.json) {
@@ -1586,7 +1807,11 @@ export function queryName(name, customDbPath, opts = {}) {
1586
1807
  }
1587
1808
 
1588
1809
  export function impactAnalysis(file, customDbPath, opts = {}) {
1589
- const data = impactAnalysisData(file, customDbPath, { noTests: opts.noTests });
1810
+ const data = impactAnalysisData(file, customDbPath, opts);
1811
+ if (opts.ndjson) {
1812
+ printNdjson(data, 'sources');
1813
+ return;
1814
+ }
1590
1815
  if (opts.json) {
1591
1816
  console.log(JSON.stringify(data, null, 2));
1592
1817
  return;
@@ -1645,7 +1870,11 @@ export function moduleMap(customDbPath, limit = 20, opts = {}) {
1645
1870
  }
1646
1871
 
1647
1872
  export function fileDeps(file, customDbPath, opts = {}) {
1648
- const data = fileDepsData(file, customDbPath, { noTests: opts.noTests });
1873
+ const data = fileDepsData(file, customDbPath, opts);
1874
+ if (opts.ndjson) {
1875
+ printNdjson(data, 'results');
1876
+ return;
1877
+ }
1649
1878
  if (opts.json) {
1650
1879
  console.log(JSON.stringify(data, null, 2));
1651
1880
  return;
@@ -1676,6 +1905,10 @@ export function fileDeps(file, customDbPath, opts = {}) {
1676
1905
 
1677
1906
  export function fnDeps(name, customDbPath, opts = {}) {
1678
1907
  const data = fnDepsData(name, customDbPath, opts);
1908
+ if (opts.ndjson) {
1909
+ printNdjson(data, 'results');
1910
+ return;
1911
+ }
1679
1912
  if (opts.json) {
1680
1913
  console.log(JSON.stringify(data, null, 2));
1681
1914
  return;
@@ -1838,14 +2071,13 @@ export function contextData(name, customDbPath, opts = {}) {
1838
2071
  const dbPath = findDbPath(customDbPath);
1839
2072
  const repoRoot = path.resolve(path.dirname(dbPath), '..');
1840
2073
 
1841
- let nodes = findMatchingNodes(db, name, { noTests, file: opts.file, kind: opts.kind });
2074
+ const nodes = findMatchingNodes(db, name, { noTests, file: opts.file, kind: opts.kind });
1842
2075
  if (nodes.length === 0) {
1843
2076
  db.close();
1844
2077
  return { name, results: [] };
1845
2078
  }
1846
2079
 
1847
- // Limit to first 5 results
1848
- nodes = nodes.slice(0, 5);
2080
+ // No hardcoded slice pagination handles bounding via limit/offset
1849
2081
 
1850
2082
  // File-lines cache to avoid re-reading the same file
1851
2083
  const fileCache = new Map();
@@ -2033,6 +2265,17 @@ export function contextData(name, customDbPath, opts = {}) {
2033
2265
  /* table may not exist */
2034
2266
  }
2035
2267
 
2268
+ // Children (parameters, properties, constants)
2269
+ let nodeChildren = [];
2270
+ try {
2271
+ nodeChildren = db
2272
+ .prepare('SELECT name, kind, line, end_line FROM nodes WHERE parent_id = ? ORDER BY line')
2273
+ .all(node.id)
2274
+ .map((c) => ({ name: c.name, kind: c.kind, line: c.line, endLine: c.end_line || null }));
2275
+ } catch {
2276
+ /* parent_id column may not exist */
2277
+ }
2278
+
2036
2279
  return {
2037
2280
  name: node.name,
2038
2281
  kind: node.kind,
@@ -2043,6 +2286,7 @@ export function contextData(name, customDbPath, opts = {}) {
2043
2286
  source,
2044
2287
  signature,
2045
2288
  complexity: complexityMetrics,
2289
+ children: nodeChildren.length > 0 ? nodeChildren : undefined,
2046
2290
  callees,
2047
2291
  callers,
2048
2292
  relatedTests,
@@ -2050,11 +2294,16 @@ export function contextData(name, customDbPath, opts = {}) {
2050
2294
  });
2051
2295
 
2052
2296
  db.close();
2053
- return { name, results };
2297
+ const base = { name, results };
2298
+ return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset });
2054
2299
  }
2055
2300
 
2056
2301
  export function context(name, customDbPath, opts = {}) {
2057
2302
  const data = contextData(name, customDbPath, opts);
2303
+ if (opts.ndjson) {
2304
+ printNdjson(data, 'results');
2305
+ return;
2306
+ }
2058
2307
  if (opts.json) {
2059
2308
  console.log(JSON.stringify(data, null, 2));
2060
2309
  return;
@@ -2077,6 +2326,15 @@ export function context(name, customDbPath, opts = {}) {
2077
2326
  console.log();
2078
2327
  }
2079
2328
 
2329
+ // Children
2330
+ if (r.children && r.children.length > 0) {
2331
+ console.log(`## Children (${r.children.length})`);
2332
+ for (const c of r.children) {
2333
+ console.log(` ${kindIcon(c.kind)} ${c.name} :${c.line}`);
2334
+ }
2335
+ console.log();
2336
+ }
2337
+
2080
2338
  // Complexity
2081
2339
  if (r.complexity) {
2082
2340
  const cx = r.complexity;
@@ -2149,6 +2407,69 @@ export function context(name, customDbPath, opts = {}) {
2149
2407
  }
2150
2408
  }
2151
2409
 
2410
+ // ─── childrenData ───────────────────────────────────────────────────────
2411
+
2412
+ export function childrenData(name, customDbPath, opts = {}) {
2413
+ const db = openReadonlyOrFail(customDbPath);
2414
+ const noTests = opts.noTests || false;
2415
+
2416
+ const nodes = findMatchingNodes(db, name, { noTests, file: opts.file, kind: opts.kind });
2417
+ if (nodes.length === 0) {
2418
+ db.close();
2419
+ return { name, results: [] };
2420
+ }
2421
+
2422
+ const results = nodes.map((node) => {
2423
+ let children;
2424
+ try {
2425
+ children = db
2426
+ .prepare('SELECT name, kind, line, end_line FROM nodes WHERE parent_id = ? ORDER BY line')
2427
+ .all(node.id);
2428
+ } catch {
2429
+ children = [];
2430
+ }
2431
+ if (noTests) children = children.filter((c) => !isTestFile(c.file || node.file));
2432
+ return {
2433
+ name: node.name,
2434
+ kind: node.kind,
2435
+ file: node.file,
2436
+ line: node.line,
2437
+ children: children.map((c) => ({
2438
+ name: c.name,
2439
+ kind: c.kind,
2440
+ line: c.line,
2441
+ endLine: c.end_line || null,
2442
+ })),
2443
+ };
2444
+ });
2445
+
2446
+ db.close();
2447
+ const base = { name, results };
2448
+ return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset });
2449
+ }
2450
+
2451
+ export function children(name, customDbPath, opts = {}) {
2452
+ const data = childrenData(name, customDbPath, opts);
2453
+ if (opts.json) {
2454
+ console.log(JSON.stringify(data, null, 2));
2455
+ return;
2456
+ }
2457
+ if (data.results.length === 0) {
2458
+ console.log(`No symbol matching "${name}"`);
2459
+ return;
2460
+ }
2461
+ for (const r of data.results) {
2462
+ console.log(`\n${kindIcon(r.kind)} ${r.name} ${r.file}:${r.line}`);
2463
+ if (r.children.length === 0) {
2464
+ console.log(' (no children)');
2465
+ } else {
2466
+ for (const c of r.children) {
2467
+ console.log(` ${kindIcon(c.kind)} ${c.name} :${c.line}`);
2468
+ }
2469
+ }
2470
+ }
2471
+ }
2472
+
2152
2473
  // ─── explainData ────────────────────────────────────────────────────────
2153
2474
 
2154
2475
  function isFileLikeTarget(target) {
@@ -2271,6 +2592,7 @@ function explainFunctionImpl(db, target, noTests, getFileLines) {
2271
2592
  if (noTests) nodes = nodes.filter((n) => !isTestFile(n.file));
2272
2593
  if (nodes.length === 0) return [];
2273
2594
 
2595
+ const hc = new Map();
2274
2596
  return nodes.slice(0, 10).map((node) => {
2275
2597
  const fileLines = getFileLines(node.file);
2276
2598
  const lineCount = node.end_line ? node.end_line - node.line + 1 : null;
@@ -2328,12 +2650,7 @@ function explainFunctionImpl(db, target, noTests, getFileLines) {
2328
2650
  }
2329
2651
 
2330
2652
  return {
2331
- name: node.name,
2332
- kind: node.kind,
2333
- file: node.file,
2334
- line: node.line,
2335
- role: node.role || null,
2336
- endLine: node.end_line || null,
2653
+ ...normalizeSymbol(node, db, hc),
2337
2654
  lineCount,
2338
2655
  summary,
2339
2656
  signature,
@@ -2410,11 +2727,16 @@ export function explainData(target, customDbPath, opts = {}) {
2410
2727
  }
2411
2728
 
2412
2729
  db.close();
2413
- return { target, kind, results };
2730
+ const base = { target, kind, results };
2731
+ return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset });
2414
2732
  }
2415
2733
 
2416
2734
  export function explain(target, customDbPath, opts = {}) {
2417
2735
  const data = explainData(target, customDbPath, opts);
2736
+ if (opts.ndjson) {
2737
+ printNdjson(data, 'results');
2738
+ return;
2739
+ }
2418
2740
  if (opts.json) {
2419
2741
  console.log(JSON.stringify(data, null, 2));
2420
2742
  return;
@@ -2541,6 +2863,41 @@ export function explain(target, customDbPath, opts = {}) {
2541
2863
 
2542
2864
  // ─── whereData ──────────────────────────────────────────────────────────
2543
2865
 
2866
+ function getFileHash(db, file) {
2867
+ const row = db.prepare('SELECT hash FROM file_hashes WHERE file = ?').get(file);
2868
+ return row ? row.hash : null;
2869
+ }
2870
+
2871
+ /**
2872
+ * Normalize a raw DB/query row into the stable 7-field symbol shape.
2873
+ * @param {object} row - Raw row (from SELECT * or explicit columns)
2874
+ * @param {object} [db] - Open DB handle; when null, fileHash will be null
2875
+ * @param {Map} [hashCache] - Optional per-file cache to avoid repeated getFileHash calls
2876
+ * @returns {{ name: string, kind: string, file: string, line: number, endLine: number|null, role: string|null, fileHash: string|null }}
2877
+ */
2878
+ export function normalizeSymbol(row, db, hashCache) {
2879
+ let fileHash = null;
2880
+ if (db) {
2881
+ if (hashCache) {
2882
+ if (!hashCache.has(row.file)) {
2883
+ hashCache.set(row.file, getFileHash(db, row.file));
2884
+ }
2885
+ fileHash = hashCache.get(row.file);
2886
+ } else {
2887
+ fileHash = getFileHash(db, row.file);
2888
+ }
2889
+ }
2890
+ return {
2891
+ name: row.name,
2892
+ kind: row.kind,
2893
+ file: row.file,
2894
+ line: row.line,
2895
+ endLine: row.end_line ?? row.endLine ?? null,
2896
+ role: row.role ?? null,
2897
+ fileHash,
2898
+ };
2899
+ }
2900
+
2544
2901
  function whereSymbolImpl(db, target, noTests) {
2545
2902
  const placeholders = ALL_SYMBOL_KINDS.map(() => '?').join(', ');
2546
2903
  let nodes = db
@@ -2550,6 +2907,7 @@ function whereSymbolImpl(db, target, noTests) {
2550
2907
  .all(`%${target}%`, ...ALL_SYMBOL_KINDS);
2551
2908
  if (noTests) nodes = nodes.filter((n) => !isTestFile(n.file));
2552
2909
 
2910
+ const hc = new Map();
2553
2911
  return nodes.map((node) => {
2554
2912
  const crossFileCallers = db
2555
2913
  .prepare(
@@ -2568,11 +2926,7 @@ function whereSymbolImpl(db, target, noTests) {
2568
2926
  if (noTests) uses = uses.filter((u) => !isTestFile(u.file));
2569
2927
 
2570
2928
  return {
2571
- name: node.name,
2572
- kind: node.kind,
2573
- file: node.file,
2574
- line: node.line,
2575
- role: node.role || null,
2929
+ ...normalizeSymbol(node, db, hc),
2576
2930
  exported,
2577
2931
  uses: uses.map((u) => ({ name: u.name, file: u.file, line: u.line })),
2578
2932
  };
@@ -2622,6 +2976,7 @@ function whereFileImpl(db, target) {
2622
2976
 
2623
2977
  return {
2624
2978
  file: fn.file,
2979
+ fileHash: getFileHash(db, fn.file),
2625
2980
  symbols: symbols.map((s) => ({ name: s.name, kind: s.kind, line: s.line })),
2626
2981
  imports,
2627
2982
  importedBy,
@@ -2645,8 +3000,7 @@ export function whereData(target, customDbPath, opts = {}) {
2645
3000
  export function where(target, customDbPath, opts = {}) {
2646
3001
  const data = whereData(target, customDbPath, opts);
2647
3002
  if (opts.ndjson) {
2648
- if (data._pagination) console.log(JSON.stringify({ _meta: data._pagination }));
2649
- for (const r of data.results) console.log(JSON.stringify(r));
3003
+ printNdjson(data, 'results');
2650
3004
  return;
2651
3005
  }
2652
3006
  if (opts.json) {
@@ -2718,7 +3072,7 @@ export function rolesData(customDbPath, opts = {}) {
2718
3072
 
2719
3073
  let rows = db
2720
3074
  .prepare(
2721
- `SELECT name, kind, file, line, role FROM nodes WHERE ${conditions.join(' AND ')} ORDER BY role, file, line`,
3075
+ `SELECT name, kind, file, line, end_line, role FROM nodes WHERE ${conditions.join(' AND ')} ORDER BY role, file, line`,
2722
3076
  )
2723
3077
  .all(...params);
2724
3078
 
@@ -2729,16 +3083,17 @@ export function rolesData(customDbPath, opts = {}) {
2729
3083
  summary[r.role] = (summary[r.role] || 0) + 1;
2730
3084
  }
2731
3085
 
3086
+ const hc = new Map();
3087
+ const symbols = rows.map((r) => normalizeSymbol(r, db, hc));
2732
3088
  db.close();
2733
- const base = { count: rows.length, summary, symbols: rows };
3089
+ const base = { count: symbols.length, summary, symbols };
2734
3090
  return paginateResult(base, 'symbols', { limit: opts.limit, offset: opts.offset });
2735
3091
  }
2736
3092
 
2737
3093
  export function roles(customDbPath, opts = {}) {
2738
3094
  const data = rolesData(customDbPath, opts);
2739
3095
  if (opts.ndjson) {
2740
- if (data._pagination) console.log(JSON.stringify({ _meta: data._pagination }));
2741
- for (const s of data.symbols) console.log(JSON.stringify(s));
3096
+ printNdjson(data, 'symbols');
2742
3097
  return;
2743
3098
  }
2744
3099
  if (opts.json) {
@@ -2777,8 +3132,172 @@ export function roles(customDbPath, opts = {}) {
2777
3132
  }
2778
3133
  }
2779
3134
 
3135
+ // ─── exportsData ─────────────────────────────────────────────────────
3136
+
3137
+ function exportsFileImpl(db, target, noTests, getFileLines) {
3138
+ const fileNodes = db
3139
+ .prepare(`SELECT * FROM nodes WHERE file LIKE ? AND kind = 'file'`)
3140
+ .all(`%${target}%`);
3141
+ if (fileNodes.length === 0) return [];
3142
+
3143
+ return fileNodes.map((fn) => {
3144
+ const symbols = db
3145
+ .prepare(`SELECT * FROM nodes WHERE file = ? AND kind != 'file' ORDER BY line`)
3146
+ .all(fn.file);
3147
+
3148
+ // IDs of symbols that have incoming calls from other files (exported)
3149
+ const exportedIds = new Set(
3150
+ db
3151
+ .prepare(
3152
+ `SELECT DISTINCT e.target_id FROM edges e
3153
+ JOIN nodes caller ON e.source_id = caller.id
3154
+ JOIN nodes target ON e.target_id = target.id
3155
+ WHERE target.file = ? AND caller.file != ? AND e.kind = 'calls'`,
3156
+ )
3157
+ .all(fn.file, fn.file)
3158
+ .map((r) => r.target_id),
3159
+ );
3160
+
3161
+ const exported = symbols.filter((s) => exportedIds.has(s.id));
3162
+ const internalCount = symbols.length - exported.length;
3163
+
3164
+ const results = exported.map((s) => {
3165
+ const fileLines = getFileLines(fn.file);
3166
+
3167
+ let consumers = db
3168
+ .prepare(
3169
+ `SELECT n.name, n.file, n.line FROM edges e JOIN nodes n ON e.source_id = n.id
3170
+ WHERE e.target_id = ? AND e.kind = 'calls'`,
3171
+ )
3172
+ .all(s.id);
3173
+ if (noTests) consumers = consumers.filter((c) => !isTestFile(c.file));
3174
+
3175
+ return {
3176
+ name: s.name,
3177
+ kind: s.kind,
3178
+ line: s.line,
3179
+ endLine: s.end_line ?? null,
3180
+ role: s.role || null,
3181
+ signature: fileLines ? extractSignature(fileLines, s.line) : null,
3182
+ summary: fileLines ? extractSummary(fileLines, s.line) : null,
3183
+ consumers: consumers.map((c) => ({ name: c.name, file: c.file, line: c.line })),
3184
+ consumerCount: consumers.length,
3185
+ };
3186
+ });
3187
+
3188
+ // Files that re-export this file (barrel → this file)
3189
+ const reexports = db
3190
+ .prepare(
3191
+ `SELECT DISTINCT n.file FROM edges e JOIN nodes n ON e.source_id = n.id
3192
+ WHERE e.target_id = ? AND e.kind = 'reexports'`,
3193
+ )
3194
+ .all(fn.id)
3195
+ .map((r) => ({ file: r.file }));
3196
+
3197
+ return {
3198
+ file: fn.file,
3199
+ results,
3200
+ reexports,
3201
+ totalExported: exported.length,
3202
+ totalInternal: internalCount,
3203
+ };
3204
+ });
3205
+ }
3206
+
3207
+ export function exportsData(file, customDbPath, opts = {}) {
3208
+ const db = openReadonlyOrFail(customDbPath);
3209
+ const noTests = opts.noTests || false;
3210
+
3211
+ const dbFilePath = findDbPath(customDbPath);
3212
+ const repoRoot = path.resolve(path.dirname(dbFilePath), '..');
3213
+
3214
+ const fileCache = new Map();
3215
+ function getFileLines(file) {
3216
+ if (fileCache.has(file)) return fileCache.get(file);
3217
+ try {
3218
+ const absPath = safePath(repoRoot, file);
3219
+ if (!absPath) {
3220
+ fileCache.set(file, null);
3221
+ return null;
3222
+ }
3223
+ const lines = fs.readFileSync(absPath, 'utf-8').split('\n');
3224
+ fileCache.set(file, lines);
3225
+ return lines;
3226
+ } catch {
3227
+ fileCache.set(file, null);
3228
+ return null;
3229
+ }
3230
+ }
3231
+
3232
+ const fileResults = exportsFileImpl(db, file, noTests, getFileLines);
3233
+ db.close();
3234
+
3235
+ if (fileResults.length === 0) {
3236
+ return paginateResult(
3237
+ { file, results: [], reexports: [], totalExported: 0, totalInternal: 0 },
3238
+ 'results',
3239
+ { limit: opts.limit, offset: opts.offset },
3240
+ );
3241
+ }
3242
+
3243
+ // For single-file match return flat; for multi-match return first (like explainData)
3244
+ const first = fileResults[0];
3245
+ const base = {
3246
+ file: first.file,
3247
+ results: first.results,
3248
+ reexports: first.reexports,
3249
+ totalExported: first.totalExported,
3250
+ totalInternal: first.totalInternal,
3251
+ };
3252
+ return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset });
3253
+ }
3254
+
3255
+ export function fileExports(file, customDbPath, opts = {}) {
3256
+ const data = exportsData(file, customDbPath, opts);
3257
+ if (opts.ndjson) {
3258
+ printNdjson(data, 'results');
3259
+ return;
3260
+ }
3261
+ if (opts.json) {
3262
+ console.log(JSON.stringify(data, null, 2));
3263
+ return;
3264
+ }
3265
+
3266
+ if (data.results.length === 0) {
3267
+ console.log(`No exported symbols found for "${file}". Run "codegraph build" first.`);
3268
+ return;
3269
+ }
3270
+
3271
+ console.log(
3272
+ `\n# ${data.file} — ${data.totalExported} exported, ${data.totalInternal} internal\n`,
3273
+ );
3274
+
3275
+ for (const sym of data.results) {
3276
+ const icon = kindIcon(sym.kind);
3277
+ const sig = sym.signature?.params ? `(${sym.signature.params})` : '';
3278
+ const role = sym.role ? ` [${sym.role}]` : '';
3279
+ console.log(` ${icon} ${sym.name}${sig}${role} :${sym.line}`);
3280
+ if (sym.consumers.length === 0) {
3281
+ console.log(' (no consumers)');
3282
+ } else {
3283
+ for (const c of sym.consumers) {
3284
+ console.log(` <- ${c.name} (${c.file}:${c.line})`);
3285
+ }
3286
+ }
3287
+ }
3288
+
3289
+ if (data.reexports.length > 0) {
3290
+ console.log(`\n Re-exports: ${data.reexports.map((r) => r.file).join(', ')}`);
3291
+ }
3292
+ console.log();
3293
+ }
3294
+
2780
3295
  export function fnImpact(name, customDbPath, opts = {}) {
2781
3296
  const data = fnImpactData(name, customDbPath, opts);
3297
+ if (opts.ndjson) {
3298
+ printNdjson(data, 'results');
3299
+ return;
3300
+ }
2782
3301
  if (opts.json) {
2783
3302
  console.log(JSON.stringify(data, null, 2));
2784
3303
  return;
@@ -2811,6 +3330,10 @@ export function diffImpact(customDbPath, opts = {}) {
2811
3330
  return;
2812
3331
  }
2813
3332
  const data = diffImpactData(customDbPath, opts);
3333
+ if (opts.ndjson) {
3334
+ printNdjson(data, 'affectedFunctions');
3335
+ return;
3336
+ }
2814
3337
  if (opts.json || opts.format === 'json') {
2815
3338
  console.log(JSON.stringify(data, null, 2));
2816
3339
  return;
@@ -2845,11 +3368,28 @@ export function diffImpact(customDbPath, opts = {}) {
2845
3368
  );
2846
3369
  }
2847
3370
  }
3371
+ if (data.ownership) {
3372
+ console.log(`\n Affected owners: ${data.ownership.affectedOwners.join(', ')}`);
3373
+ console.log(` Suggested reviewers: ${data.ownership.suggestedReviewers.join(', ')}`);
3374
+ }
3375
+ if (data.boundaryViolations && data.boundaryViolations.length > 0) {
3376
+ console.log(`\n Boundary violations (${data.boundaryViolationCount}):\n`);
3377
+ for (const v of data.boundaryViolations) {
3378
+ console.log(` [${v.name}] ${v.file} -> ${v.targetFile}`);
3379
+ if (v.message) console.log(` ${v.message}`);
3380
+ }
3381
+ }
2848
3382
  if (data.summary) {
2849
3383
  let summaryLine = `\n Summary: ${data.summary.functionsChanged} functions changed -> ${data.summary.callersAffected} callers affected across ${data.summary.filesAffected} files`;
2850
3384
  if (data.summary.historicallyCoupledCount > 0) {
2851
3385
  summaryLine += `, ${data.summary.historicallyCoupledCount} historically coupled`;
2852
3386
  }
3387
+ if (data.summary.ownersAffected > 0) {
3388
+ summaryLine += `, ${data.summary.ownersAffected} owners affected`;
3389
+ }
3390
+ if (data.summary.boundaryViolationCount > 0) {
3391
+ summaryLine += `, ${data.summary.boundaryViolationCount} boundary violations`;
3392
+ }
2853
3393
  console.log(`${summaryLine}\n`);
2854
3394
  }
2855
3395
  }