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
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,40 @@ All notable changes to DevMap are documented in this file.

## [Unreleased]

### Added

- JS/TS monorepos: imports of workspace packages by name (`@acme/ui`,
`@acme/ui/button`) now become dependency edges, resolved through the
package's `exports` (conditions and `*` patterns), `source`/`module`/`main`
and file layout; built output (`dist/`, `lib/`) is mapped back to `src/`
- package.json `imports` (`#internal/*`) subpath imports resolve inside the
nearest package; bundler query suffixes (`./x.ts?raw`) are ignored
- Nuxt: `~`, `@`, `~~`, `@@`, `#shared`, `#server` aliases (Nuxt 3 root layout,
Nuxt 4 `app/`, custom `srcDir`) and auto-import edges for components,
composables, utils, `shared/`, `server/utils/` and Pinia stores
- Entry points are read from package.json (`bin`, published `main`, script
targets) and framework conventions (Nuxt `app.vue`/`pages`/`server/api`,
SvelteKit `+page`/`+server`, Remix `root`/`entry.*`, Next Pages Router)
- CommonJS exports (`module.exports = { ... }`, `exports.x = ...`) are read as
exports and symbols
- Database detection recognizes TypeORM, Sequelize, MikroORM, Kysely, Knex,
Firebase, Convex, PocketBase, MongoDB, Neon/Vercel Postgres, libSQL and
Redis, and reports secondary stores as `database.additional`

### Fixed

- A `models/` or `schemas/` folder no longer makes a project "Mongoose";
those folders only count once `mongoose` is installed
- tsconfig/jsconfig files in root-level folders such as `frontend/` are found,
not only the ones under `apps/` and `packages/`
- On real projects this cuts "isolated" source files from 43% to 14%
(create-t3-turbo) and from 87% to 8% (Nuxt app), so critical files and
change impact are no longer computed from a near-empty graph

### Planned

- Go, Java, C# and PHP (PSR-4) import resolution, after JS/TS is mature

- Public benchmark results
- Feedback-driven fixes from the `0.2.0` beta

Expand Down
37 changes: 28 additions & 9 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ devmap/
│ │ ├── index.ts ← CLI entry (commander)
│ │ ├── analyzers/ ← Analysis pipeline
│ │ │ ├── analysis/ ← File scanning, TS-morph/heuristic analyzers
│ │ │ ├── graph/ ← Dependency graph, entry points, source scope
│ │ │ ├── graph/ ← Dependency graph, alias/workspace/package.json resolution, Nuxt auto-imports, entry points, source scope
│ │ │ ├── detectors/ ← Framework, routes, database, services, capabilities
│ │ │ ├── features/ ← Feature detection, merge, similarity
│ │ │ ├── inference/ ← Domain inference (AI), ownership topology
Expand All @@ -28,7 +28,7 @@ devmap/
│ │ ├── commands/ ← Command handlers (init, analyze, map, flow, explain, onboarding, config, doctor)
│ │ ├── onboarding/ ← Onboarding model and builder
│ │ └── utils/ ← Config, errors, output, prompts, markdown renderer
│ ├── test/ ← 33 test files
│ ├── test/ ← 39 test files (`*.test.ts`, auto-discovered by `run-tests.ts`)
│ └── package.json
├── apps/web/ ← Astro marketing site (unrelated to CLI)
├── docs/ ← Public documentation
Expand Down Expand Up @@ -98,23 +98,39 @@ businessFlow, searchTerms, confidence(high|medium|low), evidence

## Languages Supported

**Native (ts-morph, high confidence)**: .ts, .tsx, .js, .jsx
**Focus: the JS/TS ecosystem first.** Depth of analysis for TypeScript/JavaScript projects (Next, Nuxt, SvelteKit, Astro, Express, Fastify, Nest, tRPC, monorepos) comes before breadth. Other languages stay at the level below until JS/TS is mature.

**Native (ts-morph, high confidence)**: .ts, .tsx, .js, .jsx — ESM and CommonJS (`module.exports`, `exports.x`) exports are both read

**Preprocessed → ts-morph (high confidence)**: .vue (VuePreprocessor), .svelte (SveltePreprocessor), .astro (AstroPreprocessor)

