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 ADDED
@@ -0,0 +1,2 @@
1
+ """spec-eval — portable code↔doc drift audit + fingerprint."""
2
+ __version__ = "0.2.0" # THE single source of version truth; pyproject.toml reads this via dynamic version
spec_eval/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
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