@boyingliu01/xp-gate 0.24.0 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/adapter-common.sh +51 -2
- package/adapters/python.sh +63 -5
- package/hooks/adapter-common.sh +51 -2
- package/hooks/lib/coverage-total.sh +20 -0
- package/hooks/lib/python-incremental-typecheck.sh +104 -0
- package/hooks/lib/python-test-selection.sh +132 -0
- package/hooks/lib/typecheck.sh +10 -0
- package/hooks/pre-commit +157 -33
- package/lib/__tests__/init-gitleaks-notice.test.js +57 -0
- package/lib/__tests__/issue-519-phase-transition-guard.test.js +233 -0
- package/lib/init.js +46 -1
- package/lib/mattpocock-license-verdict.json +1 -1
- package/lib/phase-transition.js +61 -0
- package/lib/sprint-state-manager.js +86 -39
- package/lib/sprint-state-migrator.js +60 -22
- package/mock-policy/AGENTS.md +3 -3
- package/mutation/AGENTS.md +3 -3
- package/mutation/__tests__/gremlins-runner.test.ts +167 -0
- package/mutation/__tests__/mutmut-runner.test.ts +20 -22
- package/mutation/__tests__/runners-index.test.ts +31 -3
- package/mutation/gate-m.ts +22 -4
- package/mutation/runners/gremlins-runner.ts +238 -0
- package/mutation/runners/index.ts +9 -1
- package/mutation/runners/mutmut-runner.ts +18 -12
- package/mutation/runners/types.ts +20 -0
- package/package.json +1 -1
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/dsh/package.json +1 -1
- package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/qoder/plugin.json +1 -1
- package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
- package/principles/AGENTS.md +3 -3
- package/skills/delphi-review/AGENTS.md +3 -3
- package/skills/sprint-flow/AGENTS.md +3 -3
- package/skills/test-specification-alignment/AGENTS.md +3 -3
package/adapter-common.sh
CHANGED
|
@@ -220,9 +220,58 @@ detect_mutation_testable() {
|
|
|
220
220
|
fi
|
|
221
221
|
return 1
|
|
222
222
|
}
|
|
223
|
-
#
|
|
223
|
+
# Echo the pytest-xdist parallel arguments when xdist is importable, or an
|
|
224
|
+
# empty string with a stderr warning when it is not (#516). NEVER fails and
|
|
225
|
+
# never blocks: serial semantics are the safe fallback, the warning only tells
|
|
226
|
+
# the user why Gate 5 is slow. `-n auto` uses every core; `--dist loadfile`
|
|
227
|
+
# groups tests per file to avoid cross-file shared state (module-level
|
|
228
|
+
# fixtures, shared sqlite) racing under parallel workers — measured on a
|
|
229
|
+
# 2555-test project: 30-45min serial -> 2m23s parallel with zero flaky runs.
|
|
230
|
+
# Callers must invoke unquoted and unset: `pytest $(python_xdist_args) ...`
|
|
231
|
+
# (SC2086-intentional: the value is a multi-word argument list).
|
|
232
|
+
python_xdist_args() {
|
|
233
|
+
if python -c "import xdist" >/dev/null 2>&1; then
|
|
234
|
+
printf '%s' "-n auto --dist loadfile"
|
|
235
|
+
return 0
|
|
236
|
+
fi
|
|
237
|
+
echo "⚠ pytest-xdist not installed — Gate 5 falls back to SERIAL (may be 10x slower). Install: pip install pytest-xdist" >&2
|
|
238
|
+
return 0
|
|
239
|
+
}
|
|
240
|
+
# Detect a usable Python mutation engine (#410).
|
|
241
|
+
#
|
|
242
|
+
# Two engines, probed in preference order:
|
|
243
|
+
# 1. pytest-gremlins — mutation switching (no fork), natively cross-platform,
|
|
244
|
+
# requires Python >= 3.11. Preferred wherever it is installed.
|
|
245
|
+
# 2. mutmut — fork()-based, so on Windows it is ONLY runnable under WSL.
|
|
246
|
+
#
|
|
247
|
+
# The original implementation returned success as soon as `mutmut --version`
|
|
248
|
+
# worked, which on native Windows produced a structural deadlock: `mutmut run`
|
|
249
|
+
# exits 1 with "please use the WSL", so every push was blocked by a check that
|
|
250
|
+
# could never pass — while uninstalling mutmut flipped the gate to SKIP. The
|
|
251
|
+
# platform branch below is what removes that deadlock; it never blocks, it only
|
|
252
|
+
# decides whether Gate M runs or skips.
|
|
224
253
|
detect_python_mutation_testable() {
|
|
225
|
-
#
|
|
254
|
+
# Engine 1: pytest-gremlins (cross-platform, no fork).
|
|
255
|
+
if command -v python >/dev/null 2>&1 && python -c "import pytest_gremlins" >/dev/null 2>&1; then
|
|
256
|
+
return 0
|
|
257
|
+
fi
|
|
258
|
+
|
|
259
|
+
# Determine whether mutmut can actually RUN here, not merely whether it is
|
|
260
|
+
# installed — that distinction is the whole point of this function on Windows.
|
|
261
|
+
local _uname_s
|
|
262
|
+
_uname_s="$(uname -s 2>/dev/null || echo unknown)"
|
|
263
|
+
case "$_uname_s" in
|
|
264
|
+
MINGW*|MSYS*|CYGWIN*)
|
|
265
|
+
# Native Windows: mutmut is unusable without WSL.
|
|
266
|
+
if command -v wsl >/dev/null 2>&1 && wsl mutmut --version >/dev/null 2>&1; then
|
|
267
|
+
return 0
|
|
268
|
+
fi
|
|
269
|
+
echo "⚠ Gate M (Python): mutmut cannot run on native Windows (fork()-based) and no WSL mutmut found — SKIP. Install pytest-gremlins instead: pip install pytest-gremlins (needs Python >= 3.11)." >&2
|
|
270
|
+
return 1
|
|
271
|
+
;;
|
|
272
|
+
esac
|
|
273
|
+
|
|
274
|
+
# Unix: mutmut is fine natively.
|
|
226
275
|
if command -v mutmut >/dev/null 2>&1; then
|
|
227
276
|
return 0
|
|
228
277
|
fi
|
package/adapters/python.sh
CHANGED
|
@@ -91,12 +91,62 @@ run_static_analysis() {
|
|
|
91
91
|
return 0
|
|
92
92
|
fi
|
|
93
93
|
fi
|
|
94
|
-
#
|
|
95
|
-
|
|
94
|
+
# #534: engine selection. mypy is ALWAYS the authoritative engine (determinism —
|
|
95
|
+
# ty is an alpha, ~70% spec conformance, config-incompatible). ty is used ONLY
|
|
96
|
+
# as an explicit opt-in accelerator when XP_GATE_TY=1 AND `ty` is installed:
|
|
97
|
+
# - XP_GATE_TY=1 + ty present -> `ty check <target>` (ty's CLI is `ty check`,
|
|
98
|
+
# not `ty`; exit 1 = type errors -> BLOCK, exit 2 = config/CLI error -> SKIP)
|
|
99
|
+
# - XP_GATE_TY=1 + ty missing -> SKIP (return 0), never BLOCK: the opt-in
|
|
100
|
+
# path must not route through require_tool mypy and false-block a repo that
|
|
101
|
+
# opted into ty but lacks mypy (Delphi R1 Critical).
|
|
102
|
+
# - XP_GATE_TY unset -> pure mypy (authoritative), unchanged.
|
|
103
|
+
# Scope semantics (.xp-gate-mypy-scope / src/ scripts/) are engine-independent
|
|
104
|
+
# and preserved verbatim below.
|
|
105
|
+
local _engine=mypy
|
|
106
|
+
if [ "${XP_GATE_TY:-}" = "1" ]; then
|
|
107
|
+
if command -v ty >/dev/null 2>&1; then
|
|
108
|
+
_engine=ty
|
|
109
|
+
echo " ⚙ engine: ty (Rust, explicit opt-in via XP_GATE_TY=1) — mypy remains the default"
|
|
110
|
+
elif command -v mypy >/dev/null 2>&1; then
|
|
111
|
+
# ty opted in but missing; mypy present -> fall back to the authoritative
|
|
112
|
+
# engine with a warning (never silently drop the type gate, #534 R2 arch).
|
|
113
|
+
echo " ⚠ ty requested (XP_GATE_TY=1) but not installed — falling back to mypy"
|
|
114
|
+
else
|
|
115
|
+
echo " ⚠ ty opted in via XP_GATE_TY=1 but neither ty nor mypy is installed — SKIPPING type check (no BLOCK)"
|
|
116
|
+
echo " install: pip install mypy, or pip install ty (alpha)"
|
|
117
|
+
return 0
|
|
118
|
+
fi
|
|
119
|
+
fi
|
|
120
|
+
# require_tool mypy only on the authoritative (mypy) engine path: when the
|
|
121
|
+
# user opted into ty, a missing mypy must NOT false-BLOCK (Delphi R1 Critical).
|
|
122
|
+
if [ "$_engine" = "mypy" ]; then
|
|
123
|
+
require_tool mypy "mypy" || return 1
|
|
124
|
+
fi
|
|
96
125
|
local _mypy_rc=0
|
|
97
126
|
local _mypy_t
|
|
98
127
|
for _mypy_t in "${MYPY_TARGETS[@]}"; do
|
|
99
|
-
|
|
128
|
+
if [ "$_engine" = "ty" ]; then
|
|
129
|
+
# ty's CLI is `ty check <path>`, not `ty <path>`. Bare `ty`/`ty check .`
|
|
130
|
+
# without targets would recurse the whole tree (== the anti `mypy .`
|
|
131
|
+
# guard #501/#513): targets are always explicit here. Capture ty's own
|
|
132
|
+
# exit code and diagnostics via a temp file (never discard them): a BLOCK
|
|
133
|
+
# with no reason is undebuggable, and a pipe `| head` would lose the code.
|
|
134
|
+
local _ty_rc=0
|
|
135
|
+
local _ty_tmp
|
|
136
|
+
_ty_tmp="$(mktemp)"
|
|
137
|
+
ty check "$_mypy_t" > "$_ty_tmp" 2>&1 || _ty_rc=$?
|
|
138
|
+
if [ "$_ty_rc" -eq 2 ]; then
|
|
139
|
+
echo " ⚠ ty exited $_ty_rc on target '$_mypy_t' (config/CLI error) — SKIPPING, not blocking"
|
|
140
|
+
head -30 "$_ty_tmp"
|
|
141
|
+
elif [ "$_ty_rc" -ne 0 ]; then
|
|
142
|
+
echo " ty report for '$_mypy_t' (exit $_ty_rc):"
|
|
143
|
+
head -30 "$_ty_tmp"
|
|
144
|
+
_mypy_rc=1
|
|
145
|
+
fi
|
|
146
|
+
rm -f "$_ty_tmp"
|
|
147
|
+
else
|
|
148
|
+
mypy "$_mypy_t" || _mypy_rc=1
|
|
149
|
+
fi
|
|
100
150
|
done
|
|
101
151
|
return $_mypy_rc
|
|
102
152
|
}
|
|
@@ -133,7 +183,12 @@ run_tests() {
|
|
|
133
183
|
|
|
134
184
|
echo "Running Python tests..."
|
|
135
185
|
# Skip e2e tests by default in pre-commit (require live API + network)
|
|
136
|
-
|
|
186
|
+
# #516: pytest-xdist parallel when available, serial fallback otherwise.
|
|
187
|
+
# Unquoted expansion is intentional (multi-word arg list): the helper
|
|
188
|
+
# returns `-n auto --dist loadfile` as SEPARATE words, or an empty string
|
|
189
|
+
# for a serial fallback. Quoting would collapse it into one bogus argument.
|
|
190
|
+
# shellcheck disable=SC2046
|
|
191
|
+
PYTEST_OUTPUT=$(pytest $(python_xdist_args) --exitfirst --tb=short -m "not e2e" 2>&1)
|
|
137
192
|
PYTEST_EXIT=$?
|
|
138
193
|
|
|
139
194
|
# Show the short summary tail — this is where FAILED/assert diagnostics live.
|
|
@@ -176,7 +231,10 @@ run_coverage() {
|
|
|
176
231
|
# 阈值不在此处硬编码:pytest-cov 自动读取 pyproject.toml 的
|
|
177
232
|
# [tool.coverage.report] fail_under(唯一事实源)。此前这里重复写了
|
|
178
233
|
# --cov-fail-under=79.9,改 pyproject 不会让门禁跟着变,属于假配置。
|
|
179
|
-
|
|
234
|
+
# #516: xdist 并行(可用时),pytest-cov 与 xdist 正常联动(worker 各自
|
|
235
|
+
# 测量、主进程合并),fail_under 判定不受影响。
|
|
236
|
+
# shellcheck disable=SC2046
|
|
237
|
+
PYTEST_OUTPUT=$(pytest $(python_xdist_args) --exitfirst --tb=short --cov=src -m "not e2e" 2>&1)
|
|
180
238
|
PYTEST_EXIT=$?
|
|
181
239
|
echo "$PYTEST_OUTPUT" | grep -E "(FAILED|PASSED|passed|failed|error|ERROR|TOTAL|assert)" | tail -10
|
|
182
240
|
echo "$PYTEST_OUTPUT" | tail -5
|
package/hooks/adapter-common.sh
CHANGED
|
@@ -220,9 +220,58 @@ detect_mutation_testable() {
|
|
|
220
220
|
fi
|
|
221
221
|
return 1
|
|
222
222
|
}
|
|
223
|
-
#
|
|
223
|
+
# Echo the pytest-xdist parallel arguments when xdist is importable, or an
|
|
224
|
+
# empty string with a stderr warning when it is not (#516). NEVER fails and
|
|
225
|
+
# never blocks: serial semantics are the safe fallback, the warning only tells
|
|
226
|
+
# the user why Gate 5 is slow. `-n auto` uses every core; `--dist loadfile`
|
|
227
|
+
# groups tests per file to avoid cross-file shared state (module-level
|
|
228
|
+
# fixtures, shared sqlite) racing under parallel workers — measured on a
|
|
229
|
+
# 2555-test project: 30-45min serial -> 2m23s parallel with zero flaky runs.
|
|
230
|
+
# Callers must invoke unquoted and unset: `pytest $(python_xdist_args) ...`
|
|
231
|
+
# (SC2086-intentional: the value is a multi-word argument list).
|
|
232
|
+
python_xdist_args() {
|
|
233
|
+
if python -c "import xdist" >/dev/null 2>&1; then
|
|
234
|
+
printf '%s' "-n auto --dist loadfile"
|
|
235
|
+
return 0
|
|
236
|
+
fi
|
|
237
|
+
echo "⚠ pytest-xdist not installed — Gate 5 falls back to SERIAL (may be 10x slower). Install: pip install pytest-xdist" >&2
|
|
238
|
+
return 0
|
|
239
|
+
}
|
|
240
|
+
# Detect a usable Python mutation engine (#410).
|
|
241
|
+
#
|
|
242
|
+
# Two engines, probed in preference order:
|
|
243
|
+
# 1. pytest-gremlins — mutation switching (no fork), natively cross-platform,
|
|
244
|
+
# requires Python >= 3.11. Preferred wherever it is installed.
|
|
245
|
+
# 2. mutmut — fork()-based, so on Windows it is ONLY runnable under WSL.
|
|
246
|
+
#
|
|
247
|
+
# The original implementation returned success as soon as `mutmut --version`
|
|
248
|
+
# worked, which on native Windows produced a structural deadlock: `mutmut run`
|
|
249
|
+
# exits 1 with "please use the WSL", so every push was blocked by a check that
|
|
250
|
+
# could never pass — while uninstalling mutmut flipped the gate to SKIP. The
|
|
251
|
+
# platform branch below is what removes that deadlock; it never blocks, it only
|
|
252
|
+
# decides whether Gate M runs or skips.
|
|
224
253
|
detect_python_mutation_testable() {
|
|
225
|
-
#
|
|
254
|
+
# Engine 1: pytest-gremlins (cross-platform, no fork).
|
|
255
|
+
if command -v python >/dev/null 2>&1 && python -c "import pytest_gremlins" >/dev/null 2>&1; then
|
|
256
|
+
return 0
|
|
257
|
+
fi
|
|
258
|
+
|
|
259
|
+
# Determine whether mutmut can actually RUN here, not merely whether it is
|
|
260
|
+
# installed — that distinction is the whole point of this function on Windows.
|
|
261
|
+
local _uname_s
|
|
262
|
+
_uname_s="$(uname -s 2>/dev/null || echo unknown)"
|
|
263
|
+
case "$_uname_s" in
|
|
264
|
+
MINGW*|MSYS*|CYGWIN*)
|
|
265
|
+
# Native Windows: mutmut is unusable without WSL.
|
|
266
|
+
if command -v wsl >/dev/null 2>&1 && wsl mutmut --version >/dev/null 2>&1; then
|
|
267
|
+
return 0
|
|
268
|
+
fi
|
|
269
|
+
echo "⚠ Gate M (Python): mutmut cannot run on native Windows (fork()-based) and no WSL mutmut found — SKIP. Install pytest-gremlins instead: pip install pytest-gremlins (needs Python >= 3.11)." >&2
|
|
270
|
+
return 1
|
|
271
|
+
;;
|
|
272
|
+
esac
|
|
273
|
+
|
|
274
|
+
# Unix: mutmut is fine natively.
|
|
226
275
|
if command -v mutmut >/dev/null 2>&1; then
|
|
227
276
|
return 0
|
|
228
277
|
fi
|
|
@@ -46,6 +46,16 @@ parse_coverage_total() {
|
|
|
46
46
|
# report reads it after the Stage-2 loop (last writer wins across languages),
|
|
47
47
|
# exactly as the inline branch did.
|
|
48
48
|
enforce_python_coverage() {
|
|
49
|
+
# Critical-1 (#399): when the test run was skipped (PY_STRATEGY=skip, set in
|
|
50
|
+
# pre-commit before this Stage-2 function), refuse to consume a stale
|
|
51
|
+
# .coverage left by an earlier subset run — its low % is meaningless for a
|
|
52
|
+
# commit that changed no Python and must not BLOCK a docs-only change. This
|
|
53
|
+
# mirrors the TS branch's TESTS_SKIPPED guard.
|
|
54
|
+
if [ "${PY_TEST_SKIPPED:-false}" = "true" ]; then
|
|
55
|
+
echo "ℹ️ Test run was skipped — skipping Python coverage check."
|
|
56
|
+
return 0
|
|
57
|
+
fi
|
|
58
|
+
|
|
49
59
|
if ! command -v coverage &> /dev/null; then
|
|
50
60
|
echo "⚠️ coverage.py not available, cannot enforce Python coverage threshold."
|
|
51
61
|
return 0
|
|
@@ -61,6 +71,16 @@ enforce_python_coverage() {
|
|
|
61
71
|
|
|
62
72
|
if [ -n "$COVERAGE_PERCENT" ]; then
|
|
63
73
|
if [ "$COVERAGE_PERCENT" -lt 80 ]; then
|
|
74
|
+
# #399: a subset run (smart test selection) measures only the tested
|
|
75
|
+
# files, so its global total is meaningless as a verdict — the full
|
|
76
|
+
# suite may well be >=80% even when the subset is not. Warn exactly as
|
|
77
|
+
# the TypeScript branch does (#286) instead of blocking. Full runs keep
|
|
78
|
+
# the hard BLOCK below.
|
|
79
|
+
if [ "${PARTIAL_TEST_RUN:-false}" = "true" ]; then
|
|
80
|
+
echo "⚠️ Partial coverage ${COVERAGE_PERCENT}% (subset test run, not full suite)."
|
|
81
|
+
echo " Run the full suite for a real Python coverage check."
|
|
82
|
+
return 0
|
|
83
|
+
fi
|
|
64
84
|
echo "❌ BLOCKED - Python coverage ${COVERAGE_PERCENT}% below 80% threshold"
|
|
65
85
|
return 1
|
|
66
86
|
fi
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
|
|
3
|
+
# Incremental Python type-checking helpers for the pre-commit Python branch
|
|
4
|
+
# (#534). This is the FAST staged-only pass: pre-commit runs it on just the
|
|
5
|
+
# .py/.pyi files that changed, so a large repo doesn't pay full-inference CPU
|
|
6
|
+
# on every commit. It complements the FULL mypy pass (githooks/adapters/
|
|
7
|
+
# python.sh run_static_analysis, reachable via the adapter CLI / CI), which is
|
|
8
|
+
# where cross-module blind spots get caught.
|
|
9
|
+
#
|
|
10
|
+
# Sourced by githooks/pre-commit (the python) case. Requires CHANGED_FILES
|
|
11
|
+
# (space/newline separated, repo-root relative) to be in scope, matching the
|
|
12
|
+
# rest of the inline branch.
|
|
13
|
+
#
|
|
14
|
+
# Shellcheck: functions are the public API used by pre-commit; SC1091 sources
|
|
15
|
+
# are intentionally not resolved here (adapter-common not needed).
|
|
16
|
+
|
|
17
|
+
# Extract the staged .py/.pyi files from CHANGED_FILES, one per line.
|
|
18
|
+
# Excludes vendored/ignored trees (.venv, venv, node_modules) that a hook
|
|
19
|
+
# should never type-check. Handles whitespace in paths (echo + grep would
|
|
20
|
+
# split them).
|
|
21
|
+
python_staged_py_files() {
|
|
22
|
+
local f
|
|
23
|
+
while IFS= read -r f || [ -n "$f" ]; do
|
|
24
|
+
case "$f" in
|
|
25
|
+
.venv/*|venv/*|node_modules/*) continue ;;
|
|
26
|
+
*.py|*.pyi) printf '%s\n' "$f" ;;
|
|
27
|
+
esac
|
|
28
|
+
done <<< "$CHANGED_FILES"
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
# Run incremental mypy over the given staged files, returning mypy's REAL exit
|
|
32
|
+
# code (never a pipe stage's). Adds --follow-imports=silent so that a change to
|
|
33
|
+
# A.py that references an unchanged B.py does not surface B's own pre-existing
|
|
34
|
+
# diagnostics — only caller-level signature errors are reported. Callers must
|
|
35
|
+
# pass the file list as arguments (already filtered to .py/.pyi).
|
|
36
|
+
#
|
|
37
|
+
# Scope discipline (same .xp-gate-mypy-scope contract as python.sh): a staged
|
|
38
|
+
# file OUTSIDE the declared mypy scope is skipped with a note rather than type-
|
|
39
|
+
# checked, keeping the incremental pass consistent with the full pass. Files in
|
|
40
|
+
# scope default to "under src/ or scripts/" when no scope file exists.
|
|
41
|
+
python_typecheck_incremental() {
|
|
42
|
+
[ "$#" -gt 0 ] || return 0
|
|
43
|
+
|
|
44
|
+
# Resolve the declared scope (identical precedence to run_static_analysis):
|
|
45
|
+
# .xp-gate-mypy-scope (one target per line) wins; else src/ and scripts/
|
|
46
|
+
# are the implicit default. Any staged .py outside this surface is skipped.
|
|
47
|
+
local -a SCOPE_DIRS=()
|
|
48
|
+
local _declared=0 line target
|
|
49
|
+
if [ -f .xp-gate-mypy-scope ]; then
|
|
50
|
+
while IFS= read -r line || [ -n "$line" ]; do
|
|
51
|
+
line="${line%%#*}"
|
|
52
|
+
line="${line#"${line%%[![:space:]]*}"}"
|
|
53
|
+
line="${line%"${line##*[![:space:]]}"}"
|
|
54
|
+
[ -z "$line" ] && continue
|
|
55
|
+
_declared=1
|
|
56
|
+
# Normalize trailing slashes (`src/` -> `src`) so the `$target/*` prefix
|
|
57
|
+
# match below never sees a doubled slash (`src//a.py` would never match).
|
|
58
|
+
line="${line%/}"
|
|
59
|
+
SCOPE_DIRS+=("$line")
|
|
60
|
+
done < .xp-gate-mypy-scope
|
|
61
|
+
else
|
|
62
|
+
[ -d src ] && SCOPE_DIRS+=(src)
|
|
63
|
+
[ -d scripts ] && SCOPE_DIRS+=(scripts)
|
|
64
|
+
fi
|
|
65
|
+
|
|
66
|
+
# If a scope file is declared but yields nothing, every staged .py is out of
|
|
67
|
+
# scope -> SKIP (exit 0), mirroring the full pass.
|
|
68
|
+
local _in_scope=false
|
|
69
|
+
local -a TYPECHECK_FILES=()
|
|
70
|
+
local f
|
|
71
|
+
if [ "${#SCOPE_DIRS[@]}" -gt 0 ]; then
|
|
72
|
+
for f in "$@"; do
|
|
73
|
+
_in_scope=false
|
|
74
|
+
for target in "${SCOPE_DIRS[@]}"; do
|
|
75
|
+
case "$f" in
|
|
76
|
+
"$target"/*) _in_scope=true ;;
|
|
77
|
+
esac
|
|
78
|
+
[ "$_in_scope" = true ] && break
|
|
79
|
+
done
|
|
80
|
+
if [ "$_in_scope" = true ]; then
|
|
81
|
+
TYPECHECK_FILES+=("$f")
|
|
82
|
+
else
|
|
83
|
+
echo " ℹ️ mypy incremental: '$f' is outside declared scope — skipped"
|
|
84
|
+
fi
|
|
85
|
+
done
|
|
86
|
+
fi
|
|
87
|
+
|
|
88
|
+
[ "${#TYPECHECK_FILES[@]}" -gt 0 ] || return 0
|
|
89
|
+
|
|
90
|
+
# Capture mypy's exit code directly. A pipeline like
|
|
91
|
+
# mypy ... | head -20 | tail -10; rc=$?
|
|
92
|
+
# loses mypy's real status (the pipe stage's rc is used instead), which made
|
|
93
|
+
# the gate pass silently on real type errors. Redirect to a temp file, take
|
|
94
|
+
# mypy's own exit code, then print a truncated view.
|
|
95
|
+
local tmp
|
|
96
|
+
tmp="$(mktemp)"
|
|
97
|
+
echo "Running mypy type checking (incremental)..."
|
|
98
|
+
mypy --follow-imports=silent --ignore-missing-imports "${TYPECHECK_FILES[@]}" > "$tmp" 2>&1
|
|
99
|
+
local _mypy_rc=$?
|
|
100
|
+
head -20 "$tmp"
|
|
101
|
+
tail -10 "$tmp"
|
|
102
|
+
rm -f "$tmp"
|
|
103
|
+
return "$_mypy_rc"
|
|
104
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Python smart test selection (#399) — the Python counterpart of the TypeScript
|
|
3
|
+
# selector (#286). Kept in its own file so the strategy truth table and the
|
|
4
|
+
# related-test lookup are unit-testable without executing the hook
|
|
5
|
+
# (githooks/__tests__/issue-399-python-test-selection.test.bats).
|
|
6
|
+
#
|
|
7
|
+
# Why: Python pre-commit ran the FULL suite on every commit (2-5min on a
|
|
8
|
+
# 1000+-test project). TypeScript already counts changed test files, resolves
|
|
9
|
+
# related tests from changed sources, and only falls back to the full suite
|
|
10
|
+
# for large batches. Python had no equivalent.
|
|
11
|
+
#
|
|
12
|
+
# Three tiers, matching the TS implementation:
|
|
13
|
+
# 1. changed-tests — only test files changed: run exactly those.
|
|
14
|
+
# 2. related — source files changed, no test files: run the tests that
|
|
15
|
+
# the path convention maps to them.
|
|
16
|
+
# 3. full — >20 test files changed, dependency files changed, or no
|
|
17
|
+
# related tests found: run everything with the real
|
|
18
|
+
# coverage threshold.
|
|
19
|
+
# Tier 1/2 run WITHOUT the global coverage threshold (subset coverage is
|
|
20
|
+
# meaningless globally); the caller marks the run partial so Stage 2 warns
|
|
21
|
+
# instead of blocking.
|
|
22
|
+
|
|
23
|
+
# Max changed test files before falling back to the full suite. Same value as
|
|
24
|
+
# the TypeScript branch (#286) so the two languages behave alike.
|
|
25
|
+
PY_MAX_CHANGED_TESTS=20
|
|
26
|
+
|
|
27
|
+
# Dependency manifests: a change here can break ANY test, so the selector
|
|
28
|
+
# always escalates to the full suite regardless of how few tests changed.
|
|
29
|
+
PY_DEPENDENCY_PATTERNS='(^|/)(pyproject\.toml|setup\.py|setup\.cfg|requirements[^/]*\.txt)$'
|
|
30
|
+
|
|
31
|
+
# Decide the strategy from three counts. Pure function — no filesystem access,
|
|
32
|
+
# so the truth table is exhaustive and cheap to test.
|
|
33
|
+
#
|
|
34
|
+
# $1 test_count changed test files
|
|
35
|
+
# $2 src_count changed non-test Python source files
|
|
36
|
+
# $3 deps_changed 1 when a dependency manifest changed, else 0
|
|
37
|
+
#
|
|
38
|
+
# Echoes exactly one of: full | changed-tests | related | skip
|
|
39
|
+
select_python_test_strategy() {
|
|
40
|
+
local test_count="${1:-0}"
|
|
41
|
+
local src_count="${2:-0}"
|
|
42
|
+
local deps_changed="${3:-0}"
|
|
43
|
+
|
|
44
|
+
# Dependency changes can break anything -> always the full suite.
|
|
45
|
+
if [ "$deps_changed" = "1" ]; then
|
|
46
|
+
printf '%s' "full"
|
|
47
|
+
return 0
|
|
48
|
+
fi
|
|
49
|
+
|
|
50
|
+
# Large batch: the per-file selection loses its point, and the TS branch
|
|
51
|
+
# uses the same cutoff.
|
|
52
|
+
if [ "$test_count" -gt "$PY_MAX_CHANGED_TESTS" ] 2>/dev/null; then
|
|
53
|
+
printf '%s' "full"
|
|
54
|
+
return 0
|
|
55
|
+
fi
|
|
56
|
+
|
|
57
|
+
if [ "$test_count" -gt 0 ] 2>/dev/null; then
|
|
58
|
+
printf '%s' "changed-tests"
|
|
59
|
+
return 0
|
|
60
|
+
fi
|
|
61
|
+
|
|
62
|
+
if [ "$src_count" -gt 0 ] 2>/dev/null; then
|
|
63
|
+
printf '%s' "related"
|
|
64
|
+
return 0
|
|
65
|
+
fi
|
|
66
|
+
|
|
67
|
+
printf '%s' "skip"
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
# Resolve the test files related to one source file, following the conventions
|
|
71
|
+
# pytest projects actually use. Echoes one path per line, deduplicated, in
|
|
72
|
+
# discovery order. Echoes nothing when no candidate exists — the caller then
|
|
73
|
+
# falls back to the full suite (tier 3).
|
|
74
|
+
#
|
|
75
|
+
# $1 source path, e.g. src/foo/bar.py
|
|
76
|
+
python_related_tests() {
|
|
77
|
+
local src_file="$1"
|
|
78
|
+
[ -n "$src_file" ] || return 0
|
|
79
|
+
|
|
80
|
+
local base="${src_file%.*}"
|
|
81
|
+
local filename="${base##*/}"
|
|
82
|
+
local dir
|
|
83
|
+
dir="$(dirname "$src_file")"
|
|
84
|
+
|
|
85
|
+
local candidates=""
|
|
86
|
+
# Directory-relative candidates first (most specific), then flat tests/,
|
|
87
|
+
# then the collocated forms. Covers both pytest naming conventions
|
|
88
|
+
# (test_<name>.py and <name>_test.py).
|
|
89
|
+
candidates="$candidates
|
|
90
|
+
${dir}/test_${filename}.py
|
|
91
|
+
${dir}/${filename}_test.py
|
|
92
|
+
${dir}/__tests__/test_${filename}.py
|
|
93
|
+
${dir}/__tests__/${filename}_test.py"
|
|
94
|
+
# tests/<dir-surviving-subpath>/… : src/foo/bar.py -> tests/foo/test_bar.py
|
|
95
|
+
local subpath="${dir#src/}"
|
|
96
|
+
[ "$subpath" = "$dir" ] && subpath="$dir"
|
|
97
|
+
candidates="$candidates
|
|
98
|
+
tests/${subpath}/test_${filename}.py
|
|
99
|
+
tests/${subpath}/${filename}_test.py"
|
|
100
|
+
candidates="$candidates
|
|
101
|
+
tests/test_${filename}.py
|
|
102
|
+
tests/${filename}_test.py"
|
|
103
|
+
|
|
104
|
+
local seen=""
|
|
105
|
+
local candidate
|
|
106
|
+
# IFS-safe iteration over newline-separated candidates.
|
|
107
|
+
while IFS= read -r candidate; do
|
|
108
|
+
[ -n "$candidate" ] || continue
|
|
109
|
+
[ -f "$candidate" ] || continue
|
|
110
|
+
# Dedupe: several patterns can resolve to the same file (e.g. a source at
|
|
111
|
+
# repo root has dir='.' so the relative and flat forms coincide).
|
|
112
|
+
case "$seen" in
|
|
113
|
+
*"|$candidate|"*) continue ;;
|
|
114
|
+
esac
|
|
115
|
+
seen="$seen|$candidate|"
|
|
116
|
+
printf '%s\n' "$candidate"
|
|
117
|
+
done <<EOF
|
|
118
|
+
$candidates
|
|
119
|
+
EOF
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
# True (0) when any of the given paths is a Python dependency manifest.
|
|
123
|
+
python_dependency_changed() {
|
|
124
|
+
local path
|
|
125
|
+
for path in "$@"; do
|
|
126
|
+
[ -n "$path" ] || continue
|
|
127
|
+
if echo "$path" | grep -qE "$PY_DEPENDENCY_PATTERNS"; then
|
|
128
|
+
return 0
|
|
129
|
+
fi
|
|
130
|
+
done
|
|
131
|
+
return 1
|
|
132
|
+
}
|
package/hooks/lib/typecheck.sh
CHANGED
|
@@ -3,6 +3,16 @@
|
|
|
3
3
|
# Gate 1 TypeScript static analysis — the single place that decides HOW a project
|
|
4
4
|
# is type-checked (#436).
|
|
5
5
|
#
|
|
6
|
+
# NOT incremental (deliberately, #534): TypeScript type-checking cannot be
|
|
7
|
+
# reduced to "staged files only". `tsc` has no single-file check mode — it must
|
|
8
|
+
# resolve the whole module graph to report a file's real type errors, so
|
|
9
|
+
# type-checking only the changed files would silently miss "changing B.ts breaks
|
|
10
|
+
# an unchanged caller in A.ts". Pre-commit therefore runs the project's full
|
|
11
|
+
# typecheck (`npm run typecheck` / `npx tsc --noEmit`) every commit. This is the
|
|
12
|
+
# CORRECTNESS-preserving exception to xp-gate's incremental rule: lint (ESLint /
|
|
13
|
+
# Biome) IS incremental (staged files / --staged), but type-check stays full
|
|
14
|
+
# because incremental type-checking would be wrong, not just slower.
|
|
15
|
+
#
|
|
6
16
|
# Before this file, `githooks/pre-commit` and `githooks/adapters/typescript.sh` each
|
|
7
17
|
# hardcoded `npx tsc --noEmit`. Plain tsc cannot resolve `.vue` single-file
|
|
8
18
|
# components, so every `import X from './Foo.vue'` became a TS2307 and a Vue project
|