@boyingliu01/xp-gate 0.19.2 → 0.20.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 (223) hide show
  1. package/adapters/gate-4.sh +83 -18
  2. package/adapters/gate-8.sh +49 -5
  3. package/adapters/iac.sh +1 -1
  4. package/adapters/typescript.sh +15 -60
  5. package/bin/xp-gate.js +15 -16
  6. package/gate-10.sh +35 -2
  7. package/gate-4.sh +110 -21
  8. package/gate-8.sh +49 -5
  9. package/hooks/gate-10.sh +35 -2
  10. package/hooks/gate-4.sh +110 -21
  11. package/hooks/gate-8.sh +49 -5
  12. package/hooks/lib/jscpd-run.sh +63 -0
  13. package/hooks/lib/sprint-gate-report.sh +58 -0
  14. package/hooks/lib/test-failure.sh +152 -0
  15. package/hooks/lib/typecheck.sh +141 -0
  16. package/hooks/lib/validate-code-walkthrough.cjs +43 -5
  17. package/hooks/post-merge +100 -0
  18. package/hooks/pre-commit +401 -155
  19. package/hooks/pre-push +75 -19
  20. package/lib/__tests__/bootstrap.test.js +23 -41
  21. package/lib/__tests__/check-version.test.js +8 -1
  22. package/lib/__tests__/detect-deps.test.js +16 -0
  23. package/lib/__tests__/doctor-drift-round2.test.js +212 -0
  24. package/lib/__tests__/doctor-executed-surface.test.js +209 -0
  25. package/lib/__tests__/doctor-hook-drift.test.js +329 -0
  26. package/lib/__tests__/doctor-machine-mutation.test.js +116 -0
  27. package/lib/__tests__/doctor-module-drift.test.js +206 -0
  28. package/lib/__tests__/doctor-module-resolution-parity.test.js +314 -0
  29. package/lib/__tests__/doctor-path-equivalence.test.js +59 -0
  30. package/lib/__tests__/doctor-repair-boundary.test.js +52 -0
  31. package/lib/__tests__/doctor-tui-skill-freshness.test.js +105 -0
  32. package/lib/__tests__/doctor.test.js +155 -0
  33. package/lib/__tests__/gate-runner.test.js +19 -3
  34. package/lib/__tests__/helpers/lib-sandbox.cjs +55 -0
  35. package/lib/__tests__/init.test.js +44 -17
  36. package/lib/__tests__/install-skill-full-dir.test.js +151 -0
  37. package/lib/__tests__/install-skill.test.js +51 -132
  38. package/lib/__tests__/language-tools-detect.test.js +158 -0
  39. package/lib/__tests__/phase-transition.test.js +15 -1
  40. package/lib/__tests__/qoder-delphi-agents.test.js +285 -0
  41. package/lib/__tests__/shared-paths.test.js +20 -0
  42. package/lib/__tests__/shared-utils.test.js +69 -0
  43. package/lib/__tests__/skill-flags.test.js +48 -0
  44. package/lib/__tests__/ui-detector.test.ts +6 -2
  45. package/lib/__tests__/uninstall.test.js +20 -0
  46. package/lib/__tests__/update-hooks.test.js +12 -0
  47. package/lib/__tests__/update-skill.test.js +7 -4
  48. package/lib/detect-deps.js +3 -20
  49. package/lib/doctor-tui.js +22 -5
  50. package/lib/doctor.js +1178 -31
  51. package/lib/init.js +184 -33
  52. package/lib/install-cmd.js +7 -4
  53. package/lib/install-skill.js +38 -95
  54. package/lib/language-tools.js +83 -4
  55. package/lib/mattpocock-license-verdict.json +1 -1
  56. package/lib/shared-paths.js +5 -2
  57. package/lib/shared-utils.js +47 -3
  58. package/lib/uninstall.js +12 -0
  59. package/lib/update-hooks.js +4 -4
  60. package/lib/update-skill.js +6 -7
  61. package/mock-policy/AGENTS.md +4 -4
  62. package/mutation/AGENTS.md +4 -4
  63. package/mutation/__tests__/gate-m.test.ts +66 -14
  64. package/mutation/__tests__/mutmut-runner.test.ts +91 -42
  65. package/mutation/__tests__/test-filter-parity.test.ts +37 -0
  66. package/mutation/gate-m.ts +21 -8
  67. package/package.json +1 -1
  68. package/plugins/claude-code/.claude-plugin/plugin.json +2 -2
  69. package/plugins/claude-code/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
  70. package/plugins/claude-code/skills/delphi-review/AGENTS.md +4 -4
  71. package/plugins/claude-code/skills/delphi-review/SKILL.md +98 -14
  72. package/plugins/claude-code/skills/delphi-review/references/code-walkthrough.md +36 -3
  73. package/plugins/claude-code/skills/delphi-review/references/requirements.md +6 -6
  74. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +4 -4
  75. package/plugins/claude-code/skills/sprint-flow/references/phase-2-design.md +3 -3
  76. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +4 -4
  77. package/plugins/dsh/README.md +169 -4
  78. package/plugins/dsh/dsh-overlay.patch.yml +67 -0
  79. package/plugins/dsh/package.json +1 -1
  80. package/plugins/dsh/scripts/prepack.cjs +24 -19
  81. package/plugins/dsh/setup-dsh.ps1 +160 -0
  82. package/plugins/dsh/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
  83. package/plugins/dsh/skills/delphi-review/AGENTS.md +4 -4
  84. package/plugins/dsh/skills/delphi-review/SKILL.md +98 -14
  85. package/plugins/dsh/skills/delphi-review/references/code-walkthrough.md +36 -3
  86. package/plugins/dsh/skills/delphi-review/references/requirements.md +6 -6
  87. package/plugins/dsh/skills/sprint-flow/AGENTS.md +4 -4
  88. package/plugins/dsh/skills/sprint-flow/references/phase-2-design.md +3 -3
  89. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +4 -4
  90. package/plugins/dsh/src/command.test.ts +66 -2
  91. package/plugins/dsh/src/command.ts +36 -14
  92. package/plugins/dsh/src/contract.test.ts +31 -1
  93. package/plugins/dsh/src/delphi-config.test.ts +190 -0
  94. package/plugins/dsh/src/delphi-config.ts +223 -0
  95. package/plugins/dsh/src/delphi-run.test.ts +181 -0
  96. package/plugins/dsh/src/delphi-run.ts +129 -0
  97. package/plugins/dsh/src/delphi.test.ts +222 -0
  98. package/plugins/dsh/src/delphi.ts +247 -0
  99. package/plugins/dsh/src/dialect.test.ts +59 -0
  100. package/plugins/dsh/src/dialect.ts +40 -0
  101. package/plugins/dsh/src/index.ts +70 -4
  102. package/plugins/dsh/src/skills.test.ts +26 -19
  103. package/plugins/opencode/package.json +1 -1
  104. package/plugins/opencode/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
  105. package/plugins/opencode/skills/delphi-review/AGENTS.md +4 -4
  106. package/plugins/opencode/skills/delphi-review/SKILL.md +98 -14
  107. package/plugins/opencode/skills/delphi-review/references/code-walkthrough.md +36 -3
  108. package/plugins/opencode/skills/delphi-review/references/requirements.md +6 -6
  109. package/plugins/opencode/skills/sprint-flow/AGENTS.md +4 -4
  110. package/plugins/opencode/skills/sprint-flow/references/phase-2-design.md +3 -3
  111. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +4 -4
  112. package/plugins/qoder/agents/delphi-architecture.md +1 -1
  113. package/plugins/qoder/agents/delphi-feasibility.md +1 -1
  114. package/plugins/qoder/agents/delphi-technical.md +1 -1
  115. package/plugins/qoder/plugin.json +2 -2
  116. package/plugins/qoder/skills/admin-template-guidelines/SKILL.md +6 -797
  117. package/plugins/qoder/skills/admin-template-guidelines/references/rule-1.md +91 -0
  118. package/plugins/qoder/skills/admin-template-guidelines/references/rule-2.md +155 -0
  119. package/plugins/qoder/skills/admin-template-guidelines/references/rule-3.md +70 -0
  120. package/plugins/qoder/skills/admin-template-guidelines/references/rule-4.md +154 -0
  121. package/plugins/qoder/skills/admin-template-guidelines/references/rule-5.md +126 -0
  122. package/plugins/qoder/skills/admin-template-guidelines/references/rule-6.md +180 -0
  123. package/plugins/qoder/skills/batch-grill-me/SKILL.md +15 -0
  124. package/plugins/qoder/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
  125. package/plugins/qoder/skills/clipboard-vision/describe-clipboard.ps1 +115 -0
  126. package/plugins/qoder/skills/clipboard-vision/describe-clipboard.sh +161 -0
  127. package/plugins/qoder/skills/delphi-review/.delphi-config.json.example +61 -28
  128. package/plugins/qoder/skills/delphi-review/AGENTS.md +4 -4
  129. package/plugins/qoder/skills/delphi-review/INSTALL.md +97 -98
  130. package/plugins/qoder/skills/delphi-review/SKILL.md +170 -63
  131. package/plugins/qoder/skills/delphi-review/hooks.md +19 -0
  132. package/plugins/qoder/skills/delphi-review/opencode.json.delphi.example +6 -10
  133. package/plugins/qoder/skills/delphi-review/references/code-walkthrough.md +36 -3
  134. package/plugins/qoder/skills/delphi-review/references/orchestrator-dispatch.md +64 -0
  135. package/plugins/qoder/skills/delphi-review/references/requirements.md +402 -0
  136. package/plugins/qoder/skills/delphi-review/references/round-templates.md +134 -0
  137. package/plugins/qoder/skills/delphi-review/scripts/verify-consensus.sh +43 -0
  138. package/plugins/qoder/skills/delphi-review/tools.md +29 -0
  139. package/plugins/qoder/skills/domain-modeling/ADR-FORMAT.md +64 -0
  140. package/plugins/qoder/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
  141. package/plugins/qoder/skills/domain-modeling/SKILL.md +74 -0
  142. package/plugins/qoder/skills/grill-with-docs/SKILL.md +7 -0
  143. package/plugins/qoder/skills/grilling/SKILL.md +12 -0
  144. package/plugins/qoder/skills/ralph-loop/SKILL.md +87 -428
  145. package/plugins/qoder/skills/ralph-loop/references/components.md +113 -0
  146. package/plugins/qoder/skills/ralph-loop/references/workflow-details.md +99 -0
  147. package/plugins/qoder/skills/sprint-flow/AGENTS.md +5 -5
  148. package/plugins/qoder/skills/sprint-flow/SKILL.md +255 -454
  149. package/plugins/qoder/skills/sprint-flow/__tests__/sprint-flow.test.cjs +457 -0
  150. package/plugins/qoder/skills/sprint-flow/hooks.md +18 -0
  151. package/plugins/qoder/skills/sprint-flow/references/components/memory.md +1 -1
  152. package/plugins/qoder/skills/sprint-flow/references/components/middleware.md +1 -1
  153. package/plugins/qoder/skills/sprint-flow/references/components/skill-invocations.md +21 -20
  154. package/plugins/qoder/skills/sprint-flow/references/components/tool-descriptions.md +22 -23
  155. package/plugins/qoder/skills/sprint-flow/references/force-levels.md +0 -1
  156. package/plugins/qoder/skills/sprint-flow/references/orchestration-rules.md +34 -27
  157. package/plugins/qoder/skills/sprint-flow/references/phase-1-prep.md +1 -1
  158. package/plugins/qoder/skills/sprint-flow/references/phase-2-design.md +160 -123
  159. package/plugins/qoder/skills/sprint-flow/references/phase-3-build.md +203 -2
  160. package/plugins/qoder/skills/sprint-flow/references/phase-4-verify.md +107 -34
  161. package/plugins/qoder/skills/sprint-flow/references/phase-5-ship.md +194 -12
  162. package/plugins/qoder/skills/sprint-flow/references/phase-6-close.md +139 -7
  163. package/plugins/qoder/skills/sprint-flow/references/phase-overview.md +486 -0
  164. package/plugins/qoder/skills/sprint-flow/scripts/verify-sprint-state.sh +38 -0
  165. package/plugins/qoder/skills/sprint-flow/tools.md +29 -0
  166. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +4 -4
  167. package/plugins/qoder/skills/test-specification-alignment/SKILL.md +104 -601
  168. package/plugins/qoder/skills/to-issues/SKILL.md +1 -1
  169. package/principles/AGENTS.md +9 -8
  170. package/principles/__tests__/adapter-metrics.test.ts +292 -0
  171. package/principles/__tests__/analyzer.test.ts +29 -6
  172. package/principles/__tests__/baseline-prune-timeout.test.ts +71 -0
  173. package/principles/__tests__/baseline-prune.test.ts +338 -0
  174. package/principles/__tests__/boy-scout-init-baseline.test.ts +167 -0
  175. package/principles/__tests__/boy-scout-integration.test.ts +97 -6
  176. package/principles/__tests__/boy-scout.test.ts +253 -7
  177. package/principles/__tests__/config-enforcement.test.ts +469 -0
  178. package/principles/__tests__/config-git-timeout.test.ts +66 -0
  179. package/principles/__tests__/direct-execution.test.ts +187 -0
  180. package/principles/__tests__/index.test.ts +16 -3
  181. package/principles/__tests__/warnings-baseline-budgets.test.ts +74 -0
  182. package/principles/adapters/typescript.ts +613 -13
  183. package/principles/analyzer.ts +22 -0
  184. package/principles/baseline-prune.ts +217 -0
  185. package/principles/boy-scout.ts +481 -51
  186. package/principles/config.ts +362 -54
  187. package/principles/direct-execution.ts +60 -0
  188. package/principles/index.ts +40 -17
  189. package/principles/rules/__tests__/clean-code/many-exports.test.ts +16 -10
  190. package/principles/rules/clean-code/code-duplication.ts +8 -8
  191. package/principles/rules/clean-code/deep-nesting.ts +8 -8
  192. package/principles/rules/clean-code/god-class.ts +8 -8
  193. package/principles/rules/clean-code/large-file.ts +15 -11
  194. package/principles/rules/clean-code/long-function.ts +8 -8
  195. package/principles/rules/clean-code/magic-numbers.ts +10 -7
  196. package/principles/rules/clean-code/many-exports.ts +7 -7
  197. package/principles/rules/clean-code/missing-error-handling.ts +5 -5
  198. package/principles/rules/clean-code/too-many-params.ts +8 -8
  199. package/principles/rules/clean-code/unused-imports.ts +5 -5
  200. package/principles/rules/solid/dip.ts +18 -8
  201. package/principles/rules/solid/isp.ts +9 -8
  202. package/principles/rules/solid/lsp.ts +6 -6
  203. package/principles/rules/solid/ocp.ts +6 -5
  204. package/principles/rules/solid/srp.ts +7 -8
  205. package/principles/types.ts +15 -0
  206. package/scripts/__tests__/sync-package-content.test.js +28 -0
  207. package/scripts/delphi-external-review.cjs +193 -10
  208. package/scripts/sync-package-content.js +7 -4
  209. package/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
  210. package/skills/delphi-review/AGENTS.md +4 -4
  211. package/skills/delphi-review/SKILL.md +98 -14
  212. package/skills/delphi-review/references/code-walkthrough.md +36 -3
  213. package/skills/delphi-review/references/requirements.md +6 -6
  214. package/skills/sprint-flow/AGENTS.md +4 -4
  215. package/skills/sprint-flow/references/phase-2-design.md +3 -3
  216. package/skills/test-specification-alignment/AGENTS.md +4 -4
  217. package/lib/__tests__/download-skill.test.js +0 -281
  218. package/lib/download-skill.js +0 -96
  219. package/plugins/qoder/skills/ralph-loop/references/components/memory.md +0 -55
  220. package/plugins/qoder/skills/ralph-loop/references/components/middleware.md +0 -54
  221. package/plugins/qoder/skills/ralph-loop/references/components/skill-invocations.md +0 -39
  222. package/plugins/qoder/skills/ralph-loop/references/components/system-prompt.md +0 -24
  223. package/plugins/qoder/skills/ralph-loop/references/components/tool-descriptions.md +0 -32
