xp-gate 0.5.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 (90) hide show
  1. package/adapter-common.sh +192 -0
  2. package/adapters/cpp.sh +76 -0
  3. package/adapters/dart.sh +41 -0
  4. package/adapters/flutter.sh +41 -0
  5. package/adapters/go.sh +59 -0
  6. package/adapters/iac.sh +189 -0
  7. package/adapters/java.sh +191 -0
  8. package/adapters/kotlin.sh +77 -0
  9. package/adapters/objectivec.sh +38 -0
  10. package/adapters/powershell.sh +138 -0
  11. package/adapters/python.sh +104 -0
  12. package/adapters/shell.sh +55 -0
  13. package/adapters/swift.sh +44 -0
  14. package/adapters/typescript.sh +61 -0
  15. package/bin/xp-gate.js +157 -0
  16. package/hooks/adapter-common.sh +192 -0
  17. package/hooks/pre-commit +1667 -0
  18. package/hooks/pre-push +395 -0
  19. package/lib/__tests__/detect-deps.test.js +209 -0
  20. package/lib/__tests__/doctor.test.js +448 -0
  21. package/lib/__tests__/download-skill.test.js +281 -0
  22. package/lib/__tests__/init.test.js +327 -0
  23. package/lib/__tests__/install-skill.test.js +326 -0
  24. package/lib/__tests__/migrate.test.js +212 -0
  25. package/lib/__tests__/rollback.test.js +183 -0
  26. package/lib/__tests__/ui-detector.test.ts +200 -0
  27. package/lib/__tests__/uninstall-skill.test.js +189 -0
  28. package/lib/__tests__/uninstall.test.js +589 -0
  29. package/lib/__tests__/update-skill.test.js +276 -0
  30. package/lib/detect-deps.js +157 -0
  31. package/lib/doctor.js +370 -0
  32. package/lib/download-skill.js +96 -0
  33. package/lib/init.js +367 -0
  34. package/lib/install-skill.js +184 -0
  35. package/lib/migrate.js +120 -0
  36. package/lib/rollback.js +78 -0
  37. package/lib/ui-detector.ts +99 -0
  38. package/lib/uninstall-skill.js +69 -0
  39. package/lib/uninstall.js +401 -0
  40. package/lib/update-skill.js +90 -0
  41. package/package.json +39 -0
  42. package/plugins/claude-code/.claude-plugin/plugin.json +21 -0
  43. package/plugins/claude-code/bin/delphi-review-guard.sh +68 -0
  44. package/plugins/claude-code/bin/xp-gate-check +47 -0
  45. package/plugins/claude-code/hooks/hooks.json +37 -0
  46. package/skills/delphi-review/.delphi-config.json.example +45 -0
  47. package/skills/delphi-review/AGENTS.md +54 -0
  48. package/skills/delphi-review/INSTALL.md +152 -0
  49. package/skills/delphi-review/SKILL.md +371 -0
  50. package/skills/delphi-review/evals/evals.json +82 -0
  51. package/skills/delphi-review/opencode.json.delphi.example +56 -0
  52. package/skills/delphi-review/references/code-walkthrough.md +486 -0
  53. package/skills/ralph-loop/SKILL.md +330 -0
  54. package/skills/ralph-loop/evals/evals.json +311 -0
  55. package/skills/ralph-loop/evolution-history.json +59 -0
  56. package/skills/ralph-loop/evolution-log.md +16 -0
  57. package/skills/ralph-loop/references/components/memory.md +55 -0
  58. package/skills/ralph-loop/references/components/middleware.md +54 -0
  59. package/skills/ralph-loop/references/components/skill-invocations.md +39 -0
  60. package/skills/ralph-loop/references/components/system-prompt.md +24 -0
  61. package/skills/ralph-loop/references/components/tool-descriptions.md +32 -0
  62. package/skills/ralph-loop/references/phase-2-build-ralph.md +89 -0
  63. package/skills/ralph-loop/templates/progress-log.md +36 -0
  64. package/skills/sprint-flow/SKILL.md +600 -0
  65. package/skills/sprint-flow/evals/evals.json +78 -0
  66. package/skills/sprint-flow/evolution-history.json +39 -0
  67. package/skills/sprint-flow/evolution-log.md +23 -0
  68. package/skills/sprint-flow/references/components/memory.md +87 -0
  69. package/skills/sprint-flow/references/components/middleware.md +72 -0
  70. package/skills/sprint-flow/references/components/skill-invocations.md +104 -0
  71. package/skills/sprint-flow/references/components/system-prompt.md +27 -0
  72. package/skills/sprint-flow/references/components/tool-descriptions.md +96 -0
  73. package/skills/sprint-flow/references/phase-0-think.md +115 -0
  74. package/skills/sprint-flow/references/phase-1-plan.md +178 -0
  75. package/skills/sprint-flow/references/phase-2-build.md +198 -0
  76. package/skills/sprint-flow/references/phase-3-review.md +213 -0
  77. package/skills/sprint-flow/references/phase-4-uat.md +125 -0
  78. package/skills/sprint-flow/references/phase-5-feedback.md +100 -0
  79. package/skills/sprint-flow/references/phase-6-ship.md +193 -0
  80. package/skills/sprint-flow/references/phase-7-land.md +140 -0
  81. package/skills/sprint-flow/references/phase-8-cleanup.md +192 -0
  82. package/skills/sprint-flow/templates/emergent-issues-template.md +120 -0
  83. package/skills/sprint-flow/templates/pain-document-template.md +115 -0
  84. package/skills/sprint-flow/templates/sprint-summary-template.md +120 -0
  85. package/skills/test-specification-alignment/AGENTS.md +59 -0
  86. package/skills/test-specification-alignment/SKILL.md +605 -0
  87. package/skills/test-specification-alignment/evals/evals.json +75 -0
  88. package/skills/test-specification-alignment/references/alignment-verification-algorithm.md +493 -0
  89. package/skills/test-specification-alignment/references/phase2-constraint-enforcement.md +431 -0
  90. package/skills/test-specification-alignment/references/specification-format.md +348 -0
