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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ All notable changes to DevMap are documented in this file.

## [Unreleased]

### Fixed

- `devmap analyze` takes `.devmap/analyze.lock`, like the MCP server does, so a terminal analysis
and an MCP refresh can no longer write the snapshot at the same time. A second analysis fails with
"Another DevMap analysis is already running" instead of racing

### Planned

- Go, Java, C# and PHP (PSR-4) import resolution, after JS/TS is mature
Expand Down
10 changes: 5 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ devmap/
│ │ ├── mcp/ ← MCP server: tools, resources, prompts, snapshot provider, refresher + lock
│ │ ├── onboarding/ ← Onboarding model and builder
│ │ └── utils/ ← Config, errors, output, prompts, markdown renderer
│ ├── test/ ← 48 test files (`*.test.ts`, auto-discovered by `run-tests.ts`) + `mcp-smoke.mjs`, `package-e2e.mjs`
│ ├── test/ ← 49 test files (`*.test.ts`, auto-discovered by `run-tests.ts`) + `mcp-smoke.mjs`, `package-e2e.mjs`
│ └── package.json
├── apps/web/ ← Astro marketing site (unrelated to CLI)
├── docs/ ← Public documentation
Expand Down Expand Up @@ -76,7 +76,7 @@ Everything else (files, graph, routes, features, flows, critical files) is 100%
| MCP server | `src/mcp/server.ts` | `createDevmapServer()` — tools (`devmap_overview/map/flow/routes/impact/find/analyze`, `devmap_explain` only with `--allow-ai`), resources, prompts |
| MCP queries | `src/mcp/queries.ts` | Pure read-only queries over a `ProjectMap` (overview, routes, impact, find) |
| MCP snapshot provider | `src/mcp/snapshotProvider.ts` | mtime-cached snapshot, TTL-cached staleness, opt-in auto-refresh |
| MCP refresher | `src/mcp/refresh.ts` | Single-flight analysis + `.devmap/analyze.lock` |
| MCP refresher | `src/mcp/refresh.ts` | Single-flight analysis; holds `.devmap/analyze.lock` (lock code in `src/cache/analysisLock.ts`) |

## Data Model

Expand Down Expand Up @@ -129,12 +129,12 @@ Order for a non-relative specifier: `#subpath` (package.json `imports`) → tsco

## Testing

48 test files in `packages/cli/test/`. Key covered areas:
49 test files in `packages/cli/test/`. Key covered areas:
- Analyzers, AI clients, agent navigation, entity extractors
- Dependency graph: aliases, workspace packages, `#imports`, Nuxt auto-imports/aliases, entry points (`graph-resolution`, `dependency-graph-alias`, `js-ts-ecosystem`, `js-ts-frameworks`)
- Feature similarity/merge, frontend features, framework routes (`framework-routes`; Remix/React Router/TanStack/Expo/Hono and bundler aliases in `js-ts-frameworks`; client-side routers, Hono factories, Next normalization and critical-file ranking in `js-ts-gaps`; SolidStart, Qwik City, Angular, Elysia, Koa, AdonisJS and constant-resolved paths in `js-ts-more-frameworks`), detector fixes
- Heuristic language readers (`heuristic-languages`), scanner robustness, project scope/metadata
- Commands (init, analyze, map, flow, explain, onboarding, config, doctor)
- Commands (init, analyze, map, flow, explain, onboarding, config, doctor); `analyze-lock` covers `devmap analyze` holding `.devmap/analyze.lock`
- MCP server (`core-api`, `mcp-server`, `mcp-freshness`, `mcp-explain`): tools, resources, prompts, stale/missing snapshots, auto-refresh and lock, offline-never-touches-AI, stdio process with a clean protocol stream. `test/mcp-smoke.mjs` runs the compiled `dist/` as a server (CI and publish workflow)
- Utils (markdown terminal, JSON output, prompts, errors)

