codeforerunner 0.3.0__py3-none-any.whl
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/__init__.py +1 -0
- codeforerunner/check.py +156 -0
- codeforerunner/cli.py +236 -0
- codeforerunner/config.py +176 -0
- codeforerunner/doctor.py +321 -0
- codeforerunner/installer.py +304 -0
- codeforerunner/mcp_server.py +177 -0
- codeforerunner/providers/__init__.py +36 -0
- codeforerunner/providers/anthropic.py +61 -0
- codeforerunner/providers/base.py +31 -0
- codeforerunner/providers/google.py +62 -0
- codeforerunner/providers/ollama.py +56 -0
- codeforerunner/providers/openai.py +59 -0
- codeforerunner-0.3.0.dist-info/METADATA +120 -0
- codeforerunner-0.3.0.dist-info/RECORD +19 -0
- codeforerunner-0.3.0.dist-info/WHEEL +5 -0
- codeforerunner-0.3.0.dist-info/entry_points.txt +2 -0
- codeforerunner-0.3.0.dist-info/licenses/LICENSE.md +71 -0
- codeforerunner-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.0"
|
codeforerunner/check.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Drift detection for docs that claim files don't exist when they do."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import fnmatch
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from codeforerunner.config import CheckConfig
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class Violation:
|
|
14
|
+
path: Path
|
|
15
|
+
line: int
|
|
16
|
+
rule_id: str
|
|
17
|
+
message: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
_RULES = [
|
|
21
|
+
(
|
|
22
|
+
"R1-no-cli",
|
|
23
|
+
re.compile(
|
|
24
|
+
r"(?i)no\s+CLI\s+exists"
|
|
25
|
+
r"|CLI\s+does\s+not\s+exist"
|
|
26
|
+
r"|not\s+currently\s+present:[^.]*\bCLI\b"
|
|
27
|
+
r"|Do\s+not\s+run\s+`forerunner`"
|
|
28
|
+
),
|
|
29
|
+
("src/codeforerunner/cli.py",),
|
|
30
|
+
"doc claims no CLI exists, but src/codeforerunner/cli.py is present",
|
|
31
|
+
),
|
|
32
|
+
(
|
|
33
|
+
"R2-no-pre-commit",
|
|
34
|
+
re.compile(r"(?i)no\s+pre[- ]commit(\s+hook)?"),
|
|
35
|
+
(".pre-commit-hooks.yaml",),
|
|
36
|
+
"doc claims no pre-commit hook, but .pre-commit-hooks.yaml is present",
|
|
37
|
+
),
|
|
38
|
+
(
|
|
39
|
+
"R3-no-ci",
|
|
40
|
+
re.compile(r"(?i)no\s+CI(\s+workflow)?"),
|
|
41
|
+
(".github/workflows/*.yml",),
|
|
42
|
+
"doc claims no CI workflow, but .github/workflows/*.yml is present",
|
|
43
|
+
),
|
|
44
|
+
(
|
|
45
|
+
"R4-no-installer",
|
|
46
|
+
re.compile(r"(?i)no\s+installer"),
|
|
47
|
+
("src/codeforerunner/installer.py",),
|
|
48
|
+
"doc claims no installer, but src/codeforerunner/installer.py is present",
|
|
49
|
+
),
|
|
50
|
+
(
|
|
51
|
+
"R5-no-python-package",
|
|
52
|
+
re.compile(r"(?i)no\s+Python\s+package"),
|
|
53
|
+
("pyproject.toml",),
|
|
54
|
+
"doc claims no Python package, but pyproject.toml is present",
|
|
55
|
+
),
|
|
56
|
+
(
|
|
57
|
+
"R6-no-docker",
|
|
58
|
+
re.compile(r"(?i)no\s+Docker(\s+image)?|no\s+Dockerfile"),
|
|
59
|
+
("Dockerfile", "compose.yml", "docker-compose.yml"),
|
|
60
|
+
"doc claims no Docker, but Dockerfile/compose file is present",
|
|
61
|
+
),
|
|
62
|
+
(
|
|
63
|
+
"R6b-no-makefile",
|
|
64
|
+
re.compile(r"(?i)no\s+Makefile"),
|
|
65
|
+
("Makefile",),
|
|
66
|
+
"doc claims no Makefile, but Makefile is present",
|
|
67
|
+
),
|
|
68
|
+
(
|
|
69
|
+
"R7-no-mcp",
|
|
70
|
+
re.compile(r"(?i)no\s+MCP(\s+server)?"),
|
|
71
|
+
("src/codeforerunner/mcp_server.py",),
|
|
72
|
+
"doc claims no MCP server, but src/codeforerunner/mcp_server.py is present",
|
|
73
|
+
),
|
|
74
|
+
(
|
|
75
|
+
"R8-no-marketplace",
|
|
76
|
+
re.compile(r"(?i)no\s+marketplace(\s+manifest)?"),
|
|
77
|
+
("plugins/codex/marketplace.json",),
|
|
78
|
+
"doc claims no marketplace, but plugins/codex/marketplace.json is present",
|
|
79
|
+
),
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _trigger_exists(repo: Path, patterns: tuple[str, ...]) -> bool:
|
|
84
|
+
for pat in patterns:
|
|
85
|
+
if "*" in pat:
|
|
86
|
+
parent = repo / Path(pat).parent
|
|
87
|
+
name = Path(pat).name
|
|
88
|
+
if parent.is_dir() and any(parent.glob(name)):
|
|
89
|
+
return True
|
|
90
|
+
else:
|
|
91
|
+
if (repo / pat).exists():
|
|
92
|
+
return True
|
|
93
|
+
return False
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _scanned_docs(repo: Path) -> list[Path]:
|
|
97
|
+
docs: list[Path] = []
|
|
98
|
+
readme = repo / "README.md"
|
|
99
|
+
if readme.is_file():
|
|
100
|
+
docs.append(readme)
|
|
101
|
+
docs_dir = repo / "docs"
|
|
102
|
+
if docs_dir.is_dir():
|
|
103
|
+
docs.extend(sorted(p for p in docs_dir.rglob("*.md") if p.is_file()))
|
|
104
|
+
return docs
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _path_ignored(repo: Path, doc: Path, ignore_patterns: tuple[str, ...]) -> bool:
|
|
108
|
+
if not ignore_patterns:
|
|
109
|
+
return False
|
|
110
|
+
try:
|
|
111
|
+
rel = doc.relative_to(repo).as_posix()
|
|
112
|
+
except ValueError:
|
|
113
|
+
rel = doc.as_posix()
|
|
114
|
+
return any(fnmatch.fnmatch(rel, pat) for pat in ignore_patterns)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def run(repo: Path, config: CheckConfig | None = None) -> list[Violation]:
|
|
118
|
+
"""Scan repo docs for drift; return list of violations.
|
|
119
|
+
|
|
120
|
+
`config` filters rules via `enabled_rules` and skips docs matching `ignore_paths`.
|
|
121
|
+
`None` config (default) preserves the pre-T25 behavior: all rules, no ignores.
|
|
122
|
+
"""
|
|
123
|
+
repo = Path(repo)
|
|
124
|
+
enabled = set(config.enabled_rules) if (config and config.enabled_rules is not None) else None
|
|
125
|
+
ignore_patterns = config.ignore_paths if config else ()
|
|
126
|
+
|
|
127
|
+
active_rules = [
|
|
128
|
+
(rid, rx, msg)
|
|
129
|
+
for rid, rx, triggers, msg in _RULES
|
|
130
|
+
if _trigger_exists(repo, triggers) and (enabled is None or rid in enabled)
|
|
131
|
+
]
|
|
132
|
+
if not active_rules:
|
|
133
|
+
return []
|
|
134
|
+
|
|
135
|
+
violations: list[Violation] = []
|
|
136
|
+
for doc in _scanned_docs(repo):
|
|
137
|
+
if _path_ignored(repo, doc, ignore_patterns):
|
|
138
|
+
continue
|
|
139
|
+
try:
|
|
140
|
+
text = doc.read_text(encoding="utf-8")
|
|
141
|
+
except (OSError, UnicodeDecodeError):
|
|
142
|
+
continue
|
|
143
|
+
for lineno, line in enumerate(text.splitlines(), start=1):
|
|
144
|
+
for rid, rx, msg in active_rules:
|
|
145
|
+
if rx.search(line):
|
|
146
|
+
violations.append(
|
|
147
|
+
Violation(path=doc, line=lineno, rule_id=rid, message=msg)
|
|
148
|
+
)
|
|
149
|
+
return violations
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def format_violations(vs: list[Violation]) -> str:
|
|
153
|
+
"""Format violations one per line for stderr."""
|
|
154
|
+
return "\n".join(
|
|
155
|
+
f"{v.path}:{v.line}: {v.rule_id}: {v.message}" for v in vs
|
|
156
|
+
)
|
codeforerunner/cli.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Thin CLI orchestration. Product logic lives in `prompts/`. See SPEC.md §D.cli."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Sequence
|
|
10
|
+
|
|
11
|
+
SCAN_EXEMPT_TASKS = frozenset({"scan", "init-agent-onboarding"})
|
|
12
|
+
SCAN_DONE_ENV = "FORERUNNER_SCAN_DONE"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _repo_root(start: Path | None = None) -> Path:
|
|
16
|
+
"""Walk up from cwd (or `start`) to a directory containing `prompts/tasks`."""
|
|
17
|
+
here = (start or Path.cwd()).resolve()
|
|
18
|
+
for candidate in [here, *here.parents]:
|
|
19
|
+
if (candidate / "prompts" / "tasks").is_dir():
|
|
20
|
+
return candidate
|
|
21
|
+
raise FileNotFoundError(
|
|
22
|
+
"could not locate codeforerunner repo root (no prompts/tasks/ found upward)"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _read(path: Path) -> str:
|
|
27
|
+
return path.read_text(encoding="utf-8")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def cmd_doc(args: argparse.Namespace) -> int:
|
|
31
|
+
"""Resolve `prompts/system/base.md` + `prompts/partials/*.md` + `prompts/tasks/<task>.md` to stdout."""
|
|
32
|
+
root = _repo_root(Path(args.repo) if args.repo else None)
|
|
33
|
+
task_path = root / "prompts" / "tasks" / f"{args.task}.md"
|
|
34
|
+
if not task_path.is_file():
|
|
35
|
+
print(f"error: unknown task '{args.task}' (no {task_path})", file=sys.stderr)
|
|
36
|
+
return 2
|
|
37
|
+
|
|
38
|
+
if (
|
|
39
|
+
args.task not in SCAN_EXEMPT_TASKS
|
|
40
|
+
and (root / "forerunner.config.yaml").is_file()
|
|
41
|
+
and not os.environ.get(SCAN_DONE_ENV)
|
|
42
|
+
):
|
|
43
|
+
print(
|
|
44
|
+
f"warning: SPEC V2 scan-first — run `forerunner scan` first, "
|
|
45
|
+
f"then export {SCAN_DONE_ENV}=1 to silence this warning.",
|
|
46
|
+
file=sys.stderr,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
parts: list[str] = []
|
|
50
|
+
base = root / "prompts" / "system" / "base.md"
|
|
51
|
+
if base.is_file():
|
|
52
|
+
parts.append(f"<!-- system: base.md -->\n{_read(base).rstrip()}")
|
|
53
|
+
|
|
54
|
+
partials_dir = root / "prompts" / "partials"
|
|
55
|
+
if partials_dir.is_dir():
|
|
56
|
+
for p in sorted(partials_dir.glob("*.md")):
|
|
57
|
+
parts.append(f"<!-- partial: {p.name} -->\n{_read(p).rstrip()}")
|
|
58
|
+
|
|
59
|
+
parts.append(f"<!-- task: {task_path.name} -->\n{_read(task_path).rstrip()}")
|
|
60
|
+
sys.stdout.write("\n\n".join(parts) + "\n")
|
|
61
|
+
return 0
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _doc_for(args: argparse.Namespace, task: str) -> int:
|
|
65
|
+
ns = argparse.Namespace(repo=getattr(args, "repo", None), task=task)
|
|
66
|
+
return cmd_doc(ns)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def cmd_init(args: argparse.Namespace) -> int:
|
|
70
|
+
"""Default + --agents-only = onboarding bundle only. --full prepends scan."""
|
|
71
|
+
if getattr(args, "full", False):
|
|
72
|
+
sys.stdout.write("<!-- forerunner init --full: section 1/2 (scan) -->\n")
|
|
73
|
+
rc = _doc_for(args, "scan")
|
|
74
|
+
if rc != 0:
|
|
75
|
+
return rc
|
|
76
|
+
sys.stdout.write("\n<!-- forerunner init --full: section 2/2 (onboarding) -->\n")
|
|
77
|
+
return _doc_for(args, "init-agent-onboarding")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def cmd_scan(args: argparse.Namespace) -> int:
|
|
81
|
+
rc = _doc_for(args, "scan")
|
|
82
|
+
if rc == 0:
|
|
83
|
+
print(
|
|
84
|
+
f"hint: export {SCAN_DONE_ENV}=1 in this shell to silence "
|
|
85
|
+
"scan-first warnings on follow-up `forerunner doc`/`init` calls.",
|
|
86
|
+
file=sys.stderr,
|
|
87
|
+
)
|
|
88
|
+
return rc
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def cmd_check(args: argparse.Namespace) -> int:
|
|
92
|
+
"""Run check rules when `forerunner.config.yaml` present. Silent no-op otherwise."""
|
|
93
|
+
try:
|
|
94
|
+
root = _repo_root(Path(args.repo) if args.repo else None)
|
|
95
|
+
except FileNotFoundError:
|
|
96
|
+
root = Path.cwd()
|
|
97
|
+
from codeforerunner import check as _check
|
|
98
|
+
from codeforerunner.config import ConfigError, load_from_repo
|
|
99
|
+
try:
|
|
100
|
+
cfg = load_from_repo(root)
|
|
101
|
+
except ConfigError as e:
|
|
102
|
+
print(f"forerunner check: invalid config: {e}", file=sys.stderr)
|
|
103
|
+
return 2
|
|
104
|
+
if cfg is None:
|
|
105
|
+
return 0
|
|
106
|
+
violations = _check.run(root, cfg.check)
|
|
107
|
+
if not violations:
|
|
108
|
+
return 0
|
|
109
|
+
sys.stderr.write(_check.format_violations(violations) + "\n")
|
|
110
|
+
return 1
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def cmd_mcp_server(args: argparse.Namespace) -> int:
|
|
114
|
+
from codeforerunner import mcp_server
|
|
115
|
+
root = _repo_root(Path(args.repo) if args.repo else None)
|
|
116
|
+
return mcp_server.serve(root)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def cmd_generate(args: argparse.Namespace) -> int:
|
|
120
|
+
"""Resolve the bundle for <task> and send it to the configured provider."""
|
|
121
|
+
from codeforerunner import providers as _providers
|
|
122
|
+
from codeforerunner.config import load_from_repo
|
|
123
|
+
|
|
124
|
+
root = _repo_root(Path(args.repo) if args.repo else None)
|
|
125
|
+
cfg = load_from_repo(root)
|
|
126
|
+
|
|
127
|
+
provider_name = args.provider or (cfg.provider if cfg else "anthropic")
|
|
128
|
+
model = args.model or (cfg.model if cfg else None)
|
|
129
|
+
provider_cls = _providers.get(provider_name)
|
|
130
|
+
provider = provider_cls()
|
|
131
|
+
model = model or provider.default_model
|
|
132
|
+
|
|
133
|
+
import io as _io
|
|
134
|
+
buf = _io.StringIO()
|
|
135
|
+
ns = argparse.Namespace(repo=getattr(args, "repo", None), task=args.task)
|
|
136
|
+
# Temporarily redirect stdout to capture cmd_doc output.
|
|
137
|
+
real_stdout = sys.stdout
|
|
138
|
+
sys.stdout = buf
|
|
139
|
+
try:
|
|
140
|
+
rc = cmd_doc(ns)
|
|
141
|
+
finally:
|
|
142
|
+
sys.stdout = real_stdout
|
|
143
|
+
if rc != 0:
|
|
144
|
+
return rc
|
|
145
|
+
|
|
146
|
+
env_var = (cfg.api_key_env.get(provider_name) if cfg else None) or provider.default_env_var
|
|
147
|
+
api_key = os.environ.get(env_var)
|
|
148
|
+
if api_key is None and provider_name != "ollama":
|
|
149
|
+
print(
|
|
150
|
+
f"error: missing API key; set ${env_var}",
|
|
151
|
+
file=sys.stderr,
|
|
152
|
+
)
|
|
153
|
+
return 3
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
result = provider.complete(prompt=buf.getvalue(), model=model, api_key=api_key)
|
|
157
|
+
except _providers.ProviderError as e:
|
|
158
|
+
print(f"error: {provider_name} provider failed: {e}", file=sys.stderr)
|
|
159
|
+
return 4
|
|
160
|
+
|
|
161
|
+
sys.stdout.write(result.text.rstrip() + "\n")
|
|
162
|
+
print(
|
|
163
|
+
f"# {provider_name} {result.model} {result.usage or ''}".rstrip(),
|
|
164
|
+
file=sys.stderr,
|
|
165
|
+
)
|
|
166
|
+
return 0
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def cmd_doctor(args: argparse.Namespace) -> int:
|
|
170
|
+
from codeforerunner import doctor
|
|
171
|
+
root = _repo_root(Path(args.repo) if args.repo else None)
|
|
172
|
+
findings = doctor.run(root)
|
|
173
|
+
sys.stdout.write(doctor.format_report(findings) + "\n")
|
|
174
|
+
return 1 if any(f.severity == "error" for f in findings) else 0
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
178
|
+
p = argparse.ArgumentParser(
|
|
179
|
+
prog="forerunner",
|
|
180
|
+
description="Prompt-first repo documentation tooling. Thin CLI; product logic in prompts/.",
|
|
181
|
+
)
|
|
182
|
+
p.add_argument("--repo", help="path to repo root (defaults to cwd ancestor with prompts/tasks/)")
|
|
183
|
+
from codeforerunner import __version__ as _version
|
|
184
|
+
p.add_argument("--version", action="version", version=f"forerunner {_version}")
|
|
185
|
+
sub = p.add_subparsers(dest="cmd", required=True, metavar="<cmd>")
|
|
186
|
+
|
|
187
|
+
s_init = sub.add_parser("init", help="resolve init-agent-onboarding prompt bundle to stdout")
|
|
188
|
+
init_scope = s_init.add_mutually_exclusive_group()
|
|
189
|
+
init_scope.add_argument(
|
|
190
|
+
"--full",
|
|
191
|
+
action="store_true",
|
|
192
|
+
help="prepend scan bundle before the onboarding bundle (scan-first per V2)",
|
|
193
|
+
)
|
|
194
|
+
init_scope.add_argument(
|
|
195
|
+
"--agents-only",
|
|
196
|
+
action="store_true",
|
|
197
|
+
help="explicit alias for the default scope (AGENTS.md update only)",
|
|
198
|
+
)
|
|
199
|
+
s_init.set_defaults(func=cmd_init)
|
|
200
|
+
|
|
201
|
+
s_scan = sub.add_parser("scan", help="resolve scan prompt bundle to stdout")
|
|
202
|
+
s_scan.set_defaults(func=cmd_scan)
|
|
203
|
+
|
|
204
|
+
s_doc = sub.add_parser("doc", help="resolve prompt bundle for <task> to stdout")
|
|
205
|
+
s_doc.add_argument("task", help="task name (basename without .md) under prompts/tasks/")
|
|
206
|
+
s_doc.set_defaults(func=cmd_doc)
|
|
207
|
+
|
|
208
|
+
s_check = sub.add_parser("check", help="run drift-detection rules against tracked docs")
|
|
209
|
+
s_check.set_defaults(func=cmd_check)
|
|
210
|
+
|
|
211
|
+
s_mcp = sub.add_parser("mcp-server", help="serve prompt bundles as MCP tools over stdio")
|
|
212
|
+
s_mcp.set_defaults(func=cmd_mcp_server)
|
|
213
|
+
|
|
214
|
+
s_doctor = sub.add_parser("doctor", help="health report: skill parity + marketplace + installed dests")
|
|
215
|
+
s_doctor.set_defaults(func=cmd_doctor)
|
|
216
|
+
|
|
217
|
+
s_gen = sub.add_parser("generate", help="resolve bundle for <task> and call the configured provider")
|
|
218
|
+
s_gen.add_argument("task", help="task basename under prompts/tasks/")
|
|
219
|
+
s_gen.add_argument("--provider", help="override config provider")
|
|
220
|
+
s_gen.add_argument("--model", help="override config model")
|
|
221
|
+
s_gen.set_defaults(func=cmd_generate)
|
|
222
|
+
|
|
223
|
+
from codeforerunner import installer
|
|
224
|
+
installer.add_subparser(sub)
|
|
225
|
+
|
|
226
|
+
return p
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
230
|
+
parser = build_parser()
|
|
231
|
+
args = parser.parse_args(argv)
|
|
232
|
+
return args.func(args)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
if __name__ == "__main__": # pragma: no cover
|
|
236
|
+
raise SystemExit(main())
|
codeforerunner/config.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""`forerunner.config.yaml` schema + loader. See SPEC.md §T25."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
CONFIG_FILENAME = "forerunner.config.yaml"
|
|
12
|
+
|
|
13
|
+
_KNOWN_PROVIDERS = {"anthropic", "openai", "google", "ollama"}
|
|
14
|
+
_KNOWN_SEVERITIES = {"HIGH", "MEDIUM", "LOW"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ConfigError(Exception):
|
|
18
|
+
"""Schema violation in forerunner.config.yaml."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class CheckConfig:
|
|
23
|
+
block_on: tuple[str, ...] = ("HIGH", "MEDIUM")
|
|
24
|
+
warn_on: tuple[str, ...] = ("LOW",)
|
|
25
|
+
enabled_rules: tuple[str, ...] | None = None # None = all rules enabled
|
|
26
|
+
ignore_paths: tuple[str, ...] = ()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class VersionAuditConfig:
|
|
31
|
+
enabled: bool = True
|
|
32
|
+
stale_after_days: int = 30
|
|
33
|
+
fetch_live_eol_data: bool = False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class ForerunnerConfig:
|
|
38
|
+
provider: str = "anthropic"
|
|
39
|
+
model: str = "claude-opus-4-7"
|
|
40
|
+
output_dir: Path = field(default_factory=lambda: Path("docs"))
|
|
41
|
+
context_max_files: int = 30
|
|
42
|
+
context_max_lines_per_file: int = 300
|
|
43
|
+
approaching_eol_threshold_months: int = 6
|
|
44
|
+
ignore_patterns: tuple[str, ...] = ()
|
|
45
|
+
api_key_env: dict[str, str] = field(default_factory=dict)
|
|
46
|
+
check: CheckConfig = field(default_factory=CheckConfig)
|
|
47
|
+
version_audit: VersionAuditConfig = field(default_factory=VersionAuditConfig)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _require_type(value: Any, expected: type, field_name: str) -> Any:
|
|
51
|
+
if not isinstance(value, expected):
|
|
52
|
+
raise ConfigError(
|
|
53
|
+
f"{field_name}: expected {expected.__name__}, got {type(value).__name__}"
|
|
54
|
+
)
|
|
55
|
+
return value
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _coerce_str_tuple(value: Any, field_name: str) -> tuple[str, ...]:
|
|
59
|
+
if value is None:
|
|
60
|
+
return ()
|
|
61
|
+
if not isinstance(value, list):
|
|
62
|
+
raise ConfigError(f"{field_name}: expected list, got {type(value).__name__}")
|
|
63
|
+
out: list[str] = []
|
|
64
|
+
for i, item in enumerate(value):
|
|
65
|
+
if not isinstance(item, str):
|
|
66
|
+
raise ConfigError(f"{field_name}[{i}]: expected string, got {type(item).__name__}")
|
|
67
|
+
out.append(item)
|
|
68
|
+
return tuple(out)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _parse_api_key_env(raw: Any) -> dict[str, str]:
|
|
72
|
+
if raw is None:
|
|
73
|
+
return {}
|
|
74
|
+
if not isinstance(raw, dict):
|
|
75
|
+
raise ConfigError(f"api_key_env: expected dict, got {type(raw).__name__}")
|
|
76
|
+
out: dict[str, str] = {}
|
|
77
|
+
for k, v in raw.items():
|
|
78
|
+
if not isinstance(k, str):
|
|
79
|
+
raise ConfigError(
|
|
80
|
+
f"api_key_env: keys must be strings, got {type(k).__name__}"
|
|
81
|
+
)
|
|
82
|
+
if k not in _KNOWN_PROVIDERS:
|
|
83
|
+
raise ConfigError(
|
|
84
|
+
f"api_key_env: unknown provider '{k}' (expected one of {sorted(_KNOWN_PROVIDERS)})"
|
|
85
|
+
)
|
|
86
|
+
if not isinstance(v, str) or not v:
|
|
87
|
+
raise ConfigError(
|
|
88
|
+
f"api_key_env[{k}]: expected non-empty string, got {type(v).__name__}"
|
|
89
|
+
)
|
|
90
|
+
out[k] = v
|
|
91
|
+
return out
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _parse_check(raw: Any) -> CheckConfig:
|
|
95
|
+
if raw is None:
|
|
96
|
+
return CheckConfig()
|
|
97
|
+
_require_type(raw, dict, "tasks.check")
|
|
98
|
+
block_on = _coerce_str_tuple(raw.get("block_on", ["HIGH", "MEDIUM"]), "tasks.check.block_on")
|
|
99
|
+
warn_on = _coerce_str_tuple(raw.get("warn_on", ["LOW"]), "tasks.check.warn_on")
|
|
100
|
+
for sev in (*block_on, *warn_on):
|
|
101
|
+
if sev not in _KNOWN_SEVERITIES:
|
|
102
|
+
raise ConfigError(
|
|
103
|
+
f"tasks.check: unknown severity '{sev}' (expected one of {sorted(_KNOWN_SEVERITIES)})"
|
|
104
|
+
)
|
|
105
|
+
enabled_rules_raw = raw.get("enabled_rules")
|
|
106
|
+
enabled_rules = (
|
|
107
|
+
_coerce_str_tuple(enabled_rules_raw, "tasks.check.enabled_rules")
|
|
108
|
+
if enabled_rules_raw is not None
|
|
109
|
+
else None
|
|
110
|
+
)
|
|
111
|
+
ignore_paths = _coerce_str_tuple(raw.get("ignore_paths", []), "tasks.check.ignore_paths")
|
|
112
|
+
return CheckConfig(
|
|
113
|
+
block_on=block_on,
|
|
114
|
+
warn_on=warn_on,
|
|
115
|
+
enabled_rules=enabled_rules,
|
|
116
|
+
ignore_paths=ignore_paths,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _parse_version_audit(raw: Any) -> VersionAuditConfig:
|
|
121
|
+
if raw is None:
|
|
122
|
+
return VersionAuditConfig()
|
|
123
|
+
_require_type(raw, dict, "tasks.version_audit")
|
|
124
|
+
return VersionAuditConfig(
|
|
125
|
+
enabled=bool(raw.get("enabled", True)),
|
|
126
|
+
stale_after_days=int(raw.get("stale_after_days", 30)),
|
|
127
|
+
fetch_live_eol_data=bool(raw.get("fetch_live_eol_data", False)),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def parse(raw: dict[str, Any] | None) -> ForerunnerConfig:
|
|
132
|
+
"""Validate a parsed YAML mapping into a ForerunnerConfig."""
|
|
133
|
+
if raw is None:
|
|
134
|
+
return ForerunnerConfig()
|
|
135
|
+
_require_type(raw, dict, "<root>")
|
|
136
|
+
|
|
137
|
+
provider = raw.get("provider", "anthropic")
|
|
138
|
+
_require_type(provider, str, "provider")
|
|
139
|
+
if provider not in _KNOWN_PROVIDERS:
|
|
140
|
+
raise ConfigError(
|
|
141
|
+
f"provider: unknown '{provider}' (expected one of {sorted(_KNOWN_PROVIDERS)})"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
tasks = raw.get("tasks") or {}
|
|
145
|
+
_require_type(tasks, dict, "tasks")
|
|
146
|
+
|
|
147
|
+
return ForerunnerConfig(
|
|
148
|
+
provider=provider,
|
|
149
|
+
model=_require_type(raw.get("model", "claude-opus-4-7"), str, "model"),
|
|
150
|
+
output_dir=Path(_require_type(raw.get("output_dir", "docs"), str, "output_dir")),
|
|
151
|
+
context_max_files=int(raw.get("context_max_files", 30)),
|
|
152
|
+
context_max_lines_per_file=int(raw.get("context_max_lines_per_file", 300)),
|
|
153
|
+
approaching_eol_threshold_months=int(raw.get("approaching_eol_threshold_months", 6)),
|
|
154
|
+
ignore_patterns=_coerce_str_tuple(raw.get("ignore_patterns", []), "ignore_patterns"),
|
|
155
|
+
api_key_env=_parse_api_key_env(raw.get("api_key_env")),
|
|
156
|
+
check=_parse_check(tasks.get("check")),
|
|
157
|
+
version_audit=_parse_version_audit(tasks.get("version_audit")),
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def load(path: Path) -> ForerunnerConfig:
|
|
162
|
+
"""Load and validate a config file. Empty file = all defaults."""
|
|
163
|
+
text = path.read_text(encoding="utf-8")
|
|
164
|
+
try:
|
|
165
|
+
raw = yaml.safe_load(text)
|
|
166
|
+
except yaml.YAMLError as e:
|
|
167
|
+
raise ConfigError(f"{path}: invalid YAML: {e}") from e
|
|
168
|
+
return parse(raw)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def load_from_repo(repo: Path) -> ForerunnerConfig | None:
|
|
172
|
+
"""Return parsed config when `forerunner.config.yaml` is present, else None."""
|
|
173
|
+
p = repo / CONFIG_FILENAME
|
|
174
|
+
if not p.is_file():
|
|
175
|
+
return None
|
|
176
|
+
return load(p)
|