claude-dev-env 8.9.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.
- 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 +132 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/fix_pr_test_proof_constants.py +43 -0
- package/_shared/pr-loop/scripts/test_fix_pr_test_proof.py +126 -0
- package/bin/codex-skill-load-block.mjs +45 -0
- package/bin/codex-skill-load-block.test.mjs +72 -0
- package/bin/install-constants.mjs +5 -0
- package/bin/install.agents-home.test.mjs +76 -1
- package/bin/install.cursor-rules.test.mjs +6 -0
- package/bin/install.mjs +55 -33
- package/bin/install.test.mjs +37 -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/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/followup_ledger.py +8 -1
- package/hooks/git-hooks/test_installed_policy_lint.py +4 -2
- package/hooks/hooks.json +27 -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/skill_loaded_reminder_constants.py +16 -4
- 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/session/skill_loaded_reminder.py +28 -13
- package/hooks/session/test_skill_loaded_reminder.py +49 -2
- 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/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 +126 -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/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,119 +0,0 @@
|
|
|
1
|
-
"""Path and payload classification for the TDD-enforcer gate.
|
|
2
|
-
|
|
3
|
-
Decides which write targets the gate skips silently (docs, tests, files inside a
|
|
4
|
-
``.claude`` tree) and pulls the written text out of a Write, Edit, or MultiEdit
|
|
5
|
-
payload so the gate can inspect it.
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
from tdd_enforcer_parts.config.tdd_enforcer_constants import (
|
|
9
|
-
ALL_DIRECTORY_SKIP_COMPONENTS,
|
|
10
|
-
ALL_DOTCLAUDE_PATH_SEGMENTS,
|
|
11
|
-
ALL_PRODUCTION_EXTENSIONS,
|
|
12
|
-
ALL_SKIP_EXTENSIONS,
|
|
13
|
-
ALL_SKIP_NAME_PATTERNS,
|
|
14
|
-
NEWLINE_JOIN_SEPARATOR,
|
|
15
|
-
)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def production_extensions() -> frozenset[str]:
|
|
19
|
-
"""Return the source-file extensions the gate treats as production code."""
|
|
20
|
-
return ALL_PRODUCTION_EXTENSIONS
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def skip_extensions() -> frozenset[str]:
|
|
24
|
-
"""Return the config and documentation extensions the gate skips."""
|
|
25
|
-
return ALL_SKIP_EXTENSIONS
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def _skip_patterns() -> frozenset[str]:
|
|
29
|
-
return ALL_SKIP_NAME_PATTERNS
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def _dotclaude_path_segments() -> frozenset[str]:
|
|
33
|
-
return ALL_DOTCLAUDE_PATH_SEGMENTS
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def _directory_skip_components() -> frozenset[str]:
|
|
37
|
-
return ALL_DIRECTORY_SKIP_COMPONENTS
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def _is_inside_dotclaude_segment(file_path_string: str) -> bool:
|
|
41
|
-
"""Return whether any path segment is exactly a ``.claude`` directory.
|
|
42
|
-
|
|
43
|
-
Args:
|
|
44
|
-
file_path_string: The write target path, in either separator style.
|
|
45
|
-
|
|
46
|
-
Returns:
|
|
47
|
-
True when a ``.claude`` directory sits on the path, so the gate leaves
|
|
48
|
-
agent-config edits alone.
|
|
49
|
-
"""
|
|
50
|
-
normalized_path = file_path_string.replace("\\", "/")
|
|
51
|
-
dotclaude_segments = _dotclaude_path_segments()
|
|
52
|
-
for each_segment in normalized_path.split("/"):
|
|
53
|
-
if each_segment and each_segment in dotclaude_segments:
|
|
54
|
-
return True
|
|
55
|
-
return False
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
def _path_has_skip_directory_component(path_with_forward_slashes: str) -> bool:
|
|
59
|
-
all_components = [each_part for each_part in path_with_forward_slashes.split("/") if each_part]
|
|
60
|
-
directory_components = all_components[:-1]
|
|
61
|
-
skip_directory_components = _directory_skip_components()
|
|
62
|
-
return any(each in skip_directory_components for each in directory_components)
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
def _skip_pattern_matches(pattern: str, name_lower: str, path_with_forward_slashes: str) -> bool:
|
|
66
|
-
if pattern.endswith("/"):
|
|
67
|
-
return pattern in path_with_forward_slashes
|
|
68
|
-
return pattern in name_lower
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
def _name_or_path_matches_skip_pattern(name_lower: str, path_with_forward_slashes: str) -> bool:
|
|
72
|
-
for each_pattern in _skip_patterns():
|
|
73
|
-
if _skip_pattern_matches(each_pattern, name_lower, path_with_forward_slashes):
|
|
74
|
-
return True
|
|
75
|
-
return False
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
def _matches_any_skip_pattern(name_lower: str, path_with_forward_slashes: str) -> bool:
|
|
79
|
-
"""Return whether a path names a test, fixture, mock, or stub file.
|
|
80
|
-
|
|
81
|
-
Args:
|
|
82
|
-
name_lower: The lower-cased file name.
|
|
83
|
-
path_with_forward_slashes: The lower-cased path with forward slashes.
|
|
84
|
-
|
|
85
|
-
Returns:
|
|
86
|
-
True when a skip directory component or a skip name pattern matches.
|
|
87
|
-
"""
|
|
88
|
-
if _path_has_skip_directory_component(path_with_forward_slashes):
|
|
89
|
-
return True
|
|
90
|
-
return _name_or_path_matches_skip_pattern(name_lower, path_with_forward_slashes)
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
def _multiedit_new_strings(all_edits: list) -> list[str]:
|
|
94
|
-
all_new_strings: list[str] = []
|
|
95
|
-
for each_edit in all_edits:
|
|
96
|
-
if isinstance(each_edit, dict):
|
|
97
|
-
all_new_strings.append(each_edit.get("new_string", "") or "")
|
|
98
|
-
return all_new_strings
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
def _extract_written_content(tool_name: str, tool_input: dict) -> str:
|
|
102
|
-
"""Return the text a Write, Edit, or MultiEdit payload would introduce.
|
|
103
|
-
|
|
104
|
-
Args:
|
|
105
|
-
tool_name: The intercepted tool name.
|
|
106
|
-
tool_input: The intercepted tool's input payload.
|
|
107
|
-
|
|
108
|
-
Returns:
|
|
109
|
-
The written content for Write, the replacement for Edit, the joined
|
|
110
|
-
replacements for MultiEdit, or an empty string for any other tool.
|
|
111
|
-
"""
|
|
112
|
-
if tool_name == "Write":
|
|
113
|
-
return tool_input.get("content", "") or ""
|
|
114
|
-
if tool_name == "Edit":
|
|
115
|
-
return tool_input.get("new_string", "") or ""
|
|
116
|
-
if tool_name == "MultiEdit":
|
|
117
|
-
all_edits = tool_input.get("edits", []) or []
|
|
118
|
-
return NEWLINE_JOIN_SEPARATOR.join(_multiedit_new_strings(all_edits))
|
|
119
|
-
return ""
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
"""Behavioral tests for the candidate_paths parts module."""
|
|
2
|
-
|
|
3
|
-
from pathlib import Path
|
|
4
|
-
|
|
5
|
-
from tdd_enforcer_parts import candidate_paths
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
def test_candidate_paths_offers_flat_stem_candidates_first(tmp_path: Path) -> None:
|
|
9
|
-
(tmp_path / ".git").mkdir()
|
|
10
|
-
all_candidates = candidate_paths.candidate_test_paths_for(tmp_path / "orders.py")
|
|
11
|
-
assert all_candidates[0] == tmp_path / "test_orders.py"
|
|
12
|
-
assert all_candidates[1] == tmp_path / "orders_test.py"
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
def test_candidate_paths_returns_empty_for_unknown_extension(tmp_path: Path) -> None:
|
|
16
|
-
assert candidate_paths.candidate_test_paths_for(tmp_path / "data.rs") == []
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def test_candidate_paths_offers_javascript_test_siblings(tmp_path: Path) -> None:
|
|
20
|
-
all_candidates = candidate_paths.candidate_test_paths_for(tmp_path / "Button.tsx")
|
|
21
|
-
assert tmp_path / "Button.test.tsx" in all_candidates
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def test_ancestor_tests_directories_finds_sibling_tests(tmp_path: Path) -> None:
|
|
25
|
-
(tmp_path / ".git").mkdir()
|
|
26
|
-
package = tmp_path / "pkg"
|
|
27
|
-
package.mkdir()
|
|
28
|
-
(package / "tests").mkdir()
|
|
29
|
-
all_pairs = candidate_paths._ancestor_tests_directories(package)
|
|
30
|
-
all_tests_directories = [each_tests_directory for _, each_tests_directory in all_pairs]
|
|
31
|
-
assert package / "tests" in all_tests_directories
|
|
@@ -1,256 +0,0 @@
|
|
|
1
|
-
"""Behavioral tests for the content_hash_store parts module.
|
|
2
|
-
|
|
3
|
-
Covers the recorded-test-failure evidence path: a genuinely failing test
|
|
4
|
-
command outranks a stale mtime, never satisfies a candidate it did not
|
|
5
|
-
name, expires after the freshness window, and applies only while the
|
|
6
|
-
candidate's content still matches what actually failed. Also covers the
|
|
7
|
-
private decision helpers by name, so a rename keeps its own paired test.
|
|
8
|
-
"""
|
|
9
|
-
|
|
10
|
-
import json
|
|
11
|
-
import os
|
|
12
|
-
import time
|
|
13
|
-
from pathlib import Path
|
|
14
|
-
|
|
15
|
-
from tdd_enforcer_parts import content_hash_store
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def _write_fresh_test_file(directory: Path, content: str = "def test_fulfill(): pass\n") -> Path:
|
|
19
|
-
candidate = directory / "test_orders.py"
|
|
20
|
-
candidate.write_text(content)
|
|
21
|
-
return candidate
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def test_state_file_path_differs_by_repository_root(tmp_path: Path) -> None:
|
|
25
|
-
first_root_path = content_hash_store._state_file_path("session-a", str(tmp_path / "one"))
|
|
26
|
-
second_root_path = content_hash_store._state_file_path("session-a", str(tmp_path / "two"))
|
|
27
|
-
assert first_root_path != second_root_path
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def test_state_file_path_is_stable_for_the_same_inputs(tmp_path: Path) -> None:
|
|
31
|
-
first_lookup = content_hash_store._state_file_path("session-a", str(tmp_path))
|
|
32
|
-
second_lookup = content_hash_store._state_file_path("session-a", str(tmp_path))
|
|
33
|
-
assert first_lookup == second_lookup
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def test_first_sight_allows_and_stores_a_fresh_dirty_test(tmp_path: Path) -> None:
|
|
37
|
-
candidate = _write_fresh_test_file(tmp_path)
|
|
38
|
-
|
|
39
|
-
is_allowed = content_hash_store.has_recorded_or_fresh_test(
|
|
40
|
-
[candidate], "session-1", str(tmp_path), 600
|
|
41
|
-
)
|
|
42
|
-
|
|
43
|
-
assert is_allowed is True
|
|
44
|
-
state_file = content_hash_store._state_file_path("session-1", str(tmp_path))
|
|
45
|
-
state = json.loads(state_file.read_text())
|
|
46
|
-
assert content_hash_store._state_key_for(candidate) in state
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def test_first_sight_denies_when_test_evidence_is_missing(tmp_path: Path) -> None:
|
|
50
|
-
candidate = _write_fresh_test_file(tmp_path, content="orders = 'not a test'\n")
|
|
51
|
-
|
|
52
|
-
is_allowed = content_hash_store.has_recorded_or_fresh_test(
|
|
53
|
-
[candidate], "session-2", str(tmp_path), 600
|
|
54
|
-
)
|
|
55
|
-
|
|
56
|
-
assert is_allowed is False
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def test_first_sight_denies_when_test_file_is_stale(tmp_path: Path) -> None:
|
|
60
|
-
candidate = _write_fresh_test_file(tmp_path)
|
|
61
|
-
stale_timestamp = time.time() - 700
|
|
62
|
-
os.utime(candidate, (stale_timestamp, stale_timestamp))
|
|
63
|
-
|
|
64
|
-
is_allowed = content_hash_store.has_recorded_or_fresh_test(
|
|
65
|
-
[candidate], "session-3", str(tmp_path), 600
|
|
66
|
-
)
|
|
67
|
-
|
|
68
|
-
assert is_allowed is False
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
def test_second_sight_denies_a_touch_that_leaves_content_unchanged(tmp_path: Path) -> None:
|
|
72
|
-
candidate = _write_fresh_test_file(tmp_path)
|
|
73
|
-
session_id, repository_root = "session-4", str(tmp_path)
|
|
74
|
-
assert content_hash_store.has_recorded_or_fresh_test(
|
|
75
|
-
[candidate], session_id, repository_root, 600
|
|
76
|
-
)
|
|
77
|
-
state_file = content_hash_store._state_file_path(session_id, repository_root)
|
|
78
|
-
state = json.loads(state_file.read_text())
|
|
79
|
-
candidate_key = content_hash_store._state_key_for(candidate)
|
|
80
|
-
state[candidate_key][content_hash_store.STORED_CHANGED_AT_KEY] -= 700
|
|
81
|
-
state_file.write_text(json.dumps(state))
|
|
82
|
-
candidate.touch()
|
|
83
|
-
|
|
84
|
-
is_allowed = content_hash_store.has_recorded_or_fresh_test(
|
|
85
|
-
[candidate], session_id, repository_root, 600
|
|
86
|
-
)
|
|
87
|
-
|
|
88
|
-
assert is_allowed is False
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
def test_second_sight_allows_when_content_actually_changed_after_the_window(
|
|
92
|
-
tmp_path: Path,
|
|
93
|
-
) -> None:
|
|
94
|
-
candidate = _write_fresh_test_file(tmp_path)
|
|
95
|
-
session_id, repository_root = "session-5", str(tmp_path)
|
|
96
|
-
assert content_hash_store.has_recorded_or_fresh_test(
|
|
97
|
-
[candidate], session_id, repository_root, 600
|
|
98
|
-
)
|
|
99
|
-
state_file = content_hash_store._state_file_path(session_id, repository_root)
|
|
100
|
-
state = json.loads(state_file.read_text())
|
|
101
|
-
candidate_key = content_hash_store._state_key_for(candidate)
|
|
102
|
-
state[candidate_key][content_hash_store.STORED_CHANGED_AT_KEY] -= 700
|
|
103
|
-
state_file.write_text(json.dumps(state))
|
|
104
|
-
candidate.write_text("def test_fulfill(): assert True\n")
|
|
105
|
-
|
|
106
|
-
is_allowed = content_hash_store.has_recorded_or_fresh_test(
|
|
107
|
-
[candidate], session_id, repository_root, 600
|
|
108
|
-
)
|
|
109
|
-
|
|
110
|
-
assert is_allowed is True
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
def test_second_sight_allows_a_repeat_write_within_the_window_untouched(
|
|
114
|
-
tmp_path: Path,
|
|
115
|
-
) -> None:
|
|
116
|
-
candidate = _write_fresh_test_file(tmp_path)
|
|
117
|
-
session_id, repository_root = "session-6", str(tmp_path)
|
|
118
|
-
assert content_hash_store.has_recorded_or_fresh_test(
|
|
119
|
-
[candidate], session_id, repository_root, 600
|
|
120
|
-
)
|
|
121
|
-
|
|
122
|
-
is_allowed = content_hash_store.has_recorded_or_fresh_test(
|
|
123
|
-
[candidate], session_id, repository_root, 600
|
|
124
|
-
)
|
|
125
|
-
|
|
126
|
-
assert is_allowed is True
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
def test_any_candidate_satisfying_the_gate_allows_the_write(tmp_path: Path) -> None:
|
|
130
|
-
stale_candidate = _write_fresh_test_file(tmp_path)
|
|
131
|
-
stale_timestamp = time.time() - 700
|
|
132
|
-
os.utime(stale_candidate, (stale_timestamp, stale_timestamp))
|
|
133
|
-
fresh_candidate = tmp_path / "orders_test.py"
|
|
134
|
-
fresh_candidate.write_text("def test_fulfill(): pass\n")
|
|
135
|
-
|
|
136
|
-
is_allowed = content_hash_store.has_recorded_or_fresh_test(
|
|
137
|
-
[stale_candidate, fresh_candidate], "session-7", str(tmp_path), 600
|
|
138
|
-
)
|
|
139
|
-
|
|
140
|
-
assert is_allowed is True
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
def test_denies_when_no_candidate_file_exists(tmp_path: Path) -> None:
|
|
144
|
-
missing_candidate = tmp_path / "test_missing.py"
|
|
145
|
-
|
|
146
|
-
is_allowed = content_hash_store.has_recorded_or_fresh_test(
|
|
147
|
-
[missing_candidate], "session-8", str(tmp_path), 600
|
|
148
|
-
)
|
|
149
|
-
|
|
150
|
-
assert is_allowed is False
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
def test_recorded_failure_allows_a_stale_candidate(tmp_path: Path) -> None:
|
|
154
|
-
"""RED-evidence: a genuinely recorded failure outranks a stale mtime."""
|
|
155
|
-
candidate = _write_fresh_test_file(tmp_path)
|
|
156
|
-
stale_timestamp = time.time() - 700
|
|
157
|
-
os.utime(candidate, (stale_timestamp, stale_timestamp))
|
|
158
|
-
session_id, repository_root = "session-failure-1", str(tmp_path)
|
|
159
|
-
content_hash_store.record_test_command_failure(
|
|
160
|
-
[candidate], "pytest test_orders.py", 1, session_id, repository_root
|
|
161
|
-
)
|
|
162
|
-
|
|
163
|
-
is_allowed = content_hash_store.has_recorded_or_fresh_test(
|
|
164
|
-
[candidate], session_id, repository_root, 600
|
|
165
|
-
)
|
|
166
|
-
|
|
167
|
-
assert is_allowed is True
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
def test_recorded_failure_stores_the_command_and_exit_status(tmp_path: Path) -> None:
|
|
171
|
-
candidate = _write_fresh_test_file(tmp_path)
|
|
172
|
-
session_id, repository_root = "session-failure-2", str(tmp_path)
|
|
173
|
-
|
|
174
|
-
content_hash_store.record_test_command_failure(
|
|
175
|
-
[candidate], "pytest test_orders.py -q", 1, session_id, repository_root
|
|
176
|
-
)
|
|
177
|
-
|
|
178
|
-
state_file = content_hash_store._state_file_path(session_id, repository_root)
|
|
179
|
-
state = json.loads(state_file.read_text())
|
|
180
|
-
entry = state[content_hash_store._state_key_for(candidate)]
|
|
181
|
-
assert entry[content_hash_store.STORED_FAILURE_COMMAND_KEY] == "pytest test_orders.py -q"
|
|
182
|
-
assert entry[content_hash_store.STORED_FAILURE_EXIT_STATUS_KEY] == 1
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
def test_recorded_failure_does_not_satisfy_a_different_candidate(tmp_path: Path) -> None:
|
|
186
|
-
"""Anti-bypass: a failure recorded for one test file must not unlock a sibling."""
|
|
187
|
-
failing_candidate = tmp_path / "test_bar.py"
|
|
188
|
-
failing_candidate.write_text("def test_bar(): pass\n")
|
|
189
|
-
unrelated_candidate = tmp_path / "test_foo.py"
|
|
190
|
-
unrelated_candidate.write_text("def test_foo(): pass\n")
|
|
191
|
-
stale_timestamp = time.time() - 700
|
|
192
|
-
os.utime(unrelated_candidate, (stale_timestamp, stale_timestamp))
|
|
193
|
-
session_id, repository_root = "session-failure-3", str(tmp_path)
|
|
194
|
-
content_hash_store.record_test_command_failure(
|
|
195
|
-
[failing_candidate], "pytest test_bar.py", 1, session_id, repository_root
|
|
196
|
-
)
|
|
197
|
-
|
|
198
|
-
is_allowed = content_hash_store.has_recorded_or_fresh_test(
|
|
199
|
-
[unrelated_candidate], session_id, repository_root, 600
|
|
200
|
-
)
|
|
201
|
-
|
|
202
|
-
assert is_allowed is False
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
def test_recorded_failure_expires_after_the_freshness_window(tmp_path: Path) -> None:
|
|
206
|
-
candidate = _write_fresh_test_file(tmp_path)
|
|
207
|
-
stale_timestamp = time.time() - 700
|
|
208
|
-
os.utime(candidate, (stale_timestamp, stale_timestamp))
|
|
209
|
-
session_id, repository_root = "session-failure-4", str(tmp_path)
|
|
210
|
-
state_file = content_hash_store._state_file_path(session_id, repository_root)
|
|
211
|
-
candidate_key = content_hash_store._state_key_for(candidate)
|
|
212
|
-
stale_failed_at = time.time() - 700
|
|
213
|
-
state_file.write_text(
|
|
214
|
-
json.dumps(
|
|
215
|
-
{
|
|
216
|
-
candidate_key: {
|
|
217
|
-
content_hash_store.STORED_FAILURE_HASH_KEY: content_hash_store._content_hash(
|
|
218
|
-
candidate.read_text()
|
|
219
|
-
),
|
|
220
|
-
content_hash_store.STORED_FAILED_AT_KEY: stale_failed_at,
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
)
|
|
224
|
-
)
|
|
225
|
-
|
|
226
|
-
is_allowed = content_hash_store.has_recorded_or_fresh_test(
|
|
227
|
-
[candidate], session_id, repository_root, 600
|
|
228
|
-
)
|
|
229
|
-
|
|
230
|
-
assert is_allowed is False
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
def test_recorded_failure_does_not_apply_once_content_no_longer_matches(
|
|
234
|
-
tmp_path: Path,
|
|
235
|
-
) -> None:
|
|
236
|
-
"""Anti-bypass: editing the test after a recorded RED clears that RED as evidence.
|
|
237
|
-
|
|
238
|
-
A bare failure-only entry (no prior content-hash tracking) must not fall
|
|
239
|
-
through to the unconditional hash-differs allow either -- it still needs a
|
|
240
|
-
real qualifying first sight (fresh mtime plus a real git-visible edit).
|
|
241
|
-
"""
|
|
242
|
-
candidate = tmp_path / "test_orders.py"
|
|
243
|
-
candidate.write_text("def test_fulfill(): pass\n")
|
|
244
|
-
session_id, repository_root = "session-failure-5", str(tmp_path)
|
|
245
|
-
content_hash_store.record_test_command_failure(
|
|
246
|
-
[candidate], "pytest test_orders.py", 1, session_id, repository_root
|
|
247
|
-
)
|
|
248
|
-
candidate.write_text("def test_fulfill(): assert True\n")
|
|
249
|
-
stale_timestamp = time.time() - 700
|
|
250
|
-
os.utime(candidate, (stale_timestamp, stale_timestamp))
|
|
251
|
-
|
|
252
|
-
is_allowed = content_hash_store.has_recorded_or_fresh_test(
|
|
253
|
-
[candidate], session_id, repository_root, 600
|
|
254
|
-
)
|
|
255
|
-
|
|
256
|
-
assert is_allowed is False
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
"""Behavioral tests for the decisions parts module."""
|
|
2
|
-
|
|
3
|
-
import json
|
|
4
|
-
from pathlib import Path
|
|
5
|
-
|
|
6
|
-
import pytest
|
|
7
|
-
from tdd_enforcer_parts import decisions
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
def test_build_deny_reason_names_candidates_and_production_path() -> None:
|
|
11
|
-
reason = decisions.build_deny_reason(Path("pkg/orders.py"), [Path("pkg/test_orders.py")])
|
|
12
|
-
assert "orders.py" in reason
|
|
13
|
-
assert "test_orders.py" in reason
|
|
14
|
-
assert "propose" in reason.lower() or "enhancement" in reason.lower()
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def test_build_deny_reason_points_exemption_guidance_at_entry_hook() -> None:
|
|
18
|
-
reason = decisions.build_deny_reason(Path("pkg/orders.py"), [])
|
|
19
|
-
assert "tdd_enforcer.py" in reason
|
|
20
|
-
assert "decisions.py" not in reason
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def test_build_deny_reason_names_content_not_only_modification_time() -> None:
|
|
24
|
-
reason = decisions.build_deny_reason(Path("pkg/orders.py"), [Path("pkg/test_orders.py")])
|
|
25
|
-
assert "content" in reason.lower()
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def test_build_deny_reason_covers_first_sight_and_stale_hash_and_names_the_fix() -> None:
|
|
29
|
-
reason = decisions.build_deny_reason(Path("pkg/orders.py"), [Path("pkg/test_orders.py")]).lower()
|
|
30
|
-
assert "fresh" in reason
|
|
31
|
-
assert "change" in reason
|
|
32
|
-
assert "touch" in reason
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
def test_emit_allow_writes_allow_decision(capsys: pytest.CaptureFixture[str]) -> None:
|
|
36
|
-
decisions.emit_allow()
|
|
37
|
-
parsed = json.loads(capsys.readouterr().out)
|
|
38
|
-
assert parsed["hookSpecificOutput"]["permissionDecision"] == "allow"
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
def test_emit_deny_writes_deny_and_system_message(capsys: pytest.CaptureFixture[str]) -> None:
|
|
42
|
-
decisions.emit_deny("blocked because no fresh test")
|
|
43
|
-
parsed = json.loads(capsys.readouterr().out)
|
|
44
|
-
assert parsed["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
45
|
-
assert parsed["suppressOutput"] is True
|
|
46
|
-
assert isinstance(parsed["systemMessage"], str)
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
"""Behavioral tests for the freshness parts module."""
|
|
2
|
-
|
|
3
|
-
import os
|
|
4
|
-
import time
|
|
5
|
-
from pathlib import Path
|
|
6
|
-
|
|
7
|
-
from tdd_enforcer_parts import freshness
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
def test_has_fresh_test_true_for_recent_test_file(tmp_path: Path) -> None:
|
|
11
|
-
test_file = tmp_path / "test_orders.py"
|
|
12
|
-
test_file.write_text("def test_x(): pass\n")
|
|
13
|
-
assert freshness.has_fresh_test([test_file], freshness._freshness_seconds()) is True
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
def test_has_fresh_test_false_for_stale_test_file(tmp_path: Path) -> None:
|
|
17
|
-
test_file = tmp_path / "test_orders.py"
|
|
18
|
-
test_file.write_text("def test_x(): pass\n")
|
|
19
|
-
window = freshness._freshness_seconds()
|
|
20
|
-
stale_timestamp = time.time() - window - 60
|
|
21
|
-
os.utime(test_file, (stale_timestamp, stale_timestamp))
|
|
22
|
-
assert freshness.has_fresh_test([test_file], window) is False
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
def test_has_fresh_test_false_when_no_test_evidence(tmp_path: Path) -> None:
|
|
26
|
-
test_file = tmp_path / "test_orders.py"
|
|
27
|
-
test_file.write_text("x = 1\n")
|
|
28
|
-
assert freshness.has_fresh_test([test_file], freshness._freshness_seconds()) is False
|
|
@@ -1,111 +0,0 @@
|
|
|
1
|
-
"""Behavioral tests for the git_tracking parts module."""
|
|
2
|
-
|
|
3
|
-
import os
|
|
4
|
-
import subprocess
|
|
5
|
-
from pathlib import Path
|
|
6
|
-
|
|
7
|
-
from tdd_enforcer_parts import git_tracking
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
def _git(repository_root: Path, *arguments: str) -> None:
|
|
11
|
-
subprocess.run(
|
|
12
|
-
["git", *arguments],
|
|
13
|
-
cwd=str(repository_root),
|
|
14
|
-
check=True,
|
|
15
|
-
capture_output=True,
|
|
16
|
-
env={**os.environ, "GIT_CONFIG_GLOBAL": os.devnull, "GIT_CONFIG_SYSTEM": os.devnull},
|
|
17
|
-
)
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def _init_repository(repository_root: Path) -> None:
|
|
21
|
-
_git(repository_root, "init", "-q")
|
|
22
|
-
_git(repository_root, "config", "user.email", "test@example.com")
|
|
23
|
-
_git(repository_root, "config", "user.name", "Test")
|
|
24
|
-
_git(repository_root, "config", "commit.gpgsign", "false")
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
def test_absent_but_tracked_true_after_committed_file_removed(tmp_path: Path) -> None:
|
|
28
|
-
_init_repository(tmp_path)
|
|
29
|
-
tracked = tmp_path / "service.py"
|
|
30
|
-
tracked.write_text("def serve(): return 1\n")
|
|
31
|
-
_git(tmp_path, "add", "-A")
|
|
32
|
-
_git(tmp_path, "commit", "-q", "-m", "init")
|
|
33
|
-
tracked.unlink()
|
|
34
|
-
assert git_tracking.is_absent_but_tracked(tracked) is True
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
def test_absent_but_tracked_false_for_untracked_absent_file(tmp_path: Path) -> None:
|
|
38
|
-
_init_repository(tmp_path)
|
|
39
|
-
assert git_tracking.is_absent_but_tracked(tmp_path / "ghost.py") is False
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def test_absent_but_tracked_false_when_file_present(tmp_path: Path) -> None:
|
|
43
|
-
_init_repository(tmp_path)
|
|
44
|
-
present = tmp_path / "present.py"
|
|
45
|
-
present.write_text("def here(): return 1\n")
|
|
46
|
-
_git(tmp_path, "add", "-A")
|
|
47
|
-
_git(tmp_path, "commit", "-q", "-m", "init")
|
|
48
|
-
assert git_tracking.is_absent_but_tracked(present) is False
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def test_has_uncommitted_change_from_head_true_for_untracked_file(tmp_path: Path) -> None:
|
|
52
|
-
_init_repository(tmp_path)
|
|
53
|
-
untracked = tmp_path / "test_orders.py"
|
|
54
|
-
untracked.write_text("def test_fulfill(): pass\n")
|
|
55
|
-
assert git_tracking.has_uncommitted_change_from_head(untracked) is True
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
def test_has_uncommitted_change_from_head_true_for_tracked_modified_file(tmp_path: Path) -> None:
|
|
59
|
-
_init_repository(tmp_path)
|
|
60
|
-
tracked = tmp_path / "test_orders.py"
|
|
61
|
-
tracked.write_text("def test_fulfill(): pass\n")
|
|
62
|
-
_git(tmp_path, "add", "-A")
|
|
63
|
-
_git(tmp_path, "commit", "-q", "-m", "init")
|
|
64
|
-
tracked.write_text("def test_fulfill(): assert True\n")
|
|
65
|
-
assert git_tracking.has_uncommitted_change_from_head(tracked) is True
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
def test_has_uncommitted_change_from_head_false_for_tracked_clean_file(tmp_path: Path) -> None:
|
|
69
|
-
_init_repository(tmp_path)
|
|
70
|
-
tracked = tmp_path / "test_orders.py"
|
|
71
|
-
tracked.write_text("def test_fulfill(): pass\n")
|
|
72
|
-
_git(tmp_path, "add", "-A")
|
|
73
|
-
_git(tmp_path, "commit", "-q", "-m", "init")
|
|
74
|
-
tracked.touch()
|
|
75
|
-
assert git_tracking.has_uncommitted_change_from_head(tracked) is False
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
def test_has_uncommitted_change_from_head_true_outside_a_git_repository(tmp_path: Path) -> None:
|
|
79
|
-
stray = tmp_path / "test_orders.py"
|
|
80
|
-
stray.write_text("def test_fulfill(): pass\n")
|
|
81
|
-
assert git_tracking.has_uncommitted_change_from_head(stray) is True
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
def test_has_uncommitted_change_from_head_true_for_staged_new_file_never_committed(
|
|
85
|
-
tmp_path: Path,
|
|
86
|
-
) -> None:
|
|
87
|
-
"""A brand-new file only ``git add``-ed carries content HEAD never had.
|
|
88
|
-
|
|
89
|
-
Regression guard: comparing the working tree to the index (rather than to
|
|
90
|
-
HEAD) reads a freshly staged file as unchanged, since staging makes the
|
|
91
|
-
index match the working tree exactly. That misses every staged-but-never-
|
|
92
|
-
committed file, which is exactly how a RED-step test file is staged.
|
|
93
|
-
"""
|
|
94
|
-
_init_repository(tmp_path)
|
|
95
|
-
_git(tmp_path, "commit", "--allow-empty", "-q", "-m", "baseline")
|
|
96
|
-
staged_new_test = tmp_path / "test_orders.py"
|
|
97
|
-
staged_new_test.write_text("def test_fulfill(): pass\n")
|
|
98
|
-
_git(tmp_path, "add", "-A")
|
|
99
|
-
assert git_tracking.has_uncommitted_change_from_head(staged_new_test) is True
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
def test_has_uncommitted_change_from_head_true_for_staged_modification(tmp_path: Path) -> None:
|
|
103
|
-
"""A tracked test's staged edit still differs from HEAD, not only the index."""
|
|
104
|
-
_init_repository(tmp_path)
|
|
105
|
-
tracked = tmp_path / "test_orders.py"
|
|
106
|
-
tracked.write_text("def test_fulfill(): pass\n")
|
|
107
|
-
_git(tmp_path, "add", "-A")
|
|
108
|
-
_git(tmp_path, "commit", "-q", "-m", "init")
|
|
109
|
-
tracked.write_text("def test_fulfill(): assert True\n")
|
|
110
|
-
_git(tmp_path, "add", "-A")
|
|
111
|
-
assert git_tracking.has_uncommitted_change_from_head(tracked) is True
|
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
"""Behavioral tests for the path_classification parts module."""
|
|
2
|
-
|
|
3
|
-
from tdd_enforcer_parts import path_classification
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
def test_production_extensions_includes_python_and_typescript() -> None:
|
|
7
|
-
extensions = path_classification.production_extensions()
|
|
8
|
-
assert ".py" in extensions
|
|
9
|
-
assert ".tsx" in extensions
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
def test_skip_extensions_includes_markdown_and_json() -> None:
|
|
13
|
-
extensions = path_classification.skip_extensions()
|
|
14
|
-
assert ".md" in extensions
|
|
15
|
-
assert ".json" in extensions
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def test_is_inside_dotclaude_segment_matches_exact_segment_only() -> None:
|
|
19
|
-
assert path_classification._is_inside_dotclaude_segment("/home/user/.claude/agent.py") is True
|
|
20
|
-
assert path_classification._is_inside_dotclaude_segment("/src/my.claude.helpers.py") is False
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def test_matches_any_skip_pattern_flags_test_file_names() -> None:
|
|
24
|
-
assert (
|
|
25
|
-
path_classification._matches_any_skip_pattern("test_orders.py", "pkg/test_orders.py")
|
|
26
|
-
is True
|
|
27
|
-
)
|
|
28
|
-
assert path_classification._matches_any_skip_pattern("orders.py", "pkg/orders.py") is False
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def test_extract_written_content_reads_write_and_multiedit() -> None:
|
|
32
|
-
assert path_classification._extract_written_content("Write", {"content": "x"}) == "x"
|
|
33
|
-
joined = path_classification._extract_written_content(
|
|
34
|
-
"MultiEdit", {"edits": [{"new_string": "a"}, {"new_string": "b"}]}
|
|
35
|
-
)
|
|
36
|
-
assert joined == "a\nb"
|