@boyingliu01/xp-gate 0.35.22 → 0.35.24

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 (41) hide show
  1. package/adapter-common.sh +24 -35
  2. package/adapters/go.sh +6 -11
  3. package/adapters/kotlin.sh +53 -8
  4. package/adapters/python.sh +6 -11
  5. package/gate-13-compiler-strictness.sh +15 -1
  6. package/hooks/adapter-common.sh +24 -35
  7. package/hooks/gate-13-compiler-strictness.sh +15 -1
  8. package/hooks/pre-commit +137 -9
  9. package/lib/__tests__/doctor-delphi.test.js +3 -3
  10. package/lib/__tests__/doctor-tui-skill-freshness.test.js +3 -3
  11. package/lib/__tests__/install-skill.test.js +107 -20
  12. package/lib/__tests__/uninstall-global-residue.test.js +230 -9
  13. package/lib/__tests__/update-skill.test.js +179 -55
  14. package/lib/install-skill.js +18 -3
  15. package/lib/mattpocock-license-verdict.json +1 -1
  16. package/lib/uninstall.js +47 -10
  17. package/lib/update-skill.js +96 -26
  18. package/mock-policy/AGENTS.md +3 -3
  19. package/mutation/AGENTS.md +3 -3
  20. package/package.json +1 -1
  21. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  22. package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
  23. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
  24. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
  25. package/plugins/dsh/package.json +1 -1
  26. package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
  27. package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
  28. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
  29. package/plugins/opencode/package.json +1 -1
  30. package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
  31. package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
  32. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
  33. package/plugins/qoder/plugin.json +1 -1
  34. package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
  35. package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
  36. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
  37. package/principles/AGENTS.md +3 -3
  38. package/scripts/delphi-external-review.cjs +68 -1
  39. package/skills/delphi-review/AGENTS.md +3 -3
  40. package/skills/sprint-flow/AGENTS.md +3 -3
  41. package/skills/test-specification-alignment/AGENTS.md +3 -3
@@ -17,37 +17,60 @@ function listDirs(root) {
17
17
  .sort();
18
18
  }
19
19
 
