@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.
Files changed (177) hide show
  1. package/adapters/gate-4.sh +83 -18
  2. package/adapters/gate-8.sh +115 -29
  3. package/adapters/iac.sh +1 -1
  4. package/adapters/powershell.sh +60 -5
  5. package/adapters/python.sh +38 -4
  6. package/adapters/typescript.sh +15 -60
  7. package/bin/xp-gate.js +15 -16
  8. package/build-integrity/__tests__/import-resolver.test.ts +59 -0
  9. package/build-integrity/gate-10.ts +17 -2
  10. package/gate-10.sh +35 -2
  11. package/gate-4.sh +110 -21
  12. package/gate-8.sh +115 -29
  13. package/hooks/gate-10.sh +35 -2
  14. package/hooks/gate-4.sh +110 -21
  15. package/hooks/gate-8.sh +115 -29
  16. package/hooks/lib/jscpd-run.sh +63 -0
  17. package/hooks/lib/sprint-gate-report.sh +58 -0
  18. package/hooks/lib/test-failure.sh +152 -0
  19. package/hooks/lib/typecheck.sh +141 -0
  20. package/hooks/lib/validate-code-walkthrough.cjs +43 -5
  21. package/hooks/post-merge +100 -0
  22. package/hooks/pre-commit +514 -184
  23. package/hooks/pre-push +75 -19
  24. package/lib/__tests__/bootstrap.test.js +23 -41
  25. package/lib/__tests__/check-version.test.js +8 -1
  26. package/lib/__tests__/detect-deps.test.js +16 -0
  27. package/lib/__tests__/doctor-drift-round2.test.js +212 -0
  28. package/lib/__tests__/doctor-executed-surface.test.js +209 -0
  29. package/lib/__tests__/doctor-hook-drift.test.js +329 -0
  30. package/lib/__tests__/doctor-machine-mutation.test.js +116 -0
  31. package/lib/__tests__/doctor-module-drift.test.js +206 -0
  32. package/lib/__tests__/doctor-module-resolution-parity.test.js +314 -0
  33. package/lib/__tests__/doctor-path-equivalence.test.js +59 -0
  34. package/lib/__tests__/doctor-repair-boundary.test.js +52 -0
  35. package/lib/__tests__/doctor-tui-skill-freshness.test.js +105 -0
  36. package/lib/__tests__/doctor.test.js +155 -0
  37. package/lib/__tests__/gate-runner.test.js +19 -3
  38. package/lib/__tests__/helpers/lib-sandbox.cjs +55 -0
  39. package/lib/__tests__/init.test.js +44 -17
  40. package/lib/__tests__/install-skill-full-dir.test.js +151 -0
  41. package/lib/__tests__/install-skill.test.js +51 -132
  42. package/lib/__tests__/language-tools-detect.test.js +158 -0
  43. package/lib/__tests__/phase-transition.test.js +15 -1
  44. package/lib/__tests__/qoder-delphi-agents.test.js +285 -0
  45. package/lib/__tests__/shared-paths.test.js +20 -0
  46. package/lib/__tests__/shared-utils.test.js +69 -0
  47. package/lib/__tests__/skill-flags.test.js +48 -0
  48. package/lib/__tests__/ui-detector.test.ts +6 -2
  49. package/lib/__tests__/uninstall.test.js +20 -0
  50. package/lib/__tests__/update-hooks.test.js +12 -0
  51. package/lib/__tests__/update-skill.test.js +7 -4
  52. package/lib/detect-deps.js +3 -20
  53. package/lib/doctor-tui.js +22 -5
  54. package/lib/doctor.js +1178 -31
  55. package/lib/init.js +184 -33
  56. package/lib/install-cmd.js +7 -4
  57. package/lib/install-skill.js +38 -95
  58. package/lib/language-tools.js +83 -4
  59. package/lib/mattpocock-license-verdict.json +1 -1
  60. package/lib/shared-paths.js +5 -2
  61. package/lib/shared-utils.js +47 -3
  62. package/lib/uninstall.js +12 -0
  63. package/lib/update-hooks.js +4 -4
  64. package/lib/update-skill.js +6 -7
  65. package/mock-policy/AGENTS.md +4 -4
  66. package/mutation/AGENTS.md +4 -4
  67. package/mutation/__tests__/gate-m.test.ts +66 -14
  68. package/mutation/__tests__/mutmut-runner.test.ts +91 -42
  69. package/mutation/__tests__/test-filter-parity.test.ts +37 -0
  70. package/mutation/gate-m.ts +21 -8
  71. package/package.json +2 -2
  72. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  73. package/plugins/claude-code/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
  74. package/plugins/claude-code/skills/delphi-review/AGENTS.md +4 -4
  75. package/plugins/claude-code/skills/delphi-review/SKILL.md +98 -14
  76. package/plugins/claude-code/skills/delphi-review/references/code-walkthrough.md +36 -3
  77. package/plugins/claude-code/skills/delphi-review/references/requirements.md +6 -6
  78. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +4 -4
  79. package/plugins/claude-code/skills/sprint-flow/references/phase-2-design.md +3 -3
  80. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +4 -4
  81. package/plugins/dsh/README.md +169 -4
  82. package/plugins/dsh/dsh-overlay.patch.yml +67 -0
  83. package/plugins/dsh/package.json +1 -1
  84. package/plugins/dsh/scripts/prepack.cjs +24 -19
  85. package/plugins/dsh/setup-dsh.ps1 +160 -0
  86. package/plugins/dsh/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
  87. package/plugins/dsh/skills/delphi-review/AGENTS.md +4 -4
  88. package/plugins/dsh/skills/delphi-review/SKILL.md +98 -14
  89. package/plugins/dsh/skills/delphi-review/references/code-walkthrough.md +36 -3
  90. package/plugins/dsh/skills/delphi-review/references/requirements.md +6 -6
  91. package/plugins/dsh/skills/sprint-flow/AGENTS.md +4 -4
  92. package/plugins/dsh/skills/sprint-flow/references/phase-2-design.md +3 -3
  93. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +4 -4
  94. package/plugins/dsh/src/command.test.ts +66 -2
  95. package/plugins/dsh/src/command.ts +36 -14
  96. package/plugins/dsh/src/contract.test.ts +31 -1
  97. package/plugins/dsh/src/delphi-config.test.ts +190 -0
  98. package/plugins/dsh/src/delphi-config.ts +223 -0
  99. package/plugins/dsh/src/delphi-run.test.ts +181 -0
  100. package/plugins/dsh/src/delphi-run.ts +129 -0
  101. package/plugins/dsh/src/delphi.test.ts +222 -0
  102. package/plugins/dsh/src/delphi.ts +247 -0
  103. package/plugins/dsh/src/dialect.test.ts +59 -0
  104. package/plugins/dsh/src/dialect.ts +40 -0
  105. package/plugins/dsh/src/index.ts +70 -4
  106. package/plugins/dsh/src/skills.test.ts +26 -19
  107. package/plugins/opencode/package.json +1 -1
  108. package/plugins/opencode/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
  109. package/plugins/opencode/skills/delphi-review/AGENTS.md +4 -4
  110. package/plugins/opencode/skills/delphi-review/SKILL.md +98 -14
  111. package/plugins/opencode/skills/delphi-review/references/code-walkthrough.md +36 -3
  112. package/plugins/opencode/skills/delphi-review/references/requirements.md +6 -6
  113. package/plugins/opencode/skills/sprint-flow/AGENTS.md +4 -4
  114. package/plugins/opencode/skills/sprint-flow/references/phase-2-design.md +3 -3
  115. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +4 -4
  116. package/plugins/qoder/agents/delphi-architecture.md +1 -1
  117. package/plugins/qoder/agents/delphi-feasibility.md +1 -1
  118. package/plugins/qoder/agents/delphi-technical.md +1 -1
  119. package/plugins/qoder/plugin.json +1 -1
  120. package/plugins/qoder/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
  121. package/plugins/qoder/skills/delphi-review/AGENTS.md +4 -4
  122. package/plugins/qoder/skills/delphi-review/SKILL.md +98 -14
  123. package/plugins/qoder/skills/delphi-review/references/code-walkthrough.md +36 -3
  124. package/plugins/qoder/skills/delphi-review/references/requirements.md +6 -6
  125. package/plugins/qoder/skills/sprint-flow/AGENTS.md +4 -4
  126. package/plugins/qoder/skills/sprint-flow/references/phase-2-design.md +3 -3
  127. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +4 -4
  128. package/principles/AGENTS.md +9 -8
  129. package/principles/__tests__/adapter-metrics.test.ts +292 -0
  130. package/principles/__tests__/analyzer.test.ts +29 -6
  131. package/principles/__tests__/baseline-prune-timeout.test.ts +71 -0
  132. package/principles/__tests__/baseline-prune.test.ts +338 -0
  133. package/principles/__tests__/boy-scout-init-baseline.test.ts +167 -0
  134. package/principles/__tests__/boy-scout-integration.test.ts +97 -6
  135. package/principles/__tests__/boy-scout.test.ts +253 -7
  136. package/principles/__tests__/config-enforcement.test.ts +469 -0
  137. package/principles/__tests__/config-git-timeout.test.ts +66 -0
  138. package/principles/__tests__/direct-execution.test.ts +187 -0
  139. package/principles/__tests__/index.test.ts +16 -3
  140. package/principles/__tests__/warnings-baseline-budgets.test.ts +74 -0
  141. package/principles/adapters/typescript.ts +613 -13
  142. package/principles/analyzer.ts +22 -0
  143. package/principles/baseline-prune.ts +217 -0
  144. package/principles/boy-scout.ts +516 -56
  145. package/principles/config.ts +362 -54
  146. package/principles/direct-execution.ts +60 -0
  147. package/principles/index.ts +40 -17
  148. package/principles/rules/__tests__/clean-code/many-exports.test.ts +16 -10
  149. package/principles/rules/clean-code/code-duplication.ts +8 -8
  150. package/principles/rules/clean-code/deep-nesting.ts +8 -8
  151. package/principles/rules/clean-code/god-class.ts +8 -8
  152. package/principles/rules/clean-code/large-file.ts +15 -11
  153. package/principles/rules/clean-code/long-function.ts +8 -8
  154. package/principles/rules/clean-code/magic-numbers.ts +10 -7
  155. package/principles/rules/clean-code/many-exports.ts +7 -7
  156. package/principles/rules/clean-code/missing-error-handling.ts +5 -5
  157. package/principles/rules/clean-code/too-many-params.ts +8 -8
  158. package/principles/rules/clean-code/unused-imports.ts +5 -5
  159. package/principles/rules/solid/dip.ts +18 -8
  160. package/principles/rules/solid/isp.ts +9 -8
  161. package/principles/rules/solid/lsp.ts +6 -6
  162. package/principles/rules/solid/ocp.ts +6 -5
  163. package/principles/rules/solid/srp.ts +7 -8
  164. package/principles/types.ts +15 -0
  165. package/scripts/__tests__/sync-package-content.test.js +28 -0
  166. package/scripts/delphi-external-review.cjs +193 -10
  167. package/scripts/sync-package-content.js +20 -10
  168. package/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
  169. package/skills/delphi-review/AGENTS.md +4 -4
  170. package/skills/delphi-review/SKILL.md +98 -14
  171. package/skills/delphi-review/references/code-walkthrough.md +36 -3
  172. package/skills/delphi-review/references/requirements.md +6 -6
  173. package/skills/sprint-flow/AGENTS.md +4 -4
  174. package/skills/sprint-flow/references/phase-2-design.md +3 -3
  175. package/skills/test-specification-alignment/AGENTS.md +4 -4
  176. package/lib/__tests__/download-skill.test.js +0 -281
  177. package/lib/download-skill.js +0 -96
@@ -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.access(baselinePath);
115
- const baselineContent = await fs.readFile(baselinePath, 'utf-8');
116
- return JSON.parse(baselineContent);
117
- } catch {
118
- return {};
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
- await fs.writeFile(baselinePath, JSON.stringify(baseline, null, 2));
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 <= 5 && currentWarnings > 0) {
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
- 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
- }
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
- await initBaselineCommand((parsed.files ?? []) as string[]);
262
- console.log('Baseline initialized successfully');
263
- return 0;
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(next); return true; },
298
- '--modified-files': (parsed, next) => { parsed.modifiedFiles = splitCsvArg(next); return true; },
299
- '--baseline': (parsed, next) => { parsed.baselinePath = next; return true; },
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
- parsed.files = splitCsvArg(next);
303
- return true;
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
- async function initBaselineCommand(files: string[]): Promise<void> {
348
- const baseline = await initBaseline(files);
349
- await saveBaseline('.warnings-baseline.json', baseline);
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
- // If there are missing baseline entries, auto-initialize them
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
- 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`);
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
- if ((typeof require !== 'undefined' && require.main === module) ||
403
- (typeof require === 'undefined' && process.argv[1]?.includes('boy-scout'))) {
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
+ };