claude-dev-env 1.29.3 → 1.30.1

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 (43) hide show
  1. package/CLAUDE.md +8 -0
  2. package/agents/code-quality-agent.md +279 -24
  3. package/agents/groq-coder.md +111 -0
  4. package/commands/plan.md +4 -5
  5. package/docs/CODE_RULES.md +40 -0
  6. package/hooks/blocking/code_rules_enforcer.py +775 -8
  7. package/hooks/blocking/destructive_command_blocker.py +149 -12
  8. package/hooks/blocking/test_code_rules_enforcer.py +751 -0
  9. package/hooks/blocking/test_code_rules_enforcer_constant_equality.py +130 -0
  10. package/hooks/blocking/test_code_rules_enforcer_existence_checks.py +134 -0
  11. package/hooks/blocking/test_code_rules_enforcer_skip_decorators.py +150 -0
  12. package/hooks/blocking/test_destructive_command_blocker.py +281 -4
  13. package/hooks/git-hooks/test_config.py +9 -3
  14. package/hooks/git-hooks/test_gate_utils.py +9 -3
  15. package/hooks/git-hooks/test_pre_commit.py +9 -3
  16. package/hooks/git-hooks/test_pre_push.py +9 -3
  17. package/hooks/validators/run_all_validators.py +76 -3
  18. package/hooks/validators/test_output_formatter.py +4 -16
  19. package/hooks/validators/test_run_all_validators.py +22 -0
  20. package/hooks/validators/test_run_all_validators_integration.py +2 -11
  21. package/package.json +1 -1
  22. package/scripts/config/groq_bugteam_config.py +104 -0
  23. package/scripts/config/test_groq_bugteam_config.py +11 -0
  24. package/scripts/config/test_spec_implementer_prompt.py +36 -0
  25. package/scripts/groq_bugteam.README.md +2 -0
  26. package/scripts/groq_bugteam.py +74 -15
  27. package/scripts/groq_bugteam_dotenv.py +40 -0
  28. package/scripts/groq_bugteam_spec.py +226 -0
  29. package/scripts/test_groq_bugteam.py +143 -5
  30. package/scripts/test_groq_bugteam_apply_fix_from_spec.py +426 -0
  31. package/scripts/test_groq_bugteam_dotenv.py +66 -0
  32. package/scripts/test_groq_bugteam_spec.py +346 -0
  33. package/skills/bugteam/SKILL.md +4 -0
  34. package/skills/bugteam/reference/README.md +16 -0
  35. package/skills/bugteam/test_skill_additions.py +30 -0
  36. package/skills/monitor-open-prs/SKILL.md +104 -0
  37. package/skills/monitor-open-prs/scripts/discover_open_prs.py +69 -0
  38. package/skills/monitor-open-prs/scripts/test_discover_open_prs.py +149 -0
  39. package/skills/monitor-open-prs/test_skill_contract.py +43 -0
  40. package/skills/pr-review-responder/SKILL.md +10 -8
  41. package/hooks/github-action/pre-push-review.yml +0 -27
  42. package/hooks/github-action/test_workflow.py +0 -33
  43. package/skills/pr-review-responder/update_skill.py +0 -297
@@ -8,22 +8,29 @@ from __future__ import annotations
8
8
 
9
9
  import importlib.util
10
10
  import pathlib
11
+ import re
11
12
  import sys
12
13
  import urllib.error
13
14
 
14
- import pytest
15
-
16
- sys.path.insert(0, str(pathlib.Path(__file__).parent))
15
+ scripts_directory = pathlib.Path(__file__).parent
16
+ scripts_directory_string = str(scripts_directory)
17
+ if scripts_directory_string not in sys.path:
18
+ sys.path.insert(0, scripts_directory_string)
17
19
  for _cached in list(sys.modules):
18
20
  if _cached == "config" or _cached.startswith("config."):
19
21
  del sys.modules[_cached]
20
22
 
23
+ import groq_bugteam_dotenv # noqa: E402
24
+ import pytest # noqa: E402
25
+
21
26
  from config import groq_bugteam_config # noqa: E402
22
27
 
23
28
 
