claude-dev-env 1.95.0 → 2.0.1
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/test_verifier_verdict_minter.py +55 -158
- 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 +42 -29
- package/skills/orchestrator-refresh/SKILL.md +17 -9
- 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
|
@@ -1,1500 +1,109 @@
|
|
|
1
|
-
|
|
2
|
-
import ast
|
|
3
|
-
import importlib.util
|
|
4
|
-
import os
|
|
5
|
-
import re
|
|
6
|
-
import subprocess
|
|
7
|
-
import sys
|
|
8
|
-
from collections.abc import Callable, Iterator
|
|
9
|
-
from pathlib import Path
|
|
10
|
-
|
|
11
|
-
parent_directory = str(Path(__file__).resolve().parent)
|
|
12
|
-
if parent_directory not in sys.path:
|
|
13
|
-
sys.path.insert(0, parent_directory)
|
|
14
|
-
|
|
15
|
-
from pr_loop_shared_constants.code_rules_gate_constants import ( # noqa: E402
|
|
16
|
-
ALL_CODE_FILE_EXTENSIONS,
|
|
17
|
-
ALL_GIT_DIFF_CACHED_NAME_ONLY_NULL_TERMINATED_COMMAND,
|
|
18
|
-
ALL_GIT_DIFF_NAME_ONLY_NULL_TERMINATED_COMMAND_PREFIX,
|
|
19
|
-
ALL_POSIX_VENV_PYTHON_RELATIVE_PATH_SEGMENTS,
|
|
20
|
-
ALL_PYTEST_CONFIG_FILE_SECTIONS,
|
|
21
|
-
ALL_PYTEST_MODULE_INVOCATION,
|
|
22
|
-
ALL_TEST_FILENAME_GLOB_SUFFIXES,
|
|
23
|
-
ALL_TEST_FILENAME_SUFFIXES,
|
|
24
|
-
ALL_VENV_DIRECTORY_NAMES,
|
|
25
|
-
ALL_WINDOWS_VENV_PYTHON_RELATIVE_PATH_SEGMENTS,
|
|
26
|
-
BANNED_NOUN_DEFINITION_LINE_GROUP_INDEX,
|
|
27
|
-
BANNED_NOUN_SPAN_GROUP_INDEX,
|
|
28
|
-
BANNED_NOUN_VIOLATION_PATTERN,
|
|
29
|
-
CODE_RULES_GATE_PYTHON_ENV_VAR,
|
|
30
|
-
CODE_RULES_GATE_PYTHONPATH_ENV_VAR,
|
|
31
|
-
COMMAND_LINE_ARGUMENT_SEPARATOR_LENGTH,
|
|
32
|
-
DUPLICATE_BODY_DEFINITION_LINE_GROUP_INDEX,
|
|
33
|
-
DUPLICATE_BODY_SPAN_GROUP_INDEX,
|
|
34
|
-
DUPLICATE_BODY_VIOLATION_PATTERN,
|
|
35
|
-
EXPECTED_NON_RENAME_COLUMN_COUNT,
|
|
36
|
-
EXPECTED_RENAME_COLUMN_COUNT,
|
|
37
|
-
FUNCTION_LENGTH_DEFINITION_LINE_GROUP_INDEX,
|
|
38
|
-
FUNCTION_LENGTH_SPAN_GROUP_INDEX,
|
|
39
|
-
FUNCTION_LENGTH_VIOLATION_PATTERN,
|
|
40
|
-
GIT_NAME_STATUS_ADDED_PREFIX,
|
|
41
|
-
GIT_NAME_STATUS_RENAMED_PREFIX,
|
|
42
|
-
ISOLATION_DEFINITION_LINE_GROUP_INDEX,
|
|
43
|
-
ISOLATION_SPAN_GROUP_INDEX,
|
|
44
|
-
ISOLATION_VIOLATION_PATTERN,
|
|
45
|
-
MAXIMUM_STAGED_PYTEST_COMMAND_LINE_CHARACTERS,
|
|
46
|
-
MAX_VIOLATIONS_PER_CHECK,
|
|
47
|
-
MINIMUM_STAGED_PYTEST_PYTHON_MAJOR,
|
|
48
|
-
MINIMUM_STAGED_PYTEST_PYTHON_MINOR,
|
|
49
|
-
PYTHON_FILE_EXTENSION,
|
|
50
|
-
PYTHONPATH_ENV_VAR,
|
|
51
|
-
STAGED_PYTEST_TIMEOUT_SECONDS,
|
|
52
|
-
STAGED_TEST_FAILURE_HEADER,
|
|
53
|
-
STAGED_TEST_GROUP_FAILURE_MESSAGE,
|
|
54
|
-
TEST_CONFTEST_FILENAME,
|
|
55
|
-
TEST_FILENAME_PREFIX,
|
|
56
|
-
TESTS_PATH_SEGMENT,
|
|
57
|
-
)
|
|
58
|
-
from pr_loop_shared_constants.preflight_constants import ( # noqa: E402
|
|
59
|
-
PYTEST_NO_TESTS_COLLECTED_EXIT_CODE,
|
|
60
|
-
)
|
|
61
|
-
from pr_loop_shared_constants.inline_duplicate_body_span_constants import ( # noqa: E402
|
|
62
|
-
INLINE_DUPLICATE_BODY_ENCLOSING_LINE_GROUP_INDEX,
|
|
63
|
-
INLINE_DUPLICATE_BODY_ENCLOSING_SPAN_GROUP_INDEX,
|
|
64
|
-
INLINE_DUPLICATE_BODY_HELPER_LINE_GROUP_INDEX,
|
|
65
|
-
INLINE_DUPLICATE_BODY_HELPER_SPAN_GROUP_INDEX,
|
|
66
|
-
INLINE_DUPLICATE_BODY_VIOLATION_PATTERN,
|
|
67
|
-
)
|
|
68
|
-
from pr_loop_shared_constants.terminology_sweep_constants import ( # noqa: E402
|
|
69
|
-
TERMINOLOGY_SWEEP_GATE_HEADER,
|
|
70
|
-
)
|
|
71
|
-
from terminology_sweep import ( # noqa: E402
|
|
72
|
-
repository_environment,
|
|
73
|
-
staged_terminology_findings,
|
|
74
|
-
)
|
|
75
|
-
|
|
76
|
-
ValidateContentCallable = Callable[..., list[str]]
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
def hunk_header_pattern() -> re.Pattern[str]:
|
|
80
|
-
return re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@")
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
def violation_line_pattern() -> re.Pattern[str]:
|
|
84
|
-
return re.compile(r"^Line (\d+):")
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
def resolve_claude_dev_env_root(starting_path: Path) -> Path:
|
|
88
|
-
"""Walk up from *starting_path* to the claude-dev-env package root.
|
|
89
|
-
|
|
90
|
-
Args:
|
|
91
|
-
starting_path: A path inside the worktree; the function climbs to
|
|
92
|
-
find the ancestor containing ``hooks/blocking/code_rules_enforcer.py``.
|
|
93
|
-
|
|
94
|
-
Returns:
|
|
95
|
-
The resolved package root that contains the enforcer file.
|
|
96
|
-
|
|
97
|
-
Raises:
|
|
98
|
-
SystemExit: When no ancestor contains the enforcer.
|
|
99
|
-
"""
|
|
100
|
-
starting = Path(starting_path).resolve()
|
|
101
|
-
enforcer_relative = Path("hooks") / "blocking" / "code_rules_enforcer.py"
|
|
102
|
-
for each_candidate in [starting, *starting.parents]:
|
|
103
|
-
if (each_candidate / enforcer_relative).is_file():
|
|
104
|
-
return each_candidate
|
|
105
|
-
print(
|
|
106
|
-
f"code_rules_gate: could not locate {enforcer_relative} above {starting}",
|
|
107
|
-
file=sys.stderr,
|
|
108
|
-
)
|
|
109
|
-
raise SystemExit(2)
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
def _resolve_package_root_absolute(starting_path: Path) -> Path:
|
|
113
|
-
enforcer_relative = Path("hooks") / "blocking" / "code_rules_enforcer.py"
|
|
114
|
-
for each_starting_form in (
|
|
115
|
-
Path(starting_path).absolute(),
|
|
116
|
-
Path(starting_path).resolve(),
|
|
117
|
-
):
|
|
118
|
-
for each_candidate in [each_starting_form, *each_starting_form.parents]:
|
|
119
|
-
if (each_candidate / enforcer_relative).is_file():
|
|
120
|
-
return each_candidate
|
|
121
|
-
raise SystemExit(2)
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
def load_validate_content() -> ValidateContentCallable:
|
|
125
|
-
"""Load ``code_rules_enforcer.validate_content`` for in-process use.
|
|
126
|
-
|
|
127
|
-
Returns:
|
|
128
|
-
The ``validate_content`` callable from the enforcer module.
|
|
129
|
-
|
|
130
|
-
Raises:
|
|
131
|
-
SystemExit: When the package root cannot be located or the
|
|
132
|
-
enforcer module cannot be loaded from disk.
|
|
133
|
-
"""
|
|
134
|
-
package_root = resolve_claude_dev_env_root(Path(__file__).resolve())
|
|
135
|
-
enforcer_path = package_root / "hooks" / "blocking" / "code_rules_enforcer.py"
|
|
136
|
-
if not enforcer_path.is_file():
|
|
137
|
-
message = f"code_rules_gate: missing enforcer at {enforcer_path}"
|
|
138
|
-
print(message, file=sys.stderr)
|
|
139
|
-
raise SystemExit(2)
|
|
140
|
-
specification = importlib.util.spec_from_file_location(
|
|
141
|
-
"code_rules_enforcer",
|
|
142
|
-
enforcer_path,
|
|
143
|
-
)
|
|
144
|
-
if specification is None or specification.loader is None:
|
|
145
|
-
print("code_rules_gate: could not load code_rules_enforcer.", file=sys.stderr)
|
|
146
|
-
raise SystemExit(2)
|
|
147
|
-
module = importlib.util.module_from_spec(specification)
|
|
148
|
-
package_root_for_imports = _resolve_package_root_absolute(Path(__file__).absolute())
|
|
149
|
-
hooks_root_path = str(package_root_for_imports / "hooks")
|
|
150
|
-
while hooks_root_path in sys.path:
|
|
151
|
-
sys.path.remove(hooks_root_path)
|
|
152
|
-
sys.path.insert(0, hooks_root_path)
|
|
153
|
-
saved_hooks_constants_modules = {
|
|
154
|
-
each_module_name: sys.modules.pop(each_module_name)
|
|
155
|
-
for each_module_name in [
|
|
156
|
-
each_key for each_key in list(sys.modules)
|
|
157
|
-
if each_key == "hooks_constants" or each_key.startswith("hooks_constants.")
|
|
158
|
-
]
|
|
159
|
-
}
|
|
160
|
-
try:
|
|
161
|
-
specification.loader.exec_module(module)
|
|
162
|
-
finally:
|
|
163
|
-
while hooks_root_path in sys.path:
|
|
164
|
-
sys.path.remove(hooks_root_path)
|
|
165
|
-
for each_module_name in [
|
|
166
|
-
each_key for each_key in list(sys.modules)
|
|
167
|
-
if each_key == "hooks_constants" or each_key.startswith("hooks_constants.")
|
|
168
|
-
]:
|
|
169
|
-
sys.modules.pop(each_module_name, None)
|
|
170
|
-
sys.modules.update(saved_hooks_constants_modules)
|
|
171
|
-
return module.validate_content
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
def resolve_merge_base(repository_root: Path, base_reference: str) -> str:
|
|
175
|
-
"""Return the merge-base SHA between HEAD and *base_reference*.
|
|
176
|
-
|
|
177
|
-
Args:
|
|
178
|
-
repository_root: Repository root used as the ``git -C`` target.
|
|
179
|
-
base_reference: The git reference to merge-base against.
|
|
180
|
-
|
|
181
|
-
Returns:
|
|
182
|
-
The stripped merge-base SHA.
|
|
183
|
-
|
|
184
|
-
Raises:
|
|
185
|
-
SystemExit: When ``git merge-base`` returns non-zero.
|
|
186
|
-
"""
|
|
187
|
-
merge_result = subprocess.run(
|
|
188
|
-
["git", "merge-base", "HEAD", base_reference],
|
|
189
|
-
cwd=str(repository_root),
|
|
190
|
-
capture_output=True,
|
|
191
|
-
text=True,
|
|
192
|
-
encoding="utf-8",
|
|
193
|
-
errors="replace",
|
|
194
|
-
check=False,
|
|
195
|
-
env=repository_environment(),
|
|
196
|
-
)
|
|
197
|
-
if merge_result.returncode != 0:
|
|
198
|
-
print(
|
|
199
|
-
f"code_rules_gate: git merge-base HEAD {base_reference} failed:\n"
|
|
200
|
-
f"{merge_result.stderr}",
|
|
201
|
-
file=sys.stderr,
|
|
202
|
-
)
|
|
203
|
-
raise SystemExit(2)
|
|
204
|
-
return merge_result.stdout.strip()
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
def filter_paths_under_prefixes(
|
|
208
|
-
all_file_paths: list[Path],
|
|
209
|
-
repository_root: Path,
|
|
210
|
-
all_prefixes: list[str],
|
|
211
|
-
) -> list[Path]:
|
|
212
|
-
"""Filter *all_file_paths* to entries falling under the supplied prefixes.
|
|
213
|
-
|
|
214
|
-
Args:
|
|
215
|
-
all_file_paths: Resolved file paths to filter.
|
|
216
|
-
repository_root: Repository root used to compute relative paths.
|
|
217
|
-
all_prefixes: Repository-relative POSIX prefixes; each path must
|
|
218
|
-
equal one prefix or be nested beneath it to pass through.
|
|
219
|
-
|
|
220
|
-
Returns:
|
|
221
|
-
The subset of *all_file_paths* whose relative POSIX path matches one
|
|
222
|
-
of the prefixes. When *all_prefixes* is empty, returns the input
|
|
223
|
-
list unchanged.
|
|
224
|
-
"""
|
|
225
|
-
if not all_prefixes:
|
|
226
|
-
return all_file_paths
|
|
227
|
-
normalized_prefixes = [
|
|
228
|
-
each_prefix.strip().replace("\\", "/").rstrip("/")
|
|
229
|
-
for each_prefix in all_prefixes
|
|
230
|
-
if each_prefix.strip()
|
|
231
|
-
]
|
|
232
|
-
if not normalized_prefixes:
|
|
233
|
-
return all_file_paths
|
|
234
|
-
resolved_root = repository_root.resolve()
|
|
235
|
-
filtered: list[Path] = []
|
|
236
|
-
for each_path in all_file_paths:
|
|
237
|
-
try:
|
|
238
|
-
relative_posix = each_path.resolve().relative_to(resolved_root).as_posix()
|
|
239
|
-
except ValueError:
|
|
240
|
-
continue
|
|
241
|
-
if any(
|
|
242
|
-
relative_posix == each_prefix
|
|
243
|
-
or relative_posix.startswith(each_prefix + "/")
|
|
244
|
-
for each_prefix in normalized_prefixes
|
|
245
|
-
):
|
|
246
|
-
filtered.append(each_path)
|
|
247
|
-
return filtered
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
def paths_from_git_staged(repository_root: Path) -> list[Path]:
|
|
251
|
-
"""Return absolute paths for every file in the staged index.
|
|
252
|
-
|
|
253
|
-
Args:
|
|
254
|
-
repository_root: Repository root used as the ``git -C`` target.
|
|
255
|
-
|
|
256
|
-
Returns:
|
|
257
|
-
List of absolute paths for staged files. Names whose bytes cannot
|
|
258
|
-
be decoded as Unicode are logged and skipped.
|
|
259
|
-
|
|
260
|
-
Raises:
|
|
261
|
-
SystemExit: When ``git diff --cached --name-only -z`` returns
|
|
262
|
-
non-zero.
|
|
263
|
-
"""
|
|
264
|
-
name_result = subprocess.run(
|
|
265
|
-
list(ALL_GIT_DIFF_CACHED_NAME_ONLY_NULL_TERMINATED_COMMAND),
|
|
266
|
-
cwd=str(repository_root),
|
|
267
|
-
capture_output=True,
|
|
268
|
-
check=False,
|
|
269
|
-
env=repository_environment(),
|
|
270
|
-
)
|
|
271
|
-
if name_result.returncode != 0:
|
|
272
|
-
stderr_text = name_result.stderr.decode("utf-8", errors="replace")
|
|
273
|
-
print(
|
|
274
|
-
f"code_rules_gate: git diff --cached --name-only -z failed:\n{stderr_text}",
|
|
275
|
-
file=sys.stderr,
|
|
276
|
-
)
|
|
277
|
-
raise SystemExit(2)
|
|
278
|
-
raw_paths = name_result.stdout.split(b"\x00")
|
|
279
|
-
resolved_paths = []
|
|
280
|
-
for each_raw_path in raw_paths:
|
|
281
|
-
if not each_raw_path:
|
|
282
|
-
continue
|
|
283
|
-
try:
|
|
284
|
-
relative_path = each_raw_path.decode("utf-8")
|
|
285
|
-
except UnicodeDecodeError:
|
|
286
|
-
print(
|
|
287
|
-
f"code_rules_gate: skipping staged path with non-UTF-8 filename: {each_raw_path!r}",
|
|
288
|
-
file=sys.stderr,
|
|
289
|
-
)
|
|
290
|
-
continue
|
|
291
|
-
resolved_paths.append(repository_root / relative_path)
|
|
292
|
-
return resolved_paths
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
def staged_file_line_count(
|
|
296
|
-
repository_root: Path,
|
|
297
|
-
relative_path_posix: str,
|
|
298
|
-
) -> int:
|
|
299
|
-
"""Return the staged-blob line count for *relative_path_posix*.
|
|
300
|
-
|
|
301
|
-
Args:
|
|
302
|
-
repository_root: Repository root used as the ``git -C`` target.
|
|
303
|
-
relative_path_posix: Repository-relative POSIX path of the staged
|
|
304
|
-
file.
|
|
305
|
-
|
|
306
|
-
Returns:
|
|
307
|
-
The staged content line count, or zero when the blob is empty.
|
|
308
|
-
|
|
309
|
-
Raises:
|
|
310
|
-
SystemExit: When ``git show :<path>`` returns non-zero.
|
|
311
|
-
"""
|
|
312
|
-
show_result = subprocess.run(
|
|
313
|
-
["git", "show", f":{relative_path_posix}"],
|
|
314
|
-
cwd=str(repository_root),
|
|
315
|
-
capture_output=True,
|
|
316
|
-
text=True,
|
|
317
|
-
encoding="utf-8",
|
|
318
|
-
errors="replace",
|
|
319
|
-
check=False,
|
|
320
|
-
env=repository_environment(),
|
|
321
|
-
)
|
|
322
|
-
if show_result.returncode != 0:
|
|
323
|
-
print(
|
|
324
|
-
f"code_rules_gate: git show :{relative_path_posix} failed:\n"
|
|
325
|
-
f"{show_result.stderr}",
|
|
326
|
-
file=sys.stderr,
|
|
327
|
-
)
|
|
328
|
-
raise SystemExit(2)
|
|
329
|
-
staged_content = show_result.stdout
|
|
330
|
-
if not staged_content:
|
|
331
|
-
return 0
|
|
332
|
-
return len(staged_content.splitlines())
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
def is_staged_file_newly_added(
|
|
336
|
-
repository_root: Path,
|
|
337
|
-
relative_path_posix: str,
|
|
338
|
-
) -> bool:
|
|
339
|
-
"""Check whether *relative_path_posix* is newly added in the staged diff.
|
|
340
|
-
|
|
341
|
-
Args:
|
|
342
|
-
repository_root: Repository root used as the ``git -C`` target.
|
|
343
|
-
relative_path_posix: Repository-relative POSIX path to inspect.
|
|
344
|
-
|
|
345
|
-
Returns:
|
|
346
|
-
True when the first non-empty name-status line begins with the git
|
|
347
|
-
added-prefix; False otherwise.
|
|
348
|
-
|
|
349
|
-
Raises:
|
|
350
|
-
SystemExit: When ``git diff --cached --name-status`` returns
|
|
351
|
-
non-zero.
|
|
352
|
-
"""
|
|
353
|
-
status_result = subprocess.run(
|
|
354
|
-
["git", "diff", "--cached", "--name-status", "--", relative_path_posix],
|
|
355
|
-
cwd=str(repository_root),
|
|
356
|
-
capture_output=True,
|
|
357
|
-
text=True,
|
|
358
|
-
encoding="utf-8",
|
|
359
|
-
errors="replace",
|
|
360
|
-
check=False,
|
|
361
|
-
env=repository_environment(),
|
|
362
|
-
)
|
|
363
|
-
if status_result.returncode != 0:
|
|
364
|
-
print(
|
|
365
|
-
f"code_rules_gate: git diff --cached --name-status failed for "
|
|
366
|
-
f"{relative_path_posix}:\n{status_result.stderr}",
|
|
367
|
-
file=sys.stderr,
|
|
368
|
-
)
|
|
369
|
-
raise SystemExit(2)
|
|
370
|
-
for each_line in status_result.stdout.splitlines():
|
|
371
|
-
stripped_line = each_line.strip()
|
|
372
|
-
if stripped_line:
|
|
373
|
-
return stripped_line.startswith(GIT_NAME_STATUS_ADDED_PREFIX)
|
|
374
|
-
return False
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
def added_lines_for_staged_file(
|
|
378
|
-
repository_root: Path,
|
|
379
|
-
relative_path_posix: str,
|
|
380
|
-
) -> set[int]:
|
|
381
|
-
"""Return added line numbers within the staged diff for one file.
|
|
382
|
-
|
|
383
|
-
Args:
|
|
384
|
-
repository_root: Repository root used as the ``git -C`` target.
|
|
385
|
-
relative_path_posix: Repository-relative POSIX path to inspect.
|
|
386
|
-
|
|
387
|
-
Returns:
|
|
388
|
-
Set of line numbers (1-indexed) added in the staged diff. When the
|
|
389
|
-
file is newly added, returns every line in the staged blob.
|
|
390
|
-
|
|
391
|
-
Raises:
|
|
392
|
-
SystemExit: When the staged diff command returns non-zero.
|
|
393
|
-
"""
|
|
394
|
-
diff_result = subprocess.run(
|
|
395
|
-
["git", "diff", "--cached", "--unified=0", "--", relative_path_posix],
|
|
396
|
-
cwd=str(repository_root),
|
|
397
|
-
capture_output=True,
|
|
398
|
-
text=True,
|
|
399
|
-
encoding="utf-8",
|
|
400
|
-
errors="replace",
|
|
401
|
-
check=False,
|
|
402
|
-
env=repository_environment(),
|
|
403
|
-
)
|
|
404
|
-
if diff_result.returncode != 0:
|
|
405
|
-
print(
|
|
406
|
-
f"code_rules_gate: git diff --cached --unified=0 failed for {relative_path_posix}:\n"
|
|
407
|
-
f"{diff_result.stderr}",
|
|
408
|
-
file=sys.stderr,
|
|
409
|
-
)
|
|
410
|
-
raise SystemExit(2)
|
|
411
|
-
if diff_result.stdout.strip():
|
|
412
|
-
return parse_added_line_numbers(diff_result.stdout)
|
|
413
|
-
if is_staged_file_newly_added(repository_root, relative_path_posix):
|
|
414
|
-
total_lines = staged_file_line_count(repository_root, relative_path_posix)
|
|
415
|
-
if total_lines > 0:
|
|
416
|
-
return set(range(1, total_lines + 1))
|
|
417
|
-
return set()
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
def added_lines_by_file_staged(
|
|
421
|
-
repository_root: Path,
|
|
422
|
-
all_file_paths: list[Path],
|
|
423
|
-
) -> dict[Path, set[int]]:
|
|
424
|
-
"""Build a per-file map of staged-added line numbers.
|
|
425
|
-
|
|
426
|
-
Args:
|
|
427
|
-
repository_root: Repository root for diff invocations.
|
|
428
|
-
all_file_paths: File paths whose added lines should be collected.
|
|
429
|
-
|
|
430
|
-
Returns:
|
|
431
|
-
Mapping from resolved file path to the set of staged-added line
|
|
432
|
-
numbers.
|
|
433
|
-
"""
|
|
434
|
-
resolved_root = repository_root.resolve()
|
|
435
|
-
added_by_path: dict[Path, set[int]] = {}
|
|
436
|
-
for each_path in all_file_paths:
|
|
437
|
-
try:
|
|
438
|
-
resolved = each_path.resolve()
|
|
439
|
-
except OSError:
|
|
440
|
-
continue
|
|
441
|
-
try:
|
|
442
|
-
relative = resolved.relative_to(resolved_root)
|
|
443
|
-
except ValueError:
|
|
444
|
-
continue
|
|
445
|
-
relative_posix = str(relative).replace("\\", "/")
|
|
446
|
-
added_numbers = added_lines_for_staged_file(resolved_root, relative_posix)
|
|
447
|
-
added_by_path[resolved] = added_numbers
|
|
448
|
-
return added_by_path
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
def paths_from_git_diff(repository_root: Path, base_reference: str) -> list[Path]:
|
|
452
|
-
"""Return absolute paths for every file changed since *base_reference*.
|
|
453
|
-
|
|
454
|
-
Args:
|
|
455
|
-
repository_root: Repository root used as the ``git -C`` target.
|
|
456
|
-
base_reference: The git reference to merge-base against.
|
|
457
|
-
|
|
458
|
-
Returns:
|
|
459
|
-
List of absolute paths changed since the merge-base of HEAD and
|
|
460
|
-
*base_reference*.
|
|
461
|
-
|
|
462
|
-
Raises:
|
|
463
|
-
SystemExit: When the ``git diff --name-only`` command returns
|
|
464
|
-
non-zero.
|
|
465
|
-
"""
|
|
466
|
-
merge_base = resolve_merge_base(repository_root, base_reference)
|
|
467
|
-
diff_command = list(ALL_GIT_DIFF_NAME_ONLY_NULL_TERMINATED_COMMAND_PREFIX) + [
|
|
468
|
-
f"{merge_base}..HEAD"
|
|
469
|
-
]
|
|
470
|
-
name_result = subprocess.run(
|
|
471
|
-
diff_command,
|
|
472
|
-
cwd=str(repository_root),
|
|
473
|
-
capture_output=True,
|
|
474
|
-
check=False,
|
|
475
|
-
env=repository_environment(),
|
|
476
|
-
)
|
|
477
|
-
if name_result.returncode != 0:
|
|
478
|
-
stderr_text = name_result.stderr.decode("utf-8", errors="replace")
|
|
479
|
-
print(
|
|
480
|
-
f"code_rules_gate: git diff --name-only -z failed:\n{stderr_text}",
|
|
481
|
-
file=sys.stderr,
|
|
482
|
-
)
|
|
483
|
-
raise SystemExit(2)
|
|
484
|
-
raw_paths = name_result.stdout.split(b"\x00")
|
|
485
|
-
resolved_paths: list[Path] = []
|
|
486
|
-
for each_raw_path in raw_paths:
|
|
487
|
-
if not each_raw_path:
|
|
488
|
-
continue
|
|
489
|
-
try:
|
|
490
|
-
relative_path = each_raw_path.decode("utf-8")
|
|
491
|
-
except UnicodeDecodeError:
|
|
492
|
-
print(
|
|
493
|
-
f"code_rules_gate: skipping diff path with non-UTF-8 filename: {each_raw_path!r}",
|
|
494
|
-
file=sys.stderr,
|
|
495
|
-
)
|
|
496
|
-
continue
|
|
497
|
-
resolved_paths.append(repository_root / relative_path)
|
|
498
|
-
return resolved_paths
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
def is_code_path(file_path: Path) -> bool:
|
|
502
|
-
suffix = file_path.suffix.lower()
|
|
503
|
-
return suffix in ALL_CODE_FILE_EXTENSIONS
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
def is_test_path(file_path: str) -> bool:
|
|
507
|
-
"""Return True when *file_path* matches CODE_RULES.md test-file detection patterns.
|
|
508
|
-
|
|
509
|
-
Mirrors the test-file detection rule documented in CODE_RULES.md:
|
|
510
|
-
filename matches test_*.py OR *_test.py OR *.test.* OR *.spec.* OR
|
|
511
|
-
conftest.py, OR path contains the segment /tests/.
|
|
512
|
-
|
|
513
|
-
Args:
|
|
514
|
-
file_path: Path string to classify; backslashes are normalized to
|
|
515
|
-
forward slashes before pattern matching.
|
|
516
|
-
|
|
517
|
-
Returns:
|
|
518
|
-
True when the path matches any test-file pattern; False otherwise.
|
|
519
|
-
"""
|
|
520
|
-
normalized_posix = file_path.replace("\\", "/")
|
|
521
|
-
filename_only = normalized_posix.rsplit("/", maxsplit=1)[-1]
|
|
522
|
-
if TESTS_PATH_SEGMENT in normalized_posix:
|
|
523
|
-
return True
|
|
524
|
-
if filename_only == TEST_CONFTEST_FILENAME:
|
|
525
|
-
return True
|
|
526
|
-
if filename_only.startswith(TEST_FILENAME_PREFIX) and filename_only.endswith(
|
|
527
|
-
PYTHON_FILE_EXTENSION
|
|
528
|
-
):
|
|
529
|
-
return True
|
|
530
|
-
if any(
|
|
531
|
-
filename_only.endswith(each_suffix)
|
|
532
|
-
for each_suffix in ALL_TEST_FILENAME_SUFFIXES
|
|
533
|
-
):
|
|
534
|
-
return True
|
|
535
|
-
if any(
|
|
536
|
-
each_glob_suffix in filename_only
|
|
537
|
-
for each_glob_suffix in ALL_TEST_FILENAME_GLOB_SUFFIXES
|
|
538
|
-
):
|
|
539
|
-
return True
|
|
540
|
-
return False
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
def _iter_calls_excluding_nested_functions(node: ast.AST) -> Iterator[ast.Call]:
|
|
544
|
-
for each_child in ast.iter_child_nodes(node):
|
|
545
|
-
if isinstance(each_child, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
546
|
-
continue
|
|
547
|
-
if isinstance(each_child, ast.Call):
|
|
548
|
-
yield each_child
|
|
549
|
-
continue
|
|
550
|
-
yield from _iter_calls_excluding_nested_functions(each_child)
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
def _module_level_optional_kwargs_by_name(tree: ast.Module) -> dict[str, set[str]]:
|
|
554
|
-
function_signatures: dict[str, set[str]] = {}
|
|
555
|
-
for each_node in ast.iter_child_nodes(tree):
|
|
556
|
-
if isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
557
|
-
optional_kwargs: set[str] = set()
|
|
558
|
-
for each_kwonly, each_default in zip(
|
|
559
|
-
each_node.args.kwonlyargs, each_node.args.kw_defaults
|
|
560
|
-
):
|
|
561
|
-
if each_default is not None:
|
|
562
|
-
optional_kwargs.add(each_kwonly.arg)
|
|
563
|
-
positional_defaults = each_node.args.defaults
|
|
564
|
-
positional_args_with_defaults = (
|
|
565
|
-
each_node.args.args[-len(positional_defaults):]
|
|
566
|
-
if positional_defaults
|
|
567
|
-
else []
|
|
568
|
-
)
|
|
569
|
-
for each_positional_arg in positional_args_with_defaults:
|
|
570
|
-
optional_kwargs.add(each_positional_arg.arg)
|
|
571
|
-
function_signatures[each_node.name] = optional_kwargs
|
|
572
|
-
return function_signatures
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
def _class_method_node_ids(tree: ast.Module) -> set[int]:
|
|
576
|
-
class_method_node_ids: set[int] = set()
|
|
577
|
-
for each_class_def in ast.walk(tree):
|
|
578
|
-
if not isinstance(each_class_def, ast.ClassDef):
|
|
579
|
-
continue
|
|
580
|
-
for each_class_body_node in each_class_def.body:
|
|
581
|
-
if isinstance(
|
|
582
|
-
each_class_body_node, (ast.FunctionDef, ast.AsyncFunctionDef)
|
|
583
|
-
):
|
|
584
|
-
class_method_node_ids.add(id(each_class_body_node))
|
|
585
|
-
return class_method_node_ids
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
def _wrapper_dropped_kwarg_findings(
|
|
589
|
-
wrapper_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
590
|
-
kwargs_by_function_name: dict[str, set[str]],
|
|
591
|
-
) -> Iterator[str]:
|
|
592
|
-
wrapper_kwargs = kwargs_by_function_name.get(wrapper_node.name, set())
|
|
593
|
-
for each_call in _iter_calls_excluding_nested_functions(wrapper_node):
|
|
594
|
-
if isinstance(each_call.func, ast.Name):
|
|
595
|
-
delegate_name = each_call.func.id
|
|
596
|
-
elif isinstance(each_call.func, ast.Attribute):
|
|
597
|
-
delegate_name = each_call.func.attr
|
|
598
|
-
else:
|
|
599
|
-
continue
|
|
600
|
-
delegate_kwargs = kwargs_by_function_name.get(delegate_name)
|
|
601
|
-
if delegate_kwargs is None:
|
|
602
|
-
continue
|
|
603
|
-
missing = delegate_kwargs - wrapper_kwargs
|
|
604
|
-
if missing:
|
|
605
|
-
yield (
|
|
606
|
-
f"Line {wrapper_node.lineno}: Wrapper {wrapper_node.name!r} drops optional kwargs {sorted(missing)!r} of delegate {delegate_name!r}"
|
|
607
|
-
)
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
def check_wrapper_plumb_through(content: str, file_path: str) -> list[str]:
|
|
611
|
-
"""Flag calls inside public functions that drop a same-file delegate's optional kwargs.
|
|
612
|
-
|
|
613
|
-
Walks the AST. For every public function (name does not start with '_'),
|
|
614
|
-
inspects every ast.Call inside its body and emits one finding per call
|
|
615
|
-
whose target name matches a same-file function that exposes optional
|
|
616
|
-
kwargs the enclosing public function does not also accept. Emission is
|
|
617
|
-
capped at MAX_VIOLATIONS_PER_CHECK findings per call to run_gate.
|
|
618
|
-
|
|
619
|
-
Limitations:
|
|
620
|
-
- Only module-level FunctionDef nodes contribute signatures, and ClassDef
|
|
621
|
-
methods are skipped both as signature sources and as wrapper candidates:
|
|
622
|
-
a class method's signature is unrelated to a free-function delegate's
|
|
623
|
-
keyword surface, so treating it as a wrapper produces false positives.
|
|
624
|
-
- ast.Attribute calls match by attribute name only; the receiver type is
|
|
625
|
-
not checked, so `self.fetch(...)` and `other.fetch(...)` both match a
|
|
626
|
-
module-level `fetch` definition.
|
|
627
|
-
- Nested call expressions inside another call's arguments are not treated as
|
|
628
|
-
separate call sites; only the enclosing Call is inspected. This avoids
|
|
629
|
-
false positives where a callee nested as an argument is confused with a
|
|
630
|
-
top-level delegate invocation (for example `delegate(helper(x))`).
|
|
631
|
-
|
|
632
|
-
Args:
|
|
633
|
-
content: File content as a single string for AST parsing.
|
|
634
|
-
file_path: Repository-relative POSIX path of the file (used to
|
|
635
|
-
skip non-Python code extensions and test files early).
|
|
636
|
-
|
|
637
|
-
Returns:
|
|
638
|
-
List of violation strings, one per dropped optional kwarg. Empty for
|
|
639
|
-
a non-Python file, a test file, or a file with a syntax error.
|
|
640
|
-
"""
|
|
641
|
-
non_python_code_extensions = ALL_CODE_FILE_EXTENSIONS - {PYTHON_FILE_EXTENSION}
|
|
642
|
-
lowercase_file_path = file_path.lower()
|
|
643
|
-
if any(
|
|
644
|
-
lowercase_file_path.endswith(each_extension)
|
|
645
|
-
for each_extension in non_python_code_extensions
|
|
646
|
-
):
|
|
647
|
-
return []
|
|
648
|
-
if is_test_path(file_path):
|
|
649
|
-
return []
|
|
650
|
-
try:
|
|
651
|
-
tree = ast.parse(content)
|
|
652
|
-
except SyntaxError:
|
|
653
|
-
return []
|
|
654
|
-
function_signatures = _module_level_optional_kwargs_by_name(tree)
|
|
655
|
-
class_method_node_ids = _class_method_node_ids(tree)
|
|
656
|
-
issues: list[str] = []
|
|
657
|
-
for each_node in ast.walk(tree):
|
|
658
|
-
if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
659
|
-
continue
|
|
660
|
-
if id(each_node) in class_method_node_ids:
|
|
661
|
-
continue
|
|
662
|
-
if each_node.name.startswith("_"):
|
|
663
|
-
continue
|
|
664
|
-
for each_finding in _wrapper_dropped_kwarg_findings(each_node, function_signatures):
|
|
665
|
-
issues.append(each_finding)
|
|
666
|
-
if len(issues) >= MAX_VIOLATIONS_PER_CHECK:
|
|
667
|
-
return issues
|
|
668
|
-
return issues
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
def parse_added_line_numbers(unified_diff_text: str) -> set[int]:
|
|
672
|
-
"""Extract added line numbers from unified-diff text.
|
|
673
|
-
|
|
674
|
-
Args:
|
|
675
|
-
unified_diff_text: Output from ``git diff --unified=0``.
|
|
676
|
-
|
|
677
|
-
Returns:
|
|
678
|
-
Set of newly-added line numbers (1-indexed) extracted from the
|
|
679
|
-
hunk headers.
|
|
680
|
-
"""
|
|
681
|
-
header_regex = hunk_header_pattern()
|
|
682
|
-
added_line_numbers: set[int] = set()
|
|
683
|
-
for each_line in unified_diff_text.splitlines():
|
|
684
|
-
header_match = header_regex.match(each_line)
|
|
685
|
-
if header_match is None:
|
|
686
|
-
continue
|
|
687
|
-
new_start_text, new_count_text = header_match.groups()
|
|
688
|
-
new_start = int(new_start_text)
|
|
689
|
-
new_count = 1 if new_count_text is None else int(new_count_text)
|
|
690
|
-
if new_count <= 0:
|
|
691
|
-
continue
|
|
692
|
-
for each_number in range(new_start, new_start + new_count):
|
|
693
|
-
added_line_numbers.add(each_number)
|
|
694
|
-
return added_line_numbers
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
def is_file_new_at_base(
|
|
698
|
-
repository_root: Path,
|
|
699
|
-
merge_base: str,
|
|
700
|
-
relative_path_posix: str,
|
|
701
|
-
) -> bool:
|
|
702
|
-
"""Check whether *relative_path_posix* did not exist at *merge_base*.
|
|
703
|
-
|
|
704
|
-
Args:
|
|
705
|
-
repository_root: Repository root used as the ``git -C`` target.
|
|
706
|
-
merge_base: The merge-base SHA against which to check existence.
|
|
707
|
-
relative_path_posix: Repository-relative POSIX path to inspect.
|
|
708
|
-
|
|
709
|
-
Returns:
|
|
710
|
-
True when ``git cat-file -e`` fails to find the blob at the merge
|
|
711
|
-
base (i.e. the file was added on the HEAD side); False otherwise.
|
|
712
|
-
"""
|
|
713
|
-
cat_result = subprocess.run(
|
|
714
|
-
["git", "cat-file", "-e", f"{merge_base}:{relative_path_posix}"],
|
|
715
|
-
cwd=str(repository_root),
|
|
716
|
-
capture_output=True,
|
|
717
|
-
text=True,
|
|
718
|
-
encoding="utf-8",
|
|
719
|
-
errors="replace",
|
|
720
|
-
check=False,
|
|
721
|
-
env=repository_environment(),
|
|
722
|
-
)
|
|
723
|
-
return cat_result.returncode != 0
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
def added_lines_for_file(
|
|
727
|
-
repository_root: Path,
|
|
728
|
-
merge_base: str,
|
|
729
|
-
relative_path_posix: str,
|
|
730
|
-
) -> set[int]:
|
|
731
|
-
"""Return added line numbers for *relative_path_posix* since *merge_base*.
|
|
732
|
-
|
|
733
|
-
Args:
|
|
734
|
-
repository_root: Repository root used as the ``git -C`` target.
|
|
735
|
-
merge_base: The merge-base SHA against which to diff.
|
|
736
|
-
relative_path_posix: Repository-relative POSIX path to inspect.
|
|
737
|
-
|
|
738
|
-
Returns:
|
|
739
|
-
Set of line numbers (1-indexed) added on the HEAD side of the diff.
|
|
740
|
-
|
|
741
|
-
Raises:
|
|
742
|
-
SystemExit: When the diff command returns non-zero.
|
|
743
|
-
"""
|
|
744
|
-
diff_result = subprocess.run(
|
|
745
|
-
[
|
|
746
|
-
"git",
|
|
747
|
-
"diff",
|
|
748
|
-
"--unified=0",
|
|
749
|
-
f"{merge_base}..HEAD",
|
|
750
|
-
"--",
|
|
751
|
-
relative_path_posix,
|
|
752
|
-
],
|
|
753
|
-
cwd=str(repository_root),
|
|
754
|
-
capture_output=True,
|
|
755
|
-
text=True,
|
|
756
|
-
encoding="utf-8",
|
|
757
|
-
errors="replace",
|
|
758
|
-
check=False,
|
|
759
|
-
env=repository_environment(),
|
|
760
|
-
)
|
|
761
|
-
if diff_result.returncode != 0:
|
|
762
|
-
print(
|
|
763
|
-
f"code_rules_gate: git diff --unified=0 failed for {relative_path_posix}:\n"
|
|
764
|
-
f"{diff_result.stderr}",
|
|
765
|
-
file=sys.stderr,
|
|
766
|
-
)
|
|
767
|
-
raise SystemExit(2)
|
|
768
|
-
if not diff_result.stdout.strip():
|
|
769
|
-
return set()
|
|
770
|
-
return parse_added_line_numbers(diff_result.stdout)
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
def whole_file_line_set(file_path: Path) -> set[int]:
|
|
774
|
-
"""Return the set of line numbers covering an entire file.
|
|
775
|
-
|
|
776
|
-
Args:
|
|
777
|
-
file_path: Path to the file whose line span should be summarized.
|
|
778
|
-
|
|
779
|
-
Returns:
|
|
780
|
-
Set of line numbers (1-indexed) covering every line in *file_path*,
|
|
781
|
-
or an empty set when the file is unreadable or empty.
|
|
782
|
-
"""
|
|
783
|
-
try:
|
|
784
|
-
total_lines = len(file_path.read_text(encoding="utf-8").splitlines())
|
|
785
|
-
except (OSError, UnicodeDecodeError) as read_error:
|
|
786
|
-
print(
|
|
787
|
-
f"code_rules_gate: skipping unreadable file {file_path}: {read_error}",
|
|
788
|
-
file=sys.stderr,
|
|
789
|
-
)
|
|
790
|
-
return set()
|
|
791
|
-
if total_lines <= 0:
|
|
792
|
-
return set()
|
|
793
|
-
return set(range(1, total_lines + 1))
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
def renamed_file_source_map_since(
|
|
797
|
-
repository_root: Path,
|
|
798
|
-
merge_base: str,
|
|
799
|
-
) -> dict[str, str]:
|
|
800
|
-
"""Return a mapping from rename-destination path to rename-source path.
|
|
801
|
-
|
|
802
|
-
Runs `git diff --name-status -M -z merge_base..HEAD` and collects both
|
|
803
|
-
paths of every rename entry (status code starting with R, e.g. `R100`).
|
|
804
|
-
Keys are destination posix paths; values are source posix paths.
|
|
805
|
-
|
|
806
|
-
The -z flag asks git for null-terminated, unquoted output. A path
|
|
807
|
-
holding a tab or newline byte then survives column and line splitting
|
|
808
|
-
unmangled. Each rename record emits three null-terminated tokens
|
|
809
|
-
(status, source, destination). Every other status record emits two
|
|
810
|
-
(status, path).
|
|
811
|
-
|
|
812
|
-
Args:
|
|
813
|
-
repository_root: Repository root used as the ``git -C`` target.
|
|
814
|
-
merge_base: The merge-base SHA against which to diff.
|
|
815
|
-
|
|
816
|
-
Returns:
|
|
817
|
-
Mapping from rename-destination POSIX path to rename-source POSIX
|
|
818
|
-
path. Empty when no rename records are present.
|
|
819
|
-
|
|
820
|
-
Raises:
|
|
821
|
-
SystemExit: When ``git diff --name-status`` returns non-zero.
|
|
822
|
-
"""
|
|
823
|
-
name_status_result = subprocess.run(
|
|
824
|
-
["git", "diff", "--name-status", "-M", "-z", f"{merge_base}..HEAD"],
|
|
825
|
-
cwd=str(repository_root),
|
|
826
|
-
capture_output=True,
|
|
827
|
-
check=False,
|
|
828
|
-
env=repository_environment(),
|
|
829
|
-
)
|
|
830
|
-
if name_status_result.returncode != 0:
|
|
831
|
-
stderr_text = name_status_result.stderr.decode("utf-8", errors="replace")
|
|
832
|
-
print(
|
|
833
|
-
f"code_rules_gate: git diff --name-status -M -z failed:\n"
|
|
834
|
-
f"{stderr_text}",
|
|
835
|
-
file=sys.stderr,
|
|
836
|
-
)
|
|
837
|
-
raise SystemExit(2)
|
|
838
|
-
null_separated_tokens = [
|
|
839
|
-
each_token.decode("utf-8", errors="replace")
|
|
840
|
-
for each_token in name_status_result.stdout.split(b"\x00")
|
|
841
|
-
if each_token
|
|
842
|
-
]
|
|
843
|
-
rename_source_by_destination: dict[str, str] = {}
|
|
844
|
-
next_token_index = 0
|
|
845
|
-
while next_token_index < len(null_separated_tokens):
|
|
846
|
-
status_code = null_separated_tokens[next_token_index]
|
|
847
|
-
if status_code.startswith(GIT_NAME_STATUS_RENAMED_PREFIX):
|
|
848
|
-
if next_token_index + EXPECTED_RENAME_COLUMN_COUNT > len(
|
|
849
|
-
null_separated_tokens
|
|
850
|
-
):
|
|
851
|
-
break
|
|
852
|
-
source_path = null_separated_tokens[next_token_index + 1].replace(
|
|
853
|
-
"\\", "/"
|
|
854
|
-
)
|
|
855
|
-
destination_path = null_separated_tokens[next_token_index + 2].replace(
|
|
856
|
-
"\\", "/"
|
|
857
|
-
)
|
|
858
|
-
rename_source_by_destination[destination_path] = source_path
|
|
859
|
-
next_token_index += EXPECTED_RENAME_COLUMN_COUNT
|
|
860
|
-
continue
|
|
861
|
-
next_token_index += EXPECTED_NON_RENAME_COLUMN_COUNT
|
|
862
|
-
return rename_source_by_destination
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
def added_lines_for_renamed_file(
|
|
866
|
-
repository_root: Path,
|
|
867
|
-
merge_base: str,
|
|
868
|
-
source_posix: str,
|
|
869
|
-
destination_posix: str,
|
|
870
|
-
) -> set[int]:
|
|
871
|
-
"""Return added line numbers for a renamed file via blob comparison.
|
|
872
|
-
|
|
873
|
-
Compares `merge_base:source_posix` against `HEAD:destination_posix`
|
|
874
|
-
to surface only truly added lines, ignoring lines that already existed
|
|
875
|
-
in the source file before the rename. Falls back to whole-file coverage
|
|
876
|
-
when the source blob is absent at the merge base (i.e. the source was
|
|
877
|
-
itself a new or renamed file that landed earlier in the branch).
|
|
878
|
-
|
|
879
|
-
Args:
|
|
880
|
-
repository_root: Repository root used as the ``git -C`` target.
|
|
881
|
-
merge_base: The merge-base SHA against which to compare blobs.
|
|
882
|
-
source_posix: Rename-source POSIX path at the merge base.
|
|
883
|
-
destination_posix: Rename-destination POSIX path at HEAD.
|
|
884
|
-
|
|
885
|
-
Returns:
|
|
886
|
-
Set of line numbers (1-indexed) added on the HEAD side of the
|
|
887
|
-
comparison; empty on diff failure.
|
|
888
|
-
"""
|
|
889
|
-
diff_result = subprocess.run(
|
|
890
|
-
[
|
|
891
|
-
"git",
|
|
892
|
-
"diff",
|
|
893
|
-
"--unified=0",
|
|
894
|
-
f"{merge_base}:{source_posix}",
|
|
895
|
-
f"HEAD:{destination_posix}",
|
|
896
|
-
],
|
|
897
|
-
cwd=str(repository_root),
|
|
898
|
-
capture_output=True,
|
|
899
|
-
text=True,
|
|
900
|
-
encoding="utf-8",
|
|
901
|
-
errors="replace",
|
|
902
|
-
check=False,
|
|
903
|
-
env=repository_environment(),
|
|
904
|
-
)
|
|
905
|
-
if diff_result.returncode != 0:
|
|
906
|
-
print(
|
|
907
|
-
f"code_rules_gate: git diff failed for renamed file {merge_base}:{source_posix} "
|
|
908
|
-
f"vs HEAD:{destination_posix} (returncode={diff_result.returncode}); "
|
|
909
|
-
f"stderr={diff_result.stderr.strip()!r}",
|
|
910
|
-
file=sys.stderr,
|
|
911
|
-
)
|
|
912
|
-
return set()
|
|
913
|
-
if not diff_result.stdout.strip():
|
|
914
|
-
return set()
|
|
915
|
-
return parse_added_line_numbers(diff_result.stdout)
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
def added_lines_by_file(
|
|
919
|
-
repository_root: Path,
|
|
920
|
-
base_reference: str,
|
|
921
|
-
all_file_paths: list[Path],
|
|
922
|
-
) -> dict[Path, set[int]]:
|
|
923
|
-
"""Build a per-file map of added line numbers across the branch.
|
|
924
|
-
|
|
925
|
-
Args:
|
|
926
|
-
repository_root: Repository root for diff invocations.
|
|
927
|
-
base_reference: The git reference to merge-base against.
|
|
928
|
-
all_file_paths: File paths whose added lines should be collected.
|
|
929
|
-
|
|
930
|
-
Returns:
|
|
931
|
-
Mapping from resolved file path to the set of line numbers added
|
|
932
|
-
on the HEAD side, with renames resolved to compare against the
|
|
933
|
-
original source path.
|
|
934
|
-
"""
|
|
935
|
-
merge_base = resolve_merge_base(repository_root, base_reference)
|
|
936
|
-
resolved_root = repository_root.resolve()
|
|
937
|
-
rename_source_map = renamed_file_source_map_since(resolved_root, merge_base)
|
|
938
|
-
added_by_path: dict[Path, set[int]] = {}
|
|
939
|
-
for each_path in all_file_paths:
|
|
940
|
-
try:
|
|
941
|
-
resolved = each_path.resolve()
|
|
942
|
-
except OSError:
|
|
943
|
-
continue
|
|
944
|
-
try:
|
|
945
|
-
relative = resolved.relative_to(resolved_root)
|
|
946
|
-
except ValueError:
|
|
947
|
-
continue
|
|
948
|
-
relative_posix = str(relative).replace("\\", "/")
|
|
949
|
-
if relative_posix in rename_source_map:
|
|
950
|
-
added_numbers = added_lines_for_renamed_file(
|
|
951
|
-
resolved_root,
|
|
952
|
-
merge_base,
|
|
953
|
-
rename_source_map[relative_posix],
|
|
954
|
-
relative_posix,
|
|
955
|
-
)
|
|
956
|
-
else:
|
|
957
|
-
added_numbers = added_lines_for_file(
|
|
958
|
-
resolved_root, merge_base, relative_posix
|
|
959
|
-
)
|
|
960
|
-
if not added_numbers and resolved.is_file():
|
|
961
|
-
if is_file_new_at_base(resolved_root, merge_base, relative_posix):
|
|
962
|
-
added_numbers = whole_file_line_set(resolved)
|
|
963
|
-
added_by_path[resolved] = added_numbers
|
|
964
|
-
return added_by_path
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
def extract_violation_line_number(violation_text: str) -> int | None:
|
|
968
|
-
"""Return the line number captured by the gate's violation-line regex.
|
|
969
|
-
|
|
970
|
-
Args:
|
|
971
|
-
violation_text: A single violation string of the form ``Line N: ...``.
|
|
972
|
-
|
|
973
|
-
Returns:
|
|
974
|
-
The integer line number captured in the prefix, or None when the
|
|
975
|
-
text does not match the violation-line pattern.
|
|
976
|
-
"""
|
|
977
|
-
match_result = violation_line_pattern().match(violation_text)
|
|
978
|
-
if match_result is None:
|
|
979
|
-
return None
|
|
980
|
-
return int(match_result.group(1))
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
def function_length_span_range(violation_text: str) -> range | None:
|
|
984
|
-
"""Return the declared line range of a function-length violation, or None.
|
|
985
|
-
|
|
986
|
-
The enforcer's function-length message carries the definition line and
|
|
987
|
-
the function's line span: ``Function 'NAME' (defined at line X) is Y
|
|
988
|
-
lines - ...``. The function occupies lines ``X`` through ``X + Y - 1``
|
|
989
|
-
inclusive.
|
|
990
|
-
|
|
991
|
-
Args:
|
|
992
|
-
violation_text: A single violation string emitted by the enforcer.
|
|
993
|
-
|
|
994
|
-
Returns:
|
|
995
|
-
A ``range`` covering the function's declared line span, or None when
|
|
996
|
-
the text is not a function-length violation.
|
|
997
|
-
"""
|
|
998
|
-
span_match = FUNCTION_LENGTH_VIOLATION_PATTERN.search(violation_text)
|
|
999
|
-
if span_match is None:
|
|
1000
|
-
return None
|
|
1001
|
-
definition_line = int(span_match.group(FUNCTION_LENGTH_DEFINITION_LINE_GROUP_INDEX))
|
|
1002
|
-
line_span = int(span_match.group(FUNCTION_LENGTH_SPAN_GROUP_INDEX))
|
|
1003
|
-
return range(definition_line, definition_line + line_span)
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
def isolation_span_range(violation_text: str) -> range | None:
|
|
1007
|
-
"""Return the enclosing test-function line range of an isolation violation.
|
|
1008
|
-
|
|
1009
|
-
The enforcer's HOME/TMP isolation message carries the enclosing test
|
|
1010
|
-
function's definition line and span: ``Line N: Test 'NAME' (defined at
|
|
1011
|
-
line X, spanning Y lines) probes ...``. The function occupies lines ``X``
|
|
1012
|
-
through ``X + Y - 1`` inclusive, so a signature-line change that
|
|
1013
|
-
un-isolates an unchanged-body probe is scoped by the same span the
|
|
1014
|
-
enforcer uses rather than by the ``Line N:`` probe line alone.
|
|
1015
|
-
|
|
1016
|
-
Args:
|
|
1017
|
-
violation_text: A single violation string emitted by the enforcer.
|
|
1018
|
-
|
|
1019
|
-
Returns:
|
|
1020
|
-
A ``range`` covering the enclosing test function's declared line span,
|
|
1021
|
-
or None when the text is not an isolation violation.
|
|
1022
|
-
"""
|
|
1023
|
-
span_match = ISOLATION_VIOLATION_PATTERN.search(violation_text)
|
|
1024
|
-
if span_match is None:
|
|
1025
|
-
return None
|
|
1026
|
-
definition_line = int(span_match.group(ISOLATION_DEFINITION_LINE_GROUP_INDEX))
|
|
1027
|
-
line_span = int(span_match.group(ISOLATION_SPAN_GROUP_INDEX))
|
|
1028
|
-
return range(definition_line, definition_line + line_span)
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
def banned_noun_span_range(violation_text: str) -> range | None:
|
|
1032
|
-
"""Return the one-line binding span of a banned-noun violation, or None.
|
|
1033
|
-
|
|
1034
|
-
The enforcer's banned-noun message carries the binding line and a one-line
|
|
1035
|
-
span: ``Line N: Identifier 'NAME' ... (binding span at line X, spanning 1
|
|
1036
|
-
lines)``. A banned-noun binding is a point fact about one identifier, so the
|
|
1037
|
-
span is always the binding line alone (``X`` through ``X``) — never the
|
|
1038
|
-
enclosing function span. Scoping to the binding line keeps a pre-existing
|
|
1039
|
-
parameter or local-name binding out of scope when an unrelated line of its
|
|
1040
|
-
enclosing function is edited.
|
|
1041
|
-
|
|
1042
|
-
Args:
|
|
1043
|
-
violation_text: A single violation string emitted by the enforcer.
|
|
1044
|
-
|
|
1045
|
-
Returns:
|
|
1046
|
-
A ``range`` covering the binding's one-line span, or None when the text
|
|
1047
|
-
is not a banned-noun violation.
|
|
1048
|
-
"""
|
|
1049
|
-
span_match = BANNED_NOUN_VIOLATION_PATTERN.search(violation_text)
|
|
1050
|
-
if span_match is None:
|
|
1051
|
-
return None
|
|
1052
|
-
definition_line = int(span_match.group(BANNED_NOUN_DEFINITION_LINE_GROUP_INDEX))
|
|
1053
|
-
line_span = int(span_match.group(BANNED_NOUN_SPAN_GROUP_INDEX))
|
|
1054
|
-
return range(definition_line, definition_line + line_span)
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
def duplicate_body_span_range(violation_text: str) -> range | None:
|
|
1058
|
-
"""Return the copied function's source line range of a duplicate-body issue.
|
|
1059
|
-
|
|
1060
|
-
The duplicate-body message carries the copied function's definition line and
|
|
1061
|
-
its full body span: ``Function 'NAME' duplicates location.py::name — ...
|
|
1062
|
-
(duplicate body span at line X, spanning Y lines)``. The function occupies
|
|
1063
|
-
lines ``X`` through ``X + Y - 1`` inclusive.
|
|
1064
|
-
|
|
1065
|
-
So a duplicate of a sibling helper blocks only when the diff touches the
|
|
1066
|
-
copied function. An unrelated edit that leaves a pre-existing copy
|
|
1067
|
-
untouched keeps it advisory. This matches the span-scoped PreToolUse
|
|
1068
|
-
Write/Edit behavior rather than blocking every duplicate-body message
|
|
1069
|
-
unconditionally.
|
|
1070
|
-
|
|
1071
|
-
Args:
|
|
1072
|
-
violation_text: A single violation string emitted by the enforcer.
|
|
1073
|
-
|
|
1074
|
-
Returns:
|
|
1075
|
-
A ``range`` covering the copied function's declared line span, or None
|
|
1076
|
-
when the text is not a duplicate-body violation.
|
|
1077
|
-
"""
|
|
1078
|
-
span_match = DUPLICATE_BODY_VIOLATION_PATTERN.search(violation_text)
|
|
1079
|
-
if span_match is None:
|
|
1080
|
-
return None
|
|
1081
|
-
definition_line = int(span_match.group(DUPLICATE_BODY_DEFINITION_LINE_GROUP_INDEX))
|
|
1082
|
-
line_span = int(span_match.group(DUPLICATE_BODY_SPAN_GROUP_INDEX))
|
|
1083
|
-
return range(definition_line, definition_line + line_span)
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
def inline_duplicate_body_span_lines(violation_text: str) -> frozenset[int] | None:
|
|
1087
|
-
"""Return the union of both spans of a same-file inline-duplicate issue, or None.
|
|
1088
|
-
|
|
1089
|
-
The same-file inline-duplicate message names two functions that share a body:
|
|
1090
|
-
the helper and the enclosing function carrying the inline copy. The live
|
|
1091
|
-
Write/Edit hook scopes the violation by the union of both spans. It blocks
|
|
1092
|
-
when an edit touches either function. So the message carries both spans:
|
|
1093
|
-
``(inline duplicate body spans: helper at line H spanning P lines,
|
|
1094
|
-
enclosing at line E spanning Q lines)``.
|
|
1095
|
-
|
|
1096
|
-
The two spans can be disjoint: an unrelated function may sit between the
|
|
1097
|
-
helper and its inline copy. This returns the union as a line-number set
|
|
1098
|
-
rather than a single contiguous range. A range covering the gap would
|
|
1099
|
-
wrongly block an edit confined to that intervening function, which the
|
|
1100
|
-
PreToolUse path leaves unflagged.
|
|
1101
|
-
|
|
1102
|
-
Args:
|
|
1103
|
-
violation_text: A single violation string emitted by the enforcer.
|
|
1104
|
-
|
|
1105
|
-
Returns:
|
|
1106
|
-
The frozenset of every line in the helper span and the enclosing span, or
|
|
1107
|
-
None when the text is not a same-file inline-duplicate violation.
|
|
1108
|
-
"""
|
|
1109
|
-
span_match = INLINE_DUPLICATE_BODY_VIOLATION_PATTERN.search(violation_text)
|
|
1110
|
-
if span_match is None:
|
|
1111
|
-
return None
|
|
1112
|
-
helper_line = int(span_match.group(INLINE_DUPLICATE_BODY_HELPER_LINE_GROUP_INDEX))
|
|
1113
|
-
helper_span = int(span_match.group(INLINE_DUPLICATE_BODY_HELPER_SPAN_GROUP_INDEX))
|
|
1114
|
-
enclosing_line = int(
|
|
1115
|
-
span_match.group(INLINE_DUPLICATE_BODY_ENCLOSING_LINE_GROUP_INDEX)
|
|
1116
|
-
)
|
|
1117
|
-
enclosing_span = int(
|
|
1118
|
-
span_match.group(INLINE_DUPLICATE_BODY_ENCLOSING_SPAN_GROUP_INDEX)
|
|
1119
|
-
)
|
|
1120
|
-
helper_lines = range(helper_line, helper_line + helper_span)
|
|
1121
|
-
enclosing_lines = range(enclosing_line, enclosing_line + enclosing_span)
|
|
1122
|
-
return frozenset(helper_lines) | frozenset(enclosing_lines)
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
def _all_span_range_extractors() -> tuple[Callable[[str], range | None], ...]:
|
|
1126
|
-
return (
|
|
1127
|
-
function_length_span_range,
|
|
1128
|
-
isolation_span_range,
|
|
1129
|
-
banned_noun_span_range,
|
|
1130
|
-
duplicate_body_span_range,
|
|
1131
|
-
)
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
def enclosing_span_range(violation_text: str) -> range | None:
|
|
1135
|
-
"""Return the enclosing-unit line range of a span-tagged violation, or None.
|
|
1136
|
-
|
|
1137
|
-
Every diff-scoped enforcer check tags its message with an enclosing-unit
|
|
1138
|
-
span fragment. This dispatcher tries each span extractor from
|
|
1139
|
-
``_all_span_range_extractors``, so the gate reconstructs every scoped
|
|
1140
|
-
check's span through one shared mechanism. Adding a new scoped check
|
|
1141
|
-
means adding one extractor to that registry rather than threading a new
|
|
1142
|
-
branch through ``split_violations_by_scope``.
|
|
1143
|
-
|
|
1144
|
-
Args:
|
|
1145
|
-
violation_text: A single violation string emitted by the enforcer.
|
|
1146
|
-
|
|
1147
|
-
Returns:
|
|
1148
|
-
The first non-None span range any extractor recovers, or None when the
|
|
1149
|
-
text carries no enclosing-unit span fragment.
|
|
1150
|
-
"""
|
|
1151
|
-
for each_extractor in _all_span_range_extractors():
|
|
1152
|
-
span_range = each_extractor(violation_text)
|
|
1153
|
-
if span_range is not None:
|
|
1154
|
-
return span_range
|
|
1155
|
-
return None
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
def split_violations_by_scope(
|
|
1159
|
-
all_issues: list[str],
|
|
1160
|
-
all_added_line_numbers: set[int] | None,
|
|
1161
|
-
) -> tuple[list[str], list[str]]:
|
|
1162
|
-
"""Partition issues into blocking vs advisory based on touched lines.
|
|
1163
|
-
|
|
1164
|
-
Args:
|
|
1165
|
-
all_issues: Violation strings emitted by the enforcer.
|
|
1166
|
-
all_added_line_numbers: Lines added in the current diff, or None
|
|
1167
|
-
to treat every violation as blocking.
|
|
1168
|
-
|
|
1169
|
-
Returns:
|
|
1170
|
-
Tuple ``(blocking, advisory)``. When *all_added_line_numbers* is
|
|
1171
|
-
None, every issue is blocking. A same-file inline-duplicate violation
|
|
1172
|
-
carries both the helper span and the enclosing span;
|
|
1173
|
-
``inline_duplicate_body_span_lines`` reconstructs their union as a
|
|
1174
|
-
line-number set, and the violation is blocking when an added line falls
|
|
1175
|
-
in either span — matching the live Write/Edit hook's union scoping. Every
|
|
1176
|
-
other diff-scoped violation (function-length, HOME/TMP isolation,
|
|
1177
|
-
banned-noun, cross-file duplicate-body) carries one enclosing-unit span
|
|
1178
|
-
fragment that ``enclosing_span_range`` reconstructs through one shared
|
|
1179
|
-
extractor registry; such a violation is blocking
|
|
1180
|
-
when its declared span intersects the added lines (the unit grew or its
|
|
1181
|
-
signature changed in this diff) and advisory otherwise (a pre-existing
|
|
1182
|
-
untouched unit). Every other issue is blocking when its ``Line N:``
|
|
1183
|
-
prefix names an added line and advisory otherwise.
|
|
1184
|
-
"""
|
|
1185
|
-
if all_added_line_numbers is None:
|
|
1186
|
-
return list(all_issues), []
|
|
1187
|
-
blocking: list[str] = []
|
|
1188
|
-
advisory: list[str] = []
|
|
1189
|
-
for each_issue in all_issues:
|
|
1190
|
-
inline_duplicate_lines = inline_duplicate_body_span_lines(each_issue)
|
|
1191
|
-
if inline_duplicate_lines is not None:
|
|
1192
|
-
if inline_duplicate_lines & all_added_line_numbers:
|
|
1193
|
-
blocking.append(each_issue)
|
|
1194
|
-
else:
|
|
1195
|
-
advisory.append(each_issue)
|
|
1196
|
-
continue
|
|
1197
|
-
span_range = enclosing_span_range(each_issue)
|
|
1198
|
-
if span_range is not None:
|
|
1199
|
-
if any(each_line in all_added_line_numbers for each_line in span_range):
|
|
1200
|
-
blocking.append(each_issue)
|
|
1201
|
-
else:
|
|
1202
|
-
advisory.append(each_issue)
|
|
1203
|
-
continue
|
|
1204
|
-
violation_line = extract_violation_line_number(each_issue)
|
|
1205
|
-
if violation_line is None:
|
|
1206
|
-
blocking.append(each_issue)
|
|
1207
|
-
continue
|
|
1208
|
-
if violation_line in all_added_line_numbers:
|
|
1209
|
-
blocking.append(each_issue)
|
|
1210
|
-
else:
|
|
1211
|
-
advisory.append(each_issue)
|
|
1212
|
-
return blocking, advisory
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
def print_violation_section(
|
|
1216
|
-
header_message: str,
|
|
1217
|
-
violations_by_file: dict[Path, list[str]],
|
|
1218
|
-
repository_root: Path,
|
|
1219
|
-
) -> None:
|
|
1220
|
-
"""Print a labeled block of violations grouped by relative path.
|
|
1221
|
-
|
|
1222
|
-
Args:
|
|
1223
|
-
header_message: Section header to write to stderr.
|
|
1224
|
-
violations_by_file: Mapping from absolute file path to the list of
|
|
1225
|
-
violation strings to render under that path.
|
|
1226
|
-
repository_root: Repository root used to compute relative paths.
|
|
1227
|
-
"""
|
|
1228
|
-
print(header_message, file=sys.stderr)
|
|
1229
|
-
resolved_root = repository_root.resolve()
|
|
1230
|
-
for each_path in sorted(violations_by_file.keys()):
|
|
1231
|
-
relative = each_path.relative_to(resolved_root)
|
|
1232
|
-
print(f"{relative}:", file=sys.stderr)
|
|
1233
|
-
for each_issue in violations_by_file[each_path]:
|
|
1234
|
-
print(f" {each_issue}", file=sys.stderr)
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
def read_prior_committed_content(
|
|
1238
|
-
repository_root: Path, relative_path_posix: str
|
|
1239
|
-
) -> str:
|
|
1240
|
-
"""Return the HEAD-committed content for *relative_path_posix*.
|
|
1241
|
-
|
|
1242
|
-
Args:
|
|
1243
|
-
repository_root: Repository root used as the ``git -C`` target.
|
|
1244
|
-
relative_path_posix: Repository-relative POSIX path to read.
|
|
1245
|
-
|
|
1246
|
-
Returns:
|
|
1247
|
-
The committed file content at HEAD, or an empty string when the
|
|
1248
|
-
path is not tracked or ``git show`` returns non-zero.
|
|
1249
|
-
"""
|
|
1250
|
-
show_result = subprocess.run(
|
|
1251
|
-
["git", "show", f"HEAD:{relative_path_posix}"],
|
|
1252
|
-
cwd=str(repository_root),
|
|
1253
|
-
capture_output=True,
|
|
1254
|
-
text=True,
|
|
1255
|
-
encoding="utf-8",
|
|
1256
|
-
errors="replace",
|
|
1257
|
-
check=False,
|
|
1258
|
-
env=repository_environment(),
|
|
1259
|
-
)
|
|
1260
|
-
if show_result.returncode != 0:
|
|
1261
|
-
return ""
|
|
1262
|
-
return show_result.stdout
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
def read_staged_content(
|
|
1266
|
-
repository_root: Path, relative_path_posix: str
|
|
1267
|
-
) -> str | None:
|
|
1268
|
-
"""Return the staged-blob content for *relative_path_posix*.
|
|
1269
|
-
|
|
1270
|
-
Args:
|
|
1271
|
-
repository_root: Repository root used as the ``git -C`` target.
|
|
1272
|
-
relative_path_posix: Repository-relative POSIX path to read.
|
|
1
|
+
"""Run the code-rules validators over a resolved git file set.
|
|
1273
2
|
|
|
1274
|
-
|
|
1275
|
-
The staged blob content, or None when the path is not staged, when
|
|
1276
|
-
``git show`` returns non-zero, or when the staged bytes are not
|
|
1277
|
-
decodable Unicode (the caller skips and fails closed).
|
|
1278
|
-
"""
|
|
1279
|
-
git_show_process = subprocess.run(
|
|
1280
|
-
["git", "show", f":{relative_path_posix}"],
|
|
1281
|
-
cwd=str(repository_root),
|
|
1282
|
-
capture_output=True,
|
|
1283
|
-
check=False,
|
|
1284
|
-
env=repository_environment(),
|
|
1285
|
-
)
|
|
1286
|
-
if git_show_process.returncode != 0:
|
|
1287
|
-
return None
|
|
1288
|
-
try:
|
|
1289
|
-
return git_show_process.stdout.decode(encoding="utf-8")
|
|
1290
|
-
except UnicodeDecodeError:
|
|
1291
|
-
return None
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
def staged_blob_exists(
|
|
1295
|
-
repository_root: Path, relative_path_posix: str
|
|
1296
|
-
) -> bool:
|
|
1297
|
-
"""Report whether *relative_path_posix* is present in the staged index.
|
|
1298
|
-
|
|
1299
|
-
Args:
|
|
1300
|
-
repository_root: Repository root used as the ``git -C`` target.
|
|
1301
|
-
relative_path_posix: Repository-relative POSIX path to probe.
|
|
1302
|
-
|
|
1303
|
-
Returns:
|
|
1304
|
-
True when the path is staged for add or modify (its blob exists in the
|
|
1305
|
-
index); False when it is absent, such as a staged deletion.
|
|
1306
|
-
"""
|
|
1307
|
-
git_cat_file_process = subprocess.run(
|
|
1308
|
-
["git", "cat-file", "-e", f":{relative_path_posix}"],
|
|
1309
|
-
cwd=str(repository_root),
|
|
1310
|
-
capture_output=True,
|
|
1311
|
-
check=False,
|
|
1312
|
-
env=repository_environment(),
|
|
1313
|
-
)
|
|
1314
|
-
return git_cat_file_process.returncode == 0
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
def _path_is_eligible_for_validation(
|
|
1318
|
-
resolved_path: Path,
|
|
1319
|
-
repository_root: Path,
|
|
1320
|
-
read_staged_content_flag: bool,
|
|
1321
|
-
) -> bool:
|
|
1322
|
-
"""Decide whether *resolved_path* should be validated by the gate.
|
|
1323
|
-
|
|
1324
|
-
Args:
|
|
1325
|
-
resolved_path: A resolved candidate path already confirmed to live
|
|
1326
|
-
under *repository_root*.
|
|
1327
|
-
repository_root: Repository root used to compute the relative path.
|
|
1328
|
-
read_staged_content_flag: When True, require staged-index presence so
|
|
1329
|
-
files staged for add or modify are validated and staged deletions
|
|
1330
|
-
are skipped; when False, require working-tree presence.
|
|
1331
|
-
|
|
1332
|
-
Returns:
|
|
1333
|
-
True when the path carries a code extension and exists in the source
|
|
1334
|
-
the gate will read; False otherwise.
|
|
1335
|
-
"""
|
|
1336
|
-
if not is_code_path(resolved_path):
|
|
1337
|
-
return False
|
|
1338
|
-
if read_staged_content_flag:
|
|
1339
|
-
relative_posix = str(
|
|
1340
|
-
resolved_path.relative_to(repository_root.resolve())
|
|
1341
|
-
).replace("\\", "/")
|
|
1342
|
-
return staged_blob_exists(repository_root.resolve(), relative_posix)
|
|
1343
|
-
return resolved_path.is_file()
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
def _scoped_violations_for_file(
|
|
1347
|
-
validate_content: ValidateContentCallable,
|
|
1348
|
-
resolved_path: Path,
|
|
1349
|
-
repository_root: Path,
|
|
1350
|
-
all_added_lines_for_file: set[int] | None,
|
|
1351
|
-
read_staged_content_flag: bool = False,
|
|
1352
|
-
) -> tuple[list[str], list[str]] | None:
|
|
1353
|
-
"""Validate one resolved file and partition its violations by diff scope.
|
|
1354
|
-
|
|
1355
|
-
Args:
|
|
1356
|
-
validate_content: The enforcer ``validate_content`` callable.
|
|
1357
|
-
resolved_path: The resolved code file to validate.
|
|
1358
|
-
repository_root: Repository root used to resolve the relative path.
|
|
1359
|
-
all_added_lines_for_file: Lines added in the current diff for this file,
|
|
1360
|
-
or None to treat every violation as blocking.
|
|
1361
|
-
read_staged_content_flag: When True, source the content from the staged
|
|
1362
|
-
blob so it matches the staged diff that scoped the added lines.
|
|
3
|
+
::
|
|
1363
4
|
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
"""
|
|
1368
|
-
relative_posix = str(
|
|
1369
|
-
resolved_path.relative_to(repository_root.resolve())
|
|
1370
|
-
).replace("\\", "/")
|
|
1371
|
-
if read_staged_content_flag:
|
|
1372
|
-
staged_content = read_staged_content(repository_root.resolve(), relative_posix)
|
|
1373
|
-
if staged_content is None:
|
|
1374
|
-
print(f"code_rules_gate: skip unreadable {resolved_path}", file=sys.stderr)
|
|
1375
|
-
return None
|
|
1376
|
-
content = staged_content
|
|
1377
|
-
else:
|
|
1378
|
-
try:
|
|
1379
|
-
content = resolved_path.read_text(encoding="utf-8")
|
|
1380
|
-
except (OSError, UnicodeDecodeError):
|
|
1381
|
-
print(f"code_rules_gate: skip unreadable {resolved_path}", file=sys.stderr)
|
|
1382
|
-
return None
|
|
1383
|
-
prior_content = read_prior_committed_content(
|
|
1384
|
-
repository_root.resolve(), relative_posix
|
|
1385
|
-
)
|
|
1386
|
-
issues = validate_content(
|
|
1387
|
-
content,
|
|
1388
|
-
relative_posix,
|
|
1389
|
-
prior_content,
|
|
1390
|
-
defer_scope_to_caller=True,
|
|
1391
|
-
sibling_directory=resolved_path.parent,
|
|
1392
|
-
)
|
|
1393
|
-
issues.extend(check_wrapper_plumb_through(content, relative_posix))
|
|
1394
|
-
if not issues:
|
|
1395
|
-
return [], []
|
|
1396
|
-
return split_violations_by_scope(issues, all_added_lines_for_file)
|
|
5
|
+
default mode: git diff since merge-base, joined with untracked files
|
|
6
|
+
--staged: validate the staged index; --paths: validate explicit files
|
|
7
|
+
every mode ends by naming how many files it inspected
|
|
1397
8
|
|
|
9
|
+
This entry module wires the ``code_rules_gate_parts`` submodules into one CLI
|
|
10
|
+
and re-exports their surface for the test suite.
|
|
11
|
+
"""
|
|
1398
12
|
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
repository_root: Path,
|
|
1403
|
-
all_added_lines_by_path: dict[Path, set[int]] | None = None,
|
|
1404
|
-
read_staged_content_flag: bool = False,
|
|
1405
|
-
) -> int:
|
|
1406
|
-
"""Run the gate over *all_file_paths* and emit a partitioned report.
|
|
13
|
+
import argparse
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
1407
16
|
|
|
1408
|
-
Args:
|
|
1409
|
-
validate_content: The enforcer ``validate_content`` callable.
|
|
1410
|
-
all_file_paths: File paths to inspect.
|
|
1411
|
-
repository_root: Repository root used to resolve relative paths.
|
|
1412
|
-
all_added_lines_by_path: Optional per-file added-line maps used to
|
|
1413
|
-
partition issues into blocking vs advisory.
|
|
1414
|
-
read_staged_content_flag: When True, validate each file's staged blob
|
|
1415
|
-
so the content source matches the staged diff.
|
|
1416
17
|
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
18
|
+
def _ensure_scripts_directory_on_path() -> None:
|
|
19
|
+
"""Add this file's directory to sys.path so the parts package resolves."""
|
|
20
|
+
scripts_directory = str(Path(__file__).resolve().parent)
|
|
21
|
+
if scripts_directory not in sys.path:
|
|
22
|
+
sys.path.insert(0, scripts_directory)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
_ensure_scripts_directory_on_path()
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
from code_rules_gate_parts import (
|
|
29
|
+
added_line_maps,
|
|
30
|
+
enforcer_loading,
|
|
31
|
+
gate_arguments,
|
|
32
|
+
gate_running,
|
|
33
|
+
git_blob_readers,
|
|
34
|
+
git_file_sets,
|
|
35
|
+
staged_test_running,
|
|
36
|
+
violation_scoping,
|
|
37
|
+
wrapper_plumb_check,
|
|
38
|
+
)
|
|
39
|
+
from pr_loop_shared_constants.code_rules_gate_constants import (
|
|
40
|
+
ALL_POSIX_VENV_PYTHON_RELATIVE_PATH_SEGMENTS,
|
|
41
|
+
ALL_PYTEST_MODULE_INVOCATION,
|
|
42
|
+
ALL_WINDOWS_VENV_PYTHON_RELATIVE_PATH_SEGMENTS,
|
|
43
|
+
EMPTY_FILE_SET_EXIT_CODE,
|
|
44
|
+
EMPTY_FILE_SET_MESSAGE,
|
|
45
|
+
INSPECTED_COUNT_MESSAGE,
|
|
46
|
+
MAXIMUM_STAGED_PYTEST_COMMAND_LINE_CHARACTERS,
|
|
47
|
+
MINIMUM_STAGED_PYTEST_PYTHON_MAJOR,
|
|
48
|
+
MINIMUM_STAGED_PYTEST_PYTHON_MINOR,
|
|
1438
49
|
)
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
)
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
advisory_by_file[resolved] = advisory
|
|
1497
|
-
return blocking_by_file, advisory_by_file, skipped_unreadable_count
|
|
50
|
+
from pr_loop_shared_constants.preflight_constants import (
|
|
51
|
+
PYTEST_NO_TESTS_COLLECTED_EXIT_CODE,
|
|
52
|
+
)
|
|
53
|
+
from terminology_sweep import repository_environment, staged_terminology_findings
|
|
54
|
+
except ImportError as import_error:
|
|
55
|
+
raise ImportError(
|
|
56
|
+
"code_rules_gate: could not import its code_rules_gate_parts submodules; "
|
|
57
|
+
"ensure the pr-loop scripts directory is importable."
|
|
58
|
+
) from import_error
|
|
59
|
+
|
|
60
|
+
subprocess = git_file_sets.subprocess
|
|
61
|
+
|
|
62
|
+
resolve_claude_dev_env_root = enforcer_loading.resolve_claude_dev_env_root
|
|
63
|
+
load_validate_content = enforcer_loading.load_validate_content
|
|
64
|
+
ValidateContentCallable = enforcer_loading.ValidateContentCallable
|
|
65
|
+
|
|
66
|
+
resolve_merge_base = git_file_sets.resolve_merge_base
|
|
67
|
+
paths_from_git_staged = git_file_sets.paths_from_git_staged
|
|
68
|
+
paths_from_git_diff = git_file_sets.paths_from_git_diff
|
|
69
|
+
paths_from_git_untracked = git_file_sets.paths_from_git_untracked
|
|
70
|
+
filter_paths_under_prefixes = git_file_sets.filter_paths_under_prefixes
|
|
71
|
+
is_staged_file_newly_added = git_file_sets.is_staged_file_newly_added
|
|
72
|
+
staged_file_line_count = git_file_sets.staged_file_line_count
|
|
73
|
+
staged_unified_diff_text = git_file_sets.staged_unified_diff_text
|
|
74
|
+
|
|
75
|
+
read_prior_committed_content = git_blob_readers.read_prior_committed_content
|
|
76
|
+
read_staged_content = git_blob_readers.read_staged_content
|
|
77
|
+
staged_blob_exists = git_blob_readers.staged_blob_exists
|
|
78
|
+
|
|
79
|
+
parse_added_line_numbers = violation_scoping.parse_added_line_numbers
|
|
80
|
+
hunk_header_pattern = violation_scoping.hunk_header_pattern
|
|
81
|
+
violation_line_pattern = violation_scoping.violation_line_pattern
|
|
82
|
+
extract_violation_line_number = violation_scoping.extract_violation_line_number
|
|
83
|
+
function_length_span_range = violation_scoping.function_length_span_range
|
|
84
|
+
isolation_span_range = violation_scoping.isolation_span_range
|
|
85
|
+
banned_noun_span_range = violation_scoping.banned_noun_span_range
|
|
86
|
+
duplicate_body_span_range = violation_scoping.duplicate_body_span_range
|
|
87
|
+
inline_duplicate_body_span_lines = violation_scoping.inline_duplicate_body_span_lines
|
|
88
|
+
enclosing_span_range = violation_scoping.enclosing_span_range
|
|
89
|
+
split_violations_by_scope = violation_scoping.split_violations_by_scope
|
|
90
|
+
|
|
91
|
+
added_lines_for_file = added_line_maps.added_lines_for_file
|
|
92
|
+
added_lines_for_renamed_file = added_line_maps.added_lines_for_renamed_file
|
|
93
|
+
renamed_file_source_map_since = added_line_maps.renamed_file_source_map_since
|
|
94
|
+
added_lines_by_file = added_line_maps.added_lines_by_file
|
|
95
|
+
is_file_new_at_base = added_line_maps.is_file_new_at_base
|
|
96
|
+
whole_file_line_set = added_line_maps.whole_file_line_set
|
|
97
|
+
|
|
98
|
+
check_wrapper_plumb_through = wrapper_plumb_check.check_wrapper_plumb_through
|
|
99
|
+
is_code_path = wrapper_plumb_check.is_code_path
|
|
100
|
+
is_test_path = wrapper_plumb_check.is_test_path
|
|
101
|
+
|
|
102
|
+
run_gate = gate_running.run_gate
|
|
103
|
+
print_violation_section = gate_running.print_violation_section
|
|
104
|
+
_collect_partitioned_violations = gate_running._collect_partitioned_violations
|
|
105
|
+
_scoped_violations_for_file = gate_running._scoped_violations_for_file
|
|
106
|
+
_report_terminology_findings = gate_running._report_terminology_findings
|
|
1498
107
|
|
|
1499
108
|
|
|
1500
109
|
def _report_partitioned_violations(
|
|
@@ -1504,407 +113,101 @@ def _report_partitioned_violations(
|
|
|
1504
113
|
is_whole_file_scope: bool,
|
|
1505
114
|
skipped_unreadable_count: int,
|
|
1506
115
|
) -> int:
|
|
1507
|
-
"""Print the
|
|
116
|
+
"""Print the violation sections and return the gate exit code.
|
|
117
|
+
|
|
118
|
+
Importers outside this directory (the code-verifier spawn-preflight hook)
|
|
119
|
+
call this surface with the partition spread across positional arguments,
|
|
120
|
+
so this wrapper keeps that calling shape and folds the pieces into the
|
|
121
|
+
``PartitionedViolations`` tuple the gate-running module consumes.
|
|
1508
122
|
|
|
1509
123
|
Args:
|
|
1510
124
|
blocking_by_file: Blocking violations grouped by resolved file path.
|
|
1511
125
|
advisory_by_file: Advisory violations grouped by resolved file path.
|
|
1512
|
-
repository_root: Repository root
|
|
1513
|
-
is_whole_file_scope: True when
|
|
1514
|
-
|
|
1515
|
-
skipped_unreadable_count: Count of changed files that could not be read;
|
|
1516
|
-
a non-zero count forces a non-zero exit because the gate cannot
|
|
1517
|
-
vouch for those files.
|
|
1518
|
-
|
|
1519
|
-
Returns:
|
|
1520
|
-
Zero when no blocking violation was found and no file was skipped;
|
|
1521
|
-
non-zero otherwise.
|
|
1522
|
-
"""
|
|
1523
|
-
blocking_count = sum(len(each_list) for each_list in blocking_by_file.values())
|
|
1524
|
-
advisory_count = sum(len(each_list) for each_list in advisory_by_file.values())
|
|
1525
|
-
if blocking_count:
|
|
1526
|
-
if is_whole_file_scope:
|
|
1527
|
-
header = f"code_rules_gate: {blocking_count} violation(s) reported."
|
|
1528
|
-
else:
|
|
1529
|
-
header = (
|
|
1530
|
-
f"code_rules_gate: {blocking_count} violation(s) "
|
|
1531
|
-
"introduced on changed lines:"
|
|
1532
|
-
)
|
|
1533
|
-
print_violation_section(header, blocking_by_file, repository_root)
|
|
1534
|
-
if advisory_count:
|
|
1535
|
-
if blocking_count:
|
|
1536
|
-
print("", file=sys.stderr)
|
|
1537
|
-
print_violation_section(
|
|
1538
|
-
(
|
|
1539
|
-
f"code_rules_gate: {advisory_count} pre-existing violation(s) "
|
|
1540
|
-
"in touched files (advisory, not blocking):"
|
|
1541
|
-
),
|
|
1542
|
-
advisory_by_file,
|
|
1543
|
-
repository_root,
|
|
1544
|
-
)
|
|
1545
|
-
if skipped_unreadable_count:
|
|
1546
|
-
print(
|
|
1547
|
-
f"code_rules_gate: {skipped_unreadable_count} file(s) "
|
|
1548
|
-
"skipped due to read errors; gate cannot vouch for those files.",
|
|
1549
|
-
file=sys.stderr,
|
|
1550
|
-
)
|
|
1551
|
-
if blocking_count or skipped_unreadable_count:
|
|
1552
|
-
return 1
|
|
1553
|
-
return 0
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
def _staged_test_file_paths(repository_root: Path) -> list[Path]:
|
|
1557
|
-
"""Return the staged Python test files that exist under a repository.
|
|
1558
|
-
|
|
1559
|
-
Args:
|
|
1560
|
-
repository_root: The repository root whose staged index is read.
|
|
1561
|
-
|
|
1562
|
-
Returns:
|
|
1563
|
-
The staged paths whose extension is Python, whose name matches a
|
|
1564
|
-
test-file pattern, and which exist on disk.
|
|
1565
|
-
"""
|
|
1566
|
-
all_test_paths: list[Path] = []
|
|
1567
|
-
for each_path in paths_from_git_staged(repository_root):
|
|
1568
|
-
if each_path.suffix != PYTHON_FILE_EXTENSION:
|
|
1569
|
-
continue
|
|
1570
|
-
if is_test_path(str(each_path)) and each_path.is_file():
|
|
1571
|
-
all_test_paths.append(each_path)
|
|
1572
|
-
return all_test_paths
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
def _directory_holds_pytest_config(directory: Path) -> bool:
|
|
1576
|
-
"""Return True when *directory* holds a recognized pytest configuration file."""
|
|
1577
|
-
for each_filename, each_required_section in ALL_PYTEST_CONFIG_FILE_SECTIONS:
|
|
1578
|
-
config_path = directory / each_filename
|
|
1579
|
-
if not config_path.is_file():
|
|
1580
|
-
continue
|
|
1581
|
-
if each_required_section is None:
|
|
1582
|
-
return True
|
|
1583
|
-
try:
|
|
1584
|
-
config_text = config_path.read_text(encoding="utf-8", errors="replace")
|
|
1585
|
-
except OSError:
|
|
1586
|
-
continue
|
|
1587
|
-
if each_required_section in config_text:
|
|
1588
|
-
return True
|
|
1589
|
-
return False
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
def _resolve_owning_test_root(test_file_path: Path, repository_root: Path) -> Path:
|
|
1593
|
-
"""Return the nearest ancestor of *test_file_path* that owns a pytest config.
|
|
1594
|
-
|
|
1595
|
-
Walks up from the test file to the first directory holding a pytest config,
|
|
1596
|
-
stopping at *repository_root*, which is the fallback when none is found.
|
|
1597
|
-
|
|
1598
|
-
Args:
|
|
1599
|
-
test_file_path: The staged test file to resolve a root for.
|
|
1600
|
-
repository_root: The repository root that bounds the upward walk.
|
|
1601
|
-
|
|
1602
|
-
Returns:
|
|
1603
|
-
The resolved owning test root directory.
|
|
1604
|
-
"""
|
|
1605
|
-
resolved_repository_root = repository_root.resolve()
|
|
1606
|
-
for each_ancestor in test_file_path.resolve().parents:
|
|
1607
|
-
if _directory_holds_pytest_config(each_ancestor):
|
|
1608
|
-
return each_ancestor
|
|
1609
|
-
if each_ancestor == resolved_repository_root:
|
|
1610
|
-
return resolved_repository_root
|
|
1611
|
-
return resolved_repository_root
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
def _group_staged_tests_by_root(
|
|
1615
|
-
all_test_paths: list[Path],
|
|
1616
|
-
repository_root: Path,
|
|
1617
|
-
) -> dict[Path, list[Path]]:
|
|
1618
|
-
"""Group *all_test_paths* by their owning pytest-config root.
|
|
1619
|
-
|
|
1620
|
-
Args:
|
|
1621
|
-
all_test_paths: The staged test files to partition.
|
|
1622
|
-
repository_root: The repository root that bounds each upward walk.
|
|
1623
|
-
|
|
1624
|
-
Returns:
|
|
1625
|
-
A mapping from owning test root to the staged test files under it.
|
|
1626
|
-
"""
|
|
1627
|
-
tests_by_root: dict[Path, list[Path]] = {}
|
|
1628
|
-
for each_test_path in all_test_paths:
|
|
1629
|
-
owning_root = _resolve_owning_test_root(each_test_path, repository_root)
|
|
1630
|
-
tests_by_root.setdefault(owning_root, []).append(each_test_path)
|
|
1631
|
-
return tests_by_root
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
def _venv_python_relative_path() -> tuple[str, ...]:
|
|
1635
|
-
"""Return the venv-relative python executable path segments for this platform.
|
|
1636
|
-
|
|
1637
|
-
Returns:
|
|
1638
|
-
The Windows ``Scripts/python.exe`` segments on Windows, the POSIX
|
|
1639
|
-
``bin/python`` segments on every other platform.
|
|
1640
|
-
"""
|
|
1641
|
-
if os.name == "nt":
|
|
1642
|
-
return ALL_WINDOWS_VENV_PYTHON_RELATIVE_PATH_SEGMENTS
|
|
1643
|
-
return ALL_POSIX_VENV_PYTHON_RELATIVE_PATH_SEGMENTS
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
def _venv_python_executable(repository_root: Path) -> Path | None:
|
|
1647
|
-
"""Return the first existing venv interpreter under *repository_root*.
|
|
1648
|
-
|
|
1649
|
-
Checks each directory name in ``ALL_VENV_DIRECTORY_NAMES`` in order and
|
|
1650
|
-
returns the platform-specific interpreter path the first time it exists
|
|
1651
|
-
on disk.
|
|
1652
|
-
|
|
1653
|
-
Args:
|
|
1654
|
-
repository_root: The repository root to search for a venv directory.
|
|
1655
|
-
|
|
1656
|
-
Returns:
|
|
1657
|
-
The resolved interpreter path, or None when no candidate exists.
|
|
1658
|
-
"""
|
|
1659
|
-
relative_python_path = _venv_python_relative_path()
|
|
1660
|
-
for each_venv_directory_name in ALL_VENV_DIRECTORY_NAMES:
|
|
1661
|
-
candidate_path = repository_root.joinpath(
|
|
1662
|
-
each_venv_directory_name, *relative_python_path
|
|
1663
|
-
)
|
|
1664
|
-
if candidate_path.is_file():
|
|
1665
|
-
return candidate_path
|
|
1666
|
-
return None
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
def _resolve_gate_python_executable(repository_root: Path) -> str:
|
|
1670
|
-
"""Return the interpreter the staged-test subprocess runs under.
|
|
1671
|
-
|
|
1672
|
-
Resolves in order: the ``CODE_RULES_GATE_PYTHON`` environment variable
|
|
1673
|
-
when set, then the first existing project venv interpreter under
|
|
1674
|
-
*repository_root*, then the interpreter running the gate itself.
|
|
1675
|
-
|
|
1676
|
-
Args:
|
|
1677
|
-
repository_root: The repository root used to locate a project venv.
|
|
1678
|
-
|
|
1679
|
-
Returns:
|
|
1680
|
-
The absolute path (or command name) of the interpreter to invoke.
|
|
1681
|
-
"""
|
|
1682
|
-
configured_python = os.environ.get(CODE_RULES_GATE_PYTHON_ENV_VAR)
|
|
1683
|
-
if configured_python:
|
|
1684
|
-
return configured_python
|
|
1685
|
-
venv_python = _venv_python_executable(repository_root)
|
|
1686
|
-
if venv_python is not None:
|
|
1687
|
-
return str(venv_python)
|
|
1688
|
-
return sys.executable
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
def _staged_pytest_environment() -> dict[str, str]:
|
|
1692
|
-
"""Return the subprocess environment for the staged-test pytest run.
|
|
1693
|
-
|
|
1694
|
-
Starts from ``repository_environment()`` (the GIT_-scrubbed process
|
|
1695
|
-
environment) and, when ``CODE_RULES_GATE_PYTHONPATH`` is set, prepends
|
|
1696
|
-
its value to ``PYTHONPATH`` so a resolved venv interpreter that lacks the
|
|
1697
|
-
project on its default path can still import it.
|
|
1698
|
-
|
|
1699
|
-
Returns:
|
|
1700
|
-
The environment mapping to pass to the staged-test subprocess.
|
|
1701
|
-
"""
|
|
1702
|
-
environment = repository_environment()
|
|
1703
|
-
configured_pythonpath = os.environ.get(CODE_RULES_GATE_PYTHONPATH_ENV_VAR)
|
|
1704
|
-
if not configured_pythonpath:
|
|
1705
|
-
return environment
|
|
1706
|
-
existing_pythonpath = environment.get(PYTHONPATH_ENV_VAR, "")
|
|
1707
|
-
environment[PYTHONPATH_ENV_VAR] = (
|
|
1708
|
-
configured_pythonpath
|
|
1709
|
-
if not existing_pythonpath
|
|
1710
|
-
else os.pathsep.join([configured_pythonpath, existing_pythonpath])
|
|
1711
|
-
)
|
|
1712
|
-
return environment
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
def _relative_pytest_argument(test_path: Path, group_root: Path) -> str:
|
|
1716
|
-
"""Express *test_path* as a pytest argument relative to *group_root*.
|
|
1717
|
-
|
|
1718
|
-
*group_root* is already the pytest working directory, so the relative form
|
|
1719
|
-
names the same file in far fewer characters.
|
|
1720
|
-
|
|
1721
|
-
Example::
|
|
1722
|
-
|
|
1723
|
-
group_root = C:/repo/package
|
|
1724
|
-
ok: C:/repo/package/tests/test_a.py -> "tests/test_a.py"
|
|
1725
|
-
flag: C:/elsewhere/test_b.py -> no relative form; passed absolute
|
|
1726
|
-
|
|
1727
|
-
Args:
|
|
1728
|
-
test_path: The staged test file to express as a pytest argument.
|
|
1729
|
-
group_root: The pytest working directory for the group.
|
|
1730
|
-
|
|
1731
|
-
Returns:
|
|
1732
|
-
The path relative to *group_root* when *test_path* sits under it,
|
|
1733
|
-
otherwise the absolute path unchanged.
|
|
1734
|
-
"""
|
|
1735
|
-
try:
|
|
1736
|
-
return str(test_path.relative_to(group_root))
|
|
1737
|
-
except ValueError:
|
|
1738
|
-
return str(test_path)
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
def _batched_pytest_arguments(
|
|
1742
|
-
all_pytest_arguments: list[str], character_budget: int
|
|
1743
|
-
) -> list[list[str]]:
|
|
1744
|
-
"""Split pytest path arguments into command-line-length-safe batches.
|
|
1745
|
-
|
|
1746
|
-
Windows rejects a command line past 32767 characters with WinError 206, so a
|
|
1747
|
-
large staged set passed in one invocation crashes the gate and denies the
|
|
1748
|
-
commit. Each batch takes arguments until the next one would push its joined
|
|
1749
|
-
length past *character_budget*.
|
|
1750
|
-
|
|
1751
|
-
Example::
|
|
1752
|
-
|
|
1753
|
-
character_budget = 10
|
|
1754
|
-
arguments = ["aaaa", "bbbb", "cccc"]
|
|
1755
|
-
ok: [["aaaa", "bbbb"], ["cccc"]] - every batch within budget
|
|
1756
|
-
flag: [["aaaa", "bbbb", "cccc"]] - 14 characters, over budget
|
|
1757
|
-
|
|
1758
|
-
Args:
|
|
1759
|
-
all_pytest_arguments: The path arguments to distribute, in order.
|
|
1760
|
-
character_budget: The joined length each batch must stay within. An
|
|
1761
|
-
argument wider than the budget on its own still lands in a batch
|
|
1762
|
-
by itself, so no argument is ever dropped.
|
|
126
|
+
repository_root: Repository root for computing relative paths.
|
|
127
|
+
is_whole_file_scope: True when findings cover whole files, not a diff.
|
|
128
|
+
skipped_unreadable_count: Count of files skipped due to read errors.
|
|
1763
129
|
|
|
1764
130
|
Returns:
|
|
1765
|
-
|
|
131
|
+
The gate exit code: 1 when anything blocks, 0 otherwise.
|
|
1766
132
|
"""
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
""
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
)
|
|
1801
|
-
if pytest_process.returncode == PYTEST_NO_TESTS_COLLECTED_EXIT_CODE:
|
|
1802
|
-
return 0
|
|
1803
|
-
return pytest_process.returncode
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
def _run_pytest_for_group(
|
|
1807
|
-
group_root: Path, all_group_test_paths: list[Path], repository_root: Path
|
|
1808
|
-
) -> int:
|
|
1809
|
-
"""Run pytest over one group's test files with the working directory at its root.
|
|
1810
|
-
|
|
1811
|
-
Paths are passed relative to *group_root* (the subprocess working directory)
|
|
1812
|
-
and split into batches whose joined command line stays within
|
|
1813
|
-
``MAXIMUM_STAGED_PYTEST_COMMAND_LINE_CHARACTERS``, so a large staged set never
|
|
1814
|
-
trips the Windows 32767-character limit. A staged set that fits the budget runs
|
|
1815
|
-
as one invocation, exactly as an unbatched run did.
|
|
1816
|
-
|
|
1817
|
-
Example::
|
|
1818
|
-
|
|
1819
|
-
ok: 3 staged tests -> 1 pytest invocation, argv well under the budget
|
|
1820
|
-
flag: 800 staged tests -> 1 pytest invocation, argv over the Windows limit
|
|
1821
|
-
(WinError 206); batching splits it across several
|
|
133
|
+
return gate_running._report_partitioned_violations(
|
|
134
|
+
(blocking_by_file, advisory_by_file, skipped_unreadable_count),
|
|
135
|
+
repository_root,
|
|
136
|
+
is_whole_file_scope,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
run_staged_test_files = staged_test_running.run_staged_test_files
|
|
140
|
+
_staged_test_file_paths = staged_test_running._staged_test_file_paths
|
|
141
|
+
_resolve_owning_test_root = staged_test_running._resolve_owning_test_root
|
|
142
|
+
_group_staged_tests_by_root = staged_test_running._group_staged_tests_by_root
|
|
143
|
+
_batched_pytest_arguments = staged_test_running._batched_pytest_arguments
|
|
144
|
+
_resolve_gate_python_executable = staged_test_running._resolve_gate_python_executable
|
|
145
|
+
_staged_pytest_environment = staged_test_running._staged_pytest_environment
|
|
146
|
+
_relative_pytest_argument = staged_test_running._relative_pytest_argument
|
|
147
|
+
_run_pytest_for_group = staged_test_running._run_pytest_for_group
|
|
148
|
+
|
|
149
|
+
parse_arguments = gate_arguments.parse_arguments
|
|
150
|
+
|
|
151
|
+
__all__ = [
|
|
152
|
+
"ALL_POSIX_VENV_PYTHON_RELATIVE_PATH_SEGMENTS",
|
|
153
|
+
"ALL_PYTEST_MODULE_INVOCATION",
|
|
154
|
+
"ALL_WINDOWS_VENV_PYTHON_RELATIVE_PATH_SEGMENTS",
|
|
155
|
+
"MAXIMUM_STAGED_PYTEST_COMMAND_LINE_CHARACTERS",
|
|
156
|
+
"MINIMUM_STAGED_PYTEST_PYTHON_MAJOR",
|
|
157
|
+
"MINIMUM_STAGED_PYTEST_PYTHON_MINOR",
|
|
158
|
+
"PYTEST_NO_TESTS_COLLECTED_EXIT_CODE",
|
|
159
|
+
"repository_environment",
|
|
160
|
+
"subprocess",
|
|
161
|
+
]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def added_lines_for_staged_file(repository_root: Path, relative_path_posix: str) -> set[int]:
|
|
165
|
+
"""Return added line numbers within the staged diff for one file.
|
|
1822
166
|
|
|
1823
167
|
Args:
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
repository_root: The repository root used to resolve a project venv
|
|
1827
|
-
interpreter when ``CODE_RULES_GATE_PYTHON`` is not set.
|
|
168
|
+
repository_root: Repository root used as the ``git -C`` target.
|
|
169
|
+
relative_path_posix: Repository-relative POSIX path to inspect.
|
|
1828
170
|
|
|
1829
171
|
Returns:
|
|
1830
|
-
|
|
1831
|
-
|
|
172
|
+
Line numbers added in the staged diff, or the whole staged blob when
|
|
173
|
+
the file is newly added.
|
|
1832
174
|
"""
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
)
|
|
1841
|
-
all_batches = _batched_pytest_arguments(
|
|
1842
|
-
[
|
|
1843
|
-
_relative_pytest_argument(each_path, group_root)
|
|
1844
|
-
for each_path in all_group_test_paths
|
|
1845
|
-
],
|
|
1846
|
-
MAXIMUM_STAGED_PYTEST_COMMAND_LINE_CHARACTERS - fixed_command_length,
|
|
1847
|
-
)
|
|
1848
|
-
first_failing_exit_code = 0
|
|
1849
|
-
for each_batch in all_batches:
|
|
1850
|
-
batch_exit_code = _pytest_batch_exit_code(
|
|
1851
|
-
[*all_fixed_command, *each_batch], group_root
|
|
1852
|
-
)
|
|
1853
|
-
if batch_exit_code != 0 and first_failing_exit_code == 0:
|
|
1854
|
-
first_failing_exit_code = batch_exit_code
|
|
1855
|
-
return first_failing_exit_code
|
|
1856
|
-
|
|
175
|
+
diff_text = staged_unified_diff_text(repository_root, relative_path_posix)
|
|
176
|
+
if diff_text.strip():
|
|
177
|
+
return parse_added_line_numbers(diff_text)
|
|
178
|
+
if is_staged_file_newly_added(repository_root, relative_path_posix):
|
|
179
|
+
total_lines = staged_file_line_count(repository_root, relative_path_posix)
|
|
180
|
+
if total_lines > 0:
|
|
181
|
+
return set(range(1, total_lines + 1))
|
|
182
|
+
return set()
|
|
1857
183
|
|
|
1858
|
-
def run_staged_test_files(repository_root: Path) -> int:
|
|
1859
|
-
"""Run pytest over the staged test files and return the gate exit code.
|
|
1860
184
|
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
185
|
+
def added_lines_by_file_staged(
|
|
186
|
+
repository_root: Path, all_file_paths: list[Path]
|
|
187
|
+
) -> dict[Path, set[int]]:
|
|
188
|
+
"""Build a per-file map of staged-added line numbers.
|
|
1865
189
|
|
|
1866
190
|
Args:
|
|
1867
|
-
repository_root:
|
|
191
|
+
repository_root: Repository root for diff invocations.
|
|
192
|
+
all_file_paths: File paths whose staged-added lines are collected.
|
|
1868
193
|
|
|
1869
194
|
Returns:
|
|
1870
|
-
|
|
1871
|
-
when every group passes. The first failing group's non-zero pytest exit
|
|
1872
|
-
code otherwise, which blocks the commit.
|
|
195
|
+
Mapping from resolved file path to its staged-added line numbers.
|
|
1873
196
|
"""
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
if group_exit_code != 0:
|
|
1884
|
-
print(
|
|
1885
|
-
STAGED_TEST_GROUP_FAILURE_MESSAGE.format(group_root=each_group_root),
|
|
1886
|
-
file=sys.stderr,
|
|
1887
|
-
)
|
|
1888
|
-
if first_failing_exit_code == 0:
|
|
1889
|
-
first_failing_exit_code = group_exit_code
|
|
1890
|
-
if first_failing_exit_code != 0:
|
|
1891
|
-
print(STAGED_TEST_FAILURE_HEADER, file=sys.stderr)
|
|
1892
|
-
return first_failing_exit_code
|
|
197
|
+
resolved_root = repository_root.resolve()
|
|
198
|
+
added_by_path: dict[Path, set[int]] = {}
|
|
199
|
+
for each_path in all_file_paths:
|
|
200
|
+
resolved = added_line_maps._resolved_under_root(each_path, resolved_root)
|
|
201
|
+
if resolved is None:
|
|
202
|
+
continue
|
|
203
|
+
relative_posix = str(resolved.relative_to(resolved_root)).replace("\\", "/")
|
|
204
|
+
added_by_path[resolved] = added_lines_for_staged_file(resolved_root, relative_posix)
|
|
205
|
+
return added_by_path
|
|
1893
206
|
|
|
1894
207
|
|
|
1895
208
|
def _staged_pytest_exit_code_for_current_python(repository_root: Path) -> int:
|
|
1896
209
|
"""Run the staged test files, or skip them on a Python below the minimum.
|
|
1897
210
|
|
|
1898
|
-
::
|
|
1899
|
-
|
|
1900
|
-
running (3, 11), minimum (3, 12) -> log a notice and return 0 (skipped)
|
|
1901
|
-
running (3, 12), minimum (3, 12) -> run pytest over the staged tests
|
|
1902
|
-
|
|
1903
|
-
The staged tests call newer-Python APIs, so an older interpreter fails them
|
|
1904
|
-
for the environment rather than for a code defect. On such a runtime this
|
|
1905
|
-
step is skipped so the gap does not block the commit, while the terminology
|
|
1906
|
-
and code-rules checks still run.
|
|
1907
|
-
|
|
1908
211
|
Args:
|
|
1909
212
|
repository_root: The repository root whose staged test files run.
|
|
1910
213
|
|
|
@@ -1918,87 +221,102 @@ def _staged_pytest_exit_code_for_current_python(repository_root: Path) -> int:
|
|
|
1918
221
|
MINIMUM_STAGED_PYTEST_PYTHON_MINOR,
|
|
1919
222
|
)
|
|
1920
223
|
if running_version < minimum_version:
|
|
1921
|
-
|
|
224
|
+
sys.stderr.write(
|
|
1922
225
|
f"code_rules_gate: Python {running_version} is below the staged-test "
|
|
1923
|
-
f"minimum {minimum_version}; skipping the staged "
|
|
1924
|
-
"pytest step (environment gap, not a code failure).",
|
|
1925
|
-
file=sys.stderr,
|
|
226
|
+
f"minimum {minimum_version}; skipping the staged pytest step.\n"
|
|
1926
227
|
)
|
|
1927
228
|
return 0
|
|
1928
229
|
return run_staged_test_files(repository_root)
|
|
1929
230
|
|
|
1930
231
|
|
|
1931
|
-
def
|
|
1932
|
-
"""
|
|
232
|
+
def _deduplicate_paths(all_paths: list[Path]) -> list[Path]:
|
|
233
|
+
"""Return *all_paths* with duplicates removed, preserving first-seen order."""
|
|
234
|
+
return list(dict.fromkeys(all_paths))
|
|
1933
235
|
|
|
1934
|
-
|
|
1935
|
-
|
|
236
|
+
|
|
237
|
+
def _report_empty_file_set() -> int:
|
|
238
|
+
"""Report an empty resolved file set loudly and exit non-zero.
|
|
239
|
+
|
|
240
|
+
Zero candidate files means the gate inspected nothing, and a bad merge
|
|
241
|
+
base or a wrong directory produces exactly this state. A quiet pass here
|
|
242
|
+
would be trusted like a real pass (issue #62), so the run refuses with
|
|
243
|
+
its own exit code, distinct from the violation and error codes. A set
|
|
244
|
+
emptied only by the ``--only-under`` scope never reaches this reporter.
|
|
1936
245
|
"""
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
246
|
+
sys.stderr.write(EMPTY_FILE_SET_MESSAGE + "\n")
|
|
247
|
+
sys.stderr.write(INSPECTED_COUNT_MESSAGE.format(inspected_count=0) + "\n")
|
|
248
|
+
return EMPTY_FILE_SET_EXIT_CODE
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _run_explicit_paths_mode(
|
|
252
|
+
validate_content: enforcer_loading.ValidateContentCallable,
|
|
253
|
+
arguments: argparse.Namespace,
|
|
254
|
+
repository_root: Path,
|
|
255
|
+
) -> int:
|
|
256
|
+
"""Validate the explicit paths named on the command line."""
|
|
257
|
+
all_explicit_paths = [repository_root / each_path for each_path in arguments.paths]
|
|
258
|
+
return run_gate(
|
|
259
|
+
validate_content,
|
|
260
|
+
all_explicit_paths,
|
|
261
|
+
repository_root,
|
|
262
|
+
all_added_lines_by_path=None,
|
|
1942
263
|
)
|
|
1943
|
-
for each_finding in all_findings:
|
|
1944
|
-
print(f" {each_finding}", file=sys.stderr)
|
|
1945
264
|
|
|
1946
265
|
|
|
1947
|
-
def
|
|
1948
|
-
|
|
266
|
+
def _run_staged_mode(
|
|
267
|
+
validate_content: enforcer_loading.ValidateContentCallable,
|
|
268
|
+
arguments: argparse.Namespace,
|
|
269
|
+
repository_root: Path,
|
|
270
|
+
) -> int:
|
|
271
|
+
"""Validate the staged changes, run staged tests, and sweep terminology."""
|
|
272
|
+
_report_terminology_findings(staged_terminology_findings(repository_root))
|
|
273
|
+
staged_test_exit_code = _staged_pytest_exit_code_for_current_python(repository_root)
|
|
274
|
+
staged_file_paths = filter_paths_under_prefixes(
|
|
275
|
+
paths_from_git_staged(repository_root), repository_root, arguments.only_under
|
|
276
|
+
)
|
|
277
|
+
if not staged_file_paths:
|
|
278
|
+
sys.stderr.write(INSPECTED_COUNT_MESSAGE.format(inspected_count=0) + "\n")
|
|
279
|
+
return staged_test_exit_code
|
|
280
|
+
staged_added_lines = added_lines_by_file_staged(repository_root, staged_file_paths)
|
|
281
|
+
gate_exit_code = run_gate(
|
|
282
|
+
validate_content,
|
|
283
|
+
staged_file_paths,
|
|
284
|
+
repository_root,
|
|
285
|
+
all_added_lines_by_path=staged_added_lines,
|
|
286
|
+
should_read_staged_content=True,
|
|
287
|
+
)
|
|
288
|
+
return gate_exit_code or staged_test_exit_code
|
|
289
|
+
|
|
1949
290
|
|
|
1950
|
-
|
|
1951
|
-
|
|
291
|
+
def _run_diff_mode(
|
|
292
|
+
validate_content: enforcer_loading.ValidateContentCallable,
|
|
293
|
+
arguments: argparse.Namespace,
|
|
294
|
+
repository_root: Path,
|
|
295
|
+
) -> int:
|
|
296
|
+
"""Validate the merge-base diff joined with the untracked files.
|
|
1952
297
|
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
298
|
+
Zero candidates means nothing was inspected (bad wiring looks the same),
|
|
299
|
+
so that run refuses loudly. A set emptied only by the ``--only-under``
|
|
300
|
+
scope flows through ``run_gate`` over zero files and exits clean.
|
|
1956
301
|
"""
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
"Default file set: git diff --name-only merge-base(base)..HEAD."
|
|
1961
|
-
),
|
|
1962
|
-
)
|
|
1963
|
-
parser.add_argument(
|
|
1964
|
-
"--repo-root",
|
|
1965
|
-
type=Path,
|
|
1966
|
-
default=None,
|
|
1967
|
-
help="Repository root (default: cwd).",
|
|
302
|
+
all_candidate_paths = _deduplicate_paths(
|
|
303
|
+
paths_from_git_diff(repository_root, arguments.base)
|
|
304
|
+
+ paths_from_git_untracked(repository_root)
|
|
1968
305
|
)
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
)
|
|
1974
|
-
parser.add_argument(
|
|
1975
|
-
"--staged",
|
|
1976
|
-
action="store_true",
|
|
1977
|
-
default=False,
|
|
1978
|
-
help=(
|
|
1979
|
-
"Scope to staged changes only (git diff --cached). "
|
|
1980
|
-
"Blocks on violations introduced on staged-added lines; "
|
|
1981
|
-
"reports pre-existing violations in touched files as advisory."
|
|
1982
|
-
),
|
|
306
|
+
if not all_candidate_paths:
|
|
307
|
+
return _report_empty_file_set()
|
|
308
|
+
file_paths = filter_paths_under_prefixes(
|
|
309
|
+
all_candidate_paths, repository_root, arguments.only_under
|
|
1983
310
|
)
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
action="append",
|
|
1987
|
-
default=[],
|
|
1988
|
-
dest="only_under",
|
|
1989
|
-
metavar="PREFIX",
|
|
1990
|
-
help=(
|
|
1991
|
-
"After resolving the merge-base diff, keep only files whose repo-relative path "
|
|
1992
|
-
"uses POSIX slashes and starts with PREFIX or equals PREFIX (repeatable)."
|
|
1993
|
-
),
|
|
311
|
+
scoped_added_lines = (
|
|
312
|
+
added_lines_by_file(repository_root, arguments.base, file_paths) if file_paths else {}
|
|
1994
313
|
)
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
314
|
+
return run_gate(
|
|
315
|
+
validate_content,
|
|
316
|
+
file_paths,
|
|
317
|
+
repository_root,
|
|
318
|
+
all_added_lines_by_path=scoped_added_lines,
|
|
2000
319
|
)
|
|
2001
|
-
return parser.parse_args(all_arguments)
|
|
2002
320
|
|
|
2003
321
|
|
|
2004
322
|
def main(all_arguments: list[str]) -> int:
|
|
@@ -2008,63 +326,18 @@ def main(all_arguments: list[str]) -> int:
|
|
|
2008
326
|
all_arguments: Command-line argument list forwarded to argparse.
|
|
2009
327
|
|
|
2010
328
|
Returns:
|
|
2011
|
-
The exit code
|
|
2012
|
-
violations).
|
|
329
|
+
The gate exit code for the selected mode.
|
|
2013
330
|
"""
|
|
2014
331
|
arguments = parse_arguments(all_arguments)
|
|
2015
332
|
repository_root = (
|
|
2016
|
-
arguments.repo_root.resolve()
|
|
2017
|
-
if arguments.repo_root is not None
|
|
2018
|
-
else Path.cwd().resolve()
|
|
333
|
+
arguments.repo_root.resolve() if arguments.repo_root is not None else Path.cwd().resolve()
|
|
2019
334
|
)
|
|
2020
335
|
validate_content = load_validate_content()
|
|
2021
336
|
if arguments.paths:
|
|
2022
|
-
|
|
2023
|
-
return run_gate(
|
|
2024
|
-
validate_content, file_paths, repository_root, all_added_lines_by_path=None
|
|
2025
|
-
)
|
|
337
|
+
return _run_explicit_paths_mode(validate_content, arguments, repository_root)
|
|
2026
338
|
if arguments.staged:
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
staged_test_exit_code = _staged_pytest_exit_code_for_current_python(
|
|
2030
|
-
repository_root
|
|
2031
|
-
)
|
|
2032
|
-
staged_file_paths = paths_from_git_staged(repository_root)
|
|
2033
|
-
staged_file_paths = filter_paths_under_prefixes(
|
|
2034
|
-
staged_file_paths,
|
|
2035
|
-
repository_root,
|
|
2036
|
-
arguments.only_under,
|
|
2037
|
-
)
|
|
2038
|
-
if not staged_file_paths:
|
|
2039
|
-
return staged_test_exit_code
|
|
2040
|
-
staged_added_lines = added_lines_by_file_staged(
|
|
2041
|
-
repository_root, staged_file_paths
|
|
2042
|
-
)
|
|
2043
|
-
gate_exit_code = run_gate(
|
|
2044
|
-
validate_content,
|
|
2045
|
-
staged_file_paths,
|
|
2046
|
-
repository_root,
|
|
2047
|
-
all_added_lines_by_path=staged_added_lines,
|
|
2048
|
-
read_staged_content_flag=True,
|
|
2049
|
-
)
|
|
2050
|
-
return gate_exit_code or staged_test_exit_code
|
|
2051
|
-
file_paths = paths_from_git_diff(repository_root, arguments.base)
|
|
2052
|
-
file_paths = filter_paths_under_prefixes(
|
|
2053
|
-
file_paths,
|
|
2054
|
-
repository_root,
|
|
2055
|
-
arguments.only_under,
|
|
2056
|
-
)
|
|
2057
|
-
if not file_paths:
|
|
2058
|
-
return 0
|
|
2059
|
-
scoped_added_lines = added_lines_by_file(
|
|
2060
|
-
repository_root, arguments.base, file_paths
|
|
2061
|
-
)
|
|
2062
|
-
return run_gate(
|
|
2063
|
-
validate_content,
|
|
2064
|
-
file_paths,
|
|
2065
|
-
repository_root,
|
|
2066
|
-
all_added_lines_by_path=scoped_added_lines,
|
|
2067
|
-
)
|
|
339
|
+
return _run_staged_mode(validate_content, arguments, repository_root)
|
|
340
|
+
return _run_diff_mode(validate_content, arguments, repository_root)
|
|
2068
341
|
|
|
2069
342
|
|
|
2070
343
|
if __name__ == "__main__":
|