claude-dev-env 1.95.0 → 2.0.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/_shared/advisor/CLAUDE.md +2 -2
- package/_shared/advisor/advisor-protocol.md +20 -20
- package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +15 -12
- package/_shared/advisor/scripts/model_tier_run_validator.py +11 -10
- package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +25 -19
- package/_shared/advisor/scripts/tests/test_tier_model_ids.py +17 -17
- package/_shared/advisor/scripts/tier_model_ids.py +18 -18
- package/_shared/pr-loop/CLAUDE.md +1 -0
- package/_shared/pr-loop/scripts/CLAUDE.md +2 -1
- package/_shared/pr-loop/scripts/README.md +1 -0
- package/_shared/pr-loop/scripts/code_rules_gate.py +253 -1980
- package/_shared/pr-loop/scripts/code_rules_gate_parts/CLAUDE.md +32 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/__init__.py +7 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/added_line_maps.py +268 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/enforcer_loading.py +172 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_arguments.py +70 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_running.py +326 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/git_blob_readers.py +85 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/git_file_sets.py +331 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_running.py +369 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/conftest.py +14 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_added_line_maps.py +118 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_enforcer_loading.py +17 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_arguments.py +29 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_running.py +99 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_git_blob_readers.py +69 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_git_file_sets.py +137 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_running.py +116 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_violation_scoping.py +75 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_wrapper_plumb_check.py +49 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/violation_scoping.py +328 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/wrapper_plumb_check.py +206 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +24 -17
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviews_disabled_constants.py +1 -0
- package/_shared/pr-loop/scripts/reviews_disabled.py +19 -2
- package/_shared/pr-loop/scripts/test_code_rules_gate.py +278 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate_constants.py +6 -39
- package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +43 -0
- package/_shared/pr-loop/worker-spawn.md +186 -0
- package/agents/code-verifier.md +1 -1
- package/bin/ever-shipped-skills.mjs +3 -0
- package/bin/expand_home_directory_tokens.mjs +1 -1
- package/bin/install.mjs +5 -2
- package/hooks/advisory/refactor_guard.py +3 -4
- package/hooks/blocking/CLAUDE.md +7 -1
- package/hooks/blocking/block_main_commit.py +2 -2
- package/hooks/blocking/claude_md_orphan_file_blocker.py +75 -699
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/CLAUDE.md +28 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/__init__.py +1 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/config/__init__.py +1 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/config/orphan_blocker_constants.py +18 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/decision.py +81 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/references.py +307 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/scan_plan.py +124 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/subtree_scan.py +179 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/conftest.py +10 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_decision.py +34 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_references.py +42 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_scan_plan.py +27 -0
- package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_subtree_scan.py +30 -0
- package/hooks/blocking/code_rules_boolean_mustcheck.py +1 -1
- package/hooks/blocking/code_rules_mock_completeness.py +1 -1
- package/hooks/blocking/code_rules_optional_params.py +2 -2
- package/hooks/blocking/code_rules_shared.py +1 -1
- package/hooks/blocking/code_rules_test_assertions.py +1 -1
- package/hooks/blocking/code_rules_typeddict_stub.py +1 -1
- package/hooks/blocking/gh_pr_author_enforcer.py +1 -1
- package/hooks/blocking/inventory_intent_records/CLAUDE.md +26 -0
- package/hooks/blocking/inventory_intent_records/__init__.py +1 -0
- package/hooks/blocking/inventory_intent_records/config/__init__.py +1 -0
- package/hooks/blocking/inventory_intent_records/config/intent_records_constants.py +20 -0
- package/hooks/blocking/inventory_intent_records/records.py +271 -0
- package/hooks/blocking/inventory_intent_records/tests/conftest.py +10 -0
- package/hooks/blocking/inventory_intent_records/tests/test_records.py +80 -0
- package/hooks/blocking/package_inventory_stale_blocker.py +54 -384
- package/hooks/blocking/package_inventory_stale_blocker_parts/CLAUDE.md +26 -0
- package/hooks/blocking/package_inventory_stale_blocker_parts/__init__.py +1 -0
- package/hooks/blocking/package_inventory_stale_blocker_parts/config/__init__.py +1 -0
- package/hooks/blocking/package_inventory_stale_blocker_parts/config/inventory_blocker_constants.py +16 -0
- package/hooks/blocking/package_inventory_stale_blocker_parts/decision.py +84 -0
- package/hooks/blocking/package_inventory_stale_blocker_parts/inventory_detection.py +307 -0
- package/hooks/blocking/package_inventory_stale_blocker_parts/tests/conftest.py +10 -0
- package/hooks/blocking/package_inventory_stale_blocker_parts/tests/test_decision.py +38 -0
- package/hooks/blocking/package_inventory_stale_blocker_parts/tests/test_inventory_detection.py +61 -0
- package/hooks/blocking/pii_payload_scan.py +138 -42
- package/hooks/blocking/pii_prevention_blocker.py +185 -291
- package/hooks/blocking/pii_prevention_blocker_parts/CLAUDE.md +24 -0
- package/hooks/blocking/pii_prevention_blocker_parts/__init__.py +1 -0
- package/hooks/blocking/pii_prevention_blocker_parts/config/__init__.py +1 -0
- package/hooks/blocking/pii_prevention_blocker_parts/config/repository_resolution_constants.py +28 -0
- package/hooks/blocking/pii_prevention_blocker_parts/repository_exemption.py +214 -0
- package/hooks/blocking/pii_prevention_blocker_parts/repository_resolution.py +208 -0
- package/hooks/blocking/pr_description_command_parser.py +8 -4
- package/hooks/blocking/precommit_code_rules_gate.py +3 -3
- package/hooks/blocking/tdd_enforcer.py +97 -608
- package/hooks/blocking/tdd_enforcer_parts/CLAUDE.md +30 -0
- package/hooks/blocking/tdd_enforcer_parts/__init__.py +1 -0
- package/hooks/blocking/tdd_enforcer_parts/candidate_paths.py +142 -0
- package/hooks/blocking/tdd_enforcer_parts/config/__init__.py +1 -0
- package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +32 -0
- package/hooks/blocking/tdd_enforcer_parts/content_analysis.py +268 -0
- package/hooks/blocking/tdd_enforcer_parts/decisions.py +92 -0
- package/hooks/blocking/tdd_enforcer_parts/freshness.py +80 -0
- package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +63 -0
- package/hooks/blocking/tdd_enforcer_parts/path_classification.py +119 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/conftest.py +10 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_candidate_paths.py +31 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_content_analysis.py +30 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +34 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_freshness.py +28 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +48 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_path_classification.py +36 -0
- package/hooks/blocking/test_inventory_deadlock_resolution.py +154 -0
- package/hooks/blocking/test_pii_payload_scan.py +168 -0
- package/hooks/blocking/test_tdd_enforcer_restore.py +108 -0
- package/hooks/blocking/tests/conftest.py +10 -0
- package/hooks/blocking/tests/test_pii_prevention_blocker.py +260 -0
- package/hooks/blocking/tests/test_repository_exemption.py +105 -0
- package/hooks/blocking/tests/test_repository_resolution.py +108 -0
- package/hooks/diagnostic/hook_log_extractor.py +12 -10
- package/hooks/git-hooks/post_commit.py +3 -4
- package/hooks/hooks_constants/CLAUDE.md +2 -2
- package/hooks/hooks_constants/banned_identifiers_constants.py +0 -1
- package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -1
- package/hooks/hooks_constants/local_identity.py +59 -8
- package/hooks/hooks_constants/pii_prevention_constants.py +0 -6
- package/hooks/hooks_constants/test_local_identity.py +105 -3
- package/hooks/pyproject.toml +13 -36
- package/hooks/session/plugin_data_dir_cleanup.py +0 -1
- package/hooks/validation/mypy_validator.py +2 -2
- package/hooks/validators/health_check.py +1 -0
- package/hooks/validators/mypy_integration.py +2 -0
- package/hooks/validators/ruff_integration.py +3 -0
- package/hooks/workflow/auto_formatter.py +5 -4
- package/package.json +1 -1
- package/scripts/CLAUDE.md +4 -0
- package/scripts/dev_env_scripts_constants/CLAUDE.md +6 -4
- package/scripts/dev_env_scripts_constants/code_review_constants.py +71 -0
- package/scripts/dev_env_scripts_constants/grok_worker_constants.py +435 -0
- package/scripts/dev_env_scripts_constants/timing.py +7 -1
- package/scripts/grok_headless_runner.py +294 -0
- package/scripts/grok_worker_preflight.py +410 -0
- package/scripts/invoke_code_review.py +463 -0
- package/scripts/resolve_worker_spawn.py +619 -0
- package/scripts/spawn_grok_batch.py +672 -0
- package/scripts/test_grok_headless_runner.py +626 -0
- package/scripts/test_grok_worker_preflight.py +1054 -0
- package/scripts/test_invoke_code_review.py +672 -0
- package/scripts/test_resolve_worker_spawn.py +1014 -0
- package/scripts/test_spawn_grok_batch.py +1017 -0
- package/skills/CLAUDE.md +5 -3
- package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +72 -13
- package/skills/_shared/pr-loop/scripts/build_fix_prompt.py +121 -14
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/path_resolver_constants.py +78 -0
- package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +121 -0
- package/skills/_shared/pr-loop/scripts/test_build_fix_prompt.py +196 -6
- package/skills/autoconverge/CLAUDE.md +3 -3
- package/skills/autoconverge/SKILL.md +9 -3
- package/skills/autoconverge/reference/CLAUDE.md +2 -2
- package/skills/autoconverge/reference/convergence.md +33 -11
- package/skills/autoconverge/reference/stop-conditions.md +16 -5
- package/skills/autoconverge/workflow/CLAUDE.md +2 -1
- package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +7 -2
- package/skills/autoconverge/workflow/converge.codex-gate.test.mjs +300 -0
- package/skills/autoconverge/workflow/converge.contract.test.mjs +5 -5
- package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +29 -29
- package/skills/autoconverge/workflow/converge.fix-progress.test.mjs +1 -1
- package/skills/autoconverge/workflow/converge.mjs +200 -16
- package/skills/bugteam/CLAUDE.md +2 -2
- package/skills/bugteam/CONSTRAINTS.md +3 -2
- package/skills/bugteam/PROMPTS.md +7 -6
- package/skills/bugteam/SKILL.md +18 -13
- package/skills/bugteam/reference/audit-and-teammates.md +215 -35
- package/skills/bugteam/reference/design-rationale.md +1 -1
- package/skills/bugteam/reference/obstacles/CLAUDE.md +1 -1
- package/skills/bugteam/reference/team-setup.md +8 -2
- package/skills/codex-review/CLAUDE.md +46 -0
- package/skills/codex-review/SKILL.md +181 -0
- package/skills/codex-review/reference/CLAUDE.md +15 -0
- package/skills/codex-review/reference/cli-contract.md +253 -0
- package/skills/codex-review/reference/loop-integration.md +118 -0
- package/skills/codex-review/scripts/codex_down_classifier.py +98 -0
- package/skills/codex-review/scripts/codex_review_scripts_constants/CLAUDE.md +18 -0
- package/skills/codex-review/scripts/codex_review_scripts_constants/__init__.py +1 -0
- package/skills/codex-review/scripts/codex_review_scripts_constants/classifier_constants.py +35 -0
- package/skills/codex-review/scripts/codex_review_scripts_constants/codex_usage_probe_constants.py +86 -0
- package/skills/codex-review/scripts/codex_review_scripts_constants/findings_constants.py +18 -0
- package/skills/codex-review/scripts/codex_review_scripts_constants/run_constants.py +45 -0
- package/skills/codex-review/scripts/codex_usage_probe.py +573 -0
- package/skills/codex-review/scripts/fixtures/auth_failure_synthetic.txt +1 -0
- package/skills/codex-review/scripts/fixtures/config_load_failure_v0.125.0.txt +1 -0
- package/skills/codex-review/scripts/fixtures/freeform_findings_v0.144.3.txt +6 -0
- package/skills/codex-review/scripts/fixtures/model_rejection_v0.125.0.jsonl +5 -0
- package/skills/codex-review/scripts/fixtures/structured_findings.txt +13 -0
- package/skills/codex-review/scripts/fixtures/success_stream_v0.144.3.jsonl +6 -0
- package/skills/codex-review/scripts/fixtures/unknown_failure_synthetic.txt +1 -0
- package/skills/codex-review/scripts/fixtures/usage_limit_synthetic.txt +1 -0
- package/skills/codex-review/scripts/parse_codex_findings.py +207 -0
- package/skills/codex-review/scripts/run_codex_review.py +415 -0
- package/skills/codex-review/scripts/test_codex_down_classifier.py +143 -0
- package/skills/codex-review/scripts/test_codex_usage_probe.py +678 -0
- package/skills/codex-review/scripts/test_parse_codex_findings.py +130 -0
- package/skills/codex-review/scripts/test_run_codex_review.py +812 -0
- package/skills/codex-review/test_skill_scaffold.py +192 -0
- package/skills/grok-spawn/CLAUDE.md +28 -0
- package/skills/grok-spawn/SKILL.md +226 -0
- package/skills/grok-spawn/reference/flag-profiles.md +132 -0
- package/skills/grok-spawn/reference/worker-briefs.md +152 -0
- package/skills/grokify/SKILL.md +9 -1
- package/skills/grokify/capability-claims.test.mjs +28 -0
- package/skills/grokify/evals/README.md +72 -0
- package/skills/grokify/evals/parse-payload.test.mjs +171 -0
- package/skills/grokify/evals/run-capability-evals.mjs +545 -0
- package/skills/orchestrator/SKILL.md +15 -11
- package/skills/orchestrator-refresh/SKILL.md +5 -5
- package/skills/pr-converge/SKILL.md +34 -13
- package/skills/pr-converge/reference/convergence-gates.md +42 -15
- package/skills/pr-converge/reference/fix-protocol.md +1 -1
- package/skills/pr-converge/reference/ground-rules.md +1 -1
- package/skills/pr-converge/reference/per-tick.md +130 -42
- package/skills/pr-converge/reference/state-schema.md +10 -0
- package/skills/pr-converge/scripts/CLAUDE.md +2 -0
- package/skills/pr-converge/scripts/_pr_converge_path_setup.py +5 -1
- package/skills/pr-converge/scripts/check_convergence.py +605 -29
- package/skills/pr-converge/scripts/check_convergence_availability.py +232 -0
- package/skills/pr-converge/scripts/check_convergence_gates.py +279 -235
- package/skills/pr-converge/scripts/check_convergence_thread_gates.py +1 -1
- package/skills/pr-converge/scripts/pr_converge_scripts_constants/convergence_gate_constants.py +36 -2
- package/skills/pr-converge/scripts/test__pr_converge_path_setup.py +4 -0
- package/skills/pr-converge/scripts/test_check_convergence.py +71 -3
- package/skills/pr-converge/scripts/test_check_convergence_availability.py +326 -0
- package/skills/pr-converge/scripts/test_check_convergence_codex.py +507 -0
- package/skills/pr-converge/scripts/test_check_convergence_contract.py +89 -17
- package/skills/pr-converge/scripts/test_check_convergence_fixture.py +179 -0
- package/skills/pr-converge/scripts/test_check_convergence_gates.py +84 -68
- package/skills/pr-converge/scripts/test_check_convergence_thread_gates.py +24 -0
- package/skills/pr-converge/test_step5_host_branch.py +106 -0
- package/skills/pr-loop-cloud-transport/SKILL.md +2 -0
- package/skills/reviewer-gates/SKILL.md +7 -5
- package/skills/team-advisor/SKILL.md +7 -7
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"""Resolve the file sets the gate reads from git.
|
|
2
|
+
|
|
3
|
+
Every helper shells out to git with a scrubbed environment and null-terminated
|
|
4
|
+
output, so a filename holding a tab or newline round-trips unmangled. The path
|
|
5
|
+
readers turn ``-z`` streams into absolute paths; the staged helpers report the
|
|
6
|
+
added state and line span of one file.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from pr_loop_shared_constants.code_rules_gate_constants import (
|
|
14
|
+
ALL_GIT_DIFF_CACHED_NAME_ONLY_NULL_TERMINATED_COMMAND,
|
|
15
|
+
ALL_GIT_DIFF_NAME_ONLY_NULL_TERMINATED_COMMAND_PREFIX,
|
|
16
|
+
ALL_GIT_LS_FILES_UNTRACKED_NULL_TERMINATED_COMMAND,
|
|
17
|
+
GATE_ERROR_EXIT_CODE,
|
|
18
|
+
GIT_NAME_STATUS_ADDED_PREFIX,
|
|
19
|
+
)
|
|
20
|
+
from terminology_sweep import repository_environment
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"repository_environment",
|
|
24
|
+
"resolve_merge_base",
|
|
25
|
+
"paths_from_git_staged",
|
|
26
|
+
"paths_from_git_diff",
|
|
27
|
+
"paths_from_git_untracked",
|
|
28
|
+
"filter_paths_under_prefixes",
|
|
29
|
+
"is_staged_file_newly_added",
|
|
30
|
+
"staged_file_line_count",
|
|
31
|
+
"staged_unified_diff_text",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _git_text_or_exit(
|
|
36
|
+
repository_root: Path, all_git_arguments: list[str], failure_prefix: str
|
|
37
|
+
) -> str:
|
|
38
|
+
"""Run a git command in text mode, returning stdout or exiting on failure.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
repository_root: Repository root used as the working directory.
|
|
42
|
+
all_git_arguments: The full git argv, including the ``git`` word.
|
|
43
|
+
failure_prefix: The stderr message prefix written on a non-zero exit.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
The command's stdout.
|
|
47
|
+
|
|
48
|
+
Raises:
|
|
49
|
+
SystemExit: When the git command returns non-zero.
|
|
50
|
+
"""
|
|
51
|
+
completed = subprocess.run(
|
|
52
|
+
all_git_arguments,
|
|
53
|
+
cwd=str(repository_root),
|
|
54
|
+
capture_output=True,
|
|
55
|
+
text=True,
|
|
56
|
+
encoding="utf-8",
|
|
57
|
+
errors="replace",
|
|
58
|
+
check=False,
|
|
59
|
+
env=repository_environment(),
|
|
60
|
+
)
|
|
61
|
+
if completed.returncode != 0:
|
|
62
|
+
sys.stderr.write(f"{failure_prefix}:\n{completed.stderr}\n")
|
|
63
|
+
raise SystemExit(GATE_ERROR_EXIT_CODE)
|
|
64
|
+
return completed.stdout
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _git_bytes_or_exit(
|
|
68
|
+
repository_root: Path, all_git_arguments: list[str], failure_prefix: str
|
|
69
|
+
) -> bytes:
|
|
70
|
+
"""Run a git command in binary mode, returning stdout or exiting on failure.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
repository_root: Repository root used as the working directory.
|
|
74
|
+
all_git_arguments: The full git argv, including the ``git`` word.
|
|
75
|
+
failure_prefix: The stderr message prefix written on a non-zero exit.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
The command's stdout bytes.
|
|
79
|
+
|
|
80
|
+
Raises:
|
|
81
|
+
SystemExit: When the git command returns non-zero.
|
|
82
|
+
"""
|
|
83
|
+
completed = subprocess.run(
|
|
84
|
+
all_git_arguments,
|
|
85
|
+
cwd=str(repository_root),
|
|
86
|
+
capture_output=True,
|
|
87
|
+
check=False,
|
|
88
|
+
env=repository_environment(),
|
|
89
|
+
)
|
|
90
|
+
if completed.returncode != 0:
|
|
91
|
+
stderr_text = completed.stderr.decode("utf-8", errors="replace")
|
|
92
|
+
sys.stderr.write(f"{failure_prefix}:\n{stderr_text}\n")
|
|
93
|
+
raise SystemExit(GATE_ERROR_EXIT_CODE)
|
|
94
|
+
return completed.stdout
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _null_separated_paths(raw_stdout: bytes, repository_root: Path, skip_label: str) -> list[Path]:
|
|
98
|
+
"""Turn a null-terminated git path stream into absolute paths.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
raw_stdout: The ``-z`` git stdout bytes.
|
|
102
|
+
repository_root: Repository root each relative path is joined to.
|
|
103
|
+
skip_label: Word naming the source, shown in the skip message.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
Absolute paths; names whose bytes are not Unicode are logged and skipped.
|
|
107
|
+
"""
|
|
108
|
+
resolved_paths: list[Path] = []
|
|
109
|
+
for each_raw_path in raw_stdout.split(b"\x00"):
|
|
110
|
+
if not each_raw_path:
|
|
111
|
+
continue
|
|
112
|
+
try:
|
|
113
|
+
relative_path = each_raw_path.decode("utf-8")
|
|
114
|
+
except UnicodeDecodeError:
|
|
115
|
+
sys.stderr.write(
|
|
116
|
+
f"code_rules_gate: skipping {skip_label} path with non-UTF-8 "
|
|
117
|
+
f"filename: {each_raw_path!r}\n"
|
|
118
|
+
)
|
|
119
|
+
continue
|
|
120
|
+
resolved_paths.append(repository_root / relative_path)
|
|
121
|
+
return resolved_paths
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def resolve_merge_base(repository_root: Path, base_reference: str) -> str:
|
|
125
|
+
"""Return the merge-base SHA between HEAD and *base_reference*.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
repository_root: Repository root used as the ``git -C`` target.
|
|
129
|
+
base_reference: The git reference to merge-base against.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
The stripped merge-base SHA.
|
|
133
|
+
|
|
134
|
+
Raises:
|
|
135
|
+
SystemExit: When ``git merge-base`` returns non-zero.
|
|
136
|
+
"""
|
|
137
|
+
stdout = _git_text_or_exit(
|
|
138
|
+
repository_root,
|
|
139
|
+
["git", "merge-base", "HEAD", base_reference],
|
|
140
|
+
f"code_rules_gate: git merge-base HEAD {base_reference} failed",
|
|
141
|
+
)
|
|
142
|
+
return stdout.strip()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def paths_from_git_staged(repository_root: Path) -> list[Path]:
|
|
146
|
+
"""Return absolute paths for every file in the staged index.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
repository_root: Repository root used as the ``git -C`` target.
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
Absolute paths for staged files, non-Unicode names skipped.
|
|
153
|
+
|
|
154
|
+
Raises:
|
|
155
|
+
SystemExit: When the staged name-only command returns non-zero.
|
|
156
|
+
"""
|
|
157
|
+
raw_stdout = _git_bytes_or_exit(
|
|
158
|
+
repository_root,
|
|
159
|
+
list(ALL_GIT_DIFF_CACHED_NAME_ONLY_NULL_TERMINATED_COMMAND),
|
|
160
|
+
"code_rules_gate: git diff --cached --name-only -z failed",
|
|
161
|
+
)
|
|
162
|
+
return _null_separated_paths(raw_stdout, repository_root, "staged")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def paths_from_git_diff(repository_root: Path, base_reference: str) -> list[Path]:
|
|
166
|
+
"""Return absolute paths for every file changed since *base_reference*.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
repository_root: Repository root used as the ``git -C`` target.
|
|
170
|
+
base_reference: The git reference to merge-base against.
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
Absolute paths changed since the merge-base of HEAD and *base_reference*.
|
|
174
|
+
|
|
175
|
+
Raises:
|
|
176
|
+
SystemExit: When the diff name-only command returns non-zero.
|
|
177
|
+
"""
|
|
178
|
+
merge_base = resolve_merge_base(repository_root, base_reference)
|
|
179
|
+
diff_command = [
|
|
180
|
+
*ALL_GIT_DIFF_NAME_ONLY_NULL_TERMINATED_COMMAND_PREFIX,
|
|
181
|
+
f"{merge_base}..HEAD",
|
|
182
|
+
]
|
|
183
|
+
raw_stdout = _git_bytes_or_exit(
|
|
184
|
+
repository_root,
|
|
185
|
+
diff_command,
|
|
186
|
+
"code_rules_gate: git diff --name-only -z failed",
|
|
187
|
+
)
|
|
188
|
+
return _null_separated_paths(raw_stdout, repository_root, "diff")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def paths_from_git_untracked(repository_root: Path) -> list[Path]:
|
|
192
|
+
"""Return absolute paths for every untracked, non-ignored file.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
repository_root: Repository root used as the ``git -C`` target.
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
Absolute paths git reports as untracked and not git-ignored.
|
|
199
|
+
|
|
200
|
+
Raises:
|
|
201
|
+
SystemExit: When ``git ls-files --others`` returns non-zero.
|
|
202
|
+
"""
|
|
203
|
+
raw_stdout = _git_bytes_or_exit(
|
|
204
|
+
repository_root,
|
|
205
|
+
list(ALL_GIT_LS_FILES_UNTRACKED_NULL_TERMINATED_COMMAND),
|
|
206
|
+
"code_rules_gate: git ls-files --others --exclude-standard -z failed",
|
|
207
|
+
)
|
|
208
|
+
return _null_separated_paths(raw_stdout, repository_root, "untracked")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _normalized_prefixes(all_prefixes: list[str]) -> list[str]:
|
|
212
|
+
"""Return the trimmed, slash-normalized, non-empty prefixes."""
|
|
213
|
+
return [
|
|
214
|
+
each_prefix.strip().replace("\\", "/").rstrip("/")
|
|
215
|
+
for each_prefix in all_prefixes
|
|
216
|
+
if each_prefix.strip()
|
|
217
|
+
]
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _relative_posix_under_root(file_path: Path, resolved_root: Path) -> str | None:
|
|
221
|
+
"""Return *file_path* relative to *resolved_root* as POSIX text, or None."""
|
|
222
|
+
try:
|
|
223
|
+
return file_path.resolve().relative_to(resolved_root).as_posix()
|
|
224
|
+
except ValueError:
|
|
225
|
+
return None
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _matches_any_prefix(relative_posix: str, all_normalized_prefixes: list[str]) -> bool:
|
|
229
|
+
"""Return True when *relative_posix* equals or nests under a prefix."""
|
|
230
|
+
return any(
|
|
231
|
+
relative_posix == each_prefix or relative_posix.startswith(each_prefix + "/")
|
|
232
|
+
for each_prefix in all_normalized_prefixes
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def filter_paths_under_prefixes(
|
|
237
|
+
all_file_paths: list[Path],
|
|
238
|
+
repository_root: Path,
|
|
239
|
+
all_prefixes: list[str],
|
|
240
|
+
) -> list[Path]:
|
|
241
|
+
"""Filter *all_file_paths* to entries under the supplied prefixes.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
all_file_paths: Resolved file paths to filter.
|
|
245
|
+
repository_root: Repository root the relative paths are measured against.
|
|
246
|
+
all_prefixes: Repo-relative POSIX prefixes; a path passes when it
|
|
247
|
+
equals a prefix or is nested beneath it.
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
The matching subset, or the input unchanged when no prefix is given.
|
|
251
|
+
"""
|
|
252
|
+
all_normalized_prefixes = _normalized_prefixes(all_prefixes)
|
|
253
|
+
if not all_normalized_prefixes:
|
|
254
|
+
return all_file_paths
|
|
255
|
+
resolved_root = repository_root.resolve()
|
|
256
|
+
filtered: list[Path] = []
|
|
257
|
+
for each_path in all_file_paths:
|
|
258
|
+
relative_posix = _relative_posix_under_root(each_path, resolved_root)
|
|
259
|
+
if relative_posix is None:
|
|
260
|
+
continue
|
|
261
|
+
if _matches_any_prefix(relative_posix, all_normalized_prefixes):
|
|
262
|
+
filtered.append(each_path)
|
|
263
|
+
return filtered
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def is_staged_file_newly_added(repository_root: Path, relative_path_posix: str) -> bool:
|
|
267
|
+
"""Check whether *relative_path_posix* is newly added in the staged diff.
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
repository_root: Repository root used as the ``git -C`` target.
|
|
271
|
+
relative_path_posix: Repository-relative POSIX path to inspect.
|
|
272
|
+
|
|
273
|
+
Returns:
|
|
274
|
+
True when the first non-empty name-status line begins with ``A``.
|
|
275
|
+
|
|
276
|
+
Raises:
|
|
277
|
+
SystemExit: When the staged name-status command returns non-zero.
|
|
278
|
+
"""
|
|
279
|
+
stdout = _git_text_or_exit(
|
|
280
|
+
repository_root,
|
|
281
|
+
["git", "diff", "--cached", "--name-status", "--", relative_path_posix],
|
|
282
|
+
(f"code_rules_gate: git diff --cached --name-status failed for {relative_path_posix}"),
|
|
283
|
+
)
|
|
284
|
+
for each_line in stdout.splitlines():
|
|
285
|
+
stripped_line = each_line.strip()
|
|
286
|
+
if stripped_line:
|
|
287
|
+
return stripped_line.startswith(GIT_NAME_STATUS_ADDED_PREFIX)
|
|
288
|
+
return False
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def staged_file_line_count(repository_root: Path, relative_path_posix: str) -> int:
|
|
292
|
+
"""Return the staged-blob line count for *relative_path_posix*.
|
|
293
|
+
|
|
294
|
+
Args:
|
|
295
|
+
repository_root: Repository root used as the ``git -C`` target.
|
|
296
|
+
relative_path_posix: Repository-relative POSIX path of the staged file.
|
|
297
|
+
|
|
298
|
+
Returns:
|
|
299
|
+
The staged content line count, or zero when the blob is empty.
|
|
300
|
+
|
|
301
|
+
Raises:
|
|
302
|
+
SystemExit: When ``git show :<path>`` returns non-zero.
|
|
303
|
+
"""
|
|
304
|
+
staged_content = _git_text_or_exit(
|
|
305
|
+
repository_root,
|
|
306
|
+
["git", "show", f":{relative_path_posix}"],
|
|
307
|
+
f"code_rules_gate: git show :{relative_path_posix} failed",
|
|
308
|
+
)
|
|
309
|
+
if not staged_content:
|
|
310
|
+
return 0
|
|
311
|
+
return len(staged_content.splitlines())
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def staged_unified_diff_text(repository_root: Path, relative_path_posix: str) -> str:
|
|
315
|
+
"""Return the staged unified-zero diff text for one file.
|
|
316
|
+
|
|
317
|
+
Args:
|
|
318
|
+
repository_root: Repository root used as the ``git -C`` target.
|
|
319
|
+
relative_path_posix: Repository-relative POSIX path to diff.
|
|
320
|
+
|
|
321
|
+
Returns:
|
|
322
|
+
The ``git diff --cached --unified=0`` stdout for the file.
|
|
323
|
+
|
|
324
|
+
Raises:
|
|
325
|
+
SystemExit: When the staged diff command returns non-zero.
|
|
326
|
+
"""
|
|
327
|
+
return _git_text_or_exit(
|
|
328
|
+
repository_root,
|
|
329
|
+
["git", "diff", "--cached", "--unified=0", "--", relative_path_posix],
|
|
330
|
+
(f"code_rules_gate: git diff --cached --unified=0 failed for {relative_path_posix}"),
|
|
331
|
+
)
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
"""Run the staged Python test files, grouped by their owning pytest config.
|
|
2
|
+
|
|
3
|
+
The commit gate runs each staged test file so a broken test blocks the commit.
|
|
4
|
+
Files are grouped by the nearest ancestor holding a pytest config, and each
|
|
5
|
+
group runs in its own pytest session with the working directory at that root, so
|
|
6
|
+
two packages exposing a same-named top-level package never shadow each other.
|
|
7
|
+
|
|
8
|
+
``conftest.py`` paths are never passed as pytest collection targets: pytest loads
|
|
9
|
+
them automatically when nearby tests run, and collecting multiple bare confests
|
|
10
|
+
in one session collides on the shared module basename.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from pr_loop_shared_constants.code_rules_gate_constants import (
|
|
19
|
+
ALL_POSIX_VENV_PYTHON_RELATIVE_PATH_SEGMENTS,
|
|
20
|
+
ALL_PYTEST_CONFIG_FILE_SECTIONS,
|
|
21
|
+
ALL_PYTEST_MODULE_INVOCATION,
|
|
22
|
+
ALL_VENV_DIRECTORY_NAMES,
|
|
23
|
+
ALL_WINDOWS_VENV_PYTHON_RELATIVE_PATH_SEGMENTS,
|
|
24
|
+
CODE_RULES_GATE_PYTHON_ENV_VAR,
|
|
25
|
+
CODE_RULES_GATE_PYTHONPATH_ENV_VAR,
|
|
26
|
+
COMMAND_LINE_ARGUMENT_SEPARATOR_LENGTH,
|
|
27
|
+
MAXIMUM_STAGED_PYTEST_COMMAND_LINE_CHARACTERS,
|
|
28
|
+
PYTHON_FILE_EXTENSION,
|
|
29
|
+
PYTHONPATH_ENV_VAR,
|
|
30
|
+
STAGED_PYTEST_TIMEOUT_SECONDS,
|
|
31
|
+
STAGED_TEST_FAILURE_HEADER,
|
|
32
|
+
STAGED_TEST_GROUP_FAILURE_MESSAGE,
|
|
33
|
+
TEST_CONFTEST_FILENAME,
|
|
34
|
+
)
|
|
35
|
+
from pr_loop_shared_constants.preflight_constants import (
|
|
36
|
+
PYTEST_NO_TESTS_COLLECTED_EXIT_CODE,
|
|
37
|
+
)
|
|
38
|
+
from terminology_sweep import repository_environment
|
|
39
|
+
|
|
40
|
+
from code_rules_gate_parts.git_file_sets import paths_from_git_staged
|
|
41
|
+
from code_rules_gate_parts.wrapper_plumb_check import is_test_path
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _is_conftest_path(file_path: Path) -> bool:
|
|
45
|
+
"""Return True when *file_path* is a pytest ``conftest.py`` fixture module."""
|
|
46
|
+
return file_path.name == TEST_CONFTEST_FILENAME
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _pytest_target_paths(all_test_paths: list[Path]) -> list[Path]:
|
|
50
|
+
"""Return staged test paths that pytest should collect as targets.
|
|
51
|
+
|
|
52
|
+
::
|
|
53
|
+
|
|
54
|
+
ok: [pkg/test_a.py, pkg/conftest.py] -> [pkg/test_a.py]
|
|
55
|
+
ok: [a/conftest.py, b/conftest.py] -> []
|
|
56
|
+
flag: bare confests collected as targets share basename ``conftest``
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
all_test_paths: Staged paths that match the test-file classifier.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
The same paths with every ``conftest.py`` removed.
|
|
63
|
+
"""
|
|
64
|
+
return [
|
|
65
|
+
each_path for each_path in all_test_paths if not _is_conftest_path(each_path)
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _staged_test_file_paths(repository_root: Path) -> list[Path]:
|
|
70
|
+
"""Return the staged Python test files that exist under a repository.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
repository_root: The repository root whose staged index is read.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
Staged paths whose extension is Python, whose name matches a test-file
|
|
77
|
+
pattern, and which exist on disk.
|
|
78
|
+
"""
|
|
79
|
+
all_test_paths: list[Path] = []
|
|
80
|
+
for each_path in paths_from_git_staged(repository_root):
|
|
81
|
+
if each_path.suffix != PYTHON_FILE_EXTENSION:
|
|
82
|
+
continue
|
|
83
|
+
if is_test_path(str(each_path)) and each_path.is_file():
|
|
84
|
+
all_test_paths.append(each_path)
|
|
85
|
+
return all_test_paths
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _directory_holds_pytest_config(directory: Path) -> bool:
|
|
89
|
+
"""Return True when *directory* holds a recognized pytest configuration file."""
|
|
90
|
+
for each_filename, each_required_section in ALL_PYTEST_CONFIG_FILE_SECTIONS:
|
|
91
|
+
config_path = directory / each_filename
|
|
92
|
+
if not config_path.is_file():
|
|
93
|
+
continue
|
|
94
|
+
if each_required_section is None:
|
|
95
|
+
return True
|
|
96
|
+
try:
|
|
97
|
+
config_text = config_path.read_text(encoding="utf-8", errors="replace")
|
|
98
|
+
except OSError:
|
|
99
|
+
continue
|
|
100
|
+
if each_required_section in config_text:
|
|
101
|
+
return True
|
|
102
|
+
return False
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _resolve_owning_test_root(test_file_path: Path, repository_root: Path) -> Path:
|
|
106
|
+
"""Return the nearest ancestor of *test_file_path* owning a pytest config.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
test_file_path: The staged test file to resolve a root for.
|
|
110
|
+
repository_root: The repository root that bounds the upward walk.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
The resolved owning test root directory.
|
|
114
|
+
"""
|
|
115
|
+
resolved_repository_root = repository_root.resolve()
|
|
116
|
+
for each_ancestor in test_file_path.resolve().parents:
|
|
117
|
+
if _directory_holds_pytest_config(each_ancestor):
|
|
118
|
+
return each_ancestor
|
|
119
|
+
if each_ancestor == resolved_repository_root:
|
|
120
|
+
return resolved_repository_root
|
|
121
|
+
return resolved_repository_root
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _group_staged_tests_by_root(
|
|
125
|
+
all_test_paths: list[Path], repository_root: Path
|
|
126
|
+
) -> dict[Path, list[Path]]:
|
|
127
|
+
"""Group *all_test_paths* by their owning pytest-config root.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
all_test_paths: The staged test files to partition.
|
|
131
|
+
repository_root: The repository root that bounds each upward walk.
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
A mapping from owning test root to the staged test files under it.
|
|
135
|
+
"""
|
|
136
|
+
all_tests_by_root: dict[Path, list[Path]] = {}
|
|
137
|
+
for each_test_path in all_test_paths:
|
|
138
|
+
owning_root = _resolve_owning_test_root(each_test_path, repository_root)
|
|
139
|
+
all_tests_by_root.setdefault(owning_root, []).append(each_test_path)
|
|
140
|
+
return all_tests_by_root
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _venv_python_relative_path() -> tuple[str, ...]:
|
|
144
|
+
"""Return the venv-relative python executable path segments for this platform."""
|
|
145
|
+
if os.name == "nt":
|
|
146
|
+
return ALL_WINDOWS_VENV_PYTHON_RELATIVE_PATH_SEGMENTS
|
|
147
|
+
return ALL_POSIX_VENV_PYTHON_RELATIVE_PATH_SEGMENTS
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _venv_python_executable(repository_root: Path) -> Path | None:
|
|
151
|
+
"""Return the first existing venv interpreter under *repository_root*.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
repository_root: The repository root to search for a venv directory.
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
The resolved interpreter path, or None when no candidate exists.
|
|
158
|
+
"""
|
|
159
|
+
relative_python_path = _venv_python_relative_path()
|
|
160
|
+
for each_venv_directory_name in ALL_VENV_DIRECTORY_NAMES:
|
|
161
|
+
candidate_path = repository_root.joinpath(each_venv_directory_name, *relative_python_path)
|
|
162
|
+
if candidate_path.is_file():
|
|
163
|
+
return candidate_path
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _resolve_gate_python_executable(repository_root: Path) -> str:
|
|
168
|
+
"""Return the interpreter the staged-test subprocess runs under.
|
|
169
|
+
|
|
170
|
+
Resolves the ``CODE_RULES_GATE_PYTHON`` variable, then a project venv
|
|
171
|
+
interpreter, then the interpreter running the gate.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
repository_root: The repository root used to locate a project venv.
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
The absolute path or command name of the interpreter to invoke.
|
|
178
|
+
"""
|
|
179
|
+
configured_python = os.environ.get(CODE_RULES_GATE_PYTHON_ENV_VAR)
|
|
180
|
+
if configured_python:
|
|
181
|
+
return configured_python
|
|
182
|
+
venv_python = _venv_python_executable(repository_root)
|
|
183
|
+
if venv_python is not None:
|
|
184
|
+
return str(venv_python)
|
|
185
|
+
return sys.executable
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _staged_pytest_environment() -> dict[str, str]:
|
|
189
|
+
"""Return the subprocess environment for the staged-test pytest run.
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
The ``repository_environment`` mapping, with ``CODE_RULES_GATE_PYTHONPATH``
|
|
193
|
+
prepended to ``PYTHONPATH`` when it is set.
|
|
194
|
+
"""
|
|
195
|
+
environment = repository_environment()
|
|
196
|
+
configured_pythonpath = os.environ.get(CODE_RULES_GATE_PYTHONPATH_ENV_VAR)
|
|
197
|
+
if not configured_pythonpath:
|
|
198
|
+
return environment
|
|
199
|
+
existing_pythonpath = environment.get(PYTHONPATH_ENV_VAR, "")
|
|
200
|
+
environment[PYTHONPATH_ENV_VAR] = (
|
|
201
|
+
configured_pythonpath
|
|
202
|
+
if not existing_pythonpath
|
|
203
|
+
else os.pathsep.join([configured_pythonpath, existing_pythonpath])
|
|
204
|
+
)
|
|
205
|
+
return environment
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _relative_pytest_argument(test_path: Path, group_root: Path) -> str:
|
|
209
|
+
"""Express *test_path* as a pytest argument relative to *group_root*.
|
|
210
|
+
|
|
211
|
+
::
|
|
212
|
+
|
|
213
|
+
group_root = /repo/package
|
|
214
|
+
ok: /repo/package/tests/test_a.py -> "tests/test_a.py"
|
|
215
|
+
flag: /elsewhere/test_b.py -> passed absolute
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
test_path: The staged test file to express as a pytest argument.
|
|
219
|
+
group_root: The pytest working directory for the group.
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
The path relative to *group_root* when nested under it, else absolute.
|
|
223
|
+
"""
|
|
224
|
+
try:
|
|
225
|
+
return str(test_path.relative_to(group_root))
|
|
226
|
+
except ValueError:
|
|
227
|
+
return str(test_path)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _batched_pytest_arguments(
|
|
231
|
+
all_pytest_arguments: list[str], character_budget: int
|
|
232
|
+
) -> list[list[str]]:
|
|
233
|
+
"""Split pytest path arguments into command-line-length-safe batches.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
all_pytest_arguments: The path arguments to distribute, in order.
|
|
237
|
+
character_budget: The joined length each batch stays within; an argument
|
|
238
|
+
wider than the budget lands in a batch by itself.
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
One argument list per pytest invocation, preserving input order.
|
|
242
|
+
"""
|
|
243
|
+
all_batches: list[list[str]] = []
|
|
244
|
+
current_batch: list[str] = []
|
|
245
|
+
current_length = 0
|
|
246
|
+
for each_argument in all_pytest_arguments:
|
|
247
|
+
argument_length = len(each_argument) + COMMAND_LINE_ARGUMENT_SEPARATOR_LENGTH
|
|
248
|
+
if current_batch and current_length + argument_length > character_budget:
|
|
249
|
+
all_batches.append(current_batch)
|
|
250
|
+
current_batch = []
|
|
251
|
+
current_length = 0
|
|
252
|
+
current_batch.append(each_argument)
|
|
253
|
+
current_length += argument_length
|
|
254
|
+
if current_batch:
|
|
255
|
+
all_batches.append(current_batch)
|
|
256
|
+
return all_batches
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _pytest_batch_exit_code(all_batch_command: list[str], group_root: Path) -> int:
|
|
260
|
+
"""Run one pytest invocation and normalize its exit code.
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
all_batch_command: The full argv for this pytest invocation.
|
|
264
|
+
group_root: The owning test root used as the working directory.
|
|
265
|
+
|
|
266
|
+
Returns:
|
|
267
|
+
0 when the batch passes or collects no tests; pytest's exit code
|
|
268
|
+
otherwise.
|
|
269
|
+
"""
|
|
270
|
+
pytest_process = subprocess.run(
|
|
271
|
+
all_batch_command,
|
|
272
|
+
cwd=str(group_root),
|
|
273
|
+
timeout=STAGED_PYTEST_TIMEOUT_SECONDS,
|
|
274
|
+
check=False,
|
|
275
|
+
env=_staged_pytest_environment(),
|
|
276
|
+
)
|
|
277
|
+
if pytest_process.returncode == PYTEST_NO_TESTS_COLLECTED_EXIT_CODE:
|
|
278
|
+
return 0
|
|
279
|
+
return pytest_process.returncode
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _pytest_fixed_command(repository_root: Path) -> list[str]:
|
|
283
|
+
"""Return the interpreter-and-pytest prefix shared by every batch."""
|
|
284
|
+
return [
|
|
285
|
+
_resolve_gate_python_executable(repository_root),
|
|
286
|
+
*ALL_PYTEST_MODULE_INVOCATION,
|
|
287
|
+
]
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _first_failing_batch_exit_code(
|
|
291
|
+
all_fixed_command: list[str], all_batches: list[list[str]], group_root: Path
|
|
292
|
+
) -> int:
|
|
293
|
+
"""Run each batch and return the first non-zero pytest exit code, or zero."""
|
|
294
|
+
first_failing_exit_code = 0
|
|
295
|
+
for each_batch in all_batches:
|
|
296
|
+
batch_exit_code = _pytest_batch_exit_code([*all_fixed_command, *each_batch], group_root)
|
|
297
|
+
if batch_exit_code != 0 and first_failing_exit_code == 0:
|
|
298
|
+
first_failing_exit_code = batch_exit_code
|
|
299
|
+
return first_failing_exit_code
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _run_pytest_for_group(
|
|
303
|
+
group_root: Path, all_group_test_paths: list[Path], repository_root: Path
|
|
304
|
+
) -> int:
|
|
305
|
+
"""Run pytest over one group's test files, split into budget-safe batches.
|
|
306
|
+
|
|
307
|
+
Args:
|
|
308
|
+
group_root: The owning test root used as the pytest working directory.
|
|
309
|
+
all_group_test_paths: The staged test files that share *group_root*.
|
|
310
|
+
repository_root: The repository root used to resolve a project venv.
|
|
311
|
+
|
|
312
|
+
Returns:
|
|
313
|
+
0 when every batch passes or collects nothing; the first failing batch's
|
|
314
|
+
exit code otherwise.
|
|
315
|
+
"""
|
|
316
|
+
all_fixed_command = _pytest_fixed_command(repository_root)
|
|
317
|
+
fixed_command_length = sum(
|
|
318
|
+
len(each_part) + COMMAND_LINE_ARGUMENT_SEPARATOR_LENGTH for each_part in all_fixed_command
|
|
319
|
+
)
|
|
320
|
+
all_batches = _batched_pytest_arguments(
|
|
321
|
+
[_relative_pytest_argument(each_path, group_root) for each_path in all_group_test_paths],
|
|
322
|
+
MAXIMUM_STAGED_PYTEST_COMMAND_LINE_CHARACTERS - fixed_command_length,
|
|
323
|
+
)
|
|
324
|
+
return _first_failing_batch_exit_code(all_fixed_command, all_batches, group_root)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _run_grouped_staged_tests(
|
|
328
|
+
all_tests_by_root: dict[Path, list[Path]], repository_root: Path
|
|
329
|
+
) -> int:
|
|
330
|
+
"""Run each staged test group and return the first failing exit code, or zero."""
|
|
331
|
+
first_failing_exit_code = 0
|
|
332
|
+
for each_group_root in sorted(all_tests_by_root):
|
|
333
|
+
group_exit_code = _run_pytest_for_group(
|
|
334
|
+
each_group_root, all_tests_by_root[each_group_root], repository_root
|
|
335
|
+
)
|
|
336
|
+
if group_exit_code == 0:
|
|
337
|
+
continue
|
|
338
|
+
sys.stderr.write(
|
|
339
|
+
STAGED_TEST_GROUP_FAILURE_MESSAGE.format(group_root=each_group_root) + "\n"
|
|
340
|
+
)
|
|
341
|
+
if first_failing_exit_code == 0:
|
|
342
|
+
first_failing_exit_code = group_exit_code
|
|
343
|
+
return first_failing_exit_code
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def run_staged_test_files(repository_root: Path) -> int:
|
|
347
|
+
"""Run pytest over the staged test files and return the gate exit code.
|
|
348
|
+
|
|
349
|
+
``conftest.py`` files are excluded from collection targets. Pytest still
|
|
350
|
+
loads them automatically when a nearby staged test runs under the same
|
|
351
|
+
owning root.
|
|
352
|
+
|
|
353
|
+
Args:
|
|
354
|
+
repository_root: The repository root the staged test files belong to.
|
|
355
|
+
|
|
356
|
+
Returns:
|
|
357
|
+
0 when no collectable test file is staged, when every group collects no
|
|
358
|
+
tests, or when every group passes. The first failing group's exit code
|
|
359
|
+
otherwise.
|
|
360
|
+
"""
|
|
361
|
+
all_test_paths = _staged_test_file_paths(repository_root)
|
|
362
|
+
all_pytest_targets = _pytest_target_paths(all_test_paths)
|
|
363
|
+
if not all_pytest_targets:
|
|
364
|
+
return 0
|
|
365
|
+
all_tests_by_root = _group_staged_tests_by_root(all_pytest_targets, repository_root)
|
|
366
|
+
first_failing_exit_code = _run_grouped_staged_tests(all_tests_by_root, repository_root)
|
|
367
|
+
if first_failing_exit_code != 0:
|
|
368
|
+
sys.stderr.write(STAGED_TEST_FAILURE_HEADER + "\n")
|
|
369
|
+
return first_failing_exit_code
|