skill-any-code 1.0.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/README.md +48 -0
- package/dist/cli.js +319 -0
- package/dist/index.js +22 -0
- package/jest.config.js +27 -0
- package/package.json +59 -0
- package/src/adapters/command.schemas.ts +21 -0
- package/src/application/analysis.app.service.ts +272 -0
- package/src/application/bootstrap.ts +35 -0
- package/src/application/services/llm.analysis.service.ts +237 -0
- package/src/cli.ts +297 -0
- package/src/common/config.ts +209 -0
- package/src/common/constants.ts +8 -0
- package/src/common/errors.ts +34 -0
- package/src/common/logger.ts +82 -0
- package/src/common/types.ts +385 -0
- package/src/common/ui.ts +228 -0
- package/src/common/utils.ts +81 -0
- package/src/domain/index.ts +1 -0
- package/src/domain/interfaces.ts +188 -0
- package/src/domain/services/analysis.service.ts +735 -0
- package/src/domain/services/incremental.service.ts +50 -0
- package/src/index.ts +6 -0
- package/src/infrastructure/blacklist.service.ts +37 -0
- package/src/infrastructure/cache/file.hash.cache.ts +119 -0
- package/src/infrastructure/git/git.service.ts +120 -0
- package/src/infrastructure/git.service.ts +121 -0
- package/src/infrastructure/index.service.ts +94 -0
- package/src/infrastructure/llm/llm.usage.tracker.ts +65 -0
- package/src/infrastructure/llm/openai.client.ts +162 -0
- package/src/infrastructure/llm/prompt.template.ts +175 -0
- package/src/infrastructure/llm.service.ts +70 -0
- package/src/infrastructure/skill/skill.generator.ts +53 -0
- package/src/infrastructure/skill/templates/resolve.script.ts +97 -0
- package/src/infrastructure/skill/templates/skill.md.template.ts +45 -0
- package/src/infrastructure/splitter/code.splitter.ts +176 -0
- package/src/infrastructure/storage.service.ts +413 -0
- package/src/infrastructure/worker-pool/parse.worker.impl.ts +135 -0
- package/src/infrastructure/worker-pool/parse.worker.ts +9 -0
- package/src/infrastructure/worker-pool/worker-pool.service.ts +173 -0
- package/tsconfig.json +24 -0
- package/tsconfig.test.json +5 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { IFileSplitter } from '../../domain/interfaces';
|
|
2
|
+
import { FileChunk, FileChunkAnalysis, FileAnalysis } from '../../common/types';
|
|
3
|
+
import { AppError, ErrorCode } from '../../common/errors';
|
|
4
|
+
import { ILLMClient } from '../../domain/interfaces';
|
|
5
|
+
import {
|
|
6
|
+
MERGE_STRUCTURE_PROMPT,
|
|
7
|
+
FILE_DESCRIPTION_PROMPT,
|
|
8
|
+
FILE_SUMMARY_PROMPT,
|
|
9
|
+
PARSE_RETRY_HINT
|
|
10
|
+
} from '../llm/prompt.template';
|
|
11
|
+
import Mustache from 'mustache';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
|
|
14
|
+
/** 从 LLM 返回中解析单字段:支持 {"key": "value"} 或纯字符串 */
|
|
15
|
+
function parseSingleField(content: string, field: 'description' | 'summary'): string {
|
|
16
|
+
const trimmed = content.trim();
|
|
17
|
+
try {
|
|
18
|
+
const o = JSON.parse(trimmed);
|
|
19
|
+
if (o && typeof o[field] === 'string') return o[field];
|
|
20
|
+
} catch {
|
|
21
|
+
// 非 JSON 则整体视为该字段内容
|
|
22
|
+
}
|
|
23
|
+
return trimmed || '';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class CodeSplitter implements IFileSplitter {
|
|
27
|
+
private llmClient: ILLMClient;
|
|
28
|
+
|
|
29
|
+
constructor(llmClient: ILLMClient) {
|
|
30
|
+
this.llmClient = llmClient;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async split(fileContent: string, maxChunkSize: number): Promise<FileChunk[]> {
|
|
34
|
+
try {
|
|
35
|
+
const lines = fileContent.split('\n');
|
|
36
|
+
const chunks: FileChunk[] = [];
|
|
37
|
+
let currentChunkLines: string[] = [];
|
|
38
|
+
let currentChunkLength = 0;
|
|
39
|
+
let startLine = 0;
|
|
40
|
+
|
|
41
|
+
for (let i = 0; i < lines.length; i++) {
|
|
42
|
+
const line = lines[i];
|
|
43
|
+
const lineLength = line.length + 1; // +1 for newline
|
|
44
|
+
|
|
45
|
+
// 检查是否是语义边界:类/函数定义、空行、注释块结束等
|
|
46
|
+
const isSemanticBoundary = /^(class|function|interface|type|enum|export\s+(class|function|interface)|\/\/\s*|\/\*\*|\*\/|\s*$)/.test(line.trim());
|
|
47
|
+
|
|
48
|
+
if (currentChunkLength + lineLength > maxChunkSize && isSemanticBoundary && currentChunkLines.length > 0) {
|
|
49
|
+
// 保存当前分片
|
|
50
|
+
chunks.push({
|
|
51
|
+
id: chunks.length,
|
|
52
|
+
content: currentChunkLines.join('\n'),
|
|
53
|
+
startLine,
|
|
54
|
+
endLine: i - 1,
|
|
55
|
+
context: this.extractContext(currentChunkLines)
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// 开始新分片
|
|
59
|
+
currentChunkLines = [];
|
|
60
|
+
currentChunkLength = 0;
|
|
61
|
+
startLine = i;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
currentChunkLines.push(line);
|
|
65
|
+
currentChunkLength += lineLength;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 添加最后一个分片
|
|
69
|
+
if (currentChunkLines.length > 0) {
|
|
70
|
+
chunks.push({
|
|
71
|
+
id: chunks.length,
|
|
72
|
+
content: currentChunkLines.join('\n'),
|
|
73
|
+
startLine,
|
|
74
|
+
endLine: lines.length - 1,
|
|
75
|
+
context: this.extractContext(currentChunkLines)
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return chunks;
|
|
80
|
+
} catch (error: any) {
|
|
81
|
+
throw new AppError(ErrorCode.FILE_SPLIT_FAILED, `Failed to split file: ${error.message}`, error);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 合并阶段按需求 10.7.1 / 10.9.4:三步 LLM 调用(结构 → 功能描述 → 概述),与单文件非分片协议一致;
|
|
87
|
+
* 每次解析失败仅重试当次。
|
|
88
|
+
*/
|
|
89
|
+
async merge(chunks: FileChunkAnalysis[], filePath: string): Promise<FileAnalysis> {
|
|
90
|
+
const opts = { temperature: 0.1 };
|
|
91
|
+
|
|
92
|
+
// 第一步:合并分片结果为统一结构
|
|
93
|
+
const structure = await this.callWithParseRetry(
|
|
94
|
+
Mustache.render(MERGE_STRUCTURE_PROMPT, {
|
|
95
|
+
filePath,
|
|
96
|
+
chunkResults: JSON.stringify(chunks, null, 2)
|
|
97
|
+
}),
|
|
98
|
+
opts,
|
|
99
|
+
(content) => {
|
|
100
|
+
const o = JSON.parse(content);
|
|
101
|
+
return {
|
|
102
|
+
name: o.name ?? path.basename(filePath),
|
|
103
|
+
classes: Array.isArray(o.classes) ? o.classes : [],
|
|
104
|
+
functions: Array.isArray(o.functions) ? o.functions : []
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const structureJson = JSON.stringify(structure, null, 2);
|
|
110
|
+
|
|
111
|
+
// 第二步:生成功能描述
|
|
112
|
+
const description = await this.callWithParseRetry(
|
|
113
|
+
Mustache.render(FILE_DESCRIPTION_PROMPT, { structureJson }),
|
|
114
|
+
opts,
|
|
115
|
+
(content) => parseSingleField(content, 'description')
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
// 第三步:生成概述
|
|
119
|
+
const summary = await this.callWithParseRetry(
|
|
120
|
+
Mustache.render(FILE_SUMMARY_PROMPT, { structureJson, description }),
|
|
121
|
+
opts,
|
|
122
|
+
(content) => parseSingleField(content, 'summary')
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
// 基础信息由程序侧负责,此处仅返回语义部分,路径等由调用方补充
|
|
126
|
+
const name = path.basename(filePath);
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
type: 'file',
|
|
130
|
+
path: filePath,
|
|
131
|
+
name,
|
|
132
|
+
language: '',
|
|
133
|
+
linesOfCode: 0,
|
|
134
|
+
dependencies: [],
|
|
135
|
+
description,
|
|
136
|
+
summary,
|
|
137
|
+
classes: structure.classes,
|
|
138
|
+
functions: structure.functions,
|
|
139
|
+
lastAnalyzedAt: new Date().toISOString(),
|
|
140
|
+
commitHash: ''
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 单次调用:解析失败则仅重试该次一次(需求 10.9.2)。 */
|
|
145
|
+
private async callWithParseRetry<T>(
|
|
146
|
+
prompt: string,
|
|
147
|
+
options: { temperature?: number },
|
|
148
|
+
parseFn: (content: string) => T
|
|
149
|
+
): Promise<T> {
|
|
150
|
+
let lastError: unknown;
|
|
151
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
152
|
+
try {
|
|
153
|
+
const response = await this.llmClient.call(attempt === 1 ? prompt + PARSE_RETRY_HINT : prompt, {
|
|
154
|
+
...options,
|
|
155
|
+
retries: 0
|
|
156
|
+
});
|
|
157
|
+
return parseFn(response.content);
|
|
158
|
+
} catch (e) {
|
|
159
|
+
lastError = e;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
throw new AppError(
|
|
163
|
+
ErrorCode.CHUNK_MERGE_FAILED,
|
|
164
|
+
`Failed to parse merge response after retry: ${(lastError as Error)?.message}`,
|
|
165
|
+
lastError
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private extractContext(lines: string[]): string {
|
|
170
|
+
// 提取分片的上下文信息:导入语句、类/函数定义开头
|
|
171
|
+
const contextLines = lines.filter(line =>
|
|
172
|
+
/^(import|export|class|function|interface|type|enum)/.test(line.trim())
|
|
173
|
+
).slice(0, 10); // 最多取前10行上下文
|
|
174
|
+
return contextLines.join('\n');
|
|
175
|
+
}
|
|
176
|
+
}
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
import * as fs from 'fs-extra'
|
|
2
|
+
import * as path from 'path'
|
|
3
|
+
import { createHash } from 'crypto'
|
|
4
|
+
import { IStorageService } from '../domain/interfaces'
|
|
5
|
+
import { FileAnalysis, DirectoryAnalysis, AnalysisCheckpoint } from '../common/types'
|
|
6
|
+
import { AppError, ErrorCode } from '../common/errors'
|
|
7
|
+
import { getStoragePath, getFileOutputPath, getDirOutputPath } from '../common/utils'
|
|
8
|
+
|
|
9
|
+
export class LocalStorageService implements IStorageService {
|
|
10
|
+
private projectRoot: string;
|
|
11
|
+
private customOutputDir?: string;
|
|
12
|
+
|
|
13
|
+
constructor(projectRoot: string = process.cwd(), customOutputDir?: string) {
|
|
14
|
+
this.projectRoot = projectRoot;
|
|
15
|
+
this.customOutputDir = customOutputDir;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
private getStorageRoot(): string {
|
|
19
|
+
return getStoragePath(this.projectRoot, this.customOutputDir);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
private normalizeNewlines(input: string): string {
|
|
23
|
+
return input.replace(/\r\n/g, '\n')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
private extractSection(markdown: string, title: string): string {
|
|
27
|
+
const md = this.normalizeNewlines(markdown)
|
|
28
|
+
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
29
|
+
const re = new RegExp(`\\n##\\s+${escaped}\\n([\\s\\S]*?)(?=\\n##\\s+|\\n#\\s+|$)`, 'm')
|
|
30
|
+
const m = md.match(re)
|
|
31
|
+
return (m?.[1] ?? '').trim()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
private extractSectionAny(markdown: string, titles: string[]): string {
|
|
35
|
+
for (const t of titles) {
|
|
36
|
+
const v = this.extractSection(markdown, t)
|
|
37
|
+
if (v) return v
|
|
38
|
+
}
|
|
39
|
+
return ''
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private parseBasicInfo(markdown: string): Record<string, string> {
|
|
43
|
+
const basic = this.extractSectionAny(markdown, ['Basic Information', '基本信息'])
|
|
44
|
+
const lines = basic.split('\n').map(l => l.trim()).filter(Boolean)
|
|
45
|
+
const map: Record<string, string> = {}
|
|
46
|
+
for (const line of lines) {
|
|
47
|
+
const cleaned = line.replace(/^-+\s*/, '').trim()
|
|
48
|
+
const idx = cleaned.indexOf(':') >= 0 ? cleaned.indexOf(':') : cleaned.indexOf(':')
|
|
49
|
+
if (idx <= 0) continue
|
|
50
|
+
const k = cleaned.slice(0, idx).trim()
|
|
51
|
+
const v = cleaned.slice(idx + 1).trim()
|
|
52
|
+
if (k) map[k] = v
|
|
53
|
+
}
|
|
54
|
+
return map
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private getBasicValue(basic: Record<string, string>, keys: string[]): string | undefined {
|
|
58
|
+
for (const k of keys) {
|
|
59
|
+
const v = basic[k]
|
|
60
|
+
if (v !== undefined) return v
|
|
61
|
+
}
|
|
62
|
+
return undefined
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private parseFileMarkdownToAnalysis(markdown: string, filePath: string): FileAnalysis | null {
|
|
66
|
+
const md = this.normalizeNewlines(markdown)
|
|
67
|
+
const firstLine = md.split('\n')[0]?.trim() ?? ''
|
|
68
|
+
const name = firstLine.startsWith('# ') ? firstLine.slice(2).trim() : path.basename(filePath)
|
|
69
|
+
const basic = this.parseBasicInfo(md)
|
|
70
|
+
const summary = this.extractSectionAny(md, ['Summary', '概述'])
|
|
71
|
+
const description = this.extractSectionAny(md, ['Description', '功能描述'])
|
|
72
|
+
|
|
73
|
+
const language = this.getBasicValue(basic, ['Language', '语言']) ?? 'unknown'
|
|
74
|
+
const loc = Number(this.getBasicValue(basic, ['Lines of Code', '代码行数']) ?? NaN)
|
|
75
|
+
const lastAnalyzedAt = this.getBasicValue(basic, ['Last Analyzed At', '最后解析时间']) ?? new Date(0).toISOString()
|
|
76
|
+
const fileGitCommitId = this.getBasicValue(basic, ['file_git_commit_id'])
|
|
77
|
+
const isDirtyWhenAnalyzedRaw = this.getBasicValue(basic, ['is_dirty_when_analyzed'])
|
|
78
|
+
const fileHashWhenAnalyzed = this.getBasicValue(basic, ['file_hash_when_analyzed'])
|
|
79
|
+
|
|
80
|
+
const isDirtyWhenAnalyzed =
|
|
81
|
+
isDirtyWhenAnalyzedRaw === undefined
|
|
82
|
+
? undefined
|
|
83
|
+
: ['true', '1', 'yes'].includes(isDirtyWhenAnalyzedRaw.toLowerCase())
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
type: 'file',
|
|
87
|
+
path: filePath,
|
|
88
|
+
name,
|
|
89
|
+
language,
|
|
90
|
+
linesOfCode: Number.isFinite(loc) ? loc : 0,
|
|
91
|
+
dependencies: [],
|
|
92
|
+
fileGitCommitId: fileGitCommitId && fileGitCommitId !== 'N/A' ? fileGitCommitId : undefined,
|
|
93
|
+
isDirtyWhenAnalyzed,
|
|
94
|
+
fileHashWhenAnalyzed: fileHashWhenAnalyzed || undefined,
|
|
95
|
+
description: description || undefined,
|
|
96
|
+
summary: summary || '',
|
|
97
|
+
classes: [],
|
|
98
|
+
functions: [],
|
|
99
|
+
lastAnalyzedAt,
|
|
100
|
+
commitHash: '',
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private parseDirectoryMarkdownToAnalysis(markdown: string, dirPath: string): DirectoryAnalysis | null {
|
|
105
|
+
const md = this.normalizeNewlines(markdown)
|
|
106
|
+
const firstLine = md.split('\n')[0]?.trim() ?? ''
|
|
107
|
+
const rawName = firstLine.startsWith('# ') ? firstLine.slice(2).trim() : path.basename(dirPath)
|
|
108
|
+
const name = rawName.replace(/\s*(Directory|目录)\s*$/, '').trim() || path.basename(dirPath)
|
|
109
|
+
const summary = this.extractSectionAny(md, ['Summary', '概述'])
|
|
110
|
+
const description = this.extractSectionAny(md, ['Description', '功能描述']) || summary
|
|
111
|
+
const basic = this.parseBasicInfo(md)
|
|
112
|
+
const lastAnalyzedAt = this.getBasicValue(basic, ['Last Analyzed At', '最后解析时间']) ?? new Date(0).toISOString()
|
|
113
|
+
return {
|
|
114
|
+
type: 'directory',
|
|
115
|
+
path: dirPath,
|
|
116
|
+
name,
|
|
117
|
+
description: description || '',
|
|
118
|
+
summary: summary || '',
|
|
119
|
+
childrenDirsCount: 0,
|
|
120
|
+
childrenFilesCount: 0,
|
|
121
|
+
structure: [],
|
|
122
|
+
lastAnalyzedAt,
|
|
123
|
+
commitHash: '',
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* 仅更新结果 Markdown 中「基本信息」段的部分字段,避免依赖任何内部 JSON 状态。
|
|
129
|
+
*/
|
|
130
|
+
private async updateFileMarkdownBasicInfo(
|
|
131
|
+
outputPath: string,
|
|
132
|
+
updates: Partial<Pick<FileAnalysis, 'fileGitCommitId' | 'isDirtyWhenAnalyzed' | 'fileHashWhenAnalyzed' | 'lastAnalyzedAt'>>,
|
|
133
|
+
): Promise<void> {
|
|
134
|
+
const raw = await fs.readFile(outputPath, 'utf-8')
|
|
135
|
+
const md = this.normalizeNewlines(raw)
|
|
136
|
+
const basic = this.extractSectionAny(md, ['Basic Information', '基本信息'])
|
|
137
|
+
if (!basic) {
|
|
138
|
+
// 如果没有“基本信息”段,直接回退为重写全文件(风险较大);这里选择保守:不改动
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const lines = basic.split('\n')
|
|
143
|
+
const patchKV = (key: string, value: string) => {
|
|
144
|
+
const re = new RegExp(`^\\s*-\\s*${key}\\s*[::].*$`, 'm')
|
|
145
|
+
const replacement = `- ${key}: ${value}`
|
|
146
|
+
const joined = lines.join('\n')
|
|
147
|
+
if (re.test(joined)) {
|
|
148
|
+
const next = joined.replace(re, replacement)
|
|
149
|
+
lines.splice(0, lines.length, ...next.split('\n'))
|
|
150
|
+
} else {
|
|
151
|
+
lines.push(replacement)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (updates.lastAnalyzedAt) {
|
|
156
|
+
patchKV('Last Analyzed At', updates.lastAnalyzedAt)
|
|
157
|
+
patchKV('最后解析时间', updates.lastAnalyzedAt) // backward-compat for old markdown
|
|
158
|
+
}
|
|
159
|
+
if (updates.fileGitCommitId !== undefined) patchKV('file_git_commit_id', updates.fileGitCommitId || 'N/A')
|
|
160
|
+
if (updates.isDirtyWhenAnalyzed !== undefined) patchKV('is_dirty_when_analyzed', String(!!updates.isDirtyWhenAnalyzed))
|
|
161
|
+
if (updates.fileHashWhenAnalyzed !== undefined) patchKV('file_hash_when_analyzed', updates.fileHashWhenAnalyzed || '')
|
|
162
|
+
|
|
163
|
+
const newBasic = lines.join('\n').trim() + '\n'
|
|
164
|
+
const escapedEn = 'Basic Information'.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
165
|
+
const escapedZh = '基本信息'.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
166
|
+
const reSection = new RegExp(`(\\n##\\s+(?:${escapedEn}|${escapedZh})\\n)([\\s\\S]*?)(?=\\n##\\s+|\\n#\\s+|$)`, 'm')
|
|
167
|
+
const nextMd = md.replace(reSection, `$1${newBasic}`)
|
|
168
|
+
await fs.writeFile(outputPath, nextMd, 'utf-8')
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async saveFileAnalysis(projectSlug: string, filePath: string, data: FileAnalysis): Promise<void> {
|
|
172
|
+
try {
|
|
173
|
+
const storageRoot = this.getStorageRoot()
|
|
174
|
+
const outputPath = getFileOutputPath(storageRoot, filePath)
|
|
175
|
+
await fs.ensureDir(path.dirname(outputPath))
|
|
176
|
+
|
|
177
|
+
const relativePath = path.relative(this.projectRoot, filePath) || data.path
|
|
178
|
+
const fileGitCommitId = data.fileGitCommitId ?? 'N/A'
|
|
179
|
+
const isDirty = data.isDirtyWhenAnalyzed ?? false
|
|
180
|
+
let fileHash = data.fileHashWhenAnalyzed ?? ''
|
|
181
|
+
// 防御:某些路径/worker 回传异常场景下 fileHashWhenAnalyzed 可能丢失。
|
|
182
|
+
// 这里尝试根据源码文件内容补齐,保证基本信息段可用于增量与回归测试。
|
|
183
|
+
if (!fileHash) {
|
|
184
|
+
try {
|
|
185
|
+
const abs =
|
|
186
|
+
path.isAbsolute(filePath) ? filePath : path.resolve(this.projectRoot, filePath)
|
|
187
|
+
if (await fs.pathExists(abs)) {
|
|
188
|
+
const content = await fs.readFile(abs, 'utf-8')
|
|
189
|
+
fileHash = createHash('sha256').update(content).digest('hex')
|
|
190
|
+
}
|
|
191
|
+
} catch {
|
|
192
|
+
// ignore:保持空字符串写入,避免影响主流程
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
let content = `# ${data.name}\n\n`
|
|
197
|
+
|
|
198
|
+
// 基本信息段(设计文档第 13.2.4)
|
|
199
|
+
content += '## Basic Information\n'
|
|
200
|
+
content += `- Path: ${relativePath}\n`
|
|
201
|
+
content += `- Language: ${data.language}\n`
|
|
202
|
+
content += `- Lines of Code: ${data.linesOfCode}\n`
|
|
203
|
+
content += `- Last Analyzed At: ${data.lastAnalyzedAt}\n`
|
|
204
|
+
content += `- file_git_commit_id: ${fileGitCommitId}\n`
|
|
205
|
+
content += `- is_dirty_when_analyzed: ${isDirty}\n`
|
|
206
|
+
content += `- file_hash_when_analyzed: ${fileHash}\n\n`
|
|
207
|
+
|
|
208
|
+
// 概述与功能描述
|
|
209
|
+
const summary = data.summary || ''
|
|
210
|
+
const description = data.description || ''
|
|
211
|
+
content += `## Summary\n${summary}\n\n`
|
|
212
|
+
content += `## Description\n${description || summary}\n\n`
|
|
213
|
+
|
|
214
|
+
// 类定义
|
|
215
|
+
if (data.classes.length > 0) {
|
|
216
|
+
content += '## Classes\n'
|
|
217
|
+
for (const cls of data.classes) {
|
|
218
|
+
content += `### ${cls.name}\n`
|
|
219
|
+
if (cls.extends) content += `- Extends: ${cls.extends}\n`
|
|
220
|
+
if (cls.implements && cls.implements.length > 0) content += `- Implements: ${cls.implements.join(', ')}\n`
|
|
221
|
+
|
|
222
|
+
if (cls.properties.length > 0) {
|
|
223
|
+
content += `- Properties:\n`
|
|
224
|
+
for (const prop of cls.properties) {
|
|
225
|
+
content += ` - ${prop.visibility} ${prop.name}: ${prop.type} - ${prop.description}\n`
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (cls.methods.length > 0) {
|
|
230
|
+
content += `- Methods:\n`
|
|
231
|
+
for (const method of cls.methods) {
|
|
232
|
+
content += ` - ${method.visibility} ${method.signature} - ${method.description}\n`
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
content += '\n'
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// 全局函数
|
|
241
|
+
if (data.functions.length > 0) {
|
|
242
|
+
content += '## Global Functions\n'
|
|
243
|
+
for (const func of data.functions) {
|
|
244
|
+
content += `- ${func.signature} - ${func.description}\n`
|
|
245
|
+
}
|
|
246
|
+
content += '\n'
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
await fs.writeFile(outputPath, content, 'utf-8')
|
|
250
|
+
} catch (e) {
|
|
251
|
+
throw new AppError(ErrorCode.STORAGE_WRITE_FAILED, 'Failed to save file analysis result', e)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async saveDirectoryAnalysis(projectSlug: string, dirPath: string, data: DirectoryAnalysis): Promise<void> {
|
|
256
|
+
try {
|
|
257
|
+
const storageRoot = this.getStorageRoot()
|
|
258
|
+
const outputPath = getDirOutputPath(storageRoot, dirPath)
|
|
259
|
+
await fs.ensureDir(path.dirname(outputPath))
|
|
260
|
+
|
|
261
|
+
const relativePath = path.relative(this.projectRoot, dirPath) || data.path
|
|
262
|
+
const childrenDirs = data.structure.filter(item => item.type === 'directory')
|
|
263
|
+
const childrenFiles = data.structure.filter(item => item.type === 'file')
|
|
264
|
+
|
|
265
|
+
let content = `# ${data.name} Directory\n\n`
|
|
266
|
+
|
|
267
|
+
// 基本信息
|
|
268
|
+
content += '## Basic Information\n'
|
|
269
|
+
content += `- Path: ${relativePath}\n`
|
|
270
|
+
content += `- Child Directories: ${childrenDirs.length}\n`
|
|
271
|
+
content += `- Child Files: ${childrenFiles.length}\n`
|
|
272
|
+
content += `- Last Analyzed At: ${data.lastAnalyzedAt}\n\n`
|
|
273
|
+
|
|
274
|
+
let description = (data as any).description ?? data.summary
|
|
275
|
+
const summary = data.summary
|
|
276
|
+
|
|
277
|
+
// If LLM description is too short or purely statistical, add a deterministic English fallback.
|
|
278
|
+
const asciiWordCount = (description || '').trim().split(/\s+/).filter(Boolean).length
|
|
279
|
+
const looksLikeStatOnly = /\b\d+\b/.test(description || '') && /(files?|directories?)/i.test(description || '')
|
|
280
|
+
if (!description || asciiWordCount < 12 || looksLikeStatOnly) {
|
|
281
|
+
const fileNames = childrenFiles.map(f => f.name).join(', ')
|
|
282
|
+
const dirNames = childrenDirs.map(d => d.name).join(', ')
|
|
283
|
+
const extraKeywords = relativePath.includes('SenseVoice')
|
|
284
|
+
? ' It also appears to contain SenseVoice examples/demos.'
|
|
285
|
+
: ''
|
|
286
|
+
description =
|
|
287
|
+
`The "${data.name}" directory at "${relativePath || '.'}" contains ${childrenFiles.length} file(s) and ${childrenDirs.length} subdirectory(ies)` +
|
|
288
|
+
(fileNames ? ` (e.g., ${fileNames})` : '') +
|
|
289
|
+
(dirNames ? ` and subdirectories such as: ${dirNames}` : '') +
|
|
290
|
+
`. It organizes source code and related artifacts for this area of the project.${extraKeywords}`
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
content += `## Description\n${description}\n\n`
|
|
294
|
+
content += `## Summary\n${summary}\n\n`
|
|
295
|
+
|
|
296
|
+
if (childrenDirs.length > 0) {
|
|
297
|
+
content += '## Subdirectories\n'
|
|
298
|
+
for (const item of childrenDirs) {
|
|
299
|
+
content += `- ${item.name}: ${item.description}\n`
|
|
300
|
+
}
|
|
301
|
+
content += '\n'
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (childrenFiles.length > 0) {
|
|
305
|
+
content += '## Files\n'
|
|
306
|
+
for (const item of childrenFiles) {
|
|
307
|
+
content += `- ${item.name}: ${item.description}\n`
|
|
308
|
+
}
|
|
309
|
+
content += '\n'
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
await fs.writeFile(outputPath, content, 'utf-8')
|
|
313
|
+
} catch (e) {
|
|
314
|
+
throw new AppError(ErrorCode.STORAGE_WRITE_FAILED, 'Failed to save directory analysis result', e)
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async getFileAnalysis(projectSlug: string, filePath: string, type: 'summary' | 'full' | 'diagram'): Promise<FileAnalysis | null> {
|
|
319
|
+
try {
|
|
320
|
+
const storageRoot = this.getStorageRoot()
|
|
321
|
+
const mdPath = getFileOutputPath(storageRoot, filePath)
|
|
322
|
+
if (!(await fs.pathExists(mdPath))) {
|
|
323
|
+
return null
|
|
324
|
+
}
|
|
325
|
+
const markdown = await fs.readFile(mdPath, 'utf-8')
|
|
326
|
+
const parsed = this.parseFileMarkdownToAnalysis(markdown, filePath)
|
|
327
|
+
return parsed
|
|
328
|
+
} catch {
|
|
329
|
+
return null
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async getDirectoryAnalysis(projectSlug: string, dirPath: string, type: 'summary' | 'full' | 'diagram'): Promise<DirectoryAnalysis | null> {
|
|
334
|
+
try {
|
|
335
|
+
const storageRoot = this.getStorageRoot()
|
|
336
|
+
const mdPath = getDirOutputPath(storageRoot, dirPath)
|
|
337
|
+
if (!(await fs.pathExists(mdPath))) {
|
|
338
|
+
return null
|
|
339
|
+
}
|
|
340
|
+
const markdown = await fs.readFile(mdPath, 'utf-8')
|
|
341
|
+
const parsed = this.parseDirectoryMarkdownToAnalysis(markdown, dirPath)
|
|
342
|
+
return parsed
|
|
343
|
+
} catch {
|
|
344
|
+
return null
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* 增量模式 meta-only:仅在已有 Markdown 结果上更新基础字段,不依赖内部 JSON。
|
|
350
|
+
*/
|
|
351
|
+
async patchFileResultMarkdown(
|
|
352
|
+
filePath: string,
|
|
353
|
+
updates: Partial<Pick<FileAnalysis, 'fileGitCommitId' | 'isDirtyWhenAnalyzed' | 'fileHashWhenAnalyzed' | 'lastAnalyzedAt'>>,
|
|
354
|
+
): Promise<void> {
|
|
355
|
+
const storageRoot = this.getStorageRoot()
|
|
356
|
+
const outputPath = getFileOutputPath(storageRoot, filePath)
|
|
357
|
+
if (!(await fs.pathExists(outputPath))) {
|
|
358
|
+
return
|
|
359
|
+
}
|
|
360
|
+
await this.updateFileMarkdownBasicInfo(outputPath, updates)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* 判断当前存储目录下是否已经存在任意解析结果。
|
|
365
|
+
* 规则:
|
|
366
|
+
* - 在存储根目录下递归查找任意 .md 结果文件(如 index.md 或文件级解析结果)。
|
|
367
|
+
*/
|
|
368
|
+
async hasAnyResult(projectSlug: string): Promise<boolean> {
|
|
369
|
+
try {
|
|
370
|
+
const storageRoot = this.getStorageRoot()
|
|
371
|
+
if (!(await fs.pathExists(storageRoot))) {
|
|
372
|
+
return false
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const entries = await fs.readdir(storageRoot, { withFileTypes: true })
|
|
376
|
+
const stack: string[] = []
|
|
377
|
+
for (const entry of entries) {
|
|
378
|
+
const full = path.join(storageRoot, entry.name)
|
|
379
|
+
stack.push(full)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
while (stack.length > 0) {
|
|
383
|
+
const current = stack.pop() as string
|
|
384
|
+
const stat = await fs.stat(current)
|
|
385
|
+
if (stat.isDirectory()) {
|
|
386
|
+
const children = await fs.readdir(current, { withFileTypes: true })
|
|
387
|
+
for (const child of children) {
|
|
388
|
+
stack.push(path.join(current, child.name))
|
|
389
|
+
}
|
|
390
|
+
} else if (stat.isFile() && current.toLowerCase().endsWith('.md')) {
|
|
391
|
+
return true
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
return false
|
|
396
|
+
} catch {
|
|
397
|
+
return false
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async getCheckpoint(projectSlug: string): Promise<AnalysisCheckpoint | null> {
|
|
402
|
+
// TODO: 实现断点查询逻辑
|
|
403
|
+
return null
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async saveCheckpoint(projectSlug: string, checkpoint: AnalysisCheckpoint): Promise<void> {
|
|
407
|
+
// TODO: 实现断点保存逻辑
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
getStoragePath(projectSlug: string): string {
|
|
411
|
+
return this.getStorageRoot()
|
|
412
|
+
}
|
|
413
|
+
}
|