@boyingliu01/xp-gate 0.23.0 → 0.25.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 (61) hide show
  1. package/adapter-common.sh +51 -2
  2. package/adapters/python.sh +10 -2
  3. package/bin/xp-gate.js +15 -1
  4. package/hooks/adapter-common.sh +51 -2
  5. package/hooks/lib/coverage-total.sh +20 -0
  6. package/hooks/lib/python-test-selection.sh +132 -0
  7. package/hooks/pre-commit +100 -11
  8. package/lib/__tests__/delphi-setup.test.js +203 -0
  9. package/lib/__tests__/doctor-delphi.test.js +244 -0
  10. package/lib/__tests__/init-gitleaks-notice.test.js +57 -0
  11. package/lib/__tests__/issue-519-phase-transition-guard.test.js +233 -0
  12. package/lib/__tests__/qoder-delphi-agents.test.js +31 -0
  13. package/lib/delphi-setup.js +155 -0
  14. package/lib/doctor.js +269 -1
  15. package/lib/init.js +49 -1
  16. package/lib/mattpocock-license-verdict.json +1 -1
  17. package/lib/phase-transition.js +61 -0
  18. package/lib/sprint-state-manager.js +86 -39
  19. package/lib/sprint-state-migrator.js +60 -22
  20. package/mock-policy/AGENTS.md +3 -3
  21. package/mutation/AGENTS.md +3 -3
  22. package/mutation/__tests__/gremlins-runner.test.ts +167 -0
  23. package/mutation/__tests__/mutmut-runner.test.ts +20 -22
  24. package/mutation/__tests__/runners-index.test.ts +31 -3
  25. package/mutation/gate-m.ts +22 -4
  26. package/mutation/runners/gremlins-runner.ts +238 -0
  27. package/mutation/runners/index.ts +9 -1
  28. package/mutation/runners/mutmut-runner.ts +18 -12
  29. package/mutation/runners/types.ts +20 -0
  30. package/package.json +1 -1
  31. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  32. package/plugins/claude-code/skills/delphi-review/.delphi-config.json.example +24 -1
  33. package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
  34. package/plugins/claude-code/skills/delphi-review/SKILL.md +43 -0
  35. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
  36. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
  37. package/plugins/dsh/package.json +1 -1
  38. package/plugins/dsh/skills/delphi-review/.delphi-config.json.example +24 -1
  39. package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
  40. package/plugins/dsh/skills/delphi-review/SKILL.md +43 -0
  41. package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
  42. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
  43. package/plugins/opencode/package.json +1 -1
  44. package/plugins/opencode/skills/delphi-review/.delphi-config.json.example +24 -1
  45. package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
  46. package/plugins/opencode/skills/delphi-review/SKILL.md +43 -0
  47. package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
  48. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
  49. package/plugins/qoder/plugin.json +1 -1
  50. package/plugins/qoder/skills/delphi-review/.delphi-config.json.example +24 -1
  51. package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
  52. package/plugins/qoder/skills/delphi-review/SKILL.md +43 -0
  53. package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
  54. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
  55. package/principles/AGENTS.md +3 -3
  56. package/scripts/delphi-external-review.cjs +166 -14
  57. package/skills/delphi-review/.delphi-config.json.example +24 -1
  58. package/skills/delphi-review/AGENTS.md +3 -3
  59. package/skills/delphi-review/SKILL.md +43 -0
  60. package/skills/sprint-flow/AGENTS.md +3 -3
  61. 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
