claude-dev-env 2.30.1 → 2.31.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.
Files changed (118) hide show
  1. package/.agents/agents/pr-description-writer.md +1 -1
  2. package/AGENTS.md +6 -0
  3. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_enforcer_loading.py +37 -0
  4. package/bin/install.prune.test.mjs +4 -2
  5. package/docs/CODE_RULES.md +11 -0
  6. package/hooks/advisory/migration_safety_advisor.py +25 -1
  7. package/hooks/advisory/refactor_guard.py +85 -18
  8. package/hooks/advisory/test_migration_safety_advisor.py +89 -5
  9. package/hooks/advisory/test_refactor_guard.py +87 -0
  10. package/hooks/advisory/test_refactor_guard_advisory.py +72 -1
  11. package/hooks/blocking/code_rules_constants_config.py +7 -6
  12. package/hooks/blocking/code_rules_enforcer.py +57 -31
  13. package/hooks/blocking/codex_apply_patch.py +136 -21
  14. package/hooks/blocking/config/codex_apply_patch_constants.py +13 -0
  15. package/hooks/blocking/conftest.py +70 -0
  16. package/hooks/blocking/duplicate_rmtree_helper_blocker.py +26 -16
  17. package/hooks/blocking/hook_prose_detector_consistency.py +30 -20
  18. package/hooks/blocking/pii_payload_scan.py +61 -0
  19. package/hooks/blocking/pii_prevention_blocker.py +5 -0
  20. package/hooks/blocking/pr_description_writer_gate.py +263 -0
  21. package/hooks/blocking/pre_tool_use_dispatcher.py +1 -1
  22. package/hooks/blocking/sensitive_file_protector.py +18 -3
  23. package/hooks/blocking/shell_substitution_blocker.py +25 -47
  24. package/hooks/blocking/stale_comment_reference_blocker.py +94 -24
  25. package/hooks/blocking/state_description_blocker.py +39 -26
  26. package/hooks/blocking/subprocess_budget_completeness.py +17 -43
  27. package/hooks/blocking/tdd_enforcer.py +57 -14
  28. package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +11 -1
  29. package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +180 -0
  30. package/hooks/blocking/tdd_enforcer_parts/decisions.py +6 -2
  31. package/hooks/blocking/tdd_enforcer_parts/freshness.py +18 -3
  32. package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +66 -1
  33. package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +143 -0
  34. package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +12 -0
  35. package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +63 -0
  36. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +1 -0
  37. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +81 -2
  38. package/hooks/blocking/test_code_rules_enforcer_codex_apply_patch.py +35 -0
  39. package/hooks/blocking/test_code_rules_enforcer_config_path.py +29 -16
  40. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +2 -0
  41. package/hooks/blocking/test_code_rules_enforcer_multi_edit.py +118 -0
  42. package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +2 -0
  43. package/hooks/blocking/test_code_rules_enforcer_precheck_forecast.py +2 -0
  44. package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +29 -28
  45. package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +2 -0
  46. package/hooks/blocking/test_code_rules_enforcer_stage.py +2 -0
  47. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +2 -0
  48. package/hooks/blocking/test_codex_apply_patch.py +43 -0
  49. package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +68 -0
  50. package/hooks/blocking/test_hook_prose_detector_consistency.py +41 -0
  51. package/hooks/blocking/test_pii_payload_scan.py +65 -2
  52. package/hooks/blocking/test_pii_prevention_blocker.py +80 -10
  53. package/hooks/blocking/test_pr_description_writer_gate.py +216 -0
  54. package/hooks/blocking/test_pre_tool_use_dispatcher.py +215 -25
  55. package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +9 -4
  56. package/hooks/blocking/test_sensitive_file_protector.py +42 -2
  57. package/hooks/blocking/test_stale_comment_reference_blocker.py +71 -0
  58. package/hooks/blocking/test_state_description_blocker.py +68 -0
  59. package/hooks/blocking/test_subprocess_budget_completeness.py +67 -0
  60. package/hooks/blocking/test_tdd_enforcer.py +182 -2
  61. package/hooks/blocking/test_windows_rmtree_blocker.py +63 -0
  62. package/hooks/blocking/test_write_existing_file_blocker.py +122 -0
  63. package/hooks/blocking/windows_rmtree_blocker.py +21 -7
  64. package/hooks/blocking/write_existing_file_blocker.py +75 -29
  65. package/hooks/conftest.py +47 -0
  66. package/hooks/hooks.json +7 -12
  67. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +3 -0
  68. package/hooks/hooks_constants/code_rules_enforcer_constants.py +4 -0
  69. package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +3 -0
  70. package/hooks/hooks_constants/hook_prose_detector_consistency_constants.py +7 -2
  71. package/hooks/hooks_constants/migration_safety_advisor_constants.py +7 -0
  72. package/hooks/hooks_constants/multi_edit_reconstruction.py +44 -3
  73. package/hooks/hooks_constants/pii_prevention_constants.py +9 -7
  74. package/hooks/hooks_constants/pr_description_writer_gate_constants.py +39 -0
  75. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +31 -14
  76. package/hooks/hooks_constants/session_edit_stage_gate_constants.py +15 -1
  77. package/hooks/hooks_constants/state_description_blocker_constants.py +1 -0
  78. package/hooks/hooks_constants/subprocess_budget_completeness_content.py +71 -0
  79. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +2 -0
  80. package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +2 -0
  81. package/hooks/hooks_constants/test_multi_edit_reconstruction.py +102 -0
  82. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +139 -25
  83. package/hooks/hooks_constants/test_subprocess_budget_completeness_content.py +70 -0
  84. package/hooks/hooks_constants/validation_phase_constants.py +2 -0
  85. package/hooks/hooks_constants/workflow_substitution_slot_blocker_constants.py +7 -3
  86. package/hooks/observability/pr_description_writer_spawn_tracker.py +121 -0
  87. package/hooks/observability/session_file_edit_tracker.py +25 -4
  88. package/hooks/observability/test_pr_description_writer_spawn_tracker.py +146 -0
  89. package/hooks/observability/test_session_file_edit_tracker.py +32 -0
  90. package/hooks/validation/hook_format_validator.py +23 -9
  91. package/hooks/validation/test_hook_format_validator.py +58 -0
  92. package/hooks/validators/README.md +1 -1
  93. package/hooks/validators/abbreviation_checks.py +3 -5
  94. package/hooks/validators/config/abbreviation_checks_constants.py +3 -0
  95. package/hooks/validators/config/fast_save_validator_constants.py +32 -0
  96. package/hooks/validators/config/hook_timing_harness_constants.py +3 -1
  97. package/hooks/validators/config/test_abbreviation_checks_constants.py +11 -0
  98. package/hooks/validators/fast_save_validators.py +332 -0
  99. package/hooks/validators/hook_timing_harness.py +8 -4
  100. package/hooks/validators/python_style_checks.py +34 -60
  101. package/hooks/validators/python_style_helpers.py +16 -2
  102. package/hooks/validators/python_style_import_bootstrap.py +83 -0
  103. package/hooks/validators/run_all_validators.py +42 -41
  104. package/hooks/validators/test_abbreviation_checks.py +20 -0
  105. package/hooks/validators/test_fast_save_validators.py +172 -0
  106. package/hooks/validators/test_python_style_checks.py +9 -0
  107. package/hooks/validators/test_python_style_checks_import_order.py +81 -0
  108. package/hooks/validators/test_python_style_helpers.py +35 -0
  109. package/hooks/validators/test_python_style_import_bootstrap.py +69 -0
  110. package/hooks/validators/test_run_all_validators.py +46 -0
  111. package/hooks/validators/test_run_all_validators_pretooluse.py +2 -0
  112. package/package.json +1 -1
  113. package/rules/docstring-prose-matches-implementation.md +1 -1
  114. package/rules/git-workflow.md +2 -0
  115. package/rules/testing.md +4 -0
  116. package/scripts/codex_compat_materializer.py +5 -0
  117. package/scripts/tests/test_codex_compat_materializer.py +37 -11
  118. package/hooks/blocking/tests/conftest.py +0 -10
