repowiki-core 0.1.0
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/dist/analyzer/api-analyzer.d.ts +22 -0
- package/dist/analyzer/api-analyzer.d.ts.map +1 -0
- package/dist/analyzer/api-analyzer.js +272 -0
- package/dist/analyzer/api-analyzer.js.map +1 -0
- package/dist/analyzer/config-analyzer.d.ts +18 -0
- package/dist/analyzer/config-analyzer.d.ts.map +1 -0
- package/dist/analyzer/config-analyzer.js +200 -0
- package/dist/analyzer/config-analyzer.js.map +1 -0
- package/dist/analyzer/database-analyzer.d.ts +24 -0
- package/dist/analyzer/database-analyzer.d.ts.map +1 -0
- package/dist/analyzer/database-analyzer.js +391 -0
- package/dist/analyzer/database-analyzer.js.map +1 -0
- package/dist/analyzer/index.d.ts +10 -0
- package/dist/analyzer/index.d.ts.map +1 -0
- package/dist/analyzer/index.js +10 -0
- package/dist/analyzer/index.js.map +1 -0
- package/dist/analyzer/module-analyzer.d.ts +20 -0
- package/dist/analyzer/module-analyzer.d.ts.map +1 -0
- package/dist/analyzer/module-analyzer.js +252 -0
- package/dist/analyzer/module-analyzer.js.map +1 -0
- package/dist/analyzer/workflow-analyzer.d.ts +19 -0
- package/dist/analyzer/workflow-analyzer.d.ts.map +1 -0
- package/dist/analyzer/workflow-analyzer.js +165 -0
- package/dist/analyzer/workflow-analyzer.js.map +1 -0
- package/dist/detector/dependency-detector.d.ts +50 -0
- package/dist/detector/dependency-detector.d.ts.map +1 -0
- package/dist/detector/dependency-detector.js +326 -0
- package/dist/detector/dependency-detector.js.map +1 -0
- package/dist/detector/entrypoint-detector.d.ts +30 -0
- package/dist/detector/entrypoint-detector.d.ts.map +1 -0
- package/dist/detector/entrypoint-detector.js +240 -0
- package/dist/detector/entrypoint-detector.js.map +1 -0
- package/dist/detector/index.d.ts +10 -0
- package/dist/detector/index.d.ts.map +1 -0
- package/dist/detector/index.js +10 -0
- package/dist/detector/index.js.map +1 -0
- package/dist/detector/tech-stack-detector.d.ts +41 -0
- package/dist/detector/tech-stack-detector.d.ts.map +1 -0
- package/dist/detector/tech-stack-detector.js +300 -0
- package/dist/detector/tech-stack-detector.js.map +1 -0
- package/dist/generator/index.d.ts +9 -0
- package/dist/generator/index.d.ts.map +1 -0
- package/dist/generator/index.js +9 -0
- package/dist/generator/index.js.map +1 -0
- package/dist/generator/markdown-generator.d.ts +71 -0
- package/dist/generator/markdown-generator.d.ts.map +1 -0
- package/dist/generator/markdown-generator.js +235 -0
- package/dist/generator/markdown-generator.js.map +1 -0
- package/dist/generator/mermaid-generator.d.ts +30 -0
- package/dist/generator/mermaid-generator.d.ts.map +1 -0
- package/dist/generator/mermaid-generator.js +297 -0
- package/dist/generator/mermaid-generator.js.map +1 -0
- package/dist/generator/sidebar-generator.d.ts +10 -0
- package/dist/generator/sidebar-generator.d.ts.map +1 -0
- package/dist/generator/sidebar-generator.js +120 -0
- package/dist/generator/sidebar-generator.js.map +1 -0
- package/dist/generator/wiki-generator.d.ts +45 -0
- package/dist/generator/wiki-generator.d.ts.map +1 -0
- package/dist/generator/wiki-generator.js +217 -0
- package/dist/generator/wiki-generator.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/llm/auth-manager.d.ts +50 -0
- package/dist/llm/auth-manager.d.ts.map +1 -0
- package/dist/llm/auth-manager.js +172 -0
- package/dist/llm/auth-manager.js.map +1 -0
- package/dist/llm/index.d.ts +10 -0
- package/dist/llm/index.d.ts.map +1 -0
- package/dist/llm/index.js +9 -0
- package/dist/llm/index.js.map +1 -0
- package/dist/llm/llm-client.d.ts +132 -0
- package/dist/llm/llm-client.d.ts.map +1 -0
- package/dist/llm/llm-client.js +308 -0
- package/dist/llm/llm-client.js.map +1 -0
- package/dist/llm/prompt-manager.d.ts +67 -0
- package/dist/llm/prompt-manager.d.ts.map +1 -0
- package/dist/llm/prompt-manager.js +283 -0
- package/dist/llm/prompt-manager.js.map +1 -0
- package/dist/models/analysis-result.d.ts +425 -0
- package/dist/models/analysis-result.d.ts.map +1 -0
- package/dist/models/analysis-result.js +34 -0
- package/dist/models/analysis-result.js.map +1 -0
- package/dist/models/analysis-types.d.ts +223 -0
- package/dist/models/analysis-types.d.ts.map +1 -0
- package/dist/models/analysis-types.js +95 -0
- package/dist/models/analysis-types.js.map +1 -0
- package/dist/models/file-reference.d.ts +62 -0
- package/dist/models/file-reference.d.ts.map +1 -0
- package/dist/models/file-reference.js +34 -0
- package/dist/models/file-reference.js.map +1 -0
- package/dist/models/index.d.ts +10 -0
- package/dist/models/index.d.ts.map +1 -0
- package/dist/models/index.js +10 -0
- package/dist/models/index.js.map +1 -0
- package/dist/models/project-profile.d.ts +48 -0
- package/dist/models/project-profile.d.ts.map +1 -0
- package/dist/models/project-profile.js +26 -0
- package/dist/models/project-profile.js.map +1 -0
- package/dist/models/wiki-page.d.ts +57 -0
- package/dist/models/wiki-page.d.ts.map +1 -0
- package/dist/models/wiki-page.js +19 -0
- package/dist/models/wiki-page.js.map +1 -0
- package/dist/pipeline.d.ts +30 -0
- package/dist/pipeline.d.ts.map +1 -0
- package/dist/pipeline.js +159 -0
- package/dist/pipeline.js.map +1 -0
- package/dist/scanner/file-scanner.d.ts +27 -0
- package/dist/scanner/file-scanner.d.ts.map +1 -0
- package/dist/scanner/file-scanner.js +149 -0
- package/dist/scanner/file-scanner.js.map +1 -0
- package/dist/scanner/ignore-rules.d.ts +31 -0
- package/dist/scanner/ignore-rules.d.ts.map +1 -0
- package/dist/scanner/ignore-rules.js +98 -0
- package/dist/scanner/ignore-rules.js.map +1 -0
- package/dist/scanner/index.d.ts +8 -0
- package/dist/scanner/index.d.ts.map +1 -0
- package/dist/scanner/index.js +8 -0
- package/dist/scanner/index.js.map +1 -0
- package/dist/scanner/tree-builder.d.ts +20 -0
- package/dist/scanner/tree-builder.d.ts.map +1 -0
- package/dist/scanner/tree-builder.js +118 -0
- package/dist/scanner/tree-builder.js.map +1 -0
- package/package.json +34 -0
- package/src/analyzer/api-analyzer.ts +324 -0
- package/src/analyzer/config-analyzer.ts +209 -0
- package/src/analyzer/database-analyzer.ts +468 -0
- package/src/analyzer/index.ts +26 -0
- package/src/analyzer/module-analyzer.ts +308 -0
- package/src/analyzer/workflow-analyzer.ts +190 -0
- package/src/detector/dependency-detector.ts +390 -0
- package/src/detector/entrypoint-detector.ts +270 -0
- package/src/detector/index.ts +21 -0
- package/src/detector/tech-stack-detector.ts +377 -0
- package/src/generator/index.ts +36 -0
- package/src/generator/markdown-generator.ts +277 -0
- package/src/generator/mermaid-generator.ts +340 -0
- package/src/generator/sidebar-generator.ts +134 -0
- package/src/generator/wiki-generator.ts +281 -0
- package/src/index.ts +12 -0
- package/src/llm/auth-manager.ts +207 -0
- package/src/llm/index.ts +21 -0
- package/src/llm/llm-client.ts +417 -0
- package/src/llm/prompt-manager.ts +325 -0
- package/src/models/analysis-result.ts +44 -0
- package/src/models/analysis-types.ts +121 -0
- package/src/models/file-reference.ts +41 -0
- package/src/models/index.ts +44 -0
- package/src/models/project-profile.ts +29 -0
- package/src/models/wiki-page.ts +23 -0
- package/src/pipeline.ts +225 -0
- package/src/scanner/file-scanner.ts +192 -0
- package/src/scanner/ignore-rules.ts +112 -0
- package/src/scanner/index.ts +19 -0
- package/src/scanner/tree-builder.ts +156 -0
- package/tsconfig.json +8 -0
package/src/pipeline.ts
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import * as path from 'node:path';
|
|
2
|
+
import * as fs from 'node:fs/promises';
|
|
3
|
+
import { loadLLMConfig, LLMClient, validateConfig } from './llm/index.js';
|
|
4
|
+
import { scanDirectory } from './scanner/index.js';
|
|
5
|
+
import { buildTreeString } from './scanner/tree-builder.js';
|
|
6
|
+
import { detectTechStack, detectEntrypoints, buildDependencyGraph } from './detector/index.js';
|
|
7
|
+
import {
|
|
8
|
+
analyzeModules,
|
|
9
|
+
analyzeApiRoutes,
|
|
10
|
+
analyzeDatabaseModels,
|
|
11
|
+
analyzeConfigs,
|
|
12
|
+
analyzeWorkflows,
|
|
13
|
+
} from './analyzer/index.js';
|
|
14
|
+
import { WikiGenerator, generateHome, generateSidebar } from './generator/index.js';
|
|
15
|
+
import type { AnalysisResult, ProjectProfile, WikiPage } from './models/index.js';
|
|
16
|
+
|
|
17
|
+
export interface PipelineOptions {
|
|
18
|
+
workspacePath: string;
|
|
19
|
+
outputDir?: string;
|
|
20
|
+
modelName?: string;
|
|
21
|
+
concurrency?: number;
|
|
22
|
+
skipLlm?: boolean;
|
|
23
|
+
onProgress?: (event: PipelineEvent) => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type PipelineEvent =
|
|
27
|
+
| { type: 'PROGRESS'; stage: string; progress: number; message: string }
|
|
28
|
+
| { type: 'DONE'; payload: { docsPath: string; pagesCount: number } }
|
|
29
|
+
| { type: 'ERROR'; code: number; message: string };
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 运行核心 Wiki 生成 Pipeline
|
|
33
|
+
*/
|
|
34
|
+
export async function runPipeline(options: PipelineOptions): Promise<AnalysisResult> {
|
|
35
|
+
const {
|
|
36
|
+
workspacePath,
|
|
37
|
+
outputDir = path.join(workspacePath, 'docs', 'wiki'),
|
|
38
|
+
modelName,
|
|
39
|
+
concurrency = 3,
|
|
40
|
+
skipLlm = false,
|
|
41
|
+
onProgress,
|
|
42
|
+
} = options;
|
|
43
|
+
|
|
44
|
+
const emitProgress = (stage: string, progress: number, message: string) => {
|
|
45
|
+
if (onProgress) {
|
|
46
|
+
onProgress({ type: 'PROGRESS', stage, progress, message });
|
|
47
|
+
} else {
|
|
48
|
+
// CLI 后台或未提供回调时输出规范的 JSONL 格式进程通知
|
|
49
|
+
console.log(JSON.stringify({ type: 'PROGRESS', stage, progress, message }));
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
// ====================================================================
|
|
55
|
+
// 1. Pre-flight 阶段
|
|
56
|
+
// ====================================================================
|
|
57
|
+
emitProgress('Pre-flight', 5, '初始化环境与认证配置...');
|
|
58
|
+
|
|
59
|
+
let llmClient: LLMClient | null = null;
|
|
60
|
+
|
|
61
|
+
if (!skipLlm) {
|
|
62
|
+
const llmConfig = await loadLLMConfig(workspacePath);
|
|
63
|
+
if (modelName) {
|
|
64
|
+
llmConfig.modelName = modelName;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const validationErrors = validateConfig(llmConfig);
|
|
68
|
+
if (validationErrors.length > 0) {
|
|
69
|
+
// 如果 API Key 缺失,回退到免大模型模式
|
|
70
|
+
emitProgress('Pre-flight', 8, `未提供 API 凭证,切换至免大模型快速生成模式...`);
|
|
71
|
+
} else {
|
|
72
|
+
llmClient = new LLMClient({ config: llmConfig });
|
|
73
|
+
emitProgress('Pre-flight', 10, `成功加载 LLM 接口,使用模型: ${llmConfig.modelName}`);
|
|
74
|
+
}
|
|
75
|
+
} else {
|
|
76
|
+
emitProgress('Pre-flight', 10, '已开启免大模型(skip-llm)生成模式...');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 确保输出目录存在
|
|
80
|
+
await fs.mkdir(outputDir, { recursive: true });
|
|
81
|
+
|
|
82
|
+
// ====================================================================
|
|
83
|
+
// 2. Scan Phase 阶段
|
|
84
|
+
// ====================================================================
|
|
85
|
+
emitProgress('Scanning', 15, '扫描项目文件结构中...');
|
|
86
|
+
const files = await scanDirectory(workspacePath);
|
|
87
|
+
|
|
88
|
+
emitProgress('Scanning', 25, `扫描完成,发现 ${files.length} 个非忽略文件。构建目录树...`);
|
|
89
|
+
const treeStr = buildTreeString(files, workspacePath);
|
|
90
|
+
|
|
91
|
+
emitProgress('Scanning', 30, '识别项目技术栈与框架特征...');
|
|
92
|
+
const techStackResult = await detectTechStack(workspacePath, files);
|
|
93
|
+
|
|
94
|
+
const projectProfile: ProjectProfile = {
|
|
95
|
+
name: path.basename(workspacePath) || 'unnamed-project',
|
|
96
|
+
rootPath: workspacePath,
|
|
97
|
+
languages: techStackResult.languages,
|
|
98
|
+
frameworks: techStackResult.frameworks,
|
|
99
|
+
packageManagers: techStackResult.packageManagers,
|
|
100
|
+
databases: techStackResult.databases,
|
|
101
|
+
services: techStackResult.services,
|
|
102
|
+
entrypoints: [],
|
|
103
|
+
configFiles: techStackResult.configFiles,
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// ====================================================================
|
|
107
|
+
// 3. Analysis Phase 阶段
|
|
108
|
+
// ====================================================================
|
|
109
|
+
emitProgress('Analysis', 40, '分析项目入口及内部模块依赖图...');
|
|
110
|
+
const entrypoints = await detectEntrypoints(workspacePath, files);
|
|
111
|
+
projectProfile.entrypoints = entrypoints;
|
|
112
|
+
|
|
113
|
+
const dependencyGraph = await buildDependencyGraph(workspacePath, files);
|
|
114
|
+
|
|
115
|
+
emitProgress('Analysis', 50, '对模块结构聚类分析...');
|
|
116
|
+
const modules = await analyzeModules(workspacePath, files, llmClient);
|
|
117
|
+
|
|
118
|
+
emitProgress('Analysis', 60, '提取 API 路由及数据库模型定义...');
|
|
119
|
+
const apiRoutes = await analyzeApiRoutes(workspacePath, files);
|
|
120
|
+
const databaseModels = await analyzeDatabaseModels(workspacePath, files);
|
|
121
|
+
|
|
122
|
+
emitProgress('Analysis', 65, '分析 CI 流程、配置文件和 Agent 工作流...');
|
|
123
|
+
const configInfos = await analyzeConfigs(workspacePath, files);
|
|
124
|
+
const workflowInfos = await analyzeWorkflows(workspacePath, files);
|
|
125
|
+
|
|
126
|
+
const analysisResult: AnalysisResult = {
|
|
127
|
+
project: projectProfile,
|
|
128
|
+
tree: treeStr,
|
|
129
|
+
modules,
|
|
130
|
+
dependencies: dependencyGraph,
|
|
131
|
+
apiRoutes,
|
|
132
|
+
databaseModels,
|
|
133
|
+
wikiPages: [],
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
// ====================================================================
|
|
137
|
+
// 4. Planning Phase 阶段
|
|
138
|
+
// ====================================================================
|
|
139
|
+
emitProgress('Planning', 70, '制定 Wiki 页面规划方案...');
|
|
140
|
+
const generator = new WikiGenerator(llmClient, { outputDir, concurrency });
|
|
141
|
+
const plannedPages = await generator.planPages(analysisResult);
|
|
142
|
+
|
|
143
|
+
emitProgress('Planning', 75, `规划完成,共需要生成 ${plannedPages.length} 个 Wiki 页面。`);
|
|
144
|
+
|
|
145
|
+
// ====================================================================
|
|
146
|
+
// 5. Render Phase 阶段
|
|
147
|
+
// ====================================================================
|
|
148
|
+
const generatedPages: WikiPage[] = [];
|
|
149
|
+
|
|
150
|
+
// 构建快速交叉引用索引
|
|
151
|
+
const getExistingPagesSummary = () => {
|
|
152
|
+
return generatedPages.map((p) => `- ${p.title} (${p.filename})`).join('\n');
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
let currentProgress = 75;
|
|
156
|
+
const progressStep = (95 - 75) / Math.max(plannedPages.length, 1);
|
|
157
|
+
|
|
158
|
+
for (let i = 0; i < plannedPages.length; i++) {
|
|
159
|
+
const page = plannedPages[i];
|
|
160
|
+
currentProgress += progressStep;
|
|
161
|
+
|
|
162
|
+
emitProgress(
|
|
163
|
+
'LLM Inference',
|
|
164
|
+
Math.round(currentProgress),
|
|
165
|
+
`正在生成 (${i + 1}/${plannedPages.length}): ${page.title}...`
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
const wikiPage = await generator.generatePage(
|
|
169
|
+
workspacePath,
|
|
170
|
+
page,
|
|
171
|
+
analysisResult,
|
|
172
|
+
getExistingPagesSummary()
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
await generator.savePage(wikiPage);
|
|
176
|
+
generatedPages.push(wikiPage);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ====================================================================
|
|
180
|
+
// 6. Post-flight 阶段
|
|
181
|
+
// ====================================================================
|
|
182
|
+
emitProgress('Post-flight', 96, '生成 Wiki 导航与侧边栏 _Sidebar.md...');
|
|
183
|
+
|
|
184
|
+
const sidebarContent = generateSidebar(generatedPages);
|
|
185
|
+
const homeContent = generateHome(generatedPages, projectProfile);
|
|
186
|
+
|
|
187
|
+
await fs.writeFile(path.join(outputDir, '_Sidebar.md'), sidebarContent, 'utf-8');
|
|
188
|
+
await fs.writeFile(path.join(outputDir, 'Home.md'), homeContent, 'utf-8');
|
|
189
|
+
|
|
190
|
+
// 将新页追加到分析结果中
|
|
191
|
+
analysisResult.wikiPages = generatedPages;
|
|
192
|
+
|
|
193
|
+
emitProgress('Post-flight', 100, '所有 Wiki 文档生成完成!');
|
|
194
|
+
|
|
195
|
+
const doneEvent = {
|
|
196
|
+
type: 'DONE' as const,
|
|
197
|
+
payload: {
|
|
198
|
+
docsPath: path.resolve(outputDir),
|
|
199
|
+
pagesCount: generatedPages.length + 2, // 包含 Home.md, _Sidebar.md
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
if (onProgress) {
|
|
204
|
+
onProgress(doneEvent);
|
|
205
|
+
} else {
|
|
206
|
+
console.log(JSON.stringify(doneEvent));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return analysisResult;
|
|
210
|
+
} catch (err: any) {
|
|
211
|
+
const errorEvent = {
|
|
212
|
+
type: 'ERROR' as const,
|
|
213
|
+
code: 500,
|
|
214
|
+
message: err?.message || String(err),
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
if (onProgress) {
|
|
218
|
+
onProgress(errorEvent);
|
|
219
|
+
} else {
|
|
220
|
+
console.log(JSON.stringify(errorEvent));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
throw err;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import fg from 'fast-glob';
|
|
2
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import type { FileNode } from '../models/file-reference.js';
|
|
6
|
+
import { createIgnoreFilter } from './ignore-rules.js';
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// 语言映射表
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 文件扩展名 → 语言标识的映射。
|
|
14
|
+
* 键为小写扩展名 (不含前导点)。
|
|
15
|
+
*/
|
|
16
|
+
const LANGUAGE_MAP: Record<string, string> = {
|
|
17
|
+
ts: 'typescript',
|
|
18
|
+
tsx: 'typescriptreact',
|
|
19
|
+
js: 'javascript',
|
|
20
|
+
jsx: 'javascriptreact',
|
|
21
|
+
py: 'python',
|
|
22
|
+
go: 'go',
|
|
23
|
+
rs: 'rust',
|
|
24
|
+
java: 'java',
|
|
25
|
+
rb: 'ruby',
|
|
26
|
+
css: 'css',
|
|
27
|
+
html: 'html',
|
|
28
|
+
md: 'markdown',
|
|
29
|
+
json: 'json',
|
|
30
|
+
yaml: 'yaml',
|
|
31
|
+
yml: 'yaml',
|
|
32
|
+
toml: 'toml',
|
|
33
|
+
sql: 'sql',
|
|
34
|
+
prisma: 'prisma',
|
|
35
|
+
vue: 'vue',
|
|
36
|
+
svelte: 'svelte',
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 根据文件扩展名推断编程语言。
|
|
41
|
+
*
|
|
42
|
+
* @param filePath - 文件路径 (仅使用扩展名部分)
|
|
43
|
+
* @returns 语言标识字符串,无法识别时返回 `undefined`
|
|
44
|
+
*/
|
|
45
|
+
function detectLanguage(filePath: string): string | undefined {
|
|
46
|
+
const ext = path.extname(filePath).slice(1).toLowerCase();
|
|
47
|
+
return LANGUAGE_MAP[ext];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// 行数统计
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 统计文件中非空行数。
|
|
56
|
+
* 对大于 2 MB 的文件跳过行数统计以避免内存压力。
|
|
57
|
+
*/
|
|
58
|
+
async function countNonEmptyLines(
|
|
59
|
+
filePath: string,
|
|
60
|
+
sizeBytes: number,
|
|
61
|
+
): Promise<number | undefined> {
|
|
62
|
+
// 跳过过大的文件
|
|
63
|
+
const MAX_SIZE_FOR_LINE_COUNT = 2 * 1024 * 1024; // 2 MB
|
|
64
|
+
if (sizeBytes > MAX_SIZE_FOR_LINE_COUNT) {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const content = await readFile(filePath, 'utf-8');
|
|
70
|
+
return content.split(/\r?\n/).filter((line) => line.trim().length > 0).length;
|
|
71
|
+
} catch {
|
|
72
|
+
// 二进制文件或无法读取 —— 静默跳过
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Scanner 配置
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 文件扫描器配置项。
|
|
83
|
+
*/
|
|
84
|
+
export interface ScannerConfig {
|
|
85
|
+
/** 最大递归深度 (默认 5) */
|
|
86
|
+
maxDepth: number;
|
|
87
|
+
/** 额外忽略模式 (glob) */
|
|
88
|
+
ignorePatterns: string[];
|
|
89
|
+
/** 仅包含指定扩展名 (不含前导点)。为空时包含所有文件 */
|
|
90
|
+
includeExtensions: string[];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 默认配置 */
|
|
94
|
+
const DEFAULT_CONFIG: ScannerConfig = {
|
|
95
|
+
maxDepth: 5,
|
|
96
|
+
ignorePatterns: [],
|
|
97
|
+
includeExtensions: [],
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// 核心扫描函数
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 递归扫描项目目录,返回扁平的 {@link FileNode} 列表。
|
|
106
|
+
*
|
|
107
|
+
* @param rootPath - 项目根目录的绝对路径
|
|
108
|
+
* @param config - 可选配置,覆盖默认值
|
|
109
|
+
* @returns 排序后的 FileNode 列表 (按相对路径字母序)
|
|
110
|
+
*
|
|
111
|
+
* @example
|
|
112
|
+
* ```ts
|
|
113
|
+
* const files = await scanDirectory('/path/to/project');
|
|
114
|
+
* console.log(files.length);
|
|
115
|
+
* ```
|
|
116
|
+
*/
|
|
117
|
+
export async function scanDirectory(
|
|
118
|
+
rootPath: string,
|
|
119
|
+
config?: Partial<ScannerConfig>,
|
|
120
|
+
): Promise<FileNode[]> {
|
|
121
|
+
const cfg: ScannerConfig = { ...DEFAULT_CONFIG, ...config };
|
|
122
|
+
|
|
123
|
+
// 将 rootPath 规范化为 posix 风格以兼容 fast-glob
|
|
124
|
+
const normalizedRoot = rootPath.replace(/\\/g, '/');
|
|
125
|
+
|
|
126
|
+
// 构建忽略过滤器
|
|
127
|
+
const shouldIgnore = await createIgnoreFilter(rootPath);
|
|
128
|
+
|
|
129
|
+
// 构建 glob 模式
|
|
130
|
+
const pattern = `${normalizedRoot}/**/*`;
|
|
131
|
+
|
|
132
|
+
// fast-glob 选项
|
|
133
|
+
const entries = await fg(pattern, {
|
|
134
|
+
dot: false,
|
|
135
|
+
onlyFiles: true,
|
|
136
|
+
deep: cfg.maxDepth,
|
|
137
|
+
ignore: cfg.ignorePatterns,
|
|
138
|
+
absolute: true,
|
|
139
|
+
stats: false,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const nodes: FileNode[] = [];
|
|
143
|
+
|
|
144
|
+
for (const absPath of entries) {
|
|
145
|
+
// 计算相对路径 (posix 风格)
|
|
146
|
+
const relativePath = path
|
|
147
|
+
.relative(rootPath, absPath)
|
|
148
|
+
.replace(/\\/g, '/');
|
|
149
|
+
|
|
150
|
+
// 应用 ignore 过滤器
|
|
151
|
+
if (shouldIgnore(relativePath)) {
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// 扩展名过滤
|
|
156
|
+
if (cfg.includeExtensions.length > 0) {
|
|
157
|
+
const ext = path.extname(absPath).slice(1).toLowerCase();
|
|
158
|
+
if (!cfg.includeExtensions.includes(ext)) {
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// 获取文件信息
|
|
164
|
+
let fileStat;
|
|
165
|
+
try {
|
|
166
|
+
fileStat = await stat(absPath);
|
|
167
|
+
} catch {
|
|
168
|
+
// 文件在扫描期间被删除等异常情况 —— 静默跳过
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const sizeBytes = fileStat.size;
|
|
173
|
+
const language = detectLanguage(absPath);
|
|
174
|
+
const lineCount = language
|
|
175
|
+
? await countNonEmptyLines(absPath, sizeBytes)
|
|
176
|
+
: undefined;
|
|
177
|
+
|
|
178
|
+
nodes.push({
|
|
179
|
+
path: absPath,
|
|
180
|
+
relativePath,
|
|
181
|
+
nodeType: 'file',
|
|
182
|
+
sizeBytes,
|
|
183
|
+
language,
|
|
184
|
+
lineCount,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// 按相对路径排序
|
|
189
|
+
nodes.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
|
|
190
|
+
|
|
191
|
+
return nodes;
|
|
192
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import ignore, { type Ignore } from 'ignore';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// 默认忽略规则
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 默认忽略的目录 / 文件 glob 模式。
|
|
11
|
+
* 这些模式在任何项目中都应被忽略 (构建产物、依赖、缓存等)。
|
|
12
|
+
*/
|
|
13
|
+
export const DEFAULT_IGNORE_PATTERNS: readonly string[] = [
|
|
14
|
+
'.git/',
|
|
15
|
+
'node_modules/',
|
|
16
|
+
'dist/',
|
|
17
|
+
'build/',
|
|
18
|
+
'.next/',
|
|
19
|
+
'venv/',
|
|
20
|
+
'.venv/',
|
|
21
|
+
'__pycache__/',
|
|
22
|
+
'.pytest_cache/',
|
|
23
|
+
'coverage/',
|
|
24
|
+
'.cache/',
|
|
25
|
+
'.DS_Store',
|
|
26
|
+
'*.lock',
|
|
27
|
+
'*.min.js',
|
|
28
|
+
'*.map',
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 敏感文件强制忽略模式。
|
|
33
|
+
* 为避免将密钥 / 凭据意外写入 Wiki,这些文件始终被排除。
|
|
34
|
+
*/
|
|
35
|
+
export const SENSITIVE_FILE_PATTERNS: readonly string[] = [
|
|
36
|
+
'.env',
|
|
37
|
+
'.env.local',
|
|
38
|
+
'.env.production',
|
|
39
|
+
'*.pem',
|
|
40
|
+
'*.key',
|
|
41
|
+
'id_rsa',
|
|
42
|
+
'id_ed25519',
|
|
43
|
+
'secrets.yaml',
|
|
44
|
+
'credentials.json',
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// 辅助函数
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 尝试读取项目根目录下的 `.gitignore` 文件,并返回其内容行。
|
|
53
|
+
* 如果文件不存在或读取失败,返回空数组。
|
|
54
|
+
*/
|
|
55
|
+
async function loadGitignoreLines(projectRoot: string): Promise<string[]> {
|
|
56
|
+
try {
|
|
57
|
+
const content = await readFile(
|
|
58
|
+
path.join(projectRoot, '.gitignore'),
|
|
59
|
+
'utf-8',
|
|
60
|
+
);
|
|
61
|
+
return content
|
|
62
|
+
.split(/\r?\n/)
|
|
63
|
+
.map((line) => line.trim())
|
|
64
|
+
.filter((line) => line.length > 0 && !line.startsWith('#'));
|
|
65
|
+
} catch {
|
|
66
|
+
// .gitignore 不存在或不可读 —— 静默跳过
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// 核心 API
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 创建一个用于判断文件路径是否应被忽略的过滤函数。
|
|
77
|
+
*
|
|
78
|
+
* 合并以下三类规则:
|
|
79
|
+
* 1. 项目根目录下的 `.gitignore`
|
|
80
|
+
* 2. {@link DEFAULT_IGNORE_PATTERNS}
|
|
81
|
+
* 3. {@link SENSITIVE_FILE_PATTERNS}
|
|
82
|
+
*
|
|
83
|
+
* @param projectRoot - 项目根目录的绝对路径
|
|
84
|
+
* @returns 一个判定函数。传入**相对路径**,返回 `true` 代表该路径应被忽略。
|
|
85
|
+
*
|
|
86
|
+
* @example
|
|
87
|
+
* ```ts
|
|
88
|
+
* const shouldIgnore = await createIgnoreFilter('/path/to/project');
|
|
89
|
+
* if (shouldIgnore('node_modules/foo/index.js')) {
|
|
90
|
+
* // 跳过
|
|
91
|
+
* }
|
|
92
|
+
* ```
|
|
93
|
+
*/
|
|
94
|
+
export async function createIgnoreFilter(
|
|
95
|
+
projectRoot: string,
|
|
96
|
+
): Promise<(relativePath: string) => boolean> {
|
|
97
|
+
const ig: Ignore = (((ignore as any).default || ignore) as any)();
|
|
98
|
+
|
|
99
|
+
// 1. 加载 .gitignore
|
|
100
|
+
const gitignoreLines = await loadGitignoreLines(projectRoot);
|
|
101
|
+
if (gitignoreLines.length > 0) {
|
|
102
|
+
ig.add(gitignoreLines);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 2. 默认忽略模式
|
|
106
|
+
ig.add(DEFAULT_IGNORE_PATTERNS as string[]);
|
|
107
|
+
|
|
108
|
+
// 3. 敏感文件模式
|
|
109
|
+
ig.add(SENSITIVE_FILE_PATTERNS as string[]);
|
|
110
|
+
|
|
111
|
+
return (relativePath: string): boolean => ig.ignores(relativePath);
|
|
112
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module scanner
|
|
3
|
+
* 文件扫描器统一导出
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export {
|
|
7
|
+
DEFAULT_IGNORE_PATTERNS,
|
|
8
|
+
SENSITIVE_FILE_PATTERNS,
|
|
9
|
+
createIgnoreFilter,
|
|
10
|
+
} from './ignore-rules.js';
|
|
11
|
+
|
|
12
|
+
export {
|
|
13
|
+
type ScannerConfig,
|
|
14
|
+
scanDirectory,
|
|
15
|
+
} from './file-scanner.js';
|
|
16
|
+
|
|
17
|
+
export {
|
|
18
|
+
buildTreeString,
|
|
19
|
+
} from './tree-builder.js';
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
|
|
3
|
+
import type { FileNode } from '../models/file-reference.js';
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// 内部数据结构
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
/** 目录树中的一个节点 (中间表示) */
|
|
10
|
+
interface TreeNode {
|
|
11
|
+
/** 节点名称 (文件名或目录名) */
|
|
12
|
+
name: string;
|
|
13
|
+
/** 是否为目录 */
|
|
14
|
+
isDir: boolean;
|
|
15
|
+
/** 子节点 (仅目录有效) */
|
|
16
|
+
children: Map<string, TreeNode>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// 辅助函数
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 将扁平文件列表构建为嵌套树结构。
|
|
25
|
+
*/
|
|
26
|
+
function buildTree(files: FileNode[], rootPath: string): TreeNode {
|
|
27
|
+
const root: TreeNode = {
|
|
28
|
+
name: path.basename(rootPath),
|
|
29
|
+
isDir: true,
|
|
30
|
+
children: new Map(),
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
for (const file of files) {
|
|
34
|
+
const segments = file.relativePath.split('/');
|
|
35
|
+
let current = root;
|
|
36
|
+
|
|
37
|
+
for (let i = 0; i < segments.length; i++) {
|
|
38
|
+
const seg = segments[i];
|
|
39
|
+
const isLast = i === segments.length - 1;
|
|
40
|
+
|
|
41
|
+
if (!current.children.has(seg)) {
|
|
42
|
+
current.children.set(seg, {
|
|
43
|
+
name: seg,
|
|
44
|
+
isDir: !isLast,
|
|
45
|
+
children: new Map(),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
current = current.children.get(seg)!;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return root;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 统计树中所有叶子文件数量。
|
|
58
|
+
*/
|
|
59
|
+
function countFiles(node: TreeNode): number {
|
|
60
|
+
if (!node.isDir || node.children.size === 0) {
|
|
61
|
+
return node.isDir ? 0 : 1;
|
|
62
|
+
}
|
|
63
|
+
let count = 0;
|
|
64
|
+
for (const child of node.children.values()) {
|
|
65
|
+
count += countFiles(child);
|
|
66
|
+
}
|
|
67
|
+
return count;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 统计树中所有目录数量 (不含根节点自身)。
|
|
72
|
+
*/
|
|
73
|
+
function countDirectories(node: TreeNode): number {
|
|
74
|
+
let count = 0;
|
|
75
|
+
for (const child of node.children.values()) {
|
|
76
|
+
if (child.isDir) {
|
|
77
|
+
count += 1 + countDirectories(child);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return count;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 递归渲染树字符串。
|
|
85
|
+
*
|
|
86
|
+
* @param node - 当前节点
|
|
87
|
+
* @param prefix - 当前行前缀 (用于缩进)
|
|
88
|
+
* @param lines - 输出行收集器
|
|
89
|
+
*/
|
|
90
|
+
function renderNode(
|
|
91
|
+
node: TreeNode,
|
|
92
|
+
prefix: string,
|
|
93
|
+
lines: string[],
|
|
94
|
+
): void {
|
|
95
|
+
// 对子节点排序: 目录在前,同类按名称排序
|
|
96
|
+
const sorted = [...node.children.entries()].sort(([, a], [, b]) => {
|
|
97
|
+
if (a.isDir !== b.isDir) {
|
|
98
|
+
return a.isDir ? -1 : 1;
|
|
99
|
+
}
|
|
100
|
+
return a.name.localeCompare(b.name);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
104
|
+
const [, child] = sorted[i];
|
|
105
|
+
const isLast = i === sorted.length - 1;
|
|
106
|
+
const connector = isLast ? '\u2514\u2500\u2500 ' : '\u251C\u2500\u2500 ';
|
|
107
|
+
const childPrefix = isLast ? ' ' : '\u2502 ';
|
|
108
|
+
|
|
109
|
+
const displayName = child.isDir ? `${child.name}/` : child.name;
|
|
110
|
+
lines.push(`${prefix}${connector}${displayName}`);
|
|
111
|
+
|
|
112
|
+
if (child.isDir && child.children.size > 0) {
|
|
113
|
+
renderNode(child, prefix + childPrefix, lines);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// 核心 API
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 将扁平的 {@link FileNode} 列表渲染为可读的树形文本,类似 `tree` 命令输出。
|
|
124
|
+
*
|
|
125
|
+
* 使用 Unicode box-drawing 字符绘制分支 (├──, └──, │)。
|
|
126
|
+
* 输出末尾附带文件数 / 目录数汇总。
|
|
127
|
+
*
|
|
128
|
+
* @param files - 扁平的 FileNode 列表 (由 `scanDirectory` 返回)
|
|
129
|
+
* @param rootPath - 项目根目录的绝对路径
|
|
130
|
+
* @returns 多行字符串形式的目录树
|
|
131
|
+
*
|
|
132
|
+
* @example
|
|
133
|
+
* ```ts
|
|
134
|
+
* const files = await scanDirectory('/project');
|
|
135
|
+
* const tree = buildTreeString(files, '/project');
|
|
136
|
+
* console.log(tree);
|
|
137
|
+
* ```
|
|
138
|
+
*/
|
|
139
|
+
export function buildTreeString(files: FileNode[], rootPath: string): string {
|
|
140
|
+
if (files.length === 0) {
|
|
141
|
+
const rootName = path.basename(rootPath);
|
|
142
|
+
return `${rootName}/\n\n0 directories, 0 files`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const root = buildTree(files, rootPath);
|
|
146
|
+
const lines: string[] = [`${root.name}/`];
|
|
147
|
+
|
|
148
|
+
renderNode(root, '', lines);
|
|
149
|
+
|
|
150
|
+
const dirCount = countDirectories(root);
|
|
151
|
+
const fileCount = countFiles(root);
|
|
152
|
+
lines.push('');
|
|
153
|
+
lines.push(`${dirCount} directories, ${fileCount} files`);
|
|
154
|
+
|
|
155
|
+
return lines.join('\n');
|
|
156
|
+
}
|