From d6c3ce3da5d398c4733efc2943e44d25229cf314 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sat, 21 Mar 2026 02:43:09 -0600 Subject: [PATCH 01/18] feat: type domain/analysis modules, parser WASM maps, and add canStripTypes helper (#554) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address deferred review feedback from PR #554: - Convert all 9 domain/analysis/*.js → .ts with BetterSqlite3.Database and NodeRow type annotations replacing untyped db/node parameters - Type WASM parser cache maps (Parser, Language, Query) in domain/parser.ts with LanguageRegistryEntry interface and full function signatures - Extract canStripTypes Node version probe to tests/helpers/node-version.js shared helper (ready for import when CLI test TS guards land) --- src/domain/analysis/brief.ts | 177 ++++++ src/domain/analysis/context.ts | 522 ++++++++++++++++++ src/domain/analysis/dependencies.ts | 474 ++++++++++++++++ src/domain/analysis/exports.ts | 233 ++++++++ src/domain/analysis/impact.ts | 718 +++++++++++++++++++++++++ src/domain/analysis/implementations.ts | 97 ++++ src/domain/analysis/module-map.ts | 410 ++++++++++++++ src/domain/analysis/roles.ts | 64 +++ src/domain/analysis/symbol-lookup.ts | 284 ++++++++++ src/domain/parser.ts | 681 +++++++++++++++++++++++ tests/helpers/node-version.js | 6 + 11 files changed, 3666 insertions(+) create mode 100644 src/domain/analysis/brief.ts create mode 100644 src/domain/analysis/context.ts create mode 100644 src/domain/analysis/dependencies.ts create mode 100644 src/domain/analysis/exports.ts create mode 100644 src/domain/analysis/impact.ts create mode 100644 src/domain/analysis/implementations.ts create mode 100644 src/domain/analysis/module-map.ts create mode 100644 src/domain/analysis/roles.ts create mode 100644 src/domain/analysis/symbol-lookup.ts create mode 100644 src/domain/parser.ts create mode 100644 tests/helpers/node-version.js diff --git a/src/domain/analysis/brief.ts b/src/domain/analysis/brief.ts new file mode 100644 index 00000000..259b1f18 --- /dev/null +++ b/src/domain/analysis/brief.ts @@ -0,0 +1,177 @@ +import type BetterSqlite3 from 'better-sqlite3'; +import { + findDistinctCallers, + findFileNodes, + findImportDependents, + findImportSources, + findImportTargets, + findNodesByFile, + openReadonlyOrFail, +} from '../../db/index.js'; +import { loadConfig } from '../../infrastructure/config.js'; +import { isTestFile } from '../../infrastructure/test-filter.js'; +import type { ImportEdgeRow, NodeRow, RelatedNodeRow } from '../../types.js'; + +/** Symbol kinds meaningful for a file brief — excludes parameters, properties, constants. */ +const BRIEF_KINDS = new Set([ + 'function', + 'method', + 'class', + 'interface', + 'type', + 'struct', + 'enum', + 'trait', + 'record', + 'module', +]); + +/** + * Compute file risk tier from symbol roles and max fan-in. + */ +function computeRiskTier( + symbols: Array<{ role: string | null; callerCount: number }>, + highThreshold = 10, + mediumThreshold = 3, +): 'high' | 'medium' | 'low' { + let maxCallers = 0; + let hasCoreRole = false; + for (const s of symbols) { + if (s.callerCount > maxCallers) maxCallers = s.callerCount; + if (s.role === 'core') hasCoreRole = true; + } + if (maxCallers >= highThreshold || hasCoreRole) return 'high'; + if (maxCallers >= mediumThreshold) return 'medium'; + return 'low'; +} + +/** + * BFS to count transitive callers for a single node. + * Lightweight variant — only counts, does not collect details. + */ +function countTransitiveCallers( + db: BetterSqlite3.Database, + startId: number, + noTests: boolean, + maxDepth = 5, +): number { + const visited = new Set([startId]); + let frontier = [startId]; + + for (let d = 1; d <= maxDepth; d++) { + const nextFrontier: number[] = []; + for (const fid of frontier) { + const callers = findDistinctCallers(db, fid) as RelatedNodeRow[]; + for (const c of callers) { + if (!visited.has(c.id) && (!noTests || !isTestFile(c.file))) { + visited.add(c.id); + nextFrontier.push(c.id); + } + } + } + frontier = nextFrontier; + if (frontier.length === 0) break; + } + + return visited.size - 1; +} + +/** + * Count transitive file-level import dependents via BFS. + * Depth-bounded to match countTransitiveCallers and keep hook latency predictable. + */ +function countTransitiveImporters( + db: BetterSqlite3.Database, + fileNodeIds: number[], + noTests: boolean, + maxDepth = 5, +): number { + const visited = new Set(fileNodeIds); + let frontier = [...fileNodeIds]; + + for (let d = 1; d <= maxDepth; d++) { + const nextFrontier: number[] = []; + for (const current of frontier) { + const dependents = findImportDependents(db, current) as RelatedNodeRow[]; + for (const dep of dependents) { + if (!visited.has(dep.id) && (!noTests || !isTestFile(dep.file))) { + visited.add(dep.id); + nextFrontier.push(dep.id); + } + } + } + frontier = nextFrontier; + if (frontier.length === 0) break; + } + + return visited.size - fileNodeIds.length; +} + +/** + * Produce a token-efficient file brief: symbols with roles and caller counts, + * importer info with transitive count, and file risk tier. + */ +export function briefData( + file: string, + customDbPath: string, + // biome-ignore lint/suspicious/noExplicitAny: config shape is dynamic + opts: { noTests?: boolean; config?: any } = {}, +) { + const db = openReadonlyOrFail(customDbPath); + try { + const noTests = opts.noTests || false; + const config = opts.config || loadConfig(); + const callerDepth = config.analysis?.briefCallerDepth ?? 5; + const importerDepth = config.analysis?.briefImporterDepth ?? 5; + const highRiskCallers = config.analysis?.briefHighRiskCallers ?? 10; + const mediumRiskCallers = config.analysis?.briefMediumRiskCallers ?? 3; + const fileNodes = findFileNodes(db, `%${file}%`) as NodeRow[]; + if (fileNodes.length === 0) { + return { file, results: [] }; + } + + const results = fileNodes.map((fn) => { + // Direct importers + let importedBy = findImportSources(db, fn.id) as ImportEdgeRow[]; + if (noTests) importedBy = importedBy.filter((i) => !isTestFile(i.file)); + const directImporters = [...new Set(importedBy.map((i) => i.file))]; + + // Transitive importer count + const totalImporterCount = countTransitiveImporters(db, [fn.id], noTests, importerDepth); + + // Direct imports + let importsTo = findImportTargets(db, fn.id) as ImportEdgeRow[]; + if (noTests) importsTo = importsTo.filter((i) => !isTestFile(i.file)); + + // Symbol definitions with roles and caller counts + const defs = (findNodesByFile(db, fn.file) as NodeRow[]).filter((d) => + BRIEF_KINDS.has(d.kind), + ); + const symbols = defs.map((d) => { + const callerCount = countTransitiveCallers(db, d.id, noTests, callerDepth); + return { + name: d.name, + kind: d.kind, + line: d.line, + role: d.role || null, + callerCount, + }; + }); + + const riskTier = computeRiskTier(symbols, highRiskCallers, mediumRiskCallers); + + return { + file: fn.file, + risk: riskTier, + imports: importsTo.map((i) => i.file), + importedBy: directImporters, + totalImporterCount, + symbols, + }; + }); + + return { file, results }; + } finally { + db.close(); + } +} diff --git a/src/domain/analysis/context.ts b/src/domain/analysis/context.ts new file mode 100644 index 00000000..ec328990 --- /dev/null +++ b/src/domain/analysis/context.ts @@ -0,0 +1,522 @@ +import path from 'node:path'; +import type BetterSqlite3 from 'better-sqlite3'; +import { + findCallees, + findCallers, + findCrossFileCallTargets, + findDbPath, + findFileNodes, + findImplementors, + findImportSources, + findImportTargets, + findInterfaces, + findIntraFileCallEdges, + findNodeChildren, + findNodesByFile, + getComplexityForNode, + openReadonlyOrFail, +} from '../../db/index.js'; +import { loadConfig } from '../../infrastructure/config.js'; +import { debug } from '../../infrastructure/logger.js'; +import { isTestFile } from '../../infrastructure/test-filter.js'; +import { + createFileLinesReader, + extractSignature, + extractSummary, + isFileLikeTarget, + readSourceRange, +} from '../../shared/file-utils.js'; +import { resolveMethodViaHierarchy } from '../../shared/hierarchy.js'; +import { normalizeSymbol } from '../../shared/normalize.js'; +import { paginateResult } from '../../shared/paginate.js'; +import type { + ChildNodeRow, + ImportEdgeRow, + IntraFileCallEdge, + NodeRow, + RelatedNodeRow, +} from '../../types.js'; +import { findMatchingNodes } from './symbol-lookup.js'; + +interface DisplayOpts { + maxLines?: number; + [key: string]: unknown; +} + +function buildCallees( + db: BetterSqlite3.Database, + node: NodeRow, + repoRoot: string, + getFileLines: (file: string) => string[] | null, + opts: { noTests: boolean; depth: number; displayOpts: DisplayOpts }, +) { + const { noTests, depth, displayOpts } = opts; + const calleeRows = findCallees(db, node.id) as RelatedNodeRow[]; + const filteredCallees = noTests ? calleeRows.filter((c) => !isTestFile(c.file)) : calleeRows; + + const callees = filteredCallees.map((c) => { + const cLines = getFileLines(c.file); + const summary = cLines ? extractSummary(cLines, c.line, displayOpts) : null; + let calleeSource: string | null = null; + if (depth >= 1) { + calleeSource = readSourceRange(repoRoot, c.file, c.line, c.end_line, displayOpts); + } + return { + name: c.name, + kind: c.kind, + file: c.file, + line: c.line, + endLine: c.end_line || null, + summary, + source: calleeSource, + }; + }); + + if (depth > 1) { + const visited = new Set(filteredCallees.map((c) => c.id)); + visited.add(node.id); + let frontier = filteredCallees.map((c) => c.id); + const maxDepth = Math.min(depth, 5); + for (let d = 2; d <= maxDepth; d++) { + const nextFrontier: number[] = []; + for (const fid of frontier) { + const deeper = findCallees(db, fid) as RelatedNodeRow[]; + for (const c of deeper) { + if (!visited.has(c.id) && (!noTests || !isTestFile(c.file))) { + visited.add(c.id); + nextFrontier.push(c.id); + const cLines = getFileLines(c.file); + callees.push({ + name: c.name, + kind: c.kind, + file: c.file, + line: c.line, + endLine: c.end_line || null, + summary: cLines ? extractSummary(cLines, c.line, displayOpts) : null, + source: readSourceRange(repoRoot, c.file, c.line, c.end_line, displayOpts), + }); + } + } + } + frontier = nextFrontier; + if (frontier.length === 0) break; + } + } + + return callees; +} + +function buildCallers(db: BetterSqlite3.Database, node: NodeRow, noTests: boolean) { + let callerRows: Array = findCallers( + db, + node.id, + ) as RelatedNodeRow[]; + + if (node.kind === 'method' && node.name.includes('.')) { + const methodName = node.name.split('.').pop() ?? ''; + const relatedMethods = resolveMethodViaHierarchy(db, methodName); + for (const rm of relatedMethods) { + if (rm.id === node.id) continue; + const extraCallers = findCallers(db, rm.id) as RelatedNodeRow[]; + callerRows.push(...extraCallers.map((c) => ({ ...c, viaHierarchy: rm.name }))); + } + } + if (noTests) callerRows = callerRows.filter((c) => !isTestFile(c.file)); + + return callerRows.map((c) => ({ + name: c.name, + kind: c.kind, + file: c.file, + line: c.line, + viaHierarchy: c.viaHierarchy || undefined, + })); +} + +const INTERFACE_LIKE_KINDS = new Set(['interface', 'trait']); +const IMPLEMENTOR_KINDS = new Set(['class', 'struct', 'record', 'enum']); + +function buildImplementationInfo(db: BetterSqlite3.Database, node: NodeRow, noTests: boolean) { + // For interfaces/traits: show who implements them + if (INTERFACE_LIKE_KINDS.has(node.kind)) { + let impls = findImplementors(db, node.id) as RelatedNodeRow[]; + if (noTests) impls = impls.filter((n) => !isTestFile(n.file)); + return { + implementors: impls.map((n) => ({ name: n.name, kind: n.kind, file: n.file, line: n.line })), + }; + } + // For classes/structs: show what they implement + if (IMPLEMENTOR_KINDS.has(node.kind)) { + let ifaces = findInterfaces(db, node.id) as RelatedNodeRow[]; + if (noTests) ifaces = ifaces.filter((n) => !isTestFile(n.file)); + if (ifaces.length > 0) { + return { + implements: ifaces.map((n) => ({ name: n.name, kind: n.kind, file: n.file, line: n.line })), + }; + } + } + return {}; +} + +function buildRelatedTests( + db: BetterSqlite3.Database, + node: NodeRow, + getFileLines: (file: string) => string[] | null, + includeTests: boolean, +) { + const testCallerRows = findCallers(db, node.id) as RelatedNodeRow[]; + const testCallers = testCallerRows.filter((c) => isTestFile(c.file)); + + const testsByFile = new Map(); + for (const tc of testCallers) { + if (!testsByFile.has(tc.file)) testsByFile.set(tc.file, []); + testsByFile.get(tc.file)!.push(tc); + } + + const relatedTests: Array<{ + file: string; + testCount: number; + testNames: string[]; + source?: string; + }> = []; + for (const [file] of testsByFile) { + const tLines = getFileLines(file); + const testNames: string[] = []; + if (tLines) { + for (const tl of tLines) { + const tm = tl.match(/(?:it|test|describe)\s*\(\s*['"`]([^'"`]+)['"`]/); + if (tm) testNames.push(tm[1]!); + } + } + const testSource = includeTests && tLines ? tLines.join('\n') : undefined; + relatedTests.push({ + file, + testCount: testNames.length, + testNames, + source: testSource, + }); + } + + return relatedTests; +} + +function getComplexityMetrics(db: BetterSqlite3.Database, nodeId: number) { + try { + const cRow = getComplexityForNode(db, nodeId); + if (!cRow) return null; + return { + cognitive: cRow.cognitive, + cyclomatic: cRow.cyclomatic, + maxNesting: cRow.max_nesting, + maintainabilityIndex: cRow.maintainability_index || 0, + halsteadVolume: cRow.halstead_volume || 0, + }; + } catch (e: unknown) { + debug(`complexity lookup failed for node ${nodeId}: ${(e as Error).message}`); + return null; + } +} + +function getNodeChildrenSafe(db: BetterSqlite3.Database, nodeId: number) { + try { + return (findNodeChildren(db, nodeId) as ChildNodeRow[]).map((c) => ({ + name: c.name, + kind: c.kind, + line: c.line, + endLine: c.end_line || null, + })); + } catch (e: unknown) { + debug(`findNodeChildren failed for node ${nodeId}: ${(e as Error).message}`); + return []; + } +} + +function explainFileImpl( + db: BetterSqlite3.Database, + target: string, + getFileLines: (file: string) => string[] | null, + displayOpts: DisplayOpts, +) { + const fileNodes = findFileNodes(db, `%${target}%`) as NodeRow[]; + if (fileNodes.length === 0) return []; + + return fileNodes.map((fn) => { + const symbols = findNodesByFile(db, fn.file) as NodeRow[]; + + // IDs of symbols that have incoming calls from other files (public) + const publicIds = findCrossFileCallTargets(db, fn.file) as Set; + + const fileLines = getFileLines(fn.file); + const mapSymbol = (s: NodeRow) => ({ + name: s.name, + kind: s.kind, + line: s.line, + role: s.role || null, + summary: fileLines ? extractSummary(fileLines, s.line, displayOpts) : null, + signature: fileLines ? extractSignature(fileLines, s.line, displayOpts) : null, + }); + + const publicApi = symbols.filter((s) => publicIds.has(s.id)).map(mapSymbol); + const internal = symbols.filter((s) => !publicIds.has(s.id)).map(mapSymbol); + + const imports = (findImportTargets(db, fn.id) as ImportEdgeRow[]).map((r) => ({ + file: r.file, + })); + const importedBy = (findImportSources(db, fn.id) as ImportEdgeRow[]).map((r) => ({ + file: r.file, + })); + + const intraEdges = findIntraFileCallEdges(db, fn.file) as IntraFileCallEdge[]; + const dataFlowMap = new Map(); + for (const edge of intraEdges) { + if (!dataFlowMap.has(edge.caller_name)) dataFlowMap.set(edge.caller_name, []); + dataFlowMap.get(edge.caller_name)!.push(edge.callee_name); + } + const dataFlow = [...dataFlowMap.entries()].map(([caller, callees]) => ({ + caller, + callees, + })); + + const metric = db + .prepare(`SELECT nm.line_count FROM node_metrics nm WHERE nm.node_id = ?`) + .get(fn.id) as { line_count: number } | undefined; + let lineCount: number | null = metric?.line_count || null; + if (!lineCount) { + const maxLine = db + .prepare(`SELECT MAX(end_line) as max_end FROM nodes WHERE file = ?`) + .get(fn.file) as { max_end: number | null } | undefined; + lineCount = maxLine?.max_end || null; + } + + return { + file: fn.file, + lineCount, + symbolCount: symbols.length, + publicApi, + internal, + imports, + importedBy, + dataFlow, + }; + }); +} + +function explainFunctionImpl( + db: BetterSqlite3.Database, + target: string, + noTests: boolean, + getFileLines: (file: string) => string[] | null, + displayOpts: DisplayOpts, +) { + let nodes = db + .prepare( + `SELECT * FROM nodes WHERE name LIKE ? AND kind IN ('function','method','class','interface','type','struct','enum','trait','record','module','constant') ORDER BY file, line`, + ) + .all(`%${target}%`) as NodeRow[]; + if (noTests) nodes = nodes.filter((n) => !isTestFile(n.file)); + if (nodes.length === 0) return []; + + const hc = new Map(); + return nodes.slice(0, 10).map((node) => { + const fileLines = getFileLines(node.file); + const lineCount = node.end_line ? node.end_line - node.line + 1 : null; + const summary = fileLines ? extractSummary(fileLines, node.line, displayOpts) : null; + const signature = fileLines ? extractSignature(fileLines, node.line, displayOpts) : null; + + const callees = (findCallees(db, node.id) as RelatedNodeRow[]).map((c) => ({ + name: c.name, + kind: c.kind, + file: c.file, + line: c.line, + })); + + let callers = (findCallers(db, node.id) as RelatedNodeRow[]).map((c) => ({ + name: c.name, + kind: c.kind, + file: c.file, + line: c.line, + })); + if (noTests) callers = callers.filter((c) => !isTestFile(c.file)); + + const testCallerRows = findCallers(db, node.id) as RelatedNodeRow[]; + const seenFiles = new Set(); + const relatedTests = testCallerRows + .filter((r) => isTestFile(r.file) && !seenFiles.has(r.file) && seenFiles.add(r.file)) + .map((r) => ({ file: r.file })); + + return { + ...normalizeSymbol(node, db, hc), + lineCount, + summary, + signature, + complexity: getComplexityMetrics(db, node.id), + callees, + callers, + relatedTests, + }; + }); +} + +// biome-ignore lint/suspicious/noExplicitAny: explainFunctionImpl results have dynamic shape with _depth +function explainCallees( + parentResults: any[], + currentDepth: number, + visited: Set, + db: BetterSqlite3.Database, + noTests: boolean, + getFileLines: (file: string) => string[] | null, + displayOpts: DisplayOpts, +): void { + if (currentDepth <= 0) return; + for (const r of parentResults) { + const newCallees: typeof parentResults = []; + for (const callee of r.callees) { + const key = `${callee.name}:${callee.file}:${callee.line}`; + if (visited.has(key)) continue; + visited.add(key); + const calleeResults = explainFunctionImpl( + db, + callee.name, + noTests, + getFileLines, + displayOpts, + ); + const exact = calleeResults.find((cr) => cr.file === callee.file && cr.line === callee.line); + if (exact) { + (exact as Record)['_depth'] = + (((r as Record)['_depth'] as number) || 0) + 1; + newCallees.push(exact); + } + } + if (newCallees.length > 0) { + r.depDetails = newCallees; + explainCallees(newCallees, currentDepth - 1, visited, db, noTests, getFileLines, displayOpts); + } + } +} + +// --- Exported functions --- + +export function contextData( + name: string, + customDbPath: string, + opts: { + depth?: number; + noSource?: boolean; + noTests?: boolean; + includeTests?: boolean; + file?: string; + kind?: string; + limit?: number; + offset?: number; + // biome-ignore lint/suspicious/noExplicitAny: config shape is dynamic + config?: any; + } = {}, +) { + const db = openReadonlyOrFail(customDbPath); + try { + const depth = opts.depth || 0; + const noSource = opts.noSource || false; + const noTests = opts.noTests || false; + const includeTests = opts.includeTests || false; + + const config = opts.config || loadConfig(); + const displayOpts: DisplayOpts = config.display || {}; + + const dbPath = findDbPath(customDbPath); + const repoRoot = path.resolve(path.dirname(dbPath), '..'); + + const nodes = findMatchingNodes(db, name, { noTests, file: opts.file, kind: opts.kind }); + if (nodes.length === 0) { + return { name, results: [] }; + } + + const getFileLines = createFileLinesReader(repoRoot); + + const results = nodes.map((node) => { + const fileLines = getFileLines(node.file); + + const source = noSource + ? null + : readSourceRange(repoRoot, node.file, node.line, node.end_line, displayOpts); + + const signature = fileLines ? extractSignature(fileLines, node.line, displayOpts) : null; + + const callees = buildCallees(db, node, repoRoot, getFileLines, { + noTests, + depth, + displayOpts, + }); + const callers = buildCallers(db, node, noTests); + const relatedTests = buildRelatedTests(db, node, getFileLines, includeTests); + const complexityMetrics = getComplexityMetrics(db, node.id); + const nodeChildren = getNodeChildrenSafe(db, node.id); + const implInfo = buildImplementationInfo(db, node, noTests); + + return { + name: node.name, + kind: node.kind, + file: node.file, + line: node.line, + role: node.role || null, + endLine: node.end_line || null, + source, + signature, + complexity: complexityMetrics, + children: nodeChildren.length > 0 ? nodeChildren : undefined, + callees, + callers, + relatedTests, + ...implInfo, + }; + }); + + const base = { name, results }; + return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset }); + } finally { + db.close(); + } +} + +export function explainData( + target: string, + customDbPath: string, + opts: { + noTests?: boolean; + depth?: number; + limit?: number; + offset?: number; + // biome-ignore lint/suspicious/noExplicitAny: config shape is dynamic + config?: any; + } = {}, +) { + const db = openReadonlyOrFail(customDbPath); + try { + const noTests = opts.noTests || false; + const depth = opts.depth || 0; + const kind = isFileLikeTarget(target) ? 'file' : 'function'; + + const config = opts.config || loadConfig(); + const displayOpts: DisplayOpts = config.display || {}; + + const dbPath = findDbPath(customDbPath); + const repoRoot = path.resolve(path.dirname(dbPath), '..'); + + const getFileLines = createFileLinesReader(repoRoot); + + const results = + kind === 'file' + ? explainFileImpl(db, target, getFileLines, displayOpts) + : explainFunctionImpl(db, target, noTests, getFileLines, displayOpts); + + if (kind === 'function' && depth > 0 && results.length > 0) { + // biome-ignore lint/suspicious/noExplicitAny: results are function results when kind === 'function' + const visited = new Set(results.map((r: any) => `${r.name}:${r.file}:${r.line ?? ''}`)); + explainCallees(results, depth, visited, db, noTests, getFileLines, displayOpts); + } + + const base = { target, kind, results }; + return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset }); + } finally { + db.close(); + } +} diff --git a/src/domain/analysis/dependencies.ts b/src/domain/analysis/dependencies.ts new file mode 100644 index 00000000..52c78be6 --- /dev/null +++ b/src/domain/analysis/dependencies.ts @@ -0,0 +1,474 @@ +import type BetterSqlite3 from 'better-sqlite3'; +import { + findCallees, + findCallers, + findFileNodes, + findImportSources, + findImportTargets, + findNodesByFile, + openReadonlyOrFail, +} from '../../db/index.js'; +import { isTestFile } from '../../infrastructure/test-filter.js'; +import { resolveMethodViaHierarchy } from '../../shared/hierarchy.js'; +import { normalizeSymbol } from '../../shared/normalize.js'; +import { paginateResult } from '../../shared/paginate.js'; +import type { ImportEdgeRow, NodeRow, RelatedNodeRow } from '../../types.js'; +import { findMatchingNodes } from './symbol-lookup.js'; + +export function fileDepsData( + file: string, + customDbPath: string, + opts: { noTests?: boolean; limit?: number; offset?: number } = {}, +) { + const db = openReadonlyOrFail(customDbPath); + try { + const noTests = opts.noTests || false; + const fileNodes = findFileNodes(db, `%${file}%`) as NodeRow[]; + if (fileNodes.length === 0) { + return { file, results: [] }; + } + + const results = fileNodes.map((fn) => { + let importsTo = findImportTargets(db, fn.id) as ImportEdgeRow[]; + if (noTests) importsTo = importsTo.filter((i) => !isTestFile(i.file)); + + let importedBy = findImportSources(db, fn.id) as ImportEdgeRow[]; + if (noTests) importedBy = importedBy.filter((i) => !isTestFile(i.file)); + + const defs = findNodesByFile(db, fn.file) as NodeRow[]; + + return { + file: fn.file, + imports: importsTo.map((i) => ({ file: i.file, typeOnly: i.edge_kind === 'imports-type' })), + importedBy: importedBy.map((i) => ({ file: i.file })), + definitions: defs.map((d) => ({ name: d.name, kind: d.kind, line: d.line })), + }; + }); + + const base = { file, results }; + return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset }); + } finally { + db.close(); + } +} + +/** + * BFS transitive caller traversal starting from `callers` of `nodeId`. + * Returns an object keyed by depth (2..depth) -> array of caller descriptors. + */ +function buildTransitiveCallers( + db: BetterSqlite3.Database, + callers: Array<{ name: string; kind: string; file: string; line: number }>, + nodeId: number, + depth: number, + noTests: boolean, +) { + const transitiveCallers: Record< + number, + Array<{ name: string; kind: string; file: string; line: number }> + > = {}; + if (depth <= 1) return transitiveCallers; + + const visited = new Set([nodeId]); + let frontier = callers + .map((c) => { + const row = db + .prepare('SELECT id FROM nodes WHERE name = ? AND kind = ? AND file = ? AND line = ?') + .get(c.name, c.kind, c.file, c.line) as { id: number } | undefined; + return row ? { ...c, id: row.id } : null; + }) + .filter(Boolean) as Array<{ + name: string; + kind: string; + file: string; + line: number; + id: number; + }>; + + for (let d = 2; d <= depth; d++) { + const nextFrontier: typeof frontier = []; + for (const f of frontier) { + if (visited.has(f.id)) continue; + visited.add(f.id); + const upstream = db + .prepare(` + SELECT n.name, n.kind, n.file, n.line + FROM edges e JOIN nodes n ON e.source_id = n.id + WHERE e.target_id = ? AND e.kind = 'calls' + `) + .all(f.id) as Array<{ name: string; kind: string; file: string; line: number }>; + for (const u of upstream) { + if (noTests && isTestFile(u.file)) continue; + const uid = ( + db + .prepare('SELECT id FROM nodes WHERE name = ? AND kind = ? AND file = ? AND line = ?') + .get(u.name, u.kind, u.file, u.line) as { id: number } | undefined + )?.id; + if (uid && !visited.has(uid)) { + nextFrontier.push({ ...u, id: uid }); + } + } + } + if (nextFrontier.length > 0) { + transitiveCallers[d] = nextFrontier.map((n) => ({ + name: n.name, + kind: n.kind, + file: n.file, + line: n.line, + })); + } + frontier = nextFrontier; + if (frontier.length === 0) break; + } + + return transitiveCallers; +} + +export function fnDepsData( + name: string, + customDbPath: string, + opts: { + depth?: number; + noTests?: boolean; + file?: string; + kind?: string; + limit?: number; + offset?: number; + } = {}, +) { + const db = openReadonlyOrFail(customDbPath); + try { + const depth = opts.depth || 3; + const noTests = opts.noTests || false; + const hc = new Map(); + + const nodes = findMatchingNodes(db, name, { noTests, file: opts.file, kind: opts.kind }); + if (nodes.length === 0) { + return { name, results: [] }; + } + + const results = nodes.map((node) => { + const callees = findCallees(db, node.id) as RelatedNodeRow[]; + const filteredCallees = noTests ? callees.filter((c) => !isTestFile(c.file)) : callees; + + let callers: Array = findCallers( + db, + node.id, + ) as RelatedNodeRow[]; + + if (node.kind === 'method' && node.name.includes('.')) { + const methodName = node.name.split('.').pop()!; + const relatedMethods = resolveMethodViaHierarchy(db, methodName); + for (const rm of relatedMethods) { + if (rm.id === node.id) continue; + const extraCallers = findCallers(db, rm.id) as RelatedNodeRow[]; + callers.push(...extraCallers.map((c) => ({ ...c, viaHierarchy: rm.name }))); + } + } + if (noTests) callers = callers.filter((c) => !isTestFile(c.file)); + + const transitiveCallers = buildTransitiveCallers(db, callers, node.id, depth, noTests); + + return { + ...normalizeSymbol(node, db, hc), + callees: filteredCallees.map((c) => ({ + name: c.name, + kind: c.kind, + file: c.file, + line: c.line, + })), + callers: callers.map((c) => ({ + name: c.name, + kind: c.kind, + file: c.file, + line: c.line, + viaHierarchy: c.viaHierarchy || undefined, + })), + transitiveCallers, + }; + }); + + const base = { name, results }; + return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset }); + } finally { + db.close(); + } +} + +/** + * Resolve from/to symbol names to node records. + * Returns { sourceNode, targetNode, fromCandidates, toCandidates } on success, + * or { earlyResult } when a caller-facing error/not-found response should be returned immediately. + */ +function resolveEndpoints( + db: BetterSqlite3.Database, + from: string, + to: string, + opts: { noTests?: boolean; fromFile?: string; toFile?: string; kind?: string }, +) { + const { noTests = false } = opts; + + const fromNodes = findMatchingNodes(db, from, { + noTests, + file: opts.fromFile, + kind: opts.kind, + }); + if (fromNodes.length === 0) { + return { + earlyResult: { + from, + to, + found: false, + error: `No symbol matching "${from}"`, + fromCandidates: [], + toCandidates: [], + }, + }; + } + + const toNodes = findMatchingNodes(db, to, { + noTests, + file: opts.toFile, + kind: opts.kind, + }); + if (toNodes.length === 0) { + return { + earlyResult: { + from, + to, + found: false, + error: `No symbol matching "${to}"`, + fromCandidates: fromNodes + .slice(0, 5) + .map((n) => ({ name: n.name, kind: n.kind, file: n.file, line: n.line })), + toCandidates: [], + }, + }; + } + + const fromCandidates = fromNodes + .slice(0, 5) + .map((n) => ({ name: n.name, kind: n.kind, file: n.file, line: n.line })); + const toCandidates = toNodes + .slice(0, 5) + .map((n) => ({ name: n.name, kind: n.kind, file: n.file, line: n.line })); + + return { + sourceNode: fromNodes[0], + targetNode: toNodes[0], + fromCandidates, + toCandidates, + }; +} + +/** + * BFS from sourceId toward targetId. + * Returns { found, parent, alternateCount, foundDepth }. + * `parent` maps nodeId -> { parentId, edgeKind }. + */ +function bfsShortestPath( + db: BetterSqlite3.Database, + sourceId: number, + targetId: number, + edgeKinds: string[], + reverse: boolean, + maxDepth: number, + noTests: boolean, +) { + const kindPlaceholders = edgeKinds.map(() => '?').join(', '); + + // Forward: source_id -> target_id (A calls... calls B) + // Reverse: target_id -> source_id (B is called by... called by A) + const neighborQuery = reverse + ? `SELECT n.id, n.name, n.kind, n.file, n.line, e.kind AS edge_kind + FROM edges e JOIN nodes n ON e.source_id = n.id + WHERE e.target_id = ? AND e.kind IN (${kindPlaceholders})` + : `SELECT n.id, n.name, n.kind, n.file, n.line, e.kind AS edge_kind + FROM edges e JOIN nodes n ON e.target_id = n.id + WHERE e.source_id = ? AND e.kind IN (${kindPlaceholders})`; + const neighborStmt = db.prepare(neighborQuery); + + const visited = new Set([sourceId]); + const parent = new Map(); + let queue = [sourceId]; + let found = false; + let alternateCount = 0; + let foundDepth = -1; + + for (let depth = 1; depth <= maxDepth; depth++) { + const nextQueue: number[] = []; + for (const currentId of queue) { + const neighbors = neighborStmt.all(currentId, ...edgeKinds) as Array<{ + id: number; + name: string; + kind: string; + file: string; + line: number; + edge_kind: string; + }>; + for (const n of neighbors) { + if (noTests && isTestFile(n.file)) continue; + if (n.id === targetId) { + if (!found) { + found = true; + foundDepth = depth; + parent.set(n.id, { parentId: currentId, edgeKind: n.edge_kind }); + } + alternateCount++; + continue; + } + if (!visited.has(n.id)) { + visited.add(n.id); + parent.set(n.id, { parentId: currentId, edgeKind: n.edge_kind }); + nextQueue.push(n.id); + } + } + } + if (found) break; + queue = nextQueue; + if (queue.length === 0) break; + } + + return { found, parent, alternateCount, foundDepth }; +} + +/** + * Walk the parent map from targetId back to sourceId and return an ordered + * array of node IDs source -> target. + */ +function reconstructPath( + db: BetterSqlite3.Database, + pathIds: number[], + parent: Map, +) { + const nodeCache = new Map(); + const getNode = (id: number) => { + if (nodeCache.has(id)) return nodeCache.get(id)!; + const row = db.prepare('SELECT name, kind, file, line FROM nodes WHERE id = ?').get(id) as { + name: string; + kind: string; + file: string; + line: number; + }; + nodeCache.set(id, row); + return row; + }; + + return pathIds.map((id, idx) => { + const node = getNode(id); + const edgeKind = idx === 0 ? null : parent.get(id)!.edgeKind; + return { name: node.name, kind: node.kind, file: node.file, line: node.line, edgeKind }; + }); +} + +export function pathData( + from: string, + to: string, + customDbPath: string, + opts: { + noTests?: boolean; + maxDepth?: number; + edgeKinds?: string[]; + reverse?: boolean; + fromFile?: string; + toFile?: string; + kind?: string; + } = {}, +) { + const db = openReadonlyOrFail(customDbPath); + try { + const noTests = opts.noTests || false; + const maxDepth = opts.maxDepth || 10; + const edgeKinds = opts.edgeKinds || ['calls']; + const reverse = opts.reverse || false; + + const resolved = resolveEndpoints(db, from, to, { + noTests, + fromFile: opts.fromFile, + toFile: opts.toFile, + kind: opts.kind, + }); + if ('earlyResult' in resolved) return resolved.earlyResult; + + const { sourceNode, targetNode, fromCandidates, toCandidates } = resolved; + + // Self-path + if (sourceNode!.id === targetNode!.id) { + return { + from, + to, + fromCandidates, + toCandidates, + found: true, + hops: 0, + path: [ + { + name: sourceNode!.name, + kind: sourceNode!.kind, + file: sourceNode!.file, + line: sourceNode!.line, + edgeKind: null, + }, + ], + alternateCount: 0, + edgeKinds, + reverse, + maxDepth, + }; + } + + const { + found, + parent, + alternateCount: rawAlternateCount, + foundDepth, + } = bfsShortestPath(db, sourceNode!.id, targetNode!.id, edgeKinds, reverse, maxDepth, noTests); + + if (!found) { + return { + from, + to, + fromCandidates, + toCandidates, + found: false, + hops: null, + path: [], + alternateCount: 0, + edgeKinds, + reverse, + maxDepth, + }; + } + + // rawAlternateCount includes the one we kept; subtract 1 for "alternates" + const alternateCount = Math.max(0, rawAlternateCount - 1); + + // Reconstruct path from target back to source + const pathIds = [targetNode!.id]; + let cur = targetNode!.id; + while (cur !== sourceNode!.id) { + const p = parent.get(cur)!; + pathIds.push(p.parentId); + cur = p.parentId; + } + pathIds.reverse(); + + const resultPath = reconstructPath(db, pathIds, parent); + + return { + from, + to, + fromCandidates, + toCandidates, + found: true, + hops: foundDepth, + path: resultPath, + alternateCount, + edgeKinds, + reverse, + maxDepth, + }; + } finally { + db.close(); + } +} diff --git a/src/domain/analysis/exports.ts b/src/domain/analysis/exports.ts new file mode 100644 index 00000000..e64a756f --- /dev/null +++ b/src/domain/analysis/exports.ts @@ -0,0 +1,233 @@ +import path from 'node:path'; +import type BetterSqlite3 from 'better-sqlite3'; +import { + findCrossFileCallTargets, + findDbPath, + findFileNodes, + findNodesByFile, + openReadonlyOrFail, +} from '../../db/index.js'; +import { loadConfig } from '../../infrastructure/config.js'; +import { debug } from '../../infrastructure/logger.js'; +import { isTestFile } from '../../infrastructure/test-filter.js'; +import { + createFileLinesReader, + extractSignature, + extractSummary, +} from '../../shared/file-utils.js'; +import { paginateResult } from '../../shared/paginate.js'; +import type { NodeRow } from '../../types.js'; + +export function exportsData( + file: string, + customDbPath: string, + opts: { + noTests?: boolean; + unused?: boolean; + limit?: number; + offset?: number; + // biome-ignore lint/suspicious/noExplicitAny: config shape is dynamic + config?: any; + } = {}, +) { + const db = openReadonlyOrFail(customDbPath); + try { + const noTests = opts.noTests || false; + + const config = opts.config || loadConfig(); + const displayOpts = config.display || {}; + + const dbFilePath = findDbPath(customDbPath); + const repoRoot = path.resolve(path.dirname(dbFilePath), '..'); + + const getFileLines = createFileLinesReader(repoRoot); + + const unused = opts.unused || false; + const fileResults = exportsFileImpl(db, file, noTests, getFileLines, unused, displayOpts); + + if (fileResults.length === 0) { + return paginateResult( + { + file, + results: [], + reexports: [], + reexportedSymbols: [], + totalExported: 0, + totalInternal: 0, + totalUnused: 0, + totalReexported: 0, + totalReexportedUnused: 0, + }, + 'results', + { limit: opts.limit, offset: opts.offset }, + ); + } + + // For single-file match return flat; for multi-match return first (like explainData) + const first = fileResults[0]!; + const base = { + file: first.file, + results: first.results, + reexports: first.reexports, + reexportedSymbols: first.reexportedSymbols, + totalExported: first.totalExported, + totalInternal: first.totalInternal, + totalUnused: first.totalUnused, + totalReexported: first.totalReexported, + totalReexportedUnused: first.totalReexportedUnused, + }; + // biome-ignore lint/suspicious/noExplicitAny: paginateResult returns dynamic shape + const paginated: any = paginateResult(base, 'results', { + limit: opts.limit, + offset: opts.offset, + }); + // Paginate reexportedSymbols with the same limit/offset (match paginateResult behaviour) + if (opts.limit != null) { + const off = opts.offset || 0; + paginated.reexportedSymbols = paginated.reexportedSymbols.slice(off, off + opts.limit); + // Update _pagination.hasMore to account for reexportedSymbols (barrel-only files + // have empty results[], so hasMore would always be false without this) + if (paginated._pagination) { + const reexTotal = opts.unused ? base.totalReexportedUnused : base.totalReexported; + const resultsHasMore = paginated._pagination.hasMore; + const reexHasMore = off + opts.limit < reexTotal; + paginated._pagination.hasMore = resultsHasMore || reexHasMore; + } + } + return paginated; + } finally { + db.close(); + } +} + +function exportsFileImpl( + db: BetterSqlite3.Database, + target: string, + noTests: boolean, + getFileLines: (file: string) => string[] | null, + unused: boolean, + displayOpts: Record, +) { + const fileNodes = findFileNodes(db, `%${target}%`) as NodeRow[]; + if (fileNodes.length === 0) return []; + + // Detect whether exported column exists + let hasExportedCol = false; + try { + // biome-ignore lint/suspicious/noExplicitAny: Statement.raw() exists at runtime but is missing from type declarations + (db.prepare('SELECT exported FROM nodes LIMIT 0') as any).raw(true); + hasExportedCol = true; + } catch (e: unknown) { + debug(`exported column not available, using fallback: ${(e as Error).message}`); + } + + return fileNodes.map((fn) => { + const symbols = findNodesByFile(db, fn.file) as NodeRow[]; + + let exported: NodeRow[]; + if (hasExportedCol) { + // Use the exported column populated during build + exported = db + .prepare( + "SELECT * FROM nodes WHERE file = ? AND kind != 'file' AND exported = 1 ORDER BY line", + ) + .all(fn.file) as NodeRow[]; + } else { + // Fallback: symbols that have incoming calls from other files + const exportedIds = findCrossFileCallTargets(db, fn.file) as Set; + exported = symbols.filter((s) => exportedIds.has(s.id)); + } + const internalCount = symbols.length - exported.length; + + const buildSymbolResult = (s: NodeRow, fileLines: string[] | null) => { + let consumers = db + .prepare( + `SELECT n.name, n.file, n.line FROM edges e JOIN nodes n ON e.source_id = n.id + WHERE e.target_id = ? AND e.kind = 'calls'`, + ) + .all(s.id) as Array<{ name: string; file: string; line: number }>; + if (noTests) consumers = consumers.filter((c) => !isTestFile(c.file)); + + return { + name: s.name, + kind: s.kind, + line: s.line, + endLine: s.end_line ?? null, + role: s.role || null, + signature: fileLines ? extractSignature(fileLines, s.line, displayOpts) : null, + summary: fileLines ? extractSummary(fileLines, s.line, displayOpts) : null, + consumers: consumers.map((c) => ({ name: c.name, file: c.file, line: c.line })), + consumerCount: consumers.length, + }; + }; + + const results = exported.map((s) => buildSymbolResult(s, getFileLines(fn.file))); + + const totalUnused = results.filter((r) => r.consumerCount === 0).length; + + // Files that re-export this file (barrel -> this file) + const reexports = ( + db + .prepare( + `SELECT DISTINCT n.file FROM edges e JOIN nodes n ON e.source_id = n.id + WHERE e.target_id = ? AND e.kind = 'reexports'`, + ) + .all(fn.id) as Array<{ file: string }> + ).map((r) => ({ file: r.file })); + + // For barrel files: gather symbols re-exported from target modules + const reexportTargets = db + .prepare( + `SELECT DISTINCT n.file FROM edges e JOIN nodes n ON e.target_id = n.id + WHERE e.source_id = ? AND e.kind = 'reexports'`, + ) + .all(fn.id) as Array<{ file: string }>; + + const reexportedSymbols: Array & { originFile: string }> = + []; + for (const reexTarget of reexportTargets) { + let targetExported: NodeRow[]; + if (hasExportedCol) { + targetExported = db + .prepare( + "SELECT * FROM nodes WHERE file = ? AND kind != 'file' AND exported = 1 ORDER BY line", + ) + .all(reexTarget.file) as NodeRow[]; + } else { + // Fallback: same heuristic as direct exports — symbols called from other files + const targetSymbols = findNodesByFile(db, reexTarget.file) as NodeRow[]; + const exportedIds = findCrossFileCallTargets(db, reexTarget.file) as Set; + targetExported = targetSymbols.filter((s) => exportedIds.has(s.id)); + } + for (const s of targetExported) { + const fileLines = getFileLines(reexTarget.file); + reexportedSymbols.push({ + ...buildSymbolResult(s, fileLines), + originFile: reexTarget.file, + }); + } + } + + let filteredResults = results; + let filteredReexported = reexportedSymbols; + if (unused) { + filteredResults = results.filter((r) => r.consumerCount === 0); + filteredReexported = reexportedSymbols.filter((r) => r.consumerCount === 0); + } + + const totalReexported = reexportedSymbols.length; + const totalReexportedUnused = reexportedSymbols.filter((r) => r.consumerCount === 0).length; + + return { + file: fn.file, + results: filteredResults, + reexports, + reexportedSymbols: filteredReexported, + totalExported: exported.length, + totalInternal: internalCount, + totalUnused, + totalReexported, + totalReexportedUnused, + }; + }); +} diff --git a/src/domain/analysis/impact.ts b/src/domain/analysis/impact.ts new file mode 100644 index 00000000..261d2462 --- /dev/null +++ b/src/domain/analysis/impact.ts @@ -0,0 +1,718 @@ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import type BetterSqlite3 from 'better-sqlite3'; +import { + findDbPath, + findDistinctCallers, + findFileNodes, + findImplementors, + findImportDependents, + findNodeById, + openReadonlyOrFail, +} from '../../db/index.js'; +import { evaluateBoundaries } from '../../features/boundaries.js'; +import { coChangeForFiles } from '../../features/cochange.js'; +import { ownersForFiles } from '../../features/owners.js'; +import { loadConfig } from '../../infrastructure/config.js'; +import { debug } from '../../infrastructure/logger.js'; +import { isTestFile } from '../../infrastructure/test-filter.js'; +import { normalizeSymbol } from '../../shared/normalize.js'; +import { paginateResult } from '../../shared/paginate.js'; +import type { NodeRow, RelatedNodeRow } from '../../types.js'; +import { findMatchingNodes } from './symbol-lookup.js'; + +// --- Shared BFS: transitive callers --- + +const INTERFACE_LIKE_KINDS = new Set(['interface', 'trait']); + +/** + * Check whether the graph contains any 'implements' edges. + * Cached per db handle so the query runs at most once per connection. + */ +const _hasImplementsCache: WeakMap = new WeakMap(); +function hasImplementsEdges(db: BetterSqlite3.Database): boolean { + if (_hasImplementsCache.has(db)) return _hasImplementsCache.get(db)!; + const row = db.prepare("SELECT 1 FROM edges WHERE kind = 'implements' LIMIT 1").get(); + const result = !!row; + _hasImplementsCache.set(db, result); + return result; +} + +/** + * BFS traversal to find transitive callers of a node. + * When an interface/trait node is encountered (either as the start node or + * during traversal), its concrete implementors are also added to the frontier + * so that changes to an interface signature propagate to all implementors. + */ +export function bfsTransitiveCallers( + db: BetterSqlite3.Database, + startId: number, + { + noTests = false, + maxDepth = 3, + includeImplementors = true, + onVisit, + }: { + noTests?: boolean; + maxDepth?: number; + includeImplementors?: boolean; + onVisit?: ( + caller: RelatedNodeRow & { viaImplements?: boolean }, + parentId: number, + depth: number, + ) => void; + } = {}, +) { + // Skip all implementor lookups when the graph has no implements edges + const resolveImplementors = includeImplementors && hasImplementsEdges(db); + + const visited = new Set([startId]); + const levels: Record< + number, + Array<{ name: string; kind: string; file: string; line: number; viaImplements?: boolean }> + > = {}; + let frontier = [startId]; + + // Seed: if start node is an interface/trait, include its implementors at depth 1. + // Implementors go into a separate list so their callers appear at depth 2, not depth 1. + const implNextFrontier: number[] = []; + if (resolveImplementors) { + const startNode = findNodeById(db, startId) as NodeRow | undefined; + if (startNode && INTERFACE_LIKE_KINDS.has(startNode.kind)) { + const impls = findImplementors(db, startId) as RelatedNodeRow[]; + for (const impl of impls) { + if (!visited.has(impl.id) && (!noTests || !isTestFile(impl.file))) { + visited.add(impl.id); + implNextFrontier.push(impl.id); + if (!levels[1]) levels[1] = []; + levels[1].push({ + name: impl.name, + kind: impl.kind, + file: impl.file, + line: impl.line, + viaImplements: true, + }); + if (onVisit) onVisit({ ...impl, viaImplements: true }, startId, 1); + } + } + } + } + + for (let d = 1; d <= maxDepth; d++) { + // On the first wave, merge seeded implementors so their callers appear at d=2 + if (d === 1 && implNextFrontier.length > 0) { + frontier = [...frontier, ...implNextFrontier]; + } + const nextFrontier: number[] = []; + for (const fid of frontier) { + const callers = findDistinctCallers(db, fid) as RelatedNodeRow[]; + for (const c of callers) { + if (!visited.has(c.id) && (!noTests || !isTestFile(c.file))) { + visited.add(c.id); + nextFrontier.push(c.id); + if (!levels[d]) levels[d] = []; + levels[d]!.push({ name: c.name, kind: c.kind, file: c.file, line: c.line }); + if (onVisit) onVisit(c, fid, d); + } + + // If a caller is an interface/trait, also pull in its implementors + // Implementors are one extra hop away, so record at d+1 + if (resolveImplementors && INTERFACE_LIKE_KINDS.has(c.kind)) { + const impls = findImplementors(db, c.id) as RelatedNodeRow[]; + for (const impl of impls) { + if (!visited.has(impl.id) && (!noTests || !isTestFile(impl.file))) { + visited.add(impl.id); + nextFrontier.push(impl.id); + const implDepth = d + 1; + if (!levels[implDepth]) levels[implDepth] = []; + levels[implDepth].push({ + name: impl.name, + kind: impl.kind, + file: impl.file, + line: impl.line, + viaImplements: true, + }); + if (onVisit) onVisit({ ...impl, viaImplements: true }, c.id, implDepth); + } + } + } + } + } + frontier = nextFrontier; + if (frontier.length === 0) break; + } + + return { totalDependents: visited.size - 1, levels }; +} + +export function impactAnalysisData( + file: string, + customDbPath: string, + opts: { noTests?: boolean } = {}, +) { + const db = openReadonlyOrFail(customDbPath); + try { + const noTests = opts.noTests || false; + const fileNodes = findFileNodes(db, `%${file}%`) as NodeRow[]; + if (fileNodes.length === 0) { + return { file, sources: [], levels: {}, totalDependents: 0 }; + } + + const visited = new Set(); + const queue: number[] = []; + const levels = new Map(); + + for (const fn of fileNodes) { + visited.add(fn.id); + queue.push(fn.id); + levels.set(fn.id, 0); + } + + while (queue.length > 0) { + const current = queue.shift()!; + const level = levels.get(current)!; + const dependents = findImportDependents(db, current) as RelatedNodeRow[]; + for (const dep of dependents) { + if (!visited.has(dep.id) && (!noTests || !isTestFile(dep.file))) { + visited.add(dep.id); + queue.push(dep.id); + levels.set(dep.id, level + 1); + } + } + } + + const byLevel: Record> = {}; + for (const [id, level] of levels) { + if (level === 0) continue; + if (!byLevel[level]) byLevel[level] = []; + const node = findNodeById(db, id) as NodeRow | undefined; + if (node) byLevel[level].push({ file: node.file }); + } + + return { + file, + sources: fileNodes.map((f) => f.file), + levels: byLevel, + totalDependents: visited.size - fileNodes.length, + }; + } finally { + db.close(); + } +} + +export function fnImpactData( + name: string, + customDbPath: string, + opts: { + depth?: number; + noTests?: boolean; + file?: string; + kind?: string; + includeImplementors?: boolean; + limit?: number; + offset?: number; + // biome-ignore lint/suspicious/noExplicitAny: config shape is dynamic + config?: any; + } = {}, +) { + const db = openReadonlyOrFail(customDbPath); + try { + const config = opts.config || loadConfig(); + const maxDepth = opts.depth || config.analysis?.fnImpactDepth || 5; + const noTests = opts.noTests || false; + const hc = new Map(); + + const nodes = findMatchingNodes(db, name, { noTests, file: opts.file, kind: opts.kind }); + if (nodes.length === 0) { + return { name, results: [] }; + } + + const includeImplementors = opts.includeImplementors !== false; + + const results = nodes.map((node) => { + const { levels, totalDependents } = bfsTransitiveCallers(db, node.id, { + noTests, + maxDepth, + includeImplementors, + }); + return { + ...normalizeSymbol(node, db, hc), + levels, + totalDependents, + }; + }); + + const base = { name, results }; + return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset }); + } finally { + db.close(); + } +} + +// --- diffImpactData helpers --- + +/** + * Walk up from repoRoot until a .git directory is found. + * Returns true if a git root exists, false otherwise. + */ +function findGitRoot(repoRoot: string): boolean { + let checkDir = repoRoot; + while (checkDir) { + if (fs.existsSync(path.join(checkDir, '.git'))) { + return true; + } + const parent = path.dirname(checkDir); + if (parent === checkDir) break; + checkDir = parent; + } + return false; +} + +/** + * Execute git diff and return the raw output string. + * Returns `{ output: string }` on success or `{ error: string }` on failure. + */ +function runGitDiff( + repoRoot: string, + opts: { staged?: boolean; ref?: string }, +): { output: string; error?: never } | { error: string; output?: never } { + try { + const args = opts.staged + ? ['diff', '--cached', '--unified=0', '--no-color'] + : ['diff', opts.ref || 'HEAD', '--unified=0', '--no-color']; + const output = execFileSync('git', args, { + cwd: repoRoot, + encoding: 'utf-8', + maxBuffer: 10 * 1024 * 1024, + stdio: ['pipe', 'pipe', 'pipe'], + }); + return { output }; + } catch (e: unknown) { + return { error: `Failed to run git diff: ${(e as Error).message}` }; + } +} + +/** + * Parse raw git diff output into a changedRanges map and newFiles set. + */ +function parseGitDiff(diffOutput: string) { + const changedRanges = new Map>(); + const newFiles = new Set(); + let currentFile: string | null = null; + let prevIsDevNull = false; + + for (const line of diffOutput.split('\n')) { + if (line.startsWith('--- /dev/null')) { + prevIsDevNull = true; + continue; + } + if (line.startsWith('--- ')) { + prevIsDevNull = false; + continue; + } + const fileMatch = line.match(/^\+\+\+ b\/(.+)/); + if (fileMatch) { + currentFile = fileMatch[1]!; + if (!changedRanges.has(currentFile)) changedRanges.set(currentFile, []); + if (prevIsDevNull) newFiles.add(currentFile!); + prevIsDevNull = false; + continue; + } + const hunkMatch = line.match(/^@@ .+ \+(\d+)(?:,(\d+))? @@/); + if (hunkMatch && currentFile) { + const start = parseInt(hunkMatch[1]!, 10); + const count = parseInt(hunkMatch[2] || '1', 10); + changedRanges.get(currentFile)!.push({ start, end: start + count - 1 }); + } + } + + return { changedRanges, newFiles }; +} + +/** + * Find all function/method/class nodes whose line ranges overlap any changed range. + */ +function findAffectedFunctions( + db: BetterSqlite3.Database, + changedRanges: Map>, + noTests: boolean, +): NodeRow[] { + const affectedFunctions: NodeRow[] = []; + for (const [file, ranges] of changedRanges) { + if (noTests && isTestFile(file)) continue; + const defs = db + .prepare( + `SELECT * FROM nodes WHERE file = ? AND kind IN ('function', 'method', 'class') ORDER BY line`, + ) + .all(file) as NodeRow[]; + for (let i = 0; i < defs.length; i++) { + const def = defs[i]!; + const endLine = def.end_line || (defs[i + 1] ? defs[i + 1]!.line - 1 : 999999); + for (const range of ranges) { + if (range.start <= endLine && range.end >= def.line) { + affectedFunctions.push(def); + break; + } + } + } + } + return affectedFunctions; +} + +/** + * Run BFS per affected function, collecting per-function results and the full affected set. + */ +function buildFunctionImpactResults( + db: BetterSqlite3.Database, + affectedFunctions: NodeRow[], + noTests: boolean, + maxDepth: number, + includeImplementors = true, +) { + const allAffected = new Set(); + const functionResults = affectedFunctions.map((fn) => { + const edges: Array<{ from: string; to: string }> = []; + const idToKey = new Map(); + idToKey.set(fn.id, `${fn.file}::${fn.name}:${fn.line}`); + + const { levels, totalDependents } = bfsTransitiveCallers(db, fn.id, { + noTests, + maxDepth, + includeImplementors, + onVisit(c, parentId) { + allAffected.add(`${c.file}:${c.name}`); + const callerKey = `${c.file}::${c.name}:${c.line}`; + idToKey.set(c.id, callerKey); + edges.push({ from: idToKey.get(parentId)!, to: callerKey }); + }, + }); + + return { + name: fn.name, + kind: fn.kind, + file: fn.file, + line: fn.line, + transitiveCallers: totalDependents, + levels, + edges, + }; + }); + + return { functionResults, allAffected }; +} + +/** + * Look up historically co-changed files for the set of changed files. + * Returns an empty array if the co_changes table is unavailable. + */ +function lookupCoChanges( + db: BetterSqlite3.Database, + changedRanges: Map, + affectedFiles: Set, + noTests: boolean, +) { + try { + db.prepare('SELECT 1 FROM co_changes LIMIT 1').get(); + const changedFilesList = [...changedRanges.keys()]; + const coResults = coChangeForFiles(changedFilesList, db, { + minJaccard: 0.3, + limit: 20, + noTests, + }); + return coResults.filter((r: { file: string }) => !affectedFiles.has(r.file)); + } catch (e: unknown) { + debug(`co_changes lookup skipped: ${(e as Error).message}`); + return []; + } +} + +/** + * Look up CODEOWNERS for changed and affected files. + * Returns null if no owners are found or lookup fails. + */ +function lookupOwnership( + changedRanges: Map, + affectedFiles: Set, + repoRoot: string, +) { + try { + const allFilePaths = [...new Set([...changedRanges.keys(), ...affectedFiles])]; + const ownerResult = ownersForFiles(allFilePaths, repoRoot); + if (ownerResult.affectedOwners.length > 0) { + return { + owners: Object.fromEntries(ownerResult.owners), + affectedOwners: ownerResult.affectedOwners, + suggestedReviewers: ownerResult.suggestedReviewers, + }; + } + return null; + } catch (e: unknown) { + debug(`CODEOWNERS lookup skipped: ${(e as Error).message}`); + return null; + } +} + +/** + * Check manifesto boundary violations scoped to the changed files. + * Returns `{ boundaryViolations, boundaryViolationCount }`. + */ +function checkBoundaryViolations( + db: BetterSqlite3.Database, + changedRanges: Map, + noTests: boolean, + // biome-ignore lint/suspicious/noExplicitAny: opts shape varies by caller + opts: any, + repoRoot: string, +) { + try { + const cfg = opts.config || loadConfig(repoRoot); + const boundaryConfig = cfg.manifesto?.boundaries; + if (boundaryConfig) { + const result = evaluateBoundaries(db, boundaryConfig, { + scopeFiles: [...changedRanges.keys()], + noTests, + }); + return { + boundaryViolations: result.violations, + boundaryViolationCount: result.violationCount, + }; + } + } catch (e: unknown) { + debug(`boundary check skipped: ${(e as Error).message}`); + } + return { boundaryViolations: [], boundaryViolationCount: 0 }; +} + +// --- diffImpactData --- + +/** + * Fix #2: Shell injection vulnerability. + * Uses execFileSync instead of execSync to prevent shell interpretation of user input. + */ +export function diffImpactData( + customDbPath: string, + opts: { + noTests?: boolean; + depth?: number; + staged?: boolean; + ref?: string; + includeImplementors?: boolean; + limit?: number; + offset?: number; + // biome-ignore lint/suspicious/noExplicitAny: config shape is dynamic + config?: any; + } = {}, +) { + const db = openReadonlyOrFail(customDbPath); + try { + const noTests = opts.noTests || false; + const config = opts.config || loadConfig(); + const maxDepth = opts.depth || config.analysis?.impactDepth || 3; + + const dbPath = findDbPath(customDbPath); + const repoRoot = path.resolve(path.dirname(dbPath), '..'); + + if (!findGitRoot(repoRoot)) { + return { error: `Not a git repository: ${repoRoot}` }; + } + + const gitResult = runGitDiff(repoRoot, opts); + if ('error' in gitResult) return { error: gitResult.error }; + + if (!gitResult.output.trim()) { + return { + changedFiles: 0, + newFiles: [], + affectedFunctions: [], + affectedFiles: [], + summary: null, + }; + } + + const { changedRanges, newFiles } = parseGitDiff(gitResult.output); + + if (changedRanges.size === 0) { + return { + changedFiles: 0, + newFiles: [], + affectedFunctions: [], + affectedFiles: [], + summary: null, + }; + } + + const affectedFunctions = findAffectedFunctions(db, changedRanges, noTests); + const includeImplementors = opts.includeImplementors !== false; + const { functionResults, allAffected } = buildFunctionImpactResults( + db, + affectedFunctions, + noTests, + maxDepth, + includeImplementors, + ); + + const affectedFiles = new Set(); + for (const key of allAffected) affectedFiles.add(key.split(':')[0]!); + + const historicallyCoupled = lookupCoChanges(db, changedRanges, affectedFiles, noTests); + const ownership = lookupOwnership(changedRanges, affectedFiles, repoRoot); + const { boundaryViolations, boundaryViolationCount } = checkBoundaryViolations( + db, + changedRanges, + noTests, + opts, + repoRoot, + ); + + const base = { + changedFiles: changedRanges.size, + newFiles: [...newFiles], + affectedFunctions: functionResults, + affectedFiles: [...affectedFiles], + historicallyCoupled, + ownership, + boundaryViolations, + boundaryViolationCount, + summary: { + functionsChanged: affectedFunctions.length, + callersAffected: allAffected.size, + filesAffected: affectedFiles.size, + historicallyCoupledCount: historicallyCoupled.length, + ownersAffected: ownership ? ownership.affectedOwners.length : 0, + boundaryViolationCount, + }, + }; + return paginateResult(base, 'affectedFunctions', { limit: opts.limit, offset: opts.offset }); + } finally { + db.close(); + } +} + +export function diffImpactMermaid( + customDbPath: string, + opts: { + noTests?: boolean; + depth?: number; + staged?: boolean; + ref?: string; + includeImplementors?: boolean; + limit?: number; + offset?: number; + // biome-ignore lint/suspicious/noExplicitAny: config shape is dynamic + config?: any; + } = {}, +): string { + // biome-ignore lint/suspicious/noExplicitAny: paginateResult returns dynamic shape + const data: any = diffImpactData(customDbPath, opts); + if ('error' in data) return data.error as string; + if (data.changedFiles === 0 || data.affectedFunctions.length === 0) { + return 'flowchart TB\n none["No impacted functions detected"]'; + } + + const newFileSet = new Set(data.newFiles || []); + const lines = ['flowchart TB']; + + // Assign stable Mermaid node IDs + let nodeCounter = 0; + const nodeIdMap = new Map(); + const nodeLabels = new Map(); + function nodeId(key: string, label?: string): string { + if (!nodeIdMap.has(key)) { + nodeIdMap.set(key, `n${nodeCounter++}`); + if (label) nodeLabels.set(key, label); + } + return nodeIdMap.get(key)!; + } + + // Register all nodes (changed functions + their callers) + for (const fn of data.affectedFunctions) { + nodeId(`${fn.file}::${fn.name}:${fn.line}`, fn.name); + for (const callers of Object.values(fn.levels || {})) { + for (const c of callers as Array<{ name: string; file: string; line: number }>) { + nodeId(`${c.file}::${c.name}:${c.line}`, c.name); + } + } + } + + // Collect all edges and determine blast radius + const allEdges = new Set(); + const edgeFromNodes = new Set(); + const edgeToNodes = new Set(); + const changedKeys = new Set(); + + for (const fn of data.affectedFunctions) { + changedKeys.add(`${fn.file}::${fn.name}:${fn.line}`); + for (const edge of fn.edges || []) { + const edgeKey = `${edge.from}|${edge.to}`; + if (!allEdges.has(edgeKey)) { + allEdges.add(edgeKey); + edgeFromNodes.add(edge.from); + edgeToNodes.add(edge.to); + } + } + } + + // Blast radius: caller nodes that are never a source (leaf nodes of the impact tree) + const blastRadiusKeys = new Set(); + for (const key of edgeToNodes) { + if (!edgeFromNodes.has(key) && !changedKeys.has(key)) { + blastRadiusKeys.add(key); + } + } + + // Intermediate callers: not changed, not blast radius + const intermediateKeys = new Set(); + for (const key of edgeToNodes) { + if (!changedKeys.has(key) && !blastRadiusKeys.has(key)) { + intermediateKeys.add(key); + } + } + + // Group changed functions by file + const fileGroups = new Map(); + for (const fn of data.affectedFunctions) { + if (!fileGroups.has(fn.file)) fileGroups.set(fn.file, []); + fileGroups.get(fn.file)!.push(fn); + } + + // Emit changed-file subgraphs + let sgCounter = 0; + for (const [file, fns] of fileGroups) { + const isNew = newFileSet.has(file); + const tag = isNew ? 'new' : 'modified'; + const sgId = `sg${sgCounter++}`; + lines.push(` subgraph ${sgId}["${file} **(${tag})**"]`); + for (const fn of fns) { + const key = `${fn.file}::${fn.name}:${fn.line}`; + lines.push(` ${nodeIdMap.get(key)}["${fn.name}"]`); + } + lines.push(' end'); + const style = isNew ? 'fill:#e8f5e9,stroke:#4caf50' : 'fill:#fff3e0,stroke:#ff9800'; + lines.push(` style ${sgId} ${style}`); + } + + // Emit intermediate caller nodes (outside subgraphs) + for (const key of intermediateKeys) { + lines.push(` ${nodeIdMap.get(key)}["${nodeLabels.get(key)}"]`); + } + + // Emit blast radius subgraph + if (blastRadiusKeys.size > 0) { + const sgId = `sg${sgCounter++}`; + lines.push(` subgraph ${sgId}["Callers **(blast radius)**"]`); + for (const key of blastRadiusKeys) { + lines.push(` ${nodeIdMap.get(key)}["${nodeLabels.get(key)}"]`); + } + lines.push(' end'); + lines.push(` style ${sgId} fill:#f3e5f5,stroke:#9c27b0`); + } + + // Emit edges (impact flows from changed fn toward callers) + for (const edgeKey of allEdges) { + const [from, to] = edgeKey.split('|') as [string, string]; + lines.push(` ${nodeIdMap.get(from)} --> ${nodeIdMap.get(to)}`); + } + + return lines.join('\n'); +} diff --git a/src/domain/analysis/implementations.ts b/src/domain/analysis/implementations.ts new file mode 100644 index 00000000..c50b9bd9 --- /dev/null +++ b/src/domain/analysis/implementations.ts @@ -0,0 +1,97 @@ +import { findImplementors, findInterfaces, openReadonlyOrFail } from '../../db/index.js'; +import { isTestFile } from '../../infrastructure/test-filter.js'; +import { CORE_SYMBOL_KINDS } from '../../shared/kinds.js'; +import { normalizeSymbol } from '../../shared/normalize.js'; +import { paginateResult } from '../../shared/paginate.js'; +import type { RelatedNodeRow } from '../../types.js'; +import { findMatchingNodes } from './symbol-lookup.js'; + +/** + * Find all concrete types implementing a given interface/trait. + */ +export function implementationsData( + name: string, + customDbPath: string | undefined, + opts: { noTests?: boolean; file?: string; kind?: string; limit?: number; offset?: number } = {}, +) { + const db = openReadonlyOrFail(customDbPath); + try { + const noTests = opts.noTests || false; + const hc = new Map(); + + const nodes = findMatchingNodes(db, name, { + noTests, + file: opts.file, + kind: opts.kind, + kinds: opts.kind ? undefined : CORE_SYMBOL_KINDS, + }); + if (nodes.length === 0) { + return { name, results: [] }; + } + + const results = nodes.map((node) => { + let implementors = findImplementors(db, node.id) as RelatedNodeRow[]; + if (noTests) implementors = implementors.filter((n) => !isTestFile(n.file)); + + return { + ...normalizeSymbol(node, db, hc), + implementors: implementors.map((impl) => ({ + name: impl.name, + kind: impl.kind, + file: impl.file, + line: impl.line, + })), + }; + }); + + const base = { name, results }; + return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset }); + } finally { + db.close(); + } +} + +/** + * Find all interfaces/traits that a given class/struct implements. + */ +export function interfacesData( + name: string, + customDbPath: string | undefined, + opts: { noTests?: boolean; file?: string; kind?: string; limit?: number; offset?: number } = {}, +) { + const db = openReadonlyOrFail(customDbPath); + try { + const noTests = opts.noTests || false; + const hc = new Map(); + + const nodes = findMatchingNodes(db, name, { + noTests, + file: opts.file, + kind: opts.kind, + kinds: opts.kind ? undefined : CORE_SYMBOL_KINDS, + }); + if (nodes.length === 0) { + return { name, results: [] }; + } + + const results = nodes.map((node) => { + let interfaces = findInterfaces(db, node.id) as RelatedNodeRow[]; + if (noTests) interfaces = interfaces.filter((n) => !isTestFile(n.file)); + + return { + ...normalizeSymbol(node, db, hc), + interfaces: interfaces.map((iface) => ({ + name: iface.name, + kind: iface.kind, + file: iface.file, + line: iface.line, + })), + }; + }); + + const base = { name, results }; + return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset }); + } finally { + db.close(); + } +} diff --git a/src/domain/analysis/module-map.ts b/src/domain/analysis/module-map.ts new file mode 100644 index 00000000..18fccef6 --- /dev/null +++ b/src/domain/analysis/module-map.ts @@ -0,0 +1,410 @@ +import path from 'node:path'; +import type BetterSqlite3 from 'better-sqlite3'; +import { openReadonlyOrFail, testFilterSQL } from '../../db/index.js'; +import { loadConfig } from '../../infrastructure/config.js'; +import { debug } from '../../infrastructure/logger.js'; +import { isTestFile } from '../../infrastructure/test-filter.js'; +import { DEAD_ROLE_PREFIX } from '../../shared/kinds.js'; +import { findCycles } from '../graph/cycles.js'; +import { LANGUAGE_REGISTRY } from '../parser.js'; + +export const FALSE_POSITIVE_NAMES = new Set([ + 'run', + 'get', + 'set', + 'init', + 'start', + 'handle', + 'main', + 'new', + 'create', + 'update', + 'delete', + 'process', + 'execute', + 'call', + 'apply', + 'setup', + 'render', + 'build', + 'load', + 'save', + 'find', + 'make', + 'open', + 'close', + 'reset', + 'send', + 'read', + 'write', +]); +export const FALSE_POSITIVE_CALLER_THRESHOLD = 20; + +// --------------------------------------------------------------------------- +// Section helpers +// --------------------------------------------------------------------------- + +function buildTestFileIds(db: BetterSqlite3.Database): Set { + const allFileNodes = db.prepare("SELECT id, file FROM nodes WHERE kind = 'file'").all() as Array<{ + id: number; + file: string; + }>; + const testFileIds = new Set(); + const testFiles = new Set(); + for (const n of allFileNodes) { + if (isTestFile(n.file)) { + testFileIds.add(n.id); + testFiles.add(n.file); + } + } + const allNodes = db.prepare('SELECT id, file FROM nodes').all() as Array<{ + id: number; + file: string; + }>; + for (const n of allNodes) { + if (testFiles.has(n.file)) testFileIds.add(n.id); + } + return testFileIds; +} + +function countNodesByKind(db: BetterSqlite3.Database, testFileIds: Set | null) { + let nodeRows: Array<{ kind: string; c: number }>; + if (testFileIds) { + const allNodes = db.prepare('SELECT id, kind, file FROM nodes').all() as Array<{ + id: number; + kind: string; + file: string; + }>; + const filtered = allNodes.filter((n) => !testFileIds.has(n.id)); + const counts: Record = {}; + for (const n of filtered) counts[n.kind] = (counts[n.kind] || 0) + 1; + nodeRows = Object.entries(counts).map(([kind, c]) => ({ kind, c })); + } else { + nodeRows = db.prepare('SELECT kind, COUNT(*) as c FROM nodes GROUP BY kind').all() as Array<{ + kind: string; + c: number; + }>; + } + const byKind: Record = {}; + let total = 0; + for (const r of nodeRows) { + byKind[r.kind] = r.c; + total += r.c; + } + return { total, byKind }; +} + +function countEdgesByKind(db: BetterSqlite3.Database, testFileIds: Set | null) { + let edgeRows: Array<{ kind: string; c: number }>; + if (testFileIds) { + const allEdges = db.prepare('SELECT source_id, target_id, kind FROM edges').all() as Array<{ + source_id: number; + target_id: number; + kind: string; + }>; + const filtered = allEdges.filter( + (e) => !testFileIds.has(e.source_id) && !testFileIds.has(e.target_id), + ); + const counts: Record = {}; + for (const e of filtered) counts[e.kind] = (counts[e.kind] || 0) + 1; + edgeRows = Object.entries(counts).map(([kind, c]) => ({ kind, c })); + } else { + edgeRows = db.prepare('SELECT kind, COUNT(*) as c FROM edges GROUP BY kind').all() as Array<{ + kind: string; + c: number; + }>; + } + const byKind: Record = {}; + let total = 0; + for (const r of edgeRows) { + byKind[r.kind] = r.c; + total += r.c; + } + return { total, byKind }; +} + +function countFilesByLanguage(db: BetterSqlite3.Database, noTests: boolean) { + const extToLang = new Map(); + for (const entry of LANGUAGE_REGISTRY) { + for (const ext of entry.extensions) { + extToLang.set(ext, entry.id); + } + } + let fileNodes = db.prepare("SELECT file FROM nodes WHERE kind = 'file'").all() as Array<{ + file: string; + }>; + if (noTests) fileNodes = fileNodes.filter((n) => !isTestFile(n.file)); + const byLanguage: Record = {}; + for (const row of fileNodes) { + const ext = path.extname(row.file).toLowerCase(); + const lang = extToLang.get(ext) || 'other'; + byLanguage[lang] = (byLanguage[lang] || 0) + 1; + } + return { total: fileNodes.length, languages: Object.keys(byLanguage).length, byLanguage }; +} + +function findHotspots(db: BetterSqlite3.Database, noTests: boolean, limit: number) { + const testFilter = testFilterSQL('n.file', noTests); + const hotspotRows = db + .prepare(` + SELECT n.file, + (SELECT COUNT(*) FROM edges WHERE target_id = n.id) as fan_in, + (SELECT COUNT(*) FROM edges WHERE source_id = n.id) as fan_out + FROM nodes n + WHERE n.kind = 'file' ${testFilter} + ORDER BY (SELECT COUNT(*) FROM edges WHERE target_id = n.id) + + (SELECT COUNT(*) FROM edges WHERE source_id = n.id) DESC + `) + .all() as Array<{ file: string; fan_in: number; fan_out: number }>; + const filtered = noTests ? hotspotRows.filter((r) => !isTestFile(r.file)) : hotspotRows; + return filtered.slice(0, limit).map((r) => ({ + file: r.file, + fanIn: r.fan_in, + fanOut: r.fan_out, + })); +} + +function getEmbeddingsInfo(db: BetterSqlite3.Database) { + try { + const count = db.prepare('SELECT COUNT(*) as c FROM embeddings').get() as + | { c: number } + | undefined; + if (count && count.c > 0) { + const meta: Record = {}; + const metaRows = db.prepare('SELECT key, value FROM embedding_meta').all() as Array<{ + key: string; + value: string; + }>; + for (const r of metaRows) meta[r.key] = r.value; + return { + count: count.c, + model: meta['model'] || null, + dim: meta['dim'] ? parseInt(meta['dim'], 10) : null, + builtAt: meta['built_at'] || null, + }; + } + } catch (e: unknown) { + debug(`embeddings lookup skipped: ${(e as Error).message}`); + } + return null; +} + +function computeQualityMetrics( + db: BetterSqlite3.Database, + testFilter: string, + fpThreshold = FALSE_POSITIVE_CALLER_THRESHOLD, +) { + const qualityTestFilter = testFilter.replace(/n\.file/g, 'file'); + + const totalCallable = ( + db + .prepare( + `SELECT COUNT(*) as c FROM nodes WHERE kind IN ('function', 'method') ${qualityTestFilter}`, + ) + .get() as { c: number } + ).c; + const callableWithCallers = ( + db + .prepare(` + SELECT COUNT(DISTINCT e.target_id) as c FROM edges e + JOIN nodes n ON e.target_id = n.id + WHERE e.kind = 'calls' AND n.kind IN ('function', 'method') ${testFilter} + `) + .get() as { c: number } + ).c; + const callerCoverage = totalCallable > 0 ? callableWithCallers / totalCallable : 0; + + const totalCallEdges = ( + db.prepare("SELECT COUNT(*) as c FROM edges WHERE kind = 'calls'").get() as { c: number } + ).c; + const highConfCallEdges = ( + db + .prepare("SELECT COUNT(*) as c FROM edges WHERE kind = 'calls' AND confidence >= 0.7") + .get() as { c: number } + ).c; + const callConfidence = totalCallEdges > 0 ? highConfCallEdges / totalCallEdges : 0; + + const fpRows = db + .prepare(` + SELECT n.name, n.file, n.line, COUNT(e.source_id) as caller_count + FROM nodes n + LEFT JOIN edges e ON n.id = e.target_id AND e.kind = 'calls' + WHERE n.kind IN ('function', 'method') + GROUP BY n.id + HAVING caller_count > ? + ORDER BY caller_count DESC + `) + .all(fpThreshold) as Array<{ name: string; file: string; line: number; caller_count: number }>; + const falsePositiveWarnings = fpRows + .filter((r) => + FALSE_POSITIVE_NAMES.has(r.name.includes('.') ? r.name.split('.').pop()! : r.name), + ) + .map((r) => ({ name: r.name, file: r.file, line: r.line, callerCount: r.caller_count })); + + let fpEdgeCount = 0; + for (const fp of falsePositiveWarnings) fpEdgeCount += fp.callerCount; + const falsePositiveRatio = totalCallEdges > 0 ? fpEdgeCount / totalCallEdges : 0; + + const score = Math.round( + callerCoverage * 40 + callConfidence * 40 + (1 - falsePositiveRatio) * 20, + ); + + return { + score, + callerCoverage: { + ratio: callerCoverage, + covered: callableWithCallers, + total: totalCallable, + }, + callConfidence: { + ratio: callConfidence, + highConf: highConfCallEdges, + total: totalCallEdges, + }, + falsePositiveWarnings, + }; +} + +function countRoles(db: BetterSqlite3.Database, noTests: boolean) { + let roleRows: Array<{ role: string; c: number }>; + if (noTests) { + const allRoleNodes = db + .prepare('SELECT role, file FROM nodes WHERE role IS NOT NULL') + .all() as Array<{ role: string; file: string }>; + const filtered = allRoleNodes.filter((n) => !isTestFile(n.file)); + const counts: Record = {}; + for (const n of filtered) counts[n.role] = (counts[n.role] || 0) + 1; + roleRows = Object.entries(counts).map(([role, c]) => ({ role, c })); + } else { + roleRows = db + .prepare('SELECT role, COUNT(*) as c FROM nodes WHERE role IS NOT NULL GROUP BY role') + .all() as Array<{ role: string; c: number }>; + } + const roles: Record = {}; + let deadTotal = 0; + for (const r of roleRows) { + roles[r.role] = r.c; + if (r.role.startsWith(DEAD_ROLE_PREFIX)) deadTotal += r.c; + } + if (deadTotal > 0) roles['dead'] = deadTotal; + return roles; +} + +function getComplexitySummary(db: BetterSqlite3.Database, testFilter: string) { + try { + const cRows = db + .prepare( + `SELECT fc.cognitive, fc.cyclomatic, fc.max_nesting, fc.maintainability_index + FROM function_complexity fc JOIN nodes n ON fc.node_id = n.id + WHERE n.kind IN ('function','method') ${testFilter}`, + ) + .all() as Array<{ + cognitive: number; + cyclomatic: number; + max_nesting: number; + maintainability_index: number; + }>; + if (cRows.length > 0) { + const miValues = cRows.map((r) => r.maintainability_index || 0); + return { + analyzed: cRows.length, + avgCognitive: +(cRows.reduce((s, r) => s + r.cognitive, 0) / cRows.length).toFixed(1), + avgCyclomatic: +(cRows.reduce((s, r) => s + r.cyclomatic, 0) / cRows.length).toFixed(1), + maxCognitive: Math.max(...cRows.map((r) => r.cognitive)), + maxCyclomatic: Math.max(...cRows.map((r) => r.cyclomatic)), + avgMI: +(miValues.reduce((s, v) => s + v, 0) / miValues.length).toFixed(1), + minMI: +Math.min(...miValues).toFixed(1), + }; + } + } catch (e: unknown) { + debug(`complexity summary skipped: ${(e as Error).message}`); + } + return null; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +export function moduleMapData(customDbPath: string, limit = 20, opts: { noTests?: boolean } = {}) { + const db = openReadonlyOrFail(customDbPath); + try { + const noTests = opts.noTests || false; + + const testFilter = testFilterSQL('n.file', noTests); + + const nodes = db + .prepare(` + SELECT n.*, + (SELECT COUNT(*) FROM edges WHERE source_id = n.id AND kind NOT IN ('contains', 'parameter_of', 'receiver')) as out_edges, + (SELECT COUNT(*) FROM edges WHERE target_id = n.id AND kind NOT IN ('contains', 'parameter_of', 'receiver')) as in_edges + FROM nodes n + WHERE n.kind = 'file' + ${testFilter} + ORDER BY (SELECT COUNT(*) FROM edges WHERE target_id = n.id AND kind NOT IN ('contains', 'parameter_of', 'receiver')) DESC + LIMIT ? + `) + .all(limit) as Array<{ file: string; in_edges: number; out_edges: number }>; + + const topNodes = nodes.map((n) => ({ + file: n.file, + dir: path.dirname(n.file) || '.', + inEdges: n.in_edges, + outEdges: n.out_edges, + coupling: n.in_edges + n.out_edges, + })); + + const totalNodes = (db.prepare('SELECT COUNT(*) as c FROM nodes').get() as { c: number }).c; + const totalEdges = (db.prepare('SELECT COUNT(*) as c FROM edges').get() as { c: number }).c; + const totalFiles = ( + db.prepare("SELECT COUNT(*) as c FROM nodes WHERE kind = 'file'").get() as { c: number } + ).c; + + return { limit, topNodes, stats: { totalFiles, totalNodes, totalEdges } }; + } finally { + db.close(); + } +} + +export function statsData( + customDbPath: string, + // biome-ignore lint/suspicious/noExplicitAny: config shape is dynamic + opts: { noTests?: boolean; config?: any } = {}, +) { + const db = openReadonlyOrFail(customDbPath); + try { + const noTests = opts.noTests || false; + const config = opts.config || loadConfig(); + const testFilter = testFilterSQL('n.file', noTests); + + const testFileIds = noTests ? buildTestFileIds(db) : null; + + const { total: totalNodes, byKind: nodesByKind } = countNodesByKind(db, testFileIds); + const { total: totalEdges, byKind: edgesByKind } = countEdgesByKind(db, testFileIds); + const files = countFilesByLanguage(db, noTests); + + const fileCycles = findCycles(db, { fileLevel: true, noTests }); + const fnCycles = findCycles(db, { fileLevel: false, noTests }); + + const hotspots = findHotspots(db, noTests, 5); + const embeddings = getEmbeddingsInfo(db); + const fpThreshold = config.analysis?.falsePositiveCallers ?? FALSE_POSITIVE_CALLER_THRESHOLD; + const quality = computeQualityMetrics(db, testFilter, fpThreshold); + const roles = countRoles(db, noTests); + const complexity = getComplexitySummary(db, testFilter); + + return { + nodes: { total: totalNodes, byKind: nodesByKind }, + edges: { total: totalEdges, byKind: edgesByKind }, + files, + cycles: { fileLevel: fileCycles.length, functionLevel: fnCycles.length }, + hotspots, + embeddings, + quality, + roles, + complexity, + }; + } finally { + db.close(); + } +} diff --git a/src/domain/analysis/roles.ts b/src/domain/analysis/roles.ts new file mode 100644 index 00000000..159ed1fc --- /dev/null +++ b/src/domain/analysis/roles.ts @@ -0,0 +1,64 @@ +import { openReadonlyOrFail } from '../../db/index.js'; +import { buildFileConditionSQL } from '../../db/query-builder.js'; +import { isTestFile } from '../../infrastructure/test-filter.js'; +import { DEAD_ROLE_PREFIX } from '../../shared/kinds.js'; +import { normalizeSymbol } from '../../shared/normalize.js'; +import { paginateResult } from '../../shared/paginate.js'; +import type { NodeRow } from '../../types.js'; + +export function rolesData( + customDbPath: string, + opts: { + noTests?: boolean; + role?: string | null; + file?: string; + limit?: number; + offset?: number; + } = {}, +) { + const db = openReadonlyOrFail(customDbPath); + try { + const noTests = opts.noTests || false; + const filterRole = opts.role || null; + const conditions = ['role IS NOT NULL']; + const params: (string | number)[] = []; + + if (filterRole) { + if (filterRole === DEAD_ROLE_PREFIX) { + conditions.push('role LIKE ?'); + params.push(`${DEAD_ROLE_PREFIX}%`); + } else { + conditions.push('role = ?'); + params.push(filterRole); + } + } + { + const fc = buildFileConditionSQL(opts.file || '', 'file'); + if (fc.sql) { + // Strip leading ' AND ' since we're using conditions array + conditions.push(fc.sql.replace(/^ AND /, '')); + params.push(...fc.params); + } + } + + let rows = db + .prepare( + `SELECT name, kind, file, line, end_line, role FROM nodes WHERE ${conditions.join(' AND ')} ORDER BY role, file, line`, + ) + .all(...params) as NodeRow[]; + + if (noTests) rows = rows.filter((r) => !isTestFile(r.file)); + + const summary: Record = {}; + for (const r of rows) { + summary[r.role as string] = (summary[r.role as string] || 0) + 1; + } + + const hc = new Map(); + const symbols = rows.map((r) => normalizeSymbol(r, db, hc)); + const base = { count: symbols.length, summary, symbols }; + return paginateResult(base, 'symbols', { limit: opts.limit, offset: opts.offset }); + } finally { + db.close(); + } +} diff --git a/src/domain/analysis/symbol-lookup.ts b/src/domain/analysis/symbol-lookup.ts new file mode 100644 index 00000000..31f5c2e2 --- /dev/null +++ b/src/domain/analysis/symbol-lookup.ts @@ -0,0 +1,284 @@ +import type BetterSqlite3 from 'better-sqlite3'; +import { + countCrossFileCallers, + findAllIncomingEdges, + findAllOutgoingEdges, + findCallers, + findCrossFileCallTargets, + findFileNodes, + findImportSources, + findImportTargets, + findNodeChildren, + findNodesByFile, + findNodesWithFanIn, + listFunctionNodes, + openReadonlyOrFail, + Repository, +} from '../../db/index.js'; +import { debug } from '../../infrastructure/logger.js'; +import { isTestFile } from '../../infrastructure/test-filter.js'; +import { EVERY_SYMBOL_KIND } from '../../shared/kinds.js'; +import { getFileHash, normalizeSymbol } from '../../shared/normalize.js'; +import { paginateResult } from '../../shared/paginate.js'; +import type { + AdjacentEdgeRow, + ChildNodeRow, + ImportEdgeRow, + NodeRow, + NodeRowWithFanIn, +} from '../../types.js'; + +const FUNCTION_KINDS = ['function', 'method', 'class', 'constant']; + +/** + * Find nodes matching a name query, ranked by relevance. + * Scoring: exact=100, prefix=60, word-boundary=40, substring=10, plus fan-in tiebreaker. + */ +export function findMatchingNodes( + dbOrRepo: BetterSqlite3.Database | InstanceType, + name: string, + opts: { noTests?: boolean; file?: string; kind?: string; kinds?: readonly string[] } = {}, +): Array { + const kinds = opts.kind ? [opts.kind] : opts.kinds?.length ? [...opts.kinds] : FUNCTION_KINDS; + + const isRepo = dbOrRepo instanceof Repository; + const rows = ( + isRepo + ? (dbOrRepo as InstanceType).findNodesWithFanIn(`%${name}%`, { + kinds, + file: opts.file, + }) + : findNodesWithFanIn(dbOrRepo as BetterSqlite3.Database, `%${name}%`, { + kinds, + file: opts.file, + }) + ) as NodeRowWithFanIn[]; + + const nodes: Array = ( + opts.noTests ? rows.filter((n) => !isTestFile(n.file)) : rows + ) as Array; + + const lowerQuery = name.toLowerCase(); + for (const node of nodes) { + const lowerName = node.name.toLowerCase(); + const bareName = lowerName.includes('.') ? lowerName.split('.').pop()! : lowerName; + + let matchScore: number; + if (lowerName === lowerQuery || bareName === lowerQuery) { + matchScore = 100; + } else if (lowerName.startsWith(lowerQuery) || bareName.startsWith(lowerQuery)) { + matchScore = 60; + } else if (lowerName.includes(`.${lowerQuery}`) || lowerName.includes(`${lowerQuery}.`)) { + matchScore = 40; + } else { + matchScore = 10; + } + + const fanInBonus = Math.min(Math.log2(node.fan_in + 1) * 5, 25); + node._relevance = matchScore + fanInBonus; + } + + nodes.sort((a, b) => b._relevance - a._relevance); + return nodes; +} + +export function queryNameData( + name: string, + customDbPath: string, + opts: { noTests?: boolean; limit?: number; offset?: number } = {}, +) { + const db = openReadonlyOrFail(customDbPath); + try { + const noTests = opts.noTests || false; + let nodes = db.prepare(`SELECT * FROM nodes WHERE name LIKE ?`).all(`%${name}%`) as NodeRow[]; + if (noTests) nodes = nodes.filter((n) => !isTestFile(n.file)); + if (nodes.length === 0) { + return { query: name, results: [] }; + } + + const hc = new Map(); + const results = nodes.map((node) => { + let callees = findAllOutgoingEdges(db, node.id) as AdjacentEdgeRow[]; + + let callers = findAllIncomingEdges(db, node.id) as AdjacentEdgeRow[]; + + if (noTests) { + callees = callees.filter((c) => !isTestFile(c.file)); + callers = callers.filter((c) => !isTestFile(c.file)); + } + + return { + ...normalizeSymbol(node, db, hc), + callees: callees.map((c) => ({ + name: c.name, + kind: c.kind, + file: c.file, + line: c.line, + edgeKind: c.edge_kind, + })), + callers: callers.map((c) => ({ + name: c.name, + kind: c.kind, + file: c.file, + line: c.line, + edgeKind: c.edge_kind, + })), + }; + }); + + const base = { query: name, results }; + return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset }); + } finally { + db.close(); + } +} + +function whereSymbolImpl(db: BetterSqlite3.Database, target: string, noTests: boolean) { + const placeholders = EVERY_SYMBOL_KIND.map(() => '?').join(', '); + let nodes = db + .prepare( + `SELECT * FROM nodes WHERE name LIKE ? AND kind IN (${placeholders}) ORDER BY file, line`, + ) + .all(`%${target}%`, ...EVERY_SYMBOL_KIND) as NodeRow[]; + if (noTests) nodes = nodes.filter((n) => !isTestFile(n.file)); + + const hc = new Map(); + return nodes.map((node) => { + const crossCount = countCrossFileCallers(db, node.id, node.file); + const exported = crossCount > 0; + + let uses = findCallers(db, node.id) as Array<{ name: string; file: string; line: number }>; + if (noTests) uses = uses.filter((u) => !isTestFile(u.file)); + + return { + ...normalizeSymbol(node, db, hc), + exported, + uses: uses.map((u) => ({ name: u.name, file: u.file, line: u.line })), + }; + }); +} + +function whereFileImpl(db: BetterSqlite3.Database, target: string) { + const fileNodes = findFileNodes(db, `%${target}%`) as NodeRow[]; + if (fileNodes.length === 0) return []; + + return fileNodes.map((fn) => { + const symbols = findNodesByFile(db, fn.file) as NodeRow[]; + + const imports = (findImportTargets(db, fn.id) as ImportEdgeRow[]).map((r) => r.file); + + const importedBy = (findImportSources(db, fn.id) as ImportEdgeRow[]).map((r) => r.file); + + const exportedIds = findCrossFileCallTargets(db, fn.file) as Set; + + const exported = symbols.filter((s) => exportedIds.has(s.id)).map((s) => s.name); + + return { + file: fn.file, + fileHash: getFileHash(db, fn.file), + symbols: symbols.map((s) => ({ name: s.name, kind: s.kind, line: s.line })), + imports, + importedBy, + exported, + }; + }); +} + +export function whereData( + target: string, + customDbPath: string, + opts: { noTests?: boolean; file?: boolean; limit?: number; offset?: number } = {}, +) { + const db = openReadonlyOrFail(customDbPath); + try { + const noTests = opts.noTests || false; + const fileMode = opts.file || false; + + const results = fileMode ? whereFileImpl(db, target) : whereSymbolImpl(db, target, noTests); + + const base = { target, mode: fileMode ? 'file' : 'symbol', results }; + return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset }); + } finally { + db.close(); + } +} + +export function listFunctionsData( + customDbPath: string, + opts: { + noTests?: boolean; + file?: string; + pattern?: string; + limit?: number; + offset?: number; + } = {}, +) { + const db = openReadonlyOrFail(customDbPath); + try { + const noTests = opts.noTests || false; + + let rows = listFunctionNodes(db, { file: opts.file, pattern: opts.pattern }) as NodeRow[]; + + if (noTests) rows = rows.filter((r) => !isTestFile(r.file)); + + const hc = new Map(); + const functions = rows.map((r) => normalizeSymbol(r, db, hc)); + const base = { count: functions.length, functions }; + return paginateResult(base, 'functions', { limit: opts.limit, offset: opts.offset }); + } finally { + db.close(); + } +} + +export function childrenData( + name: string, + customDbPath: string, + opts: { noTests?: boolean; file?: string; kind?: string; limit?: number; offset?: number } = {}, +) { + const db = openReadonlyOrFail(customDbPath); + try { + const noTests = opts.noTests || false; + + const nodes = findMatchingNodes(db, name, { noTests, file: opts.file, kind: opts.kind }); + if (nodes.length === 0) { + return { name, results: [] }; + } + + const results = nodes.map((node) => { + let children: ChildNodeRow[]; + try { + children = findNodeChildren(db, node.id) as ChildNodeRow[]; + } catch (e: unknown) { + debug(`findNodeChildren failed for node ${node.id}: ${(e as Error).message}`); + children = []; + } + if (noTests) + children = children.filter( + (c) => !isTestFile((c as ChildNodeRow & { file?: string }).file || node.file), + ); + return { + name: node.name, + kind: node.kind, + file: node.file, + line: node.line, + scope: node.scope || null, + visibility: node.visibility || null, + qualifiedName: node.qualified_name || null, + children: children.map((c) => ({ + name: c.name, + kind: c.kind, + line: c.line, + endLine: c.end_line || null, + qualifiedName: c.qualified_name || null, + scope: c.scope || null, + visibility: c.visibility || null, + })), + }; + }); + + const base = { name, results }; + return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset }); + } finally { + db.close(); + } +} diff --git a/src/domain/parser.ts b/src/domain/parser.ts new file mode 100644 index 00000000..a8e3b8d7 --- /dev/null +++ b/src/domain/parser.ts @@ -0,0 +1,681 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { Tree } from 'web-tree-sitter'; +import { Language, Parser, Query } from 'web-tree-sitter'; +import { debug, warn } from '../infrastructure/logger.js'; +import { getNative, getNativePackageVersion, loadNative } from '../infrastructure/native.js'; + +// Re-export all extractors for backward compatibility +export { + extractCSharpSymbols, + extractGoSymbols, + extractHCLSymbols, + extractJavaSymbols, + extractPHPSymbols, + extractPythonSymbols, + extractRubySymbols, + extractRustSymbols, + extractSymbols, +} from '../extractors/index.js'; + +import { + extractCSharpSymbols, + extractGoSymbols, + extractHCLSymbols, + extractJavaSymbols, + extractPHPSymbols, + extractPythonSymbols, + extractRubySymbols, + extractRustSymbols, + extractSymbols, +} from '../extractors/index.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +function grammarPath(name: string): string { + return path.join(__dirname, '..', '..', 'grammars', name); +} + +let _initialized: boolean = false; + +// Memoized parsers — avoids reloading WASM grammars on every createParsers() call +let _cachedParsers: Map | null = null; + +// Cached Language objects — WASM-backed, must be .delete()'d explicitly +let _cachedLanguages: Map | null = null; + +// Query cache for JS/TS/TSX extractors (populated during createParsers) +const _queryCache: Map = new Map(); + +/** + * Declarative registry entry for a supported language. + */ +export interface LanguageRegistryEntry { + id: string; + extensions: string[]; + grammarFile: string; + // biome-ignore lint/suspicious/noExplicitAny: extractor signatures vary per language + extractor: (...args: any[]) => any; + required: boolean; +} + +interface EngineOpts { + engine?: string; + dataflow?: boolean; + ast?: boolean; +} + +interface ResolvedEngine { + name: 'native' | 'wasm'; + // biome-ignore lint/suspicious/noExplicitAny: native addon has no type declarations + native: any; +} + +// biome-ignore lint/suspicious/noExplicitAny: extractor return types vary per language +interface WasmExtractResult { + // biome-ignore lint/suspicious/noExplicitAny: extractor return shapes vary per language + symbols: any; + tree: Tree; + langId: string; +} + +// Shared patterns for all JS/TS/TSX (class_declaration excluded — name type differs) +const COMMON_QUERY_PATTERNS: string[] = [ + '(function_declaration name: (identifier) @fn_name) @fn_node', + '(variable_declarator name: (identifier) @varfn_name value: (arrow_function) @varfn_value)', + '(variable_declarator name: (identifier) @varfn_name value: (function_expression) @varfn_value)', + '(method_definition name: (property_identifier) @meth_name) @meth_node', + '(import_statement source: (string) @imp_source) @imp_node', + '(export_statement) @exp_node', + '(call_expression function: (identifier) @callfn_name) @callfn_node', + '(call_expression function: (member_expression) @callmem_fn) @callmem_node', + '(call_expression function: (subscript_expression) @callsub_fn) @callsub_node', + '(expression_statement (assignment_expression left: (member_expression) @assign_left right: (_) @assign_right)) @assign_node', +]; + +// JS: class name is (identifier) +const JS_CLASS_PATTERN: string = '(class_declaration name: (identifier) @cls_name) @cls_node'; + +// TS/TSX: class name is (type_identifier), plus interface and type alias +const TS_EXTRA_PATTERNS: string[] = [ + '(class_declaration name: (type_identifier) @cls_name) @cls_node', + '(interface_declaration name: (type_identifier) @iface_name) @iface_node', + '(type_alias_declaration name: (type_identifier) @type_name) @type_node', +]; + +export async function createParsers(): Promise> { + if (_cachedParsers) return _cachedParsers; + + if (!_initialized) { + await Parser.init(); + _initialized = true; + } + + const parsers = new Map(); + const languages = new Map(); + for (const entry of LANGUAGE_REGISTRY) { + try { + const lang = await Language.load(grammarPath(entry.grammarFile)); + const parser = new Parser(); + parser.setLanguage(lang); + parsers.set(entry.id, parser); + languages.set(entry.id, lang); + // Compile and cache tree-sitter Query for JS/TS/TSX extractors + if (entry.extractor === extractSymbols && !_queryCache.has(entry.id)) { + const isTS = entry.id === 'typescript' || entry.id === 'tsx'; + const patterns = isTS + ? [...COMMON_QUERY_PATTERNS, ...TS_EXTRA_PATTERNS] + : [...COMMON_QUERY_PATTERNS, JS_CLASS_PATTERN]; + _queryCache.set(entry.id, new Query(lang, patterns.join('\n'))); + } + } catch (e: unknown) { + if (entry.required) throw e; + warn( + `${entry.id} parser failed to initialize: ${(e as Error).message}. ${entry.id} files will be skipped.`, + ); + parsers.set(entry.id, null); + } + } + _cachedParsers = parsers; + _cachedLanguages = languages; + return parsers; +} + +/** + * Dispose all cached WASM parsers and queries to free WASM linear memory. + * Call this between repeated builds in the same process (e.g. benchmarks) + * to prevent memory accumulation that can cause segfaults. + */ +export function disposeParsers(): void { + if (_cachedParsers) { + for (const [id, parser] of _cachedParsers) { + if (parser && typeof parser.delete === 'function') { + try { + parser.delete(); + } catch (e: unknown) { + debug(`Failed to dispose parser ${id}: ${(e as Error).message}`); + } + } + } + _cachedParsers = null; + } + for (const [id, query] of _queryCache) { + if (query && typeof query.delete === 'function') { + try { + query.delete(); + } catch (e: unknown) { + debug(`Failed to dispose query ${id}: ${(e as Error).message}`); + } + } + } + _queryCache.clear(); + if (_cachedLanguages) { + for (const [id, lang] of _cachedLanguages) { + // biome-ignore lint/suspicious/noExplicitAny: .delete() exists at runtime on WASM Language objects but is missing from typings + if (lang && typeof (lang as any).delete === 'function') { + try { + (lang as any).delete(); + } catch (e: unknown) { + debug(`Failed to dispose language ${id}: ${(e as Error).message}`); + } + } + } + _cachedLanguages = null; + } + _initialized = false; +} + +export function getParser(parsers: Map, filePath: string): Parser | null { + const ext = path.extname(filePath); + const entry = _extToLang.get(ext); + if (!entry) return null; + return parsers.get(entry.id) || null; +} + +/** + * Pre-parse files missing `_tree` via WASM so downstream phases (CFG, dataflow) + * don't each need to create parsers and re-parse independently. + * Only parses files whose extension is in SUPPORTED_EXTENSIONS. + */ +// biome-ignore lint/suspicious/noExplicitAny: fileSymbols values have dynamic shape from extractors +export async function ensureWasmTrees( + fileSymbols: Map, + rootDir: string, +): Promise { + // Check if any file needs a tree + let needsParse = false; + for (const [relPath, symbols] of fileSymbols) { + if (!symbols._tree) { + const ext = path.extname(relPath).toLowerCase(); + if (_extToLang.has(ext)) { + needsParse = true; + break; + } + } + } + if (!needsParse) return; + + const parsers = await createParsers(); + + for (const [relPath, symbols] of fileSymbols) { + if (symbols._tree) continue; + const ext = path.extname(relPath).toLowerCase(); + const entry = _extToLang.get(ext); + if (!entry) continue; + const parser = parsers.get(entry.id); + if (!parser) continue; + + const absPath = path.join(rootDir, relPath); + let code: string; + try { + code = fs.readFileSync(absPath, 'utf-8'); + } catch (e: unknown) { + debug(`ensureWasmTrees: cannot read ${relPath}: ${(e as Error).message}`); + continue; + } + try { + symbols._tree = parser.parse(code); + symbols._langId = entry.id; + } catch (e: unknown) { + debug(`ensureWasmTrees: parse failed for ${relPath}: ${(e as Error).message}`); + } + } +} + +/** + * Check whether the required WASM grammar files exist on disk. + */ +export function isWasmAvailable(): boolean { + return LANGUAGE_REGISTRY.filter((e) => e.required).every((e) => + fs.existsSync(grammarPath(e.grammarFile)), + ); +} + +// ── Unified API ────────────────────────────────────────────────────────────── + +function resolveEngine(opts: EngineOpts = {}): ResolvedEngine { + const pref = opts.engine || 'auto'; + if (pref === 'wasm') return { name: 'wasm', native: null }; + if (pref === 'native' || pref === 'auto') { + const native = loadNative(); + if (native) return { name: 'native', native }; + if (pref === 'native') { + getNative(); // throws with detailed error + install instructions + } + } + return { name: 'wasm', native: null }; +} + +/** + * Patch native engine output in-place for the few remaining semantic transforms. + * With #[napi(js_name)] on Rust types, most fields already arrive as camelCase. + * This only handles: + * - _lineCount compat for builder.js + * - Backward compat for older native binaries missing js_name annotations + * - dataflow argFlows/mutations bindingType -> binding wrapper + */ +// biome-ignore lint/suspicious/noExplicitAny: native result has dynamic shape +function patchNativeResult(r: any): any { + // lineCount: napi(js_name) emits "lineCount"; older binaries may emit "line_count" + r.lineCount = r.lineCount ?? r.line_count ?? null; + r._lineCount = r.lineCount; + + // Backward compat for older binaries missing js_name annotations + if (r.definitions) { + for (const d of r.definitions) { + if (d.endLine === undefined && d.end_line !== undefined) { + d.endLine = d.end_line; + } + } + } + if (r.imports) { + for (const i of r.imports) { + if (i.typeOnly === undefined) i.typeOnly = i.type_only; + if (i.wildcardReexport === undefined) i.wildcardReexport = i.wildcard_reexport; + if (i.pythonImport === undefined) i.pythonImport = i.python_import; + if (i.goImport === undefined) i.goImport = i.go_import; + if (i.rustUse === undefined) i.rustUse = i.rust_use; + if (i.javaImport === undefined) i.javaImport = i.java_import; + if (i.csharpUsing === undefined) i.csharpUsing = i.csharp_using; + if (i.rubyRequire === undefined) i.rubyRequire = i.ruby_require; + if (i.phpUse === undefined) i.phpUse = i.php_use; + if (i.dynamicImport === undefined) i.dynamicImport = i.dynamic_import; + } + } + + // dataflow: wrap bindingType into binding object for argFlows and mutations + if (r.dataflow) { + if (r.dataflow.argFlows) { + for (const f of r.dataflow.argFlows) { + f.binding = f.bindingType ? { type: f.bindingType } : null; + } + } + if (r.dataflow.mutations) { + for (const m of r.dataflow.mutations) { + m.binding = m.bindingType ? { type: m.bindingType } : null; + } + } + } + + return r; +} + +/** + * Declarative registry of all supported languages. + * Adding a new language requires only a new entry here + its extractor function. + */ +export const LANGUAGE_REGISTRY: LanguageRegistryEntry[] = [ + { + id: 'javascript', + extensions: ['.js', '.jsx', '.mjs', '.cjs'], + grammarFile: 'tree-sitter-javascript.wasm', + extractor: extractSymbols, + required: true, + }, + { + id: 'typescript', + extensions: ['.ts'], + grammarFile: 'tree-sitter-typescript.wasm', + extractor: extractSymbols, + required: true, + }, + { + id: 'tsx', + extensions: ['.tsx'], + grammarFile: 'tree-sitter-tsx.wasm', + extractor: extractSymbols, + required: true, + }, + { + id: 'hcl', + extensions: ['.tf', '.hcl'], + grammarFile: 'tree-sitter-hcl.wasm', + extractor: extractHCLSymbols, + required: false, + }, + { + id: 'python', + extensions: ['.py', '.pyi'], + grammarFile: 'tree-sitter-python.wasm', + extractor: extractPythonSymbols, + required: false, + }, + { + id: 'go', + extensions: ['.go'], + grammarFile: 'tree-sitter-go.wasm', + extractor: extractGoSymbols, + required: false, + }, + { + id: 'rust', + extensions: ['.rs'], + grammarFile: 'tree-sitter-rust.wasm', + extractor: extractRustSymbols, + required: false, + }, + { + id: 'java', + extensions: ['.java'], + grammarFile: 'tree-sitter-java.wasm', + extractor: extractJavaSymbols, + required: false, + }, + { + id: 'csharp', + extensions: ['.cs'], + grammarFile: 'tree-sitter-c_sharp.wasm', + extractor: extractCSharpSymbols, + required: false, + }, + { + id: 'ruby', + extensions: ['.rb', '.rake', '.gemspec'], + grammarFile: 'tree-sitter-ruby.wasm', + extractor: extractRubySymbols, + required: false, + }, + { + id: 'php', + extensions: ['.php', '.phtml'], + grammarFile: 'tree-sitter-php.wasm', + extractor: extractPHPSymbols, + required: false, + }, +]; + +const _extToLang: Map = new Map(); +for (const entry of LANGUAGE_REGISTRY) { + for (const ext of entry.extensions) { + _extToLang.set(ext, entry); + } +} + +export const SUPPORTED_EXTENSIONS: Set = new Set(_extToLang.keys()); + +/** + * WASM-based typeMap backfill for older native binaries that don't emit typeMap. + * Uses tree-sitter AST extraction instead of regex to avoid false positives from + * matches inside comments and string literals. + * TODO: Remove once all published native binaries include typeMap extraction (>= 3.2.0) + */ +// biome-ignore lint/suspicious/noExplicitAny: return shape matches native result typeMap +async function backfillTypeMap( + filePath: string, + source?: string, +): Promise<{ typeMap: any; backfilled: boolean }> { + let code = source; + if (!code) { + try { + code = fs.readFileSync(filePath, 'utf-8'); + } catch { + return { typeMap: [], backfilled: false }; + } + } + const parsers = await createParsers(); + const extracted = wasmExtractSymbols(parsers, filePath, code); + try { + if (!extracted?.symbols?.typeMap) { + return { typeMap: [], backfilled: false }; + } + const tm = extracted.symbols.typeMap; + return { + typeMap: + tm instanceof Map + ? tm + : new Map(tm.map((e: { name: string; typeName: string }) => [e.name, e.typeName])), + backfilled: true, + }; + } finally { + // Free the WASM tree to prevent memory accumulation across repeated builds + if (extracted?.tree && typeof extracted.tree.delete === 'function') { + try { + extracted.tree.delete(); + } catch {} + } + } +} + +/** + * WASM extraction helper: picks the right extractor based on file extension. + */ +function wasmExtractSymbols( + parsers: Map, + filePath: string, + code: string, +): WasmExtractResult | null { + const parser = getParser(parsers, filePath); + if (!parser) return null; + + let tree: Tree | null; + try { + tree = parser.parse(code); + } catch (e: unknown) { + warn(`Parse error in ${filePath}: ${(e as Error).message}`); + return null; + } + if (!tree) return null; + + const ext = path.extname(filePath); + const entry = _extToLang.get(ext); + if (!entry) return null; + const query = _queryCache.get(entry.id) || null; + const symbols = entry.extractor(tree, filePath, query); + return symbols ? { symbols, tree, langId: entry.id } : null; +} + +/** + * Parse a single file and return normalized symbols. + */ +// biome-ignore lint/suspicious/noExplicitAny: return shape varies between native and WASM engines +export async function parseFileAuto( + filePath: string, + source: string, + opts: EngineOpts = {}, +): Promise { + const { native } = resolveEngine(opts); + + if (native) { + const result = native.parseFile(filePath, source, !!opts.dataflow, opts.ast !== false); + if (!result) return null; + const patched = patchNativeResult(result); + // Only backfill typeMap for TS/TSX — JS files have no type annotations, + // and the native engine already handles `new Expr()` patterns. + const TS_BACKFILL_EXTS = new Set(['.ts', '.tsx']); + if ( + (!patched.typeMap || patched.typeMap.length === 0) && + TS_BACKFILL_EXTS.has(path.extname(filePath)) + ) { + const { typeMap, backfilled } = await backfillTypeMap(filePath, source); + patched.typeMap = typeMap; + if (backfilled) patched._typeMapBackfilled = true; + } + return patched; + } + + // WASM path + const parsers = await createParsers(); + const extracted = wasmExtractSymbols(parsers, filePath, source); + return extracted ? extracted.symbols : null; +} + +/** + * Parse multiple files in bulk and return a Map. + */ +// biome-ignore lint/suspicious/noExplicitAny: return shape varies between native and WASM engines +export async function parseFilesAuto( + filePaths: string[], + rootDir: string, + opts: EngineOpts = {}, +): Promise> { + const { native } = resolveEngine(opts); + // biome-ignore lint/suspicious/noExplicitAny: result values have dynamic shape from extractors + const result = new Map(); + + if (native) { + const nativeResults = native.parseFiles( + filePaths, + rootDir, + !!opts.dataflow, + opts.ast !== false, + ); + const needsTypeMap: { filePath: string; relPath: string }[] = []; + for (const r of nativeResults) { + if (!r) continue; + const patched = patchNativeResult(r); + const relPath = path.relative(rootDir, r.file).split(path.sep).join('/'); + result.set(relPath, patched); + if (!patched.typeMap || patched.typeMap.length === 0) { + needsTypeMap.push({ filePath: r.file, relPath }); + } + } + // Backfill typeMap via WASM for native binaries that predate the type-map feature + if (needsTypeMap.length > 0) { + // Only backfill for languages where WASM extraction can produce typeMap + // (TS/TSX have type annotations; JS only has `new Expr()` which native already handles) + const TS_EXTS = new Set(['.ts', '.tsx']); + const tsFiles = needsTypeMap.filter(({ filePath }) => TS_EXTS.has(path.extname(filePath))); + if (tsFiles.length > 0) { + const parsers = await createParsers(); + for (const { filePath, relPath } of tsFiles) { + let extracted: WasmExtractResult | null | undefined; + try { + const code = fs.readFileSync(filePath, 'utf-8'); + extracted = wasmExtractSymbols(parsers, filePath, code); + if (extracted?.symbols?.typeMap) { + const symbols = result.get(relPath); + symbols.typeMap = + extracted.symbols.typeMap instanceof Map + ? extracted.symbols.typeMap + : new Map( + extracted.symbols.typeMap.map((e: { name: string; typeName: string }) => [ + e.name, + e.typeName, + ]), + ); + symbols._typeMapBackfilled = true; + } + } catch { + /* skip — typeMap is a best-effort backfill */ + } finally { + // Free the WASM tree to prevent memory accumulation across repeated builds + if (extracted?.tree && typeof extracted.tree.delete === 'function') { + try { + extracted.tree.delete(); + } catch {} + } + } + } + } + } + return result; + } + + // WASM path + const parsers = await createParsers(); + for (const filePath of filePaths) { + let code: string; + try { + code = fs.readFileSync(filePath, 'utf-8'); + } catch (err: unknown) { + warn(`Skipping ${path.relative(rootDir, filePath)}: ${(err as Error).message}`); + continue; + } + const extracted = wasmExtractSymbols(parsers, filePath, code); + if (extracted) { + const relPath = path.relative(rootDir, filePath).split(path.sep).join('/'); + extracted.symbols._tree = extracted.tree; + extracted.symbols._langId = extracted.langId; + extracted.symbols._lineCount = code.split('\n').length; + result.set(relPath, extracted.symbols); + } + } + return result; +} + +/** + * Report which engine is active. + */ +export function getActiveEngine(opts: EngineOpts = {}): { + name: 'native' | 'wasm'; + version: string | null; +} { + const { name, native } = resolveEngine(opts); + let version: string | null = native + ? typeof native.engineVersion === 'function' + ? native.engineVersion() + : null + : null; + // Prefer platform package.json version over binary-embedded version + // to handle stale binaries that weren't recompiled during a release + if (native) { + try { + version = getNativePackageVersion() ?? version; + } catch (e: unknown) { + debug(`getNativePackageVersion failed: ${(e as Error).message}`); + } + } + return { name, version }; +} + +/** + * Create a native ParseTreeCache for incremental parsing. + * Returns null if the native engine is unavailable (WASM fallback). + */ +// biome-ignore lint/suspicious/noExplicitAny: native ParseTreeCache has no type declarations +export function createParseTreeCache(): any { + const native = loadNative(); + if (!native || !native.ParseTreeCache) return null; + return new native.ParseTreeCache(); +} + +/** + * Parse a file incrementally using the cache, or fall back to full parse. + */ +// biome-ignore lint/suspicious/noExplicitAny: cache is native ParseTreeCache with no type declarations; return shape varies +export async function parseFileIncremental( + cache: any, + filePath: string, + source: string, + opts: EngineOpts = {}, +): Promise { + if (cache) { + const result = cache.parseFile(filePath, source); + if (!result) return null; + const patched = patchNativeResult(result); + // Only backfill typeMap for TS/TSX — JS files have no type annotations, + // and the native engine already handles `new Expr()` patterns. + const TS_BACKFILL_EXTS = new Set(['.ts', '.tsx']); + if ( + (!patched.typeMap || patched.typeMap.length === 0) && + TS_BACKFILL_EXTS.has(path.extname(filePath)) + ) { + const { typeMap, backfilled } = await backfillTypeMap(filePath, source); + patched.typeMap = typeMap; + if (backfilled) patched._typeMapBackfilled = true; + } + return patched; + } + return parseFileAuto(filePath, source, opts); +} diff --git a/tests/helpers/node-version.js b/tests/helpers/node-version.js new file mode 100644 index 00000000..314f91db --- /dev/null +++ b/tests/helpers/node-version.js @@ -0,0 +1,6 @@ +/** + * Node >= 22.6 supports --experimental-strip-types, required for tests that + * spawn child processes loading .ts source files directly. + */ +const [_major, _minor] = process.versions.node.split('.').map(Number); +export const canStripTypes = _major > 22 || (_major === 22 && _minor >= 6); From cc65d6397462f58ab938ece2cd42ef7ed1200953 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sat, 21 Mar 2026 02:43:26 -0600 Subject: [PATCH 02/18] chore: remove original .js files replaced by TypeScript conversions --- src/domain/analysis/brief.js | 161 ------ src/domain/analysis/context.js | 446 ---------------- src/domain/analysis/dependencies.js | 395 --------------- src/domain/analysis/exports.js | 206 -------- src/domain/analysis/impact.js | 675 ------------------------- src/domain/analysis/implementations.js | 98 ---- src/domain/analysis/module-map.js | 357 ------------- src/domain/analysis/roles.js | 54 -- src/domain/analysis/symbol-lookup.js | 240 --------- src/domain/parser.js | 626 ----------------------- 10 files changed, 3258 deletions(-) delete mode 100644 src/domain/analysis/brief.js delete mode 100644 src/domain/analysis/context.js delete mode 100644 src/domain/analysis/dependencies.js delete mode 100644 src/domain/analysis/exports.js delete mode 100644 src/domain/analysis/impact.js delete mode 100644 src/domain/analysis/implementations.js delete mode 100644 src/domain/analysis/module-map.js delete mode 100644 src/domain/analysis/roles.js delete mode 100644 src/domain/analysis/symbol-lookup.js delete mode 100644 src/domain/parser.js diff --git a/src/domain/analysis/brief.js b/src/domain/analysis/brief.js deleted file mode 100644 index 78c3d342..00000000 --- a/src/domain/analysis/brief.js +++ /dev/null @@ -1,161 +0,0 @@ -import { - findDistinctCallers, - findFileNodes, - findImportDependents, - findImportSources, - findImportTargets, - findNodesByFile, - openReadonlyOrFail, -} from '../../db/index.js'; -import { loadConfig } from '../../infrastructure/config.js'; -import { isTestFile } from '../../infrastructure/test-filter.js'; - -/** Symbol kinds meaningful for a file brief — excludes parameters, properties, constants. */ -const BRIEF_KINDS = new Set([ - 'function', - 'method', - 'class', - 'interface', - 'type', - 'struct', - 'enum', - 'trait', - 'record', - 'module', -]); - -/** - * Compute file risk tier from symbol roles and max fan-in. - * @param {{ role: string|null, callerCount: number }[]} symbols - * @returns {'high'|'medium'|'low'} - */ -function computeRiskTier(symbols, highThreshold = 10, mediumThreshold = 3) { - let maxCallers = 0; - let hasCoreRole = false; - for (const s of symbols) { - if (s.callerCount > maxCallers) maxCallers = s.callerCount; - if (s.role === 'core') hasCoreRole = true; - } - if (maxCallers >= highThreshold || hasCoreRole) return 'high'; - if (maxCallers >= mediumThreshold) return 'medium'; - return 'low'; -} - -/** - * BFS to count transitive callers for a single node. - * Lightweight variant — only counts, does not collect details. - */ -function countTransitiveCallers(db, startId, noTests, maxDepth = 5) { - const visited = new Set([startId]); - let frontier = [startId]; - - for (let d = 1; d <= maxDepth; d++) { - const nextFrontier = []; - for (const fid of frontier) { - const callers = findDistinctCallers(db, fid); - for (const c of callers) { - if (!visited.has(c.id) && (!noTests || !isTestFile(c.file))) { - visited.add(c.id); - nextFrontier.push(c.id); - } - } - } - frontier = nextFrontier; - if (frontier.length === 0) break; - } - - return visited.size - 1; -} - -/** - * Count transitive file-level import dependents via BFS. - * Depth-bounded to match countTransitiveCallers and keep hook latency predictable. - */ -function countTransitiveImporters(db, fileNodeIds, noTests, maxDepth = 5) { - const visited = new Set(fileNodeIds); - let frontier = [...fileNodeIds]; - - for (let d = 1; d <= maxDepth; d++) { - const nextFrontier = []; - for (const current of frontier) { - const dependents = findImportDependents(db, current); - for (const dep of dependents) { - if (!visited.has(dep.id) && (!noTests || !isTestFile(dep.file))) { - visited.add(dep.id); - nextFrontier.push(dep.id); - } - } - } - frontier = nextFrontier; - if (frontier.length === 0) break; - } - - return visited.size - fileNodeIds.length; -} - -/** - * Produce a token-efficient file brief: symbols with roles and caller counts, - * importer info with transitive count, and file risk tier. - * - * @param {string} file - File path (partial match) - * @param {string} customDbPath - Path to graph.db - * @param {{ noTests?: boolean }} opts - * @returns {{ file: string, results: object[] }} - */ -export function briefData(file, customDbPath, opts = {}) { - const db = openReadonlyOrFail(customDbPath); - try { - const noTests = opts.noTests || false; - const config = opts.config || loadConfig(); - const callerDepth = config.analysis?.briefCallerDepth ?? 5; - const importerDepth = config.analysis?.briefImporterDepth ?? 5; - const highRiskCallers = config.analysis?.briefHighRiskCallers ?? 10; - const mediumRiskCallers = config.analysis?.briefMediumRiskCallers ?? 3; - const fileNodes = findFileNodes(db, `%${file}%`); - if (fileNodes.length === 0) { - return { file, results: [] }; - } - - const results = fileNodes.map((fn) => { - // Direct importers - let importedBy = findImportSources(db, fn.id); - if (noTests) importedBy = importedBy.filter((i) => !isTestFile(i.file)); - const directImporters = [...new Set(importedBy.map((i) => i.file))]; - - // Transitive importer count - const totalImporterCount = countTransitiveImporters(db, [fn.id], noTests, importerDepth); - - // Direct imports - let importsTo = findImportTargets(db, fn.id); - if (noTests) importsTo = importsTo.filter((i) => !isTestFile(i.file)); - - // Symbol definitions with roles and caller counts - const defs = findNodesByFile(db, fn.file).filter((d) => BRIEF_KINDS.has(d.kind)); - const symbols = defs.map((d) => { - const callerCount = countTransitiveCallers(db, d.id, noTests, callerDepth); - return { - name: d.name, - kind: d.kind, - line: d.line, - role: d.role || null, - callerCount, - }; - }); - - const riskTier = computeRiskTier(symbols, highRiskCallers, mediumRiskCallers); - - return { - file: fn.file, - risk: riskTier, - imports: importsTo.map((i) => i.file), - importedBy: directImporters, - totalImporterCount, - symbols, - }; - }); - - return { file, results }; - } finally { - db.close(); - } -} diff --git a/src/domain/analysis/context.js b/src/domain/analysis/context.js deleted file mode 100644 index 1f5f113d..00000000 --- a/src/domain/analysis/context.js +++ /dev/null @@ -1,446 +0,0 @@ -import path from 'node:path'; -import { - findCallees, - findCallers, - findCrossFileCallTargets, - findDbPath, - findFileNodes, - findImplementors, - findImportSources, - findImportTargets, - findInterfaces, - findIntraFileCallEdges, - findNodeChildren, - findNodesByFile, - getComplexityForNode, - openReadonlyOrFail, -} from '../../db/index.js'; -import { loadConfig } from '../../infrastructure/config.js'; -import { debug } from '../../infrastructure/logger.js'; -import { isTestFile } from '../../infrastructure/test-filter.js'; -import { - createFileLinesReader, - extractSignature, - extractSummary, - isFileLikeTarget, - readSourceRange, -} from '../../shared/file-utils.js'; -import { resolveMethodViaHierarchy } from '../../shared/hierarchy.js'; -import { normalizeSymbol } from '../../shared/normalize.js'; -import { paginateResult } from '../../shared/paginate.js'; -import { findMatchingNodes } from './symbol-lookup.js'; - -function buildCallees(db, node, repoRoot, getFileLines, opts) { - const { noTests, depth, displayOpts } = opts; - const calleeRows = findCallees(db, node.id); - const filteredCallees = noTests ? calleeRows.filter((c) => !isTestFile(c.file)) : calleeRows; - - const callees = filteredCallees.map((c) => { - const cLines = getFileLines(c.file); - const summary = cLines ? extractSummary(cLines, c.line, displayOpts) : null; - let calleeSource = null; - if (depth >= 1) { - calleeSource = readSourceRange(repoRoot, c.file, c.line, c.end_line, displayOpts); - } - return { - name: c.name, - kind: c.kind, - file: c.file, - line: c.line, - endLine: c.end_line || null, - summary, - source: calleeSource, - }; - }); - - if (depth > 1) { - const visited = new Set(filteredCallees.map((c) => c.id)); - visited.add(node.id); - let frontier = filteredCallees.map((c) => c.id); - const maxDepth = Math.min(depth, 5); - for (let d = 2; d <= maxDepth; d++) { - const nextFrontier = []; - for (const fid of frontier) { - const deeper = findCallees(db, fid); - for (const c of deeper) { - if (!visited.has(c.id) && (!noTests || !isTestFile(c.file))) { - visited.add(c.id); - nextFrontier.push(c.id); - const cLines = getFileLines(c.file); - callees.push({ - name: c.name, - kind: c.kind, - file: c.file, - line: c.line, - endLine: c.end_line || null, - summary: cLines ? extractSummary(cLines, c.line, displayOpts) : null, - source: readSourceRange(repoRoot, c.file, c.line, c.end_line, displayOpts), - }); - } - } - } - frontier = nextFrontier; - if (frontier.length === 0) break; - } - } - - return callees; -} - -function buildCallers(db, node, noTests) { - let callerRows = findCallers(db, node.id); - - if (node.kind === 'method' && node.name.includes('.')) { - const methodName = node.name.split('.').pop(); - const relatedMethods = resolveMethodViaHierarchy(db, methodName); - for (const rm of relatedMethods) { - if (rm.id === node.id) continue; - const extraCallers = findCallers(db, rm.id); - callerRows.push(...extraCallers.map((c) => ({ ...c, viaHierarchy: rm.name }))); - } - } - if (noTests) callerRows = callerRows.filter((c) => !isTestFile(c.file)); - - return callerRows.map((c) => ({ - name: c.name, - kind: c.kind, - file: c.file, - line: c.line, - viaHierarchy: c.viaHierarchy || undefined, - })); -} - -const INTERFACE_LIKE_KINDS = new Set(['interface', 'trait']); -const IMPLEMENTOR_KINDS = new Set(['class', 'struct', 'record', 'enum']); - -function buildImplementationInfo(db, node, noTests) { - // For interfaces/traits: show who implements them - if (INTERFACE_LIKE_KINDS.has(node.kind)) { - let impls = findImplementors(db, node.id); - if (noTests) impls = impls.filter((n) => !isTestFile(n.file)); - return { - implementors: impls.map((n) => ({ name: n.name, kind: n.kind, file: n.file, line: n.line })), - }; - } - // For classes/structs: show what they implement - if (IMPLEMENTOR_KINDS.has(node.kind)) { - let ifaces = findInterfaces(db, node.id); - if (noTests) ifaces = ifaces.filter((n) => !isTestFile(n.file)); - if (ifaces.length > 0) { - return { - implements: ifaces.map((n) => ({ name: n.name, kind: n.kind, file: n.file, line: n.line })), - }; - } - } - return {}; -} - -function buildRelatedTests(db, node, getFileLines, includeTests) { - const testCallerRows = findCallers(db, node.id); - const testCallers = testCallerRows.filter((c) => isTestFile(c.file)); - - const testsByFile = new Map(); - for (const tc of testCallers) { - if (!testsByFile.has(tc.file)) testsByFile.set(tc.file, []); - testsByFile.get(tc.file).push(tc); - } - - const relatedTests = []; - for (const [file] of testsByFile) { - const tLines = getFileLines(file); - const testNames = []; - if (tLines) { - for (const tl of tLines) { - const tm = tl.match(/(?:it|test|describe)\s*\(\s*['"`]([^'"`]+)['"`]/); - if (tm) testNames.push(tm[1]); - } - } - const testSource = includeTests && tLines ? tLines.join('\n') : undefined; - relatedTests.push({ - file, - testCount: testNames.length, - testNames, - source: testSource, - }); - } - - return relatedTests; -} - -function getComplexityMetrics(db, nodeId) { - try { - const cRow = getComplexityForNode(db, nodeId); - if (!cRow) return null; - return { - cognitive: cRow.cognitive, - cyclomatic: cRow.cyclomatic, - maxNesting: cRow.max_nesting, - maintainabilityIndex: cRow.maintainability_index || 0, - halsteadVolume: cRow.halstead_volume || 0, - }; - } catch (e) { - debug(`complexity lookup failed for node ${nodeId}: ${e.message}`); - return null; - } -} - -function getNodeChildrenSafe(db, nodeId) { - try { - return findNodeChildren(db, nodeId).map((c) => ({ - name: c.name, - kind: c.kind, - line: c.line, - endLine: c.end_line || null, - })); - } catch (e) { - debug(`findNodeChildren failed for node ${nodeId}: ${e.message}`); - return []; - } -} - -function explainFileImpl(db, target, getFileLines, displayOpts) { - const fileNodes = findFileNodes(db, `%${target}%`); - if (fileNodes.length === 0) return []; - - return fileNodes.map((fn) => { - const symbols = findNodesByFile(db, fn.file); - - // IDs of symbols that have incoming calls from other files (public) - const publicIds = findCrossFileCallTargets(db, fn.file); - - const fileLines = getFileLines(fn.file); - const mapSymbol = (s) => ({ - name: s.name, - kind: s.kind, - line: s.line, - role: s.role || null, - summary: fileLines ? extractSummary(fileLines, s.line, displayOpts) : null, - signature: fileLines ? extractSignature(fileLines, s.line, displayOpts) : null, - }); - - const publicApi = symbols.filter((s) => publicIds.has(s.id)).map(mapSymbol); - const internal = symbols.filter((s) => !publicIds.has(s.id)).map(mapSymbol); - - const imports = findImportTargets(db, fn.id).map((r) => ({ file: r.file })); - const importedBy = findImportSources(db, fn.id).map((r) => ({ file: r.file })); - - const intraEdges = findIntraFileCallEdges(db, fn.file); - const dataFlowMap = new Map(); - for (const edge of intraEdges) { - if (!dataFlowMap.has(edge.caller_name)) dataFlowMap.set(edge.caller_name, []); - dataFlowMap.get(edge.caller_name).push(edge.callee_name); - } - const dataFlow = [...dataFlowMap.entries()].map(([caller, callees]) => ({ - caller, - callees, - })); - - const metric = db - .prepare(`SELECT nm.line_count FROM node_metrics nm WHERE nm.node_id = ?`) - .get(fn.id); - let lineCount = metric?.line_count || null; - if (!lineCount) { - const maxLine = db - .prepare(`SELECT MAX(end_line) as max_end FROM nodes WHERE file = ?`) - .get(fn.file); - lineCount = maxLine?.max_end || null; - } - - return { - file: fn.file, - lineCount, - symbolCount: symbols.length, - publicApi, - internal, - imports, - importedBy, - dataFlow, - }; - }); -} - -function explainFunctionImpl(db, target, noTests, getFileLines, displayOpts) { - let nodes = db - .prepare( - `SELECT * FROM nodes WHERE name LIKE ? AND kind IN ('function','method','class','interface','type','struct','enum','trait','record','module','constant') ORDER BY file, line`, - ) - .all(`%${target}%`); - if (noTests) nodes = nodes.filter((n) => !isTestFile(n.file)); - if (nodes.length === 0) return []; - - const hc = new Map(); - return nodes.slice(0, 10).map((node) => { - const fileLines = getFileLines(node.file); - const lineCount = node.end_line ? node.end_line - node.line + 1 : null; - const summary = fileLines ? extractSummary(fileLines, node.line, displayOpts) : null; - const signature = fileLines ? extractSignature(fileLines, node.line, displayOpts) : null; - - const callees = findCallees(db, node.id).map((c) => ({ - name: c.name, - kind: c.kind, - file: c.file, - line: c.line, - })); - - let callers = findCallers(db, node.id).map((c) => ({ - name: c.name, - kind: c.kind, - file: c.file, - line: c.line, - })); - if (noTests) callers = callers.filter((c) => !isTestFile(c.file)); - - const testCallerRows = findCallers(db, node.id); - const seenFiles = new Set(); - const relatedTests = testCallerRows - .filter((r) => isTestFile(r.file) && !seenFiles.has(r.file) && seenFiles.add(r.file)) - .map((r) => ({ file: r.file })); - - return { - ...normalizeSymbol(node, db, hc), - lineCount, - summary, - signature, - complexity: getComplexityMetrics(db, node.id), - callees, - callers, - relatedTests, - }; - }); -} - -function explainCallees( - parentResults, - currentDepth, - visited, - db, - noTests, - getFileLines, - displayOpts, -) { - if (currentDepth <= 0) return; - for (const r of parentResults) { - const newCallees = []; - for (const callee of r.callees) { - const key = `${callee.name}:${callee.file}:${callee.line}`; - if (visited.has(key)) continue; - visited.add(key); - const calleeResults = explainFunctionImpl( - db, - callee.name, - noTests, - getFileLines, - displayOpts, - ); - const exact = calleeResults.find((cr) => cr.file === callee.file && cr.line === callee.line); - if (exact) { - exact._depth = (r._depth || 0) + 1; - newCallees.push(exact); - } - } - if (newCallees.length > 0) { - r.depDetails = newCallees; - explainCallees(newCallees, currentDepth - 1, visited, db, noTests, getFileLines, displayOpts); - } - } -} - -// ─── Exported functions ────────────────────────────────────────────────── - -export function contextData(name, customDbPath, opts = {}) { - const db = openReadonlyOrFail(customDbPath); - try { - const depth = opts.depth || 0; - const noSource = opts.noSource || false; - const noTests = opts.noTests || false; - const includeTests = opts.includeTests || false; - - const config = opts.config || loadConfig(); - const displayOpts = config.display || {}; - - const dbPath = findDbPath(customDbPath); - const repoRoot = path.resolve(path.dirname(dbPath), '..'); - - const nodes = findMatchingNodes(db, name, { noTests, file: opts.file, kind: opts.kind }); - if (nodes.length === 0) { - return { name, results: [] }; - } - - const getFileLines = createFileLinesReader(repoRoot); - - const results = nodes.map((node) => { - const fileLines = getFileLines(node.file); - - const source = noSource - ? null - : readSourceRange(repoRoot, node.file, node.line, node.end_line, displayOpts); - - const signature = fileLines ? extractSignature(fileLines, node.line, displayOpts) : null; - - const callees = buildCallees(db, node, repoRoot, getFileLines, { - noTests, - depth, - displayOpts, - }); - const callers = buildCallers(db, node, noTests); - const relatedTests = buildRelatedTests(db, node, getFileLines, includeTests); - const complexityMetrics = getComplexityMetrics(db, node.id); - const nodeChildren = getNodeChildrenSafe(db, node.id); - const implInfo = buildImplementationInfo(db, node, noTests); - - return { - name: node.name, - kind: node.kind, - file: node.file, - line: node.line, - role: node.role || null, - endLine: node.end_line || null, - source, - signature, - complexity: complexityMetrics, - children: nodeChildren.length > 0 ? nodeChildren : undefined, - callees, - callers, - relatedTests, - ...implInfo, - }; - }); - - const base = { name, results }; - return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset }); - } finally { - db.close(); - } -} - -export function explainData(target, customDbPath, opts = {}) { - const db = openReadonlyOrFail(customDbPath); - try { - const noTests = opts.noTests || false; - const depth = opts.depth || 0; - const kind = isFileLikeTarget(target) ? 'file' : 'function'; - - const config = opts.config || loadConfig(); - const displayOpts = config.display || {}; - - const dbPath = findDbPath(customDbPath); - const repoRoot = path.resolve(path.dirname(dbPath), '..'); - - const getFileLines = createFileLinesReader(repoRoot); - - const results = - kind === 'file' - ? explainFileImpl(db, target, getFileLines, displayOpts) - : explainFunctionImpl(db, target, noTests, getFileLines, displayOpts); - - if (kind === 'function' && depth > 0 && results.length > 0) { - const visited = new Set(results.map((r) => `${r.name}:${r.file}:${r.line}`)); - explainCallees(results, depth, visited, db, noTests, getFileLines, displayOpts); - } - - const base = { target, kind, results }; - return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset }); - } finally { - db.close(); - } -} diff --git a/src/domain/analysis/dependencies.js b/src/domain/analysis/dependencies.js deleted file mode 100644 index 867cd5bd..00000000 --- a/src/domain/analysis/dependencies.js +++ /dev/null @@ -1,395 +0,0 @@ -import { - findCallees, - findCallers, - findFileNodes, - findImportSources, - findImportTargets, - findNodesByFile, - openReadonlyOrFail, -} from '../../db/index.js'; -import { isTestFile } from '../../infrastructure/test-filter.js'; -import { resolveMethodViaHierarchy } from '../../shared/hierarchy.js'; -import { normalizeSymbol } from '../../shared/normalize.js'; -import { paginateResult } from '../../shared/paginate.js'; -import { findMatchingNodes } from './symbol-lookup.js'; - -export function fileDepsData(file, customDbPath, opts = {}) { - const db = openReadonlyOrFail(customDbPath); - try { - const noTests = opts.noTests || false; - const fileNodes = findFileNodes(db, `%${file}%`); - if (fileNodes.length === 0) { - return { file, results: [] }; - } - - const results = fileNodes.map((fn) => { - let importsTo = findImportTargets(db, fn.id); - if (noTests) importsTo = importsTo.filter((i) => !isTestFile(i.file)); - - let importedBy = findImportSources(db, fn.id); - if (noTests) importedBy = importedBy.filter((i) => !isTestFile(i.file)); - - const defs = findNodesByFile(db, fn.file); - - return { - file: fn.file, - imports: importsTo.map((i) => ({ file: i.file, typeOnly: i.edge_kind === 'imports-type' })), - importedBy: importedBy.map((i) => ({ file: i.file })), - definitions: defs.map((d) => ({ name: d.name, kind: d.kind, line: d.line })), - }; - }); - - const base = { file, results }; - return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset }); - } finally { - db.close(); - } -} - -/** - * BFS transitive caller traversal starting from `callers` of `nodeId`. - * Returns an object keyed by depth (2..depth) → array of caller descriptors. - */ -function buildTransitiveCallers(db, callers, nodeId, depth, noTests) { - const transitiveCallers = {}; - if (depth <= 1) return transitiveCallers; - - const visited = new Set([nodeId]); - let frontier = callers - .map((c) => { - const row = db - .prepare('SELECT id FROM nodes WHERE name = ? AND kind = ? AND file = ? AND line = ?') - .get(c.name, c.kind, c.file, c.line); - return row ? { ...c, id: row.id } : null; - }) - .filter(Boolean); - - for (let d = 2; d <= depth; d++) { - const nextFrontier = []; - for (const f of frontier) { - if (visited.has(f.id)) continue; - visited.add(f.id); - const upstream = db - .prepare(` - SELECT n.name, n.kind, n.file, n.line - FROM edges e JOIN nodes n ON e.source_id = n.id - WHERE e.target_id = ? AND e.kind = 'calls' - `) - .all(f.id); - for (const u of upstream) { - if (noTests && isTestFile(u.file)) continue; - const uid = db - .prepare('SELECT id FROM nodes WHERE name = ? AND kind = ? AND file = ? AND line = ?') - .get(u.name, u.kind, u.file, u.line)?.id; - if (uid && !visited.has(uid)) { - nextFrontier.push({ ...u, id: uid }); - } - } - } - if (nextFrontier.length > 0) { - transitiveCallers[d] = nextFrontier.map((n) => ({ - name: n.name, - kind: n.kind, - file: n.file, - line: n.line, - })); - } - frontier = nextFrontier; - if (frontier.length === 0) break; - } - - return transitiveCallers; -} - -export function fnDepsData(name, customDbPath, opts = {}) { - const db = openReadonlyOrFail(customDbPath); - try { - const depth = opts.depth || 3; - const noTests = opts.noTests || false; - const hc = new Map(); - - const nodes = findMatchingNodes(db, name, { noTests, file: opts.file, kind: opts.kind }); - if (nodes.length === 0) { - return { name, results: [] }; - } - - const results = nodes.map((node) => { - const callees = findCallees(db, node.id); - const filteredCallees = noTests ? callees.filter((c) => !isTestFile(c.file)) : callees; - - let callers = findCallers(db, node.id); - - if (node.kind === 'method' && node.name.includes('.')) { - const methodName = node.name.split('.').pop(); - const relatedMethods = resolveMethodViaHierarchy(db, methodName); - for (const rm of relatedMethods) { - if (rm.id === node.id) continue; - const extraCallers = findCallers(db, rm.id); - callers.push(...extraCallers.map((c) => ({ ...c, viaHierarchy: rm.name }))); - } - } - if (noTests) callers = callers.filter((c) => !isTestFile(c.file)); - - const transitiveCallers = buildTransitiveCallers(db, callers, node.id, depth, noTests); - - return { - ...normalizeSymbol(node, db, hc), - callees: filteredCallees.map((c) => ({ - name: c.name, - kind: c.kind, - file: c.file, - line: c.line, - })), - callers: callers.map((c) => ({ - name: c.name, - kind: c.kind, - file: c.file, - line: c.line, - viaHierarchy: c.viaHierarchy || undefined, - })), - transitiveCallers, - }; - }); - - const base = { name, results }; - return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset }); - } finally { - db.close(); - } -} - -/** - * Resolve from/to symbol names to node records. - * Returns { sourceNode, targetNode, fromCandidates, toCandidates } on success, - * or { earlyResult } when a caller-facing error/not-found response should be returned immediately. - */ -function resolveEndpoints(db, from, to, opts) { - const { noTests = false } = opts; - - const fromNodes = findMatchingNodes(db, from, { - noTests, - file: opts.fromFile, - kind: opts.kind, - }); - if (fromNodes.length === 0) { - return { - earlyResult: { - from, - to, - found: false, - error: `No symbol matching "${from}"`, - fromCandidates: [], - toCandidates: [], - }, - }; - } - - const toNodes = findMatchingNodes(db, to, { - noTests, - file: opts.toFile, - kind: opts.kind, - }); - if (toNodes.length === 0) { - return { - earlyResult: { - from, - to, - found: false, - error: `No symbol matching "${to}"`, - fromCandidates: fromNodes - .slice(0, 5) - .map((n) => ({ name: n.name, kind: n.kind, file: n.file, line: n.line })), - toCandidates: [], - }, - }; - } - - const fromCandidates = fromNodes - .slice(0, 5) - .map((n) => ({ name: n.name, kind: n.kind, file: n.file, line: n.line })); - const toCandidates = toNodes - .slice(0, 5) - .map((n) => ({ name: n.name, kind: n.kind, file: n.file, line: n.line })); - - return { - sourceNode: fromNodes[0], - targetNode: toNodes[0], - fromCandidates, - toCandidates, - }; -} - -/** - * BFS from sourceId toward targetId. - * Returns { found, parent, alternateCount, foundDepth }. - * `parent` maps nodeId → { parentId, edgeKind }. - */ -function bfsShortestPath(db, sourceId, targetId, edgeKinds, reverse, maxDepth, noTests) { - const kindPlaceholders = edgeKinds.map(() => '?').join(', '); - - // Forward: source_id → target_id (A calls... calls B) - // Reverse: target_id → source_id (B is called by... called by A) - const neighborQuery = reverse - ? `SELECT n.id, n.name, n.kind, n.file, n.line, e.kind AS edge_kind - FROM edges e JOIN nodes n ON e.source_id = n.id - WHERE e.target_id = ? AND e.kind IN (${kindPlaceholders})` - : `SELECT n.id, n.name, n.kind, n.file, n.line, e.kind AS edge_kind - FROM edges e JOIN nodes n ON e.target_id = n.id - WHERE e.source_id = ? AND e.kind IN (${kindPlaceholders})`; - const neighborStmt = db.prepare(neighborQuery); - - const visited = new Set([sourceId]); - const parent = new Map(); - let queue = [sourceId]; - let found = false; - let alternateCount = 0; - let foundDepth = -1; - - for (let depth = 1; depth <= maxDepth; depth++) { - const nextQueue = []; - for (const currentId of queue) { - const neighbors = neighborStmt.all(currentId, ...edgeKinds); - for (const n of neighbors) { - if (noTests && isTestFile(n.file)) continue; - if (n.id === targetId) { - if (!found) { - found = true; - foundDepth = depth; - parent.set(n.id, { parentId: currentId, edgeKind: n.edge_kind }); - } - alternateCount++; - continue; - } - if (!visited.has(n.id)) { - visited.add(n.id); - parent.set(n.id, { parentId: currentId, edgeKind: n.edge_kind }); - nextQueue.push(n.id); - } - } - } - if (found) break; - queue = nextQueue; - if (queue.length === 0) break; - } - - return { found, parent, alternateCount, foundDepth }; -} - -/** - * Walk the parent map from targetId back to sourceId and return an ordered - * array of node IDs source → target. - */ -function reconstructPath(db, pathIds, parent) { - const nodeCache = new Map(); - const getNode = (id) => { - if (nodeCache.has(id)) return nodeCache.get(id); - const row = db.prepare('SELECT name, kind, file, line FROM nodes WHERE id = ?').get(id); - nodeCache.set(id, row); - return row; - }; - - return pathIds.map((id, idx) => { - const node = getNode(id); - const edgeKind = idx === 0 ? null : parent.get(id).edgeKind; - return { name: node.name, kind: node.kind, file: node.file, line: node.line, edgeKind }; - }); -} - -export function pathData(from, to, customDbPath, opts = {}) { - const db = openReadonlyOrFail(customDbPath); - try { - const noTests = opts.noTests || false; - const maxDepth = opts.maxDepth || 10; - const edgeKinds = opts.edgeKinds || ['calls']; - const reverse = opts.reverse || false; - - const resolved = resolveEndpoints(db, from, to, { - noTests, - fromFile: opts.fromFile, - toFile: opts.toFile, - kind: opts.kind, - }); - if (resolved.earlyResult) return resolved.earlyResult; - - const { sourceNode, targetNode, fromCandidates, toCandidates } = resolved; - - // Self-path - if (sourceNode.id === targetNode.id) { - return { - from, - to, - fromCandidates, - toCandidates, - found: true, - hops: 0, - path: [ - { - name: sourceNode.name, - kind: sourceNode.kind, - file: sourceNode.file, - line: sourceNode.line, - edgeKind: null, - }, - ], - alternateCount: 0, - edgeKinds, - reverse, - maxDepth, - }; - } - - const { - found, - parent, - alternateCount: rawAlternateCount, - foundDepth, - } = bfsShortestPath(db, sourceNode.id, targetNode.id, edgeKinds, reverse, maxDepth, noTests); - - if (!found) { - return { - from, - to, - fromCandidates, - toCandidates, - found: false, - hops: null, - path: [], - alternateCount: 0, - edgeKinds, - reverse, - maxDepth, - }; - } - - // rawAlternateCount includes the one we kept; subtract 1 for "alternates" - const alternateCount = Math.max(0, rawAlternateCount - 1); - - // Reconstruct path from target back to source - const pathIds = [targetNode.id]; - let cur = targetNode.id; - while (cur !== sourceNode.id) { - const p = parent.get(cur); - pathIds.push(p.parentId); - cur = p.parentId; - } - pathIds.reverse(); - - const resultPath = reconstructPath(db, pathIds, parent); - - return { - from, - to, - fromCandidates, - toCandidates, - found: true, - hops: foundDepth, - path: resultPath, - alternateCount, - edgeKinds, - reverse, - maxDepth, - }; - } finally { - db.close(); - } -} diff --git a/src/domain/analysis/exports.js b/src/domain/analysis/exports.js deleted file mode 100644 index 629ae792..00000000 --- a/src/domain/analysis/exports.js +++ /dev/null @@ -1,206 +0,0 @@ -import path from 'node:path'; -import { - findCrossFileCallTargets, - findDbPath, - findFileNodes, - findNodesByFile, - openReadonlyOrFail, -} from '../../db/index.js'; -import { loadConfig } from '../../infrastructure/config.js'; -import { debug } from '../../infrastructure/logger.js'; -import { isTestFile } from '../../infrastructure/test-filter.js'; -import { - createFileLinesReader, - extractSignature, - extractSummary, -} from '../../shared/file-utils.js'; -import { paginateResult } from '../../shared/paginate.js'; - -export function exportsData(file, customDbPath, opts = {}) { - const db = openReadonlyOrFail(customDbPath); - try { - const noTests = opts.noTests || false; - - const config = opts.config || loadConfig(); - const displayOpts = config.display || {}; - - const dbFilePath = findDbPath(customDbPath); - const repoRoot = path.resolve(path.dirname(dbFilePath), '..'); - - const getFileLines = createFileLinesReader(repoRoot); - - const unused = opts.unused || false; - const fileResults = exportsFileImpl(db, file, noTests, getFileLines, unused, displayOpts); - - if (fileResults.length === 0) { - return paginateResult( - { - file, - results: [], - reexports: [], - reexportedSymbols: [], - totalExported: 0, - totalInternal: 0, - totalUnused: 0, - totalReexported: 0, - totalReexportedUnused: 0, - }, - 'results', - { limit: opts.limit, offset: opts.offset }, - ); - } - - // For single-file match return flat; for multi-match return first (like explainData) - const first = fileResults[0]; - const base = { - file: first.file, - results: first.results, - reexports: first.reexports, - reexportedSymbols: first.reexportedSymbols, - totalExported: first.totalExported, - totalInternal: first.totalInternal, - totalUnused: first.totalUnused, - totalReexported: first.totalReexported, - totalReexportedUnused: first.totalReexportedUnused, - }; - const paginated = paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset }); - // Paginate reexportedSymbols with the same limit/offset (match paginateResult behaviour) - if (opts.limit != null) { - const off = opts.offset || 0; - paginated.reexportedSymbols = paginated.reexportedSymbols.slice(off, off + opts.limit); - // Update _pagination.hasMore to account for reexportedSymbols (barrel-only files - // have empty results[], so hasMore would always be false without this) - if (paginated._pagination) { - const reexTotal = opts.unused ? base.totalReexportedUnused : base.totalReexported; - const resultsHasMore = paginated._pagination.hasMore; - const reexHasMore = off + opts.limit < reexTotal; - paginated._pagination.hasMore = resultsHasMore || reexHasMore; - } - } - return paginated; - } finally { - db.close(); - } -} - -function exportsFileImpl(db, target, noTests, getFileLines, unused, displayOpts) { - const fileNodes = findFileNodes(db, `%${target}%`); - if (fileNodes.length === 0) return []; - - // Detect whether exported column exists - let hasExportedCol = false; - try { - db.prepare('SELECT exported FROM nodes LIMIT 0').raw(); - hasExportedCol = true; - } catch (e) { - debug(`exported column not available, using fallback: ${e.message}`); - } - - return fileNodes.map((fn) => { - const symbols = findNodesByFile(db, fn.file); - - let exported; - if (hasExportedCol) { - // Use the exported column populated during build - exported = db - .prepare( - "SELECT * FROM nodes WHERE file = ? AND kind != 'file' AND exported = 1 ORDER BY line", - ) - .all(fn.file); - } else { - // Fallback: symbols that have incoming calls from other files - const exportedIds = findCrossFileCallTargets(db, fn.file); - exported = symbols.filter((s) => exportedIds.has(s.id)); - } - const internalCount = symbols.length - exported.length; - - const buildSymbolResult = (s, fileLines) => { - let consumers = db - .prepare( - `SELECT n.name, n.file, n.line FROM edges e JOIN nodes n ON e.source_id = n.id - WHERE e.target_id = ? AND e.kind = 'calls'`, - ) - .all(s.id); - if (noTests) consumers = consumers.filter((c) => !isTestFile(c.file)); - - return { - name: s.name, - kind: s.kind, - line: s.line, - endLine: s.end_line ?? null, - role: s.role || null, - signature: fileLines ? extractSignature(fileLines, s.line, displayOpts) : null, - summary: fileLines ? extractSummary(fileLines, s.line, displayOpts) : null, - consumers: consumers.map((c) => ({ name: c.name, file: c.file, line: c.line })), - consumerCount: consumers.length, - }; - }; - - const results = exported.map((s) => buildSymbolResult(s, getFileLines(fn.file))); - - const totalUnused = results.filter((r) => r.consumerCount === 0).length; - - // Files that re-export this file (barrel → this file) - const reexports = db - .prepare( - `SELECT DISTINCT n.file FROM edges e JOIN nodes n ON e.source_id = n.id - WHERE e.target_id = ? AND e.kind = 'reexports'`, - ) - .all(fn.id) - .map((r) => ({ file: r.file })); - - // For barrel files: gather symbols re-exported from target modules - const reexportTargets = db - .prepare( - `SELECT DISTINCT n.file FROM edges e JOIN nodes n ON e.target_id = n.id - WHERE e.source_id = ? AND e.kind = 'reexports'`, - ) - .all(fn.id); - - const reexportedSymbols = []; - for (const target of reexportTargets) { - let targetExported; - if (hasExportedCol) { - targetExported = db - .prepare( - "SELECT * FROM nodes WHERE file = ? AND kind != 'file' AND exported = 1 ORDER BY line", - ) - .all(target.file); - } else { - // Fallback: same heuristic as direct exports — symbols called from other files - const targetSymbols = findNodesByFile(db, target.file); - const exportedIds = findCrossFileCallTargets(db, target.file); - targetExported = targetSymbols.filter((s) => exportedIds.has(s.id)); - } - for (const s of targetExported) { - const fileLines = getFileLines(target.file); - reexportedSymbols.push({ - ...buildSymbolResult(s, fileLines), - originFile: target.file, - }); - } - } - - let filteredResults = results; - let filteredReexported = reexportedSymbols; - if (unused) { - filteredResults = results.filter((r) => r.consumerCount === 0); - filteredReexported = reexportedSymbols.filter((r) => r.consumerCount === 0); - } - - const totalReexported = reexportedSymbols.length; - const totalReexportedUnused = reexportedSymbols.filter((r) => r.consumerCount === 0).length; - - return { - file: fn.file, - results: filteredResults, - reexports, - reexportedSymbols: filteredReexported, - totalExported: exported.length, - totalInternal: internalCount, - totalUnused, - totalReexported, - totalReexportedUnused, - }; - }); -} diff --git a/src/domain/analysis/impact.js b/src/domain/analysis/impact.js deleted file mode 100644 index 2ce1dbbf..00000000 --- a/src/domain/analysis/impact.js +++ /dev/null @@ -1,675 +0,0 @@ -import { execFileSync } from 'node:child_process'; -import fs from 'node:fs'; -import path from 'node:path'; -import { - findDbPath, - findDistinctCallers, - findFileNodes, - findImplementors, - findImportDependents, - findNodeById, - openReadonlyOrFail, -} from '../../db/index.js'; -import { evaluateBoundaries } from '../../features/boundaries.js'; -import { coChangeForFiles } from '../../features/cochange.js'; -import { ownersForFiles } from '../../features/owners.js'; -import { loadConfig } from '../../infrastructure/config.js'; -import { debug } from '../../infrastructure/logger.js'; -import { isTestFile } from '../../infrastructure/test-filter.js'; -import { normalizeSymbol } from '../../shared/normalize.js'; -import { paginateResult } from '../../shared/paginate.js'; -import { findMatchingNodes } from './symbol-lookup.js'; - -// ─── Shared BFS: transitive callers ──────────────────────────────────── - -const INTERFACE_LIKE_KINDS = new Set(['interface', 'trait']); - -/** - * Check whether the graph contains any 'implements' edges. - * Cached per db handle so the query runs at most once per connection. - */ -const _hasImplementsCache = new WeakMap(); -function hasImplementsEdges(db) { - if (_hasImplementsCache.has(db)) return _hasImplementsCache.get(db); - const row = db.prepare("SELECT 1 FROM edges WHERE kind = 'implements' LIMIT 1").get(); - const result = !!row; - _hasImplementsCache.set(db, result); - return result; -} - -/** - * BFS traversal to find transitive callers of a node. - * When an interface/trait node is encountered (either as the start node or - * during traversal), its concrete implementors are also added to the frontier - * so that changes to an interface signature propagate to all implementors. - * - * @param {import('better-sqlite3').Database} db - Open read-only SQLite database handle (not a Repository) - * @param {number} startId - Starting node ID - * @param {{ noTests?: boolean, maxDepth?: number, includeImplementors?: boolean, onVisit?: (caller: object, parentId: number, depth: number) => void }} options - * @returns {{ totalDependents: number, levels: Record> }} - */ -export function bfsTransitiveCallers( - db, - startId, - { noTests = false, maxDepth = 3, includeImplementors = true, onVisit } = {}, -) { - // Skip all implementor lookups when the graph has no implements edges - const resolveImplementors = includeImplementors && hasImplementsEdges(db); - - const visited = new Set([startId]); - const levels = {}; - let frontier = [startId]; - - // Seed: if start node is an interface/trait, include its implementors at depth 1. - // Implementors go into a separate list so their callers appear at depth 2, not depth 1. - const implNextFrontier = []; - if (resolveImplementors) { - const startNode = findNodeById(db, startId); - if (startNode && INTERFACE_LIKE_KINDS.has(startNode.kind)) { - const impls = findImplementors(db, startId); - for (const impl of impls) { - if (!visited.has(impl.id) && (!noTests || !isTestFile(impl.file))) { - visited.add(impl.id); - implNextFrontier.push(impl.id); - if (!levels[1]) levels[1] = []; - levels[1].push({ - name: impl.name, - kind: impl.kind, - file: impl.file, - line: impl.line, - viaImplements: true, - }); - if (onVisit) onVisit({ ...impl, viaImplements: true }, startId, 1); - } - } - } - } - - for (let d = 1; d <= maxDepth; d++) { - // On the first wave, merge seeded implementors so their callers appear at d=2 - if (d === 1 && implNextFrontier.length > 0) { - frontier = [...frontier, ...implNextFrontier]; - } - const nextFrontier = []; - for (const fid of frontier) { - const callers = findDistinctCallers(db, fid); - for (const c of callers) { - if (!visited.has(c.id) && (!noTests || !isTestFile(c.file))) { - visited.add(c.id); - nextFrontier.push(c.id); - if (!levels[d]) levels[d] = []; - levels[d].push({ name: c.name, kind: c.kind, file: c.file, line: c.line }); - if (onVisit) onVisit(c, fid, d); - } - - // If a caller is an interface/trait, also pull in its implementors - // Implementors are one extra hop away, so record at d+1 - if (resolveImplementors && INTERFACE_LIKE_KINDS.has(c.kind)) { - const impls = findImplementors(db, c.id); - for (const impl of impls) { - if (!visited.has(impl.id) && (!noTests || !isTestFile(impl.file))) { - visited.add(impl.id); - nextFrontier.push(impl.id); - const implDepth = d + 1; - if (!levels[implDepth]) levels[implDepth] = []; - levels[implDepth].push({ - name: impl.name, - kind: impl.kind, - file: impl.file, - line: impl.line, - viaImplements: true, - }); - if (onVisit) onVisit({ ...impl, viaImplements: true }, c.id, implDepth); - } - } - } - } - } - frontier = nextFrontier; - if (frontier.length === 0) break; - } - - return { totalDependents: visited.size - 1, levels }; -} - -export function impactAnalysisData(file, customDbPath, opts = {}) { - const db = openReadonlyOrFail(customDbPath); - try { - const noTests = opts.noTests || false; - const fileNodes = findFileNodes(db, `%${file}%`); - if (fileNodes.length === 0) { - return { file, sources: [], levels: {}, totalDependents: 0 }; - } - - const visited = new Set(); - const queue = []; - const levels = new Map(); - - for (const fn of fileNodes) { - visited.add(fn.id); - queue.push(fn.id); - levels.set(fn.id, 0); - } - - while (queue.length > 0) { - const current = queue.shift(); - const level = levels.get(current); - const dependents = findImportDependents(db, current); - for (const dep of dependents) { - if (!visited.has(dep.id) && (!noTests || !isTestFile(dep.file))) { - visited.add(dep.id); - queue.push(dep.id); - levels.set(dep.id, level + 1); - } - } - } - - const byLevel = {}; - for (const [id, level] of levels) { - if (level === 0) continue; - if (!byLevel[level]) byLevel[level] = []; - const node = findNodeById(db, id); - if (node) byLevel[level].push({ file: node.file }); - } - - return { - file, - sources: fileNodes.map((f) => f.file), - levels: byLevel, - totalDependents: visited.size - fileNodes.length, - }; - } finally { - db.close(); - } -} - -export function fnImpactData(name, customDbPath, opts = {}) { - const db = openReadonlyOrFail(customDbPath); - try { - const config = opts.config || loadConfig(); - const maxDepth = opts.depth || config.analysis?.fnImpactDepth || 5; - const noTests = opts.noTests || false; - const hc = new Map(); - - const nodes = findMatchingNodes(db, name, { noTests, file: opts.file, kind: opts.kind }); - if (nodes.length === 0) { - return { name, results: [] }; - } - - const includeImplementors = opts.includeImplementors !== false; - - const results = nodes.map((node) => { - const { levels, totalDependents } = bfsTransitiveCallers(db, node.id, { - noTests, - maxDepth, - includeImplementors, - }); - return { - ...normalizeSymbol(node, db, hc), - levels, - totalDependents, - }; - }); - - const base = { name, results }; - return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset }); - } finally { - db.close(); - } -} - -// ─── diffImpactData helpers ───────────────────────────────────────────── - -/** - * Walk up from repoRoot until a .git directory is found. - * Returns true if a git root exists, false otherwise. - * - * @param {string} repoRoot - * @returns {boolean} - */ -function findGitRoot(repoRoot) { - let checkDir = repoRoot; - while (checkDir) { - if (fs.existsSync(path.join(checkDir, '.git'))) { - return true; - } - const parent = path.dirname(checkDir); - if (parent === checkDir) break; - checkDir = parent; - } - return false; -} - -/** - * Execute git diff and return the raw output string. - * Returns `{ output: string }` on success or `{ error: string }` on failure. - * - * @param {string} repoRoot - * @param {{ staged?: boolean, ref?: string }} opts - * @returns {{ output: string } | { error: string }} - */ -function runGitDiff(repoRoot, opts) { - try { - const args = opts.staged - ? ['diff', '--cached', '--unified=0', '--no-color'] - : ['diff', opts.ref || 'HEAD', '--unified=0', '--no-color']; - const output = execFileSync('git', args, { - cwd: repoRoot, - encoding: 'utf-8', - maxBuffer: 10 * 1024 * 1024, - stdio: ['pipe', 'pipe', 'pipe'], - }); - return { output }; - } catch (e) { - return { error: `Failed to run git diff: ${e.message}` }; - } -} - -/** - * Parse raw git diff output into a changedRanges map and newFiles set. - * - * @param {string} diffOutput - * @returns {{ changedRanges: Map>, newFiles: Set }} - */ -function parseGitDiff(diffOutput) { - const changedRanges = new Map(); - const newFiles = new Set(); - let currentFile = null; - let prevIsDevNull = false; - - for (const line of diffOutput.split('\n')) { - if (line.startsWith('--- /dev/null')) { - prevIsDevNull = true; - continue; - } - if (line.startsWith('--- ')) { - prevIsDevNull = false; - continue; - } - const fileMatch = line.match(/^\+\+\+ b\/(.+)/); - if (fileMatch) { - currentFile = fileMatch[1]; - if (!changedRanges.has(currentFile)) changedRanges.set(currentFile, []); - if (prevIsDevNull) newFiles.add(currentFile); - prevIsDevNull = false; - continue; - } - const hunkMatch = line.match(/^@@ .+ \+(\d+)(?:,(\d+))? @@/); - if (hunkMatch && currentFile) { - const start = parseInt(hunkMatch[1], 10); - const count = parseInt(hunkMatch[2] || '1', 10); - changedRanges.get(currentFile).push({ start, end: start + count - 1 }); - } - } - - return { changedRanges, newFiles }; -} - -/** - * Find all function/method/class nodes whose line ranges overlap any changed range. - * - * @param {import('better-sqlite3').Database} db - * @param {Map} changedRanges - * @param {boolean} noTests - * @returns {Array} - */ -function findAffectedFunctions(db, changedRanges, noTests) { - const affectedFunctions = []; - for (const [file, ranges] of changedRanges) { - if (noTests && isTestFile(file)) continue; - const defs = db - .prepare( - `SELECT * FROM nodes WHERE file = ? AND kind IN ('function', 'method', 'class') ORDER BY line`, - ) - .all(file); - for (let i = 0; i < defs.length; i++) { - const def = defs[i]; - const endLine = def.end_line || (defs[i + 1] ? defs[i + 1].line - 1 : 999999); - for (const range of ranges) { - if (range.start <= endLine && range.end >= def.line) { - affectedFunctions.push(def); - break; - } - } - } - } - return affectedFunctions; -} - -/** - * Run BFS per affected function, collecting per-function results and the full affected set. - * - * @param {import('better-sqlite3').Database} db - * @param {Array} affectedFunctions - * @param {boolean} noTests - * @param {number} maxDepth - * @returns {{ functionResults: Array, allAffected: Set }} - */ -function buildFunctionImpactResults( - db, - affectedFunctions, - noTests, - maxDepth, - includeImplementors = true, -) { - const allAffected = new Set(); - const functionResults = affectedFunctions.map((fn) => { - const edges = []; - const idToKey = new Map(); - idToKey.set(fn.id, `${fn.file}::${fn.name}:${fn.line}`); - - const { levels, totalDependents } = bfsTransitiveCallers(db, fn.id, { - noTests, - maxDepth, - includeImplementors, - onVisit(c, parentId) { - allAffected.add(`${c.file}:${c.name}`); - const callerKey = `${c.file}::${c.name}:${c.line}`; - idToKey.set(c.id, callerKey); - edges.push({ from: idToKey.get(parentId), to: callerKey }); - }, - }); - - return { - name: fn.name, - kind: fn.kind, - file: fn.file, - line: fn.line, - transitiveCallers: totalDependents, - levels, - edges, - }; - }); - - return { functionResults, allAffected }; -} - -/** - * Look up historically co-changed files for the set of changed files. - * Returns an empty array if the co_changes table is unavailable. - * - * @param {import('better-sqlite3').Database} db - * @param {Map} changedRanges - * @param {Set} affectedFiles - * @param {boolean} noTests - * @returns {Array} - */ -function lookupCoChanges(db, changedRanges, affectedFiles, noTests) { - try { - db.prepare('SELECT 1 FROM co_changes LIMIT 1').get(); - const changedFilesList = [...changedRanges.keys()]; - const coResults = coChangeForFiles(changedFilesList, db, { - minJaccard: 0.3, - limit: 20, - noTests, - }); - return coResults.filter((r) => !affectedFiles.has(r.file)); - } catch (e) { - debug(`co_changes lookup skipped: ${e.message}`); - return []; - } -} - -/** - * Look up CODEOWNERS for changed and affected files. - * Returns null if no owners are found or lookup fails. - * - * @param {Map} changedRanges - * @param {Set} affectedFiles - * @param {string} repoRoot - * @returns {{ owners: object, affectedOwners: Array, suggestedReviewers: Array } | null} - */ -function lookupOwnership(changedRanges, affectedFiles, repoRoot) { - try { - const allFilePaths = [...new Set([...changedRanges.keys(), ...affectedFiles])]; - const ownerResult = ownersForFiles(allFilePaths, repoRoot); - if (ownerResult.affectedOwners.length > 0) { - return { - owners: Object.fromEntries(ownerResult.owners), - affectedOwners: ownerResult.affectedOwners, - suggestedReviewers: ownerResult.suggestedReviewers, - }; - } - return null; - } catch (e) { - debug(`CODEOWNERS lookup skipped: ${e.message}`); - return null; - } -} - -/** - * Check manifesto boundary violations scoped to the changed files. - * Returns `{ boundaryViolations, boundaryViolationCount }`. - * - * @param {import('better-sqlite3').Database} db - * @param {Map} changedRanges - * @param {boolean} noTests - * @param {object} opts — full diffImpactData opts (may contain `opts.config`) - * @param {string} repoRoot - * @returns {{ boundaryViolations: Array, boundaryViolationCount: number }} - */ -function checkBoundaryViolations(db, changedRanges, noTests, opts, repoRoot) { - try { - const cfg = opts.config || loadConfig(repoRoot); - const boundaryConfig = cfg.manifesto?.boundaries; - if (boundaryConfig) { - const result = evaluateBoundaries(db, boundaryConfig, { - scopeFiles: [...changedRanges.keys()], - noTests, - }); - return { - boundaryViolations: result.violations, - boundaryViolationCount: result.violationCount, - }; - } - } catch (e) { - debug(`boundary check skipped: ${e.message}`); - } - return { boundaryViolations: [], boundaryViolationCount: 0 }; -} - -// ─── diffImpactData ───────────────────────────────────────────────────── - -/** - * Fix #2: Shell injection vulnerability. - * Uses execFileSync instead of execSync to prevent shell interpretation of user input. - */ -export function diffImpactData(customDbPath, opts = {}) { - const db = openReadonlyOrFail(customDbPath); - try { - const noTests = opts.noTests || false; - const config = opts.config || loadConfig(); - const maxDepth = opts.depth || config.analysis?.impactDepth || 3; - - const dbPath = findDbPath(customDbPath); - const repoRoot = path.resolve(path.dirname(dbPath), '..'); - - if (!findGitRoot(repoRoot)) { - return { error: `Not a git repository: ${repoRoot}` }; - } - - const gitResult = runGitDiff(repoRoot, opts); - if (gitResult.error) return { error: gitResult.error }; - - if (!gitResult.output.trim()) { - return { - changedFiles: 0, - newFiles: [], - affectedFunctions: [], - affectedFiles: [], - summary: null, - }; - } - - const { changedRanges, newFiles } = parseGitDiff(gitResult.output); - - if (changedRanges.size === 0) { - return { - changedFiles: 0, - newFiles: [], - affectedFunctions: [], - affectedFiles: [], - summary: null, - }; - } - - const affectedFunctions = findAffectedFunctions(db, changedRanges, noTests); - const includeImplementors = opts.includeImplementors !== false; - const { functionResults, allAffected } = buildFunctionImpactResults( - db, - affectedFunctions, - noTests, - maxDepth, - includeImplementors, - ); - - const affectedFiles = new Set(); - for (const key of allAffected) affectedFiles.add(key.split(':')[0]); - - const historicallyCoupled = lookupCoChanges(db, changedRanges, affectedFiles, noTests); - const ownership = lookupOwnership(changedRanges, affectedFiles, repoRoot); - const { boundaryViolations, boundaryViolationCount } = checkBoundaryViolations( - db, - changedRanges, - noTests, - opts, - repoRoot, - ); - - const base = { - changedFiles: changedRanges.size, - newFiles: [...newFiles], - affectedFunctions: functionResults, - affectedFiles: [...affectedFiles], - historicallyCoupled, - ownership, - boundaryViolations, - boundaryViolationCount, - summary: { - functionsChanged: affectedFunctions.length, - callersAffected: allAffected.size, - filesAffected: affectedFiles.size, - historicallyCoupledCount: historicallyCoupled.length, - ownersAffected: ownership ? ownership.affectedOwners.length : 0, - boundaryViolationCount, - }, - }; - return paginateResult(base, 'affectedFunctions', { limit: opts.limit, offset: opts.offset }); - } finally { - db.close(); - } -} - -export function diffImpactMermaid(customDbPath, opts = {}) { - const data = diffImpactData(customDbPath, opts); - if (data.error) return data.error; - if (data.changedFiles === 0 || data.affectedFunctions.length === 0) { - return 'flowchart TB\n none["No impacted functions detected"]'; - } - - const newFileSet = new Set(data.newFiles || []); - const lines = ['flowchart TB']; - - // Assign stable Mermaid node IDs - let nodeCounter = 0; - const nodeIdMap = new Map(); - const nodeLabels = new Map(); - function nodeId(key, label) { - if (!nodeIdMap.has(key)) { - nodeIdMap.set(key, `n${nodeCounter++}`); - if (label) nodeLabels.set(key, label); - } - return nodeIdMap.get(key); - } - - // Register all nodes (changed functions + their callers) - for (const fn of data.affectedFunctions) { - nodeId(`${fn.file}::${fn.name}:${fn.line}`, fn.name); - for (const callers of Object.values(fn.levels || {})) { - for (const c of callers) { - nodeId(`${c.file}::${c.name}:${c.line}`, c.name); - } - } - } - - // Collect all edges and determine blast radius - const allEdges = new Set(); - const edgeFromNodes = new Set(); - const edgeToNodes = new Set(); - const changedKeys = new Set(); - - for (const fn of data.affectedFunctions) { - changedKeys.add(`${fn.file}::${fn.name}:${fn.line}`); - for (const edge of fn.edges || []) { - const edgeKey = `${edge.from}|${edge.to}`; - if (!allEdges.has(edgeKey)) { - allEdges.add(edgeKey); - edgeFromNodes.add(edge.from); - edgeToNodes.add(edge.to); - } - } - } - - // Blast radius: caller nodes that are never a source (leaf nodes of the impact tree) - const blastRadiusKeys = new Set(); - for (const key of edgeToNodes) { - if (!edgeFromNodes.has(key) && !changedKeys.has(key)) { - blastRadiusKeys.add(key); - } - } - - // Intermediate callers: not changed, not blast radius - const intermediateKeys = new Set(); - for (const key of edgeToNodes) { - if (!changedKeys.has(key) && !blastRadiusKeys.has(key)) { - intermediateKeys.add(key); - } - } - - // Group changed functions by file - const fileGroups = new Map(); - for (const fn of data.affectedFunctions) { - if (!fileGroups.has(fn.file)) fileGroups.set(fn.file, []); - fileGroups.get(fn.file).push(fn); - } - - // Emit changed-file subgraphs - let sgCounter = 0; - for (const [file, fns] of fileGroups) { - const isNew = newFileSet.has(file); - const tag = isNew ? 'new' : 'modified'; - const sgId = `sg${sgCounter++}`; - lines.push(` subgraph ${sgId}["${file} **(${tag})**"]`); - for (const fn of fns) { - const key = `${fn.file}::${fn.name}:${fn.line}`; - lines.push(` ${nodeIdMap.get(key)}["${fn.name}"]`); - } - lines.push(' end'); - const style = isNew ? 'fill:#e8f5e9,stroke:#4caf50' : 'fill:#fff3e0,stroke:#ff9800'; - lines.push(` style ${sgId} ${style}`); - } - - // Emit intermediate caller nodes (outside subgraphs) - for (const key of intermediateKeys) { - lines.push(` ${nodeIdMap.get(key)}["${nodeLabels.get(key)}"]`); - } - - // Emit blast radius subgraph - if (blastRadiusKeys.size > 0) { - const sgId = `sg${sgCounter++}`; - lines.push(` subgraph ${sgId}["Callers **(blast radius)**"]`); - for (const key of blastRadiusKeys) { - lines.push(` ${nodeIdMap.get(key)}["${nodeLabels.get(key)}"]`); - } - lines.push(' end'); - lines.push(` style ${sgId} fill:#f3e5f5,stroke:#9c27b0`); - } - - // Emit edges (impact flows from changed fn toward callers) - for (const edgeKey of allEdges) { - const [from, to] = edgeKey.split('|'); - lines.push(` ${nodeIdMap.get(from)} --> ${nodeIdMap.get(to)}`); - } - - return lines.join('\n'); -} diff --git a/src/domain/analysis/implementations.js b/src/domain/analysis/implementations.js deleted file mode 100644 index 487f1948..00000000 --- a/src/domain/analysis/implementations.js +++ /dev/null @@ -1,98 +0,0 @@ -import { findImplementors, findInterfaces, openReadonlyOrFail } from '../../db/index.js'; -import { isTestFile } from '../../infrastructure/test-filter.js'; -import { CORE_SYMBOL_KINDS } from '../../shared/kinds.js'; -import { normalizeSymbol } from '../../shared/normalize.js'; -import { paginateResult } from '../../shared/paginate.js'; -import { findMatchingNodes } from './symbol-lookup.js'; - -/** - * Find all concrete types implementing a given interface/trait. - * - * @param {string} name - Interface/trait name (partial match) - * @param {string|undefined} customDbPath - * @param {{ noTests?: boolean, file?: string, kind?: string, limit?: number, offset?: number }} opts - * @returns {{ name: string, results: Array<{ name: string, kind: string, file: string, line: number, implementors: Array<{ name: string, kind: string, file: string, line: number }> }> }} - */ -export function implementationsData(name, customDbPath, opts = {}) { - const db = openReadonlyOrFail(customDbPath); - try { - const noTests = opts.noTests || false; - const hc = new Map(); - - const nodes = findMatchingNodes(db, name, { - noTests, - file: opts.file, - kind: opts.kind, - kinds: opts.kind ? undefined : CORE_SYMBOL_KINDS, - }); - if (nodes.length === 0) { - return { name, results: [] }; - } - - const results = nodes.map((node) => { - let implementors = findImplementors(db, node.id); - if (noTests) implementors = implementors.filter((n) => !isTestFile(n.file)); - - return { - ...normalizeSymbol(node, db, hc), - implementors: implementors.map((impl) => ({ - name: impl.name, - kind: impl.kind, - file: impl.file, - line: impl.line, - })), - }; - }); - - const base = { name, results }; - return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset }); - } finally { - db.close(); - } -} - -/** - * Find all interfaces/traits that a given class/struct implements. - * - * @param {string} name - Class/struct name (partial match) - * @param {string|undefined} customDbPath - * @param {{ noTests?: boolean, file?: string, kind?: string, limit?: number, offset?: number }} opts - * @returns {{ name: string, results: Array<{ name: string, kind: string, file: string, line: number, interfaces: Array<{ name: string, kind: string, file: string, line: number }> }> }} - */ -export function interfacesData(name, customDbPath, opts = {}) { - const db = openReadonlyOrFail(customDbPath); - try { - const noTests = opts.noTests || false; - const hc = new Map(); - - const nodes = findMatchingNodes(db, name, { - noTests, - file: opts.file, - kind: opts.kind, - kinds: opts.kind ? undefined : CORE_SYMBOL_KINDS, - }); - if (nodes.length === 0) { - return { name, results: [] }; - } - - const results = nodes.map((node) => { - let interfaces = findInterfaces(db, node.id); - if (noTests) interfaces = interfaces.filter((n) => !isTestFile(n.file)); - - return { - ...normalizeSymbol(node, db, hc), - interfaces: interfaces.map((iface) => ({ - name: iface.name, - kind: iface.kind, - file: iface.file, - line: iface.line, - })), - }; - }); - - const base = { name, results }; - return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset }); - } finally { - db.close(); - } -} diff --git a/src/domain/analysis/module-map.js b/src/domain/analysis/module-map.js deleted file mode 100644 index d3566c8c..00000000 --- a/src/domain/analysis/module-map.js +++ /dev/null @@ -1,357 +0,0 @@ -import path from 'node:path'; -import { openReadonlyOrFail, testFilterSQL } from '../../db/index.js'; -import { loadConfig } from '../../infrastructure/config.js'; -import { debug } from '../../infrastructure/logger.js'; -import { isTestFile } from '../../infrastructure/test-filter.js'; -import { DEAD_ROLE_PREFIX } from '../../shared/kinds.js'; -import { findCycles } from '../graph/cycles.js'; -import { LANGUAGE_REGISTRY } from '../parser.js'; - -export const FALSE_POSITIVE_NAMES = new Set([ - 'run', - 'get', - 'set', - 'init', - 'start', - 'handle', - 'main', - 'new', - 'create', - 'update', - 'delete', - 'process', - 'execute', - 'call', - 'apply', - 'setup', - 'render', - 'build', - 'load', - 'save', - 'find', - 'make', - 'open', - 'close', - 'reset', - 'send', - 'read', - 'write', -]); -export const FALSE_POSITIVE_CALLER_THRESHOLD = 20; - -// --------------------------------------------------------------------------- -// Section helpers -// --------------------------------------------------------------------------- - -function buildTestFileIds(db) { - const allFileNodes = db.prepare("SELECT id, file FROM nodes WHERE kind = 'file'").all(); - const testFileIds = new Set(); - const testFiles = new Set(); - for (const n of allFileNodes) { - if (isTestFile(n.file)) { - testFileIds.add(n.id); - testFiles.add(n.file); - } - } - const allNodes = db.prepare('SELECT id, file FROM nodes').all(); - for (const n of allNodes) { - if (testFiles.has(n.file)) testFileIds.add(n.id); - } - return testFileIds; -} - -function countNodesByKind(db, testFileIds) { - let nodeRows; - if (testFileIds) { - const allNodes = db.prepare('SELECT id, kind, file FROM nodes').all(); - const filtered = allNodes.filter((n) => !testFileIds.has(n.id)); - const counts = {}; - for (const n of filtered) counts[n.kind] = (counts[n.kind] || 0) + 1; - nodeRows = Object.entries(counts).map(([kind, c]) => ({ kind, c })); - } else { - nodeRows = db.prepare('SELECT kind, COUNT(*) as c FROM nodes GROUP BY kind').all(); - } - const byKind = {}; - let total = 0; - for (const r of nodeRows) { - byKind[r.kind] = r.c; - total += r.c; - } - return { total, byKind }; -} - -function countEdgesByKind(db, testFileIds) { - let edgeRows; - if (testFileIds) { - const allEdges = db.prepare('SELECT source_id, target_id, kind FROM edges').all(); - const filtered = allEdges.filter( - (e) => !testFileIds.has(e.source_id) && !testFileIds.has(e.target_id), - ); - const counts = {}; - for (const e of filtered) counts[e.kind] = (counts[e.kind] || 0) + 1; - edgeRows = Object.entries(counts).map(([kind, c]) => ({ kind, c })); - } else { - edgeRows = db.prepare('SELECT kind, COUNT(*) as c FROM edges GROUP BY kind').all(); - } - const byKind = {}; - let total = 0; - for (const r of edgeRows) { - byKind[r.kind] = r.c; - total += r.c; - } - return { total, byKind }; -} - -function countFilesByLanguage(db, noTests) { - const extToLang = new Map(); - for (const entry of LANGUAGE_REGISTRY) { - for (const ext of entry.extensions) { - extToLang.set(ext, entry.id); - } - } - let fileNodes = db.prepare("SELECT file FROM nodes WHERE kind = 'file'").all(); - if (noTests) fileNodes = fileNodes.filter((n) => !isTestFile(n.file)); - const byLanguage = {}; - for (const row of fileNodes) { - const ext = path.extname(row.file).toLowerCase(); - const lang = extToLang.get(ext) || 'other'; - byLanguage[lang] = (byLanguage[lang] || 0) + 1; - } - return { total: fileNodes.length, languages: Object.keys(byLanguage).length, byLanguage }; -} - -function findHotspots(db, noTests, limit) { - const testFilter = testFilterSQL('n.file', noTests); - const hotspotRows = db - .prepare(` - SELECT n.file, - (SELECT COUNT(*) FROM edges WHERE target_id = n.id) as fan_in, - (SELECT COUNT(*) FROM edges WHERE source_id = n.id) as fan_out - FROM nodes n - WHERE n.kind = 'file' ${testFilter} - ORDER BY (SELECT COUNT(*) FROM edges WHERE target_id = n.id) - + (SELECT COUNT(*) FROM edges WHERE source_id = n.id) DESC - `) - .all(); - const filtered = noTests ? hotspotRows.filter((r) => !isTestFile(r.file)) : hotspotRows; - return filtered.slice(0, limit).map((r) => ({ - file: r.file, - fanIn: r.fan_in, - fanOut: r.fan_out, - })); -} - -function getEmbeddingsInfo(db) { - try { - const count = db.prepare('SELECT COUNT(*) as c FROM embeddings').get(); - if (count && count.c > 0) { - const meta = {}; - const metaRows = db.prepare('SELECT key, value FROM embedding_meta').all(); - for (const r of metaRows) meta[r.key] = r.value; - return { - count: count.c, - model: meta.model || null, - dim: meta.dim ? parseInt(meta.dim, 10) : null, - builtAt: meta.built_at || null, - }; - } - } catch (e) { - debug(`embeddings lookup skipped: ${e.message}`); - } - return null; -} - -function computeQualityMetrics(db, testFilter, fpThreshold = FALSE_POSITIVE_CALLER_THRESHOLD) { - const qualityTestFilter = testFilter.replace(/n\.file/g, 'file'); - - const totalCallable = db - .prepare( - `SELECT COUNT(*) as c FROM nodes WHERE kind IN ('function', 'method') ${qualityTestFilter}`, - ) - .get().c; - const callableWithCallers = db - .prepare(` - SELECT COUNT(DISTINCT e.target_id) as c FROM edges e - JOIN nodes n ON e.target_id = n.id - WHERE e.kind = 'calls' AND n.kind IN ('function', 'method') ${testFilter} - `) - .get().c; - const callerCoverage = totalCallable > 0 ? callableWithCallers / totalCallable : 0; - - const totalCallEdges = db.prepare("SELECT COUNT(*) as c FROM edges WHERE kind = 'calls'").get().c; - const highConfCallEdges = db - .prepare("SELECT COUNT(*) as c FROM edges WHERE kind = 'calls' AND confidence >= 0.7") - .get().c; - const callConfidence = totalCallEdges > 0 ? highConfCallEdges / totalCallEdges : 0; - - const fpRows = db - .prepare(` - SELECT n.name, n.file, n.line, COUNT(e.source_id) as caller_count - FROM nodes n - LEFT JOIN edges e ON n.id = e.target_id AND e.kind = 'calls' - WHERE n.kind IN ('function', 'method') - GROUP BY n.id - HAVING caller_count > ? - ORDER BY caller_count DESC - `) - .all(fpThreshold); - const falsePositiveWarnings = fpRows - .filter((r) => - FALSE_POSITIVE_NAMES.has(r.name.includes('.') ? r.name.split('.').pop() : r.name), - ) - .map((r) => ({ name: r.name, file: r.file, line: r.line, callerCount: r.caller_count })); - - let fpEdgeCount = 0; - for (const fp of falsePositiveWarnings) fpEdgeCount += fp.callerCount; - const falsePositiveRatio = totalCallEdges > 0 ? fpEdgeCount / totalCallEdges : 0; - - const score = Math.round( - callerCoverage * 40 + callConfidence * 40 + (1 - falsePositiveRatio) * 20, - ); - - return { - score, - callerCoverage: { - ratio: callerCoverage, - covered: callableWithCallers, - total: totalCallable, - }, - callConfidence: { - ratio: callConfidence, - highConf: highConfCallEdges, - total: totalCallEdges, - }, - falsePositiveWarnings, - }; -} - -function countRoles(db, noTests) { - let roleRows; - if (noTests) { - const allRoleNodes = db.prepare('SELECT role, file FROM nodes WHERE role IS NOT NULL').all(); - const filtered = allRoleNodes.filter((n) => !isTestFile(n.file)); - const counts = {}; - for (const n of filtered) counts[n.role] = (counts[n.role] || 0) + 1; - roleRows = Object.entries(counts).map(([role, c]) => ({ role, c })); - } else { - roleRows = db - .prepare('SELECT role, COUNT(*) as c FROM nodes WHERE role IS NOT NULL GROUP BY role') - .all(); - } - const roles = {}; - let deadTotal = 0; - for (const r of roleRows) { - roles[r.role] = r.c; - if (r.role.startsWith(DEAD_ROLE_PREFIX)) deadTotal += r.c; - } - if (deadTotal > 0) roles.dead = deadTotal; - return roles; -} - -function getComplexitySummary(db, testFilter) { - try { - const cRows = db - .prepare( - `SELECT fc.cognitive, fc.cyclomatic, fc.max_nesting, fc.maintainability_index - FROM function_complexity fc JOIN nodes n ON fc.node_id = n.id - WHERE n.kind IN ('function','method') ${testFilter}`, - ) - .all(); - if (cRows.length > 0) { - const miValues = cRows.map((r) => r.maintainability_index || 0); - return { - analyzed: cRows.length, - avgCognitive: +(cRows.reduce((s, r) => s + r.cognitive, 0) / cRows.length).toFixed(1), - avgCyclomatic: +(cRows.reduce((s, r) => s + r.cyclomatic, 0) / cRows.length).toFixed(1), - maxCognitive: Math.max(...cRows.map((r) => r.cognitive)), - maxCyclomatic: Math.max(...cRows.map((r) => r.cyclomatic)), - avgMI: +(miValues.reduce((s, v) => s + v, 0) / miValues.length).toFixed(1), - minMI: +Math.min(...miValues).toFixed(1), - }; - } - } catch (e) { - debug(`complexity summary skipped: ${e.message}`); - } - return null; -} - -// --------------------------------------------------------------------------- -// Public API -// --------------------------------------------------------------------------- - -export function moduleMapData(customDbPath, limit = 20, opts = {}) { - const db = openReadonlyOrFail(customDbPath); - try { - const noTests = opts.noTests || false; - - const testFilter = testFilterSQL('n.file', noTests); - - const nodes = db - .prepare(` - SELECT n.*, - (SELECT COUNT(*) FROM edges WHERE source_id = n.id AND kind NOT IN ('contains', 'parameter_of', 'receiver')) as out_edges, - (SELECT COUNT(*) FROM edges WHERE target_id = n.id AND kind NOT IN ('contains', 'parameter_of', 'receiver')) as in_edges - FROM nodes n - WHERE n.kind = 'file' - ${testFilter} - ORDER BY (SELECT COUNT(*) FROM edges WHERE target_id = n.id AND kind NOT IN ('contains', 'parameter_of', 'receiver')) DESC - LIMIT ? - `) - .all(limit); - - const topNodes = nodes.map((n) => ({ - file: n.file, - dir: path.dirname(n.file) || '.', - inEdges: n.in_edges, - outEdges: n.out_edges, - coupling: n.in_edges + n.out_edges, - })); - - const totalNodes = db.prepare('SELECT COUNT(*) as c FROM nodes').get().c; - const totalEdges = db.prepare('SELECT COUNT(*) as c FROM edges').get().c; - const totalFiles = db.prepare("SELECT COUNT(*) as c FROM nodes WHERE kind = 'file'").get().c; - - return { limit, topNodes, stats: { totalFiles, totalNodes, totalEdges } }; - } finally { - db.close(); - } -} - -export function statsData(customDbPath, opts = {}) { - const db = openReadonlyOrFail(customDbPath); - try { - const noTests = opts.noTests || false; - const config = opts.config || loadConfig(); - const testFilter = testFilterSQL('n.file', noTests); - - const testFileIds = noTests ? buildTestFileIds(db) : null; - - const { total: totalNodes, byKind: nodesByKind } = countNodesByKind(db, testFileIds); - const { total: totalEdges, byKind: edgesByKind } = countEdgesByKind(db, testFileIds); - const files = countFilesByLanguage(db, noTests); - - const fileCycles = findCycles(db, { fileLevel: true, noTests }); - const fnCycles = findCycles(db, { fileLevel: false, noTests }); - - const hotspots = findHotspots(db, noTests, 5); - const embeddings = getEmbeddingsInfo(db); - const fpThreshold = config.analysis?.falsePositiveCallers ?? FALSE_POSITIVE_CALLER_THRESHOLD; - const quality = computeQualityMetrics(db, testFilter, fpThreshold); - const roles = countRoles(db, noTests); - const complexity = getComplexitySummary(db, testFilter); - - return { - nodes: { total: totalNodes, byKind: nodesByKind }, - edges: { total: totalEdges, byKind: edgesByKind }, - files, - cycles: { fileLevel: fileCycles.length, functionLevel: fnCycles.length }, - hotspots, - embeddings, - quality, - roles, - complexity, - }; - } finally { - db.close(); - } -} diff --git a/src/domain/analysis/roles.js b/src/domain/analysis/roles.js deleted file mode 100644 index 403f758c..00000000 --- a/src/domain/analysis/roles.js +++ /dev/null @@ -1,54 +0,0 @@ -import { openReadonlyOrFail } from '../../db/index.js'; -import { buildFileConditionSQL } from '../../db/query-builder.js'; -import { isTestFile } from '../../infrastructure/test-filter.js'; -import { DEAD_ROLE_PREFIX } from '../../shared/kinds.js'; -import { normalizeSymbol } from '../../shared/normalize.js'; -import { paginateResult } from '../../shared/paginate.js'; - -export function rolesData(customDbPath, opts = {}) { - const db = openReadonlyOrFail(customDbPath); - try { - const noTests = opts.noTests || false; - const filterRole = opts.role || null; - const conditions = ['role IS NOT NULL']; - const params = []; - - if (filterRole) { - if (filterRole === DEAD_ROLE_PREFIX) { - conditions.push('role LIKE ?'); - params.push(`${DEAD_ROLE_PREFIX}%`); - } else { - conditions.push('role = ?'); - params.push(filterRole); - } - } - { - const fc = buildFileConditionSQL(opts.file, 'file'); - if (fc.sql) { - // Strip leading ' AND ' since we're using conditions array - conditions.push(fc.sql.replace(/^ AND /, '')); - params.push(...fc.params); - } - } - - let rows = db - .prepare( - `SELECT name, kind, file, line, end_line, role FROM nodes WHERE ${conditions.join(' AND ')} ORDER BY role, file, line`, - ) - .all(...params); - - if (noTests) rows = rows.filter((r) => !isTestFile(r.file)); - - const summary = {}; - for (const r of rows) { - summary[r.role] = (summary[r.role] || 0) + 1; - } - - const hc = new Map(); - const symbols = rows.map((r) => normalizeSymbol(r, db, hc)); - const base = { count: symbols.length, summary, symbols }; - return paginateResult(base, 'symbols', { limit: opts.limit, offset: opts.offset }); - } finally { - db.close(); - } -} diff --git a/src/domain/analysis/symbol-lookup.js b/src/domain/analysis/symbol-lookup.js deleted file mode 100644 index ffb5566c..00000000 --- a/src/domain/analysis/symbol-lookup.js +++ /dev/null @@ -1,240 +0,0 @@ -import { - countCrossFileCallers, - findAllIncomingEdges, - findAllOutgoingEdges, - findCallers, - findCrossFileCallTargets, - findFileNodes, - findImportSources, - findImportTargets, - findNodeChildren, - findNodesByFile, - findNodesWithFanIn, - listFunctionNodes, - openReadonlyOrFail, - Repository, -} from '../../db/index.js'; -import { debug } from '../../infrastructure/logger.js'; -import { isTestFile } from '../../infrastructure/test-filter.js'; -import { EVERY_SYMBOL_KIND } from '../../shared/kinds.js'; -import { getFileHash, normalizeSymbol } from '../../shared/normalize.js'; -import { paginateResult } from '../../shared/paginate.js'; - -const FUNCTION_KINDS = ['function', 'method', 'class', 'constant']; - -/** - * 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(dbOrRepo, name, opts = {}) { - const kinds = opts.kind ? [opts.kind] : opts.kinds?.length ? opts.kinds : FUNCTION_KINDS; - - 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; - - const lowerQuery = name.toLowerCase(); - for (const node of nodes) { - const lowerName = node.name.toLowerCase(); - const bareName = lowerName.includes('.') ? lowerName.split('.').pop() : lowerName; - - let matchScore; - if (lowerName === lowerQuery || bareName === lowerQuery) { - matchScore = 100; - } else if (lowerName.startsWith(lowerQuery) || bareName.startsWith(lowerQuery)) { - matchScore = 60; - } else if (lowerName.includes(`.${lowerQuery}`) || lowerName.includes(`${lowerQuery}.`)) { - matchScore = 40; - } else { - matchScore = 10; - } - - const fanInBonus = Math.min(Math.log2(node.fan_in + 1) * 5, 25); - node._relevance = matchScore + fanInBonus; - } - - nodes.sort((a, b) => b._relevance - a._relevance); - return nodes; -} - -export function queryNameData(name, customDbPath, opts = {}) { - const db = openReadonlyOrFail(customDbPath); - try { - const noTests = opts.noTests || false; - let nodes = db.prepare(`SELECT * FROM nodes WHERE name LIKE ?`).all(`%${name}%`); - if (noTests) nodes = nodes.filter((n) => !isTestFile(n.file)); - if (nodes.length === 0) { - return { query: name, results: [] }; - } - - const hc = new Map(); - const results = nodes.map((node) => { - let callees = findAllOutgoingEdges(db, node.id); - - let callers = findAllIncomingEdges(db, node.id); - - if (noTests) { - callees = callees.filter((c) => !isTestFile(c.file)); - callers = callers.filter((c) => !isTestFile(c.file)); - } - - return { - ...normalizeSymbol(node, db, hc), - callees: callees.map((c) => ({ - name: c.name, - kind: c.kind, - file: c.file, - line: c.line, - edgeKind: c.edge_kind, - })), - callers: callers.map((c) => ({ - name: c.name, - kind: c.kind, - file: c.file, - line: c.line, - edgeKind: c.edge_kind, - })), - }; - }); - - const base = { query: name, results }; - return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset }); - } finally { - db.close(); - } -} - -function whereSymbolImpl(db, target, noTests) { - const placeholders = EVERY_SYMBOL_KIND.map(() => '?').join(', '); - let nodes = db - .prepare( - `SELECT * FROM nodes WHERE name LIKE ? AND kind IN (${placeholders}) ORDER BY file, line`, - ) - .all(`%${target}%`, ...EVERY_SYMBOL_KIND); - if (noTests) nodes = nodes.filter((n) => !isTestFile(n.file)); - - const hc = new Map(); - return nodes.map((node) => { - const crossCount = countCrossFileCallers(db, node.id, node.file); - const exported = crossCount > 0; - - let uses = findCallers(db, node.id); - if (noTests) uses = uses.filter((u) => !isTestFile(u.file)); - - return { - ...normalizeSymbol(node, db, hc), - exported, - uses: uses.map((u) => ({ name: u.name, file: u.file, line: u.line })), - }; - }); -} - -function whereFileImpl(db, target) { - const fileNodes = findFileNodes(db, `%${target}%`); - if (fileNodes.length === 0) return []; - - return fileNodes.map((fn) => { - const symbols = findNodesByFile(db, fn.file); - - const imports = findImportTargets(db, fn.id).map((r) => r.file); - - const importedBy = findImportSources(db, fn.id).map((r) => r.file); - - const exportedIds = findCrossFileCallTargets(db, fn.file); - - const exported = symbols.filter((s) => exportedIds.has(s.id)).map((s) => s.name); - - return { - file: fn.file, - fileHash: getFileHash(db, fn.file), - symbols: symbols.map((s) => ({ name: s.name, kind: s.kind, line: s.line })), - imports, - importedBy, - exported, - }; - }); -} - -export function whereData(target, customDbPath, opts = {}) { - const db = openReadonlyOrFail(customDbPath); - try { - const noTests = opts.noTests || false; - const fileMode = opts.file || false; - - const results = fileMode ? whereFileImpl(db, target) : whereSymbolImpl(db, target, noTests); - - const base = { target, mode: fileMode ? 'file' : 'symbol', results }; - return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset }); - } finally { - db.close(); - } -} - -export function listFunctionsData(customDbPath, opts = {}) { - const db = openReadonlyOrFail(customDbPath); - try { - const noTests = opts.noTests || false; - - let rows = listFunctionNodes(db, { file: opts.file, pattern: opts.pattern }); - - if (noTests) rows = rows.filter((r) => !isTestFile(r.file)); - - const hc = new Map(); - const functions = rows.map((r) => normalizeSymbol(r, db, hc)); - const base = { count: functions.length, functions }; - return paginateResult(base, 'functions', { limit: opts.limit, offset: opts.offset }); - } finally { - db.close(); - } -} - -export function childrenData(name, customDbPath, opts = {}) { - const db = openReadonlyOrFail(customDbPath); - try { - const noTests = opts.noTests || false; - - const nodes = findMatchingNodes(db, name, { noTests, file: opts.file, kind: opts.kind }); - if (nodes.length === 0) { - return { name, results: [] }; - } - - const results = nodes.map((node) => { - let children; - try { - children = findNodeChildren(db, node.id); - } catch (e) { - debug(`findNodeChildren failed for node ${node.id}: ${e.message}`); - children = []; - } - if (noTests) children = children.filter((c) => !isTestFile(c.file || node.file)); - return { - name: node.name, - kind: node.kind, - file: node.file, - line: node.line, - scope: node.scope || null, - visibility: node.visibility || null, - qualifiedName: node.qualified_name || null, - children: children.map((c) => ({ - name: c.name, - kind: c.kind, - line: c.line, - endLine: c.end_line || null, - qualifiedName: c.qualified_name || null, - scope: c.scope || null, - visibility: c.visibility || null, - })), - }; - }); - - const base = { name, results }; - return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset }); - } finally { - db.close(); - } -} diff --git a/src/domain/parser.js b/src/domain/parser.js deleted file mode 100644 index 59a4a10c..00000000 --- a/src/domain/parser.js +++ /dev/null @@ -1,626 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { Language, Parser, Query } from 'web-tree-sitter'; -import { debug, warn } from '../infrastructure/logger.js'; -import { getNative, getNativePackageVersion, loadNative } from '../infrastructure/native.js'; - -// Re-export all extractors for backward compatibility -export { - extractCSharpSymbols, - extractGoSymbols, - extractHCLSymbols, - extractJavaSymbols, - extractPHPSymbols, - extractPythonSymbols, - extractRubySymbols, - extractRustSymbols, - extractSymbols, -} from '../extractors/index.js'; - -import { - extractCSharpSymbols, - extractGoSymbols, - extractHCLSymbols, - extractJavaSymbols, - extractPHPSymbols, - extractPythonSymbols, - extractRubySymbols, - extractRustSymbols, - extractSymbols, -} from '../extractors/index.js'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); - -function grammarPath(name) { - return path.join(__dirname, '..', '..', 'grammars', name); -} - -let _initialized = false; - -// Memoized parsers — avoids reloading WASM grammars on every createParsers() call -let _cachedParsers = null; - -// Cached Language objects — WASM-backed, must be .delete()'d explicitly -let _cachedLanguages = null; - -// Query cache for JS/TS/TSX extractors (populated during createParsers) -const _queryCache = new Map(); - -// Shared patterns for all JS/TS/TSX (class_declaration excluded — name type differs) -const COMMON_QUERY_PATTERNS = [ - '(function_declaration name: (identifier) @fn_name) @fn_node', - '(variable_declarator name: (identifier) @varfn_name value: (arrow_function) @varfn_value)', - '(variable_declarator name: (identifier) @varfn_name value: (function_expression) @varfn_value)', - '(method_definition name: (property_identifier) @meth_name) @meth_node', - '(import_statement source: (string) @imp_source) @imp_node', - '(export_statement) @exp_node', - '(call_expression function: (identifier) @callfn_name) @callfn_node', - '(call_expression function: (member_expression) @callmem_fn) @callmem_node', - '(call_expression function: (subscript_expression) @callsub_fn) @callsub_node', - '(expression_statement (assignment_expression left: (member_expression) @assign_left right: (_) @assign_right)) @assign_node', -]; - -// JS: class name is (identifier) -const JS_CLASS_PATTERN = '(class_declaration name: (identifier) @cls_name) @cls_node'; - -// TS/TSX: class name is (type_identifier), plus interface and type alias -const TS_EXTRA_PATTERNS = [ - '(class_declaration name: (type_identifier) @cls_name) @cls_node', - '(interface_declaration name: (type_identifier) @iface_name) @iface_node', - '(type_alias_declaration name: (type_identifier) @type_name) @type_node', -]; - -export async function createParsers() { - if (_cachedParsers) return _cachedParsers; - - if (!_initialized) { - await Parser.init(); - _initialized = true; - } - - const parsers = new Map(); - const languages = new Map(); - for (const entry of LANGUAGE_REGISTRY) { - try { - const lang = await Language.load(grammarPath(entry.grammarFile)); - const parser = new Parser(); - parser.setLanguage(lang); - parsers.set(entry.id, parser); - languages.set(entry.id, lang); - // Compile and cache tree-sitter Query for JS/TS/TSX extractors - if (entry.extractor === extractSymbols && !_queryCache.has(entry.id)) { - const isTS = entry.id === 'typescript' || entry.id === 'tsx'; - const patterns = isTS - ? [...COMMON_QUERY_PATTERNS, ...TS_EXTRA_PATTERNS] - : [...COMMON_QUERY_PATTERNS, JS_CLASS_PATTERN]; - _queryCache.set(entry.id, new Query(lang, patterns.join('\n'))); - } - } catch (e) { - if (entry.required) throw e; - warn( - `${entry.id} parser failed to initialize: ${e.message}. ${entry.id} files will be skipped.`, - ); - parsers.set(entry.id, null); - } - } - _cachedParsers = parsers; - _cachedLanguages = languages; - return parsers; -} - -/** - * Dispose all cached WASM parsers and queries to free WASM linear memory. - * Call this between repeated builds in the same process (e.g. benchmarks) - * to prevent memory accumulation that can cause segfaults. - */ -export function disposeParsers() { - if (_cachedParsers) { - for (const [id, parser] of _cachedParsers) { - if (parser && typeof parser.delete === 'function') { - try { - parser.delete(); - } catch (e) { - debug(`Failed to dispose parser ${id}: ${e.message}`); - } - } - } - _cachedParsers = null; - } - for (const [id, query] of _queryCache) { - if (query && typeof query.delete === 'function') { - try { - query.delete(); - } catch (e) { - debug(`Failed to dispose query ${id}: ${e.message}`); - } - } - } - _queryCache.clear(); - if (_cachedLanguages) { - for (const [id, lang] of _cachedLanguages) { - if (lang && typeof lang.delete === 'function') { - try { - lang.delete(); - } catch (e) { - debug(`Failed to dispose language ${id}: ${e.message}`); - } - } - } - _cachedLanguages = null; - } - _initialized = false; -} - -export function getParser(parsers, filePath) { - const ext = path.extname(filePath); - const entry = _extToLang.get(ext); - if (!entry) return null; - return parsers.get(entry.id) || null; -} - -/** - * Pre-parse files missing `_tree` via WASM so downstream phases (CFG, dataflow) - * don't each need to create parsers and re-parse independently. - * Only parses files whose extension is in SUPPORTED_EXTENSIONS. - * - * @param {Map} fileSymbols - Map - * @param {string} rootDir - absolute project root - */ -export async function ensureWasmTrees(fileSymbols, rootDir) { - // Check if any file needs a tree - let needsParse = false; - for (const [relPath, symbols] of fileSymbols) { - if (!symbols._tree) { - const ext = path.extname(relPath).toLowerCase(); - if (_extToLang.has(ext)) { - needsParse = true; - break; - } - } - } - if (!needsParse) return; - - const parsers = await createParsers(); - - for (const [relPath, symbols] of fileSymbols) { - if (symbols._tree) continue; - const ext = path.extname(relPath).toLowerCase(); - const entry = _extToLang.get(ext); - if (!entry) continue; - const parser = parsers.get(entry.id); - if (!parser) continue; - - const absPath = path.join(rootDir, relPath); - let code; - try { - code = fs.readFileSync(absPath, 'utf-8'); - } catch (e) { - debug(`ensureWasmTrees: cannot read ${relPath}: ${e.message}`); - continue; - } - try { - symbols._tree = parser.parse(code); - symbols._langId = entry.id; - } catch (e) { - debug(`ensureWasmTrees: parse failed for ${relPath}: ${e.message}`); - } - } -} - -/** - * Check whether the required WASM grammar files exist on disk. - */ -export function isWasmAvailable() { - return LANGUAGE_REGISTRY.filter((e) => e.required).every((e) => - fs.existsSync(grammarPath(e.grammarFile)), - ); -} - -// ── Unified API ────────────────────────────────────────────────────────────── - -function resolveEngine(opts = {}) { - const pref = opts.engine || 'auto'; - if (pref === 'wasm') return { name: 'wasm', native: null }; - if (pref === 'native' || pref === 'auto') { - const native = loadNative(); - if (native) return { name: 'native', native }; - if (pref === 'native') { - getNative(); // throws with detailed error + install instructions - } - } - return { name: 'wasm', native: null }; -} - -/** - * Patch native engine output in-place for the few remaining semantic transforms. - * With #[napi(js_name)] on Rust types, most fields already arrive as camelCase. - * This only handles: - * - _lineCount compat for builder.js - * - Backward compat for older native binaries missing js_name annotations - * - dataflow argFlows/mutations bindingType → binding wrapper - */ -function patchNativeResult(r) { - // lineCount: napi(js_name) emits "lineCount"; older binaries may emit "line_count" - r.lineCount = r.lineCount ?? r.line_count ?? null; - r._lineCount = r.lineCount; - - // Backward compat for older binaries missing js_name annotations - if (r.definitions) { - for (const d of r.definitions) { - if (d.endLine === undefined && d.end_line !== undefined) { - d.endLine = d.end_line; - } - } - } - if (r.imports) { - for (const i of r.imports) { - if (i.typeOnly === undefined) i.typeOnly = i.type_only; - if (i.wildcardReexport === undefined) i.wildcardReexport = i.wildcard_reexport; - if (i.pythonImport === undefined) i.pythonImport = i.python_import; - if (i.goImport === undefined) i.goImport = i.go_import; - if (i.rustUse === undefined) i.rustUse = i.rust_use; - if (i.javaImport === undefined) i.javaImport = i.java_import; - if (i.csharpUsing === undefined) i.csharpUsing = i.csharp_using; - if (i.rubyRequire === undefined) i.rubyRequire = i.ruby_require; - if (i.phpUse === undefined) i.phpUse = i.php_use; - if (i.dynamicImport === undefined) i.dynamicImport = i.dynamic_import; - } - } - - // dataflow: wrap bindingType into binding object for argFlows and mutations - if (r.dataflow) { - if (r.dataflow.argFlows) { - for (const f of r.dataflow.argFlows) { - f.binding = f.bindingType ? { type: f.bindingType } : null; - } - } - if (r.dataflow.mutations) { - for (const m of r.dataflow.mutations) { - m.binding = m.bindingType ? { type: m.bindingType } : null; - } - } - } - - return r; -} - -/** - * Declarative registry of all supported languages. - * Adding a new language requires only a new entry here + its extractor function. - */ -export const LANGUAGE_REGISTRY = [ - { - id: 'javascript', - extensions: ['.js', '.jsx', '.mjs', '.cjs'], - grammarFile: 'tree-sitter-javascript.wasm', - extractor: extractSymbols, - required: true, - }, - { - id: 'typescript', - extensions: ['.ts'], - grammarFile: 'tree-sitter-typescript.wasm', - extractor: extractSymbols, - required: true, - }, - { - id: 'tsx', - extensions: ['.tsx'], - grammarFile: 'tree-sitter-tsx.wasm', - extractor: extractSymbols, - required: true, - }, - { - id: 'hcl', - extensions: ['.tf', '.hcl'], - grammarFile: 'tree-sitter-hcl.wasm', - extractor: extractHCLSymbols, - required: false, - }, - { - id: 'python', - extensions: ['.py', '.pyi'], - grammarFile: 'tree-sitter-python.wasm', - extractor: extractPythonSymbols, - required: false, - }, - { - id: 'go', - extensions: ['.go'], - grammarFile: 'tree-sitter-go.wasm', - extractor: extractGoSymbols, - required: false, - }, - { - id: 'rust', - extensions: ['.rs'], - grammarFile: 'tree-sitter-rust.wasm', - extractor: extractRustSymbols, - required: false, - }, - { - id: 'java', - extensions: ['.java'], - grammarFile: 'tree-sitter-java.wasm', - extractor: extractJavaSymbols, - required: false, - }, - { - id: 'csharp', - extensions: ['.cs'], - grammarFile: 'tree-sitter-c_sharp.wasm', - extractor: extractCSharpSymbols, - required: false, - }, - { - id: 'ruby', - extensions: ['.rb', '.rake', '.gemspec'], - grammarFile: 'tree-sitter-ruby.wasm', - extractor: extractRubySymbols, - required: false, - }, - { - id: 'php', - extensions: ['.php', '.phtml'], - grammarFile: 'tree-sitter-php.wasm', - extractor: extractPHPSymbols, - required: false, - }, -]; - -const _extToLang = new Map(); -for (const entry of LANGUAGE_REGISTRY) { - for (const ext of entry.extensions) { - _extToLang.set(ext, entry); - } -} - -export const SUPPORTED_EXTENSIONS = new Set(_extToLang.keys()); - -/** - * WASM-based typeMap backfill for older native binaries that don't emit typeMap. - * Uses tree-sitter AST extraction instead of regex to avoid false positives from - * matches inside comments and string literals. - * TODO: Remove once all published native binaries include typeMap extraction (>= 3.2.0) - */ -async function backfillTypeMap(filePath, source) { - let code = source; - if (!code) { - try { - code = fs.readFileSync(filePath, 'utf-8'); - } catch { - return { typeMap: [], backfilled: false }; - } - } - const parsers = await createParsers(); - const extracted = wasmExtractSymbols(parsers, filePath, code); - try { - if (!extracted?.symbols?.typeMap) { - return { typeMap: [], backfilled: false }; - } - const tm = extracted.symbols.typeMap; - return { - typeMap: tm instanceof Map ? tm : new Map(tm.map((e) => [e.name, e.typeName])), - backfilled: true, - }; - } finally { - // Free the WASM tree to prevent memory accumulation across repeated builds - if (extracted?.tree && typeof extracted.tree.delete === 'function') { - try { - extracted.tree.delete(); - } catch {} - } - } -} - -/** - * WASM extraction helper: picks the right extractor based on file extension. - */ -function wasmExtractSymbols(parsers, filePath, code) { - const parser = getParser(parsers, filePath); - if (!parser) return null; - - let tree; - try { - tree = parser.parse(code); - } catch (e) { - warn(`Parse error in ${filePath}: ${e.message}`); - return null; - } - - const ext = path.extname(filePath); - const entry = _extToLang.get(ext); - if (!entry) return null; - const query = _queryCache.get(entry.id) || null; - const symbols = entry.extractor(tree, filePath, query); - return symbols ? { symbols, tree, langId: entry.id } : null; -} - -/** - * Parse a single file and return normalized symbols. - * - * @param {string} filePath Absolute path to the file. - * @param {string} source Source code string. - * @param {object} [opts] Options: { engine: 'native'|'wasm'|'auto' } - * @returns {Promise<{definitions, calls, imports, classes, exports}|null>} - */ -export async function parseFileAuto(filePath, source, opts = {}) { - const { native } = resolveEngine(opts); - - if (native) { - const result = native.parseFile(filePath, source, !!opts.dataflow, opts.ast !== false); - if (!result) return null; - const patched = patchNativeResult(result); - // Only backfill typeMap for TS/TSX — JS files have no type annotations, - // and the native engine already handles `new Expr()` patterns. - const TS_BACKFILL_EXTS = new Set(['.ts', '.tsx']); - if ( - (!patched.typeMap || patched.typeMap.length === 0) && - TS_BACKFILL_EXTS.has(path.extname(filePath)) - ) { - const { typeMap, backfilled } = await backfillTypeMap(filePath, source); - patched.typeMap = typeMap; - if (backfilled) patched._typeMapBackfilled = true; - } - return patched; - } - - // WASM path - const parsers = await createParsers(); - const extracted = wasmExtractSymbols(parsers, filePath, source); - return extracted ? extracted.symbols : null; -} - -/** - * Parse multiple files in bulk and return a Map. - * - * @param {string[]} filePaths Absolute paths to files. - * @param {string} rootDir Project root for computing relative paths. - * @param {object} [opts] Options: { engine: 'native'|'wasm'|'auto' } - * @returns {Promise>} - */ -export async function parseFilesAuto(filePaths, rootDir, opts = {}) { - const { native } = resolveEngine(opts); - const result = new Map(); - - if (native) { - const nativeResults = native.parseFiles( - filePaths, - rootDir, - !!opts.dataflow, - opts.ast !== false, - ); - const needsTypeMap = []; - for (const r of nativeResults) { - if (!r) continue; - const patched = patchNativeResult(r); - const relPath = path.relative(rootDir, r.file).split(path.sep).join('/'); - result.set(relPath, patched); - if (!patched.typeMap || patched.typeMap.length === 0) { - needsTypeMap.push({ filePath: r.file, relPath }); - } - } - // Backfill typeMap via WASM for native binaries that predate the type-map feature - if (needsTypeMap.length > 0) { - // Only backfill for languages where WASM extraction can produce typeMap - // (TS/TSX have type annotations; JS only has `new Expr()` which native already handles) - const TS_EXTS = new Set(['.ts', '.tsx']); - const tsFiles = needsTypeMap.filter(({ filePath }) => TS_EXTS.has(path.extname(filePath))); - if (tsFiles.length > 0) { - const parsers = await createParsers(); - for (const { filePath, relPath } of tsFiles) { - let extracted; - try { - const code = fs.readFileSync(filePath, 'utf-8'); - extracted = wasmExtractSymbols(parsers, filePath, code); - if (extracted?.symbols?.typeMap) { - const symbols = result.get(relPath); - symbols.typeMap = - extracted.symbols.typeMap instanceof Map - ? extracted.symbols.typeMap - : new Map(extracted.symbols.typeMap.map((e) => [e.name, e.typeName])); - symbols._typeMapBackfilled = true; - } - } catch { - /* skip — typeMap is a best-effort backfill */ - } finally { - // Free the WASM tree to prevent memory accumulation across repeated builds - if (extracted?.tree && typeof extracted.tree.delete === 'function') { - try { - extracted.tree.delete(); - } catch {} - } - } - } - } - } - return result; - } - - // WASM path - const parsers = await createParsers(); - for (const filePath of filePaths) { - let code; - try { - code = fs.readFileSync(filePath, 'utf-8'); - } catch (err) { - warn(`Skipping ${path.relative(rootDir, filePath)}: ${err.message}`); - continue; - } - const extracted = wasmExtractSymbols(parsers, filePath, code); - if (extracted) { - const relPath = path.relative(rootDir, filePath).split(path.sep).join('/'); - extracted.symbols._tree = extracted.tree; - extracted.symbols._langId = extracted.langId; - extracted.symbols._lineCount = code.split('\n').length; - result.set(relPath, extracted.symbols); - } - } - return result; -} - -/** - * Report which engine is active. - * - * @param {object} [opts] Options: { engine: 'native'|'wasm'|'auto' } - * @returns {{ name: 'native'|'wasm', version: string|null }} - */ -export function getActiveEngine(opts = {}) { - const { name, native } = resolveEngine(opts); - let version = native - ? typeof native.engineVersion === 'function' - ? native.engineVersion() - : null - : null; - // Prefer platform package.json version over binary-embedded version - // to handle stale binaries that weren't recompiled during a release - if (native) { - try { - version = getNativePackageVersion() ?? version; - } catch (e) { - debug(`getNativePackageVersion failed: ${e.message}`); - } - } - return { name, version }; -} - -/** - * Create a native ParseTreeCache for incremental parsing. - * Returns null if the native engine is unavailable (WASM fallback). - */ -export function createParseTreeCache() { - const native = loadNative(); - if (!native || !native.ParseTreeCache) return null; - return new native.ParseTreeCache(); -} - -/** - * Parse a file incrementally using the cache, or fall back to full parse. - * - * @param {object|null} cache ParseTreeCache instance (or null for full parse) - * @param {string} filePath Absolute path to the file - * @param {string} source Source code string - * @param {object} [opts] Options forwarded to parseFileAuto on fallback - * @returns {Promise<{definitions, calls, imports, classes, exports}|null>} - */ -export async function parseFileIncremental(cache, filePath, source, opts = {}) { - if (cache) { - const result = cache.parseFile(filePath, source); - if (!result) return null; - const patched = patchNativeResult(result); - // Only backfill typeMap for TS/TSX — JS files have no type annotations, - // and the native engine already handles `new Expr()` patterns. - const TS_BACKFILL_EXTS = new Set(['.ts', '.tsx']); - if ( - (!patched.typeMap || patched.typeMap.length === 0) && - TS_BACKFILL_EXTS.has(path.extname(filePath)) - ) { - const { typeMap, backfilled } = await backfillTypeMap(filePath, source); - patched.typeMap = typeMap; - if (backfilled) patched._typeMapBackfilled = true; - } - return patched; - } - return parseFileAuto(filePath, source, opts); -} From 2d5b3d2d956707c1e3efca885ea7eecd960211c4 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sat, 21 Mar 2026 02:51:16 -0600 Subject: [PATCH 03/18] Revert "chore: remove original .js files replaced by TypeScript conversions" This reverts commit cc65d6397462f58ab938ece2cd42ef7ed1200953. --- src/domain/analysis/brief.js | 161 ++++++ src/domain/analysis/context.js | 446 ++++++++++++++++ src/domain/analysis/dependencies.js | 395 +++++++++++++++ src/domain/analysis/exports.js | 206 ++++++++ src/domain/analysis/impact.js | 675 +++++++++++++++++++++++++ src/domain/analysis/implementations.js | 98 ++++ src/domain/analysis/module-map.js | 357 +++++++++++++ src/domain/analysis/roles.js | 54 ++ src/domain/analysis/symbol-lookup.js | 240 +++++++++ src/domain/parser.js | 626 +++++++++++++++++++++++ 10 files changed, 3258 insertions(+) create mode 100644 src/domain/analysis/brief.js create mode 100644 src/domain/analysis/context.js create mode 100644 src/domain/analysis/dependencies.js create mode 100644 src/domain/analysis/exports.js create mode 100644 src/domain/analysis/impact.js create mode 100644 src/domain/analysis/implementations.js create mode 100644 src/domain/analysis/module-map.js create mode 100644 src/domain/analysis/roles.js create mode 100644 src/domain/analysis/symbol-lookup.js create mode 100644 src/domain/parser.js diff --git a/src/domain/analysis/brief.js b/src/domain/analysis/brief.js new file mode 100644 index 00000000..78c3d342 --- /dev/null +++ b/src/domain/analysis/brief.js @@ -0,0 +1,161 @@ +import { + findDistinctCallers, + findFileNodes, + findImportDependents, + findImportSources, + findImportTargets, + findNodesByFile, + openReadonlyOrFail, +} from '../../db/index.js'; +import { loadConfig } from '../../infrastructure/config.js'; +import { isTestFile } from '../../infrastructure/test-filter.js'; + +/** Symbol kinds meaningful for a file brief — excludes parameters, properties, constants. */ +const BRIEF_KINDS = new Set([ + 'function', + 'method', + 'class', + 'interface', + 'type', + 'struct', + 'enum', + 'trait', + 'record', + 'module', +]); + +/** + * Compute file risk tier from symbol roles and max fan-in. + * @param {{ role: string|null, callerCount: number }[]} symbols + * @returns {'high'|'medium'|'low'} + */ +function computeRiskTier(symbols, highThreshold = 10, mediumThreshold = 3) { + let maxCallers = 0; + let hasCoreRole = false; + for (const s of symbols) { + if (s.callerCount > maxCallers) maxCallers = s.callerCount; + if (s.role === 'core') hasCoreRole = true; + } + if (maxCallers >= highThreshold || hasCoreRole) return 'high'; + if (maxCallers >= mediumThreshold) return 'medium'; + return 'low'; +} + +/** + * BFS to count transitive callers for a single node. + * Lightweight variant — only counts, does not collect details. + */ +function countTransitiveCallers(db, startId, noTests, maxDepth = 5) { + const visited = new Set([startId]); + let frontier = [startId]; + + for (let d = 1; d <= maxDepth; d++) { + const nextFrontier = []; + for (const fid of frontier) { + const callers = findDistinctCallers(db, fid); + for (const c of callers) { + if (!visited.has(c.id) && (!noTests || !isTestFile(c.file))) { + visited.add(c.id); + nextFrontier.push(c.id); + } + } + } + frontier = nextFrontier; + if (frontier.length === 0) break; + } + + return visited.size - 1; +} + +/** + * Count transitive file-level import dependents via BFS. + * Depth-bounded to match countTransitiveCallers and keep hook latency predictable. + */ +function countTransitiveImporters(db, fileNodeIds, noTests, maxDepth = 5) { + const visited = new Set(fileNodeIds); + let frontier = [...fileNodeIds]; + + for (let d = 1; d <= maxDepth; d++) { + const nextFrontier = []; + for (const current of frontier) { + const dependents = findImportDependents(db, current); + for (const dep of dependents) { + if (!visited.has(dep.id) && (!noTests || !isTestFile(dep.file))) { + visited.add(dep.id); + nextFrontier.push(dep.id); + } + } + } + frontier = nextFrontier; + if (frontier.length === 0) break; + } + + return visited.size - fileNodeIds.length; +} + +/** + * Produce a token-efficient file brief: symbols with roles and caller counts, + * importer info with transitive count, and file risk tier. + * + * @param {string} file - File path (partial match) + * @param {string} customDbPath - Path to graph.db + * @param {{ noTests?: boolean }} opts + * @returns {{ file: string, results: object[] }} + */ +export function briefData(file, customDbPath, opts = {}) { + const db = openReadonlyOrFail(customDbPath); + try { + const noTests = opts.noTests || false; + const config = opts.config || loadConfig(); + const callerDepth = config.analysis?.briefCallerDepth ?? 5; + const importerDepth = config.analysis?.briefImporterDepth ?? 5; + const highRiskCallers = config.analysis?.briefHighRiskCallers ?? 10; + const mediumRiskCallers = config.analysis?.briefMediumRiskCallers ?? 3; + const fileNodes = findFileNodes(db, `%${file}%`); + if (fileNodes.length === 0) { + return { file, results: [] }; + } + + const results = fileNodes.map((fn) => { + // Direct importers + let importedBy = findImportSources(db, fn.id); + if (noTests) importedBy = importedBy.filter((i) => !isTestFile(i.file)); + const directImporters = [...new Set(importedBy.map((i) => i.file))]; + + // Transitive importer count + const totalImporterCount = countTransitiveImporters(db, [fn.id], noTests, importerDepth); + + // Direct imports + let importsTo = findImportTargets(db, fn.id); + if (noTests) importsTo = importsTo.filter((i) => !isTestFile(i.file)); + + // Symbol definitions with roles and caller counts + const defs = findNodesByFile(db, fn.file).filter((d) => BRIEF_KINDS.has(d.kind)); + const symbols = defs.map((d) => { + const callerCount = countTransitiveCallers(db, d.id, noTests, callerDepth); + return { + name: d.name, + kind: d.kind, + line: d.line, + role: d.role || null, + callerCount, + }; + }); + + const riskTier = computeRiskTier(symbols, highRiskCallers, mediumRiskCallers); + + return { + file: fn.file, + risk: riskTier, + imports: importsTo.map((i) => i.file), + importedBy: directImporters, + totalImporterCount, + symbols, + }; + }); + + return { file, results }; + } finally { + db.close(); + } +} diff --git a/src/domain/analysis/context.js b/src/domain/analysis/context.js new file mode 100644 index 00000000..1f5f113d --- /dev/null +++ b/src/domain/analysis/context.js @@ -0,0 +1,446 @@ +import path from 'node:path'; +import { + findCallees, + findCallers, + findCrossFileCallTargets, + findDbPath, + findFileNodes, + findImplementors, + findImportSources, + findImportTargets, + findInterfaces, + findIntraFileCallEdges, + findNodeChildren, + findNodesByFile, + getComplexityForNode, + openReadonlyOrFail, +} from '../../db/index.js'; +import { loadConfig } from '../../infrastructure/config.js'; +import { debug } from '../../infrastructure/logger.js'; +import { isTestFile } from '../../infrastructure/test-filter.js'; +import { + createFileLinesReader, + extractSignature, + extractSummary, + isFileLikeTarget, + readSourceRange, +} from '../../shared/file-utils.js'; +import { resolveMethodViaHierarchy } from '../../shared/hierarchy.js'; +import { normalizeSymbol } from '../../shared/normalize.js'; +import { paginateResult } from '../../shared/paginate.js'; +import { findMatchingNodes } from './symbol-lookup.js'; + +function buildCallees(db, node, repoRoot, getFileLines, opts) { + const { noTests, depth, displayOpts } = opts; + const calleeRows = findCallees(db, node.id); + const filteredCallees = noTests ? calleeRows.filter((c) => !isTestFile(c.file)) : calleeRows; + + const callees = filteredCallees.map((c) => { + const cLines = getFileLines(c.file); + const summary = cLines ? extractSummary(cLines, c.line, displayOpts) : null; + let calleeSource = null; + if (depth >= 1) { + calleeSource = readSourceRange(repoRoot, c.file, c.line, c.end_line, displayOpts); + } + return { + name: c.name, + kind: c.kind, + file: c.file, + line: c.line, + endLine: c.end_line || null, + summary, + source: calleeSource, + }; + }); + + if (depth > 1) { + const visited = new Set(filteredCallees.map((c) => c.id)); + visited.add(node.id); + let frontier = filteredCallees.map((c) => c.id); + const maxDepth = Math.min(depth, 5); + for (let d = 2; d <= maxDepth; d++) { + const nextFrontier = []; + for (const fid of frontier) { + const deeper = findCallees(db, fid); + for (const c of deeper) { + if (!visited.has(c.id) && (!noTests || !isTestFile(c.file))) { + visited.add(c.id); + nextFrontier.push(c.id); + const cLines = getFileLines(c.file); + callees.push({ + name: c.name, + kind: c.kind, + file: c.file, + line: c.line, + endLine: c.end_line || null, + summary: cLines ? extractSummary(cLines, c.line, displayOpts) : null, + source: readSourceRange(repoRoot, c.file, c.line, c.end_line, displayOpts), + }); + } + } + } + frontier = nextFrontier; + if (frontier.length === 0) break; + } + } + + return callees; +} + +function buildCallers(db, node, noTests) { + let callerRows = findCallers(db, node.id); + + if (node.kind === 'method' && node.name.includes('.')) { + const methodName = node.name.split('.').pop(); + const relatedMethods = resolveMethodViaHierarchy(db, methodName); + for (const rm of relatedMethods) { + if (rm.id === node.id) continue; + const extraCallers = findCallers(db, rm.id); + callerRows.push(...extraCallers.map((c) => ({ ...c, viaHierarchy: rm.name }))); + } + } + if (noTests) callerRows = callerRows.filter((c) => !isTestFile(c.file)); + + return callerRows.map((c) => ({ + name: c.name, + kind: c.kind, + file: c.file, + line: c.line, + viaHierarchy: c.viaHierarchy || undefined, + })); +} + +const INTERFACE_LIKE_KINDS = new Set(['interface', 'trait']); +const IMPLEMENTOR_KINDS = new Set(['class', 'struct', 'record', 'enum']); + +function buildImplementationInfo(db, node, noTests) { + // For interfaces/traits: show who implements them + if (INTERFACE_LIKE_KINDS.has(node.kind)) { + let impls = findImplementors(db, node.id); + if (noTests) impls = impls.filter((n) => !isTestFile(n.file)); + return { + implementors: impls.map((n) => ({ name: n.name, kind: n.kind, file: n.file, line: n.line })), + }; + } + // For classes/structs: show what they implement + if (IMPLEMENTOR_KINDS.has(node.kind)) { + let ifaces = findInterfaces(db, node.id); + if (noTests) ifaces = ifaces.filter((n) => !isTestFile(n.file)); + if (ifaces.length > 0) { + return { + implements: ifaces.map((n) => ({ name: n.name, kind: n.kind, file: n.file, line: n.line })), + }; + } + } + return {}; +} + +function buildRelatedTests(db, node, getFileLines, includeTests) { + const testCallerRows = findCallers(db, node.id); + const testCallers = testCallerRows.filter((c) => isTestFile(c.file)); + + const testsByFile = new Map(); + for (const tc of testCallers) { + if (!testsByFile.has(tc.file)) testsByFile.set(tc.file, []); + testsByFile.get(tc.file).push(tc); + } + + const relatedTests = []; + for (const [file] of testsByFile) { + const tLines = getFileLines(file); + const testNames = []; + if (tLines) { + for (const tl of tLines) { + const tm = tl.match(/(?:it|test|describe)\s*\(\s*['"`]([^'"`]+)['"`]/); + if (tm) testNames.push(tm[1]); + } + } + const testSource = includeTests && tLines ? tLines.join('\n') : undefined; + relatedTests.push({ + file, + testCount: testNames.length, + testNames, + source: testSource, + }); + } + + return relatedTests; +} + +function getComplexityMetrics(db, nodeId) { + try { + const cRow = getComplexityForNode(db, nodeId); + if (!cRow) return null; + return { + cognitive: cRow.cognitive, + cyclomatic: cRow.cyclomatic, + maxNesting: cRow.max_nesting, + maintainabilityIndex: cRow.maintainability_index || 0, + halsteadVolume: cRow.halstead_volume || 0, + }; + } catch (e) { + debug(`complexity lookup failed for node ${nodeId}: ${e.message}`); + return null; + } +} + +function getNodeChildrenSafe(db, nodeId) { + try { + return findNodeChildren(db, nodeId).map((c) => ({ + name: c.name, + kind: c.kind, + line: c.line, + endLine: c.end_line || null, + })); + } catch (e) { + debug(`findNodeChildren failed for node ${nodeId}: ${e.message}`); + return []; + } +} + +function explainFileImpl(db, target, getFileLines, displayOpts) { + const fileNodes = findFileNodes(db, `%${target}%`); + if (fileNodes.length === 0) return []; + + return fileNodes.map((fn) => { + const symbols = findNodesByFile(db, fn.file); + + // IDs of symbols that have incoming calls from other files (public) + const publicIds = findCrossFileCallTargets(db, fn.file); + + const fileLines = getFileLines(fn.file); + const mapSymbol = (s) => ({ + name: s.name, + kind: s.kind, + line: s.line, + role: s.role || null, + summary: fileLines ? extractSummary(fileLines, s.line, displayOpts) : null, + signature: fileLines ? extractSignature(fileLines, s.line, displayOpts) : null, + }); + + const publicApi = symbols.filter((s) => publicIds.has(s.id)).map(mapSymbol); + const internal = symbols.filter((s) => !publicIds.has(s.id)).map(mapSymbol); + + const imports = findImportTargets(db, fn.id).map((r) => ({ file: r.file })); + const importedBy = findImportSources(db, fn.id).map((r) => ({ file: r.file })); + + const intraEdges = findIntraFileCallEdges(db, fn.file); + const dataFlowMap = new Map(); + for (const edge of intraEdges) { + if (!dataFlowMap.has(edge.caller_name)) dataFlowMap.set(edge.caller_name, []); + dataFlowMap.get(edge.caller_name).push(edge.callee_name); + } + const dataFlow = [...dataFlowMap.entries()].map(([caller, callees]) => ({ + caller, + callees, + })); + + const metric = db + .prepare(`SELECT nm.line_count FROM node_metrics nm WHERE nm.node_id = ?`) + .get(fn.id); + let lineCount = metric?.line_count || null; + if (!lineCount) { + const maxLine = db + .prepare(`SELECT MAX(end_line) as max_end FROM nodes WHERE file = ?`) + .get(fn.file); + lineCount = maxLine?.max_end || null; + } + + return { + file: fn.file, + lineCount, + symbolCount: symbols.length, + publicApi, + internal, + imports, + importedBy, + dataFlow, + }; + }); +} + +function explainFunctionImpl(db, target, noTests, getFileLines, displayOpts) { + let nodes = db + .prepare( + `SELECT * FROM nodes WHERE name LIKE ? AND kind IN ('function','method','class','interface','type','struct','enum','trait','record','module','constant') ORDER BY file, line`, + ) + .all(`%${target}%`); + if (noTests) nodes = nodes.filter((n) => !isTestFile(n.file)); + if (nodes.length === 0) return []; + + const hc = new Map(); + return nodes.slice(0, 10).map((node) => { + const fileLines = getFileLines(node.file); + const lineCount = node.end_line ? node.end_line - node.line + 1 : null; + const summary = fileLines ? extractSummary(fileLines, node.line, displayOpts) : null; + const signature = fileLines ? extractSignature(fileLines, node.line, displayOpts) : null; + + const callees = findCallees(db, node.id).map((c) => ({ + name: c.name, + kind: c.kind, + file: c.file, + line: c.line, + })); + + let callers = findCallers(db, node.id).map((c) => ({ + name: c.name, + kind: c.kind, + file: c.file, + line: c.line, + })); + if (noTests) callers = callers.filter((c) => !isTestFile(c.file)); + + const testCallerRows = findCallers(db, node.id); + const seenFiles = new Set(); + const relatedTests = testCallerRows + .filter((r) => isTestFile(r.file) && !seenFiles.has(r.file) && seenFiles.add(r.file)) + .map((r) => ({ file: r.file })); + + return { + ...normalizeSymbol(node, db, hc), + lineCount, + summary, + signature, + complexity: getComplexityMetrics(db, node.id), + callees, + callers, + relatedTests, + }; + }); +} + +function explainCallees( + parentResults, + currentDepth, + visited, + db, + noTests, + getFileLines, + displayOpts, +) { + if (currentDepth <= 0) return; + for (const r of parentResults) { + const newCallees = []; + for (const callee of r.callees) { + const key = `${callee.name}:${callee.file}:${callee.line}`; + if (visited.has(key)) continue; + visited.add(key); + const calleeResults = explainFunctionImpl( + db, + callee.name, + noTests, + getFileLines, + displayOpts, + ); + const exact = calleeResults.find((cr) => cr.file === callee.file && cr.line === callee.line); + if (exact) { + exact._depth = (r._depth || 0) + 1; + newCallees.push(exact); + } + } + if (newCallees.length > 0) { + r.depDetails = newCallees; + explainCallees(newCallees, currentDepth - 1, visited, db, noTests, getFileLines, displayOpts); + } + } +} + +// ─── Exported functions ────────────────────────────────────────────────── + +export function contextData(name, customDbPath, opts = {}) { + const db = openReadonlyOrFail(customDbPath); + try { + const depth = opts.depth || 0; + const noSource = opts.noSource || false; + const noTests = opts.noTests || false; + const includeTests = opts.includeTests || false; + + const config = opts.config || loadConfig(); + const displayOpts = config.display || {}; + + const dbPath = findDbPath(customDbPath); + const repoRoot = path.resolve(path.dirname(dbPath), '..'); + + const nodes = findMatchingNodes(db, name, { noTests, file: opts.file, kind: opts.kind }); + if (nodes.length === 0) { + return { name, results: [] }; + } + + const getFileLines = createFileLinesReader(repoRoot); + + const results = nodes.map((node) => { + const fileLines = getFileLines(node.file); + + const source = noSource + ? null + : readSourceRange(repoRoot, node.file, node.line, node.end_line, displayOpts); + + const signature = fileLines ? extractSignature(fileLines, node.line, displayOpts) : null; + + const callees = buildCallees(db, node, repoRoot, getFileLines, { + noTests, + depth, + displayOpts, + }); + const callers = buildCallers(db, node, noTests); + const relatedTests = buildRelatedTests(db, node, getFileLines, includeTests); + const complexityMetrics = getComplexityMetrics(db, node.id); + const nodeChildren = getNodeChildrenSafe(db, node.id); + const implInfo = buildImplementationInfo(db, node, noTests); + + return { + name: node.name, + kind: node.kind, + file: node.file, + line: node.line, + role: node.role || null, + endLine: node.end_line || null, + source, + signature, + complexity: complexityMetrics, + children: nodeChildren.length > 0 ? nodeChildren : undefined, + callees, + callers, + relatedTests, + ...implInfo, + }; + }); + + const base = { name, results }; + return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset }); + } finally { + db.close(); + } +} + +export function explainData(target, customDbPath, opts = {}) { + const db = openReadonlyOrFail(customDbPath); + try { + const noTests = opts.noTests || false; + const depth = opts.depth || 0; + const kind = isFileLikeTarget(target) ? 'file' : 'function'; + + const config = opts.config || loadConfig(); + const displayOpts = config.display || {}; + + const dbPath = findDbPath(customDbPath); + const repoRoot = path.resolve(path.dirname(dbPath), '..'); + + const getFileLines = createFileLinesReader(repoRoot); + + const results = + kind === 'file' + ? explainFileImpl(db, target, getFileLines, displayOpts) + : explainFunctionImpl(db, target, noTests, getFileLines, displayOpts); + + if (kind === 'function' && depth > 0 && results.length > 0) { + const visited = new Set(results.map((r) => `${r.name}:${r.file}:${r.line}`)); + explainCallees(results, depth, visited, db, noTests, getFileLines, displayOpts); + } + + const base = { target, kind, results }; + return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset }); + } finally { + db.close(); + } +} diff --git a/src/domain/analysis/dependencies.js b/src/domain/analysis/dependencies.js new file mode 100644 index 00000000..867cd5bd --- /dev/null +++ b/src/domain/analysis/dependencies.js @@ -0,0 +1,395 @@ +import { + findCallees, + findCallers, + findFileNodes, + findImportSources, + findImportTargets, + findNodesByFile, + openReadonlyOrFail, +} from '../../db/index.js'; +import { isTestFile } from '../../infrastructure/test-filter.js'; +import { resolveMethodViaHierarchy } from '../../shared/hierarchy.js'; +import { normalizeSymbol } from '../../shared/normalize.js'; +import { paginateResult } from '../../shared/paginate.js'; +import { findMatchingNodes } from './symbol-lookup.js'; + +export function fileDepsData(file, customDbPath, opts = {}) { + const db = openReadonlyOrFail(customDbPath); + try { + const noTests = opts.noTests || false; + const fileNodes = findFileNodes(db, `%${file}%`); + if (fileNodes.length === 0) { + return { file, results: [] }; + } + + const results = fileNodes.map((fn) => { + let importsTo = findImportTargets(db, fn.id); + if (noTests) importsTo = importsTo.filter((i) => !isTestFile(i.file)); + + let importedBy = findImportSources(db, fn.id); + if (noTests) importedBy = importedBy.filter((i) => !isTestFile(i.file)); + + const defs = findNodesByFile(db, fn.file); + + return { + file: fn.file, + imports: importsTo.map((i) => ({ file: i.file, typeOnly: i.edge_kind === 'imports-type' })), + importedBy: importedBy.map((i) => ({ file: i.file })), + definitions: defs.map((d) => ({ name: d.name, kind: d.kind, line: d.line })), + }; + }); + + const base = { file, results }; + return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset }); + } finally { + db.close(); + } +} + +/** + * BFS transitive caller traversal starting from `callers` of `nodeId`. + * Returns an object keyed by depth (2..depth) → array of caller descriptors. + */ +function buildTransitiveCallers(db, callers, nodeId, depth, noTests) { + const transitiveCallers = {}; + if (depth <= 1) return transitiveCallers; + + const visited = new Set([nodeId]); + let frontier = callers + .map((c) => { + const row = db + .prepare('SELECT id FROM nodes WHERE name = ? AND kind = ? AND file = ? AND line = ?') + .get(c.name, c.kind, c.file, c.line); + return row ? { ...c, id: row.id } : null; + }) + .filter(Boolean); + + for (let d = 2; d <= depth; d++) { + const nextFrontier = []; + for (const f of frontier) { + if (visited.has(f.id)) continue; + visited.add(f.id); + const upstream = db + .prepare(` + SELECT n.name, n.kind, n.file, n.line + FROM edges e JOIN nodes n ON e.source_id = n.id + WHERE e.target_id = ? AND e.kind = 'calls' + `) + .all(f.id); + for (const u of upstream) { + if (noTests && isTestFile(u.file)) continue; + const uid = db + .prepare('SELECT id FROM nodes WHERE name = ? AND kind = ? AND file = ? AND line = ?') + .get(u.name, u.kind, u.file, u.line)?.id; + if (uid && !visited.has(uid)) { + nextFrontier.push({ ...u, id: uid }); + } + } + } + if (nextFrontier.length > 0) { + transitiveCallers[d] = nextFrontier.map((n) => ({ + name: n.name, + kind: n.kind, + file: n.file, + line: n.line, + })); + } + frontier = nextFrontier; + if (frontier.length === 0) break; + } + + return transitiveCallers; +} + +export function fnDepsData(name, customDbPath, opts = {}) { + const db = openReadonlyOrFail(customDbPath); + try { + const depth = opts.depth || 3; + const noTests = opts.noTests || false; + const hc = new Map(); + + const nodes = findMatchingNodes(db, name, { noTests, file: opts.file, kind: opts.kind }); + if (nodes.length === 0) { + return { name, results: [] }; + } + + const results = nodes.map((node) => { + const callees = findCallees(db, node.id); + const filteredCallees = noTests ? callees.filter((c) => !isTestFile(c.file)) : callees; + + let callers = findCallers(db, node.id); + + if (node.kind === 'method' && node.name.includes('.')) { + const methodName = node.name.split('.').pop(); + const relatedMethods = resolveMethodViaHierarchy(db, methodName); + for (const rm of relatedMethods) { + if (rm.id === node.id) continue; + const extraCallers = findCallers(db, rm.id); + callers.push(...extraCallers.map((c) => ({ ...c, viaHierarchy: rm.name }))); + } + } + if (noTests) callers = callers.filter((c) => !isTestFile(c.file)); + + const transitiveCallers = buildTransitiveCallers(db, callers, node.id, depth, noTests); + + return { + ...normalizeSymbol(node, db, hc), + callees: filteredCallees.map((c) => ({ + name: c.name, + kind: c.kind, + file: c.file, + line: c.line, + })), + callers: callers.map((c) => ({ + name: c.name, + kind: c.kind, + file: c.file, + line: c.line, + viaHierarchy: c.viaHierarchy || undefined, + })), + transitiveCallers, + }; + }); + + const base = { name, results }; + return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset }); + } finally { + db.close(); + } +} + +/** + * Resolve from/to symbol names to node records. + * Returns { sourceNode, targetNode, fromCandidates, toCandidates } on success, + * or { earlyResult } when a caller-facing error/not-found response should be returned immediately. + */ +function resolveEndpoints(db, from, to, opts) { + const { noTests = false } = opts; + + const fromNodes = findMatchingNodes(db, from, { + noTests, + file: opts.fromFile, + kind: opts.kind, + }); + if (fromNodes.length === 0) { + return { + earlyResult: { + from, + to, + found: false, + error: `No symbol matching "${from}"`, + fromCandidates: [], + toCandidates: [], + }, + }; + } + + const toNodes = findMatchingNodes(db, to, { + noTests, + file: opts.toFile, + kind: opts.kind, + }); + if (toNodes.length === 0) { + return { + earlyResult: { + from, + to, + found: false, + error: `No symbol matching "${to}"`, + fromCandidates: fromNodes + .slice(0, 5) + .map((n) => ({ name: n.name, kind: n.kind, file: n.file, line: n.line })), + toCandidates: [], + }, + }; + } + + const fromCandidates = fromNodes + .slice(0, 5) + .map((n) => ({ name: n.name, kind: n.kind, file: n.file, line: n.line })); + const toCandidates = toNodes + .slice(0, 5) + .map((n) => ({ name: n.name, kind: n.kind, file: n.file, line: n.line })); + + return { + sourceNode: fromNodes[0], + targetNode: toNodes[0], + fromCandidates, + toCandidates, + }; +} + +/** + * BFS from sourceId toward targetId. + * Returns { found, parent, alternateCount, foundDepth }. + * `parent` maps nodeId → { parentId, edgeKind }. + */ +function bfsShortestPath(db, sourceId, targetId, edgeKinds, reverse, maxDepth, noTests) { + const kindPlaceholders = edgeKinds.map(() => '?').join(', '); + + // Forward: source_id → target_id (A calls... calls B) + // Reverse: target_id → source_id (B is called by... called by A) + const neighborQuery = reverse + ? `SELECT n.id, n.name, n.kind, n.file, n.line, e.kind AS edge_kind + FROM edges e JOIN nodes n ON e.source_id = n.id + WHERE e.target_id = ? AND e.kind IN (${kindPlaceholders})` + : `SELECT n.id, n.name, n.kind, n.file, n.line, e.kind AS edge_kind + FROM edges e JOIN nodes n ON e.target_id = n.id + WHERE e.source_id = ? AND e.kind IN (${kindPlaceholders})`; + const neighborStmt = db.prepare(neighborQuery); + + const visited = new Set([sourceId]); + const parent = new Map(); + let queue = [sourceId]; + let found = false; + let alternateCount = 0; + let foundDepth = -1; + + for (let depth = 1; depth <= maxDepth; depth++) { + const nextQueue = []; + for (const currentId of queue) { + const neighbors = neighborStmt.all(currentId, ...edgeKinds); + for (const n of neighbors) { + if (noTests && isTestFile(n.file)) continue; + if (n.id === targetId) { + if (!found) { + found = true; + foundDepth = depth; + parent.set(n.id, { parentId: currentId, edgeKind: n.edge_kind }); + } + alternateCount++; + continue; + } + if (!visited.has(n.id)) { + visited.add(n.id); + parent.set(n.id, { parentId: currentId, edgeKind: n.edge_kind }); + nextQueue.push(n.id); + } + } + } + if (found) break; + queue = nextQueue; + if (queue.length === 0) break; + } + + return { found, parent, alternateCount, foundDepth }; +} + +/** + * Walk the parent map from targetId back to sourceId and return an ordered + * array of node IDs source → target. + */ +function reconstructPath(db, pathIds, parent) { + const nodeCache = new Map(); + const getNode = (id) => { + if (nodeCache.has(id)) return nodeCache.get(id); + const row = db.prepare('SELECT name, kind, file, line FROM nodes WHERE id = ?').get(id); + nodeCache.set(id, row); + return row; + }; + + return pathIds.map((id, idx) => { + const node = getNode(id); + const edgeKind = idx === 0 ? null : parent.get(id).edgeKind; + return { name: node.name, kind: node.kind, file: node.file, line: node.line, edgeKind }; + }); +} + +export function pathData(from, to, customDbPath, opts = {}) { + const db = openReadonlyOrFail(customDbPath); + try { + const noTests = opts.noTests || false; + const maxDepth = opts.maxDepth || 10; + const edgeKinds = opts.edgeKinds || ['calls']; + const reverse = opts.reverse || false; + + const resolved = resolveEndpoints(db, from, to, { + noTests, + fromFile: opts.fromFile, + toFile: opts.toFile, + kind: opts.kind, + }); + if (resolved.earlyResult) return resolved.earlyResult; + + const { sourceNode, targetNode, fromCandidates, toCandidates } = resolved; + + // Self-path + if (sourceNode.id === targetNode.id) { + return { + from, + to, + fromCandidates, + toCandidates, + found: true, + hops: 0, + path: [ + { + name: sourceNode.name, + kind: sourceNode.kind, + file: sourceNode.file, + line: sourceNode.line, + edgeKind: null, + }, + ], + alternateCount: 0, + edgeKinds, + reverse, + maxDepth, + }; + } + + const { + found, + parent, + alternateCount: rawAlternateCount, + foundDepth, + } = bfsShortestPath(db, sourceNode.id, targetNode.id, edgeKinds, reverse, maxDepth, noTests); + + if (!found) { + return { + from, + to, + fromCandidates, + toCandidates, + found: false, + hops: null, + path: [], + alternateCount: 0, + edgeKinds, + reverse, + maxDepth, + }; + } + + // rawAlternateCount includes the one we kept; subtract 1 for "alternates" + const alternateCount = Math.max(0, rawAlternateCount - 1); + + // Reconstruct path from target back to source + const pathIds = [targetNode.id]; + let cur = targetNode.id; + while (cur !== sourceNode.id) { + const p = parent.get(cur); + pathIds.push(p.parentId); + cur = p.parentId; + } + pathIds.reverse(); + + const resultPath = reconstructPath(db, pathIds, parent); + + return { + from, + to, + fromCandidates, + toCandidates, + found: true, + hops: foundDepth, + path: resultPath, + alternateCount, + edgeKinds, + reverse, + maxDepth, + }; + } finally { + db.close(); + } +} diff --git a/src/domain/analysis/exports.js b/src/domain/analysis/exports.js new file mode 100644 index 00000000..629ae792 --- /dev/null +++ b/src/domain/analysis/exports.js @@ -0,0 +1,206 @@ +import path from 'node:path'; +import { + findCrossFileCallTargets, + findDbPath, + findFileNodes, + findNodesByFile, + openReadonlyOrFail, +} from '../../db/index.js'; +import { loadConfig } from '../../infrastructure/config.js'; +import { debug } from '../../infrastructure/logger.js'; +import { isTestFile } from '../../infrastructure/test-filter.js'; +import { + createFileLinesReader, + extractSignature, + extractSummary, +} from '../../shared/file-utils.js'; +import { paginateResult } from '../../shared/paginate.js'; + +export function exportsData(file, customDbPath, opts = {}) { + const db = openReadonlyOrFail(customDbPath); + try { + const noTests = opts.noTests || false; + + const config = opts.config || loadConfig(); + const displayOpts = config.display || {}; + + const dbFilePath = findDbPath(customDbPath); + const repoRoot = path.resolve(path.dirname(dbFilePath), '..'); + + const getFileLines = createFileLinesReader(repoRoot); + + const unused = opts.unused || false; + const fileResults = exportsFileImpl(db, file, noTests, getFileLines, unused, displayOpts); + + if (fileResults.length === 0) { + return paginateResult( + { + file, + results: [], + reexports: [], + reexportedSymbols: [], + totalExported: 0, + totalInternal: 0, + totalUnused: 0, + totalReexported: 0, + totalReexportedUnused: 0, + }, + 'results', + { limit: opts.limit, offset: opts.offset }, + ); + } + + // For single-file match return flat; for multi-match return first (like explainData) + const first = fileResults[0]; + const base = { + file: first.file, + results: first.results, + reexports: first.reexports, + reexportedSymbols: first.reexportedSymbols, + totalExported: first.totalExported, + totalInternal: first.totalInternal, + totalUnused: first.totalUnused, + totalReexported: first.totalReexported, + totalReexportedUnused: first.totalReexportedUnused, + }; + const paginated = paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset }); + // Paginate reexportedSymbols with the same limit/offset (match paginateResult behaviour) + if (opts.limit != null) { + const off = opts.offset || 0; + paginated.reexportedSymbols = paginated.reexportedSymbols.slice(off, off + opts.limit); + // Update _pagination.hasMore to account for reexportedSymbols (barrel-only files + // have empty results[], so hasMore would always be false without this) + if (paginated._pagination) { + const reexTotal = opts.unused ? base.totalReexportedUnused : base.totalReexported; + const resultsHasMore = paginated._pagination.hasMore; + const reexHasMore = off + opts.limit < reexTotal; + paginated._pagination.hasMore = resultsHasMore || reexHasMore; + } + } + return paginated; + } finally { + db.close(); + } +} + +function exportsFileImpl(db, target, noTests, getFileLines, unused, displayOpts) { + const fileNodes = findFileNodes(db, `%${target}%`); + if (fileNodes.length === 0) return []; + + // Detect whether exported column exists + let hasExportedCol = false; + try { + db.prepare('SELECT exported FROM nodes LIMIT 0').raw(); + hasExportedCol = true; + } catch (e) { + debug(`exported column not available, using fallback: ${e.message}`); + } + + return fileNodes.map((fn) => { + const symbols = findNodesByFile(db, fn.file); + + let exported; + if (hasExportedCol) { + // Use the exported column populated during build + exported = db + .prepare( + "SELECT * FROM nodes WHERE file = ? AND kind != 'file' AND exported = 1 ORDER BY line", + ) + .all(fn.file); + } else { + // Fallback: symbols that have incoming calls from other files + const exportedIds = findCrossFileCallTargets(db, fn.file); + exported = symbols.filter((s) => exportedIds.has(s.id)); + } + const internalCount = symbols.length - exported.length; + + const buildSymbolResult = (s, fileLines) => { + let consumers = db + .prepare( + `SELECT n.name, n.file, n.line FROM edges e JOIN nodes n ON e.source_id = n.id + WHERE e.target_id = ? AND e.kind = 'calls'`, + ) + .all(s.id); + if (noTests) consumers = consumers.filter((c) => !isTestFile(c.file)); + + return { + name: s.name, + kind: s.kind, + line: s.line, + endLine: s.end_line ?? null, + role: s.role || null, + signature: fileLines ? extractSignature(fileLines, s.line, displayOpts) : null, + summary: fileLines ? extractSummary(fileLines, s.line, displayOpts) : null, + consumers: consumers.map((c) => ({ name: c.name, file: c.file, line: c.line })), + consumerCount: consumers.length, + }; + }; + + const results = exported.map((s) => buildSymbolResult(s, getFileLines(fn.file))); + + const totalUnused = results.filter((r) => r.consumerCount === 0).length; + + // Files that re-export this file (barrel → this file) + const reexports = db + .prepare( + `SELECT DISTINCT n.file FROM edges e JOIN nodes n ON e.source_id = n.id + WHERE e.target_id = ? AND e.kind = 'reexports'`, + ) + .all(fn.id) + .map((r) => ({ file: r.file })); + + // For barrel files: gather symbols re-exported from target modules + const reexportTargets = db + .prepare( + `SELECT DISTINCT n.file FROM edges e JOIN nodes n ON e.target_id = n.id + WHERE e.source_id = ? AND e.kind = 'reexports'`, + ) + .all(fn.id); + + const reexportedSymbols = []; + for (const target of reexportTargets) { + let targetExported; + if (hasExportedCol) { + targetExported = db + .prepare( + "SELECT * FROM nodes WHERE file = ? AND kind != 'file' AND exported = 1 ORDER BY line", + ) + .all(target.file); + } else { + // Fallback: same heuristic as direct exports — symbols called from other files + const targetSymbols = findNodesByFile(db, target.file); + const exportedIds = findCrossFileCallTargets(db, target.file); + targetExported = targetSymbols.filter((s) => exportedIds.has(s.id)); + } + for (const s of targetExported) { + const fileLines = getFileLines(target.file); + reexportedSymbols.push({ + ...buildSymbolResult(s, fileLines), + originFile: target.file, + }); + } + } + + let filteredResults = results; + let filteredReexported = reexportedSymbols; + if (unused) { + filteredResults = results.filter((r) => r.consumerCount === 0); + filteredReexported = reexportedSymbols.filter((r) => r.consumerCount === 0); + } + + const totalReexported = reexportedSymbols.length; + const totalReexportedUnused = reexportedSymbols.filter((r) => r.consumerCount === 0).length; + + return { + file: fn.file, + results: filteredResults, + reexports, + reexportedSymbols: filteredReexported, + totalExported: exported.length, + totalInternal: internalCount, + totalUnused, + totalReexported, + totalReexportedUnused, + }; + }); +} diff --git a/src/domain/analysis/impact.js b/src/domain/analysis/impact.js new file mode 100644 index 00000000..2ce1dbbf --- /dev/null +++ b/src/domain/analysis/impact.js @@ -0,0 +1,675 @@ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { + findDbPath, + findDistinctCallers, + findFileNodes, + findImplementors, + findImportDependents, + findNodeById, + openReadonlyOrFail, +} from '../../db/index.js'; +import { evaluateBoundaries } from '../../features/boundaries.js'; +import { coChangeForFiles } from '../../features/cochange.js'; +import { ownersForFiles } from '../../features/owners.js'; +import { loadConfig } from '../../infrastructure/config.js'; +import { debug } from '../../infrastructure/logger.js'; +import { isTestFile } from '../../infrastructure/test-filter.js'; +import { normalizeSymbol } from '../../shared/normalize.js'; +import { paginateResult } from '../../shared/paginate.js'; +import { findMatchingNodes } from './symbol-lookup.js'; + +// ─── Shared BFS: transitive callers ──────────────────────────────────── + +const INTERFACE_LIKE_KINDS = new Set(['interface', 'trait']); + +/** + * Check whether the graph contains any 'implements' edges. + * Cached per db handle so the query runs at most once per connection. + */ +const _hasImplementsCache = new WeakMap(); +function hasImplementsEdges(db) { + if (_hasImplementsCache.has(db)) return _hasImplementsCache.get(db); + const row = db.prepare("SELECT 1 FROM edges WHERE kind = 'implements' LIMIT 1").get(); + const result = !!row; + _hasImplementsCache.set(db, result); + return result; +} + +/** + * BFS traversal to find transitive callers of a node. + * When an interface/trait node is encountered (either as the start node or + * during traversal), its concrete implementors are also added to the frontier + * so that changes to an interface signature propagate to all implementors. + * + * @param {import('better-sqlite3').Database} db - Open read-only SQLite database handle (not a Repository) + * @param {number} startId - Starting node ID + * @param {{ noTests?: boolean, maxDepth?: number, includeImplementors?: boolean, onVisit?: (caller: object, parentId: number, depth: number) => void }} options + * @returns {{ totalDependents: number, levels: Record> }} + */ +export function bfsTransitiveCallers( + db, + startId, + { noTests = false, maxDepth = 3, includeImplementors = true, onVisit } = {}, +) { + // Skip all implementor lookups when the graph has no implements edges + const resolveImplementors = includeImplementors && hasImplementsEdges(db); + + const visited = new Set([startId]); + const levels = {}; + let frontier = [startId]; + + // Seed: if start node is an interface/trait, include its implementors at depth 1. + // Implementors go into a separate list so their callers appear at depth 2, not depth 1. + const implNextFrontier = []; + if (resolveImplementors) { + const startNode = findNodeById(db, startId); + if (startNode && INTERFACE_LIKE_KINDS.has(startNode.kind)) { + const impls = findImplementors(db, startId); + for (const impl of impls) { + if (!visited.has(impl.id) && (!noTests || !isTestFile(impl.file))) { + visited.add(impl.id); + implNextFrontier.push(impl.id); + if (!levels[1]) levels[1] = []; + levels[1].push({ + name: impl.name, + kind: impl.kind, + file: impl.file, + line: impl.line, + viaImplements: true, + }); + if (onVisit) onVisit({ ...impl, viaImplements: true }, startId, 1); + } + } + } + } + + for (let d = 1; d <= maxDepth; d++) { + // On the first wave, merge seeded implementors so their callers appear at d=2 + if (d === 1 && implNextFrontier.length > 0) { + frontier = [...frontier, ...implNextFrontier]; + } + const nextFrontier = []; + for (const fid of frontier) { + const callers = findDistinctCallers(db, fid); + for (const c of callers) { + if (!visited.has(c.id) && (!noTests || !isTestFile(c.file))) { + visited.add(c.id); + nextFrontier.push(c.id); + if (!levels[d]) levels[d] = []; + levels[d].push({ name: c.name, kind: c.kind, file: c.file, line: c.line }); + if (onVisit) onVisit(c, fid, d); + } + + // If a caller is an interface/trait, also pull in its implementors + // Implementors are one extra hop away, so record at d+1 + if (resolveImplementors && INTERFACE_LIKE_KINDS.has(c.kind)) { + const impls = findImplementors(db, c.id); + for (const impl of impls) { + if (!visited.has(impl.id) && (!noTests || !isTestFile(impl.file))) { + visited.add(impl.id); + nextFrontier.push(impl.id); + const implDepth = d + 1; + if (!levels[implDepth]) levels[implDepth] = []; + levels[implDepth].push({ + name: impl.name, + kind: impl.kind, + file: impl.file, + line: impl.line, + viaImplements: true, + }); + if (onVisit) onVisit({ ...impl, viaImplements: true }, c.id, implDepth); + } + } + } + } + } + frontier = nextFrontier; + if (frontier.length === 0) break; + } + + return { totalDependents: visited.size - 1, levels }; +} + +export function impactAnalysisData(file, customDbPath, opts = {}) { + const db = openReadonlyOrFail(customDbPath); + try { + const noTests = opts.noTests || false; + const fileNodes = findFileNodes(db, `%${file}%`); + if (fileNodes.length === 0) { + return { file, sources: [], levels: {}, totalDependents: 0 }; + } + + const visited = new Set(); + const queue = []; + const levels = new Map(); + + for (const fn of fileNodes) { + visited.add(fn.id); + queue.push(fn.id); + levels.set(fn.id, 0); + } + + while (queue.length > 0) { + const current = queue.shift(); + const level = levels.get(current); + const dependents = findImportDependents(db, current); + for (const dep of dependents) { + if (!visited.has(dep.id) && (!noTests || !isTestFile(dep.file))) { + visited.add(dep.id); + queue.push(dep.id); + levels.set(dep.id, level + 1); + } + } + } + + const byLevel = {}; + for (const [id, level] of levels) { + if (level === 0) continue; + if (!byLevel[level]) byLevel[level] = []; + const node = findNodeById(db, id); + if (node) byLevel[level].push({ file: node.file }); + } + + return { + file, + sources: fileNodes.map((f) => f.file), + levels: byLevel, + totalDependents: visited.size - fileNodes.length, + }; + } finally { + db.close(); + } +} + +export function fnImpactData(name, customDbPath, opts = {}) { + const db = openReadonlyOrFail(customDbPath); + try { + const config = opts.config || loadConfig(); + const maxDepth = opts.depth || config.analysis?.fnImpactDepth || 5; + const noTests = opts.noTests || false; + const hc = new Map(); + + const nodes = findMatchingNodes(db, name, { noTests, file: opts.file, kind: opts.kind }); + if (nodes.length === 0) { + return { name, results: [] }; + } + + const includeImplementors = opts.includeImplementors !== false; + + const results = nodes.map((node) => { + const { levels, totalDependents } = bfsTransitiveCallers(db, node.id, { + noTests, + maxDepth, + includeImplementors, + }); + return { + ...normalizeSymbol(node, db, hc), + levels, + totalDependents, + }; + }); + + const base = { name, results }; + return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset }); + } finally { + db.close(); + } +} + +// ─── diffImpactData helpers ───────────────────────────────────────────── + +/** + * Walk up from repoRoot until a .git directory is found. + * Returns true if a git root exists, false otherwise. + * + * @param {string} repoRoot + * @returns {boolean} + */ +function findGitRoot(repoRoot) { + let checkDir = repoRoot; + while (checkDir) { + if (fs.existsSync(path.join(checkDir, '.git'))) { + return true; + } + const parent = path.dirname(checkDir); + if (parent === checkDir) break; + checkDir = parent; + } + return false; +} + +/** + * Execute git diff and return the raw output string. + * Returns `{ output: string }` on success or `{ error: string }` on failure. + * + * @param {string} repoRoot + * @param {{ staged?: boolean, ref?: string }} opts + * @returns {{ output: string } | { error: string }} + */ +function runGitDiff(repoRoot, opts) { + try { + const args = opts.staged + ? ['diff', '--cached', '--unified=0', '--no-color'] + : ['diff', opts.ref || 'HEAD', '--unified=0', '--no-color']; + const output = execFileSync('git', args, { + cwd: repoRoot, + encoding: 'utf-8', + maxBuffer: 10 * 1024 * 1024, + stdio: ['pipe', 'pipe', 'pipe'], + }); + return { output }; + } catch (e) { + return { error: `Failed to run git diff: ${e.message}` }; + } +} + +/** + * Parse raw git diff output into a changedRanges map and newFiles set. + * + * @param {string} diffOutput + * @returns {{ changedRanges: Map>, newFiles: Set }} + */ +function parseGitDiff(diffOutput) { + const changedRanges = new Map(); + const newFiles = new Set(); + let currentFile = null; + let prevIsDevNull = false; + + for (const line of diffOutput.split('\n')) { + if (line.startsWith('--- /dev/null')) { + prevIsDevNull = true; + continue; + } + if (line.startsWith('--- ')) { + prevIsDevNull = false; + continue; + } + const fileMatch = line.match(/^\+\+\+ b\/(.+)/); + if (fileMatch) { + currentFile = fileMatch[1]; + if (!changedRanges.has(currentFile)) changedRanges.set(currentFile, []); + if (prevIsDevNull) newFiles.add(currentFile); + prevIsDevNull = false; + continue; + } + const hunkMatch = line.match(/^@@ .+ \+(\d+)(?:,(\d+))? @@/); + if (hunkMatch && currentFile) { + const start = parseInt(hunkMatch[1], 10); + const count = parseInt(hunkMatch[2] || '1', 10); + changedRanges.get(currentFile).push({ start, end: start + count - 1 }); + } + } + + return { changedRanges, newFiles }; +} + +/** + * Find all function/method/class nodes whose line ranges overlap any changed range. + * + * @param {import('better-sqlite3').Database} db + * @param {Map} changedRanges + * @param {boolean} noTests + * @returns {Array} + */ +function findAffectedFunctions(db, changedRanges, noTests) { + const affectedFunctions = []; + for (const [file, ranges] of changedRanges) { + if (noTests && isTestFile(file)) continue; + const defs = db + .prepare( + `SELECT * FROM nodes WHERE file = ? AND kind IN ('function', 'method', 'class') ORDER BY line`, + ) + .all(file); + for (let i = 0; i < defs.length; i++) { + const def = defs[i]; + const endLine = def.end_line || (defs[i + 1] ? defs[i + 1].line - 1 : 999999); + for (const range of ranges) { + if (range.start <= endLine && range.end >= def.line) { + affectedFunctions.push(def); + break; + } + } + } + } + return affectedFunctions; +} + +/** + * Run BFS per affected function, collecting per-function results and the full affected set. + * + * @param {import('better-sqlite3').Database} db + * @param {Array} affectedFunctions + * @param {boolean} noTests + * @param {number} maxDepth + * @returns {{ functionResults: Array, allAffected: Set }} + */ +function buildFunctionImpactResults( + db, + affectedFunctions, + noTests, + maxDepth, + includeImplementors = true, +) { + const allAffected = new Set(); + const functionResults = affectedFunctions.map((fn) => { + const edges = []; + const idToKey = new Map(); + idToKey.set(fn.id, `${fn.file}::${fn.name}:${fn.line}`); + + const { levels, totalDependents } = bfsTransitiveCallers(db, fn.id, { + noTests, + maxDepth, + includeImplementors, + onVisit(c, parentId) { + allAffected.add(`${c.file}:${c.name}`); + const callerKey = `${c.file}::${c.name}:${c.line}`; + idToKey.set(c.id, callerKey); + edges.push({ from: idToKey.get(parentId), to: callerKey }); + }, + }); + + return { + name: fn.name, + kind: fn.kind, + file: fn.file, + line: fn.line, + transitiveCallers: totalDependents, + levels, + edges, + }; + }); + + return { functionResults, allAffected }; +} + +/** + * Look up historically co-changed files for the set of changed files. + * Returns an empty array if the co_changes table is unavailable. + * + * @param {import('better-sqlite3').Database} db + * @param {Map} changedRanges + * @param {Set} affectedFiles + * @param {boolean} noTests + * @returns {Array} + */ +function lookupCoChanges(db, changedRanges, affectedFiles, noTests) { + try { + db.prepare('SELECT 1 FROM co_changes LIMIT 1').get(); + const changedFilesList = [...changedRanges.keys()]; + const coResults = coChangeForFiles(changedFilesList, db, { + minJaccard: 0.3, + limit: 20, + noTests, + }); + return coResults.filter((r) => !affectedFiles.has(r.file)); + } catch (e) { + debug(`co_changes lookup skipped: ${e.message}`); + return []; + } +} + +/** + * Look up CODEOWNERS for changed and affected files. + * Returns null if no owners are found or lookup fails. + * + * @param {Map} changedRanges + * @param {Set} affectedFiles + * @param {string} repoRoot + * @returns {{ owners: object, affectedOwners: Array, suggestedReviewers: Array } | null} + */ +function lookupOwnership(changedRanges, affectedFiles, repoRoot) { + try { + const allFilePaths = [...new Set([...changedRanges.keys(), ...affectedFiles])]; + const ownerResult = ownersForFiles(allFilePaths, repoRoot); + if (ownerResult.affectedOwners.length > 0) { + return { + owners: Object.fromEntries(ownerResult.owners), + affectedOwners: ownerResult.affectedOwners, + suggestedReviewers: ownerResult.suggestedReviewers, + }; + } + return null; + } catch (e) { + debug(`CODEOWNERS lookup skipped: ${e.message}`); + return null; + } +} + +/** + * Check manifesto boundary violations scoped to the changed files. + * Returns `{ boundaryViolations, boundaryViolationCount }`. + * + * @param {import('better-sqlite3').Database} db + * @param {Map} changedRanges + * @param {boolean} noTests + * @param {object} opts — full diffImpactData opts (may contain `opts.config`) + * @param {string} repoRoot + * @returns {{ boundaryViolations: Array, boundaryViolationCount: number }} + */ +function checkBoundaryViolations(db, changedRanges, noTests, opts, repoRoot) { + try { + const cfg = opts.config || loadConfig(repoRoot); + const boundaryConfig = cfg.manifesto?.boundaries; + if (boundaryConfig) { + const result = evaluateBoundaries(db, boundaryConfig, { + scopeFiles: [...changedRanges.keys()], + noTests, + }); + return { + boundaryViolations: result.violations, + boundaryViolationCount: result.violationCount, + }; + } + } catch (e) { + debug(`boundary check skipped: ${e.message}`); + } + return { boundaryViolations: [], boundaryViolationCount: 0 }; +} + +// ─── diffImpactData ───────────────────────────────────────────────────── + +/** + * Fix #2: Shell injection vulnerability. + * Uses execFileSync instead of execSync to prevent shell interpretation of user input. + */ +export function diffImpactData(customDbPath, opts = {}) { + const db = openReadonlyOrFail(customDbPath); + try { + const noTests = opts.noTests || false; + const config = opts.config || loadConfig(); + const maxDepth = opts.depth || config.analysis?.impactDepth || 3; + + const dbPath = findDbPath(customDbPath); + const repoRoot = path.resolve(path.dirname(dbPath), '..'); + + if (!findGitRoot(repoRoot)) { + return { error: `Not a git repository: ${repoRoot}` }; + } + + const gitResult = runGitDiff(repoRoot, opts); + if (gitResult.error) return { error: gitResult.error }; + + if (!gitResult.output.trim()) { + return { + changedFiles: 0, + newFiles: [], + affectedFunctions: [], + affectedFiles: [], + summary: null, + }; + } + + const { changedRanges, newFiles } = parseGitDiff(gitResult.output); + + if (changedRanges.size === 0) { + return { + changedFiles: 0, + newFiles: [], + affectedFunctions: [], + affectedFiles: [], + summary: null, + }; + } + + const affectedFunctions = findAffectedFunctions(db, changedRanges, noTests); + const includeImplementors = opts.includeImplementors !== false; + const { functionResults, allAffected } = buildFunctionImpactResults( + db, + affectedFunctions, + noTests, + maxDepth, + includeImplementors, + ); + + const affectedFiles = new Set(); + for (const key of allAffected) affectedFiles.add(key.split(':')[0]); + + const historicallyCoupled = lookupCoChanges(db, changedRanges, affectedFiles, noTests); + const ownership = lookupOwnership(changedRanges, affectedFiles, repoRoot); + const { boundaryViolations, boundaryViolationCount } = checkBoundaryViolations( + db, + changedRanges, + noTests, + opts, + repoRoot, + ); + + const base = { + changedFiles: changedRanges.size, + newFiles: [...newFiles], + affectedFunctions: functionResults, + affectedFiles: [...affectedFiles], + historicallyCoupled, + ownership, + boundaryViolations, + boundaryViolationCount, + summary: { + functionsChanged: affectedFunctions.length, + callersAffected: allAffected.size, + filesAffected: affectedFiles.size, + historicallyCoupledCount: historicallyCoupled.length, + ownersAffected: ownership ? ownership.affectedOwners.length : 0, + boundaryViolationCount, + }, + }; + return paginateResult(base, 'affectedFunctions', { limit: opts.limit, offset: opts.offset }); + } finally { + db.close(); + } +} + +export function diffImpactMermaid(customDbPath, opts = {}) { + const data = diffImpactData(customDbPath, opts); + if (data.error) return data.error; + if (data.changedFiles === 0 || data.affectedFunctions.length === 0) { + return 'flowchart TB\n none["No impacted functions detected"]'; + } + + const newFileSet = new Set(data.newFiles || []); + const lines = ['flowchart TB']; + + // Assign stable Mermaid node IDs + let nodeCounter = 0; + const nodeIdMap = new Map(); + const nodeLabels = new Map(); + function nodeId(key, label) { + if (!nodeIdMap.has(key)) { + nodeIdMap.set(key, `n${nodeCounter++}`); + if (label) nodeLabels.set(key, label); + } + return nodeIdMap.get(key); + } + + // Register all nodes (changed functions + their callers) + for (const fn of data.affectedFunctions) { + nodeId(`${fn.file}::${fn.name}:${fn.line}`, fn.name); + for (const callers of Object.values(fn.levels || {})) { + for (const c of callers) { + nodeId(`${c.file}::${c.name}:${c.line}`, c.name); + } + } + } + + // Collect all edges and determine blast radius + const allEdges = new Set(); + const edgeFromNodes = new Set(); + const edgeToNodes = new Set(); + const changedKeys = new Set(); + + for (const fn of data.affectedFunctions) { + changedKeys.add(`${fn.file}::${fn.name}:${fn.line}`); + for (const edge of fn.edges || []) { + const edgeKey = `${edge.from}|${edge.to}`; + if (!allEdges.has(edgeKey)) { + allEdges.add(edgeKey); + edgeFromNodes.add(edge.from); + edgeToNodes.add(edge.to); + } + } + } + + // Blast radius: caller nodes that are never a source (leaf nodes of the impact tree) + const blastRadiusKeys = new Set(); + for (const key of edgeToNodes) { + if (!edgeFromNodes.has(key) && !changedKeys.has(key)) { + blastRadiusKeys.add(key); + } + } + + // Intermediate callers: not changed, not blast radius + const intermediateKeys = new Set(); + for (const key of edgeToNodes) { + if (!changedKeys.has(key) && !blastRadiusKeys.has(key)) { + intermediateKeys.add(key); + } + } + + // Group changed functions by file + const fileGroups = new Map(); + for (const fn of data.affectedFunctions) { + if (!fileGroups.has(fn.file)) fileGroups.set(fn.file, []); + fileGroups.get(fn.file).push(fn); + } + + // Emit changed-file subgraphs + let sgCounter = 0; + for (const [file, fns] of fileGroups) { + const isNew = newFileSet.has(file); + const tag = isNew ? 'new' : 'modified'; + const sgId = `sg${sgCounter++}`; + lines.push(` subgraph ${sgId}["${file} **(${tag})**"]`); + for (const fn of fns) { + const key = `${fn.file}::${fn.name}:${fn.line}`; + lines.push(` ${nodeIdMap.get(key)}["${fn.name}"]`); + } + lines.push(' end'); + const style = isNew ? 'fill:#e8f5e9,stroke:#4caf50' : 'fill:#fff3e0,stroke:#ff9800'; + lines.push(` style ${sgId} ${style}`); + } + + // Emit intermediate caller nodes (outside subgraphs) + for (const key of intermediateKeys) { + lines.push(` ${nodeIdMap.get(key)}["${nodeLabels.get(key)}"]`); + } + + // Emit blast radius subgraph + if (blastRadiusKeys.size > 0) { + const sgId = `sg${sgCounter++}`; + lines.push(` subgraph ${sgId}["Callers **(blast radius)**"]`); + for (const key of blastRadiusKeys) { + lines.push(` ${nodeIdMap.get(key)}["${nodeLabels.get(key)}"]`); + } + lines.push(' end'); + lines.push(` style ${sgId} fill:#f3e5f5,stroke:#9c27b0`); + } + + // Emit edges (impact flows from changed fn toward callers) + for (const edgeKey of allEdges) { + const [from, to] = edgeKey.split('|'); + lines.push(` ${nodeIdMap.get(from)} --> ${nodeIdMap.get(to)}`); + } + + return lines.join('\n'); +} diff --git a/src/domain/analysis/implementations.js b/src/domain/analysis/implementations.js new file mode 100644 index 00000000..487f1948 --- /dev/null +++ b/src/domain/analysis/implementations.js @@ -0,0 +1,98 @@ +import { findImplementors, findInterfaces, openReadonlyOrFail } from '../../db/index.js'; +import { isTestFile } from '../../infrastructure/test-filter.js'; +import { CORE_SYMBOL_KINDS } from '../../shared/kinds.js'; +import { normalizeSymbol } from '../../shared/normalize.js'; +import { paginateResult } from '../../shared/paginate.js'; +import { findMatchingNodes } from './symbol-lookup.js'; + +/** + * Find all concrete types implementing a given interface/trait. + * + * @param {string} name - Interface/trait name (partial match) + * @param {string|undefined} customDbPath + * @param {{ noTests?: boolean, file?: string, kind?: string, limit?: number, offset?: number }} opts + * @returns {{ name: string, results: Array<{ name: string, kind: string, file: string, line: number, implementors: Array<{ name: string, kind: string, file: string, line: number }> }> }} + */ +export function implementationsData(name, customDbPath, opts = {}) { + const db = openReadonlyOrFail(customDbPath); + try { + const noTests = opts.noTests || false; + const hc = new Map(); + + const nodes = findMatchingNodes(db, name, { + noTests, + file: opts.file, + kind: opts.kind, + kinds: opts.kind ? undefined : CORE_SYMBOL_KINDS, + }); + if (nodes.length === 0) { + return { name, results: [] }; + } + + const results = nodes.map((node) => { + let implementors = findImplementors(db, node.id); + if (noTests) implementors = implementors.filter((n) => !isTestFile(n.file)); + + return { + ...normalizeSymbol(node, db, hc), + implementors: implementors.map((impl) => ({ + name: impl.name, + kind: impl.kind, + file: impl.file, + line: impl.line, + })), + }; + }); + + const base = { name, results }; + return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset }); + } finally { + db.close(); + } +} + +/** + * Find all interfaces/traits that a given class/struct implements. + * + * @param {string} name - Class/struct name (partial match) + * @param {string|undefined} customDbPath + * @param {{ noTests?: boolean, file?: string, kind?: string, limit?: number, offset?: number }} opts + * @returns {{ name: string, results: Array<{ name: string, kind: string, file: string, line: number, interfaces: Array<{ name: string, kind: string, file: string, line: number }> }> }} + */ +export function interfacesData(name, customDbPath, opts = {}) { + const db = openReadonlyOrFail(customDbPath); + try { + const noTests = opts.noTests || false; + const hc = new Map(); + + const nodes = findMatchingNodes(db, name, { + noTests, + file: opts.file, + kind: opts.kind, + kinds: opts.kind ? undefined : CORE_SYMBOL_KINDS, + }); + if (nodes.length === 0) { + return { name, results: [] }; + } + + const results = nodes.map((node) => { + let interfaces = findInterfaces(db, node.id); + if (noTests) interfaces = interfaces.filter((n) => !isTestFile(n.file)); + + return { + ...normalizeSymbol(node, db, hc), + interfaces: interfaces.map((iface) => ({ + name: iface.name, + kind: iface.kind, + file: iface.file, + line: iface.line, + })), + }; + }); + + const base = { name, results }; + return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset }); + } finally { + db.close(); + } +} diff --git a/src/domain/analysis/module-map.js b/src/domain/analysis/module-map.js new file mode 100644 index 00000000..d3566c8c --- /dev/null +++ b/src/domain/analysis/module-map.js @@ -0,0 +1,357 @@ +import path from 'node:path'; +import { openReadonlyOrFail, testFilterSQL } from '../../db/index.js'; +import { loadConfig } from '../../infrastructure/config.js'; +import { debug } from '../../infrastructure/logger.js'; +import { isTestFile } from '../../infrastructure/test-filter.js'; +import { DEAD_ROLE_PREFIX } from '../../shared/kinds.js'; +import { findCycles } from '../graph/cycles.js'; +import { LANGUAGE_REGISTRY } from '../parser.js'; + +export const FALSE_POSITIVE_NAMES = new Set([ + 'run', + 'get', + 'set', + 'init', + 'start', + 'handle', + 'main', + 'new', + 'create', + 'update', + 'delete', + 'process', + 'execute', + 'call', + 'apply', + 'setup', + 'render', + 'build', + 'load', + 'save', + 'find', + 'make', + 'open', + 'close', + 'reset', + 'send', + 'read', + 'write', +]); +export const FALSE_POSITIVE_CALLER_THRESHOLD = 20; + +// --------------------------------------------------------------------------- +// Section helpers +// --------------------------------------------------------------------------- + +function buildTestFileIds(db) { + const allFileNodes = db.prepare("SELECT id, file FROM nodes WHERE kind = 'file'").all(); + const testFileIds = new Set(); + const testFiles = new Set(); + for (const n of allFileNodes) { + if (isTestFile(n.file)) { + testFileIds.add(n.id); + testFiles.add(n.file); + } + } + const allNodes = db.prepare('SELECT id, file FROM nodes').all(); + for (const n of allNodes) { + if (testFiles.has(n.file)) testFileIds.add(n.id); + } + return testFileIds; +} + +function countNodesByKind(db, testFileIds) { + let nodeRows; + if (testFileIds) { + const allNodes = db.prepare('SELECT id, kind, file FROM nodes').all(); + const filtered = allNodes.filter((n) => !testFileIds.has(n.id)); + const counts = {}; + for (const n of filtered) counts[n.kind] = (counts[n.kind] || 0) + 1; + nodeRows = Object.entries(counts).map(([kind, c]) => ({ kind, c })); + } else { + nodeRows = db.prepare('SELECT kind, COUNT(*) as c FROM nodes GROUP BY kind').all(); + } + const byKind = {}; + let total = 0; + for (const r of nodeRows) { + byKind[r.kind] = r.c; + total += r.c; + } + return { total, byKind }; +} + +function countEdgesByKind(db, testFileIds) { + let edgeRows; + if (testFileIds) { + const allEdges = db.prepare('SELECT source_id, target_id, kind FROM edges').all(); + const filtered = allEdges.filter( + (e) => !testFileIds.has(e.source_id) && !testFileIds.has(e.target_id), + ); + const counts = {}; + for (const e of filtered) counts[e.kind] = (counts[e.kind] || 0) + 1; + edgeRows = Object.entries(counts).map(([kind, c]) => ({ kind, c })); + } else { + edgeRows = db.prepare('SELECT kind, COUNT(*) as c FROM edges GROUP BY kind').all(); + } + const byKind = {}; + let total = 0; + for (const r of edgeRows) { + byKind[r.kind] = r.c; + total += r.c; + } + return { total, byKind }; +} + +function countFilesByLanguage(db, noTests) { + const extToLang = new Map(); + for (const entry of LANGUAGE_REGISTRY) { + for (const ext of entry.extensions) { + extToLang.set(ext, entry.id); + } + } + let fileNodes = db.prepare("SELECT file FROM nodes WHERE kind = 'file'").all(); + if (noTests) fileNodes = fileNodes.filter((n) => !isTestFile(n.file)); + const byLanguage = {}; + for (const row of fileNodes) { + const ext = path.extname(row.file).toLowerCase(); + const lang = extToLang.get(ext) || 'other'; + byLanguage[lang] = (byLanguage[lang] || 0) + 1; + } + return { total: fileNodes.length, languages: Object.keys(byLanguage).length, byLanguage }; +} + +function findHotspots(db, noTests, limit) { + const testFilter = testFilterSQL('n.file', noTests); + const hotspotRows = db + .prepare(` + SELECT n.file, + (SELECT COUNT(*) FROM edges WHERE target_id = n.id) as fan_in, + (SELECT COUNT(*) FROM edges WHERE source_id = n.id) as fan_out + FROM nodes n + WHERE n.kind = 'file' ${testFilter} + ORDER BY (SELECT COUNT(*) FROM edges WHERE target_id = n.id) + + (SELECT COUNT(*) FROM edges WHERE source_id = n.id) DESC + `) + .all(); + const filtered = noTests ? hotspotRows.filter((r) => !isTestFile(r.file)) : hotspotRows; + return filtered.slice(0, limit).map((r) => ({ + file: r.file, + fanIn: r.fan_in, + fanOut: r.fan_out, + })); +} + +function getEmbeddingsInfo(db) { + try { + const count = db.prepare('SELECT COUNT(*) as c FROM embeddings').get(); + if (count && count.c > 0) { + const meta = {}; + const metaRows = db.prepare('SELECT key, value FROM embedding_meta').all(); + for (const r of metaRows) meta[r.key] = r.value; + return { + count: count.c, + model: meta.model || null, + dim: meta.dim ? parseInt(meta.dim, 10) : null, + builtAt: meta.built_at || null, + }; + } + } catch (e) { + debug(`embeddings lookup skipped: ${e.message}`); + } + return null; +} + +function computeQualityMetrics(db, testFilter, fpThreshold = FALSE_POSITIVE_CALLER_THRESHOLD) { + const qualityTestFilter = testFilter.replace(/n\.file/g, 'file'); + + const totalCallable = db + .prepare( + `SELECT COUNT(*) as c FROM nodes WHERE kind IN ('function', 'method') ${qualityTestFilter}`, + ) + .get().c; + const callableWithCallers = db + .prepare(` + SELECT COUNT(DISTINCT e.target_id) as c FROM edges e + JOIN nodes n ON e.target_id = n.id + WHERE e.kind = 'calls' AND n.kind IN ('function', 'method') ${testFilter} + `) + .get().c; + const callerCoverage = totalCallable > 0 ? callableWithCallers / totalCallable : 0; + + const totalCallEdges = db.prepare("SELECT COUNT(*) as c FROM edges WHERE kind = 'calls'").get().c; + const highConfCallEdges = db + .prepare("SELECT COUNT(*) as c FROM edges WHERE kind = 'calls' AND confidence >= 0.7") + .get().c; + const callConfidence = totalCallEdges > 0 ? highConfCallEdges / totalCallEdges : 0; + + const fpRows = db + .prepare(` + SELECT n.name, n.file, n.line, COUNT(e.source_id) as caller_count + FROM nodes n + LEFT JOIN edges e ON n.id = e.target_id AND e.kind = 'calls' + WHERE n.kind IN ('function', 'method') + GROUP BY n.id + HAVING caller_count > ? + ORDER BY caller_count DESC + `) + .all(fpThreshold); + const falsePositiveWarnings = fpRows + .filter((r) => + FALSE_POSITIVE_NAMES.has(r.name.includes('.') ? r.name.split('.').pop() : r.name), + ) + .map((r) => ({ name: r.name, file: r.file, line: r.line, callerCount: r.caller_count })); + + let fpEdgeCount = 0; + for (const fp of falsePositiveWarnings) fpEdgeCount += fp.callerCount; + const falsePositiveRatio = totalCallEdges > 0 ? fpEdgeCount / totalCallEdges : 0; + + const score = Math.round( + callerCoverage * 40 + callConfidence * 40 + (1 - falsePositiveRatio) * 20, + ); + + return { + score, + callerCoverage: { + ratio: callerCoverage, + covered: callableWithCallers, + total: totalCallable, + }, + callConfidence: { + ratio: callConfidence, + highConf: highConfCallEdges, + total: totalCallEdges, + }, + falsePositiveWarnings, + }; +} + +function countRoles(db, noTests) { + let roleRows; + if (noTests) { + const allRoleNodes = db.prepare('SELECT role, file FROM nodes WHERE role IS NOT NULL').all(); + const filtered = allRoleNodes.filter((n) => !isTestFile(n.file)); + const counts = {}; + for (const n of filtered) counts[n.role] = (counts[n.role] || 0) + 1; + roleRows = Object.entries(counts).map(([role, c]) => ({ role, c })); + } else { + roleRows = db + .prepare('SELECT role, COUNT(*) as c FROM nodes WHERE role IS NOT NULL GROUP BY role') + .all(); + } + const roles = {}; + let deadTotal = 0; + for (const r of roleRows) { + roles[r.role] = r.c; + if (r.role.startsWith(DEAD_ROLE_PREFIX)) deadTotal += r.c; + } + if (deadTotal > 0) roles.dead = deadTotal; + return roles; +} + +function getComplexitySummary(db, testFilter) { + try { + const cRows = db + .prepare( + `SELECT fc.cognitive, fc.cyclomatic, fc.max_nesting, fc.maintainability_index + FROM function_complexity fc JOIN nodes n ON fc.node_id = n.id + WHERE n.kind IN ('function','method') ${testFilter}`, + ) + .all(); + if (cRows.length > 0) { + const miValues = cRows.map((r) => r.maintainability_index || 0); + return { + analyzed: cRows.length, + avgCognitive: +(cRows.reduce((s, r) => s + r.cognitive, 0) / cRows.length).toFixed(1), + avgCyclomatic: +(cRows.reduce((s, r) => s + r.cyclomatic, 0) / cRows.length).toFixed(1), + maxCognitive: Math.max(...cRows.map((r) => r.cognitive)), + maxCyclomatic: Math.max(...cRows.map((r) => r.cyclomatic)), + avgMI: +(miValues.reduce((s, v) => s + v, 0) / miValues.length).toFixed(1), + minMI: +Math.min(...miValues).toFixed(1), + }; + } + } catch (e) { + debug(`complexity summary skipped: ${e.message}`); + } + return null; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +export function moduleMapData(customDbPath, limit = 20, opts = {}) { + const db = openReadonlyOrFail(customDbPath); + try { + const noTests = opts.noTests || false; + + const testFilter = testFilterSQL('n.file', noTests); + + const nodes = db + .prepare(` + SELECT n.*, + (SELECT COUNT(*) FROM edges WHERE source_id = n.id AND kind NOT IN ('contains', 'parameter_of', 'receiver')) as out_edges, + (SELECT COUNT(*) FROM edges WHERE target_id = n.id AND kind NOT IN ('contains', 'parameter_of', 'receiver')) as in_edges + FROM nodes n + WHERE n.kind = 'file' + ${testFilter} + ORDER BY (SELECT COUNT(*) FROM edges WHERE target_id = n.id AND kind NOT IN ('contains', 'parameter_of', 'receiver')) DESC + LIMIT ? + `) + .all(limit); + + const topNodes = nodes.map((n) => ({ + file: n.file, + dir: path.dirname(n.file) || '.', + inEdges: n.in_edges, + outEdges: n.out_edges, + coupling: n.in_edges + n.out_edges, + })); + + const totalNodes = db.prepare('SELECT COUNT(*) as c FROM nodes').get().c; + const totalEdges = db.prepare('SELECT COUNT(*) as c FROM edges').get().c; + const totalFiles = db.prepare("SELECT COUNT(*) as c FROM nodes WHERE kind = 'file'").get().c; + + return { limit, topNodes, stats: { totalFiles, totalNodes, totalEdges } }; + } finally { + db.close(); + } +} + +export function statsData(customDbPath, opts = {}) { + const db = openReadonlyOrFail(customDbPath); + try { + const noTests = opts.noTests || false; + const config = opts.config || loadConfig(); + const testFilter = testFilterSQL('n.file', noTests); + + const testFileIds = noTests ? buildTestFileIds(db) : null; + + const { total: totalNodes, byKind: nodesByKind } = countNodesByKind(db, testFileIds); + const { total: totalEdges, byKind: edgesByKind } = countEdgesByKind(db, testFileIds); + const files = countFilesByLanguage(db, noTests); + + const fileCycles = findCycles(db, { fileLevel: true, noTests }); + const fnCycles = findCycles(db, { fileLevel: false, noTests }); + + const hotspots = findHotspots(db, noTests, 5); + const embeddings = getEmbeddingsInfo(db); + const fpThreshold = config.analysis?.falsePositiveCallers ?? FALSE_POSITIVE_CALLER_THRESHOLD; + const quality = computeQualityMetrics(db, testFilter, fpThreshold); + const roles = countRoles(db, noTests); + const complexity = getComplexitySummary(db, testFilter); + + return { + nodes: { total: totalNodes, byKind: nodesByKind }, + edges: { total: totalEdges, byKind: edgesByKind }, + files, + cycles: { fileLevel: fileCycles.length, functionLevel: fnCycles.length }, + hotspots, + embeddings, + quality, + roles, + complexity, + }; + } finally { + db.close(); + } +} diff --git a/src/domain/analysis/roles.js b/src/domain/analysis/roles.js new file mode 100644 index 00000000..403f758c --- /dev/null +++ b/src/domain/analysis/roles.js @@ -0,0 +1,54 @@ +import { openReadonlyOrFail } from '../../db/index.js'; +import { buildFileConditionSQL } from '../../db/query-builder.js'; +import { isTestFile } from '../../infrastructure/test-filter.js'; +import { DEAD_ROLE_PREFIX } from '../../shared/kinds.js'; +import { normalizeSymbol } from '../../shared/normalize.js'; +import { paginateResult } from '../../shared/paginate.js'; + +export function rolesData(customDbPath, opts = {}) { + const db = openReadonlyOrFail(customDbPath); + try { + const noTests = opts.noTests || false; + const filterRole = opts.role || null; + const conditions = ['role IS NOT NULL']; + const params = []; + + if (filterRole) { + if (filterRole === DEAD_ROLE_PREFIX) { + conditions.push('role LIKE ?'); + params.push(`${DEAD_ROLE_PREFIX}%`); + } else { + conditions.push('role = ?'); + params.push(filterRole); + } + } + { + const fc = buildFileConditionSQL(opts.file, 'file'); + if (fc.sql) { + // Strip leading ' AND ' since we're using conditions array + conditions.push(fc.sql.replace(/^ AND /, '')); + params.push(...fc.params); + } + } + + let rows = db + .prepare( + `SELECT name, kind, file, line, end_line, role FROM nodes WHERE ${conditions.join(' AND ')} ORDER BY role, file, line`, + ) + .all(...params); + + if (noTests) rows = rows.filter((r) => !isTestFile(r.file)); + + const summary = {}; + for (const r of rows) { + summary[r.role] = (summary[r.role] || 0) + 1; + } + + const hc = new Map(); + const symbols = rows.map((r) => normalizeSymbol(r, db, hc)); + const base = { count: symbols.length, summary, symbols }; + return paginateResult(base, 'symbols', { limit: opts.limit, offset: opts.offset }); + } finally { + db.close(); + } +} diff --git a/src/domain/analysis/symbol-lookup.js b/src/domain/analysis/symbol-lookup.js new file mode 100644 index 00000000..ffb5566c --- /dev/null +++ b/src/domain/analysis/symbol-lookup.js @@ -0,0 +1,240 @@ +import { + countCrossFileCallers, + findAllIncomingEdges, + findAllOutgoingEdges, + findCallers, + findCrossFileCallTargets, + findFileNodes, + findImportSources, + findImportTargets, + findNodeChildren, + findNodesByFile, + findNodesWithFanIn, + listFunctionNodes, + openReadonlyOrFail, + Repository, +} from '../../db/index.js'; +import { debug } from '../../infrastructure/logger.js'; +import { isTestFile } from '../../infrastructure/test-filter.js'; +import { EVERY_SYMBOL_KIND } from '../../shared/kinds.js'; +import { getFileHash, normalizeSymbol } from '../../shared/normalize.js'; +import { paginateResult } from '../../shared/paginate.js'; + +const FUNCTION_KINDS = ['function', 'method', 'class', 'constant']; + +/** + * 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(dbOrRepo, name, opts = {}) { + const kinds = opts.kind ? [opts.kind] : opts.kinds?.length ? opts.kinds : FUNCTION_KINDS; + + 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; + + const lowerQuery = name.toLowerCase(); + for (const node of nodes) { + const lowerName = node.name.toLowerCase(); + const bareName = lowerName.includes('.') ? lowerName.split('.').pop() : lowerName; + + let matchScore; + if (lowerName === lowerQuery || bareName === lowerQuery) { + matchScore = 100; + } else if (lowerName.startsWith(lowerQuery) || bareName.startsWith(lowerQuery)) { + matchScore = 60; + } else if (lowerName.includes(`.${lowerQuery}`) || lowerName.includes(`${lowerQuery}.`)) { + matchScore = 40; + } else { + matchScore = 10; + } + + const fanInBonus = Math.min(Math.log2(node.fan_in + 1) * 5, 25); + node._relevance = matchScore + fanInBonus; + } + + nodes.sort((a, b) => b._relevance - a._relevance); + return nodes; +} + +export function queryNameData(name, customDbPath, opts = {}) { + const db = openReadonlyOrFail(customDbPath); + try { + const noTests = opts.noTests || false; + let nodes = db.prepare(`SELECT * FROM nodes WHERE name LIKE ?`).all(`%${name}%`); + if (noTests) nodes = nodes.filter((n) => !isTestFile(n.file)); + if (nodes.length === 0) { + return { query: name, results: [] }; + } + + const hc = new Map(); + const results = nodes.map((node) => { + let callees = findAllOutgoingEdges(db, node.id); + + let callers = findAllIncomingEdges(db, node.id); + + if (noTests) { + callees = callees.filter((c) => !isTestFile(c.file)); + callers = callers.filter((c) => !isTestFile(c.file)); + } + + return { + ...normalizeSymbol(node, db, hc), + callees: callees.map((c) => ({ + name: c.name, + kind: c.kind, + file: c.file, + line: c.line, + edgeKind: c.edge_kind, + })), + callers: callers.map((c) => ({ + name: c.name, + kind: c.kind, + file: c.file, + line: c.line, + edgeKind: c.edge_kind, + })), + }; + }); + + const base = { query: name, results }; + return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset }); + } finally { + db.close(); + } +} + +function whereSymbolImpl(db, target, noTests) { + const placeholders = EVERY_SYMBOL_KIND.map(() => '?').join(', '); + let nodes = db + .prepare( + `SELECT * FROM nodes WHERE name LIKE ? AND kind IN (${placeholders}) ORDER BY file, line`, + ) + .all(`%${target}%`, ...EVERY_SYMBOL_KIND); + if (noTests) nodes = nodes.filter((n) => !isTestFile(n.file)); + + const hc = new Map(); + return nodes.map((node) => { + const crossCount = countCrossFileCallers(db, node.id, node.file); + const exported = crossCount > 0; + + let uses = findCallers(db, node.id); + if (noTests) uses = uses.filter((u) => !isTestFile(u.file)); + + return { + ...normalizeSymbol(node, db, hc), + exported, + uses: uses.map((u) => ({ name: u.name, file: u.file, line: u.line })), + }; + }); +} + +function whereFileImpl(db, target) { + const fileNodes = findFileNodes(db, `%${target}%`); + if (fileNodes.length === 0) return []; + + return fileNodes.map((fn) => { + const symbols = findNodesByFile(db, fn.file); + + const imports = findImportTargets(db, fn.id).map((r) => r.file); + + const importedBy = findImportSources(db, fn.id).map((r) => r.file); + + const exportedIds = findCrossFileCallTargets(db, fn.file); + + const exported = symbols.filter((s) => exportedIds.has(s.id)).map((s) => s.name); + + return { + file: fn.file, + fileHash: getFileHash(db, fn.file), + symbols: symbols.map((s) => ({ name: s.name, kind: s.kind, line: s.line })), + imports, + importedBy, + exported, + }; + }); +} + +export function whereData(target, customDbPath, opts = {}) { + const db = openReadonlyOrFail(customDbPath); + try { + const noTests = opts.noTests || false; + const fileMode = opts.file || false; + + const results = fileMode ? whereFileImpl(db, target) : whereSymbolImpl(db, target, noTests); + + const base = { target, mode: fileMode ? 'file' : 'symbol', results }; + return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset }); + } finally { + db.close(); + } +} + +export function listFunctionsData(customDbPath, opts = {}) { + const db = openReadonlyOrFail(customDbPath); + try { + const noTests = opts.noTests || false; + + let rows = listFunctionNodes(db, { file: opts.file, pattern: opts.pattern }); + + if (noTests) rows = rows.filter((r) => !isTestFile(r.file)); + + const hc = new Map(); + const functions = rows.map((r) => normalizeSymbol(r, db, hc)); + const base = { count: functions.length, functions }; + return paginateResult(base, 'functions', { limit: opts.limit, offset: opts.offset }); + } finally { + db.close(); + } +} + +export function childrenData(name, customDbPath, opts = {}) { + const db = openReadonlyOrFail(customDbPath); + try { + const noTests = opts.noTests || false; + + const nodes = findMatchingNodes(db, name, { noTests, file: opts.file, kind: opts.kind }); + if (nodes.length === 0) { + return { name, results: [] }; + } + + const results = nodes.map((node) => { + let children; + try { + children = findNodeChildren(db, node.id); + } catch (e) { + debug(`findNodeChildren failed for node ${node.id}: ${e.message}`); + children = []; + } + if (noTests) children = children.filter((c) => !isTestFile(c.file || node.file)); + return { + name: node.name, + kind: node.kind, + file: node.file, + line: node.line, + scope: node.scope || null, + visibility: node.visibility || null, + qualifiedName: node.qualified_name || null, + children: children.map((c) => ({ + name: c.name, + kind: c.kind, + line: c.line, + endLine: c.end_line || null, + qualifiedName: c.qualified_name || null, + scope: c.scope || null, + visibility: c.visibility || null, + })), + }; + }); + + const base = { name, results }; + return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset }); + } finally { + db.close(); + } +} diff --git a/src/domain/parser.js b/src/domain/parser.js new file mode 100644 index 00000000..59a4a10c --- /dev/null +++ b/src/domain/parser.js @@ -0,0 +1,626 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Language, Parser, Query } from 'web-tree-sitter'; +import { debug, warn } from '../infrastructure/logger.js'; +import { getNative, getNativePackageVersion, loadNative } from '../infrastructure/native.js'; + +// Re-export all extractors for backward compatibility +export { + extractCSharpSymbols, + extractGoSymbols, + extractHCLSymbols, + extractJavaSymbols, + extractPHPSymbols, + extractPythonSymbols, + extractRubySymbols, + extractRustSymbols, + extractSymbols, +} from '../extractors/index.js'; + +import { + extractCSharpSymbols, + extractGoSymbols, + extractHCLSymbols, + extractJavaSymbols, + extractPHPSymbols, + extractPythonSymbols, + extractRubySymbols, + extractRustSymbols, + extractSymbols, +} from '../extractors/index.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +function grammarPath(name) { + return path.join(__dirname, '..', '..', 'grammars', name); +} + +let _initialized = false; + +// Memoized parsers — avoids reloading WASM grammars on every createParsers() call +let _cachedParsers = null; + +// Cached Language objects — WASM-backed, must be .delete()'d explicitly +let _cachedLanguages = null; + +// Query cache for JS/TS/TSX extractors (populated during createParsers) +const _queryCache = new Map(); + +// Shared patterns for all JS/TS/TSX (class_declaration excluded — name type differs) +const COMMON_QUERY_PATTERNS = [ + '(function_declaration name: (identifier) @fn_name) @fn_node', + '(variable_declarator name: (identifier) @varfn_name value: (arrow_function) @varfn_value)', + '(variable_declarator name: (identifier) @varfn_name value: (function_expression) @varfn_value)', + '(method_definition name: (property_identifier) @meth_name) @meth_node', + '(import_statement source: (string) @imp_source) @imp_node', + '(export_statement) @exp_node', + '(call_expression function: (identifier) @callfn_name) @callfn_node', + '(call_expression function: (member_expression) @callmem_fn) @callmem_node', + '(call_expression function: (subscript_expression) @callsub_fn) @callsub_node', + '(expression_statement (assignment_expression left: (member_expression) @assign_left right: (_) @assign_right)) @assign_node', +]; + +// JS: class name is (identifier) +const JS_CLASS_PATTERN = '(class_declaration name: (identifier) @cls_name) @cls_node'; + +// TS/TSX: class name is (type_identifier), plus interface and type alias +const TS_EXTRA_PATTERNS = [ + '(class_declaration name: (type_identifier) @cls_name) @cls_node', + '(interface_declaration name: (type_identifier) @iface_name) @iface_node', + '(type_alias_declaration name: (type_identifier) @type_name) @type_node', +]; + +export async function createParsers() { + if (_cachedParsers) return _cachedParsers; + + if (!_initialized) { + await Parser.init(); + _initialized = true; + } + + const parsers = new Map(); + const languages = new Map(); + for (const entry of LANGUAGE_REGISTRY) { + try { + const lang = await Language.load(grammarPath(entry.grammarFile)); + const parser = new Parser(); + parser.setLanguage(lang); + parsers.set(entry.id, parser); + languages.set(entry.id, lang); + // Compile and cache tree-sitter Query for JS/TS/TSX extractors + if (entry.extractor === extractSymbols && !_queryCache.has(entry.id)) { + const isTS = entry.id === 'typescript' || entry.id === 'tsx'; + const patterns = isTS + ? [...COMMON_QUERY_PATTERNS, ...TS_EXTRA_PATTERNS] + : [...COMMON_QUERY_PATTERNS, JS_CLASS_PATTERN]; + _queryCache.set(entry.id, new Query(lang, patterns.join('\n'))); + } + } catch (e) { + if (entry.required) throw e; + warn( + `${entry.id} parser failed to initialize: ${e.message}. ${entry.id} files will be skipped.`, + ); + parsers.set(entry.id, null); + } + } + _cachedParsers = parsers; + _cachedLanguages = languages; + return parsers; +} + +/** + * Dispose all cached WASM parsers and queries to free WASM linear memory. + * Call this between repeated builds in the same process (e.g. benchmarks) + * to prevent memory accumulation that can cause segfaults. + */ +export function disposeParsers() { + if (_cachedParsers) { + for (const [id, parser] of _cachedParsers) { + if (parser && typeof parser.delete === 'function') { + try { + parser.delete(); + } catch (e) { + debug(`Failed to dispose parser ${id}: ${e.message}`); + } + } + } + _cachedParsers = null; + } + for (const [id, query] of _queryCache) { + if (query && typeof query.delete === 'function') { + try { + query.delete(); + } catch (e) { + debug(`Failed to dispose query ${id}: ${e.message}`); + } + } + } + _queryCache.clear(); + if (_cachedLanguages) { + for (const [id, lang] of _cachedLanguages) { + if (lang && typeof lang.delete === 'function') { + try { + lang.delete(); + } catch (e) { + debug(`Failed to dispose language ${id}: ${e.message}`); + } + } + } + _cachedLanguages = null; + } + _initialized = false; +} + +export function getParser(parsers, filePath) { + const ext = path.extname(filePath); + const entry = _extToLang.get(ext); + if (!entry) return null; + return parsers.get(entry.id) || null; +} + +/** + * Pre-parse files missing `_tree` via WASM so downstream phases (CFG, dataflow) + * don't each need to create parsers and re-parse independently. + * Only parses files whose extension is in SUPPORTED_EXTENSIONS. + * + * @param {Map} fileSymbols - Map + * @param {string} rootDir - absolute project root + */ +export async function ensureWasmTrees(fileSymbols, rootDir) { + // Check if any file needs a tree + let needsParse = false; + for (const [relPath, symbols] of fileSymbols) { + if (!symbols._tree) { + const ext = path.extname(relPath).toLowerCase(); + if (_extToLang.has(ext)) { + needsParse = true; + break; + } + } + } + if (!needsParse) return; + + const parsers = await createParsers(); + + for (const [relPath, symbols] of fileSymbols) { + if (symbols._tree) continue; + const ext = path.extname(relPath).toLowerCase(); + const entry = _extToLang.get(ext); + if (!entry) continue; + const parser = parsers.get(entry.id); + if (!parser) continue; + + const absPath = path.join(rootDir, relPath); + let code; + try { + code = fs.readFileSync(absPath, 'utf-8'); + } catch (e) { + debug(`ensureWasmTrees: cannot read ${relPath}: ${e.message}`); + continue; + } + try { + symbols._tree = parser.parse(code); + symbols._langId = entry.id; + } catch (e) { + debug(`ensureWasmTrees: parse failed for ${relPath}: ${e.message}`); + } + } +} + +/** + * Check whether the required WASM grammar files exist on disk. + */ +export function isWasmAvailable() { + return LANGUAGE_REGISTRY.filter((e) => e.required).every((e) => + fs.existsSync(grammarPath(e.grammarFile)), + ); +} + +// ── Unified API ────────────────────────────────────────────────────────────── + +function resolveEngine(opts = {}) { + const pref = opts.engine || 'auto'; + if (pref === 'wasm') return { name: 'wasm', native: null }; + if (pref === 'native' || pref === 'auto') { + const native = loadNative(); + if (native) return { name: 'native', native }; + if (pref === 'native') { + getNative(); // throws with detailed error + install instructions + } + } + return { name: 'wasm', native: null }; +} + +/** + * Patch native engine output in-place for the few remaining semantic transforms. + * With #[napi(js_name)] on Rust types, most fields already arrive as camelCase. + * This only handles: + * - _lineCount compat for builder.js + * - Backward compat for older native binaries missing js_name annotations + * - dataflow argFlows/mutations bindingType → binding wrapper + */ +function patchNativeResult(r) { + // lineCount: napi(js_name) emits "lineCount"; older binaries may emit "line_count" + r.lineCount = r.lineCount ?? r.line_count ?? null; + r._lineCount = r.lineCount; + + // Backward compat for older binaries missing js_name annotations + if (r.definitions) { + for (const d of r.definitions) { + if (d.endLine === undefined && d.end_line !== undefined) { + d.endLine = d.end_line; + } + } + } + if (r.imports) { + for (const i of r.imports) { + if (i.typeOnly === undefined) i.typeOnly = i.type_only; + if (i.wildcardReexport === undefined) i.wildcardReexport = i.wildcard_reexport; + if (i.pythonImport === undefined) i.pythonImport = i.python_import; + if (i.goImport === undefined) i.goImport = i.go_import; + if (i.rustUse === undefined) i.rustUse = i.rust_use; + if (i.javaImport === undefined) i.javaImport = i.java_import; + if (i.csharpUsing === undefined) i.csharpUsing = i.csharp_using; + if (i.rubyRequire === undefined) i.rubyRequire = i.ruby_require; + if (i.phpUse === undefined) i.phpUse = i.php_use; + if (i.dynamicImport === undefined) i.dynamicImport = i.dynamic_import; + } + } + + // dataflow: wrap bindingType into binding object for argFlows and mutations + if (r.dataflow) { + if (r.dataflow.argFlows) { + for (const f of r.dataflow.argFlows) { + f.binding = f.bindingType ? { type: f.bindingType } : null; + } + } + if (r.dataflow.mutations) { + for (const m of r.dataflow.mutations) { + m.binding = m.bindingType ? { type: m.bindingType } : null; + } + } + } + + return r; +} + +/** + * Declarative registry of all supported languages. + * Adding a new language requires only a new entry here + its extractor function. + */ +export const LANGUAGE_REGISTRY = [ + { + id: 'javascript', + extensions: ['.js', '.jsx', '.mjs', '.cjs'], + grammarFile: 'tree-sitter-javascript.wasm', + extractor: extractSymbols, + required: true, + }, + { + id: 'typescript', + extensions: ['.ts'], + grammarFile: 'tree-sitter-typescript.wasm', + extractor: extractSymbols, + required: true, + }, + { + id: 'tsx', + extensions: ['.tsx'], + grammarFile: 'tree-sitter-tsx.wasm', + extractor: extractSymbols, + required: true, + }, + { + id: 'hcl', + extensions: ['.tf', '.hcl'], + grammarFile: 'tree-sitter-hcl.wasm', + extractor: extractHCLSymbols, + required: false, + }, + { + id: 'python', + extensions: ['.py', '.pyi'], + grammarFile: 'tree-sitter-python.wasm', + extractor: extractPythonSymbols, + required: false, + }, + { + id: 'go', + extensions: ['.go'], + grammarFile: 'tree-sitter-go.wasm', + extractor: extractGoSymbols, + required: false, + }, + { + id: 'rust', + extensions: ['.rs'], + grammarFile: 'tree-sitter-rust.wasm', + extractor: extractRustSymbols, + required: false, + }, + { + id: 'java', + extensions: ['.java'], + grammarFile: 'tree-sitter-java.wasm', + extractor: extractJavaSymbols, + required: false, + }, + { + id: 'csharp', + extensions: ['.cs'], + grammarFile: 'tree-sitter-c_sharp.wasm', + extractor: extractCSharpSymbols, + required: false, + }, + { + id: 'ruby', + extensions: ['.rb', '.rake', '.gemspec'], + grammarFile: 'tree-sitter-ruby.wasm', + extractor: extractRubySymbols, + required: false, + }, + { + id: 'php', + extensions: ['.php', '.phtml'], + grammarFile: 'tree-sitter-php.wasm', + extractor: extractPHPSymbols, + required: false, + }, +]; + +const _extToLang = new Map(); +for (const entry of LANGUAGE_REGISTRY) { + for (const ext of entry.extensions) { + _extToLang.set(ext, entry); + } +} + +export const SUPPORTED_EXTENSIONS = new Set(_extToLang.keys()); + +/** + * WASM-based typeMap backfill for older native binaries that don't emit typeMap. + * Uses tree-sitter AST extraction instead of regex to avoid false positives from + * matches inside comments and string literals. + * TODO: Remove once all published native binaries include typeMap extraction (>= 3.2.0) + */ +async function backfillTypeMap(filePath, source) { + let code = source; + if (!code) { + try { + code = fs.readFileSync(filePath, 'utf-8'); + } catch { + return { typeMap: [], backfilled: false }; + } + } + const parsers = await createParsers(); + const extracted = wasmExtractSymbols(parsers, filePath, code); + try { + if (!extracted?.symbols?.typeMap) { + return { typeMap: [], backfilled: false }; + } + const tm = extracted.symbols.typeMap; + return { + typeMap: tm instanceof Map ? tm : new Map(tm.map((e) => [e.name, e.typeName])), + backfilled: true, + }; + } finally { + // Free the WASM tree to prevent memory accumulation across repeated builds + if (extracted?.tree && typeof extracted.tree.delete === 'function') { + try { + extracted.tree.delete(); + } catch {} + } + } +} + +/** + * WASM extraction helper: picks the right extractor based on file extension. + */ +function wasmExtractSymbols(parsers, filePath, code) { + const parser = getParser(parsers, filePath); + if (!parser) return null; + + let tree; + try { + tree = parser.parse(code); + } catch (e) { + warn(`Parse error in ${filePath}: ${e.message}`); + return null; + } + + const ext = path.extname(filePath); + const entry = _extToLang.get(ext); + if (!entry) return null; + const query = _queryCache.get(entry.id) || null; + const symbols = entry.extractor(tree, filePath, query); + return symbols ? { symbols, tree, langId: entry.id } : null; +} + +/** + * Parse a single file and return normalized symbols. + * + * @param {string} filePath Absolute path to the file. + * @param {string} source Source code string. + * @param {object} [opts] Options: { engine: 'native'|'wasm'|'auto' } + * @returns {Promise<{definitions, calls, imports, classes, exports}|null>} + */ +export async function parseFileAuto(filePath, source, opts = {}) { + const { native } = resolveEngine(opts); + + if (native) { + const result = native.parseFile(filePath, source, !!opts.dataflow, opts.ast !== false); + if (!result) return null; + const patched = patchNativeResult(result); + // Only backfill typeMap for TS/TSX — JS files have no type annotations, + // and the native engine already handles `new Expr()` patterns. + const TS_BACKFILL_EXTS = new Set(['.ts', '.tsx']); + if ( + (!patched.typeMap || patched.typeMap.length === 0) && + TS_BACKFILL_EXTS.has(path.extname(filePath)) + ) { + const { typeMap, backfilled } = await backfillTypeMap(filePath, source); + patched.typeMap = typeMap; + if (backfilled) patched._typeMapBackfilled = true; + } + return patched; + } + + // WASM path + const parsers = await createParsers(); + const extracted = wasmExtractSymbols(parsers, filePath, source); + return extracted ? extracted.symbols : null; +} + +/** + * Parse multiple files in bulk and return a Map. + * + * @param {string[]} filePaths Absolute paths to files. + * @param {string} rootDir Project root for computing relative paths. + * @param {object} [opts] Options: { engine: 'native'|'wasm'|'auto' } + * @returns {Promise>} + */ +export async function parseFilesAuto(filePaths, rootDir, opts = {}) { + const { native } = resolveEngine(opts); + const result = new Map(); + + if (native) { + const nativeResults = native.parseFiles( + filePaths, + rootDir, + !!opts.dataflow, + opts.ast !== false, + ); + const needsTypeMap = []; + for (const r of nativeResults) { + if (!r) continue; + const patched = patchNativeResult(r); + const relPath = path.relative(rootDir, r.file).split(path.sep).join('/'); + result.set(relPath, patched); + if (!patched.typeMap || patched.typeMap.length === 0) { + needsTypeMap.push({ filePath: r.file, relPath }); + } + } + // Backfill typeMap via WASM for native binaries that predate the type-map feature + if (needsTypeMap.length > 0) { + // Only backfill for languages where WASM extraction can produce typeMap + // (TS/TSX have type annotations; JS only has `new Expr()` which native already handles) + const TS_EXTS = new Set(['.ts', '.tsx']); + const tsFiles = needsTypeMap.filter(({ filePath }) => TS_EXTS.has(path.extname(filePath))); + if (tsFiles.length > 0) { + const parsers = await createParsers(); + for (const { filePath, relPath } of tsFiles) { + let extracted; + try { + const code = fs.readFileSync(filePath, 'utf-8'); + extracted = wasmExtractSymbols(parsers, filePath, code); + if (extracted?.symbols?.typeMap) { + const symbols = result.get(relPath); + symbols.typeMap = + extracted.symbols.typeMap instanceof Map + ? extracted.symbols.typeMap + : new Map(extracted.symbols.typeMap.map((e) => [e.name, e.typeName])); + symbols._typeMapBackfilled = true; + } + } catch { + /* skip — typeMap is a best-effort backfill */ + } finally { + // Free the WASM tree to prevent memory accumulation across repeated builds + if (extracted?.tree && typeof extracted.tree.delete === 'function') { + try { + extracted.tree.delete(); + } catch {} + } + } + } + } + } + return result; + } + + // WASM path + const parsers = await createParsers(); + for (const filePath of filePaths) { + let code; + try { + code = fs.readFileSync(filePath, 'utf-8'); + } catch (err) { + warn(`Skipping ${path.relative(rootDir, filePath)}: ${err.message}`); + continue; + } + const extracted = wasmExtractSymbols(parsers, filePath, code); + if (extracted) { + const relPath = path.relative(rootDir, filePath).split(path.sep).join('/'); + extracted.symbols._tree = extracted.tree; + extracted.symbols._langId = extracted.langId; + extracted.symbols._lineCount = code.split('\n').length; + result.set(relPath, extracted.symbols); + } + } + return result; +} + +/** + * Report which engine is active. + * + * @param {object} [opts] Options: { engine: 'native'|'wasm'|'auto' } + * @returns {{ name: 'native'|'wasm', version: string|null }} + */ +export function getActiveEngine(opts = {}) { + const { name, native } = resolveEngine(opts); + let version = native + ? typeof native.engineVersion === 'function' + ? native.engineVersion() + : null + : null; + // Prefer platform package.json version over binary-embedded version + // to handle stale binaries that weren't recompiled during a release + if (native) { + try { + version = getNativePackageVersion() ?? version; + } catch (e) { + debug(`getNativePackageVersion failed: ${e.message}`); + } + } + return { name, version }; +} + +/** + * Create a native ParseTreeCache for incremental parsing. + * Returns null if the native engine is unavailable (WASM fallback). + */ +export function createParseTreeCache() { + const native = loadNative(); + if (!native || !native.ParseTreeCache) return null; + return new native.ParseTreeCache(); +} + +/** + * Parse a file incrementally using the cache, or fall back to full parse. + * + * @param {object|null} cache ParseTreeCache instance (or null for full parse) + * @param {string} filePath Absolute path to the file + * @param {string} source Source code string + * @param {object} [opts] Options forwarded to parseFileAuto on fallback + * @returns {Promise<{definitions, calls, imports, classes, exports}|null>} + */ +export async function parseFileIncremental(cache, filePath, source, opts = {}) { + if (cache) { + const result = cache.parseFile(filePath, source); + if (!result) return null; + const patched = patchNativeResult(result); + // Only backfill typeMap for TS/TSX — JS files have no type annotations, + // and the native engine already handles `new Expr()` patterns. + const TS_BACKFILL_EXTS = new Set(['.ts', '.tsx']); + if ( + (!patched.typeMap || patched.typeMap.length === 0) && + TS_BACKFILL_EXTS.has(path.extname(filePath)) + ) { + const { typeMap, backfilled } = await backfillTypeMap(filePath, source); + patched.typeMap = typeMap; + if (backfilled) patched._typeMapBackfilled = true; + } + return patched; + } + return parseFileAuto(filePath, source, opts); +} From 1e77de0f5e7ae398d55001332db5d05c20bf2f50 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sat, 21 Mar 2026 04:56:46 -0600 Subject: [PATCH 04/18] fix: drop misleading underscore prefix on used variables in node-version helper --- tests/helpers/node-version.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/helpers/node-version.js b/tests/helpers/node-version.js index 314f91db..f8603a82 100644 --- a/tests/helpers/node-version.js +++ b/tests/helpers/node-version.js @@ -2,5 +2,5 @@ * Node >= 22.6 supports --experimental-strip-types, required for tests that * spawn child processes loading .ts source files directly. */ -const [_major, _minor] = process.versions.node.split('.').map(Number); -export const canStripTypes = _major > 22 || (_major === 22 && _minor >= 6); +const [major, minor] = process.versions.node.split('.').map(Number); +export const canStripTypes = major > 22 || (major === 22 && minor >= 6); From 1574c1e287590036f5083428a6f78a61e0969eef Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sat, 21 Mar 2026 04:56:56 -0600 Subject: [PATCH 05/18] fix: add Statement.raw() to vendor types and remove unnecessary as-any cast Impact: 2 functions changed, 1 affected --- src/domain/analysis/exports.ts | 3 +-- src/vendor.d.ts | 1 + 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/domain/analysis/exports.ts b/src/domain/analysis/exports.ts index e64a756f..42f0861c 100644 --- a/src/domain/analysis/exports.ts +++ b/src/domain/analysis/exports.ts @@ -114,8 +114,7 @@ function exportsFileImpl( // Detect whether exported column exists let hasExportedCol = false; try { - // biome-ignore lint/suspicious/noExplicitAny: Statement.raw() exists at runtime but is missing from type declarations - (db.prepare('SELECT exported FROM nodes LIMIT 0') as any).raw(true); + db.prepare('SELECT exported FROM nodes LIMIT 0').raw(true); hasExportedCol = true; } catch (e: unknown) { debug(`exported column not available, using fallback: ${(e as Error).message}`); diff --git a/src/vendor.d.ts b/src/vendor.d.ts index 9edc233b..e8c49fdf 100644 --- a/src/vendor.d.ts +++ b/src/vendor.d.ts @@ -19,6 +19,7 @@ declare module 'better-sqlite3' { get(...params: unknown[]): unknown | undefined; all(...params: unknown[]): unknown[]; iterate(...params: unknown[]): IterableIterator; + raw(toggle?: boolean): this; } interface RunResult { From 41c6f4d95e2a916e22e5b98e774af8d2cfbdef81 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sat, 21 Mar 2026 04:57:07 -0600 Subject: [PATCH 06/18] fix: move inline SQL queries in context.ts behind db repository layer Impact: 3 functions changed, 2 affected --- src/db/index.js | 2 ++ src/db/repository/index.js | 2 ++ src/db/repository/nodes.js | 34 ++++++++++++++++++++++++++++++++++ src/domain/analysis/context.ts | 10 ++++------ 4 files changed, 42 insertions(+), 6 deletions(-) diff --git a/src/db/index.js b/src/db/index.js index 7d938e1d..1f657a83 100644 --- a/src/db/index.js +++ b/src/db/index.js @@ -56,6 +56,8 @@ export { getFileNodesAll, getFunctionNodeId, getImportEdges, + getLineCountForNode, + getMaxEndLineForFile, getNodeId, hasCfgTables, hasCoChanges, diff --git a/src/db/repository/index.js b/src/db/repository/index.js index b96618b8..94a8cfab 100644 --- a/src/db/repository/index.js +++ b/src/db/repository/index.js @@ -42,6 +42,8 @@ export { findNodesForTriage, findNodesWithFanIn, getFunctionNodeId, + getLineCountForNode, + getMaxEndLineForFile, getNodeId, iterateFunctionNodes, listFunctionNodes, diff --git a/src/db/repository/nodes.js b/src/db/repository/nodes.js index ffcf6297..dc78d41d 100644 --- a/src/db/repository/nodes.js +++ b/src/db/repository/nodes.js @@ -297,3 +297,37 @@ export function findNodeByQualifiedName(db, qualifiedName, opts = {}) { 'SELECT * FROM nodes WHERE qualified_name = ? ORDER BY file, line', ).all(qualifiedName); } + +// ─── Metric helpers ────────────────────────────────────────────────────── + +const _getLineCountForNodeStmt = new WeakMap(); + +/** + * Get line_count from node_metrics for a given node. + * @param {object} db + * @param {number} nodeId + * @returns {{ line_count: number } | undefined} + */ +export function getLineCountForNode(db, nodeId) { + return cachedStmt( + _getLineCountForNodeStmt, + db, + 'SELECT line_count FROM node_metrics WHERE node_id = ?', + ).get(nodeId); +} + +const _getMaxEndLineForFileStmt = new WeakMap(); + +/** + * Get the maximum end_line across all nodes in a file. + * @param {object} db + * @param {string} file + * @returns {{ max_end: number | null } | undefined} + */ +export function getMaxEndLineForFile(db, file) { + return cachedStmt( + _getMaxEndLineForFileStmt, + db, + 'SELECT MAX(end_line) as max_end FROM nodes WHERE file = ?', + ).get(file); +} diff --git a/src/domain/analysis/context.ts b/src/domain/analysis/context.ts index ec328990..4def0465 100644 --- a/src/domain/analysis/context.ts +++ b/src/domain/analysis/context.ts @@ -14,6 +14,8 @@ import { findNodeChildren, findNodesByFile, getComplexityForNode, + getLineCountForNode, + getMaxEndLineForFile, openReadonlyOrFail, } from '../../db/index.js'; import { loadConfig } from '../../infrastructure/config.js'; @@ -276,14 +278,10 @@ function explainFileImpl( callees, })); - const metric = db - .prepare(`SELECT nm.line_count FROM node_metrics nm WHERE nm.node_id = ?`) - .get(fn.id) as { line_count: number } | undefined; + const metric = getLineCountForNode(db, fn.id) as { line_count: number } | undefined; let lineCount: number | null = metric?.line_count || null; if (!lineCount) { - const maxLine = db - .prepare(`SELECT MAX(end_line) as max_end FROM nodes WHERE file = ?`) - .get(fn.file) as { max_end: number | null } | undefined; + const maxLine = getMaxEndLineForFile(db, fn.file) as { max_end: number | null } | undefined; lineCount = maxLine?.max_end || null; } From aa2a7d021a937c4c923f699da2dc4f2c0563bef7 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sat, 21 Mar 2026 04:59:23 -0600 Subject: [PATCH 07/18] fix: align customDbPath type to string in implementations.ts Impact: 2 functions changed, 0 affected --- src/domain/analysis/implementations.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/domain/analysis/implementations.ts b/src/domain/analysis/implementations.ts index c50b9bd9..7d49a9c5 100644 --- a/src/domain/analysis/implementations.ts +++ b/src/domain/analysis/implementations.ts @@ -11,7 +11,7 @@ import { findMatchingNodes } from './symbol-lookup.js'; */ export function implementationsData( name: string, - customDbPath: string | undefined, + customDbPath: string, opts: { noTests?: boolean; file?: string; kind?: string; limit?: number; offset?: number } = {}, ) { const db = openReadonlyOrFail(customDbPath); @@ -56,7 +56,7 @@ export function implementationsData( */ export function interfacesData( name: string, - customDbPath: string | undefined, + customDbPath: string, opts: { noTests?: boolean; file?: string; kind?: string; limit?: number; offset?: number } = {}, ) { const db = openReadonlyOrFail(customDbPath); From 208e30f0c4e07142392b6ce700fe3d7470ceea7e Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sat, 21 Mar 2026 04:59:33 -0600 Subject: [PATCH 08/18] fix: eliminate N+1 re-query in buildTransitiveCallers by passing caller IDs directly Impact: 1 functions changed, 1 affected --- src/domain/analysis/dependencies.ts | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/src/domain/analysis/dependencies.ts b/src/domain/analysis/dependencies.ts index 52c78be6..da0a9c54 100644 --- a/src/domain/analysis/dependencies.ts +++ b/src/domain/analysis/dependencies.ts @@ -58,7 +58,7 @@ export function fileDepsData( */ function buildTransitiveCallers( db: BetterSqlite3.Database, - callers: Array<{ name: string; kind: string; file: string; line: number }>, + callers: Array<{ id: number; name: string; kind: string; file: string; line: number }>, nodeId: number, depth: number, noTests: boolean, @@ -70,20 +70,7 @@ function buildTransitiveCallers( if (depth <= 1) return transitiveCallers; const visited = new Set([nodeId]); - let frontier = callers - .map((c) => { - const row = db - .prepare('SELECT id FROM nodes WHERE name = ? AND kind = ? AND file = ? AND line = ?') - .get(c.name, c.kind, c.file, c.line) as { id: number } | undefined; - return row ? { ...c, id: row.id } : null; - }) - .filter(Boolean) as Array<{ - name: string; - kind: string; - file: string; - line: number; - id: number; - }>; + let frontier = callers; for (let d = 2; d <= depth; d++) { const nextFrontier: typeof frontier = []; From 3e3cb501b46fbe62a4c870da1ccf424b233fbf84 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sat, 21 Mar 2026 05:19:41 -0600 Subject: [PATCH 09/18] fix: hoist db.prepare() out of BFS loops and include n.id in SELECT to eliminate extra roundtrip Impact: 1 functions changed, 1 affected --- src/domain/analysis/dependencies.ts | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/src/domain/analysis/dependencies.ts b/src/domain/analysis/dependencies.ts index da0a9c54..ab924e16 100644 --- a/src/domain/analysis/dependencies.ts +++ b/src/domain/analysis/dependencies.ts @@ -72,27 +72,28 @@ function buildTransitiveCallers( const visited = new Set([nodeId]); let frontier = callers; + const upstreamStmt = db.prepare(` + SELECT n.id, n.name, n.kind, n.file, n.line + FROM edges e JOIN nodes n ON e.source_id = n.id + WHERE e.target_id = ? AND e.kind = 'calls' + `); + for (let d = 2; d <= depth; d++) { const nextFrontier: typeof frontier = []; for (const f of frontier) { if (visited.has(f.id)) continue; visited.add(f.id); - const upstream = db - .prepare(` - SELECT n.name, n.kind, n.file, n.line - FROM edges e JOIN nodes n ON e.source_id = n.id - WHERE e.target_id = ? AND e.kind = 'calls' - `) - .all(f.id) as Array<{ name: string; kind: string; file: string; line: number }>; + const upstream = upstreamStmt.all(f.id) as Array<{ + id: number; + name: string; + kind: string; + file: string; + line: number; + }>; for (const u of upstream) { if (noTests && isTestFile(u.file)) continue; - const uid = ( - db - .prepare('SELECT id FROM nodes WHERE name = ? AND kind = ? AND file = ? AND line = ?') - .get(u.name, u.kind, u.file, u.line) as { id: number } | undefined - )?.id; - if (uid && !visited.has(uid)) { - nextFrontier.push({ ...u, id: uid }); + if (!visited.has(u.id)) { + nextFrontier.push(u); } } } From b3fc6d54a4827bdbf43ad3e028f127fa934bdf8e Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sat, 21 Mar 2026 05:19:52 -0600 Subject: [PATCH 10/18] fix: promote TS_BACKFILL_EXTS to module-level constant, remove 3 duplicate inline Set constructions Impact: 3 functions changed, 1 affected --- src/domain/parser.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/domain/parser.ts b/src/domain/parser.ts index a8e3b8d7..8fd1d2e1 100644 --- a/src/domain/parser.ts +++ b/src/domain/parser.ts @@ -48,6 +48,9 @@ let _cachedLanguages: Map | null = null; // Query cache for JS/TS/TSX extractors (populated during createParsers) const _queryCache: Map = new Map(); +// Extensions that need typeMap backfill (type annotations only exist in TS/TSX) +const TS_BACKFILL_EXTS = new Set(['.ts', '.tsx']); + /** * Declarative registry entry for a supported language. */ @@ -502,7 +505,6 @@ export async function parseFileAuto( const patched = patchNativeResult(result); // Only backfill typeMap for TS/TSX — JS files have no type annotations, // and the native engine already handles `new Expr()` patterns. - const TS_BACKFILL_EXTS = new Set(['.ts', '.tsx']); if ( (!patched.typeMap || patched.typeMap.length === 0) && TS_BACKFILL_EXTS.has(path.extname(filePath)) @@ -554,8 +556,7 @@ export async function parseFilesAuto( if (needsTypeMap.length > 0) { // Only backfill for languages where WASM extraction can produce typeMap // (TS/TSX have type annotations; JS only has `new Expr()` which native already handles) - const TS_EXTS = new Set(['.ts', '.tsx']); - const tsFiles = needsTypeMap.filter(({ filePath }) => TS_EXTS.has(path.extname(filePath))); + const tsFiles = needsTypeMap.filter(({ filePath }) => TS_BACKFILL_EXTS.has(path.extname(filePath))); if (tsFiles.length > 0) { const parsers = await createParsers(); for (const { filePath, relPath } of tsFiles) { @@ -666,7 +667,6 @@ export async function parseFileIncremental( const patched = patchNativeResult(result); // Only backfill typeMap for TS/TSX — JS files have no type annotations, // and the native engine already handles `new Expr()` patterns. - const TS_BACKFILL_EXTS = new Set(['.ts', '.tsx']); if ( (!patched.typeMap || patched.typeMap.length === 0) && TS_BACKFILL_EXTS.has(path.extname(filePath)) From 7a86aa19bd2ae88170eecfe5667a2a89e6b5fe6b Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sat, 21 Mar 2026 05:20:19 -0600 Subject: [PATCH 11/18] chore: update package-lock.json --- package-lock.json | 100 ---------------------------------------------- 1 file changed, 100 deletions(-) diff --git a/package-lock.json b/package-lock.json index ce3d717f..857af3af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,8 +11,6 @@ "dependencies": { "better-sqlite3": "^12.6.2", "commander": "^14.0.3", - "graphology": "^0.26.0", - "graphology-communities-louvain": "^2.0.2", "web-tree-sitter": "^0.26.5" }, "bin": { @@ -1277,9 +1275,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1293,9 +1288,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1309,9 +1301,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -3598,15 +3587,6 @@ "node": ">= 0.6" } }, - "node_modules/events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "license": "MIT", - "engines": { - "node": ">=0.8.x" - } - }, "node_modules/eventsource": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", @@ -4169,62 +4149,6 @@ "dev": true, "license": "ISC" }, - "node_modules/graphology": { - "version": "0.26.0", - "resolved": "https://registry.npmjs.org/graphology/-/graphology-0.26.0.tgz", - "integrity": "sha512-8SSImzgUUYC89Z042s+0r/vMibY7GX/Emz4LDO5e7jYXhuoWfHISPFJYjpRLUSJGq6UQ6xlenvX1p/hJdfXuXg==", - "license": "MIT", - "dependencies": { - "events": "^3.3.0" - }, - "peerDependencies": { - "graphology-types": ">=0.24.0" - } - }, - "node_modules/graphology-communities-louvain": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/graphology-communities-louvain/-/graphology-communities-louvain-2.0.2.tgz", - "integrity": "sha512-zt+2hHVPYxjEquyecxWXoUoIuN/UvYzsvI7boDdMNz0rRvpESQ7+e+Ejv6wK7AThycbZXuQ6DkG8NPMCq6XwoA==", - "license": "MIT", - "dependencies": { - "graphology-indices": "^0.17.0", - "graphology-utils": "^2.4.4", - "mnemonist": "^0.39.0", - "pandemonium": "^2.4.1" - }, - "peerDependencies": { - "graphology-types": ">=0.19.0" - } - }, - "node_modules/graphology-indices": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/graphology-indices/-/graphology-indices-0.17.0.tgz", - "integrity": "sha512-A7RXuKQvdqSWOpn7ZVQo4S33O0vCfPBnUSf7FwE0zNCasqwZVUaCXePuWo5HBpWw68KJcwObZDHpFk6HKH6MYQ==", - "license": "MIT", - "dependencies": { - "graphology-utils": "^2.4.2", - "mnemonist": "^0.39.0" - }, - "peerDependencies": { - "graphology-types": ">=0.20.0" - } - }, - "node_modules/graphology-types": { - "version": "0.24.8", - "resolved": "https://registry.npmjs.org/graphology-types/-/graphology-types-0.24.8.tgz", - "integrity": "sha512-hDRKYXa8TsoZHjgEaysSRyPdT6uB78Ci8WnjgbStlQysz7xR52PInxNsmnB7IBOM1BhikxkNyCVEFgmPKnpx3Q==", - "license": "MIT", - "peer": true - }, - "node_modules/graphology-utils": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/graphology-utils/-/graphology-utils-2.5.2.tgz", - "integrity": "sha512-ckHg8MXrXJkOARk56ZaSCM1g1Wihe2d6iTmz1enGOz4W/l831MBCKSayeFQfowgF8wd+PQ4rlch/56Vs/VZLDQ==", - "license": "MIT", - "peerDependencies": { - "graphology-types": ">=0.23.0" - } - }, "node_modules/guid-typescript": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz", @@ -5507,15 +5431,6 @@ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "license": "MIT" }, - "node_modules/mnemonist": { - "version": "0.39.8", - "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.8.tgz", - "integrity": "sha512-vyWo2K3fjrUw8YeeZ1zF0fy6Mu59RHokURlld8ymdUPjMlD9EC9ov1/YPqTgqRvUN9nTr3Gqfz29LYAmu0PHPQ==", - "license": "MIT", - "dependencies": { - "obliterator": "^2.0.1" - } - }, "node_modules/modify-values": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/modify-values/-/modify-values-1.0.1.tgz", @@ -5658,12 +5573,6 @@ "node": ">= 0.4" } }, - "node_modules/obliterator": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz", - "integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==", - "license": "MIT" - }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -5754,15 +5663,6 @@ "node": ">=6" } }, - "node_modules/pandemonium": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/pandemonium/-/pandemonium-2.4.1.tgz", - "integrity": "sha512-wRqjisUyiUfXowgm7MFH2rwJzKIr20rca5FsHXCMNm1W5YPP1hCtrZfgmQ62kP7OZ7Xt+cR858aB28lu5NX55g==", - "license": "MIT", - "dependencies": { - "mnemonist": "^0.39.2" - } - }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", From 59cc1e1a4dc4f41a7244a5e6a99ed00f6b8c15e7 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sat, 21 Mar 2026 19:37:35 -0600 Subject: [PATCH 12/18] fix: eliminate duplicate findCallers() call and hoist db.prepare() out of per-symbol closure context.ts: reuse allCallerRows for both callers list and relatedTests instead of calling findCallers(db, node.id) twice per iteration. exports.ts: hoist consumersStmt above buildSymbolResult closure so db.prepare() runs once per fileNode instead of once per exported symbol. --- src/domain/analysis/context.ts | 7 ++++--- src/domain/analysis/exports.ts | 12 ++++++------ 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/domain/analysis/context.ts b/src/domain/analysis/context.ts index 4def0465..c5715ca8 100644 --- a/src/domain/analysis/context.ts +++ b/src/domain/analysis/context.ts @@ -327,7 +327,9 @@ function explainFunctionImpl( line: c.line, })); - let callers = (findCallers(db, node.id) as RelatedNodeRow[]).map((c) => ({ + const allCallerRows = findCallers(db, node.id) as RelatedNodeRow[]; + + let callers = allCallerRows.map((c) => ({ name: c.name, kind: c.kind, file: c.file, @@ -335,9 +337,8 @@ function explainFunctionImpl( })); if (noTests) callers = callers.filter((c) => !isTestFile(c.file)); - const testCallerRows = findCallers(db, node.id) as RelatedNodeRow[]; const seenFiles = new Set(); - const relatedTests = testCallerRows + const relatedTests = allCallerRows .filter((r) => isTestFile(r.file) && !seenFiles.has(r.file) && seenFiles.add(r.file)) .map((r) => ({ file: r.file })); diff --git a/src/domain/analysis/exports.ts b/src/domain/analysis/exports.ts index 42f0861c..ba721887 100644 --- a/src/domain/analysis/exports.ts +++ b/src/domain/analysis/exports.ts @@ -138,13 +138,13 @@ function exportsFileImpl( } const internalCount = symbols.length - exported.length; - const buildSymbolResult = (s: NodeRow, fileLines: string[] | null) => { - let consumers = db - .prepare( - `SELECT n.name, n.file, n.line FROM edges e JOIN nodes n ON e.source_id = n.id + const consumersStmt = db.prepare( + `SELECT n.name, n.file, n.line FROM edges e JOIN nodes n ON e.source_id = n.id WHERE e.target_id = ? AND e.kind = 'calls'`, - ) - .all(s.id) as Array<{ name: string; file: string; line: number }>; + ); + + const buildSymbolResult = (s: NodeRow, fileLines: string[] | null) => { + let consumers = consumersStmt.all(s.id) as Array<{ name: string; file: string; line: number }>; if (noTests) consumers = consumers.filter((c) => !isTestFile(c.file)); return { From 204c3a608446c10b1c04886921121f720b6dfc61 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sat, 21 Mar 2026 19:43:16 -0600 Subject: [PATCH 13/18] fix: auto-format exports.ts and parser.ts --- src/domain/analysis/exports.ts | 6 +++++- src/domain/parser.ts | 4 +++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/domain/analysis/exports.ts b/src/domain/analysis/exports.ts index ba721887..d92c5ac4 100644 --- a/src/domain/analysis/exports.ts +++ b/src/domain/analysis/exports.ts @@ -144,7 +144,11 @@ function exportsFileImpl( ); const buildSymbolResult = (s: NodeRow, fileLines: string[] | null) => { - let consumers = consumersStmt.all(s.id) as Array<{ name: string; file: string; line: number }>; + let consumers = consumersStmt.all(s.id) as Array<{ + name: string; + file: string; + line: number; + }>; if (noTests) consumers = consumers.filter((c) => !isTestFile(c.file)); return { diff --git a/src/domain/parser.ts b/src/domain/parser.ts index 8fd1d2e1..5e5b43d1 100644 --- a/src/domain/parser.ts +++ b/src/domain/parser.ts @@ -556,7 +556,9 @@ export async function parseFilesAuto( if (needsTypeMap.length > 0) { // Only backfill for languages where WASM extraction can produce typeMap // (TS/TSX have type annotations; JS only has `new Expr()` which native already handles) - const tsFiles = needsTypeMap.filter(({ filePath }) => TS_BACKFILL_EXTS.has(path.extname(filePath))); + const tsFiles = needsTypeMap.filter(({ filePath }) => + TS_BACKFILL_EXTS.has(path.extname(filePath)), + ); if (tsFiles.length > 0) { const parsers = await createParsers(); for (const { filePath, relPath } of tsFiles) { From b4f39799bad1f207f3736f7040eef731a321cdb4 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sat, 21 Mar 2026 23:21:00 -0600 Subject: [PATCH 14/18] fix: hoist all db.prepare() calls out of fileNodes.map() loop in exports.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves exportedNodesStmt, consumersStmt, reexportsFromStmt, and reexportsToStmt before the fileNodes.map() callback. Eliminates O(fileNodes × reexportTargets) SQL compilations — now compiles each statement exactly once per exportsFileImpl call. --- src/domain/analysis/exports.ts | 51 +++++++++++++++------------------- 1 file changed, 22 insertions(+), 29 deletions(-) diff --git a/src/domain/analysis/exports.ts b/src/domain/analysis/exports.ts index d92c5ac4..18ed03fc 100644 --- a/src/domain/analysis/exports.ts +++ b/src/domain/analysis/exports.ts @@ -120,17 +120,29 @@ function exportsFileImpl( debug(`exported column not available, using fallback: ${(e as Error).message}`); } + const exportedNodesStmt = db.prepare( + "SELECT * FROM nodes WHERE file = ? AND kind != 'file' AND exported = 1 ORDER BY line", + ); + const consumersStmt = db.prepare( + `SELECT n.name, n.file, n.line FROM edges e JOIN nodes n ON e.source_id = n.id + WHERE e.target_id = ? AND e.kind = 'calls'`, + ); + const reexportsFromStmt = db.prepare( + `SELECT DISTINCT n.file FROM edges e JOIN nodes n ON e.source_id = n.id + WHERE e.target_id = ? AND e.kind = 'reexports'`, + ); + const reexportsToStmt = db.prepare( + `SELECT DISTINCT n.file FROM edges e JOIN nodes n ON e.target_id = n.id + WHERE e.source_id = ? AND e.kind = 'reexports'`, + ); + return fileNodes.map((fn) => { const symbols = findNodesByFile(db, fn.file) as NodeRow[]; let exported: NodeRow[]; if (hasExportedCol) { // Use the exported column populated during build - exported = db - .prepare( - "SELECT * FROM nodes WHERE file = ? AND kind != 'file' AND exported = 1 ORDER BY line", - ) - .all(fn.file) as NodeRow[]; + exported = exportedNodesStmt.all(fn.file) as NodeRow[]; } else { // Fallback: symbols that have incoming calls from other files const exportedIds = findCrossFileCallTargets(db, fn.file) as Set; @@ -138,11 +150,6 @@ function exportsFileImpl( } const internalCount = symbols.length - exported.length; - const consumersStmt = db.prepare( - `SELECT n.name, n.file, n.line FROM edges e JOIN nodes n ON e.source_id = n.id - WHERE e.target_id = ? AND e.kind = 'calls'`, - ); - const buildSymbolResult = (s: NodeRow, fileLines: string[] | null) => { let consumers = consumersStmt.all(s.id) as Array<{ name: string; @@ -169,33 +176,19 @@ function exportsFileImpl( const totalUnused = results.filter((r) => r.consumerCount === 0).length; // Files that re-export this file (barrel -> this file) - const reexports = ( - db - .prepare( - `SELECT DISTINCT n.file FROM edges e JOIN nodes n ON e.source_id = n.id - WHERE e.target_id = ? AND e.kind = 'reexports'`, - ) - .all(fn.id) as Array<{ file: string }> - ).map((r) => ({ file: r.file })); + const reexports = (reexportsFromStmt.all(fn.id) as Array<{ file: string }>).map((r) => ({ + file: r.file, + })); // For barrel files: gather symbols re-exported from target modules - const reexportTargets = db - .prepare( - `SELECT DISTINCT n.file FROM edges e JOIN nodes n ON e.target_id = n.id - WHERE e.source_id = ? AND e.kind = 'reexports'`, - ) - .all(fn.id) as Array<{ file: string }>; + const reexportTargets = reexportsToStmt.all(fn.id) as Array<{ file: string }>; const reexportedSymbols: Array & { originFile: string }> = []; for (const reexTarget of reexportTargets) { let targetExported: NodeRow[]; if (hasExportedCol) { - targetExported = db - .prepare( - "SELECT * FROM nodes WHERE file = ? AND kind != 'file' AND exported = 1 ORDER BY line", - ) - .all(reexTarget.file) as NodeRow[]; + targetExported = exportedNodesStmt.all(reexTarget.file) as NodeRow[]; } else { // Fallback: same heuristic as direct exports — symbols called from other files const targetSymbols = findNodesByFile(db, reexTarget.file) as NodeRow[]; From c85f05d3a914091582d55ca7a844a13f133a363f Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sun, 22 Mar 2026 00:01:00 -0600 Subject: [PATCH 15/18] fix: hoist db.prepare() calls and cache schema probes per Greptile P2 feedback - dependencies.ts: hoist db.prepare() out of getNode closure in reconstructPath - context.ts: use cachedStmt WeakMap pattern for explainFunctionImpl node query - exports.ts: cache exported-column schema probe with WeakMap per db handle Impact: 4 functions changed, 4 affected --- src/domain/analysis/context.ts | 12 +++++++----- src/domain/analysis/dependencies.ts | 3 ++- src/domain/analysis/exports.ts | 23 ++++++++++++++++------- 3 files changed, 25 insertions(+), 13 deletions(-) diff --git a/src/domain/analysis/context.ts b/src/domain/analysis/context.ts index c5715ca8..471d87eb 100644 --- a/src/domain/analysis/context.ts +++ b/src/domain/analysis/context.ts @@ -18,6 +18,7 @@ import { getMaxEndLineForFile, openReadonlyOrFail, } from '../../db/index.js'; +import { cachedStmt } from '../../db/repository/cached-stmt.js'; import { loadConfig } from '../../infrastructure/config.js'; import { debug } from '../../infrastructure/logger.js'; import { isTestFile } from '../../infrastructure/test-filter.js'; @@ -37,6 +38,7 @@ import type { IntraFileCallEdge, NodeRow, RelatedNodeRow, + StmtCache, } from '../../types.js'; import { findMatchingNodes } from './symbol-lookup.js'; @@ -298,6 +300,9 @@ function explainFileImpl( }); } +const _explainNodeStmtCache: StmtCache = new WeakMap(); +const _EXPLAIN_NODE_SQL = `SELECT * FROM nodes WHERE name LIKE ? AND kind IN ('function','method','class','interface','type','struct','enum','trait','record','module','constant') ORDER BY file, line`; + function explainFunctionImpl( db: BetterSqlite3.Database, target: string, @@ -305,11 +310,8 @@ function explainFunctionImpl( getFileLines: (file: string) => string[] | null, displayOpts: DisplayOpts, ) { - let nodes = db - .prepare( - `SELECT * FROM nodes WHERE name LIKE ? AND kind IN ('function','method','class','interface','type','struct','enum','trait','record','module','constant') ORDER BY file, line`, - ) - .all(`%${target}%`) as NodeRow[]; + const stmt = cachedStmt(_explainNodeStmtCache, db, _EXPLAIN_NODE_SQL); + let nodes = stmt.all(`%${target}%`) as NodeRow[]; if (noTests) nodes = nodes.filter((n) => !isTestFile(n.file)); if (nodes.length === 0) return []; diff --git a/src/domain/analysis/dependencies.ts b/src/domain/analysis/dependencies.ts index ab924e16..c320ee8f 100644 --- a/src/domain/analysis/dependencies.ts +++ b/src/domain/analysis/dependencies.ts @@ -330,9 +330,10 @@ function reconstructPath( parent: Map, ) { const nodeCache = new Map(); + const nodeByIdStmt = db.prepare('SELECT name, kind, file, line FROM nodes WHERE id = ?'); const getNode = (id: number) => { if (nodeCache.has(id)) return nodeCache.get(id)!; - const row = db.prepare('SELECT name, kind, file, line FROM nodes WHERE id = ?').get(id) as { + const row = nodeByIdStmt.get(id) as { name: string; kind: string; file: string; diff --git a/src/domain/analysis/exports.ts b/src/domain/analysis/exports.ts index 18ed03fc..180f13ba 100644 --- a/src/domain/analysis/exports.ts +++ b/src/domain/analysis/exports.ts @@ -18,6 +18,9 @@ import { import { paginateResult } from '../../shared/paginate.js'; import type { NodeRow } from '../../types.js'; +/** Cache the schema probe for the `exported` column per db handle. */ +const _hasExportedColCache: WeakMap = new WeakMap(); + export function exportsData( file: string, customDbPath: string, @@ -111,13 +114,19 @@ function exportsFileImpl( const fileNodes = findFileNodes(db, `%${target}%`) as NodeRow[]; if (fileNodes.length === 0) return []; - // Detect whether exported column exists - let hasExportedCol = false; - try { - db.prepare('SELECT exported FROM nodes LIMIT 0').raw(true); - hasExportedCol = true; - } catch (e: unknown) { - debug(`exported column not available, using fallback: ${(e as Error).message}`); + // Detect whether exported column exists (cached per db handle) + let hasExportedCol: boolean; + if (_hasExportedColCache.has(db)) { + hasExportedCol = _hasExportedColCache.get(db)!; + } else { + hasExportedCol = false; + try { + db.prepare('SELECT exported FROM nodes LIMIT 0').raw(true); + hasExportedCol = true; + } catch (e: unknown) { + debug(`exported column not available, using fallback: ${(e as Error).message}`); + } + _hasExportedColCache.set(db, hasExportedCol); } const exportedNodesStmt = db.prepare( From 2d300032c1411c729d69ccbf3014f07c6fa080e7 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sun, 22 Mar 2026 00:14:16 -0600 Subject: [PATCH 16/18] fix: resolve TypeScript type mismatches from merge conflict resolution Impact: 30 functions changed, 82 affected --- src/db/repository/nodes.ts | 4 ++-- src/domain/analysis/brief.ts | 7 +++---- src/domain/analysis/context.ts | 20 ++++++++++---------- src/domain/analysis/dependencies.ts | 11 +++++------ src/domain/analysis/exports.ts | 7 +++---- src/domain/analysis/impact.ts | 17 ++++++++--------- src/domain/analysis/symbol-lookup.ts | 17 ++++++++++------- src/types.ts | 5 +++-- 8 files changed, 44 insertions(+), 44 deletions(-) diff --git a/src/db/repository/nodes.ts b/src/db/repository/nodes.ts index 14b1080e..e809b0fa 100644 --- a/src/db/repository/nodes.ts +++ b/src/db/repository/nodes.ts @@ -286,7 +286,7 @@ const _getLineCountForNodeStmt = new WeakMap(); * @param {number} nodeId * @returns {{ line_count: number } | undefined} */ -export function getLineCountForNode(db, nodeId) { +export function getLineCountForNode(db: BetterSqlite3Database, nodeId: number) { return cachedStmt( _getLineCountForNodeStmt, db, @@ -302,7 +302,7 @@ const _getMaxEndLineForFileStmt = new WeakMap(); * @param {string} file * @returns {{ max_end: number | null } | undefined} */ -export function getMaxEndLineForFile(db, file) { +export function getMaxEndLineForFile(db: BetterSqlite3Database, file: string) { return cachedStmt( _getMaxEndLineForFileStmt, db, diff --git a/src/domain/analysis/brief.ts b/src/domain/analysis/brief.ts index 259b1f18..b02704c7 100644 --- a/src/domain/analysis/brief.ts +++ b/src/domain/analysis/brief.ts @@ -1,4 +1,3 @@ -import type BetterSqlite3 from 'better-sqlite3'; import { findDistinctCallers, findFileNodes, @@ -10,7 +9,7 @@ import { } from '../../db/index.js'; import { loadConfig } from '../../infrastructure/config.js'; import { isTestFile } from '../../infrastructure/test-filter.js'; -import type { ImportEdgeRow, NodeRow, RelatedNodeRow } from '../../types.js'; +import type { BetterSqlite3Database, ImportEdgeRow, NodeRow, RelatedNodeRow } from '../../types.js'; /** Symbol kinds meaningful for a file brief — excludes parameters, properties, constants. */ const BRIEF_KINDS = new Set([ @@ -50,7 +49,7 @@ function computeRiskTier( * Lightweight variant — only counts, does not collect details. */ function countTransitiveCallers( - db: BetterSqlite3.Database, + db: BetterSqlite3Database, startId: number, noTests: boolean, maxDepth = 5, @@ -81,7 +80,7 @@ function countTransitiveCallers( * Depth-bounded to match countTransitiveCallers and keep hook latency predictable. */ function countTransitiveImporters( - db: BetterSqlite3.Database, + db: BetterSqlite3Database, fileNodeIds: number[], noTests: boolean, maxDepth = 5, diff --git a/src/domain/analysis/context.ts b/src/domain/analysis/context.ts index 471d87eb..2c3f5ed2 100644 --- a/src/domain/analysis/context.ts +++ b/src/domain/analysis/context.ts @@ -1,5 +1,4 @@ import path from 'node:path'; -import type BetterSqlite3 from 'better-sqlite3'; import { findCallees, findCallers, @@ -33,6 +32,7 @@ import { resolveMethodViaHierarchy } from '../../shared/hierarchy.js'; import { normalizeSymbol } from '../../shared/normalize.js'; import { paginateResult } from '../../shared/paginate.js'; import type { + BetterSqlite3Database, ChildNodeRow, ImportEdgeRow, IntraFileCallEdge, @@ -48,7 +48,7 @@ interface DisplayOpts { } function buildCallees( - db: BetterSqlite3.Database, + db: BetterSqlite3Database, node: NodeRow, repoRoot: string, getFileLines: (file: string) => string[] | null, @@ -110,7 +110,7 @@ function buildCallees( return callees; } -function buildCallers(db: BetterSqlite3.Database, node: NodeRow, noTests: boolean) { +function buildCallers(db: BetterSqlite3Database, node: NodeRow, noTests: boolean) { let callerRows: Array = findCallers( db, node.id, @@ -139,7 +139,7 @@ function buildCallers(db: BetterSqlite3.Database, node: NodeRow, noTests: boolea const INTERFACE_LIKE_KINDS = new Set(['interface', 'trait']); const IMPLEMENTOR_KINDS = new Set(['class', 'struct', 'record', 'enum']); -function buildImplementationInfo(db: BetterSqlite3.Database, node: NodeRow, noTests: boolean) { +function buildImplementationInfo(db: BetterSqlite3Database, node: NodeRow, noTests: boolean) { // For interfaces/traits: show who implements them if (INTERFACE_LIKE_KINDS.has(node.kind)) { let impls = findImplementors(db, node.id) as RelatedNodeRow[]; @@ -162,7 +162,7 @@ function buildImplementationInfo(db: BetterSqlite3.Database, node: NodeRow, noTe } function buildRelatedTests( - db: BetterSqlite3.Database, + db: BetterSqlite3Database, node: NodeRow, getFileLines: (file: string) => string[] | null, includeTests: boolean, @@ -203,7 +203,7 @@ function buildRelatedTests( return relatedTests; } -function getComplexityMetrics(db: BetterSqlite3.Database, nodeId: number) { +function getComplexityMetrics(db: BetterSqlite3Database, nodeId: number) { try { const cRow = getComplexityForNode(db, nodeId); if (!cRow) return null; @@ -220,7 +220,7 @@ function getComplexityMetrics(db: BetterSqlite3.Database, nodeId: number) { } } -function getNodeChildrenSafe(db: BetterSqlite3.Database, nodeId: number) { +function getNodeChildrenSafe(db: BetterSqlite3Database, nodeId: number) { try { return (findNodeChildren(db, nodeId) as ChildNodeRow[]).map((c) => ({ name: c.name, @@ -235,7 +235,7 @@ function getNodeChildrenSafe(db: BetterSqlite3.Database, nodeId: number) { } function explainFileImpl( - db: BetterSqlite3.Database, + db: BetterSqlite3Database, target: string, getFileLines: (file: string) => string[] | null, displayOpts: DisplayOpts, @@ -304,7 +304,7 @@ const _explainNodeStmtCache: StmtCache = new WeakMap(); const _EXPLAIN_NODE_SQL = `SELECT * FROM nodes WHERE name LIKE ? AND kind IN ('function','method','class','interface','type','struct','enum','trait','record','module','constant') ORDER BY file, line`; function explainFunctionImpl( - db: BetterSqlite3.Database, + db: BetterSqlite3Database, target: string, noTests: boolean, getFileLines: (file: string) => string[] | null, @@ -362,7 +362,7 @@ function explainCallees( parentResults: any[], currentDepth: number, visited: Set, - db: BetterSqlite3.Database, + db: BetterSqlite3Database, noTests: boolean, getFileLines: (file: string) => string[] | null, displayOpts: DisplayOpts, diff --git a/src/domain/analysis/dependencies.ts b/src/domain/analysis/dependencies.ts index c320ee8f..def5c965 100644 --- a/src/domain/analysis/dependencies.ts +++ b/src/domain/analysis/dependencies.ts @@ -1,4 +1,3 @@ -import type BetterSqlite3 from 'better-sqlite3'; import { findCallees, findCallers, @@ -12,7 +11,7 @@ import { isTestFile } from '../../infrastructure/test-filter.js'; import { resolveMethodViaHierarchy } from '../../shared/hierarchy.js'; import { normalizeSymbol } from '../../shared/normalize.js'; import { paginateResult } from '../../shared/paginate.js'; -import type { ImportEdgeRow, NodeRow, RelatedNodeRow } from '../../types.js'; +import type { BetterSqlite3Database, ImportEdgeRow, NodeRow, RelatedNodeRow } from '../../types.js'; import { findMatchingNodes } from './symbol-lookup.js'; export function fileDepsData( @@ -57,7 +56,7 @@ export function fileDepsData( * Returns an object keyed by depth (2..depth) -> array of caller descriptors. */ function buildTransitiveCallers( - db: BetterSqlite3.Database, + db: BetterSqlite3Database, callers: Array<{ id: number; name: string; kind: string; file: string; line: number }>, nodeId: number, depth: number, @@ -189,7 +188,7 @@ export function fnDepsData( * or { earlyResult } when a caller-facing error/not-found response should be returned immediately. */ function resolveEndpoints( - db: BetterSqlite3.Database, + db: BetterSqlite3Database, from: string, to: string, opts: { noTests?: boolean; fromFile?: string; toFile?: string; kind?: string }, @@ -255,7 +254,7 @@ function resolveEndpoints( * `parent` maps nodeId -> { parentId, edgeKind }. */ function bfsShortestPath( - db: BetterSqlite3.Database, + db: BetterSqlite3Database, sourceId: number, targetId: number, edgeKinds: string[], @@ -325,7 +324,7 @@ function bfsShortestPath( * array of node IDs source -> target. */ function reconstructPath( - db: BetterSqlite3.Database, + db: BetterSqlite3Database, pathIds: number[], parent: Map, ) { diff --git a/src/domain/analysis/exports.ts b/src/domain/analysis/exports.ts index 180f13ba..3bf0f959 100644 --- a/src/domain/analysis/exports.ts +++ b/src/domain/analysis/exports.ts @@ -1,5 +1,4 @@ import path from 'node:path'; -import type BetterSqlite3 from 'better-sqlite3'; import { findCrossFileCallTargets, findDbPath, @@ -16,10 +15,10 @@ import { extractSummary, } from '../../shared/file-utils.js'; import { paginateResult } from '../../shared/paginate.js'; -import type { NodeRow } from '../../types.js'; +import type { BetterSqlite3Database, NodeRow } from '../../types.js'; /** Cache the schema probe for the `exported` column per db handle. */ -const _hasExportedColCache: WeakMap = new WeakMap(); +const _hasExportedColCache: WeakMap = new WeakMap(); export function exportsData( file: string, @@ -104,7 +103,7 @@ export function exportsData( } function exportsFileImpl( - db: BetterSqlite3.Database, + db: BetterSqlite3Database, target: string, noTests: boolean, getFileLines: (file: string) => string[] | null, diff --git a/src/domain/analysis/impact.ts b/src/domain/analysis/impact.ts index 261d2462..a547722c 100644 --- a/src/domain/analysis/impact.ts +++ b/src/domain/analysis/impact.ts @@ -1,7 +1,6 @@ import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; -import type BetterSqlite3 from 'better-sqlite3'; import { findDbPath, findDistinctCallers, @@ -19,7 +18,7 @@ import { debug } from '../../infrastructure/logger.js'; import { isTestFile } from '../../infrastructure/test-filter.js'; import { normalizeSymbol } from '../../shared/normalize.js'; import { paginateResult } from '../../shared/paginate.js'; -import type { NodeRow, RelatedNodeRow } from '../../types.js'; +import type { BetterSqlite3Database, NodeRow, RelatedNodeRow } from '../../types.js'; import { findMatchingNodes } from './symbol-lookup.js'; // --- Shared BFS: transitive callers --- @@ -30,8 +29,8 @@ const INTERFACE_LIKE_KINDS = new Set(['interface', 'trait']); * Check whether the graph contains any 'implements' edges. * Cached per db handle so the query runs at most once per connection. */ -const _hasImplementsCache: WeakMap = new WeakMap(); -function hasImplementsEdges(db: BetterSqlite3.Database): boolean { +const _hasImplementsCache: WeakMap = new WeakMap(); +function hasImplementsEdges(db: BetterSqlite3Database): boolean { if (_hasImplementsCache.has(db)) return _hasImplementsCache.get(db)!; const row = db.prepare("SELECT 1 FROM edges WHERE kind = 'implements' LIMIT 1").get(); const result = !!row; @@ -46,7 +45,7 @@ function hasImplementsEdges(db: BetterSqlite3.Database): boolean { * so that changes to an interface signature propagate to all implementors. */ export function bfsTransitiveCallers( - db: BetterSqlite3.Database, + db: BetterSqlite3Database, startId: number, { noTests = false, @@ -334,7 +333,7 @@ function parseGitDiff(diffOutput: string) { * Find all function/method/class nodes whose line ranges overlap any changed range. */ function findAffectedFunctions( - db: BetterSqlite3.Database, + db: BetterSqlite3Database, changedRanges: Map>, noTests: boolean, ): NodeRow[] { @@ -364,7 +363,7 @@ function findAffectedFunctions( * Run BFS per affected function, collecting per-function results and the full affected set. */ function buildFunctionImpactResults( - db: BetterSqlite3.Database, + db: BetterSqlite3Database, affectedFunctions: NodeRow[], noTests: boolean, maxDepth: number, @@ -407,7 +406,7 @@ function buildFunctionImpactResults( * Returns an empty array if the co_changes table is unavailable. */ function lookupCoChanges( - db: BetterSqlite3.Database, + db: BetterSqlite3Database, changedRanges: Map, affectedFiles: Set, noTests: boolean, @@ -458,7 +457,7 @@ function lookupOwnership( * Returns `{ boundaryViolations, boundaryViolationCount }`. */ function checkBoundaryViolations( - db: BetterSqlite3.Database, + db: BetterSqlite3Database, changedRanges: Map, noTests: boolean, // biome-ignore lint/suspicious/noExplicitAny: opts shape varies by caller diff --git a/src/domain/analysis/symbol-lookup.ts b/src/domain/analysis/symbol-lookup.ts index 31f5c2e2..f7cf98cf 100644 --- a/src/domain/analysis/symbol-lookup.ts +++ b/src/domain/analysis/symbol-lookup.ts @@ -1,4 +1,3 @@ -import type BetterSqlite3 from 'better-sqlite3'; import { countCrossFileCallers, findAllIncomingEdges, @@ -22,24 +21,28 @@ import { getFileHash, normalizeSymbol } from '../../shared/normalize.js'; import { paginateResult } from '../../shared/paginate.js'; import type { AdjacentEdgeRow, + BetterSqlite3Database, ChildNodeRow, ImportEdgeRow, NodeRow, NodeRowWithFanIn, + SymbolKind, } from '../../types.js'; -const FUNCTION_KINDS = ['function', 'method', 'class', 'constant']; +const FUNCTION_KINDS: SymbolKind[] = ['function', 'method', 'class', 'constant']; /** * Find nodes matching a name query, ranked by relevance. * Scoring: exact=100, prefix=60, word-boundary=40, substring=10, plus fan-in tiebreaker. */ export function findMatchingNodes( - dbOrRepo: BetterSqlite3.Database | InstanceType, + dbOrRepo: BetterSqlite3Database | InstanceType, name: string, opts: { noTests?: boolean; file?: string; kind?: string; kinds?: readonly string[] } = {}, ): Array { - const kinds = opts.kind ? [opts.kind] : opts.kinds?.length ? [...opts.kinds] : FUNCTION_KINDS; + const kinds = ( + opts.kind ? [opts.kind] : opts.kinds?.length ? [...opts.kinds] : FUNCTION_KINDS + ) as SymbolKind[]; const isRepo = dbOrRepo instanceof Repository; const rows = ( @@ -48,7 +51,7 @@ export function findMatchingNodes( kinds, file: opts.file, }) - : findNodesWithFanIn(dbOrRepo as BetterSqlite3.Database, `%${name}%`, { + : findNodesWithFanIn(dbOrRepo as BetterSqlite3Database, `%${name}%`, { kinds, file: opts.file, }) @@ -133,7 +136,7 @@ export function queryNameData( } } -function whereSymbolImpl(db: BetterSqlite3.Database, target: string, noTests: boolean) { +function whereSymbolImpl(db: BetterSqlite3Database, target: string, noTests: boolean) { const placeholders = EVERY_SYMBOL_KIND.map(() => '?').join(', '); let nodes = db .prepare( @@ -158,7 +161,7 @@ function whereSymbolImpl(db: BetterSqlite3.Database, target: string, noTests: bo }); } -function whereFileImpl(db: BetterSqlite3.Database, target: string) { +function whereFileImpl(db: BetterSqlite3Database, target: string) { const fileNodes = findFileNodes(db, `%${target}%`) as NodeRow[]; if (fileNodes.length === 0) return []; diff --git a/src/types.ts b/src/types.ts index 9aa8bf7c..4e08f77a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1650,16 +1650,17 @@ export interface SqliteStatement { all(...params: unknown[]): TRow[]; run(...params: unknown[]): { changes: number; lastInsertRowid: number | bigint }; iterate(...params: unknown[]): IterableIterator; + raw(toggle?: boolean): this; } /** Minimal database interface matching the better-sqlite3 surface we use. */ export interface BetterSqlite3Database { prepare(sql: string): SqliteStatement; - exec(sql: string): void; + exec(sql: string): this; close(): void; pragma(sql: string): unknown; // biome-ignore lint/suspicious/noExplicitAny: must be compatible with better-sqlite3's generic Transaction return type - transaction(fn: (...args: any[]) => T): (...args: any[]) => T; + transaction any>(fn: F): F; readonly open: boolean; readonly name: string; } From 5d965c9a9f45a951b8ebc8d8b848d159645e4524 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sun, 22 Mar 2026 08:52:13 -0600 Subject: [PATCH 17/18] fix: hoist db.prepare() out of changedRanges loop in findAffectedFunctions (#558) The prepared statement was being compiled on every iteration of the changedRanges loop. Hoisted to compile once before the loop. --- src/domain/analysis/impact.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/domain/analysis/impact.ts b/src/domain/analysis/impact.ts index a547722c..2e805ffc 100644 --- a/src/domain/analysis/impact.ts +++ b/src/domain/analysis/impact.ts @@ -338,13 +338,12 @@ function findAffectedFunctions( noTests: boolean, ): NodeRow[] { const affectedFunctions: NodeRow[] = []; + const defsStmt = db.prepare( + `SELECT * FROM nodes WHERE file = ? AND kind IN ('function', 'method', 'class') ORDER BY line`, + ); for (const [file, ranges] of changedRanges) { if (noTests && isTestFile(file)) continue; - const defs = db - .prepare( - `SELECT * FROM nodes WHERE file = ? AND kind IN ('function', 'method', 'class') ORDER BY line`, - ) - .all(file) as NodeRow[]; + const defs = defsStmt.all(file) as NodeRow[]; for (let i = 0; i < defs.length; i++) { const def = defs[i]!; const endLine = def.end_line || (defs[i + 1] ? defs[i + 1]!.line - 1 : 999999); From 3d33583349ead702549efa7799dc92cd14d3bb45 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sun, 22 Mar 2026 08:52:23 -0600 Subject: [PATCH 18/18] fix: add null guard for result.get(relPath) in typeMap backfill (#558) The ! non-null assertion was dropped during TS migration. Added an explicit guard with continue to skip safely when the entry is missing, preventing silent TypeError on the subsequent property access. --- src/domain/parser.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/domain/parser.ts b/src/domain/parser.ts index 5e5b43d1..88abb89b 100644 --- a/src/domain/parser.ts +++ b/src/domain/parser.ts @@ -568,6 +568,7 @@ export async function parseFilesAuto( extracted = wasmExtractSymbols(parsers, filePath, code); if (extracted?.symbols?.typeMap) { const symbols = result.get(relPath); + if (!symbols) continue; symbols.typeMap = extracted.symbols.typeMap instanceof Map ? extracted.symbols.typeMap