@spaceflow/review 0.29.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +533 -0
- package/README.md +124 -0
- package/dist/551.js +9 -0
- package/dist/index.js +5704 -0
- package/package.json +50 -0
- package/src/README.md +364 -0
- package/src/__mocks__/@anthropic-ai/claude-agent-sdk.js +3 -0
- package/src/__mocks__/json-stringify-pretty-compact.ts +4 -0
- package/src/deletion-impact.service.spec.ts +974 -0
- package/src/deletion-impact.service.ts +879 -0
- package/src/dto/mcp.dto.ts +42 -0
- package/src/index.ts +32 -0
- package/src/issue-verify.service.spec.ts +460 -0
- package/src/issue-verify.service.ts +309 -0
- package/src/locales/en/review.json +31 -0
- package/src/locales/index.ts +11 -0
- package/src/locales/zh-cn/review.json +31 -0
- package/src/parse-title-options.spec.ts +251 -0
- package/src/parse-title-options.ts +185 -0
- package/src/review-report/formatters/deletion-impact.formatter.ts +144 -0
- package/src/review-report/formatters/index.ts +4 -0
- package/src/review-report/formatters/json.formatter.ts +8 -0
- package/src/review-report/formatters/markdown.formatter.ts +291 -0
- package/src/review-report/formatters/terminal.formatter.ts +130 -0
- package/src/review-report/index.ts +4 -0
- package/src/review-report/review-report.module.ts +8 -0
- package/src/review-report/review-report.service.ts +58 -0
- package/src/review-report/types.ts +26 -0
- package/src/review-spec/index.ts +3 -0
- package/src/review-spec/review-spec.module.ts +10 -0
- package/src/review-spec/review-spec.service.spec.ts +1543 -0
- package/src/review-spec/review-spec.service.ts +902 -0
- package/src/review-spec/types.ts +143 -0
- package/src/review.command.ts +244 -0
- package/src/review.config.ts +58 -0
- package/src/review.mcp.ts +184 -0
- package/src/review.module.ts +52 -0
- package/src/review.service.spec.ts +3007 -0
- package/src/review.service.ts +2603 -0
- package/tsconfig.json +8 -0
- package/vitest.config.ts +34 -0
|
@@ -0,0 +1,1543 @@
|
|
|
1
|
+
import { vi, type Mock } from "vitest";
|
|
2
|
+
import { Test, TestingModule } from "@nestjs/testing";
|
|
3
|
+
import { ReviewSpecService } from "./review-spec.service";
|
|
4
|
+
import { GitProviderService } from "@spaceflow/core";
|
|
5
|
+
import { readdir, readFile, mkdir, access, writeFile } from "fs/promises";
|
|
6
|
+
import * as child_process from "child_process";
|
|
7
|
+
|
|
8
|
+
vi.mock("fs/promises");
|
|
9
|
+
vi.mock("child_process");
|
|
10
|
+
|
|
11
|
+
describe("ReviewSpecService", () => {
|
|
12
|
+
let service: ReviewSpecService;
|
|
13
|
+
|
|
14
|
+
let gitProvider: { listRepositoryContents: Mock; getFileContent: Mock };
|
|
15
|
+
|
|
16
|
+
beforeEach(async () => {
|
|
17
|
+
gitProvider = {
|
|
18
|
+
listRepositoryContents: vi.fn(),
|
|
19
|
+
getFileContent: vi.fn(),
|
|
20
|
+
};
|
|
21
|
+
const module: TestingModule = await Test.createTestingModule({
|
|
22
|
+
providers: [ReviewSpecService, { provide: GitProviderService, useValue: gitProvider }],
|
|
23
|
+
}).compile();
|
|
24
|
+
|
|
25
|
+
service = module.get<ReviewSpecService>(ReviewSpecService);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
vi.clearAllMocks();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("loadReviewSpecs", () => {
|
|
33
|
+
it("should load and parse spec files correctly", async () => {
|
|
34
|
+
const mockFiles = ["js&ts.base.md", "vue.file-name.md"];
|
|
35
|
+
const mockContent1 = `# 基础规范 \`[JsTs.Base]\`
|
|
36
|
+
|
|
37
|
+
## 常量使用全大写 \`[JsTs.Base.ConstUpperCase]\`
|
|
38
|
+
|
|
39
|
+
- 排除配置文件
|
|
40
|
+
- 排除测试文件
|
|
41
|
+
|
|
42
|
+
### Good
|
|
43
|
+
\`\`\`js
|
|
44
|
+
const MAX_COUNT = 100;
|
|
45
|
+
\`\`\``;
|
|
46
|
+
|
|
47
|
+
const mockContent2 = `# Vue 文件命名 \`[Vue.FileName]\`
|
|
48
|
+
|
|
49
|
+
## 组件使用大驼峰 \`[Vue.FileName.UpperCamel]\``;
|
|
50
|
+
|
|
51
|
+
(readdir as Mock).mockResolvedValue(mockFiles);
|
|
52
|
+
(readFile as Mock).mockResolvedValueOnce(mockContent1).mockResolvedValueOnce(mockContent2);
|
|
53
|
+
|
|
54
|
+
const specs = await service.loadReviewSpecs("/test/spec/dir");
|
|
55
|
+
|
|
56
|
+
expect(specs).toHaveLength(2);
|
|
57
|
+
expect(specs[0].filename).toBe("js&ts.base.md");
|
|
58
|
+
expect(specs[0].extensions).toEqual(["js", "ts"]);
|
|
59
|
+
expect(specs[0].type).toBe("base");
|
|
60
|
+
expect(specs[0].rules).toHaveLength(2);
|
|
61
|
+
expect(specs[0].rules[0].id).toBe("JsTs.Base");
|
|
62
|
+
expect(specs[0].rules[1].id).toBe("JsTs.Base.ConstUpperCase");
|
|
63
|
+
expect(specs[0].rules[1].description).toContain("排除配置文件");
|
|
64
|
+
expect(specs[0].rules[1].description).toContain("排除测试文件");
|
|
65
|
+
expect(specs[0].rules[1].examples).toHaveLength(1);
|
|
66
|
+
expect(specs[0].rules[1].examples[0]).toEqual({
|
|
67
|
+
lang: "js",
|
|
68
|
+
code: "const MAX_COUNT = 100;",
|
|
69
|
+
type: "good",
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
expect(specs[1].filename).toBe("vue.file-name.md");
|
|
73
|
+
expect(specs[1].extensions).toEqual(["vue"]);
|
|
74
|
+
expect(specs[1].type).toBe("file-name");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("should skip non-markdown files", async () => {
|
|
78
|
+
const mockFiles = ["js&ts.base.md", "readme.txt", "config.json"];
|
|
79
|
+
const mockContent = `# Test \`[Test.Rule]\``;
|
|
80
|
+
|
|
81
|
+
(readdir as Mock).mockResolvedValue(mockFiles);
|
|
82
|
+
(readFile as Mock).mockResolvedValue(mockContent);
|
|
83
|
+
|
|
84
|
+
const specs = await service.loadReviewSpecs("/test/spec/dir");
|
|
85
|
+
|
|
86
|
+
expect(specs).toHaveLength(1);
|
|
87
|
+
expect(specs[0].filename).toBe("js&ts.base.md");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("should handle directory read errors gracefully", async () => {
|
|
91
|
+
(readdir as Mock).mockRejectedValue(new Error("Directory not found"));
|
|
92
|
+
|
|
93
|
+
const specs = await service.loadReviewSpecs("/nonexistent/dir");
|
|
94
|
+
|
|
95
|
+
expect(specs).toHaveLength(0);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("should skip files with incorrect naming format", async () => {
|
|
99
|
+
const mockFiles = ["invalid.md", "js.base.md"];
|
|
100
|
+
const mockContent = `# Test \`[Test.Rule]\``;
|
|
101
|
+
|
|
102
|
+
(readdir as Mock).mockResolvedValue(mockFiles);
|
|
103
|
+
(readFile as Mock).mockResolvedValue(mockContent);
|
|
104
|
+
|
|
105
|
+
const specs = await service.loadReviewSpecs("/test/spec/dir");
|
|
106
|
+
|
|
107
|
+
expect(specs).toHaveLength(1);
|
|
108
|
+
expect(specs[0].filename).toBe("js.base.md");
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("extractConfigValues", () => {
|
|
113
|
+
it("should extract single value", () => {
|
|
114
|
+
const content = `> - testConfig \`value1\``;
|
|
115
|
+
const result = (service as any).extractConfigValues(content, "testConfig");
|
|
116
|
+
expect(result).toEqual(["value1"]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("should extract multiple values from single line", () => {
|
|
120
|
+
const content = `> - testConfig \`value1\` \`value2\` \`value3\``;
|
|
121
|
+
const result = (service as any).extractConfigValues(content, "testConfig");
|
|
122
|
+
expect(result).toEqual(["value1", "value2", "value3"]);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("should override with later config line (same name)", () => {
|
|
126
|
+
const content = `> - testConfig \`value1\`
|
|
127
|
+
> - testConfig \`value2\` \`value3\``;
|
|
128
|
+
const result = (service as any).extractConfigValues(content, "testConfig");
|
|
129
|
+
expect(result).toEqual(["value2", "value3"]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("should return empty array when config not found", () => {
|
|
133
|
+
const content = `> - otherConfig \`value1\``;
|
|
134
|
+
const result = (service as any).extractConfigValues(content, "testConfig");
|
|
135
|
+
expect(result).toEqual([]);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("should not match config in non-blockquote lines", () => {
|
|
139
|
+
const content = `- testConfig \`value1\``;
|
|
140
|
+
const result = (service as any).extractConfigValues(content, "testConfig");
|
|
141
|
+
expect(result).toEqual([]);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("extractOverrides", () => {
|
|
146
|
+
it("should extract single override", () => {
|
|
147
|
+
const content = `> - override \`[JsTs.FileName]\``;
|
|
148
|
+
const result = (service as any).extractOverrides(content);
|
|
149
|
+
expect(result).toEqual(["JsTs.FileName"]);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("should extract multiple overrides from single line", () => {
|
|
153
|
+
const content = `> - override \`[JsTs.FileName]\` \`[JsTs.FileName.LowerCamel]\``;
|
|
154
|
+
const result = (service as any).extractOverrides(content);
|
|
155
|
+
expect(result).toEqual(["JsTs.FileName", "JsTs.FileName.LowerCamel"]);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("should override with later config line", () => {
|
|
159
|
+
const content = `> - override \`[JsTs.FileName]\`
|
|
160
|
+
> - override \`[JsTs.FileName.LowerCamel]\``;
|
|
161
|
+
const result = (service as any).extractOverrides(content);
|
|
162
|
+
// 同名配置项覆盖,只保留最后一行
|
|
163
|
+
expect(result).toEqual(["JsTs.FileName.LowerCamel"]);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("should return empty array when no overrides", () => {
|
|
167
|
+
const content = `- 使用小写加横线命名
|
|
168
|
+
- 文件名必须加 .controller.ts 后缀`;
|
|
169
|
+
const result = (service as any).extractOverrides(content);
|
|
170
|
+
expect(result).toEqual([]);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe("applyOverrides", () => {
|
|
175
|
+
it("should exclude rules matching exact override", () => {
|
|
176
|
+
const specs = [
|
|
177
|
+
{
|
|
178
|
+
filename: "js&ts.base.md",
|
|
179
|
+
extensions: ["js", "ts"],
|
|
180
|
+
type: "base",
|
|
181
|
+
content: "",
|
|
182
|
+
overrides: [],
|
|
183
|
+
severity: "error" as const,
|
|
184
|
+
includes: [],
|
|
185
|
+
rules: [
|
|
186
|
+
{ id: "JsTs.Base", title: "Base", description: "", examples: [], overrides: [] },
|
|
187
|
+
{
|
|
188
|
+
id: "JsTs.FileName",
|
|
189
|
+
title: "FileName",
|
|
190
|
+
description: "",
|
|
191
|
+
examples: [],
|
|
192
|
+
overrides: [],
|
|
193
|
+
},
|
|
194
|
+
],
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
filename: "js&ts.nest.md",
|
|
198
|
+
extensions: ["js", "ts"],
|
|
199
|
+
type: "nest",
|
|
200
|
+
content: "",
|
|
201
|
+
overrides: ["JsTs.FileName"],
|
|
202
|
+
severity: "error" as const,
|
|
203
|
+
includes: [],
|
|
204
|
+
rules: [
|
|
205
|
+
{
|
|
206
|
+
id: "JsTs.Nest.DirStructure",
|
|
207
|
+
title: "DirStructure",
|
|
208
|
+
description: "",
|
|
209
|
+
examples: [],
|
|
210
|
+
overrides: [],
|
|
211
|
+
},
|
|
212
|
+
],
|
|
213
|
+
},
|
|
214
|
+
];
|
|
215
|
+
|
|
216
|
+
const result = service.applyOverrides(specs as any);
|
|
217
|
+
expect(result).toHaveLength(2);
|
|
218
|
+
expect(result[0].rules).toHaveLength(1);
|
|
219
|
+
expect(result[0].rules[0].id).toBe("JsTs.Base");
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
describe("parseSpecFile with overrides", () => {
|
|
224
|
+
it("should parse file-level overrides", async () => {
|
|
225
|
+
const mockContent = `# Nestjs 项目下的规范 \`[JsTs.Nest]\`
|
|
226
|
+
|
|
227
|
+
> - override \`[JsTs.FileName]\`
|
|
228
|
+
|
|
229
|
+
## 目录框架规范 \`[JsTs.Nest.DirStructure]\`
|
|
230
|
+
|
|
231
|
+
> - override \`[JsTs.FileName.LowerCamel]\`
|
|
232
|
+
|
|
233
|
+
- 使用下面的 Good 目录结构`;
|
|
234
|
+
|
|
235
|
+
const spec = service.parseSpecFile("js&ts.nest.md", mockContent);
|
|
236
|
+
|
|
237
|
+
expect(spec?.overrides).toEqual(["JsTs.FileName"]);
|
|
238
|
+
expect(spec?.rules).toHaveLength(2);
|
|
239
|
+
expect(spec?.rules[0].id).toBe("JsTs.Nest");
|
|
240
|
+
expect(spec?.rules[0].overrides).toEqual(["JsTs.FileName"]);
|
|
241
|
+
expect(spec?.rules[1].id).toBe("JsTs.Nest.DirStructure");
|
|
242
|
+
expect(spec?.rules[1].overrides).toEqual(["JsTs.FileName.LowerCamel"]);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
describe("extractSeverity", () => {
|
|
247
|
+
it("should extract error severity", () => {
|
|
248
|
+
const content = `> - severity \`error\``;
|
|
249
|
+
const result = (service as any).extractSeverity(content);
|
|
250
|
+
expect(result).toBe("error");
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("should extract warn severity", () => {
|
|
254
|
+
const content = `> - severity \`warn\``;
|
|
255
|
+
const result = (service as any).extractSeverity(content);
|
|
256
|
+
expect(result).toBe("warn");
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("should extract off severity", () => {
|
|
260
|
+
const content = `> - severity \`off\``;
|
|
261
|
+
const result = (service as any).extractSeverity(content);
|
|
262
|
+
expect(result).toBe("off");
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("should return undefined when no severity specified", () => {
|
|
266
|
+
const content = `- 使用小写加横线命名`;
|
|
267
|
+
const result = (service as any).extractSeverity(content);
|
|
268
|
+
expect(result).toBeUndefined();
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("should extract severity with other content", () => {
|
|
272
|
+
const content = `> - severity \`warn\`
|
|
273
|
+
|
|
274
|
+
- 排除配置文件
|
|
275
|
+
- 排除测试文件`;
|
|
276
|
+
const result = (service as any).extractSeverity(content);
|
|
277
|
+
expect(result).toBe("warn");
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
describe("parseSpecFile with severity", () => {
|
|
282
|
+
it("should use default severity (error) when not specified", () => {
|
|
283
|
+
const mockContent = `# 基础规范 \`[JsTs.Base]\`
|
|
284
|
+
|
|
285
|
+
## 常量使用全大写 \`[JsTs.Base.ConstUpperCase]\``;
|
|
286
|
+
|
|
287
|
+
const spec = service.parseSpecFile("js&ts.base.md", mockContent);
|
|
288
|
+
|
|
289
|
+
expect(spec?.severity).toBe("error");
|
|
290
|
+
expect(spec?.rules[0].severity).toBeUndefined();
|
|
291
|
+
expect(spec?.rules[1].severity).toBeUndefined();
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("should parse file-level severity", () => {
|
|
295
|
+
const mockContent = `# 基础规范 \`[JsTs.Base]\`
|
|
296
|
+
|
|
297
|
+
> - severity \`warn\`
|
|
298
|
+
|
|
299
|
+
## 常量使用全大写 \`[JsTs.Base.ConstUpperCase]\``;
|
|
300
|
+
|
|
301
|
+
const spec = service.parseSpecFile("js&ts.base.md", mockContent);
|
|
302
|
+
|
|
303
|
+
expect(spec?.severity).toBe("warn");
|
|
304
|
+
expect(spec?.rules[0].severity).toBe("warn");
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("should parse rule-level severity override", () => {
|
|
308
|
+
const mockContent = `# 基础规范 \`[JsTs.Base]\`
|
|
309
|
+
|
|
310
|
+
> - severity \`error\`
|
|
311
|
+
|
|
312
|
+
## 常量使用全大写 \`[JsTs.Base.ConstUpperCase]\`
|
|
313
|
+
|
|
314
|
+
> - severity \`warn\`
|
|
315
|
+
|
|
316
|
+
### Good
|
|
317
|
+
\`\`\`js
|
|
318
|
+
const MAX_COUNT = 100;
|
|
319
|
+
\`\`\``;
|
|
320
|
+
|
|
321
|
+
const spec = service.parseSpecFile("js&ts.base.md", mockContent);
|
|
322
|
+
|
|
323
|
+
expect(spec?.severity).toBe("error");
|
|
324
|
+
expect(spec?.rules[0].severity).toBe("error");
|
|
325
|
+
expect(spec?.rules[1].severity).toBe("warn");
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("should parse severity with override together", () => {
|
|
329
|
+
const mockContent = `# Nestjs 规范 \`[JsTs.Nest]\`
|
|
330
|
+
|
|
331
|
+
> - severity \`warn\`
|
|
332
|
+
> - override \`[JsTs.FileName]\`
|
|
333
|
+
|
|
334
|
+
## 目录规范 \`[JsTs.Nest.DirStructure]\`
|
|
335
|
+
|
|
336
|
+
> - severity \`error\``;
|
|
337
|
+
|
|
338
|
+
const spec = service.parseSpecFile("js&ts.nest.md", mockContent);
|
|
339
|
+
|
|
340
|
+
expect(spec?.severity).toBe("warn");
|
|
341
|
+
expect(spec?.overrides).toEqual(["JsTs.FileName"]);
|
|
342
|
+
expect(spec?.rules[0].severity).toBe("warn");
|
|
343
|
+
expect(spec?.rules[1].severity).toBe("error");
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
describe("extractIncludes", () => {
|
|
348
|
+
it("should extract single include pattern", () => {
|
|
349
|
+
const content = `> - includes \`*.controller.ts\``;
|
|
350
|
+
const result = (service as any).extractIncludes(content);
|
|
351
|
+
expect(result).toEqual(["*.controller.ts"]);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it("should extract multiple include patterns", () => {
|
|
355
|
+
const content = `> - includes \`*.controller.ts\` \`*.service.ts\` \`*.module.ts\``;
|
|
356
|
+
const result = (service as any).extractIncludes(content);
|
|
357
|
+
expect(result).toEqual(["*.controller.ts", "*.service.ts", "*.module.ts"]);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it("should return empty array when no includes", () => {
|
|
361
|
+
const content = `- 使用小写加横线命名
|
|
362
|
+
- 文件名必须加 .controller.ts 后缀`;
|
|
363
|
+
const result = (service as any).extractIncludes(content);
|
|
364
|
+
expect(result).toEqual([]);
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
describe("parseSpecFile with includes", () => {
|
|
369
|
+
it("should parse file-level includes", () => {
|
|
370
|
+
const mockContent = `# Nestjs 项目下的规范 \`[JsTs.Nest]\`
|
|
371
|
+
|
|
372
|
+
> - includes \`*.controller.ts\` \`*.service.ts\` \`*.module.ts\`
|
|
373
|
+
> - override \`[JsTs.FileName]\`
|
|
374
|
+
|
|
375
|
+
## 目录框架规范 \`[JsTs.Nest.DirStructure]\``;
|
|
376
|
+
|
|
377
|
+
const spec = service.parseSpecFile("js&ts.nest.md", mockContent);
|
|
378
|
+
|
|
379
|
+
expect(spec?.includes).toEqual(["*.controller.ts", "*.service.ts", "*.module.ts"]);
|
|
380
|
+
expect(spec?.overrides).toEqual(["JsTs.FileName"]);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it("should return empty includes when not specified", () => {
|
|
384
|
+
const mockContent = `# 基础规范 \`[JsTs.Base]\`
|
|
385
|
+
|
|
386
|
+
## 常量使用全大写 \`[JsTs.Base.ConstUpperCase]\``;
|
|
387
|
+
|
|
388
|
+
const spec = service.parseSpecFile("js&ts.base.md", mockContent);
|
|
389
|
+
|
|
390
|
+
expect(spec?.includes).toEqual([]);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it("should only extract file-level includes (before first ## rule)", () => {
|
|
394
|
+
const mockContent = `# Nestjs 项目下的规范 \`[JsTs.Nest]\`
|
|
395
|
+
|
|
396
|
+
> - includes \`*.controller.ts\` \`*.service.ts\` \`*.module.ts\`
|
|
397
|
+
|
|
398
|
+
## 目录框架规范 \`[JsTs.Nest.DirStructure]\`
|
|
399
|
+
|
|
400
|
+
- 使用下面的 Good 目录结构
|
|
401
|
+
|
|
402
|
+
## Model 编写规范 \`[JsTs.Nest.ModelDefinition]\`
|
|
403
|
+
|
|
404
|
+
> - includes \`*.model.ts\`
|
|
405
|
+
|
|
406
|
+
- 内部只能写使用 model 数据库调用的逻辑`;
|
|
407
|
+
|
|
408
|
+
const spec = service.parseSpecFile("js&ts.nest.md", mockContent);
|
|
409
|
+
|
|
410
|
+
// 文件级 includes 应该只取第一个 ## 之前的配置
|
|
411
|
+
expect(spec?.includes).toEqual(["*.controller.ts", "*.service.ts", "*.module.ts"]);
|
|
412
|
+
// 规则级 includes 应该被正确提取
|
|
413
|
+
expect(spec?.rules[2].includes).toEqual(["*.model.ts"]);
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it("should parse rule-level includes", () => {
|
|
417
|
+
const mockContent = `# Nestjs 项目下的规范 \`[JsTs.Nest]\`
|
|
418
|
+
|
|
419
|
+
> - includes \`*.controller.ts\` \`*.service.ts\`
|
|
420
|
+
|
|
421
|
+
## 目录框架规范 \`[JsTs.Nest.DirStructure]\`
|
|
422
|
+
|
|
423
|
+
- 使用下面的 Good 目录结构
|
|
424
|
+
|
|
425
|
+
## Model 编写规范 \`[JsTs.Nest.ModelDefinition]\`
|
|
426
|
+
|
|
427
|
+
> - includes \`*.model.ts\`
|
|
428
|
+
|
|
429
|
+
- 内部只能写使用 model 数据库调用的逻辑`;
|
|
430
|
+
|
|
431
|
+
const spec = service.parseSpecFile("js&ts.nest.md", mockContent);
|
|
432
|
+
|
|
433
|
+
expect(spec?.rules).toHaveLength(3);
|
|
434
|
+
// 第一个规则(标题规则)的 ruleContent 包含文件级配置,所以也会提取到 includes
|
|
435
|
+
expect(spec?.rules[0].includes).toEqual(["*.controller.ts", "*.service.ts"]);
|
|
436
|
+
// 第二个规则没有自己的 includes
|
|
437
|
+
expect(spec?.rules[1].includes).toBeUndefined();
|
|
438
|
+
// 第三个规则有自己的 includes
|
|
439
|
+
expect(spec?.rules[2].includes).toEqual(["*.model.ts"]);
|
|
440
|
+
});
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
describe("filterApplicableSpecs", () => {
|
|
444
|
+
it("should filter specs by extension only", () => {
|
|
445
|
+
const specs = [
|
|
446
|
+
{
|
|
447
|
+
filename: "js&ts.base.md",
|
|
448
|
+
extensions: ["js", "ts"],
|
|
449
|
+
type: "base",
|
|
450
|
+
content: "",
|
|
451
|
+
overrides: [],
|
|
452
|
+
severity: "error" as const,
|
|
453
|
+
includes: [],
|
|
454
|
+
rules: [],
|
|
455
|
+
},
|
|
456
|
+
{
|
|
457
|
+
filename: "js&ts.nest.md",
|
|
458
|
+
extensions: ["js", "ts"],
|
|
459
|
+
type: "nest",
|
|
460
|
+
content: "",
|
|
461
|
+
overrides: [],
|
|
462
|
+
severity: "error" as const,
|
|
463
|
+
includes: ["*.controller.ts", "*.service.ts"],
|
|
464
|
+
rules: [],
|
|
465
|
+
},
|
|
466
|
+
{
|
|
467
|
+
filename: "vue.base.md",
|
|
468
|
+
extensions: ["vue"],
|
|
469
|
+
type: "base",
|
|
470
|
+
content: "",
|
|
471
|
+
overrides: [],
|
|
472
|
+
severity: "error" as const,
|
|
473
|
+
includes: [],
|
|
474
|
+
rules: [],
|
|
475
|
+
},
|
|
476
|
+
];
|
|
477
|
+
|
|
478
|
+
const changedFiles = [
|
|
479
|
+
{ filename: "src/app.ts" },
|
|
480
|
+
{ filename: "src/user/user.controller.ts" },
|
|
481
|
+
];
|
|
482
|
+
|
|
483
|
+
const result = service.filterApplicableSpecs(specs, changedFiles);
|
|
484
|
+
|
|
485
|
+
// 只按扩展名过滤,includes 在 LLM 审查后处理
|
|
486
|
+
expect(result).toHaveLength(2);
|
|
487
|
+
expect(result.map((s) => s.filename)).toContain("js&ts.base.md");
|
|
488
|
+
expect(result.map((s) => s.filename)).toContain("js&ts.nest.md");
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
it("should exclude specs when extension does not match", () => {
|
|
492
|
+
const specs = [
|
|
493
|
+
{
|
|
494
|
+
filename: "js&ts.base.md",
|
|
495
|
+
extensions: ["js", "ts"],
|
|
496
|
+
type: "base",
|
|
497
|
+
content: "",
|
|
498
|
+
overrides: [],
|
|
499
|
+
severity: "error" as const,
|
|
500
|
+
includes: [],
|
|
501
|
+
rules: [],
|
|
502
|
+
},
|
|
503
|
+
{
|
|
504
|
+
filename: "vue.base.md",
|
|
505
|
+
extensions: ["vue"],
|
|
506
|
+
type: "base",
|
|
507
|
+
content: "",
|
|
508
|
+
overrides: [],
|
|
509
|
+
severity: "error" as const,
|
|
510
|
+
includes: [],
|
|
511
|
+
rules: [],
|
|
512
|
+
},
|
|
513
|
+
];
|
|
514
|
+
|
|
515
|
+
const changedFiles = [{ filename: "src/app.ts" }];
|
|
516
|
+
|
|
517
|
+
const result = service.filterApplicableSpecs(specs, changedFiles);
|
|
518
|
+
|
|
519
|
+
expect(result).toHaveLength(1);
|
|
520
|
+
expect(result[0].filename).toBe("js&ts.base.md");
|
|
521
|
+
});
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
describe("filterIssuesByIncludes", () => {
|
|
525
|
+
it("should filter issues by spec includes pattern", () => {
|
|
526
|
+
const specs = [
|
|
527
|
+
{
|
|
528
|
+
filename: "js&ts.nest.md",
|
|
529
|
+
extensions: ["js", "ts"],
|
|
530
|
+
type: "nest",
|
|
531
|
+
content: "",
|
|
532
|
+
overrides: [],
|
|
533
|
+
severity: "error" as const,
|
|
534
|
+
includes: ["*.controller.ts", "*.service.ts"],
|
|
535
|
+
rules: [{ id: "JsTs.Nest", title: "Nest", description: "", examples: [], overrides: [] }],
|
|
536
|
+
},
|
|
537
|
+
];
|
|
538
|
+
|
|
539
|
+
const issues = [
|
|
540
|
+
{ file: "src/user/user.controller.ts", ruleId: "JsTs.Nest.DirStructure", reason: "test" },
|
|
541
|
+
{ file: "src/app.ts", ruleId: "JsTs.Nest.DirStructure", reason: "test" },
|
|
542
|
+
];
|
|
543
|
+
|
|
544
|
+
const result = service.filterIssuesByIncludes(issues, specs);
|
|
545
|
+
|
|
546
|
+
expect(result).toHaveLength(1);
|
|
547
|
+
expect(result[0].file).toBe("src/user/user.controller.ts");
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
it("should keep all issues when spec has no includes", () => {
|
|
551
|
+
const specs = [
|
|
552
|
+
{
|
|
553
|
+
filename: "js&ts.base.md",
|
|
554
|
+
extensions: ["js", "ts"],
|
|
555
|
+
type: "base",
|
|
556
|
+
content: "",
|
|
557
|
+
overrides: [],
|
|
558
|
+
severity: "error" as const,
|
|
559
|
+
includes: [],
|
|
560
|
+
rules: [{ id: "JsTs.Base", title: "Base", description: "", examples: [], overrides: [] }],
|
|
561
|
+
},
|
|
562
|
+
];
|
|
563
|
+
|
|
564
|
+
const issues = [
|
|
565
|
+
{ file: "src/app.ts", ruleId: "JsTs.Base.Rule1", reason: "test" },
|
|
566
|
+
{ file: "src/utils/helper.ts", ruleId: "JsTs.Base.Rule2", reason: "test" },
|
|
567
|
+
];
|
|
568
|
+
|
|
569
|
+
const result = service.filterIssuesByIncludes(issues, specs);
|
|
570
|
+
|
|
571
|
+
expect(result).toHaveLength(2);
|
|
572
|
+
});
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
describe("matchRuleId", () => {
|
|
576
|
+
it("should match exact rule id", () => {
|
|
577
|
+
expect((service as any).matchRuleId("JsTs.FileName", "JsTs.FileName")).toBe(true);
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
it("should match prefix rule id", () => {
|
|
581
|
+
expect((service as any).matchRuleId("JsTs.FileName.LowerCamel", "JsTs.FileName")).toBe(true);
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
it("should not match different rule id", () => {
|
|
585
|
+
expect((service as any).matchRuleId("JsTs.Base", "JsTs.FileName")).toBe(false);
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
it("should return false for empty ruleId", () => {
|
|
589
|
+
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
590
|
+
expect((service as any).matchRuleId("", "pattern")).toBe(false);
|
|
591
|
+
consoleSpy.mockRestore();
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
it("should return false for empty pattern", () => {
|
|
595
|
+
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
596
|
+
expect((service as any).matchRuleId("ruleId", "")).toBe(false);
|
|
597
|
+
consoleSpy.mockRestore();
|
|
598
|
+
});
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
describe("findByRuleId", () => {
|
|
602
|
+
it("should find exact match", () => {
|
|
603
|
+
const map = new Map([["JsTs.Base", "value1"]]);
|
|
604
|
+
expect((service as any).findByRuleId("JsTs.Base", map)).toBe("value1");
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
it("should find prefix match", () => {
|
|
608
|
+
const map = new Map([["JsTs.Base", "value1"]]);
|
|
609
|
+
expect((service as any).findByRuleId("JsTs.Base.Rule1", map)).toBe("value1");
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
it("should return undefined for no match", () => {
|
|
613
|
+
const map = new Map([["JsTs.Base", "value1"]]);
|
|
614
|
+
expect((service as any).findByRuleId("Vue.Base", map)).toBeUndefined();
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
it("should return undefined for empty ruleId", () => {
|
|
618
|
+
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
619
|
+
const map = new Map([["JsTs.Base", "value1"]]);
|
|
620
|
+
expect((service as any).findByRuleId("", map)).toBeUndefined();
|
|
621
|
+
consoleSpy.mockRestore();
|
|
622
|
+
});
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
describe("filterIssuesByCommits", () => {
|
|
626
|
+
it("should filter issues by changed lines", () => {
|
|
627
|
+
const issues = [
|
|
628
|
+
{ file: "test.ts", line: "5", ruleId: "R1" },
|
|
629
|
+
{ file: "test.ts", line: "100", ruleId: "R2" },
|
|
630
|
+
];
|
|
631
|
+
const changedFiles = [
|
|
632
|
+
{ filename: "test.ts", patch: "@@ -1,3 +1,5 @@\n line1\n line2\n line3\n+line4\n+line5" },
|
|
633
|
+
];
|
|
634
|
+
const result = service.filterIssuesByCommits(issues, changedFiles);
|
|
635
|
+
expect(result).toHaveLength(1);
|
|
636
|
+
expect(result[0].line).toBe("5");
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
it("should keep issues when no patch info", () => {
|
|
640
|
+
const issues = [{ file: "test.ts", line: "5", ruleId: "R1" }];
|
|
641
|
+
const changedFiles = [{ filename: "test.ts" }];
|
|
642
|
+
const result = service.filterIssuesByCommits(issues, changedFiles);
|
|
643
|
+
expect(result).toHaveLength(1);
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
it("should handle range line format", () => {
|
|
647
|
+
const issues = [{ file: "test.ts", line: "4-5", ruleId: "R1" }];
|
|
648
|
+
const changedFiles = [
|
|
649
|
+
{ filename: "test.ts", patch: "@@ -1,3 +1,5 @@\n line1\n line2\n line3\n+line4\n+line5" },
|
|
650
|
+
];
|
|
651
|
+
const result = service.filterIssuesByCommits(issues, changedFiles);
|
|
652
|
+
expect(result).toHaveLength(1);
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
it("should skip files without filename", () => {
|
|
656
|
+
const issues = [{ file: "test.ts", line: "5", ruleId: "R1" }];
|
|
657
|
+
const changedFiles = [{ patch: "@@ -1,1 +1,1 @@\n+new" }];
|
|
658
|
+
const result = service.filterIssuesByCommits(issues, changedFiles);
|
|
659
|
+
expect(result).toHaveLength(1);
|
|
660
|
+
});
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
describe("parseChangedLinesFromPatch", () => {
|
|
664
|
+
it("should parse added lines", () => {
|
|
665
|
+
const patch = "@@ -1,2 +1,3 @@\n line1\n+added\n line2";
|
|
666
|
+
const lines = (service as any).parseChangedLinesFromPatch(patch);
|
|
667
|
+
expect(lines.has(2)).toBe(true);
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
it("should handle deleted lines without incrementing line number", () => {
|
|
671
|
+
const patch = "@@ -1,3 +1,2 @@\n line1\n-deleted\n line2";
|
|
672
|
+
const lines = (service as any).parseChangedLinesFromPatch(patch);
|
|
673
|
+
expect(lines.size).toBe(0);
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
it("should handle multiple hunks", () => {
|
|
677
|
+
const patch = "@@ -1,1 +1,2 @@\n line1\n+added1\n@@ -10,1 +11,2 @@\n line10\n+added2";
|
|
678
|
+
const lines = (service as any).parseChangedLinesFromPatch(patch);
|
|
679
|
+
expect(lines.has(2)).toBe(true);
|
|
680
|
+
expect(lines.has(12)).toBe(true);
|
|
681
|
+
});
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
describe("parseLineRange", () => {
|
|
685
|
+
it("should parse single line", () => {
|
|
686
|
+
expect(service.parseLineRange("10")).toEqual([10]);
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
it("should parse range", () => {
|
|
690
|
+
expect(service.parseLineRange("10-12")).toEqual([10, 11, 12]);
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
it("should return empty for invalid input", () => {
|
|
694
|
+
expect(service.parseLineRange("abc")).toEqual([]);
|
|
695
|
+
});
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
describe("buildSpecsSection", () => {
|
|
699
|
+
it("should build specs section text", () => {
|
|
700
|
+
const specs = [
|
|
701
|
+
{
|
|
702
|
+
filename: "js&ts.base.md",
|
|
703
|
+
extensions: ["js", "ts"],
|
|
704
|
+
type: "base",
|
|
705
|
+
content: "",
|
|
706
|
+
overrides: [],
|
|
707
|
+
severity: "error" as const,
|
|
708
|
+
includes: [],
|
|
709
|
+
rules: [
|
|
710
|
+
{
|
|
711
|
+
id: "JsTs.Base",
|
|
712
|
+
title: "基础规范",
|
|
713
|
+
description: "desc",
|
|
714
|
+
examples: [],
|
|
715
|
+
overrides: [],
|
|
716
|
+
},
|
|
717
|
+
{
|
|
718
|
+
id: "JsTs.Base.Rule1",
|
|
719
|
+
title: "Rule1",
|
|
720
|
+
description: "rule desc",
|
|
721
|
+
examples: [{ lang: "ts", code: "const x = 1;", type: "good" as const }],
|
|
722
|
+
overrides: [],
|
|
723
|
+
},
|
|
724
|
+
],
|
|
725
|
+
},
|
|
726
|
+
];
|
|
727
|
+
const result = service.buildSpecsSection(specs);
|
|
728
|
+
expect(result).toContain("基础规范");
|
|
729
|
+
expect(result).toContain("Rule1");
|
|
730
|
+
expect(result).toContain("推荐做法");
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
it("should handle rules without examples", () => {
|
|
734
|
+
const specs = [
|
|
735
|
+
{
|
|
736
|
+
filename: "test.md",
|
|
737
|
+
extensions: ["ts"],
|
|
738
|
+
type: "test",
|
|
739
|
+
content: "",
|
|
740
|
+
overrides: [],
|
|
741
|
+
severity: "error" as const,
|
|
742
|
+
includes: [],
|
|
743
|
+
rules: [
|
|
744
|
+
{ id: "Test", title: "Test", description: "", examples: [], overrides: [] },
|
|
745
|
+
{ id: "Test.Rule1", title: "Rule1", description: "desc", examples: [], overrides: [] },
|
|
746
|
+
],
|
|
747
|
+
},
|
|
748
|
+
];
|
|
749
|
+
const result = service.buildSpecsSection(specs);
|
|
750
|
+
expect(result).toContain("Rule1");
|
|
751
|
+
});
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
describe("findRuleById", () => {
|
|
755
|
+
const specs = [
|
|
756
|
+
{
|
|
757
|
+
filename: "test.md",
|
|
758
|
+
extensions: ["ts"],
|
|
759
|
+
type: "test",
|
|
760
|
+
content: "",
|
|
761
|
+
overrides: [],
|
|
762
|
+
severity: "error" as const,
|
|
763
|
+
includes: [],
|
|
764
|
+
rules: [
|
|
765
|
+
{ id: "JsTs.Base", title: "Base", description: "", examples: [], overrides: [] },
|
|
766
|
+
{ id: "JsTs.Base.Rule1", title: "Rule1", description: "", examples: [], overrides: [] },
|
|
767
|
+
],
|
|
768
|
+
},
|
|
769
|
+
];
|
|
770
|
+
|
|
771
|
+
it("should find rule by exact id", () => {
|
|
772
|
+
const result = service.findRuleById("JsTs.Base", specs);
|
|
773
|
+
expect(result?.rule.id).toBe("JsTs.Base");
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
it("should return null for non-existent rule", () => {
|
|
777
|
+
const result = service.findRuleById("NonExistent", specs);
|
|
778
|
+
expect(result).toBeNull();
|
|
779
|
+
});
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
describe("filterIssuesByRuleExistence", () => {
|
|
783
|
+
const specs = [
|
|
784
|
+
{
|
|
785
|
+
filename: "test.md",
|
|
786
|
+
extensions: ["ts"],
|
|
787
|
+
type: "test",
|
|
788
|
+
content: "",
|
|
789
|
+
overrides: [],
|
|
790
|
+
severity: "error" as const,
|
|
791
|
+
includes: [],
|
|
792
|
+
rules: [{ id: "JsTs.Base", title: "Base", description: "", examples: [], overrides: [] }],
|
|
793
|
+
},
|
|
794
|
+
];
|
|
795
|
+
|
|
796
|
+
it("should keep issues with existing rules", () => {
|
|
797
|
+
const issues = [{ ruleId: "JsTs.Base" }];
|
|
798
|
+
const result = service.filterIssuesByRuleExistence(issues, specs);
|
|
799
|
+
expect(result).toHaveLength(1);
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
it("should remove issues with non-existent rules", () => {
|
|
803
|
+
const issues = [{ ruleId: "NonExistent" }];
|
|
804
|
+
const result = service.filterIssuesByRuleExistence(issues, specs);
|
|
805
|
+
expect(result).toHaveLength(0);
|
|
806
|
+
});
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
describe("deduplicateSpecs", () => {
|
|
810
|
+
it("should return same specs when no duplicates", () => {
|
|
811
|
+
const specs = [
|
|
812
|
+
{
|
|
813
|
+
filename: "test.md",
|
|
814
|
+
extensions: ["ts"],
|
|
815
|
+
type: "test",
|
|
816
|
+
content: "",
|
|
817
|
+
overrides: [],
|
|
818
|
+
severity: "error" as const,
|
|
819
|
+
includes: [],
|
|
820
|
+
rules: [{ id: "Rule1", title: "R1", description: "", examples: [], overrides: [] }],
|
|
821
|
+
},
|
|
822
|
+
];
|
|
823
|
+
const result = service.deduplicateSpecs(specs);
|
|
824
|
+
expect(result).toBe(specs);
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
it("should remove earlier duplicate rules", () => {
|
|
828
|
+
const specs = [
|
|
829
|
+
{
|
|
830
|
+
filename: "base.md",
|
|
831
|
+
extensions: ["ts"],
|
|
832
|
+
type: "base",
|
|
833
|
+
content: "",
|
|
834
|
+
overrides: [],
|
|
835
|
+
severity: "error" as const,
|
|
836
|
+
includes: [],
|
|
837
|
+
rules: [{ id: "Rule1", title: "R1-old", description: "", examples: [], overrides: [] }],
|
|
838
|
+
},
|
|
839
|
+
{
|
|
840
|
+
filename: "override.md",
|
|
841
|
+
extensions: ["ts"],
|
|
842
|
+
type: "override",
|
|
843
|
+
content: "",
|
|
844
|
+
overrides: [],
|
|
845
|
+
severity: "error" as const,
|
|
846
|
+
includes: [],
|
|
847
|
+
rules: [{ id: "Rule1", title: "R1-new", description: "", examples: [], overrides: [] }],
|
|
848
|
+
},
|
|
849
|
+
];
|
|
850
|
+
const result = service.deduplicateSpecs(specs);
|
|
851
|
+
expect(result).toHaveLength(1);
|
|
852
|
+
expect(result[0].rules[0].title).toBe("R1-new");
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
it("should keep non-duplicate rules in same spec", () => {
|
|
856
|
+
const specs = [
|
|
857
|
+
{
|
|
858
|
+
filename: "base.md",
|
|
859
|
+
extensions: ["ts"],
|
|
860
|
+
type: "base",
|
|
861
|
+
content: "",
|
|
862
|
+
overrides: [],
|
|
863
|
+
severity: "error" as const,
|
|
864
|
+
includes: [],
|
|
865
|
+
rules: [
|
|
866
|
+
{ id: "Rule1", title: "R1", description: "", examples: [], overrides: [] },
|
|
867
|
+
{ id: "Rule2", title: "R2", description: "", examples: [], overrides: [] },
|
|
868
|
+
],
|
|
869
|
+
},
|
|
870
|
+
{
|
|
871
|
+
filename: "override.md",
|
|
872
|
+
extensions: ["ts"],
|
|
873
|
+
type: "override",
|
|
874
|
+
content: "",
|
|
875
|
+
overrides: [],
|
|
876
|
+
severity: "error" as const,
|
|
877
|
+
includes: [],
|
|
878
|
+
rules: [{ id: "Rule1", title: "R1-new", description: "", examples: [], overrides: [] }],
|
|
879
|
+
},
|
|
880
|
+
];
|
|
881
|
+
const result = service.deduplicateSpecs(specs);
|
|
882
|
+
expect(result).toHaveLength(2);
|
|
883
|
+
expect(result[0].rules).toHaveLength(1);
|
|
884
|
+
expect(result[0].rules[0].id).toBe("Rule2");
|
|
885
|
+
});
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
describe("formatIssues", () => {
|
|
889
|
+
it("should override severity from spec", () => {
|
|
890
|
+
const issues = [{ ruleId: "JsTs.Base.Rule1", severity: "error" as const }];
|
|
891
|
+
const specs = [
|
|
892
|
+
{
|
|
893
|
+
filename: "test.md",
|
|
894
|
+
extensions: ["ts"],
|
|
895
|
+
type: "test",
|
|
896
|
+
content: "",
|
|
897
|
+
overrides: [],
|
|
898
|
+
severity: "warn" as const,
|
|
899
|
+
includes: [],
|
|
900
|
+
rules: [
|
|
901
|
+
{ id: "JsTs.Base.Rule1", title: "R1", description: "", examples: [], overrides: [] },
|
|
902
|
+
],
|
|
903
|
+
},
|
|
904
|
+
];
|
|
905
|
+
const result = service.formatIssues(issues, { specs, changedFiles: [] });
|
|
906
|
+
expect(result[0].severity).toBe("warn");
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
it("should keep original severity when no spec match", () => {
|
|
910
|
+
const issues = [{ ruleId: "Unknown.Rule", severity: "error" as const }];
|
|
911
|
+
const specs = [
|
|
912
|
+
{
|
|
913
|
+
filename: "test.md",
|
|
914
|
+
extensions: ["ts"],
|
|
915
|
+
type: "test",
|
|
916
|
+
content: "",
|
|
917
|
+
overrides: [],
|
|
918
|
+
severity: "warn" as const,
|
|
919
|
+
includes: [],
|
|
920
|
+
rules: [{ id: "JsTs.Base", title: "Base", description: "", examples: [], overrides: [] }],
|
|
921
|
+
},
|
|
922
|
+
];
|
|
923
|
+
const result = service.formatIssues(issues, { specs, changedFiles: [] });
|
|
924
|
+
expect(result[0].severity).toBe("error");
|
|
925
|
+
});
|
|
926
|
+
|
|
927
|
+
it("should keep severity when same as spec", () => {
|
|
928
|
+
const issues = [{ ruleId: "JsTs.Base", severity: "error" as const }];
|
|
929
|
+
const specs = [
|
|
930
|
+
{
|
|
931
|
+
filename: "test.md",
|
|
932
|
+
extensions: ["ts"],
|
|
933
|
+
type: "test",
|
|
934
|
+
content: "",
|
|
935
|
+
overrides: [],
|
|
936
|
+
severity: "error" as const,
|
|
937
|
+
includes: [],
|
|
938
|
+
rules: [{ id: "JsTs.Base", title: "Base", description: "", examples: [], overrides: [] }],
|
|
939
|
+
},
|
|
940
|
+
];
|
|
941
|
+
const result = service.formatIssues(issues, { specs, changedFiles: [] });
|
|
942
|
+
expect(result[0].severity).toBe("error");
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
it("should use rule-level severity over spec-level", () => {
|
|
946
|
+
const issues = [{ ruleId: "JsTs.Base.Rule1", severity: "error" as const }];
|
|
947
|
+
const specs = [
|
|
948
|
+
{
|
|
949
|
+
filename: "test.md",
|
|
950
|
+
extensions: ["ts"],
|
|
951
|
+
type: "test",
|
|
952
|
+
content: "",
|
|
953
|
+
overrides: [],
|
|
954
|
+
severity: "error" as const,
|
|
955
|
+
includes: [],
|
|
956
|
+
rules: [
|
|
957
|
+
{
|
|
958
|
+
id: "JsTs.Base.Rule1",
|
|
959
|
+
title: "R1",
|
|
960
|
+
description: "",
|
|
961
|
+
examples: [],
|
|
962
|
+
overrides: [],
|
|
963
|
+
severity: "warn" as const,
|
|
964
|
+
},
|
|
965
|
+
],
|
|
966
|
+
},
|
|
967
|
+
];
|
|
968
|
+
const result = service.formatIssues(issues, { specs, changedFiles: [] });
|
|
969
|
+
expect(result[0].severity).toBe("warn");
|
|
970
|
+
});
|
|
971
|
+
});
|
|
972
|
+
|
|
973
|
+
describe("extractRepoName", () => {
|
|
974
|
+
it("should extract from https URL", () => {
|
|
975
|
+
const result = (service as any).extractRepoName("https://github.com/org/repo.git");
|
|
976
|
+
expect(result).toBe("org__repo");
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
it("should extract from SSH URL", () => {
|
|
980
|
+
const result = (service as any).extractRepoName("git@github.com:org/repo.git");
|
|
981
|
+
expect(result).toBe("org__repo");
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
it("should handle single part path", () => {
|
|
985
|
+
const result = (service as any).extractRepoName("repo");
|
|
986
|
+
expect(result).toBe("repo");
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
it("should return null for empty path", () => {
|
|
990
|
+
const result = (service as any).extractRepoName("https://github.com/");
|
|
991
|
+
expect(result).toBeNull();
|
|
992
|
+
});
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
describe("isRepoUrl", () => {
|
|
996
|
+
it("should detect https URL", () => {
|
|
997
|
+
expect((service as any).isRepoUrl("https://github.com/org/repo")).toBe(true);
|
|
998
|
+
});
|
|
999
|
+
|
|
1000
|
+
it("should detect http URL", () => {
|
|
1001
|
+
expect((service as any).isRepoUrl("http://github.com/org/repo")).toBe(true);
|
|
1002
|
+
});
|
|
1003
|
+
|
|
1004
|
+
it("should detect git@ URL", () => {
|
|
1005
|
+
expect((service as any).isRepoUrl("git@github.com:org/repo")).toBe(true);
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
it("should detect custom protocol URL", () => {
|
|
1009
|
+
expect((service as any).isRepoUrl("git+ssh://github.com/org/repo")).toBe(true);
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
it("should return false for local path", () => {
|
|
1013
|
+
expect((service as any).isRepoUrl("/home/user/specs")).toBe(false);
|
|
1014
|
+
});
|
|
1015
|
+
});
|
|
1016
|
+
|
|
1017
|
+
describe("loadReviewSpecs - ENOENT handling", () => {
|
|
1018
|
+
it("should silently skip ENOENT errors", async () => {
|
|
1019
|
+
const enoentError = new Error("ENOENT") as NodeJS.ErrnoException;
|
|
1020
|
+
enoentError.code = "ENOENT";
|
|
1021
|
+
(readdir as Mock).mockRejectedValue(enoentError);
|
|
1022
|
+
|
|
1023
|
+
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
1024
|
+
const specs = await service.loadReviewSpecs("/nonexistent");
|
|
1025
|
+
expect(specs).toHaveLength(0);
|
|
1026
|
+
expect(consoleSpy).not.toHaveBeenCalled();
|
|
1027
|
+
consoleSpy.mockRestore();
|
|
1028
|
+
});
|
|
1029
|
+
});
|
|
1030
|
+
|
|
1031
|
+
describe("filterIssuesByOverrides", () => {
|
|
1032
|
+
it("should filter issues by override rules with global scope (empty includes)", () => {
|
|
1033
|
+
const specs = [
|
|
1034
|
+
{
|
|
1035
|
+
filename: "js&ts.nest.md",
|
|
1036
|
+
extensions: ["js", "ts"],
|
|
1037
|
+
type: "nest",
|
|
1038
|
+
content: "",
|
|
1039
|
+
overrides: ["JsTs.FileName"],
|
|
1040
|
+
severity: "error" as const,
|
|
1041
|
+
includes: [], // 空 includes = 全局作用域
|
|
1042
|
+
rules: [],
|
|
1043
|
+
},
|
|
1044
|
+
];
|
|
1045
|
+
|
|
1046
|
+
const issues = [
|
|
1047
|
+
{ ruleId: "JsTs.FileName", file: "src/app.ts", reason: "test" },
|
|
1048
|
+
{ ruleId: "JsTs.FileName.LowerCamel", file: "src/user.ts", reason: "test" },
|
|
1049
|
+
{ ruleId: "JsTs.Nest.DirStructure", file: "src/module.ts", reason: "test" },
|
|
1050
|
+
];
|
|
1051
|
+
|
|
1052
|
+
const result = service.filterIssuesByOverrides(issues, specs);
|
|
1053
|
+
|
|
1054
|
+
expect(result).toHaveLength(1);
|
|
1055
|
+
expect(result[0].ruleId).toBe("JsTs.Nest.DirStructure");
|
|
1056
|
+
});
|
|
1057
|
+
|
|
1058
|
+
it("should keep all issues when no overrides", () => {
|
|
1059
|
+
const specs = [
|
|
1060
|
+
{
|
|
1061
|
+
filename: "js&ts.base.md",
|
|
1062
|
+
extensions: ["js", "ts"],
|
|
1063
|
+
type: "base",
|
|
1064
|
+
content: "",
|
|
1065
|
+
overrides: [],
|
|
1066
|
+
severity: "error" as const,
|
|
1067
|
+
includes: [],
|
|
1068
|
+
rules: [],
|
|
1069
|
+
},
|
|
1070
|
+
];
|
|
1071
|
+
|
|
1072
|
+
const issues = [
|
|
1073
|
+
{ ruleId: "JsTs.Base.Rule1", file: "src/app.ts", reason: "test" },
|
|
1074
|
+
{ ruleId: "JsTs.Base.Rule2", file: "src/user.ts", reason: "test" },
|
|
1075
|
+
];
|
|
1076
|
+
|
|
1077
|
+
const result = service.filterIssuesByOverrides(issues, specs);
|
|
1078
|
+
|
|
1079
|
+
expect(result).toHaveLength(2);
|
|
1080
|
+
});
|
|
1081
|
+
|
|
1082
|
+
it("should only apply override within its includes scope", () => {
|
|
1083
|
+
const specs = [
|
|
1084
|
+
{
|
|
1085
|
+
filename: "controller-spec.md",
|
|
1086
|
+
extensions: ["ts"],
|
|
1087
|
+
type: "nest",
|
|
1088
|
+
content: "",
|
|
1089
|
+
overrides: ["JsTs.Base.Rule1"], // 只在 controller 文件中覆盖
|
|
1090
|
+
severity: "error" as const,
|
|
1091
|
+
includes: ["*.controller.ts"],
|
|
1092
|
+
rules: [],
|
|
1093
|
+
},
|
|
1094
|
+
];
|
|
1095
|
+
|
|
1096
|
+
const issues = [
|
|
1097
|
+
{ ruleId: "JsTs.Base.Rule1", file: "src/user.controller.ts", reason: "test" }, // 应被过滤
|
|
1098
|
+
{ ruleId: "JsTs.Base.Rule1", file: "src/user.service.ts", reason: "test" }, // 应保留(不在作用域内)
|
|
1099
|
+
{ ruleId: "JsTs.Base.Rule2", file: "src/user.controller.ts", reason: "test" }, // 应保留(ruleId 不匹配)
|
|
1100
|
+
];
|
|
1101
|
+
|
|
1102
|
+
const result = service.filterIssuesByOverrides(issues, specs);
|
|
1103
|
+
|
|
1104
|
+
expect(result).toHaveLength(2);
|
|
1105
|
+
expect(result.map((i) => i.file)).toEqual(["src/user.service.ts", "src/user.controller.ts"]);
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
it("should handle multiple specs with different scopes", () => {
|
|
1109
|
+
const specs = [
|
|
1110
|
+
{
|
|
1111
|
+
filename: "controller-spec.md",
|
|
1112
|
+
extensions: ["ts"],
|
|
1113
|
+
type: "nest",
|
|
1114
|
+
content: "",
|
|
1115
|
+
overrides: ["JsTs.Base.Rule1"],
|
|
1116
|
+
severity: "error" as const,
|
|
1117
|
+
includes: ["*.controller.ts"],
|
|
1118
|
+
rules: [],
|
|
1119
|
+
},
|
|
1120
|
+
{
|
|
1121
|
+
filename: "service-spec.md",
|
|
1122
|
+
extensions: ["ts"],
|
|
1123
|
+
type: "nest",
|
|
1124
|
+
content: "",
|
|
1125
|
+
overrides: ["JsTs.Base.Rule2"],
|
|
1126
|
+
severity: "error" as const,
|
|
1127
|
+
includes: ["*.service.ts"],
|
|
1128
|
+
rules: [],
|
|
1129
|
+
},
|
|
1130
|
+
];
|
|
1131
|
+
|
|
1132
|
+
const issues = [
|
|
1133
|
+
{ ruleId: "JsTs.Base.Rule1", file: "src/user.controller.ts", reason: "test" }, // 被 controller-spec 过滤
|
|
1134
|
+
{ ruleId: "JsTs.Base.Rule1", file: "src/user.service.ts", reason: "test" }, // 保留
|
|
1135
|
+
{ ruleId: "JsTs.Base.Rule2", file: "src/user.service.ts", reason: "test" }, // 被 service-spec 过滤
|
|
1136
|
+
{ ruleId: "JsTs.Base.Rule2", file: "src/user.controller.ts", reason: "test" }, // 保留
|
|
1137
|
+
];
|
|
1138
|
+
|
|
1139
|
+
const result = service.filterIssuesByOverrides(issues, specs);
|
|
1140
|
+
|
|
1141
|
+
expect(result).toHaveLength(2);
|
|
1142
|
+
expect(result.map((i) => `${i.ruleId}@${i.file}`)).toEqual([
|
|
1143
|
+
"JsTs.Base.Rule1@src/user.service.ts",
|
|
1144
|
+
"JsTs.Base.Rule2@src/user.controller.ts",
|
|
1145
|
+
]);
|
|
1146
|
+
});
|
|
1147
|
+
|
|
1148
|
+
it("should log with verbose=1 when overrides skip issues", () => {
|
|
1149
|
+
const specs = [
|
|
1150
|
+
{
|
|
1151
|
+
filename: "nest.md",
|
|
1152
|
+
extensions: ["ts"],
|
|
1153
|
+
type: "nest",
|
|
1154
|
+
content: "",
|
|
1155
|
+
overrides: ["JsTs.FileName"],
|
|
1156
|
+
severity: "error" as const,
|
|
1157
|
+
includes: [],
|
|
1158
|
+
rules: [],
|
|
1159
|
+
},
|
|
1160
|
+
];
|
|
1161
|
+
const issues = [{ ruleId: "JsTs.FileName", file: "src/app.ts", line: "10" }];
|
|
1162
|
+
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
1163
|
+
const result = service.filterIssuesByOverrides(issues, specs, 1);
|
|
1164
|
+
expect(result).toHaveLength(0);
|
|
1165
|
+
expect(consoleSpy).toHaveBeenCalled();
|
|
1166
|
+
consoleSpy.mockRestore();
|
|
1167
|
+
});
|
|
1168
|
+
|
|
1169
|
+
it("should log with verbose=3 for detailed override collection", () => {
|
|
1170
|
+
const specs = [
|
|
1171
|
+
{
|
|
1172
|
+
filename: "nest.md",
|
|
1173
|
+
extensions: ["ts"],
|
|
1174
|
+
type: "nest",
|
|
1175
|
+
content: "",
|
|
1176
|
+
overrides: ["JsTs.FileName"],
|
|
1177
|
+
severity: "error" as const,
|
|
1178
|
+
includes: [],
|
|
1179
|
+
rules: [
|
|
1180
|
+
{
|
|
1181
|
+
id: "JsTs.Nest",
|
|
1182
|
+
title: "Nest",
|
|
1183
|
+
description: "",
|
|
1184
|
+
examples: [],
|
|
1185
|
+
overrides: ["JsTs.Base"],
|
|
1186
|
+
},
|
|
1187
|
+
],
|
|
1188
|
+
},
|
|
1189
|
+
];
|
|
1190
|
+
const issues = [{ ruleId: "Other.Rule", file: "src/app.ts" }];
|
|
1191
|
+
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
1192
|
+
service.filterIssuesByOverrides(issues, specs, 3);
|
|
1193
|
+
expect(consoleSpy).toHaveBeenCalled();
|
|
1194
|
+
consoleSpy.mockRestore();
|
|
1195
|
+
});
|
|
1196
|
+
|
|
1197
|
+
it("should handle issue without file field", () => {
|
|
1198
|
+
const specs = [
|
|
1199
|
+
{
|
|
1200
|
+
filename: "nest.md",
|
|
1201
|
+
extensions: ["ts"],
|
|
1202
|
+
type: "nest",
|
|
1203
|
+
content: "",
|
|
1204
|
+
overrides: ["JsTs.FileName"],
|
|
1205
|
+
severity: "error" as const,
|
|
1206
|
+
includes: [],
|
|
1207
|
+
rules: [],
|
|
1208
|
+
},
|
|
1209
|
+
];
|
|
1210
|
+
const issues = [{ ruleId: "JsTs.FileName" }];
|
|
1211
|
+
const result = service.filterIssuesByOverrides(issues, specs);
|
|
1212
|
+
expect(result).toHaveLength(0);
|
|
1213
|
+
});
|
|
1214
|
+
});
|
|
1215
|
+
|
|
1216
|
+
describe("applyOverrides - verbose", () => {
|
|
1217
|
+
it("should log overridden rules with verbose=2", () => {
|
|
1218
|
+
const specs = [
|
|
1219
|
+
{
|
|
1220
|
+
filename: "base.md",
|
|
1221
|
+
extensions: ["ts"],
|
|
1222
|
+
type: "base",
|
|
1223
|
+
content: "",
|
|
1224
|
+
overrides: [],
|
|
1225
|
+
severity: "error" as const,
|
|
1226
|
+
includes: [],
|
|
1227
|
+
rules: [
|
|
1228
|
+
{ id: "JsTs.FileName", title: "FN", description: "", examples: [], overrides: [] },
|
|
1229
|
+
],
|
|
1230
|
+
},
|
|
1231
|
+
{
|
|
1232
|
+
filename: "nest.md",
|
|
1233
|
+
extensions: ["ts"],
|
|
1234
|
+
type: "nest",
|
|
1235
|
+
content: "",
|
|
1236
|
+
overrides: ["JsTs.FileName"],
|
|
1237
|
+
severity: "error" as const,
|
|
1238
|
+
includes: [],
|
|
1239
|
+
rules: [{ id: "JsTs.Nest", title: "Nest", description: "", examples: [], overrides: [] }],
|
|
1240
|
+
},
|
|
1241
|
+
];
|
|
1242
|
+
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
1243
|
+
const result = service.applyOverrides(specs as any, 2);
|
|
1244
|
+
expect(result).toHaveLength(1);
|
|
1245
|
+
expect(consoleSpy).toHaveBeenCalled();
|
|
1246
|
+
consoleSpy.mockRestore();
|
|
1247
|
+
});
|
|
1248
|
+
});
|
|
1249
|
+
|
|
1250
|
+
describe("resolveSpecSources", () => {
|
|
1251
|
+
it("should resolve local directory source", async () => {
|
|
1252
|
+
(readdir as Mock).mockResolvedValue([
|
|
1253
|
+
{ name: "spec.md", isFile: () => true, isDirectory: () => false },
|
|
1254
|
+
]);
|
|
1255
|
+
const result = await service.resolveSpecSources(["/local/dir"]);
|
|
1256
|
+
expect(result).toContain("/local/dir");
|
|
1257
|
+
});
|
|
1258
|
+
|
|
1259
|
+
it("should resolve repo URL source via cloneSpecRepo", async () => {
|
|
1260
|
+
(access as Mock).mockRejectedValue(new Error("not found"));
|
|
1261
|
+
(mkdir as Mock).mockResolvedValue(undefined);
|
|
1262
|
+
(child_process.execSync as Mock).mockReturnValue("");
|
|
1263
|
+
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
1264
|
+
const result = await service.resolveSpecSources(["https://github.com/org/repo.git"]);
|
|
1265
|
+
expect(result.length).toBeGreaterThanOrEqual(0);
|
|
1266
|
+
consoleSpy.mockRestore();
|
|
1267
|
+
});
|
|
1268
|
+
|
|
1269
|
+
it("should resolve remote repo URL via fetchRemoteSpecs", async () => {
|
|
1270
|
+
gitProvider.listRepositoryContents.mockResolvedValue([
|
|
1271
|
+
{ type: "file", name: "spec.md", path: "spec.md" },
|
|
1272
|
+
]);
|
|
1273
|
+
gitProvider.getFileContent.mockResolvedValue("# Rule `[Test.Rule]`");
|
|
1274
|
+
(mkdir as Mock).mockResolvedValue(undefined);
|
|
1275
|
+
(writeFile as Mock).mockResolvedValue(undefined);
|
|
1276
|
+
// parseRepoUrl 需要能解析的 URL
|
|
1277
|
+
const result = await service.resolveSpecSources([
|
|
1278
|
+
"https://github.com/org/repo/tree/main/references",
|
|
1279
|
+
]);
|
|
1280
|
+
expect(result.length).toBeGreaterThanOrEqual(0);
|
|
1281
|
+
});
|
|
1282
|
+
});
|
|
1283
|
+
|
|
1284
|
+
describe("fetchRemoteSpecs", () => {
|
|
1285
|
+
it("should fetch and cache remote specs", async () => {
|
|
1286
|
+
gitProvider.listRepositoryContents.mockResolvedValue([
|
|
1287
|
+
{ type: "file", name: "rule.md", path: "rule.md" },
|
|
1288
|
+
]);
|
|
1289
|
+
gitProvider.getFileContent.mockResolvedValue("# Rule `[Test.Rule]`");
|
|
1290
|
+
(mkdir as Mock).mockResolvedValue(undefined);
|
|
1291
|
+
(writeFile as Mock).mockResolvedValue(undefined);
|
|
1292
|
+
|
|
1293
|
+
const ref = { owner: "org", repo: "repo", path: "references", ref: "main" };
|
|
1294
|
+
const result = await (service as any).fetchRemoteSpecs(ref);
|
|
1295
|
+
expect(result).toBeTruthy();
|
|
1296
|
+
});
|
|
1297
|
+
|
|
1298
|
+
it("should return null when no md files found", async () => {
|
|
1299
|
+
gitProvider.listRepositoryContents.mockResolvedValue([
|
|
1300
|
+
{ type: "file", name: "readme.txt", path: "readme.txt" },
|
|
1301
|
+
]);
|
|
1302
|
+
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
1303
|
+
const ref = { owner: "org", repo: "repo" };
|
|
1304
|
+
const result = await (service as any).fetchRemoteSpecs(ref);
|
|
1305
|
+
expect(result).toBeNull();
|
|
1306
|
+
consoleSpy.mockRestore();
|
|
1307
|
+
});
|
|
1308
|
+
|
|
1309
|
+
it("should handle API failure and use expired cache", async () => {
|
|
1310
|
+
gitProvider.listRepositoryContents.mockRejectedValue(new Error("API error"));
|
|
1311
|
+
(readdir as Mock).mockResolvedValue(["cached.md"]);
|
|
1312
|
+
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
1313
|
+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
1314
|
+
const ref = { owner: "org", repo: "repo" };
|
|
1315
|
+
const result = await (service as any).fetchRemoteSpecs(ref);
|
|
1316
|
+
expect(result).toBeTruthy();
|
|
1317
|
+
consoleSpy.mockRestore();
|
|
1318
|
+
logSpy.mockRestore();
|
|
1319
|
+
});
|
|
1320
|
+
|
|
1321
|
+
it("should handle API failure without cache", async () => {
|
|
1322
|
+
gitProvider.listRepositoryContents.mockRejectedValue(new Error("API error"));
|
|
1323
|
+
(readdir as Mock).mockRejectedValue(new Error("no cache"));
|
|
1324
|
+
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
1325
|
+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
1326
|
+
const ref = { owner: "org", repo: "repo" };
|
|
1327
|
+
const result = await (service as any).fetchRemoteSpecs(ref);
|
|
1328
|
+
expect(result).toBeNull();
|
|
1329
|
+
consoleSpy.mockRestore();
|
|
1330
|
+
logSpy.mockRestore();
|
|
1331
|
+
});
|
|
1332
|
+
|
|
1333
|
+
it("should use valid cache in non-CI environment", async () => {
|
|
1334
|
+
const originalCI = process.env.CI;
|
|
1335
|
+
delete process.env.CI;
|
|
1336
|
+
(readFile as Mock).mockResolvedValue(String(Date.now()));
|
|
1337
|
+
(readdir as Mock).mockResolvedValue(["cached.md"]);
|
|
1338
|
+
const ref = { owner: "org", repo: "repo" };
|
|
1339
|
+
const result = await (service as any).fetchRemoteSpecs(ref);
|
|
1340
|
+
expect(result).toBeTruthy();
|
|
1341
|
+
process.env.CI = originalCI;
|
|
1342
|
+
});
|
|
1343
|
+
});
|
|
1344
|
+
|
|
1345
|
+
describe("resolveDepsDir", () => {
|
|
1346
|
+
it("should return dir if it contains md files", async () => {
|
|
1347
|
+
(readdir as Mock).mockResolvedValue([
|
|
1348
|
+
{ name: "spec.md", isFile: () => true, isDirectory: () => false },
|
|
1349
|
+
]);
|
|
1350
|
+
const result = await (service as any).resolveDepsDir("/deps/dir");
|
|
1351
|
+
expect(result).toContain("/deps/dir");
|
|
1352
|
+
});
|
|
1353
|
+
|
|
1354
|
+
it("should scan subdirectories for references folder", async () => {
|
|
1355
|
+
(readdir as Mock).mockResolvedValueOnce([
|
|
1356
|
+
{ name: "pkg1", isFile: () => false, isDirectory: () => true },
|
|
1357
|
+
]);
|
|
1358
|
+
(access as Mock).mockResolvedValue(undefined);
|
|
1359
|
+
const result = await (service as any).resolveDepsDir("/deps");
|
|
1360
|
+
expect(result).toContain("/deps/pkg1/references");
|
|
1361
|
+
});
|
|
1362
|
+
|
|
1363
|
+
it("should scan subdirectory itself when no references folder", async () => {
|
|
1364
|
+
(readdir as Mock)
|
|
1365
|
+
.mockResolvedValueOnce([{ name: "pkg1", isFile: () => false, isDirectory: () => true }])
|
|
1366
|
+
.mockResolvedValueOnce(["spec.md"]);
|
|
1367
|
+
(access as Mock).mockRejectedValue(new Error("not found"));
|
|
1368
|
+
const result = await (service as any).resolveDepsDir("/deps");
|
|
1369
|
+
expect(result).toContain("/deps/pkg1");
|
|
1370
|
+
});
|
|
1371
|
+
|
|
1372
|
+
it("should handle non-existent directory", async () => {
|
|
1373
|
+
(readdir as Mock).mockRejectedValue(new Error("ENOENT"));
|
|
1374
|
+
const result = await (service as any).resolveDepsDir("/nonexistent");
|
|
1375
|
+
expect(result).toHaveLength(0);
|
|
1376
|
+
});
|
|
1377
|
+
|
|
1378
|
+
it("should skip subdirectory without md files", async () => {
|
|
1379
|
+
(readdir as Mock)
|
|
1380
|
+
.mockResolvedValueOnce([{ name: "pkg1", isFile: () => false, isDirectory: () => true }])
|
|
1381
|
+
.mockResolvedValueOnce(["readme.txt"]);
|
|
1382
|
+
(access as Mock).mockRejectedValue(new Error("not found"));
|
|
1383
|
+
const result = await (service as any).resolveDepsDir("/deps");
|
|
1384
|
+
expect(result).toHaveLength(0);
|
|
1385
|
+
});
|
|
1386
|
+
|
|
1387
|
+
it("should handle unreadable subdirectory", async () => {
|
|
1388
|
+
(readdir as Mock)
|
|
1389
|
+
.mockResolvedValueOnce([{ name: "pkg1", isFile: () => false, isDirectory: () => true }])
|
|
1390
|
+
.mockRejectedValueOnce(new Error("permission denied"));
|
|
1391
|
+
(access as Mock).mockRejectedValue(new Error("not found"));
|
|
1392
|
+
const result = await (service as any).resolveDepsDir("/deps");
|
|
1393
|
+
expect(result).toHaveLength(0);
|
|
1394
|
+
});
|
|
1395
|
+
});
|
|
1396
|
+
|
|
1397
|
+
describe("cloneSpecRepo", () => {
|
|
1398
|
+
it("should use cached repo and pull updates", async () => {
|
|
1399
|
+
(access as Mock).mockResolvedValue(undefined);
|
|
1400
|
+
(child_process.execSync as Mock).mockReturnValue("");
|
|
1401
|
+
const result = await (service as any).cloneSpecRepo("https://github.com/org/repo.git");
|
|
1402
|
+
expect(result).toBeTruthy();
|
|
1403
|
+
});
|
|
1404
|
+
|
|
1405
|
+
it("should handle pull failure gracefully", async () => {
|
|
1406
|
+
(access as Mock).mockResolvedValue(undefined);
|
|
1407
|
+
(child_process.execSync as Mock).mockImplementation(() => {
|
|
1408
|
+
throw new Error("pull failed");
|
|
1409
|
+
});
|
|
1410
|
+
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
1411
|
+
const result = await (service as any).cloneSpecRepo("https://github.com/org/repo.git");
|
|
1412
|
+
expect(result).toBeTruthy();
|
|
1413
|
+
consoleSpy.mockRestore();
|
|
1414
|
+
});
|
|
1415
|
+
|
|
1416
|
+
it("should clone repo when not cached", async () => {
|
|
1417
|
+
(access as Mock).mockRejectedValue(new Error("not found"));
|
|
1418
|
+
(mkdir as Mock).mockResolvedValue(undefined);
|
|
1419
|
+
(child_process.execSync as Mock).mockReturnValue("");
|
|
1420
|
+
const result = await (service as any).cloneSpecRepo("https://github.com/org/repo.git");
|
|
1421
|
+
expect(result).toBeTruthy();
|
|
1422
|
+
});
|
|
1423
|
+
|
|
1424
|
+
it("should handle clone failure", async () => {
|
|
1425
|
+
(access as Mock).mockRejectedValue(new Error("not found"));
|
|
1426
|
+
(mkdir as Mock).mockResolvedValue(undefined);
|
|
1427
|
+
(child_process.execSync as Mock).mockImplementation(() => {
|
|
1428
|
+
throw new Error("clone failed");
|
|
1429
|
+
});
|
|
1430
|
+
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
1431
|
+
const result = await (service as any).cloneSpecRepo("https://github.com/org/repo.git");
|
|
1432
|
+
expect(result).toBeNull();
|
|
1433
|
+
consoleSpy.mockRestore();
|
|
1434
|
+
});
|
|
1435
|
+
|
|
1436
|
+
it("should return null for invalid repo URL", async () => {
|
|
1437
|
+
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
1438
|
+
const result = await (service as any).cloneSpecRepo("https://github.com/");
|
|
1439
|
+
expect(result).toBeNull();
|
|
1440
|
+
consoleSpy.mockRestore();
|
|
1441
|
+
});
|
|
1442
|
+
});
|
|
1443
|
+
|
|
1444
|
+
describe("filterApplicableSpecs - edge cases", () => {
|
|
1445
|
+
it("should handle files without extension", () => {
|
|
1446
|
+
const specs = [
|
|
1447
|
+
{
|
|
1448
|
+
filename: "js&ts.base.md",
|
|
1449
|
+
extensions: ["js", "ts"],
|
|
1450
|
+
type: "base",
|
|
1451
|
+
content: "",
|
|
1452
|
+
overrides: [],
|
|
1453
|
+
severity: "error" as const,
|
|
1454
|
+
includes: [],
|
|
1455
|
+
rules: [],
|
|
1456
|
+
},
|
|
1457
|
+
];
|
|
1458
|
+
const changedFiles = [{ filename: "Makefile" }, { filename: "src/app.ts" }];
|
|
1459
|
+
const result = service.filterApplicableSpecs(specs, changedFiles);
|
|
1460
|
+
expect(result).toHaveLength(1);
|
|
1461
|
+
});
|
|
1462
|
+
|
|
1463
|
+
it("should handle files without filename", () => {
|
|
1464
|
+
const specs = [
|
|
1465
|
+
{
|
|
1466
|
+
filename: "js&ts.base.md",
|
|
1467
|
+
extensions: ["js", "ts"],
|
|
1468
|
+
type: "base",
|
|
1469
|
+
content: "",
|
|
1470
|
+
overrides: [],
|
|
1471
|
+
severity: "error" as const,
|
|
1472
|
+
includes: [],
|
|
1473
|
+
rules: [],
|
|
1474
|
+
},
|
|
1475
|
+
];
|
|
1476
|
+
const changedFiles = [{}];
|
|
1477
|
+
const result = service.filterApplicableSpecs(specs, changedFiles);
|
|
1478
|
+
expect(result).toHaveLength(0);
|
|
1479
|
+
});
|
|
1480
|
+
});
|
|
1481
|
+
|
|
1482
|
+
describe("extractExamples - bad type", () => {
|
|
1483
|
+
it("should extract bad examples", () => {
|
|
1484
|
+
const content = `### Bad
|
|
1485
|
+
|
|
1486
|
+
\`\`\`ts
|
|
1487
|
+
const bad_name = 1;
|
|
1488
|
+
\`\`\``;
|
|
1489
|
+
const examples = (service as any).extractExamples(content);
|
|
1490
|
+
expect(examples).toHaveLength(1);
|
|
1491
|
+
expect(examples[0].type).toBe("bad");
|
|
1492
|
+
});
|
|
1493
|
+
});
|
|
1494
|
+
|
|
1495
|
+
describe("extractSeverity - invalid value", () => {
|
|
1496
|
+
it("should return undefined for invalid severity value", () => {
|
|
1497
|
+
const content = `> - severity \`invalid\``;
|
|
1498
|
+
const result = (service as any).extractSeverity(content);
|
|
1499
|
+
expect(result).toBeUndefined();
|
|
1500
|
+
});
|
|
1501
|
+
});
|
|
1502
|
+
|
|
1503
|
+
describe("filterIssuesByIncludes - rule-level includes", () => {
|
|
1504
|
+
it("should use rule-level includes for filtering", () => {
|
|
1505
|
+
const specs = [
|
|
1506
|
+
{
|
|
1507
|
+
filename: "nest.md",
|
|
1508
|
+
extensions: ["ts"],
|
|
1509
|
+
type: "nest",
|
|
1510
|
+
content: "",
|
|
1511
|
+
overrides: [],
|
|
1512
|
+
severity: "error" as const,
|
|
1513
|
+
includes: ["*.controller.ts"],
|
|
1514
|
+
rules: [
|
|
1515
|
+
{
|
|
1516
|
+
id: "JsTs.Nest",
|
|
1517
|
+
title: "Nest",
|
|
1518
|
+
description: "",
|
|
1519
|
+
examples: [],
|
|
1520
|
+
overrides: [],
|
|
1521
|
+
},
|
|
1522
|
+
{
|
|
1523
|
+
id: "JsTs.Nest.Model",
|
|
1524
|
+
title: "Model",
|
|
1525
|
+
description: "",
|
|
1526
|
+
examples: [],
|
|
1527
|
+
overrides: [],
|
|
1528
|
+
includes: ["*.model.ts"],
|
|
1529
|
+
},
|
|
1530
|
+
],
|
|
1531
|
+
},
|
|
1532
|
+
];
|
|
1533
|
+
const issues = [
|
|
1534
|
+
{ file: "user.model.ts", ruleId: "JsTs.Nest.Model" },
|
|
1535
|
+
{ file: "user.controller.ts", ruleId: "JsTs.Nest" },
|
|
1536
|
+
];
|
|
1537
|
+
const result = service.filterIssuesByIncludes(issues, specs);
|
|
1538
|
+
// spec.includes 是 *.controller.ts,user.model.ts 不匹配
|
|
1539
|
+
expect(result).toHaveLength(1);
|
|
1540
|
+
expect(result[0].file).toBe("user.controller.ts");
|
|
1541
|
+
});
|
|
1542
|
+
});
|
|
1543
|
+
});
|