claude-dev-env 2.30.1 → 2.31.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/.agents/agents/pr-description-writer.md +1 -1
- package/AGENTS.md +6 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_enforcer_loading.py +37 -0
- package/bin/install.prune.test.mjs +4 -2
- package/docs/CODE_RULES.md +12 -0
- package/hooks/advisory/migration_safety_advisor.py +25 -1
- package/hooks/advisory/refactor_guard.py +85 -18
- package/hooks/advisory/test_migration_safety_advisor.py +89 -5
- package/hooks/advisory/test_refactor_guard.py +87 -0
- package/hooks/advisory/test_refactor_guard_advisory.py +72 -1
- package/hooks/blocking/code_rules_constants_config.py +7 -6
- package/hooks/blocking/code_rules_duplicate_body.py +272 -100
- package/hooks/blocking/code_rules_enforcer.py +89 -45
- package/hooks/blocking/code_rules_shared.py +64 -14
- package/hooks/blocking/codex_apply_patch.py +136 -21
- package/hooks/blocking/config/codex_apply_patch_constants.py +13 -0
- package/hooks/blocking/conftest.py +70 -0
- package/hooks/blocking/duplicate_rmtree_helper_blocker.py +26 -16
- package/hooks/blocking/hook_prose_detector_consistency.py +30 -20
- package/hooks/blocking/pii_payload_scan.py +61 -0
- package/hooks/blocking/pii_prevention_blocker.py +5 -0
- package/hooks/blocking/pr_description_writer_gate.py +256 -0
- package/hooks/blocking/pre_tool_use_dispatcher.py +1 -1
- package/hooks/blocking/sensitive_file_protector.py +18 -3
- package/hooks/blocking/shell_substitution_blocker.py +25 -47
- package/hooks/blocking/stale_comment_reference_blocker.py +94 -24
- package/hooks/blocking/state_description_blocker.py +39 -26
- package/hooks/blocking/subprocess_budget_completeness.py +17 -43
- package/hooks/blocking/tdd_enforcer.py +63 -16
- package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +11 -1
- package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +180 -0
- package/hooks/blocking/tdd_enforcer_parts/decisions.py +6 -2
- package/hooks/blocking/tdd_enforcer_parts/freshness.py +18 -3
- package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +66 -1
- package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +143 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +12 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +63 -0
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +1 -0
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +81 -2
- package/hooks/blocking/test_code_rules_enforcer_codex_apply_patch.py +35 -0
- package/hooks/blocking/test_code_rules_enforcer_config_path.py +29 -16
- package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +2 -0
- package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +12 -1
- package/hooks/blocking/test_code_rules_enforcer_ephemeral_repository_root.py +118 -0
- package/hooks/blocking/test_code_rules_enforcer_multi_edit.py +118 -0
- package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +2 -0
- package/hooks/blocking/test_code_rules_enforcer_precheck_forecast.py +2 -0
- package/hooks/blocking/test_code_rules_enforcer_same_file_inline_duplicate.py +244 -0
- package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +29 -28
- package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +24 -0
- package/hooks/blocking/test_code_rules_enforcer_stage.py +2 -0
- package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +2 -0
- package/hooks/blocking/test_codex_apply_patch.py +43 -0
- package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +68 -0
- package/hooks/blocking/test_hook_prose_detector_consistency.py +41 -0
- package/hooks/blocking/test_pii_payload_scan.py +65 -2
- package/hooks/blocking/test_pii_prevention_blocker.py +80 -10
- package/hooks/blocking/test_pr_description_writer_gate.py +234 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +215 -25
- package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +18 -6
- package/hooks/blocking/test_sensitive_file_protector.py +42 -2
- package/hooks/blocking/test_stale_comment_reference_blocker.py +71 -0
- package/hooks/blocking/test_state_description_blocker.py +163 -49
- package/hooks/blocking/test_subprocess_budget_completeness.py +67 -0
- package/hooks/blocking/test_tdd_enforcer.py +246 -2
- package/hooks/blocking/test_windows_rmtree_blocker.py +63 -0
- package/hooks/blocking/test_write_existing_file_blocker.py +122 -0
- package/hooks/blocking/windows_rmtree_blocker.py +21 -7
- package/hooks/blocking/write_existing_file_blocker.py +75 -29
- package/hooks/conftest.py +47 -0
- package/hooks/hooks.json +7 -12
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +3 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +4 -0
- package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +3 -0
- package/hooks/hooks_constants/hook_prose_detector_consistency_constants.py +7 -2
- package/hooks/hooks_constants/migration_safety_advisor_constants.py +7 -0
- package/hooks/hooks_constants/multi_edit_reconstruction.py +44 -3
- package/hooks/hooks_constants/pii_prevention_constants.py +9 -7
- package/hooks/hooks_constants/pr_description_writer_gate_constants.py +73 -0
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +31 -14
- package/hooks/hooks_constants/session_edit_stage_gate_constants.py +15 -1
- package/hooks/hooks_constants/state_description_blocker_constants.py +1 -0
- package/hooks/hooks_constants/subprocess_budget_completeness_content.py +71 -0
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +2 -0
- package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +2 -0
- package/hooks/hooks_constants/test_multi_edit_reconstruction.py +103 -0
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +139 -25
- package/hooks/hooks_constants/test_subprocess_budget_completeness_content.py +103 -0
- package/hooks/hooks_constants/validation_phase_constants.py +2 -0
- package/hooks/hooks_constants/workflow_substitution_slot_blocker_constants.py +7 -3
- package/hooks/observability/pr_description_writer_spawn_tracker.py +113 -0
- package/hooks/observability/session_file_edit_tracker.py +25 -4
- package/hooks/observability/test_pr_description_writer_spawn_tracker.py +150 -0
- package/hooks/observability/test_session_file_edit_tracker.py +32 -0
- package/hooks/validation/hook_format_validator.py +23 -9
- package/hooks/validation/test_hook_format_validator.py +58 -0
- package/hooks/validators/README.md +1 -1
- package/hooks/validators/abbreviation_checks.py +6 -8
- package/hooks/validators/code_quality_checks.py +4 -4
- package/hooks/validators/config/abbreviation_checks_constants.py +3 -0
- package/hooks/validators/config/baseline_identity_constants.py +12 -0
- package/hooks/validators/config/directory_exemption_constants.py +44 -1
- package/hooks/validators/config/fast_save_validator_constants.py +32 -0
- package/hooks/validators/config/hook_timing_harness_constants.py +3 -1
- package/hooks/validators/config/test_abbreviation_checks_constants.py +11 -0
- package/hooks/validators/config/validator_base_constants.py +9 -0
- package/hooks/validators/fast_save_validators.py +275 -0
- package/hooks/validators/hook_timing_harness.py +8 -4
- package/hooks/validators/pr_reference_checks.py +2 -2
- package/hooks/validators/python_antipattern_checks.py +3 -3
- package/hooks/validators/python_style_checks.py +37 -62
- package/hooks/validators/python_style_helpers.py +16 -2
- package/hooks/validators/python_style_import_bootstrap.py +83 -0
- package/hooks/validators/react_checks.py +2 -9
- package/hooks/validators/run_all_validators.py +201 -155
- package/hooks/validators/security_checks.py +3 -3
- package/hooks/validators/test_abbreviation_checks.py +20 -0
- package/hooks/validators/test_directory_exemption_constants.py +30 -24
- package/hooks/validators/test_fast_save_validators.py +172 -0
- package/hooks/validators/test_hook_timing_harness.py +10 -1
- package/hooks/validators/test_python_style_checks.py +9 -0
- package/hooks/validators/test_python_style_checks_import_order.py +81 -0
- package/hooks/validators/test_python_style_helpers.py +35 -0
- package/hooks/validators/test_python_style_import_bootstrap.py +69 -0
- package/hooks/validators/test_run_all_validators.py +46 -0
- package/hooks/validators/test_run_all_validators_pretooluse.py +163 -0
- package/hooks/validators/test_run_all_validators_repository_root.py +202 -0
- package/hooks/validators/test_validator_base.py +51 -2
- package/hooks/validators/todo_checks.py +2 -2
- package/hooks/validators/type_safety_checks.py +3 -3
- package/hooks/validators/useless_test_checks.py +3 -3
- package/hooks/validators/validator_base.py +73 -1
- package/package.json +1 -1
- package/rules/docstring-prose-matches-implementation.md +1 -1
- package/rules/git-workflow.md +2 -0
- package/rules/testing.md +4 -0
- package/scripts/codex_compat_materializer.py +5 -0
- package/scripts/dev_env_scripts_constants/grok_worker_constants.py +15 -0
- package/scripts/grok_headless_runner.py +15 -12
- package/scripts/shared_tree_paths.py +70 -0
- package/scripts/test_shared_tree_paths.py +143 -0
- package/scripts/tests/test_codex_compat_materializer.py +37 -11
- package/hooks/blocking/tests/conftest.py +0 -10
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Persist each candidate test file's last-observed content hash per session.
|
|
2
|
+
|
|
3
|
+
The freshness check alone cannot tell a real test edit from a bare touch:
|
|
4
|
+
both refresh the file's mtime, so a touch reopens the TDD gate for every
|
|
5
|
+
production write it looks at afterward. This module remembers what a
|
|
6
|
+
candidate test file contained the last time the gate looked at it, keyed by
|
|
7
|
+
session and repository root, so a touch that leaves the content unchanged no
|
|
8
|
+
longer satisfies the gate.
|
|
9
|
+
|
|
10
|
+
::
|
|
11
|
+
|
|
12
|
+
first sight, dirty + fresh + real test -> allow, record hash + time
|
|
13
|
+
first sight, otherwise -> deny, record nothing
|
|
14
|
+
seen before, hash now differs -> allow, record new hash + time
|
|
15
|
+
seen before, hash matches, in window -> allow (green/refactor continues)
|
|
16
|
+
seen before, hash matches, expired -> deny
|
|
17
|
+
|
|
18
|
+
The state lives in one JSON file per (session, repository root) pair under the
|
|
19
|
+
OS temp directory, shaped like the per-session tracker in
|
|
20
|
+
hooks/observability/session_file_edit_tracker.py, so two worktrees open in one
|
|
21
|
+
session never share a file.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import hashlib
|
|
27
|
+
import json
|
|
28
|
+
import tempfile
|
|
29
|
+
import time
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
from atomic_file_writer import write_text_atomically
|
|
33
|
+
from hooks_constants.session_edit_stage_gate_constants import (
|
|
34
|
+
SESSION_ID_UNSAFE_CHARACTERS_PATTERN,
|
|
35
|
+
STATE_FILE_ATOMIC_WRITE_SUFFIX,
|
|
36
|
+
STATE_FILE_DEFAULT_SESSION_ID,
|
|
37
|
+
STATE_FILE_JSON_INDENT_SPACES,
|
|
38
|
+
)
|
|
39
|
+
from hooks_constants.setup_project_paths_constants import UTF8_ENCODING
|
|
40
|
+
from json_file_reader import read_json_object
|
|
41
|
+
from tdd_enforcer_parts import freshness, git_tracking
|
|
42
|
+
from tdd_enforcer_parts.config.tdd_enforcer_constants import (
|
|
43
|
+
HASH_STATE_FILE_PREFIX,
|
|
44
|
+
HASH_STATE_FILE_SUFFIX,
|
|
45
|
+
REPOSITORY_ROOT_DIGEST_LENGTH,
|
|
46
|
+
STORED_CHANGED_AT_KEY,
|
|
47
|
+
STORED_CONTENT_HASH_KEY,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _sanitized_session_id(session_id: str) -> str:
|
|
52
|
+
sanitized_session_id = SESSION_ID_UNSAFE_CHARACTERS_PATTERN.sub("", session_id)
|
|
53
|
+
return sanitized_session_id or STATE_FILE_DEFAULT_SESSION_ID
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _repository_root_digest(repository_root: str) -> str:
|
|
57
|
+
full_digest = hashlib.sha256(repository_root.encode("utf-8")).hexdigest()
|
|
58
|
+
return full_digest[:REPOSITORY_ROOT_DIGEST_LENGTH]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _state_file_path(session_id: str, repository_root: str) -> Path:
|
|
62
|
+
file_name = (
|
|
63
|
+
f"{HASH_STATE_FILE_PREFIX}{_sanitized_session_id(session_id)}-"
|
|
64
|
+
f"{_repository_root_digest(repository_root)}{HASH_STATE_FILE_SUFFIX}"
|
|
65
|
+
)
|
|
66
|
+
return Path(tempfile.gettempdir()) / file_name
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _state_key_for(candidate_path: Path) -> str:
|
|
70
|
+
try:
|
|
71
|
+
return str(candidate_path.resolve())
|
|
72
|
+
except OSError:
|
|
73
|
+
return str(candidate_path)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _load_state(state_file: Path) -> dict[str, object]:
|
|
77
|
+
return read_json_object(state_file, encoding=UTF8_ENCODING) or {}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _atomic_write_state(state_file: Path, all_stored_hashes: dict[str, object]) -> None:
|
|
81
|
+
write_text_atomically(
|
|
82
|
+
state_file,
|
|
83
|
+
json.dumps(all_stored_hashes, indent=STATE_FILE_JSON_INDENT_SPACES),
|
|
84
|
+
encoding=UTF8_ENCODING,
|
|
85
|
+
temporary_prefix=HASH_STATE_FILE_PREFIX,
|
|
86
|
+
temporary_suffix=STATE_FILE_ATOMIC_WRITE_SUFFIX,
|
|
87
|
+
should_reap_orphans=False,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _content_hash(candidate_text: str) -> str:
|
|
92
|
+
return hashlib.sha256(candidate_text.encode(UTF8_ENCODING)).hexdigest()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _recorded_entry(current_hash: str, current_time: float) -> dict[str, object]:
|
|
96
|
+
return {STORED_CONTENT_HASH_KEY: current_hash, STORED_CHANGED_AT_KEY: current_time}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _is_qualifying_first_sight(
|
|
100
|
+
candidate_path: Path, candidate_text: str, current_time: float, freshness_seconds: int
|
|
101
|
+
) -> bool:
|
|
102
|
+
candidate_mtime = freshness._safe_mtime(candidate_path)
|
|
103
|
+
if candidate_mtime is None or current_time - candidate_mtime > freshness_seconds:
|
|
104
|
+
return False
|
|
105
|
+
if not freshness.has_test_evidence_in(candidate_text):
|
|
106
|
+
return False
|
|
107
|
+
return git_tracking.has_uncommitted_change_from_head(candidate_path)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _evaluate_candidate(
|
|
111
|
+
candidate_path: Path,
|
|
112
|
+
stored_entry: object,
|
|
113
|
+
current_time: float,
|
|
114
|
+
freshness_seconds: int,
|
|
115
|
+
) -> tuple[bool, dict[str, object] | None]:
|
|
116
|
+
candidate_text = freshness._read_candidate_text(candidate_path)
|
|
117
|
+
if candidate_text is None:
|
|
118
|
+
return False, None
|
|
119
|
+
current_hash = _content_hash(candidate_text)
|
|
120
|
+
if not isinstance(stored_entry, dict):
|
|
121
|
+
if _is_qualifying_first_sight(
|
|
122
|
+
candidate_path, candidate_text, current_time, freshness_seconds
|
|
123
|
+
):
|
|
124
|
+
return True, _recorded_entry(current_hash, current_time)
|
|
125
|
+
return False, None
|
|
126
|
+
if current_hash != stored_entry.get(STORED_CONTENT_HASH_KEY):
|
|
127
|
+
return True, _recorded_entry(current_hash, current_time)
|
|
128
|
+
stored_changed_at = stored_entry.get(STORED_CHANGED_AT_KEY)
|
|
129
|
+
if not isinstance(stored_changed_at, (int, float)):
|
|
130
|
+
return False, None
|
|
131
|
+
return current_time - stored_changed_at <= freshness_seconds, None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _apply_candidate_decisions(
|
|
135
|
+
all_candidates: list[Path],
|
|
136
|
+
all_stored_hashes: dict[str, object],
|
|
137
|
+
current_time: float,
|
|
138
|
+
freshness_seconds: int,
|
|
139
|
+
) -> tuple[bool, bool]:
|
|
140
|
+
is_allowed = False
|
|
141
|
+
state_changed = False
|
|
142
|
+
for each_candidate in all_candidates:
|
|
143
|
+
candidate_key = _state_key_for(each_candidate)
|
|
144
|
+
candidate_allowed, updated_entry = _evaluate_candidate(
|
|
145
|
+
each_candidate, all_stored_hashes.get(candidate_key), current_time, freshness_seconds
|
|
146
|
+
)
|
|
147
|
+
if candidate_allowed:
|
|
148
|
+
is_allowed = True
|
|
149
|
+
if updated_entry is not None:
|
|
150
|
+
all_stored_hashes[candidate_key] = updated_entry
|
|
151
|
+
state_changed = True
|
|
152
|
+
return is_allowed, state_changed
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def has_recorded_or_fresh_test(
|
|
156
|
+
all_candidates: list[Path],
|
|
157
|
+
session_id: str,
|
|
158
|
+
repository_root: str,
|
|
159
|
+
freshness_seconds: int,
|
|
160
|
+
) -> bool:
|
|
161
|
+
"""Return whether any candidate's tracked content hash satisfies the gate.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
all_candidates: Candidate test paths for the production file.
|
|
165
|
+
session_id: Raw ``session_id`` from the hook payload.
|
|
166
|
+
repository_root: Raw ``cwd`` from the hook payload.
|
|
167
|
+
freshness_seconds: Maximum age, in seconds, a stored hash may have.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
True when at least one candidate is a fresh, dirty first sighting, a
|
|
171
|
+
recorded hash that now differs, or a recorded hash still in window.
|
|
172
|
+
"""
|
|
173
|
+
state_file = _state_file_path(session_id, repository_root)
|
|
174
|
+
all_stored_hashes = _load_state(state_file)
|
|
175
|
+
is_allowed, state_changed = _apply_candidate_decisions(
|
|
176
|
+
all_candidates, all_stored_hashes, time.time(), freshness_seconds
|
|
177
|
+
)
|
|
178
|
+
if state_changed:
|
|
179
|
+
_atomic_write_state(state_file, all_stored_hashes)
|
|
180
|
+
return is_allowed
|
|
@@ -47,8 +47,12 @@ def build_deny_reason(production_path: Path, all_candidates: list[Path]) -> str:
|
|
|
47
47
|
hook_source_path = _entry_hook_path()
|
|
48
48
|
return (
|
|
49
49
|
f"[TDD] Blocking write to production file: {production_path}\n"
|
|
50
|
-
f"No matching test file exists
|
|
51
|
-
f"
|
|
50
|
+
f"No matching test file exists. Or none of them is fresh, carries real test "
|
|
51
|
+
f"evidence, and holds content HEAD does not have yet. Or a previously "
|
|
52
|
+
f"recorded test's content has not changed within the last "
|
|
53
|
+
f"{FRESHNESS_WINDOW_SECONDS} seconds. Write a new test or change an "
|
|
54
|
+
f"existing one; touching a file without changing its content does not "
|
|
55
|
+
f"satisfy this gate.\n"
|
|
52
56
|
f"Expected one of:\n{candidate_lines}\n"
|
|
53
57
|
f"Write a failing test first (RED), then the minimum code to pass it (GREEN).\n\n"
|
|
54
58
|
f"If this file legitimately does not need a test (for example, a module containing only "
|
|
@@ -42,13 +42,28 @@ def _read_candidate_text(candidate_path: Path) -> str | None:
|
|
|
42
42
|
return None
|
|
43
43
|
|
|
44
44
|
|
|
45
|
+
def has_test_evidence_in(candidate_text: str) -> bool:
|
|
46
|
+
"""Return whether the given text holds a real test function.
|
|
47
|
+
|
|
48
|
+
Takes the text rather than the path, so a caller that has already read the
|
|
49
|
+
file does not read it a second time.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
candidate_text: The candidate test file's full text.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
True when the text matches any test-function pattern.
|
|
56
|
+
"""
|
|
57
|
+
return any(
|
|
58
|
+
each_pattern.search(candidate_text) for each_pattern in _test_function_patterns()
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
45
62
|
def _contains_test_evidence(candidate_path: Path) -> bool:
|
|
46
63
|
test_file_content = _read_candidate_text(candidate_path)
|
|
47
64
|
if test_file_content is None:
|
|
48
65
|
return False
|
|
49
|
-
return
|
|
50
|
-
each_pattern.search(test_file_content) for each_pattern in _test_function_patterns()
|
|
51
|
-
)
|
|
66
|
+
return has_test_evidence_in(test_file_content)
|
|
52
67
|
|
|
53
68
|
|
|
54
69
|
def _candidate_is_fresh_test(
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Ask git about a candidate path: is it a restore, and does it carry a real edit.
|
|
2
2
|
|
|
3
3
|
::
|
|
4
4
|
|
|
@@ -8,13 +8,22 @@
|
|
|
8
8
|
A full rewrite happens as remove-then-Write, so the file is absent on disk
|
|
9
9
|
between the two steps. Restoring committed code is not new production code, so
|
|
10
10
|
the gate exempts a Write whose target is absent yet tracked in git.
|
|
11
|
+
|
|
12
|
+
``has_uncommitted_change_from_head`` answers a second, related question the
|
|
13
|
+
content-hash store's first-sight check needs: whether a candidate test file
|
|
14
|
+
already carries content HEAD does not have, so a `touch` on an already-clean,
|
|
15
|
+
committed test cannot pass as a first sighting.
|
|
11
16
|
"""
|
|
12
17
|
|
|
13
18
|
import subprocess
|
|
14
19
|
from pathlib import Path
|
|
15
20
|
|
|
16
21
|
from tdd_enforcer_parts.config.tdd_enforcer_constants import (
|
|
22
|
+
GIT_DIFF_QUIET_FLAG,
|
|
23
|
+
GIT_DIFF_SUBCOMMAND,
|
|
24
|
+
GIT_DIFF_TIMEOUT_SECONDS,
|
|
17
25
|
GIT_EXECUTABLE_NAME,
|
|
26
|
+
GIT_HEAD_REVISION,
|
|
18
27
|
GIT_LS_FILES_SUBCOMMAND,
|
|
19
28
|
GIT_LS_FILES_TIMEOUT_SECONDS,
|
|
20
29
|
GIT_PATHSPEC_SEPARATOR,
|
|
@@ -25,6 +34,17 @@ def _git_ls_files_command(file_name: str) -> list[str]:
|
|
|
25
34
|
return [GIT_EXECUTABLE_NAME, GIT_LS_FILES_SUBCOMMAND, GIT_PATHSPEC_SEPARATOR, file_name]
|
|
26
35
|
|
|
27
36
|
|
|
37
|
+
def _git_diff_quiet_against_head_command(file_name: str) -> list[str]:
|
|
38
|
+
return [
|
|
39
|
+
GIT_EXECUTABLE_NAME,
|
|
40
|
+
GIT_DIFF_SUBCOMMAND,
|
|
41
|
+
GIT_DIFF_QUIET_FLAG,
|
|
42
|
+
GIT_HEAD_REVISION,
|
|
43
|
+
GIT_PATHSPEC_SEPARATOR,
|
|
44
|
+
file_name,
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
|
|
28
48
|
def _git_tracks_path(path: Path) -> bool:
|
|
29
49
|
parent_directory = path.parent
|
|
30
50
|
if not parent_directory.is_dir():
|
|
@@ -43,6 +63,29 @@ def _git_tracks_path(path: Path) -> bool:
|
|
|
43
63
|
return completed.returncode == 0 and bool(completed.stdout.strip())
|
|
44
64
|
|
|
45
65
|
|
|
66
|
+
def _working_tree_differs_from_head(path: Path) -> bool:
|
|
67
|
+
"""Compare the working tree to HEAD, not to the index.
|
|
68
|
+
|
|
69
|
+
A plain ``git diff`` (no revision argument) compares the working tree to
|
|
70
|
+
the index, so a file that was just ``git add``-ed reads as unchanged even
|
|
71
|
+
when it holds content HEAD never had. Naming ``HEAD`` folds the index and
|
|
72
|
+
the working tree into one comparison against the last commit, catching a
|
|
73
|
+
staged-but-uncommitted new file and a staged edit alike.
|
|
74
|
+
"""
|
|
75
|
+
try:
|
|
76
|
+
completed = subprocess.run(
|
|
77
|
+
_git_diff_quiet_against_head_command(path.name),
|
|
78
|
+
cwd=str(path.parent),
|
|
79
|
+
capture_output=True,
|
|
80
|
+
text=True,
|
|
81
|
+
check=False,
|
|
82
|
+
timeout=GIT_DIFF_TIMEOUT_SECONDS,
|
|
83
|
+
)
|
|
84
|
+
except (OSError, subprocess.SubprocessError):
|
|
85
|
+
return True
|
|
86
|
+
return completed.returncode != 0
|
|
87
|
+
|
|
88
|
+
|
|
46
89
|
def is_absent_but_tracked(path: Path) -> bool:
|
|
47
90
|
"""Return whether a write target is absent on disk yet tracked in git.
|
|
48
91
|
|
|
@@ -61,3 +104,25 @@ def is_absent_but_tracked(path: Path) -> bool:
|
|
|
61
104
|
if path.exists():
|
|
62
105
|
return False
|
|
63
106
|
return _git_tracks_path(path)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def has_uncommitted_change_from_head(path: Path) -> bool:
|
|
110
|
+
"""Return whether *path* carries content its git HEAD does not have.
|
|
111
|
+
|
|
112
|
+
::
|
|
113
|
+
|
|
114
|
+
untracked file -> True (no HEAD entry to match)
|
|
115
|
+
tracked, working tree != HEAD -> True (a real edit this session)
|
|
116
|
+
tracked, working tree == HEAD -> False (clean; a touch cannot pass)
|
|
117
|
+
git unavailable or errors -> True (cannot prove it is clean)
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
path: The candidate test file to check.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
True unless git can positively confirm the file is tracked and its
|
|
124
|
+
working-tree content matches HEAD exactly.
|
|
125
|
+
"""
|
|
126
|
+
if not _git_tracks_path(path):
|
|
127
|
+
return True
|
|
128
|
+
return _working_tree_differs_from_head(path)
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Behavioral tests for the content_hash_store parts module."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from tdd_enforcer_parts import content_hash_store
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _write_fresh_test_file(directory: Path, content: str = "def test_fulfill(): pass\n") -> Path:
|
|
12
|
+
candidate = directory / "test_orders.py"
|
|
13
|
+
candidate.write_text(content)
|
|
14
|
+
return candidate
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_state_file_path_differs_by_repository_root(tmp_path: Path) -> None:
|
|
18
|
+
first_root_path = content_hash_store._state_file_path("session-a", str(tmp_path / "one"))
|
|
19
|
+
second_root_path = content_hash_store._state_file_path("session-a", str(tmp_path / "two"))
|
|
20
|
+
assert first_root_path != second_root_path
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_state_file_path_is_stable_for_the_same_inputs(tmp_path: Path) -> None:
|
|
24
|
+
first_lookup = content_hash_store._state_file_path("session-a", str(tmp_path))
|
|
25
|
+
second_lookup = content_hash_store._state_file_path("session-a", str(tmp_path))
|
|
26
|
+
assert first_lookup == second_lookup
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_first_sight_allows_and_stores_a_fresh_dirty_test(tmp_path: Path) -> None:
|
|
30
|
+
candidate = _write_fresh_test_file(tmp_path)
|
|
31
|
+
|
|
32
|
+
is_allowed = content_hash_store.has_recorded_or_fresh_test(
|
|
33
|
+
[candidate], "session-1", str(tmp_path), 600
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
assert is_allowed is True
|
|
37
|
+
state_file = content_hash_store._state_file_path("session-1", str(tmp_path))
|
|
38
|
+
state = json.loads(state_file.read_text())
|
|
39
|
+
assert content_hash_store._state_key_for(candidate) in state
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_first_sight_denies_when_test_evidence_is_missing(tmp_path: Path) -> None:
|
|
43
|
+
candidate = _write_fresh_test_file(tmp_path, content="orders = 'not a test'\n")
|
|
44
|
+
|
|
45
|
+
is_allowed = content_hash_store.has_recorded_or_fresh_test(
|
|
46
|
+
[candidate], "session-2", str(tmp_path), 600
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
assert is_allowed is False
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_first_sight_denies_when_test_file_is_stale(tmp_path: Path) -> None:
|
|
53
|
+
candidate = _write_fresh_test_file(tmp_path)
|
|
54
|
+
stale_timestamp = time.time() - 700
|
|
55
|
+
os.utime(candidate, (stale_timestamp, stale_timestamp))
|
|
56
|
+
|
|
57
|
+
is_allowed = content_hash_store.has_recorded_or_fresh_test(
|
|
58
|
+
[candidate], "session-3", str(tmp_path), 600
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
assert is_allowed is False
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_second_sight_denies_a_touch_that_leaves_content_unchanged(tmp_path: Path) -> None:
|
|
65
|
+
candidate = _write_fresh_test_file(tmp_path)
|
|
66
|
+
session_id, repository_root = "session-4", str(tmp_path)
|
|
67
|
+
assert content_hash_store.has_recorded_or_fresh_test(
|
|
68
|
+
[candidate], session_id, repository_root, 600
|
|
69
|
+
)
|
|
70
|
+
state_file = content_hash_store._state_file_path(session_id, repository_root)
|
|
71
|
+
state = json.loads(state_file.read_text())
|
|
72
|
+
candidate_key = content_hash_store._state_key_for(candidate)
|
|
73
|
+
state[candidate_key][content_hash_store.STORED_CHANGED_AT_KEY] -= 700
|
|
74
|
+
state_file.write_text(json.dumps(state))
|
|
75
|
+
candidate.touch()
|
|
76
|
+
|
|
77
|
+
is_allowed = content_hash_store.has_recorded_or_fresh_test(
|
|
78
|
+
[candidate], session_id, repository_root, 600
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
assert is_allowed is False
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_second_sight_allows_when_content_actually_changed_after_the_window(
|
|
85
|
+
tmp_path: Path,
|
|
86
|
+
) -> None:
|
|
87
|
+
candidate = _write_fresh_test_file(tmp_path)
|
|
88
|
+
session_id, repository_root = "session-5", str(tmp_path)
|
|
89
|
+
assert content_hash_store.has_recorded_or_fresh_test(
|
|
90
|
+
[candidate], session_id, repository_root, 600
|
|
91
|
+
)
|
|
92
|
+
state_file = content_hash_store._state_file_path(session_id, repository_root)
|
|
93
|
+
state = json.loads(state_file.read_text())
|
|
94
|
+
candidate_key = content_hash_store._state_key_for(candidate)
|
|
95
|
+
state[candidate_key][content_hash_store.STORED_CHANGED_AT_KEY] -= 700
|
|
96
|
+
state_file.write_text(json.dumps(state))
|
|
97
|
+
candidate.write_text("def test_fulfill(): assert True\n")
|
|
98
|
+
|
|
99
|
+
is_allowed = content_hash_store.has_recorded_or_fresh_test(
|
|
100
|
+
[candidate], session_id, repository_root, 600
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
assert is_allowed is True
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_second_sight_allows_a_repeat_write_within_the_window_untouched(
|
|
107
|
+
tmp_path: Path,
|
|
108
|
+
) -> None:
|
|
109
|
+
candidate = _write_fresh_test_file(tmp_path)
|
|
110
|
+
session_id, repository_root = "session-6", str(tmp_path)
|
|
111
|
+
assert content_hash_store.has_recorded_or_fresh_test(
|
|
112
|
+
[candidate], session_id, repository_root, 600
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
is_allowed = content_hash_store.has_recorded_or_fresh_test(
|
|
116
|
+
[candidate], session_id, repository_root, 600
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
assert is_allowed is True
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_any_candidate_satisfying_the_gate_allows_the_write(tmp_path: Path) -> None:
|
|
123
|
+
stale_candidate = _write_fresh_test_file(tmp_path)
|
|
124
|
+
stale_timestamp = time.time() - 700
|
|
125
|
+
os.utime(stale_candidate, (stale_timestamp, stale_timestamp))
|
|
126
|
+
fresh_candidate = tmp_path / "orders_test.py"
|
|
127
|
+
fresh_candidate.write_text("def test_fulfill(): pass\n")
|
|
128
|
+
|
|
129
|
+
is_allowed = content_hash_store.has_recorded_or_fresh_test(
|
|
130
|
+
[stale_candidate, fresh_candidate], "session-7", str(tmp_path), 600
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
assert is_allowed is True
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_denies_when_no_candidate_file_exists(tmp_path: Path) -> None:
|
|
137
|
+
missing_candidate = tmp_path / "test_missing.py"
|
|
138
|
+
|
|
139
|
+
is_allowed = content_hash_store.has_recorded_or_fresh_test(
|
|
140
|
+
[missing_candidate], "session-8", str(tmp_path), 600
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
assert is_allowed is False
|
|
@@ -20,6 +20,18 @@ def test_build_deny_reason_points_exemption_guidance_at_entry_hook() -> None:
|
|
|
20
20
|
assert "decisions.py" not in reason
|
|
21
21
|
|
|
22
22
|
|
|
23
|
+
def test_build_deny_reason_names_content_not_only_modification_time() -> None:
|
|
24
|
+
reason = decisions.build_deny_reason(Path("pkg/orders.py"), [Path("pkg/test_orders.py")])
|
|
25
|
+
assert "content" in reason.lower()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_build_deny_reason_covers_first_sight_and_stale_hash_and_names_the_fix() -> None:
|
|
29
|
+
reason = decisions.build_deny_reason(Path("pkg/orders.py"), [Path("pkg/test_orders.py")]).lower()
|
|
30
|
+
assert "fresh" in reason
|
|
31
|
+
assert "change" in reason
|
|
32
|
+
assert "touch" in reason
|
|
33
|
+
|
|
34
|
+
|
|
23
35
|
def test_emit_allow_writes_allow_decision(capsys: pytest.CaptureFixture[str]) -> None:
|
|
24
36
|
decisions.emit_allow()
|
|
25
37
|
parsed = json.loads(capsys.readouterr().out)
|
|
@@ -46,3 +46,66 @@ def test_absent_but_tracked_false_when_file_present(tmp_path: Path) -> None:
|
|
|
46
46
|
_git(tmp_path, "add", "-A")
|
|
47
47
|
_git(tmp_path, "commit", "-q", "-m", "init")
|
|
48
48
|
assert git_tracking.is_absent_but_tracked(present) is False
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_has_uncommitted_change_from_head_true_for_untracked_file(tmp_path: Path) -> None:
|
|
52
|
+
_init_repository(tmp_path)
|
|
53
|
+
untracked = tmp_path / "test_orders.py"
|
|
54
|
+
untracked.write_text("def test_fulfill(): pass\n")
|
|
55
|
+
assert git_tracking.has_uncommitted_change_from_head(untracked) is True
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_has_uncommitted_change_from_head_true_for_tracked_modified_file(tmp_path: Path) -> None:
|
|
59
|
+
_init_repository(tmp_path)
|
|
60
|
+
tracked = tmp_path / "test_orders.py"
|
|
61
|
+
tracked.write_text("def test_fulfill(): pass\n")
|
|
62
|
+
_git(tmp_path, "add", "-A")
|
|
63
|
+
_git(tmp_path, "commit", "-q", "-m", "init")
|
|
64
|
+
tracked.write_text("def test_fulfill(): assert True\n")
|
|
65
|
+
assert git_tracking.has_uncommitted_change_from_head(tracked) is True
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_has_uncommitted_change_from_head_false_for_tracked_clean_file(tmp_path: Path) -> None:
|
|
69
|
+
_init_repository(tmp_path)
|
|
70
|
+
tracked = tmp_path / "test_orders.py"
|
|
71
|
+
tracked.write_text("def test_fulfill(): pass\n")
|
|
72
|
+
_git(tmp_path, "add", "-A")
|
|
73
|
+
_git(tmp_path, "commit", "-q", "-m", "init")
|
|
74
|
+
tracked.touch()
|
|
75
|
+
assert git_tracking.has_uncommitted_change_from_head(tracked) is False
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_has_uncommitted_change_from_head_true_outside_a_git_repository(tmp_path: Path) -> None:
|
|
79
|
+
stray = tmp_path / "test_orders.py"
|
|
80
|
+
stray.write_text("def test_fulfill(): pass\n")
|
|
81
|
+
assert git_tracking.has_uncommitted_change_from_head(stray) is True
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_has_uncommitted_change_from_head_true_for_staged_new_file_never_committed(
|
|
85
|
+
tmp_path: Path,
|
|
86
|
+
) -> None:
|
|
87
|
+
"""A brand-new file only ``git add``-ed carries content HEAD never had.
|
|
88
|
+
|
|
89
|
+
Regression guard: comparing the working tree to the index (rather than to
|
|
90
|
+
HEAD) reads a freshly staged file as unchanged, since staging makes the
|
|
91
|
+
index match the working tree exactly. That misses every staged-but-never-
|
|
92
|
+
committed file, which is exactly how a RED-step test file is staged.
|
|
93
|
+
"""
|
|
94
|
+
_init_repository(tmp_path)
|
|
95
|
+
_git(tmp_path, "commit", "--allow-empty", "-q", "-m", "baseline")
|
|
96
|
+
staged_new_test = tmp_path / "test_orders.py"
|
|
97
|
+
staged_new_test.write_text("def test_fulfill(): pass\n")
|
|
98
|
+
_git(tmp_path, "add", "-A")
|
|
99
|
+
assert git_tracking.has_uncommitted_change_from_head(staged_new_test) is True
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_has_uncommitted_change_from_head_true_for_staged_modification(tmp_path: Path) -> None:
|
|
103
|
+
"""A tracked test's staged edit still differs from HEAD, not only the index."""
|
|
104
|
+
_init_repository(tmp_path)
|
|
105
|
+
tracked = tmp_path / "test_orders.py"
|
|
106
|
+
tracked.write_text("def test_fulfill(): pass\n")
|
|
107
|
+
_git(tmp_path, "add", "-A")
|
|
108
|
+
_git(tmp_path, "commit", "-q", "-m", "init")
|
|
109
|
+
tracked.write_text("def test_fulfill(): assert True\n")
|
|
110
|
+
_git(tmp_path, "add", "-A")
|
|
111
|
+
assert git_tracking.has_uncommitted_change_from_head(tracked) is True
|
|
@@ -222,6 +222,7 @@ def test_powershell_selects_the_shared_hooks_in_registration_order() -> None:
|
|
|
222
222
|
"blocking/cursor_cli_python_misfire_blocker.py",
|
|
223
223
|
"blocking/unscoped_search_blocker.py",
|
|
224
224
|
"blocking/pii_prevention_blocker.py",
|
|
225
|
+
"blocking/pr_description_writer_gate.py",
|
|
225
226
|
]
|
|
226
227
|
|
|
227
228
|
|