@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-commit CHANGED
@@ -115,6 +115,30 @@ set -o pipefail
115
115
  # Helper Functions
116
116
  # ============================================================================
117
117
 
118
+ # Run a TypeScript entrypoint under tsx without routing arguments through the
119
+ # npx shim: Windows cmd.exe caps command lines at 8191 chars and long staged
120
+ # file lists (Boy Scout, build integrity) overflow it, turning a healthy run
121
+ # into a false BLOCK. Prefer the repo-local tsx CLI driven by node directly;
122
+ # keep npx as fallback for consumer repos relying on a global install.
123
+ # RUN_TSX_TIMEOUT (e.g. "120s") wraps the invocation in `timeout`.
124
+ if ! declare -F run_tsx >/dev/null 2>&1; then
125
+ run_tsx() {
126
+ local _root="${PROJECT_ROOT:-$(pwd)}" _timeout=()
127
+ if [ -n "${RUN_TSX_TIMEOUT:-}" ]; then
128
+ _timeout=(timeout "$RUN_TSX_TIMEOUT")
129
+ fi
130
+ if [ -f "$_root/node_modules/tsx/dist/cli.mjs" ] && command -v node >/dev/null 2>&1; then
131
+ "${_timeout[@]}" node "$_root/node_modules/tsx/dist/cli.mjs" "$@"
132
+ else
133
+ "${_timeout[@]}" npx tsx "$@"
134
+ fi
135
+ }
136
+ fi
137
+
138
+ # Gate 5's vitest exit-code judgement (is_runner_infrastructure_error,
139
+ # is_partial_threshold_only_exit, handle_test_failure) is sourced from
140
+ # lib/test-failure.sh below, next to the other hook libraries.
141
+
118
142
  # Parse LCOV file and return coverage percentage
119
143
  # Usage: parse_lcov_coverage <lcov_file>
120
144
  # Returns: coverage percentage (e.g., "85.5")
@@ -190,6 +214,16 @@ PROJECT_ROOT_FOR_AUDIT="$(git rev-parse --show-toplevel 2>/dev/null || echo "$AU
190
214
  # Issue #370: source cross-platform now_ms() helper (node-preferred, regex-validated)
191
215
  source "${AUDIT_SCRIPT_DIR}/lib/now-ms.sh"
192
216
 
217
+ # Issue #473 (+ #454): Gate 5's vitest exit-code judgement, sourced as a library
218
+ # so the fail-closed contract is unit-testable without executing this hook.
219
+ source "${AUDIT_SCRIPT_DIR}/lib/test-failure.sh"
220
+
221
+ # Issue #477: Gate 2's jscpd config probe and outcome classification, same reason.
222
+ source "${AUDIT_SCRIPT_DIR}/lib/jscpd-run.sh"
223
+
224
+ # Issue #476: Gate 11's sprint-gate.sh resolution chain and decision translation.
225
+ source "${AUDIT_SCRIPT_DIR}/lib/sprint-gate-report.sh"
226
+
193
227
  # Read max_duration_ms from .xp-gate-config.json (default 7200000 = 2 hours)
194
228
  _MAX_DURATION_MS=$(node -e "try{const c=JSON.parse(require('fs').readFileSync('${PROJECT_ROOT_FOR_AUDIT}/.xp-gate-config.json','utf8'));console.log((c.audit&&c.audit.max_duration_ms)||7200000);}catch(e){console.log(7200000);}" 2>/dev/null || echo "7200000")
195
229
 
@@ -254,7 +288,7 @@ echo ""
254
288
  GATE_0_START=$(gate_start_ms)
255
289
  GATE_0_STATUS="PASS"
256
290
 
257
- CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
291
+ CURRENT_BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null || git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "HEAD")
258
292
  PROTECTED_BRANCHES="master develop trunk mainline"
259
293
  ROOT_DIR=$(git rev-parse --show-toplevel 2>/dev/null || echo ".")
260
294
 
@@ -402,7 +436,11 @@ Before committing:
402
436
  2. Add entry to CHANGELOG.md
403
437
  3. Run: node scripts/sync-version.cjs
404
438
 
405
- Or use --no-verify (discouraged) or include [skip-version-check] in commit message.
439
+ Or use the bypass prefix — the commit message FIRST LINE must START with:
440
+ [skip-version-check] chore: <description>
441
+ Allowed types: chore: docs: release:. The bypass only permits build
442
+ tooling changes (adapters/, scripts/, hooks/); production source files
443
+ still require a VERSION/CHANGELOG update.
406
444
 
407
445
  Staged files: $(echo "$STAGED_FILES" | tr '\n' ', ' | sed 's/,$//')
408
446
  "
@@ -429,6 +467,22 @@ if [ -z "$CHANGED_FILES" ]; then
429
467
  exit 0
430
468
  fi
431
469
 
470
+ # Gate 5 test selection (#494): vitest.config.ts excludes the bundled mirror
471
+ # subtrees and the skills/plugins source trees, so a path under one of them can
472
+ # never be collected. When such a path is the only target, vitest exits 1 with
473
+ # "No test files found" and Gate 5 reports a failing test run -- a commit that
474
+ # touched nothing but a byte-identical mirror cannot land. Map each mirror path
475
+ # to the canonical twin it copies, drop what has no twin, collapse duplicates.
476
+ select_root_suite_targets() {
477
+ # Deliberately not `s|...|...|`: the alternation below contains `|`, which sed
478
+ # would read as the end of its pattern ("unknown option to `s'").
479
+ printf '%s\n' "$1" \
480
+ | sed -E 's#^src/npm-package/(principles|mutation|mock-policy|build-integrity)/#src/\1/#' \
481
+ | grep -vE '^(src/_wip/|plugins/|skills/|src/npm-package/(plugins|skills|principles|mutation|mock-policy|build-integrity)/)' \
482
+ | sed '/^$/d' \
483
+ | sort -u
484
+ }
485
+
432
486
  any_changed_files_match() {
433
487
  local pattern
434
488
 
@@ -833,72 +887,16 @@ else
833
887
  echo " ── Checking $CURRENT_LANG ──"
834
888
  case "$CURRENT_LANG" in
835
889
  typescript)
