claude-dev-env 2.29.0 → 2.30.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 +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/_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 +433 -313
- 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 +281 -0
- package/hooks/blocking/test_fable_spawn_gate.py +1 -1
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +26 -11
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +8 -0
- package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +21 -0
- package/hooks/hooks_constants/validation_phase_constants.py +34 -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
|
@@ -28,7 +28,7 @@ assert hook_spec.loader is not None
|
|
|
28
28
|
hook_module = importlib.util.module_from_spec(hook_spec)
|
|
29
29
|
hook_spec.loader.exec_module(hook_module)
|
|
30
30
|
check_banned_noun_word_boundary = hook_module.check_banned_noun_word_boundary
|
|
31
|
-
validate_content = hook_module.
|
|
31
|
+
validate_content = hook_module.validate_content_for_full_gate
|
|
32
32
|
|
|
33
33
|
from code_rules_banned_identifiers import ( # noqa: E402
|
|
34
34
|
_find_banned_noun_word,
|
|
@@ -31,7 +31,7 @@ def check_class_docstring_names_public_methods(content: str, file_path: str) ->
|
|
|
31
31
|
|
|
32
32
|
|
|
33
33
|
def validate_content(content: str, file_path: str, old_content: str) -> list[str]:
|
|
34
|
-
return code_rules_enforcer.
|
|
34
|
+
return code_rules_enforcer.validate_content_for_full_gate(content, file_path, old_content)
|
|
35
35
|
|
|
36
36
|
|
|
37
37
|
PRODUCTION_FILE_PATH = "/project/src/break_reporter.py"
|
|
@@ -1,25 +1,49 @@
|
|
|
1
|
-
"""Meta-test asserting every check_* function is
|
|
1
|
+
"""Meta-test asserting every check_* function is reachable from the full gate.
|
|
2
2
|
|
|
3
3
|
The per-check test modules each prove one ``check_*`` function flags the right
|
|
4
4
|
violation, but none proves the enforcer actually calls that function. A refactor
|
|
5
|
-
that drops a dispatch line from ``
|
|
6
|
-
|
|
5
|
+
that drops a dispatch line from ``validate_content_for_full_gate`` or from one
|
|
6
|
+
of its extension-issue helper functions leaves every per-check test green
|
|
7
|
+
while the check stops firing at Write/Edit time — the precise failure mode
|
|
7
8
|
that would let a dead module-level constant (the ``MEDIUM_TEXT`` class) or an
|
|
8
9
|
orphan CSS class slip past the gate again.
|
|
9
10
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
11
|
+
::
|
|
12
|
+
|
|
13
|
+
validate_content_for_full_gate -> _python_extension_issues -> _python_magic_value_and_constant_issues
|
|
14
|
+
|
|
|
15
|
+
v
|
|
16
|
+
references check_magic_values
|
|
17
|
+
-> check_magic_values counts as reachable
|
|
18
|
+
|
|
19
|
+
check_unanchored_command_dispatch: called only from
|
|
20
|
+
_hook_infrastructure_blocking_issues, which the full gate's call graph
|
|
21
|
+
never reaches -> stays in KNOWN_UNDISPATCHED_CHECKS
|
|
22
|
+
|
|
23
|
+
This module walks the enforcer's real call graph from
|
|
24
|
+
``validate_content_for_full_gate``, following every direct function call into
|
|
25
|
+
the functions the enforcer module defines, and collects every
|
|
26
|
+
``check_*``/``advise_*`` name each reached function body references — a direct
|
|
27
|
+
call or a bare reference passed on as a callback (the
|
|
28
|
+
``_fragment_or_deferred_check(check_magic_values, ...)`` shape). A name that
|
|
29
|
+
surfaces only in an import statement or a docstring is not a reference inside
|
|
30
|
+
a reached function body, so it does not count; this is what keeps the check
|
|
31
|
+
from going vacuous the way a whole-module substring scan would.
|
|
32
|
+
|
|
33
|
+
A check that is intentionally not wired must be listed in
|
|
34
|
+
``KNOWN_UNDISPATCHED_CHECKS`` with a reason in this docstring.
|
|
35
|
+
``check_unanchored_command_dispatch`` is listed there: it guards a
|
|
36
|
+
``hooks/blocking`` command classifier, and the whole
|
|
37
|
+
``validate_content_for_full_gate`` verdict stays off hook-infrastructure
|
|
38
|
+
files, so the enforcer dispatches it from
|
|
16
39
|
``_hook_infrastructure_blocking_issues`` instead. The companion
|
|
17
|
-
``test_code_rules_enforcer_cap_meta.py`` guards the payload-cap convention;
|
|
18
|
-
module guards the wiring.
|
|
40
|
+
``test_code_rules_enforcer_cap_meta.py`` guards the payload-cap convention;
|
|
41
|
+
this module guards the wiring.
|
|
19
42
|
"""
|
|
20
43
|
|
|
21
44
|
from __future__ import annotations
|
|
22
45
|
|
|
46
|
+
import ast
|
|
23
47
|
import importlib.util
|
|
24
48
|
import inspect
|
|
25
49
|
import pathlib
|
|
@@ -42,6 +66,8 @@ KNOWN_UNDISPATCHED_CHECKS: frozenset[str] = frozenset(
|
|
|
42
66
|
{"check_unanchored_command_dispatch"}
|
|
43
67
|
)
|
|
44
68
|
|
|
69
|
+
DISPATCH_ENTRY_POINT_NAME = "validate_content_for_full_gate"
|
|
70
|
+
|
|
45
71
|
|
|
46
72
|
def _all_check_function_names() -> list[str]:
|
|
47
73
|
return [
|
|
@@ -52,22 +78,91 @@ def _all_check_function_names() -> list[str]:
|
|
|
52
78
|
]
|
|
53
79
|
|
|
54
80
|
|
|
55
|
-
def
|
|
56
|
-
|
|
81
|
+
def _module_level_function_defs(module_tree: ast.Module) -> dict[str, ast.FunctionDef]:
|
|
82
|
+
"""Map each module-level function's name to its definition node."""
|
|
83
|
+
return {
|
|
84
|
+
each_node.name: each_node
|
|
85
|
+
for each_node in module_tree.body
|
|
86
|
+
if isinstance(each_node, ast.FunctionDef)
|
|
87
|
+
}
|
|
57
88
|
|
|
58
89
|
|
|
59
|
-
def
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
90
|
+
def _local_call_targets(function_node: ast.FunctionDef) -> set[str]:
|
|
91
|
+
"""Return the plain-name targets one function directly calls."""
|
|
92
|
+
return {
|
|
93
|
+
each_call.func.id
|
|
94
|
+
for each_call in ast.walk(function_node)
|
|
95
|
+
if isinstance(each_call, ast.Call) and isinstance(each_call.func, ast.Name)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _unvisited_local_calls(
|
|
100
|
+
function_name: str,
|
|
101
|
+
function_defs_by_name: dict[str, ast.FunctionDef],
|
|
102
|
+
visited: set[str],
|
|
103
|
+
) -> list[str]:
|
|
104
|
+
"""Return *function_name*'s call targets not already visited by the walk."""
|
|
105
|
+
function_node = function_defs_by_name.get(function_name)
|
|
106
|
+
if function_node is None:
|
|
107
|
+
return []
|
|
108
|
+
return [each for each in _local_call_targets(function_node) if each not in visited]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _reachable_function_names(
|
|
112
|
+
start_name: str, function_defs_by_name: dict[str, ast.FunctionDef]
|
|
113
|
+
) -> set[str]:
|
|
114
|
+
"""Return every function name the call graph reaches, starting from start_name."""
|
|
115
|
+
visited: set[str] = set()
|
|
116
|
+
pending = [start_name]
|
|
117
|
+
while pending:
|
|
118
|
+
each_pending_name = pending.pop()
|
|
119
|
+
if each_pending_name in visited:
|
|
120
|
+
continue
|
|
121
|
+
visited.add(each_pending_name)
|
|
122
|
+
pending.extend(_unvisited_local_calls(each_pending_name, function_defs_by_name, visited))
|
|
123
|
+
return visited
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _referenced_names(function_node: ast.FunctionDef) -> set[str]:
|
|
127
|
+
"""Return every loaded identifier one function body references.
|
|
128
|
+
|
|
129
|
+
A callback-style dispatch (``_fragment_or_deferred_check(check_magic_values,
|
|
130
|
+
...)``) passes the check function by bare name rather than calling it
|
|
131
|
+
directly, so this collects every loaded ``Name``, not only call targets.
|
|
132
|
+
"""
|
|
133
|
+
return {
|
|
134
|
+
each_name.id
|
|
135
|
+
for each_name in ast.walk(function_node)
|
|
136
|
+
if isinstance(each_name, ast.Name) and isinstance(each_name.ctx, ast.Load)
|
|
64
137
|
}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _reachable_referenced_names(
|
|
141
|
+
start_name: str, function_defs_by_name: dict[str, ast.FunctionDef]
|
|
142
|
+
) -> set[str]:
|
|
143
|
+
"""Return every identifier referenced by a function the call graph reaches."""
|
|
144
|
+
reachable_function_names = _reachable_function_names(start_name, function_defs_by_name)
|
|
145
|
+
referenced_names: set[str] = set()
|
|
146
|
+
for each_function_name in reachable_function_names:
|
|
147
|
+
function_node = function_defs_by_name.get(each_function_name)
|
|
148
|
+
if function_node is not None:
|
|
149
|
+
referenced_names.update(_referenced_names(function_node))
|
|
150
|
+
return referenced_names
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_every_check_function_is_reachable_from_the_full_gate() -> None:
|
|
154
|
+
module_tree = ast.parse(inspect.getsource(_hook_module))
|
|
155
|
+
function_defs_by_name = _module_level_function_defs(module_tree)
|
|
156
|
+
reachable_names = _reachable_referenced_names(DISPATCH_ENTRY_POINT_NAME, function_defs_by_name)
|
|
157
|
+
all_check_names = set(_all_check_function_names())
|
|
158
|
+
undispatched_check_names = all_check_names - reachable_names
|
|
65
159
|
unexpected_undispatched = undispatched_check_names - KNOWN_UNDISPATCHED_CHECKS
|
|
66
160
|
assert unexpected_undispatched == set(), (
|
|
67
|
-
f"check_* functions are imported but never
|
|
68
|
-
f"{sorted(unexpected_undispatched)}.
|
|
69
|
-
f"
|
|
70
|
-
f"
|
|
161
|
+
f"check_* functions are imported but never reachable from "
|
|
162
|
+
f"validate_content_for_full_gate's call graph: {sorted(unexpected_undispatched)}. "
|
|
163
|
+
f"Wire each into validate_content_for_phase or one of its extension-issue "
|
|
164
|
+
f"helpers so the check fires at Write/Edit time, or list it in "
|
|
165
|
+
f"KNOWN_UNDISPATCHED_CHECKS with a reason in the test header docstring."
|
|
71
166
|
)
|
|
72
167
|
|
|
73
168
|
|
|
@@ -30,7 +30,7 @@ def check_docstring_args_match_signature(content: str, file_path: str) -> list[s
|
|
|
30
30
|
|
|
31
31
|
|
|
32
32
|
def validate_content(content: str, file_path: str, old_content: str) -> list[str]:
|
|
33
|
-
return code_rules_enforcer.
|
|
33
|
+
return code_rules_enforcer.validate_content_for_full_gate(content, file_path, old_content)
|
|
34
34
|
|
|
35
35
|
|
|
36
36
|
PRODUCTION_FILE_PATH = "/project/src/services.py"
|
|
@@ -39,7 +39,7 @@ def check_docstring_prose_wall_without_illustration(content: str, file_path: str
|
|
|
39
39
|
|
|
40
40
|
|
|
41
41
|
def validate_content(content: str, file_path: str, old_content: str) -> list[str]:
|
|
42
|
-
return code_rules_enforcer.
|
|
42
|
+
return code_rules_enforcer.validate_content_for_full_gate(content, file_path, old_content)
|
|
43
43
|
|
|
44
44
|
|
|
45
45
|
PRODUCTION_FILE_PATH = "/project/src/tally.py"
|
|
@@ -42,7 +42,7 @@ def check_docstring_runon_sentence(content: str, file_path: str) -> list[str]:
|
|
|
42
42
|
|
|
43
43
|
|
|
44
44
|
def validate_content(content: str, file_path: str, old_content: str) -> list[str]:
|
|
45
|
-
return code_rules_enforcer.
|
|
45
|
+
return code_rules_enforcer.validate_content_for_full_gate(content, file_path, old_content)
|
|
46
46
|
|
|
47
47
|
|
|
48
48
|
PRODUCTION_FILE_PATH = "/project/src/run_lifecycle.py"
|
|
@@ -32,7 +32,7 @@ def check_documents_unreferenced_parameter(content: str, file_path: str) -> list
|
|
|
32
32
|
|
|
33
33
|
|
|
34
34
|
def validate_content(content: str, file_path: str, old_content: str) -> list[str]:
|
|
35
|
-
return code_rules_enforcer.
|
|
35
|
+
return code_rules_enforcer.validate_content_for_full_gate(content, file_path, old_content)
|
|
36
36
|
|
|
37
37
|
|
|
38
38
|
PRODUCTION_FILE_PATH = "/project/src/per_theme_loop.py"
|
|
@@ -17,7 +17,7 @@ assert hook_spec.loader is not None
|
|
|
17
17
|
hook_module = importlib.util.module_from_spec(hook_spec)
|
|
18
18
|
hook_spec.loader.exec_module(hook_module)
|
|
19
19
|
check_fstring_structural_literals = hook_module.check_fstring_structural_literals
|
|
20
|
-
validate_content = hook_module.
|
|
20
|
+
validate_content = hook_module.validate_content_for_full_gate
|
|
21
21
|
|
|
22
22
|
PRODUCTION_FILE_PATH = "packages/claude-dev-env/example.py"
|
|
23
23
|
TEST_FILE_PATH = "packages/claude-dev-env/test_example.py"
|
|
@@ -25,7 +25,7 @@ from code_rules_boolean_mustcheck import ( # noqa: E402
|
|
|
25
25
|
check_ignored_must_check_return as _check_ignored_must_check_return,
|
|
26
26
|
)
|
|
27
27
|
from code_rules_enforcer import ( # noqa: E402
|
|
28
|
-
|
|
28
|
+
validate_content_for_full_gate as _validate_content,
|
|
29
29
|
)
|
|
30
30
|
|
|
31
31
|
code_rules_enforcer = SimpleNamespace(
|
|
@@ -23,7 +23,7 @@ assert hook_spec.loader is not None
|
|
|
23
23
|
hook_module = importlib.util.module_from_spec(hook_spec)
|
|
24
24
|
hook_spec.loader.exec_module(hook_module)
|
|
25
25
|
check_inline_tuple_string_magic = hook_module.check_inline_tuple_string_magic
|
|
26
|
-
validate_content = hook_module.
|
|
26
|
+
validate_content = hook_module.validate_content_for_full_gate
|
|
27
27
|
|
|
28
28
|
PRODUCTION_FILE_PATH = "packages/app/services/loader.py"
|
|
29
29
|
TEST_FILE_PATH = "packages/app/services/test_loader.py"
|
|
@@ -77,7 +77,7 @@ def test_should_grandfather_unchanged_join_separator_in_wide_edit() -> None:
|
|
|
77
77
|
" # keep the join separator the same\n"
|
|
78
78
|
" return ', '.join(str(each_path) for each_path in all_paths)\n"
|
|
79
79
|
)
|
|
80
|
-
issues = code_rules_enforcer.
|
|
80
|
+
issues = code_rules_enforcer.validate_content_for_full_gate(
|
|
81
81
|
proposed_fragment,
|
|
82
82
|
PRODUCTION_FILE_PATH,
|
|
83
83
|
prior_fragment,
|
|
@@ -97,7 +97,7 @@ def test_should_block_new_join_separator_in_wide_edit() -> None:
|
|
|
97
97
|
"def render(all_paths: list) -> str:\n"
|
|
98
98
|
" return ', '.join(all_paths)\n"
|
|
99
99
|
)
|
|
100
|
-
issues = code_rules_enforcer.
|
|
100
|
+
issues = code_rules_enforcer.validate_content_for_full_gate(
|
|
101
101
|
proposed_fragment,
|
|
102
102
|
PRODUCTION_FILE_PATH,
|
|
103
103
|
prior_fragment,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Enforcer-dispatch tests for the JS returns-object schema-less branch check.
|
|
2
2
|
|
|
3
|
-
These drive ``
|
|
3
|
+
These drive ``validate_content_for_full_gate`` end-to-end on a ``.mjs`` payload, so they prove
|
|
4
4
|
the check is wired into the JavaScript branch of the enforcer, not just callable
|
|
5
5
|
in isolation. The drift: a ``function`` whose JSDoc ``@returns {Promise<object>}``
|
|
6
6
|
promises a structured object while one branch returns the agent helper with an
|
|
@@ -62,11 +62,11 @@ def _has_returns_object_finding(all_issues: list[str]) -> bool:
|
|
|
62
62
|
|
|
63
63
|
def test_enforcer_reports_schema_less_branch_under_returns_object() -> None:
|
|
64
64
|
drift_source = _mixed_schema_mjs()
|
|
65
|
-
all_issues = code_rules_enforcer.
|
|
65
|
+
all_issues = code_rules_enforcer.validate_content_for_full_gate(drift_source, _MJS_PATH, drift_source)
|
|
66
66
|
assert _has_returns_object_finding(all_issues)
|
|
67
67
|
|
|
68
68
|
|
|
69
69
|
def test_enforcer_accepts_every_branch_with_a_schema() -> None:
|
|
70
70
|
clean_source = _all_schema_mjs()
|
|
71
|
-
all_issues = code_rules_enforcer.
|
|
71
|
+
all_issues = code_rules_enforcer.validate_content_for_full_gate(clean_source, _MJS_PATH, clean_source)
|
|
72
72
|
assert not _has_returns_object_finding(all_issues)
|
|
@@ -31,7 +31,7 @@ def check_module_docstring_names_public_checks(content: str, file_path: str) ->
|
|
|
31
31
|
|
|
32
32
|
|
|
33
33
|
def validate_content(content: str, file_path: str, old_content: str) -> list[str]:
|
|
34
|
-
return code_rules_enforcer.
|
|
34
|
+
return code_rules_enforcer.validate_content_for_full_gate(content, file_path, old_content)
|
|
35
35
|
|
|
36
36
|
|
|
37
37
|
HOOK_INFRASTRUCTURE_PATH = "/home/user/.claude/hooks/blocking/code_rules_test_assertions.py"
|
|
@@ -18,7 +18,7 @@ assert _hook_spec.loader is not None
|
|
|
18
18
|
_hook_module = importlib.util.module_from_spec(_hook_spec)
|
|
19
19
|
_hook_spec.loader.exec_module(_hook_module)
|
|
20
20
|
check_boolean_naming = _hook_module.check_boolean_naming
|
|
21
|
-
validate_content = _hook_module.
|
|
21
|
+
validate_content = _hook_module.validate_content_for_full_gate
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
PRODUCTION_FILE_PATH = "src/app/feature.py"
|
|
@@ -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
|