@boyingliu01/xp-gate 0.19.3 → 0.20.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/adapters/gate-4.sh +83 -18
- package/adapters/gate-8.sh +115 -29
- package/adapters/iac.sh +1 -1
- package/adapters/powershell.sh +60 -5
- package/adapters/python.sh +38 -4
- package/adapters/typescript.sh +15 -60
- package/bin/xp-gate.js +15 -16
- package/build-integrity/__tests__/import-resolver.test.ts +59 -0
- package/build-integrity/gate-10.ts +17 -2
- package/gate-10.sh +35 -2
- package/gate-4.sh +110 -21
- package/gate-8.sh +115 -29
- package/hooks/gate-10.sh +35 -2
- package/hooks/gate-4.sh +110 -21
- package/hooks/gate-8.sh +115 -29
- package/hooks/lib/jscpd-run.sh +63 -0
- package/hooks/lib/sprint-gate-report.sh +58 -0
- package/hooks/lib/test-failure.sh +152 -0
- package/hooks/lib/typecheck.sh +141 -0
- package/hooks/lib/validate-code-walkthrough.cjs +43 -5
- package/hooks/post-merge +100 -0
- package/hooks/pre-commit +514 -184
- package/hooks/pre-push +75 -19
- package/lib/__tests__/bootstrap.test.js +23 -41
- package/lib/__tests__/check-version.test.js +8 -1
- package/lib/__tests__/detect-deps.test.js +16 -0
- package/lib/__tests__/doctor-drift-round2.test.js +212 -0
- package/lib/__tests__/doctor-executed-surface.test.js +209 -0
- package/lib/__tests__/doctor-hook-drift.test.js +329 -0
- package/lib/__tests__/doctor-machine-mutation.test.js +116 -0
- package/lib/__tests__/doctor-module-drift.test.js +206 -0
- package/lib/__tests__/doctor-module-resolution-parity.test.js +314 -0
- package/lib/__tests__/doctor-path-equivalence.test.js +59 -0
- package/lib/__tests__/doctor-repair-boundary.test.js +52 -0
- package/lib/__tests__/doctor-tui-skill-freshness.test.js +105 -0
- package/lib/__tests__/doctor.test.js +155 -0
- package/lib/__tests__/gate-runner.test.js +19 -3
- package/lib/__tests__/helpers/lib-sandbox.cjs +55 -0
- package/lib/__tests__/init.test.js +44 -17
- package/lib/__tests__/install-skill-full-dir.test.js +151 -0
- package/lib/__tests__/install-skill.test.js +51 -132
- package/lib/__tests__/language-tools-detect.test.js +158 -0
- package/lib/__tests__/phase-transition.test.js +15 -1
- package/lib/__tests__/qoder-delphi-agents.test.js +285 -0
- package/lib/__tests__/shared-paths.test.js +20 -0
- package/lib/__tests__/shared-utils.test.js +69 -0
- package/lib/__tests__/skill-flags.test.js +48 -0
- package/lib/__tests__/ui-detector.test.ts +6 -2
- package/lib/__tests__/uninstall.test.js +20 -0
- package/lib/__tests__/update-hooks.test.js +12 -0
- package/lib/__tests__/update-skill.test.js +7 -4
- package/lib/detect-deps.js +3 -20
- package/lib/doctor-tui.js +22 -5
- package/lib/doctor.js +1178 -31
- package/lib/init.js +184 -33
- package/lib/install-cmd.js +7 -4
- package/lib/install-skill.js +38 -95
- package/lib/language-tools.js +83 -4
- package/lib/mattpocock-license-verdict.json +1 -1
- package/lib/shared-paths.js +5 -2
- package/lib/shared-utils.js +47 -3
- package/lib/uninstall.js +12 -0
- package/lib/update-hooks.js +4 -4
- package/lib/update-skill.js +6 -7
- package/mock-policy/AGENTS.md +4 -4
- package/mutation/AGENTS.md +4 -4
- package/mutation/__tests__/gate-m.test.ts +66 -14
- package/mutation/__tests__/mutmut-runner.test.ts +91 -42
- package/mutation/__tests__/test-filter-parity.test.ts +37 -0
- package/mutation/gate-m.ts +21 -8
- package/package.json +2 -2
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +4 -4
- package/plugins/claude-code/skills/delphi-review/SKILL.md +98 -14
- package/plugins/claude-code/skills/delphi-review/references/code-walkthrough.md +36 -3
- package/plugins/claude-code/skills/delphi-review/references/requirements.md +6 -6
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +4 -4
- package/plugins/claude-code/skills/sprint-flow/references/phase-2-design.md +3 -3
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +4 -4
- package/plugins/dsh/README.md +169 -4
- package/plugins/dsh/dsh-overlay.patch.yml +67 -0
- package/plugins/dsh/package.json +1 -1
- package/plugins/dsh/scripts/prepack.cjs +24 -19
- package/plugins/dsh/setup-dsh.ps1 +160 -0
- package/plugins/dsh/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
- package/plugins/dsh/skills/delphi-review/AGENTS.md +4 -4
- package/plugins/dsh/skills/delphi-review/SKILL.md +98 -14
- package/plugins/dsh/skills/delphi-review/references/code-walkthrough.md +36 -3
- package/plugins/dsh/skills/delphi-review/references/requirements.md +6 -6
- package/plugins/dsh/skills/sprint-flow/AGENTS.md +4 -4
- package/plugins/dsh/skills/sprint-flow/references/phase-2-design.md +3 -3
- package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +4 -4
- package/plugins/dsh/src/command.test.ts +66 -2
- package/plugins/dsh/src/command.ts +36 -14
- package/plugins/dsh/src/contract.test.ts +31 -1
- package/plugins/dsh/src/delphi-config.test.ts +190 -0
- package/plugins/dsh/src/delphi-config.ts +223 -0
- package/plugins/dsh/src/delphi-run.test.ts +181 -0
- package/plugins/dsh/src/delphi-run.ts +129 -0
- package/plugins/dsh/src/delphi.test.ts +222 -0
- package/plugins/dsh/src/delphi.ts +247 -0
- package/plugins/dsh/src/dialect.test.ts +59 -0
- package/plugins/dsh/src/dialect.ts +40 -0
- package/plugins/dsh/src/index.ts +70 -4
- package/plugins/dsh/src/skills.test.ts +26 -19
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +4 -4
- package/plugins/opencode/skills/delphi-review/SKILL.md +98 -14
- package/plugins/opencode/skills/delphi-review/references/code-walkthrough.md +36 -3
- package/plugins/opencode/skills/delphi-review/references/requirements.md +6 -6
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +4 -4
- package/plugins/opencode/skills/sprint-flow/references/phase-2-design.md +3 -3
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +4 -4
- package/plugins/qoder/agents/delphi-architecture.md +1 -1
- package/plugins/qoder/agents/delphi-feasibility.md +1 -1
- package/plugins/qoder/agents/delphi-technical.md +1 -1
- package/plugins/qoder/plugin.json +1 -1
- package/plugins/qoder/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
- package/plugins/qoder/skills/delphi-review/AGENTS.md +4 -4
- package/plugins/qoder/skills/delphi-review/SKILL.md +98 -14
- package/plugins/qoder/skills/delphi-review/references/code-walkthrough.md +36 -3
- package/plugins/qoder/skills/delphi-review/references/requirements.md +6 -6
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +4 -4
- package/plugins/qoder/skills/sprint-flow/references/phase-2-design.md +3 -3
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +4 -4
- package/principles/AGENTS.md +9 -8
- package/principles/__tests__/adapter-metrics.test.ts +292 -0
- package/principles/__tests__/analyzer.test.ts +29 -6
- package/principles/__tests__/baseline-prune-timeout.test.ts +71 -0
- package/principles/__tests__/baseline-prune.test.ts +338 -0
- package/principles/__tests__/boy-scout-init-baseline.test.ts +167 -0
- package/principles/__tests__/boy-scout-integration.test.ts +97 -6
- package/principles/__tests__/boy-scout.test.ts +253 -7
- package/principles/__tests__/config-enforcement.test.ts +469 -0
- package/principles/__tests__/config-git-timeout.test.ts +66 -0
- package/principles/__tests__/direct-execution.test.ts +187 -0
- package/principles/__tests__/index.test.ts +16 -3
- package/principles/__tests__/warnings-baseline-budgets.test.ts +74 -0
- package/principles/adapters/typescript.ts +613 -13
- package/principles/analyzer.ts +22 -0
- package/principles/baseline-prune.ts +217 -0
- package/principles/boy-scout.ts +516 -56
- package/principles/config.ts +362 -54
- package/principles/direct-execution.ts +60 -0
- package/principles/index.ts +40 -17
- package/principles/rules/__tests__/clean-code/many-exports.test.ts +16 -10
- package/principles/rules/clean-code/code-duplication.ts +8 -8
- package/principles/rules/clean-code/deep-nesting.ts +8 -8
- package/principles/rules/clean-code/god-class.ts +8 -8
- package/principles/rules/clean-code/large-file.ts +15 -11
- package/principles/rules/clean-code/long-function.ts +8 -8
- package/principles/rules/clean-code/magic-numbers.ts +10 -7
- package/principles/rules/clean-code/many-exports.ts +7 -7
- package/principles/rules/clean-code/missing-error-handling.ts +5 -5
- package/principles/rules/clean-code/too-many-params.ts +8 -8
- package/principles/rules/clean-code/unused-imports.ts +5 -5
- package/principles/rules/solid/dip.ts +18 -8
- package/principles/rules/solid/isp.ts +9 -8
- package/principles/rules/solid/lsp.ts +6 -6
- package/principles/rules/solid/ocp.ts +6 -5
- package/principles/rules/solid/srp.ts +7 -8
- package/principles/types.ts +15 -0
- package/scripts/__tests__/sync-package-content.test.js +28 -0
- package/scripts/delphi-external-review.cjs +193 -10
- package/scripts/sync-package-content.js +20 -10
- package/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
- package/skills/delphi-review/AGENTS.md +4 -4
- package/skills/delphi-review/SKILL.md +98 -14
- package/skills/delphi-review/references/code-walkthrough.md +36 -3
- package/skills/delphi-review/references/requirements.md +6 -6
- package/skills/sprint-flow/AGENTS.md +4 -4
- package/skills/sprint-flow/references/phase-2-design.md +3 -3
- package/skills/test-specification-alignment/AGENTS.md +4 -4
- package/lib/__tests__/download-skill.test.js +0 -281
- package/lib/download-skill.js +0 -96
package/principles/boy-scout.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import * as fs from 'fs/promises';
|
|
2
2
|
import { analyze, getAdapterForFile } from './analyzer';
|
|
3
3
|
import { getAllRules } from './index';
|
|
4
|
+
import { loadConfig, setActiveConfig } from './config';
|
|
5
|
+
// Imported for the `--prune-baseline` command. The logic lives in its own module
|
|
6
|
+
// because this file is already at the clean-code.many-exports limit; consumers
|
|
7
|
+
// that need it should import from './baseline-prune' directly.
|
|
8
|
+
import { pruneBaselineEntries } from './baseline-prune';
|
|
9
|
+
import { isDirectExecution } from './direct-execution.js';
|
|
4
10
|
|
|
5
11
|
interface FileClassification {
|
|
6
12
|
new: string[];
|
|
@@ -9,6 +15,17 @@ interface FileClassification {
|
|
|
9
15
|
renamed: { oldPath: string; newPath: string }[];
|
|
10
16
|
}
|
|
11
17
|
|
|
18
|
+
/** A git rename line is `R<score> <oldPath> <newPath>` -- three fields minimum. */
|
|
19
|
+
const RENAME_PARTS = 3;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A file already tracked with at most this many warnings must be brought to
|
|
23
|
+
* zero the next time it is modified. Above it, the normal "must not increase"
|
|
24
|
+
* contract applies. Named rather than inlined so the threshold is greppable and
|
|
25
|
+
* so it stops registering as a magic number.
|
|
26
|
+
*/
|
|
27
|
+
const SMALL_WARNING_BUDGET = 5;
|
|
28
|
+
|
|
12
29
|
// Exported helper to get warning counts for a batch of files using the principles checker
|
|
13
30
|
export async function analyzeWarningsForFiles(filesInput: string | string[]): Promise<Record<string, number>> {
|
|
14
31
|
const files = (typeof filesInput === 'string' ? filesInput.split(',') : filesInput)
|
|
@@ -83,6 +100,17 @@ export function classifyFiles(gitDiffLines: string[]): FileClassification {
|
|
|
83
100
|
if (parts.length < 2) continue;
|
|
84
101
|
|
|
85
102
|
const status = parts[0].trim();
|
|
103
|
+
// Guard the rename case before the switch so the body needs no extra level.
|
|
104
|
+
if (status.charAt(0) === 'R') {
|
|
105
|
+
// A rename line carries both the old and the new path.
|
|
106
|
+
if (parts.length < RENAME_PARTS) continue;
|
|
107
|
+
result.renamed.push({
|
|
108
|
+
oldPath: parts[1],
|
|
109
|
+
newPath: parts[2]
|
|
110
|
+
});
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
|
|
86
114
|
switch (status.charAt(0)) {
|
|
87
115
|
case 'A':
|
|
88
116
|
result.new.push(parts.slice(1).join(' '));
|
|
@@ -93,14 +121,6 @@ export function classifyFiles(gitDiffLines: string[]): FileClassification {
|
|
|
93
121
|
case 'D':
|
|
94
122
|
result.deleted.push(parts.slice(1).join(' '));
|
|
95
123
|
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
124
|
default:
|
|
105
125
|
break;
|
|
106
126
|
}
|
|
@@ -109,18 +129,178 @@ export function classifyFiles(gitDiffLines: string[]): FileClassification {
|
|
|
109
129
|
return result;
|
|
110
130
|
}
|
|
111
131
|
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Validate one baseline entry.
|
|
135
|
+
*
|
|
136
|
+
* The on-disk shape is `{ totalWarnings: number, lastAnalyzed: string }`. A bare
|
|
137
|
+
* number -- `{"src/a.ts": 1}` -- is accepted by `JSON.parse` and by TypeScript's
|
|
138
|
+
* unchecked cast, but `entry.totalWarnings` then reads `undefined`, and BOTH
|
|
139
|
+
* block conditions in `evaluateModifiedFile` become false:
|
|
140
|
+
*
|
|
141
|
+
* `currentWarnings > undefined` === false
|
|
142
|
+
* `undefined <= 5` === false
|
|
143
|
+
*
|
|
144
|
+
* So the file silently passes forever no matter how many warnings it gains. That
|
|
145
|
+
* exact shape is committed in 791d825 and still present in HEAD, which is why
|
|
146
|
+
* this is a hard failure rather than a warning (#455). A baseline whose numbers
|
|
147
|
+
* cannot be trusted is worse than no baseline, because the gate still reports
|
|
148
|
+
* "PASSED" and nobody looks.
|
|
149
|
+
*/
|
|
150
|
+
function assertValidEntry(file: string, entry: unknown): asserts entry is BaselineEntry {
|
|
151
|
+
const malformed = (detail: string): never => {
|
|
152
|
+
throw new Error(
|
|
153
|
+
`Malformed baseline entry for ${file}: ${detail}. ` +
|
|
154
|
+
`Expected { totalWarnings: number, lastAnalyzed: string }. ` +
|
|
155
|
+
`Repair it with \`xp-gate baseline create\` rather than editing by hand.`,
|
|
156
|
+
);
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
160
|
+
malformed(`got ${Array.isArray(entry) ? 'an array' : typeof entry}`);
|
|
161
|
+
}
|
|
162
|
+
const total = (entry as BaselineEntry).totalWarnings;
|
|
163
|
+
if (typeof total !== 'number' || !Number.isFinite(total)) {
|
|
164
|
+
malformed(`totalWarnings is ${total === undefined ? 'missing' : String(total)}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Read the warning baseline.
|
|
170
|
+
*
|
|
171
|
+
* A missing file is normal (first run) and yields `{}`. A file that exists but
|
|
172
|
+
* cannot be parsed or whose entries are malformed is an error: continuing with
|
|
173
|
+
* silently-empty budgets is what let the gate pass everything in #455.
|
|
174
|
+
*/
|
|
112
175
|
export async function loadBaseline(baselinePath: string): Promise<Record<string, BaselineEntry>> {
|
|
176
|
+
let baselineContent: string;
|
|
113
177
|
try {
|
|
114
|
-
await fs.
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
178
|
+
baselineContent = await fs.readFile(baselinePath, 'utf-8');
|
|
179
|
+
} catch (error) {
|
|
180
|
+
// ONLY "not there yet" means first run. Any other I/O failure (EACCES, EPERM,
|
|
181
|
+
// a TOCTOU between access and read, a transient disk error) must NOT be read
|
|
182
|
+
// as an empty budget: callers react to an empty baseline by auto-initialising
|
|
183
|
+
// and then SAVING, which overwrites whatever real entries the file held --
|
|
184
|
+
// exactly the destructive overwrite #445 removed. Reporting a hard error is
|
|
185
|
+
// the only safe response when we cannot tell what the baseline contains.
|
|
186
|
+
if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') {
|
|
187
|
+
return {};
|
|
188
|
+
}
|
|
189
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
190
|
+
throw new Error(`Cannot read baseline ${baselinePath}: ${reason}`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
let parsed: unknown;
|
|
194
|
+
try {
|
|
195
|
+
parsed = JSON.parse(baselineContent);
|
|
196
|
+
} catch (error) {
|
|
197
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
198
|
+
throw new Error(`Baseline ${baselinePath} is not valid JSON: ${reason}`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
202
|
+
throw new Error(`Baseline ${baselinePath} must be a JSON object mapping paths to entries.`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
for (const [file, entry] of Object.entries(parsed as Record<string, unknown>)) {
|
|
206
|
+
assertValidEntry(file, entry);
|
|
207
|
+
}
|
|
208
|
+
return parsed as Record<string, BaselineEntry>;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Read the baseline for repair, tolerating entries `loadBaseline` would reject.
|
|
213
|
+
*
|
|
214
|
+
* `initBaselineCommand` is the tool that fixes a broken baseline, so it cannot
|
|
215
|
+
* require the baseline to be well-formed first -- otherwise a corrupt entry is
|
|
216
|
+
* unrecoverable by any supported path (#455). Malformed entries are dropped and
|
|
217
|
+
* reported; everything else is preserved, because silently discarding unrelated
|
|
218
|
+
* budgets is the destructive overwrite fixed in #445.
|
|
219
|
+
*
|
|
220
|
+
* The file is read once and parsed once -- re-reading after `loadBaseline` threw
|
|
221
|
+
* would race a concurrent writer and could report a different set of drops than
|
|
222
|
+
* the caller then saves.
|
|
223
|
+
*/
|
|
224
|
+
async function loadBaselineForRepair(
|
|
225
|
+
baselinePath: string,
|
|
226
|
+
): Promise<{ entries: Record<string, BaselineEntry>; dropped: string[]; readError: string | null }> {
|
|
227
|
+
const absent = (readError: string | null) => ({ entries: {} as Record<string, BaselineEntry>, dropped: [] as string[], readError });
|
|
228
|
+
|
|
229
|
+
const content = await readBaselineText(baselinePath);
|
|
230
|
+
if (typeof content !== 'string') {
|
|
231
|
+
// `null` means the file is simply not there yet -- a normal first run. Any
|
|
232
|
+
// other failure must be reported rather than treated as empty, so the caller
|
|
233
|
+
// can refuse to write instead of saving over content we never inspected.
|
|
234
|
+
return absent(content === null ? null : `Cannot read baseline ${baselinePath}: ${content.reason}`);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const parsed = parseBaselineJson(content, baselinePath);
|
|
238
|
+
if (typeof parsed === 'string') {
|
|
239
|
+
return absent(parsed);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return { ...partitionEntries(parsed), readError: null };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Read the baseline file. Returns the text, `null` when absent, or the failure. */
|
|
246
|
+
async function readBaselineText(baselinePath: string): Promise<string | null | { reason: string }> {
|
|
247
|
+
try {
|
|
248
|
+
return await fs.readFile(baselinePath, 'utf-8');
|
|
249
|
+
} catch (error) {
|
|
250
|
+
if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') return null;
|
|
251
|
+
return { reason: error instanceof Error ? error.message : String(error) };
|
|
119
252
|
}
|
|
120
253
|
}
|
|
121
254
|
|
|
255
|
+
/** Parse the baseline JSON. Returns the object, or a human-readable failure. */
|
|
256
|
+
function parseBaselineJson(content: string, baselinePath: string): Record<string, unknown> | string {
|
|
257
|
+
let parsed: unknown;
|
|
258
|
+
try {
|
|
259
|
+
parsed = JSON.parse(content);
|
|
260
|
+
} catch (error) {
|
|
261
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
262
|
+
return `Baseline ${baselinePath} is not valid JSON: ${reason}`;
|
|
263
|
+
}
|
|
264
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
265
|
+
return `Baseline ${baselinePath} must be a JSON object mapping paths to entries.`;
|
|
266
|
+
}
|
|
267
|
+
return parsed as Record<string, unknown>;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Split entries into the valid ones and the names of the malformed ones. */
|
|
271
|
+
function partitionEntries(parsed: Record<string, unknown>): {
|
|
272
|
+
entries: Record<string, BaselineEntry>;
|
|
273
|
+
dropped: string[];
|
|
274
|
+
} {
|
|
275
|
+
const entries: Record<string, BaselineEntry> = {};
|
|
276
|
+
const dropped: string[] = [];
|
|
277
|
+
for (const [file, entry] of Object.entries(parsed)) {
|
|
278
|
+
try {
|
|
279
|
+
assertValidEntry(file, entry);
|
|
280
|
+
entries[file] = entry;
|
|
281
|
+
} catch {
|
|
282
|
+
dropped.push(file);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return { entries, dropped };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Persist the baseline.
|
|
290
|
+
*
|
|
291
|
+
* A write failure here is not cosmetic: the caller's whole contract is "record
|
|
292
|
+
* what we found so the next run can compare against it". Silently swallowing the
|
|
293
|
+
* error would leave the baseline stale while reporting success, so the failure
|
|
294
|
+
* is wrapped with the path and re-thrown -- callers already surface a thrown
|
|
295
|
+
* error as a non-zero exit.
|
|
296
|
+
*/
|
|
122
297
|
export async function saveBaseline(baselinePath: string, baseline: Record<string, BaselineEntry>): Promise<void> {
|
|
123
|
-
|
|
298
|
+
try {
|
|
299
|
+
await fs.writeFile(baselinePath, JSON.stringify(baseline, null, 2));
|
|
300
|
+
} catch (error) {
|
|
301
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
302
|
+
throw new Error(`Failed to write baseline to ${baselinePath}: ${reason}`);
|
|
303
|
+
}
|
|
124
304
|
}
|
|
125
305
|
|
|
126
306
|
function evaluateNewFile(currentWarnings: number): Pick<DeltaResult, 'enforcement' | 'reason'> {
|
|
@@ -155,7 +335,7 @@ function evaluateModifiedFile(
|
|
|
155
335
|
};
|
|
156
336
|
}
|
|
157
337
|
|
|
158
|
-
if (baselineWarnings <=
|
|
338
|
+
if (baselineWarnings <= SMALL_WARNING_BUDGET && currentWarnings > 0) {
|
|
159
339
|
return {
|
|
160
340
|
enforcement: 'BLOCK',
|
|
161
341
|
reason: `Files with <=5 warnings must clear to zero (currently: ${currentWarnings}/${baselineWarnings}). Boy Scout Rule: Leave the code cleaner than you found it.`,
|
|
@@ -174,6 +354,26 @@ export function calculateDelta(
|
|
|
174
354
|
status: 'NEW' | 'MODIFIED'
|
|
175
355
|
): DeltaResult {
|
|
176
356
|
const baselineWarnings = baselineEntry ? baselineEntry.totalWarnings : 0;
|
|
357
|
+
|
|
358
|
+
// A non-finite baseline can only come from a corrupt entry. `loadBaseline`
|
|
359
|
+
// rejects those, but this is the last line of defence: `NaN` compares false
|
|
360
|
+
// against everything, so without this check the file would PASS forever
|
|
361
|
+
// instead of failing loudly (#455).
|
|
362
|
+
if (!Number.isFinite(baselineWarnings)) {
|
|
363
|
+
return {
|
|
364
|
+
file: '',
|
|
365
|
+
status,
|
|
366
|
+
baselineWarnings,
|
|
367
|
+
currentWarnings,
|
|
368
|
+
// NaN, not null: the field is typed `number`, and NaN is the honest value
|
|
369
|
+
// for "cannot be computed". It serialises to `null` in JSON, which reads
|
|
370
|
+
// as unavailable rather than as a real 0 delta.
|
|
371
|
+
delta: Number.NaN,
|
|
372
|
+
enforcement: 'BLOCK',
|
|
373
|
+
reason: `Baseline for this file is corrupt (totalWarnings is ${String(baselineWarnings)}). Repair .warnings-baseline.json before committing.`,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
177
377
|
const delta = status === 'NEW' ? currentWarnings : currentWarnings - baselineWarnings;
|
|
178
378
|
|
|
179
379
|
const evaluation = status === 'NEW'
|
|
@@ -216,22 +416,45 @@ export async function initBaseline(files: string[]): Promise<Record<string, Base
|
|
|
216
416
|
const baseline: Record<string, BaselineEntry> = {};
|
|
217
417
|
|
|
218
418
|
for (const file of files) {
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
}
|
|
419
|
+
const entry = baselineEntryFor(currentWarnings[file]);
|
|
420
|
+
if (entry) baseline[file] = entry;
|
|
230
421
|
}
|
|
231
422
|
|
|
232
423
|
return baseline;
|
|
233
424
|
}
|
|
234
425
|
|
|
426
|
+
/**
|
|
427
|
+
* Build the baseline entry for one file, or null when there is nothing to record.
|
|
428
|
+
*
|
|
429
|
+
* Extracted from `initBaseline` so the loop body is a single call: the previous
|
|
430
|
+
* try/if nesting reached 5 levels, over the 4-level limit. Analysis failures are
|
|
431
|
+
* handled by `analyzeWarningsForFiles`, which omits files it cannot read, so a
|
|
432
|
+
* missing count here means "no warnings", not "the read blew up".
|
|
433
|
+
*/
|
|
434
|
+
function baselineEntryFor(warningCount: number | undefined): BaselineEntry | null {
|
|
435
|
+
const total = warningCount || 0;
|
|
436
|
+
// Only files that actually carry warnings are tracked; a clean file needs no budget.
|
|
437
|
+
if (total === 0) return null;
|
|
438
|
+
return { totalWarnings: total, lastAnalyzed: new Date().toISOString() };
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Record a first-seen count for files with no baseline entry yet.
|
|
443
|
+
*
|
|
444
|
+
* Extracted from `runEnforcement`, whose if/for/if nesting reached 5 levels.
|
|
445
|
+
* Files that are already clean gain no entry -- there is nothing to budget.
|
|
446
|
+
*/
|
|
447
|
+
function recordAutoInitializedEntries(
|
|
448
|
+
baseline: Record<string, BaselineEntry>,
|
|
449
|
+
files: string[],
|
|
450
|
+
currentWarnings: Record<string, number>,
|
|
451
|
+
): void {
|
|
452
|
+
for (const file of files) {
|
|
453
|
+
const entry = baselineEntryFor(currentWarnings[file]);
|
|
454
|
+
if (entry) baseline[file] = entry;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
235
458
|
/**
|
|
236
459
|
* Initializes baseline from current violations for the specified files
|
|
237
460
|
*/
|
|
@@ -258,15 +481,54 @@ async function autoInitBaseline(
|
|
|
258
481
|
|
|
259
482
|
async function runInitBaselineCommand(parsed: Record<string, unknown>): Promise<number> {
|
|
260
483
|
try {
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
484
|
+
// Honor --baseline; the old code hardcoded the default path (#445).
|
|
485
|
+
const baselinePath = (parsed.baselinePath as string) || '.warnings-baseline.json';
|
|
486
|
+
const files = collectBaselineTargets(parsed);
|
|
487
|
+
if (files.length === 0) {
|
|
488
|
+
// Writing an empty baseline while reporting success is how a caller ends up
|
|
489
|
+
// believing a file was recorded when nothing was. `--init-baseline` takes
|
|
490
|
+
// its targets as its own value; `--new-files`/`--modified-files` also work,
|
|
491
|
+
// and previously writing them was silently ignored as well.
|
|
492
|
+
console.error(
|
|
493
|
+
'No files to record. Pass them to --init-baseline <file1,file2,...>, ' +
|
|
494
|
+
'or via --new-files/--modified-files.',
|
|
495
|
+
);
|
|
496
|
+
return 1;
|
|
497
|
+
}
|
|
498
|
+
const result = await initBaselineCommand(files, baselinePath);
|
|
499
|
+
// Use the explicit result, never process.exitCode: global exit state can be
|
|
500
|
+
// set by unrelated code and would misreport this command's outcome.
|
|
501
|
+
return result.ok ? 0 : 1;
|
|
264
502
|
} catch (error: unknown) {
|
|
265
503
|
console.error('Error initializing baseline:', error);
|
|
266
504
|
return 1;
|
|
267
505
|
}
|
|
268
506
|
}
|
|
269
507
|
|
|
508
|
+
/**
|
|
509
|
+
* Merge every way a caller can name files for `--init-baseline`.
|
|
510
|
+
*
|
|
511
|
+
* `--init-baseline` carries its own list (`parsed.files`), while
|
|
512
|
+
* `--new-files`/`--modified-files` populate separate keys. Requiring exactly one
|
|
513
|
+
* spelling meant the other was read as an empty list, so the command wrote `{}`
|
|
514
|
+
* and exited 0 -- a silent no-op (#455 family). Union them instead, preserving
|
|
515
|
+
* order and dropping duplicates.
|
|
516
|
+
*/
|
|
517
|
+
function collectBaselineTargets(parsed: Record<string, unknown>): string[] {
|
|
518
|
+
const groups = [
|
|
519
|
+
parsed.files as string[] | undefined,
|
|
520
|
+
parsed.newFiles as string[] | undefined,
|
|
521
|
+
parsed.modifiedFiles as string[] | undefined,
|
|
522
|
+
];
|
|
523
|
+
const seen = new Set<string>();
|
|
524
|
+
for (const group of groups) {
|
|
525
|
+
for (const file of group ?? []) {
|
|
526
|
+
if (file) seen.add(file);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
return [...seen];
|
|
530
|
+
}
|
|
531
|
+
|
|
270
532
|
export async function runEnforcementCommand(parsed: Record<string, unknown>): Promise<number> {
|
|
271
533
|
try {
|
|
272
534
|
const enforcementResult = await runEnforcement(
|
|
@@ -284,24 +546,113 @@ export async function runEnforcementCommand(parsed: Record<string, unknown>): Pr
|
|
|
284
546
|
|
|
285
547
|
export async function main(): Promise<number> {
|
|
286
548
|
const parsed = parseArgs(process.argv.slice(2));
|
|
549
|
+
// Install the project's `.principlesrc` before any rule runs. Gate 6 shares
|
|
550
|
+
// the same rule singletons as Gate 4, so without this the two gates would
|
|
551
|
+
// disagree about a file's warning count whenever a threshold is overridden
|
|
552
|
+
// (#457).
|
|
553
|
+
setActiveConfig(await loadConfig(typeof parsed.configPath === 'string' ? parsed.configPath : undefined));
|
|
554
|
+
if (parsed.command === 'prune-baseline') {
|
|
555
|
+
return runPruneBaselineCommand(parsed);
|
|
556
|
+
}
|
|
287
557
|
return parsed.command === 'init-baseline'
|
|
288
558
|
? runInitBaselineCommand(parsed)
|
|
289
559
|
: runEnforcementCommand(parsed);
|
|
290
560
|
}
|
|
291
561
|
|
|
562
|
+
/**
|
|
563
|
+
* Report, and optionally remove, baseline entries whose files are gone (#452 REQ-3).
|
|
564
|
+
*
|
|
565
|
+
* Prints by default so a maintainer can review the list; `--apply` rewrites the
|
|
566
|
+
* file. Exits 0 either way -- stale entries are housekeeping, and failing a
|
|
567
|
+
* build over them would tempt people to disable the gate instead.
|
|
568
|
+
*/
|
|
569
|
+
async function runPruneBaselineCommand(parsed: Record<string, unknown>): Promise<number> {
|
|
570
|
+
const baselinePath = typeof parsed.baselinePath === 'string'
|
|
571
|
+
? parsed.baselinePath
|
|
572
|
+
: '.warnings-baseline.json';
|
|
573
|
+
const projectRoot = process.cwd();
|
|
574
|
+
|
|
575
|
+
let raw: string;
|
|
576
|
+
try {
|
|
577
|
+
raw = await fs.readFile(baselinePath, 'utf-8');
|
|
578
|
+
} catch {
|
|
579
|
+
console.log(`ℹ️ No baseline at ${baselinePath} — nothing to prune.`);
|
|
580
|
+
return 0;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const result = pruneBaselineEntries(raw, projectRoot);
|
|
584
|
+
if (result.error) {
|
|
585
|
+
// Preserve the file: an entry we cannot classify must not be dropped.
|
|
586
|
+
console.error(`⚠️ ${result.error} — baseline left untouched.`);
|
|
587
|
+
return 1;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
if (result.removed.length === 0) {
|
|
591
|
+
console.log(`✅ No stale baseline entries (${result.kept.length} entries checked).`);
|
|
592
|
+
return 0;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
console.log(`Found ${result.removed.length} stale baseline entr(ies):`);
|
|
596
|
+
for (const file of result.removed) console.log(` - ${file}`);
|
|
597
|
+
|
|
598
|
+
if (parsed.apply !== true) {
|
|
599
|
+
console.log('Dry run — re-run with --apply to remove them.');
|
|
600
|
+
return 0;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const parsedBaseline = JSON.parse(raw) as Record<string, unknown>;
|
|
604
|
+
const removedSet = new Set(result.removed);
|
|
605
|
+
for (const file of result.removed) delete parsedBaseline[file];
|
|
606
|
+
try {
|
|
607
|
+
await fs.writeFile(baselinePath, `${JSON.stringify(parsedBaseline, null, 2)}\n`, 'utf-8');
|
|
608
|
+
} catch (error) {
|
|
609
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
610
|
+
console.error(`❌ Could not write ${baselinePath}: ${detail}`);
|
|
611
|
+
return 1;
|
|
612
|
+
}
|
|
613
|
+
console.log(`✅ Removed ${removedSet.size} stale entr(ies) from ${baselinePath}.`);
|
|
614
|
+
return 0;
|
|
615
|
+
}
|
|
616
|
+
|
|
292
617
|
function splitCsvArg(raw: string | undefined): string[] {
|
|
293
618
|
return raw?.split(',').map((s: string) => s.trim()).filter(Boolean) || [];
|
|
294
619
|
}
|
|
295
620
|
|
|
621
|
+
/**
|
|
622
|
+
* Read a flag's value, refusing to swallow the next flag.
|
|
623
|
+
*
|
|
624
|
+
* A bare `next` is taken as the value only when it does not itself look like a
|
|
625
|
+
* flag; otherwise the flag is treated as having no value and the following token
|
|
626
|
+
* is parsed normally. Without this, `--baseline --new-files x` recorded the
|
|
627
|
+
* literal string "--new-files" as a path (#455).
|
|
628
|
+
*/
|
|
629
|
+
function takeValue(next: string | undefined): { value: string | undefined; consumed: boolean } {
|
|
630
|
+
const isFlag = typeof next === 'string' && next.startsWith('--');
|
|
631
|
+
return isFlag || next === undefined ? { value: undefined, consumed: false } : { value: next, consumed: true };
|
|
632
|
+
}
|
|
633
|
+
|
|
296
634
|
const ARG_HANDLERS: Record<string, (parsed: Record<string, unknown>, next: string | undefined) => boolean> = {
|
|
297
|
-
'--new-files': (parsed, next) => { parsed.newFiles = splitCsvArg(
|
|
298
|
-
'--modified-files': (parsed, next) => { parsed.modifiedFiles = splitCsvArg(
|
|
299
|
-
'--baseline': (parsed, next) => { parsed.baselinePath =
|
|
635
|
+
'--new-files': (parsed, next) => { const v = takeValue(next); parsed.newFiles = splitCsvArg(v.value); return v.consumed; },
|
|
636
|
+
'--modified-files': (parsed, next) => { const v = takeValue(next); parsed.modifiedFiles = splitCsvArg(v.value); return v.consumed; },
|
|
637
|
+
'--baseline': (parsed, next) => { const v = takeValue(next); parsed.baselinePath = v.value; return v.consumed; },
|
|
638
|
+
// Lets Gate 6 be pointed at a specific `.principlesrc`, so it agrees with
|
|
639
|
+
// Gate 4 when the project overrides a threshold (#457).
|
|
640
|
+
'--config': (parsed, next) => { const v = takeValue(next); parsed.configPath = v.value; return v.consumed; },
|
|
300
641
|
'--init-baseline': (parsed, next) => {
|
|
301
642
|
parsed.command = 'init-baseline';
|
|
302
|
-
|
|
303
|
-
|
|
643
|
+
// The file list is OPTIONAL and positional: `--init-baseline a.ts,b.ts` works,
|
|
644
|
+
// and so does `--init-baseline --new-files a.ts`. Consuming the next token
|
|
645
|
+
// unconditionally swallowed the following FLAG, which silently produced an
|
|
646
|
+
// empty baseline that the command then reported as success (#455).
|
|
647
|
+
const v = takeValue(next);
|
|
648
|
+
parsed.files = splitCsvArg(v.value);
|
|
649
|
+
return v.consumed;
|
|
304
650
|
},
|
|
651
|
+
// Report (and with --apply, remove) baseline entries for files that are gone
|
|
652
|
+
// (#452 REQ-3). Dry-run by default: deleting history is not something a gate
|
|
653
|
+
// should do as a side effect of a normal run.
|
|
654
|
+
'--prune-baseline': parsed => { parsed.command = 'prune-baseline'; return false; },
|
|
655
|
+
'--apply': parsed => { parsed.apply = true; return false; },
|
|
305
656
|
};
|
|
306
657
|
|
|
307
658
|
function parseArgs(args: string[]): Record<string, unknown> {
|
|
@@ -310,6 +661,7 @@ function parseArgs(args: string[]): Record<string, unknown> {
|
|
|
310
661
|
newFiles: [],
|
|
311
662
|
modifiedFiles: [],
|
|
312
663
|
baselinePath: null,
|
|
664
|
+
configPath: null,
|
|
313
665
|
};
|
|
314
666
|
|
|
315
667
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -335,26 +687,142 @@ Options:
|
|
|
335
687
|
--modified-files <file1,file2,...> Specify modified files to analyze
|
|
336
688
|
--baseline <path> Path to baseline file (default: .warnings-baseline.json)
|
|
337
689
|
--init-baseline [file1,file2,...] Initialize baseline with current warning counts
|
|
690
|
+
--prune-baseline Report baseline entries whose files are gone
|
|
691
|
+
--apply With --prune-baseline: actually remove them
|
|
692
|
+
--config <path> Path to .principlesrc (default: git toplevel)
|
|
338
693
|
--help Show this help message
|
|
339
694
|
|
|
340
695
|
Examples:
|
|
341
696
|
npx tsx boy-scout.ts --new-files src/new-file.ts
|
|
342
697
|
npx tsx boy-scout.ts --modified-files src/changed-file.ts --baseline my-baseline.json
|
|
343
698
|
npx tsx boy-scout.ts --init-baseline src/file1.ts,src/file2.ts
|
|
699
|
+
npx tsx boy-scout.ts --prune-baseline --apply
|
|
344
700
|
`);
|
|
345
701
|
}
|
|
346
702
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
703
|
+
/**
|
|
704
|
+
* Initialize/extend the warning baseline for the given files.
|
|
705
|
+
*
|
|
706
|
+
* MERGE semantics: entries already present in `baselinePath` that are not part of
|
|
707
|
+
* `files` are preserved. The previous implementation wrote a freshly built object
|
|
708
|
+
* containing only `files`, which silently destroyed every unrelated entry (#445),
|
|
709
|
+
* and it hardcoded `.warnings-baseline.json`, ignoring `--baseline`.
|
|
710
|
+
*/
|
|
711
|
+
async function initBaselineCommand(
|
|
712
|
+
files: string[],
|
|
713
|
+
baselinePath = '.warnings-baseline.json',
|
|
714
|
+
): Promise<{ ok: boolean; reason?: string }> {
|
|
715
|
+
const { entries: existing, dropped, readError } = await loadBaselineForRepair(baselinePath);
|
|
716
|
+
|
|
717
|
+
// Refuse to write when we could not determine what the file holds. Saving now
|
|
718
|
+
// would replace unknown content with just our own entries.
|
|
719
|
+
if (readError) {
|
|
720
|
+
return { ok: false, reason: readError };
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const analyzed = await initBaseline(files);
|
|
724
|
+
|
|
725
|
+
// Preserve anything already tracked; only add/refresh entries for `files`.
|
|
726
|
+
// `merged` starts as a copy of `existing`, so a non-empty baseline can never
|
|
727
|
+
// become empty here -- the destructive overwrite fixed in #445 is structurally
|
|
728
|
+
// impossible rather than merely guarded.
|
|
729
|
+
const merged: Record<string, BaselineEntry> = { ...existing };
|
|
730
|
+
const added: string[] = [];
|
|
731
|
+
const refreshed: string[] = [];
|
|
732
|
+
for (const [file, entry] of Object.entries(analyzed)) {
|
|
733
|
+
if (existing[file]) {
|
|
734
|
+
refreshed.push(file);
|
|
735
|
+
} else {
|
|
736
|
+
added.push(file);
|
|
737
|
+
}
|
|
738
|
+
merged[file] = entry;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
reportDroppedEntries(dropped, analyzed);
|
|
742
|
+
await saveBaseline(baselinePath, merged);
|
|
743
|
+
reportBaselineUpdate(baselinePath, added, refreshed, Object.keys(existing).filter(k => !(k in analyzed)));
|
|
744
|
+
|
|
745
|
+
// Non-zero exit when repair cost us an entry that could not be rebuilt. The
|
|
746
|
+
// write still happened (the rest of the file is valid), but a caller scripting
|
|
747
|
+
// this must not see success for a run that permanently lost a budget. We cannot
|
|
748
|
+
// invent a warning count we never measured, and a wrong number would silently
|
|
749
|
+
// re-arm the gate -- the very bug being fixed.
|
|
750
|
+
const stillDropped = dropped.filter(file => !(file in analyzed));
|
|
751
|
+
if (stillDropped.length > 0) {
|
|
752
|
+
return {
|
|
753
|
+
ok: false,
|
|
754
|
+
reason: `Dropped ${stillDropped.length} unrecoverable baseline ${stillDropped.length === 1 ? 'entry' : 'entries'}: ${stillDropped.join(', ')}. Re-run with those paths to rebuild them.`,
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
return { ok: true };
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* Report malformed entries that had to be discarded, distinguishing the ones the
|
|
762
|
+
* current run measured afresh from the ones that are now permanently gone.
|
|
763
|
+
*/
|
|
764
|
+
function reportDroppedEntries(dropped: string[], analyzed: Record<string, BaselineEntry>): void {
|
|
765
|
+
if (dropped.length === 0) return;
|
|
766
|
+
const noun = dropped.length === 1 ? 'entry' : 'entries';
|
|
767
|
+
console.log(`⚠️ Dropped ${dropped.length} malformed baseline ${noun}:`);
|
|
768
|
+
for (const file of dropped) {
|
|
769
|
+
const fate = file in analyzed ? '(recreated from the current tree)' : '(lost -- not in the file list)';
|
|
770
|
+
console.log(` - ${file} ${fate}`);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/** Print the add/refresh/preserve summary for a completed baseline write. */
|
|
775
|
+
function reportBaselineUpdate(
|
|
776
|
+
baselinePath: string,
|
|
777
|
+
added: string[],
|
|
778
|
+
refreshed: string[],
|
|
779
|
+
preserved: string[],
|
|
780
|
+
): void {
|
|
781
|
+
const list = (items: string[]) => (items.length > 0 ? items.join(', ') : '(none)');
|
|
782
|
+
console.log(`ℹ️ Baseline updated: ${baselinePath}`);
|
|
783
|
+
console.log(` added: ${list(added)}`);
|
|
784
|
+
console.log(` refreshed:${refreshed.length > 0 ? ` ${list(refreshed)}` : ' (none)'}`);
|
|
785
|
+
console.log(` preserved:${preserved.length > 0 ? ` ${list(preserved)}` : ' (none)'}`);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/**
|
|
789
|
+
* Record budget entries for files the gate meets for the first time, then save
|
|
790
|
+
* and report the write. Split out of `runEnforcement` to keep both under the
|
|
791
|
+
* long-function threshold; also carries the #452 REQ-2 visibility contract:
|
|
792
|
+
* the report distinguishes "created" from "updated" and names every entry the
|
|
793
|
+
* auto-init introduced, never just a count.
|
|
794
|
+
*/
|
|
795
|
+
async function autoInitMissingEntries(
|
|
796
|
+
baseline: Record<string, BaselineEntry>,
|
|
797
|
+
missingBaselineEntries: string[],
|
|
798
|
+
currentWarnings: Record<string, number>,
|
|
799
|
+
baselinePath: string,
|
|
800
|
+
baselineExisted: boolean,
|
|
801
|
+
): Promise<void> {
|
|
802
|
+
recordAutoInitializedEntries(baseline, missingBaselineEntries, currentWarnings);
|
|
803
|
+
await saveBaseline(baselinePath, baseline);
|
|
804
|
+
const action = baselineExisted ? 'updated' : 'created';
|
|
805
|
+
const noun = missingBaselineEntries.length === 1 ? 'entry' : 'entries';
|
|
806
|
+
console.log(`ℹ️ Baseline ${action}: ${baselinePath}`);
|
|
807
|
+
console.log(` auto-initialized ${noun}: ${missingBaselineEntries.join(', ')}`);
|
|
350
808
|
}
|
|
351
809
|
|
|
352
810
|
async function runEnforcement(newFiles: string[], modifiedFiles: string[], baselinePath: string): Promise<EnforcementResult> {
|
|
353
811
|
const allFiles = [...newFiles.filter(f => f.trim()), ...modifiedFiles.filter(f => f.trim())];
|
|
354
812
|
const currentWarnings = await analyzeWarningsForFiles(allFiles);
|
|
355
|
-
|
|
813
|
+
|
|
814
|
+
// Distinguishing "created" from "updated" is part of the auto-init contract
|
|
815
|
+
// (#452 REQ-2): a silently rewritten baseline is how unrelated entries used
|
|
816
|
+
// to vanish without anyone noticing.
|
|
817
|
+
let baselineExisted = false;
|
|
818
|
+
try {
|
|
819
|
+
await fs.access(baselinePath);
|
|
820
|
+
baselineExisted = true;
|
|
821
|
+
} catch {
|
|
822
|
+
baselineExisted = false;
|
|
823
|
+
}
|
|
356
824
|
const baseline = await loadBaseline(baselinePath);
|
|
357
|
-
|
|
825
|
+
|
|
358
826
|
// Check for missing baseline entries for modified files
|
|
359
827
|
const missingBaselineEntries: string[] = [];
|
|
360
828
|
for (const file of modifiedFiles) {
|
|
@@ -362,21 +830,11 @@ async function runEnforcement(newFiles: string[], modifiedFiles: string[], basel
|
|
|
362
830
|
missingBaselineEntries.push(file);
|
|
363
831
|
}
|
|
364
832
|
}
|
|
365
|
-
|
|
366
|
-
//
|
|
833
|
+
|
|
834
|
+
// Files being modified for the first time get their current count recorded, so
|
|
835
|
+
// the next modification is compared against a real budget instead of nothing.
|
|
367
836
|
if (missingBaselineEntries.length > 0) {
|
|
368
|
-
|
|
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`);
|
|
837
|
+
await autoInitMissingEntries(baseline, missingBaselineEntries, currentWarnings, baselinePath, baselineExisted);
|
|
380
838
|
}
|
|
381
839
|
|
|
382
840
|
const deltaResults: DeltaResult[] = [];
|
|
@@ -399,8 +857,10 @@ async function runEnforcement(newFiles: string[], modifiedFiles: string[], basel
|
|
|
399
857
|
return enforceBoyScoutRule(deltaResults);
|
|
400
858
|
}
|
|
401
859
|
|
|
402
|
-
|
|
403
|
-
|
|
860
|
+
// Fires only when this file IS the entry point. The previous check --
|
|
861
|
+
// `process.argv[1]?.includes('boy-scout')` -- both over- and under-matched, and
|
|
862
|
+
// silently no-oped under CI where argv[1] does not repeat the filename (#453).
|
|
863
|
+
if (isDirectExecution(process.argv[1], import.meta.url)) {
|
|
404
864
|
main()
|
|
405
865
|
.then(code => process.exit(code))
|
|
406
866
|
.catch(error => {
|
|
@@ -413,4 +873,4 @@ export {
|
|
|
413
873
|
initBaselineCommand,
|
|
414
874
|
runEnforcement,
|
|
415
875
|
autoInitBaseline
|
|
416
|
-
};
|
|
876
|
+
};
|