836
- # TypeScript: static analysis (tsc) + linting (eslint)
837
- if ! command -v npx &> /dev/null; then
838
- echo "ℹ️ npx not available — skipping TypeScript checks"
839
- echo "⏭️ SKIPPED - Code quality (npx not available)"
840
- else
841
- # Verify tsc is real TypeScript compiler (npx may fetch deprecated tsc@2.0.4 placeholder)
842
- TSC_OUTPUT=$(npx tsc --version 2>&1)
843
- if echo "$TSC_OUTPUT" | grep -q "Version"; then
844
- # Only run tsc if tsconfig.json exists (without it tsc prints help and exits non-zero)
845
- if [ -f "tsconfig.json" ]; then
846
- # Run TypeScript type checking
847
- echo "Running TypeScript static analysis ($TSC_OUTPUT)..."
848
- npx tsc --noEmit --skipLibCheck 2>&1 | head -30
849
- TSC_EXIT=$?
850
- if [ "$TSC_EXIT" -ne 0 ]; then
851
- echo ""
852
- echo "❌ BLOCKED - TYPE ERRORS detected"
853
- echo "Fix the type errors above before committing."
854
- exit 1
855
- fi
856
- echo "✅ PASSED - TypeScript static analysis."
857
-
858
- # Also check test files if they exist (Issue #293)
859
- # Many projects exclude __tests__/ from tsconfig.json, so test file type
860
- # errors silently accumulate.
861
- if [ -d "src/__tests__" ] || [ -d "src/tests" ] || [ -d "tests" ] || [ -d "__tests__" ]; then
862
- HAS_TESTS_IN_TSC=false
863
- if npx tsc --noEmit --skipLibCheck --listFiles 2>/dev/null | grep -qE '__tests__/|\.test\.ts|\.spec\.ts'; then
864
- HAS_TESTS_IN_TSC=true
865
- fi
866
- if [ "$HAS_TESTS_IN_TSC" = false ]; then
867
- TSC_TEST_EXIT=0
868
- if [ -f "tsconfig.tests.json" ]; then
869
- echo "Checking test files with tsconfig.tests.json..."
870
- npx tsc --noEmit --project tsconfig.tests.json 2>&1 | head -30
871
- TSC_TEST_EXIT=$?
872
- else
873
- echo "Checking test files for type errors..."
874
- TEMP_TSCONFIG=".tsconfig.withtests.json"
875
- node -e "
876
- const cfg = JSON.parse(require('fs').readFileSync('tsconfig.json','utf8'));
877
- delete cfg.exclude;
878
- cfg.include = cfg.include || ['src/**/*'];
879
- cfg.include.push('src/**/__tests__/**','src/**/*.test.ts','src/**/*.spec.ts');
880
- require('fs').writeFileSync('$TEMP_TSCONFIG', JSON.stringify(cfg, null, 2));
881
- " 2>/dev/null
882
- npx tsc --noEmit --project "$TEMP_TSCONFIG" --skipLibCheck 2>&1 | head -30
883
- TSC_TEST_EXIT=$?
884
- rm -f "$TEMP_TSCONFIG"
885
- fi
886
- if [ $TSC_TEST_EXIT -ne 0 ]; then
887
- echo ""
888
- echo "❌ BLOCKED - TYPE ERRORS in test files"
889
- echo "Your project's tsconfig.json excludes test files from type checking."
890
- echo "Add a tsconfig.tests.json to customise test file checking."
891
- exit 1
892
- fi
893
- fi
894
- fi
895
- else
896
- echo "ℹ️ SKIP - tsconfig.json not found (no TypeScript project config)"
897
- fi
898
- else
899
- echo "ℹ️ SKIP - tsc not properly installed ($TSC_OUTPUT)"
900
- echo "Install typescript (npm install typescript --save-dev) for type checking."
901
- fi
890
+ # TypeScript: static analysis + linting (eslint). The type-check decision lives
891
+ # in githooks/lib/typecheck.sh so a project's own checker (vue-tsc, svelte-check,
892
+ # a `typecheck` script) is used instead of a hardcoded tsc that cannot resolve
893
+ # single-file components (#436).
894
+ source "${AUDIT_SCRIPT_DIR}/lib/typecheck.sh"
895
+ run_typescript_typecheck
896
+ TS_CHECK_EXIT=$?
897
+ if [ "$TS_CHECK_EXIT" -eq 1 ]; then
898
+ exit 1
899
+ fi
902
900
 
903
901
  # Run ESLint linting if config exists
904
902
  if [ -f ".eslintrc.json" ] || [ -f ".eslintrc.js" ] || [ -f ".eslintrc.cjs" ] || [ -f "eslint.config.js" ]; then
@@ -966,16 +964,30 @@ else
966
964
  # ── Biome lint/format check ──
967
965
  if [ -f "biome.json" ] || [ -f "biome.jsonc" ]; then
968
966
  echo "Running Biome lint/format check..."
969
- if ! npx biome check --staged . 2>/dev/null; then
970
- npx biome check . 2>&1 | head -50
971
- echo ""
972
- echo "❌ BLOCKED - Biome check failed"
973
- echo "Fix the errors above before committing."
974
- echo "Tip: Run 'npx biome check --write .' to auto-fix formatting and safe lint issues."
975
- echo " Run 'npx biome check --write --unsafe .' to also fix unsafe lint issues."
976
- exit 1
967
+ BIOME_OUT=$(npx biome check --staged . 2>&1)
968
+ BIOME_STATUS=$?
969
+ if [ "$BIOME_STATUS" -ne 0 ]; then
970
+ # Biome exits 1 with `internalError/io - No files were processed` when the
971
+ # staged set yields nothing lintable (docs-only / config-only commit, or
972
+ # everything excluded by biome.json). That is "nothing to do", not a lint
973
+ # violation -- blocking on it makes documentation uncommittable (#458).
974
+ # This matches the hook's stated principle: tool available + check
975
+ # SUCCEEDS = PASS. Real violations still carry diagnostics, so they block.
976
+ if echo "$BIOME_OUT" | grep -qE 'internalError/io|No files were processed'; then
977
+ echo "$BIOME_OUT"
978
+ echo "✅ PASSED - Biome check (no lintable files staged)."
979
+ else
980
+ echo "$BIOME_OUT" | head -50
981
+ echo ""
982
+ echo "❌ BLOCKED - Biome check failed"
983
+ echo "Fix the errors above before committing."
984
+ echo "Tip: Run 'npx biome check --write .' to auto-fix formatting and safe lint issues."
985
+ echo " Run 'npx biome check --write --unsafe .' to also fix unsafe lint issues."
986
+ exit 1
987
+ fi
988
+ else
989
+ echo "✅ PASSED - Biome check."
977
990
  fi
978
- echo "✅ PASSED - Biome check."
979
991
  else
980
992
  if [ -f "package.json" ] && command -v node &> /dev/null; then
981
993
  HAS_BIOME=$(node -e "try{const p=JSON.parse(require('fs').readFileSync('package.json','utf8'));const d=p.devDependencies||{};const deps=p.dependencies||{};console.log((d['@biomejs/biome']||deps['@biomejs/biome'])?'yes':'no')}catch{console.log('no')}") && true
@@ -1002,7 +1014,6 @@ else
1002
1014
  exit 1
1003
1015
  fi
1004
1016
  fi
1005
- fi
1006
1017
  ;;
1007
1018
 
1008
1019
  python)
@@ -1013,8 +1024,22 @@ else
1013
1024
  else
1014
1025
  # Run Ruff (includes syntax check, linting)
1015
1026
  echo "Running Ruff linting (syntax + lint)..."
1016
- RUFF_OUTPUT=$(ruff check "$CHANGED_FILES" --output-format json 2>&1)
1017
- RUFF_EXIT=$?
1027
+ # Filter to *.py/*.pyi into a bash array so each staged path is passed as
1028
+ # ONE argument even when it contains whitespace; skip ruff when no Python
1029
+ # files are staged so yaml/.gitignore are never parsed.
1030
+ RUFF_ARGS=()
1031
+ while IFS= read -r staged_file; do
1032
+ case "$staged_file" in
1033
+ *.py|*.pyi) RUFF_ARGS+=("$staged_file") ;;
1034
+ esac
1035
+ done <<< "$CHANGED_FILES"
1036
+ if [ "${#RUFF_ARGS[@]}" -eq 0 ]; then
1037
+ RUFF_OUTPUT="[]"
1038
+ RUFF_EXIT=0
1039
+ else
1040
+ RUFF_OUTPUT=$(ruff check "${RUFF_ARGS[@]}" --output-format json 2>&1)
1041
+ RUFF_EXIT=$?
1042
+ fi
1018
1043
  if [ "$RUFF_EXIT" -ne 0 ]; then
1019
1044
  if [ -f ".xp-gate/lint-baseline.json" ] && command -v node &> /dev/null; then