@@ -133,7 +133,12 @@ run_tests() {
133
133
 
134
134
  echo "Running Python tests..."
135
135
  # Skip e2e tests by default in pre-commit (require live API + network)
136
- PYTEST_OUTPUT=$(pytest --exitfirst --tb=short -m "not e2e" 2>&1)
136
+ # #516: pytest-xdist parallel when available, serial fallback otherwise.
137
+ # Unquoted expansion is intentional (multi-word arg list): the helper
138
+ # returns `-n auto --dist loadfile` as SEPARATE words, or an empty string
139
+ # for a serial fallback. Quoting would collapse it into one bogus argument.
140
+ # shellcheck disable=SC2046
141
+ PYTEST_OUTPUT=$(pytest $(python_xdist_args) --exitfirst --tb=short -m "not e2e" 2>&1)
137
142
  PYTEST_EXIT=$?
138
143
 
139
144
  # Show the short summary tail — this is where FAILED/assert diagnostics live.
@@ -176,7 +181,10 @@ run_coverage() {
176
181
  # 阈值不在此处硬编码:pytest-cov 自动读取 pyproject.toml 的
177
182
  # [tool.coverage.report] fail_under(唯一事实源)。此前这里重复写了
178
183
  # --cov-fail-under=79.9,改 pyproject 不会让门禁跟着变,属于假配置。
179
- PYTEST_OUTPUT=$(pytest --exitfirst --tb=short --cov=src -m "not e2e" 2>&1)
184
+ # #516: xdist 并行(可用时),pytest-cov 与 xdist 正常联动(worker 各自
185
+ # 测量、主进程合并),fail_under 判定不受影响。
186
+ # shellcheck disable=SC2046
187
+ PYTEST_OUTPUT=$(pytest $(python_xdist_args) --exitfirst --tb=short --cov=src -m "not e2e" 2>&1)
180
188
  PYTEST_EXIT=$?
181
189
  echo "$PYTEST_OUTPUT" | grep -E "(FAILED|PASSED|passed|failed|error|ERROR|TOTAL|assert)" | tail -10
182
190
  echo "$PYTEST_OUTPUT" | tail -5
package/bin/xp-gate.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  const path = require('path');
3
3
  const { init } = require('../lib/init.js');
4
+ const { delphiSetup } = require('../lib/delphi-setup.js');
4
5
  const { installSkill } = require('../lib/install-skill.js');
5
6
  const { updateSkill } = require('../lib/update-skill.js');
6
7
  const { uninstallSkill } = require('../lib/uninstall-skill.js');
@@ -80,6 +81,11 @@ const COMMANDS = {
80
81
  run: () => init(['--global']).then(code => process.exit(code)),
81
82
  usage: 'xp-gate setup-global'
82
83
  },
84
+ 'delphi-setup': {
85
+ description: 'Rebind the three Qoder Delphi expert agents to distinct built-in models (#414)',
86
+ run: subargs => delphiSetup(subargs).then(code => process.exit(code)),
87
+ usage: 'xp-gate delphi-setup --model architecture=<id> --model technical=<id> --model feasibility=<id> [--global|--local --project <dir>]'
88
+ },
83
89
  'install-skill': {
84
90
  description: 'Install a xp-gate skill from the package bundle',
85
91
  run: subargs => {
@@ -139,7 +145,15 @@ const COMMANDS = {
139
145
  },
140
146
  'doctor': {
141
147
  description: 'Diagnose xp-gate installation health',
142
- run: subargs => doctor(subargs).then(code => process.exit(code)),
148
+ run: subargs => {
149
+ // `xp-gate doctor delphi` is a dedicated chain diagnosis (#414 REQ-3),
150
+ // not a flag of the generic doctor run.
151
+ if (subargs[0] === 'delphi') {
152
+ const { doctorDelphi } = require('../lib/doctor.js');
153
+ return doctorDelphi({}).then(code => process.exit(code));
154
+ }
155
+ return doctor(subargs).then(code => process.exit(code));
156
+ },
143
157
  usage: 'xp-gate doctor [--fix [--install-tools] | --sync-hooks [--force]] | --json | --format json (the two machine-readable modes print one JSON document and refuse --fix/--sync-hooks)'
144
158
  },
145
159
  'ui-review': {
@@ -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,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/pre-commit CHANGED
@@ -230,6 +230,11 @@ source "${AUDIT_SCRIPT_DIR}/lib/sprint-gate-report.sh"
230
230
  # unit-testable without executing this hook.
231
231
  source "${AUDIT_SCRIPT_DIR}/lib/coverage-total.sh"
232
232
 
233
+ # Issue #399: Python smart test selection — strategy decision + related-test
234
+ # lookup, kept in a library so the truth table and the path conventions are
235
+ # unit-testable without executing this hook.
236
+ source "${AUDIT_SCRIPT_DIR}/lib/python-test-selection.sh"
237
+
233
238
  # Read max_duration_ms from .xp-gate-config.json (default 7200000 = 2 hours)
234
239
  _MAX_DURATION_MS=$(node -e "try{const c=JSON.parse(require('fs').readFileSync('${PROJECT_ROOT_FOR_AUDIT}/.xp-gate-config.json','utf8'));console.log((c.audit&&c.audit.max_duration_ms)||7200000);}catch(e){console.log(7200000);}" 2>/dev/null || echo "7200000")
235
240
 
@@ -2294,6 +2299,13 @@ else
2294
2299
  else
2295
2300
  echo "Running coverage check..."
2296
2301
 
2302
+ # Major-2 (Delphi): COV_EXIT aggregates coverage-run failures across ALL
2303
+ # languages in Stage 1. It starts at 0 and each language branch ORs its own
2304
+ # result in (`COV_EXIT=$((COV_EXIT || $?))`), so a later language's success
2305
+ # can never mask an earlier language's subset-run failure. Previously every
2306
+ # branch overwrote COV_EXIT and the last language's status won.
2307
+ COV_EXIT=0
2308
+
2297
2309
  for CURRENT_LANG in $PROJECT_LANGS; do
2298
2310
  ADAPTER_FILE=$(resolve_adapter_path "$CURRENT_LANG")
2299
2311
  if [ "${CURRENT_LANG}" != "unknown" ] && [ -n "$ADAPTER_FILE" ]; then
@@ -2307,13 +2319,13 @@ else
2307
2319
  # If no tests were run (TESTS_SKIPPED=true), skip coverage check entirely.
2308
2320
  if [ "${TESTS_SKIPPED:-false}" = "true" ]; then
2309
2321
  echo "ℹ️ Test run was skipped — skipping coverage check."
2310
- COV_EXIT=0
2322
+ COV_EXIT=$((COV_EXIT || 0))
2311
2323
  elif [ -f "coverage/coverage-summary.json" ]; then
2312
2324
  echo "TypeScript coverage collected during test run."
2313
- COV_EXIT=0
2325
+ COV_EXIT=$((COV_EXIT || 0))
2314
2326
  else
2315
2327
  echo "ℹ️ No coverage data produced — test run was skipped (no TS changes)."
2316
- COV_EXIT=0
2328
+ COV_EXIT=$((COV_EXIT || 0))
2317
2329
  fi
2318
2330
  ;;
2319
2331
  "python")
@@ -2334,12 +2346,89 @@ else
2334
2346
  fi
2335
2347
  [ -z "$PY_COV_SOURCE" ] && PY_COV_SOURCE="--cov=."
2336
2348
  [ -z "$PY_COV_FAIL_UNDER" ] && PY_COV_FAIL_UNDER=80
2337
- pytest $PY_COV_SOURCE --cov-fail-under="$PY_COV_FAIL_UNDER" --tb=short
2338
- COV_EXIT=$?
2349
+
2350
+ # ── #399: smart test selection, the Python counterpart of #286 ──
2351
+ # Three tiers (lib/python-test-selection.sh holds the pure
2352
+ # decision logic + related-test lookup, unit-tested separately):
2353
+ # changed-tests / related -> run ONLY those targets, with the
2354
+ # repo's coverage SOURCE but WITHOUT the global fail_under
2355
+ # (subset coverage is meaningless globally). The run is marked
2356
+ # partial so Stage 2 warns instead of falsely blocking.
2357
+ # full -> unchanged behavior: whole suite + real threshold.
2358
+ # skip -> nothing test-relevant changed; no pytest at all.
2359
+ PY_CHANGED_TEST_FILES=$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null \
2360
+ | grep -E '(^|/)test_[^/]*\.py$|_test\.py$' || true)
2361
+ PY_CHANGED_SRC_FILES=$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null \
2362
+ | grep -E '\.py$' | grep -vE '(^|/)test_[^/]*\.py$|_test\.py$' || true)
2363
+ PY_CHANGED_ALL=$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null || true)
2364
+ # `grep -c '.'` prints its own zero AND exits 1 on an empty list
2365
+ # (#493), so the count is derived with `|| true` + wc -l instead.
2366
+ PY_TEST_COUNT=$(printf '%s\n' "$PY_CHANGED_TEST_FILES" | grep -c '.' || true)
2367
+ PY_SRC_COUNT=$(printf '%s\n' "$PY_CHANGED_SRC_FILES" | grep -c '.' || true)
2368
+ PY_DEPS_CHANGED=0
2369
+ if python_dependency_changed $PY_CHANGED_ALL; then PY_DEPS_CHANGED=1; fi
2370
+ PY_STRATEGY=$(select_python_test_strategy "$PY_TEST_COUNT" "$PY_SRC_COUNT" "$PY_DEPS_CHANGED")
2371
+
2372
+ # xdist parallel args (#516) — empty + warning when unavailable,
2373
+ # never a BLOCK; unquoted expansion is the multi-word arg list.
2374
+ PY_XDIST_ARGS=$(python_xdist_args 2>/dev/null || true)
2375
+
2376
+ if [ "$PY_STRATEGY" = "skip" ]; then
2377
+ echo "ℹ️ Python: no test/source changes — skipping test run (#399)."
2378
+ # Critical-1 (#399): mark the run skipped so Stage 2's
2379
+ # enforce_python_coverage refuses to consume a stale .coverage
2380
+ # left by a prior subset run. Without this flag the gate read the
2381
+ # leftover low-% file and BLOCKED a docs-only commit that changed
2382
+ # no Python. Mirrors the TS branch's TESTS_SKIPPED guard.
2383
+ PY_TEST_SKIPPED=true
2384
+ COV_EXIT=$((COV_EXIT || 0))
2385
+ elif [ "$PY_STRATEGY" = "full" ]; then
2386
+ if [ "$PY_DEPS_CHANGED" = "1" ]; then
2387
+ echo "ℹ️ Python: dependency manifest changed — running FULL suite (#399)."
2388
+ elif [ "$PY_TEST_COUNT" -gt 20 ] 2>/dev/null; then
2389
+ echo "ℹ️ Python: $PY_TEST_COUNT changed test files (>20) — running FULL suite (#399)."
2390
+ fi
2391
+ # shellcheck disable=SC2086
2392
+ pytest $PY_XDIST_ARGS $PY_COV_SOURCE --cov-fail-under="$PY_COV_FAIL_UNDER" --tb=short
2393
+ COV_EXIT=$((COV_EXIT || $?))
2394
+ else
2395
+ # changed-tests | related → resolve the exact targets.
2396
+ PY_TARGETS=""
2397
+ if [ "$PY_STRATEGY" = "changed-tests" ]; then
2398
+ PY_TARGETS="$PY_CHANGED_TEST_FILES"
2399
+ else
2400
+ for _py_src in $PY_CHANGED_SRC_FILES; do
2401
+ _py_related=$(python_related_tests "$_py_src")
2402
+ if [ -n "$_py_related" ]; then
2403
+ PY_TARGETS="${PY_TARGETS}${_py_related}
2404
+ "
2405
+ fi
2406
+ done
2407
+ PY_TARGETS=$(printf '%s\n' "$PY_TARGETS" | grep -v '^$' | sort -u)
2408
+ fi
2409
+
2410
+ if [ -z "$PY_TARGETS" ]; then
2411
+ # related lookup found nothing → tier 3 full run is safest.
2412
+ echo "ℹ️ Python: no related tests found for changed sources — running FULL suite (#399)."
2413
+ # shellcheck disable=SC2086
2414
+ pytest $PY_XDIST_ARGS $PY_COV_SOURCE --cov-fail-under="$PY_COV_FAIL_UNDER" --tb=short
2415
+ COV_EXIT=$((COV_EXIT || $?))
2416
+ else
2417
+ PY_TARGET_COUNT=$(printf '%s\n' "$PY_TARGETS" | grep -c '.' || true)
2418
+ echo "ℹ️ Python: selective run ($PY_STRATEGY) — $PY_TARGET_COUNT target(s):"
2419
+ printf '%s\n' "$PY_TARGETS" | sed 's/^/ /'
2420
+ # Subset run: keep --cov so data is collected, but drop the
2421
+ # global threshold — subset coverage is meaningless globally.
2422
+ PARTIAL_TEST_RUN=true
2423
+ # shellcheck disable=SC2086
2424
+ pytest $PY_XDIST_ARGS $PY_COV_SOURCE --tb=short $PY_TARGETS
2425
+ COV_EXIT=$((COV_EXIT || $?))
2426
+ fi
2427
+ fi
2339
2428
  else
2340
2429
  echo "pytest/coverage not available, running coverage via adapter..."
2341
2430
  run_coverage_output=$(run_coverage 2>&1)
2342
- COV_EXIT=$?
2431
+ COV_EXIT=$((COV_EXIT || $?))
2343
2432
  echo "$run_coverage_output" | head -30
2344
2433
  fi
2345
2434
  ;;
@@ -2352,30 +2441,30 @@ else
2352
2441
  exit 1
2353
2442
  fi
2354
2443
  echo "Go coverage: $TOTAL_COVERAGE%"
2355
- COV_EXIT=0
2444
+ COV_EXIT=$((COV_EXIT || 0))
2356
2445
  fi
2357
2446
  ;;
2358
2447
  "shell")
