diff --git a/packages/cli/src/commands/analyze.ts b/packages/cli/src/commands/analyze.ts index 27bd84f..71a4fe2 100644 --- a/packages/cli/src/commands/analyze.ts +++ b/packages/cli/src/commands/analyze.ts @@ -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 = { @@ -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 @@ -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); @@ -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); @@ -213,6 +228,10 @@ async function printOrGenerateInterpretation( options: AnalyzeOptions, dependencies: AnalyzeDependencies ): Promise>> { + if (options.offline) { + return snapshot; + } + if (snapshot.ai && !options.fresh) { output.section("Architecture"); output.markdown(snapshot.ai.architecture); diff --git a/packages/cli/src/commands/flow.ts b/packages/cli/src/commands/flow.ts index ea07888..e53044c 100644 --- a/packages/cli/src/commands/flow.ts +++ b/packages/cli/src/commands/flow.ts @@ -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"; @@ -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. @@ -98,7 +103,7 @@ export async function runFlow( dependencies: FlowDependencies ): Promise { 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) diff --git a/packages/cli/src/commands/map.ts b/packages/cli/src/commands/map.ts index 92e20da..8f8acf4 100644 --- a/packages/cli/src/commands/map.ts +++ b/packages/cli/src/commands/map.ts @@ -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"; @@ -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 = { @@ -81,7 +86,7 @@ export async function runMap( options: MapOptions ): Promise { const projectRoot = resolve(options.projectRoot ?? "."); - const { snapshot, stale } = await loadSnapshot(projectRoot); + const { snapshot, stale } = options.loaded ?? await loadSnapshot(projectRoot); const resolved = resolveMapTarget(snapshot, target); diff --git a/packages/cli/src/commands/mcp.ts b/packages/cli/src/commands/mcp.ts index cade359..d1bf6cb 100644 --- a/packages/cli/src/commands/mcp.ts +++ b/packages/cli/src/commands/mcp.ts @@ -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; }; /** @@ -22,7 +26,11 @@ export async function mcpCommand(options: McpOptions = {}): Promise { 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 => { @@ -33,5 +41,11 @@ export async function mcpCommand(options: McpOptions = {}): Promise { 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(", ")}` : ""})` + ); } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 97bae85..7481d4a 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -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 ", "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") diff --git a/packages/cli/src/mcp/prompts.ts b/packages/cli/src/mcp/prompts.ts new file mode 100644 index 0000000..5a30ed0 --- /dev/null +++ b/packages/cli/src/mcp/prompts.ts @@ -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")) + ); +} diff --git a/packages/cli/src/mcp/queries.ts b/packages/cli/src/mcp/queries.ts index 6672c89..7169cf8 100644 --- a/packages/cli/src/mcp/queries.ts +++ b/packages/cli/src/mcp/queries.ts @@ -151,27 +151,107 @@ 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>(); + +/** file -> files that import it (built once per snapshot). */ +function reverseGraph(snapshot: ProjectMap): Map { + const cachedGraph = reverseGraphs.get(snapshot); + if (cachedGraph) return cachedGraph; + + const graph = new Map(); + 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([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(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, @@ -179,11 +259,25 @@ export function queryImpact(snapshot: ProjectMap, input: string, limit?: number) 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 }; } diff --git a/packages/cli/src/mcp/refresh.ts b/packages/cli/src/mcp/refresh.ts new file mode 100644 index 0000000..b2293d5 --- /dev/null +++ b/packages/cli/src/mcp/refresh.ts @@ -0,0 +1,184 @@ +import { mkdir, open, readFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import type { ProjectMap } from "../analyzers/pipeline/index.js"; +import { inspectSnapshot, isSnapshotStale } from "../cache/snapshot.js"; +import { runAnalyze } from "../commands/analyze.js"; +import { DevmapError } from "../utils/errors.js"; +import { withJsonOutput } from "../utils/output.js"; + +/** A lock older than this is considered abandoned, even if its pid is still alive. */ +export const LOCK_STALE_MS = 15 * 60 * 1000; + +export type AnalyzeFn = ( + projectRoot: string, + options: { fresh: boolean; offline: boolean } +) => Promise; + +export type RefreshOutcome = { + status: "refreshed" | "unchanged"; + generatedAt: string; + durationMs: number; + /** "static" = no AI was used; "ai-enabled" = the server was started with --allow-ai. */ + mode: "static" | "ai-enabled"; + /** + * True when the previous snapshot had AI-derived data (domain / architecture) + * that the new, static-only snapshot does not carry. + */ + aiDataDropped: boolean; + stats: ProjectMap["stats"]; +}; + +export type Refresher = { + /** + * Re-analyze the project and save the snapshot. Concurrent calls join the + * analysis already in flight instead of starting another one. + */ + run(options?: { fresh?: boolean }): Promise; + isRunning(): boolean; +}; + +export type RefresherOptions = { + projectRoot: string; + /** Let analysis use the configured AI provider. Default false (static only). */ + allowAi?: boolean; + /** Injected in tests; defaults to the real analyze pipeline, silenced. */ + analyze?: AnalyzeFn; +}; + +const defaultAnalyze: AnalyzeFn = (projectRoot, options) => + withJsonOutput(() => runAnalyze( + projectRoot, + { fresh: options.fresh, offline: options.offline, json: true }, + {} + )); + +export function createRefresher(options: RefresherOptions): Refresher { + const { projectRoot } = options; + const allowAi = options.allowAi === true; + const analyze = options.analyze ?? defaultAnalyze; + let inflight: Promise | undefined; + + async function execute(fresh: boolean): Promise { + const started = Date.now(); + const mode = allowAi ? "ai-enabled" : "static"; + const previous = await inspectSnapshot(projectRoot); + + // Cheap path: nothing changed since the last snapshot, so skip the full analysis. + if (!fresh && previous.status === "valid" && !(await isSnapshotStale(projectRoot, previous.snapshot))) { + return { + status: "unchanged", + generatedAt: previous.snapshot.generatedAt, + durationMs: Date.now() - started, + mode, + aiDataDropped: false, + stats: previous.snapshot.stats + }; + } + + const release = await acquireLock(projectRoot); + try { + const snapshot = await analyze(projectRoot, { fresh, offline: !allowAi }); + const hadAi = previous.status === "valid" && Boolean(previous.snapshot.domain || previous.snapshot.ai); + const hasAi = Boolean(snapshot.domain || snapshot.ai); + const reused = previous.status === "valid" && previous.snapshot.generatedAt === snapshot.generatedAt; + + return { + status: reused ? "unchanged" : "refreshed", + generatedAt: snapshot.generatedAt, + durationMs: Date.now() - started, + mode, + aiDataDropped: hadAi && !hasAi, + stats: snapshot.stats + }; + } finally { + await release(); + } + } + + return { + run(runOptions = {}): Promise { + if (!inflight) { + inflight = execute(runOptions.fresh === true).finally(() => { + inflight = undefined; + }); + } + return inflight; + }, + isRunning(): boolean { + return inflight !== undefined; + } + }; +} + +// ── cross-process lock ───────────────────────────────────────────────────── + +type LockInfo = { pid: number; startedAt: string }; + +function lockPath(projectRoot: string): string { + return join(projectRoot, ".devmap", "analyze.lock"); +} + +function isProcessAlive(pid: number): boolean { + if (!Number.isInteger(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch (error) { + // EPERM: the process exists but belongs to someone else. + return (error as NodeJS.ErrnoException).code === "EPERM"; + } +} + +async function readLock(path: string): Promise { + try { + const parsed = JSON.parse(await readFile(path, "utf8")) as Partial; + if (typeof parsed.pid === "number" && typeof parsed.startedAt === "string") { + return { pid: parsed.pid, startedAt: parsed.startedAt }; + } + } catch { + // unreadable or half-written: treated as abandoned + } + return undefined; +} + +/** + * Take the analysis lock (`.devmap/analyze.lock`) so two MCP servers (or two + * editor windows) never write the snapshot at the same time. A lock whose + * process is gone, or that is older than LOCK_STALE_MS, is taken over. + */ +export async function acquireLock(projectRoot: string): Promise<() => Promise> { + const path = lockPath(projectRoot); + await mkdir(join(projectRoot, ".devmap"), { recursive: true }); + + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + const handle = await open(path, "wx"); + try { + const info: LockInfo = { pid: process.pid, startedAt: new Date().toISOString() }; + await handle.writeFile(JSON.stringify(info)); + } finally { + await handle.close(); + } + return async () => { + await rm(path, { force: true }); + }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + + const info = await readLock(path); + const age = info ? Date.now() - Date.parse(info.startedAt) : Number.POSITIVE_INFINITY; + if (info && isProcessAlive(info.pid) && age < LOCK_STALE_MS) { + throw new DevmapError( + `Another DevMap analysis is already running (pid ${info.pid}).`, + "Wait for it to finish, then retry." + ); + } + await rm(path, { force: true }); + } + } + + throw new DevmapError( + "Could not acquire the DevMap analysis lock.", + "Delete .devmap/analyze.lock if no analysis is running, then retry." + ); +} diff --git a/packages/cli/src/mcp/server.ts b/packages/cli/src/mcp/server.ts index f15a357..9fc7b78 100644 --- a/packages/cli/src/mcp/server.ts +++ b/packages/cli/src/mcp/server.ts @@ -8,14 +8,19 @@ import { runMap } from "../commands/map.js"; import { DevmapError } from "../utils/errors.js"; import { withJsonOutput } from "../utils/output.js"; import { DEVMAP_VERSION } from "../utils/packageMetadata.js"; +import { registerPrompts } from "./prompts.js"; import { buildOverview, queryFind, queryImpact, - queryRoutes, - type SnapshotMeta + queryRoutes } from "./queries.js"; -import { createSnapshotProvider, type SnapshotProvider } from "./snapshotProvider.js"; +import { createRefresher, type AnalyzeFn, type Refresher } from "./refresh.js"; +import { + createSnapshotProvider, + type ProviderSnapshot, + type SnapshotProvider +} from "./snapshotProvider.js"; export const MAX_TEXT_CHARS = 30_000; const FEATURE_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/; @@ -24,18 +29,40 @@ const SERVER_INSTRUCTIONS = [ "DevMap gives you a precomputed map of this codebase (features, routes, flows, dependency graph).", "Start with devmap_overview, then drill into one feature with devmap_map or devmap_flow instead of reading many files.", "Call devmap_impact before changing a file, and devmap_find to locate code by keyword.", - "Every result includes snapshot.stale; when it is true the code changed after the last analysis, so treat results as approximate and ask the user to run `devmap analyze --fresh`." + "Every result includes snapshot.stale; when it is true the code changed after the last analysis, so results may be outdated: call devmap_analyze to refresh the snapshot, then repeat the query.", + "Prompts devmap_onboard and devmap_plan_change describe ready-made workflows." ].join(" "); +const DEFAULT_ANALYZE_TIMEOUT_SECONDS = 120; +const DEFAULT_HEARTBEAT_MS = 5_000; + export type DevmapServerOptions = { projectRoot: string; + /** + * Re-analyze automatically when the snapshot is stale or missing, before + * answering a query. Off by default: queries never write to disk. + */ + autoRefresh?: boolean; + /** + * Let analysis use the configured AI provider (domain inference, enrichment). + * Off by default: analysis is static, so agents cannot spend AI quota or send + * project metadata to a provider on their own. + */ + allowAi?: boolean; /** Inject a provider (tests); defaults to a cached provider for `projectRoot`. */ provider?: SnapshotProvider; + /** Inject a refresher (tests). */ + refresher?: Refresher; + /** Inject the analysis function (tests). */ + analyze?: AnalyzeFn; + /** Interval of progress notifications while devmap_analyze runs. */ + heartbeatMs?: number; }; export type DevmapServer = { server: McpServer; provider: SnapshotProvider; + refresher: Refresher; }; export function truncateText(value: string, max = MAX_TEXT_CHARS): { text: string; truncated: boolean } { @@ -46,16 +73,42 @@ export function truncateText(value: string, max = MAX_TEXT_CHARS): { text: strin }; } -function snapshotMeta(generatedAt: string, stale: boolean): SnapshotMeta & { note?: string } { +function snapshotMeta(loaded: ProviderSnapshot): { + generatedAt: string; + stale: boolean; + refreshed?: boolean; + refreshError?: string; + note?: string; +} { return { - generatedAt, - stale, - ...(stale - ? { note: "The code changed after this snapshot was generated. Results may be outdated; run `devmap analyze --fresh`." } + generatedAt: loaded.snapshot.generatedAt, + stale: loaded.stale, + ...(loaded.refreshed ? { refreshed: true } : {}), + ...(loaded.refreshError ? { refreshError: loaded.refreshError } : {}), + ...(loaded.stale + ? { + note: loaded.refreshError + ? `Auto-refresh failed (${loaded.refreshError}). Results come from an older snapshot.` + : "The code changed after this snapshot was generated. Results may be outdated; call devmap_analyze to refresh." + } : {}) }; } +/** Wait for a promise, but give up waiting after `ms` (the promise keeps running). */ +async function waitFor(promise: Promise, ms: number): Promise<{ timedOut: false; value: T } | { timedOut: true }> { + let timer: NodeJS.Timeout | undefined; + const timeout = new Promise<{ timedOut: true }>((resolveTimeout) => { + timer = setTimeout(() => resolveTimeout({ timedOut: true }), ms); + timer.unref(); + }); + try { + return await Promise.race([promise.then((value) => ({ timedOut: false as const, value })), timeout]); + } finally { + if (timer) clearTimeout(timer); + } +} + function ok(payload: unknown): CallToolResult { return { content: [{ type: "text", text: JSON.stringify(payload) }] }; } @@ -78,8 +131,16 @@ async function respond(action: () => Promise): Promise const readOnly = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }; export function createDevmapServer(options: DevmapServerOptions): DevmapServer { - const provider = options.provider ?? createSnapshotProvider(options.projectRoot); + const refresher = options.refresher ?? createRefresher({ + projectRoot: options.projectRoot, + allowAi: options.allowAi, + analyze: options.analyze + }); + const provider = options.provider ?? createSnapshotProvider(options.projectRoot, { + autoRefresh: options.autoRefresh ? () => refresher.run() : undefined + }); const projectRoot = provider.projectRoot; + const heartbeatMs = options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS; const server = new McpServer( { name: "devmap", version: DEVMAP_VERSION }, @@ -98,10 +159,10 @@ export function createDevmapServer(options: DevmapServerOptions): DevmapServer { annotations: readOnly }, () => respond(async () => { - const { snapshot, stale } = await provider.get(); + const loaded = await provider.get(); return { - ...buildOverview(snapshot), - snapshot: snapshotMeta(snapshot.generatedAt, stale) + ...buildOverview(loaded.snapshot), + snapshot: snapshotMeta(loaded) }; }) ); @@ -121,7 +182,8 @@ export function createDevmapServer(options: DevmapServerOptions): DevmapServer { annotations: readOnly }, ({ target, depth, includeMermaid }) => respond(async () => { - const result = await withJsonOutput(() => runMap(target, { projectRoot, depth, write: false })); + const loaded = await provider.get(); + const result = await withJsonOutput(() => runMap(target, { projectRoot, depth, write: false, loaded })); const markdown = truncateText(result.markdown); return { mode: result.mode, @@ -129,7 +191,7 @@ export function createDevmapServer(options: DevmapServerOptions): DevmapServer { markdown: markdown.text, ...(includeMermaid ? { mermaid: truncateText(result.mermaid).text } : {}), truncated: markdown.truncated, - snapshot: snapshotMeta(result.snapshot.generatedAt, result.snapshot.stale) + snapshot: snapshotMeta(loaded) }; }) ); @@ -148,9 +210,10 @@ export function createDevmapServer(options: DevmapServerOptions): DevmapServer { annotations: readOnly }, ({ target, all }) => respond(async () => { + const loaded = await provider.get(); const result = await withJsonOutput(() => runFlow( target, - { projectRoot, all, write: false, narrate: false }, + { projectRoot, all, write: false, narrate: false, loaded }, {} )); let truncated = false; @@ -163,7 +226,7 @@ export function createDevmapServer(options: DevmapServerOptions): DevmapServer { total: result.flows.length, truncated: truncated || result.flows.length > MAX_FLOWS, flows, - snapshot: snapshotMeta(result.snapshot.generatedAt, result.snapshot.stale) + snapshot: snapshotMeta(loaded) }; }) ); @@ -182,10 +245,10 @@ export function createDevmapServer(options: DevmapServerOptions): DevmapServer { annotations: readOnly }, (input) => respond(async () => { - const { snapshot, stale } = await provider.get(); + const loaded = await provider.get(); return { - ...queryRoutes(snapshot, input), - snapshot: snapshotMeta(snapshot.generatedAt, stale) + ...queryRoutes(loaded.snapshot, input), + snapshot: snapshotMeta(loaded) }; }) ); @@ -195,19 +258,21 @@ export function createDevmapServer(options: DevmapServerOptions): DevmapServer { { title: "Change impact", description: - "What is affected if a file changes: files that import it, areas likely impacted, features it belongs to, and what it imports. " + "What is affected if a file changes: direct and transitive dependents (follow imports up to `depth` hops), " + + "affected features, critical dependents, areas likely impacted, and what it imports. " + "Call this before editing a shared file.", inputSchema: { file: z.string().describe("Project-relative file path (a unique path suffix also works)."), + depth: z.number().int().min(1).max(5).optional().describe("Import hops to follow for dependents (default 1)."), limit: z.number().int().min(1).max(100).optional().describe("Max entries per list (default 50).") }, annotations: readOnly }, - ({ file, limit }) => respond(async () => { - const { snapshot, stale } = await provider.get(); + ({ file, depth, limit }) => respond(async () => { + const loaded = await provider.get(); return { - ...queryImpact(snapshot, file, limit), - snapshot: snapshotMeta(snapshot.generatedAt, stale) + ...queryImpact(loaded.snapshot, file, { depth, limit }), + snapshot: snapshotMeta(loaded) }; }) ); @@ -226,14 +291,91 @@ export function createDevmapServer(options: DevmapServerOptions): DevmapServer { annotations: readOnly }, ({ query, limit }) => respond(async () => { - const { snapshot, stale } = await provider.get(); + const loaded = await provider.get(); return { - ...queryFind(snapshot, query, limit), - snapshot: snapshotMeta(snapshot.generatedAt, stale) + ...queryFind(loaded.snapshot, query, limit), + snapshot: snapshotMeta(loaded) }; }) ); + server.registerTool( + "devmap_analyze", + { + title: "Refresh the project map", + description: + "Re-analyze the project and refresh the DevMap snapshot (writes only inside .devmap/). Call it when a result " + + "reports snapshot.stale = true, or when no snapshot exists. Returns status \"unchanged\" without re-analyzing if no " + + "file changed (pass fresh: true to force a full rebuild). " + + (options.allowAi + ? "AI enrichment is enabled for this server, so the configured AI provider may be used." + : "Static analysis only: no AI is used, so AI-derived data (domain, architecture) is not regenerated and aiDataDropped is reported when it is lost. ") + + "If it takes longer than timeoutSeconds it keeps running in the background and status is \"running\"; call again to wait for it.", + inputSchema: { + fresh: z.boolean().optional().describe("Ignore the cache and rebuild from scratch."), + timeoutSeconds: z.number().int().min(1).max(600).optional() + .describe(`How long to wait for the analysis (default ${DEFAULT_ANALYZE_TIMEOUT_SECONDS}).`) + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: options.allowAi === true + } + }, + async ({ fresh, timeoutSeconds }, extra) => { + const started = Date.now(); + const progressToken = extra._meta?.progressToken; + let tick = 0; + let heartbeat: NodeJS.Timeout | undefined; + + // Progress notifications keep clients from timing out on long analyses. + if (progressToken !== undefined) { + heartbeat = setInterval(() => { + tick += 1; + void extra.sendNotification({ + method: "notifications/progress", + params: { + progressToken, + progress: tick, + message: `Analyzing project… ${Math.round((Date.now() - started) / 1000)}s elapsed` + } + }).catch(() => undefined); + }, heartbeatMs); + heartbeat.unref(); + } + + try { + const run = refresher.run({ fresh: fresh === true }); + // Drop cached state whenever the analysis ends, even if we stopped waiting for it. + run.then(() => provider.invalidate(), () => undefined); + + const waited = await waitFor(run, (timeoutSeconds ?? DEFAULT_ANALYZE_TIMEOUT_SECONDS) * 1000); + if (waited.timedOut) { + return ok({ + status: "running", + elapsedSeconds: Math.round((Date.now() - started) / 1000), + hint: "The analysis is still running in the background. Call devmap_analyze again to wait for it; other tools use the previous snapshot until it finishes." + }); + } + + const outcome = waited.value; + return ok({ + ...outcome, + ...(outcome.aiDataDropped + ? { + note: "The previous snapshot had AI-derived data (domain/architecture) that static analysis cannot regenerate. Run `devmap analyze` in a terminal, or start the server with --allow-ai, to restore it." + } + : {}) + }); + } catch (error) { + return fail(error); + } finally { + if (heartbeat) clearInterval(heartbeat); + } + } + ); + // ── resources ──────────────────────────────────────────────────────────── server.registerResource( @@ -302,7 +444,9 @@ export function createDevmapServer(options: DevmapServerOptions): DevmapServer { } ); - return { server, provider }; + registerPrompts(server); + + return { server, provider, refresher }; } const MAX_FLOWS = 20; diff --git a/packages/cli/src/mcp/snapshotProvider.ts b/packages/cli/src/mcp/snapshotProvider.ts index 812e1a4..2f9c72a 100644 --- a/packages/cli/src/mcp/snapshotProvider.ts +++ b/packages/cli/src/mcp/snapshotProvider.ts @@ -6,13 +6,36 @@ import { readSnapshotOrThrow, type LoadedSnapshot } from "../cache/snapshot.js"; +import { DevmapError } from "../utils/errors.js"; const DEFAULT_STALE_TTL_MS = 10_000; +const DEFAULT_RETRY_COOLDOWN_MS = 30_000; + +export type ProviderSnapshot = LoadedSnapshot & { + /** True when this call re-analyzed the project (auto-refresh) before answering. */ + refreshed: boolean; + /** Set when auto-refresh was attempted and failed; the older snapshot is served instead. */ + refreshError?: string; +}; export type SnapshotProvider = { readonly projectRoot: string; - /** Read-only. Throws a DevmapError (with hint) when the snapshot is missing or unusable. */ - get(): Promise; + /** Throws a DevmapError (with hint) when the snapshot is missing or unusable. */ + get(): Promise; + /** Drop cached state, e.g. after the snapshot was rewritten by an analysis. */ + invalidate(): void; +}; + +export type SnapshotProviderOptions = { + staleTtlMs?: number; + now?: () => number; + /** + * Opt-in auto-refresh. Called when the snapshot is stale (or missing); the + * provider then reloads the snapshot. Off by default: reads never write. + */ + autoRefresh?: () => Promise; + /** After a failed auto-refresh, wait this long before trying again. */ + retryCooldownMs?: number; }; /** @@ -20,45 +43,92 @@ export type SnapshotProvider = { * re-fingerprinting the whole project on every call: * - the snapshot is re-read only when snapshot.json changes on disk * - the staleness check (which scans project files) is memoized for a short TTL + * - with `autoRefresh`, a stale snapshot is rebuilt before answering */ export function createSnapshotProvider( root: string, - options: { staleTtlMs?: number; now?: () => number } = {} + options: SnapshotProviderOptions = {} ): SnapshotProvider { const projectRoot = resolve(root); const ttl = options.staleTtlMs ?? DEFAULT_STALE_TTL_MS; const now = options.now ?? Date.now; + const cooldown = options.retryCooldownMs ?? DEFAULT_RETRY_COOLDOWN_MS; + const { autoRefresh } = options; let cached: { mtimeMs: number; snapshot: LoadedSnapshot["snapshot"] } | undefined; let staleCache: { value: boolean; at: number; mtimeMs: number } | undefined; + let lastRefreshFailure: { at: number; message: string } | undefined; + + function invalidate(): void { + cached = undefined; + staleCache = undefined; + } + + async function load(): Promise { + let mtimeMs: number | undefined; + try { + mtimeMs = (await stat(getSnapshotPath(projectRoot))).mtimeMs; + } catch { + mtimeMs = undefined; + } + + if (mtimeMs === undefined || !cached || cached.mtimeMs !== mtimeMs) { + // Throws DevmapError for missing/corrupt/unsupported snapshots. + const snapshot = await readSnapshotOrThrow(projectRoot); + cached = { mtimeMs: mtimeMs ?? 0, snapshot }; + staleCache = undefined; + } + + const currentMtime = cached.mtimeMs; + if (!staleCache || staleCache.mtimeMs !== currentMtime || now() - staleCache.at > ttl) { + staleCache = { + value: await isSnapshotStale(projectRoot, cached.snapshot), + at: now(), + mtimeMs: currentMtime + }; + } + + return { snapshot: cached.snapshot, stale: staleCache.value }; + } return { projectRoot, - async get(): Promise { - let mtimeMs: number | undefined; - try { - mtimeMs = (await stat(getSnapshotPath(projectRoot))).mtimeMs; - } catch { - mtimeMs = undefined; - } + invalidate, + async get(): Promise { + let loaded: LoadedSnapshot; + let refreshed = false; + let refreshError: string | undefined; - if (mtimeMs === undefined || !cached || cached.mtimeMs !== mtimeMs) { - // Throws DevmapError for missing/corrupt/unsupported snapshots. - const snapshot = await readSnapshotOrThrow(projectRoot); - cached = { mtimeMs: mtimeMs ?? 0, snapshot }; - staleCache = undefined; + try { + loaded = await load(); + } catch (error) { + if (!autoRefresh || !(error instanceof DevmapError)) throw error; + // No usable snapshot: build the first one. A failure here is the real error. + await autoRefresh(); + invalidate(); + loaded = await load(); + refreshed = true; } - const currentMtime = cached.mtimeMs; - if (!staleCache || staleCache.mtimeMs !== currentMtime || now() - staleCache.at > ttl) { - staleCache = { - value: await isSnapshotStale(projectRoot, cached.snapshot), - at: now(), - mtimeMs: currentMtime - }; + if (loaded.stale && autoRefresh && !refreshed) { + if (lastRefreshFailure && now() - lastRefreshFailure.at < cooldown) { + refreshError = lastRefreshFailure.message; + } else { + try { + await autoRefresh(); + invalidate(); + loaded = await load(); + refreshed = true; + lastRefreshFailure = undefined; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + lastRefreshFailure = { at: now(), message }; + refreshError = message; + } + } } - return { snapshot: cached.snapshot, stale: staleCache.value }; + return { ...loaded, refreshed, ...(refreshError ? { refreshError } : {}) }; } }; } diff --git a/packages/cli/test/core-api.test.ts b/packages/cli/test/core-api.test.ts index 1c8c7fe..8928aeb 100644 --- a/packages/cli/test/core-api.test.ts +++ b/packages/cli/test/core-api.test.ts @@ -6,6 +6,7 @@ import test from "node:test"; import { fileURLToPath } from "node:url"; import { createProjectMap } from "../src/analyzers/pipeline/projectMap.js"; import { loadSnapshot, saveSnapshot } from "../src/cache/snapshot.js"; +import { runAnalyze } from "../src/commands/analyze.js"; import { runFlow } from "../src/commands/flow.js"; import { runMap } from "../src/commands/map.js"; import { DevmapError } from "../src/utils/errors.js"; @@ -165,3 +166,38 @@ test("runFlow writes .devmap/flows by default", async () => { await rm(projectRoot, { recursive: true, force: true }); } }); + +test("runAnalyze with offline:true never touches the AI provider, even when an API key is configured", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "devmap-offline-analyze-")); + const { cp } = await import("node:fs/promises"); + await cp(nextFixture, projectRoot, { recursive: true }); + let aiTouched = false; + + try { + const { value: snapshot, written } = await captureStreams( + () => withJsonOutput(() => runAnalyze( + projectRoot, + { fresh: true, offline: true, json: true }, + { + loadConfig: async () => { + aiTouched = true; + return { provider: "groq", apiKey: "gsk_fixture", model: "auto" }; + }, + createAiClient: () => { + aiTouched = true; + throw new Error("AI client must not be created in offline mode"); + } + } + )) + ); + + assert.equal(aiTouched, false); + assert.equal(snapshot.domain, undefined); + assert.equal(snapshot.ai, undefined); + assert.ok(snapshot.features.length > 0, "static analysis still produces features"); + assert.deepEqual(written, [], "offline analysis is silent in JSON mode"); + assert.equal(await exists(join(projectRoot, ".devmap", "snapshot.json")), true); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); diff --git a/packages/cli/test/mcp-freshness.test.ts b/packages/cli/test/mcp-freshness.test.ts new file mode 100644 index 0000000..1e00cb2 --- /dev/null +++ b/packages/cli/test/mcp-freshness.test.ts @@ -0,0 +1,590 @@ +import assert from "node:assert/strict"; +import { cp, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import type { ProjectMap } from "../src/analyzers/pipeline/index.js"; +import { createProjectMap } from "../src/analyzers/pipeline/projectMap.js"; +import { getSnapshotPath, readSnapshot, saveSnapshot } from "../src/cache/snapshot.js"; +import { + createDevmapServer, + type DevmapServerOptions +} from "../src/mcp/server.js"; +import { queryImpact } from "../src/mcp/queries.js"; +import { + acquireLock, + createRefresher, + LOCK_STALE_MS, + type AnalyzeFn +} from "../src/mcp/refresh.js"; +import { createSnapshotProvider } from "../src/mcp/snapshotProvider.js"; +import { DevmapError } from "../src/utils/errors.js"; + +const testDirectory = dirname(fileURLToPath(import.meta.url)); +const packageRoot = resolve(testDirectory, ".."); +const hubFixture = join(testDirectory, "fixtures", "hub-fanin"); + +/** A tiny real project with a saved, current snapshot. */ +async function tinyProject(): Promise { + const projectRoot = await mkdtemp(join(tmpdir(), "devmap-mcp-fresh-")); + await mkdir(join(projectRoot, "src"), { recursive: true }); + await writeFile(join(projectRoot, "package.json"), JSON.stringify({ name: "fresh-app", version: "1.0.0" })); + await writeFile(join(projectRoot, "src", "index.ts"), "export const a = 1;\n"); + await saveSnapshot(projectRoot, await createProjectMap(projectRoot)); + return projectRoot; +} + +async function addFile(projectRoot: string, name = "extra.ts"): Promise { + await writeFile(join(projectRoot, "src", name), `export const v${Date.now()} = 2;\n`); +} + +/** Wraps the real static analysis and counts how often it runs. */ +function countingAnalyze(): { analyze: AnalyzeFn; calls: Array<{ fresh: boolean; offline: boolean }> } { + const calls: Array<{ fresh: boolean; offline: boolean }> = []; + const analyze: AnalyzeFn = async (projectRoot, options) => { + calls.push(options); + const { runAnalyze } = await import("../src/commands/analyze.js"); + const { withJsonOutput } = await import("../src/utils/output.js"); + return withJsonOutput(() => runAnalyze( + projectRoot, + { fresh: options.fresh, offline: options.offline, json: true }, + {} + )); + }; + return { analyze, calls }; +} + +async function connect( + options: DevmapServerOptions +): Promise<{ client: Client; close: () => Promise }> { + const { server } = createDevmapServer(options); + const client = new Client({ name: "test-client", version: "0.0.0" }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); + return { + client, + close: async () => { + await client.close(); + await server.close(); + } + }; +} + +async function callTool( + client: Client, + name: string, + args: Record = {} +): Promise<{ isError: boolean; data: any }> { + const result = (await client.callTool({ name, arguments: args })) as { + isError?: boolean; + content: Array<{ text: string }>; + }; + return { isError: result.isError === true, data: JSON.parse(result.content[0]!.text) }; +} + +function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolvePromise!: (value: T) => void; + const promise = new Promise((resolveFn) => { + resolvePromise = resolveFn; + }); + return { promise, resolve: resolvePromise }; +} + +// ── devmap_analyze ───────────────────────────────────────────────────────── + +test("devmap_analyze reports unchanged without re-analyzing when nothing changed", async () => { + const projectRoot = await tinyProject(); + const { analyze, calls } = countingAnalyze(); + const { client, close } = await connect({ projectRoot, analyze }); + + try { + const result = await callTool(client, "devmap_analyze"); + assert.equal(result.isError, false); + assert.equal(result.data.status, "unchanged"); + assert.equal(result.data.mode, "static"); + assert.equal(result.data.aiDataDropped, false); + assert.deepEqual(calls, []); + } finally { + await close(); + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("devmap_analyze rebuilds a stale snapshot offline and clears staleness", async () => { + const projectRoot = await tinyProject(); + const { analyze, calls } = countingAnalyze(); + const { client, close } = await connect({ projectRoot, analyze }); + + try { + await addFile(projectRoot); + assert.equal((await callTool(client, "devmap_overview")).data.snapshot.stale, true); + + const result = await callTool(client, "devmap_analyze"); + assert.equal(result.isError, false); + assert.equal(result.data.status, "refreshed"); + assert.deepEqual(calls, [{ fresh: false, offline: true }], "AI must stay off by default"); + + const overview = await callTool(client, "devmap_overview"); + assert.equal(overview.data.snapshot.stale, false); + assert.equal(overview.data.stats.totalFiles, result.data.stats.totalFiles); + assert.equal(await stat(join(projectRoot, ".devmap", "analyze.lock")).then(() => true, () => false), false, "lock is released"); + } finally { + await close(); + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("devmap_analyze fresh:true forces a rebuild even when nothing changed", async () => { + const projectRoot = await tinyProject(); + const { analyze, calls } = countingAnalyze(); + const { client, close } = await connect({ projectRoot, analyze }); + + try { + const result = await callTool(client, "devmap_analyze", { fresh: true }); + assert.equal(result.data.status, "refreshed"); + assert.deepEqual(calls, [{ fresh: true, offline: true }]); + } finally { + await close(); + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("allowAi lets analysis run with AI enabled, and says so", async () => { + const projectRoot = await tinyProject(); + const { analyze, calls } = countingAnalyze(); + const { client, close } = await connect({ projectRoot, analyze, allowAi: true }); + + try { + const tools = await client.listTools(); + const analyzeTool = tools.tools.find((tool) => tool.name === "devmap_analyze")!; + assert.equal(analyzeTool.annotations?.openWorldHint, true); + + await addFile(projectRoot); + const result = await callTool(client, "devmap_analyze"); + assert.equal(result.data.mode, "ai-enabled"); + assert.deepEqual(calls, [{ fresh: false, offline: false }]); + } finally { + await close(); + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("static analysis reports aiDataDropped when the old snapshot had AI data", async () => { + const projectRoot = await tinyProject(); + const previous = (await readSnapshot(projectRoot))!; + await saveSnapshot(projectRoot, { + ...previous, + domain: { domain: "test", summary: "AI summary" } as unknown as ProjectMap["domain"] + }); + const { analyze } = countingAnalyze(); + const { client, close } = await connect({ projectRoot, analyze }); + + try { + await addFile(projectRoot); + const result = await callTool(client, "devmap_analyze"); + assert.equal(result.data.status, "refreshed"); + assert.equal(result.data.aiDataDropped, true); + assert.match(result.data.note, /--allow-ai|devmap analyze/); + } finally { + await close(); + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("concurrent devmap_analyze calls share a single analysis", async () => { + const projectRoot = await tinyProject(); + await addFile(projectRoot); + const gate = deferred(); + let runs = 0; + const analyze: AnalyzeFn = async (root, options) => { + runs += 1; + await gate.promise; + return countingAnalyze().analyze(root, options); + }; + const { client, close } = await connect({ projectRoot, analyze }); + + try { + const first = callTool(client, "devmap_analyze"); + const second = callTool(client, "devmap_analyze"); + await new Promise((done) => setTimeout(done, 100)); + gate.resolve(); + + const [a, b] = await Promise.all([first, second]); + assert.equal(runs, 1); + assert.equal(a.data.status, "refreshed"); + assert.equal(b.data.status, "refreshed"); + } finally { + await close(); + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("a slow analysis returns status running and keeps going in the background", async () => { + const projectRoot = await tinyProject(); + await addFile(projectRoot); + const gate = deferred(); + const { analyze: realAnalyze } = countingAnalyze(); + const analyze: AnalyzeFn = async (root, options) => { + await gate.promise; + return realAnalyze(root, options); + }; + const { client, close } = await connect({ projectRoot, analyze }); + + try { + const waiting = await callTool(client, "devmap_analyze", { timeoutSeconds: 1 }); + assert.equal(waiting.isError, false); + assert.equal(waiting.data.status, "running"); + assert.match(waiting.data.hint, /again/); + + gate.resolve(); + const done = await callTool(client, "devmap_analyze", { timeoutSeconds: 30 }); + assert.equal(done.data.status, "refreshed"); + assert.equal((await callTool(client, "devmap_overview")).data.snapshot.stale, false); + } finally { + gate.resolve(); + await close(); + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("devmap_analyze sends progress notifications while it runs", async () => { + const projectRoot = await tinyProject(); + await addFile(projectRoot); + const { analyze: realAnalyze } = countingAnalyze(); + const analyze: AnalyzeFn = async (root, options) => { + await new Promise((done) => setTimeout(done, 400)); + return realAnalyze(root, options); + }; + const { client, close } = await connect({ projectRoot, analyze, heartbeatMs: 50 }); + + try { + const progress: number[] = []; + await client.callTool( + { name: "devmap_analyze", arguments: {} }, + undefined, + { onprogress: (update) => progress.push(update.progress) } + ); + assert.ok(progress.length >= 2, `expected heartbeats, got ${progress.length}`); + assert.deepEqual([...progress].sort((a, b) => a - b), progress, "progress must increase"); + } finally { + await close(); + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +// ── auto-refresh ─────────────────────────────────────────────────────────── + +test("without autoRefresh, queries never write: stale results are reported as stale", async () => { + const projectRoot = await tinyProject(); + const { analyze, calls } = countingAnalyze(); + const { client, close } = await connect({ projectRoot, analyze }); + + try { + await addFile(projectRoot); + const before = (await stat(getSnapshotPath(projectRoot))).mtimeMs; + const overview = await callTool(client, "devmap_overview"); + assert.equal(overview.data.snapshot.stale, true); + assert.equal(overview.data.snapshot.refreshed, undefined); + assert.deepEqual(calls, []); + assert.equal((await stat(getSnapshotPath(projectRoot))).mtimeMs, before); + } finally { + await close(); + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("autoRefresh re-analyzes a stale snapshot before answering", async () => { + const projectRoot = await tinyProject(); + const { analyze, calls } = countingAnalyze(); + const { client, close } = await connect({ projectRoot, analyze, autoRefresh: true }); + + try { + await addFile(projectRoot); + const overview = await callTool(client, "devmap_overview"); + assert.equal(overview.data.snapshot.stale, false); + assert.equal(overview.data.snapshot.refreshed, true); + assert.equal(overview.data.stats.totalFiles >= 3, true); + assert.equal(calls.length, 1); + + const again = await callTool(client, "devmap_overview"); + assert.equal(again.data.snapshot.refreshed, undefined); + assert.equal(calls.length, 1, "a fresh snapshot is not rebuilt again"); + } finally { + await close(); + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("autoRefresh builds the first snapshot when none exists", async () => { + const projectRoot = await tinyProject(); + await rm(join(projectRoot, ".devmap"), { recursive: true, force: true }); + const { analyze, calls } = countingAnalyze(); + const { client, close } = await connect({ projectRoot, analyze, autoRefresh: true }); + + try { + const overview = await callTool(client, "devmap_overview"); + assert.equal(overview.isError, false); + assert.equal(overview.data.snapshot.refreshed, true); + assert.equal(calls.length, 1); + } finally { + await close(); + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("a failing auto-refresh falls back to the stale snapshot and backs off", async () => { + const projectRoot = await tinyProject(); + let attempts = 0; + const analyze: AnalyzeFn = async () => { + attempts += 1; + throw new Error("boom"); + }; + const { client, close } = await connect({ projectRoot, analyze, autoRefresh: true }); + + try { + await addFile(projectRoot); + const first = await callTool(client, "devmap_overview"); + assert.equal(first.isError, false, "stale data is better than no data"); + assert.equal(first.data.snapshot.stale, true); + assert.equal(first.data.snapshot.refreshError, "boom"); + assert.match(first.data.snapshot.note, /Auto-refresh failed/); + + await callTool(client, "devmap_overview"); + assert.equal(attempts, 1, "retry is held back during the cooldown"); + } finally { + await close(); + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("provider auto-refresh retries after the cooldown", async () => { + const projectRoot = await tinyProject(); + let attempts = 0; + let clock = 1_000; + const provider = createSnapshotProvider(projectRoot, { + staleTtlMs: 0, + now: () => clock, + retryCooldownMs: 100, + autoRefresh: async () => { + attempts += 1; + throw new Error("nope"); + } + }); + + try { + await addFile(projectRoot); + await provider.get(); + clock += 50; + await provider.get(); + assert.equal(attempts, 1); + clock += 100; + await provider.get(); + assert.equal(attempts, 2); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +// ── lock ─────────────────────────────────────────────────────────────────── + +test("analysis refuses to start while a live process holds the lock", async () => { + const projectRoot = await tinyProject(); + + try { + const release = await acquireLock(projectRoot); + await assert.rejects( + () => acquireLock(projectRoot), + (error: unknown) => error instanceof DevmapError && /already running/.test(error.message) + ); + await release(); + + const again = await acquireLock(projectRoot); + await again(); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("a lock from a dead process or an expired lock is taken over", async () => { + const projectRoot = await tinyProject(); + const lockFile = join(projectRoot, ".devmap", "analyze.lock"); + + try { + await writeFile(lockFile, JSON.stringify({ pid: 2_147_483_000, startedAt: new Date().toISOString() })); + await (await acquireLock(projectRoot))(); + + await writeFile(lockFile, JSON.stringify({ + pid: process.pid, + startedAt: new Date(Date.now() - LOCK_STALE_MS - 1_000).toISOString() + })); + await (await acquireLock(projectRoot))(); + + await writeFile(lockFile, "not json"); + await (await acquireLock(projectRoot))(); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("devmap_analyze surfaces a held lock as a structured error", async () => { + const projectRoot = await tinyProject(); + await addFile(projectRoot); + const { analyze } = countingAnalyze(); + const { client, close } = await connect({ projectRoot, analyze }); + const release = await acquireLock(projectRoot); + + try { + const result = await callTool(client, "devmap_analyze"); + assert.equal(result.isError, true); + assert.match(result.data.error, /already running/); + assert.match(result.data.hint, /retry/i); + } finally { + await release(); + await close(); + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("refresher: unchanged projects skip the lock and the analysis", async () => { + const projectRoot = await tinyProject(); + const { analyze, calls } = countingAnalyze(); + const refresher = createRefresher({ projectRoot, analyze }); + + try { + const release = await acquireLock(projectRoot); // would block a real analysis + const outcome = await refresher.run(); + assert.equal(outcome.status, "unchanged"); + assert.deepEqual(calls, []); + await release(); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +// ── impact (transitive) ──────────────────────────────────────────────────── + +test("devmap_impact follows imports transitively and reports affected features", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "devmap-mcp-impact-")); + await cp(hubFixture, projectRoot, { recursive: true }); + const snapshot = await createProjectMap(projectRoot); + await saveSnapshot(projectRoot, snapshot); + + try { + // Pick the file with the most direct dependents. + const [hub] = Object.entries(snapshot.changeImpact) + .sort((a, b) => b[1].dependents.length - a[1].dependents.length)[0]!; + + const shallow = queryImpact(snapshot, hub, { depth: 1 }); + const deep = queryImpact(snapshot, hub, { depth: 3 }); + + assert.equal(shallow.transitive.depth, 1); + assert.ok(shallow.transitive.files.every((entry) => entry.depth === 1)); + assert.ok(deep.transitive.count >= shallow.transitive.count); + assert.equal(deep.dependentCount, snapshot.changeImpact[hub]!.dependents.length); + assert.ok(!deep.transitive.files.some((entry) => entry.file === hub), "the file is not its own dependent"); + assert.deepEqual( + deep.transitive.files.map((entry) => entry.depth), + [...deep.transitive.files.map((entry) => entry.depth)].sort((a, b) => a - b), + "ordered by hop" + ); + assert.equal(typeof deep.isCritical, "boolean"); + assert.ok(Array.isArray(deep.affectedFeatures)); + assert.ok(deep.criticalDependents.every((path) => snapshot.criticalFiles.some((file) => file.path === path))); + + const limited = queryImpact(snapshot, hub, { depth: 3, limit: 1 }); + assert.equal(limited.transitive.files.length, Math.min(1, deep.transitive.count)); + assert.equal(limited.transitive.truncated, deep.transitive.count > 1); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("transitive impact is cycle-safe", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "devmap-mcp-cycle-")); + await mkdir(join(projectRoot, "src"), { recursive: true }); + await writeFile(join(projectRoot, "package.json"), JSON.stringify({ name: "cycle", version: "1.0.0" })); + await writeFile(join(projectRoot, "src", "a.ts"), 'import { b } from "./b";\nexport const a = () => b;\n'); + await writeFile(join(projectRoot, "src", "b.ts"), 'import { a } from "./a";\nexport const b = () => a;\n'); + + try { + const snapshot = await createProjectMap(projectRoot); + const impact = queryImpact(snapshot, "src/a.ts", { depth: 5 }); + assert.deepEqual(impact.transitive.files.map((entry) => entry.file), ["src/b.ts"]); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +// ── prompts ──────────────────────────────────────────────────────────────── + +test("prompts expose ready-made DevMap workflows", async () => { + const projectRoot = await tinyProject(); + const { client, close } = await connect({ projectRoot }); + + try { + const { prompts } = await client.listPrompts(); + assert.deepEqual(prompts.map((prompt) => prompt.name).sort(), ["devmap_onboard", "devmap_plan_change"]); + + const onboard = await client.getPrompt({ name: "devmap_onboard", arguments: { focus: "payments" } }); + const onboardText = (onboard.messages[0]!.content as { text: string }).text; + assert.match(onboardText, /devmap_overview/); + assert.match(onboardText, /payments/); + + const plan = await client.getPrompt({ + name: "devmap_plan_change", + arguments: { change: "add rate limiting to login" } + }); + const planText = (plan.messages[0]!.content as { text: string }).text; + assert.match(planText, /add rate limiting to login/); + for (const tool of ["devmap_find", "devmap_map", "devmap_impact", "devmap_analyze"]) { + assert.match(planText, new RegExp(tool)); + } + + await assert.rejects(() => client.getPrompt({ name: "devmap_plan_change", arguments: {} })); + } finally { + await close(); + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +// ── real process ─────────────────────────────────────────────────────────── + +test("devmap mcp --auto-refresh over stdio refreshes a stale project with a clean protocol stream", async () => { + const projectRoot = await tinyProject(); + await addFile(projectRoot); + const transport = new StdioClientTransport({ + command: process.execPath, + args: ["--import", "tsx", join(packageRoot, "src", "index.ts"), "mcp", "--root", projectRoot, "--auto-refresh"], + cwd: packageRoot, + stderr: "pipe" + }); + const client = new Client({ name: "stdio-fresh-client", version: "0.0.0" }); + const protocolErrors: unknown[] = []; + transport.onerror = (error) => protocolErrors.push(error); + + try { + await client.connect(transport); + + // A real analysis runs inside the server process; any stray console/spinner + // output on stdout would be reported as a protocol error. + const overview = await callTool(client, "devmap_overview"); + assert.equal(overview.isError, false); + assert.equal(overview.data.snapshot.refreshed, true); + assert.equal(overview.data.snapshot.stale, false); + + const forced = await callTool(client, "devmap_analyze", { fresh: true }); + assert.equal(forced.data.status, "refreshed"); + assert.equal(forced.data.mode, "static"); + + assert.deepEqual(protocolErrors, []); + const saved = JSON.parse(await readFile(getSnapshotPath(projectRoot), "utf8")) as ProjectMap; + assert.ok(saved.stats.totalFiles >= 3); + } finally { + await client.close(); + await rm(projectRoot, { recursive: true, force: true }); + } +}); diff --git a/packages/cli/test/mcp-server.test.ts b/packages/cli/test/mcp-server.test.ts index 58f4559..5fa8047 100644 --- a/packages/cli/test/mcp-server.test.ts +++ b/packages/cli/test/mcp-server.test.ts @@ -73,7 +73,7 @@ async function callTool( }; } -test("server lists the read-only tools with read-only annotations", async () => { +test("server lists tools; only devmap_analyze is not read-only", async () => { const projectRoot = await projectFromFixture(nextFixture); const { client, close } = await connect(projectRoot); @@ -81,6 +81,7 @@ test("server lists the read-only tools with read-only annotations", async () => const { tools } = await client.listTools(); const names = tools.map((tool) => tool.name).sort(); assert.deepEqual(names, [ + "devmap_analyze", "devmap_find", "devmap_flow", "devmap_impact", @@ -89,7 +90,13 @@ test("server lists the read-only tools with read-only annotations", async () => "devmap_routes" ]); for (const tool of tools) { - assert.equal(tool.annotations?.readOnlyHint, true, `${tool.name} must be read-only`); + // devmap_analyze is the only tool allowed to write (inside .devmap/ only). + assert.equal( + tool.annotations?.readOnlyHint, + tool.name === "devmap_analyze" ? false : true, + `${tool.name} read-only hint` + ); + assert.equal(tool.annotations?.destructiveHint, false, `${tool.name} must not be destructive`); assert.ok(tool.description && tool.description.length > 20, `${tool.name} needs a description`); } assert.match(client.getInstructions() ?? "", /devmap_overview/); @@ -263,7 +270,7 @@ test("results flag a stale snapshot after the project changes", async () => { const stale = await callTool(client, "devmap_overview"); assert.equal(stale.data.snapshot.stale, true); - assert.match(stale.data.snapshot.note, /devmap analyze --fresh/); + assert.match(stale.data.snapshot.note, /devmap_analyze/); } finally { await client.close(); await server.close(); @@ -348,6 +355,7 @@ test("query helpers: limits, path resolution, and truncation", async () => { assert.throws(() => resolveProjectFile(snapshot, ""), DevmapError); assert.equal(queryRoutes(snapshot, { method: "ZZZ" }).total, 0); assert.throws(() => queryImpact(snapshot, "nope/missing.ts"), DevmapError); + assert.equal(queryImpact(snapshot, Object.keys(snapshot.fileIndex)[0]!, { depth: 99 }).transitive.depth, 5); assert.ok(queryFind(snapshot, "route", 3).hits.length <= 3); }); @@ -369,7 +377,7 @@ test("devmap mcp over stdio keeps stdout protocol-clean and answers real request await client.connect(transport); const { tools } = await client.listTools(); - assert.equal(tools.length, 6); + assert.equal(tools.length, 7); const overview = await callTool(client, "devmap_overview"); assert.equal(overview.isError, false); diff --git a/packages/cli/test/package-e2e.mjs b/packages/cli/test/package-e2e.mjs index 6ae7e5e..5957bad 100644 --- a/packages/cli/test/package-e2e.mjs +++ b/packages/cli/test/package-e2e.mjs @@ -176,6 +176,13 @@ try { assert.notEqual(overview.isError, true); const payload = JSON.parse(overview.content[0].text); assert.equal(payload.project.framework, expectedFramework); + + // Refresh path from the packed package: static analysis, protocol stays clean. + const refreshed = await client.callTool({ name: "devmap_analyze", arguments: { fresh: true } }); + assert.notEqual(refreshed.isError, true); + const refreshPayload = JSON.parse(refreshed.content[0].text); + assert.equal(refreshPayload.status, "refreshed"); + assert.equal(refreshPayload.mode, "static"); assert.deepEqual(protocolErrors, []); } finally { await client.close();