@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.
- package/adapter-common.sh +24 -35
- package/adapters/go.sh +6 -11
- package/adapters/kotlin.sh +53 -8
- package/adapters/python.sh +6 -11
- package/gate-13-compiler-strictness.sh +15 -1
- package/hooks/adapter-common.sh +24 -35
- package/hooks/gate-13-compiler-strictness.sh +15 -1
- package/hooks/pre-commit +137 -9
- package/lib/__tests__/doctor-delphi.test.js +3 -3
- package/lib/__tests__/doctor-tui-skill-freshness.test.js +3 -3
- package/lib/__tests__/install-skill.test.js +107 -20
- package/lib/__tests__/uninstall-global-residue.test.js +230 -9
- package/lib/__tests__/update-skill.test.js +179 -55
- package/lib/install-skill.js +18 -3
- package/lib/mattpocock-license-verdict.json +1 -1
- package/lib/uninstall.js +47 -10
- package/lib/update-skill.js +96 -26
- package/mock-policy/AGENTS.md +3 -3
- package/mutation/AGENTS.md +3 -3
- package/package.json +1 -1
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/dsh/package.json +1 -1
- package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/qoder/plugin.json +1 -1
- package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
- package/principles/AGENTS.md +3 -3
- package/scripts/delphi-external-review.cjs +68 -1
- package/skills/delphi-review/AGENTS.md +3 -3
- package/skills/sprint-flow/AGENTS.md +3 -3
- package/skills/test-specification-alignment/AGENTS.md +3 -3
|
@@ -145,51 +145,55 @@ describe('update-skill', () => {
|
|
|
145
145
|
expect(installSkillMock).not.toHaveBeenCalled();
|
|
146
146
|
});
|
|
147
147
|
|
|
148
|
+
// These编排 tests use names the bundle really carries: since #713 `--all`
|
|
149
|
+
// only claims skills xp-gate ships, so a placeholder like 'foo' is filtered
|
|
150
|
+
// out before it can exercise the loop. The orchestration behaviour under test
|
|
151
|
+
// (fan-out, error propagation, verbose passthrough) is orthogonal to ownership.
|
|
148
152
|
it('all=true updates every installed skill (calls installSkill each), returns 0 on success', async () => {
|
|
149
153
|
writeConfig({
|
|
150
|
-
|
|
151
|
-
|
|
154
|
+
'delphi-review': { version: '1.0.0' },
|
|
155
|
+
'grilling': { version: '1.0.0' },
|
|
152
156
|
});
|
|
153
157
|
const { updateSkill } = require('../update-skill');
|
|
154
158
|
const result = await updateSkill(null, { all: true });
|
|
155
159
|
expect(result).toBe(0);
|
|
156
160
|
expect(installSkillMock).toHaveBeenCalledTimes(2);
|
|
157
|
-
expect(installSkillMock).toHaveBeenCalledWith('
|
|
161
|
+
expect(installSkillMock).toHaveBeenCalledWith('delphi-review', {
|
|
158
162
|
force: true,
|
|
159
163
|
verbose: false,
|
|
160
164
|
});
|
|
161
|
-
expect(installSkillMock).toHaveBeenCalledWith('
|
|
165
|
+
expect(installSkillMock).toHaveBeenCalledWith('grilling', {
|
|
162
166
|
force: true,
|
|
163
167
|
verbose: false,
|
|
164
168
|
});
|
|
165
|
-
expect(logSpy).toHaveBeenCalledWith('Updating
|
|
166
|
-
expect(logSpy).toHaveBeenCalledWith('Updating
|
|
167
|
-
expect(logSpy).toHaveBeenCalledWith('✓
|
|
168
|
-
expect(logSpy).toHaveBeenCalledWith('✓
|
|
169
|
+
expect(logSpy).toHaveBeenCalledWith('Updating delphi-review...');
|
|
170
|
+
expect(logSpy).toHaveBeenCalledWith('Updating grilling...');
|
|
171
|
+
expect(logSpy).toHaveBeenCalledWith('✓ delphi-review updated');
|
|
172
|
+
expect(logSpy).toHaveBeenCalledWith('✓ grilling updated');
|
|
169
173
|
});
|
|
170
174
|
|
|
171
175
|
it('all=true returns 1 when at least one installSkill rejects', async () => {
|
|
172
176
|
writeConfig({
|
|
173
|
-
|
|
174
|
-
|
|
177
|
+
'delphi-review': { version: '1.0.0' },
|
|
178
|
+
'grilling': { version: '1.0.0' },
|
|
175
179
|
});
|
|
176
180
|
installSkillMock.mockImplementation(async (name) => {
|
|
177
|
-
if (name === '
|
|
181
|
+
if (name === 'grilling') throw new Error('boom');
|
|
178
182
|
return 0;
|
|
179
183
|
});
|
|
180
184
|
const { updateSkill } = require('../update-skill');
|
|
181
185
|
const result = await updateSkill(null, { all: true });
|
|
182
186
|
expect(result).toBe(1);
|
|
183
|
-
expect(errorSpy).toHaveBeenCalledWith('Failed to update
|
|
184
|
-
expect(logSpy).toHaveBeenCalledWith('✓
|
|
187
|
+
expect(errorSpy).toHaveBeenCalledWith('Failed to update grilling: boom');
|
|
188
|
+
expect(logSpy).toHaveBeenCalledWith('✓ delphi-review updated');
|
|
185
189
|
});
|
|
186
190
|
|
|
187
191
|
it('all=true propagates verbose flag into installSkill options', async () => {
|
|
188
|
-
writeConfig({
|
|
192
|
+
writeConfig({ 'delphi-review': { version: '1.0.0' } });
|
|
189
193
|
const { updateSkill } = require('../update-skill');
|
|
190
194
|
const result = await updateSkill(null, { all: true, verbose: true });
|
|
191
195
|
expect(result).toBe(0);
|
|
192
|
-
expect(installSkillMock).toHaveBeenCalledWith('
|
|
196
|
+
expect(installSkillMock).toHaveBeenCalledWith('delphi-review', {
|
|
193
197
|
force: true,
|
|
194
198
|
verbose: true,
|
|
195
199
|
});
|
|
@@ -289,79 +293,199 @@ describe('update-skill', () => {
|
|
|
289
293
|
});
|
|
290
294
|
});
|
|
291
295
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
let errorSpy;
|
|
296
|
+
/**
|
|
297
|
+
* Shared fixture for the `--all` blocks below.
|
|
298
|
+
*
|
|
299
|
+
* Both blocks need the same three things before a test runs: a throwaway HOME,
|
|
300
|
+
* `install-skill` stubbed in the require cache (update-skill lazy-requires it),
|
|
301
|
+
* and console silenced. Three copies of that boilerplate tripped the clone
|
|
302
|
+
* detector -- and it had to stay byte-identical across copies to work, which is
|
|
303
|
+
* exactly the kind of duplication that rots quietly. Returns the handles each
|
|
304
|
+
* block asserts on.
|
|
305
|
+
*/
|
|
306
|
+
function makeAllModeFixture(prefix) {
|
|
307
|
+
const handle = { tmpHome: null, installSkillMock: null, logSpy: null, errorSpy: null, originalHome: null };
|
|
305
308
|
|
|
306
|
-
beforeEach(
|
|
307
|
-
originalHome = process.env.HOME;
|
|
308
|
-
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(),
|
|
309
|
-
process.env.HOME = tmpHome;
|
|
309
|
+
handle.beforeEach = () => {
|
|
310
|
+
handle.originalHome = process.env.HOME;
|
|
311
|
+
handle.tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
312
|
+
process.env.HOME = handle.tmpHome;
|
|
310
313
|
vi.resetModules();
|
|
311
314
|
delete require.cache[require.resolve('../update-skill')];
|
|
312
315
|
delete require.cache[require.resolve('../install-skill.js')];
|
|
313
|
-
installSkillMock = vi.fn().mockResolvedValue(0);
|
|
316
|
+
handle.installSkillMock = vi.fn().mockResolvedValue(0);
|
|
314
317
|
const p = require.resolve('../install-skill.js');
|
|
315
|
-
require.cache[p] = {
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
318
|
+
require.cache[p] = {
|
|
319
|
+
id: p,
|
|
320
|
+
filename: p,
|
|
321
|
+
loaded: true,
|
|
322
|
+
exports: { installSkill: handle.installSkillMock },
|
|
323
|
+
};
|
|
324
|
+
handle.logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
325
|
+
handle.errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
326
|
+
};
|
|
319
327
|
|
|
320
|
-
afterEach(
|
|
321
|
-
process.env.HOME = originalHome;
|
|
322
|
-
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
328
|
+
handle.afterEach = () => {
|
|
329
|
+
process.env.HOME = handle.originalHome;
|
|
330
|
+
fs.rmSync(handle.tmpHome, { recursive: true, force: true });
|
|
323
331
|
delete require.cache[require.resolve('../install-skill.js')];
|
|
324
332
|
delete require.cache[require.resolve('../update-skill')];
|
|
325
333
|
vi.restoreAllMocks();
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Everything the run printed, log and error channels merged.
|
|
338
|
+
*
|
|
339
|
+
* Both channels matter: the same "nothing to update" message may be a log or
|
|
340
|
+
* a warning depending on the code path, and asserting on one alone silently
|
|
341
|
+
* passes when the message moves. Flattening to one string also keeps the
|
|
342
|
+
* assertions readable instead of re-deriving it in every test.
|
|
343
|
+
*/
|
|
344
|
+
handle.output = () =>
|
|
345
|
+
[...handle.logSpy.mock.calls, ...handle.errorSpy.mock.calls].map((c) => c.join(' ')).join('\n');
|
|
346
|
+
|
|
347
|
+
/** Deployment helper: writes `<HOME>/.config/<platform>/skills/<name>/SKILL.md`. */
|
|
348
|
+
handle.deploySkill = (platform, name, body) => {
|
|
349
|
+
const dir = path.join(handle.tmpHome, '.config', platform, 'skills', name);
|
|
330
350
|
fs.mkdirSync(dir, { recursive: true });
|
|
331
|
-
fs.writeFileSync(path.join(dir, 'SKILL.md'),
|
|
351
|
+
fs.writeFileSync(path.join(dir, 'SKILL.md'), body);
|
|
332
352
|
return dir;
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
return handle;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ── #682: `--all` must enumerate what is on disk, not the config registry ────
|
|
359
|
+
// `xp-gate init` / `setup-global` deploy skills to disk but never write
|
|
360
|
+
// `config.installedSkills`, so `--all` iterated an always-empty object: it
|
|
361
|
+
// printed "Updating all skills..." and exited 0 having touched nothing. doctor
|
|
362
|
+
// solved the identical problem in #635 by enumerating the filesystem; this
|
|
363
|
+
// block pins the same contract for update-skill, plus the honesty requirement
|
|
364
|
+
// that a run which updates nothing must not look like a successful one.
|
|
365
|
+
describe('update-skill #682 filesystem enumeration', () => {
|
|
366
|
+
const fx = makeAllModeFixture('xpgate-up682-');
|
|
367
|
+
|
|
368
|
+
beforeEach(fx.beforeEach);
|
|
369
|
+
afterEach(fx.afterEach);
|
|
370
|
+
|
|
371
|
+
function deploySkill(platform, name) {
|
|
372
|
+
return fx.deploySkill(platform, name, 'old content');
|
|
333
373
|
}
|
|
334
374
|
|
|
335
|
-
it('AC-
|
|
375
|
+
it('AC-719-01: --all updates skills found on disk even when config.installedSkills is absent', async () => {
|
|
336
376
|
deploySkill('opencode', 'sprint-flow');
|
|
337
377
|
deploySkill('opencode', 'delphi-review');
|
|
338
378
|
const { updateSkill } = require('../update-skill');
|
|
339
379
|
const code = await updateSkill(null, { all: true });
|
|
340
380
|
expect(code).toBe(0);
|
|
341
|
-
const names = installSkillMock.mock.calls.map((c) => c[0]);
|
|
381
|
+
const names = fx.installSkillMock.mock.calls.map((c) => c[0]);
|
|
342
382
|
expect(names).toContain('sprint-flow');
|
|
343
383
|
expect(names).toContain('delphi-review');
|
|
344
384
|
});
|
|
345
385
|
|
|
346
|
-
it('AC-
|
|
386
|
+
it('AC-719-02: --all does not update the same skill twice for the same platform', async () => {
|
|
347
387
|
deploySkill('opencode', 'sprint-flow');
|
|
348
|
-
fs.mkdirSync(path.join(tmpHome, '.config', 'xp-gate'), { recursive: true });
|
|
388
|
+
fs.mkdirSync(path.join(fx.tmpHome, '.config', 'xp-gate'), { recursive: true });
|
|
349
389
|
fs.writeFileSync(
|
|
350
|
-
path.join(tmpHome, '.config', 'xp-gate', 'xp-gate.json'),
|
|
390
|
+
path.join(fx.tmpHome, '.config', 'xp-gate', 'xp-gate.json'),
|
|
351
391
|
JSON.stringify({ installedSkills: { 'sprint-flow': { version: '1.0.0' } } })
|
|
352
392
|
);
|
|
353
393
|
const { updateSkill } = require('../update-skill');
|
|
354
394
|
await updateSkill(null, { all: true });
|
|
355
|
-
const sprintCalls = installSkillMock.mock.calls.filter((c) => c[0] === 'sprint-flow');
|
|
395
|
+
const sprintCalls = fx.installSkillMock.mock.calls.filter((c) => c[0] === 'sprint-flow');
|
|
356
396
|
expect(sprintCalls.length).toBe(1);
|
|
357
397
|
});
|
|
358
398
|
|
|
359
|
-
it('AC-
|
|
399
|
+
it('AC-719-03: --all with nothing installed says so instead of silently succeeding', async () => {
|
|
360
400
|
const { updateSkill } = require('../update-skill');
|
|
361
401
|
const code = await updateSkill(null, { all: true });
|
|
362
|
-
|
|
363
|
-
expect(
|
|
364
|
-
expect(
|
|
402
|
+
expect(fx.output()).toMatch(/nothing to update|no skills installed|not installed/i);
|
|
403
|
+
expect(fx.installSkillMock).not.toHaveBeenCalled();
|
|
404
|
+
expect(code).toBe(0);
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
// ── #713: `--all` must only claim skills this package actually ships ─────────
|
|
409
|
+
// #682 fixed the "enumerate nothing" side of this by scanning the filesystem,
|
|
410
|
+
// but scoped the scan too widely: any `<skillsDir>/<name>/SKILL.md` was treated
|
|
411
|
+
// as ours. Skill directories are a shared namespace -- every skill tool uses the
|
|
412
|
+
// same layout -- so on a machine that also has other skill bundles, `--all`
|
|
413
|
+
// walked all of them, printed `Error: Unknown skill: <name>` for each, and told
|
|
414
|
+
// the user to `npm install -g xp-gate@latest` to obtain a skill xp-gate does not
|
|
415
|
+
// and will never carry. The fix is intersection, not union: scanned names are
|
|
416
|
+
// filtered against the bundled set before they become candidates.
|
|
417
|
+
describe('update-skill #713 bundled-skill ownership', () => {
|
|
418
|
+
const fx = makeAllModeFixture('xpgate-up713-');
|
|
419
|
+
|
|
420
|
+
beforeEach(fx.beforeEach);
|
|
421
|
+
afterEach(fx.afterEach);
|
|
422
|
+
|
|
423
|
+
function bundledNames() {
|
|
424
|
+
// Read the real bundle so the test cannot drift from the package contents.
|
|
425
|
+
const dir = path.join(__dirname, '..', '..', 'skills');
|
|
426
|
+
return fs
|
|
427
|
+
.readdirSync(dir, { withFileTypes: true })
|
|
428
|
+
.filter((e) => e.isDirectory() && fs.existsSync(path.join(dir, e.name, 'SKILL.md')))
|
|
429
|
+
.map((e) => e.name);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function deploySkill(platform, name) {
|
|
433
|
+
return fx.deploySkill(platform, name, 'content');
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* @test REQ-713 Skill ownership scoping
|
|
438
|
+
* @intent A skill directory xp-gate does not ship must never become an --all candidate
|
|
439
|
+
* @covers AC-713-01
|
|
440
|
+
*/
|
|
441
|
+
it('AC-713-01: --all ignores skills present on disk but not in the package bundle', async () => {
|
|
442
|
+
const [ours] = bundledNames();
|
|
443
|
+
deploySkill('opencode', ours);
|
|
444
|
+
deploySkill('opencode', 'clipboard-vision');
|
|
445
|
+
deploySkill('opencode', 'code-search');
|
|
446
|
+
|
|
447
|
+
const { updateSkill } = require('../update-skill');
|
|
448
|
+
const code = await updateSkill(null, { all: true });
|
|
449
|
+
|
|
450
|
+
expect(code).toBe(0);
|
|
451
|
+
const attempted = fx.installSkillMock.mock.calls.map((c) => c[0]);
|
|
452
|
+
expect(attempted).toContain(ours);
|
|
453
|
+
expect(attempted).not.toContain('clipboard-vision');
|
|
454
|
+
expect(attempted).not.toContain('code-search');
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* @test REQ-713 Skill ownership scoping
|
|
459
|
+
* @intent Foreign skills must not produce the misleading "update the package" advice
|
|
460
|
+
* @covers AC-713-02
|
|
461
|
+
*/
|
|
462
|
+
it('AC-713-02: --all prints no Unknown-skill errors for foreign skill directories', async () => {
|
|
463
|
+
const [ours] = bundledNames();
|
|
464
|
+
deploySkill('opencode', ours);
|
|
465
|
+
deploySkill('opencode', 'pdf');
|
|
466
|
+
deploySkill('opencode', 'docx');
|
|
467
|
+
|
|
468
|
+
const { updateSkill } = require('../update-skill');
|
|
469
|
+
await updateSkill(null, { all: true });
|
|
470
|
+
|
|
471
|
+
const said = fx.errorSpy.mock.calls.map((c) => c.join(' ')).join('\n');
|
|
472
|
+
expect(said).not.toMatch(/Unknown skill/);
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* @test REQ-713 Skill ownership scoping
|
|
477
|
+
* @intent A machine holding only foreign skills must report nothing to update
|
|
478
|
+
* @covers AC-713-03
|
|
479
|
+
*/
|
|
480
|
+
it('AC-713-03: --all with only foreign skills reports nothing to update', async () => {
|
|
481
|
+
deploySkill('opencode', 'clipboard-vision');
|
|
482
|
+
deploySkill('opencode', 'xlsx');
|
|
483
|
+
|
|
484
|
+
const { updateSkill } = require('../update-skill');
|
|
485
|
+
const code = await updateSkill(null, { all: true });
|
|
486
|
+
|
|
487
|
+
expect(fx.output()).toMatch(/nothing to update|no skills installed/i);
|
|
488
|
+
expect(fx.installSkillMock).not.toHaveBeenCalled();
|
|
365
489
|
expect(code).toBe(0);
|
|
366
490
|
});
|
|
367
491
|
});
|
package/lib/install-skill.js
CHANGED
|
@@ -19,12 +19,27 @@ function getSkillsDir() {
|
|
|
19
19
|
// The npm package ships the canonical skill content; `files` includes skills/.
|
|
20
20
|
const BUNDLED_SKILLS_DIR = path.join(__dirname, '..', 'skills');
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* The version of the CLI that is actually running.
|
|
24
|
+
*
|
|
25
|
+
* This used to read `../../../VERSION` relative to __dirname. Two things were
|
|
26
|
+
* wrong with that (#713): three levels up from `node_modules/@boyingliu01/
|
|
27
|
+
* xp-gate/lib` is `node_modules/`, and the package does not ship VERSION at all
|
|
28
|
+
* (`files` omits it). The catch therefore returned `0.0.0` on every installed
|
|
29
|
+
* machine, so `xp-gate 0.0.0 bundles: ...` -- printed as help when an unknown
|
|
30
|
+
* skill is requested -- read as a corrupt installation rather than as a typo.
|
|
31
|
+
*
|
|
32
|
+
* package.json travels with the package by definition, so it is the only source
|
|
33
|
+
* that is always present and always current.
|
|
34
|
+
*
|
|
35
|
+
* @returns {string} the package version, or 'unknown' if it cannot be read
|
|
36
|
+
*/
|
|
22
37
|
function getCliVersion() {
|
|
23
38
|
try {
|
|
24
|
-
const
|
|
25
|
-
return
|
|
39
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
|
|
40
|
+
return typeof pkg.version === 'string' && pkg.version ? pkg.version : 'unknown';
|
|
26
41
|
} catch {
|
|
27
|
-
return '
|
|
42
|
+
return 'unknown';
|
|
28
43
|
}
|
|
29
44
|
}
|
|
30
45
|
|
package/lib/uninstall.js
CHANGED
|
@@ -27,15 +27,24 @@ function globalSkillRoots() {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
|
-
* Directories inside ~/.config/xp-gate/ that `setup-global` creates
|
|
31
|
-
* `uninstall` used to leave behind (#663).
|
|
30
|
+
* Directories and files inside ~/.config/xp-gate/ that `setup-global` creates
|
|
31
|
+
* and that `uninstall` used to leave behind (#663).
|
|
32
|
+
*
|
|
33
|
+
* `ownedByPath: true` marks entries the installer writes at a FIXED path under
|
|
34
|
+
* our own config directory -- there is no other way they got there, so a content
|
|
35
|
+
* signature is the wrong test for them (#714). It matters most for
|
|
36
|
+
* `package.json`: its body is nothing but dependency pins (`js-yaml`, `zod`), so
|
|
37
|
+
* no signature string can ever match it, and demanding one meant the file was
|
|
38
|
+
* scheduled for deletion and then skipped at delete time. `xp-gate.json` is
|
|
39
|
+
* equally ours -- the uninstaller itself writes `mode: "uninstalled"` into it
|
|
40
|
+
* moments earlier -- and was likewise reported as foreign.
|
|
32
41
|
*/
|
|
33
42
|
const GLOBAL_INSTALL_RESIDUE = [
|
|
34
|
-
{ key: 'modules', label: 'modules/' },
|
|
35
|
-
{ key: 'node_modules', label: 'node_modules/' },
|
|
36
|
-
{ key: 'package.json', label: 'package.json' },
|
|
37
|
-
{ key: 'package-lock.json', label: 'package-lock.json' },
|
|
38
|
-
{ key: 'xp-gate.json', label: 'xp-gate.json' },
|
|
43
|
+
{ key: 'modules', label: 'modules/', ownedByPath: true },
|
|
44
|
+
{ key: 'node_modules', label: 'node_modules/', ownedByPath: true },
|
|
45
|
+
{ key: 'package.json', label: 'package.json', ownedByPath: true },
|
|
46
|
+
{ key: 'package-lock.json', label: 'package-lock.json', ownedByPath: true },
|
|
47
|
+
{ key: 'xp-gate.json', label: 'xp-gate.json', ownedByPath: true },
|
|
39
48
|
];
|
|
40
49
|
|
|
41
50
|
/** User DATA that must survive an uninstall, and must be reported as retained. */
|
|
@@ -325,7 +334,12 @@ function buildPlan(mode) {
|
|
|
325
334
|
type: isDir ? 'dir' : 'file',
|
|
326
335
|
path: target,
|
|
327
336
|
label: `~/.config/xp-gate/${item.label}`,
|
|
328
|
-
critical: false
|
|
337
|
+
critical: false,
|
|
338
|
+
// #714: a fixed-path file we install ourselves needs no content
|
|
339
|
+
// signature. Carrying the flag through the plan is what makes the
|
|
340
|
+
// delete actually happen instead of stopping at "does not contain
|
|
341
|
+
// xp-gate signature".
|
|
342
|
+
ownedByPath: item.ownedByPath === true
|
|
329
343
|
});
|
|
330
344
|
}
|
|
331
345
|
}
|
|
@@ -365,6 +379,20 @@ function buildPlan(mode) {
|
|
|
365
379
|
* @returns {boolean} true on success, false on skip (not xp-gate owned)
|
|
366
380
|
*/
|
|
367
381
|
function removeFile(item, config) {
|
|
382
|
+
// #714: `ownedByPath` entries are files the installer writes at a fixed path
|
|
383
|
+
// inside our own ~/.config/xp-gate/ directory, so ownership is established by
|
|
384
|
+
// the path itself. Without this branch the residue files fell through to
|
|
385
|
+
// verifyFileOwnership() with neither a manifest entry nor a signature, and
|
|
386
|
+
// isXpGateFile() evaluated `content.includes(undefined)` -- always false. The
|
|
387
|
+
// file was listed in the plan, "uninstall complete" was printed, and the file
|
|
388
|
+
// stayed.
|
|
389
|
+
if (item.ownedByPath === true) {
|
|
390
|
+
if (!fs.existsSync(item.path)) return false;
|
|
391
|
+
fs.unlinkSync(item.path);
|
|
392
|
+
console.log(` Removed ${item.label}`);
|
|
393
|
+
return true;
|
|
394
|
+
}
|
|
395
|
+
|
|
368
396
|
const manifestEntry = config.manifest && config.manifest.files
|
|
369
397
|
? config.manifest.files[item.manifestKey]
|
|
370
398
|
: null;
|
|
@@ -740,8 +768,17 @@ function planHooksDirRemoval(hooksDir) {
|
|
|
740
768
|
: { action: 'remove-all', foreign: [], owned };
|
|
741
769
|
}
|
|
742
770
|
|
|
743
|
-
/**
|
|
744
|
-
|
|
771
|
+
/**
|
|
772
|
+
* Hook entry points xp-gate installs.
|
|
773
|
+
*
|
|
774
|
+
* This must stay in sync with `copyHooks()`'s `hookFiles` list, which is what
|
|
775
|
+
* actually puts them on disk. `post-commit` was missing here (#714), so the
|
|
776
|
+
* uninstaller classified its own hook as a third party's and kept it -- the
|
|
777
|
+
* file contains nine `xp-gate` references and is installed unconditionally by
|
|
778
|
+
* setup-global. The set is derived from the installer's list rather than
|
|
779
|
+
* retyped, so the two cannot drift apart again.
|
|
780
|
+
*/
|
|
781
|
+
const OWNED_HOOK_NAMES = ['pre-commit', 'pre-push', 'post-merge', 'post-commit'];
|
|
745
782
|
|
|
746
783
|
/** 'git-lfs' when the file proves it, else null. Content-based: see doctor.js. */
|
|
747
784
|
function foreignHookSource(filePath) {
|
package/lib/update-skill.js
CHANGED
|
@@ -7,6 +7,32 @@ const HOME = process.env.HOME || process.env.USERPROFILE || os.homedir();
|
|
|
7
7
|
|
|
8
8
|
const CONFIG_DIR = path.join(HOME, '.config', 'xp-gate');
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* The skill names this package ships.
|
|
12
|
+
*
|
|
13
|
+
* `skills/` is the bundle contract: a skill the installed package does not carry
|
|
14
|
+
* is not ours to touch. Resolved lazily so a caller that stubs out the package
|
|
15
|
+
* layout still gets a sensible empty set instead of a throw at require() time.
|
|
16
|
+
*
|
|
17
|
+
* @returns {Set<string>} bundled skill directory names
|
|
18
|
+
*/
|
|
19
|
+
function bundledSkillNames() {
|
|
20
|
+
const dir = path.join(__dirname, '..', 'skills');
|
|
21
|
+
try {
|
|
22
|
+
return new Set(
|
|
23
|
+
fs
|
|
24
|
+
.readdirSync(dir, { withFileTypes: true })
|
|
25
|
+
.filter(
|
|
26
|
+
(entry) =>
|
|
27
|
+
entry.isDirectory() && fs.existsSync(path.join(dir, entry.name, 'SKILL.md'))
|
|
28
|
+
)
|
|
29
|
+
.map((entry) => entry.name)
|
|
30
|
+
);
|
|
31
|
+
} catch {
|
|
32
|
+
return new Set();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
10
36
|
/**
|
|
11
37
|
* The skill names that are actually deployed on this machine.
|
|
12
38
|
*
|
|
@@ -16,46 +42,90 @@ const CONFIG_DIR = path.join(HOME, '.config', 'xp-gate');
|
|
|
16
42
|
* "Updating all skills..." and exit 0 having touched nothing (#682) -- the
|
|
17
43
|
* same defect #635 fixed for `doctor`, with the same fix: ask the filesystem.
|
|
18
44
|
*
|
|
45
|
+
* Enumerating the filesystem alone is too wide, though (#713). A skill
|
|
46
|
+
* directory is a shared namespace -- every skill tool uses the same
|
|
47
|
+
* `<dir>/<name>/SKILL.md` layout -- so on a machine that also carries other
|
|
48
|
+
* bundles, `--all` walked all of them, printed `Error: Unknown skill: <name>`
|
|
49
|
+
* for each, and advised `npm install -g xp-gate@latest` to obtain a skill this
|
|
50
|
+
* package does not and will never ship. Scanned names are therefore filtered
|
|
51
|
+
* against the bundle: the candidate set is the intersection, not the union.
|
|
52
|
+
*
|
|
19
53
|
* The explicit registry is still unioned in, so a skill that is merely
|
|
20
54
|
* registered (or was installed on a platform this scan cannot see) keeps
|
|
21
|
-
* working for callers that rely on it
|
|
55
|
+
* working for callers that rely on it -- but only when the package really
|
|
56
|
+
* carries it, for the same reason as above.
|
|
22
57
|
*
|
|
23
58
|
* @returns {string[]} de-duplicated skill names, in a stable order
|
|
24
59
|
*/
|
|
25
60
|
function installedSkillNames() {
|
|
61
|
+
const bundled = bundledSkillNames();
|
|
26
62
|
const names = new Set();
|
|
27
|
-
const config = getConfig();
|
|
28
|
-
for (const name of Object.keys(config.installedSkills || {})) names.add(name);
|
|
29
63
|
|
|
30
|
-
|
|
64
|
+
// Registry first: a skill can be registered on a platform this scan cannot see.
|
|
65
|
+
for (const name of Object.keys(getConfig().installedSkills || {})) {
|
|
66
|
+
if (bundled.has(name)) names.add(name);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
for (const name of bundledNamesOnDisk(bundled)) names.add(name);
|
|
70
|
+
return [...names].sort();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Bundled skill names that are actually deployed on this machine.
|
|
75
|
+
*
|
|
76
|
+
* Split out of installedSkillNames() because the per-platform probing is three
|
|
77
|
+
* nested try/catch levels deep on its own: reading a directory that may not
|
|
78
|
+
* exist, on a platform whose detection may itself throw. Flattening it into a
|
|
79
|
+
* named loop keeps each failure mode visible instead of piling onto one
|
|
80
|
+
* function's complexity budget.
|
|
81
|
+
*
|
|
82
|
+
* Every unreadable location is skipped silently -- an absent platform is the
|
|
83
|
+
* normal case on any given machine, not an error worth reporting mid-update.
|
|
84
|
+
*
|
|
85
|
+
* @param {Set<string>} bundled names the package ships; anything else is not ours
|
|
86
|
+
* @returns {string[]} deployed-and-bundled skill names
|
|
87
|
+
*/
|
|
88
|
+
function bundledNamesOnDisk(bundled) {
|
|
89
|
+
const found = [];
|
|
90
|
+
let detect;
|
|
91
|
+
let platforms;
|
|
31
92
|
try {
|
|
32
|
-
|
|
93
|
+
detect = require('./detect-deps.js');
|
|
94
|
+
platforms = Object.keys(detect.PLATFORM_PROFILES);
|
|
33
95
|
} catch {
|
|
34
|
-
// detect-deps unavailable:
|
|
35
|
-
//
|
|
36
|
-
|
|
96
|
+
// detect-deps unavailable: the registry half of installedSkillNames() still
|
|
97
|
+
// applies, so returning nothing here does not report "no skills installed".
|
|
98
|
+
return found;
|
|
37
99
|
}
|
|
38
|
-
|
|
100
|
+
|
|
39
101
|
for (const platform of platforms) {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
dir
|
|
43
|
-
} catch {
|
|
44
|
-
continue;
|
|
45
|
-
}
|
|
46
|
-
let entries;
|
|
47
|
-
try {
|
|
48
|
-
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
49
|
-
} catch {
|
|
50
|
-
continue;
|
|
51
|
-
}
|
|
52
|
-
for (const entry of entries) {
|
|
53
|
-
if (!entry.isDirectory()) continue;
|
|
54
|
-
// A skill is `<dir>/<name>/SKILL.md` — the shape install-skill writes.
|
|
55
|
-
if (fs.existsSync(path.join(dir, entry.name, 'SKILL.md'))) names.add(entry.name);
|
|
102
|
+
for (const entry of readSkillsDirEntries(detect, platform)) {
|
|
103
|
+
if (!entry.isDirectory || !bundled.has(entry.name)) continue;
|
|
104
|
+
if (fs.existsSync(path.join(entry.dir, entry.name, 'SKILL.md'))) found.push(entry.name);
|
|
56
105
|
}
|
|
57
106
|
}
|
|
58
|
-
return
|
|
107
|
+
return found;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Read one platform's skills directory as plain records, so callers get the
|
|
112
|
+
* directory back without depending on dirent internals.
|
|
113
|
+
* @returns {Array<{name: string, isDirectory: boolean, dir: string}>} empty when unreadable
|
|
114
|
+
*/
|
|
115
|
+
function readSkillsDirEntries(detect, platform) {
|
|
116
|
+
let dir;
|
|
117
|
+
try {
|
|
118
|
+
dir = detect.getSkillsDirs(platform)[0];
|
|
119
|
+
} catch {
|
|
120
|
+
return [];
|
|
121
|
+
}
|
|
122
|
+
let entries;
|
|
123
|
+
try {
|
|
124
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
125
|
+
} catch {
|
|
126
|
+
return [];
|
|
127
|
+
}
|
|
128
|
+
return entries.map((entry) => ({ name: entry.name, isDirectory: entry.isDirectory(), dir }));
|
|
59
129
|
}
|
|
60
130
|
|
|
61
131
|
function handleCheckMode() {
|
package/mock-policy/AGENTS.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# SRC/MOCK-POLICY KNOWLEDGE BASE
|
|
2
2
|
|
|
3
|
-
**Generated:** 2026-11-
|
|
4
|
-
**Commit:**
|
|
3
|
+
**Generated:** 2026-11-03
|
|
4
|
+
**Commit:** fa007d1f
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.35.
|
|
6
|
+
**Version:** 0.35.24.0
|
|
7
7
|
|
|
8
8
|
## OVERVIEW
|
|
9
9
|
Mock layering policy enforcement — Gate M3 of pre-push hook. Ensures integration tests use real implementations for internal dependencies, mock external dependencies, and annotate pending mocks with removal plans. Combines project scope scanning, mock decision engine, and per-file validation into a single pipeline.
|
package/mutation/AGENTS.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# SRC/MUTATION KNOWLEDGE BASE
|
|
2
2
|
|
|
3
|
-
**Generated:** 2026-11-
|
|
4
|
-
**Commit:**
|
|
3
|
+
**Generated:** 2026-11-03
|
|
4
|
+
**Commit:** fa007d1f
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.35.
|
|
6
|
+
**Version:** 0.35.24.0
|
|
7
7
|
|
|
8
8
|
## OVERVIEW
|
|
9
9
|
**Gate M** (incremental mutation testing) + **Gate M2** helpers (test-layer detection used by `src/mock-policy/`). Pre-push quality gate. TypeScript-only; uses Stryker.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "xp-gate",
|
|
3
|
-
"version": "0.35.
|
|
3
|
+
"version": "0.35.24",
|
|
4
4
|
"displayName": "XP-Gate",
|
|
5
5
|
"description": "Extreme Programming quality gates + AI workflow skills for Claude Code. Includes 12 pre-commit gates (Gate 0-11), 8 pre-push gates, Sprint Flow (6 phases), and Delphi multi-expert review (>=90% consensus).",
|
|
6
6
|
"author": {
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# SKILLS/DELPHI-REVIEW KNOWLEDGE BASE
|
|
2
2
|
|
|
3
|
-
**Generated:** 2026-11-
|
|
4
|
-
**Commit:**
|
|
3
|
+
**Generated:** 2026-11-03
|
|
4
|
+
**Commit:** fa007d1f
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.35.
|
|
6
|
+
**Version:** 0.35.24.0
|
|
7
7
|
|
|
8
8
|
## OVERVIEW
|
|
9
9
|
Delphi Consensus Review — multi-round anonymous expert review (≥90% threshold, exactly 3 experts with distinct executable model IDs). Supports design, requirements, and code-walkthrough modes.
|