@optave/codegraph 2.3.1-dev.1aeea34 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/config.js CHANGED
@@ -24,6 +24,20 @@ export const DEFAULTS = {
24
24
  llm: { provider: null, model: null, baseUrl: null, apiKey: null, apiKeyCommand: null },
25
25
  search: { defaultMinScore: 0.2, rrfK: 60, topK: 15 },
26
26
  ci: { failOnCycles: false, impactThreshold: null },
27
+ manifesto: {
28
+ rules: {
29
+ cognitive: { warn: 15 },
30
+ cyclomatic: { warn: 10 },
31
+ maxNesting: { warn: 4 },
32
+ maintainabilityIndex: { warn: 20, fail: null },
33
+ importCount: { warn: null, fail: null },
34
+ exportCount: { warn: null, fail: null },
35
+ lineCount: { warn: null, fail: null },
36
+ fanIn: { warn: null, fail: null },
37
+ fanOut: { warn: null, fail: null },
38
+ noCycles: { warn: null, fail: null },
39
+ },
40
+ },
27
41
  coChange: {
28
42
  since: '1 year ago',
29
43
  minSupport: 3,
@@ -45,7 +59,12 @@ export function loadConfig(cwd) {
45
59
  const raw = fs.readFileSync(filePath, 'utf-8');
46
60
  const config = JSON.parse(raw);
47
61
  debug(`Loaded config from ${filePath}`);
48
- return resolveSecrets(applyEnvOverrides(mergeConfig(DEFAULTS, config)));
62
+ const merged = mergeConfig(DEFAULTS, config);
63
+ if ('excludeTests' in config && !(config.query && 'excludeTests' in config.query)) {
64
+ merged.query.excludeTests = Boolean(config.excludeTests);
65
+ }
66
+ delete merged.excludeTests;
67
+ return resolveSecrets(applyEnvOverrides(merged));
49
68
  } catch (err) {
50
69
  debug(`Failed to parse config ${filePath}: ${err.message}`);
51
70
  }
package/src/db.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import Database from 'better-sqlite3';
4
- import { debug } from './logger.js';
4
+ import { debug, warn } from './logger.js';
5
5
 
6
6
  // ─── Schema Migrations ─────────────────────────────────────────────────
7
7
  export const MIGRATIONS = [
@@ -101,16 +101,126 @@ export const MIGRATIONS = [
101
101
  );
102
102
  `,
103
103
  },
104
+ {
105
+ version: 7,
106
+ up: `
107
+ CREATE TABLE IF NOT EXISTS build_meta (
108
+ key TEXT PRIMARY KEY,
109
+ value TEXT NOT NULL
110
+ );
111
+ `,
112
+ },
113
+ {
114
+ version: 8,
115
+ up: `
116
+ CREATE TABLE IF NOT EXISTS function_complexity (
117
+ node_id INTEGER PRIMARY KEY,
118
+ cognitive INTEGER NOT NULL,
119
+ cyclomatic INTEGER NOT NULL,
120
+ max_nesting INTEGER NOT NULL,
121
+ FOREIGN KEY(node_id) REFERENCES nodes(id)
122
+ );
123
+ CREATE INDEX IF NOT EXISTS idx_fc_cognitive ON function_complexity(cognitive DESC);
124
+ CREATE INDEX IF NOT EXISTS idx_fc_cyclomatic ON function_complexity(cyclomatic DESC);
125
+ `,
126
+ },
127
+ {
128
+ version: 9,
129
+ up: `
130
+ ALTER TABLE function_complexity ADD COLUMN loc INTEGER DEFAULT 0;
131
+ ALTER TABLE function_complexity ADD COLUMN sloc INTEGER DEFAULT 0;
132
+ ALTER TABLE function_complexity ADD COLUMN comment_lines INTEGER DEFAULT 0;
133
+ ALTER TABLE function_complexity ADD COLUMN halstead_n1 INTEGER DEFAULT 0;
134
+ ALTER TABLE function_complexity ADD COLUMN halstead_n2 INTEGER DEFAULT 0;
135
+ ALTER TABLE function_complexity ADD COLUMN halstead_big_n1 INTEGER DEFAULT 0;
136
+ ALTER TABLE function_complexity ADD COLUMN halstead_big_n2 INTEGER DEFAULT 0;
137
+ ALTER TABLE function_complexity ADD COLUMN halstead_vocabulary INTEGER DEFAULT 0;
138
+ ALTER TABLE function_complexity ADD COLUMN halstead_length INTEGER DEFAULT 0;
139
+ ALTER TABLE function_complexity ADD COLUMN halstead_volume REAL DEFAULT 0;
140
+ ALTER TABLE function_complexity ADD COLUMN halstead_difficulty REAL DEFAULT 0;
141
+ ALTER TABLE function_complexity ADD COLUMN halstead_effort REAL DEFAULT 0;
142
+ ALTER TABLE function_complexity ADD COLUMN halstead_bugs REAL DEFAULT 0;
143
+ ALTER TABLE function_complexity ADD COLUMN maintainability_index REAL DEFAULT 0;
144
+ CREATE INDEX IF NOT EXISTS idx_fc_mi ON function_complexity(maintainability_index ASC);
145
+ `,
146
+ },
104
147
  ];
105
148
 
149
+ export function getBuildMeta(db, key) {
150
+ try {
151
+ const row = db.prepare('SELECT value FROM build_meta WHERE key = ?').get(key);
152
+ return row ? row.value : null;
153
+ } catch {
154
+ return null;
155
+ }
156
+ }
157
+
158
+ export function setBuildMeta(db, entries) {
159
+ const upsert = db.prepare('INSERT OR REPLACE INTO build_meta (key, value) VALUES (?, ?)');
160
+ const tx = db.transaction(() => {
161
+ for (const [key, value] of Object.entries(entries)) {
162
+ upsert.run(key, String(value));
163
+ }
164
+ });
165
+ tx();
166
+ }
167
+
106
168
  export function openDb(dbPath) {
107
169
  const dir = path.dirname(dbPath);
108
170
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
171
+ acquireAdvisoryLock(dbPath);
109
172
  const db = new Database(dbPath);
110
173
  db.pragma('journal_mode = WAL');
174
+ db.pragma('busy_timeout = 5000');
175
+ db.__lockPath = `${dbPath}.lock`;
111
176
  return db;
112
177
  }
113
178
 
179
+ export function closeDb(db) {
180
+ db.close();
181
+ if (db.__lockPath) releaseAdvisoryLock(db.__lockPath);
182
+ }
183
+
184
+ function isProcessAlive(pid) {
185
+ try {
186
+ process.kill(pid, 0);
187
+ return true;
188
+ } catch {
189
+ return false;
190
+ }
191
+ }
192
+
193
+ function acquireAdvisoryLock(dbPath) {
194
+ const lockPath = `${dbPath}.lock`;
195
+ try {
196
+ if (fs.existsSync(lockPath)) {
197
+ const content = fs.readFileSync(lockPath, 'utf-8').trim();
198
+ const pid = Number(content);
199
+ if (pid && pid !== process.pid && isProcessAlive(pid)) {
200
+ warn(`Another process (PID ${pid}) may be using this database. Proceeding with caution.`);
201
+ }
202
+ }
203
+ } catch {
204
+ /* ignore read errors */
205
+ }
206
+ try {
207
+ fs.writeFileSync(lockPath, String(process.pid), 'utf-8');
208
+ } catch {
209
+ /* best-effort */
210
+ }
211
+ }
212
+
213
+ function releaseAdvisoryLock(lockPath) {
214
+ try {
215
+ const content = fs.readFileSync(lockPath, 'utf-8').trim();
216
+ if (Number(content) === process.pid) {
217
+ fs.unlinkSync(lockPath);
218
+ }
219
+ } catch {
220
+ /* ignore */
221
+ }
222
+ }
223
+
114
224
  export function initSchema(db) {
115
225
  db.exec(`CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL DEFAULT 0)`);
116
226
 
package/src/embedder.js CHANGED
@@ -1,8 +1,9 @@
1
+ import { execFileSync } from 'node:child_process';
1
2
  import fs from 'node:fs';
2
3
  import path from 'node:path';
3
- import Database from 'better-sqlite3';
4
- import { findDbPath, openReadonlyOrFail } from './db.js';
5
- import { warn } from './logger.js';
4
+ import { createInterface } from 'node:readline';
5
+ import { closeDb, findDbPath, openDb, openReadonlyOrFail } from './db.js';
6
+ import { info, warn } from './logger.js';
6
7
 
7
8
  /**
8
9
  * Split an identifier into readable words.
@@ -133,8 +134,10 @@ export function estimateTokens(text) {
133
134
  * Returns the cleaned comment text or null if none found.
134
135
  */
135
136
  function extractLeadingComment(lines, fnLineIndex) {
137
+ if (fnLineIndex > lines.length) return null;
136
138
  const raw = [];
137
139
  for (let i = fnLineIndex - 1; i >= Math.max(0, fnLineIndex - 15); i--) {
140
+ if (i >= lines.length) continue;
138
141
  const trimmed = lines[i].trim();
139
142
  if (/^(\/\/|\/\*|\*\/|\*|#|\/\/\/)/.test(trimmed)) {
140
143
  raw.unshift(trimmed);
@@ -222,18 +225,52 @@ function buildSourceText(node, file, lines) {
222
225
  return `${node.kind} ${node.name} (${readable}) in ${file}\n${context}`;
223
226
  }
224
227
 
228
+ /**
229
+ * Prompt the user to install a missing package interactively.
230
+ * Returns true if the package was installed, false otherwise.
231
+ * Skips the prompt entirely in non-TTY environments (CI, piped stdin).
232
+ */
233
+ function promptInstall(packageName) {
234
+ if (!process.stdin.isTTY) return Promise.resolve(false);
235
+
236
+ return new Promise((resolve) => {
237
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
238
+ rl.question(`Semantic search requires ${packageName}. Install it now? [y/N] `, (answer) => {
239
+ rl.close();
240
+ if (answer.trim().toLowerCase() !== 'y') return resolve(false);
241
+ try {
242
+ execFileSync('npm', ['install', packageName], {
243
+ stdio: 'inherit',
244
+ timeout: 300_000,
245
+ });
246
+ resolve(true);
247
+ } catch {
248
+ resolve(false);
249
+ }
250
+ });
251
+ });
252
+ }
253
+
225
254
  /**
226
255
  * Lazy-load @huggingface/transformers.
227
- * This is an optional dependency gives a clear error if not installed.
256
+ * If the package is missing, prompts the user to install it interactively.
257
+ * In non-TTY environments, prints an error and exits.
228
258
  */
229
259
  async function loadTransformers() {
230
260
  try {
231
261
  return await import('@huggingface/transformers');
232
262
  } catch {
233
- console.error(
234
- 'Semantic search requires @huggingface/transformers.\n' +
235
- 'Install it with: npm install @huggingface/transformers',
236
- );
263
+ const pkg = '@huggingface/transformers';
264
+ const installed = await promptInstall(pkg);
265
+ if (installed) {
266
+ try {
267
+ return await import(pkg);
268
+ } catch {
269
+ console.error(`\n${pkg} was installed but failed to load. Please check your environment.`);
270
+ process.exit(1);
271
+ }
272
+ }
273
+ console.error(`Semantic search requires ${pkg}.\n` + `Install it with: npm install ${pkg}`);
237
274
  process.exit(1);
238
275
  }
239
276
  }
@@ -262,7 +299,7 @@ async function loadModel(modelKey) {
262
299
  pipeline = transformers.pipeline;
263
300
  _cos_sim = transformers.cos_sim;
264
301
 
265
- console.log(`Loading embedding model: ${config.name} (${config.dim}d)...`);
302
+ info(`Loading embedding model: ${config.name} (${config.dim}d)...`);
266
303
  const pipelineOpts = config.quantized ? { quantized: true } : {};
267
304
  try {
268
305
  extractor = await pipeline('feature-extraction', config.name, pipelineOpts);
@@ -285,7 +322,7 @@ async function loadModel(modelKey) {
285
322
  process.exit(1);
286
323
  }
287
324
  activeModel = config.name;
288
- console.log('Model loaded.');
325
+ info('Model loaded.');
289
326
  return { extractor, config };
290
327
  }
291
328
 
@@ -369,7 +406,7 @@ export async function buildEmbeddings(rootDir, modelKey, customDbPath, options =
369
406
  process.exit(1);
370
407
  }
371
408
 
372
- const db = new Database(dbPath);
409
+ const db = openDb(dbPath);
373
410
  initEmbeddingsSchema(db);
374
411
 
375
412
  db.exec('DELETE FROM embeddings');
@@ -474,7 +511,7 @@ export async function buildEmbeddings(rootDir, modelKey, customDbPath, options =
474
511
  console.log(
475
512
  `\nStored ${vectors.length} embeddings (${dim}d, ${config.name}, strategy: ${strategy}) in graph.db`,
476
513
  );
477
- db.close();
514
+ closeDb(db);
478
515
  }
479
516
 
480
517
  /**
package/src/export.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import path from 'node:path';
2
+ import { paginateResult } from './paginate.js';
2
3
  import { isTestFile } from './queries.js';
3
4
 
4
5
  const DEFAULT_MIN_CONFIDENCE = 0.5;
@@ -10,6 +11,7 @@ export function exportDOT(db, opts = {}) {
10
11
  const fileLevel = opts.fileLevel !== false;
11
12
  const noTests = opts.noTests || false;
12
13
  const minConf = opts.minConfidence ?? DEFAULT_MIN_CONFIDENCE;
14
+ const edgeLimit = opts.limit;
13
15
  const lines = [
14
16
  'digraph codegraph {',
15
17
  ' rankdir=LR;',
@@ -30,6 +32,8 @@ export function exportDOT(db, opts = {}) {
30
32
  `)
31
33
  .all(minConf);
32
34
  if (noTests) edges = edges.filter((e) => !isTestFile(e.source) && !isTestFile(e.target));
35
+ const totalFileEdges = edges.length;
36
+ if (edgeLimit && edges.length > edgeLimit) edges = edges.slice(0, edgeLimit);
33
37
 
34
38
  // Try to use directory nodes from DB (built by structure analysis)
35
39
  const hasDirectoryNodes =
@@ -95,6 +99,9 @@ export function exportDOT(db, opts = {}) {
95
99
  for (const { source, target } of edges) {
96
100
  lines.push(` "${source}" -> "${target}";`);
97
101
  }
102
+ if (edgeLimit && totalFileEdges > edgeLimit) {
103
+ lines.push(` // Truncated: showing ${edges.length} of ${totalFileEdges} edges`);
104
+ }
98
105
  } else {
99
106
  let edges = db
100
107
  .prepare(`
@@ -111,6 +118,8 @@ export function exportDOT(db, opts = {}) {
111
118
  .all(minConf);
112
119
  if (noTests)
113
120
  edges = edges.filter((e) => !isTestFile(e.source_file) && !isTestFile(e.target_file));
121
+ const totalFnEdges = edges.length;
122
+ if (edgeLimit && edges.length > edgeLimit) edges = edges.slice(0, edgeLimit);
114
123
 
115
124
  for (const e of edges) {
116
125
  const sId = `${e.source_file}:${e.source_name}`.replace(/[^a-zA-Z0-9_]/g, '_');
@@ -119,6 +128,9 @@ export function exportDOT(db, opts = {}) {
119
128
  lines.push(` ${tId} [label="${e.target_name}\\n${path.basename(e.target_file)}"];`);
120
129
  lines.push(` ${sId} -> ${tId};`);
121
130
  }
131
+ if (edgeLimit && totalFnEdges > edgeLimit) {
132
+ lines.push(` // Truncated: showing ${edges.length} of ${totalFnEdges} edges`);
133
+ }
122
134
  }
123
135
 
124
136
  lines.push('}');
@@ -169,6 +181,7 @@ export function exportMermaid(db, opts = {}) {
169
181
  const noTests = opts.noTests || false;
170
182
  const minConf = opts.minConfidence ?? DEFAULT_MIN_CONFIDENCE;
171
183
  const direction = opts.direction || 'LR';
184
+ const edgeLimit = opts.limit;
172
185
  const lines = [`flowchart ${direction}`];
173
186
 
174
187
  let nodeCounter = 0;
@@ -190,6 +203,8 @@ export function exportMermaid(db, opts = {}) {
190
203
  `)
191
204
  .all(minConf);
192
205
  if (noTests) edges = edges.filter((e) => !isTestFile(e.source) && !isTestFile(e.target));
206
+ const totalMermaidFileEdges = edges.length;
207
+ if (edgeLimit && edges.length > edgeLimit) edges = edges.slice(0, edgeLimit);
193
208
 
194
209
  // Collect all files referenced in edges
195
210
  const allFiles = new Set();
@@ -248,6 +263,9 @@ export function exportMermaid(db, opts = {}) {
248
263
  for (const { source, target, labels } of edgeMap.values()) {
249
264
  lines.push(` ${nodeId(source)} -->|${[...labels].join(', ')}| ${nodeId(target)}`);
250
265
  }
266
+ if (edgeLimit && totalMermaidFileEdges > edgeLimit) {
267
+ lines.push(` %% Truncated: showing ${edges.length} of ${totalMermaidFileEdges} edges`);
268
+ }
251
269
  } else {
252
270
  let edges = db
253
271
  .prepare(`
@@ -265,6 +283,8 @@ export function exportMermaid(db, opts = {}) {
265
283
  .all(minConf);
266
284
  if (noTests)
267
285
  edges = edges.filter((e) => !isTestFile(e.source_file) && !isTestFile(e.target_file));
286
+ const totalMermaidFnEdges = edges.length;
287
+ if (edgeLimit && edges.length > edgeLimit) edges = edges.slice(0, edgeLimit);
268
288
 
269
289
  // Group nodes by file for subgraphs
270
290
  const fileNodes = new Map();
@@ -301,6 +321,9 @@ export function exportMermaid(db, opts = {}) {
301
321
  const tId = nodeId(`${e.target_file}::${e.target_name}`);
302
322
  lines.push(` ${sId} -->|${e.edge_kind}| ${tId}`);
303
323
  }
324
+ if (edgeLimit && totalMermaidFnEdges > edgeLimit) {
325
+ lines.push(` %% Truncated: showing ${edges.length} of ${totalMermaidFnEdges} edges`);
326
+ }
304
327
 
305
328
  // Role styling — query roles for all referenced nodes
306
329
  const allKeys = [...nodeIdMap.keys()];
@@ -348,5 +371,6 @@ export function exportJSON(db, opts = {}) {
348
371
  .all(minConf);
349
372
  if (noTests) edges = edges.filter((e) => !isTestFile(e.source) && !isTestFile(e.target));
350
373
 
351
- return { nodes, edges };
374
+ const base = { nodes, edges };
375
+ return paginateResult(base, 'edges', { limit: opts.limit, offset: opts.offset });
352
376
  }