@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.
Files changed (144) hide show
  1. package/adapters/java.sh +256 -4
  2. package/adapters/powershell.sh +45 -6
  3. package/bin/xp-gate.js +8 -2
  4. package/build-integrity/__tests__/gate-10.test.ts +88 -1
  5. package/build-integrity/gate-10.ts +51 -16
  6. package/dsh.bundle.patch.yml +15 -0
  7. package/gate-13-compiler-strictness.sh +1031 -0
  8. package/gate-3.sh +48 -13
  9. package/hooks/gate-13-compiler-strictness.sh +1031 -0
  10. package/hooks/gate-3.sh +48 -13
  11. package/hooks/lib/coverage-total.sh +18 -2
  12. package/hooks/lib/java-test-selection.sh +251 -0
  13. package/hooks/lib/jscpd-run.sh +9 -0
  14. package/hooks/lib/validate-code-walkthrough.cjs +186 -29
  15. package/hooks/pre-commit +149 -2
  16. package/lib/__tests__/detect-deps-dsh-profile.test.js +106 -0
  17. package/lib/__tests__/doctor-tui-skill-freshness.test.js +140 -0
  18. package/lib/__tests__/gate-runner.test.js +29 -2
  19. package/lib/__tests__/issue-586-principles-cmdline.test.js +35 -0
  20. package/lib/__tests__/issue-618-gate-not-run.test.js +100 -0
  21. package/lib/__tests__/shared-utils.test.js +21 -0
  22. package/lib/check.js +6 -2
  23. package/lib/detect-deps.js +42 -10
  24. package/lib/doctor-tui.js +111 -21
  25. package/lib/gate-runner.js +111 -37
  26. package/lib/mattpocock-license-verdict.json +1 -1
  27. package/lib/principles.js +48 -6
  28. package/lib/shared-utils.js +8 -0
  29. package/mock-policy/AGENTS.md +3 -3
  30. package/mock-policy/gate-m3.ts +57 -23
  31. package/mutation/AGENTS.md +3 -3
  32. package/mutation/__tests__/pitest-gradle-availability.test.ts +74 -0
  33. package/mutation/__tests__/test-filter-parity.test.ts +5 -2
  34. package/mutation/gate-m.ts +30 -24
  35. package/mutation/runners/mutmut-runner.ts +80 -30
  36. package/mutation/runners/pitest-runner.ts +60 -42
  37. package/package.json +14 -2
  38. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  39. package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
  40. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
  41. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
  42. package/plugins/dsh/lib/command.d.ts +38 -0
  43. package/plugins/dsh/lib/command.d.ts.map +1 -0
  44. package/plugins/dsh/lib/command.js +87 -0
  45. package/plugins/dsh/lib/credentials.d.ts +39 -0
  46. package/plugins/dsh/lib/credentials.d.ts.map +1 -0
  47. package/plugins/dsh/lib/credentials.js +57 -0
  48. package/plugins/dsh/lib/delphi-config.d.ts +129 -0
  49. package/plugins/dsh/lib/delphi-config.d.ts.map +1 -0
  50. package/plugins/dsh/lib/delphi-config.js +369 -0
  51. package/plugins/dsh/lib/delphi-run.d.ts +24 -0
  52. package/plugins/dsh/lib/delphi-run.d.ts.map +1 -0
  53. package/plugins/dsh/lib/delphi-run.js +113 -0
  54. package/plugins/dsh/lib/delphi.d.ts +94 -0
  55. package/plugins/dsh/lib/delphi.d.ts.map +1 -0
  56. package/plugins/dsh/lib/delphi.js +207 -0
  57. package/plugins/dsh/lib/dialect.d.ts +24 -0
  58. package/plugins/dsh/lib/dialect.d.ts.map +1 -0
  59. package/plugins/dsh/lib/dialect.js +36 -0
  60. package/plugins/dsh/lib/evidence.d.ts +70 -0
  61. package/plugins/dsh/lib/evidence.d.ts.map +1 -0
  62. package/plugins/dsh/lib/evidence.js +92 -0
  63. package/plugins/dsh/lib/gate-runner.d.ts +49 -0
  64. package/plugins/dsh/lib/gate-runner.d.ts.map +1 -0
  65. package/plugins/dsh/lib/gate-runner.js +95 -0
  66. package/plugins/dsh/lib/git-context.d.ts +26 -0
  67. package/plugins/dsh/lib/git-context.d.ts.map +1 -0
  68. package/plugins/dsh/lib/git-context.js +30 -0
  69. package/plugins/dsh/lib/hooks.d.ts +42 -0
  70. package/plugins/dsh/lib/hooks.d.ts.map +1 -0
  71. package/plugins/dsh/lib/hooks.js +113 -0
  72. package/plugins/dsh/lib/index.d.ts +20 -0
  73. package/plugins/dsh/lib/index.d.ts.map +1 -0
  74. package/plugins/dsh/lib/index.js +430 -0
  75. package/plugins/dsh/lib/skill-provider.d.ts +15 -0
  76. package/plugins/dsh/lib/skill-provider.d.ts.map +1 -0
  77. package/plugins/dsh/lib/skill-provider.js +205 -0
  78. package/plugins/dsh/lib/sprint.d.ts +60 -0
  79. package/plugins/dsh/lib/sprint.d.ts.map +1 -0
  80. package/plugins/dsh/lib/sprint.js +92 -0
  81. package/plugins/dsh/package.json +14 -8
  82. package/plugins/dsh/scripts/verify-skill-provider.mjs +113 -0
  83. package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
  84. package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
  85. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
  86. package/plugins/dsh/src/command-single-form.test.ts +67 -0
  87. package/plugins/dsh/src/command.test.ts +21 -17
  88. package/plugins/dsh/src/command.ts +19 -21
  89. package/plugins/dsh/src/compat.test.ts +200 -0
  90. package/plugins/dsh/src/contract.test.ts +7 -1
  91. package/plugins/dsh/src/credentials.test.ts +93 -0
  92. package/plugins/dsh/src/credentials.ts +71 -0
  93. package/plugins/dsh/src/delphi-cancellation.test.ts +71 -0
  94. package/plugins/dsh/src/delphi-config-apikey-override.test.ts +94 -0
  95. package/plugins/dsh/src/delphi-config-credential-refs.test.ts +75 -0
  96. package/plugins/dsh/src/delphi-config.ts +146 -53
  97. package/plugins/dsh/src/delphi-run.ts +27 -4
  98. package/plugins/dsh/src/delphi.ts +133 -65
  99. package/plugins/dsh/src/evidence.test.ts +74 -0
  100. package/plugins/dsh/src/evidence.ts +141 -0
  101. package/plugins/dsh/src/gate-runner.test.ts +14 -6
  102. package/plugins/dsh/src/gate-runner.ts +68 -5
  103. package/plugins/dsh/src/git-context.test.ts +39 -0
  104. package/plugins/dsh/src/git-context.ts +38 -0
  105. package/plugins/dsh/src/hooks.test.ts +87 -0
  106. package/plugins/dsh/src/hooks.ts +126 -0
  107. package/plugins/dsh/src/index.test.ts +63 -6
  108. package/plugins/dsh/src/index.ts +340 -13
  109. package/plugins/dsh/src/skill-provider.test.ts +194 -0
  110. package/plugins/dsh/src/skill-provider.ts +231 -0
  111. package/plugins/dsh/src/sprint.test.ts +64 -0
  112. package/plugins/dsh/src/sprint.ts +122 -0
  113. package/plugins/dsh/src/tri-state.test.ts +123 -0
  114. package/plugins/opencode/index.ts +192 -58
  115. package/plugins/opencode/package.json +1 -1
  116. package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
  117. package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
  118. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
  119. package/plugins/qoder/plugin.json +1 -1
  120. package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
  121. package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
  122. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
  123. package/principles/AGENTS.md +3 -3
  124. package/principles/__tests__/analyze-read-failure.test.ts +70 -0
  125. package/principles/__tests__/baseline-prune.test.ts +61 -0
  126. package/principles/__tests__/cli-files-separators.test.ts +69 -0
  127. package/principles/__tests__/config-merge-warnings.test.ts +148 -0
  128. package/principles/adapters/java.ts +22 -3
  129. package/principles/adapters/typescript.ts +70 -29
  130. package/principles/analyzer.ts +36 -3
  131. package/principles/baseline-prune.ts +19 -7
  132. package/principles/config.ts +56 -35
  133. package/principles/index.ts +122 -40
  134. package/principles/rules/clean-code/deep-nesting.ts +36 -22
  135. package/principles/rules/clean-code/unused-imports.ts +30 -19
  136. package/principles/rules/solid/dip.ts +30 -20
  137. package/principles/rules/solid/lsp.ts +17 -2
  138. package/principles/rules/solid/ocp.ts +33 -23
  139. package/principles/rules/solid/srp.ts +65 -23
  140. package/scripts/delphi-external-review.cjs +25 -2
  141. package/scripts/sync-package-content.js +28 -1
  142. package/skills/delphi-review/AGENTS.md +3 -3
  143. package/skills/sprint-flow/AGENTS.md +3 -3
  144. 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
