diff --git a/src/domain/analysis/context.js b/src/domain/analysis/context.js index a6721b35..e8b5a869 100644 --- a/src/domain/analysis/context.js +++ b/src/domain/analysis/context.js @@ -27,6 +27,149 @@ 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 } = 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) : null; + let calleeSource = null; + if (depth >= 1) { + calleeSource = readSourceRange(repoRoot, c.file, c.line, c.end_line); + } + 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) : null, + source: readSourceRange(repoRoot, c.file, c.line, c.end_line), + }); + } + } + } + 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, + })); +} + +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) { const fileNodes = findFileNodes(db, `%${target}%`); if (fileNodes.length === 0) return []; @@ -50,14 +193,10 @@ function explainFileImpl(db, target, getFileLines) { const publicApi = symbols.filter((s) => publicIds.has(s.id)).map(mapSymbol); const internal = symbols.filter((s) => !publicIds.has(s.id)).map(mapSymbol); - // Imports / importedBy const imports = findImportTargets(db, fn.id).map((r) => ({ file: r.file })); - const importedBy = findImportSources(db, fn.id).map((r) => ({ file: r.file })); - // Intra-file data flow 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, []); @@ -68,7 +207,6 @@ function explainFileImpl(db, target, getFileLines) { callees, })); - // Line count: prefer node_metrics (actual), fall back to MAX(end_line) const metric = db .prepare(`SELECT nm.line_count FROM node_metrics nm WHERE nm.node_id = ?`) .get(fn.id); @@ -130,29 +268,12 @@ function explainFunctionImpl(db, target, noTests, getFileLines) { .filter((r) => isTestFile(r.file) && !seenFiles.has(r.file) && seenFiles.add(r.file)) .map((r) => ({ file: r.file })); - // Complexity metrics - let complexityMetrics = null; - try { - const cRow = getComplexityForNode(db, node.id); - if (cRow) { - complexityMetrics = { - 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 ${node.id}: ${e.message}`); - } - return { ...normalizeSymbol(node, db, hc), lineCount, summary, signature, - complexity: complexityMetrics, + complexity: getComplexityMetrics(db, node.id), callees, callers, relatedTests, @@ -160,6 +281,28 @@ function explainFunctionImpl(db, target, noTests, getFileLines) { }); } +function explainCallees(parentResults, currentDepth, visited, db, noTests, getFileLines) { + 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); + 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); + } + } +} + // ─── Exported functions ────────────────────────────────────────────────── export function contextData(name, customDbPath, opts = {}) { @@ -178,156 +321,22 @@ export function contextData(name, customDbPath, opts = {}) { return { name, results: [] }; } - // No hardcoded slice — pagination handles bounding via limit/offset - const getFileLines = createFileLinesReader(repoRoot); const results = nodes.map((node) => { const fileLines = getFileLines(node.file); - // Source const source = noSource ? null : readSourceRange(repoRoot, node.file, node.line, node.end_line); - // Signature const signature = fileLines ? extractSignature(fileLines, node.line) : null; - // Callees - 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) : null; - let calleeSource = null; - if (depth >= 1) { - calleeSource = readSourceRange(repoRoot, c.file, c.line, c.end_line); - } - return { - name: c.name, - kind: c.kind, - file: c.file, - line: c.line, - endLine: c.end_line || null, - summary, - source: calleeSource, - }; - }); - - // Deep callee expansion via BFS (depth > 1, capped at 5) - 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) : null, - source: readSourceRange(repoRoot, c.file, c.line, c.end_line), - }); - } - } - } - frontier = nextFrontier; - if (frontier.length === 0) break; - } - } - - // Callers - let callerRows = findCallers(db, node.id); - - // Method hierarchy resolution - 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)); - - const callers = callerRows.map((c) => ({ - name: c.name, - kind: c.kind, - file: c.file, - line: c.line, - viaHierarchy: c.viaHierarchy || undefined, - })); - - // Related tests: callers that live in test files - 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, - }); - } - - // Complexity metrics - let complexityMetrics = null; - try { - const cRow = getComplexityForNode(db, node.id); - if (cRow) { - complexityMetrics = { - 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 ${node.id}: ${e.message}`); - } - - // Children (parameters, properties, constants) - let nodeChildren = []; - try { - nodeChildren = findNodeChildren(db, node.id).map((c) => ({ - name: c.name, - kind: c.kind, - line: c.line, - endLine: c.end_line || null, - })); - } catch (e) { - debug(`findNodeChildren failed for node ${node.id}: ${e.message}`); - } + const callees = buildCallees(db, node, repoRoot, getFileLines, { noTests, depth }); + 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); return { name: node.name, @@ -370,35 +379,9 @@ export function explainData(target, customDbPath, opts = {}) { ? explainFileImpl(db, target, getFileLines) : explainFunctionImpl(db, target, noTests, getFileLines); - // Recursive dependency explanation for function targets if (kind === 'function' && depth > 0 && results.length > 0) { const visited = new Set(results.map((r) => `${r.name}:${r.file}:${r.line}`)); - - function explainCallees(parentResults, currentDepth) { - 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); - 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); - } - } - } - - explainCallees(results, depth); + explainCallees(results, depth, visited, db, noTests, getFileLines); } const base = { target, kind, results }; diff --git a/src/domain/analysis/dependencies.js b/src/domain/analysis/dependencies.js index e632470f..867cd5bd 100644 --- a/src/domain/analysis/dependencies.js +++ b/src/domain/analysis/dependencies.js @@ -46,6 +46,61 @@ export function fileDepsData(file, customDbPath, opts = {}) { } } +/** + * 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 { @@ -75,55 +130,7 @@ export function fnDepsData(name, customDbPath, opts = {}) { } if (noTests) callers = callers.filter((c) => !isTestFile(c.file)); - // Transitive callers - const transitiveCallers = {}; - if (depth > 1) { - const visited = new Set([node.id]); - 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; - } - } + const transitiveCallers = buildTransitiveCallers(db, callers, node.id, depth, noTests); return { ...normalizeSymbol(node, db, hc), @@ -151,37 +158,40 @@ export function fnDepsData(name, customDbPath, opts = {}) { } } -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; +/** + * 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 { + 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 { + const toNodes = findMatchingNodes(db, to, { + noTests, + file: opts.toFile, + kind: opts.kind, + }); + if (toNodes.length === 0) { + return { + earlyResult: { from, to, found: false, @@ -190,18 +200,118 @@ export function pathData(from, to, customDbPath, opts = {}) { .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 sourceNode = fromNodes[0]; - const targetNode = toNodes[0]; + const resolved = resolveEndpoints(db, from, to, { + noTests, + fromFile: opts.fromFile, + toFile: opts.toFile, + kind: opts.kind, + }); + if (resolved.earlyResult) return resolved.earlyResult; - 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 })); + const { sourceNode, targetNode, fromCandidates, toCandidates } = resolved; // Self-path if (sourceNode.id === targetNode.id) { @@ -228,55 +338,12 @@ export function pathData(from, to, customDbPath, opts = {}) { }; } - // Build edge kind filter - const kindPlaceholders = edgeKinds.map(() => '?').join(', '); - - // BFS — direction depends on `reverse` flag - // 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([sourceNode.id]); - // parent map: nodeId → { parentId, edgeKind } - const parent = new Map(); - let queue = [sourceNode.id]; - 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 === targetNode.id) { - 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; - } + const { + found, + parent, + alternateCount: rawAlternateCount, + foundDepth, + } = bfsShortestPath(db, sourceNode.id, targetNode.id, edgeKinds, reverse, maxDepth, noTests); if (!found) { return { @@ -294,8 +361,8 @@ export function pathData(from, to, customDbPath, opts = {}) { }; } - // alternateCount includes the one we kept; subtract 1 for "alternates" - alternateCount = Math.max(0, alternateCount - 1); + // 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]; @@ -307,20 +374,7 @@ export function pathData(from, to, customDbPath, opts = {}) { } pathIds.reverse(); - // Build path with node info - 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; - }; - - const resultPath = 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 }; - }); + const resultPath = reconstructPath(db, pathIds, parent); return { from, diff --git a/src/domain/analysis/impact.js b/src/domain/analysis/impact.js index bd3bbe1d..6bdd5464 100644 --- a/src/domain/analysis/impact.js +++ b/src/domain/analysis/impact.js @@ -134,6 +134,251 @@ export function fnImpactData(name, customDbPath, opts = {}) { } } +// ─── 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) { + 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, + 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. @@ -147,38 +392,14 @@ export function diffImpactData(customDbPath, opts = {}) { const dbPath = findDbPath(customDbPath); const repoRoot = path.resolve(path.dirname(dbPath), '..'); - // Verify we're in a git repository before running git diff - let checkDir = repoRoot; - let isGitRepo = false; - while (checkDir) { - if (fs.existsSync(path.join(checkDir, '.git'))) { - isGitRepo = true; - break; - } - const parent = path.dirname(checkDir); - if (parent === checkDir) break; - checkDir = parent; - } - if (!isGitRepo) { + if (!findGitRoot(repoRoot)) { return { error: `Not a git repository: ${repoRoot}` }; } - let diffOutput; - try { - const args = opts.staged - ? ['diff', '--cached', '--unified=0', '--no-color'] - : ['diff', opts.ref || 'HEAD', '--unified=0', '--no-color']; - diffOutput = execFileSync('git', args, { - cwd: repoRoot, - encoding: 'utf-8', - maxBuffer: 10 * 1024 * 1024, - stdio: ['pipe', 'pipe', 'pipe'], - }); - } catch (e) { - return { error: `Failed to run git diff: ${e.message}` }; - } + const gitResult = runGitDiff(repoRoot, opts); + if (gitResult.error) return { error: gitResult.error }; - if (!diffOutput.trim()) { + if (!gitResult.output.trim()) { return { changedFiles: 0, newFiles: [], @@ -188,34 +409,7 @@ export function diffImpactData(customDbPath, opts = {}) { }; } - 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 }); - } - } + const { changedRanges, newFiles } = parseGitDiff(gitResult.output); if (changedRanges.size === 0) { return { @@ -227,106 +421,26 @@ export function diffImpactData(customDbPath, opts = {}) { }; } - 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; - } - } - } - } - - 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, - 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, - }; - }); + const affectedFunctions = findAffectedFunctions(db, changedRanges, noTests); + const { functionResults, allAffected } = buildFunctionImpactResults( + db, + affectedFunctions, + noTests, + maxDepth, + ); const affectedFiles = new Set(); for (const key of allAffected) affectedFiles.add(key.split(':')[0]); - // Look up historically coupled files from co-change data - let historicallyCoupled = []; - 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, - }); - // Exclude files already found via static analysis - historicallyCoupled = coResults.filter((r) => !affectedFiles.has(r.file)); - } catch (e) { - debug(`co_changes lookup skipped: ${e.message}`); - } - - // Look up CODEOWNERS for changed + affected files - let ownership = null; - try { - const allFilePaths = [...new Set([...changedRanges.keys(), ...affectedFiles])]; - const ownerResult = ownersForFiles(allFilePaths, repoRoot); - if (ownerResult.affectedOwners.length > 0) { - ownership = { - owners: Object.fromEntries(ownerResult.owners), - affectedOwners: ownerResult.affectedOwners, - suggestedReviewers: ownerResult.suggestedReviewers, - }; - } - } catch (e) { - debug(`CODEOWNERS lookup skipped: ${e.message}`); - } - - // Check boundary violations scoped to changed files - let boundaryViolations = []; - let boundaryViolationCount = 0; - try { - const cfg = opts.config || loadConfig(repoRoot); - const boundaryConfig = cfg.manifesto?.boundaries; - if (boundaryConfig) { - const result = evaluateBoundaries(db, boundaryConfig, { - scopeFiles: [...changedRanges.keys()], - noTests, - }); - boundaryViolations = result.violations; - boundaryViolationCount = result.violationCount; - } - } catch (e) { - debug(`boundary check skipped: ${e.message}`); - } + 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, diff --git a/src/domain/analysis/module-map.js b/src/domain/analysis/module-map.js index d2bc613b..daf09b33 100644 --- a/src/domain/analysis/module-map.js +++ b/src/domain/analysis/module-map.js @@ -37,6 +37,241 @@ export const FALSE_POSITIVE_NAMES = new Set([ ]); 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) { + 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(FALSE_POSITIVE_CALLER_THRESHOLD); + 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 = {}; + for (const r of roleRows) roles[r.role] = r.c; + 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 { @@ -79,237 +314,27 @@ export function statsData(customDbPath, opts = {}) { const db = openReadonlyOrFail(customDbPath); try { const noTests = opts.noTests || false; + const testFilter = testFilterSQL('n.file', noTests); - // Build set of test file IDs for filtering nodes and edges - let testFileIds = null; - if (noTests) { - const allFileNodes = db.prepare("SELECT id, file FROM nodes WHERE kind = 'file'").all(); - testFileIds = new Set(); - const testFiles = new Set(); - for (const n of allFileNodes) { - if (isTestFile(n.file)) { - testFileIds.add(n.id); - testFiles.add(n.file); - } - } - - // Also collect non-file node IDs that belong to test files - const allNodes = db.prepare('SELECT id, file FROM nodes').all(); - for (const n of allNodes) { - if (testFiles.has(n.file)) testFileIds.add(n.id); - } - } - - // Node breakdown by kind - let nodeRows; - if (noTests) { - 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 nodesByKind = {}; - let totalNodes = 0; - for (const r of nodeRows) { - nodesByKind[r.kind] = r.c; - totalNodes += r.c; - } - - // Edge breakdown by kind - let edgeRows; - if (noTests) { - 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 edgesByKind = {}; - let totalEdges = 0; - for (const r of edgeRows) { - edgesByKind[r.kind] = r.c; - totalEdges += r.c; - } + const testFileIds = noTests ? buildTestFileIds(db) : null; - // File/language distribution — map extensions via LANGUAGE_REGISTRY - 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; - } - const langCount = Object.keys(byLanguage).length; + const { total: totalNodes, byKind: nodesByKind } = countNodesByKind(db, testFileIds); + const { total: totalEdges, byKind: edgesByKind } = countEdgesByKind(db, testFileIds); + const files = countFilesByLanguage(db, noTests); - // Cycles const fileCycles = findCycles(db, { fileLevel: true, noTests }); const fnCycles = findCycles(db, { fileLevel: false, noTests }); - // Top 5 coupling hotspots (fan-in + fan-out, file nodes) - 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 filteredHotspots = noTests ? hotspotRows.filter((r) => !isTestFile(r.file)) : hotspotRows; - const hotspots = filteredHotspots.slice(0, 5).map((r) => ({ - file: r.file, - fanIn: r.fan_in, - fanOut: r.fan_out, - })); - - // Embeddings metadata - let embeddings = null; - 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; - embeddings = { - 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}`); - } - - // Graph quality metrics - 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; - - // False-positive warnings: generic names with > threshold callers - 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(FALSE_POSITIVE_CALLER_THRESHOLD); - 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 })); - - // Edges from suspicious nodes - 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, - ); - - const quality = { - score, - callerCoverage: { - ratio: callerCoverage, - covered: callableWithCallers, - total: totalCallable, - }, - callConfidence: { - ratio: callConfidence, - highConf: highConfCallEdges, - total: totalCallEdges, - }, - falsePositiveWarnings, - }; - - // Role distribution - 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 = {}; - for (const r of roleRows) roles[r.role] = r.c; - - // Complexity summary - let complexity = null; - 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); - complexity = { - 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}`); - } + const hotspots = findHotspots(db, noTests, 5); + const embeddings = getEmbeddingsInfo(db); + const quality = computeQualityMetrics(db, testFilter); + const roles = countRoles(db, noTests); + const complexity = getComplexitySummary(db, testFilter); return { nodes: { total: totalNodes, byKind: nodesByKind }, edges: { total: totalEdges, byKind: edgesByKind }, - files: { total: fileNodes.length, languages: langCount, byLanguage }, + files, cycles: { fileLevel: fileCycles.length, functionLevel: fnCycles.length }, hotspots, embeddings, diff --git a/src/features/boundaries.js b/src/features/boundaries.js index 7a357ebd..536dbafa 100644 --- a/src/features/boundaries.js +++ b/src/features/boundaries.js @@ -94,104 +94,119 @@ export function resolveModules(boundaryConfig) { // ─── Validation ────────────────────────────────────────────────────── /** - * Validate a boundary configuration object. - * @param {object} config - The `manifesto.boundaries` config - * @returns {{ valid: boolean, errors: string[] }} + * Validate the `modules` section of a boundary config. + * @param {object} modules + * @param {string[]} errors - Mutated: push any validation errors */ -export function validateBoundaryConfig(config) { - const errors = []; +function validateModules(modules, errors) { + if (!modules || typeof modules !== 'object' || Object.keys(modules).length === 0) { + errors.push('boundaries.modules must be a non-empty object'); + return; + } + for (const [name, value] of Object.entries(modules)) { + if (typeof value === 'string') continue; + if (value && typeof value === 'object' && typeof value.match === 'string') continue; + errors.push(`boundaries.modules.${name}: must be a glob string or { match: "" }`); + } +} - if (!config || typeof config !== 'object') { - return { valid: false, errors: ['boundaries config must be an object'] }; +/** + * Validate the `preset` field of a boundary config. + * @param {string|null|undefined} preset + * @param {string[]} errors - Mutated: push any validation errors + */ +function validatePreset(preset, errors) { + if (preset == null) return; + if (typeof preset !== 'string' || !PRESETS[preset]) { + errors.push( + `boundaries.preset: must be one of ${Object.keys(PRESETS).join(', ')} (got "${preset}")`, + ); } +} - // Validate modules - if ( - !config.modules || - typeof config.modules !== 'object' || - Object.keys(config.modules).length === 0 - ) { - errors.push('boundaries.modules must be a non-empty object'); - } else { - for (const [name, value] of Object.entries(config.modules)) { - if (typeof value === 'string') continue; - if (value && typeof value === 'object' && typeof value.match === 'string') continue; - errors.push(`boundaries.modules.${name}: must be a glob string or { match: "" }`); +/** + * Validate a single rule's target list (`notTo` or `onlyTo`). + * @param {*} list - The target list value + * @param {string} field - "notTo" or "onlyTo" + * @param {number} idx - Rule index for error messages + * @param {Set} moduleNames + * @param {string[]} errors - Mutated + */ +function validateTargetList(list, field, idx, moduleNames, errors) { + if (!Array.isArray(list)) { + errors.push(`boundaries.rules[${idx}]: "${field}" must be an array`); + return; + } + for (const target of list) { + if (!moduleNames.has(target)) { + errors.push(`boundaries.rules[${idx}]: "${field}" references unknown module "${target}"`); } } +} - // Validate preset - if (config.preset != null) { - if (typeof config.preset !== 'string' || !PRESETS[config.preset]) { - errors.push( - `boundaries.preset: must be one of ${Object.keys(PRESETS).join(', ')} (got "${config.preset}")`, - ); +/** + * Validate the `rules` array of a boundary config. + * @param {Array} rules + * @param {object|undefined} modules - The modules config (for cross-referencing names) + * @param {string[]} errors - Mutated + */ +function validateRules(rules, modules, errors) { + if (!rules) return; + if (!Array.isArray(rules)) { + errors.push('boundaries.rules must be an array'); + return; + } + const moduleNames = modules ? new Set(Object.keys(modules)) : new Set(); + for (let i = 0; i < rules.length; i++) { + const rule = rules[i]; + if (!rule.from) { + errors.push(`boundaries.rules[${i}]: missing "from" field`); + } else if (!moduleNames.has(rule.from)) { + errors.push(`boundaries.rules[${i}]: "from" references unknown module "${rule.from}"`); + } + if (rule.notTo && rule.onlyTo) { + errors.push(`boundaries.rules[${i}]: cannot have both "notTo" and "onlyTo"`); + } + if (!rule.notTo && !rule.onlyTo) { + errors.push(`boundaries.rules[${i}]: must have either "notTo" or "onlyTo"`); } + if (rule.notTo) validateTargetList(rule.notTo, 'notTo', i, moduleNames, errors); + if (rule.onlyTo) validateTargetList(rule.onlyTo, 'onlyTo', i, moduleNames, errors); } +} - // Validate rules - if (config.rules) { - if (!Array.isArray(config.rules)) { - errors.push('boundaries.rules must be an array'); - } else { - const moduleNames = config.modules ? new Set(Object.keys(config.modules)) : new Set(); - for (let i = 0; i < config.rules.length; i++) { - const rule = config.rules[i]; - if (!rule.from) { - errors.push(`boundaries.rules[${i}]: missing "from" field`); - } else if (!moduleNames.has(rule.from)) { - errors.push(`boundaries.rules[${i}]: "from" references unknown module "${rule.from}"`); - } - if (rule.notTo && rule.onlyTo) { - errors.push(`boundaries.rules[${i}]: cannot have both "notTo" and "onlyTo"`); - } - if (!rule.notTo && !rule.onlyTo) { - errors.push(`boundaries.rules[${i}]: must have either "notTo" or "onlyTo"`); - } - if (rule.notTo) { - if (!Array.isArray(rule.notTo)) { - errors.push(`boundaries.rules[${i}]: "notTo" must be an array`); - } else { - for (const target of rule.notTo) { - if (!moduleNames.has(target)) { - errors.push( - `boundaries.rules[${i}]: "notTo" references unknown module "${target}"`, - ); - } - } - } - } - if (rule.onlyTo) { - if (!Array.isArray(rule.onlyTo)) { - errors.push(`boundaries.rules[${i}]: "onlyTo" must be an array`); - } else { - for (const target of rule.onlyTo) { - if (!moduleNames.has(target)) { - errors.push( - `boundaries.rules[${i}]: "onlyTo" references unknown module "${target}"`, - ); - } - } - } - } - } +/** + * Validate that module layer assignments match preset layers. + * @param {object} config + * @param {string[]} errors - Mutated + */ +function validateLayerAssignments(config, errors) { + if (!config.preset || !PRESETS[config.preset] || !config.modules) return; + const presetLayers = new Set(PRESETS[config.preset].layers); + for (const [name, value] of Object.entries(config.modules)) { + if (typeof value === 'object' && value.layer && !presetLayers.has(value.layer)) { + errors.push( + `boundaries.modules.${name}: layer "${value.layer}" not in preset "${config.preset}" (valid: ${[...presetLayers].join(', ')})`, + ); } } +} - // Validate preset + layer assignments - if (config.preset && PRESETS[config.preset] && config.modules) { - const presetLayers = new Set(PRESETS[config.preset].layers); - for (const [name, value] of Object.entries(config.modules)) { - if (typeof value === 'object' && value.layer) { - if (!presetLayers.has(value.layer)) { - errors.push( - `boundaries.modules.${name}: layer "${value.layer}" not in preset "${config.preset}" (valid: ${[...presetLayers].join(', ')})`, - ); - } - } - } +/** + * Validate a boundary configuration object. + * @param {object} config - The `manifesto.boundaries` config + * @returns {{ valid: boolean, errors: string[] }} + */ +export function validateBoundaryConfig(config) { + if (!config || typeof config !== 'object') { + return { valid: false, errors: ['boundaries config must be an object'] }; } + const errors = []; + validateModules(config.modules, errors); + validatePreset(config.preset, errors); + validateRules(config.rules, config.modules, errors); + validateLayerAssignments(config, errors); return { valid: errors.length === 0, errors }; } diff --git a/src/features/cfg.js b/src/features/cfg.js index ae1b8564..3f029274 100644 --- a/src/features/cfg.js +++ b/src/features/cfg.js @@ -68,30 +68,15 @@ export function buildFunctionCFG(functionNode, langId) { return { blocks: r.blocks, edges: r.edges, cyclomatic: r.cyclomatic }; } -// ─── Build-Time: Compute CFG for Changed Files ───────────────────────── +// ─── Build-Time Helpers ───────────────────────────────────────────────── -/** - * Build CFG data for all function/method definitions and persist to DB. - * - * @param {object} db - open better-sqlite3 database (read-write) - * @param {Map} fileSymbols - Map - * @param {string} rootDir - absolute project root path - * @param {object} [_engineOpts] - engine options (unused; always uses WASM for AST) - */ -export async function buildCFGData(db, fileSymbols, rootDir, _engineOpts) { - // Lazily init WASM parsers if needed - let parsers = null; +async function initCfgParsers(fileSymbols) { let needsFallback = false; - // Always build ext→langId map so native-only builds (where _langId is unset) - // can still derive the language from the file extension. - const extToLang = buildExtToLangMap(); - for (const [relPath, symbols] of fileSymbols) { if (!symbols._tree) { const ext = path.extname(relPath).toLowerCase(); if (CFG_EXTENSIONS.has(ext)) { - // Check if all function/method defs already have native CFG data const hasNativeCfg = symbols.definitions .filter((d) => (d.kind === 'function' || d.kind === 'method') && d.line) .every((d) => d.cfg === null || d.cfg?.blocks?.length); @@ -103,18 +88,131 @@ export async function buildCFGData(db, fileSymbols, rootDir, _engineOpts) { } } + let parsers = null; + let getParserFn = null; + if (needsFallback) { const { createParsers } = await import('../domain/parser.js'); parsers = await createParsers(); - } - - let getParserFn = null; - if (parsers) { const mod = await import('../domain/parser.js'); getParserFn = mod.getParser; } - // findFunctionNode imported from ./ast-analysis/shared.js at module level + return { parsers, getParserFn }; +} + +function getTreeAndLang(symbols, relPath, rootDir, extToLang, parsers, getParserFn) { + const ext = path.extname(relPath).toLowerCase(); + let tree = symbols._tree; + let langId = symbols._langId; + + const allNative = symbols.definitions + .filter((d) => (d.kind === 'function' || d.kind === 'method') && d.line) + .every((d) => d.cfg === null || d.cfg?.blocks?.length); + + if (!tree && !allNative) { + if (!getParserFn) return null; + langId = extToLang.get(ext); + if (!langId || !CFG_RULES.has(langId)) return null; + + const absPath = path.join(rootDir, relPath); + let code; + try { + code = fs.readFileSync(absPath, 'utf-8'); + } catch (e) { + debug(`cfg: cannot read ${relPath}: ${e.message}`); + return null; + } + + const parser = getParserFn(parsers, absPath); + if (!parser) return null; + + try { + tree = parser.parse(code); + } catch (e) { + debug(`cfg: parse failed for ${relPath}: ${e.message}`); + return null; + } + } + + if (!langId) { + langId = extToLang.get(ext); + if (!langId) return null; + } + + return { tree, langId }; +} + +function buildVisitorCfgMap(tree, cfgRules, symbols, langId) { + const needsVisitor = + tree && + symbols.definitions.some( + (d) => + (d.kind === 'function' || d.kind === 'method') && + d.line && + d.cfg !== null && + !d.cfg?.blocks?.length, + ); + if (!needsVisitor) return null; + + const visitor = createCfgVisitor(cfgRules); + const walkerOpts = { + functionNodeTypes: new Set(cfgRules.functionNodes), + nestingNodeTypes: new Set(), + getFunctionName: (node) => { + const nameNode = node.childForFieldName('name'); + return nameNode ? nameNode.text : null; + }, + }; + const walkResults = walkWithVisitors(tree.rootNode, [visitor], langId, walkerOpts); + const cfgResults = walkResults.cfg || []; + const visitorCfgByLine = new Map(); + for (const r of cfgResults) { + if (r.funcNode) { + const line = r.funcNode.startPosition.row + 1; + if (!visitorCfgByLine.has(line)) visitorCfgByLine.set(line, []); + visitorCfgByLine.get(line).push(r); + } + } + return visitorCfgByLine; +} + +function persistCfg(cfg, nodeId, insertBlock, insertEdge) { + const blockDbIds = new Map(); + for (const block of cfg.blocks) { + const result = insertBlock.run( + nodeId, + block.index, + block.type, + block.startLine, + block.endLine, + block.label, + ); + blockDbIds.set(block.index, result.lastInsertRowid); + } + + for (const edge of cfg.edges) { + const sourceDbId = blockDbIds.get(edge.sourceIndex); + const targetDbId = blockDbIds.get(edge.targetIndex); + if (sourceDbId && targetDbId) { + insertEdge.run(nodeId, sourceDbId, targetDbId, edge.kind); + } + } +} + +// ─── Build-Time: Compute CFG for Changed Files ───────────────────────── + +/** + * Build CFG data for all function/method definitions and persist to DB. + * + * @param {object} db - open better-sqlite3 database (read-write) + * @param {Map} fileSymbols - Map + * @param {string} rootDir - absolute project root path + * @param {object} [_engineOpts] - engine options (unused; always uses WASM for AST) + */ +export async function buildCFGData(db, fileSymbols, rootDir, _engineOpts) { + const extToLang = buildExtToLangMap(); + const { parsers, getParserFn } = await initCfgParsers(fileSymbols); const insertBlock = db.prepare( `INSERT INTO cfg_blocks (function_node_id, block_index, block_type, start_line, end_line, label) @@ -131,81 +229,14 @@ export async function buildCFGData(db, fileSymbols, rootDir, _engineOpts) { const ext = path.extname(relPath).toLowerCase(); if (!CFG_EXTENSIONS.has(ext)) continue; - let tree = symbols._tree; - let langId = symbols._langId; - - // Check if all defs already have native CFG — skip WASM parse if so - const allNative = symbols.definitions - .filter((d) => (d.kind === 'function' || d.kind === 'method') && d.line) - .every((d) => d.cfg === null || d.cfg?.blocks?.length); - - // WASM fallback if no cached tree and not all native - if (!tree && !allNative) { - if (!getParserFn) continue; - langId = extToLang.get(ext); - if (!langId || !CFG_RULES.has(langId)) continue; - - const absPath = path.join(rootDir, relPath); - let code; - try { - code = fs.readFileSync(absPath, 'utf-8'); - } catch (e) { - debug(`cfg: cannot read ${relPath}: ${e.message}`); - continue; - } - - const parser = getParserFn(parsers, absPath); - if (!parser) continue; - - try { - tree = parser.parse(code); - } catch (e) { - debug(`cfg: parse failed for ${relPath}: ${e.message}`); - continue; - } - } - - if (!langId) { - langId = extToLang.get(ext); - if (!langId) continue; - } + const treeLang = getTreeAndLang(symbols, relPath, rootDir, extToLang, parsers, getParserFn); + if (!treeLang) continue; + const { tree, langId } = treeLang; const cfgRules = CFG_RULES.get(langId); if (!cfgRules) continue; - // WASM fallback: run file-level visitor walk to compute CFG for all functions - // that don't already have pre-computed data (from native engine or unified walk) - let visitorCfgByLine = null; - const needsVisitor = - tree && - symbols.definitions.some( - (d) => - (d.kind === 'function' || d.kind === 'method') && - d.line && - d.cfg !== null && - !d.cfg?.blocks?.length, - ); - if (needsVisitor) { - const visitor = createCfgVisitor(cfgRules); - const walkerOpts = { - functionNodeTypes: new Set(cfgRules.functionNodes), - nestingNodeTypes: new Set(), - getFunctionName: (node) => { - const nameNode = node.childForFieldName('name'); - return nameNode ? nameNode.text : null; - }, - }; - const walkResults = walkWithVisitors(tree.rootNode, [visitor], langId, walkerOpts); - const cfgResults = walkResults.cfg || []; - visitorCfgByLine = new Map(); - for (const r of cfgResults) { - if (r.funcNode) { - const line = r.funcNode.startPosition.row + 1; - if (!visitorCfgByLine.has(line)) visitorCfgByLine.set(line, []); - visitorCfgByLine.get(line).push(r); - } - } - } + const visitorCfgByLine = buildVisitorCfgMap(tree, cfgRules, symbols, langId); for (const def of symbols.definitions) { if (def.kind !== 'function' && def.kind !== 'method') continue; @@ -214,7 +245,6 @@ export async function buildCFGData(db, fileSymbols, rootDir, _engineOpts) { const nodeId = getFunctionNodeId(db, def.name, relPath, def.line); if (!nodeId) continue; - // Use pre-computed CFG (native engine or unified walk), then visitor fallback let cfg = null; if (def.cfg?.blocks?.length) { cfg = def.cfg; @@ -233,36 +263,10 @@ export async function buildCFGData(db, fileSymbols, rootDir, _engineOpts) { if (!cfg || cfg.blocks.length === 0) continue; - // Clear old CFG data for this function deleteCfgForNode(db, nodeId); - - // Insert blocks and build index→dbId mapping - const blockDbIds = new Map(); - for (const block of cfg.blocks) { - const result = insertBlock.run( - nodeId, - block.index, - block.type, - block.startLine, - block.endLine, - block.label, - ); - blockDbIds.set(block.index, result.lastInsertRowid); - } - - // Insert edges - for (const edge of cfg.edges) { - const sourceDbId = blockDbIds.get(edge.sourceIndex); - const targetDbId = blockDbIds.get(edge.targetIndex); - if (sourceDbId && targetDbId) { - insertEdge.run(nodeId, sourceDbId, targetDbId, edge.kind); - } - } - + persistCfg(cfg, nodeId, insertBlock, insertEdge); analyzed++; } - - // Don't release _tree here — complexity/dataflow may still need it } }); diff --git a/src/features/communities.js b/src/features/communities.js index 062a89b5..f850dc8d 100644 --- a/src/features/communities.js +++ b/src/features/communities.js @@ -11,48 +11,18 @@ function getDirectory(filePath) { return dir === '.' ? '(root)' : dir; } -// ─── Core Analysis ──────────────────────────────────────────────────── +// ─── Community Building ────────────────────────────────────────────── /** - * Run Louvain community detection and return structured data. - * - * @param {string} [customDbPath] - Path to graph.db - * @param {object} [opts] - * @param {boolean} [opts.functions] - Function-level instead of file-level - * @param {number} [opts.resolution] - Louvain resolution (default 1.0) - * @param {boolean} [opts.noTests] - Exclude test files - * @param {boolean} [opts.drift] - Drift-only mode (omit community member lists) - * @param {boolean} [opts.json] - JSON output (used by CLI wrapper only) - * @returns {{ communities: object[], modularity: number, drift: object, summary: object }} + * Group graph nodes by Louvain community assignment and build structured objects. + * @param {object} graph - The dependency graph + * @param {Map} assignments - Node key → community ID + * @param {object} opts + * @param {boolean} [opts.drift] - If true, omit member lists + * @returns {{ communities: object[], communityDirs: Map> }} */ -export function communitiesData(customDbPath, opts = {}) { - const { repo, close } = openRepo(customDbPath, opts); - let graph; - try { - graph = buildDependencyGraph(repo, { - fileLevel: !opts.functions, - noTests: opts.noTests, - }); - } finally { - close(); - } - - // Handle empty or trivial graphs - if (graph.nodeCount === 0 || graph.edgeCount === 0) { - return { - communities: [], - modularity: 0, - drift: { splitCandidates: [], mergeCandidates: [] }, - summary: { communityCount: 0, modularity: 0, nodeCount: graph.nodeCount, driftScore: 0 }, - }; - } - - // Run Louvain - const resolution = opts.resolution ?? 1.0; - const { assignments, modularity } = louvainCommunities(graph, { resolution }); - - // Group nodes by community - const communityMap = new Map(); // community id → node keys[] +function buildCommunityObjects(graph, assignments, opts) { + const communityMap = new Map(); for (const [key] of graph.nodes()) { const cid = assignments.get(key); if (cid == null) continue; @@ -60,9 +30,8 @@ export function communitiesData(customDbPath, opts = {}) { communityMap.get(cid).push(key); } - // Build community objects const communities = []; - const communityDirs = new Map(); // community id → Set + const communityDirs = new Map(); for (const [cid, members] of communityMap) { const dirCounts = {}; @@ -88,19 +57,27 @@ export function communitiesData(customDbPath, opts = {}) { }); } - // Sort by size descending communities.sort((a, b) => b.size - a.size); + return { communities, communityDirs }; +} - // ─── Drift Analysis ───────────────────────────────────────────── +// ─── Drift Analysis ────────────────────────────────────────────────── - // Split candidates: directories with members in 2+ communities - const dirToCommunities = new Map(); // dir → Set +/** + * Compute split/merge candidates and drift score from community directory data. + * @param {object[]} communities - Community objects with `directories` + * @param {Map>} communityDirs - Community ID → directory set + * @returns {{ splitCandidates: object[], mergeCandidates: object[], driftScore: number }} + */ +function analyzeDrift(communities, communityDirs) { + const dirToCommunities = new Map(); for (const [cid, dirs] of communityDirs) { for (const dir of dirs) { if (!dirToCommunities.has(dir)) dirToCommunities.set(dir, new Set()); dirToCommunities.get(dir).add(cid); } } + const splitCandidates = []; for (const [dir, cids] of dirToCommunities) { if (cids.size >= 2) { @@ -109,7 +86,6 @@ export function communitiesData(customDbPath, opts = {}) { } splitCandidates.sort((a, b) => b.communityCount - a.communityCount); - // Merge candidates: communities spanning 2+ directories const mergeCandidates = []; for (const c of communities) { const dirCount = Object.keys(c.directories).length; @@ -124,17 +100,56 @@ export function communitiesData(customDbPath, opts = {}) { } mergeCandidates.sort((a, b) => b.directoryCount - a.directoryCount); - // Drift score: 0-100 based on how much directory structure diverges from communities const totalDirs = dirToCommunities.size; - const splitDirs = splitCandidates.length; - const splitRatio = totalDirs > 0 ? splitDirs / totalDirs : 0; - + const splitRatio = totalDirs > 0 ? splitCandidates.length / totalDirs : 0; const totalComms = communities.length; - const mergeComms = mergeCandidates.length; - const mergeRatio = totalComms > 0 ? mergeComms / totalComms : 0; - + const mergeRatio = totalComms > 0 ? mergeCandidates.length / totalComms : 0; const driftScore = Math.round(((splitRatio + mergeRatio) / 2) * 100); + return { splitCandidates, mergeCandidates, driftScore }; +} + +// ─── Core Analysis ──────────────────────────────────────────────────── + +/** + * Run Louvain community detection and return structured data. + * + * @param {string} [customDbPath] - Path to graph.db + * @param {object} [opts] + * @param {boolean} [opts.functions] - Function-level instead of file-level + * @param {number} [opts.resolution] - Louvain resolution (default 1.0) + * @param {boolean} [opts.noTests] - Exclude test files + * @param {boolean} [opts.drift] - Drift-only mode (omit community member lists) + * @param {boolean} [opts.json] - JSON output (used by CLI wrapper only) + * @returns {{ communities: object[], modularity: number, drift: object, summary: object }} + */ +export function communitiesData(customDbPath, opts = {}) { + const { repo, close } = openRepo(customDbPath, opts); + let graph; + try { + graph = buildDependencyGraph(repo, { + fileLevel: !opts.functions, + noTests: opts.noTests, + }); + } finally { + close(); + } + + if (graph.nodeCount === 0 || graph.edgeCount === 0) { + return { + communities: [], + modularity: 0, + drift: { splitCandidates: [], mergeCandidates: [] }, + summary: { communityCount: 0, modularity: 0, nodeCount: graph.nodeCount, driftScore: 0 }, + }; + } + + const resolution = opts.resolution ?? 1.0; + const { assignments, modularity } = louvainCommunities(graph, { resolution }); + + const { communities, communityDirs } = buildCommunityObjects(graph, assignments, opts); + const { splitCandidates, mergeCandidates, driftScore } = analyzeDrift(communities, communityDirs); + const base = { communities: opts.drift ? [] : communities, modularity: +modularity.toFixed(4), diff --git a/src/features/complexity.js b/src/features/complexity.js index 12f5acf1..80a55377 100644 --- a/src/features/complexity.js +++ b/src/features/complexity.js @@ -330,41 +330,138 @@ export function computeAllMetrics(functionNode, langId) { */ export { _findFunctionNode as findFunctionNode }; -/** - * Re-parse changed files with WASM tree-sitter, find function AST subtrees, - * compute complexity, and upsert into function_complexity table. - * - * @param {object} db - open better-sqlite3 database (read-write) - * @param {Map} fileSymbols - Map - * @param {string} rootDir - absolute project root path - * @param {object} [engineOpts] - engine options (unused; always uses WASM for AST) - */ -export async function buildComplexityMetrics(db, fileSymbols, rootDir, _engineOpts) { - // Only initialize WASM parsers if some files lack both a cached tree AND pre-computed complexity - let parsers = null; - let extToLang = null; - let needsFallback = false; +async function initWasmParsersIfNeeded(fileSymbols) { for (const [relPath, symbols] of fileSymbols) { if (!symbols._tree) { - // Only consider files whose language actually has complexity rules const ext = path.extname(relPath).toLowerCase(); if (!COMPLEXITY_EXTENSIONS.has(ext)) continue; - // Check if all function/method defs have pre-computed complexity (native engine) const hasPrecomputed = symbols.definitions.every( (d) => (d.kind !== 'function' && d.kind !== 'method') || d.complexity, ); if (!hasPrecomputed) { - needsFallback = true; - break; + const { createParsers } = await import('../domain/parser.js'); + const parsers = await createParsers(); + const extToLang = buildExtToLangMap(); + return { parsers, extToLang }; } } } - if (needsFallback) { - const { createParsers } = await import('../domain/parser.js'); - parsers = await createParsers(); - extToLang = buildExtToLangMap(); + return { parsers: null, extToLang: null }; +} + +function getTreeForFile(symbols, relPath, rootDir, parsers, extToLang, getParser) { + let tree = symbols._tree; + let langId = symbols._langId; + + const allPrecomputed = symbols.definitions.every( + (d) => (d.kind !== 'function' && d.kind !== 'method') || d.complexity, + ); + + if (!allPrecomputed && !tree) { + const ext = path.extname(relPath).toLowerCase(); + if (!COMPLEXITY_EXTENSIONS.has(ext)) return null; + if (!extToLang) return null; + langId = extToLang.get(ext); + if (!langId) return null; + + const absPath = path.join(rootDir, relPath); + let code; + try { + code = fs.readFileSync(absPath, 'utf-8'); + } catch (e) { + debug(`complexity: cannot read ${relPath}: ${e.message}`); + return null; + } + + const parser = getParser(parsers, absPath); + if (!parser) return null; + + try { + tree = parser.parse(code); + } catch (e) { + debug(`complexity: parse failed for ${relPath}: ${e.message}`); + return null; + } } + return { tree, langId }; +} + +function upsertPrecomputedComplexity(db, upsert, def, relPath) { + const nodeId = getFunctionNodeId(db, def.name, relPath, def.line); + if (!nodeId) return 0; + const ch = def.complexity.halstead; + const cl = def.complexity.loc; + upsert.run( + nodeId, + def.complexity.cognitive, + def.complexity.cyclomatic, + def.complexity.maxNesting ?? 0, + cl ? cl.loc : 0, + cl ? cl.sloc : 0, + cl ? cl.commentLines : 0, + ch ? ch.n1 : 0, + ch ? ch.n2 : 0, + ch ? ch.bigN1 : 0, + ch ? ch.bigN2 : 0, + ch ? ch.vocabulary : 0, + ch ? ch.length : 0, + ch ? ch.volume : 0, + ch ? ch.difficulty : 0, + ch ? ch.effort : 0, + ch ? ch.bugs : 0, + def.complexity.maintainabilityIndex ?? 0, + ); + return 1; +} + +function upsertAstComplexity(db, upsert, def, relPath, tree, langId, rules) { + if (!tree || !rules) return 0; + + const funcNode = _findFunctionNode(tree.rootNode, def.line, def.endLine, rules); + if (!funcNode) return 0; + + const metrics = computeAllMetrics(funcNode, langId); + if (!metrics) return 0; + + const nodeId = getFunctionNodeId(db, def.name, relPath, def.line); + if (!nodeId) return 0; + + const h = metrics.halstead; + upsert.run( + nodeId, + metrics.cognitive, + metrics.cyclomatic, + metrics.maxNesting, + metrics.loc.loc, + metrics.loc.sloc, + metrics.loc.commentLines, + h ? h.n1 : 0, + h ? h.n2 : 0, + h ? h.bigN1 : 0, + h ? h.bigN2 : 0, + h ? h.vocabulary : 0, + h ? h.length : 0, + h ? h.volume : 0, + h ? h.difficulty : 0, + h ? h.effort : 0, + h ? h.bugs : 0, + metrics.mi, + ); + return 1; +} + +/** + * Re-parse changed files with WASM tree-sitter, find function AST subtrees, + * compute complexity, and upsert into function_complexity table. + * + * @param {object} db - open better-sqlite3 database (read-write) + * @param {Map} fileSymbols - Map + * @param {string} rootDir - absolute project root path + * @param {object} [engineOpts] - engine options (unused; always uses WASM for AST) + */ +export async function buildComplexityMetrics(db, fileSymbols, rootDir, _engineOpts) { + const { parsers, extToLang } = await initWasmParsersIfNeeded(fileSymbols); const { getParser } = await import('../domain/parser.js'); const upsert = db.prepare( @@ -381,41 +478,9 @@ export async function buildComplexityMetrics(db, fileSymbols, rootDir, _engineOp const tx = db.transaction(() => { for (const [relPath, symbols] of fileSymbols) { - // Check if all function/method defs have pre-computed complexity - const allPrecomputed = symbols.definitions.every( - (d) => (d.kind !== 'function' && d.kind !== 'method') || d.complexity, - ); - - let tree = symbols._tree; - let langId = symbols._langId; - - // Only attempt WASM fallback if we actually need AST-based computation - if (!allPrecomputed && !tree) { - const ext = path.extname(relPath).toLowerCase(); - if (!COMPLEXITY_EXTENSIONS.has(ext)) continue; // Language has no complexity rules - if (!extToLang) continue; // No WASM parsers available - langId = extToLang.get(ext); - if (!langId) continue; - - const absPath = path.join(rootDir, relPath); - let code; - try { - code = fs.readFileSync(absPath, 'utf-8'); - } catch (e) { - debug(`complexity: cannot read ${relPath}: ${e.message}`); - continue; - } - - const parser = getParser(parsers, absPath); - if (!parser) continue; - - try { - tree = parser.parse(code); - } catch (e) { - debug(`complexity: parse failed for ${relPath}: ${e.message}`); - continue; - } - } + const result = getTreeForFile(symbols, relPath, rootDir, parsers, extToLang, getParser); + const tree = result ? result.tree : null; + const langId = result ? result.langId : null; const rules = langId ? COMPLEXITY_RULES.get(langId) : null; @@ -423,71 +488,11 @@ export async function buildComplexityMetrics(db, fileSymbols, rootDir, _engineOp if (def.kind !== 'function' && def.kind !== 'method') continue; if (!def.line) continue; - // Use pre-computed complexity from native engine if available if (def.complexity) { - const nodeId = getFunctionNodeId(db, def.name, relPath, def.line); - if (!nodeId) continue; - const ch = def.complexity.halstead; - const cl = def.complexity.loc; - upsert.run( - nodeId, - def.complexity.cognitive, - def.complexity.cyclomatic, - def.complexity.maxNesting ?? 0, - cl ? cl.loc : 0, - cl ? cl.sloc : 0, - cl ? cl.commentLines : 0, - ch ? ch.n1 : 0, - ch ? ch.n2 : 0, - ch ? ch.bigN1 : 0, - ch ? ch.bigN2 : 0, - ch ? ch.vocabulary : 0, - ch ? ch.length : 0, - ch ? ch.volume : 0, - ch ? ch.difficulty : 0, - ch ? ch.effort : 0, - ch ? ch.bugs : 0, - def.complexity.maintainabilityIndex ?? 0, - ); - analyzed++; - continue; + analyzed += upsertPrecomputedComplexity(db, upsert, def, relPath); + } else { + analyzed += upsertAstComplexity(db, upsert, def, relPath, tree, langId, rules); } - - // Fallback: compute from AST tree - if (!tree || !rules) continue; - - const funcNode = _findFunctionNode(tree.rootNode, def.line, def.endLine, rules); - if (!funcNode) continue; - - // Single-pass: complexity + Halstead + LOC + MI in one DFS walk - const metrics = computeAllMetrics(funcNode, langId); - if (!metrics) continue; - - const nodeId = getFunctionNodeId(db, def.name, relPath, def.line); - if (!nodeId) continue; - - const h = metrics.halstead; - upsert.run( - nodeId, - metrics.cognitive, - metrics.cyclomatic, - metrics.maxNesting, - metrics.loc.loc, - metrics.loc.sloc, - metrics.loc.commentLines, - h ? h.n1 : 0, - h ? h.n2 : 0, - h ? h.bigN1 : 0, - h ? h.bigN2 : 0, - h ? h.vocabulary : 0, - h ? h.length : 0, - h ? h.volume : 0, - h ? h.difficulty : 0, - h ? h.effort : 0, - h ? h.bugs : 0, - metrics.mi, - ); - analyzed++; } } }); diff --git a/src/features/dataflow.js b/src/features/dataflow.js index 695afa95..2dee25b6 100644 --- a/src/features/dataflow.js +++ b/src/features/dataflow.js @@ -58,26 +58,11 @@ export function extractDataflow(tree, _filePath, _definitions, langId = 'javascr return results.dataflow; } -// ── buildDataflowEdges ────────────────────────────────────────────────────── +// ── Build-Time Helpers ────────────────────────────────────────────────────── -/** - * Build dataflow edges and insert them into the database. - * Called during graph build when --dataflow is enabled. - * - * @param {object} db - better-sqlite3 database instance - * @param {Map} fileSymbols - map of relPath → symbols - * @param {string} rootDir - absolute root directory - * @param {object} engineOpts - engine options - */ -export async function buildDataflowEdges(db, fileSymbols, rootDir, _engineOpts) { - // Lazily init WASM parsers if needed - let parsers = null; +async function initDataflowParsers(fileSymbols) { let needsFallback = false; - // Always build ext→langId map so native-only builds (where _langId is unset) - // can still derive the language from the file extension. - const extToLang = buildExtToLangMap(); - for (const [relPath, symbols] of fileSymbols) { if (!symbols._tree && !symbols.dataflow) { const ext = path.extname(relPath).toLowerCase(); @@ -88,25 +73,130 @@ export async function buildDataflowEdges(db, fileSymbols, rootDir, _engineOpts) } } + let parsers = null; + let getParserFn = null; + if (needsFallback) { const { createParsers } = await import('../domain/parser.js'); parsers = await createParsers(); - } - - let getParserFn = null; - if (parsers) { const mod = await import('../domain/parser.js'); getParserFn = mod.getParser; } + return { parsers, getParserFn }; +} + +function getDataflowForFile(symbols, relPath, rootDir, extToLang, parsers, getParserFn) { + if (symbols.dataflow) return symbols.dataflow; + + let tree = symbols._tree; + let langId = symbols._langId; + + if (!tree) { + if (!getParserFn) return null; + const ext = path.extname(relPath).toLowerCase(); + langId = extToLang.get(ext); + if (!langId || !DATAFLOW_RULES.has(langId)) return null; + + const absPath = path.join(rootDir, relPath); + let code; + try { + code = fs.readFileSync(absPath, 'utf-8'); + } catch (e) { + debug(`dataflow: cannot read ${relPath}: ${e.message}`); + return null; + } + + const parser = getParserFn(parsers, absPath); + if (!parser) return null; + + try { + tree = parser.parse(code); + } catch (e) { + debug(`dataflow: parse failed for ${relPath}: ${e.message}`); + return null; + } + } + + if (!langId) { + const ext = path.extname(relPath).toLowerCase(); + langId = extToLang.get(ext); + if (!langId) return null; + } + + if (!DATAFLOW_RULES.has(langId)) return null; + + return extractDataflow(tree, relPath, symbols.definitions, langId); +} + +function insertDataflowEdges(insert, data, resolveNode) { + let edgeCount = 0; + + for (const flow of data.argFlows) { + const sourceNode = resolveNode(flow.callerFunc); + const targetNode = resolveNode(flow.calleeName); + if (sourceNode && targetNode) { + insert.run( + sourceNode.id, + targetNode.id, + 'flows_to', + flow.argIndex, + flow.expression, + flow.line, + flow.confidence, + ); + edgeCount++; + } + } + + for (const assignment of data.assignments) { + const producerNode = resolveNode(assignment.sourceCallName); + const consumerNode = resolveNode(assignment.callerFunc); + if (producerNode && consumerNode) { + insert.run( + producerNode.id, + consumerNode.id, + 'returns', + null, + assignment.expression, + assignment.line, + 1.0, + ); + edgeCount++; + } + } + + for (const mut of data.mutations) { + const mutatorNode = resolveNode(mut.funcName); + if (mutatorNode && mut.binding?.type === 'param') { + insert.run(mutatorNode.id, mutatorNode.id, 'mutates', null, mut.mutatingExpr, mut.line, 1.0); + edgeCount++; + } + } + + return edgeCount; +} + +// ── buildDataflowEdges ────────────────────────────────────────────────────── + +/** + * Build dataflow edges and insert them into the database. + * Called during graph build when --dataflow is enabled. + * + * @param {object} db - better-sqlite3 database instance + * @param {Map} fileSymbols - map of relPath → symbols + * @param {string} rootDir - absolute root directory + * @param {object} engineOpts - engine options + */ +export async function buildDataflowEdges(db, fileSymbols, rootDir, _engineOpts) { + const extToLang = buildExtToLangMap(); + const { parsers, getParserFn } = await initDataflowParsers(fileSymbols); + const insert = db.prepare( `INSERT INTO dataflow (source_id, target_id, kind, param_index, expression, line, confidence) VALUES (?, ?, ?, ?, ?, ?, ?)`, ); - // MVP scope: only resolve function/method nodes for dataflow edges. - // Future expansion: add 'parameter', 'property', 'constant' kinds to track - // data flow through property accessors or constant references. const getNodeByNameAndFile = db.prepare( `SELECT id, name, kind, file, line FROM nodes WHERE name = ? AND file = ? AND kind IN ('function', 'method')`, @@ -125,109 +215,17 @@ export async function buildDataflowEdges(db, fileSymbols, rootDir, _engineOpts) const ext = path.extname(relPath).toLowerCase(); if (!DATAFLOW_EXTENSIONS.has(ext)) continue; - // Use native dataflow data if available — skip WASM extraction - let data = symbols.dataflow; - if (!data) { - let tree = symbols._tree; - let langId = symbols._langId; - - // WASM fallback if no cached tree - if (!tree) { - if (!getParserFn) continue; - langId = extToLang.get(ext); - if (!langId || !DATAFLOW_RULES.has(langId)) continue; - - const absPath = path.join(rootDir, relPath); - let code; - try { - code = fs.readFileSync(absPath, 'utf-8'); - } catch (e) { - debug(`dataflow: cannot read ${relPath}: ${e.message}`); - continue; - } - - const parser = getParserFn(parsers, absPath); - if (!parser) continue; - - try { - tree = parser.parse(code); - } catch (e) { - debug(`dataflow: parse failed for ${relPath}: ${e.message}`); - continue; - } - } - - if (!langId) { - langId = extToLang.get(ext); - if (!langId) continue; - } - - if (!DATAFLOW_RULES.has(langId)) continue; - - data = extractDataflow(tree, relPath, symbols.definitions, langId); - } + const data = getDataflowForFile(symbols, relPath, rootDir, extToLang, parsers, getParserFn); + if (!data) continue; - // Resolve function names to node IDs in this file first, then globally - function resolveNode(funcName) { + const resolveNode = (funcName) => { const local = getNodeByNameAndFile.all(funcName, relPath); if (local.length > 0) return local[0]; const global = getNodeByName.all(funcName); return global.length > 0 ? global[0] : null; - } - - // flows_to: parameter/variable passed as argument to another function - for (const flow of data.argFlows) { - const sourceNode = resolveNode(flow.callerFunc); - const targetNode = resolveNode(flow.calleeName); - if (sourceNode && targetNode) { - insert.run( - sourceNode.id, - targetNode.id, - 'flows_to', - flow.argIndex, - flow.expression, - flow.line, - flow.confidence, - ); - totalEdges++; - } - } - - // returns: call return value captured in caller - for (const assignment of data.assignments) { - const producerNode = resolveNode(assignment.sourceCallName); - const consumerNode = resolveNode(assignment.callerFunc); - if (producerNode && consumerNode) { - insert.run( - producerNode.id, - consumerNode.id, - 'returns', - null, - assignment.expression, - assignment.line, - 1.0, - ); - totalEdges++; - } - } + }; - // mutates: parameter-derived value is mutated - for (const mut of data.mutations) { - const mutatorNode = resolveNode(mut.funcName); - if (mutatorNode && mut.binding?.type === 'param') { - // The mutation in this function affects the parameter source - insert.run( - mutatorNode.id, - mutatorNode.id, - 'mutates', - null, - mut.mutatingExpr, - mut.line, - 1.0, - ); - totalEdges++; - } - } + totalEdges += insertDataflowEdges(insert, data, resolveNode); } }); diff --git a/src/features/sequence.js b/src/features/sequence.js index 271d2ea2..cf59ddc3 100644 --- a/src/features/sequence.js +++ b/src/features/sequence.js @@ -68,6 +68,148 @@ function buildAliases(files) { return aliases; } +// ─── Helpers ───────────────────────────────────────────────────────── + +function findEntryNode(repo, name, opts) { + let matchNode = findMatchingNodes(repo, name, opts)[0] ?? null; + if (!matchNode) { + for (const prefix of FRAMEWORK_ENTRY_PREFIXES) { + matchNode = findMatchingNodes(repo, `${prefix}${name}`, opts)[0] ?? null; + if (matchNode) break; + } + } + return matchNode; +} + +function bfsCallees(repo, matchNode, maxDepth, noTests) { + const visited = new Set([matchNode.id]); + let frontier = [matchNode.id]; + const messages = []; + const fileSet = new Set([matchNode.file]); + const idToNode = new Map(); + idToNode.set(matchNode.id, matchNode); + let truncated = false; + + for (let d = 1; d <= maxDepth; d++) { + const nextFrontier = []; + + for (const fid of frontier) { + const callees = repo.findCallees(fid); + const caller = idToNode.get(fid); + + for (const c of callees) { + if (noTests && isTestFile(c.file)) continue; + + fileSet.add(c.file); + messages.push({ + from: caller.file, + to: c.file, + label: c.name, + type: 'call', + depth: d, + }); + + if (visited.has(c.id)) continue; + + visited.add(c.id); + nextFrontier.push(c.id); + idToNode.set(c.id, c); + } + } + + frontier = nextFrontier; + if (frontier.length === 0) break; + + if (d === maxDepth && frontier.length > 0) { + const hasMoreCalls = frontier.some((fid) => repo.findCallees(fid).length > 0); + if (hasMoreCalls) truncated = true; + } + } + + return { messages, fileSet, idToNode, truncated }; +} + +function annotateDataflow(repo, messages, idToNode) { + const hasTable = repo.hasDataflowTable(); + + if (!hasTable || !(repo instanceof SqliteRepository)) return; + + const db = repo.db; + const nodeByNameFile = new Map(); + for (const n of idToNode.values()) { + nodeByNameFile.set(`${n.name}|${n.file}`, n); + } + + const getReturns = db.prepare( + `SELECT d.expression FROM dataflow d + WHERE d.source_id = ? AND d.kind = 'returns'`, + ); + const getFlowsTo = db.prepare( + `SELECT d.expression FROM dataflow d + WHERE d.target_id = ? AND d.kind = 'flows_to' + ORDER BY d.param_index`, + ); + + const seenReturns = new Set(); + for (const msg of [...messages]) { + if (msg.type !== 'call') continue; + const targetNode = nodeByNameFile.get(`${msg.label}|${msg.to}`); + if (!targetNode) continue; + + const returnKey = `${msg.to}->${msg.from}:${msg.label}`; + if (seenReturns.has(returnKey)) continue; + + const returns = getReturns.all(targetNode.id); + + if (returns.length > 0) { + seenReturns.add(returnKey); + const expr = returns[0].expression || 'result'; + messages.push({ + from: msg.to, + to: msg.from, + label: expr, + type: 'return', + depth: msg.depth, + }); + } + } + + for (const msg of messages) { + if (msg.type !== 'call') continue; + const targetNode = nodeByNameFile.get(`${msg.label}|${msg.to}`); + if (!targetNode) continue; + + const params = getFlowsTo.all(targetNode.id); + + if (params.length > 0) { + const paramNames = params + .map((p) => p.expression) + .filter(Boolean) + .slice(0, 3); + if (paramNames.length > 0) { + msg.label = `${msg.label}(${paramNames.join(', ')})`; + } + } + } +} + +function buildParticipants(fileSet, entryFile) { + const aliases = buildAliases([...fileSet]); + const participants = [...fileSet].map((file) => ({ + id: aliases.get(file), + label: file.split('/').pop(), + file, + })); + + participants.sort((a, b) => { + if (a.file === entryFile) return -1; + if (b.file === entryFile) return 1; + return a.file.localeCompare(b.file); + }); + + return { participants, aliases }; +} + // ─── Core data function ────────────────────────────────────────────── /** @@ -90,19 +232,8 @@ export function sequenceData(name, dbPath, opts = {}) { try { const maxDepth = opts.depth || 10; const noTests = opts.noTests || false; - const withDataflow = opts.dataflow || false; - - // Phase 1: Direct LIKE match - let matchNode = findMatchingNodes(repo, name, opts)[0] ?? null; - - // Phase 2: Prefix-stripped matching - if (!matchNode) { - for (const prefix of FRAMEWORK_ENTRY_PREFIXES) { - matchNode = findMatchingNodes(repo, `${prefix}${name}`, opts)[0] ?? null; - if (matchNode) break; - } - } + const matchNode = findEntryNode(repo, name, opts); if (!matchNode) { return { entry: null, @@ -121,123 +252,17 @@ export function sequenceData(name, dbPath, opts = {}) { line: matchNode.line, }; - // BFS forward — track edges, not just nodes - const visited = new Set([matchNode.id]); - let frontier = [matchNode.id]; - const messages = []; - const fileSet = new Set([matchNode.file]); - const idToNode = new Map(); - idToNode.set(matchNode.id, matchNode); - let truncated = false; - - for (let d = 1; d <= maxDepth; d++) { - const nextFrontier = []; - - for (const fid of frontier) { - const callees = repo.findCallees(fid); - - const caller = idToNode.get(fid); - - for (const c of callees) { - if (noTests && isTestFile(c.file)) continue; - - // Always record the message (even for visited nodes — different caller path) - fileSet.add(c.file); - messages.push({ - from: caller.file, - to: c.file, - label: c.name, - type: 'call', - depth: d, - }); - - if (visited.has(c.id)) continue; - - visited.add(c.id); - nextFrontier.push(c.id); - idToNode.set(c.id, c); - } - } - - frontier = nextFrontier; - if (frontier.length === 0) break; - - if (d === maxDepth && frontier.length > 0) { - // Only mark truncated if at least one frontier node has further callees - const hasMoreCalls = frontier.some((fid) => repo.findCallees(fid).length > 0); - if (hasMoreCalls) truncated = true; - } - } - - // Dataflow annotations: add return arrows - if (withDataflow && messages.length > 0) { - const hasTable = repo.hasDataflowTable(); - - if (hasTable && repo instanceof SqliteRepository) { - const db = repo.db; - // Build name|file lookup for O(1) target node access - const nodeByNameFile = new Map(); - for (const n of idToNode.values()) { - nodeByNameFile.set(`${n.name}|${n.file}`, n); - } - - const getReturns = db.prepare( - `SELECT d.expression FROM dataflow d - WHERE d.source_id = ? AND d.kind = 'returns'`, - ); - const getFlowsTo = db.prepare( - `SELECT d.expression FROM dataflow d - WHERE d.target_id = ? AND d.kind = 'flows_to' - ORDER BY d.param_index`, - ); - - // For each called function, check if it has return edges - const seenReturns = new Set(); - for (const msg of [...messages]) { - if (msg.type !== 'call') continue; - const targetNode = nodeByNameFile.get(`${msg.label}|${msg.to}`); - if (!targetNode) continue; - - const returnKey = `${msg.to}->${msg.from}:${msg.label}`; - if (seenReturns.has(returnKey)) continue; - - const returns = getReturns.all(targetNode.id); - - if (returns.length > 0) { - seenReturns.add(returnKey); - const expr = returns[0].expression || 'result'; - messages.push({ - from: msg.to, - to: msg.from, - label: expr, - type: 'return', - depth: msg.depth, - }); - } - } + const { messages, fileSet, idToNode, truncated } = bfsCallees( + repo, + matchNode, + maxDepth, + noTests, + ); - // Annotate call messages with parameter names - for (const msg of messages) { - if (msg.type !== 'call') continue; - const targetNode = nodeByNameFile.get(`${msg.label}|${msg.to}`); - if (!targetNode) continue; - - const params = getFlowsTo.all(targetNode.id); - - if (params.length > 0) { - const paramNames = params - .map((p) => p.expression) - .filter(Boolean) - .slice(0, 3); - if (paramNames.length > 0) { - msg.label = `${msg.label}(${paramNames.join(', ')})`; - } - } - } - } + if (opts.dataflow && messages.length > 0) { + annotateDataflow(repo, messages, idToNode); } - // Sort messages by depth, then call before return messages.sort((a, b) => { if (a.depth !== b.depth) return a.depth - b.depth; if (a.type === 'call' && b.type === 'return') return -1; @@ -245,22 +270,8 @@ export function sequenceData(name, dbPath, opts = {}) { return 0; }); - // Build participant list from files - const aliases = buildAliases([...fileSet]); - const participants = [...fileSet].map((file) => ({ - id: aliases.get(file), - label: file.split('/').pop(), - file, - })); - - // Sort participants: entry file first, then alphabetically - participants.sort((a, b) => { - if (a.file === entry.file) return -1; - if (b.file === entry.file) return 1; - return a.file.localeCompare(b.file); - }); + const { participants, aliases } = buildParticipants(fileSet, entry.file); - // Replace file paths with alias IDs in messages for (const msg of messages) { msg.from = aliases.get(msg.from); msg.to = aliases.get(msg.to); diff --git a/src/features/structure.js b/src/features/structure.js index 5c91a2d8..860072a5 100644 --- a/src/features/structure.js +++ b/src/features/structure.js @@ -5,73 +5,41 @@ import { isTestFile } from '../infrastructure/test-filter.js'; import { normalizePath } from '../shared/constants.js'; import { paginateResult } from '../shared/paginate.js'; -// ─── Build-time: insert directory nodes, contains edges, and metrics ──── +// ─── Build-time helpers ─────────────────────────────────────────────── -/** - * Build directory structure nodes, containment edges, and compute metrics. - * Called from builder.js after edge building. - * - * @param {import('better-sqlite3').Database} db - Open read-write database - * @param {Map} fileSymbols - Map of relPath → { definitions, imports, exports, calls } - * @param {string} rootDir - Absolute root directory - * @param {Map} lineCountMap - Map of relPath → line count - * @param {Set} directories - Set of relative directory paths - */ -export function buildStructure(db, fileSymbols, _rootDir, lineCountMap, directories, changedFiles) { - const insertNode = db.prepare( - 'INSERT OR IGNORE INTO nodes (name, kind, file, line, end_line) VALUES (?, ?, ?, ?, ?)', - ); - const getNodeIdStmt = { - get: (name, kind, file, line) => { - const id = getNodeId(db, name, kind, file, line); - return id != null ? { id } : undefined; - }, - }; - const insertEdge = db.prepare( - 'INSERT INTO edges (source_id, target_id, kind, confidence, dynamic) VALUES (?, ?, ?, ?, ?)', - ); - const upsertMetric = db.prepare(` - INSERT OR REPLACE INTO node_metrics - (node_id, line_count, symbol_count, import_count, export_count, fan_in, fan_out, cohesion, file_count) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - `); - - const isIncremental = changedFiles != null && changedFiles.length > 0; +function getAncestorDirs(filePaths) { + const dirs = new Set(); + for (const f of filePaths) { + let d = normalizePath(path.dirname(f)); + while (d && d !== '.') { + dirs.add(d); + d = normalizePath(path.dirname(d)); + } + } + return dirs; +} +function cleanupPreviousData(db, getNodeIdStmt, isIncremental, changedFiles) { if (isIncremental) { - // Incremental: only clean up data for changed files and their ancestor directories - const affectedDirs = new Set(); - for (const f of changedFiles) { - let d = normalizePath(path.dirname(f)); - while (d && d !== '.') { - affectedDirs.add(d); - d = normalizePath(path.dirname(d)); - } - } + const affectedDirs = getAncestorDirs(changedFiles); const deleteContainsForDir = db.prepare( "DELETE FROM edges WHERE kind = 'contains' AND source_id IN (SELECT id FROM nodes WHERE name = ? AND kind = 'directory')", ); const deleteMetricForNode = db.prepare('DELETE FROM node_metrics WHERE node_id = ?'); db.transaction(() => { - // Delete contains edges only from affected directories for (const dir of affectedDirs) { deleteContainsForDir.run(dir); } - // Delete metrics for changed files for (const f of changedFiles) { const fileRow = getNodeIdStmt.get(f, 'file', f, 0); if (fileRow) deleteMetricForNode.run(fileRow.id); } - // Delete metrics for affected directories for (const dir of affectedDirs) { const dirRow = getNodeIdStmt.get(dir, 'directory', dir, 0); if (dirRow) deleteMetricForNode.run(dirRow.id); } })(); } else { - // Full rebuild: clean previous directory nodes/edges (idempotent) - // Scope contains-edge delete to directory-sourced edges only, - // preserving symbol-level contains edges (file→def, class→method, etc.) db.exec(` DELETE FROM edges WHERE kind = 'contains' AND source_id IN (SELECT id FROM nodes WHERE kind = 'directory'); @@ -79,8 +47,9 @@ export function buildStructure(db, fileSymbols, _rootDir, lineCountMap, director DELETE FROM nodes WHERE kind = 'directory'; `); } +} - // Step 1: Ensure all directories are represented (including intermediate parents) +function collectAllDirectories(directories, fileSymbols) { const allDirs = new Set(); for (const dir of directories) { let d = dir; @@ -89,7 +58,6 @@ export function buildStructure(db, fileSymbols, _rootDir, lineCountMap, director d = normalizePath(path.dirname(d)); } } - // Also add dirs derived from file paths for (const relPath of fileSymbols.keys()) { let d = normalizePath(path.dirname(relPath)); while (d && d !== '.') { @@ -97,37 +65,17 @@ export function buildStructure(db, fileSymbols, _rootDir, lineCountMap, director d = normalizePath(path.dirname(d)); } } + return allDirs; +} - // Step 2: Insert directory nodes (INSERT OR IGNORE — safe for incremental) - const insertDirs = db.transaction(() => { - for (const dir of allDirs) { - insertNode.run(dir, 'directory', dir, 0, null); - } - }); - insertDirs(); - - // Step 3: Insert 'contains' edges (dir → file, dir → subdirectory) - // On incremental, only re-insert for affected directories (others are intact) - const affectedDirs = isIncremental - ? (() => { - const dirs = new Set(); - for (const f of changedFiles) { - let d = normalizePath(path.dirname(f)); - while (d && d !== '.') { - dirs.add(d); - d = normalizePath(path.dirname(d)); - } - } - return dirs; - })() - : null; +function insertContainsEdges(db, insertEdge, getNodeIdStmt, fileSymbols, allDirs, changedFiles) { + const isIncremental = changedFiles != null && changedFiles.length > 0; + const affectedDirs = isIncremental ? getAncestorDirs(changedFiles) : null; - const insertContains = db.transaction(() => { - // dir → file + db.transaction(() => { for (const relPath of fileSymbols.keys()) { const dir = normalizePath(path.dirname(relPath)); if (!dir || dir === '.') continue; - // On incremental, skip dirs whose contains edges are intact if (affectedDirs && !affectedDirs.has(dir)) continue; const dirRow = getNodeIdStmt.get(dir, 'directory', dir, 0); const fileRow = getNodeIdStmt.get(relPath, 'file', relPath, 0); @@ -135,11 +83,9 @@ export function buildStructure(db, fileSymbols, _rootDir, lineCountMap, director insertEdge.run(dirRow.id, fileRow.id, 'contains', 1.0, 0); } } - // dir → subdirectory for (const dir of allDirs) { const parent = normalizePath(path.dirname(dir)); if (!parent || parent === '.' || parent === dir) continue; - // On incremental, skip parent dirs whose contains edges are intact if (affectedDirs && !affectedDirs.has(parent)) continue; const parentRow = getNodeIdStmt.get(parent, 'directory', parent, 0); const childRow = getNodeIdStmt.get(dir, 'directory', dir, 0); @@ -147,11 +93,10 @@ export function buildStructure(db, fileSymbols, _rootDir, lineCountMap, director insertEdge.run(parentRow.id, childRow.id, 'contains', 1.0, 0); } } - }); - insertContains(); + })(); +} - // Step 4: Compute per-file metrics - // Pre-compute fan-in/fan-out per file from import edges +function computeImportEdgeMaps(db) { const fanInMap = new Map(); const fanOutMap = new Map(); const importEdges = db @@ -169,14 +114,24 @@ export function buildStructure(db, fileSymbols, _rootDir, lineCountMap, director fanOutMap.set(source_file, (fanOutMap.get(source_file) || 0) + 1); fanInMap.set(target_file, (fanInMap.get(target_file) || 0) + 1); } + return { fanInMap, fanOutMap, importEdges }; +} - const computeFileMetrics = db.transaction(() => { +function computeFileMetrics( + db, + upsertMetric, + getNodeIdStmt, + fileSymbols, + lineCountMap, + fanInMap, + fanOutMap, +) { + db.transaction(() => { for (const [relPath, symbols] of fileSymbols) { const fileRow = getNodeIdStmt.get(relPath, 'file', relPath, 0); if (!fileRow) continue; const lineCount = lineCountMap.get(relPath) || 0; - // Deduplicate definitions by name+kind+line const seen = new Set(); let symbolCount = 0; for (const d of symbols.definitions) { @@ -203,11 +158,17 @@ export function buildStructure(db, fileSymbols, _rootDir, lineCountMap, director null, ); } - }); - computeFileMetrics(); + })(); +} - // Step 5: Compute per-directory metrics - // Build a map of dir → descendant files +function computeDirectoryMetrics( + db, + upsertMetric, + getNodeIdStmt, + fileSymbols, + allDirs, + importEdges, +) { const dirFiles = new Map(); for (const dir of allDirs) { dirFiles.set(dir, []); @@ -222,7 +183,6 @@ export function buildStructure(db, fileSymbols, _rootDir, lineCountMap, director } } - // Build reverse index: file → set of ancestor directories (O(files × depth)) const fileToAncestorDirs = new Map(); for (const [dir, files] of dirFiles) { for (const f of files) { @@ -231,7 +191,6 @@ export function buildStructure(db, fileSymbols, _rootDir, lineCountMap, director } } - // Single O(E) pass: pre-aggregate edge counts per directory const dirEdgeCounts = new Map(); for (const dir of allDirs) { dirEdgeCounts.set(dir, { intra: 0, fanIn: 0, fanOut: 0 }); @@ -241,7 +200,6 @@ export function buildStructure(db, fileSymbols, _rootDir, lineCountMap, director const tgtDirs = fileToAncestorDirs.get(target_file); if (!srcDirs && !tgtDirs) continue; - // For each directory that contains the source file if (srcDirs) { for (const dir of srcDirs) { const counts = dirEdgeCounts.get(dir); @@ -253,10 +211,9 @@ export function buildStructure(db, fileSymbols, _rootDir, lineCountMap, director } } } - // For each directory that contains the target but NOT the source if (tgtDirs) { for (const dir of tgtDirs) { - if (srcDirs?.has(dir)) continue; // already counted as intra + if (srcDirs?.has(dir)) continue; const counts = dirEdgeCounts.get(dir); if (!counts) continue; counts.fanIn++; @@ -264,7 +221,7 @@ export function buildStructure(db, fileSymbols, _rootDir, lineCountMap, director } } - const computeDirMetrics = db.transaction(() => { + db.transaction(() => { for (const [dir, files] of dirFiles) { const dirRow = getNodeIdStmt.get(dir, 'directory', dir, 0); if (!dirRow) continue; @@ -286,7 +243,6 @@ export function buildStructure(db, fileSymbols, _rootDir, lineCountMap, director } } - // O(1) lookup from pre-aggregated edge counts const counts = dirEdgeCounts.get(dir) || { intra: 0, fanIn: 0, fanOut: 0 }; const totalEdges = counts.intra + counts.fanIn + counts.fanOut; const cohesion = totalEdges > 0 ? counts.intra / totalEdges : null; @@ -303,11 +259,69 @@ export function buildStructure(db, fileSymbols, _rootDir, lineCountMap, director fileCount, ); } - }); - computeDirMetrics(); + })(); +} + +// ─── Build-time: insert directory nodes, contains edges, and metrics ──── + +/** + * Build directory structure nodes, containment edges, and compute metrics. + * Called from builder.js after edge building. + * + * @param {import('better-sqlite3').Database} db - Open read-write database + * @param {Map} fileSymbols - Map of relPath → { definitions, imports, exports, calls } + * @param {string} rootDir - Absolute root directory + * @param {Map} lineCountMap - Map of relPath → line count + * @param {Set} directories - Set of relative directory paths + */ +export function buildStructure(db, fileSymbols, _rootDir, lineCountMap, directories, changedFiles) { + const insertNode = db.prepare( + 'INSERT OR IGNORE INTO nodes (name, kind, file, line, end_line) VALUES (?, ?, ?, ?, ?)', + ); + const getNodeIdStmt = { + get: (name, kind, file, line) => { + const id = getNodeId(db, name, kind, file, line); + return id != null ? { id } : undefined; + }, + }; + const insertEdge = db.prepare( + 'INSERT INTO edges (source_id, target_id, kind, confidence, dynamic) VALUES (?, ?, ?, ?, ?)', + ); + const upsertMetric = db.prepare(` + INSERT OR REPLACE INTO node_metrics + (node_id, line_count, symbol_count, import_count, export_count, fan_in, fan_out, cohesion, file_count) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + + const isIncremental = changedFiles != null && changedFiles.length > 0; + + cleanupPreviousData(db, getNodeIdStmt, isIncremental, changedFiles); + + const allDirs = collectAllDirectories(directories, fileSymbols); + + db.transaction(() => { + for (const dir of allDirs) { + insertNode.run(dir, 'directory', dir, 0, null); + } + })(); + + insertContainsEdges(db, insertEdge, getNodeIdStmt, fileSymbols, allDirs, changedFiles); + + const { fanInMap, fanOutMap, importEdges } = computeImportEdgeMaps(db); + + computeFileMetrics( + db, + upsertMetric, + getNodeIdStmt, + fileSymbols, + lineCountMap, + fanInMap, + fanOutMap, + ); + + computeDirectoryMetrics(db, upsertMetric, getNodeIdStmt, fileSymbols, allDirs, importEdges); - const dirCount = allDirs.size; - debug(`Structure: ${dirCount} directories, ${fileSymbols.size} files with metrics`); + debug(`Structure: ${allDirs.size} directories, ${fileSymbols.size} files with metrics`); } // ─── Node role classification ───────────────────────────────────────── diff --git a/src/features/triage.js b/src/features/triage.js index 00b35ccd..8c23875a 100644 --- a/src/features/triage.js +++ b/src/features/triage.js @@ -4,8 +4,83 @@ import { warn } from '../infrastructure/logger.js'; import { isTestFile } from '../infrastructure/test-filter.js'; import { paginateResult } from '../shared/paginate.js'; +// ─── Scoring ───────────────────────────────────────────────────────── + +const SORT_FNS = { + risk: (a, b) => b.riskScore - a.riskScore, + complexity: (a, b) => b.cognitive - a.cognitive, + churn: (a, b) => b.churn - a.churn, + 'fan-in': (a, b) => b.fanIn - a.fanIn, + mi: (a, b) => a.maintainabilityIndex - b.maintainabilityIndex, +}; + +/** + * Build scored triage items from raw rows and risk metrics. + * @param {object[]} rows - Raw DB rows + * @param {object[]} riskMetrics - Per-row risk metric objects from scoreRisk + * @returns {object[]} + */ +function buildTriageItems(rows, riskMetrics) { + return rows.map((r, i) => ({ + name: r.name, + kind: r.kind, + file: r.file, + line: r.line, + role: r.role || null, + fanIn: r.fan_in, + cognitive: r.cognitive, + churn: r.churn, + maintainabilityIndex: r.mi, + normFanIn: riskMetrics[i].normFanIn, + normComplexity: riskMetrics[i].normComplexity, + normChurn: riskMetrics[i].normChurn, + normMI: riskMetrics[i].normMI, + roleWeight: riskMetrics[i].roleWeight, + riskScore: riskMetrics[i].riskScore, + })); +} + +/** + * Compute signal coverage and summary statistics. + * @param {object[]} filtered - All filtered rows + * @param {object[]} scored - Scored and filtered items + * @param {object} weights - Active weights + * @returns {object} + */ +function computeTriageSummary(filtered, scored, weights) { + const signalCoverage = { + complexity: round4(filtered.filter((r) => r.cognitive > 0).length / filtered.length), + churn: round4(filtered.filter((r) => r.churn > 0).length / filtered.length), + fanIn: round4(filtered.filter((r) => r.fan_in > 0).length / filtered.length), + mi: round4(filtered.filter((r) => r.mi > 0).length / filtered.length), + }; + + const scores = scored.map((it) => it.riskScore); + const avgScore = + scores.length > 0 ? round4(scores.reduce((a, b) => a + b, 0) / scores.length) : 0; + const maxScore = scores.length > 0 ? round4(Math.max(...scores)) : 0; + + return { + total: filtered.length, + analyzed: scored.length, + avgScore, + maxScore, + weights, + signalCoverage, + }; +} + // ─── Data Function ──────────────────────────────────────────────────── +const EMPTY_SUMMARY = (weights) => ({ + total: 0, + analyzed: 0, + avgScore: 0, + maxScore: 0, + weights, + signalCoverage: {}, +}); + /** * Compute composite risk scores for all symbols. * @@ -17,9 +92,6 @@ export function triageData(customDbPath, opts = {}) { const { repo, close } = openRepo(customDbPath, opts); try { const noTests = opts.noTests || false; - const fileFilter = opts.file || null; - const kindFilter = opts.kind || null; - const roleFilter = opts.role || null; const minScore = opts.minScore != null ? Number(opts.minScore) : null; const sort = opts.sort || 'risk'; const weights = { ...DEFAULT_WEIGHTS, ...(opts.weights || {}) }; @@ -28,86 +100,29 @@ export function triageData(customDbPath, opts = {}) { try { rows = repo.findNodesForTriage({ noTests, - file: fileFilter, - kind: kindFilter, - role: roleFilter, + file: opts.file || null, + kind: opts.kind || null, + role: opts.role || null, }); } catch (err) { warn(`triage query failed: ${err.message}`); - return { - items: [], - summary: { total: 0, analyzed: 0, avgScore: 0, maxScore: 0, weights, signalCoverage: {} }, - }; + return { items: [], summary: EMPTY_SUMMARY(weights) }; } - // Post-filter test files (belt-and-suspenders) const filtered = noTests ? rows.filter((r) => !isTestFile(r.file)) : rows; - if (filtered.length === 0) { - return { - items: [], - summary: { total: 0, analyzed: 0, avgScore: 0, maxScore: 0, weights, signalCoverage: {} }, - }; + return { items: [], summary: EMPTY_SUMMARY(weights) }; } - // Delegate scoring to classifier const riskMetrics = scoreRisk(filtered, weights); + const items = buildTriageItems(filtered, riskMetrics); - // Compute risk scores - const items = filtered.map((r, i) => ({ - name: r.name, - kind: r.kind, - file: r.file, - line: r.line, - role: r.role || null, - fanIn: r.fan_in, - cognitive: r.cognitive, - churn: r.churn, - maintainabilityIndex: r.mi, - normFanIn: riskMetrics[i].normFanIn, - normComplexity: riskMetrics[i].normComplexity, - normChurn: riskMetrics[i].normChurn, - normMI: riskMetrics[i].normMI, - roleWeight: riskMetrics[i].roleWeight, - riskScore: riskMetrics[i].riskScore, - })); - - // Apply minScore filter const scored = minScore != null ? items.filter((it) => it.riskScore >= minScore) : items; - - // Sort - const sortFns = { - risk: (a, b) => b.riskScore - a.riskScore, - complexity: (a, b) => b.cognitive - a.cognitive, - churn: (a, b) => b.churn - a.churn, - 'fan-in': (a, b) => b.fanIn - a.fanIn, - mi: (a, b) => a.maintainabilityIndex - b.maintainabilityIndex, - }; - scored.sort(sortFns[sort] || sortFns.risk); - - // Signal coverage: % of items with non-zero signal - const signalCoverage = { - complexity: round4(filtered.filter((r) => r.cognitive > 0).length / filtered.length), - churn: round4(filtered.filter((r) => r.churn > 0).length / filtered.length), - fanIn: round4(filtered.filter((r) => r.fan_in > 0).length / filtered.length), - mi: round4(filtered.filter((r) => r.mi > 0).length / filtered.length), - }; - - const scores = scored.map((it) => it.riskScore); - const avgScore = - scores.length > 0 ? round4(scores.reduce((a, b) => a + b, 0) / scores.length) : 0; - const maxScore = scores.length > 0 ? round4(Math.max(...scores)) : 0; + scored.sort(SORT_FNS[sort] || SORT_FNS.risk); const result = { items: scored, - summary: { - total: filtered.length, - analyzed: scored.length, - avgScore, - maxScore, - weights, - signalCoverage, - }, + summary: computeTriageSummary(filtered, scored, weights), }; return paginateResult(result, 'items', { diff --git a/src/presentation/queries-cli/inspect.js b/src/presentation/queries-cli/inspect.js index 5a3ddcb7..59b85d63 100644 --- a/src/presentation/queries-cli/inspect.js +++ b/src/presentation/queries-cli/inspect.js @@ -96,96 +96,7 @@ export function context(name, customDbPath, opts = {}) { } for (const r of data.results) { - const lineRange = r.endLine ? `${r.line}-${r.endLine}` : `${r.line}`; - const roleTag = r.role ? ` [${r.role}]` : ''; - console.log(`\n# ${r.name} (${r.kind})${roleTag} — ${r.file}:${lineRange}\n`); - - // Signature - if (r.signature) { - console.log('## Type/Shape Info'); - if (r.signature.params != null) console.log(` Parameters: (${r.signature.params})`); - if (r.signature.returnType) console.log(` Returns: ${r.signature.returnType}`); - console.log(); - } - - // Children - if (r.children && r.children.length > 0) { - console.log(`## Children (${r.children.length})`); - for (const c of r.children) { - console.log(` ${kindIcon(c.kind)} ${c.name} :${c.line}`); - } - console.log(); - } - - // Complexity - if (r.complexity) { - const cx = r.complexity; - const miPart = cx.maintainabilityIndex ? ` | MI: ${cx.maintainabilityIndex}` : ''; - console.log('## Complexity'); - console.log( - ` Cognitive: ${cx.cognitive} | Cyclomatic: ${cx.cyclomatic} | Max Nesting: ${cx.maxNesting}${miPart}`, - ); - console.log(); - } - - // Source - if (r.source) { - console.log('## Source'); - for (const line of r.source.split('\n')) { - console.log(` ${line}`); - } - console.log(); - } - - // Callees - if (r.callees.length > 0) { - console.log(`## Direct Dependencies (${r.callees.length})`); - for (const c of r.callees) { - const summary = c.summary ? ` — ${c.summary}` : ''; - console.log(` ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}${summary}`); - if (c.source) { - for (const line of c.source.split('\n').slice(0, 10)) { - console.log(` | ${line}`); - } - } - } - console.log(); - } - - // Callers - if (r.callers.length > 0) { - console.log(`## Callers (${r.callers.length})`); - for (const c of r.callers) { - const via = c.viaHierarchy ? ` (via ${c.viaHierarchy})` : ''; - console.log(` ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}${via}`); - } - console.log(); - } - - // Related tests - if (r.relatedTests.length > 0) { - console.log('## Related Tests'); - for (const t of r.relatedTests) { - console.log(` ${t.file} — ${t.testCount} tests`); - for (const tn of t.testNames) { - console.log(` - ${tn}`); - } - if (t.source) { - console.log(' Source:'); - for (const line of t.source.split('\n').slice(0, 20)) { - console.log(` | ${line}`); - } - } - } - console.log(); - } - - if (r.callees.length === 0 && r.callers.length === 0 && r.relatedTests.length === 0) { - console.log( - ' (no call edges or tests found — may be invoked dynamically or via re-exports)', - ); - console.log(); - } + renderContextResult(r); } } @@ -209,126 +120,210 @@ export function children(name, customDbPath, opts = {}) { } } -export function explain(target, customDbPath, opts = {}) { - const data = explainData(target, customDbPath, opts); - if (outputResult(data, 'results', opts)) return; +function renderContextResult(r) { + const lineRange = r.endLine ? `${r.line}-${r.endLine}` : `${r.line}`; + const roleTag = r.role ? ` [${r.role}]` : ''; + console.log(`\n# ${r.name} (${r.kind})${roleTag} — ${r.file}:${lineRange}\n`); - if (data.results.length === 0) { - console.log(`No ${data.kind === 'file' ? 'file' : 'function/symbol'} matching "${target}"`); - return; + if (r.signature) { + console.log('## Type/Shape Info'); + if (r.signature.params != null) console.log(` Parameters: (${r.signature.params})`); + if (r.signature.returnType) console.log(` Returns: ${r.signature.returnType}`); + console.log(); } - if (data.kind === 'file') { - for (const r of data.results) { - const publicCount = r.publicApi.length; - const internalCount = r.internal.length; - const lineInfo = r.lineCount ? `${r.lineCount} lines, ` : ''; - console.log(`\n# ${r.file}`); - console.log( - ` ${lineInfo}${r.symbolCount} symbols (${publicCount} exported, ${internalCount} internal)`, - ); + if (r.children && r.children.length > 0) { + console.log(`## Children (${r.children.length})`); + for (const c of r.children) { + console.log(` ${kindIcon(c.kind)} ${c.name} :${c.line}`); + } + console.log(); + } - if (r.imports.length > 0) { - console.log(` Imports: ${r.imports.map((i) => i.file).join(', ')}`); - } - if (r.importedBy.length > 0) { - console.log(` Imported by: ${r.importedBy.map((i) => i.file).join(', ')}`); - } + if (r.complexity) { + const cx = r.complexity; + const miPart = cx.maintainabilityIndex ? ` | MI: ${cx.maintainabilityIndex}` : ''; + console.log('## Complexity'); + console.log( + ` Cognitive: ${cx.cognitive} | Cyclomatic: ${cx.cyclomatic} | Max Nesting: ${cx.maxNesting}${miPart}`, + ); + console.log(); + } - if (r.publicApi.length > 0) { - console.log(`\n## Exported`); - for (const s of r.publicApi) { - const sig = s.signature?.params != null ? `(${s.signature.params})` : ''; - const roleTag = s.role ? ` [${s.role}]` : ''; - const summary = s.summary ? ` -- ${s.summary}` : ''; - console.log(` ${kindIcon(s.kind)} ${s.name}${sig}${roleTag} :${s.line}${summary}`); - } - } + if (r.source) { + console.log('## Source'); + for (const line of r.source.split('\n')) { + console.log(` ${line}`); + } + console.log(); + } - if (r.internal.length > 0) { - console.log(`\n## Internal`); - for (const s of r.internal) { - const sig = s.signature?.params != null ? `(${s.signature.params})` : ''; - const roleTag = s.role ? ` [${s.role}]` : ''; - const summary = s.summary ? ` -- ${s.summary}` : ''; - console.log(` ${kindIcon(s.kind)} ${s.name}${sig}${roleTag} :${s.line}${summary}`); + if (r.callees.length > 0) { + console.log(`## Direct Dependencies (${r.callees.length})`); + for (const c of r.callees) { + const summary = c.summary ? ` — ${c.summary}` : ''; + console.log(` ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}${summary}`); + if (c.source) { + for (const line of c.source.split('\n').slice(0, 10)) { + console.log(` | ${line}`); } } + } + console.log(); + } - if (r.dataFlow.length > 0) { - console.log(`\n## Data Flow`); - for (const df of r.dataFlow) { - console.log(` ${df.caller} -> ${df.callees.join(', ')}`); - } - } - console.log(); + if (r.callers.length > 0) { + console.log(`## Callers (${r.callers.length})`); + for (const c of r.callers) { + const via = c.viaHierarchy ? ` (via ${c.viaHierarchy})` : ''; + console.log(` ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}${via}`); } - } else { - function printFunctionExplain(r, indent = '') { - const lineRange = r.endLine ? `${r.line}-${r.endLine}` : `${r.line}`; - const lineInfo = r.lineCount ? `${r.lineCount} lines` : ''; - const summaryPart = r.summary ? ` | ${r.summary}` : ''; - const roleTag = r.role ? ` [${r.role}]` : ''; - const depthLevel = r._depth || 0; - const heading = depthLevel === 0 ? '#' : '##'.padEnd(depthLevel + 2, '#'); - console.log(`\n${indent}${heading} ${r.name} (${r.kind})${roleTag} ${r.file}:${lineRange}`); - if (lineInfo || r.summary) { - console.log(`${indent} ${lineInfo}${summaryPart}`); - } - if (r.signature) { - if (r.signature.params != null) - console.log(`${indent} Parameters: (${r.signature.params})`); - if (r.signature.returnType) console.log(`${indent} Returns: ${r.signature.returnType}`); - } + console.log(); + } - if (r.complexity) { - const cx = r.complexity; - const miPart = cx.maintainabilityIndex ? ` MI=${cx.maintainabilityIndex}` : ''; - console.log( - `${indent} Complexity: cognitive=${cx.cognitive} cyclomatic=${cx.cyclomatic} nesting=${cx.maxNesting}${miPart}`, - ); + if (r.relatedTests.length > 0) { + console.log('## Related Tests'); + for (const t of r.relatedTests) { + console.log(` ${t.file} — ${t.testCount} tests`); + for (const tn of t.testNames) { + console.log(` - ${tn}`); } - - if (r.callees.length > 0) { - console.log(`\n${indent} Calls (${r.callees.length}):`); - for (const c of r.callees) { - console.log(`${indent} ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}`); + if (t.source) { + console.log(' Source:'); + for (const line of t.source.split('\n').slice(0, 20)) { + console.log(` | ${line}`); } } + } + console.log(); + } - if (r.callers.length > 0) { - console.log(`\n${indent} Called by (${r.callers.length}):`); - for (const c of r.callers) { - console.log(`${indent} ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}`); - } - } + if (r.callees.length === 0 && r.callers.length === 0 && r.relatedTests.length === 0) { + console.log(' (no call edges or tests found — may be invoked dynamically or via re-exports)'); + console.log(); + } +} - if (r.relatedTests.length > 0) { - const label = r.relatedTests.length === 1 ? 'file' : 'files'; - console.log(`\n${indent} Tests (${r.relatedTests.length} ${label}):`); - for (const t of r.relatedTests) { - console.log(`${indent} ${t.file}`); - } - } +function renderFileExplain(r) { + const publicCount = r.publicApi.length; + const internalCount = r.internal.length; + const lineInfo = r.lineCount ? `${r.lineCount} lines, ` : ''; + console.log(`\n# ${r.file}`); + console.log( + ` ${lineInfo}${r.symbolCount} symbols (${publicCount} exported, ${internalCount} internal)`, + ); + + if (r.imports.length > 0) { + console.log(` Imports: ${r.imports.map((i) => i.file).join(', ')}`); + } + if (r.importedBy.length > 0) { + console.log(` Imported by: ${r.importedBy.map((i) => i.file).join(', ')}`); + } - if (r.callees.length === 0 && r.callers.length === 0) { - console.log( - `${indent} (no call edges found -- may be invoked dynamically or via re-exports)`, - ); - } + if (r.publicApi.length > 0) { + console.log(`\n## Exported`); + for (const s of r.publicApi) { + const sig = s.signature?.params != null ? `(${s.signature.params})` : ''; + const roleTag = s.role ? ` [${s.role}]` : ''; + const summary = s.summary ? ` -- ${s.summary}` : ''; + console.log(` ${kindIcon(s.kind)} ${s.name}${sig}${roleTag} :${s.line}${summary}`); + } + } - // Render recursive dependency details - if (r.depDetails && r.depDetails.length > 0) { - console.log(`\n${indent} --- Dependencies (depth ${depthLevel + 1}) ---`); - for (const dep of r.depDetails) { - printFunctionExplain(dep, `${indent} `); - } - } - console.log(); + if (r.internal.length > 0) { + console.log(`\n## Internal`); + for (const s of r.internal) { + const sig = s.signature?.params != null ? `(${s.signature.params})` : ''; + const roleTag = s.role ? ` [${s.role}]` : ''; + const summary = s.summary ? ` -- ${s.summary}` : ''; + console.log(` ${kindIcon(s.kind)} ${s.name}${sig}${roleTag} :${s.line}${summary}`); + } + } + + if (r.dataFlow.length > 0) { + console.log(`\n## Data Flow`); + for (const df of r.dataFlow) { + console.log(` ${df.caller} -> ${df.callees.join(', ')}`); + } + } + console.log(); +} + +function renderFunctionExplain(r, indent = '') { + const lineRange = r.endLine ? `${r.line}-${r.endLine}` : `${r.line}`; + const lineInfo = r.lineCount ? `${r.lineCount} lines` : ''; + const summaryPart = r.summary ? ` | ${r.summary}` : ''; + const roleTag = r.role ? ` [${r.role}]` : ''; + const depthLevel = r._depth || 0; + const heading = depthLevel === 0 ? '#' : '##'.padEnd(depthLevel + 2, '#'); + console.log(`\n${indent}${heading} ${r.name} (${r.kind})${roleTag} ${r.file}:${lineRange}`); + if (lineInfo || r.summary) { + console.log(`${indent} ${lineInfo}${summaryPart}`); + } + if (r.signature) { + if (r.signature.params != null) console.log(`${indent} Parameters: (${r.signature.params})`); + if (r.signature.returnType) console.log(`${indent} Returns: ${r.signature.returnType}`); + } + + if (r.complexity) { + const cx = r.complexity; + const miPart = cx.maintainabilityIndex ? ` MI=${cx.maintainabilityIndex}` : ''; + console.log( + `${indent} Complexity: cognitive=${cx.cognitive} cyclomatic=${cx.cyclomatic} nesting=${cx.maxNesting}${miPart}`, + ); + } + + if (r.callees.length > 0) { + console.log(`\n${indent} Calls (${r.callees.length}):`); + for (const c of r.callees) { + console.log(`${indent} ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}`); + } + } + + if (r.callers.length > 0) { + console.log(`\n${indent} Called by (${r.callers.length}):`); + for (const c of r.callers) { + console.log(`${indent} ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}`); + } + } + + if (r.relatedTests.length > 0) { + const label = r.relatedTests.length === 1 ? 'file' : 'files'; + console.log(`\n${indent} Tests (${r.relatedTests.length} ${label}):`); + for (const t of r.relatedTests) { + console.log(`${indent} ${t.file}`); } + } + + if (r.callees.length === 0 && r.callers.length === 0) { + console.log(`${indent} (no call edges found -- may be invoked dynamically or via re-exports)`); + } + if (r.depDetails && r.depDetails.length > 0) { + console.log(`\n${indent} --- Dependencies (depth ${depthLevel + 1}) ---`); + for (const dep of r.depDetails) { + renderFunctionExplain(dep, `${indent} `); + } + } + console.log(); +} + +export function explain(target, customDbPath, opts = {}) { + const data = explainData(target, customDbPath, opts); + if (outputResult(data, 'results', opts)) return; + + if (data.results.length === 0) { + console.log(`No ${data.kind === 'file' ? 'file' : 'function/symbol'} matching "${target}"`); + return; + } + + if (data.kind === 'file') { + for (const r of data.results) { + renderFileExplain(r); + } + } else { for (const r of data.results) { - printFunctionExplain(r); + renderFunctionExplain(r); } } } diff --git a/src/presentation/queries-cli/overview.js b/src/presentation/queries-cli/overview.js index 88409da2..29a4f6e9 100644 --- a/src/presentation/queries-cli/overview.js +++ b/src/presentation/queries-cli/overview.js @@ -2,64 +2,42 @@ import path from 'node:path'; import { kindIcon, moduleMapData, rolesData, statsData } from '../../domain/queries.js'; import { outputResult } from '../../infrastructure/result-formatter.js'; -export async function stats(customDbPath, opts = {}) { - const data = statsData(customDbPath, { noTests: opts.noTests }); - - // Community detection summary (async import for lazy-loading) - try { - const { communitySummaryForStats } = await import('../../features/communities.js'); - data.communities = communitySummaryForStats(customDbPath, { noTests: opts.noTests }); - } catch { - /* graphology may not be available */ - } - - if (outputResult(data, null, opts)) return; - - // Human-readable output - console.log('\n# Codegraph Stats\n'); - - // Nodes - console.log(`Nodes: ${data.nodes.total} total`); - const kindEntries = Object.entries(data.nodes.byKind).sort((a, b) => b[1] - a[1]); - const kindParts = kindEntries.map(([k, v]) => `${k} ${v}`); - for (let i = 0; i < kindParts.length; i += 3) { - const row = kindParts +function printCountGrid(entries, padWidth) { + const parts = entries.map(([k, v]) => `${k} ${v}`); + for (let i = 0; i < parts.length; i += 3) { + const row = parts .slice(i, i + 3) - .map((p) => p.padEnd(18)) + .map((p) => p.padEnd(padWidth)) .join(''); console.log(` ${row}`); } +} - // Edges +function printNodes(data) { + console.log(`Nodes: ${data.nodes.total} total`); + const kindEntries = Object.entries(data.nodes.byKind).sort((a, b) => b[1] - a[1]); + printCountGrid(kindEntries, 18); +} + +function printEdges(data) { console.log(`\nEdges: ${data.edges.total} total`); const edgeEntries = Object.entries(data.edges.byKind).sort((a, b) => b[1] - a[1]); - const edgeParts = edgeEntries.map(([k, v]) => `${k} ${v}`); - for (let i = 0; i < edgeParts.length; i += 3) { - const row = edgeParts - .slice(i, i + 3) - .map((p) => p.padEnd(18)) - .join(''); - console.log(` ${row}`); - } + printCountGrid(edgeEntries, 18); +} - // Files +function printFiles(data) { console.log(`\nFiles: ${data.files.total} (${data.files.languages} languages)`); const langEntries = Object.entries(data.files.byLanguage).sort((a, b) => b[1] - a[1]); - const langParts = langEntries.map(([k, v]) => `${k} ${v}`); - for (let i = 0; i < langParts.length; i += 3) { - const row = langParts - .slice(i, i + 3) - .map((p) => p.padEnd(18)) - .join(''); - console.log(` ${row}`); - } + printCountGrid(langEntries, 18); +} - // Cycles +function printCycles(data) { console.log( `\nCycles: ${data.cycles.fileLevel} file-level, ${data.cycles.functionLevel} function-level`, ); +} - // Hotspots +function printHotspots(data) { if (data.hotspots.length > 0) { console.log(`\nTop ${data.hotspots.length} coupling hotspots:`); for (let i = 0; i < data.hotspots.length; i++) { @@ -69,8 +47,9 @@ export async function stats(customDbPath, opts = {}) { ); } } +} - // Embeddings +function printEmbeddings(data) { if (data.embeddings) { const e = data.embeddings; console.log( @@ -79,8 +58,9 @@ export async function stats(customDbPath, opts = {}) { } else { console.log('\nEmbeddings: not built'); } +} - // Quality +function printQuality(data) { if (data.quality) { const q = data.quality; const cc = q.callerCoverage; @@ -99,24 +79,18 @@ export async function stats(customDbPath, opts = {}) { } } } +} - // Roles +function printRoles(data) { if (data.roles && Object.keys(data.roles).length > 0) { const total = Object.values(data.roles).reduce((a, b) => a + b, 0); console.log(`\nRoles: ${total} classified symbols`); - const roleParts = Object.entries(data.roles) - .sort((a, b) => b[1] - a[1]) - .map(([k, v]) => `${k} ${v}`); - for (let i = 0; i < roleParts.length; i += 3) { - const row = roleParts - .slice(i, i + 3) - .map((p) => p.padEnd(18)) - .join(''); - console.log(` ${row}`); - } + const roleEntries = Object.entries(data.roles).sort((a, b) => b[1] - a[1]); + printCountGrid(roleEntries, 18); } +} - // Complexity +function printComplexity(data) { if (data.complexity) { const cx = data.complexity; const miPart = cx.avgMI != null ? ` | avg MI: ${cx.avgMI} | min MI: ${cx.minMI}` : ''; @@ -124,15 +98,40 @@ export async function stats(customDbPath, opts = {}) { `\nComplexity: ${cx.analyzed} functions | avg cognitive: ${cx.avgCognitive} | avg cyclomatic: ${cx.avgCyclomatic} | max cognitive: ${cx.maxCognitive}${miPart}`, ); } +} - // Communities +function printCommunities(data) { if (data.communities) { const cm = data.communities; console.log( `\nCommunities: ${cm.communityCount} detected | modularity: ${cm.modularity} | drift: ${cm.driftScore}%`, ); } +} + +export async function stats(customDbPath, opts = {}) { + const data = statsData(customDbPath, { noTests: opts.noTests }); + + try { + const { communitySummaryForStats } = await import('../../features/communities.js'); + data.communities = communitySummaryForStats(customDbPath, { noTests: opts.noTests }); + } catch { + /* graphology may not be available */ + } + if (outputResult(data, null, opts)) return; + + console.log('\n# Codegraph Stats\n'); + printNodes(data); + printEdges(data); + printFiles(data); + printCycles(data); + printHotspots(data); + printEmbeddings(data); + printQuality(data); + printRoles(data); + printComplexity(data); + printCommunities(data); console.log(); }