@boyingliu01/xp-gate 0.35.24 → 0.36.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 (68) hide show
  1. package/adapter-common.sh +55 -2
  2. package/adapters/csharp.sh +245 -0
  3. package/adapters/gate-3.sh +1 -1
  4. package/adapters/gate-4.sh +1 -1
  5. package/adapters/gate-9.sh +37 -2
  6. package/adapters/iac.sh +45 -5
  7. package/bin/xp-gate.js +18 -11
  8. package/gate-10.sh +18 -3
  9. package/gate-13-compiler-strictness.sh +181 -0
  10. package/gate-3.sh +1 -1
  11. package/gate-4.sh +1 -1
  12. package/gate-9.sh +37 -2
  13. package/gates/common.ts +9 -2
  14. package/hooks/adapter-common.sh +55 -2
  15. package/hooks/gate-10.sh +18 -3
  16. package/hooks/gate-13-compiler-strictness.sh +181 -0
  17. package/hooks/gate-3.sh +1 -1
  18. package/hooks/gate-4.sh +1 -1
  19. package/hooks/gate-9.sh +37 -2
  20. package/hooks/gate-registry.sh +161 -0
  21. package/hooks/lib/test-failure.sh +29 -4
  22. package/hooks/pre-commit +122 -3
  23. package/lib/__tests__/agent-runtime-journal.test.js +42 -18
  24. package/lib/__tests__/check-single-file.test.js +55 -4
  25. package/lib/__tests__/detect-deps-dsh-profile.test.js +43 -0
  26. package/lib/__tests__/doctor-delphi.test.js +175 -0
  27. package/lib/__tests__/install-skill-full-dir.test.js +45 -2
  28. package/lib/__tests__/install-skill.test.js +84 -3
  29. package/lib/__tests__/mirror-sync.test.js +23 -5
  30. package/lib/__tests__/uninstall-global-residue.test.js +192 -28
  31. package/lib/__tests__/update-skill-flags.test.js +78 -0
  32. package/lib/__tests__/update-skill.test.js +156 -3
  33. package/lib/detect-deps.js +24 -0
  34. package/lib/doctor.js +80 -12
  35. package/lib/install-skill.js +67 -18
  36. package/lib/mattpocock-license-verdict.json +1 -1
  37. package/lib/uninstall.js +96 -2
  38. package/lib/update-skill.js +143 -2
  39. package/mock-policy/AGENTS.md +4 -4
  40. package/mutation/AGENTS.md +4 -4
  41. package/package.json +1 -1
  42. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  43. package/plugins/claude-code/skills/delphi-review/AGENTS.md +4 -4
  44. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +4 -4
  45. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +4 -4
  46. package/plugins/dsh/lib/index.d.ts.map +1 -1
  47. package/plugins/dsh/lib/index.js +7 -4
  48. package/plugins/dsh/package.json +1 -1
  49. package/plugins/dsh/skills/delphi-review/AGENTS.md +4 -4
  50. package/plugins/dsh/skills/sprint-flow/AGENTS.md +4 -4
  51. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +4 -4
  52. package/plugins/dsh/src/index.test.ts +25 -0
  53. package/plugins/dsh/src/index.ts +7 -4
  54. package/plugins/opencode/package.json +1 -1
  55. package/plugins/opencode/skills/delphi-review/AGENTS.md +4 -4
  56. package/plugins/opencode/skills/sprint-flow/AGENTS.md +4 -4
  57. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +4 -4
  58. package/plugins/qoder/plugin.json +1 -1
  59. package/plugins/qoder/skills/delphi-review/AGENTS.md +4 -4
  60. package/plugins/qoder/skills/sprint-flow/AGENTS.md +4 -4
  61. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +4 -4
  62. package/principles/AGENTS.md +4 -4
  63. package/principles/adapters/csharp.ts +309 -0
  64. package/principles/analyzer.ts +5 -0
  65. package/scripts/delphi-external-review.cjs +130 -101
  66. package/skills/delphi-review/AGENTS.md +4 -4
  67. package/skills/sprint-flow/AGENTS.md +4 -4
  68. package/skills/test-specification-alignment/AGENTS.md +4 -4
@@ -451,3 +451,178 @@ describe('doctor delphi #684 global config fallback', () => {
451
451
  expect(cfgLine).not.toMatch(/global/i);
452
452
  });
453
453
  });