@@ -15,28 +15,43 @@ import sys
15
15
  from pathlib import Path
16
16
  from typing import TextIO
17
17
 
18
- _hooks_dir = str(Path(__file__).resolve().parent.parent)
19
- if _hooks_dir not in sys.path:
20
- sys.path.insert(0, _hooks_dir)
21
-
22
- from blocking.code_rules_shared import is_ephemeral_path # noqa: E402
23
- from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
24
- from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin # noqa: E402
25
- from hooks_constants.state_description_blocker_constants import ( # noqa: E402
26
- ALL_BLOCK_COMMENT_EXTENSIONS,
27
- ALL_BLOCK_COMMENT_ONLY_EXTENSIONS,
28
- ALL_COMMENT_BEARING_EXTENSIONS,
29
- ALL_COMMENT_TRANSITION_PATTERNS,
30
- ALL_DEFINITION_HEADER_PREFIXES,
31
- ALL_HASH_AND_SLASH_EXTENSIONS,
32
- ALL_HASH_ONLY_EXTENSIONS,
33
- ALL_MARKDOWN_EXTENSIONS,
34
- CODE_FENCE_PATTERN,
35
- DOUBLE_QUOTED_SPAN_PATTERN,
36
- INLINE_CODE_PATTERN,
37
- PYTHON_EXTENSION,
38
- TRIPLE_QUOTED_BLOCK_PATTERN,
39
- )
18
+ try:
19
+ _hooks_dir = str(Path(__file__).resolve().parent.parent)
20
+ if _hooks_dir not in sys.path:
21
+ sys.path.insert(0, _hooks_dir)
22
+
23
+ from blocking.code_rules_shared import is_ephemeral_path
24
+ from hooks_constants.hook_block_logger import log_hook_block
25
+ from hooks_constants.multi_edit_reconstruction import joined_new_strings
26
+ from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
27
+ from hooks_constants.state_description_blocker_constants import (
28
+ ALL_BLOCK_COMMENT_EXTENSIONS,
29
+ ALL_BLOCK_COMMENT_ONLY_EXTENSIONS,
30
+ ALL_COMMENT_BEARING_EXTENSIONS,
31
+ ALL_COMMENT_TRANSITION_PATTERNS,
32
+ ALL_DEFINITION_HEADER_PREFIXES,
33
+ ALL_HASH_AND_SLASH_EXTENSIONS,
34
+ ALL_HASH_ONLY_EXTENSIONS,
35
+ ALL_MARKDOWN_EXTENSIONS,
36
+ CODE_FENCE_PATTERN,
37
+ DOUBLE_QUOTED_SPAN_PATTERN,
38
+ INLINE_CODE_PATTERN,
39
+ PYTHON_EXTENSION,
40
+ TRIPLE_QUOTED_BLOCK_PATTERN,
41
+ )
42
+ except ImportError as import_error:
43
+ raise ImportError(
44
+ "state_description_blocker: cannot import its sibling modules"
45
+ ) from import_error
46
+
47
+
48
+ def _content_to_check(tool_name: str, all_tool_input: dict) -> str:
49
+ """Return the text a Write, Edit, or MultiEdit payload introduces."""
50
+ if tool_name == "MultiEdit":
51
+ return joined_new_strings(all_tool_input)
52
+ content_key = "content" if tool_name == "Write" else "new_string"
53
+ raw_content = all_tool_input.get(content_key, "")
54
+ return raw_content if isinstance(raw_content, str) else ""
40
55
 
