diff --git a/CHANGELOG.md b/CHANGELOG.md index 62dcb0a..4a3af83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index d69bc5b..89ae6bd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 @@ -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 @@ -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 @@ -154,7 +170,9 @@ pnpm devmap - 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 @@ -165,6 +183,7 @@ pnpm devmap - `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 diff --git a/packages/cli/src/analyzers/analysis/tsMorphAnalyzer.ts b/packages/cli/src/analyzers/analysis/tsMorphAnalyzer.ts index 58e8d25..30238d1 100644 --- a/packages/cli/src/analyzers/analysis/tsMorphAnalyzer.ts +++ b/packages/cli/src/analyzers/analysis/tsMorphAnalyzer.ts @@ -3,7 +3,8 @@ import { Project, ScriptTarget, SyntaxKind, - VariableDeclarationKind + VariableDeclarationKind, + type SourceFile } from "ts-morph"; import type { AnalyzerContext, @@ -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[] = []; @@ -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"] } => @@ -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(); + + 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; diff --git a/packages/cli/src/analyzers/detectors/databaseDetector.ts b/packages/cli/src/analyzers/detectors/databaseDetector.ts index c3e6fac..a1e0213 100644 --- a/packages/cli/src/analyzers/detectors/databaseDetector.ts +++ b/packages/cli/src/analyzers/detectors/databaseDetector.ts @@ -2,48 +2,138 @@ import { parseJsonText, type ScannedFile } from "../analysis/index.js"; import { isArchitectureSource } from "../graph/index.js"; export type DatabaseInfo = { + /** The main data layer: ORM/BaaS first, then drivers. */ provider: string; files: string[]; + /** Other data stores the project also depends on (e.g. Redis next to Prisma). */ + additional?: string[]; }; -const DATABASE_SIGNALS: Array<{ +type DatabaseSignal = { provider: string; + /** + * `driver` talks to a database an ORM/BaaS already sits on, so it only + * shows up as an extra store when nothing higher-level was found. + */ + kind: "orm" | "baas" | "driver" | "cache"; dependencies: string[]; + /** Specific enough to count with no matching dependency. */ paths: RegExp[]; -}> = [ + /** Only name this provider's files once a dependency confirms it. */ + weakPaths?: RegExp[]; +}; + +// Order is priority: the first match becomes `provider`, later matches become +// `additional`. ORMs and backend-as-a-service describe an app's data model +// better than the driver underneath them, and a cache is never the main store. +const DATABASE_SIGNALS: DatabaseSignal[] = [ { provider: "Prisma", + kind: "orm", dependencies: ["@prisma/client", "prisma"], paths: [/(^|\/)schema\.prisma$/, /(^|\/)prisma\//] }, { provider: "Drizzle", + kind: "orm", dependencies: ["drizzle-orm"], paths: [/(^|\/)drizzle\.config\.[cm]?[jt]s$/, /(^|\/)drizzle\//] }, + { + provider: "TypeORM", + kind: "orm", + dependencies: ["typeorm", "@nestjs/typeorm"], + paths: [], + weakPaths: [/\.entity\.[cm]?[jt]s$/] + }, + { + provider: "Sequelize", + kind: "orm", + dependencies: ["sequelize", "sequelize-typescript", "@nestjs/sequelize"], + paths: [/(^|\/)\.sequelizerc$/], + weakPaths: [/(^|\/)(models?|migrations)\//] + }, + { + provider: "MikroORM", + kind: "orm", + dependencies: ["@mikro-orm/core"], + paths: [], + weakPaths: [/\.entity\.[cm]?[jt]s$/] + }, + { + provider: "Kysely", + kind: "orm", + dependencies: ["kysely"], + paths: [] + }, + { + provider: "Knex", + kind: "orm", + dependencies: ["knex"], + paths: [/(^|\/)knexfile\.[cm]?[jt]s$/], + weakPaths: [/(^|\/)migrations\//] + }, { provider: "Mongoose", - dependencies: ["mongoose"], - paths: [/(^|\/)(models?|schemas?)\//] + kind: "orm", + dependencies: ["mongoose", "@nestjs/mongoose", "@typegoose/typegoose"], + paths: [], + // `models/` and `schemas/` exist in every kind of project; only name them + // as Mongoose files when mongoose is actually installed. + weakPaths: [/(^|\/)(models?|schemas?)\//] }, { provider: "Supabase", - dependencies: ["@supabase/supabase-js"], + kind: "baas", + dependencies: ["@supabase/supabase-js", "@supabase/ssr"], paths: [/(^|\/)supabase\//] }, + { + provider: "Firebase", + kind: "baas", + dependencies: ["firebase", "firebase-admin", "@firebase/firestore"], + paths: [/(^|\/)firestore\.rules$/] + }, + { + provider: "Convex", + kind: "baas", + dependencies: ["convex"], + paths: [/(^|\/)convex\/schema\.[cm]?[jt]s$/] + }, + { + provider: "PocketBase", + kind: "baas", + dependencies: ["pocketbase"], + paths: [] + }, + { + provider: "MongoDB (native driver)", + kind: "driver", + dependencies: ["mongodb"], + paths: [] + }, { provider: "PostgreSQL (raw SQL)", - dependencies: ["pg", "pg-promise"], + kind: "driver", + dependencies: ["pg", "pg-promise", "postgres", "@neondatabase/serverless", "@vercel/postgres"], paths: [] }, { provider: "MySQL (raw SQL)", - dependencies: ["mysql2", "mysql"], + kind: "driver", + dependencies: ["mysql2", "mysql", "@planetscale/database"], paths: [] }, { provider: "SQLite (raw SQL)", - dependencies: ["better-sqlite3", "sqlite3"], + kind: "driver", + dependencies: ["better-sqlite3", "sqlite3", "sqlite", "@libsql/client"], + paths: [] + }, + { + provider: "Redis", + kind: "cache", + dependencies: ["redis", "ioredis", "@upstash/redis"], paths: [] } ]; @@ -52,22 +142,37 @@ export function detectDatabase(files: ScannedFile[]): DatabaseInfo | undefined { const scopedFiles = files.filter((file) => isArchitectureSource(file.path)); const dependencies = readDependencies(scopedFiles); + const matches: Array<{ provider: string; kind: DatabaseSignal["kind"]; files: string[] }> = []; + for (const signal of DATABASE_SIGNALS) { + const dependencyMatch = signal.dependencies.some((dependency) => dependencies.has(dependency)); + const patterns = dependencyMatch ? [...signal.paths, ...(signal.weakPaths ?? [])] : signal.paths; + const matchedFiles = scopedFiles - .filter((file) => signal.paths.some((pattern) => pattern.test(file.path))) + .filter((file) => patterns.some((pattern) => pattern.test(file.path))) .map((file) => file.path) .sort(); - const dependencyMatch = signal.dependencies.some((dependency) => dependencies.has(dependency)); if (dependencyMatch || matchedFiles.length > 0) { - return { - provider: signal.provider, - files: matchedFiles - }; + matches.push({ provider: signal.provider, kind: signal.kind, files: matchedFiles }); } } - return undefined; + // A cache is a store worth listing next to a database, never the database. + const primaryIndex = matches.findIndex((match) => match.kind !== "cache"); + if (primaryIndex === -1) return undefined; + + const primary = matches[primaryIndex]; + const rest = matches.filter((_, index) => index !== primaryIndex); + + const primaryIsDriver = primary.kind === "driver"; + const others = rest.filter((match) => match.kind !== "driver" || primaryIsDriver); + + return { + provider: primary.provider, + files: primary.files, + ...(others.length > 0 ? { additional: others.map((match) => match.provider) } : {}) + }; } function readDependencies(files: ScannedFile[]): Set { diff --git a/packages/cli/src/analyzers/graph/aliasResolver.ts b/packages/cli/src/analyzers/graph/aliasResolver.ts index 913516d..4663e68 100644 --- a/packages/cli/src/analyzers/graph/aliasResolver.ts +++ b/packages/cli/src/analyzers/graph/aliasResolver.ts @@ -2,6 +2,7 @@ import { existsSync } from "node:fs"; import { readFile, readdir } from "node:fs/promises"; import { dirname, join, relative, resolve, sep } from "node:path"; import { ts } from "ts-morph"; +import { pickNuxtSrcDir } from "./nuxtAutoImports.js"; export interface AliasMapping { /** Specifier prefix (`@/`), or the whole specifier when `exact` is set. */ @@ -134,6 +135,23 @@ function scopeDepth(scope: string): number { async function findConfigDirectories(root: string): Promise { const directories = [root]; + // A frontend/ or client/ next to the root config is as common as apps/*. + try { + const children = await readdir(root, { withFileTypes: true }); + for (const entry of children.sort((a, b) => a.name.localeCompare(b.name))) { + if ( + entry.isDirectory() + && !entry.name.startsWith(".") + && entry.name !== "node_modules" + && !WORKSPACE_PARENTS.includes(entry.name) + ) { + directories.push(join(root, entry.name)); + } + } + } catch { + // unreadable root: nothing to scan + } + for (const parent of WORKSPACE_PARENTS) { try { const entries = await readdir(join(root, parent), { withFileTypes: true }); @@ -171,6 +189,53 @@ async function loadDirectoryMappings(root: string, directory: string): Promise { + const configName = NUXT_CONFIGS.find((name) => existsSync(join(directory, name))); + if (!configName) return []; + + let configText = ""; + try { + configText = await readFile(join(directory, configName), "utf8"); + } catch { + // an unreadable config still leaves the default layout + } + + const srcDir = pickNuxtSrcDir(configText, (relative) => { + const target = join(directory, relative); + return existsSync(target); + }); + + const toTarget = (sub: string) => { + const rootRelative = toRootRelative(join(directory, sub), root); + if (rootRelative === null) return null; + return rootRelative === "" ? "" : `${rootRelative}/`; + }; + + const mappings: AliasMapping[] = []; + const add = (prefix: string, sub: string) => { + const target = toTarget(sub); + if (target !== null) mappings.push({ prefix, target, scope, optional: true }); + }; + + add("~/", srcDir); + add("@/", srcDir); + add("~~/", ""); + add("@@/", ""); + add("#shared/", "shared"); + add("#server/", "server"); + return mappings; } diff --git a/packages/cli/src/analyzers/graph/dependencyGraph.ts b/packages/cli/src/analyzers/graph/dependencyGraph.ts index b431561..aaa66b3 100644 --- a/packages/cli/src/analyzers/graph/dependencyGraph.ts +++ b/packages/cli/src/analyzers/graph/dependencyGraph.ts @@ -1,6 +1,14 @@ import type { FileAnalysis, ScannedFile } from "../analysis/index.js"; import type { AliasMapping } from "./aliasResolver.js"; import { isDeclaredAlias, resolveAliasCandidates } from "./aliasResolver.js"; +import { matchLocalPath, normalizePath } from "./localPaths.js"; +import { addNuxtAutoImportEdges } from "./nuxtAutoImports.js"; +import { + indexPackages, + readPackageManifests, + resolveSubpathImport, + resolveWorkspaceSpecifier +} from "./packageManifests.js"; export type FileGraph = Record; @@ -19,6 +27,7 @@ export function buildDependencyGraph( ): { graph: FileGraph; diagnostics: DependencyGraphDiagnostics } { const graph: FileGraph = {}; const localPaths = new Set(files.map((file) => file.path)); + const packages = indexPackages(readPackageManifests(files)); const diagnostics: DependencyGraphDiagnostics = { unresolvedAliases: [], parserFallbacks: [], @@ -30,7 +39,11 @@ export function buildDependencyGraph( const edges = new Set(); const importSpecifiers = analyses[file.path]?.imports ?? findImportSpecifiers(file.content); - for (const specifier of importSpecifiers) { + for (const rawSpecifier of importSpecifiers) { + // Bundler suffixes (`./icon.svg?react`, `./worker.ts?worker`) are not + // part of the file name. + const specifier = stripQuery(rawSpecifier); + if (specifier.startsWith(".")) { const resolved = resolveImport(file.path, specifier, localPaths); if (resolved && resolved !== file.path) { @@ -39,13 +52,33 @@ export function buildDependencyGraph( continue; } + // package.json "imports": `#internal/db` is local by definition. Other + // `#` specifiers (Nuxt's `#shared/...`) are aliases and fall through. + if (specifier.startsWith("#")) { + const resolved = resolveSubpathImport(specifier, file.path, packages, localPaths); + if (resolved) { + if (resolved !== file.path) edges.add(resolved); + continue; + } + } + // Try alias resolution for non-relative imports const candidates = resolveAliasCandidates(specifier, aliasMappings, file.path); + const aliasResolved = candidates.length > 0 ? firstResolved(candidates, localPaths) : null; + if (aliasResolved) { + if (aliasResolved !== file.path) edges.add(aliasResolved); + continue; + } + + // A package that lives in this repo (workspace member): import by name. + const workspaceResolved = resolveWorkspaceSpecifier(specifier, packages, localPaths); + if (workspaceResolved) { + if (workspaceResolved !== file.path) edges.add(workspaceResolved); + continue; + } + if (candidates.length > 0) { - const resolved = firstResolved(candidates, localPaths); - if (resolved) { - if (resolved !== file.path) edges.add(resolved); - } else if (isDeclaredAlias(specifier, aliasMappings, file.path)) { + if (isDeclaredAlias(specifier, aliasMappings, file.path)) { diagnostics.unresolvedAliases.push({ file: file.path, specifier }); } } else if (file.path.endsWith(".py")) { @@ -62,6 +95,9 @@ export function buildDependencyGraph( } } + // Frameworks that connect files without import statements. + addNuxtAutoImportEdges(graph, files, analyses); + return { graph, diagnostics }; } @@ -77,6 +113,11 @@ export function countReferences(graph: FileGraph): Record { return counts; } +function stripQuery(specifier: string): string { + const query = specifier.indexOf("?"); + return query === -1 ? specifier : specifier.slice(0, query); +} + function findImportSpecifiers(content: string): string[] { const matches: string[] = []; IMPORT_RE.lastIndex = 0; @@ -120,59 +161,3 @@ function firstResolved(rootRelativePaths: string[], localPaths: Set): st } return null; } - -const SOURCE_EXTENSIONS = [ - ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue", ".svelte", ".astro", - ".py", ".rb", ".php" -]; -const INDEX_EXTENSIONS = [ - ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue", ".svelte", ".astro", - ".py", ".rb", ".php" -]; - -// TypeScript ESM writes the emitted extension in the specifier. -const EMITTED_EXTENSION_SOURCES: Array<[string, string[]]> = [ - [".js", [".ts", ".tsx"]], - [".jsx", [".tsx"]], - [".mjs", [".mts"]], - [".cjs", [".cts"]] -]; - -function matchLocalPath(normalized: string, localPaths: Set): string | null { - const candidates = [ - normalized, - ...SOURCE_EXTENSIONS.map((extension) => `${normalized}${extension}`), - ...INDEX_EXTENSIONS.map((extension) => `${normalized}/index${extension}`), - `${normalized}/__init__.py` - ]; - - for (const [emitted, sources] of EMITTED_EXTENSION_SOURCES) { - if (normalized.endsWith(emitted)) { - const withoutExtension = normalized.slice(0, -emitted.length); - candidates.push(...sources.map((source) => `${withoutExtension}${source}`)); - } - } - - return candidates.find((candidate) => localPaths.has(candidate)) ?? null; -} - -/** Collapse `.` and `..`; null when the path climbs above the project root. */ -function normalizePath(path: string): string | null { - const parts: string[] = []; - - for (const part of path.split("/")) { - if (part === "." || part === "") { - continue; - } - - if (part === "..") { - if (parts.length === 0) return null; - parts.pop(); - continue; - } - - parts.push(part); - } - - return parts.join("/"); -} diff --git a/packages/cli/src/analyzers/graph/entryPoints.ts b/packages/cli/src/analyzers/graph/entryPoints.ts index 4d9d6e0..289e257 100644 --- a/packages/cli/src/analyzers/graph/entryPoints.ts +++ b/packages/cli/src/analyzers/graph/entryPoints.ts @@ -1,4 +1,5 @@ import type { FileGraph } from "./dependencyGraph.js"; +import type { ManifestEntry } from "./packageManifests.js"; import { isArchitectureSource } from "./sourceScope.js"; const ENTRY_PATTERNS = [ @@ -9,11 +10,36 @@ const ENTRY_PATTERNS = [ /(^|\/)route\.[jt]s$/ ]; +// Files a framework loads by convention. They only count while no source file +// imports them: a Vue SPA's `pages/Home.vue` pulled in by its router is a +// component, while a Nuxt page that nothing references is where a route starts. +const FRAMEWORK_LOADED_PATTERNS = [ + /(^|\/)app\.vue$/, + /(^|\/)\+(?:page|layout)\.svelte$/, + /(^|\/)\+server\.[jt]s$/, + /(^|\/)hooks\.(?:server|client)\.[jt]s$/, + /(^|\/)app\/root\.[jt]sx?$/, + /(^|\/)entry\.(?:client|server)\.[jt]sx?$/, + /(^|\/)pages\/.+\.(?:vue|astro)$/, + /(^|\/)pages\/(?:_app|_document)\.[jt]sx?$/, + /(^|\/)server\/(?:api|routes|middleware)\/.+\.[cm]?[jt]s$/ +]; + const ENTRY_PATTERN_SCORES: Array<{ pattern: RegExp; score: number }> = [ { pattern: /(^|\/)layout\.[jt]sx?$/, score: 0 }, + { pattern: /(^|\/)app\/root\.[jt]sx?$/, score: 0 }, + { pattern: /(^|\/)\+layout\.svelte$/, score: 0 }, + { pattern: /(^|\/)app\.vue$/, score: 0 }, + { pattern: /(^|\/)pages\/(?:_app|_document)\.[jt]sx?$/, score: 0 }, + { pattern: /(^|\/)pages\/.+\.(?:vue|astro)$/, score: 1 }, { pattern: /(^|\/)page\.[jt]sx?$/, score: 1 }, + { pattern: /(^|\/)\+page\.svelte$/, score: 1 }, { pattern: /(^|\/)route\.[jt]s$/, score: 2 }, + { pattern: /(^|\/)\+server\.[jt]s$/, score: 2 }, + { pattern: /(^|\/)server\/(?:api|routes|middleware)\/.+\.[cm]?[jt]s$/, score: 2 }, { pattern: /(^|\/)middleware\.[jt]s$/, score: 3 }, + { pattern: /(^|\/)hooks\.(?:server|client)\.[jt]s$/, score: 3 }, + { pattern: /(^|\/)entry\.(?:client|server)\.[jt]sx?$/, score: 3 }, { pattern: /(^|\/)(app|server)\.[cm]?[jt]sx?$/, score: 4 }, { pattern: /(^|\/)(index|main)\.[cm]?[jt]sx?$/, score: 5 }, ]; @@ -25,7 +51,21 @@ function entryPointScore(path: string): number { return 10; } -export function detectEntryPoints(graph: FileGraph): string[] { +// Declared entries rank above guesses: a CLI's `bin` is the way in, a package's +// `main` is its public face, and a script runs the file on purpose. +const MANIFEST_SCORES: Record = { + bin: -2, + main: -1, + script: 6.5 +}; + +/** + * `manifestEntries` come from package.json (`bin`, `main`/`exports`, scripts). + * A `main` file that another source file imports is just shared plumbing, so + * it only counts while nothing imports it; `bin` and script entries are + * started from outside and always count. + */ +export function detectEntryPoints(graph: FileGraph, manifestEntries: ManifestEntry[] = []): string[] { // Only production code counts as a consumer: a barrel that nothing but its // own tests imports (or a root index.ts that tests import) is still an // entry, while a barrel re-exported through the app is just plumbing. @@ -35,10 +75,21 @@ export function detectEntryPoints(graph: FileGraph): string[] { .flatMap(([, imports]) => imports) ); + const declared = new Map(); + for (const entry of manifestEntries) { + if (!(entry.path in graph) || !isSourceFile(entry.path) || !isArchitectureSource(entry.path)) continue; + if (entry.kind === "main" && imported.has(entry.path)) continue; + const score = MANIFEST_SCORES[entry.kind]; + declared.set(entry.path, Math.min(score, declared.get(entry.path) ?? score)); + } + + const scoreOf = (path: string) => declared.get(path) ?? entryPointScore(path); + return Object.keys(graph) .filter((path) => isSourceFile(path)) .filter((path) => isArchitectureSource(path)) .filter((path) => { + if (declared.has(path)) return true; const unimportedWithImports = !imported.has(path) && (graph[path]?.length ?? 0) > 0; // index.* is also the conventional barrel file; it is an entry only when // nothing else imports it. A top-level index (index.ts, src/index.ts) is @@ -46,9 +97,11 @@ export function detectEntryPoints(graph: FileGraph): string[] { if (/(^|\/)index\.[cm]?[jt]sx?$/.test(path)) { return !imported.has(path) && ((graph[path]?.length ?? 0) > 0 || path.split("/").length <= 2); } - return ENTRY_PATTERNS.some((pattern) => pattern.test(path)) || unimportedWithImports; + return ENTRY_PATTERNS.some((pattern) => pattern.test(path)) + || (!imported.has(path) && FRAMEWORK_LOADED_PATTERNS.some((pattern) => pattern.test(path))) + || unimportedWithImports; }) - .sort((a, b) => entryPointScore(a) - entryPointScore(b) || a.localeCompare(b)) + .sort((a, b) => scoreOf(a) - scoreOf(b) || a.localeCompare(b)) .slice(0, 20); } diff --git a/packages/cli/src/analyzers/graph/index.ts b/packages/cli/src/analyzers/graph/index.ts index 80148cb..2601bf0 100644 --- a/packages/cli/src/analyzers/graph/index.ts +++ b/packages/cli/src/analyzers/graph/index.ts @@ -21,6 +21,19 @@ export { detectEntryPoints, } from "./entryPoints.js"; +export { + indexPackages, + manifestEntryPoints, + readPackageManifests, + resolveSubpathImport, + resolveWorkspaceSpecifier, +} from "./packageManifests.js"; +export type { + ManifestEntry, + PackageIndex, + PackageManifest, +} from "./packageManifests.js"; + export { isArchitectureSource, isTestPath, diff --git a/packages/cli/src/analyzers/graph/localPaths.ts b/packages/cli/src/analyzers/graph/localPaths.ts new file mode 100644 index 0000000..2296a98 --- /dev/null +++ b/packages/cli/src/analyzers/graph/localPaths.ts @@ -0,0 +1,58 @@ +// Shared path matching for graph resolution: turn an import target (no +// extension, directory, or emitted `.js`) into a scanned source file. + +const SOURCE_EXTENSIONS = [ + ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue", ".svelte", ".astro", + ".py", ".rb", ".php" +]; +const INDEX_EXTENSIONS = [ + ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue", ".svelte", ".astro", + ".py", ".rb", ".php" +]; + +// TypeScript ESM writes the emitted extension in the specifier. +const EMITTED_EXTENSION_SOURCES: Array<[string, string[]]> = [ + [".js", [".ts", ".tsx"]], + [".jsx", [".tsx"]], + [".mjs", [".mts"]], + [".cjs", [".cts"]] +]; + +export function matchLocalPath(normalized: string, localPaths: Set): string | null { + const candidates = [ + normalized, + ...SOURCE_EXTENSIONS.map((extension) => `${normalized}${extension}`), + ...INDEX_EXTENSIONS.map((extension) => `${normalized}/index${extension}`), + `${normalized}/__init__.py` + ]; + + for (const [emitted, sources] of EMITTED_EXTENSION_SOURCES) { + if (normalized.endsWith(emitted)) { + const withoutExtension = normalized.slice(0, -emitted.length); + candidates.push(...sources.map((source) => `${withoutExtension}${source}`)); + } + } + + return candidates.find((candidate) => localPaths.has(candidate)) ?? null; +} + +/** Collapse `.` and `..`; null when the path climbs above the project root. */ +export function normalizePath(path: string): string | null { + const parts: string[] = []; + + for (const part of path.split("/")) { + if (part === "." || part === "") { + continue; + } + + if (part === "..") { + if (parts.length === 0) return null; + parts.pop(); + continue; + } + + parts.push(part); + } + + return parts.join("/"); +} diff --git a/packages/cli/src/analyzers/graph/nuxtAutoImports.ts b/packages/cli/src/analyzers/graph/nuxtAutoImports.ts new file mode 100644 index 0000000..c015301 --- /dev/null +++ b/packages/cli/src/analyzers/graph/nuxtAutoImports.ts @@ -0,0 +1,270 @@ +import type { FileAnalysis, ScannedFile } from "../analysis/index.js"; +import type { FileGraph } from "./dependencyGraph.js"; +import { isArchitectureSource } from "./sourceScope.js"; + +/** + * Nuxt wires files together without import statements: components, composables + * and utils are picked up from their folders, and `~/` points at `app/` (Nuxt + * 4) or the project root (Nuxt 3), not at `src/`. Without modelling that, a + * Nuxt project looks like a pile of files that never reference each other. + */ + +export interface NuxtApp { + /** Project-root-relative directory holding nuxt.config (`""` for the root). */ + dir: string; + /** Source directory, relative to `dir` (`""` or `"app"`, or a custom srcDir). */ + srcDir: string; + /** `@pinia/nuxt` is installed: `stores/` is auto-imported too. */ + pinia: boolean; +} + +const NUXT_CONFIG = /(^|\/)nuxt\.config\.[cm]?[jt]s$/; +const SCRIPT_EXTENSIONS = new Set([".ts", ".js", ".mjs", ".mts", ".cjs", ".cts"]); +const CONSUMER_EXTENSIONS = new Set([".vue", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".mts"]); +const COMPONENT_EXTENSIONS = new Set([".vue", ".tsx", ".jsx"]); + +// Nuxt 4 moved the app into `app/`; any of these under it means that layout. +const APP_DIR_MARKERS = [ + "app/app.vue", "app/pages/", "app/components/", "app/layouts/", + "app/composables/", "app/plugins/", "app/middleware/", "app/utils/" +]; + +/** + * The source directory a Nuxt project uses. An explicit `srcDir` in the config + * wins; otherwise `app/` when the Nuxt 4 layout is present, else the root. + * `hasPath` answers whether a path (or, with a trailing slash, a folder) + * exists under the config's directory. + */ +export function pickNuxtSrcDir(configText: string, hasPath: (relative: string) => boolean): string { + const explicit = /\bsrcDir\s*:\s*['"`]([^'"`]+)['"`]/.exec(configText)?.[1]; + if (explicit) { + const cleaned = explicit.replace(/^\.\//, "").replace(/\/+$/, ""); + return cleaned === "." ? "" : cleaned; + } + return APP_DIR_MARKERS.some(hasPath) ? "app" : ""; +} + +export function detectNuxtApps(files: ScannedFile[]): NuxtApp[] { + const apps: NuxtApp[] = []; + + for (const config of files.filter((file) => NUXT_CONFIG.test(file.path))) { + const dir = config.path.includes("/") ? config.path.slice(0, config.path.lastIndexOf("/")) : ""; + const prefix = dir === "" ? "" : `${dir}/`; + const hasPath = (relative: string) => + files.some((file) => file.path.startsWith(`${prefix}${relative}`)); + + apps.push({ + dir, + srcDir: pickNuxtSrcDir(config.content, hasPath), + pinia: files.some((file) => + (file.path === `${prefix}package.json` || file.path === "package.json") + && file.content.includes("@pinia/nuxt") + ) + }); + } + + return apps.sort((a, b) => a.dir.localeCompare(b.dir)); +} + +/** + * Add the edges Nuxt creates implicitly: a file that renders `` + * or calls `useUser()` depends on the component / composable file that + * provides it. Mutates `graph`; existing explicit imports are untouched. + */ +export function addNuxtAutoImportEdges( + graph: FileGraph, + files: ScannedFile[], + analyses: Record +): void { + const apps = detectNuxtApps(files); + if (apps.length === 0) return; + + const production = files.filter((file) => isArchitectureSource(file.path)); + + for (const app of apps) { + const inApp = (path: string) => app.dir === "" || path.startsWith(`${app.dir}/`); + const appFiles = production.filter((file) => inApp(file.path)); + const rel = (path: string) => (app.dir === "" ? path : path.slice(app.dir.length + 1)); + const srcPrefix = app.srcDir === "" ? "" : `${app.srcDir}/`; + + const isServer = (path: string) => rel(path).startsWith("server/"); + // Providers visible to app code vs. to the Nitro server. + const appValues = new Map(); + const serverValues = new Map(); + const components = new Map(); + // PascalCase names, for components referenced as identifiers in a script + // (`{ component: NavButtonBookmark }`) instead of as a template tag. + const componentIdentifiers = new Map(); + + const registerValues = (target: Map, file: ScannedFile) => { + const analysis = analyses[file.path]; + const names = new Set(analysis?.exports ?? []); + if (names.delete("default")) names.add(camelCase(baseName(file.path))); + for (const name of names) { + if (name.length >= 3 && !target.has(name)) target.set(name, file.path); + } + }; + + for (const file of appFiles) { + const path = rel(file.path); + + // components/**: every .vue file, named from its folder path. + if (path.startsWith(`${srcPrefix}components/`) && COMPONENT_EXTENSIONS.has(file.extension)) { + const name = componentName(path.slice(`${srcPrefix}components/`.length)); + const key = name ? componentKey(name) : ""; + if (key && !components.has(key)) components.set(key, file.path); + if (name && name.length >= 4 && !componentIdentifiers.has(name)) componentIdentifiers.set(name, file.path); + continue; + } + + if (!SCRIPT_EXTENSIONS.has(file.extension)) continue; + + const dirs = [ + `${srcPrefix}composables/`, + `${srcPrefix}utils/`, + ...(app.pinia ? [`${srcPrefix}stores/`] : []), + "shared/utils/", + "shared/types/" + ]; + const dirHit = dirs.find((dir) => path.startsWith(dir)); + if (dirHit && isTopLevelOrIndex(path.slice(dirHit.length))) { + registerValues(appValues, file); + // shared/ is visible on both sides of the app. + if (dirHit.startsWith("shared/")) registerValues(serverValues, file); + continue; + } + + if (path.startsWith("server/utils/") && isTopLevelOrIndex(path.slice("server/utils/".length))) { + registerValues(serverValues, file); + } + } + + if (appValues.size === 0 && serverValues.size === 0 && components.size === 0) continue; + + for (const consumer of appFiles) { + if (!CONSUMER_EXTENSIONS.has(consumer.extension) || !(consumer.path in graph)) continue; + + const server = isServer(consumer.path); + const values = server ? serverValues : appValues; + const edges = new Set(graph[consumer.path]); + const before = edges.size; + + const tokens = new Set(consumer.content.match(/[A-Za-z_$][\w$]*/g) ?? []); + + if (values.size > 0) { + for (const [name, provider] of values) { + if (provider === consumer.path || edges.has(provider) || !tokens.has(name)) continue; + if (isReferenced(name, consumer.content)) edges.add(provider); + } + } + + if (!server && consumer.extension === ".vue" && components.size > 0) { + for (const tag of renderedComponents(consumer.content)) { + const provider = components.get(tag) ?? components.get(tag.replace(/^lazy/, "")); + if (provider && provider !== consumer.path) edges.add(provider); + } + } + + if (!server && COMPONENT_EXTENSIONS.has(consumer.extension) && componentIdentifiers.size > 0) { + for (const token of tokens) { + const provider = componentIdentifiers.get(token); + if (provider && provider !== consumer.path) edges.add(provider); + } + } + + if (edges.size !== before) graph[consumer.path] = [...edges]; + } + } +} + +// ------------------------------------------------------------------ naming + +/** Nuxt's component name: folder path + file name, overlap dropped. */ +export function componentName(relativeToComponents: string): string | null { + const segments = relativeToComponents.split("/"); + const file = segments.pop(); + if (!file) return null; + + let fileName = file.replace(/\.[^.]+$/, "").replace(/\.(?:client|server|global)$/i, ""); + // components/foo/index.vue is . + if (fileName.toLowerCase() === "index") { + const directory = segments[segments.length - 1]; + if (!directory) return null; + fileName = directory; + } + + const prefixParts = segments.flatMap(splitByCase); + const fileParts = splitByCase(fileName); + if (fileParts.length === 0) return null; + + const nameParts: string[] = []; + while (prefixParts.length > 0 && prefixParts[0].toLowerCase() !== fileParts[0].toLowerCase()) { + nameParts.push(prefixParts.shift() as string); + } + + return [...nameParts, ...fileParts].map(upperFirst).join(""); +} + +function componentKey(name: string): string { + return name.toLowerCase().replace(/[-_]/g, ""); +} + +/** Component tags used by a .vue file, normalized to match `componentKey`. */ +function renderedComponents(content: string): Set { + const tags = new Set(); + + for (const match of content.matchAll(/<([A-Za-z][\w-]*)(?=[\s/>])/g)) { + tags.add(componentKey(match[1])); + } + for (const match of content.matchAll(/\bresolveComponent\(\s*['"`]([\w-]+)['"`]/g)) { + tags.add(componentKey(match[1])); + } + + return tags; +} + +function splitByCase(value: string): string[] { + return value + .replace(/([a-z\d])([A-Z])/g, "$1 $2") + .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2") + .split(/[\s\-_./]+/) + .filter(Boolean); +} + +function upperFirst(value: string): string { + return value.charAt(0).toUpperCase() + value.slice(1); +} + +function camelCase(value: string): string { + const [first = "", ...rest] = splitByCase(value); + return first.charAt(0).toLowerCase() + first.slice(1) + rest.map(upperFirst).join(""); +} + +function baseName(path: string): string { + const file = path.slice(path.lastIndexOf("/") + 1).replace(/\.[^.]+$/, ""); + // utils/foo/index.ts is named after its folder. + if (file === "index") { + const parts = path.split("/"); + return parts[parts.length - 2] ?? file; + } + return file; +} + +/** Nuxt scans top-level files of an auto-import folder plus `folder/index.ts`. */ +function isTopLevelOrIndex(relative: string): boolean { + return !relative.includes("/") || /^[^/]+\/index\.[cm]?[jt]s$/.test(relative); +} + +/** + * `useX`, `PascalCase` and `CONSTANTS` are referenced as bare identifiers; + * a plain camelCase helper must be called (`formatDate(...)`, not + * `date.format(...)` or a local variable that happens to share the name). + */ +function isReferenced(name: string, content: string): boolean { + if (/^use[A-Z]/.test(name) || /^[A-Z]/.test(name)) return true; + return new RegExp(`(?; + scripts: Record; +} + +export interface PackageIndex { + manifests: PackageManifest[]; + byName: Map; +} + +export interface ManifestEntry { + path: string; + kind: "bin" | "main" | "script"; +} + +// Output folders and the format folders nested in them (`dist/esm/index.js`). +const BUILD_DIRS = new Set([ + "dist", "build", "lib", "out", "output", "esm", "cjs", "es", "umd", "compiled", "dist-esm", "dist-cjs" +]); +const FORMAT_DIRS = new Set(["esm", "cjs", "es", "umd", "types", "typings", "dts", "node", "browser"]); + +// Where a built package keeps the sources its output came from. +const SOURCE_DIRS = ["src", "source", ""]; + +// Earlier conditions are closer to the source a reader wants to land on. +const CONDITION_ORDER = [ + "source", "development", "import", "module", "default", "require", "node", "browser", "types" +]; + +export function readPackageManifests(files: ScannedFile[]): PackageManifest[] { + const manifests: PackageManifest[] = []; + + for (const file of files) { + if (file.path !== "package.json" && !file.path.endsWith("/package.json")) continue; + if (!isArchitectureSource(file.path)) continue; + + let parsed: Record; + try { + const value = parseJsonText(file.content); + if (!value || typeof value !== "object" || Array.isArray(value)) continue; + parsed = value as Record; + } catch { + continue; + } + + const dir = file.path === "package.json" ? "" : file.path.slice(0, -"/package.json".length); + manifests.push({ + dir, + name: typeof parsed.name === "string" && parsed.name.length > 0 ? parsed.name : undefined, + private: parsed.private === true, + main: stringField(parsed.main), + module: stringField(parsed.module), + types: stringField(parsed.types) ?? stringField(parsed.typings), + source: stringField(parsed.source), + bin: binTargets(parsed.bin), + exports: parsed.exports, + imports: isRecord(parsed.imports) ? parsed.imports : undefined, + scripts: stringRecord(parsed.scripts) + }); + } + + return manifests.sort((a, b) => a.dir.localeCompare(b.dir)); +} + +export function indexPackages(manifests: PackageManifest[]): PackageIndex { + const byName = new Map(); + for (const manifest of manifests) { + // Sorted by directory, so the shallowest package wins a duplicate name. + if (manifest.name && !byName.has(manifest.name)) byName.set(manifest.name, manifest); + } + return { manifests, byName }; +} + +/** + * Resolve `@scope/pkg` or `@scope/pkg/sub/path` to a scanned file when the + * package is part of this project (a workspace member). Registry packages are + * not in the scan, so they never match and stay skipped. + */ +export function resolveWorkspaceSpecifier( + specifier: string, + index: PackageIndex, + localPaths: Set +): string | null { + const split = splitPackageSpecifier(specifier); + if (!split) return null; + + const manifest = index.byName.get(split.name); + if (!manifest) return null; + + if (split.subpath === ".") return resolvePackageRoot(manifest, localPaths); + + const exported = resolveExportsField(manifest, split.subpath, localPaths); + if (exported) return exported; + + // Not (or not only) described by `exports`: fall back to the file layout. + const relativePath = split.subpath.slice(2); + for (const base of ["", "src/"]) { + const found = matchLocalPath(joinPath(manifest.dir, `${base}${relativePath}`), localPaths); + if (found) return found; + } + return null; +} + +/** `#internal/db` -> the file declared by the nearest package.json `imports`. */ +export function resolveSubpathImport( + specifier: string, + fromFile: string, + index: PackageIndex, + localPaths: Set +): string | null { + if (!specifier.startsWith("#")) return null; + + const manifest = nearestManifest(fromFile, index.manifests, (candidate) => candidate.imports !== undefined); + if (!manifest?.imports) return null; + + const match = matchSubpathMap(manifest.imports, specifier); + if (!match) return null; + + for (const target of leafTargets(match.value, match.wildcard)) { + const resolved = resolveManifestTarget(target, manifest.dir, localPaths); + if (resolved) return resolved; + } + return null; +} + +/** Files a manifest declares as runnable or importable entries. */ +export function manifestEntryPoints(manifests: PackageManifest[], localPaths: Set): ManifestEntry[] { + const entries: ManifestEntry[] = []; + const seen = new Set(); + + const add = (path: string | null, kind: ManifestEntry["kind"]) => { + if (!path || seen.has(path) || isConfigFile(path)) return; + seen.add(path); + entries.push({ path, kind }); + }; + + for (const manifest of manifests) { + for (const bin of manifest.bin) { + add(resolveManifestTarget(bin, manifest.dir, localPaths), "bin"); + } + + // An internal workspace package's root file is shared plumbing for its + // siblings, not a place execution begins. The project root and published + // packages do expose theirs. + if (!manifest.private || manifest.dir === "") { + add(resolvePackageRoot(manifest, localPaths, false), "main"); + } + + for (const command of Object.values(manifest.scripts)) { + for (const token of scriptFileTokens(command)) { + add(resolveManifestTarget(token, manifest.dir, localPaths), "script"); + } + } + } + + return entries; +} + +// ------------------------------------------------------------------ internals + +/** + * The file `import ""` lands on. `guess` also tries the usual + * `src/index` layouts when the manifest declares nothing; entry detection + * leaves that off so only what the package states counts as its entry. + */ +function resolvePackageRoot( + manifest: PackageManifest, + localPaths: Set, + guess = true +): string | null { + const exported = resolveExportsField(manifest, ".", localPaths); + if (exported) return exported; + + for (const field of [manifest.source, manifest.module, manifest.main, manifest.types]) { + if (!field) continue; + const resolved = resolveManifestTarget(field, manifest.dir, localPaths); + if (resolved) return resolved; + } + + if (!guess) return null; + + for (const fallback of ["src/index", "index", "src/main", "main"]) { + const found = matchLocalPath(joinPath(manifest.dir, fallback), localPaths); + if (found) return found; + } + return null; +} + +function resolveExportsField( + manifest: PackageManifest, + subpath: string, + localPaths: Set +): string | null { + if (manifest.exports === undefined || manifest.exports === null) return null; + + const match = matchSubpathMap(normalizeExports(manifest.exports), subpath); + if (!match) return null; + + for (const target of leafTargets(match.value, match.wildcard)) { + const resolved = resolveManifestTarget(target, manifest.dir, localPaths); + if (resolved) return resolved; + } + return null; +} + +/** + * Resolve a path written in a manifest (relative to the package directory) to + * a scanned file. Built output (`dist/index.mjs`, `dist/types/index.d.ts`) is + * mapped back to the source it came from, since only sources are scanned. + */ +export function resolveManifestTarget( + target: string, + packageDir: string, + localPaths: Set +): string | null { + // `imports`/`exports` may name another package (`"#dep": "lodash"`). + if (/^[a-z][a-z0-9+.-]*:/i.test(target)) return null; + + const joined = normalizePath(joinPath(packageDir, target)); + if (joined === null) return null; + if (packageDir !== "" && joined !== packageDir && !joined.startsWith(`${packageDir}/`)) return null; + + const direct = matchLocalPath(joined, localPaths); + if (direct) return direct; + + return resolveBuildOutput(joined, packageDir, localPaths); +} + +function resolveBuildOutput(joined: string, packageDir: string, localPaths: Set): string | null { + const relativePath = packageDir === "" ? joined : joined.slice(packageDir.length + 1); + const segments = relativePath.split("/"); + const buildIndex = segments.findIndex((segment) => BUILD_DIRS.has(segment)); + + const prefix = buildIndex === -1 ? [] : segments.slice(0, buildIndex); + const rest = buildIndex === -1 ? [...segments] : segments.slice(buildIndex + 1); + while (rest.length > 1 && FORMAT_DIRS.has(rest[0])) rest.shift(); + if (rest.length === 0) return null; + + const base = rest.join("/").replace(/\.d\.[cm]?ts$|\.[cm]?jsx?$/, ""); + if (base === "") return null; + + for (const sourceDir of SOURCE_DIRS) { + const candidate = [packageDir, ...prefix, sourceDir, base].filter((part) => part !== "").join("/"); + const found = matchLocalPath(candidate, localPaths); + if (found) return found; + } + return null; +} + +function normalizeExports(value: unknown): Record { + if (isRecord(value)) { + const keys = Object.keys(value); + // `{ "import": ..., "default": ... }` is a condition map for ".". + if (keys.length > 0 && keys.every((key) => key.startsWith("."))) return value; + } + return { ".": value }; +} + +/** Exact key first, then the longest `prefix*suffix` pattern. */ +function matchSubpathMap( + map: Record, + subpath: string +): { value: unknown; wildcard?: string } | null { + if (Object.prototype.hasOwnProperty.call(map, subpath)) return { value: map[subpath] }; + + let best: { value: unknown; wildcard: string; length: number } | null = null; + for (const [key, value] of Object.entries(map)) { + const star = key.indexOf("*"); + if (star === -1) continue; + + const prefix = key.slice(0, star); + const suffix = key.slice(star + 1); + if (subpath.length < key.length - 1) continue; + if (!subpath.startsWith(prefix) || !subpath.endsWith(suffix)) continue; + + const wildcard = subpath.slice(prefix.length, subpath.length - suffix.length); + if (!best || prefix.length > best.length) best = { value, wildcard, length: prefix.length }; + } + + return best ? { value: best.value, wildcard: best.wildcard } : null; +} + +/** Every string target of a conditional export, best condition first. */ +function leafTargets(value: unknown, wildcard?: string): string[] { + const targets: string[] = []; + + const visit = (node: unknown) => { + if (typeof node === "string") { + targets.push(wildcard === undefined ? node : node.replace(/\*/g, wildcard)); + } else if (Array.isArray(node)) { + node.forEach(visit); + } else if (isRecord(node)) { + const rank = (condition: string) => { + const position = CONDITION_ORDER.indexOf(condition); + return position === -1 ? CONDITION_ORDER.length : position; + }; + // Array.prototype.sort is stable: unknown conditions keep declared order. + for (const condition of Object.keys(node).sort((a, b) => rank(a) - rank(b))) { + visit(node[condition]); + } + } + }; + + visit(value); + return targets; +} + +function splitPackageSpecifier(specifier: string): { name: string; subpath: string } | null { + if (specifier.startsWith(".") || specifier.startsWith("/") || specifier.startsWith("#")) return null; + + const parts = specifier.split("/"); + const nameLength = specifier.startsWith("@") ? 2 : 1; + if (parts.length < nameLength || parts.slice(0, nameLength).some((part) => part === "")) return null; + + const name = parts.slice(0, nameLength).join("/"); + const rest = parts.slice(nameLength).join("/"); + return { name, subpath: rest === "" ? "." : `./${rest}` }; +} + +function nearestManifest( + file: string, + manifests: PackageManifest[], + accept: (manifest: PackageManifest) => boolean +): PackageManifest | undefined { + let best: PackageManifest | undefined; + for (const manifest of manifests) { + if (!accept(manifest)) continue; + const contains = manifest.dir === "" || file.startsWith(`${manifest.dir}/`); + if (contains && (!best || manifest.dir.length > best.dir.length)) best = manifest; + } + return best; +} + +// File-looking tokens in a script: `tsx src/server.ts`, `node dist/index.js`, +// `tsup src/index.ts --format esm`. Globs and flags never match. +function scriptFileTokens(command: string): string[] { + const tokens: string[] = []; + const pattern = /(?:^|[\s"'=:])((?:\.{1,2}\/)?[\w@.\-/]+\.(?:[cm]?[jt]sx?))(?=$|[\s"';&|])/g; + + let match = pattern.exec(command); + while (match) { + const token = match[1]; + if (!token.includes("node_modules/") && !token.startsWith("/")) tokens.push(token); + match = pattern.exec(command); + } + return tokens; +} + +function isConfigFile(path: string): boolean { + return /(^|\/)[\w.-]*\.(?:config|setup)\.[cm]?[jt]s$/.test(path) || /(^|\/)\.[\w.-]+rc\.[cm]?[jt]s$/.test(path); +} + +function joinPath(directory: string, path: string): string { + return directory === "" ? path : `${directory}/${path}`; +} + +function binTargets(value: unknown): string[] { + if (typeof value === "string") return [value]; + if (isRecord(value)) return Object.values(value).filter((item): item is string => typeof item === "string"); + return []; +} + +function stringField(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function stringRecord(value: unknown): Record { + if (!isRecord(value)) return {}; + return Object.fromEntries( + Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === "string") + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/cli/src/analyzers/pipeline/projectMap.ts b/packages/cli/src/analyzers/pipeline/projectMap.ts index 31c61fa..3b9a1d6 100644 --- a/packages/cli/src/analyzers/pipeline/projectMap.ts +++ b/packages/cli/src/analyzers/pipeline/projectMap.ts @@ -16,6 +16,8 @@ import { isArchitectureSource, isTestPath, loadAliasMappings, + manifestEntryPoints, + readPackageManifests, type DependencyGraphDiagnostics, } from "../graph/index.js"; import { @@ -195,7 +197,10 @@ export async function createProjectMap( frameworks ); const framework = project.framework; - const entryPoints = detectEntryPoints(graph); + const entryPoints = detectEntryPoints( + graph, + manifestEntryPoints(readPackageManifests(files), new Set(files.map((file) => file.path))) + ); const routes = detectRoutes(files, project.frameworks, graph); const database = detectDatabase(files); activeSpinner.succeed("Dependencies mapped"); diff --git a/packages/cli/src/commands/analyze.ts b/packages/cli/src/commands/analyze.ts index 55c9cb9..27bd84f 100644 --- a/packages/cli/src/commands/analyze.ts +++ b/packages/cli/src/commands/analyze.ts @@ -188,6 +188,9 @@ function printSnapshot( if (snapshot.database) { output.section("Database"); output.item(snapshot.database.provider); + for (const other of snapshot.database.additional ?? []) { + output.item(other); + } } } diff --git a/packages/cli/test/js-ts-ecosystem.test.ts b/packages/cli/test/js-ts-ecosystem.test.ts new file mode 100644 index 0000000..0f13ba9 --- /dev/null +++ b/packages/cli/test/js-ts-ecosystem.test.ts @@ -0,0 +1,576 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { scanFiles } from "../src/analyzers/analysis/fileScanner.js"; +import { TsMorphAnalyzer } from "../src/analyzers/analysis/tsMorphAnalyzer.js"; +import type { ScannedFile } from "../src/analyzers/analysis/index.js"; +import { detectDatabase } from "../src/analyzers/detectors/databaseDetector.js"; +import { + buildDependencyGraph, + detectEntryPoints, + loadAliasMappings, + manifestEntryPoints, + readPackageManifests +} from "../src/analyzers/graph/index.js"; +import { componentName } from "../src/analyzers/graph/nuxtAutoImports.js"; +import { analyzeFiles } from "../src/analyzers/pipeline/analyzerRegistry.js"; +import { createProjectMap } from "../src/analyzers/pipeline/projectMap.js"; + +async function withProject( + files: Record, + run: (root: string) => Promise +): Promise { + const root = await mkdtemp(join(tmpdir(), "devmap-jsts-")); + try { + for (const [path, content] of Object.entries(files)) { + const target = join(root, path); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, content, "utf8"); + } + await run(root); + } finally { + await rm(root, { recursive: true, force: true }); + } +} + +async function analyze(root: string) { + const files = await scanFiles(root); + const analyses = await analyzeFiles(files); + const mappings = await loadAliasMappings(root); + const { graph, diagnostics } = buildDependencyGraph(files, analyses, mappings); + return { files, graph, diagnostics }; +} + +async function entriesOf(root: string): Promise { + const { files, graph } = await analyze(root); + const localPaths = new Set(files.map((file) => file.path)); + return detectEntryPoints(graph, manifestEntryPoints(readPackageManifests(files), localPaths)); +} + +const pkg = (value: Record) => JSON.stringify(value); + +// ------------------------------------------------------------ workspace imports + +test("a workspace package imported by name resolves through its exports", async () => { + await withProject({ + "package.json": pkg({ name: "mono", private: true, workspaces: ["packages/*", "apps/*"] }), + "packages/ui/package.json": pkg({ + name: "@acme/ui", + exports: { ".": "./src/index.ts", "./button": "./src/button.tsx" } + }), + "packages/ui/src/index.ts": 'export * from "./button";\n', + "packages/ui/src/button.tsx": "export const Button = () => null;\n", + "apps/web/package.json": pkg({ name: "web", dependencies: { "@acme/ui": "workspace:*" } }), + "apps/web/src/page.tsx": 'import { Button } from "@acme/ui";\nimport { Button as B } from "@acme/ui/button";\nexport default [Button, B];\n' + }, async (root) => { + const { graph, diagnostics } = await analyze(root); + assert.deepEqual( + graph["apps/web/src/page.tsx"].sort(), + ["packages/ui/src/button.tsx", "packages/ui/src/index.ts"] + ); + assert.deepEqual(diagnostics.unresolvedAliases, []); + }); +}); + +test("exports wildcards and conditions pick the source file", async () => { + await withProject({ + "packages/kit/package.json": pkg({ + name: "@acme/kit", + exports: { + ".": { types: "./dist/index.d.ts", import: "./src/index.ts", default: "./dist/index.js" }, + "./utils/*": "./src/utils/*.ts" + } + }), + "packages/kit/src/index.ts": "export const kit = 1;\n", + "packages/kit/src/utils/date.ts": "export const date = 1;\n", + "app/main.ts": 'import { kit } from "@acme/kit";\nimport { date } from "@acme/kit/utils/date";\nexport default [kit, date];\n' + }, async (root) => { + const { graph } = await analyze(root); + assert.deepEqual(graph["app/main.ts"].sort(), ["packages/kit/src/index.ts", "packages/kit/src/utils/date.ts"]); + }); +}); + +test("built output paths in package.json map back to the scanned sources", async () => { + await withProject({ + "packages/core/package.json": pkg({ + name: "@acme/core", + main: "./dist/index.cjs", + module: "./dist/esm/index.mjs", + types: "./dist/types/index.d.ts" + }), + "packages/core/src/index.ts": "export const core = 1;\n", + "packages/legacy/package.json": pkg({ name: "@acme/legacy", main: "lib/index.js" }), + "packages/legacy/src/index.ts": "export const legacy = 1;\n", + "app/main.ts": 'import { core } from "@acme/core";\nimport { legacy } from "@acme/legacy";\nexport default [core, legacy];\n' + }, async (root) => { + const { graph } = await analyze(root); + assert.deepEqual(graph["app/main.ts"].sort(), ["packages/core/src/index.ts", "packages/legacy/src/index.ts"]); + }); +}); + +test("without exports, a workspace subpath falls back to the file layout", async () => { + await withProject({ + "packages/lib/package.json": pkg({ name: "lib" }), + "packages/lib/src/utils/date.ts": "export const d = 1;\n", + "packages/lib/helpers.ts": "export const h = 1;\n", + "app/main.ts": 'import { d } from "lib/utils/date";\nimport { h } from "lib/helpers";\nexport default [d, h];\n' + }, async (root) => { + const { graph } = await analyze(root); + assert.deepEqual(graph["app/main.ts"].sort(), ["packages/lib/helpers.ts", "packages/lib/src/utils/date.ts"]); + }); +}); + +test("registry packages and unknown scopes never become edges or diagnostics", async () => { + await withProject({ + "package.json": pkg({ name: "app", dependencies: { react: "1.0.0" } }), + "src/main.ts": 'import React from "react";\nimport x from "@acme/missing";\nexport default [React, x];\n' + }, async (root) => { + const { graph, diagnostics } = await analyze(root); + assert.deepEqual(graph["src/main.ts"], []); + assert.deepEqual(diagnostics.unresolvedAliases, []); + }); +}); + +test("a tsconfig baseUrl does not hide workspace packages", async () => { + await withProject({ + "tsconfig.json": pkg({ compilerOptions: { baseUrl: "." } }), + "packages/db/package.json": pkg({ name: "@acme/db", exports: "./src/index.ts" }), + "packages/db/src/index.ts": "export const db = 1;\n", + "src/main.ts": 'import { db } from "@acme/db";\nexport default db;\n' + }, async (root) => { + const { graph, diagnostics } = await analyze(root); + assert.deepEqual(graph["src/main.ts"], ["packages/db/src/index.ts"]); + assert.deepEqual(diagnostics.unresolvedAliases, []); + }); +}); + +test("an internal package's own files import it by name", async () => { + await withProject({ + "packages/api/package.json": pkg({ name: "@acme/api", exports: { ".": "./src/index.ts", "./trpc": "./src/trpc.ts" } }), + "packages/api/src/index.ts": 'export * from "@acme/api/trpc";\n', + "packages/api/src/trpc.ts": "export const t = 1;\n" + }, async (root) => { + const { graph } = await analyze(root); + assert.deepEqual(graph["packages/api/src/index.ts"], ["packages/api/src/trpc.ts"]); + }); +}); + +test("a tsconfig in a frontend/ folder next to the root still supplies its aliases", async () => { + await withProject({ + "frontend/tsconfig.json": pkg({ compilerOptions: { paths: { "@/*": ["./src/*"] } } }), + "frontend/src/main.ts": 'import { x } from "@/x";\nexport default x;\n', + "frontend/src/x.ts": "export const x = 1;\n", + "backend/src/main.ts": 'import { y } from "@/y";\nexport default y;\n', + "backend/src/y.ts": "export const y = 1;\n" + }, async (root) => { + const { graph } = await analyze(root); + assert.deepEqual(graph["frontend/src/main.ts"], ["frontend/src/x.ts"]); + // Scoped: the frontend alias does not leak into the backend. + assert.deepEqual(graph["backend/src/main.ts"], []); + }); +}); + +// ------------------------------------------------------------ package.json imports + +test("package.json imports (#subpaths) resolve inside the nearest package", async () => { + await withProject({ + "package.json": pkg({ name: "root", imports: { "#root-only": "./root.ts" } }), + "root.ts": "export const r = 1;\n", + "services/api/package.json": pkg({ + name: "api", + imports: { + "#db/*": { node: "./src/db/*.ts", default: "./src/db/*.ts" }, + "#config": "./src/config.ts", + "#ext": "some-npm-package" + } + }), + "services/api/src/db/client.ts": "export const client = 1;\n", + "services/api/src/config.ts": "export const config = 1;\n", + "services/api/src/main.ts": 'import { client } from "#db/client";\nimport { config } from "#config";\nimport ext from "#ext";\nimport r from "#root-only";\nexport default [client, config, ext, r];\n' + }, async (root) => { + const { graph } = await analyze(root); + // `#root-only` belongs to the root package, not services/api. + assert.deepEqual( + graph["services/api/src/main.ts"].sort(), + ["services/api/src/config.ts", "services/api/src/db/client.ts"] + ); + }); +}); + +test("bundler query suffixes are not part of the file name", async () => { + await withProject({ + "src/main.ts": 'import raw from "./notes.ts?raw";\nimport Worker from "./worker.ts?worker";\nexport default [raw, Worker];\n', + "src/notes.ts": "export default 'n';\n", + "src/worker.ts": "export default 1;\n" + }, async (root) => { + const { graph } = await analyze(root); + assert.deepEqual(graph["src/main.ts"].sort(), ["src/notes.ts", "src/worker.ts"]); + }); +}); + +// ------------------------------------------------------------ entry points + +test("bin, scripts and published main are entries; config files and internal roots are not", async () => { + await withProject({ + "package.json": pkg({ + name: "tool", + bin: { tool: "./dist/cli.js" }, + main: "./dist/index.js", + scripts: { + dev: "tsx watch src/server.ts", + test: "vitest --config vitest.config.ts", + seed: "node scripts/seed.js" + } + }), + "src/cli.ts": 'import { run } from "./run";\nrun();\n', + "src/run.ts": "export const run = () => 1;\n", + "src/index.ts": 'export { run } from "./run";\n', + "src/server.ts": 'import { run } from "./run";\nrun();\n', + "scripts/seed.js": 'const { run } = require("../src/run");\nrun();\n', + "vitest.config.ts": "export default {};\n", + "packages/internal/package.json": pkg({ name: "@t/internal", private: true, main: "./src/index.ts" }), + "packages/internal/src/index.ts": "export const i = 1;\n", + "src/unrelated.ts": 'import { i } from "@t/internal";\nexport default i;\n' + }, async (root) => { + const entries = await entriesOf(root); + assert.equal(entries[0], "src/cli.ts", "bin ranks first"); + assert.ok(entries.includes("src/index.ts"), "published main is an entry"); + assert.ok(entries.includes("src/server.ts"), "script target is an entry"); + assert.ok(entries.includes("scripts/seed.js")); + assert.ok(!entries.includes("vitest.config.ts")); + assert.ok(!entries.includes("packages/internal/src/index.ts")); + }); +}); + +test("a published main that other source files import is plumbing, not an entry", async () => { + await withProject({ + "package.json": pkg({ name: "lib", main: "./src/index.ts" }), + "src/index.ts": 'export * from "./a";\n', + "src/a.ts": 'import { b } from "./b";\nexport const a = b;\n', + "src/b.ts": "export const b = 1;\n", + "src/consumer.ts": 'import { a } from "./index";\nexport default a;\n' + }, async (root) => { + const entries = await entriesOf(root); + assert.ok(!entries.includes("src/index.ts")); + }); +}); + +test("framework-loaded files are entries only while nothing imports them", async () => { + await withProject({ + // Nuxt: the framework mounts app.vue and pages/*.vue. + "nuxt-app/nuxt.config.ts": "export default {};\n", + "nuxt-app/app.vue": "\n", + "nuxt-app/pages/index.vue": "\n", + "nuxt-app/server/api/health.get.ts": "export default () => 'ok';\n", + // Plain Vue SPA: the router imports pages, so they are components. + "spa/package.json": pkg({ name: "spa" }), + "spa/src/router.ts": 'import Home from "./pages/Home.vue";\nexport default [Home];\n', + "spa/src/pages/Home.vue": "\n" + }, async (root) => { + const entries = await entriesOf(root); + assert.ok(entries.includes("nuxt-app/app.vue")); + assert.ok(entries.includes("nuxt-app/pages/index.vue")); + assert.ok(entries.includes("nuxt-app/server/api/health.get.ts")); + assert.ok(!entries.includes("spa/src/pages/Home.vue")); + }); +}); + +// ------------------------------------------------------------ Nuxt + +test("Nuxt 4 aliases point ~ and @ at app/, ~~ at the root", async () => { + await withProject({ + "nuxt.config.ts": "export default defineNuxtConfig({});\n", + "app/app.vue": '\n\n', + "app/utils/a.ts": "export const a = 1;\n", + "app/utils/b.ts": "export const b = 1;\n", + "shared/c.ts": "export const c = 1;\n", + "shared/d.ts": "export const d = 1;\n" + }, async (root) => { + const { graph } = await analyze(root); + assert.deepEqual( + graph["app/app.vue"].sort(), + ["app/utils/a.ts", "app/utils/b.ts", "shared/c.ts", "shared/d.ts"] + ); + }); +}); + +test("Nuxt 3 layout and a custom srcDir keep ~ pointing at the right folder", async () => { + await withProject({ + "classic/nuxt.config.ts": "export default {};\n", + "classic/app.vue": '\n', + "classic/composables/a.ts": "export const a = 1;\n", + "custom/nuxt.config.ts": "export default defineNuxtConfig({ srcDir: 'client/' });\n", + "custom/client/app.vue": '\n', + "custom/client/utils/b.ts": "export const b = 1;\n" + }, async (root) => { + const { graph } = await analyze(root); + assert.deepEqual(graph["classic/app.vue"], ["classic/composables/a.ts"]); + assert.deepEqual(graph["custom/client/app.vue"], ["custom/client/utils/b.ts"]); + }); +}); + +test("Nuxt components are linked from the templates that render them", async () => { + await withProject({ + "nuxt.config.ts": "export default {};\n", + "app/app.vue": [ + "" + ].join("\n"), + "app/components/account/AccountAvatar.vue": "\n", + "app/components/base/Button.vue": "\n", + "app/components/base/Modal.vue": "\n", + "app/components/tabs/index.vue": "\n", + "app/components/Unused.vue": "\n" + }, async (root) => { + const { graph } = await analyze(root); + assert.deepEqual(graph["app/app.vue"].sort(), [ + "app/components/account/AccountAvatar.vue", + "app/components/base/Button.vue", + "app/components/base/Modal.vue", + "app/components/tabs/index.vue" + ]); + }); +}); + +test("a Nuxt component named in a script (not just a template) is linked too", async () => { + await withProject({ + "nuxt.config.ts": "export default {};\n", + "app/components/nav/Bar.vue": '\n\n', + "app/components/nav/button/Home.vue": "\n" + }, async (root) => { + const { graph } = await analyze(root); + assert.deepEqual(graph["app/components/nav/Bar.vue"], ["app/components/nav/button/Home.vue"]); + }); +}); + +test("Nuxt composables, utils, shared and server utils follow their own visibility", async () => { + await withProject({ + "nuxt.config.ts": "export default {};\n", + "app/composables/useUser.ts": "export const useUser = () => 1;\nexport default function useFallback() {}\n", + "app/composables/auth/index.ts": "export const useAuth = () => 1;\n", + "app/composables/deep/nested.ts": "export const useNested = () => 1;\n", + "app/utils/format.ts": "export const formatDate = (d: Date) => d.toISOString();\n", + "shared/utils/slug.ts": "export const slugify = (s: string) => s;\n", + "server/utils/db.ts": "export const useDb = () => 1;\n", + "app/pages/index.vue": [ + "" + ].join("\n"), + "server/api/me.get.ts": "export default defineEventHandler(() => [useDb(), slugify('x'), useUser(), formatDate(new Date())]);\n" + }, async (root) => { + const { graph } = await analyze(root); + assert.deepEqual(graph["app/pages/index.vue"].sort(), [ + "app/composables/auth/index.ts", + "app/composables/useUser.ts", + "app/utils/format.ts", + "shared/utils/slug.ts" + ]); + // The server sees server/utils and shared/, never the app's composables. + assert.deepEqual(graph["server/api/me.get.ts"].sort(), ["server/utils/db.ts", "shared/utils/slug.ts"]); + }); +}); + +test("a Nuxt helper must be called, so member access and locals do not link", async () => { + await withProject({ + "nuxt.config.ts": "export default {};\n", + "app/utils/format.ts": "export const format = (v: string) => v;\n", + "app/pages/a.vue": "\n", + "app/pages/b.vue": "\n", + "app/pages/c.vue": "\n" + }, async (root) => { + const { graph } = await analyze(root); + assert.deepEqual(graph["app/pages/a.vue"], []); + assert.deepEqual(graph["app/pages/b.vue"], []); + assert.deepEqual(graph["app/pages/c.vue"], ["app/utils/format.ts"]); + }); +}); + +test("auto-import edges never appear in projects that are not Nuxt", async () => { + await withProject({ + "package.json": pkg({ name: "plain" }), + "app/composables/useThing.ts": "export const useThing = () => 1;\n", + "app/main.ts": "export const x = useThing();\n" + }, async (root) => { + const { graph } = await analyze(root); + assert.deepEqual(graph["app/main.ts"], []); + }); +}); + +test("an explicit import of an auto-imported file is not duplicated", async () => { + await withProject({ + "nuxt.config.ts": "export default {};\n", + "app/composables/useThing.ts": "export const useThing = () => 1;\n", + "app/pages/index.vue": '\n' + }, async (root) => { + const { graph } = await analyze(root); + assert.deepEqual(graph["app/pages/index.vue"], ["app/composables/useThing.ts"]); + }); +}); + +test("Nuxt component names follow the folder path and drop repeated prefixes", () => { + assert.equal(componentName("account/AccountAvatar.vue"), "AccountAvatar"); + assert.equal(componentName("base/Button.vue"), "BaseButton"); + assert.equal(componentName("base/form/Input.vue"), "BaseFormInput"); + assert.equal(componentName("nav/button/Home.vue"), "NavButtonHome"); + assert.equal(componentName("tabs/index.vue"), "Tabs"); + assert.equal(componentName("Card.client.vue"), "Card"); + assert.equal(componentName("index.vue"), null); +}); + +// ------------------------------------------------------------ database + +const manifestFile = (dependencies: Record): ScannedFile => ({ + path: "package.json", + absolutePath: "/p/package.json", + extension: ".json", + size: 0, + lines: 1, + content: pkg({ dependencies }) +}); + +const sourceFile = (path: string): ScannedFile => ({ + path, + absolutePath: `/p/${path}`, + extension: path.slice(path.lastIndexOf(".")), + size: 0, + lines: 1, + content: "" +}); + +test("a models/ folder alone is not Mongoose", () => { + assert.equal(detectDatabase([sourceFile("src/models/user.ts"), sourceFile("src/schemas/order.ts")]), undefined); + + const withDependency = detectDatabase([ + manifestFile({ mongoose: "^8.0.0" }), + sourceFile("src/models/user.ts"), + sourceFile("src/schemas/order.ts") + ]); + assert.equal(withDependency?.provider, "Mongoose"); + assert.deepEqual(withDependency?.files, ["src/models/user.ts", "src/schemas/order.ts"]); +}); + +test("a primary data layer is reported together with the other stores", () => { + assert.deepEqual( + detectDatabase([manifestFile({ "@prisma/client": "^6", ioredis: "^5", "@supabase/supabase-js": "^2" })]), + { provider: "Prisma", files: [], additional: ["Supabase", "Redis"] } + ); +}); + +test("a driver under an ORM is not reported as a second database", () => { + assert.deepEqual( + detectDatabase([manifestFile({ "drizzle-orm": "^0.4", postgres: "^3" })]), + { provider: "Drizzle", files: [] } + ); + assert.deepEqual( + detectDatabase([manifestFile({ pg: "^8", mysql2: "^3" })]), + { provider: "PostgreSQL (raw SQL)", files: [], additional: ["MySQL (raw SQL)"] } + ); +}); + +test("Redis is listed beside a database but never reported as the database itself", () => { + assert.equal(detectDatabase([manifestFile({ ioredis: "^5" })]), undefined); + assert.deepEqual( + detectDatabase([manifestFile({ ioredis: "^5", pg: "^8" })]), + { provider: "PostgreSQL (raw SQL)", files: [], additional: ["Redis"] } + ); +}); + +test("more ORMs and serverless drivers are recognized", () => { + for (const [dependency, provider] of [ + ["typeorm", "TypeORM"], + ["@nestjs/typeorm", "TypeORM"], + ["sequelize", "Sequelize"], + ["@mikro-orm/core", "MikroORM"], + ["kysely", "Kysely"], + ["knex", "Knex"], + ["convex", "Convex"], + ["firebase-admin", "Firebase"], + ["mongodb", "MongoDB (native driver)"], + ["@neondatabase/serverless", "PostgreSQL (raw SQL)"], + ["@libsql/client", "SQLite (raw SQL)"] + ] as const) { + assert.equal(detectDatabase([manifestFile({ [dependency]: "1.0.0" })])?.provider, provider, dependency); + } +}); + +// ------------------------------------------------------------ CommonJS + +async function analyzeScript(code: string) { + return new TsMorphAnalyzer().analyze( + { path: "lib/x.js", absolutePath: "/p/lib/x.js", extension: ".js", size: code.length, lines: 1, content: code }, + {} as never + ); +} + +test("module.exports = { ... } exposes its keys as exports", async () => { + const analysis = await analyzeScript([ + "function helper() {}", + "async function load() {}", + "module.exports = { helper, load, run() {}, other: async () => 1 };" + ].join("\n")); + + assert.deepEqual(analysis.exports, ["helper", "load", "other", "run"]); + const bySymbol = Object.fromEntries(analysis.symbols.map((symbol) => [symbol.name, symbol])); + assert.equal(bySymbol.helper.exported, true); + assert.equal(bySymbol.load.async, true); + assert.equal(bySymbol.other.kind, "function"); + assert.equal(bySymbol.other.async, true); +}); + +test("exports.x / module.exports.x assignments are exports", async () => { + const analysis = await analyzeScript([ + "exports.parse = function () {};", + "module.exports.render = async () => {};", + "exports['kebab-name'] = 1;" + ].join("\n")); + + assert.deepEqual(analysis.exports, ["kebab-name", "parse", "render"]); +}); + +test("module.exports = value is the default export and marks a local binding exported", async () => { + const analysis = await analyzeScript("const router = {};\nmodule.exports = router;\n"); + assert.deepEqual(analysis.exports, ["default"]); + assert.equal(analysis.symbols.find((symbol) => symbol.name === "router")?.exported, true); +}); + +test("ES module files are unaffected by the CommonJS reader", async () => { + const analysis = await analyzeScript("export const a = 1;\nexport default function main() {}\n"); + assert.deepEqual(analysis.exports, ["a", "default"]); +}); + +// ------------------------------------------------------------ end to end + +test("a workspace monorepo shows cross-package edges and its real entry points", async () => { + await withProject({ + "package.json": pkg({ name: "mono", private: true, workspaces: ["apps/*", "packages/*"] }), + "pnpm-workspace.yaml": "packages:\n - apps/*\n - packages/*\n", + "apps/web/package.json": pkg({ + name: "@m/web", + private: true, + dependencies: { next: "15.0.0", react: "19.0.0", "@m/ui": "workspace:*" } + }), + "apps/web/src/app/page.tsx": 'import { Button } from "@m/ui";\nexport default function Page() { return