@@ -0,0 +1,209 @@
1
+ /**
2
+ * @test REQ-495 Round 3: only the module the hook resolves may be reported as executed
3
+ * @intent Round 2 的两位专家指向同一处缺陷:F5 把项目 tier 无条件标成"executed from the
4
+ * project tier",而 pre-commit 的解析顺序是 ADAPTER_DIR 优先取全局目录
5
+ * (githooks/pre-commit:34-35),语言适配器在项目 tier 只经 nested 命中
6
+ * (:67-69),平铺副本在全局安装存在时根本不执行。于是 doctor 既会把不执行的
7
+ * 文件判成 FAIL(R1-2 那一类"断言了假方向"的 FAIL),又因为修复面只走全局目录
8
+ * 而让该 FAIL 永远清不掉(永久 exit 1)。本文件把"哪个路径被执行"收敛到
9
+ * resolveExecutedModuleDirs()+executedModulePath() 这一个模型上:执行的才计入
10
+ * issues 并可被 sync 清除,不执行的作为 residue 报告并给出绝对路径与手工处置,
11
+ * 不计入 issues。AC-495-17 补上旧 tier 列表完全忽略的第 4 层(SCRIPT_DIR),
12
+ * AC-495-19 钉住"读不到就拒绝覆盖"的守卫缝隙。
13
+ * @covers AC-495-14, AC-495-15, AC-495-16, AC-495-17, AC-495-18, AC-495-19
14
+ */
15
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
16
+ import { tmpdir } from 'node:os';
17
+ import { join } from 'node:path';
18
+ import { describe, expect, it } from 'vitest';
19
+
20
+ const REPO_ADAPTER = '#!/bin/bash\nfixed_count_guard\n';
21
+ const STALE_FLAT = '#!/bin/bash\nstale_echo_zero\n';
22
+ // One-way stale: every line it holds is also in the repo, so the copy guard lets
23
+ // the sync through. The divergence cases keep STALE_FLAT and must refuse.
24
+ const SUBSET_FLAT = '#!/bin/bash\n';
25
+
26
+ function write(file, text) {
27
+ mkdirSync(join(file, '..'), { recursive: true });
28
+ writeFileSync(file, text);
29
+ }
30
+
31
+ function tempRoot(prefix) {
32
+ return mkdtempSync(join(tmpdir(), `xp-gate-${prefix}-`));
33
+ }
34
+
35
+ /**
36
+ * A tree carrying all the tiers the hook can resolve into, so precedence between
37
+ * tiers is part of the fixture instead of an assumption.
38
+ */
39
+ function tree(overrides = {}) {
40
+ const root = tempRoot('tier');
41
+ const repoDir = join(root, 'repo');
42
+ const repoHooks = join(repoDir, 'githooks');
43
+ const globalAdapters = join(root, 'global', 'adapters');
44
+ const scriptDir = join(root, 'global', 'hooks');
45
+ for (const dir of [join(repoHooks, 'adapters'), globalAdapters, join(globalAdapters, 'adapters'), scriptDir]) {
46
+ mkdirSync(dir, { recursive: true });
47
+ }
48
+
49
+ // Canonical repo source of truth: nested language adapter, flat gate module.
50
+ write(join(repoHooks, 'adapter-common.sh'), '#!/bin/bash\nshared\n');
51
+ write(join(repoHooks, 'pre-commit'), '#!/bin/bash\necho repo\n');
52
+ write(join(repoHooks, 'adapters', 'iac.sh'), REPO_ADAPTER);
53
+ write(join(repoHooks, 'gate-4.sh'), '#!/bin/bash\ngate_a\ngate_b\n');
54
+
55
+ // Installed global tier: the installer's nested copy is current...
56
+ write(join(globalAdapters, 'adapter-common.sh'), '#!/bin/bash\nshared\n');
57
+ write(join(globalAdapters, 'adapters', 'iac.sh'), REPO_ADAPTER);
58
+ if (overrides.globalFlatIac) write(join(globalAdapters, 'iac.sh'), overrides.globalFlatIac);
59
+ if (overrides.globalGate4) write(join(globalAdapters, 'gate-4.sh'), overrides.globalGate4);
60
+
61
+ // Project-tier flat copy (gitignored installer residue): stale by default.
62
+ if (overrides.projectFlatIac !== null) {
63
+ write(join(repoHooks, 'iac.sh'), overrides.projectFlatIac || STALE_FLAT);
64
+ }
65
+
66
+ return {
67
+ root,
68
+ repoDir,
69
+ repoHooks,
70
+ globalAdapters,
71
+ scriptDir,
72
+ projectFlatIac: join(repoHooks, 'iac.sh'),
73
+ };
74
+ }
75
+
76
+ function ctxFor(t, extra = {}) {
77
+ return {
78
+ repoRoot: t.repoDir,
79
+ repoHooks: t.repoHooks,
80
+ globalAdapterDir: t.globalAdapters,
81
+ projectGithooks: t.repoHooks,
82
+ scriptDir: t.scriptDir,
83
+ ...extra,
84
+ };
85
+ }
86
+
87
+ function drift(t, extra = {}) {
88
+ const { diagnoseModuleDrift } = require('../doctor');
89
+ return diagnoseModuleDrift(ctxFor(t, extra));
90
+ }
91
+
92
+ function checkOf(result, name) {
93
+ return result.checks.find((c) => c.name === name);
94
+ }
95
+
96
+ describe('doctor executed surface (#495 Round 3)', () => {
97
+ it('AC-495-14: the executed copy is the flat global one the hook resolves first, so its staleness is a FAIL', () => {
98
+ const t = tree({ globalFlatIac: STALE_FLAT });
99
+ const result = drift(t);
100
+ const check = checkOf(result, 'Module drift: iac.sh');
101
+ expect(check, 'a stale copy the hook executes must FAIL').toBeDefined();
102
+ expect(check.status).toBe('FAIL');
103
+ expect(result.issues).toBe(1);
104
+ });
105
+
106
+ it('AC-495-15: with a global install present, a flat copy in the project githooks/ does NOT execute and is reported as residue, not as drift', () => {
107
+ const t = tree({ projectFlatIac: undefined });
108
+ const result = drift(t);
109
+ const residue = result.checks.find((c) => /Non-executed copy: iac\.sh/.test(c.name));
110
+ expect(residue, 'the project-tier flat copy must still be surfaced').toBeDefined();
111
+ // The hook consults $PROJECT_GITHOOKS/adapters/<lang>.sh only (pre-commit:67-69),
112
+ // never a flat project copy, so claiming it "executes" would repeat the R1-2 defect.
113
+ expect(residue.status).not.toBe('FAIL');
114
+ expect(result.issues, 'a copy nothing executes cannot be an unrepairable FAIL').toBe(0);
115
+ // Actionable: the offending absolute path, and the manual remedy (#500 is still open).
116
+ expect(residue.detail).toContain(t.projectFlatIac);
117
+ expect(residue.detail, 'residue must name itself as not executed').toMatch(/not executed|does not execute/i);
118
+ expect(residue.detail, 'residue must say how to get rid of it').toMatch(/gitignore|residue|remove/i);
119
+ });
120
+
121
+ it('AC-495-16: the same project-tier flat copy IS executed once ADAPTER_DIR resolves to the project tier, and then it FAILs', () => {
122
+ const t = tree({ projectFlatIac: undefined });
123
+ // No global install -> pre-commit:36-37 resolves ADAPTER_DIR to the repo.
124
+ const result = drift(t, { globalAdapterDir: join(t.root, 'no-global-install') });
125
+ const check = result.checks.find((c) => /iac\.sh$/.test(c.name));
126
+ expect(check, 'the executed project-tier copy must be diagnosed').toBeDefined();
127
+ expect(check.status).toBe('FAIL');
128
+ expect(result.issues).toBe(1);
129
+ });
130
+
131
+ it('AC-495-17: gate modules follow GATE_DIR down to the hook\'s last tier ($SCRIPT_DIR)', () => {
132
+ // The installed hooks dir is the only tier carrying adapter-common.sh and
133
+ // gate-3.sh, so ADAPTER_DIR == GATE_DIR == $SCRIPT_DIR (pre-commit:39, 80-81)
134
+ // and a stale gate module lives there -- invisible to the old two-tier walk.
135
+ const root = tempRoot('scriptdir');
136
+ const repoDir = join(root, 'repo');
137
+ const repoHooks = join(repoDir, 'githooks');
138
+ const scriptDir = join(root, 'installed', 'hooks');
139
+ mkdirSync(join(repoHooks, 'adapters'), { recursive: true });
140
+ write(join(repoHooks, 'pre-commit'), '#!/bin/bash\necho repo\n');
141
+ write(join(repoHooks, 'gate-4.sh'), '#!/bin/bash\ngate_a\ngate_b\n');
142
+ // A repo without adapter-common.sh/gate-3.sh installed alongside: the hook's
143
+ // fallback chain ends at SCRIPT_DIR.
144
+ write(join(scriptDir, 'adapter-common.sh'), '#!/bin/bash\nshared\n');
145
+ write(join(scriptDir, 'gate-3.sh'), '#!/bin/bash\ngate3\n');
146
+ // One-way stale, so this test judges reachability of the repair, not the
147
+ // divergence guard (which AC-495-05 already pins).
148
+ write(join(scriptDir, 'gate-4.sh'), '#!/bin/bash\ngate_a\n');
149
+
150
+ const { diagnoseModuleDrift, syncModulesFromRepo } = require('../doctor');
151
+ const ctx = {
152
+ repoRoot: repoDir,
153
+ repoHooks,
154
+ globalAdapterDir: join(root, 'nothing', 'adapters'),
155
+ projectGithooks: repoHooks,
156
+ scriptDir,
157
+ };
158
+ const result = diagnoseModuleDrift(ctx);
159
+ const check = result.checks.find((c) => /gate-4\.sh$/.test(c.name));
160
+ expect(check, 'an executed copy under the installed hooks dir is invisible to doctor').toBeDefined();
161
+ expect(check.status).toBe('FAIL');
162
+ expect(result.issues).toBe(1);
163
+
164
+ // And the repair reaches the same tier, so the FAIL is not permanent.
165
+ const synced = syncModulesFromRepo(ctx);
166
+ expect(synced.synced).toContain('gate-4.sh');
167
+ expect(readFileSync(join(scriptDir, 'gate-4.sh'), 'utf8')).toContain('gate_b');
168
+ expect(diagnoseModuleDrift(ctx).issues).toBe(0);
169
+ });
170
+
171
+ it('AC-495-18: every FAIL the resolution can produce, syncModulesFromRepo can clear', () => {
172
+ const { syncModulesFromRepo, diagnoseModuleDrift } = require('../doctor');
173
+ const t = tree({ globalFlatIac: SUBSET_FLAT, projectFlatIac: null });
174
+ const ctx = ctxFor(t);
175
+ expect(diagnoseModuleDrift(ctx).issues).toBe(1);
176
+
177
+ const synced = syncModulesFromRepo(ctx);
178
+ expect(synced.synced).toContain('iac.sh');
179
+ expect(readFileSync(join(t.globalAdapters, 'iac.sh'), 'utf8')).toContain('fixed_count_guard');
180
+ expect(diagnoseModuleDrift(ctx).issues).toBe(0);
181
+ });
182
+
183
+ it('AC-495-18b: the sync never deletes or rewrites non-executed residue on its own initiative', () => {
184
+ const { syncModulesFromRepo } = require('../doctor');
185
+ const t = tree({ globalFlatIac: SUBSET_FLAT });
186
+ const before = readFileSync(t.projectFlatIac, 'utf8');
187
+ const synced = syncModulesFromRepo(ctxFor(t));
188
+ expect(synced.synced).toContain('iac.sh');
189
+ expect(readFileSync(t.projectFlatIac, 'utf8')).toBe(before);
190
+ });
191
+
192
+ it('AC-495-19: an unreadable destination is refused, not overwritten', () => {
193
+ const { copyGuardDecision } = require('../doctor');
194
+ const root = tempRoot('guard');
195
+ const source = join(root, 'source.sh');
196
+ write(source, '#!/bin/bash\nrepo_line\n');
197
+ // A directory where a file is expected: readFileSync throws, so the delta is unknown.
198
+ const targetIsDir = join(root, 'target.sh');
199
+ mkdirSync(targetIsDir, { recursive: true });
200
+
201
+ expect(copyGuardDecision(source, targetIsDir, false).refuse, 'unknown content must never be overwritten silently').toBe(true);
202
+ // Content known to be a subset still syncs; an explicit force still proceeds.
203
+ const targetOk = join(root, 'stale.sh');
204
+ write(targetOk, '#!/bin/bash\n');
205
+ expect(copyGuardDecision(source, targetOk, false).refuse).toBe(false);
206
+ expect(copyGuardDecision(source, targetIsDir, true).forced).toBe(true);
207
+ expect(existsSync(targetIsDir)).toBe(true);
208
+ });
209
+ });
@@ -0,0 +1,329 @@
1
+ /**
2
+ * @test REQ-451 xp-gate doctor detects stale global hook copies
3
+ * @intent Detect when the hooks actually executed (core.hooksPath) drift from the
4
+ * repository's source-of-truth githooks/, and report the direction of drift
5
+ * @covers AC-451-01 (drift is reported with a direction)
6
+ * @covers AC-451-02 (matching copies report no drift)
7
+ * @covers AC-451-03 (non-repo / no-githooks contexts stay unchanged)
8
+ *
9
+ * Issue #451: editing githooks/pre-commit in the repo silently has no effect,
10
+ * because core.hooksPath points at an installed copy under ~/.config/xp-gate/hooks.
11
+ * The source-of-truth hooks are never executed on a developer machine, and nothing
12
+ * reports how far the installed copy has drifted or in which direction.
13
+ */
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+ const os = require('os');
17
+
18
+ const DESCRIBE_ID = String(Math.random().toString(36).slice(2, 8));
19
+
20
+ describe('doctor hook source-of-truth drift (#451)', () => {
21
+ let tmpHome;
22
+ let tmpProject;
23
+ let originalHome;
24
+ let originalUserProfile;
25
+ let originalXpGateCacheDir;
26
+
27
+ beforeAll(() => {
28
+ originalHome = process.env.HOME;
29
+ originalUserProfile = process.env.USERPROFILE;
30
+ originalXpGateCacheDir = process.env.XP_GATE_CACHE_DIR;
31
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), `xp-gate-451-home-${DESCRIBE_ID}-`));
32
+ tmpProject = fs.mkdtempSync(path.join(os.tmpdir(), `xp-gate-451-proj-${DESCRIBE_ID}-`));
33
+ process.env.HOME = tmpHome;
34
+ process.env.USERPROFILE = tmpHome;
35
+ });
36
+
37
+ afterAll(() => {
38
+ // Every path this suite touches is passed in explicitly, so there is no cwd
39
+ // to restore (process.chdir is unavailable inside vitest workers anyway).
40
+ process.env.HOME = originalHome;
41
+ process.env.USERPROFILE = originalUserProfile;
42
+ process.env.XP_GATE_CACHE_DIR = originalXpGateCacheDir;
43
+ fs.rmSync(tmpHome, { recursive: true, force: true });
44
+ fs.rmSync(tmpProject, { recursive: true, force: true });
45
+ });
46
+
47
+ /**
48
+ * Build a repo-like tree with a canonical githooks/ and an installed copy.
49
+ * The installed copy lives outside the repo, mirroring the real layout.
50
+ */
51
+ function scaffold({ repoPreCommit, installedPreCommit }) {
52
+ const repoRoot = path.join(tmpProject, 'repo');
53
+ const repoHooks = path.join(repoRoot, 'githooks');
54
+ const installedHooks = path.join(tmpHome, '.config', 'xp-gate', 'hooks');
55
+
56
+ fs.rmSync(repoRoot, { recursive: true, force: true });
57
+ fs.rmSync(installedHooks, { recursive: true, force: true });
58
+ fs.mkdirSync(repoHooks, { recursive: true });
59
+ fs.mkdirSync(path.join(repoRoot, '.git'), { recursive: true });
60
+ fs.mkdirSync(installedHooks, { recursive: true });
61
+
62
+ fs.writeFileSync(path.join(repoHooks, 'pre-commit'), repoPreCommit);
63
+ fs.writeFileSync(
64
+ path.join(installedHooks, 'pre-commit'),
65
+ installedPreCommit === undefined ? repoPreCommit : installedPreCommit
66
+ );
67
+
68
+ return { repoRoot, repoHooks, installedHooks };
69
+ }
70
+
71
+ /** require the module fresh so it picks up the patched HOME. */
72
+ function loadDoctor() {
73
+ for (const key of Object.keys(require.cache)) {
74
+ if (key.includes(`${path.sep}lib${path.sep}doctor.js`)) delete require.cache[key];
75
+ }
76
+ return require('../doctor');
77
+ }
78
+
79
+ // -------------------------------------------------------------------------
80
+ // AC-451-01: drift is detected and its direction reported
81
+ // -------------------------------------------------------------------------
82
+
83
+ it('AC-451-01: reports the installed hook is NEWER than the repo copy', () => {
84
+ const repoBody = '#!/bin/bash\n# xp-gate pre-commit\nv1\n';
85
+ const installedBody = '#!/bin/bash\n# xp-gate pre-commit\nv2-newer\n';
86
+ const { repoRoot, repoHooks, installedHooks } = scaffold({
87
+ repoPreCommit: repoBody,
88
+ installedPreCommit: installedBody,
89
+ });
90
+
91
+ const { diagnoseHookDrift } = loadDoctor();
92
+ const result = diagnoseHookDrift({ repoRoot, repoHooks, installedHooks });
93
+
94
+ expect(result.issues).toBeGreaterThan(0);
95
+ const check = result.checks.find((c) => c.name.includes('pre-commit'));
96
+ expect(check).toBeDefined();
97
+ expect(check.status).toBe('FAIL');
98
+ // The direction must be explicit -- "differs" alone does not tell a
99
+ // developer whether their local gate is stricter or looser than the repo.
100
+ expect(check.detail).toMatch(/installed.*newer|newer.*installed/i);
101
+ });
102
+
103
+ it('AC-451-01: reports the installed hook is OLDER than the repo copy', () => {
104
+ const repoBody = '#!/bin/bash\n# xp-gate pre-commit\nrepo-edited\n';
105
+ const installedBody = '#!/bin/bash\n# xp-gate pre-commit\nstale\n';
106
+ const { repoRoot, repoHooks, installedHooks } = scaffold({
107
+ repoPreCommit: repoBody,
108
+ installedPreCommit: installedBody,
109
+ });
110
+
111
+ // mtime granularity is coarse enough that two writes in the same test tick
112
+ // can land on the same millisecond, which would make the comparison
113
+ // ambiguous. Pin the times explicitly so the direction is unambiguous.
114
+ const now = Date.now() / 1000;
115
+ fs.utimesSync(path.join(installedHooks, 'pre-commit'), now - 3600, now - 3600);
116
+ fs.utimesSync(path.join(repoHooks, 'pre-commit'), now, now);
117
+
118
+ const { diagnoseHookDrift } = loadDoctor();
119
+ const result = diagnoseHookDrift({ repoRoot, repoHooks, installedHooks });
120
+
121
+ expect(result.issues).toBeGreaterThan(0);
122
+ const check = result.checks.find((c) => c.name.includes('pre-commit'));
123
+ expect(check.status).toBe('FAIL');
124
+ expect(check.detail).toMatch(/installed.*older|older.*installed|repo.*newer/i);
125
+ });
126
+
127
+ // -------------------------------------------------------------------------
128
+ // AC-451-02: identical copies are silent
129
+ // -------------------------------------------------------------------------
130
+
131
+ it('AC-451-02: no issue when the installed copy matches the repo', () => {
132
+ const body = '#!/bin/bash\n# xp-gate pre-commit\nsame\n';
133
+ const { repoRoot, repoHooks, installedHooks } = scaffold({ repoPreCommit: body });
134
+
135
+ const { diagnoseHookDrift } = loadDoctor();
136
+ const result = diagnoseHookDrift({ repoRoot, repoHooks, installedHooks });
137
+
138
+ expect(result.issues).toBe(0);
139
+ const check = result.checks.find((c) => c.name.includes('pre-commit'));
140
+ expect(check.status).toBe('PASS');
141
+ });
142
+
143
+ // -------------------------------------------------------------------------
144
+ // AC-451-03: no drift check outside a repo with githooks/
145
+ // -------------------------------------------------------------------------
146
+
147
+ it('AC-451-03: reports nothing when the project has no githooks/ directory', () => {
148
+ const bare = fs.mkdtempSync(path.join(os.tmpdir(), 'xp-gate-451-bare-'));
149
+ fs.mkdirSync(path.join(bare, '.git'), { recursive: true });
150
+
151
+ const { diagnoseHookDrift } = loadDoctor();
152
+ const result = diagnoseHookDrift({
153
+ repoRoot: bare,
154
+ repoHooks: path.join(bare, 'githooks'),
155
+ installedHooks: path.join(tmpHome, '.config', 'xp-gate', 'hooks'),
156
+ });
157
+
158
+ expect(result.issues).toBe(0);
159
+ expect(result.checks).toHaveLength(0);
160
+ fs.rmSync(bare, { recursive: true, force: true });
161
+ });
162
+
163
+ it('AC-451-03: reports nothing when no global copy is installed', () => {
164
+ const { repoRoot, repoHooks } = scaffold({
165
+ repoPreCommit: '#!/bin/bash\n# xp-gate pre-commit\nx\n',
166
+ });
167
+
168
+ const { diagnoseHookDrift } = loadDoctor();
169
+ const result = diagnoseHookDrift({
170
+ repoRoot,
171
+ repoHooks,
172
+ installedHooks: path.join(tmpHome, 'nowhere', 'hooks'),
173
+ });
174
+
175
+ expect(result.issues).toBe(0);
176
+ expect(result.checks).toHaveLength(0);
177
+ });
178
+
179
+ it('AC-451-03: ignores a githooks/ that holds only adapters (consumer layout)', () => {
180
+ // `xp-gate init` gives consumer projects githooks/adapters/ but no hook
181
+ // files. That is not a hook source of truth, so it must not be compared --
182
+ // otherwise every consumer project with a global install reports drift.
183
+ const { repoRoot, repoHooks, installedHooks } = scaffold({
184
+ repoPreCommit: '#!/bin/bash\n# xp-gate pre-commit\nx\n',
185
+ });
186
+ fs.rmSync(path.join(repoHooks, 'pre-commit'));
187
+ fs.mkdirSync(path.join(repoHooks, 'adapters'), { recursive: true });
188
+ fs.writeFileSync(path.join(repoHooks, 'adapters', 'typescript.sh'), '#!/bin/bash\n');
189
+
190
+ const { diagnoseHookDrift } = loadDoctor();
191
+ const result = diagnoseHookDrift({ repoRoot, repoHooks, installedHooks });
192
+
193
+ expect(result.issues).toBe(0);
194
+ expect(result.checks).toHaveLength(0);
195
+ });
196
+
197
+ // -------------------------------------------------------------------------
198
+ // AC-451-04: only hooks that exist in BOTH places are compared
199
+ // -------------------------------------------------------------------------
200
+
201
+ it('compares pre-commit and pre-push, ignoring hooks absent from either side', () => {
202
+ const { repoRoot, repoHooks, installedHooks } = scaffold({
203
+ repoPreCommit: '#!/bin/bash\n# xp-gate pre-commit\nsame\n',
204
+ });
205
+ fs.writeFileSync(path.join(repoHooks, 'pre-push'), '#!/bin/bash\n# xp-gate pre-push\nrepo\n');
206
+ fs.writeFileSync(
207
+ path.join(installedHooks, 'pre-push'),
208
+ '#!/bin/bash\n# xp-gate pre-push\ninstalled\n'
209
+ );
210
+ // post-merge exists only in the installed copy -> not a drift (the repo may
211
+ // legitimately not carry it), and must not crash the comparison.
212
+ fs.writeFileSync(path.join(installedHooks, 'post-merge'), '#!/bin/bash\n# xp-gate post-merge\n');
213
+
214
+ const { diagnoseHookDrift } = loadDoctor();
215
+ const result = diagnoseHookDrift({ repoRoot, repoHooks, installedHooks });
216
+
217
+ const names = result.checks.map((c) => c.name);
218
+ expect(names.some((n) => n.includes('pre-push'))).toBe(true);
219
+ expect(names.some((n) => n.includes('post-merge'))).toBe(false);
220
+ expect(result.issues).toBe(1);
221
+ });
222
+
223
+ // -------------------------------------------------------------------------
224
+ // AC-451-02 (REQ-2): one-command sync makes the repo copy effective
225
+ // -------------------------------------------------------------------------
226
+
227
+ it('REQ-2: syncGlobalHooksFromRepo copies repo hooks over the installed copy', () => {
228
+ const repoBody = '#!/bin/bash\n# xp-gate pre-commit\nrepo-authoritative\n';
229
+ const { repoRoot, repoHooks, installedHooks } = scaffold({
230
+ repoPreCommit: repoBody,
231
+ installedPreCommit: '#!/bin/bash\n# xp-gate pre-commit\nstale\n',
232
+ });
233
+
234
+ const { syncGlobalHooksFromRepo, diagnoseHookDrift } = loadDoctor();
235
+ const syncResult = syncGlobalHooksFromRepo({ repoRoot, repoHooks, installedHooks });
236
+
237
+ expect(syncResult.errors).toEqual([]);
238
+ expect(syncResult.synced).toContain('pre-commit');
239
+ expect(fs.readFileSync(path.join(installedHooks, 'pre-commit'), 'utf8')).toBe(repoBody);
240
+
241
+ // The drift must be gone afterwards -- that is the whole point of the sync.
242
+ const after = diagnoseHookDrift({ repoRoot, repoHooks, installedHooks });
243
+ expect(after.issues).toBe(0);
244
+ });
245
+
246
+ it('REQ-2: sync reports an error when there is no githooks/ to copy from', () => {
247
+ const bare = fs.mkdtempSync(path.join(os.tmpdir(), 'xp-gate-451-nogit-'));
248
+ const { syncGlobalHooksFromRepo } = loadDoctor();
249
+
250
+ const result = syncGlobalHooksFromRepo({
251
+ repoRoot: bare,
252
+ repoHooks: path.join(bare, 'githooks'),
253
+ installedHooks: path.join(tmpHome, '.config', 'xp-gate', 'hooks'),
254
+ });
255
+
256
+ expect(result.errors.length).toBeGreaterThan(0);
257
+ expect(result.errors[0]).toMatch(/no githooks/i);
258
+ fs.rmSync(bare, { recursive: true, force: true });
259
+ });
260
+
261
+ it('REQ-2: sync leaves already-identical hooks untouched', () => {
262
+ const body = '#!/bin/bash\n# xp-gate pre-commit\nsame\n';
263
+ const { repoRoot, repoHooks, installedHooks } = scaffold({ repoPreCommit: body });
264
+ const installedFile = path.join(installedHooks, 'pre-commit');
265
+ const before = fs.statSync(installedFile).mtimeMs;
266
+
267
+ const { syncGlobalHooksFromRepo } = loadDoctor();
268
+ const result = syncGlobalHooksFromRepo({ repoRoot, repoHooks, installedHooks });
269
+
270
+ expect(result.synced).toEqual([]);
271
+ expect(result.skipped).toContain('pre-commit');
272
+ expect(fs.statSync(installedFile).mtimeMs).toBe(before);
273
+ });
274
+
275
+ // -------------------------------------------------------------------------
276
+ // REQ-488: sync and drift must cover the githooks/lib/ shared libraries.
277
+ // After pre-commit was refactored to `source lib/*.sh`, a synced pre-commit
278
+ // died at the source line on the machine: sync copied only the three hook
279
+ // files, so the installed lib/ stayed stale or empty (#488).
280
+ // -------------------------------------------------------------------------
281
+
282
+ it('REQ-488: syncGlobalHooksFromRepo copies githooks/lib/ shared libraries', () => {
283
+ const { repoRoot, repoHooks, installedHooks } = scaffold({
284
+ repoPreCommit: '#!/bin/bash\n# xp-gate pre-commit\nsource lib/typecheck.sh\n',
285
+ });
286
+ fs.mkdirSync(path.join(repoHooks, 'lib'), { recursive: true });
287
+ fs.writeFileSync(path.join(repoHooks, 'lib', 'typecheck.sh'), 'typecheck-lib\n');
288
+ fs.writeFileSync(path.join(repoHooks, 'lib', 'validate-code-walkthrough.cjs'), 'walkthrough-lib\n');
289
+
290
+ const { syncGlobalHooksFromRepo } = loadDoctor();
291
+ const result = syncGlobalHooksFromRepo({ repoRoot, repoHooks, installedHooks });
292
+
293
+ expect(result.errors).toEqual([]);
294
+ expect(fs.readFileSync(path.join(installedHooks, 'lib', 'typecheck.sh'), 'utf8')).toBe('typecheck-lib\n');
295
+ expect(fs.readFileSync(path.join(installedHooks, 'lib', 'validate-code-walkthrough.cjs'), 'utf8')).toBe('walkthrough-lib\n');
296
+ expect(result.synced).toContain('lib/typecheck.sh');
297
+ });
298
+
299
+ it('REQ-488: drift reports a missing or stale installed lib/ file', () => {
300
+ const { repoRoot, repoHooks, installedHooks } = scaffold({ repoPreCommit: 'same\n' });
301
+ fs.mkdirSync(path.join(repoHooks, 'lib'), { recursive: true });
302
+ fs.writeFileSync(path.join(repoHooks, 'lib', 'typecheck.sh'), 'repo-version\n');
303
+
304
+ const { diagnoseHookDrift } = loadDoctor();
305
+
306
+ const missing = diagnoseHookDrift({ repoRoot, repoHooks, installedHooks });
307
+ expect(missing.issues).toBe(1);
308
+ expect(missing.checks.some(c => c.name === 'Hook drift: lib/typecheck.sh' && c.status === 'FAIL')).toBe(true);
309
+
310
+ fs.mkdirSync(path.join(installedHooks, 'lib'), { recursive: true });
311
+ fs.writeFileSync(path.join(installedHooks, 'lib', 'typecheck.sh'), 'installed-version\n');
312
+ const stale = diagnoseHookDrift({ repoRoot, repoHooks, installedHooks });
313
+ expect(stale.issues).toBe(1);
314
+
315
+ fs.writeFileSync(path.join(installedHooks, 'lib', 'typecheck.sh'), 'repo-version\n');
316
+ const ok = diagnoseHookDrift({ repoRoot, repoHooks, installedHooks });
317
+ expect(ok.issues).toBe(0);
318
+ });
319
+
320
+ it('REQ-488: a repo without lib/ still syncs and drifts cleanly', () => {
321
+ const { repoRoot, repoHooks, installedHooks } = scaffold({ repoPreCommit: '#!/bin/bash\n# xp-gate pre-commit\n' });
322
+
323
+ const { syncGlobalHooksFromRepo, diagnoseHookDrift } = loadDoctor();
324
+ const syncResult = syncGlobalHooksFromRepo({ repoRoot, repoHooks, installedHooks });
325
+
326
+ expect(syncResult.errors).toEqual([]);
327
+ expect(diagnoseHookDrift({ repoRoot, repoHooks, installedHooks }).issues).toBe(0);
328
+ });
329
+ });
@@ -0,0 +1,116 @@
1
+ /**
2
+ * @test REQ-502 Round 3: --fix must be the only way anything mutates the machine
3
+ * @intent Round 2 的两位专家各自命中一条"修复面越界"的残留路径:
4
+ * (1) F7 只把 langIssues 那条分支挂上 --install-tools,而 fixIssues() 里的
5
+ * fixMissingCliTools() 仍然无条件 require('./bootstrap.js') 并 execSync
6
+ * 安装缺失的门禁 CLI(gitleaks/semgrep/lizard…),`xp-gate install` 末尾
7
+ * 又自动跑 doctor --fix —— 与 #502 立项时同一类、且被 F7 自己的注释否定;
8
+ * (2) fixStaleHooks() 用 updateHooks({force:true, noBackup:true, scope:'all'})
9
+ * 覆盖 hooks/adapters/gate 脚本,而同一次发布里 syncModulesFromRepo 对
10
+ * 同类风险显式拒绝并保留字节 —— 同一命令面内两套销毁标准,且 force 让
11
+ * update-hooks 的本地改动守卫直接返回 0,无备份地抹掉机器独有内容。
12
+ * @covers AC-502-04, AC-502-05, AC-502-06, AC-495-20
13
+ */
14
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
15
+ import { tmpdir } from 'node:os';
16
+ import { join } from 'node:path';
17
+ import { describe, expect, it } from 'vitest';
18
+
19
+ const DOCTOR = readFileSync(join(__dirname, '..', 'doctor.js'), 'utf8');
20
+
21
+ function tempRoot(prefix) {
22
+ return mkdtempSync(join(tmpdir(), `xp-gate-${prefix}-`));
23
+ }
24
+
25
+ function write(file, text) {
26
+ mkdirSync(join(file, '..'), { recursive: true });
27
+ writeFileSync(file, text);
28
+ }
29
+
30
+ describe('doctor machine-mutation boundary (#502/#495 Round 3)', () => {
31
+ it('AC-502-04: the CLI-tool bootstrap is reachable only behind --install-tools', () => {
32
+ // fixIssues must take the flag and gate the installer on it, exactly like the
33
+ // language-tool branch already does.
34
+ expect(DOCTOR).toMatch(/function fixIssues\([^)]*installToolsFlag/);
35
+ expect(DOCTOR).toMatch(/if \(installToolsFlag\) \{\s*\n\s*fixed = fixMissingCliTools\(\) \|\| fixed;/);
36
+ expect(DOCTOR).not.toMatch(/fixed = fixMissingCliTools\(\) \|\| fixed;\n\s*fixed = fixTuiRegistration/);
37
+ // The call site has to hand it the flag; --fix alone must not.
38
+ expect(DOCTOR).toMatch(/fixIssues\([^)]*installToolsFlag[^)]*\)/);
39
+ // Guidance stays ungated: reporting is not a machine mutation.
40
+ expect(DOCTOR).toMatch(/fixed = printCliToolGuidance\(\)/);
41
+ // No second route into the installer.
42
+ expect((DOCTOR.match(/require\('\.\/bootstrap\.js'\)/g) || []).length).toBe(1);
43
+ expect(INSTALL_CMD_NEVER_OPTED_IN()).toEqual([]);
44
+ });
45
+
46
+ function INSTALL_CMD_NEVER_OPTED_IN() {
47
+ const src = readFileSync(join(__dirname, '..', 'install-cmd.js'), 'utf8');
48
+ const offenders = [];
49
+ for (const call of src.match(/doctor\(\[[^\]]*\]\)/g) || []) {
50
+ if (call.includes('--install-tools')) offenders.push(call);
51
+ }
52
+ return offenders;
53
+ }
54
+
55
+ it('AC-502-05: the stale-hook refresh writes only what the plan judged safe', () => {
56
+ // A blanket forced updateHooks() overwrites exactly the files the plan just
57
+ // refused, and the backup-less variant of it is what destroyed the machine-only
58
+ // content in the first place. The repair surface is now a per-file copy of the
59
+ // judged-safe entries; nothing in doctor forces, and nothing leaves .bak litter
60
+ // in the user's work tree (#428's guard would reject that too).
61
+ expect(DOCTOR).not.toMatch(/updateHooks\(\s*\{[^}]*force:\s*true/);
62
+ expect(DOCTOR).toMatch(/for \(const entry of plan\.safe\)/);
63
+ expect(DOCTOR).not.toMatch(/noBackup:/);
64
+ });
65
+
66
+ // AC-502-06 (#488 Round 4, feasibility): removing the backup-less forced write
67
+ // also removed the only recovery the old path had. The judgement is still the
68
+ // right one — .bak litter is what #428's guard caught — but a user who does run
69
+ // `--sync-hooks --force` must be told before the bytes are gone that nothing
70
+ // keeps a copy for them.
71
+ it('AC-502-06: the path that suggests --force says it overwrites with no backup', () => {
72
+ expect(DOCTOR).toMatch(/function fixStaleHooks[\s\S]{0,3500}no automatic backup/);
73
+ expect(DOCTOR).toMatch(/--sync-hooks --force[^\n]*no backup|no backup[^\n]*--sync-hooks --force/);
74
+ });
75
+
76
+ it('AC-495-20: planStaleHookSync splits stale files (safe to sync) from diverged ones (must be judged)', () => { const { planStaleHookSync } = require('../doctor');
77
+ const root = tempRoot('stale-plan');
78
+ const srcDir = join(root, 'pkg', 'githooks');
79
+ const destDir = join(root, 'installed', 'adapters');
80
+
81
+ write(join(srcDir, 'gate-4.sh'), '#!/bin/bash\nline_a\nline_b\n');
82
+ write(join(destDir, 'gate-4.sh'), '#!/bin/bash\nline_a\n'); // stale: repo ahead
83
+ write(join(srcDir, 'gate-8.sh'), '#!/bin/bash\nline_a\nline_b\n');
84
+ write(join(destDir, 'gate-8.sh'), '#!/bin/bash\nline_a\nmachine_only_hardening\n'); // diverged
85
+ write(join(srcDir, 'typescript.sh'), '#!/bin/bash\nline_a\nline_b\n');
86
+ write(join(destDir, 'typescript.sh'), '#!/bin/bash\nline_a\nline_b\n'); // identical
87
+
88
+ const plan = planStaleHookSync(
89
+ ['gate-4.sh', 'gate-8.sh', 'typescript.sh'].map((name) => ({
90
+ name,
91
+ source: join(srcDir, name),
92
+ target: join(destDir, name),
93
+ })),
94
+ );
95
+
96
+ expect(plan.safe.map((e) => e.name)).toContain('gate-4.sh');
97
+ expect(plan.diverged.map((e) => e.name)).toContain('gate-8.sh');
98
+ expect(plan.diverged[0].installedOnly).toBe(1);
99
+ expect(plan.unknown.map((e) => e.name)).toEqual([]);
100
+ // A plan that is entirely safe is what allows the sync; one diverged file is not.
101
+ expect(plan.hasBlocking).toBe(true);
102
+
103
+ const unreadable = planStaleHookSync([{
104
+ name: 'gate-9.sh',
105
+ source: join(srcDir, 'gate-9.sh'),
106
+ target: destDir, // directory where a file is expected
107
+ }]);
108
+ expect(unreadable.unknown.map((e) => e.name)).toContain('gate-9.sh');
109
+ expect(unreadable.hasBlocking).toBe(true);
110
+
111
+ // fixStaleHooks has to consult the plan instead of forcing over it.
112
+ expect(DOCTOR).toMatch(/planStaleHookSync\(/);
113
+ expect(DOCTOR).toMatch(/function fixStaleHooks[\s\S]{0,2000}plan\.hasBlocking/);
114
+ expect(readFileSync(join(destDir, 'gate-8.sh'), 'utf8')).toContain('machine_only_hardening');
115
+ });
116
+ });