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.
- clean_docs/__init__.py +26 -0
- clean_docs/__main__.py +3 -0
- clean_docs/accessibility.py +182 -0
- clean_docs/adapters/__init__.py +1 -0
- clean_docs/adapters/event_capture.py +56 -0
- clean_docs/adapters/mdx_dependencies.json +714 -0
- clean_docs/adapters/mdx_parser.mjs +29992 -0
- clean_docs/applicability.py +330 -0
- clean_docs/audit.py +1120 -0
- clean_docs/bootstrap.py +507 -0
- clean_docs/capabilities.py +200 -0
- clean_docs/changed.py +452 -0
- clean_docs/claims.py +840 -0
- clean_docs/cli.py +1612 -0
- clean_docs/context.py +307 -0
- clean_docs/corpus.py +377 -0
- clean_docs/demo.py +369 -0
- clean_docs/doctor.py +184 -0
- clean_docs/emit/__init__.py +4 -0
- clean_docs/emit/llms_txt.py +102 -0
- clean_docs/emit/stepwise.py +168 -0
- clean_docs/engine.py +324 -0
- clean_docs/errors.py +20 -0
- clean_docs/evaluation.py +867 -0
- clean_docs/execution.py +138 -0
- clean_docs/explain.py +123 -0
- clean_docs/extractors/__init__.py +19 -0
- clean_docs/extractors/command.py +51 -0
- clean_docs/extractors/inventory.py +176 -0
- clean_docs/extractors/json_pointer.py +84 -0
- clean_docs/extractors/python_literal.py +104 -0
- clean_docs/extractors/static.py +111 -0
- clean_docs/feedback.py +1390 -0
- clean_docs/impact.py +1624 -0
- clean_docs/improvements.py +1178 -0
- clean_docs/inventory.py +474 -0
- clean_docs/isolation.py +157 -0
- clean_docs/manifest.py +898 -0
- clean_docs/mdx.py +272 -0
- clean_docs/migration.py +121 -0
- clean_docs/models.py +194 -0
- clean_docs/outcomes.py +296 -0
- clean_docs/performance.py +123 -0
- clean_docs/phrasing.py +448 -0
- clean_docs/plugins.py +249 -0
- clean_docs/policy.py +536 -0
- clean_docs/projections.py +255 -0
- clean_docs/regions.py +75 -0
- clean_docs/release.py +232 -0
- clean_docs/renderers.py +57 -0
- clean_docs/residue.py +311 -0
- clean_docs/review_contracts.py +862 -0
- clean_docs/review_ledger.py +297 -0
- clean_docs/review_limits.py +9 -0
- clean_docs/sensitivity.py +602 -0
- clean_docs/snapshot.py +212 -0
- clean_docs/standard.py +281 -0
- clean_docs/standards/default.json +309 -0
- clean_docs/standards/exemplars.md +87 -0
- clean_docs/standards/v0-migrations.json +26 -0
- clean_docs/symbols.py +47 -0
- clean_docs/templates.py +96 -0
- clean_docs/verdict.py +1397 -0
- clean_docs/visuals.py +346 -0
- clean_docs/write_gate.py +170 -0
- sourcebound-1.2.1.dist-info/LICENSE +21 -0
- sourcebound-1.2.1.dist-info/METADATA +109 -0
- sourcebound-1.2.1.dist-info/RECORD +71 -0
- sourcebound-1.2.1.dist-info/WHEEL +5 -0
- sourcebound-1.2.1.dist-info/entry_points.txt +2 -0
- 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"
|
clean_docs/renderers.py
ADDED
|
@@ -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}")
|