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
10 changes: 8 additions & 2 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/ ← 49 test files (`*.test.ts`, auto-discovered by `run-tests.ts`) + `mcp-smoke.mjs`, `package-e2e.mjs`
│ ├── test/ ← 51 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 @@ -129,11 +129,12 @@ Order for a non-relative specifier: `#subpath` (package.json `imports`) → tsco

## Testing

49 test files in `packages/cli/test/`. Key covered areas:
51 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
- Analyzer stability and grounding (`analyzer-stability`: linear time on generated files, quote-free text, long identifiers, size cap, symlinks, locale-free ordering; `analyzer-grounding`: language, devDependency frameworks, workspaces, entry points, search terms, duplicate feature names, domain-inference validation)
- 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 +175,8 @@ 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)
- A file over `MAX_READ_BYTES` (5 MB) is listed with `oversize: true` and no content, and `warnings` names it; its fingerprint input is its size. Symlinks that resolve outside the project root are skipped
- Text is ordered with `compareText` (`utils/compare.ts`), never `String#localeCompare`, which follows `LANG` and changes snapshots between machines; a test fails on any use
- `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
Expand All @@ -189,6 +192,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
- 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 @@ -204,6 +208,8 @@ MCP server (after `pnpm build:cli`): `node packages/cli/test/mcp-smoke.mjs`.
- `.js` → `.ts` extension swap in import resolution (shared in `graph/localPaths.ts`)
- Database detection ranks ORM/BaaS above drivers; a cache (Redis) is only ever listed as `additional`
- Singularize entity names for feature matching
- Never use `getStartLineNumber()` in a loop over a file's nodes: ts-morph recounts newlines from the top each call. Use `LineIndex` (`analysis/lineIndex.ts`)
- Regexes that read `import`/`export ... from` take their clause from `IMPORT_CLAUSE` (`analysis/importClause.ts`); an unbounded `[^'"]+` rescans a quote-free file from every `export`
- REASON_TAGS constant for critical file ranking reasoning
- 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
Expand Down
5 changes: 3 additions & 2 deletions packages/cli/src/ai/contextBuilder.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { readFile, realpath } from "node:fs/promises";
import { isAbsolute, relative, resolve } from "node:path";
import type { ProjectMap } from "../analyzers/pipeline/index.js";
import { compareText } from "../utils/compare.js";

const DEFAULT_MAX_FILES = 5;
const DEFAULT_MAX_LINES_PER_FILE = 200;
Expand Down Expand Up @@ -445,7 +446,7 @@ function rankContextFiles(
.sort((left, right) =>
right.score - left.score
|| Number(right.direct) - Number(left.direct)
|| left.path.localeCompare(right.path)
|| compareText(left.path, right.path)
);
}

Expand Down Expand Up @@ -918,7 +919,7 @@ function tokenizeQuestion(value: string): string[] {
function splitSearchTerms(value: string): Set<string> {
const spaced = value
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2");
.replace(/(?<=[A-Z])(?=[A-Z][a-z])/g, " ");

return new Set(tokenizeQuestion(spaced));
}
3 changes: 2 additions & 1 deletion packages/cli/src/ai/custom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
parseSseStream,
readCompletionPayload
} from "./openaiCompatibleStream.js";
import { compareText } from "../utils/compare.js";

const CUSTOM_PAYLOAD_MESSAGES = {
unreadable: () => new DevmapError(
Expand Down Expand Up @@ -220,7 +221,7 @@ async function readModelIds(response: Response): Promise<string[]> {
return (payload.data ?? [])
.map((model) => model.id)
.filter((id): id is string => typeof id === "string")
.sort((a, b) => a.localeCompare(b));
.sort((a, b) => compareText(a, b));
} catch {
throw new DevmapError(
"The custom endpoint returned an unreadable model list.",
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/ai/groq.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
type PayloadUnreadableMessages,
type StreamInterruptedMessages
} from "./openaiCompatibleStream.js";
import { compareText } from "../utils/compare.js";

const GROQ_MODELS_URL = "https://api.groq.com/openai/v1/models";
const GROQ_CHAT_COMPLETIONS_URL = "https://api.groq.com/openai/v1/chat/completions";
Expand Down Expand Up @@ -446,7 +447,7 @@ async function readModelIds(response: Response): Promise<string[]> {
if (aIndex !== -1 && bIndex !== -1) return aIndex - bIndex;
if (aIndex !== -1) return -1;
if (bIndex !== -1) return 1;
return a.localeCompare(b);
return compareText(a, b);
});
} catch {
throw new DevmapError(
Expand Down
46 changes: 39 additions & 7 deletions packages/cli/src/analyzers/analysis/fileScanner.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type { Dirent, Stats } from "node:fs";
import { readdir, readFile, stat } from "node:fs/promises";
import { extname, join, relative } from "node:path";
import { readdir, readFile, realpath, stat } from "node:fs/promises";
import { extname, join, relative, sep } from "node:path";
import pLimit from "p-limit";
import { clearGitignoreCache, shouldIgnorePath } from "./filterEngine.js";
import { compareText } from "../../utils/compare.js";

export type ScannedFile = {
path: string;
Expand All @@ -11,11 +12,21 @@ export type ScannedFile = {
size: number;
lines: number;
content: string;
/** The file is larger than MAX_READ_BYTES: it is listed, but its content was not read. */
oversize?: boolean;
};

/**
* Past this size a text file is a dataset, a dump or a generated bundle, and
* reading it whole is what exhausts the heap: six 400 MB CSV files in one
* project crashed the process before any analysis started.
*/
export const MAX_READ_BYTES = 5 * 1024 * 1024;

export async function scanFiles(projectRoot: string): Promise<ScannedFile[]> {
const files: ScannedFile[] = [];
const limit = pLimit(50);
const realRoot = await realpath(projectRoot).catch(() => projectRoot);

// Ignore files are cached per process; re-read them for every scan.
clearGitignoreCache();
Expand All @@ -34,7 +45,7 @@ export async function scanFiles(projectRoot: string): Promise<ScannedFile[]> {
if (isRoot) throw error;
return;
}
entries.sort((a, b) => a.name.localeCompare(b.name));
entries.sort((a, b) => compareText(a.name, b.name));

const tasks: Promise<void>[] = [];

Expand Down Expand Up @@ -62,16 +73,20 @@ export async function scanFiles(projectRoot: string): Promise<ScannedFile[]> {
return;
}
if (!metadata.isFile()) return;
// A link that leaves the project would put someone else's file in the map.
if (entry.isSymbolicLink() && !(await resolvesInside(absolutePath, realRoot))) return;

const content = await readFile(absolutePath, "utf8").catch(() => "");
const oversize = metadata.size > MAX_READ_BYTES;
const content = oversize ? "" : await readFile(absolutePath, "utf8").catch(() => "");

files.push({
path: relativePath,
absolutePath,
extension: extname(entry.name).toLowerCase(),
size: metadata.size,
lines: content.length === 0 ? 0 : content.split(/\r?\n/).length,
content
lines: countLines(content),
content,
...(oversize ? { oversize: true } : {})
});
}));
}
Expand All @@ -85,5 +100,22 @@ export async function scanFiles(projectRoot: string): Promise<ScannedFile[]> {
// I/O completion timing, not directory structure. Sort here so every
// downstream consumer (fileIndex ordering, onboarding path ties, etc.)
// gets a stable, reproducible order across runs on an unchanged project.
return files.sort((a, b) => a.path.localeCompare(b.path));
return files.sort((a, b) => compareText(a.path, b.path));
}

async function resolvesInside(path: string, realRoot: string): Promise<boolean> {
try {
const target = await realpath(path);
return target === realRoot || target.startsWith(realRoot + sep);
} catch {
return false;
}
}

// Same count as content.split(/\r?\n/).length without allocating a string per line.
function countLines(content: string): number {
if (content.length === 0) return 0;
let lines = 1;
for (let at = content.indexOf("\n"); at !== -1; at = content.indexOf("\n", at + 1)) lines += 1;
return lines;
}
8 changes: 8 additions & 0 deletions packages/cli/src/analyzers/analysis/importClause.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/**
* What may sit between `import`/`export` and `from`: names, braces, commas, `*`,
* `as` and comments, never a quote, `;`, `=`, `(`, `)` or `:`. Stopping there and
* capping the length keeps a quote-free file (generated types, no-semicolon
* style) from rescanning to the end of the text at every `export`; the unbounded
* `[^'"]+` it replaces made a file of 12,000 exported functions cost 13 seconds.
*/
export const IMPORT_CLAUSE = String.raw`[^'";:=()]{1,4000}`;
1 change: 1 addition & 0 deletions packages/cli/src/analyzers/analysis/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export {
scanFiles,
MAX_READ_BYTES,
} from "./fileScanner.js";
export type {
ScannedFile,
Expand Down
26 changes: 26 additions & 0 deletions packages/cli/src/analyzers/analysis/lineIndex.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* Line numbers for many nodes of one file. ts-morph's `getStartLineNumber()`
* recounts newlines from the top of the text on every call, so a generated file
* with n declarations costs O(n * size) and a 4 MB file runs for minutes.
*/
export class LineIndex {
private readonly breaks: number[] = [];

constructor(text: string) {
for (let at = text.indexOf("\n"); at !== -1; at = text.indexOf("\n", at + 1)) {
this.breaks.push(at);
}
}

/** 1-based line of the character at `position`: one more than the newlines before it. */
lineAt(position: number): number {
let low = 0;
let high = this.breaks.length;
while (low < high) {
const middle = (low + high) >>> 1;
if (this.breaks[middle] < position) low = middle + 1;
else high = middle;
}
return low + 1;
}
}
40 changes: 24 additions & 16 deletions packages/cli/src/analyzers/analysis/tsMorphAnalyzer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ import type {
SymbolInfo
} from "./fileAnalysis.js";
import type { ScannedFile } from "./fileScanner.js";
import { LineIndex } from "./lineIndex.js";
import { AstroPreprocessor, SveltePreprocessor, VuePreprocessor } from "./preprocessors/index.js";
import type { LanguagePreprocessor } from "./preprocessors/index.js";
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"]);
Expand Down Expand Up @@ -83,6 +85,8 @@ export class TsMorphAnalyzer implements FileAnalyzer {
_originalPath: string
): FileAnalysis {
const sourceFile = this.project.createSourceFile(parsePath, content, { overwrite: true });
const lines = new LineIndex(sourceFile.getFullText());
const lineOf = (node: Node) => lines.lineAt(node.getStart());

// --- Imports ---
const imports = new Set<string>();
Expand Down Expand Up @@ -122,7 +126,7 @@ export class TsMorphAnalyzer implements FileAnalyzer {
// --- Exports ---
// `module.exports = { a, b }` and `exports.c = ...` are what most Node
// packages and older JS codebases export; the ESM view above sees none of it.
const commonJs = readCommonJsExports(sourceFile);
const commonJs = readCommonJsExports(sourceFile, lineOf);
const exports = [...new Set([
...sourceFile.getExportedDeclarations().keys(),
...commonJs.map((entry) => entry.name)
Expand All @@ -138,7 +142,7 @@ export class TsMorphAnalyzer implements FileAnalyzer {
symbols.push({
name: statement.getNameOrThrow(),
kind: "function",
line: statement.getStartLineNumber(),
line: lineOf(statement),
exported: statement.isExported() || statement.isDefaultExport(),
async: statement.isAsync()
});
Expand All @@ -150,15 +154,15 @@ export class TsMorphAnalyzer implements FileAnalyzer {
symbols.push({
name: statement.getNameOrThrow(),
kind: "class",
line: statement.getStartLineNumber(),
line: lineOf(statement),
exported: classExported
});
for (const method of statement.getMethods()) {
if (method.isOverload()) continue;
symbols.push({
name: method.getName(),
kind: "method",
line: method.getStartLineNumber(),
line: lineOf(method),
exported: false,
async: method.isAsync()
});
Expand All @@ -167,17 +171,17 @@ export class TsMorphAnalyzer implements FileAnalyzer {
}

if (Node.isInterfaceDeclaration(statement)) {
symbols.push(createDeclarationSymbol(statement, "interface"));
symbols.push(createDeclarationSymbol(statement, "interface", lineOf(statement)));
continue;
}

if (Node.isTypeAliasDeclaration(statement)) {
symbols.push(createDeclarationSymbol(statement, "type"));
symbols.push(createDeclarationSymbol(statement, "type", lineOf(statement)));
continue;
}

if (Node.isEnumDeclaration(statement)) {
symbols.push(createDeclarationSymbol(statement, "enum"));
symbols.push(createDeclarationSymbol(statement, "enum", lineOf(statement)));
continue;
}

Expand All @@ -197,7 +201,7 @@ export class TsMorphAnalyzer implements FileAnalyzer {
symbols.push({
name,
kind: "const",
line: declaration.getStartLineNumber(),
line: lineOf(declaration),
exported: statement.isExported(),
async: isAsyncFunction
});
Expand All @@ -206,8 +210,12 @@ export class TsMorphAnalyzer implements FileAnalyzer {
}
}

const symbolsByName = new Map<string, SymbolInfo>();
for (const symbol of symbols) {
if (!symbolsByName.has(symbol.name)) symbolsByName.set(symbol.name, symbol);
}
for (const entry of commonJs) {
const existing = symbols.find((symbol) => symbol.name === (entry.alias ?? entry.name));
const existing = symbolsByName.get(entry.alias ?? entry.name);
if (existing) {
existing.exported = true;
} else if (entry.name !== "default") {
Expand All @@ -220,7 +228,7 @@ export class TsMorphAnalyzer implements FileAnalyzer {
});
}
}
symbols.sort((left, right) => left.line - right.line || left.name.localeCompare(right.name));
symbols.sort((left, right) => left.line - right.line || compareText(left.name, right.name));

// --- Top functions ---
const topFunctions = symbols
Expand Down Expand Up @@ -276,7 +284,7 @@ interface CommonJsExport {
* Top-level `module.exports = ...`, `module.exports.x = ...` and `exports.x = ...`.
* An object literal contributes its keys; anything else is the default export.
*/
function readCommonJsExports(sourceFile: SourceFile): CommonJsExport[] {
function readCommonJsExports(sourceFile: SourceFile, lineOf: (node: Node) => number): CommonJsExport[] {
const found: CommonJsExport[] = [];
const seen = new Set<string>();

Expand All @@ -290,7 +298,7 @@ function readCommonJsExports(sourceFile: SourceFile): CommonJsExport[] {
name,
...(name === "default" && value && Node.isIdentifier(value) ? { alias: value.getText() } : {}),
kind: callable || (value && Node.isClassExpression(value)) ? "function" : "const",
line: node.getStartLineNumber(),
line: lineOf(node),
async: Boolean(callable && (value as { isAsync(): boolean }).isAsync())
});
};
Expand Down Expand Up @@ -368,22 +376,22 @@ function emptyAnalysis(
function createDeclarationSymbol(
declaration: {
getName(): string;
getStartLineNumber(): number;
isExported(): boolean;
isDefaultExport(): boolean;
},
kind: "interface" | "type" | "enum"
kind: "interface" | "type" | "enum",
line: number
): SymbolInfo {
return {
name: declaration.getName(),
kind,
line: declaration.getStartLineNumber(),
line,
exported: declaration.isExported() || declaration.isDefaultExport()
};
}

function compareFunctions(left: FunctionInfo, right: FunctionInfo): number {
return Number(right.exported) - Number(left.exported)
|| left.line - right.line
|| left.name.localeCompare(right.name);
|| compareText(left.name, right.name);
}
Loading
Loading