41
56
 
42
57
  def _get_file_extension(file_path: str) -> str:
@@ -289,7 +304,7 @@ def evaluate(payload_by_key: dict[str, object]) -> str | None:
289
304
  """
290
305
  raw_tool_name = payload_by_key.get("tool_name", "")
291
306
  tool_name = raw_tool_name if isinstance(raw_tool_name, str) else ""
292
- if tool_name not in ("Write", "Edit"):
307
+ if tool_name not in ("Write", "Edit", "MultiEdit"):
293
308
  return None
294
309
 
295
310
  raw_tool_input = payload_by_key.get("tool_input", {})
@@ -303,9 +318,7 @@ def evaluate(payload_by_key: dict[str, object]) -> str | None:
303
318
  if not (is_markdown_file(file_path) or is_comment_bearing_file(file_path)):
304
319
  return None
305
320
 
306
- content_key = "content" if tool_name == "Write" else "new_string"
307
- raw_content = tool_input.get(content_key, "")
308
- content_to_check = raw_content if isinstance(raw_content, str) else ""
321
+ content_to_check = _content_to_check(tool_name, tool_input)
309
322
  if not content_to_check:
310
323
  return None
311
324
 
@@ -42,55 +42,29 @@ if _blocking_dir not in sys.path:
42
42
  if _hooks_dir not in sys.path:
43
43
  sys.path.insert(0, _hooks_dir)
44
44
 
45
- from code_rules_shared import is_test_file # noqa: E402
46
-
47
- from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
48
- from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
49
- read_hook_input_dictionary_from_stdin,
50
- )
51
- from hooks_constants.subprocess_budget_completeness_constants import ( # noqa: E402
52
- ALL_BUDGET_NAME_MARKERS,
53
- BUDGET_ENTRY_POINT_FUNCTION_NAME,
54
- SUBPROCESS_TIMEOUT_KEYWORD,
55
- )
56
- from hooks_constants.windows_rmtree_blocker_constants import ( # noqa: E402
57
- PYTHON_FILE_EXTENSION,
58
- )
45
+ try:
46
+ from code_rules_shared import is_test_file
47
+
48
+ from hooks_constants.hook_block_logger import log_hook_block
49
+ from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
50
+ from hooks_constants.subprocess_budget_completeness_constants import (
51
+ ALL_BUDGET_NAME_MARKERS,
52
+ BUDGET_ENTRY_POINT_FUNCTION_NAME,
53
+ SUBPROCESS_TIMEOUT_KEYWORD,
54
+ )
55
+ from hooks_constants.subprocess_budget_completeness_content import resolved_content
56
+ from hooks_constants.windows_rmtree_blocker_constants import PYTHON_FILE_EXTENSION
57
+ except ImportError as import_error:
58
+ raise ImportError(
59
+ "subprocess_budget_completeness: cannot import its sibling modules; "
60
+ "ensure the hooks directory is importable."
61
+ ) from import_error
59
62
 
60
63
 
61
64
  def is_python_target(file_path: str) -> bool:
62
65
  return file_path.endswith(PYTHON_FILE_EXTENSION)
63
66
 
64
67
 
65
- def resolved_content(all_tool_input_fields: dict[str, object]) -> str:
66
- written_content = all_tool_input_fields.get("content")
67
- if isinstance(written_content, str):
68
- return written_content
69
- return reconstructed_edit_content(all_tool_input_fields)
70
-
71
-
72
- def reconstructed_edit_content(all_tool_input_fields: dict[str, object]) -> str:
73
- file_path = all_tool_input_fields.get("file_path")
74
- old_string = all_tool_input_fields.get("old_string")
75
- new_string = all_tool_input_fields.get("new_string")
76
- if not isinstance(file_path, str) or not isinstance(old_string, str):
77
- return ""
78
- if not isinstance(new_string, str) or not old_string:
79
- return ""
80
- existing_content = existing_file_content(file_path)
81
- if existing_content is None or old_string not in existing_content:
82
- return ""
83
- return existing_content.replace(old_string, new_string, 1)
84
-
85
-
86
- def existing_file_content(file_path: str) -> str | None:
87
- try:
88
- with open(file_path, "r", encoding="utf-8") as existing_file:
89
- return existing_file.read()
90
- except (FileNotFoundError, OSError, UnicodeDecodeError):
91
- return None
92
-
93
-
94
68
  def integer_literal_value(node: ast.expr) -> int | None:
95
69
  if (
96
70
  isinstance(node, ast.Constant)
@@ -2,6 +2,9 @@
2
2
 
3
3
  Blocks a write to a production source file when no matching test was modified
4
4
  within the freshness window, enforcing "TDD IS NON-NEGOTIABLE" from CLAUDE.md.
5
+ The gate records each candidate test's content hash at every sighting and
6
+ compares that hash on the next write. A first sighting requires content that
7
+ differs from HEAD.
5
8
  Each concern lives in a ``tdd_enforcer_parts`` submodule; this entry wires them
6
9
  into one PreToolUse gate and re-exports their surface for the test suite.
7
10
  """
