claude-dev-env 1.28.0 → 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.
Files changed (54) hide show
  1. package/agents/caveman.md +74 -0
  2. package/hooks/blocking/code_rules_enforcer.py +82 -7
  3. package/hooks/blocking/code_rules_path_utils.py +31 -0
  4. package/hooks/blocking/es_exe_path_rewriter.py +159 -0
  5. package/hooks/blocking/hedging_language_blocker.py +12 -2
  6. package/hooks/blocking/test_code_rules_enforcer.py +148 -0
  7. package/hooks/blocking/test_code_rules_enforcer_config_path.py +123 -0
  8. package/hooks/blocking/test_code_rules_enforcer_magic_allowlist.py +1 -1
  9. package/hooks/blocking/test_code_rules_path_utils.py +52 -0
  10. package/hooks/blocking/test_es_exe_path_rewriter.py +369 -0
  11. package/hooks/blocking/test_hedging_language_blocker.py +7 -6
  12. package/hooks/config/dynamic_stderr_handler.py +22 -0
  13. package/hooks/config/path_rewriter_constants.py +13 -0
  14. package/hooks/config/project_paths_reader.py +78 -0
  15. package/hooks/config/setup_project_paths_constants.py +41 -0
  16. package/hooks/config/test_dynamic_stderr_handler.py +48 -0
  17. package/hooks/config/test_messages.py +5 -1
  18. package/hooks/config/test_path_rewriter_constants.py +57 -0
  19. package/hooks/config/test_project_paths_reader.py +149 -0
  20. package/hooks/config/test_setup_project_paths_constants.py +74 -0
  21. package/hooks/git-hooks/test_config.py +1 -0
  22. package/hooks/git-hooks/test_gate_utils.py +1 -0
  23. package/hooks/git-hooks/test_pre_commit.py +1 -0
  24. package/hooks/git-hooks/test_pre_push.py +1 -0
  25. package/hooks/hooks.json +10 -0
  26. package/hooks/session/test_untracked_repo_detector.py +192 -0
  27. package/hooks/session/untracked_repo_detector.py +103 -0
  28. package/hooks/validators/exempt_paths.py +17 -14
  29. package/hooks/validators/test_exempt_paths.py +65 -0
  30. package/hooks/validators/test_git_checks.py +17 -17
  31. package/package.json +1 -1
  32. package/scripts/config/__init__.py +1 -0
  33. package/scripts/config/groq_bugteam_config.py +118 -0
  34. package/scripts/config/test_groq_bugteam_config.py +72 -0
  35. package/scripts/groq_bugteam.README.md +129 -0
  36. package/scripts/groq_bugteam.py +586 -0
  37. package/scripts/setup_project_paths.py +347 -0
  38. package/scripts/test_groq_bugteam.py +391 -0
  39. package/scripts/test_setup_project_paths.py +532 -0
  40. package/scripts/test_setup_project_paths_config.py +6 -0
  41. package/skills/bugteam/CONSTRAINTS.md +1 -1
  42. package/skills/bugteam/PROMPTS.md +1 -1
  43. package/skills/bugteam/SKILL.md +5 -5
  44. package/skills/bugteam/SKILL_EVALS.md +5 -5
  45. package/skills/bugteam/reference/audit-and-teammates.md +3 -3
  46. package/skills/bugteam/reference/audit-contract.md +159 -0
  47. package/skills/bugteam/reference/team-setup.md +2 -2
  48. package/skills/bugteam/scripts/bugteam_preflight.py +66 -0
  49. package/skills/bugteam/scripts/test_bugteam_preflight.py +189 -0
  50. package/skills/copilot-review/SKILL.md +145 -0
  51. package/skills/findbugs/SKILL.md +14 -22
  52. package/skills/qbug/SKILL.md +56 -12
  53. package/skills/qbug/test_qbug_skill_audit_schema.py +156 -0
  54. 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
- sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "config"))
15
- from messages import USER_FACING_NOTICE
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
+ )