@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.
- package/adapter-common.sh +51 -2
- package/adapters/python.sh +10 -2
- package/bin/xp-gate.js +15 -1
- package/hooks/adapter-common.sh +51 -2
- package/hooks/lib/coverage-total.sh +20 -0
- package/hooks/lib/python-test-selection.sh +132 -0
- package/hooks/pre-commit +100 -11
- package/lib/__tests__/delphi-setup.test.js +203 -0
- package/lib/__tests__/doctor-delphi.test.js +244 -0
- package/lib/__tests__/init-gitleaks-notice.test.js +57 -0
- package/lib/__tests__/issue-519-phase-transition-guard.test.js +233 -0
- package/lib/__tests__/qoder-delphi-agents.test.js +31 -0
- package/lib/delphi-setup.js +155 -0
- package/lib/doctor.js +269 -1
- package/lib/init.js +49 -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/.delphi-config.json.example +24 -1
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/claude-code/skills/delphi-review/SKILL.md +43 -0
- 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/.delphi-config.json.example +24 -1
- package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/dsh/skills/delphi-review/SKILL.md +43 -0
- 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/.delphi-config.json.example +24 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/opencode/skills/delphi-review/SKILL.md +43 -0
- 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/.delphi-config.json.example +24 -1
- package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/qoder/skills/delphi-review/SKILL.md +43 -0
- 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/scripts/delphi-external-review.cjs +166 -14
- package/skills/delphi-review/.delphi-config.json.example +24 -1
- package/skills/delphi-review/AGENTS.md +3 -3
- package/skills/delphi-review/SKILL.md +43 -0
- 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
|
@@ -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
|
-
|
|
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
|
-
|
|
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 =>
|
|
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': {
|
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,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
|
|
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
|
|
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
|
|
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
|
-
|
|
2338
|
-
|
|
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
|
|
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
|
|
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
|
|
2467
|
+
COV_EXIT=$((COV_EXIT || 0))
|
|
2379
2468
|
fi
|
|
2380
2469
|
done # end for CURRENT_LANG (Stage 1 coverage)
|
|
2381
2470
|
|