1020
1045
  # Get baseline warnings for changed Python files
@@ -1070,9 +1095,14 @@ else
1070
1095
 
1071
1096
  # Ruff format check (formatting gate)
1072
1097
  if command -v ruff &> /dev/null; then
1073
- PY_FMT_FILES=$(echo "$CHANGED_FILES" | grep '\.py$' || true)
1074
- if [ -n "$PY_FMT_FILES" ]; then
1075
- RUFF_FMT_OUTPUT=$(ruff format --check $PY_FMT_FILES 2>&1)
1098
+ FMT_ARGS=()
1099
+ while IFS= read -r staged_file; do
1100
+ case "$staged_file" in
1101
+ *.py|*.pyi) FMT_ARGS+=("$staged_file") ;;
1102
+ esac
1103
+ done <<< "$CHANGED_FILES"
1104
+ if [ "${#FMT_ARGS[@]}" -gt 0 ]; then
1105
+ RUFF_FMT_OUTPUT=$(ruff format --check "${FMT_ARGS[@]}" 2>&1)
1076
1106
  RUFF_FMT_EXIT=$?
1077
1107
  if [ "$RUFF_FMT_EXIT" -ne 0 ]; then
1078
1108
  echo "$RUFF_FMT_OUTPUT" | head -20
@@ -1086,9 +1116,14 @@ else
1086
1116
  fi
1087
1117
 
1088
1118
  # Debug statement detection (breakpoint/pdb/ipdb)
1089
- PY_DEBUG_FILES=$(echo "$CHANGED_FILES" | grep '\.py$' || true)
1090
- if [ -n "$PY_DEBUG_FILES" ]; then
1091
- DEBUG_HITS=$(grep -n 'breakpoint()\|pdb\.set_trace()\|ipdb\.set_trace()\|import pdb\|import ipdb' $PY_DEBUG_FILES 2>/dev/null || true)
1119
+ DEBUG_ARGS=()
1120
+ while IFS= read -r staged_file; do
1121
+ case "$staged_file" in
1122
+ *.py|*.pyi) DEBUG_ARGS+=("$staged_file") ;;
1123
+ esac
1124
+ done <<< "$CHANGED_FILES"
1125
+ if [ "${#DEBUG_ARGS[@]}" -gt 0 ]; then
1126
+ DEBUG_HITS=$(grep -n 'breakpoint()\|pdb\.set_trace()\|ipdb\.set_trace()\|import pdb\|import ipdb' "${DEBUG_ARGS[@]}" 2>/dev/null || true)
1092
1127
  if [ -n "$DEBUG_HITS" ]; then
1093
1128
  echo ""
1094
1129
  echo "❌ BLOCKED - Debug statements detected in Python files:"
@@ -1101,9 +1136,19 @@ else
1101
1136
 
1102
1137
  # Optional: Type checking with mypy
1103
1138
  if command -v mypy &> /dev/null; then
1104
- echo "Running mypy type checking (optional)..."
1105
- mypy --ignore-missing-imports $(echo "$CHANGED_FILES" | grep "\.py$" || echo ".") 2>&1 | head -20 | tail -10
1106
- MYPY_EXIT=$?
1139
+ MYPY_ARGS=()
1140
+ while IFS= read -r staged_file; do
1141
+ case "$staged_file" in
1142
+ *.py|*.pyi) MYPY_ARGS+=("$staged_file") ;;
1143
+ esac
1144
+ done <<< "$CHANGED_FILES"
1145
+ if [ "${#MYPY_ARGS[@]}" -eq 0 ]; then
1146
+ MYPY_EXIT=0
1147
+ else
1148
+ echo "Running mypy type checking (optional)..."
1149
+ mypy --ignore-missing-imports "${MYPY_ARGS[@]}" 2>&1 | head -20 | tail -10
1150
+ MYPY_EXIT=$?
1151
+ fi
1107
1152
  if [ "$MYPY_EXIT" -ne 0 ]; then
1108
1153
  echo ""
1109
1154
  echo "❌ BLOCKED - MYPI TYPE ERRORS detected"
@@ -1253,6 +1298,7 @@ record_gate_audit "gate-1" "code-quality" "$GATE_1_STATUS" "0" "$GATE_1_START"
1253
1298
  2>&1 echo ""
1254
1299
  2>&1 echo "→ Gate 2: Duplicate code detection..."
1255
1300
  GATE_2_START=$(gate_start_ms)
1301
+ GATE_2_STATUS="PASS"
1256
1302
 
1257
1303
  if [ "$PROJECT_LANG" = "documentation-only" ]; then
1258
1304
  echo "⏭️ SKIPPED - Duplicate code (documentation project)."
@@ -1267,13 +1313,34 @@ else
1267
1313
  fi
1268
1314
 
1269
1315
  echo "Running jscpd for duplicate code detection..."
1270
- jscpd --config jscpd.conf.json $(echo "$CHANGED_FILES" | tr '\n' ' ') 2>&1 | head -30
1271
- JSCPD_EXIT=$?
1272
- if [ "$JSCPD_EXIT" -ne 0 ]; then
1316
+ # #477: only pass --config when the file actually exists, and let jscpd's report
1317
+ # decide the message. Before this, a missing config produced "os error 2" and a
1318
+ # non-zero exit that the gate translated into a fabricated "found duplicated
1319
+ # code"; a real clone report that exited 0 was announced as a clean pass.
1320
+ JSCPD_CONFIG=$(resolve_jscpd_config)
1321
+ JSCPD_ARGS=()
1322
+ if [ -n "$JSCPD_CONFIG" ]; then
1323
+ JSCPD_ARGS=(--config "$JSCPD_CONFIG")
1324
+ else
1325
+ echo "ℹ️ No jscpd.conf.json found - using jscpd defaults"
1326
+ fi
1327
+ JSCPD_OUTPUT=$(mktemp)
1328
+ JSCPD_EXIT=0
1329
+ # shellcheck disable=SC2086 # pre-existing splitting of the file list; #477 changes the reporting, not the scan set
1330
+ jscpd ${JSCPD_ARGS[@]+"${JSCPD_ARGS[@]}"} $(echo "$CHANGED_FILES" | tr '\n' ' ') > "$JSCPD_OUTPUT" 2>&1 || JSCPD_EXIT=$?
1331
+ head -30 "$JSCPD_OUTPUT"
1332
+ JSCPD_OUTCOME=$(classify_jscpd_run "$JSCPD_EXIT" "$(cat "$JSCPD_OUTPUT")")
1333
+ rm -f "$JSCPD_OUTPUT"
1334
+ if [ "$JSCPD_OUTCOME" = "duplicates" ]; then
1273
1335
  echo "ℹ️ jscpd found duplicated code (warning, not blocking by default)"
1274
1336
  echo " Consider refactoring duplicate code blocks."
1337
+ elif [ "$JSCPD_OUTCOME" = "error" ]; then
1338
+ echo "⚠️ jscpd exited with code $JSCPD_EXIT without a report — the output above is its real error"
1339
+ echo "⏭️ SKIPPED - Duplicate code (jscpd run error)"
1340
+ GATE_2_STATUS="SKIP"
1341
+ else
1342
+ echo "✅ PASSED - jscpd duplicate code check completed."
1275
1343
  fi
1276
- echo "✅ PASSED - jscpd duplicate code check completed."
1277
1344
  ;;
1278
1345
 
1279
1346
  python)
@@ -1331,7 +1398,8 @@ else
1331
1398
  esac
1332
1399
  done
1333
1400
  fi
