@boyingliu01/xp-gate 0.31.1 → 0.31.3

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 (86) hide show
  1. package/adapter-common.sh +41 -3
  2. package/adapters/gate-3.sh +8 -1
  3. package/adapters/gate-8.sh +67 -6
  4. package/adapters/go.sh +18 -1
  5. package/adapters/iac.sh +18 -1
  6. package/adapters/powershell.sh +37 -1
  7. package/adapters/python.sh +18 -4
  8. package/gate-3.sh +254 -10
  9. package/gate-8.sh +67 -6
  10. package/hooks/adapter-common.sh +41 -3
  11. package/hooks/gate-3.sh +254 -10
  12. package/hooks/gate-8.sh +67 -6
  13. package/hooks/lib/Measure-PowerShellComplexity.ps1 +186 -0
  14. package/hooks/lib/coverage-total.sh +20 -5
  15. package/hooks/lib/gate-5c-alignment.sh +145 -0
  16. package/hooks/lib/jscpd-run.sh +40 -1
  17. package/hooks/lib/native-path.sh +106 -0
  18. package/hooks/lib/prepush-score.py +143 -0
  19. package/hooks/lib/sprint-gate-report.sh +23 -0
  20. package/hooks/lib/toolchain-path.sh +370 -0
  21. package/hooks/lib/validate-code-walkthrough.cjs +224 -23
  22. package/hooks/pre-commit +403 -165
  23. package/hooks/pre-push +192 -74
  24. package/hooks/sprint-gate.sh +14 -3
  25. package/lib/__tests__/check-alignment-cli.test.mjs +20 -3
  26. package/lib/__tests__/check-alignment-reasons.test.mjs +91 -0
  27. package/lib/__tests__/doctor-delphi.test.js +46 -0
  28. package/lib/__tests__/doctor-exec-retry.test.js +20 -10
  29. package/lib/__tests__/doctor-hook-signatures.test.js +65 -0
  30. package/lib/__tests__/doctor-path-equivalence.test.js +12 -1
  31. package/lib/__tests__/doctor.test.js +7 -1
  32. package/lib/__tests__/hook-health-path.test.js +9 -1
  33. package/lib/__tests__/issue-566-sync-hooks-backup.test.js +221 -0
  34. package/lib/__tests__/issue-583-hook-deploy-log.test.js +121 -0
  35. package/lib/__tests__/issue-586-principles-cmdline.test.js +183 -0
  36. package/lib/__tests__/issue-589-check-all-gate3.test.js +190 -0
  37. package/lib/__tests__/issue-590-archlint-vacuous-pass.test.js +138 -0
  38. package/lib/__tests__/shared-paths-lazy-home.test.js +67 -0
  39. package/lib/arch.js +261 -44
  40. package/lib/check-alignment-runner.cjs +23 -0
  41. package/lib/check.js +4 -1
  42. package/lib/doctor.js +194 -49
  43. package/lib/gate-runner.js +265 -58
  44. package/lib/mattpocock-license-verdict.json +1 -1
  45. package/lib/principles.js +228 -6
  46. package/lib/shared-paths.js +53 -19
  47. package/mock-policy/AGENTS.md +3 -3
  48. package/mutation/AGENTS.md +3 -3
  49. package/mutation/__tests__/gremlins-runner.test.ts +3 -3
  50. package/package.json +1 -1
  51. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  52. package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
  53. package/plugins/claude-code/skills/delphi-review/SKILL.md +50 -0
  54. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
  55. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
  56. package/plugins/dsh/package.json +1 -1
  57. package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
  58. package/plugins/dsh/skills/delphi-review/SKILL.md +50 -0
  59. package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
  60. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
  61. package/plugins/dsh/src/delphi-config-env-diagnostics.test.ts +210 -0
  62. package/plugins/dsh/src/delphi-config.ts +151 -9
  63. package/plugins/opencode/package.json +1 -1
  64. package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
  65. package/plugins/opencode/skills/delphi-review/SKILL.md +50 -0
  66. package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
  67. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
  68. package/plugins/qoder/plugin.json +1 -1
  69. package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
  70. package/plugins/qoder/skills/delphi-review/SKILL.md +50 -0
  71. package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
  72. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
  73. package/principles/AGENTS.md +3 -3
  74. package/principles/__tests__/issue-587-java-member-count.test.ts +279 -0
  75. package/principles/__tests__/issue-588-lsp-java-false-positives.test.ts +213 -0
  76. package/principles/adapters/java.ts +127 -11
  77. package/principles/rules/__tests__/solid/issue-588-lsp-base-contract.test.ts +252 -0
  78. package/principles/rules/__tests__/solid/lsp.test.ts +26 -22
  79. package/principles/rules/solid/lsp.ts +348 -23
  80. package/scripts/__tests__/sync-package-content.test.js +32 -1
  81. package/scripts/sync-package-content.js +6 -1
  82. package/skills/delphi-review/AGENTS.md +3 -3
  83. package/skills/delphi-review/SKILL.md +50 -0
  84. package/skills/sprint-flow/AGENTS.md +3 -3
  85. package/skills/test-specification-alignment/AGENTS.md +3 -3
  86. package/sprint-gate.sh +14 -3