Expand Down Expand Up @@ -174,6 +174,7 @@ MCP server (after `pnpm build:cli`): `node packages/cli/test/mcp-smoke.mjs`.
- Domain inference is cached for idempotency (`.devmap/domain-cache.json`)
- Snapshot is regenerated on every `devmap analyze` run
- `AGENTS.md` is never overwritten by init (append-only)
- `analyzeCommand` (CLI) holds `.devmap/analyze.lock` around `runAnalyze`; `runAnalyze` itself never takes it, because the MCP refresher already holds the lock when it calls it
- `.devmap/` is always gitignored
- `devmap mcp` owns stdout: it is the JSON-RPC stream. Never `console.log` from code the MCP server can reach; use `output.*` inside `withJsonOutput()` (silent) or stderr
- The MCP server is read-only and static by default. Only `devmap_analyze` / `--auto-refresh` write, and only inside `.devmap/`. AI is reachable only with `--allow-ai`; `devmap_explain` is registered only then
Expand All @@ -189,7 +190,6 @@ MCP server (after `pnpm build:cli`): `node packages/cli/test/mcp-smoke.mjs`.
- Elysia, Koa and Adonis detectors follow literal call chains and mounts; routers built inside factory functions, `.use()` of computed values and runtime-assembled paths are not followed. Angular reads literal `Routes` arrays and `loadChildren` imports
- Feature similarity threshold (0.35) is tuned but may cause false merges
- Domain inference is single-shot — no conversation context
- `.devmap/analyze.lock` is taken by MCP-driven analysis only; `devmap analyze` does not take or check it
- The MCP staleness check re-scans project files (cached for 10 s), so it is not free on very large repositories

## Engineering Notes
Expand Down
4 changes: 2 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -564,8 +564,8 @@ Consequences for the architecture:
snapshot can be refreshed on request (`devmap_analyze`) or automatically
(`--auto-refresh`).
- **Concurrency now exists.** Two agents, or an agent and a terminal, can ask for
analysis at once. In-process calls are single-flight; MCP-driven analysis also
takes `.devmap/analyze.lock`. The CLI `analyze` does not take the lock yet.
analysis at once. In-process calls are single-flight; MCP-driven analysis and
the CLI `analyze` both take `.devmap/analyze.lock`.

Deferred: a remote (HTTP) transport, serving several projects from one server, and
watching files for changes instead of checking staleness per request.
Expand Down
4 changes: 2 additions & 2 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -482,8 +482,8 @@ Cursor, opencode and Codex.
* Static by default: no AI provider is contacted, and the AI config is not read,
unless `--allow-ai` is set
* `--auto-refresh` is off by default: a question never writes to disk
* Analysis takes `.devmap/analyze.lock` so two servers do not write the snapshot
at once
* Analysis takes `.devmap/analyze.lock` (as does `devmap analyze`) so two analyses
do not write the snapshot at once
* `devmap mcp` has no `--json` flag; its output is the protocol

---
Expand Down
6 changes: 3 additions & 3 deletions docs/generated-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,16 +240,16 @@ Purpose:

## .devmap/analyze.lock

Created while `devmap mcp` runs an analysis (`devmap_analyze` or
`--auto-refresh`) and removed when it finishes.
Created while an analysis runs, either `devmap analyze` or the MCP server
(`devmap_analyze`, `--auto-refresh`), and removed when it finishes.

```json
{ "pid": 12345, "startedAt": "2026-10-30T02:11:09.000Z" }
```

Purpose:

- Stop two MCP servers (or editor windows) from writing the snapshot at once
- Stop two analyses (a terminal `devmap analyze`, or MCP servers in different editor windows) from writing the snapshot at once

A lock whose process is gone, or that is older than 15 minutes, is taken over
automatically. If no analysis is running and the file is still there, it is safe
Expand Down
7 changes: 5 additions & 2 deletions docs/guide/commands/09-mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,11 @@ Before taking the lock, `run()` checks staleness. When nothing changed it return
`unchanged` without touching the lock or the analyzer, so a call that has nothing
to do is also never blocked by another process's analysis.

`devmap analyze` in the terminal does **not** take this lock. The window for a
collision is small, but it exists; if it matters, `runAnalyze` is the place to add it.
`devmap analyze` in the terminal takes the same lock (`analyzeCommand` wraps
`runAnalyze`). `runAnalyze` itself does not, because the refresher already holds
the lock when it calls it. If a terminal analysis is running, `devmap_analyze`
returns the "already running" error, and `devmap analyze` fails the same way
while the server is refreshing.

### `aiDataDropped`

