diff --git a/src/ast-analysis/visitor.ts b/src/ast-analysis/visitor.ts index f2e72dd23..a47ddb672 100644 --- a/src/ast-analysis/visitor.ts +++ b/src/ast-analysis/visitor.ts @@ -152,24 +152,18 @@ function collectResults(visitors: Visitor[]): WalkResults { return results; } -/** - * Walk an AST root with multiple visitors in a single DFS pass. - * - * @param {object} rootNode - tree-sitter root node to walk - * @param {Visitor[]} visitors - array of visitor objects - * @param {string} langId - language identifier - * @param {object} [options] - * @param {Set} [options.functionNodeTypes] - set of node types that are function boundaries - * @param {Set} [options.nestingNodeTypes] - set of node types that increase nesting depth - * @param {function} [options.getFunctionName] - (funcNode) => string|null - * @returns {object} Map of visitor.name → finish() result - */ -export function walkWithVisitors( - rootNode: TreeSitterNode, - visitors: Visitor[], - langId: string, - options: WalkOptions = {}, -): WalkResults { +interface WalkState { + visitors: Visitor[]; + allFuncTypes: Set; + nestingNodeTypes: Set; + getFunctionName: (node: TreeSitterNode) => string | null; + context: VisitorContext; + scopeStack: ScopeEntry[]; + skipDepths: Map; +} + +/** Build walk state from options: resolve function types, init visitors, create shared context. */ +function buildWalkState(visitors: Visitor[], langId: string, options: WalkOptions): WalkState { const { functionNodeTypes = new Set(), nestingNodeTypes = new Set(), @@ -179,7 +173,6 @@ export function walkWithVisitors( const allFuncTypes = mergeFunctionNodeTypes(visitors, functionNodeTypes); initVisitors(visitors, langId); - // Shared context object (mutated during walk) const scopeStack: ScopeEntry[] = []; const context: VisitorContext = { nestingLevel: 0, @@ -188,49 +181,86 @@ export function walkWithVisitors( scopeStack, }; - const skipDepths = new Map(); + return { + visitors, + allFuncTypes, + nestingNodeTypes, + getFunctionName, + context, + scopeStack, + skipDepths: new Map(), + }; +} - function walk(node: TreeSitterNode | null, depth: number): void { - if (!node) return; - if (depth > MAX_WALK_DEPTH) { - debug(`walkWithVisitors: AST depth limit (${MAX_WALK_DEPTH}) hit — subtree truncated`); - return; - } +/** Single DFS step: dispatch enter/exit hooks and recurse into children. */ +function walkNode(state: WalkState, node: TreeSitterNode | null, depth: number): void { + if (!node) return; + if (depth > MAX_WALK_DEPTH) { + debug(`walkWithVisitors: AST depth limit (${MAX_WALK_DEPTH}) hit — subtree truncated`); + return; + } - const type = node.type; - const isFuncBoundary = allFuncTypes.has(type); - let funcName: string | null = null; + const { + visitors, + allFuncTypes, + nestingNodeTypes, + getFunctionName, + context, + scopeStack, + skipDepths, + } = state; + const type = node.type; + const isFuncBoundary = allFuncTypes.has(type); + let funcName: string | null = null; - if (isFuncBoundary) { - funcName = getFunctionName(node); - context.currentFunction = node; - scopeStack.push({ funcName, funcNode: node, params: new Map(), locals: new Map() }); - dispatchEnterFunction(visitors, skipDepths, node, funcName, context, depth); - } + if (isFuncBoundary) { + funcName = getFunctionName(node); + context.currentFunction = node; + scopeStack.push({ funcName, funcNode: node, params: new Map(), locals: new Map() }); + dispatchEnterFunction(visitors, skipDepths, node, funcName, context, depth); + } - dispatchEnterNode(visitors, skipDepths, node, context, depth); + dispatchEnterNode(visitors, skipDepths, node, context, depth); - const addsNesting = nestingNodeTypes.has(type); - if (addsNesting) context.nestingLevel++; + const addsNesting = nestingNodeTypes.has(type); + if (addsNesting) context.nestingLevel++; - for (let i = 0; i < node.childCount; i++) { - walk(node.child(i), depth + 1); - } + for (let i = 0; i < node.childCount; i++) { + walkNode(state, node.child(i), depth + 1); + } - if (addsNesting) context.nestingLevel--; + if (addsNesting) context.nestingLevel--; - dispatchExitNode(visitors, skipDepths, node, context, depth); - clearSkipFlags(skipDepths, visitors.length, depth); + dispatchExitNode(visitors, skipDepths, node, context, depth); + clearSkipFlags(skipDepths, visitors.length, depth); - if (isFuncBoundary) { - dispatchExitFunction(visitors, skipDepths, node, funcName, context, depth); - scopeStack.pop(); - context.currentFunction = - scopeStack.length > 0 ? scopeStack[scopeStack.length - 1]!.funcNode : null; - } + if (isFuncBoundary) { + dispatchExitFunction(visitors, skipDepths, node, funcName, context, depth); + scopeStack.pop(); + context.currentFunction = + scopeStack.length > 0 ? scopeStack[scopeStack.length - 1]!.funcNode : null; } +} - walk(rootNode, 0); - +/** + * Walk an AST root with multiple visitors in a single DFS pass. + * + * @param {object} rootNode - tree-sitter root node to walk + * @param {Visitor[]} visitors - array of visitor objects + * @param {string} langId - language identifier + * @param {object} [options] + * @param {Set} [options.functionNodeTypes] - set of node types that are function boundaries + * @param {Set} [options.nestingNodeTypes] - set of node types that increase nesting depth + * @param {function} [options.getFunctionName] - (funcNode) => string|null + * @returns {object} Map of visitor.name → finish() result + */ +export function walkWithVisitors( + rootNode: TreeSitterNode, + visitors: Visitor[], + langId: string, + options: WalkOptions = {}, +): WalkResults { + const state = buildWalkState(visitors, langId, options); + walkNode(state, rootNode, 0); return collectResults(visitors); } diff --git a/src/ast-analysis/visitors/complexity-visitor.ts b/src/ast-analysis/visitors/complexity-visitor.ts index ddddd8276..2545e6c84 100644 --- a/src/ast-analysis/visitors/complexity-visitor.ts +++ b/src/ast-analysis/visitors/complexity-visitor.ts @@ -203,6 +203,43 @@ function classifyNode( if (cRules.caseNodes.has(type) && node.childCount > 0) acc.cyclomatic++; } +/** + * Compute the effective nesting level for complexity classification. + * + * In file-level mode, funcDepth starts at 0 for the active function. + * In function-level mode, funcDepth starts at 1 for the root function + * (since enterFunction always increments it). Subtract 1 so the root + * function contributes 0 nesting and each nested level adds +1, matching + * the Rust engine's behavior. + */ +function computeEffectiveNesting( + contextNesting: number, + funcDepth: number, + nestingAdjust: number, + fileLevelWalk: boolean, +): number { + const funcNesting = fileLevelWalk ? funcDepth : Math.max(0, funcDepth - 1); + return contextNesting + funcNesting - nestingAdjust; +} + +/** + * If this node is an else-if that the walker treats as a nesting node but + * the DFS engine would NOT increment nesting for, track it so children see + * the correct (non-inflated) nesting level. + */ +function trackElseIfNestingAdjust( + node: TreeSitterNode, + cRules: AnyRules, + nestingAdjust: number, + adjustNodeIds: Set, +): number { + if (cRules.nestingNodes.has(node.type) && isElseIfNonNesting(node, node.type, cRules)) { + adjustNodeIds.add(node.id); + return nestingAdjust + 1; + } + return nestingAdjust; +} + export function createComplexityVisitor( cRules: AnyRules, hRules?: AnyRules | null, @@ -265,23 +302,14 @@ export function createComplexityVisitor( enterNode(node: TreeSitterNode, context: VisitorContext): EnterNodeResult | undefined { if (fileLevelWalk && !activeFuncNode) return; - // In file-level mode, funcDepth starts at 0 for the active function. - // In function-level mode, funcDepth starts at 1 for the root function - // (since enterFunction always increments it). Nested functions add +1 - // each level — subtract 1 so the root function contributes 0 nesting - // and each nested level adds +1, matching the Rust engine's behavior. - const funcNesting = fileLevelWalk ? funcDepth : Math.max(0, funcDepth - 1); - const nestingLevel = context.nestingLevel + funcNesting - nestingAdjust; + const nestingLevel = computeEffectiveNesting( + context.nestingLevel, + funcDepth, + nestingAdjust, + fileLevelWalk, + ); classifyNode(node, nestingLevel, cRules, hRules, acc); - - // If this is an else-if if_statement that the walker will treat as a - // nesting node (incrementing context.nestingLevel for children), but - // the DFS walk would NOT increment nesting for, compensate by bumping - // nestingAdjust so children see the correct level. - if (cRules.nestingNodes.has(node.type) && isElseIfNonNesting(node, node.type, cRules)) { - nestingAdjust++; - adjustNodeIds.add(node.id); - } + nestingAdjust = trackElseIfNestingAdjust(node, cRules, nestingAdjust, adjustNodeIds); }, exitNode(node: TreeSitterNode): void { diff --git a/src/ast-analysis/visitors/dataflow-visitor.ts b/src/ast-analysis/visitors/dataflow-visitor.ts index b062b15c3..6b0e27a7b 100644 --- a/src/ast-analysis/visitors/dataflow-visitor.ts +++ b/src/ast-analysis/visitors/dataflow-visitor.ts @@ -287,6 +287,28 @@ function handleAssignment( } } +/** Unwrap argument wrapper and spread nodes to get the core argument node. */ +function unwrapArg( + arg: TreeSitterNode, + rules: AnyRules, +): { unwrapped: TreeSitterNode; raw: TreeSitterNode } { + let raw = arg; + if (rules.argumentWrapperType && arg.type === rules.argumentWrapperType) { + raw = arg.namedChildren[0] || arg; + } + const unwrapped = + rules.spreadType && raw.type === rules.spreadType ? raw.namedChildren[0] || raw : raw; + return { unwrapped, raw }; +} + +/** Resolve the tracked name for a call argument (identifier or member receiver). */ +function resolveArgTrackedName(node: TreeSitterNode, rules: AnyRules): string | null { + const argName = isIdent(node.type, rules) ? node.text : null; + const argMember = + rules.memberNode && node.type === rules.memberNode ? memberReceiver(node, rules) : null; + return argName || argMember; +} + function handleCallExpr( node: TreeSitterNode, rules: AnyRules, @@ -299,24 +321,14 @@ function handleCallExpr( if (!callee || !argsNode || !scope?.funcName) return; let argIndex = 0; - for (let arg of argsNode.namedChildren) { - if (rules.argumentWrapperType && arg.type === rules.argumentWrapperType) { - arg = arg.namedChildren[0] || arg; - } - const unwrapped = - rules.spreadType && arg.type === rules.spreadType ? arg.namedChildren[0] || arg : arg; + for (const arg of argsNode.namedChildren) { + const { unwrapped, raw } = unwrapArg(arg, rules); if (!unwrapped) { argIndex++; continue; } - const argName = isIdent(unwrapped.type, rules) ? unwrapped.text : null; - const argMember = - rules.memberNode && unwrapped.type === rules.memberNode - ? memberReceiver(unwrapped, rules) - : null; - const trackedName = argName || argMember; - + const trackedName = resolveArgTrackedName(unwrapped, rules); if (trackedName) { const binding = findBinding(trackedName, scopeStack); if (binding) { @@ -327,7 +339,7 @@ function handleCallExpr( argName: trackedName, binding, confidence: bindingConfidence(binding), - expression: truncate(arg.text), + expression: truncate(raw.text), line: node.startPosition.row + 1, }); } @@ -336,17 +348,11 @@ function handleCallExpr( } } -function handleExprStmtMutation( - node: TreeSitterNode, +/** Resolve the method name and receiver from a call expression node. */ +function resolveMutationCallParts( + expr: TreeSitterNode, rules: AnyRules, - scopeStack: ScopeEntry[], - mutations: DataflowMutation[], - isCallNode: (t: string) => boolean, -): void { - if (rules.mutatingMethods.size === 0) return; - const expr = node.namedChildren[0]; - if (!expr || !isCall(expr, isCallNode)) return; - +): { methodName: string | null; receiver: string | null } { let methodName: string | null = null; let receiver: string | null = null; @@ -366,6 +372,21 @@ function handleExprStmtMutation( } } + return { methodName, receiver }; +} + +function handleExprStmtMutation( + node: TreeSitterNode, + rules: AnyRules, + scopeStack: ScopeEntry[], + mutations: DataflowMutation[], + isCallNode: (t: string) => boolean, +): void { + if (rules.mutatingMethods.size === 0) return; + const expr = node.namedChildren[0]; + if (!expr || !isCall(expr, isCallNode)) return; + + const { methodName, receiver } = resolveMutationCallParts(expr, rules); if (!methodName || !rules.mutatingMethods.has(methodName)) return; const scope = currentScope(scopeStack); diff --git a/src/db/repository/native-repository.ts b/src/db/repository/native-repository.ts index 90e1d0062..a822c6912 100644 --- a/src/db/repository/native-repository.ts +++ b/src/db/repository/native-repository.ts @@ -163,6 +163,61 @@ function toComplexityMetrics(r: NativeComplexityMetrics): ComplexityMetrics { }; } +// ── fnDeps conversion helpers ──────────────────────────────────────────── + +/** + * Convert a native transitive-callers array (array of `{ depth, callers[] }`) + * into the JS `Record` shape the Repository interface expects. + */ +function mapTransitiveCallers(groups: any[]): Record { + const result: Record = {}; + for (const group of groups ?? []) { + result[group.depth] = (group.callers ?? []).map( + (c: any): FnDepsNode => ({ + name: c.name, + kind: c.kind, + file: c.file, + line: c.line ?? null, + }), + ); + } + return result; +} + +/** + * Convert a single raw native fnDeps entry into the typed `FnDepsEntry` shape. + * Handles napi-rs camelCase → Repository snake_case field aliasing. + */ +function mapFnDepsEntry(entry: any): FnDepsEntry { + return { + name: entry.name, + kind: entry.kind, + file: entry.file, + line: entry.line ?? null, + endLine: entry.endLine ?? entry.end_line ?? null, + role: entry.role ?? null, + fileHash: entry.fileHash ?? entry.file_hash ?? null, + callees: (entry.callees ?? []).map( + (c: any): FnDepsNode => ({ + name: c.name, + kind: c.kind, + file: c.file, + line: c.line ?? null, + }), + ), + callers: (entry.callers ?? []).map( + (c: any): FnDepsCallerNode => ({ + name: c.name, + kind: c.kind, + file: c.file, + line: c.line ?? null, + viaHierarchy: c.viaHierarchy ?? c.via_hierarchy ?? undefined, + }), + ), + transitiveCallers: mapTransitiveCallers(entry.transitiveCallers), + }; +} + // ── NativeRepository ──────────────────────────────────────────────────── export class NativeRepository extends Repository { @@ -485,46 +540,7 @@ export class NativeRepository extends Repository { // to JS format (transitiveCallers as Record) return { name: raw.name, - results: raw.results.map((entry: any): FnDepsEntry => { - const transitiveCallers: Record = {}; - for (const group of entry.transitiveCallers ?? []) { - transitiveCallers[group.depth] = (group.callers ?? []).map( - (c: any): FnDepsNode => ({ - name: c.name, - kind: c.kind, - file: c.file, - line: c.line ?? null, - }), - ); - } - return { - name: entry.name, - kind: entry.kind, - file: entry.file, - line: entry.line ?? null, - endLine: entry.endLine ?? entry.end_line ?? null, - role: entry.role ?? null, - fileHash: entry.fileHash ?? entry.file_hash ?? null, - callees: (entry.callees ?? []).map( - (c: any): FnDepsNode => ({ - name: c.name, - kind: c.kind, - file: c.file, - line: c.line ?? null, - }), - ), - callers: (entry.callers ?? []).map( - (c: any): FnDepsCallerNode => ({ - name: c.name, - kind: c.kind, - file: c.file, - line: c.line ?? null, - viaHierarchy: c.viaHierarchy ?? c.via_hierarchy ?? undefined, - }), - ), - transitiveCallers, - }; - }), + results: raw.results.map(mapFnDepsEntry), }; } } diff --git a/src/domain/wasm-worker-pool.ts b/src/domain/wasm-worker-pool.ts index 4f4413318..75641b619 100644 --- a/src/domain/wasm-worker-pool.ts +++ b/src/domain/wasm-worker-pool.ts @@ -86,11 +86,14 @@ interface PendingJob { */ const WORKER_PARSE_TIMEOUT_MS = 60_000; -function deserializeResult(ser: SerializedExtractorOutput | null): ExtractorOutput | null { - if (!ser) return null; +/** Deserialize the core fields and metadata scalars from a serialized result. */ +function deserializeCoreFields( + ser: SerializedExtractorOutput, +): Pick & + Partial> { const typeMap = new Map(); for (const [k, v] of ser.typeMap) typeMap.set(k, v); - const out: ExtractorOutput = { + const out: ReturnType = { definitions: ser.definitions, calls: ser.calls, imports: ser.imports, @@ -106,6 +109,11 @@ function deserializeResult(ser: SerializedExtractorOutput | null): ExtractorOutp // {line, kind, name, text?, receiver?} shape — see engine.ts:822 where the // visitor output is cast the same way. if (ser.astNodes !== undefined) out.astNodes = ser.astNodes as unknown as ASTNodeRow[]; + return out; +} + +/** Deserialize the simple array binding fields (present when non-empty). */ +function deserializeBindingFields(ser: SerializedExtractorOutput, out: ExtractorOutput): void { if (ser.fnRefBindings?.length) out.fnRefBindings = ser.fnRefBindings; if (ser.paramBindings?.length) out.paramBindings = ser.paramBindings; if (ser.arrayElemBindings?.length) out.arrayElemBindings = ser.arrayElemBindings; @@ -117,6 +125,11 @@ function deserializeResult(ser: SerializedExtractorOutput | null): ExtractorOutp if (ser.objectPropBindings?.length) out.objectPropBindings = ser.objectPropBindings; if (ser.thisCallBindings?.length) out.thisCallBindings = ser.thisCallBindings; if (ser.newExpressions?.length) out.newExpressions = ser.newExpressions; + if (ser.callAssignments?.length) out.callAssignments = ser.callAssignments; +} + +/** Deserialize the Map-typed fields that require entry-by-entry reconstruction. */ +function deserializeMapFields(ser: SerializedExtractorOutput, out: ExtractorOutput): void { if (ser.definePropertyReceivers?.length) { const m = new Map(); for (const [k, v] of ser.definePropertyReceivers) m.set(k, v); @@ -127,7 +140,17 @@ function deserializeResult(ser: SerializedExtractorOutput | null): ExtractorOutp for (const [k, v] of ser.returnTypeMap) returnTypeMap.set(k, v); out.returnTypeMap = returnTypeMap; } - if (ser.callAssignments?.length) out.callAssignments = ser.callAssignments; +} + +function deserializeResult(ser: SerializedExtractorOutput | null): ExtractorOutput | null { + if (!ser) return null; + // deserializeCoreFields supplies all required ExtractorOutput fields (definitions, + // calls, imports, classes, exports, typeMap). The cast is safe: every required field + // is present in the returned Pick. If a new required field is added to ExtractorOutput, + // add it to deserializeCoreFields' return Pick and body to keep the cast honest. + const out = { ...deserializeCoreFields(ser) } as ExtractorOutput; + deserializeBindingFields(ser, out); + deserializeMapFields(ser, out); return out; } diff --git a/src/features/audit.ts b/src/features/audit.ts index 18ee81743..9f0d5183b 100644 --- a/src/features/audit.ts +++ b/src/features/audit.ts @@ -289,6 +289,46 @@ interface FileSymbol { signature?: string | null; } +/** Query callees, callers, and related test files for a node. */ +function querySymbolEdges( + db: BetterSqlite3Database, + nodeId: number, + noTests: boolean, +): { callees: SymbolRef[]; callers: SymbolRef[]; relatedTests: { file: string }[] } { + const callees = ( + db + .prepare( + `SELECT n.name, n.kind, n.file, n.line + FROM edges e JOIN nodes n ON e.target_id = n.id + WHERE e.source_id = ? AND e.kind = 'calls'`, + ) + .all(nodeId) as SymbolRef[] + ).map(toSymbolRef); + + let callers = ( + db + .prepare( + `SELECT n.name, n.kind, n.file, n.line + FROM edges e JOIN nodes n ON e.source_id = n.id + WHERE e.target_id = ? AND e.kind = 'calls'`, + ) + .all(nodeId) as SymbolRef[] + ).map(toSymbolRef); + if (noTests) callers = callers.filter((c) => !isTestFile(c.file)); + + const testCallerRows = db + .prepare( + `SELECT DISTINCT n.file FROM edges e JOIN nodes n ON e.source_id = n.id + WHERE e.target_id = ? AND e.kind = 'calls'`, + ) + .all(nodeId) as { file: string }[]; + const relatedTests = testCallerRows + .filter((r) => isTestFile(r.file)) + .map((r) => ({ file: r.file })); + + return { callees, callers, relatedTests }; +} + function enrichSymbol( db: BetterSqlite3Database, sym: FileSymbol, @@ -305,40 +345,9 @@ function enrichSymbol( const endLine = nodeRow?.end_line || null; const lineCount = endLine ? endLine - sym.line + 1 : null; - // Get callers/callees for this symbol - let callees: SymbolRef[] = []; - let callers: SymbolRef[] = []; - let relatedTests: { file: string }[] = []; - if (nodeId) { - callees = ( - db - .prepare( - `SELECT n.name, n.kind, n.file, n.line - FROM edges e JOIN nodes n ON e.target_id = n.id - WHERE e.source_id = ? AND e.kind = 'calls'`, - ) - .all(nodeId) as SymbolRef[] - ).map(toSymbolRef); - - callers = ( - db - .prepare( - `SELECT n.name, n.kind, n.file, n.line - FROM edges e JOIN nodes n ON e.source_id = n.id - WHERE e.target_id = ? AND e.kind = 'calls'`, - ) - .all(nodeId) as SymbolRef[] - ).map(toSymbolRef); - if (noTests) callers = callers.filter((c) => !isTestFile(c.file)); - - const testCallerRows = db - .prepare( - `SELECT DISTINCT n.file FROM edges e JOIN nodes n ON e.source_id = n.id - WHERE e.target_id = ? AND e.kind = 'calls'`, - ) - .all(nodeId) as { file: string }[]; - relatedTests = testCallerRows.filter((r) => isTestFile(r.file)).map((r) => ({ file: r.file })); - } + const { callees, callers, relatedTests } = nodeId + ? querySymbolEdges(db, nodeId, noTests) + : { callees: [], callers: [], relatedTests: [] }; const health = nodeId ? buildHealth(db, nodeId, thresholds) : defaultHealth(); const impact = nodeId diff --git a/src/features/boundaries.ts b/src/features/boundaries.ts index 0857ffc60..c35f3c10e 100644 --- a/src/features/boundaries.ts +++ b/src/features/boundaries.ts @@ -174,6 +174,21 @@ export function validateBoundaryConfig(config: unknown): { valid: boolean; error // ─── Preset Rule Generation ───────────────────────────────────────── +/** Collect the names of all modules assigned to layers outer than `layerIdx`. */ +function collectOuterModules( + modulesByLayer: Map, + layerIndex: Map, + layerIdx: number, +): string[] { + const outer: string[] = []; + for (const [otherLayer, otherModNames] of modulesByLayer) { + if (layerIndex.get(otherLayer)! > layerIdx) { + outer.push(...otherModNames); + } + } + return outer; +} + function generatePresetRules( modules: Map, presetName: string, @@ -181,8 +196,7 @@ function generatePresetRules( const preset = PRESETS[presetName]; if (!preset) return []; - const layers = preset.layers; - const layerIndex = new Map(layers.map((l, i) => [l, i])); + const layerIndex = new Map(preset.layers.map((l, i) => [l, i])); const modulesByLayer = new Map(); for (const [name, mod] of modules) { @@ -194,13 +208,7 @@ function generatePresetRules( const rules: BoundaryRule[] = []; for (const [layer, modNames] of modulesByLayer) { - const idx = layerIndex.get(layer)!; - const outerModules: string[] = []; - for (const [otherLayer, otherModNames] of modulesByLayer) { - if (layerIndex.get(otherLayer)! > idx) { - outerModules.push(...otherModNames); - } - } + const outerModules = collectOuterModules(modulesByLayer, layerIndex, layerIndex.get(layer)!); if (outerModules.length > 0) { for (const from of modNames) { rules.push({ from, notTo: outerModules }); diff --git a/src/features/cfg.ts b/src/features/cfg.ts index 382d32a68..a1a036166 100644 --- a/src/features/cfg.ts +++ b/src/features/cfg.ts @@ -136,6 +136,35 @@ async function initCfgParsers( return { parsers, getParserFn }; } +/** Parse source via WASM when no native CFG data is available. + * Returns the parsed tree or undefined on any read/parse failure. */ +function parseTreeForCfg( + relPath: string, + rootDir: string, + _langId: string, + parsers: unknown, + getParserFn: unknown, +): { rootNode: TreeSitterNode } | undefined { + const absPath = path.join(rootDir, relPath); + let code: string; + try { + code = fs.readFileSync(absPath, 'utf-8'); + } catch (e) { + debug(`cfg: cannot read ${relPath}: ${(e as Error).message}`); + return undefined; + } + + const parser = (getParserFn as (parsers: unknown, absPath: string) => unknown)(parsers, absPath); + if (!parser) return undefined; + + try { + return (parser as { parse: (code: string) => { rootNode: TreeSitterNode } }).parse(code); + } catch (e) { + debug(`cfg: parse failed for ${relPath}: ${(e as Error).message}`); + return undefined; + } +} + function getTreeAndLang( symbols: FileSymbols, relPath: string, @@ -152,28 +181,8 @@ function getTreeAndLang( if (!getParserFn) return null; langId = extToLang.get(ext); if (!langId || !CFG_RULES.has(langId)) return null; - - const absPath = path.join(rootDir, relPath); - let code: string; - try { - code = fs.readFileSync(absPath, 'utf-8'); - } catch (e) { - debug(`cfg: cannot read ${relPath}: ${(e as Error).message}`); - return null; - } - - const parser = (getParserFn as (parsers: unknown, absPath: string) => unknown)( - parsers, - absPath, - ); - if (!parser) return null; - - try { - tree = (parser as { parse: (code: string) => { rootNode: TreeSitterNode } }).parse(code); - } catch (e) { - debug(`cfg: parse failed for ${relPath}: ${(e as Error).message}`); - return null; - } + tree = parseTreeForCfg(relPath, rootDir, langId, parsers, getParserFn); + if (!tree) return null; } if (!langId) { diff --git a/src/features/check.ts b/src/features/check.ts index 99fe6f3a2..471e7492e 100644 --- a/src/features/check.ts +++ b/src/features/check.ts @@ -25,6 +25,22 @@ interface ParsedDiff { const HUNK_RE = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/; const NEW_FILE_RE = /^\+\+\+ b\/(.+)/; +/** Returns true if the diff line marks the old file as /dev/null (new-file creation). */ +function isDevNullSourceLine(line: string): boolean { + return line.startsWith('--- /dev/null'); +} + +/** Returns true if the diff line is a `---` source file header (not /dev/null). */ +function isSourceFileHeaderLine(line: string): boolean { + return line.startsWith('--- '); +} + +/** Extracts the new filename from a `+++ b/` diff header, or null. */ +function extractNewFileName(line: string): string | null { + const m = line.match(NEW_FILE_RE); + return m ? m[1]! : null; +} + function pushHunkRanges( line: string, currentFile: string, @@ -53,17 +69,17 @@ export function parseDiffOutput(diffOutput: string): ParsedDiff { let prevIsDevNull = false; for (const line of diffOutput.split('\n')) { - if (line.startsWith('--- /dev/null')) { + if (isDevNullSourceLine(line)) { prevIsDevNull = true; continue; } - if (line.startsWith('--- ')) { + if (isSourceFileHeaderLine(line)) { prevIsDevNull = false; continue; } - const fileMatch = line.match(NEW_FILE_RE); - if (fileMatch) { - currentFile = fileMatch[1]!; + const newFile = extractNewFileName(line); + if (newFile) { + currentFile = newFile; if (!changedRanges.has(currentFile)) changedRanges.set(currentFile, []); if (!oldRanges.has(currentFile)) oldRanges.set(currentFile, []); if (prevIsDevNull) newFiles.add(currentFile); diff --git a/src/features/communities.ts b/src/features/communities.ts index 40879c564..0244c976c 100644 --- a/src/features/communities.ts +++ b/src/features/communities.ts @@ -146,33 +146,42 @@ function analyzeDrift( // ─── Core Analysis ──────────────────────────────────────────────────── -export function communitiesData( - customDbPath?: string, - opts: { - functions?: boolean; - resolution?: number; - noTests?: boolean; - drift?: boolean; - json?: boolean; - config?: CodegraphConfig; - maxLevels?: number; - maxLocalPasses?: number; - refinementTheta?: number; - limit?: number; - offset?: number; - repo?: Repository; - } = {}, -): Record { +type CommunitiesDataOpts = { + functions?: boolean; + resolution?: number; + noTests?: boolean; + drift?: boolean; + json?: boolean; + config?: CodegraphConfig; + maxLevels?: number; + maxLocalPasses?: number; + refinementTheta?: number; + limit?: number; + offset?: number; + repo?: Repository; +}; + +/** Load dependency graph from the repo, then close the DB connection. */ +function loadCommunityGraph( + customDbPath: string | undefined, + opts: CommunitiesDataOpts, +): CodeGraph { const { repo, close } = openRepo(customDbPath, opts); - let graph: CodeGraph; try { - graph = buildDependencyGraph(repo, { + return buildDependencyGraph(repo, { fileLevel: !opts.functions, noTests: opts.noTests, }); } finally { close(); } +} + +export function communitiesData( + customDbPath?: string, + opts: CommunitiesDataOpts = {}, +): Record { + const graph = loadCommunityGraph(customDbPath, opts); if (graph.nodeCount === 0 || graph.edgeCount === 0) { return { diff --git a/src/features/sequence.ts b/src/features/sequence.ts index db2db7fb2..61362c8d2 100644 --- a/src/features/sequence.ts +++ b/src/features/sequence.ts @@ -10,6 +10,34 @@ import { FRAMEWORK_ENTRY_PREFIXES } from './structure.js'; // ─── Alias generation ──────────────────────────────────────────────── +/** Build aliases for a group of paths that share the same basename. + * Progressively adds parent dirs until all aliases are unique. */ +function resolveCollisionAliases(paths: string[], aliases: Map): void { + for (let depth = 2; depth <= 10; depth++) { + const trial = new Map(); + let allUnique = true; + const seen = new Set(); + + for (const p of paths) { + const parts = p.replace(/\.[^.]+$/, '').split('/'); + const alias = parts + .slice(-depth) + .join('_') + .replace(/[^a-zA-Z0-9_-]/g, '_'); + trial.set(p, alias); + if (seen.has(alias)) allUnique = false; + seen.add(alias); + } + + if (allUnique || depth === 10) { + for (const [p, alias] of trial) { + aliases.set(p, alias); + } + break; + } + } +} + function buildAliases(files: string[]): Map { const aliases = new Map(); const basenames = new Map(); @@ -26,29 +54,7 @@ function buildAliases(files: string[]): Map { aliases.set(paths[0]!, base); } else { // Collision — progressively add parent dirs until aliases are unique - for (let depth = 2; depth <= 10; depth++) { - const trial = new Map(); - let allUnique = true; - const seen = new Set(); - - for (const p of paths) { - const parts = p.replace(/\.[^.]+$/, '').split('/'); - const alias = parts - .slice(-depth) - .join('_') - .replace(/[^a-zA-Z0-9_-]/g, '_'); - trial.set(p, alias); - if (seen.has(alias)) allUnique = false; - seen.add(alias); - } - - if (allUnique || depth === 10) { - for (const [p, alias] of trial) { - aliases.set(p, alias); - } - break; - } - } + resolveCollisionAliases(paths, aliases); } } diff --git a/src/features/snapshot.ts b/src/features/snapshot.ts index f9e2d7b5c..8f612a6c4 100644 --- a/src/features/snapshot.ts +++ b/src/features/snapshot.ts @@ -25,6 +25,41 @@ interface SnapshotSaveOptions { force?: boolean; } +/** + * Atomically place `tmp` at `dest`. + * - force=true: renameSync (overwrites any existing file). + * - force=false: linkSync so EEXIST is detected atomically; then unlink tmp. + * Callers are responsible for cleaning up `tmp` on any thrown error. + */ +function atomicPlaceFile(tmp: string, dest: string, name: string, force?: boolean): void { + if (force) { + // renameSync overwrites atomically — the correct semantics for --force. + fs.renameSync(tmp, dest); + return; + } + + // Non-force path: linkSync fails atomically with EEXIST if dest exists, + // closing the TOCTOU window between existsSync above and the final + // placement. We then unlink the temp file; on POSIX and NTFS, link + // creates a second reference so tmp can safely be removed. + try { + fs.linkSync(tmp, dest); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'EEXIST') { + throw new ConfigError(`Snapshot "${name}" already exists. Use --force to overwrite.`); + } + throw err; + } + try { + fs.unlinkSync(tmp); + } catch (cleanupErr) { + // Best-effort — dest is already in place, so a leftover tmp file is + // harmless. Log at debug so repeated failures surface during + // troubleshooting without noising up normal operation. + debug(`snapshotSave: failed to remove temp file ${tmp}: ${cleanupErr}`); + } +} + export function snapshotSave( name: string, options: SnapshotSaveOptions = {}, @@ -73,31 +108,7 @@ export function snapshotSave( } try { - if (options.force) { - // renameSync overwrites atomically — the correct semantics for --force. - fs.renameSync(tmp, dest); - } else { - // Non-force path: linkSync fails atomically with EEXIST if dest exists, - // closing the TOCTOU window between existsSync above and the final - // placement. We then unlink the temp file; on POSIX and NTFS, link - // creates a second reference so tmp can safely be removed. - try { - fs.linkSync(tmp, dest); - } catch (err) { - if ((err as NodeJS.ErrnoException).code === 'EEXIST') { - throw new ConfigError(`Snapshot "${name}" already exists. Use --force to overwrite.`); - } - throw err; - } - try { - fs.unlinkSync(tmp); - } catch (cleanupErr) { - // Best-effort — dest is already in place, so a leftover tmp file is - // harmless. Log at debug so repeated failures surface during - // troubleshooting without noising up normal operation. - debug(`snapshotSave: failed to remove temp file ${tmp}: ${cleanupErr}`); - } - } + atomicPlaceFile(tmp, dest, name, options.force); } catch (err) { try { fs.unlinkSync(tmp); diff --git a/src/features/triage.ts b/src/features/triage.ts index e503261f1..95e7d1559 100644 --- a/src/features/triage.ts +++ b/src/features/triage.ts @@ -116,6 +116,33 @@ interface TriageDataOpts { repo?: Repository; } +interface ResolvedRiskConfig { + weights: RiskWeights; + riskOpts: { roleWeights?: Record; defaultRoleWeight?: number }; +} + +/** Resolve risk weights and role-weight options from config + opts overrides. */ +function resolveRiskConfig(opts: TriageDataOpts): ResolvedRiskConfig { + const config = opts.config || loadConfig(); + const riskConfig = ((config as unknown as Record).risk || {}) as { + weights?: Partial; + roleWeights?: Record; + defaultRoleWeight?: number; + }; + const weights: RiskWeights = { + ...DEFAULT_WEIGHTS, + ...(riskConfig.weights || {}), + ...(opts.weights || {}), + }; + return { + weights, + riskOpts: { + roleWeights: riskConfig.roleWeights, + defaultRoleWeight: riskConfig.defaultRoleWeight, + }, + }; +} + export function triageData( customDbPath?: string, opts: TriageDataOpts = {}, @@ -125,21 +152,7 @@ export function triageData( const noTests = opts.noTests || false; const minScore = opts.minScore != null ? Number(opts.minScore) : null; const sort = opts.sort || 'risk'; - const config = opts.config || loadConfig(); - const riskConfig = ((config as unknown as Record).risk || {}) as { - weights?: Partial; - roleWeights?: Record; - defaultRoleWeight?: number; - }; - const weights: RiskWeights = { - ...DEFAULT_WEIGHTS, - ...(riskConfig.weights || {}), - ...(opts.weights || {}), - }; - const riskOpts = { - roleWeights: riskConfig.roleWeights, - defaultRoleWeight: riskConfig.defaultRoleWeight, - }; + const { weights, riskOpts } = resolveRiskConfig(opts); let rows: TriageNodeRow[]; try { diff --git a/src/graph/algorithms/tarjan.ts b/src/graph/algorithms/tarjan.ts index f2619ee11..4d1fac678 100644 --- a/src/graph/algorithms/tarjan.ts +++ b/src/graph/algorithms/tarjan.ts @@ -15,6 +15,7 @@ export function tarjan(graph: CodeGraph): string[][] { const sccs: string[][] = []; function strongconnect(v: string): void { + // Assign the next discovery index and initialise lowlink to self indices.set(v, index); lowlinks.set(v, index); index++; @@ -23,13 +24,16 @@ export function tarjan(graph: CodeGraph): string[][] { for (const w of graph.successors(v)) { if (!indices.has(w)) { + // Tree edge: recurse then propagate lowlink upward strongconnect(w); lowlinks.set(v, Math.min(lowlinks.get(v)!, lowlinks.get(w)!)); } else if (onStack.has(w)) { + // Back/cross edge to a node still on the stack: update lowlink via index lowlinks.set(v, Math.min(lowlinks.get(v)!, indices.get(w)!)); } } + // v is the root of an SCC when its lowlink equals its own discovery index if (lowlinks.get(v) === indices.get(v)) { const scc: string[] = []; let w: string | undefined; @@ -38,6 +42,7 @@ export function tarjan(graph: CodeGraph): string[][] { onStack.delete(w); scc.push(w); } while (w !== v); + // Only report non-trivial SCCs (length > 1 = a real cycle) if (scc.length > 1) sccs.push(scc); } } diff --git a/src/graph/builders/dependency.ts b/src/graph/builders/dependency.ts index 9af53751e..fb970b42a 100644 --- a/src/graph/builders/dependency.ts +++ b/src/graph/builders/dependency.ts @@ -78,6 +78,37 @@ interface MinConfidenceEdgeRow { target_id: number; } +/** + * Fetch call edges from `dbOrRepo`, optionally filtered by a minimum confidence + * threshold. When `minConfidence` is unset, all call edges are returned. + */ +function resolveCallEdges( + dbOrRepo: BetterSqlite3Database | Repository, + isRepo: boolean, + minConfidence?: number, +): CallEdgeRow[] | MinConfidenceEdgeRow[] { + if (minConfidence == null) { + return isRepo + ? (dbOrRepo as Repository).getCallEdges() + : getCallEdges(dbOrRepo as BetterSqlite3Database); + } + if (isRepo) { + // Trade-off: Repository.getCallEdges() returns all call edges, so we + // filter in JS. This is O(all call edges) rather than the SQL path's + // indexed WHERE clause. Acceptable for current data sizes; a dedicated + // getCallEdgesByMinConfidence(threshold) method on the Repository + // interface would be the proper fix if this becomes a bottleneck. + return (dbOrRepo as Repository) + .getCallEdges() + .filter((e) => e.confidence != null && e.confidence >= minConfidence); + } + return (dbOrRepo as BetterSqlite3Database) + .prepare( + "SELECT source_id, target_id FROM edges WHERE kind = 'calls' AND confidence >= ?", + ) + .all(minConfidence); +} + function buildFunctionLevelGraph( dbOrRepo: BetterSqlite3Database | Repository, noTests: boolean, @@ -86,7 +117,9 @@ function buildFunctionLevelGraph( const graph = new CodeGraph(); const isRepo = dbOrRepo instanceof Repository; - let nodes: CallableNodeRow[] = isRepo ? dbOrRepo.getCallableNodes() : getCallableNodes(dbOrRepo); + let nodes: CallableNodeRow[] = isRepo + ? (dbOrRepo as Repository).getCallableNodes() + : getCallableNodes(dbOrRepo as BetterSqlite3Database); if (noTests) nodes = nodes.filter((n) => !isTestFile(n.file)); const nodeIds = new Set(); @@ -100,28 +133,7 @@ function buildFunctionLevelGraph( nodeIds.add(n.id); } - let edges: CallEdgeRow[] | MinConfidenceEdgeRow[]; - if (minConfidence != null) { - if (isRepo) { - // Trade-off: Repository.getCallEdges() returns all call edges, so we - // filter in JS. This is O(all call edges) rather than the SQL path's - // indexed WHERE clause. Acceptable for current data sizes; a dedicated - // getCallEdgesByMinConfidence(threshold) method on the Repository - // interface would be the proper fix if this becomes a bottleneck. - edges = dbOrRepo - .getCallEdges() - .filter((e) => e.confidence != null && e.confidence >= minConfidence); - } else { - edges = (dbOrRepo as BetterSqlite3Database) - .prepare( - "SELECT source_id, target_id FROM edges WHERE kind = 'calls' AND confidence >= ?", - ) - .all(minConfidence); - } - } else { - edges = isRepo ? dbOrRepo.getCallEdges() : getCallEdges(dbOrRepo); - } - + const edges = resolveCallEdges(dbOrRepo, isRepo, minConfidence); for (const e of edges) { if (!nodeIds.has(e.source_id) || !nodeIds.has(e.target_id)) continue; const src = String(e.source_id); diff --git a/src/infrastructure/registry.ts b/src/infrastructure/registry.ts index f9d4f18ae..3ef7b2b34 100644 --- a/src/infrastructure/registry.ts +++ b/src/infrastructure/registry.ts @@ -242,19 +242,18 @@ interface PrunedEntry { * * When `dryRun` is true, entries are identified but not removed from disk. */ -export function pruneRegistry( - registryPath: string = REGISTRY_PATH, - ttlDays: number = DEFAULT_TTL_DAYS, - excludeNames: string[] = [], - dryRun = false, +/** + * Walk `registry.repos`, identify entries that are missing on disk or have + * exceeded the TTL, and optionally delete them (when `dryRun` is false). + * Returns the list of entries that were (or would be) pruned. + */ +function pruneRepoEntries( + registry: Registry, + cutoff: number, + excludeSet: Set, + dryRun: boolean, ): PrunedEntry[] { - const registry = loadRegistry(registryPath); const pruned: PrunedEntry[] = []; - const cutoff = Date.now() - ttlDays * 24 * 60 * 60 * 1000; - const excludeSet = new Set( - excludeNames.filter((n) => typeof n === 'string' && n.trim().length > 0), - ); - for (const [name, entry] of Object.entries(registry.repos)) { if (excludeSet.has(name)) continue; if (!fs.existsSync(entry.path)) { @@ -268,18 +267,43 @@ export function pruneRegistry( if (!dryRun) delete registry.repos[name]; } } + return pruned; +} - // Prune consent entries whose repo paths no longer exist on disk. - // Consent entries are TTL-exempt — only the missing-path rule applies. - let consentChanged = false; - if (!dryRun && registry.userConfig?.consent) { - for (const p of Object.keys(registry.userConfig.consent)) { - if (!fs.existsSync(p)) { - delete registry.userConfig.consent[p]; - consentChanged = true; - } +/** + * Prune consent entries whose repo paths no longer exist on disk. + * Consent entries are TTL-exempt — only the missing-path rule applies. + * Returns true when at least one entry was removed. + */ +function pruneConsentEntries(consent: Record): boolean { + let changed = false; + for (const p of Object.keys(consent)) { + if (!fs.existsSync(p)) { + delete consent[p]; + changed = true; } } + return changed; +} + +export function pruneRegistry( + registryPath: string = REGISTRY_PATH, + ttlDays: number = DEFAULT_TTL_DAYS, + excludeNames: string[] = [], + dryRun = false, +): PrunedEntry[] { + const registry = loadRegistry(registryPath); + const cutoff = Date.now() - ttlDays * 24 * 60 * 60 * 1000; + const excludeSet = new Set( + excludeNames.filter((n) => typeof n === 'string' && n.trim().length > 0), + ); + + const pruned = pruneRepoEntries(registry, cutoff, excludeSet, dryRun); + + const consentChanged = + !dryRun && registry.userConfig?.consent + ? pruneConsentEntries(registry.userConfig.consent) + : false; if (!dryRun && (pruned.length > 0 || consentChanged)) { saveRegistry(registry, registryPath); diff --git a/src/infrastructure/update-check.ts b/src/infrastructure/update-check.ts index d8438088d..bfd881f77 100644 --- a/src/infrastructure/update-check.ts +++ b/src/infrastructure/update-check.ts @@ -61,6 +61,33 @@ function saveCache(cache: UpdateCache, cachePath: string = CACHE_PATH): void { fs.renameSync(tmp, cachePath); } +/** + * Collect the full response body from an IncomingMessage stream. + * Resolves with the body string, or null on parse/status error. + */ +function collectResponseBody(res: import('node:http').IncomingMessage): Promise { + return new Promise((resolve) => { + if (res.statusCode !== 200) { + res.resume(); + resolve(null); + return; + } + let body = ''; + res.setEncoding('utf-8'); + res.on('data', (chunk: string) => { + body += chunk; + }); + res.on('end', () => { + try { + const data = JSON.parse(body); + resolve(typeof data.version === 'string' ? data.version : null); + } catch { + resolve(null); + } + }); + }); +} + /** * Fetch the latest version string from the npm registry. * Returns the version string or null on failure. @@ -71,24 +98,7 @@ function fetchLatestVersion(): Promise { REGISTRY_URL, { timeout: FETCH_TIMEOUT_MS, headers: { Accept: 'application/json' } }, (res) => { - if (res.statusCode !== 200) { - res.resume(); - resolve(null); - return; - } - let body = ''; - res.setEncoding('utf-8'); - res.on('data', (chunk: string) => { - body += chunk; - }); - res.on('end', () => { - try { - const data = JSON.parse(body); - resolve(typeof data.version === 'string' ? data.version : null); - } catch { - resolve(null); - } - }); + collectResponseBody(res).then(resolve); }, ); req.on('error', () => resolve(null)); @@ -109,6 +119,32 @@ interface CheckForUpdatesOptions { _fetchLatest?: () => Promise; } +/** + * Return the latest version from cache if fresh, or fetch it from the registry. + * Persists the fetched version to the cache only when a network fetch occurs + * (i.e. the cache is stale or missing). Returns the cached value directly + * without updating the cache when the cache is still within `CACHE_TTL_MS`. + * Returns null when the network fetch fails or the cache is corrupt. + */ +async function resolveLatestVersion( + cachePath: string, + fetchFn: () => Promise, +): Promise { + const cache = loadCache(cachePath); + + if (cache && Date.now() - cache.lastCheckedAt < CACHE_TTL_MS) { + // Cache is fresh — use stored value directly + return cache.latestVersion; + } + + // Cache is stale or missing — fetch from registry + const latest = await fetchFn(); + if (!latest) return null; + + saveCache({ lastCheckedAt: Date.now(), latestVersion: latest }, cachePath); + return latest; +} + /** * Check whether a newer version of codegraph is available. * @@ -129,23 +165,9 @@ export async function checkForUpdates( const fetchFn = options._fetchLatest || fetchLatestVersion; try { - const cache = loadCache(cachePath); - - // Cache is fresh — use it - if (cache && Date.now() - cache.lastCheckedAt < CACHE_TTL_MS) { - if (semverCompare(currentVersion, cache.latestVersion) < 0) { - return { current: currentVersion, latest: cache.latestVersion }; - } - return null; - } - - // Cache is stale or missing — fetch - const latest = await fetchFn(); + const latest = await resolveLatestVersion(cachePath, fetchFn); if (!latest) return null; - // Update cache regardless of result - saveCache({ lastCheckedAt: Date.now(), latestVersion: latest }, cachePath); - if (semverCompare(currentVersion, latest) < 0) { return { current: currentVersion, latest }; } diff --git a/src/presentation/viewer.ts b/src/presentation/viewer.ts index 56e1bb297..d340838a2 100644 --- a/src/presentation/viewer.ts +++ b/src/presentation/viewer.ts @@ -150,80 +150,10 @@ export function buildLayoutOptions(cfg: PlotConfig): LayoutOptions { return opts; } -// ─── HTML Renderer ─────────────────────────────────────────────────── - -export interface ViewerNode { - id: number | string; - label: string; - kind: string; - file: string; - line: number; - role: string | null; - fanIn: number; - fanOut: number; - cognitive: number | null; - cyclomatic: number | null; - maintainabilityIndex: number | null; - community: number | null; - directory: string | null; - risk: string[]; -} - -export interface ViewerEdge { - id: number | string; - from: number | string; - to: number | string; -} - -export interface ViewerData { - nodes: ViewerNode[]; - edges: ViewerEdge[]; - seedNodeIds: (number | string)[]; -} - -interface ResolvedPlotConfig { - layoutOpts: LayoutOptions; - title: string; - layoutAlgorithm: string; - layoutDirection: string; - physicsEnabled: boolean; - sizeBy: string; - clusterBy: string; - effectiveColorBy: string; - effectiveRisk: boolean; -} +// ─── HTML Section Renderers ────────────────────────────────────────── -function resolvePlotConfig(cfg: PlotConfig): ResolvedPlotConfig { - return { - layoutOpts: buildLayoutOptions(cfg), - title: cfg.title || 'Codegraph', - layoutAlgorithm: cfg.layout?.algorithm || 'hierarchical', - layoutDirection: cfg.layout?.direction || 'LR', - physicsEnabled: cfg.physics?.enabled !== false, - sizeBy: cfg.sizeBy || 'uniform', - clusterBy: cfg.clusterBy || 'none', - effectiveColorBy: - cfg.overlays?.complexity && cfg.colorBy === 'kind' ? 'complexity' : cfg.colorBy || 'kind', - effectiveRisk: cfg.overlays?.risk || false, - }; -} - -export function renderPlotHTML(data: ViewerData, cfg: PlotConfig): string { - const { - layoutOpts, - title, - layoutAlgorithm, - layoutDirection, - physicsEnabled, - sizeBy, - clusterBy, - effectiveColorBy, - effectiveRisk, - } = resolvePlotConfig(cfg); - - return ` - - +function renderViewerHead(title: string): string { + return ` ${escapeHtml(title)} @@ -257,9 +187,13 @@ export function renderPlotHTML(data: ViewerData, cfg: PlotConfig): string { #legend div { display: flex; align-items: center; gap: 6px; margin: 2px 0; } #legend span.swatch { width: 14px; height: 14px; border-radius: 3px; display: inline-block; flex-shrink: 0; } - - -
+`; +} + +function renderViewerControls(resolved: ResolvedPlotConfig): string { + const { layoutAlgorithm, physicsEnabled, effectiveColorBy, sizeBy, clusterBy, effectiveRisk } = + resolved; + return `
-
-
-
-
- × -
-
-
-
- +`; +} + +// ─── HTML Renderer ─────────────────────────────────────────────────── + +export interface ViewerNode { + id: number | string; + label: string; + kind: string; + file: string; + line: number; + role: string | null; + fanIn: number; + fanOut: number; + cognitive: number | null; + cyclomatic: number | null; + maintainabilityIndex: number | null; + community: number | null; + directory: string | null; + risk: string[]; +} + +export interface ViewerEdge { + id: number | string; + from: number | string; + to: number | string; +} + +export interface ViewerData { + nodes: ViewerNode[]; + edges: ViewerEdge[]; + seedNodeIds: (number | string)[]; +} + +interface ResolvedPlotConfig { + layoutOpts: LayoutOptions; + title: string; + layoutAlgorithm: string; + layoutDirection: string; + physicsEnabled: boolean; + sizeBy: string; + clusterBy: string; + effectiveColorBy: string; + effectiveRisk: boolean; +} + +function resolvePlotConfig(cfg: PlotConfig): ResolvedPlotConfig { + return { + layoutOpts: buildLayoutOptions(cfg), + title: cfg.title || 'Codegraph', + layoutAlgorithm: cfg.layout?.algorithm || 'hierarchical', + layoutDirection: cfg.layout?.direction || 'LR', + physicsEnabled: cfg.physics?.enabled !== false, + sizeBy: cfg.sizeBy || 'uniform', + clusterBy: cfg.clusterBy || 'none', + effectiveColorBy: + cfg.overlays?.complexity && cfg.colorBy === 'kind' ? 'complexity' : cfg.colorBy || 'kind', + effectiveRisk: cfg.overlays?.risk || false, + }; +} + +export function renderPlotHTML(data: ViewerData, cfg: PlotConfig): string { + const resolved = resolvePlotConfig(cfg); + const { title } = resolved; + return ` + +${renderViewerHead(title)} + +${renderViewerControls(resolved)} +
+
+
+ × +
+
+
+
+${renderViewerScript(data, cfg, resolved)} `; } diff --git a/src/shared/normalize.ts b/src/shared/normalize.ts index ce69d10c1..52872928d 100644 --- a/src/shared/normalize.ts +++ b/src/shared/normalize.ts @@ -29,29 +29,20 @@ export function toSymbolRef(row: { name: string; kind: string; file: string; lin return { name: row.name, kind: row.kind, file: row.file, line: row.line }; } +const KIND_ICONS: Readonly> = { + function: 'f', + class: '*', + method: 'o', + file: '#', + interface: 'I', + type: 'T', + parameter: 'p', + property: '.', + constant: 'C', +}; + export function kindIcon(kind: string): string { - switch (kind) { - case 'function': - return 'f'; - case 'class': - return '*'; - case 'method': - return 'o'; - case 'file': - return '#'; - case 'interface': - return 'I'; - case 'type': - return 'T'; - case 'parameter': - return 'p'; - case 'property': - return '.'; - case 'constant': - return 'C'; - default: - return '-'; - } + return KIND_ICONS[kind] ?? '-'; } export interface NormalizedSymbol {