sourcebound 1.2.1__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.
Files changed (71) hide show
  1. clean_docs/__init__.py +26 -0
  2. clean_docs/__main__.py +3 -0
  3. clean_docs/accessibility.py +182 -0
  4. clean_docs/adapters/__init__.py +1 -0
  5. clean_docs/adapters/event_capture.py +56 -0
  6. clean_docs/adapters/mdx_dependencies.json +714 -0
  7. clean_docs/adapters/mdx_parser.mjs +29992 -0
  8. clean_docs/applicability.py +330 -0
  9. clean_docs/audit.py +1120 -0
  10. clean_docs/bootstrap.py +507 -0
  11. clean_docs/capabilities.py +200 -0
  12. clean_docs/changed.py +452 -0
  13. clean_docs/claims.py +840 -0
  14. clean_docs/cli.py +1612 -0
  15. clean_docs/context.py +307 -0
  16. clean_docs/corpus.py +377 -0
  17. clean_docs/demo.py +369 -0
  18. clean_docs/doctor.py +184 -0
  19. clean_docs/emit/__init__.py +4 -0
  20. clean_docs/emit/llms_txt.py +102 -0
  21. clean_docs/emit/stepwise.py +168 -0
  22. clean_docs/engine.py +324 -0
  23. clean_docs/errors.py +20 -0
  24. clean_docs/evaluation.py +867 -0
  25. clean_docs/execution.py +138 -0
  26. clean_docs/explain.py +123 -0
  27. clean_docs/extractors/__init__.py +19 -0
  28. clean_docs/extractors/command.py +51 -0
  29. clean_docs/extractors/inventory.py +176 -0
  30. clean_docs/extractors/json_pointer.py +84 -0
  31. clean_docs/extractors/python_literal.py +104 -0
  32. clean_docs/extractors/static.py +111 -0
  33. clean_docs/feedback.py +1390 -0
  34. clean_docs/impact.py +1624 -0
  35. clean_docs/improvements.py +1178 -0
  36. clean_docs/inventory.py +474 -0
  37. clean_docs/isolation.py +157 -0
  38. clean_docs/manifest.py +898 -0
  39. clean_docs/mdx.py +272 -0
  40. clean_docs/migration.py +121 -0
  41. clean_docs/models.py +194 -0
  42. clean_docs/outcomes.py +296 -0
  43. clean_docs/performance.py +123 -0
  44. clean_docs/phrasing.py +448 -0
  45. clean_docs/plugins.py +249 -0
  46. clean_docs/policy.py +536 -0
  47. clean_docs/projections.py +255 -0
  48. clean_docs/regions.py +75 -0
  49. clean_docs/release.py +232 -0
  50. clean_docs/renderers.py +57 -0
  51. clean_docs/residue.py +311 -0
  52. clean_docs/review_contracts.py +862 -0
  53. clean_docs/review_ledger.py +297 -0
  54. clean_docs/review_limits.py +9 -0
  55. clean_docs/sensitivity.py +602 -0
  56. clean_docs/snapshot.py +212 -0
  57. clean_docs/standard.py +281 -0
  58. clean_docs/standards/default.json +309 -0
  59. clean_docs/standards/exemplars.md +87 -0
  60. clean_docs/standards/v0-migrations.json +26 -0
  61. clean_docs/symbols.py +47 -0
  62. clean_docs/templates.py +96 -0
  63. clean_docs/verdict.py +1397 -0
  64. clean_docs/visuals.py +346 -0
  65. clean_docs/write_gate.py +170 -0
  66. sourcebound-1.2.1.dist-info/LICENSE +21 -0
  67. sourcebound-1.2.1.dist-info/METADATA +109 -0
  68. sourcebound-1.2.1.dist-info/RECORD +71 -0
  69. sourcebound-1.2.1.dist-info/WHEEL +5 -0
  70. sourcebound-1.2.1.dist-info/entry_points.txt +2 -0
  71. sourcebound-1.2.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,255 @@