1334
- GATE_2_STATUS="PASS"
1401
+ # Set at the top of this gate; a branch may already have marked it SKIP (#477). The
1402
+ # audit must record what happened, not what the gate defaults to.
1335
1403
  record_gate_audit "gate-2" "duplicate-code" "$GATE_2_STATUS" "0" "$GATE_2_START"
1336
1404
 
1337
1405
  # ============================================================================
@@ -1392,7 +1460,7 @@ else
1392
1460
 
1393
1461
  SKIP_5A_BLOCK=false
1394
1462
  if [ "${SKIP_GATE_5A_BLOCK:-0}" = "1" ]; then
1395
- CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
1463
+ CURRENT_BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null || git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "HEAD")
1396
1464
  if [ "$CURRENT_BRANCH" != "main" ] && [ "$CURRENT_BRANCH" != "master" ]; then
1397
1465
  REASON="${SKIP_GATE_5A_BLOCK_REASON:-not specified}"
1398
1466
  mkdir -p .xp-gate/reports
@@ -1617,7 +1685,15 @@ else
1617
1685
  # Gate 5b: Mock Density ADVISORY Scan (pure bash — no npx tsx overhead)
1618
1686
  # 30% = ADVISORY, 50% = suggests @mock-justified. Does NOT block commit.
1619
1687
  # ========================================================================
1620
- CHANGED_TEST_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(test|spec)\.(ts|tsx|js|jsx|py|go)$' || true)
1688
+ CHANGED_TEST_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(test|spec)\.(ts|tsx|js|jsx|cjs|mjs|py|go)$' || true)
1689
+
1690
+ # Nested plugin sub-projects (plugins/<name>/) own their own vitest config and
1691
+ # are excluded from the root suite (`vitest.config.ts` excludes `plugins/**`,
1692
+ # because plugins/*/node_modules contains vendored tests). Feeding their test
1693
+ # paths to the root vitest yields "No test files found" → false BLOCK, so drop
1694
+ # them here and let the per-plugin suite cover them (CI runs `npm test` inside
1695
+ # each plugin directory).
1696
+ CHANGED_TEST_FILES=$(echo "$CHANGED_TEST_FILES" | grep -vE '^plugins/[^/]+/' || true)
1621
1697
 
1622
1698
  # Strip PROJECT_SUBDIR prefix from test file paths when running in subdirectory.
1623
1699
  # git diff returns repo-root relative paths, but after cd "$PROJECT_SUBDIR" (line 728)
@@ -1701,7 +1777,7 @@ else
1701
1777
  echo "⚠️ Gate 5c: req-ids.json is stale (specification.yaml updated since last check-alignment)"
1702
1778
  echo " Run 'xp-gate check-alignment' to regenerate before next commit."
1703
1779
  else
1704
- CHANGED_TEST_FILES_ANNOTATED=$(echo "$CHANGED_FILES" | grep -E '\.(test|spec)\.(ts|tsx|js|jsx)$' || true)
1780
+ CHANGED_TEST_FILES_ANNOTATED=$(echo "$CHANGED_FILES" | grep -E '\.(test|spec)\.(ts|tsx|js|jsx|cjs|mjs)$' || true)
1705
1781
  if [ -n "$CHANGED_TEST_FILES_ANNOTATED" ]; then
1706
1782
  echo " → Checking @test REQ-XXX annotations..."
1707
1783
  ANNOTATION_ERRORS=0
@@ -1730,7 +1806,7 @@ else
1730
1806
  # Check reverse: any REQ in spec with no annotated test → visible FAIL (non-blocking)
1731
1807
  ALL_SPEC_REQS=$(jq -r '.[]' "$REQ_IDS_FILE" 2>/dev/null)
1732
1808
  ANNOTATED_ALL=""
1733
- for tf in $(echo "$CHANGED_FILES" | grep -E '\.(test|spec)\.(ts|tsx|js|jsx)$' || true); do
1809
+ for tf in $(echo "$CHANGED_FILES" | grep -E '\.(test|spec)\.(ts|tsx|js|jsx|cjs|mjs)$' || true); do
1734
1810
  if [ -f "$tf" ]; then
1735
1811
  ANNOTATED_ALL="$ANNOTATED_ALL $(grep -oE '@test\s+REQ-[A-Z0-9-]+' "$tf" 2>/dev/null | sed 's/@test\s*//' || true)"
1736
1812
  fi
@@ -1784,16 +1860,41 @@ else
1784
1860
  # TypeScript: vitest with smart test selection
1785
1861
  if [ "$CURRENT_LANG" = "typescript" ] && { [ -f "package.json" ] || [ -f "$PROJECT_ROOT/package.json" ]; }; then
1786
1862
  if run_without_git_context npx vitest --version >/dev/null 2>&1; then
1787
- # Count changed test files to decide strategy
1788
- CHANGED_TEST_FILE_COUNT=$(echo "$CHANGED_TEST_FILES" | grep -c '.' || echo "0")
1863
+ # Count changed test files to decide strategy. `grep -c '.'` prints its
1864
+ # own zero AND exits 1 on an empty list, so `|| echo "0"` used to yield
1865
+ # $'0\n0' -- the same two-value bug that false-blocked Gate 8 (#493).
1866
+ # Bundled mirrors under src/npm-package/ are excluded from the root
1867
+ # vitest project (vitest.config.ts, #418), so selecting them makes
1868
+ # vitest exit 1 with "No test files found" and this gate reads that as
1869
+ # failing tests: a false BLOCK for any commit that only touches mirrors
1870
+ # (#494). Route both lists through the selector and announce it.
1871
+ SELECTED_TEST_FILES=$(select_root_suite_targets "$CHANGED_TEST_FILES")
1872
+ if [ "$SELECTED_TEST_FILES" != "$CHANGED_TEST_FILES" ]; then
1873
+ echo " ↪ Selection adjusted for bundled mirrors (#494):"
1874
+ echo " was: $(echo "$CHANGED_TEST_FILES" | tr '\n' ' ')"
1875
+ echo " now: $(echo "$SELECTED_TEST_FILES" | tr '\n' ' ')"
1876
+ fi
1877
+ CHANGED_TEST_FILE_COUNT=$(echo "$SELECTED_TEST_FILES" | grep -c '.' || true)
1789
1878
 
1790
1879
  # Also get changed source files (non-test) for related-test lookup
1791
1880
  CHANGED_SRC_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(ts|tsx|js|jsx)$' | grep -v '\.test\.' | grep -v '\.spec\.' | grep -v '__tests__' | grep -v '\.d\.ts$' | grep -v '__snapshots__' || true)
1881
+ # Nested plugin sub-projects are excluded from the root suite; their
1882
+ # sources must not drive root-vitest related-test lookup either (it
1883
+ # would resolve to plugin-local paths vitest cannot collect).
1884
+ CHANGED_SRC_FILES=$(echo "$CHANGED_SRC_FILES" | grep -vE '^plugins/[^/]+/' || true)
1885
+ # Mirror handling is identical for source files: their test-adjacent
1886
+ # paths must never reach vitest as root-project targets (#494).
1887
+ SELECTED_SRC_FILES=$(select_root_suite_targets "$CHANGED_SRC_FILES")
1888
+ if [ "$SELECTED_SRC_FILES" != "$CHANGED_SRC_FILES" ]; then
1889
+ echo " ↪ Source selection adjusted for bundled mirrors (#494):"
1890
+ echo " was: $(echo "$CHANGED_SRC_FILES" | tr '\n' ' ')"
1891
+ echo " now: $(echo "$SELECTED_SRC_FILES" | tr '\n' ' ')"
1892
+ fi
1792
1893
  # Strip PROJECT_SUBDIR prefix (same as CHANGED_TEST_FILES fix above)
