553 lines
17 KiB
TypeScript
553 lines
17 KiB
TypeScript
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<number, (typeof fileSymbols)[0]>();
|
||
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");
|
||
}
|
||
}
|