@boyingliu01/xp-gate 0.19.3 → 0.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. package/adapters/gate-4.sh +83 -18
  2. package/adapters/gate-8.sh +115 -29
  3. package/adapters/iac.sh +1 -1
  4. package/adapters/powershell.sh +60 -5
  5. package/adapters/python.sh +38 -4
  6. package/adapters/typescript.sh +15 -60
  7. package/bin/xp-gate.js +15 -16
  8. package/build-integrity/__tests__/import-resolver.test.ts +59 -0
  9. package/build-integrity/gate-10.ts +17 -2
  10. package/gate-10.sh +35 -2
  11. package/gate-4.sh +110 -21
  12. package/gate-8.sh +115 -29
  13. package/hooks/gate-10.sh +35 -2
  14. package/hooks/gate-4.sh +110 -21
  15. package/hooks/gate-8.sh +115 -29
  16. package/hooks/lib/jscpd-run.sh +63 -0
  17. package/hooks/lib/sprint-gate-report.sh +58 -0
  18. package/hooks/lib/test-failure.sh +152 -0
  19. package/hooks/lib/typecheck.sh +141 -0
  20. package/hooks/lib/validate-code-walkthrough.cjs +43 -5
  21. package/hooks/post-merge +100 -0
  22. package/hooks/pre-commit +514 -184
  23. package/hooks/pre-push +75 -19
  24. package/lib/__tests__/bootstrap.test.js +23 -41
  25. package/lib/__tests__/check-version.test.js +8 -1
  26. package/lib/__tests__/detect-deps.test.js +16 -0
  27. package/lib/__tests__/doctor-drift-round2.test.js +212 -0
  28. package/lib/__tests__/doctor-executed-surface.test.js +209 -0
  29. package/lib/__tests__/doctor-hook-drift.test.js +329 -0
  30. package/lib/__tests__/doctor-machine-mutation.test.js +116 -0
  31. package/lib/__tests__/doctor-module-drift.test.js +206 -0
  32. package/lib/__tests__/doctor-module-resolution-parity.test.js +314 -0
  33. package/lib/__tests__/doctor-path-equivalence.test.js +59 -0
  34. package/lib/__tests__/doctor-repair-boundary.test.js +52 -0
  35. package/lib/__tests__/doctor-tui-skill-freshness.test.js +105 -0
  36. package/lib/__tests__/doctor.test.js +155 -0
  37. package/lib/__tests__/gate-runner.test.js +19 -3
  38. package/lib/__tests__/helpers/lib-sandbox.cjs +55 -0
  39. package/lib/__tests__/init.test.js +44 -17
  40. package/lib/__tests__/install-skill-full-dir.test.js +151 -0
  41. package/lib/__tests__/install-skill.test.js +51 -132
  42. package/lib/__tests__/language-tools-detect.test.js +158 -0
  43. package/lib/__tests__/phase-transition.test.js +15 -1
  44. package/lib/__tests__/qoder-delphi-agents.test.js +285 -0
  45. package/lib/__tests__/shared-paths.test.js +20 -0
  46. package/lib/__tests__/shared-utils.test.js +69 -0
  47. package/lib/__tests__/skill-flags.test.js +48 -0
  48. package/lib/__tests__/ui-detector.test.ts +6 -2
  49. package/lib/__tests__/uninstall.test.js +20 -0
  50. package/lib/__tests__/update-hooks.test.js +12 -0
  51. package/lib/__tests__/update-skill.test.js +7 -4
  52. package/lib/detect-deps.js +3 -20
  53. package/lib/doctor-tui.js +22 -5
  54. package/lib/doctor.js +1178 -31
  55. package/lib/init.js +184 -33
  56. package/lib/install-cmd.js +7 -4
  57. package/lib/install-skill.js +38 -95
  58. package/lib/language-tools.js +83 -4
  59. package/lib/mattpocock-license-verdict.json +1 -1
  60. package/lib/shared-paths.js +5 -2
  61. package/lib/shared-utils.js +47 -3
  62. package/lib/uninstall.js +12 -0
  63. package/lib/update-hooks.js +4 -4
  64. package/lib/update-skill.js +6 -7
  65. package/mock-policy/AGENTS.md +4 -4
  66. package/mutation/AGENTS.md +4 -4
  67. package/mutation/__tests__/gate-m.test.ts +66 -14
  68. package/mutation/__tests__/mutmut-runner.test.ts +91 -42
  69. package/mutation/__tests__/test-filter-parity.test.ts +37 -0
  70. package/mutation/gate-m.ts +21 -8
  71. package/package.json +2 -2
  72. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  73. package/plugins/claude-code/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
  74. package/plugins/claude-code/skills/delphi-review/AGENTS.md +4 -4
  75. package/plugins/claude-code/skills/delphi-review/SKILL.md +98 -14
  76. package/plugins/claude-code/skills/delphi-review/references/code-walkthrough.md +36 -3
  77. package/plugins/claude-code/skills/delphi-review/references/requirements.md +6 -6
  78. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +4 -4
  79. package/plugins/claude-code/skills/sprint-flow/references/phase-2-design.md +3 -3
  80. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +4 -4
  81. package/plugins/dsh/README.md +169 -4
  82. package/plugins/dsh/dsh-overlay.patch.yml +67 -0
  83. package/plugins/dsh/package.json +1 -1
  84. package/plugins/dsh/scripts/prepack.cjs +24 -19
  85. package/plugins/dsh/setup-dsh.ps1 +160 -0
  86. package/plugins/dsh/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
  87. package/plugins/dsh/skills/delphi-review/AGENTS.md +4 -4
  88. package/plugins/dsh/skills/delphi-review/SKILL.md +98 -14
  89. package/plugins/dsh/skills/delphi-review/references/code-walkthrough.md +36 -3
  90. package/plugins/dsh/skills/delphi-review/references/requirements.md +6 -6
  91. package/plugins/dsh/skills/sprint-flow/AGENTS.md +4 -4
  92. package/plugins/dsh/skills/sprint-flow/references/phase-2-design.md +3 -3
  93. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +4 -4
  94. package/plugins/dsh/src/command.test.ts +66 -2
  95. package/plugins/dsh/src/command.ts +36 -14
  96. package/plugins/dsh/src/contract.test.ts +31 -1
  97. package/plugins/dsh/src/delphi-config.test.ts +190 -0
  98. package/plugins/dsh/src/delphi-config.ts +223 -0
  99. package/plugins/dsh/src/delphi-run.test.ts +181 -0
  100. package/plugins/dsh/src/delphi-run.ts +129 -0
  101. package/plugins/dsh/src/delphi.test.ts +222 -0
  102. package/plugins/dsh/src/delphi.ts +247 -0
  103. package/plugins/dsh/src/dialect.test.ts +59 -0
  104. package/plugins/dsh/src/dialect.ts +40 -0
  105. package/plugins/dsh/src/index.ts +70 -4
  106. package/plugins/dsh/src/skills.test.ts +26 -19
  107. package/plugins/opencode/package.json +1 -1
  108. package/plugins/opencode/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
  109. package/plugins/opencode/skills/delphi-review/AGENTS.md +4 -4
  110. package/plugins/opencode/skills/delphi-review/SKILL.md +98 -14
  111. package/plugins/opencode/skills/delphi-review/references/code-walkthrough.md +36 -3
  112. package/plugins/opencode/skills/delphi-review/references/requirements.md +6 -6
  113. package/plugins/opencode/skills/sprint-flow/AGENTS.md +4 -4
  114. package/plugins/opencode/skills/sprint-flow/references/phase-2-design.md +3 -3
  115. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +4 -4
  116. package/plugins/qoder/agents/delphi-architecture.md +1 -1
  117. package/plugins/qoder/agents/delphi-feasibility.md +1 -1
  118. package/plugins/qoder/agents/delphi-technical.md +1 -1
  119. package/plugins/qoder/plugin.json +1 -1
  120. package/plugins/qoder/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
  121. package/plugins/qoder/skills/delphi-review/AGENTS.md +4 -4
  122. package/plugins/qoder/skills/delphi-review/SKILL.md +98 -14
  123. package/plugins/qoder/skills/delphi-review/references/code-walkthrough.md +36 -3
  124. package/plugins/qoder/skills/delphi-review/references/requirements.md +6 -6
  125. package/plugins/qoder/skills/sprint-flow/AGENTS.md +4 -4
  126. package/plugins/qoder/skills/sprint-flow/references/phase-2-design.md +3 -3
  127. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +4 -4
  128. package/principles/AGENTS.md +9 -8
  129. package/principles/__tests__/adapter-metrics.test.ts +292 -0
  130. package/principles/__tests__/analyzer.test.ts +29 -6
  131. package/principles/__tests__/baseline-prune-timeout.test.ts +71 -0
  132. package/principles/__tests__/baseline-prune.test.ts +338 -0
  133. package/principles/__tests__/boy-scout-init-baseline.test.ts +167 -0
  134. package/principles/__tests__/boy-scout-integration.test.ts +97 -6
  135. package/principles/__tests__/boy-scout.test.ts +253 -7
  136. package/principles/__tests__/config-enforcement.test.ts +469 -0
  137. package/principles/__tests__/config-git-timeout.test.ts +66 -0
  138. package/principles/__tests__/direct-execution.test.ts +187 -0
  139. package/principles/__tests__/index.test.ts +16 -3
  140. package/principles/__tests__/warnings-baseline-budgets.test.ts +74 -0
  141. package/principles/adapters/typescript.ts +613 -13
  142. package/principles/analyzer.ts +22 -0
  143. package/principles/baseline-prune.ts +217 -0
  144. package/principles/boy-scout.ts +516 -56
  145. package/principles/config.ts +362 -54
  146. package/principles/direct-execution.ts +60 -0
  147. package/principles/index.ts +40 -17
  148. package/principles/rules/__tests__/clean-code/many-exports.test.ts +16 -10
  149. package/principles/rules/clean-code/code-duplication.ts +8 -8
  150. package/principles/rules/clean-code/deep-nesting.ts +8 -8
  151. package/principles/rules/clean-code/god-class.ts +8 -8
  152. package/principles/rules/clean-code/large-file.ts +15 -11
  153. package/principles/rules/clean-code/long-function.ts +8 -8
  154. package/principles/rules/clean-code/magic-numbers.ts +10 -7
  155. package/principles/rules/clean-code/many-exports.ts +7 -7
  156. package/principles/rules/clean-code/missing-error-handling.ts +5 -5
  157. package/principles/rules/clean-code/too-many-params.ts +8 -8
  158. package/principles/rules/clean-code/unused-imports.ts +5 -5
  159. package/principles/rules/solid/dip.ts +18 -8
  160. package/principles/rules/solid/isp.ts +9 -8
  161. package/principles/rules/solid/lsp.ts +6 -6
  162. package/principles/rules/solid/ocp.ts +6 -5
  163. package/principles/rules/solid/srp.ts +7 -8
  164. package/principles/types.ts +15 -0
  165. package/scripts/__tests__/sync-package-content.test.js +28 -0
  166. package/scripts/delphi-external-review.cjs +193 -10
  167. package/scripts/sync-package-content.js +20 -10
  168. package/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
  169. package/skills/delphi-review/AGENTS.md +4 -4
  170. package/skills/delphi-review/SKILL.md +98 -14
  171. package/skills/delphi-review/references/code-walkthrough.md +36 -3
  172. package/skills/delphi-review/references/requirements.md +6 -6
  173. package/skills/sprint-flow/AGENTS.md +4 -4
  174. package/skills/sprint-flow/references/phase-2-design.md +3 -3
  175. package/skills/test-specification-alignment/AGENTS.md +4 -4
  176. package/lib/__tests__/download-skill.test.js +0 -281
  177. package/lib/download-skill.js +0 -96
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
  "