1793
- if [ -n "$PROJECT_SUBDIR" ] && [ "$PROJECT_SUBDIR" != "." ] && [ -n "$CHANGED_SRC_FILES" ]; then
1794
- CHANGED_SRC_FILES=$(echo "$CHANGED_SRC_FILES" | sed "s|^${PROJECT_SUBDIR}/||")
1894
+ if [ -n "$PROJECT_SUBDIR" ] && [ "$PROJECT_SUBDIR" != "." ] && [ -n "$SELECTED_SRC_FILES" ]; then
1895
+ SELECTED_SRC_FILES=$(echo "$SELECTED_SRC_FILES" | sed "s|^${PROJECT_SUBDIR}/||")
1795
1896
  fi
1796
- CHANGED_SRC_COUNT=$(echo "$CHANGED_SRC_FILES" | grep -c '.' || echo "0")
1897
+ CHANGED_SRC_COUNT=$(echo "$SELECTED_SRC_FILES" | grep -c '.' || true)
1797
1898
 
1798
1899
  # Decide strategy
1799
1900
  if [ "$CHANGED_TEST_FILE_COUNT" -gt 20 ] 2>/dev/null; then
@@ -1803,24 +1904,24 @@ else
1803
1904
  TESTS_EXIT_CODE=$?
1804
1905
  echo "$TESTS_OUTPUT" | head -60
1805
1906
  if [ "$TESTS_EXIT_CODE" -ne 0 ]; then
1806
- echo ""
1807
- echo "❌ BLOCKED - Tests FAILED"
1808
- exit 1
1907
+ handle_test_failure "$TESTS_OUTPUT" "full test suite" || exit 1
1809
1908
  else
1810
1909
  echo "✅ PASSED - Full test suite passed (with coverage)."
1811
1910
  fi
1812
1911
  elif [ "$CHANGED_TEST_FILE_COUNT" -gt 0 ] 2>/dev/null; then
1813
1912
  # Run only changed test files with coverage
1814
- SMART_TARGETS=$(echo "$CHANGED_TEST_FILES" | tr '\n' ' ')
1913
+ PARTIAL_TEST_RUN=true # Mark as partial run for coverage check
1914
+ SMART_TARGETS=$(echo "$SELECTED_TEST_FILES" | tr '\n' ' ')
1815
1915
  echo "Running ${CHANGED_TEST_FILE_COUNT} changed test file(s) with coverage..."
1816
- echo " Target(s): $(echo "$CHANGED_TEST_FILES" | tr '\n' ' ')"
1916
+ echo " Target(s): $(echo "$SELECTED_TEST_FILES" | tr '\n' ' ')"
1817
1917
  TESTS_OUTPUT=$(run_without_git_context npx vitest run --coverage $SMART_TARGETS 2>&1)
1818
1918
  TESTS_EXIT_CODE=$?
1819
1919
  echo "$TESTS_OUTPUT" | head -60
1820
1920
  if [ "$TESTS_EXIT_CODE" -ne 0 ]; then
1821
- echo ""
1822
- echo "❌ BLOCKED - Tests FAILED in changed test files"
1823
- exit 1
1921
+ # Distinguish a real test failure from a runner infrastructure
1922
+ # error -- see #454. handle_test_failure routes both cases and
1923
+ # keeps the message consistent across every Gate 5 branch.
1924
+ handle_test_failure "$TESTS_OUTPUT" "changed test files" || exit 1
1824
1925
  else
1825
1926
  echo "✅ PASSED - Changed test files passed (with coverage)."
1826
1927
  fi
@@ -1828,7 +1929,7 @@ else
1828
1929
  # Source files changed but no test files → find related tests or run full
1829
1930
  echo "Source file(s) changed, finding related tests..."
1830
1931
  RELATED_TESTS=""
1831
- for src_file in $CHANGED_SRC_FILES; do
1932
+ for src_file in $SELECTED_SRC_FILES; do
1832
1933
  base="${src_file%.*}"
1833
1934
  filename="${base##*/}"
1834
1935
  dir="$(dirname "$src_file")"
@@ -1862,9 +1963,7 @@ else
1862
1963
  TESTS_EXIT_CODE=$?
1863
1964
  echo "$TESTS_OUTPUT" | head -60
1864
1965
  if [ "$TESTS_EXIT_CODE" -ne 0 ]; then
1865
- echo ""
1866
- echo "❌ BLOCKED - Tests FAILED in related test files"
1867
- exit 1
1966
+ handle_test_failure "$TESTS_OUTPUT" "related test files" || exit 1
1868
1967
  else
1869
1968
  echo "✅ PASSED - Related test files passed (with coverage)."
1870
1969
  fi
@@ -1875,9 +1974,7 @@ else
1875
1974
  TESTS_EXIT_CODE=$?
1876
1975
  echo "$TESTS_OUTPUT" | head -60
1877
1976
  if [ "$TESTS_EXIT_CODE" -ne 0 ]; then
1878
- echo ""
1879
- echo "❌ BLOCKED - Tests FAILED"
1880
- exit 1
1977
+ handle_test_failure "$TESTS_OUTPUT" "full test suite" || exit 1
1881
1978
  else
1882
1979
  echo "✅ PASSED - Full test suite passed (with coverage)."
1883
1980
  fi
@@ -1894,13 +1991,22 @@ else
1894
1991
  TESTS_EXIT_CODE=$?
1895
1992
  echo "$TESTS_OUTPUT" | head -50
1896
1993
  if [ "$TESTS_EXIT_CODE" -ne 0 ]; then
1897
- echo ""
1898
- echo "❌ BLOCKED - Tests FAILED"
1899
- exit 1
1994
+ # This branch is reached from the typescript adapter, so run_tests ran
1995
+ # vitest (adapters/typescript.sh) and its output is vitest-shaped: the
1996
+ # same three non-failure exits (#454 runner EPERM, #473 partial
1997
+ # threshold, #498 no test files) apply here as in the four branches
1998
+ # above. #454 left it unguarded because its own comment covers the
1999
+ # NON-typescript fallback below, whose runner is pytest/go/jest; this
2000
+ # one is not that branch.
2001
+ handle_test_failure "$TESTS_OUTPUT" "typescript adapter fallback" || exit 1
1900
2002
  fi
1901
2003
  fi
1902
2004
  else
1903
- # Non-TypeScript: standard adapter test flow
2005
+ # Adapter test flow for everything the TypeScript branch above did not
2006
+ # claim. The label this branch carried (#454) was "Non-TypeScript", which
2007
+ # is not what reaches it: a TypeScript project without a package.json also
2008
+ # lands here, and its adapter runs vitest — which is how a test-less TS
2009
+ # repo got false-blocked on "No test files found" (#498).
1904
2010
  # Skip for Python — tests already run with coverage in the coverage section below.
1905
2011
  # Running them here first without coverage doubles pre-commit time for no benefit.
1906
2012
  if [ "$CURRENT_LANG" = "python" ]; then
@@ -1910,9 +2016,18 @@ else
1910
2016
  TESTS_EXIT_CODE=$?
1911
2017
  echo "$TESTS_OUTPUT" | head -50
1912
2018
  if [ "$TESTS_EXIT_CODE" -ne 0 ]; then
