claude-dev-env 2.29.0 → 2.30.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/.agents/agents/pr-description-writer.md +21 -6
- package/.agents/agents/reference/README.md +6 -0
- package/.agents/agents/reference/pr-description-illustrative-voice.md +45 -0
- package/.agents/agents/reference/pr-description-verification.md +19 -0
- package/.agents/agents/test_pr_description_writer_style.py +47 -0
- package/.agents/skills/e-simplify/SKILL.md +20 -8
- package/_shared/pr-loop/code-rules-gate.md +1 -1
- package/_shared/pr-loop/scripts/code_rules_gate.py +2 -2
- package/_shared/pr-loop/scripts/code_rules_gate_parts/enforcer_loading.py +8 -8
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_enforcer_loading.py +2 -2
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_running.py +1 -1
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +15 -15
- package/docs/CODE_RULES.md +10 -0
- package/hooks/blocking/code_rules_enforcer.py +435 -318
- package/hooks/blocking/shell_substitution_blocker.py +106 -30
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +14 -0
- package/hooks/blocking/test_code_rules_enforcer_banned_noun_word.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_class_docstring_methods.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_dispatch_wiring.py +117 -22
- package/hooks/blocking/test_code_rules_enforcer_docstring_args_signature.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_docstring_prose_wall_illustration.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_docstring_runon_sentence.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_docstring_unreferenced_param.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_fstring_scan.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_ignored_must_check_return.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_inline_tuple_string_magic.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_join_separator_magic.py +2 -2
- package/hooks/blocking/test_code_rules_enforcer_js_returns_object.py +3 -3
- package/hooks/blocking/test_code_rules_enforcer_module_docstring_roster.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_naming_pattern.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +117 -38
- package/hooks/blocking/test_code_rules_enforcer_output_snapshot.py +121 -0
- package/hooks/blocking/test_code_rules_enforcer_precheck_forecast.py +17 -15
- package/hooks/blocking/test_code_rules_enforcer_split_entry_1.py +4 -2
- package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_stage.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_string_magic.py +4 -4
- package/hooks/blocking/test_code_rules_enforcer_thin_wrapper_files.py +2 -2
- package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias.py +2 -2
- package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +2 -2
- package/hooks/blocking/test_code_rules_validation_phase.py +332 -0
- package/hooks/blocking/test_fable_spawn_gate.py +1 -1
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +26 -11
- package/hooks/blocking/test_shell_substitution_blocker.py +69 -0
- package/hooks/hooks.json +1 -1
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +7 -0
- package/hooks/hooks_constants/shell_substitution_blocker_constants.py +4 -1
- package/hooks/hooks_constants/task_tool_prompt_constants.py +13 -0
- package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +12 -0
- package/hooks/hooks_constants/validation_phase_constants.py +34 -0
- package/hooks/session/task_tool_prompt.py +37 -0
- package/hooks/session/test_task_tool_prompt.py +65 -0
- package/hooks/validators/README.md +1 -1
- package/hooks/validators/config/hook_timing_harness_constants.py +16 -0
- package/hooks/validators/hook_timing_harness.py +287 -0
- package/hooks/validators/test_hook_timing_harness.py +251 -0
- package/package.json +1 -1
- 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
|
@@ -20,7 +20,7 @@ if _HOOKS_DIRECTORY not in sys.path:
|
|
|
20
20
|
|
|
21
21
|
from code_rules_enforcer import ( # noqa: E402
|
|
22
22
|
main,
|
|
23
|
-
validate_content,
|
|
23
|
+
validate_content_for_full_gate as validate_content,
|
|
24
24
|
)
|
|
25
25
|
|
|
26
26
|
|
|
@@ -165,47 +165,126 @@ def _validate_narrow_edit(narrow_edit_fixture: NarrowEditFixture) -> list[str]:
|
|
|
165
165
|
)
|
|
166
166
|
|
|
167
167
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
168
|
+
_SCOPE_CARRYING_ATTRIBUTE_NAMES = frozenset({"all_changed_lines", "defer_scope_to_caller"})
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _assignment_target_names(target: ast.expr) -> list[str]:
|
|
172
|
+
"""Return the plain names one assignment target binds, Name or Tuple-of-Name."""
|
|
173
|
+
if isinstance(target, ast.Name):
|
|
174
|
+
return [target.id]
|
|
175
|
+
if isinstance(target, ast.Tuple):
|
|
176
|
+
return [each.id for each in target.elts if isinstance(each, ast.Name)]
|
|
177
|
+
return []
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _scope_aliases_for_pair(target_names: list[str], all_values: list[ast.expr]) -> dict[str, str]:
|
|
181
|
+
"""Return the local-name-to-scope-attribute map one assignment pair contributes."""
|
|
182
|
+
aliases: dict[str, str] = {}
|
|
183
|
+
for each_name, each_value in zip(target_names, all_values):
|
|
184
|
+
if not isinstance(each_value, ast.Attribute):
|
|
173
185
|
continue
|
|
174
|
-
if not
|
|
186
|
+
if each_value.attr not in _SCOPE_CARRYING_ATTRIBUTE_NAMES:
|
|
175
187
|
continue
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
188
|
+
aliases[each_name] = each_value.attr
|
|
189
|
+
return aliases
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _context_alias_map(function_node: ast.AST) -> dict[str, str]:
|
|
193
|
+
"""Map each local name one function binds from ``context.<scope attribute>``.
|
|
194
|
+
|
|
195
|
+
A dispatch helper unpacks ``context.all_changed_lines`` and
|
|
196
|
+
``context.defer_scope_to_caller`` into a short local name (``changed``,
|
|
197
|
+
``defer``) before passing it on, so a call carrying that local name still
|
|
198
|
+
carries scope, even though the local name spells out none of it.
|
|
199
|
+
"""
|
|
200
|
+
aliases: dict[str, str] = {}
|
|
201
|
+
for each_node in ast.walk(function_node):
|
|
202
|
+
if not isinstance(each_node, ast.Assign) or len(each_node.targets) != 1:
|
|
189
203
|
continue
|
|
190
|
-
|
|
204
|
+
target_names = _assignment_target_names(each_node.targets[0])
|
|
205
|
+
if not target_names:
|
|
191
206
|
continue
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
207
|
+
value = each_node.value
|
|
208
|
+
all_values = value.elts if isinstance(value, ast.Tuple) else [value]
|
|
209
|
+
aliases.update(_scope_aliases_for_pair(target_names, all_values))
|
|
210
|
+
return aliases
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _scope_names_for_reference(reference: ast.AST, aliases: dict[str, str]) -> set[str]:
|
|
214
|
+
"""Return the scope-carrying attribute name one AST reference resolves to, if any."""
|
|
215
|
+
if isinstance(reference, ast.Name) and reference.id in aliases:
|
|
216
|
+
return {aliases[reference.id]}
|
|
217
|
+
if isinstance(reference, ast.Attribute) and reference.attr in _SCOPE_CARRYING_ATTRIBUTE_NAMES:
|
|
218
|
+
return {reference.attr}
|
|
219
|
+
return set()
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _referenced_scope_attribute_names(node: ast.AST, aliases: dict[str, str]) -> set[str]:
|
|
223
|
+
"""Return every scope-carrying attribute name *node* references, direct or aliased."""
|
|
224
|
+
found: set[str] = set()
|
|
225
|
+
for each_reference in ast.walk(node):
|
|
226
|
+
found.update(_scope_names_for_reference(each_reference, aliases))
|
|
227
|
+
return found
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _fragment_deferred_rule_name(call_node: ast.Call, aliases: dict[str, str]) -> str | None:
|
|
231
|
+
"""Return the rule name one ``_fragment_or_deferred_check`` call selects, if scoped."""
|
|
232
|
+
argument_scope_names = {
|
|
233
|
+
each_scope_name
|
|
234
|
+
for each_argument in call_node.args[1:]
|
|
235
|
+
for each_scope_name in _referenced_scope_attribute_names(each_argument, aliases)
|
|
236
|
+
}
|
|
237
|
+
if "defer_scope_to_caller" not in argument_scope_names:
|
|
238
|
+
return None
|
|
239
|
+
if not call_node.args or not isinstance(call_node.args[0], ast.Name):
|
|
240
|
+
return None
|
|
241
|
+
return call_node.args[0].id
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _check_call_rule_name(call_node: ast.Call, aliases: dict[str, str]) -> str | None:
|
|
245
|
+
"""Return the rule name one direct ``check_*`` call selects, if it carries scope."""
|
|
246
|
+
func_name = call_node.func.id # type: ignore[attr-defined] # caller already narrowed func to ast.Name
|
|
247
|
+
if not func_name.startswith("check_"):
|
|
248
|
+
return None
|
|
249
|
+
all_call_arguments = (*call_node.args, *(each_keyword.value for each_keyword in call_node.keywords))
|
|
250
|
+
argument_scope_names = {
|
|
251
|
+
each_scope_name
|
|
252
|
+
for each_argument in all_call_arguments
|
|
253
|
+
for each_scope_name in _referenced_scope_attribute_names(each_argument, aliases)
|
|
254
|
+
}
|
|
255
|
+
return func_name if argument_scope_names else None
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _rule_names_from_function(function_node: ast.FunctionDef) -> set[str]:
|
|
259
|
+
"""Return the check_* names *function_node* calls with scope-carrying arguments."""
|
|
260
|
+
aliases = _context_alias_map(function_node)
|
|
261
|
+
rule_names: set[str] = set()
|
|
262
|
+
for each_call in ast.walk(function_node):
|
|
263
|
+
if not isinstance(each_call, ast.Call) or not isinstance(each_call.func, ast.Name):
|
|
264
|
+
continue
|
|
265
|
+
if each_call.func.id == "_fragment_or_deferred_check":
|
|
266
|
+
rule_name = _fragment_deferred_rule_name(each_call, aliases)
|
|
267
|
+
else:
|
|
268
|
+
rule_name = _check_call_rule_name(each_call, aliases)
|
|
269
|
+
if rule_name is not None:
|
|
270
|
+
rule_names.add(rule_name)
|
|
271
|
+
return rule_names
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _scope_aware_rule_names_from_source() -> frozenset[str]:
|
|
275
|
+
"""Return every check_* name dispatched anywhere in the enforcer with scope.
|
|
276
|
+
|
|
277
|
+
Walks every function in the enforcer module rather than only
|
|
278
|
+
``validate_content``, since the extension-issue helper functions hold the
|
|
279
|
+
actual dispatch calls today.
|
|
280
|
+
"""
|
|
281
|
+
enforcer_module = inspect.getmodule(validate_content)
|
|
282
|
+
assert enforcer_module is not None
|
|
283
|
+
module_tree = ast.parse(inspect.getsource(enforcer_module))
|
|
284
|
+
all_rule_names: set[str] = set()
|
|
285
|
+
for each_function in ast.walk(module_tree):
|
|
286
|
+
if isinstance(each_function, ast.FunctionDef):
|
|
287
|
+
all_rule_names.update(_rule_names_from_function(each_function))
|
|
209
288
|
return frozenset(all_rule_names)
|
|
210
289
|
|
|
211
290
|
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Golden-output snapshot for the phase-split validators, guarding both phases.
|
|
2
|
+
|
|
3
|
+
Records the full-gate phase's ordered issue list for three representative
|
|
4
|
+
fixtures — a Python cross-file duplicate-body hit, a JavaScript boolean-naming
|
|
5
|
+
hit, and a Python-built-HTML orphan-CSS-class hit — so a decomposition that
|
|
6
|
+
reorders or drops a dispatch line shows up as a changed list, not a
|
|
7
|
+
still-green suite. Each fixture also records the edit-lane phase's list, which
|
|
8
|
+
matches the full-gate list except for the cross-file finding the edit lane
|
|
9
|
+
skips.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import importlib.util
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from types import ModuleType
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _load_module_from_path(module_name: str, module_path: Path) -> ModuleType:
|
|
20
|
+
"""Load and execute the module found at module_path, under module_name."""
|
|
21
|
+
specification = importlib.util.spec_from_file_location(module_name, module_path)
|
|
22
|
+
assert specification is not None
|
|
23
|
+
assert specification.loader is not None
|
|
24
|
+
module = importlib.util.module_from_spec(specification)
|
|
25
|
+
specification.loader.exec_module(module)
|
|
26
|
+
return module
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
_BLOCKING_DIRECTORY = Path(__file__).resolve().parent
|
|
30
|
+
code_rules_enforcer = _load_module_from_path(
|
|
31
|
+
"code_rules_enforcer", _BLOCKING_DIRECTORY / "code_rules_enforcer.py"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
_DUPLICATE_FUNCTION_SOURCE = (
|
|
35
|
+
"def compute_shipping_total(order_total: float, tax_rate: float) -> float:\n"
|
|
36
|
+
" taxed_total = order_total * (1 + tax_rate)\n"
|
|
37
|
+
" rounded_total = round(taxed_total, 2)\n"
|
|
38
|
+
" return rounded_total\n"
|
|
39
|
+
)
|
|
40
|
+
_JS_BOOLEAN_SOURCE = "const active = true;\n"
|
|
41
|
+
_CSS_ORPHAN_CLASS_SOURCE = (
|
|
42
|
+
'"""Render a status badge."""\n\n'
|
|
43
|
+
"STATUS_MARKUP = '<div class=\"status-badge\">Active</div>'\n"
|
|
44
|
+
"STYLE_BLOCK = '<style>.status-pill { color: green; }</style>'\n"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
EXPECTED_PYTHON_CROSS_FILE_ISSUES = [
|
|
48
|
+
"Line 3: Magic value 2 - extract to named constant",
|
|
49
|
+
"Function 'compute_shipping_total' duplicates shipping_helper.py::compute_shipping_total"
|
|
50
|
+
" — this function body is identical to one in a sibling module; extract a single shared"
|
|
51
|
+
" helper (for example in hooks_constants/) and import it from both modules instead of"
|
|
52
|
+
" copying it (Reuse before create / DRY) (duplicate body span at line 1, spanning 4 lines)",
|
|
53
|
+
]
|
|
54
|
+
EXPECTED_PYTHON_CROSS_FILE_EDIT_LANE_ISSUES = [
|
|
55
|
+
"Line 3: Magic value 2 - extract to named constant",
|
|
56
|
+
]
|
|
57
|
+
EXPECTED_JS_ISSUES = [
|
|
58
|
+
"Line 1: Boolean active - prefix with is/has/should/can/was/did",
|
|
59
|
+
]
|
|
60
|
+
EXPECTED_CSS_ISSUES = [
|
|
61
|
+
"Line 3: Constant STATUS_MARKUP - move to config/",
|
|
62
|
+
"Line 4: Constant STYLE_BLOCK - move to config/",
|
|
63
|
+
"Line 3: CSS class 'status-badge' used in markup has no matching '.status-badge' selector"
|
|
64
|
+
" - add a matching '.<class>' selector to the <style> block, or drop the unused class"
|
|
65
|
+
" attribute (CODE_RULES self-documenting markup)",
|
|
66
|
+
]
|
|
67
|
+
EXPECTED_CSS_EDIT_LANE_ISSUES = [
|
|
68
|
+
"Line 3: Constant STATUS_MARKUP - move to config/",
|
|
69
|
+
"Line 4: Constant STYLE_BLOCK - move to config/",
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_python_cross_file_duplicate_snapshot_is_unchanged(tmp_path: Path) -> None:
|
|
74
|
+
"""Both phases' ordered output for a cross-file duplicate stays fixed.
|
|
75
|
+
|
|
76
|
+
``file_path`` is a fixed classification-only path, never opened, so the
|
|
77
|
+
real ``tmp_path`` sibling scan (which embeds this test's own name, a
|
|
78
|
+
``test_`` match) never misclassifies the fixture as a test file.
|
|
79
|
+
"""
|
|
80
|
+
sibling_dir = tmp_path / "sibling_dir"
|
|
81
|
+
sibling_dir.mkdir()
|
|
82
|
+
(sibling_dir / "shipping_helper.py").write_text(_DUPLICATE_FUNCTION_SOURCE, encoding="utf-8")
|
|
83
|
+
classification_only_file_path = "/repo/packages/demo/shipping/shipping_target.py"
|
|
84
|
+
|
|
85
|
+
full_gate_issues = code_rules_enforcer.validate_content_for_full_gate(
|
|
86
|
+
_DUPLICATE_FUNCTION_SOURCE,
|
|
87
|
+
classification_only_file_path,
|
|
88
|
+
"",
|
|
89
|
+
sibling_directory=sibling_dir,
|
|
90
|
+
)
|
|
91
|
+
edit_lane_issues = code_rules_enforcer.validate_content_for_edit_lane(
|
|
92
|
+
_DUPLICATE_FUNCTION_SOURCE,
|
|
93
|
+
classification_only_file_path,
|
|
94
|
+
"",
|
|
95
|
+
sibling_directory=sibling_dir,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
assert full_gate_issues == EXPECTED_PYTHON_CROSS_FILE_ISSUES
|
|
99
|
+
assert edit_lane_issues == EXPECTED_PYTHON_CROSS_FILE_EDIT_LANE_ISSUES
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_javascript_boolean_naming_snapshot_is_unchanged() -> None:
|
|
103
|
+
full_gate_issues = code_rules_enforcer.validate_content_for_full_gate(
|
|
104
|
+
_JS_BOOLEAN_SOURCE, "packages/app/frontend/status.js", ""
|
|
105
|
+
)
|
|
106
|
+
edit_lane_issues = code_rules_enforcer.validate_content_for_edit_lane(
|
|
107
|
+
_JS_BOOLEAN_SOURCE, "packages/app/frontend/status.js", ""
|
|
108
|
+
)
|
|
109
|
+
assert full_gate_issues == EXPECTED_JS_ISSUES
|
|
110
|
+
assert edit_lane_issues == EXPECTED_JS_ISSUES
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_python_orphan_css_class_snapshot_is_unchanged() -> None:
|
|
114
|
+
full_gate_issues = code_rules_enforcer.validate_content_for_full_gate(
|
|
115
|
+
_CSS_ORPHAN_CLASS_SOURCE, "packages/app/widgets/status_widget.py", ""
|
|
116
|
+
)
|
|
117
|
+
edit_lane_issues = code_rules_enforcer.validate_content_for_edit_lane(
|
|
118
|
+
_CSS_ORPHAN_CLASS_SOURCE, "packages/app/widgets/status_widget.py", ""
|
|
119
|
+
)
|
|
120
|
+
assert full_gate_issues == EXPECTED_CSS_ISSUES
|
|
121
|
+
assert edit_lane_issues == EXPECTED_CSS_EDIT_LANE_ISSUES
|
|
@@ -7,6 +7,8 @@ import subprocess
|
|
|
7
7
|
import sys
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
10
12
|
_BLOCKING_DIRECTORY = str(Path(__file__).resolve().parent)
|
|
11
13
|
_HOOKS_DIRECTORY = str(Path(__file__).resolve().parent.parent)
|
|
12
14
|
if _BLOCKING_DIRECTORY not in sys.path:
|
|
@@ -16,7 +18,7 @@ if _HOOKS_DIRECTORY not in sys.path:
|
|
|
16
18
|
|
|
17
19
|
from code_rules_enforcer import ( # noqa: E402
|
|
18
20
|
main,
|
|
19
|
-
validate_content,
|
|
21
|
+
validate_content_for_full_gate as validate_content,
|
|
20
22
|
)
|
|
21
23
|
from code_rules_enforcer_test_support import ( # noqa: E402
|
|
22
24
|
run_enforcer_cli,
|
|
@@ -115,7 +117,7 @@ def test_precheck_stream_parameter_carries_no_banned_noun() -> None:
|
|
|
115
117
|
|
|
116
118
|
|
|
117
119
|
def test_precheck_reports_violations_and_exits_nonzero_for_production_candidate(
|
|
118
|
-
tmp_path_factory:
|
|
120
|
+
tmp_path_factory: pytest.TempPathFactory,
|
|
119
121
|
) -> None:
|
|
120
122
|
"""A violating candidate judged at a production target prints each violation to
|
|
121
123
|
stdout and exits 1."""
|
|
@@ -138,7 +140,7 @@ def test_precheck_reports_violations_and_exits_nonzero_for_production_candidate(
|
|
|
138
140
|
|
|
139
141
|
|
|
140
142
|
def test_precheck_emits_no_output_and_exits_zero_for_clean_candidate(
|
|
141
|
-
tmp_path_factory:
|
|
143
|
+
tmp_path_factory: pytest.TempPathFactory,
|
|
142
144
|
) -> None:
|
|
143
145
|
"""A clean candidate produces no stdout and exits 0."""
|
|
144
146
|
staging_directory = getattr(tmp_path_factory, "mktemp")("staging")
|
|
@@ -155,7 +157,7 @@ def test_precheck_emits_no_output_and_exits_zero_for_clean_candidate(
|
|
|
155
157
|
|
|
156
158
|
|
|
157
159
|
def test_precheck_target_path_drives_test_file_exemptions(
|
|
158
|
-
tmp_path_factory:
|
|
160
|
+
tmp_path_factory: pytest.TempPathFactory,
|
|
159
161
|
) -> None:
|
|
160
162
|
"""Production-shaped content judged against a ``test_*.py`` target passes
|
|
161
163
|
because test-file exemptions apply through the target path."""
|
|
@@ -172,7 +174,7 @@ def test_precheck_target_path_drives_test_file_exemptions(
|
|
|
172
174
|
|
|
173
175
|
|
|
174
176
|
def test_precheck_noncode_target_exits_zero_with_no_output(
|
|
175
|
-
tmp_path_factory:
|
|
177
|
+
tmp_path_factory: pytest.TempPathFactory,
|
|
176
178
|
) -> None:
|
|
177
179
|
"""A non-code target extension is exempt: the pre-check exits 0 with no output."""
|
|
178
180
|
staging_directory = getattr(tmp_path_factory, "mktemp")("staging")
|
|
@@ -186,7 +188,7 @@ def test_precheck_noncode_target_exits_zero_with_no_output(
|
|
|
186
188
|
|
|
187
189
|
|
|
188
190
|
def test_precheck_missing_candidate_errors_on_stderr_without_traceback(
|
|
189
|
-
tmp_path_factory:
|
|
191
|
+
tmp_path_factory: pytest.TempPathFactory,
|
|
190
192
|
) -> None:
|
|
191
193
|
"""A nonexistent candidate path prints a one-line stderr error and exits
|
|
192
194
|
nonzero without a Python traceback."""
|
|
@@ -204,7 +206,7 @@ def test_precheck_missing_candidate_errors_on_stderr_without_traceback(
|
|
|
204
206
|
|
|
205
207
|
|
|
206
208
|
def test_edit_deny_reason_includes_forecast_and_precheck_hint(
|
|
207
|
-
tmp_path_factory:
|
|
209
|
+
tmp_path_factory: pytest.TempPathFactory,
|
|
208
210
|
) -> None:
|
|
209
211
|
"""An Edit whose new_string introduces a violation on a file that already
|
|
210
212
|
contains a separate violation elsewhere blocks on the fragment violation and
|
|
@@ -236,7 +238,7 @@ def test_edit_deny_reason_includes_forecast_and_precheck_hint(
|
|
|
236
238
|
|
|
237
239
|
|
|
238
240
|
def test_edit_clean_fragment_on_dirty_file_produces_no_deny_payload(
|
|
239
|
-
tmp_path_factory:
|
|
241
|
+
tmp_path_factory: pytest.TempPathFactory,
|
|
240
242
|
) -> None:
|
|
241
243
|
"""A clean Edit fragment on a file that is dirty elsewhere must not block —
|
|
242
244
|
the forecast never converts a clean-fragment edit into a deny."""
|
|
@@ -258,7 +260,7 @@ def test_edit_clean_fragment_on_dirty_file_produces_no_deny_payload(
|
|
|
258
260
|
|
|
259
261
|
|
|
260
262
|
def test_every_deny_reason_carries_the_precheck_hint(
|
|
261
|
-
tmp_path_factory:
|
|
263
|
+
tmp_path_factory: pytest.TempPathFactory,
|
|
262
264
|
) -> None:
|
|
263
265
|
"""A deny with no forecast still appends the pre-check hint to the reason."""
|
|
264
266
|
production_directory = getattr(tmp_path_factory, "mktemp")("production_pkg")
|
|
@@ -287,7 +289,7 @@ def test_every_deny_reason_carries_the_precheck_hint(
|
|
|
287
289
|
|
|
288
290
|
|
|
289
291
|
def test_forecast_skipped_when_edit_prior_is_unreconstructable(
|
|
290
|
-
tmp_path_factory:
|
|
292
|
+
tmp_path_factory: pytest.TempPathFactory,
|
|
291
293
|
) -> None:
|
|
292
294
|
"""An Edit whose old_string is absent from the file has no reliable prior to
|
|
293
295
|
diff against, so the full-file forecast must not run: a pre-existing inline
|
|
@@ -313,7 +315,7 @@ def test_forecast_skipped_when_edit_prior_is_unreconstructable(
|
|
|
313
315
|
|
|
314
316
|
|
|
315
317
|
def test_forecast_omits_the_fragment_introduced_violation(
|
|
316
|
-
tmp_path_factory:
|
|
318
|
+
tmp_path_factory: pytest.TempPathFactory,
|
|
317
319
|
) -> None:
|
|
318
320
|
"""An Edit that introduces the file's only print() blocks on it without the
|
|
319
321
|
forecast re-listing that same violation under its full-file line number."""
|
|
@@ -353,7 +355,7 @@ def test_precheck_missing_candidate_value_exits_two_with_usage() -> None:
|
|
|
353
355
|
|
|
354
356
|
|
|
355
357
|
def test_precheck_flag_shaped_candidate_value_exits_two_with_usage(
|
|
356
|
-
tmp_path_factory:
|
|
358
|
+
tmp_path_factory: pytest.TempPathFactory,
|
|
357
359
|
) -> None:
|
|
358
360
|
"""``--check`` immediately followed by ``--as`` is a usage error rather than
|
|
359
361
|
an attempt to read a file literally named ``--as``."""
|
|
@@ -370,7 +372,7 @@ def test_precheck_flag_shaped_candidate_value_exits_two_with_usage(
|
|
|
370
372
|
|
|
371
373
|
|
|
372
374
|
def test_precheck_rejects_unrecognized_trailing_token_with_usage(
|
|
373
|
-
tmp_path_factory:
|
|
375
|
+
tmp_path_factory: pytest.TempPathFactory,
|
|
374
376
|
) -> None:
|
|
375
377
|
"""A pre-check vector carrying a token beyond the supported
|
|
376
378
|
``--check <candidate> [--as <target>]`` shape is a usage error: an extra
|
|
@@ -393,7 +395,7 @@ def test_precheck_rejects_unrecognized_trailing_token_with_usage(
|
|
|
393
395
|
|
|
394
396
|
|
|
395
397
|
def test_precheck_strips_candidate_byte_order_mark_before_validation(
|
|
396
|
-
tmp_path_factory:
|
|
398
|
+
tmp_path_factory: pytest.TempPathFactory,
|
|
397
399
|
) -> None:
|
|
398
400
|
"""A UTF-8 byte-order mark on the candidate must not hide AST-based
|
|
399
401
|
violations: the candidate is judged exactly as its decoded content would
|
|
@@ -415,7 +417,7 @@ def test_precheck_strips_candidate_byte_order_mark_before_validation(
|
|
|
415
417
|
|
|
416
418
|
|
|
417
419
|
def test_precheck_strips_every_leading_byte_order_mark_before_validation(
|
|
418
|
-
tmp_path_factory:
|
|
420
|
+
tmp_path_factory: pytest.TempPathFactory,
|
|
419
421
|
) -> None:
|
|
420
422
|
"""Stacked byte-order marks must not hide AST-based violations: every
|
|
421
423
|
leading mark is stripped, so a double-BOM candidate fails exactly like its
|
|
@@ -6,6 +6,8 @@ import sys
|
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
from types import SimpleNamespace
|
|
8
8
|
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
9
11
|
_BLOCKING_DIRECTORY = str(Path(__file__).resolve().parent)
|
|
10
12
|
_HOOKS_DIRECTORY = str(Path(__file__).resolve().parent.parent)
|
|
11
13
|
if _BLOCKING_DIRECTORY not in sys.path:
|
|
@@ -17,7 +19,7 @@ from code_rules_annotations_length import ( # noqa: E402
|
|
|
17
19
|
FUNCTION_LENGTH_BLOCKING_THRESHOLD,
|
|
18
20
|
)
|
|
19
21
|
from code_rules_enforcer import ( # noqa: E402
|
|
20
|
-
validate_content,
|
|
22
|
+
validate_content_for_full_gate as validate_content,
|
|
21
23
|
)
|
|
22
24
|
|
|
23
25
|
code_rules_enforcer = SimpleNamespace(
|
|
@@ -38,7 +40,7 @@ def _oversized_function_source(name: str) -> str:
|
|
|
38
40
|
|
|
39
41
|
|
|
40
42
|
def test_should_emit_advisory_for_duplicated_format_patterns_via_validate_content(
|
|
41
|
-
capsys:
|
|
43
|
+
capsys: pytest.CaptureFixture[str],
|
|
42
44
|
) -> None:
|
|
43
45
|
repeated_pattern_source = (
|
|
44
46
|
"def get_user(user_id: str) -> str:\n"
|
|
@@ -22,7 +22,7 @@ from code_rules_annotations_length import ( # noqa: E402
|
|
|
22
22
|
from code_rules_enforcer import ( # noqa: E402
|
|
23
23
|
main,
|
|
24
24
|
prior_and_post_edit_content,
|
|
25
|
-
validate_content,
|
|
25
|
+
validate_content_for_full_gate as validate_content,
|
|
26
26
|
)
|
|
27
27
|
from code_rules_enforcer_test_support import ( # noqa: E402
|
|
28
28
|
run_serialized_payload_entrypoint,
|
|
@@ -25,7 +25,7 @@ if _PR_LOOP_SCRIPTS_DIRECTORY not in sys.path:
|
|
|
25
25
|
from code_rules_enforcer import ( # noqa: E402
|
|
26
26
|
_fragment_or_deferred_check,
|
|
27
27
|
main,
|
|
28
|
-
validate_content,
|
|
28
|
+
validate_content_for_full_gate as validate_content,
|
|
29
29
|
)
|
|
30
30
|
from code_rules_gate_parts import gate_running # noqa: E402
|
|
31
31
|
from code_rules_gate_parts.tests._repo_test_helpers import ( # noqa: E402
|
|
@@ -248,7 +248,7 @@ def test_should_grandfather_unchanged_string_magic_in_wide_edit() -> None:
|
|
|
248
248
|
" # wide edit that leaves the magic string in place\n"
|
|
249
249
|
" return os.environ['STRIPE_SECRET']\n"
|
|
250
250
|
)
|
|
251
|
-
issues = code_rules_enforcer.
|
|
251
|
+
issues = code_rules_enforcer.validate_content_for_full_gate(
|
|
252
252
|
proposed_fragment,
|
|
253
253
|
PRODUCTION_FILE_PATH,
|
|
254
254
|
prior_fragment,
|
|
@@ -272,7 +272,7 @@ def test_should_block_new_string_magic_in_wide_edit() -> None:
|
|
|
272
272
|
"def fetch_secret() -> str:\n"
|
|
273
273
|
" return os.environ['STRIPE_SECRET']\n"
|
|
274
274
|
)
|
|
275
|
-
issues = code_rules_enforcer.
|
|
275
|
+
issues = code_rules_enforcer.validate_content_for_full_gate(
|
|
276
276
|
proposed_fragment,
|
|
277
277
|
PRODUCTION_FILE_PATH,
|
|
278
278
|
prior_fragment,
|
|
@@ -300,7 +300,7 @@ def test_should_block_second_identical_string_magic_per_occurrence() -> None:
|
|
|
300
300
|
"def fetch_backup() -> str:\n"
|
|
301
301
|
" return os.environ['STRIPE_SECRET']\n"
|
|
302
302
|
)
|
|
303
|
-
issues = code_rules_enforcer.
|
|
303
|
+
issues = code_rules_enforcer.validate_content_for_full_gate(
|
|
304
304
|
proposed_fragment,
|
|
305
305
|
PRODUCTION_FILE_PATH,
|
|
306
306
|
prior_fragment,
|
|
@@ -320,7 +320,7 @@ def test_should_surface_magic_when_gate_defers_scope_with_head_as_old() -> None:
|
|
|
320
320
|
" old_number = 9999\n"
|
|
321
321
|
" return old_number\n"
|
|
322
322
|
)
|
|
323
|
-
issues = code_rules_enforcer.
|
|
323
|
+
issues = code_rules_enforcer.validate_content_for_full_gate(
|
|
324
324
|
source,
|
|
325
325
|
PRODUCTION_FILE_PATH,
|
|
326
326
|
source,
|
|
@@ -149,7 +149,7 @@ def test_should_flag_thin_wrapper_with_module_docstring() -> None:
|
|
|
149
149
|
)
|
|
150
150
|
|
|
151
151
|
|
|
152
|
-
def
|
|
152
|
+
def test_validate_content_for_full_gate_uses_empty_full_file_content_over_pre_edit_fragment() -> None:
|
|
153
153
|
"""An empty-string `full_file_content` must be honored, not silently replaced with `content`.
|
|
154
154
|
|
|
155
155
|
Regression for loop1-8: the `or` short-circuit at line 3775 collapsed
|
|
@@ -159,7 +159,7 @@ def test_validate_content_uses_empty_full_file_content_over_pre_edit_fragment()
|
|
|
159
159
|
not a thin wrapper, but a pre-edit fragment with imports + __all__ is.
|
|
160
160
|
"""
|
|
161
161
|
pre_edit_fragment = "from real_module import do_thing\n__all__ = ['do_thing']\n"
|
|
162
|
-
issues = code_rules_enforcer.
|
|
162
|
+
issues = code_rules_enforcer.validate_content_for_full_gate(
|
|
163
163
|
pre_edit_fragment,
|
|
164
164
|
PRODUCTION_FILE_PATH,
|
|
165
165
|
full_file_content="",
|
|
@@ -240,7 +240,7 @@ def test_should_not_flag_recursive_self_call() -> None:
|
|
|
240
240
|
)
|
|
241
241
|
|
|
242
242
|
|
|
243
|
-
def
|
|
243
|
+
def test_should_flag_through_validate_content_for_full_gate_for_hook_file() -> None:
|
|
244
244
|
source = (
|
|
245
245
|
"def find_bare_path_segments(content: str) -> set[str]:\n"
|
|
246
246
|
" return {part for part in content.split() if part}\n"
|
|
@@ -248,7 +248,7 @@ def test_should_flag_through_validate_content_for_hook_file() -> None:
|
|
|
248
248
|
"def find_bare_index_segments(content: str) -> set[str]:\n"
|
|
249
249
|
" return find_bare_path_segments(content)\n"
|
|
250
250
|
)
|
|
251
|
-
issues = code_rules_enforcer.
|
|
251
|
+
issues = code_rules_enforcer.validate_content_for_full_gate(source, HOOK_INFRASTRUCTURE_PATH)
|
|
252
252
|
assert any("find_bare_index_segments" in each for each in issues), (
|
|
253
253
|
f"validate_content must surface the alias for hook files, got: {issues!r}"
|
|
254
254
|
)
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
A pass-through alias inside a hook module is the motivating case for the
|
|
4
4
|
zero-payload-alias check, so the deny must fire on the same PreToolUse path a
|
|
5
5
|
live Write into ``packages/claude-dev-env/hooks/blocking`` would take — not only
|
|
6
|
-
through ``
|
|
6
|
+
through ``validate_content_for_edit_lane``, which hook files never reach at PreToolUse. These
|
|
7
7
|
tests drive the real ``main()`` stdin entry point and the pre-check CLI with a
|
|
8
8
|
hook-infrastructure target.
|
|
9
9
|
|
|
@@ -66,7 +66,7 @@ def test_write_of_pass_through_alias_into_hook_directory_denies(
|
|
|
66
66
|
The target lives under a hook-infrastructure path the full code-rules suite
|
|
67
67
|
exempts, so this proves the zero-payload-alias check still fires on the exact
|
|
68
68
|
directory its docstring names as the motivating case — at the PreToolUse Write
|
|
69
|
-
point, not only through ``
|
|
69
|
+
point, not only through ``validate_content_for_edit_lane``."""
|
|
70
70
|
new_file = hook_blocking_dir / "new_blocker.py"
|
|
71
71
|
captured_stdout, _exit_code = run_write_entrypoint(
|
|
72
72
|
main,
|