spec-eval 0.2.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.
spec_eval/cli.py ADDED
@@ -0,0 +1,192 @@
1
+ """spec-eval CLI — portable spec coverage + code↔doc drift + sufficiency + spec authoring.
2
+
3
+ spec-eval coverage <repo> --config pairs.yml # free, no key
4
+ spec-eval generate <repo> [--config pairs.yml] --model … --env .env # author specs beside the code
5
+ spec-eval audit <repo> --config pairs.yml --model … --env .env # drift
6
+ spec-eval sufficiency <repo> --config pairs.yml --model … --env .env # sufficiency
7
+
8
+ Keys come from the environment (ANTHROPIC_API_KEY / OPENAI_API_KEY / GOOGLE_API_KEY); --env loads a .env file.
9
+ """
10
+ import argparse
11
+ import os
12
+ import sys
13
+ import json
14
+ from . import providers, audit, report, coverage as coverage_mod, sufficiency as sufficiency_mod, authoring, runlog
15
+
16
+ UNCOVERED_LIST_CAP = 25 # cap the per-line uncovered list printed to the terminal; full list always lands in coverage.md
17
+
18
+
19
+ def _load_keys(env, config):
20
+ providers.load_env(env)
21
+ if config:
22
+ providers.load_env(os.path.join(os.path.dirname(os.path.abspath(config)), ".env"))
23
+ providers.load_env(".env")
24
+
25
+
26
+ def _file_scope(path, cfg):
27
+ """PROJECT_DIR may be a single code FILE (audit / sufficiency / generate): narrow to the file's directory
28
+ with ONE synthesized pair — its co-located `<stem>.md`, or the folder spec under a per-dir layout — so no
29
+ pairs config is needed to scope a check to the file you just changed. Overviews are forced off."""
30
+ repo = os.path.dirname(os.path.abspath(path))
31
+ rel = os.path.basename(path)
32
+ stem = os.path.splitext(rel)[0]
33
+ doc = stem + ".md"
34
+ authoring_cfg = cfg.get("authoring", {})
35
+ if not os.path.exists(os.path.join(repo, doc)) and authoring_cfg.get("layout") == "per-dir":
36
+ doc = os.path.basename(coverage_mod.dir_spec_path(rel, os.path.basename(repo),
37
+ authoring_cfg.get("dir_spec_name", "<dir>")))
38
+ return repo, {**cfg, "pairs": [{"label": stem, "code": [rel], "docs": [doc]}],
39
+ "authoring": {**authoring_cfg, "overview": "none"}}
40
+
41
+
42
+ def main(argv=None):
43
+ ap = argparse.ArgumentParser(prog="spec-eval",
44
+ description="Portable spec coverage + drift + sufficiency + authoring.")
45
+ sub = ap.add_subparsers(dest="cmd", required=True)
46
+
47
+ def model_args(p):
48
+ p.add_argument("repo", metavar="PROJECT_DIR", help="path to the project — a repo root, a subdirectory, or a single code file")
49
+ p.add_argument("--config", "-c", default=None,
50
+ help="pairs config (YAML/JSON); optional — co-located `<file>.md` specs are auto-paired")
51
+ p.add_argument("--model", "-m", default=providers.DEFAULT_MODEL,
52
+ help="provider:model (e.g. openai:gpt-5.5, google:gemini-3.5-flash), or claude-code "
53
+ "(no API key — your Claude subscription via the claude CLI)")
54
+ p.add_argument("--out", "-o", default="spec-reports", help="output directory")
55
+ p.add_argument("--env", default=None, help="path to a .env file with API keys")
56
+ p.add_argument("--fingerprint", action=argparse.BooleanOptionalAction, default=True,
57
+ help="include the markdown unicode-bar fingerprint (default: on)")
58
+
59
+ model_args(sub.add_parser("audit", help="DRIFT: do the pairs CONTRADICT their specs? (needs a model)"))
60
+ model_args(sub.add_parser("sufficiency", help="SUFFICIENCY: how completely do the specs capture the code's "
61
+ "behavior? (needs a model)"))
62
+
63
+ g = sub.add_parser("generate", help="AUTHOR an intent-led spec BESIDE each spec-worthy code file with no spec (needs a model)")
64
+ g.add_argument("repo", metavar="PROJECT_DIR", help="path to the project — a repo root, a subdirectory, or a single code file")
65
+ g.add_argument("--config", "-c", default=None, help="optional config (YAML/JSON) for code_ext / exclude; defaults to sensible excludes")
66
+ g.add_argument("--model", "-m", default=providers.DEFAULT_MODEL,
67
+ help="provider:model, or claude-code (no API key — your Claude subscription via the claude CLI)")
68
+ g.add_argument("--out", "-o", default="spec-reports", help="output directory (run summary)")
69
+ g.add_argument("--env", default=None, help="path to a .env file with API keys")
70
+ g.add_argument("--overwrite", action="store_true", help="re-author specs that already exist")
71
+ g.add_argument("--layout", choices=["per-file", "per-dir", "per-pair"], default=None,
72
+ help="spec granularity: per-file (default) | per-dir | per-pair")
73
+ g.add_argument("--overview", choices=["none", "repo", "per-dir", "both"], default=None,
74
+ help="also write a navigation index (default: none)")
75
+ g.add_argument("--template", default=None,
76
+ help="path to a custom authoring template (replaces the built-in structure)")
77
+
78
+ c = sub.add_parser("coverage", help="COVERAGE: which code files have NO governing spec? (free; no API key)")
79
+ c.add_argument("repo", metavar="PROJECT_DIR", help="path to the project — a repo root or any subdirectory")
80
+ c.add_argument("--config", "-c", default=None,
81
+ help="pairs config (YAML/JSON); optional — co-located `<file>.md` specs count as covered")
82
+ c.add_argument("--out", "-o", default="spec-reports", help="output directory")
83
+ c.add_argument("--min", type=float, default=None, help="fail (exit 1) if coverage %% is below this")
84
+
85
+ args = ap.parse_args(argv)
86
+
87
+ if args.cmd == "audit":
88
+ _load_keys(args.env, args.config)
89
+ cfg = audit.load_config(args.config) if args.config else {}
90
+ if os.path.isfile(args.repo):
91
+ args.repo, cfg = _file_scope(args.repo, cfg)
92
+ os.makedirs(args.out, exist_ok=True)
93
+ results = audit.audit_repo(args.repo, cfg, args.model)
94
+ json.dump(results, open(os.path.join(args.out, "findings.json"), "w"), indent=2)
95
+ total = report.write_markdown(results, args.repo, args.model, os.path.join(args.out, "report.md"), args.fingerprint)
96
+ audited = sum(1 for r in results if not r.get("skipped"))
97
+ print(f"{total} high/medium drift finding(s) across {audited}/{len(results)} audited pair(s).")
98
+ truncated = sum(1 for r in results if r.get("truncated"))
99
+ if truncated:
100
+ print(f"⚠ {truncated} pair(s) graded on a partial view (input cap or reply token cap) — "
101
+ f"flagged per pair in report.md")
102
+ print(f"wrote report.md + findings.json → {os.path.abspath(args.out)}")
103
+ print(f"{providers.USAGE['calls']} model call(s), "
104
+ f"{providers.USAGE['in']:,} in + {providers.USAGE['out']:,} out tokens")
105
+ runlog.append_run(args.out, args.repo, "audit", args.model,
106
+ {"high_med_drift": total, "pairs_audited": audited, "pairs_truncated": truncated,
107
+ "per_module": {r["label"]: report.drift_load(r) for r in results if not r.get("skipped")}})
108
+
109
+ elif args.cmd == "sufficiency":
110
+ _load_keys(args.env, args.config)
111
+ cfg = audit.load_config(args.config) if args.config else {}
112
+ if os.path.isfile(args.repo):
113
+ args.repo, cfg = _file_scope(args.repo, cfg)
114
+ os.makedirs(args.out, exist_ok=True)
115
+ results = sufficiency_mod.sufficiency_repo(args.repo, cfg, args.model)
116
+ json.dump(results, open(os.path.join(args.out, "sufficiency.json"), "w"), indent=2)
117
+ avg = report.write_sufficiency_markdown(results, args.repo, args.model, os.path.join(args.out, "sufficiency.md"), args.fingerprint)
118
+ scored = sum(1 for r in results if r.get("sufficiency") is not None)
119
+ print(f"average spec sufficiency {avg:.2f} across {scored}/{len(results)} pairs (1.0 = no gaps found — an "
120
+ f"indicator, not a guarantee)")
121
+ truncated = sum(1 for r in results if r.get("truncated"))
122
+ if truncated:
123
+ print(f"⚠ {truncated} pair(s) scored on a partial view (input cap or reply token cap) — "
124
+ f"flagged per pair in sufficiency.md")
125
+ print(f"wrote sufficiency.md + sufficiency.json → {os.path.abspath(args.out)}")
126
+ print(f"{providers.USAGE['calls']} model call(s), {providers.USAGE['in']:,} in + {providers.USAGE['out']:,} out tokens")
127
+ runlog.append_run(args.out, args.repo, "sufficiency", args.model,
128
+ {"avg_sufficiency": round(avg, 2), "pairs_scored": scored, "pairs_truncated": truncated,
129
+ "per_module": {r["label"]: r["sufficiency"] for r in results if r.get("sufficiency") is not None}})
130
+
131
+ elif args.cmd == "generate":
132
+ _load_keys(args.env, args.config)
133
+ cfg = audit.load_config(args.config) if args.config else {"pairs": []}
134
+ authoring_cfg = dict(cfg.get("authoring", {})) # CLI flags override the config's authoring block
135
+ if args.layout:
136
+ authoring_cfg["layout"] = args.layout
137
+ if args.overview:
138
+ authoring_cfg["overview"] = args.overview
139
+ if args.template:
140
+ authoring_cfg["template"] = args.template
141
+ cfg = {**cfg, "authoring": authoring_cfg} # coverage() (called inside generate_repo) reads this too
142
+ if os.path.isfile(args.repo):
143
+ args.repo, cfg = _file_scope(args.repo, cfg)
144
+ cfg["authoring"]["layout"] = "per-pair" # author exactly that file's spec
145
+ os.makedirs(args.out, exist_ok=True)
146
+ print("generating specs — one model call per module; progress below (Ctrl-C is safe)…", file=sys.stderr, flush=True)
147
+ res = authoring.generate_repo(args.repo, cfg, args.model, overwrite=args.overwrite,
148
+ on_progress=lambda m: print(f" … {m}", file=sys.stderr, flush=True))
149
+ json.dump(res, open(os.path.join(args.out, "generated.json"), "w"), indent=2)
150
+ for r in res:
151
+ note = f" — {r['note']}" if r.get("note") else ""
152
+ print(f" {r['status']:10} {r['spec']}{note}")
153
+ authored = sum(1 for r in res if r["status"] == "authored")
154
+ print(f"authored {authored} spec(s) beside the code; {len(res)-authored} skipped. "
155
+ f"Review with `git diff`; drop any you don't want with `git checkout --`.")
156
+ print(f"{providers.USAGE['calls']} model call(s), {providers.USAGE['in']:,} in + {providers.USAGE['out']:,} out tokens")
157
+ runlog.append_run(args.out, args.repo, "generate", args.model,
158
+ {"authored": authored, "skipped": len(res) - authored,
159
+ "flagged": sum(1 for r in res if r.get("note"))})
160
+
161
+ elif args.cmd == "coverage":
162
+ if os.path.isfile(args.repo):
163
+ parent = os.path.dirname(args.repo) or "."
164
+ raise SystemExit(f"coverage reports on directories — try `spec-eval coverage {parent}`. "
165
+ "(audit / sufficiency / generate do accept a single file.)")
166
+ cfg = audit.load_config(args.config) if args.config else {}
167
+ os.makedirs(args.out, exist_ok=True)
168
+ cov = coverage_mod.coverage(args.repo, cfg)
169
+ json.dump(cov, open(os.path.join(args.out, "coverage.json"), "w"), indent=2)
170
+ open(os.path.join(args.out, "coverage.md"), "w").write(coverage_mod.format_report(cov, args.repo))
171
+ print(f"spec coverage: {cov['pct']:.0f}% — {len(cov['covered'])}/{cov['spec_worthy']} spec-worthy files covered")
172
+ if cov["uncovered"]:
173
+ print(f" uncovered ({len(cov['uncovered'])}) — spec-worthy files with no governing spec:")
174
+ for f in cov["uncovered"][:UNCOVERED_LIST_CAP]:
175
+ print(f" - {f}")
176
+ if len(cov["uncovered"]) > UNCOVERED_LIST_CAP:
177
+ print(f" … and {len(cov['uncovered']) - UNCOVERED_LIST_CAP} more (full list in coverage.md)")
178
+ if cov.get("orphans"):
179
+ print(f" possible orphaned spec(s) ({len(cov['orphans'])}) — spec-shaped .md with no same-stem code file; review or remove:")
180
+ for f in cov["orphans"][:UNCOVERED_LIST_CAP]:
181
+ print(f" - {f}")
182
+ print(f"wrote coverage.md + coverage.json → {os.path.abspath(args.out)} (free; no API key)")
183
+ runlog.append_run(args.out, args.repo, "coverage", None,
184
+ {"coverage_pct": cov["pct"], "covered": len(cov["covered"]),
185
+ "spec_worthy": cov["spec_worthy"], "orphans": len(cov.get("orphans", []))})
186
+ if args.min is not None and cov["pct"] < args.min:
187
+ print(f"FAIL: coverage {cov['pct']:.0f}% < --min {args.min:.0f}%")
188
+ raise SystemExit(1)
189
+
190
+
191
+ if __name__ == "__main__":
192
+ main()
spec_eval/coverage.py ADDED
@@ -0,0 +1,191 @@
1
+ """Spec coverage — which code files have NO governing spec? Pure filesystem set-arithmetic: NO API key, NO
2
+ model call, free + CI-fast. The spec analogue of test coverage.
3
+
4
+ COVERED = files matched by any pair's `code` globs in the config.
5
+ CANDIDATE = repo code files (.py) minus the EXCLUDES taxonomy (tests / config / generated / glue / skills/docs
6
+ / user `exclude:` pragmas) — the classes that are impractical to spec (each would create a second
7
+ source of truth with no independent representation to cross-check, the exact drift the tool sells
8
+ against). 'Missing spec' (coverage) is a SEPARATE finding type from 'drift' (accuracy).
9
+ UNCOVERED = spec-worthy code files with no governing pair.
10
+ """
11
+ import os
12
+ import glob
13
+ import fnmatch
14
+
15
+ # the code universe — language-agnostic by default; override per-repo via the config's `code_ext:` list.
16
+ DEFAULT_CODE_EXT = (".py", ".ts", ".tsx", ".js", ".jsx", ".go", ".rs", ".java", ".rb", ".kt", ".swift", ".php", ".cs")
17
+
18
+
19
+ def classify_exclude(rel, user_excludes):
20
+ """Return the exclusion tier for a path, or None if it is spec-worthy. Language-agnostic conventions."""
21
+ base = os.path.basename(rel)
22
+ stem = base.rsplit(".", 1)[0]
23
+ ext = os.path.splitext(base)[1]
24
+ parts = set(rel.split(os.sep))
25
+ if any(fnmatch.fnmatch(rel, p) or fnmatch.fnmatch(base, p) for p in (user_excludes or [])):
26
+ return "user"
27
+ # tests (py/ts/js/go/rust conventions): test_x, x_test, x.test.*, x.spec.*, in a tests dir
28
+ if (base.startswith("test_") or stem.endswith("_test") or ".test." in base or ".spec." in base
29
+ or {"tests", "__tests__", "test", "spec", "__mocks__"} & parts):
30
+ return "test"
31
+ # glue / trivial entrypoints across languages
32
+ if base in ("__init__.py", "__main__.py", "conftest.py", "setup.py", "index.ts", "index.js",
33
+ "index.tsx", "index.jsx", "main.go", "mod.rs", "lib.rs"):
34
+ return "glue"
35
+ if {"scripts", "tools", "tooling"} & parts:
36
+ return "tooling"
37
+ if {"__pycache__", "build", "dist", ".venv", "node_modules", "mutants", "formal"} & parts:
38
+ return "generated"
39
+ if base.endswith(".min.js") or base.endswith(".min.mjs"):
40
+ return "generated" # minified vendor bundles are build output, not source
41
+ if ext in (".yml", ".yaml", ".toml", ".cfg", ".ini", ".txt", ".lock"):
42
+ return "config"
43
+ if ext == ".md" or base == "SKILL.md":
44
+ return "skill/doc"
45
+ return None
46
+
47
+
48
+ # doc names that are never co-located specs — a `README.md` beside code is a doc, not an orphaned spec
49
+ CONVENTIONAL_DOC_STEMS = {"readme", "overview", "changelog", "changes", "contributing", "license", "notice",
50
+ "spec-health", "testing", "faq", "getting-started", "getting_started", "security",
51
+ "code_of_conduct", "skill", "todo", "authors", "maintainers", "install", "upgrading"}
52
+
53
+ # directories never worth walking into (vendored deps, build output, caches, generated test artifacts)
54
+ PRUNE_DIRS = {".git", ".venv", "venv", "env", "build", "dist", "node_modules", "__pycache__",
55
+ ".mypy_cache", ".pytest_cache", ".ruff_cache", ".tox", "htmlcov", "site-packages",
56
+ "mutants", ".idea", ".vscode", ".eggs",
57
+ "vendor", "vendored", "third_party", "thirdparty", "bower_components", "jspm_packages"}
58
+
59
+
60
+ def dir_spec_path(code_path, repo_name, dir_spec_name="<dir>"):
61
+ """Per-directory spec path for the directory containing `code_path`. `<dir>` -> the directory's own name
62
+ (`src/parser/lexer.py` -> `src/parser/parser.md`); a literal like `README` -> `src/parser/README.md`. A
63
+ repo-root file uses `repo_name`. Shared with `authoring` so `coverage` and `generate` agree on the layout."""
64
+ d = os.path.dirname(code_path)
65
+ dirname = os.path.basename(d) if d else repo_name
66
+ name = dirname if dir_spec_name == "<dir>" else dir_spec_name
67
+ return os.path.join(d, name + ".md")
68
+
69
+
70
+ def infer_pairs(repo, config):
71
+ """Co-located pairs — each spec-worthy code file + its sibling `<stem>.md`, when the `.md` exists.
72
+
73
+ Used when the config declares no explicit `pairs`: the "specs live beside the code" convention, so
74
+ `audit`/`sufficiency` need no `pairs.yml` — a `model.md` next to `model.py` is auto-paired.
75
+ """
76
+ repo = os.path.abspath(repo)
77
+ exts = tuple(config.get("code_ext") or DEFAULT_CODE_EXT)
78
+ user_excludes = config.get("exclude", [])
79
+ pairs = []
80
+ for root, dirs, files in os.walk(repo):
81
+ dirs[:] = [d for d in dirs if d not in PRUNE_DIRS]
82
+ for fn in sorted(files):
83
+ if not fn.endswith(exts):
84
+ continue
85
+ rel = os.path.relpath(os.path.join(root, fn), repo)
86
+ if classify_exclude(rel, user_excludes):
87
+ continue
88
+ md = os.path.splitext(rel)[0] + ".md"
89
+ if os.path.isfile(os.path.join(repo, md)):
90
+ pairs.append({"label": os.path.splitext(os.path.basename(rel))[0], "code": [rel], "docs": [md]})
91
+ return sorted(pairs, key=lambda p: p["label"])
92
+
93
+
94
+ def coverage(repo, config):
95
+ repo = os.path.abspath(repo)
96
+ exts = tuple(config.get("code_ext") or DEFAULT_CODE_EXT) # language-agnostic; override per repo
97
+ authoring = config.get("authoring", {}) # the authoring layout decides what "covered" means
98
+ layout = authoring.get("layout", "per-file")
99
+ dir_spec_name = authoring.get("dir_spec_name", "<dir>")
100
+ repo_name = os.path.basename(repo)
101
+ covered = set()
102
+ for p in config.get("pairs", []):
103
+ for pat in p.get("code", []):
104
+ for f in glob.glob(os.path.join(repo, pat), recursive=True):
105
+ if os.path.isfile(f):
106
+ covered.add(os.path.relpath(f, repo))
107
+
108
+ candidate = set()
109
+ mds = set()
110
+ for root, dirs, files in os.walk(repo):
111
+ dirs[:] = [d for d in dirs if d not in PRUNE_DIRS] # don't descend into vendored/generated/cache dirs
112
+ for fn in files:
113
+ if fn.endswith(exts):
114
+ candidate.add(os.path.relpath(os.path.join(root, fn), repo))
115
+ elif fn.endswith(".md"):
116
+ mds.add(os.path.relpath(os.path.join(root, fn), repo))
117
+
118
+ for rel in candidate: # a co-located `<stem>.md` counts as coverage
119
+ if os.path.isfile(os.path.join(repo, os.path.splitext(rel)[0] + ".md")):
120
+ covered.add(rel)
121
+ elif layout == "per-dir" and os.path.isfile(os.path.join(repo, dir_spec_path(rel, repo_name, dir_spec_name))):
122
+ covered.add(rel) # per-directory layout: the folder spec governs the file
123
+
124
+ user_excludes = config.get("exclude", [])
125
+ excluded = {}
126
+ for rel in candidate:
127
+ tier = classify_exclude(rel, user_excludes)
128
+ if tier:
129
+ excluded[rel] = tier
130
+ spec_worthy = candidate - set(excluded)
131
+ covered_worthy = covered & spec_worthy
132
+ uncovered = sorted(spec_worthy - covered)
133
+ pct = 100 * len(covered_worthy) / max(len(spec_worthy), 1)
134
+ by_tier = {}
135
+ for rel, t in excluded.items():
136
+ by_tier.setdefault(t, []).append(rel)
137
+
138
+ # Possible ORPHANED specs (heuristic, advisory only — never affects pct/covered/uncovered): a spec-shaped
139
+ # `.md` sitting in a directory that has code, whose same-stem code file no longer exists. Catches the
140
+ # agentic-dev churn case where code moves and its old spec is left behind. Conventional docs (README, …),
141
+ # folder specs, pair-declared docs, and docs-only directories are never flagged.
142
+ pair_docs = set()
143
+ for p in config.get("pairs", []):
144
+ for pat in p.get("docs", []):
145
+ for f in glob.glob(os.path.join(repo, pat), recursive=True):
146
+ if os.path.isfile(f):
147
+ pair_docs.add(os.path.relpath(f, repo))
148
+ code_dirs = {os.path.dirname(rel) for rel in candidate}
149
+ orphans = []
150
+ for md in mds:
151
+ d = os.path.dirname(md)
152
+ if d not in code_dirs:
153
+ continue # docs-only dir → docs, not specs
154
+ if classify_exclude(md, user_excludes) == "user":
155
+ continue # respect the repo's own exclude: scoping
156
+ stem = os.path.splitext(os.path.basename(md))[0]
157
+ if stem.lower() in CONVENTIONAL_DOC_STEMS:
158
+ continue
159
+ if stem == (os.path.basename(d) if d else repo_name):
160
+ continue # a folder spec (`<dir>/<dir>.md`), not an orphan
161
+ if dir_spec_name != "<dir>" and stem == dir_spec_name:
162
+ continue
163
+ if md in pair_docs:
164
+ continue # explicitly paired — governed, wherever its code is
165
+ base = os.path.splitext(md)[0]
166
+ if any(os.path.isfile(os.path.join(repo, base + ext)) for ext in exts):
167
+ continue # sibling code exists → a normal co-located spec
168
+ orphans.append(md)
169
+
170
+ return {"pct": round(pct, 1), "spec_worthy": len(spec_worthy),
171
+ "covered": sorted(covered_worthy), "uncovered": uncovered, "orphans": sorted(orphans),
172
+ "excluded": {t: sorted(v) for t, v in by_tier.items()}}
173
+
174
+
175
+ def format_report(cov, repo):
176
+ name = os.path.basename(os.path.abspath(repo))
177
+ lines = [f"# Spec coverage — `{name}`",
178
+ f"**{cov['pct']:.0f}%** — {len(cov['covered'])}/{cov['spec_worthy']} spec-worthy code files have a governing spec.", ""]
179
+ if cov["uncovered"]:
180
+ lines.append("## ⚠ Uncovered (spec-worthy, no governing pair)")
181
+ lines += [f"- `{f}`" for f in cov["uncovered"]]
182
+ lines.append("")
183
+ if cov.get("orphans"):
184
+ lines.append("## Possible orphaned specs *(heuristic — a spec-shaped `.md` whose same-stem code file is gone; the code may have moved. Review, then delete or re-pair.)*")
185
+ lines += [f"- `{f}`" for f in cov["orphans"]]
186
+ lines.append("")
187
+ lines.append("## Excluded (impractical to spec — by class)")
188
+ for tier, files in sorted(cov["excluded"].items()):
189
+ lines.append(f"- **{tier}** ({len(files)}): " + ", ".join(f"`{f}`" for f in files[:8]) +
190
+ (" …" if len(files) > 8 else ""))
191
+ return "\n".join(lines)
spec_eval/providers.py ADDED
@@ -0,0 +1,119 @@
1
+ """Portable model providers — anthropic / openai / google (env-keyed) plus the `claude-code` bridge (the local
2
+ Claude Code CLI: your Claude subscription, NO API key). Usage-tracked. No hardcoded paths.
3
+
4
+ Truncation transparency: every API reply carries a stop/finish reason; when it says the reply hit `max_tokens`,
5
+ that fact is recorded (`LAST["truncated"]` for the call just made, `USAGE["truncated"]` as a tally) so callers
6
+ can flag a partial view instead of passing it off as complete. The bridge exposes no stop reason, so bridge
7
+ calls never set the flag."""
8
+ import os
9
+
10
+ DEFAULT_MODEL = "anthropic:claude-opus-4-8" # THE single default model; the CLI's --model defaults read this
11
+ _clients = {}
12
+ USAGE = {"in": 0, "out": 0, "calls": 0, "truncated": 0} # exact token + call tallies; the tool reports usage, never a dollar figure
13
+ LAST = {"truncated": False} # the call JUST made (calls are sequential); read it right after gen() returns
14
+
15
+
16
+ def load_env(path):
17
+ """Minimal .env loader (KEY=VALUE), non-overriding. Safe if the file is absent."""
18
+ if not path or not os.path.exists(path):
19
+ return
20
+ for line in open(path):
21
+ line = line.strip()
22
+ if line and not line.startswith("#") and "=" in line:
23
+ k, v = line.split("=", 1)
24
+ os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))
25
+
26
+
27
+ def parse_model(spec):
28
+ """'anthropic:claude-opus-4-8' → (anthropic, claude-opus-4-8); bare names get a sensible default provider.
29
+ 'claude-code' → the Claude Code CLI bridge ('claude-code:<name>' passes <name> to the CLI's --model)."""
30
+ if spec == "claude-code":
31
+ return "claude-code", "" # bridge, using the CLI's own default model
32
+ if ":" in spec:
33
+ p, m = spec.split(":", 1)
34
+ return p, m
35
+ if spec.startswith("gpt"):
36
+ return "openai", spec
37
+ if spec.startswith("gemini"):
38
+ return "google", spec
39
+ return "anthropic", spec
40
+
41
+
42
+ def _track(u_in, u_out, truncated=False):
43
+ """Record one call's token usage and whether its reply was cut off at the token cap (None-safe)."""
44
+ USAGE["in"] += u_in or 0
45
+ USAGE["out"] += u_out or 0
46
+ USAGE["calls"] += 1
47
+ USAGE["truncated"] += 1 if truncated else 0
48
+ LAST["truncated"] = bool(truncated)
49
+
50
+
51
+ def _gen_claude_code(model, system, user):
52
+ """Bridge to the Claude Code CLI (`claude -p`): runs on the CLI's own login — a Claude subscription — so NO
53
+ API key is needed. ANTHROPIC_API_KEY is stripped from the child environment so a stray key (e.g. auto-loaded
54
+ from a .env) can never silently switch billing from the subscription to the paid API. Reads the CLI's
55
+ `--output-format json` envelope from stdout only (stderr may carry warnings); token counts are exact, from
56
+ the envelope's `usage`. `max_tokens` is not passed (the CLI has no such flag), and the envelope exposes no
57
+ stop reason — so bridge calls can never flag reply truncation."""
58
+ import json
59
+ import shutil
60
+ import subprocess
61
+ exe = shutil.which("claude")
62
+ if not exe:
63
+ raise RuntimeError("model 'claude-code' needs the Claude Code CLI on PATH — install it and log in once, "
64
+ "or use an API-key provider (anthropic: / openai: / google:)")
65
+ env = {k: v for k, v in os.environ.items() if k != "ANTHROPIC_API_KEY"}
66
+ cmd = [exe, "-p", "--output-format", "json", "--system-prompt", system]
67
+ if model:
68
+ cmd += ["--model", model]
69
+ r = subprocess.run(cmd, input=user, capture_output=True, text=True, env=env, timeout=600)
70
+ if r.returncode != 0:
71
+ raise RuntimeError(f"`claude -p` failed (exit {r.returncode}): {(r.stderr or r.stdout).strip()[:400]}")
72
+ d = json.loads(r.stdout) # {"result": text, "usage": {...}, "is_error": bool, ...}
73
+ if d.get("is_error") or "result" not in d:
74
+ raise RuntimeError(f"`claude -p` returned an error envelope: {r.stdout[:400]}")
75
+ u = d.get("usage") or {}
76
+ u_in = ((u.get("input_tokens") or 0) + (u.get("cache_creation_input_tokens") or 0)
77
+ + (u.get("cache_read_input_tokens") or 0)) # the CLI reports cached input separately; sum = tokens actually processed
78
+ _track(u_in, u.get("output_tokens"))
79
+ return d.get("result") or ""
80
+
81
+
82
+ def gen(model_spec, system, user, max_tokens=1200):
83
+ prov, model = parse_model(model_spec)
84
+ if prov == "claude-code":
85
+ return _gen_claude_code(model, system, user)
86
+ if prov == "anthropic":
87
+ if "anthropic" not in _clients:
88
+ import anthropic
89
+ _clients["anthropic"] = anthropic.Anthropic() # reads ANTHROPIC_API_KEY
90
+ r = _clients["anthropic"].messages.create(
91
+ model=model, max_tokens=max_tokens, system=system,
92
+ messages=[{"role": "user", "content": user}])
93
+ _track(r.usage.input_tokens, r.usage.output_tokens,
94
+ truncated=(getattr(r, "stop_reason", None) == "max_tokens"))
95
+ return "".join(b.text for b in r.content if getattr(b, "type", None) == "text")
96
+ if prov == "openai":
97
+ if "openai" not in _clients:
98
+ from openai import OpenAI
99
+ _clients["openai"] = OpenAI() # reads OPENAI_API_KEY
100
+ r = _clients["openai"].chat.completions.create(
101
+ model=model, messages=[{"role": "system", "content": system}, {"role": "user", "content": user}])
102
+ _track(r.usage.prompt_tokens, r.usage.completion_tokens,
103
+ truncated=(getattr(r.choices[0], "finish_reason", None) == "length" if r.choices else False))
104
+ return r.choices[0].message.content or ""
105
+ if prov == "google":
106
+ from google import genai
107
+ from google.genai import types
108
+ if "google" not in _clients:
109
+ _clients["google"] = genai.Client() # reads GOOGLE_API_KEY / GEMINI_API_KEY
110
+ r = _clients["google"].models.generate_content(
111
+ model=model, contents=user,
112
+ config=types.GenerateContentConfig(system_instruction=system, max_output_tokens=max_tokens))
113
+ um = getattr(r, "usage_metadata", None)
114
+ cands = getattr(r, "candidates", None) or []
115
+ fr = getattr(cands[0], "finish_reason", None) if cands else None
116
+ _track(getattr(um, "prompt_token_count", 0), getattr(um, "candidates_token_count", 0),
117
+ truncated="MAX_TOKENS" in (getattr(fr, "name", None) or str(fr or "")))
118
+ return r.text or ""
119
+ raise ValueError(f"unknown provider '{prov}' (use anthropic: / openai: / google: / claude-code)")
spec_eval/report.py ADDED
@@ -0,0 +1,102 @@
1
+ """Render the drift + sufficiency reports (markdown) — the legible product output.
2
+
3
+ The "fingerprint" is a MARKDOWN unicode-bar table (diffable, code-searchable, renders in any markdown viewer),
4
+ toggled by `include_fingerprint`.
5
+ """
6
+ import os
7
+ from . import providers
8
+
9
+
10
+ def drift_load(r):
11
+ return sum(1 for f in r["findings"] if f["severity"] in ("high", "medium"))
12
+
13
+
14
+ def _bar(v, width=20):
15
+ """Unicode bar for a 0..1 value (full block = filled, light shade = empty)."""
16
+ v = max(0.0, min(1.0, float(v)))
17
+ filled = int(round(v * width))
18
+ return "█" * filled + "░" * (width - filled)
19
+
20
+
21
+ def sufficiency_fingerprint(results):
22
+ """A markdown unicode-bar table of per-pair sufficiency (worst first). Pure text."""
23
+ rs = sorted((r for r in results if r.get("sufficiency") is not None), key=lambda r: r["sufficiency"])
24
+ if not rs:
25
+ return ""
26
+ lines = ["", "## Sufficiency fingerprint *(at a glance — worst first)*", "",
27
+ "| Pair | Spec completeness | Score |", "|---|---|---|"]
28
+ for r in rs:
29
+ lines.append(f"| `{r['label']}` | `{_bar(r['sufficiency'])}` | {r['sufficiency']:.2f} |")
30
+ return "\n".join(lines) + "\n"
31
+
32
+
33
+ def drift_fingerprint(results):
34
+ """A markdown table of per-pair drift load (✓ clean / count). Pure text."""
35
+ rs = [r for r in results if not r.get("skipped")]
36
+ if not rs:
37
+ return ""
38
+ lines = ["", "### Drift fingerprint", "", "| Pair | High+med findings |", "|---|---|"]
39
+ for r in rs:
40
+ n = drift_load(r)
41
+ lines.append(f"| `{r['label']}` | {'✓ clean' if n == 0 else f'⚠ {n}'} |")
42
+ return "\n".join(lines) + "\n"
43
+
44
+
45
+ def write_markdown(results, repo, model, out_path, include_fingerprint=True):
46
+ name = os.path.basename(os.path.abspath(repo))
47
+ total = sum(drift_load(r) for r in results if not r.get("skipped"))
48
+ audited = [r for r in results if not r.get("skipped")]
49
+ lines = [f"# Drift report — `{name}`",
50
+ f"detector: `{model}` · {len(audited)}/{len(results)} pairs audited · "
51
+ f"{providers.USAGE['calls']} model call(s)", "",
52
+ f"**{total} high/medium drift finding(s) across {len(audited)} audited pair(s).**", ""]
53
+ for r in results:
54
+ if r.get("skipped"):
55
+ lines += [f"## {r['label']} — _skipped: {r['skipped']}_", ""]
56
+ continue
57
+ n = drift_load(r)
58
+ lines.append(f"## {r['label']} — {'✓ clean' if n == 0 else f'⚠ {n} drift'}")
59
+ if r.get("truncated"):
60
+ lines.append(f"- ⚠ *partial view ({'; '.join(r['truncated'])}) — findings may be incomplete*")
61
+ for f in r["findings"]:
62
+ ref = f" (`{f.get('code_ref') or '?'}` vs `{f.get('doc_ref') or '?'}`)" if f.get("code_ref") or f.get("doc_ref") else ""
63
+ lines.append(f"- **[{f['severity']}]** {f['summary']}{ref}")
64
+ if f.get("suggestion"):
65
+ lines.append(f" - *fix:* {f['suggestion']}")
66
+ lines.append("")
67
+ body = "\n".join(lines)
68
+ if include_fingerprint:
69
+ body += drift_fingerprint(results)
70
+ open(out_path, "w").write(body)
71
+ return total
72
+
73
+
74
+ def write_sufficiency_markdown(results, repo, model, out_path, include_fingerprint=True):
75
+ name = os.path.basename(os.path.abspath(repo))
76
+ scored = [r for r in results if r.get("sufficiency") is not None]
77
+ avg = sum(r["sufficiency"] for r in scored) / len(scored) if scored else 0
78
+ head = [f"# Spec sufficiency — `{name}`",
79
+ f"detector: `{model}` · {len(scored)}/{len(results)} pairs scored · "
80
+ f"{providers.USAGE['calls']} model call(s)", "",
81
+ f"**Average sufficiency {avg:.2f}** — how completely does the spec capture the code's behavior? "
82
+ f"(1.0 = no gaps found; gaps = behavior in the code but not the spec. An indicator, not a guarantee.)", ""]
83
+ # Fingerprint FIRST — the at-a-glance summary, so a reader sees the shape before the per-module detail.
84
+ summary = sufficiency_fingerprint(results) if include_fingerprint else ""
85
+ detail = ["## Per-module gaps *(worst first)*", ""]
86
+ for r in sorted(results, key=lambda x: (x.get("sufficiency") is None, x.get("sufficiency") or 0)):
87
+ if r.get("skipped"):
88
+ detail += [f"### {r['label']} — _skipped: {r['skipped']}_", ""]
89
+ continue
90
+ if r.get("sufficiency") is None:
91
+ detail.append(f"### {r['label']} — not scored (unparseable model reply)")
92
+ else:
93
+ detail.append(f"### {r['label']} — sufficiency {r['sufficiency']:.2f}")
94
+ if r.get("truncated"):
95
+ detail.append(f"- ⚠ *partial view ({'; '.join(r['truncated'])})*")
96
+ for g in r["gaps"]:
97
+ ref = f" · `{g['code_ref']}`" if g.get("code_ref") else "" # '·' — em dashes occur in gap prose; the dot splits unambiguously
98
+ detail.append(f"- **[{g['severity']}]** {g['missing']}{ref}")
99
+ detail.append("")
100
+ body = "\n".join(head) + summary + "\n" + "\n".join(detail)
101
+ open(out_path, "w").write(body)
102
+ return avg
spec_eval/rubric.py ADDED
@@ -0,0 +1,39 @@
1
+ """The bundled code↔doc drift rubric (self-contained — no external file dependency).
2
+
3
+ Captures conservative, prefer-false-negatives drift-detection principles, bundled with the product so it stays
4
+ portable. Severity is reserved;
5
+ silence/scope are not drift. Tune with evidence — loosening it trades trust for noise.
6
+
7
+ KEEP IN SYNC: `skills/spec-check/SKILL.md` carries the agent-session copy of this rubric (same severity tiers,
8
+ same do-not-flag list). If you change the principles here, change them there — `tests/contract/test_rubric_sync.py`
9
+ asserts the load-bearing phrases match, and this module's own spec is `rubric.md` beside this file (guarded by
10
+ spec-eval's self-audit).
11
+ """
12
+
13
+ DRIFT_RUBRIC = """You are a careful technical reviewer auditing a codebase for DRIFT between implementation and
14
+ documentation: places where the code does X but the docs/spec claim Y (or vice versa).
15
+
16
+ You are shown ONE pair: source file(s) plus the doc(s)/spec(s) meant to describe them. Find real mismatches.
17
+
18
+ Severity is RESERVED — be strict; the tier is set by the DOC's claim:
19
+ - high: the doc states a MEASURABLE GUARANTEE the code breaks — a numeric value/threshold/default that
20
+ disagrees with code; an explicit function/class/method signature; a named event/message the code never
21
+ emits; a CLI flag default; an invariant/acceptance criterion violated. If you must paraphrase the doc to
22
+ see the violation, it is NOT high.
23
+ - medium: misleading but not load-bearing — a renamed function that still does the same thing; a stale
24
+ example; a field present in code but missing from a spec table; mechanism described differently.
25
+ - low: cosmetic or trivially-fixable wording.
26
+
27
+ Do NOT flag: stylistic differences; trivial restatements; missing-but-implied behaviour where a doc could
28
+ plausibly be silent (silence is not drift); a doc describing a BROADER system of which this file is only one
29
+ part (scope is not drift); comments inside code that disagree with each other (only code-vs-doc); drift you
30
+ can only verify by RUNNING the code.
31
+
32
+ Be conservative: prefer false negatives over false positives. An empty findings list is a perfectly valid
33
+ answer.
34
+
35
+ Output strict JSON, no preamble:
36
+ {"findings":[{"severity":"high|medium|low","code_ref":"file:Lxx or null","doc_ref":"file:Lxx or null",
37
+ "summary":"one sentence","evidence":"quote the conflicting code and doc snippets","suggestion":"the fix"}]}
38
+ If there is no drift, return {"findings": []}.
39
+ """