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,50 @@
|
|
|
1
|
+
import { IIncrementalService, IGitService, IStorageService } from '../interfaces'
|
|
2
|
+
|
|
3
|
+
export class IncrementalService implements IIncrementalService {
|
|
4
|
+
constructor(
|
|
5
|
+
private gitService: IGitService,
|
|
6
|
+
private storageService: IStorageService
|
|
7
|
+
) {}
|
|
8
|
+
|
|
9
|
+
async canDoIncremental(projectRoot: string): Promise<{ available: boolean; baseCommit?: string; reason?: string }> {
|
|
10
|
+
const isGit = await this.gitService.isGitProject(projectRoot)
|
|
11
|
+
if (!isGit) return { available: false, reason: 'Not a git project' }
|
|
12
|
+
|
|
13
|
+
// V2.6:不再生成/读取 .analysis_metadata.json,因此无法基于历史记录计算可用的 baseCommit。
|
|
14
|
+
// 主流程会回退到逐文件 commitId/hash 判定,无需依赖此服务。
|
|
15
|
+
return { available: false, reason: 'Metadata disabled (V2.6): fallback to per-file incremental detection' }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async getChangedFiles(projectRoot: string, baseCommit: string, targetCommit: string): Promise<string[]> {
|
|
19
|
+
return this.gitService.diffCommits(projectRoot, baseCommit, targetCommit)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async findNearestCommonAncestor(projectRoot: string, commits: string[]): Promise<string | null> {
|
|
23
|
+
try {
|
|
24
|
+
const { simpleGit } = await import('simple-git')
|
|
25
|
+
const git = simpleGit(projectRoot)
|
|
26
|
+
const commonAncestor = await git.raw(['merge-base', '--octopus', ...commits])
|
|
27
|
+
return commonAncestor.trim() || null
|
|
28
|
+
} catch (e) {
|
|
29
|
+
return null
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
getAffectedDirectories(changedFiles: string[]): string[] {
|
|
34
|
+
const directories = new Set<string>()
|
|
35
|
+
|
|
36
|
+
changedFiles.forEach(file => {
|
|
37
|
+
const parts = file.split('/')
|
|
38
|
+
for (let i = 1; i < parts.length; i++) {
|
|
39
|
+
const dir = parts.slice(0, i).join('/')
|
|
40
|
+
if (dir) {
|
|
41
|
+
directories.add(dir)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
directories.add('')
|
|
47
|
+
|
|
48
|
+
return Array.from(directories)
|
|
49
|
+
}
|
|
50
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import ignore, { Ignore } from 'ignore'
|
|
2
|
+
import * as fs from 'fs-extra'
|
|
3
|
+
import * as path from 'path'
|
|
4
|
+
import { IBlacklistService } from '../domain/interfaces'
|
|
5
|
+
|
|
6
|
+
export class BlacklistService implements IBlacklistService {
|
|
7
|
+
private ig: Ignore = ignore()
|
|
8
|
+
|
|
9
|
+
async load(globalBlacklist: string[], projectRoot: string): Promise<void> {
|
|
10
|
+
this.ig = ignore()
|
|
11
|
+
|
|
12
|
+
this.ig.add(globalBlacklist)
|
|
13
|
+
|
|
14
|
+
const projectIgnorePath = path.join(projectRoot, '.skill-any-code-ignore')
|
|
15
|
+
if (await fs.pathExists(projectIgnorePath)) {
|
|
16
|
+
const content = await fs.readFile(projectIgnorePath, 'utf-8')
|
|
17
|
+
this.ig.add(content)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const gitignorePath = path.join(projectRoot, '.gitignore')
|
|
21
|
+
if (await fs.pathExists(gitignorePath)) {
|
|
22
|
+
const content = await fs.readFile(gitignorePath, 'utf-8')
|
|
23
|
+
this.ig.add(content)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
isIgnored(relativePath: string): boolean {
|
|
28
|
+
// 1. 统一为正斜杠(Windows path.relative 可能返回反斜杠)
|
|
29
|
+
let normalized = relativePath.replace(/\\/g, '/')
|
|
30
|
+
// 2. 去掉 leading ./ 或 /(ignore 库拒绝此类路径并抛错,见 REGEX_TEST_INVALID_PATH)
|
|
31
|
+
// path.relative 在部分 Windows 场景下可能返回 ".\" 前缀,归一化后为 "./",会导致 ignore 库抛出 RangeError
|
|
32
|
+
normalized = normalized.replace(/^\.\//, '').replace(/^\/+/, '')
|
|
33
|
+
// ignore 库不接受空字符串路径;空路径表示“项目根自身”,这里视为不忽略
|
|
34
|
+
if (!normalized) return false
|
|
35
|
+
return this.ig.ignores(normalized)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { IAnalysisCache } from '../../domain/interfaces';
|
|
2
|
+
import { FileAnalysis } from '../../common/types';
|
|
3
|
+
import * as fs from 'fs-extra';
|
|
4
|
+
import * as path from 'path';
|
|
5
|
+
import { createHash } from 'crypto';
|
|
6
|
+
import { logger } from '../../common/logger';
|
|
7
|
+
|
|
8
|
+
interface FileHashCacheOptions {
|
|
9
|
+
cacheDir: string;
|
|
10
|
+
maxSizeMb: number; // 0 表示禁用(V2.5)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class FileHashCache implements IAnalysisCache {
|
|
14
|
+
private cacheDir: string;
|
|
15
|
+
private maxSizeMb: number;
|
|
16
|
+
|
|
17
|
+
constructor(options: FileHashCacheOptions) {
|
|
18
|
+
this.cacheDir = options.cacheDir;
|
|
19
|
+
this.maxSizeMb = options.maxSizeMb;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
private async getDirSize(): Promise<number> {
|
|
23
|
+
try {
|
|
24
|
+
const files = await fs.readdir(this.cacheDir);
|
|
25
|
+
let total = 0;
|
|
26
|
+
for (const f of files) {
|
|
27
|
+
const full = path.join(this.cacheDir, f);
|
|
28
|
+
const stat = await fs.stat(full);
|
|
29
|
+
if (stat.isFile()) {
|
|
30
|
+
total += stat.size;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return total;
|
|
34
|
+
} catch {
|
|
35
|
+
return 0;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private async enforceLimit(): Promise<void> {
|
|
40
|
+
if (this.maxSizeMb <= 0) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
await fs.ensureDir(this.cacheDir);
|
|
45
|
+
|
|
46
|
+
const maxBytes = this.maxSizeMb * 1024 * 1024;
|
|
47
|
+
let total = await this.getDirSize();
|
|
48
|
+
if (total <= maxBytes) return;
|
|
49
|
+
|
|
50
|
+
const entries = await fs.readdir(this.cacheDir);
|
|
51
|
+
const fileStats: { filePath: string; mtimeMs: number; size: number }[] = [];
|
|
52
|
+
|
|
53
|
+
for (const name of entries) {
|
|
54
|
+
const filePath = path.join(this.cacheDir, name);
|
|
55
|
+
const stat = await fs.stat(filePath);
|
|
56
|
+
if (stat.isFile()) {
|
|
57
|
+
fileStats.push({ filePath, mtimeMs: stat.mtimeMs, size: stat.size });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
fileStats.sort((a, b) => a.mtimeMs - b.mtimeMs);
|
|
62
|
+
|
|
63
|
+
for (const f of fileStats) {
|
|
64
|
+
if (total <= maxBytes) break;
|
|
65
|
+
try {
|
|
66
|
+
await fs.remove(f.filePath);
|
|
67
|
+
total -= f.size;
|
|
68
|
+
} catch (e) {
|
|
69
|
+
logger.warn(`Failed to delete cache file: ${f.filePath}`, e);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async get(fileHash: string): Promise<FileAnalysis | null> {
|
|
75
|
+
if (this.maxSizeMb === 0) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
const cachePath = path.join(this.cacheDir, `${fileHash}.json`);
|
|
79
|
+
try {
|
|
80
|
+
if (await fs.pathExists(cachePath)) {
|
|
81
|
+
const data = await fs.readJSON(cachePath);
|
|
82
|
+
return data as FileAnalysis;
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async set(fileHash: string, result: FileAnalysis): Promise<void> {
|
|
91
|
+
if (this.maxSizeMb === 0) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
await fs.ensureDir(this.cacheDir);
|
|
97
|
+
await this.enforceLimit();
|
|
98
|
+
|
|
99
|
+
const cachePath = path.join(this.cacheDir, `${fileHash}.json`);
|
|
100
|
+
await fs.writeJSON(cachePath, result, { spaces: 2 });
|
|
101
|
+
} catch (error) {
|
|
102
|
+
// 缓存写入失败不影响主流程
|
|
103
|
+
logger.warn('Failed to write cache:', error);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async clear(fileHash?: string): Promise<void> {
|
|
108
|
+
if (fileHash) {
|
|
109
|
+
const cachePath = path.join(this.cacheDir, `${fileHash}.json`);
|
|
110
|
+
await fs.remove(cachePath);
|
|
111
|
+
} else {
|
|
112
|
+
await fs.emptyDir(this.cacheDir);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
static calculateFileHash(content: string): string {
|
|
117
|
+
return createHash('sha256').update(content).digest('hex');
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import simpleGit, { SimpleGit } from 'simple-git'
|
|
2
|
+
import { IGitService } from '../../domain/interfaces'
|
|
3
|
+
import { AppError, ErrorCode } from '../../common/errors'
|
|
4
|
+
import * as path from 'path'
|
|
5
|
+
|
|
6
|
+
export class GitService implements IGitService {
|
|
7
|
+
private gitInstances: Map<string, SimpleGit> = new Map()
|
|
8
|
+
|
|
9
|
+
private getGitInstance(projectRoot: string): SimpleGit {
|
|
10
|
+
if (!this.gitInstances.has(projectRoot)) {
|
|
11
|
+
this.gitInstances.set(projectRoot, simpleGit(projectRoot))
|
|
12
|
+
}
|
|
13
|
+
return this.gitInstances.get(projectRoot)!
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async isGitProject(projectRoot: string): Promise<boolean> {
|
|
17
|
+
try {
|
|
18
|
+
const git = this.getGitInstance(projectRoot)
|
|
19
|
+
await git.revparse(['--is-inside-work-tree'])
|
|
20
|
+
return true
|
|
21
|
+
} catch (e) {
|
|
22
|
+
return false
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async getCurrentCommit(projectRoot: string): Promise<string> {
|
|
27
|
+
try {
|
|
28
|
+
const git = this.getGitInstance(projectRoot)
|
|
29
|
+
const commitHash = await git.revparse(['HEAD'])
|
|
30
|
+
return commitHash.trim()
|
|
31
|
+
} catch (e) {
|
|
32
|
+
throw new AppError(ErrorCode.GIT_OPERATION_FAILED, 'Failed to get current commit', (e as Error).message)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async getCurrentBranch(projectRoot: string): Promise<string> {
|
|
37
|
+
try {
|
|
38
|
+
const git = this.getGitInstance(projectRoot)
|
|
39
|
+
const branch = await git.revparse(['--abbrev-ref', 'HEAD'])
|
|
40
|
+
return branch.trim()
|
|
41
|
+
} catch (e) {
|
|
42
|
+
throw new AppError(ErrorCode.GIT_OPERATION_FAILED, 'Failed to get current branch', (e as Error).message)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async getProjectSlug(projectRoot: string): Promise<string> {
|
|
47
|
+
try {
|
|
48
|
+
const git = this.getGitInstance(projectRoot)
|
|
49
|
+
const remoteUrl = await git.remote(['get-url', 'origin'])
|
|
50
|
+
const url = (remoteUrl || '').trim()
|
|
51
|
+
|
|
52
|
+
// 处理SSH和HTTPS格式的URL
|
|
53
|
+
let slug: string
|
|
54
|
+
if (url.startsWith('git@')) {
|
|
55
|
+
slug = url.split(':')[1].replace('.git', '')
|
|
56
|
+
} else if (url.startsWith('http')) {
|
|
57
|
+
const parts = new URL(url).pathname.split('/')
|
|
58
|
+
slug = `${parts[1]}/${parts[2].replace('.git', '')}`
|
|
59
|
+
} else {
|
|
60
|
+
// 如果没有远程仓库,使用目录名作为slug
|
|
61
|
+
slug = path.basename(projectRoot)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return slug.toLowerCase().replace(/[^a-z0-9-_/]/g, '-')
|
|
65
|
+
} catch (e) {
|
|
66
|
+
// 如果获取远程URL失败,使用目录名作为slug
|
|
67
|
+
return path.basename(projectRoot).toLowerCase().replace(/[^a-z0-9-_]/g, '-')
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async getUncommittedChanges(projectRoot: string): Promise<string[]> {
|
|
72
|
+
try {
|
|
73
|
+
const git = this.getGitInstance(projectRoot)
|
|
74
|
+
const status = await git.status()
|
|
75
|
+
return [...status.modified, ...status.created, ...status.deleted, ...status.renamed.map(r => r.to)]
|
|
76
|
+
} catch (e) {
|
|
77
|
+
throw new AppError(ErrorCode.GIT_OPERATION_FAILED, 'Failed to get uncommitted changes', (e as Error).message)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async diffCommits(projectRoot: string, commit1: string, commit2: string): Promise<string[]> {
|
|
82
|
+
try {
|
|
83
|
+
const git = this.getGitInstance(projectRoot)
|
|
84
|
+
const diff = await git.diff(['--name-only', commit1, commit2])
|
|
85
|
+
return diff.trim().split('\n').filter(line => line.length > 0)
|
|
86
|
+
} catch (e) {
|
|
87
|
+
throw new AppError(ErrorCode.GIT_OPERATION_FAILED, 'Failed to diff commits', (e as Error).message)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async getFileLastCommit(projectRoot: string, filePath: string): Promise<string | null> {
|
|
92
|
+
try {
|
|
93
|
+
const git = this.getGitInstance(projectRoot)
|
|
94
|
+
const result = await git.raw(['log', '-n', '1', '--pretty=format:%H', '--', filePath])
|
|
95
|
+
const hash = result.trim()
|
|
96
|
+
return hash || null
|
|
97
|
+
} catch {
|
|
98
|
+
// 对于未纳入 Git 管理或无提交记录的文件,返回 null
|
|
99
|
+
return null
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async isFileDirty(projectRoot: string, filePath: string): Promise<boolean> {
|
|
104
|
+
try {
|
|
105
|
+
const git = this.getGitInstance(projectRoot)
|
|
106
|
+
const status = await git.status()
|
|
107
|
+
const normalizedPath = filePath.replace(/\\/g, '/')
|
|
108
|
+
|
|
109
|
+
if (status.modified.includes(normalizedPath)) return true
|
|
110
|
+
if (status.created.includes(normalizedPath)) return true
|
|
111
|
+
if (status.deleted.includes(normalizedPath)) return true
|
|
112
|
+
if (status.renamed.some(r => r.to === normalizedPath || r.from === normalizedPath)) return true
|
|
113
|
+
|
|
114
|
+
return false
|
|
115
|
+
} catch {
|
|
116
|
+
// Git 不可用时视为非 dirty,交由上层逻辑处理
|
|
117
|
+
return false
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import simpleGit, { SimpleGit } from 'simple-git'
|
|
2
|
+
import * as fs from 'fs-extra'
|
|
3
|
+
import { IGitService } from '../domain/interfaces'
|
|
4
|
+
import { AppError, ErrorCode } from '../common/errors'
|
|
5
|
+
|
|
6
|
+
export class GitService implements IGitService {
|
|
7
|
+
private git?: SimpleGit
|
|
8
|
+
private projectRoot: string
|
|
9
|
+
|
|
10
|
+
constructor(projectRoot: string) {
|
|
11
|
+
this.projectRoot = projectRoot
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
private getGit(): SimpleGit {
|
|
15
|
+
if (!this.git) {
|
|
16
|
+
this.git = simpleGit(this.projectRoot)
|
|
17
|
+
}
|
|
18
|
+
return this.git
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async isGitProject(): Promise<boolean> {
|
|
22
|
+
try {
|
|
23
|
+
// 先判断目录是否存在
|
|
24
|
+
if (!(await fs.pathExists(this.projectRoot))) {
|
|
25
|
+
return false
|
|
26
|
+
}
|
|
27
|
+
return await this.getGit().checkIsRepo()
|
|
28
|
+
} catch {
|
|
29
|
+
return false
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async getCurrentCommit(): Promise<string> {
|
|
34
|
+
// 重试3次,避免Windows下git命令偶发失败
|
|
35
|
+
const maxRetries = 3;
|
|
36
|
+
for (let i = 0; i < maxRetries; i++) {
|
|
37
|
+
try {
|
|
38
|
+
return await this.getGit().revparse(['HEAD'])
|
|
39
|
+
} catch (e) {
|
|
40
|
+
if (i === maxRetries - 1) {
|
|
41
|
+
throw new AppError(ErrorCode.GIT_OPERATION_FAILED, 'Failed to get current commit', e)
|
|
42
|
+
}
|
|
43
|
+
await new Promise(resolve => setTimeout(resolve, 100 * (i + 1)));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
throw new AppError(ErrorCode.GIT_OPERATION_FAILED, 'Failed to get current commit')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async getCurrentBranch(): Promise<string> {
|
|
50
|
+
try {
|
|
51
|
+
return await this.getGit().revparse(['--abbrev-ref', 'HEAD'])
|
|
52
|
+
} catch (e) {
|
|
53
|
+
throw new AppError(ErrorCode.GIT_OPERATION_FAILED, 'Failed to get current branch', e)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async getProjectSlug(): Promise<string> {
|
|
58
|
+
try {
|
|
59
|
+
// 尝试从remote url获取slug
|
|
60
|
+
const remoteUrl = (await this.getGit().remote(['get-url', 'origin'])) as string
|
|
61
|
+
const match = remoteUrl?.match(/[:/]([^/]+\/[^/.]+)(\.git)?$/)
|
|
62
|
+
if (match) {
|
|
63
|
+
return match[1]
|
|
64
|
+
}
|
|
65
|
+
} catch (e) {
|
|
66
|
+
// 没有remote的情况,使用目录名作为slug
|
|
67
|
+
const path = require('path')
|
|
68
|
+
const crypto = require('crypto')
|
|
69
|
+
const dirName = path.basename(this.projectRoot)
|
|
70
|
+
const hash = crypto.createHash('md5').update(this.projectRoot).digest('hex').slice(0, 8)
|
|
71
|
+
return `${dirName}-${hash}`
|
|
72
|
+
}
|
|
73
|
+
// 无法解析remote的情况,同样使用目录名加hash
|
|
74
|
+
const path = require('path')
|
|
75
|
+
const crypto = require('crypto')
|
|
76
|
+
const dirName = path.basename(this.projectRoot)
|
|
77
|
+
const hash = crypto.createHash('md5').update(this.projectRoot).digest('hex').slice(0, 8)
|
|
78
|
+
return `${dirName}-${hash}`
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async getUncommittedChanges(): Promise<string[]> {
|
|
82
|
+
try {
|
|
83
|
+
const status = await this.getGit().status()
|
|
84
|
+
return status.files.map(f => f.path)
|
|
85
|
+
} catch (e) {
|
|
86
|
+
throw new AppError(ErrorCode.GIT_OPERATION_FAILED, 'Failed to get uncommitted changes', e)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async diffCommits(commit1: string, commit2: string): Promise<string[]> {
|
|
91
|
+
try {
|
|
92
|
+
const diff = await this.getGit().diff([`${commit1}..${commit2}`, '--name-only'])
|
|
93
|
+
return diff.split('\n').filter(Boolean)
|
|
94
|
+
} catch (e) {
|
|
95
|
+
throw new AppError(ErrorCode.GIT_OPERATION_FAILED, 'Failed to diff commits', e)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async getFileLastCommit(projectRoot: string, filePath: string): Promise<string | null> {
|
|
100
|
+
try {
|
|
101
|
+
const git = this.getGit()
|
|
102
|
+
const relPath = filePath
|
|
103
|
+
const log = await git.raw(['log', '-n', '1', '--pretty=format:%H', '--', relPath])
|
|
104
|
+
const trimmed = (log || '').trim()
|
|
105
|
+
return trimmed || null
|
|
106
|
+
} catch {
|
|
107
|
+
return null
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async isFileDirty(projectRoot: string, filePath: string): Promise<boolean> {
|
|
112
|
+
try {
|
|
113
|
+
const git = this.getGit()
|
|
114
|
+
const status = await git.status()
|
|
115
|
+
return status.files.some(f => f.path === filePath)
|
|
116
|
+
} catch {
|
|
117
|
+
// git 不可用时视为非 dirty,交由上层按非 git 场景处理
|
|
118
|
+
return false
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import * as fs from 'fs-extra'
|
|
2
|
+
import * as path from 'path'
|
|
3
|
+
import { AnalysisIndex, IndexEntry } from '../common/types'
|
|
4
|
+
import { normalizePath } from '../common/utils'
|
|
5
|
+
|
|
6
|
+
// V2.6 起不再在主流程生成/依赖 analysis-index.json。
|
|
7
|
+
// 为兼容历史代码/测试,此类暂保留,但不再实现 IIndexService 接口。
|
|
8
|
+
export class IndexService {
|
|
9
|
+
private getIndexFilePath(storageRoot: string): string {
|
|
10
|
+
return path.join(storageRoot, 'analysis-index.json')
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async buildIndex(
|
|
14
|
+
projectRoot: string,
|
|
15
|
+
storageRoot: string,
|
|
16
|
+
fileEntries: Array<{ sourcePath: string; resultPath: string }>,
|
|
17
|
+
dirEntries: Array<{ sourcePath: string; resultPath: string }>
|
|
18
|
+
): Promise<void> {
|
|
19
|
+
const entries: Record<string, IndexEntry> = {}
|
|
20
|
+
|
|
21
|
+
for (const entry of fileEntries) {
|
|
22
|
+
entries[normalizePath(entry.sourcePath)] = {
|
|
23
|
+
resultPath: normalizePath(entry.resultPath),
|
|
24
|
+
type: 'file',
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
for (const entry of dirEntries) {
|
|
29
|
+
entries[normalizePath(entry.sourcePath)] = {
|
|
30
|
+
resultPath: normalizePath(entry.resultPath),
|
|
31
|
+
type: 'directory',
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const indexData: AnalysisIndex = {
|
|
36
|
+
version: '1.0',
|
|
37
|
+
projectRoot: normalizePath(projectRoot),
|
|
38
|
+
storageRoot: normalizePath(storageRoot),
|
|
39
|
+
generatedAt: new Date().toISOString(),
|
|
40
|
+
entries,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const indexPath = this.getIndexFilePath(storageRoot)
|
|
44
|
+
await fs.ensureDir(path.dirname(indexPath))
|
|
45
|
+
await fs.writeJson(indexPath, indexData, { spaces: 2 })
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async updateIndex(
|
|
49
|
+
storageRoot: string,
|
|
50
|
+
updatedEntries: Array<{ sourcePath: string; resultPath: string; type: 'file' | 'directory' }>,
|
|
51
|
+
removedPaths: string[]
|
|
52
|
+
): Promise<void> {
|
|
53
|
+
const existing = (await this.readIndex(storageRoot)) ?? {
|
|
54
|
+
version: '1.0',
|
|
55
|
+
projectRoot: '',
|
|
56
|
+
storageRoot,
|
|
57
|
+
generatedAt: new Date().toISOString(),
|
|
58
|
+
entries: {} as Record<string, IndexEntry>,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
for (const removedPath of removedPaths) {
|
|
62
|
+
delete existing.entries[normalizePath(removedPath)]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
for (const entry of updatedEntries) {
|
|
66
|
+
existing.entries[normalizePath(entry.sourcePath)] = {
|
|
67
|
+
resultPath: normalizePath(entry.resultPath),
|
|
68
|
+
type: entry.type,
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
existing.generatedAt = new Date().toISOString()
|
|
73
|
+
|
|
74
|
+
const indexPath = this.getIndexFilePath(storageRoot)
|
|
75
|
+
await fs.writeJson(indexPath, existing, { spaces: 2 })
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async readIndex(storageRoot: string): Promise<AnalysisIndex | null> {
|
|
79
|
+
const indexPath = this.getIndexFilePath(storageRoot)
|
|
80
|
+
if (await fs.pathExists(indexPath)) {
|
|
81
|
+
return await fs.readJson(indexPath)
|
|
82
|
+
}
|
|
83
|
+
return null
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async resolve(storageRoot: string, absolutePath: string): Promise<string | null> {
|
|
87
|
+
const indexData = await this.readIndex(storageRoot)
|
|
88
|
+
if (!indexData) return null
|
|
89
|
+
|
|
90
|
+
const normalized = normalizePath(absolutePath)
|
|
91
|
+
const entry = indexData.entries[normalized]
|
|
92
|
+
return entry ? entry.resultPath : null
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { TokenUsageStats } from '../../common/types'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 单次解析周期内的 LLM Token 使用统计器。
|
|
5
|
+
*
|
|
6
|
+
* - 对所有调用做累计统计;
|
|
7
|
+
* - 通过 onSnapshot 回调向上层(如 CLI 渲染器)推送最新快照;
|
|
8
|
+
* - 不直接向 stdout 打印 Token 行,由 CLI 统一渲染「Tokens: ...」。
|
|
9
|
+
*/
|
|
10
|
+
export class LLMUsageTracker {
|
|
11
|
+
private stats: TokenUsageStats = {
|
|
12
|
+
totalPromptTokens: 0,
|
|
13
|
+
totalCompletionTokens: 0,
|
|
14
|
+
totalTokens: 0,
|
|
15
|
+
totalCalls: 0,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
constructor(
|
|
19
|
+
private readonly onSnapshot?: (stats: TokenUsageStats) => void,
|
|
20
|
+
) {}
|
|
21
|
+
|
|
22
|
+
addUsage(usage: { promptTokens?: number; completionTokens?: number; totalTokens?: number }): void {
|
|
23
|
+
this.stats.totalPromptTokens += usage.promptTokens ?? 0
|
|
24
|
+
this.stats.totalCompletionTokens += usage.completionTokens ?? 0
|
|
25
|
+
this.stats.totalTokens += usage.totalTokens ?? 0
|
|
26
|
+
this.stats.totalCalls += 1
|
|
27
|
+
|
|
28
|
+
const snapshot = this.getStats()
|
|
29
|
+
|
|
30
|
+
if (this.onSnapshot) {
|
|
31
|
+
this.onSnapshot(snapshot)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 将外部汇总(例如 worker 线程返回的本任务 usage delta)累加到总量中。
|
|
37
|
+
* 注意:这里的 totalCalls 表示调用次数增量,而不是“任务数”。
|
|
38
|
+
*/
|
|
39
|
+
addTotals(delta: Partial<TokenUsageStats>): void {
|
|
40
|
+
this.stats.totalPromptTokens += delta.totalPromptTokens ?? 0
|
|
41
|
+
this.stats.totalCompletionTokens += delta.totalCompletionTokens ?? 0
|
|
42
|
+
this.stats.totalTokens += delta.totalTokens ?? 0
|
|
43
|
+
this.stats.totalCalls += delta.totalCalls ?? 0
|
|
44
|
+
|
|
45
|
+
const snapshot = this.getStats()
|
|
46
|
+
|
|
47
|
+
if (this.onSnapshot) {
|
|
48
|
+
this.onSnapshot(snapshot)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
getStats(): TokenUsageStats {
|
|
53
|
+
return { ...this.stats }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
reset(): void {
|
|
57
|
+
this.stats = {
|
|
58
|
+
totalPromptTokens: 0,
|
|
59
|
+
totalCompletionTokens: 0,
|
|
60
|
+
totalTokens: 0,
|
|
61
|
+
totalCalls: 0,
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|