claude-dev-env 8.10.0 → 8.11.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 (77) hide show
  1. package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_regression.py +85 -3
  2. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_regression.py +34 -0
  3. package/_shared/pr-loop/scripts/fix_pr_test_proof.py +132 -0
  4. package/_shared/pr-loop/scripts/pr_loop_shared_constants/fix_pr_test_proof_constants.py +43 -0
  5. package/_shared/pr-loop/scripts/test_fix_pr_test_proof.py +126 -0
  6. package/bin/codex-skill-load-block.mjs +45 -0
  7. package/bin/codex-skill-load-block.test.mjs +72 -0
  8. package/bin/install.agents-home.test.mjs +76 -1
  9. package/bin/install.mjs +53 -31
  10. package/bin/install.test.mjs +37 -0
  11. package/bin/install.transaction.test.mjs +29 -7
  12. package/bin/seed-codex-pstack-models.mjs +27 -0
  13. package/bin/seed-codex-pstack-models.test.mjs +72 -0
  14. package/docs/CODE_RULES.md +5 -1
  15. package/hooks/blocking/code_rules_enforcer.py +1 -3
  16. package/hooks/blocking/code_rules_magic_values.py +1 -3
  17. package/hooks/blocking/code_rules_paired_test.py +5 -6
  18. package/hooks/blocking/tdd_enforcer_parts/__init__.py +1 -1
  19. package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +1 -46
  20. package/hooks/blocking/test_pre_tool_use_dispatcher.py +2 -40
  21. package/hooks/followup_ledger.py +8 -1
  22. package/hooks/git-hooks/test_installed_policy_lint.py +4 -2
  23. package/hooks/hooks.json +15 -0
  24. package/hooks/hooks_constants/followup_ledger_constants.py +4 -0
  25. package/hooks/hooks_constants/messages.py +0 -1
  26. package/hooks/hooks_constants/nested_project_hooks_constants.py +37 -0
  27. package/hooks/hooks_constants/test_messages.py +1 -7
  28. package/hooks/lifecycle/nested_project_hooks.py +395 -0
  29. package/hooks/lifecycle/test_nested_project_hooks.py +251 -0
  30. package/hooks/test_followup_ledger.py +18 -0
  31. package/package.json +2 -1
  32. package/pstack-codex-models.md +37 -0
  33. package/rules/bdd.md +1 -1
  34. package/rules/build-needs-a-user.md +53 -0
  35. package/rules/code-standards.md +1 -1
  36. package/rules/flag-non-breaking-findings.md +7 -0
  37. package/rules/paired-test-coverage.md +4 -2
  38. package/rules/testing.md +5 -1
  39. package/scripts/cde_lint.py +47 -1
  40. package/scripts/policy_lint/adapter_callers.py +124 -0
  41. package/scripts/policy_lint/adapter_pairing.py +4 -2
  42. package/scripts/policy_lint/adapter_support.py +4 -3
  43. package/scripts/policy_lint/adapters.py +15 -0
  44. package/scripts/policy_lint/check_catalog.py +24 -0
  45. package/scripts/policy_lint/config/approved_test_pairs.py +3 -2
  46. package/scripts/policy_lint/config/check_catalog_constants.py +13 -0
  47. package/scripts/policy_lint/config/constants.py +18 -0
  48. package/scripts/policy_lint/engine.py +9 -3
  49. package/scripts/policy_lint/model.py +6 -2
  50. package/scripts/policy_lint/registry.py +6 -0
  51. package/scripts/policy_lint/selection.py +2 -0
  52. package/scripts/tests/test_cde_lint.py +50 -0
  53. package/scripts/tests/test_policy_lint_rules_archive.py +16 -0
  54. package/scripts/tests/test_policy_lint_rules_callers.py +126 -0
  55. package/scripts/tests/test_policy_lint_rules_check_catalog.py +25 -1
  56. package/scripts/tests/test_policy_lint_rules_pairing.py +31 -2
  57. package/scripts/tests/test_policy_lint_selection.py +35 -0
  58. package/hooks/blocking/tdd_enforcer.py +0 -195
  59. package/hooks/blocking/tdd_enforcer_parts/candidate_paths.py +0 -142
  60. package/hooks/blocking/tdd_enforcer_parts/config/__init__.py +0 -1
  61. package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +0 -49
  62. package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +0 -298
  63. package/hooks/blocking/tdd_enforcer_parts/decisions.py +0 -96
  64. package/hooks/blocking/tdd_enforcer_parts/freshness.py +0 -95
  65. package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +0 -132
  66. package/hooks/blocking/tdd_enforcer_parts/path_classification.py +0 -119
  67. package/hooks/blocking/tdd_enforcer_parts/tests/test_candidate_paths.py +0 -31
  68. package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +0 -256
  69. package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +0 -46
  70. package/hooks/blocking/tdd_enforcer_parts/tests/test_freshness.py +0 -28
  71. package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +0 -111
  72. package/hooks/blocking/tdd_enforcer_parts/tests/test_path_classification.py +0 -36
  73. package/hooks/blocking/test_tdd_enforcer.py +0 -1506
  74. package/hooks/blocking/test_tdd_enforcer_restore.py +0 -108
  75. package/hooks/blocking/test_tdd_enforcer_scratchpad.py +0 -105
  76. package/scripts/codex_compat_watcher.py +0 -502
  77. package/scripts/tests/test_codex_compat_watcher.py +0 -599
