import type Database from "better-sqlite3"; import { readdirSync, statSync, existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { Project, SyntaxKind, Node, type ModifierableNode, } from "ts-morph"; function detectLayer(basePath: string): string { const normalized = basePath.replace(/\\/g, "/"); if (normalized.endsWith("src/modules")) return "modules"; if (normalized.endsWith("src/shared")) return "shared"; if (normalized.endsWith("src/app")) return "app"; return "root"; } function extractDescription(modulePath: string): string | null { const readmePath = join(modulePath, "README.md"); if (!existsSync(readmePath)) return null; try { const content = readFileSync(readmePath, "utf-8"); const lines = content .split("\n") .filter((l: string) => l.trim() && !l.startsWith("#")); return lines[0]?.slice(0, 200) || null; } catch { return null; } } export function scanModules(db: Database.Database, basePath: string): void { if (!existsSync(basePath)) return; const layer = detectLayer(basePath); const now = new Date().toISOString(); const insert = db.prepare( "INSERT OR IGNORE INTO modules (name, path, description, layer, created_at) VALUES (?, ?, ?, ?, ?)" ); if (layer === "shared") { // src/shared 是单个模块,其子目录是内部结构 const description = extractDescription(basePath); insert.run("shared", basePath, description, layer, now); return; } // src/modules 和 src/app:每个子目录是一个模块 const entries = readdirSync(basePath); for (const entry of entries) { const fullPath = join(basePath, entry); if (!statSync(fullPath).isDirectory()) continue; if (entry.startsWith(".") || entry.startsWith("_")) continue; const description = extractDescription(fullPath); insert.run(entry, fullPath, description, layer, now); } } export function scanAllModules( db: Database.Database, projectRoot: string ): void { scanModules(db, join(projectRoot, "src/modules")); scanModules(db, join(projectRoot, "src/shared")); } const TS_EXTENSIONS = [".ts", ".tsx"]; function detectFileKind(filePath: string): string { const normalized = filePath.replace(/\\/g, "/"); const filename = normalized.split("/").pop() || ""; if (filename === "actions.ts" || filename.match(/^actions-\w+\.ts$/)) return "actions"; if (filename === "data-access.ts" || filename.match(/^data-access-\w+\.ts$/)) return "data-access"; if (filename === "schema.ts") return "schema"; if (filename === "types.ts") return "types"; if (filename.endsWith(".tsx")) return "component"; if (filename.startsWith("use-") && filename.endsWith(".ts")) return "hook"; if (filename === "page.tsx") return "page"; if (filename === "layout.tsx") return "layout"; if (filename === "route.ts") return "route"; if (filename === "error.tsx") return "error"; if (filename === "loading.tsx") return "loading"; if (filename === "proxy.ts") return "config"; if (filename === "next.config.ts") return "config"; return "lib"; } function countLines(content: string): number { return content.split("\n").length; } function detectDirectives(content: string): { isServer: boolean; isClient: boolean; hasServerOnly: boolean; } { const firstLine = content.split("\n")[0] || ""; return { isServer: firstLine.includes('"use server"') || firstLine.includes("'use server'"), isClient: firstLine.includes('"use client"') || firstLine.includes("'use client'"), hasServerOnly: content.includes('import "server-only"') || content.includes("import 'server-only'"), }; } function scanFilesRecursive( db: Database.Database, dirPath: string, moduleName: string ): void { const entries = readdirSync(dirPath); for (const entry of entries) { const fullPath = join(dirPath, entry); const stat = statSync(fullPath); if (stat.isDirectory()) { if (entry.startsWith(".") || entry === "node_modules") continue; scanFilesRecursive(db, fullPath, moduleName); continue; } if (!TS_EXTENSIONS.some((ext) => entry.endsWith(ext))) continue; const content = readFileSync(fullPath, "utf-8"); const directives = detectDirectives(content); const kind = detectFileKind(fullPath); const lines = countLines(content); const moduleId = ( db.prepare("SELECT id FROM modules WHERE name = ?").get(moduleName) as { id: number; } ).id; db.prepare( `INSERT OR REPLACE INTO files (module_id, path, kind, lines, is_server, is_client, has_server_only) VALUES (?, ?, ?, ?, ?, ?, ?)` ).run( moduleId, fullPath.replace(/\\/g, "/"), kind, lines, directives.isServer ? 1 : 0, directives.isClient ? 1 : 0, directives.hasServerOnly ? 1 : 0 ); } } export function scanFiles(db: Database.Database, basePath: string): void { if (!existsSync(basePath)) return; // 扫描 DB 中所有模块的文件(scanModules 已先行填充 modules 表) const modules = db .prepare("SELECT name, path FROM modules") .all() as { name: string; path: string }[]; for (const mod of modules) { if (!existsSync(mod.path)) continue; scanFilesRecursive(db, mod.path, mod.name); } } function hasModifier( node: ModifierableNode | undefined, kind: SyntaxKind ): boolean { if (!node) return false; try { return node.getModifiers().some((m) => m.getKind() === kind); } catch { return false; } } function extractSignature(node: Node): string { try { const text = node.getText(); const firstLine = text.split("\n")[0]; return firstLine.slice(0, 200); } catch { return ""; } } export function scanSymbols(db: Database.Database): void { const project = new Project({ tsConfigFilePath: "./tsconfig.json", skipAddingFilesFromTsConfig: true, }); const files = db .prepare("SELECT id, path FROM files") .all() as { id: number; path: string }[]; const insertSymbol = db.prepare( `INSERT OR IGNORE INTO symbols (file_id, name, kind, is_exported, is_async, is_server_action, signature, start_line, end_line) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` ); for (const file of files) { let sourceFile; try { sourceFile = project.addSourceFileAtPath(file.path); } catch { continue; } const fileContent = sourceFile.getFullText(); const isServerFile = fileContent.startsWith('"use server"') || fileContent.startsWith("'use server'"); sourceFile.forEachChild((node) => { // 处理函数/类/接口/类型别名声明 if ( Node.isFunctionDeclaration(node) || Node.isClassDeclaration(node) || Node.isInterfaceDeclaration(node) || Node.isTypeAliasDeclaration(node) ) { const name = node.getName(); if (!name) return; const kind = Node.isFunctionDeclaration(node) ? "function" : Node.isClassDeclaration(node) ? "class" : Node.isInterfaceDeclaration(node) ? "interface" : "type"; const exported = hasModifier(node, SyntaxKind.ExportKeyword); const async = hasModifier(node, SyntaxKind.AsyncKeyword); const signature = extractSignature(node); const startLine = node.getStartLineNumber(); const endLine = node.getEndLineNumber(); const isServerAction = isServerFile && exported && async; insertSymbol.run( file.id, name, kind, exported ? 1 : 0, async ? 1 : 0, isServerAction ? 1 : 0, signature, startLine, endLine ); } // 处理变量声明(export const/let/var) if (Node.isVariableStatement(node)) { const exported = hasModifier(node, SyntaxKind.ExportKeyword); const declarations = node.getDeclarations(); for (const decl of declarations) { const name = decl.getName(); if (!name) continue; const initializer = decl.getInitializer(); const kind = initializer && initializer.getKind() === SyntaxKind.ArrowFunction ? "function" : "const"; const signature = extractSignature(decl); const startLine = decl.getStartLineNumber(); const endLine = decl.getEndLineNumber(); insertSymbol.run( file.id, name, kind, exported ? 1 : 0, 0, 0, signature, startLine, endLine ); } } }); } } function findSymbolIdByName( db: Database.Database, name: string, preferFileId?: number ): number | null { if (preferFileId !== undefined) { const sameFile = db .prepare("SELECT id FROM symbols WHERE name = ? AND file_id = ? LIMIT 1") .get(name, preferFileId) as { id: number } | undefined; if (sameFile) return sameFile.id; } const anyFile = db .prepare("SELECT id FROM symbols WHERE name = ? LIMIT 1") .get(name) as { id: number } | undefined; return anyFile?.id || null; } export function scanCalls(db: Database.Database): void { const project = new Project({ tsConfigFilePath: "./tsconfig.json", skipAddingFilesFromTsConfig: true, }); const files = db .prepare("SELECT id, path FROM files") .all() as { id: number; path: string }[]; const insertCall = db.prepare( `INSERT INTO calls (caller_id, callee_id, callee_external, call_line, count) VALUES (?, ?, ?, ?, ?)` ); for (const file of files) { let sourceFile; try { sourceFile = project.addSourceFileAtPath(file.path); } catch { continue; } const fileSymbols = db .prepare("SELECT id, name, start_line, end_line FROM symbols WHERE file_id = ?") .all(file.id) as { id: number; name: string; start_line: number; end_line: number; }[]; // 按行号建立符号索引(一个符号覆盖 start_line..end_line) const symbolByLine = new Map(); for (const sym of fileSymbols) { for (let line = sym.start_line; line <= sym.end_line; line++) { symbolByLine.set(line, sym); } } const callExpressions = sourceFile.getDescendantsOfKind( SyntaxKind.CallExpression ); for (const call of callExpressions) { const callLine = call.getStartLineNumber(); const caller = symbolByLine.get(callLine); if (!caller) continue; const expr = call.getExpression(); let calleeName = ""; if (Node.isPropertyAccessExpression(expr)) { calleeName = expr.getName(); } else if (Node.isIdentifier(expr)) { calleeName = expr.getText(); } if (!calleeName) continue; const calleeId = findSymbolIdByName(db, calleeName, file.id); const calleeExternal = calleeId ? null : calleeName; insertCall.run(caller.id, calleeId, calleeExternal, callLine, 1); } } } interface TechTagRule { name: string; category: string; detect: (content: string, imports: string[]) => boolean; } const TECH_TAG_RULES: TechTagRule[] = [ { name: "cacheFn", category: "cache", detect: (content) => content.includes("cacheFn("), }, { name: "zustand", category: "state", detect: (_content, imports) => imports.some((i) => i.includes('"zustand"') || i.includes("'zustand'")), }, { name: "useOptimistic", category: "state", detect: (content) => content.includes("useOptimistic("), }, { name: "react-hook-form", category: "form", detect: (_content, imports) => imports.some((i) => i.includes("react-hook-form")), }, { name: "TanStack Query", category: "state", detect: (_content, imports) => imports.some((i) => i.includes("@tanstack/react-query")), }, { name: "Server Action", category: "server", detect: (content) => content.startsWith('"use server"') || content.startsWith("'use server'"), }, { name: "Tiptap", category: "ui", detect: (_content, imports) => imports.some((i) => i.includes("@tiptap/")), }, { name: "Drizzle", category: "db", detect: (_content, imports) => imports.some((i) => i.includes("drizzle-orm")), }, { name: "nuqs", category: "state", detect: (_content, imports) => imports.some((i) => i.includes('"nuqs"') || i.includes("'nuqs'")), }, { name: "recharts", category: "ui", detect: (_content, imports) => imports.some((i) => i.includes('"recharts"') || i.includes("'recharts'")), }, ]; export function scanTechTags(db: Database.Database): void { const insertTag = db.prepare( "INSERT OR IGNORE INTO tech_tags (name, category) VALUES (?, ?)" ); for (const rule of TECH_TAG_RULES) { insertTag.run(rule.name, rule.category); } const getTagId = db.prepare("SELECT id FROM tech_tags WHERE name = ?"); const insertSymbolTag = db.prepare( "INSERT OR IGNORE INTO symbol_tech_tags (symbol_id, tag_id) VALUES (?, ?)" ); const files = db .prepare("SELECT id, path FROM files") .all() as { id: number; path: string }[]; for (const file of files) { let content: string; try { content = readFileSync(file.path, "utf-8"); } catch { continue; } const importLines = content .split("\n") .filter((l) => l.startsWith("import ")); const detectedTags = TECH_TAG_RULES.filter((r) => r.detect(content, importLines) ); if (detectedTags.length === 0) continue; const fileSymbols = db .prepare("SELECT id FROM symbols WHERE file_id = ?") .all(file.id) as { id: number }[]; for (const tag of detectedTags) { const tagRow = getTagId.get(tag.name) as { id: number }; for (const sym of fileSymbols) { insertSymbolTag.run(sym.id, tagRow.id); } } } } function extractModuleFromPath(importPath: string): string | null { const modulesMatch = importPath.match(/@\/modules\/([^/"]+)/); if (modulesMatch) return modulesMatch[1]; const sharedMatch = importPath.match(/@\/shared/); if (sharedMatch) return "shared"; return null; } export function scanFileImports(db: Database.Database): void { const files = db .prepare("SELECT id, path FROM files") .all() as { id: number; path: string }[]; const insertImport = db.prepare( `INSERT INTO file_imports (source_file_id, imported_file_id, import_path, is_type_only, imported_names) VALUES (?, ?, ?, ?, ?)` ); for (const file of files) { let content: string; try { content = readFileSync(file.path, "utf-8"); } catch { continue; } const importRegex = /^import\s+(?:type\s+)?(.+?)\s+from\s+["']([^"']+)["']/gm; let match: RegExpExecArray | null; while ((match = importRegex.exec(content)) !== null) { const isTypeOnly = match[0].startsWith("import type"); const importedNames = match[1]; const importPath = match[2]; let importedFileId: number | null = null; if (importPath.startsWith("@/")) { const resolvedPath = importPath .replace("@/", "src/") .replace(/\.(ts|tsx)$/, ""); const candidatePaths = [ resolvedPath, `${resolvedPath}.ts`, `${resolvedPath}.tsx`, `${resolvedPath}/index.ts`, ]; for (const candidate of candidatePaths) { const result = db .prepare("SELECT id FROM files WHERE path = ?") .get(candidate) as { id: number } | undefined; if (result) { importedFileId = result.id; break; } } } insertImport.run( file.id, importedFileId, importPath, isTypeOnly ? 1 : 0, importedNames ); } } } export function scanModuleDeps(db: Database.Database): void { const insertDep = db.prepare( `INSERT OR IGNORE INTO module_deps (source_module_id, target_module_id, dep_type) VALUES (?, ?, ?)` ); const moduleByPath = db .prepare("SELECT name, id FROM modules") .all() as { name: string; id: number }[]; const moduleIdByName = new Map(moduleByPath.map((m) => [m.name, m.id])); // 1. 从 file_imports 聚合 import 类型依赖 const imports = db .prepare( `SELECT fi.import_path, f.module_id AS source_module_id FROM file_imports fi JOIN files f ON fi.source_file_id = f.id WHERE fi.import_path LIKE '%@/modules/%' OR fi.import_path LIKE '%@/shared%'` ) .all() as { import_path: string; source_module_id: number }[]; for (const imp of imports) { const targetModuleName = extractModuleFromPath(imp.import_path); if (!targetModuleName) continue; const targetModuleId = moduleIdByName.get(targetModuleName); if (!targetModuleId) continue; if (imp.source_module_id === targetModuleId) continue; insertDep.run(imp.source_module_id, targetModuleId, "import"); } // 2. 从 calls 聚合 data-access-call 类型依赖 const crossModuleCalls = db .prepare( `SELECT DISTINCT f1.module_id AS source_module_id, f2.module_id AS target_module_id FROM calls c JOIN symbols s1 ON c.caller_id = s1.id JOIN files f1 ON s1.file_id = f1.id JOIN symbols s2 ON c.callee_id = s2.id JOIN files f2 ON s2.file_id = f2.id WHERE f1.module_id != f2.module_id AND f2.kind = 'data-access'` ) .all() as { source_module_id: number; target_module_id: number }[]; for (const call of crossModuleCalls) { insertDep.run(call.source_module_id, call.target_module_id, "data-access-call"); } }