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,391 @@
1
+ """Tests for groq_bugteam.py pure logic.
2
+
3
+ Network calls (Groq HTTP) and filesystem/git side effects are out of scope for
4
+ unit tests; they are exercised in the live end-to-end run.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import importlib.util
10
+ import pathlib
11
+ import sys
12
+ import urllib.error
13
+
14
+ import pytest
15
+
16
+ sys.path.insert(0, str(pathlib.Path(__file__).parent))
17
+ for _cached in list(sys.modules):
18
+ if _cached == "config" or _cached.startswith("config."):
19
+ del sys.modules[_cached]
20
+
21
+ from config import groq_bugteam_config # noqa: E402
22
+
23
+
24
+ def _load_groq_bugteam_module():
25
+ scripts_directory = pathlib.Path(__file__).parent
26
+ sys.path.insert(0, str(scripts_directory))
27
+ for cached_module_name in list(sys.modules):
28
+ if cached_module_name == "config" or cached_module_name.startswith("config."):
29
+ del sys.modules[cached_module_name]
30
+ module_path = scripts_directory / "groq_bugteam.py"
31
+ module_spec = importlib.util.spec_from_file_location("groq_bugteam", module_path)
32
+ loaded_module = importlib.util.module_from_spec(module_spec)
33
+ sys.modules["groq_bugteam"] = loaded_module
34
+ module_spec.loader.exec_module(loaded_module)
35
+ return loaded_module
36
+
37
+
38
+ groq_bugteam = _load_groq_bugteam_module()
39
+
40
+
41
+ class TestConstantsSourcedFromConfig:
42
+ def test_endpoint_is_imported_from_config(self):
43
+ assert groq_bugteam.GROQ_API_ENDPOINT == groq_bugteam_config.GROQ_API_ENDPOINT
44
+
45
+ def test_primary_model_is_imported_from_config(self):
46
+ assert groq_bugteam.GROQ_PRIMARY_MODEL == groq_bugteam_config.GROQ_PRIMARY_MODEL
47
+
48
+
49
+ class TestClampText:
50
+ def test_returns_text_unchanged_when_under_limit(self):
51
+ assert groq_bugteam.clamp_text("hello world", 100) == "hello world"
52
+
53
+ def test_truncates_long_text_with_marker(self):
54
+ long_text = "a" * 1000
55
+ clamped = groq_bugteam.clamp_text(long_text, 200)
56
+ assert "truncated" in clamped
57
+ assert len(clamped) < len(long_text)
58
+ assert clamped.startswith("a")
59
+ assert clamped.endswith("a")
60
+
61
+ def test_preserves_head_and_tail(self):
62
+ text = "HEAD" + ("x" * 1000) + "TAIL"
63
+ clamped = groq_bugteam.clamp_text(text, 100)
64
+ assert clamped.startswith("HEAD")
65
+ assert clamped.endswith("TAIL")
66
+
67
+
68
+ class TestParseJsonObject:
69
+ def test_parses_clean_json(self):
70
+ parsed = groq_bugteam.parse_json_object('{"findings": []}')
71
+ assert parsed == {"findings": []}
72
+
73
+ def test_extracts_json_from_surrounding_prose(self):
74
+ noisy_response = 'Sure, here is the result:\n\n{"findings": [{"severity": "P1"}]}\n\nLet me know if you need more.'
75
+ parsed = groq_bugteam.parse_json_object(noisy_response)
76
+ assert parsed == {"findings": [{"severity": "P1"}]}
77
+
78
+ def test_raises_when_no_json_present(self):
79
+ with pytest.raises(ValueError):
80
+ groq_bugteam.parse_json_object("no braces here")
81
+
82
+
83
+ class TestNormalizeFindings:
84
+ def test_drops_findings_with_unknown_files(self):
85
+ raw_findings = [
86
+ {
87
+ "severity": "P0",
88
+ "category": "H",
89
+ "file": "known.py",
90
+ "line": 10,
91
+ "title": "t",
92
+ "description": "d",
93
+ },
94
+ {
95
+ "severity": "P1",
96
+ "category": "A",
97
+ "file": "unknown.py",
98
+ "line": 5,
99
+ "title": "t2",
100
+ "description": "d2",
101
+ },
102
+ ]
103
+ normalized = groq_bugteam.normalize_findings(raw_findings, {"known.py": ""})
104
+ assert len(normalized) == 1
105
+ assert normalized[0]["file"] == "known.py"
106
+
107
+ def test_coerces_non_string_line_to_int(self):
108
+ raw_findings = [
109
+ {
110
+ "severity": "P0",
111
+ "category": "H",
112
+ "file": "a.py",
113
+ "line": "42",
114
+ "title": "t",
115
+ "description": "d",
116
+ },
117
+ ]
118
+ normalized = groq_bugteam.normalize_findings(raw_findings, {"a.py": ""})
119
+ assert normalized[0]["line"] == 42
120
+
121
+ def test_defaults_to_zero_line_on_bad_value(self):
122
+ raw_findings = [
123
+ {
124
+ "severity": "P1",
125
+ "category": "H",
126
+ "file": "a.py",
127
+ "line": "not-a-number",
128
+ "title": "t",
129
+ "description": "d",
130
+ },
131
+ ]
132
+ normalized = groq_bugteam.normalize_findings(raw_findings, {"a.py": ""})
133
+ assert normalized[0]["line"] == 0
134
+
135
+ def test_clamps_invalid_severity_to_p2(self):
136
+ raw_findings = [
137
+ {
138
+ "severity": "CRITICAL",
139
+ "category": "H",
140
+ "file": "a.py",
141
+ "line": 1,
142
+ "title": "t",
143
+ "description": "d",
144
+ },
145
+ ]
146
+ normalized = groq_bugteam.normalize_findings(raw_findings, {"a.py": ""})
147
+ assert normalized[0]["severity"] == "P2"
148
+
149
+ def test_keeps_single_letter_category(self):
150
+ raw_findings = [
151
+ {
152
+ "severity": "P0",
153
+ "category": "HIJ",
154
+ "file": "a.py",
155
+ "line": 1,
156
+ "title": "t",
157
+ "description": "d",
158
+ },
159
+ ]
160
+ normalized = groq_bugteam.normalize_findings(raw_findings, {"a.py": ""})
161
+ assert normalized[0]["category"] == "H"
162
+
163
+ def test_handles_empty_input(self):
164
+ assert groq_bugteam.normalize_findings([], {"a.py": ""}) == []
165
+
166
+
167
+ class TestGroupFindingsByFile:
168
+ def test_groups_findings_and_preserves_global_indexes(self):
169
+ findings = [
170
+ {
171
+ "file": "a.py",
172
+ "severity": "P0",
173
+ "category": "H",
174
+ "line": 1,
175
+ "title": "t1",
176
+ "description": "d1",
177
+ },
178
+ {
179
+ "file": "b.py",
180
+ "severity": "P1",
181
+ "category": "A",
182
+ "line": 2,
183
+ "title": "t2",
184
+ "description": "d2",
185
+ },
186
+ {
187
+ "file": "a.py",
188
+ "severity": "P2",
189
+ "category": "E",
190
+ "line": 3,
191
+ "title": "t3",
192
+ "description": "d3",
193
+ },
194
+ ]
195
+ grouped = groq_bugteam.group_findings_by_file(findings)
196
+ assert set(grouped.keys()) == {"a.py", "b.py"}
197
+ assert [index for index, _ in grouped["a.py"]] == [0, 2]
198
+ assert [index for index, _ in grouped["b.py"]] == [1]
199
+
200
+
201
+ class TestBuildReviewBody:
202
+ def test_returns_clean_body_when_no_findings(self):
203
+ body = groq_bugteam.build_review_body([], "llama-3.3-70b-versatile", "", [])
204
+ assert "clean" in body
205
+ assert "llama-3.3-70b-versatile" in body
206
+
207
+ def test_counts_severities_and_lists_findings(self):
208
+ findings = [
209
+ {
210
+ "severity": "P0",
211
+ "category": "H",
212
+ "file": "a.py",
213
+ "line": 10,
214
+ "title": "SQL injection",
215
+ "description": "trace",
216
+ },
217
+ {
218
+ "severity": "P1",
219
+ "category": "F",
220
+ "file": "b.py",
221
+ "line": 5,
222
+ "title": "silent except",
223
+ "description": "trace2",
224
+ },
225
+ ]
226
+ fix_outcomes = [
227
+ {"finding_index": 0, "status": "fixed"},
228
+ {"finding_index": 1, "status": "skipped", "reason": "too complex"},
229
+ ]
230
+ body = groq_bugteam.build_review_body(
231
+ findings, "llama-3.3-70b-versatile", "abc1234", fix_outcomes
232
+ )
233
+ assert "1 P0 / 1 P1 / 0 P2" in body
234
+ assert "abc1234" in body
235
+ assert "SQL injection" in body
236
+ assert "silent except" in body
237
+ assert "fixed" in body
238
+ assert "skipped: too complex" in body
239
+
240
+ def test_marks_findings_without_outcome_as_not_attempted(self):
241
+ findings = [
242
+ {
243
+ "severity": "P2",
244
+ "category": "E",
245
+ "file": "a.py",
246
+ "line": 1,
247
+ "title": "dead code",
248
+ "description": "d",
249
+ },
250
+ ]
251
+ body = groq_bugteam.build_review_body(
252
+ findings, "llama-3.3-70b-versatile", "", []
253
+ )
254
+ assert "not attempted" in body
255
+
256
+
257
+ class TestIsRecoverableHttpError:
258
+ def _make_error(self, status_code: int) -> urllib.error.HTTPError:
259
+ return urllib.error.HTTPError(
260
+ url="x", code=status_code, msg="", hdrs=None, fp=None
261
+ )
262
+
263
+ @pytest.mark.parametrize("status", [408, 429, 500, 502, 503, 504])
264
+ def test_recoverable_statuses(self, status):
265
+ assert groq_bugteam.is_recoverable_http_error(self._make_error(status)) is True
266
+
267
+ @pytest.mark.parametrize("status", [400, 401, 403, 404, 422])
268
+ def test_non_recoverable_statuses(self, status):
269
+ assert groq_bugteam.is_recoverable_http_error(self._make_error(status)) is False
270
+
271
+ def test_413_triggers_skip_to_next_model(self):
272
+ assert groq_bugteam.should_skip_to_next_model(self._make_error(413)) is True
273
+
274
+ @pytest.mark.parametrize("status", [400, 401, 403, 429, 500, 503])
275
+ def test_other_statuses_do_not_trigger_model_skip(self, status):
276
+ assert groq_bugteam.should_skip_to_next_model(self._make_error(status)) is False
277
+
278
+
279
+ class TestBuildFixUserMessage:
280
+ def test_embeds_file_content_byte_for_byte_with_trailing_newline(self):
281
+ original_content = "line1\nline2\n"
282
+ message = groq_bugteam.build_fix_user_message("some.py", original_content, findings_block="[]")
283
+ assert original_content in message
284
+ assert "line2\n</current_file_contents>" in message
285
+
286
+ def test_embeds_file_content_byte_for_byte_without_trailing_newline(self):
287
+ original_content = "line1\nline2"
288
+ message = groq_bugteam.build_fix_user_message("some.py", original_content, findings_block="[]")
289
+ assert f"{original_content}\n</current_file_contents>" in message
290
+ assert "line2\n\n</current_file_contents>" not in message
291
+
292
+
293
+ class TestShouldWriteFixedFile:
294
+ def test_does_not_write_when_no_finding_applied(self):
295
+ assert groq_bugteam.should_write_fixed_file(
296
+ applied_indexes=set(),
297
+ updated_content="new",
298
+ current_content="old",
299
+ ) is False
300
+
301
+ def test_does_not_write_when_content_unchanged(self):
302
+ assert groq_bugteam.should_write_fixed_file(
303
+ applied_indexes={0},
304
+ updated_content="same",
305
+ current_content="same",
306
+ ) is False
307
+
308
+ def test_writes_when_finding_applied_and_content_changed(self):
309
+ assert groq_bugteam.should_write_fixed_file(
310
+ applied_indexes={0},
311
+ updated_content="new",
312
+ current_content="old",
313
+ ) is True
314
+
315
+
316
+ class TestPreserveTrailingNewline:
317
+ def test_adds_trailing_newline_when_original_had_one(self):
318
+ preserved = groq_bugteam.preserve_trailing_newline(
319
+ original="line1\nline2\n", updated="line1\nfixed2"
320
+ )
321
+ assert preserved == "line1\nfixed2\n"
322
+
323
+ def test_strips_trailing_newline_when_original_lacked_one(self):
324
+ preserved = groq_bugteam.preserve_trailing_newline(
325
+ original="no newline", updated="fixed content\n"
326
+ )
327
+ assert preserved == "fixed content"
328
+
329
+ def test_keeps_matching_form_unchanged(self):
330
+ assert (
331
+ groq_bugteam.preserve_trailing_newline(original="x\n", updated="y\n")
332
+ == "y\n"
333
+ )
334
+ assert (
335
+ groq_bugteam.preserve_trailing_newline(original="x", updated="y") == "y"
336
+ )
337
+
338
+
339
+ class TestIsSafeRelativePath:
340
+ def test_rejects_absolute_posix_path(self):
341
+ assert groq_bugteam.is_safe_relative_path("/etc/passwd") is False
342
+
343
+ def test_rejects_parent_directory_escape(self):
344
+ assert groq_bugteam.is_safe_relative_path("../../etc/passwd") is False
345
+
346
+ def test_rejects_embedded_parent_reference(self):
347
+ assert groq_bugteam.is_safe_relative_path("src/../../etc/passwd") is False
348
+
349
+ def test_accepts_simple_relative_path(self):
350
+ assert groq_bugteam.is_safe_relative_path("src/foo.py") is True
351
+
352
+ def test_accepts_nested_relative_path(self):
353
+ assert groq_bugteam.is_safe_relative_path("packages/mod/scripts/foo.py") is True
354
+
355
+
356
+ class TestDecodeSubprocessStderr:
357
+ def test_decodes_bytes_input(self):
358
+ decoded = groq_bugteam.decode_subprocess_stderr(b"fatal: broken")
359
+ assert decoded == "fatal: broken"
360
+
361
+ def test_returns_str_input_unchanged(self):
362
+ assert groq_bugteam.decode_subprocess_stderr("fatal: broken") == "fatal: broken"
363
+
364
+ def test_handles_none_input(self):
365
+ assert groq_bugteam.decode_subprocess_stderr(None) == ""
366
+
367
+ def test_replaces_undecodable_bytes(self):
368
+ decoded = groq_bugteam.decode_subprocess_stderr(b"\xff\xfe broken")
369
+ assert "broken" in decoded
370
+
371
+
372
+ class TestRunPipelineRefusals:
373
+ def test_rejects_missing_api_key(self, monkeypatch):
374
+ monkeypatch.delenv("GROQ_API_KEY", raising=False)
375
+ result = groq_bugteam.run_pipeline({"diff": "anything"})
376
+ assert "error" in result
377
+ assert "GROQ_API_KEY" in result["error"]
378
+
379
+ def test_rejects_empty_diff(self, monkeypatch):
380
+ monkeypatch.setenv("GROQ_API_KEY", "gsk_test_placeholder_value")
381
+ result = groq_bugteam.run_pipeline({"diff": " ", "files_content": {}})
382
+ assert "error" in result
383
+ assert "diff is empty" in result["error"]
384
+
385
+ def test_rejects_fixes_without_worktree(self, monkeypatch):
386
+ monkeypatch.setenv("GROQ_API_KEY", "gsk_test_placeholder_value")
387
+ result = groq_bugteam.run_pipeline(
388
+ {"diff": "some diff", "files_content": {"a.py": ""}, "apply_fixes": True}
389
+ )
390
+ assert "error" in result
391
+ assert "worktree_path" in result["error"]