@@ -421,14 +459,45 @@ record_gate_audit "gate-0" "version-consistency" "$GATE_0_STATUS" "0" "$GATE_0_S
421
459
 
422
460
  echo ""
423
461
 
424
- # Get list of changed files
425
- CHANGED_FILES=$(git diff --cached --name-only --diff-filter=ACM)
426
-
427
- if [ -z "$CHANGED_FILES" ]; then
462
+ # Get list of changed files.
463
+ #
464
+ # Byte-identical mirrors (src/npm-package/**) are governed by CI Mirror Parity,
465
+ # not by re-linting. Leaving them in this scan set double-counted every
466
+ # lint/complexity signal and made jscpd report the source-vs-mirror pair as a
467
+ # 100% whole-file clone, which then tripped Gate 6's "<=5 warnings must clear
468
+ # to zero" rule on ANY edit to a mirrored file -- a warning that is
469
+ # structurally impossible to clear. (Gate 6's MIRROR_EXCLUDE and #494's test
470
+ # selection already exclude mirrors for the same reason.)
471
+ #
472
+ # IMPORTANT: the exclusion applies to the LINT SCAN CORPUS only. The early
473
+ # exit below must key on the RAW staged list -- Gate 8 (secret scanning) is
474
+ # unconditional and fail-closed (#499), and gating it behind a path-filtered
475
+ # list would let a mirror-only commit land with zero secret scanning and no
476
+ # CI backstop.
477
+ RAW_CHANGED_FILES=$(git diff --cached --name-only --diff-filter=ACM)
478
+ CHANGED_FILES=$(printf '%s\n' "$RAW_CHANGED_FILES" | grep -v '^src/npm-package/' || true)
479
+
480
+ if [ -z "$RAW_CHANGED_FILES" ]; then
428
481
  echo "No files changed. Skipping gates."
429
482
  exit 0
430
483
  fi
431
484
 