24
29
  def _load_groq_bugteam_module():
25
30
  scripts_directory = pathlib.Path(__file__).parent
26
- sys.path.insert(0, str(scripts_directory))
31
+ scripts_directory_string = str(scripts_directory)
32
+ if scripts_directory_string not in sys.path:
33
+ sys.path.insert(0, scripts_directory_string)
27
34
  for cached_module_name in list(sys.modules):
28
35
  if cached_module_name == "config" or cached_module_name.startswith("config."):
29
36
  del sys.modules[cached_module_name]
@@ -64,6 +71,32 @@ class TestClampText:
64
71
  assert clamped.startswith("HEAD")
65
72
  assert clamped.endswith("TAIL")
66
73
 
74
+ @pytest.mark.parametrize("max_characters", [50, 100, 200, 500, 1000])
75
+ def test_output_never_exceeds_max_characters(self, max_characters):
76
+ long_text = "a" * 5000
77
+ clamped = groq_bugteam.clamp_text(long_text, max_characters)
78
+ assert len(clamped) <= max_characters
79
+
80
+ def test_returns_plain_head_when_marker_does_not_fit(self):
81
+ long_text = "a" * 1000
82
+ tiny_budget = 10
83
+ clamped = groq_bugteam.clamp_text(long_text, tiny_budget)
84
+ assert len(clamped) <= tiny_budget
85
+ assert clamped == long_text[:tiny_budget]
86
+ assert "truncated" not in clamped
87
+
88
+ def test_truncation_marker_count_matches_characters_actually_dropped(self):
89
+ long_text = "a" * 1000
90
+ max_characters = 200
91
+ clamped = groq_bugteam.clamp_text(long_text, max_characters)
92
+ marker_match = re.search(r"truncated (\d+) chars", clamped)
93
+ assert marker_match is not None
94
+ reported_truncated_count = int(marker_match.group(1))
95
+ full_marker = f"\n\n... [truncated {reported_truncated_count} chars] ...\n\n"
96
+ preserved_original_length = len(clamped) - len(full_marker)
97
+ actually_truncated_count = len(long_text) - preserved_original_length
98
+ assert reported_truncated_count == actually_truncated_count
99
+
67
100
 
68
101
  class TestParseJsonObject:
69
102
  def test_parses_clean_json(self):
@@ -276,6 +309,106 @@ class TestIsRecoverableHttpError:
276
309
  assert groq_bugteam.should_skip_to_next_model(self._make_error(status)) is False
277
310
 
278
311
 
