初始提交:代码可视化分析工具(CLI 分析器 + Web 可视化 + 设计文档)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
朱葛
2026-07-22 15:19:31 +08:00
commit cb23f814c7
35 changed files with 74071 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
# Dependencies
node_modules/
# Build output
dist/
build/
*.tsbuildinfo
# Logs
*.log
npm-debug.log*
# Editor / OS
.vscode/
.idea/
.DS_Store
Thumbs.db
# Env
.env
.env.local
.env.*.local
+336
View File
@@ -0,0 +1,336 @@
# 代码分析可视化工具 · 设计文档
> 面向 AI 生成的「大锅饭」代码,帮助开发者快速掌握有哪些模块、模块间关系如何,颗粒度到函数级;聚焦宏观流程打通,不做具体实现的 bug 排查。以流程图为核心展示,抽象出一套标准化报告数据结构,任何项目按此结构产出报告均可在工具内展示。
---
## 一、产品概述
- 1.1 背景与问题(AI 大锅饭代码难以掌握全貌)
- 1.2 目标用户与典型使用场景
- 1.3 核心价值主张
- 1.4 范围与非目标(只讲宏观模块与流程,不做 bug 检测 / 代码质量评审)
- 1.5 术语表
## 二、核心设计理念
- 2.1 以流程图为核心的展示范式
- 2.2 模块 → 函数 两级颗粒度
- 2.3 报告即数据(标准化数据结构驱动展示)
- 2.4 项目无关性(跨语言 / 跨框架 / 跨规模)
## 三、标准化代码报告数据结构(核心 Schema)
### 3.0 设计原则
- **聚焦原汁原味的逻辑**:核心是函数以及函数之间的调用关系,不纠结语言特性。
- **不区分语言细节**class / struct / interface 统一抽象成「容器 Container」,只负责持有函数与字段;不处理继承、可见性、抽象等语法概念。
- **扁平存储 + ID 引用**:所有实体平铺存放,用 `id` 互相引用(如容器用 `functionIds` 持有函数,函数用 `ownerId` 回指容器),便于图渲染、跨模块跳转与下钻。
- **统一边模型**:调用、导入、引用、触发、数据流等所有关系归一为一种 `Edge`,作为关系的唯一真相源;函数上不再冗余存调用列表。
- **来源可追溯**:每个实体/边带 `provenance`,区分「AST 确定的结构」与「AI 推断的解读」(尤其业务流程)。
### 3.1 基础通用类型
```ts
type Id = string;
// 实体/边的来源:ast=语法确定, resolved=引用解析, ai=模型推断, manual=人工
type Provenance = "ast" | "resolved" | "ai" | "manual";
interface SourceLocation {
file: string; // 相对仓库根的路径
startLine: number;
endLine: number;
startCol?: number;
endCol?: number;
}
// 所有实体的公共字段
interface BaseEntity {
id: Id;
name: string;
qualifiedName?: string; // 完全限定名,如 server/TaskRepo.create
location?: SourceLocation;
language?: string;
provenance: Provenance;
confidence?: number; // 0~1,启发式解析时使用
summary?: string; // 一句话职责(可由 AI 生成)
tags?: string[];
extra?: Record<string, unknown>; // 场景/语言专属扩展位
}
```
### 3.2 模块 Module
模块 = 目录 / 包 / 逻辑分组,可嵌套成树,是宏观视图的分层单位。
```ts
interface Module extends BaseEntity {
kind: "module";
parentId?: Id;
childModuleIds: Id[];
fileIds: Id[];
path?: string;
}
```
### 3.3 文件 File
```ts
interface CodeFile extends BaseEntity {
kind: "file";
moduleId: Id;
path: string;
loc?: number; // 行数
containerIds: Id[]; // 本文件定义的容器(类/结构等)
functionIds: Id[]; // 本文件的顶层函数
}
```
### 3.4 容器 Container(类 / 结构 等的统一抽象)
不区分 class / struct / interface,只作为函数与字段的持有者。
```ts
interface Container extends BaseEntity {
kind: "container";
fileId: Id;
moduleId?: Id;
functionIds: Id[]; // ← 持有的函数(方法)
fieldIds: Id[]; // ← 持有的字段/属性
}
```
### 3.5 函数 Function(核心)
顶层函数与容器方法统一用一个结构;是否为方法由 `ownerId` 区分。函数之间的调用关系不存在这里,而是统一走 `Edge`
```ts
interface Func extends BaseEntity {
kind: "function";
fileId: Id;
ownerId?: Id; // 若为方法,指向所属容器;顶层函数留空
moduleId?: Id;
signature?: string; // 原始签名文本
params: Param[];
returnType?: string;
isEntry?: boolean; // 是否入口点(main / 路由处理器 / 事件回调)
interactions?: Interaction[]; // ← 该函数的跨边界交互(前后端协议 / 数据库)
loc?: number;
}
interface Param {
name: string;
typeRef?: string; // 声明类型(文本即可,不强求解析)
optional?: boolean;
defaultValue?: string;
}
```
#### 跨边界交互 Interaction
理解宏观逻辑的关键,往往不在于「函数调了谁」,而在于它**越过了哪条系统边界**:前端发往后端的一次请求、后端打到数据库的一次读写。这类信息直接作为函数的属性挂在函数上,而非独立节点。外部系统清单(用到哪些数据库 / 外部服务)可由全部函数的 `interactions` 聚合派生,无需单独维护。
```ts
type InteractionKind = "api" | "db"; // 可扩展:mq / cache / rpc ...
// in = 对外暴露 / 被外部调用(如接口处理器)
// out = 本函数主动发起(如发请求 / 查库)
type InteractionDirection = "in" | "out";
interface InteractionBase {
id: Id;
direction: InteractionDirection;
provenance: Provenance;
confidence?: number;
summary?: string;
}
// 前后端协议交互
interface ApiInteraction extends InteractionBase {
kind: "api";
protocol: "http" | "ws" | "rpc" | "graphql";
method?: string; // GET / POST / ...http
path?: string; // 如 /api/tasks/:id
peer?: string; // 对端:前端页面 / 外部服务名(若可知)
}
// 数据库交互
interface DbInteraction extends InteractionBase {
kind: "db";
operation: "read" | "create" | "update" | "delete" | "other";
target?: string; // 表 / 模型 / 集合名
engine?: string; // postgres / mysql / mongo ...(可选)
}
type Interaction = ApiInteraction | DbInteraction;
```
### 3.6 字段 Field
```ts
interface Field extends BaseEntity {
kind: "field";
ownerId: Id; // 所属容器
typeRef?: string;
}
```
### 3.7 关系 Edge(统一边模型,关系的唯一真相源)
```ts
type EdgeKind =
| "call" // 函数调用(Call-Flow 的原子)
| "import" // 文件/模块导入
| "reference" // 一般引用/使用
| "dataflow" // 数据流(可选,后期)
| "trigger"; // 触发:路由→处理器、事件→回调
interface Edge {
id: Id;
kind: EdgeKind;
sourceId: Id; // 起点实体 id
targetId: Id; // 终点实体 id(未解析时指向占位节点)
provenance: Provenance;
confidence?: number;
label?: string;
extra?: Record<string, unknown>;
}
```
### 3.8 流程 FlowCall-Flow 与 Business-Flow 同结构)
```ts
interface Flow {
id: Id;
name: string;
flowType: "call" | "business"; // 调用链 / 业务流程
summary?: string;
provenance: Provenance; // call 多为 resolvedbusiness 多为 ai/manual
entryNodeId?: Id;
steps: FlowStep[];
edgeIds?: Id[]; // 关联到结构层的底层调用边,支持回溯
}
interface FlowStep {
id: Id;
order: number;
nodeId?: Id; // 指向某函数/容器/模块
title: string;
description?: string;
branchLabel?: string; // 分支条件文字
nextStepIds: Id[]; // 支持分支/汇合
}
```
### 3.9 外部系统 ExternalSystem(由交互派生)
外部系统不再作为原始录入节点,而是由聚合全部函数的 `interactions` **派生**得到,作为报告里的物化视图供展示消费:
- `db` 交互的 `engine` / `target` 去重 → 数据库系统及其数据表清单;
- `out` 方向的 `api` 交互的 `peer` / `path` 去重 → 依赖的外部服务清单。
每个外部系统都回指与之交互的函数,保证可回溯。
```ts
type ExternalSystemKind = "database" | "service";
interface ExternalSystemBase {
id: Id;
kind: ExternalSystemKind;
name: string; // 数据库/引擎名 或 外部服务名
functionIds: Id[]; // 与之交互的函数(回溯来源)
interactionCount: number; // 交互次数
provenance: Provenance; // 派生结果,通常为 resolved
}
// 数据库系统
interface DatabaseSystem extends ExternalSystemBase {
kind: "database";
engine?: string; // postgres / mysql / mongo ...
tables: string[]; // 去重后的表 / 模型清单
}
// 外部服务
interface ServiceSystem extends ExternalSystemBase {
kind: "service";
protocol?: string; // http / ws / rpc / graphql
endpoints: string[]; // 去重后的 path / peer 清单
}
type ExternalSystem = DatabaseSystem | ServiceSystem;
```
### 3.10 顶层报告 Report
```ts
interface Report {
schemaVersion: string;
project: {
name: string;
root: string;
languages: string[];
generatedAt: string;
generator?: string; // 生成工具 / 版本
};
stats: {
fileCount: number;
containerCount: number;
functionCount: number;
edgeCount: number;
loc?: number;
};
modules: Module[];
files: CodeFile[];
containers: Container[];
functions: Func[];
fields: Field[];
edges: Edge[];
flows: Flow[];
externalSystems?: ExternalSystem[]; // 由 interactions 派生的物化视图(可选缓存)
}
```
### 3.11 持有关系总览
```mermaid
flowchart TD
Report --> Module
Module --> CodeFile
CodeFile --> Container
CodeFile --> Func
Container -->|functionIds| Func
Container -->|fieldIds| Field
Func -->|interactions| Interaction
Report -->|"call/import/..."| Edge
Report --> Flow
Flow -->|steps.nodeId| Func
Edge -->|source/target| Func
```
## 四、可视化展示设计
- 4.1 整体架构图(分层视图)
- 4.2 模块关系图(依赖 / 引用)
- 4.3 流程图 · 核心视图(Call-Flow / Business-Flow 同画布切换)
- 4.4 函数级调用图(下钻视图)
- 4.5 源码目录结构树
- 4.6 数据模型 ER 图
- 4.7 交互能力(缩放 / 下钻 / 搜索 / 高亮 / 跳转 / 联动)
- 4.8 多视图切换与状态同步
## 五、报告生成与接入方式
- 5.1 手工编辑
- 5.2 AI 辅助分析生成
- 5.3 静态解析工具导入
- 5.4 报告导入 / 导出格式
## 六、软件架构与技术选型
- 6.1 整体架构
- 6.2 前端渲染方案(图形库选型)
- 6.3 数据加载与存储
- 6.4 部署形态
## 七、界面与页面结构
- 7.1 报告列表 / 项目切换
- 7.2 主画布与侧边面板
- 7.3 详情抽屉与检索
## 八、里程碑与迭代计划
+570
View File
@@ -0,0 +1,570 @@
{
"name": "code-visualize-cli",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "code-visualize-cli",
"version": "0.1.0",
"dependencies": {
"typescript": "^5.6.3"
},
"bin": {
"codeviz": "src/cli.ts"
},
"devDependencies": {
"@types/node": "^22.7.5",
"tsx": "^4.19.1"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@types/node": {
"version": "22.20.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
"integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/esbuild": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.1",
"@esbuild/android-arm": "0.28.1",
"@esbuild/android-arm64": "0.28.1",
"@esbuild/android-x64": "0.28.1",
"@esbuild/darwin-arm64": "0.28.1",
"@esbuild/darwin-x64": "0.28.1",
"@esbuild/freebsd-arm64": "0.28.1",
"@esbuild/freebsd-x64": "0.28.1",
"@esbuild/linux-arm": "0.28.1",
"@esbuild/linux-arm64": "0.28.1",
"@esbuild/linux-ia32": "0.28.1",
"@esbuild/linux-loong64": "0.28.1",
"@esbuild/linux-mips64el": "0.28.1",
"@esbuild/linux-ppc64": "0.28.1",
"@esbuild/linux-riscv64": "0.28.1",
"@esbuild/linux-s390x": "0.28.1",
"@esbuild/linux-x64": "0.28.1",
"@esbuild/netbsd-arm64": "0.28.1",
"@esbuild/netbsd-x64": "0.28.1",
"@esbuild/openbsd-arm64": "0.28.1",
"@esbuild/openbsd-x64": "0.28.1",
"@esbuild/openharmony-arm64": "0.28.1",
"@esbuild/sunos-x64": "0.28.1",
"@esbuild/win32-arm64": "0.28.1",
"@esbuild/win32-ia32": "0.28.1",
"@esbuild/win32-x64": "0.28.1"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/tsx": {
"version": "4.23.1",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz",
"integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.28.0"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
}
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"name": "code-visualize-cli",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "分析目标代码库,产出符合标准 Schema 的代码报告 JSON",
"bin": {
"codeviz": "src/cli.ts"
},
"scripts": {
"analyze": "tsx src/cli.ts"
},
"dependencies": {
"typescript": "^5.6.3"
},
"devDependencies": {
"@types/node": "^22.7.5",
"tsx": "^4.19.1"
}
}
+28907
View File
File diff suppressed because it is too large Load Diff
+5190
View File
File diff suppressed because it is too large Load Diff
+609
View File
@@ -0,0 +1,609 @@
import ts from "typescript";
import path from "node:path";
import { toPosix, collectSourceFiles, languageOf } from "./scanner";
import type {
Report,
Module,
CodeFile,
Container,
Func,
Field,
Edge,
Interaction,
DbInteraction,
ApiInteraction,
Param,
SourceLocation,
} from "./types";
import { deriveExternalSystems } from "./externals";
import { generateCallFlows } from "./flows";
const SCHEMA_VERSION = "0.1.0";
const ORM_READ = new Set(["findMany", "findFirst", "findUnique", "findUniqueOrThrow", "findFirstOrThrow", "count", "aggregate", "groupBy"]);
const ORM_CREATE = new Set(["create", "createMany", "upsert", "createManyAndReturn"]);
const ORM_UPDATE = new Set(["update", "updateMany"]);
const ORM_DELETE = new Set(["delete", "deleteMany"]);
const ORM_RAW = new Set(["$queryRaw", "$executeRaw", "$queryRawUnsafe", "$executeRawUnsafe", "$transaction"]);
const ORM_ALL = new Set([...ORM_READ, ...ORM_CREATE, ...ORM_UPDATE, ...ORM_DELETE]);
const HTTP_VERBS = new Set(["get", "post", "put", "delete", "patch", "options", "head", "all"]);
export function analyzeProject(root: string): Report {
const absRoot = path.resolve(root);
const projectName = path.basename(absRoot);
const files = collectSourceFiles(absRoot);
const program = ts.createProgram(files, {
allowJs: true,
checkJs: false,
jsx: ts.JsxEmit.Preserve,
target: ts.ScriptTarget.ESNext,
module: ts.ModuleKind.ESNext,
moduleResolution: ts.ModuleResolutionKind.Bundler,
noEmit: true,
skipLibCheck: true,
allowImportingTsExtensions: true,
esModuleInterop: true,
});
const checker = program.getTypeChecker();
// ---------- 收集容器 ----------
const modules = new Map<string, Module>();
const fileEntities = new Map<string, CodeFile>(); // key: rel path
const containers: Container[] = [];
const functions: Func[] = [];
const fields: Field[] = [];
const edges = new Map<string, Edge>();
const funcIdByNode = new Map<ts.Node, string>();
const funcById = new Map<string, Func>();
const registered = new Set<ts.Node>();
const languages = new Set<string>();
const relOf = (sf: ts.SourceFile): string => toPosix(path.relative(absRoot, path.resolve(sf.fileName)));
const inProject = (sf: ts.SourceFile): boolean => {
const rel = relOf(sf);
return !rel.startsWith("..") && !rel.includes("node_modules") && !sf.isDeclarationFile;
};
const locOf = (node: ts.Node, sf: ts.SourceFile): SourceLocation => {
const start = sf.getLineAndCharacterOfPosition(node.getStart(sf));
const end = sf.getLineAndCharacterOfPosition(node.getEnd());
return {
file: relOf(sf),
startLine: start.line + 1,
endLine: end.line + 1,
startCol: start.character + 1,
endCol: end.character + 1,
};
};
// ---------- 模块树 ----------
const ensureModule = (dir: string): Module => {
const key = dir === "." ? "" : dir;
const existing = modules.get(key);
if (existing) return existing;
const name = key === "" ? projectName : key.split("/").pop()!;
const mod: Module = {
id: `module:${key || "."}`,
kind: "module",
name,
provenance: "ast",
path: key || ".",
childModuleIds: [],
fileIds: [],
};
modules.set(key, mod);
if (key !== "") {
const parentDir = key.includes("/") ? key.slice(0, key.lastIndexOf("/")) : "";
const parent = ensureModule(parentDir);
mod.parentId = parent.id;
if (!parent.childModuleIds.includes(mod.id)) parent.childModuleIds.push(mod.id);
}
return mod;
};
// ---------- 参数/签名 ----------
const getParams = (fn: ts.SignatureDeclarationBase, sf: ts.SourceFile): Param[] => {
return fn.parameters.map((p) => ({
name: p.name.getText(sf),
typeRef: p.type?.getText(sf),
optional: !!p.questionToken || !!p.initializer,
defaultValue: p.initializer?.getText(sf),
}));
};
const buildSignature = (name: string, params: Param[], ret?: string): string => {
const ps = params.map((p) => (p.typeRef ? `${p.name}: ${p.typeRef}` : p.name)).join(", ");
return `${name}(${ps})${ret ? `: ${ret}` : ""}`;
};
const registerFunc = (
declNode: ts.Node,
fnNode: ts.SignatureDeclarationBase & ts.Node,
name: string,
sf: ts.SourceFile,
rel: string,
fileEntity: CodeFile,
ownerId?: string,
qualifiedPrefix?: string
): Func => {
if (registered.has(declNode)) return funcById.get(funcIdByNode.get(declNode)!)!;
registered.add(declNode);
const loc = locOf(declNode, sf);
const params = getParams(fnNode, sf);
const returnType = (fnNode as ts.FunctionLikeDeclaration).type?.getText(sf);
const qualified = qualifiedPrefix ? `${qualifiedPrefix}.${name}` : name;
const id = `func:${rel}#${qualified}@${loc.startLine}`;
const isEntry = /(^|\/)(server|main|index|app)\.[jt]sx?$/.test(rel) && !ownerId;
const fn: Func = {
id,
kind: "function",
name,
qualifiedName: qualified,
location: loc,
language: languageOf(rel),
provenance: "ast",
fileId: fileEntity.id,
ownerId,
moduleId: fileEntity.moduleId,
signature: buildSignature(name, params, returnType),
params,
returnType,
isEntry: isEntry || undefined,
loc: loc.endLine - loc.startLine + 1,
};
functions.push(fn);
funcById.set(id, fn);
funcIdByNode.set(declNode, id);
if (ownerId) {
const c = containers.find((x) => x.id === ownerId);
c?.functionIds.push(id);
} else {
fileEntity.functionIds.push(id);
}
return fn;
};
const isFnInit = (node?: ts.Node): node is ts.ArrowFunction | ts.FunctionExpression =>
!!node && (ts.isArrowFunction(node) || ts.isFunctionExpression(node));
// 判断函数体内是否存在调用(用于筛选「有意义的」内联回调,跳过琐碎箭头)
const containsCall = (n: ts.Node): boolean => {
let found = false;
const scan = (x: ts.Node): void => {
if (found) return;
if (ts.isCallExpression(x) || ts.isNewExpression(x)) {
found = true;
return;
}
ts.forEachChild(x, scan);
};
ts.forEachChild(n, scan);
return found;
};
// 若某个调用是路由注册(app/router.verb('/path', ...)),返回形如 "GET /path" 的名字
const routeNameFromCall = (call: ts.CallExpression, sf: ts.SourceFile): string | undefined => {
const e = call.expression;
if (
ts.isPropertyAccessExpression(e) &&
HTTP_VERBS.has(e.name.text) &&
/app|router|server|route/i.test(e.expression.getText(sf))
) {
const p = extractPathArg(call.arguments[0], sf);
if (p !== undefined) return `${e.name.text.toUpperCase()} ${p}`;
}
return undefined;
};
// 为内联匿名函数按上下文取名:路由 handler > 回调所属调用 > JSX 属性 > 匿名
const inlineName = (fn: ts.ArrowFunction | ts.FunctionExpression, sf: ts.SourceFile): string => {
const line = locOf(fn, sf).startLine;
const parent = fn.parent;
if (parent && ts.isCallExpression(parent)) {
const routeName = routeNameFromCall(parent, sf);
if (routeName) return routeName;
const callee = parent.expression;
const cn = ts.isPropertyAccessExpression(callee)
? callee.name.text
: ts.isIdentifier(callee)
? callee.text
: "fn";
return `${cn}() 回调@${line}`;
}
if (parent && ts.isJsxAttribute(parent)) {
return `${parent.name.getText(sf)} 处理@${line}`;
}
return `匿名函数@${line}`;
};
// ---------- 注册 pass ----------
const registerFile = (sf: ts.SourceFile): void => {
const rel = relOf(sf);
languages.add(languageOf(rel));
const dir = toPosix(path.dirname(rel));
const mod = ensureModule(dir === "." ? "" : dir);
const lineCount = sf.getLineAndCharacterOfPosition(sf.getEnd()).line + 1;
const fileEntity: CodeFile = {
id: `file:${rel}`,
kind: "file",
name: toPosix(path.basename(rel)),
location: { file: rel, startLine: 1, endLine: lineCount },
language: languageOf(rel),
provenance: "ast",
moduleId: mod.id,
path: rel,
loc: lineCount,
containerIds: [],
functionIds: [],
};
fileEntities.set(rel, fileEntity);
if (!mod.fileIds.includes(fileEntity.id)) mod.fileIds.push(fileEntity.id);
const visit = (node: ts.Node): void => {
// 类 / 类似容器
if (ts.isClassDeclaration(node) || ts.isClassExpression(node)) {
const cname = node.name?.text ?? "<anonymous class>";
const loc = locOf(node, sf);
const container: Container = {
id: `container:${rel}#${cname}@${loc.startLine}`,
kind: "container",
name: cname,
qualifiedName: cname,
location: loc,
language: languageOf(rel),
provenance: "ast",
fileId: fileEntity.id,
moduleId: mod.id,
functionIds: [],
fieldIds: [],
};
containers.push(container);
fileEntity.containerIds.push(container.id);
for (const member of node.members) {
if (
ts.isMethodDeclaration(member) ||
ts.isConstructorDeclaration(member) ||
ts.isGetAccessor(member) ||
ts.isSetAccessor(member)
) {
const mname = ts.isConstructorDeclaration(member) ? "constructor" : member.name?.getText(sf) ?? "<anon>";
registerFunc(member, member, mname, sf, rel, fileEntity, container.id, cname);
} else if (ts.isPropertyDeclaration(member)) {
registered.add(member);
if (isFnInit(member.initializer)) {
registered.add(member.initializer);
const mname = member.name.getText(sf);
registerFunc(member, member.initializer, mname, sf, rel, fileEntity, container.id, cname);
} else {
const loc2 = locOf(member, sf);
const fname = member.name.getText(sf);
const field: Field = {
id: `field:${rel}#${cname}.${fname}@${loc2.startLine}`,
kind: "field",
name: fname,
qualifiedName: `${cname}.${fname}`,
location: loc2,
language: languageOf(rel),
provenance: "ast",
ownerId: container.id,
typeRef: member.type?.getText(sf),
};
fields.push(field);
container.fieldIds.push(field.id);
}
}
}
ts.forEachChild(node, visit);
return;
}
if (ts.isFunctionDeclaration(node) && node.name && !registered.has(node)) {
registerFunc(node, node, node.name.text, sf, rel, fileEntity);
} else if (ts.isVariableDeclaration(node) && isFnInit(node.initializer) && ts.isIdentifier(node.name)) {
registered.add(node.initializer);
registerFunc(node, node.initializer, node.name.text, sf, rel, fileEntity);
} else if (
ts.isPropertyAssignment(node) &&
isFnInit(node.initializer) &&
(ts.isIdentifier(node.name) || ts.isStringLiteral(node.name)) &&
!registered.has(node)
) {
registered.add(node.initializer);
registerFunc(node, node.initializer, node.name.getText(sf), sf, rel, fileEntity);
} else if (
(ts.isArrowFunction(node) || ts.isFunctionExpression(node)) &&
!registered.has(node) &&
ts.isBlock(node.body) &&
containsCall(node.body)
) {
// 有意义的内联匿名回调(含调用的块体):登记为节点,避免调用被拍平到 <module>
registerFunc(node, node, inlineName(node, sf), sf, rel, fileEntity);
}
ts.forEachChild(node, visit);
};
ts.forEachChild(sf, visit);
};
// ---------- 交互识别 ----------
const extractPathArg = (node: ts.Expression | undefined, sf: ts.SourceFile): string | undefined => {
if (!node) return undefined;
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text;
if (ts.isTemplateExpression(node)) return node.getText(sf);
if (ts.isCallExpression(node)) return extractPathArg(node.arguments[0], sf); // apiPath('/x')
return undefined;
};
const extractMethodFromOptions = (arg: ts.Expression | undefined, sf: ts.SourceFile): string | undefined => {
if (!arg || !ts.isObjectLiteralExpression(arg)) return undefined;
for (const p of arg.properties) {
if (ts.isPropertyAssignment(p) && p.name.getText(sf) === "method") {
const v = p.initializer;
if (ts.isStringLiteral(v) || ts.isNoSubstitutionTemplateLiteral(v)) return v.text.toUpperCase();
}
}
return undefined;
};
const hostOf = (url?: string): string | undefined => {
if (!url) return undefined;
const m = /^https?:\/\/([^/]+)/i.exec(url);
return m ? m[1] : undefined;
};
const detectDb = (call: ts.CallExpression, sf: ts.SourceFile): DbInteraction | undefined => {
const expr = call.expression;
if (!ts.isPropertyAccessExpression(expr)) return undefined;
const method = expr.name.text;
if (ORM_RAW.has(method)) {
const baseText = expr.expression.getText(sf);
if (!/prisma|db|client/i.test(baseText)) return undefined;
return { id: "", kind: "db", direction: "out", provenance: "resolved", operation: "other", engine: "prisma" };
}
if (!ORM_ALL.has(method)) return undefined;
// 形如 prisma.<model>.<op>()
const modelAccess = expr.expression;
if (!ts.isPropertyAccessExpression(modelAccess)) return undefined;
const baseText = modelAccess.expression.getText(sf);
if (!/prisma|db|client|repository|repo/i.test(baseText)) return undefined;
const target = modelAccess.name.text;
let operation: DbInteraction["operation"] = "other";
if (ORM_READ.has(method)) operation = "read";
else if (ORM_CREATE.has(method)) operation = "create";
else if (ORM_UPDATE.has(method)) operation = "update";
else if (ORM_DELETE.has(method)) operation = "delete";
return { id: "", kind: "db", direction: "out", provenance: "resolved", operation, target, engine: "prisma" };
};
const detectApiOut = (call: ts.CallExpression, sf: ts.SourceFile): ApiInteraction | undefined => {
const expr = call.expression;
// fetch(url, opts)
if (ts.isIdentifier(expr) && expr.text === "fetch") {
const url = extractPathArg(call.arguments[0], sf);
const method = extractMethodFromOptions(call.arguments[1], sf) ?? "GET";
return { id: "", kind: "api", direction: "out", provenance: "resolved", protocol: "http", method, path: url, peer: hostOf(url) };
}
// axios.get(url) / http.post(url)
if (ts.isPropertyAccessExpression(expr) && HTTP_VERBS.has(expr.name.text)) {
const baseText = expr.expression.getText(sf);
if (!/axios|http|client|request|api/i.test(baseText)) return undefined;
const url = extractPathArg(call.arguments[0], sf);
return { id: "", kind: "api", direction: "out", provenance: "resolved", protocol: "http", method: expr.name.text.toUpperCase(), path: url, peer: hostOf(url) };
}
return undefined;
};
const detectApiIn = (
call: ts.CallExpression,
sf: ts.SourceFile
): { interaction: ApiInteraction; handler?: ts.Node } | undefined => {
const expr = call.expression;
if (!ts.isPropertyAccessExpression(expr)) return undefined;
const verb = expr.name.text;
if (!HTTP_VERBS.has(verb)) return undefined;
const baseText = expr.expression.getText(sf);
if (!/app|router|server|route/i.test(baseText)) return undefined;
const p = extractPathArg(call.arguments[0], sf);
if (p === undefined) return undefined;
const handler = call.arguments[call.arguments.length - 1];
return {
interaction: { id: "", kind: "api", direction: "in", provenance: "resolved", protocol: "http", method: verb.toUpperCase(), path: p },
handler,
};
};
const addInteraction = (fnId: string, ix: Interaction): void => {
const fn = funcById.get(fnId);
if (!fn) return;
if (!fn.interactions) fn.interactions = [];
const key =
ix.kind === "db"
? `db:${ix.direction}:${(ix as DbInteraction).operation}:${(ix as DbInteraction).target ?? ""}`
: `api:${ix.direction}:${(ix as ApiInteraction).method ?? ""}:${(ix as ApiInteraction).path ?? ""}`;
ix.id = `ix:${fnId}:${key}`;
if (fn.interactions.some((x) => x.id === ix.id)) return;
fn.interactions.push(ix);
};
// ---------- 关系 pass ----------
const addEdge = (kind: Edge["kind"], sourceId: string, targetId: string, provenance: Edge["provenance"]): void => {
if (!sourceId || !targetId || sourceId === targetId) return;
const id = `edge:${kind}:${sourceId}=>${targetId}`;
if (edges.has(id)) return;
edges.set(id, { id, kind, sourceId, targetId, provenance });
};
const resolveCallTargetFuncId = (call: ts.CallExpression): string | undefined => {
const expr = call.expression;
let sym = checker.getSymbolAtLocation(expr);
if (!sym && ts.isPropertyAccessExpression(expr)) sym = checker.getSymbolAtLocation(expr.name);
if (!sym) return undefined;
if (sym.flags & ts.SymbolFlags.Alias) {
try {
sym = checker.getAliasedSymbol(sym);
} catch {
/* ignore */
}
}
const decls = sym.getDeclarations();
if (!decls) return undefined;
for (const d of decls) {
const found = funcIdByNode.get(d);
if (found) return found;
}
return undefined;
};
const moduleFuncCache = new Map<string, string>();
const getModuleFunc = (sf: ts.SourceFile, rel: string, fileEntity: CodeFile): string => {
const cached = moduleFuncCache.get(rel);
if (cached) return cached;
const id = `func:${rel}#<module>@0`;
const fn: Func = {
id,
kind: "function",
name: "<module>",
qualifiedName: "<module>",
location: { file: rel, startLine: 1, endLine: fileEntity.loc ?? 1 },
language: languageOf(rel),
provenance: "ast",
fileId: fileEntity.id,
moduleId: fileEntity.moduleId,
params: [],
isEntry: /(^|\/)(server|main|index|app)\.[jt]sx?$/.test(rel) || undefined,
summary: "模块顶层代码(副作用/初始化)",
};
functions.push(fn);
funcById.set(id, fn);
fileEntity.functionIds.push(id);
moduleFuncCache.set(rel, id);
return id;
};
const analyzeRelations = (sf: ts.SourceFile): void => {
const rel = relOf(sf);
const fileEntity = fileEntities.get(rel);
if (!fileEntity) return;
const walk = (node: ts.Node, currentFuncId: string | undefined): void => {
let cur = currentFuncId;
const mapped = funcIdByNode.get(node);
if (mapped) cur = mapped;
// import 边(内部文件间)
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
const targetRel = resolveInternalImport(rel, node.moduleSpecifier.text);
if (targetRel && fileEntities.has(targetRel)) {
addEdge("import", fileEntity.id, `file:${targetRel}`, "ast");
}
}
if (ts.isCallExpression(node)) {
const owner = cur ?? getModuleFunc(sf, rel, fileEntity);
// 调用边
const targetFn = resolveCallTargetFuncId(node);
if (targetFn) addEdge("call", owner, targetFn, "resolved");
// 交互
const db = detectDb(node, sf);
if (db) addInteraction(owner, db);
const apiOut = detectApiOut(node, sf);
if (apiOut) addInteraction(owner, apiOut);
const apiIn = detectApiIn(node, sf);
if (apiIn) {
let handlerFn: string | undefined;
const h = apiIn.handler;
if (h) {
if (funcIdByNode.has(h)) {
handlerFn = funcIdByNode.get(h); // 内联 handler 已在 pass1 登记
} else if (ts.isIdentifier(h)) {
const sym = checker.getSymbolAtLocation(h);
for (const d of sym?.getDeclarations() ?? []) {
const f = funcIdByNode.get(d);
if (f) {
handlerFn = f;
break;
}
}
}
}
const target = handlerFn ?? owner;
addInteraction(target, apiIn.interaction);
// 注册作用域 → handler 的触发边,保持连通性
if (handlerFn && handlerFn !== owner) addEdge("trigger", owner, handlerFn, "resolved");
}
}
ts.forEachChild(node, (c) => walk(c, cur));
};
ts.forEachChild(sf, (n) => walk(n, undefined));
};
const resolveInternalImport = (fromRel: string, spec: string): string | undefined => {
if (!spec.startsWith(".")) return undefined;
const fromDir = toPosix(path.dirname(fromRel));
const joined = toPosix(path.normalize(path.join(fromDir, spec)));
const candidates = [
joined,
`${joined}.ts`,
`${joined}.tsx`,
`${joined}.js`,
`${joined}.jsx`,
`${joined}/index.ts`,
`${joined}/index.tsx`,
`${joined}/index.js`,
];
// 处理带扩展名的显式导入(如 ./db.js -> ./db.ts
const stripped = joined.replace(/\.(js|jsx|ts|tsx)$/, "");
candidates.push(`${stripped}.ts`, `${stripped}.tsx`, `${stripped}.js`, `${stripped}.jsx`);
for (const c of candidates) {
if (fileEntities.has(c)) return c;
}
return undefined;
};
// ---------- 执行 ----------
const projectSourceFiles = program.getSourceFiles().filter(inProject);
for (const sf of projectSourceFiles) registerFile(sf);
for (const sf of projectSourceFiles) analyzeRelations(sf);
ensureModule(""); // 保证根模块存在
const totalLoc = [...fileEntities.values()].reduce((s, f) => s + (f.loc ?? 0), 0);
const edgeList = [...edges.values()];
const flows = generateCallFlows(functions, edgeList);
const report: Report = {
schemaVersion: SCHEMA_VERSION,
project: {
name: projectName,
root: toPosix(absRoot),
languages: [...languages].sort(),
generatedAt: new Date().toISOString(),
generator: "code-visualize-cli@" + SCHEMA_VERSION,
},
stats: {
fileCount: fileEntities.size,
containerCount: containers.length,
functionCount: functions.length,
edgeCount: edgeList.length,
loc: totalLoc,
},
modules: [...modules.values()],
files: [...fileEntities.values()],
containers,
functions,
fields,
edges: edgeList,
flows,
externalSystems: deriveExternalSystems(functions),
};
return report;
}
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { analyzeProject } from "./analyzer";
interface CliArgs {
projectDir?: string;
output?: string;
pretty: boolean;
help: boolean;
}
function parseArgs(argv: string[]): CliArgs {
const args: CliArgs = { pretty: true, help: false };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === "-h" || a === "--help") args.help = true;
else if (a === "-o" || a === "--output") args.output = argv[++i];
else if (a === "--minify") args.pretty = false;
else if (!a.startsWith("-") && !args.projectDir) args.projectDir = a;
}
return args;
}
const HELP = `代码分析 CLI — 产出标准化代码报告 JSON
用法:
codeviz <项目目录> [选项]
选项:
-o, --output <文件> 输出 JSON 路径(默认 <项目目录>/code-report.json
--minify 输出压缩 JSON(默认美化)
-h, --help 显示帮助
示例:
npm run analyze -- E:/task-flow-manager
npm run analyze -- E:/task-flow-manager -o report.json
`;
function main(): void {
const args = parseArgs(process.argv.slice(2));
if (args.help || !args.projectDir) {
console.log(HELP);
process.exit(args.help ? 0 : 1);
}
const projectDir = path.resolve(args.projectDir!);
if (!fs.existsSync(projectDir) || !fs.statSync(projectDir).isDirectory()) {
console.error(`错误: 目录不存在或不是文件夹 -> ${projectDir}`);
process.exit(1);
}
const outPath = path.resolve(args.output ?? path.join(projectDir, "code-report.json"));
console.error(`正在分析: ${projectDir}`);
const started = Date.now();
const report = analyzeProject(projectDir);
const json = JSON.stringify(report, null, args.pretty ? 2 : 0);
fs.writeFileSync(outPath, json, "utf8");
const ms = Date.now() - started;
console.error(
`完成 (${ms}ms): 文件 ${report.stats.fileCount} · 容器 ${report.stats.containerCount} · 函数 ${report.stats.functionCount} · 边 ${report.stats.edgeCount} · 流程 ${report.flows.length}`
);
console.error(`外部系统: ${(report.externalSystems ?? []).map((e) => e.name).join(", ") || "无"}`);
console.error(`报告已写入: ${outPath}`);
}
main();
+60
View File
@@ -0,0 +1,60 @@
import type { Func, ExternalSystem, DatabaseSystem, ServiceSystem, ApiInteraction, DbInteraction } from "./types";
/** 由所有函数的 interactions 聚合派生出外部系统清单 */
export function deriveExternalSystems(functions: Func[]): ExternalSystem[] {
const databases = new Map<string, DatabaseSystem>();
const services = new Map<string, ServiceSystem>();
for (const fn of functions) {
for (const ix of fn.interactions ?? []) {
if (ix.kind === "db") {
const db = ix as DbInteraction;
const engine = db.engine ?? "database";
const key = engine;
let sys = databases.get(key);
if (!sys) {
sys = {
id: `ext:db:${engine}`,
kind: "database",
name: engine,
engine,
tables: [],
functionIds: [],
interactionCount: 0,
provenance: "resolved",
};
databases.set(key, sys);
}
sys.interactionCount += 1;
if (!sys.functionIds.includes(fn.id)) sys.functionIds.push(fn.id);
if (db.target && !sys.tables.includes(db.target)) sys.tables.push(db.target);
} else if (ix.kind === "api" && ix.direction === "out") {
const api = ix as ApiInteraction;
// 仅绝对 URL(有 peer 主机)算外部服务;相对路径是前后端内部协议
if (!api.peer) continue;
let sys = services.get(api.peer);
if (!sys) {
sys = {
id: `ext:service:${api.peer}`,
kind: "service",
name: api.peer,
protocol: api.protocol,
endpoints: [],
functionIds: [],
interactionCount: 0,
provenance: "resolved",
};
services.set(api.peer, sys);
}
sys.interactionCount += 1;
if (!sys.functionIds.includes(fn.id)) sys.functionIds.push(fn.id);
if (api.path && !sys.endpoints.includes(api.path)) sys.endpoints.push(api.path);
}
}
}
for (const s of databases.values()) s.tables.sort();
for (const s of services.values()) s.endpoints.sort();
return [...databases.values(), ...services.values()];
}
+102
View File
@@ -0,0 +1,102 @@
import type { Func, Edge, Flow, FlowStep } from "./types";
export interface FlowOptions {
maxNodes: number; // 单条流程最多包含的函数数
maxDepth: number; // 调用链最大深度
maxFlows: number; // 最多生成的流程数
}
const DEFAULTS: FlowOptions = { maxNodes: 60, maxDepth: 12, maxFlows: 80 };
/**
* 从调用边自动生成 Call-Flow
* 以「入口函数」为根,沿调用边做有界 BFS,得到一条以函数为步骤的调用链流程。
* 入口 = isEntry 标记,或调用入度为 0 且有出边的函数(天然的顶层起点)。
*/
export function generateCallFlows(functions: Func[], edges: Edge[], options?: Partial<FlowOptions>): Flow[] {
const opts = { ...DEFAULTS, ...options };
const callEdges = edges.filter((e) => e.kind === "call");
const funcById = new Map(functions.map((f) => [f.id, f] as const));
const outAdj = new Map<string, string[]>();
const inDeg = new Map<string, number>();
for (const f of functions) {
outAdj.set(f.id, []);
inDeg.set(f.id, 0);
}
for (const e of callEdges) {
if (!outAdj.has(e.sourceId)) outAdj.set(e.sourceId, []);
outAdj.get(e.sourceId)!.push(e.targetId);
inDeg.set(e.targetId, (inDeg.get(e.targetId) ?? 0) + 1);
}
const roots = functions.filter(
(f) => (f.isEntry || (inDeg.get(f.id) ?? 0) === 0) && (outAdj.get(f.id)?.length ?? 0) > 0
);
// 入口优先、出边多者优先,保证先生成信息量大的主流程
roots.sort(
(a, b) =>
(b.isEntry ? 1 : 0) - (a.isEntry ? 1 : 0) ||
(outAdj.get(b.id)?.length ?? 0) - (outAdj.get(a.id)?.length ?? 0)
);
const flows: Flow[] = [];
for (const root of roots) {
if (flows.length >= opts.maxFlows) break;
const included: string[] = [];
const seen = new Set<string>([root.id]);
const depth = new Map<string, number>([[root.id, 0]]);
const queue: string[] = [root.id];
while (queue.length > 0 && included.length < opts.maxNodes) {
const id = queue.shift()!;
included.push(id);
const d = depth.get(id)!;
if (d >= opts.maxDepth) continue;
for (const t of outAdj.get(id) ?? []) {
if (!seen.has(t)) {
seen.add(t);
depth.set(t, d + 1);
queue.push(t);
}
}
}
if (included.length < 2) continue; // 只有孤立入口,不成流程
const includedSet = new Set(included);
const flowId = `flow:call:${root.id}`;
const stepIdOf = (fid: string): string => `step:${flowId}:${fid}`;
const steps: FlowStep[] = included.map((fid, i) => {
const fn = funcById.get(fid);
return {
id: stepIdOf(fid),
order: i,
nodeId: fid,
title: fn?.qualifiedName ?? fn?.name ?? fid,
nextStepIds: (outAdj.get(fid) ?? []).filter((t) => includedSet.has(t)).map(stepIdOf),
};
});
const edgeIds = callEdges
.filter((e) => includedSet.has(e.sourceId) && includedSet.has(e.targetId))
.map((e) => e.id);
const rootName = root.qualifiedName ?? root.name;
const where = root.location?.file ?? "";
flows.push({
id: flowId,
name: where ? `${rootName} (${where})` : rootName,
flowType: "call",
provenance: "resolved",
entryNodeId: root.id,
steps,
edgeIds,
summary: `${rootName} 为入口的调用链,覆盖 ${included.length} 个函数`,
});
}
return flows;
}
+68
View File
@@ -0,0 +1,68 @@
import fs from "node:fs";
import path from "node:path";
export const SOURCE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"];
const IGNORED_DIRS = new Set([
"node_modules",
".git",
"dist",
"build",
"out",
".next",
".turbo",
"coverage",
".cache",
"vendor",
]);
export function toPosix(p: string): string {
return p.split(path.sep).join("/");
}
/** 递归收集目标目录下的源码文件(绝对路径) */
export function collectSourceFiles(root: string): string[] {
const result: string[] = [];
const walk = (dir: string): void => {
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (IGNORED_DIRS.has(entry.name) || entry.name.startsWith(".")) continue;
walk(full);
} else if (entry.isFile()) {
const ext = path.extname(entry.name);
if (!SOURCE_EXTENSIONS.includes(ext)) continue;
if (entry.name.endsWith(".d.ts")) continue;
result.push(full);
}
}
};
walk(root);
return result.sort();
}
export function languageOf(file: string): string {
const ext = path.extname(file);
switch (ext) {
case ".ts":
return "typescript";
case ".tsx":
return "tsx";
case ".js":
case ".mjs":
case ".cjs":
return "javascript";
case ".jsx":
return "jsx";
default:
return "unknown";
}
}
+204
View File
@@ -0,0 +1,204 @@
// 标准化代码报告数据结构(对齐 docs/设计文档.md 第三章)
export type Id = string;
export type Provenance = "ast" | "resolved" | "ai" | "manual";
export interface SourceLocation {
file: string;
startLine: number;
endLine: number;
startCol?: number;
endCol?: number;
}
export interface BaseEntity {
id: Id;
name: string;
qualifiedName?: string;
location?: SourceLocation;
language?: string;
provenance: Provenance;
confidence?: number;
summary?: string;
tags?: string[];
extra?: Record<string, unknown>;
}
export interface Module extends BaseEntity {
kind: "module";
parentId?: Id;
childModuleIds: Id[];
fileIds: Id[];
path?: string;
}
export interface CodeFile extends BaseEntity {
kind: "file";
moduleId: Id;
path: string;
loc?: number;
containerIds: Id[];
functionIds: Id[];
}
export interface Container extends BaseEntity {
kind: "container";
fileId: Id;
moduleId?: Id;
functionIds: Id[];
fieldIds: Id[];
}
export interface Param {
name: string;
typeRef?: string;
optional?: boolean;
defaultValue?: string;
}
export interface Func extends BaseEntity {
kind: "function";
fileId: Id;
ownerId?: Id;
moduleId?: Id;
signature?: string;
params: Param[];
returnType?: string;
isEntry?: boolean;
interactions?: Interaction[];
loc?: number;
}
export interface Field extends BaseEntity {
kind: "field";
ownerId: Id;
typeRef?: string;
}
// ---------- 跨边界交互 ----------
export type InteractionKind = "api" | "db";
export type InteractionDirection = "in" | "out";
export interface InteractionBase {
id: Id;
direction: InteractionDirection;
provenance: Provenance;
confidence?: number;
summary?: string;
}
export interface ApiInteraction extends InteractionBase {
kind: "api";
protocol: "http" | "ws" | "rpc" | "graphql";
method?: string;
path?: string;
peer?: string;
}
export interface DbInteraction extends InteractionBase {
kind: "db";
operation: "read" | "create" | "update" | "delete" | "other";
target?: string;
engine?: string;
}
export type Interaction = ApiInteraction | DbInteraction;
// ---------- 关系边 ----------
export type EdgeKind = "call" | "import" | "reference" | "dataflow" | "trigger";
export interface Edge {
id: Id;
kind: EdgeKind;
sourceId: Id;
targetId: Id;
provenance: Provenance;
confidence?: number;
label?: string;
extra?: Record<string, unknown>;
}
// ---------- 流程 ----------
export interface FlowStep {
id: Id;
order: number;
nodeId?: Id;
title: string;
description?: string;
branchLabel?: string;
nextStepIds: Id[];
}
export interface Flow {
id: Id;
name: string;
flowType: "call" | "business";
summary?: string;
provenance: Provenance;
entryNodeId?: Id;
steps: FlowStep[];
edgeIds?: Id[];
}
// ---------- 外部系统(派生) ----------
export type ExternalSystemKind = "database" | "service";
export interface ExternalSystemBase {
id: Id;
kind: ExternalSystemKind;
name: string;
functionIds: Id[];
interactionCount: number;
provenance: Provenance;
}
export interface DatabaseSystem extends ExternalSystemBase {
kind: "database";
engine?: string;
tables: string[];
}
export interface ServiceSystem extends ExternalSystemBase {
kind: "service";
protocol?: string;
endpoints: string[];
}
export type ExternalSystem = DatabaseSystem | ServiceSystem;
// ---------- 顶层报告 ----------
export interface ProjectMeta {
name: string;
root: string;
languages: string[];
generatedAt: string;
generator?: string;
}
export interface ReportStats {
fileCount: number;
containerCount: number;
functionCount: number;
edgeCount: number;
loc?: number;
}
export interface Report {
schemaVersion: string;
project: ProjectMeta;
stats: ReportStats;
modules: Module[];
files: CodeFile[];
containers: Container[];
functions: Func[];
fields: Field[];
edges: Edge[];
flows: Flow[];
externalSystems?: ExternalSystem[];
}
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"types": ["node"],
"noEmit": true
},
"include": ["src"]
}
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>代码分析可视化</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2119
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "code-visualize-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@dagrejs/dagre": "^1.1.4",
"@xyflow/react": "^12.3.5",
"mobx": "^6.13.5",
"mobx-react-lite": "^4.0.7",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.6.3",
"vite": "^6.0.5"
}
}
+28907
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
import React from "react";
import ReactDOM from "react-dom/client";
import "@xyflow/react/dist/style.css";
import "./styles.css";
import { App } from "./view/App";
import { VMContext } from "./view/vmContext";
import { GraphViewModel } from "./viewmodel/GraphViewModel";
import { HttpReportRepository, type ReportSource } from "./model/reportRepository";
// 可切换的报告数据源;新增项目只需往这里加一条
const sources: ReportSource[] = [
{ id: "taskflow", name: "task-flow-manager", url: "/report.json" },
{ id: "self", name: "code-visualize(本工具)", url: "/self-report.json" },
];
const viewModel = new GraphViewModel(sources, (url) => new HttpReportRepository(url));
void viewModel.load();
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<VMContext.Provider value={viewModel}>
<App />
</VMContext.Provider>
</React.StrictMode>
);
+66
View File
@@ -0,0 +1,66 @@
import type { CodeFile, Container, Flow, Func, Interaction } from "./report";
/** 与渲染技术无关的「中性图」结构。ViewModel 与 View 都基于它工作。 */
export interface GraphNode {
id: string; // 图节点 id(此处等于 FlowStep.id
label: string; // 函数名
qualifier?: string; // 归属:方法所属类名 / 自由函数所属文件名
funcId?: string;
isEntry?: boolean;
interactions?: Interaction[];
}
export interface GraphContext {
funcById: Map<string, Func>;
containerById: Map<string, Container>;
fileById: Map<string, CodeFile>;
}
function baseName(path: string): string {
const parts = path.split("/");
return parts[parts.length - 1] || path;
}
function qualifierOf(fn: Func | undefined, ctx: GraphContext): string | undefined {
if (!fn) return undefined;
if (fn.ownerId) return ctx.containerById.get(fn.ownerId)?.name;
const file = ctx.fileById.get(fn.fileId);
return file ? baseName(file.path) : undefined;
}
export interface GraphEdge {
id: string;
source: string;
target: string;
}
export interface GraphData {
nodes: GraphNode[];
edges: GraphEdge[];
}
/** 将一条 Flow 转换为中性图(纯函数,无副作用、无框架依赖) */
export function flowToGraph(flow: Flow, ctx: GraphContext): GraphData {
const nodes: GraphNode[] = flow.steps.map((step) => {
const fn = step.nodeId ? ctx.funcById.get(step.nodeId) : undefined;
return {
id: step.id,
label: fn?.name ?? step.title,
qualifier: qualifierOf(fn, ctx),
funcId: step.nodeId,
isEntry: fn?.isEntry,
interactions: fn?.interactions,
};
});
const validStepIds = new Set(nodes.map((n) => n.id));
const edges: GraphEdge[] = [];
for (const step of flow.steps) {
for (const next of step.nextStepIds) {
if (!validStepIds.has(next)) continue;
edges.push({ id: `${step.id}->${next}`, source: step.id, target: next });
}
}
return { nodes, edges };
}
+193
View File
@@ -0,0 +1,193 @@
// 标准化代码报告数据结构(与 CLI 端 src/types.ts 对齐)
export type Id = string;
export type Provenance = "ast" | "resolved" | "ai" | "manual";
export interface SourceLocation {
file: string;
startLine: number;
endLine: number;
startCol?: number;
endCol?: number;
}
export interface BaseEntity {
id: Id;
name: string;
qualifiedName?: string;
location?: SourceLocation;
language?: string;
provenance: Provenance;
confidence?: number;
summary?: string;
tags?: string[];
extra?: Record<string, unknown>;
}
export interface Module extends BaseEntity {
kind: "module";
parentId?: Id;
childModuleIds: Id[];
fileIds: Id[];
path?: string;
}
export interface CodeFile extends BaseEntity {
kind: "file";
moduleId: Id;
path: string;
loc?: number;
containerIds: Id[];
functionIds: Id[];
}
export interface Container extends BaseEntity {
kind: "container";
fileId: Id;
moduleId?: Id;
functionIds: Id[];
fieldIds: Id[];
}
export interface Param {
name: string;
typeRef?: string;
optional?: boolean;
defaultValue?: string;
}
export interface Func extends BaseEntity {
kind: "function";
fileId: Id;
ownerId?: Id;
moduleId?: Id;
signature?: string;
params: Param[];
returnType?: string;
isEntry?: boolean;
interactions?: Interaction[];
loc?: number;
}
export interface Field extends BaseEntity {
kind: "field";
ownerId: Id;
typeRef?: string;
}
export type InteractionKind = "api" | "db";
export type InteractionDirection = "in" | "out";
export interface InteractionBase {
id: Id;
direction: InteractionDirection;
provenance: Provenance;
confidence?: number;
summary?: string;
}
export interface ApiInteraction extends InteractionBase {
kind: "api";
protocol: "http" | "ws" | "rpc" | "graphql";
method?: string;
path?: string;
peer?: string;
}
export interface DbInteraction extends InteractionBase {
kind: "db";
operation: "read" | "create" | "update" | "delete" | "other";
target?: string;
engine?: string;
}
export type Interaction = ApiInteraction | DbInteraction;
export type EdgeKind = "call" | "import" | "reference" | "dataflow" | "trigger";
export interface Edge {
id: Id;
kind: EdgeKind;
sourceId: Id;
targetId: Id;
provenance: Provenance;
confidence?: number;
label?: string;
extra?: Record<string, unknown>;
}
export interface FlowStep {
id: Id;
order: number;
nodeId?: Id;
title: string;
description?: string;
branchLabel?: string;
nextStepIds: Id[];
}
export interface Flow {
id: Id;
name: string;
flowType: "call" | "business";
summary?: string;
provenance: Provenance;
entryNodeId?: Id;
steps: FlowStep[];
edgeIds?: Id[];
}
export type ExternalSystemKind = "database" | "service";
export interface ExternalSystemBase {
id: Id;
kind: ExternalSystemKind;
name: string;
functionIds: Id[];
interactionCount: number;
provenance: Provenance;
}
export interface DatabaseSystem extends ExternalSystemBase {
kind: "database";
engine?: string;
tables: string[];
}
export interface ServiceSystem extends ExternalSystemBase {
kind: "service";
protocol?: string;
endpoints: string[];
}
export type ExternalSystem = DatabaseSystem | ServiceSystem;
export interface ProjectMeta {
name: string;
root: string;
languages: string[];
generatedAt: string;
generator?: string;
}
export interface ReportStats {
fileCount: number;
containerCount: number;
functionCount: number;
edgeCount: number;
loc?: number;
}
export interface Report {
schemaVersion: string;
project: ProjectMeta;
stats: ReportStats;
modules: Module[];
files: CodeFile[];
containers: Container[];
functions: Func[];
fields: Field[];
edges: Edge[];
flows: Flow[];
externalSystems?: ExternalSystem[];
}
+26
View File
@@ -0,0 +1,26 @@
import type { Report } from "./report";
/** 一个可供选择的报告数据源 */
export interface ReportSource {
id: string;
name: string;
url: string;
}
/** 报告数据源抽象。切换来源(HTTP / 本地文件 / 上传)只需换实现,不影响上层。 */
export interface ReportRepository {
load(): Promise<Report>;
}
/** 从 URL 加载报告 JSON */
export class HttpReportRepository implements ReportRepository {
constructor(private readonly url: string) {}
async load(): Promise<Report> {
const res = await fetch(this.url);
if (!res.ok) {
throw new Error(`加载报告失败: HTTP ${res.status} (${this.url})`);
}
return (await res.json()) as Report;
}
}
+62
View File
@@ -0,0 +1,62 @@
import dagre from "@dagrejs/dagre";
export type LayoutDirection = "LR" | "TB";
export interface LayoutNodeInput {
id: string;
width: number;
height: number;
}
export interface LayoutEdgeInput {
source: string;
target: string;
}
export interface Position {
x: number;
y: number;
}
export interface LayoutOptions {
direction?: LayoutDirection;
nodeSep?: number;
rankSep?: number;
}
/**
* 用 dagre 计算有向图的分层布局,返回每个节点左上角坐标。
* 纯服务,不依赖任何渲染库。
*/
export function computeLayout(
nodes: LayoutNodeInput[],
edges: LayoutEdgeInput[],
options: LayoutOptions = {}
): Map<string, Position> {
const { direction = "LR", nodeSep = 40, rankSep = 90 } = options;
const g = new dagre.graphlib.Graph();
g.setGraph({ rankdir: direction, nodesep: nodeSep, ranksep: rankSep, marginx: 20, marginy: 20 });
g.setDefaultEdgeLabel(() => ({}));
for (const n of nodes) {
g.setNode(n.id, { width: n.width, height: n.height });
}
for (const e of edges) {
if (e.source !== e.target) g.setEdge(e.source, e.target);
}
dagre.layout(g);
const result = new Map<string, Position>();
for (const n of nodes) {
const node = g.node(n.id);
if (!node) {
result.set(n.id, { x: 0, y: 0 });
continue;
}
// dagre 返回中心点,转换为左上角
result.set(n.id, { x: node.x - n.width / 2, y: node.y - n.height / 2 });
}
return result;
}
+547
View File
@@ -0,0 +1,547 @@
* {
box-sizing: border-box;
}
html,
body,
#root {
height: 100%;
margin: 0;
}
body {
font-family: "Segoe UI", "Microsoft YaHei", system-ui, sans-serif;
color: #1e293b;
background: #f1f5f9;
}
.app {
display: flex;
flex-direction: column;
height: 100%;
}
.app__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 18px;
background: #0f172a;
color: #f8fafc;
flex: 0 0 auto;
}
.app__title {
font-size: 15px;
font-weight: 600;
}
.app__report-select {
margin-left: 12px;
background: #1e293b;
color: #e2e8f0;
border: 1px solid #334155;
border-radius: 6px;
padding: 3px 8px;
font-size: 12.5px;
cursor: pointer;
}
.app__stats {
font-size: 12px;
color: #cbd5e1;
flex: 0 0 auto;
}
/* ---------- 搜索栏 ---------- */
.search {
position: relative;
flex: 1 1 auto;
max-width: 420px;
margin: 0 20px;
}
.search__input {
width: 100%;
background: #1e293b;
color: #e2e8f0;
border: 1px solid #334155;
border-radius: 8px;
padding: 6px 12px;
font-size: 13px;
outline: none;
}
.search__input:focus {
border-color: #3b82f6;
}
.search__input::placeholder {
color: #64748b;
}
.search__dropdown {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 10px;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.18);
max-height: 360px;
overflow: auto;
z-index: 100;
padding: 6px;
}
.search__empty {
padding: 10px 12px;
color: #94a3b8;
font-size: 12.5px;
}
.search__item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
text-align: left;
border: none;
background: transparent;
border-radius: 8px;
padding: 7px 10px;
cursor: pointer;
}
.search__item:hover {
background: #f1f5f9;
}
.search__kind {
flex: 0 0 auto;
font-size: 10px;
font-weight: 700;
border-radius: 5px;
padding: 1px 6px;
}
.search__kind--flow {
background: #dbeafe;
color: #1d4ed8;
}
.search__kind--func {
background: #e2e8f0;
color: #475569;
}
.search__label {
flex: 1 1 auto;
font-size: 12.5px;
color: #1e293b;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.search__sub {
flex: 0 0 auto;
font-size: 11px;
color: #94a3b8;
max-width: 45%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.app__body {
flex: 1 1 auto;
display: flex;
min-height: 0;
}
.app__status {
margin: auto;
color: #64748b;
font-size: 14px;
}
.app__status--error {
color: #dc2626;
}
.app__canvas {
flex: 1 1 auto;
min-width: 0;
height: 100%;
}
/* ---------- 侧栏面板 ---------- */
.panel {
display: flex;
flex-direction: column;
background: #fff;
flex: 0 0 auto;
}
.panel--groups {
width: 210px;
border-right: 1px solid #e2e8f0;
}
.panel--left {
width: 280px;
border-right: 1px solid #e2e8f0;
}
.panel--right {
width: 320px;
border-left: 1px solid #e2e8f0;
}
.panel__header {
padding: 10px 14px;
font-size: 13px;
font-weight: 600;
color: #334155;
border-bottom: 1px solid #eef2f6;
}
.panel__body {
flex: 1 1 auto;
overflow: auto;
padding: 8px;
}
/* ---------- 流程列表 ---------- */
.flow-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
text-align: left;
border: 1px solid transparent;
background: transparent;
border-radius: 8px;
padding: 8px 10px;
cursor: pointer;
font-size: 12.5px;
color: #475569;
}
.flow-item:hover {
background: #f1f5f9;
}
.flow-item.is-active {
background: #eff6ff;
border-color: #bfdbfe;
color: #1d4ed8;
}
.flow-item__name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.flow-item__count {
flex: 0 0 auto;
font-size: 11px;
color: #94a3b8;
background: #f1f5f9;
border-radius: 10px;
padding: 1px 8px;
}
/* ---------- 分组列表 ---------- */
.group-item {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
text-align: left;
border: 1px solid transparent;
background: transparent;
border-radius: 8px;
padding: 7px 9px;
cursor: pointer;
font-size: 12.5px;
color: #475569;
}
.group-item:hover {
background: #f1f5f9;
}
.group-item.is-active {
background: #eef2ff;
border-color: #c7d2fe;
color: #4338ca;
}
.group-item--all {
font-weight: 600;
color: #334155;
margin-bottom: 4px;
}
.group-item__tag {
flex: 0 0 auto;
font-size: 10px;
font-weight: 700;
border-radius: 5px;
padding: 1px 5px;
}
.group-item__tag--class {
background: #ddd6fe;
color: #6d28d9;
}
.group-item__tag--file {
background: #e2e8f0;
color: #475569;
}
.group-item__name {
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.group-item__count {
flex: 0 0 auto;
font-size: 11px;
color: #94a3b8;
background: #f1f5f9;
border-radius: 10px;
padding: 1px 8px;
}
/* ---------- 图节点 ---------- */
.flow-node {
width: 220px;
min-height: 68px;
background: #fff;
border: 1.5px solid #cbd5e1;
border-radius: 10px;
padding: 8px 12px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
display: flex;
flex-direction: column;
gap: 4px;
justify-content: center;
}
.flow-node__qualifier {
font-size: 10.5px;
font-weight: 600;
color: #94a3b8;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.flow-node.is-entry {
border-color: #34d399;
background: #ecfdf5;
}
.flow-node.is-selected {
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.25);
}
.flow-node__title {
font-size: 12.5px;
font-weight: 600;
color: #1e293b;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: flex;
align-items: center;
gap: 6px;
}
.flow-node__entry-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #10b981;
flex: 0 0 auto;
}
.flow-node__badges {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.badge {
font-size: 10px;
font-weight: 700;
border-radius: 6px;
padding: 1px 6px;
letter-spacing: 0.3px;
}
.badge-db {
background: #fef3c7;
color: #b45309;
}
.badge-api-in {
background: #dbeafe;
color: #1d4ed8;
}
.badge-api-out {
background: #ede9fe;
color: #6d28d9;
}
/* ---------- 详情面板 ---------- */
.detail-empty,
.detail__muted {
color: #94a3b8;
font-size: 12.5px;
}
.detail__name {
font-size: 15px;
font-weight: 700;
color: #0f172a;
}
.detail__qualified {
font-size: 12px;
color: #64748b;
margin-top: 2px;
word-break: break-all;
}
.detail__section {
margin-top: 14px;
}
.detail__label {
font-size: 11px;
font-weight: 700;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.4px;
margin-bottom: 4px;
}
.detail__mono {
font-family: Consolas, "Courier New", monospace;
font-size: 12px;
color: #334155;
word-break: break-all;
}
.detail__sig {
background: #f8fafc;
border: 1px solid #eef2f6;
border-radius: 6px;
padding: 6px 8px;
}
.detail__ix {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.ix-line {
font-size: 12px;
border-radius: 6px;
padding: 4px 8px;
}
.ix-db {
background: #fffbeb;
color: #92400e;
}
.ix-api {
background: #eff6ff;
color: #1e40af;
}
.detail__flows {
display: flex;
flex-direction: column;
gap: 4px;
}
.detail-flow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
text-align: left;
border: 1px solid #e2e8f0;
background: #fff;
border-radius: 6px;
padding: 5px 8px;
cursor: pointer;
font-size: 12px;
color: #475569;
}
.detail-flow:hover {
background: #f8fafc;
border-color: #cbd5e1;
}
.detail-flow.is-active {
background: #eff6ff;
border-color: #bfdbfe;
color: #1d4ed8;
}
.detail-flow__name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.detail-flow__count {
flex: 0 0 auto;
font-size: 11px;
color: #94a3b8;
background: #f1f5f9;
border-radius: 10px;
padding: 1px 8px;
}
.detail__meta {
display: flex;
gap: 10px;
align-items: center;
font-size: 11px;
color: #94a3b8;
}
.detail__entry {
background: #d1fae5;
color: #047857;
border-radius: 6px;
padding: 1px 8px;
font-weight: 700;
}
.graph-empty {
margin: auto;
color: #94a3b8;
}
+54
View File
@@ -0,0 +1,54 @@
import { observer } from "mobx-react-lite";
import { useGraphVM } from "./vmContext";
import { FlowGraph } from "./components/FlowGraph";
import { FlowList } from "./components/FlowList";
import { GroupList } from "./components/GroupList";
import { NodeDetailPanel } from "./components/NodeDetailPanel";
import { SearchBar } from "./components/SearchBar";
export const App = observer(function App() {
const vm = useGraphVM();
return (
<div className="app">
<header className="app__header">
<div className="app__title">
<select
className="app__report-select"
value={vm.selectedSourceId ?? ""}
onChange={(e) => vm.selectReport(e.target.value)}
>
{vm.sources.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
</div>
{vm.report ? <SearchBar /> : null}
{vm.report ? (
<div className="app__stats">
{vm.report.stats.fileCount} · {vm.report.stats.functionCount} · {" "}
{vm.report.stats.edgeCount} · {vm.report.flows.length}
</div>
) : null}
</header>
<div className="app__body">
{vm.loading ? <div className="app__status"></div> : null}
{vm.error ? <div className="app__status app__status--error">: {vm.error}</div> : null}
{!vm.loading && !vm.error && vm.report ? (
<>
<GroupList />
<FlowList />
<main className="app__canvas">
<FlowGraph />
</main>
<NodeDetailPanel />
</>
) : null}
</div>
</div>
);
});
+72
View File
@@ -0,0 +1,72 @@
import { useMemo } from "react";
import { observer } from "mobx-react-lite";
import {
ReactFlow,
Background,
Controls,
MiniMap,
MarkerType,
type Node,
type Edge,
} from "@xyflow/react";
import { useGraphVM } from "../vmContext";
import { FlowNode, type FlowNodeData } from "./FlowNode";
const nodeTypes = { flowNode: FlowNode };
export const FlowGraph = observer(function FlowGraph() {
const vm = useGraphVM();
const { nodes, edges } = vm.positionedGraph;
const selectedNodeId = vm.selectedNodeId;
const rfNodes: Node<FlowNodeData>[] = useMemo(
() =>
nodes.map((n) => ({
id: n.id,
type: "flowNode",
position: { x: n.x, y: n.y },
selected: n.id === selectedNodeId,
data: { label: n.label, qualifier: n.qualifier, isEntry: n.isEntry, interactions: n.interactions },
})),
[nodes, selectedNodeId]
);
const rfEdges: Edge[] = useMemo(
() =>
edges.map((e) => ({
id: e.id,
source: e.source,
target: e.target,
markerEnd: { type: MarkerType.ArrowClosed, width: 18, height: 18 },
style: { stroke: "#94a3b8", strokeWidth: 1.5 },
})),
[edges]
);
if (nodes.length === 0) {
return <div className="graph-empty"></div>;
}
return (
<ReactFlow
key={vm.selectedFlowId ?? "none"}
nodes={rfNodes}
edges={rfEdges}
nodeTypes={nodeTypes}
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable
fitView
fitViewOptions={{ padding: 0.2 }}
minZoom={0.1}
maxZoom={2}
proOptions={{ hideAttribution: true }}
onNodeClick={(_, node) => vm.selectNode(node.id)}
onPaneClick={() => vm.selectNode(null)}
>
<Background gap={20} color="#e2e8f0" />
<Controls showInteractive={false} />
<MiniMap pannable zoomable nodeStrokeWidth={2} />
</ReactFlow>
);
});
+25
View File
@@ -0,0 +1,25 @@
import { observer } from "mobx-react-lite";
import { useGraphVM } from "../vmContext";
export const FlowList = observer(function FlowList() {
const vm = useGraphVM();
return (
<aside className="panel panel--left">
<div className="panel__header"> ({vm.visibleFlows.length})</div>
<div className="panel__body">
{vm.visibleFlows.map((flow) => (
<button
key={flow.id}
className={`flow-item${flow.id === vm.selectedFlowId ? " is-active" : ""}`}
onClick={() => vm.selectFlow(flow.id)}
title={flow.name}
>
<span className="flow-item__name">{flow.name}</span>
<span className="flow-item__count">{flow.steps.length}</span>
</button>
))}
</div>
</aside>
);
});
+66
View File
@@ -0,0 +1,66 @@
import { memo } from "react";
import { Handle, Position, type NodeProps } from "@xyflow/react";
import type { Interaction } from "../../model/report";
export interface FlowNodeData extends Record<string, unknown> {
label: string;
qualifier?: string;
isEntry?: boolean;
interactions?: Interaction[];
}
function interactionBadges(interactions?: Interaction[]): { key: string; text: string; cls: string }[] {
if (!interactions?.length) return [];
const badges: { key: string; text: string; cls: string }[] = [];
const seen = new Set<string>();
for (const ix of interactions) {
let key: string;
let text: string;
let cls: string;
if (ix.kind === "db") {
key = "db";
text = "DB";
cls = "badge-db";
} else if (ix.direction === "in") {
key = "api-in";
text = "API▸";
cls = "badge-api-in";
} else {
key = "api-out";
text = "▸API";
cls = "badge-api-out";
}
if (seen.has(key)) continue;
seen.add(key);
badges.push({ key, text, cls });
}
return badges;
}
export const FlowNode = memo(function FlowNode({ data, selected }: NodeProps & { data: FlowNodeData }) {
const badges = interactionBadges(data.interactions);
return (
<div className={`flow-node${selected ? " is-selected" : ""}${data.isEntry ? " is-entry" : ""}`}>
<Handle type="target" position={Position.Left} />
{data.qualifier ? (
<div className="flow-node__qualifier" title={data.qualifier}>
{data.qualifier}
</div>
) : null}
<div className="flow-node__title" title={data.label}>
{data.isEntry ? <span className="flow-node__entry-dot" /> : null}
{data.label}
</div>
{badges.length > 0 ? (
<div className="flow-node__badges">
{badges.map((b) => (
<span key={b.key} className={`badge ${b.cls}`}>
{b.text}
</span>
))}
</div>
) : null}
<Handle type="source" position={Position.Right} />
</div>
);
});
+36
View File
@@ -0,0 +1,36 @@
import { observer } from "mobx-react-lite";
import { useGraphVM } from "../vmContext";
export const GroupList = observer(function GroupList() {
const vm = useGraphVM();
return (
<aside className="panel panel--groups">
<div className="panel__header"> ({vm.groups.length})</div>
<div className="panel__body">
<button
className={`group-item group-item--all${vm.selectedGroupId === null ? " is-active" : ""}`}
onClick={() => vm.selectGroup(null)}
>
<span className="group-item__name"></span>
<span className="group-item__count">{vm.flows.length}</span>
</button>
{vm.groups.map((g) => (
<button
key={g.id}
className={`group-item${g.id === vm.selectedGroupId ? " is-active" : ""}`}
onClick={() => vm.selectGroup(g.id)}
title={g.name}
>
<span className={`group-item__tag group-item__tag--${g.kind}`}>
{g.kind === "class" ? "类" : "文件"}
</span>
<span className="group-item__name">{g.name}</span>
<span className="group-item__count">{g.count}</span>
</button>
))}
</div>
</aside>
);
});
@@ -0,0 +1,85 @@
import { observer } from "mobx-react-lite";
import { useGraphVM } from "../vmContext";
import type { Interaction } from "../../model/report";
function describeInteraction(ix: Interaction): string {
if (ix.kind === "db") {
return `DB · ${ix.operation}${ix.target ? `${ix.target}` : ""}${ix.engine ? ` (${ix.engine})` : ""}`;
}
const dir = ix.direction === "in" ? "接收" : "发起";
return `API ${dir} · ${ix.method ?? ""} ${ix.path ?? ""}${ix.peer ? ` @${ix.peer}` : ""}`;
}
export const NodeDetailPanel = observer(function NodeDetailPanel() {
const vm = useGraphVM();
const fn = vm.selectedFunc;
return (
<aside className="panel panel--right">
<div className="panel__header"></div>
<div className="panel__body">
{!fn ? (
<div className="detail-empty"></div>
) : (
<div className="detail">
<div className="detail__name">{fn.name}</div>
{fn.qualifiedName && fn.qualifiedName !== fn.name ? (
<div className="detail__qualified">{fn.qualifiedName}</div>
) : null}
<div className="detail__section">
<div className="detail__label"></div>
<div className="detail__mono">
{fn.location ? `${fn.location.file}:${fn.location.startLine}` : "—"}
</div>
</div>
{fn.signature ? (
<div className="detail__section">
<div className="detail__label"></div>
<div className="detail__mono detail__sig">{fn.signature}</div>
</div>
) : null}
<div className="detail__section">
<div className="detail__label"> ({fn.interactions?.length ?? 0})</div>
{fn.interactions?.length ? (
<ul className="detail__ix">
{fn.interactions.map((ix) => (
<li key={ix.id} className={`ix-line ix-${ix.kind}`}>
{describeInteraction(ix)}
</li>
))}
</ul>
) : (
<div className="detail__muted"></div>
)}
</div>
<div className="detail__section">
<div className="detail__label"> ({vm.flowsForSelectedFunc.length})</div>
<div className="detail__flows">
{vm.flowsForSelectedFunc.map((flow) => (
<button
key={flow.id}
className={`detail-flow${flow.id === vm.selectedFlowId ? " is-active" : ""}`}
onClick={() => vm.openFlowForFunc(flow.id, fn.id)}
title={flow.name}
>
<span className="detail-flow__name">{flow.name}</span>
<span className="detail-flow__count">{flow.steps.length}</span>
</button>
))}
</div>
</div>
<div className="detail__section detail__meta">
<span>: {fn.provenance}</span>
{fn.isEntry ? <span className="detail__entry"></span> : null}
</div>
</div>
)}
</div>
</aside>
);
});
+59
View File
@@ -0,0 +1,59 @@
import { useState } from "react";
import { observer } from "mobx-react-lite";
import { useGraphVM } from "../vmContext";
import type { SearchResult } from "../../viewmodel/GraphViewModel";
export const SearchBar = observer(function SearchBar() {
const vm = useGraphVM();
const [focused, setFocused] = useState(false);
const results = vm.searchResults;
const open = focused && vm.searchQuery.trim().length > 0;
const activate = (r: SearchResult): void => {
vm.activateSearchResult(r);
setFocused(false);
};
return (
<div className="search">
<input
className="search__input"
placeholder="搜索流程 / 函数…"
value={vm.searchQuery}
onChange={(e) => vm.setSearchQuery(e.target.value)}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
onKeyDown={(e) => {
if (e.key === "Enter" && results[0]) activate(results[0]);
if (e.key === "Escape") vm.setSearchQuery("");
}}
/>
{open ? (
<div className="search__dropdown">
{results.length === 0 ? (
<div className="search__empty"></div>
) : (
results.map((r) => (
<button
key={`${r.kind}:${r.id}`}
className="search__item"
// onMouseDown 先于 input 的 blur 触发,保证点击生效
onMouseDown={(e) => {
e.preventDefault();
activate(r);
}}
title={r.label}
>
<span className={`search__kind search__kind--${r.kind}`}>
{r.kind === "flow" ? "流程" : "函数"}
</span>
<span className="search__label">{r.label}</span>
<span className="search__sub">{r.sublabel}</span>
</button>
))
)}
</div>
) : null}
</div>
);
});
+10
View File
@@ -0,0 +1,10 @@
import { createContext, useContext } from "react";
import type { GraphViewModel } from "../viewmodel/GraphViewModel";
export const VMContext = createContext<GraphViewModel | null>(null);
export function useGraphVM(): GraphViewModel {
const vm = useContext(VMContext);
if (!vm) throw new Error("GraphViewModel 未在 VMContext 中提供");
return vm;
}
+289
View File
@@ -0,0 +1,289 @@
import { makeAutoObservable, runInAction } from "mobx";
import type { Report, Flow, Func, Container, CodeFile } from "../model/report";
import type { ReportRepository, ReportSource } from "../model/reportRepository";
import { flowToGraph, type GraphEdge, type GraphNode } from "../model/graphModel";
import { computeLayout, type Position } from "../services/dagreLayout";
export const NODE_WIDTH = 220;
export const NODE_HEIGHT = 68;
export interface PositionedNode extends GraphNode, Position {
width: number;
height: number;
}
export interface PositionedGraph {
nodes: PositionedNode[];
edges: GraphEdge[];
}
export interface FlowGroup {
id: string;
name: string;
kind: "class" | "file";
count: number;
}
export interface SearchResult {
id: string;
kind: "flow" | "func";
label: string;
sublabel: string;
}
function baseName(path: string): string {
const parts = path.split("/");
return parts[parts.length - 1] || path;
}
/**
* 图视图的 ViewModel:编排数据加载、流程选择、布局计算与选中状态。
* 不依赖任何 React / 渲染库,仅暴露可观察状态与命令。
*/
export class GraphViewModel {
loading = false;
error: string | null = null;
report: Report | null = null;
selectedSourceId: string | null = null;
selectedGroupId: string | null = null; // null = 全部
selectedFlowId: string | null = null;
selectedNodeId: string | null = null;
searchQuery = "";
private funcById = new Map<string, Func>();
private containerById = new Map<string, Container>();
private fileById = new Map<string, CodeFile>();
constructor(
private readonly sourceList: ReportSource[],
private readonly makeRepository: (url: string) => ReportRepository
) {
this.selectedSourceId = sourceList[0]?.id ?? null;
makeAutoObservable(this, {}, { autoBind: true });
}
get sources(): ReportSource[] {
return this.sourceList;
}
get currentSource(): ReportSource | null {
return this.sourceList.find((s) => s.id === this.selectedSourceId) ?? null;
}
selectReport(sourceId: string): void {
if (sourceId === this.selectedSourceId) return;
this.selectedSourceId = sourceId;
this.report = null;
this.selectedGroupId = null;
this.selectedFlowId = null;
this.selectedNodeId = null;
this.searchQuery = "";
void this.load();
}
/** 计算一条流程入口所属的分组(方法→类,自由函数→文件) */
private groupOfFlow(flow: Flow): FlowGroup {
const entry = flow.entryNodeId ? this.funcById.get(flow.entryNodeId) : undefined;
if (entry?.ownerId) {
const container = this.containerById.get(entry.ownerId);
return { id: `class:${entry.ownerId}`, name: container?.name ?? "未知类", kind: "class", count: 0 };
}
const file = entry ? this.fileById.get(entry.fileId) : undefined;
if (file) {
return { id: `file:${file.id}`, name: baseName(file.path), kind: "file", count: 0 };
}
return { id: "ungrouped", name: "未归类", kind: "file", count: 0 };
}
/** 分组列表:按包含流程数从多到少排序 */
get groups(): FlowGroup[] {
const map = new Map<string, FlowGroup>();
for (const flow of this.flows) {
const g = this.groupOfFlow(flow);
const existing = map.get(g.id);
if (existing) existing.count += 1;
else map.set(g.id, { ...g, count: 1 });
}
return [...map.values()].sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
}
/** 当前分组过滤后的流程列表 */
get visibleFlows(): Flow[] {
if (!this.selectedGroupId) return this.flows;
return this.flows.filter((f) => this.groupOfFlow(f).id === this.selectedGroupId);
}
selectGroup(groupId: string | null): void {
this.selectedGroupId = groupId;
const visible = this.visibleFlows;
if (!visible.some((f) => f.id === this.selectedFlowId)) {
this.selectedFlowId = visible[0]?.id ?? null;
this.selectedNodeId = null;
}
}
async load(): Promise<void> {
const source = this.currentSource;
if (!source) return;
this.loading = true;
this.error = null;
try {
const report = await this.makeRepository(source.url).load();
const funcIndex = new Map<string, Func>();
for (const fn of report.functions) funcIndex.set(fn.id, fn);
const containerIndex = new Map<string, Container>();
for (const c of report.containers) containerIndex.set(c.id, c);
const fileIndex = new Map<string, CodeFile>();
for (const f of report.files) fileIndex.set(f.id, f);
runInAction(() => {
this.report = report;
this.funcById = funcIndex;
this.containerById = containerIndex;
this.fileById = fileIndex;
this.selectedGroupId = null;
this.selectedFlowId = this.flows[0]?.id ?? null;
this.selectedNodeId = null;
});
} catch (e) {
runInAction(() => {
this.error = e instanceof Error ? e.message : String(e);
});
} finally {
runInAction(() => {
this.loading = false;
});
}
}
/** 流程列表,按步骤数从多到少排序,信息量大的排前面 */
get flows(): Flow[] {
if (!this.report) return [];
return [...this.report.flows].sort((a, b) => b.steps.length - a.steps.length);
}
get currentFlow(): Flow | null {
if (!this.report || !this.selectedFlowId) return null;
return this.report.flows.find((f) => f.id === this.selectedFlowId) ?? null;
}
/** 计算好坐标的中性图,供 View 适配到具体渲染库 */
get positionedGraph(): PositionedGraph {
const flow = this.currentFlow;
if (!flow) return { nodes: [], edges: [] };
const graph = flowToGraph(flow, {
funcById: this.funcById,
containerById: this.containerById,
fileById: this.fileById,
});
const positions = computeLayout(
graph.nodes.map((n) => ({ id: n.id, width: NODE_WIDTH, height: NODE_HEIGHT })),
graph.edges.map((e) => ({ source: e.source, target: e.target })),
{ direction: "LR" }
);
const nodes: PositionedNode[] = graph.nodes.map((n) => {
const pos = positions.get(n.id) ?? { x: 0, y: 0 };
return { ...n, x: pos.x, y: pos.y, width: NODE_WIDTH, height: NODE_HEIGHT };
});
return { nodes, edges: graph.edges };
}
get selectedFunc(): Func | null {
if (!this.selectedNodeId) return null;
const node = this.positionedGraph.nodes.find((n) => n.id === this.selectedNodeId);
if (!node?.funcId) return null;
return this.funcById.get(node.funcId) ?? null;
}
/** 当前选中函数参与(出现在其中)的所有流程 */
get flowsForSelectedFunc(): Flow[] {
const fn = this.selectedFunc;
if (!fn || !this.report) return [];
return this.report.flows
.filter((flow) => flow.steps.some((s) => s.nodeId === fn.id))
.sort((a, b) => b.steps.length - a.steps.length);
}
/** 跳转到指定流程,并定位到其中承载该函数的节点 */
openFlowForFunc(flowId: string, funcId: string): void {
this.selectedGroupId = null; // 清除分组过滤,确保目标流程在列表中可见
this.selectedFlowId = flowId;
const flow = this.report?.flows.find((f) => f.id === flowId);
const step = flow?.steps.find((s) => s.nodeId === funcId);
this.selectedNodeId = step?.id ?? null;
}
// ---------- 搜索 ----------
setSearchQuery(q: string): void {
this.searchQuery = q;
}
private get funcIdsInFlows(): Set<string> {
const set = new Set<string>();
for (const flow of this.report?.flows ?? []) {
for (const step of flow.steps) if (step.nodeId) set.add(step.nodeId);
}
return set;
}
private biggestFlowForFunc(funcId: string): Flow | null {
const matched = (this.report?.flows ?? [])
.filter((f) => f.steps.some((s) => s.nodeId === funcId))
.sort((a, b) => b.steps.length - a.steps.length);
return matched[0] ?? null;
}
get searchResults(): SearchResult[] {
const q = this.searchQuery.trim().toLowerCase();
if (!this.report || q.length < 1) return [];
const results: SearchResult[] = [];
for (const flow of this.flows) {
if (flow.name.toLowerCase().includes(q)) {
results.push({ id: flow.id, kind: "flow", label: flow.name, sublabel: `流程 · ${flow.steps.length}` });
}
if (results.length >= 20) break;
}
const inFlows = this.funcIdsInFlows;
for (const fn of this.report.functions) {
if (results.length >= 40) break;
if (!inFlows.has(fn.id)) continue; // 只搜可在图上定位的函数
const name = (fn.qualifiedName ?? fn.name).toLowerCase();
if (!name.includes(q)) continue;
results.push({
id: fn.id,
kind: "func",
label: fn.qualifiedName ?? fn.name,
sublabel: fn.location?.file ?? "",
});
}
return results.slice(0, 40);
}
activateSearchResult(result: SearchResult): void {
if (result.kind === "flow") {
this.selectedGroupId = null;
this.selectedFlowId = result.id;
this.selectedNodeId = null;
} else {
const flow = this.biggestFlowForFunc(result.id);
if (flow) this.openFlowForFunc(flow.id, result.id);
}
this.searchQuery = "";
}
selectFlow(flowId: string): void {
if (this.selectedFlowId === flowId) return;
this.selectedFlowId = flowId;
this.selectedNodeId = null;
}
selectNode(nodeId: string | null): void {
this.selectedNodeId = nodeId;
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noEmit": true,
"experimentalDecorators": false,
"types": ["node"]
},
"include": ["src", "vite.config.ts"]
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5300,
open: true,
},
});