Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ src/
parser/index.ts <- TS AST extraction + git churn + test detection
graph/index.ts <- graphology graph + circular dep detection
analyzer/index.ts <- All metric computation
graph-loader/index.ts <- Shared parse/build/analyze/cache pipeline + progress events
core/index.ts <- Shared result computation (MCP + CLI)
operations/index.ts <- Analysis operation descriptors + typed input schemas
config/index.ts <- Config discovery + zod validation
Expand All @@ -58,6 +59,10 @@ src/
## Data Flow

```
loadCodebaseGraph(rootDir)
-> cached CodebaseGraph when cache key matches
-> otherwise emits progress events through parse/build/analyze/cache

parseCodebase(rootDir)
-> ParsedFile[] (with churn, complexity, test mapping)

Expand All @@ -82,6 +87,7 @@ runOperation(operation, codebaseGraph, input, context)

- **Dual interface**: MCP stdio for LLM agents, CLI subcommands for humans/CI. Both consume `src/core/`.
- **Operation registry foundation**: Analysis operations now have typed descriptors in `src/operations/` with operation names, CLI command names, MCP tool names, input schemas, and discriminated run results. MCP tool registration and CLI command execution consume those descriptors; CLI text formatting still lives in `src/cli.ts` until the formatter migration lands.
- **Shared graph-load pipeline**: CLI commands and MCP stdio startup both use `src/graph-loader/` for path checks, legacy cache migration, cache reuse, parse/build/analyze, optional persistence, and stderr progress events.
- **graphology**: In-memory graph with O(1) neighbor lookup. PageRank and betweenness computed via graphology-metrics.
- **Batch git churn**: Single `git log --all --name-only` call, parsed for all files. Avoids O(n) subprocess spawning.
- **Monorepo import resolution**: Root `tsconfig.json` path aliases and local `package.json` package names resolve to source files before graph construction.
Expand Down
6 changes: 6 additions & 0 deletions llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ src/
parser/index.ts <- TS AST extraction + git churn + test detection
graph/index.ts <- graphology graph + circular dep detection
analyzer/index.ts <- All metric computation
graph-loader/index.ts <- Shared parse/build/analyze/cache pipeline + progress events
core/index.ts <- Shared result computation (MCP + CLI)
operations/index.ts <- Analysis operation descriptors + typed input schemas
mcp/index.ts <- 17 MCP tools for LLM integration
Expand All @@ -61,6 +62,10 @@ src/
## Data Flow

```
loadCodebaseGraph(rootDir)
-> cached CodebaseGraph when cache key matches
-> otherwise emits progress events through parse/build/analyze/cache

parseCodebase(rootDir)
-> ParsedFile[] (with churn, complexity, test mapping)

Expand All @@ -82,6 +87,7 @@ runOperation(operation, codebaseGraph, input, context)

- **graphology**: In-memory graph with O(1) neighbor lookup. PageRank and betweenness computed via graphology-metrics.
- **Operation registry foundation**: Analysis operations have typed descriptors in `src/operations/` with operation names, CLI command names, MCP tool names, input schemas, and discriminated run results. MCP tool registration and CLI command execution consume those descriptors; CLI text formatting still lives in `src/cli.ts` until the formatter migration lands.
- **Shared graph-load pipeline**: CLI commands and MCP stdio startup both use `src/graph-loader/` for path checks, legacy cache migration, cache reuse, parse/build/analyze, optional persistence, and stderr progress events.
- **Batch git churn**: Single `git log --all --name-only` call, parsed for all files. Avoids O(n) subprocess spawning.
- **Dead export detection**: Cross-references parsed exports against edge symbol lists. May miss `import *` or re-exports.
- **Graceful degradation**: Non-git dirs get churn=0, no-test codebases get coverage=false. Never crashes.
Expand Down
4 changes: 2 additions & 2 deletions roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -275,12 +275,12 @@ Collapse CLI + MCP operation duplication into one descriptor registry before add
- Add CLI registry parity coverage for representative descriptor runs and invalid input.
- Expand CH-P1-01 coverage from overview/representative CLI/MCP operations to every operation.
- Expand CH-P1-02 coverage for descriptor validation, CLI parse failure, and cache reuse through registry-adapted commands.
- Use one graph-load pipeline with progress callbacks.
- Extend CH-P1-02 coverage to MCP/stdio graph-load behavior.