@@ -1,195 +0,0 @@
1
- """BDD Automate-phase gate (production code touch).
2
-
3
- Blocks a write to a production source file when no matching test was modified
4
- within the freshness window, enforcing "TDD IS NON-NEGOTIABLE" from CLAUDE.md.
5
- 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. A stored failing pytest run is read before that fallback
8
- and applies while the candidate's content still matches the stored hash.
9
- Each concern lives in a ``tdd_enforcer_parts`` submodule; this entry wires them
10
- into one PreToolUse gate and re-exports their surface for the test suite.
11
- """
12
-
13
- import json
14
- import sys
15
- from pathlib import Path
16
-
17
- try:
18
- _hooks_root_directory = str(Path(__file__).resolve().parent.parent)
19
- _blocking_directory = str(Path(__file__).resolve().parent)
20
- for each_bootstrap_directory in (_hooks_root_directory, _blocking_directory):
21
- if each_bootstrap_directory not in sys.path:
22
- sys.path.insert(0, each_bootstrap_directory)
23
- from code_rules_shared import (
24
- is_agent_home_tooling,
25
- is_ephemeral_script_path,
26
- is_under_session_scratchpad,
27
- )
28
- from codex_apply_patch import (
29
- payload_patch_target_paths,
30
- )
31
- from hooks_constants.pre_tool_use_dispatcher_constants import APPLY_PATCH_TOOL_NAME
32
- from tdd_enforcer_parts import (
33
- candidate_paths,
34
- content_analysis,
35
- content_hash_store,
36
- decisions,
37
- freshness,
38
- git_tracking,
39
- path_classification,
40
- )
41
- from tdd_enforcer_parts.config.tdd_enforcer_constants import PYTHON_SOURCE_EXTENSION
42
- except ImportError as import_error:
43
- raise ImportError(
44
- "tdd_enforcer: cannot import its tdd_enforcer_parts submodules; "
45
- "ensure the hooks directory is importable."
46
- ) from import_error
47
-
48
-
49
- def _is_session_scratchpad_write(file_path: str, input_data: dict) -> bool:
50
- return is_under_session_scratchpad(file_path, input_data)
51
-
52
-
53
- def _is_agent_home_tooling_write(file_path: str) -> bool:
54
- """A coding agent's own home-directory tooling is outside the TDD gate."""
55
- return is_agent_home_tooling(file_path)
56
-
57
-
58
- production_extensions = path_classification.production_extensions
59
- skip_extensions = path_classification.skip_extensions
60
- _is_inside_dotclaude_segment = path_classification._is_inside_dotclaude_segment
61
- _directory_skip_components = path_classification._directory_skip_components
62
- _matches_any_skip_pattern = path_classification._matches_any_skip_pattern
63
- _extract_written_content = path_classification._extract_written_content
64
- candidate_test_paths_for = candidate_paths.candidate_test_paths_for
65
- _ancestor_tests_directories = candidate_paths._ancestor_tests_directories
66
- _parent_walk_limit = candidate_paths._parent_walk_limit
67
- _freshness_seconds = freshness._freshness_seconds
68
- _read_candidate_text = freshness._read_candidate_text
69
- has_recorded_or_fresh_test = content_hash_store.has_recorded_or_fresh_test
70
- _is_constants_only_python_content = content_analysis._is_constants_only_python_content
71
- _is_post_edit_import_only = content_analysis._is_post_edit_import_only
72
- _is_post_edit_constants_only = content_analysis._is_post_edit_constants_only
73
- is_absent_but_tracked = git_tracking.is_absent_but_tracked
74
- build_deny_reason = decisions.build_deny_reason
75
- emit_allow = decisions.emit_allow
76
- emit_deny = decisions.emit_deny
77
-
78
-
79
- def _resolve_payload(input_data: dict) -> tuple[str, dict, str]:
80
- tool_input = input_data.get("tool_input", {})
81
- return input_data.get("tool_name", ""), tool_input, tool_input.get("file_path", "")
82
-
83
-
84
- def _is_silently_skipped(
85
- file_path: str, extension: str, path: Path, repository_root: str = ""
86
- ) -> bool:
87
- if _is_inside_dotclaude_segment(file_path) or is_ephemeral_script_path(
88
- file_path, repository_root
89
- ):
90
- return True
91
- if _is_agent_home_tooling_write(file_path):
92
- return True
93
- if extension in skip_extensions():
94
- return True
95
- if extension not in production_extensions():
96
- return True
97
- path_with_forward_slashes = str(path).lower().replace("\\", "/")
98
- return _matches_any_skip_pattern(path.name.lower(), path_with_forward_slashes)
99
-
100
-
101
- def _edit_is_constants_or_import_only(
102
- tool_name: str, tool_input: dict, path: Path, extension: str
103
- ) -> bool:
104
- if (
105
- tool_name not in ("Edit", "MultiEdit")
106
- or extension != PYTHON_SOURCE_EXTENSION
107
- or not path.exists()
108
- ):
109
- return False
110
- existing_content = _read_candidate_text(path)
111
- if existing_content is None:
112
- return False
113
- if _is_post_edit_import_only(existing_content, tool_name, tool_input):
114
- return True
115
- return _is_post_edit_constants_only(existing_content, tool_name, tool_input)
116
-
117
-
118
- def _write_is_exempt(
119
- tool_name: str, tool_input: dict, path: Path, extension: str
120
- ) -> bool:
121
- if is_absent_but_tracked(path):
122
- return True
123
- written_content = _extract_written_content(tool_name, tool_input)
124
- if (
125
- tool_name == "Write"
126
- and extension == PYTHON_SOURCE_EXTENSION
127
- and _is_constants_only_python_content(written_content)
128
- ):
129
- return True
130
- return _edit_is_constants_or_import_only(tool_name, tool_input, path, extension)
131
-
132
-
133
- def _decide_for_target(
134
- tool_name: str, tool_input: dict, file_path: str, input_data: dict
135
- ) -> tuple[bool, str] | None:
136
- """Return (is_allowed, deny_reason) for one target file, or None to skip silently."""
137
- if not file_path:
138
- return None
139
- if _is_session_scratchpad_write(file_path, input_data):
140
- return None
141
- path = Path(file_path)
142
- extension = path.suffix.lower()
143
- repository_root = str(input_data.get("cwd") or "")
144
- if _is_silently_skipped(file_path, extension, path, repository_root):
145
- return None
146
- if _write_is_exempt(tool_name, tool_input, path, extension):
147
- return True, ""
148
- all_candidates = candidate_test_paths_for(path)
149
- session_id = str(input_data.get("session_id") or "")
150
- is_recorded_or_fresh = has_recorded_or_fresh_test(
151
- all_candidates, session_id, repository_root, _freshness_seconds()
152
- )
153
- if is_recorded_or_fresh:
154
- return True, ""
155
- return False, build_deny_reason(path, all_candidates)
156
-
157
-
158
- def _decide_apply_patch(input_data: dict, tool_input: dict) -> None:
159
- """Run the TDD gate over every file a Codex apply_patch payload touches."""
160
- for each_file_path in payload_patch_target_paths(input_data, tool_input):
161
- decision = _decide_for_target(
162
- APPLY_PATCH_TOOL_NAME, tool_input, each_file_path, input_data
163
- )
164
- if decision is None:
165
- continue
166
- is_allowed, deny_reason = decision
167
- if not is_allowed:
168
- emit_deny(deny_reason)
169
- return
170
- emit_allow()
171
-
172
-
173
- def main() -> None:
174
- """Run the TDD gate over one PreToolUse Write, Edit, MultiEdit, or apply_patch payload."""
175
- try:
176
- input_data = json.load(sys.stdin)
177
- except json.JSONDecodeError:
178
- sys.exit(0)
179
- tool_name, tool_input, file_path = _resolve_payload(input_data)
180
- if tool_name == APPLY_PATCH_TOOL_NAME:
181
- _decide_apply_patch(input_data, tool_input)
182
- sys.exit(0)
183
- decision = _decide_for_target(tool_name, tool_input, file_path, input_data)
184
- if decision is None:
185
- sys.exit(0)
186
- is_allowed, deny_reason = decision
187
- if is_allowed:
188
- emit_allow()
189
- sys.exit(0)
190
- emit_deny(deny_reason)
191
- sys.exit(0)
192
-
193
-
194
- if __name__ == "__main__":
195
- main()
@@ -1,142 +0,0 @@
1
- """Resolve the test files whose freshness can satisfy the gate for a module.
2
-
3
- Given a production file, builds every path a matching test could live at: the
4
- flat sibling ``test_<stem>.py``, the package-mirroring candidates under an
5
- ancestor ``tests`` directory, and the split test family for ``code_rules_*``
6
- modules.
7
- """
8
-
9
- from pathlib import Path
10
-
11
- from tdd_enforcer_parts.config.tdd_enforcer_constants import (
12
- ALL_JAVASCRIPT_TEST_EXTENSIONS,
13
- ALL_REPO_BOUNDARY_SENTINELS,
14
- PARENT_WALK_LIMIT,
15
- PYTHON_SOURCE_EXTENSION,
16
- )
17
-
18
-
19
- def _tests_directory_name() -> str:
20
- return "tests"
21
-
22
-
23
- def _parent_walk_limit() -> int:
24
- return PARENT_WALK_LIMIT
25
-
26
-
27
- def _repo_boundary_sentinels() -> frozenset[str]:
28
- return ALL_REPO_BOUNDARY_SENTINELS
29
-
30
-
31
- def _split_module_stem_prefix() -> str:
32
- return "code_rules_"
33
-
34
-
35
- def _split_test_family_glob() -> str:
36
- return "test_code_rules_enforcer_*.py"
37
-
38
-
39
- def _javascript_test_extensions() -> frozenset[str]:
40
- return ALL_JAVASCRIPT_TEST_EXTENSIONS
41
-
42
-
43
- def _is_repo_boundary(candidate_directory: Path) -> bool:
44
- for each_sentinel in _repo_boundary_sentinels():
45
- if (candidate_directory / each_sentinel).exists():
46
- return True
47
- return False
48
-
49
-
50
- def _ancestor_tests_directories(start_directory: Path) -> list[tuple[Path, Path]]:
51
- """Collect each ancestor's sibling ``tests`` directory up to the repo root.
52
-
53
- Args:
54
- start_directory: Directory of the production file under edit.
55
-
56
- Returns:
57
- Ordered ``(ancestor, tests_directory)`` pairs, nearer ancestors first.
58
- """
59
- all_pairs: list[tuple[Path, Path]] = []
60
- current_directory = start_directory
61
- remaining_steps = _parent_walk_limit()
62
- while remaining_steps > 0:
63
- remaining_steps -= 1
64
- sibling_tests = current_directory / _tests_directory_name()
65
- if sibling_tests.is_dir():
66
- all_pairs.append((current_directory, sibling_tests))
67
- if _is_repo_boundary(current_directory):
68
- break
69
- if current_directory.parent == current_directory:
70
- break
71
- current_directory = current_directory.parent
72
- return all_pairs
73
-
74
-
75
- def _split_family_candidates(directory: Path, stem: str) -> list[Path]:
76
- if not stem.startswith(_split_module_stem_prefix()):
77
- return []
78
- return sorted(directory.glob(_split_test_family_glob()))
79
-
80
-
81
- def _flat_stem_candidates(directory: Path, stem: str) -> list[Path]:
82
- return [directory / f"test_{stem}.py", directory / f"{stem}_test.py"]
83
-
84
-
85
- def _mirrored_nested_candidates(
86
- directory: Path, ancestor: Path, tests_directory: Path, stem: str
87
- ) -> list[Path]:
88
- all_candidates: list[Path] = []
89
- nested_directory = tests_directory
90
- for each_relative_part in directory.relative_to(ancestor).parts:
91
- nested_directory = nested_directory / each_relative_part
92
- all_candidates.append(nested_directory / f"test_{stem}.py")
93
- return all_candidates
94
-
95
-
96
- def _nested_tests_candidates(directory: Path, stem: str) -> list[Path]:
97
- all_candidates: list[Path] = []
98
- for each_ancestor, each_tests_directory in _ancestor_tests_directories(directory):
99
- all_candidates.append(each_tests_directory / f"test_{stem}.py")
100
- all_candidates.extend(
101
- _mirrored_nested_candidates(directory, each_ancestor, each_tests_directory, stem)
102
- )
103
- return all_candidates
104
-
105
-
106
- def _python_candidate_test_paths(directory: Path, stem: str) -> list[Path]:
107
- all_candidates = _flat_stem_candidates(directory, stem)
108
- all_candidates.extend(_nested_tests_candidates(directory, stem))
109
- all_candidates.extend(_split_family_candidates(directory, stem))
110
- return all_candidates
111
-
112
-
113
- def _javascript_candidate_test_paths(directory: Path, stem: str, extension: str) -> list[Path]:
114
- return [directory / f"{stem}.test{extension}", directory / f"{stem}.spec{extension}"]
115
-
116
-
117
- def candidate_test_paths_for(production_path: Path) -> list[Path]:
118
- """Return the test files that can satisfy the gate for a production file.
119
-
120
- ::
121
-
122
- pkg/orders.py -> pkg/test_orders.py, pkg/orders_test.py,
123
- pkg/tests/test_orders.py (and package mirrors)
124
-
125
- ``code_rules_*`` Python modules also gain the split test family. Plain
126
- stem-derived candidates always come first.
127
-
128
- Args:
129
- production_path: The production source file being written or edited.
130
-
131
- Returns:
132
- Ordered candidate test paths; stem-derived siblings precede any
133
- split-family additions.
134
- """
135
- directory = production_path.parent
136
- stem = production_path.stem
137
- extension = production_path.suffix.lower()
138
- if extension == PYTHON_SOURCE_EXTENSION:
139
- return _python_candidate_test_paths(directory, stem)
140
- if extension in _javascript_test_extensions():
141
- return _javascript_candidate_test_paths(directory, stem, extension)
142
- return []
@@ -1 +0,0 @@
1
- """Configuration home for the TDD-enforcer parts constants."""
@@ -1,49 +0,0 @@
1
- """Constants for the TDD-enforcer hook and its decomposed parts modules.
2
-
3
- Centralizes the freshness window, the ancestor-walk limit, the git-tracking
4
- 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.
6
- The store's JSON keys also cover a recorded failing-test-run entry: the test
7
- file's content hash at failure time, the failure timestamp, the failing
8
- command, and its exit status.
9
- """
10
-
11
- from __future__ import annotations
12
-
13
- FRESHNESS_WINDOW_SECONDS: int = 600
14
- PARENT_WALK_LIMIT: int = 10
15
- GIT_LS_FILES_TIMEOUT_SECONDS: int = 10
16
- GIT_DIFF_TIMEOUT_SECONDS: int = 10
17
- GIT_EXECUTABLE_NAME: str = "git"
18
- GIT_LS_FILES_SUBCOMMAND: str = "ls-files"
19
- GIT_DIFF_SUBCOMMAND: str = "diff"
20
- GIT_DIFF_QUIET_FLAG: str = "--quiet"
21
- GIT_HEAD_REVISION: str = "HEAD"
22
- GIT_PATHSPEC_SEPARATOR: str = "--"
23
- HASH_STATE_FILE_PREFIX: str = "claude-tdd-test-hashes-"
24
- HASH_STATE_FILE_SUFFIX: str = ".json"
25
- REPOSITORY_ROOT_DIGEST_LENGTH: int = 16
26
- STORED_CONTENT_HASH_KEY: str = "hash"
27
- STORED_CHANGED_AT_KEY: str = "changed_at"
28
- STORED_FAILURE_HASH_KEY: str = "failed_hash"
29
- STORED_FAILED_AT_KEY: str = "failed_at"
30
- STORED_FAILURE_COMMAND_KEY: str = "failed_command"
31
- STORED_FAILURE_EXIT_STATUS_KEY: str = "failed_exit_status"
32
- PYTHON_SOURCE_EXTENSION: str = ".py"
33
- ENTRY_HOOK_FILE_NAME: str = "tdd_enforcer.py"
34
- NEWLINE_JOIN_SEPARATOR: str = "\n"
35
- ALL_PRODUCTION_EXTENSIONS: frozenset[str] = frozenset({".py", ".ts", ".tsx", ".js", ".jsx"})
36
- ALL_SKIP_EXTENSIONS: frozenset[str] = frozenset(
37
- {".md", ".json", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".txt"}
38
- )
39
- ALL_SKIP_NAME_PATTERNS: frozenset[str] = frozenset(
40
- {"test_", "_test.", ".test.", "tests/", "__tests__/", "conftest", "fixture", "mock", "stub"}
41
- )
42
- ALL_DIRECTORY_SKIP_COMPONENTS: frozenset[str] = frozenset(
43
- {"conftest", "fixture", "fixtures", "mock", "mocks", "stub", "stubs"}
44
- )
45
- ALL_DOTCLAUDE_PATH_SEGMENTS: frozenset[str] = frozenset({".claude"})
46
- ALL_REPO_BOUNDARY_SENTINELS: frozenset[str] = frozenset(
47
- {".git", "pyproject.toml", "package.json", "Cargo.toml", "go.mod"}
48
- )
49
- ALL_JAVASCRIPT_TEST_EXTENSIONS: frozenset[str] = frozenset({".tsx", ".ts", ".jsx", ".js"})
@@ -1,298 +0,0 @@
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.state_file_lock import hold_state_file_lock
34
- from hooks_constants.session_edit_stage_gate_constants import (
35
- SESSION_ID_UNSAFE_CHARACTERS_PATTERN,
36
- STATE_FILE_ATOMIC_WRITE_SUFFIX,
37
- STATE_FILE_DEFAULT_SESSION_ID,
38
- STATE_FILE_JSON_INDENT_SPACES,
39
- )
40
- from hooks_constants.setup_project_paths_constants import UTF8_ENCODING
41
- from json_file_reader import read_json_object
42
- from tdd_enforcer_parts import freshness, git_tracking
43
- from tdd_enforcer_parts.config.tdd_enforcer_constants import (
44
- HASH_STATE_FILE_PREFIX,
45
- HASH_STATE_FILE_SUFFIX,
46
- REPOSITORY_ROOT_DIGEST_LENGTH,
47
- STORED_CHANGED_AT_KEY,
48
- STORED_CONTENT_HASH_KEY,
49
- STORED_FAILED_AT_KEY,
50
- STORED_FAILURE_COMMAND_KEY,
51
- STORED_FAILURE_EXIT_STATUS_KEY,
52
- STORED_FAILURE_HASH_KEY,
53
- )
54
-
55
-
56
- def _sanitized_session_id(session_id: str) -> str:
57
- sanitized_session_id = SESSION_ID_UNSAFE_CHARACTERS_PATTERN.sub("", session_id)
58
- return sanitized_session_id or STATE_FILE_DEFAULT_SESSION_ID
59
-
60
-
61
- def _repository_root_digest(repository_root: str) -> str:
62
- full_digest = hashlib.sha256(repository_root.encode("utf-8")).hexdigest()
63
- return full_digest[:REPOSITORY_ROOT_DIGEST_LENGTH]
64
-
65
-
66
- def _state_file_path(session_id: str, repository_root: str) -> Path:
67
- file_name = (
68
- f"{HASH_STATE_FILE_PREFIX}{_sanitized_session_id(session_id)}-"
69
- f"{_repository_root_digest(repository_root)}{HASH_STATE_FILE_SUFFIX}"
70
- )
71
- return Path(tempfile.gettempdir()) / file_name
72
-
73
-
74
- def _state_key_for(candidate_path: Path) -> str:
75
- try:
76
- return str(candidate_path.resolve())
77
- except OSError:
78
- return str(candidate_path)
79
-
80
-
81
- def _load_state(state_file: Path) -> dict[str, object]:
82
- return read_json_object(state_file, encoding=UTF8_ENCODING) or {}
83
-
84
-
85
- def _atomic_write_state(state_file: Path, all_stored_hashes: dict[str, object]) -> None:
86
- write_text_atomically(
87
- state_file,
88
- json.dumps(all_stored_hashes, indent=STATE_FILE_JSON_INDENT_SPACES),
89
- encoding=UTF8_ENCODING,
90
- temporary_prefix=HASH_STATE_FILE_PREFIX,
91
- temporary_suffix=STATE_FILE_ATOMIC_WRITE_SUFFIX,
92
- should_reap_orphans=False,
93
- )
94
-
95
-
96
- def _content_hash(candidate_text: str) -> str:
97
- return hashlib.sha256(candidate_text.encode(UTF8_ENCODING)).hexdigest()
98
-
99
-
100
- def _recorded_entry(current_hash: str, current_time: float) -> dict[str, object]:
101
- return {STORED_CONTENT_HASH_KEY: current_hash, STORED_CHANGED_AT_KEY: current_time}
102
-
103
-
104
- def _has_matching_recorded_failure(
105
- stored_entry: object, current_hash: str, current_time: float, freshness_seconds: int
106
- ) -> bool:
107
- """Return whether a recorded test failure still evidences the candidate's content.
108
-
109
- A failure counts as evidence only while the candidate's content is
110
- exactly what failed and the record has not aged past the freshness
111
- window -- editing the test again, or letting the record go stale, drops
112
- it as evidence and falls back to the ordinary content-hash tracking.
113
- """
114
- if not isinstance(stored_entry, dict):
115
- return False
116
- if stored_entry.get(STORED_FAILURE_HASH_KEY) != current_hash:
117
- return False
118
- failed_at = stored_entry.get(STORED_FAILED_AT_KEY)
119
- if not isinstance(failed_at, (int, float)):
120
- return False
121
- return current_time - failed_at <= freshness_seconds
122
-
123
-
124
- def _is_qualifying_first_sight(
125
- candidate_path: Path, candidate_text: str, current_time: float, freshness_seconds: int
126
- ) -> bool:
127
- candidate_mtime = freshness._safe_mtime(candidate_path)
128
- if candidate_mtime is None or current_time - candidate_mtime > freshness_seconds:
129
- return False
130
- if not freshness.has_test_evidence_in(candidate_text):
131
- return False
132
- return git_tracking.has_uncommitted_change_from_head(candidate_path)
133
-
134
-
135
- def _first_sight_decision(
136
- candidate_path: Path,
137
- candidate_text: str,
138
- current_time: float,
139
- freshness_seconds: int,
140
- current_hash: str,
141
- ) -> tuple[bool, dict[str, object] | None]:
142
- if not _is_qualifying_first_sight(
143
- candidate_path, candidate_text, current_time, freshness_seconds
144
- ):
145
- return False, None
146
- return True, _recorded_entry(current_hash, current_time)
147
-
148
-
149
- def _content_hash_decision(
150
- all_stored_entry_fields: dict[str, object],
151
- current_hash: str,
152
- current_time: float,
153
- freshness_seconds: int,
154
- ) -> tuple[bool, dict[str, object] | None]:
155
- if current_hash != all_stored_entry_fields.get(STORED_CONTENT_HASH_KEY):
156
- return True, _recorded_entry(current_hash, current_time)
157
- stored_changed_at = all_stored_entry_fields.get(STORED_CHANGED_AT_KEY)
158
- if not isinstance(stored_changed_at, (int, float)):
159
- return False, None
160
- return current_time - stored_changed_at <= freshness_seconds, None
161
-
162
-
163
- def _evaluate_candidate(
164
- candidate_path: Path,
165
- stored_entry: object,
166
- current_time: float,
167
- freshness_seconds: int,
168
- ) -> tuple[bool, dict[str, object] | None]:
169
- candidate_text = freshness._read_candidate_text(candidate_path)
170
- if candidate_text is None:
171
- return False, None
172
- current_hash = _content_hash(candidate_text)
173
- if _has_matching_recorded_failure(
174
- stored_entry, current_hash, current_time, freshness_seconds
175
- ):
176
- return True, None
177
- if not isinstance(stored_entry, dict) or STORED_CONTENT_HASH_KEY not in stored_entry:
178
- return _first_sight_decision(
179
- candidate_path, candidate_text, current_time, freshness_seconds, current_hash
180
- )
181
- return _content_hash_decision(stored_entry, current_hash, current_time, freshness_seconds)
182
-
183
-
184
- def _apply_candidate_decisions(
185
- all_candidates: list[Path],
186
- all_stored_hashes: dict[str, object],
187
- current_time: float,
188
- freshness_seconds: int,
189
- ) -> tuple[bool, bool]:
190
- is_allowed = False
191
- state_changed = False
192
- for each_candidate in all_candidates:
193
- candidate_key = _state_key_for(each_candidate)
194
- candidate_allowed, updated_entry = _evaluate_candidate(
195
- each_candidate,
196
- all_stored_hashes.get(candidate_key),
197
- current_time,
198
- freshness_seconds,
199
- )
200
- if candidate_allowed:
201
- is_allowed = True
202
- if updated_entry is not None:
203
- all_stored_hashes[candidate_key] = updated_entry
204
- state_changed = True
205
- return is_allowed, state_changed
206
-
207
-
208
- def has_recorded_or_fresh_test(
209
- all_candidates: list[Path],
210
- session_id: str,
211
- repository_root: str,
212
- freshness_seconds: int,
213
- ) -> bool:
214
- """Return whether any candidate's tracked content hash satisfies the gate.
215
-
216
- Args:
217
- all_candidates: Candidate test paths for the production file.
218
- session_id: Raw session_id from the hook payload.
219
- repository_root: Raw cwd from the hook payload.
220
- freshness_seconds: Maximum age, in seconds, a stored hash may have.
221
-
222
- Returns:
223
- True when at least one candidate carries a matching recorded test
224
- failure, is a fresh dirty first sighting, has a recorded hash that
225
- now differs, or has a recorded hash still in window.
226
- """
227
- state_file = _state_file_path(session_id, repository_root)
228
- with hold_state_file_lock(state_file):
229
- all_stored_hashes = _load_state(state_file)
230
- is_allowed, state_changed = _apply_candidate_decisions(
231
- all_candidates, all_stored_hashes, time.time(), freshness_seconds
232
- )
233
- if state_changed:
234
- _atomic_write_state(state_file, all_stored_hashes)
235
- return is_allowed
236
-
237
-
238
- def _merged_failure_entry(
239
- existing_entry: object,
240
- candidate_text: str,
241
- command: str,
242
- exit_status: int,
243
- current_time: float,
244
- ) -> dict[str, object]:
245
- merged_entry = dict(existing_entry) if isinstance(existing_entry, dict) else {}
246
- merged_entry[STORED_FAILURE_HASH_KEY] = _content_hash(candidate_text)
247
- merged_entry[STORED_FAILED_AT_KEY] = current_time
248
- merged_entry[STORED_FAILURE_COMMAND_KEY] = command
249
- merged_entry[STORED_FAILURE_EXIT_STATUS_KEY] = exit_status
250
- return merged_entry
251
-
252
-
253
- def _record_one_test_file_failure(
254
- test_file_path: Path,
255
- all_stored_hashes: dict[str, object],
256
- command: str,
257
- exit_status: int,
258
- current_time: float,
259
- ) -> bool:
260
- candidate_text = freshness._read_candidate_text(test_file_path)
261
- if candidate_text is None:
262
- return False
263
- candidate_key = _state_key_for(test_file_path)
264
- all_stored_hashes[candidate_key] = _merged_failure_entry(
265
- all_stored_hashes.get(candidate_key),
266
- candidate_text,
267
- command,
268
- exit_status,
269
- current_time,
270
- )
271
- return True
272
-
273
-
274
- def record_test_command_failure(
275
- all_test_file_paths: list[Path],
276
- command: str,
277
- exit_status: int,
278
- session_id: str,
279
- repository_root: str,
280
- ) -> None:
281
- """Record a failing test command as gate evidence, one entry per test file.
282
-
283
- Keyed exactly as the gate looks candidates up, so a failure satisfies the
284
- gate only for the file it names, never a sibling the command also ran.
285
- Existing content-hash fields on that entry are preserved, not overwritten.
286
- """
287
- state_file = _state_file_path(session_id, repository_root)
288
- current_time = time.time()
289
- with hold_state_file_lock(state_file):
290
- all_stored_hashes = _load_state(state_file)
291
- all_recorded = [
292
- _record_one_test_file_failure(
293
- each_path, all_stored_hashes, command, exit_status, current_time
294
- )
295
- for each_path in all_test_file_paths
296
- ]
297
- if any(all_recorded):
298
- _atomic_write_state(state_file, all_stored_hashes)