@@ -0,0 +1,65 @@
1
+ /**
2
+ * @test REQ-2 xp-gate doctor
3
+ * @intent Prove that the SHIPPED hook files still carry the exact signature
4
+ * strings doctor uses to decide "this file is an xp-gate hook".
5
+ * @covers AC-05, AC-10
6
+ *
7
+ * Why this exists (#599): doctor identifies an installed hook by substring
8
+ * (`SIGNATURES`). The signatures are prose taken from each hook's own banner, so
9
+ * an innocuous editorial change to a banner line — replacing the em dash in
10
+ * `# Pre-push Hook — 8 gates:` with an ASCII hyphen, say — silently makes every
11
+ * installed pre-push hook unrecognisable. doctor then reports
12
+ * `Hooks: pre-push: Missing or not xp-gate` on a perfectly healthy install and
13
+ * `--fix` cannot clear it, because there is nothing wrong to fix.
14
+ *
15
+ * That is exactly what happened while rewriting the CRLF self-check in #546: the
16
+ * banner was retyped, the dash changed, and 9 doctor `--fix` cases went red with
17
+ * "expected 1 to be +0" — an assertion far from the actual cause. This test
18
+ * turns that class of failure into a one-line diagnostic naming the file and the
19
+ * missing string.
20
+ */
21
+ const fs = require('fs');
22
+ const path = require('path');
23
+
24
+ describe('shipped hooks carry doctor SIGNATURES', () => {
25
+ const repoRoot = path.resolve(__dirname, '../../../..');
26
+ const { SIGNATURES } = require('../doctor');
27
+
28
+ // The hook files that this repository actually ships, in both mirrors.
29
+ const SHIPPED_HOOKS = [
30
+ { sig: 'pre-commit', file: 'githooks/pre-commit' },
31
+ { sig: 'pre-commit', file: 'src/npm-package/hooks/pre-commit' },
32
+ { sig: 'pre-push', file: 'githooks/pre-push' },
33
+ { sig: 'pre-push', file: 'src/npm-package/hooks/pre-push' },
34
+ ];
35
+
36
+ for (const { sig, file } of SHIPPED_HOOKS) {
37
+ it(`${file} contains SIGNATURES['${sig}']`, () => {
38
+ const abs = path.join(repoRoot, file);
39
+ expect(fs.existsSync(abs), `${file} does not exist`).toBe(true);
40
+ const content = fs.readFileSync(abs, 'utf8');
41
+
42
+ expect(
43
+ content.includes(SIGNATURES[sig]),
44
+ `${file} does not contain ${JSON.stringify(SIGNATURES[sig])}.\n`
45
+ + 'doctor identifies an xp-gate hook by this exact substring, so an '
46
+ + 'edit to the banner line silently makes every installed hook look '
47
+ + 'foreign. Restore the string, or update SIGNATURES in doctor.js in '
48
+ + 'the same commit (and keep the mirrors byte-identical).'
49
+ ).toBe(true);
50
+ });
51
+ }
52
+
53
+ it('the two hook mirrors are byte-identical', () => {
54
+ for (const name of ['pre-commit', 'pre-push']) {
55
+ const a = path.join(repoRoot, 'githooks', name);
56
+ const b = path.join(repoRoot, 'src/npm-package/hooks', name);
57
+ expect(fs.existsSync(a), a).toBe(true);
58
+ expect(fs.existsSync(b), b).toBe(true);
59
+ expect(
60
+ fs.readFileSync(a, 'utf8') === fs.readFileSync(b, 'utf8'),
61
+ `githooks/${name} and src/npm-package/hooks/${name} differ`
62
+ ).toBe(true);
63
+ }
64
+ });
65
+ });
@@ -50,10 +50,21 @@ describe('doctor path equivalence (#496)', () => {
50
50
  );
51
51
  // The two comparison sites that made the Windows call fail must both route
52
52
  // through the equivalence check rather than a byte comparison.
53
+ //
54
+ // #599: this used to match the literal `pathsEquivalent(hooksPath,
55
+ // GLOBAL_HOOKS_DIR)`. GLOBAL_HOOKS_DIR became a function when the path
56
+ // constants stopped being captured at require() time (they follow
57
+ // process.env.HOME now), so the call site reads `GLOBAL_HOOKS_DIR()`. A
58
+ // literal match made that a test failure instead of a grammar change.
59
+ // The invariant is "the hooksPath comparison goes through pathsEquivalent
60
+ // and passes the global hooks dir to it" — match that, not its spelling.
53
61
  const callSites = source
54
62
  .split('\n')
55
63
  .filter((line) => /hooksPath\s*!==\s*GLOBAL_HOOKS_DIR/.test(line));
56
64
  expect(callSites).toHaveProperty('length', 0);
57
- expect(source).toMatch(/pathsEquivalent\(hooksPath, GLOBAL_HOOKS_DIR\)/);
65
+
66
+ expect(source).toMatch(
67
+ /pathsEquivalent\(\s*hooksPath\s*,\s*GLOBAL_HOOKS_DIR(?:\(\))?\s*\)/
68
+ );
58
69
  });
59
70
  });
