diff --git a/src/commands/_shared/file-resolver.ts b/src/commands/_shared/file-resolver.ts new file mode 100644 index 0000000..a468f2b --- /dev/null +++ b/src/commands/_shared/file-resolver.ts @@ -0,0 +1,18 @@ +import path from 'node:path'; +import type { IndexDatabase } from '../../db/database.js'; + +/** + * Resolve a file path to a database file ID. + * + * Tries three lookup strategies in order: + * 1. Relative path (db.toRelativePath of the resolved absolute path) + * 2. Absolute resolved path + * 3. The original path as-is (handles suffix/partial matches stored in the db) + * + * Returns `null` if no match is found. + */ +export function resolveFileId(db: IndexDatabase, filePath: string): number | null { + const resolvedPath = path.resolve(filePath); + const relativePath = db.toRelativePath(resolvedPath); + return db.files.getIdByPath(relativePath) ?? db.files.getIdByPath(resolvedPath) ?? db.files.getIdByPath(filePath); +} diff --git a/src/commands/_shared/index.ts b/src/commands/_shared/index.ts index d7b4882..fd596d9 100644 --- a/src/commands/_shared/index.ts +++ b/src/commands/_shared/index.ts @@ -1,4 +1,5 @@ export { openDatabase, withDatabase, resolveDbPath } from './db-helper.js'; +export { resolveFileId } from './file-resolver.js'; export { SymbolResolver, type ResolvedSymbol, type ResolvedSymbolWithDetails } from './symbol-resolver.js'; export { SharedFlags, LlmFlags } from './flags.js'; export { outputJsonOrPlain, truncate, tableSeparator, formatLineNumber } from './output.js'; diff --git a/src/commands/files/imported-by.ts b/src/commands/files/imported-by.ts index 3564be8..ce9f5de 100644 --- a/src/commands/files/imported-by.ts +++ b/src/commands/files/imported-by.ts @@ -1,7 +1,7 @@ import path from 'node:path'; import { Args, Command } from '@oclif/core'; import chalk from 'chalk'; -import { SharedFlags, withDatabase } from '../_shared/index.js'; +import { SharedFlags, resolveFileId, withDatabase } from '../_shared/index.js'; export default class ImportedBy extends Command { static override description = 'List files that import a specific file'; @@ -27,7 +27,7 @@ export default class ImportedBy extends Command { const filePath = path.resolve(args.file); await withDatabase(flags.database, this, async (db) => { - const fileId = db.files.getIdByPath(db.toRelativePath(filePath)) ?? db.files.getIdByPath(filePath); + const fileId = resolveFileId(db, filePath); if (fileId === null) { this.error(chalk.red(`File "${filePath}" not found in the index.`)); } diff --git a/src/commands/files/imports.ts b/src/commands/files/imports.ts index 60da51d..06707d8 100644 --- a/src/commands/files/imports.ts +++ b/src/commands/files/imports.ts @@ -1,7 +1,7 @@ import path from 'node:path'; import { Args, Command, Flags } from '@oclif/core'; import chalk from 'chalk'; -import { SharedFlags, withDatabase } from '../_shared/index.js'; +import { SharedFlags, resolveFileId, withDatabase } from '../_shared/index.js'; export default class Imports extends Command { static override description = 'List files imported by a specific file'; @@ -32,7 +32,7 @@ export default class Imports extends Command { const filePath = path.resolve(args.file); await withDatabase(flags.database, this, async (db) => { - const fileId = db.files.getIdByPath(db.toRelativePath(filePath)) ?? db.files.getIdByPath(filePath); + const fileId = resolveFileId(db, filePath); if (fileId === null) { this.error(chalk.red(`File "${filePath}" not found in the index.`)); } diff --git a/src/commands/files/show.ts b/src/commands/files/show.ts index 177fa8e..4cb7795 100644 --- a/src/commands/files/show.ts +++ b/src/commands/files/show.ts @@ -1,7 +1,7 @@ import path from 'node:path'; import { Args, Command } from '@oclif/core'; import chalk from 'chalk'; -import { SharedFlags, formatModuleRef, outputJsonOrPlain, withDatabase } from '../_shared/index.js'; +import { SharedFlags, formatModuleRef, outputJsonOrPlain, resolveFileId, withDatabase } from '../_shared/index.js'; export default class FilesShow extends Command { static override description = 'Show file details including definitions and imports'; @@ -25,7 +25,7 @@ export default class FilesShow extends Command { const filePath = path.resolve(args.path); await withDatabase(flags.database, this, async (db) => { - const fileId = db.files.getIdByPath(db.toRelativePath(filePath)) ?? db.files.getIdByPath(filePath); + const fileId = resolveFileId(db, filePath); if (fileId === null) { this.error(chalk.red(`File "${filePath}" not found in the index.`)); } diff --git a/src/commands/llm/_shared/pure-check.ts b/src/commands/llm/_shared/pure-check.ts index bb861cf..1e18d63 100644 --- a/src/commands/llm/_shared/pure-check.ts +++ b/src/commands/llm/_shared/pure-check.ts @@ -7,6 +7,7 @@ import type { SyntaxNode } from 'tree-sitter'; import Parser from 'tree-sitter'; import Ruby from 'tree-sitter-ruby'; import TypeScript from 'tree-sitter-typescript'; +import { countArguments } from '../../../parser/_shared/ast-utils.js'; let tsParser: Parser | null = null; function getParser(): Parser { @@ -626,21 +627,6 @@ function resolveRootIdentifier(node: SyntaxNode): string | null { return null; } -/** - * Count non-punctuation arguments in an arguments node. - */ -function countArguments(argsNode: SyntaxNode): number { - let count = 0; - for (let i = 0; i < argsNode.childCount; i++) { - const child = argsNode.child(i); - if (child) { - const t = child.type; - if (t !== '(' && t !== ')' && t !== ',') count++; - } - } - return count; -} - /** * Check if a node has any function/arrow_function/method ancestor. * Used to determine if a throw_statement is inside a function body diff --git a/src/commands/symbols/_show-data.ts b/src/commands/symbols/_show-data.ts new file mode 100644 index 0000000..2dae800 --- /dev/null +++ b/src/commands/symbols/_show-data.ts @@ -0,0 +1,448 @@ +import type { IndexDatabase } from '../../db/database.js'; +import type { InteractionWithPaths } from '../../db/schema.js'; +import { resolveFileId } from '../_shared/file-resolver.js'; +import { readAllLines, readSourceLines } from '../_shared/index.js'; + +// ─── Shared types ──────────────────────────────────────────────────────────── + +export interface CallSiteWithContext { + filePath: string; + line: number; + column: number; + containingFunction: string | null; + contextLines: string[]; + contextStartLine: number; +} + +export interface MappedInteraction { + id: number; + fromModulePath: string; + toModulePath: string; + pattern: string | null; + semantic: string | null; + weight: number; + direction: string; + source: string; +} + +export function mapInteraction(i: InteractionWithPaths): MappedInteraction { + return { + id: i.id, + fromModulePath: i.fromModulePath, + toModulePath: i.toModulePath, + pattern: i.pattern, + semantic: i.semantic, + weight: i.weight, + direction: i.direction, + source: i.source, + }; +} + +// ─── SymbolShowData ─────────────────────────────────────────────────────────── + +export interface SymbolShowData { + id: number; + name: string; + kind: string; + filePath: string; + line: number; + endLine: number; + isExported: boolean; + metadata: Record; + module: { id: number; name: string; fullPath: string } | null; + relationships: Array<{ + toDefinitionId: number; + toName: string; + toKind: string; + relationshipType: string; + semantic: string; + toFilePath: string; + toLine: number; + }>; + incomingRelationships: Array<{ + fromDefinitionId: number; + fromName: string; + fromKind: string; + relationshipType: string; + semantic: string; + fromFilePath: string; + fromLine: number; + }>; + dependencies: Array<{ + id: number; + name: string; + kind: string; + filePath: string; + line: number; + }>; + dependents: { + count: number; + sample: Array<{ + id: number; + name: string; + kind: string; + filePath: string; + line: number; + }>; + }; + flows: Array<{ + id: number; + name: string; + slug: string; + stakeholder: string | null; + }>; + interactions: { + incoming: MappedInteraction[]; + outgoing: MappedInteraction[]; + }; + sourceCode: string[]; + callSites: CallSiteWithContext[]; +} + +// ─── FileShowData ───────────────────────────────────────────────────────────── + +export interface FileShowData { + file: string; + symbols: Array<{ + id: number; + name: string; + kind: string; + line: number; + endLine: number; + isExported: boolean; + }>; + modules: Array<{ name: string; fullPath: string }>; + relationships: { + outgoing: Array<{ + toDefinitionId: number; + toName: string; + toKind: string; + relationshipType: string; + semantic: string; + toFilePath: string; + toLine: number; + }>; + incoming: Array<{ + fromDefinitionId: number; + fromName: string; + fromKind: string; + relationshipType: string; + semantic: string; + fromFilePath: string; + fromLine: number; + }>; + }; + interactions: { + incoming: MappedInteraction[]; + outgoing: MappedInteraction[]; + }; + flows: Array<{ id: number; name: string; slug: string; stakeholder: string | null }>; +} + +// ─── SymbolShowDataGatherer ─────────────────────────────────────────────────── + +/** + * Pure data-gathering class for the `symbols show` command. + * Separates data collection from rendering concerns. + */ +export class SymbolShowDataGatherer { + /** + * Gather all data for a single-symbol display. + */ + async gatherSymbolData(db: IndexDatabase, definitionId: number, contextLines: number): Promise { + const defDetails = db.definitions.getById(definitionId); + if (!defDetails) { + throw new Error(`Definition with ID ${definitionId} not found`); + } + + const sourceCode = await readSourceLines( + db.resolveFilePath(defDetails.filePath), + defDetails.line, + defDetails.endLine + ); + + const callSites = await this.getCallSitesWithContext(db, definitionId, contextLines); + const metadata = db.metadata.get(definitionId); + const moduleResult = db.modules.getDefinitionModule(definitionId); + const outgoingRelationships = db.relationships.getFrom(definitionId); + const incomingRelationships = db.relationships.getTo(definitionId); + const dependencies = db.dependencies.getForDefinition(definitionId); + const dependents = db.dependencies.getIncoming(definitionId, 10); + const dependentCount = db.dependencies.getIncomingCount(definitionId); + const flows = db.flows.getFlowsWithDefinition(definitionId); + + const moduleId = moduleResult?.module.id; + let incomingInteractions: InteractionWithPaths[] = []; + let outgoingInteractions: InteractionWithPaths[] = []; + if (moduleId) { + const depNames = dependencies.map((d) => d.name); + incomingInteractions = db.interactions.getIncomingForSymbols(moduleId, [defDetails.name]); + outgoingInteractions = db.interactions.getOutgoingForSymbols(moduleId, depNames); + } + + return { + id: defDetails.id, + name: defDetails.name, + kind: defDetails.kind, + filePath: defDetails.filePath, + line: defDetails.line, + endLine: defDetails.endLine, + isExported: defDetails.isExported, + metadata, + module: moduleResult + ? { id: moduleResult.module.id, name: moduleResult.module.name, fullPath: moduleResult.module.fullPath } + : null, + relationships: outgoingRelationships.map((r) => ({ + toDefinitionId: r.toDefinitionId, + toName: r.toName, + toKind: r.toKind, + relationshipType: r.relationshipType, + semantic: r.semantic, + toFilePath: r.toFilePath, + toLine: r.toLine, + })), + incomingRelationships: incomingRelationships.map((r) => ({ + fromDefinitionId: r.fromDefinitionId, + fromName: r.fromName, + fromKind: r.fromKind, + relationshipType: r.relationshipType, + semantic: r.semantic, + fromFilePath: r.fromFilePath, + fromLine: r.fromLine, + })), + dependencies: dependencies.map((d) => ({ + id: d.dependencyId, + name: d.name, + kind: d.kind, + filePath: d.filePath, + line: d.line, + })), + dependents: { + count: dependentCount, + sample: dependents.map((d) => ({ + id: d.id, + name: d.name, + kind: d.kind, + filePath: d.filePath, + line: d.line, + })), + }, + flows: flows.map((f) => ({ + id: f.id, + name: f.name, + slug: f.slug, + stakeholder: f.stakeholder, + })), + interactions: { + incoming: incomingInteractions.map(mapInteraction), + outgoing: outgoingInteractions.map(mapInteraction), + }, + sourceCode, + callSites, + }; + } + + /** + * Gather all data for file-level aggregation display. + */ + async gatherFileData(db: IndexDatabase, filePath: string): Promise { + const fileId = resolveFileId(db, filePath); + if (!fileId) return null; + + const relativePath = db.toRelativePath(filePath) || filePath; + const fileDefs = db.definitions.getForFile(fileId); + if (fileDefs.length === 0) return null; + + // Collect unique modules + const moduleMap = new Map(); + for (const def of fileDefs) { + const modResult = db.modules.getDefinitionModule(def.id); + if (modResult && !moduleMap.has(modResult.module.id)) { + moduleMap.set(modResult.module.id, { + name: modResult.module.name, + fullPath: modResult.module.fullPath, + }); + } + } + + // Aggregate relationships (deduplicated) + const outRelMap = new Map< + string, + { + toDefinitionId: number; + toName: string; + toKind: string; + relationshipType: string; + semantic: string; + toFilePath: string; + toLine: number; + } + >(); + const inRelMap = new Map< + string, + { + fromDefinitionId: number; + fromName: string; + fromKind: string; + relationshipType: string; + semantic: string; + fromFilePath: string; + fromLine: number; + } + >(); + + for (const def of fileDefs) { + for (const r of db.relationships.getFrom(def.id)) { + const key = `${def.id}-${r.toDefinitionId}-${r.relationshipType}`; + if (!outRelMap.has(key)) { + outRelMap.set(key, { + toDefinitionId: r.toDefinitionId, + toName: r.toName, + toKind: r.toKind, + relationshipType: r.relationshipType, + semantic: r.semantic, + toFilePath: r.toFilePath, + toLine: r.toLine, + }); + } + } + for (const r of db.relationships.getTo(def.id)) { + const key = `${r.fromDefinitionId}-${def.id}-${r.relationshipType}`; + if (!inRelMap.has(key)) { + inRelMap.set(key, { + fromDefinitionId: r.fromDefinitionId, + fromName: r.fromName, + fromKind: r.fromKind, + relationshipType: r.relationshipType, + semantic: r.semantic, + fromFilePath: r.fromFilePath, + fromLine: r.fromLine, + }); + } + } + } + + // Aggregate flows (deduplicated) + const flowMap = new Map(); + for (const def of fileDefs) { + for (const f of db.flows.getFlowsWithDefinition(def.id)) { + if (!flowMap.has(f.id)) { + flowMap.set(f.id, { id: f.id, name: f.name, slug: f.slug, stakeholder: f.stakeholder }); + } + } + } + + // Aggregate interactions + const allSymbolNames = fileDefs.map((d) => d.name); + const allDepNames: string[] = []; + for (const def of fileDefs) { + for (const dep of db.dependencies.getForDefinition(def.id)) { + allDepNames.push(dep.name); + } + } + const uniqueDepNames = [...new Set(allDepNames)]; + + const inInteractionMap = new Map(); + const outInteractionMap = new Map(); + for (const [moduleId] of moduleMap) { + for (const i of db.interactions.getIncomingForSymbols(moduleId, allSymbolNames)) { + if (!inInteractionMap.has(i.id)) { + inInteractionMap.set(i.id, mapInteraction(i)); + } + } + for (const i of db.interactions.getOutgoingForSymbols(moduleId, uniqueDepNames)) { + if (!outInteractionMap.has(i.id)) { + outInteractionMap.set(i.id, mapInteraction(i)); + } + } + } + + return { + file: relativePath, + symbols: fileDefs.map((d) => ({ + id: d.id, + name: d.name, + kind: d.kind, + line: d.line, + endLine: d.endLine, + isExported: d.isExported, + })), + modules: [...moduleMap.entries()].map(([, m]) => ({ name: m.name, fullPath: m.fullPath })), + relationships: { + outgoing: [...outRelMap.values()], + incoming: [...inRelMap.values()], + }, + interactions: { + incoming: [...inInteractionMap.values()], + outgoing: [...outInteractionMap.values()], + }, + flows: [...flowMap.values()], + }; + } + + /** + * Fetch call sites for a definition and enrich them with source context. + */ + private async getCallSitesWithContext( + db: IndexDatabase, + definitionId: number, + contextLines: number + ): Promise { + const callsites = db.dependencies.getCallsites(definitionId); + + // Group call sites by file for efficient reading + const byFile = new Map(); + for (const cs of callsites) { + if (!byFile.has(cs.filePath)) { + byFile.set(cs.filePath, []); + } + byFile.get(cs.filePath)!.push(cs); + } + + const result: CallSiteWithContext[] = []; + + for (const [filePath, fileCallsites] of byFile) { + const fileLines = await readAllLines(db.resolveFilePath(filePath)); + + if (fileLines.length === 0) { + for (const cs of fileCallsites) { + result.push({ + filePath: cs.filePath, + line: cs.line, + column: cs.column, + containingFunction: null, + contextLines: [''], + contextStartLine: cs.line, + }); + } + continue; + } + + const fileId = db.files.getIdByPath(filePath); + const fileDefs = fileId ? db.definitions.getForFile(fileId) : []; + + for (const cs of fileCallsites) { + const containingDef = fileDefs + .filter((def) => def.line <= cs.line && cs.line <= def.endLine) + .sort((a, b) => a.endLine - a.line - (b.endLine - b.line))[0]; + + const containingFunction = containingDef?.name ?? null; + + const startLine = Math.max(1, cs.line - contextLines); + const endLine = Math.min(fileLines.length, cs.line + contextLines); + const context = fileLines.slice(startLine - 1, endLine); + + result.push({ + filePath: cs.filePath, + line: cs.line, + column: cs.column, + containingFunction, + contextLines: context, + contextStartLine: startLine, + }); + } + } + + return result; + } +} diff --git a/src/commands/symbols/_show-renderer.ts b/src/commands/symbols/_show-renderer.ts new file mode 100644 index 0000000..11b68d1 --- /dev/null +++ b/src/commands/symbols/_show-renderer.ts @@ -0,0 +1,239 @@ +import type { Command } from '@oclif/core'; +import chalk from 'chalk'; +import type { CallSiteWithContext, FileShowData, MappedInteraction, SymbolShowData } from './_show-data.js'; + +/** + * Renders the output for the `symbols show` command. + * Consumes typed data objects produced by SymbolShowDataGatherer. + */ +export class SymbolShowRenderer { + constructor(private command: Command) {} + + // ─── Symbol mode ───────────────────────────────────────────────────────────── + + renderSymbol(data: SymbolShowData): void { + // Definition section + this.command.log(chalk.bold('=== Definition ===')); + this.command.log(''); + this.command.log(`Name: ${chalk.cyan(data.name)}`); + this.command.log(`Kind: ${data.kind}`); + this.command.log(`File: ${data.filePath}`); + this.command.log(`Lines: ${data.line}-${data.endLine}`); + this.command.log(`Exported: ${data.isExported ? 'yes' : 'no'}`); + + // Metadata section + const metadataKeys = Object.keys(data.metadata); + if (metadataKeys.length > 0) { + this.command.log(''); + this.command.log(chalk.bold('=== Metadata ===')); + this.command.log(''); + for (const key of metadataKeys.sort()) { + this.command.log(`${key}:`.padEnd(12) + data.metadata[key]); + } + } + + // Module section + if (data.module) { + this.command.log(''); + this.command.log(chalk.bold('=== Module ===')); + this.command.log(''); + this.command.log(`${chalk.cyan(data.module.name)} ${chalk.gray(`(${data.module.fullPath})`)}`); + } + + // Relationships (outgoing) + if (data.relationships.length > 0) { + this.command.log(''); + this.command.log(chalk.bold(`=== Relationships Outgoing (${data.relationships.length}) ===`)); + this.command.log(''); + for (const r of data.relationships) { + const semantic = r.semantic ? ` "${r.semantic}"` : ''; + this.command.log( + ` -> ${chalk.cyan(r.toName)} (${r.toKind}) [${r.relationshipType}]${chalk.gray(semantic)} ${chalk.gray(`${r.toFilePath}:${r.toLine}`)}` + ); + } + } + + // Relationships (incoming) + if (data.incomingRelationships.length > 0) { + this.command.log(''); + this.command.log(chalk.bold(`=== Relationships Incoming (${data.incomingRelationships.length}) ===`)); + this.command.log(''); + for (const r of data.incomingRelationships) { + const semantic = r.semantic ? ` "${r.semantic}"` : ''; + this.command.log( + ` <- ${chalk.cyan(r.fromName)} (${r.fromKind}) [${r.relationshipType}]${chalk.gray(semantic)} ${chalk.gray(`${r.fromFilePath}:${r.fromLine}`)}` + ); + } + } + + // Dependencies + if (data.dependencies.length > 0) { + this.command.log(''); + this.command.log(chalk.bold(`=== Dependencies (${data.dependencies.length}) ===`)); + this.command.log(''); + for (const d of data.dependencies) { + this.command.log(` ${chalk.cyan(d.name)} (${d.kind}) ${chalk.gray(`${d.filePath}:${d.line}`)}`); + } + } + + // Dependents + if (data.dependents.count > 0) { + this.command.log(''); + this.command.log(chalk.bold(`=== Dependents (${data.dependents.sample.length} of ${data.dependents.count}) ===`)); + this.command.log(''); + for (const d of data.dependents.sample) { + this.command.log(` ${chalk.cyan(d.name)} (${d.kind}) ${chalk.gray(`${d.filePath}:${d.line}`)}`); + } + if (data.dependents.count > data.dependents.sample.length) { + this.command.log(chalk.gray(` ... and ${data.dependents.count - data.dependents.sample.length} more`)); + } + } + + // Flows + if (data.flows.length > 0) { + this.command.log(''); + this.command.log(chalk.bold(`=== Flows (${data.flows.length}) ===`)); + this.command.log(''); + for (const f of data.flows) { + const stakeholder = f.stakeholder ? ` [${f.stakeholder}]` : ''; + this.command.log(` ${chalk.cyan(f.name)} (${f.slug})${chalk.gray(stakeholder)}`); + } + } + + // Interactions + this.renderInteractionsSection('Incoming', data.interactions.incoming); + this.renderInteractionsSection('Outgoing', data.interactions.outgoing); + + // Source code section + this.command.log(''); + this.command.log(chalk.bold('=== Source Code ===')); + this.command.log(''); + for (let i = 0; i < data.sourceCode.length; i++) { + const lineNum = data.line + i; + const lineNumStr = String(lineNum).padStart(5, ' '); + this.command.log(`${chalk.gray(lineNumStr)} | ${data.sourceCode[i]}`); + } + + // Call sites section + this.renderCallSites(data.callSites); + } + + // ─── File mode ──────────────────────────────────────────────────────────────── + + renderFile(data: FileShowData): void { + this.command.log(chalk.bold(`=== File: ${data.file} ===`)); + + // Symbols + this.command.log(''); + this.command.log(chalk.bold(`=== Symbols (${data.symbols.length}) ===`)); + this.command.log(''); + for (const s of data.symbols) { + const exported = s.isExported ? chalk.green('exported') : chalk.gray('internal'); + this.command.log(` ${chalk.cyan(s.name)} (${s.kind}) ${exported} ${chalk.gray(`L${s.line}-${s.endLine}`)}`); + } + + // Modules + if (data.modules.length > 0) { + this.command.log(''); + this.command.log(chalk.bold(`=== Modules (${data.modules.length}) ===`)); + this.command.log(''); + for (const m of data.modules) { + this.command.log(` ${chalk.cyan(m.name)} ${chalk.gray(`(${m.fullPath})`)}`); + } + } + + // Relationships + if (data.relationships.outgoing.length > 0) { + this.command.log(''); + this.command.log(chalk.bold(`=== Relationships Outgoing (${data.relationships.outgoing.length}) ===`)); + this.command.log(''); + for (const r of data.relationships.outgoing) { + const semantic = r.semantic ? ` "${r.semantic}"` : ''; + this.command.log( + ` -> ${chalk.cyan(r.toName)} (${r.toKind}) [${r.relationshipType}]${chalk.gray(semantic)} ${chalk.gray(`${r.toFilePath}:${r.toLine}`)}` + ); + } + } + + if (data.relationships.incoming.length > 0) { + this.command.log(''); + this.command.log(chalk.bold(`=== Relationships Incoming (${data.relationships.incoming.length}) ===`)); + this.command.log(''); + for (const r of data.relationships.incoming) { + const semantic = r.semantic ? ` "${r.semantic}"` : ''; + this.command.log( + ` <- ${chalk.cyan(r.fromName)} (${r.fromKind}) [${r.relationshipType}]${chalk.gray(semantic)} ${chalk.gray(`${r.fromFilePath}:${r.fromLine}`)}` + ); + } + } + + // Interactions + this.renderInteractionsSection('Incoming', data.interactions.incoming); + this.renderInteractionsSection('Outgoing', data.interactions.outgoing); + + // Flows + if (data.flows.length > 0) { + this.command.log(''); + this.command.log(chalk.bold(`=== Flows (${data.flows.length}) ===`)); + this.command.log(''); + for (const f of data.flows) { + const stakeholder = f.stakeholder ? ` [${f.stakeholder}]` : ''; + this.command.log(` ${chalk.cyan(f.name)} (${f.slug})${chalk.gray(stakeholder)}`); + } + } + } + + // ─── Shared sections ────────────────────────────────────────────────────────── + + renderInteractionsSection(label: string, interactions: MappedInteraction[]): void { + if (interactions.length === 0) return; + + this.command.log(''); + this.command.log(chalk.bold(`=== Interactions ${label} (${interactions.length}) ===`)); + this.command.log(''); + for (const i of interactions) { + const arrow = i.direction === 'bi' ? '\u2194' : '\u2192'; + const patternLabel = + i.pattern === 'business' ? chalk.cyan('[business]') : i.pattern === 'utility' ? chalk.yellow('[utility]') : ''; + const sourceLabel = i.source === 'llm-inferred' ? chalk.magenta('[inferred]') : chalk.gray('[ast]'); + + const fromShort = i.fromModulePath.split('.').slice(-2).join('.'); + const toShort = i.toModulePath.split('.').slice(-2).join('.'); + + this.command.log(` ${fromShort} ${arrow} ${toShort} ${patternLabel} ${sourceLabel}`); + + if (i.semantic) { + this.command.log(` ${chalk.gray(`"${i.semantic}"`)}`); + } + } + } + + private renderCallSites(callSites: CallSiteWithContext[]): void { + this.command.log(''); + this.command.log(chalk.bold(`=== Call Sites (${callSites.length}) ===`)); + + if (callSites.length === 0) { + this.command.log(''); + this.command.log(chalk.gray('No call sites found.')); + return; + } + + for (const callSite of callSites) { + this.command.log(''); + const location = `${callSite.filePath}:${callSite.line}`; + const inFunction = callSite.containingFunction ? ` in ${chalk.cyan(callSite.containingFunction)}()` : ''; + this.command.log(`${chalk.yellow(location)}${inFunction}`); + this.command.log(chalk.gray('\u2500'.repeat(60))); + + for (let i = 0; i < callSite.contextLines.length; i++) { + const lineNum = callSite.contextStartLine + i; + const lineNumStr = String(lineNum).padStart(5, ' '); + const isTargetLine = lineNum === callSite.line; + const prefix = isTargetLine ? chalk.red('>') : ' '; + const line = callSite.contextLines[i]; + const formattedLine = isTargetLine ? chalk.white(line) : line; + this.command.log(`${prefix}${chalk.gray(lineNumStr)} | ${formattedLine}`); + } + } + } +} diff --git a/src/commands/symbols/list.ts b/src/commands/symbols/list.ts index 425cc68..6804b89 100644 --- a/src/commands/symbols/list.ts +++ b/src/commands/symbols/list.ts @@ -1,7 +1,7 @@ import path from 'node:path'; import { Command, Flags } from '@oclif/core'; import chalk from 'chalk'; -import { SharedFlags, withDatabase } from '../_shared/index.js'; +import { SharedFlags, resolveFileId, withDatabase } from '../_shared/index.js'; export default class SymbolsList extends Command { static override description = 'List all symbols in the index'; @@ -169,10 +169,9 @@ export default class SymbolsList extends Command { // Resolve file path if provided let fileId: number | null = null; if (flags.file) { - const filePath = path.resolve(flags.file); - fileId = db.files.getIdByPath(db.toRelativePath(filePath)) ?? db.files.getIdByPath(filePath); + fileId = resolveFileId(db, flags.file); if (fileId === null) { - this.error(chalk.red(`File "${filePath}" not found in the index.`)); + this.error(chalk.red(`File "${path.resolve(flags.file)}" not found in the index.`)); } } diff --git a/src/commands/symbols/show.ts b/src/commands/symbols/show.ts index c263db1..7befe0f 100644 --- a/src/commands/symbols/show.ts +++ b/src/commands/symbols/show.ts @@ -1,50 +1,9 @@ -import path from 'node:path'; import { Args, Command, Flags } from '@oclif/core'; import chalk from 'chalk'; -import type { IndexDatabase } from '../../db/database.js'; -import type { InteractionWithPaths } from '../../db/schema.js'; -import { - SharedFlags, - SymbolResolver, - formatModuleRef, - outputJsonOrPlain, - readAllLines, - readSourceLines, - withDatabase, -} from '../_shared/index.js'; - -interface CallSiteWithContext { - filePath: string; - line: number; - column: number; - containingFunction: string | null; - contextLines: string[]; - contextStartLine: number; -} - -interface MappedInteraction { - id: number; - fromModulePath: string; - toModulePath: string; - pattern: string | null; - semantic: string | null; - weight: number; - direction: string; - source: string; -} - -function mapInteraction(i: InteractionWithPaths): MappedInteraction { - return { - id: i.id, - fromModulePath: i.fromModulePath, - toModulePath: i.toModulePath, - pattern: i.pattern, - semantic: i.semantic, - weight: i.weight, - direction: i.direction, - source: i.source, - }; -} +import { resolveFileId } from '../_shared/file-resolver.js'; +import { SharedFlags, SymbolResolver, outputJsonOrPlain, withDatabase } from '../_shared/index.js'; +import { SymbolShowDataGatherer } from './_show-data.js'; +import { SymbolShowRenderer } from './_show-renderer.js'; export default class Show extends Command { static override description = 'Show detailed information about a symbol or file'; @@ -85,7 +44,17 @@ export default class Show extends Command { // File aggregation mode: --file without name or --id if (flags.file && !args.name && flags.id === undefined) { await withDatabase(flags.database, this, async (db) => { - await this.runFileMode(db, flags.file!, flags.json); + const gatherer = new SymbolShowDataGatherer(); + const renderer = new SymbolShowRenderer(this); + + const fileData = await gatherer.gatherFileData(db, flags.file!); + if (!fileData) { + this.error(chalk.red(`File not found in index or has no symbols: "${flags.file}"`)); + } + + outputJsonOrPlain(this, flags.json ?? false, fileData, () => { + renderer.renderFile(fileData); + }); }); return; } @@ -103,622 +72,22 @@ export default class Show extends Command { return; // Disambiguation message already shown } - // Get full definition details - const defDetails = db.definitions.getById(definition.id); - if (!defDetails) { - this.error(chalk.red(`Definition with ID ${definition.id} not found`)); - } - - // Read source code - const sourceCode = await readSourceLines( - db.resolveFilePath(defDetails.filePath), - defDetails.line, - defDetails.endLine - ); - - // Get call sites with context - const callSites = await this.getCallSitesWithContext(db, definition.id, flags['context-lines']); - - // Get metadata - const metadata = db.metadata.get(definition.id); - - // Get module membership - const moduleResult = db.modules.getDefinitionModule(definition.id); - - // Get relationships (outgoing and incoming) - const outgoingRelationships = db.relationships.getFrom(definition.id); - const incomingRelationships = db.relationships.getTo(definition.id); + const gatherer = new SymbolShowDataGatherer(); + const renderer = new SymbolShowRenderer(this); - // Get dependencies and dependents - const dependencies = db.dependencies.getForDefinition(definition.id); - const dependents = db.dependencies.getIncoming(definition.id, 10); - const dependentCount = db.dependencies.getIncomingCount(definition.id); - - // Get flows involving this definition - const flows = db.flows.getFlowsWithDefinition(definition.id); - - // Get interactions involving this symbol - const moduleId = moduleResult?.module.id; - let incomingInteractions: InteractionWithPaths[] = []; - let outgoingInteractions: InteractionWithPaths[] = []; - if (moduleId) { - const depNames = dependencies.map((d) => d.name); - incomingInteractions = db.interactions.getIncomingForSymbols(moduleId, [defDetails.name]); - outgoingInteractions = db.interactions.getOutgoingForSymbols(moduleId, depNames); + let data: Awaited>; + try { + data = await gatherer.gatherSymbolData(db, definition.id, flags['context-lines']); + } catch (err) { + this.error(chalk.red((err as Error).message)); } - const jsonData = { - id: defDetails.id, - name: defDetails.name, - kind: defDetails.kind, - filePath: defDetails.filePath, - line: defDetails.line, - endLine: defDetails.endLine, - isExported: defDetails.isExported, - metadata, - module: formatModuleRef(moduleResult), - relationships: outgoingRelationships.map((r) => ({ - toDefinitionId: r.toDefinitionId, - toName: r.toName, - toKind: r.toKind, - relationshipType: r.relationshipType, - semantic: r.semantic, - toFilePath: r.toFilePath, - toLine: r.toLine, - })), - incomingRelationships: incomingRelationships.map((r) => ({ - fromDefinitionId: r.fromDefinitionId, - fromName: r.fromName, - fromKind: r.fromKind, - relationshipType: r.relationshipType, - semantic: r.semantic, - fromFilePath: r.fromFilePath, - fromLine: r.fromLine, - })), - dependencies: dependencies.map((d) => ({ - id: d.dependencyId, - name: d.name, - kind: d.kind, - filePath: d.filePath, - line: d.line, - })), - dependents: { - count: dependentCount, - sample: dependents.map((d) => ({ - id: d.id, - name: d.name, - kind: d.kind, - filePath: d.filePath, - line: d.line, - })), - }, - flows: flows.map((f) => ({ - id: f.id, - name: f.name, - slug: f.slug, - stakeholder: f.stakeholder, - })), - interactions: { - incoming: incomingInteractions.map(mapInteraction), - outgoing: outgoingInteractions.map(mapInteraction), - }, - sourceCode, - callSites, - }; - - outputJsonOrPlain(this, flags.json, jsonData, () => { - this.outputPlainText( - jsonData, - outgoingRelationships, - incomingRelationships, - dependencies, - dependents, - dependentCount, - flows, - moduleResult, - incomingInteractions, - outgoingInteractions - ); + outputJsonOrPlain(this, flags.json, data, () => { + renderer.renderSymbol(data); }); }); } - - private async runFileMode(db: IndexDatabase, filePath: string, jsonFlag: boolean | undefined): Promise { - // Resolve file path - const resolvedPath = path.resolve(filePath); - const relativePath = db.toRelativePath(resolvedPath); - - // Try both relative and resolved paths - let fileId = db.files.getIdByPath(relativePath); - if (!fileId) { - fileId = db.files.getIdByPath(resolvedPath); - } - if (!fileId) { - // Try matching by suffix - fileId = db.files.getIdByPath(filePath); - } - if (!fileId) { - this.error(chalk.red(`File not found in index: "${filePath}"`)); - } - - // Get all definitions in file - const fileDefs = db.definitions.getForFile(fileId); - if (fileDefs.length === 0) { - this.error(chalk.red(`No symbols found in file: "${filePath}"`)); - } - - // Get all unique modules these definitions belong to - const moduleMap = new Map(); - for (const def of fileDefs) { - const modResult = db.modules.getDefinitionModule(def.id); - if (modResult && !moduleMap.has(modResult.module.id)) { - moduleMap.set(modResult.module.id, { name: modResult.module.name, fullPath: modResult.module.fullPath }); - } - } - - // Aggregate relationships across all definitions (deduplicate by a composite key) - const outRelMap = new Map(); - const inRelMap = new Map(); - const outgoingRels: Array<{ - toDefinitionId: number; - toName: string; - toKind: string; - relationshipType: string; - semantic: string; - toFilePath: string; - toLine: number; - }> = []; - const incomingRels: Array<{ - fromDefinitionId: number; - fromName: string; - fromKind: string; - relationshipType: string; - semantic: string; - fromFilePath: string; - fromLine: number; - }> = []; - - for (const def of fileDefs) { - for (const r of db.relationships.getFrom(def.id)) { - const key = `${def.id}-${r.toDefinitionId}-${r.relationshipType}`; - if (!outRelMap.has(key)) { - const mapped = { - toDefinitionId: r.toDefinitionId, - toName: r.toName, - toKind: r.toKind, - relationshipType: r.relationshipType, - semantic: r.semantic, - toFilePath: r.toFilePath, - toLine: r.toLine, - }; - outRelMap.set(key, mapped); - outgoingRels.push(mapped); - } - } - for (const r of db.relationships.getTo(def.id)) { - const key = `${r.fromDefinitionId}-${def.id}-${r.relationshipType}`; - if (!inRelMap.has(key)) { - const mapped = { - fromDefinitionId: r.fromDefinitionId, - fromName: r.fromName, - fromKind: r.fromKind, - relationshipType: r.relationshipType, - semantic: r.semantic, - fromFilePath: r.fromFilePath, - fromLine: r.fromLine, - }; - inRelMap.set(key, mapped); - incomingRels.push(mapped); - } - } - } - - // Aggregate flows (deduplicate by flow id) - const flowMap = new Map(); - for (const def of fileDefs) { - for (const f of db.flows.getFlowsWithDefinition(def.id)) { - if (!flowMap.has(f.id)) { - flowMap.set(f.id, { id: f.id, name: f.name, slug: f.slug, stakeholder: f.stakeholder }); - } - } - } - - // Aggregate interactions using all file symbol names - const allSymbolNames = fileDefs.map((d) => d.name); - // Collect all dependency names across all definitions for outgoing - const allDepNames: string[] = []; - for (const def of fileDefs) { - for (const dep of db.dependencies.getForDefinition(def.id)) { - allDepNames.push(dep.name); - } - } - const uniqueDepNames = [...new Set(allDepNames)]; - - const inInteractionMap = new Map(); - const outInteractionMap = new Map(); - for (const [moduleId] of moduleMap) { - for (const i of db.interactions.getIncomingForSymbols(moduleId, allSymbolNames)) { - if (!inInteractionMap.has(i.id)) { - inInteractionMap.set(i.id, mapInteraction(i)); - } - } - for (const i of db.interactions.getOutgoingForSymbols(moduleId, uniqueDepNames)) { - if (!outInteractionMap.has(i.id)) { - outInteractionMap.set(i.id, mapInteraction(i)); - } - } - } - - const jsonData = { - file: relativePath || filePath, - symbols: fileDefs.map((d) => ({ - id: d.id, - name: d.name, - kind: d.kind, - line: d.line, - endLine: d.endLine, - isExported: d.isExported, - })), - modules: [...moduleMap.entries()].map(([, m]) => ({ name: m.name, fullPath: m.fullPath })), - relationships: { - outgoing: outgoingRels, - incoming: incomingRels, - }, - interactions: { - incoming: [...inInteractionMap.values()], - outgoing: [...outInteractionMap.values()], - }, - flows: [...flowMap.values()], - }; - - outputJsonOrPlain(this, jsonFlag ?? false, jsonData, () => { - this.outputFileModePlainText(jsonData); - }); - } - - private outputFileModePlainText(data: { - file: string; - symbols: Array<{ id: number; name: string; kind: string; line: number; endLine: number; isExported: boolean }>; - modules: Array<{ name: string; fullPath: string }>; - relationships: { - outgoing: Array<{ - toName: string; - toKind: string; - relationshipType: string; - semantic: string; - toFilePath: string; - toLine: number; - }>; - incoming: Array<{ - fromName: string; - fromKind: string; - relationshipType: string; - semantic: string; - fromFilePath: string; - fromLine: number; - }>; - }; - interactions: { incoming: MappedInteraction[]; outgoing: MappedInteraction[] }; - flows: Array<{ id: number; name: string; slug: string; stakeholder: string | null }>; - }): void { - this.log(chalk.bold(`=== File: ${data.file} ===`)); - - // Symbols - this.log(''); - this.log(chalk.bold(`=== Symbols (${data.symbols.length}) ===`)); - this.log(''); - for (const s of data.symbols) { - const exported = s.isExported ? chalk.green('exported') : chalk.gray('internal'); - this.log(` ${chalk.cyan(s.name)} (${s.kind}) ${exported} ${chalk.gray(`L${s.line}-${s.endLine}`)}`); - } - - // Modules - if (data.modules.length > 0) { - this.log(''); - this.log(chalk.bold(`=== Modules (${data.modules.length}) ===`)); - this.log(''); - for (const m of data.modules) { - this.log(` ${chalk.cyan(m.name)} ${chalk.gray(`(${m.fullPath})`)}`); - } - } - - // Relationships - if (data.relationships.outgoing.length > 0) { - this.log(''); - this.log(chalk.bold(`=== Relationships Outgoing (${data.relationships.outgoing.length}) ===`)); - this.log(''); - for (const r of data.relationships.outgoing) { - const semantic = r.semantic ? ` "${r.semantic}"` : ''; - this.log( - ` -> ${chalk.cyan(r.toName)} (${r.toKind}) [${r.relationshipType}]${chalk.gray(semantic)} ${chalk.gray(`${r.toFilePath}:${r.toLine}`)}` - ); - } - } - - if (data.relationships.incoming.length > 0) { - this.log(''); - this.log(chalk.bold(`=== Relationships Incoming (${data.relationships.incoming.length}) ===`)); - this.log(''); - for (const r of data.relationships.incoming) { - const semantic = r.semantic ? ` "${r.semantic}"` : ''; - this.log( - ` <- ${chalk.cyan(r.fromName)} (${r.fromKind}) [${r.relationshipType}]${chalk.gray(semantic)} ${chalk.gray(`${r.fromFilePath}:${r.fromLine}`)}` - ); - } - } - - // Interactions - this.printInteractionsSection('Incoming', data.interactions.incoming); - this.printInteractionsSection('Outgoing', data.interactions.outgoing); - - // Flows - if (data.flows.length > 0) { - this.log(''); - this.log(chalk.bold(`=== Flows (${data.flows.length}) ===`)); - this.log(''); - for (const f of data.flows) { - const stakeholder = f.stakeholder ? ` [${f.stakeholder}]` : ''; - this.log(` ${chalk.cyan(f.name)} (${f.slug})${chalk.gray(stakeholder)}`); - } - } - } - - private async getCallSitesWithContext( - db: IndexDatabase, - definitionId: number, - contextLines: number - ): Promise { - const callsites = db.dependencies.getCallsites(definitionId); - - // Group call sites by file for efficient reading - const byFile = new Map(); - for (const cs of callsites) { - if (!byFile.has(cs.filePath)) { - byFile.set(cs.filePath, []); - } - byFile.get(cs.filePath)!.push(cs); - } - - const result: CallSiteWithContext[] = []; - - for (const [filePath, fileCallsites] of byFile) { - // Read file content once using shared utility - const fileLines = await readAllLines(db.resolveFilePath(filePath)); - - if (fileLines.length === 0) { - // File not readable, add callsites without context - for (const cs of fileCallsites) { - result.push({ - filePath: cs.filePath, - line: cs.line, - column: cs.column, - containingFunction: null, - contextLines: [''], - contextStartLine: cs.line, - }); - } - continue; - } - - // Get definitions in this file to find containing functions - const fileId = db.files.getIdByPath(filePath); - const fileDefs = fileId ? db.definitions.getForFile(fileId) : []; - - for (const cs of fileCallsites) { - // Find containing function (smallest definition that contains this line) - const containingDef = fileDefs - .filter((def) => def.line <= cs.line && cs.line <= def.endLine) - .sort((a, b) => a.endLine - a.line - (b.endLine - b.line))[0]; - - const containingFunction = containingDef?.name ?? null; - - // Extract context lines - const startLine = Math.max(1, cs.line - contextLines); - const endLine = Math.min(fileLines.length, cs.line + contextLines); - const context = fileLines.slice(startLine - 1, endLine); - - result.push({ - filePath: cs.filePath, - line: cs.line, - column: cs.column, - containingFunction, - contextLines: context, - contextStartLine: startLine, - }); - } - } - - return result; - } - - private printInteractionsSection(label: string, interactions: MappedInteraction[]): void { - if (interactions.length === 0) return; - - this.log(''); - this.log(chalk.bold(`=== Interactions ${label} (${interactions.length}) ===`)); - this.log(''); - for (const i of interactions) { - const arrow = i.direction === 'bi' ? '\u2194' : '\u2192'; - const patternLabel = - i.pattern === 'business' ? chalk.cyan('[business]') : i.pattern === 'utility' ? chalk.yellow('[utility]') : ''; - const sourceLabel = i.source === 'llm-inferred' ? chalk.magenta('[inferred]') : chalk.gray('[ast]'); - - const fromShort = i.fromModulePath.split('.').slice(-2).join('.'); - const toShort = i.toModulePath.split('.').slice(-2).join('.'); - - this.log(` ${fromShort} ${arrow} ${toShort} ${patternLabel} ${sourceLabel}`); - - if (i.semantic) { - this.log(` ${chalk.gray(`"${i.semantic}"`)}`); - } - } - } - - private outputPlainText( - info: { - id: number; - name: string; - kind: string; - filePath: string; - line: number; - endLine: number; - isExported: boolean; - metadata: Record; - sourceCode: string[]; - callSites: CallSiteWithContext[]; - }, - outgoing: Array<{ - toName: string; - toKind: string; - relationshipType: string; - semantic: string; - toFilePath: string; - toLine: number; - }>, - incoming: Array<{ - fromName: string; - fromKind: string; - relationshipType: string; - semantic: string; - fromFilePath: string; - fromLine: number; - }>, - dependencies: Array<{ name: string; kind: string; filePath: string; line: number }>, - dependents: Array<{ name: string; kind: string; filePath: string; line: number }>, - dependentCount: number, - flows: Array<{ name: string; slug: string; stakeholder: string | null }>, - moduleResult: { module: { name: string; fullPath: string } } | null, - incomingInteractions: InteractionWithPaths[], - outgoingInteractions: InteractionWithPaths[] - ): void { - // Definition section - this.log(chalk.bold('=== Definition ===')); - this.log(''); - this.log(`Name: ${chalk.cyan(info.name)}`); - this.log(`Kind: ${info.kind}`); - this.log(`File: ${info.filePath}`); - this.log(`Lines: ${info.line}-${info.endLine}`); - this.log(`Exported: ${info.isExported ? 'yes' : 'no'}`); - - // Metadata section - const metadataKeys = Object.keys(info.metadata); - if (metadataKeys.length > 0) { - this.log(''); - this.log(chalk.bold('=== Metadata ===')); - this.log(''); - for (const key of metadataKeys.sort()) { - this.log(`${key}:`.padEnd(12) + info.metadata[key]); - } - } - - // Module section - if (moduleResult) { - this.log(''); - this.log(chalk.bold('=== Module ===')); - this.log(''); - this.log(`${chalk.cyan(moduleResult.module.name)} ${chalk.gray(`(${moduleResult.module.fullPath})`)}`); - } - - // Relationships (outgoing) - if (outgoing.length > 0) { - this.log(''); - this.log(chalk.bold(`=== Relationships Outgoing (${outgoing.length}) ===`)); - this.log(''); - for (const r of outgoing) { - const semantic = r.semantic ? ` "${r.semantic}"` : ''; - this.log( - ` -> ${chalk.cyan(r.toName)} (${r.toKind}) [${r.relationshipType}]${chalk.gray(semantic)} ${chalk.gray(`${r.toFilePath}:${r.toLine}`)}` - ); - } - } - - // Relationships (incoming) - if (incoming.length > 0) { - this.log(''); - this.log(chalk.bold(`=== Relationships Incoming (${incoming.length}) ===`)); - this.log(''); - for (const r of incoming) { - const semantic = r.semantic ? ` "${r.semantic}"` : ''; - this.log( - ` <- ${chalk.cyan(r.fromName)} (${r.fromKind}) [${r.relationshipType}]${chalk.gray(semantic)} ${chalk.gray(`${r.fromFilePath}:${r.fromLine}`)}` - ); - } - } - - // Dependencies - if (dependencies.length > 0) { - this.log(''); - this.log(chalk.bold(`=== Dependencies (${dependencies.length}) ===`)); - this.log(''); - for (const d of dependencies) { - this.log(` ${chalk.cyan(d.name)} (${d.kind}) ${chalk.gray(`${d.filePath}:${d.line}`)}`); - } - } - - // Dependents - if (dependentCount > 0) { - this.log(''); - this.log(chalk.bold(`=== Dependents (${dependents.length} of ${dependentCount}) ===`)); - this.log(''); - for (const d of dependents) { - this.log(` ${chalk.cyan(d.name)} (${d.kind}) ${chalk.gray(`${d.filePath}:${d.line}`)}`); - } - if (dependentCount > dependents.length) { - this.log(chalk.gray(` ... and ${dependentCount - dependents.length} more`)); - } - } - - // Flows - if (flows.length > 0) { - this.log(''); - this.log(chalk.bold(`=== Flows (${flows.length}) ===`)); - this.log(''); - for (const f of flows) { - const stakeholder = f.stakeholder ? ` [${f.stakeholder}]` : ''; - this.log(` ${chalk.cyan(f.name)} (${f.slug})${chalk.gray(stakeholder)}`); - } - } - - // Interactions - this.printInteractionsSection('Incoming', incomingInteractions.map(mapInteraction)); - this.printInteractionsSection('Outgoing', outgoingInteractions.map(mapInteraction)); - - // Source code section - this.log(''); - this.log(chalk.bold('=== Source Code ===')); - this.log(''); - for (let i = 0; i < info.sourceCode.length; i++) { - const lineNum = info.line + i; - const lineNumStr = String(lineNum).padStart(5, ' '); - this.log(`${chalk.gray(lineNumStr)} | ${info.sourceCode[i]}`); - } - - // Call sites section - this.log(''); - this.log(chalk.bold(`=== Call Sites (${info.callSites.length}) ===`)); - - if (info.callSites.length === 0) { - this.log(''); - this.log(chalk.gray('No call sites found.')); - return; - } - - for (const callSite of info.callSites) { - this.log(''); - const location = `${callSite.filePath}:${callSite.line}`; - const inFunction = callSite.containingFunction ? ` in ${chalk.cyan(callSite.containingFunction)}()` : ''; - this.log(`${chalk.yellow(location)}${inFunction}`); - this.log(chalk.gray('\u2500'.repeat(60))); - - for (let i = 0; i < callSite.contextLines.length; i++) { - const lineNum = callSite.contextStartLine + i; - const lineNumStr = String(lineNum).padStart(5, ' '); - const isTargetLine = lineNum === callSite.line; - const prefix = isTargetLine ? chalk.red('>') : ' '; - const line = callSite.contextLines[i]; - const formattedLine = isTargetLine ? chalk.white(line) : line; - this.log(`${prefix}${chalk.gray(lineNumStr)} | ${formattedLine}`); - } - } - } } + +// Re-export for convenience (used by consumers that previously imported from show.ts) +export { resolveFileId }; diff --git a/src/parser/_shared/ast-utils.ts b/src/parser/_shared/ast-utils.ts new file mode 100644 index 0000000..c21e131 --- /dev/null +++ b/src/parser/_shared/ast-utils.ts @@ -0,0 +1,19 @@ +import type { SyntaxNode } from 'tree-sitter'; + +/** + * Count the number of non-punctuation arguments in an arguments node. + * Skips `(`, `)`, and `,` tokens when counting. + */ +export function countArguments(argsNode: SyntaxNode): number { + let count = 0; + for (let i = 0; i < argsNode.childCount; i++) { + const child = argsNode.child(i); + if (child) { + const type = child.type; + if (type !== '(' && type !== ')' && type !== ',') { + count++; + } + } + } + return count; +} diff --git a/src/parser/reference-extractor.ts b/src/parser/reference-extractor.ts index f07f657..1fdac4e 100644 --- a/src/parser/reference-extractor.ts +++ b/src/parser/reference-extractor.ts @@ -1,6 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; import type { SyntaxNode } from 'tree-sitter'; +import { countArguments } from './_shared/ast-utils.js'; import type { Definition } from './definition-extractor.js'; import type { WorkspaceMap } from './workspace-resolver.js'; import { resolveWorkspaceImport } from './workspace-resolver.js'; @@ -252,24 +253,6 @@ interface UsageInfo { callsite?: CallsiteMetadata; } -/** - * Count the number of arguments in an arguments node - */ -function countArguments(argsNode: SyntaxNode): number { - let count = 0; - for (let i = 0; i < argsNode.childCount; i++) { - const child = argsNode.child(i); - if (child) { - // Skip punctuation: (, ), , - const type = child.type; - if (type !== '(' && type !== ')' && type !== ',') { - count++; - } - } - } - return count; -} - /** * Get the context (parent node type) for a usage, with optional callsite metadata */ diff --git a/test/commands/_shared/file-resolver.test.ts b/test/commands/_shared/file-resolver.test.ts new file mode 100644 index 0000000..6efb1a6 --- /dev/null +++ b/test/commands/_shared/file-resolver.test.ts @@ -0,0 +1,89 @@ +import path from 'node:path'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { resolveFileId } from '../../../src/commands/_shared/file-resolver.js'; +import type { IndexDatabase } from '../../../src/db/database.js'; + +describe('resolveFileId', () => { + let mockDb: IndexDatabase; + const fakeRelativePath = 'src/services/user.ts'; + const fakeAbsolutePath = '/workspace/project/src/services/user.ts'; + + beforeEach(() => { + mockDb = { + toRelativePath: vi.fn((p: string) => { + // Simulate: strip workspace root prefix + return p.startsWith('/workspace/project/') ? p.replace('/workspace/project/', '') : p; + }), + files: { + getIdByPath: vi.fn((p: string): number | null => { + if (p === fakeRelativePath) return 42; + return null; + }), + }, + } as unknown as IndexDatabase; + }); + + it('resolves via relative path (primary strategy)', () => { + // Provide absolute path; helper should convert to relative and find it + const result = resolveFileId(mockDb, fakeAbsolutePath); + expect(result).toBe(42); + expect(mockDb.files.getIdByPath).toHaveBeenCalledWith(fakeRelativePath); + }); + + it('resolves via absolute path when relative lookup fails', () => { + // Make relative lookup fail, absolute succeed + vi.mocked(mockDb.files.getIdByPath).mockImplementation((p: string) => { + if (p === fakeAbsolutePath) return 99; + return null; + }); + + const result = resolveFileId(mockDb, fakeAbsolutePath); + expect(result).toBe(99); + }); + + it('resolves via original path as last resort', () => { + const shortPath = 'user.ts'; + vi.mocked(mockDb.files.getIdByPath).mockImplementation((p: string) => { + if (p === shortPath) return 7; + return null; + }); + + const result = resolveFileId(mockDb, shortPath); + expect(result).toBe(7); + }); + + it('returns null when none of the strategies match', () => { + vi.mocked(mockDb.files.getIdByPath).mockReturnValue(null); + + const result = resolveFileId(mockDb, '/nonexistent/path.ts'); + expect(result).toBeNull(); + }); + + it('uses path.resolve internally so ~ and relative paths are normalised', () => { + const relativePath = './src/services/user.ts'; + const expectedResolved = path.resolve(relativePath); + const expectedRelative = expectedResolved.replace('/workspace/project/', ''); + + vi.mocked(mockDb.toRelativePath).mockImplementation((p: string) => { + return p === expectedResolved ? expectedRelative : p; + }); + vi.mocked(mockDb.files.getIdByPath).mockImplementation((p: string) => { + return p === expectedRelative ? 100 : null; + }); + + const result = resolveFileId(mockDb, relativePath); + expect(result).toBe(100); + }); + + it('prefers relative path over absolute path result', () => { + // Both relative and absolute would return a value; we should get relative one + vi.mocked(mockDb.files.getIdByPath).mockImplementation((p: string) => { + if (p === fakeRelativePath) return 42; + if (p === fakeAbsolutePath) return 99; + return null; + }); + + const result = resolveFileId(mockDb, fakeAbsolutePath); + expect(result).toBe(42); // relative path wins + }); +}); diff --git a/test/commands/symbols/show-data.test.ts b/test/commands/symbols/show-data.test.ts new file mode 100644 index 0000000..2a50521 --- /dev/null +++ b/test/commands/symbols/show-data.test.ts @@ -0,0 +1,230 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { SymbolShowDataGatherer } from '../../../src/commands/symbols/_show-data.js'; +import type { IndexDatabase } from '../../../src/db/database.js'; + +// ─── Minimal mock factories ─────────────────────────────────────────────────── + +function makeDefinition(overrides: Partial> = {}) { + return { + id: 1, + name: 'myFunction', + kind: 'function', + filePath: 'src/utils.ts', + line: 10, + endLine: 20, + isExported: true, + ...overrides, + }; +} + +function makeMockDb(overrides: Partial> = {}): IndexDatabase { + return { + definitions: { + getById: vi.fn(() => makeDefinition()), + getForFile: vi.fn(() => [makeDefinition()]), + }, + files: { + getIdByPath: vi.fn((p: string) => (p === 'src/utils.ts' ? 10 : null)), + }, + metadata: { + get: vi.fn(() => ({ purpose: 'test utility' })), + }, + modules: { + getDefinitionModule: vi.fn(() => null), + }, + relationships: { + getFrom: vi.fn(() => []), + getTo: vi.fn(() => []), + }, + dependencies: { + getForDefinition: vi.fn(() => []), + getIncoming: vi.fn(() => []), + getIncomingCount: vi.fn(() => 0), + getCallsites: vi.fn(() => []), + }, + flows: { + getFlowsWithDefinition: vi.fn(() => []), + }, + interactions: { + getIncomingForSymbols: vi.fn(() => []), + getOutgoingForSymbols: vi.fn(() => []), + }, + toRelativePath: vi.fn((p: string) => p), + resolveFilePath: vi.fn((p: string) => `/workspace/${p}`), + ...overrides, + } as unknown as IndexDatabase; +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('SymbolShowDataGatherer', () => { + let gatherer: SymbolShowDataGatherer; + let db: IndexDatabase; + + beforeEach(() => { + gatherer = new SymbolShowDataGatherer(); + db = makeMockDb(); + }); + + describe('gatherSymbolData', () => { + it('throws when definition not found', async () => { + vi.mocked(db.definitions.getById).mockReturnValue(null as never); + await expect(gatherer.gatherSymbolData(db, 999, 3)).rejects.toThrow('Definition with ID 999 not found'); + }); + + it('returns basic definition fields', async () => { + const data = await gatherer.gatherSymbolData(db, 1, 3); + expect(data.id).toBe(1); + expect(data.name).toBe('myFunction'); + expect(data.kind).toBe('function'); + expect(data.filePath).toBe('src/utils.ts'); + expect(data.line).toBe(10); + expect(data.endLine).toBe(20); + expect(data.isExported).toBe(true); + }); + + it('includes metadata', async () => { + const data = await gatherer.gatherSymbolData(db, 1, 3); + expect(data.metadata).toEqual({ purpose: 'test utility' }); + }); + + it('module is null when no module found', async () => { + const data = await gatherer.gatherSymbolData(db, 1, 3); + expect(data.module).toBeNull(); + }); + + it('includes module when found', async () => { + vi.mocked(db.modules.getDefinitionModule).mockReturnValue({ + module: { id: 5, name: 'MyModule', fullPath: 'project.mymodule' }, + } as never); + const data = await gatherer.gatherSymbolData(db, 1, 3); + expect(data.module).toEqual({ id: 5, name: 'MyModule', fullPath: 'project.mymodule' }); + }); + + it('includes empty relationships arrays by default', async () => { + const data = await gatherer.gatherSymbolData(db, 1, 3); + expect(data.relationships).toEqual([]); + expect(data.incomingRelationships).toEqual([]); + }); + + it('includes empty dependencies and dependents by default', async () => { + const data = await gatherer.gatherSymbolData(db, 1, 3); + expect(data.dependencies).toEqual([]); + expect(data.dependents.count).toBe(0); + expect(data.dependents.sample).toEqual([]); + }); + + it('includes empty flows and interactions by default', async () => { + const data = await gatherer.gatherSymbolData(db, 1, 3); + expect(data.flows).toEqual([]); + expect(data.interactions.incoming).toEqual([]); + expect(data.interactions.outgoing).toEqual([]); + }); + + it('maps relationships correctly', async () => { + vi.mocked(db.relationships.getFrom).mockReturnValue([ + { + toDefinitionId: 2, + toName: 'helperFn', + toKind: 'function', + relationshipType: 'calls', + semantic: 'delegates-to', + toFilePath: 'src/helper.ts', + toLine: 5, + }, + ] as never); + + const data = await gatherer.gatherSymbolData(db, 1, 3); + expect(data.relationships).toHaveLength(1); + expect(data.relationships[0]).toMatchObject({ + toName: 'helperFn', + toKind: 'function', + relationshipType: 'calls', + semantic: 'delegates-to', + }); + }); + + it('maps dependents correctly including count', async () => { + vi.mocked(db.dependencies.getIncomingCount).mockReturnValue(5); + vi.mocked(db.dependencies.getIncoming).mockReturnValue([ + { id: 3, name: 'caller', kind: 'function', filePath: 'src/main.ts', line: 1 }, + ] as never); + + const data = await gatherer.gatherSymbolData(db, 1, 3); + expect(data.dependents.count).toBe(5); + expect(data.dependents.sample).toHaveLength(1); + expect(data.dependents.sample[0].name).toBe('caller'); + }); + }); + + describe('gatherFileData', () => { + it('returns null when file not found', async () => { + vi.mocked(db.files.getIdByPath).mockReturnValue(null); + const result = await gatherer.gatherFileData(db, '/nonexistent/file.ts'); + expect(result).toBeNull(); + }); + + it('returns null when file has no definitions', async () => { + vi.mocked(db.files.getIdByPath).mockReturnValue(10); + vi.mocked(db.definitions.getForFile).mockReturnValue([]); + const result = await gatherer.gatherFileData(db, 'src/utils.ts'); + expect(result).toBeNull(); + }); + + it('returns file data when file has definitions', async () => { + vi.mocked(db.files.getIdByPath).mockReturnValue(10); + const result = await gatherer.gatherFileData(db, 'src/utils.ts'); + expect(result).not.toBeNull(); + expect(result!.symbols).toHaveLength(1); + expect(result!.symbols[0].name).toBe('myFunction'); + }); + + it('aggregates modules from all definitions', async () => { + vi.mocked(db.files.getIdByPath).mockReturnValue(10); + vi.mocked(db.modules.getDefinitionModule).mockReturnValue({ + module: { id: 7, name: 'UtilsModule', fullPath: 'project.utils' }, + } as never); + + const result = await gatherer.gatherFileData(db, 'src/utils.ts'); + expect(result!.modules).toHaveLength(1); + expect(result!.modules[0].name).toBe('UtilsModule'); + }); + + it('returns empty relationships when none exist', async () => { + vi.mocked(db.files.getIdByPath).mockReturnValue(10); + const result = await gatherer.gatherFileData(db, 'src/utils.ts'); + expect(result!.relationships.outgoing).toEqual([]); + expect(result!.relationships.incoming).toEqual([]); + }); + + it('deduplicates relationships across multiple definitions', async () => { + const def1 = makeDefinition({ id: 1 }); + const def2 = makeDefinition({ id: 2, name: 'otherFn' }); + vi.mocked(db.files.getIdByPath).mockReturnValue(10); + vi.mocked(db.definitions.getForFile).mockReturnValue([def1, def2] as never); + + const rel = { + toDefinitionId: 99, + toName: 'external', + toKind: 'function', + relationshipType: 'calls', + semantic: '', + toFilePath: 'src/external.ts', + toLine: 1, + fromDefinitionId: 1, + fromName: 'myFunction', + fromKind: 'function', + fromFilePath: 'src/utils.ts', + fromLine: 10, + }; + + // Both definitions return the same relationship (same toDefinitionId + type) + vi.mocked(db.relationships.getFrom).mockReturnValue([rel] as never); + + const result = await gatherer.gatherFileData(db, 'src/utils.ts'); + // Should not be deduplicated by different fromDefinitionId keys: def1 vs def2 keys differ + // def1: "1-99-calls", def2: "2-99-calls" -> 2 outgoing + expect(result!.relationships.outgoing).toHaveLength(2); + }); + }); +});