codeforerunner 0.3.1__tar.gz → 0.3.2__tar.gz
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.
- {codeforerunner-0.3.1/src/codeforerunner.egg-info → codeforerunner-0.3.2}/PKG-INFO +1 -1
- {codeforerunner-0.3.1 → codeforerunner-0.3.2}/pyproject.toml +1 -1
- {codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner/check.py +116 -23
- {codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner/cli.py +25 -0
- {codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner/doctor.py +20 -0
- codeforerunner-0.3.2/src/codeforerunner/providers/anthropic.py +118 -0
- {codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner/providers/base.py +9 -1
- {codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner/providers/google.py +50 -0
- {codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner/providers/ollama.py +45 -0
- {codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner/providers/openai.py +54 -0
- {codeforerunner-0.3.1 → codeforerunner-0.3.2/src/codeforerunner.egg-info}/PKG-INFO +1 -1
- {codeforerunner-0.3.1 → codeforerunner-0.3.2}/tests/test_check.py +114 -0
- {codeforerunner-0.3.1 → codeforerunner-0.3.2}/tests/test_doctor.py +40 -1
- {codeforerunner-0.3.1 → codeforerunner-0.3.2}/tests/test_providers.py +177 -0
- codeforerunner-0.3.1/src/codeforerunner/providers/anthropic.py +0 -61
- {codeforerunner-0.3.1 → codeforerunner-0.3.2}/LICENSE.md +0 -0
- {codeforerunner-0.3.1 → codeforerunner-0.3.2}/README.md +0 -0
- {codeforerunner-0.3.1 → codeforerunner-0.3.2}/setup.cfg +0 -0
- {codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner/__init__.py +0 -0
- {codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner/bundle.py +0 -0
- {codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner/config.py +0 -0
- {codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner/installer.py +0 -0
- {codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner/mcp_server.py +0 -0
- {codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner/prompts/partials/context-format.md +0 -0
- {codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner/prompts/partials/output-rules.md +0 -0
- {codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner/prompts/partials/stack-hints.md +0 -0
- {codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner/prompts/system/base.md +0 -0
- {codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner/prompts/tasks/api-docs.md +0 -0
- {codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner/prompts/tasks/audit.md +0 -0
- {codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner/prompts/tasks/changelog.md +0 -0
- {codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner/prompts/tasks/check.md +0 -0
- {codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner/prompts/tasks/diagrams.md +0 -0
- {codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner/prompts/tasks/flows.md +0 -0
- {codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner/prompts/tasks/init-agent-onboarding.md +0 -0
- {codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner/prompts/tasks/readme.md +0 -0
- {codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner/prompts/tasks/review.md +0 -0
- {codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner/prompts/tasks/scan.md +0 -0
- {codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner/prompts/tasks/stack-docs.md +0 -0
- {codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner/prompts/tasks/version-audit.md +0 -0
- {codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner/providers/__init__.py +0 -0
- {codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner.egg-info/SOURCES.txt +0 -0
- {codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner.egg-info/dependency_links.txt +0 -0
- {codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner.egg-info/entry_points.txt +0 -0
- {codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner.egg-info/requires.txt +0 -0
- {codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner.egg-info/top_level.txt +0 -0
- {codeforerunner-0.3.1 → codeforerunner-0.3.2}/tests/test_check_config_integration.py +0 -0
- {codeforerunner-0.3.1 → codeforerunner-0.3.2}/tests/test_cli.py +0 -0
- {codeforerunner-0.3.1 → codeforerunner-0.3.2}/tests/test_config.py +0 -0
- {codeforerunner-0.3.1 → codeforerunner-0.3.2}/tests/test_examples.py +0 -0
- {codeforerunner-0.3.1 → codeforerunner-0.3.2}/tests/test_hooks_manifest.py +0 -0
- {codeforerunner-0.3.1 → codeforerunner-0.3.2}/tests/test_installer.py +0 -0
- {codeforerunner-0.3.1 → codeforerunner-0.3.2}/tests/test_mcp_server.py +0 -0
- {codeforerunner-0.3.1 → codeforerunner-0.3.2}/tests/test_validate_codex_marketplace.py +0 -0
- {codeforerunner-0.3.1 → codeforerunner-0.3.2}/tests/test_workflows_yaml.py +0 -0
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
"""Drift detection for docs
|
|
1
|
+
"""Drift detection for docs vs repo state."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
4
|
import fnmatch
|
|
5
5
|
import re
|
|
6
|
-
from dataclasses import dataclass
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
|
|
9
9
|
from codeforerunner.config import CheckConfig
|
|
@@ -17,8 +17,18 @@ class Violation:
|
|
|
17
17
|
message: str
|
|
18
18
|
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class _Rule:
|
|
22
|
+
id: str
|
|
23
|
+
pattern: re.Pattern
|
|
24
|
+
triggers: tuple[str, ...]
|
|
25
|
+
message: str
|
|
26
|
+
invert: bool = False # True = fire when triggers ABSENT (doc claims feature exists but file gone)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
_RULES: list[_Rule] = [
|
|
30
|
+
# Normal rules: fire when trigger EXISTS and phrase matches (doc denies a thing that's present)
|
|
31
|
+
_Rule(
|
|
22
32
|
"R1-no-cli",
|
|
23
33
|
re.compile(
|
|
24
34
|
r"(?i)no\s+CLI\s+exists"
|
|
@@ -29,56 +39,87 @@ _RULES = [
|
|
|
29
39
|
("src/codeforerunner/cli.py",),
|
|
30
40
|
"doc claims no CLI exists, but src/codeforerunner/cli.py is present",
|
|
31
41
|
),
|
|
32
|
-
(
|
|
42
|
+
_Rule(
|
|
33
43
|
"R2-no-pre-commit",
|
|
34
44
|
re.compile(r"(?i)no\s+pre[- ]commit(\s+hook)?"),
|
|
35
45
|
(".pre-commit-hooks.yaml",),
|
|
36
46
|
"doc claims no pre-commit hook, but .pre-commit-hooks.yaml is present",
|
|
37
47
|
),
|
|
38
|
-
(
|
|
48
|
+
_Rule(
|
|
39
49
|
"R3-no-ci",
|
|
40
50
|
re.compile(r"(?i)no\s+CI(\s+workflow)?"),
|
|
41
51
|
(".github/workflows/*.yml",),
|
|
42
52
|
"doc claims no CI workflow, but .github/workflows/*.yml is present",
|
|
43
53
|
),
|
|
44
|
-
(
|
|
54
|
+
_Rule(
|
|
45
55
|
"R4-no-installer",
|
|
46
56
|
re.compile(r"(?i)no\s+installer"),
|
|
47
57
|
("src/codeforerunner/installer.py",),
|
|
48
58
|
"doc claims no installer, but src/codeforerunner/installer.py is present",
|
|
49
59
|
),
|
|
50
|
-
(
|
|
60
|
+
_Rule(
|
|
51
61
|
"R5-no-python-package",
|
|
52
62
|
re.compile(r"(?i)no\s+Python\s+package"),
|
|
53
63
|
("pyproject.toml",),
|
|
54
64
|
"doc claims no Python package, but pyproject.toml is present",
|
|
55
65
|
),
|
|
56
|
-
(
|
|
66
|
+
_Rule(
|
|
57
67
|
"R6-no-docker",
|
|
58
68
|
re.compile(r"(?i)no\s+Docker(\s+image)?|no\s+Dockerfile"),
|
|
59
69
|
("Dockerfile", "compose.yml", "docker-compose.yml"),
|
|
60
70
|
"doc claims no Docker, but Dockerfile/compose file is present",
|
|
61
71
|
),
|
|
62
|
-
(
|
|
72
|
+
_Rule(
|
|
63
73
|
"R6b-no-makefile",
|
|
64
74
|
re.compile(r"(?i)no\s+Makefile"),
|
|
65
75
|
("Makefile",),
|
|
66
76
|
"doc claims no Makefile, but Makefile is present",
|
|
67
77
|
),
|
|
68
|
-
(
|
|
78
|
+
_Rule(
|
|
69
79
|
"R7-no-mcp",
|
|
70
80
|
re.compile(r"(?i)no\s+MCP(\s+server)?"),
|
|
71
81
|
("src/codeforerunner/mcp_server.py",),
|
|
72
82
|
"doc claims no MCP server, but src/codeforerunner/mcp_server.py is present",
|
|
73
83
|
),
|
|
74
|
-
(
|
|
84
|
+
_Rule(
|
|
75
85
|
"R8-no-marketplace",
|
|
76
86
|
re.compile(r"(?i)no\s+marketplace(\s+manifest)?"),
|
|
77
87
|
("plugins/codex/marketplace.json",),
|
|
78
88
|
"doc claims no marketplace, but plugins/codex/marketplace.json is present",
|
|
79
89
|
),
|
|
90
|
+
# Inverse rules: fire when trigger ABSENT and phrase matches (doc claims thing exists but file gone)
|
|
91
|
+
_Rule(
|
|
92
|
+
"RI1-missing-cli",
|
|
93
|
+
re.compile(
|
|
94
|
+
r"(?i)\bforerunner\s+(?:init|scan|doc|check|generate|doctor)\b"
|
|
95
|
+
),
|
|
96
|
+
("src/codeforerunner/cli.py",),
|
|
97
|
+
"doc references forerunner CLI commands, but src/codeforerunner/cli.py is absent",
|
|
98
|
+
invert=True,
|
|
99
|
+
),
|
|
100
|
+
_Rule(
|
|
101
|
+
"RI5-missing-python-package",
|
|
102
|
+
re.compile(r"(?i)\bpipx?\s+install\s+codeforerunner\b"),
|
|
103
|
+
("pyproject.toml",),
|
|
104
|
+
"doc claims package is installable via pip/pipx, but pyproject.toml is absent",
|
|
105
|
+
invert=True,
|
|
106
|
+
),
|
|
107
|
+
_Rule(
|
|
108
|
+
"RI7-missing-mcp",
|
|
109
|
+
re.compile(r"(?i)\bforerunner\s+mcp-server\b"),
|
|
110
|
+
("src/codeforerunner/mcp_server.py",),
|
|
111
|
+
"doc references forerunner mcp-server, but src/codeforerunner/mcp_server.py is absent",
|
|
112
|
+
invert=True,
|
|
113
|
+
),
|
|
80
114
|
]
|
|
81
115
|
|
|
116
|
+
_VERSION_PIN_RE = re.compile(
|
|
117
|
+
r"(?:codeforerunner==|codeforerunner@v)"
|
|
118
|
+
r"(\d+\.\d+\.\d+)"
|
|
119
|
+
)
|
|
120
|
+
_PYPROJECT_VERSION_RE = re.compile(r'^version\s*=\s*"(\d+\.\d+\.\d+)"', re.MULTILINE)
|
|
121
|
+
_CHANGELOG_FILENAME = "CHANGELOG.md"
|
|
122
|
+
|
|
82
123
|
|
|
83
124
|
def _trigger_exists(repo: Path, patterns: tuple[str, ...]) -> bool:
|
|
84
125
|
for pat in patterns:
|
|
@@ -114,6 +155,54 @@ def _path_ignored(repo: Path, doc: Path, ignore_patterns: tuple[str, ...]) -> bo
|
|
|
114
155
|
return any(fnmatch.fnmatch(rel, pat) for pat in ignore_patterns)
|
|
115
156
|
|
|
116
157
|
|
|
158
|
+
def _current_version(repo: Path) -> str | None:
|
|
159
|
+
pyproject = repo / "pyproject.toml"
|
|
160
|
+
if not pyproject.is_file():
|
|
161
|
+
return None
|
|
162
|
+
try:
|
|
163
|
+
text = pyproject.read_text(encoding="utf-8")
|
|
164
|
+
except OSError:
|
|
165
|
+
return None
|
|
166
|
+
m = _PYPROJECT_VERSION_RE.search(text)
|
|
167
|
+
return m.group(1) if m else None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _check_version_drift(
|
|
171
|
+
repo: Path,
|
|
172
|
+
docs: list[Path],
|
|
173
|
+
ignore_patterns: tuple[str, ...],
|
|
174
|
+
enabled: set[str] | None,
|
|
175
|
+
) -> list[Violation]:
|
|
176
|
+
if enabled is not None and "RV1-version-drift" not in enabled:
|
|
177
|
+
return []
|
|
178
|
+
current = _current_version(repo)
|
|
179
|
+
if current is None:
|
|
180
|
+
return []
|
|
181
|
+
violations: list[Violation] = []
|
|
182
|
+
for doc in docs:
|
|
183
|
+
if doc.name == _CHANGELOG_FILENAME:
|
|
184
|
+
continue
|
|
185
|
+
if _path_ignored(repo, doc, ignore_patterns):
|
|
186
|
+
continue
|
|
187
|
+
try:
|
|
188
|
+
text = doc.read_text(encoding="utf-8")
|
|
189
|
+
except (OSError, UnicodeDecodeError):
|
|
190
|
+
continue
|
|
191
|
+
for lineno, line in enumerate(text.splitlines(), start=1):
|
|
192
|
+
for m in _VERSION_PIN_RE.finditer(line):
|
|
193
|
+
pinned = m.group(1)
|
|
194
|
+
if pinned != current:
|
|
195
|
+
violations.append(
|
|
196
|
+
Violation(
|
|
197
|
+
path=doc,
|
|
198
|
+
line=lineno,
|
|
199
|
+
rule_id="RV1-version-drift",
|
|
200
|
+
message=f"version pin {pinned!r} does not match current {current!r}",
|
|
201
|
+
)
|
|
202
|
+
)
|
|
203
|
+
return violations
|
|
204
|
+
|
|
205
|
+
|
|
117
206
|
def run(repo: Path, config: CheckConfig | None = None) -> list[Violation]:
|
|
118
207
|
"""Scan repo docs for drift; return list of violations.
|
|
119
208
|
|
|
@@ -124,16 +213,18 @@ def run(repo: Path, config: CheckConfig | None = None) -> list[Violation]:
|
|
|
124
213
|
enabled = set(config.enabled_rules) if (config and config.enabled_rules is not None) else None
|
|
125
214
|
ignore_patterns = config.ignore_paths if config else ()
|
|
126
215
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
216
|
+
docs = _scanned_docs(repo)
|
|
217
|
+
|
|
218
|
+
active_rules: list[_Rule] = []
|
|
219
|
+
for rule in _RULES:
|
|
220
|
+
if enabled is not None and rule.id not in enabled:
|
|
221
|
+
continue
|
|
222
|
+
trigger_found = _trigger_exists(repo, rule.triggers)
|
|
223
|
+
if (not rule.invert and trigger_found) or (rule.invert and not trigger_found):
|
|
224
|
+
active_rules.append(rule)
|
|
134
225
|
|
|
135
226
|
violations: list[Violation] = []
|
|
136
|
-
for doc in
|
|
227
|
+
for doc in docs:
|
|
137
228
|
if _path_ignored(repo, doc, ignore_patterns):
|
|
138
229
|
continue
|
|
139
230
|
try:
|
|
@@ -141,11 +232,13 @@ def run(repo: Path, config: CheckConfig | None = None) -> list[Violation]:
|
|
|
141
232
|
except (OSError, UnicodeDecodeError):
|
|
142
233
|
continue
|
|
143
234
|
for lineno, line in enumerate(text.splitlines(), start=1):
|
|
144
|
-
for
|
|
145
|
-
if
|
|
235
|
+
for rule in active_rules:
|
|
236
|
+
if rule.pattern.search(line):
|
|
146
237
|
violations.append(
|
|
147
|
-
Violation(path=doc, line=lineno, rule_id=
|
|
238
|
+
Violation(path=doc, line=lineno, rule_id=rule.id, message=rule.message)
|
|
148
239
|
)
|
|
240
|
+
|
|
241
|
+
violations.extend(_check_version_drift(repo, docs, ignore_patterns, enabled))
|
|
149
242
|
return violations
|
|
150
243
|
|
|
151
244
|
|
|
@@ -135,6 +135,17 @@ def cmd_generate(args: argparse.Namespace) -> int:
|
|
|
135
135
|
print(f"error: missing API key; set ${env_var}", file=sys.stderr)
|
|
136
136
|
return 3
|
|
137
137
|
|
|
138
|
+
if getattr(args, "stream", False):
|
|
139
|
+
try:
|
|
140
|
+
for chunk in provider.stream(prompt=buf.getvalue(), model=model, api_key=api_key):
|
|
141
|
+
sys.stdout.write(chunk)
|
|
142
|
+
sys.stdout.flush()
|
|
143
|
+
except _providers.ProviderError as e:
|
|
144
|
+
print(f"error: {provider_name} provider failed: {e}", file=sys.stderr)
|
|
145
|
+
return 4
|
|
146
|
+
sys.stdout.write("\n")
|
|
147
|
+
return 0
|
|
148
|
+
|
|
138
149
|
try:
|
|
139
150
|
result = provider.complete(prompt=buf.getvalue(), model=model, api_key=api_key)
|
|
140
151
|
except _providers.ProviderError as e:
|
|
@@ -151,7 +162,15 @@ def cmd_generate(args: argparse.Namespace) -> int:
|
|
|
151
162
|
|
|
152
163
|
def cmd_doctor(args: argparse.Namespace) -> int:
|
|
153
164
|
from codeforerunner import doctor
|
|
165
|
+
from codeforerunner.config import CONFIG_FILENAME
|
|
154
166
|
root = Path(args.repo).resolve() if args.repo else Path.cwd()
|
|
167
|
+
if getattr(args, "fix", False):
|
|
168
|
+
cfg_path = root / CONFIG_FILENAME
|
|
169
|
+
if not cfg_path.is_file():
|
|
170
|
+
cfg_path.write_text(doctor.starter_config(), encoding="utf-8")
|
|
171
|
+
print(f"wrote {cfg_path}", file=sys.stderr)
|
|
172
|
+
else:
|
|
173
|
+
print(f"{cfg_path} already exists; skipping --fix", file=sys.stderr)
|
|
155
174
|
findings = doctor.run(root)
|
|
156
175
|
sys.stdout.write(doctor.format_report(findings) + "\n")
|
|
157
176
|
return 1 if any(f.severity == "error" for f in findings) else 0
|
|
@@ -200,12 +219,18 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
200
219
|
s_mcp.set_defaults(func=cmd_mcp_server)
|
|
201
220
|
|
|
202
221
|
s_doctor = sub.add_parser("doctor", help="health report: skill parity + marketplace + installed dests")
|
|
222
|
+
s_doctor.add_argument(
|
|
223
|
+
"--fix",
|
|
224
|
+
action="store_true",
|
|
225
|
+
help="write a starter forerunner.config.yaml if absent",
|
|
226
|
+
)
|
|
203
227
|
s_doctor.set_defaults(func=cmd_doctor)
|
|
204
228
|
|
|
205
229
|
s_gen = sub.add_parser("generate", help="resolve bundle for <task> and call the configured provider")
|
|
206
230
|
s_gen.add_argument("task", help="task basename under prompts/tasks/")
|
|
207
231
|
s_gen.add_argument("--provider", help="override config provider")
|
|
208
232
|
s_gen.add_argument("--model", help="override config model")
|
|
233
|
+
s_gen.add_argument("--stream", action="store_true", help="stream output token-by-token")
|
|
209
234
|
s_gen.set_defaults(func=cmd_generate)
|
|
210
235
|
|
|
211
236
|
from codeforerunner import installer
|
|
@@ -274,6 +274,26 @@ def _check_provider_api_key(repo: Path) -> list[Finding]:
|
|
|
274
274
|
]
|
|
275
275
|
|
|
276
276
|
|
|
277
|
+
_STARTER_CONFIG = """\
|
|
278
|
+
# forerunner.config.yaml — generated by `forerunner doctor --fix`
|
|
279
|
+
# See https://github.com/derek-palmer/codeforerunner for docs.
|
|
280
|
+
|
|
281
|
+
enabled_rules:
|
|
282
|
+
- R1-no-cli
|
|
283
|
+
- R2-no-pre-commit
|
|
284
|
+
- R3-no-ci
|
|
285
|
+
- R4-no-installer
|
|
286
|
+
- R5-no-python-package
|
|
287
|
+
- R7-no-mcp
|
|
288
|
+
- R8-no-marketplace
|
|
289
|
+
ignore_paths: []
|
|
290
|
+
"""
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def starter_config() -> str:
|
|
294
|
+
return _STARTER_CONFIG
|
|
295
|
+
|
|
296
|
+
|
|
277
297
|
def run(repo: Path) -> list[Finding]:
|
|
278
298
|
repo = repo.resolve()
|
|
279
299
|
findings: list[Finding] = []
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Anthropic Messages API provider. Stdlib HTTP only."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.request
|
|
8
|
+
from typing import Iterator
|
|
9
|
+
|
|
10
|
+
from codeforerunner.providers.base import CompletionResult, ProviderError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AnthropicProvider:
|
|
14
|
+
name = "anthropic"
|
|
15
|
+
default_env_var = "ANTHROPIC_API_KEY"
|
|
16
|
+
default_model = "claude-opus-4-5"
|
|
17
|
+
|
|
18
|
+
endpoint = "https://api.anthropic.com/v1/messages"
|
|
19
|
+
|
|
20
|
+
def complete(
|
|
21
|
+
self,
|
|
22
|
+
*,
|
|
23
|
+
prompt: str,
|
|
24
|
+
model: str | None = None,
|
|
25
|
+
api_key: str | None = None,
|
|
26
|
+
) -> CompletionResult:
|
|
27
|
+
if not api_key:
|
|
28
|
+
raise ProviderError(f"missing API key (set ${self.default_env_var})")
|
|
29
|
+
model = model or self.default_model
|
|
30
|
+
body = json.dumps(
|
|
31
|
+
{
|
|
32
|
+
"model": model,
|
|
33
|
+
"max_tokens": 4096,
|
|
34
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
35
|
+
}
|
|
36
|
+
).encode("utf-8")
|
|
37
|
+
req = urllib.request.Request(
|
|
38
|
+
self.endpoint,
|
|
39
|
+
data=body,
|
|
40
|
+
method="POST",
|
|
41
|
+
headers={
|
|
42
|
+
"x-api-key": api_key,
|
|
43
|
+
"anthropic-version": "2023-06-01",
|
|
44
|
+
"content-type": "application/json",
|
|
45
|
+
},
|
|
46
|
+
)
|
|
47
|
+
try:
|
|
48
|
+
with urllib.request.urlopen(req) as resp:
|
|
49
|
+
raw = resp.read()
|
|
50
|
+
except urllib.error.HTTPError as e:
|
|
51
|
+
snippet = (e.read() or b"")[:500].decode("utf-8", errors="replace")
|
|
52
|
+
raise ProviderError(f"HTTP {e.code}: {snippet}") from e
|
|
53
|
+
except urllib.error.URLError as e:
|
|
54
|
+
raise ProviderError(f"network error: {e.reason}") from e
|
|
55
|
+
try:
|
|
56
|
+
data = json.loads(raw.decode("utf-8"))
|
|
57
|
+
text = data["content"][0]["text"]
|
|
58
|
+
except (json.JSONDecodeError, KeyError, IndexError, TypeError) as e:
|
|
59
|
+
raise ProviderError(f"malformed response: {e}") from e
|
|
60
|
+
return CompletionResult(
|
|
61
|
+
text=text, model=data.get("model", model), usage=data.get("usage")
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def stream(
|
|
65
|
+
self,
|
|
66
|
+
*,
|
|
67
|
+
prompt: str,
|
|
68
|
+
model: str | None = None,
|
|
69
|
+
api_key: str | None = None,
|
|
70
|
+
) -> Iterator[str]:
|
|
71
|
+
if not api_key:
|
|
72
|
+
raise ProviderError(f"missing API key (set ${self.default_env_var})")
|
|
73
|
+
model = model or self.default_model
|
|
74
|
+
body = json.dumps(
|
|
75
|
+
{
|
|
76
|
+
"model": model,
|
|
77
|
+
"max_tokens": 4096,
|
|
78
|
+
"stream": True,
|
|
79
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
80
|
+
}
|
|
81
|
+
).encode("utf-8")
|
|
82
|
+
req = urllib.request.Request(
|
|
83
|
+
self.endpoint,
|
|
84
|
+
data=body,
|
|
85
|
+
method="POST",
|
|
86
|
+
headers={
|
|
87
|
+
"x-api-key": api_key,
|
|
88
|
+
"anthropic-version": "2023-06-01",
|
|
89
|
+
"content-type": "application/json",
|
|
90
|
+
},
|
|
91
|
+
)
|
|
92
|
+
try:
|
|
93
|
+
resp = urllib.request.urlopen(req)
|
|
94
|
+
except urllib.error.HTTPError as e:
|
|
95
|
+
snippet = (e.read() or b"")[:500].decode("utf-8", errors="replace")
|
|
96
|
+
raise ProviderError(f"HTTP {e.code}: {snippet}") from e
|
|
97
|
+
except urllib.error.URLError as e:
|
|
98
|
+
raise ProviderError(f"network error: {e.reason}") from e
|
|
99
|
+
try:
|
|
100
|
+
for raw_line in resp:
|
|
101
|
+
line = raw_line.decode("utf-8").rstrip("\n")
|
|
102
|
+
if not line.startswith("data: "):
|
|
103
|
+
continue
|
|
104
|
+
payload = line[6:]
|
|
105
|
+
if payload.strip() == "[DONE]":
|
|
106
|
+
break
|
|
107
|
+
try:
|
|
108
|
+
event = json.loads(payload)
|
|
109
|
+
except json.JSONDecodeError:
|
|
110
|
+
continue
|
|
111
|
+
if event.get("type") == "content_block_delta":
|
|
112
|
+
delta = event.get("delta", {})
|
|
113
|
+
if delta.get("type") == "text_delta":
|
|
114
|
+
text = delta.get("text", "")
|
|
115
|
+
if text:
|
|
116
|
+
yield text
|
|
117
|
+
finally:
|
|
118
|
+
resp.close()
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
from dataclasses import dataclass
|
|
6
|
-
from typing import Protocol
|
|
6
|
+
from typing import Iterator, Protocol
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
@dataclass(frozen=True)
|
|
@@ -26,6 +26,14 @@ class Provider(Protocol):
|
|
|
26
26
|
api_key: str | None = None,
|
|
27
27
|
) -> CompletionResult: ...
|
|
28
28
|
|
|
29
|
+
def stream(
|
|
30
|
+
self,
|
|
31
|
+
*,
|
|
32
|
+
prompt: str,
|
|
33
|
+
model: str | None = None,
|
|
34
|
+
api_key: str | None = None,
|
|
35
|
+
) -> Iterator[str]: ...
|
|
36
|
+
|
|
29
37
|
|
|
30
38
|
class ProviderError(Exception):
|
|
31
39
|
"""Raised on provider HTTP failures, missing keys, or malformed responses."""
|
|
@@ -6,6 +6,7 @@ import json
|
|
|
6
6
|
import urllib.error
|
|
7
7
|
import urllib.parse
|
|
8
8
|
import urllib.request
|
|
9
|
+
from typing import Iterator
|
|
9
10
|
|
|
10
11
|
from codeforerunner.providers.base import CompletionResult, ProviderError
|
|
11
12
|
|
|
@@ -18,6 +19,10 @@ class GoogleProvider:
|
|
|
18
19
|
endpoint_template = (
|
|
19
20
|
"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={key}"
|
|
20
21
|
)
|
|
22
|
+
stream_endpoint_template = (
|
|
23
|
+
"https://generativelanguage.googleapis.com/v1beta/models/{model}:streamGenerateContent"
|
|
24
|
+
"?key={key}&alt=sse"
|
|
25
|
+
)
|
|
21
26
|
|
|
22
27
|
def complete(
|
|
23
28
|
self,
|
|
@@ -60,3 +65,48 @@ class GoogleProvider:
|
|
|
60
65
|
model=data.get("modelVersion", model),
|
|
61
66
|
usage=data.get("usageMetadata"),
|
|
62
67
|
)
|
|
68
|
+
|
|
69
|
+
def stream(
|
|
70
|
+
self,
|
|
71
|
+
*,
|
|
72
|
+
prompt: str,
|
|
73
|
+
model: str | None = None,
|
|
74
|
+
api_key: str | None = None,
|
|
75
|
+
) -> Iterator[str]:
|
|
76
|
+
if not api_key:
|
|
77
|
+
raise ProviderError(f"missing API key (set ${self.default_env_var})")
|
|
78
|
+
model = model or self.default_model
|
|
79
|
+
url = self.stream_endpoint_template.format(
|
|
80
|
+
model=urllib.parse.quote(model, safe=""),
|
|
81
|
+
key=urllib.parse.quote(api_key, safe=""),
|
|
82
|
+
)
|
|
83
|
+
body = json.dumps(
|
|
84
|
+
{"contents": [{"parts": [{"text": prompt}]}]}
|
|
85
|
+
).encode("utf-8")
|
|
86
|
+
req = urllib.request.Request(
|
|
87
|
+
url,
|
|
88
|
+
data=body,
|
|
89
|
+
method="POST",
|
|
90
|
+
headers={"content-type": "application/json"},
|
|
91
|
+
)
|
|
92
|
+
try:
|
|
93
|
+
resp = urllib.request.urlopen(req)
|
|
94
|
+
except urllib.error.HTTPError as e:
|
|
95
|
+
snippet = (e.read() or b"")[:500].decode("utf-8", errors="replace")
|
|
96
|
+
raise ProviderError(f"HTTP {e.code}: {snippet}") from e
|
|
97
|
+
except urllib.error.URLError as e:
|
|
98
|
+
raise ProviderError(f"network error: {e.reason}") from e
|
|
99
|
+
try:
|
|
100
|
+
for raw_line in resp:
|
|
101
|
+
line = raw_line.decode("utf-8").rstrip("\n")
|
|
102
|
+
if not line.startswith("data: "):
|
|
103
|
+
continue
|
|
104
|
+
try:
|
|
105
|
+
event = json.loads(line[6:])
|
|
106
|
+
text = event["candidates"][0]["content"]["parts"][0]["text"]
|
|
107
|
+
if text:
|
|
108
|
+
yield text
|
|
109
|
+
except (json.JSONDecodeError, KeyError, IndexError, TypeError):
|
|
110
|
+
continue
|
|
111
|
+
finally:
|
|
112
|
+
resp.close()
|
|
@@ -6,6 +6,7 @@ import json
|
|
|
6
6
|
import os
|
|
7
7
|
import urllib.error
|
|
8
8
|
import urllib.request
|
|
9
|
+
from typing import Iterator
|
|
9
10
|
|
|
10
11
|
from codeforerunner.providers.base import CompletionResult, ProviderError
|
|
11
12
|
|
|
@@ -54,3 +55,47 @@ class OllamaProvider:
|
|
|
54
55
|
usage_keys = ("prompt_eval_count", "eval_count", "total_duration")
|
|
55
56
|
usage = {k: data[k] for k in usage_keys if k in data} or None
|
|
56
57
|
return CompletionResult(text=text, model=data.get("model", model), usage=usage)
|
|
58
|
+
|
|
59
|
+
def stream(
|
|
60
|
+
self,
|
|
61
|
+
*,
|
|
62
|
+
prompt: str,
|
|
63
|
+
model: str | None = None,
|
|
64
|
+
api_key: str | None = None,
|
|
65
|
+
) -> Iterator[str]:
|
|
66
|
+
base = api_key or os.environ.get(self.default_env_var) or DEFAULT_HOST
|
|
67
|
+
base = base.rstrip("/")
|
|
68
|
+
model = model or self.default_model
|
|
69
|
+
url = f"{base}/api/generate"
|
|
70
|
+
body = json.dumps(
|
|
71
|
+
{"model": model, "prompt": prompt, "stream": True}
|
|
72
|
+
).encode("utf-8")
|
|
73
|
+
req = urllib.request.Request(
|
|
74
|
+
url,
|
|
75
|
+
data=body,
|
|
76
|
+
method="POST",
|
|
77
|
+
headers={"content-type": "application/json"},
|
|
78
|
+
)
|
|
79
|
+
try:
|
|
80
|
+
resp = urllib.request.urlopen(req)
|
|
81
|
+
except urllib.error.HTTPError as e:
|
|
82
|
+
snippet = (e.read() or b"")[:500].decode("utf-8", errors="replace")
|
|
83
|
+
raise ProviderError(f"HTTP {e.code}: {snippet}") from e
|
|
84
|
+
except urllib.error.URLError as e:
|
|
85
|
+
raise ProviderError(f"network error: {e.reason}") from e
|
|
86
|
+
try:
|
|
87
|
+
for raw_line in resp:
|
|
88
|
+
line = raw_line.decode("utf-8").strip()
|
|
89
|
+
if not line:
|
|
90
|
+
continue
|
|
91
|
+
try:
|
|
92
|
+
event = json.loads(line)
|
|
93
|
+
except json.JSONDecodeError:
|
|
94
|
+
continue
|
|
95
|
+
text = event.get("response", "")
|
|
96
|
+
if text:
|
|
97
|
+
yield text
|
|
98
|
+
if event.get("done", False):
|
|
99
|
+
break
|
|
100
|
+
finally:
|
|
101
|
+
resp.close()
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import json
|
|
6
6
|
import urllib.error
|
|
7
7
|
import urllib.request
|
|
8
|
+
from typing import Iterator
|
|
8
9
|
|
|
9
10
|
from codeforerunner.providers.base import CompletionResult, ProviderError
|
|
10
11
|
|
|
@@ -57,3 +58,56 @@ class OpenAIProvider:
|
|
|
57
58
|
return CompletionResult(
|
|
58
59
|
text=text, model=data.get("model", model), usage=data.get("usage")
|
|
59
60
|
)
|
|
61
|
+
|
|
62
|
+
def stream(
|
|
63
|
+
self,
|
|
64
|
+
*,
|
|
65
|
+
prompt: str,
|
|
66
|
+
model: str | None = None,
|
|
67
|
+
api_key: str | None = None,
|
|
68
|
+
) -> Iterator[str]:
|
|
69
|
+
if not api_key:
|
|
70
|
+
raise ProviderError(f"missing API key (set ${self.default_env_var})")
|
|
71
|
+
model = model or self.default_model
|
|
72
|
+
body = json.dumps(
|
|
73
|
+
{
|
|
74
|
+
"model": model,
|
|
75
|
+
"stream": True,
|
|
76
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
77
|
+
}
|
|
78
|
+
).encode("utf-8")
|
|
79
|
+
req = urllib.request.Request(
|
|
80
|
+
self.endpoint,
|
|
81
|
+
data=body,
|
|
82
|
+
method="POST",
|
|
83
|
+
headers={
|
|
84
|
+
"Authorization": f"Bearer {api_key}",
|
|
85
|
+
"content-type": "application/json",
|
|
86
|
+
},
|
|
87
|
+
)
|
|
88
|
+
try:
|
|
89
|
+
resp = urllib.request.urlopen(req)
|
|
90
|
+
except urllib.error.HTTPError as e:
|
|
91
|
+
snippet = (e.read() or b"")[:500].decode("utf-8", errors="replace")
|
|
92
|
+
raise ProviderError(f"HTTP {e.code}: {snippet}") from e
|
|
93
|
+
except urllib.error.URLError as e:
|
|
94
|
+
raise ProviderError(f"network error: {e.reason}") from e
|
|
95
|
+
try:
|
|
96
|
+
for raw_line in resp:
|
|
97
|
+
line = raw_line.decode("utf-8").rstrip("\n")
|
|
98
|
+
if not line.startswith("data: "):
|
|
99
|
+
continue
|
|
100
|
+
payload = line[6:]
|
|
101
|
+
if payload.strip() == "[DONE]":
|
|
102
|
+
break
|
|
103
|
+
try:
|
|
104
|
+
event = json.loads(payload)
|
|
105
|
+
except json.JSONDecodeError:
|
|
106
|
+
continue
|
|
107
|
+
choices = event.get("choices", [])
|
|
108
|
+
if choices:
|
|
109
|
+
text = choices[0].get("delta", {}).get("content")
|
|
110
|
+
if text:
|
|
111
|
+
yield text
|
|
112
|
+
finally:
|
|
113
|
+
resp.close()
|
|
@@ -271,6 +271,120 @@ def test_violation_line_number_correct(tmp_path):
|
|
|
271
271
|
assert vs[0].line == 3
|
|
272
272
|
|
|
273
273
|
|
|
274
|
+
# ── Inverse rules (RI*) ────────────────────────────────────────────────────
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def test_ri1_fires_when_cli_absent(tmp_path):
|
|
278
|
+
_seed(tmp_path, {"README.md": "Run `forerunner init` to get started.\n"})
|
|
279
|
+
vs = run(tmp_path)
|
|
280
|
+
assert any(v.rule_id == "RI1-missing-cli" for v in vs)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def test_ri1_silent_when_cli_present(tmp_path):
|
|
284
|
+
_seed(
|
|
285
|
+
tmp_path,
|
|
286
|
+
{
|
|
287
|
+
"README.md": "Run `forerunner init` to get started.\n",
|
|
288
|
+
"src/codeforerunner/cli.py": "# cli\n",
|
|
289
|
+
},
|
|
290
|
+
)
|
|
291
|
+
assert not any(v.rule_id == "RI1-missing-cli" for v in run(tmp_path))
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def test_ri5_fires_when_pyproject_absent(tmp_path):
|
|
295
|
+
_seed(tmp_path, {"README.md": "Install with `pip install codeforerunner`.\n"})
|
|
296
|
+
vs = run(tmp_path)
|
|
297
|
+
assert any(v.rule_id == "RI5-missing-python-package" for v in vs)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def test_ri5_silent_when_pyproject_present(tmp_path):
|
|
301
|
+
_seed(
|
|
302
|
+
tmp_path,
|
|
303
|
+
{
|
|
304
|
+
"README.md": "Install with `pip install codeforerunner`.\n",
|
|
305
|
+
"pyproject.toml": '[project]\nname = "codeforerunner"\n',
|
|
306
|
+
},
|
|
307
|
+
)
|
|
308
|
+
assert not any(v.rule_id == "RI5-missing-python-package" for v in run(tmp_path))
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def test_ri7_fires_when_mcp_server_absent(tmp_path):
|
|
312
|
+
_seed(tmp_path, {"README.md": "Start with `forerunner mcp-server`.\n"})
|
|
313
|
+
vs = run(tmp_path)
|
|
314
|
+
assert any(v.rule_id == "RI7-missing-mcp" for v in vs)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def test_ri7_silent_when_mcp_server_present(tmp_path):
|
|
318
|
+
_seed(
|
|
319
|
+
tmp_path,
|
|
320
|
+
{
|
|
321
|
+
"README.md": "Start with `forerunner mcp-server`.\n",
|
|
322
|
+
"src/codeforerunner/mcp_server.py": "# mcp\n",
|
|
323
|
+
},
|
|
324
|
+
)
|
|
325
|
+
assert not any(v.rule_id == "RI7-missing-mcp" for v in run(tmp_path))
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
# ── Version drift (RV1) ────────────────────────────────────────────────────
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def test_rv1_fires_when_pin_mismatches(tmp_path):
|
|
332
|
+
_seed(
|
|
333
|
+
tmp_path,
|
|
334
|
+
{
|
|
335
|
+
"README.md": "Install `pip install codeforerunner==0.1.0`.\n",
|
|
336
|
+
"pyproject.toml": '[project]\nname = "codeforerunner"\nversion = "0.3.1"\n',
|
|
337
|
+
},
|
|
338
|
+
)
|
|
339
|
+
vs = run(tmp_path)
|
|
340
|
+
rv = [v for v in vs if v.rule_id == "RV1-version-drift"]
|
|
341
|
+
assert len(rv) == 1
|
|
342
|
+
assert "0.1.0" in rv[0].message
|
|
343
|
+
assert "0.3.1" in rv[0].message
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def test_rv1_silent_when_pin_matches(tmp_path):
|
|
347
|
+
_seed(
|
|
348
|
+
tmp_path,
|
|
349
|
+
{
|
|
350
|
+
"README.md": "Install `pip install codeforerunner==0.3.1`.\n",
|
|
351
|
+
"pyproject.toml": '[project]\nname = "codeforerunner"\nversion = "0.3.1"\n',
|
|
352
|
+
},
|
|
353
|
+
)
|
|
354
|
+
assert not any(v.rule_id == "RV1-version-drift" for v in run(tmp_path))
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def test_rv1_skips_changelog_md(tmp_path):
|
|
358
|
+
# CHANGELOG.md not in _scanned_docs; old pins there should not fire
|
|
359
|
+
_seed(
|
|
360
|
+
tmp_path,
|
|
361
|
+
{
|
|
362
|
+
"CHANGELOG.md": "## 0.1.0\n- `pip install codeforerunner==0.1.0`\n",
|
|
363
|
+
"pyproject.toml": '[project]\nname = "codeforerunner"\nversion = "0.3.1"\n',
|
|
364
|
+
},
|
|
365
|
+
)
|
|
366
|
+
assert not any(v.rule_id == "RV1-version-drift" for v in run(tmp_path))
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def test_rv1_silent_when_no_pyproject(tmp_path):
|
|
370
|
+
_seed(tmp_path, {"README.md": "See `pip install codeforerunner==0.1.0`.\n"})
|
|
371
|
+
assert not any(v.rule_id == "RV1-version-drift" for v in run(tmp_path))
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def test_rv1_not_run_when_excluded_from_enabled_rules(tmp_path):
|
|
375
|
+
from codeforerunner.config import CheckConfig
|
|
376
|
+
|
|
377
|
+
_seed(
|
|
378
|
+
tmp_path,
|
|
379
|
+
{
|
|
380
|
+
"README.md": "Install `pip install codeforerunner==0.1.0`.\n",
|
|
381
|
+
"pyproject.toml": '[project]\nname = "codeforerunner"\nversion = "0.3.1"\n',
|
|
382
|
+
},
|
|
383
|
+
)
|
|
384
|
+
cfg = CheckConfig(enabled_rules=("R1-no-cli",))
|
|
385
|
+
assert not any(v.rule_id == "RV1-version-drift" for v in run(tmp_path, cfg))
|
|
386
|
+
|
|
387
|
+
|
|
274
388
|
def test_workflows_lint_when_actionlint_available():
|
|
275
389
|
import shutil
|
|
276
390
|
import subprocess
|
|
@@ -8,7 +8,7 @@ from pathlib import Path
|
|
|
8
8
|
|
|
9
9
|
import pytest
|
|
10
10
|
|
|
11
|
-
from codeforerunner.doctor import Finding, format_report, main, run
|
|
11
|
+
from codeforerunner.doctor import Finding, format_report, main, run, starter_config
|
|
12
12
|
|
|
13
13
|
REPO = Path(__file__).resolve().parent.parent
|
|
14
14
|
|
|
@@ -135,3 +135,42 @@ def test_main_exits_one_when_error_present(tmp_path: Path, capsys):
|
|
|
135
135
|
rc = main(["--repo", str(repo)])
|
|
136
136
|
capsys.readouterr()
|
|
137
137
|
assert rc == 1
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ── starter_config / --fix ─────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_starter_config_contains_expected_rules():
|
|
144
|
+
cfg = starter_config()
|
|
145
|
+
assert "R1-no-cli" in cfg
|
|
146
|
+
assert "R7-no-mcp" in cfg
|
|
147
|
+
assert "R8-no-marketplace" in cfg
|
|
148
|
+
assert "ignore_paths" in cfg
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_doctor_fix_writes_config_when_absent(tmp_path: Path, capsys):
|
|
152
|
+
from codeforerunner.cli import main as cli_main
|
|
153
|
+
|
|
154
|
+
repo = _copy_repo_layout(tmp_path)
|
|
155
|
+
cfg_path = repo / "forerunner.config.yaml"
|
|
156
|
+
assert not cfg_path.exists()
|
|
157
|
+
|
|
158
|
+
cli_main(["--repo", str(repo), "doctor", "--fix"])
|
|
159
|
+
capsys.readouterr()
|
|
160
|
+
|
|
161
|
+
assert cfg_path.is_file()
|
|
162
|
+
content = cfg_path.read_text(encoding="utf-8")
|
|
163
|
+
assert "R1-no-cli" in content
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_doctor_fix_does_not_overwrite_existing_config(tmp_path: Path, capsys):
|
|
167
|
+
from codeforerunner.cli import main as cli_main
|
|
168
|
+
|
|
169
|
+
repo = _copy_repo_layout(tmp_path)
|
|
170
|
+
cfg_path = repo / "forerunner.config.yaml"
|
|
171
|
+
cfg_path.write_text("# my custom config\n", encoding="utf-8")
|
|
172
|
+
|
|
173
|
+
cli_main(["--repo", str(repo), "doctor", "--fix"])
|
|
174
|
+
capsys.readouterr()
|
|
175
|
+
|
|
176
|
+
assert cfg_path.read_text(encoding="utf-8") == "# my custom config\n"
|
|
@@ -302,3 +302,180 @@ def test_ollama_api_key_used_as_base_url(monkeypatch):
|
|
|
302
302
|
with patch("urllib.request.urlopen", side_effect=_fake_urlopen(fake, captured)):
|
|
303
303
|
OllamaProvider().complete(prompt="hi", api_key="http://custom:8888")
|
|
304
304
|
assert captured["url"] == "http://custom:8888/api/generate"
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
# ── Streaming helpers ─────────────────────────────────────────────────────
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _fake_stream_urlopen(lines: list[bytes]):
|
|
311
|
+
"""Return urlopen side_effect that yields byte-lines and has .close()."""
|
|
312
|
+
|
|
313
|
+
def _opener(req, *args, **kwargs):
|
|
314
|
+
mock = MagicMock()
|
|
315
|
+
mock.__iter__ = MagicMock(return_value=iter(lines))
|
|
316
|
+
mock.close = MagicMock()
|
|
317
|
+
return mock
|
|
318
|
+
|
|
319
|
+
return _opener
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _stream_http_error(code: int):
|
|
323
|
+
import urllib.error
|
|
324
|
+
|
|
325
|
+
def _opener(req, *args, **kwargs):
|
|
326
|
+
raise urllib.error.HTTPError(
|
|
327
|
+
url=req.full_url, code=code, msg="err", hdrs=None, fp=io.BytesIO(b"err")
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
return _opener
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
# ── Anthropic stream ──────────────────────────────────────────────────────
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def test_anthropic_stream_yields_text():
|
|
337
|
+
lines = [
|
|
338
|
+
b'event: content_block_delta\n',
|
|
339
|
+
b'data: {"type": "content_block_delta", "delta": {"type": "text_delta", "text": "hello "}}\n',
|
|
340
|
+
b'\n',
|
|
341
|
+
b'data: {"type": "content_block_delta", "delta": {"type": "text_delta", "text": "world"}}\n',
|
|
342
|
+
b'data: {"type": "message_stop"}\n',
|
|
343
|
+
]
|
|
344
|
+
with patch("urllib.request.urlopen", side_effect=_fake_stream_urlopen(lines)):
|
|
345
|
+
chunks = list(AnthropicProvider().stream(prompt="hi", api_key="sk"))
|
|
346
|
+
assert chunks == ["hello ", "world"]
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def test_anthropic_stream_sends_stream_true():
|
|
350
|
+
lines = [b'data: {"type": "message_stop"}\n']
|
|
351
|
+
captured: dict = {}
|
|
352
|
+
|
|
353
|
+
def _opener(req, *args, **kwargs):
|
|
354
|
+
captured["body"] = json.loads(req.data.decode("utf-8"))
|
|
355
|
+
mock = MagicMock()
|
|
356
|
+
mock.__iter__ = MagicMock(return_value=iter(lines))
|
|
357
|
+
mock.close = MagicMock()
|
|
358
|
+
return mock
|
|
359
|
+
|
|
360
|
+
with patch("urllib.request.urlopen", side_effect=_opener):
|
|
361
|
+
list(AnthropicProvider().stream(prompt="hi", api_key="sk"))
|
|
362
|
+
assert captured["body"]["stream"] is True
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def test_anthropic_stream_missing_key_raises():
|
|
366
|
+
with pytest.raises(ProviderError):
|
|
367
|
+
list(AnthropicProvider().stream(prompt="hi", api_key=None))
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def test_anthropic_stream_http_error_raises():
|
|
371
|
+
with patch("urllib.request.urlopen", side_effect=_stream_http_error(401)):
|
|
372
|
+
with pytest.raises(ProviderError, match="HTTP 401"):
|
|
373
|
+
list(AnthropicProvider().stream(prompt="hi", api_key="sk"))
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
# ── OpenAI stream ─────────────────────────────────────────────────────────
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def test_openai_stream_yields_text():
|
|
380
|
+
lines = [
|
|
381
|
+
b'data: {"choices": [{"delta": {"content": "foo"}}]}\n',
|
|
382
|
+
b'data: {"choices": [{"delta": {"content": " bar"}}]}\n',
|
|
383
|
+
b'data: [DONE]\n',
|
|
384
|
+
]
|
|
385
|
+
with patch("urllib.request.urlopen", side_effect=_fake_stream_urlopen(lines)):
|
|
386
|
+
chunks = list(OpenAIProvider().stream(prompt="hi", api_key="sk"))
|
|
387
|
+
assert chunks == ["foo", " bar"]
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def test_openai_stream_missing_key_raises():
|
|
391
|
+
with pytest.raises(ProviderError):
|
|
392
|
+
list(OpenAIProvider().stream(prompt="hi", api_key=None))
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def test_openai_stream_http_error_raises():
|
|
396
|
+
with patch("urllib.request.urlopen", side_effect=_stream_http_error(429)):
|
|
397
|
+
with pytest.raises(ProviderError, match="HTTP 429"):
|
|
398
|
+
list(OpenAIProvider().stream(prompt="hi", api_key="sk"))
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def test_openai_stream_ignores_done_sentinel():
|
|
402
|
+
lines = [
|
|
403
|
+
b'data: {"choices": [{"delta": {"content": "x"}}]}\n',
|
|
404
|
+
b'data: [DONE]\n',
|
|
405
|
+
b'data: {"choices": [{"delta": {"content": "should not appear"}}]}\n',
|
|
406
|
+
]
|
|
407
|
+
with patch("urllib.request.urlopen", side_effect=_fake_stream_urlopen(lines)):
|
|
408
|
+
chunks = list(OpenAIProvider().stream(prompt="hi", api_key="sk"))
|
|
409
|
+
assert chunks == ["x"]
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
# ── Google stream ─────────────────────────────────────────────────────────
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def test_google_stream_yields_text():
|
|
416
|
+
lines = [
|
|
417
|
+
b'data: {"candidates": [{"content": {"parts": [{"text": "hi "}]}}]}\n',
|
|
418
|
+
b'data: {"candidates": [{"content": {"parts": [{"text": "there"}]}}]}\n',
|
|
419
|
+
]
|
|
420
|
+
with patch("urllib.request.urlopen", side_effect=_fake_stream_urlopen(lines)):
|
|
421
|
+
chunks = list(GoogleProvider().stream(prompt="q", api_key="g-key"))
|
|
422
|
+
assert chunks == ["hi ", "there"]
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def test_google_stream_uses_stream_endpoint():
|
|
426
|
+
lines = [b'data: {"candidates": [{"content": {"parts": [{"text": "x"}]}}]}\n']
|
|
427
|
+
captured: dict = {}
|
|
428
|
+
|
|
429
|
+
def _opener(req, *args, **kwargs):
|
|
430
|
+
captured["url"] = req.full_url
|
|
431
|
+
mock = MagicMock()
|
|
432
|
+
mock.__iter__ = MagicMock(return_value=iter(lines))
|
|
433
|
+
mock.close = MagicMock()
|
|
434
|
+
return mock
|
|
435
|
+
|
|
436
|
+
with patch("urllib.request.urlopen", side_effect=_opener):
|
|
437
|
+
list(GoogleProvider().stream(prompt="q", api_key="g-key"))
|
|
438
|
+
assert "streamGenerateContent" in captured["url"]
|
|
439
|
+
assert "alt=sse" in captured["url"]
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def test_google_stream_missing_key_raises():
|
|
443
|
+
with pytest.raises(ProviderError):
|
|
444
|
+
list(GoogleProvider().stream(prompt="hi", api_key=None))
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def test_google_stream_http_error_raises():
|
|
448
|
+
with patch("urllib.request.urlopen", side_effect=_stream_http_error(403)):
|
|
449
|
+
with pytest.raises(ProviderError, match="HTTP 403"):
|
|
450
|
+
list(GoogleProvider().stream(prompt="hi", api_key="g-key"))
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
# ── Ollama stream ─────────────────────────────────────────────────────────
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def test_ollama_stream_yields_text():
|
|
457
|
+
lines = [
|
|
458
|
+
b'{"response": "foo ", "done": false}\n',
|
|
459
|
+
b'{"response": "bar", "done": true}\n',
|
|
460
|
+
]
|
|
461
|
+
with patch("urllib.request.urlopen", side_effect=_fake_stream_urlopen(lines)):
|
|
462
|
+
chunks = list(OllamaProvider().stream(prompt="hi"))
|
|
463
|
+
assert chunks == ["foo ", "bar"]
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def test_ollama_stream_stops_at_done(monkeypatch):
|
|
467
|
+
monkeypatch.delenv("OLLAMA_HOST", raising=False)
|
|
468
|
+
lines = [
|
|
469
|
+
b'{"response": "a", "done": false}\n',
|
|
470
|
+
b'{"response": "b", "done": true}\n',
|
|
471
|
+
b'{"response": "c", "done": false}\n',
|
|
472
|
+
]
|
|
473
|
+
with patch("urllib.request.urlopen", side_effect=_fake_stream_urlopen(lines)):
|
|
474
|
+
chunks = list(OllamaProvider().stream(prompt="hi"))
|
|
475
|
+
assert chunks == ["a", "b"]
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def test_ollama_stream_http_error_raises():
|
|
479
|
+
with patch("urllib.request.urlopen", side_effect=_stream_http_error(500)):
|
|
480
|
+
with pytest.raises(ProviderError, match="HTTP 500"):
|
|
481
|
+
list(OllamaProvider().stream(prompt="hi"))
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
"""Anthropic Messages API provider. Stdlib HTTP only."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import json
|
|
6
|
-
import urllib.error
|
|
7
|
-
import urllib.request
|
|
8
|
-
|
|
9
|
-
from codeforerunner.providers.base import CompletionResult, ProviderError
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
class AnthropicProvider:
|
|
13
|
-
name = "anthropic"
|
|
14
|
-
default_env_var = "ANTHROPIC_API_KEY"
|
|
15
|
-
default_model = "claude-opus-4-5"
|
|
16
|
-
|
|
17
|
-
endpoint = "https://api.anthropic.com/v1/messages"
|
|
18
|
-
|
|
19
|
-
def complete(
|
|
20
|
-
self,
|
|
21
|
-
*,
|
|
22
|
-
prompt: str,
|
|
23
|
-
model: str | None = None,
|
|
24
|
-
api_key: str | None = None,
|
|
25
|
-
) -> CompletionResult:
|
|
26
|
-
if not api_key:
|
|
27
|
-
raise ProviderError(f"missing API key (set ${self.default_env_var})")
|
|
28
|
-
model = model or self.default_model
|
|
29
|
-
body = json.dumps(
|
|
30
|
-
{
|
|
31
|
-
"model": model,
|
|
32
|
-
"max_tokens": 4096,
|
|
33
|
-
"messages": [{"role": "user", "content": prompt}],
|
|
34
|
-
}
|
|
35
|
-
).encode("utf-8")
|
|
36
|
-
req = urllib.request.Request(
|
|
37
|
-
self.endpoint,
|
|
38
|
-
data=body,
|
|
39
|
-
method="POST",
|
|
40
|
-
headers={
|
|
41
|
-
"x-api-key": api_key,
|
|
42
|
-
"anthropic-version": "2023-06-01",
|
|
43
|
-
"content-type": "application/json",
|
|
44
|
-
},
|
|
45
|
-
)
|
|
46
|
-
try:
|
|
47
|
-
with urllib.request.urlopen(req) as resp:
|
|
48
|
-
raw = resp.read()
|
|
49
|
-
except urllib.error.HTTPError as e:
|
|
50
|
-
snippet = (e.read() or b"")[:500].decode("utf-8", errors="replace")
|
|
51
|
-
raise ProviderError(f"HTTP {e.code}: {snippet}") from e
|
|
52
|
-
except urllib.error.URLError as e:
|
|
53
|
-
raise ProviderError(f"network error: {e.reason}") from e
|
|
54
|
-
try:
|
|
55
|
-
data = json.loads(raw.decode("utf-8"))
|
|
56
|
-
text = data["content"][0]["text"]
|
|
57
|
-
except (json.JSONDecodeError, KeyError, IndexError, TypeError) as e:
|
|
58
|
-
raise ProviderError(f"malformed response: {e}") from e
|
|
59
|
-
return CompletionResult(
|
|
60
|
-
text=text, model=data.get("model", model), usage=data.get("usage")
|
|
61
|
-
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner/prompts/partials/context-format.md
RENAMED
|
File without changes
|
{codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner/prompts/partials/output-rules.md
RENAMED
|
File without changes
|
{codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner/prompts/partials/stack-hints.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner/prompts/tasks/stack-docs.md
RENAMED
|
File without changes
|
{codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner/prompts/tasks/version-audit.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{codeforerunner-0.3.1 → codeforerunner-0.3.2}/src/codeforerunner.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|