485
+ # Gate 5 test selection (#494): vitest.config.ts excludes the bundled mirror
486
+ # subtrees and the skills/plugins source trees, so a path under one of them can
487
+ # never be collected. When such a path is the only target, vitest exits 1 with
488
+ # "No test files found" and Gate 5 reports a failing test run -- a commit that
489
+ # touched nothing but a byte-identical mirror cannot land. Map each mirror path
490
+ # to the canonical twin it copies, drop what has no twin, collapse duplicates.
491
+ select_root_suite_targets() {
492
+ # Deliberately not `s|...|...|`: the alternation below contains `|`, which sed
493
+ # would read as the end of its pattern ("unknown option to `s'").
494
+ printf '%s\n' "$1" \
495
+ | sed -E 's#^src/npm-package/(principles|mutation|mock-policy|build-integrity)/#src/\1/#' \
496
+ | grep -vE '^(src/_wip/|plugins/|skills/|src/npm-package/(plugins|skills|principles|mutation|mock-policy|build-integrity)/)' \
497
+ | sed '/^$/d' \
498
+ | sort -u
499
+ }
500
+
432
501
  any_changed_files_match() {
433
502
  local pattern
434
503
 
@@ -803,11 +872,21 @@ has_project_lang() {
803
872
  }
804
873
  # Switch to subdirectory if detected (for single-language subdirectory projects).
805
874
  # Multi-language projects stay at root — each adapter handles its own path.
875
+ #
876
+ # IN_PROJECT_SUBDIR records whether this cd actually happened. Git reports
877
+ # staged paths relative to the repo root; after cd "$PROJECT_SUBDIR" the [ -f ]
878
+ # probes below need subdirectory-relative paths (hence the PROJECT_SUBDIR
879
+ # prefix strips in Gates 5a/5b/5c), but when the hook stays at the root
880
+ # (multi-language projects) stripping the prefix makes every path-based check
881
+ # resolve against the wrong directory — false BLOCKs in Gate 5a, silent no-ops
882
+ # in 5b/5c. Only strip when we really are inside the subdirectory.
806
883
  ORIGINAL_DIR=""
884
+ IN_PROJECT_SUBDIR=0
807
885
  _lang_count=$(echo "$PROJECT_LANGS" | wc -w)
808
886
  if [ -n "$PROJECT_SUBDIR" ] && [ "$PROJECT_SUBDIR" != "." ] && [ "$PROJECT_LANG" != "documentation-only" ] && [ "$_lang_count" -le 1 ]; then
809
887
  ORIGINAL_DIR=$(pwd)
810
888
  cd "$PROJECT_SUBDIR"
889
+ IN_PROJECT_SUBDIR=1
811
890
  echo "Working in: $PROJECT_SUBDIR/"
812
891
  echo ""
813
892
  fi
@@ -833,80 +912,71 @@ else
833
912
  echo " ── Checking $CURRENT_LANG ──"
834
913
  case "$CURRENT_LANG" in
835
914
  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."
915
+ # TypeScript: static analysis + linting (eslint). The type-check decision lives
916
+ # in githooks/lib/typecheck.sh so a project's own checker (vue-tsc, svelte-check,
917
+ # a `typecheck` script) is used instead of a hardcoded tsc that cannot resolve
918
+ # single-file components (#436).
919
+ #
920
+ # Resolve the TS project directory first (#470). The probes below used to run
921
+ # against the repo root only; in multi-language monorepos (e.g. web/ +
922
+ # backend/, where the hook stays at the root — see the cd block above)
923
+ # tsconfig.json / biome.json live in the language subdirectory, so the whole
924
+ # TS half of Gate 1 silently SKIPped ("tsconfig.json not found") while a
925
+ # project config existed. Order: repo root first (unchanged behaviour for
926
+ # plain projects), then the detected language subdirectory, then a shallow
927
+ # search.
928
+ TS_PROJECT_DIR=""
929
+ for _ts_dir in "." "${PROJECT_SUBDIR:-}"; do
930
+ [ -z "$_ts_dir" ] && continue
931
+ if [ -f "$_ts_dir/tsconfig.json" ] || [ -f "$_ts_dir/biome.json" ] || [ -f "$_ts_dir/biome.jsonc" ] \
932
+ || [ -f "$_ts_dir/eslint.config.js" ] || [ -f "$_ts_dir/.eslintrc.json" ] \
933
+ || [ -f "$_ts_dir/.eslintrc.js" ] || [ -f "$_ts_dir/.eslintrc.cjs" ]; then
934
+ TS_PROJECT_DIR="$_ts_dir"
935
+ break
936
+ fi
937
+ done
938
+ if [ -z "$TS_PROJECT_DIR" ]; then
939
+ _TSCONFIG_CAND=$(find . -maxdepth 2 \( -name tsconfig.json -o -name biome.json -o -name biome.jsonc -o -name eslint.config.js -o -name .eslintrc.json -o -name .eslintrc.js -o -name .eslintrc.cjs \) -not -path './.git/*' -not -path './node_modules/*' 2>/dev/null | head -1)
940
+ if [ -n "$_TSCONFIG_CAND" ]; then
941
+ TS_PROJECT_DIR=$(dirname "$_TSCONFIG_CAND")
901
942
  fi
943
+ fi
944
+ [ -z "$TS_PROJECT_DIR" ] && TS_PROJECT_DIR="."
945
+ if [ "$TS_PROJECT_DIR" != "." ]; then
946
+ echo " TypeScript project directory: $TS_PROJECT_DIR/"
947
+ # typecheck.sh is cwd-relative: run it from the TS project subdirectory in a
948
+ # subshell so the language loop's next iteration still starts at the root.
949
+ # Exit contract: 0 clean, 1 findings (block), 2 checker unavailable (skip).
950
+ # A cd/source infrastructure failure must NOT read as "type errors": map it
951
+ # to 2 (skip) so 1 stays reserved for real findings (#508 walkthrough).
952
+ ( cd "$TS_PROJECT_DIR" 2>/dev/null || exit 2 \
953
+ ; [ -r "${AUDIT_SCRIPT_DIR}/lib/typecheck.sh" ] || exit 2 \
954
+ ; source "${AUDIT_SCRIPT_DIR}/lib/typecheck.sh" && run_typescript_typecheck )
955
+ TS_CHECK_EXIT=$?
956
+ else
957
+ source "${AUDIT_SCRIPT_DIR}/lib/typecheck.sh"
958
+ run_typescript_typecheck
959
+ TS_CHECK_EXIT=$?
960
+ fi
961
+ if [ "$TS_CHECK_EXIT" -eq 1 ]; then
962
+ exit 1
963
+ fi
902
964
 
903
- # Run ESLint linting if config exists
904
- if [ -f ".eslintrc.json" ] || [ -f ".eslintrc.js" ] || [ -f ".eslintrc.cjs" ] || [ -f "eslint.config.js" ]; then
965
+ # Run ESLint linting if config exists (root or TS project subdirectory)
966
+ if [ -f "$TS_PROJECT_DIR/.eslintrc.json" ] || [ -f "$TS_PROJECT_DIR/.eslintrc.js" ] || [ -f "$TS_PROJECT_DIR/.eslintrc.cjs" ] || [ -f "$TS_PROJECT_DIR/eslint.config.js" ]; then
905
967
  echo "Running ESLint linting..."
906
968
  # Filter to only TS/JS files - ESLint config ignores non-matching files
907
969
  ESLINT_FILES=$(echo "$CHANGED_FILES" | grep -E '\.(ts|tsx|js|jsx)$' || true)
908
970
  if [ -n "$ESLINT_FILES" ]; then
909
- ESLINT_OUTPUT=$(npx eslint $ESLINT_FILES -f json --no-warn-ignored 2>&1)
971
+ # File paths from git are repo-root relative, so they are correct from
972
+ # the root. Legacy .eslintrc.* configs are discovered by walking up from
973
+ # each linted file (found in the subdirectory automatically); flat
974
+ # configs are discovered from the CWD, so point at them explicitly.
975
+ if [ "$TS_PROJECT_DIR" != "." ] && [ -f "$TS_PROJECT_DIR/eslint.config.js" ]; then
976
+ ESLINT_OUTPUT=$(npx eslint --config "$TS_PROJECT_DIR/eslint.config.js" $ESLINT_FILES -f json --no-warn-ignored 2>&1)
977
+ else
978
+ ESLINT_OUTPUT=$(npx eslint $ESLINT_FILES -f json --no-warn-ignored 2>&1)
979
+ fi
910
980
  ESLINT_EXIT=$?
911
981
  if [ "$ESLINT_EXIT" -ne 0 ]; then
912
982
  if [ -f ".xp-gate/lint-baseline.json" ] && command -v node &> /dev/null; then
@@ -953,8 +1023,8 @@ else
953
1023
  fi
954
1024
  else
955
1025
  echo "ℹ️ No ESLint configuration found - Skipping"
956
- if [ -f "package.json" ] && command -v node &> /dev/null; then
957
- HAS_ESLINT=$(node -e "try{const p=JSON.parse(require('fs').readFileSync('package.json','utf8'));const d=p.devDependencies||{};const deps=p.dependencies||{};console.log((d.eslint||deps.eslint)?'yes':'no')}catch{console.log('no')}") && true
1026
+ if [ -f "$TS_PROJECT_DIR/package.json" ] && command -v node &> /dev/null; then
1027
+ HAS_ESLINT=$(node -e "try{const p=JSON.parse(require('fs').readFileSync('$TS_PROJECT_DIR/package.json','utf8'));const d=p.devDependencies||{};const deps=p.dependencies||{};console.log((d.eslint||deps.eslint)?'yes':'no')}catch{console.log('no')}") && true
958
1028
  if [ "$HAS_ESLINT" = "yes" ]; then
959
1029
  echo "⚠️ WARNING: eslint is installed in package.json but no eslint config found"
960
1030
  echo " Either create .eslintrc.* / eslint.config.* or remove eslint from dependencies"
@@ -964,21 +1034,43 @@ else
964
1034
  fi
965
1035
 
966
1036
  # ── Biome lint/format check ──
967
- if [ -f "biome.json" ] || [ -f "biome.jsonc" ]; then
1037
+ # Default the project dir so this branch stays runnable standalone
1038
+ # (the gate-1-biome-noop suite extracts it verbatim); a no-op inside the
1039
+ # hook, where TS_PROJECT_DIR is resolved above.
1040
+ TS_PROJECT_DIR="${TS_PROJECT_DIR:-.}"
1041
+ if [ -f "$TS_PROJECT_DIR/biome.json" ] || [ -f "$TS_PROJECT_DIR/biome.jsonc" ]; then
968
1042
  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
1043
+ # Run from the TS project directory so Biome auto-discovers its config and
1044
+ # scopes --staged to that subdirectory (mirrors the single-language subdir
1045
+ # mode, which executes after cd "$PROJECT_SUBDIR"). Empirically verified:
1046
+ # `biome check --staged .` from the subdir checks the staged files under it.
1047
+ BIOME_OUT=$(cd "$TS_PROJECT_DIR" && npx biome check --staged . 2>&1)
1048
+ BIOME_STATUS=$?
1049
+ if [ "$BIOME_STATUS" -ne 0 ]; then
1050
+ # Biome exits 1 with `internalError/io - No files were processed` when the
1051
+ # staged set yields nothing lintable (docs-only / config-only commit, or
1052
+ # everything excluded by biome.json). That is "nothing to do", not a lint
1053
+ # violation -- blocking on it makes documentation uncommittable (#458).
1054
+ # This matches the hook's stated principle: tool available + check
1055
+ # SUCCEEDS = PASS. Real violations still carry diagnostics, so they block.
1056
+ if echo "$BIOME_OUT" | grep -qE 'internalError/io|No files were processed'; then
1057
+ echo "$BIOME_OUT"
1058
+ echo "✅ PASSED - Biome check (no lintable files staged)."
1059
+ else
1060
+ echo "$BIOME_OUT" | head -50
1061
+ echo ""
1062
+ echo "❌ BLOCKED - Biome check failed"
1063
+ echo "Fix the errors above before committing."
1064
+ echo "Tip: Run 'npx biome check --write .' to auto-fix formatting and safe lint issues."
1065
+ echo " Run 'npx biome check --write --unsafe .' to also fix unsafe lint issues."
1066
+ exit 1
1067
+ fi
1068
+ else
1069
+ echo "✅ PASSED - Biome check."
977
1070
  fi
978
- echo "✅ PASSED - Biome check."
979
1071
  else
980
- if [ -f "package.json" ] && command -v node &> /dev/null; then
981
- 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
1072
+ if [ -f "$TS_PROJECT_DIR/package.json" ] && command -v node &> /dev/null; then
1073
+ HAS_BIOME=$(node -e "try{const p=JSON.parse(require('fs').readFileSync('$TS_PROJECT_DIR/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
982
1074
  if [ "$HAS_BIOME" = "yes" ]; then
983
1075
  echo ""
984
1076
  echo "⚠️ WARNING: @biomejs/biome is installed in package.json but no biome.json found"
@@ -1002,7 +1094,6 @@ else
1002
1094
  exit 1
1003
1095
  fi
1004
1096
  fi
1005
- fi
1006
1097
  ;;
1007
1098
 
1008
1099
  python)
@@ -1013,14 +1104,20 @@ else
1013
1104
  else
1014
1105
  # Run Ruff (includes syntax check, linting)
1015
1106
  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
1107
+ # Filter to *.py/*.pyi into a bash array so each staged path is passed as
1108
+ # ONE argument even when it contains whitespace; skip ruff when no Python
1109
+ # files are staged so yaml/.gitignore are never parsed.
1110
+ RUFF_ARGS=()
1111
+ while IFS= read -r staged_file; do
1112
+ case "$staged_file" in
1113
+ *.py|*.pyi) RUFF_ARGS+=("$staged_file") ;;
1114
+ esac
1115
+ done <<< "$CHANGED_FILES"
1116
+ if [ "${#RUFF_ARGS[@]}" -eq 0 ]; then
1020
1117
  RUFF_OUTPUT="[]"
