diff --git a/packages/cli/src/cache/snapshot.ts b/packages/cli/src/cache/snapshot.ts index 78c1991..cb6daea 100644 --- a/packages/cli/src/cache/snapshot.ts +++ b/packages/cli/src/cache/snapshot.ts @@ -36,6 +36,22 @@ export async function isSnapshotStale( return createProjectFingerprint(files) !== snapshot.fingerprint; } +export type LoadedSnapshot = { + snapshot: ProjectMap; + /** True when the project files changed since the snapshot was generated. */ + stale: boolean; +}; + +/** + * Read the snapshot (throwing a DevmapError with a hint when it is missing, + * corrupt, or unsupported) together with its staleness. Read-only. + */ +export async function loadSnapshot(projectRoot: string): Promise { + const snapshot = await readSnapshotOrThrow(projectRoot); + const stale = await isSnapshotStale(projectRoot, snapshot); + return { snapshot, stale }; +} + export async function readSnapshot(projectRoot: string): Promise { const result = await inspectSnapshot(projectRoot); return result.status === "valid" ? result.snapshot : null; diff --git a/packages/cli/src/commands/explain.ts b/packages/cli/src/commands/explain.ts index 5cbf4cf..cbb332b 100644 --- a/packages/cli/src/commands/explain.ts +++ b/packages/cli/src/commands/explain.ts @@ -62,7 +62,13 @@ export async function explainCommand( } } -async function runExplain( +/** + * Core explain computation, shared by the CLI and other front-ends (e.g. an MCP server). + * Requires a configured AI provider. Only writes `.devmap/explain/*` when + * `options.write` is true. Pass `json: true` (and call inside `withJsonOutput`) + * to disable terminal streaming. + */ +export async function runExplain( target: string, options: ExplainOptions, dependencies: ExplainDependencies diff --git a/packages/cli/src/commands/flow.ts b/packages/cli/src/commands/flow.ts index 22ff7fd..ea07888 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 { isSnapshotStale, readSnapshotOrThrow } from "../cache/snapshot.js"; +import { loadSnapshot } from "../cache/snapshot.js"; import { resolveEffectiveConfig, type DevmapConfig } from "../utils/config.js"; import { DevmapError } from "../utils/errors.js"; import { buildMapMarkdown } from "../utils/mapRenderer.js"; @@ -25,6 +25,16 @@ export type FlowOptions = { json?: boolean; projectRoot?: string; all?: boolean; + /** + * Persist flows under `.devmap/flows/`. Defaults to true (CLI behavior). + * Pass `false` to compute flows without touching the disk. + */ + write?: boolean; + /** + * Add AI narration when an AI provider is configured. Defaults to true. + * Pass `false` for a purely static, offline result. + */ + narrate?: boolean; }; export type FlowDependencies = { @@ -76,36 +86,47 @@ export async function flowCommand( } } -async function runFlow( +/** + * Core flow computation, shared by the CLI and other front-ends (e.g. an MCP server). + * Human-facing notes go through `output.*`, so call it inside `withJsonOutput` + * to keep stdout clean. Writes `.devmap/flows/*` unless `options.write === false`; + * skips AI entirely when `options.narrate === false`. + */ +export async function runFlow( target: string | undefined, options: FlowOptions, dependencies: FlowDependencies ): Promise { const projectRoot = resolve(options.projectRoot ?? "."); - const snapshot = await readSnapshotOrThrow(projectRoot); - const stale = await isSnapshotStale(projectRoot, snapshot); + const { snapshot, stale } = await loadSnapshot(projectRoot); const flows = options.all ? buildAllFlows(snapshot) : snapshot.flows; const resolved = resolveFlowTarget(flows, target); - const loadConfig = dependencies.loadConfig - ?? (() => resolveEffectiveConfig(projectRoot)); - const config = await loadConfig(); + const shouldWrite = options.write !== false; let client: AiClient | undefined; let routing: { model: string; fallbackModels: readonly string[] } | undefined; - if (!config?.apiKey) { - output.note("AI flow narration is not configured. Run devmap init to enable it."); - } else { - const createAiClient = dependencies.createAiClient ?? createDefaultAiClient; - client = createAiClient(config); - routing = resolveAiRouting(config, "flowNarration"); + if (options.narrate !== false) { + const loadConfig = dependencies.loadConfig + ?? (() => resolveEffectiveConfig(projectRoot)); + const config = await loadConfig(); + + if (!config?.apiKey) { + output.note("AI flow narration is not configured. Run devmap init to enable it."); + } else { + const createAiClient = dependencies.createAiClient ?? createDefaultAiClient; + client = createAiClient(config); + routing = resolveAiRouting(config, "flowNarration"); + } } const flowsDir = join(projectRoot, ".devmap", "flows"); - await mkdir(flowsDir, { recursive: true }); + if (shouldWrite) { + await mkdir(flowsDir, { recursive: true }); + } const built: Array<{ name: string; @@ -137,8 +158,10 @@ async function runFlow( const mermaid = flow.mermaid ?? renderMermaidFlow(flow.steps); const slug = slugifyMapName(flow.name); - await writeFile(join(flowsDir, `${slug}.md`), markdown, "utf8"); - await writeFile(join(flowsDir, `${slug}.mermaid`), `${mermaid}\n`, "utf8"); + if (shouldWrite) { + await writeFile(join(flowsDir, `${slug}.md`), markdown, "utf8"); + await writeFile(join(flowsDir, `${slug}.mermaid`), `${mermaid}\n`, "utf8"); + } built.push({ name: flow.name, @@ -154,11 +177,13 @@ async function runFlow( return { status: "ok", flows: built.map(({ slug: _slug, ...rest }) => rest), - writtenPaths: built.map(({ name, slug }) => ({ - name, - markdown: `.devmap/flows/${slug}.md`, - mermaid: `.devmap/flows/${slug}.mermaid` - })), + writtenPaths: shouldWrite + ? built.map(({ name, slug }) => ({ + name, + markdown: `.devmap/flows/${slug}.md`, + mermaid: `.devmap/flows/${slug}.mermaid` + })) + : [], snapshot: { generatedAt: snapshot.generatedAt, stale } }; } diff --git a/packages/cli/src/commands/map.ts b/packages/cli/src/commands/map.ts index 90e6940..92e20da 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 { isSnapshotStale, readSnapshotOrThrow } from "../cache/snapshot.js"; +import { loadSnapshot } from "../cache/snapshot.js"; import { DevmapError } from "../utils/errors.js"; import { slugifyMapName } from "../utils/slug.js"; import { resolveFileTarget } from "../utils/targetResolver.js"; @@ -30,6 +30,11 @@ export type MapOptions = { target?: string; depth?: number; all?: boolean; + /** + * Persist the map under `.devmap/maps/`. Defaults to true (CLI behavior). + * Pass `false` to compute the map without touching the disk. + */ + write?: boolean; }; export type MapResult = { @@ -38,7 +43,8 @@ export type MapResult = { target: string; markdown: string; mermaid: string; - writtenPaths: { markdown: string; mermaid: string }; + /** Absent when the map was computed with `write: false`. */ + writtenPaths?: { markdown: string; mermaid: string }; snapshot: { generatedAt: string; stale: boolean }; }; @@ -60,17 +66,22 @@ export async function mapCommand( output.note("Run devmap analyze --fresh, then repeat devmap map."); } output.markdown(result.markdown); - output.success(`Wrote ${result.writtenPaths.markdown}`); - output.success(`Wrote ${result.writtenPaths.mermaid}`); + if (result.writtenPaths) { + output.success(`Wrote ${result.writtenPaths.markdown}`); + output.success(`Wrote ${result.writtenPaths.mermaid}`); + } } -async function runMap( +/** + * Core map computation, shared by the CLI and other front-ends (e.g. an MCP server). + * Never prints. Writes `.devmap/maps/*` unless `options.write === false`. + */ +export async function runMap( target: string | undefined, options: MapOptions ): Promise { const projectRoot = resolve(options.projectRoot ?? "."); - const snapshot = await readSnapshotOrThrow(projectRoot); - const stale = await isSnapshotStale(projectRoot, snapshot); + const { snapshot, stale } = await loadSnapshot(projectRoot); const resolved = resolveMapTarget(snapshot, target); @@ -80,14 +91,19 @@ async function runMap( ? buildFeatureMap(snapshot, resolved.value, options.depth, options.all) : buildProjectMap(snapshot, options.all); - const slug = slugifyMapName(resolved.value); - const mapsDir = join(projectRoot, ".devmap", "maps"); - await mkdir(mapsDir, { recursive: true }); + let writtenPaths: MapResult["writtenPaths"]; + if (options.write !== false) { + const slug = slugifyMapName(resolved.value); + const mapsDir = join(projectRoot, ".devmap", "maps"); + await mkdir(mapsDir, { recursive: true }); - const markdownPath = join(mapsDir, `${slug}.md`); - const mermaidPath = join(mapsDir, `${slug}.mermaid`); - await writeFile(markdownPath, built.markdown, "utf8"); - await writeFile(mermaidPath, `${built.mermaid}\n`, "utf8"); + await writeFile(join(mapsDir, `${slug}.md`), built.markdown, "utf8"); + await writeFile(join(mapsDir, `${slug}.mermaid`), `${built.mermaid}\n`, "utf8"); + writtenPaths = { + markdown: `.devmap/maps/${slug}.md`, + mermaid: `.devmap/maps/${slug}.mermaid` + }; + } return { status: "ok", @@ -95,10 +111,7 @@ async function runMap( target: resolved.value, markdown: built.markdown, mermaid: built.mermaid, - writtenPaths: { - markdown: `.devmap/maps/${slug}.md`, - mermaid: `.devmap/maps/${slug}.mermaid` - }, + ...(writtenPaths ? { writtenPaths } : {}), snapshot: { generatedAt: snapshot.generatedAt, stale } }; } diff --git a/packages/cli/test/core-api.test.ts b/packages/cli/test/core-api.test.ts new file mode 100644 index 0000000..1c8c7fe --- /dev/null +++ b/packages/cli/test/core-api.test.ts @@ -0,0 +1,167 @@ +import assert from "node:assert/strict"; +import { access, mkdtemp, readdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +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 { runFlow } from "../src/commands/flow.js"; +import { runMap } from "../src/commands/map.js"; +import { DevmapError } from "../src/utils/errors.js"; +import { withJsonOutput } from "../src/utils/output.js"; + +const testDirectory = dirname(fileURLToPath(import.meta.url)); +const nextFixture = join(testDirectory, "fixtures", "nextjs-project"); + +async function projectFromFixture(fixtureRoot: string): Promise { + const projectRoot = await mkdtemp(join(tmpdir(), "devmap-core-api-test-")); + const snapshot = await createProjectMap(fixtureRoot); + await saveSnapshot(projectRoot, snapshot); + return projectRoot; +} + +async function exists(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; + } +} + +/** Run an action and return everything written through console.log/error and process.stdout. */ +async function captureStreams(action: () => Promise): Promise<{ value: T; written: string[] }> { + const written: string[] = []; + const originalLog = console.log; + const originalError = console.error; + const originalStdoutWrite = process.stdout.write.bind(process.stdout); + + console.log = (...values: unknown[]) => { written.push(values.join(" ")); }; + console.error = (...values: unknown[]) => { written.push(values.join(" ")); }; + process.stdout.write = ((chunk: unknown) => { + written.push(String(chunk)); + return true; + }) as typeof process.stdout.write; + + try { + const value = await action(); + return { value, written }; + } finally { + console.log = originalLog; + console.error = originalError; + process.stdout.write = originalStdoutWrite; + } +} + +test("loadSnapshot returns the snapshot with its staleness", async () => { + const projectRoot = await projectFromFixture(nextFixture); + + try { + const { snapshot, stale } = await loadSnapshot(projectRoot); + assert.ok(snapshot.generatedAt); + assert.equal(typeof stale, "boolean"); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("loadSnapshot throws a hinted DevmapError when no snapshot exists", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "devmap-core-api-empty-")); + + try { + await assert.rejects( + () => loadSnapshot(projectRoot), + (error: unknown) => error instanceof DevmapError && /analyze/.test(error.hint ?? "") + ); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("runMap with write:false computes the map without writing or printing", async () => { + const projectRoot = await projectFromFixture(nextFixture); + + try { + const { value: result, written } = await captureStreams( + () => withJsonOutput(() => runMap(undefined, { projectRoot, write: false })) + ); + + assert.equal(result.status, "ok"); + assert.equal(result.mode, "project"); + assert.ok(result.markdown.length > 0); + assert.ok(result.mermaid.length > 0); + assert.equal(result.writtenPaths, undefined); + assert.deepEqual(written, []); + assert.equal(await exists(join(projectRoot, ".devmap", "maps")), false); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("runMap writes .devmap/maps by default and reports the paths", async () => { + const projectRoot = await projectFromFixture(nextFixture); + + try { + const { value: result } = await captureStreams( + () => withJsonOutput(() => runMap(undefined, { projectRoot })) + ); + + assert.ok(result.writtenPaths); + assert.equal(result.writtenPaths.markdown, ".devmap/maps/project.md"); + assert.equal(await exists(join(projectRoot, result.writtenPaths.markdown)), true); + assert.equal(await exists(join(projectRoot, result.writtenPaths.mermaid)), true); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("runFlow with write:false and narrate:false is offline, silent, and writes nothing", async () => { + const projectRoot = await projectFromFixture(nextFixture); + let configLoaded = false; + + try { + const { value: result, written } = await captureStreams( + () => withJsonOutput(() => runFlow( + undefined, + { projectRoot, write: false, narrate: false }, + { + loadConfig: async () => { + configLoaded = true; + return null; + } + } + )) + ); + + assert.equal(result.status, "ok"); + assert.ok(result.flows.length > 0); + assert.ok(result.flows.every((flow) => flow.narrated === false)); + assert.deepEqual(result.writtenPaths, []); + assert.equal(configLoaded, false); + assert.deepEqual(written, []); + assert.equal(await exists(join(projectRoot, ".devmap", "flows")), false); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("runFlow writes .devmap/flows by default", async () => { + const projectRoot = await projectFromFixture(nextFixture); + + try { + const { value: result } = await captureStreams( + () => withJsonOutput(() => runFlow( + undefined, + { projectRoot }, + { loadConfig: async () => null } + )) + ); + + assert.equal(result.writtenPaths.length, result.flows.length); + const files = await readdir(join(projectRoot, ".devmap", "flows")); + assert.ok(files.some((file) => file.endsWith(".md"))); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +});