@boyingliu01/xp-gate 0.19.3 → 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 (173) 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 +389 -162
  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 +1 -1
  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 +1 -1
  116. package/plugins/qoder/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
  117. package/plugins/qoder/skills/delphi-review/AGENTS.md +4 -4
  118. package/plugins/qoder/skills/delphi-review/SKILL.md +98 -14
  119. package/plugins/qoder/skills/delphi-review/references/code-walkthrough.md +36 -3
  120. package/plugins/qoder/skills/delphi-review/references/requirements.md +6 -6
  121. package/plugins/qoder/skills/sprint-flow/AGENTS.md +4 -4
  122. package/plugins/qoder/skills/sprint-flow/references/phase-2-design.md +3 -3
  123. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +4 -4
  124. package/principles/AGENTS.md +9 -8
  125. package/principles/__tests__/adapter-metrics.test.ts +292 -0
  126. package/principles/__tests__/analyzer.test.ts +29 -6
  127. package/principles/__tests__/baseline-prune-timeout.test.ts +71 -0
  128. package/principles/__tests__/baseline-prune.test.ts +338 -0
  129. package/principles/__tests__/boy-scout-init-baseline.test.ts +167 -0
  130. package/principles/__tests__/boy-scout-integration.test.ts +97 -6
  131. package/principles/__tests__/boy-scout.test.ts +253 -7
  132. package/principles/__tests__/config-enforcement.test.ts +469 -0
  133. package/principles/__tests__/config-git-timeout.test.ts +66 -0
  134. package/principles/__tests__/direct-execution.test.ts +187 -0
  135. package/principles/__tests__/index.test.ts +16 -3
  136. package/principles/__tests__/warnings-baseline-budgets.test.ts +74 -0
  137. package/principles/adapters/typescript.ts +613 -13
  138. package/principles/analyzer.ts +22 -0
  139. package/principles/baseline-prune.ts +217 -0
  140. package/principles/boy-scout.ts +481 -51
  141. package/principles/config.ts +362 -54
  142. package/principles/direct-execution.ts +60 -0
  143. package/principles/index.ts +40 -17
  144. package/principles/rules/__tests__/clean-code/many-exports.test.ts +16 -10
  145. package/principles/rules/clean-code/code-duplication.ts +8 -8
  146. package/principles/rules/clean-code/deep-nesting.ts +8 -8
  147. package/principles/rules/clean-code/god-class.ts +8 -8
  148. package/principles/rules/clean-code/large-file.ts +15 -11
  149. package/principles/rules/clean-code/long-function.ts +8 -8
  150. package/principles/rules/clean-code/magic-numbers.ts +10 -7
  151. package/principles/rules/clean-code/many-exports.ts +7 -7
  152. package/principles/rules/clean-code/missing-error-handling.ts +5 -5
  153. package/principles/rules/clean-code/too-many-params.ts +8 -8
  154. package/principles/rules/clean-code/unused-imports.ts +5 -5
  155. package/principles/rules/solid/dip.ts +18 -8
  156. package/principles/rules/solid/isp.ts +9 -8
  157. package/principles/rules/solid/lsp.ts +6 -6
  158. package/principles/rules/solid/ocp.ts +6 -5
  159. package/principles/rules/solid/srp.ts +7 -8
  160. package/principles/types.ts +15 -0
  161. package/scripts/__tests__/sync-package-content.test.js +28 -0
  162. package/scripts/delphi-external-review.cjs +193 -10
  163. package/scripts/sync-package-content.js +7 -4
  164. package/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
  165. package/skills/delphi-review/AGENTS.md +4 -4
  166. package/skills/delphi-review/SKILL.md +98 -14
  167. package/skills/delphi-review/references/code-walkthrough.md +36 -3
  168. package/skills/delphi-review/references/requirements.md +6 -6
  169. package/skills/sprint-flow/AGENTS.md +4 -4
  170. package/skills/sprint-flow/references/phase-2-design.md +3 -3
  171. package/skills/test-specification-alignment/AGENTS.md +4 -4
  172. package/lib/__tests__/download-skill.test.js +0 -281
  173. package/lib/download-skill.js +0 -96
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,14 +1024,20 @@ else
1013
1024
  else
1014
1025
  # Run Ruff (includes syntax check, linting)
1015
1026
  echo "Running Ruff linting (syntax + lint)..."
