@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.
Files changed (46) hide show
  1. package/adapter-common.sh +51 -2
  2. package/adapters/python.sh +63 -5
  3. package/hooks/adapter-common.sh +51 -2
  4. package/hooks/lib/coverage-total.sh +20 -0
  5. package/hooks/lib/python-incremental-typecheck.sh +104 -0
  6. package/hooks/lib/python-test-selection.sh +132 -0
  7. package/hooks/lib/typecheck.sh +10 -0
  8. package/hooks/pre-commit +157 -33
  9. package/lib/__tests__/init-gitleaks-notice.test.js +57 -0
  10. package/lib/__tests__/issue-519-phase-transition-guard.test.js +233 -0
  11. package/lib/init.js +46 -1
  12. package/lib/mattpocock-license-verdict.json +1 -1
  13. package/lib/phase-transition.js +61 -0
  14. package/lib/sprint-state-manager.js +86 -39
  15. package/lib/sprint-state-migrator.js +60 -22
  16. package/mock-policy/AGENTS.md +3 -3
  17. package/mutation/AGENTS.md +3 -3
  18. package/mutation/__tests__/gremlins-runner.test.ts +167 -0
  19. package/mutation/__tests__/mutmut-runner.test.ts +20 -22
  20. package/mutation/__tests__/runners-index.test.ts +31 -3
  21. package/mutation/gate-m.ts +22 -4
  22. package/mutation/runners/gremlins-runner.ts +238 -0
  23. package/mutation/runners/index.ts +9 -1
  24. package/mutation/runners/mutmut-runner.ts +18 -12
  25. package/mutation/runners/types.ts +20 -0
  26. package/package.json +1 -1
  27. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  28. package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
  29. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
  30. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
  31. package/plugins/dsh/package.json +1 -1
  32. package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
  33. package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
  34. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
  35. package/plugins/opencode/package.json +1 -1
  36. package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
  37. package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
  38. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
  39. package/plugins/qoder/plugin.json +1 -1
  40. package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
  41. package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
  42. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
  43. package/principles/AGENTS.md +3 -3
  44. package/skills/delphi-review/AGENTS.md +3 -3
  45. package/skills/sprint-flow/AGENTS.md +3 -3
  46. 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
- # Detect if a Python project has mutmut installed and configured
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
- # Check for mutmut installation
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
@@ -91,12 +91,62 @@ run_static_analysis() {
91
91
  return 0
92
92
  fi
93
93
  fi
94
- # 逐目标调用并聚合退出码(口径见函数头注释)。
95
- require_tool mypy "mypy" || return 1
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
- mypy "$_mypy_t" || _mypy_rc=1
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
- PYTEST_OUTPUT=$(pytest --exitfirst --tb=short -m "not e2e" 2>&1)
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
- PYTEST_OUTPUT=$(pytest --exitfirst --tb=short --cov=src -m "not e2e" 2>&1)
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
@@ -220,9 +220,58 @@ detect_mutation_testable() {
220
220
  fi
221
221
  return 1
222
222
  }
223
- # Detect if a Python project has mutmut installed and configured
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
- # Check for mutmut installation
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
+ }
@@ -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