@boyingliu01/xp-gate 0.19.3 → 0.20.0

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 (173) hide show
  1. package/adapters/gate-4.sh +83 -18
  2. package/adapters/gate-8.sh +49 -5
  3. package/adapters/iac.sh +1 -1
  4. package/adapters/typescript.sh +15 -60
  5. package/bin/xp-gate.js +15 -16
  6. package/gate-10.sh +35 -2
  7. package/gate-4.sh +110 -21
  8. package/gate-8.sh +49 -5
  9. package/hooks/gate-10.sh +35 -2
  10. package/hooks/gate-4.sh +110 -21
  11. package/hooks/gate-8.sh +49 -5
  12. package/hooks/lib/jscpd-run.sh +63 -0
  13. package/hooks/lib/sprint-gate-report.sh +58 -0
  14. package/hooks/lib/test-failure.sh +152 -0
  15. package/hooks/lib/typecheck.sh +141 -0
  16. package/hooks/lib/validate-code-walkthrough.cjs +43 -5
  17. package/hooks/post-merge +100 -0
  18. package/hooks/pre-commit +389 -162
  19. package/hooks/pre-push +75 -19
  20. package/lib/__tests__/bootstrap.test.js +23 -41
  21. package/lib/__tests__/check-version.test.js +8 -1
  22. package/lib/__tests__/detect-deps.test.js +16 -0
  23. package/lib/__tests__/doctor-drift-round2.test.js +212 -0
  24. package/lib/__tests__/doctor-executed-surface.test.js +209 -0
  25. package/lib/__tests__/doctor-hook-drift.test.js +329 -0
  26. package/lib/__tests__/doctor-machine-mutation.test.js +116 -0
  27. package/lib/__tests__/doctor-module-drift.test.js +206 -0
  28. package/lib/__tests__/doctor-module-resolution-parity.test.js +314 -0
  29. package/lib/__tests__/doctor-path-equivalence.test.js +59 -0
  30. package/lib/__tests__/doctor-repair-boundary.test.js +52 -0
  31. package/lib/__tests__/doctor-tui-skill-freshness.test.js +105 -0
  32. package/lib/__tests__/doctor.test.js +155 -0
  33. package/lib/__tests__/gate-runner.test.js +19 -3
  34. package/lib/__tests__/helpers/lib-sandbox.cjs +55 -0
  35. package/lib/__tests__/init.test.js +44 -17
  36. package/lib/__tests__/install-skill-full-dir.test.js +151 -0
  37. package/lib/__tests__/install-skill.test.js +51 -132
  38. package/lib/__tests__/language-tools-detect.test.js +158 -0
  39. package/lib/__tests__/phase-transition.test.js +15 -1
  40. package/lib/__tests__/qoder-delphi-agents.test.js +285 -0
  41. package/lib/__tests__/shared-paths.test.js +20 -0
  42. package/lib/__tests__/shared-utils.test.js +69 -0
  43. package/lib/__tests__/skill-flags.test.js +48 -0
  44. package/lib/__tests__/ui-detector.test.ts +6 -2
  45. package/lib/__tests__/uninstall.test.js +20 -0
  46. package/lib/__tests__/update-hooks.test.js +12 -0
  47. package/lib/__tests__/update-skill.test.js +7 -4
  48. package/lib/detect-deps.js +3 -20
  49. package/lib/doctor-tui.js +22 -5
  50. package/lib/doctor.js +1178 -31
  51. package/lib/init.js +184 -33
  52. package/lib/install-cmd.js +7 -4
  53. package/lib/install-skill.js +38 -95
  54. package/lib/language-tools.js +83 -4
  55. package/lib/mattpocock-license-verdict.json +1 -1
  56. package/lib/shared-paths.js +5 -2
  57. package/lib/shared-utils.js +47 -3
  58. package/lib/uninstall.js +12 -0
  59. package/lib/update-hooks.js +4 -4
  60. package/lib/update-skill.js +6 -7
  61. package/mock-policy/AGENTS.md +4 -4
  62. package/mutation/AGENTS.md +4 -4
  63. package/mutation/__tests__/gate-m.test.ts +66 -14
  64. package/mutation/__tests__/mutmut-runner.test.ts +91 -42
  65. package/mutation/__tests__/test-filter-parity.test.ts +37 -0
  66. package/mutation/gate-m.ts +21 -8
  67. package/package.json +1 -1
  68. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  69. package/plugins/claude-code/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
  70. package/plugins/claude-code/skills/delphi-review/AGENTS.md +4 -4
  71. package/plugins/claude-code/skills/delphi-review/SKILL.md +98 -14
  72. package/plugins/claude-code/skills/delphi-review/references/code-walkthrough.md +36 -3
  73. package/plugins/claude-code/skills/delphi-review/references/requirements.md +6 -6
  74. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +4 -4
  75. package/plugins/claude-code/skills/sprint-flow/references/phase-2-design.md +3 -3
  76. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +4 -4
  77. package/plugins/dsh/README.md +169 -4
  78. package/plugins/dsh/dsh-overlay.patch.yml +67 -0
  79. package/plugins/dsh/package.json +1 -1
  80. package/plugins/dsh/scripts/prepack.cjs +24 -19
  81. package/plugins/dsh/setup-dsh.ps1 +160 -0
  82. package/plugins/dsh/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
  83. package/plugins/dsh/skills/delphi-review/AGENTS.md +4 -4
  84. package/plugins/dsh/skills/delphi-review/SKILL.md +98 -14
  85. package/plugins/dsh/skills/delphi-review/references/code-walkthrough.md +36 -3
  86. package/plugins/dsh/skills/delphi-review/references/requirements.md +6 -6
  87. package/plugins/dsh/skills/sprint-flow/AGENTS.md +4 -4
  88. package/plugins/dsh/skills/sprint-flow/references/phase-2-design.md +3 -3
  89. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +4 -4
  90. package/plugins/dsh/src/command.test.ts +66 -2
  91. package/plugins/dsh/src/command.ts +36 -14
  92. package/plugins/dsh/src/contract.test.ts +31 -1
  93. package/plugins/dsh/src/delphi-config.test.ts +190 -0
  94. package/plugins/dsh/src/delphi-config.ts +223 -0
  95. package/plugins/dsh/src/delphi-run.test.ts +181 -0
  96. package/plugins/dsh/src/delphi-run.ts +129 -0
  97. package/plugins/dsh/src/delphi.test.ts +222 -0
  98. package/plugins/dsh/src/delphi.ts +247 -0
  99. package/plugins/dsh/src/dialect.test.ts +59 -0
  100. package/plugins/dsh/src/dialect.ts +40 -0
  101. package/plugins/dsh/src/index.ts +70 -4
  102. package/plugins/dsh/src/skills.test.ts +26 -19
  103. package/plugins/opencode/package.json +1 -1
  104. package/plugins/opencode/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
  105. package/plugins/opencode/skills/delphi-review/AGENTS.md +4 -4
  106. package/plugins/opencode/skills/delphi-review/SKILL.md +98 -14
  107. package/plugins/opencode/skills/delphi-review/references/code-walkthrough.md +36 -3
  108. package/plugins/opencode/skills/delphi-review/references/requirements.md +6 -6
  109. package/plugins/opencode/skills/sprint-flow/AGENTS.md +4 -4
  110. package/plugins/opencode/skills/sprint-flow/references/phase-2-design.md +3 -3
  111. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +4 -4
  112. package/plugins/qoder/agents/delphi-architecture.md +1 -1
  113. package/plugins/qoder/agents/delphi-feasibility.md +1 -1
  114. package/plugins/qoder/agents/delphi-technical.md +1 -1
  115. package/plugins/qoder/plugin.json +1 -1
  116. package/plugins/qoder/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
  117. package/plugins/qoder/skills/delphi-review/AGENTS.md +4 -4
  118. package/plugins/qoder/skills/delphi-review/SKILL.md +98 -14
  119. package/plugins/qoder/skills/delphi-review/references/code-walkthrough.md +36 -3
  120. package/plugins/qoder/skills/delphi-review/references/requirements.md +6 -6
  121. package/plugins/qoder/skills/sprint-flow/AGENTS.md +4 -4
  122. package/plugins/qoder/skills/sprint-flow/references/phase-2-design.md +3 -3
  123. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +4 -4
  124. package/principles/AGENTS.md +9 -8
  125. package/principles/__tests__/adapter-metrics.test.ts +292 -0
  126. package/principles/__tests__/analyzer.test.ts +29 -6
  127. package/principles/__tests__/baseline-prune-timeout.test.ts +71 -0
  128. package/principles/__tests__/baseline-prune.test.ts +338 -0
  129. package/principles/__tests__/boy-scout-init-baseline.test.ts +167 -0
  130. package/principles/__tests__/boy-scout-integration.test.ts +97 -6
  131. package/principles/__tests__/boy-scout.test.ts +253 -7
  132. package/principles/__tests__/config-enforcement.test.ts +469 -0
  133. package/principles/__tests__/config-git-timeout.test.ts +66 -0
  134. package/principles/__tests__/direct-execution.test.ts +187 -0
  135. package/principles/__tests__/index.test.ts +16 -3
  136. package/principles/__tests__/warnings-baseline-budgets.test.ts +74 -0
  137. package/principles/adapters/typescript.ts +613 -13
  138. package/principles/analyzer.ts +22 -0
  139. package/principles/baseline-prune.ts +217 -0
  140. package/principles/boy-scout.ts +481 -51
  141. package/principles/config.ts +362 -54
  142. package/principles/direct-execution.ts +60 -0
  143. package/principles/index.ts +40 -17
  144. package/principles/rules/__tests__/clean-code/many-exports.test.ts +16 -10
  145. package/principles/rules/clean-code/code-duplication.ts +8 -8
  146. package/principles/rules/clean-code/deep-nesting.ts +8 -8
  147. package/principles/rules/clean-code/god-class.ts +8 -8
  148. package/principles/rules/clean-code/large-file.ts +15 -11
  149. package/principles/rules/clean-code/long-function.ts +8 -8
  150. package/principles/rules/clean-code/magic-numbers.ts +10 -7
  151. package/principles/rules/clean-code/many-exports.ts +7 -7
  152. package/principles/rules/clean-code/missing-error-handling.ts +5 -5
  153. package/principles/rules/clean-code/too-many-params.ts +8 -8
  154. package/principles/rules/clean-code/unused-imports.ts +5 -5
  155. package/principles/rules/solid/dip.ts +18 -8
  156. package/principles/rules/solid/isp.ts +9 -8
  157. package/principles/rules/solid/lsp.ts +6 -6
  158. package/principles/rules/solid/ocp.ts +6 -5
  159. package/principles/rules/solid/srp.ts +7 -8
  160. package/principles/types.ts +15 -0
  161. package/scripts/__tests__/sync-package-content.test.js +28 -0
  162. package/scripts/delphi-external-review.cjs +193 -10
  163. package/scripts/sync-package-content.js +7 -4
  164. package/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
  165. package/skills/delphi-review/AGENTS.md +4 -4
  166. package/skills/delphi-review/SKILL.md +98 -14
  167. package/skills/delphi-review/references/code-walkthrough.md +36 -3
  168. package/skills/delphi-review/references/requirements.md +6 -6
  169. package/skills/sprint-flow/AGENTS.md +4 -4
  170. package/skills/sprint-flow/references/phase-2-design.md +3 -3
  171. package/skills/test-specification-alignment/AGENTS.md +4 -4
  172. package/lib/__tests__/download-skill.test.js +0 -281
  173. package/lib/download-skill.js +0 -96
