@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
|
@@ -58,27 +58,41 @@ function journalText(events) {
|
|
|
58
58
|
function toolPair(callId, name, args, startT, endT, result = {}, pos = {}) {
|
|
59
59
|
const turn = pos.turn ?? 1;
|
|
60
60
|
const step = pos.step ?? 1;
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
61
|
+
const call = {
|
|
62
|
+
type: 'tool/call',
|
|
63
|
+
time: startT,
|
|
64
|
+
data: { turn, step, callId, name, arguments: JSON.stringify(args) },
|
|
65
|
+
};
|
|
66
|
+
// Built separately so the pair stays flat: inlining it put the event five
|
|
67
|
+
// objects deep, which trips clean-code.deep-nesting (max 4) on every run.
|
|
68
|
+
const reply = {
|
|
69
|
+
type: 'tool/result',
|
|
70
|
+
time: endT,
|
|
71
|
+
data: { turn, step, message: toolResultMessage(callId, result) },
|
|
72
|
+
};
|
|
73
|
+
return [call, reply];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The `message` payload of a tool/result event. */
|
|
77
|
+
function toolResultMessage(callId, result) {
|
|
78
|
+
return {
|
|
79
|
+
role: 'tool',
|
|
80
|
+
toolCallId: callId,
|
|
81
|
+
isError: !!result.isError,
|
|
82
|
+
content: [{ type: 'text', text: result.text ?? 'ok' }],
|
|
83
|
+
};
|
|
78
84
|
}
|
|
79
85
|
|
|
80
86
|
describe('parseSessionBuffer', () => {
|
|
81
|
-
|
|
87
|
+
// `zlib.zstdCompressSync` / `zstdDecompressSync` landed in Node 22. CI pins
|
|
88
|
+
// Node 20.x, so without this guard the test throws "zstdCompressSync is not a
|
|
89
|
+
// function" there and turns Gate 5 red on a machine difference rather than a
|
|
90
|
+
// code defect. Locally (Node 24) it passed, which is exactly why the gap
|
|
91
|
+
// survived. Feature-detected rather than version-sniffed, so a backport or a
|
|
92
|
+
// future rename cannot silently disable the coverage.
|
|
93
|
+
const hasZstd = typeof zlib.zstdCompressSync === 'function' && typeof zlib.zstdDecompressSync === 'function';
|
|
94
|
+
|
|
95
|
+
it.skipIf(!hasZstd)('reads a zstd-compressed session journal', () => {
|
|
82
96
|
const events = toolPair('c1', 'read', { file_path: '/a.ts' }, 1000, 1010);
|
|
83
97
|
const buf = zlib.zstdCompressSync(Buffer.from(journalText(events), 'utf8'));
|
|
84
98
|
const parsed = parseSessionBuffer(buf);
|
|
@@ -86,6 +100,16 @@ describe('parseSessionBuffer', () => {
|
|
|
86
100
|
expect(parsed[0].type).toBe('tool/call');
|
|
87
101
|
});
|
|
88
102
|
|
|
103
|
+
it('skips a zstd-magic buffer it cannot decode, rather than throwing', () => {
|
|
104
|
+
// The counterpart to the test above: on a runtime without zstd the magic
|
|
105
|
+
// number still gets recognised, `zstdDecompressSync` is missing, and the
|
|
106
|
+
// catch must degrade to "no events" instead of crashing the whole journal.
|
|
107
|
+
// This runs on every Node version, so the degraded path is never untested.
|
|
108
|
+
const magic = Buffer.from([0x28, 0xb5, 0x2f, 0xfd, 0x00, 0x00, 0x00, 0x00]);
|
|
109
|
+
expect(() => parseSessionBuffer(magic)).not.toThrow();
|
|
110
|
+
expect(parseSessionBuffer(magic)).toEqual([]);
|
|
111
|
+
});
|
|
112
|
+
|
|
89
113
|
it('reads an uncompressed journal too, so fixtures need no encoder', () => {
|
|
90
114
|
const events = toolPair('c1', 'read', { file_path: '/a.ts' }, 1000, 1010);
|
|
91
115
|
const parsed = parseSessionBuffer(Buffer.from(journalText(events), 'utf8'));
|
|
@@ -43,6 +43,32 @@ function runCheck(relPath) {
|
|
|
43
43
|
return { stdout, status, elapsed: Date.now() - started };
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
* 量一次「同一 CLI 做最少的事」要多久——只解析参数并打印版本,不跑任何 gate。
|
|
48
|
+
* 用作 AC-653-05 的相对基线:Node 启动 + bin 模块加载 + 依赖解析的固有成本,
|
|
49
|
+
* 这部分与机器负载同步伸缩,减掉它才能量出 check 自己的开销。
|
|
50
|
+
*
|
|
51
|
+
* 取三次的最小值:最小值受负载尖峰影响最小,比平均值稳定得多。
|
|
52
|
+
*/
|
|
53
|
+
function measure(args) {
|
|
54
|
+
let best = Infinity;
|
|
55
|
+
for (let i = 0; i < 3; i += 1) {
|
|
56
|
+
const started = Date.now();
|
|
57
|
+
try {
|
|
58
|
+
execFileSync(process.execPath, [CLI, ...args], {
|
|
59
|
+
cwd: REPO,
|
|
60
|
+
encoding: 'utf8',
|
|
61
|
+
timeout: 120000,
|
|
62
|
+
stdio: 'pipe',
|
|
63
|
+
});
|
|
64
|
+
} catch {
|
|
65
|
+
// --version 不该失败;真失败时这一次不计入,别把错误吞成 0ms。
|
|
66
|
+
}
|
|
67
|
+
best = Math.min(best, Date.now() - started);
|
|
68
|
+
}
|
|
69
|
+
return best;
|
|
70
|
+
}
|
|
71
|
+
|
|
46
72
|
describe('check --gates 单文件自检 (#653)', () => {
|
|
47
73
|
it('AC-653-04: 传文件路径时 gate 真的执行,而不是报 spawn 失败', () => {
|
|
48
74
|
const target = 'src/npm-package/lib/doctor-brief.js';
|
|
@@ -54,10 +80,35 @@ describe('check --gates 单文件自检 (#653)', () => {
|
|
|
54
80
|
expect(stdout).toMatch(/principles/i);
|
|
55
81
|
});
|
|
56
82
|
|
|
57
|
-
it('AC-653-05:
|
|
83
|
+
it('AC-653-05: 单文件自检的增量开销远小于一次完整 pre-commit', () => {
|
|
84
|
+
// #653 的价值主张:提交前自检要快过等一轮完整 pre-commit(几分钟)。
|
|
85
|
+
// "快路径"如果慢就不叫快路径。
|
|
86
|
+
//
|
|
87
|
+
// 但这里不能用绝对墙钟阈值。原实现写 `elapsed < 2000`,实测同一台机器:
|
|
88
|
+
// 裸跑 `node bin/xp-gate.js check ...` 654–822ms(远快于阈值)
|
|
89
|
+
// 在 vitest 里跑同一命令 2191–2352ms(偶尔超阈值)
|
|
90
|
+
// 它测的是「机器此刻有多空」,不是「这个功能够不够快」——并行跑测试时翻红、
|
|
91
|
+
// 空闲时变绿,红绿与功能无关(#722)。一个随负载漂移的断言,既不能证明快,
|
|
92
|
+
// 也不能证明慢。
|
|
93
|
+
//
|
|
94
|
+
// 改成相对判定:先用 `--version` 量出「同一 CLI、同一 Node 启动、同一份模块
|
|
95
|
+
// 加载」的固有成本,再看 check 是它的几倍。两者对机器快慢同步伸缩,比值因此
|
|
96
|
+
// 稳定。实测:
|
|
97
|
+
// 空闲 check 852ms / base 172ms = 4.95x
|
|
98
|
+
// 重负载 check 2563ms / base 445ms = 5.76x (4 个 CPU 密集进程并行)
|
|
99
|
+
// 比值只涨 16%,而绝对时间涨 3 倍——所以比值才是与负载无关的那个量。
|
|
100
|
+
//
|
|
101
|
+
// 门槛取 12x:它意味着 check 花的时间不超过「启动 CLI 本身」的一打倍——
|
|
102
|
+
// 一次完整 pre-commit 是分钟级,12x 启动成本仍是亚秒到一秒级,离「慢」很远;
|
|
103
|
+
// 同时留足余量,不会因为机器一时抖动就翻红。
|
|
104
|
+
const baseline = measure(['--version']);
|
|
58
105
|
const { elapsed } = runCheck('src/npm-package/lib/doctor-brief.js');
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
expect(
|
|
106
|
+
const ratio = elapsed / baseline;
|
|
107
|
+
|
|
108
|
+
expect(ratio).toBeLessThan(12);
|
|
109
|
+
// 翻红时把三个数字一起打出来,能立刻分辨是「基线被拖慢」还是「check 真变慢」。
|
|
110
|
+
expect(
|
|
111
|
+
`${elapsed}ms / ${baseline}ms = ${ratio.toFixed(1)}x`,
|
|
112
|
+
).toMatch(/^\d+ms \/ \d+ms = [\d.]+x$/);
|
|
62
113
|
});
|
|
63
114
|
});
|
|
@@ -104,3 +104,46 @@ describe('PLATFORM_PROFILES covers dsh (#635)', () => {
|
|
|
104
104
|
expect(getSkillsDirs('claude-code')[0]).toBe(path.join(tmpHome, '.claude', 'skills'));
|
|
105
105
|
});
|
|
106
106
|
});
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* @test REQ-725
|
|
110
|
+
* @intent 多平台共存时 detectPlatform() 按优先级短路返回单个平台,install-skill/
|
|
111
|
+
* update-skill 与 doctor 都只拿它选中的目录,其余平台(含 detectPlatform()
|
|
112
|
+
* 从不返回的 dsh)被永久冻结。detectInstalledPlatforms() 是「检测所有已安装
|
|
113
|
+
* agent」的原始能力:按 PLATFORM_PROFILES 里目录真实存在的平台返回全集。
|
|
114
|
+
* @covers AC-725-01, AC-725-06
|
|
115
|
+
*/
|
|
116
|
+
describe('detectInstalledPlatforms (#725 detect-all)', () => {
|
|
117
|
+
let tmpHome;
|
|
118
|
+
let originalHome;
|
|
119
|
+
|
|
120
|
+
beforeEach(() => {
|
|
121
|
+
originalHome = process.env.HOME;
|
|
122
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'xpgate-detectall-'));
|
|
123
|
+
process.env.HOME = tmpHome;
|
|
124
|
+
vi.resetModules();
|
|
125
|
+
delete require.cache[require.resolve('../detect-deps')];
|
|
126
|
+
delete require.cache[require.resolve('../shared-paths')];
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
afterEach(() => {
|
|
130
|
+
process.env.HOME = originalHome;
|
|
131
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('returns every platform whose directory exists, including dsh', () => {
|
|
135
|
+
fs.mkdirSync(path.join(tmpHome, '.qoder'), { recursive: true });
|
|
136
|
+
fs.mkdirSync(path.join(tmpHome, '.config', 'opencode'), { recursive: true });
|
|
137
|
+
fs.mkdirSync(path.join(tmpHome, '.dsh'), { recursive: true });
|
|
138
|
+
|
|
139
|
+
const { detectInstalledPlatforms } = require('../detect-deps');
|
|
140
|
+
const installed = detectInstalledPlatforms();
|
|
141
|
+
expect(installed).toEqual(expect.arrayContaining(['opencode', 'qoder', 'dsh']));
|
|
142
|
+
expect(installed).not.toContain('claude-code');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('returns an empty list when no platform directory exists', () => {
|
|
146
|
+
const { detectInstalledPlatforms } = require('../detect-deps');
|
|
147
|
+
expect(detectInstalledPlatforms()).toEqual([]);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -403,7 +403,7 @@ describe('doctor delphi #684 global config fallback', () => {
|
|
|
403
403
|
return { logs, restore: () => spy.mockRestore() };
|
|
404
404
|
}
|
|
405
405
|
|
|
406
|
-
it('AC-
|
|
406
|
+
it('AC-721-01: a valid global config with no project copy PASSes instead of failing', async () => {
|
|
407
407
|
const globalPath = writeGlobalConfig();
|
|
408
408
|
const project = path.join(tmpHome, 'a-project-with-no-config');
|
|
409
409
|
fs.mkdirSync(project, { recursive: true });
|
|
@@ -417,7 +417,7 @@ describe('doctor delphi #684 global config fallback', () => {
|
|
|
417
417
|
expect(cfgLine).toMatch(/global/i);
|
|
418
418
|
});
|
|
419
419
|
|
|
420
|
-
it('AC-
|
|
420
|
+
it('AC-721-02: no config in either scope still FAILs (fallback must not mask a broken install)', async () => {
|
|
421
421
|
const project = path.join(tmpHome, 'bare-project');
|
|
422
422
|
fs.mkdirSync(project, { recursive: true });
|
|
423
423
|
const { logs, restore } = captureLogs();
|
|
@@ -436,7 +436,7 @@ describe('doctor delphi #684 global config fallback', () => {
|
|
|
436
436
|
expect(cfgLine).toMatch(/not found/);
|
|
437
437
|
});
|
|
438
438
|
|
|
439
|
-
it('AC-
|
|
439
|
+
it('AC-721-03: a project config still wins over the global one', async () => {
|
|
440
440
|
const globalPath = writeGlobalConfig();
|
|
441
441
|
const project = path.join(tmpHome, 'project-with-own-config');
|
|
442
442
|
fs.mkdirSync(project, { recursive: true });
|
|
@@ -451,3 +451,178 @@ describe('doctor delphi #684 global config fallback', () => {
|
|
|
451
451
|
expect(cfgLine).not.toMatch(/global/i);
|
|
452
452
|
});
|
|
453
453
|
});
|
|
454
|
+
|
|
455
|
+
describe('doctor delphi #725 multi-platform skill parity', () => {
|
|
456
|
+
let tmpDirs;
|
|
457
|
+
let tmpHome;
|
|
458
|
+
let originalHome;
|
|
459
|
+
let originalUserProfile;
|
|
460
|
+
|
|
461
|
+
beforeEach(() => {
|
|
462
|
+
tmpDirs = makeTempDirs(['doctor-725-home']);
|
|
463
|
+
tmpHome = tmpDirs['doctor-725-home'];
|
|
464
|
+
originalHome = process.env.HOME;
|
|
465
|
+
originalUserProfile = process.env.USERPROFILE;
|
|
466
|
+
process.env.HOME = tmpHome;
|
|
467
|
+
process.env.USERPROFILE = tmpHome;
|
|
468
|
+
resetLibModules(vi, LIB_MODULES);
|
|
469
|
+
muteConsole(vi);
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
afterEach(() => {
|
|
473
|
+
if (originalHome === undefined) delete process.env.HOME; else process.env.HOME = originalHome;
|
|
474
|
+
if (originalUserProfile === undefined) delete process.env.USERPROFILE;
|
|
475
|
+
else process.env.USERPROFILE = originalUserProfile;
|
|
476
|
+
removeTempDirs(tmpDirs);
|
|
477
|
+
vi.restoreAllMocks();
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
/** Copy the shipped bundle into \`<home>/<rel>/delphi-review\`. */
|
|
481
|
+
function deploySkillAt(rel, mutate) {
|
|
482
|
+
const dest = path.join(tmpHome, rel, 'delphi-review');
|
|
483
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
484
|
+
const src = bundledSkillDir();
|
|
485
|
+
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
|
486
|
+
const from = path.join(src, entry.name);
|
|
487
|
+
const to = path.join(dest, entry.name);
|
|
488
|
+
if (entry.isDirectory()) {
|
|
489
|
+
fs.mkdirSync(to, { recursive: true });
|
|
490
|
+
for (const inner of fs.readdirSync(from)) {
|
|
491
|
+
if (fs.statSync(path.join(from, inner)).isFile()) {
|
|
492
|
+
fs.copyFileSync(path.join(from, inner), path.join(to, inner));
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
} else {
|
|
496
|
+
fs.copyFileSync(from, to);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
if (mutate) mutate(dest);
|
|
500
|
+
return dest;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** Minimal agent set so only skill parity is under test. */
|
|
504
|
+
function seedAgents() {
|
|
505
|
+
fs.mkdirSync(path.join(tmpHome, '.qoder', 'agents'), { recursive: true });
|
|
506
|
+
for (const role of ['architecture', 'technical', 'feasibility']) {
|
|
507
|
+
fs.copyFileSync(
|
|
508
|
+
path.join(repoRoot(), 'plugins', 'qoder', 'agents', `delphi-${role}.md`),
|
|
509
|
+
path.join(tmpHome, '.qoder', 'agents', `delphi-${role}.md`),
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function captureResults() {
|
|
515
|
+
const lines = [];
|
|
516
|
+
const spy = vi.spyOn(console, 'log').mockImplementation((...args) => lines.push(args.join(' ')));
|
|
517
|
+
return { lines, spy };
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* @test REQ-725 Multi-platform skill parity
|
|
522
|
+
* @intent A second platform whose copy drifted must fail the parity check instead of being invisible
|
|
523
|
+
* @covers AC-725-01
|
|
524
|
+
*/
|
|
525
|
+
it('AC-725-01: a drifted OpenCode copy FAILs parity even though Qoder is clean', async () => {
|
|
526
|
+
seedAgents();
|
|
527
|
+
deploySkillAt(path.join('.qoder', 'skills'));
|
|
528
|
+
// Qoder is selected by detectPlatform(), so this is the copy the check used
|
|
529
|
+
// to look at -- and it is clean. The OpenCode copy is the one that rotted.
|
|
530
|
+
deploySkillAt(path.join('.config', 'opencode', 'skills'), (dest) => {
|
|
531
|
+
fs.writeFileSync(path.join(dest, 'AGENTS.md'), '# stale copy from 0.32.0.0\n');
|
|
532
|
+
});
|
|
533
|
+
fs.mkdirSync(path.join(tmpHome, '.config', 'xp-gate'), { recursive: true });
|
|
534
|
+
fs.writeFileSync(
|
|
535
|
+
path.join(tmpHome, '.config', 'xp-gate', 'xp-gate.json'),
|
|
536
|
+
JSON.stringify({ installedSkills: {} }),
|
|
537
|
+
);
|
|
538
|
+
|
|
539
|
+
const { doctorDelphi } = require('../doctor');
|
|
540
|
+
const { lines, spy } = captureResults();
|
|
541
|
+
await doctorDelphi({ skipLatestProbe: true });
|
|
542
|
+
spy.mockRestore();
|
|
543
|
+
|
|
544
|
+
const parity = lines.filter((l) => /parity/i.test(l)).join('\n');
|
|
545
|
+
expect(parity, 'a stale second-platform copy must be reported').toMatch(/✗/);
|
|
546
|
+
expect(parity).toMatch(/opencode/i);
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* @test REQ-725 Multi-platform skill parity
|
|
551
|
+
* @intent Both platforms matching must still PASS, and say how many were compared
|
|
552
|
+
* @covers AC-725-02
|
|
553
|
+
*/
|
|
554
|
+
it('AC-725-02: two clean platform copies PASS together', async () => {
|
|
555
|
+
seedAgents();
|
|
556
|
+
deploySkillAt(path.join('.qoder', 'skills'));
|
|
557
|
+
deploySkillAt(path.join('.config', 'opencode', 'skills'));
|
|
558
|
+
fs.mkdirSync(path.join(tmpHome, '.config', 'xp-gate'), { recursive: true });
|
|
559
|
+
fs.writeFileSync(
|
|
560
|
+
path.join(tmpHome, '.config', 'xp-gate', 'xp-gate.json'),
|
|
561
|
+
JSON.stringify({ installedSkills: {} }),
|
|
562
|
+
);
|
|
563
|
+
|
|
564
|
+
const { doctorDelphi } = require('../doctor');
|
|
565
|
+
const { lines, spy } = captureResults();
|
|
566
|
+
await doctorDelphi({ skipLatestProbe: true });
|
|
567
|
+
spy.mockRestore();
|
|
568
|
+
|
|
569
|
+
const parity = lines.filter((l) => /parity/i.test(l)).join('\n');
|
|
570
|
+
expect(parity).toMatch(/✓/);
|
|
571
|
+
expect(parity).toMatch(/2 platform/i);
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* @test REQ-725 Multi-platform skill parity
|
|
576
|
+
* @intent dsh is a first-class deploy target but detectPlatform() never returns
|
|
577
|
+
* it, so a drifted dsh copy must fail parity exactly like opencode
|
|
578
|
+
* @covers AC-725-01
|
|
579
|
+
*/
|
|
580
|
+
it('AC-725-01: a drifted dsh copy FAILs parity even though Qoder is clean', async () => {
|
|
581
|
+
seedAgents();
|
|
582
|
+
deploySkillAt(path.join('.qoder', 'skills'));
|
|
583
|
+
deploySkillAt(path.join('.dsh', 'skills'), (dest) => {
|
|
584
|
+
fs.writeFileSync(path.join(dest, 'AGENTS.md'), '# stale dsh copy\n');
|
|
585
|
+
});
|
|
586
|
+
fs.mkdirSync(path.join(tmpHome, '.config', 'xp-gate'), { recursive: true });
|
|
587
|
+
fs.writeFileSync(
|
|
588
|
+
path.join(tmpHome, '.config', 'xp-gate', 'xp-gate.json'),
|
|
589
|
+
JSON.stringify({ installedSkills: {} }),
|
|
590
|
+
);
|
|
591
|
+
|
|
592
|
+
const { doctorDelphi } = require('../doctor');
|
|
593
|
+
const { lines, spy } = captureResults();
|
|
594
|
+
await doctorDelphi({ skipLatestProbe: true });
|
|
595
|
+
spy.mockRestore();
|
|
596
|
+
|
|
597
|
+
const parity = lines.filter((l) => /parity/i.test(l)).join('\n');
|
|
598
|
+
expect(parity, 'a stale dsh copy must be reported').toMatch(/✗/);
|
|
599
|
+
expect(parity).toMatch(/dsh/i);
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* @test REQ-725 Multi-platform skill parity
|
|
604
|
+
* @intent a machine whose only installed agent is dsh has no marker detectPlatform()
|
|
605
|
+
* keys on, so the opencode fallback falsely FAILs "not installed" even though
|
|
606
|
+
* the skill IS deployed under ~/.dsh/skills
|
|
607
|
+
* @covers AC-725-01
|
|
608
|
+
*/
|
|
609
|
+
it('AC-725-01: a dsh-only machine PASSes parity instead of "not installed at opencode"', async () => {
|
|
610
|
+
// No seedAgents(): keep .qoder/.claude/.config-opencode absent so detectPlatform()
|
|
611
|
+
// falls back to opencode while dsh is the only live tree.
|
|
612
|
+
deploySkillAt(path.join('.dsh', 'skills'));
|
|
613
|
+
fs.mkdirSync(path.join(tmpHome, '.config', 'xp-gate'), { recursive: true });
|
|
614
|
+
fs.writeFileSync(
|
|
615
|
+
path.join(tmpHome, '.config', 'xp-gate', 'xp-gate.json'),
|
|
616
|
+
JSON.stringify({ installedSkills: {} }),
|
|
617
|
+
);
|
|
618
|
+
|
|
619
|
+
const { doctorDelphi } = require('../doctor');
|
|
620
|
+
const { lines, spy } = captureResults();
|
|
621
|
+
await doctorDelphi({ skipLatestProbe: true });
|
|
622
|
+
spy.mockRestore();
|
|
623
|
+
|
|
624
|
+
const parity = lines.filter((l) => /parity/i.test(l)).join('\n');
|
|
625
|
+
expect(parity, 'a deployed dsh copy must be found and verified').toMatch(/✓/);
|
|
626
|
+
expect(parity).not.toMatch(/not installed/i);
|
|
627
|
+
});
|
|
628
|
+
});
|
|
@@ -336,7 +336,7 @@ describe('doctor-tui skill freshness #681 equal version, different content', ()
|
|
|
336
336
|
return { issues, check: checks.find((c) => c.name === `Skill: ${name}`) };
|
|
337
337
|
}
|
|
338
338
|
|
|
339
|
-
it('AC-
|
|
339
|
+
it('AC-718-01: same version but a missing chapter is WARN, not "Up to date"', () => {
|
|
340
340
|
const bundled = fs.readFileSync(path.join(bundledSkillsDir, 'delphi-review', 'SKILL.md'), 'utf8');
|
|
341
341
|
// Keep the frontmatter (version included), drop a large slice of the body.
|
|
342
342
|
const idx = bundled.indexOf('\n---', 4);
|
|
@@ -352,7 +352,7 @@ describe('doctor-tui skill freshness #681 equal version, different content', ()
|
|
|
352
352
|
expect(issues).toBe(1);
|
|
353
353
|
});
|
|
354
354
|
|
|
355
|
-
it('AC-
|
|
355
|
+
it('AC-718-02: same version and same content is still "Up to date" (no false alarm)', () => {
|
|
356
356
|
const bundled = fs.readFileSync(path.join(bundledSkillsDir, 'delphi-review', 'SKILL.md'), 'utf8');
|
|
357
357
|
installSkill('delphi-review', bundled);
|
|
358
358
|
const { issues, check } = runFreshness('delphi-review');
|
|
@@ -360,7 +360,7 @@ describe('doctor-tui skill freshness #681 equal version, different content', ()
|
|
|
360
360
|
expect(issues).toBe(0);
|
|
361
361
|
});
|
|
362
362
|
|
|
363
|
-
it('AC-
|
|
363
|
+
it('AC-718-03: line endings alone still do not count as a difference', () => {
|
|
364
364
|
const bundled = fs.readFileSync(path.join(bundledSkillsDir, 'delphi-review', 'SKILL.md'), 'utf8');
|
|
365
365
|
installSkill('delphi-review', bundled.replace(/\r\n/g, '\n').replace(/\n/g, '\r\n'));
|
|
366
366
|
const { issues, check } = runFreshness('delphi-review');
|
|
@@ -102,6 +102,11 @@ describe('install-skill full skill directory (#416)', () => {
|
|
|
102
102
|
it('updates the skills directory of the platform actually in use', async () => {
|
|
103
103
|
// update-skill used to hardcode the opencode path while install-skill wrote
|
|
104
104
|
// to the platform dir, so on Qoder it deleted nothing (or the wrong copy).
|
|
105
|
+
//
|
|
106
|
+
// #725 changed the other half of this: a platform that ALSO has the skill
|
|
107
|
+
// deployed is updated too, because leaving it behind froze it at an old
|
|
108
|
+
// version while the command reported success. Both trees below are live, so
|
|
109
|
+
// both are updated -- "untouched" is no longer the expected end state.
|
|
105
110
|
for (const name of ['superpowers', 'gstack']) {
|
|
106
111
|
const dir = path.join(tmpHome, '.qoder', 'skills', name);
|
|
107
112
|
fs.mkdirSync(dir, { recursive: true });
|
|
@@ -125,9 +130,47 @@ describe('install-skill full skill directory (#416)', () => {
|
|
|
125
130
|
const { updateSkill } = require('../update-skill');
|
|
126
131
|
expect(await updateSkill('ralph-loop', {})).toBe(0);
|
|
127
132
|
|
|
133
|
+
const bundled = listFiles(path.join(BUNDLED_SKILLS, 'ralph-loop'));
|
|
134
|
+
|
|
135
|
+
// qoder keeps its #416 guarantee: stale files removed, full bundle written.
|
|
128
136
|
expect(fs.existsSync(path.join(qoderSkills, 'obsolete.md'))).toBe(false);
|
|
129
|
-
expect(listFiles(qoderSkills)).toEqual(
|
|
130
|
-
|
|
137
|
+
expect(listFiles(qoderSkills)).toEqual(bundled);
|
|
138
|
+
|
|
139
|
+
// #725: the opencode copy is a second live deployment, so it is updated too
|
|
140
|
+
// rather than silently frozen at `untouched`.
|
|
141
|
+
expect(listFiles(opencodeSkills)).toEqual(bundled);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('updates a skill deployed under ~/.dsh too (#725 AC-725-06)', async () => {
|
|
145
|
+
// Qoder keeps the dep gate green AND is the platform detectPlatform() picks.
|
|
146
|
+
for (const name of ['superpowers', 'gstack']) {
|
|
147
|
+
const dir = path.join(tmpHome, '.qoder', 'skills', name);
|
|
148
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
149
|
+
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ version: '2.0.0' }));
|
|
150
|
+
}
|
|
151
|
+
const qoderSkills = path.join(tmpHome, '.qoder', 'skills', 'ralph-loop');
|
|
152
|
+
fs.mkdirSync(qoderSkills, { recursive: true });
|
|
153
|
+
fs.writeFileSync(path.join(qoderSkills, 'SKILL.md'), '# old qoder copy');
|
|
154
|
+
|
|
155
|
+
// DSH is a live second deployment that detectPlatform() never returns.
|
|
156
|
+
const dshSkills = path.join(tmpHome, '.dsh', 'skills', 'ralph-loop');
|
|
157
|
+
fs.mkdirSync(dshSkills, { recursive: true });
|
|
158
|
+
fs.writeFileSync(path.join(dshSkills, 'SKILL.md'), '# old dsh copy');
|
|
159
|
+
fs.writeFileSync(path.join(dshSkills, 'obsolete.md'), 'stale');
|
|
160
|
+
|
|
161
|
+
const configDir = path.join(tmpHome, '.config', 'xp-gate');
|
|
162
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
163
|
+
fs.writeFileSync(
|
|
164
|
+
path.join(configDir, 'xp-gate.json'),
|
|
165
|
+
JSON.stringify({ installedSkills: { 'ralph-loop': { version: '0.0.1' } } })
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
const { updateSkill } = require('../update-skill');
|
|
169
|
+
expect(await updateSkill('ralph-loop', {})).toBe(0);
|
|
170
|
+
|
|
171
|
+
const bundled = listFiles(path.join(BUNDLED_SKILLS, 'ralph-loop'));
|
|
172
|
+
expect(listFiles(dshSkills)).toEqual(bundled);
|
|
173
|
+
expect(fs.existsSync(path.join(dshSkills, 'obsolete.md'))).toBe(false);
|
|
131
174
|
});
|
|
132
175
|
|
|
133
176
|
it('keeps the installed directory when the package no longer bundles the skill', async () => {
|