From 06db2c918d36cb6ee69766319859116a6202d772 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 06:54:54 +0800 Subject: [PATCH 1/3] fix(graph): resolve real-world tsconfig aliases and complete the import graph Alias loading used plain JSON.parse on the root tsconfig only, so JSONC configs, `extends`, `baseUrl`, project `references`, workspace sub-package configs and SvelteKit's $lib were all silently dropped, and an exact alias (`@config`) swallowed longer specifiers (`@configuration/x`). - aliasResolver: parse JSONC via ts.parseConfigFileTextToJson, follow extends/references, honour baseUrl, scope mappings to the declaring config, longest-pattern-wins, exact aliases, $lib default for SvelteKit - dependencyGraph: dedupe edges, drop self-edges, dynamic import(), .jsx/.mts/ .cts resolution, relative imports above the root no longer match root files - tsMorphAnalyzer: dynamic import(), import = require(), import() types, destructured const names, overload signatures listed once - filterEngine: nested .gitignore, case-insensitive extensions, .env matching by file name instead of prefix, cache cleared per scan Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_019HJc4sv9pSLh7vhduSUxBE --- .../cli/src/analyzers/analysis/fileScanner.ts | 5 +- .../src/analyzers/analysis/filterEngine.ts | 89 +++-- .../src/analyzers/analysis/tsMorphAnalyzer.ts | 59 +++- .../cli/src/analyzers/graph/aliasResolver.ts | 304 +++++++++++++++-- .../src/analyzers/graph/dependencyGraph.ts | 112 +++---- packages/cli/src/analyzers/graph/index.ts | 2 + packages/cli/test/graph-resolution.test.ts | 313 ++++++++++++++++++ 7 files changed, 744 insertions(+), 140 deletions(-) create mode 100644 packages/cli/test/graph-resolution.test.ts diff --git a/packages/cli/src/analyzers/analysis/fileScanner.ts b/packages/cli/src/analyzers/analysis/fileScanner.ts index 0ddb76e..99e0cb8 100644 --- a/packages/cli/src/analyzers/analysis/fileScanner.ts +++ b/packages/cli/src/analyzers/analysis/fileScanner.ts @@ -2,7 +2,7 @@ import type { Dirent, Stats } from "node:fs"; import { readdir, readFile, stat } from "node:fs/promises"; import { extname, join, relative } from "node:path"; import pLimit from "p-limit"; -import { shouldIgnorePath } from "./filterEngine.js"; +import { clearGitignoreCache, shouldIgnorePath } from "./filterEngine.js"; export type ScannedFile = { path: string; @@ -17,6 +17,9 @@ export async function scanFiles(projectRoot: string): Promise { const files: ScannedFile[] = []; const limit = pLimit(50); + // Ignore files are cached per process; re-read them for every scan. + clearGitignoreCache(); + // The limiter only guards actual filesystem I/O (readdir, stat + readFile). // visit() must NOT run inside limit(): a directory task that awaits its // children while holding a slot starves those children once 50 directories diff --git a/packages/cli/src/analyzers/analysis/filterEngine.ts b/packages/cli/src/analyzers/analysis/filterEngine.ts index 3edbad8..f9b33bc 100644 --- a/packages/cli/src/analyzers/analysis/filterEngine.ts +++ b/packages/cli/src/analyzers/analysis/filterEngine.ts @@ -58,41 +58,75 @@ const IGNORED_FILES = new Set([ "bun.lockb" ]); -const gitignoreCache = new Map>(); +type IgnoreMatcher = ReturnType; -function loadGitignore(projectRoot: string): void { - const resolvedRoot = path.resolve(projectRoot); - if (gitignoreCache.has(resolvedRoot)) return; +// One matcher per directory that holds a .gitignore (null = checked, none). +const gitignoreCache = new Map(); +const rootMatcherCache = new Map(); - const ig = ignore(); +/** Forget cached ignore files, e.g. before a fresh scan of an edited project. */ +export function clearGitignoreCache(): void { + gitignoreCache.clear(); + rootMatcherCache.clear(); +} + +function readIgnoreFile(directory: string): IgnoreMatcher | null { + const cached = gitignoreCache.get(directory); + if (cached !== undefined) return cached; - const gitignorePath = path.join(resolvedRoot, ".gitignore"); + let matcher: IgnoreMatcher | null = null; try { - ig.add(fs.readFileSync(gitignorePath, "utf8")); + matcher = ignore().add(fs.readFileSync(path.join(directory, ".gitignore"), "utf8")); } catch { - // no .gitignore + // no .gitignore in this directory } - const excludePath = path.join(resolvedRoot, ".git", "info", "exclude"); - try { - ig.add(fs.readFileSync(excludePath, "utf8")); - } catch { - // no .git/info/exclude + gitignoreCache.set(directory, matcher); + return matcher; +} + +// Root rules: .git/info/exclude has the lowest precedence, then the root .gitignore. +function getRootMatcher(resolvedRoot: string): IgnoreMatcher { + const cached = rootMatcherCache.get(resolvedRoot); + if (cached) return cached; + + const ig = ignore(); + for (const ruleFile of [path.join(".git", "info", "exclude"), ".gitignore"]) { + try { + ig.add(fs.readFileSync(path.join(resolvedRoot, ruleFile), "utf8")); + } catch { + // rule file not present + } } - gitignoreCache.set(resolvedRoot, ig); + rootMatcherCache.set(resolvedRoot, ig); + return ig; } -function getGitignore(projectRoot: string): ReturnType { - const resolvedRoot = path.resolve(projectRoot); - if (!gitignoreCache.has(resolvedRoot)) { - loadGitignore(resolvedRoot); +// Git applies each .gitignore relative to its own directory, and rules in a +// deeper file override rules from the directories above it. +function isGitIgnored(p: string, isDirectory: boolean, resolvedRoot: string): boolean { + const target = isDirectory ? `${p}/` : p; + const rootResult = getRootMatcher(resolvedRoot).test(target); + let ignored = rootResult.ignored && !rootResult.unignored; + + const segments = p.split("/"); + for (let depth = 1; depth < segments.length; depth += 1) { + const matcher = readIgnoreFile(path.join(resolvedRoot, ...segments.slice(0, depth))); + if (!matcher) continue; + + const result = matcher.test(`${segments.slice(depth).join("/")}${isDirectory ? "/" : ""}`); + if (result.ignored) ignored = true; + else if (result.unignored) ignored = false; } - return gitignoreCache.get(resolvedRoot)!; + + return ignored; } +const ENV_FILE_SEGMENT = /^\.env(\.|$)/; + export function shouldIgnorePath(p: string, isDirectory: boolean, projectRoot?: string): boolean { - const resolvedRoot = projectRoot ?? process.cwd(); + const resolvedRoot = path.resolve(projectRoot ?? process.cwd()); const segments = p.split("/"); if (segments.some((segment) => IGNORED_DIRECTORIES.has(segment))) { @@ -103,22 +137,19 @@ export function shouldIgnorePath(p: string, isDirectory: boolean, projectRoot?: return true; } - if (p.startsWith(".env") || p.includes("/.env")) { - return true; - } - - if (p.includes("public/assets/") || p.endsWith(".min.js") || p.endsWith(".min.ts")) { + // .env, .env.local, ... but not look-alikes such as .envrc or .environment.ts + if (segments.some((segment) => ENV_FILE_SEGMENT.test(segment))) { return true; } - if (!isDirectory && [...IGNORED_EXTENSIONS].some((ext) => p.endsWith(ext))) { + const lowerPath = p.toLowerCase(); + if (/(^|\/)public\/assets\//.test(lowerPath) || lowerPath.endsWith(".min.js") || lowerPath.endsWith(".min.ts")) { return true; } - const ig = getGitignore(resolvedRoot); - if (ig.ignores(isDirectory ? `${p}/` : p)) { + if (!isDirectory && [...IGNORED_EXTENSIONS].some((ext) => lowerPath.endsWith(ext))) { return true; } - return false; + return isGitIgnored(p, isDirectory, resolvedRoot); } diff --git a/packages/cli/src/analyzers/analysis/tsMorphAnalyzer.ts b/packages/cli/src/analyzers/analysis/tsMorphAnalyzer.ts index 8c7b166..58e8d25 100644 --- a/packages/cli/src/analyzers/analysis/tsMorphAnalyzer.ts +++ b/packages/cli/src/analyzers/analysis/tsMorphAnalyzer.ts @@ -94,12 +94,29 @@ export class TsMorphAnalyzer implements FileAnalyzer { if (specifier) imports.add(specifier); } for (const call of sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) { - if (call.getExpression().getText() !== "require") continue; + const callee = call.getExpression(); + // require("x") and dynamic import("x"); lazy routes and code splitting + // are only reachable through the latter. + if (callee.getKind() !== SyntaxKind.ImportKeyword && callee.getText() !== "require") continue; const argument = call.getArguments()[0]; - if (argument && Node.isStringLiteral(argument)) { + if (argument && (Node.isStringLiteral(argument) || Node.isNoSubstitutionTemplateLiteral(argument))) { imports.add(argument.getLiteralValue()); } } + for (const declaration of sourceFile.getDescendantsOfKind(SyntaxKind.ImportEqualsDeclaration)) { + const reference = declaration.getModuleReference(); + if (Node.isExternalModuleReference(reference)) { + const expression = reference.getExpression(); + if (expression && Node.isStringLiteral(expression)) imports.add(expression.getLiteralValue()); + } + } + for (const importType of sourceFile.getDescendantsOfKind(SyntaxKind.ImportType)) { + const argument = importType.getArgument(); + if (Node.isLiteralTypeNode(argument)) { + const literal = argument.getLiteral(); + if (Node.isStringLiteral(literal)) imports.add(literal.getLiteralValue()); + } + } // --- Exports --- const exports = [...sourceFile.getExportedDeclarations().keys()].sort(); @@ -109,6 +126,8 @@ export class TsMorphAnalyzer implements FileAnalyzer { for (const statement of sourceFile.getStatements()) { if (Node.isFunctionDeclaration(statement) && statement.getName()) { + // Overload signatures share the implementation's name; list it once. + if (statement.isOverload()) continue; symbols.push({ name: statement.getNameOrThrow(), kind: "function", @@ -128,6 +147,7 @@ export class TsMorphAnalyzer implements FileAnalyzer { exported: classExported }); for (const method of statement.getMethods()) { + if (method.isOverload()) continue; symbols.push({ name: method.getName(), kind: "method", @@ -160,17 +180,21 @@ export class TsMorphAnalyzer implements FileAnalyzer { ) { for (const declaration of statement.getDeclarations()) { const initializer = declaration.getInitializer(); - symbols.push({ - name: declaration.getName(), - kind: "const", - line: declaration.getStartLineNumber(), - exported: statement.isExported(), - async: Boolean( - initializer - && (Node.isArrowFunction(initializer) || Node.isFunctionExpression(initializer)) - && initializer.isAsync() - ) - }); + const isAsyncFunction = Boolean( + initializer + && (Node.isArrowFunction(initializer) || Node.isFunctionExpression(initializer)) + && initializer.isAsync() + ); + // `const { a, b } = x` declares a and b, not a symbol named "{ a, b }". + for (const name of declaredNames(declaration.getNameNode())) { + symbols.push({ + name, + kind: "const", + line: declaration.getStartLineNumber(), + exported: statement.isExported(), + async: isAsyncFunction + }); + } } } } @@ -207,6 +231,15 @@ export class TsMorphAnalyzer implements FileAnalyzer { // Helpers // --------------------------------------------------------------------------- +function declaredNames(nameNode: Node): string[] { + if (Node.isIdentifier(nameNode)) return [nameNode.getText()]; + return nameNode + .getDescendantsOfKind(SyntaxKind.BindingElement) + .map((element) => element.getNameNode()) + .filter((node) => Node.isIdentifier(node)) + .map((node) => node.getText()); +} + function replaceExtension(filePath: string, newExt: string): string { const lastDot = filePath.lastIndexOf("."); if (lastDot === -1) return filePath + newExt; diff --git a/packages/cli/src/analyzers/graph/aliasResolver.ts b/packages/cli/src/analyzers/graph/aliasResolver.ts index b1d2ada..913516d 100644 --- a/packages/cli/src/analyzers/graph/aliasResolver.ts +++ b/packages/cli/src/analyzers/graph/aliasResolver.ts @@ -1,59 +1,293 @@ -import { readFile } from "node:fs/promises"; -import { join } from "node:path"; +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"; export interface AliasMapping { + /** Specifier prefix (`@/`), or the whole specifier when `exact` is set. */ prefix: string; + /** Project-root-relative target. Directory targets end in `/`. */ target: string; + /** Alias declared without a wildcard (`"@config": ["./config.ts"]`). */ + exact?: boolean; + /** + * Directory (project-root-relative, `""` for the root) of the tsconfig that + * declared this mapping. It only applies to files below that directory. + */ + scope?: string; + /** + * Resolution is a best-effort guess (baseUrl, framework defaults): a miss is + * normal for npm packages and must not be reported as an unresolved alias. + */ + optional?: boolean; +} + +const CONFIG_NAMES = ["tsconfig.json", "jsconfig.json"]; +const WORKSPACE_PARENTS = ["apps", "packages", "libs", "services"]; +const SVELTE_CONFIGS = ["svelte.config.js", "svelte.config.ts", "svelte.config.mjs", "svelte.config.cjs"]; + +interface LoadedConfig { + baseUrl?: string; + paths?: Record; + pathsDir?: string; + references: string[]; } /** - * Load alias mappings from tsconfig.json paths and common framework conventions. - * Supports `@/*` -> `./src/*`, `~/` -> `./src/`, and `~` -> `./src/`. + * Load alias mappings from tsconfig/jsconfig (JSONC, `extends`, `baseUrl`, + * project `references`, workspace sub-packages) plus common framework + * conventions (`$lib` for SvelteKit, `@/` and `~/` -> `src/` as a last resort). */ export async function loadAliasMappings(projectRoot: string): Promise { + const root = resolve(projectRoot); const mappings: AliasMapping[] = []; - try { - const tsconfigPath = join(projectRoot, "tsconfig.json"); - const raw = await readFile(tsconfigPath, "utf8"); - const config = JSON.parse(raw); - const paths = config?.compilerOptions?.paths as Record | undefined; - - if (paths) { - for (const [alias, targets] of Object.entries(paths)) { - if (Array.isArray(targets) && targets.length > 0) { - const prefix = alias.endsWith("*") ? alias.slice(0, -1) : alias; - const target = targets[0].endsWith("*") - ? targets[0].slice(0, -1) - : targets[0]; - mappings.push({ prefix, target }); - } - } - } - } catch { - // tsconfig.json missing or unparseable — fall through to framework defaults + for (const directory of await findConfigDirectories(root)) { + mappings.push(...(await loadDirectoryMappings(root, directory))); } - // Common framework conventions if no tsconfig paths defined + // Framework defaults only when the project declares no aliases of its own. if (mappings.length === 0) { - mappings.push({ prefix: "@/", target: "./src/" }); - mappings.push({ prefix: "~/", target: "./src/" }); - mappings.push({ prefix: "~", target: "./src/" }); + mappings.push({ prefix: "@/", target: "./src/", optional: true }); + mappings.push({ prefix: "~/", target: "./src/", optional: true }); + } + + if ( + !mappings.some((mapping) => mapping.prefix === "$lib" || mapping.prefix === "$lib/") + && SVELTE_CONFIGS.some((name) => existsSync(join(root, name))) + ) { + mappings.push({ prefix: "$lib", target: "src/lib", exact: true, scope: "" }); + mappings.push({ prefix: "$lib/", target: "src/lib/", scope: "" }); } return mappings; } /** - * Resolve an alias import specifier to a relative path using configured mappings. - * Returns null if no mapping matches. + * All root-relative path candidates for an alias specifier, best match first. + * Mirrors TypeScript: the longest matching pattern wins, an exact alias beats a + * wildcard of the same length, and the nearest tsconfig scope wins. */ -export function resolveAlias(specifier: string, mappings: AliasMapping[]): string | null { - for (const mapping of mappings) { - if (mapping.prefix && specifier.startsWith(mapping.prefix)) { - const remainder = specifier.slice(mapping.prefix.length); - return mapping.target + remainder; +export function resolveAliasCandidates( + specifier: string, + mappings: AliasMapping[], + fromFile?: string +): string[] { + const scopes = [...new Set(mappings.map((mapping) => mapping.scope ?? ""))] + .filter((scope) => fromFile === undefined || isWithinScope(fromFile, scope)) + .sort((a, b) => scopeDepth(b) - scopeDepth(a)); + + for (const scope of scopes) { + const matches = mappings.filter((mapping) => + (mapping.scope ?? "") === scope && matchesSpecifier(mapping, specifier) + ); + if (matches.length === 0) continue; + + const longest = Math.max(...matches.map((mapping) => mapping.prefix.length)); + const best = matches.filter((mapping) => mapping.prefix.length === longest); + const exact = best.filter((mapping) => mapping.exact); + const chosen = exact.length > 0 ? exact : best; + + return chosen.map((mapping) => + mapping.exact ? mapping.target : mapping.target + specifier.slice(mapping.prefix.length) + ); + } + + return []; +} + +/** Whether any mapping that is not just a guess claims this specifier. */ +export function isDeclaredAlias(specifier: string, mappings: AliasMapping[], fromFile?: string): boolean { + return mappings.some((mapping) => + !mapping.optional + && (fromFile === undefined || isWithinScope(fromFile, mapping.scope ?? "")) + && matchesSpecifier(mapping, specifier) + ); +} + +/** + * Resolve an alias import specifier to a root-relative path using configured + * mappings. Returns null if no mapping matches. + */ +export function resolveAlias( + specifier: string, + mappings: AliasMapping[], + fromFile?: string +): string | null { + return resolveAliasCandidates(specifier, mappings, fromFile)[0] ?? null; +} + +function matchesSpecifier(mapping: AliasMapping, specifier: string): boolean { + if (mapping.exact) return specifier === mapping.prefix; + if (mapping.prefix === "") return true; + return specifier.startsWith(mapping.prefix); +} + +function isWithinScope(file: string, scope: string): boolean { + return scope === "" || file === scope || file.startsWith(`${scope}/`); +} + +function scopeDepth(scope: string): number { + return scope === "" ? 0 : scope.split("/").length; +} + +async function findConfigDirectories(root: string): Promise { + const directories = [root]; + + for (const parent of WORKSPACE_PARENTS) { + try { + const entries = await readdir(join(root, parent), { withFileTypes: true }); + for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { + if (entry.isDirectory() && !entry.name.startsWith(".") && entry.name !== "node_modules") { + directories.push(join(root, parent, entry.name)); + } + } + } catch { + // no such workspace parent + } + } + + return directories; +} + +async function loadDirectoryMappings(root: string, directory: string): Promise { + const scope = toPosix(relative(root, directory)); + const mappings: AliasMapping[] = []; + const queue = CONFIG_NAMES.map((name) => join(directory, name)); + const visited = new Set(); + + while (queue.length > 0) { + const configPath = queue.shift()!; + if (visited.has(configPath)) continue; + visited.add(configPath); + + const config = await loadConfig(configPath, new Set()); + if (!config) continue; + + mappings.push(...configToMappings(config, root, scope)); + for (const reference of config.references) { + // `references` may point at a config file or at a directory holding one. + queue.push(reference.endsWith(".json") ? reference : join(reference, "tsconfig.json")); } } - return null; + + return mappings; +} + +async function loadConfig(configPath: string, seen: Set): Promise { + if (seen.has(configPath)) return null; + seen.add(configPath); + + let text: string; + try { + text = await readFile(configPath, "utf8"); + } catch { + return null; + } + + // tsconfig is JSONC: comments and trailing commas are legal and common. + const parsed = ts.parseConfigFileTextToJson(configPath, text.replace(/^/, "")); + const json = parsed.config as Record | undefined; + if (parsed.error || !json || typeof json !== "object") return null; + + const configDir = dirname(configPath); + const merged: LoadedConfig = { references: [] }; + + for (const parentPath of extendsPaths(json.extends, configDir)) { + const parent = await loadConfig(parentPath, seen); + if (!parent) continue; + if (parent.baseUrl !== undefined) merged.baseUrl = parent.baseUrl; + if (parent.paths !== undefined) { + merged.paths = parent.paths; + merged.pathsDir = parent.pathsDir; + } + } + + const options = json.compilerOptions as Record | undefined; + if (options && typeof options.baseUrl === "string") { + merged.baseUrl = resolve(configDir, options.baseUrl); + } + if (options && options.paths && typeof options.paths === "object") { + // `paths` replaces the inherited object wholesale, it is not merged. + merged.paths = options.paths as Record; + merged.pathsDir = configDir; + } + + if (Array.isArray(json.references)) { + for (const reference of json.references) { + const path = (reference as { path?: unknown } | null)?.path; + if (typeof path === "string") merged.references.push(resolve(configDir, path)); + } + } + + return merged; +} + +function extendsPaths(value: unknown, configDir: string): string[] { + const entries = Array.isArray(value) ? value : [value]; + const paths: string[] = []; + + for (const entry of entries) { + // Package specifiers (`@tsconfig/node20`) carry no project aliases. + if (typeof entry !== "string" || !entry.startsWith(".")) continue; + const resolved = resolve(configDir, entry); + paths.push(resolved.endsWith(".json") ? resolved : `${resolved}.json`); + } + + return paths; +} + +function configToMappings(config: LoadedConfig, root: string, scope: string): AliasMapping[] { + const mappings: AliasMapping[] = []; + + if (config.paths) { + const base = config.baseUrl ?? config.pathsDir ?? root; + + for (const [alias, targets] of Object.entries(config.paths)) { + if (!Array.isArray(targets)) continue; + + const star = alias.indexOf("*"); + // Only a trailing wildcard (`@/*`) or no wildcard is supported. + if (star !== -1 && star !== alias.length - 1) continue; + + for (const target of targets) { + if (typeof target !== "string") continue; + const targetStar = target.indexOf("*"); + if (targetStar !== -1 && targetStar !== target.length - 1) continue; + + const rootRelative = toRootRelative(resolve(base, targetStar === -1 ? target : target.slice(0, -1)), root); + if (rootRelative === null) continue; + + if (star === -1) { + mappings.push({ prefix: alias, target: rootRelative, exact: true, scope }); + } else { + const directory = rootRelative === "" || rootRelative.endsWith("/") ? rootRelative : `${rootRelative}/`; + mappings.push({ prefix: alias.slice(0, -1), target: directory, scope }); + } + } + } + } + + if (config.baseUrl !== undefined) { + const baseRelative = toRootRelative(config.baseUrl, root); + if (baseRelative !== null) { + const directory = baseRelative === "" ? "" : `${baseRelative}/`; + // Bare specifiers resolve against baseUrl; most are npm packages. + mappings.push({ prefix: "", target: directory, scope, optional: true }); + } + } + + return mappings; +} + +function toRootRelative(absolutePath: string, root: string): string | null { + const relativePath = toPosix(relative(root, absolutePath)); + if (relativePath === ".." || relativePath.startsWith("../") || /^[a-zA-Z]:/.test(relativePath)) { + return null; + } + return relativePath; +} + +function toPosix(path: string): string { + return sep === "/" ? path : path.split(sep).join("/"); } diff --git a/packages/cli/src/analyzers/graph/dependencyGraph.ts b/packages/cli/src/analyzers/graph/dependencyGraph.ts index 3871b23..c6db17b 100644 --- a/packages/cli/src/analyzers/graph/dependencyGraph.ts +++ b/packages/cli/src/analyzers/graph/dependencyGraph.ts @@ -1,6 +1,6 @@ import type { FileAnalysis, ScannedFile } from "../analysis/index.js"; import type { AliasMapping } from "./aliasResolver.js"; -import { resolveAlias } from "./aliasResolver.js"; +import { isDeclaredAlias, resolveAliasCandidates } from "./aliasResolver.js"; export type FileGraph = Record; @@ -9,7 +9,8 @@ export interface DependencyGraphDiagnostics { parserFallbacks: string[]; } -const IMPORT_RE = /(?:import\s+(?:[^'"]+\s+from\s+)?|export\s+[^'"]+\s+from\s+|require\()\s*['"]([^'"]+)['"]/g; +// Static imports, re-exports, require() and dynamic import() with a literal. +const IMPORT_RE = /(?:\bimport\s+(?:[^'"]+\s+from\s+)?|\bexport\s+[^'"]+\s+from\s+|\b(?:require|import)\s*\(\s*)['"]([^'"]+)['"]/g; export function buildDependencyGraph( files: ScannedFile[], @@ -24,31 +25,35 @@ export function buildDependencyGraph( }; for (const file of files) { - graph[file.path] = []; + // A file importing the same module twice (`import type` + value import, + // or a static and a dynamic import) is still one dependency edge. + const edges = new Set(); const importSpecifiers = analyses[file.path]?.imports ?? findImportSpecifiers(file.content); for (const specifier of importSpecifiers) { if (specifier.startsWith(".")) { const resolved = resolveImport(file.path, specifier, localPaths); - if (resolved) { - graph[file.path].push(resolved); + if (resolved && resolved !== file.path) { + edges.add(resolved); } continue; } // Try alias resolution for non-relative imports - const aliasResolved = resolveAlias(specifier, aliasMappings); - if (aliasResolved) { - const resolved = resolveAliasPath(aliasResolved, localPaths); + const candidates = resolveAliasCandidates(specifier, aliasMappings, file.path); + if (candidates.length > 0) { + const resolved = firstResolved(candidates, localPaths); if (resolved) { - graph[file.path].push(resolved); - } else { + if (resolved !== file.path) edges.add(resolved); + } else if (isDeclaredAlias(specifier, aliasMappings, file.path)) { diagnostics.unresolvedAliases.push({ file: file.path, specifier }); } } // Non-relative, non-alias imports are silently skipped (npm packages etc.) } + graph[file.path] = [...edges]; + if (!analyses[file.path]) { diagnostics.parserFallbacks.push(file.path); } @@ -86,71 +91,53 @@ function resolveImport(fromPath: string, specifier: string, localPaths: Set): string | null { + for (const rootRelativePath of rootRelativePaths) { + const normalized = normalizePath(rootRelativePath); + if (normalized === null) continue; + const resolved = matchLocalPath(normalized, localPaths); + if (resolved) return resolved; } - if (normalized.endsWith(".mjs")) { - candidates.push(`${normalized.slice(0, -4)}.mts`); - } - if (normalized.endsWith(".cjs")) { - candidates.push(`${normalized.slice(0, -4)}.cts`); - } - - return candidates.find((candidate) => localPaths.has(candidate)) ?? null; + return null; } -function resolveAliasPath(rootRelativePath: string, localPaths: Set): string | null { - const normalized = normalizePath(rootRelativePath); +const SOURCE_EXTENSIONS = [ + ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue", ".svelte", ".astro" +]; +const INDEX_EXTENSIONS = [ + ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue", ".svelte", ".astro" +]; + +// 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, - `${normalized}.ts`, - `${normalized}.tsx`, - `${normalized}.js`, - `${normalized}.jsx`, - `${normalized}.mjs`, - `${normalized}.cjs`, - `${normalized}.vue`, - `${normalized}.svelte`, - `${normalized}.astro`, - `${normalized}/index.ts`, - `${normalized}/index.tsx`, - `${normalized}/index.js`, - `${normalized}/index.jsx`, - `${normalized}/index.vue`, - `${normalized}/index.svelte`, - `${normalized}/index.astro` + ...SOURCE_EXTENSIONS.map((extension) => `${normalized}${extension}`), + ...INDEX_EXTENSIONS.map((extension) => `${normalized}/index${extension}`) ]; - if (normalized.endsWith(".js")) { - const withoutExtension = normalized.slice(0, -3); - candidates.push(`${withoutExtension}.ts`, `${withoutExtension}.tsx`); + 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; } -function normalizePath(path: string): string { +/** 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("/")) { @@ -159,6 +146,7 @@ function normalizePath(path: string): string { } if (part === "..") { + if (parts.length === 0) return null; parts.pop(); continue; } diff --git a/packages/cli/src/analyzers/graph/index.ts b/packages/cli/src/analyzers/graph/index.ts index 55a25f9..d57b39b 100644 --- a/packages/cli/src/analyzers/graph/index.ts +++ b/packages/cli/src/analyzers/graph/index.ts @@ -10,6 +10,8 @@ export type { export { loadAliasMappings, resolveAlias, + resolveAliasCandidates, + isDeclaredAlias, } from "./aliasResolver.js"; export type { AliasMapping, diff --git a/packages/cli/test/graph-resolution.test.ts b/packages/cli/test/graph-resolution.test.ts new file mode 100644 index 0000000..1b61b36 --- /dev/null +++ b/packages/cli/test/graph-resolution.test.ts @@ -0,0 +1,313 @@ +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 { + buildDependencyGraph, + loadAliasMappings, + resolveAlias +} from "../src/analyzers/graph/index.js"; +import { analyzeFiles } from "../src/analyzers/pipeline/analyzerRegistry.js"; + +async function withProject( + files: Record, + run: (root: string) => Promise +): Promise { + const root = await mkdtemp(join(tmpdir(), "devmap-graph-")); + 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 graphOf(root: string) { + const files = await scanFiles(root); + const analyses = await analyzeFiles(files); + const mappings = await loadAliasMappings(root); + return buildDependencyGraph(files, analyses, mappings); +} + +// ---------------------------------------------------------------- alias config + +test("tsconfig with comments and trailing commas still yields alias edges", async () => { + await withProject({ + "tsconfig.json": `{ + // JSONC: comments and trailing commas are legal in tsconfig + "compilerOptions": { + "paths": { "@app/*": ["./lib/*"], }, + }, + }`, + "src/main.ts": 'import { run } from "@app/run";\nrun();\n', + "lib/run.ts": "export const run = () => 1;\n" + }, async (root) => { + const { graph } = await graphOf(root); + assert.deepEqual(graph["src/main.ts"], ["lib/run.ts"]); + }); +}); + +test("paths declared in an extended tsconfig resolve relative to the config that declares them", async () => { + await withProject({ + "tsconfig.base.json": JSON.stringify({ compilerOptions: { paths: { "@shared/*": ["./shared/*"] } } }), + "tsconfig.json": JSON.stringify({ extends: "./tsconfig.base.json" }), + "src/main.ts": 'import { id } from "@shared/id";\nexport const x = id;\n', + "shared/id.ts": "export const id = 1;\n" + }, async (root) => { + const { graph } = await graphOf(root); + assert.deepEqual(graph["src/main.ts"], ["shared/id.ts"]); + }); +}); + +test("baseUrl lets bare specifiers resolve to local files without flagging npm packages", async () => { + await withProject({ + "package.json": JSON.stringify({ name: "x", dependencies: { react: "1.0.0" } }), + "tsconfig.json": JSON.stringify({ compilerOptions: { baseUrl: "src" } }), + "src/app.ts": 'import react from "react";\nimport { util } from "utils/util";\nexport default [react, util];\n', + "src/utils/util.ts": "export const util = 1;\n" + }, async (root) => { + const { graph, diagnostics } = await graphOf(root); + assert.deepEqual(graph["src/app.ts"], ["src/utils/util.ts"]); + assert.deepEqual(diagnostics.unresolvedAliases, []); + }); +}); + +test("an exact alias does not capture longer specifiers that merely start with it", async () => { + await withProject({ + "tsconfig.json": JSON.stringify({ + compilerOptions: { paths: { "@config": ["./config/index.ts"] } } + }), + "src/a.ts": 'import c from "@config";\nexport default c;\n', + "src/b.ts": 'import c from "@configuration/thing";\nexport default c;\n', + "config/index.ts": "export default 1;\n" + }, async (root) => { + const mappings = await loadAliasMappings(root); + assert.equal(resolveAlias("@config", mappings), "config/index.ts"); + assert.equal(resolveAlias("@configuration/thing", mappings), null); + + const { graph, diagnostics } = await graphOf(root); + assert.deepEqual(graph["src/a.ts"], ["config/index.ts"]); + assert.deepEqual(graph["src/b.ts"], []); + assert.deepEqual(diagnostics.unresolvedAliases, []); + }); +}); + +test("the longest matching alias pattern wins", async () => { + await withProject({ + "tsconfig.json": JSON.stringify({ + compilerOptions: { paths: { "@/*": ["./src/*"], "@/ui/*": ["./packages/ui/*"] } } + }), + "src/main.ts": 'import { b } from "@/ui/button";\nexport default b;\n', + "src/ui/button.ts": "export const b = 'wrong';\n", + "packages/ui/button.ts": "export const b = 'right';\n" + }, async (root) => { + const { graph } = await graphOf(root); + assert.deepEqual(graph["src/main.ts"], ["packages/ui/button.ts"]); + }); +}); + +test("SvelteKit $lib resolves through the generated tsconfig and the svelte config", async () => { + await withProject({ + "svelte.config.js": "export default {};\n", + "src/routes/+page.ts": 'import { greet } from "$lib/greet";\nexport const load = () => greet();\n', + "src/lib/greet.ts": "export const greet = () => 'hi';\n" + }, async (root) => { + const { graph } = await graphOf(root); + assert.deepEqual(graph["src/routes/+page.ts"], ["src/lib/greet.ts"]); + }); +}); + +test("workspace packages use their own tsconfig aliases", async () => { + await withProject({ + "package.json": JSON.stringify({ name: "mono", private: true }), + "apps/web/tsconfig.json": JSON.stringify({ compilerOptions: { paths: { "@/*": ["./src/*"] } } }), + "apps/web/src/page.ts": 'import { x } from "@/x";\nexport default x;\n', + "apps/web/src/x.ts": "export const x = 1;\n", + "apps/api/tsconfig.json": JSON.stringify({ compilerOptions: { paths: { "@/*": ["./app/*"] } } }), + "apps/api/app/main.ts": 'import { x } from "@/x";\nexport default x;\n', + "apps/api/app/x.ts": "export const x = 2;\n" + }, async (root) => { + const { graph } = await graphOf(root); + assert.deepEqual(graph["apps/web/src/page.ts"], ["apps/web/src/x.ts"]); + assert.deepEqual(graph["apps/api/app/main.ts"], ["apps/api/app/x.ts"]); + }); +}); + +test("paths declared in a referenced tsconfig (Vite style) are honoured", async () => { + await withProject({ + "tsconfig.json": JSON.stringify({ files: [], references: [{ path: "./tsconfig.app.json" }] }), + "tsconfig.app.json": JSON.stringify({ compilerOptions: { paths: { "@app/*": ["./src/*"] } } }), + "src/main.ts": 'import { v } from "@app/v";\nexport default v;\n', + "src/v.ts": "export const v = 1;\n" + }, async (root) => { + const { graph } = await graphOf(root); + assert.deepEqual(graph["src/main.ts"], ["src/v.ts"]); + }); +}); + +test("a declared alias that points nowhere is reported once per import", async () => { + await withProject({ + "tsconfig.json": JSON.stringify({ compilerOptions: { paths: { "@/*": ["./src/*"] } } }), + "src/main.ts": 'import { a } from "@/missing";\nexport default a;\n' + }, async (root) => { + const { graph, diagnostics } = await graphOf(root); + assert.deepEqual(graph["src/main.ts"], []); + assert.deepEqual(diagnostics.unresolvedAliases, [{ file: "src/main.ts", specifier: "@/missing" }]); + }); +}); + +// ------------------------------------------------------------ edge resolution + +test("dynamic import() creates an edge", async () => { + await withProject({ + "src/router.ts": 'export const load = () => import("./pages/home");\n', + "src/pages/home.ts": "export default 1;\n" + }, async (root) => { + const { graph } = await graphOf(root); + assert.deepEqual(graph["src/router.ts"], ["src/pages/home.ts"]); + }); +}); + +test("import = require() and import('x') types create edges", async () => { + await withProject({ + "src/a.ts": 'import legacy = require("./legacy");\nexport type T = import("./types").T;\nexport default legacy;\n', + "src/legacy.ts": "export = 1;\n", + "src/types.ts": "export type T = string;\n" + }, async (root) => { + const { graph } = await graphOf(root); + assert.deepEqual([...graph["src/a.ts"]].sort(), ["src/legacy.ts", "src/types.ts"]); + }); +}); + +test("importing the same module several times yields a single edge", async () => { + await withProject({ + "src/a.ts": [ + 'import type { T } from "./b";', + 'import { v } from "./b";', + 'export { v } from "./b";', + 'export const lazy = () => import("./b");', + "export type U = T;" + ].join("\n"), + "src/b.ts": "export type T = string;\nexport const v = 1;\n" + }, async (root) => { + const { graph } = await graphOf(root); + assert.deepEqual(graph["src/a.ts"], ["src/b.ts"]); + }); +}); + +test("a file never depends on itself", async () => { + await withProject({ + "src/index.ts": 'export * from "./index";\nexport const a = 1;\n' + }, async (root) => { + const { graph } = await graphOf(root); + assert.deepEqual(graph["src/index.ts"], []); + }); +}); + +test("./x.jsx, ./x.mjs and ./x.cjs resolve to their TypeScript sources", async () => { + await withProject({ + "src/a.tsx": 'import "./b.jsx";\nimport "./c.mjs";\nimport "./d.cjs";\nexport default 1;\n', + "src/b.tsx": "export default 1;\n", + "src/c.mts": "export default 1;\n", + "src/d.cts": "export default 1;\n" + }, async (root) => { + const { graph } = await graphOf(root); + assert.deepEqual([...graph["src/a.tsx"]].sort(), ["src/b.tsx", "src/c.mts", "src/d.cts"]); + }); +}); + +test("relative imports that climb above the project root do not match root files", async () => { + await withProject({ + "a.ts": 'import "../b";\nexport default 1;\n', + "b.ts": "export default 2;\n" + }, async (root) => { + const { graph } = await graphOf(root); + assert.deepEqual(graph["a.ts"], []); + }); +}); + +// ----------------------------------------------------------- analyzer details + +async function analyzeTs(content: string) { + const analyzer = new TsMorphAnalyzer(); + return analyzer.analyze( + { path: "src/x.ts", absolutePath: "/x/src/x.ts", extension: ".ts", size: content.length, lines: 1, content }, + { projectRoot: "/x" } as never + ); +} + +test("destructured constants are listed by their bound names", async () => { + const analysis = await analyzeTs("export const { alpha, beta: renamed } = source;\nexport const [first] = list;\n"); + const names = analysis.symbols.map((symbol) => symbol.name).sort(); + assert.deepEqual(names, ["alpha", "first", "renamed"]); +}); + +test("function overloads produce one symbol", async () => { + const analysis = await analyzeTs([ + "export function parse(value: string): string;", + "export function parse(value: number): number;", + "export function parse(value: unknown) { return value; }", + "class A { m(a: string): void; m(a: number): void; m(a: unknown) {} }" + ].join("\n")); + assert.equal(analysis.symbols.filter((symbol) => symbol.name === "parse").length, 1); + assert.equal(analysis.symbols.filter((symbol) => symbol.name === "m").length, 1); +}); + +// --------------------------------------------------------------- ignore rules + +test("nested .gitignore files apply to their own subtree only", async () => { + await withProject({ + ".gitignore": "root-ignored.ts\n", + "root-ignored.ts": "export const a = 1;\n", + "pkg/.gitignore": "generated/\nlocal.ts\n", + "pkg/generated/out.ts": "export const g = 1;\n", + "pkg/local.ts": "export const l = 1;\n", + "pkg/keep.ts": "export const k = 1;\n", + "other/local.ts": "export const l = 1;\n" + }, async (root) => { + const files = (await scanFiles(root)).map((file) => file.path); + assert.deepEqual(files, [".gitignore", "other/local.ts", "pkg/.gitignore", "pkg/keep.ts"]); + }); +}); + +test("editing .gitignore between scans in the same process is picked up", async () => { + await withProject({ + ".gitignore": "a.ts\n", + "a.ts": "export const a = 1;\n", + "b.ts": "export const b = 1;\n" + }, async (root) => { + assert.deepEqual((await scanFiles(root)).map((file) => file.path), [".gitignore", "b.ts"]); + await writeFile(join(root, ".gitignore"), "b.ts\n", "utf8"); + assert.deepEqual((await scanFiles(root)).map((file) => file.path), [".gitignore", "a.ts"]); + }); +}); + +test("secret env files are ignored but .env look-alikes are not", async () => { + await withProject({ + ".env": "SECRET=1\n", + ".env.local": "SECRET=2\n", + "src/.env.production": "SECRET=3\n", + ".envrc": "use nix\n", + "src/.environment.ts": "export const environment = {};\n" + }, async (root) => { + const files = (await scanFiles(root)).map((file) => file.path); + assert.deepEqual(files, [".envrc", "src/.environment.ts"]); + }); +}); + +test("ignored binary extensions match regardless of case", async () => { + await withProject({ + "assets/Logo.PNG": "x", + "assets/photo.JPG": "x", + "src/a.ts": "export const a = 1;\n" + }, async (root) => { + assert.deepEqual((await scanFiles(root)).map((file) => file.path), ["src/a.ts"]); + }); +}); From c67087e56ff0adea6208993b483427ac88b85705 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 06:57:51 +0800 Subject: [PATCH 2/3] fix(analysis): read imports and symbols for Python, Ruby, PHP, Go, C# and Java The heuristic analyzer ran JS-syntax regexes over every language it claims to support, so .py/.php/.go/.rb/.cs/.java files always had empty imports and exports, and docstring text produced bogus symbols (`class of`). - per-language readers with comment/docstring stripping that keeps string literals (a `//` in a URL no longer hides code) - Python relative imports become path-style specifiers; the graph resolves them plus absolute dotted modules (root and src/ layouts, __init__.py) - Ruby require_relative and relative PHP require/include become edges - JS-family files handled by this analyzer also pick up dynamic import() Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_019HJc4sv9pSLh7vhduSUxBE --- .../src/analyzers/graph/dependencyGraph.ts | 26 +- .../analyzers/pipeline/heuristicAnalyzer.ts | 13 +- .../src/analyzers/pipeline/languageReaders.ts | 354 ++++++++++++++++++ packages/cli/test/heuristic-languages.test.ts | 206 ++++++++++ 4 files changed, 593 insertions(+), 6 deletions(-) create mode 100644 packages/cli/src/analyzers/pipeline/languageReaders.ts create mode 100644 packages/cli/test/heuristic-languages.test.ts diff --git a/packages/cli/src/analyzers/graph/dependencyGraph.ts b/packages/cli/src/analyzers/graph/dependencyGraph.ts index c6db17b..b431561 100644 --- a/packages/cli/src/analyzers/graph/dependencyGraph.ts +++ b/packages/cli/src/analyzers/graph/dependencyGraph.ts @@ -48,6 +48,9 @@ export function buildDependencyGraph( } else if (isDeclaredAlias(specifier, aliasMappings, file.path)) { diagnostics.unresolvedAliases.push({ file: file.path, specifier }); } + } else if (file.path.endsWith(".py")) { + const resolved = resolvePythonModule(specifier, localPaths); + if (resolved && resolved !== file.path) edges.add(resolved); } // Non-relative, non-alias imports are silently skipped (npm packages etc.) } @@ -94,6 +97,20 @@ function resolveImport(fromPath: string, specifier: string, localPaths: Set app/models.py or app/models/__init__.py, +// looked up from the project root and from a conventional src/ layout. +function resolvePythonModule(specifier: string, localPaths: Set): string | null { + if (!/^[A-Za-z_][\w.]*$/.test(specifier)) return null; + const modulePath = specifier.replace(/\./g, "/"); + + for (const base of ["", "src/"]) { + for (const candidate of [`${base}${modulePath}.py`, `${base}${modulePath}/__init__.py`]) { + if (localPaths.has(candidate)) return candidate; + } + } + return null; +} + function firstResolved(rootRelativePaths: string[], localPaths: Set): string | null { for (const rootRelativePath of rootRelativePaths) { const normalized = normalizePath(rootRelativePath); @@ -105,10 +122,12 @@ function firstResolved(rootRelativePaths: string[], localPaths: Set): st } const SOURCE_EXTENSIONS = [ - ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue", ".svelte", ".astro" + ".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" + ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue", ".svelte", ".astro", + ".py", ".rb", ".php" ]; // TypeScript ESM writes the emitted extension in the specifier. @@ -123,7 +142,8 @@ function matchLocalPath(normalized: string, localPaths: Set): string | n const candidates = [ normalized, ...SOURCE_EXTENSIONS.map((extension) => `${normalized}${extension}`), - ...INDEX_EXTENSIONS.map((extension) => `${normalized}/index${extension}`) + ...INDEX_EXTENSIONS.map((extension) => `${normalized}/index${extension}`), + `${normalized}/__init__.py` ]; for (const [emitted, sources] of EMITTED_EXTENSION_SOURCES) { diff --git a/packages/cli/src/analyzers/pipeline/heuristicAnalyzer.ts b/packages/cli/src/analyzers/pipeline/heuristicAnalyzer.ts index 197cacf..dd85083 100644 --- a/packages/cli/src/analyzers/pipeline/heuristicAnalyzer.ts +++ b/packages/cli/src/analyzers/pipeline/heuristicAnalyzer.ts @@ -6,6 +6,7 @@ import type { SymbolInfo, ScannedFile, } from "../analysis/index.js"; +import { readLanguage, supportsLanguageReader } from "./languageReaders.js"; // TS/JS extensions are intentionally excluded here. // TsMorphAnalyzer handles .ts/.tsx/.js/.jsx with full AST accuracy (confidence: "high"). @@ -36,8 +37,14 @@ export class HeuristicAnalyzer implements FileAnalyzer { } async analyze(file: ScannedFile, _context: AnalyzerContext): Promise { - const imports = readImportSpecifiers(file.content); - const symbols = readSymbols(file.content); + // Python, Ruby, PHP, Go, C# and Java have their own import and declaration + // syntax; running the JS patterns over them yields nothing (imports) or + // noise (`class of` from a docstring). The JS family keeps the JS readers. + const reading = supportsLanguageReader(file.extension) + ? readLanguage(file.extension, file.content) + : undefined; + const imports = reading?.imports ?? readImportSpecifiers(file.content); + const symbols = reading?.symbols ?? readSymbols(file.content); const exports = symbols .filter((symbol) => symbol.exported) .map((symbol) => symbol.name) @@ -88,7 +95,7 @@ export class FallbackAnalyzer implements FileAnalyzer { function readImportSpecifiers(content: string): string[] { const imports = new Set(); - const pattern = /(?:import\s+(?:[^'"]+\s+from\s+)?|export\s+[^'"]+\s+from\s+|require\()\s*['"]([^'"]+)['"]/g; + const pattern = /(?:\bimport\s+(?:[^'"]+\s+from\s+)?|\bexport\s+[^'"]+\s+from\s+|\b(?:require|import)\s*\(\s*)['"]([^'"]+)['"]/g; let match = pattern.exec(content); while (match) { diff --git a/packages/cli/src/analyzers/pipeline/languageReaders.ts b/packages/cli/src/analyzers/pipeline/languageReaders.ts new file mode 100644 index 0000000..6cbca3b --- /dev/null +++ b/packages/cli/src/analyzers/pipeline/languageReaders.ts @@ -0,0 +1,354 @@ +import type { SymbolInfo } from "../analysis/index.js"; + +export interface LanguageReading { + imports: string[]; + symbols: SymbolInfo[]; +} + +interface CommentSyntax { + line: string[]; + block?: [string, string]; + /** Python-style triple-quoted strings are blanked too: they are docstrings. */ + tripleQuotes?: boolean; + /** Ruby =begin/=end block comments. */ + rubyBlock?: boolean; + quotes: string[]; +} + +const C_LIKE: CommentSyntax = { line: ["//"], block: ["/*", "*/"], quotes: ['"', "'"] }; + +const SYNTAX: Record = { + ".py": { line: ["#"], tripleQuotes: true, quotes: ['"', "'"] }, + ".rb": { line: ["#"], rubyBlock: true, quotes: ['"', "'"] }, + ".php": { line: ["//", "#"], block: ["/*", "*/"], quotes: ['"', "'"] }, + ".go": { line: ["//"], block: ["/*", "*/"], quotes: ['"', "'", "`"] }, + ".cs": C_LIKE, + ".java": C_LIKE +}; + +export function supportsLanguageReader(extension: string): boolean { + return extension in SYNTAX; +} + +export function readLanguage(extension: string, content: string): LanguageReading { + const syntax = SYNTAX[extension]; + const code = stripComments(content, syntax); + + switch (extension) { + case ".py": return readPython(code); + case ".rb": return readRuby(code); + case ".php": return readPhp(code); + case ".go": return readGo(code); + case ".cs": return readCSharp(code); + default: return readJava(code); + } +} + +/** + * Blank out comments (and Python docstrings) while keeping every newline and + * string literal, so offsets and line numbers stay valid and `#` or `//` inside + * a string is not mistaken for a comment. + */ +function stripComments(content: string, syntax: CommentSyntax): string { + const out: string[] = []; + let index = 0; + + const blank = (text: string) => text.replace(/[^\n]/g, " "); + + while (index < content.length) { + const rest = content.slice(index, index + 3); + + if (syntax.rubyBlock && content.startsWith("=begin", index) && (index === 0 || content[index - 1] === "\n")) { + const end = content.indexOf("\n=end", index); + const stop = end === -1 ? content.length : end + 5; + out.push(blank(content.slice(index, stop))); + index = stop; + continue; + } + + if (syntax.tripleQuotes && (rest === '"""' || rest === "'''")) { + const end = content.indexOf(rest, index + 3); + const stop = end === -1 ? content.length : end + 3; + out.push(blank(content.slice(index, stop))); + index = stop; + continue; + } + + if (syntax.block && content.startsWith(syntax.block[0], index)) { + const end = content.indexOf(syntax.block[1], index + syntax.block[0].length); + const stop = end === -1 ? content.length : end + syntax.block[1].length; + out.push(blank(content.slice(index, stop))); + index = stop; + continue; + } + + const lineMarker = syntax.line.find((marker) => content.startsWith(marker, index)); + if (lineMarker) { + const end = content.indexOf("\n", index); + const stop = end === -1 ? content.length : end; + out.push(blank(content.slice(index, stop))); + index = stop; + continue; + } + + const character = content[index]; + if (syntax.quotes.includes(character)) { + let cursor = index + 1; + // Backtick (Go raw) strings may span lines and do not escape. + const multiline = character === "`"; + while (cursor < content.length && content[cursor] !== character) { + if (content[cursor] === "\\" && !multiline) cursor += 1; + else if (content[cursor] === "\n" && !multiline) break; + cursor += 1; + } + const stop = Math.min(cursor + 1, content.length); + out.push(content.slice(index, stop)); + index = stop; + continue; + } + + out.push(character); + index += 1; + } + + return out.join(""); +} + +function lineStarts(content: string): number[] { + const starts = [0]; + for (let index = 0; index < content.length; index += 1) { + if (content[index] === "\n") starts.push(index + 1); + } + return starts; +} + +function lineAt(starts: number[], offset: number): number { + let low = 0; + let high = starts.length - 1; + while (low < high) { + const middle = Math.ceil((low + high) / 2); + if (starts[middle] <= offset) low = middle; + else high = middle - 1; + } + return low + 1; +} + +function matchAll(content: string, pattern: RegExp): RegExpExecArray[] { + return [...content.matchAll(new RegExp(pattern.source, pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`))]; +} + +// ------------------------------------------------------------------- Python + +function readPython(code: string): LanguageReading { + const imports = new Set(); + const symbols: SymbolInfo[] = []; + const starts = lineStarts(code); + + for (const match of matchAll(code, /^[ \t]*import[ \t]+([^\n;#]+)/m)) { + for (const part of match[1].split(",")) { + const module = part.trim().split(/\s+as\s+/)[0].trim(); + if (/^[A-Za-z_][\w.]*$/.test(module)) imports.add(module); + } + } + + for (const match of matchAll(code, /^[ \t]*from[ \t]+(\.*)([A-Za-z_][\w.]*)?[ \t]+import[ \t]+([^\n;#]*)/m)) { + const dots = match[1].length; + const module = match[2] ?? ""; + + if (dots === 0) { + if (module) imports.add(module); + continue; + } + + // Relative imports become path-style specifiers the graph can resolve: + // `.models` -> ./models, `..core.db` -> ../core/db. + const base = dots === 1 ? "./" : "../".repeat(dots - 1); + if (module) { + imports.add(`${base}${module.replace(/\./g, "/")}`); + } else { + // `from . import views, forms` imports sibling modules. + for (const part of match[3].replace(/[()]/g, "").split(",")) { + const name = part.trim().split(/\s+as\s+/)[0].trim(); + if (/^[A-Za-z_]\w*$/.test(name)) imports.add(`${base}${name}`); + } + } + } + + for (const match of matchAll(code, /^([ \t]*)(async[ \t]+)?(def|class)[ \t]+([A-Za-z_]\w*)/m)) { + const indented = match[1].length > 0; + const name = match[4]; + const isClass = match[3] === "class"; + + symbols.push({ + name, + kind: isClass ? "class" : indented ? "method" : "function", + line: lineAt(starts, match.index), + // Python has no export keyword: public top-level names are the module API. + exported: !indented && !name.startsWith("_"), + ...(isClass ? {} : { async: Boolean(match[2]) }) + }); + } + + return { imports: [...imports], symbols }; +} + +// --------------------------------------------------------------------- Ruby + +function readRuby(code: string): LanguageReading { + const imports = new Set(); + const symbols: SymbolInfo[] = []; + const starts = lineStarts(code); + + for (const match of matchAll(code, /^[ \t]*(require_relative|require|load)[ \t(]+['"]([^'"]+)['"]/m)) { + const specifier = match[2]; + if (match[1] === "require_relative") { + imports.add(specifier.startsWith(".") ? specifier : `./${specifier}`); + } else { + imports.add(specifier); + } + } + + for (const match of matchAll(code, /^([ \t]*)(class|module|def)[ \t]+(self\.)?([A-Za-z_][\w:]*[?!=]?)/m)) { + const indented = match[1].length > 0; + const keyword = match[2]; + const kind: SymbolInfo["kind"] = keyword === "def" ? (indented ? "method" : "function") : "class"; + + symbols.push({ + name: match[4], + kind, + line: lineAt(starts, match.index), + exported: keyword !== "def" || !indented, + ...(keyword === "def" ? { async: false } : {}) + }); + } + + return { imports: [...imports], symbols }; +} + +// ---------------------------------------------------------------------- PHP + +function readPhp(code: string): LanguageReading { + const imports = new Set(); + const symbols: SymbolInfo[] = []; + const starts = lineStarts(code); + + for (const match of matchAll(code, /^[ \t]*use[ \t]+(?:function[ \t]+|const[ \t]+)?([A-Za-z_\\][\w\\]*)(?:[ \t]+as[ \t]+\w+)?[ \t]*;/m)) { + imports.add(match[1].replace(/^\\/, "")); + } + + for (const match of matchAll(code, /\b(?:require|include)(?:_once)?[ \t]*\(?[ \t]*(__DIR__[ \t]*\.[ \t]*)?['"]([^'"]+\.php)['"]/)) { + const path = match[2]; + if (/^[a-z][a-z0-9+.-]*:/i.test(path)) continue; + if (match[1]) imports.add(`./${path.replace(/^\//, "")}`); + else imports.add(path.startsWith(".") ? path : `./${path}`); + } + + for (const match of matchAll(code, /^[ \t]*(?:(?:abstract|final|readonly)[ \t]+)*(class|interface|trait|enum)[ \t]+([A-Za-z_]\w*)/m)) { + symbols.push({ + name: match[2], + kind: match[1] === "interface" ? "interface" : match[1] === "enum" ? "enum" : "class", + line: lineAt(starts, match.index), + exported: true + }); + } + + for (const match of matchAll(code, /^([ \t]*)(?:(?:public|protected|private|static|abstract|final)[ \t]+)*function[ \t]+&?([A-Za-z_]\w*)/m)) { + const indented = match[1].length > 0; + symbols.push({ + name: match[2], + kind: indented ? "method" : "function", + line: lineAt(starts, match.index), + exported: !indented, + async: false + }); + } + + return { imports: [...imports], symbols: symbols.sort(byLine) }; +} + +// ----------------------------------------------------------------------- Go + +function readGo(code: string): LanguageReading { + const imports = new Set(); + const symbols: SymbolInfo[] = []; + const starts = lineStarts(code); + + for (const match of matchAll(code, /^import[ \t]+(?:[\w.]+[ \t]+)?"([^"]+)"/m)) { + imports.add(match[1]); + } + for (const block of matchAll(code, /^import[ \t]*\(([^)]*)\)/m)) { + for (const entry of matchAll(block[1], /^[ \t]*(?:[\w.]+[ \t]+)?"([^"]+)"/m)) { + imports.add(entry[1]); + } + } + + for (const match of matchAll(code, /^func[ \t]+(\([^)]*\)[ \t]*)?([A-Za-z_]\w*)/m)) { + symbols.push({ + name: match[2], + kind: match[1] ? "method" : "function", + line: lineAt(starts, match.index), + exported: /^[A-Z]/.test(match[2]), + async: false + }); + } + + for (const match of matchAll(code, /^type[ \t]+([A-Za-z_]\w*)[ \t]+(struct|interface)?/m)) { + symbols.push({ + name: match[1], + kind: match[2] === "interface" ? "interface" : match[2] === "struct" ? "class" : "type", + line: lineAt(starts, match.index), + exported: /^[A-Z]/.test(match[1]) + }); + } + + return { imports: [...imports], symbols: symbols.sort(byLine) }; +} + +// ------------------------------------------------------------ C# and Java + +function readCSharp(code: string): LanguageReading { + const imports = new Set(); + const starts = lineStarts(code); + const symbols: SymbolInfo[] = []; + + // `using System;` / `using static X.Y;` / `using Alias = X.Y;` but not `using (var x = ...)`. + for (const match of matchAll(code, /^[ \t]*(?:global[ \t]+)?using[ \t]+(?:static[ \t]+)?(?:\w+[ \t]*=[ \t]*)?([A-Za-z_][\w.]*)[ \t]*;/m)) { + imports.add(match[1]); + } + + for (const match of matchAll(code, /^[ \t]*((?:(?:public|internal|private|protected|static|abstract|sealed|partial|readonly)[ \t]+)*)(class|interface|struct|enum|record)[ \t]+([A-Za-z_]\w*)/m)) { + symbols.push({ + name: match[3], + kind: match[2] === "interface" ? "interface" : match[2] === "enum" ? "enum" : "class", + line: lineAt(starts, match.index), + exported: /\bpublic\b/.test(match[1]) + }); + } + + return { imports: [...imports], symbols }; +} + +function readJava(code: string): LanguageReading { + const imports = new Set(); + const starts = lineStarts(code); + const symbols: SymbolInfo[] = []; + + for (const match of matchAll(code, /^[ \t]*import[ \t]+(?:static[ \t]+)?([A-Za-z_][\w.]*(?:\.\*)?)[ \t]*;/m)) { + imports.add(match[1]); + } + + for (const match of matchAll(code, /^[ \t]*((?:(?:public|private|protected|static|abstract|final|sealed)[ \t]+)*)(class|interface|enum|record|@interface)[ \t]+([A-Za-z_]\w*)/m)) { + symbols.push({ + name: match[3], + kind: match[2] === "interface" || match[2] === "@interface" ? "interface" : match[2] === "enum" ? "enum" : "class", + line: lineAt(starts, match.index), + exported: /\bpublic\b/.test(match[1]) + }); + } + + return { imports: [...imports], symbols }; +} + +function byLine(left: SymbolInfo, right: SymbolInfo): number { + return left.line - right.line || left.name.localeCompare(right.name); +} diff --git a/packages/cli/test/heuristic-languages.test.ts b/packages/cli/test/heuristic-languages.test.ts new file mode 100644 index 0000000..e41affe --- /dev/null +++ b/packages/cli/test/heuristic-languages.test.ts @@ -0,0 +1,206 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { ScannedFile } from "../src/analyzers/analysis/index.js"; +import { buildDependencyGraph } from "../src/analyzers/graph/index.js"; +import { HeuristicAnalyzer } from "../src/analyzers/pipeline/heuristicAnalyzer.js"; + +const analyzer = new HeuristicAnalyzer(); + +function fileOf(path: string, content: string): ScannedFile { + const extension = path.slice(path.lastIndexOf(".")); + return { path, absolutePath: `/p/${path}`, extension, size: content.length, lines: content.split("\n").length, content }; +} + +async function analyze(path: string, content: string) { + return analyzer.analyze(fileOf(path, content), { projectRoot: "/p" } as never); +} + +test("python: imports, relative imports, symbols and visibility", async () => { + const analysis = await analyze("app/views.py", [ + '"""Views for the app. This class of helpers is documented here."""', + "import os, sys as system", + "import json # import not_real", + "from app.models import User", + "from .forms import LoginForm", + "from ..core.db import session", + "from . import helpers, utils as u", + "", + "def index(request):", + " return 1", + "", + "async def fetch():", + " pass", + "", + "def _private():", + " pass", + "", + "class Page:", + " def render(self):", + " pass", + "" + ].join("\n")); + + assert.deepEqual([...analysis.imports].sort(), [ + "../core/db", "./forms", "./helpers", "./utils", "app.models", "json", "os", "sys" + ].sort()); + const byName = Object.fromEntries(analysis.symbols.map((symbol) => [symbol.name, symbol])); + assert.equal(byName.index.kind, "function"); + assert.equal(byName.index.exported, true); + assert.equal(byName.fetch.async, true); + assert.equal(byName._private.exported, false); + assert.equal(byName.Page.kind, "class"); + assert.equal(byName.render.kind, "method"); + assert.equal(byName.render.exported, false); + assert.equal(byName.index.line, 9); + assert.equal(analysis.symbols.some((symbol) => symbol.name === "of"), false, "docstring text is not code"); + assert.deepEqual(analysis.exports, ["Page", "fetch", "index"]); +}); + +test("python: imports resolve to local modules in the dependency graph", async () => { + const files = [ + fileOf("manage.py", "from app.models import User\nfrom app import settings\n"), + fileOf("app/__init__.py", ""), + fileOf("app/models.py", "from .base import Base\n"), + fileOf("app/base.py", ""), + fileOf("app/settings.py", ""), + fileOf("src/lib/util.py", ""), + fileOf("app/views.py", "import lib.util\nimport os\n") + ]; + const analyses = Object.fromEntries(await Promise.all(files.map( + async (file) => [file.path, await analyzer.analyze(file, { projectRoot: "/p" } as never)] as const + ))); + const { graph } = buildDependencyGraph(files, analyses); + + assert.deepEqual(graph["manage.py"], ["app/models.py", "app/__init__.py"]); + assert.deepEqual(graph["app/models.py"], ["app/base.py"]); + assert.deepEqual(graph["app/views.py"], ["src/lib/util.py"]); +}); + +test("ruby: require, require_relative, classes and methods", async () => { + const analysis = await analyze("lib/user.rb", [ + "# require 'commented'", + "require 'json'", + "require_relative 'base'", + "module Accounts", + " class User < Base", + " def self.find(id)", + " end", + " def name", + " end", + " end", + "end", + "def helper", + "end" + ].join("\n")); + + assert.deepEqual(analysis.imports, ["json", "./base"]); + const names = analysis.symbols.map((symbol) => `${symbol.kind}:${symbol.name}`); + assert.deepEqual(names, [ + "class:Accounts", "class:User", "method:find", "method:name", "function:helper" + ]); +}); + +test("php: use statements, relative requires and declarations", async () => { + const analysis = await analyze("src/Controller.php", [ + " [symbol.name, symbol])); + assert.equal(byName.Controller.kind, "class"); + assert.equal(byName.Handler.kind, "interface"); + assert.equal(byName.show.kind, "method"); + assert.equal(byName.boot.kind, "function"); +}); + +test("go: single and block imports, exported funcs, methods and types", async () => { + const analysis = await analyze("cmd/server/main.go", [ + "package main", + "", + 'import "fmt"', + "import (", + '\t"net/http"', + '\tlog "github.com/sirupsen/logrus"', + '\t// "commented/out"', + ")", + "", + "type Server struct{}", + "type Handler interface{}", + "func (s *Server) Start() {}", + "func main() {}", + "func Run() {}" + ].join("\n")); + + assert.deepEqual([...analysis.imports].sort(), ["fmt", "github.com/sirupsen/logrus", "net/http"]); + const byName = Object.fromEntries(analysis.symbols.map((symbol) => [symbol.name, symbol])); + assert.equal(byName.Server.kind, "class"); + assert.equal(byName.Server.exported, true); + assert.equal(byName.Handler.kind, "interface"); + assert.equal(byName.Start.kind, "method"); + assert.equal(byName.main.exported, false); + assert.equal(byName.Run.exported, true); +}); + +test("c#: using directives and public types, ignoring using statements", async () => { + const analysis = await analyze("Services/OrderService.cs", [ + "using System;", + "using System.Collections.Generic;", + "using static System.Math;", + "namespace Shop.Services {", + " public class OrderService {", + " void Run() { using (var scope = Create()) { } }", + " }", + " internal interface IHidden {}", + "}" + ].join("\n")); + + assert.deepEqual(analysis.imports, ["System", "System.Collections.Generic", "System.Math"]); + const byName = Object.fromEntries(analysis.symbols.map((symbol) => [symbol.name, symbol])); + assert.equal(byName.OrderService.exported, true); + assert.equal(byName.IHidden.exported, false); +}); + +test("java: imports and public types", async () => { + const analysis = await analyze("src/main/java/Foo.java", [ + "package demo;", + "import java.util.List;", + "import static org.junit.Assert.*;", + "// import java.io.File;", + "public class Foo {}", + "interface Bar {}", + "public enum Mode { A }" + ].join("\n")); + + assert.deepEqual(analysis.imports, ["java.util.List", "org.junit.Assert.*"]); + const byName = Object.fromEntries(analysis.symbols.map((symbol) => [symbol.name, symbol])); + assert.equal(byName.Foo.exported, true); + assert.equal(byName.Bar.exported, false); + assert.equal(byName.Mode.kind, "enum"); +}); + +test("comment markers inside string literals do not hide code", async () => { + const analysis = await analyze("main.go", [ + 'var url = "http://example.com" // trailing', + "func Real() {}" + ].join("\n")); + assert.deepEqual(analysis.symbols.map((symbol) => symbol.name), ["Real"]); +}); + +test("javascript-family files handled by the heuristic analyzer still find dynamic imports", async () => { + const analysis = await analyze("lib/loader.mjs", 'export const load = () => import("./page.mjs");\nconst fs = require("node:fs");\n'); + assert.deepEqual([...analysis.imports].sort(), ["./page.mjs", "node:fs"]); + assert.equal(analysis.symbols.find((symbol) => symbol.name === "load")?.exported, true); +}); From 73ec7faf7d4d46a79c8b6153ff9b0d61e3c67669 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 18:17:19 +0800 Subject: [PATCH 3/3] fix(detectors): framework, route, entry point and scope detection gaps - package.json with a UTF-8 BOM no longer empties every manifest detector - @vue/cli-service (not vue-cli-service) marks a Vue app - npm init's default "main" no longer turns an Express/Next app into a 'library' with framework unknown - barrel index files that the app imports are no longer entry points; only production importers count, so tests importing src/index.ts do not demote it - sample/doc folders are only excluded at repo or workspace level, so a src/features/demo or src/docs feature is analysed; spec/test patterns cover .mts/.cts and Python/Go test files - routes: pages/index is '/', export { h as GET } and export const { GET } = handlers report methods, Nuxt server/api + server/routes, express mounts with middleware and multi-level router nesting - file scope: root test/ folders are tests; .svelte/.vue/.astro and +page/+layout files under routes/ are UI - projectMap: fingerprint no longer depends on locale, escape entity slug in the CRUD flow regex, fix the db/ data-layer pattern, drop devmap-only projectMap/filescanner purposes that mislabelled other projects Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_019HJc4sv9pSLh7vhduSUxBE --- packages/cli/src/analyzers/analysis/index.ts | 4 + .../cli/src/analyzers/analysis/jsonText.ts | 8 + .../analyzers/detectors/databaseDetector.ts | 4 +- .../analyzers/detectors/frameworkDetector.ts | 7 +- .../src/analyzers/detectors/routeDetector.ts | 131 +++++++++--- .../analyzers/detectors/serviceDetector.ts | 4 +- .../cli/src/analyzers/graph/entryPoints.ts | 20 +- packages/cli/src/analyzers/graph/index.ts | 1 + .../cli/src/analyzers/graph/sourceScope.ts | 45 +++-- .../cli/src/analyzers/pipeline/projectMap.ts | 49 ++--- .../src/analyzers/pipeline/projectMetadata.ts | 46 ++++- packages/cli/test/detector-fixes.test.ts | 188 ++++++++++++++++++ 12 files changed, 424 insertions(+), 83 deletions(-) create mode 100644 packages/cli/src/analyzers/analysis/jsonText.ts create mode 100644 packages/cli/test/detector-fixes.test.ts diff --git a/packages/cli/src/analyzers/analysis/index.ts b/packages/cli/src/analyzers/analysis/index.ts index de60a6c..16f4386 100644 --- a/packages/cli/src/analyzers/analysis/index.ts +++ b/packages/cli/src/analyzers/analysis/index.ts @@ -33,6 +33,10 @@ export { shouldIgnorePath, } from "./filterEngine.js"; +export { + parseJsonText, +} from "./jsonText.js"; + export { PrismaExtractor, } from "./extractors/prismaExtractor.js"; diff --git a/packages/cli/src/analyzers/analysis/jsonText.ts b/packages/cli/src/analyzers/analysis/jsonText.ts new file mode 100644 index 0000000..a002978 --- /dev/null +++ b/packages/cli/src/analyzers/analysis/jsonText.ts @@ -0,0 +1,8 @@ +/** + * JSON.parse that tolerates a leading UTF-8 byte order mark. Windows editors + * write BOM-prefixed package.json files that npm accepts but plain JSON.parse + * rejects, which silently emptied every manifest-based detector. + */ +export function parseJsonText(content: string): unknown { + return JSON.parse(content.charCodeAt(0) === 0xfeff ? content.slice(1) : content); +} diff --git a/packages/cli/src/analyzers/detectors/databaseDetector.ts b/packages/cli/src/analyzers/detectors/databaseDetector.ts index dfdfb89..c3e6fac 100644 --- a/packages/cli/src/analyzers/detectors/databaseDetector.ts +++ b/packages/cli/src/analyzers/detectors/databaseDetector.ts @@ -1,4 +1,4 @@ -import type { ScannedFile } from "../analysis/index.js"; +import { parseJsonText, type ScannedFile } from "../analysis/index.js"; import { isArchitectureSource } from "../graph/index.js"; export type DatabaseInfo = { @@ -75,7 +75,7 @@ function readDependencies(files: ScannedFile[]): Set { for (const file of files.filter((item) => item.path.endsWith("package.json"))) { try { - const parsed = JSON.parse(file.content) as { + const parsed = parseJsonText(file.content) as { dependencies?: Record; devDependencies?: Record; }; diff --git a/packages/cli/src/analyzers/detectors/frameworkDetector.ts b/packages/cli/src/analyzers/detectors/frameworkDetector.ts index 4dd4e9d..8a113fd 100644 --- a/packages/cli/src/analyzers/detectors/frameworkDetector.ts +++ b/packages/cli/src/analyzers/detectors/frameworkDetector.ts @@ -1,4 +1,4 @@ -import type { ScannedFile } from "../analysis/index.js"; +import { parseJsonText, type ScannedFile } from "../analysis/index.js"; import { isArchitectureSource } from "../graph/index.js"; export const FRONTEND_FRAMEWORKS = [ @@ -65,6 +65,7 @@ export function detectFrameworks(files: ScannedFile[]): DetectedFramework[] { // Vue UI library (vue only in peerDependencies) from being labeled an app. const hasVueRuntime = "@vitejs/plugin-vue" in dependencies || "@vitejs/plugin-vue2" in dependencies + || "@vue/cli-service" in dependencies || "vue-cli-service" in dependencies; if (!("nuxt" in dependencies) && "vue" in dependencies && hasVueRuntime) { detected.add("vue"); @@ -158,7 +159,7 @@ export function detectFrameworks(files: ScannedFile[]): DetectedFramework[] { function isParseableJson(content: string): boolean { try { - JSON.parse(content); + parseJsonText(content); return true; } catch { return false; @@ -181,7 +182,7 @@ function readManifestDependencies(files: ScannedFile[]): Array { try { - const parsed = JSON.parse(content) as { + const parsed = parseJsonText(content) as { dependencies?: Record; devDependencies?: Record; }; diff --git a/packages/cli/src/analyzers/detectors/routeDetector.ts b/packages/cli/src/analyzers/detectors/routeDetector.ts index b7c3752..f1d01d7 100644 --- a/packages/cli/src/analyzers/detectors/routeDetector.ts +++ b/packages/cli/src/analyzers/detectors/routeDetector.ts @@ -97,7 +97,8 @@ function detectNextRoutes(files: ScannedFile[]): RouteInfo[] { } const isApi = pagesMatch[1].startsWith("api/"); - const segments = pagesMatch[1].replace(/\/index$/, "").split("/").filter(Boolean); + // pages/index.tsx is "/" and pages/blog/index.tsx is "/blog". + const segments = pagesMatch[1].replace(/(^|\/)index$/, "").split("/").filter(Boolean); routes.push({ path: toRoutePath(segments), file: file.path, @@ -153,6 +154,31 @@ function detectNuxtRoutes(files: ScannedFile[]): RouteInfo[] { const routes: RouteInfo[] = []; for (const file of files.filter((item) => isArchitectureSource(item.path))) { + const serverMatch = file.path.match(/(?:^|\/)server\/(api|routes)\/(.+)\.[cm]?[jt]s$/); + if (serverMatch) { + // Nitro: server/api/** is mounted under /api, server/routes/** at the root, + // and a ".get"/".post" suffix pins the method (users/[id].get.ts). + const methodSuffix = serverMatch[2].match(/\.(get|post|put|patch|delete|options|head)$/i); + const withoutMethod = methodSuffix + ? serverMatch[2].slice(0, -methodSuffix[0].length) + : serverMatch[2]; + const serverSegments = [ + ...(serverMatch[1] === "api" ? ["api"] : []), + ...withoutMethod.split("/").filter(Boolean) + ]; + const cleanServerSegments = serverSegments[serverSegments.length - 1] === "index" + ? serverSegments.slice(0, -1) + : serverSegments; + + routes.push({ + path: toRoutePath(cleanServerSegments), + file: file.path, + kind: "api", + ...(methodSuffix ? { methods: [methodSuffix[1].toUpperCase()] } : {}) + }); + continue; + } + const match = file.path.match(/(?:^|\/)pages\/(.+)\.vue$/); if (!match) continue; @@ -216,8 +242,11 @@ const ROUTE_METHOD_PATTERN = // HTTP-client calls like stripe.get() while catching mounted routers. const MOUNTED_ROUTER_PATTERN = /\b(?:app|\w*[Rr]outer)\.(get|post|put|patch|delete|options|head)\(\s*["'`]([^"'`]+)["'`]/gi; +// app.use("/api", router) plus middleware before the router +// (app.use("/api", auth, limiter(), router)) and member access (api.router). +// The mounting object may be any app/server/*Router, not just "app". const MOUNT_PATTERN = - /\b(?:app|router)\.use\(\s*["'`]([^"'`]+)["'`]\s*,\s*(\w+)\s*\)/g; + /\b(?:app|server|\w*[Rr]outer)\.use\(\s*["'`]([^"'`]+)["'`]\s*,\s*(?:[\w.]+(?:\([^()]*\))?\s*,\s*)*(?:[\w]+\.)*(\w+)\s*\)/g; type ExpressMount = { prefix: string; identifier: string; file: string }; @@ -361,40 +390,71 @@ function detectExpressRoutes( } } - // Second pass: resolve mounted routers and compose prefix + sub-path. - const mountedFiles = new Set(); - for (const mount of mounts) { - const target = resolveRouterTarget( + // Second pass: resolve each mount to the router file it points at. + const resolvedMounts = mounts.map((mount) => ({ + mount, + target: resolveRouterTarget( mount.identifier, mount.file, eligibleFiles, byPath, graph, (file) => collectRouteMethods(file.content, MOUNTED_ROUTER_PATTERN).length > 0 + ) + })); + + // A router can itself be mounted (app -> /api -> v1 router -> /users -> users + // router), so a mount's effective prefix is the chain of mounts above its file. + const mountedBy = new Map(); + for (const { mount, target } of resolvedMounts) { + if (target && target.path !== mount.file) { + mountedBy.set(target.path, [...(mountedBy.get(target.path) ?? []), mount]); + } + } + + const prefixesOf = (file: string, seen: Set = new Set()): string[] => { + const parents = mountedBy.get(file); + if (!parents || seen.has(file)) return [""]; + const nextSeen = new Set(seen).add(file); + return parents.flatMap((parent) => + prefixesOf(parent.file, nextSeen).map((outer) => composeMountPath(outer, parent.prefix)) ); + }; + + const mountsByFile = new Set(mounts.map((mount) => mount.file)); + const mountedFiles = new Set(); + for (const { mount, target } of resolvedMounts) { const subRoutes = target ? collectRouteMethods(target.content, MOUNTED_ROUTER_PATTERN) : []; - if (target && subRoutes.length > 0) { - mountedFiles.add(target.path); - for (const [subPath, methods] of subRoutes) { + for (const outer of prefixesOf(mount.file)) { + const base = composeMountPath(outer, mount.prefix); + + if (target && subRoutes.length > 0) { + mountedFiles.add(target.path); + for (const [subPath, methods] of subRoutes) { + routes.push({ + path: composeMountPath(base, subPath), + file: mount.file, + kind: "api", + methods + }); + } + } else if (target && mountsByFile.has(target.path)) { + // A router that only forwards to further routers: its children carry + // the full prefix, so a bare USE entry would be noise. + mountedFiles.add(target.path); + } else { + // Unresolvable or empty router — keep the mount itself as a USE route so + // the middleware prefix stays visible instead of silently disappearing. routes.push({ - path: composeMountPath(mount.prefix, subPath), + path: base || "/", file: mount.file, kind: "api", - methods + methods: ["USE"] }); } - } else { - // Unresolvable or empty router — keep the mount itself as a USE route so - // the middleware prefix stays visible instead of silently disappearing. - routes.push({ - path: mount.prefix, - file: mount.file, - kind: "api", - methods: ["USE"] - }); } } @@ -483,14 +543,35 @@ function toRoutePath(segments: string[]): string { return `/${visible.join("/")}`.replace(/\/+/g, "/"); } +const HTTP_METHOD_NAMES = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"]); + function findHttpMethods(content: string): string[] { const methods = new Set(); - const pattern = /export\s+(?:async\s+)?(?:function|const)\s+(GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)\b/g; - let match = pattern.exec(content); + const add = (name: string | undefined) => { + if (name && HTTP_METHOD_NAMES.has(name)) methods.add(name); + }; + + // export async function GET() {} / export const POST = ... + for (const match of content.matchAll( + /export\s+(?:async\s+)?(?:function|const)\s+(GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)\b/g + )) { + add(match[1]); + } - while (match) { - methods.add(match[1]); - match = pattern.exec(content); + // export { handler as GET, handler as POST }, export { GET } from "./handlers" + for (const match of content.matchAll(/export\s*(?:type\s*)?\{([^}]*)\}/g)) { + for (const specifier of match[1].split(",")) { + const exported = specifier.trim().split(/\s+as\s+/).pop()?.trim(); + add(exported); + } + } + + // export const { GET, POST } = handlers; (Auth.js and similar) + for (const match of content.matchAll(/export\s+const\s*\{([^}]*)\}\s*=/g)) { + for (const element of match[1].split(",")) { + const bound = element.trim().split(":").pop()?.trim().split("=")[0].trim(); + add(bound); + } } return [...methods].sort(); diff --git a/packages/cli/src/analyzers/detectors/serviceDetector.ts b/packages/cli/src/analyzers/detectors/serviceDetector.ts index 8d71f71..adfaff2 100644 --- a/packages/cli/src/analyzers/detectors/serviceDetector.ts +++ b/packages/cli/src/analyzers/detectors/serviceDetector.ts @@ -1,4 +1,4 @@ -import type { ScannedFile } from "../analysis/index.js"; +import { parseJsonText, type ScannedFile } from "../analysis/index.js"; import { isArchitectureSource } from "../graph/index.js"; import { SERVICES, SOURCE_SERVICE_SIGNALS } from "../registry/index.js"; @@ -28,7 +28,7 @@ function readPackageDependencyNames(files: ScannedFile[]): string[] { for (const file of packageFiles) { try { - const parsed = JSON.parse(file.content) as { + const parsed = parseJsonText(file.content) as { dependencies?: Record; devDependencies?: Record; }; diff --git a/packages/cli/src/analyzers/graph/entryPoints.ts b/packages/cli/src/analyzers/graph/entryPoints.ts index df8e2ea..4d9d6e0 100644 --- a/packages/cli/src/analyzers/graph/entryPoints.ts +++ b/packages/cli/src/analyzers/graph/entryPoints.ts @@ -26,12 +26,28 @@ function entryPointScore(path: string): number { } export function detectEntryPoints(graph: FileGraph): string[] { - const imported = new Set(Object.values(graph).flat()); + // 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. + const imported = new Set( + Object.entries(graph) + .filter(([path]) => isArchitectureSource(path)) + .flatMap(([, imports]) => imports) + ); return Object.keys(graph) .filter((path) => isSourceFile(path)) .filter((path) => isArchitectureSource(path)) - .filter((path) => ENTRY_PATTERNS.some((pattern) => pattern.test(path)) || (!imported.has(path) && (graph[path]?.length ?? 0) > 0)) + .filter((path) => { + 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 + // the package entry even when it has no imports of its own. + 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; + }) .sort((a, b) => entryPointScore(a) - entryPointScore(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 d57b39b..80148cb 100644 --- a/packages/cli/src/analyzers/graph/index.ts +++ b/packages/cli/src/analyzers/graph/index.ts @@ -23,6 +23,7 @@ export { export { isArchitectureSource, + isTestPath, } from "./sourceScope.js"; export { diff --git a/packages/cli/src/analyzers/graph/sourceScope.ts b/packages/cli/src/analyzers/graph/sourceScope.ts index 5fac3fc..62203b0 100644 --- a/packages/cli/src/analyzers/graph/sourceScope.ts +++ b/packages/cli/src/analyzers/graph/sourceScope.ts @@ -1,34 +1,47 @@ +// Test and fixture folders are never production code, at any depth. const NON_PRODUCTION_SEGMENTS = new Set([ "__fixtures__", "__mocks__", "__tests__", "coverage", - "demo", - "docs", - "example", - "examples", "fixtures", - "samples", "test", "tests" ]); +// Sample material is only recognised where a repo keeps it (the root or a +// workspace member's root). A product feature that happens to live in +// src/features/demo or src/docs is real code. +const SAMPLE_SEGMENTS = new Set(["demo", "docs", "example", "examples", "samples"]); +const WORKSPACE_PARENTS = new Set(["apps", "packages", "libs", "services", "projects"]); + +const TEST_FILE_PATTERN = /\.(?:test|spec)\.[cm]?[jt]sx?$|(?:^|\/)test_[^/]+\.py$|_test\.(?:py|go)$|(?:^|\/)conftest\.py$/; + +/** Test, spec, fixture and mock files, including a root-level test/ folder. */ +export function isTestPath(path: string): boolean { + const normalized = path.toLowerCase(); + return normalized.split("/").some((segment) => NON_PRODUCTION_SEGMENTS.has(segment)) + || TEST_FILE_PATTERN.test(normalized); +} + export function isArchitectureSource(path: string): boolean { const normalized = path.toLowerCase(); const segments = normalized.split("/"); - if (segments.some((segment) => NON_PRODUCTION_SEGMENTS.has(segment))) { + if (isTestPath(normalized)) { + return false; + } + + if (SAMPLE_SEGMENTS.has(segments[0]) && segments.length > 1) { + return false; + } + if ( + segments.length > 3 + && WORKSPACE_PARENTS.has(segments[0]) + && SAMPLE_SEGMENTS.has(segments[2]) + ) { return false; } - return !( - normalized.endsWith(".test.ts") - || normalized.endsWith(".test.tsx") - || normalized.endsWith(".test.js") - || normalized.endsWith(".test.jsx") - || normalized.endsWith(".spec.ts") - || normalized.endsWith(".spec.tsx") - || normalized.endsWith(".spec.js") - || normalized.endsWith(".spec.jsx") - ); + return true; } diff --git a/packages/cli/src/analyzers/pipeline/projectMap.ts b/packages/cli/src/analyzers/pipeline/projectMap.ts index 48943ed..31c61fa 100644 --- a/packages/cli/src/analyzers/pipeline/projectMap.ts +++ b/packages/cli/src/analyzers/pipeline/projectMap.ts @@ -14,6 +14,7 @@ import { buildDependencyGraph, countReferences, detectEntryPoints, isArchitectureSource, + isTestPath, loadAliasMappings, type DependencyGraphDiagnostics, } from "../graph/index.js"; @@ -29,6 +30,7 @@ import { } from "../features/index.js"; import { scanFiles, + parseJsonText, extractEntities, type ScannedFile, type EntityGraph, @@ -321,7 +323,9 @@ function createAgentInstructions(): ProjectMap["agentInstructions"] { export function createProjectFingerprint(files: ScannedFile[]): string { const content = files .map((file) => [file.path, hashContent(file.content)] as const) - .sort(([left], [right]) => left.localeCompare(right)) + // Plain code-unit order: localeCompare would change the fingerprint of an + // unchanged project when the machine locale differs. + .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) .map(([path, hash]) => `${path}:${hash}`) .join("\n"); @@ -402,7 +406,7 @@ function readPackageDependencies(files: ScannedFile[]): Record } try { - const parsed = JSON.parse(packageJson.content) as { + const parsed = parseJsonText(packageJson.content) as { dependencies?: Record; devDependencies?: Record; }; @@ -473,7 +477,10 @@ function classifyFileScope( const text = `${path} ${name} ${exportedSymbols.join(" ")} ${imports.join(" ")}`.toLowerCase(); if (isTestFile(path)) return "test"; - if (path.endsWith(".md") || path.startsWith("docs/")) return "docs"; + if (path.endsWith(".md") || path.endsWith(".mdx") || path.startsWith("docs/")) return "docs"; + // Single-file components and SvelteKit page/layout files are UI even when they + // live under a routes/ folder; +server files stay API (matched below). + if (/\.(svelte|vue|astro)$/.test(path) || /(^|\/)\+(page|layout|error)(\.server)?\.[cm]?[jt]s$/.test(path)) return "ui"; if (/(^|\/)(api|apis|routes?|controllers?|actions?)\//.test(path)) return "api"; if (/(^|\/)(server|app|main|index)\.[cm]?[jt]sx?$/.test(path) && imports.some((specifier) => /routes?|controllers?|api/.test(specifier))) return "api"; if (/(^|\/)(views?|pages?|components?|screens?|templates?|layouts?)\//.test(path)) return "ui"; @@ -632,12 +639,6 @@ function inferKnownFilePurpose(path: string): string | undefined { if (/\/ai\/completion\.[cm]?[jt]s$/.test(path)) { return "coordinates streaming and non-streaming AI completion output."; } - if (/\/analyzers\/projectmap\.[cm]?[jt]s$/.test(path)) { - return "orchestrates scanning, analysis, feature mapping, flows, and snapshot metadata."; - } - if (/\/analyzers\/filescanner\.[cm]?[jt]s$/.test(path)) { - return "scans eligible project files while applying ignore and safety rules."; - } if (/(^|\/)types?\.[cm]?[jt]s$/.test(path)) { return "defines shared type contracts used by neighboring modules."; } @@ -839,14 +840,16 @@ function buildStructuralFeatureFlow(featureName: string, files: string[]): strin const entitySlug = featureName .toLowerCase() .replace(/\s+(management|system|module|feature)$/, "") - .replace(/\s+/g, "[-_]?"); + .split(/\s+/) + .map(escapeRegExpSource) + .join("[-_]?"); - const entityPattern = new RegExp(`\/${entitySlug}`, "i"); + const entityPattern = new RegExp(`\\/${entitySlug}`, "i"); steps.push( ["Handle API request", find(entityPattern, /\/(routes?|controllers?|api)\//i)], ["Apply business logic", find(/\/(services?|usecases?|actions?)\//i)], - ["Access data", find(/\/(repositories?|lib\/prisma|db\/)\//i)], + ["Access data", find(/\/(repositories?|lib\/prisma|lib\/db|db)\//i)], ); } @@ -1106,26 +1109,16 @@ export function renderMermaidFlow(steps: FlowInfo["steps"]): string { return lines.join("\n"); } +function escapeRegExpSource(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + function escapeMermaidLabel(value: string): string { return value.replace(/"/g, "'"); } function isTestFile(path: string): boolean { - const normalized = path.toLowerCase(); - return ( - normalized.includes("/test/") - || normalized.includes("/tests/") - || normalized.includes("/__tests__/") - || normalized.includes("/fixtures/") - || normalized.endsWith(".test.ts") - || normalized.endsWith(".test.tsx") - || normalized.endsWith(".test.js") - || normalized.endsWith(".test.jsx") - || normalized.endsWith(".spec.ts") - || normalized.endsWith(".spec.tsx") - || normalized.endsWith(".spec.js") - || normalized.endsWith(".spec.jsx") - ); + return isTestPath(path); } function isVagueSearchTerm(term: string): boolean { @@ -1168,7 +1161,7 @@ function detectAnalysisWarnings( } try { - JSON.parse(packageJson.content); + parseJsonText(packageJson.content); } catch { warnings.push("package.json could not be parsed. Dependency-based detection may be incomplete."); } diff --git a/packages/cli/src/analyzers/pipeline/projectMetadata.ts b/packages/cli/src/analyzers/pipeline/projectMetadata.ts index f064080..c6a0cbc 100644 --- a/packages/cli/src/analyzers/pipeline/projectMetadata.ts +++ b/packages/cli/src/analyzers/pipeline/projectMetadata.ts @@ -1,6 +1,7 @@ import { existsSync } from "node:fs"; import { basename, join } from "node:path"; import type { ScannedFile } from "../analysis/fileScanner.js"; +import { parseJsonText } from "../analysis/jsonText.js"; import { BACKEND_FRAMEWORK_SET, FRONTEND_FRAMEWORK_SET, @@ -37,7 +38,7 @@ export function detectProjectMetadata( const projectTypes = detectProjectTypes(frameworks, manifests); const primaryManifest = selectPrimaryManifest(manifests, projectTypes); const workspaceType = detectWorkspaceType(projectRoot, manifests); - const primaryFramework = isRootCliOrLibrary(manifests) ? "unknown" : framework; + const primaryFramework = isRootCliOrLibrary(manifests, framework) ? "unknown" : framework; return { name: readProjectName(manifests) ?? basename(projectRoot), @@ -70,7 +71,7 @@ function readPackageManifests(files: ScannedFile[]): PackageManifest[] { .filter((file) => file.path.endsWith("package.json") && isArchitectureSource(file.path)) .flatMap((file) => { try { - const parsed = JSON.parse(file.content) as Record; + const parsed = parseJsonText(file.content) as Record; return [{ path: file.path, ...(typeof parsed.name === "string" ? { name: parsed.name } : {}), @@ -100,12 +101,43 @@ function readPackageManifests(files: ScannedFile[]): PackageManifest[] { * itself a CLI, and shouldn't have its correctly-detected framework (e.g. * an Astro app living in apps/web) blanked out to "unknown" because of it. */ -function isRootCliOrLibrary(manifests: PackageManifest[]): boolean { +function isRootCliOrLibrary(manifests: PackageManifest[], framework: Framework): boolean { const rootManifest = manifests.find((manifest) => manifest.path === "package.json"); if (!rootManifest) return false; if (rootManifest.bin) return true; - return Boolean((rootManifest.exports || rootManifest.main) && !rootManifest.bin); + + // `npm init` writes "main": "index.js" into every package, including plain + // Express/Next apps. A root that itself declares the detected framework is + // that app, not a library, whatever its main/exports say. + if (framework !== "unknown" && declaresFramework(rootManifest, framework)) return false; + + return Boolean(rootManifest.exports || rootManifest.main); +} + +// Runtime dependency whose presence in a manifest means "this package is that app". +const FRAMEWORK_DEPENDENCIES: Record = { + nextjs: ["next"], + nuxt: ["nuxt"], + sveltekit: ["@sveltejs/kit"], + astro: ["astro"], + react: ["react-dom", "react-scripts"], + vue: ["vue"], + svelte: ["svelte"], + nestjs: ["@nestjs/core"], + fastify: ["fastify"], + express: ["express"], + koa: ["koa"] +}; + +function declaresFramework(manifest: PackageManifest, framework: Framework): boolean { + return (FRAMEWORK_DEPENDENCIES[framework] ?? []).some((dependency) => + dependency in manifest.dependencies || dependency in manifest.devDependencies + ); +} + +function declaresAnyFramework(manifest: PackageManifest): boolean { + return Object.keys(FRAMEWORK_DEPENDENCIES).some((framework) => declaresFramework(manifest, framework as Framework)); } function readProjectName(manifests: PackageManifest[]): string | null { @@ -128,7 +160,11 @@ function detectProjectTypes( if (manifests.some((manifest) => manifest.bin)) { types.add("node-cli"); } - if (manifests.some((manifest) => (manifest.exports || manifest.main) && !manifest.bin)) { + // A package that ships an app framework with its default "main" is an app, + // not a library. + if (manifests.some((manifest) => + (manifest.exports || manifest.main) && !manifest.bin && !declaresAnyFramework(manifest) + )) { types.add("library"); } diff --git a/packages/cli/test/detector-fixes.test.ts b/packages/cli/test/detector-fixes.test.ts new file mode 100644 index 0000000..d2f4019 --- /dev/null +++ b/packages/cli/test/detector-fixes.test.ts @@ -0,0 +1,188 @@ +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 type { ScannedFile } from "../src/analyzers/analysis/index.js"; +import { detectFrameworks, detectRoutes } from "../src/analyzers/detectors/index.js"; +import { buildDependencyGraph, detectEntryPoints, isArchitectureSource } from "../src/analyzers/graph/index.js"; +import { createProjectMap } from "../src/analyzers/pipeline/projectMap.js"; +import { detectProjectMetadata } from "../src/analyzers/pipeline/projectMetadata.js"; + +function fileOf(path: string, content = ""): ScannedFile { + const extension = path.includes(".") ? path.slice(path.lastIndexOf(".")) : ""; + return { path, absolutePath: `/p/${path}`, extension, size: content.length, lines: 1, content }; +} + +async function withProject(files: Record, run: (root: string) => Promise) { + const root = await mkdtemp(join(tmpdir(), "devmap-detect-")); + 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 }); + } +} + +// ------------------------------------------------------------ framework + meta + +test("a package.json with a UTF-8 BOM is still read", () => { + const manifest = `${JSON.stringify({ name: "api", dependencies: { express: "^4.0.0" } })}`; + assert.deepEqual(detectFrameworks([fileOf("package.json", manifest)]), ["express"]); +}); + +test("@vue/cli-service marks a Vue app", () => { + const manifest = JSON.stringify({ + name: "legacy", dependencies: { vue: "^2.6.0" }, devDependencies: { "@vue/cli-service": "~4.5.0" } + }); + assert.deepEqual(detectFrameworks([fileOf("package.json", manifest)]), ["vue"]); +}); + +test("an Express app with npm init's default main is not downgraded to a library", () => { + const manifest = JSON.stringify({ name: "api", main: "index.js", dependencies: { express: "^4.0.0" } }); + const files = [fileOf("package.json", manifest), fileOf("index.js", "const express = require('express');\n")]; + const frameworks = detectFrameworks(files); + const metadata = detectProjectMetadata("/p", frameworks[0] ?? "unknown", files, frameworks); + + assert.equal(metadata.framework, "express"); + assert.ok(metadata.projectTypes.includes("api-service")); + assert.ok(!metadata.projectTypes.includes("library")); +}); + +test("a plain package with main/exports and no app framework is still a library", () => { + const files = [fileOf("package.json", JSON.stringify({ name: "lib", main: "dist/index.js" })), fileOf("src/index.ts", "export const a = 1;")]; + const metadata = detectProjectMetadata("/p", "unknown", files, []); + assert.deepEqual(metadata.projectTypes, ["library"]); +}); + +// ------------------------------------------------------------ entry + scope + +test("barrel index files that the app imports are not entry points", () => { + const entries = detectEntryPoints({ + "src/main.ts": ["src/components/index.ts", "src/components/index.test.ts"], + "src/components/index.ts": ["src/components/Button.ts"], + "src/components/Button.ts": [], + "src/components/index.test.ts": ["src/components/index.ts"] + }); + assert.deepEqual(entries, ["src/main.ts"]); +}); + +test("a root index imported only by its tests is still an entry point", () => { + const entries = detectEntryPoints({ + "src/index.ts": ["src/run.ts"], + "src/run.ts": [], + "src/index.test.ts": ["src/index.ts"] + }); + assert.deepEqual(entries, ["src/index.ts"]); +}); + +test("feature folders named demo/docs are source; repo-level samples and test folders are not", () => { + assert.equal(isArchitectureSource("src/features/demo/page.ts"), true); + assert.equal(isArchitectureSource("src/docs/render.ts"), true); + assert.equal(isArchitectureSource("examples/basic/index.ts"), false); + assert.equal(isArchitectureSource("packages/ui/examples/a.ts"), false); + assert.equal(isArchitectureSource("test/helpers.ts"), false); + assert.equal(isArchitectureSource("src/util.test.mts"), false); + assert.equal(isArchitectureSource("app/test_models.py"), false); +}); + +test("root-level test folders and SvelteKit pages get the right file scope", async () => { + await withProject({ + "package.json": JSON.stringify({ name: "kit", devDependencies: { "@sveltejs/kit": "^2.0.0" } }), + "src/routes/+page.svelte": "\n

{a}

\n", + "src/routes/api/items/+server.ts": "export const GET = () => new Response('ok');\n", + "test/helpers.ts": "export const helper = 1;\n" + }, async (root) => { + const map = await createProjectMap(root); + assert.equal(map.fileIndex["src/routes/+page.svelte"]?.scope, "ui"); + assert.equal(map.fileIndex["src/routes/api/items/+server.ts"]?.scope, "api"); + assert.equal(map.fileIndex["test/helpers.ts"]?.scope, "test"); + }); +}); + +// ------------------------------------------------------------------- routes + +test("pages/index is the root route and not /index", () => { + const routes = detectRoutes([ + fileOf("pages/index.tsx", "export default () => null;"), + fileOf("pages/blog/index.tsx", "export default () => null;") + ], ["nextjs"]); + assert.deepEqual(routes.map((route) => route.path), ["/", "/blog"]); +}); + +test("route handlers exported through lists or destructuring still report their methods", () => { + const routes = detectRoutes([ + fileOf("app/api/a/route.ts", "const handler = () => {};\nexport { handler as GET, handler as POST };\n"), + fileOf("app/api/auth/route.ts", "export const { GET, POST } = handlers;\n"), + fileOf("app/api/c/route.ts", 'export { PUT, DELETE } from "./impl";\n') + ], ["nextjs"]); + const byPath = Object.fromEntries(routes.map((route) => [route.path, route.methods])); + assert.deepEqual(byPath["/api/a"], ["GET", "POST"]); + assert.deepEqual(byPath["/api/auth"], ["GET", "POST"]); + assert.deepEqual(byPath["/api/c"], ["DELETE", "PUT"]); +}); + +test("nuxt server/api and server/routes are API routes with method suffixes", () => { + const routes = detectRoutes([ + fileOf("server/api/users/index.get.ts", "export default defineEventHandler(() => []);"), + fileOf("server/api/users/[id].delete.ts", "export default defineEventHandler(() => ({}));"), + fileOf("server/routes/health.ts", "export default defineEventHandler(() => 'ok');"), + fileOf("pages/index.vue", "") + ], ["nuxt"]); + + const summary = routes.map((route) => `${route.kind} ${route.path} ${(route.methods ?? []).join(",")}`.trim()); + assert.deepEqual(summary, [ + "page /", + "api /api/users GET", + "api /api/users/[id] DELETE", + "api /health" + ]); +}); + +test("express routers mounted through several levels compose every prefix", () => { + const files = [ + fileOf("src/app.ts", [ + 'import express from "express";', + 'import { apiRouter } from "./api";', + "const app = express();", + 'app.use("/api", apiRouter);' + ].join("\n")), + fileOf("src/api.ts", [ + 'import { Router } from "express";', + 'import { usersRouter } from "./users";', + "export const apiRouter = Router();", + 'apiRouter.use("/v1", usersRouter);' + ].join("\n")), + fileOf("src/users.ts", [ + 'import { Router } from "express";', + "export const usersRouter = Router();", + 'usersRouter.get("/me", () => {});', + 'usersRouter.post("/", () => {});' + ].join("\n")) + ]; + const { graph } = buildDependencyGraph(files); + const routes = detectRoutes(files, ["express"], graph); + + assert.deepEqual(routes.map((route) => `${route.path} ${route.methods?.join(",")}`), [ + "/api/v1 POST", + "/api/v1/me GET" + ]); +}); + +test("express mounts with middleware before the router still resolve", () => { + const files = [ + fileOf("src/app.ts", [ + 'import { ordersRouter } from "./orders";', + "const app = express();", + 'app.use("/orders", requireAuth, limiter(), ordersRouter);' + ].join("\n")), + fileOf("src/orders.ts", 'export const ordersRouter = Router();\nordersRouter.get("/", () => {});\n') + ]; + const { graph } = buildDependencyGraph(files); + const routes = detectRoutes(files, ["express"], graph); + assert.deepEqual(routes.map((route) => `${route.path} ${route.methods?.join(",")}`), ["/orders GET"]); +});