@boyingliu01/xp-gate 0.35.24 → 0.36.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.
- package/adapter-common.sh +55 -2
- package/adapters/csharp.sh +245 -0
- package/adapters/gate-3.sh +1 -1
- package/adapters/gate-4.sh +1 -1
- package/adapters/gate-9.sh +37 -2
- package/adapters/iac.sh +45 -5
- package/bin/xp-gate.js +18 -11
- package/gate-10.sh +18 -3
- package/gate-13-compiler-strictness.sh +181 -0
- package/gate-3.sh +1 -1
- package/gate-4.sh +1 -1
- package/gate-9.sh +37 -2
- package/gates/common.ts +9 -2
- package/hooks/adapter-common.sh +55 -2
- package/hooks/gate-10.sh +18 -3
- package/hooks/gate-13-compiler-strictness.sh +181 -0
- package/hooks/gate-3.sh +1 -1
- package/hooks/gate-4.sh +1 -1
- package/hooks/gate-9.sh +37 -2
- package/hooks/gate-registry.sh +161 -0
- package/hooks/lib/test-failure.sh +29 -4
- package/hooks/pre-commit +122 -3
- package/lib/__tests__/agent-runtime-journal.test.js +42 -18
- package/lib/__tests__/check-single-file.test.js +55 -4
- package/lib/__tests__/detect-deps-dsh-profile.test.js +43 -0
- package/lib/__tests__/doctor-delphi.test.js +175 -0
- package/lib/__tests__/install-skill-full-dir.test.js +45 -2
- package/lib/__tests__/install-skill.test.js +84 -3
- package/lib/__tests__/mirror-sync.test.js +23 -5
- package/lib/__tests__/principles-csharp-coverage.test.js +227 -0
- package/lib/__tests__/uninstall-global-residue.test.js +192 -28
- package/lib/__tests__/update-skill-flags.test.js +78 -0
- package/lib/__tests__/update-skill.test.js +156 -3
- package/lib/detect-deps.js +24 -0
- package/lib/doctor.js +80 -12
- package/lib/install-skill.js +67 -18
- package/lib/mattpocock-license-verdict.json +1 -1
- package/lib/principles.js +9 -1
- package/lib/uninstall.js +96 -2
- package/lib/update-skill.js +143 -2
- package/mock-policy/AGENTS.md +4 -4
- package/mutation/AGENTS.md +4 -4
- package/package.json +1 -1
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +4 -4
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +4 -4
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +4 -4
- package/plugins/dsh/lib/index.d.ts.map +1 -1
- package/plugins/dsh/lib/index.js +7 -4
- package/plugins/dsh/package.json +1 -1
- package/plugins/dsh/skills/delphi-review/AGENTS.md +4 -4
- package/plugins/dsh/skills/sprint-flow/AGENTS.md +4 -4
- package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +4 -4
- package/plugins/dsh/src/index.test.ts +25 -0
- package/plugins/dsh/src/index.ts +7 -4
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +4 -4
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +4 -4
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +4 -4
- package/plugins/qoder/plugin.json +1 -1
- package/plugins/qoder/skills/delphi-review/AGENTS.md +4 -4
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +4 -4
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +4 -4
- package/principles/AGENTS.md +4 -4
- package/principles/adapters/csharp.ts +309 -0
- package/principles/analyzer.ts +5 -0
- package/scripts/delphi-external-review.cjs +130 -101
- package/skills/delphi-review/AGENTS.md +4 -4
- package/skills/sprint-flow/AGENTS.md +4 -4
- package/skills/test-specification-alignment/AGENTS.md +4 -4
|
@@ -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
|
-
|
|
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
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test REQ-725
|
|
3
|
+
* @intent `xp-gate update-skill --check` treated the flag itself as a skill name
|
|
4
|
+
* and failed with "--check is not installed". #347 fixed exactly this for
|
|
5
|
+
* --all and the sibling flag was left broken, so the regression is pinned
|
|
6
|
+
* here at the CLI boundary -- where the bug was -- rather than at the
|
|
7
|
+
* parser, which was never wrong.
|
|
8
|
+
* @covers AC-725-03
|
|
9
|
+
*/
|
|
10
|
+
const { spawnSync } = require('child_process');
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const os = require('os');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
|
|
15
|
+
const BIN = path.join(__dirname, '..', '..', 'bin', 'xp-gate.js');
|
|
16
|
+
|
|
17
|
+
/** Run the real CLI against a throwaway HOME so no real install is touched. */
|
|
18
|
+
function runCli(args, home) {
|
|
19
|
+
const env = { ...process.env, HOME: home, USERPROFILE: home };
|
|
20
|
+
return spawnSync(process.execPath, [BIN, ...args], {
|
|
21
|
+
encoding: 'utf8',
|
|
22
|
+
env,
|
|
23
|
+
timeout: 120000,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('update-skill flag parsing (#725)', () => {
|
|
28
|
+
let home;
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
home = fs.mkdtempSync(path.join(os.tmpdir(), 'xpg-725-cli-'));
|
|
32
|
+
// An empty but present config dir keeps the command on the update path
|
|
33
|
+
// instead of bailing out with "not installed" for the wrong reason.
|
|
34
|
+
fs.mkdirSync(path.join(home, '.config', 'xp-gate'), { recursive: true });
|
|
35
|
+
fs.writeFileSync(
|
|
36
|
+
path.join(home, '.config', 'xp-gate', 'xp-gate.json'),
|
|
37
|
+
JSON.stringify({ installedSkills: {} })
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @covers AC-725-03
|
|
47
|
+
*/
|
|
48
|
+
it('AC-725-03: --check is not mistaken for a skill name', () => {
|
|
49
|
+
const result = runCli(['update-skill', '--check'], home);
|
|
50
|
+
const said = `${result.stdout}\n${result.stderr}`;
|
|
51
|
+
|
|
52
|
+
// The bug printed exactly this. Anything mentioning it means the flag went
|
|
53
|
+
// through the name slot again.
|
|
54
|
+
expect(said, '--check must not be read as a skill name').not.toMatch(/--check is not installed/i);
|
|
55
|
+
// It should reach check mode instead: a listing pass, exit 0.
|
|
56
|
+
expect(result.status).toBe(0);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @covers AC-725-03
|
|
61
|
+
*/
|
|
62
|
+
it('AC-725-03: --all --check keeps working (the combination #347 fixed)', () => {
|
|
63
|
+
const result = runCli(['update-skill', '--all', '--check'], home);
|
|
64
|
+
expect(`${result.stdout}\n${result.stderr}`).not.toMatch(/--all is not installed/i);
|
|
65
|
+
expect(result.status).toBe(0);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @covers AC-725-03
|
|
70
|
+
*/
|
|
71
|
+
it('AC-725-03: a bare name still routes to the single-skill path', () => {
|
|
72
|
+
// A name that is not installed must be rejected BY NAME -- proof the name
|
|
73
|
+
// slot still works after the flag extraction.
|
|
74
|
+
const result = runCli(['update-skill', 'definitely-not-a-skill'], home);
|
|
75
|
+
const said = `${result.stdout}\n${result.stderr}`;
|
|
76
|
+
expect(said).toMatch(/definitely-not-a-skill/);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -114,8 +114,14 @@ describe('update-skill', () => {
|
|
|
114
114
|
const result = await updateSkill(null, { check: true });
|
|
115
115
|
expect(result).toBe(0);
|
|
116
116
|
expect(logSpy).toHaveBeenCalledWith('Checking for updates...');
|
|
117
|
-
|
|
118
|
-
|
|
117
|
+
|
|
118
|
+
// #725: the check reads the DISK now, not the registry. These skills have no
|
|
119
|
+
// copy anywhere on this fixture HOME, so the honest answer is "registry only"
|
|
120
|
+
// -- printing the recorded version as though it were verified is what let a
|
|
121
|
+
// stale multi-platform copy look up to date.
|
|
122
|
+
const lines = logSpy.mock.calls.map((c) => c[0]);
|
|
123
|
+
expect(lines.some((l) => /foo: 1\.2\.3 \(registry only/.test(l))).toBe(true);
|
|
124
|
+
expect(lines.some((l) => /bar: 2\.0\.0 \(registry only/.test(l))).toBe(true);
|
|
119
125
|
expect(logSpy).toHaveBeenCalledWith('Update check complete');
|
|
120
126
|
});
|
|
121
127
|
|
|
@@ -124,7 +130,8 @@ describe('update-skill', () => {
|
|
|
124
130
|
const { updateSkill } = require('../update-skill');
|
|
125
131
|
const result = await updateSkill(null, { check: true });
|
|
126
132
|
expect(result).toBe(0);
|
|
127
|
-
|
|
133
|
+
const lines = logSpy.mock.calls.map((c) => c[0]);
|
|
134
|
+
expect(lines.some((l) => /baz: unknown/.test(l))).toBe(true);
|
|
128
135
|
});
|
|
129
136
|
|
|
130
137
|
it('check=true with empty config returns 0 and prints only headers', async () => {
|
|
@@ -489,3 +496,149 @@ describe('update-skill #713 bundled-skill ownership', () => {
|
|
|
489
496
|
expect(code).toBe(0);
|
|
490
497
|
});
|
|
491
498
|
});
|
|
499
|
+
|
|
500
|
+
describe('update-skill #725 --check reads the disk, not the registry', () => {
|
|
501
|
+
let tmpHome;
|
|
502
|
+
let originalHome;
|
|
503
|
+
let logSpy;
|
|
504
|
+
|
|
505
|
+
beforeEach(() => {
|
|
506
|
+
originalHome = process.env.HOME;
|
|
507
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'xpgate-725-'));
|
|
508
|
+
process.env.HOME = tmpHome;
|
|
509
|
+
vi.resetModules();
|
|
510
|
+
delete require.cache[require.resolve('../update-skill')];
|
|
511
|
+
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
afterEach(() => {
|
|
515
|
+
if (originalHome === undefined) delete process.env.HOME; else process.env.HOME = originalHome;
|
|
516
|
+
logSpy.mockRestore();
|
|
517
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
function writeConfig(installedSkills) {
|
|
521
|
+
const dir = path.join(tmpHome, '.config', 'xp-gate');
|
|
522
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
523
|
+
fs.writeFileSync(path.join(dir, 'xp-gate.json'), JSON.stringify({ installedSkills }));
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// The bundled version these tests compare against is whatever the package under
|
|
527
|
+
// test actually declares. Reading it keeps the fixtures honest across a release
|
|
528
|
+
// bump: hardcoded stamps silently turn "up to date" fixtures into "behind" ones
|
|
529
|
+
// the moment VERSION moves, and the failure then names /behind/i rather than the
|
|
530
|
+
// stale literal that caused it.
|
|
531
|
+
function bundledVersion() {
|
|
532
|
+
return JSON.parse(
|
|
533
|
+
fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf8'),
|
|
534
|
+
).version;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** The same release in the 4-part stamp form AGENTS.md files carry. */
|
|
538
|
+
function bundledStamp() {
|
|
539
|
+
const v = bundledVersion();
|
|
540
|
+
return /^\d+\.\d+\.\d+$/.test(v) ? `${v}.0` : v;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** Deploy a copy of a shipped skill into one platform, with a chosen stamp. */
|
|
544
|
+
function deploy(platformRel, name, version) {
|
|
545
|
+
const dir = path.join(tmpHome, platformRel, name);
|
|
546
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
547
|
+
// SKILL.md is copied verbatim so a content comparison would call it current;
|
|
548
|
+
// the version stamp is what must decide.
|
|
549
|
+
const bundled = path.join(__dirname, '..', '..', 'skills', name, 'SKILL.md');
|
|
550
|
+
fs.copyFileSync(bundled, path.join(dir, 'SKILL.md'));
|
|
551
|
+
if (version) {
|
|
552
|
+
fs.writeFileSync(
|
|
553
|
+
path.join(dir, 'AGENTS.md'),
|
|
554
|
+
`# KB\n\n**Version:** ${version}\n`
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
return dir;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function lines() {
|
|
561
|
+
return logSpy.mock.calls.map((c) => String(c[0]));
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* @test REQ-725
|
|
566
|
+
* @intent A copy stamped with an older version must be reported even when the registry says it is current
|
|
567
|
+
* @covers AC-725-04
|
|
568
|
+
*/
|
|
569
|
+
it('AC-725-04: reports a stale on-disk copy the registry calls current', async () => {
|
|
570
|
+
writeConfig({ 'delphi-review': { version: bundledVersion() } });
|
|
571
|
+
deploy(path.join('.qoder', 'skills'), 'delphi-review', bundledStamp());
|
|
572
|
+
deploy(path.join('.config', 'opencode', 'skills'), 'delphi-review', '0.32.0.0');
|
|
573
|
+
|
|
574
|
+
const { updateSkill } = require('../update-skill');
|
|
575
|
+
await updateSkill(null, { check: true });
|
|
576
|
+
|
|
577
|
+
const said = lines().join('\n');
|
|
578
|
+
// The registry claims the bundled version for this skill, and it is right about Qoder.
|
|
579
|
+
// It is wrong about OpenCode, and only reading the disk can say so.
|
|
580
|
+
expect(said).toMatch(/delphi-review \[opencode\]: 0\.32\.0\.0/);
|
|
581
|
+
expect(said).toMatch(/behind/i);
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* @test REQ-725
|
|
586
|
+
* @intent Two matching copies must report as up to date
|
|
587
|
+
* @covers AC-725-04
|
|
588
|
+
*/
|
|
589
|
+
it('AC-725-04: both copies current reports up to date and no shortfall', async () => {
|
|
590
|
+
writeConfig({ 'delphi-review': { version: bundledVersion() } });
|
|
591
|
+
deploy(path.join('.qoder', 'skills'), 'delphi-review', bundledStamp());
|
|
592
|
+
deploy(path.join('.config', 'opencode', 'skills'), 'delphi-review', bundledStamp());
|
|
593
|
+
|
|
594
|
+
const { updateSkill } = require('../update-skill');
|
|
595
|
+
await updateSkill(null, { check: true });
|
|
596
|
+
|
|
597
|
+
const said = lines().join('\n');
|
|
598
|
+
expect(said).not.toMatch(/behind/i);
|
|
599
|
+
expect(said).toMatch(/up to date on 2 platform/i);
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* @test REQ-725
|
|
604
|
+
* @intent A release whose micro digit is 0 must not be read as a different release
|
|
605
|
+
* @covers AC-725-04
|
|
606
|
+
*/
|
|
607
|
+
it('AC-725-04: a micro .0 stamp matches its own 3-part version', async () => {
|
|
608
|
+
// The stamp and the packaged version name the SAME release; only their digit
|
|
609
|
+
// count differs. This regressed once: normalization stripped every trailing
|
|
610
|
+
// zero instead of the last segment, so `X.Y.0.0` became `X.Y.0` while
|
|
611
|
+
// `X.Y.0` became `X.Y`, and a current copy was reported as behind. It was
|
|
612
|
+
// invisible while releases ended in a non-zero micro digit, which is why the
|
|
613
|
+
// assertion is built from the live packaged version rather than a literal:
|
|
614
|
+
// it only has teeth on releases whose micro digit is 0.
|
|
615
|
+
const three = bundledVersion();
|
|
616
|
+
const four = bundledStamp();
|
|
617
|
+
|
|
618
|
+
writeConfig({ 'delphi-review': { version: three } });
|
|
619
|
+
deploy(path.join('.qoder', 'skills'), 'delphi-review', four);
|
|
620
|
+
|
|
621
|
+
const { updateSkill } = require('../update-skill');
|
|
622
|
+
await updateSkill(null, { check: true });
|
|
623
|
+
|
|
624
|
+
const said = lines().join('\n');
|
|
625
|
+
expect(said, `${four} and ${three} name the same release`).not.toMatch(/behind/i);
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* @test REQ-725
|
|
630
|
+
* @intent A skill with no version stamp must not be flagged merely for lacking one
|
|
631
|
+
* @covers AC-725-05
|
|
632
|
+
*/
|
|
633
|
+
it('AC-725-05: an unstamped copy identical to the bundle is not reported as behind', async () => {
|
|
634
|
+
// Most skills ship no AGENTS.md, so "unknown" is their normal state. Measuring
|
|
635
|
+
// staleness by absence of a stamp flagged every skill on a healthy machine.
|
|
636
|
+
writeConfig({ 'admin-template-guidelines': { version: bundledVersion() } });
|
|
637
|
+
deploy(path.join('.qoder', 'skills'), 'admin-template-guidelines', null);
|
|
638
|
+
|
|
639
|
+
const { updateSkill } = require('../update-skill');
|
|
640
|
+
await updateSkill(null, { check: true });
|
|
641
|
+
|
|
642
|
+
expect(lines().join('\n')).not.toMatch(/behind/i);
|
|
643
|
+
});
|
|
644
|
+
});
|
package/lib/detect-deps.js
CHANGED
|
@@ -302,6 +302,29 @@ function getSkillsDirs(platform) {
|
|
|
302
302
|
return profile.skillsDirs.map((segments) => path.join(home, ...segments));
|
|
303
303
|
}
|
|
304
304
|
|
|
305
|
+
/**
|
|
306
|
+
* Every AI-agent platform whose directory actually exists in the home dir.
|
|
307
|
+
*
|
|
308
|
+
* `detectPlatform()` short-circuits to a single platform (opencode is the
|
|
309
|
+
* fallback), which is right for "which one do I target by default" but wrong for
|
|
310
|
+
* "which agents does this machine actually run" -- a migration period, or a user
|
|
311
|
+
* who keeps two tools, has several live trees, and an update must reach all of
|
|
312
|
+
* them. Each profile's `skillsDirs` lists home-relative path segments; a platform
|
|
313
|
+
* counts as installed when any of its directories exists.
|
|
314
|
+
*
|
|
315
|
+
* @returns {string[]} installed platform names, in PLATFORM_PROFILES order
|
|
316
|
+
*/
|
|
317
|
+
function detectInstalledPlatforms() {
|
|
318
|
+
const home = require('./shared-paths.js').HOME_DIR;
|
|
319
|
+
const installed = [];
|
|
320
|
+
for (const [platform, profile] of Object.entries(PLATFORM_PROFILES)) {
|
|
321
|
+
if (profile.skillsDirs.some((segments) => fs.existsSync(path.join(home, ...segments)))) {
|
|
322
|
+
installed.push(platform);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return installed;
|
|
326
|
+
}
|
|
327
|
+
|
|
305
328
|
/**
|
|
306
329
|
* Resolve a bash that can actually run the hooks.
|
|
307
330
|
*
|
|
@@ -659,6 +682,7 @@ module.exports = {
|
|
|
659
682
|
autoInstallDeps,
|
|
660
683
|
detectPlatform,
|
|
661
684
|
getSkillsDirs,
|
|
685
|
+
detectInstalledPlatforms,
|
|
662
686
|
PLATFORM_PROFILES,
|
|
663
687
|
REQUIRED_DEPS,
|
|
664
688
|
GATE_CLI_TOOLS,
|
package/lib/doctor.js
CHANGED
|
@@ -21,7 +21,7 @@ const CONFIG_FILE = () => sharedPaths.CONFIG_FILE;
|
|
|
21
21
|
const GLOBAL_HOOKS_DIR = () => sharedPaths.GLOBAL_HOOKS_DIR;
|
|
22
22
|
const GLOBAL_ADAPTERS_DIR = () => sharedPaths.GLOBAL_ADAPTERS_DIR;
|
|
23
23
|
const { checkUpgrade, formatUpgradeMsg } = require('./check-version.js');
|
|
24
|
-
const { GATE_CLI_TOOLS, checkCliTool, getToolInstallCmd, checkBash } = require('./detect-deps.js');
|
|
24
|
+
const { GATE_CLI_TOOLS, checkCliTool, getToolInstallCmd, checkBash, detectInstalledPlatforms, getSkillsDirs } = require('./detect-deps.js');
|
|
25
25
|
// #505: the single source of truth for module resolution order. The hook reads a
|
|
26
26
|
// generated shell projection of this same module.
|
|
27
27
|
const { RESOLUTION_ORDER } = require('./hook-module-resolution.js');
|
|
@@ -3343,7 +3343,13 @@ async function doctorDelphi(options = {}) {
|
|
|
3343
3343
|
}
|
|
3344
3344
|
return out;
|
|
3345
3345
|
};
|
|
3346
|
-
|
|
3346
|
+
// #725 follow-up: decide "installed anywhere?" from the full platform set, not
|
|
3347
|
+
// just the directory `detectPlatform()` selects — a dsh-only machine has no
|
|
3348
|
+
// marker detectPlatform() keys on, so the opencode fallback directory never
|
|
3349
|
+
// exists and the old check falsely reported "not installed" even though the
|
|
3350
|
+
// copy IS deployed under ~/.dsh/skills.
|
|
3351
|
+
const deployedDirs = otherDeployedSkillDirs(installedSkillDir);
|
|
3352
|
+
if (deployedDirs.length === 0) {
|
|
3347
3353
|
add('Delphi skill install parity', 'FAIL',
|
|
3348
3354
|
`not installed at ${installedSkillDir} (install: xp-gate install-skill delphi-review)`);
|
|
3349
3355
|
} else {
|
|
@@ -3357,24 +3363,26 @@ async function doctorDelphi(options = {}) {
|
|
|
3357
3363
|
add('Delphi skill install parity', 'FAIL',
|
|
3358
3364
|
`bundled skill directory missing or empty: ${bundledSkillDir} — reinstall xp-gate`);
|
|
3359
3365
|
} else {
|
|
3366
|
+
// #725: check every platform that has the skill deployed, not only the one
|
|
3367
|
+
// `detectPlatform()` selects. On a machine with both Qoder and OpenCode the
|
|
3368
|
+
// OpenCode copy was frozen at an old version and this check reported PASS,
|
|
3369
|
+
// because it never looked there -- the drifted copy was invisible to both
|
|
3370
|
+
// `update-skill` and `doctor`.
|
|
3360
3371
|
const drifted = [];
|
|
3361
|
-
for (const
|
|
3362
|
-
const
|
|
3363
|
-
|
|
3364
|
-
|
|
3365
|
-
drifted.push(`${rel}: missing`);
|
|
3366
|
-
} else if (sha256(installedPath) !== sha256(bundledPath)) {
|
|
3367
|
-
drifted.push(`${rel}: differs from bundle`);
|
|
3372
|
+
for (const dir of deployedDirs) {
|
|
3373
|
+
const dirDrift = collectSkillDrift(dir, bundledFiles, bundledSkillDir, sha256);
|
|
3374
|
+
if (dirDrift.length > 0) {
|
|
3375
|
+
drifted.push(`${platformTag(dir)}: ${dirDrift.join(', ')}`);
|
|
3368
3376
|
}
|
|
3369
3377
|
}
|
|
3370
3378
|
if (drifted.length > 0) {
|
|
3371
3379
|
add('Delphi skill install parity', 'FAIL',
|
|
3372
|
-
|
|
3373
|
-
+ `${drifted.slice(0,
|
|
3380
|
+
`installed copy differs from the ${getCliVersionSafe()} bundle — `
|
|
3381
|
+
+ `${drifted.slice(0, 3).join('; ')}${drifted.length > 3 ? '; ...' : ''} `
|
|
3374
3382
|
+ '(reinstall: xp-gate install-skill delphi-review --force)');
|
|
3375
3383
|
} else {
|
|
3376
3384
|
add('Delphi skill install parity', 'PASS',
|
|
3377
|
-
`${bundledFiles.length} file(s) match the bundle`);
|
|
3385
|
+
`${bundledFiles.length} file(s) match the bundle across ${deployedDirs.length} platform(s)`);
|
|
3378
3386
|
}
|
|
3379
3387
|
}
|
|
3380
3388
|
}
|
|
@@ -3591,6 +3599,66 @@ function getCliVersionSafe() {
|
|
|
3591
3599
|
}
|
|
3592
3600
|
}
|
|
3593
3601
|
|
|
3602
|
+
/**
|
|
3603
|
+
* Skills directories that hold the delphi-review skill, including the detected one.
|
|
3604
|
+
*
|
|
3605
|
+
* #725: `detectPlatform()` returns a single platform, so on a machine carrying
|
|
3606
|
+
* both Qoder and OpenCode the OpenCode copy of the skill was never examined —
|
|
3607
|
+
* `update-skill` did not refresh it and `doctor` reported PASS anyway. Both were
|
|
3608
|
+
* looking at the same one directory. A migration period (user moved between AI
|
|
3609
|
+
* tools, both trees still live) is the normal case that produces this, and the
|
|
3610
|
+
* frozen copy is exactly what a parity check exists to catch.
|
|
3611
|
+
*
|
|
3612
|
+
* The original fix still hardcoded the three pre-dsh platforms, so the DSH copy
|
|
3613
|
+
* (`~/.dsh/skills/delphi-review`) — which `detectPlatform()` never returns and
|
|
3614
|
+
* `PLATFORM_PROFILES` does carry — stayed invisible. Derive the candidates from
|
|
3615
|
+
* the profiles so every installed platform is examined.
|
|
3616
|
+
*
|
|
3617
|
+
* @param {string} detectedDir the platform `detectPlatform()` selected
|
|
3618
|
+
* @returns {string[]} directories that actually contain the skill
|
|
3619
|
+
*/
|
|
3620
|
+
function otherDeployedSkillDirs(detectedDir) {
|
|
3621
|
+
const candidates = [detectedDir];
|
|
3622
|
+
for (const platform of detectInstalledPlatforms()) {
|
|
3623
|
+
candidates.push(path.join(getSkillsDirs(platform)[0], 'delphi-review'));
|
|
3624
|
+
}
|
|
3625
|
+
const seen = new Set();
|
|
3626
|
+
const found = [];
|
|
3627
|
+
for (const dir of candidates) {
|
|
3628
|
+
if (seen.has(dir)) continue;
|
|
3629
|
+
seen.add(dir);
|
|
3630
|
+
if (fs.existsSync(path.join(dir, 'SKILL.md'))) found.push(dir);
|
|
3631
|
+
}
|
|
3632
|
+
return found;
|
|
3633
|
+
}
|
|
3634
|
+
|
|
3635
|
+
/**
|
|
3636
|
+
* A short label for a skills directory: the platform segment, not the full path.
|
|
3637
|
+
* Keeps the doctor line readable when two platforms disagree.
|
|
3638
|
+
*/
|
|
3639
|
+
function platformTag(skillDir) {
|
|
3640
|
+
const parent = path.basename(path.dirname(path.dirname(skillDir)));
|
|
3641
|
+
return parent === 'opencode' ? 'opencode' : parent;
|
|
3642
|
+
}
|
|
3643
|
+
|
|
3644
|
+
/**
|
|
3645
|
+
* Files in `skillDir` that are missing or differ from the bundle.
|
|
3646
|
+
* @returns {string[]} `rel: reason` entries; empty when the copy is clean
|
|
3647
|
+
*/
|
|
3648
|
+
function collectSkillDrift(skillDir, bundledFiles, bundledSkillDir, sha256) {
|
|
3649
|
+
const drifted = [];
|
|
3650
|
+
for (const rel of bundledFiles) {
|
|
3651
|
+
const installedPath = path.join(skillDir, rel);
|
|
3652
|
+
const bundledPath = path.join(bundledSkillDir, rel);
|
|
3653
|
+
if (!fs.existsSync(installedPath)) {
|
|
3654
|
+
drifted.push(`${rel}: missing`);
|
|
3655
|
+
} else if (sha256(installedPath) !== sha256(bundledPath)) {
|
|
3656
|
+
drifted.push(`${rel}: differs`);
|
|
3657
|
+
}
|
|
3658
|
+
}
|
|
3659
|
+
return drifted;
|
|
3660
|
+
}
|
|
3661
|
+
|
|
3594
3662
|
module.exports = {
|
|
3595
3663
|
doctor, doctorDelphi, isXpGateFile, SIGNATURES,
|
|
3596
3664
|
fixVersionMismatch,
|