- if (code === 0) tally.passed += 1;
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
  }
@@ -3,7 +3,10 @@ const path = require('path');
3
3
  const os = require('os');
4
4
  const { execSync } = require('child_process');
5
5
 
6
- const { HOME_DIR, detectPlatform } = require('./shared-paths.js');
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
- path.join(HOME_DIR, '.config', 'opencode', 'skills'),
240
- path.join(HOME_DIR, '.config', 'opencode'),
252
+ ['.config', 'opencode', 'skills'],
253
+ ['.config', 'opencode'],
241
254
  ],
242
255
  requiredDeps: REQUIRED_DEPS,
243
256
  },
244
257
  'claude-code': {
245
258
  skillsDirs: [
246
- path.join(HOME_DIR, '.claude', 'skills'),
247
- path.join(HOME_DIR, '.claude'),
259
+ ['.claude', 'skills'],
260
+ ['.claude'],
248
261
  ],
249
262
  requiredDeps: REQUIRED_DEPS,
250
263
  },
251
264
  qoder: {
252
265
  skillsDirs: [
253
- path.join(HOME_DIR, '.qoder', 'skills'),
254
- path.join(HOME_DIR, '.qoder'),
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
- * @param {string} platform - 'opencode' | 'claude-code' | 'qoder'
266
- * @returns {string[]}
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
- return profile.skillsDirs;
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
- let userSkillsDir;
144
- if (platform === 'qoder') {
145
- userSkillsDir = path.join(HOME_DIR, '.qoder', 'skills');
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: `Skill: ${name}`, status: 'FAIL', detail: 'Installed SKILL.md missing' });
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: `Skill: ${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: `Skill: ${name}`, status: 'PASS', detail: 'Up to date' });
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: `Skill: ${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: `Skill: ${name}`, status: 'PASS', detail: 'Up to date' });
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: `Skill: ${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: `Skill: ${name}`, status: 'PASS', detail: 'Up to date' });
305
+ checks.push({ name: label(name), status: 'PASS', detail: 'Up to date' });
216
306
  }
217
307
  }
218
308
  }