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
|
@@ -24,13 +24,28 @@ are treated as literal only when the count of preceding backslashes is odd;
|
|
|
24
24
|
an even count (including zero) means the backtick is a live substitution.
|
|
25
25
|
|
|
26
26
|
Bash arithmetic expansion ``$((...))`` is explicitly NOT blocked: it does not
|
|
27
|
-
spawn a subshell and does not defeat the allowlist matcher
|
|
28
|
-
|
|
27
|
+
spawn a subshell and does not defeat the allowlist matcher. Telling it apart
|
|
28
|
+
from a disguised command substitution takes more than counting two open
|
|
29
|
+
parens, because bash itself falls back from arithmetic to command
|
|
30
|
+
substitution when the expansion body does not close as arithmetic. Observed
|
|
31
|
+
against real bash:
|
|
32
|
+
|
|
33
|
+
echo $((1 + 2)) prints 3 (arithmetic; not blocked)
|
|
34
|
+
echo $((cd /tmp) && pwd) prints the new cwd (command substitution; blocked)
|
|
35
|
+
echo $((1+2) ) runs "1+2" as a command, rc=0, no output
|
|
36
|
+
(command substitution; blocked)
|
|
37
|
+
|
|
38
|
+
Bash's own rule is the terminator: walk forward from ``$((`` tracking paren
|
|
39
|
+
depth; when depth returns to zero on a closer immediately preceded by
|
|
40
|
+
another ``)`` (an ``))`` pair), the expansion is arithmetic and passes. When
|
|
41
|
+
the closer that brings depth to zero is a lone ``)``, bash has fallen back to
|
|
42
|
+
running a parenthesized command inside the substitution, so it is blocked.
|
|
43
|
+
This hook applies that walk, tracking paren depth from the opening ``$((``.
|
|
29
44
|
|
|
30
45
|
False positives are still possible when the user is intentionally writing
|
|
31
46
|
shell-script content (for example, authoring a .sh file via heredoc) and the
|
|
32
47
|
substitution is literal payload rather than something to execute. In that
|
|
33
|
-
case,
|
|
48
|
+
case, author the file with the Write tool, not a Bash heredoc.
|
|
34
49
|
"""
|
|
35
50
|
|
|
36
51
|
from __future__ import annotations
|
|
@@ -39,34 +54,95 @@ import json
|
|
|
39
54
|
import sys
|
|
40
55
|
from pathlib import Path
|
|
41
56
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
)
|
|
57
|
+
try:
|
|
58
|
+
from hooks_constants.shell_substitution_blocker_constants import (
|
|
59
|
+
BASH_TOOL_NAME,
|
|
60
|
+
CLOSE_PAREN_CHARACTER,
|
|
61
|
+
COMMAND_KEY,
|
|
62
|
+
CORRECTIVE_MESSAGE,
|
|
63
|
+
DENY_DECISION,
|
|
64
|
+
DOLLAR_PAREN_PATTERN,
|
|
65
|
+
EVEN_BACKSLASH_BACKTICK_PATTERN,
|
|
66
|
+
HOOK_EVENT_NAME,
|
|
67
|
+
HOOK_EVENT_NAME_KEY,
|
|
68
|
+
HOOK_SPECIFIC_OUTPUT_KEY,
|
|
69
|
+
OPEN_PAREN_CHARACTER,
|
|
70
|
+
PAREN_DEPTH_AFTER_DOUBLE_OPEN,
|
|
71
|
+
PERMISSION_DECISION_KEY,
|
|
72
|
+
PERMISSION_DECISION_REASON_KEY,
|
|
73
|
+
PROCESS_SUBSTITUTION_PATTERN,
|
|
74
|
+
SINGLE_QUOTED_RUN_PATTERN,
|
|
75
|
+
STRIPPED_RUN_REPLACEMENT,
|
|
76
|
+
TOOL_INPUT_KEY,
|
|
77
|
+
TOOL_NAME_KEY,
|
|
78
|
+
)
|
|
79
|
+
except ModuleNotFoundError:
|
|
80
|
+
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
81
|
+
if _hooks_dir not in sys.path:
|
|
82
|
+
sys.path.insert(0, _hooks_dir)
|
|
83
|
+
from hooks_constants.shell_substitution_blocker_constants import (
|
|
84
|
+
BASH_TOOL_NAME,
|
|
85
|
+
CLOSE_PAREN_CHARACTER,
|
|
86
|
+
COMMAND_KEY,
|
|
87
|
+
CORRECTIVE_MESSAGE,
|
|
88
|
+
DENY_DECISION,
|
|
89
|
+
DOLLAR_PAREN_PATTERN,
|
|
90
|
+
EVEN_BACKSLASH_BACKTICK_PATTERN,
|
|
91
|
+
HOOK_EVENT_NAME,
|
|
92
|
+
HOOK_EVENT_NAME_KEY,
|
|
93
|
+
HOOK_SPECIFIC_OUTPUT_KEY,
|
|
94
|
+
OPEN_PAREN_CHARACTER,
|
|
95
|
+
PAREN_DEPTH_AFTER_DOUBLE_OPEN,
|
|
96
|
+
PERMISSION_DECISION_KEY,
|
|
97
|
+
PERMISSION_DECISION_REASON_KEY,
|
|
98
|
+
PROCESS_SUBSTITUTION_PATTERN,
|
|
99
|
+
SINGLE_QUOTED_RUN_PATTERN,
|
|
100
|
+
STRIPPED_RUN_REPLACEMENT,
|
|
101
|
+
TOOL_INPUT_KEY,
|
|
102
|
+
TOOL_NAME_KEY,
|
|
103
|
+
)
|
|
64
104
|
|
|
65
105
|
|
|
66
106
|
def _strip_single_quoted_runs(command: str) -> str:
|
|
67
107
|
return SINGLE_QUOTED_RUN_PATTERN.sub(STRIPPED_RUN_REPLACEMENT, command)
|
|
68
108
|
|
|
69
109
|
|
|
110
|
+
def _closes_as_arithmetic_expansion(command: str, scan_start_index: int) -> bool:
|
|
111
|
+
"""Return True when a ``$((`` expansion closes on an ``))`` pair.
|
|
112
|
+
|
|
113
|
+
See the module docstring for the real-bash evidence behind this rule.
|
|
114
|
+
"""
|
|
115
|
+
paren_depth = PAREN_DEPTH_AFTER_DOUBLE_OPEN
|
|
116
|
+
was_previous_character_close_paren = False
|
|
117
|
+
for each_index in range(scan_start_index, len(command)):
|
|
118
|
+
current_character = command[each_index]
|
|
119
|
+
if current_character == OPEN_PAREN_CHARACTER:
|
|
120
|
+
paren_depth += 1
|
|
121
|
+
was_previous_character_close_paren = False
|
|
122
|
+
continue
|
|
123
|
+
if current_character != CLOSE_PAREN_CHARACTER:
|
|
124
|
+
was_previous_character_close_paren = False
|
|
125
|
+
continue
|
|
126
|
+
paren_depth -= 1
|
|
127
|
+
if paren_depth == 0:
|
|
128
|
+
return was_previous_character_close_paren
|
|
129
|
+
was_previous_character_close_paren = True
|
|
130
|
+
return False
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _has_unsafe_dollar_paren_expansion(command: str) -> bool:
|
|
134
|
+
"""Return True for a `$(` that is not a safe `$((...))` arithmetic pair."""
|
|
135
|
+
for each_match in DOLLAR_PAREN_PATTERN.finditer(command):
|
|
136
|
+
is_arithmetic_style_open = (
|
|
137
|
+
command[each_match.end() : each_match.end() + 1] == OPEN_PAREN_CHARACTER
|
|
138
|
+
)
|
|
139
|
+
if not is_arithmetic_style_open:
|
|
140
|
+
return True
|
|
141
|
+
if not _closes_as_arithmetic_expansion(command, each_match.end() + 1):
|
|
142
|
+
return True
|
|
143
|
+
return False
|
|
144
|
+
|
|
145
|
+
|
|
70
146
|
def has_shell_substitution(command: str) -> bool:
|
|
71
147
|
"""Return True when a Bash command carries a live shell substitution.
|
|
72
148
|
|
|
@@ -79,18 +155,18 @@ def has_shell_substitution(command: str) -> bool:
|
|
|
79
155
|
echo $((2 + 2)) ok: arithmetic expansion
|
|
80
156
|
|
|
81
157
|
Single-quoted runs are stripped first because bash substitutes nothing
|
|
82
|
-
inside them. A backtick preceded by an odd
|
|
83
|
-
|
|
158
|
+
inside them. A backtick preceded by an odd backslash count is escaped.
|
|
159
|
+
A `$((` pair is walked to its terminator (module docstring) to tell
|
|
160
|
+
real arithmetic from a disguised subshell.
|
|
84
161
|
|
|
85
162
|
Args:
|
|
86
163
|
command: The raw Bash command string from the tool input.
|
|
87
164
|
|
|
88
165
|
Returns:
|
|
89
|
-
True when a substitution the
|
|
90
|
-
present outside single quotes.
|
|
166
|
+
True when a substitution the matcher cannot descend into is present.
|
|
91
167
|
"""
|
|
92
168
|
scannable_command = _strip_single_quoted_runs(command)
|
|
93
|
-
if
|
|
169
|
+
if _has_unsafe_dollar_paren_expansion(scannable_command):
|
|
94
170
|
return True
|
|
95
171
|
if EVEN_BACKSLASH_BACKTICK_PATTERN.search(scannable_command):
|
|
96
172
|
return True
|
|
@@ -261,6 +261,20 @@ def test_emit_decision_re_emits_silent_deny_fields(
|
|
|
261
261
|
assert emitted_payload["suppressOutput"] is True
|
|
262
262
|
|
|
263
263
|
|
|
264
|
+
def test_dispatcher_denies_subshell_disguised_as_arithmetic_expansion() -> None:
|
|
265
|
+
"""The dispatcher denies a `$((cd X) && Y)` subshell, not only the bare hook.
|
|
266
|
+
|
|
267
|
+
`echo $((cd /tmp) && pwd)` opens with a `$((` two-character lookahead
|
|
268
|
+
match, so a function-level test alone does not prove the block reaches
|
|
269
|
+
the live Bash tool path.
|
|
270
|
+
"""
|
|
271
|
+
payload_text = _bash_payload("echo $((cd /tmp) && pwd)")
|
|
272
|
+
decision, _reason = _decision_from_stdout(
|
|
273
|
+
_run_process(_DISPATCHER_SCRIPT, payload_text).stdout
|
|
274
|
+
)
|
|
275
|
+
assert decision == "deny"
|
|
276
|
+
|
|
277
|
+
|
|
264
278
|
def test_dispatch_emits_deny_immediately_and_skips_later_hooks(
|
|
265
279
|
monkeypatch: pytest.MonkeyPatch,
|
|
266
280
|
capsys: pytest.CaptureFixture[str],
|
|
@@ -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"
|