1016
- # Filter to *.py (unquoted) like the format/debug gates below; skip ruff
1017
- # when no Python files are staged so yaml/.gitignore are never parsed.
1018
- PY_LINT_FILES=$(echo "$CHANGED_FILES" | grep '\.py$' || true)
1019
- if [ -z "$PY_LINT_FILES" ]; then
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
1020
1037
  RUFF_OUTPUT="[]"
1021
1038
  RUFF_EXIT=0
1022
1039
  else
1023
- RUFF_OUTPUT=$(ruff check $PY_LINT_FILES --output-format json 2>&1)
1040
+ RUFF_OUTPUT=$(ruff check "${RUFF_ARGS[@]}" --output-format json 2>&1)
1024
1041
  RUFF_EXIT=$?
1025
1042
  fi
1026
1043
  if [ "$RUFF_EXIT" -ne 0 ]; then
@@ -1078,9 +1095,14 @@ else
1078
1095
 
1079
1096
  # Ruff format check (formatting gate)
1080
1097
  if command -v ruff &> /dev/null; then
1081
- PY_FMT_FILES=$(echo "$CHANGED_FILES" | grep '\.py$' || true)
1082
- if [ -n "$PY_FMT_FILES" ]; then
1083
- 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)
1084
1106
  RUFF_FMT_EXIT=$?
1085
1107
  if [ "$RUFF_FMT_EXIT" -ne 0 ]; then
1086
1108
  echo "$RUFF_FMT_OUTPUT" | head -20
@@ -1094,9 +1116,14 @@ else
1094
1116
  fi
1095
1117
 
1096
1118
  # Debug statement detection (breakpoint/pdb/ipdb)
1097
- PY_DEBUG_FILES=$(echo "$CHANGED_FILES" | grep '\.py$' || true)
1098
- if [ -n "$PY_DEBUG_FILES" ]; then
1099
- 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)
1100
1127
  if [ -n "$DEBUG_HITS" ]; then
1101
1128
  echo ""
1102
1129
  echo "❌ BLOCKED - Debug statements detected in Python files:"
@@ -1109,12 +1136,17 @@ else
1109
1136
 
1110
1137
  # Optional: Type checking with mypy
1111
1138
  if command -v mypy &> /dev/null; then
1112
- PY_MYPY_FILES=$(echo "$CHANGED_FILES" | grep "\.py$" || true)
1113
- if [ -z "$PY_MYPY_FILES" ]; then
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
1114
1146
  MYPY_EXIT=0
1115
1147
  else
1116
1148
  echo "Running mypy type checking (optional)..."
1117
- mypy --ignore-missing-imports $PY_MYPY_FILES 2>&1 | head -20 | tail -10
1149
+ mypy --ignore-missing-imports "${MYPY_ARGS[@]}" 2>&1 | head -20 | tail -10
1118
1150
  MYPY_EXIT=$?
1119
1151
  fi
1120
1152
  if [ "$MYPY_EXIT" -ne 0 ]; then
@@ -1266,6 +1298,7 @@ record_gate_audit "gate-1" "code-quality" "$GATE_1_STATUS" "0" "$GATE_1_START"
1266
1298
  2>&1 echo ""
1267
1299
  2>&1 echo "→ Gate 2: Duplicate code detection..."
1268
1300
  GATE_2_START=$(gate_start_ms)
1301
+ GATE_2_STATUS="PASS"
1269
1302
 
1270
1303
  if [ "$PROJECT_LANG" = "documentation-only" ]; then
1271
1304
  echo "⏭️ SKIPPED - Duplicate code (documentation project)."
@@ -1280,13 +1313,34 @@ else
1280
1313
  fi
1281
1314
 
1282
1315
  echo "Running jscpd for duplicate code detection..."
1283
- jscpd --config jscpd.conf.json $(echo "$CHANGED_FILES" | tr '\n' ' ') 2>&1 | head -30
1284
- JSCPD_EXIT=$?
1285
- 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
1286
1335
  echo "ℹ️ jscpd found duplicated code (warning, not blocking by default)"
1287
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."
1288
1343
  fi
1289
- echo "✅ PASSED - jscpd duplicate code check completed."
1290
1344
  ;;
1291
1345
 
1292
1346
  python)
@@ -1344,7 +1398,8 @@ else
1344
1398
  esac
1345
1399
  done
1346
1400
  fi
1347
- 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.
1348
1403
  record_gate_audit "gate-2" "duplicate-code" "$GATE_2_STATUS" "0" "$GATE_2_START"
1349
1404
 
1350
1405
  # ============================================================================
@@ -1405,7 +1460,7 @@ else
1405
1460
 