@@ -951,7 +951,13 @@ describe('doctor', () => {
951
951
 
952
952
  // === --fix TUI registration ===
953
953
 
954
- it('--fix: diagnoses TUI not registered then re-diagnoses after fix', async () => {
954
+ // 15s, not vitest's 5s default (#588). This case runs the full `doctor(['--fix'])`
955
+ // pipeline over a real temp install: it writes hooks, adapters and tui.json and
956
+ // re-diagnoses. Measured in isolation it takes ~3.8s, which is already 76% of the
957
+ // default budget — so under the full suite (where 200 files share the machine) it
958
+ // crossed 5s and failed with "Test timed out in 5000ms". The work is legitimate;
959
+ // the budget was simply too tight for it. Similar cases in this file take 2-4s.
960
+ it('--fix: diagnoses TUI not registered then re-diagnoses after fix', { timeout: 15000 }, async () => {
955
961
  setupLocalInstall();
956
962
  seedVersionCache();
957
963
  mockExecSuccess();
@@ -72,7 +72,15 @@ describe('bash -n path handling', () => {
72
72
  );
73
73
  });
74
74
 
75
- test('AC-566-02: a healthy hook under a native Windows path is parseable', () => {
75
+ // Windows-only by construction: the assertion below requires path.join to
76
+ // produce a backslash path (`C:\...`), which only happens on win32. On Linux
77
+ // path.join yields `/tmp/...` and the test fails with
78
+ // expected '/tmp/hook-posix-XiaI9b/pre-push' to contain '\'
79
+ // That is a platform premise, not a regression (#586). Skipping keeps the gap
80
+ // visible in the run summary instead of painting CI red on every build.
81
+ const onWindows = process.platform === 'win32';
82
+
83
+ test.skipIf(!onWindows)('AC-566-02: a healthy hook under a native Windows path is parseable', () => {
76
84
  // The defect, end to end. The path MUST come from path.join, not from a
77
85
  // string literal: path.join produces the single-backslash form that Node
78
86
  // hands to spawnSync, and that is the form bash mangles. A hand-written
@@ -0,0 +1,221 @@
1
+ /**
2
+ * @test REQ-599
3
+ * @intent #566 was a hook that existed on exactly one machine and in no commit:
4
+ * the installed `~/.config/xp-gate/hooks/pre-push` carried a local
5
+ * `_score()` helper plus `gates_skipped` that no branch had ever held,
6
+ * and `xp-gate doctor --sync-hooks` replaced it without a word. The repo
7
+ * side then looked correct and the only copy of that work was gone.
8
+ *
9
+ * doctor already REFUSES to overwrite a file whose content diverges, and
10
+ * says "there is no automatic backup ... nothing records the previous
11
+ * version". That refusal covers divergence; it does not cover the
12
+ * legitimate overwrites that also destroy a previous version -- `--force`,
13
+ * and files that differ only in whitespace. So the loss #566 describes
14
+ * was still possible after the refusal landed.
15
+ *
16
+ * These cases assert that every overwrite keeps the previous version,
17
+ * in a `backups/` directory beside the installed hooks (not in the user's
18
+ * work tree), timestamped so a later sync cannot destroy an earlier
19
+ * backup.
20
+ * @covers AC-599-13
21
+ */
22
+ const fs = require('fs');
23
+ const path = require('path');
24
+ const os = require('os');
25
+
26
+ const DESCRIBE_ID = String(Math.random().toString(36).slice(2, 8));
27
+
28
+ describe('doctor --sync-hooks keeps the previous version (#566)', () => {
29
+ let tmpHome;
30
+ let originalHome;
31
+ let originalUserProfile;
32
+
33
+ beforeAll(() => {
34
+ originalHome = process.env.HOME;
35
+ originalUserProfile = process.env.USERPROFILE;
36
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), `xp-gate-566-home-${DESCRIBE_ID}-`));
37
+ process.env.HOME = tmpHome;
38
+ process.env.USERPROFILE = tmpHome;
39
+ });
40
+
41
+ afterAll(() => {
42
+ process.env.HOME = originalHome;
43
+ process.env.USERPROFILE = originalUserProfile;
44
+ fs.rmSync(tmpHome, { recursive: true, force: true });
45
+ });
46
+
47
+ function loadDoctor() {
48
+ return require('../doctor');
49
+ }
50
+
51
+ /**
52
+ * Build a repo-like githooks/ plus an installed hooks dir.
53
+ * @param {{installedPrePush?: string}} [opts] content for the installed pre-push
54
+ */
55
+ function makeLayout(opts = {}) {
56
+ const root = fs.mkdtempSync(path.join(tmpHome, `layout-${DESCRIBE_ID}-`));
57
+ const repoRoot = path.join(root, 'repo');
58
+ const repoHooks = path.join(repoRoot, 'githooks');
59
+ const installedHooks = path.join(root, 'installed-hooks');
60
+ fs.mkdirSync(repoHooks, { recursive: true });
61
+ fs.mkdirSync(installedHooks, { recursive: true });
62
+
63
+ // The canonical hook carries the xp-gate signature doctor looks for.
64
+ fs.writeFileSync(
65
+ path.join(repoHooks, 'pre-push'),
66
+ '#!/usr/bin/env bash\n# XP-Gate Pre-Push Hook\n# Pre-push Hook — 8 gates: Gate 10\necho canonical\n',
67
+ );
68
+ if (opts.installedPrePush !== undefined) {
69
+ fs.writeFileSync(path.join(installedHooks, 'pre-push'), opts.installedPrePush);
70
+ }
71
+ return { root, repoRoot, repoHooks, installedHooks };
72
+ }
73
+
74
+ it('AC-599-13: overwriting an existing hook keeps a copy of the previous version', () => {
75
+ const { repoHooks, installedHooks } = makeLayout({
76
+ installedPrePush: '#!/usr/bin/env bash\n# XP-Gate Pre-Push Hook\n# GHOST LOCAL EDIT\n',
77
+ });
78
+
79
+ const { syncGlobalHooksFromRepo } = loadDoctor();
80
+ const result = syncGlobalHooksFromRepo({ repoHooks, installedHooks });
81
+
82
+ expect(result.synced).toContain('pre-push');
83
+ expect(result.backups.length).toBeGreaterThan(0);
84
+
85
+ const backup = result.backups.find((b) => path.basename(b).startsWith('pre-push.'));
86
+ expect(backup).toBeTruthy();
87
+ expect(fs.readFileSync(backup, 'utf8')).toContain('GHOST LOCAL EDIT');
88
+ });
89
+
90
+ it('AC-599-13: the backup lives beside the installed hooks, not in a work tree', () => {
91
+ const { repoRoot, repoHooks, installedHooks } = makeLayout({
92
+ installedPrePush: '#!/usr/bin/env bash\n# XP-Gate Pre-Push Hook\n# local only\n',
93
+ });
94
+
95
+ const { syncGlobalHooksFromRepo } = loadDoctor();
96
+ syncGlobalHooksFromRepo({ repoHooks, installedHooks });
97
+
98
+ const backupsDir = path.join(installedHooks, 'backups');
99
+ expect(fs.existsSync(backupsDir)).toBe(true);
100
+ // Nothing was written into the repository: a backup must not become the
101
+ // user's problem to clean out of `git status`.
102
+ const repoEntries = fs.readdirSync(repoRoot).sort();
103
+ expect(repoEntries).toEqual(['githooks']);
104
+ });
105
+
106
+ it('AC-599-13: installing into an empty hooks dir creates no backup', () => {
107
+ // Nothing was overwritten, so there is nothing to keep -- a backup directory
108
+ // of empty files would be noise that trains the operator to ignore it.
109
+ const { repoHooks, installedHooks } = makeLayout();
110
+
111
+ const { syncGlobalHooksFromRepo } = loadDoctor();
112
+ const result = syncGlobalHooksFromRepo({ repoHooks, installedHooks });
113
+
114
+ expect(result.synced).toContain('pre-push');
115
+ expect(result.backups).toEqual([]);
116
+ expect(fs.existsSync(path.join(installedHooks, 'backups'))).toBe(false);
117
+ });
118
+
119
+ it('AC-599-13: two successive overwrites keep both previous versions', () => {
120
+ // A timestamped name is the point: the second sync must not destroy the
121
+ // backup the first sync made, or the work it was protecting is lost anyway.
122
+ const { repoHooks, installedHooks } = makeLayout({
123
+ installedPrePush: '#!/usr/bin/env bash\n# XP-Gate Pre-Push Hook\n# EDIT ONE\n',
124
+ });
125
+ const { syncGlobalHooksFromRepo } = loadDoctor();
126
+ syncGlobalHooksFromRepo({ repoHooks, installedHooks });
127
+
128
+ fs.writeFileSync(path.join(installedHooks, 'pre-push'), '#!/usr/bin/env bash\n# XP-Gate Pre-Push Hook\n# EDIT TWO\n');
129
+ const second = syncGlobalHooksFromRepo({ repoHooks, installedHooks });
130
+
131
+ const backupsDir = path.join(installedHooks, 'backups');
132
+ const files = fs.readdirSync(backupsDir).filter((f) => f.startsWith('pre-push.'));
133
+ expect(files.length).toBe(2);
134
+ const contents = files.map((f) => fs.readFileSync(path.join(backupsDir, f), 'utf8'));
135
+ expect(contents.some((c) => c.includes('EDIT ONE'))).toBe(true);
136
+ expect(contents.some((c) => c.includes('EDIT TWO'))).toBe(true);
137
+ expect(second.backups.length).toBeGreaterThan(0);
138
+ });
139
+
140
+ it('AC-599-13: an identical file is not re-copied and leaves no backup', () => {
141
+ const canonical = '#!/usr/bin/env bash\n# XP-Gate Pre-Push Hook\n# Pre-push Hook — 8 gates: Gate 10\necho canonical\n';
142
+ const { repoHooks, installedHooks } = makeLayout({ installedPrePush: canonical });
143
+
144
+ const { syncGlobalHooksFromRepo } = loadDoctor();
145
+ const result = syncGlobalHooksFromRepo({ repoHooks, installedHooks });
146
+
147
+ expect(result.synced).not.toContain('pre-push');
148
+ expect(result.skipped).toContain('pre-push');
149
+ expect(result.backups).toEqual([]);
150
+ });
151
+
152
+ it('AC-599-13: shared lib/ libraries are backed up before being replaced too', () => {
153
+ // The lib/ channel overwrites unconditionally unless it refuses, so it is the
154
+ // channel most likely to erase machine-only hardening.
155
+ const { repoRoot, repoHooks, installedHooks } = makeLayout();
156
+ fs.mkdirSync(path.join(repoHooks, 'lib'), { recursive: true });
157
+ fs.writeFileSync(path.join(repoHooks, 'lib', 'coverage-total.sh'), 'echo canonical-lib\n');
158
+ fs.mkdirSync(path.join(installedHooks, 'lib'), { recursive: true });
159
+ fs.writeFileSync(path.join(installedHooks, 'lib', 'coverage-total.sh'), 'echo LOCAL-LIB-EDIT\n');
160
+
161
+ // Force, because otherwise the divergence guard refuses before the overwrite.
162
+ const { syncGlobalHooksFromRepo } = loadDoctor();
163
+ const result = syncGlobalHooksFromRepo({ repoRoot, repoHooks, installedHooks, force: true });
164
+
165
+ const backup = result.backups.find((b) => path.basename(b).startsWith('coverage-total.sh.'));
166
+ expect(backup).toBeTruthy();
167
+ expect(fs.readFileSync(backup, 'utf8')).toContain('LOCAL-LIB-EDIT');
168
+ });
169
+
170
+ it('AC-599-13: two syncs inside the same millisecond both keep their version', () => {
171
+ // The CI failure that motivated this case: "two successive overwrites keep
172
+ // both previous versions" passed locally and failed on a fast runner with
173
+ // `expected 1 to be 2`. The stamp has millisecond resolution, so when two
174
+ // syncs land in the SAME millisecond they compute the SAME backup name and
175
+ // the second copy silently destroys the first. Local runs usually straddled
176
+ // a millisecond boundary and never saw it.
177
+ //
178
+ // Pin Date so the collision is guaranteed rather than hoped for: asserting a
179
+ // bug that only appears under a timing race is how the suite went green while
180
+ // the defect shipped.
181
+ const { repoHooks, installedHooks } = makeLayout({
182
+ installedPrePush: '#!/usr/bin/env bash\n# XP-Gate Pre-Push Hook\n# SAME-MS-ONE\n',
183
+ });
184
+ const { syncGlobalHooksFromRepo } = loadDoctor();
185
+
186
+ // Freeze ONLY the clock the stamp reads. Nothing else is replaced: an earlier
187
+ // draft overrode Date.prototype.toISOString, which made the backup helper
188
+ // recurse into itself and threw "Maximum call stack size exceeded" -- the
189
+ // case then appeared to fail for a reason that had nothing to do with backups.
190
+ const RealDate = Date;
191
+ const frozen = 1767225600123; // any instant; only that it never advances
192
+ global.Date = class extends RealDate {
193
+ constructor(...args) {
194
+ if (args.length === 0) super(frozen);
195
+ else super(...args);
196
+ }
197
+ static now() {
198
+ return frozen;
199
+ }
200
+ };
201
+ try {
202
+ syncGlobalHooksFromRepo({ repoHooks, installedHooks, force: true });
203
+
204
+ fs.writeFileSync(
205
+ path.join(installedHooks, 'pre-push'),
206
+ '#!/usr/bin/env bash\n# XP-Gate Pre-Push Hook\n# SAME-MS-TWO\n',
207
+ );
208
+ syncGlobalHooksFromRepo({ repoHooks, installedHooks, force: true });
209
+ } finally {
210
+ global.Date = RealDate;
211
+ }
212
+
213
+ const backupsDir = path.join(installedHooks, 'backups');
214
+ const files = fs.readdirSync(backupsDir).filter((f) => f.startsWith('pre-push.'));
215
+ expect(files.length).toBe(2);
216
+
217
+ const contents = files.map((f) => fs.readFileSync(path.join(backupsDir, f), 'utf8'));
218
+ expect(contents.some((c) => c.includes('SAME-MS-ONE'))).toBe(true);
219
+ expect(contents.some((c) => c.includes('SAME-MS-TWO'))).toBe(true);
220
+ });
221
+ });
@@ -0,0 +1,121 @@
1
+ /**
2
+ * @test REQ-604
3
+ * @intent A deploy that overwrites an installed hook must leave a record of what
4
+ * it replaced and which version did it.
5
+ *
6
+ * #583: `xp-gate doctor --sync-hooks` overwrote `~/.config/xp-gate/hooks/
7
+ * pre-commit` three times in one day, each time silently. A downstream
8
+ * that had hardened its hooks (and kept the vendored copy in sync, with a
9
+ * consistency test guarding it) discovered it only by running its own
10
+ * SHA256 comparison. The consequences were not cosmetic: one overwrite
11
+ * reverted a fixed fail-open in Gate 5's coverage arm, so commits started
12
+ * passing on unmeasurable coverage again — a defect that had already been
13
+ * filed, fixed and tested.
14
+ *
15
+ * #566 added the backup, which makes the damaged file recoverable. It
16
+ * does not answer the question the downstream actually has: "was my patch
17
+ * eaten, and by which version?" A directory of timestamped `.bak` files
18
+ * can be read, but only by someone who already suspects an overwrite.
19
+ *
20
+ * So the invariant here is narrow and provable without a network, a
21
+ * registry, or a real install: when sync-hooks overwrites file X, one
22
+ * line is appended to a deploy log naming X, the version that did it, and
23
+ * the backup that holds the previous content. Nothing more.
24
+ *
25
+ * Deliberately NOT in scope (the issue's option 2): refusing to overwrite
26
+ * a locally-modified hook without `--force`. That changes the deploy
27
+ * contract for every consumer and needs a product decision, not a bug fix.
28
+ * @covers AC-604-01, AC-604-02, AC-604-03
29
+ */
30
+
31
+ const fs = require('fs');
32
+ const os = require('os');
33
+ const path = require('path');
34
+
35
+ const { syncGlobalHooksFromRepo } = require('../doctor.js');
36
+
37
+ function writeHook(dir, name, body) {
38
+ const p = path.join(dir, name);
39
+ try {
40
+ fs.mkdirSync(dir, { recursive: true });
41
+ fs.writeFileSync(p, body, { mode: 0o755 });
42
+ } catch (err) {
43
+ // A fixture that cannot create its file must fail the case loudly, not leave
44
+ // a partially-built directory for the assertions to interpret (#583).
45
+ throw new Error(`fixture setup failed writing ${p}: ${err.message}`);
46
+ }
47
+ return p;
48
+ }
49
+
50
+ describe('REQ-604: sync-hooks leaves a deploy record', () => {
51
+ let tmp;
52
+ let repoHooks;
53
+ let installedHooks;
54
+
55
+ beforeEach(() => {
56
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'xp-gate-deploy-log-'));
57
+ repoHooks = path.join(tmp, 'repo', 'githooks');
58
+ installedHooks = path.join(tmp, 'installed', 'hooks');
59
+ fs.mkdirSync(installedHooks, { recursive: true });
60
+ // The banner line is the signature doctor uses to recognise an xp-gate hook.
61
+ writeHook(repoHooks, 'pre-commit', '#!/bin/bash\n# XP-GATE QUALITY GATES v0.31.1.0\necho new\n');
62
+ });
63
+
64
+ afterEach(() => {
65
+ fs.rmSync(tmp, { recursive: true, force: true });
66
+ });
67
+
68
+ it('AC-604-01: an overwrite appends one line naming the file, the version and the backup', () => {
69
+ // The downstream's hardened copy: different from upstream, so it IS replaced.
70
+ writeHook(installedHooks, 'pre-commit', '#!/bin/bash\n# XP-GATE QUALITY GATES v0.30.0.0\n# downstream hardening\necho old\n');
71
+
72
+ const result = syncGlobalHooksFromRepo({ repoHooks, installedHooks, version: '0.31.1.0' });
73
+
74
+ expect(result.errors).toEqual([]);
75
+ expect(result.synced).toContain('pre-commit');
76
+
77
+ const logPath = path.join(installedHooks, 'deploy.log');
78
+ expect(fs.existsSync(logPath)).toBe(true);
79
+
80
+ const lines = fs.readFileSync(logPath, 'utf8').trim().split('\n').filter(Boolean);
81
+ const entry = lines.find((l) => l.includes('pre-commit'));
82
+ expect(entry, `deploy log must name pre-commit, got:\n${lines.join('\n')}`).toBeTruthy();
83
+
84
+ // The three facts the downstream needs, all in that one line: which file,
85
+ // which version replaced it, and where the previous content went.
86
+ expect(entry).toMatch(/pre-commit/);
87
+ expect(entry).toMatch(/0\.31\.1\.0/);
88
+ expect(entry, `names the backup holding the old content, got: ${entry}`).toMatch(/\.bak/);
89
+ });
90
+
91
+ it('AC-604-02: an unchanged file produces no deploy entry', () => {
92
+ // Identical content: sync skips it, so there is nothing to report. A log that
93
+ // records non-events is noise, and noise is what makes real overwrites
94
+ // invisible.
95
+ writeHook(installedHooks, 'pre-commit', '#!/bin/bash\n# XP-GATE QUALITY GATES v0.31.1.0\necho new\n');
96
+
97
+ const result = syncGlobalHooksFromRepo({ repoHooks, installedHooks, version: '0.31.1.0' });
98
+ expect(result.skipped).toContain('pre-commit');
99
+
100
+ const logPath = path.join(installedHooks, 'deploy.log');
101
+ if (fs.existsSync(logPath)) {
102
+ const content = fs.readFileSync(logPath, 'utf8');
103
+ expect(content).not.toContain('pre-commit');
104
+ }
105
+ });
106
+
107
+ it('AC-604-03: repeated overwrites accumulate rather than replacing history', () => {
108
+ // The whole point of the log is answering "how many times, and when" — one
109
+ // day saw three overwrites. Overwriting the log each run would destroy
110
+ // exactly the evidence the downstream needs.
111
+ writeHook(installedHooks, 'pre-commit', '#!/bin/bash\n# XP-GATE QUALITY GATES v0.30.0.0\necho v1\n');
112
+ syncGlobalHooksFromRepo({ repoHooks, installedHooks, version: '0.31.1.0' });
113
+
114
+ writeHook(installedHooks, 'pre-commit', '#!/bin/bash\n# XP-GATE QUALITY GATES v0.30.0.0\necho v1-patched-by-downstream\n');
115
+ syncGlobalHooksFromRepo({ repoHooks, installedHooks, version: '0.31.1.0' });
116
+
117
+ const logPath = path.join(installedHooks, 'deploy.log');
118
+ const lines = fs.readFileSync(logPath, 'utf8').trim().split('\n').filter((l) => l.includes('pre-commit'));
119
+ expect(lines).toHaveLength(2);
120
+ });
121
+ });
@@ -0,0 +1,183 @@
1
+ /**
2
+ * @test REQ-613
3
+ * @intent `xp-gate check . --all` could never run Gate 4 on Windows:
4
+ *
5
+ * Failed to run principles check: The command line is too long.
6
+ *
7
+ * Two causes added together in `lib/principles.js`:
8
+ *
9
+ * 1. `shell: process.platform === 'win32'` routes through cmd.exe, whose
10
+ * command line is capped at 8191 characters (CreateProcess allows
11
+ * 32767).
12
+ * 2. every path is joined into ONE argv element — `files.join(' ')` — so
13
+ * the whole file list becomes part of that command line. Measured in
14
+ * the issue: 135 Java files came to 12944 characters before the
15
+ * `npx -y tsx <entry> --files --format json` prefix was added.
16
+ *
17
+ * This is not "the occasional huge project"; it is a mid-sized project on
18
+ * Windows, every time.
19
+ *
20
+ * Splitting the argv was explicitly ruled out in the issue and the reasoning
21
+ * holds: the bottleneck is total character count, not quoting, and `npx` on
22
+ * Windows still needs `shell: true` to be found at all.
23
+ *
24
+ * The other half of the issue is what the failure is CALLED. "Failed to run
25
+ * principles check" reads like a principles violation. It is not — the tool
26
+ * never ran. #596 fixed the same confusion in the shell toolchain
27
+ * (`classify_tool_failure`); this pins it for the JS side.
28
+ * @covers AC-613-01, AC-613-02, AC-613-03
29
+ */
30
+
31
+ const fs = require('fs');
32
+ const os = require('os');
33
+ const path = require('path');
34
+
35
+ const principles = require('../principles.js');
36
+
37
+ // cmd.exe's command-line ceiling. Named rather than repeated: it is the number the
38
+ // whole issue is about, and a bare 8191 in several places reads as unrelated facts.
39
+ const CMD_EXE_LIMIT = 8191;
40
+
41
+ // The measured case from the issue: 135 Java files => 12944 characters, which blocked
42
+ // every `--all` run on Windows.
43
+ const ISSUE_FILE_COUNT = 135;
44
+
45
+ // Comfortably past the limit, used where the exact count does not matter — only that
46
+ // the list is too long to inline.
47
+ const LARGE_FILE_COUNT = 400;
48
+
49
+ /** A path-only string of roughly `len` characters (the join() payload). */
50
+ function longFileList(count) {
51
+ // Paths must be long enough that `count` of them realistically exceeds the
52
+ // Windows limit; the issue's own measurement was ~90 chars per path.
53
+ return Array.from(
54
+ { length: count },
55
+ (_, i) =>
56
+ `src/main/java/com/example/deeply/nested/module${i}/service/implementation/File${i}ImplementationImpl.java`
57
+ );
58
+ }
59
+
60
+ describe('REQ-613: Gate 4 must not blow the Windows command-line limit', () => {
61
+ it('AC-613-01: the command line for a mid-sized Java project stays under cmd.exe\\u2019s character limit', () => {
62
+ // The measured case from the issue: 135 files, 12944 characters, blocked.
63
+ const files = longFileList(ISSUE_FILE_COUNT);
64
+ const joined = files.join(' ');
65
+ expect(joined.length).toBeGreaterThan(CMD_EXE_LIMIT);
66
+
67
+ const plan = principles.buildPrinciplesInvocation(files, 'json');
68
+ // The contract: whatever is handed to the OS is a single argument list, and the
69
+ // total of that list's characters is what must fit. A plan that still carries the
70
+ // whole list in one argument has not solved anything.
71
+ const longest = Math.max(...plan.args.map((a) => String(a).length));
72
+ expect(longest, `longest single argument is ${longest} chars`).toBeLessThan(CMD_EXE_LIMIT);
73
+ });
74
+
75
+ it('AC-613-01: a large file list is passed by reference, not by value', () => {
76
+ // The fix that the issue recommends first: use the project's own tsx through
77
+ // node.exe (an absolute path, no shim, no shell) and keep the argv bounded. If a
78
+ // future change puts every path back on the command line, this fails.
79
+ const files = longFileList(LARGE_FILE_COUNT);
80
+ const plan = principles.buildPrinciplesInvocation(files, 'json');
81
+
82
+ const argvChars = plan.args.reduce((n, a) => n + String(a).length + 1, 0);
83
+ expect(argvChars, `argv carries ${argvChars} chars`).toBeLessThan(CMD_EXE_LIMIT);
84
+ });
85
+
86
+ it('AC-613-02: the invocation never opts into a shell on any platform', () => {
87
+ // `shell: true` is what routes through cmd.exe and its 8191 limit. It must not be
88
+ // requested at all: the correct target is node.exe, which needs no shim.
89
+ const plan = principles.buildPrinciplesInvocation(['a.ts'], 'json');
90
+ expect(plan.useShell).toBe(false);
91
+ });
92
+
93
+ it('AC-613-02: the plan names an executable that needs no shell shim', () => {
94
+ // Either node.exe (preferred — absolute, no shim) or a resolved tool path. A bare
95
+ // 'npx' string would force the caller back to shell:true on Windows.
96
+ const plan = principles.buildPrinciplesInvocation(['a.ts'], 'json');
97
+ expect(plan.command.length).toBeGreaterThan(0);
98
+ expect(plan.command).not.toBe('npx');
99
+ });
100
+
101
+ it('AC-613-03: a command-line-too-long failure is classified as a toolchain problem, not a finding', () => {
102
+ // The message used to be "Failed to run principles check", which reads as a code
103
+ // verdict. It never was one.
104
+ expect(principles.classifyPrinciplesFailure({ error: new Error('E2BIG') })).toBe('TOOLCHAIN');
105
+ expect(principles.classifyPrinciplesFailure({ error: new Error('spawn ENAMETOOLONG') })).toBe('TOOLCHAIN');
106
+ expect(
107
+ principles.classifyPrinciplesFailure({
108
+ stdout: 'The command line is too long.',
109
+ status: 1,
110
+ })
111
+ ).toBe('TOOLCHAIN');
112
+ });
113
+
114
+ it('AC-613-03: a real principles violation is still classified as a finding', () => {
115
+ // The control. If everything classified as TOOLCHAIN, Gate 4 could never block on
116
+ // an actual violation again — worse than the bug being fixed.
117
+ expect(principles.classifyPrinciplesFailure({ stdout: '1 violations total', status: 1 })).toBe('FINDING');
118
+ expect(principles.classifyPrinciplesFailure({ status: 1 })).toBe('FINDING');
119
+ });
120
+
121
+ it('AC-613-01: a file list that fits is still passed directly', () => {
122
+ // Small lists must not be needlessly diverted; the direct path is well tested and
123
+ // has different failure semantics.
124
+ const plan = principles.buildPrinciplesInvocation(['src/a.ts', 'src/b.ts'], 'json');
125
+ const flat = plan.args.join(' ');
126
+ expect(flat).toContain('src/a.ts');
127
+ expect(flat).toContain('src/b.ts');
128
+ });
129
+
130
+ it('AC-613-01: the by-reference handoff writes the list somewhere the child can read', () => {
131
+ // Sanity on the mechanism itself: if the plan says "by reference", the reference
132
+ // has to be a real file path the child will be given, not a placeholder.
133
+ const files = longFileList(LARGE_FILE_COUNT);
134
+ const plan = principles.buildPrinciplesInvocation(files, 'json');
135
+ const refArg = plan.args.find((a) => String(a).startsWith('@'));
136
+ expect(refArg, 'no @file argument in the plan').toBeTruthy();
137
+ const refPath = refArg.slice(1);
138
+ expect(path.isAbsolute(refPath) || refPath.includes('tmp') || refPath.includes('TEMP') || refPath.toLowerCase().includes('temp')).toBe(true);
139
+ // And the file must not be left behind by merely planning.
140
+ expect(fs.existsSync(refPath) && plan.cleanup === false).toBe(false);
141
+ });
142
+
143
+ it('AC-613-02: the WIRE test \u2014 the call site actually uses the plan it builds', () => {
144
+ // This case exists because the first version of this suite had a hole: every
145
+ // other case asserts on `buildPrinciplesInvocation`'s OUTPUT, and reverting the
146
+ // call site to the old `spawnSync('npx', [...files.join(' ') ...], { shell: true })`
147
+ // left all 8 green. A perfectly correct planner that nobody consults.
148
+ //
149
+ // Intercepting child_process.spawnSync does NOT work here: principles.js
150
+ // destructures `{ spawnSync }` at load time, so replacing the property afterwards
151
+ // never reaches the bound local. (I tried that first and the case failed with
152
+ // "spawnSync was never called" — which is itself a useful demonstration that an
153
+ // indirect assertion can fail for a reason unrelated to the defect.)
154
+ //
155
+ // So this asserts the wiring structurally, plus the one property that is cheap to
156
+ // check at runtime: the module must expose the planner, meaning the call site has
157
+ // something to consume.
158
+ const source = fs.readFileSync(path.join(__dirname, '..', 'principles.js'), 'utf8');
159
+
160
+ // The shell:true on the call site, verbatim.
161
+ expect(source, 'the shell:true spawn is back at the call site').not.toContain(
162
+ "shell: process.platform === 'win32'"
163
+ );
164
+
165
+ // The wiring: spawnSync must be fed the plan, and the plan must control the shell.
166
+ expect(source).toContain('spawnSync(plan.command, plan.args');
167
+ expect(source).toContain('shell: plan.useShell');
168
+
169
+ // The planner bounds the list; the join() is still allowed for SHORT lists inside
170
+ // buildPrinciplesInvocation (that is the correct path for small inputs), but it
171
+ // must never appear in the spawnSync argument position any more.
172
+ const spawnIdx = source.indexOf('spawnSync(plan.command, plan.args');
173
+ const joinAfterSpawn = source.indexOf('files.join(', spawnIdx);
174
+ expect(joinAfterSpawn, 'the spawn call still joins the whole list inline').toBe(-1);
175
+
176
+ // And the planner must really exist and be exported, so the call site cannot be
177
+ // referencing a name that only looks right.
178
+ expect(typeof principles.buildPrinciplesInvocation).toBe('function');
179
+ });
180
+ });
181
+
182
+ // Keep the imports referenced so linting does not flag them when the module grows.
183
+ void os;