454
+
455
+ describe('doctor delphi #725 multi-platform skill parity', () => {
456
+ let tmpDirs;
457
+ let tmpHome;
458
+ let originalHome;
459
+ let originalUserProfile;
460
+
461
+ beforeEach(() => {
462
+ tmpDirs = makeTempDirs(['doctor-725-home']);
463
+ tmpHome = tmpDirs['doctor-725-home'];
464
+ originalHome = process.env.HOME;
465
+ originalUserProfile = process.env.USERPROFILE;
466
+ process.env.HOME = tmpHome;
467
+ process.env.USERPROFILE = tmpHome;
468
+ resetLibModules(vi, LIB_MODULES);
469
+ muteConsole(vi);
470
+ });
471
+
472
+ afterEach(() => {
473
+ if (originalHome === undefined) delete process.env.HOME; else process.env.HOME = originalHome;
474
+ if (originalUserProfile === undefined) delete process.env.USERPROFILE;
475
+ else process.env.USERPROFILE = originalUserProfile;
476
+ removeTempDirs(tmpDirs);
477
+ vi.restoreAllMocks();
478
+ });
479
+
480
+ /** Copy the shipped bundle into \`<home>/<rel>/delphi-review\`. */
481
+ function deploySkillAt(rel, mutate) {
482
+ const dest = path.join(tmpHome, rel, 'delphi-review');
483
+ fs.mkdirSync(dest, { recursive: true });
484
+ const src = bundledSkillDir();
485
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
486
+ const from = path.join(src, entry.name);
487
+ const to = path.join(dest, entry.name);
488
+ if (entry.isDirectory()) {
489
+ fs.mkdirSync(to, { recursive: true });
490
+ for (const inner of fs.readdirSync(from)) {
491
+ if (fs.statSync(path.join(from, inner)).isFile()) {
492
+ fs.copyFileSync(path.join(from, inner), path.join(to, inner));
493
+ }
494
+ }
495
+ } else {
496
+ fs.copyFileSync(from, to);
497
+ }
498
+ }
499
+ if (mutate) mutate(dest);
500
+ return dest;
501
+ }
502
+
503
+ /** Minimal agent set so only skill parity is under test. */
504
+ function seedAgents() {
505
+ fs.mkdirSync(path.join(tmpHome, '.qoder', 'agents'), { recursive: true });
506
+ for (const role of ['architecture', 'technical', 'feasibility']) {
507
+ fs.copyFileSync(
508
+ path.join(repoRoot(), 'plugins', 'qoder', 'agents', `delphi-${role}.md`),
509
+ path.join(tmpHome, '.qoder', 'agents', `delphi-${role}.md`),
510
+ );
511
+ }
512
+ }
513
+
514
+ function captureResults() {
515
+ const lines = [];
516
+ const spy = vi.spyOn(console, 'log').mockImplementation((...args) => lines.push(args.join(' ')));
517
+ return { lines, spy };
518
+ }
519
+
520
+ /**
521
+ * @test REQ-725 Multi-platform skill parity
522
+ * @intent A second platform whose copy drifted must fail the parity check instead of being invisible
523
+ * @covers AC-725-01
524
+ */
525
+ it('AC-725-01: a drifted OpenCode copy FAILs parity even though Qoder is clean', async () => {
526
+ seedAgents();
527
+ deploySkillAt(path.join('.qoder', 'skills'));
528
+ // Qoder is selected by detectPlatform(), so this is the copy the check used
529
+ // to look at -- and it is clean. The OpenCode copy is the one that rotted.
530
+ deploySkillAt(path.join('.config', 'opencode', 'skills'), (dest) => {
531
+ fs.writeFileSync(path.join(dest, 'AGENTS.md'), '# stale copy from 0.32.0.0\n');
532
+ });
533
+ fs.mkdirSync(path.join(tmpHome, '.config', 'xp-gate'), { recursive: true });
534
+ fs.writeFileSync(
535
+ path.join(tmpHome, '.config', 'xp-gate', 'xp-gate.json'),
536
+ JSON.stringify({ installedSkills: {} }),
537
+ );
538
+
539
+ const { doctorDelphi } = require('../doctor');
540
+ const { lines, spy } = captureResults();
541
+ await doctorDelphi({ skipLatestProbe: true });
542
+ spy.mockRestore();
543
+
544
+ const parity = lines.filter((l) => /parity/i.test(l)).join('\n');
545
+ expect(parity, 'a stale second-platform copy must be reported').toMatch(/✗/);
546
+ expect(parity).toMatch(/opencode/i);
547
+ });
548
+
549
+ /**
550
+ * @test REQ-725 Multi-platform skill parity
551
+ * @intent Both platforms matching must still PASS, and say how many were compared
552
+ * @covers AC-725-02
553
+ */
554
+ it('AC-725-02: two clean platform copies PASS together', async () => {
555
+ seedAgents();
556
+ deploySkillAt(path.join('.qoder', 'skills'));
557
+ deploySkillAt(path.join('.config', 'opencode', 'skills'));
558
+ fs.mkdirSync(path.join(tmpHome, '.config', 'xp-gate'), { recursive: true });
559
+ fs.writeFileSync(
560
+ path.join(tmpHome, '.config', 'xp-gate', 'xp-gate.json'),
561
+ JSON.stringify({ installedSkills: {} }),
562
+ );
563
+
564
+ const { doctorDelphi } = require('../doctor');
565
+ const { lines, spy } = captureResults();
566
+ await doctorDelphi({ skipLatestProbe: true });
567
+ spy.mockRestore();
568
+
569
+ const parity = lines.filter((l) => /parity/i.test(l)).join('\n');
570
+ expect(parity).toMatch(/✓/);
571
+ expect(parity).toMatch(/2 platform/i);
572
+ });
573
+
574
+ /**
575
+ * @test REQ-725 Multi-platform skill parity
576
+ * @intent dsh is a first-class deploy target but detectPlatform() never returns
577
+ * it, so a drifted dsh copy must fail parity exactly like opencode
578
+ * @covers AC-725-01
579
+ */
580
+ it('AC-725-01: a drifted dsh copy FAILs parity even though Qoder is clean', async () => {
581
+ seedAgents();
582
+ deploySkillAt(path.join('.qoder', 'skills'));
583
+ deploySkillAt(path.join('.dsh', 'skills'), (dest) => {
584
+ fs.writeFileSync(path.join(dest, 'AGENTS.md'), '# stale dsh copy\n');
585
+ });
586
+ fs.mkdirSync(path.join(tmpHome, '.config', 'xp-gate'), { recursive: true });
587
+ fs.writeFileSync(
588
+ path.join(tmpHome, '.config', 'xp-gate', 'xp-gate.json'),
589
+ JSON.stringify({ installedSkills: {} }),
590
+ );
591
+
592
+ const { doctorDelphi } = require('../doctor');
593
+ const { lines, spy } = captureResults();
594
+ await doctorDelphi({ skipLatestProbe: true });
595
+ spy.mockRestore();
596
+
597
+ const parity = lines.filter((l) => /parity/i.test(l)).join('\n');
598
+ expect(parity, 'a stale dsh copy must be reported').toMatch(/✗/);
599
+ expect(parity).toMatch(/dsh/i);
600
+ });
601
+
602
+ /**
603
+ * @test REQ-725 Multi-platform skill parity
604
+ * @intent a machine whose only installed agent is dsh has no marker detectPlatform()
605
+ * keys on, so the opencode fallback falsely FAILs "not installed" even though
606
+ * the skill IS deployed under ~/.dsh/skills
607
+ * @covers AC-725-01
608
+ */
609
+ it('AC-725-01: a dsh-only machine PASSes parity instead of "not installed at opencode"', async () => {
610
+ // No seedAgents(): keep .qoder/.claude/.config-opencode absent so detectPlatform()
611
+ // falls back to opencode while dsh is the only live tree.
612
+ deploySkillAt(path.join('.dsh', 'skills'));
613
+ fs.mkdirSync(path.join(tmpHome, '.config', 'xp-gate'), { recursive: true });
614
+ fs.writeFileSync(
615
+ path.join(tmpHome, '.config', 'xp-gate', 'xp-gate.json'),
616
+ JSON.stringify({ installedSkills: {} }),
617
+ );
618
+
619
+ const { doctorDelphi } = require('../doctor');
620
+ const { lines, spy } = captureResults();
621
+ await doctorDelphi({ skipLatestProbe: true });
622
+ spy.mockRestore();
623
+
624
+ const parity = lines.filter((l) => /parity/i.test(l)).join('\n');
625
+ expect(parity, 'a deployed dsh copy must be found and verified').toMatch(/✓/);
626
+ expect(parity).not.toMatch(/not installed/i);
627
+ });
628
+ });
@@ -102,6 +102,11 @@ describe('install-skill full skill directory (#416)', () => {
102
102
  it('updates the skills directory of the platform actually in use', async () => {
103
103
  // update-skill used to hardcode the opencode path while install-skill wrote
104
104
  // to the platform dir, so on Qoder it deleted nothing (or the wrong copy).
105
+ //
106
+ // #725 changed the other half of this: a platform that ALSO has the skill
107
+ // deployed is updated too, because leaving it behind froze it at an old
108
+ // version while the command reported success. Both trees below are live, so
109
+ // both are updated -- "untouched" is no longer the expected end state.
105
110
  for (const name of ['superpowers', 'gstack']) {
106
111
  const dir = path.join(tmpHome, '.qoder', 'skills', name);
107
112
  fs.mkdirSync(dir, { recursive: true });
@@ -125,9 +130,47 @@ describe('install-skill full skill directory (#416)', () => {
125
130
  const { updateSkill } = require('../update-skill');
126
131
  expect(await updateSkill('ralph-loop', {})).toBe(0);
127
132
 
133
+ const bundled = listFiles(path.join(BUNDLED_SKILLS, 'ralph-loop'));
134
+
135
+ // qoder keeps its #416 guarantee: stale files removed, full bundle written.
128
136
  expect(fs.existsSync(path.join(qoderSkills, 'obsolete.md'))).toBe(false);
129
- expect(listFiles(qoderSkills)).toEqual(listFiles(path.join(BUNDLED_SKILLS, 'ralph-loop')));
130
- expect(fs.readFileSync(path.join(opencodeSkills, 'SKILL.md'), 'utf8')).toBe('untouched');
137
+ expect(listFiles(qoderSkills)).toEqual(bundled);
138
+
139
+ // #725: the opencode copy is a second live deployment, so it is updated too
140
+ // rather than silently frozen at `untouched`.
141
+ expect(listFiles(opencodeSkills)).toEqual(bundled);
142
+ });
143
+
144
+ it('updates a skill deployed under ~/.dsh too (#725 AC-725-06)', async () => {
145
+ // Qoder keeps the dep gate green AND is the platform detectPlatform() picks.
146
+ for (const name of ['superpowers', 'gstack']) {
147
+ const dir = path.join(tmpHome, '.qoder', 'skills', name);
148
+ fs.mkdirSync(dir, { recursive: true });
149
+ fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ version: '2.0.0' }));
150
+ }
151
+ const qoderSkills = path.join(tmpHome, '.qoder', 'skills', 'ralph-loop');
152
+ fs.mkdirSync(qoderSkills, { recursive: true });
153
+ fs.writeFileSync(path.join(qoderSkills, 'SKILL.md'), '# old qoder copy');
154
+
155
+ // DSH is a live second deployment that detectPlatform() never returns.
156
+ const dshSkills = path.join(tmpHome, '.dsh', 'skills', 'ralph-loop');
157
+ fs.mkdirSync(dshSkills, { recursive: true });
158
+ fs.writeFileSync(path.join(dshSkills, 'SKILL.md'), '# old dsh copy');
159
+ fs.writeFileSync(path.join(dshSkills, 'obsolete.md'), 'stale');
160
+
161
+ const configDir = path.join(tmpHome, '.config', 'xp-gate');
162
+ fs.mkdirSync(configDir, { recursive: true });
163
+ fs.writeFileSync(
164
+ path.join(configDir, 'xp-gate.json'),
165
+ JSON.stringify({ installedSkills: { 'ralph-loop': { version: '0.0.1' } } })
166
+ );
167
+
168
+ const { updateSkill } = require('../update-skill');
169
+ expect(await updateSkill('ralph-loop', {})).toBe(0);
170
+
171
+ const bundled = listFiles(path.join(BUNDLED_SKILLS, 'ralph-loop'));
172
+ expect(listFiles(dshSkills)).toEqual(bundled);
173
+ expect(fs.existsSync(path.join(dshSkills, 'obsolete.md'))).toBe(false);
131
174
  });
132
175
 
133
176
  it('keeps the installed directory when the package no longer bundles the skill', async () => {
@@ -1,7 +1,9 @@
1
1
  /**
2
- * @test install-skill
3
- * @intent Verify installSkill() handles deps check, bundle lookup, config, backup, and errors
4
- * @covers AC-416-01, AC-416-05
2
+ * @test REQ-416, REQ-725
3
+ * @intent Verify installSkill() handles deps check, bundle lookup, config, backup, and errors,
4
+ * and that it writes EVERY platform already carrying the skill rather than only the
5
+ * one detectPlatform() short-circuits to.
6
+ * @covers AC-416-01, AC-416-05, AC-725-06
5
7
  */
6
8
  const fs = require('fs');
7
9
  const path = require('path');
@@ -332,4 +334,83 @@ describe('install-skill #713 CLI version reporting', () => {
332
334
  expect(said).not.toContain('0.0.0');
333
335
  expect(said).toContain(`xp-gate ${pkg.version} bundles:`);
334
336
  });
337
+
338
+ /**
339
+ * AC-725-06: a machine carrying the same skill on more than one platform must
340
+ * have every copy updated. `detectPlatform()` short-circuits to the first
341
+ * marker it finds, so the single-target path froze the other copy while
342
+ * reporting success -- and `doctor` then agreed, because both read the same
343
+ * short-circuited answer. Migration-era machines with Qoder AND OpenCode are
344
+ * the normal case, not an edge one.
345
+ */
346
+ it('AC-725-06: updates every deployed platform, not just the detected one', async () => {
347
+ // Two platforms carry the skill; detectPlatform() would only ever see one.
348
+ const qoderSkills = path.join(fx.tmpHome, '.qoder', 'skills');
349
+ const opencodeSkills = path.join(fx.tmpHome, '.config', 'opencode', 'skills');
350
+ const SKILL = 'sprint-flow';
351
+ for (const root of [qoderSkills, opencodeSkills]) {
352
+ fs.mkdirSync(path.join(root, SKILL), { recursive: true });
353
+ fs.writeFileSync(path.join(root, SKILL, 'SKILL.md'), '# stale copy\n');
354
+ }
355
+
356
+ const detectPath = require.resolve('../detect-deps.js');
357
+ require.cache[detectPath] = {
358
+ id: detectPath,
359
+ filename: detectPath,
360
+ loaded: true,
361
+ exports: {
362
+ ...require('../detect-deps.js'),
363
+ checkDeps: async () => ({ ok: true }),
364
+ },
365
+ };
366
+
367
+ const { installSkill } = require('../install-skill');
368
+ const code = await installSkill(SKILL, { force: true });
369
+ expect(code).toBe(0);
370
+
371
+ // Both copies must now carry the shipped content, not the stale fixture.
372
+ for (const root of [qoderSkills, opencodeSkills]) {
373
+ const installed = path.join(root, SKILL, 'SKILL.md');
374
+ expect(fs.existsSync(installed)).toBe(true);
375
+ const body = fs.readFileSync(installed, 'utf8');
376
+ expect(body).not.toBe('# stale copy\n');
377
+ expect(body.length).toBeGreaterThan(100);
378
+ }
379
+ });
380
+
381
+ it('AC-725-06: a platform whose copy cannot be read does not abort the others', async () => {
382
+ // Qoder's skills root is a FILE, so readdir throws ENOTDIR for it. That
383
+ // platform must be skipped, and OpenCode -- which is readable -- must still
384
+ // be updated. The prior single-target code never reached this case because
385
+ // detectPlatform() would have picked one platform and ignored the other.
386
+ const qoderSkills = path.join(fx.tmpHome, '.qoder', 'skills');
387
+ const opencodeSkills = path.join(fx.tmpHome, '.config', 'opencode', 'skills');
388
+ const SKILL = 'sprint-flow';
389
+ fs.mkdirSync(path.dirname(qoderSkills), { recursive: true });
390
+ fs.writeFileSync(qoderSkills, 'not a directory');
391
+ fs.mkdirSync(path.join(opencodeSkills, SKILL), { recursive: true });
392
+ fs.writeFileSync(path.join(opencodeSkills, SKILL, 'SKILL.md'), '# stale copy\n');
393
+
394
+ const detectPath = require.resolve('../detect-deps.js');
395
+ require.cache[detectPath] = {
396
+ id: detectPath,
397
+ filename: detectPath,
398
+ loaded: true,
399
+ exports: {
400
+ ...require('../detect-deps.js'),
401
+ checkDeps: async () => ({ ok: true }),
402
+ },
403
+ };
404
+
405
+ const { installSkill } = require('../install-skill');
406
+ const code = await installSkill(SKILL, { force: true });
407
+
408
+ // The readable platform must still have been written.
409
+ const healthy = path.join(opencodeSkills, SKILL, 'SKILL.md');
410
+ expect(fs.existsSync(healthy)).toBe(true);
411
+ expect(fs.readFileSync(healthy, 'utf8')).not.toBe('# stale copy\n');
412
+ // The unreadable root must not have been silently "updated" into a file.
413
+ expect(fs.statSync(qoderSkills).isFile()).toBe(true);
414
+ expect(code).toBe(0);
415
+ });
335
416
  });
@@ -49,8 +49,15 @@ afterEach(() => {
49
49
  */
50
50
  function write(root, rel, content) {
51
51
  const full = path.join(root, rel);
52
- fs.mkdirSync(path.dirname(full), { recursive: true });
53
- fs.writeFileSync(full, content);
52
+ try {
53
+ fs.mkdirSync(path.dirname(full), { recursive: true });
54
+ fs.writeFileSync(full, content);
55
+ } catch (err) {
56
+ // Swallowing this would turn a broken fixture into a confusing assertion
57
+ // failure further down ("expected undefined"), so rethrow with the path that
58
+ // actually failed. The rule wants the IO handled, not hidden.
59
+ throw new Error(`failed to write fixture ${rel}: ${err instanceof Error ? err.message : String(err)}`);
60
+ }
54
61
  }
55
62
 
56
63
  /**
@@ -264,13 +271,24 @@ describe('syncMirrors — AC-713-04 safety', () => {
264
271
  it('preserves the executable bit so hooks stay runnable', () => {
265
272
  write(TMP_REPO, 'canonical/hook.sh', '#!/bin/sh\necho a\n');
266
273
  fs.chmodSync(path.join(TMP_REPO, 'canonical/hook.sh'), 0o755);
267
- write(TMP_REPO, 'mirror/hook.sh', '#!/bin/sh\necho a\n');
274
+ // The mirror must start as *drift*. The sync loop only touches files that
275
+ // are `missing` or `differing`, so identical content is skipped and the mode
276
+ // never gets rewritten. An earlier version gave both sides the same bytes,
277
+ // so the file never entered `toWrite`; on Linux the assertion then failed
278
+ // ("expected +0 to be truthy") while Windows skipped the whole block, which
279
+ // is how it stayed invisible locally. The test was measuring nothing.
280
+ write(TMP_REPO, 'mirror/hook.sh', '#!/bin/sh\necho stale\n');
268
281
  trackAll(TMP_REPO);
269
282
 
270
- syncMirrors(TMP_REPO, [{ canonical: 'canonical', mirror: 'mirror' }]);
271
- const mode = fs.statSync(path.join(TMP_REPO, 'mirror/hook.sh')).mode;
283
+ const result = syncMirrors(TMP_REPO, [{ canonical: 'canonical', mirror: 'mirror' }]);
284
+ // Prove the copy actually happened, so a green result cannot come from
285
+ // "nothing was touched".
286
+ expect(result.copied).toContain('hook.sh');
287
+ expect(fs.readFileSync(path.join(TMP_REPO, 'mirror/hook.sh'), 'utf8')).toBe('#!/bin/sh\necho a\n');
288
+
272
289
  // On Windows the exec bit is not meaningful; only assert where it applies.
273
290
  if (process.platform !== 'win32') {
291
+ const mode = fs.statSync(path.join(TMP_REPO, 'mirror/hook.sh')).mode;
274
292
  expect(mode & 0o111).toBeTruthy();
275
293
  }
276
294
  });
@@ -76,6 +76,40 @@ function seedGlobalInstall() {
76
76
  fs.writeFileSync(path.join(configDir, '.delphi-config.json'), '{}\n');
77
77
  }
78
78
 
79
+ /**
80
+ * A REAL setup-global layout at an arbitrary config dir.
81
+ *
82
+ * The three manifest-ish files carry the content `setup-global` actually writes,
83
+ * so a signature check has to succeed or fail on realistic input rather than on
84
+ * `{}`. Shared by every block below -- the per-describe copies were identical
85
+ * and tripped the clone detector.
86
+ *
87
+ * @param {string} configDir the ~/.config/xp-gate directory to populate
88
+ */
89
+ function seedRealisticLayout(configDir) {
90
+ for (const rel of ['hooks', 'adapters', 'modules/principles', 'node_modules/js-yaml']) {
91
+ fs.mkdirSync(path.join(configDir, rel), { recursive: true });
92
+ fs.writeFileSync(path.join(configDir, rel, 'index.js'), 'module.exports = {};\n');
93
+ }
94
+ fs.writeFileSync(
95
+ path.join(configDir, 'package.json'),
96
+ JSON.stringify({ dependencies: { 'js-yaml': '^5.2.2', zod: '^4.4.3' } }, null, 2)
97
+ );
98
+ fs.writeFileSync(
99
+ path.join(configDir, 'package-lock.json'),
100
+ JSON.stringify({ name: 'xp-gate-global', lockfileVersion: 3, packages: {} }, null, 2)
101
+ );
102
+ // `mode: 'global'` is what uninstall() reads to decide there is anything to
103
+ // do; without it the run exits "No xp-gate installation found" and the test
104
+ // would pass while touching nothing.
105
+ fs.writeFileSync(
106
+ path.join(configDir, 'xp-gate.json'),
107
+ JSON.stringify({ lastInit: new Date().toISOString(), mode: 'global', manifest: { files: {} } }, null, 2)
108
+ );
109
+ // User data: must survive the uninstall, and must be reported as retained.
110
+ fs.writeFileSync(path.join(configDir, '.delphi-config.json'), '{ "active_profile": "default" }\n');
111
+ }
112
+
79
113
 
80
114
  describe('uninstall --global plans the whole installation, not just hooks (#663)', () => {
81
115
  let home;
@@ -212,34 +246,7 @@ describe('uninstall --global actually removes the residue (#714)', () => {
212
246
  * on realistic input rather than on `{}`.
213
247
  */
214
248
  function seedRealisticInstall() {
215
- for (const rel of ['hooks', 'adapters', 'modules/principles', 'node_modules/js-yaml']) {
216
- fs.mkdirSync(path.join(configDir(), rel), { recursive: true });
217
- fs.writeFileSync(path.join(configDir(), rel, 'index.js'), 'module.exports = {};\n');
218
- }
219
- fs.writeFileSync(
220
- path.join(configDir(), 'package.json'),
221
- JSON.stringify({ dependencies: { 'js-yaml': '^5.2.2', zod: '^4.4.3' } }, null, 2)
222
- );
223
- fs.writeFileSync(
224
- path.join(configDir(), 'package-lock.json'),
225
- JSON.stringify({ name: 'xp-gate-global', lockfileVersion: 3, packages: {} }, null, 2)
226
- );
227
- // `mode: 'global'` is what uninstall() reads to decide there is anything to
228
- // do; without it the run exits "No xp-gate installation found" and the test
229
- // would pass while touching nothing.
230
- fs.writeFileSync(
231
- path.join(configDir(), 'xp-gate.json'),
232
- JSON.stringify(
233
- {
234
- lastInit: new Date().toISOString(),
235
- mode: 'global',
236
- manifest: { files: {} },
237
- },
238
- null,
239
- 2
240
- )
241
- );
242
- fs.writeFileSync(path.join(configDir(), '.delphi-config.json'), '{ "active_profile": "default" }\n');
249
+ seedRealisticLayout(configDir());
243
250
  }
244
251
 
245
252
  /**
@@ -379,3 +386,160 @@ describe('uninstall hooks directory ownership (#714)', () => {
379
386
  expect(result.action).toBe('remove-owned-only');
380
387
  });
381
388
  });
389
+
390
+ describe('uninstall --global does not grow the config directory (#724)', () => {
391
+ let home;
392
+ const savedEnv = {};
393
+ const realArgv = process.argv;
394
+
395
+ beforeEach(() => {
396
+ home = makeHome();
397
+ savedEnv.HOME = process.env.HOME;
398
+ savedEnv.USERPROFILE = process.env.USERPROFILE;
399
+ process.env.HOME = home;
400
+ process.env.USERPROFILE = home;
401
+ process.argv = ['node', 'xp-gate', 'uninstall', '--global', '--force'];
402
+ });
403
+
404
+ afterEach(() => {
405
+ process.argv = realArgv;
406
+ process.env.HOME = savedEnv.HOME;
407
+ process.env.USERPROFILE = savedEnv.USERPROFILE;
408
+ fs.rmSync(home, { recursive: true, force: true });
409
+ });
410
+
411
+ function configDir() {
412
+ return path.join(home, '.config', 'xp-gate');
413
+ }
414
+
415
+ /**
416
+ * Same realistic layout as the #714 block. Delegates to the file-scope helper:
417
+ * two copies of this body tripped the clone detector, and they had to stay
418
+ * byte-identical to remain correct.
419
+ */
420
+ function seedInstall() {
421
+ seedRealisticLayout(configDir());
422
+ }
423
+
424
+ function countFiles(dir) {
425
+ if (!fs.existsSync(dir)) return 0;
426
+ let n = 0;
427
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
428
+ const p = path.join(dir, entry.name);
429
+ n += entry.isDirectory() ? countFiles(p) : 1;
430
+ }
431
+ return n;
432
+ }
433
+
434
+ function snapshotDirs() {
435
+ const base = path.join(configDir(), '.uninstall-backup');
436
+ if (!fs.existsSync(base)) return [];
437
+ return fs.readdirSync(base, { withFileTypes: true })
438
+ .filter((e) => e.isDirectory() && e.name.startsWith('uninstall-'))
439
+ .map((e) => e.name);
440
+ }
441
+
442
+ /**
443
+ * @test REQ-724 Uninstall leaves no growing residue
444
+ * @intent A dependency tree has no rollback value and must not be copied into the snapshot
445
+ * @covers AC-724-01
446
+ */
447
+ it('AC-724-01: the rollback snapshot does not copy node_modules/ or modules/', async () => {
448
+ seedInstall();
449
+ const { uninstall } = freshRequire('../uninstall');
450
+ await uninstall(['--global', '--force']);
451
+
452
+ const sessions = snapshotDirs();
453
+ expect(sessions.length, 'one snapshot per uninstall run').toBe(1);
454
+ const snap = path.join(configDir(), '.uninstall-backup', sessions[0]);
455
+
456
+ // The config is worth restoring; the dependency tree is not. Copying it was
457
+ // what made an uninstall write ~1000 files / 12 MB and leave the deleted
458
+ // directory LARGER than it found it.
459
+ expect(fs.existsSync(path.join(snap, 'node_modules')), 'node_modules must not be snapshotted').toBe(false);
460
+ expect(fs.existsSync(path.join(snap, 'modules')), 'modules must not be snapshotted').toBe(false);
461
+ expect(fs.existsSync(path.join(snap, 'xp-gate.json')), 'the manifest IS worth restoring').toBe(true);
462
+ });
463
+
464
+ /**
465
+ * @test REQ-724 Uninstall leaves no growing residue
466
+ * @intent Repeated uninstalls must not accumulate unbounded snapshots
467
+ * @covers AC-724-02
468
+ */
469
+ it('AC-724-02: snapshots are pruned to a bounded number', async () => {
470
+ const { uninstall } = freshRequire('../uninstall');
471
+ // Five separate setups/uninstalls in the same HOME -- the shape of a user
472
+ // re-running install/uninstall while debugging.
473
+ for (let i = 0; i < 5; i += 1) {
474
+ seedInstall();
475
+ await uninstall(['--global', '--force']);
476
+ }
477
+
478
+ const sessions = snapshotDirs();
479
+ expect(sessions.length, 'snapshots must not accumulate without bound').toBeLessThanOrEqual(3);
480
+
481
+ // Retention keeps the NEWEST snapshots. Names are `uninstall-<epoch-ms>`, so
482
+ // the survivors must be the highest-numbered ones -- a prune that kept the
483
+ // oldest would discard the recovery point the user is most likely to want.
484
+ const stamps = sessions.map((n) => Number(n.replace('uninstall-', '')));
485
+ const newest = Math.max(...stamps);
486
+ expect(
487
+ stamps.includes(newest),
488
+ 'the most recent snapshot must survive pruning'
489
+ ).toBe(true);
490
+ // Five runs, three retained: the two oldest are the ones evicted.
491
+ expect(sessions.length).toBe(3);
492
+ });
493
+
494
+ /**
495
+ * @test REQ-724 Uninstall leaves no growing residue
496
+ * @intent Retained user data must be advertised as retained, not listed for removal
497
+ * @covers AC-724-03
498
+ */
499
+ it('AC-724-03: the preview puts .delphi-config.json under KEPT, not "will be removed"', async () => {
500
+ seedInstall();
501
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
502
+ let said;
503
+ try {
504
+ // The spy must be installed BEFORE freshRequire. Dropping the module cache
505
+ // re-runs uninstall.js, and the module binds its own `console` reference at
506
+ // load time -- a spy taken afterwards captures nothing, so every assertion
507
+ // below would pass vacuously against an empty string.
508
+ const { uninstall } = freshRequire('../uninstall');
509
+ await uninstall(['--global', '--force']);
510
+ said = logSpy.mock.calls.map((c) => c.join(' ')).join('\n');
511
+ } finally {
512
+ logSpy.mockRestore();
513
+ }
514
+
515
+ // #724: the preview announced it would be removed and then kept it, with no
516
+ // mention anywhere. Anyone scripting cleanup from that preview would delete
517
+ // a directory they had been told was already gone.
518
+ const keptHeader = said.indexOf('KEPT');
519
+ expect(keptHeader, 'the output must name a KEPT group').toBeGreaterThan(-1);
520
+
521
+ const removedSection = said.slice(0, keptHeader);
522
+ expect(removedSection).not.toContain('.delphi-config.json');
523
+ expect(said.slice(keptHeader)).toContain('.delphi-config.json');
524
+ // And it is restated in the summary, which is the last thing users read.
525
+ expect(said).toMatch(/Kept: .*delphi-config\.json/);
526
+ });
527
+
528
+ /**
529
+ * @test REQ-724 Uninstall leaves no growing residue
530
+ * @intent The snapshot must not be written where deleting the config dir destroys it
531
+ * @covers AC-724-04
532
+ */
533
+ it('AC-724-04: the snapshot lives inside the config dir it backs up (documented tradeoff)', async () => {
534
+ seedInstall();
535
+ const { uninstall } = freshRequire('../uninstall');
536
+ await uninstall(['--global', '--force']);
537
+
538
+ // Pins the CURRENT location so a future move to an out-of-tree path is a
539
+ // deliberate, tested change rather than a silent behaviour shift. The
540
+ // summary now prints the path, which is what makes it discoverable.
541
+ const sessions = snapshotDirs();
542
+ expect(sessions.length).toBe(1);
543
+ expect(path.join(configDir(), '.uninstall-backup', sessions[0])).toContain('.uninstall-backup');
544
+ });
545
+ });