claude-dev-env 2.30.0 → 2.31.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/.agents/agents/pr-description-writer.md +1 -1
- package/.agents/skills/e-simplify/SKILL.md +20 -8
- package/AGENTS.md +6 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_enforcer_loading.py +37 -0
- package/bin/install.prune.test.mjs +4 -2
- package/docs/CODE_RULES.md +11 -0
- package/hooks/advisory/migration_safety_advisor.py +25 -1
- package/hooks/advisory/refactor_guard.py +85 -18
- package/hooks/advisory/test_migration_safety_advisor.py +89 -5
- package/hooks/advisory/test_refactor_guard.py +87 -0
- package/hooks/advisory/test_refactor_guard_advisory.py +72 -1
- package/hooks/blocking/code_rules_constants_config.py +7 -6
- package/hooks/blocking/code_rules_enforcer.py +59 -36
- package/hooks/blocking/codex_apply_patch.py +136 -21
- package/hooks/blocking/config/codex_apply_patch_constants.py +13 -0
- package/hooks/blocking/conftest.py +70 -0
- package/hooks/blocking/duplicate_rmtree_helper_blocker.py +26 -16
- package/hooks/blocking/hook_prose_detector_consistency.py +30 -20
- package/hooks/blocking/pii_payload_scan.py +61 -0
- package/hooks/blocking/pii_prevention_blocker.py +5 -0
- package/hooks/blocking/pr_description_writer_gate.py +263 -0
- package/hooks/blocking/pre_tool_use_dispatcher.py +1 -1
- package/hooks/blocking/sensitive_file_protector.py +18 -3
- package/hooks/blocking/shell_substitution_blocker.py +62 -8
- package/hooks/blocking/stale_comment_reference_blocker.py +94 -24
- package/hooks/blocking/state_description_blocker.py +39 -26
- package/hooks/blocking/subprocess_budget_completeness.py +17 -43
- package/hooks/blocking/tdd_enforcer.py +57 -14
- package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +11 -1
- package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +180 -0
- package/hooks/blocking/tdd_enforcer_parts/decisions.py +6 -2
- package/hooks/blocking/tdd_enforcer_parts/freshness.py +18 -3
- package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +66 -1
- package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +143 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +12 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +63 -0
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +15 -0
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +81 -2
- package/hooks/blocking/test_code_rules_enforcer_codex_apply_patch.py +35 -0
- package/hooks/blocking/test_code_rules_enforcer_config_path.py +29 -16
- package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +2 -0
- package/hooks/blocking/test_code_rules_enforcer_multi_edit.py +118 -0
- package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +2 -0
- package/hooks/blocking/test_code_rules_enforcer_precheck_forecast.py +2 -0
- package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +29 -28
- package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +2 -0
- package/hooks/blocking/test_code_rules_enforcer_stage.py +2 -0
- package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +2 -0
- package/hooks/blocking/test_code_rules_validation_phase.py +51 -0
- package/hooks/blocking/test_codex_apply_patch.py +43 -0
- package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +68 -0
- package/hooks/blocking/test_hook_prose_detector_consistency.py +41 -0
- package/hooks/blocking/test_pii_payload_scan.py +65 -2
- package/hooks/blocking/test_pii_prevention_blocker.py +80 -10
- package/hooks/blocking/test_pr_description_writer_gate.py +216 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +215 -25
- package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +9 -4
- package/hooks/blocking/test_sensitive_file_protector.py +42 -2
- package/hooks/blocking/test_shell_substitution_blocker.py +69 -0
- package/hooks/blocking/test_stale_comment_reference_blocker.py +71 -0
- package/hooks/blocking/test_state_description_blocker.py +68 -0
- package/hooks/blocking/test_subprocess_budget_completeness.py +67 -0
- package/hooks/blocking/test_tdd_enforcer.py +182 -2
- package/hooks/blocking/test_windows_rmtree_blocker.py +63 -0
- package/hooks/blocking/test_write_existing_file_blocker.py +122 -0
- package/hooks/blocking/windows_rmtree_blocker.py +21 -7
- package/hooks/blocking/write_existing_file_blocker.py +75 -29
- package/hooks/conftest.py +47 -0
- package/hooks/hooks.json +8 -13
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +3 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +4 -1
- package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +3 -0
- package/hooks/hooks_constants/hook_prose_detector_consistency_constants.py +7 -2
- package/hooks/hooks_constants/migration_safety_advisor_constants.py +7 -0
- package/hooks/hooks_constants/multi_edit_reconstruction.py +44 -3
- package/hooks/hooks_constants/pii_prevention_constants.py +9 -7
- package/hooks/hooks_constants/pr_description_writer_gate_constants.py +39 -0
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +31 -14
- package/hooks/hooks_constants/session_edit_stage_gate_constants.py +15 -1
- package/hooks/hooks_constants/shell_substitution_blocker_constants.py +4 -1
- package/hooks/hooks_constants/state_description_blocker_constants.py +1 -0
- package/hooks/hooks_constants/subprocess_budget_completeness_content.py +71 -0
- package/hooks/hooks_constants/task_tool_prompt_constants.py +13 -0
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +2 -0
- package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +2 -9
- package/hooks/hooks_constants/test_multi_edit_reconstruction.py +102 -0
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +139 -25
- package/hooks/hooks_constants/test_subprocess_budget_completeness_content.py +70 -0
- package/hooks/hooks_constants/validation_phase_constants.py +2 -0
- package/hooks/hooks_constants/workflow_substitution_slot_blocker_constants.py +7 -3
- package/hooks/observability/pr_description_writer_spawn_tracker.py +121 -0
- package/hooks/observability/session_file_edit_tracker.py +25 -4
- package/hooks/observability/test_pr_description_writer_spawn_tracker.py +146 -0
- package/hooks/observability/test_session_file_edit_tracker.py +32 -0
- package/hooks/session/task_tool_prompt.py +37 -0
- package/hooks/session/test_task_tool_prompt.py +65 -0
- package/hooks/validation/hook_format_validator.py +23 -9
- package/hooks/validation/test_hook_format_validator.py +58 -0
- package/hooks/validators/README.md +1 -1
- package/hooks/validators/abbreviation_checks.py +3 -5
- package/hooks/validators/config/abbreviation_checks_constants.py +3 -0
- package/hooks/validators/config/fast_save_validator_constants.py +32 -0
- package/hooks/validators/config/hook_timing_harness_constants.py +3 -1
- package/hooks/validators/config/test_abbreviation_checks_constants.py +11 -0
- package/hooks/validators/fast_save_validators.py +332 -0
- package/hooks/validators/hook_timing_harness.py +8 -4
- package/hooks/validators/python_style_checks.py +34 -60
- package/hooks/validators/python_style_helpers.py +16 -2
- package/hooks/validators/python_style_import_bootstrap.py +83 -0
- package/hooks/validators/run_all_validators.py +42 -41
- package/hooks/validators/test_abbreviation_checks.py +20 -0
- package/hooks/validators/test_fast_save_validators.py +172 -0
- package/hooks/validators/test_python_style_checks.py +9 -0
- package/hooks/validators/test_python_style_checks_import_order.py +81 -0
- package/hooks/validators/test_python_style_helpers.py +35 -0
- package/hooks/validators/test_python_style_import_bootstrap.py +69 -0
- package/hooks/validators/test_run_all_validators.py +46 -0
- package/hooks/validators/test_run_all_validators_pretooluse.py +2 -0
- package/package.json +1 -1
- package/rules/docstring-prose-matches-implementation.md +1 -1
- package/rules/git-workflow.md +2 -0
- package/rules/testing.md +4 -0
- package/scripts/codex_compat_materializer.py +5 -0
- package/scripts/tests/test_codex_compat_materializer.py +37 -11
- package/hooks/blocking/tests/conftest.py +0 -10
- package/hooks/hooks_constants/task_list_loop_starter_constants.py +0 -19
- package/hooks/session/task_list_loop_starter.py +0 -45
- package/hooks/session/test_task_list_loop_starter.py +0 -63
|
@@ -42,13 +42,28 @@ def _read_candidate_text(candidate_path: Path) -> str | None:
|
|
|
42
42
|
return None
|
|
43
43
|
|
|
44
44
|
|
|
45
|
+
def has_test_evidence_in(candidate_text: str) -> bool:
|
|
46
|
+
"""Return whether the given text holds a real test function.
|
|
47
|
+
|
|
48
|
+
Takes the text rather than the path, so a caller that has already read the
|
|
49
|
+
file does not read it a second time.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
candidate_text: The candidate test file's full text.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
True when the text matches any test-function pattern.
|
|
56
|
+
"""
|
|
57
|
+
return any(
|
|
58
|
+
each_pattern.search(candidate_text) for each_pattern in _test_function_patterns()
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
45
62
|
def _contains_test_evidence(candidate_path: Path) -> bool:
|
|
46
63
|
test_file_content = _read_candidate_text(candidate_path)
|
|
47
64
|
if test_file_content is None:
|
|
48
65
|
return False
|
|
49
|
-
return
|
|
50
|
-
each_pattern.search(test_file_content) for each_pattern in _test_function_patterns()
|
|
51
|
-
)
|
|
66
|
+
return has_test_evidence_in(test_file_content)
|
|
52
67
|
|
|
53
68
|
|
|
54
69
|
def _candidate_is_fresh_test(
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Ask git about a candidate path: is it a restore, and does it carry a real edit.
|
|
2
2
|
|
|
3
3
|
::
|
|
4
4
|
|
|
@@ -8,13 +8,22 @@
|
|
|
8
8
|
A full rewrite happens as remove-then-Write, so the file is absent on disk
|
|
9
9
|
between the two steps. Restoring committed code is not new production code, so
|
|
10
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.
|
|
11
16
|
"""
|
|
12
17
|
|
|
13
18
|
import subprocess
|
|
14
19
|
from pathlib import Path
|
|
15
20
|
|
|
16
21
|
from tdd_enforcer_parts.config.tdd_enforcer_constants import (
|
|
22
|
+
GIT_DIFF_QUIET_FLAG,
|
|
23
|
+
GIT_DIFF_SUBCOMMAND,
|
|
24
|
+
GIT_DIFF_TIMEOUT_SECONDS,
|
|
17
25
|
GIT_EXECUTABLE_NAME,
|
|
26
|
+
GIT_HEAD_REVISION,
|
|
18
27
|
GIT_LS_FILES_SUBCOMMAND,
|
|
19
28
|
GIT_LS_FILES_TIMEOUT_SECONDS,
|
|
20
29
|
GIT_PATHSPEC_SEPARATOR,
|
|
@@ -25,6 +34,17 @@ def _git_ls_files_command(file_name: str) -> list[str]:
|
|
|
25
34
|
return [GIT_EXECUTABLE_NAME, GIT_LS_FILES_SUBCOMMAND, GIT_PATHSPEC_SEPARATOR, file_name]
|
|
26
35
|
|
|
27
36
|
|
|
37
|
+
def _git_diff_quiet_against_head_command(file_name: str) -> list[str]:
|
|
38
|
+
return [
|
|
39
|
+
GIT_EXECUTABLE_NAME,
|
|
40
|
+
GIT_DIFF_SUBCOMMAND,
|
|
41
|
+
GIT_DIFF_QUIET_FLAG,
|
|
42
|
+
GIT_HEAD_REVISION,
|
|
43
|
+
GIT_PATHSPEC_SEPARATOR,
|
|
44
|
+
file_name,
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
|
|
28
48
|
def _git_tracks_path(path: Path) -> bool:
|
|
29
49
|
parent_directory = path.parent
|
|
30
50
|
if not parent_directory.is_dir():
|
|
@@ -43,6 +63,29 @@ def _git_tracks_path(path: Path) -> bool:
|
|
|
43
63
|
return completed.returncode == 0 and bool(completed.stdout.strip())
|
|
44
64
|
|
|
45
65
|
|
|
66
|
+
def _working_tree_differs_from_head(path: Path) -> bool:
|
|
67
|
+
"""Compare the working tree to HEAD, not to the index.
|
|
68
|
+
|
|
69
|
+
A plain ``git diff`` (no revision argument) compares the working tree to
|
|
70
|
+
the index, so a file that was just ``git add``-ed reads as unchanged even
|
|
71
|
+
when it holds content HEAD never had. Naming ``HEAD`` folds the index and
|
|
72
|
+
the working tree into one comparison against the last commit, catching a
|
|
73
|
+
staged-but-uncommitted new file and a staged edit alike.
|
|
74
|
+
"""
|
|
75
|
+
try:
|
|
76
|
+
completed = subprocess.run(
|
|
77
|
+
_git_diff_quiet_against_head_command(path.name),
|
|
78
|
+
cwd=str(path.parent),
|
|
79
|
+
capture_output=True,
|
|
80
|
+
text=True,
|
|
81
|
+
check=False,
|
|
82
|
+
timeout=GIT_DIFF_TIMEOUT_SECONDS,
|
|
83
|
+
)
|
|
84
|
+
except (OSError, subprocess.SubprocessError):
|
|
85
|
+
return True
|
|
86
|
+
return completed.returncode != 0
|
|
87
|
+
|
|
88
|
+
|
|
46
89
|
def is_absent_but_tracked(path: Path) -> bool:
|
|
47
90
|
"""Return whether a write target is absent on disk yet tracked in git.
|
|
48
91
|
|
|
@@ -61,3 +104,25 @@ def is_absent_but_tracked(path: Path) -> bool:
|
|
|
61
104
|
if path.exists():
|
|
62
105
|
return False
|
|
63
106
|
return _git_tracks_path(path)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def has_uncommitted_change_from_head(path: Path) -> bool:
|
|
110
|
+
"""Return whether *path* carries content its git HEAD does not have.
|
|
111
|
+
|
|
112
|
+
::
|
|
113
|
+
|
|
114
|
+
untracked file -> True (no HEAD entry to match)
|
|
115
|
+
tracked, working tree != HEAD -> True (a real edit this session)
|
|
116
|
+
tracked, working tree == HEAD -> False (clean; a touch cannot pass)
|
|
117
|
+
git unavailable or errors -> True (cannot prove it is clean)
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
path: The candidate test file to check.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
True unless git can positively confirm the file is tracked and its
|
|
124
|
+
working-tree content matches HEAD exactly.
|
|
125
|
+
"""
|
|
126
|
+
if not _git_tracks_path(path):
|
|
127
|
+
return True
|
|
128
|
+
return _working_tree_differs_from_head(path)
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Behavioral tests for the content_hash_store parts module."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from tdd_enforcer_parts import content_hash_store
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _write_fresh_test_file(directory: Path, content: str = "def test_fulfill(): pass\n") -> Path:
|
|
12
|
+
candidate = directory / "test_orders.py"
|
|
13
|
+
candidate.write_text(content)
|
|
14
|
+
return candidate
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_state_file_path_differs_by_repository_root(tmp_path: Path) -> None:
|
|
18
|
+
first_root_path = content_hash_store._state_file_path("session-a", str(tmp_path / "one"))
|
|
19
|
+
second_root_path = content_hash_store._state_file_path("session-a", str(tmp_path / "two"))
|
|
20
|
+
assert first_root_path != second_root_path
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_state_file_path_is_stable_for_the_same_inputs(tmp_path: Path) -> None:
|
|
24
|
+
first_lookup = content_hash_store._state_file_path("session-a", str(tmp_path))
|
|
25
|
+
second_lookup = content_hash_store._state_file_path("session-a", str(tmp_path))
|
|
26
|
+
assert first_lookup == second_lookup
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_first_sight_allows_and_stores_a_fresh_dirty_test(tmp_path: Path) -> None:
|
|
30
|
+
candidate = _write_fresh_test_file(tmp_path)
|
|
31
|
+
|
|
32
|
+
is_allowed = content_hash_store.has_recorded_or_fresh_test(
|
|
33
|
+
[candidate], "session-1", str(tmp_path), 600
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
assert is_allowed is True
|
|
37
|
+
state_file = content_hash_store._state_file_path("session-1", str(tmp_path))
|
|
38
|
+
state = json.loads(state_file.read_text())
|
|
39
|
+
assert content_hash_store._state_key_for(candidate) in state
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_first_sight_denies_when_test_evidence_is_missing(tmp_path: Path) -> None:
|
|
43
|
+
candidate = _write_fresh_test_file(tmp_path, content="orders = 'not a test'\n")
|
|
44
|
+
|
|
45
|
+
is_allowed = content_hash_store.has_recorded_or_fresh_test(
|
|
46
|
+
[candidate], "session-2", str(tmp_path), 600
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
assert is_allowed is False
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_first_sight_denies_when_test_file_is_stale(tmp_path: Path) -> None:
|
|
53
|
+
candidate = _write_fresh_test_file(tmp_path)
|
|
54
|
+
stale_timestamp = time.time() - 700
|
|
55
|
+
os.utime(candidate, (stale_timestamp, stale_timestamp))
|
|
56
|
+
|
|
57
|
+
is_allowed = content_hash_store.has_recorded_or_fresh_test(
|
|
58
|
+
[candidate], "session-3", str(tmp_path), 600
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
assert is_allowed is False
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_second_sight_denies_a_touch_that_leaves_content_unchanged(tmp_path: Path) -> None:
|
|
65
|
+
candidate = _write_fresh_test_file(tmp_path)
|
|
66
|
+
session_id, repository_root = "session-4", str(tmp_path)
|
|
67
|
+
assert content_hash_store.has_recorded_or_fresh_test(
|
|
68
|
+
[candidate], session_id, repository_root, 600
|
|
69
|
+
)
|
|
70
|
+
state_file = content_hash_store._state_file_path(session_id, repository_root)
|
|
71
|
+
state = json.loads(state_file.read_text())
|
|
72
|
+
candidate_key = content_hash_store._state_key_for(candidate)
|
|
73
|
+
state[candidate_key][content_hash_store.STORED_CHANGED_AT_KEY] -= 700
|
|
74
|
+
state_file.write_text(json.dumps(state))
|
|
75
|
+
candidate.touch()
|
|
76
|
+
|
|
77
|
+
is_allowed = content_hash_store.has_recorded_or_fresh_test(
|
|
78
|
+
[candidate], session_id, repository_root, 600
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
assert is_allowed is False
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_second_sight_allows_when_content_actually_changed_after_the_window(
|
|
85
|
+
tmp_path: Path,
|
|
86
|
+
) -> None:
|
|
87
|
+
candidate = _write_fresh_test_file(tmp_path)
|
|
88
|
+
session_id, repository_root = "session-5", str(tmp_path)
|
|
89
|
+
assert content_hash_store.has_recorded_or_fresh_test(
|
|
90
|
+
[candidate], session_id, repository_root, 600
|
|
91
|
+
)
|
|
92
|
+
state_file = content_hash_store._state_file_path(session_id, repository_root)
|
|
93
|
+
state = json.loads(state_file.read_text())
|
|
94
|
+
candidate_key = content_hash_store._state_key_for(candidate)
|
|
95
|
+
state[candidate_key][content_hash_store.STORED_CHANGED_AT_KEY] -= 700
|
|
96
|
+
state_file.write_text(json.dumps(state))
|
|
97
|
+
candidate.write_text("def test_fulfill(): assert True\n")
|
|
98
|
+
|
|
99
|
+
is_allowed = content_hash_store.has_recorded_or_fresh_test(
|
|
100
|
+
[candidate], session_id, repository_root, 600
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
assert is_allowed is True
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_second_sight_allows_a_repeat_write_within_the_window_untouched(
|
|
107
|
+
tmp_path: Path,
|
|
108
|
+
) -> None:
|
|
109
|
+
candidate = _write_fresh_test_file(tmp_path)
|
|
110
|
+
session_id, repository_root = "session-6", str(tmp_path)
|
|
111
|
+
assert content_hash_store.has_recorded_or_fresh_test(
|
|
112
|
+
[candidate], session_id, repository_root, 600
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
is_allowed = content_hash_store.has_recorded_or_fresh_test(
|
|
116
|
+
[candidate], session_id, repository_root, 600
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
assert is_allowed is True
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_any_candidate_satisfying_the_gate_allows_the_write(tmp_path: Path) -> None:
|
|
123
|
+
stale_candidate = _write_fresh_test_file(tmp_path)
|
|
124
|
+
stale_timestamp = time.time() - 700
|
|
125
|
+
os.utime(stale_candidate, (stale_timestamp, stale_timestamp))
|
|
126
|
+
fresh_candidate = tmp_path / "orders_test.py"
|
|
127
|
+
fresh_candidate.write_text("def test_fulfill(): pass\n")
|
|
128
|
+
|
|
129
|
+
is_allowed = content_hash_store.has_recorded_or_fresh_test(
|
|
130
|
+
[stale_candidate, fresh_candidate], "session-7", str(tmp_path), 600
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
assert is_allowed is True
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_denies_when_no_candidate_file_exists(tmp_path: Path) -> None:
|
|
137
|
+
missing_candidate = tmp_path / "test_missing.py"
|
|
138
|
+
|
|
139
|
+
is_allowed = content_hash_store.has_recorded_or_fresh_test(
|
|
140
|
+
[missing_candidate], "session-8", str(tmp_path), 600
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
assert is_allowed is False
|
|
@@ -20,6 +20,18 @@ def test_build_deny_reason_points_exemption_guidance_at_entry_hook() -> None:
|
|
|
20
20
|
assert "decisions.py" not in reason
|
|
21
21
|
|
|
22
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
|
+
|
|
23
35
|
def test_emit_allow_writes_allow_decision(capsys: pytest.CaptureFixture[str]) -> None:
|
|
24
36
|
decisions.emit_allow()
|
|
25
37
|
parsed = json.loads(capsys.readouterr().out)
|
|
@@ -46,3 +46,66 @@ def test_absent_but_tracked_false_when_file_present(tmp_path: Path) -> None:
|
|
|
46
46
|
_git(tmp_path, "add", "-A")
|
|
47
47
|
_git(tmp_path, "commit", "-q", "-m", "init")
|
|
48
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
|
|
@@ -222,6 +222,7 @@ def test_powershell_selects_the_shared_hooks_in_registration_order() -> None:
|
|
|
222
222
|
"blocking/cursor_cli_python_misfire_blocker.py",
|
|
223
223
|
"blocking/unscoped_search_blocker.py",
|
|
224
224
|
"blocking/pii_prevention_blocker.py",
|
|
225
|
+
"blocking/pr_description_writer_gate.py",
|
|
225
226
|
]
|
|
226
227
|
|
|
227
228
|
|
|
@@ -261,6 +262,20 @@ def test_emit_decision_re_emits_silent_deny_fields(
|
|
|
261
262
|
assert emitted_payload["suppressOutput"] is True
|
|
262
263
|
|
|
263
264
|
|
|
265
|
+
def test_dispatcher_denies_subshell_disguised_as_arithmetic_expansion() -> None:
|
|
266
|
+
"""The dispatcher denies a `$((cd X) && Y)` subshell, not only the bare hook.
|
|
267
|
+
|
|
268
|
+
`echo $((cd /tmp) && pwd)` opens with a `$((` two-character lookahead
|
|
269
|
+
match, so a function-level test alone does not prove the block reaches
|
|
270
|
+
the live Bash tool path.
|
|
271
|
+
"""
|
|
272
|
+
payload_text = _bash_payload("echo $((cd /tmp) && pwd)")
|
|
273
|
+
decision, _reason = _decision_from_stdout(
|
|
274
|
+
_run_process(_DISPATCHER_SCRIPT, payload_text).stdout
|
|
275
|
+
)
|
|
276
|
+
assert decision == "deny"
|
|
277
|
+
|
|
278
|
+
|
|
264
279
|
def test_dispatch_emits_deny_immediately_and_skips_later_hooks(
|
|
265
280
|
monkeypatch: pytest.MonkeyPatch,
|
|
266
281
|
capsys: pytest.CaptureFixture[str],
|
|
@@ -24,6 +24,8 @@ consideration will trip this test.
|
|
|
24
24
|
|
|
25
25
|
from __future__ import annotations
|
|
26
26
|
|
|
27
|
+
import ast
|
|
28
|
+
import functools
|
|
27
29
|
import importlib.util
|
|
28
30
|
import inspect
|
|
29
31
|
import pathlib
|
|
@@ -31,12 +33,13 @@ import sys
|
|
|
31
33
|
|
|
32
34
|
|
|
33
35
|
_HOOK_DIRECTORY = pathlib.Path(__file__).parent
|
|
36
|
+
_ENFORCER_SOURCE_PATH = _HOOK_DIRECTORY / "code_rules_enforcer.py"
|
|
34
37
|
if str(_HOOK_DIRECTORY) not in sys.path:
|
|
35
38
|
sys.path.insert(0, str(_HOOK_DIRECTORY))
|
|
36
39
|
|
|
37
40
|
_hook_spec = importlib.util.spec_from_file_location(
|
|
38
41
|
"code_rules_enforcer",
|
|
39
|
-
|
|
42
|
+
_ENFORCER_SOURCE_PATH,
|
|
40
43
|
)
|
|
41
44
|
assert _hook_spec is not None
|
|
42
45
|
assert _hook_spec.loader is not None
|
|
@@ -45,11 +48,14 @@ _hook_spec.loader.exec_module(_hook_module)
|
|
|
45
48
|
|
|
46
49
|
VOID_ADVISORY_CHECK_FUNCTION_NAMES: frozenset[str] = frozenset(
|
|
47
50
|
{
|
|
51
|
+
"check_constants_outside_config_advisory",
|
|
48
52
|
"check_duplicated_format_patterns",
|
|
49
53
|
"check_flag_gated_scenario_test_naming",
|
|
50
54
|
}
|
|
51
55
|
)
|
|
52
56
|
|
|
57
|
+
PAYLOAD_COLLECTING_METHOD_NAMES: frozenset[str] = frozenset({"extend", "append"})
|
|
58
|
+
|
|
53
59
|
DIFF_SCOPED_CHECK_FUNCTION_NAMES: frozenset[str] = frozenset(
|
|
54
60
|
{
|
|
55
61
|
"check_banned_noun_word_boundary",
|
|
@@ -65,7 +71,6 @@ KNOWN_UNCAPPED_CHECKS_PENDING_REVIEW: frozenset[str] = frozenset(
|
|
|
65
71
|
"check_comment_changes",
|
|
66
72
|
"check_constant_equality_tests",
|
|
67
73
|
"check_constants_outside_config",
|
|
68
|
-
"check_constants_outside_config_advisory",
|
|
69
74
|
"check_existence_check_tests",
|
|
70
75
|
"check_file_global_constants_use_count",
|
|
71
76
|
"check_imports_at_top",
|
|
@@ -174,6 +179,80 @@ def test_void_advisory_checks_are_registered_and_disjoint() -> None:
|
|
|
174
179
|
)
|
|
175
180
|
|
|
176
181
|
|
|
182
|
+
@functools.lru_cache(maxsize=1)
|
|
183
|
+
def _enforcer_syntax_tree() -> ast.Module:
|
|
184
|
+
"""Parse code_rules_enforcer.py once and hand the same tree to every caller.
|
|
185
|
+
|
|
186
|
+
The source does not change while the suite runs, so re-reading and
|
|
187
|
+
re-parsing it for each test buys nothing.
|
|
188
|
+
"""
|
|
189
|
+
return ast.parse(_ENFORCER_SOURCE_PATH.read_text(encoding="utf-8"))
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _void_advisory_names_called_under(node: ast.AST) -> set[str]:
|
|
193
|
+
return {
|
|
194
|
+
each_node.func.id
|
|
195
|
+
for each_node in ast.walk(node)
|
|
196
|
+
if isinstance(each_node, ast.Call)
|
|
197
|
+
and isinstance(each_node.func, ast.Name)
|
|
198
|
+
and each_node.func.id in VOID_ADVISORY_CHECK_FUNCTION_NAMES
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _payload_collecting_calls(syntax_tree: ast.Module) -> list[ast.Call]:
|
|
203
|
+
return [
|
|
204
|
+
each_node
|
|
205
|
+
for each_node in ast.walk(syntax_tree)
|
|
206
|
+
if isinstance(each_node, ast.Call)
|
|
207
|
+
and isinstance(each_node.func, ast.Attribute)
|
|
208
|
+
and each_node.func.attr in PAYLOAD_COLLECTING_METHOD_NAMES
|
|
209
|
+
]
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _void_advisory_names_fed_into_the_deny_payload(syntax_tree: ast.Module) -> set[str]:
|
|
213
|
+
"""Return void-advisory names whose result is collected into the blocking payload.
|
|
214
|
+
|
|
215
|
+
The enforcer builds its deny payload by extending a list::
|
|
216
|
+
|
|
217
|
+
ok: check_duplicated_format_patterns(content, file_path)
|
|
218
|
+
flag: all_issues.extend(check_duplicated_format_patterns(content, file_path))
|
|
219
|
+
|
|
220
|
+
The flagged form routes a stderr-only advisory into the payload that denies
|
|
221
|
+
the write, so the advisory stops being advisory.
|
|
222
|
+
"""
|
|
223
|
+
fed_names: set[str] = set()
|
|
224
|
+
for each_call in _payload_collecting_calls(syntax_tree):
|
|
225
|
+
for each_argument in each_call.args:
|
|
226
|
+
fed_names |= _void_advisory_names_called_under(each_argument)
|
|
227
|
+
return fed_names
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _bare_call_names(syntax_tree: ast.Module) -> set[str]:
|
|
231
|
+
return {
|
|
232
|
+
each_node.value.func.id
|
|
233
|
+
for each_node in ast.walk(syntax_tree)
|
|
234
|
+
if isinstance(each_node, ast.Expr)
|
|
235
|
+
and isinstance(each_node.value, ast.Call)
|
|
236
|
+
and isinstance(each_node.value.func, ast.Name)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def test_void_advisory_checks_never_feed_the_deny_payload() -> None:
|
|
241
|
+
fed_names = _void_advisory_names_fed_into_the_deny_payload(_enforcer_syntax_tree())
|
|
242
|
+
assert fed_names == set(), (
|
|
243
|
+
f"Void-advisory checks must run for their stderr side effect alone, never collected "
|
|
244
|
+
f"into the blocking payload. Fed into extend/append: {sorted(fed_names)}"
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def test_void_advisory_checks_are_each_called_by_the_enforcer() -> None:
|
|
249
|
+
uncalled_names = VOID_ADVISORY_CHECK_FUNCTION_NAMES - _bare_call_names(_enforcer_syntax_tree())
|
|
250
|
+
assert uncalled_names == set(), (
|
|
251
|
+
f"Every void-advisory check must appear as a bare call in code_rules_enforcer.py so "
|
|
252
|
+
f"its findings reach stderr. Never called: {sorted(uncalled_names)}"
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
|
|
177
256
|
def test_diff_scoped_checks_are_registered_and_disjoint() -> None:
|
|
178
257
|
all_check_names = set(_all_check_function_names())
|
|
179
258
|
assert DIFF_SCOPED_CHECK_FUNCTION_NAMES <= all_check_names, (
|
|
@@ -20,6 +20,8 @@ if str(_HOOKS_PARENT) not in sys.path:
|
|
|
20
20
|
import code_rules_enforcer
|
|
21
21
|
import codex_apply_patch
|
|
22
22
|
|
|
23
|
+
pytestmark = pytest.mark.usefixtures("ephemeral_exempt_off")
|
|
24
|
+
|
|
23
25
|
|
|
24
26
|
def _run_codex_payload(
|
|
25
27
|
payload: dict[str, object],
|
|
@@ -146,6 +148,39 @@ def test_codex_payload_blocks_malformed_patch(
|
|
|
146
148
|
deny_payload = json.loads(stdout)
|
|
147
149
|
assert deny_payload["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
148
150
|
assert "payload requires accepted patch markers" in deny_payload["hookSpecificOutput"]["permissionDecisionReason"]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_codex_patch_operation_targets_names_every_section_without_reading_content(
|
|
154
|
+
tmp_path: Path, three_operation_patch: str
|
|
155
|
+
) -> None:
|
|
156
|
+
"""The path-only helper resolves every section's target without any disk read.
|
|
157
|
+
|
|
158
|
+
Unlike ``parse_codex_apply_patch``, it never reads prior file content, so a
|
|
159
|
+
caller that only needs each target path is not tripped up by an unreadable
|
|
160
|
+
prior file.
|
|
161
|
+
"""
|
|
162
|
+
all_targets = codex_apply_patch.codex_patch_operation_targets(
|
|
163
|
+
three_operation_patch, str(tmp_path)
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
all_target_names = {
|
|
167
|
+
(each_operation, Path(each_path).name) for each_operation, each_path in all_targets
|
|
168
|
+
}
|
|
169
|
+
assert all_target_names == {
|
|
170
|
+
("update", "updated.py"),
|
|
171
|
+
(codex_apply_patch.CODEX_ADD_OPERATION, "added.py"),
|
|
172
|
+
("delete", "deleted.py"),
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def test_codex_patch_operation_targets_rejects_path_traversal(tmp_path: Path) -> None:
|
|
177
|
+
"""A patch path that escapes the working directory is rejected before any read."""
|
|
178
|
+
escaping_patch = "*** Begin Patch\n*** Add File: ../outside.py\n+content\n*** End Patch"
|
|
179
|
+
|
|
180
|
+
with pytest.raises(codex_apply_patch.CodexPatchError):
|
|
181
|
+
codex_apply_patch.codex_patch_operation_targets(escaping_patch, str(tmp_path))
|
|
182
|
+
|
|
183
|
+
|
|
149
184
|
def test_codex_payload_blocks_a_nul_containing_patch_path(
|
|
150
185
|
tmp_path: Path,
|
|
151
186
|
monkeypatch: pytest.MonkeyPatch,
|
|
@@ -4,6 +4,7 @@ Covers:
|
|
|
4
4
|
- is_config_file: must use directory-segment matching, not filename-stem matching
|
|
5
5
|
- check_constants_outside_config: advisory (not blocking) for function-body UPPER_SNAKE
|
|
6
6
|
- check_constants_outside_config: stable sort order by line number
|
|
7
|
+
- validate_content_for_edit_lane: function-local advisories stay out of the deny payload
|
|
7
8
|
"""
|
|
8
9
|
|
|
9
10
|
from __future__ import annotations
|
|
@@ -19,19 +20,22 @@ if _BLOCKING_DIRECTORY not in sys.path:
|
|
|
19
20
|
if _HOOKS_DIRECTORY not in sys.path:
|
|
20
21
|
sys.path.insert(0, _HOOKS_DIRECTORY)
|
|
21
22
|
|
|
22
|
-
from code_rules_constants_config import
|
|
23
|
-
|
|
24
|
-
check_constants_outside_config_advisory,
|
|
25
|
-
)
|
|
23
|
+
from code_rules_constants_config import check_constants_outside_config # noqa: E402
|
|
24
|
+
from code_rules_enforcer import validate_content_for_edit_lane # noqa: E402
|
|
26
25
|
from code_rules_path_utils import is_config_file # noqa: E402
|
|
27
26
|
|
|
28
27
|
code_rules_enforcer = SimpleNamespace(
|
|
29
28
|
check_constants_outside_config=check_constants_outside_config,
|
|
30
|
-
check_constants_outside_config_advisory=check_constants_outside_config_advisory,
|
|
31
29
|
is_config_file=is_config_file,
|
|
32
30
|
)
|
|
33
31
|
|
|
34
32
|
PRODUCTION_FILE_PATH = "packages/claude-dev-env/src/example.py"
|
|
33
|
+
FUNCTION_LOCAL_UPPER_SNAKE_SOURCE = (
|
|
34
|
+
"def fetch_data():\n"
|
|
35
|
+
" MAX_RETRIES = 3\n"
|
|
36
|
+
" for attempt in range(MAX_RETRIES):\n"
|
|
37
|
+
" pass\n"
|
|
38
|
+
)
|
|
35
39
|
|
|
36
40
|
|
|
37
41
|
def test_should_return_false_for_filename_named_config_dot_py() -> None:
|
|
@@ -63,22 +67,22 @@ def test_should_return_true_for_config_dir_backslash() -> None:
|
|
|
63
67
|
|
|
64
68
|
|
|
65
69
|
def test_should_produce_advisory_not_blocking_for_function_local_upper_snake() -> None:
|
|
66
|
-
source = (
|
|
67
|
-
"def fetch_data():\n"
|
|
68
|
-
" MAX_RETRIES = 3\n"
|
|
69
|
-
" for attempt in range(MAX_RETRIES):\n"
|
|
70
|
-
" pass\n"
|
|
71
|
-
)
|
|
72
|
-
advisory_issues = code_rules_enforcer.check_constants_outside_config_advisory(
|
|
73
|
-
source, PRODUCTION_FILE_PATH
|
|
74
|
-
)
|
|
75
70
|
blocking_issues = code_rules_enforcer.check_constants_outside_config(
|
|
76
|
-
|
|
71
|
+
FUNCTION_LOCAL_UPPER_SNAKE_SOURCE, PRODUCTION_FILE_PATH
|
|
77
72
|
)
|
|
78
|
-
assert any("MAX_RETRIES" in issue for issue in advisory_issues)
|
|
79
73
|
assert not any("MAX_RETRIES" in issue for issue in blocking_issues)
|
|
80
74
|
|
|
81
75
|
|
|
76
|
+
def test_function_local_upper_snake_advisory_never_reaches_the_deny_payload() -> None:
|
|
77
|
+
edit_lane_issues = validate_content_for_edit_lane(
|
|
78
|
+
FUNCTION_LOCAL_UPPER_SNAKE_SOURCE, PRODUCTION_FILE_PATH
|
|
79
|
+
)
|
|
80
|
+
assert not any("MAX_RETRIES" in issue for issue in edit_lane_issues), (
|
|
81
|
+
f"Expected function-local MAX_RETRIES to stay out of the deny payload, "
|
|
82
|
+
f"got: {edit_lane_issues}"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
82
86
|
def test_should_produce_blocking_for_module_level_upper_snake_outside_config() -> None:
|
|
83
87
|
source = "MAX_RETRIES = 3\n\ndef fetch_data():\n pass\n"
|
|
84
88
|
blocking_issues = code_rules_enforcer.check_constants_outside_config(
|
|
@@ -87,6 +91,15 @@ def test_should_produce_blocking_for_module_level_upper_snake_outside_config() -
|
|
|
87
91
|
assert any("MAX_RETRIES" in issue for issue in blocking_issues)
|
|
88
92
|
|
|
89
93
|
|
|
94
|
+
def test_module_level_upper_snake_still_reaches_the_deny_payload() -> None:
|
|
95
|
+
source = "MAXIMUM_RETRIES = 3\n\ndef fetch_data() -> int:\n return MAXIMUM_RETRIES\n"
|
|
96
|
+
edit_lane_issues = validate_content_for_edit_lane(source, PRODUCTION_FILE_PATH)
|
|
97
|
+
assert any(
|
|
98
|
+
"MAXIMUM_RETRIES" in issue and "move to config/" in issue
|
|
99
|
+
for issue in edit_lane_issues
|
|
100
|
+
), f"Expected module-level MAXIMUM_RETRIES to keep denying the write, got: {edit_lane_issues}"
|
|
101
|
+
|
|
102
|
+
|
|
90
103
|
def test_should_produce_stable_ordering_sorted_by_line_number() -> None:
|
|
91
104
|
source = (
|
|
92
105
|
"ALPHA_CONSTANT = 1\n"
|