@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,105 @@
1
+ /**
2
+ * @test REQ-439
3
+ * @intent doctor 判技能新鲜度必须行尾无关:frontmatter 正则只认 LF + 退化分支做
4
+ * 字节比较,导致 autocrlf 工作树下 12/13 个技能被假报 Outdated,真差异
5
+ * 反而被噪声淹没(#439)
6
+ * @covers AC-439-01, AC-439-02, AC-439-03
7
+ */
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const os = require('os');
11
+
12
+ describe('doctor-tui skill freshness (#439)', () => {
13
+ let tmpHome;
14
+ let originalHome;
15
+ // Same dir doctor-tui resolves as PKG_DIR (path.dirname of lib/).
16
+ const bundledSkillsDir = path.join(__dirname, '..', '..', 'skills');
17
+
18
+ beforeEach(() => {
19
+ originalHome = process.env.HOME;
20
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'xpgate-fresh-'));
21
+ process.env.HOME = tmpHome;
22
+ vi.resetModules();
23
+ delete require.cache[require.resolve('../doctor-tui')];
24
+ delete require.cache[require.resolve('../shared-paths')];
25
+ });
26
+
27
+ afterEach(() => {
28
+ process.env.HOME = originalHome;
29
+ fs.rmSync(tmpHome, { recursive: true, force: true });
30
+ });
31
+
32
+ // HOME has no platform marker, so detectPlatform() resolves to opencode.
33
+ function installSkill(name, content) {
34
+ const dir = path.join(tmpHome, '.config', 'opencode', 'skills', name);
35
+ fs.mkdirSync(dir, { recursive: true });
36
+ fs.writeFileSync(path.join(dir, 'SKILL.md'), content);
37
+ }
38
+
39
+ function readBundled(name) {
40
+ return fs.readFileSync(path.join(bundledSkillsDir, name, 'SKILL.md'), 'utf8');
41
+ }
42
+
43
+ /** Same bytes, opposite line endings — the only difference a fresh install can have. */
44
+ function flipLineEndings(content) {
45
+ const lf = content.replace(/\r\n/g, '\n');
46
+ return lf === content ? lf.replace(/\n/g, '\r\n') : lf;
47
+ }
48
+
49
+ function runFreshness(name) {
50
+ const { diagnoseInstalledSkills } = require('../doctor-tui');
51
+ const checks = [];
52
+ const issues = diagnoseInstalledSkills({ installedSkills: { [name]: {} } }, checks);
53
+ const check = checks.find(c => c.name === `Skill: ${name}`);
54
+ return { issues, check };
55
+ }
56
+
57
+ it('reads a version from CRLF frontmatter, not only LF', () => {
58
+ const { extractSkillVersion } = require('../doctor-tui');
59
+ expect(extractSkillVersion('---\r\nname: x\r\nversion: 2.1.0\r\n---\r\nbody\r\n')).toBe('2.1.0');
60
+ // LF must keep working -- the fix is a widening, not a swap.
61
+ expect(extractSkillVersion('---\nname: x\nversion: 2.1.0\n---\nbody\n')).toBe('2.1.0');
62
+ // ... and so must a lone CR, the third convention the helper's name covers.
63
+ expect(extractSkillVersion('---\rname: x\rversion: 2.1.0\r---\rbody\r')).toBe('2.1.0');
64
+ });
65
+
66
+ it('treats a versioned skill as up to date when only line endings differ', () => {
67
+ const bundled = readBundled('sprint-flow');
68
+ expect(bundled).toMatch(/^---/);
69
+ installSkill('sprint-flow', flipLineEndings(bundled));
70
+ const { check } = runFreshness('sprint-flow');
71
+ expect(check, JSON.stringify(check)).toBeDefined();
72
+ expect(check.status).toBe('PASS');
73
+ });
74
+
75
+ it('treats a versionless skill as up to date when only line endings differ', () => {
76
+ const bundled = readBundled('grilling');
77
+ // This is the branch the issue hit for 11 of 12 skills: no version field at all,
78
+ // so freshness can only be decided by content.
79
+ expect(bundled).not.toMatch(/^version:/m);
80
+ installSkill('grilling', flipLineEndings(bundled));
81
+ const { check } = runFreshness('grilling');
82
+ expect(check, JSON.stringify(check)).toBeDefined();
83
+ expect(check.status).toBe('PASS');
84
+ });
85
+
86
+ // AC-439-02 promises "differences beyond line endings" still report Outdated,
87
+ // which means every line-ending convention has to be tolerated, not just CRLF
88
+ // (Round 2 feasibility FC-05: the helper's name already says as much).
89
+ it('treats a lone-CR skill file as up to date too', () => {
90
+ const bundled = readBundled('grilling');
91
+ installSkill('grilling', bundled.replace(/\r\n/g, '\n').replace(/\n/g, '\r'));
92
+ const { check } = runFreshness('grilling');
93
+ expect(check, JSON.stringify(check)).toBeDefined();
94
+ expect(check.status).toBe('PASS');
95
+ });
96
+
97
+ it('still reports Outdated for a real content difference (anti-vacuity)', () => {
98
+ const bundled = readBundled('grilling');
99
+ installSkill('grilling', `${flipLineEndings(bundled)}\n## Diverged section\n`);
100
+ const { issues, check } = runFreshness('grilling');
101
+ expect(check.status).toBe('WARN');
102
+ expect(check.detail).toContain('Outdated');
103
+ expect(issues).toBe(1);
104
+ });
105
+ });
@@ -18,6 +18,7 @@ describe('doctor', () => {
18
18
  let tmpProject;
19
19
  let originalHome;
20
20
  let originalXpGateCacheDir;
21
+ let originalRepoRoot;
21
22
  let originalDetectDepsTools;
22
23
  let originalExecSync;
23
24
  let originalExec;
@@ -54,12 +55,19 @@ describe('doctor', () => {
54
55
  beforeEach(() => {
55
56
  originalHome = process.env.HOME;
56
57
  originalXpGateCacheDir = process.env.XP_GATE_CACHE_DIR;
58
+ originalRepoRoot = process.env.XP_GATE_REPO_ROOT;
57
59
  originalExecSync = cp.execSync;
58
60
  originalExec = cp.exec;
59
61
  // Unique per-describe + per-test prefixes to avoid parallel collisions.
60
62
  tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), `xpgate-dr-${DESCRIBE_ID}-`));
61
63
  tmpProject = fs.mkdtempSync(path.join(os.tmpdir(), `xpgate-dr-proj-${DESCRIBE_ID}-`));
62
64
  process.env.HOME = tmpHome;
65
+ // #451: the hook-drift check reads the canonical githooks/ of *this* repo
66
+ // when no override is given. In a fixture whose whole point is "a healthy
67
+ // installation", that would compare the real checkout against synthetic
68
+ // temp files and always report drift. Point it at an empty dir instead:
69
+ // these tests assert installation health, not drift.
70
+ process.env.XP_GATE_REPO_ROOT = path.join(tmpProject, 'no-repo-hooks');
63
71
  vi.resetModules();
64
72
  // Invalidate all cached modules that load path/environment globals at require-time.
65
73
  // Do NOT invalidate detect-deps.js — its GATE_CLI_TOOLS was already cleared in beforeAll
@@ -79,6 +87,8 @@ describe('doctor', () => {
79
87
  afterEach(() => {
80
88
  process.env.HOME = originalHome;
81
89
  process.env.XP_GATE_CACHE_DIR = originalXpGateCacheDir;
90
+ if (originalRepoRoot === undefined) delete process.env.XP_GATE_REPO_ROOT;
91
+ else process.env.XP_GATE_REPO_ROOT = originalRepoRoot;
82
92
  cp.execSync = originalExecSync;
83
93
  cp.exec = originalExec;
84
94
  if (tmpHome && fs.existsSync(tmpHome)) {
@@ -130,6 +140,14 @@ describe('doctor', () => {
130
140
  );
131
141
  }
132
142
 
143
+ function createXpGatePostMerge(dir) {
144
+ fs.mkdirSync(dir, { recursive: true });
145
+ fs.writeFileSync(
146
+ path.join(dir, 'post-merge'),
147
+ '#!/usr/bin/env bash\n# post-merge - XP-Gate VERSION sync hook\n'
148
+ );
149
+ }
150
+
133
151
  function createXpGateAdapterCommon(dir) {
134
152
  fs.mkdirSync(dir, { recursive: true });
135
153
  fs.writeFileSync(
@@ -166,6 +184,7 @@ describe('doctor', () => {
166
184
  function setupLocalInstall() {
167
185
  createXpGatePreCommit(projectHooksDir());
168
186
  createXpGatePrePush(projectHooksDir());
187
+ createXpGatePostMerge(projectHooksDir());
169
188
  createXpGateAdapterCommon(projectGithooksDir());
170
189
  createXpGateAdapterScripts(projectGithooksDir());
171
190
 
@@ -182,6 +201,7 @@ describe('doctor', () => {
182
201
  function setupGlobalInstall() {
183
202
  createXpGatePreCommit(globalHooksDir());
184
203
  createXpGatePrePush(globalHooksDir());
204
+ createXpGatePostMerge(globalHooksDir());
185
205
  createXpGateAdapterCommon(globalAdaptersDir());
186
206
  // For global mode, create adapters directly in globalAdaptersDir (not in a subdirectory)
187
207
  fs.mkdirSync(globalAdaptersDir(), { recursive: true });
@@ -337,6 +357,21 @@ describe('doctor', () => {
337
357
  );
338
358
  });
339
359
 
360
+ it('AC-08: doctor detects a missing post-merge hook', async () => {
361
+ setupLocalInstall();
362
+ seedVersionCache();
363
+ fs.unlinkSync(path.join(projectHooksDir(), 'post-merge'));
364
+ mockExecSuccess();
365
+ const { doctor } = require('../doctor');
366
+
367
+ const result = await doctor([]);
368
+
369
+ expect(result).toBe(1);
370
+ expect(logSpy).toHaveBeenCalledWith(
371
+ expect.stringContaining('post-merge')
372
+ );
373
+ });
374
+
340
375
  it('AC-08: doctor detects missing config file', async () => {
341
376
  // No config at all
342
377
  const { doctor } = require('../doctor');
@@ -463,6 +498,19 @@ describe('doctor', () => {
463
498
  expect(fs.existsSync(path.join(projectHooksDir(), 'pre-push'))).toBe(true);
464
499
  });
465
500
 
501
+ it('AC-10: doctor --fix restores a missing post-merge hook', async () => {
502
+ setupLocalInstall();
503
+ seedVersionCache();
504
+ fs.unlinkSync(path.join(projectHooksDir(), 'post-merge'));
505
+ mockExecSuccess();
506
+ const { doctor } = require('../doctor');
507
+
508
+ const result = await doctor(['--fix']);
509
+
510
+ expect(result).toBe(0);
511
+ expect(fs.existsSync(path.join(projectHooksDir(), 'post-merge'))).toBe(true);
512
+ });
513
+
466
514
  it('AC-10: doctor --fix reinstall adapters when mode is active and adapters missing', async () => {
467
515
  setupLocalInstall();
468
516
  seedVersionCache();
@@ -551,6 +599,23 @@ describe('doctor', () => {
551
599
 
552
600
  // === Edge cases ===
553
601
 
602
+ it('AC-488: doctor rejects unknown flags instead of silently ignoring them', async () => {
603
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
604
+ const { doctor } = require('../doctor');
605
+ const result = await doctor(['--syn-hooks', 'extra-arg']);
606
+ expect(result).toBe(1);
607
+ expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Unknown flag'));
608
+ errorSpy.mockRestore();
609
+ });
610
+
611
+ it('AC-488: doctor still accepts its documented flags', async () => {
612
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
613
+ const { doctor } = require('../doctor');
614
+ await doctor(['--fix', '--sync-hooks']);
615
+ expect(errorSpy).not.toHaveBeenCalled();
616
+ errorSpy.mockRestore();
617
+ });
618
+
554
619
  it('reports exit code 1 with unhealthy or missing install', async () => {
555
620
  const { doctor } = require('../doctor');
556
621
  const result = await doctor([]);
@@ -1053,4 +1118,94 @@ describe('doctor', () => {
1053
1118
  expect(json.ok).toBe(true);
1054
1119
  expect(json.issues).toBe(0);
1055
1120
  });
1121
+
1122
+ // AC-488-08: the flag was advertised in the release notes since the version
1123
+ // that added doctor, and rejected as an unknown flag until now. Asserting the
1124
+ // FORMATTER alone would pass while `doctor --format json` still crashed, so
1125
+ // this runs the command and inspects what a script would actually parse.
1126
+ it('AC-488-08: doctor --format json emits one parseable document and nothing else', async () => {
1127
+ setupLocalInstall();
1128
+ seedVersionCache();
1129
+ mockExecSuccess();
1130
+ const { doctor } = require('../doctor');
1131
+ const seen = [];
1132
+ const logSpy = vi.spyOn(console, 'log').mockImplementation((msg) => { seen.push(String(msg)); });
1133
+
1134
+ let code;
1135
+ try {
1136
+ code = await doctor(['--format', 'json']);
1137
+ } finally {
1138
+ logSpy.mockRestore();
1139
+ }
1140
+
1141
+ expect(seen, `expected exactly one JSON line, got:\n${seen.join('\n')}`).toHaveLength(1);
1142
+ const doc = JSON.parse(seen[0]);
1143
+ expect(doc.ok).toBe(code === 0);
1144
+ expect(Array.isArray(doc.checks)).toBe(true);
1145
+ expect(doc.checks.length).toBeGreaterThan(0);
1146
+ expect(seen[0]).not.toMatch(/XP-Gate Doctor|Diagnosis Report/);
1147
+ });
1148
+
1149
+ it('AC-488-08: a machine-readable doctor run cannot also mutate the machine', async () => {
1150
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
1151
+ const { doctor } = require('../doctor');
1152
+
1153
+ expect(await doctor(['--format', 'json', '--fix'])).toBe(1);
1154
+ expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('--fix'));
1155
+ expect(await doctor(['--json', '--sync-hooks'])).toBe(1);
1156
+ errorSpy.mockRestore();
1157
+ });
1158
+
1159
+ it('AC-488-08: an unknown --format value is rejected instead of silently ignored', async () => {
1160
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
1161
+ const { doctor } = require('../doctor');
1162
+
1163
+ expect(await doctor(['--format', 'yaml'])).toBe(1);
1164
+ expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('--format'));
1165
+ errorSpy.mockRestore();
1166
+ });
1167
+
1168
+ // AC-488-09 (#488 Round 4, feasibility): the same class as the original issue —
1169
+ // a flag that is accepted and then does nothing. `--format` with no value, and
1170
+ // `--force` with no mode that reads it, both looked like they ran.
1171
+ it('AC-488-09: a value-less --format is rejected instead of silently dropping the flag', async () => {
1172
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
1173
+ const { doctor } = require('../doctor');
1174
+
1175
+ expect(await doctor(['--format'])).toBe(1);
1176
+ expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('--format'));
1177
+ errorSpy.mockRestore();
1178
+ });
1179
+
1180
+ it('AC-488-09: --force is refused when no mode consumes it', () => {
1181
+ // Asserted through the pure contract rather than by running the command:
1182
+ // the only mode that reads --force is the one that copies files over the
1183
+ // installed hooks, so a run-based assertion would mutate this machine.
1184
+ const { doctorFlagContract } = require('../doctor');
1185
+
1186
+ const bare = doctorFlagContract(['--force']);
1187
+ expect(bare.errors).toHaveLength(2);
1188
+ expect(bare.errors[0]).toContain('--force');
1189
+ expect(bare.errors[0]).toContain('--sync-hooks');
1190
+ expect(doctorFlagContract(['--fix', '--force']).errors.length, '--fix does not read --force either').toBeGreaterThan(0);
1191
+
1192
+ const synced = doctorFlagContract(['--sync-hooks', '--force']);
1193
+ expect(synced.errors, synced.errors.join('\n')).toHaveLength(0);
1194
+ expect(synced.force).toBe(true);
1195
+ expect(synced.syncHooks).toBe(true);
1196
+ });
1197
+
1198
+ it('AC-488-09: --install-tools is refused unless --fix is the mode that reads it', () => {
1199
+ const { doctorFlagContract } = require('../doctor');
1200
+
1201
+ const bare = doctorFlagContract(['--install-tools']);
1202
+ expect(bare.errors).toHaveLength(2);
1203
+ expect(bare.errors[0]).toContain('--install-tools');
1204
+ expect(bare.errors[0]).toContain('--fix');
1205
+ expect(doctorFlagContract(['--sync-hooks', '--install-tools']).errors.length).toBeGreaterThan(0);
1206
+
1207
+ const fixing = doctorFlagContract(['--fix', '--install-tools']);
1208
+ expect(fixing.errors, fixing.errors.join('\n')).toHaveLength(0);
1209
+ expect(fixing.installToolsFlag).toBe(true);
1210
+ });
1056
1211
  });
@@ -43,6 +43,17 @@ function shellParameter(expression) {
43
43
  return ['$', '{', expression, '}'].join('');
44
44
  }
45
45
 
46
+ // Path segment that must survive quoting untouched. Windows rejects `"` and `*`
47
+ // in a filename, so the hostile set there keeps the same bash hazards — word
48
+ // split, parameter expansion, command substitution, quoting, list separator —
49
+ // with legal characters instead (#428).
50
+ function hostilePathSegment() {
51
+ if (process.platform === 'win32') {
52
+ return "space $HOME `marker` 'quoted' & (paren)";
53
+ }
54
+ return 'space $HOME `marker` "quoted"';
55
+ }
56
+
46
57
  function initializeGitProject() {
47
58
  execFileSync('git', ['init', '-q'], { cwd: tmpProject });
48
59
  execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: tmpProject });
@@ -171,20 +182,25 @@ describe('gate-runner.js — runGateAdapter', () => {
171
182
  path.join(callerProject, 'githooks', 'gates', 'gate-9-caller.sh'),
172
183
  'echo "__CALLER_REPO__"\n'
173
184
  );
185
+ // The fragment is written into the target project only, so a relative probe
186
+ // answers "which project did we run in?" without comparing path strings —
187
+ // bash reports $PWD in POSIX form, which can never equal the Windows path
188
+ // the test passes as an argument (#428).
174
189
  fs.writeFileSync(
175
190
  path.join(targetProject, 'githooks', 'gates', 'gate-9-target.sh'),
176
191
  [
177
- 'if [ "$PWD" != "$EXPECTED_TARGET" ]; then exit 88; fi',
192
+ 'if [ ! -f "githooks/gates/gate-9-target.sh" ]; then exit 88; fi',
193
+ 'if [ -f ".xpgate-caller-only" ]; then exit 88; fi',
178
194
  'echo "__TARGET_REPO__"',
179
195
  ].join('\n')
180
196
  );
197
+ fs.writeFileSync(path.join(callerProject, '.xpgate-caller-only'), 'caller\n');
181
198
 
182
199
  const result = spawnSync(
183
200
  process.execPath,
184
201
  [path.join(packageFixture, 'bin', 'xp-gate.js'), 'check', '9', targetProject],
185
202
  {
186
203
  cwd: callerProject,
187
- env: { ...process.env, EXPECTED_TARGET: targetProject },
188
204
  encoding: 'utf8',
189
205
  timeout: 30000,
190
206
  }
@@ -197,7 +213,7 @@ describe('gate-runner.js — runGateAdapter', () => {
197
213
 
198
214
  it('sources fragment and helper paths containing shell metacharacters literally (REQ-standalone-gates-07)', () => {
199
215
  const runnerAbsPath = path.resolve(__dirname, '../gate-runner.js');
200
- const hostileRoot = path.join(tmpProject, 'space $HOME `marker` "quoted"');
216
+ const hostileRoot = path.join(tmpProject, hostilePathSegment());
201
217
  tmpAdapters = path.join(hostileRoot, 'githooks', 'adapters');
202
218
  fs.mkdirSync(tmpAdapters, { recursive: true });
203
219
  writeAdapterCommon();
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Shared scaffolding for `lib/__tests__` suites. Hoisted out of the #417 agent
3
+ * test because the same reset-and-mute sequence already lives in two other
4
+ * suites and a third inline copy tripped the architecture ratchet.
5
+ */
6
+ const fs = require('fs');
7
+ const os = require('os');
8
+ const path = require('path');
9
+
10
+ /**
11
+ * Drop the HOME-derived modules from Node's cache so the next require() sees
12
+ * the sandboxed `process.env.HOME`.
13
+ *
14
+ * @param {object} vi - Vitest API
15
+ * @param {string[]} resolvedPaths - already require.resolve()d module paths
16
+ */
17
+ function resetLibModules(vi, resolvedPaths) {
18
+ vi.resetModules();
19
+ for (const resolved of resolvedPaths) delete require.cache[resolved];
20
+ }
21
+
22
+ /**
23
+ * @param {object} vi - Vitest API
24
+ * @returns {{log: object, warn: object, error: object}} console spies
25
+ */
26
+ function muteConsole(vi) {
27
+ const spies = {};
28
+ for (const level of ['log', 'warn', 'error']) {
29
+ spies[level] = vi.spyOn(console, level).mockImplementation(() => {});
30
+ }
31
+ return spies;
32
+ }
33
+
34
+ /**
35
+ * @param {string[]} prefixes - tmpdir name prefixes to create
36
+ * @returns {Object<string, string>} created temp directories by prefix
37
+ */
38
+ function makeTempDirs(prefixes) {
39
+ const dirs = {};
40
+ for (const prefix of prefixes) {
41
+ dirs[prefix] = fs.mkdtempSync(path.join(os.tmpdir(), `${prefix}-`));
42
+ }
43
+ return dirs;
44
+ }
45
+
46
+ /**
47
+ * @param {Object<string, string>} dirs - directories returned by makeTempDirs
48
+ */
49
+ function removeTempDirs(dirs) {
50
+ for (const dir of Object.values(dirs)) {
51
+ fs.rmSync(dir, { recursive: true, force: true });
52
+ }
53
+ }
54
+
55
+ module.exports = { resetLibModules, muteConsole, makeTempDirs, removeTempDirs };
@@ -385,28 +385,35 @@ describe('init', () => {
385
385
  return path.join(__dirname, '..', '..', 'plugins', 'qoder', 'agents');
386
386
  }
387
387
 
388
- it('deploys Delphi agents when platform is Qoder and agents do not exist', async () => {
389
- fs.mkdirSync(path.join(tmpProject, '.git', 'hooks'), { recursive: true });
390
- mockExecSuccess();
391
- setupQoderPlatform();
392
-
393
- // Ensure agent templates exist in the npm package
388
+ // Real agent templates ship inside the git-tracked mirror
389
+ // (src/npm-package/plugins/qoder/agents/) — tests must never write there.
390
+ function assertRealTemplatePresent() {
394
391
  const templateDir = agentTemplateDir();
395
- fs.mkdirSync(templateDir, { recursive: true });
396
- fs.writeFileSync(path.join(templateDir, 'delphi-architecture.md'), '---\nname: test\n---\narch expert');
392
+ expect(fs.existsSync(path.join(templateDir, 'delphi-architecture.md'))).toBe(true);
393
+ }
397
394
 
395
+ // Fresh module registry so init() picks up this test's tmpProject/tmpHome.
396
+ function reloadInit() {
398
397
  vi.resetModules();
399
398
  delete require.cache[require.resolve('../init')];
400
399
  delete require.cache[require.resolve('../detect-deps.js')];
401
400
  delete require.cache[require.resolve('../shared-paths')];
401
+ return require('../init');
402
+ }
402
403
 
403
- const { init } = require('../init');
404
+ it('deploys Delphi agents when platform is Qoder and agents do not exist', async () => {
405
+ fs.mkdirSync(path.join(tmpProject, '.git', 'hooks'), { recursive: true });
406
+ mockExecSuccess();
407
+ setupQoderPlatform();
408
+ assertRealTemplatePresent();
409
+
410
+ const { init } = reloadInit();
404
411
  const result = await init(['--core-only']);
405
412
  expect(result).toBe(0);
406
413
 
407
414
  const deployedAgent = path.join(tmpProject, '.qoder', 'agents', 'delphi-architecture.md');
408
415
  expect(fs.existsSync(deployedAgent)).toBe(true);
409
- expect(fs.readFileSync(deployedAgent, 'utf8')).toContain('arch expert');
416
+ expect(fs.readFileSync(deployedAgent, 'utf8')).toContain('name: delphi-architecture');
410
417
  });
411
418
 
412
419
  it('does not overwrite existing agent files (preserves user customizations)', async () => {
@@ -419,10 +426,18 @@ describe('init', () => {
419
426
  fs.mkdirSync(agentsDir, { recursive: true });
420
427
  fs.writeFileSync(path.join(agentsDir, 'delphi-architecture.md'), 'CUSTOM USER CONTENT');
421
428
 
422
- // Template in npm package
423
- const templateDir = agentTemplateDir();
424
- fs.mkdirSync(templateDir, { recursive: true });
425
- fs.writeFileSync(path.join(templateDir, 'delphi-architecture.md'), 'TEMPLATE CONTENT');
429
+ assertRealTemplatePresent();
430
+
431
+ const { init } = reloadInit();
432
+ await init(['--core-only']);
433
+
434
+ // User content preserved
435
+ expect(fs.readFileSync(path.join(agentsDir, 'delphi-architecture.md'), 'utf8')).toBe('CUSTOM USER CONTENT');
436
+ });
437
+
438
+ it('deploys Delphi agents to the user-level agents dir in global mode', async () => {
439
+ mockExecSuccess();
440
+ setupQoderPlatform();
426
441
 
427
442
  vi.resetModules();
428
443
  delete require.cache[require.resolve('../init')];
@@ -430,10 +445,22 @@ describe('init', () => {
430
445
  delete require.cache[require.resolve('../shared-paths')];
431
446
 
432
447
  const { init } = require('../init');
433
- await init(['--core-only']);
448
+ const result = await init(['--global']);
449
+ expect(result).toBe(0);
434
450
 
435
- // User content preserved
436
- expect(fs.readFileSync(path.join(agentsDir, 'delphi-architecture.md'), 'utf8')).toBe('CUSTOM USER CONTENT');
451
+ const globalAgent = path.join(tmpHome, '.qoder', 'agents', 'delphi-architecture.md');
452
+ expect(fs.existsSync(globalAgent)).toBe(true);
453
+ });
454
+
455
+ it('pins every expert template to a distinct built-in model in "[Name](modelId)" form', () => {
456
+ const templateDir = agentTemplateDir();
457
+ const { readQoderModelId } = require('../init');
458
+ const models = ['delphi-architecture', 'delphi-technical', 'delphi-feasibility'].map(name => {
459
+ const modelId = readQoderModelId(path.join(templateDir, `${name}.md`));
460
+ expect(modelId).not.toBeNull();
461
+ return modelId;
462
+ });
463
+ expect(new Set(models).size).toBe(3);
437
464
  });
438
465
 
439
466
  it('skips deployment when platform is not Qoder', async () => {