claude-dev-env 1.29.2 → 1.30.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/code-quality-agent.md +279 -24
- package/agents/groq-coder.md +111 -0
- package/commands/plan.md +4 -5
- package/hooks/blocking/code_rules_enforcer.py +775 -8
- package/hooks/blocking/destructive_command_blocker.py +149 -12
- package/hooks/blocking/test_code_rules_enforcer.py +751 -0
- package/hooks/blocking/test_code_rules_enforcer_constant_equality.py +130 -0
- package/hooks/blocking/test_code_rules_enforcer_existence_checks.py +134 -0
- package/hooks/blocking/test_code_rules_enforcer_skip_decorators.py +150 -0
- package/hooks/blocking/test_destructive_command_blocker.py +281 -4
- package/hooks/blocking/test_pr_description_enforcer.py +9 -8
- package/hooks/git-hooks/test_config.py +9 -3
- package/hooks/git-hooks/test_gate_utils.py +9 -3
- package/hooks/git-hooks/test_pre_commit.py +9 -3
- package/hooks/git-hooks/test_pre_push.py +9 -3
- package/hooks/validators/run_all_validators.py +76 -3
- package/hooks/validators/test_files/skip_decorators/conftest.py +9 -0
- package/hooks/validators/test_output_formatter.py +4 -16
- package/hooks/validators/test_run_all_validators.py +22 -0
- package/hooks/validators/test_run_all_validators_integration.py +2 -11
- package/package.json +1 -1
- package/scripts/config/groq_bugteam_config.py +104 -0
- package/scripts/config/test_groq_bugteam_config.py +11 -0
- package/scripts/config/test_spec_implementer_prompt.py +36 -0
- package/scripts/groq_bugteam.README.md +2 -0
- package/scripts/groq_bugteam.py +74 -15
- package/scripts/groq_bugteam_dotenv.py +40 -0
- package/scripts/groq_bugteam_spec.py +226 -0
- package/scripts/test_groq_bugteam.py +143 -5
- package/scripts/test_groq_bugteam_apply_fix_from_spec.py +426 -0
- package/scripts/test_groq_bugteam_dotenv.py +66 -0
- package/scripts/test_groq_bugteam_spec.py +346 -0
- package/scripts/tests/test_sync_to_cursor.py +36 -70
- package/skills/bugteam/SKILL.md +4 -0
- package/skills/bugteam/reference/README.md +16 -0
- package/skills/bugteam/test_skill_additions.py +30 -0
- package/skills/monitor-open-prs/SKILL.md +104 -0
- package/skills/monitor-open-prs/scripts/discover_open_prs.py +69 -0
- package/skills/monitor-open-prs/scripts/test_discover_open_prs.py +149 -0
- package/skills/monitor-open-prs/test_skill_contract.py +43 -0
- package/skills/pr-review-responder/SKILL.md +10 -8
- package/hooks/github-action/pre-push-review.yml +0 -27
- package/hooks/github-action/test_workflow.py +0 -33
- 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
|
-
|
|
15
|
-
|
|
16
|
-
sys.path
|
|
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
|
-
|
|
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"
|