1913
- echo ""
1914
- echo "❌ BLOCKED - Tests FAILED"
1915
- exit 1
2019
+ if [ "$CURRENT_LANG" = "typescript" ]; then
2020
+ # vitest output, so the same three non-failure exits are judged
2021
+ # here as in the branches above (#454, #473, #498).
2022
+ handle_test_failure "$TESTS_OUTPUT" "typescript adapter (no package.json)" || exit 1
2023
+ else
2024
+ # Genuinely non-vitest runners (pytest/go/jest/...): their output
2025
+ # shape is not vitest's, so the guard could excuse a real failure.
2026
+ # They keep blocking on any non-zero exit (#454).
2027
+ echo ""
2028
+ echo "❌ BLOCKED - Tests FAILED"
2029
+ exit 1
2030
+ fi
1916
2031
  else
1917
2032
  echo "✅ PASSED - Unit tests passed."
1918
2033
  fi
@@ -2031,8 +2146,9 @@ else
2031
2146
  " 2>/dev/null)
2032
2147
  if [ "$COVERAGE_PERCENT" != "parse_error" ] && [ -n "$COVERAGE_PERCENT" ]; then
2033
2148
  if [ "$COVERAGE_PERCENT" -lt 80 ]; then
2034
- # If only subset of tests ran (partial run or related tests), warn instead of block
2035
- if [ "${PARTIAL_TEST_RUN:-false}" = "true" ] || { [ -n "${CHANGED_TEST_FILE_COUNT:-}" ] && [ "${CHANGED_TEST_FILE_COUNT:-0}" -gt 0 ] 2>/dev/null; }; then
2149
+ # Only a subset test run (smart selection) leaves partial coverage data;
2150
+ # a full suite run must block on <80% even when many test files changed.
2151
+ if [ "${PARTIAL_TEST_RUN:-false}" = "true" ]; then
2036
2152
  echo "⚠️ Partial coverage ${COVERAGE_PERCENT}% (subset test run, not full suite)."
2037
2153
  echo " Run 'npx vitest run --coverage' for full coverage check."
2038
2154
  else
@@ -2072,6 +2188,64 @@ else
2072
2188
  echo "⚠️ coverage.py not available, cannot enforce Python coverage threshold."
2073
2189
  fi
2074
2190
  ;;
2191
+ "java")
2192
+ COVERAGE_ENFORCED=true
2193
+ # JaCoCo writes target/site/jacoco/ (Maven) or build/reports/jacoco/test/
2194
+ # (Gradle), i.e. neither path probed by the default branch below, so Java
2195
+ # needs its own branch to be measurable at all.
2196
+ jacoco_csv=""
2197
+ for jacoco_candidate in \
2198
+ "target/site/jacoco/jacoco.csv" \
2199
+ "build/reports/jacoco/test/jacocoTestReport.csv"; do
2200
+ [ -f "$jacoco_candidate" ] && { jacoco_csv="$jacoco_candidate"; break; }
2201
+ done
2202
+
2203
+ if [ -n "$jacoco_csv" ]; then
2204
+ # Aggregate INSTRUCTION_MISSED / INSTRUCTION_COVERED over every class row.
2205
+ # Columns are located by header name because their position is not part of
2206
+ # the CSV's stable contract.
2207
+ COVERAGE_PERCENT=$(awk -F, '
2208
+ NR == 1 {
2209
+ for (i = 1; i <= NF; i++) {
2210
+ if ($i == "INSTRUCTION_MISSED") missed_col = i;
2211
+ if ($i == "INSTRUCTION_COVERED") covered_col = i;
2212
+ }
2213
+ next;
2214
+ }
2215
+ missed_col && covered_col {
2216
+ missed += $missed_col;
2217
+ covered += $covered_col;
2218
+ }
2219
+ END {
2220
+ total = missed + covered;
2221
+ if (total > 0) printf "%.2f", (covered / total) * 100;
2222
+ else print "";
2223
+ }
2224
+ ' "$jacoco_csv" 2>/dev/null) || COVERAGE_PERCENT=""
2225
+
2226
+ if [ -n "$COVERAGE_PERCENT" ]; then
2227
+ # Compare on the unrounded value so 79.5% cannot pass as 80%.
2228
+ COVERAGE_ROUNDED=${COVERAGE_PERCENT%.*}
2229
+ if awk -v pct="$COVERAGE_PERCENT" 'BEGIN { exit !(pct < 80) }'; then
2230
+ if [ "${PARTIAL_TEST_RUN:-false}" = "true" ]; then
2231
+ echo "⚠️ Partial coverage ${COVERAGE_ROUNDED}% (subset test run, not full suite)."
2232
+ echo " Run the full test suite for a complete Java coverage check."
2233
+ else
2234
+ echo "❌ BLOCKED - Java coverage ${COVERAGE_ROUNDED}% below 80% threshold"
2235
+ exit 1
2236
+ fi
2237
+ else
2238
+ echo "Java coverage: ${COVERAGE_ROUNDED}% ✅ (≥ 80%)"
2239
+ fi
2240
+ else
2241
+ echo "⚠️ Could not parse Java coverage from ${jacoco_csv} (no instruction data)."
2242
+ fi
2243
+ else
2244
+ echo "⚠️ No JaCoCo report found. Run 'mvn verify' or 'gradle jacocoTestReport'"
2245
+ echo " so that target/site/jacoco/jacoco.csv exists — otherwise Java coverage"
2246
+ echo " cannot be enforced (see also: JaCoCo detection in adapters/java.sh)."
2247
+ fi
2248
+ ;;
2075
2249
  "go")
2076
2250
  # Go enforcement already handled inline in Stage 1 (lines 933-942)
2077
2251
  ;;
@@ -2134,6 +2308,10 @@ else
2134
2308
  echo "⚠️ coverage.xml not found. Ensure Pester coverage was run."
2135
2309
  fi
2136
2310
  ;;
2311
+ "iac")
2312
+ # IaC has no coverage concept — must not borrow the TypeScript report
2313
+ # that Stage 1 leaves behind (same rule as the PowerShell branch).
2314
+ ;;
2137
2315
  *)
2138
2316
  # Default: attempt to parse coverage from common formats regardless of language
2139
2317
  COVERAGE_ENFORCED=true
@@ -2157,10 +2335,18 @@ else
2157
2335
  fi
2158
2336
  if [ -n "$COVERAGE_PERCENT" ] && [ "$COVERAGE_PERCENT" != "parse_error" ] && [ "$COVERAGE_PERCENT" -gt 0 ]; then
2159
2337
  if [ "$COVERAGE_PERCENT" -lt 80 ]; then
2160
- echo "❌ BLOCKED - ${CURRENT_LANG} coverage ${COVERAGE_PERCENT}% below 80% threshold"
2161
- exit 1
2338
+ # Only a subset test run (smart selection) warns — the full suite may
2339
+ # still reach ≥80%; a full suite run must block.
2340
+ if [ "${PARTIAL_TEST_RUN:-false}" = "true" ]; then
2341
+ echo "⚠️ Partial coverage ${COVERAGE_PERCENT}% (subset test run, not full suite)."
2342
+ echo " Run the full test suite for a complete ${CURRENT_LANG} coverage check."
2343
+ else
2344
+ echo "❌ BLOCKED - ${CURRENT_LANG} coverage ${COVERAGE_PERCENT}% below 80% threshold"
2345
+ exit 1
2346
+ fi
2347
+ else
2348
+ echo "${CURRENT_LANG} coverage: ${COVERAGE_PERCENT}% ✅ (≥ 80%)"
2162
2349
  fi