@@ -21,9 +24,14 @@ try:
21
24
  is_ephemeral_script_path,
22
25
  is_under_session_scratchpad,
23
26
  )
27
+ from codex_apply_patch import (
28
+ payload_patch_target_paths,
29
+ )
30
+ from hooks_constants.pre_tool_use_dispatcher_constants import APPLY_PATCH_TOOL_NAME
24
31
  from tdd_enforcer_parts import (
25
32
  candidate_paths,
26
33
  content_analysis,
34
+ content_hash_store,
27
35
  decisions,
28
36
  freshness,
29
37
  git_tracking,
@@ -56,8 +64,8 @@ candidate_test_paths_for = candidate_paths.candidate_test_paths_for
56
64
  _ancestor_tests_directories = candidate_paths._ancestor_tests_directories
57
65
  _parent_walk_limit = candidate_paths._parent_walk_limit
58
66
  _freshness_seconds = freshness._freshness_seconds
59
- has_fresh_test = freshness.has_fresh_test
60
67
  _read_candidate_text = freshness._read_candidate_text
68
+ has_recorded_or_fresh_test = content_hash_store.has_recorded_or_fresh_test
61
69
  _is_constants_only_python_content = content_analysis._is_constants_only_python_content
62
70
  _is_post_edit_import_only = content_analysis._is_post_edit_import_only
63
71
  _is_post_edit_constants_only = content_analysis._is_post_edit_constants_only
@@ -117,29 +125,64 @@ def _write_is_exempt(
117
125
  return _edit_is_constants_or_import_only(tool_name, tool_input, path, extension)
118
126
 
119
127
 
128
+ def _decide_for_target(
129
+ tool_name: str, tool_input: dict, file_path: str, input_data: dict
130
+ ) -> tuple[bool, str] | None:
131
+ """Return (is_allowed, deny_reason) for one target file, or None to skip silently."""
132
+ if not file_path:
133
+ return None
134
+ if _is_session_scratchpad_write(file_path, input_data):
135
+ return None
136
+ path = Path(file_path)
137
+ extension = path.suffix.lower()
138
+ if _is_silently_skipped(file_path, extension, path):
139
+ return None
140
+ if _write_is_exempt(tool_name, tool_input, path, extension):
141
+ return True, ""
142
+ all_candidates = candidate_test_paths_for(path)
143
+ session_id = str(input_data.get("session_id") or "")
144
+ repository_root = str(input_data.get("cwd") or "")
145
+ is_recorded_or_fresh = has_recorded_or_fresh_test(
146
+ all_candidates, session_id, repository_root, _freshness_seconds()
147
+ )
148
+ if is_recorded_or_fresh:
149
+ return True, ""
150
+ return False, build_deny_reason(path, all_candidates)
151
+
152
+
153
+ def _decide_apply_patch(input_data: dict, tool_input: dict) -> None:
154
+ """Run the TDD gate over every file a Codex apply_patch payload touches."""
155
+ for each_file_path in payload_patch_target_paths(input_data, tool_input):
156
+ decision = _decide_for_target(
157
+ APPLY_PATCH_TOOL_NAME, tool_input, each_file_path, input_data
158
+ )
159
+ if decision is None:
160
+ continue
161
+ is_allowed, deny_reason = decision
162
+ if not is_allowed:
163
+ emit_deny(deny_reason)
164
+ return
165
+ emit_allow()
166
+
167
+
120
168
  def main() -> None:
121
- """Run the TDD gate over one PreToolUse Write, Edit, or MultiEdit payload."""
169
+ """Run the TDD gate over one PreToolUse Write, Edit, MultiEdit, or apply_patch payload."""
122
170
  try:
123
171
  input_data = json.load(sys.stdin)
124
172
  except json.JSONDecodeError:
125
173
  sys.exit(0)
126
174
  tool_name, tool_input, file_path = _resolve_payload(input_data)
127
- if not file_path:
128
- sys.exit(0)
129
- if _is_session_scratchpad_write(file_path, input_data):
130
- sys.exit(0)
131
- path = Path(file_path)
132
- extension = path.suffix.lower()
133
- if _is_silently_skipped(file_path, extension, path):
175
+ if tool_name == APPLY_PATCH_TOOL_NAME:
176
+ _decide_apply_patch(input_data, tool_input)
134
177
  sys.exit(0)
135
- if _write_is_exempt(tool_name, tool_input, path, extension):
136
- emit_allow()
178
+ decision = _decide_for_target(tool_name, tool_input, file_path, input_data)
179
+ if decision is None:
137
180
  sys.exit(0)
138
- all_candidates = candidate_test_paths_for(path)
139
- if has_fresh_test(all_candidates, _freshness_seconds()):
181
+ is_allowed, deny_reason = decision
182
+ if is_allowed:
140
183
  emit_allow()
141
184
  sys.exit(0)
142
- emit_deny(build_deny_reason(path, all_candidates))
185
+ emit_deny(deny_reason)
143
186
  sys.exit(0)
144
187
 
145
188
 
@@ -1,7 +1,8 @@
1
1
  """Constants for the TDD-enforcer hook and its decomposed parts modules.
2
2
 
3
3
  Centralizes the freshness window, the ancestor-walk limit, the git-tracking
4
- command tokens, the source-file extension sets, and the join separator.
4
+ and git-diff command tokens, the source-file extension sets, the join
5
+ separator, and the last-observed-hash store's filename shape and JSON keys.
5
6
  """
6
7
 
7
8
  from __future__ import annotations
@@ -9,9 +10,18 @@ from __future__ import annotations
9
10
  FRESHNESS_WINDOW_SECONDS: int = 600
10
11
  PARENT_WALK_LIMIT: int = 10
11
12
  GIT_LS_FILES_TIMEOUT_SECONDS: int = 10
13
+ GIT_DIFF_TIMEOUT_SECONDS: int = 10
12
14
  GIT_EXECUTABLE_NAME: str = "git"
13
15
  GIT_LS_FILES_SUBCOMMAND: str = "ls-files"
16
+ GIT_DIFF_SUBCOMMAND: str = "diff"
17
+ GIT_DIFF_QUIET_FLAG: str = "--quiet"
18
+ GIT_HEAD_REVISION: str = "HEAD"
14
19
  GIT_PATHSPEC_SEPARATOR: str = "--"
20
+ HASH_STATE_FILE_PREFIX: str = "claude-tdd-test-hashes-"
21
+ HASH_STATE_FILE_SUFFIX: str = ".json"
22
+ REPOSITORY_ROOT_DIGEST_LENGTH: int = 16
23
+ STORED_CONTENT_HASH_KEY: str = "hash"
24
+ STORED_CHANGED_AT_KEY: str = "changed_at"
15
25
  PYTHON_SOURCE_EXTENSION: str = ".py"
16
26
  ENTRY_HOOK_FILE_NAME: str = "tdd_enforcer.py"
17
27
  NEWLINE_JOIN_SEPARATOR: str = "\n"
@@ -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, or it has not been modified within the last "
51
- f"{FRESHNESS_WINDOW_SECONDS} seconds.\n"
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 any(
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
- """Detect a write that restores a tracked-but-absent production file.
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)