1021
1118
  RUFF_EXIT=0
1022
1119
  else
1023
- RUFF_OUTPUT=$(ruff check $PY_LINT_FILES --output-format json 2>&1)
1120
+ RUFF_OUTPUT=$(ruff check "${RUFF_ARGS[@]}" --output-format json 2>&1)
1024
1121
  RUFF_EXIT=$?
1025
1122
  fi
1026
1123
  if [ "$RUFF_EXIT" -ne 0 ]; then
@@ -1078,9 +1175,14 @@ else
1078
1175
 
1079
1176
  # Ruff format check (formatting gate)
1080
1177
  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)
1178
+ FMT_ARGS=()
1179
+ while IFS= read -r staged_file; do
1180
+ case "$staged_file" in
1181
+ *.py|*.pyi) FMT_ARGS+=("$staged_file") ;;
1182
+ esac
1183
+ done <<< "$CHANGED_FILES"
1184
+ if [ "${#FMT_ARGS[@]}" -gt 0 ]; then
1185
+ RUFF_FMT_OUTPUT=$(ruff format --check "${FMT_ARGS[@]}" 2>&1)
1084
1186
  RUFF_FMT_EXIT=$?
1085
1187
  if [ "$RUFF_FMT_EXIT" -ne 0 ]; then
1086
1188
  echo "$RUFF_FMT_OUTPUT" | head -20
@@ -1094,9 +1196,14 @@ else
1094
1196
  fi
1095
1197
 
1096
1198
  # 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)
1199
+ DEBUG_ARGS=()
1200
+ while IFS= read -r staged_file; do
1201
+ case "$staged_file" in
1202
+ *.py|*.pyi) DEBUG_ARGS+=("$staged_file") ;;
1203
+ esac
1204
+ done <<< "$CHANGED_FILES"
1205
+ if [ "${#DEBUG_ARGS[@]}" -gt 0 ]; then
1206
+ DEBUG_HITS=$(grep -n 'breakpoint()\|pdb\.set_trace()\|ipdb\.set_trace()\|import pdb\|import ipdb' "${DEBUG_ARGS[@]}" 2>/dev/null || true)
1100
1207
  if [ -n "$DEBUG_HITS" ]; then
1101
1208
  echo ""
1102
1209
  echo "❌ BLOCKED - Debug statements detected in Python files:"
@@ -1109,12 +1216,17 @@ else
1109
1216
 
1110
1217
  # Optional: Type checking with mypy
1111
1218
  if command -v mypy &> /dev/null; then
1112
- PY_MYPY_FILES=$(echo "$CHANGED_FILES" | grep "\.py$" || true)
1113
- if [ -z "$PY_MYPY_FILES" ]; then
1219
+ MYPY_ARGS=()
1220
+ while IFS= read -r staged_file; do
1221
+ case "$staged_file" in
1222
+ *.py|*.pyi) MYPY_ARGS+=("$staged_file") ;;
1223
+ esac
1224
+ done <<< "$CHANGED_FILES"
1225
+ if [ "${#MYPY_ARGS[@]}" -eq 0 ]; then
1114
1226
  MYPY_EXIT=0
1115
1227
  else
1116
1228
  echo "Running mypy type checking (optional)..."
1117
- mypy --ignore-missing-imports $PY_MYPY_FILES 2>&1 | head -20 | tail -10
1229
+ mypy --ignore-missing-imports "${MYPY_ARGS[@]}" 2>&1 | head -20 | tail -10
1118
1230
  MYPY_EXIT=$?
1119
1231
  fi
1120
1232
  if [ "$MYPY_EXIT" -ne 0 ]; then
@@ -1266,6 +1378,7 @@ record_gate_audit "gate-1" "code-quality" "$GATE_1_STATUS" "0" "$GATE_1_START"
1266
1378
  2>&1 echo ""
1267
1379
  2>&1 echo "→ Gate 2: Duplicate code detection..."
1268
1380
  GATE_2_START=$(gate_start_ms)
1381
+ GATE_2_STATUS="PASS"
1269
1382
 
1270
1383
  if [ "$PROJECT_LANG" = "documentation-only" ]; then
1271
1384
  echo "⏭️ SKIPPED - Duplicate code (documentation project)."
@@ -1280,13 +1393,34 @@ else
1280
1393
  fi
1281
1394
 
1282
1395
  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
1396
+ # #477: only pass --config when the file actually exists, and let jscpd's report
1397
+ # decide the message. Before this, a missing config produced "os error 2" and a
1398
+ # non-zero exit that the gate translated into a fabricated "found duplicated
1399
+ # code"; a real clone report that exited 0 was announced as a clean pass.
1400
+ JSCPD_CONFIG=$(resolve_jscpd_config)
1401
+ JSCPD_ARGS=()
1402
+ if [ -n "$JSCPD_CONFIG" ]; then
1403
+ JSCPD_ARGS=(--config "$JSCPD_CONFIG")
1404
+ else
1405
+ echo "ℹ️ No jscpd.conf.json found - using jscpd defaults"
1406
+ fi
1407
+ JSCPD_OUTPUT=$(mktemp)
1408
+ JSCPD_EXIT=0
1409
+ # shellcheck disable=SC2086 # pre-existing splitting of the file list; #477 changes the reporting, not the scan set
1410
+ jscpd ${JSCPD_ARGS[@]+"${JSCPD_ARGS[@]}"} $(echo "$CHANGED_FILES" | tr '\n' ' ') > "$JSCPD_OUTPUT" 2>&1 || JSCPD_EXIT=$?
1411
+ head -30 "$JSCPD_OUTPUT"
1412
+ JSCPD_OUTCOME=$(classify_jscpd_run "$JSCPD_EXIT" "$(cat "$JSCPD_OUTPUT")")
1413
+ rm -f "$JSCPD_OUTPUT"
1414
+ if [ "$JSCPD_OUTCOME" = "duplicates" ]; then
1286
1415
  echo "ℹ️ jscpd found duplicated code (warning, not blocking by default)"
1287
1416
  echo " Consider refactoring duplicate code blocks."
1417
+ elif [ "$JSCPD_OUTCOME" = "error" ]; then
1418
+ echo "⚠️ jscpd exited with code $JSCPD_EXIT without a report — the output above is its real error"
1419
+ echo "⏭️ SKIPPED - Duplicate code (jscpd run error)"
1420
+ GATE_2_STATUS="SKIP"
1421
+ else
1422
+ echo "✅ PASSED - jscpd duplicate code check completed."
1288
1423
  fi
1289
- echo "✅ PASSED - jscpd duplicate code check completed."
1290
1424
  ;;
1291
1425
 
1292
1426
  python)
@@ -1344,7 +1478,8 @@ else
1344
1478
  esac
1345
1479
  done
1346
1480
  fi
1347
- GATE_2_STATUS="PASS"
1481
+ # Set at the top of this gate; a branch may already have marked it SKIP (#477). The
1482
+ # audit must record what happened, not what the gate defaults to.
1348
1483
  record_gate_audit "gate-2" "duplicate-code" "$GATE_2_STATUS" "0" "$GATE_2_START"
1349
1484
 
1350
1485
  # ============================================================================
@@ -1405,7 +1540,7 @@ else
1405
1540
 
1406
1541
  SKIP_5A_BLOCK=false
1407
1542
  if [ "${SKIP_GATE_5A_BLOCK:-0}" = "1" ]; then
1408
- CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
1543
+ CURRENT_BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null || git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "HEAD")
1409
1544
  if [ "$CURRENT_BRANCH" != "main" ] && [ "$CURRENT_BRANCH" != "master" ]; then
1410
1545
  REASON="${SKIP_GATE_5A_BLOCK_REASON:-not specified}"
1411
1546
  mkdir -p .xp-gate/reports
@@ -1425,11 +1560,15 @@ else
1425
1560
  # --- BLOCK path: New .ts/.tsx files without corresponding tests ---
1426
1561
  NEW_TS_FILES=$(git diff --cached --name-only --diff-filter=A | grep -E '\.(ts|tsx)$' | grep -v '\.d\.ts$' | grep -v '__tests__' | grep -v '\.test\.' | grep -v '\.spec\.' | grep -v '__snapshots__' | grep -v 'node_modules/' | grep -v '\.next/' | grep -v '\.nuxt/' | grep -v 'dist/' | grep -v 'build/' | grep -v '.turbo/' | grep -v '.cache/' | grep -Ev '(^|/)vitest\.config\.' | grep -Ev '(^|/)vite\.config\.' | grep -Ev '(^|/)jest\.config\.' | grep -Ev '(^|/)tsconfig\.' | grep -Ev '(^|/)eslint\.' | grep -Ev '(^|/)prettier\.' | grep -Ev '(^|/)tailwind\.' || true)
1427
1562
 
1428
- # Strip PROJECT_SUBDIR prefix from new-TS-file paths when running in a subdirectory.
1429
- # git diff returns repo-root relative paths, but after cd "$PROJECT_SUBDIR"
1430
- # the test-pairing [ -f ] checks below need subdirectory-relative paths.
1563
+ # Strip PROJECT_SUBDIR prefix from new-TS-file paths ONLY when the hook
1564
+ # actually cd'd into the subdirectory (IN_PROJECT_SUBDIR=1; single-language
1565
+ # subdirectory projects). git diff returns repo-root relative paths; after
1566
+ # cd "$PROJECT_SUBDIR" the test-pairing [ -f ] checks below need
1567
+ # subdirectory-relative paths. When the hook stays at the repo root
1568
+ # (multi-language projects) the paths must KEEP their prefix or every
1569
+ # [ -f ] lookup misses and paired files are falsely BLOCKED.
1431
1570
  # (Mirrors the v0.14.31+ Gate 5b fix at lines 1603-1608; Gate 5a was missing it.)
1432
- if [ -n "$PROJECT_SUBDIR" ] && [ "$PROJECT_SUBDIR" != "." ] && [ -n "$NEW_TS_FILES" ]; then
1571
+ if [ "$IN_PROJECT_SUBDIR" = "1" ] && [ -n "$NEW_TS_FILES" ]; then
1433
1572
  NEW_TS_FILES=$(echo "$NEW_TS_FILES" | sed "s|^${PROJECT_SUBDIR}/||")
1434
1573
  fi
1435
1574
 
@@ -1630,12 +1769,23 @@ else
1630
1769
  # Gate 5b: Mock Density ADVISORY Scan (pure bash — no npx tsx overhead)
1631
1770
  # 30% = ADVISORY, 50% = suggests @mock-justified. Does NOT block commit.
1632
1771
  # ========================================================================
1633
- CHANGED_TEST_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(test|spec)\.(ts|tsx|js|jsx|py|go)$' || true)
1634
-
1635
- # Strip PROJECT_SUBDIR prefix from test file paths when running in subdirectory.
1636
- # git diff returns repo-root relative paths, but after cd "$PROJECT_SUBDIR" (line 728)
1637
- # vitest and [ -f ] checks need subdirectory-relative paths. (v0.14.31+ fix)
1638
- if [ -n "$PROJECT_SUBDIR" ] && [ "$PROJECT_SUBDIR" != "." ] && [ -n "$CHANGED_TEST_FILES" ]; then
1772
+ CHANGED_TEST_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(test|spec)\.(ts|tsx|js|jsx|cjs|mjs|py|go)$' || true)
1773
+
1774
+ # Nested plugin sub-projects (plugins/<name>/) own their own vitest config and
1775
+ # are excluded from the root suite (`vitest.config.ts` excludes `plugins/**`,
1776
+ # because plugins/*/node_modules contains vendored tests). Feeding their test
1777
+ # paths to the root vitest yields "No test files found" → false BLOCK, so drop
1778
+ # them here and let the per-plugin suite cover them (CI runs `npm test` inside
1779
+ # each plugin directory).
1780
+ CHANGED_TEST_FILES=$(echo "$CHANGED_TEST_FILES" | grep -vE '^plugins/[^/]+/' || true)
1781
+
1782
+ # Strip PROJECT_SUBDIR prefix from test file paths ONLY when the hook
1783
+ # actually cd'd into the subdirectory (IN_PROJECT_SUBDIR=1). git diff returns
1784
+ # repo-root relative paths; after cd "$PROJECT_SUBDIR" vitest and [ -f ]
1785
+ # checks need subdirectory-relative paths. At the repo root (multi-language
1786
+ # projects) stripping would silently skip the mock-density scan and feed
1787
+ # vitest paths that do not exist. (v0.14.31+ fix, gated on the cd in v0.19.x)
1788
+ if [ "$IN_PROJECT_SUBDIR" = "1" ] && [ -n "$CHANGED_TEST_FILES" ]; then
1639
1789
  CHANGED_TEST_FILES=$(echo "$CHANGED_TEST_FILES" | sed "s|^${PROJECT_SUBDIR}/||")
1640
1790
  fi
1641
1791
 
@@ -1714,7 +1864,7 @@ else
1714
1864
  echo "⚠️ Gate 5c: req-ids.json is stale (specification.yaml updated since last check-alignment)"
1715
1865
  echo " Run 'xp-gate check-alignment' to regenerate before next commit."
1716
1866
  else
1717
- CHANGED_TEST_FILES_ANNOTATED=$(echo "$CHANGED_FILES" | grep -E '\.(test|spec)\.(ts|tsx|js|jsx)$' || true)
1867
+ CHANGED_TEST_FILES_ANNOTATED=$(echo "$CHANGED_FILES" | grep -E '\.(test|spec)\.(ts|tsx|js|jsx|cjs|mjs)$' || true)
1718
1868
  if [ -n "$CHANGED_TEST_FILES_ANNOTATED" ]; then
1719
1869
  echo " → Checking @test REQ-XXX annotations..."
1720
1870
  ANNOTATION_ERRORS=0
@@ -1743,7 +1893,7 @@ else
1743
1893
  # Check reverse: any REQ in spec with no annotated test → visible FAIL (non-blocking)
1744
1894
  ALL_SPEC_REQS=$(jq -r '.[]' "$REQ_IDS_FILE" 2>/dev/null)
1745
1895
  ANNOTATED_ALL=""
1746
- for tf in $(echo "$CHANGED_FILES" | grep -E '\.(test|spec)\.(ts|tsx|js|jsx)$' || true); do
1896
+ for tf in $(echo "$CHANGED_FILES" | grep -E '\.(test|spec)\.(ts|tsx|js|jsx|cjs|mjs)$' || true); do
1747
1897
  if [ -f "$tf" ]; then
1748
1898
  ANNOTATED_ALL="$ANNOTATED_ALL $(grep -oE '@test\s+REQ-[A-Z0-9-]+' "$tf" 2>/dev/null | sed 's/@test\s*//' || true)"
1749
1899
  fi
@@ -1797,16 +1947,47 @@ else
1797
1947
  # TypeScript: vitest with smart test selection
1798
1948
  if [ "$CURRENT_LANG" = "typescript" ] && { [ -f "package.json" ] || [ -f "$PROJECT_ROOT/package.json" ]; }; then
1799
1949
  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")
1950
+ # Count changed test files to decide strategy. `grep -c '.'` prints its
1951
+ # own zero AND exits 1 on an empty list, so `|| echo "0"` used to yield
1952
+ # $'0\n0' -- the same two-value bug that false-blocked Gate 8 (#493).
1953
+ # Bundled mirrors under src/npm-package/ are excluded from the root
1954
+ # vitest project (vitest.config.ts, #418), so selecting them makes
1955
+ # vitest exit 1 with "No test files found" and this gate reads that as
1956
+ # failing tests: a false BLOCK for any commit that only touches mirrors
1957
+ # (#494). Route both lists through the selector and announce it.
1958
+ SELECTED_TEST_FILES=$(select_root_suite_targets "$CHANGED_TEST_FILES")
1959
+ if [ "$SELECTED_TEST_FILES" != "$CHANGED_TEST_FILES" ]; then
1960
+ echo " ↪ Selection adjusted for bundled mirrors (#494):"
1961
+ echo " was: $(echo "$CHANGED_TEST_FILES" | tr '\n' ' ')"
1962
+ echo " now: $(echo "$SELECTED_TEST_FILES" | tr '\n' ' ')"
1963
+ fi
1964
+ CHANGED_TEST_FILE_COUNT=$(echo "$SELECTED_TEST_FILES" | grep -c '.' || true)
1802
1965
 
1803
1966
  # Also get changed source files (non-test) for related-test lookup
1804
1967
  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)
1805
- # 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}/||")
1968
+ # Nested plugin sub-projects are excluded from the root suite; their
1969
+ # sources must not drive root-vitest related-test lookup either (it
1970
+ # would resolve to plugin-local paths vitest cannot collect).
1971
+ CHANGED_SRC_FILES=$(echo "$CHANGED_SRC_FILES" | grep -vE '^plugins/[^/]+/' || true)
1972
+ # Mirror handling is identical for source files: their test-adjacent
1973
+ # paths must never reach vitest as root-project targets (#494).
1974
+ # Selection runs on repo-root relative paths (git diff output), before
1975
+ # any PROJECT_SUBDIR stripping, so the bundled-mirror prefixes match.
1976
+ SELECTED_SRC_FILES=$(select_root_suite_targets "$CHANGED_SRC_FILES")
1977
+ if [ "$SELECTED_SRC_FILES" != "$CHANGED_SRC_FILES" ]; then
1978
+ echo " ↪ Source selection adjusted for bundled mirrors (#494):"
1979
+ echo " was: $(echo "$CHANGED_SRC_FILES" | tr '\n' ' ')"
1980
+ echo " now: $(echo "$SELECTED_SRC_FILES" | tr '\n' ' ')"
1808
1981
  fi
