claude-dev-env 1.19.0 → 1.19.2
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/docs/CODE_RULES.md +24 -2
- package/hooks/blocking/code-rules-enforcer.py +33 -13
- package/hooks/blocking/content-search-to-zoekt-redirector.py +6 -2
- package/hooks/blocking/content_search_zoekt_block_payload.py +9 -5
- package/hooks/blocking/content_search_zoekt_redirect_guidance.py +7 -1
- package/hooks/blocking/test_content_search_to_zoekt_redirector_integration.py +8 -1
- package/hooks/blocking/test_content_search_to_zoekt_redirector_unit.py +7 -2
- package/hooks/hooks.json +15 -0
- package/package.json +1 -1
package/docs/CODE_RULES.md
CHANGED
|
@@ -44,7 +44,7 @@ These rules are automatically enforced by `code-rules-enforcer.py`. Violations b
|
|
|
44
44
|
| No NEW comments | `#` / `//` in new code only (existing comments NEVER removed; shebangs, type:, noqa, eslint, docstrings exempt) |
|
|
45
45
|
| Imports at top | No `import` inside function bodies |
|
|
46
46
|
| Logging format args | No `log_*(f"...")` - use `log_*("...", arg)` |
|
|
47
|
-
| File line count |
|
|
47
|
+
| File line count | Advisory only — see [File length guidance](#65-file-length-guidance) |
|
|
48
48
|
| Magic values | No literals in function bodies (0, 1, -1 exempt). Includes string templates — if you strip the interpolations from an f-string and the remaining literal text is structural (paths, URLs, patterns), those fragments are magic values that belong in config |
|
|
49
49
|
| Constants location | No `UPPER_SNAKE =` outside `config/` |
|
|
50
50
|
|
|
@@ -121,6 +121,28 @@ def function_name(
|
|
|
121
121
|
|
|
122
122
|
---
|
|
123
123
|
|
|
124
|
+
## 6.5 FILE LENGTH GUIDANCE
|
|
125
|
+
|
|
126
|
+
File length is a **smell signal, not a hard threshold**. Long files often hide multiple responsibilities, but legitimately long files exist (migrations, generated code, registries, fixtures). The hook surfaces advisories instead of blocking.
|
|
127
|
+
|
|
128
|
+
**Two advisory thresholds (non-blocking, stderr only):**
|
|
129
|
+
|
|
130
|
+
| Threshold | Source basis | Hook behavior |
|
|
131
|
+
|-----------|--------------|---------------|
|
|
132
|
+
| `>= 400` lines | Robert C. Martin, *Clean Code* (2008), Ch. 5 "Formatting" — small files preferred; Martin Fowler, *Refactoring* — "Large Class" code smell | Soft advisory: "consider splitting" |
|
|
133
|
+
| `>= 1000` lines | pylint default `max-module-lines=1000`; SonarQube rule S104 default `1000` | Strong nudge: "exceeds widely-used static-analysis defaults" |
|
|
134
|
+
|
|
135
|
+
**What we deliberately reject:**
|
|
136
|
+
|
|
137
|
+
- **Hard numeric blocks** — Google's Python Style Guide imposes no file-length cap (only a ~40-line function review hint at https://google.github.io/styleguide/pyguide.html). A blocking rule produces false positives on legitimate cases.
|
|
138
|
+
- **A single magic number** — Different sources land at 200 (*Clean Code* preference), 750 (some SonarQube language profiles), or 1000 (pylint, Sonar Java). No source justifies a single universal cap.
|
|
139
|
+
|
|
140
|
+
**When to actually split:**
|
|
141
|
+
|
|
142
|
+
The size signal matters *because* of what it usually indicates: multiple responsibilities (Single Responsibility Principle — Robert C. Martin, *Agile Software Development*, 2002), poor cohesion (Steve McConnell, *Code Complete 2e*, 2004, Ch. 5–6), or the "Large Class" / "Long Function" smells (Fowler). Use the readability rubric (`~/.claude/skills/readability-review/SKILL.md`) when an advisory fires — split based on cohesion, not line count.
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
124
146
|
## 7. RIGHT-SIZED ENGINEERING
|
|
125
147
|
|
|
126
148
|
**Simple > Clever. Functions > Classes. Concrete > Abstract.**
|
|
@@ -175,7 +197,7 @@ Hook will enforce:
|
|
|
175
197
|
[⚡] No magic values
|
|
176
198
|
[⚡] Imports at top
|
|
177
199
|
[⚡] Logging format args
|
|
178
|
-
[
|
|
200
|
+
[ ] File length reasonable (advisory at 400, strong nudge at 1000 — see §6.5)
|
|
179
201
|
[⚡] Constants in config/
|
|
180
202
|
|
|
181
203
|
Manual check:
|
|
@@ -2,15 +2,17 @@
|
|
|
2
2
|
"""
|
|
3
3
|
CODE_RULES.md enforcer - blocks code that violates mandatory rules.
|
|
4
4
|
|
|
5
|
-
Checks (
|
|
5
|
+
Checks (blocking):
|
|
6
6
|
1. No comments (# or // in code, excluding shebangs/type: ignore)
|
|
7
7
|
2. Imports at top (no imports inside functions)
|
|
8
8
|
3. Logging f-strings (log_* calls must use format args)
|
|
9
|
-
4.
|
|
10
|
-
5.
|
|
11
|
-
6.
|
|
12
|
-
7.
|
|
13
|
-
|
|
9
|
+
4. Windows API None (win32gui calls with None parameter)
|
|
10
|
+
5. Magic values (literals in function bodies)
|
|
11
|
+
6. E2E test naming (no online/offline in test names)
|
|
12
|
+
7. Constants outside config (UPPER_SNAKE = in non-config files)
|
|
13
|
+
|
|
14
|
+
Advisory only (non-blocking):
|
|
15
|
+
- File line count: stderr warning at 400 lines (soft) and 1000 lines (hard)
|
|
14
16
|
"""
|
|
15
17
|
import json
|
|
16
18
|
import re
|
|
@@ -27,6 +29,9 @@ HOOK_INFRASTRUCTURE_PATTERNS = {"/.claude/hooks/", "\\.claude\\hooks\\", "\\.cla
|
|
|
27
29
|
WORKFLOW_REGISTRY_PATTERNS = {"/workflow/", "\\workflow\\", "_tab.py", "/states.py", "\\states.py", "/modules.py", "\\modules.py"}
|
|
28
30
|
MIGRATION_PATH_PATTERNS = {"/migrations/", "\\migrations\\"}
|
|
29
31
|
|
|
32
|
+
ADVISORY_LINE_THRESHOLD_SOFT = 400
|
|
33
|
+
ADVISORY_LINE_THRESHOLD_HARD = 1000
|
|
34
|
+
|
|
30
35
|
|
|
31
36
|
def get_file_extension(file_path: str) -> str:
|
|
32
37
|
"""Extract lowercase file extension."""
|
|
@@ -308,12 +313,27 @@ def check_logging_fstrings(content: str) -> list[str]:
|
|
|
308
313
|
return issues
|
|
309
314
|
|
|
310
315
|
|
|
311
|
-
def
|
|
312
|
-
"""
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
316
|
+
def advise_file_line_count(content: str, file_path: str) -> None:
|
|
317
|
+
"""Emit non-blocking stderr advisories when a file crosses size smell thresholds.
|
|
318
|
+
|
|
319
|
+
Thresholds are smell signals, not hard caps. See CODE_RULES.md "File length guidance"
|
|
320
|
+
for rationale. Soft threshold aligns with Clean Code Ch. 5 / Fowler "Large Class".
|
|
321
|
+
Hard threshold matches pylint default max-module-lines and SonarQube S104 default.
|
|
322
|
+
"""
|
|
323
|
+
line_count = len(content.splitlines())
|
|
324
|
+
if line_count >= ADVISORY_LINE_THRESHOLD_HARD:
|
|
325
|
+
print(
|
|
326
|
+
f"[CODE_RULES advisory] {file_path}: {line_count} lines - "
|
|
327
|
+
f"exceeds pylint/SonarQube default ({ADVISORY_LINE_THRESHOLD_HARD}); "
|
|
328
|
+
f"strongly consider splitting by responsibility (SRP / cohesion)",
|
|
329
|
+
file=sys.stderr,
|
|
330
|
+
)
|
|
331
|
+
elif line_count >= ADVISORY_LINE_THRESHOLD_SOFT:
|
|
332
|
+
print(
|
|
333
|
+
f"[CODE_RULES advisory] {file_path}: {line_count} lines - "
|
|
334
|
+
f"consider splitting (Clean Code Ch. 5; Fowler 'Large Class' smell)",
|
|
335
|
+
file=sys.stderr,
|
|
336
|
+
)
|
|
317
337
|
|
|
318
338
|
|
|
319
339
|
def check_windows_api_none(content: str) -> list[str]:
|
|
@@ -485,7 +505,7 @@ def validate_content(content: str, file_path: str, old_content: str = "") -> lis
|
|
|
485
505
|
all_issues.extend(check_e2e_test_naming(content, file_path))
|
|
486
506
|
|
|
487
507
|
if extension in ALL_CODE_EXTENSIONS:
|
|
488
|
-
|
|
508
|
+
advise_file_line_count(content, file_path)
|
|
489
509
|
|
|
490
510
|
return all_issues
|
|
491
511
|
|
|
@@ -8,7 +8,10 @@ import sys
|
|
|
8
8
|
from content_search_zoekt_bash_block_reason import block_reason_for_bash_command
|
|
9
9
|
from content_search_zoekt_block_payload import build_block_payload
|
|
10
10
|
from content_search_zoekt_indexed_paths import is_in_indexed_repo, is_specific_file
|
|
11
|
-
from content_search_zoekt_redirect_guidance import
|
|
11
|
+
from content_search_zoekt_redirect_guidance import (
|
|
12
|
+
get_zoekt_redirect_guidance,
|
|
13
|
+
get_zoekt_redirect_reason_brief,
|
|
14
|
+
)
|
|
12
15
|
|
|
13
16
|
|
|
14
17
|
def main() -> None:
|
|
@@ -45,7 +48,8 @@ def main() -> None:
|
|
|
45
48
|
short_label = f"blocked {block_reason}; use Zoekt MCP"
|
|
46
49
|
payload = build_block_payload(
|
|
47
50
|
brief_label=short_label,
|
|
48
|
-
permission_decision_reason=
|
|
51
|
+
permission_decision_reason=get_zoekt_redirect_reason_brief(),
|
|
52
|
+
additional_context=get_zoekt_redirect_guidance(),
|
|
49
53
|
)
|
|
50
54
|
print(json.dumps(payload))
|
|
51
55
|
sys.exit(0)
|
|
@@ -4,14 +4,18 @@
|
|
|
4
4
|
def build_block_payload(
|
|
5
5
|
brief_label: str,
|
|
6
6
|
permission_decision_reason: str,
|
|
7
|
+
additional_context: str | None = None,
|
|
7
8
|
) -> dict:
|
|
8
9
|
destructive_gate_label_prefix = "[destructive-gate]"
|
|
10
|
+
hook_specific_output: dict = {
|
|
11
|
+
"hookEventName": "PreToolUse",
|
|
12
|
+
"permissionDecision": "deny",
|
|
13
|
+
"permissionDecisionReason": permission_decision_reason,
|
|
14
|
+
}
|
|
15
|
+
if additional_context is not None:
|
|
16
|
+
hook_specific_output["additionalContext"] = additional_context
|
|
9
17
|
return {
|
|
10
|
-
"hookSpecificOutput":
|
|
11
|
-
"hookEventName": "PreToolUse",
|
|
12
|
-
"permissionDecision": "deny",
|
|
13
|
-
"permissionDecisionReason": permission_decision_reason,
|
|
14
|
-
},
|
|
18
|
+
"hookSpecificOutput": hook_specific_output,
|
|
15
19
|
"systemMessage": f"{destructive_gate_label_prefix} {brief_label}",
|
|
16
20
|
"suppressOutput": True,
|
|
17
21
|
}
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
"""Zoekt MCP usage and repo-to-disk path mapping for PreToolUse
|
|
1
|
+
"""Zoekt MCP usage and repo-to-disk path mapping for PreToolUse outputs."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def get_zoekt_redirect_reason_brief() -> str:
|
|
5
|
+
return (
|
|
6
|
+
"Use Zoekt MCP (e.g. mcp__zoekt__search) instead of Grep/Search in Zoekt-indexed trees."
|
|
7
|
+
)
|
|
2
8
|
|
|
3
9
|
|
|
4
10
|
def get_zoekt_redirect_guidance() -> str:
|
|
@@ -13,7 +13,10 @@ class ContentSearchHookIntegrationTests(unittest.TestCase):
|
|
|
13
13
|
hook_directory = pathlib.Path(__file__).resolve().parent
|
|
14
14
|
if str(hook_directory) not in sys.path:
|
|
15
15
|
sys.path.insert(0, str(hook_directory))
|
|
16
|
-
from content_search_zoekt_redirect_guidance import
|
|
16
|
+
from content_search_zoekt_redirect_guidance import (
|
|
17
|
+
get_zoekt_redirect_guidance,
|
|
18
|
+
get_zoekt_redirect_reason_brief,
|
|
19
|
+
)
|
|
17
20
|
|
|
18
21
|
hook_path = hook_directory / "content-search-to-zoekt-redirector.py"
|
|
19
22
|
destructive_gate_label_prefix = "[destructive-gate]"
|
|
@@ -38,6 +41,10 @@ class ContentSearchHookIntegrationTests(unittest.TestCase):
|
|
|
38
41
|
)
|
|
39
42
|
self.assertEqual(
|
|
40
43
|
payload["hookSpecificOutput"]["permissionDecisionReason"],
|
|
44
|
+
get_zoekt_redirect_reason_brief(),
|
|
45
|
+
)
|
|
46
|
+
self.assertEqual(
|
|
47
|
+
payload["hookSpecificOutput"]["additionalContext"],
|
|
41
48
|
get_zoekt_redirect_guidance(),
|
|
42
49
|
)
|
|
43
50
|
self.assertEqual(
|
|
@@ -11,7 +11,10 @@ if str(HOOK_DIRECTORY) not in sys.path:
|
|
|
11
11
|
sys.path.insert(0, str(HOOK_DIRECTORY))
|
|
12
12
|
|
|
13
13
|
from content_search_zoekt_block_payload import build_block_payload
|
|
14
|
-
from content_search_zoekt_redirect_guidance import
|
|
14
|
+
from content_search_zoekt_redirect_guidance import (
|
|
15
|
+
get_zoekt_redirect_guidance,
|
|
16
|
+
get_zoekt_redirect_reason_brief,
|
|
17
|
+
)
|
|
15
18
|
|
|
16
19
|
|
|
17
20
|
class BuildBlockPayloadTests(unittest.TestCase):
|
|
@@ -32,12 +35,14 @@ class BuildBlockPayloadTests(unittest.TestCase):
|
|
|
32
35
|
self.assertEqual(payload["suppressOutput"], True)
|
|
33
36
|
self.assertNotIn("decision", payload)
|
|
34
37
|
self.assertNotIn("reason", payload)
|
|
38
|
+
self.assertNotIn("additionalContext", payload["hookSpecificOutput"])
|
|
35
39
|
|
|
36
40
|
def test_serialized_payload_under_documented_context_cap(self) -> None:
|
|
37
41
|
cap_characters = 10_000
|
|
38
42
|
payload = build_block_payload(
|
|
39
43
|
brief_label="blocked Bash(grep); use Zoekt MCP",
|
|
40
|
-
permission_decision_reason=
|
|
44
|
+
permission_decision_reason=get_zoekt_redirect_reason_brief(),
|
|
45
|
+
additional_context=get_zoekt_redirect_guidance(),
|
|
41
46
|
)
|
|
42
47
|
serialized = json.dumps(payload)
|
|
43
48
|
self.assertLessEqual(
|
package/hooks/hooks.json
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
"description": "Code standards enforcement, safety guards, and development workflow hooks",
|
|
3
3
|
"hooks": {
|
|
4
4
|
"PreToolUse": [
|
|
5
|
+
{
|
|
6
|
+
"matcher": "Grep|Search",
|
|
7
|
+
"hooks": [
|
|
8
|
+
{
|
|
9
|
+
"type": "command",
|
|
10
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/content-search-to-zoekt-redirector.py",
|
|
11
|
+
"timeout": 10
|
|
12
|
+
}
|
|
13
|
+
]
|
|
14
|
+
},
|
|
5
15
|
{
|
|
6
16
|
"matcher": "Write|Edit",
|
|
7
17
|
"hooks": [
|
|
@@ -84,6 +94,11 @@
|
|
|
84
94
|
"type": "command",
|
|
85
95
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/test-preflight-check.py",
|
|
86
96
|
"timeout": 10
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
"type": "command",
|
|
100
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/content-search-to-zoekt-redirector.py",
|
|
101
|
+
"timeout": 10
|
|
87
102
|
}
|
|
88
103
|
]
|
|
89
104
|
},
|