claude-dev-env 2.31.1 → 2.33.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/clean-coder.md +8 -7
- package/.agents/agents/code-quality-agent.md +4 -2
- package/.agents/agents/pr-description-writer.md +8 -3
- package/.agents/agents/reference/pr-description-illustrative-voice.md +6 -1
- package/.agents/agents/test_agent_frontmatter.py +0 -9
- package/.agents/agents/test_pr_description_writer_style.py +24 -1
- package/.agents/skills/e-simplify/SKILL.md +1 -15
- package/.agents/skills/grok-spawn/reference/worker-briefs.md +4 -0
- package/.agents/skills/pr-refinement/SKILL.md +2 -2
- package/.agents/skills/pr-shared-extraction/SKILL.md +2 -2
- package/.agents/skills/pr-title-description/SKILL.md +6 -2
- package/.agents/skills/review-tier/SKILL.md +1 -1
- package/.agents/skills/review-tier/reference/tier-policy.json +2 -2
- package/.agents/skills/review-tier/scripts/review_tier.py +31 -4
- package/.agents/skills/review-tier/scripts/test_generation.py +7 -19
- package/.agents/skills/review-tier/scripts/test_review_tier.py +11 -11
- package/AGENTS.md +83 -0
- package/_shared/pr-loop/code-rules-gate.md +5 -2
- package/_shared/pr-loop/scripts/code_rules_gate.py +7 -3
- package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_arguments.py +13 -1
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_arguments.py +7 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +47 -1
- package/audit-rubrics/audit-categories.json +2 -2
- package/audit-rubrics/category_rubrics/category-e-dead-code.md +1 -1
- package/audit-rubrics/category_rubrics/category-j-code-rules-compliance.md +5 -3
- package/audit-rubrics/category_rubrics/category-l-behavior-equivalence.md +6 -3
- package/audit-rubrics/prompts/category-e-dead-code.md +6 -6
- package/audit-rubrics/prompts/category-j-code-rules-compliance.md +25 -19
- package/audit-rubrics/prompts/category-l-behavior-equivalence.md +28 -25
- package/bin/install.test.mjs +11 -3
- package/docs/CODE_RULES.md +8 -9
- package/hooks/blocking/bash_post_call_dispatcher.py +70 -0
- package/hooks/blocking/bash_pre_tool_use_dispatcher.py +8 -21
- package/hooks/blocking/code_rules_comments.py +118 -59
- package/hooks/blocking/code_rules_duplicate_body.py +13 -7
- package/hooks/blocking/code_rules_enforcer.py +19 -7
- package/hooks/blocking/code_rules_enforcer_test_support.py +39 -0
- package/hooks/blocking/code_rules_type_escape.py +16 -23
- package/hooks/blocking/javascript_comment_scanner.py +99 -0
- package/hooks/blocking/shell_substitution_blocker.py +175 -8
- package/hooks/blocking/stop_dispatcher.py +6 -9
- package/hooks/blocking/tdd_enforcer.py +3 -1
- package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +7 -0
- package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +141 -23
- package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +114 -1
- package/hooks/blocking/test_bash_post_call_dispatcher.py +112 -0
- package/hooks/blocking/test_code_rules_enforcer_any_type_ignore.py +24 -4
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +0 -1
- package/hooks/blocking/test_code_rules_enforcer_comment_string_awareness.py +190 -3
- package/hooks/blocking/test_code_rules_enforcer_javascript_comments.py +44 -0
- package/hooks/blocking/test_code_rules_enforcer_join_separator_magic.py +0 -1
- package/hooks/blocking/test_code_rules_enforcer_same_file_inline_duplicate.py +8 -1
- package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +7 -2
- package/hooks/blocking/test_shell_substitution_blocker.py +105 -0
- package/hooks/blocking/test_state_description_blocker.py +56 -71
- package/hooks/blocking/test_tdd_enforcer.py +67 -1
- package/hooks/git-hooks/pull_request_handoff.py +1 -1
- package/hooks/git-hooks/test_post_commit.py +8 -0
- package/hooks/hooks.json +2 -2
- package/hooks/hooks_constants/bash_post_call_dispatcher_constants.py +22 -0
- package/hooks/hooks_constants/hosted_hook_runner.py +53 -0
- package/hooks/hooks_constants/piped_pytest_blocker_constants.py +9 -2
- package/hooks/hooks_constants/shell_substitution_blocker_constants.py +10 -0
- package/hooks/hooks_constants/stale_comment_reference_blocker_constants.py +3 -4
- package/hooks/hooks_constants/state_file_lock.py +76 -0
- package/hooks/hooks_constants/test_bash_post_call_dispatcher_constants.py +27 -0
- package/hooks/hooks_constants/test_failure_recorder_constants.py +35 -0
- package/hooks/hooks_constants/test_hosted_hook_runner.py +55 -1
- package/hooks/hooks_constants/test_unused_module_import_constants.py +9 -1
- package/hooks/hooks_constants/unused_module_import_constants.py +1 -1
- package/hooks/observability/session_file_edit_tracker.py +2 -55
- package/hooks/observability/test_failure_recorder.py +219 -0
- package/hooks/observability/test_test_failure_recorder.py +173 -0
- package/hooks/validators/comment_checks.py +2 -2
- package/hooks/validators/config/directory_exemption_constants.py +0 -9
- package/hooks/validators/config/system_temporary_roots_constants.py +15 -0
- package/hooks/validators/fast_save_validators.py +0 -9
- package/hooks/validators/git_checks.py +0 -84
- package/hooks/validators/mypy_integration.py +10 -36
- package/hooks/validators/project_roots.py +58 -0
- package/hooks/validators/run_all_validators.py +89 -42
- package/hooks/validators/system_temporary_roots.py +1 -1
- package/hooks/validators/test_fast_save_validators.py +2 -8
- package/hooks/validators/test_git_checks.py +0 -249
- package/hooks/validators/test_run_all_validators.py +22 -0
- package/hooks/validators/test_run_all_validators_mutation_tool_parity.py +249 -0
- package/hooks/validators/test_run_all_validators_reporting.py +66 -0
- package/hooks/validators/test_todo_checks.py +1 -0
- package/hooks/validators/todo_checks.py +1 -1
- package/package.json +1 -1
- package/rules/git-workflow.md +0 -8
- package/rules/shell-invocation.md +1 -1
- package/rules/testing.md +4 -0
- package/scripts/check.ps1 +35 -1
- package/scripts/codex_compat_materializer.py +1 -0
- package/scripts/dev_env_scripts_constants/grok_worker_constants.py +9 -5
- package/scripts/dev_env_scripts_constants/shared_tree_constants.py +17 -0
- package/scripts/shared_tree_paths.py +59 -25
- package/scripts/test_shared_tree_paths.py +30 -0
- package/scripts/tests/test_rules.py +111 -0
- package/system-prompts/software-engineer.xml +6 -19
- package/.agents/skills-archived/split-pr/SKILL.md +0 -85
- package/.agents/skills-archived/split-pr/reference/path-layers.md +0 -16
- package/.agents/skills-archived/split-pr/reference/proposal-format.md +0 -15
- package/.agents/skills-archived/split-pr/reference/split-further-loop.md +0 -10
- package/.agents/skills-archived/split-pr/reference/splitting-principles.md +0 -26
- package/.agents/skills-archived/split-pr/scripts/analyze_pr.py +0 -279
- package/.agents/skills-archived/split-pr/scripts/categorize_files.py +0 -106
- package/.agents/skills-archived/split-pr/scripts/config/__init__.py +0 -1
- package/.agents/skills-archived/split-pr/scripts/config/dependency_constants.py +0 -14
- package/.agents/skills-archived/split-pr/scripts/config/git_operations_constants.py +0 -36
- package/.agents/skills-archived/split-pr/scripts/config/packing_constants.py +0 -61
- package/.agents/skills-archived/split-pr/scripts/config/plan_constants.py +0 -49
- package/.agents/skills-archived/split-pr/scripts/config/split_pr_constants.py +0 -110
- package/.agents/skills-archived/split-pr/scripts/execute_split_slices.py +0 -82
- package/.agents/skills-archived/split-pr/scripts/pack_files_into_slices.py +0 -212
- package/.agents/skills-archived/split-pr/scripts/split_pr_dependency_graph.py +0 -70
- package/.agents/skills-archived/split-pr/scripts/split_pr_git_operations.py +0 -184
- package/.agents/skills-archived/split-pr/scripts/split_pr_layer_order.py +0 -58
- package/.agents/skills-archived/split-pr/scripts/split_pr_paginate.py +0 -119
- package/.agents/skills-archived/split-pr/scripts/split_pr_process_runner.py +0 -52
- package/.agents/skills-archived/split-pr/scripts/split_pr_script_types.py +0 -126
- package/.agents/skills-archived/split-pr/scripts/split_pr_title.py +0 -41
- package/.agents/skills-archived/split-pr/scripts/test_analyze_pr.py +0 -228
- package/.agents/skills-archived/split-pr/scripts/test_categorize_files.py +0 -55
- package/.agents/skills-archived/split-pr/scripts/test_categorize_files_packing.py +0 -59
- package/.agents/skills-archived/split-pr/scripts/test_execute_split_slices.py +0 -99
- package/.agents/skills-archived/split-pr/scripts/test_split_pr_dependency_graph.py +0 -47
- package/.agents/skills-archived/split-pr/scripts/test_split_pr_git_operations.py +0 -125
- package/.agents/skills-archived/split-pr/scripts/test_split_pr_layer_order.py +0 -36
- package/.agents/skills-archived/split-pr/scripts/test_split_pr_paginate.py +0 -65
- package/.agents/skills-archived/split-pr/scripts/test_split_pr_script_types.py +0 -73
- package/.agents/skills-archived/split-pr/scripts/test_split_pr_title.py +0 -28
- package/.agents/skills-archived/split-pr/scripts/test_verify_dependency_graph.py +0 -46
- package/.agents/skills-archived/split-pr/scripts/test_verify_plan.py +0 -56
- package/.agents/skills-archived/split-pr/scripts/test_verify_plan_contract.py +0 -50
- package/.agents/skills-archived/split-pr/scripts/test_verify_plan_path_normalization.py +0 -45
- package/.agents/skills-archived/split-pr/scripts/verify_dependency_graph.py +0 -111
- package/.agents/skills-archived/split-pr/scripts/verify_plan.py +0 -139
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PostToolUse dispatcher that hosts the Bash hook chain running after a call.
|
|
3
|
+
|
|
4
|
+
Named ``bash_post_call_dispatcher`` rather than the more obvious
|
|
5
|
+
``bash_post_tool_use_dispatcher``: ``bin/install.test.mjs`` counts every
|
|
6
|
+
PostToolUse command whose text contains the literal substring
|
|
7
|
+
``post_tool_use_dispatcher.py`` and asserts exactly one match, against the
|
|
8
|
+
unrelated Write/Edit dispatcher in ``validation/post_tool_use_dispatcher.py``.
|
|
9
|
+
That check carries no basename anchor (its PreToolUse sibling does), so a
|
|
10
|
+
correctly-named Bash-side sibling here would read as a second match of an
|
|
11
|
+
unrelated dispatcher. This name carries the same meaning without the
|
|
12
|
+
collision, and needs no change to that shared, out-of-scope test file.
|
|
13
|
+
|
|
14
|
+
Reads the tool payload from stdin once, selects the hosted hooks applicable to
|
|
15
|
+
the payload's tool name, and runs each one in-process via the shared
|
|
16
|
+
hosted-hook runner. A hosted PostToolUse hook here carries no permission
|
|
17
|
+
decision -- it is a side-effecting observer, not a gate -- so this dispatcher
|
|
18
|
+
runs the whole roster unconditionally and emits nothing itself, matching every
|
|
19
|
+
hosted hook's own empty-stdout contract.
|
|
20
|
+
|
|
21
|
+
A single hosted hook crash fails open: ``run_hook_capturing_output`` isolates
|
|
22
|
+
it, so it contributes nothing and does not stop the remaining hosted hooks.
|
|
23
|
+
|
|
24
|
+
One interpreter start here serves the whole roster: every hosted hook's
|
|
25
|
+
imports load once into this process rather than once per hook.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
import _path_setup # noqa: F401
|
|
32
|
+
|
|
33
|
+
from hooks_constants.bash_post_call_dispatcher_constants import (
|
|
34
|
+
ALL_BASH_POST_TOOL_USE_HOSTED_HOOK_ENTRIES,
|
|
35
|
+
)
|
|
36
|
+
from hooks_constants.bash_pre_tool_use_dispatcher_constants import BashHostedHookEntry
|
|
37
|
+
from hooks_constants.hosted_hook_runner import (
|
|
38
|
+
run_hook_capturing_output,
|
|
39
|
+
resolved_hook_script_path,
|
|
40
|
+
run_dispatcher_main,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def select_applicable_entries(tool_name: str) -> list[BashHostedHookEntry]:
|
|
45
|
+
"""Return the ordered hosted-hook entries that apply to tool_name."""
|
|
46
|
+
return [
|
|
47
|
+
each_entry
|
|
48
|
+
for each_entry in ALL_BASH_POST_TOOL_USE_HOSTED_HOOK_ENTRIES
|
|
49
|
+
if tool_name in each_entry.applicable_tool_names
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def dispatch(payload_text: str, tool_name: str) -> None:
|
|
54
|
+
"""Run every hosted hook applicable to tool_name, in registration order.
|
|
55
|
+
|
|
56
|
+
Each hosted hook's crash is isolated by ``run_hook_capturing_output``, so
|
|
57
|
+
one hook failing never stops a later one in the same roster.
|
|
58
|
+
"""
|
|
59
|
+
for each_entry in select_applicable_entries(tool_name):
|
|
60
|
+
script_path = resolved_hook_script_path(each_entry.script_relative_path)
|
|
61
|
+
run_hook_capturing_output(script_path, payload_text)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def main() -> None:
|
|
65
|
+
"""Read stdin once and dispatch the Bash PostToolUse hosted-hook roster."""
|
|
66
|
+
run_dispatcher_main(dispatch)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
if __name__ == "__main__":
|
|
70
|
+
main()
|
|
@@ -22,7 +22,6 @@ from __future__ import annotations
|
|
|
22
22
|
import json
|
|
23
23
|
import sys
|
|
24
24
|
from dataclasses import dataclass, field
|
|
25
|
-
from pathlib import Path
|
|
26
25
|
|
|
27
26
|
import _path_setup # noqa: F401
|
|
28
27
|
|
|
@@ -36,8 +35,12 @@ from hooks_constants.bash_pre_tool_use_dispatcher_constants import (
|
|
|
36
35
|
REASON_JOIN_SEPARATOR,
|
|
37
36
|
BashHostedHookEntry,
|
|
38
37
|
)
|
|
39
|
-
from hooks_constants.hosted_hook_runner import
|
|
40
|
-
|
|
38
|
+
from hooks_constants.hosted_hook_runner import (
|
|
39
|
+
run_dispatcher_main,
|
|
40
|
+
run_hook_capturing_output,
|
|
41
|
+
HostedHookRun,
|
|
42
|
+
resolved_hook_script_path,
|
|
43
|
+
)
|
|
41
44
|
|
|
42
45
|
_PERMISSION_DECISION_KEY = "permissionDecision"
|
|
43
46
|
_PERMISSION_REASON_KEY = "permissionDecisionReason"
|
|
@@ -203,12 +206,6 @@ def aggregate_bash_hook_results(
|
|
|
203
206
|
)
|
|
204
207
|
|
|
205
208
|
|
|
206
|
-
def _resolve_hook_script_path(relative_path: str) -> str:
|
|
207
|
-
"""Resolve a hooks/-relative path to an absolute script path."""
|
|
208
|
-
hooks_root = Path(__file__).resolve().parent.parent
|
|
209
|
-
return str(hooks_root / relative_path)
|
|
210
|
-
|
|
211
|
-
|
|
212
209
|
def _emit_decision(decision: BashDispatcherDecision) -> None:
|
|
213
210
|
"""Write one PreToolUse permission payload to stdout when an outcome exists."""
|
|
214
211
|
if not decision.decision:
|
|
@@ -251,7 +248,7 @@ def dispatch(payload_text: str, tool_name: str) -> None:
|
|
|
251
248
|
applicable_entries = select_applicable_entries(tool_name)
|
|
252
249
|
all_runs: list[HostedHookRun] = []
|
|
253
250
|
for each_entry in applicable_entries:
|
|
254
|
-
script_path =
|
|
251
|
+
script_path = resolved_hook_script_path(each_entry.script_relative_path)
|
|
255
252
|
hook_run = run_hook_capturing_output(script_path, payload_text)
|
|
256
253
|
all_runs.append(hook_run)
|
|
257
254
|
if _run_is_deny(hook_run):
|
|
@@ -264,17 +261,7 @@ def dispatch(payload_text: str, tool_name: str) -> None:
|
|
|
264
261
|
|
|
265
262
|
def main() -> None:
|
|
266
263
|
"""Read stdin once and dispatch the Bash/PowerShell hosted-hook chain."""
|
|
267
|
-
|
|
268
|
-
if payload_dictionary is None:
|
|
269
|
-
sys.exit(0)
|
|
270
|
-
|
|
271
|
-
payload_text = json.dumps(payload_dictionary)
|
|
272
|
-
tool_name = payload_dictionary.get("tool_name", "")
|
|
273
|
-
if not isinstance(tool_name, str) or not tool_name:
|
|
274
|
-
sys.exit(0)
|
|
275
|
-
|
|
276
|
-
dispatch(payload_text, tool_name)
|
|
277
|
-
sys.exit(0)
|
|
264
|
+
run_dispatcher_main(dispatch)
|
|
278
265
|
|
|
279
266
|
|
|
280
267
|
if __name__ == "__main__":
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
"""Comment-presence and comment-change checks for Python and JavaScript sources."""
|
|
2
2
|
|
|
3
|
+
import difflib
|
|
4
|
+
import importlib
|
|
3
5
|
import io
|
|
4
6
|
import sys
|
|
5
7
|
import tokenize
|
|
8
|
+
from collections import Counter
|
|
6
9
|
from collections.abc import Iterator
|
|
7
10
|
from pathlib import Path
|
|
11
|
+
from typing import cast
|
|
8
12
|
|
|
9
13
|
_blocking_directory = str(Path(__file__).resolve().parent)
|
|
10
14
|
_hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
@@ -20,7 +24,6 @@ from code_rules_shared import ( # noqa: E402
|
|
|
20
24
|
from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
|
|
21
25
|
ALL_FREE_FORM_EXEMPT_COMMENT_BODIES,
|
|
22
26
|
ALL_JAVASCRIPT_EXEMPT_COMMENT_PREFIXES,
|
|
23
|
-
ALL_JAVASCRIPT_EXEMPT_INLINE_COMMENT_PREFIXES,
|
|
24
27
|
ALL_JAVASCRIPT_EXTENSIONS,
|
|
25
28
|
ALL_PYTHON_EXTENSIONS,
|
|
26
29
|
ALL_PYTHON_TOKENIZE_FAILURE_EXCEPTIONS,
|
|
@@ -29,6 +32,10 @@ from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
|
|
|
29
32
|
CHAINED_INLINE_COMMENT_PATTERN,
|
|
30
33
|
MAX_COMMENT_ISSUES,
|
|
31
34
|
)
|
|
35
|
+
_javascript_comment_scanner = importlib.import_module("javascript_comment_scanner")
|
|
36
|
+
extract_javascript_comment_occurrences = (
|
|
37
|
+
_javascript_comment_scanner.extract_javascript_comment_occurrences
|
|
38
|
+
)
|
|
32
39
|
|
|
33
40
|
|
|
34
41
|
def check_comments_python(content: str) -> list[str]:
|
|
@@ -93,7 +100,9 @@ def check_comments_javascript(content: str) -> list[str]:
|
|
|
93
100
|
return issues
|
|
94
101
|
|
|
95
102
|
|
|
96
|
-
def extract_comment_texts(
|
|
103
|
+
def extract_comment_texts(
|
|
104
|
+
content: str, file_path: str, include_directive_comments: bool = False
|
|
105
|
+
) -> tuple[set[str], set[str]]:
|
|
97
106
|
"""Extract normalized comment text strings from content for comparison.
|
|
98
107
|
|
|
99
108
|
Returns:
|
|
@@ -108,46 +117,58 @@ def extract_comment_texts(content: str, file_path: str) -> tuple[set[str], set[s
|
|
|
108
117
|
return inline_comments, standalone_comments
|
|
109
118
|
|
|
110
119
|
if extension in ALL_PYTHON_EXTENSIONS:
|
|
111
|
-
inline_comments, standalone_comments, _ = _extract_python_comment_sets(
|
|
120
|
+
inline_comments, standalone_comments, _ = _extract_python_comment_sets(
|
|
121
|
+
content, include_directive_comments
|
|
122
|
+
)
|
|
112
123
|
return inline_comments, standalone_comments
|
|
113
124
|
|
|
114
|
-
lines = content.split("\n")
|
|
115
|
-
|
|
116
125
|
if extension in ALL_JAVASCRIPT_EXTENSIONS:
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
if stripped.startswith("//"):
|
|
132
|
-
if not stripped.startswith(ALL_JAVASCRIPT_EXEMPT_COMMENT_PREFIXES):
|
|
133
|
-
standalone_comments.add(stripped)
|
|
134
|
-
elif "//" in each_line:
|
|
135
|
-
before_slash = each_line[:each_line.index("//")]
|
|
136
|
-
if before_slash.strip():
|
|
137
|
-
comment_start = stripped.index("//")
|
|
138
|
-
comment_text = stripped[comment_start + 2 :].strip()
|
|
139
|
-
if not comment_text.startswith(ALL_JAVASCRIPT_EXEMPT_INLINE_COMMENT_PREFIXES):
|
|
140
|
-
inline_comments.add(stripped[comment_start:])
|
|
126
|
+
all_occurrences: list[tuple[str, int, bool]] = cast(
|
|
127
|
+
list[tuple[str, int, bool]],
|
|
128
|
+
extract_javascript_comment_occurrences(content, include_directive_comments),
|
|
129
|
+
)
|
|
130
|
+
inline_comments = {
|
|
131
|
+
each_text
|
|
132
|
+
for each_text, _each_line_number, is_inline in all_occurrences
|
|
133
|
+
if is_inline
|
|
134
|
+
}
|
|
135
|
+
standalone_comments = {
|
|
136
|
+
each_text
|
|
137
|
+
for each_text, _each_line_number, is_inline in all_occurrences
|
|
138
|
+
if not is_inline
|
|
139
|
+
}
|
|
141
140
|
|
|
142
141
|
return inline_comments, standalone_comments
|
|
143
142
|
|
|
144
143
|
|
|
144
|
+
def _python_comment_occurrences(
|
|
145
|
+
content: str,
|
|
146
|
+
) -> tuple[list[tuple[str, int, bool]], bool]:
|
|
147
|
+
"""Return Python comments with exact lines and a tokenize status."""
|
|
148
|
+
source_lines = content.split("\n")
|
|
149
|
+
try:
|
|
150
|
+
all_tokens = list(_python_tokens(content))
|
|
151
|
+
except ALL_PYTHON_TOKENIZE_FAILURE_EXCEPTIONS:
|
|
152
|
+
return [], False
|
|
153
|
+
all_occurrences: list[tuple[str, int, bool]] = []
|
|
154
|
+
for each_token in all_tokens:
|
|
155
|
+
if each_token.type != tokenize.COMMENT:
|
|
156
|
+
continue
|
|
157
|
+
if each_token.string.startswith("#!") and each_token.start == (1, 0):
|
|
158
|
+
continue
|
|
159
|
+
source_line = source_lines[each_token.start[0] - 1]
|
|
160
|
+
is_inline = bool(source_line[: each_token.start[1]].strip())
|
|
161
|
+
all_occurrences.append(
|
|
162
|
+
(each_token.string.strip(), each_token.start[0], is_inline)
|
|
163
|
+
)
|
|
164
|
+
return all_occurrences, True
|
|
165
|
+
|
|
166
|
+
|
|
145
167
|
def check_comment_changes(old_content: str, new_content: str, file_path: str) -> list[str]:
|
|
146
168
|
"""Check for comment additions or removals between old and new content.
|
|
147
169
|
|
|
148
|
-
Inline
|
|
149
|
-
|
|
150
|
-
Existing comments being removed: NUDGE (print advisory) — comment preservation is advisory.
|
|
170
|
+
Inline and standalone comments are blocking findings when added.
|
|
171
|
+
Existing comments can be removed when the touched code no longer needs them.
|
|
151
172
|
|
|
152
173
|
When the file is Python and either *old_content* or *new_content* cannot
|
|
153
174
|
be tokenized (common for mid-edit Edit fragments), the comparison is
|
|
@@ -161,38 +182,71 @@ def check_comment_changes(old_content: str, new_content: str, file_path: str) ->
|
|
|
161
182
|
|
|
162
183
|
extension = get_file_extension(file_path)
|
|
163
184
|
if extension in ALL_PYTHON_EXTENSIONS:
|
|
164
|
-
|
|
165
|
-
|
|
185
|
+
old_occurrences, old_tokenize_ok = _python_comment_occurrences(old_content)
|
|
186
|
+
new_occurrences, new_tokenize_ok = _python_comment_occurrences(new_content)
|
|
166
187
|
if not (old_tokenize_ok and new_tokenize_ok):
|
|
167
188
|
return issues
|
|
168
189
|
else:
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
if not code_was_removed:
|
|
190
|
-
sample = next(iter(removed_comments))
|
|
191
|
-
print(f"[CODE_RULES advisory] Existing comment removed: {sample[:60]} - comment preservation is advisory", file=sys.stderr)
|
|
190
|
+
old_occurrences = extract_javascript_comment_occurrences(old_content, True)
|
|
191
|
+
new_occurrences = extract_javascript_comment_occurrences(new_content, True)
|
|
192
|
+
|
|
193
|
+
old_occurrence_counts = Counter(
|
|
194
|
+
(each_text, is_inline)
|
|
195
|
+
for each_text, _line_number, is_inline in old_occurrences
|
|
196
|
+
)
|
|
197
|
+
seen_occurrence_counts: Counter[tuple[str, bool]] = Counter()
|
|
198
|
+
for each_text, each_line_number, each_is_inline in new_occurrences:
|
|
199
|
+
occurrence_key = (each_text, each_is_inline)
|
|
200
|
+
if seen_occurrence_counts[occurrence_key] >= old_occurrence_counts[occurrence_key]:
|
|
201
|
+
comment_kind = "Inline" if each_is_inline else "Standalone"
|
|
202
|
+
issues.append(
|
|
203
|
+
f"Line {each_line_number}: {comment_kind} comment added: "
|
|
204
|
+
f"{each_text[:60]} - refactor to self-documenting code"
|
|
205
|
+
)
|
|
206
|
+
seen_occurrence_counts[occurrence_key] += 1
|
|
207
|
+
if len(issues) >= MAX_COMMENT_ISSUES:
|
|
208
|
+
break
|
|
209
|
+
issues.extend(_retained_comment_issues(old_content, new_content, file_path))
|
|
192
210
|
|
|
193
211
|
return issues
|
|
194
212
|
|
|
195
213
|
|
|
214
|
+
def _changed_line_numbers(old_content: str, new_content: str) -> set[int]:
|
|
215
|
+
matcher = difflib.SequenceMatcher(
|
|
216
|
+
None, old_content.splitlines(), new_content.splitlines(), autojunk=False
|
|
217
|
+
)
|
|
218
|
+
return {
|
|
219
|
+
each_line_number
|
|
220
|
+
for each_tag, _, _, each_new_start, each_new_end in matcher.get_opcodes()
|
|
221
|
+
if each_tag != "equal"
|
|
222
|
+
for each_line_number in range(each_new_start + 1, each_new_end + 1)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _retained_comment_issues(
|
|
227
|
+
old_content: str, new_content: str, file_path: str
|
|
228
|
+
) -> list[str]:
|
|
229
|
+
old_inline, old_standalone = extract_comment_texts(old_content, file_path, True)
|
|
230
|
+
new_inline, new_standalone = extract_comment_texts(new_content, file_path, True)
|
|
231
|
+
shared_inline = set(Counter(old_inline) & Counter(new_inline))
|
|
232
|
+
shared_standalone = old_standalone & new_standalone
|
|
233
|
+
if not shared_inline and not shared_standalone:
|
|
234
|
+
return []
|
|
235
|
+
changed_lines = _changed_line_numbers(old_content, new_content)
|
|
236
|
+
new_lines = new_content.splitlines()
|
|
237
|
+
issues: list[str] = []
|
|
238
|
+
for each_line_number in sorted(changed_lines):
|
|
239
|
+
inline_comment = next((each_comment for each_comment in shared_inline if each_comment in new_lines[each_line_number - 1]), None)
|
|
240
|
+
if inline_comment is not None:
|
|
241
|
+
issues.append(f"Line {each_line_number}: Inline comment retained on changed code: {inline_comment} - remove the comment")
|
|
242
|
+
preceding_line_number = each_line_number - 1
|
|
243
|
+
if preceding_line_number > 0 and new_lines[preceding_line_number - 1].strip() in shared_standalone:
|
|
244
|
+
issues.append(f"Line {each_line_number}: Standalone comment retained on changed code at line {preceding_line_number}: {new_lines[preceding_line_number - 1].strip()} - remove the comment")
|
|
245
|
+
if len(issues) >= MAX_COMMENT_ISSUES:
|
|
246
|
+
break
|
|
247
|
+
return issues[:MAX_COMMENT_ISSUES]
|
|
248
|
+
|
|
249
|
+
|
|
196
250
|
def _python_tokens(source: str) -> Iterator[tokenize.TokenInfo]:
|
|
197
251
|
"""Yield Python tokens from *source* one at a time.
|
|
198
252
|
|
|
@@ -304,7 +358,9 @@ def _starts_with_bounded_token_anchored_directive(directive_body: str) -> bool:
|
|
|
304
358
|
return False
|
|
305
359
|
|
|
306
360
|
|
|
307
|
-
def _extract_python_comment_sets(
|
|
361
|
+
def _extract_python_comment_sets(
|
|
362
|
+
content: str, include_directive_comments: bool = False
|
|
363
|
+
) -> tuple[set[str], set[str], bool]:
|
|
308
364
|
"""Return (inline_comments, standalone_comments, tokenize_succeeded).
|
|
309
365
|
|
|
310
366
|
Streams *content* once via ``_python_tokens``. A tokenize failure
|
|
@@ -321,7 +377,10 @@ def _extract_python_comment_sets(content: str) -> tuple[set[str], set[str], bool
|
|
|
321
377
|
for each_token in _python_tokens(content):
|
|
322
378
|
if each_token.type != tokenize.COMMENT:
|
|
323
379
|
continue
|
|
324
|
-
|
|
380
|
+
is_shebang = each_token.string.startswith("#!") and each_token.start == (1, 0)
|
|
381
|
+
if is_shebang or (
|
|
382
|
+
not include_directive_comments and _is_exempt_python_comment(each_token)
|
|
383
|
+
):
|
|
325
384
|
continue
|
|
326
385
|
line_number = each_token.start[0]
|
|
327
386
|
column_offset = each_token.start[1]
|
|
@@ -492,6 +492,7 @@ def _total_reachable_statement_count(
|
|
|
492
492
|
|
|
493
493
|
def _helper_match_window_dumps(
|
|
494
494
|
function_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
495
|
+
dump_by_statement: dict[ast.stmt, str],
|
|
495
496
|
) -> list[str] | None:
|
|
496
497
|
"""Return the helper's substantive block as per-statement dumps, or None.
|
|
497
498
|
|
|
@@ -541,7 +542,10 @@ def _helper_match_window_dumps(
|
|
|
541
542
|
)
|
|
542
543
|
if not has_substantial_compound:
|
|
543
544
|
return None
|
|
544
|
-
return [
|
|
545
|
+
return [
|
|
546
|
+
_memoized_statement_dump(each_statement, dump_by_statement)
|
|
547
|
+
for each_statement in match_window
|
|
548
|
+
]
|
|
545
549
|
|
|
546
550
|
|
|
547
551
|
def check_same_file_inline_duplicate_body(
|
|
@@ -763,14 +767,14 @@ def _inline_duplicate_violation(
|
|
|
763
767
|
|
|
764
768
|
|
|
765
769
|
def _violations_for_qualifying_helpers(
|
|
766
|
-
window_by_function: dict[_ModuleScopeFunction, list[str]
|
|
770
|
+
window_by_function: dict[_ModuleScopeFunction, list[str]],
|
|
767
771
|
profile_by_function: dict[_ModuleScopeFunction, _FunctionScanProfile],
|
|
768
772
|
maximum_issue_count: int,
|
|
769
773
|
) -> list[tuple[frozenset[int], str]]:
|
|
770
774
|
"""Walk the helpers in source order and report the first inliner of each.
|
|
771
775
|
|
|
772
776
|
Args:
|
|
773
|
-
window_by_function: Each function's helper match window
|
|
777
|
+
window_by_function: Each qualifying function's helper match window.
|
|
774
778
|
profile_by_function: Each function's readings, keyed in source order.
|
|
775
779
|
maximum_issue_count: The report ceiling the walk stops at.
|
|
776
780
|
|
|
@@ -779,8 +783,6 @@ def _violations_for_qualifying_helpers(
|
|
|
779
783
|
"""
|
|
780
784
|
all_violations_in_walk_order: list[tuple[frozenset[int], str]] = []
|
|
781
785
|
for each_helper, each_window_dumps in window_by_function.items():
|
|
782
|
-
if each_window_dumps is None:
|
|
783
|
-
continue
|
|
784
786
|
each_enclosing = _first_enclosing_inliner(
|
|
785
787
|
each_helper, each_window_dumps, profile_by_function
|
|
786
788
|
)
|
|
@@ -811,10 +813,14 @@ def _inline_duplicate_violations(
|
|
|
811
813
|
"""
|
|
812
814
|
dump_by_statement: dict[ast.stmt, str] = {}
|
|
813
815
|
window_by_function = {
|
|
814
|
-
each_function:
|
|
816
|
+
each_function: each_window
|
|
815
817
|
for each_function in all_top_level_functions
|
|
818
|
+
if (
|
|
819
|
+
each_window := _helper_match_window_dumps(each_function, dump_by_statement)
|
|
820
|
+
)
|
|
821
|
+
is not None
|
|
816
822
|
}
|
|
817
|
-
if
|
|
823
|
+
if not window_by_function:
|
|
818
824
|
return []
|
|
819
825
|
profile_by_function = {
|
|
820
826
|
each_function: _function_scan_profile(each_function, dump_by_statement)
|
|
@@ -196,6 +196,8 @@ from hooks_constants.subprocess_budget_completeness_content import ( # noqa: E4
|
|
|
196
196
|
existing_file_content,
|
|
197
197
|
)
|
|
198
198
|
|
|
199
|
+
__all__ = ("is_test_file",)
|
|
200
|
+
|
|
199
201
|
|
|
200
202
|
def _multiedit_post_edit_view(
|
|
201
203
|
file_path: str, all_tool_input: dict[str, object]
|
|
@@ -234,6 +236,7 @@ class _ValidationContext(NamedTuple):
|
|
|
234
236
|
defer_scope_to_caller: bool
|
|
235
237
|
sibling_directory: Path | None
|
|
236
238
|
phase: str
|
|
239
|
+
include_comment_policy: bool = True
|
|
237
240
|
|
|
238
241
|
|
|
239
242
|
def _validated_phase(phase: str) -> str:
|
|
@@ -325,7 +328,7 @@ def _python_comment_and_logging_issues(context: _ValidationContext) -> list[str]
|
|
|
325
328
|
context.file_path,
|
|
326
329
|
)
|
|
327
330
|
all_issues: list[str] = []
|
|
328
|
-
if
|
|
331
|
+
if context.phase == FULL_GATE_PHASE and context.include_comment_policy:
|
|
329
332
|
all_issues.extend(check_comment_changes(old_content, content, file_path))
|
|
330
333
|
all_issues.extend(check_imports_at_top(content))
|
|
331
334
|
all_issues.extend(check_logging_fstrings(content))
|
|
@@ -366,7 +369,15 @@ def _python_duplicate_body_and_banned_issues(context: _ValidationContext) -> lis
|
|
|
366
369
|
all_issues = check_same_file_inline_duplicate_body(
|
|
367
370
|
effective_content, file_path, changed, defer
|
|
368
371
|
)
|
|
369
|
-
all_issues.extend(
|
|
372
|
+
all_issues.extend(
|
|
373
|
+
check_type_escape_hatches(
|
|
374
|
+
effective_content,
|
|
375
|
+
file_path,
|
|
376
|
+
include_type_ignore_comments=(
|
|
377
|
+
context.phase == FULL_GATE_PHASE and context.include_comment_policy
|
|
378
|
+
),
|
|
379
|
+
)
|
|
380
|
+
)
|
|
370
381
|
all_issues.extend(
|
|
371
382
|
check_banned_identifiers(effective_content, file_path, changed, defer)
|
|
372
383
|
)
|
|
@@ -576,7 +587,7 @@ def _javascript_comment_and_naming_issues(context: _ValidationContext) -> list[s
|
|
|
576
587
|
context.file_path,
|
|
577
588
|
)
|
|
578
589
|
all_issues: list[str] = []
|
|
579
|
-
if
|
|
590
|
+
if context.phase == FULL_GATE_PHASE and context.include_comment_policy:
|
|
580
591
|
all_issues.extend(check_comment_changes(old_content, content, file_path))
|
|
581
592
|
all_issues.extend(check_e2e_test_naming(content, file_path))
|
|
582
593
|
all_issues.extend(
|
|
@@ -642,6 +653,7 @@ def validate_content_for_phase(
|
|
|
642
653
|
sibling_directory: Path | None = None,
|
|
643
654
|
*,
|
|
644
655
|
phase: str,
|
|
656
|
+
include_comment_policy: bool = True,
|
|
645
657
|
) -> list[str]:
|
|
646
658
|
"""Run all applicable validators on content for one named validation phase."""
|
|
647
659
|
validated_phase = _validated_phase(phase)
|
|
@@ -649,10 +661,7 @@ def validate_content_for_phase(
|
|
|
649
661
|
effective_content, all_changed_lines = _effective_content_and_changed_lines(
|
|
650
662
|
content, full_file_content, prior_full_file_content
|
|
651
663
|
)
|
|
652
|
-
context = _ValidationContext(
|
|
653
|
-
content, old_content, effective_content, file_path, all_changed_lines,
|
|
654
|
-
defer_scope_to_caller, sibling_directory, validated_phase,
|
|
655
|
-
)
|
|
664
|
+
context = _ValidationContext(content, old_content, effective_content, file_path, all_changed_lines, defer_scope_to_caller, sibling_directory, validated_phase, include_comment_policy)
|
|
656
665
|
all_issues: list[str] = []
|
|
657
666
|
if extension in ALL_PYTHON_EXTENSIONS:
|
|
658
667
|
all_issues = _python_extension_issues(context)
|
|
@@ -693,6 +702,8 @@ def validate_content_for_full_gate(
|
|
|
693
702
|
prior_full_file_content: str = "",
|
|
694
703
|
defer_scope_to_caller: bool = False,
|
|
695
704
|
sibling_directory: Path | None = None,
|
|
705
|
+
*,
|
|
706
|
+
include_comment_policy: bool = True,
|
|
696
707
|
) -> list[str]:
|
|
697
708
|
"""Run the full-gate phase, including the six checks that read sibling files."""
|
|
698
709
|
return validate_content_for_phase(
|
|
@@ -704,6 +715,7 @@ def validate_content_for_full_gate(
|
|
|
704
715
|
defer_scope_to_caller,
|
|
705
716
|
sibling_directory,
|
|
706
717
|
phase=FULL_GATE_PHASE,
|
|
718
|
+
include_comment_policy=include_comment_policy,
|
|
707
719
|
)
|
|
708
720
|
|
|
709
721
|
|
|
@@ -14,6 +14,45 @@ from pathlib import Path
|
|
|
14
14
|
_ENFORCER_SCRIPT_PATH = Path(__file__).with_name("code_rules_enforcer.py")
|
|
15
15
|
|
|
16
16
|
|
|
17
|
+
def repository_root() -> Path:
|
|
18
|
+
"""Return the root of the checkout these tests run inside.
|
|
19
|
+
|
|
20
|
+
::
|
|
21
|
+
|
|
22
|
+
<checkout>/packages/claude-dev-env/hooks/blocking/test_x.py -> <checkout>
|
|
23
|
+
|
|
24
|
+
Found by walking up for a ``.git`` entry rather than by counting
|
|
25
|
+
directories, so moving a test file does not silently anchor it somewhere
|
|
26
|
+
else. Falls back to the four-levels-up ancestor when no marker is found,
|
|
27
|
+
which is where the checkout root sits for this directory.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
The checkout root directory.
|
|
31
|
+
"""
|
|
32
|
+
all_ancestors = Path(__file__).resolve().parents
|
|
33
|
+
for each_ancestor in all_ancestors:
|
|
34
|
+
if (each_ancestor / ".git").exists():
|
|
35
|
+
return each_ancestor
|
|
36
|
+
return all_ancestors[3]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def repository_file_path(relative_path: str) -> str:
|
|
40
|
+
"""Return the absolute path of a repository-relative file in this checkout.
|
|
41
|
+
|
|
42
|
+
A hook resolves a bare relative path against the process working
|
|
43
|
+
directory, so a payload carrying one answers to wherever pytest was
|
|
44
|
+
invoked from. An absolute path under the checkout root keeps the
|
|
45
|
+
decision stable however the suite is launched.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
relative_path: The target file's path relative to the checkout root.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
That file's absolute path inside this checkout.
|
|
52
|
+
"""
|
|
53
|
+
return str(repository_root() / relative_path)
|
|
54
|
+
|
|
55
|
+
|
|
17
56
|
def build_write_payload(file_path: str, content: str) -> str:
|
|
18
57
|
"""Build the JSON payload sent by a Write tool call."""
|
|
19
58
|
return json.dumps(
|
|
@@ -79,23 +79,13 @@ def _find_any_annotation_lines(source: str) -> list[int]:
|
|
|
79
79
|
return offending_line_numbers
|
|
80
80
|
|
|
81
81
|
|
|
82
|
-
def
|
|
83
|
-
"""Return line numbers of
|
|
82
|
+
def _find_type_ignore_lines(source: str) -> list[int]:
|
|
83
|
+
"""Return line numbers of all type-ignore comments."""
|
|
84
84
|
ignore_pattern = re.compile(r"#\s*type:\s*ignore(?:\[[^\]]*\])?(.*)$")
|
|
85
|
-
minimum_justification_characters = len("xxxxx")
|
|
86
85
|
offending_line_numbers: list[int] = []
|
|
87
86
|
for each_comment_token in _comment_tokens(source):
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
continue
|
|
91
|
-
line_number = each_comment_token.start[0]
|
|
92
|
-
trailing_text = matched.group(1).strip()
|
|
93
|
-
if not trailing_text.startswith("#"):
|
|
94
|
-
offending_line_numbers.append(line_number)
|
|
95
|
-
continue
|
|
96
|
-
justification_text = trailing_text.lstrip("#").strip()
|
|
97
|
-
if len(justification_text) < minimum_justification_characters:
|
|
98
|
-
offending_line_numbers.append(line_number)
|
|
87
|
+
if ignore_pattern.search(each_comment_token.string):
|
|
88
|
+
offending_line_numbers.append(each_comment_token.start[0])
|
|
99
89
|
return offending_line_numbers
|
|
100
90
|
|
|
101
91
|
|
|
@@ -625,12 +615,20 @@ def _find_object_annotated_parameter_lines(source: str) -> list[tuple[int, str]]
|
|
|
625
615
|
return offending_parameters
|
|
626
616
|
|
|
627
617
|
|
|
628
|
-
def check_type_escape_hatches(
|
|
629
|
-
|
|
618
|
+
def check_type_escape_hatches(
|
|
619
|
+
content: str, file_path: str, include_type_ignore_comments: bool = True
|
|
620
|
+
) -> list[str]:
|
|
621
|
+
"""Flag type escape hatches and every type-ignore comment."""
|
|
622
|
+
issues: list[str] = []
|
|
623
|
+
type_ignore_issues: list[str] = []
|
|
624
|
+
if include_type_ignore_comments:
|
|
625
|
+
for each_ignore_line in _find_type_ignore_lines(content):
|
|
626
|
+
type_ignore_issues.append(
|
|
627
|
+
f"Line {each_ignore_line}: # type: ignore is not allowed - remove it and use a typed boundary or real type"
|
|
628
|
+
)
|
|
630
629
|
if is_test_file(file_path) or is_hook_infrastructure(file_path):
|
|
631
|
-
return
|
|
630
|
+
return type_ignore_issues
|
|
632
631
|
|
|
633
|
-
issues: list[str] = []
|
|
634
632
|
is_any_exempt = _file_path_matches_any_exemption(file_path)
|
|
635
633
|
|
|
636
634
|
if not is_any_exempt:
|
|
@@ -670,11 +668,6 @@ def check_type_escape_hatches(content: str, file_path: str) -> list[str]:
|
|
|
670
668
|
)
|
|
671
669
|
issues.extend(object_parameter_issues[:MAX_TYPE_ESCAPE_HATCH_ISSUES])
|
|
672
670
|
|
|
673
|
-
type_ignore_issues: list[str] = []
|
|
674
|
-
for each_ignore_line in _find_unjustified_type_ignore_lines(content):
|
|
675
|
-
type_ignore_issues.append(
|
|
676
|
-
f"Line {each_ignore_line}: Unjustified # type: ignore - add trailing '# reason' explaining why"
|
|
677
|
-
)
|
|
678
671
|
issues.extend(type_ignore_issues[:MAX_TYPE_ESCAPE_HATCH_ISSUES])
|
|
679
672
|
|
|
680
673
|
return issues
|