@@ -0,0 +1,589 @@
1
+ /**
2
+ * @test REQ-1 xp-gate uninstall
3
+ * @intent Verify uninstall correctly reverses init, handles local/global modes, dry-run, safety, and idempotency
4
+ * @covers AC-01, AC-02, AC-03, AC-04, AC-08, AC-09, AC-11
5
+ */
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+ const os = require('os');
9
+ const childProcess = require('child_process');
10
+ const crypto = require('crypto');
11
+
12
+ describe('uninstall', () => {
13
+ let tmpHome;
14
+ let tmpProject;
15
+ let originalHome;
16
+ let logSpy;
17
+ let warnSpy;
18
+ let errorSpy;
19
+ let execSpy;
20
+
21
+ beforeEach(() => {
22
+ originalHome = process.env.HOME;
23
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'xpgate-uni-'));
24
+ tmpProject = fs.mkdtempSync(path.join(os.tmpdir(), 'xpgate-uni-proj-'));
25
+ process.env.HOME = tmpHome;
26
+ vi.resetModules();
27
+ delete require.cache[require.resolve('../uninstall')];
28
+ delete require.cache[require.resolve('../init')];
29
+ delete require.cache[require.resolve('../detect-deps.js')];
30
+ logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
31
+ warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
32
+ errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
33
+ });
34
+
35
+ afterEach(() => {
36
+ process.env.HOME = originalHome;
37
+ if (tmpHome && fs.existsSync(tmpHome)) {
38
+ fs.rmSync(tmpHome, { recursive: true, force: true });
39
+ }
40
+ if (tmpProject && fs.existsSync(tmpProject)) {
41
+ fs.rmSync(tmpProject, { recursive: true, force: true });
42
+ }
43
+ vi.restoreAllMocks();
44
+ });
45
+
46
+ function configFile() {
47
+ return path.join(tmpHome, '.config', 'xp-gate', 'xp-gate.json');
48
+ }
49
+
50
+ function globalHooksDir() {
51
+ return path.join(tmpHome, '.config', 'xp-gate', 'hooks');
52
+ }
53
+
54
+ function globalAdaptersDir() {
55
+ return path.join(tmpHome, '.config', 'xp-gate', 'adapters');
56
+ }
57
+
58
+ function templateDir() {
59
+ return path.join(tmpHome, '.config', 'opencode', 'git-hooks-template');
60
+ }
61
+
62
+ function projectGitDir() {
63
+ return path.join(tmpProject, '.git');
64
+ }
65
+
66
+ function projectHooksDir() {
67
+ return path.join(tmpProject, '.git', 'hooks');
68
+ }
69
+
70
+ function projectGithooksDir() {
71
+ return path.join(tmpProject, 'githooks');
72
+ }
73
+
74
+ function projectAdaptersDir() {
75
+ return path.join(tmpProject, 'githooks', 'adapters');
76
+ }
77
+
78
+ function backupDir() {
79
+ return path.join(tmpHome, '.config', 'xp-gate', '.uninstall-backup');
80
+ }
81
+
82
+ // Create a fake hook file that contains the xp-gate signature
83
+ function createXpGatePreCommit(dir) {
84
+ fs.mkdirSync(dir, { recursive: true });
85
+ fs.writeFileSync(
86
+ path.join(dir, 'pre-commit'),
87
+ '#!/bin/bash\n# OpenCode Quality Gates - Pre-Commit Hook - Test\n'
88
+ );
89
+ }
90
+
91
+ function createXpGatePrePush(dir) {
92
+ fs.mkdirSync(dir, { recursive: true });
93
+ fs.writeFileSync(
94
+ path.join(dir, 'pre-push'),
95
+ '#!/bin/bash\n# Pre-push Hook - Code Walkthrough Result Validator\n'
96
+ );
97
+ }
98
+
99
+ function createXpGateAdapterCommon(dir) {
100
+ fs.mkdirSync(dir, { recursive: true });
101
+ fs.writeFileSync(
102
+ path.join(dir, 'adapter-common.sh'),
103
+ '#!/usr/bin/env bash\n\n# Common adapter functions\ndetect_project_lang() {\n echo "typescript"\n}\n'
104
+ );
105
+ }
106
+
107
+ function createXpGateAdapterScripts(dir) {
108
+ fs.mkdirSync(path.join(dir, 'adapters'), { recursive: true });
109
+ fs.writeFileSync(
110
+ path.join(dir, 'adapters', 'typescript.sh'),
111
+ '#!/usr/bin/env bash\necho "ts adapter"\n'
112
+ );
113
+ fs.writeFileSync(
114
+ path.join(dir, 'adapters', 'python.sh'),
115
+ '#!/usr/bin/env bash\necho "py adapter"\n'
116
+ );
117
+ }
118
+
119
+ // Create a custom (non-xp-gate) hook file
120
+ function createCustomHook(dir) {
121
+ fs.mkdirSync(dir, { recursive: true });
122
+ fs.writeFileSync(
123
+ path.join(dir, 'pre-commit'),
124
+ '#!/bin/bash\n# My custom hook\n'
125
+ );
126
+ }
127
+
128
+ // Simulate a full local install
129
+ function setupLocalInstall() {
130
+ // Create project .git/hooks with xp-gate hooks
131
+ createXpGatePreCommit(projectHooksDir());
132
+ createXpGatePrePush(projectHooksDir());
133
+
134
+ // Create project githooks/ with adapters
135
+ createXpGateAdapterCommon(projectGithooksDir());
136
+ createXpGateAdapterScripts(projectGithooksDir());
137
+
138
+ // Create template dir
139
+ createXpGatePreCommit(templateDir());
140
+ createXpGatePrePush(templateDir());
141
+ fs.mkdirSync(path.join(templateDir(), 'adapters'), { recursive: true });
142
+
143
+ // Write config
144
+ fs.mkdirSync(path.dirname(configFile()), { recursive: true });
145
+ fs.writeFileSync(
146
+ configFile(),
147
+ JSON.stringify({
148
+ mode: 'local',
149
+ lastInit: new Date().toISOString()
150
+ }, null, 2)
151
+ );
152
+
153
+ return configFile();
154
+ }
155
+
156
+ // Simulate a full global install
157
+ function setupGlobalInstall() {
158
+ // Create global hooks dir
159
+ createXpGatePreCommit(globalHooksDir());
160
+ createXpGatePrePush(globalHooksDir());
161
+
162
+ // Create global adapters dir
163
+ createXpGateAdapterCommon(globalAdaptersDir());
164
+ createXpGateAdapterScripts(globalAdaptersDir());
165
+
166
+ // Create template dir
167
+ createXpGatePreCommit(templateDir());
168
+ createXpGatePrePush(templateDir());
169
+ fs.mkdirSync(path.join(templateDir(), 'adapters'), { recursive: true });
170
+
171
+ // Write config
172
+ fs.mkdirSync(path.dirname(configFile()), { recursive: true });
173
+ fs.writeFileSync(
174
+ configFile(),
175
+ JSON.stringify({
176
+ mode: 'global',
177
+ lastInit: new Date().toISOString()
178
+ }, null, 2)
179
+ );
180
+
181
+ return configFile();
182
+ }
183
+
184
+ function mockExecSuccess() {
185
+ execSpy = vi.spyOn(childProcess, 'execSync').mockImplementation((cmd) => {
186
+ if (cmd === 'git rev-parse --git-dir') {
187
+ return path.join(tmpProject, '.git') + '\n';
188
+ }
189
+ if (cmd.includes('git config --global core.hooksPath')) {
190
+ return '';
191
+ }
192
+ if (cmd.includes('git config --global --unset')) {
193
+ return '';
194
+ }
195
+ return '';
196
+ });
197
+ }
198
+
199
+ function mockExecGlobalHooksPath(expectedPath) {
200
+ execSpy = vi.spyOn(childProcess, 'execSync').mockImplementation((cmd) => {
201
+ if (cmd.includes('git config --global core.hooksPath')) {
202
+ if (cmd.includes('--unset')) {
203
+ return '';
204
+ }
205
+ // For reading the current value
206
+ return expectedPath + '\n';
207
+ }
208
+ if (cmd === 'git rev-parse --git-dir') {
209
+ return path.join(tmpProject, '.git') + '\n';
210
+ }
211
+ return '';
212
+ });
213
+ }
214
+
215
+ function mockExecFail() {
216
+ execSpy = vi.spyOn(childProcess, 'execSync').mockImplementation(() => {
217
+ throw new Error('Command failed');
218
+ });
219
+ }
220
+
221
+ // === AC-01: local mode complete cleanup ===
222
+
223
+ it('AC-01: uninstall in local mode removes hooks, adapters, template dir, and updates config', async () => {
224
+ setupLocalInstall();
225
+ mockExecSuccess();
226
+ const { uninstall } = require('../uninstall');
227
+
228
+ const result = await uninstall([]);
229
+
230
+ expect(result).toBe(0);
231
+
232
+ // Verify hooks removed from .git/hooks/
233
+ expect(fs.existsSync(path.join(projectHooksDir(), 'pre-commit'))).toBe(false);
234
+ expect(fs.existsSync(path.join(projectHooksDir(), 'pre-push'))).toBe(false);
235
+
236
+ // Verify adapters removed from githooks/
237
+ expect(fs.existsSync(path.join(projectAdaptersDir(), 'typescript.sh'))).toBe(false);
238
+ expect(fs.existsSync(path.join(projectAdaptersDir(), 'python.sh'))).toBe(false);
239
+ expect(fs.existsSync(path.join(projectGithooksDir(), 'adapter-common.sh'))).toBe(false);
240
+
241
+ // Verify template dir removed
242
+ expect(fs.existsSync(templateDir())).toBe(false);
243
+
244
+ // Verify config updated to uninstalled
245
+ const cfg = JSON.parse(fs.readFileSync(configFile(), 'utf8'));
246
+ expect(cfg.mode).toBe('uninstalled');
247
+ expect(cfg.uninstalled).toBeDefined();
248
+ });
249
+
250
+ it('AC-01: uninstall returns 0 even when no files exist (graceful)', async () => {
251
+ fs.mkdirSync(path.dirname(configFile()), { recursive: true });
252
+ fs.writeFileSync(configFile(), JSON.stringify({ mode: 'local', lastInit: '2025-01-01' }, null, 2));
253
+ mockExecSuccess();
254
+ const { uninstall } = require('../uninstall');
255
+
256
+ const result = await uninstall([]);
257
+ expect(result).toBe(0);
258
+ });
259
+
260
+ // === AC-02: global mode unset core.hooksPath ===
261
+
262
+ it('AC-02: uninstall in global mode unsets core.hooksPath and removes global dirs', async () => {
263
+ setupGlobalInstall();
264
+ const expectedHooksPath = globalHooksDir();
265
+ mockExecGlobalHooksPath(expectedHooksPath);
266
+ const { uninstall } = require('../uninstall');
267
+
268
+ const result = await uninstall([]);
269
+
270
+ expect(result).toBe(0);
271
+
272
+ // Verify unset was called
273
+ expect(execSpy).toHaveBeenCalledWith(
274
+ expect.stringContaining('git config --global --unset core.hooksPath'),
275
+ expect.any(Object)
276
+ );
277
+
278
+ // Verify global hooks/adapters dirs removed
279
+ expect(fs.existsSync(globalHooksDir())).toBe(false);
280
+ expect(fs.existsSync(globalAdaptersDir())).toBe(false);
281
+
282
+ // Verify template dir removed
283
+ expect(fs.existsSync(templateDir())).toBe(false);
284
+
285
+ // Verify config updated
286
+ const cfg = JSON.parse(fs.readFileSync(configFile(), 'utf8'));
287
+ expect(cfg.mode).toBe('uninstalled');
288
+ expect(cfg.uninstalled).toBeDefined();
289
+ });
290
+
291
+ it('AC-02: uninstall in global mode skips unset when hooksPath does not match', async () => {
292
+ setupGlobalInstall();
293
+ mockExecGlobalHooksPath('/some/other/path');
294
+ const { uninstall } = require('../uninstall');
295
+
296
+ const result = await uninstall([]);
297
+
298
+ expect(result).toBe(0);
299
+
300
+ // Should warn that hooksPath does not match
301
+ expect(warnSpy).toHaveBeenCalledWith(
302
+ expect.stringContaining('does not match')
303
+ );
304
+
305
+ // Should NOT have called unset
306
+ const unsetCalls = execSpy.mock.calls.filter(
307
+ c => c[0].includes('--unset')
308
+ );
309
+ expect(unsetCalls.length).toBe(0);
310
+ });
311
+
312
+ // === AC-03: --dry-run ===
313
+
314
+ it('AC-03: --dry-run does not remove any files', async () => {
315
+ setupLocalInstall();
316
+ mockExecSuccess();
317
+ const { uninstall } = require('../uninstall');
318
+
319
+ const result = await uninstall(['--dry-run']);
320
+
321
+ expect(result).toBe(0);
322
+
323
+ // All files should still exist
324
+ expect(fs.existsSync(path.join(projectHooksDir(), 'pre-commit'))).toBe(true);
325
+ expect(fs.existsSync(path.join(projectHooksDir(), 'pre-push'))).toBe(true);
326
+ expect(fs.existsSync(path.join(projectGithooksDir(), 'adapter-common.sh'))).toBe(true);
327
+ expect(fs.existsSync(path.join(projectAdaptersDir(), 'typescript.sh'))).toBe(true);
328
+ expect(fs.existsSync(templateDir())).toBe(true);
329
+
330
+ // Config should still show active mode
331
+ const cfg = JSON.parse(fs.readFileSync(configFile(), 'utf8'));
332
+ expect(cfg.mode).toBe('local');
333
+
334
+ // Should have printed plan
335
+ expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Dry-run'));
336
+ });
337
+
338
+ // === AC-04: non-xp-gate hooks preserved ===
339
+
340
+ it('AC-04: non-xp-gate hooks files are not deleted', async () => {
341
+ setupLocalInstall();
342
+ // Create a custom hook (must be after setup which creates xp-gate hooks)
343
+ createCustomHook(projectHooksDir());
344
+ mockExecSuccess();
345
+ const { uninstall } = require('../uninstall');
346
+
347
+ const result = await uninstall([]);
348
+
349
+ expect(result).toBe(0);
350
+
351
+ // Custom hook should still exist (no xp-gate signature)
352
+ expect(fs.existsSync(path.join(projectHooksDir(), 'pre-commit'))).toBe(true);
353
+
354
+ // Read it - should be the custom one
355
+ const content = fs.readFileSync(path.join(projectHooksDir(), 'pre-commit'), 'utf8');
356
+ expect(content).toContain('My custom hook');
357
+ });
358
+
359
+ // === AC-08: partial failure detection ===
360
+
361
+ it('AC-08: uninstall detects config file absence and exits cleanly', async () => {
362
+ // No config file at all
363
+ const { uninstall } = require('../uninstall');
364
+
365
+ const result = await uninstall([]);
366
+
367
+ expect(result).toBe(0);
368
+ expect(logSpy).toHaveBeenCalledWith(
369
+ expect.stringContaining('xp-gate installation found')
370
+ );
371
+ });
372
+
373
+ it('AC-08: uninstall continues despite individual file deletion errors', async () => {
374
+ setupLocalInstall();
375
+ mockExecSuccess();
376
+
377
+ // Make one adapter file non-deletable by making it non-existent
378
+ // uninstall should handle this gracefully (skip + warn)
379
+ const { uninstall } = require('../uninstall');
380
+
381
+ const result = await uninstall([]);
382
+ expect(result).toBe(0);
383
+ });
384
+
385
+ // === AC-09: idempotent ===
386
+
387
+ it('AC-09: second uninstall outputs clean message (idempotent)', async () => {
388
+ setupLocalInstall();
389
+ mockExecSuccess();
390
+ const { uninstall } = require('../uninstall');
391
+
392
+ // First uninstall
393
+ await uninstall([]);
394
+
395
+ // Clear log spy
396
+ logSpy.mockClear();
397
+
398
+ // Second uninstall
399
+ const result = await uninstall([]);
400
+
401
+ expect(result).toBe(0);
402
+
403
+ // Should detect already uninstalled state
404
+ expect(logSpy).toHaveBeenCalledWith(
405
+ expect.stringContaining('xp-gate installation found')
406
+ );
407
+ });
408
+
409
+ // === AC-11: manifest sha256 vs signature fallback ===
410
+
411
+ it('AC-11: uninstall uses manifest sha256 when available for verification', async () => {
412
+ setupLocalInstall();
413
+
414
+ // Add manifest to config
415
+ const cfg = JSON.parse(fs.readFileSync(configFile(), 'utf8'));
416
+
417
+ // Calculate actual sha256 of the hooks file
418
+ const hookPath = path.join(projectHooksDir(), 'pre-commit');
419
+ const hookContent = fs.readFileSync(hookPath);
420
+ const sha256 = crypto.createHash('sha256').update(hookContent).digest('hex');
421
+
422
+ cfg.manifest = {
423
+ version: 1,
424
+ files: {
425
+ '.git/hooks/pre-commit': { sha256, size: hookContent.length },
426
+ '.git/hooks/pre-push': {
427
+ sha256: crypto.createHash('sha256')
428
+ .update(fs.readFileSync(path.join(projectHooksDir(), 'pre-push')))
429
+ .digest('hex'),
430
+ size: fs.readFileSync(path.join(projectHooksDir(), 'pre-push')).length
431
+ }
432
+ }
433
+ };
434
+ fs.writeFileSync(configFile(), JSON.stringify(cfg, null, 2));
435
+
436
+ mockExecSuccess();
437
+ const { uninstall } = require('../uninstall');
438
+
439
+ const result = await uninstall([]);
440
+ expect(result).toBe(0);
441
+
442
+ // Files should be removed (matched via sha256)
443
+ expect(fs.existsSync(path.join(projectHooksDir(), 'pre-commit'))).toBe(false);
444
+ });
445
+
446
+ it('AC-11: uninstall falls back to signature detection when manifest sha256 does not match file content', async () => {
447
+ setupLocalInstall();
448
+
449
+ // Add manifest with WRONG sha256
450
+ const cfg = JSON.parse(fs.readFileSync(configFile(), 'utf8'));
451
+ cfg.manifest = {
452
+ version: 1,
453
+ files: {
454
+ '.git/hooks/pre-commit': { sha256: 'deadbeef' + '0'.repeat(56), size: 999 },
455
+ '.git/hooks/pre-push': { sha256: 'deadbeef' + '0'.repeat(56), size: 999 }
456
+ }
457
+ };
458
+ fs.writeFileSync(configFile(), JSON.stringify(cfg, null, 2));
459
+
460
+ mockExecSuccess();
461
+ const { uninstall } = require('../uninstall');
462
+
463
+ const result = await uninstall([]);
464
+ expect(result).toBe(0);
465
+
466
+ // Files should still be removed (fell back to signature detection)
467
+ expect(fs.existsSync(path.join(projectHooksDir(), 'pre-commit'))).toBe(false);
468
+ expect(fs.existsSync(path.join(projectHooksDir(), 'pre-push'))).toBe(false);
469
+ });
470
+
471
+ it('AC-11: manifest fallback to signature logs warning about sha256 mismatch', async () => {
472
+ setupLocalInstall();
473
+
474
+ const cfg = JSON.parse(fs.readFileSync(configFile(), 'utf8'));
475
+ cfg.manifest = {
476
+ version: 1,
477
+ files: {
478
+ '.git/hooks/pre-commit': { sha256: 'wrongsha256' + '0'.repeat(48), size: 999 }
479
+ }
480
+ };
481
+ fs.writeFileSync(configFile(), JSON.stringify(cfg, null, 2));
482
+
483
+ mockExecSuccess();
484
+ const { uninstall } = require('../uninstall');
485
+
486
+ await uninstall([]);
487
+
488
+ // Should warn about manifest sha256 mismatch and fallback
489
+ expect(warnSpy).toHaveBeenCalledWith(
490
+ expect.stringContaining('sha256')
491
+ );
492
+ });
493
+
494
+ // === Edge cases ===
495
+
496
+ it('returns 0 when config does not exist', async () => {
497
+ const { uninstall } = require('../uninstall');
498
+ const result = await uninstall([]);
499
+ expect(result).toBe(0);
500
+ expect(logSpy).toHaveBeenCalledWith(
501
+ expect.stringContaining('xp-gate installation found')
502
+ );
503
+ });
504
+
505
+ it('skips deletion when file signature does not match', async () => {
506
+ setupLocalInstall();
507
+
508
+ // Replace the pre-commit with something that lacks the signature
509
+ fs.writeFileSync(
510
+ path.join(projectHooksDir(), 'pre-commit'),
511
+ '#!/bin/bash\necho "custom hook"\n'
512
+ );
513
+ // But keep pre-push as xp-gate
514
+
515
+ mockExecSuccess();
516
+ const { uninstall } = require('../uninstall');
517
+
518
+ const result = await uninstall([]);
519
+ expect(result).toBe(0);
520
+
521
+ // Customized pre-commit should remain (no signature match)
522
+ expect(fs.existsSync(path.join(projectHooksDir(), 'pre-commit'))).toBe(true);
523
+ // Pre-push should have been removed
524
+ expect(fs.existsSync(path.join(projectHooksDir(), 'pre-push'))).toBe(false);
525
+ });
526
+
527
+ it('handles --force flag to skip confirmation', async () => {
528
+ setupLocalInstall();
529
+ mockExecSuccess();
530
+ const { uninstall } = require('../uninstall');
531
+
532
+ const result = await uninstall(['--force']);
533
+ expect(result).toBe(0);
534
+
535
+ expect(fs.existsSync(path.join(projectHooksDir(), 'pre-commit'))).toBe(false);
536
+ });
537
+
538
+ it('handles --local flag to override auto-detection', async () => {
539
+ // Set config to global but pass --local
540
+ setupGlobalInstall();
541
+ mockExecSuccess();
542
+ const { uninstall } = require('../uninstall');
543
+
544
+ const result = await uninstall(['--local']);
545
+ expect(result).toBe(0);
546
+
547
+ // Should have tried to look at project hooks (based on local mode)
548
+ // Since no git dir in this context, should still work gracefully
549
+ const cfg = JSON.parse(fs.readFileSync(configFile(), 'utf8'));
550
+ expect(cfg.mode).toBe('uninstalled');
551
+ });
552
+
553
+ it('handles --global flag to override auto-detection', async () => {
554
+ setupLocalInstall();
555
+ mockExecGlobalHooksPath(globalHooksDir());
556
+ const { uninstall } = require('../uninstall');
557
+
558
+ const result = await uninstall(['--global']);
559
+ expect(result).toBe(0);
560
+
561
+ // Should have attempted global cleanup even though config says local
562
+ const cfg = JSON.parse(fs.readFileSync(configFile(), 'utf8'));
563
+ expect(cfg.mode).toBe('uninstalled');
564
+ });
565
+
566
+ it('prints plan summary before execution', async () => {
567
+ setupLocalInstall();
568
+ mockExecSuccess();
569
+ const { uninstall } = require('../uninstall');
570
+
571
+ await uninstall(['--dry-run']);
572
+
573
+ // Should list what it would do
574
+ expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('pre-commit'));
575
+ expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('pre-push'));
576
+ expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('adapter-common.sh'));
577
+ });
578
+
579
+ it('saves rollback snapshot before destructive operations', async () => {
580
+ setupLocalInstall();
581
+ mockExecSuccess();
582
+ const { uninstall } = require('../uninstall');
583
+
584
+ await uninstall([]);
585
+
586
+ // After successful uninstall, backup should be cleaned up
587
+ expect(fs.existsSync(backupDir())).toBe(false);
588
+ });
589
+ });