1406
1461
  SKIP_5A_BLOCK=false
1407
1462
  if [ "${SKIP_GATE_5A_BLOCK:-0}" = "1" ]; then
1408
- 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")
1409
1464
  if [ "$CURRENT_BRANCH" != "main" ] && [ "$CURRENT_BRANCH" != "master" ]; then
1410
1465
  REASON="${SKIP_GATE_5A_BLOCK_REASON:-not specified}"
1411
1466
  mkdir -p .xp-gate/reports
@@ -1630,7 +1685,15 @@ else
1630
1685
  # Gate 5b: Mock Density ADVISORY Scan (pure bash — no npx tsx overhead)
1631
1686
  # 30% = ADVISORY, 50% = suggests @mock-justified. Does NOT block commit.
1632
1687
  # ========================================================================
1633
- 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)
1634
1697
 
1635
1698
  # Strip PROJECT_SUBDIR prefix from test file paths when running in subdirectory.
1636
1699
  # git diff returns repo-root relative paths, but after cd "$PROJECT_SUBDIR" (line 728)
@@ -1714,7 +1777,7 @@ else
1714
1777
  echo "⚠️ Gate 5c: req-ids.json is stale (specification.yaml updated since last check-alignment)"
1715
1778
  echo " Run 'xp-gate check-alignment' to regenerate before next commit."
1716
1779
  else
1717
- 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)
1718
1781
  if [ -n "$CHANGED_TEST_FILES_ANNOTATED" ]; then
1719
1782
  echo " → Checking @test REQ-XXX annotations..."
1720
1783
  ANNOTATION_ERRORS=0
@@ -1743,7 +1806,7 @@ else
1743
1806
  # Check reverse: any REQ in spec with no annotated test → visible FAIL (non-blocking)
1744
1807
  ALL_SPEC_REQS=$(jq -r '.[]' "$REQ_IDS_FILE" 2>/dev/null)
1745
1808
  ANNOTATED_ALL=""
1746
- 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
1747
1810
  if [ -f "$tf" ]; then
1748
1811
  ANNOTATED_ALL="$ANNOTATED_ALL $(grep -oE '@test\s+REQ-[A-Z0-9-]+' "$tf" 2>/dev/null | sed 's/@test\s*//' || true)"
1749
1812
  fi
@@ -1797,16 +1860,41 @@ else
1797
1860
  # TypeScript: vitest with smart test selection
1798
1861
  if [ "$CURRENT_LANG" = "typescript" ] && { [ -f "package.json" ] || [ -f "$PROJECT_ROOT/package.json" ]; }; then
1799
1862
  if run_without_git_context npx vitest --version >/dev/null 2>&1; then
1800
- # Count changed test files to decide strategy
1801
- 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)
1802
1878
 
1803
1879
  # Also get changed source files (non-test) for related-test lookup
1804
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
1805
1893
  # Strip PROJECT_SUBDIR prefix (same as CHANGED_TEST_FILES fix above)
1806
- if [ -n "$PROJECT_SUBDIR" ] && [ "$PROJECT_SUBDIR" != "." ] && [ -n "$CHANGED_SRC_FILES" ]; then
1807
- 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}/||")
1808
1896
  fi
1809
- CHANGED_SRC_COUNT=$(echo "$CHANGED_SRC_FILES" | grep -c '.' || echo "0")
1897
+ CHANGED_SRC_COUNT=$(echo "$SELECTED_SRC_FILES" | grep -c '.' || true)
1810
1898
 
1811
1899
  # Decide strategy
1812
1900
  if [ "$CHANGED_TEST_FILE_COUNT" -gt 20 ] 2>/dev/null; then
@@ -1816,24 +1904,24 @@ else
1816
1904
  TESTS_EXIT_CODE=$?
1817
1905
  echo "$TESTS_OUTPUT" | head -60
1818
1906
  if [ "$TESTS_EXIT_CODE" -ne 0 ]; then
1819
- echo ""
1820
- echo "❌ BLOCKED - Tests FAILED"
1821
- exit 1
1907
+ handle_test_failure "$TESTS_OUTPUT" "full test suite" || exit 1
1822
1908
  else
1823
1909
  echo "✅ PASSED - Full test suite passed (with coverage)."
1824
1910
  fi
1825
1911
  elif [ "$CHANGED_TEST_FILE_COUNT" -gt 0 ] 2>/dev/null; then
1826
1912
  # Run only changed test files with coverage
