@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.
- package/adapters/gate-4.sh +83 -18
- package/adapters/gate-8.sh +115 -29
- package/adapters/iac.sh +1 -1
- package/adapters/powershell.sh +60 -5
- package/adapters/python.sh +38 -4
- package/adapters/typescript.sh +15 -60
- package/bin/xp-gate.js +15 -16
- package/build-integrity/__tests__/import-resolver.test.ts +59 -0
- package/build-integrity/gate-10.ts +17 -2
- package/gate-10.sh +35 -2
- package/gate-4.sh +110 -21
- package/gate-8.sh +115 -29
- package/hooks/gate-10.sh +35 -2
- package/hooks/gate-4.sh +110 -21
- package/hooks/gate-8.sh +115 -29
- package/hooks/lib/jscpd-run.sh +63 -0
- package/hooks/lib/sprint-gate-report.sh +58 -0
- package/hooks/lib/test-failure.sh +152 -0
- package/hooks/lib/typecheck.sh +141 -0
- package/hooks/lib/validate-code-walkthrough.cjs +43 -5
- package/hooks/post-merge +100 -0
- package/hooks/pre-commit +514 -184
- package/hooks/pre-push +75 -19
- package/lib/__tests__/bootstrap.test.js +23 -41
- package/lib/__tests__/check-version.test.js +8 -1
- package/lib/__tests__/detect-deps.test.js +16 -0
- package/lib/__tests__/doctor-drift-round2.test.js +212 -0
- package/lib/__tests__/doctor-executed-surface.test.js +209 -0
- package/lib/__tests__/doctor-hook-drift.test.js +329 -0
- package/lib/__tests__/doctor-machine-mutation.test.js +116 -0
- package/lib/__tests__/doctor-module-drift.test.js +206 -0
- package/lib/__tests__/doctor-module-resolution-parity.test.js +314 -0
- package/lib/__tests__/doctor-path-equivalence.test.js +59 -0
- package/lib/__tests__/doctor-repair-boundary.test.js +52 -0
- package/lib/__tests__/doctor-tui-skill-freshness.test.js +105 -0
- package/lib/__tests__/doctor.test.js +155 -0
- package/lib/__tests__/gate-runner.test.js +19 -3
- package/lib/__tests__/helpers/lib-sandbox.cjs +55 -0
- package/lib/__tests__/init.test.js +44 -17
- package/lib/__tests__/install-skill-full-dir.test.js +151 -0
- package/lib/__tests__/install-skill.test.js +51 -132
- package/lib/__tests__/language-tools-detect.test.js +158 -0
- package/lib/__tests__/phase-transition.test.js +15 -1
- package/lib/__tests__/qoder-delphi-agents.test.js +285 -0
- package/lib/__tests__/shared-paths.test.js +20 -0
- package/lib/__tests__/shared-utils.test.js +69 -0
- package/lib/__tests__/skill-flags.test.js +48 -0
- package/lib/__tests__/ui-detector.test.ts +6 -2
- package/lib/__tests__/uninstall.test.js +20 -0
- package/lib/__tests__/update-hooks.test.js +12 -0
- package/lib/__tests__/update-skill.test.js +7 -4
- package/lib/detect-deps.js +3 -20
- package/lib/doctor-tui.js +22 -5
- package/lib/doctor.js +1178 -31
- package/lib/init.js +184 -33
- package/lib/install-cmd.js +7 -4
- package/lib/install-skill.js +38 -95
- package/lib/language-tools.js +83 -4
- package/lib/mattpocock-license-verdict.json +1 -1
- package/lib/shared-paths.js +5 -2
- package/lib/shared-utils.js +47 -3
- package/lib/uninstall.js +12 -0
- package/lib/update-hooks.js +4 -4
- package/lib/update-skill.js +6 -7
- package/mock-policy/AGENTS.md +4 -4
- package/mutation/AGENTS.md +4 -4
- package/mutation/__tests__/gate-m.test.ts +66 -14
- package/mutation/__tests__/mutmut-runner.test.ts +91 -42
- package/mutation/__tests__/test-filter-parity.test.ts +37 -0
- package/mutation/gate-m.ts +21 -8
- package/package.json +2 -2
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +4 -4
- package/plugins/claude-code/skills/delphi-review/SKILL.md +98 -14
- package/plugins/claude-code/skills/delphi-review/references/code-walkthrough.md +36 -3
- package/plugins/claude-code/skills/delphi-review/references/requirements.md +6 -6
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +4 -4
- package/plugins/claude-code/skills/sprint-flow/references/phase-2-design.md +3 -3
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +4 -4
- package/plugins/dsh/README.md +169 -4
- package/plugins/dsh/dsh-overlay.patch.yml +67 -0
- package/plugins/dsh/package.json +1 -1
- package/plugins/dsh/scripts/prepack.cjs +24 -19
- package/plugins/dsh/setup-dsh.ps1 +160 -0
- package/plugins/dsh/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
- package/plugins/dsh/skills/delphi-review/AGENTS.md +4 -4
- package/plugins/dsh/skills/delphi-review/SKILL.md +98 -14
- package/plugins/dsh/skills/delphi-review/references/code-walkthrough.md +36 -3
- package/plugins/dsh/skills/delphi-review/references/requirements.md +6 -6
- package/plugins/dsh/skills/sprint-flow/AGENTS.md +4 -4
- package/plugins/dsh/skills/sprint-flow/references/phase-2-design.md +3 -3
- package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +4 -4
- package/plugins/dsh/src/command.test.ts +66 -2
- package/plugins/dsh/src/command.ts +36 -14
- package/plugins/dsh/src/contract.test.ts +31 -1
- package/plugins/dsh/src/delphi-config.test.ts +190 -0
- package/plugins/dsh/src/delphi-config.ts +223 -0
- package/plugins/dsh/src/delphi-run.test.ts +181 -0
- package/plugins/dsh/src/delphi-run.ts +129 -0
- package/plugins/dsh/src/delphi.test.ts +222 -0
- package/plugins/dsh/src/delphi.ts +247 -0
- package/plugins/dsh/src/dialect.test.ts +59 -0
- package/plugins/dsh/src/dialect.ts +40 -0
- package/plugins/dsh/src/index.ts +70 -4
- package/plugins/dsh/src/skills.test.ts +26 -19
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +4 -4
- package/plugins/opencode/skills/delphi-review/SKILL.md +98 -14
- package/plugins/opencode/skills/delphi-review/references/code-walkthrough.md +36 -3
- package/plugins/opencode/skills/delphi-review/references/requirements.md +6 -6
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +4 -4
- package/plugins/opencode/skills/sprint-flow/references/phase-2-design.md +3 -3
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +4 -4
- package/plugins/qoder/agents/delphi-architecture.md +1 -1
- package/plugins/qoder/agents/delphi-feasibility.md +1 -1
- package/plugins/qoder/agents/delphi-technical.md +1 -1
- package/plugins/qoder/plugin.json +1 -1
- package/plugins/qoder/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
- package/plugins/qoder/skills/delphi-review/AGENTS.md +4 -4
- package/plugins/qoder/skills/delphi-review/SKILL.md +98 -14
- package/plugins/qoder/skills/delphi-review/references/code-walkthrough.md +36 -3
- package/plugins/qoder/skills/delphi-review/references/requirements.md +6 -6
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +4 -4
- package/plugins/qoder/skills/sprint-flow/references/phase-2-design.md +3 -3
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +4 -4
- package/principles/AGENTS.md +9 -8
- package/principles/__tests__/adapter-metrics.test.ts +292 -0
- package/principles/__tests__/analyzer.test.ts +29 -6
- package/principles/__tests__/baseline-prune-timeout.test.ts +71 -0
- package/principles/__tests__/baseline-prune.test.ts +338 -0
- package/principles/__tests__/boy-scout-init-baseline.test.ts +167 -0
- package/principles/__tests__/boy-scout-integration.test.ts +97 -6
- package/principles/__tests__/boy-scout.test.ts +253 -7
- package/principles/__tests__/config-enforcement.test.ts +469 -0
- package/principles/__tests__/config-git-timeout.test.ts +66 -0
- package/principles/__tests__/direct-execution.test.ts +187 -0
- package/principles/__tests__/index.test.ts +16 -3
- package/principles/__tests__/warnings-baseline-budgets.test.ts +74 -0
- package/principles/adapters/typescript.ts +613 -13
- package/principles/analyzer.ts +22 -0
- package/principles/baseline-prune.ts +217 -0
- package/principles/boy-scout.ts +516 -56
- package/principles/config.ts +362 -54
- package/principles/direct-execution.ts +60 -0
- package/principles/index.ts +40 -17
- package/principles/rules/__tests__/clean-code/many-exports.test.ts +16 -10
- package/principles/rules/clean-code/code-duplication.ts +8 -8
- package/principles/rules/clean-code/deep-nesting.ts +8 -8
- package/principles/rules/clean-code/god-class.ts +8 -8
- package/principles/rules/clean-code/large-file.ts +15 -11
- package/principles/rules/clean-code/long-function.ts +8 -8
- package/principles/rules/clean-code/magic-numbers.ts +10 -7
- package/principles/rules/clean-code/many-exports.ts +7 -7
- package/principles/rules/clean-code/missing-error-handling.ts +5 -5
- package/principles/rules/clean-code/too-many-params.ts +8 -8
- package/principles/rules/clean-code/unused-imports.ts +5 -5
- package/principles/rules/solid/dip.ts +18 -8
- package/principles/rules/solid/isp.ts +9 -8
- package/principles/rules/solid/lsp.ts +6 -6
- package/principles/rules/solid/ocp.ts +6 -5
- package/principles/rules/solid/srp.ts +7 -8
- package/principles/types.ts +15 -0
- package/scripts/__tests__/sync-package-content.test.js +28 -0
- package/scripts/delphi-external-review.cjs +193 -10
- package/scripts/sync-package-content.js +20 -10
- package/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
- package/skills/delphi-review/AGENTS.md +4 -4
- package/skills/delphi-review/SKILL.md +98 -14
- package/skills/delphi-review/references/code-walkthrough.md +36 -3
- package/skills/delphi-review/references/requirements.md +6 -6
- package/skills/sprint-flow/AGENTS.md +4 -4
- package/skills/sprint-flow/references/phase-2-design.md +3 -3
- package/skills/test-specification-alignment/AGENTS.md +4 -4
- package/lib/__tests__/download-skill.test.js +0 -281
- 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
|
|
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
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
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 "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
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
|
|
1017
|
-
#
|
|
1018
|
-
|
|
1019
|
-
|
|
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 $
|
|
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
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
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
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
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
|
-
|
|
1113
|
-
|
|
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 $
|
|
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
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
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
|
-
|
|
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
|
|
1429
|
-
#
|
|
1430
|
-
#
|
|
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 [
|
|
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
|
-
#
|
|
1636
|
-
#
|
|
1637
|
-
#
|
|
1638
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
1806
|
-
|
|
1807
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 "$
|
|
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
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
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 $
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
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
|
-
#
|
|
2048
|
-
|
|
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
|
-
|
|
2174
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
2420
|
-
|
|
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=$(
|
|
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/
|
|
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
|
|
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=$(
|
|
2548
|
-
|
|
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
|
-
#
|
|
2583
|
-
#
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
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
|
-
|
|
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
|
|
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 "
|
|
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
|
-
|
|
2670
|
-
|
|
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
|