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
25 changes: 22 additions & 3 deletions packages/cli/src/commands/analyze.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ import { output, withJsonOutput } from "../utils/output.js";
export type AnalyzeOptions = {
fresh?: boolean;
json?: boolean;
/**
* Skip every AI call (domain inference, snapshot enrichment, architecture
* interpretation). The result is purely static analysis. Used by non-CLI
* front-ends such as the MCP server, which must not spend the user's AI
* quota or send metadata to a provider unless explicitly allowed.
*/
offline?: boolean;
};

export type AnalyzeDependencies = {
Expand All @@ -40,7 +47,13 @@ export async function analyzeCommand(
await runAnalyze(target, options, dependencies);
}

async function runAnalyze(
/**
* Core analyze pipeline, shared by the CLI and other front-ends (e.g. an MCP server).
* Writes `.devmap/snapshot.json` and the agent navigation files. Human-facing
* output goes through `output.*`, so call it inside `withJsonOutput` to stay silent.
* Pass `offline: true` to run static analysis only.
*/
export async function runAnalyze(
target: string,
options: AnalyzeOptions,
dependencies: AnalyzeDependencies
Expand All @@ -52,7 +65,7 @@ async function runAnalyze(
// Build callAI wrapper — dipakai oleh domain inference (Step 5) di createProjectMap.
// Dibuat di sini biar analyze command yang kontrol config + client lifecycle,
// sementara projectMap tetap decoupled dari AI provider specifics.
const callAI = await buildCallAI(projectRoot, dependencies);
const callAI = options.offline ? undefined : await buildCallAI(projectRoot, dependencies);

let snapshot = await createProjectMap(projectRoot, callAI);
const previous = options.fresh ? { status: "missing" as const } : await inspectSnapshot(projectRoot);
Expand All @@ -69,7 +82,9 @@ async function runAnalyze(
);
}

snapshot = await enrichSnapshot(snapshot, projectRoot, options, dependencies);
if (!options.offline) {
snapshot = await enrichSnapshot(snapshot, projectRoot, options, dependencies);
}
await saveSnapshot(projectRoot, snapshot);
await writeAgentNavigationFiles(projectRoot, snapshot);
printSnapshot(snapshot);
Expand Down Expand Up @@ -213,6 +228,10 @@ async function printOrGenerateInterpretation(
options: AnalyzeOptions,
dependencies: AnalyzeDependencies
): Promise<Awaited<ReturnType<typeof createProjectMap>>> {
if (options.offline) {
return snapshot;
}

if (snapshot.ai && !options.fresh) {
output.section("Architecture");
output.markdown(snapshot.ai.architecture);
Expand Down
9 changes: 7 additions & 2 deletions packages/cli/src/commands/flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
type FlowInfo,
type ProjectMap
} from "../analyzers/pipeline/index.js";
import { loadSnapshot } from "../cache/snapshot.js";
import { loadSnapshot, type LoadedSnapshot } from "../cache/snapshot.js";
import { resolveEffectiveConfig, type DevmapConfig } from "../utils/config.js";
import { DevmapError } from "../utils/errors.js";
import { buildMapMarkdown } from "../utils/mapRenderer.js";
Expand All @@ -30,6 +30,11 @@ export type FlowOptions = {
* Pass `false` to compute flows without touching the disk.
*/
write?: boolean;
/**
* A snapshot the caller already loaded (long-lived front-ends cache it).
* When set, the snapshot is not re-read and staleness is not re-scanned.
*/
loaded?: LoadedSnapshot;
/**
* Add AI narration when an AI provider is configured. Defaults to true.
* Pass `false` for a purely static, offline result.
Expand Down Expand Up @@ -98,7 +103,7 @@ export async function runFlow(
dependencies: FlowDependencies
): Promise<FlowResult> {
const projectRoot = resolve(options.projectRoot ?? ".");
const { snapshot, stale } = await loadSnapshot(projectRoot);
const { snapshot, stale } = options.loaded ?? await loadSnapshot(projectRoot);

const flows = options.all
? buildAllFlows(snapshot)
Expand Down
9 changes: 7 additions & 2 deletions packages/cli/src/commands/map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
type MapTreeNode
} from "../analyzers/graph/index.js";
import type { ProjectMap } from "../analyzers/pipeline/index.js";
import { loadSnapshot } from "../cache/snapshot.js";
import { loadSnapshot, type LoadedSnapshot } from "../cache/snapshot.js";
import { DevmapError } from "../utils/errors.js";
import { slugifyMapName } from "../utils/slug.js";
import { resolveFileTarget } from "../utils/targetResolver.js";
Expand Down Expand Up @@ -35,6 +35,11 @@ export type MapOptions = {
* Pass `false` to compute the map without touching the disk.
*/
write?: boolean;
/**
* A snapshot the caller already loaded (long-lived front-ends cache it).
* When set, the snapshot is not re-read and staleness is not re-scanned.
*/
loaded?: LoadedSnapshot;
};

export type MapResult = {
Expand Down Expand Up @@ -81,7 +86,7 @@ export async function runMap(
options: MapOptions
): Promise<MapResult> {
const projectRoot = resolve(options.projectRoot ?? ".");
const { snapshot, stale } = await loadSnapshot(projectRoot);
const { snapshot, stale } = options.loaded ?? await loadSnapshot(projectRoot);

const resolved = resolveMapTarget(snapshot, target);

Expand Down
18 changes: 16 additions & 2 deletions packages/cli/src/commands/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import { createDevmapServer } from "../mcp/server.js";

export type McpOptions = {
root?: string;
/** Re-analyze automatically when the snapshot is stale or missing. Default off. */
autoRefresh?: boolean;
/** Allow analysis to use the configured AI provider. Default off. */
allowAi?: boolean;
};

/**
Expand All @@ -22,7 +26,11 @@ export async function mcpCommand(options: McpOptions = {}): Promise<void> {
console.debug = redirect;

const projectRoot = resolve(options.root ?? ".");
const { server } = createDevmapServer({ projectRoot });
const { server } = createDevmapServer({
projectRoot,
autoRefresh: options.autoRefresh === true,
allowAi: options.allowAi === true
});
const transport = new StdioServerTransport();

const shutdown = (): void => {
Expand All @@ -33,5 +41,11 @@ export async function mcpCommand(options: McpOptions = {}): Promise<void> {
process.stdin.once("end", shutdown);

await server.connect(transport);
console.error(`DevMap MCP server ready (root: ${projectRoot})`);
const modes = [
options.autoRefresh ? "auto-refresh" : undefined,
options.allowAi ? "ai-enabled" : undefined
].filter(Boolean);
console.error(
`DevMap MCP server ready (root: ${projectRoot}${modes.length > 0 ? `, ${modes.join(", ")}` : ""})`
);
}
8 changes: 7 additions & 1 deletion packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,13 @@ program
.command("mcp")
.description("Run DevMap as an MCP server (stdio) so AI agents can query the project map")
.option("--root <path>", "project root containing .devmap/snapshot.json", ".")
.action((options) => mcpCommand({ root: options.root }));
.option("--auto-refresh", "re-analyze automatically when the snapshot is stale or missing (default: off)")
.option("--allow-ai", "let analysis use the configured AI provider (default: off, static analysis only)")
.action((options) => mcpCommand({
root: options.root,
autoRefresh: options.autoRefresh,
allowAi: options.allowAi
}));

program
.command("doctor")
Expand Down
62 changes: 62 additions & 0 deletions packages/cli/src/mcp/prompts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";

type PromptMessages = {
messages: Array<{ role: "user"; content: { type: "text"; text: string } }>;
};

function userPrompt(text: string): PromptMessages {
return { messages: [{ role: "user", content: { type: "text", text } }] };
}

/**
* Reusable workflows that tell the agent *which DevMap tools to call, in what
* order*. They contain no project data; the tools provide that.
*/
export function registerPrompts(server: McpServer): void {
server.registerPrompt(
"devmap_onboard",
{
title: "Onboard me to this codebase",
description: "Walk through the project using DevMap and produce a short onboarding guide.",
argsSchema: {
focus: z.string().optional().describe("Optional area to emphasize, e.g. \"payments\" or \"frontend\".")
}
},
({ focus }) => userPrompt([
"Help me understand this codebase using the DevMap tools instead of reading files blindly.",
"",
"1. Call devmap_overview to learn the framework, entry points, critical files, features and flows.",
"2. Call devmap_flow with no target to see the top flows, then trace the two or three most important ones.",
"3. For the most important feature, call devmap_map with its name to see its files and dependencies.",
"4. Only then open specific files, starting with the critical files and entry points.",
focus ? `5. Pay special attention to: ${focus}. Use devmap_find to locate it.` : "",
"",
"If any result reports snapshot.stale = true, call devmap_analyze first.",
"Finish with: what the project does, how it is structured, where to start reading, and what to be careful about."
].filter((line, index, all) => line !== "" || all[index - 1] !== "").join("\n"))
);

server.registerPrompt(
"devmap_plan_change",
{
title: "Plan a change safely",
description: "Plan a code change using DevMap to find the right files and check their blast radius before editing.",
argsSchema: {
change: z.string().min(3).describe("What you want to change, e.g. \"add rate limiting to the login API\".")
}
},
({ change }) => userPrompt([
`I want to make this change: ${change}`,
"",
"Plan it with DevMap before editing anything:",
"1. Call devmap_find with the key terms of the change to locate the relevant features, files and routes.",
"2. Call devmap_map on the most relevant feature (or file) to see how it is wired.",
"3. If it touches a request or user journey, call devmap_flow to trace it end to end.",
"4. For every file you expect to modify, call devmap_impact with depth 2 and note the dependents, affected features and critical dependents.",
"5. If a result reports snapshot.stale = true, call devmap_analyze and repeat the affected steps.",
"",
"Then give me a plan: the files to change and why, what else could break (from devmap_impact), the tests to run or add, and any risks. Do not edit code until I approve the plan."
].join("\n"))
);
}
100 changes: 97 additions & 3 deletions packages/cli/src/mcp/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,39 +151,133 @@ export function resolveProjectFile(snapshot: ProjectMap, input: string): string
);
}

export type ImpactOptions = {
/** Max entries per list (default 50). */
limit?: number;
/** How many import hops to follow when collecting dependents (1-5, default 1). */
depth?: number;
};

export type ImpactResult = {
file: string;
purpose?: string;
scope: string;
features: string[];
importance: number;
/** True when the file is ranked among the project's critical files. */
isCritical: boolean;
criticalReasons?: string[];
/** Files/areas likely affected when this file changes. */
impacts: string[];
/** Files that import this file directly. */
dependents: string[];
/** Number of direct dependents (before the list limit). */
dependentCount: number;
/** Everything that reaches this file through up to `depth` import hops. */
transitive: {
depth: number;
count: number;
files: Array<{ file: string; depth: number }>;
truncated: boolean;
};
/** Critical files among the transitive dependents. */
criticalDependents: string[];
/** Features that contain this file or any transitive dependent. */
affectedFeatures: string[];
/** Local files this file imports. */
imports: string[];
truncated: boolean;
};

export function queryImpact(snapshot: ProjectMap, input: string, limit?: number): ImpactResult {
const MAX_IMPACT_DEPTH = 5;
const reverseGraphs = new WeakMap<ProjectMap, Map<string, string[]>>();

/** file -> files that import it (built once per snapshot). */
function reverseGraph(snapshot: ProjectMap): Map<string, string[]> {
const cachedGraph = reverseGraphs.get(snapshot);
if (cachedGraph) return cachedGraph;

const graph = new Map<string, string[]>();
for (const [importer, imports] of Object.entries(snapshot.fileGraph)) {
for (const imported of imports) {
const list = graph.get(imported);
if (list) list.push(importer);
else graph.set(imported, [importer]);
}
}
for (const list of graph.values()) list.sort();

reverseGraphs.set(snapshot, graph);
return graph;
}

export function queryImpact(
snapshot: ProjectMap,
input: string,
options: ImpactOptions = {}
): ImpactResult {
const file = resolveProjectFile(snapshot, input);
const cap = clampLimit(limit, 50);
const cap = clampLimit(options.limit, 50);
const depth = Math.max(1, Math.min(MAX_IMPACT_DEPTH, Math.floor(options.depth ?? 1)));
const entry = snapshot.fileIndex[file]!;
const impact = snapshot.changeImpact[file] ?? { impacts: [], dependents: [] };
const imports = snapshot.fileGraph[file] ?? [];
const critical = snapshot.criticalFiles.find((candidate) => candidate.path === file);

// Breadth-first over "who imports me", cycle-safe, ordered by hop then path.
const reverse = reverseGraph(snapshot);
const seen = new Set<string>([file]);
const reached: Array<{ file: string; depth: number }> = [];
let frontier = [file];
for (let hop = 1; hop <= depth && frontier.length > 0; hop += 1) {
const next: string[] = [];
for (const current of frontier) {
for (const importer of reverse.get(current) ?? []) {
if (seen.has(importer)) continue;
seen.add(importer);
reached.push({ file: importer, depth: hop });
next.push(importer);
}
}
frontier = next;
}

const criticalPaths = new Set(snapshot.criticalFiles.map((candidate) => candidate.path));
const features = new Set<string>(entry.featureRefs);
for (const { file: reachedFile } of reached) {
for (const feature of snapshot.fileIndex[reachedFile]?.featureRefs ?? []) {
features.add(feature);
}
}
const criticalDependents = reached
.filter((candidate) => criticalPaths.has(candidate.file))
.map((candidate) => candidate.file);

return {
file,
...(entry.purpose ? { purpose: entry.purpose } : {}),
scope: entry.scope,
features: entry.featureRefs,
importance: entry.importance,
isCritical: critical !== undefined,
...(critical ? { criticalReasons: critical.reasons } : {}),
impacts: impact.impacts.slice(0, cap),
dependents: impact.dependents.slice(0, cap),
dependentCount: impact.dependents.length,
transitive: {
depth,
count: reached.length,
files: reached.slice(0, cap),
truncated: reached.length > cap
},
criticalDependents: criticalDependents.slice(0, cap),
affectedFeatures: [...features].sort(),
imports: imports.slice(0, cap),
truncated:
impact.impacts.length > cap || impact.dependents.length > cap || imports.length > cap
impact.impacts.length > cap
|| impact.dependents.length > cap
|| imports.length > cap
|| reached.length > cap
};
}

Expand Down
Loading
Loading