@@ -0,0 +1,338 @@
1
+ /**
2
+ * @test REQ-452 REQ-3
3
+ * @intent A baseline entry for a file that no longer exists must not be kept
4
+ * forever. Previously `classifyFiles` parsed `deleted` but nothing
5
+ * consumed it, so `.warnings-baseline.json` accumulated stale entries
6
+ * that let a re-created file inherit a bogus allowance.
7
+ * @covers AC-452-10, AC-452-11, AC-452-12, AC-452-13, AC-452-14
8
+ *
9
+ * The removal is deliberately DOUBLE-CONFIRMED: a path is only treated as
10
+ * deleted when the filesystem says it is absent AND git no longer tracks it.
11
+ * Either signal alone produces false positives -- a sparse checkout, a rebase
12
+ * mid-flight, a case-insensitive filesystem, or a symlink all make an existing
13
+ * tracked file look "missing".
14
+ */
15
+
16
+ import { execFileSync } from 'node:child_process';
17
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
18
+ import { tmpdir } from 'node:os';
19
+ import { join } from 'node:path';
20
+ import { afterEach, describe, expect, it } from 'vitest';
21
+ import { pruneBaselineEntries } from '../baseline-prune';
22
+
23
+ const tempDirs: string[] = [];
24
+
25
+ function makeTempDir(): string {
26
+ const dir = mkdtempSync(join(tmpdir(), 'xpgate-452-'));
27
+ tempDirs.push(dir);
28
+ return dir;
29
+ }
30
+
31
+ /** Create a git repo with one committed file, and return its root. */
32
+ function makeRepoWithCommit(): { root: string; tracked: string } {
33
+ const root = makeTempDir();
34
+ const tracked = 'tracked.ts';
35
+ const run = (args: string[]): void => {
36
+ try {
37
+ execFileSync('git', args, { cwd: root, stdio: 'ignore' });
38
+ } catch (error) {
39
+ const detail = error instanceof Error ? error.message : String(error);
40
+ throw new Error(`git ${args.join(' ')} failed in ${root}: ${detail}`);
41
+ }
42
+ };
43
+ try {
44
+ writeFileSync(join(root, tracked), 'export const a = 1;\n', 'utf8');
45
+ } catch (error) {
46
+ const detail = error instanceof Error ? error.message : String(error);
47
+ throw new Error(`could not seed repo file: ${detail}`);
48
+ }
49
+ run(['init', '-b', 'main']);
50
+ run(['config', 'user.email', 'test@example.com']);
51
+ run(['config', 'user.name', 'Test']);
52
+ // This fixture is scaffolding, not a real commit: the global XP-Gate hooks are
53
+ // installed on this machine and would otherwise block the seed commit
54
+ // (Gate 5a rejects an untested source file). `core.hooksPath` is set to a
55
+ // non-existent path rather than using --no-verify so the intent is explicit
56
+ // and cannot be mistaken for bypassing this repository's own gates.
57
+ run(['config', 'core.hooksPath', join(root, '.no-hooks')]);
58
+ run(['add', tracked]);
59
+ run(['commit', '-m', 'seed']);
60
+ return { root, tracked };
61
+ }
62
+
63
+ /**
64
+ * Stage many paths at once, to push `git ls-files` output past the 1 MiB default
65
+ * stdout buffer that `execFileSync` would otherwise apply.
66
+ *
67
+ * Uses `git update-index --index-info` to write the index directly rather than
68
+ * creating files and running `git add`: that path costs over 50s for this many
69
+ * entries (filesystem + index churn), whereas feeding the index in one batch takes
70
+ * under a second. The blob hash is git's well-known empty-blob id, so nothing needs
71
+ * to exist on disk -- which is fine, because the assertion is about `git ls-files`
72
+ * reporting the index, not about the working tree.
73
+ */
74
+ function stageManyPaths(root: string, count: number, nameFor: (index: number) => string): void {
75
+ const EMPTY_BLOB = 'e69de29bb2d1d6434b8b29ae775ad8c2e48c5391';
76
+ const lines: string[] = [];
77
+ for (let i = 0; i < count; i += 1) {
78
+ lines.push(`100644 ${EMPTY_BLOB}\t${nameFor(i)}`);
79
+ }
80
+ try {
81
+ execFileSync('git', ['update-index', '--index-info'], {
82
+ cwd: root,
83
+ input: `${lines.join('\n')}\n`,
84
+ stdio: ['pipe', 'ignore', 'ignore'],
85
+ });
86
+ } catch (error) {
87
+ const detail = error instanceof Error ? error.message : String(error);
88
+ throw new Error(`could not stage filler paths: ${detail}`);
89
+ }
90
+ }
91
+
92
+ /**
93
+ * A baseline shaped like the real `.warnings-baseline.json` payload.
94
+ *
95
+ * The on-disk format is a FLAT path -> entry map, verified against the committed
96
+ * file; there is no `files` wrapper.
97
+ */
98
+ function baselineWith(paths: string[]): string {
99
+ const files: Record<string, unknown> = {};
100
+ for (const path of paths) {
101
+ files[path] = { totalWarnings: 3, lastAnalyzed: '2026-10-03T00:00:00.000Z' };
102
+ }
103
+ return JSON.stringify(files);
104
+ }
105
+
106
+ /**
107
+ * Stage exact paths in the index without creating working-tree files.
108
+ *
109
+ * Index-only staging is what makes the case-sensitivity assertions portable:
110
+ * neither spelling exists on disk (so the `existsSync` half of the double-confirm
111
+ * is false everywhere), and the only signal left is the comparison against the
112
+ * git index -- which is exactly the behavior under test. The blob hash is git's
113
+ * well-known empty-blob id, so nothing needs to exist on disk.
114
+ */
115
+ function stageIndexOnly(root: string, paths: string[]): void {
116
+ const EMPTY_BLOB = 'e69de29bb2d1d6434b8b29ae775ad8c2e48c5391';
117
+ const input = paths.map((path) => `100644 ${EMPTY_BLOB}\t${path}`).join('\n');
118
+ try {
119
+ execFileSync('git', ['update-index', '--index-info'], {
120
+ cwd: root,
121
+ input: `${input}\n`,
122
+ stdio: ['pipe', 'ignore', 'ignore'],
123
+ });
124
+ } catch (error) {
125
+ const detail = error instanceof Error ? error.message : String(error);
126
+ throw new Error(`could not stage index paths: ${detail}`);
127
+ }
128
+ }
129
+
130
+ afterEach(() => {
131
+ for (const dir of tempDirs.splice(0)) {
132
+ try {
133
+ rmSync(dir, { recursive: true, force: true });
134
+ } catch {
135
+ /* best effort */
136
+ }
137
+ }
138
+ });
139
+
140
+ describe('#452 REQ-3 stale baseline entries are pruned', () => {
141
+ it('AC-452-10: a path that is absent AND untracked is reported for removal', () => {
142
+ const { root } = makeRepoWithCommit();
143
+ const stale = 'gone-forever.ts';
144
+
145
+ const result = pruneBaselineEntries(baselineWith([stale]), root);
146
+
147
+ expect(result.removed).toContain(stale);
148
+ expect(result.kept).not.toContain(stale);
149
+ });
150
+
151
+ it('AC-452-11: an existing tracked file is KEPT even when absent from disk', () => {
152
+ // Simulates a sparse checkout / mid-rebase / symlinked worktree: the file
153
+ // is gone from the working tree but git still tracks it. Removing the entry
154
+ // here would silently discard a legitimate allowance.
155
+ const { root, tracked } = makeRepoWithCommit();
156
+ rmSync(join(root, tracked));
157
+
158
+ const result = pruneBaselineEntries(baselineWith([tracked]), root);
159
+
160
+ expect(result.kept).toContain(tracked);
161
+ expect(result.removed).not.toContain(tracked);
162
+ });
163
+
164
+ it('AC-452-12: a file present on disk is kept', () => {
165
+ const { root, tracked } = makeRepoWithCommit();
166
+
167
+ const result = pruneBaselineEntries(baselineWith([tracked]), root);
168
+
169
+ expect(result.kept).toContain(tracked);
170
+ expect(result.removed).toHaveLength(0);
171
+ });
172
+
173
+ it('AC-452-13: an untracked-but-present file is kept (git is not authoritative alone)', () => {
174
+ const { root } = makeRepoWithCommit();
175
+ const untracked = 'untracked.ts';
176
+ mkdirSync(root, { recursive: true });
177
+ try {
178
+ writeFileSync(join(root, untracked), 'export const b = 2;\n', 'utf8');
179
+ } catch (error) {
180
+ const detail = error instanceof Error ? error.message : String(error);
181
+ throw new Error(`could not write untracked file: ${detail}`);
182
+ }
183
+
184
+ const result = pruneBaselineEntries(baselineWith([untracked]), root);
185
+
186
+ expect(result.kept).toContain(untracked);
187
+ });
188
+
189
+ it('AC-452-10: pruning is non-destructive — it never mutates the input', () => {
190
+ const { root } = makeRepoWithCommit();
191
+ const input = baselineWith(['gone-forever.ts', 'also-gone.ts']);
192
+
193
+ pruneBaselineEntries(input, root);
194
+
195
+ // The caller decides when to persist; the helper must not rewrite in place.
196
+ const parsed = JSON.parse(input) as Record<string, unknown>;
197
+ expect(Object.keys(parsed)).toHaveLength(2);
198
+ });
199
+
200
+ it('AC-452-11: every entry is preserved when git is unavailable', () => {
201
+ // A non-repo directory means `git ls-files` fails. Failing closed (keep
202
+ // everything) is safer than deleting entries we cannot confirm.
203
+ const dir = makeTempDir();
204
+ const paths = ['a.ts', 'b.ts'];
205
+
206
+ const result = pruneBaselineEntries(baselineWith(paths), dir);
207
+
208
+ expect(result.kept.sort()).toEqual(paths);
209
+ expect(result.removed).toHaveLength(0);
210
+ });
211
+
212
+ it('AC-452-13: an empty baseline yields empty kept/removed lists', () => {
213
+ const { root } = makeRepoWithCommit();
214
+
215
+ const result = pruneBaselineEntries(baselineWith([]), root);
216
+
217
+ expect(result.kept).toHaveLength(0);
218
+ expect(result.removed).toHaveLength(0);
219
+ });
220
+
221
+ it('AC-452-12: a malformed baseline is preserved rather than silently emptied', () => {
222
+ // The reporter's `{}` came from this path. Keeping the entries and
223
+ // reporting an error is the fail-safe behaviour.
224
+ const { root } = makeRepoWithCommit();
225
+
226
+ const result = pruneBaselineEntries('{ not json', root);
227
+
228
+ expect(result.removed).toHaveLength(0);
229
+ expect(result.error).toBeDefined();
230
+ });
231
+
232
+ it('AC-452-10: pruning still works when the git index exceeds the default pipe buffer', () => {
233
+ // `execFileSync` defaults to a 1 MiB stdout buffer. A repo whose `git
234
+ // ls-files` output exceeds it throws, which `listTrackedFiles` would report
235
+ // as "git unavailable" -- silently disabling pruning at exactly the scale
236
+ // where a stale baseline is most likely. Committing enough paths to blow past
237
+ // 1 MiB proves the raised maxBuffer is actually in effect: without it every
238
+ // entry is merely "kept" and the stale path below would never be reported.
239
+ const { root } = makeRepoWithCommit();
240
+ // Measured: each path is ~48 bytes once `git ls-files` newline-joins it, so
241
+ // 1 MiB / 48 = ~21.8k entries. Use 30000 (~1.4 MiB) so the threshold is
242
+ // cleared with real headroom rather than marginally -- an earlier version used
243
+ // 19000, which came to 0.88 MiB and passed even with the buffer bug present.
244
+ stageManyPaths(root, 30000, (i) => `filler/s${i}/a-fairly-long-file-name-padding.ts`);
245
+
246
+ const result = pruneBaselineEntries(baselineWith(['deleted-long-ago.ts']), root);
247
+
248
+ expect(result.removed).toEqual(['deleted-long-ago.ts']);
249
+ expect(result.error).toBeUndefined();
250
+ });
251
+
252
+ it('AC-452-14: on a case-sensitive filesystem the index path is the canonical form', () => {
253
+ // Two tracked files whose names differ only by case are two DIFFERENT files on
254
+ // Linux. A baseline key that matches neither spelling exactly is a phantom:
255
+ // folding it to `case/alpha.ts` used to match the index and retain the entry
256
+ // forever -- the same stale-allowance bug this module exists to remove, since
257
+ // re-creating `case/ALPHA.ts` would inherit the retained allowance.
258
+ const { root } = makeRepoWithCommit();
259
+ stageIndexOnly(root, ['case/Alpha.ts', 'case/alpha.ts']);
260
+
261
+ const result = pruneBaselineEntries(
262
+ baselineWith(['case/ALPHA.ts']),
263
+ root,
264
+ { caseInsensitivePaths: false },
265
+ );
266
+
267
+ expect(result.removed).toEqual(['case/ALPHA.ts']);
268
+ expect(result.kept).toHaveLength(0);
269
+ });
270
+
271
+ it('AC-452-14: a case-sensitive filesystem does not conflate a differently-cased key with a tracked path', () => {
272
+ const { root } = makeRepoWithCommit();
273
+ stageIndexOnly(root, ['src/Tracked.ts']);
274
+
275
+ const result = pruneBaselineEntries(
276
+ baselineWith(['src/tracked.ts']),
277
+ root,
278
+ { caseInsensitivePaths: false },
279
+ );
280
+
281
+ expect(result.removed).toEqual(['src/tracked.ts']);
282
+ });
283
+
284
+ it('AC-452-14: an exact index match is kept on a case-sensitive filesystem', () => {
285
+ // Guards against the opposite regression: dropping the fold must not turn a
286
+ // legitimately tracked path into a prunable one.
287
+ const { root } = makeRepoWithCommit();
288
+ stageIndexOnly(root, ['case/alpha.ts']);
289
+
290
+ const result = pruneBaselineEntries(
291
+ baselineWith(['case/alpha.ts']),
292
+ root,
293
+ { caseInsensitivePaths: false },
294
+ );
295
+
296
+ expect(result.kept).toContain('case/alpha.ts');
297
+ expect(result.removed).toHaveLength(0);
298
+ });
299
+
300
+ it('AC-452-14: a case-insensitive filesystem keeps the retain-bias for a differently-cased key', () => {
301
+ // On Windows (and macOS default volumes) `src/tracked.ts` and `src/Tracked.ts`
302
+ // are the same file, and the key can be written by a tool that reported a
303
+ // different casing than the index. Losing that allowance in a sparse checkout
304
+ // -- when `existsSync` cannot confirm either spelling -- is the failure mode
305
+ // the original fold was introduced to prevent, so case-folding stays enabled
306
+ // exactly where the filesystem actually folds.
307
+ const { root } = makeRepoWithCommit();
308
+ stageIndexOnly(root, ['src/Tracked.ts']);
309
+
310
+ const result = pruneBaselineEntries(
311
+ baselineWith(['src/tracked.ts']),
312
+ root,
313
+ { caseInsensitivePaths: true },
314
+ );
315
+
316
+ expect(result.kept).toContain('src/tracked.ts');
317
+ expect(result.removed).toHaveLength(0);
318
+ });
319
+
320
+ it('AC-452-14: the default follows the host filesystem, not a hardcoded assumption', () => {
321
+ // This repository's own tests must stay portable: Linux CI and this Windows
322
+ // checkout have to disagree here, because the correct answer IS platform
323
+ // dependent. The assertion pins the wiring (default = detection) rather than a
324
+ // single outcome.
325
+ const { root } = makeRepoWithCommit();
326
+ stageIndexOnly(root, ['src/Tracked.ts']);
327
+
328
+ const result = pruneBaselineEntries(baselineWith(['src/tracked.ts']), root);
329
+ const folded = process.platform === 'win32' || process.platform === 'darwin';
330
+
331
+ if (folded) {
332
+ expect(result.kept).toContain('src/tracked.ts');
333
+ expect(result.removed).toHaveLength(0);
334
+ } else {
335
+ expect(result.removed).toEqual(['src/tracked.ts']);
336
+ }
337
+ });
338
+ });
@@ -0,0 +1,167 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+
3
+ vi.mock('fs/promises', async (importOriginal) => {
4
+ const actual = await importOriginal<typeof import('fs/promises')>();
5
+ return {
6
+ ...actual,
7
+ readFile: vi.fn(),
8
+ access: vi.fn(),
9
+ writeFile: vi.fn(),
10
+ };
11
+ });
12
+
13
+ vi.mock('../analyzer', () => ({
14
+ analyze: vi.fn(),
15
+ getAdapterForFile: vi.fn(),
16
+ }));
17
+
18
+ vi.mock('../index', () => ({
19
+ getAllRules: vi.fn(() => []),
20
+ }));
21
+
22
+ import { writeFile, readFile } from 'fs/promises';
23
+ import { initBaselineCommand } from '../boy-scout';
24
+ import { analyze } from '../analyzer';
25
+
26
+ const mockAnalyze = vi.mocked(analyze);
27
+ const mockWriteFile = vi.mocked(writeFile);
28
+ const mockReadFile = vi.mocked(readFile);
29
+
30
+ function violationsFor(counts: Record<string, number>) {
31
+ const violations = [];
32
+ for (const [file, n] of Object.entries(counts)) {
33
+ for (let i = 0; i < n; i++) {
34
+ violations.push({
35
+ file,
36
+ line: i + 1,
37
+ ruleId: 'clean-code.magic-numbers',
38
+ message: 'm',
39
+ severity: 'warning' as const,
40
+ });
41
+ }
42
+ }
43
+ return { violations, summary: {}, fileResults: {}, ruleResults: {}, executionTimeMs: 0, errors: [] };
44
+ }
45
+
46
+ /**
47
+ * A realistic ENOENT. `loadBaseline` distinguishes "no file yet" from every other
48
+ * I/O failure by `error.code`, so a mock that only says `new Error('ENOENT')`
49
+ * (no `code`) is not a real ENOENT and would be treated as unreadable (#455).
50
+ */
51
+ function enoent(): NodeJS.ErrnoException {
52
+ return Object.assign(new Error('ENOENT: no such file or directory'), { code: 'ENOENT' });
53
+ }
54
+
55
+ /**
56
+ * @test REQ-DSH-014
57
+ * @intent 验证 `--init-baseline` 是**合并**语义而非覆盖:既有基线条目必须保留,
58
+ * 且写入路径必须尊重 `--baseline` 参数,不得硬编码 .warnings-baseline.json
59
+ * (#445 —— 实测该命令会把已跟踪的基线摧毁成 {})
60
+ * @covers AC-DSH-014-01
61
+ */
62
+ describe('initBaselineCommand must not destroy existing baselines (#445)', () => {
63
+ beforeEach(() => {
64
+ vi.clearAllMocks();
65
+ mockWriteFile.mockResolvedValue(undefined);
66
+ });
67
+
68
+ it('merges into the existing baseline instead of replacing it', async () => {
69
+ // Existing tracked baseline already has an entry for an unrelated file.
70
+ const existing = {
71
+ 'src/legacy.ts': { totalWarnings: 3, lastAnalyzed: '2026-01-01T00:00:00.000Z' },
72
+ };
73
+ mockReadFile.mockResolvedValue(JSON.stringify(existing) as never);
74
+ mockAnalyze.mockResolvedValue(violationsFor({ 'src/new.ts': 2 }) as never);
75
+
76
+ await initBaselineCommand(['src/new.ts'], '.warnings-baseline.json');
77
+
78
+ expect(mockWriteFile).toHaveBeenCalledTimes(1);
79
+ const [, writtenRaw] = mockWriteFile.mock.calls[0];
80
+ const written = JSON.parse(String(writtenRaw));
81
+
82
+ // The pre-existing entry MUST survive.
83
+ expect(written['src/legacy.ts']).toBeDefined();
84
+ expect(written['src/legacy.ts'].totalWarnings).toBe(3);
85
+ // And the newly analysed file must be added.
86
+ expect(written['src/new.ts']).toBeDefined();
87
+ expect(written['src/new.ts'].totalWarnings).toBe(2);
88
+ });
89
+
90
+ it('writes to the path given by --baseline, not the hardcoded default', async () => {
91
+ mockReadFile.mockRejectedValue(enoent() as never);
92
+ mockAnalyze.mockResolvedValue(violationsFor({ 'src/a.ts': 1 }) as never);
93
+
94
+ await initBaselineCommand(['src/a.ts'], 'custom/baseline.json');
95
+
96
+ const [targetPath] = mockWriteFile.mock.calls[0];
97
+ expect(String(targetPath)).toBe('custom/baseline.json');
98
+ expect(String(targetPath)).not.toBe('.warnings-baseline.json');
99
+ });
100
+
101
+ it('does not persist an empty object when analysis yields no warnings', async () => {
102
+ const existing = {
103
+ 'src/legacy.ts': { totalWarnings: 1, lastAnalyzed: '2026-01-01T00:00:00.000Z' },
104
+ };
105
+ mockReadFile.mockResolvedValue(JSON.stringify(existing) as never);
106
+ // File now has zero warnings.
107
+ mockAnalyze.mockResolvedValue(violationsFor({}) as never);
108
+
109
+ await initBaselineCommand(['src/clean.ts'], '.warnings-baseline.json');
110
+
111
+ const [, writtenRaw] = mockWriteFile.mock.calls[0];
112
+ const written = JSON.parse(String(writtenRaw));
113
+ // Must NOT be {} -- the legacy entry has to survive.
114
+ expect(Object.keys(written)).toContain('src/legacy.ts');
115
+ });
116
+
117
+ it('reports which entries were added and which were preserved', async () => {
118
+ const existing = {
119
+ 'src/legacy.ts': { totalWarnings: 3, lastAnalyzed: '2026-01-01T00:00:00.000Z' },
120
+ };
121
+ mockReadFile.mockResolvedValue(JSON.stringify(existing) as never);
122
+ mockAnalyze.mockResolvedValue(violationsFor({ 'src/new.ts': 1 }) as never);
123
+ const log = vi.spyOn(console, 'log').mockImplementation(() => undefined);
124
+
125
+ await initBaselineCommand(['src/new.ts'], '.warnings-baseline.json');
126
+
127
+ const output = log.mock.calls.map((c) => c.join(' ')).join('\n');
128
+ expect(output).toMatch(/src\/legacy\.ts/);
129
+ expect(output).toMatch(/src\/new\.ts/);
130
+ log.mockRestore();
131
+ });
132
+
133
+ it('signals outcome via its return value, never process.exitCode', async () => {
134
+ // Delphi Round 1: three experts independently flagged that this command
135
+ // reported failure by mutating global exit state, so an unrelated
136
+ // non-zero process.exitCode would be misread as this command failing.
137
+ mockReadFile.mockRejectedValue(enoent() as never);
138
+ mockAnalyze.mockResolvedValue(violationsFor({ 'src/a.ts': 1 }) as never);
139
+ const previousExitCode = process.exitCode;
140
+ process.exitCode = 0;
141
+
142
+ const result = await initBaselineCommand(['src/a.ts'], '.warnings-baseline.json');
143
+
144
+ expect(result.ok).toBe(true);
145
+ // Global state must be untouched by the command.
146
+ expect(process.exitCode).toBe(0);
147
+
148
+ process.exitCode = previousExitCode;
149
+ });
150
+
151
+ it('can never empty a non-empty baseline, even when analysis finds nothing', async () => {
152
+ // The merge starts from the existing entries, so the destructive overwrite
153
+ // from #445 is structurally impossible rather than merely guarded.
154
+ const existing = {
155
+ 'src/legacy.ts': { totalWarnings: 1, lastAnalyzed: '2026-01-01T00:00:00.000Z' },
156
+ };
157
+ mockReadFile.mockResolvedValue(JSON.stringify(existing) as never);
158
+ mockAnalyze.mockResolvedValue(violationsFor({}) as never);
159
+
160
+ const result = await initBaselineCommand(['src/clean.ts'], '.warnings-baseline.json');
161
+
162
+ expect(result.ok).toBe(true);
163
+ const [, writtenRaw] = mockWriteFile.mock.calls[0];
164
+ const written = JSON.parse(String(writtenRaw));
165
+ expect(Object.keys(written)).toContain('src/legacy.ts');
166
+ });
167
+ });
@@ -2,6 +2,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
  import { exec } from 'child_process';
