@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
package/hooks/pre-push CHANGED
@@ -89,13 +89,40 @@ while read local_ref local_sha remote_ref remote_sha; do
89
89
  echo " ℹ️ Branch deletion detected ($local_ref). Skipping validation."
90
90
  continue
91
91
  elif [ "$remote_sha" = "0000000000000000000000000000000000000000" ]; then
92
- # New branch - compare cumulative diff vs base branch
93
- # Use git merge-base HEAD main/master to find the branch point,
94
- # then diff the full branch range. Falls back to HEAD-only diff if no base found.
92
+ # New ref — a branch or a tag.
93
+ # Base-branch resolution shared by the tag check below (#491) and the
94
+ # cumulative new-branch diff.
95
95
  BASE_BRANCH="origin/main"
96
96
  if ! git rev-parse --verify "$BASE_BRANCH" >/dev/null 2>&1; then
97
97
  BASE_BRANCH="origin/master"
98
98
  fi
99
+ # Tag push (#491): a tag carries no code beyond the commit it points at.
100
+ # If that commit is already published on the base branch, its content
101
+ # passed these gates when the base ref was pushed. Re-validating it here
102
+ # false-blocked every release: GATE MW binds evidence to the local HEAD,
103
+ # and a squash merge rewrote the reviewed commit's hash, so no genuine
104
+ # evidence could ever match. The scope is deliberately the PROTECTED base
105
+ # ref, not "any remote-tracking branch": a commit that only ever lived on
106
+ # a side branch still gets the full gates. A tag on a NOT-yet-published
107
+ # commit falls through to the cumulative validation below — creating a
108
+ # tag must not become a path around review. Threat model: like any
109
+ # client-side hook this bounds honest workflows and accidents, not an
110
+ # adversary with write access to this repository — such an actor could
111
+ # forge refs AND the walkthrough evidence file itself; server-side branch
112
+ # protection remains the real boundary.
113
+ if [[ "$local_ref" == refs/tags/* ]]; then
114
+ _tag_commit=$(git rev-parse --verify "${local_sha}^{commit}" 2>/dev/null || true)
115
+ if [ -z "$_tag_commit" ]; then
116
+ echo " ⚠️ Could not dereference $local_ref to a commit — treating it as un-published."
117
+ elif git rev-parse --verify "$BASE_BRANCH" >/dev/null 2>&1 \
118
+ && git merge-base --is-ancestor "$_tag_commit" "$BASE_BRANCH" 2>/dev/null; then
119
+ echo " ℹ️ Tag $local_ref points at a commit already published on $BASE_BRANCH — no new code content."
120
+ continue
121
+ fi
122
+ fi
123
+ # New branch (or tag on un-published work) - compare cumulative diff vs base branch
124
+ # Use git merge-base HEAD main/master to find the branch point,
125
+ # then diff the full branch range. Falls back to HEAD-only diff if no base found.
99
126
  if git rev-parse --verify "$BASE_BRANCH" >/dev/null 2>&1; then
100
127
  MERGE_BASE=$(git merge-base HEAD "$BASE_BRANCH" 2>/dev/null)
101
128
  if [ -n "$MERGE_BASE" ] && [ "$MERGE_BASE" != "$(git rev-parse HEAD)" ]; then
@@ -123,7 +150,7 @@ fi
123
150
 
124
151
  # Determine if pushed files contain ONLY documentation/non-source files
125
152
  DOC_ONLY=true
126
- SOURCE_EXTENSIONS="\.py$|\.js$|\.ts$|\.tsx$|\.java$|\.go$|\.rs$|\.cpp$|\.c$|\.swift$|\.kt$|\.sh$|\.dart$|\.ps1$|\.m$|\.mm$|\.h$|\.hpp$"
153
+ SOURCE_EXTENSIONS="\.py$|\.js$|\.cjs$|\.mjs$|\.ts$|\.tsx$|\.java$|\.go$|\.rs$|\.cpp$|\.c$|\.swift$|\.kt$|\.sh$|\.dart$|\.ps1$|\.m$|\.mm$|\.h$|\.hpp$"
127
154
 
128
155
  for file in $PUSHED_FILES; do
129
156
  if echo "$file" | grep -qE "$SOURCE_EXTENSIONS"; then
@@ -152,6 +179,26 @@ echo "Files changed: $FILES_CHANGED"
152
179
  echo "Lines: +$LINES_ADDED -$LINES_DELETED"
153
180
  echo ""
154
181
 
182
+ # Run a TypeScript entrypoint under tsx without routing arguments through the
183
+ # npx shim: Windows cmd.exe caps command lines at 8191 chars and long changed-file
184
+ # lists overflow it, turning a healthy run into a false BLOCK. Prefer the
185
+ # repo-local tsx CLI driven by node directly; keep npx as fallback for consumer
186
+ # repos relying on a global install. RUN_TSX_TIMEOUT (e.g. "120s") wraps the
187
+ # invocation in `timeout`.
188
+ if ! declare -F run_tsx >/dev/null 2>&1; then
189
+ run_tsx() {
190
+ local _root="${PROJECT_ROOT:-$(pwd)}" _timeout=()
191
+ if [ -n "${RUN_TSX_TIMEOUT:-}" ]; then
192
+ _timeout=(timeout "$RUN_TSX_TIMEOUT")
193
+ fi
194
+ if [ -f "$_root/node_modules/tsx/dist/cli.mjs" ] && command -v node >/dev/null 2>&1; then
195
+ "${_timeout[@]}" node "$_root/node_modules/tsx/dist/cli.mjs" "$@"
196
+ else
197
+ "${_timeout[@]}" npx tsx "$@"
198
+ fi
199
+ }
200
+ fi
201
+
155
202
  # ============================================================================
156
203
  # GATE S: SPRINT FLOW ENFORCEMENT
157
204
  # Validates sprint state consistency before push
@@ -161,11 +208,17 @@ GATE_S_STATUS="PASS"
161
208
  PROJECT_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || echo ".")"
162
209
  SPRINT_GATE_SCRIPT=""
163
210
 
164
- if [ -f "$PROJECT_ROOT/githooks/sprint-gate.sh" ]; then
165
- SPRINT_GATE_SCRIPT="$PROJECT_ROOT/githooks/sprint-gate.sh"
166
- elif [ -f "$(dirname "$0")/sprint-gate.sh" ]; then
167
- SPRINT_GATE_SCRIPT="$(dirname "$0")/sprint-gate.sh"
168
- fi
211
+ # Same chain as pre-commit's Gate 11 (#476). The two tiers here both point at
212
+ # gate-script directories, while update-hooks installs sprint-gate.sh under
213
+ # ~/.config/xp-gate/hooks -- a correctly installed machine was told the file was
214
+ # missing here too. Nothing is sourced for this: pre-push resolves its libraries
215
+ # further down (ADAPTER_COMMON), and this gate must decide before that.
216
+ for _sprint_gate_dir in "$PROJECT_ROOT/githooks" "$(dirname "$0")" "$HOME/.config/xp-gate/hooks"; do
217
+ if [ -n "$_sprint_gate_dir" ] && [ -f "$_sprint_gate_dir/sprint-gate.sh" ]; then
218
+ SPRINT_GATE_SCRIPT="$_sprint_gate_dir/sprint-gate.sh"
219
+ break
220
+ fi
221
+ done
169
222
 
170
223
  if [ -n "$SPRINT_GATE_SCRIPT" ]; then
171
224
  if ! bash "$SPRINT_GATE_SCRIPT" --pre-push; then
@@ -174,7 +227,7 @@ if [ -n "$SPRINT_GATE_SCRIPT" ]; then
174
227
  exit 1
175
228
  fi
176
229
  else
177
- echo "⏭️ SKIPPED - Gate MS: Sprint Flow (sprint-gate.sh not found)"
230
+ echo "⏭️ SKIPPED - Gate MS: Sprint Flow (sprint-gate.sh not found; searched: $PROJECT_ROOT/githooks, $(dirname "$0"), $HOME/.config/xp-gate/hooks)"
178
231
  GATE_S_STATUS="SKIP"
179
232
  fi
180
233
  echo ""
@@ -210,7 +263,7 @@ if [[ -f "package.json" ]] && [[ -f "tsconfig.json" ]] && [[ -n "$TS_FILES" ]];
210
263
  done <<< "$TS_FILES"
211
264
 
212
265
  GATE_10_OUTPUT=$(mktemp)
213
- if timeout 120s npx tsx "$GATE_10_SCRIPT" --changed-files "$GATE_10_FILE_LIST" --project-root "$PROJECT_ROOT" > "$GATE_10_OUTPUT" 2>&1; then
266
+ if RUN_TSX_TIMEOUT=120s run_tsx "$GATE_10_SCRIPT" --changed-files "$GATE_10_FILE_LIST" --project-root "$PROJECT_ROOT" > "$GATE_10_OUTPUT" 2>&1; then
214
267
  cat "$GATE_10_OUTPUT"
215
268
  GATE_10_STATUS="PASS"
216
269
  echo "✅ Gate 10: PASS"
@@ -274,8 +327,11 @@ else
274
327
  echo "⚠️ Stryker config not found or @stryker-mutator not installed."
275
328
  echo " SKIP — Gate M mutation testing."
276
329
  else
277
- # Filter to source files (exclude tests)
278
- CHANGED_SOURCE_FILES=$(echo "$TS_FILES" | grep -v '__tests__' | grep -v '\.test\.' | grep -v '\.spec\.' | grep -v '\.d\.ts$' || true)
330
+ # Filter to source files (exclude tests). The tree-segment grep excludes
331
+ # test infrastructure (helpers/fixtures under tests/, test/, __tests__/)
332
+ # that is not named *.test.* -- it must be judged by the suites that run
333
+ # it, not by a mutation score budgeted for production code (#480).
334
+ CHANGED_SOURCE_FILES=$(echo "$TS_FILES" | grep -v '__tests__' | grep -v '\.test\.' | grep -v '\.spec\.' | grep -v '\.d\.ts$' | grep -v -E '(^|/)(tests?|__tests__)/' || true)
279
335
 
280
336
  if [ -z "$CHANGED_SOURCE_FILES" ]; then
281
337
  echo "📚 No changed TypeScript source files. SKIP — Gate M."
@@ -298,7 +354,7 @@ else
298
354
  else
299
355
  # Run mutation gate with timeout matching stryker.conf.json (timeoutMS: 600000 = 10min)
300
356
  MUTATION_OUTPUT=$(mktemp)
301
- timeout 600s npx tsx $MUTATION_SCRIPT --changed-files "$CHANGED_SOURCE_FILES" > "$MUTATION_OUTPUT" 2>&1
357
+ RUN_TSX_TIMEOUT=600s run_tsx $MUTATION_SCRIPT --changed-files "$CHANGED_SOURCE_FILES" > "$MUTATION_OUTPUT" 2>&1
302
358
  MUTATION_EXIT=$?
303
359
 
304
360
  case $MUTATION_EXIT in
@@ -361,7 +417,7 @@ else
361
417
  if detect_python_mutation_testable 2>/dev/null; then
362
418
  if [ -f "src/mutation/gate-m.ts" ]; then
363
419
  MUTATION_OUTPUT=$(mktemp)
364
- timeout 120s npx tsx src/mutation/gate-m.ts --changed-files "$PYTHON_FILE_LIST" > "$MUTATION_OUTPUT" 2>&1
420
+ RUN_TSX_TIMEOUT=120s run_tsx src/mutation/gate-m.ts --changed-files "$PYTHON_FILE_LIST" > "$MUTATION_OUTPUT" 2>&1
365
421
  MUTATION_EXIT=$?
366
422
 
367
423
  case $MUTATION_EXIT in
@@ -430,7 +486,7 @@ else
430
486
  echo "⚠ Gate M script not found. SKIP — Gate M (Go)."
431
487
  else
432
488
  MUTATION_OUTPUT=$(mktemp)
433
- timeout 120s npx tsx "$MUTATION_SCRIPT" --changed-files "$GO_FILE_LIST" --timeout-ms 120000 > "$MUTATION_OUTPUT" 2>&1
489
+ RUN_TSX_TIMEOUT=120s run_tsx "$MUTATION_SCRIPT" --changed-files "$GO_FILE_LIST" --timeout-ms 120000 > "$MUTATION_OUTPUT" 2>&1
434
490
  MUTATION_EXIT=$?
435
491
 
436
492
  case $MUTATION_EXIT in
@@ -500,7 +556,7 @@ else
500
556
  echo "⚠ Gate M script not found. SKIP — Gate M (Java)."
501
557
  else
502
558
  MUTATION_OUTPUT=$(mktemp)
503
- timeout 600s npx tsx "$MUTATION_SCRIPT" --changed-files "$JAVA_FILE_LIST" --timeout-ms 600000 > "$MUTATION_OUTPUT" 2>&1
559
+ RUN_TSX_TIMEOUT=600s run_tsx "$MUTATION_SCRIPT" --changed-files "$JAVA_FILE_LIST" --timeout-ms 600000 > "$MUTATION_OUTPUT" 2>&1
504
560
  MUTATION_EXIT=$?
505
561
 
506
562
  case $MUTATION_EXIT in
@@ -570,7 +626,7 @@ else
570
626
  echo "⚠ Gate M script not found. SKIP — Gate M (Kotlin)."
571
627
  else
572
628
  MUTATION_OUTPUT=$(mktemp)
573
- timeout 600s npx tsx "$MUTATION_SCRIPT" --changed-files "$KT_FILE_LIST" --timeout-ms 600000 > "$MUTATION_OUTPUT" 2>&1
629
+ RUN_TSX_TIMEOUT=600s run_tsx "$MUTATION_SCRIPT" --changed-files "$KT_FILE_LIST" --timeout-ms 600000 > "$MUTATION_OUTPUT" 2>&1
574
630
  MUTATION_EXIT=$?
575
631
 
576
632
  case $MUTATION_EXIT in
@@ -752,7 +808,7 @@ run_gate_m3() {
752
808
  [[ -n "$f" ]] && args+=("$f")
753
809
  done <<< "$test_files"
754
810
 
755
- if npx tsx "$GATE_M3_SCRIPT" "${args[@]}"; then
811
+ if run_tsx "$GATE_M3_SCRIPT" "${args[@]}"; then
756
812
  echo "✅ Gate ML: PASS"
757
813
  return 0
758
814
  else
@@ -1,78 +1,60 @@
1
+ const fs = require('node:fs');
2
+ const os = require('node:os');
3
+ const path = require('node:path');
1
4
  const { bootstrap } = require('../bootstrap.js');
2
5
 
6
+ const GATE_TOOLS = ['jscpd', 'lizard', 'checkov', 'hadolint', 'gitleaks', 'semgrep', 'npx', 'jq'];
7
+
3
8
  describe('bootstrap', () => {
4
9
  let consoleLogSpy;
5
-
10
+ let realPath;
11
+ let stubDir;
12
+
13
+ // The module mocks these tests used to declare were never applied: bootstrap.js
14
+ // is CommonJS, so its internal require of detect-deps.js bypasses the mock
15
+ // registry and every case fell through to the real installer (global npm/pip
16
+ // installs, ~60s in CI). Stubbing the tools on PATH exercises the real
17
+ // detection path without touching the environment.
6
18
  beforeEach(() => {
7
19
  consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
20
+ realPath = process.env.PATH;
21
+ stubDir = fs.mkdtempSync(path.join(os.tmpdir(), 'xp-gate-bootstrap-'));
22
+ for (const tool of GATE_TOOLS) {
23
+ const stub = path.join(stubDir, process.platform === 'win32' ? `${tool}.cmd` : tool);
24
+ fs.writeFileSync(stub, process.platform === 'win32' ? '@echo 1.0.0\r\n' : '#!/bin/sh\necho 1.0.0\n');
25
+ if (process.platform !== 'win32') fs.chmodSync(stub, 0o755);
26
+ }
27
+ process.env.PATH = `${stubDir}${path.delimiter}${realPath}`;
8
28
  });
9
29
 
10
30
  afterEach(() => {
31
+ process.env.PATH = realPath;
32
+ fs.rmSync(stubDir, { recursive: true, force: true });
11
33
  vi.restoreAllMocks();
12
34
  });
13
35
 
14
36
  it('returns 0 when --dry-run given with no missing tools', () => {
15
- vi.mock('../detect-deps.js', () => ({
16
- GATE_CLI_TOOLS: [],
17
- checkCliTool: () => ({ available: true }),
18
- getToolInstallCmd: () => 'echo done',
19
- }));
20
-
21
- const { bootstrap } = require('../bootstrap.js');
22
37
  const code = bootstrap(['--dry-run']);
23
38
  expect(code).toBe(0);
24
- vi.restoreAllMocks();
25
39
  });
26
40
 
27
41
  it('returns 0 when all CLI tools are available', () => {
28
- vi.mock('../detect-deps.js', () => ({
29
- GATE_CLI_TOOLS: [{ tool: 'testtool', gates: ['Gate 1'], install: { linux: 'echo ok' } }],
30
- checkCliTool: () => ({ available: true, version: '1.0' }),
31
- getToolInstallCmd: () => 'echo ok',
32
- }));
33
-
34
- const { bootstrap } = require('../bootstrap.js');
35
42
  const code = bootstrap([]);
36
43
  expect(code).toBe(0);
37
- vi.restoreAllMocks();
38
44
  });
39
45
 
40
46
  it('accepts --lang ts parameter', () => {
41
- vi.mock('../detect-deps.js', () => ({
42
- GATE_CLI_TOOLS: [],
43
- checkCliTool: () => ({ available: true }),
44
- getToolInstallCmd: () => 'echo done',
45
- }));
46
-
47
- const { bootstrap } = require('../bootstrap.js');
48
47
  const code = bootstrap(['--lang', 'ts']);
49
48
  expect(code).toBe(0);
50
- vi.restoreAllMocks();
51
49
  });
52
50
 
53
51
  it('accepts --lang ts,py parameter', () => {
54
- vi.mock('../detect-deps.js', () => ({
55
- GATE_CLI_TOOLS: [],
56
- checkCliTool: () => ({ available: true }),
57
- getToolInstallCmd: () => 'echo done',
58
- }));
59
-
60
- const { bootstrap } = require('../bootstrap.js');
61
52
  const code = bootstrap(['--lang', 'ts,py']);
62
53
  expect(code).toBe(0);
63
- vi.restoreAllMocks();
64
54
  });
65
55
 
66
56
  it('recognizes --verbose flag without error', () => {
67
- vi.mock('../detect-deps.js', () => ({
68
- GATE_CLI_TOOLS: [],
69
- checkCliTool: () => ({ available: true }),
70
- getToolInstallCmd: () => 'echo done',
71
- }));
72
-
73
- const { bootstrap } = require('../bootstrap.js');
74
57
  const code = bootstrap(['--verbose']);
75
58
  expect(code).toBe(0);
76
- vi.restoreAllMocks();
77
59
  });
78
60
  });
@@ -318,7 +318,14 @@ describe('check-version.js — REQ-001-01', () => {
318
318
  const r = await mod.getRemoteVersion('@nonexistent/pkg-test-only');
319
319
  // Without network: null. With network (npm registry proxy): object.
320
320
  expect(r === null || (typeof r.latest === 'string')).toBe(true);
321
- });
321
+ // This case performs a real registry lookup, so it must be given a budget
322
+ // ABOVE the module's own NETWORK_TIMEOUT_MS (5000). At vitest's 5000ms
323
+ // default the two race, and on a machine where the registry is slow or
324
+ // unreachable the test failed as "Test timed out" -- reporting a stuck test
325
+ // rather than the graceful degradation it exists to verify. Raised here
326
+ // rather than lowering the module timeout, which is deliberate production
327
+ // behaviour.
328
+ }, 20000);
322
329
 
323
330
  // AC-001-02-A: writeCache rejects non-semver versions from registry response
324
331
  function evictCache() {
@@ -285,6 +285,22 @@ describe('detect-deps', () => {
285
285
  const { detectPlatform } = require('../detect-deps');
286
286
  expect(detectPlatform()).toBe('qoder');
287
287
  });
288
+
289
+ // ── #424: brand-new Qoder installs have no skills/ subdirectory yet, so a
290
+ // skills-only marker misdetects them as opencode and configureQoderDelphiAgents
291
+ // deploys zero Delphi agents. Any ~/.qoder content must count as qoder. ──
292
+
293
+ it('returns "qoder" when only ~/.qoder exists (brand-new install, no skills dir)', () => {
294
+ fs.mkdirSync(path.join(tmpHome, '.qoder'), { recursive: true });
295
+ const { detectPlatform } = require('../detect-deps');
296
+ expect(detectPlatform()).toBe('qoder');
297
+ });
298
+
299
+ it('returns "qoder" when only ~/.qoder/agents exists (no skills dir)', () => {
300
+ fs.mkdirSync(path.join(tmpHome, '.qoder', 'agents'), { recursive: true });
301
+ const { detectPlatform } = require('../detect-deps');
302
+ expect(detectPlatform()).toBe('qoder');
303
+ });
288
304
  });
289
305
 
290
306
  // ── getSkillsDirs tests ──
@@ -0,0 +1,212 @@
1
+ /**
2
+ * @test REQ-495 Round 2: the drift surface must not lie in either direction
3
+ * @intent 三位专家对 origin/main..HEAD 的走查给出三条同族判定:(1) 字节不同但行集
4
+ * 相同(仅行尾/缩进差异)落入 moduleDriftDetail 兜底分支,报 FAIL 却写着
5
+ * "0 line(s) absent from the repo",方向断言为伪且 --fix 清不掉;(2)
6
+ * diagnoseModuleDrift 认 XP_GATE_REPO_ROOT 而 syncModulesFromRepo 只认
7
+ * process.cwd(),在仓库外带 env 运行 --sync-hooks 时诊断报 FAIL、同步静默
8
+ * 空转(#488/#416 已明令禁止的"被忽略的选项"缺陷类);(3) drift 结果被
9
+ * Promise.race 的全局 10s 超时整份丢弃并返回 issues:0,doctor 随即打印
10
+ * "✓ All checks passed" —— 正是 #495 要消灭的"模块陈旧但 doctor 全绿"。
11
+ * 外加 lib/ 通道:syncGlobalHooksFromRepo 对 githooks/lib/*.sh 无条件覆盖,
12
+ * 而同一次发布里模块同步对同类风险显式拒绝,破坏性写入标准不对称。
13
+ * @covers AC-495-07, AC-495-08, AC-495-09, AC-495-10, AC-495-13, AC-488-05
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
+ function fixture(spec) {
21
+ const root = mkdtempSync(join(tmpdir(), 'xp-gate-r2-'));
22
+ const repoHooks = join(root, 'githooks');
23
+ const installedHooks = join(root, 'hooks');
24
+ const installedAdapters = join(root, 'adapters');
25
+ mkdirSync(join(repoHooks, 'adapters'), { recursive: true });
26
+ mkdirSync(join(repoHooks, 'lib'), { recursive: true });
27
+ mkdirSync(installedHooks, { recursive: true });
28
+ mkdirSync(installedHooks + '/lib', { recursive: true });
29
+ mkdirSync(installedAdapters, { recursive: true });
30
+
31
+ const write = (file, text) => {
32
+ mkdirSync(join(file, '..'), { recursive: true });
33
+ writeFileSync(file, text);
34
+ };
35
+
36
+ for (const [name, sides] of Object.entries(spec)) {
37
+ const rootLevel = name.startsWith('gate-') || name === 'adapter-common.sh' || name.startsWith('lib/');
38
+ if (sides.repo !== null) {
39
+ write(join(repoHooks, rootLevel ? name : join('adapters', name)), sides.repo);
40
+ }
41
+ if (sides.installed !== undefined && sides.installed !== null) {
42
+ write(join(installedAdapters, name), sides.installed);
43
+ }
44
+ // lib/ lives under hooks/, not under the adapters dir, so it needs its own pair.
45
+ if (sides.installedLib !== undefined) {
46
+ write(join(installedHooks, name), sides.installedLib);
47
+ }
48
+ }
49
+
50
+ write(join(repoHooks, 'pre-commit'), '#!/bin/bash\necho repo\n');
51
+ write(join(installedHooks, 'pre-commit'), '#!/bin/bash\necho repo\n');
52
+
53
+ // Resolution anchors: the installed dir only becomes ADAPTER_DIR / GATE_DIR if
54
+ // adapter-common.sh (githooks/pre-commit:34) and gate-3.sh (:80) live in it.
55
+ // Without them the fixture describes a tier the hook never resolves to, and the
56
+ // comparison silently targets a directory outside the test (#495 Round 3).
57
+ const ANCHOR = '#!/bin/bash\nanchor\n';
58
+ for (const anchor of ['adapter-common.sh', 'gate-3.sh']) {
59
+ if (spec[anchor]) continue;
60
+ write(join(repoHooks, anchor), ANCHOR);
61
+ write(join(installedAdapters, anchor), ANCHOR);
62
+ }
63
+
64
+ return { root, repoHooks, installedHooks, installedAdapters, write };
65
+ }
66
+
67
+ /**
68
+ * Name every tier the hook can resolve into so no fixture can reach this
69
+ * machine's real `~/.config/xp-gate`.
70
+ */
71
+ function ctxFor(f, extra = {}) {
72
+ return {
73
+ repoRoot: f.root,
74
+ repoHooks: f.repoHooks,
75
+ installedAdapters: f.installedAdapters,
76
+ scriptDir: f.installedHooks,
77
+ ...extra,
78
+ };
79
+ }
80
+
81
+ describe('doctor drift Round 2 (#495)', () => {
82
+ it('AC-495-07: a line-ending-only difference is not reported as drift, and never claims zero unique lines', () => {
83
+ const { diagnoseModuleDrift } = require('../doctor');
84
+ const repo = '#!/bin/bash\ngate_line_one\ngate_line_two\n';
85
+ const f = fixture({
86
+ 'gate-4.sh': { repo, installed: repo.replace(/\n/g, '\r\n') },
87
+ });
88
+
89
+ const result = diagnoseModuleDrift(ctxFor(f));
90
+ const check = result.checks.find((c) => c.name === 'Module drift: gate-4.sh');
91
+ expect(check).toBeDefined();
92
+ expect(check.status).not.toBe('FAIL');
93
+ expect(result.issues).toBe(0);
94
+ // The self-contradicting verdict ("newer ... 0 line(s) absent") must be gone.
95
+ expect(check.detail).not.toMatch(/0 line\(s\)/);
96
+ });
97
+
98
+ it('AC-495-08: sync resolves the repo root the same way the diagnosis does', () => {
99
+ const { syncModulesFromRepo, diagnoseModuleDrift } = require('../doctor');
100
+ const f = fixture({
101
+ 'gate-4.sh': { repo: '#!/bin/bash\nline_a\nline_b\n', installed: '#!/bin/bash\nline_a\n' },
102
+ });
103
+
104
+ // Run from OUTSIDE the repository with the repo located by env only.
105
+ const previous = process.env.XP_GATE_REPO_ROOT;
106
+ process.env.XP_GATE_REPO_ROOT = f.root;
107
+ try {
108
+ const diagnosis = diagnoseModuleDrift(ctxFor(f));
109
+ expect(diagnosis.issues).toBe(1);
110
+
111
+ // Only the install target is named: the repo must come from the same env
112
+ // the diagnosis used, or the FAIL this produces can never be cleared.
113
+ const synced = syncModulesFromRepo({ installedAdapters: f.installedAdapters, scriptDir: f.installedHooks });
114
+ expect(synced.synced).toContain('gate-4.sh');
115
+ expect(readFileSync(join(f.installedAdapters, 'gate-4.sh'), 'utf8')).toContain('line_b');
116
+ expect(diagnoseModuleDrift(ctxFor(f)).issues).toBe(0);
117
+ } finally {
118
+ if (previous === undefined) delete process.env.XP_GATE_REPO_ROOT;
119
+ else process.env.XP_GATE_REPO_ROOT = previous;
120
+ }
121
+ });
122
+
123
+ it('AC-495-09: sync with no canonical repo source says so instead of returning an empty success', () => {
124
+ const { syncModulesFromRepo } = require('../doctor');
125
+ const root = mkdtempSync(join(tmpdir(), 'xp-gate-r2-nosource-'));
126
+ const installedAdapters = join(root, 'adapters');
127
+ mkdirSync(installedAdapters, { recursive: true });
128
+ writeFileSync(join(installedAdapters, 'gate-4.sh'), '#!/bin/bash\ninstalled\n');
129
+
130
+ const previous = process.env.XP_GATE_REPO_ROOT;
131
+ process.env.XP_GATE_REPO_ROOT = root; // no githooks/ under here at all
132
+ try {
133
+ const result = syncModulesFromRepo({ repoRoot: root, installedAdapters });
134
+ expect(result.sourceMissing).toBe(true);
135
+ expect(result.errors.join('\n')).toMatch(/no (canonical )?githooks/i);
136
+ } finally {
137
+ if (previous === undefined) delete process.env.XP_GATE_REPO_ROOT;
138
+ else process.env.XP_GATE_REPO_ROOT = previous;
139
+ }
140
+ });
141
+
142
+ it('AC-495-10: a timed-out diagnosis can never print "All checks passed"', () => {
143
+ const source = readFileSync(join(__dirname, '..', 'doctor.js'), 'utf8');
144
+
145
+ // The race outcome must carry timedOut out of the destructuring...
146
+ expect(source).toMatch(/const \{[^}]*timedOut[^}]*\} = await Promise\.race/);
147
+ // ...and branch on it, because a timeout discards every computed FAIL.
148
+ expect(source).toMatch(/if \(timedOut\)[\s\S]{0,200}issues\s*(\+\+|\+=|= Math\.max)/);
149
+ });
150
+
151
+ it('AC-495-13: a flat copy inside the project githooks/ is diagnosed when the project tier is what executes', () => {
152
+ const { diagnoseModuleDrift } = require('../doctor');
153
+ // Project mode (no global install): pre-commit:36-37 resolves ADAPTER_DIR to
154
+ // <repo>/githooks, and resolve_adapter_path() then tries $ADAPTER_DIR/<lang>.sh
155
+ // before the nested source. The installer wrote those flat copies once and
156
+ // .gitignore:42 keeps them out of the repo, so a later repo fix that only
157
+ // touches adapters/<lang>.sh leaves the stale flat copy executing -- a
158
+ // #493-class repair inert in the very repository that shipped it.
159
+ const root = mkdtempSync(join(tmpdir(), 'xp-gate-project-tier-'));
160
+ const repoHooks = join(root, 'githooks');
161
+ mkdirSync(join(repoHooks, 'adapters'), { recursive: true });
162
+ writeFileSync(join(repoHooks, 'pre-commit'), '#!/bin/bash\necho repo\n');
163
+ writeFileSync(join(repoHooks, 'adapter-common.sh'), '#!/bin/bash\nshared\n');
164
+ writeFileSync(join(repoHooks, 'gate-3.sh'), '#!/bin/bash\ngate3\n');
165
+ writeFileSync(join(repoHooks, 'adapters', 'iac.sh'), '#!/bin/bash\nfixed_count_guard\n');
166
+ writeFileSync(join(repoHooks, 'iac.sh'), '#!/bin/bash\nstale_echo_zero\n');
167
+
168
+ const result = diagnoseModuleDrift({
169
+ repoRoot: root,
170
+ installedAdapters: join(root, 'nothing-installed-globally'),
171
+ scriptDir: join(root, 'nothing-installed-hooks'),
172
+ });
173
+
174
+ const projectCheck = result.checks.find((c) => /Module drift \(project\): iac\.sh/.test(c.name));
175
+ expect(projectCheck, 'the executed project-tier flat copy is invisible to doctor').toBeDefined();
176
+ expect(projectCheck.status).toBe('FAIL');
177
+ // The verdict has to say what to do with a gitignored installer residue.
178
+ expect(projectCheck.detail).toMatch(/flat|shadow/i);
179
+ expect(result.issues).toBe(1);
180
+ });
181
+
182
+ it('AC-488-05: lib/ sync refuses an installed library the repo does not have lines for', () => {
183
+ const { syncGlobalHooksFromRepo } = require('../doctor');
184
+ const f = fixture({
185
+ 'lib/test-failure.sh': {
186
+ repo: '#!/bin/bash\nrepo_shared_helper\n',
187
+ installedLib: '#!/bin/bash\ninstalled_machine_hardening\n',
188
+ },
189
+ });
190
+
191
+ const result = syncGlobalHooksFromRepo({
192
+ repoHooks: f.repoHooks,
193
+ installedHooks: f.installedHooks,
194
+ });
195
+
196
+ expect(result.refused.map((r) => r.name)).toContain('lib/test-failure.sh');
197
+ expect(readFileSync(join(f.installedHooks, 'lib', 'test-failure.sh'), 'utf8'))
198
+ .toContain('installed_machine_hardening');
199
+ expect(existsSync(join(f.installedHooks, 'lib', 'test-failure.sh'))).toBe(true);
200
+
201
+ // A one-way-stale library still syncs: the guard is for divergence, not staleness.
202
+ const stale = fixture({
203
+ 'lib/test-failure.sh': {
204
+ repo: '#!/bin/bash\nshared_a\nshared_b\n',
205
+ installedLib: '#!/bin/bash\nshared_a\n',
206
+ },
207
+ });
208
+ const second = syncGlobalHooksFromRepo({ repoHooks: stale.repoHooks, installedHooks: stale.installedHooks });
209
+ expect(second.synced).toContain('lib/test-failure.sh');
210
+ expect(readFileSync(join(stale.installedHooks, 'lib', 'test-failure.sh'), 'utf8')).toContain('shared_b');
211
+ });
212
+ });