From e1dde3560ecdd5c32a3a04723797b2bdcdcd1675 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Wed, 25 Mar 2026 22:18:58 -0600 Subject: [PATCH 1/9] fix: add debug logging to empty catch blocks across infrastructure and domain layers Impact: 8 functions changed, 33 affected --- src/domain/graph/resolve.ts | 21 +++++++++++++++------ src/domain/parser.ts | 15 ++++++++++----- src/infrastructure/config.ts | 18 ++++++++++-------- src/infrastructure/native.ts | 10 ++++++++-- 4 files changed, 43 insertions(+), 21 deletions(-) diff --git a/src/domain/graph/resolve.ts b/src/domain/graph/resolve.ts index 06c62141..8588c7c9 100644 --- a/src/domain/graph/resolve.ts +++ b/src/domain/graph/resolve.ts @@ -1,5 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; +import { debug } from '../../infrastructure/logger.js'; import { loadNative } from '../../infrastructure/native.js'; import { normalizePath } from '../../shared/constants.js'; import type { BareSpecifier, BatchResolvedMap, ImportBatchItem, PathAliases } from '../../types.js'; @@ -64,7 +65,10 @@ function getPackageExports(packageDir: string): any { const exports = pkg.exports ?? null; _exportsCache.set(packageDir, exports); return exports; - } catch { + } catch (e) { + debug( + `readPackageExports: failed to read package.json in ${packageDir}: ${(e as Error).message}`, + ); _exportsCache.set(packageDir, null); return null; } @@ -515,8 +519,10 @@ export function resolveImportPath( // unresolved ".." components (PathBuf::components().collect() doesn't // collapse parent refs). Apply the remap on the JS side as a fallback. return remapJsToTs(normalized, rootDir); - } catch { - // fall through to JS + } catch (e) { + debug( + `resolveImportPath: native resolution failed, falling back to JS: ${(e as Error).message}`, + ); } } return resolveImportPathJS(fromFile, importSource, rootDir, aliases); @@ -535,8 +541,10 @@ export function computeConfidence( if (native) { try { return native.computeConfidence(callerFile, targetFile, importedFrom || null); - } catch { - // fall through to JS + } catch (e) { + debug( + `computeConfidence: native computation failed, falling back to JS: ${(e as Error).message}`, + ); } } return computeConfidenceJS(callerFile, targetFile, importedFrom); @@ -575,7 +583,8 @@ export function resolveImportsBatch( map.set(`${r.fromFile}|${r.importSource}`, resolved); } return map; - } catch { + } catch (e) { + debug(`batchResolve: native batch resolution failed: ${(e as Error).message}`); return null; } } diff --git a/src/domain/parser.ts b/src/domain/parser.ts index ecbc3f88..0497d5e9 100644 --- a/src/domain/parser.ts +++ b/src/domain/parser.ts @@ -442,7 +442,8 @@ async function backfillTypeMap( if (!code) { try { code = fs.readFileSync(filePath, 'utf-8'); - } catch { + } catch (e) { + debug(`backfillTypeMap: failed to read ${filePath}: ${(e as Error).message}`); return { typeMap: new Map(), backfilled: false }; } } @@ -458,7 +459,9 @@ async function backfillTypeMap( if (extracted?.tree && typeof extracted.tree.delete === 'function') { try { extracted.tree.delete(); - } catch {} + } catch (e) { + debug(`backfillTypeMap: WASM tree cleanup failed: ${(e as Error).message}`); + } } } } @@ -571,14 +574,16 @@ export async function parseFilesAuto( symbols.typeMap = extracted.symbols.typeMap; symbols._typeMapBackfilled = true; } - } catch { - /* skip — typeMap is a best-effort backfill */ + } catch (e) { + debug(`batchExtract: typeMap backfill failed: ${(e as Error).message}`); } finally { // Free the WASM tree to prevent memory accumulation across repeated builds if (extracted?.tree && typeof extracted.tree.delete === 'function') { try { extracted.tree.delete(); - } catch {} + } catch (e) { + debug(`batchExtract: WASM tree cleanup failed: ${(e as Error).message}`); + } } } } diff --git a/src/infrastructure/config.ts b/src/infrastructure/config.ts index fb162f15..d6a7c609 100644 --- a/src/infrastructure/config.ts +++ b/src/infrastructure/config.ts @@ -310,8 +310,10 @@ function resolveWorkspaceEntry(pkgDir: string): string | null { const candidate = path.resolve(pkgDir, idx); if (fs.existsSync(candidate)) return candidate; } - } catch { - /* ignore */ + } catch (e) { + debug( + `resolveWorkspaceEntry: package.json probe failed for ${pkgDir}: ${(e as Error).message}`, + ); } return null; } @@ -344,8 +346,8 @@ export function detectWorkspaces(rootDir: string): Map { } } } - } catch { - /* ignore */ + } catch (e) { + debug(`detectWorkspaces: failed to parse pnpm-workspace.yaml: ${(e as Error).message}`); } } @@ -363,8 +365,8 @@ export function detectWorkspaces(rootDir: string): Map { // Yarn classic format: { packages: [...], nohoist: [...] } patterns.push(...ws.packages); } - } catch { - /* ignore */ + } catch (e) { + debug(`detectWorkspaces: failed to parse package.json workspaces: ${(e as Error).message}`); } } } @@ -379,8 +381,8 @@ export function detectWorkspaces(rootDir: string): Map { if (Array.isArray(lerna.packages)) { patterns.push(...lerna.packages); } - } catch { - /* ignore */ + } catch (e) { + debug(`detectWorkspaces: failed to parse lerna.json: ${(e as Error).message}`); } } } diff --git a/src/infrastructure/native.ts b/src/infrastructure/native.ts index ed29ae3d..a397405f 100644 --- a/src/infrastructure/native.ts +++ b/src/infrastructure/native.ts @@ -10,6 +10,7 @@ import { createRequire } from 'node:module'; import os from 'node:os'; import { EngineError } from '../shared/errors.js'; import type { NativeAddon } from '../types.js'; +import { debug } from './logger.js'; let _cached: NativeAddon | null | undefined; // undefined = not yet tried, null = failed, NativeAddon = module let _loadError: Error | null = null; @@ -26,7 +27,9 @@ function detectLibc(): 'gnu' | 'musl' { if (files.some((f: string) => f.startsWith('ld-musl-') && f.endsWith('.so.1'))) { return 'musl'; } - } catch {} + } catch (e) { + debug(`detectLibc: failed to read /lib: ${(e as Error).message}`); + } return 'gnu'; } @@ -92,7 +95,10 @@ export function getNativePackageVersion(): string | null { try { const pkgJson = _require(`${pkg}/package.json`) as { version?: string }; return pkgJson.version || null; - } catch { + } catch (e) { + debug( + `getNativePackageVersion: failed to read package.json for ${pkg}: ${(e as Error).message}`, + ); return null; } } From e8f41f414878d53dbb078d9b348e02f1e82815b6 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Wed, 25 Mar 2026 22:33:07 -0600 Subject: [PATCH 2/9] refactor: split impact.ts into fn-impact and diff-impact modules --- src/domain/analysis/diff-impact.ts | 356 ++++++++++++ src/domain/analysis/fn-impact.ts | 242 ++++++++ src/domain/analysis/impact.ts | 727 +----------------------- src/presentation/diff-impact-mermaid.ts | 129 +++++ 4 files changed, 736 insertions(+), 718 deletions(-) create mode 100644 src/domain/analysis/diff-impact.ts create mode 100644 src/domain/analysis/fn-impact.ts create mode 100644 src/presentation/diff-impact-mermaid.ts diff --git a/src/domain/analysis/diff-impact.ts b/src/domain/analysis/diff-impact.ts new file mode 100644 index 00000000..c2c684c5 --- /dev/null +++ b/src/domain/analysis/diff-impact.ts @@ -0,0 +1,356 @@ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { findDbPath, openReadonlyOrFail } from '../../db/index.js'; +import { cachedStmt } from '../../db/repository/cached-stmt.js'; +import { evaluateBoundaries } from '../../features/boundaries.js'; +import { coChangeForFiles } from '../../features/cochange.js'; +import { ownersForFiles } from '../../features/owners.js'; +import { loadConfig } from '../../infrastructure/config.js'; +import { debug } from '../../infrastructure/logger.js'; +import { isTestFile } from '../../infrastructure/test-filter.js'; +import { paginateResult } from '../../shared/paginate.js'; +import type { BetterSqlite3Database, NodeRow, StmtCache } from '../../types.js'; +import { bfsTransitiveCallers } from './fn-impact.js'; + +const _defsStmtCache: StmtCache = new WeakMap(); + +// --- diffImpactData helpers --- + +/** + * Walk up from repoRoot until a .git directory is found. + * Returns true if a git root exists, false otherwise. + */ +function findGitRoot(repoRoot: string): boolean { + let checkDir = repoRoot; + while (checkDir) { + if (fs.existsSync(path.join(checkDir, '.git'))) { + return true; + } + const parent = path.dirname(checkDir); + if (parent === checkDir) break; + checkDir = parent; + } + return false; +} + +/** + * Execute git diff and return the raw output string. + * Returns `{ output: string }` on success or `{ error: string }` on failure. + */ +function runGitDiff( + repoRoot: string, + opts: { staged?: boolean; ref?: string }, +): { output: string; error?: never } | { error: string; output?: never } { + try { + const args = opts.staged + ? ['diff', '--cached', '--unified=0', '--no-color'] + : ['diff', opts.ref || 'HEAD', '--unified=0', '--no-color']; + const output = execFileSync('git', args, { + cwd: repoRoot, + encoding: 'utf-8', + maxBuffer: 10 * 1024 * 1024, + stdio: ['pipe', 'pipe', 'pipe'], + }); + return { output }; + } catch (e: unknown) { + return { error: `Failed to run git diff: ${(e as Error).message}` }; + } +} + +/** + * Parse raw git diff output into a changedRanges map and newFiles set. + */ +function parseGitDiff(diffOutput: string) { + const changedRanges = new Map>(); + const newFiles = new Set(); + let currentFile: string | null = null; + let prevIsDevNull = false; + + for (const line of diffOutput.split('\n')) { + if (line.startsWith('--- /dev/null')) { + prevIsDevNull = true; + continue; + } + if (line.startsWith('--- ')) { + prevIsDevNull = false; + continue; + } + const fileMatch = line.match(/^\+\+\+ b\/(.+)/); + if (fileMatch) { + currentFile = fileMatch[1]!; + if (!changedRanges.has(currentFile)) changedRanges.set(currentFile, []); + if (prevIsDevNull) newFiles.add(currentFile!); + prevIsDevNull = false; + continue; + } + const hunkMatch = line.match(/^@@ .+ \+(\d+)(?:,(\d+))? @@/); + if (hunkMatch && currentFile) { + const start = parseInt(hunkMatch[1]!, 10); + const count = parseInt(hunkMatch[2] || '1', 10); + changedRanges.get(currentFile)!.push({ start, end: start + count - 1 }); + } + } + + return { changedRanges, newFiles }; +} + +/** + * Find all function/method/class nodes whose line ranges overlap any changed range. + */ +function findAffectedFunctions( + db: BetterSqlite3Database, + changedRanges: Map>, + noTests: boolean, +): NodeRow[] { + const affectedFunctions: NodeRow[] = []; + const defsStmt = cachedStmt( + _defsStmtCache, + db, + `SELECT * FROM nodes WHERE file = ? AND kind IN ('function', 'method', 'class') ORDER BY line`, + ); + for (const [file, ranges] of changedRanges) { + if (noTests && isTestFile(file)) continue; + const defs = defsStmt.all(file) as NodeRow[]; + for (let i = 0; i < defs.length; i++) { + const def = defs[i]!; + const endLine = def.end_line || (defs[i + 1] ? defs[i + 1]!.line - 1 : 999999); + for (const range of ranges) { + if (range.start <= endLine && range.end >= def.line) { + affectedFunctions.push(def); + break; + } + } + } + } + return affectedFunctions; +} + +/** + * Run BFS per affected function, collecting per-function results and the full affected set. + */ +function buildFunctionImpactResults( + db: BetterSqlite3Database, + affectedFunctions: NodeRow[], + noTests: boolean, + maxDepth: number, + includeImplementors = true, +) { + const allAffected = new Set(); + const functionResults = affectedFunctions.map((fn) => { + const edges: Array<{ from: string; to: string }> = []; + const idToKey = new Map(); + idToKey.set(fn.id, `${fn.file}::${fn.name}:${fn.line}`); + + const { levels, totalDependents } = bfsTransitiveCallers(db, fn.id, { + noTests, + maxDepth, + includeImplementors, + onVisit(c, parentId) { + allAffected.add(`${c.file}:${c.name}`); + const callerKey = `${c.file}::${c.name}:${c.line}`; + idToKey.set(c.id, callerKey); + edges.push({ from: idToKey.get(parentId)!, to: callerKey }); + }, + }); + + return { + name: fn.name, + kind: fn.kind, + file: fn.file, + line: fn.line, + transitiveCallers: totalDependents, + levels, + edges, + }; + }); + + return { functionResults, allAffected }; +} + +/** + * Look up historically co-changed files for the set of changed files. + * Returns an empty array if the co_changes table is unavailable. + */ +function lookupCoChanges( + db: BetterSqlite3Database, + changedRanges: Map, + affectedFiles: Set, + noTests: boolean, +) { + try { + db.prepare('SELECT 1 FROM co_changes LIMIT 1').get(); + const changedFilesList = [...changedRanges.keys()]; + const coResults = coChangeForFiles(changedFilesList, db, { + minJaccard: 0.3, + limit: 20, + noTests, + }); + return coResults.filter((r: { file: string }) => !affectedFiles.has(r.file)); + } catch (e: unknown) { + debug(`co_changes lookup skipped: ${(e as Error).message}`); + return []; + } +} + +/** + * Look up CODEOWNERS for changed and affected files. + * Returns null if no owners are found or lookup fails. + */ +function lookupOwnership( + changedRanges: Map, + affectedFiles: Set, + repoRoot: string, +) { + try { + const allFilePaths = [...new Set([...changedRanges.keys(), ...affectedFiles])]; + const ownerResult = ownersForFiles(allFilePaths, repoRoot); + if (ownerResult.affectedOwners.length > 0) { + return { + owners: Object.fromEntries(ownerResult.owners), + affectedOwners: ownerResult.affectedOwners, + suggestedReviewers: ownerResult.suggestedReviewers, + }; + } + return null; + } catch (e: unknown) { + debug(`CODEOWNERS lookup skipped: ${(e as Error).message}`); + return null; + } +} + +/** + * Check manifesto boundary violations scoped to the changed files. + * Returns `{ boundaryViolations, boundaryViolationCount }`. + */ +function checkBoundaryViolations( + db: BetterSqlite3Database, + changedRanges: Map, + noTests: boolean, + // biome-ignore lint/suspicious/noExplicitAny: opts shape varies by caller + opts: any, + repoRoot: string, +) { + try { + const cfg = opts.config || loadConfig(repoRoot); + const boundaryConfig = cfg.manifesto?.boundaries; + if (boundaryConfig) { + const result = evaluateBoundaries(db, boundaryConfig, { + scopeFiles: [...changedRanges.keys()], + noTests, + }); + return { + boundaryViolations: result.violations, + boundaryViolationCount: result.violationCount, + }; + } + } catch (e: unknown) { + debug(`boundary check skipped: ${(e as Error).message}`); + } + return { boundaryViolations: [], boundaryViolationCount: 0 }; +} + +// --- diffImpactData --- + +/** + * Fix #2: Shell injection vulnerability. + * Uses execFileSync instead of execSync to prevent shell interpretation of user input. + */ +export function diffImpactData( + customDbPath: string, + opts: { + noTests?: boolean; + depth?: number; + staged?: boolean; + ref?: string; + includeImplementors?: boolean; + limit?: number; + offset?: number; + // biome-ignore lint/suspicious/noExplicitAny: config shape is dynamic + config?: any; + } = {}, +) { + const db = openReadonlyOrFail(customDbPath); + try { + const noTests = opts.noTests || false; + const config = opts.config || loadConfig(); + const maxDepth = opts.depth || config.analysis?.impactDepth || 3; + + const dbPath = findDbPath(customDbPath); + const repoRoot = path.resolve(path.dirname(dbPath), '..'); + + if (!findGitRoot(repoRoot)) { + return { error: `Not a git repository: ${repoRoot}` }; + } + + const gitResult = runGitDiff(repoRoot, opts); + if ('error' in gitResult) return { error: gitResult.error }; + + if (!gitResult.output.trim()) { + return { + changedFiles: 0, + newFiles: [], + affectedFunctions: [], + affectedFiles: [], + summary: null, + }; + } + + const { changedRanges, newFiles } = parseGitDiff(gitResult.output); + + if (changedRanges.size === 0) { + return { + changedFiles: 0, + newFiles: [], + affectedFunctions: [], + affectedFiles: [], + summary: null, + }; + } + + const affectedFunctions = findAffectedFunctions(db, changedRanges, noTests); + const includeImplementors = opts.includeImplementors !== false; + const { functionResults, allAffected } = buildFunctionImpactResults( + db, + affectedFunctions, + noTests, + maxDepth, + includeImplementors, + ); + + const affectedFiles = new Set(); + for (const key of allAffected) affectedFiles.add(key.split(':')[0]!); + + const historicallyCoupled = lookupCoChanges(db, changedRanges, affectedFiles, noTests); + const ownership = lookupOwnership(changedRanges, affectedFiles, repoRoot); + const { boundaryViolations, boundaryViolationCount } = checkBoundaryViolations( + db, + changedRanges, + noTests, + opts, + repoRoot, + ); + + const base = { + changedFiles: changedRanges.size, + newFiles: [...newFiles], + affectedFunctions: functionResults, + affectedFiles: [...affectedFiles], + historicallyCoupled, + ownership, + boundaryViolations, + boundaryViolationCount, + summary: { + functionsChanged: affectedFunctions.length, + callersAffected: allAffected.size, + filesAffected: affectedFiles.size, + historicallyCoupledCount: historicallyCoupled.length, + ownersAffected: ownership ? ownership.affectedOwners.length : 0, + boundaryViolationCount, + }, + }; + return paginateResult(base, 'affectedFunctions', { limit: opts.limit, offset: opts.offset }); + } finally { + db.close(); + } +} diff --git a/src/domain/analysis/fn-impact.ts b/src/domain/analysis/fn-impact.ts new file mode 100644 index 00000000..114b2db7 --- /dev/null +++ b/src/domain/analysis/fn-impact.ts @@ -0,0 +1,242 @@ +import { + findDistinctCallers, + findFileNodes, + findImplementors, + findImportDependents, + findNodeById, + openReadonlyOrFail, +} from '../../db/index.js'; +import { loadConfig } from '../../infrastructure/config.js'; +import { isTestFile } from '../../infrastructure/test-filter.js'; +import { normalizeSymbol } from '../../shared/normalize.js'; +import { paginateResult } from '../../shared/paginate.js'; +import type { BetterSqlite3Database, NodeRow, RelatedNodeRow } from '../../types.js'; +import { findMatchingNodes } from './symbol-lookup.js'; + +// --- Shared BFS: transitive callers --- + +const INTERFACE_LIKE_KINDS = new Set(['interface', 'trait']); + +/** + * Check whether the graph contains any 'implements' edges. + * Cached per db handle so the query runs at most once per connection. + */ +const _hasImplementsCache: WeakMap = new WeakMap(); +function hasImplementsEdges(db: BetterSqlite3Database): boolean { + if (_hasImplementsCache.has(db)) return _hasImplementsCache.get(db)!; + const row = db.prepare("SELECT 1 FROM edges WHERE kind = 'implements' LIMIT 1").get(); + const result = !!row; + _hasImplementsCache.set(db, result); + return result; +} + +/** + * BFS traversal to find transitive callers of a node. + * When an interface/trait node is encountered (either as the start node or + * during traversal), its concrete implementors are also added to the frontier + * so that changes to an interface signature propagate to all implementors. + */ +export function bfsTransitiveCallers( + db: BetterSqlite3Database, + startId: number, + { + noTests = false, + maxDepth = 3, + includeImplementors = true, + onVisit, + }: { + noTests?: boolean; + maxDepth?: number; + includeImplementors?: boolean; + onVisit?: ( + caller: RelatedNodeRow & { viaImplements?: boolean }, + parentId: number, + depth: number, + ) => void; + } = {}, +) { + // Skip all implementor lookups when the graph has no implements edges + const resolveImplementors = includeImplementors && hasImplementsEdges(db); + + const visited = new Set([startId]); + const levels: Record< + number, + Array<{ name: string; kind: string; file: string; line: number; viaImplements?: boolean }> + > = {}; + let frontier = [startId]; + + // Seed: if start node is an interface/trait, include its implementors at depth 1. + // Implementors go into a separate list so their callers appear at depth 2, not depth 1. + const implNextFrontier: number[] = []; + if (resolveImplementors) { + const startNode = findNodeById(db, startId) as NodeRow | undefined; + if (startNode && INTERFACE_LIKE_KINDS.has(startNode.kind)) { + const impls = findImplementors(db, startId) as RelatedNodeRow[]; + for (const impl of impls) { + if (!visited.has(impl.id) && (!noTests || !isTestFile(impl.file))) { + visited.add(impl.id); + implNextFrontier.push(impl.id); + if (!levels[1]) levels[1] = []; + levels[1].push({ + name: impl.name, + kind: impl.kind, + file: impl.file, + line: impl.line, + viaImplements: true, + }); + if (onVisit) onVisit({ ...impl, viaImplements: true }, startId, 1); + } + } + } + } + + for (let d = 1; d <= maxDepth; d++) { + // On the first wave, merge seeded implementors so their callers appear at d=2 + if (d === 1 && implNextFrontier.length > 0) { + frontier = [...frontier, ...implNextFrontier]; + } + const nextFrontier: number[] = []; + for (const fid of frontier) { + const callers = findDistinctCallers(db, fid) as RelatedNodeRow[]; + for (const c of callers) { + if (!visited.has(c.id) && (!noTests || !isTestFile(c.file))) { + visited.add(c.id); + nextFrontier.push(c.id); + if (!levels[d]) levels[d] = []; + levels[d]!.push({ name: c.name, kind: c.kind, file: c.file, line: c.line }); + if (onVisit) onVisit(c, fid, d); + } + + // If a caller is an interface/trait, also pull in its implementors + // Implementors are one extra hop away, so record at d+1 + if (resolveImplementors && INTERFACE_LIKE_KINDS.has(c.kind)) { + const impls = findImplementors(db, c.id) as RelatedNodeRow[]; + for (const impl of impls) { + if (!visited.has(impl.id) && (!noTests || !isTestFile(impl.file))) { + visited.add(impl.id); + nextFrontier.push(impl.id); + const implDepth = d + 1; + if (!levels[implDepth]) levels[implDepth] = []; + levels[implDepth].push({ + name: impl.name, + kind: impl.kind, + file: impl.file, + line: impl.line, + viaImplements: true, + }); + if (onVisit) onVisit({ ...impl, viaImplements: true }, c.id, implDepth); + } + } + } + } + } + frontier = nextFrontier; + if (frontier.length === 0) break; + } + + return { totalDependents: visited.size - 1, levels }; +} + +export function impactAnalysisData( + file: string, + customDbPath: string, + opts: { noTests?: boolean } = {}, +) { + const db = openReadonlyOrFail(customDbPath); + try { + const noTests = opts.noTests || false; + const fileNodes = findFileNodes(db, `%${file}%`) as NodeRow[]; + if (fileNodes.length === 0) { + return { file, sources: [], levels: {}, totalDependents: 0 }; + } + + const visited = new Set(); + const queue: number[] = []; + const levels = new Map(); + + for (const fn of fileNodes) { + visited.add(fn.id); + queue.push(fn.id); + levels.set(fn.id, 0); + } + + while (queue.length > 0) { + const current = queue.shift()!; + const level = levels.get(current)!; + const dependents = findImportDependents(db, current) as RelatedNodeRow[]; + for (const dep of dependents) { + if (!visited.has(dep.id) && (!noTests || !isTestFile(dep.file))) { + visited.add(dep.id); + queue.push(dep.id); + levels.set(dep.id, level + 1); + } + } + } + + const byLevel: Record> = {}; + for (const [id, level] of levels) { + if (level === 0) continue; + if (!byLevel[level]) byLevel[level] = []; + const node = findNodeById(db, id) as NodeRow | undefined; + if (node) byLevel[level].push({ file: node.file }); + } + + return { + file, + sources: fileNodes.map((f) => f.file), + levels: byLevel, + totalDependents: visited.size - fileNodes.length, + }; + } finally { + db.close(); + } +} + +export function fnImpactData( + name: string, + customDbPath: string, + opts: { + depth?: number; + noTests?: boolean; + file?: string; + kind?: string; + includeImplementors?: boolean; + limit?: number; + offset?: number; + // biome-ignore lint/suspicious/noExplicitAny: config shape is dynamic + config?: any; + } = {}, +) { + const db = openReadonlyOrFail(customDbPath); + try { + const config = opts.config || loadConfig(); + const maxDepth = opts.depth || config.analysis?.fnImpactDepth || 5; + const noTests = opts.noTests || false; + const hc = new Map(); + + const nodes = findMatchingNodes(db, name, { noTests, file: opts.file, kind: opts.kind }); + if (nodes.length === 0) { + return { name, results: [] }; + } + + const includeImplementors = opts.includeImplementors !== false; + + const results = nodes.map((node) => { + const { levels, totalDependents } = bfsTransitiveCallers(db, node.id, { + noTests, + maxDepth, + includeImplementors, + }); + return { + ...normalizeSymbol(node, db, hc), + levels, + totalDependents, + }; + }); + + const base = { name, results }; + return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset }); + } finally { + db.close(); + } +} diff --git a/src/domain/analysis/impact.ts b/src/domain/analysis/impact.ts index 58882bc7..5cd25214 100644 --- a/src/domain/analysis/impact.ts +++ b/src/domain/analysis/impact.ts @@ -1,721 +1,12 @@ -import { execFileSync } from 'node:child_process'; -import fs from 'node:fs'; -import path from 'node:path'; -import { - findDbPath, - findDistinctCallers, - findFileNodes, - findImplementors, - findImportDependents, - findNodeById, - openReadonlyOrFail, -} from '../../db/index.js'; -import { cachedStmt } from '../../db/repository/cached-stmt.js'; -import { evaluateBoundaries } from '../../features/boundaries.js'; -import { coChangeForFiles } from '../../features/cochange.js'; -import { ownersForFiles } from '../../features/owners.js'; -import { loadConfig } from '../../infrastructure/config.js'; -import { debug } from '../../infrastructure/logger.js'; -import { isTestFile } from '../../infrastructure/test-filter.js'; -import { normalizeSymbol } from '../../shared/normalize.js'; -import { paginateResult } from '../../shared/paginate.js'; -import type { BetterSqlite3Database, NodeRow, RelatedNodeRow, StmtCache } from '../../types.js'; -import { findMatchingNodes } from './symbol-lookup.js'; - -const _defsStmtCache: StmtCache = new WeakMap(); - -// --- Shared BFS: transitive callers --- - -const INTERFACE_LIKE_KINDS = new Set(['interface', 'trait']); - -/** - * Check whether the graph contains any 'implements' edges. - * Cached per db handle so the query runs at most once per connection. - */ -const _hasImplementsCache: WeakMap = new WeakMap(); -function hasImplementsEdges(db: BetterSqlite3Database): boolean { - if (_hasImplementsCache.has(db)) return _hasImplementsCache.get(db)!; - const row = db.prepare("SELECT 1 FROM edges WHERE kind = 'implements' LIMIT 1").get(); - const result = !!row; - _hasImplementsCache.set(db, result); - return result; -} - -/** - * BFS traversal to find transitive callers of a node. - * When an interface/trait node is encountered (either as the start node or - * during traversal), its concrete implementors are also added to the frontier - * so that changes to an interface signature propagate to all implementors. - */ -export function bfsTransitiveCallers( - db: BetterSqlite3Database, - startId: number, - { - noTests = false, - maxDepth = 3, - includeImplementors = true, - onVisit, - }: { - noTests?: boolean; - maxDepth?: number; - includeImplementors?: boolean; - onVisit?: ( - caller: RelatedNodeRow & { viaImplements?: boolean }, - parentId: number, - depth: number, - ) => void; - } = {}, -) { - // Skip all implementor lookups when the graph has no implements edges - const resolveImplementors = includeImplementors && hasImplementsEdges(db); - - const visited = new Set([startId]); - const levels: Record< - number, - Array<{ name: string; kind: string; file: string; line: number; viaImplements?: boolean }> - > = {}; - let frontier = [startId]; - - // Seed: if start node is an interface/trait, include its implementors at depth 1. - // Implementors go into a separate list so their callers appear at depth 2, not depth 1. - const implNextFrontier: number[] = []; - if (resolveImplementors) { - const startNode = findNodeById(db, startId) as NodeRow | undefined; - if (startNode && INTERFACE_LIKE_KINDS.has(startNode.kind)) { - const impls = findImplementors(db, startId) as RelatedNodeRow[]; - for (const impl of impls) { - if (!visited.has(impl.id) && (!noTests || !isTestFile(impl.file))) { - visited.add(impl.id); - implNextFrontier.push(impl.id); - if (!levels[1]) levels[1] = []; - levels[1].push({ - name: impl.name, - kind: impl.kind, - file: impl.file, - line: impl.line, - viaImplements: true, - }); - if (onVisit) onVisit({ ...impl, viaImplements: true }, startId, 1); - } - } - } - } - - for (let d = 1; d <= maxDepth; d++) { - // On the first wave, merge seeded implementors so their callers appear at d=2 - if (d === 1 && implNextFrontier.length > 0) { - frontier = [...frontier, ...implNextFrontier]; - } - const nextFrontier: number[] = []; - for (const fid of frontier) { - const callers = findDistinctCallers(db, fid) as RelatedNodeRow[]; - for (const c of callers) { - if (!visited.has(c.id) && (!noTests || !isTestFile(c.file))) { - visited.add(c.id); - nextFrontier.push(c.id); - if (!levels[d]) levels[d] = []; - levels[d]!.push({ name: c.name, kind: c.kind, file: c.file, line: c.line }); - if (onVisit) onVisit(c, fid, d); - } - - // If a caller is an interface/trait, also pull in its implementors - // Implementors are one extra hop away, so record at d+1 - if (resolveImplementors && INTERFACE_LIKE_KINDS.has(c.kind)) { - const impls = findImplementors(db, c.id) as RelatedNodeRow[]; - for (const impl of impls) { - if (!visited.has(impl.id) && (!noTests || !isTestFile(impl.file))) { - visited.add(impl.id); - nextFrontier.push(impl.id); - const implDepth = d + 1; - if (!levels[implDepth]) levels[implDepth] = []; - levels[implDepth].push({ - name: impl.name, - kind: impl.kind, - file: impl.file, - line: impl.line, - viaImplements: true, - }); - if (onVisit) onVisit({ ...impl, viaImplements: true }, c.id, implDepth); - } - } - } - } - } - frontier = nextFrontier; - if (frontier.length === 0) break; - } - - return { totalDependents: visited.size - 1, levels }; -} - -export function impactAnalysisData( - file: string, - customDbPath: string, - opts: { noTests?: boolean } = {}, -) { - const db = openReadonlyOrFail(customDbPath); - try { - const noTests = opts.noTests || false; - const fileNodes = findFileNodes(db, `%${file}%`) as NodeRow[]; - if (fileNodes.length === 0) { - return { file, sources: [], levels: {}, totalDependents: 0 }; - } - - const visited = new Set(); - const queue: number[] = []; - const levels = new Map(); - - for (const fn of fileNodes) { - visited.add(fn.id); - queue.push(fn.id); - levels.set(fn.id, 0); - } - - while (queue.length > 0) { - const current = queue.shift()!; - const level = levels.get(current)!; - const dependents = findImportDependents(db, current) as RelatedNodeRow[]; - for (const dep of dependents) { - if (!visited.has(dep.id) && (!noTests || !isTestFile(dep.file))) { - visited.add(dep.id); - queue.push(dep.id); - levels.set(dep.id, level + 1); - } - } - } - - const byLevel: Record> = {}; - for (const [id, level] of levels) { - if (level === 0) continue; - if (!byLevel[level]) byLevel[level] = []; - const node = findNodeById(db, id) as NodeRow | undefined; - if (node) byLevel[level].push({ file: node.file }); - } - - return { - file, - sources: fileNodes.map((f) => f.file), - levels: byLevel, - totalDependents: visited.size - fileNodes.length, - }; - } finally { - db.close(); - } -} - -export function fnImpactData( - name: string, - customDbPath: string, - opts: { - depth?: number; - noTests?: boolean; - file?: string; - kind?: string; - includeImplementors?: boolean; - limit?: number; - offset?: number; - // biome-ignore lint/suspicious/noExplicitAny: config shape is dynamic - config?: any; - } = {}, -) { - const db = openReadonlyOrFail(customDbPath); - try { - const config = opts.config || loadConfig(); - const maxDepth = opts.depth || config.analysis?.fnImpactDepth || 5; - const noTests = opts.noTests || false; - const hc = new Map(); - - const nodes = findMatchingNodes(db, name, { noTests, file: opts.file, kind: opts.kind }); - if (nodes.length === 0) { - return { name, results: [] }; - } - - const includeImplementors = opts.includeImplementors !== false; - - const results = nodes.map((node) => { - const { levels, totalDependents } = bfsTransitiveCallers(db, node.id, { - noTests, - maxDepth, - includeImplementors, - }); - return { - ...normalizeSymbol(node, db, hc), - levels, - totalDependents, - }; - }); - - const base = { name, results }; - return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset }); - } finally { - db.close(); - } -} - -// --- diffImpactData helpers --- - -/** - * Walk up from repoRoot until a .git directory is found. - * Returns true if a git root exists, false otherwise. - */ -function findGitRoot(repoRoot: string): boolean { - let checkDir = repoRoot; - while (checkDir) { - if (fs.existsSync(path.join(checkDir, '.git'))) { - return true; - } - const parent = path.dirname(checkDir); - if (parent === checkDir) break; - checkDir = parent; - } - return false; -} - -/** - * Execute git diff and return the raw output string. - * Returns `{ output: string }` on success or `{ error: string }` on failure. - */ -function runGitDiff( - repoRoot: string, - opts: { staged?: boolean; ref?: string }, -): { output: string; error?: never } | { error: string; output?: never } { - try { - const args = opts.staged - ? ['diff', '--cached', '--unified=0', '--no-color'] - : ['diff', opts.ref || 'HEAD', '--unified=0', '--no-color']; - const output = execFileSync('git', args, { - cwd: repoRoot, - encoding: 'utf-8', - maxBuffer: 10 * 1024 * 1024, - stdio: ['pipe', 'pipe', 'pipe'], - }); - return { output }; - } catch (e: unknown) { - return { error: `Failed to run git diff: ${(e as Error).message}` }; - } -} - -/** - * Parse raw git diff output into a changedRanges map and newFiles set. - */ -function parseGitDiff(diffOutput: string) { - const changedRanges = new Map>(); - const newFiles = new Set(); - let currentFile: string | null = null; - let prevIsDevNull = false; - - for (const line of diffOutput.split('\n')) { - if (line.startsWith('--- /dev/null')) { - prevIsDevNull = true; - continue; - } - if (line.startsWith('--- ')) { - prevIsDevNull = false; - continue; - } - const fileMatch = line.match(/^\+\+\+ b\/(.+)/); - if (fileMatch) { - currentFile = fileMatch[1]!; - if (!changedRanges.has(currentFile)) changedRanges.set(currentFile, []); - if (prevIsDevNull) newFiles.add(currentFile!); - prevIsDevNull = false; - continue; - } - const hunkMatch = line.match(/^@@ .+ \+(\d+)(?:,(\d+))? @@/); - if (hunkMatch && currentFile) { - const start = parseInt(hunkMatch[1]!, 10); - const count = parseInt(hunkMatch[2] || '1', 10); - changedRanges.get(currentFile)!.push({ start, end: start + count - 1 }); - } - } - - return { changedRanges, newFiles }; -} - -/** - * Find all function/method/class nodes whose line ranges overlap any changed range. - */ -function findAffectedFunctions( - db: BetterSqlite3Database, - changedRanges: Map>, - noTests: boolean, -): NodeRow[] { - const affectedFunctions: NodeRow[] = []; - const defsStmt = cachedStmt( - _defsStmtCache, - db, - `SELECT * FROM nodes WHERE file = ? AND kind IN ('function', 'method', 'class') ORDER BY line`, - ); - for (const [file, ranges] of changedRanges) { - if (noTests && isTestFile(file)) continue; - const defs = defsStmt.all(file) as NodeRow[]; - for (let i = 0; i < defs.length; i++) { - const def = defs[i]!; - const endLine = def.end_line || (defs[i + 1] ? defs[i + 1]!.line - 1 : 999999); - for (const range of ranges) { - if (range.start <= endLine && range.end >= def.line) { - affectedFunctions.push(def); - break; - } - } - } - } - return affectedFunctions; -} - -/** - * Run BFS per affected function, collecting per-function results and the full affected set. - */ -function buildFunctionImpactResults( - db: BetterSqlite3Database, - affectedFunctions: NodeRow[], - noTests: boolean, - maxDepth: number, - includeImplementors = true, -) { - const allAffected = new Set(); - const functionResults = affectedFunctions.map((fn) => { - const edges: Array<{ from: string; to: string }> = []; - const idToKey = new Map(); - idToKey.set(fn.id, `${fn.file}::${fn.name}:${fn.line}`); - - const { levels, totalDependents } = bfsTransitiveCallers(db, fn.id, { - noTests, - maxDepth, - includeImplementors, - onVisit(c, parentId) { - allAffected.add(`${c.file}:${c.name}`); - const callerKey = `${c.file}::${c.name}:${c.line}`; - idToKey.set(c.id, callerKey); - edges.push({ from: idToKey.get(parentId)!, to: callerKey }); - }, - }); - - return { - name: fn.name, - kind: fn.kind, - file: fn.file, - line: fn.line, - transitiveCallers: totalDependents, - levels, - edges, - }; - }); - - return { functionResults, allAffected }; -} - -/** - * Look up historically co-changed files for the set of changed files. - * Returns an empty array if the co_changes table is unavailable. - */ -function lookupCoChanges( - db: BetterSqlite3Database, - changedRanges: Map, - affectedFiles: Set, - noTests: boolean, -) { - try { - db.prepare('SELECT 1 FROM co_changes LIMIT 1').get(); - const changedFilesList = [...changedRanges.keys()]; - const coResults = coChangeForFiles(changedFilesList, db, { - minJaccard: 0.3, - limit: 20, - noTests, - }); - return coResults.filter((r: { file: string }) => !affectedFiles.has(r.file)); - } catch (e: unknown) { - debug(`co_changes lookup skipped: ${(e as Error).message}`); - return []; - } -} - /** - * Look up CODEOWNERS for changed and affected files. - * Returns null if no owners are found or lookup fails. + * impact.ts — Re-export barrel for backward compatibility. + * + * The implementation has been split into focused modules: + * - fn-impact.ts: bfsTransitiveCallers, impactAnalysisData, fnImpactData + * - diff-impact.ts: diffImpactData and git diff analysis helpers + * - presentation/diff-impact-mermaid.ts: diffImpactMermaid (Mermaid diagram generation) */ -function lookupOwnership( - changedRanges: Map, - affectedFiles: Set, - repoRoot: string, -) { - try { - const allFilePaths = [...new Set([...changedRanges.keys(), ...affectedFiles])]; - const ownerResult = ownersForFiles(allFilePaths, repoRoot); - if (ownerResult.affectedOwners.length > 0) { - return { - owners: Object.fromEntries(ownerResult.owners), - affectedOwners: ownerResult.affectedOwners, - suggestedReviewers: ownerResult.suggestedReviewers, - }; - } - return null; - } catch (e: unknown) { - debug(`CODEOWNERS lookup skipped: ${(e as Error).message}`); - return null; - } -} - -/** - * Check manifesto boundary violations scoped to the changed files. - * Returns `{ boundaryViolations, boundaryViolationCount }`. - */ -function checkBoundaryViolations( - db: BetterSqlite3Database, - changedRanges: Map, - noTests: boolean, - // biome-ignore lint/suspicious/noExplicitAny: opts shape varies by caller - opts: any, - repoRoot: string, -) { - try { - const cfg = opts.config || loadConfig(repoRoot); - const boundaryConfig = cfg.manifesto?.boundaries; - if (boundaryConfig) { - const result = evaluateBoundaries(db, boundaryConfig, { - scopeFiles: [...changedRanges.keys()], - noTests, - }); - return { - boundaryViolations: result.violations, - boundaryViolationCount: result.violationCount, - }; - } - } catch (e: unknown) { - debug(`boundary check skipped: ${(e as Error).message}`); - } - return { boundaryViolations: [], boundaryViolationCount: 0 }; -} - -// --- diffImpactData --- - -/** - * Fix #2: Shell injection vulnerability. - * Uses execFileSync instead of execSync to prevent shell interpretation of user input. - */ -export function diffImpactData( - customDbPath: string, - opts: { - noTests?: boolean; - depth?: number; - staged?: boolean; - ref?: string; - includeImplementors?: boolean; - limit?: number; - offset?: number; - // biome-ignore lint/suspicious/noExplicitAny: config shape is dynamic - config?: any; - } = {}, -) { - const db = openReadonlyOrFail(customDbPath); - try { - const noTests = opts.noTests || false; - const config = opts.config || loadConfig(); - const maxDepth = opts.depth || config.analysis?.impactDepth || 3; - - const dbPath = findDbPath(customDbPath); - const repoRoot = path.resolve(path.dirname(dbPath), '..'); - - if (!findGitRoot(repoRoot)) { - return { error: `Not a git repository: ${repoRoot}` }; - } - - const gitResult = runGitDiff(repoRoot, opts); - if ('error' in gitResult) return { error: gitResult.error }; - - if (!gitResult.output.trim()) { - return { - changedFiles: 0, - newFiles: [], - affectedFunctions: [], - affectedFiles: [], - summary: null, - }; - } - - const { changedRanges, newFiles } = parseGitDiff(gitResult.output); - - if (changedRanges.size === 0) { - return { - changedFiles: 0, - newFiles: [], - affectedFunctions: [], - affectedFiles: [], - summary: null, - }; - } - - const affectedFunctions = findAffectedFunctions(db, changedRanges, noTests); - const includeImplementors = opts.includeImplementors !== false; - const { functionResults, allAffected } = buildFunctionImpactResults( - db, - affectedFunctions, - noTests, - maxDepth, - includeImplementors, - ); - - const affectedFiles = new Set(); - for (const key of allAffected) affectedFiles.add(key.split(':')[0]!); - - const historicallyCoupled = lookupCoChanges(db, changedRanges, affectedFiles, noTests); - const ownership = lookupOwnership(changedRanges, affectedFiles, repoRoot); - const { boundaryViolations, boundaryViolationCount } = checkBoundaryViolations( - db, - changedRanges, - noTests, - opts, - repoRoot, - ); - - const base = { - changedFiles: changedRanges.size, - newFiles: [...newFiles], - affectedFunctions: functionResults, - affectedFiles: [...affectedFiles], - historicallyCoupled, - ownership, - boundaryViolations, - boundaryViolationCount, - summary: { - functionsChanged: affectedFunctions.length, - callersAffected: allAffected.size, - filesAffected: affectedFiles.size, - historicallyCoupledCount: historicallyCoupled.length, - ownersAffected: ownership ? ownership.affectedOwners.length : 0, - boundaryViolationCount, - }, - }; - return paginateResult(base, 'affectedFunctions', { limit: opts.limit, offset: opts.offset }); - } finally { - db.close(); - } -} - -export function diffImpactMermaid( - customDbPath: string, - opts: { - noTests?: boolean; - depth?: number; - staged?: boolean; - ref?: string; - includeImplementors?: boolean; - limit?: number; - offset?: number; - // biome-ignore lint/suspicious/noExplicitAny: config shape is dynamic - config?: any; - } = {}, -): string { - // biome-ignore lint/suspicious/noExplicitAny: paginateResult returns dynamic shape - const data: any = diffImpactData(customDbPath, opts); - if ('error' in data) return data.error as string; - if (data.changedFiles === 0 || data.affectedFunctions.length === 0) { - return 'flowchart TB\n none["No impacted functions detected"]'; - } - - const newFileSet = new Set(data.newFiles || []); - const lines = ['flowchart TB']; - - // Assign stable Mermaid node IDs - let nodeCounter = 0; - const nodeIdMap = new Map(); - const nodeLabels = new Map(); - function nodeId(key: string, label?: string): string { - if (!nodeIdMap.has(key)) { - nodeIdMap.set(key, `n${nodeCounter++}`); - if (label) nodeLabels.set(key, label); - } - return nodeIdMap.get(key)!; - } - - // Register all nodes (changed functions + their callers) - for (const fn of data.affectedFunctions) { - nodeId(`${fn.file}::${fn.name}:${fn.line}`, fn.name); - for (const callers of Object.values(fn.levels || {})) { - for (const c of callers as Array<{ name: string; file: string; line: number }>) { - nodeId(`${c.file}::${c.name}:${c.line}`, c.name); - } - } - } - - // Collect all edges and determine blast radius - const allEdges = new Set(); - const edgeFromNodes = new Set(); - const edgeToNodes = new Set(); - const changedKeys = new Set(); - - for (const fn of data.affectedFunctions) { - changedKeys.add(`${fn.file}::${fn.name}:${fn.line}`); - for (const edge of fn.edges || []) { - const edgeKey = `${edge.from}|${edge.to}`; - if (!allEdges.has(edgeKey)) { - allEdges.add(edgeKey); - edgeFromNodes.add(edge.from); - edgeToNodes.add(edge.to); - } - } - } - - // Blast radius: caller nodes that are never a source (leaf nodes of the impact tree) - const blastRadiusKeys = new Set(); - for (const key of edgeToNodes) { - if (!edgeFromNodes.has(key) && !changedKeys.has(key)) { - blastRadiusKeys.add(key); - } - } - - // Intermediate callers: not changed, not blast radius - const intermediateKeys = new Set(); - for (const key of edgeToNodes) { - if (!changedKeys.has(key) && !blastRadiusKeys.has(key)) { - intermediateKeys.add(key); - } - } - - // Group changed functions by file - const fileGroups = new Map(); - for (const fn of data.affectedFunctions) { - if (!fileGroups.has(fn.file)) fileGroups.set(fn.file, []); - fileGroups.get(fn.file)!.push(fn); - } - - // Emit changed-file subgraphs - let sgCounter = 0; - for (const [file, fns] of fileGroups) { - const isNew = newFileSet.has(file); - const tag = isNew ? 'new' : 'modified'; - const sgId = `sg${sgCounter++}`; - lines.push(` subgraph ${sgId}["${file} **(${tag})**"]`); - for (const fn of fns) { - const key = `${fn.file}::${fn.name}:${fn.line}`; - lines.push(` ${nodeIdMap.get(key)}["${fn.name}"]`); - } - lines.push(' end'); - const style = isNew ? 'fill:#e8f5e9,stroke:#4caf50' : 'fill:#fff3e0,stroke:#ff9800'; - lines.push(` style ${sgId} ${style}`); - } - - // Emit intermediate caller nodes (outside subgraphs) - for (const key of intermediateKeys) { - lines.push(` ${nodeIdMap.get(key)}["${nodeLabels.get(key)}"]`); - } - - // Emit blast radius subgraph - if (blastRadiusKeys.size > 0) { - const sgId = `sg${sgCounter++}`; - lines.push(` subgraph ${sgId}["Callers **(blast radius)**"]`); - for (const key of blastRadiusKeys) { - lines.push(` ${nodeIdMap.get(key)}["${nodeLabels.get(key)}"]`); - } - lines.push(' end'); - lines.push(` style ${sgId} fill:#f3e5f5,stroke:#9c27b0`); - } - - // Emit edges (impact flows from changed fn toward callers) - for (const edgeKey of allEdges) { - const [from, to] = edgeKey.split('|') as [string, string]; - lines.push(` ${nodeIdMap.get(from)} --> ${nodeIdMap.get(to)}`); - } - return lines.join('\n'); -} +export { diffImpactMermaid } from '../../presentation/diff-impact-mermaid.js'; +export { diffImpactData } from './diff-impact.js'; +export { bfsTransitiveCallers, fnImpactData, impactAnalysisData } from './fn-impact.js'; diff --git a/src/presentation/diff-impact-mermaid.ts b/src/presentation/diff-impact-mermaid.ts new file mode 100644 index 00000000..68fd9999 --- /dev/null +++ b/src/presentation/diff-impact-mermaid.ts @@ -0,0 +1,129 @@ +import { diffImpactData } from '../domain/analysis/diff-impact.js'; + +export function diffImpactMermaid( + customDbPath: string, + opts: { + noTests?: boolean; + depth?: number; + staged?: boolean; + ref?: string; + includeImplementors?: boolean; + limit?: number; + offset?: number; + // biome-ignore lint/suspicious/noExplicitAny: config shape is dynamic + config?: any; + } = {}, +): string { + // biome-ignore lint/suspicious/noExplicitAny: paginateResult returns dynamic shape + const data: any = diffImpactData(customDbPath, opts); + if ('error' in data) return data.error as string; + if (data.changedFiles === 0 || data.affectedFunctions.length === 0) { + return 'flowchart TB\n none["No impacted functions detected"]'; + } + + const newFileSet = new Set(data.newFiles || []); + const lines = ['flowchart TB']; + + // Assign stable Mermaid node IDs + let nodeCounter = 0; + const nodeIdMap = new Map(); + const nodeLabels = new Map(); + function nodeId(key: string, label?: string): string { + if (!nodeIdMap.has(key)) { + nodeIdMap.set(key, `n${nodeCounter++}`); + if (label) nodeLabels.set(key, label); + } + return nodeIdMap.get(key)!; + } + + // Register all nodes (changed functions + their callers) + for (const fn of data.affectedFunctions) { + nodeId(`${fn.file}::${fn.name}:${fn.line}`, fn.name); + for (const callers of Object.values(fn.levels || {})) { + for (const c of callers as Array<{ name: string; file: string; line: number }>) { + nodeId(`${c.file}::${c.name}:${c.line}`, c.name); + } + } + } + + // Collect all edges and determine blast radius + const allEdges = new Set(); + const edgeFromNodes = new Set(); + const edgeToNodes = new Set(); + const changedKeys = new Set(); + + for (const fn of data.affectedFunctions) { + changedKeys.add(`${fn.file}::${fn.name}:${fn.line}`); + for (const edge of fn.edges || []) { + const edgeKey = `${edge.from}|${edge.to}`; + if (!allEdges.has(edgeKey)) { + allEdges.add(edgeKey); + edgeFromNodes.add(edge.from); + edgeToNodes.add(edge.to); + } + } + } + + // Blast radius: caller nodes that are never a source (leaf nodes of the impact tree) + const blastRadiusKeys = new Set(); + for (const key of edgeToNodes) { + if (!edgeFromNodes.has(key) && !changedKeys.has(key)) { + blastRadiusKeys.add(key); + } + } + + // Intermediate callers: not changed, not blast radius + const intermediateKeys = new Set(); + for (const key of edgeToNodes) { + if (!changedKeys.has(key) && !blastRadiusKeys.has(key)) { + intermediateKeys.add(key); + } + } + + // Group changed functions by file + const fileGroups = new Map(); + for (const fn of data.affectedFunctions) { + if (!fileGroups.has(fn.file)) fileGroups.set(fn.file, []); + fileGroups.get(fn.file)!.push(fn); + } + + // Emit changed-file subgraphs + let sgCounter = 0; + for (const [file, fns] of fileGroups) { + const isNew = newFileSet.has(file); + const tag = isNew ? 'new' : 'modified'; + const sgId = `sg${sgCounter++}`; + lines.push(` subgraph ${sgId}["${file} **(${tag})**"]`); + for (const fn of fns) { + const key = `${fn.file}::${fn.name}:${fn.line}`; + lines.push(` ${nodeIdMap.get(key)}["${fn.name}"]`); + } + lines.push(' end'); + const style = isNew ? 'fill:#e8f5e9,stroke:#4caf50' : 'fill:#fff3e0,stroke:#ff9800'; + lines.push(` style ${sgId} ${style}`); + } + + // Emit intermediate caller nodes (outside subgraphs) + for (const key of intermediateKeys) { + lines.push(` ${nodeIdMap.get(key)}["${nodeLabels.get(key)}"]`); + } + + // Emit blast radius subgraph + if (blastRadiusKeys.size > 0) { + const sgId = `sg${sgCounter++}`; + lines.push(` subgraph ${sgId}["Callers **(blast radius)**"]`); + for (const key of blastRadiusKeys) { + lines.push(` ${nodeIdMap.get(key)}["${nodeLabels.get(key)}"]`); + } + lines.push(' end'); + lines.push(` style ${sgId} fill:#f3e5f5,stroke:#9c27b0`); + } + + // Emit edges (impact flows from changed fn toward callers) + for (const edgeKey of allEdges) { + const [from, to] = edgeKey.split('|') as [string, string]; + lines.push(` ${nodeIdMap.get(from)} --> ${nodeIdMap.get(to)}`); + } + + return lines.join('\n'); +} From 2113bd65302a3253e13c3004ddea8f2ad670b33a Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Wed, 25 Mar 2026 22:54:27 -0600 Subject: [PATCH 3/9] refactor: split cfg-visitor.ts by control-flow construct Impact: 16 functions changed, 0 affected --- src/ast-analysis/visitors/cfg-conditionals.ts | 221 ++++++ src/ast-analysis/visitors/cfg-loops.ts | 138 ++++ src/ast-analysis/visitors/cfg-shared.ts | 189 +++++ src/ast-analysis/visitors/cfg-try-catch.ts | 143 ++++ src/ast-analysis/visitors/cfg-visitor.ts | 689 +----------------- 5 files changed, 725 insertions(+), 655 deletions(-) create mode 100644 src/ast-analysis/visitors/cfg-conditionals.ts create mode 100644 src/ast-analysis/visitors/cfg-loops.ts create mode 100644 src/ast-analysis/visitors/cfg-shared.ts create mode 100644 src/ast-analysis/visitors/cfg-try-catch.ts diff --git a/src/ast-analysis/visitors/cfg-conditionals.ts b/src/ast-analysis/visitors/cfg-conditionals.ts new file mode 100644 index 00000000..1b56e327 --- /dev/null +++ b/src/ast-analysis/visitors/cfg-conditionals.ts @@ -0,0 +1,221 @@ +import type { TreeSitterNode } from '../../types.js'; +import type { ProcessStatementsFn } from './cfg-loops.js'; +import type { AnyRules, CfgBlockInternal, FuncState, LoopCtx } from './cfg-shared.js'; +import { getBodyStatements, isCaseNode, isIfNode, nn } from './cfg-shared.js'; + +export function processIf( + ifStmt: TreeSitterNode, + currentBlock: CfgBlockInternal, + S: FuncState, + cfgRules: AnyRules, + processStatements: ProcessStatementsFn, +): CfgBlockInternal { + currentBlock.endLine = ifStmt.startPosition.row + 1; + + const condBlock = S.makeBlock( + 'condition', + ifStmt.startPosition.row + 1, + ifStmt.startPosition.row + 1, + 'if', + ); + S.addEdge(currentBlock, condBlock, 'fallthrough'); + + const joinBlock = S.makeBlock('body'); + + const consequentField = cfgRules.ifConsequentField || 'consequence'; + const consequent = ifStmt.childForFieldName(consequentField); + const trueBlock = S.makeBlock('branch_true', null, null, 'then'); + S.addEdge(condBlock, trueBlock, 'branch_true'); + const trueStmts = getBodyStatements(consequent, cfgRules); + const trueEnd = processStatements(trueStmts, trueBlock, S, cfgRules); + if (trueEnd) { + S.addEdge(trueEnd, joinBlock, 'fallthrough'); + } + + if (cfgRules.elifNode) { + processElifSiblings(ifStmt, condBlock, joinBlock, S, cfgRules, processStatements); + } else { + processAlternative(ifStmt, condBlock, joinBlock, S, cfgRules, processStatements); + } + + return joinBlock; +} + +export function processAlternative( + ifStmt: TreeSitterNode, + condBlock: CfgBlockInternal, + joinBlock: CfgBlockInternal, + S: FuncState, + cfgRules: AnyRules, + processStatements: ProcessStatementsFn, +): void { + const alternative = ifStmt.childForFieldName('alternative'); + if (!alternative) { + S.addEdge(condBlock, joinBlock, 'branch_false'); + return; + } + + if (cfgRules.elseViaAlternative && alternative.type !== cfgRules.elseClause) { + if (isIfNode(alternative.type, cfgRules)) { + const falseBlock = S.makeBlock('branch_false', null, null, 'else-if'); + S.addEdge(condBlock, falseBlock, 'branch_false'); + const elseIfEnd = processIf(alternative, falseBlock, S, cfgRules, processStatements); + if (elseIfEnd) S.addEdge(elseIfEnd, joinBlock, 'fallthrough'); + } else { + const falseBlock = S.makeBlock('branch_false', null, null, 'else'); + S.addEdge(condBlock, falseBlock, 'branch_false'); + const falseStmts = getBodyStatements(alternative, cfgRules); + const falseEnd = processStatements(falseStmts, falseBlock, S, cfgRules); + if (falseEnd) S.addEdge(falseEnd, joinBlock, 'fallthrough'); + } + } else if (alternative.type === cfgRules.elseClause) { + const elseChildren: TreeSitterNode[] = []; + for (let i = 0; i < alternative.namedChildCount; i++) { + elseChildren.push(nn(alternative.namedChild(i))); + } + if (elseChildren.length === 1 && isIfNode(elseChildren[0]!.type, cfgRules)) { + const falseBlock = S.makeBlock('branch_false', null, null, 'else-if'); + S.addEdge(condBlock, falseBlock, 'branch_false'); + const elseIfEnd = processIf(elseChildren[0]!, falseBlock, S, cfgRules, processStatements); + if (elseIfEnd) S.addEdge(elseIfEnd, joinBlock, 'fallthrough'); + } else { + const falseBlock = S.makeBlock('branch_false', null, null, 'else'); + S.addEdge(condBlock, falseBlock, 'branch_false'); + const falseEnd = processStatements(elseChildren, falseBlock, S, cfgRules); + if (falseEnd) S.addEdge(falseEnd, joinBlock, 'fallthrough'); + } + } +} + +export function processElifSiblings( + ifStmt: TreeSitterNode, + firstCondBlock: CfgBlockInternal, + joinBlock: CfgBlockInternal, + S: FuncState, + cfgRules: AnyRules, + processStatements: ProcessStatementsFn, +): void { + let lastCondBlock = firstCondBlock; + let foundElse = false; + + for (let i = 0; i < ifStmt.namedChildCount; i++) { + const child = nn(ifStmt.namedChild(i)); + + if (child.type === cfgRules.elifNode) { + const elifCondBlock = S.makeBlock( + 'condition', + child.startPosition.row + 1, + child.startPosition.row + 1, + 'else-if', + ); + S.addEdge(lastCondBlock, elifCondBlock, 'branch_false'); + + const elifConsequentField = cfgRules.ifConsequentField || 'consequence'; + const elifConsequent = child.childForFieldName(elifConsequentField); + const elifTrueBlock = S.makeBlock('branch_true', null, null, 'then'); + S.addEdge(elifCondBlock, elifTrueBlock, 'branch_true'); + const elifTrueStmts = getBodyStatements(elifConsequent, cfgRules); + const elifTrueEnd = processStatements(elifTrueStmts, elifTrueBlock, S, cfgRules); + if (elifTrueEnd) S.addEdge(elifTrueEnd, joinBlock, 'fallthrough'); + + lastCondBlock = elifCondBlock; + } else if (child.type === cfgRules.elseClause) { + const elseBlock = S.makeBlock('branch_false', null, null, 'else'); + S.addEdge(lastCondBlock, elseBlock, 'branch_false'); + + const elseBody = child.childForFieldName('body'); + let elseStmts: TreeSitterNode[]; + if (elseBody) { + elseStmts = getBodyStatements(elseBody, cfgRules); + } else { + elseStmts = []; + for (let j = 0; j < child.namedChildCount; j++) { + elseStmts.push(nn(child.namedChild(j))); + } + } + const elseEnd = processStatements(elseStmts, elseBlock, S, cfgRules); + if (elseEnd) S.addEdge(elseEnd, joinBlock, 'fallthrough'); + + foundElse = true; + } + } + + if (!foundElse) { + S.addEdge(lastCondBlock, joinBlock, 'branch_false'); + } +} + +export function processSwitch( + switchStmt: TreeSitterNode, + currentBlock: CfgBlockInternal, + S: FuncState, + cfgRules: AnyRules, + processStatements: ProcessStatementsFn, +): CfgBlockInternal { + currentBlock.endLine = switchStmt.startPosition.row + 1; + + const switchHeader = S.makeBlock( + 'condition', + switchStmt.startPosition.row + 1, + switchStmt.startPosition.row + 1, + 'switch', + ); + S.addEdge(currentBlock, switchHeader, 'fallthrough'); + + const joinBlock = S.makeBlock('body'); + const switchCtx: LoopCtx = { headerBlock: switchHeader, exitBlock: joinBlock }; + S.loopStack.push(switchCtx); + + const switchBody = switchStmt.childForFieldName('body'); + const container = switchBody || switchStmt; + + let hasDefault = false; + for (let i = 0; i < container.namedChildCount; i++) { + const caseClause = nn(container.namedChild(i)); + + const isDefault = caseClause.type === cfgRules.defaultNode; + const isCase = isDefault || isCaseNode(caseClause.type, cfgRules); + if (!isCase) continue; + + const caseLabel = isDefault ? 'default' : 'case'; + const caseBlock = S.makeBlock('case', caseClause.startPosition.row + 1, null, caseLabel); + S.addEdge(switchHeader, caseBlock, isDefault ? 'branch_false' : 'branch_true'); + if (isDefault) hasDefault = true; + + const caseStmts = extractCaseBody(caseClause, cfgRules); + const caseEnd = processStatements(caseStmts, caseBlock, S, cfgRules); + if (caseEnd) S.addEdge(caseEnd, joinBlock, 'fallthrough'); + } + + if (!hasDefault) { + S.addEdge(switchHeader, joinBlock, 'branch_false'); + } + + S.loopStack.pop(); + return joinBlock; +} + +export function extractCaseBody(caseClause: TreeSitterNode, cfgRules: AnyRules): TreeSitterNode[] { + const caseBodyNode = + caseClause.childForFieldName('body') || caseClause.childForFieldName('consequence'); + if (caseBodyNode) { + return getBodyStatements(caseBodyNode, cfgRules); + } + + const stmts: TreeSitterNode[] = []; + const valueNode = caseClause.childForFieldName('value'); + const patternNode = caseClause.childForFieldName('pattern'); + for (let j = 0; j < caseClause.namedChildCount; j++) { + const child = nn(caseClause.namedChild(j)); + if (child !== valueNode && child !== patternNode && child.type !== 'switch_label') { + if (child.type === 'statement_list') { + for (let k = 0; k < child.namedChildCount; k++) { + stmts.push(nn(child.namedChild(k))); + } + } else { + stmts.push(child); + } + } + } + return stmts; +} diff --git a/src/ast-analysis/visitors/cfg-loops.ts b/src/ast-analysis/visitors/cfg-loops.ts new file mode 100644 index 00000000..51c792f4 --- /dev/null +++ b/src/ast-analysis/visitors/cfg-loops.ts @@ -0,0 +1,138 @@ +import type { TreeSitterNode } from '../../types.js'; +import type { AnyRules, CfgBlockInternal, FuncState, LoopCtx } from './cfg-shared.js'; +import { getBodyStatements, registerLabelCtx } from './cfg-shared.js'; + +/** Callback type for the mutual recursion with processStatements in cfg-visitor. */ +export type ProcessStatementsFn = ( + stmts: TreeSitterNode[], + currentBlock: CfgBlockInternal, + S: FuncState, + cfgRules: AnyRules, +) => CfgBlockInternal | null; + +export function processForLoop( + forStmt: TreeSitterNode, + currentBlock: CfgBlockInternal, + S: FuncState, + cfgRules: AnyRules, + processStatements: ProcessStatementsFn, +): CfgBlockInternal { + const headerBlock = S.makeBlock( + 'loop_header', + forStmt.startPosition.row + 1, + forStmt.startPosition.row + 1, + 'for', + ); + S.addEdge(currentBlock, headerBlock, 'fallthrough'); + + const loopExitBlock = S.makeBlock('body'); + const loopCtx: LoopCtx = { headerBlock, exitBlock: loopExitBlock }; + S.loopStack.push(loopCtx); + registerLabelCtx(S, headerBlock, loopExitBlock); + + const body = forStmt.childForFieldName('body'); + const bodyBlock = S.makeBlock('loop_body'); + S.addEdge(headerBlock, bodyBlock, 'branch_true'); + + const bodyStmts = getBodyStatements(body, cfgRules); + const bodyEnd = processStatements(bodyStmts, bodyBlock, S, cfgRules); + if (bodyEnd) S.addEdge(bodyEnd, headerBlock, 'loop_back'); + + S.addEdge(headerBlock, loopExitBlock, 'loop_exit'); + S.loopStack.pop(); + return loopExitBlock; +} + +export function processWhileLoop( + whileStmt: TreeSitterNode, + currentBlock: CfgBlockInternal, + S: FuncState, + cfgRules: AnyRules, + processStatements: ProcessStatementsFn, +): CfgBlockInternal { + const headerBlock = S.makeBlock( + 'loop_header', + whileStmt.startPosition.row + 1, + whileStmt.startPosition.row + 1, + 'while', + ); + S.addEdge(currentBlock, headerBlock, 'fallthrough'); + + const loopExitBlock = S.makeBlock('body'); + const loopCtx: LoopCtx = { headerBlock, exitBlock: loopExitBlock }; + S.loopStack.push(loopCtx); + registerLabelCtx(S, headerBlock, loopExitBlock); + + const body = whileStmt.childForFieldName('body'); + const bodyBlock = S.makeBlock('loop_body'); + S.addEdge(headerBlock, bodyBlock, 'branch_true'); + + const bodyStmts = getBodyStatements(body, cfgRules); + const bodyEnd = processStatements(bodyStmts, bodyBlock, S, cfgRules); + if (bodyEnd) S.addEdge(bodyEnd, headerBlock, 'loop_back'); + + S.addEdge(headerBlock, loopExitBlock, 'loop_exit'); + S.loopStack.pop(); + return loopExitBlock; +} + +export function processDoWhileLoop( + doStmt: TreeSitterNode, + currentBlock: CfgBlockInternal, + S: FuncState, + cfgRules: AnyRules, + processStatements: ProcessStatementsFn, +): CfgBlockInternal { + const bodyBlock = S.makeBlock('loop_body', doStmt.startPosition.row + 1, null, 'do'); + S.addEdge(currentBlock, bodyBlock, 'fallthrough'); + + const condBlock = S.makeBlock('loop_header', null, null, 'do-while'); + const loopExitBlock = S.makeBlock('body'); + + const loopCtx: LoopCtx = { headerBlock: condBlock, exitBlock: loopExitBlock }; + S.loopStack.push(loopCtx); + registerLabelCtx(S, condBlock, loopExitBlock); + + const body = doStmt.childForFieldName('body'); + const bodyStmts = getBodyStatements(body, cfgRules); + const bodyEnd = processStatements(bodyStmts, bodyBlock, S, cfgRules); + if (bodyEnd) S.addEdge(bodyEnd, condBlock, 'fallthrough'); + + S.addEdge(condBlock, bodyBlock, 'loop_back'); + S.addEdge(condBlock, loopExitBlock, 'loop_exit'); + + S.loopStack.pop(); + return loopExitBlock; +} + +export function processInfiniteLoop( + loopStmt: TreeSitterNode, + currentBlock: CfgBlockInternal, + S: FuncState, + cfgRules: AnyRules, + processStatements: ProcessStatementsFn, +): CfgBlockInternal { + const headerBlock = S.makeBlock( + 'loop_header', + loopStmt.startPosition.row + 1, + loopStmt.startPosition.row + 1, + 'loop', + ); + S.addEdge(currentBlock, headerBlock, 'fallthrough'); + + const loopExitBlock = S.makeBlock('body'); + const loopCtx: LoopCtx = { headerBlock, exitBlock: loopExitBlock }; + S.loopStack.push(loopCtx); + registerLabelCtx(S, headerBlock, loopExitBlock); + + const body = loopStmt.childForFieldName('body'); + const bodyBlock = S.makeBlock('loop_body'); + S.addEdge(headerBlock, bodyBlock, 'branch_true'); + + const bodyStmts = getBodyStatements(body, cfgRules); + const bodyEnd = processStatements(bodyStmts, bodyBlock, S, cfgRules); + if (bodyEnd) S.addEdge(bodyEnd, headerBlock, 'loop_back'); + + S.loopStack.pop(); + return loopExitBlock; +} diff --git a/src/ast-analysis/visitors/cfg-shared.ts b/src/ast-analysis/visitors/cfg-shared.ts new file mode 100644 index 00000000..73631422 --- /dev/null +++ b/src/ast-analysis/visitors/cfg-shared.ts @@ -0,0 +1,189 @@ +import type { TreeSitterNode } from '../../types.js'; + +// biome-ignore lint/suspicious/noExplicitAny: CFG rules are opaque language-specific objects +export type AnyRules = any; + +export function nn(node: TreeSitterNode | null, context?: string): TreeSitterNode { + if (node === null) { + throw new Error(`Unexpected null tree-sitter node${context ? ` (${context})` : ''}`); + } + return node; +} + +export interface CfgBlockInternal { + index: number; + type: string; + startLine: number | null; + endLine: number | null; + label: string | null; +} + +export interface CfgEdgeInternal { + sourceIndex: number; + targetIndex: number; + kind: string; +} + +export interface LabelCtx { + headerBlock: CfgBlockInternal | null; + exitBlock: CfgBlockInternal | null; +} + +export interface LoopCtx { + headerBlock: CfgBlockInternal; + exitBlock: CfgBlockInternal; +} + +export interface FuncState { + blocks: CfgBlockInternal[]; + edges: CfgEdgeInternal[]; + makeBlock( + type: string, + startLine?: number | null, + endLine?: number | null, + label?: string | null, + ): CfgBlockInternal; + addEdge(source: CfgBlockInternal, target: CfgBlockInternal, kind: string): void; + entryBlock: CfgBlockInternal; + exitBlock: CfgBlockInternal; + currentBlock: CfgBlockInternal | null; + loopStack: LoopCtx[]; + labelMap: Map; + cfgStack: FuncState[]; + funcNode: TreeSitterNode | null; +} + +export interface CFGResultInternal { + funcNode: TreeSitterNode; + blocks: CfgBlockInternal[]; + edges: CfgEdgeInternal[]; + cyclomatic: number; +} + +export function isIfNode(type: string, cfgRules: AnyRules): boolean { + return type === cfgRules.ifNode || cfgRules.ifNodes?.has(type); +} + +export function isForNode(type: string, cfgRules: AnyRules): boolean { + return cfgRules.forNodes.has(type); +} + +export function isWhileNode(type: string, cfgRules: AnyRules): boolean { + return type === cfgRules.whileNode || cfgRules.whileNodes?.has(type); +} + +export function isSwitchNode(type: string, cfgRules: AnyRules): boolean { + return type === cfgRules.switchNode || cfgRules.switchNodes?.has(type); +} + +export function isCaseNode(type: string, cfgRules: AnyRules): boolean { + return ( + type === cfgRules.caseNode || type === cfgRules.defaultNode || cfgRules.caseNodes?.has(type) + ); +} + +export function isBlockNode(type: string, cfgRules: AnyRules): boolean { + return type === 'statement_list' || type === cfgRules.blockNode || cfgRules.blockNodes?.has(type); +} + +export function isControlFlow(type: string, cfgRules: AnyRules): boolean { + return ( + isIfNode(type, cfgRules) || + (cfgRules.unlessNode && type === cfgRules.unlessNode) || + isForNode(type, cfgRules) || + isWhileNode(type, cfgRules) || + (cfgRules.untilNode && type === cfgRules.untilNode) || + (cfgRules.doNode && type === cfgRules.doNode) || + (cfgRules.infiniteLoopNode && type === cfgRules.infiniteLoopNode) || + isSwitchNode(type, cfgRules) || + (cfgRules.tryNode && type === cfgRules.tryNode) || + type === cfgRules.returnNode || + type === cfgRules.throwNode || + type === cfgRules.breakNode || + type === cfgRules.continueNode || + type === cfgRules.labeledNode + ); +} + +export function effectiveNode(node: TreeSitterNode, cfgRules: AnyRules): TreeSitterNode { + if (node.type === 'expression_statement' && node.namedChildCount === 1) { + const inner = nn(node.namedChild(0)); + if (isControlFlow(inner.type, cfgRules)) return inner; + } + return node; +} + +export function registerLabelCtx( + S: FuncState, + headerBlock: CfgBlockInternal, + exitBlock: CfgBlockInternal, +): void { + for (const [, ctx] of Array.from(S.labelMap)) { + if (!ctx.headerBlock) { + ctx.headerBlock = headerBlock; + ctx.exitBlock = exitBlock; + } + } +} + +export function getBodyStatements( + bodyNode: TreeSitterNode | null, + cfgRules: AnyRules, +): TreeSitterNode[] { + if (!bodyNode) return []; + if (isBlockNode(bodyNode.type, cfgRules)) { + const stmts: TreeSitterNode[] = []; + for (let i = 0; i < bodyNode.namedChildCount; i++) { + const child = nn(bodyNode.namedChild(i)); + if (child.type === 'statement_list') { + for (let j = 0; j < child.namedChildCount; j++) { + stmts.push(nn(child.namedChild(j))); + } + } else { + stmts.push(child); + } + } + return stmts; + } + return [bodyNode]; +} + +export function makeFuncState(): FuncState { + const blocks: CfgBlockInternal[] = []; + const edges: CfgEdgeInternal[] = []; + let nextIndex = 0; + + function makeBlock( + type: string, + startLine: number | null = null, + endLine: number | null = null, + label: string | null = null, + ): CfgBlockInternal { + const block: CfgBlockInternal = { index: nextIndex++, type, startLine, endLine, label }; + blocks.push(block); + return block; + } + + function addEdge(source: CfgBlockInternal, target: CfgBlockInternal, kind: string): void { + edges.push({ sourceIndex: source.index, targetIndex: target.index, kind }); + } + + const entry = makeBlock('entry'); + const exit = makeBlock('exit'); + const firstBody = makeBlock('body'); + addEdge(entry, firstBody, 'fallthrough'); + + return { + blocks, + edges, + makeBlock, + addEdge, + entryBlock: entry, + exitBlock: exit, + currentBlock: firstBody, + loopStack: [], + labelMap: new Map(), + cfgStack: [], + funcNode: null, + }; +} diff --git a/src/ast-analysis/visitors/cfg-try-catch.ts b/src/ast-analysis/visitors/cfg-try-catch.ts new file mode 100644 index 00000000..4f575afc --- /dev/null +++ b/src/ast-analysis/visitors/cfg-try-catch.ts @@ -0,0 +1,143 @@ +import type { TreeSitterNode } from '../../types.js'; +import type { ProcessStatementsFn } from './cfg-loops.js'; +import type { AnyRules, CfgBlockInternal, FuncState } from './cfg-shared.js'; +import { getBodyStatements, nn } from './cfg-shared.js'; + +export function processTryCatch( + tryStmt: TreeSitterNode, + currentBlock: CfgBlockInternal, + S: FuncState, + cfgRules: AnyRules, + processStatements: ProcessStatementsFn, +): CfgBlockInternal { + currentBlock.endLine = tryStmt.startPosition.row + 1; + + const joinBlock = S.makeBlock('body'); + + const tryBody = tryStmt.childForFieldName('body'); + let tryBodyStart: number; + let tryStmts: TreeSitterNode[]; + if (tryBody) { + tryBodyStart = tryBody.startPosition.row + 1; + tryStmts = getBodyStatements(tryBody, cfgRules); + } else { + tryBodyStart = tryStmt.startPosition.row + 1; + tryStmts = []; + for (let i = 0; i < tryStmt.namedChildCount; i++) { + const child = nn(tryStmt.namedChild(i)); + if (cfgRules.catchNode && child.type === cfgRules.catchNode) continue; + if (cfgRules.finallyNode && child.type === cfgRules.finallyNode) continue; + tryStmts.push(child); + } + } + + const tryBlock = S.makeBlock('body', tryBodyStart, null, 'try'); + S.addEdge(currentBlock, tryBlock, 'fallthrough'); + const tryEnd = processStatements(tryStmts, tryBlock, S, cfgRules); + + const { catchHandler, finallyHandler } = findTryHandlers(tryStmt, cfgRules); + + if (catchHandler) { + processCatchHandler( + catchHandler, + tryBlock, + tryEnd, + finallyHandler, + joinBlock, + S, + cfgRules, + processStatements, + ); + } else if (finallyHandler) { + processFinallyOnly(finallyHandler, tryEnd, joinBlock, S, cfgRules, processStatements); + } else { + if (tryEnd) S.addEdge(tryEnd, joinBlock, 'fallthrough'); + } + + return joinBlock; +} + +export function findTryHandlers( + tryStmt: TreeSitterNode, + cfgRules: AnyRules, +): { catchHandler: TreeSitterNode | null; finallyHandler: TreeSitterNode | null } { + let catchHandler: TreeSitterNode | null = null; + let finallyHandler: TreeSitterNode | null = null; + for (let i = 0; i < tryStmt.namedChildCount; i++) { + const child = nn(tryStmt.namedChild(i)); + if (cfgRules.catchNode && child.type === cfgRules.catchNode) catchHandler = child; + if (cfgRules.finallyNode && child.type === cfgRules.finallyNode) finallyHandler = child; + } + return { catchHandler, finallyHandler }; +} + +export function processCatchHandler( + catchHandler: TreeSitterNode, + tryBlock: CfgBlockInternal, + tryEnd: CfgBlockInternal | null, + finallyHandler: TreeSitterNode | null, + joinBlock: CfgBlockInternal, + S: FuncState, + cfgRules: AnyRules, + processStatements: ProcessStatementsFn, +): void { + const catchBlock = S.makeBlock('catch', catchHandler.startPosition.row + 1, null, 'catch'); + S.addEdge(tryBlock, catchBlock, 'exception'); + + const catchBodyNode = catchHandler.childForFieldName('body'); + let catchStmts: TreeSitterNode[]; + if (catchBodyNode) { + catchStmts = getBodyStatements(catchBodyNode, cfgRules); + } else { + catchStmts = []; + for (let i = 0; i < catchHandler.namedChildCount; i++) { + catchStmts.push(nn(catchHandler.namedChild(i))); + } + } + const catchEnd = processStatements(catchStmts, catchBlock, S, cfgRules); + + if (finallyHandler) { + const finallyBlock = S.makeBlock( + 'finally', + finallyHandler.startPosition.row + 1, + null, + 'finally', + ); + if (tryEnd) S.addEdge(tryEnd, finallyBlock, 'fallthrough'); + if (catchEnd) S.addEdge(catchEnd, finallyBlock, 'fallthrough'); + + const finallyBodyNode = finallyHandler.childForFieldName('body'); + const finallyStmts = finallyBodyNode + ? getBodyStatements(finallyBodyNode, cfgRules) + : getBodyStatements(finallyHandler, cfgRules); + const finallyEnd = processStatements(finallyStmts, finallyBlock, S, cfgRules); + if (finallyEnd) S.addEdge(finallyEnd, joinBlock, 'fallthrough'); + } else { + if (tryEnd) S.addEdge(tryEnd, joinBlock, 'fallthrough'); + if (catchEnd) S.addEdge(catchEnd, joinBlock, 'fallthrough'); + } +} + +export function processFinallyOnly( + finallyHandler: TreeSitterNode, + tryEnd: CfgBlockInternal | null, + joinBlock: CfgBlockInternal, + S: FuncState, + cfgRules: AnyRules, + processStatements: ProcessStatementsFn, +): void { + const finallyBlock = S.makeBlock( + 'finally', + finallyHandler.startPosition.row + 1, + null, + 'finally', + ); + if (tryEnd) S.addEdge(tryEnd, finallyBlock, 'fallthrough'); + + const finallyBodyNode = finallyHandler.childForFieldName('body'); + const finallyStmts = finallyBodyNode + ? getBodyStatements(finallyBodyNode, cfgRules) + : getBodyStatements(finallyHandler, cfgRules); + const finallyEnd = processStatements(finallyStmts, finallyBlock, S, cfgRules); + if (finallyEnd) S.addEdge(finallyEnd, joinBlock, 'fallthrough'); +} diff --git a/src/ast-analysis/visitors/cfg-visitor.ts b/src/ast-analysis/visitors/cfg-visitor.ts index 59db422c..6e4e841c 100644 --- a/src/ast-analysis/visitors/cfg-visitor.ts +++ b/src/ast-analysis/visitors/cfg-visitor.ts @@ -1,189 +1,31 @@ import type { TreeSitterNode, Visitor, VisitorContext } from '../../types.js'; - -// biome-ignore lint/suspicious/noExplicitAny: CFG rules are opaque language-specific objects -type AnyRules = any; - -function nn(node: TreeSitterNode | null, context?: string): TreeSitterNode { - if (node === null) { - throw new Error(`Unexpected null tree-sitter node${context ? ` (${context})` : ''}`); - } - return node; -} - -interface CfgBlockInternal { - index: number; - type: string; - startLine: number | null; - endLine: number | null; - label: string | null; -} - -interface CfgEdgeInternal { - sourceIndex: number; - targetIndex: number; - kind: string; -} - -interface LabelCtx { - headerBlock: CfgBlockInternal | null; - exitBlock: CfgBlockInternal | null; -} - -interface LoopCtx { - headerBlock: CfgBlockInternal; - exitBlock: CfgBlockInternal; -} - -interface FuncState { - blocks: CfgBlockInternal[]; - edges: CfgEdgeInternal[]; - makeBlock( - type: string, - startLine?: number | null, - endLine?: number | null, - label?: string | null, - ): CfgBlockInternal; - addEdge(source: CfgBlockInternal, target: CfgBlockInternal, kind: string): void; - entryBlock: CfgBlockInternal; - exitBlock: CfgBlockInternal; - currentBlock: CfgBlockInternal | null; - loopStack: LoopCtx[]; - labelMap: Map; - cfgStack: FuncState[]; - funcNode: TreeSitterNode | null; -} - -interface CFGResultInternal { - funcNode: TreeSitterNode; - blocks: CfgBlockInternal[]; - edges: CfgEdgeInternal[]; - cyclomatic: number; -} - -function isIfNode(type: string, cfgRules: AnyRules): boolean { - return type === cfgRules.ifNode || cfgRules.ifNodes?.has(type); -} - -function isForNode(type: string, cfgRules: AnyRules): boolean { - return cfgRules.forNodes.has(type); -} - -function isWhileNode(type: string, cfgRules: AnyRules): boolean { - return type === cfgRules.whileNode || cfgRules.whileNodes?.has(type); -} - -function isSwitchNode(type: string, cfgRules: AnyRules): boolean { - return type === cfgRules.switchNode || cfgRules.switchNodes?.has(type); -} - -function isCaseNode(type: string, cfgRules: AnyRules): boolean { - return ( - type === cfgRules.caseNode || type === cfgRules.defaultNode || cfgRules.caseNodes?.has(type) - ); -} - -function isBlockNode(type: string, cfgRules: AnyRules): boolean { - return type === 'statement_list' || type === cfgRules.blockNode || cfgRules.blockNodes?.has(type); -} - -function isControlFlow(type: string, cfgRules: AnyRules): boolean { - return ( - isIfNode(type, cfgRules) || - (cfgRules.unlessNode && type === cfgRules.unlessNode) || - isForNode(type, cfgRules) || - isWhileNode(type, cfgRules) || - (cfgRules.untilNode && type === cfgRules.untilNode) || - (cfgRules.doNode && type === cfgRules.doNode) || - (cfgRules.infiniteLoopNode && type === cfgRules.infiniteLoopNode) || - isSwitchNode(type, cfgRules) || - (cfgRules.tryNode && type === cfgRules.tryNode) || - type === cfgRules.returnNode || - type === cfgRules.throwNode || - type === cfgRules.breakNode || - type === cfgRules.continueNode || - type === cfgRules.labeledNode - ); -} - -function effectiveNode(node: TreeSitterNode, cfgRules: AnyRules): TreeSitterNode { - if (node.type === 'expression_statement' && node.namedChildCount === 1) { - const inner = nn(node.namedChild(0)); - if (isControlFlow(inner.type, cfgRules)) return inner; - } - return node; -} - -function registerLabelCtx( - S: FuncState, - headerBlock: CfgBlockInternal, - exitBlock: CfgBlockInternal, -): void { - for (const [, ctx] of Array.from(S.labelMap)) { - if (!ctx.headerBlock) { - ctx.headerBlock = headerBlock; - ctx.exitBlock = exitBlock; - } - } -} - -function getBodyStatements(bodyNode: TreeSitterNode | null, cfgRules: AnyRules): TreeSitterNode[] { - if (!bodyNode) return []; - if (isBlockNode(bodyNode.type, cfgRules)) { - const stmts: TreeSitterNode[] = []; - for (let i = 0; i < bodyNode.namedChildCount; i++) { - const child = nn(bodyNode.namedChild(i)); - if (child.type === 'statement_list') { - for (let j = 0; j < child.namedChildCount; j++) { - stmts.push(nn(child.namedChild(j))); - } - } else { - stmts.push(child); - } - } - return stmts; - } - return [bodyNode]; -} - -function makeFuncState(): FuncState { - const blocks: CfgBlockInternal[] = []; - const edges: CfgEdgeInternal[] = []; - let nextIndex = 0; - - function makeBlock( - type: string, - startLine: number | null = null, - endLine: number | null = null, - label: string | null = null, - ): CfgBlockInternal { - const block: CfgBlockInternal = { index: nextIndex++, type, startLine, endLine, label }; - blocks.push(block); - return block; - } - - function addEdge(source: CfgBlockInternal, target: CfgBlockInternal, kind: string): void { - edges.push({ sourceIndex: source.index, targetIndex: target.index, kind }); - } - - const entry = makeBlock('entry'); - const exit = makeBlock('exit'); - const firstBody = makeBlock('body'); - addEdge(entry, firstBody, 'fallthrough'); - - return { - blocks, - edges, - makeBlock, - addEdge, - entryBlock: entry, - exitBlock: exit, - currentBlock: firstBody, - loopStack: [], - labelMap: new Map(), - cfgStack: [], - funcNode: null, - }; -} +import { processIf, processSwitch } from './cfg-conditionals.js'; +import { + processDoWhileLoop, + processForLoop, + processInfiniteLoop, + processWhileLoop, +} from './cfg-loops.js'; +import type { + AnyRules, + CFGResultInternal, + CfgBlockInternal, + FuncState, + LabelCtx, +} from './cfg-shared.js'; +import { + effectiveNode, + getBodyStatements, + isBlockNode, + isForNode, + isIfNode, + isSwitchNode, + isWhileNode, + makeFuncState, +} from './cfg-shared.js'; +import { processTryCatch } from './cfg-try-catch.js'; + +export type { CfgBlockInternal } from './cfg-shared.js'; function processStatements( stmts: TreeSitterNode[], @@ -214,25 +56,25 @@ function processStatement( return processLabeled(effNode, currentBlock, S, cfgRules); } if (isIfNode(type, cfgRules) || (cfgRules.unlessNode && type === cfgRules.unlessNode)) { - return processIf(effNode, currentBlock, S, cfgRules); + return processIf(effNode, currentBlock, S, cfgRules, processStatements); } if (isForNode(type, cfgRules)) { - return processForLoop(effNode, currentBlock, S, cfgRules); + return processForLoop(effNode, currentBlock, S, cfgRules, processStatements); } if (isWhileNode(type, cfgRules) || (cfgRules.untilNode && type === cfgRules.untilNode)) { - return processWhileLoop(effNode, currentBlock, S, cfgRules); + return processWhileLoop(effNode, currentBlock, S, cfgRules, processStatements); } if (cfgRules.doNode && type === cfgRules.doNode) { - return processDoWhileLoop(effNode, currentBlock, S, cfgRules); + return processDoWhileLoop(effNode, currentBlock, S, cfgRules, processStatements); } if (cfgRules.infiniteLoopNode && type === cfgRules.infiniteLoopNode) { - return processInfiniteLoop(effNode, currentBlock, S, cfgRules); + return processInfiniteLoop(effNode, currentBlock, S, cfgRules, processStatements); } if (isSwitchNode(type, cfgRules)) { - return processSwitch(effNode, currentBlock, S, cfgRules); + return processSwitch(effNode, currentBlock, S, cfgRules, processStatements); } if (cfgRules.tryNode && type === cfgRules.tryNode) { - return processTryCatch(effNode, currentBlock, S, cfgRules); + return processTryCatch(effNode, currentBlock, S, cfgRules, processStatements); } if (type === cfgRules.returnNode) { currentBlock.endLine = effNode.startPosition.row + 1; @@ -323,469 +165,6 @@ function processContinue( return currentBlock; } -function processIf( - ifStmt: TreeSitterNode, - currentBlock: CfgBlockInternal, - S: FuncState, - cfgRules: AnyRules, -): CfgBlockInternal { - currentBlock.endLine = ifStmt.startPosition.row + 1; - - const condBlock = S.makeBlock( - 'condition', - ifStmt.startPosition.row + 1, - ifStmt.startPosition.row + 1, - 'if', - ); - S.addEdge(currentBlock, condBlock, 'fallthrough'); - - const joinBlock = S.makeBlock('body'); - - const consequentField = cfgRules.ifConsequentField || 'consequence'; - const consequent = ifStmt.childForFieldName(consequentField); - const trueBlock = S.makeBlock('branch_true', null, null, 'then'); - S.addEdge(condBlock, trueBlock, 'branch_true'); - const trueStmts = getBodyStatements(consequent, cfgRules); - const trueEnd = processStatements(trueStmts, trueBlock, S, cfgRules); - if (trueEnd) { - S.addEdge(trueEnd, joinBlock, 'fallthrough'); - } - - if (cfgRules.elifNode) { - processElifSiblings(ifStmt, condBlock, joinBlock, S, cfgRules); - } else { - processAlternative(ifStmt, condBlock, joinBlock, S, cfgRules); - } - - return joinBlock; -} - -function processAlternative( - ifStmt: TreeSitterNode, - condBlock: CfgBlockInternal, - joinBlock: CfgBlockInternal, - S: FuncState, - cfgRules: AnyRules, -): void { - const alternative = ifStmt.childForFieldName('alternative'); - if (!alternative) { - S.addEdge(condBlock, joinBlock, 'branch_false'); - return; - } - - if (cfgRules.elseViaAlternative && alternative.type !== cfgRules.elseClause) { - if (isIfNode(alternative.type, cfgRules)) { - const falseBlock = S.makeBlock('branch_false', null, null, 'else-if'); - S.addEdge(condBlock, falseBlock, 'branch_false'); - const elseIfEnd = processIf(alternative, falseBlock, S, cfgRules); - if (elseIfEnd) S.addEdge(elseIfEnd, joinBlock, 'fallthrough'); - } else { - const falseBlock = S.makeBlock('branch_false', null, null, 'else'); - S.addEdge(condBlock, falseBlock, 'branch_false'); - const falseStmts = getBodyStatements(alternative, cfgRules); - const falseEnd = processStatements(falseStmts, falseBlock, S, cfgRules); - if (falseEnd) S.addEdge(falseEnd, joinBlock, 'fallthrough'); - } - } else if (alternative.type === cfgRules.elseClause) { - const elseChildren: TreeSitterNode[] = []; - for (let i = 0; i < alternative.namedChildCount; i++) { - elseChildren.push(nn(alternative.namedChild(i))); - } - if (elseChildren.length === 1 && isIfNode(elseChildren[0]!.type, cfgRules)) { - const falseBlock = S.makeBlock('branch_false', null, null, 'else-if'); - S.addEdge(condBlock, falseBlock, 'branch_false'); - const elseIfEnd = processIf(elseChildren[0]!, falseBlock, S, cfgRules); - if (elseIfEnd) S.addEdge(elseIfEnd, joinBlock, 'fallthrough'); - } else { - const falseBlock = S.makeBlock('branch_false', null, null, 'else'); - S.addEdge(condBlock, falseBlock, 'branch_false'); - const falseEnd = processStatements(elseChildren, falseBlock, S, cfgRules); - if (falseEnd) S.addEdge(falseEnd, joinBlock, 'fallthrough'); - } - } -} - -function processElifSiblings( - ifStmt: TreeSitterNode, - firstCondBlock: CfgBlockInternal, - joinBlock: CfgBlockInternal, - S: FuncState, - cfgRules: AnyRules, -): void { - let lastCondBlock = firstCondBlock; - let foundElse = false; - - for (let i = 0; i < ifStmt.namedChildCount; i++) { - const child = nn(ifStmt.namedChild(i)); - - if (child.type === cfgRules.elifNode) { - const elifCondBlock = S.makeBlock( - 'condition', - child.startPosition.row + 1, - child.startPosition.row + 1, - 'else-if', - ); - S.addEdge(lastCondBlock, elifCondBlock, 'branch_false'); - - const elifConsequentField = cfgRules.ifConsequentField || 'consequence'; - const elifConsequent = child.childForFieldName(elifConsequentField); - const elifTrueBlock = S.makeBlock('branch_true', null, null, 'then'); - S.addEdge(elifCondBlock, elifTrueBlock, 'branch_true'); - const elifTrueStmts = getBodyStatements(elifConsequent, cfgRules); - const elifTrueEnd = processStatements(elifTrueStmts, elifTrueBlock, S, cfgRules); - if (elifTrueEnd) S.addEdge(elifTrueEnd, joinBlock, 'fallthrough'); - - lastCondBlock = elifCondBlock; - } else if (child.type === cfgRules.elseClause) { - const elseBlock = S.makeBlock('branch_false', null, null, 'else'); - S.addEdge(lastCondBlock, elseBlock, 'branch_false'); - - const elseBody = child.childForFieldName('body'); - let elseStmts: TreeSitterNode[]; - if (elseBody) { - elseStmts = getBodyStatements(elseBody, cfgRules); - } else { - elseStmts = []; - for (let j = 0; j < child.namedChildCount; j++) { - elseStmts.push(nn(child.namedChild(j))); - } - } - const elseEnd = processStatements(elseStmts, elseBlock, S, cfgRules); - if (elseEnd) S.addEdge(elseEnd, joinBlock, 'fallthrough'); - - foundElse = true; - } - } - - if (!foundElse) { - S.addEdge(lastCondBlock, joinBlock, 'branch_false'); - } -} - -function processForLoop( - forStmt: TreeSitterNode, - currentBlock: CfgBlockInternal, - S: FuncState, - cfgRules: AnyRules, -): CfgBlockInternal { - const headerBlock = S.makeBlock( - 'loop_header', - forStmt.startPosition.row + 1, - forStmt.startPosition.row + 1, - 'for', - ); - S.addEdge(currentBlock, headerBlock, 'fallthrough'); - - const loopExitBlock = S.makeBlock('body'); - const loopCtx: LoopCtx = { headerBlock, exitBlock: loopExitBlock }; - S.loopStack.push(loopCtx); - registerLabelCtx(S, headerBlock, loopExitBlock); - - const body = forStmt.childForFieldName('body'); - const bodyBlock = S.makeBlock('loop_body'); - S.addEdge(headerBlock, bodyBlock, 'branch_true'); - - const bodyStmts = getBodyStatements(body, cfgRules); - const bodyEnd = processStatements(bodyStmts, bodyBlock, S, cfgRules); - if (bodyEnd) S.addEdge(bodyEnd, headerBlock, 'loop_back'); - - S.addEdge(headerBlock, loopExitBlock, 'loop_exit'); - S.loopStack.pop(); - return loopExitBlock; -} - -function processWhileLoop( - whileStmt: TreeSitterNode, - currentBlock: CfgBlockInternal, - S: FuncState, - cfgRules: AnyRules, -): CfgBlockInternal { - const headerBlock = S.makeBlock( - 'loop_header', - whileStmt.startPosition.row + 1, - whileStmt.startPosition.row + 1, - 'while', - ); - S.addEdge(currentBlock, headerBlock, 'fallthrough'); - - const loopExitBlock = S.makeBlock('body'); - const loopCtx: LoopCtx = { headerBlock, exitBlock: loopExitBlock }; - S.loopStack.push(loopCtx); - registerLabelCtx(S, headerBlock, loopExitBlock); - - const body = whileStmt.childForFieldName('body'); - const bodyBlock = S.makeBlock('loop_body'); - S.addEdge(headerBlock, bodyBlock, 'branch_true'); - - const bodyStmts = getBodyStatements(body, cfgRules); - const bodyEnd = processStatements(bodyStmts, bodyBlock, S, cfgRules); - if (bodyEnd) S.addEdge(bodyEnd, headerBlock, 'loop_back'); - - S.addEdge(headerBlock, loopExitBlock, 'loop_exit'); - S.loopStack.pop(); - return loopExitBlock; -} - -function processDoWhileLoop( - doStmt: TreeSitterNode, - currentBlock: CfgBlockInternal, - S: FuncState, - cfgRules: AnyRules, -): CfgBlockInternal { - const bodyBlock = S.makeBlock('loop_body', doStmt.startPosition.row + 1, null, 'do'); - S.addEdge(currentBlock, bodyBlock, 'fallthrough'); - - const condBlock = S.makeBlock('loop_header', null, null, 'do-while'); - const loopExitBlock = S.makeBlock('body'); - - const loopCtx: LoopCtx = { headerBlock: condBlock, exitBlock: loopExitBlock }; - S.loopStack.push(loopCtx); - registerLabelCtx(S, condBlock, loopExitBlock); - - const body = doStmt.childForFieldName('body'); - const bodyStmts = getBodyStatements(body, cfgRules); - const bodyEnd = processStatements(bodyStmts, bodyBlock, S, cfgRules); - if (bodyEnd) S.addEdge(bodyEnd, condBlock, 'fallthrough'); - - S.addEdge(condBlock, bodyBlock, 'loop_back'); - S.addEdge(condBlock, loopExitBlock, 'loop_exit'); - - S.loopStack.pop(); - return loopExitBlock; -} - -function processInfiniteLoop( - loopStmt: TreeSitterNode, - currentBlock: CfgBlockInternal, - S: FuncState, - cfgRules: AnyRules, -): CfgBlockInternal { - const headerBlock = S.makeBlock( - 'loop_header', - loopStmt.startPosition.row + 1, - loopStmt.startPosition.row + 1, - 'loop', - ); - S.addEdge(currentBlock, headerBlock, 'fallthrough'); - - const loopExitBlock = S.makeBlock('body'); - const loopCtx: LoopCtx = { headerBlock, exitBlock: loopExitBlock }; - S.loopStack.push(loopCtx); - registerLabelCtx(S, headerBlock, loopExitBlock); - - const body = loopStmt.childForFieldName('body'); - const bodyBlock = S.makeBlock('loop_body'); - S.addEdge(headerBlock, bodyBlock, 'branch_true'); - - const bodyStmts = getBodyStatements(body, cfgRules); - const bodyEnd = processStatements(bodyStmts, bodyBlock, S, cfgRules); - if (bodyEnd) S.addEdge(bodyEnd, headerBlock, 'loop_back'); - - S.loopStack.pop(); - return loopExitBlock; -} - -function processSwitch( - switchStmt: TreeSitterNode, - currentBlock: CfgBlockInternal, - S: FuncState, - cfgRules: AnyRules, -): CfgBlockInternal { - currentBlock.endLine = switchStmt.startPosition.row + 1; - - const switchHeader = S.makeBlock( - 'condition', - switchStmt.startPosition.row + 1, - switchStmt.startPosition.row + 1, - 'switch', - ); - S.addEdge(currentBlock, switchHeader, 'fallthrough'); - - const joinBlock = S.makeBlock('body'); - const switchCtx: LoopCtx = { headerBlock: switchHeader, exitBlock: joinBlock }; - S.loopStack.push(switchCtx); - - const switchBody = switchStmt.childForFieldName('body'); - const container = switchBody || switchStmt; - - let hasDefault = false; - for (let i = 0; i < container.namedChildCount; i++) { - const caseClause = nn(container.namedChild(i)); - - const isDefault = caseClause.type === cfgRules.defaultNode; - const isCase = isDefault || isCaseNode(caseClause.type, cfgRules); - if (!isCase) continue; - - const caseLabel = isDefault ? 'default' : 'case'; - const caseBlock = S.makeBlock('case', caseClause.startPosition.row + 1, null, caseLabel); - S.addEdge(switchHeader, caseBlock, isDefault ? 'branch_false' : 'branch_true'); - if (isDefault) hasDefault = true; - - const caseStmts = extractCaseBody(caseClause, cfgRules); - const caseEnd = processStatements(caseStmts, caseBlock, S, cfgRules); - if (caseEnd) S.addEdge(caseEnd, joinBlock, 'fallthrough'); - } - - if (!hasDefault) { - S.addEdge(switchHeader, joinBlock, 'branch_false'); - } - - S.loopStack.pop(); - return joinBlock; -} - -function extractCaseBody(caseClause: TreeSitterNode, cfgRules: AnyRules): TreeSitterNode[] { - const caseBodyNode = - caseClause.childForFieldName('body') || caseClause.childForFieldName('consequence'); - if (caseBodyNode) { - return getBodyStatements(caseBodyNode, cfgRules); - } - - const stmts: TreeSitterNode[] = []; - const valueNode = caseClause.childForFieldName('value'); - const patternNode = caseClause.childForFieldName('pattern'); - for (let j = 0; j < caseClause.namedChildCount; j++) { - const child = nn(caseClause.namedChild(j)); - if (child !== valueNode && child !== patternNode && child.type !== 'switch_label') { - if (child.type === 'statement_list') { - for (let k = 0; k < child.namedChildCount; k++) { - stmts.push(nn(child.namedChild(k))); - } - } else { - stmts.push(child); - } - } - } - return stmts; -} - -function processTryCatch( - tryStmt: TreeSitterNode, - currentBlock: CfgBlockInternal, - S: FuncState, - cfgRules: AnyRules, -): CfgBlockInternal { - currentBlock.endLine = tryStmt.startPosition.row + 1; - - const joinBlock = S.makeBlock('body'); - - const tryBody = tryStmt.childForFieldName('body'); - let tryBodyStart: number; - let tryStmts: TreeSitterNode[]; - if (tryBody) { - tryBodyStart = tryBody.startPosition.row + 1; - tryStmts = getBodyStatements(tryBody, cfgRules); - } else { - tryBodyStart = tryStmt.startPosition.row + 1; - tryStmts = []; - for (let i = 0; i < tryStmt.namedChildCount; i++) { - const child = nn(tryStmt.namedChild(i)); - if (cfgRules.catchNode && child.type === cfgRules.catchNode) continue; - if (cfgRules.finallyNode && child.type === cfgRules.finallyNode) continue; - tryStmts.push(child); - } - } - - const tryBlock = S.makeBlock('body', tryBodyStart, null, 'try'); - S.addEdge(currentBlock, tryBlock, 'fallthrough'); - const tryEnd = processStatements(tryStmts, tryBlock, S, cfgRules); - - const { catchHandler, finallyHandler } = findTryHandlers(tryStmt, cfgRules); - - if (catchHandler) { - processCatchHandler(catchHandler, tryBlock, tryEnd, finallyHandler, joinBlock, S, cfgRules); - } else if (finallyHandler) { - processFinallyOnly(finallyHandler, tryEnd, joinBlock, S, cfgRules); - } else { - if (tryEnd) S.addEdge(tryEnd, joinBlock, 'fallthrough'); - } - - return joinBlock; -} - -function findTryHandlers( - tryStmt: TreeSitterNode, - cfgRules: AnyRules, -): { catchHandler: TreeSitterNode | null; finallyHandler: TreeSitterNode | null } { - let catchHandler: TreeSitterNode | null = null; - let finallyHandler: TreeSitterNode | null = null; - for (let i = 0; i < tryStmt.namedChildCount; i++) { - const child = nn(tryStmt.namedChild(i)); - if (cfgRules.catchNode && child.type === cfgRules.catchNode) catchHandler = child; - if (cfgRules.finallyNode && child.type === cfgRules.finallyNode) finallyHandler = child; - } - return { catchHandler, finallyHandler }; -} - -function processCatchHandler( - catchHandler: TreeSitterNode, - tryBlock: CfgBlockInternal, - tryEnd: CfgBlockInternal | null, - finallyHandler: TreeSitterNode | null, - joinBlock: CfgBlockInternal, - S: FuncState, - cfgRules: AnyRules, -): void { - const catchBlock = S.makeBlock('catch', catchHandler.startPosition.row + 1, null, 'catch'); - S.addEdge(tryBlock, catchBlock, 'exception'); - - const catchBodyNode = catchHandler.childForFieldName('body'); - let catchStmts: TreeSitterNode[]; - if (catchBodyNode) { - catchStmts = getBodyStatements(catchBodyNode, cfgRules); - } else { - catchStmts = []; - for (let i = 0; i < catchHandler.namedChildCount; i++) { - catchStmts.push(nn(catchHandler.namedChild(i))); - } - } - const catchEnd = processStatements(catchStmts, catchBlock, S, cfgRules); - - if (finallyHandler) { - const finallyBlock = S.makeBlock( - 'finally', - finallyHandler.startPosition.row + 1, - null, - 'finally', - ); - if (tryEnd) S.addEdge(tryEnd, finallyBlock, 'fallthrough'); - if (catchEnd) S.addEdge(catchEnd, finallyBlock, 'fallthrough'); - - const finallyBodyNode = finallyHandler.childForFieldName('body'); - const finallyStmts = finallyBodyNode - ? getBodyStatements(finallyBodyNode, cfgRules) - : getBodyStatements(finallyHandler, cfgRules); - const finallyEnd = processStatements(finallyStmts, finallyBlock, S, cfgRules); - if (finallyEnd) S.addEdge(finallyEnd, joinBlock, 'fallthrough'); - } else { - if (tryEnd) S.addEdge(tryEnd, joinBlock, 'fallthrough'); - if (catchEnd) S.addEdge(catchEnd, joinBlock, 'fallthrough'); - } -} - -function processFinallyOnly( - finallyHandler: TreeSitterNode, - tryEnd: CfgBlockInternal | null, - joinBlock: CfgBlockInternal, - S: FuncState, - cfgRules: AnyRules, -): void { - const finallyBlock = S.makeBlock( - 'finally', - finallyHandler.startPosition.row + 1, - null, - 'finally', - ); - if (tryEnd) S.addEdge(tryEnd, finallyBlock, 'fallthrough'); - - const finallyBodyNode = finallyHandler.childForFieldName('body'); - const finallyStmts = finallyBodyNode - ? getBodyStatements(finallyBodyNode, cfgRules) - : getBodyStatements(finallyHandler, cfgRules); - const finallyEnd = processStatements(finallyStmts, finallyBlock, S, cfgRules); - if (finallyEnd) S.addEdge(finallyEnd, joinBlock, 'fallthrough'); -} - function processFunctionBody(funcNode: TreeSitterNode, S: FuncState, cfgRules: AnyRules): void { const body = funcNode.childForFieldName('body'); if (!body) { From 4ceed5d226a886f8ba1ed99dca3d13f615f60609 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Wed, 25 Mar 2026 23:03:02 -0600 Subject: [PATCH 4/9] refactor: extract MAX_WALK_DEPTH constant to extractors/helpers.ts Impact: 9 functions changed, 123 affected --- src/extractors/csharp.ts | 4 ++-- src/extractors/go.ts | 4 ++-- src/extractors/helpers.ts | 6 ++++++ src/extractors/javascript.ts | 4 ++-- src/extractors/php.ts | 4 ++-- src/extractors/python.ts | 4 ++-- src/extractors/rust.ts | 4 ++-- 7 files changed, 18 insertions(+), 12 deletions(-) diff --git a/src/extractors/csharp.ts b/src/extractors/csharp.ts index 8308abbb..3a79bb28 100644 --- a/src/extractors/csharp.ts +++ b/src/extractors/csharp.ts @@ -6,7 +6,7 @@ import type { TreeSitterNode, TreeSitterTree, } from '../types.js'; -import { extractModifierVisibility, findChild, nodeEndLine } from './helpers.js'; +import { extractModifierVisibility, findChild, MAX_WALK_DEPTH, nodeEndLine } from './helpers.js'; /** * Extract symbols from C# files. @@ -333,7 +333,7 @@ function extractCSharpTypeMapDepth( ctx: ExtractorOutput, depth: number, ): void { - if (depth >= 200) return; + if (depth >= MAX_WALK_DEPTH) return; // local_declaration_statement → variable_declaration → type + variable_declarator(s) if (node.type === 'variable_declaration') { diff --git a/src/extractors/go.ts b/src/extractors/go.ts index b31d50c3..3e832b37 100644 --- a/src/extractors/go.ts +++ b/src/extractors/go.ts @@ -6,7 +6,7 @@ import type { TreeSitterTree, TypeMapEntry, } from '../types.js'; -import { findChild, goVisibility, nodeEndLine } from './helpers.js'; +import { findChild, goVisibility, MAX_WALK_DEPTH, nodeEndLine } from './helpers.js'; /** * Extract symbols from Go files. @@ -233,7 +233,7 @@ function setIfHigher( } function extractGoTypeMapDepth(node: TreeSitterNode, ctx: ExtractorOutput, depth: number): void { - if (depth >= 200) return; + if (depth >= MAX_WALK_DEPTH) return; // var x MyType = ... or var x, y MyType → var_declaration > var_spec (confidence 0.9) if (node.type === 'var_spec') { diff --git a/src/extractors/helpers.ts b/src/extractors/helpers.ts index be710d18..56b05543 100644 --- a/src/extractors/helpers.ts +++ b/src/extractors/helpers.ts @@ -1,5 +1,11 @@ import type { TreeSitterNode } from '../types.js'; +/** + * Maximum recursion depth for tree-sitter AST walkers. + * Shared across all language extractors to prevent stack overflow on deeply nested ASTs. + */ +export const MAX_WALK_DEPTH = 200; + export function nodeEndLine(node: TreeSitterNode): number { return node.endPosition.row + 1; } diff --git a/src/extractors/javascript.ts b/src/extractors/javascript.ts index a05a58d6..e6fa4fe1 100644 --- a/src/extractors/javascript.ts +++ b/src/extractors/javascript.ts @@ -12,7 +12,7 @@ import type { TreeSitterTree, TypeMapEntry, } from '../types.js'; -import { findChild, nodeEndLine } from './helpers.js'; +import { findChild, MAX_WALK_DEPTH, nodeEndLine } from './helpers.js'; /** Built-in globals that start with uppercase but are not user-defined types. */ const BUILTIN_GLOBALS: Set = new Set([ @@ -929,7 +929,7 @@ function extractTypeMapWalk(rootNode: TreeSitterNode, typeMap: Map= 200) return; + if (depth >= MAX_WALK_DEPTH) return; const t = node.type; if (t === 'variable_declarator') { const nameN = node.childForFieldName('name'); diff --git a/src/extractors/php.ts b/src/extractors/php.ts index 1219665b..653971ee 100644 --- a/src/extractors/php.ts +++ b/src/extractors/php.ts @@ -5,7 +5,7 @@ import type { TreeSitterNode, TreeSitterTree, } from '../types.js'; -import { extractModifierVisibility, findChild, nodeEndLine } from './helpers.js'; +import { extractModifierVisibility, findChild, MAX_WALK_DEPTH, nodeEndLine } from './helpers.js'; function extractPhpParameters(fnNode: TreeSitterNode): SubDeclaration[] { const params: SubDeclaration[] = []; @@ -340,7 +340,7 @@ function extractPhpTypeMap(node: TreeSitterNode, ctx: ExtractorOutput): void { } function extractPhpTypeMapDepth(node: TreeSitterNode, ctx: ExtractorOutput, depth: number): void { - if (depth >= 200) return; + if (depth >= MAX_WALK_DEPTH) return; // Function/method parameters with type hints if ( diff --git a/src/extractors/python.ts b/src/extractors/python.ts index 0443237d..b1d8804a 100644 --- a/src/extractors/python.ts +++ b/src/extractors/python.ts @@ -6,7 +6,7 @@ import type { TreeSitterTree, TypeMapEntry, } from '../types.js'; -import { findChild, nodeEndLine, pythonVisibility } from './helpers.js'; +import { findChild, MAX_WALK_DEPTH, nodeEndLine, pythonVisibility } from './helpers.js'; /** Built-in globals that start with uppercase but are not user-defined types. */ const BUILTIN_GLOBALS_PY: Set = new Set([ @@ -365,7 +365,7 @@ function extractPythonTypeMapDepth( ctx: ExtractorOutput, depth: number, ): void { - if (depth >= 200) return; + if (depth >= MAX_WALK_DEPTH) return; // typed_parameter: identifier : type (confidence 0.9) if (node.type === 'typed_parameter') { diff --git a/src/extractors/rust.ts b/src/extractors/rust.ts index b7e15764..e74f2e78 100644 --- a/src/extractors/rust.ts +++ b/src/extractors/rust.ts @@ -5,7 +5,7 @@ import type { TreeSitterNode, TreeSitterTree, } from '../types.js'; -import { findChild, nodeEndLine, rustVisibility } from './helpers.js'; +import { findChild, MAX_WALK_DEPTH, nodeEndLine, rustVisibility } from './helpers.js'; /** * Extract symbols from Rust files. @@ -274,7 +274,7 @@ function extractRustTypeMap(node: TreeSitterNode, ctx: ExtractorOutput): void { } function extractRustTypeMapDepth(node: TreeSitterNode, ctx: ExtractorOutput, depth: number): void { - if (depth >= 200) return; + if (depth >= MAX_WALK_DEPTH) return; // let x: MyType = ... if (node.type === 'let_declaration') { From 23bf54629efe6da34999e6e4769b0ac645598961 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Wed, 25 Mar 2026 23:19:12 -0600 Subject: [PATCH 5/9] refactor: address SLOC warnings in domain and features layers Impact: 2 functions changed, 5 affected --- src/features/complexity-query.ts | 371 +++++++++++++++++++++++++++++ src/features/complexity.ts | 367 +---------------------------- src/features/structure-query.ts | 385 +++++++++++++++++++++++++++++++ src/features/structure.ts | 380 +----------------------------- 4 files changed, 767 insertions(+), 736 deletions(-) create mode 100644 src/features/complexity-query.ts create mode 100644 src/features/structure-query.ts diff --git a/src/features/complexity-query.ts b/src/features/complexity-query.ts new file mode 100644 index 00000000..58de031d --- /dev/null +++ b/src/features/complexity-query.ts @@ -0,0 +1,371 @@ +/** + * Complexity query functions — read-only DB queries for complexity metrics. + * + * Split from complexity.ts to separate query-time concerns (DB reads, filtering, + * pagination) from compute-time concerns (AST traversal, metric algorithms). + */ + +import { openReadonlyOrFail } from '../db/index.js'; +import { buildFileConditionSQL } from '../db/query-builder.js'; +import { loadConfig } from '../infrastructure/config.js'; +import { debug } from '../infrastructure/logger.js'; +import { isTestFile } from '../infrastructure/test-filter.js'; +import { paginateResult } from '../shared/paginate.js'; +import type { CodegraphConfig } from '../types.js'; + +// ─── Query-Time Functions ───────────────────────────────────────────────── + +interface ComplexityRow { + name: string; + kind: string; + file: string; + line: number; + end_line: number | null; + cognitive: number; + cyclomatic: number; + max_nesting: number; + loc: number; + sloc: number; + maintainability_index: number; + halstead_volume: number; + halstead_difficulty: number; + halstead_effort: number; + halstead_bugs: number; +} + +export function complexityData( + customDbPath?: string, + opts: { + target?: string; + limit?: number; + sort?: string; + aboveThreshold?: boolean; + file?: string; + kind?: string; + noTests?: boolean; + config?: CodegraphConfig; + offset?: number; + } = {}, +): Record { + const db = openReadonlyOrFail(customDbPath); + try { + const sort = opts.sort || 'cognitive'; + const noTests = opts.noTests || false; + const aboveThreshold = opts.aboveThreshold || false; + const target = opts.target || null; + const fileFilter = opts.file || null; + const kindFilter = opts.kind || null; + + // Load thresholds from config + const config = opts.config || loadConfig(process.cwd()); + // biome-ignore lint/suspicious/noExplicitAny: thresholds come from config with dynamic keys + const thresholds: any = config.manifesto?.rules || { + cognitive: { warn: 15, fail: null }, + cyclomatic: { warn: 10, fail: null }, + maxNesting: { warn: 4, fail: null }, + maintainabilityIndex: { warn: 20, fail: null }, + }; + + // Build query + let where = "WHERE n.kind IN ('function','method')"; + const params: unknown[] = []; + + if (noTests) { + where += ` AND n.file NOT LIKE '%.test.%' + AND n.file NOT LIKE '%.spec.%' + AND n.file NOT LIKE '%__test__%' + AND n.file NOT LIKE '%__tests__%' + AND n.file NOT LIKE '%.stories.%'`; + } + if (target) { + where += ' AND n.name LIKE ?'; + params.push(`%${target}%`); + } + { + const fc = buildFileConditionSQL(fileFilter as string, 'n.file'); + where += fc.sql; + params.push(...fc.params); + } + if (kindFilter) { + where += ' AND n.kind = ?'; + params.push(kindFilter); + } + + const isValidThreshold = (v: unknown): v is number => + typeof v === 'number' && Number.isFinite(v); + + let having = ''; + if (aboveThreshold) { + const conditions: string[] = []; + if (isValidThreshold(thresholds.cognitive?.warn)) { + conditions.push(`fc.cognitive >= ${thresholds.cognitive.warn}`); + } + if (isValidThreshold(thresholds.cyclomatic?.warn)) { + conditions.push(`fc.cyclomatic >= ${thresholds.cyclomatic.warn}`); + } + if (isValidThreshold(thresholds.maxNesting?.warn)) { + conditions.push(`fc.max_nesting >= ${thresholds.maxNesting.warn}`); + } + if (isValidThreshold(thresholds.maintainabilityIndex?.warn)) { + conditions.push( + `fc.maintainability_index > 0 AND fc.maintainability_index <= ${thresholds.maintainabilityIndex.warn}`, + ); + } + if (conditions.length > 0) { + having = `AND (${conditions.join(' OR ')})`; + } + } + + const orderMap: Record = { + cognitive: 'fc.cognitive DESC', + cyclomatic: 'fc.cyclomatic DESC', + nesting: 'fc.max_nesting DESC', + mi: 'fc.maintainability_index ASC', + volume: 'fc.halstead_volume DESC', + effort: 'fc.halstead_effort DESC', + bugs: 'fc.halstead_bugs DESC', + loc: 'fc.loc DESC', + }; + const orderBy = orderMap[sort] || 'fc.cognitive DESC'; + + let rows: ComplexityRow[]; + try { + rows = db + .prepare( + `SELECT n.name, n.kind, n.file, n.line, n.end_line, + fc.cognitive, fc.cyclomatic, fc.max_nesting, + fc.loc, fc.sloc, fc.maintainability_index, + fc.halstead_volume, fc.halstead_difficulty, fc.halstead_effort, fc.halstead_bugs + FROM function_complexity fc + JOIN nodes n ON fc.node_id = n.id + ${where} ${having} + ORDER BY ${orderBy}`, + ) + .all(...params); + } catch (e: unknown) { + debug(`complexity query failed (table may not exist): ${(e as Error).message}`); + // Check if graph has nodes even though complexity table is missing/empty + let hasGraph = false; + try { + hasGraph = (db.prepare<{ c: number }>('SELECT COUNT(*) as c FROM nodes').get()?.c ?? 0) > 0; + } catch (e2: unknown) { + debug(`nodes table check failed: ${(e2 as Error).message}`); + } + return { functions: [], summary: null, thresholds, hasGraph }; + } + + // Post-filter test files if needed (belt-and-suspenders for isTestFile) + const filtered = noTests ? rows.filter((r) => !isTestFile(r.file)) : rows; + + const functions = filtered.map((r) => { + const exceeds: string[] = []; + if ( + isValidThreshold(thresholds.cognitive?.warn) && + r.cognitive >= (thresholds.cognitive?.warn ?? 0) + ) + exceeds.push('cognitive'); + if ( + isValidThreshold(thresholds.cyclomatic?.warn) && + r.cyclomatic >= (thresholds.cyclomatic?.warn ?? 0) + ) + exceeds.push('cyclomatic'); + if ( + isValidThreshold(thresholds.maxNesting?.warn) && + r.max_nesting >= (thresholds.maxNesting?.warn ?? 0) + ) + exceeds.push('maxNesting'); + if ( + isValidThreshold(thresholds.maintainabilityIndex?.warn) && + r.maintainability_index > 0 && + r.maintainability_index <= (thresholds.maintainabilityIndex?.warn ?? 0) + ) + exceeds.push('maintainabilityIndex'); + + return { + name: r.name, + kind: r.kind, + file: r.file, + line: r.line, + endLine: r.end_line || null, + cognitive: r.cognitive, + cyclomatic: r.cyclomatic, + maxNesting: r.max_nesting, + loc: r.loc || 0, + sloc: r.sloc || 0, + maintainabilityIndex: r.maintainability_index || 0, + halstead: { + volume: r.halstead_volume || 0, + difficulty: r.halstead_difficulty || 0, + effort: r.halstead_effort || 0, + bugs: r.halstead_bugs || 0, + }, + exceeds: exceeds.length > 0 ? exceeds : undefined, + }; + }); + + // Summary stats + let summary: Record | null = null; + try { + const allRows = db + .prepare<{ + cognitive: number; + cyclomatic: number; + max_nesting: number; + maintainability_index: number; + }>( + `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') + ${noTests ? `AND n.file NOT LIKE '%.test.%' AND n.file NOT LIKE '%.spec.%' AND n.file NOT LIKE '%__test__%' AND n.file NOT LIKE '%__tests__%' AND n.file NOT LIKE '%.stories.%'` : ''}`, + ) + .all(); + + if (allRows.length > 0) { + const miValues = allRows.map((r) => r.maintainability_index || 0); + summary = { + analyzed: allRows.length, + avgCognitive: +(allRows.reduce((s, r) => s + r.cognitive, 0) / allRows.length).toFixed(1), + avgCyclomatic: +(allRows.reduce((s, r) => s + r.cyclomatic, 0) / allRows.length).toFixed( + 1, + ), + maxCognitive: Math.max(...allRows.map((r) => r.cognitive)), + maxCyclomatic: Math.max(...allRows.map((r) => r.cyclomatic)), + avgMI: +(miValues.reduce((s, v) => s + v, 0) / miValues.length).toFixed(1), + minMI: +Math.min(...miValues).toFixed(1), + aboveWarn: allRows.filter( + (r) => + (isValidThreshold(thresholds.cognitive?.warn) && + r.cognitive >= (thresholds.cognitive?.warn ?? 0)) || + (isValidThreshold(thresholds.cyclomatic?.warn) && + r.cyclomatic >= (thresholds.cyclomatic?.warn ?? 0)) || + (isValidThreshold(thresholds.maxNesting?.warn) && + r.max_nesting >= (thresholds.maxNesting?.warn ?? 0)) || + (isValidThreshold(thresholds.maintainabilityIndex?.warn) && + r.maintainability_index > 0 && + r.maintainability_index <= (thresholds.maintainabilityIndex?.warn ?? 0)), + ).length, + }; + } + } catch (e: unknown) { + debug(`complexity summary query failed: ${(e as Error).message}`); + } + + // When summary is null (no complexity rows), check if graph has nodes + let hasGraph = false; + if (summary === null) { + try { + hasGraph = (db.prepare<{ c: number }>('SELECT COUNT(*) as c FROM nodes').get()?.c ?? 0) > 0; + } catch (e: unknown) { + debug(`nodes table check failed: ${(e as Error).message}`); + } + } + + const base = { functions, summary, thresholds, hasGraph }; + return paginateResult(base, 'functions', { limit: opts.limit, offset: opts.offset }); + } finally { + db.close(); + } +} + +interface IterComplexityRow { + name: string; + kind: string; + file: string; + line: number; + end_line: number | null; + cognitive: number; + cyclomatic: number; + max_nesting: number; + loc: number; + sloc: number; +} + +export function* iterComplexity( + customDbPath?: string, + opts: { + noTests?: boolean; + file?: string; + target?: string; + kind?: string; + sort?: string; + } = {}, +): Generator<{ + name: string; + kind: string; + file: string; + line: number; + endLine: number | null; + cognitive: number; + cyclomatic: number; + maxNesting: number; + loc: number; + sloc: number; +}> { + const db = openReadonlyOrFail(customDbPath); + try { + const noTests = opts.noTests || false; + const sort = opts.sort || 'cognitive'; + + let where = "WHERE n.kind IN ('function','method')"; + const params: unknown[] = []; + + if (noTests) { + where += ` AND n.file NOT LIKE '%.test.%' + AND n.file NOT LIKE '%.spec.%' + AND n.file NOT LIKE '%__test__%' + AND n.file NOT LIKE '%__tests__%' + AND n.file NOT LIKE '%.stories.%'`; + } + if (opts.target) { + where += ' AND n.name LIKE ?'; + params.push(`%${opts.target}%`); + } + { + const fc = buildFileConditionSQL(opts.file as string, 'n.file'); + where += fc.sql; + params.push(...fc.params); + } + if (opts.kind) { + where += ' AND n.kind = ?'; + params.push(opts.kind); + } + + const orderMap: Record = { + cognitive: 'fc.cognitive DESC', + cyclomatic: 'fc.cyclomatic DESC', + nesting: 'fc.max_nesting DESC', + mi: 'fc.maintainability_index ASC', + volume: 'fc.halstead_volume DESC', + effort: 'fc.halstead_effort DESC', + bugs: 'fc.halstead_bugs DESC', + loc: 'fc.loc DESC', + }; + const orderBy = orderMap[sort] || 'fc.cognitive DESC'; + + const stmt = db.prepare( + `SELECT n.name, n.kind, n.file, n.line, n.end_line, + fc.cognitive, fc.cyclomatic, fc.max_nesting, fc.loc, fc.sloc + FROM function_complexity fc + JOIN nodes n ON fc.node_id = n.id + ${where} + ORDER BY ${orderBy}`, + ); + for (const r of stmt.iterate(...params)) { + if (noTests && isTestFile(r.file)) continue; + yield { + name: r.name, + kind: r.kind, + file: r.file, + line: r.line, + endLine: r.end_line || null, + cognitive: r.cognitive, + cyclomatic: r.cyclomatic, + maxNesting: r.max_nesting, + loc: r.loc || 0, + sloc: r.sloc || 0, + }; + } + } finally { + db.close(); + } +} diff --git a/src/features/complexity.ts b/src/features/complexity.ts index 559238d9..b83acfaf 100644 --- a/src/features/complexity.ts +++ b/src/features/complexity.ts @@ -12,15 +12,10 @@ import { } from '../ast-analysis/shared.js'; import { walkWithVisitors } from '../ast-analysis/visitor.js'; import { createComplexityVisitor } from '../ast-analysis/visitors/complexity-visitor.js'; -import { getFunctionNodeId, openReadonlyOrFail } from '../db/index.js'; -import { buildFileConditionSQL } from '../db/query-builder.js'; -import { loadConfig } from '../infrastructure/config.js'; +import { getFunctionNodeId } from '../db/index.js'; import { debug, info } from '../infrastructure/logger.js'; -import { isTestFile } from '../infrastructure/test-filter.js'; -import { paginateResult } from '../shared/paginate.js'; import type { BetterSqlite3Database, - CodegraphConfig, ComplexityRules, HalsteadDerivedMetrics, HalsteadRules, @@ -556,359 +551,7 @@ export async function buildComplexityMetrics( } } -// ─── Query-Time Functions ───────────────────────────────────────────────── - -interface ComplexityRow { - name: string; - kind: string; - file: string; - line: number; - end_line: number | null; - cognitive: number; - cyclomatic: number; - max_nesting: number; - loc: number; - sloc: number; - maintainability_index: number; - halstead_volume: number; - halstead_difficulty: number; - halstead_effort: number; - halstead_bugs: number; -} - -export function complexityData( - customDbPath?: string, - opts: { - target?: string; - limit?: number; - sort?: string; - aboveThreshold?: boolean; - file?: string; - kind?: string; - noTests?: boolean; - config?: CodegraphConfig; - offset?: number; - } = {}, -): Record { - const db = openReadonlyOrFail(customDbPath); - try { - const sort = opts.sort || 'cognitive'; - const noTests = opts.noTests || false; - const aboveThreshold = opts.aboveThreshold || false; - const target = opts.target || null; - const fileFilter = opts.file || null; - const kindFilter = opts.kind || null; - - // Load thresholds from config - const config = opts.config || loadConfig(process.cwd()); - // biome-ignore lint/suspicious/noExplicitAny: thresholds come from config with dynamic keys - const thresholds: any = config.manifesto?.rules || { - cognitive: { warn: 15, fail: null }, - cyclomatic: { warn: 10, fail: null }, - maxNesting: { warn: 4, fail: null }, - maintainabilityIndex: { warn: 20, fail: null }, - }; - - // Build query - let where = "WHERE n.kind IN ('function','method')"; - const params: unknown[] = []; - - if (noTests) { - where += ` AND n.file NOT LIKE '%.test.%' - AND n.file NOT LIKE '%.spec.%' - AND n.file NOT LIKE '%__test__%' - AND n.file NOT LIKE '%__tests__%' - AND n.file NOT LIKE '%.stories.%'`; - } - if (target) { - where += ' AND n.name LIKE ?'; - params.push(`%${target}%`); - } - { - const fc = buildFileConditionSQL(fileFilter as string, 'n.file'); - where += fc.sql; - params.push(...fc.params); - } - if (kindFilter) { - where += ' AND n.kind = ?'; - params.push(kindFilter); - } - - const isValidThreshold = (v: unknown): v is number => - typeof v === 'number' && Number.isFinite(v); - - let having = ''; - if (aboveThreshold) { - const conditions: string[] = []; - if (isValidThreshold(thresholds.cognitive?.warn)) { - conditions.push(`fc.cognitive >= ${thresholds.cognitive.warn}`); - } - if (isValidThreshold(thresholds.cyclomatic?.warn)) { - conditions.push(`fc.cyclomatic >= ${thresholds.cyclomatic.warn}`); - } - if (isValidThreshold(thresholds.maxNesting?.warn)) { - conditions.push(`fc.max_nesting >= ${thresholds.maxNesting.warn}`); - } - if (isValidThreshold(thresholds.maintainabilityIndex?.warn)) { - conditions.push( - `fc.maintainability_index > 0 AND fc.maintainability_index <= ${thresholds.maintainabilityIndex.warn}`, - ); - } - if (conditions.length > 0) { - having = `AND (${conditions.join(' OR ')})`; - } - } - - const orderMap: Record = { - cognitive: 'fc.cognitive DESC', - cyclomatic: 'fc.cyclomatic DESC', - nesting: 'fc.max_nesting DESC', - mi: 'fc.maintainability_index ASC', - volume: 'fc.halstead_volume DESC', - effort: 'fc.halstead_effort DESC', - bugs: 'fc.halstead_bugs DESC', - loc: 'fc.loc DESC', - }; - const orderBy = orderMap[sort] || 'fc.cognitive DESC'; - - let rows: ComplexityRow[]; - try { - rows = db - .prepare( - `SELECT n.name, n.kind, n.file, n.line, n.end_line, - fc.cognitive, fc.cyclomatic, fc.max_nesting, - fc.loc, fc.sloc, fc.maintainability_index, - fc.halstead_volume, fc.halstead_difficulty, fc.halstead_effort, fc.halstead_bugs - FROM function_complexity fc - JOIN nodes n ON fc.node_id = n.id - ${where} ${having} - ORDER BY ${orderBy}`, - ) - .all(...params); - } catch (e: unknown) { - debug(`complexity query failed (table may not exist): ${(e as Error).message}`); - // Check if graph has nodes even though complexity table is missing/empty - let hasGraph = false; - try { - hasGraph = (db.prepare<{ c: number }>('SELECT COUNT(*) as c FROM nodes').get()?.c ?? 0) > 0; - } catch (e2: unknown) { - debug(`nodes table check failed: ${(e2 as Error).message}`); - } - return { functions: [], summary: null, thresholds, hasGraph }; - } - - // Post-filter test files if needed (belt-and-suspenders for isTestFile) - const filtered = noTests ? rows.filter((r) => !isTestFile(r.file)) : rows; - - const functions = filtered.map((r) => { - const exceeds: string[] = []; - if ( - isValidThreshold(thresholds.cognitive?.warn) && - r.cognitive >= (thresholds.cognitive?.warn ?? 0) - ) - exceeds.push('cognitive'); - if ( - isValidThreshold(thresholds.cyclomatic?.warn) && - r.cyclomatic >= (thresholds.cyclomatic?.warn ?? 0) - ) - exceeds.push('cyclomatic'); - if ( - isValidThreshold(thresholds.maxNesting?.warn) && - r.max_nesting >= (thresholds.maxNesting?.warn ?? 0) - ) - exceeds.push('maxNesting'); - if ( - isValidThreshold(thresholds.maintainabilityIndex?.warn) && - r.maintainability_index > 0 && - r.maintainability_index <= (thresholds.maintainabilityIndex?.warn ?? 0) - ) - exceeds.push('maintainabilityIndex'); - - return { - name: r.name, - kind: r.kind, - file: r.file, - line: r.line, - endLine: r.end_line || null, - cognitive: r.cognitive, - cyclomatic: r.cyclomatic, - maxNesting: r.max_nesting, - loc: r.loc || 0, - sloc: r.sloc || 0, - maintainabilityIndex: r.maintainability_index || 0, - halstead: { - volume: r.halstead_volume || 0, - difficulty: r.halstead_difficulty || 0, - effort: r.halstead_effort || 0, - bugs: r.halstead_bugs || 0, - }, - exceeds: exceeds.length > 0 ? exceeds : undefined, - }; - }); - - // Summary stats - let summary: Record | null = null; - try { - const allRows = db - .prepare<{ - cognitive: number; - cyclomatic: number; - max_nesting: number; - maintainability_index: number; - }>( - `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') - ${noTests ? `AND n.file NOT LIKE '%.test.%' AND n.file NOT LIKE '%.spec.%' AND n.file NOT LIKE '%__test__%' AND n.file NOT LIKE '%__tests__%' AND n.file NOT LIKE '%.stories.%'` : ''}`, - ) - .all(); - - if (allRows.length > 0) { - const miValues = allRows.map((r) => r.maintainability_index || 0); - summary = { - analyzed: allRows.length, - avgCognitive: +(allRows.reduce((s, r) => s + r.cognitive, 0) / allRows.length).toFixed(1), - avgCyclomatic: +(allRows.reduce((s, r) => s + r.cyclomatic, 0) / allRows.length).toFixed( - 1, - ), - maxCognitive: Math.max(...allRows.map((r) => r.cognitive)), - maxCyclomatic: Math.max(...allRows.map((r) => r.cyclomatic)), - avgMI: +(miValues.reduce((s, v) => s + v, 0) / miValues.length).toFixed(1), - minMI: +Math.min(...miValues).toFixed(1), - aboveWarn: allRows.filter( - (r) => - (isValidThreshold(thresholds.cognitive?.warn) && - r.cognitive >= (thresholds.cognitive?.warn ?? 0)) || - (isValidThreshold(thresholds.cyclomatic?.warn) && - r.cyclomatic >= (thresholds.cyclomatic?.warn ?? 0)) || - (isValidThreshold(thresholds.maxNesting?.warn) && - r.max_nesting >= (thresholds.maxNesting?.warn ?? 0)) || - (isValidThreshold(thresholds.maintainabilityIndex?.warn) && - r.maintainability_index > 0 && - r.maintainability_index <= (thresholds.maintainabilityIndex?.warn ?? 0)), - ).length, - }; - } - } catch (e: unknown) { - debug(`complexity summary query failed: ${(e as Error).message}`); - } - - // When summary is null (no complexity rows), check if graph has nodes - let hasGraph = false; - if (summary === null) { - try { - hasGraph = (db.prepare<{ c: number }>('SELECT COUNT(*) as c FROM nodes').get()?.c ?? 0) > 0; - } catch (e: unknown) { - debug(`nodes table check failed: ${(e as Error).message}`); - } - } - - const base = { functions, summary, thresholds, hasGraph }; - return paginateResult(base, 'functions', { limit: opts.limit, offset: opts.offset }); - } finally { - db.close(); - } -} - -interface IterComplexityRow { - name: string; - kind: string; - file: string; - line: number; - end_line: number | null; - cognitive: number; - cyclomatic: number; - max_nesting: number; - loc: number; - sloc: number; -} - -export function* iterComplexity( - customDbPath?: string, - opts: { - noTests?: boolean; - file?: string; - target?: string; - kind?: string; - sort?: string; - } = {}, -): Generator<{ - name: string; - kind: string; - file: string; - line: number; - endLine: number | null; - cognitive: number; - cyclomatic: number; - maxNesting: number; - loc: number; - sloc: number; -}> { - const db = openReadonlyOrFail(customDbPath); - try { - const noTests = opts.noTests || false; - const sort = opts.sort || 'cognitive'; - - let where = "WHERE n.kind IN ('function','method')"; - const params: unknown[] = []; - - if (noTests) { - where += ` AND n.file NOT LIKE '%.test.%' - AND n.file NOT LIKE '%.spec.%' - AND n.file NOT LIKE '%__test__%' - AND n.file NOT LIKE '%__tests__%' - AND n.file NOT LIKE '%.stories.%'`; - } - if (opts.target) { - where += ' AND n.name LIKE ?'; - params.push(`%${opts.target}%`); - } - { - const fc = buildFileConditionSQL(opts.file as string, 'n.file'); - where += fc.sql; - params.push(...fc.params); - } - if (opts.kind) { - where += ' AND n.kind = ?'; - params.push(opts.kind); - } - - const orderMap: Record = { - cognitive: 'fc.cognitive DESC', - cyclomatic: 'fc.cyclomatic DESC', - nesting: 'fc.max_nesting DESC', - mi: 'fc.maintainability_index ASC', - volume: 'fc.halstead_volume DESC', - effort: 'fc.halstead_effort DESC', - bugs: 'fc.halstead_bugs DESC', - loc: 'fc.loc DESC', - }; - const orderBy = orderMap[sort] || 'fc.cognitive DESC'; - - const stmt = db.prepare( - `SELECT n.name, n.kind, n.file, n.line, n.end_line, - fc.cognitive, fc.cyclomatic, fc.max_nesting, fc.loc, fc.sloc - FROM function_complexity fc - JOIN nodes n ON fc.node_id = n.id - ${where} - ORDER BY ${orderBy}`, - ); - for (const r of stmt.iterate(...params)) { - if (noTests && isTestFile(r.file)) continue; - yield { - name: r.name, - kind: r.kind, - file: r.file, - line: r.line, - endLine: r.end_line || null, - cognitive: r.cognitive, - cyclomatic: r.cyclomatic, - maxNesting: r.max_nesting, - loc: r.loc || 0, - sloc: r.sloc || 0, - }; - } - } finally { - db.close(); - } -} +// ─── Query-Time Functions (re-exported from complexity-query.ts) ────────── +// Split to separate query-time concerns (DB reads, filtering, pagination) +// from compute-time concerns (AST traversal, metric algorithms). +export { complexityData, iterComplexity } from './complexity-query.js'; diff --git a/src/features/structure-query.ts b/src/features/structure-query.ts new file mode 100644 index 00000000..3e2509f4 --- /dev/null +++ b/src/features/structure-query.ts @@ -0,0 +1,385 @@ +/** + * Structure query functions — read-only DB queries for directory structure, + * hotspots, and module boundaries. + * + * Split from structure.ts to separate query-time concerns (DB reads, sorting, + * pagination) from build-time concerns (directory insertion, metrics computation, + * role classification). + */ + +import { openReadonlyOrFail, testFilterSQL } from '../db/index.js'; +import { loadConfig } from '../infrastructure/config.js'; +import { isTestFile } from '../infrastructure/test-filter.js'; +import { normalizePath } from '../shared/constants.js'; +import { paginateResult } from '../shared/paginate.js'; +import type { CodegraphConfig } from '../types.js'; + +// ─── Query functions (read-only) ────────────────────────────────────── + +interface DirRow { + id: number; + name: string; + file: string; + symbol_count: number | null; + fan_in: number | null; + fan_out: number | null; + cohesion: number | null; + file_count: number | null; +} + +interface FileMetricRow { + name: string; + line_count: number | null; + symbol_count: number | null; + import_count: number | null; + export_count: number | null; + fan_in: number | null; + fan_out: number | null; +} + +interface StructureDataOpts { + directory?: string; + depth?: number; + sort?: string; + noTests?: boolean; + full?: boolean; + fileLimit?: number; + limit?: number; + offset?: number; +} + +interface DirectoryEntry { + directory: string; + fileCount: number; + symbolCount: number; + fanIn: number; + fanOut: number; + cohesion: number | null; + density: number; + files: { + file: string; + lineCount: number; + symbolCount: number; + importCount: number; + exportCount: number; + fanIn: number; + fanOut: number; + }[]; + subdirectories: string[]; +} + +export function structureData( + customDbPath?: string, + opts: StructureDataOpts = {}, +): { + directories: DirectoryEntry[]; + count: number; + suppressed?: number; + warning?: string; +} { + const db = openReadonlyOrFail(customDbPath); + try { + const rawDir = opts.directory || null; + const filterDir = rawDir && normalizePath(rawDir) !== '.' ? rawDir : null; + const maxDepth = opts.depth || null; + const sortBy = opts.sort || 'files'; + const noTests = opts.noTests || false; + const full = opts.full || false; + const fileLimit = opts.fileLimit || 25; + + // Get all directory nodes with their metrics + let dirs = db + .prepare(` + SELECT n.id, n.name, n.file, nm.symbol_count, nm.fan_in, nm.fan_out, nm.cohesion, nm.file_count + FROM nodes n + LEFT JOIN node_metrics nm ON n.id = nm.node_id + WHERE n.kind = 'directory' + `) + .all() as DirRow[]; + + if (filterDir) { + const norm = normalizePath(filterDir); + dirs = dirs.filter((d) => d.name === norm || d.name.startsWith(`${norm}/`)); + } + + if (maxDepth) { + const baseDepth = filterDir ? normalizePath(filterDir).split('/').length : 0; + dirs = dirs.filter((d) => { + const depth = d.name.split('/').length - baseDepth; + return depth <= maxDepth; + }); + } + + // Sort + const sortFn = getSortFn(sortBy); + dirs.sort(sortFn); + + // Get file metrics for each directory + const result: DirectoryEntry[] = dirs.map((d) => { + let files = db + .prepare(` + SELECT n.name, nm.line_count, nm.symbol_count, nm.import_count, nm.export_count, nm.fan_in, nm.fan_out + FROM edges e + JOIN nodes n ON e.target_id = n.id + LEFT JOIN node_metrics nm ON n.id = nm.node_id + WHERE e.source_id = ? AND e.kind = 'contains' AND n.kind = 'file' + `) + .all(d.id) as FileMetricRow[]; + if (noTests) files = files.filter((f) => !isTestFile(f.name)); + + const subdirs = db + .prepare(` + SELECT n.name + FROM edges e + JOIN nodes n ON e.target_id = n.id + WHERE e.source_id = ? AND e.kind = 'contains' AND n.kind = 'directory' + `) + .all(d.id) as { name: string }[]; + + const fileCount = noTests ? files.length : d.file_count || 0; + return { + directory: d.name, + fileCount, + symbolCount: d.symbol_count || 0, + fanIn: d.fan_in || 0, + fanOut: d.fan_out || 0, + cohesion: d.cohesion, + density: fileCount > 0 ? (d.symbol_count || 0) / fileCount : 0, + files: files.map((f) => ({ + file: f.name, + lineCount: f.line_count || 0, + symbolCount: f.symbol_count || 0, + importCount: f.import_count || 0, + exportCount: f.export_count || 0, + fanIn: f.fan_in || 0, + fanOut: f.fan_out || 0, + })), + subdirectories: subdirs.map((s) => s.name), + }; + }); + + // Apply global file limit unless full mode + if (!full) { + const totalFiles = result.reduce((sum, d) => sum + d.files.length, 0); + if (totalFiles > fileLimit) { + let shown = 0; + for (const d of result) { + const remaining = fileLimit - shown; + if (remaining <= 0) { + d.files = []; + } else if (d.files.length > remaining) { + d.files = d.files.slice(0, remaining); + shown = fileLimit; + } else { + shown += d.files.length; + } + } + const suppressed = totalFiles - fileLimit; + return { + directories: result, + count: result.length, + suppressed, + warning: `${suppressed} files omitted (showing ${fileLimit}/${totalFiles}). Use --full to show all files, or narrow with --directory.`, + }; + } + } + + const base = { directories: result, count: result.length }; + return paginateResult(base, 'directories', { limit: opts.limit, offset: opts.offset }); + } finally { + db.close(); + } +} + +interface HotspotRow { + name: string; + kind: string; + line_count: number | null; + symbol_count: number | null; + import_count: number | null; + export_count: number | null; + fan_in: number | null; + fan_out: number | null; + cohesion: number | null; + file_count: number | null; +} + +interface HotspotsDataOpts { + metric?: string; + level?: string; + limit?: number; + offset?: number; + noTests?: boolean; +} + +export function hotspotsData( + customDbPath?: string, + opts: HotspotsDataOpts = {}, +): { + metric: string; + level: string; + limit: number; + hotspots: unknown[]; +} { + const db = openReadonlyOrFail(customDbPath); + try { + const metric = opts.metric || 'fan-in'; + const level = opts.level || 'file'; + const limit = opts.limit || 10; + const noTests = opts.noTests || false; + + const kind = level === 'directory' ? 'directory' : 'file'; + + const testFilter = testFilterSQL('n.name', noTests && kind === 'file'); + + const HOTSPOT_QUERIES: Record = { + 'fan-in': db.prepare(` + SELECT n.name, n.kind, nm.line_count, nm.symbol_count, nm.import_count, nm.export_count, + nm.fan_in, nm.fan_out, nm.cohesion, nm.file_count + FROM nodes n JOIN node_metrics nm ON n.id = nm.node_id + WHERE n.kind = ? ${testFilter} ORDER BY nm.fan_in DESC NULLS LAST LIMIT ?`), + 'fan-out': db.prepare(` + SELECT n.name, n.kind, nm.line_count, nm.symbol_count, nm.import_count, nm.export_count, + nm.fan_in, nm.fan_out, nm.cohesion, nm.file_count + FROM nodes n JOIN node_metrics nm ON n.id = nm.node_id + WHERE n.kind = ? ${testFilter} ORDER BY nm.fan_out DESC NULLS LAST LIMIT ?`), + density: db.prepare(` + SELECT n.name, n.kind, nm.line_count, nm.symbol_count, nm.import_count, nm.export_count, + nm.fan_in, nm.fan_out, nm.cohesion, nm.file_count + FROM nodes n JOIN node_metrics nm ON n.id = nm.node_id + WHERE n.kind = ? ${testFilter} ORDER BY nm.symbol_count DESC NULLS LAST LIMIT ?`), + coupling: db.prepare(` + SELECT n.name, n.kind, nm.line_count, nm.symbol_count, nm.import_count, nm.export_count, + nm.fan_in, nm.fan_out, nm.cohesion, nm.file_count + FROM nodes n JOIN node_metrics nm ON n.id = nm.node_id + WHERE n.kind = ? ${testFilter} ORDER BY (COALESCE(nm.fan_in, 0) + COALESCE(nm.fan_out, 0)) DESC NULLS LAST LIMIT ?`), + }; + + const stmt = HOTSPOT_QUERIES[metric] ?? HOTSPOT_QUERIES['fan-in']; + // stmt is always defined: metric is a valid key or the fallback is a concrete property + const rows = stmt!.all(kind, limit); + + const hotspots = rows.map((r) => ({ + name: r.name, + kind: r.kind, + lineCount: r.line_count, + symbolCount: r.symbol_count, + importCount: r.import_count, + exportCount: r.export_count, + fanIn: r.fan_in, + fanOut: r.fan_out, + cohesion: r.cohesion, + fileCount: r.file_count, + density: + (r.file_count ?? 0) > 0 + ? (r.symbol_count || 0) / (r.file_count ?? 1) + : (r.line_count ?? 0) > 0 + ? (r.symbol_count || 0) / (r.line_count ?? 1) + : 0, + coupling: (r.fan_in || 0) + (r.fan_out || 0), + })); + + const base = { metric, level, limit, hotspots }; + return paginateResult(base, 'hotspots', { limit: opts.limit, offset: opts.offset }); + } finally { + db.close(); + } +} + +interface ModuleBoundariesOpts { + threshold?: number; + config?: CodegraphConfig; +} + +export function moduleBoundariesData( + customDbPath?: string, + opts: ModuleBoundariesOpts = {}, +): { + threshold: number; + modules: { + directory: string; + cohesion: number | null; + fileCount: number; + symbolCount: number; + fanIn: number; + fanOut: number; + files: string[]; + }[]; + count: number; +} { + const db = openReadonlyOrFail(customDbPath); + try { + const config = opts.config || loadConfig(); + const threshold = + opts.threshold ?? + (config as unknown as { structure?: { cohesionThreshold?: number } }).structure + ?.cohesionThreshold ?? + 0.3; + + const dirs = db + .prepare(` + SELECT n.id, n.name, nm.symbol_count, nm.fan_in, nm.fan_out, nm.cohesion, nm.file_count + FROM nodes n + JOIN node_metrics nm ON n.id = nm.node_id + WHERE n.kind = 'directory' AND nm.cohesion IS NOT NULL AND nm.cohesion >= ? + ORDER BY nm.cohesion DESC + `) + .all(threshold) as { + id: number; + name: string; + symbol_count: number | null; + fan_in: number | null; + fan_out: number | null; + cohesion: number | null; + file_count: number | null; + }[]; + + const modules = dirs.map((d) => { + // Get files inside this directory + const files = ( + db + .prepare(` + SELECT n.name FROM edges e + JOIN nodes n ON e.target_id = n.id + WHERE e.source_id = ? AND e.kind = 'contains' AND n.kind = 'file' + `) + .all(d.id) as { name: string }[] + ).map((f) => f.name); + + return { + directory: d.name, + cohesion: d.cohesion, + fileCount: d.file_count || 0, + symbolCount: d.symbol_count || 0, + fanIn: d.fan_in || 0, + fanOut: d.fan_out || 0, + files, + }; + }); + + return { threshold, modules, count: modules.length }; + } finally { + db.close(); + } +} + +// ─── Helpers ────────────────────────────────────────────────────────── + +function getSortFn(sortBy: string): (a: DirRow, b: DirRow) => number { + switch (sortBy) { + case 'cohesion': + return (a, b) => (b.cohesion ?? -1) - (a.cohesion ?? -1); + case 'fan-in': + return (a, b) => (b.fan_in || 0) - (a.fan_in || 0); + case 'fan-out': + return (a, b) => (b.fan_out || 0) - (a.fan_out || 0); + case 'density': + return (a, b) => { + const da = (a.file_count ?? 0) > 0 ? (a.symbol_count || 0) / (a.file_count ?? 1) : 0; + const db_ = (b.file_count ?? 0) > 0 ? (b.symbol_count || 0) / (b.file_count ?? 1) : 0; + return db_ - da; + }; + default: + return (a, b) => a.name.localeCompare(b.name); + } +} diff --git a/src/features/structure.ts b/src/features/structure.ts index 9976907f..ec57dfd5 100644 --- a/src/features/structure.ts +++ b/src/features/structure.ts @@ -1,11 +1,8 @@ import path from 'node:path'; -import { getNodeId, openReadonlyOrFail, testFilterSQL } from '../db/index.js'; -import { loadConfig } from '../infrastructure/config.js'; +import { getNodeId, testFilterSQL } from '../db/index.js'; import { debug } from '../infrastructure/logger.js'; -import { isTestFile } from '../infrastructure/test-filter.js'; import { normalizePath } from '../shared/constants.js'; -import { paginateResult } from '../shared/paginate.js'; -import type { BetterSqlite3Database, CodegraphConfig } from '../types.js'; +import type { BetterSqlite3Database } from '../types.js'; // ─── Build-time helpers ─────────────────────────────────────────────── @@ -508,372 +505,7 @@ export function classifyNodeRoles(db: BetterSqlite3Database): RoleSummary { return summary; } -// ─── Query functions (read-only) ────────────────────────────────────── - -interface DirRow { - id: number; - name: string; - file: string; - symbol_count: number | null; - fan_in: number | null; - fan_out: number | null; - cohesion: number | null; - file_count: number | null; -} - -interface FileMetricRow { - name: string; - line_count: number | null; - symbol_count: number | null; - import_count: number | null; - export_count: number | null; - fan_in: number | null; - fan_out: number | null; -} - -interface StructureDataOpts { - directory?: string; - depth?: number; - sort?: string; - noTests?: boolean; - full?: boolean; - fileLimit?: number; - limit?: number; - offset?: number; -} - -interface DirectoryEntry { - directory: string; - fileCount: number; - symbolCount: number; - fanIn: number; - fanOut: number; - cohesion: number | null; - density: number; - files: { - file: string; - lineCount: number; - symbolCount: number; - importCount: number; - exportCount: number; - fanIn: number; - fanOut: number; - }[]; - subdirectories: string[]; -} - -export function structureData( - customDbPath?: string, - opts: StructureDataOpts = {}, -): { - directories: DirectoryEntry[]; - count: number; - suppressed?: number; - warning?: string; -} { - const db = openReadonlyOrFail(customDbPath); - try { - const rawDir = opts.directory || null; - const filterDir = rawDir && normalizePath(rawDir) !== '.' ? rawDir : null; - const maxDepth = opts.depth || null; - const sortBy = opts.sort || 'files'; - const noTests = opts.noTests || false; - const full = opts.full || false; - const fileLimit = opts.fileLimit || 25; - - // Get all directory nodes with their metrics - let dirs = db - .prepare(` - SELECT n.id, n.name, n.file, nm.symbol_count, nm.fan_in, nm.fan_out, nm.cohesion, nm.file_count - FROM nodes n - LEFT JOIN node_metrics nm ON n.id = nm.node_id - WHERE n.kind = 'directory' - `) - .all() as DirRow[]; - - if (filterDir) { - const norm = normalizePath(filterDir); - dirs = dirs.filter((d) => d.name === norm || d.name.startsWith(`${norm}/`)); - } - - if (maxDepth) { - const baseDepth = filterDir ? normalizePath(filterDir).split('/').length : 0; - dirs = dirs.filter((d) => { - const depth = d.name.split('/').length - baseDepth; - return depth <= maxDepth; - }); - } - - // Sort - const sortFn = getSortFn(sortBy); - dirs.sort(sortFn); - - // Get file metrics for each directory - const result: DirectoryEntry[] = dirs.map((d) => { - let files = db - .prepare(` - SELECT n.name, nm.line_count, nm.symbol_count, nm.import_count, nm.export_count, nm.fan_in, nm.fan_out - FROM edges e - JOIN nodes n ON e.target_id = n.id - LEFT JOIN node_metrics nm ON n.id = nm.node_id - WHERE e.source_id = ? AND e.kind = 'contains' AND n.kind = 'file' - `) - .all(d.id) as FileMetricRow[]; - if (noTests) files = files.filter((f) => !isTestFile(f.name)); - - const subdirs = db - .prepare(` - SELECT n.name - FROM edges e - JOIN nodes n ON e.target_id = n.id - WHERE e.source_id = ? AND e.kind = 'contains' AND n.kind = 'directory' - `) - .all(d.id) as { name: string }[]; - - const fileCount = noTests ? files.length : d.file_count || 0; - return { - directory: d.name, - fileCount, - symbolCount: d.symbol_count || 0, - fanIn: d.fan_in || 0, - fanOut: d.fan_out || 0, - cohesion: d.cohesion, - density: fileCount > 0 ? (d.symbol_count || 0) / fileCount : 0, - files: files.map((f) => ({ - file: f.name, - lineCount: f.line_count || 0, - symbolCount: f.symbol_count || 0, - importCount: f.import_count || 0, - exportCount: f.export_count || 0, - fanIn: f.fan_in || 0, - fanOut: f.fan_out || 0, - })), - subdirectories: subdirs.map((s) => s.name), - }; - }); - - // Apply global file limit unless full mode - if (!full) { - const totalFiles = result.reduce((sum, d) => sum + d.files.length, 0); - if (totalFiles > fileLimit) { - let shown = 0; - for (const d of result) { - const remaining = fileLimit - shown; - if (remaining <= 0) { - d.files = []; - } else if (d.files.length > remaining) { - d.files = d.files.slice(0, remaining); - shown = fileLimit; - } else { - shown += d.files.length; - } - } - const suppressed = totalFiles - fileLimit; - return { - directories: result, - count: result.length, - suppressed, - warning: `${suppressed} files omitted (showing ${fileLimit}/${totalFiles}). Use --full to show all files, or narrow with --directory.`, - }; - } - } - - const base = { directories: result, count: result.length }; - return paginateResult(base, 'directories', { limit: opts.limit, offset: opts.offset }); - } finally { - db.close(); - } -} - -interface HotspotRow { - name: string; - kind: string; - line_count: number | null; - symbol_count: number | null; - import_count: number | null; - export_count: number | null; - fan_in: number | null; - fan_out: number | null; - cohesion: number | null; - file_count: number | null; -} - -interface HotspotsDataOpts { - metric?: string; - level?: string; - limit?: number; - offset?: number; - noTests?: boolean; -} - -export function hotspotsData( - customDbPath?: string, - opts: HotspotsDataOpts = {}, -): { - metric: string; - level: string; - limit: number; - hotspots: unknown[]; -} { - const db = openReadonlyOrFail(customDbPath); - try { - const metric = opts.metric || 'fan-in'; - const level = opts.level || 'file'; - const limit = opts.limit || 10; - const noTests = opts.noTests || false; - - const kind = level === 'directory' ? 'directory' : 'file'; - - const testFilter = testFilterSQL('n.name', noTests && kind === 'file'); - - const HOTSPOT_QUERIES: Record = { - 'fan-in': db.prepare(` - SELECT n.name, n.kind, nm.line_count, nm.symbol_count, nm.import_count, nm.export_count, - nm.fan_in, nm.fan_out, nm.cohesion, nm.file_count - FROM nodes n JOIN node_metrics nm ON n.id = nm.node_id - WHERE n.kind = ? ${testFilter} ORDER BY nm.fan_in DESC NULLS LAST LIMIT ?`), - 'fan-out': db.prepare(` - SELECT n.name, n.kind, nm.line_count, nm.symbol_count, nm.import_count, nm.export_count, - nm.fan_in, nm.fan_out, nm.cohesion, nm.file_count - FROM nodes n JOIN node_metrics nm ON n.id = nm.node_id - WHERE n.kind = ? ${testFilter} ORDER BY nm.fan_out DESC NULLS LAST LIMIT ?`), - density: db.prepare(` - SELECT n.name, n.kind, nm.line_count, nm.symbol_count, nm.import_count, nm.export_count, - nm.fan_in, nm.fan_out, nm.cohesion, nm.file_count - FROM nodes n JOIN node_metrics nm ON n.id = nm.node_id - WHERE n.kind = ? ${testFilter} ORDER BY nm.symbol_count DESC NULLS LAST LIMIT ?`), - coupling: db.prepare(` - SELECT n.name, n.kind, nm.line_count, nm.symbol_count, nm.import_count, nm.export_count, - nm.fan_in, nm.fan_out, nm.cohesion, nm.file_count - FROM nodes n JOIN node_metrics nm ON n.id = nm.node_id - WHERE n.kind = ? ${testFilter} ORDER BY (COALESCE(nm.fan_in, 0) + COALESCE(nm.fan_out, 0)) DESC NULLS LAST LIMIT ?`), - }; - - const stmt = HOTSPOT_QUERIES[metric] ?? HOTSPOT_QUERIES['fan-in']; - // stmt is always defined: metric is a valid key or the fallback is a concrete property - const rows = stmt!.all(kind, limit); - - const hotspots = rows.map((r) => ({ - name: r.name, - kind: r.kind, - lineCount: r.line_count, - symbolCount: r.symbol_count, - importCount: r.import_count, - exportCount: r.export_count, - fanIn: r.fan_in, - fanOut: r.fan_out, - cohesion: r.cohesion, - fileCount: r.file_count, - density: - (r.file_count ?? 0) > 0 - ? (r.symbol_count || 0) / (r.file_count ?? 1) - : (r.line_count ?? 0) > 0 - ? (r.symbol_count || 0) / (r.line_count ?? 1) - : 0, - coupling: (r.fan_in || 0) + (r.fan_out || 0), - })); - - const base = { metric, level, limit, hotspots }; - return paginateResult(base, 'hotspots', { limit: opts.limit, offset: opts.offset }); - } finally { - db.close(); - } -} - -interface ModuleBoundariesOpts { - threshold?: number; - config?: CodegraphConfig; -} - -export function moduleBoundariesData( - customDbPath?: string, - opts: ModuleBoundariesOpts = {}, -): { - threshold: number; - modules: { - directory: string; - cohesion: number | null; - fileCount: number; - symbolCount: number; - fanIn: number; - fanOut: number; - files: string[]; - }[]; - count: number; -} { - const db = openReadonlyOrFail(customDbPath); - try { - const config = opts.config || loadConfig(); - const threshold = - opts.threshold ?? - (config as unknown as { structure?: { cohesionThreshold?: number } }).structure - ?.cohesionThreshold ?? - 0.3; - - const dirs = db - .prepare(` - SELECT n.id, n.name, nm.symbol_count, nm.fan_in, nm.fan_out, nm.cohesion, nm.file_count - FROM nodes n - JOIN node_metrics nm ON n.id = nm.node_id - WHERE n.kind = 'directory' AND nm.cohesion IS NOT NULL AND nm.cohesion >= ? - ORDER BY nm.cohesion DESC - `) - .all(threshold) as { - id: number; - name: string; - symbol_count: number | null; - fan_in: number | null; - fan_out: number | null; - cohesion: number | null; - file_count: number | null; - }[]; - - const modules = dirs.map((d) => { - // Get files inside this directory - const files = ( - db - .prepare(` - SELECT n.name FROM edges e - JOIN nodes n ON e.target_id = n.id - WHERE e.source_id = ? AND e.kind = 'contains' AND n.kind = 'file' - `) - .all(d.id) as { name: string }[] - ).map((f) => f.name); - - return { - directory: d.name, - cohesion: d.cohesion, - fileCount: d.file_count || 0, - symbolCount: d.symbol_count || 0, - fanIn: d.fan_in || 0, - fanOut: d.fan_out || 0, - files, - }; - }); - - return { threshold, modules, count: modules.length }; - } finally { - db.close(); - } -} - -// ─── Helpers ────────────────────────────────────────────────────────── - -function getSortFn(sortBy: string): (a: DirRow, b: DirRow) => number { - switch (sortBy) { - case 'cohesion': - return (a, b) => (b.cohesion ?? -1) - (a.cohesion ?? -1); - case 'fan-in': - return (a, b) => (b.fan_in || 0) - (a.fan_in || 0); - case 'fan-out': - return (a, b) => (b.fan_out || 0) - (a.fan_out || 0); - case 'density': - return (a, b) => { - const da = (a.file_count ?? 0) > 0 ? (a.symbol_count || 0) / (a.file_count ?? 1) : 0; - const db_ = (b.file_count ?? 0) > 0 ? (b.symbol_count || 0) / (b.file_count ?? 1) : 0; - return db_ - da; - }; - default: - return (a, b) => a.name.localeCompare(b.name); - } -} +// ─── Query functions (re-exported from structure-query.ts) ──────────── +// Split to separate query-time concerns (DB reads, sorting, pagination) +// from build-time concerns (directory insertion, metrics computation, role classification). +export { hotspotsData, moduleBoundariesData, structureData } from './structure-query.js'; From 4869dcad8c6fbc85b1df56fea6d2610ad332232b Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Wed, 25 Mar 2026 23:50:02 -0600 Subject: [PATCH 6/9] feat(titan): improve pipeline autonomy, artifact handling, and add audit report - titan-run: auto-create worktree instead of stopping, skip pre-analysis confirmation, dispatch titan-close as final phase - titan-close: load all titan-run artifacts (gauntlet.ndjson, arch-snapshot, drift-report), add architecture before/after comparison, stop gitignoring reports - Add Titan audit report with per-pillar breakdowns, architecture cohesion deltas, and full artifact inventory --- .claude/skills/titan-close/SKILL.md | 18 +- .claude/skills/titan-run/SKILL.md | 67 ++-- .gitignore | 3 - ...titan-report-v3.3.1-2026-03-25T23-33-11.md | 285 ++++++++++++++++++ 4 files changed, 315 insertions(+), 58 deletions(-) create mode 100644 generated/titan/titan-report-v3.3.1-2026-03-25T23-33-11.md diff --git a/.claude/skills/titan-close/SKILL.md b/.claude/skills/titan-close/SKILL.md index 57568d65..299bc447 100644 --- a/.claude/skills/titan-close/SKILL.md +++ b/.claude/skills/titan-close/SKILL.md @@ -58,13 +58,18 @@ Your goal: analyze all commits on the current branch, split them into focused PR 4. **Load artifacts.** Read: - `.codegraph/titan/titan-state.json` — session state, baseline metrics, progress - `.codegraph/titan/GLOBAL_ARCH.md` — architecture document - - `.codegraph/titan/gauntlet-summary.json` — audit results + - `.codegraph/titan/gauntlet.ndjson` — full per-target audit data (pillar verdicts, metrics, violations) + - `.codegraph/titan/gauntlet-summary.json` — audit result totals - `.codegraph/titan/sync.json` — execution plan (commit grouping) - - `.codegraph/titan/gate-log.ndjson` — validation history + - `.codegraph/titan/gate-log.ndjson` — validation history (may not exist if gate wasn't run) - `.codegraph/titan/issues.ndjson` — issue tracker from all phases + - `.codegraph/titan/arch-snapshot.json` — pre-forge architectural snapshot (communities, structure, drift). Use for before/after comparison in the Metrics section. May not exist if capture failed. + - `.codegraph/titan/drift-report.json` — cumulative drift reports from all phases. May not exist if no drift was detected. If `titan-state.json` is missing after the search, stop: "No Titan session found. Run `/titan-recon` first." + > **When called from `/titan-run`:** The orchestrator already ensured worktree isolation, synced with main, and all artifacts are in the current worktree. Steps 0.1–0.3 (worktree search, isolation check, main sync) can be skipped if the orchestrator tells you to skip them. + 5. **Detect version.** Extract from `package.json`: ```bash node -e "console.log(require('./package.json').version)" @@ -193,6 +198,10 @@ codegraph complexity --health --sort bugs -T --json --limit 10 codegraph complexity --health --sort mi -T --json --limit 10 ``` +### Architecture comparison (if arch-snapshot.json exists) + +If `.codegraph/titan/arch-snapshot.json` was captured before forge, compare its `structure` data against current `codegraph structure --depth 2 --json` output. Report cohesion changes per directory (improved / degraded / unchanged). Include in the "Metrics: Before & After" section of the report. + ### Compute deltas Compare final metrics against `titan-state.json` baseline: @@ -505,10 +514,7 @@ Write `.codegraph/titan/close-summary.json`: ``` Delete any remaining batch snapshots. -3. **Add report to .gitignore** if `generated/titan/` is not already ignored: - ```bash - grep -q "generated/titan/" .gitignore || echo "generated/titan/" >> .gitignore - ``` +3. **Titan reports are committed to the repo** (not gitignored). The `generated/titan/` directory is tracked so reports are preserved in git history. --- diff --git a/.claude/skills/titan-run/SKILL.md b/.claude/skills/titan-run/SKILL.md index 3ee9e68a..fefd96d9 100644 --- a/.claude/skills/titan-run/SKILL.md +++ b/.claude/skills/titan-run/SKILL.md @@ -28,7 +28,7 @@ You are the **orchestrator** for the full Titan Paradigm pipeline. Your job is t ```bash git rev-parse --show-toplevel && git worktree list ``` - If you are NOT in a worktree, **stop:** "Run `/worktree` first. The Titan pipeline writes artifacts and makes code changes — worktree isolation is required." + The Titan pipeline writes artifacts and makes code changes — worktree isolation is required. If you are NOT in a worktree, **create one automatically** using the `EnterWorktree` tool (fetch it via `ToolSearch` if needed) with name `titan-run`. Do NOT stop and ask the user — just enter the worktree and continue. 2. **Parse arguments.** Determine: - `targetPath` (default: `.`) @@ -58,7 +58,7 @@ You are the **orchestrator** for the full Titan Paradigm pipeline. Your job is t Forge requires explicit confirmation (analysis phases are safe to automate). ``` - If `--yes` is NOT set, ask user to confirm before proceeding. + Start immediately — do NOT ask for confirmation before analysis phases. The user invoked `/titan-run`; that is the confirmation. Analysis phases (recon, gauntlet, sync) are read-only and safe to automate. The forge checkpoint (Step 3.5b) still applies unless `--yes` is set. --- @@ -577,58 +577,27 @@ Print forge summary. --- -## Step 5 — Final Report +## Step 5 — CLOSE (report + PRs) -Read all artifacts and produce a summary: +After forge completes, dispatch `/titan-close` to produce the final report with before/after metrics and split commits into focused PRs. + +### 5a. Run Pre-Agent Gate (G1-G4) + +### 5b. Dispatch sub-agent ``` -============================================ - TITAN PIPELINE COMPLETE -============================================ - -Target: -Duration: - -RECON: - Files: , Symbols: , Domains: - Quality score: - -GAUNTLET: - Audited: / targets (% coverage) - Pass: | Warn: | Fail: | Decompose: - NDJSON integrity: / lines - -SYNC: - Execution phases: - Shared abstractions: - -FORGE: - Commits: - Targets completed: - Targets failed: - Diff review rejections: - Diff review warnings: - Gate verdicts: PASS, FAIL - Semantic assertion failures: - Architectural violations caught: - - Failed targets (if any): - - : - -Validation warnings (if any): - - - -Artifacts: - .codegraph/titan/titan-state.json - .codegraph/titan/GLOBAL_ARCH.md - .codegraph/titan/gauntlet.ndjson - .codegraph/titan/gauntlet-summary.json - .codegraph/titan/sync.json - .codegraph/titan/arch-snapshot.json - .codegraph/titan/gate-log.ndjson -============================================ +Agent → "Run /titan-close. Read .claude/skills/titan-close/SKILL.md and follow it exactly. + Skip worktree check and main sync — already handled." ``` +### 5c. Post-phase validation + +After the agent returns, verify: +- `.codegraph/titan/TITAN_REPORT.md` or `generated/titan/titan-report-*.md` exists and has content (> 20 lines) +- Print: "CLOSE complete. Report: " + +If the agent created PRs, print the PR URLs. + --- ## Error Handling diff --git a/.gitignore b/.gitignore index 9acd21d6..4f78ca5a 100644 --- a/.gitignore +++ b/.gitignore @@ -9,8 +9,5 @@ grammars/*.wasm .claude/session-edits.log generated/DEPENDENCIES.md generated/DEPENDENCIES.json -generated/titan/ artifacts/ pkg/ -generated/DEPENDENCIES.md -generated/DEPENDENCIES.json diff --git a/generated/titan/titan-report-v3.3.1-2026-03-25T23-33-11.md b/generated/titan/titan-report-v3.3.1-2026-03-25T23-33-11.md new file mode 100644 index 00000000..40a5e6b9 --- /dev/null +++ b/generated/titan/titan-report-v3.3.1-2026-03-25T23-33-11.md @@ -0,0 +1,285 @@ +# Titan Audit Report + +**Version:** 3.3.1 +**Date:** 2026-03-25T03:20Z -> 2026-03-25T23:33Z +**Branch:** worktree-titan-run +**Target:** . (full codebase) + +--- + +## Executive Summary + +The Titan pipeline audited 88 files across 15 domains of the codegraph codebase, spanning all architectural layers from the root type hub through infrastructure, domain logic, features, presentation, CLI, and MCP. The codebase follows strong layered architecture with clean dependency direction. Six files failed audit (all SLOC or empty-catch violations in infrastructure and domain layers), and all six were addressed through targeted fixes: debug logging for empty catches, file decompositions for oversized modules, and DRY extraction for duplicated constants. Pipeline freshness remained high throughout -- main advanced only 4 commits total across the session, none touching audited src/ files. + +--- + +## Pipeline Timeline + +| Phase | Started | Completed | Duration | +|-------|---------|-----------|----------| +| RECON | 2026-03-25T03:20Z | 2026-03-25T04:00Z | ~40min | +| GAUNTLET | 2026-03-25T04:00Z | 2026-03-25T05:35Z | ~95min | +| SYNC | 2026-03-25T05:35Z | 2026-03-25T06:00Z | ~25min | +| GATE (5 runs) | 2026-03-25T07:00Z | 2026-03-26T05:30Z | ~22.5h | +| CLOSE | 2026-03-25T23:33Z | 2026-03-25T23:33Z | ~5min | + +--- + +## Metrics: Before & After + +| Metric | Baseline | Final | Delta | Trend | +|--------|----------|-------|-------|-------| +| Quality Score | N/A (DB error) | 64 | -- | -- | +| Total Files | 464 | 473 | +9 | (new files from decompositions) | +| Total Symbols | 6345 | 10963 | +4618 | (fresh full build with complexity/dataflow) | +| Total Edges | 12800 | 20916 | +8116 | (includes calls, dataflow, parameter_of) | +| Functions Above Threshold | N/A | 409 | -- | -- | +| Dead Symbols | N/A | 8259 | -- | -- | +| Core Symbols | N/A | 760 | -- | -- | +| File-Level Cycles | 1 | 1 | 0 | -- | +| Function-Level Cycles | 8 | 8 | 0 | -- | +| Avg Cognitive Complexity | N/A | 7.6 | -- | -- | +| Avg Cyclomatic Complexity | N/A | 5.5 | -- | -- | +| Avg Maintainability Index | N/A | 61.5 | -- | -- | +| Community Count | N/A | 106 | -- | -- | +| Modularity | N/A | 0.474 | -- | -- | + +> **Note:** Baseline metrics for complexity, roles, and quality score were unavailable during RECON due to a missing DB `role` column (codegraph bug). The final build on a fresh DB produced complete metrics. Delta comparison is not possible for most metrics -- this run establishes the baseline for future Titan runs. + +### Architecture: Before & After (from arch-snapshot.json) + +Pre-forge snapshot captured at commit `0435c41`, compared against post-forge state: + +| Directory | Files (before → after) | Symbols (before → after) | Cohesion (before → after) | Trend | +|-----------|----------------------|--------------------------|---------------------------|-------| +| src/ast-analysis | 18 → 22 | 252 → 288 | 0.386 → 0.460 | +19% cohesion (cfg-visitor split improved modularity) | +| src/domain | 44 → 46 | 449 → 502 | 0.158 → 0.155 | ~stable (impact.ts split added 2 files) | +| src/features | 21 → 23 | 896 → 915 | 0.041 → 0.040 | ~stable (complexity/structure split) | +| src/extractors | 11 → 11 | 176 → 184 | 0.281 → 0.281 | unchanged (MAX_WALK_DEPTH was additive) | +| src/infrastructure | 7 → 7 | 64 → 79 | 0.016 → 0.023 | +44% cohesion (debug imports added connections) | +| src/presentation | 30 → 31 | 950 → 958 | 0.154 → 0.157 | +2% cohesion (diff-impact-mermaid moved here) | +| src/db | 18 → 18 | 277 → 327 | 0.069 → 0.068 | unchanged | +| src/shared | 8 → 8 | 114 → 127 | 0.008 → 0.008 | unchanged | +| src/mcp | 40 → 40 | 311 → 351 | 0.840 → 0.840 | unchanged | +| src/graph | 22 → 22 | 325 → 335 | 0.406 → 0.406 | unchanged | +| src/cli | 48 → 48 | 120 → 165 | 0.302 → 0.302 | unchanged | + +> **Key insight:** The forge changes improved cohesion in the two most-modified directories (ast-analysis +19%, infrastructure +44%) without degrading any other directory. File decompositions increased file count by 9 but kept cohesion stable or improved. No architectural regressions. + +### Remaining Hot Spots (Top 10 by Halstead Bugs) + +| Function | File | Bugs | MI | Exceeds | +|----------|------|------|----|---------| +| makePartition | leiden/partition.ts:59 | 6.257 | 5.0 | cognitive, cyclomatic, nesting, MI | +| walk_node_depth | javascript.rs:128 | 5.476 | 8.4 | cognitive, cyclomatic, nesting, MI | +| build_call_edges | edge_builder.rs:91 | 4.434 | 22.1 | cognitive, cyclomatic, nesting | +| walk_node_depth | php.rs:40 | 3.749 | 17.0 | cognitive, cyclomatic, nesting, MI | +| walk_node_depth | csharp.rs:41 | 3.438 | 13.2 | cognitive, cyclomatic, nesting, MI | +| walk_node_depth | java.rs:98 | 2.842 | 19.7 | cognitive, cyclomatic, nesting, MI | +| complexityData | complexity-query.ts:36 | 2.648 | 21.0 | cognitive, cyclomatic | +| walk_node_depth | python.rs:24 | 2.558 | 20.4 | cognitive, cyclomatic, nesting | +| prepareFunctionLevelData | graph-enrichment.ts:86 | 2.545 | 25.3 | cognitive, cyclomatic | +| walk_node_depth | rust_lang.rs:37 | 2.468 | 18.1 | cognitive, cyclomatic, nesting, MI | + +> Most hot spots are in Rust native extractors (inherently complex AST walkers) and the Leiden algorithm implementation. These are structural complexity that may not benefit from decomposition. + +--- + +## Audit Results Summary + +**Targets audited:** 88 +**Pass:** 63 | **Warn:** 19 | **Fail:** 6 | **Decompose:** 0 + +### By Pillar (from gauntlet.ndjson — 88 targets) + +| Pillar | Pass | Warn | Fail | +|--------|------|------|------| +| I — Structural Purity | 68 | 16 | 4 | +| II — Data & Type Sovereignty | 84 | 0 | 4 | +| III — Ecosystem Synergy | 81 | 7 | 0 | +| IV — Quality Vigil | 84 | 4 | 0 | + +### Most Common Violations (from gauntlet.ndjson) + +| # | Rule | Pillar | Metric | Count | Level | Sample | +|---|------|--------|--------|-------|-------|--------| +| 1 | R1 | I | sloc | 17 | warn | Files exceeding 500-line threshold | +| 2 | R11 | III | DRY | 7 | warn | `depth >= 200` pattern repeated across 6 extractors | +| 3 | R10 | II | empty-catch | 4 | fail | Empty catch blocks with `/* ignore */` comments | +| 4 | R15 | IV | console.log | 2 | warn | console.log in non-presentation modules | +| 5 | R10 | IV | empty-catch | 2 | warn | Empty catches in embedded client-side JS (viewer) | +| 6 | R6 | I | mutation | 2 | warn | .sort()/.push()/delete on local data structures | +| 7 | R1 | I | density | 1 | warn | 52 console.log calls in 330 lines (output-dense module) | +| 8 | R12 | III | naming | 1 | warn | Section numbering duplication | +| 9 | R7 | II | magic-value | 1 | advisory | depth >= 200 safety guard (Category F, acceptable) | + +### Worst Offenders (FAIL) + +| File | SLOC | Violations | Status After | +|------|------|------------|-------------| +| src/ast-analysis/visitors/cfg-visitor.ts | 874 | sloc-fail | Fixed (split into 4 modules) | +| src/domain/analysis/impact.ts | 721 | sloc-fail | Fixed (split into fn-impact + diff-impact) | +| src/domain/parser.ts | 672 | sloc-fail, empty-catch | Partially fixed (catch fixed, SLOC deferred) | +| src/domain/graph/resolve.ts | 585 | sloc-fail, empty-catch | Partially fixed (catch fixed, SLOC deferred) | +| src/infrastructure/config.ts | 438 | empty-catch-x4 | Fixed (debug logging added) | +| src/infrastructure/native.ts | 113 | empty-catch-x2 | Fixed (debug logging added) | + +--- + +## Changes Made + +### Commits: 5 (Titan forge only) + +| SHA | Message | Files Changed | Domain | +|-----|---------|---------------|--------| +| e1dde35 | fix: add debug logging to empty catch blocks across infrastructure and domain layers | 4 | infrastructure, domain | +| e8f41f4 | refactor: split impact.ts into fn-impact and diff-impact modules | 4 | domain/analysis | +| 2113bd6 | refactor: split cfg-visitor.ts by control-flow construct | 5 | ast-analysis | +| 4ceed5d | refactor: extract MAX_WALK_DEPTH constant to extractors/helpers.ts | 7 | extractors | +| 23bf546 | refactor: address SLOC warnings in domain and features layers | 4 | features | + +**Total files changed:** 24 (across 5 commits) + +### PR Split Plan + +| PR # | Title | Concern | Domain | Commits | Files | Depends On | URL | +|------|-------|---------|--------|---------|-------|------------|-----| +| 1 | fix: add debug logging to empty catch blocks | quality_fix | infrastructure + domain | 1 | 4 | -- | [#616](https://github.com/optave/codegraph/pull/616) | +| 2 | refactor: split impact.ts into fn-impact and diff-impact | decomposition | domain/analysis | 1 | 4 | -- | [#617](https://github.com/optave/codegraph/pull/617) | +| 3 | refactor: split cfg-visitor.ts by control-flow construct | decomposition | ast-analysis | 1 | 5 | -- | [#619](https://github.com/optave/codegraph/pull/619) | +| 4 | refactor: extract MAX_WALK_DEPTH to helpers.ts | abstraction | extractors | 1 | 7 | -- | [#620](https://github.com/optave/codegraph/pull/620) | +| 5 | refactor: address SLOC warnings in features | warning | features | 1 | 4 | PR #1, #2 | [#621](https://github.com/optave/codegraph/pull/621) | + +> All PRs are independent except PR #5 which depends on #1 (empty-catch fixes) and #2 (impact.ts split). PRs #1-4 can be merged in any order. + +--- + +## Gate Validation History + +**Total runs:** 5 +**Pass:** 5 | **Warn:** 0 | **Fail:** 0 +**Rollbacks:** 0 + +### Check Results Across All Runs + +| Check | Pass | Skip | Fail | +|-------|------|------|------| +| cycles | 5 | 0 | 0 | +| lint | 5 | 0 | 0 | +| tests | 5 | 0 | 0 | +| semanticAssertions | 5 | 0 | 0 | +| archSnapshot | 5 | 0 | 0 | +| syncAlignment | 5 | 0 | 0 | +| blastRadius | 5 | 0 | 0 | +| manifesto | 2 | 3 | 0 | +| complexity | 1 | 4 | 0 | +| build | 0 | 5 | 0 | + +> All 5 gate runs passed. No rollbacks were triggered. Build check was skipped (no build step configured). Manifesto and complexity checks were skipped in early runs due to missing DB data but passed in later runs after fresh graph build. + +--- + +## Issues Discovered + +### Codegraph Bugs (1) + +| Severity | Description | Context | +|----------|-------------|---------| +| bug | `roles --role dead` fails with "no such column: role" DB schema error | Fixed by rebuilding DB from scratch; likely a migration gap | + +### Codegraph Limitations (3) + +| Severity | Description | Context | +|----------|-------------|---------| +| limitation | `complexity --file` returns empty functions array for all TypeScript files | function_complexity table not populated until fresh build | +| limitation | Same as above for all shared/ and extractor files | Resolved after DB rebuild | +| limitation | `codegraph path` requires symbol names, not file paths | Cannot query file-to-file shortest path directly | + +### Process Suggestions (1) + +| Severity | Description | Context | +|----------|-------------|---------| +| suggestion | RECON batch file lists should validate file existence | Batch 9 referenced non-existent typescript.ts, terraform.ts | + +--- + +## Domains Analyzed + +| Domain | Files | Status | Pass | Warn | Fail | +|--------|-------|--------|------|------|------| +| types | 1 | audited | 0 | 1 | 0 | +| shared | 8 | audited | 5 | 0 | 0 | +| infrastructure | 10 | audited | 3 | 1 | 2 | +| db | 15 | audited | 6 | 1 | 0 | +| domain | 44 | audited | 9 | 4 | 2 | +| graph | 10 | audited | 6 | 0 | 0 | +| ast-analysis | 14 | audited | 4 | 2 | 1 | +| extractors | 12 | audited | 9 | 1 | 0 | +| features | 20 | audited | 7 | 5 | 0 | +| presentation | 30 | audited | 9 | 4 | 0 | +| cli | 48 | audited | 1 | 0 | 0 | +| mcp | 40 | audited | 2 | 1 | 0 | +| crates | 24 | not audited | -- | -- | -- | +| scripts | 25 | not audited | -- | -- | -- | +| tests | 140 | not audited | -- | -- | -- | + +> 88 of 464 files audited (19%). Focus was on src/ production code. Rust crates, scripts, and tests were excluded from audit scope. + +--- + +## Pipeline Freshness + +**Main at RECON:** 0435c41 +**Main at CLOSE:** 5bf0a8b +**Commits behind:** 4 (cumulative across pipeline) +**Overall staleness:** fresh + +### Drift Events (from drift-report.json) + +| Phase | Timestamp | Main SHA (then) | Commits Behind | Changed Files | Impacted Targets | Staleness | Action | +|-------|-----------|-----------------|----------------|---------------|------------------|-----------|--------| +| GAUNTLET | 2026-03-25T04:00Z | 0435c41 | 0 | 0 | 0 | none | continue | +| SYNC | 2026-03-25T06:00Z | 9107ec2 | 3 | 7 (CHANGELOG, README, Cargo.toml, BACKLOG, ROADMAP, package.json, lock) | 0 | low | continue | +| CLOSE | 2026-03-25T23:33Z | 5bf0a8b | 1 | 3 (DEPENDENCIES.json, package.json, lock) | 0 | none | continue | + +### Stale Targets + +None. All drift events involved non-source files (documentation, configs, generated files). No audited targets were modified on main during the pipeline. + +--- + +## Recommendations for Next Run + +1. **Fix the DB migration gap.** The `role` column error prevented metrics collection during RECON. Ensure `codegraph build` always produces a schema that supports `codegraph stats --json` and `codegraph roles`. This blocks accurate baseline capture. + +2. **Audit Rust crates next.** The top complexity hot spots are all in `crates/codegraph-core/src/extractors/*.rs`. The `walk_node_depth` functions across 7 language extractors have Halstead bugs 2.4-5.5 and MI 8-20. These are the highest-risk code in the codebase. + +3. **Address Leiden algorithm complexity.** `makePartition` (bugs=6.257, MI=5.0) is the single worst function. It may benefit from decomposition despite being an algorithm implementation. + +4. **Tackle remaining SLOC warnings.** parser.ts (672 SLOC) and resolve.ts (585 SLOC) still exceed thresholds. The empty-catch violations are fixed but the file sizes remain. Consider splitting in a follow-up run. + +5. **Validate RECON file lists.** Batch 9 referenced non-existent files. Add file existence validation to the RECON batch planner. + +6. **Add `codegraph path` file-level support.** The inability to query file-to-file shortest path limited sync-phase analysis. Consider adding `--file` flag support to the `path` command. + +7. **Run with `--engine wasm` comparison.** This audit used native engine only. A follow-up run comparing WASM vs native metric differences would validate engine parity. + +--- + +## Artifacts + +All pipeline artifacts are stored in `.codegraph/titan/`: + +| Artifact | Description | +|----------|-------------| +| `titan-state.json` | Full pipeline state: domains, batches, priority queue, execution progress | +| `GLOBAL_ARCH.md` | Architecture document with domain map and dependency flow | +| `gauntlet.ndjson` | 88 per-target audit records (pillar verdicts, metrics, violations) | +| `gauntlet-summary.json` | Audit totals: pass/warn/fail/decompose counts | +| `sync.json` | Execution plan: 5 phases, 4 clusters, 2 abstractions, dependency order | +| `arch-snapshot.json` | Pre-forge architectural snapshot (structure cohesion by directory) | +| `drift-report.json` | 3 drift assessments (gauntlet, sync, close) — all clean | +| `gate-log.ndjson` | 5 gate validation records — all PASS | +| `issues.ndjson` | 5 issues: 1 bug, 3 limitations, 1 process suggestion | +| `close-summary.json` | Machine-readable close summary with metrics and PR URLs | +| `titan-baseline.db` | Pre-pipeline SQLite graph snapshot (rollback point) | From d3c9f5b48d3121d9bb0ef9269afd57219934c866 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Wed, 25 Mar 2026 23:54:04 -0600 Subject: [PATCH 7/9] docs: add titan-close to skill examples and update pipeline docs - Add titan-close/SKILL.md to docs/examples/claude-code-skills/ - Update README pipeline diagram, skills table, and artifacts table - Sync titan-run SKILL.md with latest changes (auto-worktree, close dispatch) --- docs/examples/claude-code-skills/README.md | 16 +- .../claude-code-skills/titan-close/SKILL.md | 564 ++++++++++++++++++ .../claude-code-skills/titan-run/SKILL.md | 67 +-- 3 files changed, 593 insertions(+), 54 deletions(-) create mode 100644 docs/examples/claude-code-skills/titan-close/SKILL.md diff --git a/docs/examples/claude-code-skills/README.md b/docs/examples/claude-code-skills/README.md index 5c37d429..63fce698 100644 --- a/docs/examples/claude-code-skills/README.md +++ b/docs/examples/claude-code-skills/README.md @@ -21,9 +21,11 @@ A single AI agent cannot hold an entire large codebase in context. The Titan Par │ ├─→ /titan-sync → sync.json (execution plan) │ - └─→ /titan-forge → code changes + commits (loops phases) - │ - └─→ /titan-gate (validates each commit) + ├─→ /titan-forge → code changes + commits (loops phases) + │ │ + │ └─→ /titan-gate (validates each commit) + │ + └─→ /titan-close → PRs + titan-report.md /titan-reset (escape hatch: clean up everything) ``` @@ -38,6 +40,7 @@ A single AI agent cannot hold an entire large codebase in context. The Titan Par | `/titan-sync` | GLOBAL SYNC | Dependency clusters, code ownership, shared abstractions, ordered execution plan with logical commits | `sync.json` | | `/titan-forge` | FORGE | Executes the sync plan — makes code changes, validates with `/titan-gate`, commits, advances state. One phase per invocation | `titan-state.json` | | `/titan-gate` | STATE MACHINE | `codegraph check --staged --cycles --blast-radius 30 --boundaries` + lint/build/test. Snapshot restore on failure | `gate-log.ndjson` | +| `/titan-close` | CLOSE | Splits branch commits into focused PRs, captures final metrics, generates comprehensive audit report with before/after comparison | `titan-report-*.md` | | `/titan-reset` | ESCAPE HATCH | Restores baseline snapshot, deletes all artifacts and snapshots, rebuilds graph | — | ## Installation @@ -118,11 +121,14 @@ All artifacts are written to `.codegraph/titan/` (6 files, no redundancy): | `gauntlet-summary.json` | JSON | GAUNTLET | RUN, SYNC, GATE | | `sync.json` | JSON | SYNC | RUN, FORGE (diff review), GATE | | `arch-snapshot.json` | JSON | RUN (pre-forge) | GATE (architectural comparison) | -| `gate-log.ndjson` | NDJSON | GATE | RUN, Audit trail | +| `gate-log.ndjson` | NDJSON | GATE | RUN, CLOSE, Audit trail | +| `drift-report.json` | JSON | GAUNTLET, SYNC, CLOSE | RUN, CLOSE | +| `close-summary.json` | JSON | CLOSE | — | +| `generated/titan/titan-report-*.md` | Markdown | CLOSE | — (committed to repo) | NDJSON format (one JSON object per line) means partial results survive crashes mid-batch. -**Tip:** Add `.codegraph/titan/` to `.gitignore` — these are ephemeral analysis artifacts, not source code. +**Tip:** Add `.codegraph/titan/` to `.gitignore` — these are ephemeral analysis artifacts. The final report (`generated/titan/`) is tracked in git. ## Snapshots diff --git a/docs/examples/claude-code-skills/titan-close/SKILL.md b/docs/examples/claude-code-skills/titan-close/SKILL.md new file mode 100644 index 00000000..299bc447 --- /dev/null +++ b/docs/examples/claude-code-skills/titan-close/SKILL.md @@ -0,0 +1,564 @@ +--- +name: titan-close +description: Split branch commits into focused PRs, compile issue tracker, generate final report with before/after metrics (Titan Paradigm Phase 5) +argument-hint: <--dry-run to preview without creating PRs> +allowed-tools: Bash, Read, Write, Glob, Grep, Edit +--- + +# Titan CLOSE — PR Splitting & Final Report + +You are running the **CLOSE** phase of the Titan Paradigm. + +Your goal: analyze all commits on the current branch, split them into focused PRs for easier review, compile the issue tracker from all phases, capture final metrics, and generate a comprehensive audit report. + +> **Context budget:** This phase reads artifacts and git history. Keep codegraph queries targeted — only for final metrics comparison. + +**Dry-run mode:** If `$ARGUMENTS` contains `--dry-run`, preview the PR split plan and report without creating PRs or pushing branches. + +--- + +## Step 0 — Pre-flight: find and consolidate the Titan session + +1. **Locate the Titan session.** All prior phases (RECON → GAUNTLET → SYNC → GATE) may have run across different worktrees or branches. You need to consolidate their work. + + ```bash + git worktree list + ``` + + For each worktree, check for Titan artifacts: + ```bash + ls /.codegraph/titan/titan-state.json 2>/dev/null + ``` + + Also check branches (including remote): + ```bash + git branch -a --list '*titan*' + git branch -a --list '*refactor/*' + ``` + + **Decision logic:** + - **Found exactly one worktree/branch with `titan-state.json`:** Read its `currentPhase`. If it's `"sync"` or later, this is the right session. Merge its branch into your worktree. + - **Found a worktree but `currentPhase` is earlier than expected (e.g., `"recon"` or `"gauntlet"`):** The pipeline may not be complete. Keep searching — there may be a more advanced worktree. If nothing better found, ask the user: "Found Titan state at `` with phase ``. The pipeline appears incomplete. Continue anyway, or should I look elsewhere?" + - **Found multiple worktrees with `titan-state.json`:** List them all with `currentPhase`, `lastUpdated`, and branch name. The Titan pipeline may have been split across worktrees (RECON in one, GAUNTLET in another). Merge them in phase order into your worktree. If there's ambiguity (e.g., two worktrees at the same phase), ask the user. + - **Found branches but no worktrees:** Merge the titan branch(es) in phase order: `git merge --no-edit` + - **Found nothing:** Stop: "No Titan session found in any worktree or branch. Run `/titan-recon` first." + +2. **Ensure worktree isolation:** + ```bash + git rev-parse --show-toplevel && git worktree list + ``` + If not in a worktree, stop: "Run `/worktree` first." + +3. **Sync with main:** + ```bash + git fetch origin main && git merge origin/main --no-edit + ``` + If there are merge conflicts, stop: "Merge conflict detected. Resolve conflicts and re-run `/titan-close`." + +4. **Load artifacts.** Read: + - `.codegraph/titan/titan-state.json` — session state, baseline metrics, progress + - `.codegraph/titan/GLOBAL_ARCH.md` — architecture document + - `.codegraph/titan/gauntlet.ndjson` — full per-target audit data (pillar verdicts, metrics, violations) + - `.codegraph/titan/gauntlet-summary.json` — audit result totals + - `.codegraph/titan/sync.json` — execution plan (commit grouping) + - `.codegraph/titan/gate-log.ndjson` — validation history (may not exist if gate wasn't run) + - `.codegraph/titan/issues.ndjson` — issue tracker from all phases + - `.codegraph/titan/arch-snapshot.json` — pre-forge architectural snapshot (communities, structure, drift). Use for before/after comparison in the Metrics section. May not exist if capture failed. + - `.codegraph/titan/drift-report.json` — cumulative drift reports from all phases. May not exist if no drift was detected. + + If `titan-state.json` is missing after the search, stop: "No Titan session found. Run `/titan-recon` first." + + > **When called from `/titan-run`:** The orchestrator already ensured worktree isolation, synced with main, and all artifacts are in the current worktree. Steps 0.1–0.3 (worktree search, isolation check, main sync) can be skipped if the orchestrator tells you to skip them. + +5. **Detect version.** Extract from `package.json`: + ```bash + node -e "console.log(require('./package.json').version)" + ``` + +--- + +## Step 1 — Drift detection: final staleness assessment + +CLOSE is the last phase — it must assess the full pipeline's freshness before generating the report. + +1. **Compare main SHA:** + ```bash + git rev-parse origin/main + ``` + Compare against `titan-state.json → mainSHA`. + +2. **If main has advanced**, calculate full drift: + ```bash + git rev-list --count ..origin/main + git diff --name-only ..origin/main + ``` + +3. **Read all prior drift reports** from `.codegraph/titan/drift-report.json` (a JSON array of entries, one per phase that detected drift). This shows the cumulative drift across the pipeline. + +4. **Assess overall pipeline freshness:** + + | Level | Condition | Action | + |-------|-----------|--------| + | **fresh** | mainSHA matches current main, no drift reports with severity > low | Generate report normally | + | **acceptable** | Some drift detected but phases handled it (re-audited stale targets) | Generate report — note drift in Executive Summary | + | **stale** | Significant unaddressed drift: >10 commits behind, >20% of audited targets changed on main since audit | **Warn user:** "Pipeline results are partially stale. N targets were modified on main after being audited. The report will flag these. Consider re-running `/titan-gauntlet` for affected targets before finalizing." | + | **expired** | >50 commits behind OR >50% of targets changed OR architecture-level changes (new directories in src/) | **Stop:** "Pipeline results are too stale to produce a reliable report. Run `/titan-recon` for a fresh baseline." | + +5. **Write final drift assessment** to the drift report (same schema, `"detectedBy": "close"`). + +6. **Include drift summary in the report.** The final report's Executive Summary and Recommendations sections must reflect any staleness. Stale targets should be called out in a "Staleness Warnings" subsection. + +--- + +## Step 2 — Collect branch commit history + +```bash +git log main..HEAD --oneline --no-merges +git log main..HEAD --format="%H %s" --no-merges +``` + +Extract: total commit count, commit messages, SHAs. If zero commits, stop: "No commits on this branch. Nothing to close." + +For each commit, get the files changed: +```bash +git diff-tree --no-commit-id --name-only -r +``` + +--- + +## Step 3 — Classify commits into PR groups + +Analyze commit messages and changed files to group commits into **focused PRs**. Each PR should address a single concern for easier review. + +### Grouping strategy (in priority order) + +Use `sync.json` execution phases as the primary guide if available: + +1. **Dead code cleanup** — commits removing dead symbols + - PR title: `chore: remove dead code identified by Titan audit` +2. **Shared abstractions** — commits extracting interfaces/utilities + - PR title: `refactor: extract from ` +3. **Cycle breaks** — commits resolving circular dependencies + - PR title: `refactor: break circular dependency in ` +4. **Decompositions** — commits splitting complex functions/files + - PR title: `refactor: decompose in ` +5. **Quality fixes** — commits addressing fail-level violations + - Group by domain: `fix: address quality issues in ` +6. **Warning improvements** — commits addressing warn-level issues + - Group by domain: `refactor: improve code quality in ` + +### Fallback grouping (if no sync.json) + +Group by changed file paths — commits touching the same directory/domain go together. Use commit message prefixes (`fix:`, `refactor:`, `chore:`) as secondary signals. + +### Rules for grouping +- A PR should touch **one domain** where possible +- A PR should address **one concern** (don't mix dead code removal with refactors) +- Order PRs so dependencies come first (if PR B depends on PR A's changes, A merges first) +- Each PR must be independently reviewable — no PR should break the build alone +- If a commit touches files across multiple concerns, assign it to the primary concern and note the cross-cutting nature in the PR description + +Record the grouping plan: +```json +[ + { + "pr": 1, + "title": "...", + "concern": "dead_code|abstraction|cycle_break|decomposition|quality_fix|warning", + "domain": "", + "commits": ["", ""], + "files": ["", ""], + "dependsOn": [], + "description": "..." + } +] +``` + +--- + +## Step 4 — Capture final metrics + +Rebuild the graph and collect current metrics: + +```bash +codegraph build +codegraph stats --json +codegraph complexity --health --above-threshold -T --json --limit 50 +codegraph roles --role dead -T --json +codegraph roles --role core -T --json +codegraph cycles --json +``` + +Extract: `totalNodes`, `totalEdges`, `totalFiles`, `qualityScore`, functions above threshold, dead symbol count, core symbol count, cycle count. + +Also get the worst offenders for comparison: +```bash +codegraph complexity --health --sort effort -T --json --limit 10 +codegraph complexity --health --sort bugs -T --json --limit 10 +codegraph complexity --health --sort mi -T --json --limit 10 +``` + +### Architecture comparison (if arch-snapshot.json exists) + +If `.codegraph/titan/arch-snapshot.json` was captured before forge, compare its `structure` data against current `codegraph structure --depth 2 --json` output. Report cohesion changes per directory (improved / degraded / unchanged). Include in the "Metrics: Before & After" section of the report. + +### Compute deltas + +Compare final metrics against `titan-state.json` baseline: + +| Metric | Baseline | Final | Delta | +|--------|----------|-------|-------| +| Quality Score | from state | from stats | +/- | +| Functions above threshold | from state | from complexity | +/- | +| Dead symbols | from state | from roles | +/- | +| Cycles | from state | from cycles | +/- | +| Total nodes | from state | from stats | +/- | + +--- + +## Step 5 — Compile the issue tracker + +Read `.codegraph/titan/issues.ndjson`. Each line is a JSON object: + +```json +{"phase": "recon|gauntlet|sync|gate", "timestamp": "ISO 8601", "severity": "bug|limitation|suggestion", "category": "codegraph|tooling|process|codebase", "description": "...", "context": "optional detail"} +``` + +Group issues by category and severity. Summarize: +- **Codegraph bugs:** issues with codegraph itself (wrong output, crashes, missing features) +- **Tooling issues:** problems with the Titan pipeline or other tools +- **Process notes:** suggestions for improving the Titan workflow +- **Codebase observations:** structural concerns beyond what the audit covered + +--- + +## Step 6 — Compile the gate log + +Read `.codegraph/titan/gate-log.ndjson`. Summarize: +- Total gate runs +- Pass / Warn / Fail counts +- Rollbacks triggered +- Most common failure reasons + +--- + +## Step 7 — Generate the report + +### Report path + +``` +generated/titan/titan-report-v-T