1827
- 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' ' ')
1828
1915
  echo "Running ${CHANGED_TEST_FILE_COUNT} changed test file(s) with coverage..."
1829
- echo " Target(s): $(echo "$CHANGED_TEST_FILES" | tr '\n' ' ')"
1916
+ echo " Target(s): $(echo "$SELECTED_TEST_FILES" | tr '\n' ' ')"
1830
1917
  TESTS_OUTPUT=$(run_without_git_context npx vitest run --coverage $SMART_TARGETS 2>&1)
1831
1918
  TESTS_EXIT_CODE=$?
1832
1919
  echo "$TESTS_OUTPUT" | head -60
1833
1920
  if [ "$TESTS_EXIT_CODE" -ne 0 ]; then
1834
- echo ""
1835
- echo "❌ BLOCKED - Tests FAILED in changed test files"
1836
- 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
1837
1925
  else
1838
1926
  echo "✅ PASSED - Changed test files passed (with coverage)."
1839
1927
  fi
@@ -1841,7 +1929,7 @@ else
1841
1929
  # Source files changed but no test files → find related tests or run full
1842
1930
  echo "Source file(s) changed, finding related tests..."
1843
1931
  RELATED_TESTS=""
1844
- for src_file in $CHANGED_SRC_FILES; do
1932
+ for src_file in $SELECTED_SRC_FILES; do
1845
1933
  base="${src_file%.*}"
1846
1934
  filename="${base##*/}"
1847
1935
  dir="$(dirname "$src_file")"
@@ -1875,9 +1963,7 @@ else
1875
1963
  TESTS_EXIT_CODE=$?
1876
1964
  echo "$TESTS_OUTPUT" | head -60
1877
1965
  if [ "$TESTS_EXIT_CODE" -ne 0 ]; then
1878
- echo ""
1879
- echo "❌ BLOCKED - Tests FAILED in related test files"
1880
- exit 1
1966
+ handle_test_failure "$TESTS_OUTPUT" "related test files" || exit 1
1881
1967
  else
1882
1968
  echo "✅ PASSED - Related test files passed (with coverage)."
1883
1969
  fi
@@ -1888,9 +1974,7 @@ else
1888
1974
  TESTS_EXIT_CODE=$?
1889
1975
  echo "$TESTS_OUTPUT" | head -60
1890
1976
  if [ "$TESTS_EXIT_CODE" -ne 0 ]; then
1891
- echo ""
1892
- echo "❌ BLOCKED - Tests FAILED"
1893
- exit 1
1977
+ handle_test_failure "$TESTS_OUTPUT" "full test suite" || exit 1
1894
1978
  else
1895
1979
  echo "✅ PASSED - Full test suite passed (with coverage)."
1896
1980
  fi
@@ -1907,13 +1991,22 @@ else
1907
1991
  TESTS_EXIT_CODE=$?
1908
1992
  echo "$TESTS_OUTPUT" | head -50
1909
1993
  if [ "$TESTS_EXIT_CODE" -ne 0 ]; then
1910
- echo ""
1911
- echo "❌ BLOCKED - Tests FAILED"
1912
- 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
1913
2002
  fi
1914
2003
  fi
1915
2004
  else
1916
- # 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).
1917
2010
  # Skip for Python — tests already run with coverage in the coverage section below.
1918
2011
  # Running them here first without coverage doubles pre-commit time for no benefit.
1919
2012
  if [ "$CURRENT_LANG" = "python" ]; then
@@ -1923,9 +2016,18 @@ else
1923
2016
  TESTS_EXIT_CODE=$?
1924
2017
  echo "$TESTS_OUTPUT" | head -50
1925
2018
  if [ "$TESTS_EXIT_CODE" -ne 0 ]; then
1926
- echo ""
1927
- echo "❌ BLOCKED - Tests FAILED"
1928
- 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
1929
2031
  else
1930
2032
  echo "✅ PASSED - Unit tests passed."
1931
2033
  fi
@@ -2044,8 +2146,9 @@ else
2044
2146
  " 2>/dev/null)
2045
2147
  if [ "$COVERAGE_PERCENT" != "parse_error" ] && [ -n "$COVERAGE_PERCENT" ]; then
2046
2148
  if [ "$COVERAGE_PERCENT" -lt 80 ]; then
2047
- # If only subset of tests ran (partial run or related tests), warn instead of block
2048
- 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
2049
2152
  echo "⚠️ Partial coverage ${COVERAGE_PERCENT}% (subset test run, not full suite)."
2050
2153
  echo " Run 'npx vitest run --coverage' for full coverage check."
