@boyingliu01/xp-gate 0.19.3 → 0.20.1

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 (177) hide show
  1. package/adapters/gate-4.sh +83 -18
  2. package/adapters/gate-8.sh +115 -29
  3. package/adapters/iac.sh +1 -1
  4. package/adapters/powershell.sh +60 -5
  5. package/adapters/python.sh +38 -4
  6. package/adapters/typescript.sh +15 -60
  7. package/bin/xp-gate.js +15 -16
  8. package/build-integrity/__tests__/import-resolver.test.ts +59 -0
  9. package/build-integrity/gate-10.ts +17 -2
  10. package/gate-10.sh +35 -2
  11. package/gate-4.sh +110 -21
  12. package/gate-8.sh +115 -29
  13. package/hooks/gate-10.sh +35 -2
  14. package/hooks/gate-4.sh +110 -21
  15. package/hooks/gate-8.sh +115 -29
  16. package/hooks/lib/jscpd-run.sh +63 -0
  17. package/hooks/lib/sprint-gate-report.sh +58 -0
  18. package/hooks/lib/test-failure.sh +152 -0
  19. package/hooks/lib/typecheck.sh +141 -0
  20. package/hooks/lib/validate-code-walkthrough.cjs +43 -5
  21. package/hooks/post-merge +100 -0
  22. package/hooks/pre-commit +514 -184
  23. package/hooks/pre-push +75 -19
  24. package/lib/__tests__/bootstrap.test.js +23 -41
  25. package/lib/__tests__/check-version.test.js +8 -1
  26. package/lib/__tests__/detect-deps.test.js +16 -0
  27. package/lib/__tests__/doctor-drift-round2.test.js +212 -0
  28. package/lib/__tests__/doctor-executed-surface.test.js +209 -0
  29. package/lib/__tests__/doctor-hook-drift.test.js +329 -0
  30. package/lib/__tests__/doctor-machine-mutation.test.js +116 -0
  31. package/lib/__tests__/doctor-module-drift.test.js +206 -0
  32. package/lib/__tests__/doctor-module-resolution-parity.test.js +314 -0
  33. package/lib/__tests__/doctor-path-equivalence.test.js +59 -0
  34. package/lib/__tests__/doctor-repair-boundary.test.js +52 -0
  35. package/lib/__tests__/doctor-tui-skill-freshness.test.js +105 -0
  36. package/lib/__tests__/doctor.test.js +155 -0
  37. package/lib/__tests__/gate-runner.test.js +19 -3
  38. package/lib/__tests__/helpers/lib-sandbox.cjs +55 -0
  39. package/lib/__tests__/init.test.js +44 -17
  40. package/lib/__tests__/install-skill-full-dir.test.js +151 -0
  41. package/lib/__tests__/install-skill.test.js +51 -132
  42. package/lib/__tests__/language-tools-detect.test.js +158 -0
  43. package/lib/__tests__/phase-transition.test.js +15 -1
  44. package/lib/__tests__/qoder-delphi-agents.test.js +285 -0
  45. package/lib/__tests__/shared-paths.test.js +20 -0
  46. package/lib/__tests__/shared-utils.test.js +69 -0
  47. package/lib/__tests__/skill-flags.test.js +48 -0
  48. package/lib/__tests__/ui-detector.test.ts +6 -2
  49. package/lib/__tests__/uninstall.test.js +20 -0
  50. package/lib/__tests__/update-hooks.test.js +12 -0
  51. package/lib/__tests__/update-skill.test.js +7 -4
  52. package/lib/detect-deps.js +3 -20
  53. package/lib/doctor-tui.js +22 -5
  54. package/lib/doctor.js +1178 -31
  55. package/lib/init.js +184 -33
  56. package/lib/install-cmd.js +7 -4
  57. package/lib/install-skill.js +38 -95
  58. package/lib/language-tools.js +83 -4
  59. package/lib/mattpocock-license-verdict.json +1 -1
  60. package/lib/shared-paths.js +5 -2
  61. package/lib/shared-utils.js +47 -3
  62. package/lib/uninstall.js +12 -0
  63. package/lib/update-hooks.js +4 -4
  64. package/lib/update-skill.js +6 -7
  65. package/mock-policy/AGENTS.md +4 -4
  66. package/mutation/AGENTS.md +4 -4
  67. package/mutation/__tests__/gate-m.test.ts +66 -14
  68. package/mutation/__tests__/mutmut-runner.test.ts +91 -42
  69. package/mutation/__tests__/test-filter-parity.test.ts +37 -0
  70. package/mutation/gate-m.ts +21 -8
  71. package/package.json +2 -2
  72. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  73. package/plugins/claude-code/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
  74. package/plugins/claude-code/skills/delphi-review/AGENTS.md +4 -4
  75. package/plugins/claude-code/skills/delphi-review/SKILL.md +98 -14
  76. package/plugins/claude-code/skills/delphi-review/references/code-walkthrough.md +36 -3
  77. package/plugins/claude-code/skills/delphi-review/references/requirements.md +6 -6
  78. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +4 -4
  79. package/plugins/claude-code/skills/sprint-flow/references/phase-2-design.md +3 -3
  80. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +4 -4
  81. package/plugins/dsh/README.md +169 -4
  82. package/plugins/dsh/dsh-overlay.patch.yml +67 -0
  83. package/plugins/dsh/package.json +1 -1
  84. package/plugins/dsh/scripts/prepack.cjs +24 -19
  85. package/plugins/dsh/setup-dsh.ps1 +160 -0
  86. package/plugins/dsh/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
  87. package/plugins/dsh/skills/delphi-review/AGENTS.md +4 -4
  88. package/plugins/dsh/skills/delphi-review/SKILL.md +98 -14
  89. package/plugins/dsh/skills/delphi-review/references/code-walkthrough.md +36 -3
  90. package/plugins/dsh/skills/delphi-review/references/requirements.md +6 -6
  91. package/plugins/dsh/skills/sprint-flow/AGENTS.md +4 -4
  92. package/plugins/dsh/skills/sprint-flow/references/phase-2-design.md +3 -3
  93. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +4 -4
  94. package/plugins/dsh/src/command.test.ts +66 -2
  95. package/plugins/dsh/src/command.ts +36 -14
  96. package/plugins/dsh/src/contract.test.ts +31 -1
  97. package/plugins/dsh/src/delphi-config.test.ts +190 -0
  98. package/plugins/dsh/src/delphi-config.ts +223 -0
  99. package/plugins/dsh/src/delphi-run.test.ts +181 -0
  100. package/plugins/dsh/src/delphi-run.ts +129 -0
  101. package/plugins/dsh/src/delphi.test.ts +222 -0
  102. package/plugins/dsh/src/delphi.ts +247 -0
  103. package/plugins/dsh/src/dialect.test.ts +59 -0
  104. package/plugins/dsh/src/dialect.ts +40 -0
  105. package/plugins/dsh/src/index.ts +70 -4
  106. package/plugins/dsh/src/skills.test.ts +26 -19
  107. package/plugins/opencode/package.json +1 -1
  108. package/plugins/opencode/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
  109. package/plugins/opencode/skills/delphi-review/AGENTS.md +4 -4
  110. package/plugins/opencode/skills/delphi-review/SKILL.md +98 -14
  111. package/plugins/opencode/skills/delphi-review/references/code-walkthrough.md +36 -3
  112. package/plugins/opencode/skills/delphi-review/references/requirements.md +6 -6
  113. package/plugins/opencode/skills/sprint-flow/AGENTS.md +4 -4
  114. package/plugins/opencode/skills/sprint-flow/references/phase-2-design.md +3 -3
  115. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +4 -4
  116. package/plugins/qoder/agents/delphi-architecture.md +1 -1
  117. package/plugins/qoder/agents/delphi-feasibility.md +1 -1
  118. package/plugins/qoder/agents/delphi-technical.md +1 -1
  119. package/plugins/qoder/plugin.json +1 -1
  120. package/plugins/qoder/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
  121. package/plugins/qoder/skills/delphi-review/AGENTS.md +4 -4
  122. package/plugins/qoder/skills/delphi-review/SKILL.md +98 -14
  123. package/plugins/qoder/skills/delphi-review/references/code-walkthrough.md +36 -3
  124. package/plugins/qoder/skills/delphi-review/references/requirements.md +6 -6
  125. package/plugins/qoder/skills/sprint-flow/AGENTS.md +4 -4
  126. package/plugins/qoder/skills/sprint-flow/references/phase-2-design.md +3 -3
  127. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +4 -4
  128. package/principles/AGENTS.md +9 -8
  129. package/principles/__tests__/adapter-metrics.test.ts +292 -0
  130. package/principles/__tests__/analyzer.test.ts +29 -6
  131. package/principles/__tests__/baseline-prune-timeout.test.ts +71 -0
  132. package/principles/__tests__/baseline-prune.test.ts +338 -0
  133. package/principles/__tests__/boy-scout-init-baseline.test.ts +167 -0
  134. package/principles/__tests__/boy-scout-integration.test.ts +97 -6
  135. package/principles/__tests__/boy-scout.test.ts +253 -7
  136. package/principles/__tests__/config-enforcement.test.ts +469 -0
  137. package/principles/__tests__/config-git-timeout.test.ts +66 -0
  138. package/principles/__tests__/direct-execution.test.ts +187 -0
  139. package/principles/__tests__/index.test.ts +16 -3
  140. package/principles/__tests__/warnings-baseline-budgets.test.ts +74 -0
  141. package/principles/adapters/typescript.ts +613 -13
  142. package/principles/analyzer.ts +22 -0
  143. package/principles/baseline-prune.ts +217 -0
  144. package/principles/boy-scout.ts +516 -56
  145. package/principles/config.ts +362 -54
  146. package/principles/direct-execution.ts +60 -0
  147. package/principles/index.ts +40 -17
  148. package/principles/rules/__tests__/clean-code/many-exports.test.ts +16 -10
  149. package/principles/rules/clean-code/code-duplication.ts +8 -8
  150. package/principles/rules/clean-code/deep-nesting.ts +8 -8
  151. package/principles/rules/clean-code/god-class.ts +8 -8
  152. package/principles/rules/clean-code/large-file.ts +15 -11
  153. package/principles/rules/clean-code/long-function.ts +8 -8
  154. package/principles/rules/clean-code/magic-numbers.ts +10 -7
  155. package/principles/rules/clean-code/many-exports.ts +7 -7
  156. package/principles/rules/clean-code/missing-error-handling.ts +5 -5
  157. package/principles/rules/clean-code/too-many-params.ts +8 -8
  158. package/principles/rules/clean-code/unused-imports.ts +5 -5
  159. package/principles/rules/solid/dip.ts +18 -8
  160. package/principles/rules/solid/isp.ts +9 -8
  161. package/principles/rules/solid/lsp.ts +6 -6
  162. package/principles/rules/solid/ocp.ts +6 -5
  163. package/principles/rules/solid/srp.ts +7 -8
  164. package/principles/types.ts +15 -0
  165. package/scripts/__tests__/sync-package-content.test.js +28 -0
  166. package/scripts/delphi-external-review.cjs +193 -10
  167. package/scripts/sync-package-content.js +20 -10
  168. package/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
  169. package/skills/delphi-review/AGENTS.md +4 -4
  170. package/skills/delphi-review/SKILL.md +98 -14
  171. package/skills/delphi-review/references/code-walkthrough.md +36 -3
  172. package/skills/delphi-review/references/requirements.md +6 -6
  173. package/skills/sprint-flow/AGENTS.md +4 -4
  174. package/skills/sprint-flow/references/phase-2-design.md +3 -3
  175. package/skills/test-specification-alignment/AGENTS.md +4 -4
  176. package/lib/__tests__/download-skill.test.js +0 -281
  177. package/lib/download-skill.js +0 -96
