@boyingliu01/xp-gate 0.31.3 → 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 (151) 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 +269 -13
  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-entry-total-warnings.test.ts +76 -0
  126. package/principles/__tests__/baseline-prune.test.ts +61 -0
  127. package/principles/__tests__/cli-files-separators.test.ts +69 -0
  128. package/principles/__tests__/config-merge-warnings.test.ts +148 -0
  129. package/principles/adapters/__tests__/cpp-code-block.test.ts +123 -0
  130. package/principles/adapters/__tests__/cpp-scanner-equivalence.test.ts +67 -0
  131. package/principles/adapters/__tests__/typescript-block-comment-lines.test.ts +53 -0
  132. package/principles/adapters/__tests__/typescript-masking.test.ts +96 -0
  133. package/principles/adapters/cpp.ts +102 -50
  134. package/principles/adapters/java.ts +22 -3
  135. package/principles/adapters/typescript.ts +548 -178
  136. package/principles/analyzer.ts +36 -3
  137. package/principles/baseline-prune.ts +19 -7
  138. package/principles/baseline.ts +58 -70
  139. package/principles/config.ts +56 -35
  140. package/principles/index.ts +122 -40
  141. package/principles/rules/clean-code/deep-nesting.ts +36 -22
  142. package/principles/rules/clean-code/unused-imports.ts +30 -19
  143. package/principles/rules/solid/dip.ts +30 -20
  144. package/principles/rules/solid/lsp.ts +17 -2
  145. package/principles/rules/solid/ocp.ts +33 -23
  146. package/principles/rules/solid/srp.ts +65 -23
  147. package/scripts/delphi-external-review.cjs +25 -2
  148. package/scripts/sync-package-content.js +28 -1
  149. package/skills/delphi-review/AGENTS.md +3 -3
  150. package/skills/sprint-flow/AGENTS.md +3 -3
  151. package/skills/test-specification-alignment/AGENTS.md +3 -3