2051
2154
  else
@@ -2085,6 +2188,64 @@ else
2085
2188
  echo "⚠️ coverage.py not available, cannot enforce Python coverage threshold."
2086
2189
  fi
2087
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
+ ;;
2088
2249
  "go")
2089
2250
  # Go enforcement already handled inline in Stage 1 (lines 933-942)
2090
2251
  ;;
@@ -2147,6 +2308,10 @@ else
2147
2308
  echo "⚠️ coverage.xml not found. Ensure Pester coverage was run."
2148
2309
  fi
2149
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
+ ;;
2150
2315
  *)
2151
2316
  # Default: attempt to parse coverage from common formats regardless of language
2152
2317
  COVERAGE_ENFORCED=true
@@ -2170,10 +2335,18 @@ else
2170
2335
  fi
2171
2336
  if [ -n "$COVERAGE_PERCENT" ] && [ "$COVERAGE_PERCENT" != "parse_error" ] && [ "$COVERAGE_PERCENT" -gt 0 ]; then
2172
2337
  if [ "$COVERAGE_PERCENT" -lt 80 ]; then
2173
- echo "❌ BLOCKED - ${CURRENT_LANG} coverage ${COVERAGE_PERCENT}% below 80% threshold"
2174
- 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%)"
2175
2349
  fi
2176
- echo "${CURRENT_LANG} coverage: ${COVERAGE_PERCENT}% ✅ (≥ 80%)"
2177
2350
  else
2178
2351
  echo "⚠️ Could not determine ${CURRENT_LANG} coverage percentage. No standard coverage report files found."
2179
2352
  fi
@@ -2314,10 +2487,20 @@ else
2314
2487
  fi
2315
2488
  ;;
2316
2489
  "python")
2317
- 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
2318
2492
  if require_tool "lint-imports" "Gate 6" "pip install import-linter"; then
2319
2493
  echo " Running import-linter for Python..."
2320
- 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
2321
2504
  echo " ✅ Python architecture validation completed."
2322
2505
  else
2323
2506
  echo " ❌ BLOCKED - import-linter not installed"
@@ -2325,7 +2508,7 @@ else
2325
2508
  exit 1
2326
2509
  fi
2327
2510
  else
2328
- echo " ℹ️ No .import-linter.yml found - skipping Python architecture"
2511
+ echo " ℹ️ No import-linter config found (.import-linter.yml/.importlinter/[tool.importlinter]) - skipping"
2329
2512
  echo " ⏭️ SKIPPED - Python architecture validation (no config)"
2330
2513
  fi
2331
2514
  ;;
@@ -2415,20 +2598,30 @@ else
2415
2598
  if [ -n "$BOY_SCOUT_SCRIPT" ]; then
2416
2599
  echo " Checking Boy Scout Rule compliance..."
2417
2600
 
2418
- # Separate new files and modified files
2419
- NEW_FILES=$(git diff --cached --name-only --diff-filter=A | tr '\n' ' ')
2420
- 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' ' ')
2421
2614
 
2422
2615
  if [ -z "$NEW_FILES" ] && [ -z "$MODIFIED_FILES" ]; then
2423
2616
  echo " ✅ No new or modified files to check."
2424
2617
  else
2425
2618
  # Run Boy Scout Rule enforcement
2426
- 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
2427
2620
  # Skip if no files to check
2428
2621
  if [ -z "$(echo "$NEW_FILES" | tr -d ' ')" ] && [ -z "$(echo "$MODIFIED_FILES" | tr -d ' ')" ]; then
2429
2622
  echo " ✅ No new or modified source files for Boy Scout check."
2430
2623
  else
2431
- BOY_SCOUT_OUTPUT=$(npx tsx $BOY_SCOUT_SCRIPT \
2624
+ BOY_SCOUT_OUTPUT=$(run_tsx "$BOY_SCOUT_SCRIPT" \
2432
2625
  --new-files "$(echo "$NEW_FILES" | tr ' ' ',')" \
2433
2626
  --modified-files "$(echo "$MODIFIED_FILES" | tr ' ' ',')" \
2434
2627
  --baseline ".warnings-baseline.json" 2>&1)
@@ -2459,8 +2652,8 @@ else
2459
2652
  echo " ✅ PASSED - Boy Scout Rule compliance."
2460
2653
  fi
2461
2654
  else
2462
- echo " ℹ️ npx not available - skipping Boy Scout Rule"
2463
- 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)"
2464
2657
  fi
2465
2658
  fi
