chore: snapshot before P0 security phase (backup point)
This commit is contained in:
3
scripts/arch-scan/.gitignore
vendored
Normal file
3
scripts/arch-scan/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
*.wal
|
||||
*.shm
|
||||
*.tmp
|
||||
BIN
scripts/arch-scan/arch.db
Normal file
BIN
scripts/arch-scan/arch.db
Normal file
Binary file not shown.
BIN
scripts/arch-scan/arch.db-shm
Normal file
BIN
scripts/arch-scan/arch.db-shm
Normal file
Binary file not shown.
0
scripts/arch-scan/arch.db-wal
Normal file
0
scripts/arch-scan/arch.db-wal
Normal file
24
scripts/arch-scan/cli.ts
Normal file
24
scripts/arch-scan/cli.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { runScan, runQuery } from "./index";
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const command = args[0];
|
||||
|
||||
if (command === "scan") {
|
||||
runScan();
|
||||
} else if (command === "query") {
|
||||
runQuery(args.slice(1));
|
||||
} else {
|
||||
console.log("Usage:");
|
||||
console.log(" npm run arch:scan");
|
||||
console.log(" npm run arch:query <command> [args]");
|
||||
console.log("");
|
||||
console.log("Commands:");
|
||||
console.log(" scan Scan codebase and update arch.db");
|
||||
console.log(" query ref <symbol> [--forward] Query symbol references");
|
||||
console.log(" query module <name> [--reverse] Query module dependencies");
|
||||
console.log(" query tech <tag> Query tech tag usage");
|
||||
console.log(" query violations Detect architecture violations");
|
||||
console.log(' query sql "<SQL>" Run free SQL');
|
||||
console.log(" query repl SQLite REPL info");
|
||||
process.exit(1);
|
||||
}
|
||||
187
scripts/arch-scan/index.ts
Normal file
187
scripts/arch-scan/index.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import Database from "better-sqlite3";
|
||||
import { execSync } from "node:child_process";
|
||||
import { initSchema, clearAllData } from "./schema";
|
||||
import {
|
||||
scanAllModules,
|
||||
scanFiles,
|
||||
scanSymbols,
|
||||
scanCalls,
|
||||
scanTechTags,
|
||||
scanFileImports,
|
||||
scanModuleDeps,
|
||||
} from "./scanner";
|
||||
import {
|
||||
querySymbolRefs,
|
||||
querySymbolForwardCalls,
|
||||
queryModuleDeps,
|
||||
queryModuleReverseDeps,
|
||||
queryTechUsage,
|
||||
queryViolations,
|
||||
} from "./query";
|
||||
|
||||
const DB_PATH = "scripts/arch-scan/arch.db";
|
||||
|
||||
function getCommitHash(): string {
|
||||
try {
|
||||
return execSync("git rev-parse --short HEAD")
|
||||
.toString()
|
||||
.trim();
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
export function runScan(): void {
|
||||
console.log("Starting architecture scan...");
|
||||
const db = new Database(DB_PATH);
|
||||
db.pragma("journal_mode = WAL");
|
||||
initSchema(db);
|
||||
clearAllData(db);
|
||||
console.log("Schema initialized.");
|
||||
scanAllModules(db, process.cwd());
|
||||
console.log("Modules scanned.");
|
||||
// scanFiles 扫描 DB 中所有模块,只需调用一次
|
||||
scanFiles(db, "src/modules");
|
||||
console.log("Files scanned.");
|
||||
scanSymbols(db);
|
||||
console.log("Symbols scanned.");
|
||||
scanCalls(db);
|
||||
console.log("Calls scanned.");
|
||||
scanTechTags(db);
|
||||
console.log("Tech tags scanned.");
|
||||
scanFileImports(db);
|
||||
scanModuleDeps(db);
|
||||
console.log("Module dependencies scanned.");
|
||||
const now = new Date().toISOString();
|
||||
const commit = getCommitHash();
|
||||
const meta = db.prepare(
|
||||
"INSERT OR REPLACE INTO scan_meta (key, value) VALUES (?, ?)"
|
||||
);
|
||||
meta.run("scanned_at", now);
|
||||
meta.run("commit_hash", commit);
|
||||
meta.run("scanner_version", "1.0.0");
|
||||
const stats = {
|
||||
files: (db.prepare("SELECT count(*) as c FROM files").get() as { c: number }).c,
|
||||
symbols: (db.prepare("SELECT count(*) as c FROM symbols").get() as { c: number }).c,
|
||||
calls: (db.prepare("SELECT count(*) as c FROM calls").get() as { c: number }).c,
|
||||
};
|
||||
meta.run("total_files", String(stats.files));
|
||||
meta.run("total_symbols", String(stats.symbols));
|
||||
meta.run("total_calls", String(stats.calls));
|
||||
console.log(
|
||||
`Scan complete: ${stats.files} files, ${stats.symbols} symbols, ${stats.calls} calls`
|
||||
);
|
||||
console.log(`Commit: ${commit}, Time: ${now}`);
|
||||
db.close();
|
||||
}
|
||||
|
||||
export function runQuery(args: string[]): void {
|
||||
const db = new Database(DB_PATH, { readonly: true });
|
||||
const command = args[0];
|
||||
switch (command) {
|
||||
case "ref": {
|
||||
const symbol = args[1];
|
||||
if (!symbol) {
|
||||
console.error("Usage: arch:query ref <symbolName> [--forward] [--depth=N]");
|
||||
process.exit(1);
|
||||
}
|
||||
const forward = args.includes("--forward");
|
||||
const depthArg = args.find((a) => a.startsWith("--depth="));
|
||||
const depth = depthArg ? parseInt(depthArg.split("=")[1], 10) : 10;
|
||||
if (forward) {
|
||||
const calls = querySymbolForwardCalls(db, symbol, depth);
|
||||
console.log(`▼ ${symbol}`);
|
||||
for (const call of calls) {
|
||||
const indent = " ".repeat(call.depth + 1);
|
||||
console.log(
|
||||
`${indent}└─▶ ${call.callee_name} (${call.callee_path}#L${call.callee_line})`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const refs = querySymbolRefs(db, symbol, depth);
|
||||
console.log(`▼ ${symbol}`);
|
||||
for (const ref of refs) {
|
||||
const indent = " ".repeat(ref.depth + 1);
|
||||
console.log(
|
||||
`${indent}├─▼ ${ref.caller_name} (${ref.caller_path}#L${ref.caller_line})${ref.is_server_action ? " [Server Action]" : ""}`
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "module": {
|
||||
const moduleName = args[1];
|
||||
if (!moduleName) {
|
||||
console.error("Usage: arch:query module <moduleName> [--reverse] [--depth=N]");
|
||||
process.exit(1);
|
||||
}
|
||||
const reverse = args.includes("--reverse");
|
||||
if (reverse) {
|
||||
const deps = queryModuleReverseDeps(db, moduleName);
|
||||
console.log(`▼ ${moduleName} (reverse deps)`);
|
||||
for (const dep of deps) {
|
||||
console.log(` └─◀ ${dep.source_module} [${dep.dep_type}]`);
|
||||
}
|
||||
} else {
|
||||
const deps = queryModuleDeps(db, moduleName);
|
||||
console.log(`▼ ${moduleName} (forward deps)`);
|
||||
for (const dep of deps) {
|
||||
console.log(` └─▶ ${dep.target_module} [${dep.dep_type}]`);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "tech": {
|
||||
const tag = args[1];
|
||||
if (!tag) {
|
||||
console.error("Usage: arch:query tech <tagName>");
|
||||
process.exit(1);
|
||||
}
|
||||
const usage = queryTechUsage(db, tag);
|
||||
console.log(`▼ tech: ${tag} (${usage.length} usages)`);
|
||||
for (const u of usage) {
|
||||
console.log(` └─ ${u.symbol_name} (${u.file_path})`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "violations": {
|
||||
const v = queryViolations(db);
|
||||
console.log("▼ Architecture Violations");
|
||||
console.log(` Long files (>800 lines): ${v.long_files.length}`);
|
||||
for (const f of v.long_files) {
|
||||
console.log(` └─ ${f.path}: ${f.lines} lines`);
|
||||
}
|
||||
console.log(
|
||||
` Server Actions without requirePermission: ${v.server_actions_without_permission.length}`
|
||||
);
|
||||
for (const sa of v.server_actions_without_permission) {
|
||||
console.log(` └─ ${sa.name} (${sa.path})`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "sql": {
|
||||
const sql = args.slice(1).join(" ");
|
||||
if (!sql) {
|
||||
console.error('Usage: arch:query sql "<SQL>"');
|
||||
process.exit(1);
|
||||
}
|
||||
const rows = db.prepare(sql).all();
|
||||
console.log(JSON.stringify(rows, null, 2));
|
||||
break;
|
||||
}
|
||||
case "repl": {
|
||||
console.log("Entering SQLite REPL. Type .exit to quit.");
|
||||
console.log(`DB: ${DB_PATH}`);
|
||||
console.log(
|
||||
'Tip: use "npm run arch:query -- sql \\"<SQL>\\"" for quick queries instead.'
|
||||
);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
console.error(
|
||||
"Unknown command. Available: ref, module, tech, violations, sql, repl"
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
db.close();
|
||||
}
|
||||
106
scripts/arch-scan/query.test.ts
Normal file
106
scripts/arch-scan/query.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import Database from "better-sqlite3";
|
||||
import { initSchema } from "./schema";
|
||||
import {
|
||||
querySymbolRefs,
|
||||
querySymbolForwardCalls,
|
||||
queryModuleDeps,
|
||||
queryModuleReverseDeps,
|
||||
queryViolations,
|
||||
} from "./query";
|
||||
|
||||
function setupTestDb(): Database.Database {
|
||||
const db = new Database(":memory:");
|
||||
initSchema(db);
|
||||
// 插入测试数据
|
||||
db.prepare(
|
||||
"INSERT INTO modules (name, path, layer, created_at) VALUES (?, ?, ?, ?)"
|
||||
).run("exams", "src/modules/exams", "modules", "2026-07-07");
|
||||
db.prepare(
|
||||
"INSERT INTO modules (name, path, layer, created_at) VALUES (?, ?, ?, ?)"
|
||||
).run("grades", "src/modules/grades", "modules", "2026-07-07");
|
||||
db.prepare(
|
||||
"INSERT INTO files (module_id, path, kind, lines) VALUES (?, ?, ?, ?)"
|
||||
).run(1, "src/modules/exams/actions.ts", "actions", 100);
|
||||
db.prepare(
|
||||
"INSERT INTO files (module_id, path, kind, lines) VALUES (?, ?, ?, ?)"
|
||||
).run(1, "src/modules/exams/data-access.ts", "data-access", 200);
|
||||
db.prepare(
|
||||
"INSERT INTO symbols (file_id, name, kind, is_exported, is_async, is_server_action, start_line, end_line) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
).run(1, "createExamAction", "function", 1, 1, 1, 10, 50);
|
||||
db.prepare(
|
||||
"INSERT INTO symbols (file_id, name, kind, is_exported, is_async, is_server_action, start_line, end_line) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
).run(2, "createExam", "function", 1, 1, 0, 20, 80);
|
||||
db.prepare(
|
||||
"INSERT INTO calls (caller_id, callee_id, call_line, count) VALUES (?, ?, ?, ?)"
|
||||
).run(1, 2, 30, 1); // createExamAction 调用 createExam
|
||||
db.prepare(
|
||||
"INSERT INTO module_deps (source_module_id, target_module_id, dep_type) VALUES (?, ?, ?)"
|
||||
).run(1, 2, "data-access-call"); // exams 依赖 grades
|
||||
return db;
|
||||
}
|
||||
|
||||
describe("querySymbolRefs", () => {
|
||||
it("should find callers of a symbol (reverse)", () => {
|
||||
const db = setupTestDb();
|
||||
const refs = querySymbolRefs(db, "createExam");
|
||||
expect(refs).toHaveLength(1);
|
||||
expect(refs[0].caller_name).toBe("createExamAction");
|
||||
});
|
||||
});
|
||||
|
||||
describe("querySymbolForwardCalls", () => {
|
||||
it("should find callees of a symbol (forward)", () => {
|
||||
const db = setupTestDb();
|
||||
const calls = querySymbolForwardCalls(db, "createExamAction");
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].callee_name).toBe("createExam");
|
||||
});
|
||||
});
|
||||
|
||||
describe("queryModuleDeps", () => {
|
||||
it("should find module dependencies (forward)", () => {
|
||||
const db = setupTestDb();
|
||||
const deps = queryModuleDeps(db, "exams");
|
||||
expect(deps).toContainEqual({
|
||||
target_module: "grades",
|
||||
dep_type: "data-access-call",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("queryModuleReverseDeps", () => {
|
||||
it("should find module reverse dependencies", () => {
|
||||
const db = setupTestDb();
|
||||
const deps = queryModuleReverseDeps(db, "grades");
|
||||
expect(deps).toContainEqual({
|
||||
source_module: "exams",
|
||||
dep_type: "data-access-call",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("queryViolations", () => {
|
||||
it("should detect files over 800 lines", () => {
|
||||
const db = setupTestDb();
|
||||
// 插入一个超长文件
|
||||
db.prepare(
|
||||
"INSERT INTO files (module_id, path, kind, lines) VALUES (?, ?, ?, ?)"
|
||||
).run(1, "src/modules/exams/huge.ts", "lib", 900);
|
||||
const violations = queryViolations(db);
|
||||
expect(violations.long_files).toContainEqual({
|
||||
path: "src/modules/exams/huge.ts",
|
||||
lines: 900,
|
||||
});
|
||||
});
|
||||
|
||||
it("should detect Server Actions without requirePermission", () => {
|
||||
const db = setupTestDb();
|
||||
const violations = queryViolations(db);
|
||||
// createExamAction 是 Server Action 但没有调用 requirePermission
|
||||
const missing = violations.server_actions_without_permission.find(
|
||||
(v) => v.name === "createExamAction"
|
||||
);
|
||||
expect(missing).toBeDefined();
|
||||
});
|
||||
});
|
||||
155
scripts/arch-scan/query.ts
Normal file
155
scripts/arch-scan/query.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import type Database from "better-sqlite3";
|
||||
|
||||
export interface SymbolRef {
|
||||
caller_name: string;
|
||||
caller_path: string;
|
||||
caller_line: number;
|
||||
is_server_action: boolean;
|
||||
depth: number;
|
||||
path_chain: string;
|
||||
}
|
||||
|
||||
export function querySymbolRefs(
|
||||
db: Database.Database,
|
||||
symbolName: string,
|
||||
maxDepth = 10
|
||||
): SymbolRef[] {
|
||||
const sql = `
|
||||
WITH RECURSIVE upstream(caller_id, caller_name, caller_path, caller_line, is_sa, depth, path_chain) AS (
|
||||
SELECT c.caller_id, s.name, f.path, c.call_line, s.is_server_action, 0, s.name
|
||||
FROM calls c
|
||||
JOIN symbols s ON c.caller_id = s.id
|
||||
JOIN files f ON s.file_id = f.id
|
||||
WHERE c.callee_id = (SELECT id FROM symbols WHERE name = ? LIMIT 1)
|
||||
UNION
|
||||
SELECT c.caller_id, s.name, f.path, c.call_line, s.is_server_action, u.depth + 1,
|
||||
u.path_chain || ' → ' || s.name
|
||||
FROM upstream u
|
||||
JOIN calls c ON c.callee_id = u.caller_id
|
||||
JOIN symbols s ON c.caller_id = s.id
|
||||
JOIN files f ON s.file_id = f.id
|
||||
WHERE u.depth < ?
|
||||
)
|
||||
SELECT caller_name, caller_path, caller_line, is_sa as is_server_action, depth, path_chain
|
||||
FROM upstream ORDER BY depth, caller_path`;
|
||||
return db.prepare(sql).all(symbolName, maxDepth) as SymbolRef[];
|
||||
}
|
||||
|
||||
export interface SymbolCall {
|
||||
callee_name: string;
|
||||
callee_path: string;
|
||||
callee_line: number;
|
||||
depth: number;
|
||||
path_chain: string;
|
||||
}
|
||||
|
||||
export function querySymbolForwardCalls(
|
||||
db: Database.Database,
|
||||
symbolName: string,
|
||||
maxDepth = 10
|
||||
): SymbolCall[] {
|
||||
const sql = `
|
||||
WITH RECURSIVE downstream(callee_id, callee_name, callee_path, callee_line, depth, path_chain) AS (
|
||||
SELECT c.callee_id, s.name, f.path, c.call_line, 0, ?
|
||||
FROM calls c
|
||||
JOIN symbols s ON c.callee_id = s.id
|
||||
JOIN files f ON s.file_id = f.id
|
||||
WHERE c.caller_id = (SELECT id FROM symbols WHERE name = ? LIMIT 1)
|
||||
UNION
|
||||
SELECT c.callee_id, s.name, f.path, c.call_line, d.depth + 1,
|
||||
d.path_chain || ' → ' || s.name
|
||||
FROM downstream d
|
||||
JOIN calls c ON c.caller_id = d.callee_id
|
||||
JOIN symbols s ON c.callee_id = s.id
|
||||
JOIN files f ON s.file_id = f.id
|
||||
WHERE d.depth < ?
|
||||
)
|
||||
SELECT callee_name, callee_path, callee_line, depth, path_chain
|
||||
FROM downstream ORDER BY depth, callee_path`;
|
||||
return db.prepare(sql).all(symbolName, symbolName, maxDepth) as SymbolCall[];
|
||||
}
|
||||
|
||||
export interface ModuleDep {
|
||||
target_module: string;
|
||||
dep_type: string;
|
||||
}
|
||||
|
||||
export function queryModuleDeps(
|
||||
db: Database.Database,
|
||||
moduleName: string,
|
||||
_maxDepth = 10
|
||||
): ModuleDep[] {
|
||||
const sql = `
|
||||
SELECT DISTINCT m2.name AS target_module, md.dep_type
|
||||
FROM module_deps md
|
||||
JOIN modules m ON md.source_module_id = m.id
|
||||
JOIN modules m2 ON md.target_module_id = m2.id
|
||||
WHERE m.name = ?`;
|
||||
return db.prepare(sql).all(moduleName) as ModuleDep[];
|
||||
}
|
||||
|
||||
export interface ModuleReverseDep {
|
||||
source_module: string;
|
||||
dep_type: string;
|
||||
}
|
||||
|
||||
export function queryModuleReverseDeps(
|
||||
db: Database.Database,
|
||||
moduleName: string,
|
||||
_maxDepth = 10
|
||||
): ModuleReverseDep[] {
|
||||
const sql = `
|
||||
SELECT DISTINCT m.name AS source_module, md.dep_type
|
||||
FROM module_deps md
|
||||
JOIN modules m ON md.source_module_id = m.id
|
||||
JOIN modules m2 ON md.target_module_id = m2.id
|
||||
WHERE m2.name = ?`;
|
||||
return db.prepare(sql).all(moduleName) as ModuleReverseDep[];
|
||||
}
|
||||
|
||||
export interface TechUsage {
|
||||
symbol_name: string;
|
||||
file_path: string;
|
||||
tag_name: string;
|
||||
}
|
||||
|
||||
export function queryTechUsage(
|
||||
db: Database.Database,
|
||||
tagName: string
|
||||
): TechUsage[] {
|
||||
const sql = `
|
||||
SELECT s.name AS symbol_name, f.path AS file_path, t.name AS tag_name
|
||||
FROM symbols s
|
||||
JOIN symbol_tech_tags stt ON s.id = stt.symbol_id
|
||||
JOIN tech_tags t ON stt.tag_id = t.id
|
||||
JOIN files f ON s.file_id = f.id
|
||||
WHERE t.name = ?`;
|
||||
return db.prepare(sql).all(tagName) as TechUsage[];
|
||||
}
|
||||
|
||||
export interface Violations {
|
||||
long_files: { path: string; lines: number }[];
|
||||
server_actions_without_permission: { name: string; path: string }[];
|
||||
}
|
||||
|
||||
export function queryViolations(db: Database.Database): Violations {
|
||||
const longFiles = db
|
||||
.prepare("SELECT path, lines FROM files WHERE lines > 800 ORDER BY lines DESC")
|
||||
.all() as { path: string; lines: number }[];
|
||||
const serverActionsWithoutPerm = db
|
||||
.prepare(
|
||||
`SELECT s.name, f.path FROM symbols s
|
||||
JOIN files f ON s.file_id = f.id
|
||||
WHERE s.is_server_action = 1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM calls c
|
||||
JOIN symbols cs ON c.callee_id = cs.id
|
||||
WHERE c.caller_id = s.id AND cs.name = 'requirePermission'
|
||||
)`
|
||||
)
|
||||
.all() as { name: string; path: string }[];
|
||||
return {
|
||||
long_files: longFiles,
|
||||
server_actions_without_permission: serverActionsWithoutPerm,
|
||||
};
|
||||
}
|
||||
226
scripts/arch-scan/scanner.test.ts
Normal file
226
scripts/arch-scan/scanner.test.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import Database from "better-sqlite3";
|
||||
import { initSchema } from "./schema";
|
||||
import { scanModules, scanFiles, scanSymbols, scanCalls, scanTechTags, scanModuleDeps, scanFileImports } from "./scanner";
|
||||
|
||||
describe("scanModules", () => {
|
||||
it("should detect src/modules/* as modules", () => {
|
||||
const db = new Database(":memory:");
|
||||
initSchema(db);
|
||||
scanModules(db, "src/modules");
|
||||
const modules = db
|
||||
.prepare("SELECT name FROM modules ORDER BY name")
|
||||
.all() as { name: string }[];
|
||||
const names = modules.map((m) => m.name);
|
||||
expect(names).toContain("exams");
|
||||
expect(names).toContain("grades");
|
||||
expect(names).toContain("classes");
|
||||
});
|
||||
|
||||
it("should detect shared/ as a module with layer=shared", () => {
|
||||
const db = new Database(":memory:");
|
||||
initSchema(db);
|
||||
scanModules(db, "src/shared");
|
||||
const shared = db
|
||||
.prepare("SELECT * FROM modules WHERE name = 'shared'")
|
||||
.get() as { layer: string };
|
||||
expect(shared.layer).toBe("shared");
|
||||
});
|
||||
|
||||
it("should set layer=modules for src/modules/*", () => {
|
||||
const db = new Database(":memory:");
|
||||
initSchema(db);
|
||||
scanModules(db, "src/modules");
|
||||
const exams = db
|
||||
.prepare("SELECT * FROM modules WHERE name = 'exams'")
|
||||
.get() as { layer: string };
|
||||
expect(exams.layer).toBe("modules");
|
||||
});
|
||||
});
|
||||
|
||||
describe("scanFiles", () => {
|
||||
it("should scan .ts and .tsx files in modules", () => {
|
||||
const db = new Database(":memory:");
|
||||
initSchema(db);
|
||||
scanModules(db, "src/modules");
|
||||
scanFiles(db, "src/modules");
|
||||
const files = db
|
||||
.prepare("SELECT path, kind FROM files WHERE path LIKE '%exams%' LIMIT 5")
|
||||
.all() as { path: string; kind: string }[];
|
||||
expect(files.length).toBeGreaterThan(0);
|
||||
const actionFile = files.find((f) => f.kind === "actions");
|
||||
expect(actionFile).toBeDefined();
|
||||
});
|
||||
|
||||
it("should detect file kind from filename", () => {
|
||||
const db = new Database(":memory:");
|
||||
initSchema(db);
|
||||
scanModules(db, "src/modules");
|
||||
scanFiles(db, "src/modules");
|
||||
const actionsFile = db
|
||||
.prepare("SELECT kind FROM files WHERE path LIKE '%exams/actions.ts'")
|
||||
.get() as { kind: string };
|
||||
expect(actionsFile.kind).toBe("actions");
|
||||
const dataAccessFile = db
|
||||
.prepare("SELECT kind FROM files WHERE path LIKE '%exams/data-access.ts'")
|
||||
.get() as { kind: string };
|
||||
expect(dataAccessFile.kind).toBe("data-access");
|
||||
});
|
||||
|
||||
it("should count lines correctly", () => {
|
||||
const db = new Database(":memory:");
|
||||
initSchema(db);
|
||||
scanModules(db, "src/modules");
|
||||
scanFiles(db, "src/modules");
|
||||
const file = db
|
||||
.prepare("SELECT lines FROM files WHERE path LIKE '%exams/actions.ts'")
|
||||
.get() as { lines: number };
|
||||
expect(file.lines).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should detect use server and use client directives", () => {
|
||||
const db = new Database(":memory:");
|
||||
initSchema(db);
|
||||
scanModules(db, "src/modules");
|
||||
scanFiles(db, "src/modules");
|
||||
const serverFile = db
|
||||
.prepare("SELECT is_server FROM files WHERE path LIKE '%exams/actions.ts'")
|
||||
.get() as { is_server: number };
|
||||
expect(serverFile.is_server).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scanSymbols", () => {
|
||||
it("should extract exported functions from actions.ts", () => {
|
||||
const db = new Database(":memory:");
|
||||
initSchema(db);
|
||||
scanModules(db, "src/modules");
|
||||
scanFiles(db, "src/modules");
|
||||
scanSymbols(db);
|
||||
const symbols = db
|
||||
.prepare(
|
||||
"SELECT name, is_exported, is_async FROM symbols WHERE file_id IN (SELECT id FROM files WHERE path LIKE '%exams/actions.ts') LIMIT 5"
|
||||
)
|
||||
.all() as { name: string; is_exported: number; is_async: number }[];
|
||||
expect(symbols.length).toBeGreaterThan(0);
|
||||
const exported = symbols.find((s) => s.is_exported === 1);
|
||||
expect(exported).toBeDefined();
|
||||
});
|
||||
|
||||
it("should detect Server Action by 'use server' directive", () => {
|
||||
const db = new Database(":memory:");
|
||||
initSchema(db);
|
||||
scanModules(db, "src/modules");
|
||||
scanFiles(db, "src/modules");
|
||||
scanSymbols(db);
|
||||
const serverActions = db
|
||||
.prepare("SELECT name FROM symbols WHERE is_server_action = 1 LIMIT 5")
|
||||
.all() as { name: string }[];
|
||||
expect(serverActions.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should extract function signatures", () => {
|
||||
const db = new Database(":memory:");
|
||||
initSchema(db);
|
||||
scanModules(db, "src/modules");
|
||||
scanFiles(db, "src/modules");
|
||||
scanSymbols(db);
|
||||
const withSignature = db
|
||||
.prepare("SELECT signature FROM symbols WHERE signature IS NOT NULL AND signature != '' LIMIT 1")
|
||||
.get() as { signature: string };
|
||||
expect(withSignature).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("scanCalls", () => {
|
||||
it("should detect calls between symbols", () => {
|
||||
const db = new Database(":memory:");
|
||||
initSchema(db);
|
||||
scanModules(db, "src/modules");
|
||||
scanFiles(db, "src/modules");
|
||||
scanSymbols(db);
|
||||
scanCalls(db);
|
||||
const calls = db
|
||||
.prepare("SELECT count(*) as count FROM calls")
|
||||
.get() as { count: number };
|
||||
expect(calls.count).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should record external calls (callee_external)", () => {
|
||||
const db = new Database(":memory:");
|
||||
initSchema(db);
|
||||
scanModules(db, "src/modules");
|
||||
scanFiles(db, "src/modules");
|
||||
scanSymbols(db);
|
||||
scanCalls(db);
|
||||
const externalCalls = db
|
||||
.prepare("SELECT callee_external FROM calls WHERE callee_external IS NOT NULL LIMIT 5")
|
||||
.all() as { callee_external: string }[];
|
||||
expect(externalCalls.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scanTechTags", () => {
|
||||
it("should detect cacheFn usage", () => {
|
||||
const db = new Database(":memory:");
|
||||
initSchema(db);
|
||||
scanModules(db, "src/modules");
|
||||
scanFiles(db, "src/modules");
|
||||
scanSymbols(db);
|
||||
scanTechTags(db);
|
||||
const cacheFnFiles = db
|
||||
.prepare(
|
||||
`SELECT s.name FROM symbols s
|
||||
JOIN symbol_tech_tags stt ON s.id = stt.symbol_id
|
||||
JOIN tech_tags t ON stt.tag_id = t.id
|
||||
WHERE t.name = 'cacheFn' LIMIT 5`
|
||||
)
|
||||
.all() as { name: string }[];
|
||||
expect(cacheFnFiles.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should create tech_tags entries", () => {
|
||||
const db = new Database(":memory:");
|
||||
initSchema(db);
|
||||
scanModules(db, "src/modules");
|
||||
scanFiles(db, "src/modules");
|
||||
scanSymbols(db);
|
||||
scanTechTags(db);
|
||||
const tags = db
|
||||
.prepare("SELECT name FROM tech_tags ORDER BY name")
|
||||
.all() as { name: string }[];
|
||||
const tagNames = tags.map((t) => t.name);
|
||||
expect(tagNames).toContain("cacheFn");
|
||||
expect(tagNames).toContain("zustand");
|
||||
expect(tagNames).toContain("Drizzle");
|
||||
});
|
||||
});
|
||||
|
||||
describe("scanModuleDeps", () => {
|
||||
it("should detect module dependencies from imports", () => {
|
||||
const db = new Database(":memory:");
|
||||
initSchema(db);
|
||||
scanModules(db, "src/modules");
|
||||
scanFiles(db, "src/modules");
|
||||
scanFileImports(db);
|
||||
scanModuleDeps(db);
|
||||
const deps = db
|
||||
.prepare("SELECT count(*) as count FROM module_deps")
|
||||
.get() as { count: number };
|
||||
expect(deps.count).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should detect data-access-call dependency type", () => {
|
||||
const db = new Database(":memory:");
|
||||
initSchema(db);
|
||||
scanModules(db, "src/modules");
|
||||
scanFiles(db, "src/modules");
|
||||
scanSymbols(db);
|
||||
scanCalls(db);
|
||||
scanModuleDeps(db);
|
||||
const dataAccessDeps = db
|
||||
.prepare("SELECT count(*) as count FROM module_deps WHERE dep_type = 'data-access-call'")
|
||||
.get() as { count: number };
|
||||
expect(dataAccessDeps.count).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
552
scripts/arch-scan/scanner.ts
Normal file
552
scripts/arch-scan/scanner.ts
Normal file
@@ -0,0 +1,552 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
52
scripts/arch-scan/schema.test.ts
Normal file
52
scripts/arch-scan/schema.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import Database from "better-sqlite3";
|
||||
import { initSchema } from "./schema";
|
||||
|
||||
describe("initSchema", () => {
|
||||
it("should create all 12 tables", () => {
|
||||
const db = new Database(":memory:");
|
||||
initSchema(db);
|
||||
const tables = db
|
||||
.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
|
||||
)
|
||||
.all() as { name: string }[];
|
||||
const tableNames = tables.map((t) => t.name);
|
||||
expect(tableNames).toContain("modules");
|
||||
expect(tableNames).toContain("files");
|
||||
expect(tableNames).toContain("symbols");
|
||||
expect(tableNames).toContain("calls");
|
||||
expect(tableNames).toContain("file_imports");
|
||||
expect(tableNames).toContain("module_deps");
|
||||
expect(tableNames).toContain("tech_tags");
|
||||
expect(tableNames).toContain("symbol_tech_tags");
|
||||
expect(tableNames).toContain("permissions");
|
||||
expect(tableNames).toContain("routes");
|
||||
expect(tableNames).toContain("db_tables");
|
||||
expect(tableNames).toContain("scan_meta");
|
||||
});
|
||||
|
||||
it("should create all indexes", () => {
|
||||
const db = new Database(":memory:");
|
||||
initSchema(db);
|
||||
const indexes = db
|
||||
.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='index' AND name LIKE 'idx_%' ORDER BY name"
|
||||
)
|
||||
.all() as { name: string }[];
|
||||
const indexNames = indexes.map((i) => i.name);
|
||||
expect(indexNames).toContain("idx_symbols_file");
|
||||
expect(indexNames).toContain("idx_symbols_name");
|
||||
expect(indexNames).toContain("idx_calls_caller");
|
||||
expect(indexNames).toContain("idx_calls_callee");
|
||||
expect(indexNames).toContain("idx_file_imports_source");
|
||||
expect(indexNames).toContain("idx_file_imports_target");
|
||||
expect(indexNames).toContain("idx_symbol_tech_tags_tag");
|
||||
});
|
||||
|
||||
it("should be idempotent", () => {
|
||||
const db = new Database(":memory:");
|
||||
expect(() => initSchema(db)).not.toThrow();
|
||||
expect(() => initSchema(db)).not.toThrow();
|
||||
});
|
||||
});
|
||||
125
scripts/arch-scan/schema.ts
Normal file
125
scripts/arch-scan/schema.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import type Database from "better-sqlite3";
|
||||
|
||||
const DDL_STATEMENTS = [
|
||||
`CREATE TABLE IF NOT EXISTS modules (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
path TEXT NOT NULL,
|
||||
description TEXT,
|
||||
layer TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS files (
|
||||
id INTEGER PRIMARY KEY,
|
||||
module_id INTEGER REFERENCES modules(id),
|
||||
path TEXT NOT NULL UNIQUE,
|
||||
kind TEXT NOT NULL,
|
||||
lines INTEGER,
|
||||
is_server INTEGER DEFAULT 0,
|
||||
is_client INTEGER DEFAULT 0,
|
||||
has_server_only INTEGER DEFAULT 0
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS symbols (
|
||||
id INTEGER PRIMARY KEY,
|
||||
file_id INTEGER NOT NULL REFERENCES files(id),
|
||||
name TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
is_exported INTEGER DEFAULT 0,
|
||||
is_async INTEGER DEFAULT 0,
|
||||
is_server_action INTEGER DEFAULT 0,
|
||||
signature TEXT,
|
||||
start_line INTEGER,
|
||||
end_line INTEGER,
|
||||
UNIQUE(file_id, name, start_line)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS calls (
|
||||
id INTEGER PRIMARY KEY,
|
||||
caller_id INTEGER NOT NULL REFERENCES symbols(id),
|
||||
callee_id INTEGER REFERENCES symbols(id),
|
||||
callee_external TEXT,
|
||||
call_line INTEGER,
|
||||
count INTEGER DEFAULT 1
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS file_imports (
|
||||
id INTEGER PRIMARY KEY,
|
||||
source_file_id INTEGER NOT NULL REFERENCES files(id),
|
||||
imported_file_id INTEGER REFERENCES files(id),
|
||||
import_path TEXT NOT NULL,
|
||||
is_type_only INTEGER DEFAULT 0,
|
||||
imported_names TEXT
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS module_deps (
|
||||
id INTEGER PRIMARY KEY,
|
||||
source_module_id INTEGER NOT NULL REFERENCES modules(id),
|
||||
target_module_id INTEGER NOT NULL REFERENCES modules(id),
|
||||
dep_type TEXT NOT NULL,
|
||||
UNIQUE(source_module_id, target_module_id, dep_type)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS tech_tags (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
category TEXT
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS symbol_tech_tags (
|
||||
symbol_id INTEGER NOT NULL REFERENCES symbols(id),
|
||||
tag_id INTEGER NOT NULL REFERENCES tech_tags(id),
|
||||
PRIMARY KEY(symbol_id, tag_id)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS permissions (
|
||||
id INTEGER PRIMARY KEY,
|
||||
key TEXT NOT NULL UNIQUE,
|
||||
description TEXT
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS routes (
|
||||
id INTEGER PRIMARY KEY,
|
||||
path TEXT NOT NULL UNIQUE,
|
||||
kind TEXT NOT NULL,
|
||||
file_id INTEGER REFERENCES files(id),
|
||||
min_permission TEXT
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS db_tables (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
module_id INTEGER REFERENCES modules(id),
|
||||
description TEXT
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS scan_meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_symbols_file ON symbols(file_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_symbols_name ON symbols(name)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_calls_caller ON calls(caller_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_calls_callee ON calls(callee_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_file_imports_source ON file_imports(source_file_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_file_imports_target ON file_imports(imported_file_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_symbol_tech_tags_tag ON symbol_tech_tags(tag_id)`,
|
||||
];
|
||||
|
||||
export function initSchema(db: Database.Database): void {
|
||||
for (const stmt of DDL_STATEMENTS) {
|
||||
db.exec(stmt);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearAllData(db: Database.Database): void {
|
||||
const tables = [
|
||||
"symbol_tech_tags",
|
||||
"calls",
|
||||
"file_imports",
|
||||
"module_deps",
|
||||
"symbols",
|
||||
"files",
|
||||
"modules",
|
||||
"tech_tags",
|
||||
"permissions",
|
||||
"routes",
|
||||
"db_tables",
|
||||
"scan_meta",
|
||||
];
|
||||
db.exec("PRAGMA foreign_keys = OFF");
|
||||
for (const table of tables) {
|
||||
db.exec(`DELETE FROM ${table}`);
|
||||
}
|
||||
db.exec("PRAGMA foreign_keys = ON");
|
||||
}
|
||||
Reference in New Issue
Block a user