@boyingliu01/xp-gate 0.8.9 → 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.
- package/lib/init.js +36 -0
- package/mock-policy/AGENTS.md +94 -0
- package/mock-policy/__tests__/config.test.ts +292 -0
- package/mock-policy/__tests__/integration.test.ts +600 -0
- package/mock-policy/__tests__/mock-decision-engine.test.ts +231 -0
- package/mock-policy/__tests__/scope-scanner.test.ts +535 -0
- package/mock-policy/config.ts +106 -0
- package/mock-policy/gate-m3.ts +233 -0
- package/mock-policy/mock-decision-engine.ts +155 -0
- package/mock-policy/schema.ts +22 -0
- package/mock-policy/scope-scanner.ts +288 -0
- package/mock-policy/types.ts +65 -0
- package/mutation/AGENTS.md +73 -0
- package/mutation/__tests__/detect-ai-test.test.ts +152 -0
- package/mutation/__tests__/gate-m.test.ts +194 -0
- package/mutation/detect-ai-test.ts +128 -0
- package/mutation/gate-m.ts +619 -0
- package/mutation/init-baseline.ts +192 -0
- package/mutation/stryker-types.ts +14 -0
- package/mutation/types.ts +70 -0
- package/mutation/update-baseline.ts +160 -0
- package/package.json +5 -2
- package/plugins/claude-code/.claude-plugin/plugin.json +2 -2
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +7 -7
- package/plugins/claude-code/skills/delphi-review/INSTALL.md +1 -1
- package/plugins/claude-code/skills/delphi-review/SKILL.md +12 -12
- package/plugins/claude-code/skills/delphi-review/references/code-walkthrough.md +1 -1
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +7 -7
- package/plugins/claude-code/skills/sprint-flow/SKILL.md +1 -1
- package/plugins/claude-code/skills/sprint-flow/references/force-levels.md +1 -1
- package/plugins/claude-code/skills/sprint-flow/references/phase-3-review.md +2 -2
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +7 -7
- package/plugins/opencode/skills/delphi-review/INSTALL.md +1 -1
- package/plugins/opencode/skills/delphi-review/SKILL.md +12 -12
- package/plugins/opencode/skills/delphi-review/references/code-walkthrough.md +1 -1
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +7 -7
- package/plugins/opencode/skills/sprint-flow/SKILL.md +1 -1
- package/plugins/opencode/skills/sprint-flow/references/force-levels.md +1 -1
- package/plugins/opencode/skills/sprint-flow/references/phase-3-review.md +2 -2
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/qoder/plugin.json +1 -1
- package/plugins/qoder/skills/delphi-review/AGENTS.md +7 -7
- package/plugins/qoder/skills/delphi-review/INSTALL.md +1 -1
- package/plugins/qoder/skills/delphi-review/SKILL.md +12 -12
- package/plugins/qoder/skills/delphi-review/references/code-walkthrough.md +1 -1
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +7 -7
- package/plugins/qoder/skills/sprint-flow/SKILL.md +51 -1
- package/plugins/qoder/skills/sprint-flow/references/force-levels.md +1 -1
- package/plugins/qoder/skills/sprint-flow/references/phase-3-review.md +2 -2
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
- package/principles/AGENTS.md +111 -0
- package/principles/__tests__/analyzer.test.ts +343 -0
- package/principles/__tests__/baseline-storage.test.ts +187 -0
- package/principles/__tests__/baseline.test.ts +328 -0
- package/principles/__tests__/boy-scout-integration.test.ts +264 -0
- package/principles/__tests__/boy-scout.test.ts +646 -0
- package/principles/__tests__/config.test.ts +78 -0
- package/principles/__tests__/index.test.ts +81 -0
- package/principles/__tests__/lint-baseline.test.ts +314 -0
- package/principles/__tests__/reporter.test.ts +832 -0
- package/principles/__tests__/types.test.ts +101 -0
- package/principles/adapters/__tests__/base.test.ts +95 -0
- package/principles/adapters/__tests__/cpp.test.ts +290 -0
- package/principles/adapters/__tests__/dart.test.ts +84 -0
- package/principles/adapters/__tests__/go.test.ts +68 -0
- package/principles/adapters/__tests__/java.test.ts +68 -0
- package/principles/adapters/__tests__/kotlin.test.ts +76 -0
- package/principles/adapters/__tests__/objectivec.test.ts +453 -0
- package/principles/adapters/__tests__/python.test.ts +84 -0
- package/principles/adapters/__tests__/swift.test.ts +68 -0
- package/principles/adapters/__tests__/typescript.test.ts +116 -0
- package/principles/adapters/base.ts +124 -0
- package/principles/adapters/cpp.ts +115 -0
- package/principles/adapters/dart.ts +44 -0
- package/principles/adapters/go.ts +55 -0
- package/principles/adapters/java.ts +56 -0
- package/principles/adapters/kotlin.ts +54 -0
- package/principles/adapters/objectivec.ts +82 -0
- package/principles/adapters/python.ts +83 -0
- package/principles/adapters/swift.ts +40 -0
- package/principles/adapters/typescript.ts +66 -0
- package/principles/analyzer.ts +190 -0
- package/principles/baseline.ts +273 -0
- package/principles/boy-scout.ts +416 -0
- package/principles/config.ts +118 -0
- package/principles/index.ts +87 -0
- package/principles/lint-baseline.ts +385 -0
- package/principles/reporter.ts +188 -0
- package/principles/rules/__tests__/clean-code/code-duplication.test.ts +84 -0
- package/principles/rules/__tests__/clean-code/deep-nesting.test.ts +74 -0
- package/principles/rules/__tests__/clean-code/god-class.test.ts +111 -0
- package/principles/rules/__tests__/clean-code/large-file.test.ts +73 -0
- package/principles/rules/__tests__/clean-code/long-function.test.ts +83 -0
- package/principles/rules/__tests__/clean-code/magic-numbers.test.ts +68 -0
- package/principles/rules/__tests__/clean-code/many-exports.test.ts +118 -0
- package/principles/rules/__tests__/clean-code/missing-error-handling.test.ts +173 -0
- package/principles/rules/__tests__/clean-code/too-many-params.test.ts +60 -0
- package/principles/rules/__tests__/clean-code/unused-imports.test.ts +107 -0
- package/principles/rules/__tests__/index.test.ts +49 -0
- package/principles/rules/__tests__/solid/dip.test.ts +121 -0
- package/principles/rules/__tests__/solid/isp.test.ts +60 -0
- package/principles/rules/__tests__/solid/lsp.test.ts +184 -0
- package/principles/rules/__tests__/solid/ocp.test.ts +69 -0
- package/principles/rules/__tests__/solid/srp.test.ts +89 -0
- package/principles/rules/clean-code/code-duplication.ts +31 -0
- package/principles/rules/clean-code/deep-nesting.ts +38 -0
- package/principles/rules/clean-code/god-class.ts +54 -0
- package/principles/rules/clean-code/large-file.ts +34 -0
- package/principles/rules/clean-code/long-function.ts +47 -0
- package/principles/rules/clean-code/magic-numbers.ts +59 -0
- package/principles/rules/clean-code/many-exports.ts +40 -0
- package/principles/rules/clean-code/missing-error-handling.ts +33 -0
- package/principles/rules/clean-code/too-many-params.ts +35 -0
- package/principles/rules/clean-code/unused-imports.ts +33 -0
- package/principles/rules/index.ts +61 -0
- package/principles/rules/solid/dip.ts +47 -0
- package/principles/rules/solid/isp.ts +37 -0
- package/principles/rules/solid/lsp.ts +79 -0
- package/principles/rules/solid/ocp.ts +40 -0
- package/principles/rules/solid/srp.ts +44 -0
- package/principles/types.ts +27 -0
- package/skills/delphi-review/AGENTS.md +7 -7
- package/skills/delphi-review/INSTALL.md +1 -1
- package/skills/delphi-review/SKILL.md +12 -12
- package/skills/delphi-review/references/code-walkthrough.md +1 -1
- package/skills/sprint-flow/AGENTS.md +7 -7
- package/skills/sprint-flow/SKILL.md +1 -1
- package/skills/sprint-flow/references/force-levels.md +1 -1
- package/skills/sprint-flow/references/phase-3-review.md +2 -2
- package/skills/test-specification-alignment/AGENTS.md +3 -3
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
|
|
3
|
+
interface BaselineEntry {
|
|
4
|
+
eslint?: { warnings: number; errors: number };
|
|
5
|
+
principles?: { warnings: number; errors: number };
|
|
6
|
+
ccn?: { warnings: number; max: number };
|
|
7
|
+
ruff?: { warnings: number; errors: number };
|
|
8
|
+
golangci?: { warnings: number; errors: number };
|
|
9
|
+
shellcheck?: { warnings: number; errors: number };
|
|
10
|
+
totalWarnings: number;
|
|
11
|
+
lastAnalyzed: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface BaselineStorageConfig {
|
|
15
|
+
maxSize?: number;
|
|
16
|
+
timeoutMs?: number;
|
|
17
|
+
batchSize?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type ToolValidator = (entry: BaselineEntry, file: string) => void;
|
|
21
|
+
|
|
22
|
+
function computeSummaryStats(baseline: Record<string, BaselineEntry>) {
|
|
23
|
+
let totalFiles = 0;
|
|
24
|
+
let totalWarnings = 0;
|
|
25
|
+
const toolStats: Record<string, { totalWarnings: number; totalErrors: number }> = {};
|
|
26
|
+
let ccnTotalWarnings = 0;
|
|
27
|
+
let ccnTotalMax = 0;
|
|
28
|
+
|
|
29
|
+
for (const entry of Object.values(baseline)) {
|
|
30
|
+
totalFiles++;
|
|
31
|
+
totalWarnings += entry.totalWarnings;
|
|
32
|
+
aggregateTool(toolStats, entry);
|
|
33
|
+
if (entry.ccn) {
|
|
34
|
+
ccnTotalWarnings += entry.ccn.warnings;
|
|
35
|
+
ccnTotalMax += entry.ccn.max;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (ccnTotalWarnings > 0 || ccnTotalMax > 0) {
|
|
40
|
+
toolStats.ccn = { totalWarnings: ccnTotalWarnings, totalErrors: 0, totalMax: ccnTotalMax } as unknown as { totalWarnings: number; totalErrors: number };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
totalFiles,
|
|
45
|
+
totalWarnings,
|
|
46
|
+
averageWarningsPerFile: totalFiles > 0 ? totalWarnings / totalFiles : 0,
|
|
47
|
+
...toolStats,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function aggregateTool(stats: Record<string, { totalWarnings: number; totalErrors: number }>, entry: BaselineEntry) {
|
|
52
|
+
const tools: Array<{ key: string; warnings: number; errors: number }> = [
|
|
53
|
+
{ key: 'eslint', warnings: entry.eslint?.warnings ?? 0, errors: entry.eslint?.errors ?? 0 },
|
|
54
|
+
{ key: 'principles', warnings: entry.principles?.warnings ?? 0, errors: entry.principles?.errors ?? 0 },
|
|
55
|
+
{ key: 'ruff', warnings: entry.ruff?.warnings ?? 0, errors: entry.ruff?.errors ?? 0 },
|
|
56
|
+
{ key: 'golangci', warnings: entry.golangci?.warnings ?? 0, errors: entry.golangci?.errors ?? 0 },
|
|
57
|
+
{ key: 'shellcheck', warnings: entry.shellcheck?.warnings ?? 0, errors: entry.shellcheck?.errors ?? 0 },
|
|
58
|
+
];
|
|
59
|
+
for (const t of tools) {
|
|
60
|
+
if (t.warnings > 0 || t.errors > 0) {
|
|
61
|
+
stats[t.key] = { totalWarnings: (stats[t.key]?.totalWarnings || 0) + t.warnings, totalErrors: (stats[t.key]?.totalErrors || 0) + t.errors };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function validateNumber(
|
|
67
|
+
entry: BaselineEntry,
|
|
68
|
+
tool: string,
|
|
69
|
+
file: string,
|
|
70
|
+
props: string[],
|
|
71
|
+
): void {
|
|
72
|
+
const obj = entry[tool as keyof BaselineEntry];
|
|
73
|
+
if (!obj || typeof obj !== 'object') return;
|
|
74
|
+
for (const prop of props) {
|
|
75
|
+
if (typeof (obj as Record<string, unknown>)[prop] !== 'number') {
|
|
76
|
+
throw new Error(`Invalid ${tool} properties for file ${file}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const ENTRY_VALIDATORS: ToolValidator[] = [
|
|
82
|
+
e => validateNumber(e, 'eslint', '', ['warnings', 'errors']),
|
|
83
|
+
e => validateNumber(e, 'principles', '', ['warnings', 'errors']),
|
|
84
|
+
e => validateNumber(e, 'ccn', '', ['warnings', 'max']),
|
|
85
|
+
e => validateNumber(e, 'ruff', '', ['warnings', 'errors']),
|
|
86
|
+
e => validateNumber(e, 'golangci', '', ['warnings', 'errors']),
|
|
87
|
+
e => validateNumber(e, 'shellcheck', '', ['warnings', 'errors']),
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
function validateEntry(file: string, entry: BaselineEntry): void {
|
|
91
|
+
if (typeof entry.totalWarnings !== 'number' || entry.totalWarnings < 0) {
|
|
92
|
+
throw new Error(`Invalid totalWarnings value for file ${file}: ${entry.totalWarnings}`);
|
|
93
|
+
}
|
|
94
|
+
if (typeof entry.lastAnalyzed !== 'string') {
|
|
95
|
+
throw new Error(`Missing or invalid lastAnalyzed timestamp for file ${file}`);
|
|
96
|
+
}
|
|
97
|
+
for (const validator of ENTRY_VALIDATORS) {
|
|
98
|
+
validator(entry, file);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function filterBaselineWarnings(baseline: Record<string, BaselineEntry>, minWarningCount: number = 1): Record<string, BaselineEntry> {
|
|
103
|
+
const filtered: Record<string, BaselineEntry> = {};
|
|
104
|
+
for (const [file, entry] of Object.entries(baseline)) {
|
|
105
|
+
if (entry.totalWarnings >= minWarningCount) {
|
|
106
|
+
filtered[file] = entry;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return filtered;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
class BaselineStorage {
|
|
113
|
+
private config: BaselineStorageConfig = {
|
|
114
|
+
maxSize: 10000,
|
|
115
|
+
timeoutMs: 300000, // 5 minutes
|
|
116
|
+
batchSize: 50
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
constructor(config?: BaselineStorageConfig) {
|
|
120
|
+
if (config) {
|
|
121
|
+
this.config = { ...this.config, ...config };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async load(baselinePath: string): Promise<Record<string, BaselineEntry>> {
|
|
126
|
+
try {
|
|
127
|
+
await fs.access(baselinePath);
|
|
128
|
+
const baselineContent = await fs.readFile(baselinePath, 'utf-8');
|
|
129
|
+
return JSON.parse(baselineContent);
|
|
130
|
+
} catch {
|
|
131
|
+
return {};
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async save(baselinePath: string, baseline: Record<string, BaselineEntry>): Promise<void> {
|
|
136
|
+
await fs.writeFile(baselinePath, JSON.stringify(baseline, null, 2));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
validate(baseline: Record<string, BaselineEntry>): boolean {
|
|
140
|
+
if (Object.keys(baseline).length > this.config.maxSize!) {
|
|
141
|
+
throw new Error(`Baseline exceeds maximum size of ${this.config.maxSize} files`);
|
|
142
|
+
}
|
|
143
|
+
for (const [file, entry] of Object.entries(baseline)) {
|
|
144
|
+
validateEntry(file, entry);
|
|
145
|
+
}
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async createFromFiles(warningData: Array<{ file: string; counts: Partial<BaselineEntry> }>): Promise<Record<string, BaselineEntry>> {
|
|
150
|
+
const baseline: Record<string, BaselineEntry> = {};
|
|
151
|
+
|
|
152
|
+
for (const item of warningData) {
|
|
153
|
+
let totalWarnings = 0;
|
|
154
|
+
|
|
155
|
+
if (item.counts.eslint?.warnings) {
|
|
156
|
+
totalWarnings += item.counts.eslint.warnings;
|
|
157
|
+
}
|
|
158
|
+
if (item.counts.principles?.warnings) {
|
|
159
|
+
totalWarnings += item.counts.principles.warnings;
|
|
160
|
+
}
|
|
161
|
+
if (item.counts.ccn?.warnings) {
|
|
162
|
+
totalWarnings += item.counts.ccn.warnings;
|
|
163
|
+
}
|
|
164
|
+
if (item.counts.ruff?.warnings) {
|
|
165
|
+
totalWarnings += item.counts.ruff.warnings;
|
|
166
|
+
}
|
|
167
|
+
if (item.counts.golangci?.warnings) {
|
|
168
|
+
totalWarnings += item.counts.golangci.warnings;
|
|
169
|
+
}
|
|
170
|
+
if (item.counts.shellcheck?.warnings) {
|
|
171
|
+
totalWarnings += item.counts.shellcheck.warnings;
|
|
172
|
+
}
|
|
173
|
+
if (item.counts.totalWarnings) {
|
|
174
|
+
totalWarnings = item.counts.totalWarnings;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
baseline[item.file] = {
|
|
178
|
+
...(item.counts.eslint && { eslint: item.counts.eslint }),
|
|
179
|
+
...(item.counts.principles && { principles: item.counts.principles }),
|
|
180
|
+
...(item.counts.ccn && { ccn: item.counts.ccn }),
|
|
181
|
+
...(item.counts.ruff && { ruff: item.counts.ruff }),
|
|
182
|
+
...(item.counts.golangci && { golangci: item.counts.golangci }),
|
|
183
|
+
...(item.counts.shellcheck && { shellcheck: item.counts.shellcheck }),
|
|
184
|
+
totalWarnings,
|
|
185
|
+
lastAnalyzed: new Date().toISOString()
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return baseline;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
getSummaryStatistics(baseline: Record<string, BaselineEntry>) {
|
|
193
|
+
return computeSummaryStats(baseline);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async initializeWithAnalyzer(
|
|
197
|
+
files: string[],
|
|
198
|
+
warningCountFunction: (file: string) => Promise<Partial<BaselineEntry>>,
|
|
199
|
+
onProgress?: (progress: { current: number; total: number; completed: string[] }) => void
|
|
200
|
+
): Promise<Record<string, BaselineEntry>> {
|
|
201
|
+
if (files.length > this.config.maxSize!) {
|
|
202
|
+
throw new Error(`Trying to initialize baseline with ${files.length} files, which exceeds the maximum of ${this.config.maxSize}`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const baseline: Record<string, BaselineEntry> = {};
|
|
206
|
+
const totalFiles = files.length;
|
|
207
|
+
const completed: string[] = [];
|
|
208
|
+
|
|
209
|
+
const timeoutPromise = new Promise<Record<string, BaselineEntry>>((_, reject) => {
|
|
210
|
+
setTimeout(() => reject(new Error('Baseline initialization timed out')), this.config.timeoutMs);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const processBatches = async () => {
|
|
214
|
+
for (let i = 0; i < files.length; i += this.config.batchSize!) {
|
|
215
|
+
const batch = files.slice(i, i + this.config.batchSize!);
|
|
216
|
+
|
|
217
|
+
const promiseResults = await Promise.allSettled(
|
|
218
|
+
batch.map(async (file) => {
|
|
219
|
+
const counts = await warningCountFunction(file);
|
|
220
|
+
|
|
221
|
+
let totalWarnings = 0;
|
|
222
|
+
if (counts.eslint?.warnings) totalWarnings += counts.eslint.warnings;
|
|
223
|
+
if (counts.principles?.warnings) totalWarnings += counts.principles.warnings;
|
|
224
|
+
if (counts.ccn?.warnings) totalWarnings += counts.ccn.warnings;
|
|
225
|
+
if (counts.ruff?.warnings) totalWarnings += counts.ruff.warnings;
|
|
226
|
+
if (counts.golangci?.warnings) totalWarnings += counts.golangci.warnings;
|
|
227
|
+
if (counts.shellcheck?.warnings) totalWarnings += counts.shellcheck.warnings;
|
|
228
|
+
if (counts.totalWarnings !== undefined) totalWarnings = counts.totalWarnings;
|
|
229
|
+
|
|
230
|
+
const baselineEntry: BaselineEntry = {
|
|
231
|
+
...(counts.eslint && { eslint: counts.eslint }),
|
|
232
|
+
...(counts.principles && { principles: counts.principles }),
|
|
233
|
+
...(counts.ccn && { ccn: counts.ccn }),
|
|
234
|
+
...(counts.ruff && { ruff: counts.ruff }),
|
|
235
|
+
...(counts.golangci && { golangci: counts.golangci }),
|
|
236
|
+
...(counts.shellcheck && { shellcheck: counts.shellcheck }),
|
|
237
|
+
totalWarnings,
|
|
238
|
+
lastAnalyzed: new Date().toISOString()
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
baseline[file] = baselineEntry;
|
|
242
|
+
completed.push(file);
|
|
243
|
+
|
|
244
|
+
if (onProgress) {
|
|
245
|
+
onProgress({
|
|
246
|
+
current: completed.length,
|
|
247
|
+
total: totalFiles,
|
|
248
|
+
completed: [...completed]
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
})
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
const rejectedPromises = promiseResults.filter(r => r.status === 'rejected') as Array<{ status: 'rejected'; reason: unknown }>;
|
|
255
|
+
if (rejectedPromises.length > 0) {
|
|
256
|
+
console.error('Some files failed to analyze:', rejectedPromises.map(r => r.reason));
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return baseline;
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
return Promise.race([processBatches(), timeoutPromise]);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export {
|
|
268
|
+
BaselineEntry,
|
|
269
|
+
BaselineStorage,
|
|
270
|
+
filterBaselineWarnings,
|
|
271
|
+
type BaselineStorageConfig
|
|
272
|
+
};
|
|
273
|
+
|
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
import * as fs from 'fs/promises';
|
|
2
|
+
import { analyze, getAdapterForFile } from './analyzer';
|
|
3
|
+
import { getAllRules } from './index';
|
|
4
|
+
|
|
5
|
+
interface FileClassification {
|
|
6
|
+
new: string[];
|
|
7
|
+
modified: string[];
|
|
8
|
+
deleted: string[];
|
|
9
|
+
renamed: { oldPath: string; newPath: string }[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Exported helper to get warning counts for a batch of files using the principles checker
|
|
13
|
+
export async function analyzeWarningsForFiles(filesInput: string | string[]): Promise<Record<string, number>> {
|
|
14
|
+
const files = (typeof filesInput === 'string' ? filesInput.split(',') : filesInput)
|
|
15
|
+
.flatMap(f => f.split(',').map(s => s.trim()))
|
|
16
|
+
.filter(f => f);
|
|
17
|
+
if (files.length === 0) {
|
|
18
|
+
return {};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const rules = getAllRules();
|
|
22
|
+
const result = await analyze(files, rules, getAdapterForFile);
|
|
23
|
+
|
|
24
|
+
const fileWarnings: Record<string, number> = {};
|
|
25
|
+
|
|
26
|
+
// Initialize all requested files with 0 warnings
|
|
27
|
+
for (const file of files) {
|
|
28
|
+
fileWarnings[file] = 0;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Count violations per file
|
|
32
|
+
for (const violation of result.violations) {
|
|
33
|
+
// Only count warnings and errors (ignore info level)
|
|
34
|
+
if (violation.severity === 'warning' || violation.severity === 'error') {
|
|
35
|
+
fileWarnings[violation.file] = (fileWarnings[violation.file] || 0) + 1;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return fileWarnings;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface BaselineEntry {
|
|
43
|
+
eslint?: { warnings: number; errors: number };
|
|
44
|
+
principles?: { warnings: number; errors: number };
|
|
45
|
+
ccn?: { warnings: number; max: number };
|
|
46
|
+
totalWarnings: number;
|
|
47
|
+
lastAnalyzed: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface DeltaResult {
|
|
51
|
+
file: string;
|
|
52
|
+
status: 'NEW' | 'MODIFIED' | 'UNCHANGED';
|
|
53
|
+
baselineWarnings: number;
|
|
54
|
+
currentWarnings: number;
|
|
55
|
+
delta: number;
|
|
56
|
+
enforcement: 'PASS' | 'BLOCK';
|
|
57
|
+
reason: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface EnforcementResult {
|
|
61
|
+
overallStatus: 'PASS' | 'BLOCK';
|
|
62
|
+
violations: DeltaResult[];
|
|
63
|
+
detailedReport: DeltaResult[];
|
|
64
|
+
summary: {
|
|
65
|
+
totalFiles: number;
|
|
66
|
+
passedFiles: number;
|
|
67
|
+
blockedFiles: number;
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function classifyFiles(gitDiffLines: string[]): FileClassification {
|
|
72
|
+
const result: FileClassification = {
|
|
73
|
+
new: [],
|
|
74
|
+
modified: [],
|
|
75
|
+
deleted: [],
|
|
76
|
+
renamed: []
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
for (const line of gitDiffLines) {
|
|
80
|
+
if (!line.trim()) continue;
|
|
81
|
+
|
|
82
|
+
const parts = line.split(/\s+/).filter(Boolean);
|
|
83
|
+
if (parts.length < 2) continue;
|
|
84
|
+
|
|
85
|
+
const status = parts[0].trim();
|
|
86
|
+
switch (status.charAt(0)) {
|
|
87
|
+
case 'A':
|
|
88
|
+
result.new.push(parts.slice(1).join(' '));
|
|
89
|
+
break;
|
|
90
|
+
case 'M':
|
|
91
|
+
result.modified.push(parts.slice(1).join(' '));
|
|
92
|
+
break;
|
|
93
|
+
case 'D':
|
|
94
|
+
result.deleted.push(parts.slice(1).join(' '));
|
|
95
|
+
break;
|
|
96
|
+
case 'R':
|
|
97
|
+
if (parts.length >= 3) {
|
|
98
|
+
result.renamed.push({
|
|
99
|
+
oldPath: parts[1],
|
|
100
|
+
newPath: parts[2]
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
break;
|
|
104
|
+
default:
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return result;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function loadBaseline(baselinePath: string): Promise<Record<string, BaselineEntry>> {
|
|
113
|
+
try {
|
|
114
|
+
await fs.access(baselinePath);
|
|
115
|
+
const baselineContent = await fs.readFile(baselinePath, 'utf-8');
|
|
116
|
+
return JSON.parse(baselineContent);
|
|
117
|
+
} catch {
|
|
118
|
+
return {};
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function saveBaseline(baselinePath: string, baseline: Record<string, BaselineEntry>): Promise<void> {
|
|
123
|
+
await fs.writeFile(baselinePath, JSON.stringify(baseline, null, 2));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function evaluateNewFile(currentWarnings: number): Pick<DeltaResult, 'enforcement' | 'reason'> {
|
|
127
|
+
if (currentWarnings > 0) {
|
|
128
|
+
return {
|
|
129
|
+
enforcement: 'BLOCK',
|
|
130
|
+
reason: `New files must have zero warnings (currently: ${currentWarnings}). Boy Scout Rule: Leave the code cleaner than you found it.`,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
return { enforcement: 'PASS', reason: 'New file with zero warnings' };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function describeNonIncreasedDelta(delta: number, currentWarnings: number): string {
|
|
137
|
+
if (delta < 0) return `Warnings decreased by ${Math.abs(delta)}`;
|
|
138
|
+
if (currentWarnings === 0) return 'All warnings cleared';
|
|
139
|
+
return 'No new warnings introduced';
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function evaluateModifiedFile(
|
|
143
|
+
baselineEntry: BaselineEntry | null,
|
|
144
|
+
currentWarnings: number,
|
|
145
|
+
baselineWarnings: number,
|
|
146
|
+
): Pick<DeltaResult, 'enforcement' | 'reason'> {
|
|
147
|
+
if (!baselineEntry) {
|
|
148
|
+
return { enforcement: 'PASS', reason: 'File added to baseline with current warning count' };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (currentWarnings > baselineWarnings) {
|
|
152
|
+
return {
|
|
153
|
+
enforcement: 'BLOCK',
|
|
154
|
+
reason: `Modified files cannot increase warnings (${currentWarnings} > ${baselineWarnings}). Boy Scout Rule: Leave the code cleaner than you found it.`,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (baselineWarnings <= 5 && currentWarnings > 0) {
|
|
159
|
+
return {
|
|
160
|
+
enforcement: 'BLOCK',
|
|
161
|
+
reason: `Files with <=5 warnings must clear to zero (currently: ${currentWarnings}/${baselineWarnings}). Boy Scout Rule: Leave the code cleaner than you found it.`,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
enforcement: 'PASS',
|
|
167
|
+
reason: describeNonIncreasedDelta(currentWarnings - baselineWarnings, currentWarnings),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function calculateDelta(
|
|
172
|
+
baselineEntry: BaselineEntry | null,
|
|
173
|
+
currentWarnings: number,
|
|
174
|
+
status: 'NEW' | 'MODIFIED'
|
|
175
|
+
): DeltaResult {
|
|
176
|
+
const baselineWarnings = baselineEntry ? baselineEntry.totalWarnings : 0;
|
|
177
|
+
const delta = status === 'NEW' ? currentWarnings : currentWarnings - baselineWarnings;
|
|
178
|
+
|
|
179
|
+
const evaluation = status === 'NEW'
|
|
180
|
+
? evaluateNewFile(currentWarnings)
|
|
181
|
+
: evaluateModifiedFile(baselineEntry, currentWarnings, baselineWarnings);
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
file: '',
|
|
185
|
+
status,
|
|
186
|
+
baselineWarnings,
|
|
187
|
+
currentWarnings,
|
|
188
|
+
delta,
|
|
189
|
+
enforcement: evaluation.enforcement,
|
|
190
|
+
reason: evaluation.reason,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function enforceBoyScoutRule(deltas: DeltaResult[]): EnforcementResult {
|
|
195
|
+
const violations = deltas.filter(delta => delta.enforcement === 'BLOCK');
|
|
196
|
+
|
|
197
|
+
let passedCount = 0;
|
|
198
|
+
deltas.forEach(delta => {
|
|
199
|
+
if (delta.enforcement === 'PASS') passedCount++;
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
overallStatus: violations.length > 0 ? 'BLOCK' : 'PASS',
|
|
204
|
+
violations,
|
|
205
|
+
detailedReport: deltas,
|
|
206
|
+
summary: {
|
|
207
|
+
totalFiles: deltas.length,
|
|
208
|
+
passedFiles: passedCount,
|
|
209
|
+
blockedFiles: violations.length
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export async function initBaseline(files: string[]): Promise<Record<string, BaselineEntry>> {
|
|
215
|
+
const currentWarnings = await analyzeWarningsForFiles(files);
|
|
216
|
+
const baseline: Record<string, BaselineEntry> = {};
|
|
217
|
+
|
|
218
|
+
for (const file of files) {
|
|
219
|
+
try {
|
|
220
|
+
const warningCount = currentWarnings[file] || 0;
|
|
221
|
+
if (warningCount > 0) {
|
|
222
|
+
baseline[file] = {
|
|
223
|
+
totalWarnings: warningCount,
|
|
224
|
+
lastAnalyzed: new Date().toISOString(),
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
} catch (error: unknown) {
|
|
228
|
+
console.error(`Failed to analyze file for baseline: ${file}`, error);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return baseline;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Initializes baseline from current violations for the specified files
|
|
237
|
+
*/
|
|
238
|
+
async function autoInitBaseline(
|
|
239
|
+
files: string[],
|
|
240
|
+
baselinePath: string
|
|
241
|
+
): Promise<Record<string, BaselineEntry>> {
|
|
242
|
+
const currentWarnings = await analyzeWarningsForFiles(files);
|
|
243
|
+
const baseline: Record<string, BaselineEntry> = {};
|
|
244
|
+
|
|
245
|
+
for (const file of files) {
|
|
246
|
+
const count = currentWarnings[file] || 0;
|
|
247
|
+
if (count > 0) {
|
|
248
|
+
baseline[file] = {
|
|
249
|
+
totalWarnings: count,
|
|
250
|
+
lastAnalyzed: new Date().toISOString(),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
await saveBaseline(baselinePath, baseline);
|
|
256
|
+
return baseline;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function runInitBaselineCommand(parsed: Record<string, unknown>): Promise<number> {
|
|
260
|
+
try {
|
|
261
|
+
await initBaselineCommand((parsed.files ?? []) as string[]);
|
|
262
|
+
console.log('Baseline initialized successfully');
|
|
263
|
+
return 0;
|
|
264
|
+
} catch (error: unknown) {
|
|
265
|
+
console.error('Error initializing baseline:', error);
|
|
266
|
+
return 1;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export async function runEnforcementCommand(parsed: Record<string, unknown>): Promise<number> {
|
|
271
|
+
try {
|
|
272
|
+
const enforcementResult = await runEnforcement(
|
|
273
|
+
(parsed.newFiles ?? []) as string[],
|
|
274
|
+
(parsed.modifiedFiles ?? []) as string[],
|
|
275
|
+
(parsed.baselinePath as string) || '.warnings-baseline.json'
|
|
276
|
+
);
|
|
277
|
+
console.log(JSON.stringify(enforcementResult, null, 2));
|
|
278
|
+
return enforcementResult.overallStatus === 'PASS' ? 0 : 1;
|
|
279
|
+
} catch (error: unknown) {
|
|
280
|
+
console.error('Error during enforcement:', error);
|
|
281
|
+
return 1;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export async function main(): Promise<number> {
|
|
286
|
+
const parsed = parseArgs(process.argv.slice(2));
|
|
287
|
+
return parsed.command === 'init-baseline'
|
|
288
|
+
? runInitBaselineCommand(parsed)
|
|
289
|
+
: runEnforcementCommand(parsed);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function splitCsvArg(raw: string | undefined): string[] {
|
|
293
|
+
return raw?.split(',').map((s: string) => s.trim()).filter(Boolean) || [];
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const ARG_HANDLERS: Record<string, (parsed: Record<string, unknown>, next: string | undefined) => boolean> = {
|
|
297
|
+
'--new-files': (parsed, next) => { parsed.newFiles = splitCsvArg(next); return true; },
|
|
298
|
+
'--modified-files': (parsed, next) => { parsed.modifiedFiles = splitCsvArg(next); return true; },
|
|
299
|
+
'--baseline': (parsed, next) => { parsed.baselinePath = next; return true; },
|
|
300
|
+
'--init-baseline': (parsed, next) => {
|
|
301
|
+
parsed.command = 'init-baseline';
|
|
302
|
+
parsed.files = splitCsvArg(next);
|
|
303
|
+
return true;
|
|
304
|
+
},
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
function parseArgs(args: string[]): Record<string, unknown> {
|
|
308
|
+
const parsed: Record<string, unknown> = {
|
|
309
|
+
command: null,
|
|
310
|
+
newFiles: [],
|
|
311
|
+
modifiedFiles: [],
|
|
312
|
+
baselinePath: null,
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
for (let i = 0; i < args.length; i++) {
|
|
316
|
+
const arg = args[i];
|
|
317
|
+
if (arg === '--help' || arg === '-h' || arg === 'help') {
|
|
318
|
+
showHelp();
|
|
319
|
+
process.exit(0);
|
|
320
|
+
}
|
|
321
|
+
const handler = ARG_HANDLERS[arg];
|
|
322
|
+
if (handler && handler(parsed, args[i + 1])) {
|
|
323
|
+
i++;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return parsed;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function showHelp(): void {
|
|
331
|
+
console.log(`
|
|
332
|
+
Usage: boy-scout <options>
|
|
333
|
+
Options:
|
|
334
|
+
--new-files <file1,file2,...> Specify new files to analyze
|
|
335
|
+
--modified-files <file1,file2,...> Specify modified files to analyze
|
|
336
|
+
--baseline <path> Path to baseline file (default: .warnings-baseline.json)
|
|
337
|
+
--init-baseline [file1,file2,...] Initialize baseline with current warning counts
|
|
338
|
+
--help Show this help message
|
|
339
|
+
|
|
340
|
+
Examples:
|
|
341
|
+
npx tsx boy-scout.ts --new-files src/new-file.ts
|
|
342
|
+
npx tsx boy-scout.ts --modified-files src/changed-file.ts --baseline my-baseline.json
|
|
343
|
+
npx tsx boy-scout.ts --init-baseline src/file1.ts,src/file2.ts
|
|
344
|
+
`);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async function initBaselineCommand(files: string[]): Promise<void> {
|
|
348
|
+
const baseline = await initBaseline(files);
|
|
349
|
+
await saveBaseline('.warnings-baseline.json', baseline);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function runEnforcement(newFiles: string[], modifiedFiles: string[], baselinePath: string): Promise<EnforcementResult> {
|
|
353
|
+
const allFiles = [...newFiles.filter(f => f.trim()), ...modifiedFiles.filter(f => f.trim())];
|
|
354
|
+
const currentWarnings = await analyzeWarningsForFiles(allFiles);
|
|
355
|
+
|
|
356
|
+
const baseline = await loadBaseline(baselinePath);
|
|
357
|
+
|
|
358
|
+
// Check for missing baseline entries for modified files
|
|
359
|
+
const missingBaselineEntries: string[] = [];
|
|
360
|
+
for (const file of modifiedFiles) {
|
|
361
|
+
if (!baseline[file]) {
|
|
362
|
+
missingBaselineEntries.push(file);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// If there are missing baseline entries, auto-initialize them
|
|
367
|
+
if (missingBaselineEntries.length > 0) {
|
|
368
|
+
for (const file of missingBaselineEntries) {
|
|
369
|
+
const warningCount = currentWarnings[file] || 0;
|
|
370
|
+
if (warningCount > 0) {
|
|
371
|
+
baseline[file] = {
|
|
372
|
+
totalWarnings: warningCount,
|
|
373
|
+
lastAnalyzed: new Date().toISOString(),
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
// Save the updated baseline
|
|
378
|
+
await saveBaseline(baselinePath, baseline);
|
|
379
|
+
console.log(`ℹ️ Auto-initialized baseline for ${missingBaselineEntries.length} files`);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const deltaResults: DeltaResult[] = [];
|
|
383
|
+
|
|
384
|
+
for (const file of newFiles) {
|
|
385
|
+
const warningCount = currentWarnings[file] || 0;
|
|
386
|
+
const delta = calculateDelta(null, warningCount, 'NEW');
|
|
387
|
+
delta.file = file;
|
|
388
|
+
deltaResults.push(delta);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
for (const file of modifiedFiles) {
|
|
392
|
+
const baselineEntry = baseline[file] || null;
|
|
393
|
+
const warningCount = currentWarnings[file] || 0;
|
|
394
|
+
const delta = calculateDelta(baselineEntry, warningCount, 'MODIFIED');
|
|
395
|
+
delta.file = file;
|
|
396
|
+
deltaResults.push(delta);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return enforceBoyScoutRule(deltaResults);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if ((typeof require !== 'undefined' && require.main === module) ||
|
|
403
|
+
(typeof require === 'undefined' && process.argv[1]?.includes('boy-scout'))) {
|
|
404
|
+
main()
|
|
405
|
+
.then(code => process.exit(code))
|
|
406
|
+
.catch(error => {
|
|
407
|
+
console.error('Unhandled error:', error);
|
|
408
|
+
process.exit(1);
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
export {
|
|
413
|
+
initBaselineCommand,
|
|
414
|
+
runEnforcement,
|
|
415
|
+
autoInitBaseline
|
|
416
|
+
};
|