2359
2448
  shell_cov_output=$(run_coverage 2>&1)
2360
- COV_EXIT=$?
2449
+ COV_EXIT=$((COV_EXIT || $?))
2361
2450
  echo "$shell_cov_output" | head -10
2362
2451
  echo "ℹ️ Shell coverage not typically measured"
2363
2452
  ;;
2364
2453
  *)
2365
2454
  default_cov_output=$(run_coverage 2>&1)
2366
- COV_EXIT=$?
2455
+ COV_EXIT=$((COV_EXIT || $?))
2367
2456
  echo "$default_cov_output" | head -30
2368
2457
  ;;
2369
2458
  esac
2370
2459
  else
2371
2460
  # Adapter source failed — warn and let Stage 2 attempt file-based enforcement
2372
2461
  echo "⚠️ Could not source ${CURRENT_LANG} adapter from ${ADAPTER_FILE}, will check coverage files directly..."
2373
- COV_EXIT=0
2462
+ COV_EXIT=$((COV_EXIT || 0))
2374
2463
  fi
2375
2464
  else
2376
2465
  # No adapter available — warn and let Stage 2 attempt file-based enforcement
2377
2466
  echo "⚠️ No adapter found for ${CURRENT_LANG} (searched: ${ADAPTER_DIR}/, ${ADAPTER_DIR}/adapters/, ${PROJECT_GITHOOKS}/adapters/, ${SCRIPT_DIR}/adapters/) — will check coverage files directly..."
2378
- COV_EXIT=0
2467
+ COV_EXIT=$((COV_EXIT || 0))
2379
2468
  fi
2380
2469
  done # end for CURRENT_LANG (Stage 1 coverage)
2381
2470