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,192 @@
|
|
|
1
|
+
"""Tests for untracked_repo_detector — SessionStart hook for new repo detection."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from io import StringIO
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from unittest.mock import patch
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
_SESSION_DIR = Path(__file__).resolve().parent
|
|
12
|
+
_HOOKS_ROOT = _SESSION_DIR.parent
|
|
13
|
+
for each_sys_path_entry in (str(_SESSION_DIR), str(_HOOKS_ROOT)):
|
|
14
|
+
if each_sys_path_entry not in sys.path:
|
|
15
|
+
sys.path.insert(0, each_sys_path_entry)
|
|
16
|
+
|
|
17
|
+
import untracked_repo_detector as detector
|
|
18
|
+
from config.project_paths_reader import registry_file_path
|
|
19
|
+
from config.setup_project_paths_constants import GIT_DIRECTORY_SEGMENT_NAME
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _run_main_with_cwd(cwd: str, known_registry: dict) -> tuple[str, str, int]:
|
|
23
|
+
"""Return (stdout, stderr, exit_code) from running main() with patched cwd and registry."""
|
|
24
|
+
captured_stdout = StringIO()
|
|
25
|
+
captured_stderr = StringIO()
|
|
26
|
+
exit_code = 0
|
|
27
|
+
try:
|
|
28
|
+
with (
|
|
29
|
+
patch(
|
|
30
|
+
"untracked_repo_detector.current_working_directory", return_value=cwd
|
|
31
|
+
),
|
|
32
|
+
patch("untracked_repo_detector.load_registry", return_value=known_registry),
|
|
33
|
+
patch("sys.stdout", captured_stdout),
|
|
34
|
+
patch("sys.stderr", captured_stderr),
|
|
35
|
+
):
|
|
36
|
+
detector.main()
|
|
37
|
+
except SystemExit as e:
|
|
38
|
+
exit_code = e.code or 0
|
|
39
|
+
return captured_stdout.getvalue(), captured_stderr.getvalue(), exit_code
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class TestFindGitRoot:
|
|
43
|
+
def test_returns_none_when_no_git_directory_found(self, tmp_path: Path) -> None:
|
|
44
|
+
non_repo_path = tmp_path / "not-a-repo"
|
|
45
|
+
non_repo_path.mkdir()
|
|
46
|
+
original_exists = Path.exists
|
|
47
|
+
|
|
48
|
+
def exists_without_ambient_git(self_path: Path) -> bool:
|
|
49
|
+
if self_path.name == ".git" and tmp_path.resolve() not in self_path.resolve().parents and self_path.resolve().parent != tmp_path.resolve():
|
|
50
|
+
return False
|
|
51
|
+
return original_exists(self_path)
|
|
52
|
+
|
|
53
|
+
with patch.object(Path, "exists", exists_without_ambient_git):
|
|
54
|
+
found_root = detector.find_git_root(str(non_repo_path))
|
|
55
|
+
assert found_root is None
|
|
56
|
+
|
|
57
|
+
def test_returns_root_when_dot_git_exists(self, tmp_path: Path) -> None:
|
|
58
|
+
repo_root = tmp_path / "my-repo"
|
|
59
|
+
repo_root.mkdir()
|
|
60
|
+
(repo_root / ".git").mkdir()
|
|
61
|
+
found_root = detector.find_git_root(str(repo_root))
|
|
62
|
+
assert found_root is not None
|
|
63
|
+
assert Path(found_root).resolve() == repo_root.resolve()
|
|
64
|
+
|
|
65
|
+
def test_returns_root_when_cwd_is_subdirectory(self, tmp_path: Path) -> None:
|
|
66
|
+
repo_root = tmp_path / "my-repo"
|
|
67
|
+
nested_directory = repo_root / "src" / "deep"
|
|
68
|
+
nested_directory.mkdir(parents=True)
|
|
69
|
+
(repo_root / ".git").mkdir()
|
|
70
|
+
found_root = detector.find_git_root(str(nested_directory))
|
|
71
|
+
assert found_root is not None
|
|
72
|
+
assert Path(found_root).resolve() == repo_root.resolve()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class TestRegistryContainsRepo:
|
|
76
|
+
def test_cwd_inside_tracked_repo_produces_no_output(self, tmp_path: Path) -> None:
|
|
77
|
+
repo_root = tmp_path / "tracked-repo"
|
|
78
|
+
repo_root.mkdir()
|
|
79
|
+
(repo_root / ".git").mkdir()
|
|
80
|
+
known_registry = {"tracked-repo": str(repo_root)}
|
|
81
|
+
stdout, _, _ = _run_main_with_cwd(str(repo_root), known_registry)
|
|
82
|
+
assert stdout.strip() == ""
|
|
83
|
+
|
|
84
|
+
def test_cwd_outside_any_git_repo_produces_no_output(self, tmp_path: Path) -> None:
|
|
85
|
+
non_repo = tmp_path / "not-a-repo"
|
|
86
|
+
non_repo.mkdir()
|
|
87
|
+
with patch("untracked_repo_detector.find_git_root", return_value=None):
|
|
88
|
+
stdout, _, _ = _run_main_with_cwd(str(non_repo), {})
|
|
89
|
+
assert stdout.strip() == ""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class TestUntrackedRepoDetection:
|
|
93
|
+
def test_cwd_inside_untracked_repo_produces_additional_context(
|
|
94
|
+
self, tmp_path: Path
|
|
95
|
+
) -> None:
|
|
96
|
+
repo_root = tmp_path / "new-repo"
|
|
97
|
+
repo_root.mkdir()
|
|
98
|
+
(repo_root / ".git").mkdir()
|
|
99
|
+
stdout, _, _ = _run_main_with_cwd(str(repo_root), {})
|
|
100
|
+
assert stdout.strip() != ""
|
|
101
|
+
emitted = json.loads(stdout)
|
|
102
|
+
assert "additionalContext" in emitted
|
|
103
|
+
|
|
104
|
+
def test_emitted_context_names_the_detected_repo_path(self, tmp_path: Path) -> None:
|
|
105
|
+
repo_root = tmp_path / "new-repo"
|
|
106
|
+
repo_root.mkdir()
|
|
107
|
+
(repo_root / ".git").mkdir()
|
|
108
|
+
stdout, _, _ = _run_main_with_cwd(str(repo_root), {})
|
|
109
|
+
emitted = json.loads(stdout)
|
|
110
|
+
context_text = emitted["additionalContext"]
|
|
111
|
+
assert str(repo_root) in context_text
|
|
112
|
+
|
|
113
|
+
def test_emitted_context_names_the_config_file_path(self, tmp_path: Path) -> None:
|
|
114
|
+
repo_root = tmp_path / "new-repo"
|
|
115
|
+
repo_root.mkdir()
|
|
116
|
+
(repo_root / ".git").mkdir()
|
|
117
|
+
stdout, _, _ = _run_main_with_cwd(str(repo_root), {})
|
|
118
|
+
emitted = json.loads(stdout)
|
|
119
|
+
context_text = emitted["additionalContext"]
|
|
120
|
+
assert "project-paths.json" in context_text
|
|
121
|
+
|
|
122
|
+
def test_emitted_context_instructs_claude_to_use_ask_user_question(
|
|
123
|
+
self, tmp_path: Path
|
|
124
|
+
) -> None:
|
|
125
|
+
repo_root = tmp_path / "new-repo"
|
|
126
|
+
repo_root.mkdir()
|
|
127
|
+
(repo_root / ".git").mkdir()
|
|
128
|
+
stdout, _, _ = _run_main_with_cwd(str(repo_root), {})
|
|
129
|
+
emitted = json.loads(stdout)
|
|
130
|
+
context_text = emitted["additionalContext"]
|
|
131
|
+
assert "AskUserQuestion" in context_text
|
|
132
|
+
|
|
133
|
+
def test_emitted_context_states_hook_has_written_nothing(
|
|
134
|
+
self, tmp_path: Path
|
|
135
|
+
) -> None:
|
|
136
|
+
repo_root = tmp_path / "new-repo"
|
|
137
|
+
repo_root.mkdir()
|
|
138
|
+
(repo_root / ".git").mkdir()
|
|
139
|
+
stdout, _, _ = _run_main_with_cwd(str(repo_root), {})
|
|
140
|
+
emitted = json.loads(stdout)
|
|
141
|
+
context_text = emitted["additionalContext"]
|
|
142
|
+
assert (
|
|
143
|
+
"written nothing" in context_text.lower()
|
|
144
|
+
or "has not written" in context_text.lower()
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class TestSharedRegistryPath:
|
|
149
|
+
def test_config_file_path_not_a_module_level_attribute(self) -> None:
|
|
150
|
+
"""Pin PR #230 round 7: _CONFIG_FILE_PATH inlined into _build_confirm_instruction.
|
|
151
|
+
|
|
152
|
+
Single-consumer module-level constant moved to local per file-global-constants rule.
|
|
153
|
+
"""
|
|
154
|
+
assert not hasattr(detector, "_CONFIG_FILE_PATH")
|
|
155
|
+
|
|
156
|
+
def test_confirm_instruction_contains_registry_file_path(
|
|
157
|
+
self, tmp_path: Path
|
|
158
|
+
) -> None:
|
|
159
|
+
repo_root = str(tmp_path / "some-repo")
|
|
160
|
+
instruction_text = detector._build_confirm_instruction(repo_root)
|
|
161
|
+
assert str(registry_file_path()) in instruction_text
|
|
162
|
+
|
|
163
|
+
def test_confirm_instruction_contains_project_paths_json(
|
|
164
|
+
self, tmp_path: Path
|
|
165
|
+
) -> None:
|
|
166
|
+
repo_root = str(tmp_path / "some-repo")
|
|
167
|
+
instruction_text = detector._build_confirm_instruction(repo_root)
|
|
168
|
+
assert "project-paths.json" in instruction_text
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class TestSharedGitDirectoryConstant:
|
|
172
|
+
def test_find_git_root_uses_shared_git_directory_constant(self, tmp_path: Path) -> None:
|
|
173
|
+
"""Pin: find_git_root must use GIT_DIRECTORY_SEGMENT_NAME from shared constants."""
|
|
174
|
+
repo_root = tmp_path / "uses-shared-constant"
|
|
175
|
+
repo_root.mkdir()
|
|
176
|
+
(repo_root / GIT_DIRECTORY_SEGMENT_NAME).mkdir()
|
|
177
|
+
found_root = detector.find_git_root(str(repo_root))
|
|
178
|
+
assert found_root is not None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class TestPathNormalization:
|
|
182
|
+
def test_windows_and_posix_forms_of_same_path_compare_equal(
|
|
183
|
+
self, tmp_path: Path
|
|
184
|
+
) -> None:
|
|
185
|
+
repo_root = tmp_path / "cross-platform-repo"
|
|
186
|
+
repo_root.mkdir()
|
|
187
|
+
(repo_root / ".git").mkdir()
|
|
188
|
+
windows_path = str(repo_root)
|
|
189
|
+
posix_path = str(repo_root).replace("\\", "/")
|
|
190
|
+
registry_with_posix = {"cross-platform-repo": posix_path}
|
|
191
|
+
stdout, _, _ = _run_main_with_cwd(windows_path, registry_with_posix)
|
|
192
|
+
assert stdout.strip() == ""
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""SessionStart hook: detect git repos not present in ~/.claude/project-paths.json.
|
|
3
|
+
|
|
4
|
+
When Claude Code opens inside a git repo that is not registered, emits an
|
|
5
|
+
additionalContext instruction asking Claude to confirm the mapping with the
|
|
6
|
+
user via AskUserQuestion before persisting anything. The hook itself never
|
|
7
|
+
writes to the config file.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
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.project_paths_reader import (
|
|
30
|
+
load_registry,
|
|
31
|
+
registry_contains_path,
|
|
32
|
+
registry_file_path,
|
|
33
|
+
)
|
|
34
|
+
from config.setup_project_paths_constants import GIT_DIRECTORY_SEGMENT_NAME
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
_logger = logging.getLogger("untracked_repo_detector")
|
|
38
|
+
if not _logger.handlers:
|
|
39
|
+
_stderr_handler = DynamicStderrHandler()
|
|
40
|
+
_stderr_handler.setFormatter(logging.Formatter("%(name)s: %(message)s"))
|
|
41
|
+
_logger.addHandler(_stderr_handler)
|
|
42
|
+
_logger.setLevel(logging.INFO)
|
|
43
|
+
_logger.propagate = False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def current_working_directory() -> str:
|
|
47
|
+
"""Return the process working directory as a string."""
|
|
48
|
+
return os.getcwd()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def find_git_root(start_path: str) -> str | None:
|
|
52
|
+
"""Walk upward from start_path looking for a .git directory.
|
|
53
|
+
|
|
54
|
+
The walk is bounded by the user's home directory: once the candidate
|
|
55
|
+
reaches the home directory without finding ``.git``, the search stops.
|
|
56
|
+
This prevents a stray ``.git`` above the user's home (for example a
|
|
57
|
+
parent dotfiles repo) from being falsely reported as the session's repo.
|
|
58
|
+
|
|
59
|
+
Returns the absolute path of the repo root, or None if not found.
|
|
60
|
+
"""
|
|
61
|
+
home_directory = Path.home().resolve()
|
|
62
|
+
candidate = Path(start_path).resolve()
|
|
63
|
+
while True:
|
|
64
|
+
if (candidate / GIT_DIRECTORY_SEGMENT_NAME).exists():
|
|
65
|
+
return str(candidate)
|
|
66
|
+
if candidate == home_directory:
|
|
67
|
+
return None
|
|
68
|
+
parent = candidate.parent
|
|
69
|
+
if parent == candidate:
|
|
70
|
+
return None
|
|
71
|
+
candidate = parent
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _build_confirm_instruction(repo_root: str) -> str:
|
|
75
|
+
config_file_path = str(registry_file_path())
|
|
76
|
+
return (
|
|
77
|
+
f"UNTRACKED REPO DETECTED: The current session is running inside a git "
|
|
78
|
+
f"repository at '{repo_root}' that is not present in {config_file_path}. "
|
|
79
|
+
f"Use AskUserQuestion with two options — 'Save mapping' (recommended) and "
|
|
80
|
+
f"'Skip for this session' — to confirm whether to persist this repo path. "
|
|
81
|
+
f"On approval, append a new entry to {config_file_path} mapping the "
|
|
82
|
+
f"repository leaf name to '{repo_root}'. This hook has written nothing."
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def main() -> None:
|
|
87
|
+
try:
|
|
88
|
+
session_cwd = current_working_directory()
|
|
89
|
+
git_root = find_git_root(session_cwd)
|
|
90
|
+
if git_root is None:
|
|
91
|
+
sys.exit(0)
|
|
92
|
+
known_registry = load_registry()
|
|
93
|
+
if registry_contains_path(known_registry, git_root):
|
|
94
|
+
sys.exit(0)
|
|
95
|
+
instruction = _build_confirm_instruction(git_root)
|
|
96
|
+
print(json.dumps({"additionalContext": instruction}))
|
|
97
|
+
except Exception as e:
|
|
98
|
+
_logger.error("%s", e)
|
|
99
|
+
sys.exit(0)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
if __name__ == "__main__":
|
|
103
|
+
main()
|
|
@@ -9,20 +9,28 @@ the two produced the "inconsistent verdicts" bug this module prevents.
|
|
|
9
9
|
Matching is case-insensitive so paths like ``Config/foo.py`` or
|
|
10
10
|
``src/Tests/test_x.py`` are treated the same on case-preserving
|
|
11
11
|
filesystems (macOS default, Windows NTFS) as on case-sensitive ones.
|
|
12
|
+
|
|
13
|
+
``is_config_file`` canonical implementation lives in
|
|
14
|
+
``hooks/blocking/code_rules_path_utils.py`` and is re-exported here so
|
|
15
|
+
both the pre-write gate and the pre-push validator share identical logic.
|
|
12
16
|
"""
|
|
13
17
|
|
|
14
18
|
from __future__ import annotations
|
|
15
19
|
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _ensure_blocking_package_on_path() -> None:
|
|
25
|
+
blocking_directory = str(Path(__file__).resolve().parent.parent / "blocking")
|
|
26
|
+
if blocking_directory not in sys.path:
|
|
27
|
+
sys.path.insert(0, blocking_directory)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_ensure_blocking_package_on_path()
|
|
31
|
+
|
|
32
|
+
from code_rules_path_utils import is_config_file # type: ignore[import-not-found] # noqa: E402
|
|
16
33
|
|
|
17
|
-
CONFIG_PATH_PATTERNS: frozenset[str] = frozenset(
|
|
18
|
-
{
|
|
19
|
-
"config/",
|
|
20
|
-
"config\\",
|
|
21
|
-
"/config.",
|
|
22
|
-
"\\config.",
|
|
23
|
-
"settings.py",
|
|
24
|
-
}
|
|
25
|
-
)
|
|
26
34
|
|
|
27
35
|
TEST_PATH_PATTERNS: frozenset[str] = frozenset(
|
|
28
36
|
{
|
|
@@ -65,11 +73,6 @@ MIGRATION_PATH_PATTERNS: frozenset[str] = frozenset(
|
|
|
65
73
|
)
|
|
66
74
|
|
|
67
75
|
|
|
68
|
-
def is_config_file(file_path: str) -> bool:
|
|
69
|
-
path_lower = file_path.lower()
|
|
70
|
-
return any(pattern in path_lower for pattern in CONFIG_PATH_PATTERNS)
|
|
71
|
-
|
|
72
|
-
|
|
73
76
|
def is_test_file(file_path: str) -> bool:
|
|
74
77
|
path_lower = file_path.lower()
|
|
75
78
|
return any(pattern in path_lower for pattern in TEST_PATH_PATTERNS)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Tests for exempt_paths path-classification helpers.
|
|
2
|
+
|
|
3
|
+
Covers the is_config_file() contract: only files whose parent directory
|
|
4
|
+
segment is literally 'config' should match. A filename of 'config.py'
|
|
5
|
+
outside a config/ directory must NOT match.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
_BLOCKING_DIR = str(Path(__file__).resolve().parent.parent / "blocking")
|
|
14
|
+
if _BLOCKING_DIR not in sys.path:
|
|
15
|
+
sys.path.insert(0, _BLOCKING_DIR)
|
|
16
|
+
|
|
17
|
+
from validators.exempt_paths import is_config_file # noqa: E402
|
|
18
|
+
from code_rules_path_utils import is_config_file as path_utils_is_config_file # noqa: E402
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_should_exempt_file_inside_config_directory() -> None:
|
|
22
|
+
assert is_config_file("project/config/constants.py") is True
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_should_exempt_file_inside_nested_config_directory() -> None:
|
|
26
|
+
assert is_config_file("packages/myapp/config/timing.py") is True
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_should_not_exempt_file_named_config_dot_py_outside_config_dir() -> None:
|
|
30
|
+
assert is_config_file("scripts/db/config.py") is False
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_should_not_exempt_file_with_config_in_filename_only() -> None:
|
|
34
|
+
assert is_config_file("src/app_config.py") is False
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_should_not_exempt_file_with_config_in_parent_partial_match() -> None:
|
|
38
|
+
assert is_config_file("src/reconfigured/constants.py") is False
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_should_exempt_settings_py_by_filename() -> None:
|
|
42
|
+
assert is_config_file("any/path/settings.py") is True
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_should_exempt_windows_path_inside_config_directory() -> None:
|
|
46
|
+
assert is_config_file("packages\\myapp\\config\\timing.py") is True
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_should_not_exempt_filename_ending_with_settings_py_but_not_exactly_settings_py() -> None:
|
|
50
|
+
assert is_config_file("mysettings.py") is False
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_should_exempt_bare_settings_py_filename() -> None:
|
|
54
|
+
assert is_config_file("settings.py") is True
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_should_exempt_settings_py_in_nested_path() -> None:
|
|
58
|
+
assert is_config_file("path/to/settings.py") is True
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_is_config_file_is_identical_function_object_from_path_utils() -> None:
|
|
62
|
+
"""After refactor, exempt_paths.is_config_file must be the same object as path_utils."""
|
|
63
|
+
assert is_config_file is path_utils_is_config_file, (
|
|
64
|
+
"exempt_paths.is_config_file must be imported from code_rules_path_utils, not re-defined"
|
|
65
|
+
)
|
|
@@ -19,7 +19,7 @@ from .git_checks import (
|
|
|
19
19
|
class TestSingleCommitWhenPrExists:
|
|
20
20
|
"""Test that PR branches have exactly 1 commit ahead of base."""
|
|
21
21
|
|
|
22
|
-
@patch("git_checks.subprocess.run")
|
|
22
|
+
@patch("validators.git_checks.subprocess.run")
|
|
23
23
|
def test_no_pr_returns_empty(self, mock_run: MagicMock) -> None:
|
|
24
24
|
"""When no PR exists, check should return empty list."""
|
|
25
25
|
mock_run.side_effect = [
|
|
@@ -31,7 +31,7 @@ class TestSingleCommitWhenPrExists:
|
|
|
31
31
|
|
|
32
32
|
assert violations == []
|
|
33
33
|
|
|
34
|
-
@patch("git_checks.subprocess.run")
|
|
34
|
+
@patch("validators.git_checks.subprocess.run")
|
|
35
35
|
def test_single_commit_ahead_passes(self, mock_run: MagicMock) -> None:
|
|
36
36
|
"""Exactly 1 commit ahead should pass."""
|
|
37
37
|
mock_run.side_effect = [
|
|
@@ -44,7 +44,7 @@ class TestSingleCommitWhenPrExists:
|
|
|
44
44
|
|
|
45
45
|
assert violations == []
|
|
46
46
|
|
|
47
|
-
@patch("git_checks.subprocess.run")
|
|
47
|
+
@patch("validators.git_checks.subprocess.run")
|
|
48
48
|
def test_zero_commits_ahead_fails(self, mock_run: MagicMock) -> None:
|
|
49
49
|
"""Zero commits ahead should fail."""
|
|
50
50
|
mock_run.side_effect = [
|
|
@@ -61,7 +61,7 @@ class TestSingleCommitWhenPrExists:
|
|
|
61
61
|
assert "exactly 1 commit" in violations[0].message
|
|
62
62
|
assert "0 commits" in violations[0].message
|
|
63
63
|
|
|
64
|
-
@patch("git_checks.subprocess.run")
|
|
64
|
+
@patch("validators.git_checks.subprocess.run")
|
|
65
65
|
def test_multiple_commits_ahead_fails(self, mock_run: MagicMock) -> None:
|
|
66
66
|
"""More than 1 commit ahead should fail."""
|
|
67
67
|
mock_run.side_effect = [
|
|
@@ -76,7 +76,7 @@ class TestSingleCommitWhenPrExists:
|
|
|
76
76
|
assert "exactly 1 commit" in violations[0].message
|
|
77
77
|
assert "3 commits" in violations[0].message
|
|
78
78
|
|
|
79
|
-
@patch("git_checks.subprocess.run")
|
|
79
|
+
@patch("validators.git_checks.subprocess.run")
|
|
80
80
|
def test_gh_cli_not_available_returns_empty(self, mock_run: MagicMock) -> None:
|
|
81
81
|
"""When gh CLI not available, should return empty (warning, not failure)."""
|
|
82
82
|
mock_run.side_effect = [
|
|
@@ -88,7 +88,7 @@ class TestSingleCommitWhenPrExists:
|
|
|
88
88
|
|
|
89
89
|
assert violations == []
|
|
90
90
|
|
|
91
|
-
@patch("git_checks.subprocess.run")
|
|
91
|
+
@patch("validators.git_checks.subprocess.run")
|
|
92
92
|
def test_git_not_available_returns_empty(self, mock_run: MagicMock) -> None:
|
|
93
93
|
"""When git not available, should return empty."""
|
|
94
94
|
mock_run.side_effect = [
|
|
@@ -101,7 +101,7 @@ class TestSingleCommitWhenPrExists:
|
|
|
101
101
|
|
|
102
102
|
assert violations == []
|
|
103
103
|
|
|
104
|
-
@patch("git_checks.subprocess.run")
|
|
104
|
+
@patch("validators.git_checks.subprocess.run")
|
|
105
105
|
def test_extracts_base_branch_from_pr_info(self, mock_run: MagicMock) -> None:
|
|
106
106
|
"""Should extract base branch name from gh pr list JSON output, falling back to main when absent."""
|
|
107
107
|
mock_run.side_effect = [
|
|
@@ -140,7 +140,7 @@ class TestSingleCommitWhenPrExists:
|
|
|
140
140
|
timeout=30,
|
|
141
141
|
)
|
|
142
142
|
|
|
143
|
-
@patch("git_checks.subprocess.run")
|
|
143
|
+
@patch("validators.git_checks.subprocess.run")
|
|
144
144
|
def test_non_numeric_commit_count_returns_empty(self, mock_run: MagicMock) -> None:
|
|
145
145
|
"""When git rev-list returns non-numeric output, should return empty."""
|
|
146
146
|
mock_run.side_effect = [
|
|
@@ -153,7 +153,7 @@ class TestSingleCommitWhenPrExists:
|
|
|
153
153
|
|
|
154
154
|
assert violations == []
|
|
155
155
|
|
|
156
|
-
@patch("git_checks.subprocess.run")
|
|
156
|
+
@patch("validators.git_checks.subprocess.run")
|
|
157
157
|
def test_gh_timeout_returns_empty(self, mock_run: MagicMock) -> None:
|
|
158
158
|
"""When gh CLI times out, should return empty (warning, not failure)."""
|
|
159
159
|
mock_run.side_effect = [
|
|
@@ -165,7 +165,7 @@ class TestSingleCommitWhenPrExists:
|
|
|
165
165
|
|
|
166
166
|
assert violations == []
|
|
167
167
|
|
|
168
|
-
@patch("git_checks.subprocess.run")
|
|
168
|
+
@patch("validators.git_checks.subprocess.run")
|
|
169
169
|
def test_git_timeout_returns_empty(self, mock_run: MagicMock) -> None:
|
|
170
170
|
"""When git times out, should return empty (warning, not failure)."""
|
|
171
171
|
mock_run.side_effect = [
|
|
@@ -178,7 +178,7 @@ class TestSingleCommitWhenPrExists:
|
|
|
178
178
|
|
|
179
179
|
assert violations == []
|
|
180
180
|
|
|
181
|
-
@patch("git_checks.subprocess.run")
|
|
181
|
+
@patch("validators.git_checks.subprocess.run")
|
|
182
182
|
def test_passes_resolved_branch_name_to_gh(self, mock_run: MagicMock) -> None:
|
|
183
183
|
"""gh pr list must receive the resolved branch name, never the literal 'HEAD'."""
|
|
184
184
|
mock_run.side_effect = [
|
|
@@ -197,7 +197,7 @@ class TestSingleCommitWhenPrExists:
|
|
|
197
197
|
timeout=30,
|
|
198
198
|
)
|
|
199
199
|
|
|
200
|
-
@patch("git_checks.subprocess.run")
|
|
200
|
+
@patch("validators.git_checks.subprocess.run")
|
|
201
201
|
def test_unresolved_branch_returns_empty(self, mock_run: MagicMock) -> None:
|
|
202
202
|
"""When current branch cannot be resolved, should return empty."""
|
|
203
203
|
mock_run.side_effect = [
|
|
@@ -213,7 +213,7 @@ class TestSingleCommitWhenPrExists:
|
|
|
213
213
|
class TestDraftPrState:
|
|
214
214
|
"""Test that PR is in draft state when pushing review fixes."""
|
|
215
215
|
|
|
216
|
-
@patch("git_checks.subprocess.run")
|
|
216
|
+
@patch("validators.git_checks.subprocess.run")
|
|
217
217
|
def test_no_pr_returns_empty(self, mock_run: MagicMock) -> None:
|
|
218
218
|
"""When no PR exists, check should return empty list."""
|
|
219
219
|
mock_run.return_value = MagicMock(returncode=0, stdout="[]", stderr="")
|
|
@@ -222,7 +222,7 @@ class TestDraftPrState:
|
|
|
222
222
|
|
|
223
223
|
assert violations == []
|
|
224
224
|
|
|
225
|
-
@patch("git_checks.subprocess.run")
|
|
225
|
+
@patch("validators.git_checks.subprocess.run")
|
|
226
226
|
def test_draft_pr_passes(self, mock_run: MagicMock) -> None:
|
|
227
227
|
"""Draft PR should pass."""
|
|
228
228
|
mock_run.return_value = MagicMock(
|
|
@@ -235,7 +235,7 @@ class TestDraftPrState:
|
|
|
235
235
|
|
|
236
236
|
assert violations == []
|
|
237
237
|
|
|
238
|
-
@patch("git_checks.subprocess.run")
|
|
238
|
+
@patch("validators.git_checks.subprocess.run")
|
|
239
239
|
def test_non_draft_pr_fails(self, mock_run: MagicMock) -> None:
|
|
240
240
|
"""Non-draft PR should fail."""
|
|
241
241
|
mock_run.return_value = MagicMock(
|
|
@@ -252,7 +252,7 @@ class TestDraftPrState:
|
|
|
252
252
|
assert "draft" in violations[0].message.lower()
|
|
253
253
|
assert "gh pr ready --undo" in violations[0].message
|
|
254
254
|
|
|
255
|
-
@patch("git_checks.subprocess.run")
|
|
255
|
+
@patch("validators.git_checks.subprocess.run")
|
|
256
256
|
def test_gh_cli_not_available_returns_empty(self, mock_run: MagicMock) -> None:
|
|
257
257
|
"""When gh CLI not available, should return empty (warning, not failure)."""
|
|
258
258
|
mock_run.side_effect = FileNotFoundError("gh not found")
|
|
@@ -261,7 +261,7 @@ class TestDraftPrState:
|
|
|
261
261
|
|
|
262
262
|
assert violations == []
|
|
263
263
|
|
|
264
|
-
@patch("git_checks.subprocess.run")
|
|
264
|
+
@patch("validators.git_checks.subprocess.run")
|
|
265
265
|
def test_gh_timeout_returns_empty(self, mock_run: MagicMock) -> None:
|
|
266
266
|
"""When gh CLI times out, should return empty (warning, not failure)."""
|
|
267
267
|
mock_run.side_effect = subprocess.TimeoutExpired(cmd=["gh", "pr", "list"], timeout=30)
|
package/package.json
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Scripts configuration package — home for scalar constants per CODE_RULES."""
|