**Heuristic (medium confidence)**: .py, .php, .go, .rb, .cs, .cjs, .cts, .mjs, .mts
**Heuristic (medium confidence)**: .cjs, .cts, .mjs, .mts (JS readers); .py, .rb, .php, .go, .cs, .java (per-language readers in `languageReaders.ts`)

**Fallback (low confidence)**: everything else

Dependency-graph edges exist for JS/TS-family files, Python modules and relative `require`/`include` in Ruby/PHP. Go, Java, C# and PHP namespace/PSR-4 imports are read but not yet resolved to edges (deliberately deferred).

### JS/TS import resolution (`analyzers/graph/`)

Order for a non-relative specifier: `#subpath` (package.json `imports`) → tsconfig/jsconfig aliases and framework aliases (`aliasResolver.ts`) → workspace package by name (`packageManifests.ts`) → skipped as an npm package.

- **Aliases**: JSONC tsconfig, `extends`, `references`, `baseUrl`; configs are discovered at the root, in `apps|packages|libs|services/*` and in any root-level folder (`frontend/`, `client/`) and apply only below their own directory. SvelteKit `$lib`; Nuxt `~`, `@`, `~~`, `@@`, `#shared`, `#server` are rebuilt because `.nuxt/tsconfig` is not checked in.
- **Workspace packages**: `@scope/pkg` and `@scope/pkg/sub` resolve through the member's `exports` (conditions, `*` patterns), then `source`/`module`/`main`/`types`, then the file layout. Build output (`dist/index.mjs`, `dist/types/index.d.ts`, `lib/index.js`) is mapped back to `src/`.
- **Nuxt auto-imports** (`nuxtAutoImports.ts`): components (by Nuxt naming rules), `composables/`, `utils/`, `shared/`, `server/utils/` and Pinia `stores/` become edges for the files that use them. camelCase helpers must be *called* to link; `use*`/PascalCase names link on any reference.
- **Entry points** (`entryPoints.ts`): package.json `bin` > published/root `main` (only if no source file imports it) > file conventions (Next `page|layout|route`, SvelteKit `+page|+server`, Nuxt `app.vue`/`pages/`/`server/api`, Remix `root`/`entry.*`) > scripts targets (`tsx src/x.ts`). Config files are never entries; private workspace package roots are not entries.
- Bundler query suffixes (`./x.ts?raw`) are stripped before resolution.

## Testing

33 test files in `packages/cli/test/`. Key covered areas:
39 test files in `packages/cli/test/`. Key covered areas:
- Analyzers, AI clients, agent navigation, entity extractors
- Feature similarity/merge, frontend features, framework routes
- Dependency graph: aliases, workspace packages, `#imports`, Nuxt auto-imports/aliases, entry points (`graph-resolution`, `dependency-graph-alias`, `js-ts-ecosystem`)
- Feature similarity/merge, frontend features, framework routes, detector fixes
- Heuristic language readers (`heuristic-languages`), scanner robustness, project scope/metadata
- Commands (init, analyze, map, flow, explain, onboarding, config, doctor)
- Utils (markdown terminal, JSON output, prompts, errors)

**Untested**: domain inference, ownership topology, cache layer internals, pipeline orchestrator, capability detector, service detector, filter engine, dependency graph builder, entry points detector.
**No dedicated unit tests**: capability detector (exercised through feature/onboarding tests), cache layer internals. Domain inference is covered by `analyze-ai`; the pipeline orchestrator, service detector, filter engine, dependency graph builder and entry point detector are covered by `analyzers`, `graph-resolution` and `js-ts-ecosystem`.

## Development Commands

Expand Down Expand Up @@ -154,7 +170,9 @@ pnpm devmap <command>

- 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
- No AST-level analysis for non-JS/TS languages; Go/Java/C#/PHP-namespace imports produce no graph edges yet
- Nuxt auto-import detection is name-based (no type information) and ignores layers and custom `imports.dirs`
- Vite/webpack aliases that exist only in bundler config (not tsconfig) are not read
- Feature similarity threshold (0.35) is tuned but may cause false merges
- Domain inference is single-shot — no conversation context