312
+ class TestCallGroqWithFallback:
313
+ def _install_fake_transport(self, monkeypatch, fake_post_to_groq):
314
+ monkeypatch.setattr(groq_bugteam, "post_to_groq", fake_post_to_groq)
315
+ monkeypatch.setattr(groq_bugteam.time, "sleep", lambda _seconds: None)
316
+
317
+ def test_non_recoverable_http_error_does_not_attempt_fallback_model(self, monkeypatch):
318
+ attempted_models: list[str] = []
319
+
320
+ def fake_post_to_groq(api_key, model, messages, temperature, max_completion_tokens):
321
+ attempted_models.append(model)
322
+ raise urllib.error.HTTPError(
323
+ url="x", code=401, msg="unauthorized", hdrs=None, fp=None
324
+ )
325
+
326
+ self._install_fake_transport(monkeypatch, fake_post_to_groq)
327
+ with pytest.raises(RuntimeError):
328
+ groq_bugteam.call_groq_with_fallback("k", [], 0.0, 100)
329
+ assert attempted_models == [groq_bugteam.GROQ_PRIMARY_MODEL]
330
+
331
+ def test_413_falls_back_to_secondary_model(self, monkeypatch):
332
+ attempted_models: list[str] = []
333
+
334
+ def fake_post_to_groq(api_key, model, messages, temperature, max_completion_tokens):
335
+ attempted_models.append(model)
336
+ if model == groq_bugteam.GROQ_PRIMARY_MODEL:
337
+ raise urllib.error.HTTPError(
338
+ url="x", code=413, msg="payload too large", hdrs=None, fp=None
339
+ )
340
+ return "ok-content"
341
+
342
+ self._install_fake_transport(monkeypatch, fake_post_to_groq)
343
+ result = groq_bugteam.call_groq_with_fallback("k", [], 0.0, 100)
344
+ assert result.model == groq_bugteam.GROQ_FALLBACK_MODEL
345
+ assert attempted_models[0] == groq_bugteam.GROQ_PRIMARY_MODEL
346
+ assert groq_bugteam.GROQ_FALLBACK_MODEL in attempted_models
347
+
348
+ def test_recoverable_error_retries_same_model_then_falls_back(self, monkeypatch):
349
+ call_log: list[str] = []
350
+
351
+ def fake_post_to_groq(api_key, model, messages, temperature, max_completion_tokens):
352
+ call_log.append(model)
353
+ raise urllib.error.HTTPError(
354
+ url="x", code=503, msg="service unavailable", hdrs=None, fp=None
355
+ )
356
+
357
+ self._install_fake_transport(monkeypatch, fake_post_to_groq)
358
+ with pytest.raises(RuntimeError):
359
+ groq_bugteam.call_groq_with_fallback("k", [], 0.0, 100)
360
+ assert call_log.count(groq_bugteam.GROQ_PRIMARY_MODEL) > 1
361
+ assert groq_bugteam.GROQ_FALLBACK_MODEL in call_log
362
+
363
+
364
+ class TestCoerceIndexesToIntSet:
365
+ def test_coerces_string_indexes_to_ints(self):
366
+ assert groq_bugteam.coerce_indexes_to_int_set(["0", "2"]) == {0, 2}
367
+
368
+ def test_drops_non_numeric_entries(self):
369
+ assert groq_bugteam.coerce_indexes_to_int_set(["0", "abc", None, 1]) == {0, 1}
370
+
371
+ def test_handles_none_input(self):
372
+ assert groq_bugteam.coerce_indexes_to_int_set(None) == set()
373
+
374
+ def test_handles_empty_list(self):
375
+ assert groq_bugteam.coerce_indexes_to_int_set([]) == set()
376
+
377
+ def test_accepts_already_int_values(self):
378
+ assert groq_bugteam.coerce_indexes_to_int_set([0, 1, 2]) == {0, 1, 2}
379
+
380
+
381
+ class TestCoerceSkippedEntries:
382
+ def test_coerces_string_finding_index_to_int(self):
383
+ assert groq_bugteam.coerce_skipped_entries(
384
+ [{"finding_index": "3", "reason": "x"}]
385
+ ) == {3: "x"}
386
+
387
+ def test_drops_entries_without_parseable_index(self):
388
+ assert groq_bugteam.coerce_skipped_entries(
389
+ [{"finding_index": "not-a-number", "reason": "x"}]
390
+ ) == {}
391
+
392
+ def test_drops_entries_missing_finding_index(self):
393
+ assert groq_bugteam.coerce_skipped_entries([{"reason": "orphan"}]) == {}
394
+
395
+ def test_defaults_reason_to_empty_string(self):
396
+ assert groq_bugteam.coerce_skipped_entries([{"finding_index": 1}]) == {1: ""}
397
+
398
+ def test_handles_none_input(self):
399
+ assert groq_bugteam.coerce_skipped_entries(None) == {}
400
+
401
+ def test_treats_none_reason_as_empty_string(self):
402
+ assert groq_bugteam.coerce_skipped_entries(
403
+ [{"finding_index": 1, "reason": None}]
404
+ ) == {1: ""}
405
+
406
+ def test_stringifies_non_string_reasons(self):
407
+ assert groq_bugteam.coerce_skipped_entries(
408
+ [{"finding_index": 1, "reason": 42}]
409
+ ) == {1: "42"}
410
+
411
+
279
412
  class TestBuildFixUserMessage:
280
413
  def test_embeds_file_content_byte_for_byte_with_trailing_newline(self):
281
414
  original_content = "line1\nline2\n"
@@ -370,8 +503,13 @@ class TestDecodeSubprocessStderr:
370
503
 
371
504
 
372
505
  class TestRunPipelineRefusals:
