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,132 +0,0 @@
|
|
|
1
|
-
"""Ask git about a candidate path: is it a restore, and does it carry a real edit.
|
|
2
|
-
|
|
3
|
-
::
|
|
4
|
-
|
|
5
|
-
remove orders.py (git-tracked) -> Write orders.py -> restore (allow)
|
|
6
|
-
Write brand_new.py (untracked, absent) -> new code (gate)
|
|
7
|
-
|
|
8
|
-
A full rewrite happens as remove-then-Write, so the file is absent on disk
|
|
9
|
-
between the two steps. Restoring committed code is not new production code, so
|
|
10
|
-
the gate exempts a Write whose target is absent yet tracked in git.
|
|
11
|
-
|
|
12
|
-
``has_uncommitted_change_from_head`` answers a second, related question the
|
|
13
|
-
content-hash store's first-sight check needs: whether a candidate test file
|
|
14
|
-
already carries content HEAD does not have, so a `touch` on an already-clean,
|
|
15
|
-
committed test cannot pass as a first sighting.
|
|
16
|
-
"""
|
|
17
|
-
|
|
18
|
-
import subprocess
|
|
19
|
-
from pathlib import Path
|
|
20
|
-
|
|
21
|
-
from tdd_enforcer_parts.config.tdd_enforcer_constants import (
|
|
22
|
-
GIT_DIFF_QUIET_FLAG,
|
|
23
|
-
GIT_DIFF_SUBCOMMAND,
|
|
24
|
-
GIT_DIFF_TIMEOUT_SECONDS,
|
|
25
|
-
GIT_EXECUTABLE_NAME,
|
|
26
|
-
GIT_HEAD_REVISION,
|
|
27
|
-
GIT_LS_FILES_SUBCOMMAND,
|
|
28
|
-
GIT_LS_FILES_TIMEOUT_SECONDS,
|
|
29
|
-
GIT_PATHSPEC_SEPARATOR,
|
|
30
|
-
)
|
|
31
|
-
|
|
32
|
-
from hooks_constants.subprocess_window import hidden_window_creation_flags
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
def _git_ls_files_command(file_name: str) -> list[str]:
|
|
36
|
-
return [GIT_EXECUTABLE_NAME, GIT_LS_FILES_SUBCOMMAND, GIT_PATHSPEC_SEPARATOR, file_name]
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
def _git_diff_quiet_against_head_command(file_name: str) -> list[str]:
|
|
40
|
-
return [
|
|
41
|
-
GIT_EXECUTABLE_NAME,
|
|
42
|
-
GIT_DIFF_SUBCOMMAND,
|
|
43
|
-
GIT_DIFF_QUIET_FLAG,
|
|
44
|
-
GIT_HEAD_REVISION,
|
|
45
|
-
GIT_PATHSPEC_SEPARATOR,
|
|
46
|
-
file_name,
|
|
47
|
-
]
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
def _git_tracks_path(path: Path) -> bool:
|
|
51
|
-
parent_directory = path.parent
|
|
52
|
-
if not parent_directory.is_dir():
|
|
53
|
-
return False
|
|
54
|
-
try:
|
|
55
|
-
completed = subprocess.run(
|
|
56
|
-
_git_ls_files_command(path.name),
|
|
57
|
-
cwd=str(parent_directory),
|
|
58
|
-
capture_output=True,
|
|
59
|
-
text=True,
|
|
60
|
-
check=False,
|
|
61
|
-
timeout=GIT_LS_FILES_TIMEOUT_SECONDS,
|
|
62
|
-
creationflags=hidden_window_creation_flags(),
|
|
63
|
-
)
|
|
64
|
-
except (OSError, subprocess.SubprocessError):
|
|
65
|
-
return False
|
|
66
|
-
return completed.returncode == 0 and bool(completed.stdout.strip())
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
def _working_tree_differs_from_head(path: Path) -> bool:
|
|
70
|
-
"""Compare the working tree to HEAD, not to the index.
|
|
71
|
-
|
|
72
|
-
A plain ``git diff`` (no revision argument) compares the working tree to
|
|
73
|
-
the index, so a file that was just ``git add``-ed reads as unchanged even
|
|
74
|
-
when it holds content HEAD never had. Naming ``HEAD`` folds the index and
|
|
75
|
-
the working tree into one comparison against the last commit, catching a
|
|
76
|
-
staged-but-uncommitted new file and a staged edit alike.
|
|
77
|
-
"""
|
|
78
|
-
try:
|
|
79
|
-
completed = subprocess.run(
|
|
80
|
-
_git_diff_quiet_against_head_command(path.name),
|
|
81
|
-
cwd=str(path.parent),
|
|
82
|
-
capture_output=True,
|
|
83
|
-
text=True,
|
|
84
|
-
check=False,
|
|
85
|
-
timeout=GIT_DIFF_TIMEOUT_SECONDS,
|
|
86
|
-
creationflags=hidden_window_creation_flags(),
|
|
87
|
-
)
|
|
88
|
-
except (OSError, subprocess.SubprocessError):
|
|
89
|
-
return True
|
|
90
|
-
return completed.returncode != 0
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
def is_absent_but_tracked(path: Path) -> bool:
|
|
94
|
-
"""Return whether a write target is absent on disk yet tracked in git.
|
|
95
|
-
|
|
96
|
-
::
|
|
97
|
-
|
|
98
|
-
path missing on disk + git ls-files finds it -> True (restore)
|
|
99
|
-
path present, or git does not track it -> False
|
|
100
|
-
|
|
101
|
-
Args:
|
|
102
|
-
path: The write target.
|
|
103
|
-
|
|
104
|
-
Returns:
|
|
105
|
-
True only when the path does not exist but git tracks it, marking the
|
|
106
|
-
write a restore of committed code rather than new production code.
|
|
107
|
-
"""
|
|
108
|
-
if path.exists():
|
|
109
|
-
return False
|
|
110
|
-
return _git_tracks_path(path)
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
def has_uncommitted_change_from_head(path: Path) -> bool:
|
|
114
|
-
"""Return whether *path* carries content its git HEAD does not have.
|
|
115
|
-
|
|
116
|
-
::
|
|
117
|
-
|
|
118
|
-
untracked file -> True (no HEAD entry to match)
|
|
119
|
-
tracked, working tree != HEAD -> True (a real edit this session)
|
|
120
|
-
tracked, working tree == HEAD -> False (clean; a touch cannot pass)
|
|
121
|
-
git unavailable or errors -> True (cannot prove it is clean)
|
|
122
|
-
|
|
123
|
-
Args:
|
|
124
|
-
path: The candidate test file to check.
|
|
125
|
-
|
|
126
|
-
Returns:
|
|
127
|
-
True unless git can positively confirm the file is tracked and its
|
|
128
|
-
working-tree content matches HEAD exactly.
|
|
129
|
-
"""
|
|
130
|
-
if not _git_tracks_path(path):
|
|
131
|
-
return True
|
|
132
|
-
return _working_tree_differs_from_head(path)
|
|
@@ -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
|