codeforerunner 0.4.2__tar.gz → 0.4.3__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.4.2/src/codeforerunner.egg-info → codeforerunner-0.4.3}/PKG-INFO +1 -1
- {codeforerunner-0.4.2 → codeforerunner-0.4.3}/pyproject.toml +7 -1
- {codeforerunner-0.4.2 → codeforerunner-0.4.3}/src/codeforerunner/__init__.py +3 -1
- {codeforerunner-0.4.2 → codeforerunner-0.4.3}/src/codeforerunner/check.py +2 -0
- {codeforerunner-0.4.2 → codeforerunner-0.4.3}/src/codeforerunner/cli.py +7 -1
- {codeforerunner-0.4.2 → codeforerunner-0.4.3}/src/codeforerunner/config.py +8 -2
- {codeforerunner-0.4.2 → codeforerunner-0.4.3}/src/codeforerunner/doctor.py +11 -8
- {codeforerunner-0.4.2 → codeforerunner-0.4.3}/src/codeforerunner/installer.py +10 -0
- {codeforerunner-0.4.2 → codeforerunner-0.4.3}/src/codeforerunner/mcp_server.py +3 -1
- {codeforerunner-0.4.2 → codeforerunner-0.4.3}/src/codeforerunner/providers/__init__.py +1 -0
- {codeforerunner-0.4.2 → codeforerunner-0.4.3}/src/codeforerunner/providers/anthropic.py +4 -0
- {codeforerunner-0.4.2 → codeforerunner-0.4.3}/src/codeforerunner/providers/base.py +10 -2
- {codeforerunner-0.4.2 → codeforerunner-0.4.3}/src/codeforerunner/providers/google.py +4 -0
- {codeforerunner-0.4.2 → codeforerunner-0.4.3}/src/codeforerunner/providers/ollama.py +5 -1
- {codeforerunner-0.4.2 → codeforerunner-0.4.3}/src/codeforerunner/providers/openai.py +4 -0
- {codeforerunner-0.4.2 → codeforerunner-0.4.3/src/codeforerunner.egg-info}/PKG-INFO +1 -1
- {codeforerunner-0.4.2 → codeforerunner-0.4.3}/src/codeforerunner.egg-info/SOURCES.txt +1 -0
- codeforerunner-0.4.3/tests/test_bundle.py +64 -0
- {codeforerunner-0.4.2 → codeforerunner-0.4.3}/tests/test_check.py +63 -0
- {codeforerunner-0.4.2 → codeforerunner-0.4.3}/tests/test_cli.py +99 -0
- {codeforerunner-0.4.2 → codeforerunner-0.4.3}/tests/test_config.py +37 -0
- codeforerunner-0.4.3/tests/test_doctor.py +461 -0
- codeforerunner-0.4.3/tests/test_installer.py +564 -0
- codeforerunner-0.4.3/tests/test_mcp_server.py +431 -0
- {codeforerunner-0.4.2 → codeforerunner-0.4.3}/tests/test_providers.py +139 -0
- codeforerunner-0.4.2/tests/test_doctor.py +0 -219
- codeforerunner-0.4.2/tests/test_installer.py +0 -229
- codeforerunner-0.4.2/tests/test_mcp_server.py +0 -220
- {codeforerunner-0.4.2 → codeforerunner-0.4.3}/LICENSE.md +0 -0
- {codeforerunner-0.4.2 → codeforerunner-0.4.3}/README.md +0 -0
- {codeforerunner-0.4.2 → codeforerunner-0.4.3}/setup.cfg +0 -0
- {codeforerunner-0.4.2 → codeforerunner-0.4.3}/src/codeforerunner/bundle.py +0 -0
- {codeforerunner-0.4.2 → codeforerunner-0.4.3}/src/codeforerunner/prompts/partials/context-format.md +0 -0
- {codeforerunner-0.4.2 → codeforerunner-0.4.3}/src/codeforerunner/prompts/partials/output-rules.md +0 -0
- {codeforerunner-0.4.2 → codeforerunner-0.4.3}/src/codeforerunner/prompts/partials/stack-hints.md +0 -0
- {codeforerunner-0.4.2 → codeforerunner-0.4.3}/src/codeforerunner/prompts/system/base.md +0 -0
- {codeforerunner-0.4.2 → codeforerunner-0.4.3}/src/codeforerunner/prompts/tasks/api-docs.md +0 -0
- {codeforerunner-0.4.2 → codeforerunner-0.4.3}/src/codeforerunner/prompts/tasks/audit.md +0 -0
- {codeforerunner-0.4.2 → codeforerunner-0.4.3}/src/codeforerunner/prompts/tasks/changelog.md +0 -0
- {codeforerunner-0.4.2 → codeforerunner-0.4.3}/src/codeforerunner/prompts/tasks/check.md +0 -0
- {codeforerunner-0.4.2 → codeforerunner-0.4.3}/src/codeforerunner/prompts/tasks/diagrams.md +0 -0
- {codeforerunner-0.4.2 → codeforerunner-0.4.3}/src/codeforerunner/prompts/tasks/flows.md +0 -0
- {codeforerunner-0.4.2 → codeforerunner-0.4.3}/src/codeforerunner/prompts/tasks/init-agent-onboarding.md +0 -0
- {codeforerunner-0.4.2 → codeforerunner-0.4.3}/src/codeforerunner/prompts/tasks/readme.md +0 -0
- {codeforerunner-0.4.2 → codeforerunner-0.4.3}/src/codeforerunner/prompts/tasks/review.md +0 -0
- {codeforerunner-0.4.2 → codeforerunner-0.4.3}/src/codeforerunner/prompts/tasks/scan.md +0 -0
- {codeforerunner-0.4.2 → codeforerunner-0.4.3}/src/codeforerunner/prompts/tasks/stack-docs.md +0 -0
- {codeforerunner-0.4.2 → codeforerunner-0.4.3}/src/codeforerunner/prompts/tasks/version-audit.md +0 -0
- {codeforerunner-0.4.2 → codeforerunner-0.4.3}/src/codeforerunner.egg-info/dependency_links.txt +0 -0
- {codeforerunner-0.4.2 → codeforerunner-0.4.3}/src/codeforerunner.egg-info/entry_points.txt +0 -0
- {codeforerunner-0.4.2 → codeforerunner-0.4.3}/src/codeforerunner.egg-info/requires.txt +0 -0
- {codeforerunner-0.4.2 → codeforerunner-0.4.3}/src/codeforerunner.egg-info/top_level.txt +0 -0
- {codeforerunner-0.4.2 → codeforerunner-0.4.3}/tests/test_check_config_integration.py +0 -0
- {codeforerunner-0.4.2 → codeforerunner-0.4.3}/tests/test_examples.py +0 -0
- {codeforerunner-0.4.2 → codeforerunner-0.4.3}/tests/test_hooks_manifest.py +0 -0
- {codeforerunner-0.4.2 → codeforerunner-0.4.3}/tests/test_validate_codex_marketplace.py +0 -0
- {codeforerunner-0.4.2 → codeforerunner-0.4.3}/tests/test_workflows_yaml.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "codeforerunner"
|
|
7
|
-
version = "0.4.
|
|
7
|
+
version = "0.4.3"
|
|
8
8
|
description = "Model-agnostic repository documentation tooling (prompt-first; thin CLI)."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -54,3 +54,9 @@ codeforerunner = ["py.typed", "prompts/**/*.md"]
|
|
|
54
54
|
|
|
55
55
|
[tool.pytest.ini_options]
|
|
56
56
|
testpaths = ["tests"]
|
|
57
|
+
|
|
58
|
+
[dependency-groups]
|
|
59
|
+
dev = [
|
|
60
|
+
"pydocstyle>=6.3.0",
|
|
61
|
+
"pytest-cov>=7.1.0",
|
|
62
|
+
]
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
"""codeforerunner — prompt-first repo documentation tooling."""
|
|
2
|
+
|
|
1
3
|
from importlib.metadata import PackageNotFoundError, version
|
|
2
4
|
|
|
3
5
|
try:
|
|
4
6
|
__version__ = version("codeforerunner")
|
|
5
|
-
except PackageNotFoundError:
|
|
7
|
+
except PackageNotFoundError: # pragma: no cover
|
|
6
8
|
__version__ = "0.0.0" # running from source without install
|
|
@@ -61,7 +61,7 @@ def _doc_for(args: argparse.Namespace, task: str) -> int:
|
|
|
61
61
|
|
|
62
62
|
|
|
63
63
|
def cmd_init(args: argparse.Namespace) -> int:
|
|
64
|
-
"""
|
|
64
|
+
"""Emit onboarding bundle; prepend scan bundle when --full is given."""
|
|
65
65
|
if getattr(args, "full", False):
|
|
66
66
|
sys.stdout.write("<!-- forerunner init --full: section 1/2 (scan) -->\n")
|
|
67
67
|
rc = _doc_for(args, "scan")
|
|
@@ -72,6 +72,7 @@ def cmd_init(args: argparse.Namespace) -> int:
|
|
|
72
72
|
|
|
73
73
|
|
|
74
74
|
def cmd_scan(args: argparse.Namespace) -> int:
|
|
75
|
+
"""Emit the scan prompt bundle and hint about FORERUNNER_SCAN_DONE."""
|
|
75
76
|
rc = _doc_for(args, "scan")
|
|
76
77
|
if rc == 0:
|
|
77
78
|
print(
|
|
@@ -102,6 +103,7 @@ def cmd_check(args: argparse.Namespace) -> int:
|
|
|
102
103
|
|
|
103
104
|
|
|
104
105
|
def cmd_mcp_server(args: argparse.Namespace) -> int:
|
|
106
|
+
"""Start the stdio MCP server exposing prompt bundles as tools."""
|
|
105
107
|
from codeforerunner import mcp_server
|
|
106
108
|
try:
|
|
107
109
|
prompts_root = find_prompts_root(args.repo)
|
|
@@ -133,6 +135,7 @@ def cmd_generate(args: argparse.Namespace) -> int:
|
|
|
133
135
|
if rc != 0:
|
|
134
136
|
return rc
|
|
135
137
|
|
|
138
|
+
|
|
136
139
|
explicit_provider = args.provider or (cfg.provider if cfg else None)
|
|
137
140
|
provider_name = explicit_provider or "anthropic"
|
|
138
141
|
model = args.model or (cfg.model if cfg else None)
|
|
@@ -193,6 +196,7 @@ def cmd_generate(args: argparse.Namespace) -> int:
|
|
|
193
196
|
|
|
194
197
|
|
|
195
198
|
def cmd_doctor(args: argparse.Namespace) -> int:
|
|
199
|
+
"""Run health checks and print a single-screen report; exit 1 on errors."""
|
|
196
200
|
from codeforerunner import doctor
|
|
197
201
|
from codeforerunner.config import CONFIG_FILENAME
|
|
198
202
|
root = Path(args.repo).resolve() if args.repo else Path.cwd()
|
|
@@ -209,6 +213,7 @@ def cmd_doctor(args: argparse.Namespace) -> int:
|
|
|
209
213
|
|
|
210
214
|
|
|
211
215
|
def build_parser() -> argparse.ArgumentParser:
|
|
216
|
+
"""Build and return the top-level argument parser with all subcommands registered."""
|
|
212
217
|
p = argparse.ArgumentParser(
|
|
213
218
|
prog="forerunner",
|
|
214
219
|
description="Prompt-first repo documentation tooling. Thin CLI; product logic in prompts/.",
|
|
@@ -285,6 +290,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
285
290
|
|
|
286
291
|
|
|
287
292
|
def main(argv: Sequence[str] | None = None) -> int:
|
|
293
|
+
"""Parse argv and dispatch to the appropriate subcommand handler."""
|
|
288
294
|
parser = build_parser()
|
|
289
295
|
args = parser.parse_args(argv)
|
|
290
296
|
if not hasattr(args, "repo"):
|
|
@@ -19,6 +19,8 @@ class ConfigError(Exception):
|
|
|
19
19
|
|
|
20
20
|
@dataclass(frozen=True)
|
|
21
21
|
class CheckConfig:
|
|
22
|
+
"""Drift-check task configuration: severity gates and path filters."""
|
|
23
|
+
|
|
22
24
|
block_on: tuple[str, ...] = ("HIGH", "MEDIUM")
|
|
23
25
|
warn_on: tuple[str, ...] = ("LOW",)
|
|
24
26
|
enabled_rules: tuple[str, ...] | None = None # None = all rules enabled
|
|
@@ -27,6 +29,8 @@ class CheckConfig:
|
|
|
27
29
|
|
|
28
30
|
@dataclass(frozen=True)
|
|
29
31
|
class VersionAuditConfig:
|
|
32
|
+
"""Version-audit task configuration: staleness window and live EOL data toggle."""
|
|
33
|
+
|
|
30
34
|
enabled: bool = True
|
|
31
35
|
stale_after_days: int = 30
|
|
32
36
|
fetch_live_eol_data: bool = False
|
|
@@ -34,6 +38,8 @@ class VersionAuditConfig:
|
|
|
34
38
|
|
|
35
39
|
@dataclass(frozen=True)
|
|
36
40
|
class ForerunnerConfig:
|
|
41
|
+
"""Top-level forerunner.config.yaml configuration."""
|
|
42
|
+
|
|
37
43
|
provider: str = "anthropic"
|
|
38
44
|
model: str = "claude-opus-4-7"
|
|
39
45
|
approaching_eol_threshold_months: int = 6
|
|
@@ -52,7 +58,7 @@ def _require_type(value: Any, expected: type, field_name: str) -> Any:
|
|
|
52
58
|
|
|
53
59
|
|
|
54
60
|
def _coerce_str_tuple(value: Any, field_name: str) -> tuple[str, ...]:
|
|
55
|
-
if value is None:
|
|
61
|
+
if value is None: # pragma: no cover - callers supply defaults, never pass None
|
|
56
62
|
return ()
|
|
57
63
|
if not isinstance(value, list):
|
|
58
64
|
raise ConfigError(f"{field_name}: expected list, got {type(value).__name__}")
|
|
@@ -71,7 +77,7 @@ def _parse_api_key_env(raw: Any) -> dict[str, str]:
|
|
|
71
77
|
raise ConfigError(f"api_key_env: expected dict, got {type(raw).__name__}")
|
|
72
78
|
out: dict[str, str] = {}
|
|
73
79
|
for k, v in raw.items():
|
|
74
|
-
if not isinstance(k, str):
|
|
80
|
+
if not isinstance(k, str): # pragma: no cover - YAML keys are always strings
|
|
75
81
|
raise ConfigError(
|
|
76
82
|
f"api_key_env: keys must be strings, got {type(k).__name__}"
|
|
77
83
|
)
|
|
@@ -33,6 +33,8 @@ _DEFAULT_PROVIDER_ENV = {
|
|
|
33
33
|
|
|
34
34
|
@dataclass(frozen=True)
|
|
35
35
|
class Finding:
|
|
36
|
+
"""Single health-check result with severity, check name, and human message."""
|
|
37
|
+
|
|
36
38
|
severity: str # "ok" | "warn" | "error"
|
|
37
39
|
check: str
|
|
38
40
|
message: str
|
|
@@ -244,7 +246,7 @@ def _check_config_loadable(repo: Path) -> list[Finding]:
|
|
|
244
246
|
|
|
245
247
|
|
|
246
248
|
def _skill_mode_active() -> bool:
|
|
247
|
-
"""True if any installed skill destination has managed markers
|
|
249
|
+
"""Return True if any installed skill destination has managed markers (agent is the model)."""
|
|
248
250
|
for dest in _installed_skill_destinations():
|
|
249
251
|
if dest.exists():
|
|
250
252
|
try:
|
|
@@ -315,16 +317,13 @@ def _check_provider_api_key(repo: Path) -> list[Finding]:
|
|
|
315
317
|
env_var = cfg.api_key_env.get(provider) or _DEFAULT_PROVIDER_ENV.get(provider, "")
|
|
316
318
|
if os.environ.get(env_var):
|
|
317
319
|
return [Finding("ok", "provider-api-key", f"{provider}: {env_var} is set")]
|
|
320
|
+
# Emit the same warning regardless of skill mode: an explicit provider in config
|
|
321
|
+
# means the user intends to use that provider, so a missing key is always a real problem.
|
|
318
322
|
return [
|
|
319
323
|
Finding(
|
|
320
324
|
"warn",
|
|
321
325
|
"provider-api-key",
|
|
322
|
-
f"{provider}: ${env_var} is not set; `forerunner generate` will refuse to run"
|
|
323
|
-
+ (
|
|
324
|
-
" (use `--prompt-only` for key-free bundle output)"
|
|
325
|
-
if _skill_mode_active()
|
|
326
|
-
else ""
|
|
327
|
-
),
|
|
326
|
+
f"{provider}: ${env_var} is not set; `forerunner generate` will refuse to run",
|
|
328
327
|
)
|
|
329
328
|
]
|
|
330
329
|
|
|
@@ -348,10 +347,12 @@ tasks:
|
|
|
348
347
|
|
|
349
348
|
|
|
350
349
|
def starter_config() -> str:
|
|
350
|
+
"""Return the default forerunner.config.yaml content written by --fix."""
|
|
351
351
|
return _STARTER_CONFIG
|
|
352
352
|
|
|
353
353
|
|
|
354
354
|
def run(repo: Path, run_scripts: bool = False) -> list[Finding]:
|
|
355
|
+
"""Run all health checks against *repo* and return findings."""
|
|
355
356
|
repo = repo.resolve()
|
|
356
357
|
findings: list[Finding] = []
|
|
357
358
|
findings.extend(_check_skill_body_parity(repo, run_scripts=run_scripts))
|
|
@@ -363,16 +364,18 @@ def run(repo: Path, run_scripts: bool = False) -> list[Finding]:
|
|
|
363
364
|
|
|
364
365
|
|
|
365
366
|
def format_report(findings: list[Finding]) -> str:
|
|
367
|
+
"""Format findings as a human-readable report string with a summary line."""
|
|
366
368
|
lines = [f"[{f.severity}] {f.check}: {f.message}" for f in findings]
|
|
367
369
|
counts: dict[str, int] = {"ok": 0, "warn": 0, "error": 0}
|
|
368
370
|
for f in findings:
|
|
369
|
-
counts[f.severity]
|
|
371
|
+
counts[f.severity] += 1
|
|
370
372
|
summary = f"summary: {counts['ok']} ok, {counts['warn']} warn, {counts['error']} error"
|
|
371
373
|
lines.append(summary)
|
|
372
374
|
return "\n".join(lines)
|
|
373
375
|
|
|
374
376
|
|
|
375
377
|
def main(argv: list[str] | None = None) -> int:
|
|
378
|
+
"""Entry point for `forerunner doctor`; returns 1 when any finding is an error."""
|
|
376
379
|
parser = argparse.ArgumentParser(
|
|
377
380
|
prog="forerunner doctor",
|
|
378
381
|
description="Single-screen health report for codeforerunner repo.",
|
|
@@ -41,6 +41,8 @@ TASK_SKILL_SLUGS: tuple[str, ...] = (
|
|
|
41
41
|
|
|
42
42
|
@dataclass(frozen=True)
|
|
43
43
|
class Target:
|
|
44
|
+
"""Resolved install destination: agent name + absolute path."""
|
|
45
|
+
|
|
44
46
|
name: str
|
|
45
47
|
path: Path
|
|
46
48
|
|
|
@@ -50,6 +52,7 @@ def _home() -> Path:
|
|
|
50
52
|
|
|
51
53
|
|
|
52
54
|
def resolve_target(agent: str, override: Path | None) -> Target:
|
|
55
|
+
"""Return the default install Target for the given agent, or use override path."""
|
|
53
56
|
if agent == "generic":
|
|
54
57
|
if override is None:
|
|
55
58
|
raise ValueError("generic target requires --path PATH")
|
|
@@ -126,6 +129,7 @@ def install_all_skills(
|
|
|
126
129
|
|
|
127
130
|
|
|
128
131
|
def resolve_marketplace_target(agent: str, override: Path | None) -> Target:
|
|
132
|
+
"""Return the marketplace install Target for the given agent, or use override path."""
|
|
129
133
|
if agent == "generic":
|
|
130
134
|
if override is None:
|
|
131
135
|
raise ValueError("generic marketplace target requires --path PATH")
|
|
@@ -181,6 +185,7 @@ def render(source_text: str, dest_existing: str | None, agent: str) -> str:
|
|
|
181
185
|
|
|
182
186
|
|
|
183
187
|
def find_markers(text: str) -> tuple[int, int] | None:
|
|
188
|
+
"""Return (start, end) byte offsets of the managed region, or None if absent."""
|
|
184
189
|
a = text.find(MARKER_BEGIN)
|
|
185
190
|
if a < 0:
|
|
186
191
|
return None
|
|
@@ -202,12 +207,15 @@ def overlay(dest_text: str, source_body: str) -> str:
|
|
|
202
207
|
|
|
203
208
|
@dataclass
|
|
204
209
|
class Plan:
|
|
210
|
+
"""Pending install action computed by plan_install or plan_marketplace."""
|
|
211
|
+
|
|
205
212
|
action: str # "create" | "update" | "skip" | "abort"
|
|
206
213
|
reason: str
|
|
207
214
|
target: Target
|
|
208
215
|
new_content: str | None = None
|
|
209
216
|
|
|
210
217
|
def write(self) -> None:
|
|
218
|
+
"""Execute the plan: create or update the destination file."""
|
|
211
219
|
if self.action in ("skip", "abort"):
|
|
212
220
|
return
|
|
213
221
|
assert self.new_content is not None
|
|
@@ -306,6 +314,7 @@ def install(
|
|
|
306
314
|
out=None,
|
|
307
315
|
err=None,
|
|
308
316
|
) -> int:
|
|
317
|
+
"""Run one install operation (skill or marketplace). Returns an EXIT_* code."""
|
|
309
318
|
out = out or sys.stdout
|
|
310
319
|
err = err or sys.stderr
|
|
311
320
|
|
|
@@ -356,6 +365,7 @@ def install(
|
|
|
356
365
|
|
|
357
366
|
|
|
358
367
|
def add_subparser(sub: argparse._SubParsersAction) -> None:
|
|
368
|
+
"""Register the `forerunner install` subcommand onto *sub*."""
|
|
359
369
|
p = sub.add_parser("install", help="install skill(s) into agent-specific directories (D.installer)")
|
|
360
370
|
p.add_argument("agent", choices=["codex", "claude", "generic"], nargs="?",
|
|
361
371
|
help="target agent (omit with --all to install to all detected agents)")
|
|
@@ -114,6 +114,7 @@ def _handle(prompts_root: Path, msg: dict[str, Any], state: dict[str, Any]) -> d
|
|
|
114
114
|
|
|
115
115
|
|
|
116
116
|
def serve(prompts_root: Path, stdin: Iterable[str] = sys.stdin, stdout=sys.stdout, stderr=sys.stderr) -> int:
|
|
117
|
+
"""Run the JSON-RPC 2.0 MCP server loop over *stdin*/*stdout* until EOF."""
|
|
117
118
|
state: dict[str, Any] = {"scan_called": False, "initialized": False}
|
|
118
119
|
for raw in stdin:
|
|
119
120
|
line = raw.strip()
|
|
@@ -141,6 +142,7 @@ def serve(prompts_root: Path, stdin: Iterable[str] = sys.stdin, stdout=sys.stdou
|
|
|
141
142
|
|
|
142
143
|
|
|
143
144
|
def main(argv: list[str] | None = None) -> int:
|
|
145
|
+
"""Locate prompts root and start the MCP server; returns 2 if prompts not found."""
|
|
144
146
|
try:
|
|
145
147
|
prompts_root = find_prompts_root()
|
|
146
148
|
except FileNotFoundError as e:
|
|
@@ -149,5 +151,5 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
149
151
|
return serve(prompts_root)
|
|
150
152
|
|
|
151
153
|
|
|
152
|
-
if __name__ == "__main__":
|
|
154
|
+
if __name__ == "__main__": # pragma: no cover
|
|
153
155
|
raise SystemExit(main())
|
|
@@ -30,6 +30,7 @@ REGISTRY: dict[str, type] = {
|
|
|
30
30
|
|
|
31
31
|
|
|
32
32
|
def get(name: str) -> type:
|
|
33
|
+
"""Return the provider class for *name*, or raise ProviderError if unknown."""
|
|
33
34
|
if name not in REGISTRY:
|
|
34
35
|
raise ProviderError(
|
|
35
36
|
f"unknown provider '{name}' (expected one of {sorted(REGISTRY)})"
|
|
@@ -11,6 +11,8 @@ from codeforerunner.providers.base import CompletionResult, ProviderError
|
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
class AnthropicProvider:
|
|
14
|
+
"""Anthropic Messages API provider using stdlib HTTP."""
|
|
15
|
+
|
|
14
16
|
name = "anthropic"
|
|
15
17
|
default_env_var = "ANTHROPIC_API_KEY"
|
|
16
18
|
default_model = "claude-opus-4-7"
|
|
@@ -24,6 +26,7 @@ class AnthropicProvider:
|
|
|
24
26
|
model: str | None = None,
|
|
25
27
|
api_key: str | None = None,
|
|
26
28
|
) -> CompletionResult:
|
|
29
|
+
"""Send *prompt* to the Anthropic Messages API and return the full response."""
|
|
27
30
|
if not api_key:
|
|
28
31
|
raise ProviderError(f"missing API key (set ${self.default_env_var})")
|
|
29
32
|
model = model or self.default_model
|
|
@@ -68,6 +71,7 @@ class AnthropicProvider:
|
|
|
68
71
|
model: str | None = None,
|
|
69
72
|
api_key: str | None = None,
|
|
70
73
|
) -> Iterator[str]:
|
|
74
|
+
"""Yield text chunks from the Anthropic streaming Messages API."""
|
|
71
75
|
if not api_key:
|
|
72
76
|
raise ProviderError(f"missing API key (set ${self.default_env_var})")
|
|
73
77
|
model = model or self.default_model
|
|
@@ -8,12 +8,16 @@ from typing import Iterator, Protocol
|
|
|
8
8
|
|
|
9
9
|
@dataclass(frozen=True)
|
|
10
10
|
class CompletionResult:
|
|
11
|
+
"""Completed text response returned by a provider."""
|
|
12
|
+
|
|
11
13
|
text: str
|
|
12
14
|
model: str
|
|
13
15
|
usage: dict | None = None # provider-reported token counts; None if unknown
|
|
14
16
|
|
|
15
17
|
|
|
16
18
|
class Provider(Protocol):
|
|
19
|
+
"""Structural protocol that all LLM provider classes must satisfy."""
|
|
20
|
+
|
|
17
21
|
name: str
|
|
18
22
|
default_env_var: str # e.g. "ANTHROPIC_API_KEY"
|
|
19
23
|
default_model: str # provider's recommended default
|
|
@@ -24,7 +28,9 @@ class Provider(Protocol):
|
|
|
24
28
|
prompt: str,
|
|
25
29
|
model: str | None = None,
|
|
26
30
|
api_key: str | None = None,
|
|
27
|
-
) -> CompletionResult:
|
|
31
|
+
) -> CompletionResult:
|
|
32
|
+
"""Send *prompt* and return the full completion result."""
|
|
33
|
+
...
|
|
28
34
|
|
|
29
35
|
def stream(
|
|
30
36
|
self,
|
|
@@ -32,7 +38,9 @@ class Provider(Protocol):
|
|
|
32
38
|
prompt: str,
|
|
33
39
|
model: str | None = None,
|
|
34
40
|
api_key: str | None = None,
|
|
35
|
-
) -> Iterator[str]:
|
|
41
|
+
) -> Iterator[str]:
|
|
42
|
+
"""Yield text chunks from *prompt* as they arrive from the provider."""
|
|
43
|
+
...
|
|
36
44
|
|
|
37
45
|
|
|
38
46
|
class ProviderError(Exception):
|
|
@@ -12,6 +12,8 @@ from codeforerunner.providers.base import CompletionResult, ProviderError
|
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
class GoogleProvider:
|
|
15
|
+
"""Google Gemini generateContent provider using stdlib HTTP."""
|
|
16
|
+
|
|
15
17
|
name = "google"
|
|
16
18
|
default_env_var = "GOOGLE_API_KEY"
|
|
17
19
|
default_model = "gemini-2.5-pro"
|
|
@@ -33,6 +35,7 @@ class GoogleProvider:
|
|
|
33
35
|
model: str | None = None,
|
|
34
36
|
api_key: str | None = None,
|
|
35
37
|
) -> CompletionResult:
|
|
38
|
+
"""Send *prompt* to the Gemini generateContent endpoint and return the full response."""
|
|
36
39
|
if not api_key:
|
|
37
40
|
raise ProviderError(f"missing API key (set ${self.default_env_var})")
|
|
38
41
|
model = model or self.default_model
|
|
@@ -75,6 +78,7 @@ class GoogleProvider:
|
|
|
75
78
|
model: str | None = None,
|
|
76
79
|
api_key: str | None = None,
|
|
77
80
|
) -> Iterator[str]:
|
|
81
|
+
"""Yield text chunks from the Gemini streaming generateContent endpoint."""
|
|
78
82
|
if not api_key:
|
|
79
83
|
raise ProviderError(f"missing API key (set ${self.default_env_var})")
|
|
80
84
|
model = model or self.default_model
|
|
@@ -20,7 +20,7 @@ def _validate_ollama_base(base: str) -> None:
|
|
|
20
20
|
"""Reject URLs that look like cloud metadata endpoints or use unexpected schemes."""
|
|
21
21
|
try:
|
|
22
22
|
parsed = urllib.parse.urlparse(base)
|
|
23
|
-
except Exception as e:
|
|
23
|
+
except Exception as e: # pragma: no cover - urlparse never raises
|
|
24
24
|
raise ValueError(f"OLLAMA_HOST: invalid URL: {e}") from e
|
|
25
25
|
if parsed.scheme not in ("http", "https"):
|
|
26
26
|
raise ValueError(
|
|
@@ -46,6 +46,8 @@ def is_available(host: str | None = None) -> bool:
|
|
|
46
46
|
|
|
47
47
|
|
|
48
48
|
class OllamaProvider:
|
|
49
|
+
"""Ollama local provider using stdlib HTTP."""
|
|
50
|
+
|
|
49
51
|
name = "ollama"
|
|
50
52
|
default_env_var = "OLLAMA_HOST"
|
|
51
53
|
default_model = "llama3"
|
|
@@ -57,6 +59,7 @@ class OllamaProvider:
|
|
|
57
59
|
model: str | None = None,
|
|
58
60
|
api_key: str | None = None,
|
|
59
61
|
) -> CompletionResult:
|
|
62
|
+
"""Send *prompt* to the Ollama /api/generate endpoint and return the full response."""
|
|
60
63
|
# api_key is interpreted as a base URL override; fall back to env then default.
|
|
61
64
|
base = api_key or os.environ.get(self.default_env_var) or DEFAULT_HOST
|
|
62
65
|
base = base.rstrip("/")
|
|
@@ -96,6 +99,7 @@ class OllamaProvider:
|
|
|
96
99
|
model: str | None = None,
|
|
97
100
|
api_key: str | None = None,
|
|
98
101
|
) -> Iterator[str]:
|
|
102
|
+
"""Yield text chunks from the Ollama /api/generate streaming endpoint."""
|
|
99
103
|
base = api_key or os.environ.get(self.default_env_var) or DEFAULT_HOST
|
|
100
104
|
base = base.rstrip("/")
|
|
101
105
|
_validate_ollama_base(base)
|
|
@@ -11,6 +11,8 @@ from codeforerunner.providers.base import CompletionResult, ProviderError
|
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
class OpenAIProvider:
|
|
14
|
+
"""OpenAI chat completions provider using stdlib HTTP."""
|
|
15
|
+
|
|
14
16
|
name = "openai"
|
|
15
17
|
default_env_var = "OPENAI_API_KEY"
|
|
16
18
|
default_model = "gpt-4o"
|
|
@@ -24,6 +26,7 @@ class OpenAIProvider:
|
|
|
24
26
|
model: str | None = None,
|
|
25
27
|
api_key: str | None = None,
|
|
26
28
|
) -> CompletionResult:
|
|
29
|
+
"""Send *prompt* to the OpenAI chat completions endpoint and return the full response."""
|
|
27
30
|
if not api_key:
|
|
28
31
|
raise ProviderError(f"missing API key (set ${self.default_env_var})")
|
|
29
32
|
model = model or self.default_model
|
|
@@ -66,6 +69,7 @@ class OpenAIProvider:
|
|
|
66
69
|
model: str | None = None,
|
|
67
70
|
api_key: str | None = None,
|
|
68
71
|
) -> Iterator[str]:
|
|
72
|
+
"""Yield text chunks from the OpenAI streaming chat completions endpoint."""
|
|
69
73
|
if not api_key:
|
|
70
74
|
raise ProviderError(f"missing API key (set ${self.default_env_var})")
|
|
71
75
|
model = model or self.default_model
|
|
@@ -37,6 +37,7 @@ src/codeforerunner/providers/base.py
|
|
|
37
37
|
src/codeforerunner/providers/google.py
|
|
38
38
|
src/codeforerunner/providers/ollama.py
|
|
39
39
|
src/codeforerunner/providers/openai.py
|
|
40
|
+
tests/test_bundle.py
|
|
40
41
|
tests/test_check.py
|
|
41
42
|
tests/test_check_config_integration.py
|
|
42
43
|
tests/test_cli.py
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Tests for bundle.py edge cases."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from unittest.mock import patch
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from codeforerunner.bundle import find_prompts_root, resolve_bundle
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_find_prompts_root_raises_when_repo_arg_has_no_tasks(tmp_path):
|
|
13
|
+
with pytest.raises(FileNotFoundError, match="no prompts/tasks/"):
|
|
14
|
+
find_prompts_root(tmp_path)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_find_prompts_root_uses_repo_arg_when_tasks_present(tmp_path):
|
|
18
|
+
(tmp_path / "prompts" / "tasks").mkdir(parents=True)
|
|
19
|
+
result = find_prompts_root(tmp_path)
|
|
20
|
+
assert result == tmp_path / "prompts"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_find_prompts_root_walks_cwd_to_find_prompts(tmp_path):
|
|
24
|
+
# Create prompts/tasks/ in tmp_path and change cwd into a subdir
|
|
25
|
+
(tmp_path / "prompts" / "tasks").mkdir(parents=True)
|
|
26
|
+
subdir = tmp_path / "deep" / "nested"
|
|
27
|
+
subdir.mkdir(parents=True)
|
|
28
|
+
with patch("codeforerunner.bundle.Path") as MockPath:
|
|
29
|
+
# Replace cwd() so the walk starts from subdir
|
|
30
|
+
MockPath.cwd.return_value = subdir
|
|
31
|
+
# Make sure Path still works for everything else
|
|
32
|
+
MockPath.side_effect = lambda x=None: Path(x) if x is not None else Path()
|
|
33
|
+
# Directly test the cwd walk logic with the real function
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
# Simpler approach: call find_prompts_root from within a subdir of tmp_path
|
|
37
|
+
import os
|
|
38
|
+
old_cwd = os.getcwd()
|
|
39
|
+
try:
|
|
40
|
+
subdir.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
os.chdir(subdir)
|
|
42
|
+
result = find_prompts_root()
|
|
43
|
+
assert result == tmp_path / "prompts"
|
|
44
|
+
finally:
|
|
45
|
+
os.chdir(old_cwd)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_find_prompts_root_raises_when_no_package_prompts(tmp_path, monkeypatch):
|
|
49
|
+
import os
|
|
50
|
+
old_cwd = os.getcwd()
|
|
51
|
+
try:
|
|
52
|
+
os.chdir(tmp_path)
|
|
53
|
+
# Patch _package_prompts to point to a dir with no tasks/
|
|
54
|
+
with patch("codeforerunner.bundle._package_prompts", return_value=tmp_path / "pkg"):
|
|
55
|
+
with pytest.raises(FileNotFoundError, match="could not find prompts/tasks"):
|
|
56
|
+
find_prompts_root()
|
|
57
|
+
finally:
|
|
58
|
+
os.chdir(old_cwd)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_resolve_bundle_raises_for_unknown_task(tmp_path):
|
|
62
|
+
(tmp_path / "tasks").mkdir()
|
|
63
|
+
with pytest.raises(FileNotFoundError, match="unknown task"):
|
|
64
|
+
resolve_bundle(tmp_path, "nonexistent-task")
|
|
@@ -385,6 +385,69 @@ def test_rv1_not_run_when_excluded_from_enabled_rules(tmp_path):
|
|
|
385
385
|
assert not any(v.rule_id == "RV1-version-drift" for v in run(tmp_path, cfg))
|
|
386
386
|
|
|
387
387
|
|
|
388
|
+
# ── Version drift internals ────────────────────────────────────────────────────
|
|
389
|
+
|
|
390
|
+
def test_rv1_skips_changelog_in_docs_dir(tmp_path):
|
|
391
|
+
# CHANGELOG.md inside docs/ is picked up by _scanned_docs but must be skipped
|
|
392
|
+
_seed(
|
|
393
|
+
tmp_path,
|
|
394
|
+
{
|
|
395
|
+
"docs/CHANGELOG.md": "codeforerunner==0.1.0\n",
|
|
396
|
+
"pyproject.toml": '[project]\nname = "codeforerunner"\nversion = "0.3.1"\n',
|
|
397
|
+
},
|
|
398
|
+
)
|
|
399
|
+
vs = [v for v in run(tmp_path) if v.rule_id == "RV1-version-drift"]
|
|
400
|
+
assert vs == []
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def test_rv1_skips_ignored_path(tmp_path):
|
|
404
|
+
from codeforerunner.config import CheckConfig
|
|
405
|
+
_seed(
|
|
406
|
+
tmp_path,
|
|
407
|
+
{
|
|
408
|
+
"docs/legacy.md": "codeforerunner==0.1.0\n",
|
|
409
|
+
"pyproject.toml": '[project]\nname = "codeforerunner"\nversion = "0.3.1"\n',
|
|
410
|
+
},
|
|
411
|
+
)
|
|
412
|
+
cfg = CheckConfig(ignore_paths=("docs/legacy.md",))
|
|
413
|
+
vs = [v for v in run(tmp_path, cfg) if v.rule_id == "RV1-version-drift"]
|
|
414
|
+
assert vs == []
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def test_rv1_skips_unreadable_doc(tmp_path):
|
|
418
|
+
import pathlib
|
|
419
|
+
from unittest.mock import MagicMock, patch
|
|
420
|
+
from codeforerunner.check import _check_version_drift
|
|
421
|
+
|
|
422
|
+
(tmp_path / "pyproject.toml").write_text('[project]\nversion = "1.0.0"\n', encoding="utf-8")
|
|
423
|
+
mock_doc = MagicMock()
|
|
424
|
+
mock_doc.name = "guide.md"
|
|
425
|
+
mock_doc.read_text.side_effect = OSError("permission denied")
|
|
426
|
+
|
|
427
|
+
violations = _check_version_drift(tmp_path, [mock_doc], (), None)
|
|
428
|
+
assert violations == []
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def test_path_ignored_doc_outside_repo():
|
|
432
|
+
from codeforerunner.check import _path_ignored
|
|
433
|
+
repo = Path("/tmp/some-repo")
|
|
434
|
+
doc = Path("/other/location/README.md")
|
|
435
|
+
# Should not crash; uses abs posix path matching
|
|
436
|
+
result = _path_ignored(repo, doc, ("*.md",))
|
|
437
|
+
assert isinstance(result, bool)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def test_current_version_returns_none_on_oserror(tmp_path):
|
|
441
|
+
import pathlib
|
|
442
|
+
from unittest.mock import patch
|
|
443
|
+
from codeforerunner.check import _current_version
|
|
444
|
+
|
|
445
|
+
(tmp_path / "pyproject.toml").touch()
|
|
446
|
+
with patch.object(pathlib.Path, "read_text", side_effect=OSError("denied")):
|
|
447
|
+
result = _current_version(tmp_path)
|
|
448
|
+
assert result is None
|
|
449
|
+
|
|
450
|
+
|
|
388
451
|
def test_workflows_lint_when_actionlint_available():
|
|
389
452
|
import shutil
|
|
390
453
|
import subprocess
|