kodu 1.1.2 → 1.1.4
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/.prettierrc +4 -0
- package/AGENTS.md +97 -0
- package/README.md +85 -100
- package/biome.json +50 -0
- package/dist/app.module.d.ts +2 -0
- package/dist/app.module.js +42 -0
- package/dist/app.module.js.map +1 -0
- package/dist/commands/clean/clean.command.d.ts +16 -0
- package/dist/commands/clean/clean.command.js +88 -0
- package/dist/commands/clean/clean.command.js.map +1 -0
- package/dist/commands/clean/clean.module.d.ts +2 -0
- package/dist/commands/clean/clean.module.js +25 -0
- package/dist/commands/clean/clean.module.js.map +1 -0
- package/dist/commands/commit/commit.command.d.ts +18 -0
- package/dist/commands/commit/commit.command.js +135 -0
- package/dist/commands/commit/commit.command.js.map +1 -0
- package/dist/commands/commit/commit.module.d.ts +2 -0
- package/dist/commands/commit/commit.module.js +25 -0
- package/dist/commands/commit/commit.module.js.map +1 -0
- package/dist/commands/init/init.command.d.ts +14 -0
- package/dist/commands/init/init.command.js +166 -0
- package/dist/commands/init/init.command.js.map +1 -0
- package/dist/commands/init/init.module.d.ts +2 -0
- package/dist/commands/init/init.module.js +22 -0
- package/dist/commands/init/init.module.js.map +1 -0
- package/dist/commands/pack/pack.command.d.ts +25 -0
- package/dist/commands/pack/pack.command.js +161 -0
- package/dist/commands/pack/pack.command.js.map +1 -0
- package/dist/commands/pack/pack.module.d.ts +2 -0
- package/dist/commands/pack/pack.module.js +25 -0
- package/dist/commands/pack/pack.module.js.map +1 -0
- package/dist/commands/review/review.command.d.ts +31 -0
- package/dist/commands/review/review.command.js +260 -0
- package/dist/commands/review/review.command.js.map +1 -0
- package/dist/commands/review/review.module.d.ts +2 -0
- package/dist/commands/review/review.module.js +26 -0
- package/dist/commands/review/review.module.js.map +1 -0
- package/dist/core/config/config.module.d.ts +2 -0
- package/dist/core/config/config.module.js +22 -0
- package/dist/core/config/config.module.js.map +1 -0
- package/dist/core/config/config.schema.d.ts +16 -0
- package/dist/core/config/config.schema.js +48 -0
- package/dist/core/config/config.schema.js.map +1 -0
- package/dist/core/config/config.service.d.ts +7 -0
- package/dist/core/config/config.service.js +51 -0
- package/dist/core/config/config.service.js.map +1 -0
- package/dist/core/config/config.types.d.ts +1 -0
- package/dist/core/config/config.types.js +3 -0
- package/dist/core/config/config.types.js.map +1 -0
- package/dist/core/file-system/fs.module.d.ts +2 -0
- package/dist/core/file-system/fs.module.js +21 -0
- package/dist/core/file-system/fs.module.js.map +1 -0
- package/dist/core/file-system/fs.service.d.ts +8 -0
- package/dist/core/file-system/fs.service.js +62 -0
- package/dist/core/file-system/fs.service.js.map +1 -0
- package/dist/core/ui/ui.module.d.ts +2 -0
- package/dist/core/ui/ui.module.js +22 -0
- package/dist/core/ui/ui.module.js.map +1 -0
- package/dist/core/ui/ui.service.d.ts +22 -0
- package/dist/core/ui/ui.service.js +43 -0
- package/dist/core/ui/ui.service.js.map +1 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +10 -0
- package/dist/main.js.map +1 -0
- package/dist/shared/ai/ai.module.d.ts +2 -0
- package/dist/shared/ai/ai.module.js +23 -0
- package/dist/shared/ai/ai.module.js.map +1 -0
- package/dist/shared/ai/ai.service.d.ts +27 -0
- package/dist/shared/ai/ai.service.js +126 -0
- package/dist/shared/ai/ai.service.js.map +1 -0
- package/dist/shared/cleaner/cleaner.service.d.ts +18 -0
- package/dist/shared/cleaner/cleaner.service.js +187 -0
- package/dist/shared/cleaner/cleaner.service.js.map +1 -0
- package/dist/shared/cleaner/cleaner.types.d.ts +14 -0
- package/dist/shared/cleaner/cleaner.types.js +3 -0
- package/dist/shared/cleaner/cleaner.types.js.map +1 -0
- package/dist/shared/git/git.module.d.ts +2 -0
- package/dist/shared/git/git.module.js +23 -0
- package/dist/shared/git/git.module.js.map +1 -0
- package/dist/shared/git/git.service.d.ts +11 -0
- package/dist/shared/git/git.service.js +61 -0
- package/dist/shared/git/git.service.js.map +1 -0
- package/dist/shared/tokenizer/tokenizer.module.d.ts +2 -0
- package/dist/shared/tokenizer/tokenizer.module.js +23 -0
- package/dist/shared/tokenizer/tokenizer.module.js.map +1 -0
- package/dist/shared/tokenizer/tokenizer.service.d.ts +15 -0
- package/dist/shared/tokenizer/tokenizer.service.js +59 -0
- package/dist/shared/tokenizer/tokenizer.service.js.map +1 -0
- package/dist/tsconfig.build.tsbuildinfo +1 -0
- package/docs/plan.md +92 -0
- package/docs/project_charter.md +92 -0
- package/docs/todo.md +5 -0
- package/knip.json +10 -0
- package/kodu.json +22 -0
- package/nest-cli.json +8 -0
- package/package.json +41 -41
- package/src/app.module.ts +29 -0
- package/src/commands/clean/clean.command.ts +83 -0
- package/src/commands/clean/clean.module.ts +12 -0
- package/src/commands/commit/commit.command.ts +120 -0
- package/src/commands/commit/commit.module.ts +12 -0
- package/src/commands/init/init.command.ts +193 -0
- package/src/commands/init/init.module.ts +9 -0
- package/src/commands/pack/pack.command.ts +167 -0
- package/src/commands/pack/pack.module.ts +12 -0
- package/src/commands/review/review.command.ts +266 -0
- package/src/commands/review/review.module.ts +13 -0
- package/src/core/config/config.module.ts +9 -0
- package/src/core/config/config.schema.ts +50 -0
- package/src/core/config/config.service.ts +46 -0
- package/src/core/config/config.types.ts +1 -0
- package/src/core/file-system/fs.module.ts +8 -0
- package/src/core/file-system/fs.service.ts +44 -0
- package/src/core/ui/ui.module.ts +9 -0
- package/src/core/ui/ui.service.ts +39 -0
- package/src/main.ts +9 -0
- package/src/shared/ai/ai.module.ts +10 -0
- package/src/shared/ai/ai.service.ts +160 -0
- package/src/shared/cleaner/cleaner.service.ts +227 -0
- package/src/shared/cleaner/cleaner.types.ts +16 -0
- package/src/shared/git/git.module.ts +10 -0
- package/src/shared/git/git.service.ts +49 -0
- package/src/shared/tokenizer/tokenizer.module.ts +10 -0
- package/src/shared/tokenizer/tokenizer.service.ts +54 -0
- package/tsconfig.build.json +4 -0
- package/tsconfig.json +25 -0
- package/LICENSE +0 -21
- package/dist/index.js +0 -146
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
const llmSchema = z.object({
|
|
4
|
+
provider: z.literal('openai').default('openai'),
|
|
5
|
+
model: z.string().default('gpt-4o'),
|
|
6
|
+
apiKeyEnv: z.string().default('OPENAI_API_KEY'),
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
const cleanerSchema = z.object({
|
|
10
|
+
whitelist: z.array(z.string()).default(['//!']),
|
|
11
|
+
keepJSDoc: z.boolean().default(true),
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const packerSchema = z.object({
|
|
15
|
+
ignore: z
|
|
16
|
+
.array(z.string())
|
|
17
|
+
.default([
|
|
18
|
+
'package-lock.json',
|
|
19
|
+
'yarn.lock',
|
|
20
|
+
'pnpm-lock.yaml',
|
|
21
|
+
'.git',
|
|
22
|
+
'.kodu',
|
|
23
|
+
'node_modules',
|
|
24
|
+
'dist',
|
|
25
|
+
'coverage',
|
|
26
|
+
]),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export const configSchema = z.object({
|
|
30
|
+
llm: llmSchema.default({
|
|
31
|
+
provider: 'openai',
|
|
32
|
+
model: 'gpt-4o',
|
|
33
|
+
apiKeyEnv: 'OPENAI_API_KEY',
|
|
34
|
+
}),
|
|
35
|
+
cleaner: cleanerSchema.default({ whitelist: ['//!'], keepJSDoc: true }),
|
|
36
|
+
packer: packerSchema.default({
|
|
37
|
+
ignore: [
|
|
38
|
+
'package-lock.json',
|
|
39
|
+
'yarn.lock',
|
|
40
|
+
'pnpm-lock.yaml',
|
|
41
|
+
'.git',
|
|
42
|
+
'.kodu',
|
|
43
|
+
'node_modules',
|
|
44
|
+
'dist',
|
|
45
|
+
'coverage',
|
|
46
|
+
],
|
|
47
|
+
}),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
export type KoduConfig = z.infer<typeof configSchema>;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Injectable } from '@nestjs/common';
|
|
2
|
+
import { lilconfigSync } from 'lilconfig';
|
|
3
|
+
import pc from 'picocolors';
|
|
4
|
+
import { configSchema, type KoduConfig } from './config.schema';
|
|
5
|
+
|
|
6
|
+
@Injectable()
|
|
7
|
+
export class ConfigService {
|
|
8
|
+
private config?: KoduConfig;
|
|
9
|
+
|
|
10
|
+
getConfig(): KoduConfig {
|
|
11
|
+
if (!this.config) {
|
|
12
|
+
this.config = this.loadConfig();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return this.config;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
private loadConfig(): KoduConfig {
|
|
19
|
+
const explorer = lilconfigSync('kodu', { searchPlaces: ['kodu.json'] });
|
|
20
|
+
const result = explorer.search(process.cwd());
|
|
21
|
+
|
|
22
|
+
if (!result || result.isEmpty || !result.config) {
|
|
23
|
+
this.terminate(
|
|
24
|
+
'Не найден конфиг kodu.json. Запустите `kodu init`, чтобы создать файл.',
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const parsed = configSchema.safeParse(result.config);
|
|
29
|
+
|
|
30
|
+
if (!parsed.success) {
|
|
31
|
+
console.error(pc.red('Конфиг kodu.json невалиден:'));
|
|
32
|
+
parsed.error.issues.forEach((issue) => {
|
|
33
|
+
const path = issue.path.join('.') || '(root)';
|
|
34
|
+
console.error(pc.red(`- ${path}: ${issue.message}`));
|
|
35
|
+
});
|
|
36
|
+
this.terminate('Исправьте конфиг и запустите команду снова.');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return parsed.data;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private terminate(message: string): never {
|
|
43
|
+
console.error(pc.red(message));
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export type { KoduConfig } from './config.schema';
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { Injectable } from '@nestjs/common';
|
|
4
|
+
import { glob } from 'tinyglobby';
|
|
5
|
+
import { ConfigService } from '../config/config.service';
|
|
6
|
+
|
|
7
|
+
@Injectable()
|
|
8
|
+
export class FsService {
|
|
9
|
+
constructor(private readonly configService: ConfigService) {}
|
|
10
|
+
|
|
11
|
+
async findProjectFiles(): Promise<string[]> {
|
|
12
|
+
const { packer } = this.configService.getConfig();
|
|
13
|
+
const gitignore = await this.readGitignorePatterns();
|
|
14
|
+
const entries = await glob(['**/*'], {
|
|
15
|
+
onlyFiles: true,
|
|
16
|
+
absolute: true,
|
|
17
|
+
ignore: [...packer.ignore, ...gitignore],
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
return entries
|
|
21
|
+
.map((entry) => path.relative(process.cwd(), entry))
|
|
22
|
+
.filter((relative) => relative.length > 0)
|
|
23
|
+
.sort((a, b) => a.localeCompare(b));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async readFileRelative(relativePath: string): Promise<string> {
|
|
27
|
+
const absolute = path.resolve(process.cwd(), relativePath);
|
|
28
|
+
return fs.readFile(absolute, 'utf8');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
private async readGitignorePatterns(): Promise<string[]> {
|
|
32
|
+
const gitignorePath = path.join(process.cwd(), '.gitignore');
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const content = await fs.readFile(gitignorePath, 'utf8');
|
|
36
|
+
return content
|
|
37
|
+
.split(/\r?\n/)
|
|
38
|
+
.map((line) => line.trim())
|
|
39
|
+
.filter((line) => line.length > 0 && !line.startsWith('#'));
|
|
40
|
+
} catch {
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import confirm from '@inquirer/confirm';
|
|
2
|
+
import input from '@inquirer/input';
|
|
3
|
+
import select from '@inquirer/select';
|
|
4
|
+
import { Injectable } from '@nestjs/common';
|
|
5
|
+
import pc from 'picocolors';
|
|
6
|
+
import yoctoSpinner, {
|
|
7
|
+
type Spinner,
|
|
8
|
+
type Options as SpinnerOptions,
|
|
9
|
+
} from 'yocto-spinner';
|
|
10
|
+
|
|
11
|
+
type InputOptions = Parameters<typeof input>[0];
|
|
12
|
+
type ConfirmOptions = Parameters<typeof confirm>[0];
|
|
13
|
+
type SelectOptions<TValue> = Parameters<typeof select<TValue>>[0];
|
|
14
|
+
|
|
15
|
+
@Injectable()
|
|
16
|
+
export class UiService {
|
|
17
|
+
readonly log = {
|
|
18
|
+
success: (message: string) => console.log(pc.green(`✔ ${message}`)),
|
|
19
|
+
warn: (message: string) => console.log(pc.yellow(`⚠ ${message}`)),
|
|
20
|
+
error: (message: string) => console.log(pc.red(`✖ ${message}`)),
|
|
21
|
+
info: (message: string) => console.log(pc.cyan(`ℹ ${message}`)),
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
createSpinner(options?: SpinnerOptions & { text?: string }): Spinner {
|
|
25
|
+
return yoctoSpinner({ text: options?.text ?? '', ...options });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
promptInput(options: InputOptions): Promise<string> {
|
|
29
|
+
return input(options);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
promptConfirm(options: ConfirmOptions): Promise<boolean> {
|
|
33
|
+
return confirm(options);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
promptSelect<TValue>(options: SelectOptions<TValue>): Promise<TValue> {
|
|
37
|
+
return select<TValue>(options);
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/main.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Module } from '@nestjs/common';
|
|
2
|
+
import { ConfigModule } from '../../core/config/config.module';
|
|
3
|
+
import { AiService } from './ai.service';
|
|
4
|
+
|
|
5
|
+
@Module({
|
|
6
|
+
imports: [ConfigModule],
|
|
7
|
+
providers: [AiService],
|
|
8
|
+
exports: [AiService],
|
|
9
|
+
})
|
|
10
|
+
export class AiModule {}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { Agent } from '@mastra/core/agent';
|
|
2
|
+
import { Injectable } from '@nestjs/common';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { ConfigService } from '../../core/config/config.service';
|
|
5
|
+
|
|
6
|
+
export type ReviewMode = 'bug' | 'style' | 'security';
|
|
7
|
+
|
|
8
|
+
type ReviewIssue = {
|
|
9
|
+
severity: 'low' | 'medium' | 'high';
|
|
10
|
+
file?: string;
|
|
11
|
+
line?: number;
|
|
12
|
+
message: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type ReviewResult = {
|
|
16
|
+
summary: string;
|
|
17
|
+
issues: ReviewIssue[];
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
@Injectable()
|
|
21
|
+
export class AiService {
|
|
22
|
+
constructor(private readonly configService: ConfigService) {}
|
|
23
|
+
|
|
24
|
+
async reviewDiff(
|
|
25
|
+
diff: string,
|
|
26
|
+
mode: ReviewMode,
|
|
27
|
+
structured: boolean,
|
|
28
|
+
): Promise<{ text: string; structured?: ReviewResult }> {
|
|
29
|
+
const agent = this.createAgent(
|
|
30
|
+
'kodu-review-agent',
|
|
31
|
+
'AI Reviewer for staged git diff. Be concise.',
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const userPrompt = this.buildReviewPrompt(diff, mode);
|
|
35
|
+
|
|
36
|
+
if (structured) {
|
|
37
|
+
const schema = z.object({
|
|
38
|
+
summary: z.string(),
|
|
39
|
+
issues: z
|
|
40
|
+
.array(
|
|
41
|
+
z.object({
|
|
42
|
+
severity: z.enum(['low', 'medium', 'high']).default('low'),
|
|
43
|
+
file: z.string().optional(),
|
|
44
|
+
line: z.number().int().positive().optional(),
|
|
45
|
+
message: z.string(),
|
|
46
|
+
}),
|
|
47
|
+
)
|
|
48
|
+
.default([]),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const output = await agent.generate(userPrompt, {
|
|
52
|
+
structuredOutput: { schema },
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
return { text: output.text.trim(), structured: output.object };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const output = await agent.generate(userPrompt);
|
|
59
|
+
return { text: output.text.trim() };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async generateCommitMessage(diff: string): Promise<string> {
|
|
63
|
+
const agent = this.createAgent(
|
|
64
|
+
'kodu-commit-agent',
|
|
65
|
+
'Generate a concise Conventional Commit message. Only output the message string.',
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const output = await agent.generate(
|
|
69
|
+
`
|
|
70
|
+
You generate Conventional Commit messages.
|
|
71
|
+
Rules:
|
|
72
|
+
- Format: <type>(<optional scope>): <subject>
|
|
73
|
+
- Lowercase subject, no trailing period.
|
|
74
|
+
- Keep under 70 characters.
|
|
75
|
+
- Summarize the diff accurately.
|
|
76
|
+
|
|
77
|
+
Diff:
|
|
78
|
+
${diff}
|
|
79
|
+
`.trim(),
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const raw = output.text.trim();
|
|
83
|
+
const cleaned = this.cleanCommitMessage(raw);
|
|
84
|
+
|
|
85
|
+
if (!cleaned) {
|
|
86
|
+
throw new Error('AI не вернул валидное сообщение коммита.');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return cleaned;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private createAgent(id: string, instructions: string): Agent {
|
|
93
|
+
const apiKey = this.getApiKey();
|
|
94
|
+
const modelId = this.getModelId();
|
|
95
|
+
|
|
96
|
+
return new Agent({
|
|
97
|
+
id,
|
|
98
|
+
name: id,
|
|
99
|
+
instructions,
|
|
100
|
+
model: { id: modelId as `${string}/${string}`, apiKey },
|
|
101
|
+
maxRetries: 1,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private buildReviewPrompt(diff: string, mode: ReviewMode): string {
|
|
106
|
+
const focusByMode: Record<ReviewMode, string> = {
|
|
107
|
+
bug: 'Найди потенциальные баги, логические ошибки, регрессы.',
|
|
108
|
+
style: 'Проверь читаемость, согласованность, форматирование и нейминг.',
|
|
109
|
+
security:
|
|
110
|
+
'Найди уязвимости, утечки секретов, неправильные проверки прав.',
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
return `
|
|
114
|
+
Ты — строгий ревьюер кода. Формат ответа: краткий markdown с пунктами.
|
|
115
|
+
Режим: ${mode}. ${focusByMode[mode]}
|
|
116
|
+
Дай сжатый список проблем и рекомендаций. Если критичных нет — скажи об этом.
|
|
117
|
+
|
|
118
|
+
Diff:
|
|
119
|
+
${diff}
|
|
120
|
+
`.trim();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private getApiKey(): string {
|
|
124
|
+
const envName =
|
|
125
|
+
this.configService.getConfig().llm.apiKeyEnv ?? 'OPENAI_API_KEY';
|
|
126
|
+
const value = process.env[envName];
|
|
127
|
+
|
|
128
|
+
if (!value) {
|
|
129
|
+
throw new Error(`Не найден API ключ: установите ${envName} в окружении.`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return value;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private getModelId(): string {
|
|
136
|
+
const model = this.configService.getConfig().llm.model;
|
|
137
|
+
const normalized = model.includes('/') ? model : `openai/${model}`;
|
|
138
|
+
return normalized;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private cleanCommitMessage(text: string): string {
|
|
142
|
+
const unfenced = text.replace(/^```[a-zA-Z]*\s*/g, '').replace(/```$/g, '');
|
|
143
|
+
const lines = unfenced
|
|
144
|
+
.split('\n')
|
|
145
|
+
.map((line) => line.trim())
|
|
146
|
+
.filter((line) => line.length > 0);
|
|
147
|
+
|
|
148
|
+
if (lines.length === 0) {
|
|
149
|
+
return '';
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const first = lines[0]
|
|
153
|
+
.replace(/^"|"$/g, '')
|
|
154
|
+
.replace(/^'|'$/g, '')
|
|
155
|
+
.replace(/^Commit message:?\s*/i, '')
|
|
156
|
+
.trim();
|
|
157
|
+
|
|
158
|
+
return first;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { Injectable } from '@nestjs/common';
|
|
4
|
+
import { Project, type SourceFile, SyntaxKind, ts } from 'ts-morph';
|
|
5
|
+
import { ConfigService } from '../../core/config/config.service';
|
|
6
|
+
import { FsService } from '../../core/file-system/fs.service';
|
|
7
|
+
import {
|
|
8
|
+
type CleanOptions,
|
|
9
|
+
type CleanSummary,
|
|
10
|
+
type FileCleanReport,
|
|
11
|
+
} from './cleaner.types';
|
|
12
|
+
|
|
13
|
+
type RemovalRange = {
|
|
14
|
+
start: number;
|
|
15
|
+
end: number;
|
|
16
|
+
text: string;
|
|
17
|
+
kind: 'comment' | 'jsx';
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
@Injectable()
|
|
21
|
+
export class CleanerService {
|
|
22
|
+
private readonly project = new Project({
|
|
23
|
+
useInMemoryFileSystem: false,
|
|
24
|
+
skipFileDependencyResolution: true,
|
|
25
|
+
compilerOptions: {
|
|
26
|
+
allowJs: true,
|
|
27
|
+
jsx: ts.JsxEmit.Preserve,
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
private readonly systemWhitelist = [
|
|
32
|
+
'@ts-ignore',
|
|
33
|
+
'@ts-expect-error',
|
|
34
|
+
'eslint-disable',
|
|
35
|
+
'prettier-ignore',
|
|
36
|
+
'biome-ignore',
|
|
37
|
+
'todo',
|
|
38
|
+
'fixme',
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
constructor(
|
|
42
|
+
private readonly configService: ConfigService,
|
|
43
|
+
private readonly fsService: FsService,
|
|
44
|
+
) {}
|
|
45
|
+
|
|
46
|
+
async cleanFiles(
|
|
47
|
+
files: string[],
|
|
48
|
+
options: CleanOptions = {},
|
|
49
|
+
): Promise<CleanSummary> {
|
|
50
|
+
const config = this.configService.getConfig();
|
|
51
|
+
const whitelist = this.buildWhitelist(config.cleaner.whitelist);
|
|
52
|
+
let commentsRemoved = 0;
|
|
53
|
+
let filesChanged = 0;
|
|
54
|
+
const reports: FileCleanReport[] = [];
|
|
55
|
+
|
|
56
|
+
for (const file of files) {
|
|
57
|
+
const original = await this.fsService.readFileRelative(file);
|
|
58
|
+
const result = this.cleanSource(
|
|
59
|
+
file,
|
|
60
|
+
original,
|
|
61
|
+
whitelist,
|
|
62
|
+
config.cleaner.keepJSDoc,
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
if (result.removed > 0) {
|
|
66
|
+
filesChanged += 1;
|
|
67
|
+
commentsRemoved += result.removed;
|
|
68
|
+
|
|
69
|
+
if (!options.dryRun) {
|
|
70
|
+
await this.writeFile(file, result.nextContent);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
reports.push({
|
|
75
|
+
file,
|
|
76
|
+
removed: result.removed,
|
|
77
|
+
previews: result.previews,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
filesProcessed: files.length,
|
|
83
|
+
filesChanged,
|
|
84
|
+
commentsRemoved,
|
|
85
|
+
reports,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private cleanSource(
|
|
90
|
+
file: string,
|
|
91
|
+
content: string,
|
|
92
|
+
whitelist: Set<string>,
|
|
93
|
+
keepJSDoc: boolean,
|
|
94
|
+
): { nextContent: string; removed: number; previews: string[] } {
|
|
95
|
+
const sourceFile = this.project.createSourceFile(file, content, {
|
|
96
|
+
overwrite: true,
|
|
97
|
+
});
|
|
98
|
+
const fullText = sourceFile.getFullText();
|
|
99
|
+
|
|
100
|
+
const ranges = this.collectCommentRanges(sourceFile);
|
|
101
|
+
const candidates = ranges.filter((range) =>
|
|
102
|
+
this.shouldRemove(range, whitelist, keepJSDoc),
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
if (candidates.length === 0) {
|
|
106
|
+
return { nextContent: content, removed: 0, previews: [] };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const previews = candidates
|
|
110
|
+
.slice(0, 3)
|
|
111
|
+
.map((range) => this.normalizePreview(range.text));
|
|
112
|
+
|
|
113
|
+
const sorted = [...candidates].sort((a, b) => b.start - a.start);
|
|
114
|
+
let nextContent = fullText;
|
|
115
|
+
|
|
116
|
+
for (const range of sorted) {
|
|
117
|
+
const replacement = this.getReplacement(fullText, range);
|
|
118
|
+
nextContent = `${nextContent.slice(0, range.start)}${replacement}${nextContent.slice(range.end)}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return { nextContent, removed: candidates.length, previews };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private collectCommentRanges(sourceFile: SourceFile): RemovalRange[] {
|
|
125
|
+
const fullText = sourceFile.getFullText();
|
|
126
|
+
const ranges = new Map<string, RemovalRange>();
|
|
127
|
+
|
|
128
|
+
const addRanges = (items: readonly ts.CommentRange[] | undefined) => {
|
|
129
|
+
if (!items) return;
|
|
130
|
+
|
|
131
|
+
for (const item of items) {
|
|
132
|
+
const key = `${item.pos}:${item.end}`;
|
|
133
|
+
if (ranges.has(key)) continue;
|
|
134
|
+
ranges.set(key, {
|
|
135
|
+
start: item.pos,
|
|
136
|
+
end: item.end,
|
|
137
|
+
text: fullText.slice(item.pos, item.end),
|
|
138
|
+
kind: 'comment',
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const visit = (node: ts.Node): void => {
|
|
144
|
+
addRanges(ts.getLeadingCommentRanges(fullText, node.getFullStart()));
|
|
145
|
+
addRanges(ts.getTrailingCommentRanges(fullText, node.getEnd()));
|
|
146
|
+
ts.forEachChild(node, visit);
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
visit(sourceFile.compilerNode);
|
|
150
|
+
|
|
151
|
+
const jsxExpressions = sourceFile.getDescendantsOfKind(
|
|
152
|
+
SyntaxKind.JsxExpression,
|
|
153
|
+
);
|
|
154
|
+
for (const jsx of jsxExpressions) {
|
|
155
|
+
if (jsx.getExpression()) continue;
|
|
156
|
+
const text = jsx.getText();
|
|
157
|
+
if (!text.includes('/*')) continue;
|
|
158
|
+
|
|
159
|
+
const start = jsx.getPos();
|
|
160
|
+
const end = jsx.getEnd();
|
|
161
|
+
const key = `${start}:${end}`;
|
|
162
|
+
|
|
163
|
+
if (!ranges.has(key)) {
|
|
164
|
+
ranges.set(key, {
|
|
165
|
+
start,
|
|
166
|
+
end,
|
|
167
|
+
text: fullText.slice(start, end),
|
|
168
|
+
kind: 'jsx',
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return [...ranges.values()];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private shouldRemove(
|
|
177
|
+
range: RemovalRange,
|
|
178
|
+
whitelist: Set<string>,
|
|
179
|
+
keepJSDoc: boolean,
|
|
180
|
+
): boolean {
|
|
181
|
+
const trimmed = range.text.trimStart();
|
|
182
|
+
if (keepJSDoc && trimmed.startsWith('/**')) {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const lower = range.text.toLowerCase();
|
|
187
|
+
for (const token of whitelist) {
|
|
188
|
+
if (lower.includes(token)) {
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private normalizePreview(text: string): string {
|
|
197
|
+
const singleLine = text.replace(/\s+/g, ' ').trim();
|
|
198
|
+
if (singleLine.length <= 50) return singleLine;
|
|
199
|
+
return `${singleLine.slice(0, 47)}...`;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private getReplacement(original: string, range: RemovalRange): string {
|
|
203
|
+
if (range.kind === 'jsx') {
|
|
204
|
+
return '';
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const before = range.start > 0 ? original[range.start - 1] : '';
|
|
208
|
+
const after = range.end < original.length ? original[range.end] : '';
|
|
209
|
+
const isIdentifier = (ch: string): boolean => /[A-Za-z0-9_$]/.test(ch);
|
|
210
|
+
|
|
211
|
+
if (isIdentifier(before) && isIdentifier(after)) {
|
|
212
|
+
return ' ';
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return '';
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
private buildWhitelist(userList: string[]): Set<string> {
|
|
219
|
+
const normalized = userList.map((item) => item.toLowerCase());
|
|
220
|
+
return new Set([...this.systemWhitelist, ...normalized]);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private async writeFile(file: string, content: string): Promise<void> {
|
|
224
|
+
const absolute = path.resolve(process.cwd(), file);
|
|
225
|
+
await fs.writeFile(absolute, content, 'utf8');
|
|
226
|
+
}
|
|
227
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type CleanOptions = {
|
|
2
|
+
dryRun?: boolean;
|
|
3
|
+
};
|
|
4
|
+
|
|
5
|
+
export type FileCleanReport = {
|
|
6
|
+
file: string;
|
|
7
|
+
removed: number;
|
|
8
|
+
previews: string[];
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type CleanSummary = {
|
|
12
|
+
filesProcessed: number;
|
|
13
|
+
filesChanged: number;
|
|
14
|
+
commentsRemoved: number;
|
|
15
|
+
reports: FileCleanReport[];
|
|
16
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Module } from '@nestjs/common';
|
|
2
|
+
import { ConfigModule } from '../../core/config/config.module';
|
|
3
|
+
import { GitService } from './git.service';
|
|
4
|
+
|
|
5
|
+
@Module({
|
|
6
|
+
imports: [ConfigModule],
|
|
7
|
+
providers: [GitService],
|
|
8
|
+
exports: [GitService],
|
|
9
|
+
})
|
|
10
|
+
export class GitModule {}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Injectable } from '@nestjs/common';
|
|
2
|
+
import { execa } from 'execa';
|
|
3
|
+
import { ConfigService } from '../../core/config/config.service';
|
|
4
|
+
|
|
5
|
+
const EXCLUDE_PREFIX = ':(exclude)';
|
|
6
|
+
|
|
7
|
+
@Injectable()
|
|
8
|
+
export class GitService {
|
|
9
|
+
constructor(private readonly configService: ConfigService) {}
|
|
10
|
+
|
|
11
|
+
async ensureRepo(): Promise<void> {
|
|
12
|
+
try {
|
|
13
|
+
await execa('git', ['rev-parse', '--is-inside-work-tree']);
|
|
14
|
+
} catch (error) {
|
|
15
|
+
const message =
|
|
16
|
+
error instanceof Error && 'stdout' in error
|
|
17
|
+
? String((error as { stderr?: string }).stderr ?? error.message)
|
|
18
|
+
: 'Git репозиторий не найден. Инициализируйте git перед выполнением команды.';
|
|
19
|
+
throw new Error(message);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async hasStagedChanges(): Promise<boolean> {
|
|
24
|
+
const { stdout } = await execa('git', ['diff', '--staged', '--name-only']);
|
|
25
|
+
return stdout.trim().length > 0;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async getStagedDiff(): Promise<string> {
|
|
29
|
+
await this.ensureRepo();
|
|
30
|
+
const excludeArgs = this.buildExcludeArgs();
|
|
31
|
+
const args = ['diff', '--staged', '--unified=3', '--', '.', ...excludeArgs];
|
|
32
|
+
const { stdout } = await execa('git', args);
|
|
33
|
+
return stdout;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async getStatusShort(): Promise<string> {
|
|
37
|
+
const { stdout } = await execa('git', ['status', '--short']);
|
|
38
|
+
return stdout.trim();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async commit(message: string): Promise<void> {
|
|
42
|
+
await execa('git', ['commit', '-m', message]);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
private buildExcludeArgs(): string[] {
|
|
46
|
+
const ignore = this.configService.getConfig().packer.ignore ?? [];
|
|
47
|
+
return ignore.map((pattern) => `${EXCLUDE_PREFIX}${pattern}`);
|
|
48
|
+
}
|
|
49
|
+
}
|