1809
- CHANGED_SRC_COUNT=$(echo "$CHANGED_SRC_FILES" | grep -c '.' || echo "0")
1982
+ # Strip PROJECT_SUBDIR prefix ONLY when the hook cd'd into the
1983
+ # subdirectory (IN_PROJECT_SUBDIR=1; same gate as the CHANGED_TEST_FILES
1984
+ # fix above — at the repo root the paths must stay root-relative or the
1985
+ # related-test lookup and vitest targets resolve against the wrong dir,
1986
+ # #470)
1987
+ if [ "$IN_PROJECT_SUBDIR" = "1" ] && [ -n "$SELECTED_SRC_FILES" ]; then
1988
+ SELECTED_SRC_FILES=$(echo "$SELECTED_SRC_FILES" | sed "s|^${PROJECT_SUBDIR}/||")
1989
+ fi
1990
+ CHANGED_SRC_COUNT=$(echo "$SELECTED_SRC_FILES" | grep -c '.' || true)
1810
1991
 
1811
1992
  # Decide strategy
1812
1993
  if [ "$CHANGED_TEST_FILE_COUNT" -gt 20 ] 2>/dev/null; then
@@ -1816,24 +1997,24 @@ else
1816
1997
  TESTS_EXIT_CODE=$?
1817
1998
  echo "$TESTS_OUTPUT" | head -60
1818
1999
  if [ "$TESTS_EXIT_CODE" -ne 0 ]; then
1819
- echo ""
1820
- echo "❌ BLOCKED - Tests FAILED"
1821
- exit 1
2000
+ handle_test_failure "$TESTS_OUTPUT" "full test suite" || exit 1
1822
2001
  else
1823
2002
  echo "✅ PASSED - Full test suite passed (with coverage)."
1824
2003
  fi
1825
2004
  elif [ "$CHANGED_TEST_FILE_COUNT" -gt 0 ] 2>/dev/null; then
1826
2005
  # Run only changed test files with coverage
1827
- SMART_TARGETS=$(echo "$CHANGED_TEST_FILES" | tr '\n' ' ')
2006
+ PARTIAL_TEST_RUN=true # Mark as partial run for coverage check
2007
+ SMART_TARGETS=$(echo "$SELECTED_TEST_FILES" | tr '\n' ' ')
1828
2008
  echo "Running ${CHANGED_TEST_FILE_COUNT} changed test file(s) with coverage..."
1829
- echo " Target(s): $(echo "$CHANGED_TEST_FILES" | tr '\n' ' ')"
2009
+ echo " Target(s): $(echo "$SELECTED_TEST_FILES" | tr '\n' ' ')"
1830
2010
  TESTS_OUTPUT=$(run_without_git_context npx vitest run --coverage $SMART_TARGETS 2>&1)
1831
2011
  TESTS_EXIT_CODE=$?
1832
2012
  echo "$TESTS_OUTPUT" | head -60
1833
2013
  if [ "$TESTS_EXIT_CODE" -ne 0 ]; then
1834
- echo ""
1835
- echo "❌ BLOCKED - Tests FAILED in changed test files"
1836
- exit 1
2014
+ # Distinguish a real test failure from a runner infrastructure
2015
+ # error -- see #454. handle_test_failure routes both cases and
2016
+ # keeps the message consistent across every Gate 5 branch.
2017
+ handle_test_failure "$TESTS_OUTPUT" "changed test files" || exit 1
1837
2018
  else
1838
2019
  echo "✅ PASSED - Changed test files passed (with coverage)."
1839
2020
  fi
@@ -1841,7 +2022,7 @@ else
1841
2022
  # Source files changed but no test files → find related tests or run full
1842
2023
  echo "Source file(s) changed, finding related tests..."
1843
2024
  RELATED_TESTS=""
1844
- for src_file in $CHANGED_SRC_FILES; do
2025
+ for src_file in $SELECTED_SRC_FILES; do
1845
2026
  base="${src_file%.*}"
1846
2027
  filename="${base##*/}"
1847
2028
  dir="$(dirname "$src_file")"
@@ -1875,9 +2056,7 @@ else
1875
2056
  TESTS_EXIT_CODE=$?
1876
2057
  echo "$TESTS_OUTPUT" | head -60
1877
2058
  if [ "$TESTS_EXIT_CODE" -ne 0 ]; then
1878
- echo ""
1879
- echo "❌ BLOCKED - Tests FAILED in related test files"
1880
- exit 1
2059
+ handle_test_failure "$TESTS_OUTPUT" "related test files" || exit 1
1881
2060
  else
1882
2061
  echo "✅ PASSED - Related test files passed (with coverage)."
1883
2062
  fi
@@ -1888,9 +2067,7 @@ else
1888
2067
  TESTS_EXIT_CODE=$?
1889
2068
  echo "$TESTS_OUTPUT" | head -60
1890
2069
  if [ "$TESTS_EXIT_CODE" -ne 0 ]; then
1891
- echo ""
1892
- echo "❌ BLOCKED - Tests FAILED"
1893
- exit 1
2070
+ handle_test_failure "$TESTS_OUTPUT" "full test suite" || exit 1
1894
2071
  else
1895
2072
  echo "✅ PASSED - Full test suite passed (with coverage)."
1896
2073
  fi
@@ -1907,13 +2084,22 @@ else
1907
2084
  TESTS_EXIT_CODE=$?
1908
2085
  echo "$TESTS_OUTPUT" | head -50
1909
2086
  if [ "$TESTS_EXIT_CODE" -ne 0 ]; then
1910
- echo ""
1911
- echo "❌ BLOCKED - Tests FAILED"
1912
- exit 1
2087
+ # This branch is reached from the typescript adapter, so run_tests ran
2088
+ # vitest (adapters/typescript.sh) and its output is vitest-shaped: the
2089
+ # same three non-failure exits (#454 runner EPERM, #473 partial
2090
+ # threshold, #498 no test files) apply here as in the four branches
2091
+ # above. #454 left it unguarded because its own comment covers the
2092
+ # NON-typescript fallback below, whose runner is pytest/go/jest; this
2093
+ # one is not that branch.
2094
+ handle_test_failure "$TESTS_OUTPUT" "typescript adapter fallback" || exit 1
1913
2095
  fi
1914
2096
  fi
1915
2097
  else
1916
- # Non-TypeScript: standard adapter test flow
2098
+ # Adapter test flow for everything the TypeScript branch above did not
2099
+ # claim. The label this branch carried (#454) was "Non-TypeScript", which
2100
+ # is not what reaches it: a TypeScript project without a package.json also
2101
+ # lands here, and its adapter runs vitest — which is how a test-less TS
2102
+ # repo got false-blocked on "No test files found" (#498).
1917
2103
  # Skip for Python — tests already run with coverage in the coverage section below.
1918
2104
  # Running them here first without coverage doubles pre-commit time for no benefit.
1919
2105
  if [ "$CURRENT_LANG" = "python" ]; then
@@ -1923,9 +2109,18 @@ else
1923
2109
  TESTS_EXIT_CODE=$?
1924
2110
  echo "$TESTS_OUTPUT" | head -50
1925
2111
  if [ "$TESTS_EXIT_CODE" -ne 0 ]; then
1926
- echo ""
1927
- echo "❌ BLOCKED - Tests FAILED"
1928
- exit 1
2112
+ if [ "$CURRENT_LANG" = "typescript" ]; then
2113
+ # vitest output, so the same three non-failure exits are judged
2114
+ # here as in the branches above (#454, #473, #498).
2115
+ handle_test_failure "$TESTS_OUTPUT" "typescript adapter (no package.json)" || exit 1
2116
+ else
2117
+ # Genuinely non-vitest runners (pytest/go/jest/...): their output
2118
+ # shape is not vitest's, so the guard could excuse a real failure.
2119
+ # They keep blocking on any non-zero exit (#454).
2120
+ echo ""
2121
+ echo "❌ BLOCKED - Tests FAILED"
2122
+ exit 1
2123
+ fi
1929
2124
  else
1930
2125
  echo "✅ PASSED - Unit tests passed."
1931
2126
  fi
@@ -2044,8 +2239,9 @@ else
2044
2239
  " 2>/dev/null)
2045
2240
  if [ "$COVERAGE_PERCENT" != "parse_error" ] && [ -n "$COVERAGE_PERCENT" ]; then
2046
2241
  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
2242
+ # Only a subset test run (smart selection) leaves partial coverage data;
2243
+ # a full suite run must block on <80% even when many test files changed.
2244
+ if [ "${PARTIAL_TEST_RUN:-false}" = "true" ]; then
2049
2245
  echo "⚠️ Partial coverage ${COVERAGE_PERCENT}% (subset test run, not full suite)."
2050
2246
  echo " Run 'npx vitest run --coverage' for full coverage check."
2051
2247
  else
@@ -2085,6 +2281,64 @@ else
2085
2281
  echo "⚠️ coverage.py not available, cannot enforce Python coverage threshold."
2086
2282
  fi
2087
2283
  ;;
