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
9 changes: 7 additions & 2 deletions src/cli/commands/path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,15 @@ import type { CommandDefinition } from '../types.js';

export const command: CommandDefinition = {
name: 'path <from> <to>',
description: 'Find shortest path between two symbols',
description: 'Find shortest path between two symbols (or files with --file)',
options: [
['-d, --db <path>', 'Path to graph.db'],
['-f, --file', 'Treat <from> and <to> as file paths instead of symbol names'],
['--reverse', 'Follow edges backward'],
['--kinds <kinds>', 'Comma-separated edge kinds to follow (default: calls)'],
[
'--kinds <kinds>',
'Comma-separated edge kinds to follow (default: calls; file mode: imports,imports-type)',
],
['--from-file <path>', 'Disambiguate source symbol by file'],
['--to-file <path>', 'Disambiguate target symbol by file'],
['--depth <n>', 'Max traversal depth', '10'],
Expand All @@ -32,6 +36,7 @@ export const command: CommandDefinition = {
kind: opts.kind,
noTests: ctx.resolveNoTests(opts),
json: opts.json,
file: opts.file,
});
},
};
165 changes: 165 additions & 0 deletions src/domain/analysis/dependencies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -481,3 +481,168 @@ export function pathData(
db.close();
}
}

// ── File-level shortest path ────────────────────────────────────────────