@@ -0,0 +1,151 @@
1
+ /**
2
+ * @test REQ-416
3
+ * @intent install-skill/update-skill 只从 GitHub main 下载单个 SKILL.md,导致 13 个技能里
4
+ * 8 个的 references/templates/scripts 全部缺失、SKILL.md 内的路径引用悬空,而
5
+ * update 先删目录再只写回 SKILL.md 会把用户已有的完整目录清空(#416)
6
+ * @covers AC-416-01, AC-416-02, AC-416-03, AC-416-04
7
+ */
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const os = require('os');
11
+
12
+ const BUNDLED_SKILLS = path.join(__dirname, '..', '..', 'skills');
13
+
14
+ function listFiles(dir, prefix = '') {
15
+ const out = [];
16
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
17
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
18
+ if (entry.isDirectory()) out.push(...listFiles(path.join(dir, entry.name), rel));
19
+ else out.push(rel);
20
+ }
21
+ return out;
22
+ }
23
+
24
+ describe('install-skill full skill directory (#416)', () => {
25
+ let tmpHome;
26
+ let originalHome;
27
+
28
+ beforeEach(() => {
29
+ originalHome = process.env.HOME;
30
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'xpgate-full-'));
31
+ process.env.HOME = tmpHome;
32
+ vi.resetModules();
33
+ for (const mod of ['../install-skill', '../update-skill', '../detect-deps', '../shared-paths', '../rollback']) {
34
+ delete require.cache[require.resolve(mod)];
35
+ }
36
+ vi.spyOn(console, 'log').mockImplementation(() => {});
37
+ vi.spyOn(console, 'error').mockImplementation(() => {});
38
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
39
+
40
+ // Dependency gate: superpowers + gstack must look installed.
41
+ for (const name of ['superpowers', 'gstack']) {
42
+ const dir = path.join(tmpHome, '.config', 'opencode', 'skills', name);
43
+ fs.mkdirSync(dir, { recursive: true });
44
+ fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ version: '2.0.0' }));
45
+ }
46
+ });
47
+
48
+ afterEach(() => {
49
+ process.env.HOME = originalHome;
50
+ vi.restoreAllMocks();
51
+ fs.rmSync(tmpHome, { recursive: true, force: true });
52
+ });
53
+
54
+ it('installs every file the bundled skill ships, not only SKILL.md', async () => {
55
+ const { installSkill } = require('../install-skill');
56
+ const result = await installSkill('ralph-loop');
57
+
58
+ expect(result).toBe(0);
59
+ const installed = path.join(tmpHome, '.config', 'opencode', 'skills', 'ralph-loop');
60
+ expect(listFiles(installed)).toEqual(listFiles(path.join(BUNDLED_SKILLS, 'ralph-loop')));
61
+ expect(fs.existsSync(path.join(installed, 'references'))).toBe(true);
62
+ });
63
+
64
+ it('installs from the package without touching the network', async () => {
65
+ // The npm package already carries the content, so there is no `main`-branch
66
+ // download left to drift against the installed CLI version.
67
+ const https = require('https');
68
+ const getSpy = vi.spyOn(https, 'get').mockImplementation(() => {
69
+ throw new Error('install-skill must not reach the network');
70
+ });
71
+
72
+ const { installSkill } = require('../install-skill');
73
+ const result = await installSkill('delphi-review');
74
+
75
+ expect(result).toBe(0);
76
+ expect(getSpy).not.toHaveBeenCalled();
77
+ const installed = path.join(tmpHome, '.config', 'opencode', 'skills', 'delphi-review');
78
+ expect(fs.readFileSync(path.join(installed, 'SKILL.md'), 'utf8'))
79
+ .toBe(fs.readFileSync(path.join(BUNDLED_SKILLS, 'delphi-review', 'SKILL.md'), 'utf8'));
80
+ });
81
+
82
+ it('replaces a stale installed directory with the complete bundled one on update', async () => {
83
+ const installed = path.join(tmpHome, '.config', 'opencode', 'skills', 'ralph-loop');
84
+ fs.mkdirSync(installed, { recursive: true });
85
+ fs.writeFileSync(path.join(installed, 'SKILL.md'), '# old');
86
+ fs.writeFileSync(path.join(installed, 'obsolete-reference.md'), '# deleted upstream');
87
+
88
+ const { updateSkill } = require('../update-skill');
89
+ const configDir = path.join(tmpHome, '.config', 'xp-gate');
90
+ fs.mkdirSync(configDir, { recursive: true });
91
+ fs.writeFileSync(
92
+ path.join(configDir, 'xp-gate.json'),
93
+ JSON.stringify({ installedSkills: { 'ralph-loop': { version: '0.0.1' } } })
94
+ );
95
+
96
+ const result = await updateSkill('ralph-loop', {});
97
+ expect(result).toBe(0);
98
+ expect(listFiles(installed)).toEqual(listFiles(path.join(BUNDLED_SKILLS, 'ralph-loop')));
99
+ expect(fs.existsSync(path.join(installed, 'obsolete-reference.md'))).toBe(false);
100
+ });
101
+
102
+ it('updates the skills directory of the platform actually in use', async () => {
103
+ // update-skill used to hardcode the opencode path while install-skill wrote
104
+ // to the platform dir, so on Qoder it deleted nothing (or the wrong copy).
105
+ for (const name of ['superpowers', 'gstack']) {
106
+ const dir = path.join(tmpHome, '.qoder', 'skills', name);
107
+ fs.mkdirSync(dir, { recursive: true });
108
+ fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ version: '2.0.0' }));
109
+ }
110
+ const qoderSkills = path.join(tmpHome, '.qoder', 'skills', 'ralph-loop');
111
+ fs.mkdirSync(qoderSkills, { recursive: true });
112
+ fs.writeFileSync(path.join(qoderSkills, 'SKILL.md'), '# old qoder copy');
113
+ fs.writeFileSync(path.join(qoderSkills, 'obsolete.md'), 'stale');
114
+ const opencodeSkills = path.join(tmpHome, '.config', 'opencode', 'skills', 'ralph-loop');
115
+ fs.mkdirSync(opencodeSkills, { recursive: true });
116
+ fs.writeFileSync(path.join(opencodeSkills, 'SKILL.md'), 'untouched');
117
+
118
+ const configDir = path.join(tmpHome, '.config', 'xp-gate');
119
+ fs.mkdirSync(configDir, { recursive: true });
120
+ fs.writeFileSync(
121
+ path.join(configDir, 'xp-gate.json'),
122
+ JSON.stringify({ installedSkills: { 'ralph-loop': { version: '0.0.1' } } })
123
+ );
124
+
125
+ const { updateSkill } = require('../update-skill');
126
+ expect(await updateSkill('ralph-loop', {})).toBe(0);
127
+
128
+ expect(fs.existsSync(path.join(qoderSkills, 'obsolete.md'))).toBe(false);
129
+ expect(listFiles(qoderSkills)).toEqual(listFiles(path.join(BUNDLED_SKILLS, 'ralph-loop')));
130
+ expect(fs.readFileSync(path.join(opencodeSkills, 'SKILL.md'), 'utf8')).toBe('untouched');
131
+ });
132
+
133
+ it('keeps the installed directory when the package no longer bundles the skill', async () => {
134
+ // update-skill used to rmSync the target before delegating to install, so a
135
+ // skill the installed package does not carry was destroyed first and the
136
+ // update failed afterwards — data loss with nothing to show for it.
137
+ const installed = path.join(tmpHome, '.config', 'opencode', 'skills', 'removed-in-this-version');
138
+ fs.mkdirSync(installed, { recursive: true });
139
+ fs.writeFileSync(path.join(installed, 'SKILL.md'), '# user copy');
140
+ const configDir = path.join(tmpHome, '.config', 'xp-gate');
141
+ fs.mkdirSync(configDir, { recursive: true });
142
+ fs.writeFileSync(
143
+ path.join(configDir, 'xp-gate.json'),
144
+ JSON.stringify({ installedSkills: { 'removed-in-this-version': { version: '0.0.1' } } })
145
+ );
146
+
147
+ const { updateSkill } = require('../update-skill');
148
+ expect(await updateSkill('removed-in-this-version', {})).toBe(1);
149
+ expect(fs.readFileSync(path.join(installed, 'SKILL.md'), 'utf8')).toBe('# user copy');
150
+ });
151
+ });
@@ -1,62 +1,20 @@
1
1
  /**
2
2
  * @test install-skill
3
- * @intent Verify installSkill() handles deps check, registry lookup, download, config, and errors
3
+ * @intent Verify installSkill() handles deps check, bundle lookup, config, backup, and errors
4
+ * @covers AC-416-01, AC-416-05
4
5
  */
