@boyingliu01/xp-gate 0.31.4 → 0.32.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/adapters/java.sh +256 -4
- package/adapters/powershell.sh +45 -6
- package/bin/xp-gate.js +8 -2
- package/build-integrity/__tests__/gate-10.test.ts +88 -1
- package/build-integrity/gate-10.ts +51 -16
- package/dsh.bundle.patch.yml +15 -0
- package/gate-13-compiler-strictness.sh +1031 -0
- package/gate-3.sh +48 -13
- package/hooks/gate-13-compiler-strictness.sh +1031 -0
- package/hooks/gate-3.sh +48 -13
- package/hooks/lib/coverage-total.sh +18 -2
- package/hooks/lib/java-test-selection.sh +251 -0
- package/hooks/lib/jscpd-run.sh +9 -0
- package/hooks/lib/validate-code-walkthrough.cjs +186 -29
- package/hooks/pre-commit +149 -2
- package/lib/__tests__/detect-deps-dsh-profile.test.js +106 -0
- package/lib/__tests__/doctor-tui-skill-freshness.test.js +140 -0
- package/lib/__tests__/gate-runner.test.js +29 -2
- package/lib/__tests__/issue-586-principles-cmdline.test.js +35 -0
- package/lib/__tests__/issue-618-gate-not-run.test.js +100 -0
- package/lib/__tests__/shared-utils.test.js +21 -0
- package/lib/check.js +6 -2
- package/lib/detect-deps.js +42 -10
- package/lib/doctor-tui.js +111 -21
- package/lib/gate-runner.js +111 -37
- package/lib/mattpocock-license-verdict.json +1 -1
- package/lib/principles.js +48 -6
- package/lib/shared-utils.js +8 -0
- package/mock-policy/AGENTS.md +3 -3
- package/mock-policy/gate-m3.ts +57 -23
- package/mutation/AGENTS.md +3 -3
- package/mutation/__tests__/pitest-gradle-availability.test.ts +74 -0
- package/mutation/__tests__/test-filter-parity.test.ts +5 -2
- package/mutation/gate-m.ts +30 -24
- package/mutation/runners/mutmut-runner.ts +80 -30
- package/mutation/runners/pitest-runner.ts +60 -42
- package/package.json +14 -2
- 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/lib/command.d.ts +38 -0
- package/plugins/dsh/lib/command.d.ts.map +1 -0
- package/plugins/dsh/lib/command.js +87 -0
- package/plugins/dsh/lib/credentials.d.ts +39 -0
- package/plugins/dsh/lib/credentials.d.ts.map +1 -0
- package/plugins/dsh/lib/credentials.js +57 -0
- package/plugins/dsh/lib/delphi-config.d.ts +129 -0
- package/plugins/dsh/lib/delphi-config.d.ts.map +1 -0
- package/plugins/dsh/lib/delphi-config.js +369 -0
- package/plugins/dsh/lib/delphi-run.d.ts +24 -0
- package/plugins/dsh/lib/delphi-run.d.ts.map +1 -0
- package/plugins/dsh/lib/delphi-run.js +113 -0
- package/plugins/dsh/lib/delphi.d.ts +94 -0
- package/plugins/dsh/lib/delphi.d.ts.map +1 -0
- package/plugins/dsh/lib/delphi.js +207 -0
- package/plugins/dsh/lib/dialect.d.ts +24 -0
- package/plugins/dsh/lib/dialect.d.ts.map +1 -0
- package/plugins/dsh/lib/dialect.js +36 -0
- package/plugins/dsh/lib/evidence.d.ts +70 -0
- package/plugins/dsh/lib/evidence.d.ts.map +1 -0
- package/plugins/dsh/lib/evidence.js +92 -0
- package/plugins/dsh/lib/gate-runner.d.ts +49 -0
- package/plugins/dsh/lib/gate-runner.d.ts.map +1 -0
- package/plugins/dsh/lib/gate-runner.js +95 -0
- package/plugins/dsh/lib/git-context.d.ts +26 -0
- package/plugins/dsh/lib/git-context.d.ts.map +1 -0
- package/plugins/dsh/lib/git-context.js +30 -0
- package/plugins/dsh/lib/hooks.d.ts +42 -0
- package/plugins/dsh/lib/hooks.d.ts.map +1 -0
- package/plugins/dsh/lib/hooks.js +113 -0
- package/plugins/dsh/lib/index.d.ts +20 -0
- package/plugins/dsh/lib/index.d.ts.map +1 -0
- package/plugins/dsh/lib/index.js +430 -0
- package/plugins/dsh/lib/skill-provider.d.ts +15 -0
- package/plugins/dsh/lib/skill-provider.d.ts.map +1 -0
- package/plugins/dsh/lib/skill-provider.js +205 -0
- package/plugins/dsh/lib/sprint.d.ts +60 -0
- package/plugins/dsh/lib/sprint.d.ts.map +1 -0
- package/plugins/dsh/lib/sprint.js +92 -0
- package/plugins/dsh/package.json +14 -8
- package/plugins/dsh/scripts/verify-skill-provider.mjs +113 -0
- 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/dsh/src/command-single-form.test.ts +67 -0
- package/plugins/dsh/src/command.test.ts +21 -17
- package/plugins/dsh/src/command.ts +19 -21
- package/plugins/dsh/src/compat.test.ts +200 -0
- package/plugins/dsh/src/contract.test.ts +7 -1
- package/plugins/dsh/src/credentials.test.ts +93 -0
- package/plugins/dsh/src/credentials.ts +71 -0
- package/plugins/dsh/src/delphi-cancellation.test.ts +71 -0
- package/plugins/dsh/src/delphi-config-apikey-override.test.ts +94 -0
- package/plugins/dsh/src/delphi-config-credential-refs.test.ts +75 -0
- package/plugins/dsh/src/delphi-config.ts +146 -53
- package/plugins/dsh/src/delphi-run.ts +27 -4
- package/plugins/dsh/src/delphi.ts +133 -65
- package/plugins/dsh/src/evidence.test.ts +74 -0
- package/plugins/dsh/src/evidence.ts +141 -0
- package/plugins/dsh/src/gate-runner.test.ts +14 -6
- package/plugins/dsh/src/gate-runner.ts +68 -5
- package/plugins/dsh/src/git-context.test.ts +39 -0
- package/plugins/dsh/src/git-context.ts +38 -0
- package/plugins/dsh/src/hooks.test.ts +87 -0
- package/plugins/dsh/src/hooks.ts +126 -0
- package/plugins/dsh/src/index.test.ts +63 -6
- package/plugins/dsh/src/index.ts +340 -13
- package/plugins/dsh/src/skill-provider.test.ts +194 -0
- package/plugins/dsh/src/skill-provider.ts +231 -0
- package/plugins/dsh/src/sprint.test.ts +64 -0
- package/plugins/dsh/src/sprint.ts +122 -0
- package/plugins/dsh/src/tri-state.test.ts +123 -0
- package/plugins/opencode/index.ts +192 -58
- 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/principles/__tests__/analyze-read-failure.test.ts +70 -0
- package/principles/__tests__/baseline-prune.test.ts +61 -0
- package/principles/__tests__/cli-files-separators.test.ts +69 -0
- package/principles/__tests__/config-merge-warnings.test.ts +148 -0
- package/principles/adapters/java.ts +22 -3
- package/principles/adapters/typescript.ts +70 -29
- package/principles/analyzer.ts +36 -3
- package/principles/baseline-prune.ts +19 -7
- package/principles/config.ts +56 -35
- package/principles/index.ts +122 -40
- package/principles/rules/clean-code/deep-nesting.ts +36 -22
- package/principles/rules/clean-code/unused-imports.ts +30 -19
- package/principles/rules/solid/dip.ts +30 -20
- package/principles/rules/solid/lsp.ts +17 -2
- package/principles/rules/solid/ocp.ts +33 -23
- package/principles/rules/solid/srp.ts +65 -23
- package/scripts/delphi-external-review.cjs +25 -2
- package/scripts/sync-package-content.js +28 -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
|
@@ -154,3 +154,143 @@ describe('doctor-tui skill freshness (#439)', () => {
|
|
|
154
154
|
expect(missing, `skills missing version frontmatter: ${missing.join(', ')}`).toEqual([]);
|
|
155
155
|
});
|
|
156
156
|
});
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* @test REQ-635
|
|
160
|
+
* @intent doctor 的 "Installed skills" 检查从 config.installedSkills 取清单,
|
|
161
|
+
* 而 xp-gate init 从不写该字段,导致全新安装的机器(12/12 技能已部署)
|
|
162
|
+
* 稳定报 "No skills installed" 并在读盘前就返回;同时只解析单一平台,
|
|
163
|
+
* init 实际部署的另外两个平台(含 .dsh)从不校验(#635)
|
|
164
|
+
* @covers AC-635-01, AC-635-02, AC-635-03
|
|
165
|
+
*/
|
|
166
|
+
describe('doctor-tui installed skills enumerate the filesystem (#635)', () => {
|
|
167
|
+
let tmpHome;
|
|
168
|
+
let originalHome;
|
|
169
|
+
|
|
170
|
+
// Same dir doctor-tui resolves as PKG_DIR (path.dirname of lib/). Re-declared
|
|
171
|
+
// here because the outer describe's copy is scoped to that callback.
|
|
172
|
+
const bundledSkillsDir = path.join(__dirname, '..', '..', 'skills');
|
|
173
|
+
|
|
174
|
+
// Every directory init deploys to. `.dsh` is the one detectPlatform() cannot
|
|
175
|
+
// report, and the one that went unverified entirely.
|
|
176
|
+
const DEPLOY_DIRS = [
|
|
177
|
+
['opencode', ['.config', 'opencode', 'skills']],
|
|
178
|
+
['dsh', ['.dsh', 'skills']],
|
|
179
|
+
['qoder', ['.qoder', 'skills']],
|
|
180
|
+
];
|
|
181
|
+
|
|
182
|
+
beforeEach(() => {
|
|
183
|
+
originalHome = process.env.HOME;
|
|
184
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'xpgate-635-'));
|
|
185
|
+
process.env.HOME = tmpHome;
|
|
186
|
+
vi.resetModules();
|
|
187
|
+
delete require.cache[require.resolve('../doctor-tui')];
|
|
188
|
+
delete require.cache[require.resolve('../shared-paths')];
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
afterEach(() => {
|
|
192
|
+
process.env.HOME = originalHome;
|
|
193
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
/** Deploy a real bundled skill into one platform's skills dir. */
|
|
197
|
+
function deploy(platformDirs, name) {
|
|
198
|
+
const dir = path.join(tmpHome, ...platformDirs, name);
|
|
199
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
200
|
+
const bundled = fs.readFileSync(path.join(bundledSkillsDir, name, 'SKILL.md'), 'utf8');
|
|
201
|
+
fs.writeFileSync(path.join(dir, 'SKILL.md'), bundled);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// AC-635-01: a machine with skills deployed and NO installedSkills config key
|
|
205
|
+
// must be verified, not reported as "No skills installed".
|
|
206
|
+
it('verifies deployed skills even when config.installedSkills is absent', () => {
|
|
207
|
+
deploy(['.config', 'opencode', 'skills'], 'grilling');
|
|
208
|
+
const { diagnoseInstalledSkills } = require('../doctor-tui');
|
|
209
|
+
const checks = [];
|
|
210
|
+
// init writes exactly this shape: no installedSkills key at all.
|
|
211
|
+
const issues = diagnoseInstalledSkills({ mode: 'global', templateDir: '/x' }, checks);
|
|
212
|
+
|
|
213
|
+
expect(issues).toBe(0);
|
|
214
|
+
const skills = checks.filter(c => c.name.startsWith('Skill '));
|
|
215
|
+
expect(skills.length).toBeGreaterThan(0);
|
|
216
|
+
expect(skills.every(c => c.status === 'PASS')).toBe(true);
|
|
217
|
+
// The old behaviour: a single SKIP row and zero skills examined.
|
|
218
|
+
expect(checks.find(c => c.name === 'Installed skills')).toBeUndefined();
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// AC-635-02: `.dsh` is a first-class deploy target and must be checked, even
|
|
222
|
+
// though detectPlatform() never returns 'dsh'.
|
|
223
|
+
it('checks the .dsh platform, which detectPlatform() cannot report', () => {
|
|
224
|
+
deploy(['.dsh', 'skills'], 'grilling');
|
|
225
|
+
const { diagnoseInstalledSkills } = require('../doctor-tui');
|
|
226
|
+
const checks = [];
|
|
227
|
+
diagnoseInstalledSkills({ mode: 'global' }, checks);
|
|
228
|
+
|
|
229
|
+
const dshRows = checks.filter(c => c.name.startsWith('Skill [dsh]'));
|
|
230
|
+
expect(dshRows.length).toBeGreaterThan(0);
|
|
231
|
+
expect(dshRows[0].status).toBe('PASS');
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// AC-635-03: all deployed platforms are verified in one run, not just the
|
|
235
|
+
// one detectPlatform() happens to pick.
|
|
236
|
+
it('verifies every deployed platform, not only the detected one', () => {
|
|
237
|
+
for (const [, dirs] of DEPLOY_DIRS) deploy(dirs, 'grilling');
|
|
238
|
+
const { diagnoseInstalledSkills } = require('../doctor-tui');
|
|
239
|
+
const checks = [];
|
|
240
|
+
diagnoseInstalledSkills({ mode: 'global' }, checks);
|
|
241
|
+
|
|
242
|
+
for (const [platform] of DEPLOY_DIRS) {
|
|
243
|
+
const rows = checks.filter(c => c.name.startsWith(`Skill [${platform}]`));
|
|
244
|
+
expect(rows.length, `no checks emitted for platform ${platform}`).toBeGreaterThan(0);
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// Anti-vacuity: enumeration must still surface real drift. A stale copy in
|
|
249
|
+
// .dsh specifically must WARN — proving the new rows are not decorative.
|
|
250
|
+
it('reports drift found in a non-detected platform (anti-vacuity)', () => {
|
|
251
|
+
deploy(['.config', 'opencode', 'skills'], 'grilling');
|
|
252
|
+
const staleDir = path.join(tmpHome, '.dsh', 'skills', 'grilling');
|
|
253
|
+
fs.mkdirSync(staleDir, { recursive: true });
|
|
254
|
+
fs.writeFileSync(
|
|
255
|
+
path.join(staleDir, 'SKILL.md'),
|
|
256
|
+
'---\nname: grilling\nversion: 0.0.1\n---\n\nstale body\n'
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
const { diagnoseInstalledSkills } = require('../doctor-tui');
|
|
260
|
+
const checks = [];
|
|
261
|
+
const issues = diagnoseInstalledSkills({ mode: 'global' }, checks);
|
|
262
|
+
|
|
263
|
+
expect(issues).toBeGreaterThan(0);
|
|
264
|
+
const stale = checks.find(c => c.name === 'Skill [dsh]: grilling');
|
|
265
|
+
expect(stale).toBeDefined();
|
|
266
|
+
expect(stale.status).toBe('WARN');
|
|
267
|
+
expect(stale.detail).toContain('Outdated');
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
// Honest SKIP: nothing deployed anywhere must say so, and name the locations
|
|
271
|
+
// it looked at — the old message could not be told apart from "not wired up".
|
|
272
|
+
it('reports SKIP naming every searched location when nothing is deployed', () => {
|
|
273
|
+
const { diagnoseInstalledSkills } = require('../doctor-tui');
|
|
274
|
+
const checks = [];
|
|
275
|
+
const issues = diagnoseInstalledSkills({ mode: 'global' }, checks);
|
|
276
|
+
|
|
277
|
+
expect(issues).toBe(0);
|
|
278
|
+
const row = checks.find(c => c.name === 'Installed skills');
|
|
279
|
+
expect(row).toBeDefined();
|
|
280
|
+
expect(row.status).toBe('SKIP');
|
|
281
|
+
expect(row.detail).toContain('dsh');
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// Backwards compatibility: an explicit list keeps the legacy single-target
|
|
285
|
+
// contract and the unprefixed check names existing callers rely on.
|
|
286
|
+
it('honours an explicit installedSkills list without the platform prefix', () => {
|
|
287
|
+
deploy(['.config', 'opencode', 'skills'], 'grilling');
|
|
288
|
+
const { diagnoseInstalledSkills } = require('../doctor-tui');
|
|
289
|
+
const checks = [];
|
|
290
|
+
diagnoseInstalledSkills({ installedSkills: { grilling: {} } }, checks);
|
|
291
|
+
|
|
292
|
+
const row = checks.find(c => c.name === 'Skill: grilling');
|
|
293
|
+
expect(row).toBeDefined();
|
|
294
|
+
expect(row.status).toBe('PASS');
|
|
295
|
+
});
|
|
296
|
+
});
|
|
@@ -67,10 +67,11 @@ function initializeGitProject() {
|
|
|
67
67
|
|
|
68
68
|
// Runs runGateAdapter in a child node process (worker threads forbid process
|
|
69
69
|
// chdir, and runGateAdapter execs bash), capturing stdout/exit status.
|
|
70
|
-
function runChildAdapter(gateRunnerAbsPath, fragmentPath, cwd = tmpProject) {
|
|
70
|
+
function runChildAdapter(gateRunnerAbsPath, fragmentPath, cwd = tmpProject, opts = null) {
|
|
71
|
+
const optsArgument = opts ? `, ${JSON.stringify(opts)}` : '';
|
|
71
72
|
const script = [
|
|
72
73
|
`const { runGateAdapter } = require(${JSON.stringify(gateRunnerAbsPath)});`,
|
|
73
|
-
`runGateAdapter(${JSON.stringify(fragmentPath)}, ${JSON.stringify(cwd)});`,
|
|
74
|
+
`runGateAdapter(${JSON.stringify(fragmentPath)}, ${JSON.stringify(cwd)}${optsArgument});`,
|
|
74
75
|
"console.log('__ADAPTER_DONE__');",
|
|
75
76
|
].join('\n');
|
|
76
77
|
try {
|
|
@@ -146,6 +147,32 @@ describe('gate-runner.js — runGateAdapter', () => {
|
|
|
146
147
|
expect(stdout).toContain('__ADAPTER_DONE__');
|
|
147
148
|
}, 30000);
|
|
148
149
|
|
|
150
|
+
it('propagates the whole tracked tree under scope=all, one file per line (#589/#<new>)', () => {
|
|
151
|
+
const runnerAbsPath = path.resolve(__dirname, '../gate-runner.js');
|
|
152
|
+
writeAdapterCommon();
|
|
153
|
+
initializeGitProject();
|
|
154
|
+
fs.writeFileSync(path.join(tmpProject, 'untracked.ts'), 'const value = 3;\n');
|
|
155
|
+
// Two tracked files so a one-line join (the old JSON.stringify form) would fail
|
|
156
|
+
// the equality check even though the list was non-empty.
|
|
157
|
+
fs.writeFileSync(path.join(tmpProject, 'second.ts'), 'const value = 4;\n');
|
|
158
|
+
execFileSync('git', ['add', 'second.ts'], { cwd: tmpProject });
|
|
159
|
+
execFileSync('git', ['-c', 'core.hooksPath=/dev/null', 'commit', '-qm', 'second'], {
|
|
160
|
+
cwd: tmpProject,
|
|
161
|
+
});
|
|
162
|
+
writeGateFragment([
|
|
163
|
+
`printf '%s\\n' "${shellParameter('CHANGED_FILES')}" | grep -qx 'tracked.ts' || exit 87`,
|
|
164
|
+
`printf '%s\\n' "${shellParameter('CHANGED_FILES')}" | grep -qx 'second.ts' || exit 86`,
|
|
165
|
+
`[ "$(printf '%s\\n' "${shellParameter('CHANGED_FILES')}" | wc -l)" -eq 2 ] || exit 85`,
|
|
166
|
+
`if [ "${shellParameter('GATE_SCOPE')}" != "all" ]; then exit 84; fi`,
|
|
167
|
+
]);
|
|
168
|
+
|
|
169
|
+
const fragPath = path.join(tmpAdapters, 'gate-9.sh');
|
|
170
|
+
const { status, stdout } = runChildAdapter(runnerAbsPath, fragPath, tmpProject, { scope: 'all' });
|
|
171
|
+
|
|
172
|
+
expect(status).toBe(0);
|
|
173
|
+
expect(stdout).toContain('__ADAPTER_DONE__');
|
|
174
|
+
}, 30000);
|
|
175
|
+
|
|
149
176
|
it('runs a standalone gate bash fallback through the public CLI (REQ-standalone-gates-05)', () => {
|
|
150
177
|
const packageFixture = path.join(tmpProject, 'package');
|
|
151
178
|
fs.cpSync(path.resolve(__dirname, '../..'), packageFixture, { recursive: true });
|
|
@@ -140,6 +140,41 @@ describe('REQ-613: Gate 4 must not blow the Windows command-line limit', () => {
|
|
|
140
140
|
expect(fs.existsSync(refPath) && plan.cleanup === false).toBe(false);
|
|
141
141
|
});
|
|
142
142
|
|
|
143
|
+
it('AC-613-02: without a local tsx, npm\u2019s own npx entry runs under node.exe', () => {
|
|
144
|
+
// `spawnSync('npx', ..., { shell: false })` is ENOENT on Windows: CreateProcess
|
|
145
|
+
// resolves only `.exe`, while the installed entry point is `npx.cmd`. Running
|
|
146
|
+
// npm's JS entry with the current node binary is the same program, without a shim
|
|
147
|
+
// — and without handing the file list to cmd.exe.
|
|
148
|
+
const existsSpy = vi.spyOn(fs, 'existsSync').mockImplementation((candidate) =>
|
|
149
|
+
String(candidate).endsWith(path.join('npm', 'bin', 'npx-cli.js'))
|
|
150
|
+
);
|
|
151
|
+
try {
|
|
152
|
+
const plan = principles.buildPrinciplesInvocation(['a.ts'], 'json');
|
|
153
|
+
expect(plan.command).toBe(process.execPath);
|
|
154
|
+
expect(plan.args[0]).toMatch(/npx-cli\.js$/);
|
|
155
|
+
expect(plan.useShell).toBe(false);
|
|
156
|
+
} finally {
|
|
157
|
+
existsSpy.mockRestore();
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('AC-613-02: the bare-npx fallback asks for a shell exactly where the shim needs one', () => {
|
|
162
|
+
// Last resort when neither tsx nor npm's entry can be found. The shell flag must
|
|
163
|
+
// still travel with the plan: hard-coding false is what produced ENOENT on
|
|
164
|
+
// Windows, hard-coding true is what produced the 8191-character limit.
|
|
165
|
+
const existsSpy = vi.spyOn(fs, 'existsSync').mockReturnValue(false);
|
|
166
|
+
try {
|
|
167
|
+
const direct = principles.buildPrinciplesInvocation(['a.ts'], 'json');
|
|
168
|
+
expect(direct.command).toBe('npx');
|
|
169
|
+
expect(direct.useShell).toBe(process.platform === 'win32');
|
|
170
|
+
|
|
171
|
+
const byReference = principles.buildPrinciplesInvocation(longFileList(LARGE_FILE_COUNT), 'json');
|
|
172
|
+
expect(byReference.useShell).toBe(process.platform === 'win32');
|
|
173
|
+
} finally {
|
|
174
|
+
existsSpy.mockRestore();
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
143
178
|
it('AC-613-02: the WIRE test \u2014 the call site actually uses the plan it builds', () => {
|
|
144
179
|
// This case exists because the first version of this suite had a hole: every
|
|
145
180
|
// other case asserts on `buildPrinciplesInvocation`'s OUTPUT, and reverting the
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test REQ-618 `check --all` must not count a gate that ran NOTHING as passed
|
|
3
|
+
* @intent Three gates the CLI advertises as runnable analysed nothing and were
|
|
4
|
+
* counted as passes. Measured on xp-gate 0.31.3:
|
|
5
|
+
*
|
|
6
|
+
* Gate python-health: TypeScript module not found and no bash fallback available.
|
|
7
|
+
* Gate test-layers: TypeScript module not found and no bash fallback available.
|
|
8
|
+
* Gate pbt: TypeScript module not found and no bash fallback available.
|
|
9
|
+
*
|
|
10
|
+
* [xp-gate check --all] 8 passed / 2 failed / 5 skipped / 15 total
|
|
11
|
+
*
|
|
12
|
+
* None of the three analysed anything, and all three were inside the
|
|
13
|
+
* `8 passed`. The root cause was `runTsGate()`'s no-entry branch: it
|
|
14
|
+
* printed a message and then fell through to `return 0`, which is
|
|
15
|
+
* indistinguishable from "ran and found nothing". #568 taught the
|
|
16
|
+
* summary to separate `preCommitOnly` skips from passes; this is the
|
|
17
|
+
* same distinction one level down -- a gate that could not run is a
|
|
18
|
+
* skip, not a pass.
|
|
19
|
+
*
|
|
20
|
+
* The second defect on the same path: the bash-fallback branch called
|
|
21
|
+
* `runGateAdapter(...)` and DISCARDED its verdict, so a fallback that
|
|
22
|
+
* failed still reported 0.
|
|
23
|
+
* @covers AC-618-01, AC-618-02, AC-618-03
|
|
24
|
+
*/
|
|
25
|
+
const gateRunner = require('../gate-runner.js');
|
|
26
|
+
const { checkAllGates } = require('../check.js');
|
|
27
|
+
|
|
28
|
+
const { GATE_REGISTRY, GATE_NOT_RUN } = gateRunner;
|
|
29
|
+
|
|
30
|
+
/** Original registry contents, restored after every test. */
|
|
31
|
+
let ORIGINAL_REGISTRY;
|
|
32
|
+
|
|
33
|
+
const SENTINEL_GATE_ID = '618-sentinel';
|
|
34
|
+
const PASS_GATE_ID = '618-pass';
|
|
35
|
+
const FAIL_GATE_ID = '618-fail';
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
ORIGINAL_REGISTRY = { ...GATE_REGISTRY };
|
|
39
|
+
for (const k of Object.keys(GATE_REGISTRY)) delete GATE_REGISTRY[k];
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
for (const k of Object.keys(GATE_REGISTRY)) delete GATE_REGISTRY[k];
|
|
44
|
+
Object.assign(GATE_REGISTRY, ORIGINAL_REGISTRY);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
function installGates() {
|
|
48
|
+
GATE_REGISTRY[SENTINEL_GATE_ID] = {
|
|
49
|
+
name: 'ran nothing',
|
|
50
|
+
description: 'no module, no fallback',
|
|
51
|
+
aliases: [],
|
|
52
|
+
run: () => GATE_NOT_RUN,
|
|
53
|
+
};
|
|
54
|
+
GATE_REGISTRY[PASS_GATE_ID] = {
|
|
55
|
+
name: 'ran and passed',
|
|
56
|
+
description: 'ordinary passing gate',
|
|
57
|
+
aliases: [],
|
|
58
|
+
run: () => 0,
|
|
59
|
+
};
|
|
60
|
+
GATE_REGISTRY[FAIL_GATE_ID] = {
|
|
61
|
+
name: 'ran and failed',
|
|
62
|
+
description: 'ordinary failing gate',
|
|
63
|
+
aliases: [],
|
|
64
|
+
run: () => 1,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
test('AC-618-01: the sentinel is distinct from every exit code', () => {
|
|
69
|
+
// It must not be a number: 0 means "ran, found nothing" and 1 means "failed".
|
|
70
|
+
expect(typeof GATE_NOT_RUN).not.toBe('number');
|
|
71
|
+
expect(GATE_NOT_RUN).not.toBe(0);
|
|
72
|
+
expect(GATE_NOT_RUN).not.toBe(1);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('AC-618-02: a gate that ran nothing is counted as skipped, not passed', async () => {
|
|
76
|
+
installGates();
|
|
77
|
+
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
78
|
+
try {
|
|
79
|
+
await checkAllGates('.');
|
|
80
|
+
const printed = log.mock.calls.map(c => String(c[0])).join('\n');
|
|
81
|
+
// 3 gates total: one skipped (ran nothing), one passed, one failed.
|
|
82
|
+
expect(printed).toMatch(/1 passed \/ 1 failed \/ 1 skipped \/ 3 total/);
|
|
83
|
+
} finally {
|
|
84
|
+
log.mockRestore();
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('AC-618-03: runGate propagates the sentinel instead of flattening it to 0', async () => {
|
|
89
|
+
installGates();
|
|
90
|
+
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
91
|
+
try {
|
|
92
|
+
// The sentinel must survive runGate(), otherwise check.js cannot see it.
|
|
93
|
+
expect(await gateRunner.runGate(SENTINEL_GATE_ID, '.')).toBe(GATE_NOT_RUN);
|
|
94
|
+
// And the ordinary verdicts must be untouched by the change.
|
|
95
|
+
expect(await gateRunner.runGate(PASS_GATE_ID, '.')).toBe(0);
|
|
96
|
+
expect(await gateRunner.runGate(FAIL_GATE_ID, '.')).toBe(1);
|
|
97
|
+
} finally {
|
|
98
|
+
log.mockRestore();
|
|
99
|
+
}
|
|
100
|
+
});
|
|
@@ -65,5 +65,26 @@ describe('shared-utils', () => {
|
|
|
65
65
|
expect(fs.existsSync(path.join(destDir, 'verify.sh'))).toBe(false);
|
|
66
66
|
expect(fs.existsSync(path.join(destDir, 'VERSION'))).toBe(false);
|
|
67
67
|
});
|
|
68
|
+
it('copies hooks/lib so gate-3 can source the complexity ratchet library', () => {
|
|
69
|
+
// gate-3.sh resolves $GATE_DIR/lib/ccn-ratchet.sh, and GATE_DIR is this same
|
|
70
|
+
// adapters directory (init.js: copyAdapters(srcDir, GLOBAL_ADAPTERS_DIR)). The
|
|
71
|
+
// installer used to skip hooks/lib entirely, so the source failed with "No such
|
|
72
|
+
// file or directory", evaluate_ccn_ratchet stayed undefined and the ratchet
|
|
73
|
+
// graded every CCN regression as PASS.
|
|
74
|
+
const pkgRoot = path.join(tmpRoot, 'pkg');
|
|
75
|
+
fs.mkdirSync(path.join(pkgRoot, 'hooks', 'lib'), { recursive: true });
|
|
76
|
+
fs.writeFileSync(path.join(pkgRoot, 'gate-3.sh'), '# gate-3');
|
|
77
|
+
fs.writeFileSync(path.join(pkgRoot, 'hooks', 'lib', 'ccn-ratchet.sh'), '# ratchet');
|
|
78
|
+
fs.writeFileSync(path.join(pkgRoot, 'hooks', 'lib', 'now-ms.sh'), '# now-ms');
|
|
79
|
+
|
|
80
|
+
const destDir = path.join(tmpRoot, 'adapters');
|
|
81
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
82
|
+
|
|
83
|
+
const { copyAdapters } = require('../shared-utils');
|
|
84
|
+
copyAdapters(pkgRoot, destDir);
|
|
85
|
+
|
|
86
|
+
expect(fs.existsSync(path.join(destDir, 'lib', 'ccn-ratchet.sh'))).toBe(true);
|
|
87
|
+
expect(fs.existsSync(path.join(destDir, 'lib', 'now-ms.sh'))).toBe(true);
|
|
88
|
+
});
|
|
68
89
|
});
|
|
69
90
|
});
|
package/lib/check.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const { principles } = require('./principles.js');
|
|
4
4
|
const { arch } = require('./arch.js');
|
|
5
|
-
const { getGateInfo, runGate, resolveAlias, getAliases } = require('./gate-runner.js');
|
|
5
|
+
const { getGateInfo, runGate, resolveAlias, getAliases, GATE_NOT_RUN } = require('./gate-runner.js');
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* Resolve a token (gate ID or alias) to a canonical gate ID.
|
|
@@ -151,7 +151,11 @@ async function checkAllGates(target) {
|
|
|
151
151
|
// read CHANGED_FILES from scanning an empty diff and reporting SKIP — which
|
|
152
152
|
// read like a pass while checking LESS than an ordinary commit.
|
|
153
153
|
const code = await runGate(gate.id, target, { scope: 'all' });
|
|
154
|
-
|
|
154
|
+
// #618: a gate that ran nothing (no TS module, no bash fallback) returns the
|
|
155
|
+
// GATE_NOT_RUN sentinel. Counting it as passed put three gates that analysed
|
|
156
|
+
// nothing inside "8 passed"; it is a skip, and the summary must say so.
|
|
157
|
+
if (code === GATE_NOT_RUN) tally.skipped += 1;
|
|
158
|
+
else if (code === 0) tally.passed += 1;
|
|
155
159
|
else tally.failed += 1;
|
|
156
160
|
console.log('');
|
|
157
161
|
}
|
package/lib/detect-deps.js
CHANGED
|
@@ -3,7 +3,10 @@ const path = require('path');
|
|
|
3
3
|
const os = require('os');
|
|
4
4
|
const { execSync } = require('child_process');
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
// HOME_DIR is deliberately NOT destructured here: the platform profiles keep
|
|
7
|
+
// home-relative path segments and resolve them through getSkillsDirs() instead,
|
|
8
|
+
// so nothing in this module freezes the home directory at require() time.
|
|
9
|
+
const { detectPlatform } = require('./shared-paths.js');
|
|
7
10
|
|
|
8
11
|
const REQUIRED_DEPS = [
|
|
9
12
|
{ name: 'superpowers', repo: 'obra/superpowers', minVersion: '1.0.0' },
|
|
@@ -231,27 +234,51 @@ function getToolInstallCmd(toolEntry, platform) {
|
|
|
231
234
|
* Platform profiles — each AI agent platform has its own skills directory.
|
|
232
235
|
* All platforms require the same dependencies (superpowers, gstack).
|
|
233
236
|
*
|
|
237
|
+
* `skillsDirs` is a list of path SEGMENTS relative to the home directory, not
|
|
238
|
+
* an absolute path. It used to hold `path.join(HOME_DIR, ...)` evaluated at
|
|
239
|
+
* require() time, which pinned every profile to whatever HOME was when
|
|
240
|
+
* detect-deps was first loaded — the same trap doctor.js documents at its top.
|
|
241
|
+
* Keeping segments here and resolving them in getSkillsDirs() makes the
|
|
242
|
+
* profiles plain, uniform data (no mix of getters and values) that follows
|
|
243
|
+
* process.env.HOME, and gives the platform-to-directory mapping one source of
|
|
244
|
+
* truth (Delphi R1 CI-01 / MA-01 on #635).
|
|
245
|
+
*
|
|
234
246
|
* @covers Issue #128 Bug 1 — Qoder platform was excluded from dependency checks
|
|
247
|
+
* @covers Issue #635 — dsh was missing entirely
|
|
235
248
|
*/
|
|
236
249
|
const PLATFORM_PROFILES = {
|
|
237
250
|
opencode: {
|
|
238
251
|
skillsDirs: [
|
|
239
|
-
|
|
240
|
-
|
|
252
|
+
['.config', 'opencode', 'skills'],
|
|
253
|
+
['.config', 'opencode'],
|
|
241
254
|
],
|
|
242
255
|
requiredDeps: REQUIRED_DEPS,
|
|
243
256
|
},
|
|
244
257
|
'claude-code': {
|
|
245
258
|
skillsDirs: [
|
|
246
|
-
|
|
247
|
-
|
|
259
|
+
['.claude', 'skills'],
|
|
260
|
+
['.claude'],
|
|
248
261
|
],
|
|
249
262
|
requiredDeps: REQUIRED_DEPS,
|
|
250
263
|
},
|
|
251
264
|
qoder: {
|
|
252
265
|
skillsDirs: [
|
|
253
|
-
|
|
254
|
-
|
|
266
|
+
['.qoder', 'skills'],
|
|
267
|
+
['.qoder'],
|
|
268
|
+
],
|
|
269
|
+
requiredDeps: REQUIRED_DEPS,
|
|
270
|
+
},
|
|
271
|
+
// #635: `xp-gate init` deploys skills to ~/.dsh/skills, the npm package ships
|
|
272
|
+
// plugins/dsh (`@boyingliu01/dsh-plugin-xp-gate`), and
|
|
273
|
+
// scripts/sync-package-content.js lists `dsh` among PLUGINS — but this entry
|
|
274
|
+
// was missing, so getSkillsDirs('dsh') fell through to the opencode paths and
|
|
275
|
+
// DSH skills were never resolved. detectPlatform() still never returns 'dsh'
|
|
276
|
+
// (there is no marker it could key on that opencode's would not also match),
|
|
277
|
+
// so callers that must cover DSH ask for it by name.
|
|
278
|
+
dsh: {
|
|
279
|
+
skillsDirs: [
|
|
280
|
+
['.dsh', 'skills'],
|
|
281
|
+
['.dsh'],
|
|
255
282
|
],
|
|
256
283
|
requiredDeps: REQUIRED_DEPS,
|
|
257
284
|
},
|
|
@@ -262,12 +289,17 @@ const PLATFORM_PROFILES = {
|
|
|
262
289
|
|
|
263
290
|
/**
|
|
264
291
|
* Get the skills directories for a given platform.
|
|
265
|
-
*
|
|
266
|
-
*
|
|
292
|
+
*
|
|
293
|
+
* Resolves the home directory at call time so the result follows
|
|
294
|
+
* process.env.HOME, and so every profile is a plain data entry.
|
|
295
|
+
*
|
|
296
|
+
* @param {string} platform - 'opencode' | 'claude-code' | 'qoder' | 'dsh'
|
|
297
|
+
* @returns {string[]} absolute paths; skillsDirs[0] is the skills directory
|
|
267
298
|
*/
|
|
268
299
|
function getSkillsDirs(platform) {
|
|
269
300
|
const profile = PLATFORM_PROFILES[platform] || PLATFORM_PROFILES.opencode;
|
|
270
|
-
|
|
301
|
+
const home = require('./shared-paths.js').HOME_DIR;
|
|
302
|
+
return profile.skillsDirs.map((segments) => path.join(home, ...segments));
|
|
271
303
|
}
|
|
272
304
|
|
|
273
305
|
/**
|
package/lib/doctor-tui.js
CHANGED
|
@@ -117,38 +117,128 @@ function compareSemver(a, b) {
|
|
|
117
117
|
return 0;
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
/**
|
|
121
|
+
* Every skills directory `xp-gate init` deploys to, derived from
|
|
122
|
+
* PLATFORM_PROFILES so the platform-to-directory mapping has one source of
|
|
123
|
+
* truth. A hardcoded copy here would drift the moment a platform is added
|
|
124
|
+
* (Delphi R1 MA-01).
|
|
125
|
+
*
|
|
126
|
+
* init deploys to all of these at once, so doctor has to verify all of them —
|
|
127
|
+
* resolving a single "current" platform silently left the others unchecked
|
|
128
|
+
* (#635). `.dsh` is included because the package ships `plugins/dsh` and
|
|
129
|
+
* `sync-package-content.js` treats `dsh` as a first-class platform, even
|
|
130
|
+
* though `detectPlatform()` never returns it.
|
|
131
|
+
*
|
|
132
|
+
* Resolved through getSkillsDirs() at call time: the home directory and the
|
|
133
|
+
* profile list are read lazily, and tests reassign process.env.HOME.
|
|
134
|
+
*
|
|
135
|
+
* @returns {Array<{platform: string, dir: string}>}
|
|
136
|
+
*/
|
|
137
|
+
function skillsDeployTargets() {
|
|
138
|
+
const { PLATFORM_PROFILES, getSkillsDirs } = require('./detect-deps.js');
|
|
139
|
+
return Object.keys(PLATFORM_PROFILES).map((platform) => ({
|
|
140
|
+
platform,
|
|
141
|
+
// skillsDirs[0] is the skills directory; [1] is the broader platform dir.
|
|
142
|
+
dir: getSkillsDirs(platform)[0],
|
|
143
|
+
}));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* List the skill names actually present in a skills directory.
|
|
148
|
+
*
|
|
149
|
+
* A skill counts as installed when `<dir>/<name>/SKILL.md` exists — the same
|
|
150
|
+
* shape `install-skill` writes. Returns [] when the directory is missing or
|
|
151
|
+
* unreadable; a missing directory is "not deployed here", not an error.
|
|
152
|
+
*
|
|
153
|
+
* @param {string} dir
|
|
154
|
+
* @returns {string[]}
|
|
155
|
+
*/
|
|
156
|
+
function listInstalledSkillNames(dir) {
|
|
157
|
+
let entries;
|
|
158
|
+
try {
|
|
159
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
160
|
+
} catch {
|
|
161
|
+
return [];
|
|
162
|
+
}
|
|
163
|
+
return entries
|
|
164
|
+
.filter((e) => e.isDirectory() && fs.existsSync(path.join(dir, e.name, 'SKILL.md')))
|
|
165
|
+
.map((e) => e.name)
|
|
166
|
+
.sort();
|
|
167
|
+
}
|
|
168
|
+
|
|
120
169
|
/**
|
|
121
170
|
* Check 9: Installed skills vs package-bundled skills (#332).
|
|
122
171
|
* Uses semver version comparison on SKILL.md frontmatter when available;
|
|
123
172
|
* falls back to byte-for-byte content comparison for skills without version field.
|
|
173
|
+
*
|
|
174
|
+
* The skill list comes from the filesystem, not from `config.installedSkills`:
|
|
175
|
+
* `xp-gate init` never writes that field, so keying off it made this check
|
|
176
|
+
* report "No skills installed" on a fully deployed machine and return before
|
|
177
|
+
* touching disk (#635). An explicit `config.installedSkills` is still honoured
|
|
178
|
+
* when present, so callers and older tests keep their behaviour.
|
|
179
|
+
*
|
|
124
180
|
* @param {object} config
|
|
125
181
|
* @param {Array} checks
|
|
126
182
|
* @returns {number} issue count
|
|
127
183
|
*/
|
|
128
184
|
function diagnoseInstalledSkills(config, checks) {
|
|
129
|
-
const installedSkills = config.installedSkills || {};
|
|
130
|
-
const skillNames = Object.keys(installedSkills);
|
|
131
|
-
if (skillNames.length === 0) {
|
|
132
|
-
checks.push({ name: 'Installed skills', status: 'SKIP', detail: 'No skills installed' });
|
|
133
|
-
return 0;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
185
|
const bundledSkillsDir = path.join(PKG_DIR, 'skills');
|
|
137
186
|
if (!fs.existsSync(bundledSkillsDir)) {
|
|
138
187
|
checks.push({ name: 'Installed skills', status: 'SKIP', detail: 'Package skills dir not found' });
|
|
139
188
|
return 0;
|
|
140
189
|
}
|
|
141
190
|
|
|
191
|
+
const explicit = config && config.installedSkills ? Object.keys(config.installedSkills) : [];
|
|
192
|
+
const targets = skillsDeployTargets();
|
|
193
|
+
|
|
194
|
+
// An explicit `installedSkills` list is a caller saying "check THIS list, for
|
|
195
|
+
// THIS platform" — legacy single-target behaviour, kept so existing callers
|
|
196
|
+
// and their tests retain an unchanged contract (Delphi R1 MA-02).
|
|
197
|
+
//
|
|
198
|
+
// The asymmetry with the default path below is deliberate and load-bearing:
|
|
199
|
+
// the explicit list names no directory, so a platform still has to be
|
|
200
|
+
// inferred, and silently checking a different platform's directory than the
|
|
201
|
+
// caller meant would be worse than checking one. `xp-gate init` never writes
|
|
202
|
+
// this field, so in practice doctor takes the filesystem path below.
|
|
142
203
|
const platform = detectPlatform();
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
} else if (platform === 'claude-code') {
|
|
147
|
-
userSkillsDir = path.join(HOME_DIR, '.claude', 'skills');
|
|
148
|
-
} else {
|
|
149
|
-
userSkillsDir = path.join(HOME_DIR, '.config', 'opencode', 'skills');
|
|
204
|
+
if (explicit.length > 0) {
|
|
205
|
+
const match = targets.find((t) => t.platform === platform) || targets[0];
|
|
206
|
+
return diagnoseSkillsInDir(explicit, match.dir, bundledSkillsDir, checks, null);
|
|
150
207
|
}
|
|
151
208
|
|
|
209
|
+
// No explicit list: verify every directory that actually has skills deployed.
|
|
210
|
+
const deployed = targets.filter((t) => listInstalledSkillNames(t.dir).length > 0);
|
|
211
|
+
if (deployed.length === 0) {
|
|
212
|
+
checks.push({
|
|
213
|
+
name: 'Installed skills',
|
|
214
|
+
status: 'SKIP',
|
|
215
|
+
detail: `No skills deployed in any known location (${targets.map((t) => t.platform).join(', ')})`,
|
|
216
|
+
});
|
|
217
|
+
return 0;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
let issues = 0;
|
|
221
|
+
for (const target of deployed) {
|
|
222
|
+
const names = listInstalledSkillNames(target.dir);
|
|
223
|
+
issues += diagnoseSkillsInDir(names, target.dir, bundledSkillsDir, checks, target.platform);
|
|
224
|
+
}
|
|
225
|
+
return issues;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Compare one deployed skills directory against the bundled copy.
|
|
230
|
+
*
|
|
231
|
+
* @param {string[]} skillNames
|
|
232
|
+
* @param {string} userSkillsDir
|
|
233
|
+
* @param {string} bundledSkillsDir
|
|
234
|
+
* @param {Array} checks
|
|
235
|
+
* @param {string|null} platformLabel - prefix for check names, or null for the
|
|
236
|
+
* legacy unprefixed names (explicit-installedSkills callers).
|
|
237
|
+
* @returns {number} issue count
|
|
238
|
+
*/
|
|
239
|
+
function diagnoseSkillsInDir(skillNames, userSkillsDir, bundledSkillsDir, checks, platformLabel) {
|
|
240
|
+
const label = (name) => (platformLabel ? `Skill [${platformLabel}]: ${name}` : `Skill: ${name}`);
|
|
241
|
+
|
|
152
242
|
let issues = 0;
|
|
153
243
|
for (const name of skillNames) {
|
|
154
244
|
const bundledSkillMd = path.join(bundledSkillsDir, name, 'SKILL.md');
|
|
@@ -156,7 +246,7 @@ function diagnoseInstalledSkills(config, checks) {
|
|
|
156
246
|
|
|
157
247
|
if (!fs.existsSync(bundledSkillMd)) continue;
|
|
158
248
|
if (!fs.existsSync(userSkillMd)) {
|
|
159
|
-
checks.push({ name:
|
|
249
|
+
checks.push({ name: label(name), status: 'FAIL', detail: 'Installed SKILL.md missing' });
|
|
160
250
|
issues++;
|
|
161
251
|
continue;
|
|
162
252
|
}
|
|
@@ -174,24 +264,24 @@ function diagnoseInstalledSkills(config, checks) {
|
|
|
174
264
|
// Version strings not valid semver — fall back to content comparison
|
|
175
265
|
if (!sameContentIgnoringLineEndings(bundledContent, userContent)) {
|
|
176
266
|
checks.push({
|
|
177
|
-
name:
|
|
267
|
+
name: label(name),
|
|
178
268
|
status: 'WARN',
|
|
179
269
|
detail: `Outdated — run xp-gate update-skill --all (installed: ${userVersion}, bundled: ${bundledVersion})`,
|
|
180
270
|
});
|
|
181
271
|
issues++;
|
|
182
272
|
} else {
|
|
183
|
-
checks.push({ name:
|
|
273
|
+
checks.push({ name: label(name), status: 'PASS', detail: 'Up to date' });
|
|
184
274
|
}
|
|
185
275
|
} else if (cmp < 0) {
|
|
186
276
|
checks.push({
|
|
187
|
-
name:
|
|
277
|
+
name: label(name),
|
|
188
278
|
status: 'WARN',
|
|
189
279
|
detail: `Outdated — run xp-gate update-skill --all (installed: ${userVersion}, bundled: ${bundledVersion})`,
|
|
190
280
|
});
|
|
191
281
|
issues++;
|
|
192
282
|
} else {
|
|
193
283
|
// cmp >= 0: installed version is same or newer than bundled
|
|
194
|
-
checks.push({ name:
|
|
284
|
+
checks.push({ name: label(name), status: 'PASS', detail: 'Up to date' });
|
|
195
285
|
}
|
|
196
286
|
} else {
|
|
197
287
|
// No version in at least one file — compare content, but not byte-for-byte:
|
|
@@ -206,13 +296,13 @@ function diagnoseInstalledSkills(config, checks) {
|
|
|
206
296
|
withoutVersionLine(userContent)
|
|
207
297
|
)) {
|
|
208
298
|
checks.push({
|
|
209
|
-
name:
|
|
299
|
+
name: label(name),
|
|
210
300
|
status: 'WARN',
|
|
211
301
|
detail: 'Content differs and version cannot be determined (missing version frontmatter) — compare manually before running update-skill (it pulls from main, which may be older than the installed copy)',
|
|
212
302
|
});
|
|
213
303
|
issues++;
|
|
214
304
|
} else {
|
|
215
|
-
checks.push({ name:
|
|
305
|
+
checks.push({ name: label(name), status: 'PASS', detail: 'Up to date' });
|
|
216
306
|
}
|
|
217
307
|
}
|
|
218
308
|
}
|