Expand Down
4 changes: 2 additions & 2 deletions docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -330,8 +330,8 @@ There are three ways to refresh, from most to least hands-on:

Without `--auto-refresh`, a question never writes to disk.

Analysis takes a lock (`.devmap/analyze.lock`) so two servers or editor windows
cannot write the snapshot at once. A lock left by a dead process, or older than
Analysis takes a lock (`.devmap/analyze.lock`) so two servers, editor windows or a
terminal `devmap analyze` cannot write the snapshot at once. A lock left by a dead process, or older than
15 minutes, is taken over. If another analysis is running, `devmap_analyze`
returns an error telling the agent to retry.

Expand Down
77 changes: 77 additions & 0 deletions packages/cli/src/cache/analysisLock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { mkdir, open, readFile, rm } from "node:fs/promises";
import { join } from "node:path";
import { DevmapError } from "../utils/errors.js";

/** A lock older than this is considered abandoned, even if its pid is still alive. */
export const LOCK_STALE_MS = 15 * 60 * 1000;

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<LockInfo | undefined> {
try {
const parsed = JSON.parse(await readFile(path, "utf8")) as Partial<LockInfo>;
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 analyses (`devmap analyze`,
* or the MCP server of 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<void>> {
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."
);
}
27 changes: 25 additions & 2 deletions packages/cli/src/commands/analyze.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { stat } from "node:fs/promises";
import { resolve } from "node:path";
import { completeWithOptionalStreaming } from "../ai/completion.js";
import {
Expand All @@ -8,6 +9,7 @@ import { buildAnalyzeMessages } from "../ai/prompts.js";
import { enrichSnapshotWithAi } from "../ai/snapshotEnrichment.js";
import type { AiClient } from "../ai/types.js";
import { createProjectMap } from "../analyzers/pipeline/index.js";
import { acquireLock } from "../cache/analysisLock.js";
import { inspectSnapshot, saveSnapshot } from "../cache/snapshot.js";
import { writeAgentNavigationFiles } from "../cache/agentNavigation.js";
import { resolveEffectiveConfig, type DevmapConfig } from "../utils/config.js";
Expand Down Expand Up @@ -38,13 +40,34 @@ export async function analyzeCommand(
): Promise<void> {
if (options.json) {
await withJsonOutput(async () => {
const snapshot = await runAnalyze(target, options, dependencies);
const snapshot = await withAnalysisLock(target, () => runAnalyze(target, options, dependencies));
output.json(snapshot);
});
return;
}

await runAnalyze(target, options, dependencies);
await withAnalysisLock(target, () => runAnalyze(target, options, dependencies));
}

/**
* Hold `.devmap/analyze.lock` while the CLI analyzes, so it never writes the
* snapshot at the same time as another `devmap analyze` or an MCP server's
* refresh. Only `analyzeCommand` takes it: `runAnalyze` is also called by the MCP
* refresher, which holds the same lock itself.
*/
async function withAnalysisLock<T>(target: string, run: () => Promise<T>): Promise<T> {
const projectRoot = resolve(target);
const isDirectory = await stat(projectRoot).then((info) => info.isDirectory(), () => false);
// A missing target keeps failing in the analyzer with its own message
// instead of getting a stray .devmap folder from the lock.
if (!isDirectory) return run();

const release = await acquireLock(projectRoot);
try {
return await run();
} finally {
await release();
}
}

/**
Expand Down
81 changes: 3 additions & 78 deletions packages/cli/src/mcp/refresh.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
import { mkdir, open, readFile, rm } from "node:fs/promises";
import { join } from "node:path";
import type { ProjectMap } from "../analyzers/pipeline/index.js";
import { acquireLock, LOCK_STALE_MS } from "../cache/analysisLock.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;
// The lock itself lives in cache/analysisLock.ts so `devmap analyze` can share it.
export { acquireLock, LOCK_STALE_MS };

export type AnalyzeFn = (
projectRoot: string,
Expand Down Expand Up @@ -109,76 +107,3 @@ export function createRefresher(options: RefresherOptions): Refresher {
}
};
}

// ── 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<LockInfo | undefined> {
try {
const parsed = JSON.parse(await readFile(path, "utf8")) as Partial<LockInfo>;
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<void>> {
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."
);
}
Loading
Loading