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.
Files changed (81) hide show
  1. package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_regression.py +85 -3
  2. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_regression.py +34 -0
  3. package/_shared/pr-loop/scripts/fix_pr_test_proof.py +233 -0
  4. package/_shared/pr-loop/scripts/pr_loop_shared_constants/fix_pr_test_proof_constants.py +47 -0
  5. package/_shared/pr-loop/scripts/test_fix_pr_test_proof.py +186 -0
  6. package/bin/codex-skill-load-block.mjs +93 -0
  7. package/bin/codex-skill-load-block.test.mjs +134 -0
  8. package/bin/install.agents-home.test.mjs +76 -1
  9. package/bin/install.mjs +55 -31
  10. package/bin/install.test.mjs +57 -0
  11. package/bin/install.transaction.test.mjs +29 -7
  12. package/bin/seed-codex-pstack-models.mjs +27 -0
  13. package/bin/seed-codex-pstack-models.test.mjs +72 -0
  14. package/docs/CODE_RULES.md +5 -1
  15. package/hooks/blocking/code_rules_enforcer.py +1 -3
  16. package/hooks/blocking/code_rules_magic_values.py +1 -3
  17. package/hooks/blocking/code_rules_paired_test.py +5 -6
  18. package/hooks/blocking/pii_prevention_blocker_parts/repository_exemption.py +3 -3
  19. package/hooks/blocking/tdd_enforcer_parts/__init__.py +1 -1
  20. package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +1 -46
  21. package/hooks/blocking/test_pre_tool_use_dispatcher.py +2 -40
  22. package/hooks/blocking/tests/test_repository_exemption.py +7 -0
  23. package/hooks/followup_ledger.py +8 -1
  24. package/hooks/git-hooks/test_installed_policy_lint.py +4 -2
  25. package/hooks/hooks.json +15 -0
  26. package/hooks/hooks_constants/followup_ledger_constants.py +4 -0
  27. package/hooks/hooks_constants/messages.py +0 -1
  28. package/hooks/hooks_constants/nested_project_hooks_constants.py +37 -0
  29. package/hooks/hooks_constants/test_messages.py +1 -7
  30. package/hooks/lifecycle/nested_project_hooks.py +395 -0
  31. package/hooks/lifecycle/test_nested_project_hooks.py +251 -0
  32. package/hooks/test_followup_ledger.py +18 -0
  33. package/package.json +2 -1
  34. package/pstack-codex-models.md +37 -0
  35. package/rules/bdd.md +1 -1
  36. package/rules/build-needs-a-user.md +53 -0
  37. package/rules/code-standards.md +1 -1
  38. package/rules/flag-non-breaking-findings.md +7 -0
  39. package/rules/paired-test-coverage.md +4 -2
  40. package/rules/testing.md +5 -1
  41. package/scripts/cde_lint.py +47 -1
  42. package/scripts/policy_lint/adapter_callers.py +124 -0
  43. package/scripts/policy_lint/adapter_pairing.py +4 -2
  44. package/scripts/policy_lint/adapter_support.py +4 -3
  45. package/scripts/policy_lint/adapters.py +15 -0
  46. package/scripts/policy_lint/check_catalog.py +24 -0
  47. package/scripts/policy_lint/config/approved_test_pairs.py +3 -2
  48. package/scripts/policy_lint/config/check_catalog_constants.py +13 -0
  49. package/scripts/policy_lint/config/constants.py +18 -0
  50. package/scripts/policy_lint/engine.py +9 -3
  51. package/scripts/policy_lint/model.py +6 -2
  52. package/scripts/policy_lint/registry.py +6 -0
  53. package/scripts/policy_lint/selection.py +2 -0
  54. package/scripts/repository_checks/tracked_private_terms.py +15 -1
  55. package/scripts/tests/test_cde_lint.py +50 -0
  56. package/scripts/tests/test_policy_lint_rules_archive.py +16 -0
  57. package/scripts/tests/test_policy_lint_rules_callers.py +140 -0
  58. package/scripts/tests/test_policy_lint_rules_check_catalog.py +25 -1
  59. package/scripts/tests/test_policy_lint_rules_pairing.py +31 -2
  60. package/scripts/tests/test_policy_lint_selection.py +35 -0
  61. package/scripts/tests/test_tracked_private_terms.py +35 -0
  62. package/hooks/blocking/tdd_enforcer.py +0 -195
  63. package/hooks/blocking/tdd_enforcer_parts/candidate_paths.py +0 -142
  64. package/hooks/blocking/tdd_enforcer_parts/config/__init__.py +0 -1
  65. package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +0 -49
  66. package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +0 -298
  67. package/hooks/blocking/tdd_enforcer_parts/decisions.py +0 -96
  68. package/hooks/blocking/tdd_enforcer_parts/freshness.py +0 -95
  69. package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +0 -132
  70. package/hooks/blocking/tdd_enforcer_parts/path_classification.py +0 -119
  71. package/hooks/blocking/tdd_enforcer_parts/tests/test_candidate_paths.py +0 -31
  72. package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +0 -256
  73. package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +0 -46
  74. package/hooks/blocking/tdd_enforcer_parts/tests/test_freshness.py +0 -28
  75. package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +0 -111
  76. package/hooks/blocking/tdd_enforcer_parts/tests/test_path_classification.py +0 -36
  77. package/hooks/blocking/test_tdd_enforcer.py +0 -1506
  78. package/hooks/blocking/test_tdd_enforcer_restore.py +0 -108
  79. package/hooks/blocking/test_tdd_enforcer_scratchpad.py +0 -105
  80. package/scripts/codex_compat_watcher.py +0 -502
  81. 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