Files
NextEdu/scripts/arch-scan/scanner.ts

553 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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");
}
}