claude-dev-env 1.28.1 → 1.29.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/caveman.md +74 -0
- package/hooks/blocking/code_rules_enforcer.py +82 -7
- package/hooks/blocking/code_rules_path_utils.py +31 -0
- package/hooks/blocking/es_exe_path_rewriter.py +159 -0
- package/hooks/blocking/hedging_language_blocker.py +12 -2
- package/hooks/blocking/test_code_rules_enforcer.py +148 -0
- package/hooks/blocking/test_code_rules_enforcer_config_path.py +123 -0
- package/hooks/blocking/test_code_rules_enforcer_magic_allowlist.py +1 -1
- package/hooks/blocking/test_code_rules_path_utils.py +52 -0
- package/hooks/blocking/test_es_exe_path_rewriter.py +369 -0
- package/hooks/blocking/test_hedging_language_blocker.py +7 -6
- package/hooks/config/dynamic_stderr_handler.py +22 -0
- package/hooks/config/path_rewriter_constants.py +13 -0
- package/hooks/config/project_paths_reader.py +78 -0
- package/hooks/config/setup_project_paths_constants.py +41 -0
- package/hooks/config/test_dynamic_stderr_handler.py +48 -0
- package/hooks/config/test_messages.py +5 -1
- package/hooks/config/test_path_rewriter_constants.py +57 -0
- package/hooks/config/test_project_paths_reader.py +149 -0
- package/hooks/config/test_setup_project_paths_constants.py +74 -0
- package/hooks/git-hooks/test_config.py +1 -0
- package/hooks/git-hooks/test_gate_utils.py +1 -0
- package/hooks/git-hooks/test_pre_commit.py +1 -0
- package/hooks/git-hooks/test_pre_push.py +1 -0
- package/hooks/hooks.json +10 -0
- package/hooks/session/test_untracked_repo_detector.py +192 -0
- package/hooks/session/untracked_repo_detector.py +103 -0
- package/hooks/validators/exempt_paths.py +17 -14
- package/hooks/validators/test_exempt_paths.py +65 -0
- package/hooks/validators/test_git_checks.py +17 -17
- package/package.json +1 -1
- package/scripts/config/__init__.py +1 -0
- package/scripts/config/groq_bugteam_config.py +118 -0
- package/scripts/config/test_groq_bugteam_config.py +72 -0
- package/scripts/groq_bugteam.README.md +129 -0
- package/scripts/groq_bugteam.py +586 -0
- package/scripts/setup_project_paths.py +347 -0
- package/scripts/test_groq_bugteam.py +391 -0
- package/scripts/test_setup_project_paths.py +532 -0
- package/scripts/test_setup_project_paths_config.py +6 -0
- package/skills/bugteam/CONSTRAINTS.md +1 -1
- package/skills/bugteam/PROMPTS.md +1 -1
- package/skills/bugteam/SKILL.md +5 -5
- package/skills/bugteam/SKILL_EVALS.md +5 -5
- package/skills/bugteam/reference/audit-and-teammates.md +3 -3
- package/skills/bugteam/reference/audit-contract.md +159 -0
- package/skills/bugteam/reference/team-setup.md +2 -2
- package/skills/bugteam/scripts/bugteam_preflight.py +66 -0
- package/skills/bugteam/scripts/test_bugteam_preflight.py +189 -0
- package/skills/copilot-review/SKILL.md +145 -0
- package/skills/findbugs/SKILL.md +14 -22
- package/skills/qbug/SKILL.md +56 -13
- package/skills/qbug/test_qbug_skill_audit_schema.py +156 -0
- package/skills/qbug/test_qbug_skill_post_fix_audit.py +103 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: caveman
|
|
3
|
+
description: Trims noise from an artifact the main caller has already authored. Input is a draft (skill, doc, plan, response, README, prompt, PR description) — output is the same artifact with filler, hedging, preamble, recap, and restatement removed. Preserves structure, technical substance, frontmatter, and anything load-bearing. Does NOT redesign, restructure, or overrule the caller's scope decisions.
|
|
4
|
+
model: inherit
|
|
5
|
+
color: red
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
You are the caveman. You trim. You do not build. You do not restructure.
|
|
9
|
+
|
|
10
|
+
## What you are
|
|
11
|
+
|
|
12
|
+
A noise filter. The main caller has already decided *what* the artifact is, *how* it is structured, and *what lives in it*. Your job is to strip fluff off that artifact without touching the bones.
|
|
13
|
+
|
|
14
|
+
You are downstream of design decisions, not upstream.
|
|
15
|
+
|
|
16
|
+
## What you trim
|
|
17
|
+
|
|
18
|
+
| Noise type | Example |
|
|
19
|
+
|---|---|
|
|
20
|
+
| Preamble / recap | "As discussed above, this skill will..." |
|
|
21
|
+
| Hedging | "This might, in some cases, potentially..." |
|
|
22
|
+
| Filler transitions | "Now, moving on to..." / "It's worth noting that..." |
|
|
23
|
+
| Restatement | the same point made twice in different words |
|
|
24
|
+
| Empty future-proofing | parameters, sections, or fields with no current consumer |
|
|
25
|
+
| Dead examples | examples that duplicate another example without adding coverage |
|
|
26
|
+
| Pleasantries | "Hope this helps." / "Feel free to..." |
|
|
27
|
+
| Vague qualifiers | "various", "several", "a number of" — replace with the actual count or cut |
|
|
28
|
+
|
|
29
|
+
Rewrite prose into the caveman pattern only where it does not change meaning: `[thing] [action] [reason]. [next step].`
|
|
30
|
+
|
|
31
|
+
## What you do NOT touch
|
|
32
|
+
|
|
33
|
+
- **Structure the caller chose.** Four sections in, four sections out. Do not collapse or merge.
|
|
34
|
+
- **Frontmatter fields.** All fields stay. Tighten values if verbose; do not drop fields.
|
|
35
|
+
- **Technical substance.** Code, commands, paths, URLs, errors, JSON, schema — unchanged.
|
|
36
|
+
- **Trigger words / activation phrases.** Load-bearing for skill matching.
|
|
37
|
+
- **Safety / escape-hatch language.** Warnings about destructive ops, irreversible actions, credentials, money, production systems — preserve verbatim.
|
|
38
|
+
- **Caller-flagged content.** If the caller said "keep X verbatim", X is untouchable.
|
|
39
|
+
- **Counts and specifics.** Numbers, thresholds, version strings, identifiers — unchanged.
|
|
40
|
+
- **Register in examples and docstrings.** Unless caller asked for caveman voice throughout, keep the original register of user-facing copy.
|
|
41
|
+
|
|
42
|
+
## What you do NOT decide
|
|
43
|
+
|
|
44
|
+
You do not tell the caller:
|
|
45
|
+
- "Use the existing tool instead" — design call, caller's call.
|
|
46
|
+
- "Make this one file instead of three" — structure call, caller's call.
|
|
47
|
+
- "Drop this section" — scope call, caller's call.
|
|
48
|
+
- "Add tests" / "remove tests" — scope call, caller's call.
|
|
49
|
+
|
|
50
|
+
If you suspect a section is pure noise, flag it in the report. Leave it in place unless the caller told you to remove it.
|
|
51
|
+
|
|
52
|
+
## Process
|
|
53
|
+
|
|
54
|
+
1. Read the artifact end to end before touching it.
|
|
55
|
+
2. Mark the bones — frontmatter, structure, technical substance, trigger words, safety language. Off-limits.
|
|
56
|
+
3. Trim noise per the table above.
|
|
57
|
+
4. Return the trimmed artifact in the caller's original file format.
|
|
58
|
+
|
|
59
|
+
## Output shape
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
trimmed: <path or artifact name>
|
|
63
|
+
removed: <bullets — noise categories cut, with rough line counts>
|
|
64
|
+
preserved-verbatim: <what you refused to touch and why>
|
|
65
|
+
flagged: <content you suspect is noise but left in place for caller to decide>
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
No recap of the artifact itself. Caller has it.
|
|
69
|
+
|
|
70
|
+
## Escape hatch
|
|
71
|
+
|
|
72
|
+
If trimming would drop a safety warning, remove an irreversible-action caveat, collapse a distinction the caller made deliberately, or if you are unsure whether content is load-bearing — leave it in place and flag it. Ask before cutting.
|
|
73
|
+
|
|
74
|
+
Terse is for noise, not for substance.
|
|
@@ -28,13 +28,19 @@ import json
|
|
|
28
28
|
import re
|
|
29
29
|
import sys
|
|
30
30
|
import tokenize
|
|
31
|
+
from pathlib import Path
|
|
31
32
|
from typing import Optional
|
|
32
33
|
|
|
34
|
+
_BLOCKING_DIR = str(Path(__file__).resolve().parent)
|
|
35
|
+
if _BLOCKING_DIR not in sys.path:
|
|
36
|
+
sys.path.insert(0, _BLOCKING_DIR)
|
|
37
|
+
|
|
38
|
+
from code_rules_path_utils import is_config_file # noqa: E402
|
|
39
|
+
|
|
33
40
|
PYTHON_EXTENSIONS = {".py"}
|
|
34
41
|
JAVASCRIPT_EXTENSIONS = {".js", ".ts", ".tsx", ".jsx"}
|
|
35
42
|
ALL_CODE_EXTENSIONS = PYTHON_EXTENSIONS | JAVASCRIPT_EXTENSIONS
|
|
36
43
|
|
|
37
|
-
CONFIG_PATH_PATTERNS = {"config/", "config\\", "/config.", "\\config.", "settings.py"}
|
|
38
44
|
TEST_PATH_PATTERNS = {"test_", "_test.", ".test.", ".spec.", "/tests/", "\\tests\\", "/tests.py", "\\tests.py"}
|
|
39
45
|
HOOK_INFRASTRUCTURE_PATTERNS = {"/.claude/hooks/", "\\.claude\\hooks\\", "\\.claude/hooks/", "/packages/claude-dev-env/hooks/", "\\packages\\claude-dev-env\\hooks\\"}
|
|
40
46
|
WORKFLOW_REGISTRY_PATTERNS = {"/workflow/", "\\workflow\\", "_tab.py", "/states.py", "\\states.py", "/modules.py", "\\modules.py"}
|
|
@@ -69,12 +75,6 @@ def is_hook_infrastructure(file_path: str) -> bool:
|
|
|
69
75
|
return any(pattern.replace("\\", "/") in path_lower for pattern in HOOK_INFRASTRUCTURE_PATTERNS)
|
|
70
76
|
|
|
71
77
|
|
|
72
|
-
def is_config_file(file_path: str) -> bool:
|
|
73
|
-
"""Check if file is in a config directory or is a config file."""
|
|
74
|
-
path_lower = file_path.lower()
|
|
75
|
-
return any(pattern in path_lower for pattern in CONFIG_PATH_PATTERNS)
|
|
76
|
-
|
|
77
|
-
|
|
78
78
|
def is_test_file(file_path: str) -> bool:
|
|
79
79
|
"""Check if file is a test file."""
|
|
80
80
|
path_lower = file_path.lower()
|
|
@@ -780,6 +780,79 @@ def check_constants_outside_config(content: str, file_path: str) -> list[str]:
|
|
|
780
780
|
return issues
|
|
781
781
|
|
|
782
782
|
|
|
783
|
+
def _is_exempt_for_advisory_scan(file_path: str) -> bool:
|
|
784
|
+
"""Return True when the file is exempt from the function-local UPPER_SNAKE advisory."""
|
|
785
|
+
if is_config_file(file_path):
|
|
786
|
+
return True
|
|
787
|
+
if is_test_file(file_path):
|
|
788
|
+
return True
|
|
789
|
+
if is_workflow_registry_file(file_path):
|
|
790
|
+
return True
|
|
791
|
+
if is_migration_file(file_path):
|
|
792
|
+
return True
|
|
793
|
+
return False
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
def _scan_function_body_constants(content: str) -> list[str]:
|
|
797
|
+
"""Return advisory messages for UPPER_SNAKE assignments inside function bodies.
|
|
798
|
+
|
|
799
|
+
Only lines inside a function body (tracked via an indent stack) are
|
|
800
|
+
flagged. Module-level assignments and class-body assignments are ignored.
|
|
801
|
+
Returns at most MAX_ISSUES_PER_CHECK entries.
|
|
802
|
+
"""
|
|
803
|
+
advisory_issues: list[str] = []
|
|
804
|
+
lines = content.split("\n")
|
|
805
|
+
function_indent_stack: list[int] = []
|
|
806
|
+
constant_pattern = re.compile(r"^([A-Z][A-Z0-9_]{2,})\s*=\s*[^=]")
|
|
807
|
+
|
|
808
|
+
for line_number, line in enumerate(lines, 1):
|
|
809
|
+
stripped = line.strip()
|
|
810
|
+
|
|
811
|
+
if not stripped:
|
|
812
|
+
continue
|
|
813
|
+
|
|
814
|
+
indent = len(line) - len(line.lstrip())
|
|
815
|
+
|
|
816
|
+
while function_indent_stack and indent <= function_indent_stack[-1] and not stripped.startswith(("#", "@", ")")):
|
|
817
|
+
function_indent_stack.pop()
|
|
818
|
+
|
|
819
|
+
if re.match(r"^class\s+\w+", stripped):
|
|
820
|
+
if indent == 0:
|
|
821
|
+
function_indent_stack.clear()
|
|
822
|
+
continue
|
|
823
|
+
|
|
824
|
+
if re.match(r"^(async\s+)?def\s+\w+", stripped):
|
|
825
|
+
function_indent_stack.append(indent)
|
|
826
|
+
continue
|
|
827
|
+
|
|
828
|
+
if function_indent_stack:
|
|
829
|
+
match = constant_pattern.match(stripped)
|
|
830
|
+
if match:
|
|
831
|
+
constant_name = match.group(1)
|
|
832
|
+
advisory_issues.append(
|
|
833
|
+
f"Line {line_number}: Function-local constant {constant_name} - consider moving to config/"
|
|
834
|
+
)
|
|
835
|
+
|
|
836
|
+
if len(advisory_issues) >= MAX_ISSUES_PER_CHECK:
|
|
837
|
+
break
|
|
838
|
+
|
|
839
|
+
return advisory_issues
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
def check_constants_outside_config_advisory(content: str, file_path: str) -> list[str]:
|
|
843
|
+
"""Return advisory entries for UPPER_SNAKE assignments inside function bodies.
|
|
844
|
+
|
|
845
|
+
Module-level UPPER_SNAKE outside config/ is blocking (see
|
|
846
|
+
check_constants_outside_config). Function-local UPPER_SNAKE is a softer
|
|
847
|
+
smell — it belongs in config/ but does not block the write. This function
|
|
848
|
+
surfaces those as advisory so callers can route them to stderr rather than
|
|
849
|
+
to the blocking deny payload.
|
|
850
|
+
"""
|
|
851
|
+
if _is_exempt_for_advisory_scan(file_path):
|
|
852
|
+
return []
|
|
853
|
+
return _scan_function_body_constants(content)
|
|
854
|
+
|
|
855
|
+
|
|
783
856
|
BANNED_IDENTIFIERS: frozenset[str] = frozenset({"result", "data", "output", "response", "value", "item", "temp"})
|
|
784
857
|
MAX_BANNED_IDENTIFIER_ISSUES: int = 3
|
|
785
858
|
BANNED_IDENTIFIER_MESSAGE_SUFFIX: str = "use descriptive name (see CODE_RULES Naming section)"
|
|
@@ -1118,6 +1191,8 @@ def validate_content(content: str, file_path: str, old_content: str = "") -> lis
|
|
|
1118
1191
|
all_issues.extend(check_magic_values(content, file_path))
|
|
1119
1192
|
all_issues.extend(check_fstring_structural_literals(content, file_path))
|
|
1120
1193
|
all_issues.extend(check_constants_outside_config(content, file_path))
|
|
1194
|
+
for each_advisory in check_constants_outside_config_advisory(content, file_path):
|
|
1195
|
+
print(f"[CODE_RULES advisory] {file_path}: {each_advisory}", file=sys.stderr)
|
|
1121
1196
|
all_issues.extend(check_file_global_constants_use_count(content, file_path))
|
|
1122
1197
|
all_issues.extend(check_type_escape_hatches(content, file_path))
|
|
1123
1198
|
all_issues.extend(check_banned_identifiers(content, file_path))
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Shared path-classification helpers for the blocking hook layer.
|
|
2
|
+
|
|
3
|
+
Both ``code_rules_enforcer.py`` (pre-write gate) and
|
|
4
|
+
``validators/exempt_paths.py`` (pre-push validator) need to agree on what
|
|
5
|
+
constitutes a config file. This module is the single implementation; both
|
|
6
|
+
import ``is_config_file`` from here so they cannot drift apart.
|
|
7
|
+
|
|
8
|
+
``validators/exempt_paths.py`` re-exports this function and documents that
|
|
9
|
+
the canonical implementation lives here.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def is_config_file(file_path: str) -> bool:
|
|
18
|
+
"""Return True when the path points to a config file.
|
|
19
|
+
|
|
20
|
+
Uses pathlib parts so a filename of ``config.py`` does not match — any
|
|
21
|
+
directory segment before the filename must be literally ``config``.
|
|
22
|
+
``settings.py`` matches regardless of parent directory. Filename-only
|
|
23
|
+
matches such as ``scripts/db/config.py`` or ``lib/myconfig.py`` return
|
|
24
|
+
False because the check requires a *directory* segment named ``config``,
|
|
25
|
+
not a filename stem.
|
|
26
|
+
"""
|
|
27
|
+
normalized = file_path.replace("\\", "/").lower()
|
|
28
|
+
if normalized.endswith("/settings.py") or normalized == "settings.py":
|
|
29
|
+
return True
|
|
30
|
+
path_parts = Path(normalized).parts
|
|
31
|
+
return "config" in path_parts[:-1]
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse hook: rewrite es.exe commands by substituting registry tokens with absolute paths.
|
|
3
|
+
|
|
4
|
+
Reads tool_input.command from stdin JSON. When the command invokes the Everything
|
|
5
|
+
command-line binary, substitutes {project-name} placeholder tokens and bare registry-key
|
|
6
|
+
tokens with their quoted absolute paths from ~/.claude/project-paths.json before
|
|
7
|
+
the Bash call runs. Never blocks or denies — on any error exits 0 with empty output.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import re
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path, PurePosixPath, PureWindowsPath
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _insert_hooks_tree_for_imports() -> None:
|
|
20
|
+
hooks_tree = Path(__file__).resolve().parent.parent
|
|
21
|
+
hooks_tree_string = str(hooks_tree)
|
|
22
|
+
if hooks_tree_string not in sys.path:
|
|
23
|
+
sys.path.insert(0, hooks_tree_string)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
_insert_hooks_tree_for_imports()
|
|
27
|
+
|
|
28
|
+
from config.dynamic_stderr_handler import DynamicStderrHandler
|
|
29
|
+
from config.path_rewriter_constants import (
|
|
30
|
+
BASH_TOOL_NAME,
|
|
31
|
+
HOOK_EVENT_NAME,
|
|
32
|
+
PERMISSION_ALLOW,
|
|
33
|
+
PLACEHOLDER_TOKEN_PATTERN,
|
|
34
|
+
)
|
|
35
|
+
from config.project_paths_reader import load_registry
|
|
36
|
+
|
|
37
|
+
_ES_EXE_TRIGGER_PATTERN = re.compile(
|
|
38
|
+
r"(?i)(?<![\w.])(?:Everything[/\\])?es\.exe(?![\w.])",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
_logger = logging.getLogger("es_exe_path_rewriter")
|
|
43
|
+
if not _logger.handlers:
|
|
44
|
+
_stderr_handler = DynamicStderrHandler()
|
|
45
|
+
_stderr_handler.setFormatter(logging.Formatter("%(name)s: %(message)s"))
|
|
46
|
+
_logger.addHandler(_stderr_handler)
|
|
47
|
+
_logger.setLevel(logging.INFO)
|
|
48
|
+
_logger.propagate = False
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def command_invokes_es_exe(command: str) -> bool:
|
|
52
|
+
"""Return True when the command string contains an es.exe invocation."""
|
|
53
|
+
return bool(_ES_EXE_TRIGGER_PATTERN.search(command))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _token_is_absolute_path(token: str) -> bool:
|
|
57
|
+
stripped = token.strip("\"'")
|
|
58
|
+
try:
|
|
59
|
+
return (
|
|
60
|
+
PureWindowsPath(stripped).is_absolute()
|
|
61
|
+
or PurePosixPath(stripped).is_absolute()
|
|
62
|
+
)
|
|
63
|
+
except (ValueError, TypeError):
|
|
64
|
+
return False
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _quote_path(absolute_path: str) -> str:
|
|
68
|
+
return f'"{absolute_path}"'
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _rewrite_placeholder_tokens(command_suffix: str, registry: dict[str, str]) -> str:
|
|
72
|
+
def replace_placeholder(match: re.Match) -> str:
|
|
73
|
+
inner_name = match.group(1)
|
|
74
|
+
if inner_name not in registry:
|
|
75
|
+
return match.group(0)
|
|
76
|
+
return _quote_path(registry[inner_name])
|
|
77
|
+
|
|
78
|
+
return PLACEHOLDER_TOKEN_PATTERN.sub(replace_placeholder, command_suffix)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _split_on_es_exe(command: str) -> tuple[str, str]:
|
|
82
|
+
match = _ES_EXE_TRIGGER_PATTERN.search(command)
|
|
83
|
+
if not match:
|
|
84
|
+
return command, ""
|
|
85
|
+
return command[: match.end()], command[match.end() :]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _strip_matching_outer_quotes(token: str) -> tuple[str, bool]:
|
|
89
|
+
"""Return (inner_text, was_quoted) after removing matched outer quotes."""
|
|
90
|
+
if len(token) > 1 and token[0] in ('"', "'") and token[-1] == token[0]:
|
|
91
|
+
return token[1:-1], True
|
|
92
|
+
return token, False
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _rewrite_bare_tokens(command_suffix: str, registry: dict[str, str]) -> str:
|
|
96
|
+
all_raw_parts = re.split(r"(\s+)", command_suffix)
|
|
97
|
+
all_rewritten_parts: list[str] = []
|
|
98
|
+
for each_raw_part in all_raw_parts:
|
|
99
|
+
if not each_raw_part or each_raw_part.isspace():
|
|
100
|
+
all_rewritten_parts.append(each_raw_part)
|
|
101
|
+
continue
|
|
102
|
+
unquoted_text, _was_quoted = _strip_matching_outer_quotes(each_raw_part)
|
|
103
|
+
if unquoted_text in registry and not _token_is_absolute_path(unquoted_text):
|
|
104
|
+
all_rewritten_parts.append(_quote_path(registry[unquoted_text]))
|
|
105
|
+
else:
|
|
106
|
+
all_rewritten_parts.append(each_raw_part)
|
|
107
|
+
return "".join(all_rewritten_parts)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def rewrite_command(command: str, registry: dict[str, str]) -> str:
|
|
111
|
+
"""Apply registry substitutions to any es.exe argument tokens.
|
|
112
|
+
|
|
113
|
+
Applies placeholder form {name} first, then bare-token form.
|
|
114
|
+
Absolute-path arguments and unknown tokens are left untouched.
|
|
115
|
+
Returns the original command when no substitution applies.
|
|
116
|
+
"""
|
|
117
|
+
if not registry:
|
|
118
|
+
return command
|
|
119
|
+
prefix, suffix = _split_on_es_exe(command)
|
|
120
|
+
rewritten_suffix = _rewrite_placeholder_tokens(suffix, registry)
|
|
121
|
+
rewritten_suffix = _rewrite_bare_tokens(rewritten_suffix, registry)
|
|
122
|
+
return prefix + rewritten_suffix
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _build_allow_response(rewritten_command: str, original_tool_input: dict) -> dict:
|
|
126
|
+
updated_input = {**original_tool_input, "command": rewritten_command}
|
|
127
|
+
return {
|
|
128
|
+
"hookSpecificOutput": {
|
|
129
|
+
"hookEventName": HOOK_EVENT_NAME,
|
|
130
|
+
"permissionDecision": PERMISSION_ALLOW,
|
|
131
|
+
"updatedInput": updated_input,
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def main() -> None:
|
|
137
|
+
try:
|
|
138
|
+
hook_input = json.load(sys.stdin)
|
|
139
|
+
tool_name = hook_input.get("tool_name", "")
|
|
140
|
+
if tool_name != BASH_TOOL_NAME:
|
|
141
|
+
sys.exit(0)
|
|
142
|
+
tool_input = hook_input.get("tool_input", {})
|
|
143
|
+
command = tool_input.get("command", "")
|
|
144
|
+
if not command_invokes_es_exe(command):
|
|
145
|
+
sys.exit(0)
|
|
146
|
+
known_registry = load_registry()
|
|
147
|
+
if not known_registry:
|
|
148
|
+
sys.exit(0)
|
|
149
|
+
rewritten_command = rewrite_command(command, known_registry)
|
|
150
|
+
if rewritten_command == command:
|
|
151
|
+
sys.exit(0)
|
|
152
|
+
print(json.dumps(_build_allow_response(rewritten_command, tool_input)))
|
|
153
|
+
except Exception as e:
|
|
154
|
+
_logger.error("%s", e)
|
|
155
|
+
sys.exit(0)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
if __name__ == "__main__":
|
|
159
|
+
main()
|
|
@@ -10,9 +10,19 @@ import json
|
|
|
10
10
|
import os
|
|
11
11
|
import re
|
|
12
12
|
import sys
|
|
13
|
+
from pathlib import Path
|
|
13
14
|
|
|
14
|
-
|
|
15
|
-
|
|
15
|
+
|
|
16
|
+
def _insert_hooks_tree_for_imports() -> None:
|
|
17
|
+
hooks_tree = Path(__file__).resolve().parent.parent
|
|
18
|
+
hooks_tree_string = str(hooks_tree)
|
|
19
|
+
if hooks_tree_string not in sys.path:
|
|
20
|
+
sys.path.insert(0, hooks_tree_string)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
_insert_hooks_tree_for_imports()
|
|
24
|
+
|
|
25
|
+
from config.messages import USER_FACING_NOTICE
|
|
16
26
|
|
|
17
27
|
PLUGIN_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
18
28
|
|
|
@@ -10,6 +10,7 @@ the "zero function references" exemption does not swallow real references.
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
12
|
import importlib.util
|
|
13
|
+
import sys
|
|
13
14
|
from pathlib import Path
|
|
14
15
|
from types import ModuleType
|
|
15
16
|
|
|
@@ -26,6 +27,11 @@ def _load_enforcer_module() -> ModuleType:
|
|
|
26
27
|
|
|
27
28
|
code_rules_enforcer = _load_enforcer_module()
|
|
28
29
|
|
|
30
|
+
_BLOCKING_DIR = Path(__file__).resolve().parent
|
|
31
|
+
if str(_BLOCKING_DIR) not in sys.path:
|
|
32
|
+
sys.path.insert(0, str(_BLOCKING_DIR))
|
|
33
|
+
|
|
34
|
+
from code_rules_path_utils import is_config_file as path_utils_is_config_file # noqa: E402
|
|
29
35
|
|
|
30
36
|
PRODUCTION_FILE_PATH = "packages/claude-dev-env/hooks/blocking/example_production.py"
|
|
31
37
|
|
|
@@ -59,3 +65,145 @@ def test_should_flag_constant_used_once_at_module_scope_and_once_in_function() -
|
|
|
59
65
|
assert issues == [], (
|
|
60
66
|
f"Expected module-scope + function usage to count as 2 distinct callers, got: {issues}"
|
|
61
67
|
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_is_config_file_rejects_filename_only_config_pattern() -> None:
|
|
71
|
+
"""Paths where 'config' appears only in the filename (not as a directory segment) must return False."""
|
|
72
|
+
assert code_rules_enforcer.is_config_file("scripts/db/config.py") is False, (
|
|
73
|
+
"scripts/db/config.py — filename is config.py but parent dir is db, must be False"
|
|
74
|
+
)
|
|
75
|
+
assert code_rules_enforcer.is_config_file("lib/myconfig.py") is False, (
|
|
76
|
+
"lib/myconfig.py — config appears only in the filename stem, must be False"
|
|
77
|
+
)
|
|
78
|
+
assert code_rules_enforcer.is_config_file("src/app_config.py") is False, (
|
|
79
|
+
"src/app_config.py — config appears only in the filename stem, must be False"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_is_config_file_via_path_utils_returns_same_results_as_enforcer() -> None:
|
|
84
|
+
"""is_config_file from code_rules_path_utils must agree with the enforcer on all sample paths."""
|
|
85
|
+
all_sample_paths = [
|
|
86
|
+
"scripts/db/config.py",
|
|
87
|
+
"config/timing.py",
|
|
88
|
+
"settings.py",
|
|
89
|
+
]
|
|
90
|
+
for each_path in all_sample_paths:
|
|
91
|
+
enforcer_result = code_rules_enforcer.is_config_file(each_path)
|
|
92
|
+
path_utils_result = path_utils_is_config_file(each_path)
|
|
93
|
+
assert enforcer_result == path_utils_result, (
|
|
94
|
+
f"is_config_file diverged for {each_path!r}: "
|
|
95
|
+
f"enforcer={enforcer_result}, code_rules_path_utils={path_utils_result}"
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_is_exempt_for_advisory_scan_returns_true_for_config_file() -> None:
|
|
100
|
+
assert code_rules_enforcer._is_exempt_for_advisory_scan("project/config/constants.py") is True
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_is_exempt_for_advisory_scan_returns_true_for_test_file() -> None:
|
|
104
|
+
assert code_rules_enforcer._is_exempt_for_advisory_scan("test_example.py") is True
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_is_exempt_for_advisory_scan_returns_true_for_workflow_registry() -> None:
|
|
108
|
+
assert code_rules_enforcer._is_exempt_for_advisory_scan("app/workflow/states.py") is True
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_is_exempt_for_advisory_scan_returns_true_for_migration() -> None:
|
|
112
|
+
assert code_rules_enforcer._is_exempt_for_advisory_scan("app/migrations/0001_initial.py") is True
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_is_exempt_for_advisory_scan_returns_false_for_production_file() -> None:
|
|
116
|
+
assert code_rules_enforcer._is_exempt_for_advisory_scan("packages/myapp/some_module.py") is False
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_scan_function_body_constants_finds_upper_snake_in_function() -> None:
|
|
120
|
+
source = (
|
|
121
|
+
"def fetch():\n"
|
|
122
|
+
" MAX_RETRIES = 3\n"
|
|
123
|
+
" for attempt in range(MAX_RETRIES):\n"
|
|
124
|
+
" pass\n"
|
|
125
|
+
)
|
|
126
|
+
advisory_issues = code_rules_enforcer._scan_function_body_constants(source)
|
|
127
|
+
assert any("MAX_RETRIES" in issue for issue in advisory_issues)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_scan_function_body_constants_does_not_flag_module_level() -> None:
|
|
131
|
+
source = "MAX_RETRIES = 3\n\ndef fetch():\n pass\n"
|
|
132
|
+
advisory_issues = code_rules_enforcer._scan_function_body_constants(source)
|
|
133
|
+
assert advisory_issues == []
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_advisory_should_not_flag_class_attribute_after_method_def() -> None:
|
|
137
|
+
source_with_class_attribute_after_method = (
|
|
138
|
+
"class ExampleModel:\n"
|
|
139
|
+
" def method_a(self) -> None:\n"
|
|
140
|
+
" pass\n"
|
|
141
|
+
"\n"
|
|
142
|
+
" TABLE_NAME = \"example\"\n"
|
|
143
|
+
)
|
|
144
|
+
advisory_issues = code_rules_enforcer.check_constants_outside_config_advisory(
|
|
145
|
+
source_with_class_attribute_after_method,
|
|
146
|
+
"example_module.py",
|
|
147
|
+
)
|
|
148
|
+
assert advisory_issues == [], (
|
|
149
|
+
"Class-level TABLE_NAME attribute must not be flagged as function-local"
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_advisory_should_still_flag_actual_method_body_constant() -> None:
|
|
154
|
+
source_with_method_body_constant = (
|
|
155
|
+
"class ExampleModel:\n"
|
|
156
|
+
" def method_a(self) -> None:\n"
|
|
157
|
+
" MAXIMUM_RETRIES = 3\n"
|
|
158
|
+
" return None\n"
|
|
159
|
+
)
|
|
160
|
+
advisory_issues = code_rules_enforcer.check_constants_outside_config_advisory(
|
|
161
|
+
source_with_method_body_constant,
|
|
162
|
+
"example_module.py",
|
|
163
|
+
)
|
|
164
|
+
assert len(advisory_issues) == 1, (
|
|
165
|
+
"Method-body UPPER_SNAKE constant must still surface as advisory"
|
|
166
|
+
)
|
|
167
|
+
assert "MAXIMUM_RETRIES" in advisory_issues[0]
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def test_advisory_cap_matches_max_issues_per_check_constant() -> None:
|
|
171
|
+
many_constants_source = (
|
|
172
|
+
"def crowded_function():\n"
|
|
173
|
+
" ALPHA_CONSTANT = 1\n"
|
|
174
|
+
" BETA_CONSTANT = 2\n"
|
|
175
|
+
" GAMMA_CONSTANT = 3\n"
|
|
176
|
+
" DELTA_CONSTANT = 4\n"
|
|
177
|
+
" EPSILON_CONSTANT = 5\n"
|
|
178
|
+
)
|
|
179
|
+
advisory_issues = code_rules_enforcer.check_constants_outside_config_advisory(
|
|
180
|
+
many_constants_source,
|
|
181
|
+
"example_module.py",
|
|
182
|
+
)
|
|
183
|
+
assert len(advisory_issues) == code_rules_enforcer.MAX_ISSUES_PER_CHECK, (
|
|
184
|
+
"Advisory cap must equal MAX_ISSUES_PER_CHECK, not a hardcoded literal"
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def test_advisory_should_flag_outer_constants_after_nested_def() -> None:
|
|
189
|
+
source_with_nested_def = (
|
|
190
|
+
"def outer():\n"
|
|
191
|
+
" OUTER_CONST = 1\n"
|
|
192
|
+
" def inner():\n"
|
|
193
|
+
" INNER_CONST = 2\n"
|
|
194
|
+
" ANOTHER_OUTER = 3\n"
|
|
195
|
+
)
|
|
196
|
+
advisory_issues = code_rules_enforcer.check_constants_outside_config_advisory(
|
|
197
|
+
source_with_nested_def,
|
|
198
|
+
"example_module.py",
|
|
199
|
+
)
|
|
200
|
+
flagged_names = " ".join(advisory_issues)
|
|
201
|
+
assert "OUTER_CONST" in flagged_names, (
|
|
202
|
+
"OUTER_CONST before nested def must be flagged"
|
|
203
|
+
)
|
|
204
|
+
assert "INNER_CONST" in flagged_names, (
|
|
205
|
+
"INNER_CONST inside nested def must be flagged"
|
|
206
|
+
)
|
|
207
|
+
assert "ANOTHER_OUTER" in flagged_names, (
|
|
208
|
+
"ANOTHER_OUTER after nested def must be flagged — this is the regression case"
|
|
209
|
+
)
|