Files
Edu/scripts/arch-scan/scanners/ts-scanner.ts
SpecialX 594a4e65fe fix(arch-scan): 修复 ts-scanner 误识别 Python 服务 + 扩展符号提取
- 新增 pyproject.toml 检测,跳过纯 Python 服务(ai/data-ana)和包(shared-py)

- 扩展符号提取:新增 TS_CONST_EXPORT_RE 匹配 camelCase/PascalCase 导出常量

- 新增 TS_TYPE_RE 匹配 export type alias

- 修复后 arch:scan 输出 22 模块 / 4715 符号 / 475 契约
2026-07-14 21:24:22 +08:00

265 lines
8.3 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 path from "node:path";
import fs from "node:fs";
import type { Database as DBType } from "better-sqlite3";
interface ScanStats {
modules: number;
symbols: number;
}
/** 递归收集目录下匹配扩展名的所有文件 */
function walkDir(dir: string, exts: string[]): string[] {
if (!fs.existsSync(dir)) return [];
const results: string[] = [];
const stack: string[] = [dir];
while (stack.length > 0) {
const current = stack.pop()!;
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(current, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
const fullPath = path.join(current, entry.name);
if (entry.isDirectory()) {
// 跳过 node_modules / dist / .git
if (
["node_modules", "dist", ".git", ".next", "build"].includes(
entry.name,
)
)
continue;
stack.push(fullPath);
} else if (entry.isFile()) {
const ext = path.extname(entry.name);
if (exts.includes(ext)) results.push(fullPath);
}
}
}
return results;
}
export function scanTypeScript(db: DBType, root: string): ScanStats {
const insertModule = db.prepare(
"INSERT OR IGNORE INTO modules (name, path, language, service, type) VALUES (?, ?, ?, ?, ?)",
);
const insertSymbol = db.prepare(
"INSERT INTO symbols (module_id, name, kind, language, file_path, line_start, line_end, is_exported) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
);
const getModuleId = db.prepare("SELECT id FROM modules WHERE name = ?") as {
get: (name: string) => { id: number } | undefined;
};
let modules = 0;
let symbols = 0;
// 扫描 services/* 和 apps/* 和 packages/* 下的 TS 服务
const serviceDirs = ["services", "apps"];
const moduleList: { name: string; path: string; service: string }[] = [];
for (const dir of serviceDirs) {
const baseDir = path.join(root, dir);
if (!fs.existsSync(baseDir)) continue;
const entries = fs.readdirSync(baseDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const serviceName = entry.name;
const servicePath = path.join(baseDir, serviceName);
// 确认是 TS 服务(有 src 目录或 package.json
const hasSrc = fs.existsSync(path.join(servicePath, "src"));
const hasPkg = fs.existsSync(path.join(servicePath, "package.json"));
if (!hasSrc && !hasPkg) continue;
// 跳过纯 Python 服务(有 pyproject.toml 且无 package.json
const hasPyproject = fs.existsSync(
path.join(servicePath, "pyproject.toml"),
);
if (hasPyproject && !hasPkg) continue;
const moduleName = serviceName;
insertModule.run(moduleName, servicePath, "ts", serviceName, "service");
const row = getModuleId.get(moduleName);
if (row) {
modules++;
moduleList.push({
name: moduleName,
path: servicePath,
service: serviceName,
});
}
}
}
// packages/* 下的 TS 包
const packagesDir = path.join(root, "packages");
if (fs.existsSync(packagesDir)) {
const entries = fs.readdirSync(packagesDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const pkgName = entry.name;
const pkgPath = path.join(packagesDir, pkgName);
const hasSrc = fs.existsSync(path.join(pkgPath, "src"));
const hasPkg = fs.existsSync(path.join(pkgPath, "package.json"));
const hasProto = fs.existsSync(path.join(pkgPath, "proto"));
if (!hasSrc && !hasPkg && !hasProto) continue;
// 跳过纯 Python 包(有 pyproject.toml 且无 package.json
const hasPyprojectPkg = fs.existsSync(
path.join(pkgPath, "pyproject.toml"),
);
if (hasPyprojectPkg && !hasPkg) continue;
const moduleName = pkgName;
insertModule.run(moduleName, pkgPath, "ts", pkgName, "package");
const row = getModuleId.get(moduleName);
if (row) {
modules++;
moduleList.push({ name: moduleName, path: pkgPath, service: pkgName });
}
}
}
// 收集所有 .ts 文件并用 regex 提取符号ts-morph 对未安装依赖的文件可能解析失败)
const TS_FUNC_RE = /(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*\(/g;
const TS_CLASS_RE = /(?:export\s+)?(?:abstract\s+)?class\s+(\w+)/g;
const TS_INTERFACE_RE = /(?:export\s+)?interface\s+(\w+)/g;
// 全大写常量(如 MAX_RETRY_COUNT
const TS_CONST_UPPER_RE = /(?:export\s+)?const\s+([A-Z][A-Z0-9_]+)\s*=/g;
// 导出的 camelCase / PascalCase 常量(如 colors、fontSizes
const TS_CONST_EXPORT_RE = /export\s+const\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*=/g;
// 导出的 type alias如 ColorScheme
const TS_TYPE_RE = /export\s+type\s+(\w+)\s*=/g;
for (const mod of moduleList) {
const srcDir = path.join(mod.path, "src");
if (!fs.existsSync(srcDir)) continue;
const tsFiles = walkDir(srcDir, [".ts", ".tsx"]);
const modRow = getModuleId.get(mod.name);
if (!modRow) continue;
for (const filePath of tsFiles) {
let content: string;
try {
content = fs.readFileSync(filePath, "utf-8");
} catch {
continue;
}
// 函数
let match: RegExpExecArray | null;
const funcRe = new RegExp(TS_FUNC_RE);
while ((match = funcRe.exec(content)) !== null) {
const name = match[1];
const lineNum = content.slice(0, match.index).split("\n").length;
const isExported = match[0].includes("export") ? 1 : 0;
insertSymbol.run(
modRow.id,
name,
"function",
"ts",
filePath,
lineNum,
lineNum,
isExported,
);
symbols++;
}
// 类
const classRe = new RegExp(TS_CLASS_RE);
while ((match = classRe.exec(content)) !== null) {
const name = match[1];
const lineNum = content.slice(0, match.index).split("\n").length;
const isExported = match[0].includes("export") ? 1 : 0;
insertSymbol.run(
modRow.id,
name,
"class",
"ts",
filePath,
lineNum,
lineNum,
isExported,
);
symbols++;
}
// 接口
const ifaceRe = new RegExp(TS_INTERFACE_RE);
while ((match = ifaceRe.exec(content)) !== null) {
const name = match[1];
const lineNum = content.slice(0, match.index).split("\n").length;
const isExported = match[0].includes("export") ? 1 : 0;
insertSymbol.run(
modRow.id,
name,
"interface",
"ts",
filePath,
lineNum,
lineNum,
isExported,
);
symbols++;
}
// 常量UPPER_CASE
const constRe = new RegExp(TS_CONST_UPPER_RE);
while ((match = constRe.exec(content)) !== null) {
const name = match[1];
const lineNum = content.slice(0, match.index).split("\n").length;
const isExported = match[0].includes("export") ? 1 : 0;
insertSymbol.run(
modRow.id,
name,
"const",
"ts",
filePath,
lineNum,
lineNum,
isExported,
);
symbols++;
}
// 导出的 camelCase / PascalCase 常量(去重:已被 UPPER_CASE 匹配的跳过)
const constExportRe = new RegExp(TS_CONST_EXPORT_RE);
while ((match = constExportRe.exec(content)) !== null) {
const name = match[1];
// 跳过已被 UPPER_CASE 匹配的
if (/^[A-Z][A-Z0-9_]+$/.test(name)) continue;
const lineNum = content.slice(0, match.index).split("\n").length;
insertSymbol.run(
modRow.id,
name,
"const",
"ts",
filePath,
lineNum,
lineNum,
1,
);
symbols++;
}
// 导出的 type alias
const typeRe = new RegExp(TS_TYPE_RE);
while ((match = typeRe.exec(content)) !== null) {
const name = match[1];
const lineNum = content.slice(0, match.index).split("\n").length;
insertSymbol.run(
modRow.id,
name,
"type",
"ts",
filePath,
lineNum,
lineNum,
1,
);
symbols++;
}
}
}
return { modules, symbols };
}