/**
* BFS at the file level: find shortest import/edge path between two files.
* Adjacency: file A → file B if any symbol in A has an edge to any symbol in B.
*/
export function filePathData(
from: string,
to: string,
customDbPath: string,
opts: {
noTests?: boolean;
maxDepth?: number;
edgeKinds?: string[];
reverse?: boolean;
} = {},
) {
const db = openReadonlyOrFail(customDbPath);
try {
const noTests = opts.noTests || false;
const maxDepth = opts.maxDepth || 10;
const edgeKinds = opts.edgeKinds || ['imports', 'imports-type'];
const reverse = opts.reverse || false;

// Resolve from/to as file paths (LIKE match)
const fromFiles = findFileNodes(db, `%${from}%`) as NodeRow[];
if (fromFiles.length === 0) {
return {
from,
to,
found: false,
error: `No file matching "${from}"`,
path: [],
fromCandidates: [],
toCandidates: [],
};
}
const toFiles = findFileNodes(db, `%${to}%`) as NodeRow[];
if (toFiles.length === 0) {
return {
from,
to,
found: false,
error: `No file matching "${to}"`,
path: [],
fromCandidates: fromFiles.slice(0, 5).map((f) => f.file),
toCandidates: [],
};
}

const sourceFile = fromFiles[0]!.file;
const targetFile = toFiles[0]!.file;

const fromCandidates = fromFiles.slice(0, 5).map((f) => f.file);
const toCandidates = toFiles.slice(0, 5).map((f) => f.file);

if (sourceFile === targetFile) {
return {
from,
to,
fromCandidates,
toCandidates,
found: true,
hops: 0,
path: [sourceFile],
alternateCount: 0,
edgeKinds,
reverse,
maxDepth,
};
}

// Build neighbor query: find all distinct files adjacent to a given file via edges
const kindPlaceholders = edgeKinds.map(() => '?').join(', ');
const neighborQuery = reverse
? `SELECT DISTINCT n_src.file AS neighbor_file
FROM nodes n_tgt
JOIN edges e ON e.target_id = n_tgt.id
JOIN nodes n_src ON e.source_id = n_src.id
WHERE n_tgt.file = ? AND e.kind IN (${kindPlaceholders}) AND n_src.file != n_tgt.file`
: `SELECT DISTINCT n_tgt.file AS neighbor_file
FROM nodes n_src
JOIN edges e ON e.source_id = n_src.id
JOIN nodes n_tgt ON e.target_id = n_tgt.id
WHERE n_src.file = ? AND e.kind IN (${kindPlaceholders}) AND n_tgt.file != n_src.file`;
const neighborStmt = db.prepare(neighborQuery);

// BFS
const visited = new Set([sourceFile]);
const parentMap = new Map<string, string>();
let queue = [sourceFile];
let found = false;
let alternateCount = 0;

for (let depth = 1; depth <= maxDepth; depth++) {
const nextQueue: string[] = [];
for (const currentFile of queue) {
const neighbors = neighborStmt.all(currentFile, ...edgeKinds) as Array<{
neighbor_file: string;
}>;
for (const n of neighbors) {
if (noTests && isTestFile(n.neighbor_file)) continue;
if (n.neighbor_file === targetFile) {
if (!found) {
found = true;
parentMap.set(n.neighbor_file, currentFile);
}
alternateCount++;
continue;
}
if (!visited.has(n.neighbor_file)) {
visited.add(n.neighbor_file);
parentMap.set(n.neighbor_file, currentFile);
nextQueue.push(n.neighbor_file);
}
}
}
if (found) break;
queue = nextQueue;
if (queue.length === 0) break;
}

if (!found) {
return {
from,
to,
fromCandidates,
toCandidates,
found: false,
hops: null,
path: [],
alternateCount: 0,
edgeKinds,
reverse,
maxDepth,
};
}

// Reconstruct path
const filePath: string[] = [targetFile];
let cur = targetFile;
while (cur !== sourceFile) {
cur = parentMap.get(cur)!;
filePath.push(cur);
}
filePath.reverse();

return {
from,
to,
fromCandidates,
toCandidates,
found: true,
hops: filePath.length - 1,
path: filePath,
alternateCount: Math.max(0, alternateCount - 1),
edgeKinds,
reverse,
maxDepth,
};
} finally {
db.close();
}
}
73 changes: 51 additions & 22 deletions src/domain/graph/builder/stages/build-structure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,31 +37,54 @@ export async function buildStructure(ctx: PipelineContext): Promise<void> {
const existingFiles = db
.prepare("SELECT DISTINCT file FROM nodes WHERE kind = 'file'")
.all() as Array<{ file: string }>;
const defsByFile = db.prepare(
"SELECT name, kind, line FROM nodes WHERE file = ? AND kind != 'file' AND kind != 'directory'",
);
const importCountByFile = db.prepare(
`SELECT COUNT(DISTINCT n2.file) AS cnt FROM edges e
JOIN nodes n1 ON e.source_id = n1.id
JOIN nodes n2 ON e.target_id = n2.id
WHERE n1.file = ? AND e.kind = 'imports'`,
);
const lineCountByFile = db.prepare(
`SELECT n.name AS file, m.line_count
FROM node_metrics m JOIN nodes n ON m.node_id = n.id
WHERE n.kind = 'file'`,
);

// Batch load: all definitions, import counts, and line counts in single queries
const allDefs = db
.prepare(
"SELECT file, name, kind, line FROM nodes WHERE kind != 'file' AND kind != 'directory'",
)
.all() as Array<{ file: string; name: string; kind: string; line: number }>;
const defsByFileMap = new Map<string, Array<{ name: string; kind: string; line: number }>>();
for (const row of allDefs) {
let arr = defsByFileMap.get(row.file);
if (!arr) {
arr = [];
defsByFileMap.set(row.file, arr);
}
arr.push({ name: row.name, kind: row.kind, line: row.line });
}

const allImportCounts = db
.prepare(
`SELECT n1.file, COUNT(DISTINCT n2.file) AS cnt FROM edges e
JOIN nodes n1 ON e.source_id = n1.id
JOIN nodes n2 ON e.target_id = n2.id
WHERE e.kind = 'imports'
GROUP BY n1.file`,
)
.all() as Array<{ file: string; cnt: number }>;
const importCountMap = new Map<string, number>();
for (const row of allImportCounts) {
importCountMap.set(row.file, row.cnt);
}

const cachedLineCounts = new Map<string, number>();
for (const row of lineCountByFile.all() as Array<{ file: string; line_count: number }>) {
for (const row of db
.prepare(
`SELECT n.name AS file, m.line_count
FROM node_metrics m JOIN nodes n ON m.node_id = n.id
WHERE n.kind = 'file'`,
)
.all() as Array<{ file: string; line_count: number }>) {
cachedLineCounts.set(row.file, row.line_count);
}

let loadedFromDb = 0;
for (const { file: relPath } of existingFiles) {
if (!fileSymbols.has(relPath)) {
const importCount =
(importCountByFile.get(relPath) as { cnt: number } | undefined)?.cnt || 0;
const importCount = importCountMap.get(relPath) || 0;
fileSymbols.set(relPath, {
definitions: defsByFile.all(relPath),
definitions: defsByFileMap.get(relPath) || [],
imports: new Array(importCount) as unknown as ExtractorOutput['imports'],
exports: [],
} as unknown as ExtractorOutput);
Expand Down Expand Up @@ -111,15 +134,21 @@ export async function buildStructure(ctx: PipelineContext): Promise<void> {
}
ctx.timing.structureMs = performance.now() - t0;

// Classify node roles
// Classify node roles (incremental: only reclassify changed files' nodes)
const t1 = performance.now();
try {
const { classifyNodeRoles } = (await import('../../../../features/structure.js')) as {
classifyNodeRoles: (db: PipelineContext['db']) => Record<string, number>;
classifyNodeRoles: (
db: PipelineContext['db'],
changedFiles?: string[] | null,
) => Record<string, number>;
};
const roleSummary = classifyNodeRoles(db);
const changedFileList = isFullBuild ? null : [...allSymbols.keys()];
const roleSummary = classifyNodeRoles(db, changedFileList);
debug(
`Roles: ${Object.entries(roleSummary)
`Roles${changedFileList ? ` (incremental, ${changedFileList.length} files)` : ''}: ${Object.entries(
roleSummary,
)
.map(([r, c]) => `${r}=${c}`)
.join(', ')}`,
);
Expand Down
Loading
Loading