feat: add codegraph tool for querying pre-computed codebase dependency graph

- Implemented `tools-codegraph.ts` to provide various queries on the codebase dependency graph, including impact analysis, dead code detection, module boundaries, and more.
- Created a schema for validating query inputs using Zod.
- Developed helper functions for resolving file paths, formatting output, and handling different query types.
- Added tests for the codegraph queries in `codegraph-queries.test.ts` to ensure functionality and correctness.
- Introduced tests for the codegraph scanning and loading functionalities in `codegraph.test.ts`, verifying the detection of source files, imports, exports, circular dependencies, and entry points.
This commit is contained in:
asepharyana
2026-09-04 22:37:38 +07:00
parent 4dac34b13f
commit bb36af62e2
12 changed files with 1458 additions and 3 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# dependencies (bun install)
node_modules
.shiro/
# build output
out
dist
+146
View File
@@ -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<string, FileNode>;
moduleMap: Record<string, string[]>; // 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 <path>' | 'deps <path>' | 'types' | 'circular',
},
}
```
- `list`: all files with kind + size
- `file <path>`: full info for one file
- `deps <path>`: 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
+56
View File
@@ -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.
+582
View File
@@ -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<string, FileNode>;
moduleMap: Record<string, string[]>; // 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, FileNode>): string[][] {
const rawCycles: string[][] = [];
const visited = new Set<string>();
const inStack = new Set<string>();
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<string>();
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, FileNode>): string[] {
// Build fan-in (how many files import this one) and fan-out (how many this imports)
const fanIn = new Map<string, number>();
const fanOut = new Map<string, number>();
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<string, FileNode>): { path: string; fanIn: number; fanOut: number }[] {
const fanIn = new Map<string, number>();
const fanOut = new Map<string, number>();
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<string, FileNode>): Record<string, string[]> {
const map: Record<string, string[]> = {};
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<CodeGraph, 'summary'>): 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<string, number>();
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<string, FileNode> = {};
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<CodeGraph, 'summary'> = {
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;
}
+4
View File
@@ -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':
+4 -1
View File
@@ -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)}
+14
View File
@@ -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<string, FileMutation>();
/** 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 ?? []),
+463
View File
@@ -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 <path>: full info; ' +
'deps <path>: imports and reverse-deps; types: all exported types; ' +
'circular: circular chains; entry: entry points; ' +
'impact <path>: all files transitively affected by changes to this file; ' +
'dead: files never imported (potential dead code); ' +
'tests <path>: which test files cover this source file; ' +
'boundaries: module-to-module dependency summary; ' +
'depth <path>: import chain depth from this file; ' +
'cycle-check <path>: check if adding an import to <target> 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<typeof codegraphQuerySchema>;
// ── 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<string>();
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<string>();
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<string, string[]> = {};
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<string, number>();
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<string, string[]> = {};
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<string, Set<string>> = {};
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<string, number>();
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<string>();
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);
},
};
+4 -1
View File
@@ -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<string, ToolEffect> = {
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
+13
View File
@@ -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);
+65
View File
@@ -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');
});
+106
View File
@@ -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');
});