2284
+ "java")
2285
+ COVERAGE_ENFORCED=true
2286
+ # JaCoCo writes target/site/jacoco/ (Maven) or build/reports/jacoco/test/
2287
+ # (Gradle), i.e. neither path probed by the default branch below, so Java
2288
+ # needs its own branch to be measurable at all.
2289
+ jacoco_csv=""
2290
+ for jacoco_candidate in \
2291
+ "target/site/jacoco/jacoco.csv" \
2292
+ "build/reports/jacoco/test/jacocoTestReport.csv"; do
2293
+ [ -f "$jacoco_candidate" ] && { jacoco_csv="$jacoco_candidate"; break; }
2294
+ done
2295
+
2296
+ if [ -n "$jacoco_csv" ]; then
2297
+ # Aggregate INSTRUCTION_MISSED / INSTRUCTION_COVERED over every class row.
2298
+ # Columns are located by header name because their position is not part of
2299
+ # the CSV's stable contract.
2300
+ COVERAGE_PERCENT=$(awk -F, '
2301
+ NR == 1 {
2302
+ for (i = 1; i <= NF; i++) {
2303
+ if ($i == "INSTRUCTION_MISSED") missed_col = i;
2304
+ if ($i == "INSTRUCTION_COVERED") covered_col = i;
2305
+ }
2306
+ next;
2307
+ }
2308
+ missed_col && covered_col {
2309
+ missed += $missed_col;
2310
+ covered += $covered_col;
2311
+ }
2312
+ END {
2313
+ total = missed + covered;
2314
+ if (total > 0) printf "%.2f", (covered / total) * 100;
2315
+ else print "";
2316
+ }
2317
+ ' "$jacoco_csv" 2>/dev/null) || COVERAGE_PERCENT=""
2318
+
2319
+ if [ -n "$COVERAGE_PERCENT" ]; then
2320
+ # Compare on the unrounded value so 79.5% cannot pass as 80%.
2321
+ COVERAGE_ROUNDED=${COVERAGE_PERCENT%.*}
2322
+ if awk -v pct="$COVERAGE_PERCENT" 'BEGIN { exit !(pct < 80) }'; then
2323
+ if [ "${PARTIAL_TEST_RUN:-false}" = "true" ]; then
2324
+ echo "⚠️ Partial coverage ${COVERAGE_ROUNDED}% (subset test run, not full suite)."
2325
+ echo " Run the full test suite for a complete Java coverage check."
2326
+ else
2327
+ echo "❌ BLOCKED - Java coverage ${COVERAGE_ROUNDED}% below 80% threshold"
2328
+ exit 1
2329
+ fi
2330
+ else
2331
+ echo "Java coverage: ${COVERAGE_ROUNDED}% ✅ (≥ 80%)"
2332
+ fi
2333
+ else
2334
+ echo "⚠️ Could not parse Java coverage from ${jacoco_csv} (no instruction data)."
2335
+ fi
2336
+ else
2337
+ echo "⚠️ No JaCoCo report found. Run 'mvn verify' or 'gradle jacocoTestReport'"
2338
+ echo " so that target/site/jacoco/jacoco.csv exists — otherwise Java coverage"
2339
+ echo " cannot be enforced (see also: JaCoCo detection in adapters/java.sh)."
2340
+ fi
2341
+ ;;
2088
2342
  "go")
2089
2343
  # Go enforcement already handled inline in Stage 1 (lines 933-942)
2090
2344
  ;;
@@ -2147,6 +2401,10 @@ else
2147
2401
  echo "⚠️ coverage.xml not found. Ensure Pester coverage was run."
2148
2402
  fi
2149
2403
  ;;
2404
+ "iac")
2405
+ # IaC has no coverage concept — must not borrow the TypeScript report
2406
+ # that Stage 1 leaves behind (same rule as the PowerShell branch).
2407
+ ;;
2150
2408
  *)
2151
2409
  # Default: attempt to parse coverage from common formats regardless of language
2152
2410
  COVERAGE_ENFORCED=true
@@ -2170,10 +2428,18 @@ else
2170
2428
  fi
2171
2429
  if [ -n "$COVERAGE_PERCENT" ] && [ "$COVERAGE_PERCENT" != "parse_error" ] && [ "$COVERAGE_PERCENT" -gt 0 ]; then
2172
2430
  if [ "$COVERAGE_PERCENT" -lt 80 ]; then
2173
- echo "❌ BLOCKED - ${CURRENT_LANG} coverage ${COVERAGE_PERCENT}% below 80% threshold"
2174
- exit 1
2431
+ # Only a subset test run (smart selection) warns — the full suite may
2432
+ # still reach ≥80%; a full suite run must block.
2433
+ if [ "${PARTIAL_TEST_RUN:-false}" = "true" ]; then
2434
+ echo "⚠️ Partial coverage ${COVERAGE_PERCENT}% (subset test run, not full suite)."
2435
+ echo " Run the full test suite for a complete ${CURRENT_LANG} coverage check."
2436
+ else
2437
+ echo "❌ BLOCKED - ${CURRENT_LANG} coverage ${COVERAGE_PERCENT}% below 80% threshold"
2438
+ exit 1
2439
+ fi
2440
+ else
2441
+ echo "${CURRENT_LANG} coverage: ${COVERAGE_PERCENT}% ✅ (≥ 80%)"
2175
2442
  fi
2176
- echo "${CURRENT_LANG} coverage: ${COVERAGE_PERCENT}% ✅ (≥ 80%)"
2177
2443
  else
2178
2444
  echo "⚠️ Could not determine ${CURRENT_LANG} coverage percentage. No standard coverage report files found."
2179
2445
  fi
@@ -2314,10 +2580,20 @@ else
2314
2580
  fi
2315
2581
  ;;
2316
2582
  "python")
2317
- if [ -f ".import-linter.yml" ] || [ -f "import_linter_config.yml" ]; then
2583
+ # detect any import-linter carrier (incl. pyproject.toml [tool.importlinter])
2584
+ 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
2585
  if require_tool "lint-imports" "Gate 6" "pip install import-linter"; then
2319
2586
  echo " Running import-linter for Python..."
2320
- lint-imports 2>&1 | head -20
2587
+ # run at PROJECT_ROOT (hook may have cd'd); block on exit code
2588
+ LINT_IMPORTS_OUTPUT=$(cd "$PROJECT_ROOT" && lint-imports 2>&1)
2589
+ LINT_IMPORTS_EXIT=$?
2590
+ echo "$LINT_IMPORTS_OUTPUT" | head -30
2591
+ if [ "$LINT_IMPORTS_EXIT" -ne 0 ]; then
2592
+ echo ""
2593
+ echo "❌ BLOCKED - Python architecture contract violations (import-linter)"
2594
+ echo "Fix the broken contracts above before committing."
2595
+ exit 1
2596
+ fi
2321
2597
  echo " ✅ Python architecture validation completed."
2322
2598
  else
2323
2599
  echo " ❌ BLOCKED - import-linter not installed"
@@ -2325,7 +2601,7 @@ else
2325
2601
  exit 1
2326
2602
  fi
2327
2603
  else
2328
- echo " ℹ️ No .import-linter.yml found - skipping Python architecture"
2604
+ echo " ℹ️ No import-linter config found (.import-linter.yml/.importlinter/[tool.importlinter]) - skipping"
2329
2605
  echo " ⏭️ SKIPPED - Python architecture validation (no config)"
2330
2606
  fi
2331
2607
  ;;
@@ -2415,20 +2691,30 @@ else
2415
2691
  if [ -n "$BOY_SCOUT_SCRIPT" ]; then
2416
2692
  echo " Checking Boy Scout Rule compliance..."
2417
2693
 
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' ' ')
2694
+ # Separate new files and modified files.
2695
+ #
2696
+ # src/npm-package/** is a byte-identical MIRROR of canonical sources
2697
+ # (plugins/**, src/principles/**, githooks/**) produced by
2698
+ # sync-package-content.js at publish time. Checking it here double-counts
2699
+ # every warning: the mirror path enters the baseline instead of the source
2700
+ # path, so a violation gets recorded against a path nobody edits and the
2701
+ # real source path stays unbudgeted. Exclude it and let the mirroring be
2702
+ # governed by Mirror Parity in CI, which compares content rather than
2703
+ # re-linting it. (See the same exclusion in adapters/powershell.sh.)
2704
+ MIRROR_EXCLUDE='^src/npm-package/'
2705
+ NEW_FILES=$(git diff --cached --name-only --diff-filter=A | grep -v "$MIRROR_EXCLUDE" | tr '\n' ' ')
2706
+ MODIFIED_FILES=$(git diff --cached --name-only --diff-filter=M | grep -v "$MIRROR_EXCLUDE" | tr '\n' ' ')
2421
2707
 
2422
2708
  if [ -z "$NEW_FILES" ] && [ -z "$MODIFIED_FILES" ]; then
2423
2709
  echo " ✅ No new or modified files to check."
2424
2710
  else
2425
2711
  # Run Boy Scout Rule enforcement
2426
- if command -v npx &> /dev/null; then
2712
+ if command -v npx &> /dev/null || [ -f "${PROJECT_ROOT:-$(pwd)}/node_modules/tsx/dist/cli.mjs" ]; then
2427
2713
  # Skip if no files to check
2428
2714
  if [ -z "$(echo "$NEW_FILES" | tr -d ' ')" ] && [ -z "$(echo "$MODIFIED_FILES" | tr -d ' ')" ]; then
2429
2715
  echo " ✅ No new or modified source files for Boy Scout check."
2430
2716
  else
