blast-radius-analyzer 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +108 -0
- package/TEST-REPORT.md +379 -0
- package/dist/core/AnalysisCache.d.ts +59 -0
- package/dist/core/AnalysisCache.js +156 -0
- package/dist/core/BlastRadiusAnalyzer.d.ts +99 -0
- package/dist/core/BlastRadiusAnalyzer.js +510 -0
- package/dist/core/CallStackBuilder.d.ts +63 -0
- package/dist/core/CallStackBuilder.js +269 -0
- package/dist/core/DataFlowAnalyzer.d.ts +215 -0
- package/dist/core/DataFlowAnalyzer.js +1115 -0
- package/dist/core/DependencyGraph.d.ts +55 -0
- package/dist/core/DependencyGraph.js +541 -0
- package/dist/core/ImpactTracer.d.ts +96 -0
- package/dist/core/ImpactTracer.js +398 -0
- package/dist/core/PropagationTracker.d.ts +73 -0
- package/dist/core/PropagationTracker.js +502 -0
- package/dist/core/PropertyAccessTracker.d.ts +56 -0
- package/dist/core/PropertyAccessTracker.js +281 -0
- package/dist/core/SymbolAnalyzer.d.ts +139 -0
- package/dist/core/SymbolAnalyzer.js +608 -0
- package/dist/core/TypeFlowAnalyzer.d.ts +120 -0
- package/dist/core/TypeFlowAnalyzer.js +654 -0
- package/dist/core/TypePropagationAnalyzer.d.ts +58 -0
- package/dist/core/TypePropagationAnalyzer.js +269 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +952 -0
- package/dist/types.d.ts +102 -0
- package/dist/types.js +5 -0
- package/package.json +39 -0
- package/src/core/AnalysisCache.ts +189 -0
- package/src/core/CallStackBuilder.ts +345 -0
- package/src/core/DataFlowAnalyzer.ts +1403 -0
- package/src/core/DependencyGraph.ts +584 -0
- package/src/core/ImpactTracer.ts +521 -0
- package/src/core/PropagationTracker.ts +630 -0
- package/src/core/PropertyAccessTracker.ts +349 -0
- package/src/core/SymbolAnalyzer.ts +746 -0
- package/src/core/TypeFlowAnalyzer.ts +844 -0
- package/src/core/TypePropagationAnalyzer.ts +332 -0
- package/src/index.ts +1071 -0
- package/src/types.ts +163 -0
- package/test-cases/.blast-radius-cache/file-states.json +14 -0
- package/test-cases/config.ts +13 -0
- package/test-cases/consumer.ts +12 -0
- package/test-cases/nested.ts +25 -0
- package/test-cases/simple.ts +62 -0
- package/test-cases/tsconfig.json +11 -0
- package/test-cases/user.ts +32 -0
- package/tsconfig.json +16 -0
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Blast Radius Analyzer - Type Definitions
|
|
3
|
+
* 分析代码改动影响范围的核心类型
|
|
4
|
+
*/
|
|
5
|
+
export interface CodeChange {
|
|
6
|
+
file: string;
|
|
7
|
+
type: ChangeType;
|
|
8
|
+
symbol?: string;
|
|
9
|
+
line?: number;
|
|
10
|
+
diff?: string;
|
|
11
|
+
}
|
|
12
|
+
export type ChangeType = 'add' | 'delete' | 'modify' | 'rename';
|
|
13
|
+
export interface DependencyNode {
|
|
14
|
+
id: string;
|
|
15
|
+
kind: NodeKind;
|
|
16
|
+
file?: string;
|
|
17
|
+
line?: number;
|
|
18
|
+
exports?: string[];
|
|
19
|
+
imports?: ImportInfo[];
|
|
20
|
+
dependents: string[];
|
|
21
|
+
depth: number;
|
|
22
|
+
}
|
|
23
|
+
export type NodeKind = 'file' | 'function' | 'class' | 'interface' | 'type' | 'variable' | 'export';
|
|
24
|
+
export interface ImportInfo {
|
|
25
|
+
source: string;
|
|
26
|
+
imported: string[];
|
|
27
|
+
isReExport: boolean;
|
|
28
|
+
}
|
|
29
|
+
export interface DependencyGraph {
|
|
30
|
+
nodes: Map<string, DependencyNode>;
|
|
31
|
+
edges: Edge[];
|
|
32
|
+
entryPoints: string[];
|
|
33
|
+
}
|
|
34
|
+
export interface Edge {
|
|
35
|
+
from: string;
|
|
36
|
+
to: string;
|
|
37
|
+
type: EdgeType;
|
|
38
|
+
symbol?: string;
|
|
39
|
+
}
|
|
40
|
+
export type EdgeType = 'import' | 'extend' | 'implement' | 'type-ref' | 'call' | 'property-access' | 'param-type';
|
|
41
|
+
export interface ImpactScope {
|
|
42
|
+
changedFile: string;
|
|
43
|
+
timestamp: string;
|
|
44
|
+
stats: ImpactStats;
|
|
45
|
+
levels: ImpactLevel[];
|
|
46
|
+
affectedFiles: AffectedFile[];
|
|
47
|
+
propagationPaths: PropagationPath[];
|
|
48
|
+
highRiskImpacts: HighRiskImpact[];
|
|
49
|
+
recommendations: string[];
|
|
50
|
+
}
|
|
51
|
+
export interface ImpactStats {
|
|
52
|
+
totalAffectedFiles: number;
|
|
53
|
+
directDependencies: number;
|
|
54
|
+
transitiveDependencies: number;
|
|
55
|
+
filesWithBreakingChanges: number;
|
|
56
|
+
criticalFiles: number;
|
|
57
|
+
estimatedRippleDepth: number;
|
|
58
|
+
}
|
|
59
|
+
export interface ImpactLevel {
|
|
60
|
+
depth: number;
|
|
61
|
+
description: string;
|
|
62
|
+
files: string[];
|
|
63
|
+
nodeCount: number;
|
|
64
|
+
}
|
|
65
|
+
export interface AffectedFile {
|
|
66
|
+
file: string;
|
|
67
|
+
kind: NodeKind;
|
|
68
|
+
changeType: ChangeType;
|
|
69
|
+
impactLevel: number;
|
|
70
|
+
impactFactors: ImpactFactor[];
|
|
71
|
+
line?: number;
|
|
72
|
+
}
|
|
73
|
+
export interface ImpactFactor {
|
|
74
|
+
factor: string;
|
|
75
|
+
weight: number;
|
|
76
|
+
reason: string;
|
|
77
|
+
}
|
|
78
|
+
export interface PropagationPath {
|
|
79
|
+
from: string;
|
|
80
|
+
to: string;
|
|
81
|
+
path: string[];
|
|
82
|
+
edgeTypes: EdgeType[];
|
|
83
|
+
riskLevel: RiskLevel;
|
|
84
|
+
}
|
|
85
|
+
export type RiskLevel = 'low' | 'medium' | 'high' | 'critical';
|
|
86
|
+
export interface HighRiskImpact {
|
|
87
|
+
file: string;
|
|
88
|
+
reason: string;
|
|
89
|
+
riskLevel: RiskLevel;
|
|
90
|
+
mitigation?: string;
|
|
91
|
+
}
|
|
92
|
+
export interface AnalyzerConfig {
|
|
93
|
+
projectRoot: string;
|
|
94
|
+
tsConfigPath?: string;
|
|
95
|
+
maxDepth: number;
|
|
96
|
+
includeNodeModules: boolean;
|
|
97
|
+
includeTests: boolean;
|
|
98
|
+
criticalPatterns: string[];
|
|
99
|
+
ignorePatterns: string[];
|
|
100
|
+
verbose: boolean;
|
|
101
|
+
outputFormat: 'json' | 'text' | 'html' | 'graph';
|
|
102
|
+
}
|
package/dist/types.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "blast-radius-analyzer",
|
|
3
|
+
"version": "1.2.1",
|
|
4
|
+
"description": "Analyze code change impact and blast radius - 改动影响范围分析器",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"blast-radius": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsc",
|
|
12
|
+
"dev": "tsc --watch",
|
|
13
|
+
"analyze": "node dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"blast-radius",
|
|
17
|
+
"impact-analysis",
|
|
18
|
+
"code-analysis",
|
|
19
|
+
"dependency-tracking",
|
|
20
|
+
"data-flow",
|
|
21
|
+
"typescript"
|
|
22
|
+
],
|
|
23
|
+
"author": "",
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": ""
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"ts-morph": "^25.0.0"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@types/node": "^20.0.0",
|
|
34
|
+
"typescript": "^5.4.0"
|
|
35
|
+
},
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=18.0.0"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AnalysisCache - 增量分析缓存
|
|
3
|
+
*
|
|
4
|
+
* 缓存符号分析结果,检测文件变更,只重新分析受影响的部分
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as fs from 'fs';
|
|
8
|
+
import * as path from 'path';
|
|
9
|
+
import type { ReferenceInfo, SymbolInfo } from './SymbolAnalyzer.js';
|
|
10
|
+
|
|
11
|
+
export interface CacheEntry {
|
|
12
|
+
symbolName: string;
|
|
13
|
+
file: string;
|
|
14
|
+
timestamp: number;
|
|
15
|
+
fileHash: string;
|
|
16
|
+
references: ReferenceInfo[];
|
|
17
|
+
symbolInfo: SymbolInfo | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface FileState {
|
|
21
|
+
mtime: number;
|
|
22
|
+
hash: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class AnalysisCache {
|
|
26
|
+
private cacheDir: string;
|
|
27
|
+
private fileStates: Map<string, FileState> = new Map();
|
|
28
|
+
private memoryCache: Map<string, CacheEntry> = new Map();
|
|
29
|
+
|
|
30
|
+
constructor(projectRoot: string) {
|
|
31
|
+
this.cacheDir = path.join(projectRoot, '.blast-radius-cache');
|
|
32
|
+
this.ensureCacheDir();
|
|
33
|
+
this.loadFileStates();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
private ensureCacheDir(): void {
|
|
37
|
+
if (!fs.existsSync(this.cacheDir)) {
|
|
38
|
+
fs.mkdirSync(this.cacheDir, { recursive: true });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private loadFileStates(): void {
|
|
43
|
+
const stateFile = path.join(this.cacheDir, 'file-states.json');
|
|
44
|
+
if (fs.existsSync(stateFile)) {
|
|
45
|
+
try {
|
|
46
|
+
const data = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
|
|
47
|
+
for (const [key, value] of Object.entries(data)) {
|
|
48
|
+
this.fileStates.set(key, value as FileState);
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
// 忽略解析错误
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private saveFileStates(): void {
|
|
57
|
+
const stateFile = path.join(this.cacheDir, 'file-states.json');
|
|
58
|
+
const data: Record<string, FileState> = {};
|
|
59
|
+
for (const [key, value] of this.fileStates) {
|
|
60
|
+
data[key] = value;
|
|
61
|
+
}
|
|
62
|
+
fs.writeFileSync(stateFile, JSON.stringify(data, null, 2), 'utf-8');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private computeFileHash(filePath: string): string {
|
|
66
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
67
|
+
// 简单的哈希计算
|
|
68
|
+
let hash = 0;
|
|
69
|
+
for (let i = 0; i < content.length; i++) {
|
|
70
|
+
const char = content.charCodeAt(i);
|
|
71
|
+
hash = ((hash << 5) - hash) + char;
|
|
72
|
+
hash = hash & hash;
|
|
73
|
+
}
|
|
74
|
+
return Math.abs(hash).toString(16);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 检测文件是否有变更
|
|
79
|
+
*/
|
|
80
|
+
hasFileChanged(filePath: string): boolean {
|
|
81
|
+
if (!fs.existsSync(filePath)) {
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const stat = fs.statSync(filePath);
|
|
86
|
+
const currentMtime = stat.mtimeMs;
|
|
87
|
+
const currentHash = this.computeFileHash(filePath);
|
|
88
|
+
|
|
89
|
+
const saved = this.fileStates.get(filePath);
|
|
90
|
+
if (!saved) {
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return saved.hash !== currentHash || saved.mtime !== currentMtime;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 获取所有变更的文件
|
|
99
|
+
*/
|
|
100
|
+
getChangedFiles(filePaths: string[]): string[] {
|
|
101
|
+
return filePaths.filter(f => this.hasFileChanged(f));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 更新文件状态
|
|
106
|
+
*/
|
|
107
|
+
updateFileState(filePath: string): void {
|
|
108
|
+
if (!fs.existsSync(filePath)) {
|
|
109
|
+
this.fileStates.delete(filePath);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const stat = fs.statSync(filePath);
|
|
114
|
+
this.fileStates.set(filePath, {
|
|
115
|
+
mtime: stat.mtimeMs,
|
|
116
|
+
hash: this.computeFileHash(filePath),
|
|
117
|
+
});
|
|
118
|
+
this.saveFileStates();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* 获取缓存的引用结果
|
|
123
|
+
*/
|
|
124
|
+
getCachedReferences(symbolName: string, file: string): CacheEntry | null {
|
|
125
|
+
const key = `${file}:${symbolName}`;
|
|
126
|
+
return this.memoryCache.get(key) || null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* 缓存引用结果
|
|
131
|
+
*/
|
|
132
|
+
cacheReferences(symbolName: string, file: string, symbolInfo: SymbolInfo | null, references: ReferenceInfo[]): void {
|
|
133
|
+
const key = `${file}:${symbolName}`;
|
|
134
|
+
this.memoryCache.set(key, {
|
|
135
|
+
symbolName,
|
|
136
|
+
file,
|
|
137
|
+
timestamp: Date.now(),
|
|
138
|
+
fileHash: this.computeFileHash(file),
|
|
139
|
+
references,
|
|
140
|
+
symbolInfo,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// 同时持久化到磁盘
|
|
144
|
+
const cacheFile = path.join(this.cacheDir, `${Buffer.from(key).toString('base64url')}.json`);
|
|
145
|
+
try {
|
|
146
|
+
fs.writeFileSync(cacheFile, JSON.stringify({
|
|
147
|
+
symbolName,
|
|
148
|
+
file,
|
|
149
|
+
timestamp: Date.now(),
|
|
150
|
+
references,
|
|
151
|
+
symbolInfo: symbolInfo ? {
|
|
152
|
+
name: symbolInfo.name,
|
|
153
|
+
kind: symbolInfo.kind,
|
|
154
|
+
file: symbolInfo.file,
|
|
155
|
+
line: symbolInfo.line,
|
|
156
|
+
} : null,
|
|
157
|
+
}, null, 2), 'utf-8');
|
|
158
|
+
} catch {
|
|
159
|
+
// 忽略缓存写入错误
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* 清除缓存
|
|
165
|
+
*/
|
|
166
|
+
clear(): void {
|
|
167
|
+
this.memoryCache.clear();
|
|
168
|
+
this.fileStates.clear();
|
|
169
|
+
this.saveFileStates();
|
|
170
|
+
|
|
171
|
+
// 清除缓存文件
|
|
172
|
+
const files = fs.readdirSync(this.cacheDir);
|
|
173
|
+
for (const file of files) {
|
|
174
|
+
if (file.endsWith('.json') && file !== 'file-states.json') {
|
|
175
|
+
fs.unlinkSync(path.join(this.cacheDir, file));
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* 获取缓存统计
|
|
182
|
+
*/
|
|
183
|
+
getStats(): { entries: number; files: number } {
|
|
184
|
+
return {
|
|
185
|
+
entries: this.memoryCache.size,
|
|
186
|
+
files: this.fileStates.size,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
}
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CallStackBuilder - 调用栈视图构建器
|
|
3
|
+
*
|
|
4
|
+
* 从改动点向上追踪,构建完整的调用链视图
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Project, Node, SyntaxKind, SourceFile } from 'ts-morph';
|
|
8
|
+
import * as path from 'path';
|
|
9
|
+
|
|
10
|
+
export interface CallStackNode {
|
|
11
|
+
name: string;
|
|
12
|
+
file: string;
|
|
13
|
+
line: number;
|
|
14
|
+
type: 'function' | 'arrow' | 'method' | 'component';
|
|
15
|
+
children: CallStackNode[];
|
|
16
|
+
/** 调用此函数的代码(父节点的调用点) */
|
|
17
|
+
callSite?: {
|
|
18
|
+
line: number;
|
|
19
|
+
expression: string;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface CallStackTree {
|
|
24
|
+
root: CallStackNode;
|
|
25
|
+
depth: number;
|
|
26
|
+
path: string[]; // 路径字符串
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class CallStackBuilder {
|
|
30
|
+
private project: Project;
|
|
31
|
+
private projectRoot: string;
|
|
32
|
+
|
|
33
|
+
constructor(projectRoot: string, tsConfigPath: string) {
|
|
34
|
+
this.projectRoot = projectRoot;
|
|
35
|
+
this.project = new Project({
|
|
36
|
+
tsConfigFilePath: tsConfigPath,
|
|
37
|
+
skipAddingFilesFromTsConfig: true,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 添加源文件到项目
|
|
43
|
+
*/
|
|
44
|
+
addSourceFiles(patterns: string[]): void {
|
|
45
|
+
for (const pattern of patterns) {
|
|
46
|
+
this.project.addSourceFilesAtPaths(pattern);
|
|
47
|
+
}
|
|
48
|
+
// 过滤 node_modules
|
|
49
|
+
const sourceFiles = this.project.getSourceFiles();
|
|
50
|
+
for (const sf of sourceFiles) {
|
|
51
|
+
if (sf.getFilePath().includes('node_modules')) {
|
|
52
|
+
sf.forget();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 构建调用栈视图(从改动点向上追踪到入口)
|
|
59
|
+
*/
|
|
60
|
+
buildCallStack(targetSymbol: string, targetFile: string): CallStackTree | null {
|
|
61
|
+
// 找到目标函数的定义
|
|
62
|
+
const definition = this.findSymbolDefinition(targetSymbol, targetFile);
|
|
63
|
+
if (!definition) return null;
|
|
64
|
+
|
|
65
|
+
const root: CallStackNode = {
|
|
66
|
+
name: targetSymbol,
|
|
67
|
+
file: definition.file,
|
|
68
|
+
line: definition.line,
|
|
69
|
+
type: 'function',
|
|
70
|
+
children: [],
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// 递归追踪调用者
|
|
74
|
+
this.traceCallers(root, new Set<string>(), 0, 10);
|
|
75
|
+
|
|
76
|
+
// 计算深度
|
|
77
|
+
const depth = this.calculateDepth(root);
|
|
78
|
+
|
|
79
|
+
// 构建路径字符串
|
|
80
|
+
const pathStr = this.buildPathString(root);
|
|
81
|
+
|
|
82
|
+
return { root, depth, path: pathStr };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 查找符号定义
|
|
87
|
+
*/
|
|
88
|
+
private findSymbolDefinition(symbolName: string, inFile: string): {
|
|
89
|
+
file: string;
|
|
90
|
+
line: number;
|
|
91
|
+
type: 'function' | 'variable' | 'class' | 'interface';
|
|
92
|
+
} | null {
|
|
93
|
+
const sourceFile = this.project.getSourceFile(inFile);
|
|
94
|
+
if (!sourceFile) return null;
|
|
95
|
+
|
|
96
|
+
let result: { file: string; line: number; type: 'function' | 'variable' | 'class' | 'interface' } | null = null;
|
|
97
|
+
|
|
98
|
+
sourceFile.forEachDescendant(node => {
|
|
99
|
+
if (Node.isFunctionDeclaration(node)) {
|
|
100
|
+
const func = node;
|
|
101
|
+
if (func.getName() === symbolName) {
|
|
102
|
+
result = {
|
|
103
|
+
file: inFile,
|
|
104
|
+
line: func.getStartLineNumber(),
|
|
105
|
+
type: 'function',
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (Node.isVariableStatement(node)) {
|
|
111
|
+
const varDecl = node.getFirstDescendantByKind(SyntaxKind.VariableDeclaration);
|
|
112
|
+
if (varDecl && varDecl.getName() === symbolName) {
|
|
113
|
+
result = {
|
|
114
|
+
file: inFile,
|
|
115
|
+
line: varDecl.getStartLineNumber(),
|
|
116
|
+
type: 'variable',
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
return result;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* 递归追踪调用者
|
|
127
|
+
*/
|
|
128
|
+
private traceCallers(node: CallStackNode, visited: Set<string>, depth: number, maxDepth: number): void {
|
|
129
|
+
if (depth > maxDepth) return;
|
|
130
|
+
|
|
131
|
+
// 找所有调用此函数的地方
|
|
132
|
+
const callers = this.findCallers(node.name, node.file);
|
|
133
|
+
|
|
134
|
+
for (const caller of callers) {
|
|
135
|
+
const key = `${caller.file}:${caller.line}:${caller.name}`;
|
|
136
|
+
if (visited.has(key)) continue;
|
|
137
|
+
visited.add(key);
|
|
138
|
+
|
|
139
|
+
// 创建调用者节点
|
|
140
|
+
const callerNode: CallStackNode = {
|
|
141
|
+
name: caller.name,
|
|
142
|
+
file: caller.file,
|
|
143
|
+
line: caller.line,
|
|
144
|
+
type: caller.type,
|
|
145
|
+
children: [],
|
|
146
|
+
callSite: {
|
|
147
|
+
line: caller.callLine,
|
|
148
|
+
expression: caller.callExpression,
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
node.children.push(callerNode);
|
|
153
|
+
|
|
154
|
+
// 递归追踪调用者的调用者
|
|
155
|
+
this.traceCallers(callerNode, visited, depth + 1, maxDepth);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* 查找调用某个函数的所有地方
|
|
161
|
+
*/
|
|
162
|
+
private findCallers(symbolName: string, definedInFile: string): Array<{
|
|
163
|
+
name: string;
|
|
164
|
+
file: string;
|
|
165
|
+
line: number;
|
|
166
|
+
type: 'function' | 'arrow' | 'method' | 'component';
|
|
167
|
+
callLine: number;
|
|
168
|
+
callExpression: string;
|
|
169
|
+
}> {
|
|
170
|
+
const results: Array<{
|
|
171
|
+
name: string;
|
|
172
|
+
file: string;
|
|
173
|
+
line: number;
|
|
174
|
+
type: 'function' | 'arrow' | 'method' | 'component';
|
|
175
|
+
callLine: number;
|
|
176
|
+
callExpression: string;
|
|
177
|
+
}> = [];
|
|
178
|
+
|
|
179
|
+
for (const sourceFile of this.project.getSourceFiles()) {
|
|
180
|
+
const filePath = sourceFile.getFilePath();
|
|
181
|
+
|
|
182
|
+
sourceFile.forEachDescendant((node: Node) => {
|
|
183
|
+
// 查找函数定义
|
|
184
|
+
let funcInfo: {
|
|
185
|
+
name: string;
|
|
186
|
+
file: string;
|
|
187
|
+
line: number;
|
|
188
|
+
type: 'function' | 'arrow' | 'method' | 'component';
|
|
189
|
+
} | null = null;
|
|
190
|
+
|
|
191
|
+
if (Node.isFunctionDeclaration(node)) {
|
|
192
|
+
const func = node;
|
|
193
|
+
const name = func.getName();
|
|
194
|
+
if (name) {
|
|
195
|
+
funcInfo = {
|
|
196
|
+
name,
|
|
197
|
+
file: filePath,
|
|
198
|
+
line: func.getStartLineNumber(),
|
|
199
|
+
type: 'function',
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
} else if (Node.isArrowFunction(node)) {
|
|
203
|
+
// 检查是否是某个 const/let 声明的箭头函数
|
|
204
|
+
const parent = node.getParent();
|
|
205
|
+
if (parent && Node.isVariableDeclaration(parent)) {
|
|
206
|
+
const varDecl = parent;
|
|
207
|
+
const name = varDecl.getName();
|
|
208
|
+
if (name && !name.startsWith('_')) {
|
|
209
|
+
funcInfo = {
|
|
210
|
+
name,
|
|
211
|
+
file: filePath,
|
|
212
|
+
line: node.getStartLineNumber(),
|
|
213
|
+
type: 'arrow',
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
} else if (Node.isMethodDeclaration(node)) {
|
|
218
|
+
const method = node;
|
|
219
|
+
const name = method.getName();
|
|
220
|
+
funcInfo = {
|
|
221
|
+
name,
|
|
222
|
+
file: filePath,
|
|
223
|
+
line: method.getStartLineNumber(),
|
|
224
|
+
type: 'method',
|
|
225
|
+
};
|
|
226
|
+
} else if (Node.isPropertyAssignment(node) && Node.isFunctionExpression(node.getInitializer())) {
|
|
227
|
+
// React 组件: onClick={() => ...}
|
|
228
|
+
const prop = node;
|
|
229
|
+
const name = prop.getName();
|
|
230
|
+
funcInfo = {
|
|
231
|
+
name: name || 'anonymous',
|
|
232
|
+
file: filePath,
|
|
233
|
+
line: node.getStartLineNumber(),
|
|
234
|
+
type: 'component',
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// 如果找到了函数定义,检查是否调用了目标符号
|
|
239
|
+
if (funcInfo && funcInfo.name !== symbolName) {
|
|
240
|
+
const calls = this.findCallsInNode(node, symbolName);
|
|
241
|
+
for (const call of calls) {
|
|
242
|
+
results.push({
|
|
243
|
+
...funcInfo,
|
|
244
|
+
callLine: call.line,
|
|
245
|
+
callExpression: call.expression,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return results;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* 在节点内查找对某个符号的调用
|
|
257
|
+
*/
|
|
258
|
+
private findCallsInNode(node: Node, symbolName: string): Array<{ line: number; expression: string }> {
|
|
259
|
+
const results: Array<{ line: number; expression: string }> = [];
|
|
260
|
+
|
|
261
|
+
node.forEachDescendant((child: Node) => {
|
|
262
|
+
if (Node.isCallExpression(child)) {
|
|
263
|
+
const callExpr = child;
|
|
264
|
+
const expr = callExpr.getExpression();
|
|
265
|
+
|
|
266
|
+
if (Node.isIdentifier(expr)) {
|
|
267
|
+
const name = expr.getText();
|
|
268
|
+
if (name === symbolName) {
|
|
269
|
+
results.push({
|
|
270
|
+
line: callExpr.getStartLineNumber(),
|
|
271
|
+
expression: callExpr.getText().slice(0, 50),
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
return results;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* 计算树深度
|
|
283
|
+
*/
|
|
284
|
+
private calculateDepth(node: CallStackNode): number {
|
|
285
|
+
if (node.children.length === 0) return 0;
|
|
286
|
+
return 1 + Math.max(...node.children.map(c => this.calculateDepth(c)));
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* 构建路径字符串
|
|
291
|
+
*/
|
|
292
|
+
private buildPathString(node: CallStackNode): string[] {
|
|
293
|
+
const result: string[] = [];
|
|
294
|
+
const build = (n: CallStackNode) => {
|
|
295
|
+
result.push(`${path.basename(n.file)}:${n.line} (${n.name})`);
|
|
296
|
+
if (n.children.length > 0) {
|
|
297
|
+
build(n.children[0]);
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
build(node);
|
|
301
|
+
return result;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* 生成文本格式的调用栈视图
|
|
306
|
+
*/
|
|
307
|
+
formatAsText(tree: CallStackTree, changedSymbol: string): string {
|
|
308
|
+
const lines: string[] = [];
|
|
309
|
+
|
|
310
|
+
lines.push('');
|
|
311
|
+
lines.push('═══════════════════════════════════════════════════════════════');
|
|
312
|
+
lines.push(' 📞 调用栈视图 (Call Stack) ');
|
|
313
|
+
lines.push('═══════════════════════════════════════════════════════════════');
|
|
314
|
+
lines.push('');
|
|
315
|
+
|
|
316
|
+
const renderNode = (n: CallStackNode, prefix: string, isLast: boolean, isRoot: boolean) => {
|
|
317
|
+
const connector = isLast ? '└─' : '├─';
|
|
318
|
+
const current = isRoot
|
|
319
|
+
? `📍 ${n.name} (改动点)`
|
|
320
|
+
: `${connector} ${n.name}`;
|
|
321
|
+
|
|
322
|
+
lines.push(`${prefix}${current} → ${path.basename(n.file)}:${n.line}`);
|
|
323
|
+
|
|
324
|
+
if (n.callSite) {
|
|
325
|
+
lines.push(`${prefix} │`);
|
|
326
|
+
lines.push(`${prefix} └── 调用: 第${n.callSite.line}行 "${n.callSite.expression}"`);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const childPrefix = prefix + (isLast ? ' ' : '│ ');
|
|
330
|
+
n.children.forEach((child, idx) => {
|
|
331
|
+
const isChildLast = idx === n.children.length - 1;
|
|
332
|
+
renderNode(child, childPrefix, isChildLast, false);
|
|
333
|
+
});
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
renderNode(tree.root, '', true, true);
|
|
337
|
+
|
|
338
|
+
lines.push('');
|
|
339
|
+
lines.push(`深度: ${tree.depth} 层`);
|
|
340
|
+
lines.push(`路径: ${tree.path.join(' → ')}`);
|
|
341
|
+
lines.push('');
|
|
342
|
+
|
|
343
|
+
return lines.join('\n');
|
|
344
|
+
}
|
|
345
|
+
}
|