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/__init__.py +2 -0
- spec_eval/__main__.py +4 -0
- spec_eval/audit.py +117 -0
- spec_eval/authoring.py +340 -0
- spec_eval/cli.py +192 -0
- spec_eval/coverage.py +191 -0
- spec_eval/providers.py +119 -0
- spec_eval/report.py +102 -0
- spec_eval/rubric.py +39 -0
- spec_eval/runlog.py +30 -0
- spec_eval/sufficiency.py +67 -0
- spec_eval-0.2.0.dist-info/METADATA +293 -0
- spec_eval-0.2.0.dist-info/RECORD +17 -0
- spec_eval-0.2.0.dist-info/WHEEL +5 -0
- spec_eval-0.2.0.dist-info/entry_points.txt +2 -0
- spec_eval-0.2.0.dist-info/licenses/LICENSE +21 -0
- spec_eval-0.2.0.dist-info/top_level.txt +1 -0
spec_eval/__init__.py
ADDED
spec_eval/__main__.py
ADDED
spec_eval/audit.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Audit a repo's configured code↔doc pairs for drift. Filesystem-based, portable (repo path is an argument)."""
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
import json
|
|
5
|
+
import glob
|
|
6
|
+
from .rubric import DRIFT_RUBRIC
|
|
7
|
+
from . import providers
|
|
8
|
+
|
|
9
|
+
SEV = {"high": 3, "medium": 2, "low": 1}
|
|
10
|
+
REVIEW_MAX_TOKENS = 3000 # output-token budget for the two review checks (drift + sufficiency): each emits a
|
|
11
|
+
# JSON list (findings / gaps) that is unparseable if cut mid-list — sufficiency reads
|
|
12
|
+
# THIS constant so the "same budget" coupling is structural, not a comment.
|
|
13
|
+
CODE_CAP, DOC_CAP = 64000, 28000 # char caps per side (bound cost; directional). CODE_CAP=64k covers p99 of a
|
|
14
|
+
# broad real-world corpus (~20k tokens, below the ~50k context-rot onset);
|
|
15
|
+
# DOC_CAP=28k is p95 of real design docs. Larger inputs get a partial-view flag.
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def load_config(path):
|
|
19
|
+
"""A config is YAML or JSON: {pairs: [{label, code: [globs], docs: [globs]}]}."""
|
|
20
|
+
text = open(path).read()
|
|
21
|
+
if path.endswith((".yml", ".yaml")):
|
|
22
|
+
import yaml
|
|
23
|
+
return yaml.safe_load(text)
|
|
24
|
+
return json.loads(text)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _read_globs(repo, patterns, cap):
|
|
28
|
+
"""Concatenate the files matching `patterns` (relative headers included), capped at `cap` chars.
|
|
29
|
+
Returns (text, file_count, capped) — `capped` is True when the cap cut material, so callers can surface
|
|
30
|
+
the partial view to the USER (the `[truncated]` marker below only tells the MODEL)."""
|
|
31
|
+
chunks = []
|
|
32
|
+
for pat in (patterns or []):
|
|
33
|
+
for f in sorted(glob.glob(os.path.join(repo, pat), recursive=True)):
|
|
34
|
+
if os.path.isfile(f):
|
|
35
|
+
try:
|
|
36
|
+
c = open(f, errors="ignore").read()
|
|
37
|
+
except OSError:
|
|
38
|
+
continue
|
|
39
|
+
if c.strip():
|
|
40
|
+
chunks.append(f"### {os.path.relpath(f, repo)}\n{c}")
|
|
41
|
+
text = "\n\n".join(chunks)
|
|
42
|
+
capped = len(text) > cap
|
|
43
|
+
return (text[:cap] + ("\n...[truncated]" if capped else "")), len(chunks), capped
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def first_json_object(resp, *keys):
|
|
47
|
+
"""The first PARSEABLE JSON object in `resp` carrying any of `keys` — immune to brace-y prose around the
|
|
48
|
+
JSON (a greedy `{.*}` regex spans from the first prose brace and never parses). strict=False tolerates
|
|
49
|
+
literal newlines/tabs inside quoted strings (models quote multi-line code in `evidence`)."""
|
|
50
|
+
dec = json.JSONDecoder(strict=False)
|
|
51
|
+
for m in re.finditer(r"\{", resp):
|
|
52
|
+
try:
|
|
53
|
+
obj, _ = dec.raw_decode(resp, m.start())
|
|
54
|
+
except ValueError:
|
|
55
|
+
continue
|
|
56
|
+
if isinstance(obj, dict) and any(k in obj for k in keys):
|
|
57
|
+
return obj
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def parse_findings(resp):
|
|
62
|
+
out = []
|
|
63
|
+
d = first_json_object(resp, "findings")
|
|
64
|
+
if d is not None:
|
|
65
|
+
try:
|
|
66
|
+
for f in d.get("findings", []):
|
|
67
|
+
s = str(f.get("severity", "")).lower()
|
|
68
|
+
if s in SEV:
|
|
69
|
+
out.append({"severity": s, "summary": str(f.get("summary", "")),
|
|
70
|
+
"code_ref": f.get("code_ref"), "doc_ref": f.get("doc_ref"),
|
|
71
|
+
"suggestion": f.get("suggestion", "")})
|
|
72
|
+
return out
|
|
73
|
+
except Exception:
|
|
74
|
+
pass
|
|
75
|
+
for s in re.findall(r'"severity"\s*:\s*"(high|medium|low)"', resp, re.I):
|
|
76
|
+
out.append({"severity": s.lower(),
|
|
77
|
+
"summary": "(unparsed finding — the response was not valid JSON; re-run this pair to get detail)"})
|
|
78
|
+
return out
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def caps_from(config):
|
|
82
|
+
"""The per-side input caps in chars, config-overridable: `caps: {code, docs}`. The defaults are calibrated
|
|
83
|
+
to real-world percentiles; raising them trades cost for coverage of larger files (e.g. `code: 100000` for
|
|
84
|
+
C/C++-sized modules), lowering them tightens the cost ceiling."""
|
|
85
|
+
c = (config or {}).get("caps") or {}
|
|
86
|
+
return int(c.get("code", CODE_CAP)), int(c.get("docs", DOC_CAP))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def truncation_notes(code_capped, doc_capped, code_cap=CODE_CAP, doc_cap=DOC_CAP):
|
|
90
|
+
"""The pair-level partial-view notes shared by audit and sufficiency: the two input caps, plus whether the
|
|
91
|
+
model's REPLY was cut off at the token cap (read from the call just made)."""
|
|
92
|
+
notes = ([f"code input capped at ~{code_cap:,} chars"] if code_capped else []) \
|
|
93
|
+
+ ([f"docs input capped at ~{doc_cap:,} chars"] if doc_capped else [])
|
|
94
|
+
if providers.LAST["truncated"]:
|
|
95
|
+
notes.append("reply hit the token cap")
|
|
96
|
+
return notes
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def audit_pair(repo, pair, model, code_cap=CODE_CAP, doc_cap=DOC_CAP):
|
|
100
|
+
code, nc, code_capped = _read_globs(repo, pair.get("code", []), code_cap)
|
|
101
|
+
doc, nd, doc_capped = _read_globs(repo, pair.get("docs", []), doc_cap)
|
|
102
|
+
if nc == 0 or nd == 0:
|
|
103
|
+
return {"label": pair["label"], "skipped": f"no files matched (code={nc}, docs={nd})", "findings": []}
|
|
104
|
+
user = f"# Drift review: {pair['label']}\n\n## Code\n```\n{code}\n```\n\n## Docs / spec\n{doc}\n"
|
|
105
|
+
findings = parse_findings(providers.gen(model, DRIFT_RUBRIC, user, max_tokens=REVIEW_MAX_TOKENS)) # headroom: a truncated findings list is unparseable
|
|
106
|
+
rec = {"label": pair["label"], "code_files": nc, "doc_files": nd, "findings": findings}
|
|
107
|
+
notes = truncation_notes(code_capped, doc_capped, code_cap, doc_cap)
|
|
108
|
+
if notes:
|
|
109
|
+
rec["truncated"] = notes
|
|
110
|
+
return rec
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def audit_repo(repo, config, model):
|
|
114
|
+
from . import coverage as coverage_mod
|
|
115
|
+
pairs = config.get("pairs") or coverage_mod.infer_pairs(repo, config) # co-located specs need no pairs.yml
|
|
116
|
+
code_cap, doc_cap = caps_from(config)
|
|
117
|
+
return [audit_pair(repo, p, model, code_cap, doc_cap) for p in pairs]
|
spec_eval/authoring.py
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"""Spec AUTHORING — generate intent-led specs from code.
|
|
2
|
+
|
|
3
|
+
The generator that pairs with the drift/sufficiency checkers: point it at a repo and, for each spec-worthy code
|
|
4
|
+
file with no governing spec yet, author a spec. The layout is chosen by config (`authoring.layout`):
|
|
5
|
+
|
|
6
|
+
per-file (default) — one spec BESIDE each code file (`src/x.py` -> `src/x.md`).
|
|
7
|
+
per-dir — one spec per directory (`src/parser/*.py` -> `src/parser/parser.md`), synthesised from the
|
|
8
|
+
per-module intents (map -> reduce) so a big folder never blows the char cap.
|
|
9
|
+
per-pair — author the `docs` file of each explicit config pair from its `code` glob.
|
|
10
|
+
|
|
11
|
+
An optional `authoring.overview` (none | repo | per-dir | both) adds a navigation index (a repo-level
|
|
12
|
+
`OVERVIEW.md` and/or a per-directory `README.md`) that links down to the specs the layout produced. The built-in
|
|
13
|
+
per-module rubric can be swapped with `authoring.template`; the built-in authoring DISCIPLINE is always appended
|
|
14
|
+
so a swapped template still yields specs the drift/sufficiency checkers can grade.
|
|
15
|
+
|
|
16
|
+
Nothing is ever clobbered: a target that already has a file is skipped (no model call) unless `overwrite`.
|
|
17
|
+
Authored specs are ordinary new files in the working tree — review them like code (`git diff`) and drop any you
|
|
18
|
+
don't want (`git checkout --`). See skills/spec-authoring/SKILL.md.
|
|
19
|
+
"""
|
|
20
|
+
import os
|
|
21
|
+
import glob
|
|
22
|
+
from . import providers, audit, coverage as coverage_mod
|
|
23
|
+
|
|
24
|
+
# The built-in rubric is split so a custom template can replace the STRUCTURE while the authoring DISCIPLINE
|
|
25
|
+
# (the quality rules the checkers rely on) is always applied.
|
|
26
|
+
# KEEP IN SYNC: `skills/spec-authoring/SKILL.md` carries the agent-session copy of this rubric, and the shipped
|
|
27
|
+
# templates (`skills/spec-authoring/templates/spec-template.md`, `configs/spec-template.example.md`) carry its
|
|
28
|
+
# skeleton markers — `tests/contract/test_rubric_sync.py` pins the shared load-bearing phrases so a change to
|
|
29
|
+
# one copy without the others fails loudly. (`templates/OVERVIEW-template.md` is the skill's richer
|
|
30
|
+
# project-overview and intentionally NOT a copy of OVERVIEW_RUBRIC below.)
|
|
31
|
+
AUTHORING_STRUCTURE = (
|
|
32
|
+
"You author an INTENT-LED specification (markdown) for ONE code module. A reviewer must be able to grasp the "
|
|
33
|
+
"full intent and contract from the spec ALONE. Follow this structure and these rules exactly:\n"
|
|
34
|
+
"- '## 1. Purpose' — open with a bold one-liner: '**In one line:** <the capability in <=20 words>'. Then 1-2 "
|
|
35
|
+
"sentences of WHAT + WHY, before any type or signature, and the governing constraint stated DIRECTLY as a "
|
|
36
|
+
"checkable consequence (e.g. 'decode(encode(x)) == x for all UTF-8 input') — never as meta-phrasing like "
|
|
37
|
+
"'the one governing constraint a reviewer can check'.\n"
|
|
38
|
+
"- '## 2. Definitions' — a short table of the domain vocabulary (term -> meaning, with bounds/units).\n"
|
|
39
|
+
"- '## 3. Behavior' — EXPLANATION ONLY: the rules / modes / flows over that vocabulary; NOT a per-method "
|
|
40
|
+
"walkthrough. Do NOT inline exhaustive reference material (full option/enum sets, every directory or "
|
|
41
|
+
"extension name, complete rule tables) mid-rule — put it in a §4 table or a §2 term and reference it by "
|
|
42
|
+
"name. When behavior enumerates 3+ cases / conditions / branches, write a bulleted list or a table — never "
|
|
43
|
+
"a comma/semicolon chain. Attach a one-line '**Why:**' ONLY where the rationale is non-obvious or "
|
|
44
|
+
"load-bearing — an obvious Why is padding (confidence-tagged '> Reconstructed intent' rationale always "
|
|
45
|
+
"stays). Load-bearing BEHAVIORS (e.g. generation/sampling policy, weight-loading, "
|
|
46
|
+
"optimizer/decay policy, defaults, error semantics) ARE behavior — specify them; do NOT drop them as 'detail'.\n"
|
|
47
|
+
"- '## 4. Contracts' (REFERENCE, demoted to the end) — open the section with the italic line "
|
|
48
|
+
"'*Reference — consult when implementing or reviewing a change; skip on a first read for intent.*': "
|
|
49
|
+
"SEMANTIC shapes (e.g. `(B, T, vocab)`) with meaning and "
|
|
50
|
+
"bounds — NOT language types; a table headed '### Invariants (*rules that must always hold*)' with IDs "
|
|
51
|
+
"INV-1.. — assert an INV ONLY if the code ENFORCES it (an assert / clamp / validation / raised error); do NOT "
|
|
52
|
+
"state a plausible-but-unenforced range or property, that is drift not an invariant. A table headed "
|
|
53
|
+
"'### Acceptance criteria (*Given / When / Then*)' with IDs AC-1.. and concrete numbers.\n"
|
|
54
|
+
)
|
|
55
|
+
AUTHORING_DISCIPLINE = (
|
|
56
|
+
"RULES: Organize headings by CAPABILITY, never by the function/symbol tree. NEVER make a function signature or "
|
|
57
|
+
"a language type a heading. DROP code trivia (empty-input errors, slicing tricks, a bare except, 'no caching "
|
|
58
|
+
"in that branch'). DESCRIBE what a capability IS and does — never define it by what it ISN'T ('not a X', "
|
|
59
|
+
"'unlike Y'); state it on its own terms. SENTENCE DISCIPLINE: one idea per sentence; split any sentence past "
|
|
60
|
+
"~30 words; at most "
|
|
61
|
+
"one dash-aside or parenthetical per sentence; no nested parentheses. RIGHT-SIZE to the module: collapse to "
|
|
62
|
+
"one sentence — or omit — any section that would carry <=1 real row; empty scaffolding and N/A rows are "
|
|
63
|
+
"noise, not rigor (a small utility module gets a short spec). Every table row must be FILLED — never emit an "
|
|
64
|
+
"empty INV-*/AC- row; omit an id rather than "
|
|
65
|
+
"leave it blank. LABEL any inferred rationale not determinable from the code alone: "
|
|
66
|
+
"'> Reconstructed intent (confidence: low/med/high) — inferred from the code.'\n"
|
|
67
|
+
"Output ONLY the finished markdown document — no preamble, no title-line meta-note about sources of truth, "
|
|
68
|
+
"and do NOT wrap the whole document in a code fence."
|
|
69
|
+
)
|
|
70
|
+
AUTHORING_RUBRIC = AUTHORING_STRUCTURE + AUTHORING_DISCIPLINE # built-in default (per-module authoring)
|
|
71
|
+
|
|
72
|
+
# Synthesis (reduce) rubrics: fed the per-module INTENT SPECS, never the raw code, so a whole directory's specs
|
|
73
|
+
# fit under the cap where the raw code would not.
|
|
74
|
+
FOLDER_SPEC_RUBRIC = (
|
|
75
|
+
"You author ONE intent-led specification for a whole DIRECTORY, given the intent specs of the modules it "
|
|
76
|
+
"contains. The reader has ONLY this file (the per-module specs may not exist), so make it SELF-CONTAINED:\n"
|
|
77
|
+
"- '## 1. Purpose' — what capability this directory provides as a unit, and why.\n"
|
|
78
|
+
"- '## 2. Modules' — a table: module -> its one-line responsibility (summarise inline).\n"
|
|
79
|
+
"- '## 3. How it fits together' — the data / control flow across these modules, in prose.\n"
|
|
80
|
+
"- '## 4. Shared contract' — invariants and definitions that span modules (cross-module only; do not restate "
|
|
81
|
+
"every per-module detail).\n"
|
|
82
|
+
+ AUTHORING_DISCIPLINE
|
|
83
|
+
)
|
|
84
|
+
OVERVIEW_RUBRIC = (
|
|
85
|
+
"You author a navigation OVERVIEW for a set of modules, given each module's intent spec and its spec path. "
|
|
86
|
+
"This is an INDEX a reader orients from before opening any module — not a restatement:\n"
|
|
87
|
+
"- '## Map' — a table linking each module's spec (by its given path) to its one-line purpose. REUSE that "
|
|
88
|
+
"spec's '**In one line:**' sentence VERBATIM as the purpose — it is the canonical one-liner; copy it, do "
|
|
89
|
+
"not paraphrase (a paraphrase is a second version that drifts). If a spec has no such line, write one short "
|
|
90
|
+
"line.\n"
|
|
91
|
+
"- '## How it fits together' — the flow across these modules, in prose.\n"
|
|
92
|
+
"- '## Shared contract' — only invariants / definitions that span modules; DEFER all module detail to the "
|
|
93
|
+
"linked specs, never restate them.\n"
|
|
94
|
+
+ AUTHORING_DISCIPLINE
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
REDUCE_CAP = 48000 # char budget for the per-module intents concatenated into one synthesis (reduce) call;
|
|
98
|
+
# ~8 real intents (~6k chars each) = p85-p90 directory fan-out in a single pass, with the
|
|
99
|
+
# recursion below absorbing larger folders at depth ~2 (widen fan-in, don't deepen the tree)
|
|
100
|
+
_MAX_LEVELS = 4 # recursion bound for multi-pass synthesis; past it, remaining items are force-fitted
|
|
101
|
+
AUTHOR_MAX_TOKENS = 5000 # output-token budget for authoring a spec (map or synthesis) — one source for all four call sites
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _rubric(template_path):
|
|
105
|
+
"""The per-module authoring instruction: the built-in structure, or a custom template file. The built-in
|
|
106
|
+
DISCIPLINE is always appended so a swapped template still yields specs the checkers can grade."""
|
|
107
|
+
if template_path:
|
|
108
|
+
return open(template_path).read().strip() + "\n\n" + AUTHORING_DISCIPLINE
|
|
109
|
+
return AUTHORING_RUBRIC
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _unfence(md):
|
|
113
|
+
"""Strip an accidental outer ``` fence so the written .md is raw markdown."""
|
|
114
|
+
md = md.strip()
|
|
115
|
+
if md.startswith("```"):
|
|
116
|
+
md = md.split("\n", 1)[1] if "\n" in md else md
|
|
117
|
+
if md.rstrip().endswith("```"):
|
|
118
|
+
md = md.rstrip()[:-3].rstrip()
|
|
119
|
+
return md
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def spec_path_for(code_path):
|
|
123
|
+
"""Co-located per-file spec path: the .md beside the code file (`src/x.py` -> `src/x.md`)."""
|
|
124
|
+
base, _ = os.path.splitext(code_path)
|
|
125
|
+
return base + ".md"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def author_file(repo, code_path, model, rubric=AUTHORING_RUBRIC, code_cap=None):
|
|
129
|
+
"""Author (map) an intent-led spec markdown for a single code file (path relative to `repo`).
|
|
130
|
+
Returns (markdown, note|None) — the note flags a partial view: code input over the cap, or a model reply
|
|
131
|
+
cut off at the token cap (either can silently produce a spec that ends mid-section)."""
|
|
132
|
+
code_cap = audit.CODE_CAP if code_cap is None else code_cap
|
|
133
|
+
raw = open(os.path.join(repo, code_path), errors="ignore").read()
|
|
134
|
+
code = raw[:code_cap]
|
|
135
|
+
stem = os.path.splitext(os.path.basename(code_path))[0]
|
|
136
|
+
user = f"# Author a spec for module `{stem}`\n\n## Code (`{code_path}`)\n```\n{code}\n```\n"
|
|
137
|
+
md = _unfence(providers.gen(model, rubric, user, max_tokens=AUTHOR_MAX_TOKENS))
|
|
138
|
+
notes = ([f"code input capped at ~{code_cap:,} chars — authored from a partial view"]
|
|
139
|
+
if len(raw) > code_cap else []) \
|
|
140
|
+
+ (["reply hit the token cap — the spec may end mid-section"] if providers.LAST["truncated"] else [])
|
|
141
|
+
return md, ("; ".join(notes) or None)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _pack(items, cap):
|
|
145
|
+
"""Group (label, intent-md) items into consecutive groups whose rendered blocks each fit under `cap`.
|
|
146
|
+
A single block larger than `cap` is sliced to fit (with a marker) so packing always terminates."""
|
|
147
|
+
groups, cur, used = [], [], 0
|
|
148
|
+
for label, md in items:
|
|
149
|
+
block = f"### {label}\n{(md or '').strip()}\n"
|
|
150
|
+
if len(block) > cap:
|
|
151
|
+
block = block[:cap] + "\n...[truncated]" # a lone oversized intent: slice, never loop
|
|
152
|
+
if cur and used + len(block) > cap:
|
|
153
|
+
groups.append(cur)
|
|
154
|
+
cur, used = [], 0
|
|
155
|
+
cur.append((label, block))
|
|
156
|
+
used += len(block)
|
|
157
|
+
if cur:
|
|
158
|
+
groups.append(cur)
|
|
159
|
+
return groups
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _synthesize(model, rubric, items, header, on_progress=None, _level=1, reduce_cap=None):
|
|
163
|
+
"""Reduce: synthesise (label, intent-markdown) modules into one document. Returns
|
|
164
|
+
(markdown, levels, reply_capped). When the concatenated intents exceed the reduce cap, the items are packed
|
|
165
|
+
into sub-groups, each sub-group is synthesised into an intermediate intent, and the intermediates are
|
|
166
|
+
reduced in turn (recursively) — modules are NEVER dropped. `levels` counts the stacked synthesis passes
|
|
167
|
+
(1 = a single call); `reply_capped` is True when ANY pass's reply hit the token cap.
|
|
168
|
+
Termination is guaranteed: past _MAX_LEVELS, or when a pass stops reducing the item count, the remaining
|
|
169
|
+
items are force-fitted into one final call (each sliced to an equal share of the cap, visibly marked)."""
|
|
170
|
+
cap = REDUCE_CAP if reduce_cap is None else reduce_cap
|
|
171
|
+
groups = _pack(items, cap)
|
|
172
|
+
if len(groups) == 1:
|
|
173
|
+
user = f"# {header}\n\n" + "\n".join(block for _, block in groups[0])
|
|
174
|
+
md = _unfence(providers.gen(model, rubric, user, max_tokens=AUTHOR_MAX_TOKENS))
|
|
175
|
+
return md, _level, providers.LAST["truncated"]
|
|
176
|
+
if _level >= _MAX_LEVELS or (_level > 1 and len(groups) >= len(items)):
|
|
177
|
+
share = max(200, cap // len(items) - 40)
|
|
178
|
+
blocks = [f"### {label}\n{(md or '').strip()[:share]}\n...[truncated]\n" for label, md in items]
|
|
179
|
+
user = f"# {header}\n\n" + "\n".join(blocks)
|
|
180
|
+
md = _unfence(providers.gen(model, rubric, user, max_tokens=AUTHOR_MAX_TOKENS))
|
|
181
|
+
return md, _level, providers.LAST["truncated"]
|
|
182
|
+
intermediates, capped = [], False
|
|
183
|
+
for i, group in enumerate(groups, 1):
|
|
184
|
+
if on_progress:
|
|
185
|
+
on_progress(f"· synthesis pass {_level}: group {i}/{len(groups)} ({len(group)} modules)")
|
|
186
|
+
user = f"# {header} (part {i}/{len(groups)})\n\n" + "\n".join(block for _, block in group)
|
|
187
|
+
gmd = _unfence(providers.gen(model, rubric, user, max_tokens=AUTHOR_MAX_TOKENS))
|
|
188
|
+
capped = capped or providers.LAST["truncated"]
|
|
189
|
+
intermediates.append((f"{group[0][0]} … {group[-1][0]}", gmd))
|
|
190
|
+
md, levels, sub_capped = _synthesize(model, rubric, intermediates, header, on_progress, _level + 1, cap)
|
|
191
|
+
return md, levels, capped or sub_capped
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _layout_targets(repo, files, layout, dir_spec_name, config):
|
|
195
|
+
"""Group spec-worthy code files into {spec_path: [code files]} per the layout."""
|
|
196
|
+
if layout == "per-file":
|
|
197
|
+
return {spec_path_for(f): [f] for f in files}
|
|
198
|
+
if layout == "per-dir":
|
|
199
|
+
repo_name = os.path.basename(os.path.abspath(repo))
|
|
200
|
+
groups = {}
|
|
201
|
+
for f in files:
|
|
202
|
+
groups.setdefault(coverage_mod.dir_spec_path(f, repo_name, dir_spec_name), []).append(f)
|
|
203
|
+
return {sp: sorted(fs) for sp, fs in groups.items()}
|
|
204
|
+
if layout == "per-pair":
|
|
205
|
+
groups = {}
|
|
206
|
+
for p in config.get("pairs", []):
|
|
207
|
+
doc = (p.get("docs") or [None])[0]
|
|
208
|
+
if not doc:
|
|
209
|
+
continue
|
|
210
|
+
cfs = []
|
|
211
|
+
for pat in p.get("code", []):
|
|
212
|
+
for m in glob.glob(os.path.join(repo, pat), recursive=True):
|
|
213
|
+
if os.path.isfile(m):
|
|
214
|
+
cfs.append(os.path.relpath(m, repo))
|
|
215
|
+
if cfs:
|
|
216
|
+
groups.setdefault(doc, []).extend(sorted(set(cfs)))
|
|
217
|
+
return {sp: sorted(set(fs)) for sp, fs in groups.items()}
|
|
218
|
+
raise ValueError(f"unknown layout '{layout}' (use per-file | per-dir | per-pair)")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def generate_repo(repo, config, model, overwrite=False, on_progress=None):
|
|
222
|
+
"""Author specs for a repo per the `authoring` config. Returns a list of {code, spec, status[, note]}.
|
|
223
|
+
|
|
224
|
+
`authoring.layout` (per-file | per-dir | per-pair) sets the spec granularity; `authoring.overview`
|
|
225
|
+
(none | repo | per-dir | both) adds a navigation index; `authoring.template` swaps the per-module rubric.
|
|
226
|
+
Existing files are skipped (no model call) unless `overwrite` — review the written specs via version control.
|
|
227
|
+
"""
|
|
228
|
+
authoring = config.get("authoring", {})
|
|
229
|
+
layout = authoring.get("layout", "per-file")
|
|
230
|
+
dir_spec_name = authoring.get("dir_spec_name", "<dir>")
|
|
231
|
+
overview = authoring.get("overview", "none")
|
|
232
|
+
overview_min_files = int(authoring.get("overview_min_files", 2))
|
|
233
|
+
rubric = _rubric(authoring.get("template"))
|
|
234
|
+
code_cap, _ = audit.caps_from(config) # caps: {code, docs, reduce} — config-overridable
|
|
235
|
+
reduce_cap = int((config.get("caps") or {}).get("reduce", REDUCE_CAP))
|
|
236
|
+
|
|
237
|
+
cov = coverage_mod.coverage(repo, config)
|
|
238
|
+
files = sorted(cov["covered"] + cov["uncovered"]) # all spec-worthy code files
|
|
239
|
+
targets = _layout_targets(repo, files, layout, dir_spec_name, config)
|
|
240
|
+
|
|
241
|
+
results = []
|
|
242
|
+
_intent = {} # code_path -> per-module intent (reused across reduces)
|
|
243
|
+
|
|
244
|
+
def module_intent(code_path):
|
|
245
|
+
if code_path not in _intent:
|
|
246
|
+
colo = os.path.join(repo, spec_path_for(code_path))
|
|
247
|
+
if os.path.exists(colo):
|
|
248
|
+
_intent[code_path] = open(colo, errors="ignore").read() # reuse the existing per-file spec
|
|
249
|
+
else:
|
|
250
|
+
if on_progress:
|
|
251
|
+
on_progress(f"· module {code_path}") # each map call is a model round-trip
|
|
252
|
+
_intent[code_path] = author_file(repo, code_path, model, rubric, code_cap)[0] # intermediate — not written to disk
|
|
253
|
+
return _intent[code_path]
|
|
254
|
+
|
|
255
|
+
def target_md(spec_path, code_files):
|
|
256
|
+
dest = os.path.join(repo, spec_path)
|
|
257
|
+
if os.path.exists(dest):
|
|
258
|
+
return open(dest, errors="ignore").read()
|
|
259
|
+
if len(code_files) == 1:
|
|
260
|
+
return module_intent(code_files[0])
|
|
261
|
+
md, _, _ = _synthesize(model, FOLDER_SPEC_RUBRIC, # INV-4: synthesise, never silently slice
|
|
262
|
+
[(f, module_intent(f)) for f in code_files],
|
|
263
|
+
f"Modules in `{os.path.dirname(spec_path) or '.'}`", on_progress,
|
|
264
|
+
reduce_cap=reduce_cap)
|
|
265
|
+
return md
|
|
266
|
+
|
|
267
|
+
def emit(spec_path, code_ref, make_md, label):
|
|
268
|
+
"""Skip-existing / write, uniformly for specs and overviews. make_md() -> (markdown, note|None), and is
|
|
269
|
+
called only when a file is actually going to be written (never on a skip)."""
|
|
270
|
+
dest = os.path.join(repo, spec_path)
|
|
271
|
+
if os.path.exists(dest) and not overwrite:
|
|
272
|
+
results.append({"code": code_ref, "spec": spec_path, "status": "skipped"})
|
|
273
|
+
return
|
|
274
|
+
if on_progress:
|
|
275
|
+
on_progress(label)
|
|
276
|
+
md, note = make_md()
|
|
277
|
+
rec = {"code": code_ref, "spec": spec_path, "status": "authored"}
|
|
278
|
+
if note:
|
|
279
|
+
rec["note"] = note
|
|
280
|
+
os.makedirs(os.path.dirname(dest) or ".", exist_ok=True)
|
|
281
|
+
open(dest, "w").write(md.rstrip() + "\n")
|
|
282
|
+
results.append(rec)
|
|
283
|
+
|
|
284
|
+
def _cap_notes(*parts):
|
|
285
|
+
"""Join shortfall notes (drop counts, token-cap flags) into one note string, or None."""
|
|
286
|
+
joined = "; ".join(p for p in parts if p)
|
|
287
|
+
return joined or None
|
|
288
|
+
|
|
289
|
+
# 1. Specs — map (single file) or map -> reduce (a directory / pair of files).
|
|
290
|
+
for spec_path, code_files in sorted(targets.items()):
|
|
291
|
+
if len(code_files) == 1:
|
|
292
|
+
code_ref, cf = code_files[0], code_files[0]
|
|
293
|
+
emit(spec_path, code_ref, lambda cf=cf: author_file(repo, cf, model, rubric, code_cap),
|
|
294
|
+
f"authoring {spec_path}")
|
|
295
|
+
else:
|
|
296
|
+
code_ref = os.path.dirname(spec_path) or "."
|
|
297
|
+
|
|
298
|
+
def _folder(sp=spec_path, cfs=code_files):
|
|
299
|
+
md, levels, reply_capped = _synthesize(model, FOLDER_SPEC_RUBRIC,
|
|
300
|
+
[(f, module_intent(f)) for f in cfs],
|
|
301
|
+
f"Modules in `{os.path.dirname(sp) or '.'}`", on_progress,
|
|
302
|
+
reduce_cap=reduce_cap)
|
|
303
|
+
return md, _cap_notes(
|
|
304
|
+
(f"synthesised in {levels} passes from all {len(cfs)} modules "
|
|
305
|
+
f"(nothing dropped)") if levels > 1 else None,
|
|
306
|
+
"reply hit the token cap — the spec may end mid-section" if reply_capped else None)
|
|
307
|
+
emit(spec_path, code_ref, _folder, f"authoring {spec_path} ({len(code_files)} modules)")
|
|
308
|
+
|
|
309
|
+
# 2. Overview layer — an index that links down to the specs the layout produced (runs after every spec exists).
|
|
310
|
+
if overview in ("repo", "both"):
|
|
311
|
+
def _repo_overview():
|
|
312
|
+
items = [(sp, target_md(sp, cf)) for sp, cf in sorted(targets.items())]
|
|
313
|
+
md, levels, reply_capped = _synthesize(model, OVERVIEW_RUBRIC, items, "Repository overview",
|
|
314
|
+
on_progress, reduce_cap=reduce_cap)
|
|
315
|
+
return md, _cap_notes(
|
|
316
|
+
f"index synthesised in {levels} passes over all {len(items)} specs (nothing dropped)" if levels > 1 else None,
|
|
317
|
+
"reply hit the token cap — the index may end mid-section" if reply_capped else None)
|
|
318
|
+
emit("OVERVIEW.md", ".", _repo_overview, f"authoring OVERVIEW.md (index over {len(targets)} spec(s))")
|
|
319
|
+
|
|
320
|
+
if overview in ("per-dir", "both"):
|
|
321
|
+
by_dir = {}
|
|
322
|
+
for f in files:
|
|
323
|
+
by_dir.setdefault(os.path.dirname(f), []).append(f)
|
|
324
|
+
for d, dfiles in sorted(by_dir.items()):
|
|
325
|
+
if len(dfiles) <= overview_min_files: # only index directories with several modules
|
|
326
|
+
continue
|
|
327
|
+
dtargets = {tp: cf for tp, cf in targets.items() if os.path.dirname(tp) == d}
|
|
328
|
+
readme = os.path.join(d, "README.md")
|
|
329
|
+
|
|
330
|
+
def _dir_overview(dtargets=dtargets, d=d):
|
|
331
|
+
items = [(tp, target_md(tp, cf)) for tp, cf in sorted(dtargets.items())]
|
|
332
|
+
md, levels, reply_capped = _synthesize(model, OVERVIEW_RUBRIC, items,
|
|
333
|
+
f"Directory overview — `{d or '.'}`", on_progress,
|
|
334
|
+
reduce_cap=reduce_cap)
|
|
335
|
+
return md, _cap_notes(
|
|
336
|
+
f"index synthesised in {levels} passes (nothing dropped)" if levels > 1 else None,
|
|
337
|
+
"reply hit the token cap — the index may end mid-section" if reply_capped else None)
|
|
338
|
+
emit(readme, d or ".", _dir_overview, f"authoring {readme} (index)")
|
|
339
|
+
|
|
340
|
+
return results
|