claude-dev-env 8.10.0 → 8.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_regression.py +85 -3
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_regression.py +34 -0
- package/_shared/pr-loop/scripts/fix_pr_test_proof.py +233 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/fix_pr_test_proof_constants.py +47 -0
- package/_shared/pr-loop/scripts/test_fix_pr_test_proof.py +186 -0
- package/bin/codex-skill-load-block.mjs +93 -0
- package/bin/codex-skill-load-block.test.mjs +134 -0
- package/bin/install.agents-home.test.mjs +76 -1
- package/bin/install.mjs +55 -31
- package/bin/install.test.mjs +57 -0
- package/bin/install.transaction.test.mjs +29 -7
- package/bin/seed-codex-pstack-models.mjs +27 -0
- package/bin/seed-codex-pstack-models.test.mjs +72 -0
- package/docs/CODE_RULES.md +5 -1
- package/hooks/blocking/code_rules_enforcer.py +1 -3
- package/hooks/blocking/code_rules_magic_values.py +1 -3
- package/hooks/blocking/code_rules_paired_test.py +5 -6
- package/hooks/blocking/pii_prevention_blocker_parts/repository_exemption.py +3 -3
- package/hooks/blocking/tdd_enforcer_parts/__init__.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +1 -46
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +2 -40
- package/hooks/blocking/tests/test_repository_exemption.py +7 -0
- package/hooks/followup_ledger.py +8 -1
- package/hooks/git-hooks/test_installed_policy_lint.py +4 -2
- package/hooks/hooks.json +15 -0
- package/hooks/hooks_constants/followup_ledger_constants.py +4 -0
- package/hooks/hooks_constants/messages.py +0 -1
- package/hooks/hooks_constants/nested_project_hooks_constants.py +37 -0
- package/hooks/hooks_constants/test_messages.py +1 -7
- package/hooks/lifecycle/nested_project_hooks.py +395 -0
- package/hooks/lifecycle/test_nested_project_hooks.py +251 -0
- package/hooks/test_followup_ledger.py +18 -0
- package/package.json +2 -1
- package/pstack-codex-models.md +37 -0
- package/rules/bdd.md +1 -1
- package/rules/build-needs-a-user.md +53 -0
- package/rules/code-standards.md +1 -1
- package/rules/flag-non-breaking-findings.md +7 -0
- package/rules/paired-test-coverage.md +4 -2
- package/rules/testing.md +5 -1
- package/scripts/cde_lint.py +47 -1
- package/scripts/policy_lint/adapter_callers.py +124 -0
- package/scripts/policy_lint/adapter_pairing.py +4 -2
- package/scripts/policy_lint/adapter_support.py +4 -3
- package/scripts/policy_lint/adapters.py +15 -0
- package/scripts/policy_lint/check_catalog.py +24 -0
- package/scripts/policy_lint/config/approved_test_pairs.py +3 -2
- package/scripts/policy_lint/config/check_catalog_constants.py +13 -0
- package/scripts/policy_lint/config/constants.py +18 -0
- package/scripts/policy_lint/engine.py +9 -3
- package/scripts/policy_lint/model.py +6 -2
- package/scripts/policy_lint/registry.py +6 -0
- package/scripts/policy_lint/selection.py +2 -0
- package/scripts/repository_checks/tracked_private_terms.py +15 -1
- package/scripts/tests/test_cde_lint.py +50 -0
- package/scripts/tests/test_policy_lint_rules_archive.py +16 -0
- package/scripts/tests/test_policy_lint_rules_callers.py +140 -0
- package/scripts/tests/test_policy_lint_rules_check_catalog.py +25 -1
- package/scripts/tests/test_policy_lint_rules_pairing.py +31 -2
- package/scripts/tests/test_policy_lint_selection.py +35 -0
- package/scripts/tests/test_tracked_private_terms.py +35 -0
- package/hooks/blocking/tdd_enforcer.py +0 -195
- package/hooks/blocking/tdd_enforcer_parts/candidate_paths.py +0 -142
- package/hooks/blocking/tdd_enforcer_parts/config/__init__.py +0 -1
- package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +0 -49
- package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +0 -298
- package/hooks/blocking/tdd_enforcer_parts/decisions.py +0 -96
- package/hooks/blocking/tdd_enforcer_parts/freshness.py +0 -95
- package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +0 -132
- package/hooks/blocking/tdd_enforcer_parts/path_classification.py +0 -119
- package/hooks/blocking/tdd_enforcer_parts/tests/test_candidate_paths.py +0 -31
- package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +0 -256
- package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +0 -46
- package/hooks/blocking/tdd_enforcer_parts/tests/test_freshness.py +0 -28
- package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +0 -111
- package/hooks/blocking/tdd_enforcer_parts/tests/test_path_classification.py +0 -36
- package/hooks/blocking/test_tdd_enforcer.py +0 -1506
- package/hooks/blocking/test_tdd_enforcer_restore.py +0 -108
- package/hooks/blocking/test_tdd_enforcer_scratchpad.py +0 -105
- package/scripts/codex_compat_watcher.py +0 -502
- package/scripts/tests/test_codex_compat_watcher.py +0 -599
|
@@ -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)
|
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
"""Build and emit the gate's allow and deny decisions.
|
|
2
|
-
|
|
3
|
-
Writes the PreToolUse decision JSON to stdout and, on a deny, records the block
|
|
4
|
-
and attaches the short user-facing notice.
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
import json
|
|
8
|
-
import sys
|
|
9
|
-
from pathlib import Path
|
|
10
|
-
|
|
11
|
-
from tdd_enforcer_parts.config.tdd_enforcer_constants import (
|
|
12
|
-
ENTRY_HOOK_FILE_NAME,
|
|
13
|
-
FRESHNESS_WINDOW_SECONDS,
|
|
14
|
-
NEWLINE_JOIN_SEPARATOR,
|
|
15
|
-
)
|
|
16
|
-
|
|
17
|
-
from hooks_constants.hook_block_logger import log_hook_block
|
|
18
|
-
from hooks_constants.messages import USER_FACING_TDD_NOTICE
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
def _entry_hook_path() -> Path:
|
|
22
|
-
"""Return the entry hook file that wires the gate's exemption rules together.
|
|
23
|
-
|
|
24
|
-
The exemption logic the deny message points at lives in the entry hook
|
|
25
|
-
beside this parts package, not in this emit-only module::
|
|
26
|
-
|
|
27
|
-
tdd_enforcer_parts/decisions.py -> parent -> blocking/
|
|
28
|
-
blocking/tdd_enforcer.py <- the named exemption home
|
|
29
|
-
|
|
30
|
-
So the guidance names the entry hook, letting a reader land on the module
|
|
31
|
-
that actually decides exemptions.
|
|
32
|
-
"""
|
|
33
|
-
return Path(__file__).resolve().parent.parent / ENTRY_HOOK_FILE_NAME
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def build_deny_reason(production_path: Path, all_candidates: list[Path]) -> str:
|
|
37
|
-
"""Return the verbose deny message naming the candidate test files.
|
|
38
|
-
|
|
39
|
-
Args:
|
|
40
|
-
production_path: The production file the write targets.
|
|
41
|
-
all_candidates: Candidate test paths the gate looked for.
|
|
42
|
-
|
|
43
|
-
Returns:
|
|
44
|
-
The multi-line reason shown when no fresh test satisfies the gate.
|
|
45
|
-
"""
|
|
46
|
-
candidate_lines = NEWLINE_JOIN_SEPARATOR.join(f" - {each_path}" for each_path in all_candidates)
|
|
47
|
-
hook_source_path = _entry_hook_path()
|
|
48
|
-
return (
|
|
49
|
-
f"[TDD] Blocking write to production file: {production_path}\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"
|
|
56
|
-
f"Expected one of:\n{candidate_lines}\n"
|
|
57
|
-
f"Write a failing test first (RED), then the minimum code to pass it (GREEN).\n\n"
|
|
58
|
-
f"If this file legitimately does not need a test (for example, a module containing only "
|
|
59
|
-
f"module-level constants with no behavior), that is a hook enhancement, not a bypass. "
|
|
60
|
-
f"Propose an exemption rule in {hook_source_path} so every similar file benefits "
|
|
61
|
-
f"automatically. Do not add escape-hatch markers to production files."
|
|
62
|
-
)
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
def emit_allow() -> None:
|
|
66
|
-
"""Write the allow decision to stdout."""
|
|
67
|
-
allow_payload = {
|
|
68
|
-
"hookSpecificOutput": {
|
|
69
|
-
"hookEventName": "PreToolUse",
|
|
70
|
-
"permissionDecision": "allow",
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
sys.stdout.write(json.dumps(allow_payload))
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
def emit_deny(reason: str) -> None:
|
|
77
|
-
"""Write the deny decision to stdout and record the block.
|
|
78
|
-
|
|
79
|
-
Args:
|
|
80
|
-
reason: The verbose deny reason shown to the agent.
|
|
81
|
-
"""
|
|
82
|
-
deny_payload = {
|
|
83
|
-
"hookSpecificOutput": {
|
|
84
|
-
"hookEventName": "PreToolUse",
|
|
85
|
-
"permissionDecision": "deny",
|
|
86
|
-
"permissionDecisionReason": reason,
|
|
87
|
-
},
|
|
88
|
-
"suppressOutput": True,
|
|
89
|
-
"systemMessage": USER_FACING_TDD_NOTICE,
|
|
90
|
-
}
|
|
91
|
-
log_hook_block(
|
|
92
|
-
calling_hook_name="tdd_enforcer.py",
|
|
93
|
-
hook_event="PreToolUse",
|
|
94
|
-
block_reason=reason,
|
|
95
|
-
)
|
|
96
|
-
sys.stdout.write(json.dumps(deny_payload))
|
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
"""Freshness check for the candidate test files the gate resolves.
|
|
2
|
-
|
|
3
|
-
A candidate satisfies the gate when it was modified inside the freshness window
|
|
4
|
-
and holds a real test function, so a stale test or a same-named file with no
|
|
5
|
-
test evidence does not open the gate.
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
import re
|
|
9
|
-
import time
|
|
10
|
-
from pathlib import Path
|
|
11
|
-
|
|
12
|
-
from tdd_enforcer_parts.config.tdd_enforcer_constants import FRESHNESS_WINDOW_SECONDS
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
def _freshness_seconds() -> int:
|
|
16
|
-
return FRESHNESS_WINDOW_SECONDS
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def _test_file_encoding() -> str:
|
|
20
|
-
return "utf-8"
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def _test_function_patterns() -> tuple[re.Pattern[str], ...]:
|
|
24
|
-
return (
|
|
25
|
-
re.compile(r"\bdef\s+test_"),
|
|
26
|
-
re.compile(r"\b(?:it|test|describe)\s*\("),
|
|
27
|
-
)
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def _safe_mtime(candidate_path: Path) -> float | None:
|
|
31
|
-
try:
|
|
32
|
-
return candidate_path.stat().st_mtime
|
|
33
|
-
except (FileNotFoundError, OSError):
|
|
34
|
-
return None
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
def _read_candidate_text(candidate_path: Path) -> str | None:
|
|
38
|
-
try:
|
|
39
|
-
with candidate_path.open("r", encoding=_test_file_encoding(), errors="ignore") as each_file:
|
|
40
|
-
return each_file.read()
|
|
41
|
-
except (FileNotFoundError, OSError):
|
|
42
|
-
return None
|
|
43
|
-
|
|
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
|
-
|
|
62
|
-
def _contains_test_evidence(candidate_path: Path) -> bool:
|
|
63
|
-
test_file_content = _read_candidate_text(candidate_path)
|
|
64
|
-
if test_file_content is None:
|
|
65
|
-
return False
|
|
66
|
-
return has_test_evidence_in(test_file_content)
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
def _candidate_is_fresh_test(
|
|
70
|
-
candidate_path: Path, current_time: float, freshness_seconds: int
|
|
71
|
-
) -> bool:
|
|
72
|
-
candidate_mtime = _safe_mtime(candidate_path)
|
|
73
|
-
if candidate_mtime is None:
|
|
74
|
-
return False
|
|
75
|
-
if current_time - candidate_mtime > freshness_seconds:
|
|
76
|
-
return False
|
|
77
|
-
return _contains_test_evidence(candidate_path)
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
def has_fresh_test(all_candidates: list[Path], freshness_seconds: int) -> bool:
|
|
81
|
-
"""Return whether any candidate is a recently modified real test file.
|
|
82
|
-
|
|
83
|
-
Args:
|
|
84
|
-
all_candidates: Candidate test paths for the production file.
|
|
85
|
-
freshness_seconds: Maximum age, in seconds, a test may have.
|
|
86
|
-
|
|
87
|
-
Returns:
|
|
88
|
-
True when at least one candidate was modified within the window and
|
|
89
|
-
holds a test function.
|
|
90
|
-
"""
|
|
91
|
-
current_time = time.time()
|
|
92
|
-
return any(
|
|
93
|
-
_candidate_is_fresh_test(each_candidate, current_time, freshness_seconds)
|
|
94
|
-
for each_candidate in all_candidates
|
|
95
|
-
)
|