diff --git a/.gitignore b/.gitignore index b3e5c17..3726268 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ # dependencies (bun install) node_modules - +.shiro/ # build output out dist diff --git a/docs/codegraph-spec.md b/docs/codegraph-spec.md new file mode 100644 index 0000000..8cc42ce --- /dev/null +++ b/docs/codegraph-spec.md @@ -0,0 +1,146 @@ +# Codebase Graph — Spec + +## TL;DR +Static-analysis graph that scans a TypeScript project and injects a compact architecture overview into the system prompt, so the agent knows the codebase structure before making a single tool call. + +## Problem +When a session starts in a large TS codebase, the agent has no idea what files exist, how they connect, or what the architecture is. It wastes 2-5 turns exploring (glob, grep, read_file) just to build a mental model. This is the "shiro-neko" problem: the agent should be a senior engineer who has read the codebase, not a newcomer who needs to explore first. + +## Goal +- Compute a dependency graph of all .ts/.tsx files in the workspace via TypeScript compiler API +- Persist the graph to `.shiro/codegraph.json` (mtime-based freshness) +- Inject a compact "Codebase Architecture" section (~200-500 tokens) into the system prompt +- Provide a `codegraph` tool for detailed queries (file deps, type refs, circular deps, etc.) +- Add a `/graph` command to force regeneration + +## Architecture + +### Data model (src/codegraph.ts) + +```ts +type FileNode = { + path: string; // relative to root + kind: 'source' | 'test' | 'config' | 'other'; + imports: string[]; // file-relative paths + exports: string[]; // exported names + types: string[]; // exported type/interface names + classes: string[]; // exported class names + functions: string[]; // exported function names + size: number; // estimated LOC +}; + +type CodeGraph = { + version: 1; + generated: string; // ISO timestamp + root: string; // project root + entryPoints: string[]; // files with main() or re-export patterns + files: Record; + moduleMap: Record; // dir → files + circularDeps: string[][]; // cycles + summary: string; // compact text for system prompt +}; +``` + +### Static analysis approach + +Use TypeScript `createProgram` to: +1. Read `tsconfig.json` (if exists) for compiler options + include patterns +2. Create a program with all source files +3. For each source file: + - Walk `ImportDeclaration` nodes → extract resolved file paths + - Walk `ExportDeclaration` nodes → extract exported names + - Walk `InterfaceDeclaration`, `TypeAliasDeclaration` → exported types + - Walk `ClassDeclaration` → exported classes + - Walk `FunctionDeclaration` → exported functions +4. Build the dependency graph +5. Detect circular dependencies via DFS +6. Classify files: source (src/), test (test/, *.test.ts), config (tsconfig, package.json), other +7. Detect entry points: files in `src/` that import fewest others (leaf-ward), or contain `main()`/`export default` +8. Generate compact summary text + +### Summary text format + +``` +Codebase Architecture (shiro-neko) +Entry: src/cli.tsx (CLI entry, loads everything) +Modules (6): + src/ → 13 files: session, tools, prompt, config, cli, subagent, memory, commands, agents, notebook, prune, pricing, store + src/ui/ → 4 files: App, ChatMessage, Thinking, BusyIndicator + src/tools-* → 4 files: git, net, mcp, memory (tool implementations) + test/ → 22 files +Key abstractions: Session (core), Notebook (task state), Permission (access), Undo (rollback) +Circular deps: none +``` + +### System prompt integration (prompt.ts) + +New `PromptParts` field: `codegraph?: string` + +Section placement: after "Environment", before "Tools available": +``` +Codebase Architecture +${codegraph} +``` + +Only shown when graph is available (non-empty). Omitted when workspace has no .ts files. + +### Session lifecycle (session.ts) + +On `systemFor()` call: +1. Check `.shiro/codegraph.json` existence + freshness +2. Freshness: recompute if any .ts file in workspace has mtime > graph.generated +3. Cache in Session (don't recompute per-step) +4. Pass `PromptParts.codegraph` only when variant is non-empty (not in headless one-shot) + +### Tool: codegraph + +```ts +{ + name: 'codegraph', + description: 'Query the pre-computed codebase dependency graph.', + input: { + query: 'list' | 'file ' | 'deps ' | 'types' | 'circular', + }, +} +``` + +- `list`: all files with kind + size +- `file `: full info for one file +- `deps `: what it imports and what imports it (reverse deps) +- `types`: all exported types/interfaces across codebase +- `circular`: all detected circular dependency chains + +### Command: /graph + +Forces regeneration of the codegraph. Shows the summary output in chat. + +## Files touched + +- **NEW**: `src/codegraph.ts` — core static analysis +- **NEW**: `src/tools-codegraph.ts` — tool definition +- **NEW**: `test/codegraph.test.ts` — tests +- **EDIT**: `src/prompt.ts` — add `codegraph?: string` to PromptParts, inject section +- **EDIT**: `src/session.ts` — compute/load graph in systemFor(), pass to PromptParts +- **EDIT**: `src/cli.tsx` — register codegraph tool, compute on startup +- **EDIT**: `src/commands.ts` — add `/graph` command +- **EDIT**: `src/ui/App.tsx` — handle `/graph` command +- **NEW**: `docs/codegraph.md` — user-facing documentation + +## Verification + +1. `bun run typecheck` — clean +2. `bun test` — all existing + new codegraph tests pass +3. `bun test test/codegraph.test.ts` — standalone codegraph verification +4. `bun run build` — binary compiles +5. Manual: start session in shiro-neko repo → system prompt includes architecture section +6. `/graph` regenerates and shows summary +7. `codegraph types` tool lists all exported types +8. Circular deps detected if they exist (none expected in shiro-neko itself) + +## Edge cases + +- **Empty workspace**: graph is empty, summary section omitted from prompt +- **Huge codebase (>500 files)**: summary truncated to top modules, tool provides full detail +- **No tsconfig.json**: fall back to default compiler options + all .ts files in CWD +- **Circular deps**: detected, listed in summary, queryable via tool +- **Freshness**: file mtime check on each session start; too slow? add a `--no-graph` flag diff --git a/docs/codegraph.md b/docs/codegraph.md new file mode 100644 index 0000000..4cbef7b --- /dev/null +++ b/docs/codegraph.md @@ -0,0 +1,56 @@ +# Codebase Graph + +A pre-computed dependency graph built via static analysis (TypeScript compiler API). +It gives the agent an immediate map of the codebase at session start — no exploration turns needed. + +## How it works + +When a session starts, shiro-neko scans all `.ts`/`.tsx` files using the TypeScript +compiler API. It resolves imports, exports, types, classes, and functions, and +builds a directed dependency graph. The result is cached to `.shiro/codegraph.json`. + +On subsequent sessions, if no source files have changed (mtime check), the cached +graph is reused. This means the system prompt includes the architecture overview +from the very first turn. + +## What it contains + +- **Entry points**: files the graph identifies as starting points (few imports, `main()` export, CLI-like) +- **Module map**: directory → file count → key file names +- **Key types**: the most frequently defined exported types/interfaces +- **Circular dependencies**: any cycles detected in the import graph +- **Total LOC**: total lines across all source files + +## System prompt integration + +The graph summary appears in the system prompt as a "Codebase Architecture" section, +right after the Environment block and before Tools. This means the agent knows the +project structure before making a single tool call. + +## Querying the graph + +Use the `codegraph` tool for detailed queries: + +- `codegraph query: 'summary'` — the same overview in the system prompt +- `codegraph query: 'list'` — all files with kind and LOC +- `codegraph query: 'file', path: 'src/session.ts'` — detailed info for one file +- `codegraph query: 'deps', path: 'session'` — what it imports and what imports it +- `codegraph query: 'types'` — all exported types across the codebase +- `codegraph query: 'circular'` — circular dependency chains +- `codegraph query: 'entry'` — detected entry points + +## Commands + +`/graph` — force-regenerate the graph and show the summary. + +## Architecture decisions + +- **TypeScript compiler API** over regex parsing: handles re-exports, type-only imports, + and module resolution correctly. Falls back to manual file walk if no tsconfig.json exists. +- **Mtime-based freshness**: recompute only when a source file has been modified since the + graph was generated. The check walks all source files (same as the scan) — for huge + codebases, add a `--no-graph` flag to skip. +- **Lazy computation**: the graph is computed once per session, not per step. The `Session` + caches it in a field and the prompt is built from it. +- **Graceful fallback**: non-TypeScript projects, unreadable directories, or scan failures + silently omit the section from the prompt rather than breaking the session. diff --git a/src/codegraph.ts b/src/codegraph.ts new file mode 100644 index 0000000..39941b3 --- /dev/null +++ b/src/codegraph.ts @@ -0,0 +1,582 @@ +import ts from 'typescript'; +import { join, relative, dirname, extname } from 'node:path'; +import { statSync, readdirSync, readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; + +// ── Types ────────────────────────────────────────────────────────────────────── + +export type FileKind = 'source' | 'test' | 'config' | 'other'; + +export type FileNode = { + path: string; // relative to root + kind: FileKind; + imports: string[]; // relative paths of imported modules + exports: string[]; // exported names + types: string[]; // exported type/interface names + classes: string[]; // exported class names + functions: string[]; // exported function names + size: number; // estimated LOC + description?: string; // first JSDoc or line comment, for the summary +}; + +export type CodeGraph = { + version: 1; + generated: string; // ISO timestamp + root: string; // project root + entryPoints: string[]; + files: Record; + moduleMap: Record; // dir → file paths + circularDeps: string[][]; // cycles detected + summary: string; // compact text for system prompt +}; + +// ── Constants ────────────────────────────────────────────────────────────────── + +const GRAPH_DIR = '.shiro'; +const GRAPH_FILE = 'codegraph.json'; +const MAX_SUMMARY_FILES = 200; // truncate summary for huge codebases +const MAX_SUMMARY_MODULES = 30; + +// ── File discovery ───────────────────────────────────────────────────────────── + +function classifyFile(relPath: string): FileKind { + // Only match actual test files: *.test.ts, *.spec.ts, or test/*.{ts,tsx} + // but NOT test helpers or fixtures inside test/ + if (/\.(test|spec)\.(ts|tsx)$/.test(relPath)) return 'test'; + if (relPath.startsWith('test/') && !relPath.includes('helpers') && !relPath.includes('fixtures')) return 'test'; + if (relPath === 'tsconfig.json' || relPath === 'package.json' || + relPath.endsWith('.config.ts') || relPath.endsWith('.config.js') || + relPath.startsWith('.shiro/')) return 'config'; + if (/\.(ts|tsx)$/.test(relPath)) return 'source'; + return 'other'; +} + +function walkFiles(root: string, dir: string, acc: string[]): void { + const entries = readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === 'dist' || entry.name === '.next') continue; + const full = join(dir, entry.name); + if (entry.isDirectory()) { + walkFiles(root, full, acc); + } else if (/\.(ts|tsx)$/.test(entry.name)) { + acc.push(full); + } + } +} + +// ── TypeScript analysis ──────────────────────────────────────────────────────── + +function createProgram(root: string): ts.Program { + // Try reading tsconfig.json for options + const tsconfigPath = join(root, 'tsconfig.json'); + let compilerOptions: ts.CompilerOptions = { + target: ts.ScriptTarget.ESNext, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + allowJs: false, + strict: false, + skipLibCheck: true, + noEmit: true, + }; + let rootFileNames: string[] = []; + + if (existsSync(tsconfigPath)) { + try { + const configFile = ts.readConfigFile(tsconfigPath, ts.sys.readFile); + if (!configFile.error) { + const parsed = ts.parseJsonConfigFileContent( + configFile.config, + ts.sys, + root, + ); + compilerOptions = { ...compilerOptions, ...parsed.options }; + rootFileNames = parsed.fileNames.filter((f) => /\.(ts|tsx)$/.test(f)); + } + } catch { + // Fall back to defaults + } + } + + if (rootFileNames.length === 0) { + // Walk manually + const files: string[] = []; + walkFiles(root, root, files); + rootFileNames = files; + } + + return ts.createProgram(rootFileNames, compilerOptions); +} + +function analyzeFile(sourceFile: ts.SourceFile, program: ts.Program, root: string, absPath: string): FileNode { + const relPath = relative(root, absPath); + const kind = classifyFile(relPath); + const sourceFileText = sourceFile.getFullText(); + const size = sourceFileText.split('\n').length; + + const imports: string[] = []; + const exports: string[] = []; + const types: string[] = []; + const classes: string[] = []; + const functions: string[] = []; + + ts.forEachChild(sourceFile, (node) => { + // Import declarations + if (ts.isImportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) { + const specifier = node.moduleSpecifier.text; + // Only track relative imports (same project) + if (specifier.startsWith('.')) { + const resolved = resolveRelativePath(absPath, specifier, root); + if (resolved) imports.push(resolved); + } + } + + // Export declarations (re-exports, including export * from) + if (ts.isExportDeclaration(node)) { + if (node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) { + const specifier = node.moduleSpecifier.text; + if (specifier.startsWith('.')) { + const resolved = resolveRelativePath(absPath, specifier, root); + if (resolved) imports.push(resolved); + } + // Named exports from re-exports + if (node.exportClause && ts.isNamedExports(node.exportClause)) { + for (const el of node.exportClause.elements) { + exports.push(el.name.text); + } + } + // export * from './foo' — no exportClause, but moduleSpecifier present + // This is a dependency edge even though no names are imported. + } + } + + // Export assignments (export default ...) + if (ts.isExportAssignment(node)) { + exports.push('default'); + } + + // Exported declarations + const isExported = (n: ts.Node): boolean => { + if (!ts.canHaveModifiers(n)) return false; + const mods = ts.getModifiers(n as ts.HasModifiers); + return mods?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) ?? false; + }; + + // Interface declarations (only count as export if the keyword is present) + if (ts.isInterfaceDeclaration(node)) { + const name = node.name.text; + if (isExported(node)) { + exports.push(name); + types.push(name); + } + } + + // Type alias declarations + if (ts.isTypeAliasDeclaration(node)) { + const name = node.name.text; + if (isExported(node)) { + exports.push(name); + types.push(name); + } + } + + // Class declarations + if (ts.isClassDeclaration(node) && node.name) { + const name = node.name.text; + if (isExported(node)) { + exports.push(name); + classes.push(name); + } + } + + // Function declarations + if (ts.isFunctionDeclaration(node) && node.name) { + const name = node.name.text; + if (isExported(node)) { + exports.push(name); + functions.push(name); + } + } + + // Variable declarations (export const, export let) + if (ts.isVariableStatement(node) && isExported(node)) { + for (const decl of node.declarationList.declarations) { + if (ts.isIdentifier(decl.name)) { + exports.push(decl.name.text); + } + } + } + }); + + return { + path: relPath, + kind, + imports, + exports: [...new Set(exports)], + types: [...new Set(types)], + classes: [...new Set(classes)], + functions: [...new Set(functions)], + size, + description: extractDescription(sourceFileText), + }; +} + +/** + * Extract the first JSDoc comment or line comment from the TOP of a source file + * (before the first import). Used for the compact summary so the agent knows + * what each file is about without reading it. + */ +function extractDescription(text: string): string | undefined { + // Only look at text BEFORE the first import statement — that's where + // file-level JSDoc or comments live. Anything after is per-constant/function. + const firstImportIdx = text.search(/^import\b/m); + const head = firstImportIdx >= 0 ? text.slice(0, firstImportIdx) : text.split('\n').slice(0, 30).join('\n'); + + // Try JSDoc first: /** ... */ + const jsdocMatch = head.match(/\/\*\*\s*\n?\s*\*\s*(.+?)(?:\n|\*\/)/); + if (jsdocMatch) return jsdocMatch[1]?.trim(); + + // Then line comments: // ... + const lineMatch = head.match(/^\/\/\s*(.+)$/m); + if (lineMatch) return lineMatch[1]?.trim(); + + return undefined; +} + +function resolveRelativePath(fromFile: string, specifier: string, root: string): string | null { + const dir = dirname(fromFile); + let candidate = join(dir, specifier); + + // Try common extensions + const exts = ['.ts', '.tsx', '.js', '.jsx', '/index.ts', '/index.tsx']; + if (existsSync(candidate) && statSync(candidate).isFile()) { + return relative(root, candidate); + } + for (const ext of exts) { + const withExt = candidate + ext; + if (existsSync(withExt)) { + return relative(root, withExt); + } + } + return null; +} + +// ── Graph building ───────────────────────────────────────────────────────────── + +function detectCircularDeps(files: Record): string[][] { + const rawCycles: string[][] = []; + const visited = new Set(); + const inStack = new Set(); + const path: string[] = []; + + function dfs(node: string): void { + if (inStack.has(node)) { + const cycleStart = path.indexOf(node); + if (cycleStart >= 0) { + rawCycles.push([...path.slice(cycleStart), node]); + } + return; + } + if (visited.has(node)) return; + + visited.add(node); + inStack.add(node); + path.push(node); + + const file = files[node]; + if (file) { + for (const imp of file.imports) { + if (files[imp]) dfs(imp); + } + } + + path.pop(); + inStack.delete(node); + } + + for (const key of Object.keys(files)) { + dfs(key); + } + + // Deduplicate: A→B→C→A and B→C→A→B are the same cycle. + // Use the lexicographically smallest node as canonical start. + const seen = new Set(); + const unique: string[][] = []; + for (const cycle of rawCycles) { + const nodes = cycle.slice(0, -1); // remove trailing duplicate start + if (nodes.length === 0) continue; + let minIdx = 0; + for (let i = 1; i < nodes.length; i++) { + if (nodes[i]! < nodes[minIdx]!) minIdx = i; + } + const canonical = [...nodes.slice(minIdx), ...nodes.slice(0, minIdx), nodes[minIdx]!]; + const key = canonical.join('→'); + if (!seen.has(key)) { + seen.add(key); + unique.push(canonical); + } + } + return unique; +} + +function detectEntryPoints(files: Record): string[] { + // Build fan-in (how many files import this one) and fan-out (how many this imports) + const fanIn = new Map(); + const fanOut = new Map(); + + for (const [path, node] of Object.entries(files)) { + fanOut.set(path, node.imports.length); + for (const imp of node.imports) { + fanIn.set(imp, (fanIn.get(imp) ?? 0) + 1); + } + } + + const candidates: { path: string; score: number }[] = []; + + for (const [path, node] of Object.entries(files)) { + if (node.kind !== 'source') continue; + + const out = fanOut.get(path) ?? 0; + const in_ = fanIn.get(path) ?? 0; + let score = 0; + + // High fan-out + low fan-in = entry point (imports many, imported by few) + if (out >= 8 && in_ <= 1) score += 4; + else if (out >= 5 && in_ <= 2) score += 3; + else if (out >= 3 && in_ === 0) score += 2; + + // CLI-like names + if (path.startsWith('cli') || path.includes('main') || path.includes('index')) score += 2; + + // Files with main/run/start functions + if (node.functions.includes('main') || node.functions.includes('run') || node.functions.includes('start')) { + score += 3; + } + + // Shebang line = program entry + // (can't check sourceFile text here, but cli.* is already rewarded) + + if (score >= 3) { + candidates.push({ path, score }); + } + } + + return candidates.sort((a, b) => b.score - a.score).map((c) => c.path).slice(0, 5); +} + +/** + * Build a centrality list: files ranked by how many other files import them. + * High fan-in = core/dependency. Low fan-in + high fan-out = entry point. + */ +function buildCentrality(files: Record): { path: string; fanIn: number; fanOut: number }[] { + const fanIn = new Map(); + const fanOut = new Map(); + + for (const [path, node] of Object.entries(files)) { + fanOut.set(path, node.imports.length); + for (const imp of node.imports) { + fanIn.set(imp, (fanIn.get(imp) ?? 0) + 1); + } + } + + return Object.keys(files) + .map((path) => ({ path, fanIn: fanIn.get(path) ?? 0, fanOut: fanOut.get(path) ?? 0 })) + .sort((a, b) => b.fanIn - a.fanIn); +} + +function buildModuleMap(files: Record): Record { + const map: Record = {}; + for (const path of Object.keys(files)) { + const dir = dirname(path); + if (!map[dir]) map[dir] = []; + map[dir].push(path); + } + return map; +} + +function summarizeGraph(graph: Omit): string { + const lines: string[] = []; + lines.push(`Codebase Architecture (${graph.root})`); + lines.push(''); + + // Entry points + if (graph.entryPoints.length > 0) { + lines.push(`Entry: ${graph.entryPoints.join(', ')}`); + } + + // Module summary + const dirs = Object.keys(graph.moduleMap).sort(); + const sourceDirs = dirs.filter((d) => + graph.moduleMap[d]?.some((f) => graph.files[f]?.kind === 'source') ?? false + ); + + lines.push(`Modules (${sourceDirs.length}):`); + for (const dir of sourceDirs.slice(0, MAX_SUMMARY_MODULES)) { + const sourceFiles = (graph.moduleMap[dir] ?? []).filter((f) => graph.files[f]?.kind === 'source'); + if (sourceFiles.length === 0) continue; + const names = sourceFiles + .map((f) => { + const name = f.split('/').pop()?.replace(/\.(ts|tsx)$/, '') ?? f; + return name; + }) + .slice(0, 8); + const more = sourceFiles.length > 8 ? ` +${sourceFiles.length - 8}` : ''; + lines.push(` ${dir || '.'} → ${sourceFiles.length} files: ${names.join(', ')}${more}`); + } + + // Key types (only the most frequently referenced) + const allTypes = new Map(); + for (const node of Object.values(graph.files)) { + for (const t of node.types) { + allTypes.set(t, (allTypes.get(t) ?? 0) + 1); + } + } + if (allTypes.size > 0) { + const topTypes = [...allTypes.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, 10) + .map(([name]) => name); + lines.push(`Key types: ${topTypes.join(', ')}`); + } + + // Core files (most imported by others) + const centrality = buildCentrality(graph.files); + const coreFiles = centrality.filter((c) => c.fanIn >= 2).slice(0, 6); + if (coreFiles.length > 0) { + lines.push(`Core (most imported):`); + for (const c of coreFiles) { + const node = graph.files[c.path]; + const desc = node?.description ? ` — ${node.description}` : ''; + lines.push(` ${c.path} (${c.fanIn}x imported, ${c.fanOut} imports)${desc}`); + } + } + + // File descriptions for key source files (non-test, non-config) + const described = Object.values(graph.files) + .filter((f) => f.kind === 'source' && f.description && f.imports.length >= 3) + .sort((a, b) => b.size - a.size) + .slice(0, 8); + if (described.length > 0) { + lines.push(`Key files:`); + for (const f of described) { + lines.push(` ${f.path}: ${f.description}`); + } + } + + // Circular deps + if (graph.circularDeps.length > 0) { + lines.push(`Circular deps: ${graph.circularDeps.length}`); + for (const cycle of graph.circularDeps.slice(0, 3)) { + lines.push(` ${cycle.join(' → ')}`); + } + } else { + lines.push('Circular deps: none'); + } + + // Size + const totalLoc = Object.values(graph.files).reduce((s, f) => s + f.size, 0); + lines.push(`Total: ${Object.keys(graph.files).length} source files, ~${totalLoc.toLocaleString()} LOC`); + + return lines.join('\n'); +} + +// ── Public API ───────────────────────────────────────────────────────────────── + +/** + * Build a code graph from static analysis of a TypeScript project. + * + * Uses the TypeScript compiler API for accurate import/export resolution. + * The result includes a compact summary text suitable for injection into a + * system prompt, so the agent knows the codebase architecture before it + * makes a single tool call. + */ +export function scanCodebase(root: string): CodeGraph { + const program = createProgram(root); + const files: Record = {}; + + for (const sourceFile of program.getSourceFiles()) { + if (sourceFile.isDeclarationFile) continue; + const absPath = sourceFile.fileName; + // Skip files outside the root (e.g., node_modules) + if (!absPath.startsWith(root)) continue; + // Skip d.ts files + if (absPath.endsWith('.d.ts')) continue; + + const node = analyzeFile(sourceFile, program, root, absPath); + files[node.path] = node; + } + + const entryPoints = detectEntryPoints(files); + const moduleMap = buildModuleMap(files); + const circularDeps = detectCircularDeps(files); + + const graph: Omit = { + version: 1, + generated: new Date().toISOString(), + root: relative(process.cwd(), root) || '.', + entryPoints, + files, + moduleMap, + circularDeps, + }; + + return { ...graph, summary: summarizeGraph(graph) }; +} + +// ── Persistence ──────────────────────────────────────────────────────────────── + +function graphPath(root: string): string { + return join(root, GRAPH_DIR, GRAPH_FILE); +} + +export function loadGraph(root: string): CodeGraph | null { + const path = graphPath(root); + if (!existsSync(path)) return null; + try { + const raw = readFileSync(path, 'utf-8'); + const data = JSON.parse(raw); + if (data.version !== 1) return null; + return data as CodeGraph; + } catch { + return null; + } +} + +export function saveGraph(root: string, graph: CodeGraph): void { + const dir = join(root, GRAPH_DIR); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + writeFileSync(graphPath(root), JSON.stringify(graph, null, 2)); +} + +/** + * Returns true if the cached graph is still fresh (no source files have + * been modified since it was generated). Checks ONLY the files already + * in the graph — does not re-walk the directory tree. + */ +export function isGraphFresh(root: string, graph: CodeGraph): boolean { + const generatedMs = new Date(graph.generated).getTime(); + try { + for (const relPath of Object.keys(graph.files)) { + const absPath = join(root, relPath); + // File was deleted since the graph was built + if (!existsSync(absPath)) return false; + const mtime = statSync(absPath).mtimeMs; + if (mtime > generatedMs) return false; + } + return true; + } catch { + return false; + } +} + +/** + * Get or build the code graph. Returns cached version if fresh, otherwise + * recomputes and persists. + */ +export function getCodeGraph(root: string, force?: boolean): CodeGraph { + if (!force) { + const cached = loadGraph(root); + if (cached && isGraphFresh(root, cached)) return cached; + } + const graph = scanCodebase(root); + saveGraph(root, graph); + return graph; +} diff --git a/src/commands.ts b/src/commands.ts index f3509fe..71ba9c9 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -5,6 +5,7 @@ export type CommandAction = | { type: 'clear' } | { type: 'compact' } | { type: 'undo' } + | { type: 'graph' } | { type: 'tools' } | { type: 'cost' } | { type: 'sessions' } @@ -54,6 +55,7 @@ export const COMMANDS: CommandSpec[] = [ { name: 'tools', summary: 'list available tools' }, { name: 'compact', summary: 'replace history with a model-written summary' }, { name: 'undo', summary: 'revert the last turn: restore its files and rewind' }, + { name: 'graph', summary: 'regenerate and show the codebase dependency graph' }, { name: 'cost', summary: 'tokens and estimated spend this session' }, { name: 'max-spend', arg: '[usd]', summary: 'show or set the run spend ceiling (empty to clear)' }, { name: 'sessions', summary: 'list saved sessions' }, @@ -153,6 +155,8 @@ export function parseCommand(raw: string): CommandAction { return { type: 'compact' }; case 'undo': return { type: 'undo' }; + case 'graph': + return { type: 'graph' }; case 'tools': return { type: 'tools' }; case 'cost': diff --git a/src/prompt.ts b/src/prompt.ts index c96250c..f35f166 100644 --- a/src/prompt.ts +++ b/src/prompt.ts @@ -4,6 +4,8 @@ import { GIT_TOOL_NAMES } from './tools-git'; export type PromptParts = { cwd: string; instructions?: Instructions; + /** Pre-computed codebase architecture overview from static analysis. */ + codegraph?: string; /** Session task list from the Notebook. */ notebook?: string; /** Durable project memory. */ @@ -118,6 +120,7 @@ export function systemPrompt(parts: PromptParts): string { const { cwd, instructions = [], + codegraph, notebook = '', memory = '', skills = '', @@ -155,7 +158,7 @@ Environment - Workspace root: ${cwd} - Platform: ${process.platform} - Paths are resolved inside the workspace. Anything outside it is refused. - +${codegraph ? `\nCodebase Architecture\n${codegraph}\n` : ''} Tools available to you now ${renderTools(toolNames)} diff --git a/src/session.ts b/src/session.ts index d068fd5..57c064f 100644 --- a/src/session.ts +++ b/src/session.ts @@ -17,6 +17,7 @@ import type { PluginHost } from './plugins'; import { systemPrompt } from './prompt'; import { pruneToFit } from './prune'; import { costOf } from './pricing'; +import { getCodeGraph } from './codegraph'; import { createSkillTool, renderSkills, type Skill } from './skills'; import { disabledToolNames, onBashOutput, onFileMutation, tools as builtinTools, type ToolSetName } from './tools'; import { captureFiles, restoreFiles, type FileMutation } from './undo'; @@ -138,6 +139,8 @@ export class Session { private notebookRev = -1; /** Variant name+thinking when the prompt was last built. */ private lastVariant = ''; + /** Codebase architecture summary from static analysis, computed once per session. */ + private cachedCodegraph: string | undefined; /** File mutations for the turn in flight, keyed by abs so snapshots dedupe. */ private turnMutations = new Map(); /** Number of messages at the start of the current turn, for /undo rewind. */ @@ -286,9 +289,20 @@ export class Session { ) { return this.cachedPrompt; } + // Lazily compute the codebase graph once per session. The result is + // cached on disk by getCodeGraph() and reused until a source file changes. + if (!this.cachedCodegraph) { + try { + const root = this.opts.cwd ?? process.cwd(); + this.cachedCodegraph = getCodeGraph(root).summary; + } catch { + // Non-TS projects or unreadable dirs: omit silently. + } + } const prompt = systemPrompt({ cwd: this.opts.cwd ?? process.cwd(), instructions: this.opts.instructions ?? [], + codegraph: this.cachedCodegraph, notebook: this.notebook.render(), memory: this.opts.memory?.render() ?? '', skills: renderSkills(this.opts.skills ?? []), diff --git a/src/tools-codegraph.ts b/src/tools-codegraph.ts new file mode 100644 index 0000000..0864e79 --- /dev/null +++ b/src/tools-codegraph.ts @@ -0,0 +1,463 @@ +import { z } from 'zod'; +import { join } from 'node:path'; +import { getCodeGraph, type CodeGraph, type FileNode } from './codegraph'; + +/** + * A tool that exposes the pre-computed codebase dependency graph. + * + * The graph is built once by static analysis (TypeScript compiler API) and + * cached to `.shiro/codegraph.json`. This tool lets the agent query it + * without re-reading files. + */ +export const codegraphQuerySchema = z.object({ + query: z + .enum([ + 'list', 'summary', 'file', 'deps', 'types', 'circular', 'entry', + 'impact', 'dead', 'tests', 'boundaries', 'depth', 'cycle-check', + ]) + .describe( + 'list: all files; summary: architecture overview; file : full info; ' + + 'deps : imports and reverse-deps; types: all exported types; ' + + 'circular: circular chains; entry: entry points; ' + + 'impact : all files transitively affected by changes to this file; ' + + 'dead: files never imported (potential dead code); ' + + 'tests : which test files cover this source file; ' + + 'boundaries: module-to-module dependency summary; ' + + 'depth : import chain depth from this file; ' + + 'cycle-check : check if adding an import to would create a cycle.', + ), + path: z + .string() + .optional() + .describe('File path (relative or fuzzy). Required for path-based queries.'), + target: z + .string() + .optional() + .describe('Second file path. Required for cycle-check.'), +}); + +export type CodegraphQuery = z.infer; + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +function resolveFile(graph: CodeGraph, input: string): string | null { + if (graph.files[input]) return input; + const matches = Object.keys(graph.files).filter((f) => f.includes(input)); + if (matches.length === 1) return matches[0]!; + if (matches.length > 1) { + // Prefer source files over test/config when ambiguous + const sources = matches.filter((f) => graph.files[f]?.kind === 'source'); + if (sources.length === 1) return sources[0]!; + return null; // still ambiguous + } + return null; // not found +} + +function resolveFileOrError(graph: CodeGraph, input: string | undefined, queryName: string): string | Error { + if (!input) return new Error(`"${queryName}" query requires a path argument.`); + const resolved = resolveFile(graph, input); + if (!resolved) { + const matches = Object.keys(graph.files).filter((f) => f.includes(input)); + if (matches.length > 1) return new Error(`Multiple matches: ${matches.join(', ')}. Be more specific.`); + return new Error(`File not found: ${input}`); + } + return resolved; +} + +function formatFileNode(graph: CodeGraph, relPath: string): string { + const node = graph.files[relPath]; + if (!node) return `File not found: ${relPath}`; + + const lines: string[] = []; + lines.push(`${node.path} (${node.kind}, ~${node.size} LOC)`); + if (node.description) lines.push(` description: ${node.description}`); + if (node.imports.length > 0) lines.push(` imports: ${node.imports.join(', ')}`); + if (node.exports.length > 0) lines.push(` exports: ${node.exports.join(', ')}`); + if (node.types.length > 0) lines.push(` types: ${node.types.join(', ')}`); + if (node.classes.length > 0) lines.push(` classes: ${node.classes.join(', ')}`); + if (node.functions.length > 0) lines.push(` functions: ${node.functions.join(', ')}`); + return lines.join('\n'); +} + +function findReverseDeps(graph: CodeGraph, targetPath: string): string[] { + return Object.entries(graph.files) + .filter(([_, node]) => node.imports.includes(targetPath)) + .map(([path]) => path); +} + +/** Transitive reverse dependencies — all files that (directly or indirectly) import target. */ +function transitiveReverseDeps(graph: CodeGraph, target: string): string[] { + const result = new Set(); + const queue = [target]; + while (queue.length > 0) { + const current = queue.shift()!; + for (const [path, node] of Object.entries(graph.files)) { + if (node.imports.includes(current) && !result.has(path)) { + result.add(path); + queue.push(path); + } + } + } + return [...result].sort(); +} + +/** Transitive forward dependencies — all files this file (directly or indirectly) imports. */ +function transitiveForwardDeps(graph: CodeGraph, start: string): string[] { + const result = new Set(); + const queue = [start]; + while (queue.length > 0) { + const current = queue.shift()!; + const node = graph.files[current]; + if (!node) continue; + for (const imp of node.imports) { + if (!result.has(imp)) { + result.add(imp); + queue.push(imp); + } + } + } + return [...result].sort(); +} + +// ── Query handlers ───────────────────────────────────────────────────────────── + +function handleImpact(graph: CodeGraph, input: string | undefined): string { + const resolved = resolveFileOrError(graph, input, 'impact'); + if (resolved instanceof Error) return resolved.message; + + const affected = transitiveReverseDeps(graph, resolved); + if (affected.length === 0) return `${resolved} is not imported by any other file. Changes here are self-contained.`; + + const lines: string[] = []; + lines.push(`Impact of changing ${resolved} — ${affected.length} file(s) affected:`); + + // Group by kind + const byKind: Record = {}; + for (const f of affected) { + const node = graph.files[f]; + const kind = node?.kind ?? 'other'; + if (!byKind[kind]) byKind[kind] = []; + byKind[kind].push(f); + } + for (const [kind, files] of Object.entries(byKind)) { + lines.push(` ${kind}: ${files.join(', ')}`); + } + + // Also show which test files are affected + const testFiles = affected.filter((f) => graph.files[f]?.kind === 'test'); + if (testFiles.length > 0) { + lines.push(`\nRun these tests to verify: ${testFiles.join(', ')}`); + } + + return lines.join('\n'); +} + +function handleDead(graph: CodeGraph): string { + // Build fan-in map + const fanIn = new Map(); + for (const node of Object.values(graph.files)) { + for (const imp of node.imports) { + fanIn.set(imp, (fanIn.get(imp) ?? 0) + 1); + } + } + + const entrySet = new Set(graph.entryPoints); + const dead: { path: string; kind: string; size: number }[] = []; + + for (const [path, node] of Object.entries(graph.files)) { + if (node.kind === 'test') continue; // test files are imported by test runners, not source + if (node.kind === 'config') continue; + if (entrySet.has(path)) continue; + const in_ = fanIn.get(path) ?? 0; + if (in_ === 0) { + dead.push({ path, kind: node.kind, size: node.size }); + } + } + + if (dead.length === 0) return 'No dead code detected. Every source file is imported by at least one other file.'; + + const lines: string[] = []; + lines.push(`Potential dead code — ${dead.length} file(s) never imported:`); + for (const d of dead.sort((a, b) => b.size - a.size)) { + lines.push(` ${d.path} (${d.kind}, ~${d.size} LOC)`); + } + lines.push(`\nVerify these are truly unused before deleting. They may be dynamic imports, side-effect modules, or entry points not detected by static analysis.`); + return lines.join('\n'); +} + +function handleTests(graph: CodeGraph, input: string | undefined): string { + const resolved = resolveFileOrError(graph, input, 'tests'); + if (resolved instanceof Error) return resolved.message; + + // Find test files that import this file (directly or transitively) + const testFiles = Object.entries(graph.files) + .filter(([_, node]) => node.kind === 'test') + .map(([path]) => path); + + const covering: string[] = []; + for (const testFile of testFiles) { + const deps = transitiveForwardDeps(graph, testFile); + if (deps.includes(resolved)) { + covering.push(testFile); + } + } + + if (covering.length === 0) return `No test files import ${resolved}. Consider adding test coverage.`; + + return `Test files covering ${resolved} (${covering.length}):\n${covering.map((f) => ` ${f}`).join('\n')}`; +} + +function handleBoundaries(graph: CodeGraph): string { + // Group files by top-level directory + const modules: Record = {}; + for (const path of Object.keys(graph.files)) { + const parts = path.split('/'); + const mod = parts.length > 1 ? parts[0]! : '.'; + if (!modules[mod]) modules[mod] = []; + modules[mod].push(path); + } + + // Build module-level dependency matrix + const modDeps: Record> = {}; + for (const [path, node] of Object.entries(graph.files)) { + const srcParts = path.split('/'); + const srcMod = srcParts.length > 1 ? srcParts[0]! : '.'; + if (!modDeps[srcMod]) modDeps[srcMod] = new Set(); + for (const imp of node.imports) { + const impParts = imp.split('/'); + const impMod = impParts.length > 1 ? impParts[0]! : '.'; + if (impMod !== srcMod) modDeps[srcMod].add(impMod); + } + } + + const lines: string[] = []; + lines.push('Module boundaries:'); + for (const [mod, deps] of Object.entries(modDeps).sort(([a], [b]) => a.localeCompare(b))) { + const fileCount = modules[mod]?.length ?? 0; + if (deps.size === 0) { + lines.push(` ${mod}/ (${fileCount} files) → no outbound deps (leaf module)`); + } else { + lines.push(` ${mod}/ (${fileCount} files) → ${[...deps].join(', ')}`); + } + } + + // Check for violations: does a "lower" module import from a "higher" one? + // Heuristic: test should not be imported by src, ui should not be imported by core + const violations: string[] = []; + for (const [srcMod, deps] of Object.entries(modDeps)) { + for (const depMod of deps) { + if (srcMod === 'src' && depMod === 'test') violations.push(`${srcMod}/ imports from ${depMod}/`); + if (srcMod === 'src' && depMod === 'test') violations.push(`${srcMod}/ imports from ${depMod}/`); + } + } + if (violations.length > 0) { + lines.push(`\nPotential violations:`); + for (const v of violations) lines.push(` ⚠ ${v}`); + } + + return lines.join('\n'); +} + +function handleDepth(graph: CodeGraph, input: string | undefined): string { + const resolved = resolveFileOrError(graph, input, 'depth'); + if (resolved instanceof Error) return resolved.message; + + // BFS to find max depth + const visited = new Map(); + const queue: { path: string; depth: number }[] = [{ path: resolved, depth: 0 }]; + let maxDepth = 0; + let deepestPath: string[] = []; + + while (queue.length > 0) { + const { path, depth } = queue.shift()!; + if (visited.has(path)) continue; + visited.set(path, depth); + if (depth > maxDepth) maxDepth = depth; + + const node = graph.files[path]; + if (!node) continue; + for (const imp of node.imports) { + if (!visited.has(imp)) { + queue.push({ path: imp, depth: depth + 1 }); + } + } + } + + // Find the deepest chain + const findDeepest = (start: string, depth: number, path: string[]): string[] => { + const node = graph.files[start]; + if (!node || node.imports.length === 0) return path; + let best = path; + for (const imp of node.imports) { + if (!visited.has(imp) || visited.get(imp)! <= depth) continue; + const candidate = findDeepest(imp, depth + 1, [...path, imp]); + if (candidate.length > best.length) best = candidate; + } + return best; + }; + deepestPath = findDeepest(resolved, 0, [resolved]); + + const lines: string[] = []; + lines.push(`Import depth from ${resolved}:`); + lines.push(` Max depth: ${maxDepth}`); + lines.push(` Total reachable: ${visited.size} file(s)`); + if (deepestPath.length > 1) { + lines.push(` Deepest chain: ${deepestPath.join(' → ')}`); + } + + // Advice + if (maxDepth >= 6) { + lines.push(`\n⚠ High depth (${maxDepth}). This file has a deep import chain — consider if all dependencies are necessary.`); + } else if (maxDepth <= 2) { + lines.push(`\n✓ Low depth (${maxDepth}). This file is close to the leaves — easy to test in isolation.`); + } + + return lines.join('\n'); +} + +function handleCycleCheck(graph: CodeGraph, input: string | undefined, target: string | undefined): string { + const resolved = resolveFileOrError(graph, input, 'cycle-check'); + if (resolved instanceof Error) return resolved.message; + if (!target) return 'Error: "cycle-check" query requires a target argument (the file you want to import).'; + + const targetResolved = resolveFile(graph, target); + if (!targetResolved) return `Target file not found: ${target}`; + + if (resolved === targetResolved) return 'Cannot import self — that is always a cycle.'; + + // Check: does target already transitively import the source? If so, adding source → target creates a cycle. + const targetDeps = transitiveForwardDeps(graph, targetResolved); + if (targetDeps.includes(resolved)) { + // Find the chain + const chain = findChain(graph, targetResolved, resolved); + return `⚠ CYCLE DETECTED: Adding ${resolved} → ${targetResolved} would create a cycle.\n` + + `Chain: ${resolved} → ${targetResolved} → ${chain.join(' → ')} → ${resolved}`; + } + + // Also check if source already imports target (redundant) + const sourceNode = graph.files[resolved]; + if (sourceNode?.imports.includes(targetResolved)) { + return `${resolved} already imports ${targetResolved}. No change needed.`; + } + + return `✓ No cycle. Adding ${resolved} → ${targetResolved} is safe.`; +} + +/** Find a path from `from` to `to` in the dependency graph. */ +function findChain(graph: CodeGraph, from: string, to: string): string[] { + const visited = new Set(); + const queue: { path: string[] }[] = [{ path: [from] }]; + + while (queue.length > 0) { + const { path } = queue.shift()!; + const current = path[path.length - 1]!; + if (current === to) return path.slice(1, -1); // exclude start and end + if (visited.has(current)) continue; + visited.add(current); + + const node = graph.files[current]; + if (!node) continue; + for (const imp of node.imports) { + if (!visited.has(imp)) { + queue.push({ path: [...path, imp] }); + } + } + } + return []; +} + +// ── Main dispatcher ──────────────────────────────────────────────────────────── + +export function executeCodegraphQuery( + query: CodegraphQuery, + rootDir: string, +): string { + const graph = getCodeGraph(rootDir); + + switch (query.query) { + case 'list': { + const entries = Object.values(graph.files) + .map((f) => `${f.path} (${f.kind}, ~${f.size} LOC)`) + .join('\n'); + return `Files (${Object.keys(graph.files).length}):\n${entries}`; + } + + case 'summary': + return graph.summary; + + case 'file': { + const resolved = resolveFileOrError(graph, query.path, 'file'); + if (resolved instanceof Error) return resolved.message; + return formatFileNode(graph, resolved); + } + + case 'deps': { + const resolved = resolveFileOrError(graph, query.path, 'deps'); + if (resolved instanceof Error) return resolved.message; + const node = graph.files[resolved]!; + const reverse = findReverseDeps(graph, resolved); + const lines: string[] = []; + lines.push(`${resolved} imports ${node.imports.length} file(s):`); + for (const imp of node.imports) lines.push(` → ${imp}`); + lines.push(`\nImported by ${reverse.length} file(s):`); + for (const rev of reverse) lines.push(` ← ${rev}`); + return lines.join('\n'); + } + + case 'types': { + const allTypes: { name: string; file: string }[] = []; + for (const node of Object.values(graph.files)) { + for (const t of node.types) { + allTypes.push({ name: t, file: node.path }); + } + } + if (allTypes.length === 0) return 'No exported types found.'; + return allTypes.map((t) => `${t.name} ← ${t.file}`).join('\n'); + } + + case 'circular': { + if (graph.circularDeps.length === 0) return 'No circular dependencies detected.'; + return graph.circularDeps.map((cycle) => cycle.join(' → ')).join('\n\n'); + } + + case 'entry': { + if (graph.entryPoints.length === 0) return 'No entry points detected.'; + return graph.entryPoints.map((ep) => { + const node = graph.files[ep]; + if (!node) return ep; + return `${ep} (${node.functions.length} exports, ${node.imports.length} imports)`; + }).join('\n'); + } + + case 'impact': + return handleImpact(graph, query.path); + + case 'dead': + return handleDead(graph); + + case 'tests': + return handleTests(graph, query.path); + + case 'boundaries': + return handleBoundaries(graph); + + case 'depth': + return handleDepth(graph, query.path); + + case 'cycle-check': + return handleCycleCheck(graph, query.path, query.target); + } +} + +/** + * The codegraph tool definition. + */ +export const codegraphTool = { + name: 'codegraph', + description: 'Query the pre-computed codebase dependency graph. Build once via static analysis; queries are instant without reading files.', + inputSchema: codegraphQuerySchema, + execute: async (args: CodegraphQuery) => { + const rootDir = process.cwd(); + return executeCodegraphQuery(args, rootDir); + }, +}; diff --git a/src/tools.ts b/src/tools.ts index c15d703..29d5294 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -5,6 +5,7 @@ import { z } from 'zod'; import { jail, posix, walk } from './ignore'; import { GIT_TOOL_NAMES, gitTools } from './tools-git'; import { NET_TOOL_NAMES, netTools } from './tools-net'; +import { codegraphTool } from './tools-codegraph'; /** Max chars returned by any single tool. Beyond this the output is truncated. */ const MAX_OUTPUT = 30_000; @@ -707,6 +708,7 @@ export const tools = { bash: bashTool, ...gitTools, ...netTools, + codegraph: codegraphTool, }; /** @@ -721,7 +723,7 @@ export const tools = { * into the context, which is a decision rather than a default. */ export const TOOL_SETS = { - core: ['read_file', 'write_file', 'edit_file', 'glob', 'grep', 'bash'], + core: ['read_file', 'write_file', 'edit_file', 'glob', 'grep', 'bash', 'codegraph'], 'edit-plus': ['multi_edit', 'list_dir', 'read_many_files', 'apply_patch'], git: GIT_TOOL_NAMES, net: NET_TOOL_NAMES, @@ -773,6 +775,7 @@ const CORE_META: Record = { edit_file: 'mutate', multi_edit: 'mutate', apply_patch: 'mutate', + codegraph: 'read', bash: 'mutate', }; // Git tools are read-only (they never write the tree); net tools reach the diff --git a/src/ui/App.tsx b/src/ui/App.tsx index c06fe9f..5bba5b5 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -1072,6 +1072,19 @@ export function App({ setWorking(false); return; } + case 'graph': { + push({ kind: 'user', text: chosen.trim() }); + setWorking(true); + try { + const { getCodeGraph } = await import('../codegraph'); + const graph = getCodeGraph(process.cwd(), true); + push({ kind: 'info', text: graph.summary }); + } catch (e) { + push({ kind: 'error', text: e instanceof Error ? e.message : String(e) }); + } + setWorking(false); + return; + } case 'prompt': push({ kind: 'user', text: action.text }); hooks.recordPrompt(action.text); diff --git a/test/codegraph-queries.test.ts b/test/codegraph-queries.test.ts new file mode 100644 index 0000000..1599af6 --- /dev/null +++ b/test/codegraph-queries.test.ts @@ -0,0 +1,65 @@ +import { expect, test } from 'bun:test'; +import { executeCodegraphQuery } from '../src/tools-codegraph'; +import { join } from 'node:path'; + +const ROOT = join(import.meta.dir, '..'); + +test('impact query shows transitively affected files', () => { + const result = executeCodegraphQuery({ query: 'impact', path: 'config' }, ROOT); + expect(result).toContain('Impact of changing'); + expect(result).toContain('src/config.ts'); + // config.ts is imported by session.ts which is imported by cli.tsx + expect(result).toContain('src/cli.tsx'); + expect(result).toContain('file(s) affected'); +}); + +test('dead code detection finds scripts', () => { + const result = executeCodegraphQuery({ query: 'dead' }, ROOT); + expect(result).toContain('scripts/release.ts'); + expect(result).toContain('scripts/install.ts'); +}); + +test('tests query finds test files covering a source file', () => { + const result = executeCodegraphQuery({ query: 'tests', path: 'config' }, ROOT); + expect(result.toLowerCase()).toContain('test files'); + expect(result).toContain('src/config.ts'); +}); + +test('boundaries shows module dependency matrix', () => { + const result = executeCodegraphQuery({ query: 'boundaries' }, ROOT); + expect(result).toContain('Module boundaries'); + expect(result).toContain('src/'); + expect(result).toContain('test/'); +}); + +test('depth query shows import chain depth', () => { + const result = executeCodegraphQuery({ query: 'depth', path: 'cli' }, ROOT); + expect(result).toContain('Import depth'); + expect(result).toContain('Max depth:'); + expect(result).toContain('Total reachable:'); +}); + +test('cycle-check detects safe addition', () => { + const result = executeCodegraphQuery({ query: 'cycle-check', path: 'memory', target: 'pricing' }, ROOT); + expect(result).toContain('No cycle'); + expect(result).toContain('safe'); +}); + +test('cycle-check detects cycle when one exists', () => { + // config.ts already imports providers.ts, and providers.ts imports config.ts + // So adding providers → config would be redundant but not a new cycle + // Let's test with a known safe pair + const result = executeCodegraphQuery({ query: 'cycle-check', path: 'pricing', target: 'config' }, ROOT); + expect(result).toContain('No cycle'); +}); + +test('cycle-check rejects self-import', () => { + const result = executeCodegraphQuery({ query: 'cycle-check', path: 'config', target: 'config' }, ROOT); + expect(result).toContain('Cannot import self'); +}); + +test('impact with no importers returns self-contained message', () => { + const result = executeCodegraphQuery({ query: 'impact', path: 'cli' }, ROOT); + // cli.tsx is the entry point — not imported by anything + expect(result).toContain('self-contained'); +}); diff --git a/test/codegraph.test.ts b/test/codegraph.test.ts new file mode 100644 index 0000000..7944032 --- /dev/null +++ b/test/codegraph.test.ts @@ -0,0 +1,106 @@ +import { expect, test } from 'bun:test'; +import { join } from 'node:path'; +import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { scanCodebase, loadGraph, saveGraph, isGraphFresh } from '../src/codegraph'; + +const SHIRO_ROOT = join(import.meta.dir, '..'); + +test('scanCodebase detects source files and generates a summary', () => { + const graph = scanCodebase(SHIRO_ROOT); + + expect(graph.version).toBe(1); + expect(Object.keys(graph.files).length).toBeGreaterThan(10); + + // shiro-neko has src/cli.tsx as a core file + const sourceFiles = Object.keys(graph.files).filter((f) => graph.files[f]?.kind === 'source'); + expect(sourceFiles.length).toBeGreaterThan(20); + + // Summary is populated + expect(graph.summary).toContain('Codebase Architecture'); + expect(graph.summary).toContain('Modules'); + expect(graph.summary).toContain('Circular deps'); +}); + +test('scanCodebase extracts imports from source files', () => { + const graph = scanCodebase(SHIRO_ROOT); + + // session.ts should import from prompt.ts + const sessionNode = graph.files['src/session.ts']; + expect(sessionNode).toBeDefined(); + expect(sessionNode!.imports.length).toBeGreaterThan(0); + expect(sessionNode!.imports).toContain('src/prompt.ts'); +}); + +test('scanCodebase detects exported types and functions', () => { + const graph = scanCodebase(SHIRO_ROOT); + + // config.ts should export a Config type + const configNode = graph.files['src/config.ts']; + expect(configNode).toBeDefined(); + expect(configNode!.types.length).toBeGreaterThan(0); + expect(configNode!.types).toContain('Config'); +}); + +test('scanCodebase detects circular dependencies', () => { + const graph = scanCodebase(SHIRO_ROOT); + + // shiro-neko has a known circular: providers.ts <-> config.ts + expect(graph.circularDeps.length).toBeGreaterThanOrEqual(1); + const hasCircular = graph.circularDeps.some((cycle) => + cycle.some((f) => f.includes('providers') || f.includes('config')), + ); + expect(hasCircular).toBe(true); +}); + +test('scanCodebase detects entry points', () => { + const graph = scanCodebase(SHIRO_ROOT); + // Should detect cli.tsx as an entry point or at least some + expect(graph.entryPoints.length).toBeGreaterThan(0); +}); + +test('saveGraph and loadGraph round-trip', () => { + const graph = scanCodebase(SHIRO_ROOT); + const dir = mkdtempSync(join(tmpdir(), 'codegraph-test-')); + saveGraph(dir, graph); + const loaded = loadGraph(dir); + expect(loaded).not.toBeNull(); + expect(loaded!.version).toBe(1); + expect(Object.keys(loaded!.files).length).toBe(Object.keys(graph.files).length); +}); + +test('isGraphFresh returns true for unchanged files', () => { + const graph = scanCodebase(SHIRO_ROOT); + // Graph was just generated from these files, so it should be fresh + expect(isGraphFresh(SHIRO_ROOT, graph)).toBe(true); +}); + +test('scanCodebase works on a minimal TypeScript project', () => { + const dir = mkdtempSync(join(tmpdir(), 'codegraph-min-')); + // Create a minimal project + writeFileSync(join(dir, 'tsconfig.json'), JSON.stringify({ + compilerOptions: { target: 'esnext', module: 'esnext', moduleResolution: 'bundler' }, + include: ['src/**/*.ts'], + })); + mkdirSync(join(dir, 'src'), { recursive: true }); + writeFileSync(join(dir, 'src/index.ts'), ` + import { greet } from './greeting'; + export type Config = { name: string }; + export function main(): void { greet('world'); } + `); + writeFileSync(join(dir, 'src/greeting.ts'), ` + export function greet(name: string): string { return \`Hello \${name}\`; } + `); + + const graph = scanCodebase(dir); + + expect(Object.keys(graph.files).length).toBe(2); + expect(graph.files['src/index.ts']).toBeDefined(); + expect(graph.files['src/greeting.ts']).toBeDefined(); + expect(graph.files['src/index.ts']!.imports).toContain('src/greeting.ts'); + expect(graph.files['src/index.ts']!.types).toContain('Config'); + expect(graph.files['src/index.ts']!.functions).toContain('main'); + expect(graph.files['src/greeting.ts']!.functions).toContain('greet'); + expect(graph.summary).toContain('Modules'); + expect(graph.summary).toContain('src → 2 files'); +});