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
29 changes: 17 additions & 12 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/ ← 53 test files (`*.test.ts`, auto-discovered by `run-tests.ts`) + `mcp-smoke.mjs`, `package-e2e.mjs`
│ ├── test/ ← 54 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 @@ -86,7 +86,7 @@ Everything else (files, graph, routes, features, flows, critical files) is 100%
version, generatedAt, fingerprint, projectRoot, framework,
project(ProjectMetadata), stats, entryPoints, criticalFiles,
routes, apiRoutes, externalServices, database?,
features, entityGraph?, capabilities?, domain?,
features, entityGraph?, capabilities?, domain?, ownership?,
flows, onboarding, changeImpact, warnings?,
dependencies, fileGraph(imports), ai?, fileIndex
```
Expand Down Expand Up @@ -129,7 +129,7 @@ Order for a non-relative specifier: `#subpath` (package.json `imports`) → tsco

## Testing

53 test files in `packages/cli/test/`. Key covered areas:
54 test files in `packages/cli/test/`. Key covered areas:
- Analyzers, AI clients, agent navigation, entity extractors (Prisma, Drizzle, SQL, route hints; `capability-grounding` covers resource naming, read-only stats/report endpoints and the Payments signal)
- 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
Expand All @@ -153,25 +153,22 @@ cd packages/cli && pnpm build
# Test
cd packages/cli && pnpm test

# Lint
pnpm lint

# Typecheck
pnpm typecheck
# Typecheck (also part of `pnpm test`)
cd packages/cli && pnpm test:types

# Dev mode
pnpm dev
pnpm dev:cli