2466
2659
  else
@@ -2516,7 +2709,7 @@ if [ -f "package.json" ] && [ -f "tsconfig.json" ]; then
2516
2709
  if [ -n "$GATE_9_SCRIPT" ]; then
2517
2710
  CHANGED_FILES_CSV=$(echo "$CHANGED_FILES" | tr ' ' ',')
2518
2711
  GATE_9_OUTPUT=$(mktemp)
2519
- 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
2520
2713
  GATE_9_STATUS="PASS"
2521
2714
  echo " ✅ Build integrity check passed."
2522
2715
  else
@@ -2544,8 +2737,9 @@ else
2544
2737
  echo " ⏭️ SKIPPED - Build integrity (not a TypeScript project)"
2545
2738
  fi
2546
2739
 
2547
- GATE_9_END=$(date +%s)
2548
- 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))
2549
2743
  echo "✅ Gate 9 completed in ${GATE_9_DURATION}s"
2550
2744
  record_gate_audit "gate-9" "build-integrity" "$GATE_9_STATUS" "0" "$GATE_9_START"
2551
2745
  echo ""
@@ -2579,34 +2773,65 @@ GATE_11_START=$(date +%s)
2579
2773
  GATE_11_STATUS="PASS"
2580
2774
 
2581
2775
  # BEGIN SPRINT_GATE_SCRIPT RESOLUTION
2582
- # Tier order: GATE_DIR (gate scripts dir) -> repo-root githooks/ -> SCRIPT_DIR
2583
- # (hooks dir; update-hooks installs sprint-gate.sh beside pre-commit/pre-push).
2584
- SPRINT_GATE_SCRIPT=""
2585
- if [ -f "$GATE_DIR/sprint-gate.sh" ]; then
2586
- SPRINT_GATE_SCRIPT="$GATE_DIR/sprint-gate.sh"
2587
- elif [ -f "$(git rev-parse --show-toplevel 2>/dev/null)/githooks/sprint-gate.sh" ]; then
2588
- SPRINT_GATE_SCRIPT="$(git rev-parse --show-toplevel)/githooks/sprint-gate.sh"
2589
- elif [ -f "$SCRIPT_DIR/sprint-gate.sh" ]; then
2590
- SPRINT_GATE_SCRIPT="$SCRIPT_DIR/sprint-gate.sh"
2591
- fi
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[@]}"})
2592
2789
  # END SPRINT_GATE_SCRIPT RESOLUTION
2593
2790
 
2791
+ echo "→ Gate 11: Sprint Flow Enforcement..."
2792
+
2594
2793
  if [ -n "$SPRINT_GATE_SCRIPT" ]; then
2595
- 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
2596
2804
  GATE_11_STATUS="BLOCK"
2597
- 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/^/ /'
2598
2807
  record_gate_audit "gate-11" "sprint-flow" "$GATE_11_STATUS" "1" "$GATE_11_START"
2599
2808
  # Sprint gate failure is a hard block — exit immediately
2600
2809
  exit 1
2601
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
2602
2827
  else
2603
- 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[*]}"))"
2604
2829
  GATE_11_STATUS="SKIP"
2605
2830
  fi
2606
2831
 
2607
2832
  GATE_11_END=$(date +%s)
2608
2833
  GATE_11_DURATION=$((GATE_11_END - GATE_11_START))
2609
- echo "✅ Gate 11 completed in ${GATE_11_DURATION}s"
2834
+ echo " Gate 11 ($GATE_11_STATUS) completed in ${GATE_11_DURATION}s"
2610
2835
  record_gate_audit "gate-11" "sprint-flow" "$GATE_11_STATUS" "0" "$GATE_11_START"
2611
2836
  echo ""
2612
2837
  # ============================================================================
@@ -2666,8 +2891,10 @@ generate_quality_report() {
2666
2891
  if [ -n "$TESTS_OUTPUT" ] && echo "$TESTS_OUTPUT" | grep -qi "fail\|error\|FAILED"; then
2667
2892
  # Extract failure lines: "FAIL src/foo.test.ts" or " ✗ test name"
2668
2893
  local FAIL_LINES
2669
- FAIL_LINES=$(echo "$TESTS_OUTPUT" | grep -E "^\s*(FAIL|✗|failed|AssertionError)" | head -10 | \
2670
- 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
2671
2898
  if [ -n "$FAIL_LINES" ]; then
2672
2899
  FAILED_TESTS_JSON="[$FAIL_LINES]"
2673
2900
  fi