@boyingliu01/xp-gate 0.30.2 → 0.30.4
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/iac.sh +45 -4
- package/adapters/java.sh +82 -16
- package/adapters/powershell.sh +70 -9
- package/gate-10.sh +110 -13
- package/gate-3.sh +17 -0
- package/gate-4.sh +12 -0
- package/gate-7.sh +4 -0
- package/hooks/gate-10.sh +110 -13
- package/hooks/gate-3.sh +17 -0
- package/hooks/gate-4.sh +12 -0
- package/hooks/gate-7.sh +4 -0
- package/hooks/hook-module-resolution.sh +14 -0
- package/hooks/lib/coverage-total.sh +1 -1
- package/hooks/pre-commit +138 -17
- package/hooks/pre-push +150 -9
- package/hooks/sprint-gate.sh +81 -2
- package/lib/__tests__/hook-health.test.js +85 -0
- package/lib/coverage-total.sh +92 -0
- package/lib/doctor.js +44 -0
- package/lib/hook-health.js +121 -0
- package/lib/mattpocock-license-verdict.json +1 -1
- package/mock-policy/AGENTS.md +2 -2
- package/mutation/AGENTS.md +2 -2
- package/mutation/__tests__/gate-m-survivors.test.ts +133 -0
- package/mutation/gate-m.ts +114 -11
- package/package.json +1 -1
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +2 -2
- package/plugins/dsh/package.json +1 -1
- package/plugins/dsh/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/dsh/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +2 -2
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +2 -2
- package/plugins/qoder/plugin.json +1 -1
- package/plugins/qoder/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +2 -2
- package/principles/AGENTS.md +2 -2
- package/skills/delphi-review/AGENTS.md +2 -2
- package/skills/sprint-flow/AGENTS.md +2 -2
- package/skills/test-specification-alignment/AGENTS.md +2 -2
- package/sprint-gate.sh +81 -2
package/hooks/pre-commit
CHANGED
|
@@ -1,4 +1,20 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
|
+
# Line-ending self-check (#546). Must stay on the first executable line: if this
|
|
3
|
+
# file is ever rewritten with CRLF, bash needs to reach this check before the
|
|
4
|
+
# parser chokes somewhere far away and blames an innocent line. `tr` also
|
|
5
|
+
# strips the \r from this very line, so the check runs even when everything
|
|
6
|
+
# around it is broken.
|
|
7
|
+
if tr -d '\r' < "$0" | sed -n '2p' | tr -d '\r' | grep -q 'Line-ending self-check'; then
|
|
8
|
+
if [ "$(tr -cd '\r' < "$0" | wc -c | tr -d '[:space:]')" -gt 0 ]; then
|
|
9
|
+
echo "======================================================================" >&2
|
|
10
|
+
echo "FATAL: hook has CRLF line endings — bash cannot parse it (#546)." >&2
|
|
11
|
+
echo " file: $0" >&2
|
|
12
|
+
echo " fix : tr -d '\\r' < \"$0\" > \"$0.tmp\" && mv \"$0.tmp\" \"$0\" && chmod +x \"$0\"" >&2
|
|
13
|
+
echo "Or re-sync the installed copy: xp-gate doctor --sync-hooks" >&2
|
|
14
|
+
echo "======================================================================" >&2
|
|
15
|
+
exit 1
|
|
16
|
+
fi
|
|
17
|
+
fi
|
|
2
18
|
# OpenCode Quality Gates - Pre-Commit Hook - 12 numbered gates (Gate 0–11)
|
|
3
19
|
#
|
|
4
20
|
# DESIGN PRINCIPLE: Tool unavailable = SKIP (graceful degradation), tool available + check fails = BLOCK
|
|
@@ -41,6 +57,41 @@ fi
|
|
|
41
57
|
# shellcheck source=/dev/null
|
|
42
58
|
. "$HOOK_RESOLUTION_SH"
|
|
43
59
|
|
|
60
|
+
# --- Coverage threshold: ONE resolver, one number (#555) --------------------
|
|
61
|
+
# The threshold used to exist three times with two different values: the report
|
|
62
|
+
# printed a literal 80, coverage-total.sh compared against a literal 80, and the
|
|
63
|
+
# generic block branch echoed "below 80% threshold" — while the repo's own
|
|
64
|
+
# pyproject declared fail_under. CI honours the declared value, so a commit at
|
|
65
|
+
# 81% passed the hook (judged against 80) and failed CI (82). The report was the
|
|
66
|
+
# worst of the three: it quoted a threshold as though it were the enforced one,
|
|
67
|
+
# and a plausible wrong number is harder to notice than a visible gap.
|
|
68
|
+
#
|
|
69
|
+
# Resolve from the repo's own declaration, in the order the tools themselves
|
|
70
|
+
# use: pyproject [tool.coverage.report] fail_under, then .coveragerc. A repo
|
|
71
|
+
# declaring neither keeps the historical 80.
|
|
72
|
+
_resolve_coverage_threshold() {
|
|
73
|
+
_rct_value=""
|
|
74
|
+
_rct_src="default"
|
|
75
|
+
if [ -f pyproject.toml ]; then
|
|
76
|
+
_rct_value=$(python -c "import tomllib;print(tomllib.load(open('pyproject.toml','rb'))['tool']['coverage']['report']['fail_under'])" 2>/dev/null || true)
|
|
77
|
+
[ -n "$_rct_value" ] && _rct_src="pyproject.toml [tool.coverage.report] fail_under"
|
|
78
|
+
if [ -z "$_rct_value" ]; then
|
|
79
|
+
_rct_value=$(grep -oE '^[[:space:]]*fail_under[[:space:]]*=[[:space:]]*[0-9]+' pyproject.toml 2>/dev/null | grep -oE '[0-9]+' | head -1 || true)
|
|
80
|
+
[ -n "$_rct_value" ] && _rct_src="pyproject.toml fail_under (text scan)"
|
|
81
|
+
fi
|
|
82
|
+
fi
|
|
83
|
+
if [ -z "$_rct_value" ] && [ -f .coveragerc ]; then
|
|
84
|
+
_rct_value=$(grep -oE '^[[:space:]]*fail_under[[:space:]]*=[[:space:]]*[0-9]+' .coveragerc 2>/dev/null | grep -oE '[0-9]+' | head -1 || true)
|
|
85
|
+
[ -n "$_rct_value" ] && _rct_src=".coveragerc fail_under"
|
|
86
|
+
fi
|
|
87
|
+
case "$_rct_value" in
|
|
88
|
+
''|*[!0-9]*) _rct_value="80"; _rct_src="default (no fail_under declared)" ;;
|
|
89
|
+
esac
|
|
90
|
+
COVERAGE_THRESHOLD_SOURCE="$_rct_src"
|
|
91
|
+
printf '%s' "$_rct_value"
|
|
92
|
+
}
|
|
93
|
+
COVERAGE_THRESHOLD="$(_resolve_coverage_threshold)"
|
|
94
|
+
|
|
44
95
|
# Look up one tier's directory variable by name without eval-ing arbitrary input:
|
|
45
96
|
# the base names are a closed set baked into the generated file.
|
|
46
97
|
_hook_tier_base() {
|
|
@@ -1238,7 +1289,7 @@ fi
|
|
|
1238
1289
|
GATE_1_START=$(gate_start_ms)
|
|
1239
1290
|
GATE_1_STATUS="PASS"
|
|
1240
1291
|
GATE_1_DETAIL=""
|
|
1241
|
-
if ! any_changed_files_match "\.(ts|tsx|js|jsx|py|go|java|kt|cpp|c|swift|m|mm|dart|sh)$" "\.ya?ml$" "\.json$" "Dockerfile" "\.tf$"; then
|
|
1292
|
+
if ! any_changed_files_match "\.(ts|tsx|js|jsx|py|go|java|kt|cpp|c|swift|m|mm|dart|sh|ps1|psm1)$" "\.ya?ml$" "\.json$" "Dockerfile" "\.tf$"; then
|
|
1242
1293
|
echo "✅ PASSED - No code files changed, skipping lint."
|
|
1243
1294
|
GATE_1_STATUS="SKIP"
|
|
1244
1295
|
GATE_1_DETAIL="no_matching_files"
|
|
@@ -1764,6 +1815,58 @@ else
|
|
|
1764
1815
|
fi
|
|
1765
1816
|
;;
|
|
1766
1817
|
|
|
1818
|
+
powershell)
|
|
1819
|
+
# #547 finding 2: PowerShell had no arm here, so it fell into the generic
|
|
1820
|
+
# `*)` branch, where a non-zero analysis verdict printed a non-blocking
|
|
1821
|
+
# line and GATE_1_STATUS stayed PASS. The java arm's XP_G1_REASON
|
|
1822
|
+
# protocol applies verbatim: 0 = pass, 1 = block, 2 = unusable
|
|
1823
|
+
# (fail-closed), 3 = WARN with a reason that STAYS in the scoring
|
|
1824
|
+
# denominator so a skip is visible in retro instead of vanishing.
|
|
1825
|
+
POWERSHELL_ADAPTER=$(resolve_adapter_path "powershell")
|
|
1826
|
+
if [ -z "$POWERSHELL_ADAPTER" ]; then
|
|
1827
|
+
echo "⚠️ WARN - PowerShell adapter not found (code quality NOT verified)"
|
|
1828
|
+
GATE_1_STATUS="WARN"
|
|
1829
|
+
GATE_1_DETAIL="powershell: adapter missing"
|
|
1830
|
+
else
|
|
1831
|
+
PS_G1_EXIT=0
|
|
1832
|
+
# Subshell: reasons cross as `XP_G1_REASON: ...` stdout lines, exactly
|
|
1833
|
+
# as in the java arm — an export inside the adapter cannot reach us.
|
|
1834
|
+
PS_G1_OUTPUT=$(source "$POWERSHELL_ADAPTER" 2>/dev/null; run_static_analysis 2>&1) || PS_G1_EXIT=$?
|
|
1835
|
+
PS_G1_REASON=$(printf '%s\n' "$PS_G1_OUTPUT" | sed -n 's/^XP_G1_REASON: //p' | head -1)
|
|
1836
|
+
if [ -n "$PS_G1_REASON" ]; then
|
|
1837
|
+
PS_G1_OUTPUT=$(printf '%s\n' "$PS_G1_OUTPUT" | grep -v '^XP_G1_REASON: ')
|
|
1838
|
+
fi
|
|
1839
|
+
[ -n "$PS_G1_OUTPUT" ] && printf '%s\n' "$PS_G1_OUTPUT" | head -40
|
|
1840
|
+
case "$PS_G1_EXIT" in
|
|
1841
|
+
0)
|
|
1842
|
+
echo "✅ PASSED - PowerShell code quality (PSScriptAnalyzer)."
|
|
1843
|
+
;;
|
|
1844
|
+
1)
|
|
1845
|
+
echo "❌ BLOCKED - PSScriptAnalyzer violations (fix or add suppressions)"
|
|
1846
|
+
GATE_1_STATUS="BLOCK"
|
|
1847
|
+
GATE_1_DETAIL="powershell: PSScriptAnalyzer findings"
|
|
1848
|
+
exit 1
|
|
1849
|
+
;;
|
|
1850
|
+
2)
|
|
1851
|
+
echo "❌ BLOCKED - PowerShell static analysis unusable (fail-closed): ${PS_G1_REASON:-unknown}"
|
|
1852
|
+
GATE_1_STATUS="BLOCK"
|
|
1853
|
+
GATE_1_DETAIL="powershell: ${PS_G1_REASON:-unusable}"
|
|
1854
|
+
exit 1
|
|
1855
|
+
;;
|
|
1856
|
+
3)
|
|
1857
|
+
echo "⚠️ WARN - PowerShell code quality NOT verified: ${PS_G1_REASON:-engine unavailable}"
|
|
1858
|
+
GATE_1_STATUS="WARN"
|
|
1859
|
+
GATE_1_DETAIL="powershell: ${PS_G1_REASON:-engine unavailable}"
|
|
1860
|
+
;;
|
|
1861
|
+
*)
|
|
1862
|
+
echo "⚠️ WARN - PowerShell code quality NOT verified (unexpected verdict $PS_G1_EXIT)"
|
|
1863
|
+
GATE_1_STATUS="WARN"
|
|
1864
|
+
GATE_1_DETAIL="powershell: unexpected verdict $PS_G1_EXIT"
|
|
1865
|
+
;;
|
|
1866
|
+
esac
|
|
1867
|
+
fi
|
|
1868
|
+
;;
|
|
1869
|
+
|
|
1767
1870
|
*)
|
|
1768
1871
|
# Route to appropriate adapter for other languages
|
|
1769
1872
|
ADAPTER_FILE=$(resolve_adapter_path "$CURRENT_LANG")
|
|
@@ -2711,11 +2814,18 @@ else
|
|
|
2711
2814
|
PY_TARGET_COUNT=$(printf '%s\n' "$PY_TARGETS" | grep -c '.' || true)
|
|
2712
2815
|
echo "ℹ️ Python: selective run ($PY_STRATEGY) — $PY_TARGET_COUNT target(s):"
|
|
2713
2816
|
printf '%s\n' "$PY_TARGETS" | sed 's/^/ /'
|
|
2714
|
-
# Subset run: keep --cov so data is collected, but
|
|
2715
|
-
#
|
|
2817
|
+
# Subset run: keep --cov so data is collected, but the global
|
|
2818
|
+
# threshold must be actively DISABLED, not merely omitted.
|
|
2819
|
+
# Omitting --cov-fail-under does not work (#556): pytest-cov
|
|
2820
|
+
# reads fail_under from pyproject on its own, so the floor
|
|
2821
|
+
# stayed in force, a selective run at 23% exited 1, and the
|
|
2822
|
+
# exit was escalated to a hard BLOCK -- printing "Partial
|
|
2823
|
+
# coverage ... not full suite" immediately above "BLOCKED -
|
|
2824
|
+
# Coverage check FAILED". --cov-fail-under=0 is the documented
|
|
2825
|
+
# way to switch it off for one invocation.
|
|
2716
2826
|
PARTIAL_TEST_RUN=true
|
|
2717
2827
|
# shellcheck disable=SC2086
|
|
2718
|
-
pytest $PY_XDIST_ARGS $PY_COV_SOURCE --tb=short $PY_TARGETS
|
|
2828
|
+
pytest $PY_XDIST_ARGS $PY_COV_SOURCE --cov-fail-under=0 --tb=short $PY_TARGETS
|
|
2719
2829
|
COV_EXIT=$((COV_EXIT || $?))
|
|
2720
2830
|
fi
|
|
2721
2831
|
fi
|
|
@@ -2731,7 +2841,7 @@ else
|
|
|
2731
2841
|
go test -coverprofile=coverage.out ./... 2>/dev/null
|
|
2732
2842
|
TOTAL_COVERAGE=$(go tool cover -func=coverage.out 2>/dev/null | grep "^total:" | awk '{print substr($3, 1, length($3)-1)}')
|
|
2733
2843
|
if [ -n "$TOTAL_COVERAGE" ] && (( $(echo "$TOTAL_COVERAGE < 80" | bc -l 2>/dev/null || echo "0") )); then
|
|
2734
|
-
echo "❌ BLOCKED - Go coverage $TOTAL_COVERAGE% below
|
|
2844
|
+
echo "❌ BLOCKED - Go coverage $TOTAL_COVERAGE% below ${COVERAGE_THRESHOLD}% threshold"
|
|
2735
2845
|
exit 1
|
|
2736
2846
|
fi
|
|
2737
2847
|
echo "Go coverage: $TOTAL_COVERAGE%"
|
|
@@ -2789,14 +2899,14 @@ else
|
|
|
2789
2899
|
} catch(e) { console.log('parse_error'); }
|
|
2790
2900
|
" 2>/dev/null)
|
|
2791
2901
|
if [ "$COVERAGE_PERCENT" != "parse_error" ] && [ -n "$COVERAGE_PERCENT" ]; then
|
|
2792
|
-
if [ "$COVERAGE_PERCENT" -lt
|
|
2902
|
+
if [ "$COVERAGE_PERCENT" -lt "$COVERAGE_THRESHOLD" ]; then
|
|
2793
2903
|
# Only a subset test run (smart selection) leaves partial coverage data;
|
|
2794
2904
|
# a full suite run must block on <80% even when many test files changed.
|
|
2795
2905
|
if [ "${PARTIAL_TEST_RUN:-false}" = "true" ]; then
|
|
2796
2906
|
echo "⚠️ Partial coverage ${COVERAGE_PERCENT}% (subset test run, not full suite)."
|
|
2797
2907
|
echo " Run 'npx vitest run --coverage' for full coverage check."
|
|
2798
2908
|
else
|
|
2799
|
-
echo "❌ BLOCKED - TypeScript coverage ${COVERAGE_PERCENT}% below
|
|
2909
|
+
echo "❌ BLOCKED - TypeScript coverage ${COVERAGE_PERCENT}% below ${COVERAGE_THRESHOLD}% threshold"
|
|
2800
2910
|
exit 1
|
|
2801
2911
|
fi
|
|
2802
2912
|
else
|
|
@@ -2860,7 +2970,7 @@ else
|
|
|
2860
2970
|
echo "⚠️ Partial coverage ${COVERAGE_ROUNDED}% (subset test run, not full suite)."
|
|
2861
2971
|
echo " Run the full test suite for a complete Java coverage check."
|
|
2862
2972
|
else
|
|
2863
|
-
echo "❌ BLOCKED - Java coverage ${COVERAGE_ROUNDED}% below
|
|
2973
|
+
echo "❌ BLOCKED - Java coverage ${COVERAGE_ROUNDED}% below ${COVERAGE_THRESHOLD}% threshold"
|
|
2864
2974
|
exit 1
|
|
2865
2975
|
fi
|
|
2866
2976
|
else
|
|
@@ -2886,8 +2996,8 @@ else
|
|
|
2886
2996
|
if [ -f "coverage/lcov.info" ]; then
|
|
2887
2997
|
COVERAGE_PERCENT=$(parse_lcov_coverage "coverage/lcov.info" 2>/dev/null) || COVERAGE_PERCENT="0"
|
|
2888
2998
|
if [ "$COVERAGE_PERCENT" != "0" ] && [ -n "$COVERAGE_PERCENT" ]; then
|
|
2889
|
-
if [ "$COVERAGE_PERCENT" -lt
|
|
2890
|
-
echo "❌ BLOCKED - Flutter/Dart coverage ${COVERAGE_PERCENT}% below
|
|
2999
|
+
if [ "$COVERAGE_PERCENT" -lt "$COVERAGE_THRESHOLD" ]; then
|
|
3000
|
+
echo "❌ BLOCKED - Flutter/Dart coverage ${COVERAGE_PERCENT}% below ${COVERAGE_THRESHOLD}% threshold"
|
|
2891
3001
|
exit 1
|
|
2892
3002
|
fi
|
|
2893
3003
|
echo "Flutter/Dart coverage: ${COVERAGE_PERCENT}% ✅ (≥ 80%)"
|
|
@@ -2926,7 +3036,7 @@ else
|
|
|
2926
3036
|
COVERAGE_STATUS=${COVERAGE_RESULT%%|*}
|
|
2927
3037
|
COVERAGE_PERCENT=${COVERAGE_RESULT#*|}
|
|
2928
3038
|
if [ "$COVERAGE_STATUS" = "below" ]; then
|
|
2929
|
-
echo "❌ BLOCKED - PowerShell coverage ${COVERAGE_PERCENT}% below
|
|
3039
|
+
echo "❌ BLOCKED - PowerShell coverage ${COVERAGE_PERCENT}% below ${COVERAGE_THRESHOLD}% threshold"
|
|
2930
3040
|
exit 1
|
|
2931
3041
|
fi
|
|
2932
3042
|
echo "PowerShell coverage: ${COVERAGE_PERCENT}% ✅ (≥ 80%)"
|
|
@@ -2963,14 +3073,14 @@ else
|
|
|
2963
3073
|
fi
|
|
2964
3074
|
fi
|
|
2965
3075
|
if [ -n "$COVERAGE_PERCENT" ] && [ "$COVERAGE_PERCENT" != "parse_error" ] && [ "$COVERAGE_PERCENT" -gt 0 ]; then
|
|
2966
|
-
if [ "$COVERAGE_PERCENT" -lt
|
|
3076
|
+
if [ "$COVERAGE_PERCENT" -lt "$COVERAGE_THRESHOLD" ]; then
|
|
2967
3077
|
# Only a subset test run (smart selection) warns — the full suite may
|
|
2968
3078
|
# still reach ≥80%; a full suite run must block.
|
|
2969
3079
|
if [ "${PARTIAL_TEST_RUN:-false}" = "true" ]; then
|
|
2970
3080
|
echo "⚠️ Partial coverage ${COVERAGE_PERCENT}% (subset test run, not full suite)."
|
|
2971
3081
|
echo " Run the full test suite for a complete ${CURRENT_LANG} coverage check."
|
|
2972
3082
|
else
|
|
2973
|
-
echo "❌ BLOCKED - ${CURRENT_LANG} coverage ${COVERAGE_PERCENT}% below
|
|
3083
|
+
echo "❌ BLOCKED - ${CURRENT_LANG} coverage ${COVERAGE_PERCENT}% below ${COVERAGE_THRESHOLD}% threshold"
|
|
2974
3084
|
exit 1
|
|
2975
3085
|
fi
|
|
2976
3086
|
else
|
|
@@ -3019,10 +3129,15 @@ else
|
|
|
3019
3129
|
}
|
|
3020
3130
|
} catch(e) { console.log('parse_error'); }
|
|
3021
3131
|
" 2>/dev/null)
|
|
3132
|
+
# NOTE (#555): the 80 below is deliberately NOT $COVERAGE_THRESHOLD.
|
|
3133
|
+
# That one is the project-level floor a repo declares in fail_under;
|
|
3134
|
+
# this is the bar a single NEW file must clear to justify existing at
|
|
3135
|
+
# all. Tying them would mean a repo with fail_under=82 could not add a
|
|
3136
|
+
# file at 81% even while the project sits well above its floor.
|
|
3022
3137
|
if [ "$FILE_COV" = "no_data" ] || [ "$FILE_COV" = "parse_error" ]; then
|
|
3023
3138
|
echo " ⚠️ $new_file — no coverage data (file may be excluded or not exercised)"
|
|
3024
3139
|
elif [ "$FILE_COV" -lt 80 ]; then
|
|
3025
|
-
echo " ❌ $new_file — ${FILE_COV}% coverage (below 80% threshold)"
|
|
3140
|
+
echo " ❌ $new_file — ${FILE_COV}% coverage (below 80% per-new-file threshold)"
|
|
3026
3141
|
NEW_FILE_COV_FAIL=true
|
|
3027
3142
|
else
|
|
3028
3143
|
echo " ✅ $new_file — ${FILE_COV}% coverage"
|
|
@@ -3036,8 +3151,13 @@ else
|
|
|
3036
3151
|
fi
|
|
3037
3152
|
fi
|
|
3038
3153
|
fi
|
|
3154
|
+
# Only reached on the non-docs-only path. Assigning PASS outside the if/else
|
|
3155
|
+
# overwrote the docs-only SKIP a few hundred lines above and reported
|
|
3156
|
+
# "Tests + Coverage: PASS (coverage: N/A)" for a commit where no test ran
|
|
3157
|
+
# (#554) -- a SKIP promoted to PASS inflates the pass rate and hides that the
|
|
3158
|
+
# gate never executed.
|
|
3159
|
+
GATE_5_STATUS="PASS"
|
|
3039
3160
|
fi
|
|
3040
|
-
GATE_5_STATUS="PASS"
|
|
3041
3161
|
record_gate_audit "gate-5" "tests-coverage" "$GATE_5_STATUS" "0" "$GATE_5_START" "${GATE_5_DETAIL:-}"
|
|
3042
3162
|
|
|
3043
3163
|
# ============================================================================
|
|
@@ -3582,7 +3702,7 @@ echo ""
|
|
|
3582
3702
|
# v0.14.3+ (Issue #312): Skip if no code files changed
|
|
3583
3703
|
# v0.18.5 (Issue #397): Fix gate status variable collision — SAST now sets GATE_10_STATUS
|
|
3584
3704
|
# ============================================================================
|
|
3585
|
-
if any_changed_files_match "\.(ts|tsx|js|jsx|py|go|java|kt|cpp|c|swift|m|dart|sh)$"; then
|
|
3705
|
+
if any_changed_files_match "\.(ts|tsx|js|jsx|py|go|java|kt|cpp|c|swift|m|dart|sh|ps1|psm1)$"; then
|
|
3586
3706
|
if [ -f "$GATE_DIR/gate-10.sh" ]; then
|
|
3587
3707
|
source "$GATE_DIR/gate-10.sh"
|
|
3588
3708
|
else
|
|
@@ -3885,7 +4005,8 @@ generate_quality_report() {
|
|
|
3885
4005
|
"gate5_tests": {
|
|
3886
4006
|
"name": "Tests + Coverage",
|
|
3887
4007
|
"status": "${GATE_5_STATUS:-PASS}",
|
|
3888
|
-
"thresholds": { "lines":
|
|
4008
|
+
"thresholds": { "lines": ${COVERAGE_THRESHOLD}, "functions": null, "branches": null, "statements": null },
|
|
4009
|
+
"threshold_source": "${COVERAGE_THRESHOLD_SOURCE}",
|
|
3889
4010
|
"actual": { "coverage": "${COV_PCT}" },
|
|
3890
4011
|
"failedTests": $FAILED_TESTS_JSON
|
|
3891
4012
|
},
|
package/hooks/pre-push
CHANGED
|
@@ -1,4 +1,20 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
|
+
# Line-ending self-check (#546). Must stay on the first executable line: if this
|
|
3
|
+
# file is ever rewritten with CRLF, bash needs to reach this check before the
|
|
4
|
+
# parser chokes somewhere far away and blames an innocent line. `tr` also
|
|
5
|
+
# strips the \r from this very line, so the check runs even when everything
|
|
6
|
+
# around it is broken.
|
|
7
|
+
if tr -d '\r' < "$0" | sed -n '2p' | tr -d '\r' | grep -q 'Line-ending self-check'; then
|
|
8
|
+
if [ "$(tr -cd '\r' < "$0" | wc -c | tr -d '[:space:]')" -gt 0 ]; then
|
|
9
|
+
echo "======================================================================" >&2
|
|
10
|
+
echo "FATAL: hook has CRLF line endings — bash cannot parse it (#546)." >&2
|
|
11
|
+
echo " file: $0" >&2
|
|
12
|
+
echo " fix : tr -d '\\r' < \"$0\" > \"$0.tmp\" && mv \"$0.tmp\" \"$0\" && chmod +x \"$0\"" >&2
|
|
13
|
+
echo "Or re-sync the installed copy: xp-gate doctor --sync-hooks" >&2
|
|
14
|
+
echo "======================================================================" >&2
|
|
15
|
+
exit 1
|
|
16
|
+
fi
|
|
17
|
+
fi
|
|
2
18
|
# Pre-push Hook — 8 gates: Gate 10 (Build Integrity) + M/M-Python/M-Go/M-Java/M-Kotlin (Mutation)
|
|
3
19
|
# + M2 (Mock Density, WARNING) + ML (Mock Layering) + UI (UI Sprint Gates) + MW (Code Walkthrough) + S (Sprint Flow)
|
|
4
20
|
#
|
|
@@ -31,11 +47,28 @@ write_pre_push_report() {
|
|
|
31
47
|
commit_hash=$(git rev-parse HEAD 2>/dev/null || echo "unknown")
|
|
32
48
|
verdict=$([ "$exit_code" -eq 0 ] && echo "PASS" || echo "BLOCKED")
|
|
33
49
|
mkdir -p "$reports_dir"
|
|
50
|
+
# #544: the survivor detail is a JSON document, so it cannot be interpolated
|
|
51
|
+
# into this heredoc — a single quote in a path would break the Python source.
|
|
52
|
+
# Hand it to the child through the environment and let Python parse it, then
|
|
53
|
+
# store it serialised back into the `details` string domain (every value there
|
|
54
|
+
# is a string; the journal has broken before over mixed types).
|
|
55
|
+
export XP_GATE_M_SURVIVORS_JSON="${M_SURVIVORS_JSON:-}"
|
|
34
56
|
python3 - <<PY 2>/dev/null || true
|
|
35
57
|
import json
|
|
58
|
+
import os
|
|
36
59
|
from pathlib import Path
|
|
37
60
|
pushed_files = '''${PUSHED_FILES:-}'''.splitlines()
|
|
38
61
|
passed = 1 if '$verdict' == 'PASS' else 0
|
|
62
|
+
# Absent stays absent: no key at all when nothing was measured, so a reader can
|
|
63
|
+
# tell "not measured" from "measured: zero survivors" (#544).
|
|
64
|
+
try:
|
|
65
|
+
_survivors_raw = os.environ.get('XP_GATE_M_SURVIVORS_JSON', '').strip()
|
|
66
|
+
survivors_detail = json.loads(_survivors_raw) if _survivors_raw else None
|
|
67
|
+
except (ValueError, TypeError):
|
|
68
|
+
survivors_detail = None
|
|
69
|
+
_m_details = {'skip_reason': '${GATE_M_SKIP_REASON:-}', 'duration_ms': '${M_DURATION_MS:-0}', 'mutation_score': '${M_SCORE:-}'}
|
|
70
|
+
if survivors_detail is not None:
|
|
71
|
+
_m_details['survivors'] = json.dumps(survivors_detail, sort_keys=True)
|
|
39
72
|
report = {
|
|
40
73
|
'timestamp': '$timestamp',
|
|
41
74
|
'trigger': 'pre-push',
|
|
@@ -51,15 +84,20 @@ report = {
|
|
|
51
84
|
'gates': [
|
|
52
85
|
{'id': 'MP', 'name': 'Gate-Script Mirror Parity', 'status': '${GATE_MP_STATUS:-SKIP}', 'details': {'duration_ms': '${MP_DURATION_MS:-0}'}},
|
|
53
86
|
{'id': '10', 'name': 'Build Integrity', 'status': '${GATE_10_STATUS:-SKIP}', 'details': {}},
|
|
54
|
-
{'id': 'M', 'name': 'Mutation Testing', 'status': '${GATE_M_STATUS:-SKIP}', 'details':
|
|
55
|
-
|
|
87
|
+
{'id': 'M', 'name': 'Mutation Testing', 'status': '${GATE_M_STATUS:-SKIP}', 'details': _m_details},
|
|
88
|
+
# #557 follow-up: this line read GATE_M_PYTHON_STATUS while the code assigns
|
|
89
|
+
# GATE_M_PY_STATUS, so the field always fell back to its SKIP default -- the
|
|
90
|
+
# "never write the real state" bug #557 set out to fix, still present for
|
|
91
|
+
# this one gate. The skip reason now travels with it, so a retro reader can
|
|
92
|
+
# tell "no Python here" from "timed out" from "mutmut missing".
|
|
93
|
+
{'id': 'M-Python', 'name': 'Mutation Testing (Python)', 'status': '${GATE_M_PY_STATUS:-SKIP}', 'details': {'skip_reason': '${GATE_M_PY_SKIP_REASON:-}'}},
|
|
56
94
|
{'id': 'M-Go', 'name': 'Mutation Testing (Go)', 'status': '${GATE_M_GO_STATUS:-SKIP}', 'details': {}},
|
|
57
95
|
{'id': 'M-Java', 'name': 'Mutation Testing (Java)', 'status': '${GATE_M_JAVA_STATUS:-SKIP}', 'details': {}},
|
|
58
96
|
{'id': 'M-Kotlin', 'name': 'Mutation Testing (Kotlin)', 'status': '${GATE_M_KOTLIN_STATUS:-SKIP}', 'details': {}},
|
|
59
97
|
{'id': 'M2', 'name': 'Mock Density (WARNING only)', 'status': '${GATE_M2_STATUS:-SKIP}', 'details': {}},
|
|
60
98
|
{'id': 'ML', 'name': 'Mock Layering', 'status': '${GATE_M3_STATUS:-SKIP}', 'details': {}},
|
|
61
99
|
{'id': 'UI', 'name': 'UI Sprint Gates', 'status': '${GATE_UI_STATUS:-SKIP}', 'details': {}},
|
|
62
|
-
{'id': 'MW', 'name': 'Code Walkthrough', 'status': '${GATE_DELPHI_STATUS:-SKIP}', 'details': {'remote': '$REMOTE', 'url': '$URL'}},
|
|
100
|
+
{'id': 'MW', 'name': 'Code Walkthrough', 'status': '${GATE_DELPHI_STATUS:-SKIP}', 'details': {'remote': '$REMOTE', 'url': '$URL', 'skip_reason': '${GATE_DELPHI_SKIP_REASON:-}'}},
|
|
63
101
|
{'id': 'S', 'name': 'Sprint Flow Enforcement', 'status': '${GATE_S_STATUS:-SKIP}', 'details': {}}
|
|
64
102
|
],
|
|
65
103
|
'warnings': [],
|
|
@@ -220,6 +258,10 @@ if [ -f "$MP_ROOT/scripts/check-hook-mirror.sh" ]; then
|
|
|
220
258
|
fi
|
|
221
259
|
else
|
|
222
260
|
echo "⚠️ Gate MP: check-hook-mirror.sh not found. SKIP — Gate MP."
|
|
261
|
+
echo " Gate MP did not run; this push was not checked for mirror drift."
|
|
262
|
+
GATE_MP_STATUS="SKIP"
|
|
263
|
+
GATE_MP_SKIP_REASON="mirror_script_not_found"
|
|
264
|
+
GATES_SKIPPED="${GATES_SKIPPED:-}Gate MP (mirror parity: script not found)|"
|
|
223
265
|
MP_DURATION_MS=0
|
|
224
266
|
fi
|
|
225
267
|
echo ""
|
|
@@ -433,6 +475,15 @@ else
|
|
|
433
475
|
# It is printed by gate-m.ts; take the last percentage it reported.
|
|
434
476
|
M_SCORE=$(sed -n 's/.*[Mm]utation score[: ]*\([0-9][0-9]*\(\.[0-9][0-9]*\)\?\)%.*/\1/p' "$MUTATION_OUTPUT" 2>/dev/null | tail -1)
|
|
435
477
|
|
|
478
|
+
# #544: per-file survivor detail, so retro can answer "which module has
|
|
479
|
+
# the most surviving mutants". gate-m.ts emits one
|
|
480
|
+
# GATE_M_SURVIVORS_JSON= line; it is ABSENT when no report was produced,
|
|
481
|
+
# and absent must stay absent. An empty value means "not measured"; a
|
|
482
|
+
# written zero would mean "measured, no survivors". Only the former is
|
|
483
|
+
# ever true when the line is missing -- conflating the two is the whole
|
|
484
|
+
# of #544.
|
|
485
|
+
M_SURVIVORS_JSON=$(printf '%s\n' "$MUTATION_OUTPUT" | sed -n 's/^GATE_M_SURVIVORS_JSON=//p' | tail -1)
|
|
486
|
+
|
|
436
487
|
case $MUTATION_EXIT in
|
|
437
488
|
0)
|
|
438
489
|
echo "✅ Gate M: PASS"
|
|
@@ -491,6 +542,13 @@ PY_SOURCE_FILES=$(echo "$PY_FILES" | grep -v '/venv/' | grep -v '/\.venv/' || tr
|
|
|
491
542
|
|
|
492
543
|
if [ -z "$PY_SOURCE_FILES" ]; then
|
|
493
544
|
echo "📚 No changed Python source files. SKIP — Gate M (Python)."
|
|
545
|
+
# #557: this outermost branch was the only Gate M (Python) path that assigned
|
|
546
|
+
# no status, while Gate M (TypeScript) records a reason on every path. Without
|
|
547
|
+
# it, "no Python in this push" and "the tool is missing" look identical in the
|
|
548
|
+
# journals, which is the gap #404 P0 spent effort closing for the TS gate.
|
|
549
|
+
GATE_M_PY_STATUS="SKIP"
|
|
550
|
+
GATE_M_PY_SKIP_REASON="no_python_source_files"
|
|
551
|
+
GATES_SKIPPED="${GATES_SKIPPED:-}Gate M Python (no changed Python source files)|"
|
|
494
552
|
else
|
|
495
553
|
PYTHON_FILE_LIST=$(echo "$PY_SOURCE_FILES" | tr '\n' ',' | sed 's/,$//')
|
|
496
554
|
echo "🐍 Changed Python files: $PYTHON_FILE_LIST"
|
|
@@ -504,6 +562,7 @@ else
|
|
|
504
562
|
case $MUTATION_EXIT in
|
|
505
563
|
0)
|
|
506
564
|
echo "✅ Gate M (Python): PASS"
|
|
565
|
+
GATE_M_PY_STATUS="PASS"
|
|
507
566
|
;;
|
|
508
567
|
1)
|
|
509
568
|
echo ""
|
|
@@ -519,16 +578,28 @@ else
|
|
|
519
578
|
124)
|
|
520
579
|
cat "$MUTATION_OUTPUT"
|
|
521
580
|
echo "⏱ Gate M (Python): TIMEOUT (120s). Allowing push with warning."
|
|
581
|
+
# A timeout means the mutation score was never measured. Saying
|
|
582
|
+
# nothing here let the closing banner claim success for a gate that
|
|
583
|
+
# started work and could not finish (#557).
|
|
584
|
+
GATE_M_PY_STATUS="TIMEOUT"
|
|
585
|
+
GATE_M_PY_SKIP_REASON="timeout_120s"
|
|
586
|
+
GATES_SKIPPED="${GATES_SKIPPED:-}Gate M Python (TIMEOUT — score never measured)|"
|
|
522
587
|
;;
|
|
523
588
|
*)
|
|
524
589
|
cat "$MUTATION_OUTPUT"
|
|
525
590
|
echo "⚠ Gate M (Python): Unexpected exit code $MUTATION_EXIT. Allowing push with warning."
|
|
591
|
+
GATE_M_PY_STATUS="ERROR"
|
|
592
|
+
GATE_M_PY_SKIP_REASON="unexpected_exit_${MUTATION_EXIT}"
|
|
593
|
+
GATES_SKIPPED="${GATES_SKIPPED:-}Gate M Python (error, exit ${MUTATION_EXIT} — score never measured)|"
|
|
526
594
|
;;
|
|
527
595
|
esac
|
|
528
596
|
|
|
529
597
|
rm -f "$MUTATION_OUTPUT"
|
|
530
598
|
else
|
|
531
599
|
echo "⚠ Gate M script not found. SKIP — Gate M (Python)."
|
|
600
|
+
GATE_M_PY_STATUS="SKIP"
|
|
601
|
+
GATE_M_PY_SKIP_REASON="gate_m_script_not_found"
|
|
602
|
+
GATES_SKIPPED="${GATES_SKIPPED:-}Gate M Python (gate-m.ts not found)|"
|
|
532
603
|
fi
|
|
533
604
|
else
|
|
534
605
|
echo "⚠ mutmut not installed. SKIP — Gate M (Python)."
|
|
@@ -929,11 +1000,51 @@ if [[ "$XP_GATE_SKIP_UI_GATES" == "1" ]]; then
|
|
|
929
1000
|
AUDIT_ENTRY="{\"timestamp\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"branch\":\"$CURRENT_BRANCH\",\"commit\":\"$(git rev-parse HEAD)\",\"user\":\"$(git config user.name || echo 'unknown')\",\"reason\":\"$XP_GATE_BYPASS_REASON\",\"bypass_type\":\"ui-gates\"}"
|
|
930
1001
|
echo "$AUDIT_ENTRY" >> .audit-log.jsonl
|
|
931
1002
|
else
|
|
932
|
-
# Detect UI changes in pushed files
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
1003
|
+
# Detect UI changes in pushed files.
|
|
1004
|
+
#
|
|
1005
|
+
# stderr is kept out of the captured value but its ABSENCE is no longer
|
|
1006
|
+
# treated as a clean "no UI here" answer (#553). jq -r prints the empty
|
|
1007
|
+
# string and exits 0 on empty input, so the old `|| echo "false"` fallback
|
|
1008
|
+
# was unreachable; an empty verdict became "", which is not "true", which
|
|
1009
|
+
# silently took the "No UI changes detected — skipping" branch. A push whose
|
|
1010
|
+
# only change was src/ui/streamlit_app.py was therefore told there was
|
|
1011
|
+
# nothing to review — the failure Gate UI exists to prevent, inverted.
|
|
1012
|
+
UI_DETECTION=$(echo "$PUSHED_FILES" | npx -y tsx src/npm-package/lib/ui-detector.ts --push-mode --from-stdin 2>/dev/null)
|
|
1013
|
+
UI_DETECT_EXIT=$?
|
|
1014
|
+
|
|
1015
|
+
if [ "$UI_DETECT_EXIT" -eq 0 ] && [ -n "$UI_DETECTION" ]; then
|
|
1016
|
+
IS_UI_SPRINT=$(echo "$UI_DETECTION" | jq -r 'if type == "object" then (.isUiSprint | tostring) else "unknown" end' 2>/dev/null)
|
|
1017
|
+
else
|
|
1018
|
+
IS_UI_SPRINT=""
|
|
1019
|
+
fi
|
|
1020
|
+
# Three answers, not two. "" (detector missing, crashed, or emitted nothing)
|
|
1021
|
+
# is "unknown", and unknown must never read as "no UI".
|
|
1022
|
+
if [ -z "$IS_UI_SPRINT" ] || [ "$IS_UI_SPRINT" = "null" ]; then
|
|
1023
|
+
IS_UI_SPRINT="unknown"
|
|
1024
|
+
fi
|
|
1025
|
+
|
|
1026
|
+
if [ "$IS_UI_SPRINT" = "unknown" ]; then
|
|
1027
|
+
# A missing detector must not disable UI review. Fall back to a path
|
|
1028
|
+
# check over the same file list: a cheap, conservative substitute for a
|
|
1029
|
+
# verdict we do not have.
|
|
1030
|
+
UI_PATH_HITS=$(echo "$PUSHED_FILES" | grep -Ei '(^|/)(src/ui|ui|components|pages|views|screens)/|[.](tsx|jsx|vue|svelte|css|scss|sass|less)$' || true)
|
|
1031
|
+
if [ -n "$UI_PATH_HITS" ]; then
|
|
1032
|
+
echo ""
|
|
1033
|
+
echo "❌ Gate UI: could not evaluate the UI verdict (detector unavailable or produced no verdict, exit ${UI_DETECT_EXIT})."
|
|
1034
|
+
echo " This push touches UI-looking paths:"
|
|
1035
|
+
echo "$UI_PATH_HITS" | sed 's/^/ /'
|
|
1036
|
+
echo ""
|
|
1037
|
+
echo " The gate cannot claim there is nothing to review when it never got an answer."
|
|
1038
|
+
echo " Fix: install the detector (npx -y tsx must be able to load src/npm-package/lib/ui-detector.ts),"
|
|
1039
|
+
echo " or run the review explicitly: xp-gate ui-review"
|
|
1040
|
+
echo " To accept the risk for this push: XP_GATE_SKIP_UI_GATES=1 XP_GATE_BYPASS_REASON=\"<why>\""
|
|
1041
|
+
echo ""
|
|
1042
|
+
exit 1
|
|
1043
|
+
fi
|
|
1044
|
+
echo "ℹ️ Gate UI: detector unavailable (exit ${UI_DETECT_EXIT}) and no UI-looking paths in this push — nothing to review."
|
|
1045
|
+
IS_UI_SPRINT="false"
|
|
1046
|
+
fi
|
|
1047
|
+
|
|
937
1048
|
if [[ "$IS_UI_SPRINT" == "true" ]]; then
|
|
938
1049
|
UI_RESULT_FILE=".ui-gate-result.json"
|
|
939
1050
|
MISSING_RESULTS=()
|
|
@@ -986,6 +1097,15 @@ else
|
|
|
986
1097
|
# ============================================================================
|
|
987
1098
|
RESULT_FILE=".code-walkthrough-result.json"
|
|
988
1099
|
MW_SKIPPED=0
|
|
1100
|
+
# Journal status (#557 follow-up). The journal already read GATE_DELPHI_STATUS
|
|
1101
|
+
# but nothing ever assigned it, so Gate MW reported its SKIP default in every
|
|
1102
|
+
# report -- including pushes where the walkthrough was in fact verified. The
|
|
1103
|
+
# closing banner told the truth while the machine-readable journal did not, which
|
|
1104
|
+
# is the half-fix #557 was about.
|
|
1105
|
+
GATE_DELPHI_STATUS="SKIP"
|
|
1106
|
+
# Consumed by the journal heredoc (sourced/inherited boundary).
|
|
1107
|
+
# shellcheck disable=SC2034
|
|
1108
|
+
GATE_DELPHI_SKIP_REASON="not_run"
|
|
989
1109
|
|
|
990
1110
|
# Skipped on main/master by design, exactly like Gate ML and Gate UI above.
|
|
991
1111
|
# Work is already walkthrough-reviewed on the feature branch before it lands, so
|
|
@@ -995,6 +1115,9 @@ MW_SKIPPED=0
|
|
|
995
1115
|
# documented this skip; the code did not implement it (drift fixed here).
|
|
996
1116
|
if [ "$CURRENT_BRANCH" = "main" ] || [ "$CURRENT_BRANCH" = "master" ]; then
|
|
997
1117
|
MW_SKIPPED=1
|
|
1118
|
+
# Consumed by the journal heredoc (sourced/inherited boundary).
|
|
1119
|
+
# shellcheck disable=SC2034
|
|
1120
|
+
GATE_DELPHI_SKIP_REASON="main_branch_push"
|
|
998
1121
|
echo ""
|
|
999
1122
|
echo " ℹ️ Gate MW skipped (pushing to ${CURRENT_BRANCH} — pre-reviewed via branch walkthrough)"
|
|
1000
1123
|
echo ""
|
|
@@ -1064,13 +1187,31 @@ if ! VALIDATION_ERROR=$(node "$MW_VALIDATOR" "$RESULT_FILE" "$EXPECTED_COMMIT" "
|
|
|
1064
1187
|
echo ""
|
|
1065
1188
|
exit 1
|
|
1066
1189
|
fi
|
|
1190
|
+
|
|
1191
|
+
# The validator passed: record that the walkthrough was actually verified.
|
|
1192
|
+
# Consumed by the journal heredoc (sourced/inherited boundary).
|
|
1193
|
+
# shellcheck disable=SC2034
|
|
1194
|
+
GATE_DELPHI_STATUS="PASS"
|
|
1195
|
+
# shellcheck disable=SC2034
|
|
1196
|
+
GATE_DELPHI_SKIP_REASON=""
|
|
1067
1197
|
fi
|
|
1068
1198
|
|
|
1069
1199
|
# ============================================================================
|
|
1070
|
-
#
|
|
1200
|
+
# Closing banner. States what actually happened (#557): a gate that skipped is
|
|
1201
|
+
# named. The heading used to read "ALL CHECKS PASSED" unconditionally while Gate
|
|
1202
|
+
# MP and Gate M (Python) skipped on a typical push without recording why, so a
|
|
1203
|
+
# reviewer skimming the log had no signal that mutation testing never ran.
|
|
1071
1204
|
# ============================================================================
|
|
1072
1205
|
echo ""
|
|
1073
1206
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
1207
|
+
# Trim the trailing separator and turn it into a displayable list.
|
|
1208
|
+
SKIPPED_DISPLAY=$(printf '%s' "${GATES_SKIPPED:-}" | sed 's/|$//' | sed 's/|/ , /g')
|
|
1209
|
+
if [ -n "$SKIPPED_DISPLAY" ]; then
|
|
1210
|
+
echo " ⚠️ NO BLOCKING FAILURES — but not every gate ran:"
|
|
1211
|
+
echo "$SKIPPED_DISPLAY" | tr ',' '\n' | sed '/^[[:space:]]*$/d' | sed 's/^[[:space:]]*/ - /'
|
|
1212
|
+
else
|
|
1213
|
+
echo " ✅ ALL CHECKS PASSED"
|
|
1214
|
+
fi
|
|
1074
1215
|
# Say what actually happened. Printing "VERIFIED" on a skipped gate would be a
|
|
1075
1216
|
# claim the hook cannot support — the same dishonesty (evidence asserting an
|
|
1076
1217
|
# unperformed check) that Gate MW exists to prevent.
|
package/hooks/sprint-gate.sh
CHANGED
|
@@ -181,6 +181,65 @@ read_verdict() {
|
|
|
181
181
|
json_extract "$APPROVED_FILE" '.verdict // ""' || echo ""
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
+
# --- Helper: sprint age in days, or empty if unknown ---
|
|
185
|
+
# Reads status + started_at from sprint-state.json. #551: the gate judged only
|
|
186
|
+
# `phase` and the delphi verdict, so a state file left at status=in_progress
|
|
187
|
+
# reported "sprint phase 6 validated" on unrelated commits for as long as
|
|
188
|
+
# nobody fixed it. An advancing sprint and an abandoned one were
|
|
189
|
+
# indistinguishable -- worse than SKIP, because SKIP is honest and PASS lies.
|
|
190
|
+
read_sprint_status() {
|
|
191
|
+
if [ ! -f "$SPRINT_STATE_JSON" ] || [ "$JSON_PARSER" = "none" ]; then
|
|
192
|
+
echo ""
|
|
193
|
+
return
|
|
194
|
+
fi
|
|
195
|
+
json_validate "$SPRINT_STATE_JSON" || { echo ""; return; }
|
|
196
|
+
json_extract "$SPRINT_STATE_JSON" '.status // ""' 2>/dev/null || echo ""
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
read_sprint_age_days() {
|
|
200
|
+
if [ ! -f "$SPRINT_STATE_JSON" ] || [ "$JSON_PARSER" = "none" ]; then
|
|
201
|
+
echo ""
|
|
202
|
+
return
|
|
203
|
+
fi
|
|
204
|
+
json_validate "$SPRINT_STATE_JSON" || { echo ""; return; }
|
|
205
|
+
local started
|
|
206
|
+
started=$(json_extract "$SPRINT_STATE_JSON" '.started_at // ""' 2>/dev/null || echo "")
|
|
207
|
+
# Older state files may carry updated_at only; either is the last sign of life.
|
|
208
|
+
if [ -z "$started" ] || [ "$started" = "null" ]; then
|
|
209
|
+
started=$(json_extract "$SPRINT_STATE_JSON" '.updated_at // ""' 2>/dev/null || echo "")
|
|
210
|
+
fi
|
|
211
|
+
if [ -z "$started" ] || [ "$started" = "null" ]; then
|
|
212
|
+
echo ""
|
|
213
|
+
return
|
|
214
|
+
fi
|
|
215
|
+
# Portable ISO-8601 -> epoch. GNU date first, BSD date second, node last; if
|
|
216
|
+
# all fail the age is unknown and the caller must not invent one.
|
|
217
|
+
local epoch=""
|
|
218
|
+
epoch=$(date -u -d "$started" +%s 2>/dev/null || true)
|
|
219
|
+
if [ -z "$epoch" ]; then
|
|
220
|
+
epoch=$(date -u -j -f "%Y-%m-%dT%H:%M:%SZ" "$started" +%s 2>/dev/null || true)
|
|
221
|
+
fi
|
|
222
|
+
if [ -z "$epoch" ] && command -v node >/dev/null 2>&1; then
|
|
223
|
+
epoch=$(node -e "const t=Date.parse(process.argv[1]);if(Number.isFinite(t))process.stdout.write(String(Math.floor(t/1000)))" "$started" 2>/dev/null || true)
|
|
224
|
+
fi
|
|
225
|
+
if [ -z "$epoch" ]; then
|
|
226
|
+
echo ""
|
|
227
|
+
return
|
|
228
|
+
fi
|
|
229
|
+
local now
|
|
230
|
+
now=$(date -u +%s)
|
|
231
|
+
local age=$(( (now - epoch) / 86400 ))
|
|
232
|
+
[ "$age" -lt 0 ] && age=0
|
|
233
|
+
echo "$age"
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
# Threshold in days beyond which an in_progress sprint is considered abandoned.
|
|
237
|
+
# Configurable because the right number is project-specific: a weekly cadence
|
|
238
|
+
# wants 14, a monthly one wants more. Default 14 = two full weeks without
|
|
239
|
+
# progress: enough that a sprint paused over a holiday is not called dead, tight
|
|
240
|
+
# enough that the reported 12-day case is caught within two days of it firing.
|
|
241
|
+
SPRINT_STALE_DAYS="${SPRINT_STALE_DAYS:-14}"
|
|
242
|
+
|
|
184
243
|
# --- Pre-commit mode ---
|
|
185
244
|
if [ "$MODE" = "pre-commit" ]; then
|
|
186
245
|
PHASE=$(read_phase)
|
|
@@ -222,8 +281,28 @@ if [ "$MODE" = "pre-commit" ]; then
|
|
|
222
281
|
exit 1
|
|
223
282
|
fi
|
|
224
283
|
|
|
225
|
-
#
|
|
226
|
-
|
|
284
|
+
# #551: staleness. A sprint left at status=in_progress past the threshold is
|
|
285
|
+
# abandoned, not validated -- reporting PASS for it means the gate cannot
|
|
286
|
+
# distinguish an advancing sprint from a forgotten one, and it injects
|
|
287
|
+
# ancient phase_history into every unrelated commit's audit trail. SKIP is
|
|
288
|
+
# the honest answer: the gate declines to certify a sprint nobody is
|
|
289
|
+
# driving, without blocking work that has nothing to do with it.
|
|
290
|
+
SPRINT_STATUS=$(read_sprint_status)
|
|
291
|
+
SPRINT_AGE_DAYS=$(read_sprint_age_days)
|
|
292
|
+
if [ "$SPRINT_STATUS" = "in_progress" ] && [ -n "$SPRINT_AGE_DAYS" ] \
|
|
293
|
+
&& [ "$SPRINT_AGE_DAYS" -ge "$SPRINT_STALE_DAYS" ] 2>/dev/null; then
|
|
294
|
+
echo "{\"decision\":\"skip\",\"reason\":\"STALE SPRINT: status=in_progress for ${SPRINT_AGE_DAYS} days (threshold ${SPRINT_STALE_DAYS}) — not certifying it. Finish it (sprint-flow close) or clear .sprint-state/sprint-state.json\"}"
|
|
295
|
+
exit 0
|
|
296
|
+
fi
|
|
297
|
+
|
|
298
|
+
# All checks passed. Name the age so "validated" is not an unconditional
|
|
299
|
+
# claim: a 29-day-old sprint under a 30-day threshold is a very different
|
|
300
|
+
# situation from one opened today, and the log should say which it is.
|
|
301
|
+
if [ -n "$SPRINT_AGE_DAYS" ]; then
|
|
302
|
+
echo "{\"decision\":\"allow\",\"message\":\"delphi-review APPROVED, sprint phase ${PHASE} validated (sprint open ${SPRINT_AGE_DAYS} day(s), staleness threshold ${SPRINT_STALE_DAYS})\"}"
|
|
303
|
+
else
|
|
304
|
+
echo "{\"decision\":\"allow\",\"message\":\"delphi-review APPROVED, sprint phase ${PHASE} validated (sprint age unknown — started_at unreadable)\"}"
|
|
305
|
+
fi
|
|
227
306
|
exit 0
|
|
228
307
|
fi
|
|
229
308
|
|