# Run CLI locally
pnpm devmap <command>
node packages/cli/dist/index.js <command>
```

MCP server (after `pnpm build:cli`): `node packages/cli/test/mcp-smoke.mjs`.

## Important Constraints

- No source files should be modified during reverse-engineering tasks
- AI errors are always non-fatal — `devmap analyze` completes even if AI fails
- AI errors are always non-fatal — `devmap analyze` completes even if AI fails. Every chat request is aborted after `DEVMAP_AI_TIMEOUT_MS` (default 120 s) so an endpoint that never answers cannot block `explain`, `flow` or the MCP server
- 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)
Expand All @@ -184,6 +181,8 @@ MCP server (after `pnpm build:cli`): `node packages/cli/test/mcp-smoke.mjs`.

## Known Limitations

- A file with a line over 50,000 characters (a one-line bundle) is listed with low confidence and no symbols or imports: ts-morph's comment scan is quadratic on such a line

- In-memory graph, rebuilt fully on every analyze run — no incremental analysis
- Regex-based heuristic analyzer is explicitly TODO'd toward tree-sitter
- No AST-level analysis for non-JS/TS languages; Go/Java/C#/PHP-namespace imports produce no graph edges yet
Expand All @@ -192,7 +191,7 @@ MCP server (after `pnpm build:cli`): `node packages/cli/test/mcp-smoke.mjs`.
- Client router detection reads literal route arrays, JSX trees and `getParentRoute` chains; routes assembled by loops, spreads of computed values or runtime config are not followed
- 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
- Feature `confidence` measures parse quality, not evidence strength: `projectMap.ts` lifts any feature with two files, one parsed by ts-morph, to `high`, so a folder named `search/` reads as `high` like an imported vendor package does
- Feature `confidence` measures parse quality and evidence kind, not product importance: a vocabulary signal that only a file name or local import supports is reported at `medium` at most unless a route is served from its files (an imported vendor package keeps it `high`), but a library's own runtime folder named `cache/` is still listed as a `Caching` feature. The registry does not separate vendor terms from generic words (`vitest` next to `spec`), so a package named by a generic word still counts
- Domain inference is single-shot — no conversation context
- The MCP staleness check re-scans project files (cached for 10 s), so it is not free on very large repositories

Expand All @@ -214,5 +213,11 @@ MCP server (after `pnpm build:cli`): `node packages/cli/test/mcp-smoke.mjs`.
- Critical-file execution bonus (`projectMap.ts`) is name-based (router, controller, orchestrator, engine, dispatcher, scheduler, pipeline, registry, kernel, `commands/`) or fan-out-based (≥ 8 distinct local imports in a non-index file) — never tied to devmap's own file names
- Angular, Elysia, Koa and Adonis detectors share `moduleBindings.ts` (`createModuleIndex`): imports/`require`, exports and re-exports, and `stringOf` (literals, templates, `+`, constants, `as const` objects and enums across files). Instances (Elysia/Koa) are keyed by `new` expression and only unmounted ones are walked from the top, like Hono
- Feature merging (`featureCandidates.ts`): two page candidates never merge by shared file, a page never merges with a vocabulary signal unless they share the exact name, a `standalone` candidate (Home) is never merged, and a bridge candidate cannot fuse two different pages or entities. Weak signal words live in `genericTerms`; package-only words in `importTerms`; `excludePaths` removes false paths per signal
- Feature confidence has two kinds of evidence: parse quality (`calculateFeatureConfidence`, the `projectMap.ts` lift) and, for registry vocabulary signals, whether an imported npm package names the signal (`hasPackageEvidence`, `namesPackage`: a scoped package counts through its scope, `@playwright/test`, never through the name behind it, `@standard-schema/spec`). Signals without that evidence carry `nameOnly` and `detectFeatures` returns them in `nameOnlyFeatures`; `attachFeatureEntryPoints` caps them at `medium` unless a route is served from their files. An entry point is no proof, since any file nothing imports is one
- `featureStem` aliases `doc` to `documentation` (a `/docs` page and the Documentation role are one feature, kept under the concept's name)
- `project.frameworks` is the winner per category (frontend, backend) and gates route detection; `project.detectedFrameworks` lists every framework with signals (a Next.js + Expo + TanStack Start monorepo names all three; React inside Next.js is folded away) and is what `doctor`, `analyze` and `.devmap/index.json` show. Read it through `allFrameworks()`, since older snapshots lack it
- `readWorkspacePatterns` ignores patterns that name the root (`packages: ["."]`), so they do not make a monorepo
- A file TypeScript had to recover from (`parseDiagnostics`, internal to TypeScript and guarded) is `medium`, not `high`; a template-only `.vue`/`.svelte`/`.astro` file is `medium` too
- AI domain inference is grounded (`inference/domainGrounding.ts`): a domain that no entity, route or feature in the project supports is ignored with a warning, and features whose only related entities are generic (User, Account, Session) are dropped. `ownership` in the snapshot is the deterministic topology; "Personal app" wording needs `single_user_isolated`, which is inferred from missing cross-user evidence and is therefore weak
- `nonResourceSegments.ts` lists operational URL segments (health, revalidate, robots.txt, login ...) that never become resources or "Management" features
- Client-side routes (`clientRouteDetector.ts`) are self-gated by router imports/types and run for every project, like the other self-gating detectors; Hono factory functions are modeled as one app per call
10 changes: 9 additions & 1 deletion packages/cli/src/ai/custom.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { DevmapError } from "../utils/errors.js";
import { aiRequestSignal, isRequestTimeout } from "./requestTimeout.js";
import type {
AiClient,
AiCompletionRequest,
Expand Down Expand Up @@ -124,6 +125,7 @@ export class CustomProviderClient implements AiClient {
Authorization: `Bearer ${this.apiKey}`,
"Content-Type": "application/json"
},
signal: aiRequestSignal(),
body: JSON.stringify({
model: request.model,
messages: request.messages,
Expand All @@ -135,7 +137,13 @@ export class CustomProviderClient implements AiClient {
} : {})
})
});
} catch {
} catch (error) {
if (isRequestTimeout(error)) {
throw new DevmapError(
"The custom endpoint did not answer in time.",
"Try again, or raise DEVMAP_AI_TIMEOUT_MS (milliseconds)."
);
}
throw unreachableEndpointError();
}
}
Expand Down
10 changes: 9 additions & 1 deletion packages/cli/src/ai/groq.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { DevmapError } from "../utils/errors.js";
import { aiRequestSignal, isRequestTimeout } from "./requestTimeout.js";
import type {
AiClient,
AiCompletionRequest,
Expand Down Expand Up @@ -239,6 +240,7 @@ export class GroqClient implements AiClient {
Authorization: `Bearer ${this.apiKey}`,
"Content-Type": "application/json"
},
signal: aiRequestSignal(),
body: JSON.stringify({
model,
messages: request.messages,
Expand All @@ -250,7 +252,13 @@ export class GroqClient implements AiClient {
} : {})
})
});
} catch {
} catch (error) {
if (isRequestTimeout(error)) {
throw new DevmapError(
"Groq did not answer in time.",
"Try again, or raise DEVMAP_AI_TIMEOUT_MS (milliseconds)."
);
}
throw new DevmapError(
"Could not connect to Groq.",
"Check your internet connection and run devmap doctor."
Expand Down
10 changes: 9 additions & 1 deletion packages/cli/src/ai/openrouter.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { DevmapError } from "../utils/errors.js";
import { aiRequestSignal, isRequestTimeout } from "./requestTimeout.js";
import type {
AiClient,
AiCompletionRequest,
Expand Down Expand Up @@ -113,6 +114,7 @@ export class OpenRouterClient implements AiClient {
"HTTP-Referer": "https://github.com/itsflaid/devmap",
"X-OpenRouter-Title": "DevMap"
},
signal: aiRequestSignal(),
body: JSON.stringify({
...(models.length > 1 ? { models } : { model: models[0] }),
messages: request.messages,
Expand All @@ -121,7 +123,13 @@ export class OpenRouterClient implements AiClient {
...(stream ? { stream: true } : {})
})
});
} catch {
} catch (error) {
if (isRequestTimeout(error)) {
throw new DevmapError(
"OpenRouter did not answer in time.",
"Try again, or raise DEVMAP_AI_TIMEOUT_MS (milliseconds)."
);
}
throw new DevmapError(
"Could not connect to OpenRouter.",
"Check your internet connection and run devmap doctor."
Expand Down
15 changes: 15 additions & 0 deletions packages/cli/src/ai/requestTimeout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
const DEFAULT_AI_TIMEOUT_MS = 120_000;

/**
* Abort signal for one chat request. A provider that accepts the connection and
* never answers would otherwise block `explain`, `flow` and the MCP server forever.
* `DEVMAP_AI_TIMEOUT_MS` overrides the default.
*/
export function aiRequestSignal(): AbortSignal {
const configured = Number(process.env.DEVMAP_AI_TIMEOUT_MS);
return AbortSignal.timeout(Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_AI_TIMEOUT_MS);
}

export function isRequestTimeout(error: unknown): boolean {
return error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError");
}
6 changes: 6 additions & 0 deletions packages/cli/src/analyzers/analysis/filterEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,12 @@ export function shouldIgnorePath(p: string, isDirectory: boolean, projectRoot?:
return true;
}

// `devmap onboarding --write` creates this file after the snapshot, so counting it
// would report every project as stale right after the guide is written.
if (!isDirectory && lowerPath === "onboarding.md") {
return true;
}

if (!isDirectory && [...IGNORED_EXTENSIONS].some((ext) => lowerPath.endsWith(ext))) {
return true;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,37 @@
import type { ExtractedScript, LanguagePreprocessor } from "./types.js";

function countNewlines(text: string): number {
let count = 0;
for (let index = text.indexOf("\n"); index !== -1; index = text.indexOf("\n", index + 1)) count++;
return count;
}

/**
* An SFC may carry `<script>` (module-level exports) next to `<script setup>`.
* Both feed one virtual module; newlines keep every block on its original line.
*/
function mergeScriptBlocks(content: string, blocks: RegExpMatchArray[]): ExtractedScript {
let code = "";
let cursorLine = 0;
let isTypeScript = false;

for (const block of blocks) {
const body = block[2];
const contentStart = (block.index ?? 0) + block[0].indexOf(body);
const startLine = countNewlines(content.slice(0, contentStart));
code += "\n".repeat(Math.max(0, startLine - cursorLine)) + body;
cursorLine = startLine + countNewlines(body);
if (/\blang\s*=\s*["']tsx?["']/.test(block[1] ?? "")) isTypeScript = true;
}

return {
code,
language: isTypeScript ? "ts" : "js",
lineOffset: 0,
virtualExtension: isTypeScript ? ".tsx" : ".jsx"
};
}

/**
* VuePreprocessor — extracts <script> or <script setup> from Vue SFCs.
*
Expand All @@ -21,6 +53,10 @@ export class VuePreprocessor implements LanguagePreprocessor {
extract(content: string, _filePath: string): ExtractedScript | null {
// Match <script> or <script setup> with optional attributes.
// Non-greedy [\s\S]*? ensures we stop at the first </script>.
const blocks = [...content.matchAll(/<script(\s[^>]*)?\s*>([\s\S]*?)<\/script>/gi)]
.filter((block) => block[2].trim());
if (blocks.length > 1) return mergeScriptBlocks(content, blocks);

const match = content.match(/<script(\s[^>]*)?\s*>([\s\S]*?)<\/script>/i);
if (!match) return null;

Expand Down
31 changes: 30 additions & 1 deletion packages/cli/src/analyzers/analysis/tsMorphAnalyzer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,32 @@ import { compareText } from "../../utils/compare.js";
// Native extensions — ts-morph parses these directly, no preprocessing needed.
const NATIVE_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx"]);

// ts-morph rescans a line for comments once per statement, so a bundle written on a
// single line (a 1.7 MB `vendor.js`) takes minutes. Real source never has such a line.
const MAX_LINE_LENGTH = 50_000;

/**
* Whether TypeScript recovered from a syntax error while parsing. The tree of such a file is
* partial, so its imports and symbols may be incomplete and it must not read as a clean parse.
* `parseDiagnostics` is internal to TypeScript; should a release drop it, every file reads as clean.
*/
function hasSyntaxErrors(sourceFile: SourceFile): boolean {
const diagnostics = (sourceFile.compilerNode as unknown as { parseDiagnostics?: unknown }).parseDiagnostics;
return Array.isArray(diagnostics) && diagnostics.length > 0;
}

function hasOversizedLine(content: string): boolean {
if (content.length <= MAX_LINE_LENGTH) return false;
let start = 0;
while (start < content.length) {
const newline = content.indexOf("\n", start);
const end = newline === -1 ? content.length : newline;
if (end - start > MAX_LINE_LENGTH) return true;
start = end + 1;
}
return false;
}

// Preprocessors handle files that contain embedded JS/TS inside a larger format.
// Order matters only for logging — each preprocessor's extensions are disjoint.
const PREPROCESSORS: LanguagePreprocessor[] = [
Expand Down Expand Up @@ -84,7 +110,10 @@ export class TsMorphAnalyzer implements FileAnalyzer {
content: string,
_originalPath: string
): FileAnalysis {
if (hasOversizedLine(content)) return emptyAnalysis(this.id, "low");

const sourceFile = this.project.createSourceFile(parsePath, content, { overwrite: true });
const confidence: FileAnalysis["confidence"] = hasSyntaxErrors(sourceFile) ? "medium" : "high";
const lines = new LineIndex(sourceFile.getFullText());
const lineOf = (node: Node) => lines.lineAt(node.getStart());

Expand Down Expand Up @@ -249,7 +278,7 @@ export class TsMorphAnalyzer implements FileAnalyzer {

return {
analyzer: this.id,
confidence: "high",
confidence,
imports: [...imports],
exports,
symbols,
Expand Down
Loading
Loading