2431
- BOY_SCOUT_OUTPUT=$(npx tsx $BOY_SCOUT_SCRIPT \
2717
+ BOY_SCOUT_OUTPUT=$(run_tsx "$BOY_SCOUT_SCRIPT" \
2432
2718
  --new-files "$(echo "$NEW_FILES" | tr ' ' ',')" \
2433
2719
  --modified-files "$(echo "$MODIFIED_FILES" | tr ' ' ',')" \
2434
2720
  --baseline ".warnings-baseline.json" 2>&1)
@@ -2455,12 +2741,22 @@ else
2455
2741
  echo "❌ BLOCKED - Boy Scout Rule violations detected ($VIOLATION_COUNT)"
2456
2742
  exit 1
2457
2743
  fi
2458
-
2744
+
2745
+ # REQ-4 (#452): the auto-init rewrites a committed file, which used to
2746
+ # surface as an unexplained dirty ` M .warnings-baseline.json` after
2747
+ # every commit that touched a first-time-modified file. Stage it so the
2748
+ # budget update travels with the commit that caused it; when the
2749
+ # baseline is unchanged (the common case) this is a no-op.
2750
+ if [ -n "$(git status --porcelain -- .warnings-baseline.json 2>/dev/null)" ]; then
2751
+ git add .warnings-baseline.json 2>/dev/null || true
2752
+ echo " ℹ️ .warnings-baseline.json auto-updated and staged with this commit."
2753
+ fi
2754
+
2459
2755
  echo " ✅ PASSED - Boy Scout Rule compliance."
2460
2756
  fi
2461
2757
  else
2462
- echo " ℹ️ npx not available - skipping Boy Scout Rule"
2463
- echo " ⏭️ SKIPPED - Boy Scout Rule (Node.js/npx not available)"
2758
+ echo " ℹ️ npx/tsx not available - skipping Boy Scout Rule"
2759
+ echo " ⏭️ SKIPPED - Boy Scout Rule (Node.js/tsx not available)"
2464
2760
  fi
2465
2761
  fi
2466
2762
  else
@@ -2516,7 +2812,7 @@ if [ -f "package.json" ] && [ -f "tsconfig.json" ]; then
2516
2812
  if [ -n "$GATE_9_SCRIPT" ]; then
2517
2813
  CHANGED_FILES_CSV=$(echo "$CHANGED_FILES" | tr ' ' ',')
2518
2814
  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
2815
+ 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
2816
  GATE_9_STATUS="PASS"
2521
2817
  echo " ✅ Build integrity check passed."
2522
2818
  else
@@ -2544,8 +2840,9 @@ else
2544
2840
  echo " ⏭️ SKIPPED - Build integrity (not a TypeScript project)"
2545
2841
  fi
2546
2842
 
2547
- GATE_9_END=$(date +%s)
2548
- GATE_9_DURATION=$((GATE_9_END - GATE_9_START))
2843
+ GATE_9_END=$(gate_start_ms)
2844
+ GATE_9_DURATION_MS=$((GATE_9_END - GATE_9_START))
2845
+ GATE_9_DURATION=$((GATE_9_DURATION_MS / 1000))
2549
2846
  echo "✅ Gate 9 completed in ${GATE_9_DURATION}s"
2550
2847
  record_gate_audit "gate-9" "build-integrity" "$GATE_9_STATUS" "0" "$GATE_9_START"
2551
2848
  echo ""
@@ -2579,34 +2876,65 @@ GATE_11_START=$(date +%s)
2579
2876
  GATE_11_STATUS="PASS"
2580
2877
 
2581
2878
  # 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
2879
+ # Every tier is a *directory*; the resolver returns the first one that actually holds
2880
+ # sprint-gate.sh. The chain used to stop at the gate-script directories, but the flat
2881
+ # global layout ($GATE_DIR -> ~/.config/xp-gate/adapters) carries gate-3/4/7/8/9/10.sh
2882
+ # and never this script: update-hooks installs sprint-gate.sh under
2883
+ # ~/.config/xp-gate/hooks, which nothing probed, so a correctly installed machine was
2884
+ # told the file was missing (#476).
2885
+ SPRINT_GATE_CANDIDATES=(
2886
+ "$GATE_DIR"
2887
+ "$(git rev-parse --show-toplevel 2>/dev/null)/githooks"
2888
+ "$SCRIPT_DIR"
2889
+ "$HOME/.config/xp-gate/hooks"
2890
+ )
2891
+ SPRINT_GATE_SCRIPT=$(resolve_sprint_gate_script ${SPRINT_GATE_CANDIDATES[@]+"${SPRINT_GATE_CANDIDATES[@]}"})
2592
2892
  # END SPRINT_GATE_SCRIPT RESOLUTION
2593
2893
 
2894
+ echo "→ Gate 11: Sprint Flow Enforcement..."
2895
+
2594
2896
  if [ -n "$SPRINT_GATE_SCRIPT" ]; then
2595
- if ! bash "$SPRINT_GATE_SCRIPT" --pre-commit; then
2897
+ # The decision JSON was piped straight to the terminal with no heading, so it landed
2898
+ # under Gate 10's banner and a legitimate "skip: not a sprint project" looked like a
2899
+ # bare machine object. The exit status stays the authority for blocking; the JSON only
2900
+ # decides which words the user reads.
2901
+ SPRINT_GATE_EXIT=0
2902
+ SPRINT_GATE_OUTPUT=$(bash "$SPRINT_GATE_SCRIPT" --pre-commit 2>&1) || SPRINT_GATE_EXIT=$?
2903
+ SPRINT_GATE_DECISION=$(sprint_gate_decision "$SPRINT_GATE_OUTPUT")
2904
+ SPRINT_GATE_DETAIL=$(sprint_gate_detail "$SPRINT_GATE_OUTPUT")
2905
+
2906
+ if [ "$SPRINT_GATE_EXIT" -ne 0 ]; then
2596
2907
  GATE_11_STATUS="BLOCK"
2597
- echo "❌ BLOCKED - Gate 11: Sprint Flow Enforcement"
2908
+ echo "❌ BLOCKED - Gate 11: Sprint Flow (${SPRINT_GATE_DETAIL:-sprint gate denied})"
2909
+ printf '%s\n' "$SPRINT_GATE_OUTPUT" | sed 's/^/ /'
2598
2910
  record_gate_audit "gate-11" "sprint-flow" "$GATE_11_STATUS" "1" "$GATE_11_START"
2599
2911
  # Sprint gate failure is a hard block — exit immediately
2600
2912
  exit 1
2601
2913
  fi
2914
+
2915
+ case "$SPRINT_GATE_DECISION" in
2916
+ skip)
2917
+ echo "⏭️ SKIPPED - Gate 11: Sprint Flow (${SPRINT_GATE_DETAIL:-no reason given})"
2918
+ GATE_11_STATUS="SKIP"
2919
+ ;;
2920
+ allow)
2921
+ echo "✅ PASSED - Gate 11: Sprint Flow (${SPRINT_GATE_DETAIL:-sprint state validated})"
2922
+ ;;
2923
+ *)
2924
+ # A run that succeeded but said nothing this gate understands. Pass on the exit
2925
+ # status, but keep the raw output visible rather than inventing a verdict.
2926
+ echo "✅ PASSED - Gate 11: Sprint Flow (no recognisable decision in the gate output)"
2927
+ printf '%s\n' "$SPRINT_GATE_OUTPUT" | sed 's/^/ /'
2928
+ ;;
2929
+ esac
2602
2930
  else
2603
- echo "⏭️ SKIPPED - Gate 11: Sprint Flow (sprint-gate.sh not found)"
2931
+ echo "⏭️ SKIPPED - Gate 11: Sprint Flow (sprint-gate.sh not found; searched: $(IFS=', '; printf '%s' "${SPRINT_GATE_CANDIDATES[*]}"))"
2604
2932
  GATE_11_STATUS="SKIP"
2605
2933
  fi
2606
2934
 
2607
2935
  GATE_11_END=$(date +%s)
2608
2936
  GATE_11_DURATION=$((GATE_11_END - GATE_11_START))
2609
- echo "✅ Gate 11 completed in ${GATE_11_DURATION}s"
2937
+ echo " Gate 11 ($GATE_11_STATUS) completed in ${GATE_11_DURATION}s"
2610
2938
  record_gate_audit "gate-11" "sprint-flow" "$GATE_11_STATUS" "0" "$GATE_11_START"
2611
2939
  echo ""
2612
2940
  # ============================================================================
@@ -2666,8 +2994,10 @@ generate_quality_report() {
2666
2994
  if [ -n "$TESTS_OUTPUT" ] && echo "$TESTS_OUTPUT" | grep -qi "fail\|error\|FAILED"; then
2667
2995
  # Extract failure lines: "FAIL src/foo.test.ts" or " ✗ test name"
2668
2996
  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="," }')
2997
+ # errexit/pipefail-safe: grep returns 1 on zero matches (see #489) and this
2998
+ # function is also sourced by consumer wrappers that may enable errexit
2999
+ FAIL_LINES=$(echo "$TESTS_OUTPUT" | grep -E "^\s*(FAIL|✗|failed|AssertionError)" | sed -n '1,10p' | \
3000
+ sed 's/"/\\"/g' | awk '{ printf "%s%s%s", sep, "\"", $0; sep="," }') || true
2671
3001
  if [ -n "$FAIL_LINES" ]; then
2672
3002
  FAILED_TESTS_JSON="[$FAIL_LINES]"
2673
3003
  fi