**Remaining:**

- Use one graph-load pipeline with progress callbacks.
- Move text/SARIF/markdown formatting over result objects into formatters.
- Extend CH-P1-02 coverage to MCP/stdio graph-load behavior after the shared graph-load pipeline exists.

### Type/Shape Layer

Expand Down
169 changes: 44 additions & 125 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,26 +11,23 @@ process.on("uncaughtException", (err) => {

import fs from "fs";
import path from "path";
import { execSync } from "child_process";
import { createRequire } from "module";
import { Command } from "commander";

const require = createRequire(import.meta.url);
const pkg = require("../package.json") as { version: string };
import { parseCodebase } from "./parser/index.js";
import { buildGraph } from "./graph/index.js";
import { analyzeGraph } from "./analyzer/index.js";
import { startMcpServer } from "./mcp/index.js";
import { setIndexedHead, setRoot } from "./server/graph-store.js";
import { exportGraph, importGraph } from "./persistence/index.js";
import { getCacheKey } from "./persistence/cache-key.js";
import { importGraph } from "./persistence/index.js";
import {
cleanIndexDirectories,
getCacheFacts,
getCacheFactsForTarget,
prepareIndexDirectory,
type IndexDirectoryResolution,
} from "./persistence/index-dir.js";
import {
GraphLoadError,
loadCodebaseGraph,
prepareGraphCache,
type GraphLoadProgress,
} from "./graph-loader/index.js";
import {
operations,
parseOperationInput,
Expand All @@ -55,28 +52,6 @@ import type { CacheFacts, CodebaseGraph, OutputFormat } from "./types/index.js";

let activeCacheFacts: CacheFacts | null = null;

function reportIndexMigration(resolution: IndexDirectoryResolution): void {
if (resolution.migration === "migrated-legacy") {
progress(`Migrated legacy index ${resolution.legacyDir} to ${resolution.canonicalDir}`);
}
if (resolution.migration === "ignored-legacy") {
progress(`Using ${resolution.canonicalDir}; legacy index remains at ${resolution.legacyDir}`);
}
}

function getHeadHash(targetPath: string): string {
try {
return execSync("git rev-parse HEAD", {
cwd: path.resolve(targetPath),
encoding: "utf-8",
timeout: 5000,
stdio: ["ignore", "pipe", "ignore"],
}).trim();
} catch {
return "unknown";
}
}

function progress(msg: string): void {
process.stderr.write(`${msg}\n`);
}
Expand Down Expand Up @@ -140,59 +115,32 @@ function runCliOperation<TInput extends object, TResult>(
return result.data;
}

/** Load (or parse+cache) the codebase graph for a target path. */
function loadGraph(targetPath: string, force = false): { graph: CodebaseGraph; headHash: string } {
const resolved = path.resolve(targetPath);
if (!fs.existsSync(resolved)) {
process.stderr.write(`Error: Path does not exist: ${targetPath}\n`);
process.exit(1);
}
setRoot(resolved);

const indexResolution = prepareIndexDirectory(targetPath);
reportIndexMigration(indexResolution);
activeCacheFacts = getCacheFacts(indexResolution);
const indexDir = indexResolution.activeDir;
const headHash = getHeadHash(targetPath);
const cacheKey = getCacheKey(targetPath, { headHash, cliVersion: pkg.version });

if (!force && headHash !== "unknown") {
const cached = importGraph(indexDir);
if (cached?.headHash === headHash && cached.cacheKey === cacheKey) {
progress(`Using cached index (HEAD: ${headHash.slice(0, 7)})`);
setIndexedHead(cached.headHash);
return { graph: cached.graph, headHash };
}
}

progress(`Parsing ${targetPath}...`);
const files = parseCodebase(targetPath);
progress(`Parsed ${files.length} files`);
function reportGraphLoadProgress(event: GraphLoadProgress): void {
progress(event.message);
}

if (files.length === 0) {
process.stderr.write(`Error: No TypeScript files found at ${targetPath}\n`);
function printGraphLoadError(err: unknown): never {
if (err instanceof GraphLoadError) {
process.stderr.write(`Error: ${err.message}\n`);
process.exit(1);
}
throw err;
}

const built = buildGraph(files);
progress(
`Built graph: ${built.nodes.filter((n) => n.type === "file").length} files, ` +
`${built.nodes.filter((n) => n.type === "function").length} functions, ` +
`${built.edges.length} dependencies`,
);

const graph = analyzeGraph(built, files);
progress(
`Analysis complete: ${graph.stats.circularDeps.length} circular deps, ` +
`${graph.forceAnalysis.tensionFiles.length} tension files`,
);

setIndexedHead(headHash);

exportGraph(graph, indexDir, headHash, cacheKey);
progress(`Index saved to ${indexDir}`);

return { graph, headHash };
function loadGraph(targetPath: string, force = false): { graph: CodebaseGraph; headHash: string } {
try {
const result = loadCodebaseGraph({
targetPath,
force,
persist: true,
cliVersion: pkg.version,
onProgress: reportGraphLoadProgress,
});
activeCacheFacts = result.cacheFacts;
return { graph: result.graph, headHash: result.headHash };
} catch (err) {
printGraphLoadError(err);
}
}

// ── CLI Program ─────────────────────────────────────────────
Expand Down Expand Up @@ -1218,8 +1166,6 @@ program
});

async function runMcpMode(targetPath: string, options: McpOptions): Promise<void> {
setRoot(path.resolve(targetPath));

if (options.clean) {
const removed = cleanIndexDirectories(targetPath);
if (removed.length === 0) {
Expand All @@ -1230,12 +1176,10 @@ async function runMcpMode(targetPath: string, options: McpOptions): Promise<void
return;
}

const indexResolution = prepareIndexDirectory(targetPath);
reportIndexMigration(indexResolution);
activeCacheFacts = getCacheFacts(indexResolution);
const indexDir = indexResolution.activeDir;

if (options.status) {
const cache = prepareGraphCache({ targetPath, onProgress: reportGraphLoadProgress });
activeCacheFacts = cache.cacheFacts;
const indexDir = cache.indexDir;
const result = importGraph(indexDir);
if (!result) {
progress("No index found. Run with --index to create one.");
Expand All @@ -1257,44 +1201,19 @@ async function runMcpMode(targetPath: string, options: McpOptions): Promise<void
return;
}

const headHash = getHeadHash(targetPath);
const cacheKey = getCacheKey(targetPath, { headHash, cliVersion: pkg.version });

if (!options.force && headHash !== "unknown") {
const cached = importGraph(indexDir);
if (cached?.headHash === headHash && cached.cacheKey === cacheKey) {
progress(`Using cached index (HEAD: ${headHash.slice(0, 7)})`);
setIndexedHead(cached.headHash);
await startMcpServer(cached.graph);
return;
}
}

progress(`Parsing ${targetPath}...`);
const files = parseCodebase(targetPath);
progress(`Parsed ${files.length} files`);

const built = buildGraph(files);
progress(
`Built graph: ${built.nodes.filter((n) => n.type === "file").length} files, ` +
`${built.nodes.filter((n) => n.type === "function").length} functions, ` +
`${built.edges.length} dependencies`,
);

const codebaseGraph = analyzeGraph(built, files);
progress(
`Analysis complete: ${codebaseGraph.stats.circularDeps.length} circular deps, ` +
`${codebaseGraph.forceAnalysis.tensionFiles.length} tension files`,
);

setIndexedHead(headHash);

if (options.index) {
exportGraph(codebaseGraph, indexDir, headHash, cacheKey);
progress(`Index saved to ${indexDir}`);
try {
const result = loadCodebaseGraph({
targetPath,
force: options.force === true,
persist: options.index === true,
cliVersion: pkg.version,
onProgress: reportGraphLoadProgress,
});
activeCacheFacts = result.cacheFacts;
await startMcpServer(result.graph);
} catch (err) {
printGraphLoadError(err);
}

await startMcpServer(codebaseGraph);
}

// ── Default action: bare <path> → MCP mode ──────────────────
Expand Down
Loading
Loading