20
- describe('install-skill', () => {
21
- let tmpHome, originalHome;
22
-
23
- beforeEach(() => {
24
- originalHome = process.env.HOME;
25
- tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'xpgate-in-'));
26
- process.env.HOME = tmpHome;
20
+ /**
21
+ * Shared fixture for the blocks that need a throwaway HOME and a clean module
22
+ * cache.
23
+ *
24
+ * install-skill reads caches HOME at module load, so every test has to reset
25
+ * four modules and silence three console channels. Two copies of that boilerplate
26
+ * tripped the clone detector, and the two copies had already drifted (one
27
+ * afterEach waited for a pending setImmediate, the other did not) -- which is
28
+ * exactly how a teardown race starts. The stricter teardown wins here.
29
+ */
30
+ function makeHomeFixture(prefix) {
31
+ const handle = { tmpHome: null, originalHome: null };
32
+ const MODULES = ['../install-skill', '../detect-deps', '../shared-paths', '../rollback'];
33
+
34
+ handle.dropModuleCache = () => {
35
+ for (const mod of MODULES) delete require.cache[require.resolve(mod)];
36
+ };
37
+
38
+ handle.beforeEach = () => {
39
+ handle.originalHome = process.env.HOME;
40
+ handle.tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
41
+ process.env.HOME = handle.tmpHome;
27
42
  vi.resetModules();
28
- for (const mod of ['../install-skill', '../detect-deps', '../shared-paths', '../rollback']) {
29
- delete require.cache[require.resolve(mod)];
30
- }
43
+ handle.dropModuleCache();
31
44
  vi.spyOn(console, 'log').mockImplementation(() => {});
32
45
  vi.spyOn(console, 'error').mockImplementation(() => {});
33
46
  vi.spyOn(console, 'warn').mockImplementation(() => {});
34
- });
47
+ };
35
48
 
36
- afterEach(async () => {
49
+ handle.afterEach = async () => {
37
50
  await new Promise((resolve) => setImmediate(resolve));
38
- process.env.HOME = originalHome;
51
+ process.env.HOME = handle.originalHome;
39
52
  vi.restoreAllMocks();
40
- if (fs.existsSync(tmpHome)) {
41
- fs.rmSync(tmpHome, { recursive: true, force: true });
53
+ if (fs.existsSync(handle.tmpHome)) {
54
+ fs.rmSync(handle.tmpHome, { recursive: true, force: true });
42
55
  }
43
- });
56
+ handle.dropModuleCache();
57
+ };
58
+
59
+ return handle;
60
+ }
61
+
62
+ describe('install-skill', () => {
63
+ const fx = makeHomeFixture('xpgate-in-');
64
+
65
+ beforeEach(fx.beforeEach);
66
+ afterEach(fx.afterEach);
44
67
 
45
68
  function skillsDir() {
46
- return path.join(tmpHome, '.config', 'opencode', 'skills');
69
+ return path.join(fx.tmpHome, '.config', 'opencode', 'skills');
47
70
  }
48
71
 
49
72
  function configDir() {
50
- return path.join(tmpHome, '.config', 'xp-gate');
73
+ return path.join(fx.tmpHome, '.config', 'xp-gate');
51
74
  }
52
75
 
53
76
  function setupValidDeps() {
@@ -161,8 +184,13 @@ describe('install-skill', () => {
161
184
  expect(fs.existsSync(configFile)).toBe(true);
162
185
  const config = JSON.parse(fs.readFileSync(configFile, 'utf8'));
163
186
  expect(config.installedSkills).toHaveProperty('delphi-review');
164
- // Read version dynamically from VERSION file — no hardcoded version to break on bump
165
- const expectedVersion = fs.readFileSync(path.join(__dirname, '..', '..', '..', '..', 'VERSION'), 'utf8').trim();
187
+ // Read the version dynamically so a bump never breaks this test. The source
188
+ // is package.json, not the repo's VERSION: that file is a release stamp
189
+ // ('0.35.23.0'), is not shipped in the npm package, and resolving it from
190
+ // __dirname only works inside a checkout (#713).
191
+ const expectedVersion = JSON.parse(
192
+ fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf8')
193
+ ).version;
166
194
  expect(config.installedSkills['delphi-review'].version).toBe(expectedVersion);
167
195
  expect(config.installedSkills['delphi-review'].installedAt).toMatch(/^\d{4}-/);
168
196
  });
@@ -246,3 +274,62 @@ describe('install-skill', () => {
246
274
  expect(result).toBe(0);
247
275
  });
248
276
  });
277
+
278
+ // ── #713: the "unknown skill" hint must not print a fake version ─────────────
279
+ // getCliVersion() resolved `../../../VERSION` relative to __dirname. In an
280
+ // installed package __dirname is
281
+ // `node_modules/@boyingliu01/xp-gate/lib`, so three levels up lands in
282
+ // `node_modules/` -- and the package does not ship a VERSION file at all. The
283
+ // catch therefore fed `0.0.0` to every caller that prints it, so a user seeing
284
+ //
285
+ // xp-gate 0.0.0 bundles: ...
286
+ //
287
+ // reasonably concluded their install was broken. The version has to come from
288
+ // the package.json the CLI is actually running out of.
289
+ describe('install-skill #713 CLI version reporting', () => {
290
+ const fx = makeHomeFixture('xpgate-ver-');
291
+
292
+ beforeEach(fx.beforeEach);
293
+ afterEach(fx.afterEach);
294
+
295
+ /**
296
+ * @test REQ-713 Honest version reporting
297
+ * @intent An unknown-skill error must name the real package version, never 0.0.0
298
+ * @covers AC-713-04
299
+ *
300
+ * The repo checkout masks this bug: `<repo>/VERSION` exists, so resolving
301
+ * `../../../VERSION` from `src/npm-package/lib/` lands on it and looks correct.
302
+ * An installed package has no such luck -- npm's `files` list does not include
303
+ * VERSION, and three levels up from `node_modules/@boyingliu01/xp-gate/lib` is
304
+ * `node_modules/`. So the assertion that matters is the one that holds in the
305
+ * shipped layout, which is measured here by demanding the value AGREE with
306
+ * package.json rather than with whatever VERSION happens to sit above the tree.
307
+ */
308
+ it('AC-713-04: unknown-skill error reports the package version, not 0.0.0 and not repo VERSION', async () => {
309
+ const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf8'));
310
+
311
+ // install-skill destructures checkDeps at require time, so a spy on the
312
+ // module object cannot intercept it. Inject a stub into the require cache
313
+ // BEFORE install-skill loads -- the deps gate runs ahead of the name check,
314
+ // and this test measures version reporting, not dependency probing.
315
+ const detectPath = require.resolve('../detect-deps.js');
316
+ require.cache[detectPath] = {
317
+ id: detectPath,
318
+ filename: detectPath,
319
+ loaded: true,
320
+ exports: {
321
+ ...require('../detect-deps.js'),
322
+ checkDeps: async () => ({ ok: true }),
323
+ },
324
+ };
325
+
326
+ const { installSkill } = require('../install-skill');
327
+ const code = await installSkill('definitely-not-a-bundled-skill', { force: true });
328
+ expect(code).toBe(1);
329
+
330
+ const said = console.error.mock.calls.map((c) => c.join(' ')).join('\n');
331
+ expect(said).toMatch(/Unknown skill/);
332
+ expect(said).not.toContain('0.0.0');
333
+ expect(said).toContain(`xp-gate ${pkg.version} bundles:`);
334
+ });
335
+ });
@@ -44,6 +44,21 @@ function makeHome() {
44
44
  return home;
45
45
  }
46
46
 
47
+ /**
48
+ * Load a module fresh so it re-reads HOME. Every lib reads os.homedir() at call
49
+ * time, but a cached module still holds `GLOBAL_DIRS` values captured earlier.
50
+ * Shared by every describe block here: three copies of this loop tripped the
51
+ * clone detector, and they had to stay byte-identical to be correct.
52
+ */
53
+ function freshRequire(relPath) {
54
+ for (const key of Object.keys(require.cache)) {
55
+ if (key.includes(`${path.sep}npm-package${path.sep}lib${path.sep}`)) {
56
+ delete require.cache[key];
57
+ }
58
+ }
59
+ return require(relPath);
60
+ }
61
+
47
62
  /**
48
63
  * Reproduce the on-disk shape `xp-gate setup-global` leaves behind, including the
49
64
  * parts that were measured as residue in #663.
@@ -84,15 +99,6 @@ describe('uninstall --global plans the whole installation, not just hooks (#663)
84
99
  * Load a module fresh so it re-reads HOME. Every lib reads os.homedir() at call
85
100
  * time, but a cached module still holds `GLOBAL_DIRS` values captured earlier.
86
101
  */
87
- function freshRequire(relPath) {
88
- for (const key of Object.keys(require.cache)) {
89
- if (key.includes(`${path.sep}npm-package${path.sep}lib${path.sep}`)) {
90
- delete require.cache[key];
91
- }
92
- }
93
- return require(relPath);
94
- }
95
-
96
102
  it('AC-670-37: modules, node_modules and the manifest files are all in scope', () => {
97
103
  const { buildPlan } = freshRequire('../uninstall');
98
104
  expect(typeof buildPlan).toBe('function');
@@ -158,3 +164,218 @@ describe('uninstall --global plans the whole installation, not just hooks (#663)
158
164
  expect(names).not.toContain('edited-skill');
159
165
  });
160
166
  });
167
+
168
+ // -- #714: the residue must be DELETED, not merely scheduled --------------------
169
+ // #663 taught `buildPlan()` to list modules/, node_modules/, package.json,
170
+ // package-lock.json and xp-gate.json, so AC-670-37 passed -- but it only ever
171
+ // asserted those paths appear in the plan. On Windows the run still printed
172
+ //
173
+ // Warning: ~/.config/xp-gate/xp-gate.json does not contain xp-gate signature - skipping
174
+ //
175
+ // for all three files (the two directories were deleted, because a `dir` step
176
+ // recurses without an ownership check). The plan entries carried neither
177
+ // `signature` nor `manifestKey`, so removeFile() reached
178
+ // `content.includes(undefined)`, which is always false. Worst case: the
179
+ // uninstaller had just written `"mode": "uninstalled"` into xp-gate.json and
180
+ // then reported that file as "not xp-gate's".
181
+ //
182
+ // The lesson these tests encode: a residue test that only inspects the PLAN
183
+ // cannot see a broken DELETE. Every assertion below runs the real uninstall.
184
+ describe('uninstall --global actually removes the residue (#714)', () => {
185
+ let home;
186
+ const savedEnv = {};
187
+ const realArgv = process.argv;
188
+
189
+ beforeEach(() => {
190
+ home = makeHome();
191
+ savedEnv.HOME = process.env.HOME;
192
+ savedEnv.USERPROFILE = process.env.USERPROFILE;
193
+ process.env.HOME = home;
194
+ process.env.USERPROFILE = home;
195
+ process.argv = ['node', 'xp-gate', 'uninstall', '--global', '--force'];
196
+ });
197
+
198
+ afterEach(() => {
199
+ process.argv = realArgv;
200
+ process.env.HOME = savedEnv.HOME;
201
+ process.env.USERPROFILE = savedEnv.USERPROFILE;
202
+ fs.rmSync(home, { recursive: true, force: true });
203
+ });
204
+
205
+ function configDir() {
206
+ return path.join(home, '.config', 'xp-gate');
207
+ }
208
+
209
+ /**
210
+ * Seed a REAL setup-global layout: the three manifest-ish files carry the
211
+ * content `setup-global` writes, so a signature check has to succeed or fail
212
+ * on realistic input rather than on `{}`.
213
+ */
214
+ 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');
243
+ }
244
+
245
+ /**
246
+ * @test REQ-717 Uninstall completeness
247
+ * @intent A file step in the residue plan must be deleted, not skipped for lack of a signature
248
+ * @covers AC-717-01
249
+ */
250
+ it('AC-717-01: package.json, package-lock.json and xp-gate.json are gone after uninstall', async () => {
251
+ seedRealisticInstall();
252
+ const { uninstall } = freshRequire('../uninstall');
253
+ await uninstall(['--global', '--force']);
254
+
255
+ for (const file of ['package.json', 'package-lock.json']) {
256
+ expect(
257
+ fs.existsSync(path.join(configDir(), file)),
258
+ `${file} survived uninstall - it is xp-gate's own file, not user data`
259
+ ).toBe(false);
260
+ }
261
+
262
+ // xp-gate.json is deliberately retained: the state machine rewrites it with
263
+ // `mode: "uninstalled"` so a second `uninstall` is a clean no-op (AC-09).
264
+ // What #714 broke was not the retention but the CLAIM about it -- the run
265
+ // called this file "not xp-gate's" while rewriting it. AC-717-04 pins that.
266
+ const manifestFile = path.join(configDir(), 'xp-gate.json');
267
+ expect(fs.existsSync(manifestFile)).toBe(true);
268
+ const manifest = JSON.parse(fs.readFileSync(manifestFile, 'utf8'));
269
+ expect(manifest.mode).toBe('uninstalled');
270
+ });
271
+
272
+ /**
273
+ * @test REQ-717 Uninstall completeness
274
+ * @intent The two residue directories must go too, so a later setup-global cannot reuse them
275
+ * @covers AC-717-02
276
+ */
277
+ it('AC-717-02: modules/ and node_modules/ are gone after uninstall', async () => {
278
+ seedRealisticInstall();
279
+ const { uninstall } = freshRequire('../uninstall');
280
+ await uninstall(['--global', '--force']);
281
+
282
+ expect(fs.existsSync(path.join(configDir(), 'modules'))).toBe(false);
283
+ expect(fs.existsSync(path.join(configDir(), 'node_modules'))).toBe(false);
284
+ expect(fs.existsSync(path.join(configDir(), 'adapters'))).toBe(false);
285
+ });
286
+
287
+ /**
288
+ * @test REQ-717 Uninstall completeness
289
+ * @intent User data must still survive the now-working deletion path
290
+ * @covers AC-717-03
291
+ */
292
+ it('AC-717-03: .delphi-config.json survives the completed uninstall', async () => {
293
+ seedRealisticInstall();
294
+ const { uninstall } = freshRequire('../uninstall');
295
+ await uninstall(['--global', '--force']);
296
+
297
+ expect(fs.existsSync(path.join(configDir(), '.delphi-config.json'))).toBe(true);
298
+ });
299
+
300
+ /**
301
+ * @test REQ-717 Uninstall completeness
302
+ * @intent The uninstall must not report success while leaving its own residue behind
303
+ * @covers AC-717-04
304
+ */
305
+ it('AC-717-04: uninstall does not warn that its own residue files are foreign', async () => {
306
+ seedRealisticInstall();
307
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
308
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
309
+ try {
310
+ const { uninstall } = freshRequire('../uninstall');
311
+ await uninstall(['--global', '--force']);
312
+
313
+ const said = warnSpy.mock.calls.map((c) => c.join(' ')).join('\n');
314
+ for (const file of ['package.json', 'package-lock.json', 'xp-gate.json']) {
315
+ expect(
316
+ said.includes(`${file} does not contain xp-gate signature`),
317
+ `uninstall disowned ${file}: ${said}`
318
+ ).toBe(false);
319
+ }
320
+ } finally {
321
+ warnSpy.mockRestore();
322
+ logSpy.mockRestore();
323
+ }
324
+ });
325
+ });
326
+
327
+ describe('uninstall hooks directory ownership (#714)', () => {
328
+ let home;
329
+ const savedEnv = {};
330
+
331
+ beforeEach(() => {
332
+ home = makeHome();
333
+ savedEnv.HOME = process.env.HOME;
334
+ savedEnv.USERPROFILE = process.env.USERPROFILE;
335
+ process.env.HOME = home;
336
+ process.env.USERPROFILE = home;
337
+ });
338
+
339
+ afterEach(() => {
340
+ process.env.HOME = savedEnv.HOME;
341
+ process.env.USERPROFILE = savedEnv.USERPROFILE;
342
+ fs.rmSync(home, { recursive: true, force: true });
343
+ });
344
+
345
+ /**
346
+ * @test REQ-717 Hook ownership
347
+ * @intent post-commit is installed by copyHooks(), so it must count as xp-gate's own
348
+ * @covers AC-717-05
349
+ */
350
+ it('AC-717-05: post-commit is classified as xp-gate-owned, not foreign', () => {
351
+ const { planHooksDirRemoval } = freshRequire('../uninstall');
352
+ const hooksDir = path.join(process.env.HOME, '.config', 'xp-gate', 'hooks');
353
+ fs.mkdirSync(hooksDir, { recursive: true });
354
+ // copyHooks() installs exactly these; post-commit was the one it forgot.
355
+ for (const f of ['pre-commit', 'pre-push', 'post-merge', 'post-commit', 'hook-module-resolution.sh']) {
356
+ fs.writeFileSync(path.join(hooksDir, f), '#!/bin/sh\n');
357
+ }
358
+
359
+ const result = planHooksDirRemoval(hooksDir);
360
+ const foreignNames = (result.foreign || []).map((f) => f.name);
361
+ expect(foreignNames).not.toContain('post-commit');
362
+ });
363
+
364
+ /**
365
+ * @test REQ-717 Hook ownership
366
+ * @intent A genuinely foreign hook (git-lfs) must still be protected
367
+ * @covers AC-717-06
368
+ */
369
+ it('AC-717-06: a third-party hook is still classified as foreign and kept', () => {
370
+ const { planHooksDirRemoval } = freshRequire('../uninstall');
371
+ const hooksDir = path.join(process.env.HOME, '.config', 'xp-gate', 'hooks');
372
+ fs.mkdirSync(hooksDir, { recursive: true });
373
+ fs.writeFileSync(path.join(hooksDir, 'pre-commit'), '#!/bin/sh\n');
374
+ fs.writeFileSync(path.join(hooksDir, 'post-checkout'), '#!/bin/sh\n# git-lfs\n');
375
+
376
+ const result = planHooksDirRemoval(hooksDir);
377
+ const foreignNames = (result.foreign || []).map((f) => f.name);
378
+ expect(foreignNames).toContain('post-checkout');
379
+ expect(result.action).toBe('remove-owned-only');
380
+ });
381
+ });