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:
+1
-1
@@ -1,6 +1,6 @@
|
||||
# dependencies (bun install)
|
||||
node_modules
|
||||
|
||||
.shiro/
|
||||
# build output
|
||||
out
|
||||
dist
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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)}
|
||||
|
||||
|
||||
@@ -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 ?? []),
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user