claude-dev-env 1.94.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/_shared/advisor/CLAUDE.md +2 -2
- package/_shared/advisor/advisor-protocol.md +41 -33
- package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +17 -13
- package/_shared/advisor/scripts/model_tier_run_validator.py +27 -18
- package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +95 -25
- 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/CLAUDE.md +10 -1
- package/bin/ever-shipped-skills.mjs +73 -0
- package/bin/expand_home_directory_tokens.mjs +1 -1
- package/bin/install.mjs +140 -7
- package/bin/install.prune.test.mjs +457 -0
- package/docs/CODE_RULES.md +1 -1
- 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_enforcer.py +4 -0
- 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 +83 -1
- package/hooks/blocking/code_rules_test_assertions.py +1 -1
- package/hooks/blocking/code_rules_test_layout.py +9 -3
- 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/plain_language_blocker.py +138 -4
- package/hooks/blocking/pr_description_command_parser.py +8 -4
- package/hooks/blocking/precommit_code_rules_gate.py +3 -3
- package/hooks/blocking/sensitive_file_protector.py +114 -48
- package/hooks/blocking/tdd_enforcer.py +97 -601
- 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_code_rules_enforcer_scratchpad.py +105 -0
- package/hooks/blocking/test_code_rules_shared.py +181 -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_plain_language_blocker_allowlist.py +184 -0
- package/hooks/blocking/test_sensitive_file_protector.py +185 -0
- package/hooks/blocking/test_tdd_enforcer_restore.py +108 -0
- package/hooks/blocking/test_tdd_enforcer_scratchpad.py +105 -0
- package/hooks/blocking/tests/conftest.py +10 -0
- package/hooks/blocking/tests/test_pii_prevention_blocker.py +260 -0
- package/hooks/blocking/tests/test_repository_exemption.py +105 -0
- package/hooks/blocking/tests/test_repository_resolution.py +108 -0
- package/hooks/diagnostic/hook_log_extractor.py +12 -10
- package/hooks/git-hooks/post_commit.py +3 -4
- package/hooks/hooks_constants/CLAUDE.md +4 -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/harness_scratchpad_constants.py +17 -0
- package/hooks/hooks_constants/local_identity.py +59 -8
- package/hooks/hooks_constants/pii_prevention_constants.py +0 -6
- package/hooks/hooks_constants/plain_language_blocker_constants.py +5 -0
- package/hooks/hooks_constants/sensitive_file_protector_constants.py +42 -0
- package/hooks/hooks_constants/test_local_identity.py +105 -3
- package/hooks/pyproject.toml +52 -4
- package/hooks/session/plugin_data_dir_cleanup.py +0 -1
- package/hooks/validation/mypy_validator.py +2 -2
- package/hooks/validators/CLAUDE.md +1 -1
- package/hooks/validators/README.md +2 -0
- package/hooks/validators/health_check.py +1 -0
- package/hooks/validators/mypy_integration.py +2 -0
- package/hooks/validators/python_style_checks.py +114 -136
- package/hooks/validators/python_style_helpers.py +95 -0
- package/hooks/validators/ruff_integration.py +3 -0
- package/hooks/validators/test_python_style_checks.py +0 -164
- package/hooks/validators/test_python_style_checks_decorator_gap.py +119 -0
- package/hooks/validators/test_python_style_fixes.py +251 -0
- package/hooks/validators/test_python_style_helpers.py +125 -0
- package/hooks/workflow/auto_formatter.py +5 -4
- package/package.json +1 -1
- package/rules/CLAUDE.md +1 -0
- package/rules/anti-corollary-tests.md +69 -0
- package/rules/bdd.md +1 -3
- package/rules/code-reviews.md +1 -1
- package/rules/gh-paginate.md +1 -1
- package/rules/plain-language.md +2 -0
- 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 +6 -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 +202 -18
- 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/README.md +2 -3
- 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/closeout/SKILL.md +153 -0
- package/skills/closeout/reference/handoff-prompt-template.md +72 -0
- package/skills/closeout/reference/issue-body-templates.md +108 -0
- package/skills/closeout/reference/pii-redaction-checklist.md +36 -0
- 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 +32 -22
- package/skills/orchestrator-refresh/SKILL.md +12 -8
- package/skills/pr-converge/CLAUDE.md +1 -1
- 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-fix-protocol/SKILL.md +65 -0
- package/skills/pr-loop-cloud-transport/SKILL.md +2 -0
- package/skills/reviewer-gates/SKILL.md +7 -5
- package/skills/skill-builder/references/skill-modularity.md +1 -1
- package/skills/team-advisor/SKILL.md +16 -12
- package/system-prompts/software-engineer.xml +7 -6
- package/hooks/validators/test_verify_paths.py +0 -32
- package/hooks/validators/verify_paths.py +0 -57
|
@@ -1,640 +1,136 @@
|
|
|
1
|
-
|
|
2
|
-
"""
|
|
3
|
-
BDD Automate-phase gate (production code touch).
|
|
1
|
+
"""BDD Automate-phase gate (production code touch).
|
|
4
2
|
|
|
5
|
-
Blocks
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
Blocks a write to a production source file when no matching test was modified
|
|
4
|
+
within the freshness window, enforcing "TDD IS NON-NEGOTIABLE" from CLAUDE.md.
|
|
5
|
+
Each concern lives in a ``tdd_enforcer_parts`` submodule; this entry wires them
|
|
6
|
+
into one PreToolUse gate and re-exports their surface for the test suite.
|
|
8
7
|
"""
|
|
9
|
-
|
|
8
|
+
|
|
10
9
|
import json
|
|
11
|
-
import re
|
|
12
10
|
import sys
|
|
13
|
-
import time
|
|
14
|
-
from collections import Counter
|
|
15
11
|
from pathlib import Path
|
|
16
12
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
28
|
-
from hooks_constants.messages import USER_FACING_TDD_NOTICE # noqa: E402
|
|
29
|
-
|
|
30
|
-
PRODUCTION_EXTENSIONS = {'.py', '.ts', '.tsx', '.js', '.jsx'}
|
|
31
|
-
SKIP_PATTERNS = {
|
|
32
|
-
'test_', '_test.', '.test.', 'tests/', '__tests__/',
|
|
33
|
-
'conftest', 'fixture', 'mock', 'stub'
|
|
34
|
-
}
|
|
35
|
-
SKIP_EXTENSIONS = {'.md', '.json', '.yaml', '.yml', '.toml', '.ini', '.cfg', '.txt'}
|
|
36
|
-
DOTCLAUDE_PATH_SEGMENTS = frozenset({".claude"})
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
def _is_inside_dotclaude_segment(file_path_string: str) -> bool:
|
|
40
|
-
normalized_path = file_path_string.replace("\\", "/")
|
|
41
|
-
for each_segment in normalized_path.split("/"):
|
|
42
|
-
if each_segment and each_segment in DOTCLAUDE_PATH_SEGMENTS:
|
|
43
|
-
return True
|
|
44
|
-
return False
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
def _freshness_seconds() -> int:
|
|
48
|
-
return 600
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def _constants_only_allowed_node_types() -> tuple[type, ...]:
|
|
52
|
-
return (
|
|
53
|
-
ast.Import,
|
|
54
|
-
ast.ImportFrom,
|
|
55
|
-
ast.Assign,
|
|
56
|
-
ast.AnnAssign,
|
|
13
|
+
try:
|
|
14
|
+
_hooks_root_directory = str(Path(__file__).resolve().parent.parent)
|
|
15
|
+
_blocking_directory = str(Path(__file__).resolve().parent)
|
|
16
|
+
for each_bootstrap_directory in (_hooks_root_directory, _blocking_directory):
|
|
17
|
+
if each_bootstrap_directory not in sys.path:
|
|
18
|
+
sys.path.insert(0, each_bootstrap_directory)
|
|
19
|
+
from code_rules_shared import (
|
|
20
|
+
is_ephemeral_script_path,
|
|
21
|
+
is_under_session_scratchpad,
|
|
57
22
|
)
|
|
23
|
+
from tdd_enforcer_parts import (
|
|
24
|
+
candidate_paths,
|
|
25
|
+
content_analysis,
|
|
26
|
+
decisions,
|
|
27
|
+
freshness,
|
|
28
|
+
git_tracking,
|
|
29
|
+
path_classification,
|
|
30
|
+
)
|
|
31
|
+
from tdd_enforcer_parts.config.tdd_enforcer_constants import PYTHON_SOURCE_EXTENSION
|
|
32
|
+
except ImportError as import_error:
|
|
33
|
+
raise ImportError(
|
|
34
|
+
"tdd_enforcer: cannot import its tdd_enforcer_parts submodules; "
|
|
35
|
+
"ensure the hooks directory is importable."
|
|
36
|
+
) from import_error
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _is_session_scratchpad_write(file_path: str, input_data: dict) -> bool:
|
|
40
|
+
return is_under_session_scratchpad(file_path, input_data)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
production_extensions = path_classification.production_extensions
|
|
44
|
+
skip_extensions = path_classification.skip_extensions
|
|
45
|
+
_is_inside_dotclaude_segment = path_classification._is_inside_dotclaude_segment
|
|
46
|
+
_directory_skip_components = path_classification._directory_skip_components
|
|
47
|
+
_matches_any_skip_pattern = path_classification._matches_any_skip_pattern
|
|
48
|
+
_extract_written_content = path_classification._extract_written_content
|
|
49
|
+
candidate_test_paths_for = candidate_paths.candidate_test_paths_for
|
|
50
|
+
_ancestor_tests_directories = candidate_paths._ancestor_tests_directories
|
|
51
|
+
_parent_walk_limit = candidate_paths._parent_walk_limit
|
|
52
|
+
_freshness_seconds = freshness._freshness_seconds
|
|
53
|
+
has_fresh_test = freshness.has_fresh_test
|
|
54
|
+
_read_candidate_text = freshness._read_candidate_text
|
|
55
|
+
_is_constants_only_python_content = content_analysis._is_constants_only_python_content
|
|
56
|
+
_is_post_edit_import_only = content_analysis._is_post_edit_import_only
|
|
57
|
+
_is_post_edit_constants_only = content_analysis._is_post_edit_constants_only
|
|
58
|
+
is_absent_but_tracked = git_tracking.is_absent_but_tracked
|
|
59
|
+
build_deny_reason = decisions.build_deny_reason
|
|
60
|
+
emit_allow = decisions.emit_allow
|
|
61
|
+
emit_deny = decisions.emit_deny
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _resolve_payload(input_data: dict) -> tuple[str, dict, str]:
|
|
65
|
+
tool_input = input_data.get("tool_input", {})
|
|
66
|
+
return input_data.get("tool_name", ""), tool_input, tool_input.get("file_path", "")
|
|
58
67
|
|
|
59
68
|
|
|
60
|
-
def
|
|
61
|
-
if
|
|
62
|
-
return False
|
|
63
|
-
expression_value = module_level_node.value
|
|
64
|
-
if not isinstance(expression_value, ast.Constant):
|
|
65
|
-
return False
|
|
66
|
-
return isinstance(expression_value.value, str)
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
def _safe_constant_functions() -> frozenset[str]:
|
|
70
|
-
"""Unqualified function names treated as safe value constructors."""
|
|
71
|
-
return frozenset({"Path", "frozenset"})
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
def _safe_constant_attribute_calls() -> frozenset[tuple[str, str]]:
|
|
75
|
-
"""(module, attr) pairs treated as safe value constructors."""
|
|
76
|
-
return frozenset({("re", "compile")})
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
def _rhs_has_unsafe_call(rhs_node: ast.AST) -> bool:
|
|
80
|
-
"""Return True when rhs_node contains a function call outside the safe allowlist.
|
|
81
|
-
|
|
82
|
-
Safe calls are value constructors (``Path(...)``, ``re.compile(...)``)
|
|
83
|
-
that create objects without side effects. Any other call pattern is
|
|
84
|
-
treated as unsafe import-time behavior.
|
|
85
|
-
"""
|
|
86
|
-
safe_functions = _safe_constant_functions()
|
|
87
|
-
safe_attribute_calls = _safe_constant_attribute_calls()
|
|
88
|
-
for each_subnode in ast.walk(rhs_node):
|
|
89
|
-
if isinstance(each_subnode, ast.Call):
|
|
90
|
-
function_node = each_subnode.func
|
|
91
|
-
if isinstance(function_node, ast.Name):
|
|
92
|
-
if function_node.id not in safe_functions:
|
|
93
|
-
return True
|
|
94
|
-
elif isinstance(function_node, ast.Attribute):
|
|
95
|
-
if isinstance(function_node.value, ast.Name):
|
|
96
|
-
pair = (function_node.value.id, function_node.attr)
|
|
97
|
-
if pair not in safe_attribute_calls:
|
|
98
|
-
return True
|
|
99
|
-
else:
|
|
100
|
-
return True
|
|
101
|
-
else:
|
|
102
|
-
return True
|
|
103
|
-
elif isinstance(
|
|
104
|
-
each_subnode,
|
|
105
|
-
(ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp, ast.Lambda),
|
|
106
|
-
):
|
|
107
|
-
return True
|
|
108
|
-
return False
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
def _is_constants_only_python_content(content: str) -> bool:
|
|
112
|
-
if not content.strip():
|
|
113
|
-
return False
|
|
114
|
-
try:
|
|
115
|
-
parsed_tree = ast.parse(content)
|
|
116
|
-
except SyntaxError:
|
|
117
|
-
return False
|
|
118
|
-
if not parsed_tree.body:
|
|
119
|
-
return False
|
|
120
|
-
allowed_node_types = _constants_only_allowed_node_types()
|
|
121
|
-
for each_top_level_node in parsed_tree.body:
|
|
122
|
-
if isinstance(each_top_level_node, allowed_node_types):
|
|
123
|
-
if isinstance(each_top_level_node, (ast.Assign, ast.AnnAssign)):
|
|
124
|
-
rhs = each_top_level_node.value
|
|
125
|
-
if rhs is not None and _rhs_has_unsafe_call(rhs):
|
|
126
|
-
return False
|
|
127
|
-
continue
|
|
128
|
-
if _is_module_docstring_expression(each_top_level_node):
|
|
129
|
-
continue
|
|
130
|
-
return False
|
|
131
|
-
return True
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
def _apply_edit_to_content(
|
|
135
|
-
existing_content: str, old_str: str, new_str: str, should_replace_all: bool
|
|
136
|
-
) -> str:
|
|
137
|
-
"""Apply an edit's replacement to content the way the Edit tool would.
|
|
138
|
-
|
|
139
|
-
Args:
|
|
140
|
-
existing_content: The text being edited.
|
|
141
|
-
old_str: The substring the edit replaces.
|
|
142
|
-
new_str: The replacement substring.
|
|
143
|
-
should_replace_all: Replace every occurrence when True (matching the
|
|
144
|
-
Edit tool's ``replace_all`` flag), otherwise only the first.
|
|
145
|
-
|
|
146
|
-
Returns:
|
|
147
|
-
The post-edit content.
|
|
148
|
-
"""
|
|
149
|
-
if should_replace_all:
|
|
150
|
-
return existing_content.replace(old_str, new_str)
|
|
151
|
-
return existing_content.replace(old_str, new_str, 1)
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
def _future_module_name() -> str:
|
|
155
|
-
return "__future__"
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
def _is_future_import(node: ast.stmt) -> bool:
|
|
159
|
-
"""Return whether a statement is a ``from __future__`` import.
|
|
160
|
-
|
|
161
|
-
Args:
|
|
162
|
-
node: A top-level module statement.
|
|
163
|
-
|
|
164
|
-
Returns:
|
|
165
|
-
True when the statement imports from ``__future__``, whose presence
|
|
166
|
-
affects module-wide compilation semantics.
|
|
167
|
-
"""
|
|
168
|
-
return isinstance(node, ast.ImportFrom) and node.module == _future_module_name()
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
def _is_removable_import(node: ast.stmt) -> bool:
|
|
172
|
-
"""Return whether a statement is an import that removes or reorders cleanly.
|
|
173
|
-
|
|
174
|
-
Args:
|
|
175
|
-
node: A top-level module statement.
|
|
176
|
-
|
|
177
|
-
Returns:
|
|
178
|
-
True for plain ``import`` and ``from`` imports. ``from __future__``
|
|
179
|
-
imports return False because their presence affects module-wide
|
|
180
|
-
compilation semantics, so the gate treats them as behavior-bearing
|
|
181
|
-
statements rather than removable imports; every non-import statement
|
|
182
|
-
also returns False.
|
|
183
|
-
"""
|
|
184
|
-
if isinstance(node, ast.Import):
|
|
69
|
+
def _is_silently_skipped(file_path: str, extension: str, path: Path) -> bool:
|
|
70
|
+
if _is_inside_dotclaude_segment(file_path) or is_ephemeral_script_path(file_path):
|
|
185
71
|
return True
|
|
186
|
-
if
|
|
187
|
-
return
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
"""Return the ``ast.dump`` signatures of a module's ``from __future__`` imports.
|
|
193
|
-
|
|
194
|
-
Args:
|
|
195
|
-
content: Python source text to parse.
|
|
196
|
-
|
|
197
|
-
Returns:
|
|
198
|
-
The future-import signatures in source order, or ``None`` when the
|
|
199
|
-
content does not parse.
|
|
200
|
-
"""
|
|
201
|
-
try:
|
|
202
|
-
parsed_tree = ast.parse(content)
|
|
203
|
-
except SyntaxError:
|
|
204
|
-
return None
|
|
205
|
-
return [ast.dump(each_node) for each_node in parsed_tree.body if _is_future_import(each_node)]
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
def _is_post_edit_constants_only(existing_content: str, tool_name: str, tool_input: dict) -> bool:
|
|
209
|
-
"""Check if post-edit content remains constants-only after Edit or MultiEdit.
|
|
210
|
-
|
|
211
|
-
Both the existing content and the post-edit result must be constants-only
|
|
212
|
-
to prevent edits on files with behavior from bypassing the TDD gate. Editing
|
|
213
|
-
a ``from __future__`` import also fails this check, so a future-import edit
|
|
214
|
-
on a constants-only file faces the gate rather than slipping through the
|
|
215
|
-
constants exemption.
|
|
216
|
-
"""
|
|
217
|
-
if not _is_constants_only_python_content(existing_content):
|
|
218
|
-
return False
|
|
219
|
-
|
|
220
|
-
if tool_name == "Edit":
|
|
221
|
-
old_str = tool_input.get("old_string", "") or ""
|
|
222
|
-
new_str = tool_input.get("new_string", "") or ""
|
|
223
|
-
if not old_str:
|
|
224
|
-
return False
|
|
225
|
-
should_replace_all = bool(tool_input.get("replace_all", False))
|
|
226
|
-
post_edit_content = _apply_edit_to_content(existing_content, old_str, new_str, should_replace_all)
|
|
227
|
-
elif tool_name == "MultiEdit":
|
|
228
|
-
all_edits = tool_input.get("edits", []) or []
|
|
229
|
-
post_edit_content = existing_content
|
|
230
|
-
for each_edit in all_edits:
|
|
231
|
-
if not isinstance(each_edit, dict):
|
|
232
|
-
return False
|
|
233
|
-
each_old = each_edit.get("old_string", "") or ""
|
|
234
|
-
each_new = each_edit.get("new_string", "") or ""
|
|
235
|
-
if not each_old:
|
|
236
|
-
return False
|
|
237
|
-
should_replace_all = bool(each_edit.get("replace_all", False))
|
|
238
|
-
post_edit_content = _apply_edit_to_content(
|
|
239
|
-
post_edit_content, each_old, each_new, should_replace_all
|
|
240
|
-
)
|
|
241
|
-
else:
|
|
242
|
-
return False
|
|
243
|
-
|
|
244
|
-
if _future_import_signatures(existing_content) != _future_import_signatures(post_edit_content):
|
|
245
|
-
return False
|
|
246
|
-
return _is_constants_only_python_content(post_edit_content)
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
def _top_level_signatures(content: str) -> tuple[list[str], list[str]] | None:
|
|
250
|
-
"""Split a module's top-level statements into removable-import and other signatures.
|
|
251
|
-
|
|
252
|
-
Args:
|
|
253
|
-
content: Python source text to parse.
|
|
254
|
-
|
|
255
|
-
Returns:
|
|
256
|
-
A pair ``(import_signatures, non_import_signatures)`` of ``ast.dump``
|
|
257
|
-
strings in source order, or ``None`` when the content does not parse.
|
|
258
|
-
Plain imports populate the first list; ``from __future__`` imports and
|
|
259
|
-
every non-import statement populate the second, so editing a future
|
|
260
|
-
import reads as a behavior edit rather than a removable-import edit.
|
|
261
|
-
Signatures omit line and column attributes, so statements that only
|
|
262
|
-
shift position compare equal.
|
|
263
|
-
"""
|
|
264
|
-
try:
|
|
265
|
-
parsed_tree = ast.parse(content)
|
|
266
|
-
except SyntaxError:
|
|
267
|
-
return None
|
|
268
|
-
import_signatures: list[str] = []
|
|
269
|
-
non_import_signatures: list[str] = []
|
|
270
|
-
for each_node in parsed_tree.body:
|
|
271
|
-
if _is_removable_import(each_node):
|
|
272
|
-
import_signatures.append(ast.dump(each_node))
|
|
273
|
-
else:
|
|
274
|
-
non_import_signatures.append(ast.dump(each_node))
|
|
275
|
-
return import_signatures, non_import_signatures
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
def _is_post_edit_import_only(existing_content: str, tool_name: str, tool_input: dict) -> bool:
|
|
279
|
-
"""Check whether an Edit or MultiEdit only removes or reorders imports.
|
|
280
|
-
|
|
281
|
-
The top-level statements are split into imports and the rest, before and
|
|
282
|
-
after applying the edit. The edit is exempt only when the non-import
|
|
283
|
-
statements are unchanged and every post-edit import statement already
|
|
284
|
-
appears among the pre-edit imports, so removing or reordering imports is
|
|
285
|
-
exempt while adding, swapping, or retargeting an import stays gated: those
|
|
286
|
-
can change behavior through a new symbol in scope, a different
|
|
287
|
-
implementation bound to the same name, or `from __future__` semantics. An
|
|
288
|
-
edit that leaves the parsed module identical (a comment- or whitespace-only
|
|
289
|
-
change) also stays gated. Reading the resulting file rather than the edit
|
|
290
|
-
fragments keeps the exemption from firing on import text inside a string
|
|
291
|
-
literal, and lets it fire on edits whose old string carries surrounding
|
|
292
|
-
context for uniqueness.
|
|
293
|
-
|
|
294
|
-
Args:
|
|
295
|
-
existing_content: Current text of the file under edit.
|
|
296
|
-
tool_name: The intercepted tool (Edit or MultiEdit).
|
|
297
|
-
tool_input: The intercepted tool's input payload.
|
|
298
|
-
|
|
299
|
-
Returns:
|
|
300
|
-
True when the edit leaves the non-import statements unchanged and the
|
|
301
|
-
post-edit imports are a reordering or removal of the pre-edit imports.
|
|
302
|
-
"""
|
|
303
|
-
existing_signatures = _top_level_signatures(existing_content)
|
|
304
|
-
if existing_signatures is None:
|
|
305
|
-
return False
|
|
72
|
+
if extension in skip_extensions():
|
|
73
|
+
return True
|
|
74
|
+
if extension not in production_extensions():
|
|
75
|
+
return True
|
|
76
|
+
path_with_forward_slashes = str(path).lower().replace("\\", "/")
|
|
77
|
+
return _matches_any_skip_pattern(path.name.lower(), path_with_forward_slashes)
|
|
306
78
|
|
|
307
|
-
if tool_name == "Edit":
|
|
308
|
-
old_str = tool_input.get("old_string", "") or ""
|
|
309
|
-
new_str = tool_input.get("new_string", "") or ""
|
|
310
|
-
if not old_str:
|
|
311
|
-
return False
|
|
312
|
-
should_replace_all = bool(tool_input.get("replace_all", False))
|
|
313
|
-
post_edit_content = _apply_edit_to_content(existing_content, old_str, new_str, should_replace_all)
|
|
314
|
-
elif tool_name == "MultiEdit":
|
|
315
|
-
all_edits = tool_input.get("edits", []) or []
|
|
316
|
-
if not all_edits:
|
|
317
|
-
return False
|
|
318
|
-
post_edit_content = existing_content
|
|
319
|
-
for each_edit in all_edits:
|
|
320
|
-
if not isinstance(each_edit, dict):
|
|
321
|
-
return False
|
|
322
|
-
each_old = each_edit.get("old_string", "") or ""
|
|
323
|
-
each_new = each_edit.get("new_string", "") or ""
|
|
324
|
-
if not each_old:
|
|
325
|
-
return False
|
|
326
|
-
should_replace_all = bool(each_edit.get("replace_all", False))
|
|
327
|
-
post_edit_content = _apply_edit_to_content(
|
|
328
|
-
post_edit_content, each_old, each_new, should_replace_all
|
|
329
|
-
)
|
|
330
|
-
else:
|
|
331
|
-
return False
|
|
332
79
|
|
|
333
|
-
|
|
334
|
-
|
|
80
|
+
def _edit_is_constants_or_import_only(
|
|
81
|
+
tool_name: str, tool_input: dict, path: Path, extension: str
|
|
82
|
+
) -> bool:
|
|
83
|
+
if (
|
|
84
|
+
tool_name not in ("Edit", "MultiEdit")
|
|
85
|
+
or extension != PYTHON_SOURCE_EXTENSION
|
|
86
|
+
or not path.exists()
|
|
87
|
+
):
|
|
335
88
|
return False
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
if post_rest != existing_rest or post_imports == existing_imports:
|
|
89
|
+
existing_content = _read_candidate_text(path)
|
|
90
|
+
if existing_content is None:
|
|
339
91
|
return False
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
def _tests_directory_name() -> str:
|
|
344
|
-
return "tests"
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
def _parent_walk_limit() -> int:
|
|
348
|
-
return 10
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
def _repo_boundary_sentinels() -> frozenset[str]:
|
|
352
|
-
return frozenset({".git", "pyproject.toml", "package.json", "Cargo.toml", "go.mod"})
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
def _test_function_patterns() -> tuple[re.Pattern[str], ...]:
|
|
356
|
-
return (
|
|
357
|
-
re.compile(r"\bdef\s+test_"),
|
|
358
|
-
re.compile(r"\b(?:it|test|describe)\s*\("),
|
|
359
|
-
)
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
def _directory_skip_components() -> frozenset[str]:
|
|
363
|
-
return frozenset({
|
|
364
|
-
"conftest", "fixture", "fixtures", "mock", "mocks", "stub", "stubs",
|
|
365
|
-
})
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
def _is_repo_boundary(candidate_directory: Path) -> bool:
|
|
369
|
-
for each_sentinel in _repo_boundary_sentinels():
|
|
370
|
-
if (candidate_directory / each_sentinel).exists():
|
|
371
|
-
return True
|
|
372
|
-
return False
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
def _ancestor_tests_directories(start_directory: Path) -> list[tuple[Path, Path]]:
|
|
376
|
-
"""Collect each ancestor's sibling tests directory up to the repo boundary.
|
|
377
|
-
|
|
378
|
-
Args:
|
|
379
|
-
start_directory: Directory of the production file under edit.
|
|
380
|
-
|
|
381
|
-
Returns:
|
|
382
|
-
Ordered (ancestor, tests_directory) pairs; nearer ancestors first.
|
|
383
|
-
"""
|
|
384
|
-
all_pairs: list[tuple[Path, Path]] = []
|
|
385
|
-
current_directory = start_directory
|
|
386
|
-
for _ in range(_parent_walk_limit()):
|
|
387
|
-
sibling_tests = current_directory / _tests_directory_name()
|
|
388
|
-
if sibling_tests.is_dir():
|
|
389
|
-
all_pairs.append((current_directory, sibling_tests))
|
|
390
|
-
if _is_repo_boundary(current_directory):
|
|
391
|
-
break
|
|
392
|
-
if current_directory.parent == current_directory:
|
|
393
|
-
break
|
|
394
|
-
current_directory = current_directory.parent
|
|
395
|
-
return all_pairs
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
def _split_module_stem_prefix() -> str:
|
|
399
|
-
return "code_rules_"
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
def _split_test_family_glob() -> str:
|
|
403
|
-
return "test_code_rules_enforcer_*.py"
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
def _split_family_candidates(directory: Path, stem: str) -> list[Path]:
|
|
407
|
-
if not stem.startswith(_split_module_stem_prefix()):
|
|
408
|
-
return []
|
|
409
|
-
return sorted(directory.glob(_split_test_family_glob()))
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
def candidate_test_paths_for(production_path: Path) -> list[Path]:
|
|
413
|
-
"""Return the test files whose freshness can satisfy the gate for a production file.
|
|
414
|
-
|
|
415
|
-
Every ancestor ``tests`` directory contributes a flat candidate
|
|
416
|
-
(``tests/test_<stem>.py``) and package-mirroring nested candidates
|
|
417
|
-
(``tests/<subpackage path>/test_<stem>.py``), so repos that keep tests
|
|
418
|
-
either beside the package or in a category tree both resolve.
|
|
419
|
-
|
|
420
|
-
For ``code_rules_*`` Python modules the candidate list is extended with the
|
|
421
|
-
sibling split test family (``test_code_rules_enforcer_*.py``), because that
|
|
422
|
-
family collectively covers the split check modules; a fresh edit to any
|
|
423
|
-
family file satisfies the RED step for editing one of those modules. The
|
|
424
|
-
glob is directory-local, so ``code_rules_*`` files elsewhere gain no extra
|
|
425
|
-
candidates. Plain stem-derived candidates always come first.
|
|
426
|
-
|
|
427
|
-
Args:
|
|
428
|
-
production_path: The production source file being written or edited.
|
|
429
|
-
|
|
430
|
-
Returns:
|
|
431
|
-
Ordered candidate test paths; stem-derived siblings precede any
|
|
432
|
-
split-family additions.
|
|
433
|
-
"""
|
|
434
|
-
directory = production_path.parent
|
|
435
|
-
stem = production_path.stem
|
|
436
|
-
extension = production_path.suffix.lower()
|
|
437
|
-
all_candidates: list[Path] = []
|
|
438
|
-
|
|
439
|
-
if extension == ".py":
|
|
440
|
-
all_candidates.append(directory / f"test_{stem}.py")
|
|
441
|
-
all_candidates.append(directory / f"{stem}_test.py")
|
|
442
|
-
for each_ancestor, each_tests_directory in _ancestor_tests_directories(directory):
|
|
443
|
-
all_candidates.append(each_tests_directory / f"test_{stem}.py")
|
|
444
|
-
nested_directory = each_tests_directory
|
|
445
|
-
for each_relative_part in directory.relative_to(each_ancestor).parts:
|
|
446
|
-
nested_directory = nested_directory / each_relative_part
|
|
447
|
-
all_candidates.append(nested_directory / f"test_{stem}.py")
|
|
448
|
-
all_candidates.extend(_split_family_candidates(directory, stem))
|
|
449
|
-
return all_candidates
|
|
450
|
-
|
|
451
|
-
if extension in {".tsx", ".ts", ".jsx", ".js"}:
|
|
452
|
-
all_candidates.append(directory / f"{stem}.test{extension}")
|
|
453
|
-
all_candidates.append(directory / f"{stem}.spec{extension}")
|
|
454
|
-
return all_candidates
|
|
455
|
-
|
|
456
|
-
return all_candidates
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
def _test_file_encoding() -> str:
|
|
460
|
-
return "utf-8"
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
def _safe_mtime(candidate_path: Path) -> float | None:
|
|
464
|
-
try:
|
|
465
|
-
return candidate_path.stat().st_mtime
|
|
466
|
-
except (FileNotFoundError, OSError):
|
|
467
|
-
return None
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
def _read_candidate_text(candidate_path: Path) -> str | None:
|
|
471
|
-
try:
|
|
472
|
-
with candidate_path.open("r", encoding=_test_file_encoding(), errors="ignore") as each_file:
|
|
473
|
-
return each_file.read()
|
|
474
|
-
except (FileNotFoundError, OSError):
|
|
475
|
-
return None
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
def _contains_test_evidence(candidate_path: Path) -> bool:
|
|
479
|
-
test_file_content = _read_candidate_text(candidate_path)
|
|
480
|
-
if test_file_content is None:
|
|
481
|
-
return False
|
|
482
|
-
for each_pattern in _test_function_patterns():
|
|
483
|
-
if each_pattern.search(test_file_content):
|
|
484
|
-
return True
|
|
485
|
-
return False
|
|
92
|
+
if _is_post_edit_import_only(existing_content, tool_name, tool_input):
|
|
93
|
+
return True
|
|
94
|
+
return _is_post_edit_constants_only(existing_content, tool_name, tool_input)
|
|
486
95
|
|
|
487
96
|
|
|
488
|
-
def
|
|
489
|
-
|
|
490
|
-
freshness_seconds: int,
|
|
97
|
+
def _write_is_exempt(
|
|
98
|
+
tool_name: str, tool_input: dict, path: Path, extension: str
|
|
491
99
|
) -> bool:
|
|
492
|
-
|
|
493
|
-
for each_candidate in all_candidates:
|
|
494
|
-
candidate_mtime = _safe_mtime(each_candidate)
|
|
495
|
-
if candidate_mtime is None:
|
|
496
|
-
continue
|
|
497
|
-
age_seconds = current_time - candidate_mtime
|
|
498
|
-
if age_seconds > freshness_seconds:
|
|
499
|
-
continue
|
|
500
|
-
if not _contains_test_evidence(each_candidate):
|
|
501
|
-
continue
|
|
100
|
+
if is_absent_but_tracked(path):
|
|
502
101
|
return True
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
f"No matching test file exists, or it has not been modified within the last "
|
|
512
|
-
f"{_freshness_seconds()} seconds.\n"
|
|
513
|
-
f"Expected one of:\n{candidate_lines}\n"
|
|
514
|
-
f"Write a failing test first (RED), then the minimum code to pass it (GREEN).\n\n"
|
|
515
|
-
f"If this file legitimately does not need a test (for example, a module containing only "
|
|
516
|
-
f"module-level constants with no behavior), that is a hook enhancement, not a bypass. "
|
|
517
|
-
f"Propose an exemption rule in {hook_source_path} so every similar file benefits "
|
|
518
|
-
f"automatically. Do not add escape-hatch markers to production files."
|
|
519
|
-
)
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
def emit_allow() -> None:
|
|
523
|
-
allow_payload = {
|
|
524
|
-
"hookSpecificOutput": {
|
|
525
|
-
"hookEventName": "PreToolUse",
|
|
526
|
-
"permissionDecision": "allow",
|
|
527
|
-
}
|
|
528
|
-
}
|
|
529
|
-
print(json.dumps(allow_payload))
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
def emit_deny(reason: str) -> None:
|
|
533
|
-
deny_payload = {
|
|
534
|
-
"hookSpecificOutput": {
|
|
535
|
-
"hookEventName": "PreToolUse",
|
|
536
|
-
"permissionDecision": "deny",
|
|
537
|
-
"permissionDecisionReason": reason,
|
|
538
|
-
},
|
|
539
|
-
"suppressOutput": True,
|
|
540
|
-
"systemMessage": USER_FACING_TDD_NOTICE,
|
|
541
|
-
}
|
|
542
|
-
log_hook_block(
|
|
543
|
-
calling_hook_name="tdd_enforcer.py",
|
|
544
|
-
hook_event="PreToolUse",
|
|
545
|
-
block_reason=reason,
|
|
546
|
-
)
|
|
547
|
-
print(json.dumps(deny_payload))
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
def _matches_any_skip_pattern(name_lower: str, path_with_forward_slashes: str) -> bool:
|
|
551
|
-
path_components_lower = [each_part for each_part in path_with_forward_slashes.split("/") if each_part]
|
|
552
|
-
directory_components = path_components_lower[:-1]
|
|
553
|
-
skip_directory_components = _directory_skip_components()
|
|
554
|
-
for each_directory_component in directory_components:
|
|
555
|
-
if each_directory_component in skip_directory_components:
|
|
556
|
-
return True
|
|
557
|
-
for each_pattern in SKIP_PATTERNS:
|
|
558
|
-
if each_pattern.endswith("/"):
|
|
559
|
-
if each_pattern in path_with_forward_slashes:
|
|
560
|
-
return True
|
|
561
|
-
continue
|
|
562
|
-
if each_pattern in name_lower:
|
|
563
|
-
return True
|
|
564
|
-
return False
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
def _extract_written_content(tool_name: str, tool_input: dict) -> str:
|
|
568
|
-
if tool_name == "Write":
|
|
569
|
-
return tool_input.get("content", "") or ""
|
|
570
|
-
if tool_name == "Edit":
|
|
571
|
-
return tool_input.get("new_string", "") or ""
|
|
572
|
-
if tool_name == "MultiEdit":
|
|
573
|
-
all_edits = tool_input.get("edits", []) or []
|
|
574
|
-
joined_new_strings: list[str] = []
|
|
575
|
-
for each_edit in all_edits:
|
|
576
|
-
if isinstance(each_edit, dict):
|
|
577
|
-
joined_new_strings.append(each_edit.get("new_string", "") or "")
|
|
578
|
-
return "\n".join(joined_new_strings)
|
|
579
|
-
return ""
|
|
102
|
+
written_content = _extract_written_content(tool_name, tool_input)
|
|
103
|
+
if (
|
|
104
|
+
tool_name == "Write"
|
|
105
|
+
and extension == PYTHON_SOURCE_EXTENSION
|
|
106
|
+
and _is_constants_only_python_content(written_content)
|
|
107
|
+
):
|
|
108
|
+
return True
|
|
109
|
+
return _edit_is_constants_or_import_only(tool_name, tool_input, path, extension)
|
|
580
110
|
|
|
581
111
|
|
|
582
112
|
def main() -> None:
|
|
113
|
+
"""Run the TDD gate over one PreToolUse Write, Edit, or MultiEdit payload."""
|
|
583
114
|
try:
|
|
584
115
|
input_data = json.load(sys.stdin)
|
|
585
116
|
except json.JSONDecodeError:
|
|
586
117
|
sys.exit(0)
|
|
587
|
-
|
|
588
|
-
tool_name = input_data.get("tool_name", "")
|
|
589
|
-
tool_input = input_data.get("tool_input", {})
|
|
590
|
-
file_path = tool_input.get("file_path", "")
|
|
591
|
-
|
|
118
|
+
tool_name, tool_input, file_path = _resolve_payload(input_data)
|
|
592
119
|
if not file_path:
|
|
593
120
|
sys.exit(0)
|
|
594
|
-
|
|
595
|
-
if _is_inside_dotclaude_segment(file_path) or is_ephemeral_script_path(file_path):
|
|
121
|
+
if _is_session_scratchpad_write(file_path, input_data):
|
|
596
122
|
sys.exit(0)
|
|
597
|
-
|
|
598
123
|
path = Path(file_path)
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
# Skip config/docs
|
|
602
|
-
if ext in SKIP_EXTENSIONS:
|
|
124
|
+
extension = path.suffix.lower()
|
|
125
|
+
if _is_silently_skipped(file_path, extension, path):
|
|
603
126
|
sys.exit(0)
|
|
604
|
-
|
|
605
|
-
# Skip non-production code files
|
|
606
|
-
if ext not in PRODUCTION_EXTENSIONS:
|
|
607
|
-
sys.exit(0)
|
|
608
|
-
|
|
609
|
-
# Skip test files
|
|
610
|
-
name_lower = path.name.lower()
|
|
611
|
-
path_str = str(path).lower().replace("\\", "/")
|
|
612
|
-
if _matches_any_skip_pattern(name_lower, path_str):
|
|
613
|
-
sys.exit(0)
|
|
614
|
-
|
|
615
|
-
# Block production code - require confirmation
|
|
616
|
-
# Exempt constants-only content for Write (full content provided)
|
|
617
|
-
written_content = _extract_written_content(tool_name, tool_input)
|
|
618
|
-
if tool_name == "Write" and ext == ".py" and _is_constants_only_python_content(written_content):
|
|
127
|
+
if _write_is_exempt(tool_name, tool_input, path, extension):
|
|
619
128
|
emit_allow()
|
|
620
129
|
sys.exit(0)
|
|
621
|
-
|
|
622
|
-
# Exempt Edit/MultiEdit on constants-only files when post-edit content remains constants-only
|
|
623
|
-
if tool_name in ("Edit", "MultiEdit") and ext == ".py" and path.exists():
|
|
624
|
-
existing_content = _read_candidate_text(path)
|
|
625
|
-
if existing_content is not None:
|
|
626
|
-
if _is_post_edit_import_only(existing_content, tool_name, tool_input):
|
|
627
|
-
emit_allow()
|
|
628
|
-
sys.exit(0)
|
|
629
|
-
if _is_post_edit_constants_only(existing_content, tool_name, tool_input):
|
|
630
|
-
emit_allow()
|
|
631
|
-
sys.exit(0)
|
|
632
|
-
|
|
633
130
|
all_candidates = candidate_test_paths_for(path)
|
|
634
131
|
if has_fresh_test(all_candidates, _freshness_seconds()):
|
|
635
132
|
emit_allow()
|
|
636
133
|
sys.exit(0)
|
|
637
|
-
|
|
638
134
|
emit_deny(build_deny_reason(path, all_candidates))
|
|
639
135
|
sys.exit(0)
|
|
640
136
|
|