Expand All @@ -165,6 +183,7 @@ pnpm devmap <command>
- `AsyncLocalStorage` used for output mode (human/json)
- p-limit(50) concurrency for file scanning
- MD5 content hashing for fingerprinting
- `.js` → `.ts` extension swap in import resolution
- `.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
- REASON_TAGS constant for critical file ranking reasoning
109 changes: 107 additions & 2 deletions packages/cli/src/analyzers/analysis/tsMorphAnalyzer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import {
Project,
ScriptTarget,
SyntaxKind,
VariableDeclarationKind
VariableDeclarationKind,
type SourceFile
} from "ts-morph";
import type {
AnalyzerContext,
Expand Down Expand Up @@ -119,7 +120,13 @@ export class TsMorphAnalyzer implements FileAnalyzer {
}

// --- Exports ---
const exports = [...sourceFile.getExportedDeclarations().keys()].sort();
// `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 exports = [...new Set([
...sourceFile.getExportedDeclarations().keys(),
...commonJs.map((entry) => entry.name)
])].sort();

// --- Symbols ---
const symbols: SymbolInfo[] = [];
Expand Down Expand Up @@ -199,6 +206,22 @@ export class TsMorphAnalyzer implements FileAnalyzer {
}
}

for (const entry of commonJs) {
const existing = symbols.find((symbol) => symbol.name === (entry.alias ?? entry.name));
if (existing) {
existing.exported = true;
} else if (entry.name !== "default") {
symbols.push({
name: entry.name,
kind: entry.kind,
line: entry.line,
exported: true,
async: entry.async
});
}
}
symbols.sort((left, right) => left.line - right.line || left.name.localeCompare(right.name));

// --- Top functions ---
const topFunctions = symbols
.filter((symbol): symbol is SymbolInfo & { kind: FunctionInfo["kind"] } =>
Expand Down Expand Up @@ -240,6 +263,88 @@ function declaredNames(nameNode: Node): string[] {
.map((node) => node.getText());
}

interface CommonJsExport {
name: string;
/** `module.exports = router`: the local binding the default export points at. */
alias?: string;
kind: "function" | "const";
line: number;
async: boolean;
}

/**
* 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[] {
const found: CommonJsExport[] = [];
const seen = new Set<string>();

const add = (name: string, node: Node, value?: Node) => {
if (seen.has(name)) return;
seen.add(name);
const callable = value && (
Node.isFunctionExpression(value) || Node.isArrowFunction(value) || Node.isMethodDeclaration(value)
);
found.push({
name,
...(name === "default" && value && Node.isIdentifier(value) ? { alias: value.getText() } : {}),
kind: callable || (value && Node.isClassExpression(value)) ? "function" : "const",
line: node.getStartLineNumber(),
async: Boolean(callable && (value as { isAsync(): boolean }).isAsync())
});
};

for (const statement of sourceFile.getStatements()) {
if (!Node.isExpressionStatement(statement)) continue;
const expression = statement.getExpression();
if (!Node.isBinaryExpression(expression)) continue;
if (expression.getOperatorToken().getKind() !== SyntaxKind.EqualsToken) continue;

const target = commonJsTarget(expression.getLeft());
if (!target) continue;
const value = expression.getRight();

if (target.kind === "named") {
add(target.name, statement, value);
continue;
}

if (Node.isObjectLiteralExpression(value)) {
for (const property of value.getProperties()) {
if (Node.isPropertyAssignment(property)) {
add(property.getName().replace(/^['"]|['"]$/g, ""), property, property.getInitializer());
} else if (Node.isShorthandPropertyAssignment(property)) {
add(property.getName(), property);
} else if (Node.isMethodDeclaration(property)) {
add(property.getName(), property, property);
}
}
} else {
add("default", statement, value);
}
}

return found;
}

function commonJsTarget(left: Node): { kind: "all" } | { kind: "named"; name: string } | null {
const isExportsObject = (node: Node) => ["module.exports", "exports"].includes(node.getText());

if (Node.isPropertyAccessExpression(left)) {
if (left.getText() === "module.exports") return { kind: "all" };
if (isExportsObject(left.getExpression())) return { kind: "named", name: left.getName() };
return null;
}

if (Node.isElementAccessExpression(left) && isExportsObject(left.getExpression())) {
const argument = left.getArgumentExpression();
if (argument && Node.isStringLiteral(argument)) return { kind: "named", name: argument.getLiteralValue() };
}

return null;
}

function replaceExtension(filePath: string, newExt: string): string {
const lastDot = filePath.lastIndexOf(".");
if (lastDot === -1) return filePath + newExt;
Expand Down
Loading
Loading