@boyingliu01/xp-gate 0.8.8 → 0.8.10

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.
Files changed (141) hide show
  1. package/bin/xp-gate.js +18 -0
  2. package/hooks/pre-commit +565 -120
  3. package/lib/arch.js +49 -0
  4. package/lib/check.js +50 -0
  5. package/lib/init.js +36 -0
  6. package/lib/principles.js +48 -0
  7. package/mock-policy/AGENTS.md +94 -0
  8. package/mock-policy/__tests__/config.test.ts +292 -0
  9. package/mock-policy/__tests__/integration.test.ts +600 -0
  10. package/mock-policy/__tests__/mock-decision-engine.test.ts +231 -0
  11. package/mock-policy/__tests__/scope-scanner.test.ts +535 -0
  12. package/mock-policy/config.ts +106 -0
  13. package/mock-policy/gate-m3.ts +233 -0
  14. package/mock-policy/mock-decision-engine.ts +155 -0
  15. package/mock-policy/schema.ts +22 -0
  16. package/mock-policy/scope-scanner.ts +288 -0
  17. package/mock-policy/types.ts +65 -0
  18. package/mutation/AGENTS.md +73 -0
  19. package/mutation/__tests__/detect-ai-test.test.ts +152 -0
  20. package/mutation/__tests__/gate-m.test.ts +194 -0
  21. package/mutation/detect-ai-test.ts +128 -0
  22. package/mutation/gate-m.ts +619 -0
  23. package/mutation/init-baseline.ts +192 -0
  24. package/mutation/stryker-types.ts +14 -0
  25. package/mutation/types.ts +70 -0
  26. package/mutation/update-baseline.ts +160 -0
  27. package/package.json +5 -2
  28. package/plugins/claude-code/.claude-plugin/plugin.json +2 -2
  29. package/plugins/claude-code/skills/delphi-review/AGENTS.md +7 -7
  30. package/plugins/claude-code/skills/delphi-review/INSTALL.md +1 -1
  31. package/plugins/claude-code/skills/delphi-review/SKILL.md +12 -12
  32. package/plugins/claude-code/skills/delphi-review/references/code-walkthrough.md +1 -1
  33. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +83 -36
  34. package/plugins/claude-code/skills/sprint-flow/SKILL.md +1 -1
  35. package/plugins/claude-code/skills/sprint-flow/references/force-levels.md +1 -1
  36. package/plugins/claude-code/skills/sprint-flow/references/phase-3-review.md +2 -2
  37. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
  38. package/plugins/opencode/README.md +23 -7
  39. package/plugins/opencode/index.ts +36 -21
  40. package/plugins/opencode/package.json +1 -1
  41. package/plugins/opencode/skills/delphi-review/AGENTS.md +7 -7
  42. package/plugins/opencode/skills/delphi-review/INSTALL.md +1 -1
  43. package/plugins/opencode/skills/delphi-review/SKILL.md +12 -12
  44. package/plugins/opencode/skills/delphi-review/references/code-walkthrough.md +1 -1
  45. package/plugins/opencode/skills/sprint-flow/AGENTS.md +83 -36
  46. package/plugins/opencode/skills/sprint-flow/SKILL.md +1 -1
  47. package/plugins/opencode/skills/sprint-flow/references/force-levels.md +1 -1
  48. package/plugins/opencode/skills/sprint-flow/references/phase-3-review.md +2 -2
  49. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
  50. package/plugins/qoder/plugin.json +20 -0
  51. package/plugins/qoder/skills/delphi-review/AGENTS.md +7 -7
  52. package/plugins/qoder/skills/delphi-review/INSTALL.md +1 -1
  53. package/plugins/qoder/skills/delphi-review/SKILL.md +12 -12
  54. package/plugins/qoder/skills/delphi-review/references/code-walkthrough.md +1 -1
  55. package/plugins/qoder/skills/sprint-flow/AGENTS.md +83 -36
  56. package/plugins/qoder/skills/sprint-flow/SKILL.md +51 -1
  57. package/plugins/qoder/skills/sprint-flow/references/force-levels.md +1 -1
  58. package/plugins/qoder/skills/sprint-flow/references/phase-3-review.md +2 -2
  59. package/plugins/qoder/skills/test-driven-development/SKILL.md +371 -0
  60. package/plugins/qoder/skills/test-driven-development/testing-anti-patterns.md +299 -0
  61. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
  62. package/principles/AGENTS.md +111 -0
  63. package/principles/__tests__/analyzer.test.ts +343 -0
  64. package/principles/__tests__/baseline-storage.test.ts +187 -0
  65. package/principles/__tests__/baseline.test.ts +328 -0
  66. package/principles/__tests__/boy-scout-integration.test.ts +264 -0
  67. package/principles/__tests__/boy-scout.test.ts +646 -0
  68. package/principles/__tests__/config.test.ts +78 -0
  69. package/principles/__tests__/index.test.ts +81 -0
  70. package/principles/__tests__/lint-baseline.test.ts +314 -0
  71. package/principles/__tests__/reporter.test.ts +832 -0
  72. package/principles/__tests__/types.test.ts +101 -0
  73. package/principles/adapters/__tests__/base.test.ts +95 -0
  74. package/principles/adapters/__tests__/cpp.test.ts +290 -0
  75. package/principles/adapters/__tests__/dart.test.ts +84 -0
  76. package/principles/adapters/__tests__/go.test.ts +68 -0
  77. package/principles/adapters/__tests__/java.test.ts +68 -0
  78. package/principles/adapters/__tests__/kotlin.test.ts +76 -0
  79. package/principles/adapters/__tests__/objectivec.test.ts +453 -0
  80. package/principles/adapters/__tests__/python.test.ts +84 -0
  81. package/principles/adapters/__tests__/swift.test.ts +68 -0
  82. package/principles/adapters/__tests__/typescript.test.ts +116 -0
  83. package/principles/adapters/base.ts +124 -0
  84. package/principles/adapters/cpp.ts +115 -0
  85. package/principles/adapters/dart.ts +44 -0
  86. package/principles/adapters/go.ts +55 -0
  87. package/principles/adapters/java.ts +56 -0
  88. package/principles/adapters/kotlin.ts +54 -0
  89. package/principles/adapters/objectivec.ts +82 -0
  90. package/principles/adapters/python.ts +83 -0
  91. package/principles/adapters/swift.ts +40 -0
  92. package/principles/adapters/typescript.ts +66 -0
  93. package/principles/analyzer.ts +190 -0
  94. package/principles/baseline.ts +273 -0
  95. package/principles/boy-scout.ts +416 -0
  96. package/principles/config.ts +118 -0
  97. package/principles/index.ts +87 -0
  98. package/principles/lint-baseline.ts +385 -0
  99. package/principles/reporter.ts +188 -0
  100. package/principles/rules/__tests__/clean-code/code-duplication.test.ts +84 -0
  101. package/principles/rules/__tests__/clean-code/deep-nesting.test.ts +74 -0
  102. package/principles/rules/__tests__/clean-code/god-class.test.ts +111 -0
  103. package/principles/rules/__tests__/clean-code/large-file.test.ts +73 -0
  104. package/principles/rules/__tests__/clean-code/long-function.test.ts +83 -0
  105. package/principles/rules/__tests__/clean-code/magic-numbers.test.ts +68 -0
  106. package/principles/rules/__tests__/clean-code/many-exports.test.ts +118 -0
  107. package/principles/rules/__tests__/clean-code/missing-error-handling.test.ts +173 -0
  108. package/principles/rules/__tests__/clean-code/too-many-params.test.ts +60 -0
  109. package/principles/rules/__tests__/clean-code/unused-imports.test.ts +107 -0
  110. package/principles/rules/__tests__/index.test.ts +49 -0
  111. package/principles/rules/__tests__/solid/dip.test.ts +121 -0
  112. package/principles/rules/__tests__/solid/isp.test.ts +60 -0
  113. package/principles/rules/__tests__/solid/lsp.test.ts +184 -0
  114. package/principles/rules/__tests__/solid/ocp.test.ts +69 -0
  115. package/principles/rules/__tests__/solid/srp.test.ts +89 -0
  116. package/principles/rules/clean-code/code-duplication.ts +31 -0
  117. package/principles/rules/clean-code/deep-nesting.ts +38 -0
  118. package/principles/rules/clean-code/god-class.ts +54 -0
  119. package/principles/rules/clean-code/large-file.ts +34 -0
  120. package/principles/rules/clean-code/long-function.ts +47 -0
  121. package/principles/rules/clean-code/magic-numbers.ts +59 -0
  122. package/principles/rules/clean-code/many-exports.ts +40 -0
  123. package/principles/rules/clean-code/missing-error-handling.ts +33 -0
  124. package/principles/rules/clean-code/too-many-params.ts +35 -0
  125. package/principles/rules/clean-code/unused-imports.ts +33 -0
  126. package/principles/rules/index.ts +61 -0
  127. package/principles/rules/solid/dip.ts +47 -0
  128. package/principles/rules/solid/isp.ts +37 -0
  129. package/principles/rules/solid/lsp.ts +79 -0
  130. package/principles/rules/solid/ocp.ts +40 -0
  131. package/principles/rules/solid/srp.ts +44 -0
  132. package/principles/types.ts +27 -0
  133. package/skills/delphi-review/AGENTS.md +7 -7
  134. package/skills/delphi-review/INSTALL.md +1 -1
  135. package/skills/delphi-review/SKILL.md +12 -12
  136. package/skills/delphi-review/references/code-walkthrough.md +1 -1
  137. package/skills/sprint-flow/AGENTS.md +83 -36
  138. package/skills/sprint-flow/SKILL.md +1 -1
  139. package/skills/sprint-flow/references/force-levels.md +1 -1
  140. package/skills/sprint-flow/references/phase-3-review.md +2 -2
  141. package/skills/test-specification-alignment/AGENTS.md +3 -3
