Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
257 changes: 186 additions & 71 deletions docs/roadmap/ROADMAP.md

Large diffs are not rendered by default.

31 changes: 31 additions & 0 deletions src/db/connection.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import path from 'node:path';
import Database from 'better-sqlite3';
import { warn } from '../infrastructure/logger.js';
import { DbError } from '../shared/errors.js';
import { Repository } from './repository/base.js';
import { SqliteRepository } from './repository/sqlite-repository.js';

function isProcessAlive(pid) {
try {
Expand Down Expand Up @@ -86,3 +88,32 @@ export function openReadonlyOrFail(customPath) {
}
return new Database(dbPath, { readonly: true });
}

/**
* Open a Repository from either an injected instance or a DB path.
*
* When `opts.repo` is a Repository instance, returns it directly (no DB opened).
* Otherwise opens a readonly SQLite DB and wraps it in SqliteRepository.
*
* @param {string} [customDbPath] - Path to graph.db (ignored when opts.repo is set)
* @param {object} [opts]
* @param {Repository} [opts.repo] - Pre-built Repository to use instead of SQLite
* @returns {{ repo: Repository, close(): void }}
*/
export function openRepo(customDbPath, opts = {}) {
if (opts.repo != null) {
if (!(opts.repo instanceof Repository)) {
throw new TypeError(
`openRepo: opts.repo must be a Repository instance, got ${Object.prototype.toString.call(opts.repo)}`,
);
}
return { repo: opts.repo, close() {} };
}
const db = openReadonlyOrFail(customDbPath);
return {
repo: new SqliteRepository(db),
close() {
db.close();
},
};
}
2 changes: 1 addition & 1 deletion src/db/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Barrel re-export — keeps all existing `import { ... } from '…/db/index.js'` working.
export { closeDb, findDbPath, openDb, openReadonlyOrFail } from './connection.js';
export { closeDb, findDbPath, openDb, openReadonlyOrFail, openRepo } from './connection.js';
export { getBuildMeta, initSchema, MIGRATIONS, setBuildMeta } from './migrations.js';
export {
fanInJoinSQL,
Expand Down
10 changes: 8 additions & 2 deletions src/domain/analysis/symbol-lookup.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
findNodesWithFanIn,
listFunctionNodes,
openReadonlyOrFail,
Repository,
} from '../../db/index.js';
import { isTestFile } from '../../infrastructure/test-filter.js';
import { ALL_SYMBOL_KINDS } from '../../shared/kinds.js';
Expand All @@ -23,11 +24,16 @@ const FUNCTION_KINDS = ['function', 'method', 'class'];
/**
* Find nodes matching a name query, ranked by relevance.
* Scoring: exact=100, prefix=60, word-boundary=40, substring=10, plus fan-in tiebreaker.
*
* @param {object} dbOrRepo - A better-sqlite3 Database or a Repository instance
*/
export function findMatchingNodes(db, name, opts = {}) {
export function findMatchingNodes(dbOrRepo, name, opts = {}) {
const kinds = opts.kind ? [opts.kind] : opts.kinds?.length ? opts.kinds : FUNCTION_KINDS;

const rows = findNodesWithFanIn(db, `%${name}%`, { kinds, file: opts.file });
const isRepo = dbOrRepo instanceof Repository;
const rows = isRepo
? dbOrRepo.findNodesWithFanIn(`%${name}%`, { kinds, file: opts.file })
: findNodesWithFanIn(dbOrRepo, `%${name}%`, { kinds, file: opts.file });

const nodes = opts.noTests ? rows.filter((n) => !isTestFile(n.file)) : rows;

Expand Down
8 changes: 4 additions & 4 deletions src/features/communities.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import path from 'node:path';
import { openReadonlyOrFail } from '../db/index.js';
import { openRepo } from '../db/index.js';
import { louvainCommunities } from '../graph/algorithms/louvain.js';
import { buildDependencyGraph } from '../graph/builders/dependency.js';
import { paginateResult } from '../shared/paginate.js';
Expand All @@ -26,15 +26,15 @@ function getDirectory(filePath) {
* @returns {{ communities: object[], modularity: number, drift: object, summary: object }}
*/
export function communitiesData(customDbPath, opts = {}) {
const db = openReadonlyOrFail(customDbPath);
const { repo, close } = openRepo(customDbPath, opts);
let graph;
try {
graph = buildDependencyGraph(db, {
graph = buildDependencyGraph(repo, {
fileLevel: !opts.functions,
noTests: opts.noTests,
});
} finally {
db.close();
close();
}

// Handle empty or trivial graphs
Expand Down
22 changes: 11 additions & 11 deletions src/features/sequence.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
* sequence-diagram conventions.
*/

import { findCallees, openReadonlyOrFail } from '../db/index.js';
import { openRepo } from '../db/index.js';
import { SqliteRepository } from '../db/repository/sqlite-repository.js';
import { findMatchingNodes } from '../domain/queries.js';
import { isTestFile } from '../infrastructure/test-filter.js';
import { paginateResult } from '../shared/paginate.js';
Expand Down Expand Up @@ -85,19 +86,19 @@ function buildAliases(files) {
* @returns {{ entry, participants, messages, depth, totalMessages, truncated }}
*/
export function sequenceData(name, dbPath, opts = {}) {
const db = openReadonlyOrFail(dbPath);
const { repo, close } = openRepo(dbPath, opts);
try {
const maxDepth = opts.depth || 10;
const noTests = opts.noTests || false;
const withDataflow = opts.dataflow || false;

// Phase 1: Direct LIKE match
let matchNode = findMatchingNodes(db, name, opts)[0] ?? null;
let matchNode = findMatchingNodes(repo, name, opts)[0] ?? null;

// Phase 2: Prefix-stripped matching
if (!matchNode) {
for (const prefix of FRAMEWORK_ENTRY_PREFIXES) {
matchNode = findMatchingNodes(db, `${prefix}${name}`, opts)[0] ?? null;
matchNode = findMatchingNodes(repo, `${prefix}${name}`, opts)[0] ?? null;
if (matchNode) break;
}
}
Expand Down Expand Up @@ -133,7 +134,7 @@ export function sequenceData(name, dbPath, opts = {}) {
const nextFrontier = [];

for (const fid of frontier) {
const callees = findCallees(db, fid);
const callees = repo.findCallees(fid);

const caller = idToNode.get(fid);

Expand Down Expand Up @@ -163,18 +164,17 @@ export function sequenceData(name, dbPath, opts = {}) {

if (d === maxDepth && frontier.length > 0) {
// Only mark truncated if at least one frontier node has further callees
const hasMoreCalls = frontier.some((fid) => findCallees(db, fid).length > 0);
const hasMoreCalls = frontier.some((fid) => repo.findCallees(fid).length > 0);
if (hasMoreCalls) truncated = true;
}
}

// Dataflow annotations: add return arrows
if (withDataflow && messages.length > 0) {
const hasTable = db
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='dataflow'")
.get();
const hasTable = repo.hasDataflowTable();

if (hasTable) {
if (hasTable && repo instanceof SqliteRepository) {
const db = repo.db;
// Build name|file lookup for O(1) target node access
const nodeByNameFile = new Map();
for (const n of idToNode.values()) {
Expand Down Expand Up @@ -281,7 +281,7 @@ export function sequenceData(name, dbPath, opts = {}) {
}
return result;
} finally {
db.close();
close();
}
}

Expand Down
8 changes: 4 additions & 4 deletions src/features/triage.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { findNodesForTriage, openReadonlyOrFail } from '../db/index.js';
import { openRepo } from '../db/index.js';
import { DEFAULT_WEIGHTS, scoreRisk } from '../graph/classifiers/risk.js';
import { warn } from '../infrastructure/logger.js';
import { isTestFile } from '../infrastructure/test-filter.js';
Expand All @@ -14,7 +14,7 @@ import { paginateResult } from '../shared/paginate.js';
* @returns {{ items: object[], summary: object, _pagination?: object }}
*/
export function triageData(customDbPath, opts = {}) {
const db = openReadonlyOrFail(customDbPath);
const { repo, close } = openRepo(customDbPath, opts);
try {
const noTests = opts.noTests || false;
const fileFilter = opts.file || null;
Expand All @@ -26,7 +26,7 @@ export function triageData(customDbPath, opts = {}) {

let rows;
try {
rows = findNodesForTriage(db, {
rows = repo.findNodesForTriage({
noTests,
file: fileFilter,
kind: kindFilter,
Expand Down Expand Up @@ -115,7 +115,7 @@ export function triageData(customDbPath, opts = {}) {
offset: opts.offset,
});
} finally {
db.close();
close();
}
}

Expand Down
41 changes: 27 additions & 14 deletions src/graph/builders/dependency.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,32 +3,39 @@
* Replaces inline graph construction in cycles.js, communities.js, viewer.js, export.js.
*/

import { getCallableNodes, getCallEdges, getFileNodesAll, getImportEdges } from '../../db/index.js';
import {
getCallableNodes,
getCallEdges,
getFileNodesAll,
getImportEdges,
Repository,
} from '../../db/index.js';
import { isTestFile } from '../../infrastructure/test-filter.js';
import { CodeGraph } from '../model.js';

/**
* @param {object} db - Open better-sqlite3 database (readonly)
* @param {object} dbOrRepo - Open better-sqlite3 database (readonly) or a Repository instance
* @param {object} [opts]
* @param {boolean} [opts.fileLevel=true] - File-level (imports) or function-level (calls)
* @param {boolean} [opts.noTests=false] - Exclude test files
* @param {number} [opts.minConfidence] - Minimum edge confidence (function-level only)
* @returns {CodeGraph}
*/
export function buildDependencyGraph(db, opts = {}) {
export function buildDependencyGraph(dbOrRepo, opts = {}) {
const fileLevel = opts.fileLevel !== false;
const noTests = opts.noTests || false;

if (fileLevel) {
return buildFileLevelGraph(db, noTests);
return buildFileLevelGraph(dbOrRepo, noTests);
}
return buildFunctionLevelGraph(db, noTests, opts.minConfidence);
return buildFunctionLevelGraph(dbOrRepo, noTests, opts.minConfidence);
}

function buildFileLevelGraph(db, noTests) {
function buildFileLevelGraph(dbOrRepo, noTests) {
const graph = new CodeGraph();
const isRepo = dbOrRepo instanceof Repository;

let nodes = getFileNodesAll(db);
let nodes = isRepo ? dbOrRepo.getFileNodesAll() : getFileNodesAll(dbOrRepo);
if (noTests) nodes = nodes.filter((n) => !isTestFile(n.file));

const nodeIds = new Set();
Expand All @@ -37,7 +44,7 @@ function buildFileLevelGraph(db, noTests) {
nodeIds.add(n.id);
}

const edges = getImportEdges(db);
const edges = isRepo ? dbOrRepo.getImportEdges() : getImportEdges(dbOrRepo);
for (const e of edges) {
if (!nodeIds.has(e.source_id) || !nodeIds.has(e.target_id)) continue;
const src = String(e.source_id);
Expand All @@ -51,10 +58,11 @@ function buildFileLevelGraph(db, noTests) {
return graph;
}

function buildFunctionLevelGraph(db, noTests, minConfidence) {
function buildFunctionLevelGraph(dbOrRepo, noTests, minConfidence) {
const graph = new CodeGraph();
const isRepo = dbOrRepo instanceof Repository;

let nodes = getCallableNodes(db);
let nodes = isRepo ? dbOrRepo.getCallableNodes() : getCallableNodes(dbOrRepo);
if (noTests) nodes = nodes.filter((n) => !isTestFile(n.file));

const nodeIds = new Set();
Expand All @@ -70,11 +78,16 @@ function buildFunctionLevelGraph(db, noTests, minConfidence) {

let edges;
if (minConfidence != null) {
edges = db
.prepare("SELECT source_id, target_id FROM edges WHERE kind = 'calls' AND confidence >= ?")
.all(minConfidence);
if (isRepo) {
// minConfidence filtering not supported by Repository — fall back to getCallEdges
edges = dbOrRepo.getCallEdges();
} else {
edges = dbOrRepo
.prepare("SELECT source_id, target_id FROM edges WHERE kind = 'calls' AND confidence >= ?")
.all(minConfidence);
}
Comment on lines 80 to +88
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minConfidence silently ignored for Repository

When a Repository is passed and minConfidence is set, the filter is silently dropped — getCallEdges() returns all call edges regardless of confidence. This means callers like viewer.js and export.js that pass minConfidence would get incorrect (unfiltered) graphs if they were ever migrated to use the Repository path.

At minimum, a warn() log would make this visible. Better would be to add a getCallEdgesAboveConfidence(min) method to the Repository contract, or post-filter the results:

Suggested change
if (minConfidence != null) {
edges = db
.prepare("SELECT source_id, target_id FROM edges WHERE kind = 'calls' AND confidence >= ?")
.all(minConfidence);
if (isRepo) {
// minConfidence filtering not supported by Repository — fall back to getCallEdges
edges = dbOrRepo.getCallEdges();
} else {
edges = dbOrRepo
.prepare("SELECT source_id, target_id FROM edges WHERE kind = 'calls' AND confidence >= ?")
.all(minConfidence);
}
if (minConfidence != null) {
if (isRepo) {
// minConfidence filtering not yet in Repository — filter in JS
edges = dbOrRepo.getCallEdges().filter((e) => e.confidence == null || e.confidence >= minConfidence);
} else {
edges = dbOrRepo
.prepare("SELECT source_id, target_id FROM edges WHERE kind = 'calls' AND confidence >= ?")
.all(minConfidence);
}

Note: this suggestion requires getCallEdges() to include a confidence field in its return shape, which would need a small change to InMemoryRepository.getCallEdges() and SqliteRepository.getCallEdges().

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — when a Repository is passed and minConfidence is set, call edges are now post-filtered in JS: .filter(e => e.confidence != null && e.confidence >= minConfidence). Updated getCallEdges() in all three layers (SqliteRepository, InMemoryRepository, base JSDoc) to include the confidence field in the return shape.

} else {
edges = getCallEdges(db);
edges = isRepo ? dbOrRepo.getCallEdges() : getCallEdges(dbOrRepo);
}

for (const e of edges) {
Expand Down
Loading
Loading