1
+ """Render and verify projections of the canonical documentation graph."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import difflib
6
+ import hashlib
7
+ import os
8
+ import re
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from urllib.parse import unquote
12
+
13
+ from clean_docs.demo import load_demo_evidence, render_static_demo
14
+ from clean_docs.emit import render_llms_txt
15
+ from clean_docs.errors import ConfigurationError
16
+ from clean_docs.models import BindingResult, ContextBundleProjection, Manifest, Provenance
17
+ from clean_docs.regions import atomic_write
18
+ from clean_docs.visuals import (
19
+ load_visual_record,
20
+ render_agent_visual,
21
+ render_human_visual,
22
+ )
23
+
24
+
25
+ LINK = re.compile(r"\[[^\]]+\]\(([^)\s]+)(?:\s+[^)]*)?\)")
26
+ HTML_LINK = re.compile(r'href="([^"]+)"')
27
+ HEADING = re.compile(r"^#{1,6}\s+(.+?)\s*$")
28
+ CANONICAL_BLOCK = re.compile(
29
+ r"<!-- sourcebound:canonical .+? begin -->.*?"
30
+ r"<!-- sourcebound:canonical .+? end -->",
31
+ re.DOTALL,
32
+ )
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class ProjectionSet:
37
+ source_ref: str
38
+ corpus_digest: str
39
+ files: dict[Path, str]
40
+ digests: dict[Path, str]
41
+
42
+
43
+ def _read_documents(root: Path, manifest: Manifest) -> dict[str, bytes]:
44
+ documents: dict[str, bytes] = {}
45
+ for document in sorted({binding.doc.as_posix() for binding in manifest.bindings}):
46
+ try:
47
+ documents[document] = (root / document).read_bytes()
48
+ except OSError as exc:
49
+ raise ConfigurationError(
50
+ f"cannot project bound document {document}: {exc}"
51
+ ) from exc
52
+ return documents
53
+
54
+
55
+ def _corpus_digest(documents: dict[str, bytes]) -> str:
56
+ digest = hashlib.sha256()
57
+ for path, content in sorted(documents.items()):
58
+ digest.update(path.encode("utf-8"))
59
+ digest.update(b"\0")
60
+ digest.update(hashlib.sha256(content).digest())
61
+ return digest.hexdigest()
62
+
63
+
64
+ def _source_ref() -> str:
65
+ # A working-tree projection cannot embed HEAD without becoming stale when its own commit
66
+ # changes HEAD. The corpus digest identifies the exact bytes; immutable refs use a separate
67
+ # snapshot path when supported.
68
+ return "WORKTREE"
69
+
70
+
71
+ def _relative_link(root: Path, output: Path, document: Path) -> str:
72
+ link = os.path.relpath(root / document, (root / output).parent).replace(os.sep, "/")
73
+ return link
74
+
75
+
76
+ def _render_bundle(
77
+ root: Path,
78
+ bundle: ContextBundleProjection,
79
+ documents: dict[str, bytes],
80
+ source_ref: str,
81
+ corpus_digest: str,
82
+ ) -> str:
83
+ lines = [
84
+ f"# Context bundle: {bundle.id}",
85
+ "",
86
+ f"- Source ref: `{source_ref}`",
87
+ f"- Corpus sha256: `{corpus_digest}`",
88
+ "- Content: exact canonical document bytes",
89
+ ]
90
+ for document in bundle.include:
91
+ key = document.as_posix()
92
+ content = documents[key].decode("utf-8")
93
+ link = _relative_link(root, bundle.output, document)
94
+ digest = hashlib.sha256(documents[key]).hexdigest()
95
+ lines.extend([
96
+ "",
97
+ f"## Canonical document: {key}",
98
+ "",
99
+ f"- Source: [{key}]({link})",
100
+ f"- Content sha256: `{digest}`",
101
+ "",
102
+ f"<!-- sourcebound:canonical {key} begin -->",
103
+ content.rstrip(),
104
+ f"<!-- sourcebound:canonical {key} end -->",
105
+ ])
106
+ return "\n".join(lines) + "\n"
107
+
108
+
109
+ def _slug(title: str) -> str:
110
+ value = re.sub(r"<[^>]+>", "", title).strip().lower()
111
+ value = re.sub(r"[^a-z0-9 _-]", "", value)
112
+ return re.sub(r"[ _]+", "-", value).strip("-")
113
+
114
+
115
+ def _anchors(content: str) -> set[str]:
116
+ counts: dict[str, int] = {}
117
+ anchors: set[str] = set()
118
+ for line in content.splitlines():
119
+ match = HEADING.match(line)
120
+ if not match:
121
+ continue
122
+ base = _slug(match.group(1))
123
+ count = counts.get(base, 0)
124
+ counts[base] = count + 1
125
+ anchors.add(base if count == 0 else f"{base}-{count}")
126
+ anchors.update(re.findall(r'\bid="([^"]+)"', content))
127
+ return anchors
128
+
129
+
130
+ def _verify_links(root: Path, files: dict[Path, str]) -> None:
131
+ source_paths = set(files)
132
+ for relative, content in files.items():
133
+ # Embedded canonical bytes retain links relative to their original page. The original
134
+ # page is checked separately; generated bundle metadata is checked at the bundle path.
135
+ checked_content = CANONICAL_BLOCK.sub("", content)
136
+ matches = [*LINK.finditer(checked_content), *HTML_LINK.finditer(checked_content)]
137
+ for match in matches:
138
+ raw = unquote(match.group(1))
139
+ if raw.startswith(("http://", "https://", "mailto:")):
140
+ continue
141
+ target_text, _, fragment = raw.partition("#")
142
+ target = (
143
+ relative
144
+ if not target_text
145
+ else Path(os.path.normpath((relative.parent / target_text).as_posix()))
146
+ )
147
+ if target.is_absolute() or ".." in target.parts:
148
+ resolved = (root / relative.parent / target_text).resolve()
149
+ try:
150
+ target = resolved.relative_to(root)
151
+ except ValueError as exc:
152
+ raise ConfigurationError(
153
+ f"projection link escapes repository: {relative} -> {raw}"
154
+ ) from exc
155
+ if target in source_paths:
156
+ target_content = files[target]
157
+ else:
158
+ path = root / target
159
+ if path.exists() and not fragment:
160
+ continue
161
+ try:
162
+ target_content = path.read_text(encoding="utf-8")
163
+ except OSError as exc:
164
+ raise ConfigurationError(
165
+ f"broken projection link: {relative} -> {raw}"
166
+ ) from exc
167
+ if fragment and unquote(fragment) not in _anchors(target_content):
168
+ raise ConfigurationError(
169
+ f"broken projection anchor: {relative} -> {raw}"
170
+ )
171
+
172
+
173
+ def render_projections(root: Path, manifest: Manifest) -> ProjectionSet:
174
+ root = root.resolve()
175
+ if manifest.projections is None:
176
+ raise ConfigurationError("manifest does not configure projections")
177
+ documents = _read_documents(root, manifest)
178
+ corpus_digest = _corpus_digest(documents)
179
+ source_ref = _source_ref()
180
+ files: dict[Path, str] = {}
181
+ digests: dict[Path, str] = {}
182
+ llms = manifest.projections.llms_txt
183
+ if llms:
184
+ files[llms.output] = render_llms_txt(
185
+ manifest,
186
+ title=llms.title,
187
+ summary=llms.summary,
188
+ documents=documents,
189
+ output_path=root / llms.output,
190
+ )
191
+ digests[llms.output] = corpus_digest
192
+ for bundle in manifest.projections.bundles:
193
+ files[bundle.output] = _render_bundle(
194
+ root, bundle, documents, source_ref, corpus_digest
195
+ )
196
+ digests[bundle.output] = corpus_digest
197
+ demo = manifest.projections.demo
198
+ if demo:
199
+ evidence = load_demo_evidence(root / demo.evidence)
200
+ files[demo.output] = render_static_demo(evidence, demo.output)
201
+ digests[demo.output] = evidence.digest
202
+ for visual in manifest.projections.visuals:
203
+ record = load_visual_record(root / visual.source, visual.id)
204
+ files[visual.human_output] = render_human_visual(root, visual, record)
205
+ files[visual.agent_output] = render_agent_visual(root, visual, record)
206
+ digests[visual.human_output] = record.digest
207
+ digests[visual.agent_output] = record.digest
208
+ combined = {Path(path): content.decode("utf-8") for path, content in documents.items()}
209
+ combined.update(files)
210
+ _verify_links(root, combined)
211
+ return ProjectionSet(source_ref, corpus_digest, files, digests)
212
+
213
+
214
+ def evaluate_projections(root: Path, manifest: Manifest) -> list[BindingResult]:
215
+ projection_set = render_projections(root, manifest)
216
+ results = []
217
+ for path, expected in sorted(projection_set.files.items(), key=lambda item: item[0].as_posix()):
218
+ try:
219
+ observed = (root / path).read_text(encoding="utf-8")
220
+ except FileNotFoundError:
221
+ observed = ""
222
+ except OSError as exc:
223
+ raise ConfigurationError(f"cannot read projection {path}: {exc}") from exc
224
+ diff = "".join(difflib.unified_diff(
225
+ observed.splitlines(keepends=True),
226
+ expected.splitlines(keepends=True),
227
+ fromfile=path.as_posix(),
228
+ tofile=f"{path.as_posix()} (projected)",
229
+ ))
230
+ results.append(BindingResult(
231
+ binding_id=f"projection:{path.as_posix()}",
232
+ doc=path.as_posix(),
233
+ changed=observed != expected,
234
+ expected=expected,
235
+ observed=observed,
236
+ diff=diff,
237
+ provenance=Provenance(
238
+ ref=projection_set.source_ref,
239
+ path=path.as_posix(),
240
+ locator="documentation-graph",
241
+ extractor="projection",
242
+ digest=projection_set.digests[path],
243
+ ),
244
+ binding_type="projection",
245
+ ))
246
+ return results
247
+
248
+
249
+ def write_projections(root: Path, manifest: Manifest) -> tuple[Path, ...]:
250
+ projection_set = render_projections(root, manifest)
251
+ written = []
252
+ for path, content in sorted(projection_set.files.items(), key=lambda item: item[0].as_posix()):
253
+ atomic_write(root / path, content)
254
+ written.append(path)
255
+ return tuple(written)
clean_docs/regions.py ADDED
@@ -0,0 +1,75 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import stat
5
+ import tempfile
6
+ from pathlib import Path
7
+
8
+ from clean_docs.errors import RegionError
9
+
10
+
11
+ def markers(region: str) -> tuple[str, str]:
12
+ return (
13
+ f"<!-- sourcebound:begin {region} -->",
14
+ f"<!-- sourcebound:end {region} -->",
15
+ )
16
+
17
+
18
+ def mdx_markers(region: str) -> tuple[str, str]:
19
+ return (
20
+ f"{{/* sourcebound:begin {region} */}}",
21
+ f"{{/* sourcebound:end {region} */}}",
22
+ )
23
+
24
+
25
+ def replace_region(document: str, region: str, generated: str) -> str:
26
+ forms = [
27
+ candidate
28
+ for candidate in (markers(region), mdx_markers(region))
29
+ if candidate[0] in document or candidate[1] in document
30
+ ]
31
+ if len(forms) != 1:
32
+ raise RegionError(
33
+ f"region {region!r} must use exactly one Markdown or MDX marker form"
34
+ )
35
+ begin, end = forms[0]
36
+ if document.count(begin) != 1 or document.count(end) != 1:
37
+ raise RegionError(f"region {region!r} must have exactly one begin and one end marker")
38
+ start = document.index(begin) + len(begin)
39
+ finish = document.index(end)
40
+ if finish < start:
41
+ raise RegionError(f"region {region!r} end marker precedes its begin marker")
42
+ between = document[start:finish]
43
+ if any(
44
+ marker in between
45
+ for marker in (
46
+ "<!-- sourcebound:begin ",
47
+ "<!-- sourcebound:end ",
48
+ "{/* sourcebound:begin ",
49
+ "{/* sourcebound:end ",
50
+ )
51
+ ):
52
+ raise RegionError(f"region {region!r} contains nested sourcebound markers")
53
+ return document[:start] + "\n" + generated.rstrip() + "\n" + document[finish:]
54
+
55
+
56
+ def atomic_write(path: Path, content: str) -> None:
57
+ path.parent.mkdir(parents=True, exist_ok=True)
58
+ try:
59
+ mode = stat.S_IMODE(path.stat().st_mode)
60
+ except OSError:
61
+ mode = 0o644
62
+ descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
63
+ try:
64
+ os.fchmod(descriptor, mode)
65
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
66
+ handle.write(content)
67
+ handle.flush()
68
+ os.fsync(handle.fileno())
69
+ os.replace(temporary, path)
70
+ except BaseException:
71
+ try:
72
+ os.unlink(temporary)
73
+ except OSError:
74
+ pass
75
+ raise
clean_docs/release.py ADDED
@@ -0,0 +1,232 @@
1
+ """Build deterministic release facts from normalized evidence at two git refs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import asdict, dataclass
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from clean_docs.errors import ConfigurationError
11
+ from clean_docs.inventory import InventoryItem, scan_inventory
12
+ from clean_docs.policy import check_prose
13
+ from clean_docs.plugins import discover_plugin_items, merge_plugin_inventory
14
+ from clean_docs.manifest import load_manifest
15
+ from clean_docs.snapshot import RepositorySnapshot
16
+ from clean_docs.standard import load_default_pack
17
+
18
+
19
+ DELTA_KINDS = ("added", "removed", "changed")
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class EvidenceDelta:
24
+ id: str
25
+ change: str
26
+ kind: str
27
+ name: str
28
+ source: str
29
+ locator: str
30
+ adapter: str
31
+ before_sha256: str | None
32
+ after_sha256: str | None
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class ReleaseReport:
37
+ from_ref: str
38
+ to_ref: str
39
+ deltas: tuple[EvidenceDelta, ...]
40
+
41
+ def as_dict(self) -> dict[str, object]:
42
+ return {
43
+ "schema": "sourcebound.release-delta.v1",
44
+ "from": self.from_ref,
45
+ "to": self.to_ref,
46
+ "deltas": [asdict(delta) for delta in self.deltas],
47
+ "counts": {
48
+ change: sum(delta.change == change for delta in self.deltas)
49
+ for change in DELTA_KINDS
50
+ },
51
+ }
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class NarrativeDraft:
56
+ delta_id: str
57
+ text: str
58
+ citation: str
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class NarrativeResult:
63
+ drafts: tuple[NarrativeDraft, ...]
64
+ findings: tuple[str, ...]
65
+
66
+ @property
67
+ def ok(self) -> bool:
68
+ return not self.findings
69
+
70
+ def as_dict(self) -> dict[str, object]:
71
+ return {
72
+ "ok": self.ok,
73
+ "drafts": [asdict(draft) for draft in self.drafts],
74
+ "findings": list(self.findings),
75
+ }
76
+
77
+
78
+ def _items(root: Path, ref: str) -> tuple[str, dict[str, InventoryItem]]:
79
+ snapshot = RepositorySnapshot(root, ref)
80
+ label = snapshot.label
81
+ with snapshot.materialized_root() as materialized:
82
+ items = list(scan_inventory(materialized).items)
83
+ manifest_path = materialized / ".sourcebound.yml"
84
+ plugins = load_manifest(manifest_path).plugins if manifest_path.is_file() else ()
85
+ merged = merge_plugin_inventory(
86
+ tuple(items), discover_plugin_items(snapshot, plugins)
87
+ )
88
+ return label, {item.id: item for item in merged}
89
+
90
+
91
+ def _delta(
92
+ identifier: str,
93
+ change: str,
94
+ before: InventoryItem | None,
95
+ after: InventoryItem | None,
96
+ ) -> EvidenceDelta:
97
+ item = after or before
98
+ if item is None: # pragma: no cover - protected by the caller
99
+ raise AssertionError("a release delta needs evidence")
100
+ return EvidenceDelta(
101
+ identifier,
102
+ change,
103
+ item.kind,
104
+ item.name,
105
+ item.source,
106
+ item.locator,
107
+ item.adapter,
108
+ before.digest if before is not None else None,
109
+ after.digest if after is not None else None,
110
+ )
111
+
112
+
113
+ def build_release_report(root: Path, from_ref: str, to_ref: str) -> ReleaseReport:
114
+ if not from_ref or not to_ref:
115
+ raise ConfigurationError("release requires --from and --to refs")
116
+ root = root.resolve()
117
+ from_label, before = _items(root, from_ref)
118
+ to_label, after = _items(root, to_ref)
119
+ deltas: list[EvidenceDelta] = []
120
+ for identifier in sorted(set(before) | set(after)):
121
+ old = before.get(identifier)
122
+ new = after.get(identifier)
123
+ if old is None:
124
+ deltas.append(_delta(identifier, "added", None, new))
125
+ elif new is None:
126
+ deltas.append(_delta(identifier, "removed", old, None))
127
+ elif old != new:
128
+ deltas.append(_delta(identifier, "changed", old, new))
129
+ return ReleaseReport(from_label, to_label, tuple(deltas))
130
+
131
+
132
+ def _mapping(value: Any, where: str) -> dict[str, Any]:
133
+ if not isinstance(value, dict):
134
+ raise ConfigurationError(f"{where} must be an object")
135
+ return value
136
+
137
+
138
+ def validate_release_narrative(report: ReleaseReport, response: str) -> NarrativeResult:
139
+ try:
140
+ raw = json.loads(response)
141
+ except json.JSONDecodeError as exc:
142
+ raise ConfigurationError(f"release narrative is not valid JSON: {exc}") from exc
143
+ root = _mapping(raw, "release narrative")
144
+ if set(root) != {"schema", "drafts"} or root.get("schema") != "sourcebound.release-narrative.v1":
145
+ raise ConfigurationError(
146
+ "release narrative must contain schema sourcebound.release-narrative.v1 and drafts"
147
+ )
148
+ if not isinstance(root["drafts"], list):
149
+ raise ConfigurationError("release narrative drafts must be a list")
150
+ expected = {delta.id: delta for delta in report.deltas}
151
+ seen: set[str] = set()
152
+ accepted: list[NarrativeDraft] = []
153
+ findings: list[str] = []
154
+ for index, candidate in enumerate(root["drafts"]):
155
+ draft = _mapping(candidate, f"release narrative draft {index}")
156
+ required = {"delta_id", "change", "kind", "name", "citation", "text"}
157
+ if set(draft) != required:
158
+ raise ConfigurationError(
159
+ f"release narrative draft {index} must contain exactly: "
160
+ + ", ".join(sorted(required))
161
+ )
162
+ delta_id = draft["delta_id"]
163
+ if not isinstance(delta_id, str) or delta_id not in expected:
164
+ findings.append(f"draft {index} names an unknown delta")
165
+ continue
166
+ if delta_id in seen:
167
+ findings.append(f"delta {delta_id} has duplicate drafts")
168
+ continue
169
+ seen.add(delta_id)
170
+ delta = expected[delta_id]
171
+ mirrors = {
172
+ "change": delta.change,
173
+ "kind": delta.kind,
174
+ "name": delta.name,
175
+ "citation": f"{delta.source}#{delta.locator}",
176
+ }
177
+ mismatched = [key for key, value in mirrors.items() if draft[key] != value]
178
+ text = draft["text"]
179
+ if not isinstance(text, str) or not text.strip() or len(text) > 500:
180
+ findings.append(f"delta {delta_id} has invalid narrative text")
181
+ continue
182
+ if mismatched:
183
+ findings.append(
184
+ f"delta {delta_id} contradicts deterministic fields: {', '.join(mismatched)}"
185
+ )
186
+ continue
187
+ policy = check_prose("<release-narrative>", text, load_default_pack())
188
+ if policy:
189
+ findings.extend(
190
+ f"delta {delta_id} violates {finding.rule}: {finding.detail}"
191
+ for finding in policy
192
+ )
193
+ continue
194
+ accepted.append(NarrativeDraft(delta_id, text.strip(), mirrors["citation"]))
195
+ for delta_id in sorted(set(expected) - seen):
196
+ findings.append(f"delta {delta_id} is omitted from the narrative")
197
+ if findings:
198
+ accepted = []
199
+ return NarrativeResult(tuple(accepted), tuple(findings))
200
+
201
+
202
+ def render_release_markdown(
203
+ report: ReleaseReport, narrative: NarrativeResult | None = None
204
+ ) -> str:
205
+ lines = [
206
+ "# Release facts",
207
+ "",
208
+ f"Evidence compared from `{report.from_ref}` to `{report.to_ref}`.",
209
+ ]
210
+ for change in DELTA_KINDS:
211
+ lines.extend(("", f"## {change.title()}"))
212
+ selected = [delta for delta in report.deltas if delta.change == change]
213
+ if not selected:
214
+ lines.extend(("", "None."))
215
+ continue
216
+ lines.append("")
217
+ for delta in selected:
218
+ digest = delta.after_sha256 or delta.before_sha256
219
+ lines.append(
220
+ f"- `{delta.kind}` `{delta.name}` at "
221
+ f"[{delta.source}]({delta.source}) locator `{delta.locator}` via "
222
+ f"`{delta.adapter}`; evidence sha256 `{digest}`."
223
+ )
224
+ if narrative is not None:
225
+ lines.extend(("", "## Narrative draft", ""))
226
+ if narrative.ok:
227
+ for draft in narrative.drafts:
228
+ lines.append(f"- {draft.text} (`{draft.citation}`)")
229
+ else:
230
+ lines.append("Withheld because the draft did not preserve every release fact.")
231
+ lines.extend(f"- {finding}" for finding in narrative.findings)
232
+ return "\n".join(lines) + "\n"
@@ -0,0 +1,57 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from clean_docs.errors import ExtractionError
6
+ from clean_docs.models import EvidenceValue, RegionBinding
7
+
8
+
9
+ def _cell(value: Any) -> str:
10
+ if value is None:
11
+ return ""
12
+ if isinstance(value, bool):
13
+ text = "true" if value else "false"
14
+ elif isinstance(value, (dict, list)):
15
+ raise ExtractionError("nested values cannot be rendered as Markdown table cells")
16
+ else:
17
+ text = str(value)
18
+ return text.replace("|", "\\|").replace("\n", " ")
19
+
20
+
21
+ def render_markdown_table(evidence: EvidenceValue, binding: RegionBinding) -> str:
22
+ rows = evidence.value
23
+ missing = sorted({column for column in binding.columns for row in rows if column not in row})
24
+ if missing:
25
+ raise ExtractionError(f"binding {binding.id} is missing column(s): {', '.join(missing)}")
26
+ header = "| " + " | ".join(binding.columns) + " |"
27
+ divider = "| " + " | ".join("---" for _ in binding.columns) + " |"
28
+ body = [
29
+ "| " + " | ".join(_cell(row[column]) for column in binding.columns) + " |"
30
+ for row in rows
31
+ ]
32
+ return "\n".join([header, divider, *body])
33
+
34
+
35
+ def render(evidence: EvidenceValue, binding: RegionBinding) -> str:
36
+ if binding.renderer == "markdown-table":
37
+ return render_markdown_table(evidence, binding)
38
+ if binding.renderer == "markdown-list":
39
+ if not isinstance(evidence.value, list) or any(
40
+ isinstance(item, (dict, list)) for item in evidence.value
41
+ ):
42
+ raise ExtractionError("markdown-list evidence must contain scalar items")
43
+ return "\n".join(f"- {_cell(item)}" for item in evidence.value)
44
+ if binding.renderer == "scalar":
45
+ if isinstance(evidence.value, (dict, list)):
46
+ raise ExtractionError("scalar renderer requires a scalar value")
47
+ return _cell(evidence.value)
48
+ if binding.renderer == "markdown-fragment":
49
+ if not isinstance(evidence.value, str) or evidence.kind != "markdown":
50
+ raise ExtractionError("markdown-fragment requires generated Markdown evidence")
51
+ return evidence.value.rstrip()
52
+ if binding.renderer == "fenced-text":
53
+ if not isinstance(evidence.value, str):
54
+ raise ExtractionError("fenced-text renderer requires text evidence")
55
+ fence = "```" + (binding.language or "")
56
+ return f"{fence}\n{evidence.value.rstrip()}\n```"
57
+ raise ExtractionError(f"unsupported renderer: {binding.renderer}")