@@ -0,0 +1,328 @@
1
+ /**
2
+ * @test REQ-COV-003 BaselineStorage uncovered branches
3
+ * @intent Cover initializeWithAnalyzer (batching, timeout, errors) and ccn statistics
4
+ * @covers AC-COV-003
5
+ */
6
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
7
+ import fs from 'fs/promises';
8
+ import path from 'path';
9
+ import os from 'os';
10
+ import { BaselineStorage } from '../baseline';
11
+
12
+ describe('BaselineEntry - lint tool fields (ruff, golangci, shellcheck)', () => {
13
+ it('accepts ruff field in BaselineEntry', () => {
14
+ const storage = new BaselineStorage();
15
+ const entry: Record<string, unknown> = {
16
+ 'src/app.py': {
17
+ ruff: { warnings: 5, errors: 2 },
18
+ totalWarnings: 5,
19
+ lastAnalyzed: new Date().toISOString(),
20
+ },
21
+ };
22
+ expect(() => storage.validate(entry as Record<string, import('../baseline').BaselineEntry>)).not.toThrow();
23
+ });
24
+
25
+ it('accepts golangci field in BaselineEntry', () => {
26
+ const storage = new BaselineStorage();
27
+ const entry: Record<string, unknown> = {
28
+ 'src/main.go': {
29
+ golangci: { warnings: 3, errors: 1 },
30
+ totalWarnings: 3,
31
+ lastAnalyzed: new Date().toISOString(),
32
+ },
33
+ };
34
+ expect(() => storage.validate(entry as Record<string, import('../baseline').BaselineEntry>)).not.toThrow();
35
+ });
36
+
37
+ it('accepts shellcheck field in BaselineEntry', () => {
38
+ const storage = new BaselineStorage();
39
+ const entry: Record<string, unknown> = {
40
+ 'deploy.sh': {
41
+ shellcheck: { warnings: 2, errors: 0 },
42
+ totalWarnings: 2,
43
+ lastAnalyzed: new Date().toISOString(),
44
+ },
45
+ };
46
+ expect(() => storage.validate(entry as Record<string, import('../baseline').BaselineEntry>)).not.toThrow();
47
+ });
48
+
49
+ it('rejects ruff entry with non-numeric warnings', () => {
50
+ const storage = new BaselineStorage();
51
+ const entry = {
52
+ 'src/app.py': {
53
+ ruff: { warnings: 'high', errors: 2 },
54
+ totalWarnings: 5,
55
+ lastAnalyzed: new Date().toISOString(),
56
+ },
57
+ };
58
+ expect(() => storage.validate(entry as Record<string, import('../baseline').BaselineEntry>)).toThrow(/Invalid ruff properties/);
59
+ });
60
+
61
+ it('combines all lint tools in getSummaryStatistics', () => {
62
+ const storage = new BaselineStorage();
63
+ const baseline: Record<string, unknown> = {
64
+ 'src/app.py': {
65
+ ruff: { warnings: 5, errors: 2 },
66
+ totalWarnings: 5,
67
+ lastAnalyzed: new Date().toISOString(),
68
+ },
69
+ 'src/main.go': {
70
+ golangci: { warnings: 3, errors: 1 },
71
+ totalWarnings: 3,
72
+ lastAnalyzed: new Date().toISOString(),
73
+ },
74
+ 'deploy.sh': {
75
+ shellcheck: { warnings: 2, errors: 0 },
76
+ totalWarnings: 2,
77
+ lastAnalyzed: new Date().toISOString(),
78
+ },
79
+ };
80
+
81
+ const stats = storage.getSummaryStatistics(baseline as Record<string, import('../baseline').BaselineEntry>);
82
+
83
+ expect(stats.totalFiles).toBe(3);
84
+ expect(stats.totalWarnings).toBe(10);
85
+ expect(stats.ruff?.totalWarnings).toBe(5);
86
+ expect(stats.ruff?.totalErrors).toBe(2);
87
+ expect(stats.golangci?.totalWarnings).toBe(3);
88
+ expect(stats.golangci?.totalErrors).toBe(1);
89
+ expect(stats.shellcheck?.totalWarnings).toBe(2);
90
+ expect(stats.shellcheck?.totalErrors).toBe(0);
91
+ });
92
+ });
93
+
94
+ describe('BaselineStorage - extended coverage', () => {
95
+ let tmpDir: string;
96
+ let baselinePath: string;
97
+
98
+ beforeEach(async () => {
99
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'baseline-test-'));
100
+ baselinePath = path.join(tmpDir, '.warnings-baseline.json');
101
+ });
102
+
103
+ afterEach(async () => {
104
+ await fs.rm(tmpDir, { recursive: true, force: true });
105
+ vi.restoreAllMocks();
106
+ });
107
+
108
+ describe('getSummaryStatistics - ccn aggregation', () => {
109
+ it('aggregates ccn warnings and max across files', () => {
110
+ const storage = new BaselineStorage();
111
+ const baseline = {
112
+ 'src/a.ts': {
113
+ ccn: { warnings: 3, max: 12 },
114
+ totalWarnings: 3,
115
+ lastAnalyzed: new Date().toISOString(),
116
+ },
117
+ 'src/b.ts': {
118
+ ccn: { warnings: 5, max: 18 },
119
+ totalWarnings: 5,
120
+ lastAnalyzed: new Date().toISOString(),
121
+ },
122
+ };
123
+
124
+ const stats = storage.getSummaryStatistics(baseline);
125
+
126
+ expect(stats.totalFiles).toBe(2);
127
+ expect(stats.ccn?.totalWarnings).toBe(8);
128
+ expect(stats.ccn?.totalMax).toBe(30);
129
+ });
130
+
131
+ it('returns averageWarningsPerFile of 0 for empty baseline', () => {
132
+ const storage = new BaselineStorage();
133
+ const stats = storage.getSummaryStatistics({});
134
+
135
+ expect(stats.totalFiles).toBe(0);
136
+ expect(stats.totalWarnings).toBe(0);
137
+ expect(stats.averageWarningsPerFile).toBe(0);
138
+ expect(stats.ccn).toBeUndefined();
139
+ });
140
+ });
141
+
142
+ describe('initializeWithAnalyzer - batching and progress', () => {
143
+ it('processes files in batches based on batchSize config', async () => {
144
+ const storage = new BaselineStorage({ batchSize: 2 });
145
+ const files = ['f1.ts', 'f2.ts', 'f3.ts', 'f4.ts', 'f5.ts'];
146
+ const callOrder: string[] = [];
147
+
148
+ const warningFn = vi.fn(async (file: string) => {
149
+ callOrder.push(file);
150
+ return { eslint: { warnings: 1, errors: 0 } };
151
+ });
152
+
153
+ const result = await storage.initializeWithAnalyzer(files, warningFn);
154
+
155
+ expect(warningFn).toHaveBeenCalledTimes(5);
156
+ expect(Object.keys(result)).toHaveLength(5);
157
+ expect(callOrder).toEqual(files);
158
+ });
159
+
160
+ it('invokes onProgress callback for each completed file', async () => {
161
+ const storage = new BaselineStorage({ batchSize: 2 });
162
+ const files = ['a.ts', 'b.ts', 'c.ts'];
163
+ const progressEvents: Array<{ current: number; total: number }> = [];
164
+
165
+ await storage.initializeWithAnalyzer(
166
+ files,
167
+ async () => ({ eslint: { warnings: 1, errors: 0 } }),
168
+ (progress) => {
169
+ progressEvents.push({ current: progress.current, total: progress.total });
170
+ },
171
+ );
172
+
173
+ expect(progressEvents).toHaveLength(3);
174
+ expect(progressEvents[progressEvents.length - 1].current).toBe(3);
175
+ expect(progressEvents.every((e) => e.total === 3)).toBe(true);
176
+ });
177
+ });
178
+
179
+ describe('initializeWithAnalyzer - error and limit handling', () => {
180
+ it('throws when files array exceeds maxSize', async () => {
181
+ const storage = new BaselineStorage({ maxSize: 2 });
182
+ const files = ['a.ts', 'b.ts', 'c.ts'];
183
+
184
+ await expect(
185
+ storage.initializeWithAnalyzer(files, async () => ({})),
186
+ ).rejects.toThrow(/exceeds the maximum of 2/);
187
+ });
188
+
189
+ it('logs errors via console.error when warningCountFunction rejects', async () => {
190
+ const storage = new BaselineStorage({ batchSize: 5 });
191
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => { /* noop */ });
192
+ const files = ['ok.ts', 'fail.ts'];
193
+
194
+ const warningFn = async (file: string) => {
195
+ if (file === 'fail.ts') {
196
+ throw new Error('analyzer crashed');
197
+ }
198
+ return { eslint: { warnings: 2, errors: 0 } };
199
+ };
200
+
201
+ const result = await storage.initializeWithAnalyzer(files, warningFn);
202
+
203
+ expect(errorSpy).toHaveBeenCalledWith(
204
+ 'Some files failed to analyze:',
205
+ expect.any(Array),
206
+ );
207
+ expect(result['ok.ts']).toBeDefined();
208
+ expect(result['fail.ts']).toBeUndefined();
209
+ });
210
+
211
+ it('rejects with timeout when processing exceeds timeoutMs', async () => {
212
+ const storage = new BaselineStorage({ timeoutMs: 50, batchSize: 1 });
213
+ const files = ['slow.ts'];
214
+
215
+ const warningFn = () =>
216
+ new Promise<{ totalWarnings: number }>((resolve) => {
217
+ setTimeout(() => resolve({ totalWarnings: 1 }), 200);
218
+ });
219
+
220
+ await expect(
221
+ storage.initializeWithAnalyzer(files, warningFn),
222
+ ).rejects.toThrow(/timed out/);
223
+ });
224
+ });
225
+
226
+ describe('initializeWithAnalyzer - totalWarnings calculation', () => {
227
+ it('calculates totalWarnings from eslint-only entries', async () => {
228
+ const storage = new BaselineStorage();
229
+ const result = await storage.initializeWithAnalyzer(['a.ts'], async () => ({
230
+ eslint: { warnings: 7, errors: 1 },
231
+ }));
232
+
233
+ expect(result['a.ts'].totalWarnings).toBe(7);
234
+ expect(result['a.ts'].eslint).toEqual({ warnings: 7, errors: 1 });
235
+ });
236
+
237
+ it('calculates totalWarnings from principles-only entries', async () => {
238
+ const storage = new BaselineStorage();
239
+ const result = await storage.initializeWithAnalyzer(['b.ts'], async () => ({
240
+ principles: { warnings: 4, errors: 0 },
241
+ }));
242
+
243
+ expect(result['b.ts'].totalWarnings).toBe(4);
244
+ expect(result['b.ts'].principles).toEqual({ warnings: 4, errors: 0 });
245
+ });
246
+
247
+ it('calculates totalWarnings from ccn-only entries', async () => {
248
+ const storage = new BaselineStorage();
249
+ const result = await storage.initializeWithAnalyzer(['c.ts'], async () => ({
250
+ ccn: { warnings: 6, max: 15 },
251
+ }));
252
+
253
+ expect(result['c.ts'].totalWarnings).toBe(6);
254
+ expect(result['c.ts'].ccn).toEqual({ warnings: 6, max: 15 });
255
+ });
256
+
257
+ it('uses explicit totalWarnings override when provided', async () => {
258
+ const storage = new BaselineStorage();
259
+ const result = await storage.initializeWithAnalyzer(['d.ts'], async () => ({
260
+ eslint: { warnings: 3, errors: 0 },
261
+ principles: { warnings: 2, errors: 0 },
262
+ ccn: { warnings: 1, max: 5 },
263
+ totalWarnings: 99,
264
+ }));
265
+
266
+ expect(result['d.ts'].totalWarnings).toBe(99);
267
+ });
268
+
269
+ it('aggregates totalWarnings across all three sources when no override', async () => {
270
+ const storage = new BaselineStorage();
271
+ const result = await storage.initializeWithAnalyzer(['e.ts'], async () => ({
272
+ eslint: { warnings: 2, errors: 0 },
273
+ principles: { warnings: 3, errors: 0 },
274
+ ccn: { warnings: 4, max: 10 },
275
+ }));
276
+
277
+ expect(result['e.ts'].totalWarnings).toBe(9);
278
+ });
279
+
280
+ it('sets totalWarnings to 0 when warningCountFunction returns empty object', async () => {
281
+ const storage = new BaselineStorage();
282
+ const result = await storage.initializeWithAnalyzer(['empty.ts'], async () => ({}));
283
+
284
+ expect(result['empty.ts'].totalWarnings).toBe(0);
285
+ expect(result['empty.ts'].eslint).toBeUndefined();
286
+ expect(result['empty.ts'].principles).toBeUndefined();
287
+ expect(result['empty.ts'].ccn).toBeUndefined();
288
+ });
289
+
290
+ it('honors explicit totalWarnings of 0 when provided', async () => {
291
+ const storage = new BaselineStorage();
292
+ const result = await storage.initializeWithAnalyzer(['z.ts'], async () => ({
293
+ eslint: { warnings: 5, errors: 0 },
294
+ totalWarnings: 0,
295
+ }));
296
+
297
+ expect(result['z.ts'].totalWarnings).toBe(0);
298
+ });
299
+ });
300
+
301
+ describe('initializeWithAnalyzer - integration with save/load', () => {
302
+ it('saves and reloads baseline produced by initializeWithAnalyzer', async () => {
303
+ const storage = new BaselineStorage();
304
+ const built = await storage.initializeWithAnalyzer(
305
+ ['file1.ts', 'file2.ts'],
306
+ async (file) => ({
307
+ eslint: { warnings: file === 'file1.ts' ? 1 : 2, errors: 0 },
308
+ }),
309
+ );
310
+
311
+ await storage.save(baselinePath, built);
312
+ const loaded = await storage.load(baselinePath);
313
+
314
+ expect(Object.keys(loaded).sort()).toEqual(['file1.ts', 'file2.ts']);
315
+ expect(loaded['file2.ts'].totalWarnings).toBe(2);
316
+ });
317
+
318
+ it('handles empty files array without invoking analyzer', async () => {
319
+ const storage = new BaselineStorage();
320
+ const warningFn = vi.fn(async () => ({}));
321
+
322
+ const result = await storage.initializeWithAnalyzer([], warningFn);
323
+
324
+ expect(result).toEqual({});
325
+ expect(warningFn).not.toHaveBeenCalled();
326
+ });
327
+ });
328
+ });
@@ -0,0 +1,264 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { exec } from 'child_process';
3
+ import { promisify } from 'util';
4
+ import * as path from 'path';
5
+
6
+ const execAsync = promisify(exec);
7
+
8
+ vi.mock('fs/promises', async (importOriginal) => {
9
+ const actual = await importOriginal<typeof import('fs/promises')>();
10
+ return {
11
+ ...actual,
12
+ readFile: vi.fn(),
13
+ access: vi.fn(),
14
+ writeFile: vi.fn(),
15
+ };
16
+ });
17
+
18
+ vi.mock('../analyzer', () => ({
19
+ analyze: vi.fn(),
20
+ getAdapterForFile: vi.fn(),
21
+ }));
22
+
23
+ vi.mock('../index', () => ({
24
+ getAllRules: vi.fn(() => []),
25
+ }));
26
+
27
+ import { readFile, access, writeFile } from 'fs/promises';
28
+ import {
29
+ classifyFiles,
30
+ calculateDelta,
31
+ runEnforcement,
32
+ } from '../boy-scout';
33
+ import { analyze } from '../analyzer';
34
+
35
+ const mockAnalyze = vi.mocked(analyze);
36
+ const mockReadFile = vi.mocked(readFile);
37
+ const mockAccess = vi.mocked(access);
38
+ const mockWriteFile = vi.mocked(writeFile);
39
+
40
+ interface BaselineEntry {
41
+ totalWarnings: number;
42
+ lastAnalyzed: string;
43
+ }
44
+
45
+ type AnalysisResult = Awaited<ReturnType<typeof analyze>>;
46
+
47
+ const emptyAnalysis = (): AnalysisResult => ({ violations: [] } as unknown as AnalysisResult);
48
+
49
+ function fakeAnalysis(violations: Partial<AnalysisResult['violations']>[number][]): AnalysisResult {
50
+ return { violations: violations as unknown as AnalysisResult['violations'] } as unknown as AnalysisResult;
51
+ }
52
+
53
+ beforeEach(() => {
54
+ vi.clearAllMocks();
55
+ });
56
+
57
+ /**
58
+ * @test REQ-59 Boy Scout Rule integration tests
59
+ * @intent Verify runEnforcement, CLI integration, and edge cases for Boy Scout Rule
60
+ * @covers AC-59-01
61
+ */
62
+ describe('Boy Scout Rule - runEnforcement', () => {
63
+ it('passes when new files have zero warnings and modified files are clean', async () => {
64
+ mockAnalyze.mockResolvedValue(emptyAnalysis());
65
+ mockAccess.mockResolvedValue(undefined);
66
+ mockReadFile.mockResolvedValue(
67
+ JSON.stringify({ 'src/modified.ts': { totalWarnings: 3, lastAnalyzed: '2024-01-01' } })
68
+ );
69
+ mockWriteFile.mockResolvedValue(undefined);
70
+
71
+ const result = await runEnforcement(['src/new.ts'], ['src/modified.ts'], '/tmp/test-baseline.json');
72
+
73
+ expect(result.overallStatus).toBe('PASS');
74
+ expect(result.summary.totalFiles).toBe(2);
75
+ });
76
+
77
+ it('blocks when new files have warnings', async () => {
78
+ mockAnalyze.mockResolvedValue(
79
+ fakeAnalysis([{ file: 'src/new.ts', line: 1, ruleId: 'test', severity: 'warning' as const, message: 'm' }])
80
+ );
81
+ mockAccess.mockRejectedValue(new Error('ENOENT'));
82
+ mockWriteFile.mockResolvedValue(undefined);
83
+
84
+ const result = await runEnforcement(['src/new.ts'], [], '/tmp/test-baseline.json');
85
+
86
+ expect(result.overallStatus).toBe('BLOCK');
87
+ expect(result.summary.blockedFiles).toBe(1);
88
+ });
89
+
90
+ it('blocks when modified files increase warnings', async () => {
91
+ mockAnalyze.mockResolvedValue(
92
+ fakeAnalysis([
93
+ { file: 'src/modified.ts', line: 1, ruleId: 'test', severity: 'warning' as const, message: 'm' },
94
+ { file: 'src/modified.ts', line: 2, ruleId: 'test', severity: 'warning' as const, message: 'm' },
95
+ { file: 'src/modified.ts', line: 3, ruleId: 'test', severity: 'warning' as const, message: 'm' },
96
+ ])
97
+ );
98
+ mockAccess.mockResolvedValue(undefined);
99
+ mockReadFile.mockResolvedValue(
100
+ JSON.stringify({ 'src/modified.ts': { totalWarnings: 1, lastAnalyzed: '2024-01-01' } })
101
+ );
102
+ mockWriteFile.mockResolvedValue(undefined);
103
+
104
+ const result = await runEnforcement([], ['src/modified.ts'], '/tmp/test-baseline.json');
105
+ expect(result.overallStatus).toBe('BLOCK');
106
+ expect(result.violations[0].reason).toContain('cannot increase warnings');
107
+ });
108
+
109
+ it('auto-initializes missing baseline entries for modified files', async () => {
110
+ mockAnalyze.mockResolvedValue(emptyAnalysis());
111
+ mockAccess.mockResolvedValue(undefined);
112
+ mockReadFile.mockResolvedValue('{}');
113
+ mockWriteFile.mockResolvedValue(undefined);
114
+
115
+ const result = await runEnforcement([], ['src/modified.ts'], '/tmp/test-baseline.json');
116
+ expect(mockWriteFile).toHaveBeenCalled();
117
+ expect(result.overallStatus).toBe('PASS');
118
+ });
119
+
120
+ it('handles empty newFiles and modifiedFiles arrays', async () => {
121
+ mockAnalyze.mockResolvedValue(emptyAnalysis());
122
+ mockAccess.mockRejectedValue(new Error('ENOENT'));
123
+
124
+ const result = await runEnforcement([], [], '/tmp/test-baseline.json');
125
+ expect(result.overallStatus).toBe('PASS');
126
+ expect(result.summary.totalFiles).toBe(0);
127
+ });
128
+
129
+ it('filters out empty strings from file arrays', async () => {
130
+ mockAnalyze.mockResolvedValue(emptyAnalysis());
131
+ mockAccess.mockRejectedValue(new Error('ENOENT'));
132
+
133
+ const result = await runEnforcement(['', ' ', ''], ['', ' '], '/tmp/test.json');
134
+ expect(result.overallStatus).toBe('PASS');
135
+ });
136
+
137
+ it('auto-init saves updated baseline when missing entries exist', async () => {
138
+ mockAnalyze.mockResolvedValue(
139
+ fakeAnalysis([{ file: 'src/a.ts', line: 1, ruleId: 'test', severity: 'warning' as const, message: 'm' }])
140
+ );
141
+ mockAccess.mockResolvedValue(undefined);
142
+ mockReadFile.mockResolvedValue(JSON.stringify({}));
143
+ mockWriteFile.mockResolvedValue(undefined);
144
+
145
+ await runEnforcement([], ['src/a.ts'], '/tmp/test-baseline.json');
146
+ expect(mockWriteFile).toHaveBeenCalled();
147
+ expect(vi.mocked(mockWriteFile).mock.calls[0][0]).toBe('/tmp/test-baseline.json');
148
+ });
149
+
150
+ it('blocks modified file with <=5 baseline warnings that are not cleared', async () => {
151
+ mockAnalyze.mockResolvedValue(
152
+ fakeAnalysis([{ file: 'src/mod.ts', line: 1, ruleId: 'test', severity: 'warning' as const, message: 'm' }])
153
+ );
154
+ mockAccess.mockResolvedValue(undefined);
155
+ mockReadFile.mockResolvedValue(
156
+ JSON.stringify({ 'src/mod.ts': { totalWarnings: 3, lastAnalyzed: '2024-01-01' } })
157
+ );
158
+ mockWriteFile.mockResolvedValue(undefined);
159
+
160
+ const result = await runEnforcement([], ['src/mod.ts'], '/tmp/test-baseline.json');
161
+ expect(result.overallStatus).toBe('BLOCK');
162
+ expect(result.violations[0].reason).toContain('must clear to zero');
163
+ });
164
+
165
+ it('enforcement passes when modified file clears all warnings', async () => {
166
+ mockAnalyze.mockResolvedValue(emptyAnalysis());
167
+ mockAccess.mockResolvedValue(undefined);
168
+ mockReadFile.mockResolvedValue(
169
+ JSON.stringify({ 'src/mod.ts': { totalWarnings: 3, lastAnalyzed: '2024-01-01' } })
170
+ );
171
+ mockWriteFile.mockResolvedValue(undefined);
172
+
173
+ const result = await runEnforcement([], ['src/mod.ts'], '/tmp/test-baseline.json');
174
+ expect(result.overallStatus).toBe('PASS');
175
+ });
176
+ });
177
+
178
+ describe('calculateDelta reason messages', () => {
179
+ it('returns "Warnings decreased" reason on decrease', () => {
180
+ const result = calculateDelta({ totalWarnings: 10 } as BaselineEntry, 7, 'MODIFIED');
181
+ expect(result.delta).toBe(-3);
182
+ expect(result.enforcement).toBe('PASS');
183
+ expect(result.reason).toContain('Warnings decreased by 3');
184
+ });
185
+
186
+ it('returns "All warnings cleared" reason when current is zero', () => {
187
+ const result = calculateDelta({ totalWarnings: 0 } as BaselineEntry, 0, 'MODIFIED');
188
+ expect(result.delta).toBe(0);
189
+ expect(result.enforcement).toBe('PASS');
190
+ expect(result.reason).toContain('All warnings cleared');
191
+ });
192
+
193
+ it('returns "No new warnings introduced" when same count and >5', () => {
194
+ const result = calculateDelta({ totalWarnings: 8, lastAnalyzed: new Date().toISOString() } as BaselineEntry, 8, 'MODIFIED');
195
+ expect(result.delta).toBe(0);
196
+ expect(result.enforcement).toBe('PASS');
197
+ expect(result.reason).toContain('No new warnings introduced');
198
+ });
199
+
200
+ it('sets correct file field in delta result', () => {
201
+ const result = calculateDelta(null, 5, 'MODIFIED');
202
+ result.file = 'src/test.ts';
203
+ expect(result.file).toBe('src/test.ts');
204
+ expect(result.status).toBe('MODIFIED');
205
+ });
206
+ });
207
+
208
+ describe('classifyFiles edge cases', () => {
209
+ it('ignores rename lines with insufficient parts', () => {
210
+ const classified = classifyFiles(['R095']);
211
+ expect(classified.renamed).toEqual([]);
212
+ });
213
+
214
+ it('ignores lines with less than 2 parts', () => {
215
+ const classified = classifyFiles(['A']);
216
+ expect(classified.new).toEqual([]);
217
+ });
218
+
219
+ it('handles unknown status codes gracefully', () => {
220
+ const classified = classifyFiles(['X src/unknown.ts']);
221
+ expect(classified.new).toEqual([]);
222
+ expect(classified.renamed).toEqual([]);
223
+ });
224
+
225
+ it('handles files with spaces in paths for new files', () => {
226
+ const classified = classifyFiles(['A src/my file.ts']);
227
+ expect(classified.new).toEqual(['src/my file.ts']);
228
+ });
229
+ });
230
+
231
+ describe('CLI integration', () => {
232
+ const BOY_SCOUT_PATH = path.resolve(__dirname, '../boy-scout.ts');
233
+ const CLI_TEST_TIMEOUT = 60000;
234
+
235
+ it('shows help and exits with --help flag', async () => {
236
+ const { stdout } = await execAsync(`npx tsx ${BOY_SCOUT_PATH} --help`, { timeout: 30000 });
237
+ expect(stdout).toContain('Usage: boy-scout');
238
+ expect(stdout).toContain('--new-files');
239
+ }, CLI_TEST_TIMEOUT);
240
+
241
+ it('shows help and exits with -h flag', async () => {
242
+ const { stdout } = await execAsync(`npx tsx ${BOY_SCOUT_PATH} -h`, { timeout: 30000 });
243
+ expect(stdout).toContain('Usage: boy-scout');
244
+ }, CLI_TEST_TIMEOUT);
245
+
246
+ it('shows help and exits with help command', async () => {
247
+ const { stdout } = await execAsync(`npx tsx ${BOY_SCOUT_PATH} help`, { timeout: 30000 });
248
+ expect(stdout).toContain('Usage: boy-scout');
249
+ }, CLI_TEST_TIMEOUT);
250
+
251
+ it('runs init-baseline via CLI', async () => {
252
+ const { stdout } = await execAsync(
253
+ `npx tsx ${BOY_SCOUT_PATH} --init-baseline src/principles/boy-scout.ts`,
254
+ { timeout: 30000 }
255
+ );
256
+ expect(stdout).toContain('Baseline initialized successfully');
257
+ }, CLI_TEST_TIMEOUT);
258
+
259
+ it('runs enforcement via CLI with empty files', async () => {
260
+ await expect(
261
+ execAsync(`npx tsx ${BOY_SCOUT_PATH} --new-files "" --modified-files ""`, { timeout: 30000 })
262
+ ).resolves.toMatchObject({ stdout: expect.stringContaining('"overallStatus"') });
263
+ }, CLI_TEST_TIMEOUT);
264
+ });