5
6
  const fs = require('fs');
6
7
  const path = require('path');
7
8
  const os = require('os');
8
- const https = require('https');
9
- const { EventEmitter } = require('events');
10
-
11
- function mockHttpsGet(optsOrList) {
12
- const list = Array.isArray(optsOrList) ? [...optsOrList] : [optsOrList];
13
- // Synchronous fake createWriteStream — the real one opens the FD asynchronously
14
- // and races against downloadFile's unlinkSync(dest) in 301/302/error branches,
15
- // causing ENOENT unhandled errors.
16
- vi.spyOn(fs, 'createWriteStream').mockImplementation((dest) => {
17
- let buf = '';
18
- try {
19
- fs.writeFileSync(dest, '');
20
- } catch {
21
- /* dest dir may not exist; downstream code will surface that */
22
- }
23
- const stream = new EventEmitter();
24
- stream.write = (chunk) => {
25
- buf += chunk;
26
- return true;
27
- };
28
- stream.end = () => {
29
- try {
30
- fs.writeFileSync(dest, buf);
31
- } catch {
32
- /* file may have been unlinked by source; safe to ignore */
33
- }
34
- process.nextTick(() => stream.emit('finish'));
35
- };
36
- stream.close = () => {};
37
- return stream;
38
- });
39
9
 
40
- vi.spyOn(https, 'get').mockImplementation((url, options, cb) => {
41
- const callback = typeof options === 'function' ? options : cb;
42
- const req = new EventEmitter();
43
- const config = list.shift() || { statusCode: 200, body: '# Skill' };
44
- process.nextTick(() => {
45
- if (config.errorAfter) {
46
- req.emit('error', new Error('Network error'));
47
- return;
48
- }
49
- const response = new EventEmitter();
50
- response.statusCode = config.statusCode != null ? config.statusCode : 200;
51
- response.headers = config.redirectTo ? { location: config.redirectTo } : {};
52
- response.pipe = (file) => {
53
- file.write(config.body != null ? config.body : '# Skill');
54
- file.end();
55
- };
56
- callback(response);
57
- });
58
- return req;
59
- });
10
+ const BUNDLED_SKILLS = path.join(__dirname, '..', '..', 'skills');
11
+
12
+ function listDirs(root) {
13
+ return fs
14
+ .readdirSync(root, { withFileTypes: true })
15
+ .filter((entry) => entry.isDirectory())
16
+ .map((entry) => entry.name)
17
+ .sort();
60
18
  }
61
19
 
62
20
  describe('install-skill', () => {
@@ -67,11 +25,9 @@ describe('install-skill', () => {
67
25
  tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'xpgate-in-'));
68
26
  process.env.HOME = tmpHome;
69
27
  vi.resetModules();
70
- delete require.cache[require.resolve('../install-skill')];
71
- delete require.cache[require.resolve('../detect-deps')];
72
- delete require.cache[require.resolve('../shared-paths')];
73
- delete require.cache[require.resolve('../download-skill')];
74
- delete require.cache[require.resolve('../rollback')];
28
+ for (const mod of ['../install-skill', '../detect-deps', '../shared-paths', '../rollback']) {
29
+ delete require.cache[require.resolve(mod)];
30
+ }
75
31
  vi.spyOn(console, 'log').mockImplementation(() => {});
76
32
  vi.spyOn(console, 'error').mockImplementation(() => {});
77
33
  vi.spyOn(console, 'warn').mockImplementation(() => {});
@@ -145,7 +101,7 @@ describe('install-skill', () => {
145
101
  );
146
102
  });
147
103
 
148
- it('returns 1 + Unknown skill error for unregistered name', async () => {
104
+ it('returns 1 + Unknown skill error for a name the bundle does not carry', async () => {
149
105
  setupValidDeps();
150
106
  const { installSkill } = require('../install-skill');
151
107
  const result = await installSkill('not-a-real-skill');
@@ -153,23 +109,50 @@ describe('install-skill', () => {
153
109
  expect(console.error).toHaveBeenCalledWith('Error: Unknown skill: not-a-real-skill');
154
110
  });
155
111
 
156
- it('returns 0 and writes SKILL.md when install succeeds', async () => {
112
+ // The old hand-maintained SKILLS_REGISTRY could list a skill the bundle did not
113
+ // ship (or miss one it did). The error hint must come from the bundle itself.
114
+ it('names exactly the bundled skills in the unknown-skill hint', async () => {
115
+ setupValidDeps();
116
+ const { installSkill } = require('../install-skill');
117
+ await installSkill('not-a-real-skill');
118
+
119
+ const hint = console.error.mock.calls
120
+ .map(([message]) => message)
121
+ .find((message) => typeof message === 'string' && message.includes('bundles:'));
122
+ expect(hint).toBeDefined();
123
+ expect(hint.match(/bundles: ([^.]*)\./)[1].split(', ').sort()).toEqual(listDirs(BUNDLED_SKILLS));
124
+ });
125
+
126
+ it('keeps an existing directory when the skill is not bundled', async () => {
127
+ // Validation must precede backupExisting(), which rmSyncs the target: a
128
+ // leftover skill from an older package must never be wiped by a failed install.
129
+ setupValidDeps();
130
+ const target = path.join(skillsDir(), 'removed-in-this-version');
131
+ fs.mkdirSync(target, { recursive: true });
132
+ fs.writeFileSync(path.join(target, 'SKILL.md'), 'keep me');
133
+
134
+ const { installSkill } = require('../install-skill');
135
+ const result = await installSkill('removed-in-this-version');
136
+
137
+ expect(result).toBe(1);
138
+ expect(fs.readFileSync(path.join(target, 'SKILL.md'), 'utf8')).toBe('keep me');
139
+ });
140
+
141
+ it('returns 0 and installs the bundled skill when install succeeds', async () => {
157
142
  setupValidDeps();
158
- mockHttpsGet({ statusCode: 200, body: '# Sprint Flow\nskill content' });
159
143
 
160
144
  const { installSkill } = require('../install-skill');
161
145
  const result = await installSkill('sprint-flow');
162
146
 
163
147
  expect(result).toBe(0);
164
148
  const installedFile = path.join(skillsDir(), 'sprint-flow', 'SKILL.md');
165
- expect(fs.existsSync(installedFile)).toBe(true);
166
- expect(fs.readFileSync(installedFile, 'utf8')).toContain('Sprint Flow');
149
+ expect(fs.readFileSync(installedFile, 'utf8'))
150
+ .toBe(fs.readFileSync(path.join(BUNDLED_SKILLS, 'sprint-flow', 'SKILL.md'), 'utf8'));
167
151
  expect(console.log).toHaveBeenCalledWith('✓ sprint-flow installed');
168
152
  });
169
153
 
170
154
  it('updates config with installedSkills metadata after successful install', async () => {
171
155
  setupValidDeps();
172
- mockHttpsGet({ statusCode: 200, body: '# Content' });
173
156
 
174
157
  const { installSkill } = require('../install-skill');
175
158
  await installSkill('delphi-review');
@@ -202,15 +185,15 @@ describe('install-skill', () => {
202
185
  const target = path.join(skillsDir(), 'sprint-flow');
203
186
  fs.mkdirSync(target, { recursive: true });
204
187
  fs.writeFileSync(path.join(target, 'OLD.md'), 'old-content');
205
- mockHttpsGet({ statusCode: 200, body: '# New Content' });
206
188
 
207
189
  const { installSkill } = require('../install-skill');
208
190
  const result = await installSkill('sprint-flow', { force: true });
209
191
 
210
192
  expect(result).toBe(0);
211
193
  const skillMd = path.join(target, 'SKILL.md');
212
- expect(fs.existsSync(skillMd)).toBe(true);
213
- expect(fs.readFileSync(skillMd, 'utf8')).toContain('New Content');
194
+ expect(fs.readFileSync(skillMd, 'utf8'))
195
+ .toBe(fs.readFileSync(path.join(BUNDLED_SKILLS, 'sprint-flow', 'SKILL.md'), 'utf8'));
196
+ expect(fs.existsSync(path.join(target, 'OLD.md'))).toBe(false);
214
197
 
215
198
  const backupRoot = path.join(configDir(), 'backup');
216
199
  expect(fs.existsSync(backupRoot)).toBe(true);
@@ -221,66 +204,16 @@ describe('install-skill', () => {
221
204
  expect(fs.readFileSync(backedUpFile, 'utf8')).toBe('old-content');
222
205
  });
223
206
 
224
- it('returns 1 + Failed to download when network fails (non-offline)', async () => {
225
- setupValidDeps();
226
- mockHttpsGet({ statusCode: 404 });
227
-
228
- const { installSkill } = require('../install-skill');
229
- const result = await installSkill('sprint-flow');
230
-
231
- expect(result).toBe(1);
232
- expect(console.error).toHaveBeenCalledWith('Error: Failed to download sprint-flow');
233
- });
234
-
235
- it('returns 2 + offline-cache error when offline=true and no cache', async () => {
236
- setupValidDeps();
237
-
238
- const { installSkill } = require('../install-skill');
239
- const result = await installSkill('sprint-flow', { offline: true });
240
-
241
- expect(result).toBe(2);
242
- expect(console.error).toHaveBeenCalledWith(
243
- expect.stringContaining('--offline specified but sprint-flow not in cache')
244
- );
245
- });
246
-
247
- it('verbose=true prints Downloading and Installed-to logs', async () => {
207
+ it('verbose=true prints Installing and Installed-to logs', async () => {
248
208
  setupValidDeps();
249
- mockHttpsGet({ statusCode: 200, body: '# X' });
250
209
 
251
210
  const { installSkill } = require('../install-skill');
252
211
  await installSkill('test-driven-development', { verbose: true });
253
212
 
254
- expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Downloading '));
213
+ expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Installing '));
255
214
  expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Installed to '));
256
215
  });
257
216
 
258
- it('verbose=true prints download-failure warning when network errors', async () => {
259
- setupValidDeps();
260
- mockHttpsGet({ statusCode: 500 });
261
-
262
- const { installSkill } = require('../install-skill');
263
- const result = await installSkill('ralph-loop', { verbose: true });
264
-
265
- expect(result).toBe(1);
266
- expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('Download failed'));
267
- });
268
-
269
- it('follows redirect (302) to download successfully', async () => {
270
- setupValidDeps();
271
- mockHttpsGet([
272
- { statusCode: 302, redirectTo: 'https://new.example.com/skill.md' },
273
- { statusCode: 200, body: '# Redirected Content' },
274
- ]);
275
-
276
- const { installSkill } = require('../install-skill');
277
- const result = await installSkill('sprint-flow');
278
-
279
- expect(result).toBe(0);
280
- const installedFile = path.join(skillsDir(), 'sprint-flow', 'SKILL.md');
281
- expect(fs.readFileSync(installedFile, 'utf8')).toContain('Redirected Content');
282
- });
283
-
284
217
  it('merges new skill into existing installedSkills config', async () => {
285
218
  setupValidDeps();
286
219
  fs.mkdirSync(configDir(), { recursive: true });
@@ -291,7 +224,6 @@ describe('install-skill', () => {
291
224
  otherSetting: true,
292
225
  })
293
226
  );
294
- mockHttpsGet({ statusCode: 200, body: '# X' });
295
227
 
296
228
  const { installSkill } = require('../install-skill');
297
229
  await installSkill('delphi-review');
@@ -308,22 +240,9 @@ describe('install-skill', () => {
308
240
  setupValidDeps();
309
241
  fs.mkdirSync(configDir(), { recursive: true });
310
242
  fs.writeFileSync(path.join(configDir(), 'xp-gate.json'), '{not-valid-json');
311
- mockHttpsGet({ statusCode: 200, body: '# X' });
312
243
 
313
244
  const { installSkill } = require('../install-skill');
314
245
  const result = await installSkill('sprint-flow');
315
246
  expect(result).toBe(0);
316
247
  });
317
-
318
- it('returns 1 + Failed to download on network error (non-verbose silent)', async () => {
319
- setupValidDeps();
320
- mockHttpsGet({ errorAfter: true });
321
-
322
- const { installSkill } = require('../install-skill');
323
- const result = await installSkill('test-driven-development');
324
-
325
- expect(result).toBe(1);
326
- expect(console.warn).not.toHaveBeenCalled();
327
- expect(console.error).toHaveBeenCalledWith('Error: Failed to download test-driven-development');
328
- });
329
248
  });
@@ -0,0 +1,158 @@
1
+ /**
2
+ * @test REQ-468
3
+ * @intent detectProjectLanguages() 只按 configFiles 判定语言,而 shell/powershell 两条
4
+ * 注册表项的 configFiles 为空数组 —— 这两种没有包管理器配置的语言因此永远检不出,
5
+ * doctor/check-tools 对 147 个 .sh + 27 个 .ps1 的仓库视而不见(#468)
6
+ * @covers AC-468-01, AC-468-02, AC-468-03, AC-468-04, AC-468-05
7
+ */
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const os = require('os');
11
+
12
+ function makeFixture(spec) {
13
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'xpgate-lang-'));
14
+ for (const [rel, content] of Object.entries(spec)) {
15
+ const target = path.join(root, rel);
16
+ fs.mkdirSync(path.dirname(target), { recursive: true });
17
+ fs.writeFileSync(target, content);
18
+ }
19
+ return root;
20
+ }
21
+
22
+ describe('detectProjectLanguages extension fallback (#468)', () => {
23
+ let loadModule;
24
+
25
+ beforeEach(() => {
26
+ vi.resetModules();
27
+ for (const mod of ['../language-tools']) {
28
+ delete require.cache[require.resolve(mod)];
29
+ }
30
+ loadModule = () => require('../language-tools');
31
+ vi.spyOn(console, 'log').mockImplementation(() => {});
32
+ });
33
+
34
+ afterEach(() => {
35
+ vi.restoreAllMocks();
36
+ });
37
+
38
+ function detectedIn(spec) {
39
+ const root = makeFixture(spec);
40
+ try {
41
+ return loadModule().detectProjectLanguages(root).detected;
42
+ } finally {
43
+ fs.rmSync(root, { recursive: true, force: true });
44
+ }
45
+ }
46
+
47
+ it('detects Shell from .sh files with no shell config file present', () => {
48
+ expect(detectedIn({ 'scripts/build.sh': '#!/bin/sh\necho hi\n' })).toContain('shell');
49
+ });
50
+
51
+ it('detects PowerShell from .ps1 files with no config file present', () => {
52
+ expect(detectedIn({ 'tools/audit.ps1': 'Write-Host hi\n' })).toContain('powershell');
53
+ });
54
+
55
+ it('does not report Shell for a project that has no shell script', () => {
56
+ const detected = detectedIn({ 'package.json': '{}' });
57
+ expect(detected).not.toContain('shell');
58
+ expect(detected).not.toContain('powershell');
59
+ });
60
+
61
+ // node_modules ships .sh files, so scanning it would label every Node project a
62
+ // Shell project and make check-tools demand shellcheck forever.
63
+ it('ignores dependency and VCS directories when scanning extensions', () => {
64
+ const detected = detectedIn({
65
+ 'package.json': '{}',
66
+ 'node_modules/left-pad/build.sh': '#!/bin/sh\n',
67
+ '.git/hooks/pre-commit.ps1': 'x\n',
68
+ });
69
+ expect(detected).not.toContain('shell');
70
+ expect(detected).not.toContain('powershell');
71
+ });
72
+
73
+ // The fallback is only for languages that have no config-file signal. Extensions
74
+ // are registered for every language, and IaC lists yaml/yml — applying them
75
+ // across the board would make any repo with a CI YAML an IaC project.
76
+ it('does not turn an unrelated YAML file into an IaC project', () => {
77
+ const detected = detectedIn({
78
+ 'package.json': '{}',
79
+ '.github/workflows/ci.yml': 'name: ci\n',
80
+ });
81
+ expect(detected).not.toContain('iac');
82
+ });
83
+
84
+ it('keeps config-file detection for the languages that already had it', () => {
85
+ const root = makeFixture({
86
+ 'package.json': '{"name":"x"}',
87
+ 'tsconfig.json': '{}',
88
+ });
89
+ let result;
90
+ try {
91
+ result = loadModule().detectProjectLanguages(root);
92
+ } finally {
93
+ fs.rmSync(root, { recursive: true, force: true });
94
+ }
95
+ expect(result.detected).toContain('typescript');
96
+ expect(result.configFiles.typescript).toContain('tsconfig.json');
97
+ });
98
+ });
99
+
100
+ describe('check-tools language selection (#468)', () => {
101
+ let fixtureRoot;
102
+
103
+ beforeEach(() => {
104
+ vi.resetModules();
105
+ delete require.cache[require.resolve('../language-tools')];
106
+ vi.spyOn(console, 'log').mockImplementation(() => {});
107
+ fixtureRoot = makeFixture({ 'scripts/build.sh': '#!/bin/sh\necho hi\n' });
108
+ // vitest workers forbid process.chdir(), and the command resolves the project
109
+ // root through process.cwd().
110
+ vi.spyOn(process, 'cwd').mockReturnValue(fixtureRoot);
111
+ });
112
+
113
+ afterEach(() => {
114
+ fs.rmSync(fixtureRoot, { recursive: true, force: true });
115
+ vi.restoreAllMocks();
116
+ });
117
+
118
+ it('--languages <lang> with a space is not silently ignored', async () => {
119
+ const { handleCheckTools } = require('../language-tools');
120
+ const code = await handleCheckTools(['--languages', 'shell', '--json']);
121
+
122
+ expect(code).toBe(0);
123
+ const reported = JSON.parse(console.log.mock.calls.at(-1)[0]);
124
+ expect(Object.keys(reported)).toEqual(['shell']);
125
+ expect(reported.shell.map((t) => t.name)).toContain('shellcheck');
126
+ });
127
+
128
+ it('--languages=<lang> keeps working', async () => {
129
+ const { handleCheckTools } = require('../language-tools');
130
+ const code = await handleCheckTools(['--languages=shell', '--json']);
131
+
132
+ expect(code).toBe(0);
133
+ const reported = JSON.parse(console.log.mock.calls.at(-1)[0]);
134
+ expect(Object.keys(reported)).toEqual(['shell']);
135
+ });
136
+
137
+ it('auto-detection reaches Shell, so check-tools with no flag lists shellcheck', async () => {
138
+ const { handleCheckTools } = require('../language-tools');
139
+ const code = await handleCheckTools(['--json']);
140
+
141
+ expect(code).toBe(0);
142
+ const reported = JSON.parse(console.log.mock.calls.at(-1)[0]);
143
+ expect(Object.keys(reported)).toContain('shell');
144
+ });
145
+
146
+ // Round 1 review (architecture MI-02): `--languages=` split into [''], which
147
+ // was treated as an explicit selection and reported a language named '' instead
148
+ // of falling back to detection like the bare flag does.
149
+ it('--languages= with an empty value falls back to auto-detection', async () => {
150
+ const { handleCheckTools } = require('../language-tools');
151
+ const code = await handleCheckTools(['--languages=', '--json']);
152
+
153
+ expect(code).toBe(0);
154
+ const reported = JSON.parse(console.log.mock.calls.at(-1)[0]);
155
+ expect(Object.keys(reported)).not.toContain('');
156
+ expect(Object.keys(reported)).toContain('shell');
157
+ });
158
+ });
@@ -697,7 +697,21 @@ describe('phase-transition', () => {
697
697
  const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'requirements-context-'));
698
698
  const outsideFile = path.join(outsideDir, 'CONTEXT.md');
699
699
  fs.writeFileSync(outsideFile, 'outside context\n', 'utf8');
700
- fs.symlinkSync(outsideFile, path.join(tmpDir, 'CONTEXT.md'));
700
+ // Creating a symlink needs SeCreateSymbolicLinkPrivilege (or Developer Mode)
701
+ // on Windows and fails with EPERM otherwise. Returning early here would
702
+ // leave an assertion that can never fail, so the gap is reported instead --
703
+ // and the try/finally below still cleans up the temp dir.
704
+ try {
705
+ fs.symlinkSync(outsideFile, path.join(tmpDir, 'CONTEXT.md'));
706
+ } catch (error) {
707
+ fs.rmSync(outsideDir, { recursive: true, force: true });
708
+ console.warn(
709
+ '[skip] symlink privilege unavailable on this host; the outside-root ' +
710
+ 'context symlink rejection is NOT covered by this run.',
711
+ );
712
+ expect(error.code).toBe('EPERM');
713
+ return;
714
+ }
701
715
  const evidence = validRequirementsReview(tmpDir, { context_file_used: 'CONTEXT.md' });
702
716
  writeRequirementsReview(tmpDir, evidence);
703
717