2163
- echo "${CURRENT_LANG} coverage: ${COVERAGE_PERCENT}% ✅ (≥ 80%)"
2164
2350
  else
2165
2351
  echo "⚠️ Could not determine ${CURRENT_LANG} coverage percentage. No standard coverage report files found."
2166
2352
  fi
@@ -2301,10 +2487,20 @@ else
2301
2487
  fi
2302
2488
  ;;
2303
2489
  "python")
2304
- if [ -f ".import-linter.yml" ] || [ -f "import_linter_config.yml" ]; then
2490
+ # detect any import-linter carrier (incl. pyproject.toml [tool.importlinter])
2491
+ if [ -f "$PROJECT_ROOT/.import-linter.yml" ] || [ -f "$PROJECT_ROOT/import_linter_config.yml" ] || [ -f "$PROJECT_ROOT/.importlinter" ] || grep -q '^\[tool\.importlinter\]' "$PROJECT_ROOT/pyproject.toml" 2>/dev/null; then
2305
2492
  if require_tool "lint-imports" "Gate 6" "pip install import-linter"; then
2306
2493
  echo " Running import-linter for Python..."
2307
- lint-imports 2>&1 | head -20
2494
+ # run at PROJECT_ROOT (hook may have cd'd); block on exit code
2495
+ LINT_IMPORTS_OUTPUT=$(cd "$PROJECT_ROOT" && lint-imports 2>&1)
2496
+ LINT_IMPORTS_EXIT=$?
2497
+ echo "$LINT_IMPORTS_OUTPUT" | head -30
2498
+ if [ "$LINT_IMPORTS_EXIT" -ne 0 ]; then
2499
+ echo ""
2500
+ echo "❌ BLOCKED - Python architecture contract violations (import-linter)"
2501
+ echo "Fix the broken contracts above before committing."
2502
+ exit 1
2503
+ fi
2308
2504
  echo " ✅ Python architecture validation completed."
2309
2505
  else
2310
2506
  echo " ❌ BLOCKED - import-linter not installed"
@@ -2312,7 +2508,7 @@ else
2312
2508
  exit 1
2313
2509
  fi
2314
2510
  else
2315
- echo " ℹ️ No .import-linter.yml found - skipping Python architecture"
2511
+ echo " ℹ️ No import-linter config found (.import-linter.yml/.importlinter/[tool.importlinter]) - skipping"
2316
2512
  echo " ⏭️ SKIPPED - Python architecture validation (no config)"
2317
2513
  fi
2318
2514
  ;;
@@ -2402,20 +2598,30 @@ else
2402
2598
  if [ -n "$BOY_SCOUT_SCRIPT" ]; then
2403
2599
  echo " Checking Boy Scout Rule compliance..."
2404
2600
 
2405
- # Separate new files and modified files
2406
- NEW_FILES=$(git diff --cached --name-only --diff-filter=A | tr '\n' ' ')
2407
- MODIFIED_FILES=$(git diff --cached --name-only --diff-filter=M | tr '\n' ' ')
2601
+ # Separate new files and modified files.
2602
+ #
2603
+ # src/npm-package/** is a byte-identical MIRROR of canonical sources
2604
+ # (plugins/**, src/principles/**, githooks/**) produced by
2605
+ # sync-package-content.js at publish time. Checking it here double-counts
2606
+ # every warning: the mirror path enters the baseline instead of the source
2607
+ # path, so a violation gets recorded against a path nobody edits and the
2608
+ # real source path stays unbudgeted. Exclude it and let the mirroring be
2609
+ # governed by Mirror Parity in CI, which compares content rather than
2610
+ # re-linting it. (See the same exclusion in adapters/powershell.sh.)
2611
+ MIRROR_EXCLUDE='^src/npm-package/'
2612
+ NEW_FILES=$(git diff --cached --name-only --diff-filter=A | grep -v "$MIRROR_EXCLUDE" | tr '\n' ' ')
2613
+ MODIFIED_FILES=$(git diff --cached --name-only --diff-filter=M | grep -v "$MIRROR_EXCLUDE" | tr '\n' ' ')
2408
2614
 
2409
2615
  if [ -z "$NEW_FILES" ] && [ -z "$MODIFIED_FILES" ]; then
2410
2616
  echo " ✅ No new or modified files to check."
2411
2617
  else
2412
2618
  # Run Boy Scout Rule enforcement
2413
- if command -v npx &> /dev/null; then
2619
+ if command -v npx &> /dev/null || [ -f "${PROJECT_ROOT:-$(pwd)}/node_modules/tsx/dist/cli.mjs" ]; then
2414
2620
  # Skip if no files to check
2415
2621
  if [ -z "$(echo "$NEW_FILES" | tr -d ' ')" ] && [ -z "$(echo "$MODIFIED_FILES" | tr -d ' ')" ]; then
2416
2622
  echo " ✅ No new or modified source files for Boy Scout check."
2417
2623
  else