3
3
  import { promisify } from 'util';
4
4
  import * as path from 'path';
5
+ import { tmpdir } from 'os';
6
+ import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
5
7
 
6
8
  const execAsync = promisify(exec);
7
9
 
@@ -147,6 +149,51 @@ describe('Boy Scout Rule - runEnforcement', () => {
147
149
  expect(vi.mocked(mockWriteFile).mock.calls[0][0]).toBe('/tmp/test-baseline.json');
148
150
  });
149
151
 
152
+ // #452: the auto-init path inside runEnforcement once wrote only the current
153
+ // commit's files, silently dropping entries for untouched files -- which then
154
+ // made Gate 6 BLOCK files the commit never touched. The explicit
155
+ // init-baseline command got merge semantics (#445); these tests pin the same
156
+ // guarantee on the automatic path.
157
+ it('REQ-452: auto-init preserves entries for files not in the current commit', async () => {
158
+ // 6 warnings so the entry survives the "small baselines must clear" rule;
159
+ // a clean file gains no entry by design (nothing to budget).
160
+ const warnings = Array.from({ length: 6 }, (_, i) => ({
161
+ file: 'plugins/dsh/src/command.ts', line: i + 1, ruleId: 'test', severity: 'warning' as const, message: 'm',
162
+ }));
163
+ mockAnalyze.mockResolvedValue(fakeAnalysis(warnings));
164
+ mockAccess.mockResolvedValue(undefined);
165
+ mockReadFile.mockResolvedValue(
166
+ JSON.stringify({ 'src/npm-package/lib/test-alignment.ts': { totalWarnings: 1, lastAnalyzed: '2024-01-01' } })
167
+ );
168
+ mockWriteFile.mockResolvedValue(undefined);
169
+
170
+ const result = await runEnforcement([], ['plugins/dsh/src/command.ts'], '/tmp/test-baseline.json');
171
+
172
+ expect(result.overallStatus).toBe('PASS');
173
+ expect(mockWriteFile).toHaveBeenCalled();
174
+ const saved = JSON.parse(vi.mocked(mockWriteFile).mock.calls[0][1] as string) as Record<string, BaselineEntry>;
175
+ expect(saved['src/npm-package/lib/test-alignment.ts']).toEqual({ totalWarnings: 1, lastAnalyzed: '2024-01-01' });
176
+ expect(saved['plugins/dsh/src/command.ts']?.totalWarnings).toBe(6);
177
+ });
178
+
179
+ it('REQ-452: auto-init is idempotent -- a second run over the same files writes the same entries', async () => {
180
+ mockAnalyze.mockResolvedValue(emptyAnalysis());
181
+ mockAccess.mockResolvedValue(undefined);
182
+ mockWriteFile.mockResolvedValue(undefined);
183
+ const first = JSON.stringify({});
184
+
185
+ mockReadFile.mockResolvedValue(first);
186
+ await runEnforcement([], ['src/a.ts'], '/tmp/test-baseline.json');
187
+ const savedOnce = vi.mocked(mockWriteFile).mock.calls.at(-1)![1] as string;
188
+
189
+ mockReadFile.mockResolvedValue(savedOnce);
190
+ mockWriteFile.mockClear();
191
+ await runEnforcement([], ['src/a.ts'], '/tmp/test-baseline.json');
192
+ const savedTwice = vi.mocked(mockWriteFile).mock.calls.at(-1)![1] as string;
193
+
194
+ expect(JSON.parse(savedOnce)).toEqual(JSON.parse(savedTwice));
195
+ });
196
+
150
197
  it('blocks modified file with <=5 baseline warnings that are not cleared', async () => {
151
198
  mockAnalyze.mockResolvedValue(
152
199
  fakeAnalysis([{ file: 'src/mod.ts', line: 1, ruleId: 'test', severity: 'warning' as const, message: 'm' }])
@@ -248,12 +295,56 @@ describe('CLI integration', () => {
248
295
  expect(stdout).toContain('Usage: boy-scout');
249
296
  }, CLI_TEST_TIMEOUT);
250
297
 
251
- it('runs init-baseline via CLI', async () => {
252
- const { stdout } = await execAsync(
253
- `npx tsx ${BOY_SCOUT_PATH} --init-baseline src/principles/boy-scout.ts`,
254
- { timeout: 30000 }
255
- );
256
- expect(stdout).toContain('Baseline initialized successfully');
298
+ it('runs init-baseline via CLI against an isolated baseline', async () => {
299
+ try {
300
+ // Never point --baseline at the repo's tracked .warnings-baseline.json: this
301
+ // test must not mutate tracked state. Use a throwaway path instead (#445).
302
+ // NOTE: this file mocks 'fs/promises' (which also covers 'node:fs/promises'),
303
+ // so use node:fs sync APIs -- they are not intercepted.
304
+ const tmpBaseline = path.join(mkdtempSync(path.join(tmpdir(), 'bs-init-')), 'baseline.json');
305
+ const { stdout } = await execAsync(
306
+ `npx tsx ${BOY_SCOUT_PATH} --init-baseline src/principles/boy-scout.ts --baseline "${tmpBaseline}"`,
307
+ { timeout: 30000 }
308
+ );
309
+ // The command now reports a merge summary instead of the old (misleading)
310
+ // "Baseline initialized successfully" — it updates rather than replaces.
311
+ expect(stdout).toContain('Baseline updated');
312
+ expect(stdout).toContain(tmpBaseline);
313
+ expect(typeof readFileSync(tmpBaseline, 'utf-8')).toBe('string');
314
+ } catch (error) {
315
+ throw error instanceof Error ? error : new Error(String(error));
316
+ }
317
+ }, CLI_TEST_TIMEOUT);
318
+
319
+ /**
320
+ * @test REQ-DSH-014
321
+ * @intent 验证 CLI 的 --init-baseline 是合并语义:既有条目不因初始化无关文件而丢失
322
+ * (#445 实测会把已跟踪基线摧毁成 {})
323
+ * @covers AC-DSH-014-01
324
+ */
325
+ it('init-baseline preserves pre-existing entries (merge, not replace)', async () => {
326
+ try {
327
+ const dir = mkdtempSync(path.join(tmpdir(), 'bs-merge-'));
328
+ const tmpBaseline = path.join(dir, 'baseline.json');
329
+ writeFileSync(
330
+ tmpBaseline,
331
+ JSON.stringify({
332
+ 'src/legacy.ts': { totalWarnings: 3, lastAnalyzed: '2026-01-01T00:00:00.000Z' },
333
+ }),
334
+ 'utf-8'
335
+ );
336
+
337
+ await execAsync(
338
+ `npx tsx ${BOY_SCOUT_PATH} --init-baseline src/principles/boy-scout.ts --baseline "${tmpBaseline}"`,
339
+ { timeout: 30000 }
340
+ );
341
+
342
+ const written = JSON.parse(readFileSync(tmpBaseline, 'utf-8'));
343
+ expect(written['src/legacy.ts']).toBeDefined();
344
+ expect(written['src/legacy.ts'].totalWarnings).toBe(3);
345
+ } catch (error) {
346
+ throw error instanceof Error ? error : new Error(String(error));
347
+ }
257
348
  }, CLI_TEST_TIMEOUT);
258
349
 
259
350
  it('runs enforcement via CLI with empty files', async () => {