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,63 @@
|
|
|
1
|
+
"""Detect a write that restores a tracked-but-absent production file.
|
|
2
|
+
|
|
3
|
+
::
|
|
4
|
+
|
|
5
|
+
remove orders.py (git-tracked) -> Write orders.py -> restore (allow)
|
|
6
|
+
Write brand_new.py (untracked, absent) -> new code (gate)
|
|
7
|
+
|
|
8
|
+
A full rewrite happens as remove-then-Write, so the file is absent on disk
|
|
9
|
+
between the two steps. Restoring committed code is not new production code, so
|
|
10
|
+
the gate exempts a Write whose target is absent yet tracked in git.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import subprocess
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from tdd_enforcer_parts.config.tdd_enforcer_constants import (
|
|
17
|
+
GIT_EXECUTABLE_NAME,
|
|
18
|
+
GIT_LS_FILES_SUBCOMMAND,
|
|
19
|
+
GIT_LS_FILES_TIMEOUT_SECONDS,
|
|
20
|
+
GIT_PATHSPEC_SEPARATOR,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _git_ls_files_command(file_name: str) -> list[str]:
|
|
25
|
+
return [GIT_EXECUTABLE_NAME, GIT_LS_FILES_SUBCOMMAND, GIT_PATHSPEC_SEPARATOR, file_name]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _git_tracks_path(path: Path) -> bool:
|
|
29
|
+
parent_directory = path.parent
|
|
30
|
+
if not parent_directory.is_dir():
|
|
31
|
+
return False
|
|
32
|
+
try:
|
|
33
|
+
completed = subprocess.run(
|
|
34
|
+
_git_ls_files_command(path.name),
|
|
35
|
+
cwd=str(parent_directory),
|
|
36
|
+
capture_output=True,
|
|
37
|
+
text=True,
|
|
38
|
+
check=False,
|
|
39
|
+
timeout=GIT_LS_FILES_TIMEOUT_SECONDS,
|
|
40
|
+
)
|
|
41
|
+
except (OSError, subprocess.SubprocessError):
|
|
42
|
+
return False
|
|
43
|
+
return completed.returncode == 0 and bool(completed.stdout.strip())
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def is_absent_but_tracked(path: Path) -> bool:
|
|
47
|
+
"""Return whether a write target is absent on disk yet tracked in git.
|
|
48
|
+
|
|
49
|
+
::
|
|
50
|
+
|
|
51
|
+
path missing on disk + git ls-files finds it -> True (restore)
|
|
52
|
+
path present, or git does not track it -> False
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
path: The write target.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
True only when the path does not exist but git tracks it, marking the
|
|
59
|
+
write a restore of committed code rather than new production code.
|
|
60
|
+
"""
|
|
61
|
+
if path.exists():
|
|
62
|
+
return False
|
|
63
|
+
return _git_tracks_path(path)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Path and payload classification for the TDD-enforcer gate.
|
|
2
|
+
|
|
3
|
+
Decides which write targets the gate skips silently (docs, tests, files inside a
|
|
4
|
+
``.claude`` tree) and pulls the written text out of a Write, Edit, or MultiEdit
|
|
5
|
+
payload so the gate can inspect it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from tdd_enforcer_parts.config.tdd_enforcer_constants import (
|
|
9
|
+
ALL_DIRECTORY_SKIP_COMPONENTS,
|
|
10
|
+
ALL_DOTCLAUDE_PATH_SEGMENTS,
|
|
11
|
+
ALL_PRODUCTION_EXTENSIONS,
|
|
12
|
+
ALL_SKIP_EXTENSIONS,
|
|
13
|
+
ALL_SKIP_NAME_PATTERNS,
|
|
14
|
+
NEWLINE_JOIN_SEPARATOR,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def production_extensions() -> frozenset[str]:
|
|
19
|
+
"""Return the source-file extensions the gate treats as production code."""
|
|
20
|
+
return ALL_PRODUCTION_EXTENSIONS
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def skip_extensions() -> frozenset[str]:
|
|
24
|
+
"""Return the config and documentation extensions the gate skips."""
|
|
25
|
+
return ALL_SKIP_EXTENSIONS
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _skip_patterns() -> frozenset[str]:
|
|
29
|
+
return ALL_SKIP_NAME_PATTERNS
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _dotclaude_path_segments() -> frozenset[str]:
|
|
33
|
+
return ALL_DOTCLAUDE_PATH_SEGMENTS
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _directory_skip_components() -> frozenset[str]:
|
|
37
|
+
return ALL_DIRECTORY_SKIP_COMPONENTS
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _is_inside_dotclaude_segment(file_path_string: str) -> bool:
|
|
41
|
+
"""Return whether any path segment is exactly a ``.claude`` directory.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
file_path_string: The write target path, in either separator style.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
True when a ``.claude`` directory sits on the path, so the gate leaves
|
|
48
|
+
agent-config edits alone.
|
|
49
|
+
"""
|
|
50
|
+
normalized_path = file_path_string.replace("\\", "/")
|
|
51
|
+
dotclaude_segments = _dotclaude_path_segments()
|
|
52
|
+
for each_segment in normalized_path.split("/"):
|
|
53
|
+
if each_segment and each_segment in dotclaude_segments:
|
|
54
|
+
return True
|
|
55
|
+
return False
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _path_has_skip_directory_component(path_with_forward_slashes: str) -> bool:
|
|
59
|
+
all_components = [each_part for each_part in path_with_forward_slashes.split("/") if each_part]
|
|
60
|
+
directory_components = all_components[:-1]
|
|
61
|
+
skip_directory_components = _directory_skip_components()
|
|
62
|
+
return any(each in skip_directory_components for each in directory_components)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _skip_pattern_matches(pattern: str, name_lower: str, path_with_forward_slashes: str) -> bool:
|
|
66
|
+
if pattern.endswith("/"):
|
|
67
|
+
return pattern in path_with_forward_slashes
|
|
68
|
+
return pattern in name_lower
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _name_or_path_matches_skip_pattern(name_lower: str, path_with_forward_slashes: str) -> bool:
|
|
72
|
+
for each_pattern in _skip_patterns():
|
|
73
|
+
if _skip_pattern_matches(each_pattern, name_lower, path_with_forward_slashes):
|
|
74
|
+
return True
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _matches_any_skip_pattern(name_lower: str, path_with_forward_slashes: str) -> bool:
|
|
79
|
+
"""Return whether a path names a test, fixture, mock, or stub file.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
name_lower: The lower-cased file name.
|
|
83
|
+
path_with_forward_slashes: The lower-cased path with forward slashes.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
True when a skip directory component or a skip name pattern matches.
|
|
87
|
+
"""
|
|
88
|
+
if _path_has_skip_directory_component(path_with_forward_slashes):
|
|
89
|
+
return True
|
|
90
|
+
return _name_or_path_matches_skip_pattern(name_lower, path_with_forward_slashes)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _multiedit_new_strings(all_edits: list) -> list[str]:
|
|
94
|
+
all_new_strings: list[str] = []
|
|
95
|
+
for each_edit in all_edits:
|
|
96
|
+
if isinstance(each_edit, dict):
|
|
97
|
+
all_new_strings.append(each_edit.get("new_string", "") or "")
|
|
98
|
+
return all_new_strings
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _extract_written_content(tool_name: str, tool_input: dict) -> str:
|
|
102
|
+
"""Return the text a Write, Edit, or MultiEdit payload would introduce.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
tool_name: The intercepted tool name.
|
|
106
|
+
tool_input: The intercepted tool's input payload.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
The written content for Write, the replacement for Edit, the joined
|
|
110
|
+
replacements for MultiEdit, or an empty string for any other tool.
|
|
111
|
+
"""
|
|
112
|
+
if tool_name == "Write":
|
|
113
|
+
return tool_input.get("content", "") or ""
|
|
114
|
+
if tool_name == "Edit":
|
|
115
|
+
return tool_input.get("new_string", "") or ""
|
|
116
|
+
if tool_name == "MultiEdit":
|
|
117
|
+
all_edits = tool_input.get("edits", []) or []
|
|
118
|
+
return NEWLINE_JOIN_SEPARATOR.join(_multiedit_new_strings(all_edits))
|
|
119
|
+
return ""
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Put the blocking and hooks directories on sys.path for the parts tests."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
_blocking_directory = str(Path(__file__).resolve().parents[2])
|
|
7
|
+
_hooks_directory = str(Path(__file__).resolve().parents[3])
|
|
8
|
+
for each_bootstrap_directory in (_blocking_directory, _hooks_directory):
|
|
9
|
+
if each_bootstrap_directory not in sys.path:
|
|
10
|
+
sys.path.insert(0, each_bootstrap_directory)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Behavioral tests for the candidate_paths parts module."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from tdd_enforcer_parts import candidate_paths
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_candidate_paths_offers_flat_stem_candidates_first(tmp_path: Path) -> None:
|
|
9
|
+
(tmp_path / ".git").mkdir()
|
|
10
|
+
all_candidates = candidate_paths.candidate_test_paths_for(tmp_path / "orders.py")
|
|
11
|
+
assert all_candidates[0] == tmp_path / "test_orders.py"
|
|
12
|
+
assert all_candidates[1] == tmp_path / "orders_test.py"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_candidate_paths_returns_empty_for_unknown_extension(tmp_path: Path) -> None:
|
|
16
|
+
assert candidate_paths.candidate_test_paths_for(tmp_path / "data.rs") == []
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_candidate_paths_offers_javascript_test_siblings(tmp_path: Path) -> None:
|
|
20
|
+
all_candidates = candidate_paths.candidate_test_paths_for(tmp_path / "Button.tsx")
|
|
21
|
+
assert tmp_path / "Button.test.tsx" in all_candidates
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_ancestor_tests_directories_finds_sibling_tests(tmp_path: Path) -> None:
|
|
25
|
+
(tmp_path / ".git").mkdir()
|
|
26
|
+
package = tmp_path / "pkg"
|
|
27
|
+
package.mkdir()
|
|
28
|
+
(package / "tests").mkdir()
|
|
29
|
+
all_pairs = candidate_paths._ancestor_tests_directories(package)
|
|
30
|
+
all_tests_directories = [each_tests_directory for _, each_tests_directory in all_pairs]
|
|
31
|
+
assert package / "tests" in all_tests_directories
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Behavioral tests for the content_analysis parts module."""
|
|
2
|
+
|
|
3
|
+
from tdd_enforcer_parts import content_analysis
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_constants_only_accepts_module_docstring_and_assignment() -> None:
|
|
7
|
+
content = '"""doc."""\nMAX_ORDERS = 3\n'
|
|
8
|
+
assert content_analysis._is_constants_only_python_content(content) is True
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_constants_only_rejects_function_definition() -> None:
|
|
12
|
+
assert content_analysis._is_constants_only_python_content("def f():\n return 1\n") is False
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_post_edit_import_only_allows_import_removal() -> None:
|
|
16
|
+
existing = "import os\n\ndef run(): return 1\n"
|
|
17
|
+
tool_input = {"old_string": "import os\n", "new_string": ""}
|
|
18
|
+
assert content_analysis._is_post_edit_import_only(existing, "Edit", tool_input) is True
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_post_edit_import_only_blocks_import_swap() -> None:
|
|
22
|
+
existing = "import os\n\ndef run(): return 1\n"
|
|
23
|
+
tool_input = {"old_string": "import os", "new_string": "import sys"}
|
|
24
|
+
assert content_analysis._is_post_edit_import_only(existing, "Edit", tool_input) is False
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_post_edit_constants_only_allows_constant_value_change() -> None:
|
|
28
|
+
existing = '"""doc."""\nMAX_ORDERS = 3\n'
|
|
29
|
+
tool_input = {"old_string": "MAX_ORDERS = 3", "new_string": "MAX_ORDERS = 5"}
|
|
30
|
+
assert content_analysis._is_post_edit_constants_only(existing, "Edit", tool_input) is True
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Behavioral tests for the decisions parts module."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
from tdd_enforcer_parts import decisions
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_build_deny_reason_names_candidates_and_production_path() -> None:
|
|
11
|
+
reason = decisions.build_deny_reason(Path("pkg/orders.py"), [Path("pkg/test_orders.py")])
|
|
12
|
+
assert "orders.py" in reason
|
|
13
|
+
assert "test_orders.py" in reason
|
|
14
|
+
assert "propose" in reason.lower() or "enhancement" in reason.lower()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_build_deny_reason_points_exemption_guidance_at_entry_hook() -> None:
|
|
18
|
+
reason = decisions.build_deny_reason(Path("pkg/orders.py"), [])
|
|
19
|
+
assert "tdd_enforcer.py" in reason
|
|
20
|
+
assert "decisions.py" not in reason
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_emit_allow_writes_allow_decision(capsys: pytest.CaptureFixture[str]) -> None:
|
|
24
|
+
decisions.emit_allow()
|
|
25
|
+
parsed = json.loads(capsys.readouterr().out)
|
|
26
|
+
assert parsed["hookSpecificOutput"]["permissionDecision"] == "allow"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_emit_deny_writes_deny_and_system_message(capsys: pytest.CaptureFixture[str]) -> None:
|
|
30
|
+
decisions.emit_deny("blocked because no fresh test")
|
|
31
|
+
parsed = json.loads(capsys.readouterr().out)
|
|
32
|
+
assert parsed["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
33
|
+
assert parsed["suppressOutput"] is True
|
|
34
|
+
assert isinstance(parsed["systemMessage"], str)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Behavioral tests for the freshness parts module."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import time
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from tdd_enforcer_parts import freshness
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_has_fresh_test_true_for_recent_test_file(tmp_path: Path) -> None:
|
|
11
|
+
test_file = tmp_path / "test_orders.py"
|
|
12
|
+
test_file.write_text("def test_x(): pass\n")
|
|
13
|
+
assert freshness.has_fresh_test([test_file], freshness._freshness_seconds()) is True
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_has_fresh_test_false_for_stale_test_file(tmp_path: Path) -> None:
|
|
17
|
+
test_file = tmp_path / "test_orders.py"
|
|
18
|
+
test_file.write_text("def test_x(): pass\n")
|
|
19
|
+
window = freshness._freshness_seconds()
|
|
20
|
+
stale_timestamp = time.time() - window - 60
|
|
21
|
+
os.utime(test_file, (stale_timestamp, stale_timestamp))
|
|
22
|
+
assert freshness.has_fresh_test([test_file], window) is False
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_has_fresh_test_false_when_no_test_evidence(tmp_path: Path) -> None:
|
|
26
|
+
test_file = tmp_path / "test_orders.py"
|
|
27
|
+
test_file.write_text("x = 1\n")
|
|
28
|
+
assert freshness.has_fresh_test([test_file], freshness._freshness_seconds()) is False
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Behavioral tests for the git_tracking parts module."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from tdd_enforcer_parts import git_tracking
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _git(repository_root: Path, *arguments: str) -> None:
|
|
11
|
+
subprocess.run(
|
|
12
|
+
["git", *arguments],
|
|
13
|
+
cwd=str(repository_root),
|
|
14
|
+
check=True,
|
|
15
|
+
capture_output=True,
|
|
16
|
+
env={**os.environ, "GIT_CONFIG_GLOBAL": os.devnull, "GIT_CONFIG_SYSTEM": os.devnull},
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _init_repository(repository_root: Path) -> None:
|
|
21
|
+
_git(repository_root, "init", "-q")
|
|
22
|
+
_git(repository_root, "config", "user.email", "test@example.com")
|
|
23
|
+
_git(repository_root, "config", "user.name", "Test")
|
|
24
|
+
_git(repository_root, "config", "commit.gpgsign", "false")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_absent_but_tracked_true_after_committed_file_removed(tmp_path: Path) -> None:
|
|
28
|
+
_init_repository(tmp_path)
|
|
29
|
+
tracked = tmp_path / "service.py"
|
|
30
|
+
tracked.write_text("def serve(): return 1\n")
|
|
31
|
+
_git(tmp_path, "add", "-A")
|
|
32
|
+
_git(tmp_path, "commit", "-q", "-m", "init")
|
|
33
|
+
tracked.unlink()
|
|
34
|
+
assert git_tracking.is_absent_but_tracked(tracked) is True
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_absent_but_tracked_false_for_untracked_absent_file(tmp_path: Path) -> None:
|
|
38
|
+
_init_repository(tmp_path)
|
|
39
|
+
assert git_tracking.is_absent_but_tracked(tmp_path / "ghost.py") is False
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_absent_but_tracked_false_when_file_present(tmp_path: Path) -> None:
|
|
43
|
+
_init_repository(tmp_path)
|
|
44
|
+
present = tmp_path / "present.py"
|
|
45
|
+
present.write_text("def here(): return 1\n")
|
|
46
|
+
_git(tmp_path, "add", "-A")
|
|
47
|
+
_git(tmp_path, "commit", "-q", "-m", "init")
|
|
48
|
+
assert git_tracking.is_absent_but_tracked(present) is False
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Behavioral tests for the path_classification parts module."""
|
|
2
|
+
|
|
3
|
+
from tdd_enforcer_parts import path_classification
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_production_extensions_includes_python_and_typescript() -> None:
|
|
7
|
+
extensions = path_classification.production_extensions()
|
|
8
|
+
assert ".py" in extensions
|
|
9
|
+
assert ".tsx" in extensions
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_skip_extensions_includes_markdown_and_json() -> None:
|
|
13
|
+
extensions = path_classification.skip_extensions()
|
|
14
|
+
assert ".md" in extensions
|
|
15
|
+
assert ".json" in extensions
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_is_inside_dotclaude_segment_matches_exact_segment_only() -> None:
|
|
19
|
+
assert path_classification._is_inside_dotclaude_segment("/home/user/.claude/agent.py") is True
|
|
20
|
+
assert path_classification._is_inside_dotclaude_segment("/src/my.claude.helpers.py") is False
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_matches_any_skip_pattern_flags_test_file_names() -> None:
|
|
24
|
+
assert (
|
|
25
|
+
path_classification._matches_any_skip_pattern("test_orders.py", "pkg/test_orders.py")
|
|
26
|
+
is True
|
|
27
|
+
)
|
|
28
|
+
assert path_classification._matches_any_skip_pattern("orders.py", "pkg/orders.py") is False
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_extract_written_content_reads_write_and_multiedit() -> None:
|
|
32
|
+
assert path_classification._extract_written_content("Write", {"content": "x"}) == "x"
|
|
33
|
+
joined = path_classification._extract_written_content(
|
|
34
|
+
"MultiEdit", {"edits": [{"new_string": "a"}, {"new_string": "b"}]}
|
|
35
|
+
)
|
|
36
|
+
assert joined == "a\nb"
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""End-to-end tests for the inventory/orphan pending-intent deadlock resolution.
|
|
2
|
+
|
|
3
|
+
Each test drives the two real hook binaries through their production ``main()``
|
|
4
|
+
stdin path over a temp directory tree whose ``CLAUDE.md`` inventory names two
|
|
5
|
+
sibling files. A shared ``session_id`` and an isolated ``TMPDIR`` make the two
|
|
6
|
+
hooks read one shared intent-records file, so a deny by one hook opens the gate
|
|
7
|
+
for the sibling's second write — in either add order.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
BLOCKING_DIRECTORY = Path(__file__).resolve().parent
|
|
17
|
+
|
|
18
|
+
INVENTORY_HOOK = "package_inventory_stale_blocker.py"
|
|
19
|
+
ORPHAN_HOOK = "claude_md_orphan_file_blocker.py"
|
|
20
|
+
|
|
21
|
+
SESSION_ID = "deadlock-session"
|
|
22
|
+
|
|
23
|
+
INVENTORY_TABLE_HEADER = "# pkg\n\n| File | Purpose |\n|---|---|\n"
|
|
24
|
+
ALPHA_ROW = "| `alpha.py` | Does A |\n"
|
|
25
|
+
BETA_ROW = "| `beta.py` | Does B |\n"
|
|
26
|
+
GAMMA_ROW = "| `gamma.py` | Does G |\n"
|
|
27
|
+
EPSILON_ROW = "| `epsilon.py` | Does E |\n"
|
|
28
|
+
|
|
29
|
+
BASE_INVENTORY = INVENTORY_TABLE_HEADER + ALPHA_ROW + BETA_ROW
|
|
30
|
+
INVENTORY_WITH_GAMMA = BASE_INVENTORY + GAMMA_ROW
|
|
31
|
+
INVENTORY_WITH_EPSILON = BASE_INVENTORY + EPSILON_ROW
|
|
32
|
+
|
|
33
|
+
MODULE_BODY = "x = 1\n"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _package_directory(tmp_path: Path) -> Path:
|
|
37
|
+
"""Return a package directory holding a CLAUDE.md table naming two siblings."""
|
|
38
|
+
package_directory = tmp_path / "pkg"
|
|
39
|
+
package_directory.mkdir()
|
|
40
|
+
(package_directory / "CLAUDE.md").write_text(BASE_INVENTORY, encoding="utf-8")
|
|
41
|
+
(package_directory / "alpha.py").write_text(MODULE_BODY, encoding="utf-8")
|
|
42
|
+
(package_directory / "beta.py").write_text(MODULE_BODY, encoding="utf-8")
|
|
43
|
+
return package_directory
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _run_hook(
|
|
47
|
+
hook_filename: str, tool_name: str, tool_input: dict, temp_directory: Path
|
|
48
|
+
) -> subprocess.CompletedProcess:
|
|
49
|
+
"""Run one hook binary over a payload, sharing an isolated intent-records store."""
|
|
50
|
+
payload = json.dumps(
|
|
51
|
+
{"tool_name": tool_name, "tool_input": tool_input, "session_id": SESSION_ID}
|
|
52
|
+
)
|
|
53
|
+
hook_environment = dict(os.environ)
|
|
54
|
+
hook_environment["TMPDIR"] = str(temp_directory)
|
|
55
|
+
return subprocess.run(
|
|
56
|
+
[sys.executable, str(BLOCKING_DIRECTORY / hook_filename)],
|
|
57
|
+
input=payload,
|
|
58
|
+
capture_output=True,
|
|
59
|
+
text=True,
|
|
60
|
+
check=False,
|
|
61
|
+
env=hook_environment,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _decision(completed: subprocess.CompletedProcess) -> str | None:
|
|
66
|
+
"""Return the permission decision a hook emitted, or None when it allowed."""
|
|
67
|
+
if not completed.stdout.strip():
|
|
68
|
+
return None
|
|
69
|
+
return json.loads(completed.stdout)["hookSpecificOutput"]["permissionDecision"]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _reason(completed: subprocess.CompletedProcess) -> str:
|
|
73
|
+
"""Return the deny reason a hook emitted."""
|
|
74
|
+
return json.loads(completed.stdout)["hookSpecificOutput"]["permissionDecisionReason"]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_file_first_order_resolves_after_one_deny(tmp_path: Path) -> None:
|
|
78
|
+
package_directory = _package_directory(tmp_path)
|
|
79
|
+
new_file = package_directory / "gamma.py"
|
|
80
|
+
claude_md = package_directory / "CLAUDE.md"
|
|
81
|
+
|
|
82
|
+
first_write = _run_hook(
|
|
83
|
+
INVENTORY_HOOK, "Write", {"file_path": str(new_file), "content": MODULE_BODY}, tmp_path
|
|
84
|
+
)
|
|
85
|
+
assert _decision(first_write) == "deny"
|
|
86
|
+
assert "gamma.py" in _reason(first_write)
|
|
87
|
+
assert "recorded" in _reason(first_write)
|
|
88
|
+
|
|
89
|
+
row_write = _run_hook(
|
|
90
|
+
ORPHAN_HOOK,
|
|
91
|
+
"Write",
|
|
92
|
+
{"file_path": str(claude_md), "content": INVENTORY_WITH_GAMMA},
|
|
93
|
+
tmp_path,
|
|
94
|
+
)
|
|
95
|
+
assert _decision(row_write) is None
|
|
96
|
+
|
|
97
|
+
claude_md.write_text(INVENTORY_WITH_GAMMA, encoding="utf-8")
|
|
98
|
+
retry_write = _run_hook(
|
|
99
|
+
INVENTORY_HOOK, "Write", {"file_path": str(new_file), "content": MODULE_BODY}, tmp_path
|
|
100
|
+
)
|
|
101
|
+
assert _decision(retry_write) is None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_row_first_order_resolves_after_one_deny(tmp_path: Path) -> None:
|
|
105
|
+
package_directory = _package_directory(tmp_path)
|
|
106
|
+
new_file = package_directory / "gamma.py"
|
|
107
|
+
claude_md = package_directory / "CLAUDE.md"
|
|
108
|
+
|
|
109
|
+
first_row = _run_hook(
|
|
110
|
+
ORPHAN_HOOK,
|
|
111
|
+
"Write",
|
|
112
|
+
{"file_path": str(claude_md), "content": INVENTORY_WITH_GAMMA},
|
|
113
|
+
tmp_path,
|
|
114
|
+
)
|
|
115
|
+
assert _decision(first_row) == "deny"
|
|
116
|
+
assert "gamma.py" in _reason(first_row)
|
|
117
|
+
assert "recorded" in _reason(first_row)
|
|
118
|
+
|
|
119
|
+
file_write = _run_hook(
|
|
120
|
+
INVENTORY_HOOK, "Write", {"file_path": str(new_file), "content": MODULE_BODY}, tmp_path
|
|
121
|
+
)
|
|
122
|
+
assert _decision(file_write) is None
|
|
123
|
+
|
|
124
|
+
new_file.write_text(MODULE_BODY, encoding="utf-8")
|
|
125
|
+
retry_row = _run_hook(
|
|
126
|
+
ORPHAN_HOOK,
|
|
127
|
+
"Write",
|
|
128
|
+
{"file_path": str(claude_md), "content": INVENTORY_WITH_GAMMA},
|
|
129
|
+
tmp_path,
|
|
130
|
+
)
|
|
131
|
+
assert _decision(retry_row) is None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_new_file_with_no_row_attempt_stays_blocked(tmp_path: Path) -> None:
|
|
135
|
+
package_directory = _package_directory(tmp_path)
|
|
136
|
+
new_file = package_directory / "delta.py"
|
|
137
|
+
file_write = _run_hook(
|
|
138
|
+
INVENTORY_HOOK, "Write", {"file_path": str(new_file), "content": MODULE_BODY}, tmp_path
|
|
139
|
+
)
|
|
140
|
+
assert _decision(file_write) == "deny"
|
|
141
|
+
assert "delta.py" in _reason(file_write)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_row_naming_uncreated_file_stays_blocked(tmp_path: Path) -> None:
|
|
145
|
+
package_directory = _package_directory(tmp_path)
|
|
146
|
+
claude_md = package_directory / "CLAUDE.md"
|
|
147
|
+
row_write = _run_hook(
|
|
148
|
+
ORPHAN_HOOK,
|
|
149
|
+
"Write",
|
|
150
|
+
{"file_path": str(claude_md), "content": INVENTORY_WITH_EPSILON},
|
|
151
|
+
tmp_path,
|
|
152
|
+
)
|
|
153
|
+
assert _decision(row_write) == "deny"
|
|
154
|
+
assert "epsilon.py" in _reason(row_write)
|