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
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# tdd_enforcer_parts
|
|
2
|
+
|
|
3
|
+
The concern modules `tdd_enforcer.py` wires together to run the TDD gate. Each
|
|
4
|
+
module owns one concern; the entry hook imports them and re-exports their
|
|
5
|
+
surface for the test suite.
|
|
6
|
+
|
|
7
|
+
## Modules
|
|
8
|
+
|
|
9
|
+
| File | Purpose |
|
|
10
|
+
|---|---|
|
|
11
|
+
| `path_classification.py` | Classifies a write target (docs, tests, `.claude` trees) the gate skips, and extracts the written text from a Write, Edit, or MultiEdit payload |
|
|
12
|
+
| `content_analysis.py` | Decides whether a payload is constants-only, and whether an Edit or MultiEdit merely removes or reorders imports |
|
|
13
|
+
| `candidate_paths.py` | Resolves the candidate test files whose freshness can satisfy the gate for a production file |
|
|
14
|
+
| `freshness.py` | Checks whether a candidate test was modified within the window and holds a real test function |
|
|
15
|
+
| `git_tracking.py` | Detects a write that restores a git-tracked file absent on disk, so a remove-then-Write rewrite is exempt from the gate |
|
|
16
|
+
| `decisions.py` | Builds the deny reason and writes the allow or deny decision JSON to stdout |
|
|
17
|
+
| `__init__.py` | Package marker |
|
|
18
|
+
|
|
19
|
+
## Subdirectories
|
|
20
|
+
|
|
21
|
+
| Entry | Description |
|
|
22
|
+
|---|---|
|
|
23
|
+
| `config/` | The freshness window, the ancestor-walk limit, the git-tracking command tokens, the source-file extension sets, and the join separator (`tdd_enforcer_constants.py`) |
|
|
24
|
+
| `tests/` | pytest suite with one test module per module above |
|
|
25
|
+
|
|
26
|
+
## Running tests
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
python -m pytest packages/claude-dev-env/hooks/blocking/tdd_enforcer_parts/tests/
|
|
30
|
+
```
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Decomposed concern modules the ``tdd_enforcer`` entry hook wires together."""
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Resolve the test files whose freshness can satisfy the gate for a module.
|
|
2
|
+
|
|
3
|
+
Given a production file, builds every path a matching test could live at: the
|
|
4
|
+
flat sibling ``test_<stem>.py``, the package-mirroring candidates under an
|
|
5
|
+
ancestor ``tests`` directory, and the split test family for ``code_rules_*``
|
|
6
|
+
modules.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from tdd_enforcer_parts.config.tdd_enforcer_constants import (
|
|
12
|
+
ALL_JAVASCRIPT_TEST_EXTENSIONS,
|
|
13
|
+
ALL_REPO_BOUNDARY_SENTINELS,
|
|
14
|
+
PARENT_WALK_LIMIT,
|
|
15
|
+
PYTHON_SOURCE_EXTENSION,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _tests_directory_name() -> str:
|
|
20
|
+
return "tests"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _parent_walk_limit() -> int:
|
|
24
|
+
return PARENT_WALK_LIMIT
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _repo_boundary_sentinels() -> frozenset[str]:
|
|
28
|
+
return ALL_REPO_BOUNDARY_SENTINELS
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _split_module_stem_prefix() -> str:
|
|
32
|
+
return "code_rules_"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _split_test_family_glob() -> str:
|
|
36
|
+
return "test_code_rules_enforcer_*.py"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _javascript_test_extensions() -> frozenset[str]:
|
|
40
|
+
return ALL_JAVASCRIPT_TEST_EXTENSIONS
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _is_repo_boundary(candidate_directory: Path) -> bool:
|
|
44
|
+
for each_sentinel in _repo_boundary_sentinels():
|
|
45
|
+
if (candidate_directory / each_sentinel).exists():
|
|
46
|
+
return True
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _ancestor_tests_directories(start_directory: Path) -> list[tuple[Path, Path]]:
|
|
51
|
+
"""Collect each ancestor's sibling ``tests`` directory up to the repo root.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
start_directory: Directory of the production file under edit.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
Ordered ``(ancestor, tests_directory)`` pairs, nearer ancestors first.
|
|
58
|
+
"""
|
|
59
|
+
all_pairs: list[tuple[Path, Path]] = []
|
|
60
|
+
current_directory = start_directory
|
|
61
|
+
remaining_steps = _parent_walk_limit()
|
|
62
|
+
while remaining_steps > 0:
|
|
63
|
+
remaining_steps -= 1
|
|
64
|
+
sibling_tests = current_directory / _tests_directory_name()
|
|
65
|
+
if sibling_tests.is_dir():
|
|
66
|
+
all_pairs.append((current_directory, sibling_tests))
|
|
67
|
+
if _is_repo_boundary(current_directory):
|
|
68
|
+
break
|
|
69
|
+
if current_directory.parent == current_directory:
|
|
70
|
+
break
|
|
71
|
+
current_directory = current_directory.parent
|
|
72
|
+
return all_pairs
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _split_family_candidates(directory: Path, stem: str) -> list[Path]:
|
|
76
|
+
if not stem.startswith(_split_module_stem_prefix()):
|
|
77
|
+
return []
|
|
78
|
+
return sorted(directory.glob(_split_test_family_glob()))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _flat_stem_candidates(directory: Path, stem: str) -> list[Path]:
|
|
82
|
+
return [directory / f"test_{stem}.py", directory / f"{stem}_test.py"]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _mirrored_nested_candidates(
|
|
86
|
+
directory: Path, ancestor: Path, tests_directory: Path, stem: str
|
|
87
|
+
) -> list[Path]:
|
|
88
|
+
all_candidates: list[Path] = []
|
|
89
|
+
nested_directory = tests_directory
|
|
90
|
+
for each_relative_part in directory.relative_to(ancestor).parts:
|
|
91
|
+
nested_directory = nested_directory / each_relative_part
|
|
92
|
+
all_candidates.append(nested_directory / f"test_{stem}.py")
|
|
93
|
+
return all_candidates
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _nested_tests_candidates(directory: Path, stem: str) -> list[Path]:
|
|
97
|
+
all_candidates: list[Path] = []
|
|
98
|
+
for each_ancestor, each_tests_directory in _ancestor_tests_directories(directory):
|
|
99
|
+
all_candidates.append(each_tests_directory / f"test_{stem}.py")
|
|
100
|
+
all_candidates.extend(
|
|
101
|
+
_mirrored_nested_candidates(directory, each_ancestor, each_tests_directory, stem)
|
|
102
|
+
)
|
|
103
|
+
return all_candidates
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _python_candidate_test_paths(directory: Path, stem: str) -> list[Path]:
|
|
107
|
+
all_candidates = _flat_stem_candidates(directory, stem)
|
|
108
|
+
all_candidates.extend(_nested_tests_candidates(directory, stem))
|
|
109
|
+
all_candidates.extend(_split_family_candidates(directory, stem))
|
|
110
|
+
return all_candidates
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _javascript_candidate_test_paths(directory: Path, stem: str, extension: str) -> list[Path]:
|
|
114
|
+
return [directory / f"{stem}.test{extension}", directory / f"{stem}.spec{extension}"]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def candidate_test_paths_for(production_path: Path) -> list[Path]:
|
|
118
|
+
"""Return the test files that can satisfy the gate for a production file.
|
|
119
|
+
|
|
120
|
+
::
|
|
121
|
+
|
|
122
|
+
pkg/orders.py -> pkg/test_orders.py, pkg/orders_test.py,
|
|
123
|
+
pkg/tests/test_orders.py (and package mirrors)
|
|
124
|
+
|
|
125
|
+
``code_rules_*`` Python modules also gain the split test family. Plain
|
|
126
|
+
stem-derived candidates always come first.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
production_path: The production source file being written or edited.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
Ordered candidate test paths; stem-derived siblings precede any
|
|
133
|
+
split-family additions.
|
|
134
|
+
"""
|
|
135
|
+
directory = production_path.parent
|
|
136
|
+
stem = production_path.stem
|
|
137
|
+
extension = production_path.suffix.lower()
|
|
138
|
+
if extension == PYTHON_SOURCE_EXTENSION:
|
|
139
|
+
return _python_candidate_test_paths(directory, stem)
|
|
140
|
+
if extension in _javascript_test_extensions():
|
|
141
|
+
return _javascript_candidate_test_paths(directory, stem, extension)
|
|
142
|
+
return []
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Configuration home for the TDD-enforcer parts constants."""
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Constants for the TDD-enforcer hook and its decomposed parts modules.
|
|
2
|
+
|
|
3
|
+
Centralizes the freshness window, the ancestor-walk limit, the git-tracking
|
|
4
|
+
command tokens, the source-file extension sets, and the join separator.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
FRESHNESS_WINDOW_SECONDS: int = 600
|
|
10
|
+
PARENT_WALK_LIMIT: int = 10
|
|
11
|
+
GIT_LS_FILES_TIMEOUT_SECONDS: int = 10
|
|
12
|
+
GIT_EXECUTABLE_NAME: str = "git"
|
|
13
|
+
GIT_LS_FILES_SUBCOMMAND: str = "ls-files"
|
|
14
|
+
GIT_PATHSPEC_SEPARATOR: str = "--"
|
|
15
|
+
PYTHON_SOURCE_EXTENSION: str = ".py"
|
|
16
|
+
ENTRY_HOOK_FILE_NAME: str = "tdd_enforcer.py"
|
|
17
|
+
NEWLINE_JOIN_SEPARATOR: str = "\n"
|
|
18
|
+
ALL_PRODUCTION_EXTENSIONS: frozenset[str] = frozenset({".py", ".ts", ".tsx", ".js", ".jsx"})
|
|
19
|
+
ALL_SKIP_EXTENSIONS: frozenset[str] = frozenset(
|
|
20
|
+
{".md", ".json", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".txt"}
|
|
21
|
+
)
|
|
22
|
+
ALL_SKIP_NAME_PATTERNS: frozenset[str] = frozenset(
|
|
23
|
+
{"test_", "_test.", ".test.", "tests/", "__tests__/", "conftest", "fixture", "mock", "stub"}
|
|
24
|
+
)
|
|
25
|
+
ALL_DIRECTORY_SKIP_COMPONENTS: frozenset[str] = frozenset(
|
|
26
|
+
{"conftest", "fixture", "fixtures", "mock", "mocks", "stub", "stubs"}
|
|
27
|
+
)
|
|
28
|
+
ALL_DOTCLAUDE_PATH_SEGMENTS: frozenset[str] = frozenset({".claude"})
|
|
29
|
+
ALL_REPO_BOUNDARY_SENTINELS: frozenset[str] = frozenset(
|
|
30
|
+
{".git", "pyproject.toml", "package.json", "Cargo.toml", "go.mod"}
|
|
31
|
+
)
|
|
32
|
+
ALL_JAVASCRIPT_TEST_EXTENSIONS: frozenset[str] = frozenset({".tsx", ".ts", ".jsx", ".js"})
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""Content analysis for the TDD-enforcer constants and import-only exemptions.
|
|
2
|
+
|
|
3
|
+
Answers two questions about a payload: is the written module only module-level
|
|
4
|
+
constants (no behavior to test), and does an Edit or MultiEdit merely remove or
|
|
5
|
+
reorder imports on a file that already has no behavior worth a fresh test.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import ast
|
|
9
|
+
from collections import Counter
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _constants_only_allowed_node_types() -> tuple[type, ...]:
|
|
13
|
+
return (ast.Import, ast.ImportFrom, ast.Assign, ast.AnnAssign)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _is_module_docstring_expression(module_level_node: ast.stmt) -> bool:
|
|
17
|
+
if not isinstance(module_level_node, ast.Expr):
|
|
18
|
+
return False
|
|
19
|
+
expression_value = module_level_node.value
|
|
20
|
+
if not isinstance(expression_value, ast.Constant):
|
|
21
|
+
return False
|
|
22
|
+
return isinstance(expression_value.value, str)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _safe_constant_functions() -> frozenset[str]:
|
|
26
|
+
return frozenset({"Path", "frozenset"})
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _safe_constant_attribute_calls() -> frozenset[tuple[str, str]]:
|
|
30
|
+
return frozenset({("re", "compile")})
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _node_is_unsafe_construct(subnode: ast.AST) -> bool:
|
|
34
|
+
return isinstance(
|
|
35
|
+
subnode,
|
|
36
|
+
(ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp, ast.Lambda),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _attribute_call_is_unsafe(function_node: ast.Attribute) -> bool:
|
|
41
|
+
if not isinstance(function_node.value, ast.Name):
|
|
42
|
+
return True
|
|
43
|
+
pair = (function_node.value.id, function_node.attr)
|
|
44
|
+
return pair not in _safe_constant_attribute_calls()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _call_is_unsafe(call_node: ast.Call) -> bool:
|
|
48
|
+
function_node = call_node.func
|
|
49
|
+
if isinstance(function_node, ast.Name):
|
|
50
|
+
return function_node.id not in _safe_constant_functions()
|
|
51
|
+
if isinstance(function_node, ast.Attribute):
|
|
52
|
+
return _attribute_call_is_unsafe(function_node)
|
|
53
|
+
return True
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _rhs_has_unsafe_call(rhs_node: ast.AST) -> bool:
|
|
57
|
+
"""Return True when a constant's value contains a non-allowlisted call.
|
|
58
|
+
|
|
59
|
+
Safe calls are value constructors (``Path(...)``, ``re.compile(...)``) that
|
|
60
|
+
build objects without side effects; any other call or a comprehension reads
|
|
61
|
+
as import-time behavior.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
rhs_node: The assignment's right-hand-side expression node.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
True when an unsafe call or comprehension appears anywhere within it.
|
|
68
|
+
"""
|
|
69
|
+
for each_subnode in ast.walk(rhs_node):
|
|
70
|
+
if isinstance(each_subnode, ast.Call) and _call_is_unsafe(each_subnode):
|
|
71
|
+
return True
|
|
72
|
+
if _node_is_unsafe_construct(each_subnode):
|
|
73
|
+
return True
|
|
74
|
+
return False
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _assignment_rhs_is_safe(node: ast.stmt) -> bool:
|
|
78
|
+
right_hand_side = getattr(node, "value", None)
|
|
79
|
+
if right_hand_side is None:
|
|
80
|
+
return True
|
|
81
|
+
return not _rhs_has_unsafe_call(right_hand_side)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _top_level_node_is_constant(node: ast.stmt) -> bool:
|
|
85
|
+
if isinstance(node, (ast.Assign, ast.AnnAssign)):
|
|
86
|
+
return _assignment_rhs_is_safe(node)
|
|
87
|
+
if isinstance(node, _constants_only_allowed_node_types()):
|
|
88
|
+
return True
|
|
89
|
+
return _is_module_docstring_expression(node)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _is_constants_only_python_content(content: str) -> bool:
|
|
93
|
+
"""Return whether module source declares only constants, imports, docstring.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
content: Python source text.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
True when every top-level statement is an import, a safe constant
|
|
100
|
+
assignment, or the module docstring; False on empty or unparseable
|
|
101
|
+
content, or any statement that carries behavior.
|
|
102
|
+
"""
|
|
103
|
+
if not content.strip():
|
|
104
|
+
return False
|
|
105
|
+
try:
|
|
106
|
+
parsed_tree = ast.parse(content)
|
|
107
|
+
except SyntaxError:
|
|
108
|
+
return False
|
|
109
|
+
if not parsed_tree.body:
|
|
110
|
+
return False
|
|
111
|
+
return all(_top_level_node_is_constant(each_node) for each_node in parsed_tree.body)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _apply_edit_to_content(
|
|
115
|
+
existing_content: str, old_str: str, new_str: str, should_replace_all: bool
|
|
116
|
+
) -> str:
|
|
117
|
+
if should_replace_all:
|
|
118
|
+
return existing_content.replace(old_str, new_str)
|
|
119
|
+
return existing_content.replace(old_str, new_str, 1)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _future_module_name() -> str:
|
|
123
|
+
return "__future__"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _is_future_import(node: ast.stmt) -> bool:
|
|
127
|
+
return isinstance(node, ast.ImportFrom) and node.module == _future_module_name()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _is_removable_import(node: ast.stmt) -> bool:
|
|
131
|
+
if isinstance(node, ast.Import):
|
|
132
|
+
return True
|
|
133
|
+
if isinstance(node, ast.ImportFrom):
|
|
134
|
+
return not _is_future_import(node)
|
|
135
|
+
return False
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _future_import_signatures(content: str) -> list[str] | None:
|
|
139
|
+
try:
|
|
140
|
+
parsed_tree = ast.parse(content)
|
|
141
|
+
except SyntaxError:
|
|
142
|
+
return None
|
|
143
|
+
return [ast.dump(each_node) for each_node in parsed_tree.body if _is_future_import(each_node)]
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _top_level_signatures(content: str) -> tuple[list[str], list[str]] | None:
|
|
147
|
+
"""Split top-level statements into removable-import and other signatures.
|
|
148
|
+
|
|
149
|
+
``from __future__`` imports and every non-import statement land in the
|
|
150
|
+
second list, so editing a future import reads as a behavior edit rather than
|
|
151
|
+
a removable-import edit. Signatures omit line and column attributes.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
content: Python source text.
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
A pair ``(import_signatures, other_signatures)`` in source order, or
|
|
158
|
+
None when the content does not parse.
|
|
159
|
+
"""
|
|
160
|
+
try:
|
|
161
|
+
parsed_tree = ast.parse(content)
|
|
162
|
+
except SyntaxError:
|
|
163
|
+
return None
|
|
164
|
+
import_signatures: list[str] = []
|
|
165
|
+
non_import_signatures: list[str] = []
|
|
166
|
+
for each_node in parsed_tree.body:
|
|
167
|
+
if _is_removable_import(each_node):
|
|
168
|
+
import_signatures.append(ast.dump(each_node))
|
|
169
|
+
else:
|
|
170
|
+
non_import_signatures.append(ast.dump(each_node))
|
|
171
|
+
return import_signatures, non_import_signatures
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _post_edit_content_for_edit(existing_content: str, tool_input: dict) -> str | None:
|
|
175
|
+
old_str = tool_input.get("old_string", "") or ""
|
|
176
|
+
new_str = tool_input.get("new_string", "") or ""
|
|
177
|
+
if not old_str:
|
|
178
|
+
return None
|
|
179
|
+
should_replace_all = bool(tool_input.get("replace_all", False))
|
|
180
|
+
return _apply_edit_to_content(existing_content, old_str, new_str, should_replace_all)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _post_edit_content_for_multiedit(existing_content: str, tool_input: dict) -> str | None:
|
|
184
|
+
all_edits = tool_input.get("edits", []) or []
|
|
185
|
+
post_edit_content = existing_content
|
|
186
|
+
for each_edit in all_edits:
|
|
187
|
+
if not isinstance(each_edit, dict):
|
|
188
|
+
return None
|
|
189
|
+
each_old = each_edit.get("old_string", "") or ""
|
|
190
|
+
each_new = each_edit.get("new_string", "") or ""
|
|
191
|
+
if not each_old:
|
|
192
|
+
return None
|
|
193
|
+
should_replace_all = bool(each_edit.get("replace_all", False))
|
|
194
|
+
post_edit_content = _apply_edit_to_content(
|
|
195
|
+
post_edit_content, each_old, each_new, should_replace_all
|
|
196
|
+
)
|
|
197
|
+
return post_edit_content
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _compute_post_edit_content(
|
|
201
|
+
existing_content: str, tool_name: str, tool_input: dict
|
|
202
|
+
) -> str | None:
|
|
203
|
+
if tool_name == "Edit":
|
|
204
|
+
return _post_edit_content_for_edit(existing_content, tool_input)
|
|
205
|
+
if tool_name == "MultiEdit":
|
|
206
|
+
return _post_edit_content_for_multiedit(existing_content, tool_input)
|
|
207
|
+
return None
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _is_post_edit_constants_only(existing_content: str, tool_name: str, tool_input: dict) -> bool:
|
|
211
|
+
"""Return whether an Edit or MultiEdit keeps a constants-only file so.
|
|
212
|
+
|
|
213
|
+
Both the existing content and the post-edit result must be constants-only,
|
|
214
|
+
and the ``from __future__`` imports must be unchanged, so a future-import
|
|
215
|
+
edit faces the gate rather than slipping through this exemption.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
existing_content: Current file text.
|
|
219
|
+
tool_name: The intercepted tool (Edit or MultiEdit).
|
|
220
|
+
tool_input: The intercepted tool's input payload.
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
True when the file was and stays constants-only across the edit.
|
|
224
|
+
"""
|
|
225
|
+
if not _is_constants_only_python_content(existing_content):
|
|
226
|
+
return False
|
|
227
|
+
post_edit_content = _compute_post_edit_content(existing_content, tool_name, tool_input)
|
|
228
|
+
if post_edit_content is None:
|
|
229
|
+
return False
|
|
230
|
+
if _future_import_signatures(existing_content) != _future_import_signatures(post_edit_content):
|
|
231
|
+
return False
|
|
232
|
+
return _is_constants_only_python_content(post_edit_content)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _import_signatures_are_reorder_or_removal(
|
|
236
|
+
all_existing_signatures: tuple[list[str], list[str]],
|
|
237
|
+
all_post_edit_signatures: tuple[list[str], list[str]],
|
|
238
|
+
) -> bool:
|
|
239
|
+
existing_imports, existing_rest = all_existing_signatures
|
|
240
|
+
post_imports, post_rest = all_post_edit_signatures
|
|
241
|
+
if post_rest != existing_rest or post_imports == existing_imports:
|
|
242
|
+
return False
|
|
243
|
+
return Counter(post_imports) <= Counter(existing_imports)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _is_post_edit_import_only(existing_content: str, tool_name: str, tool_input: dict) -> bool:
|
|
247
|
+
"""Return whether an Edit or MultiEdit only removes or reorders imports.
|
|
248
|
+
|
|
249
|
+
Args:
|
|
250
|
+
existing_content: Current file text.
|
|
251
|
+
tool_name: The intercepted tool (Edit or MultiEdit).
|
|
252
|
+
tool_input: The intercepted tool's input payload.
|
|
253
|
+
|
|
254
|
+
Returns:
|
|
255
|
+
True when the edit is a pure reordering or removal of imports.
|
|
256
|
+
"""
|
|
257
|
+
existing_signatures = _top_level_signatures(existing_content)
|
|
258
|
+
if existing_signatures is None:
|
|
259
|
+
return False
|
|
260
|
+
if tool_name == "MultiEdit" and not (tool_input.get("edits") or []):
|
|
261
|
+
return False
|
|
262
|
+
post_edit_content = _compute_post_edit_content(existing_content, tool_name, tool_input)
|
|
263
|
+
if post_edit_content is None:
|
|
264
|
+
return False
|
|
265
|
+
post_edit_signatures = _top_level_signatures(post_edit_content)
|
|
266
|
+
if post_edit_signatures is None:
|
|
267
|
+
return False
|
|
268
|
+
return _import_signatures_are_reorder_or_removal(existing_signatures, post_edit_signatures)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Build and emit the gate's allow and deny decisions.
|
|
2
|
+
|
|
3
|
+
Writes the PreToolUse decision JSON to stdout and, on a deny, records the block
|
|
4
|
+
and attaches the short user-facing notice.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from tdd_enforcer_parts.config.tdd_enforcer_constants import (
|
|
12
|
+
ENTRY_HOOK_FILE_NAME,
|
|
13
|
+
FRESHNESS_WINDOW_SECONDS,
|
|
14
|
+
NEWLINE_JOIN_SEPARATOR,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
from hooks_constants.hook_block_logger import log_hook_block
|
|
18
|
+
from hooks_constants.messages import USER_FACING_TDD_NOTICE
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _entry_hook_path() -> Path:
|
|
22
|
+
"""Return the entry hook file that wires the gate's exemption rules together.
|
|
23
|
+
|
|
24
|
+
The exemption logic the deny message points at lives in the entry hook
|
|
25
|
+
beside this parts package, not in this emit-only module::
|
|
26
|
+
|
|
27
|
+
tdd_enforcer_parts/decisions.py -> parent -> blocking/
|
|
28
|
+
blocking/tdd_enforcer.py <- the named exemption home
|
|
29
|
+
|
|
30
|
+
So the guidance names the entry hook, letting a reader land on the module
|
|
31
|
+
that actually decides exemptions.
|
|
32
|
+
"""
|
|
33
|
+
return Path(__file__).resolve().parent.parent / ENTRY_HOOK_FILE_NAME
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def build_deny_reason(production_path: Path, all_candidates: list[Path]) -> str:
|
|
37
|
+
"""Return the verbose deny message naming the candidate test files.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
production_path: The production file the write targets.
|
|
41
|
+
all_candidates: Candidate test paths the gate looked for.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
The multi-line reason shown when no fresh test satisfies the gate.
|
|
45
|
+
"""
|
|
46
|
+
candidate_lines = NEWLINE_JOIN_SEPARATOR.join(f" - {each_path}" for each_path in all_candidates)
|
|
47
|
+
hook_source_path = _entry_hook_path()
|
|
48
|
+
return (
|
|
49
|
+
f"[TDD] Blocking write to production file: {production_path}\n"
|
|
50
|
+
f"No matching test file exists, or it has not been modified within the last "
|
|
51
|
+
f"{FRESHNESS_WINDOW_SECONDS} seconds.\n"
|
|
52
|
+
f"Expected one of:\n{candidate_lines}\n"
|
|
53
|
+
f"Write a failing test first (RED), then the minimum code to pass it (GREEN).\n\n"
|
|
54
|
+
f"If this file legitimately does not need a test (for example, a module containing only "
|
|
55
|
+
f"module-level constants with no behavior), that is a hook enhancement, not a bypass. "
|
|
56
|
+
f"Propose an exemption rule in {hook_source_path} so every similar file benefits "
|
|
57
|
+
f"automatically. Do not add escape-hatch markers to production files."
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def emit_allow() -> None:
|
|
62
|
+
"""Write the allow decision to stdout."""
|
|
63
|
+
allow_payload = {
|
|
64
|
+
"hookSpecificOutput": {
|
|
65
|
+
"hookEventName": "PreToolUse",
|
|
66
|
+
"permissionDecision": "allow",
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
sys.stdout.write(json.dumps(allow_payload))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def emit_deny(reason: str) -> None:
|
|
73
|
+
"""Write the deny decision to stdout and record the block.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
reason: The verbose deny reason shown to the agent.
|
|
77
|
+
"""
|
|
78
|
+
deny_payload = {
|
|
79
|
+
"hookSpecificOutput": {
|
|
80
|
+
"hookEventName": "PreToolUse",
|
|
81
|
+
"permissionDecision": "deny",
|
|
82
|
+
"permissionDecisionReason": reason,
|
|
83
|
+
},
|
|
84
|
+
"suppressOutput": True,
|
|
85
|
+
"systemMessage": USER_FACING_TDD_NOTICE,
|
|
86
|
+
}
|
|
87
|
+
log_hook_block(
|
|
88
|
+
calling_hook_name="tdd_enforcer.py",
|
|
89
|
+
hook_event="PreToolUse",
|
|
90
|
+
block_reason=reason,
|
|
91
|
+
)
|
|
92
|
+
sys.stdout.write(json.dumps(deny_payload))
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Freshness check for the candidate test files the gate resolves.
|
|
2
|
+
|
|
3
|
+
A candidate satisfies the gate when it was modified inside the freshness window
|
|
4
|
+
and holds a real test function, so a stale test or a same-named file with no
|
|
5
|
+
test evidence does not open the gate.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
import time
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from tdd_enforcer_parts.config.tdd_enforcer_constants import FRESHNESS_WINDOW_SECONDS
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _freshness_seconds() -> int:
|
|
16
|
+
return FRESHNESS_WINDOW_SECONDS
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _test_file_encoding() -> str:
|
|
20
|
+
return "utf-8"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _test_function_patterns() -> tuple[re.Pattern[str], ...]:
|
|
24
|
+
return (
|
|
25
|
+
re.compile(r"\bdef\s+test_"),
|
|
26
|
+
re.compile(r"\b(?:it|test|describe)\s*\("),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _safe_mtime(candidate_path: Path) -> float | None:
|
|
31
|
+
try:
|
|
32
|
+
return candidate_path.stat().st_mtime
|
|
33
|
+
except (FileNotFoundError, OSError):
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _read_candidate_text(candidate_path: Path) -> str | None:
|
|
38
|
+
try:
|
|
39
|
+
with candidate_path.open("r", encoding=_test_file_encoding(), errors="ignore") as each_file:
|
|
40
|
+
return each_file.read()
|
|
41
|
+
except (FileNotFoundError, OSError):
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _contains_test_evidence(candidate_path: Path) -> bool:
|
|
46
|
+
test_file_content = _read_candidate_text(candidate_path)
|
|
47
|
+
if test_file_content is None:
|
|
48
|
+
return False
|
|
49
|
+
return any(
|
|
50
|
+
each_pattern.search(test_file_content) for each_pattern in _test_function_patterns()
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _candidate_is_fresh_test(
|
|
55
|
+
candidate_path: Path, current_time: float, freshness_seconds: int
|
|
56
|
+
) -> bool:
|
|
57
|
+
candidate_mtime = _safe_mtime(candidate_path)
|
|
58
|
+
if candidate_mtime is None:
|
|
59
|
+
return False
|
|
60
|
+
if current_time - candidate_mtime > freshness_seconds:
|
|
61
|
+
return False
|
|
62
|
+
return _contains_test_evidence(candidate_path)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def has_fresh_test(all_candidates: list[Path], freshness_seconds: int) -> bool:
|
|
66
|
+
"""Return whether any candidate is a recently modified real test file.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
all_candidates: Candidate test paths for the production file.
|
|
70
|
+
freshness_seconds: Maximum age, in seconds, a test may have.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
True when at least one candidate was modified within the window and
|
|
74
|
+
holds a test function.
|
|
75
|
+
"""
|
|
76
|
+
current_time = time.time()
|
|
77
|
+
return any(
|
|
78
|
+
_candidate_is_fresh_test(each_candidate, current_time, freshness_seconds)
|
|
79
|
+
for each_candidate in all_candidates
|
|
80
|
+
)
|