2418
- BOY_SCOUT_OUTPUT=$(npx tsx $BOY_SCOUT_SCRIPT \
2624
+ BOY_SCOUT_OUTPUT=$(run_tsx "$BOY_SCOUT_SCRIPT" \
2419
2625
  --new-files "$(echo "$NEW_FILES" | tr ' ' ',')" \
2420
2626
  --modified-files "$(echo "$MODIFIED_FILES" | tr ' ' ',')" \
2421
2627
  --baseline ".warnings-baseline.json" 2>&1)
@@ -2446,8 +2652,8 @@ else
2446
2652
  echo " ✅ PASSED - Boy Scout Rule compliance."
2447
2653
  fi
2448
2654
  else
2449
- echo " ℹ️ npx not available - skipping Boy Scout Rule"
2450
- echo " ⏭️ SKIPPED - Boy Scout Rule (Node.js/npx not available)"
2655
+ echo " ℹ️ npx/tsx not available - skipping Boy Scout Rule"
2656
+ echo " ⏭️ SKIPPED - Boy Scout Rule (Node.js/tsx not available)"
2451
2657
  fi
2452
2658
  fi
2453
2659
  else
@@ -2503,7 +2709,7 @@ if [ -f "package.json" ] && [ -f "tsconfig.json" ]; then
2503
2709
  if [ -n "$GATE_9_SCRIPT" ]; then
2504
2710
  CHANGED_FILES_CSV=$(echo "$CHANGED_FILES" | tr ' ' ',')
2505
2711
  GATE_9_OUTPUT=$(mktemp)
2506
- if timeout 120s npx tsx "$GATE_9_SCRIPT" --changed-files "$CHANGED_FILES_CSV" --project-root "$PROJECT_ROOT" --timeout 115000 > "$GATE_9_OUTPUT" 2>&1; then
2712
+ if RUN_TSX_TIMEOUT=120s run_tsx "$GATE_9_SCRIPT" --changed-files "$CHANGED_FILES_CSV" --project-root "$PROJECT_ROOT" --timeout 115000 > "$GATE_9_OUTPUT" 2>&1; then
2507
2713
  GATE_9_STATUS="PASS"
2508
2714
  echo " ✅ Build integrity check passed."
2509
2715
  else
@@ -2531,8 +2737,9 @@ else
2531
2737
  echo " ⏭️ SKIPPED - Build integrity (not a TypeScript project)"
2532
2738
  fi
2533
2739
 
2534
- GATE_9_END=$(date +%s)
2535
- GATE_9_DURATION=$((GATE_9_END - GATE_9_START))
2740
+ GATE_9_END=$(gate_start_ms)
2741
+ GATE_9_DURATION_MS=$((GATE_9_END - GATE_9_START))
2742
+ GATE_9_DURATION=$((GATE_9_DURATION_MS / 1000))
2536
2743
  echo "✅ Gate 9 completed in ${GATE_9_DURATION}s"
2537
2744
  record_gate_audit "gate-9" "build-integrity" "$GATE_9_STATUS" "0" "$GATE_9_START"
2538
2745
  echo ""
@@ -2565,29 +2772,66 @@ fi
2565
2772
  GATE_11_START=$(date +%s)
2566
2773
  GATE_11_STATUS="PASS"
2567
2774
 
2568
- SPRINT_GATE_SCRIPT=""
2569
- if [ -f "$GATE_DIR/sprint-gate.sh" ]; then
2570
- SPRINT_GATE_SCRIPT="$GATE_DIR/sprint-gate.sh"
2571
- elif [ -f "$(git rev-parse --show-toplevel 2>/dev/null)/githooks/sprint-gate.sh" ]; then
2572
- SPRINT_GATE_SCRIPT="$(git rev-parse --show-toplevel)/githooks/sprint-gate.sh"
2573
- fi
2775
+ # BEGIN SPRINT_GATE_SCRIPT RESOLUTION
2776
+ # Every tier is a *directory*; the resolver returns the first one that actually holds
2777
+ # sprint-gate.sh. The chain used to stop at the gate-script directories, but the flat
2778
+ # global layout ($GATE_DIR -> ~/.config/xp-gate/adapters) carries gate-3/4/7/8/9/10.sh
2779
+ # and never this script: update-hooks installs sprint-gate.sh under
2780
+ # ~/.config/xp-gate/hooks, which nothing probed, so a correctly installed machine was
2781
+ # told the file was missing (#476).
2782
+ SPRINT_GATE_CANDIDATES=(
2783
+ "$GATE_DIR"
2784
+ "$(git rev-parse --show-toplevel 2>/dev/null)/githooks"
2785
+ "$SCRIPT_DIR"
2786
+ "$HOME/.config/xp-gate/hooks"
2787
+ )
2788
+ SPRINT_GATE_SCRIPT=$(resolve_sprint_gate_script ${SPRINT_GATE_CANDIDATES[@]+"${SPRINT_GATE_CANDIDATES[@]}"})
2789
+ # END SPRINT_GATE_SCRIPT RESOLUTION
2790
+
2791
+ echo "→ Gate 11: Sprint Flow Enforcement..."
2574
2792
 
2575
2793
  if [ -n "$SPRINT_GATE_SCRIPT" ]; then
2576
- if ! bash "$SPRINT_GATE_SCRIPT" --pre-commit; then
2794
+ # The decision JSON was piped straight to the terminal with no heading, so it landed
2795
+ # under Gate 10's banner and a legitimate "skip: not a sprint project" looked like a
2796
+ # bare machine object. The exit status stays the authority for blocking; the JSON only
2797
+ # decides which words the user reads.
2798
+ SPRINT_GATE_EXIT=0
2799
+ SPRINT_GATE_OUTPUT=$(bash "$SPRINT_GATE_SCRIPT" --pre-commit 2>&1) || SPRINT_GATE_EXIT=$?
2800
+ SPRINT_GATE_DECISION=$(sprint_gate_decision "$SPRINT_GATE_OUTPUT")
2801
+ SPRINT_GATE_DETAIL=$(sprint_gate_detail "$SPRINT_GATE_OUTPUT")
2802
+
2803
+ if [ "$SPRINT_GATE_EXIT" -ne 0 ]; then
2577
2804
  GATE_11_STATUS="BLOCK"
2578
- echo "❌ BLOCKED - Gate 11: Sprint Flow Enforcement"
2805
+ echo "❌ BLOCKED - Gate 11: Sprint Flow (${SPRINT_GATE_DETAIL:-sprint gate denied})"
2806
+ printf '%s\n' "$SPRINT_GATE_OUTPUT" | sed 's/^/ /'
2579
2807
  record_gate_audit "gate-11" "sprint-flow" "$GATE_11_STATUS" "1" "$GATE_11_START"
2580
2808
  # Sprint gate failure is a hard block — exit immediately
2581
2809
  exit 1
2582
2810
  fi
2811
+
2812
+ case "$SPRINT_GATE_DECISION" in
2813
+ skip)
2814
+ echo "⏭️ SKIPPED - Gate 11: Sprint Flow (${SPRINT_GATE_DETAIL:-no reason given})"
2815
+ GATE_11_STATUS="SKIP"
2816
+ ;;
2817
+ allow)
2818
+ echo "✅ PASSED - Gate 11: Sprint Flow (${SPRINT_GATE_DETAIL:-sprint state validated})"
2819
+ ;;
2820
+ *)
2821
+ # A run that succeeded but said nothing this gate understands. Pass on the exit
2822
+ # status, but keep the raw output visible rather than inventing a verdict.
2823
+ echo "✅ PASSED - Gate 11: Sprint Flow (no recognisable decision in the gate output)"
2824
+ printf '%s\n' "$SPRINT_GATE_OUTPUT" | sed 's/^/ /'
2825
+ ;;
2826
+ esac
2583
2827
  else
2584
- echo "⏭️ SKIPPED - Gate 11: Sprint Flow (sprint-gate.sh not found)"
2828
+ echo "⏭️ SKIPPED - Gate 11: Sprint Flow (sprint-gate.sh not found; searched: $(IFS=', '; printf '%s' "${SPRINT_GATE_CANDIDATES[*]}"))"
2585
2829
  GATE_11_STATUS="SKIP"
2586
2830
  fi
2587
2831
 
2588
2832
  GATE_11_END=$(date +%s)
2589
2833
  GATE_11_DURATION=$((GATE_11_END - GATE_11_START))
2590
- echo "✅ Gate 11 completed in ${GATE_11_DURATION}s"
2834
+ echo " Gate 11 ($GATE_11_STATUS) completed in ${GATE_11_DURATION}s"
2591
2835
  record_gate_audit "gate-11" "sprint-flow" "$GATE_11_STATUS" "0" "$GATE_11_START"
2592
2836
  echo ""
2593
2837
  # ============================================================================
@@ -2647,8 +2891,10 @@ generate_quality_report() {
2647
2891
  if [ -n "$TESTS_OUTPUT" ] && echo "$TESTS_OUTPUT" | grep -qi "fail\|error\|FAILED"; then
2648
2892
  # Extract failure lines: "FAIL src/foo.test.ts" or " ✗ test name"
2649
2893
  local FAIL_LINES
2650
- FAIL_LINES=$(echo "$TESTS_OUTPUT" | grep -E "^\s*(FAIL|✗|failed|AssertionError)" | head -10 | \
2651
- sed 's/"/\\"/g' | awk '{ printf "%s%s%s", sep, "\"", $0; sep="," }')
2894
+ # errexit/pipefail-safe: grep returns 1 on zero matches (see #489) and this
2895
+ # function is also sourced by consumer wrappers that may enable errexit
2896
+ FAIL_LINES=$(echo "$TESTS_OUTPUT" | grep -E "^\s*(FAIL|✗|failed|AssertionError)" | sed -n '1,10p' | \
2897
+ sed 's/"/\\"/g' | awk '{ printf "%s%s%s", sep, "\"", $0; sep="," }') || true
2652
2898
  if [ -n "$FAIL_LINES" ]; then
2653
2899
  FAILED_TESTS_JSON="[$FAIL_LINES]"
2654
2900
  fi