@optave/codegraph 2.1.0 → 2.1.1-dev.00f091c

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
@@ -945,6 +945,402 @@ export function fnDeps(name, customDbPath, opts = {}) {
945
945
  }
946
946
  }
947
947
 
948
+ // ─── Context helpers (private) ──────────────────────────────────────────
949
+
950
+ function readSourceRange(repoRoot, file, startLine, endLine) {
951
+ try {
952
+ const absPath = path.resolve(repoRoot, file);
953
+ const content = fs.readFileSync(absPath, 'utf-8');
954
+ const lines = content.split('\n');
955
+ const start = Math.max(0, (startLine || 1) - 1);
956
+ const end = Math.min(lines.length, endLine || startLine + 50);
957
+ return lines.slice(start, end).join('\n');
958
+ } catch {
959
+ return null;
960
+ }
961
+ }
962
+
963
+ function extractSummary(fileLines, line) {
964
+ if (!fileLines || !line || line <= 1) return null;
965
+ const idx = line - 2; // line above the definition (0-indexed)
966
+ // Scan up to 10 lines above for JSDoc or comment
967
+ let jsdocEnd = -1;
968
+ for (let i = idx; i >= Math.max(0, idx - 10); i--) {
969
+ const trimmed = fileLines[i].trim();
970
+ if (trimmed.endsWith('*/')) {
971
+ jsdocEnd = i;
972
+ break;
973
+ }
974
+ if (trimmed.startsWith('//') || trimmed.startsWith('#')) {
975
+ // Single-line comment immediately above
976
+ const text = trimmed
977
+ .replace(/^\/\/\s*/, '')
978
+ .replace(/^#\s*/, '')
979
+ .trim();
980
+ return text.length > 100 ? `${text.slice(0, 100)}...` : text;
981
+ }
982
+ if (trimmed !== '' && !trimmed.startsWith('*') && !trimmed.startsWith('/*')) break;
983
+ }
984
+ if (jsdocEnd >= 0) {
985
+ // Find opening /**
986
+ for (let i = jsdocEnd; i >= Math.max(0, jsdocEnd - 20); i--) {
987
+ if (fileLines[i].trim().startsWith('/**')) {
988
+ // Extract first non-tag, non-empty line
989
+ for (let j = i + 1; j <= jsdocEnd; j++) {
990
+ const docLine = fileLines[j]
991
+ .trim()
992
+ .replace(/^\*\s?/, '')
993
+ .trim();
994
+ if (docLine && !docLine.startsWith('@') && docLine !== '/' && docLine !== '*/') {
995
+ return docLine.length > 100 ? `${docLine.slice(0, 100)}...` : docLine;
996
+ }
997
+ }
998
+ break;
999
+ }
1000
+ }
1001
+ }
1002
+ return null;
1003
+ }
1004
+
1005
+ function extractSignature(fileLines, line) {
1006
+ if (!fileLines || !line) return null;
1007
+ const idx = line - 1;
1008
+ // Gather up to 5 lines to handle multi-line params
1009
+ const chunk = fileLines.slice(idx, Math.min(fileLines.length, idx + 5)).join('\n');
1010
+
1011
+ // JS/TS: function name(params) or (params) => or async function
1012
+ let m = chunk.match(
1013
+ /(?:export\s+)?(?:async\s+)?function\s*\*?\s*\w*\s*\(([^)]*)\)\s*(?::\s*([^\n{]+))?/,
1014
+ );
1015
+ if (m) {
1016
+ return {
1017
+ params: m[1].trim() || null,
1018
+ returnType: m[2] ? m[2].trim().replace(/\s*\{$/, '') : null,
1019
+ };
1020
+ }
1021
+ // Arrow: const name = (params) => or (params):ReturnType =>
1022
+ m = chunk.match(/=\s*(?:async\s+)?\(([^)]*)\)\s*(?::\s*([^=>\n{]+))?\s*=>/);
1023
+ if (m) {
1024
+ return {
1025
+ params: m[1].trim() || null,
1026
+ returnType: m[2] ? m[2].trim() : null,
1027
+ };
1028
+ }
1029
+ // Python: def name(params) -> return:
1030
+ m = chunk.match(/def\s+\w+\s*\(([^)]*)\)\s*(?:->\s*([^:\n]+))?/);
1031
+ if (m) {
1032
+ return {
1033
+ params: m[1].trim() || null,
1034
+ returnType: m[2] ? m[2].trim() : null,
1035
+ };
1036
+ }
1037
+ // Go: func (recv) name(params) (returns)
1038
+ m = chunk.match(/func\s+(?:\([^)]*\)\s+)?\w+\s*\(([^)]*)\)\s*(?:\(([^)]+)\)|(\w[^\n{]*))?/);
1039
+ if (m) {
1040
+ return {
1041
+ params: m[1].trim() || null,
1042
+ returnType: (m[2] || m[3] || '').trim() || null,
1043
+ };
1044
+ }
1045
+ // Rust: fn name(params) -> ReturnType
1046
+ m = chunk.match(/fn\s+\w+\s*\(([^)]*)\)\s*(?:->\s*([^\n{]+))?/);
1047
+ if (m) {
1048
+ return {
1049
+ params: m[1].trim() || null,
1050
+ returnType: m[2] ? m[2].trim() : null,
1051
+ };
1052
+ }
1053
+ return null;
1054
+ }
1055
+
1056
+ // ─── contextData ────────────────────────────────────────────────────────
1057
+
1058
+ export function contextData(name, customDbPath, opts = {}) {
1059
+ const db = openReadonlyOrFail(customDbPath);
1060
+ const depth = opts.depth || 0;
1061
+ const noSource = opts.noSource || false;
1062
+ const noTests = opts.noTests || false;
1063
+ const includeTests = opts.includeTests || false;
1064
+
1065
+ const dbPath = findDbPath(customDbPath);
1066
+ const repoRoot = path.resolve(path.dirname(dbPath), '..');
1067
+
1068
+ let nodes = db
1069
+ .prepare(
1070
+ `SELECT * FROM nodes WHERE name LIKE ? AND kind IN ('function', 'method', 'class') ORDER BY file, line`,
1071
+ )
1072
+ .all(`%${name}%`);
1073
+ if (noTests) nodes = nodes.filter((n) => !isTestFile(n.file));
1074
+ if (nodes.length === 0) {
1075
+ db.close();
1076
+ return { name, results: [] };
1077
+ }
1078
+
1079
+ // Limit to first 5 results
1080
+ nodes = nodes.slice(0, 5);
1081
+
1082
+ // File-lines cache to avoid re-reading the same file
1083
+ const fileCache = new Map();
1084
+ function getFileLines(file) {
1085
+ if (fileCache.has(file)) return fileCache.get(file);
1086
+ try {
1087
+ const absPath = path.resolve(repoRoot, file);
1088
+ const lines = fs.readFileSync(absPath, 'utf-8').split('\n');
1089
+ fileCache.set(file, lines);
1090
+ return lines;
1091
+ } catch {
1092
+ fileCache.set(file, null);
1093
+ return null;
1094
+ }
1095
+ }
1096
+
1097
+ const results = nodes.map((node) => {
1098
+ const fileLines = getFileLines(node.file);
1099
+
1100
+ // Source
1101
+ const source = noSource ? null : readSourceRange(repoRoot, node.file, node.line, node.end_line);
1102
+
1103
+ // Signature
1104
+ const signature = fileLines ? extractSignature(fileLines, node.line) : null;
1105
+
1106
+ // Callees
1107
+ const calleeRows = db
1108
+ .prepare(
1109
+ `SELECT n.id, n.name, n.kind, n.file, n.line, n.end_line
1110
+ FROM edges e JOIN nodes n ON e.target_id = n.id
1111
+ WHERE e.source_id = ? AND e.kind = 'calls'`,
1112
+ )
1113
+ .all(node.id);
1114
+ const filteredCallees = noTests ? calleeRows.filter((c) => !isTestFile(c.file)) : calleeRows;
1115
+
1116
+ const callees = filteredCallees.map((c) => {
1117
+ const cLines = getFileLines(c.file);
1118
+ const summary = cLines ? extractSummary(cLines, c.line) : null;
1119
+ let calleeSource = null;
1120
+ if (depth >= 1) {
1121
+ calleeSource = readSourceRange(repoRoot, c.file, c.line, c.end_line);
1122
+ }
1123
+ return {
1124
+ name: c.name,
1125
+ kind: c.kind,
1126
+ file: c.file,
1127
+ line: c.line,
1128
+ endLine: c.end_line || null,
1129
+ summary,
1130
+ source: calleeSource,
1131
+ };
1132
+ });
1133
+
1134
+ // Deep callee expansion via BFS (depth > 1, capped at 5)
1135
+ if (depth > 1) {
1136
+ const visited = new Set(filteredCallees.map((c) => c.id));
1137
+ visited.add(node.id);
1138
+ let frontier = filteredCallees.map((c) => c.id);
1139
+ const maxDepth = Math.min(depth, 5);
1140
+ for (let d = 2; d <= maxDepth; d++) {
1141
+ const nextFrontier = [];
1142
+ for (const fid of frontier) {
1143
+ const deeper = db
1144
+ .prepare(
1145
+ `SELECT n.id, n.name, n.kind, n.file, n.line, n.end_line
1146
+ FROM edges e JOIN nodes n ON e.target_id = n.id
1147
+ WHERE e.source_id = ? AND e.kind = 'calls'`,
1148
+ )
1149
+ .all(fid);
1150
+ for (const c of deeper) {
1151
+ if (!visited.has(c.id) && (!noTests || !isTestFile(c.file))) {
1152
+ visited.add(c.id);
1153
+ nextFrontier.push(c.id);
1154
+ const cLines = getFileLines(c.file);
1155
+ callees.push({
1156
+ name: c.name,
1157
+ kind: c.kind,
1158
+ file: c.file,
1159
+ line: c.line,
1160
+ endLine: c.end_line || null,
1161
+ summary: cLines ? extractSummary(cLines, c.line) : null,
1162
+ source: readSourceRange(repoRoot, c.file, c.line, c.end_line),
1163
+ });
1164
+ }
1165
+ }
1166
+ }
1167
+ frontier = nextFrontier;
1168
+ if (frontier.length === 0) break;
1169
+ }
1170
+ }
1171
+
1172
+ // Callers
1173
+ let callerRows = db
1174
+ .prepare(
1175
+ `SELECT n.name, n.kind, n.file, n.line
1176
+ FROM edges e JOIN nodes n ON e.source_id = n.id
1177
+ WHERE e.target_id = ? AND e.kind = 'calls'`,
1178
+ )
1179
+ .all(node.id);
1180
+
1181
+ // Method hierarchy resolution
1182
+ if (node.kind === 'method' && node.name.includes('.')) {
1183
+ const methodName = node.name.split('.').pop();
1184
+ const relatedMethods = resolveMethodViaHierarchy(db, methodName);
1185
+ for (const rm of relatedMethods) {
1186
+ if (rm.id === node.id) continue;
1187
+ const extraCallers = db
1188
+ .prepare(
1189
+ `SELECT n.name, n.kind, n.file, n.line
1190
+ FROM edges e JOIN nodes n ON e.source_id = n.id
1191
+ WHERE e.target_id = ? AND e.kind = 'calls'`,
1192
+ )
1193
+ .all(rm.id);
1194
+ callerRows.push(...extraCallers.map((c) => ({ ...c, viaHierarchy: rm.name })));
1195
+ }
1196
+ }
1197
+ if (noTests) callerRows = callerRows.filter((c) => !isTestFile(c.file));
1198
+
1199
+ const callers = callerRows.map((c) => ({
1200
+ name: c.name,
1201
+ kind: c.kind,
1202
+ file: c.file,
1203
+ line: c.line,
1204
+ viaHierarchy: c.viaHierarchy || undefined,
1205
+ }));
1206
+
1207
+ // Related tests: callers that live in test files
1208
+ const testCallerRows = db
1209
+ .prepare(
1210
+ `SELECT n.name, n.kind, n.file, n.line
1211
+ FROM edges e JOIN nodes n ON e.source_id = n.id
1212
+ WHERE e.target_id = ? AND e.kind = 'calls'`,
1213
+ )
1214
+ .all(node.id);
1215
+ const testCallers = testCallerRows.filter((c) => isTestFile(c.file));
1216
+
1217
+ const testsByFile = new Map();
1218
+ for (const tc of testCallers) {
1219
+ if (!testsByFile.has(tc.file)) testsByFile.set(tc.file, []);
1220
+ testsByFile.get(tc.file).push(tc);
1221
+ }
1222
+
1223
+ const relatedTests = [];
1224
+ for (const [file] of testsByFile) {
1225
+ const tLines = getFileLines(file);
1226
+ const testNames = [];
1227
+ if (tLines) {
1228
+ for (const tl of tLines) {
1229
+ const tm = tl.match(/(?:it|test|describe)\s*\(\s*['"`]([^'"`]+)['"`]/);
1230
+ if (tm) testNames.push(tm[1]);
1231
+ }
1232
+ }
1233
+ const testSource = includeTests && tLines ? tLines.join('\n') : undefined;
1234
+ relatedTests.push({
1235
+ file,
1236
+ testCount: testNames.length,
1237
+ testNames,
1238
+ source: testSource,
1239
+ });
1240
+ }
1241
+
1242
+ return {
1243
+ name: node.name,
1244
+ kind: node.kind,
1245
+ file: node.file,
1246
+ line: node.line,
1247
+ endLine: node.end_line || null,
1248
+ source,
1249
+ signature,
1250
+ callees,
1251
+ callers,
1252
+ relatedTests,
1253
+ };
1254
+ });
1255
+
1256
+ db.close();
1257
+ return { name, results };
1258
+ }
1259
+
1260
+ export function context(name, customDbPath, opts = {}) {
1261
+ const data = contextData(name, customDbPath, opts);
1262
+ if (opts.json) {
1263
+ console.log(JSON.stringify(data, null, 2));
1264
+ return;
1265
+ }
1266
+ if (data.results.length === 0) {
1267
+ console.log(`No function/method/class matching "${name}"`);
1268
+ return;
1269
+ }
1270
+
1271
+ for (const r of data.results) {
1272
+ const lineRange = r.endLine ? `${r.line}-${r.endLine}` : `${r.line}`;
1273
+ console.log(`\n# ${r.name} (${r.kind}) — ${r.file}:${lineRange}\n`);
1274
+
1275
+ // Signature
1276
+ if (r.signature) {
1277
+ console.log('## Type/Shape Info');
1278
+ if (r.signature.params != null) console.log(` Parameters: (${r.signature.params})`);
1279
+ if (r.signature.returnType) console.log(` Returns: ${r.signature.returnType}`);
1280
+ console.log();
1281
+ }
1282
+
1283
+ // Source
1284
+ if (r.source) {
1285
+ console.log('## Source');
1286
+ for (const line of r.source.split('\n')) {
1287
+ console.log(` ${line}`);
1288
+ }
1289
+ console.log();
1290
+ }
1291
+
1292
+ // Callees
1293
+ if (r.callees.length > 0) {
1294
+ console.log(`## Direct Dependencies (${r.callees.length})`);
1295
+ for (const c of r.callees) {
1296
+ const summary = c.summary ? ` — ${c.summary}` : '';
1297
+ console.log(` ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}${summary}`);
1298
+ if (c.source) {
1299
+ for (const line of c.source.split('\n').slice(0, 10)) {
1300
+ console.log(` | ${line}`);
1301
+ }
1302
+ }
1303
+ }
1304
+ console.log();
1305
+ }
1306
+
1307
+ // Callers
1308
+ if (r.callers.length > 0) {
1309
+ console.log(`## Callers (${r.callers.length})`);
1310
+ for (const c of r.callers) {
1311
+ const via = c.viaHierarchy ? ` (via ${c.viaHierarchy})` : '';
1312
+ console.log(` ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}${via}`);
1313
+ }
1314
+ console.log();
1315
+ }
1316
+
1317
+ // Related tests
1318
+ if (r.relatedTests.length > 0) {
1319
+ console.log('## Related Tests');
1320
+ for (const t of r.relatedTests) {
1321
+ console.log(` ${t.file} — ${t.testCount} tests`);
1322
+ for (const tn of t.testNames) {
1323
+ console.log(` - ${tn}`);
1324
+ }
1325
+ if (t.source) {
1326
+ console.log(' Source:');
1327
+ for (const line of t.source.split('\n').slice(0, 20)) {
1328
+ console.log(` | ${line}`);
1329
+ }
1330
+ }
1331
+ }
1332
+ console.log();
1333
+ }
1334
+
1335
+ if (r.callees.length === 0 && r.callers.length === 0 && r.relatedTests.length === 0) {
1336
+ console.log(
1337
+ ' (no call edges or tests found — may be invoked dynamically or via re-exports)',
1338
+ );
1339
+ console.log();
1340
+ }
1341
+ }
1342
+ }
1343
+
948
1344
  export function fnImpact(name, customDbPath, opts = {}) {
949
1345
  const data = fnImpactData(name, customDbPath, opts);
950
1346
  if (opts.json) {
package/src/watcher.js CHANGED
@@ -2,6 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { EXTENSIONS, IGNORE_DIRS, normalizePath } from './constants.js';
4
4
  import { initSchema, openDb } from './db.js';
5
+ import { appendJournalEntries } from './journal.js';
5
6
  import { info, warn } from './logger.js';
6
7
  import { createParseTreeCache, getActiveEngine, parseFileIncremental } from './parser.js';
7
8
  import { resolveImportPath } from './resolve.js';
@@ -205,6 +206,19 @@ export async function watchProject(rootDir, opts = {}) {
205
206
  }
206
207
  const updates = results;
207
208
 
209
+ // Append processed files to journal for Tier 0 detection on next build
210
+ if (updates.length > 0) {
211
+ const entries = updates.map((r) => ({
212
+ file: r.file,
213
+ deleted: r.deleted || false,
214
+ }));
215
+ try {
216
+ appendJournalEntries(rootDir, entries);
217
+ } catch {
218
+ /* journal write failure is non-fatal */
219
+ }
220
+ }
221
+
208
222
  for (const r of updates) {
209
223
  const nodeDelta = r.nodesAdded - r.nodesRemoved;
210
224
  const nodeStr = nodeDelta >= 0 ? `+${nodeDelta}` : `${nodeDelta}`;
@@ -234,6 +248,17 @@ export async function watchProject(rootDir, opts = {}) {
234
248
  process.on('SIGINT', () => {
235
249
  console.log('\nStopping watcher...');
236
250
  watcher.close();
251
+ // Flush any pending file paths to journal before exit
252
+ if (pending.size > 0) {
253
+ const entries = [...pending].map((filePath) => ({
254
+ file: normalizePath(path.relative(rootDir, filePath)),
255
+ }));
256
+ try {
257
+ appendJournalEntries(rootDir, entries);
258
+ } catch {
259
+ /* best-effort */
260
+ }
261
+ }
237
262
  if (cache) cache.clear();
238
263
  db.close();
239
264
  process.exit(0);