@@ -0,0 +1,106 @@
1
+ /**
2
+ * @test REQ-635
3
+ * @intent `xp-gate init` 会向 ~/.dsh/skills 部署技能、npm 包也带 plugins/dsh,
4
+ * 但 PLATFORM_PROFILES 里没有 dsh 条目,getSkillsDirs('dsh') 静默回落到
5
+ * opencode 的路径,导致 DSH 机器的技能从不被解析(#635)
6
+ * @covers AC-635-04, AC-635-05
7
+ */
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const os = require('os');
11
+
12
+ describe('PLATFORM_PROFILES covers dsh (#635)', () => {
13
+ let tmpHome;
14
+ let originalHome;
15
+
16
+ beforeEach(() => {
17
+ originalHome = process.env.HOME;
18
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'xpgate-dsh-'));
19
+ process.env.HOME = tmpHome;
20
+ vi.resetModules();
21
+ delete require.cache[require.resolve('../detect-deps')];
22
+ delete require.cache[require.resolve('../shared-paths')];
23
+ });
24
+
25
+ afterEach(() => {
26
+ process.env.HOME = originalHome;
27
+ fs.rmSync(tmpHome, { recursive: true, force: true });
28
+ });
29
+
30
+ // AC-635-04: asking for dsh skills must return the dsh directory, not a
31
+ // silent fallback to opencode's.
32
+ it('getSkillsDirs("dsh") resolves ~/.dsh, not the opencode fallback', () => {
33
+ const { getSkillsDirs } = require('../detect-deps');
34
+ const dirs = getSkillsDirs('dsh');
35
+
36
+ expect(dirs.length).toBeGreaterThan(0);
37
+ expect(dirs[0]).toBe(path.join(tmpHome, '.dsh', 'skills'));
38
+ // The pre-fix behaviour: falls through to PLATFORM_PROFILES.opencode.
39
+ expect(dirs[0]).not.toContain(path.join('.config', 'opencode'));
40
+ });
41
+
42
+ // AC-635-05: the profile is a real entry, so a caller enumerating platforms
43
+ // sees dsh alongside the three that were already there.
44
+ it('dsh is an enumerable platform profile with skillsDirs and requiredDeps', () => {
45
+ const { PLATFORM_PROFILES, getSkillsDirs } = require('../detect-deps');
46
+
47
+ expect(Object.keys(PLATFORM_PROFILES)).toContain('dsh');
48
+ expect(Object.keys(PLATFORM_PROFILES)).toEqual(
49
+ expect.arrayContaining(['opencode', 'claude-code', 'qoder', 'dsh'])
50
+ );
51
+
52
+ const profile = PLATFORM_PROFILES.dsh;
53
+ expect(Array.isArray(profile.skillsDirs)).toBe(true);
54
+ expect(profile.requiredDeps).toBeDefined();
55
+ // Profiles store home-relative segments; getSkillsDirs resolves them.
56
+ expect(profile.skillsDirs[0]).toEqual(['.dsh', 'skills']);
57
+ expect(getSkillsDirs('dsh')[0]).toBe(path.join(tmpHome, '.dsh', 'skills'));
58
+ });
59
+
60
+ // Every profile must be plain data — no getters mixed in with values, which
61
+ // would break Object.keys()/JSON round-trips and made the object's contract
62
+ // depend on which key you touched (Delphi R1 CI-01).
63
+ it('every profile is plain enumerable data, with no accessor properties', () => {
64
+ const { PLATFORM_PROFILES } = require('../detect-deps');
65
+
66
+ for (const name of Object.keys(PLATFORM_PROFILES)) {
67
+ const descriptor = Object.getOwnPropertyDescriptor(PLATFORM_PROFILES, name);
68
+ expect(descriptor.get, `${name} must not be an accessor`).toBeUndefined();
69
+ // A plain value also survives a JSON round-trip unchanged.
70
+ expect(JSON.parse(JSON.stringify(PLATFORM_PROFILES[name]))).toEqual(
71
+ PLATFORM_PROFILES[name]
72
+ );
73
+ }
74
+ });
75
+
76
+ // Paths must follow process.env.HOME rather than pinning the home directory
77
+ // at require() time — the trap doctor.js documents at its top.
78
+ it('resolved paths follow a HOME change after require', () => {
79
+ const { getSkillsDirs } = require('../detect-deps');
80
+ // Loaded above with the first tmpHome; move HOME and resolve again.
81
+ const secondHome = fs.mkdtempSync(path.join(os.tmpdir(), 'xpgate-dsh2-'));
82
+ try {
83
+ process.env.HOME = secondHome;
84
+ expect(getSkillsDirs('dsh')[0]).toBe(path.join(secondHome, '.dsh', 'skills'));
85
+ // The pre-existing profiles must follow too — they used to be frozen at
86
+ // require() time from a destructured HOME_DIR.
87
+ expect(getSkillsDirs('opencode')[0]).toBe(
88
+ path.join(secondHome, '.config', 'opencode', 'skills')
89
+ );
90
+ } finally {
91
+ fs.rmSync(secondHome, { recursive: true, force: true });
92
+ }
93
+ });
94
+
95
+ // Anti-vacuity: the three pre-existing profiles must keep their own paths,
96
+ // proving the getter did not disturb them.
97
+ it('leaves the pre-existing profiles unchanged', () => {
98
+ const { getSkillsDirs } = require('../detect-deps');
99
+
100
+ expect(getSkillsDirs('opencode')[0]).toBe(
101
+ path.join(tmpHome, '.config', 'opencode', 'skills')
102
+ );
103
+ expect(getSkillsDirs('qoder')[0]).toBe(path.join(tmpHome, '.qoder', 'skills'));
104
+ expect(getSkillsDirs('claude-code')[0]).toBe(path.join(tmpHome, '.claude', 'skills'));
105
+ });
106
+ });
@@ -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
  /**