373
- def test_rejects_missing_api_key(self, monkeypatch):
506
+ def test_rejects_missing_api_key(self, monkeypatch, tmp_path):
374
507
  monkeypatch.delenv("GROQ_API_KEY", raising=False)
508
+ monkeypatch.setattr(
509
+ groq_bugteam_dotenv,
510
+ "claude_dev_env_dotenv_path",
511
+ lambda: tmp_path / "missing.env",
512
+ )
375
513
  result = groq_bugteam.run_pipeline({"diff": "anything"})
376
514
  assert "error" in result
377
515
  assert "GROQ_API_KEY" in result["error"]
@@ -0,0 +1,426 @@
1
+ """Tests for groq_bugteam.apply_fix_from_spec().
2
+
3
+ Covers the Claude-authored fix-spec pipeline: replacement_code splicing,
4
+ intended_change derivation, acceptance-criterion self-check, out-of-range
5
+ guard, and trailing-newline preservation. All Groq HTTP calls are
6
+ monkeypatched; no network activity.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import importlib.util
12
+ import io
13
+ import json
14
+ import pathlib
15
+ import sys
16
+
17
+ import pytest
18
+
19
+
20
+ def _load_groq_bugteam_module():
21
+ scripts_directory = pathlib.Path(__file__).parent
22
+ sys.path.insert(0, str(scripts_directory))
23
+ modules_to_remove = [
24
+ each_module_name
25
+ for each_module_name in list(sys.modules)
26
+ if each_module_name == "groq_bugteam"
27
+ or each_module_name.startswith("groq_bugteam.")
28
+ ]
29
+ for each_module_name in modules_to_remove:
30
+ del sys.modules[each_module_name]
31
+ module_path = scripts_directory / "groq_bugteam.py"
32
+ module_spec = importlib.util.spec_from_file_location("groq_bugteam", module_path)
33
+ loaded_module = importlib.util.module_from_spec(module_spec)
34
+ sys.modules["groq_bugteam"] = loaded_module
35
+ module_spec.loader.exec_module(loaded_module)
36
+ return loaded_module
37
+
38
+
39
+ groq_bugteam = _load_groq_bugteam_module()
40
+
41
+
42
+ FAKE_API_KEY = "gsk_test_placeholder_value"
43
+
44
+
45
+ def _stub_groq_response(monkeypatch, response_object: dict) -> None:
46
+ """Force call_groq_with_fallback() to return a synthetic JSON payload."""
47
+
48
+ def fake_call(api_key, messages, temperature, max_completion_tokens):
49
+ return groq_bugteam.GroqCallResult(
50
+ content=json.dumps(response_object),
51
+ model="fake-model",
52
+ )
53
+
54
+ monkeypatch.setenv("GROQ_API_KEY", FAKE_API_KEY)
55
+ monkeypatch.setattr(groq_bugteam, "call_groq_with_fallback", fake_call)
56
+
57
+
58
+ class TestApplyFixFromSpecReplacementCode:
59
+ def test_applies_replacement_code_byte_for_byte_outside_edit(self, monkeypatch):
60
+ original_file = "line_one\nline_two\nline_three\n"
61
+ spec_list = [
62
+ {
63
+ "finding_index": 0,
64
+ "severity": "P1",
65
+ "category": "J",
66
+ "file": "sample.py",
67
+ "target_line_start": 2,
68
+ "target_line_end": 2,
69
+ "intended_change": "replace line_two",
70
+ "replacement_code": "line_two_fixed",
71
+ "acceptance_criteria": ["line_two_fixed appears on line 2"],
72
+ }
73
+ ]
74
+ patched_file = "line_one\nline_two_fixed\nline_three\n"
75
+ fake_response = {
76
+ "updated_content": patched_file,
77
+ "applied_finding_indexes": [0],
78
+ "skipped": [],
79
+ "acceptance_checks": [
80
+ {
81
+ "finding_index": 0,
82
+ "criterion": "line_two_fixed appears on line 2",
83
+ "met": True,
84
+ }
85
+ ],
86
+ }
87
+ _stub_groq_response(monkeypatch, fake_response)
88
+
89
+ outcome = groq_bugteam.apply_fix_from_spec(spec_list, original_file)
90
+
91
+ assert outcome["updated_content"] == patched_file
92
+ assert outcome["applied_finding_indexes"] == [0]
93
+ assert outcome["skipped"] == []
94
+
95
+
96
+ class TestApplyFixFromSpecDerivedEdit:
97
+ def test_derives_minimal_edit_when_replacement_absent(self, monkeypatch):
98
+ original_file = "value = 1\nreturn value\n"
99
+ spec_list = [
100
+ {
101
+ "finding_index": 3,
102
+ "severity": "P2",
103
+ "category": "E",
104
+ "file": "sample.py",
105
+ "target_line_start": 1,
106
+ "target_line_end": 1,
107
+ "intended_change": "rename value to total_count",
108
+ "acceptance_criteria": [
109
+ "variable named total_count exists on line 1",
110
+ "the literal token value does not appear on line 1",
111
+ ],
112
+ }
113
+ ]
114
+ patched_file = "total_count = 1\nreturn value\n"
115
+ fake_response = {
116
+ "updated_content": patched_file,
117
+ "applied_finding_indexes": [3],
118
+ "skipped": [],
119
+ "acceptance_checks": [
120
+ {
121
+ "finding_index": 3,
122
+ "criterion": "variable named total_count exists on line 1",
123
+ "met": True,
124
+ },
125
+ {
126
+ "finding_index": 3,
127
+ "criterion": "the literal token value does not appear on line 1",
128
+ "met": True,
129
+ },
130
+ ],
131
+ }
132
+ _stub_groq_response(monkeypatch, fake_response)
133
+
134
+ outcome = groq_bugteam.apply_fix_from_spec(spec_list, original_file)
135
+
136
+ assert outcome["updated_content"] == patched_file
137
+ assert outcome["applied_finding_indexes"] == [3]
138
+
139
+
140
+ class TestApplyFixFromSpecAcceptanceFailure:
141
+ def test_moves_finding_to_skipped_when_any_criterion_unmet(self, monkeypatch):
142
+ original_file = "alpha\nbeta\n"
143
+ spec_list = [
144
+ {
145
+ "finding_index": 7,
146
+ "severity": "P1",
147
+ "category": "H",
148
+ "file": "sample.py",
149
+ "target_line_start": 2,
150
+ "target_line_end": 2,
151
+ "intended_change": "replace beta with gamma",
152
+ "replacement_code": "gamma",
153
+ "acceptance_criteria": [
154
+ "gamma appears on line 2",
155
+ "delta appears on line 2",
156
+ ],
157
+ }
158
+ ]
159
+ patched_file = "alpha\ngamma\n"
160
+ fake_response = {
161
+ "updated_content": patched_file,
162
+ "applied_finding_indexes": [7],
163
+ "skipped": [],
164
+ "acceptance_checks": [
165
+ {
166
+ "finding_index": 7,
167
+ "criterion": "gamma appears on line 2",
168
+ "met": True,
169
+ },
170
+ {
171
+ "finding_index": 7,
172
+ "criterion": "delta appears on line 2",
173
+ "met": False,
174
+ },
175
+ ],
176
+ }
177
+ _stub_groq_response(monkeypatch, fake_response)
178
+
179
+ outcome = groq_bugteam.apply_fix_from_spec(spec_list, original_file)
180
+
181
+ assert 7 not in outcome["applied_finding_indexes"]
182
+ skipped_indexes = [each["finding_index"] for each in outcome["skipped"]]
183
+ assert 7 in skipped_indexes
184
+ reason_text = next(
185
+ each["reason"] for each in outcome["skipped"] if each["finding_index"] == 7
186
+ )
187
+ assert "delta appears on line 2" in reason_text
188
+
189
+
190
+ class TestApplyFixFromSpecOutOfRange:
191
+ def test_skips_when_target_lines_out_of_range(self, monkeypatch):
192
+ original_file = "only_line\n"
193
+ spec_list = [
194
+ {
195
+ "finding_index": 2,
196
+ "severity": "P2",
197
+ "category": "E",
198
+ "file": "sample.py",
199
+ "target_line_start": 50,
200
+ "target_line_end": 51,
201
+ "intended_change": "fix beyond file end",
202
+ "replacement_code": "noop",
203
+ "acceptance_criteria": ["noop replaces line 50"],
204
+ }
205
+ ]
206
+ fake_response = {
207
+ "updated_content": original_file,
208
+ "applied_finding_indexes": [],
209
+ "skipped": [
210
+ {
211
+ "finding_index": 2,
212
+ "reason": "target_line_start out of range",
213
+ }
214
+ ],
215
+ "acceptance_checks": [],
216
+ }
217
+ _stub_groq_response(monkeypatch, fake_response)
218
+
219
+ outcome = groq_bugteam.apply_fix_from_spec(spec_list, original_file)
220
+
221
+ assert outcome["updated_content"] == original_file
222
+ assert outcome["applied_finding_indexes"] == []
223
+ assert outcome["skipped"][0]["finding_index"] == 2
224
+
225
+
226
+ class TestApplyFixFromSpecTrailingNewline:
227
+ def test_preserves_trailing_newline_when_original_had_one(self, monkeypatch):
228
+ original_file = "alpha\nbeta\n"
229
+ spec_list = [
230
+ {
231
+ "finding_index": 0,
232
+ "severity": "P1",
233
+ "category": "J",
234
+ "file": "sample.py",
235
+ "target_line_start": 1,
236
+ "target_line_end": 1,
237
+ "intended_change": "rename alpha to alpha_fixed",
238
+ "replacement_code": "alpha_fixed",
239
+ "acceptance_criteria": ["alpha_fixed appears on line 1"],
240
+ }
241
+ ]
242
+ fake_response = {
243
+ "updated_content": "alpha_fixed\nbeta",
244
+ "applied_finding_indexes": [0],
245
+ "skipped": [],
246
+ "acceptance_checks": [
247
+ {
248
+ "finding_index": 0,
249
+ "criterion": "alpha_fixed appears on line 1",
250
+ "met": True,
251
+ }
252
+ ],
253
+ }
254
+ _stub_groq_response(monkeypatch, fake_response)
255
+
256
+ outcome = groq_bugteam.apply_fix_from_spec(spec_list, original_file)
257
+
258
+ assert outcome["updated_content"].endswith("\n")
259
+
260
+ def test_preserves_absence_of_trailing_newline(self, monkeypatch):
261
+ original_file = "alpha\nbeta"
262
+ spec_list = [
263
+ {
264
+ "finding_index": 0,
265
+ "severity": "P1",
266
+ "category": "J",
267
+ "file": "sample.py",
268
+ "target_line_start": 1,
269
+ "target_line_end": 1,
270
+ "intended_change": "rename alpha to alpha_fixed",
271
+ "replacement_code": "alpha_fixed",
272
+ "acceptance_criteria": ["alpha_fixed appears on line 1"],
273
+ }
274
+ ]
275
+ fake_response = {
276
+ "updated_content": "alpha_fixed\nbeta\n",
277
+ "applied_finding_indexes": [0],
278
+ "skipped": [],
279
+ "acceptance_checks": [
280
+ {
281
+ "finding_index": 0,
282
+ "criterion": "alpha_fixed appears on line 1",
283
+ "met": True,
284
+ }
285
+ ],
286
+ }
287
+ _stub_groq_response(monkeypatch, fake_response)
288
+
289
+ outcome = groq_bugteam.apply_fix_from_spec(spec_list, original_file)
290
+
291
+ assert not outcome["updated_content"].endswith("\n")
292
+
293
+
294
+ class TestApplyFixFromSpecUntrustedResponseShape:
295
+ def test_skipped_entry_missing_finding_index_does_not_crash(self, monkeypatch):
296
+ original_file = "alpha\nbeta\n"
297
+ spec_list = [
298
+ {
299
+ "finding_index": 4,
300
+ "severity": "P1",
301
+ "category": "J",
302
+ "file": "sample.py",
303
+ "target_line_start": 1,
304
+ "target_line_end": 1,
305
+ "intended_change": "rename alpha",
306
+ "replacement_code": "alpha_fixed",
307
+ "acceptance_criteria": ["alpha_fixed appears on line 1"],
308
+ }
309
+ ]
310
+ patched_file = "alpha_fixed\nbeta\n"
311
+ fake_response = {
312
+ "updated_content": patched_file,
313
+ "applied_finding_indexes": [4],
314
+ "skipped": [{"reason": "malformed entry without finding_index"}],
315
+ "acceptance_checks": [
316
+ {
317
+ "finding_index": 4,
318
+ "criterion": "alpha_fixed appears on line 1",
319
+ "met": True,
320
+ }
321
+ ],
322
+ }
323
+ _stub_groq_response(monkeypatch, fake_response)
324
+
325
+ outcome = groq_bugteam.apply_fix_from_spec(spec_list, original_file)
326
+
327
+ assert outcome["updated_content"] == patched_file
328
+ assert outcome["applied_finding_indexes"] == [4]
329
+
330
+ def test_null_updated_content_falls_back_to_current_content(self, monkeypatch):
331
+ original_file = "alpha\nbeta\n"
332
+ spec_list = [
333
+ {
334
+ "finding_index": 0,
335
+ "severity": "P2",
336
+ "category": "E",
337
+ "file": "sample.py",
338
+ "target_line_start": 1,
339
+ "target_line_end": 1,
340
+ "intended_change": "no-op fallback",
341
+ "replacement_code": "alpha",
342
+ "acceptance_criteria": ["alpha remains on line 1"],
343
+ }
344
+ ]
345
+ fake_response = {
346
+ "updated_content": None,
347
+ "applied_finding_indexes": [],
348
+ "skipped": [
349
+ {
350
+ "finding_index": 0,
351
+ "reason": "Groq returned null updated_content",
352
+ }
353
+ ],
354
+ "acceptance_checks": [],
355
+ }
356
+ _stub_groq_response(monkeypatch, fake_response)
357
+
358
+ outcome = groq_bugteam.apply_fix_from_spec(spec_list, original_file)
359
+
360
+ assert outcome["updated_content"] == original_file
361
+
362
+ def test_null_collection_fields_coerce_to_empty_lists(self, monkeypatch):
363
+ original_file = "alpha\n"
364
+ spec_list = [
365
+ {
366
+ "finding_index": 1,
367
+ "severity": "P2",
368
+ "category": "E",
369
+ "file": "sample.py",
370
+ "target_line_start": 1,
371
+ "target_line_end": 1,
372
+ "intended_change": "no-op",
373
+ "replacement_code": "alpha",
374
+ "acceptance_criteria": ["alpha remains"],
375
+ }
376
+ ]
377
+ fake_response = {
378
+ "updated_content": original_file,
379
+ "applied_finding_indexes": None,
380
+ "skipped": None,
381
+ "acceptance_checks": None,
382
+ }
383
+ _stub_groq_response(monkeypatch, fake_response)
384
+
385
+ outcome = groq_bugteam.apply_fix_from_spec(spec_list, original_file)
386
+
387
+ assert outcome["applied_finding_indexes"] == []
388
+ assert outcome["skipped"] == []
389
+ assert outcome["acceptance_checks"] == []
390
+
391
+
392
+ class TestRunSpecModeMainErrorContract:
393
+ def test_missing_api_key_emits_json_error_and_exits_nonzero(
394
+ self, monkeypatch, capsys
395
+ ):
396
+ monkeypatch.delenv("GROQ_API_KEY", raising=False)
397
+ monkeypatch.setattr(
398
+ "groq_bugteam_dotenv.load_claude_dev_env_dotenv_file",
399
+ lambda: None,
400
+ )
401
+ spec_payload = {
402
+ "spec": [
403
+ {
404
+ "finding_index": 0,
405
+ "severity": "P1",
406
+ "category": "J",
407
+ "file": "sample.py",
408
+ "target_line_start": 1,
409
+ "target_line_end": 1,
410
+ "intended_change": "noop",
411
+ "replacement_code": "noop",
412
+ "acceptance_criteria": ["noop"],
413
+ }
414
+ ],
415
+ "current_content": "noop\n",
416
+ }
417
+ monkeypatch.setattr("sys.stdin", io.StringIO(json.dumps(spec_payload)))
418
+
419
+ with pytest.raises(SystemExit) as exit_info:
420
+ groq_bugteam.run_spec_mode_main()
421
+
422
+ captured = capsys.readouterr()
423
+ emitted_outcome = json.loads(captured.out)
424
+ assert "error" in emitted_outcome
425
+ assert "GROQ_API_KEY" in emitted_outcome["error"]
426
+ assert exit_info.value.code != 0
@@ -0,0 +1,66 @@
1
+ """Tests for groq_bugteam_dotenv local .env loading."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import pathlib
7
+ import sys
8
+
9
+ import pytest
10
+
11
+ _SCRIPTS_DIRECTORY = pathlib.Path(__file__).parent.resolve()
12
+ sys.path.insert(0, str(_SCRIPTS_DIRECTORY))
13
+
14
+ from groq_bugteam_dotenv import ( # noqa: E402
15
+ claude_dev_env_dotenv_path,
16
+ load_claude_dev_env_dotenv_file,
17
+ )
18
+
19
+
20
+ class TestLoadClaudeDevEnvDotenvFile:
21
+ def test_sets_groq_key_from_file(self, monkeypatch, tmp_path):
22
+ monkeypatch.delenv("GROQ_API_KEY", raising=False)
23
+ env_file = tmp_path / ".env"
24
+ env_file.write_text("GROQ_API_KEY=from_file_value\n", encoding="utf-8")
25
+ load_claude_dev_env_dotenv_file(env_file)
26
+ assert os.environ["GROQ_API_KEY"] == "from_file_value"
27
+
28
+ def test_does_not_override_existing_key(self, monkeypatch, tmp_path):
29
+ monkeypatch.setenv("GROQ_API_KEY", "preset_value")
30
+ env_file = tmp_path / ".env"
31
+ env_file.write_text("GROQ_API_KEY=from_file_value\n", encoding="utf-8")
32
+ load_claude_dev_env_dotenv_file(env_file)
33
+ assert os.environ["GROQ_API_KEY"] == "preset_value"
34
+
35
+ def test_skips_comments_and_blank_lines(self, monkeypatch, tmp_path):
36
+ monkeypatch.delenv("GROQ_API_KEY", raising=False)
37
+ env_file = tmp_path / ".env"
38
+ env_file.write_text("\n# comment\nGROQ_API_KEY=x\n", encoding="utf-8")
39
+ load_claude_dev_env_dotenv_file(env_file)
40
+ assert os.environ["GROQ_API_KEY"] == "x"
41
+
42
+ def test_strips_export_prefix(self, monkeypatch, tmp_path):
43
+ monkeypatch.delenv("GROQ_API_KEY", raising=False)
44
+ env_file = tmp_path / ".env"
45
+ env_file.write_text("export GROQ_API_KEY=exported\n", encoding="utf-8")
46
+ load_claude_dev_env_dotenv_file(env_file)
47
+ assert os.environ["GROQ_API_KEY"] == "exported"
48
+
49
+ def test_strips_double_quotes(self, monkeypatch, tmp_path):
50
+ monkeypatch.delenv("GROQ_API_KEY", raising=False)
51
+ env_file = tmp_path / ".env"
52
+ env_file.write_text('GROQ_API_KEY="quoted"\n', encoding="utf-8")
53
+ load_claude_dev_env_dotenv_file(env_file)
54
+ assert os.environ["GROQ_API_KEY"] == "quoted"
55
+
56
+ def test_missing_file_is_no_op(self, monkeypatch, tmp_path):
57
+ monkeypatch.delenv("GROQ_API_KEY", raising=False)
58
+ missing_file = tmp_path / "does_not_exist.env"
59
+ load_claude_dev_env_dotenv_file(missing_file)
60
+ assert "GROQ_API_KEY" not in os.environ
61
+
62
+
63
+ def test_claude_dev_env_dotenv_path_ends_with_env_filename():
64
+ resolved = claude_dev_env_dotenv_path()
65
+ assert resolved.name == ".env"
66
+ assert resolved.parent.name == "claude-dev-env"