@boyingliu01/xp-gate 0.35.23 → 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.
- package/adapter-common.sh +78 -36
- 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/go.sh +6 -11
- package/adapters/iac.sh +45 -5
- package/adapters/python.sh +6 -11
- 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 +78 -36
- 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 +178 -3
- package/lib/__tests__/doctor-tui-skill-freshness.test.js +3 -3
- package/lib/__tests__/install-skill-full-dir.test.js +45 -2
- package/lib/__tests__/install-skill.test.js +191 -23
- package/lib/__tests__/mirror-sync.test.js +23 -5
- package/lib/__tests__/uninstall-global-residue.test.js +394 -9
- package/lib/__tests__/update-skill-flags.test.js +78 -0
- package/lib/__tests__/update-skill.test.js +334 -57
- package/lib/detect-deps.js +24 -0
- package/lib/doctor.js +80 -12
- package/lib/install-skill.js +85 -21
- package/lib/mattpocock-license-verdict.json +1 -1
- package/lib/uninstall.js +143 -12
- package/lib/update-skill.js +239 -28
- 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
|
@@ -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 () => {
|
|
@@ -145,51 +152,55 @@ describe('update-skill', () => {
|
|
|
145
152
|
expect(installSkillMock).not.toHaveBeenCalled();
|
|
146
153
|
});
|
|
147
154
|
|
|
155
|
+
// These编排 tests use names the bundle really carries: since #713 `--all`
|
|
156
|
+
// only claims skills xp-gate ships, so a placeholder like 'foo' is filtered
|
|
157
|
+
// out before it can exercise the loop. The orchestration behaviour under test
|
|
158
|
+
// (fan-out, error propagation, verbose passthrough) is orthogonal to ownership.
|
|
148
159
|
it('all=true updates every installed skill (calls installSkill each), returns 0 on success', async () => {
|
|
149
160
|
writeConfig({
|
|
150
|
-
|
|
151
|
-
|
|
161
|
+
'delphi-review': { version: '1.0.0' },
|
|
162
|
+
'grilling': { version: '1.0.0' },
|
|
152
163
|
});
|
|
153
164
|
const { updateSkill } = require('../update-skill');
|
|
154
165
|
const result = await updateSkill(null, { all: true });
|
|
155
166
|
expect(result).toBe(0);
|
|
156
167
|
expect(installSkillMock).toHaveBeenCalledTimes(2);
|
|
157
|
-
expect(installSkillMock).toHaveBeenCalledWith('
|
|
168
|
+
expect(installSkillMock).toHaveBeenCalledWith('delphi-review', {
|
|
158
169
|
force: true,
|
|
159
170
|
verbose: false,
|
|
160
171
|
});
|
|
161
|
-
expect(installSkillMock).toHaveBeenCalledWith('
|
|
172
|
+
expect(installSkillMock).toHaveBeenCalledWith('grilling', {
|
|
162
173
|
force: true,
|
|
163
174
|
verbose: false,
|
|
164
175
|
});
|
|
165
|
-
expect(logSpy).toHaveBeenCalledWith('Updating
|
|
166
|
-
expect(logSpy).toHaveBeenCalledWith('Updating
|
|
167
|
-
expect(logSpy).toHaveBeenCalledWith('✓
|
|
168
|
-
expect(logSpy).toHaveBeenCalledWith('✓
|
|
176
|
+
expect(logSpy).toHaveBeenCalledWith('Updating delphi-review...');
|
|
177
|
+
expect(logSpy).toHaveBeenCalledWith('Updating grilling...');
|
|
178
|
+
expect(logSpy).toHaveBeenCalledWith('✓ delphi-review updated');
|
|
179
|
+
expect(logSpy).toHaveBeenCalledWith('✓ grilling updated');
|
|
169
180
|
});
|
|
170
181
|
|
|
171
182
|
it('all=true returns 1 when at least one installSkill rejects', async () => {
|
|
172
183
|
writeConfig({
|
|
173
|
-
|
|
174
|
-
|
|
184
|
+
'delphi-review': { version: '1.0.0' },
|
|
185
|
+
'grilling': { version: '1.0.0' },
|
|
175
186
|
});
|
|
176
187
|
installSkillMock.mockImplementation(async (name) => {
|
|
177
|
-
if (name === '
|
|
188
|
+
if (name === 'grilling') throw new Error('boom');
|
|
178
189
|
return 0;
|
|
179
190
|
});
|
|
180
191
|
const { updateSkill } = require('../update-skill');
|
|
181
192
|
const result = await updateSkill(null, { all: true });
|
|
182
193
|
expect(result).toBe(1);
|
|
183
|
-
expect(errorSpy).toHaveBeenCalledWith('Failed to update
|
|
184
|
-
expect(logSpy).toHaveBeenCalledWith('✓
|
|
194
|
+
expect(errorSpy).toHaveBeenCalledWith('Failed to update grilling: boom');
|
|
195
|
+
expect(logSpy).toHaveBeenCalledWith('✓ delphi-review updated');
|
|
185
196
|
});
|
|
186
197
|
|
|
187
198
|
it('all=true propagates verbose flag into installSkill options', async () => {
|
|
188
|
-
writeConfig({
|
|
199
|
+
writeConfig({ 'delphi-review': { version: '1.0.0' } });
|
|
189
200
|
const { updateSkill } = require('../update-skill');
|
|
190
201
|
const result = await updateSkill(null, { all: true, verbose: true });
|
|
191
202
|
expect(result).toBe(0);
|
|
192
|
-
expect(installSkillMock).toHaveBeenCalledWith('
|
|
203
|
+
expect(installSkillMock).toHaveBeenCalledWith('delphi-review', {
|
|
193
204
|
force: true,
|
|
194
205
|
verbose: true,
|
|
195
206
|
});
|
|
@@ -289,6 +300,68 @@ describe('update-skill', () => {
|
|
|
289
300
|
});
|
|
290
301
|
});
|
|
291
302
|
|
|
303
|
+
/**
|
|
304
|
+
* Shared fixture for the `--all` blocks below.
|
|
305
|
+
*
|
|
306
|
+
* Both blocks need the same three things before a test runs: a throwaway HOME,
|
|
307
|
+
* `install-skill` stubbed in the require cache (update-skill lazy-requires it),
|
|
308
|
+
* and console silenced. Three copies of that boilerplate tripped the clone
|
|
309
|
+
* detector -- and it had to stay byte-identical across copies to work, which is
|
|
310
|
+
* exactly the kind of duplication that rots quietly. Returns the handles each
|
|
311
|
+
* block asserts on.
|
|
312
|
+
*/
|
|
313
|
+
function makeAllModeFixture(prefix) {
|
|
314
|
+
const handle = { tmpHome: null, installSkillMock: null, logSpy: null, errorSpy: null, originalHome: null };
|
|
315
|
+
|
|
316
|
+
handle.beforeEach = () => {
|
|
317
|
+
handle.originalHome = process.env.HOME;
|
|
318
|
+
handle.tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
319
|
+
process.env.HOME = handle.tmpHome;
|
|
320
|
+
vi.resetModules();
|
|
321
|
+
delete require.cache[require.resolve('../update-skill')];
|
|
322
|
+
delete require.cache[require.resolve('../install-skill.js')];
|
|
323
|
+
handle.installSkillMock = vi.fn().mockResolvedValue(0);
|
|
324
|
+
const p = require.resolve('../install-skill.js');
|
|
325
|
+
require.cache[p] = {
|
|
326
|
+
id: p,
|
|
327
|
+
filename: p,
|
|
328
|
+
loaded: true,
|
|
329
|
+
exports: { installSkill: handle.installSkillMock },
|
|
330
|
+
};
|
|
331
|
+
handle.logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
332
|
+
handle.errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
handle.afterEach = () => {
|
|
336
|
+
process.env.HOME = handle.originalHome;
|
|
337
|
+
fs.rmSync(handle.tmpHome, { recursive: true, force: true });
|
|
338
|
+
delete require.cache[require.resolve('../install-skill.js')];
|
|
339
|
+
delete require.cache[require.resolve('../update-skill')];
|
|
340
|
+
vi.restoreAllMocks();
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Everything the run printed, log and error channels merged.
|
|
345
|
+
*
|
|
346
|
+
* Both channels matter: the same "nothing to update" message may be a log or
|
|
347
|
+
* a warning depending on the code path, and asserting on one alone silently
|
|
348
|
+
* passes when the message moves. Flattening to one string also keeps the
|
|
349
|
+
* assertions readable instead of re-deriving it in every test.
|
|
350
|
+
*/
|
|
351
|
+
handle.output = () =>
|
|
352
|
+
[...handle.logSpy.mock.calls, ...handle.errorSpy.mock.calls].map((c) => c.join(' ')).join('\n');
|
|
353
|
+
|
|
354
|
+
/** Deployment helper: writes `<HOME>/.config/<platform>/skills/<name>/SKILL.md`. */
|
|
355
|
+
handle.deploySkill = (platform, name, body) => {
|
|
356
|
+
const dir = path.join(handle.tmpHome, '.config', platform, 'skills', name);
|
|
357
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
358
|
+
fs.writeFileSync(path.join(dir, 'SKILL.md'), body);
|
|
359
|
+
return dir;
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
return handle;
|
|
363
|
+
}
|
|
364
|
+
|
|
292
365
|
// ── #682: `--all` must enumerate what is on disk, not the config registry ────
|
|
293
366
|
// `xp-gate init` / `setup-global` deploy skills to disk but never write
|
|
294
367
|
// `config.installedSkills`, so `--all` iterated an always-empty object: it
|
|
@@ -297,71 +370,275 @@ describe('update-skill', () => {
|
|
|
297
370
|
// block pins the same contract for update-skill, plus the honesty requirement
|
|
298
371
|
// that a run which updates nothing must not look like a successful one.
|
|
299
372
|
describe('update-skill #682 filesystem enumeration', () => {
|
|
373
|
+
const fx = makeAllModeFixture('xpgate-up682-');
|
|
374
|
+
|
|
375
|
+
beforeEach(fx.beforeEach);
|
|
376
|
+
afterEach(fx.afterEach);
|
|
377
|
+
|
|
378
|
+
function deploySkill(platform, name) {
|
|
379
|
+
return fx.deploySkill(platform, name, 'old content');
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
it('AC-719-01: --all updates skills found on disk even when config.installedSkills is absent', async () => {
|
|
383
|
+
deploySkill('opencode', 'sprint-flow');
|
|
384
|
+
deploySkill('opencode', 'delphi-review');
|
|
385
|
+
const { updateSkill } = require('../update-skill');
|
|
386
|
+
const code = await updateSkill(null, { all: true });
|
|
387
|
+
expect(code).toBe(0);
|
|
388
|
+
const names = fx.installSkillMock.mock.calls.map((c) => c[0]);
|
|
389
|
+
expect(names).toContain('sprint-flow');
|
|
390
|
+
expect(names).toContain('delphi-review');
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it('AC-719-02: --all does not update the same skill twice for the same platform', async () => {
|
|
394
|
+
deploySkill('opencode', 'sprint-flow');
|
|
395
|
+
fs.mkdirSync(path.join(fx.tmpHome, '.config', 'xp-gate'), { recursive: true });
|
|
396
|
+
fs.writeFileSync(
|
|
397
|
+
path.join(fx.tmpHome, '.config', 'xp-gate', 'xp-gate.json'),
|
|
398
|
+
JSON.stringify({ installedSkills: { 'sprint-flow': { version: '1.0.0' } } })
|
|
399
|
+
);
|
|
400
|
+
const { updateSkill } = require('../update-skill');
|
|
401
|
+
await updateSkill(null, { all: true });
|
|
402
|
+
const sprintCalls = fx.installSkillMock.mock.calls.filter((c) => c[0] === 'sprint-flow');
|
|
403
|
+
expect(sprintCalls.length).toBe(1);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it('AC-719-03: --all with nothing installed says so instead of silently succeeding', async () => {
|
|
407
|
+
const { updateSkill } = require('../update-skill');
|
|
408
|
+
const code = await updateSkill(null, { all: true });
|
|
409
|
+
expect(fx.output()).toMatch(/nothing to update|no skills installed|not installed/i);
|
|
410
|
+
expect(fx.installSkillMock).not.toHaveBeenCalled();
|
|
411
|
+
expect(code).toBe(0);
|
|
412
|
+
});
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
// ── #713: `--all` must only claim skills this package actually ships ─────────
|
|
416
|
+
// #682 fixed the "enumerate nothing" side of this by scanning the filesystem,
|
|
417
|
+
// but scoped the scan too widely: any `<skillsDir>/<name>/SKILL.md` was treated
|
|
418
|
+
// as ours. Skill directories are a shared namespace -- every skill tool uses the
|
|
419
|
+
// same layout -- so on a machine that also has other skill bundles, `--all`
|
|
420
|
+
// walked all of them, printed `Error: Unknown skill: <name>` for each, and told
|
|
421
|
+
// the user to `npm install -g xp-gate@latest` to obtain a skill xp-gate does not
|
|
422
|
+
// and will never carry. The fix is intersection, not union: scanned names are
|
|
423
|
+
// filtered against the bundled set before they become candidates.
|
|
424
|
+
describe('update-skill #713 bundled-skill ownership', () => {
|
|
425
|
+
const fx = makeAllModeFixture('xpgate-up713-');
|
|
426
|
+
|
|
427
|
+
beforeEach(fx.beforeEach);
|
|
428
|
+
afterEach(fx.afterEach);
|
|
429
|
+
|
|
430
|
+
function bundledNames() {
|
|
431
|
+
// Read the real bundle so the test cannot drift from the package contents.
|
|
432
|
+
const dir = path.join(__dirname, '..', '..', 'skills');
|
|
433
|
+
return fs
|
|
434
|
+
.readdirSync(dir, { withFileTypes: true })
|
|
435
|
+
.filter((e) => e.isDirectory() && fs.existsSync(path.join(dir, e.name, 'SKILL.md')))
|
|
436
|
+
.map((e) => e.name);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function deploySkill(platform, name) {
|
|
440
|
+
return fx.deploySkill(platform, name, 'content');
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* @test REQ-713 Skill ownership scoping
|
|
445
|
+
* @intent A skill directory xp-gate does not ship must never become an --all candidate
|
|
446
|
+
* @covers AC-713-01
|
|
447
|
+
*/
|
|
448
|
+
it('AC-713-01: --all ignores skills present on disk but not in the package bundle', async () => {
|
|
449
|
+
const [ours] = bundledNames();
|
|
450
|
+
deploySkill('opencode', ours);
|
|
451
|
+
deploySkill('opencode', 'clipboard-vision');
|
|
452
|
+
deploySkill('opencode', 'code-search');
|
|
453
|
+
|
|
454
|
+
const { updateSkill } = require('../update-skill');
|
|
455
|
+
const code = await updateSkill(null, { all: true });
|
|
456
|
+
|
|
457
|
+
expect(code).toBe(0);
|
|
458
|
+
const attempted = fx.installSkillMock.mock.calls.map((c) => c[0]);
|
|
459
|
+
expect(attempted).toContain(ours);
|
|
460
|
+
expect(attempted).not.toContain('clipboard-vision');
|
|
461
|
+
expect(attempted).not.toContain('code-search');
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* @test REQ-713 Skill ownership scoping
|
|
466
|
+
* @intent Foreign skills must not produce the misleading "update the package" advice
|
|
467
|
+
* @covers AC-713-02
|
|
468
|
+
*/
|
|
469
|
+
it('AC-713-02: --all prints no Unknown-skill errors for foreign skill directories', async () => {
|
|
470
|
+
const [ours] = bundledNames();
|
|
471
|
+
deploySkill('opencode', ours);
|
|
472
|
+
deploySkill('opencode', 'pdf');
|
|
473
|
+
deploySkill('opencode', 'docx');
|
|
474
|
+
|
|
475
|
+
const { updateSkill } = require('../update-skill');
|
|
476
|
+
await updateSkill(null, { all: true });
|
|
477
|
+
|
|
478
|
+
const said = fx.errorSpy.mock.calls.map((c) => c.join(' ')).join('\n');
|
|
479
|
+
expect(said).not.toMatch(/Unknown skill/);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* @test REQ-713 Skill ownership scoping
|
|
484
|
+
* @intent A machine holding only foreign skills must report nothing to update
|
|
485
|
+
* @covers AC-713-03
|
|
486
|
+
*/
|
|
487
|
+
it('AC-713-03: --all with only foreign skills reports nothing to update', async () => {
|
|
488
|
+
deploySkill('opencode', 'clipboard-vision');
|
|
489
|
+
deploySkill('opencode', 'xlsx');
|
|
490
|
+
|
|
491
|
+
const { updateSkill } = require('../update-skill');
|
|
492
|
+
const code = await updateSkill(null, { all: true });
|
|
493
|
+
|
|
494
|
+
expect(fx.output()).toMatch(/nothing to update|no skills installed/i);
|
|
495
|
+
expect(fx.installSkillMock).not.toHaveBeenCalled();
|
|
496
|
+
expect(code).toBe(0);
|
|
497
|
+
});
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
describe('update-skill #725 --check reads the disk, not the registry', () => {
|
|
300
501
|
let tmpHome;
|
|
301
502
|
let originalHome;
|
|
302
|
-
let installSkillMock;
|
|
303
503
|
let logSpy;
|
|
304
|
-
let errorSpy;
|
|
305
504
|
|
|
306
505
|
beforeEach(() => {
|
|
307
506
|
originalHome = process.env.HOME;
|
|
308
|
-
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'xpgate-
|
|
507
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'xpgate-725-'));
|
|
309
508
|
process.env.HOME = tmpHome;
|
|
310
509
|
vi.resetModules();
|
|
311
510
|
delete require.cache[require.resolve('../update-skill')];
|
|
312
|
-
delete require.cache[require.resolve('../install-skill.js')];
|
|
313
|
-
installSkillMock = vi.fn().mockResolvedValue(0);
|
|
314
|
-
const p = require.resolve('../install-skill.js');
|
|
315
|
-
require.cache[p] = { id: p, filename: p, loaded: true, exports: { installSkill: installSkillMock } };
|
|
316
511
|
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
317
|
-
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
318
512
|
});
|
|
319
513
|
|
|
320
514
|
afterEach(() => {
|
|
321
|
-
process.env.HOME = originalHome;
|
|
515
|
+
if (originalHome === undefined) delete process.env.HOME; else process.env.HOME = originalHome;
|
|
516
|
+
logSpy.mockRestore();
|
|
322
517
|
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
323
|
-
delete require.cache[require.resolve('../install-skill.js')];
|
|
324
|
-
delete require.cache[require.resolve('../update-skill')];
|
|
325
|
-
vi.restoreAllMocks();
|
|
326
518
|
});
|
|
327
519
|
|
|
328
|
-
function
|
|
329
|
-
const dir = path.join(tmpHome, '.config',
|
|
520
|
+
function writeConfig(installedSkills) {
|
|
521
|
+
const dir = path.join(tmpHome, '.config', 'xp-gate');
|
|
330
522
|
fs.mkdirSync(dir, { recursive: true });
|
|
331
|
-
fs.writeFileSync(path.join(dir, '
|
|
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
|
+
}
|
|
332
557
|
return dir;
|
|
333
558
|
}
|
|
334
559
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
+
|
|
338
574
|
const { updateSkill } = require('../update-skill');
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
const
|
|
342
|
-
|
|
343
|
-
|
|
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);
|
|
344
582
|
});
|
|
345
583
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
);
|
|
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
|
+
|
|
353
594
|
const { updateSkill } = require('../update-skill');
|
|
354
|
-
await updateSkill(null, {
|
|
355
|
-
|
|
356
|
-
|
|
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);
|
|
357
600
|
});
|
|
358
601
|
|
|
359
|
-
|
|
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
|
+
|
|
360
621
|
const { updateSkill } = require('../update-skill');
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
expect(
|
|
365
|
-
|
|
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);
|
|
366
643
|
});
|
|
367
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,
|