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
clean_docs/demo.py
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
"""Generate one static, accessible demonstration from recorded fixture evidence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import html
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from html.parser import HTMLParser
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from clean_docs.errors import ConfigurationError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
EVIDENCE_KEYS = {
|
|
19
|
+
"schema", "title", "intended_reader", "value", "prerequisites", "states", "limits",
|
|
20
|
+
"next_step",
|
|
21
|
+
}
|
|
22
|
+
STATE_KEYS = {"id", "label", "steps"}
|
|
23
|
+
STEP_KEYS = {"command", "exit_code", "output"}
|
|
24
|
+
NEXT_STEP_KEYS = {"label", "href"}
|
|
25
|
+
STATE_IDS = ("before", "drift", "repaired")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class DemoStep:
|
|
30
|
+
command: str
|
|
31
|
+
exit_code: int
|
|
32
|
+
output: str
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class DemoState:
|
|
37
|
+
id: str
|
|
38
|
+
label: str
|
|
39
|
+
steps: tuple[DemoStep, ...]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class DemoEvidence:
|
|
44
|
+
title: str
|
|
45
|
+
intended_reader: str
|
|
46
|
+
value: str
|
|
47
|
+
prerequisites: tuple[str, ...]
|
|
48
|
+
states: tuple[DemoState, ...]
|
|
49
|
+
limits: tuple[str, ...]
|
|
50
|
+
next_step_label: str
|
|
51
|
+
next_step_href: str
|
|
52
|
+
digest: str
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _mapping(raw: Any, where: str) -> dict[str, Any]:
|
|
56
|
+
if not isinstance(raw, dict):
|
|
57
|
+
raise ConfigurationError(f"{where} must be a mapping")
|
|
58
|
+
return raw
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _exact(data: dict[str, Any], keys: set[str], where: str) -> None:
|
|
62
|
+
if set(data) != keys:
|
|
63
|
+
raise ConfigurationError(f"{where} must contain exactly: {', '.join(sorted(keys))}")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _text(raw: Any, where: str) -> str:
|
|
67
|
+
if not isinstance(raw, str) or not raw.strip():
|
|
68
|
+
raise ConfigurationError(f"{where} must be non-empty text")
|
|
69
|
+
return raw.strip()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _strings(raw: Any, where: str) -> tuple[str, ...]:
|
|
73
|
+
if not isinstance(raw, list) or not raw or not all(
|
|
74
|
+
isinstance(value, str) and value.strip() for value in raw
|
|
75
|
+
):
|
|
76
|
+
raise ConfigurationError(f"{where} must be a non-empty string list")
|
|
77
|
+
return tuple(value.strip() for value in raw)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def load_demo_evidence(path: Path) -> DemoEvidence:
|
|
81
|
+
try:
|
|
82
|
+
content = path.read_bytes()
|
|
83
|
+
raw = json.loads(content)
|
|
84
|
+
except OSError as exc:
|
|
85
|
+
raise ConfigurationError(f"cannot read demo evidence {path}: {exc}") from exc
|
|
86
|
+
except json.JSONDecodeError as exc:
|
|
87
|
+
raise ConfigurationError(f"invalid demo evidence JSON: {exc}") from exc
|
|
88
|
+
root = _mapping(raw, "demo evidence")
|
|
89
|
+
_exact(root, EVIDENCE_KEYS, "demo evidence")
|
|
90
|
+
if root["schema"] != "sourcebound.demo-evidence.v1":
|
|
91
|
+
raise ConfigurationError("demo evidence has an unsupported schema")
|
|
92
|
+
raw_states = root["states"]
|
|
93
|
+
if not isinstance(raw_states, list) or len(raw_states) != 3:
|
|
94
|
+
raise ConfigurationError("demo evidence must contain before, drift, and repaired states")
|
|
95
|
+
states = []
|
|
96
|
+
for index, raw_state in enumerate(raw_states):
|
|
97
|
+
where = f"demo evidence.states[{index}]"
|
|
98
|
+
state = _mapping(raw_state, where)
|
|
99
|
+
_exact(state, STATE_KEYS, where)
|
|
100
|
+
if state["id"] != STATE_IDS[index]:
|
|
101
|
+
raise ConfigurationError(
|
|
102
|
+
"demo evidence states must be ordered before, drift, repaired"
|
|
103
|
+
)
|
|
104
|
+
raw_steps = state["steps"]
|
|
105
|
+
if not isinstance(raw_steps, list) or not raw_steps:
|
|
106
|
+
raise ConfigurationError(f"{where}.steps must be a non-empty list")
|
|
107
|
+
steps = []
|
|
108
|
+
for step_index, raw_step in enumerate(raw_steps):
|
|
109
|
+
step_where = f"{where}.steps[{step_index}]"
|
|
110
|
+
step = _mapping(raw_step, step_where)
|
|
111
|
+
_exact(step, STEP_KEYS, step_where)
|
|
112
|
+
exit_code = step["exit_code"]
|
|
113
|
+
if not isinstance(exit_code, int) or exit_code < 0:
|
|
114
|
+
raise ConfigurationError(f"{step_where}.exit_code must be a non-negative integer")
|
|
115
|
+
steps.append(DemoStep(
|
|
116
|
+
command=_text(step["command"], f"{step_where}.command"),
|
|
117
|
+
exit_code=exit_code,
|
|
118
|
+
output=_text(step["output"], f"{step_where}.output"),
|
|
119
|
+
))
|
|
120
|
+
states.append(DemoState(
|
|
121
|
+
id=state["id"],
|
|
122
|
+
label=_text(state["label"], f"{where}.label"),
|
|
123
|
+
steps=tuple(steps),
|
|
124
|
+
))
|
|
125
|
+
next_step = _mapping(root["next_step"], "demo evidence.next_step")
|
|
126
|
+
_exact(next_step, NEXT_STEP_KEYS, "demo evidence.next_step")
|
|
127
|
+
href = _text(next_step["href"], "demo evidence.next_step.href")
|
|
128
|
+
if href.startswith(("http://", "//")):
|
|
129
|
+
raise ConfigurationError("demo next step must be local or use HTTPS")
|
|
130
|
+
return DemoEvidence(
|
|
131
|
+
title=_text(root["title"], "demo evidence.title"),
|
|
132
|
+
intended_reader=_text(root["intended_reader"], "demo evidence.intended_reader"),
|
|
133
|
+
value=_text(root["value"], "demo evidence.value"),
|
|
134
|
+
prerequisites=_strings(root["prerequisites"], "demo evidence.prerequisites"),
|
|
135
|
+
states=tuple(states),
|
|
136
|
+
limits=_strings(root["limits"], "demo evidence.limits"),
|
|
137
|
+
next_step_label=_text(next_step["label"], "demo evidence.next_step.label"),
|
|
138
|
+
next_step_href=href,
|
|
139
|
+
digest=hashlib.sha256(content).hexdigest(),
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class _StructureParser(HTMLParser):
|
|
144
|
+
def __init__(self) -> None:
|
|
145
|
+
super().__init__()
|
|
146
|
+
self.lang = ""
|
|
147
|
+
self.in_title = False
|
|
148
|
+
self.title = ""
|
|
149
|
+
self.headings: list[int] = []
|
|
150
|
+
self.ids: set[str] = set()
|
|
151
|
+
self.fragments: list[str] = []
|
|
152
|
+
self.labelled_by: list[str] = []
|
|
153
|
+
self.skip_main = False
|
|
154
|
+
self.main = False
|
|
155
|
+
self.scripts = 0
|
|
156
|
+
self.external_resources: list[str] = []
|
|
157
|
+
self.images_without_alt = 0
|
|
158
|
+
|
|
159
|
+
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
160
|
+
values = {key: value or "" for key, value in attrs}
|
|
161
|
+
if tag == "html":
|
|
162
|
+
self.lang = values.get("lang", "")
|
|
163
|
+
if tag == "title":
|
|
164
|
+
self.in_title = True
|
|
165
|
+
if tag == "main" and values.get("id") == "main":
|
|
166
|
+
self.main = True
|
|
167
|
+
if tag == "script":
|
|
168
|
+
self.scripts += 1
|
|
169
|
+
if tag == "img" and "alt" not in values:
|
|
170
|
+
self.images_without_alt += 1
|
|
171
|
+
if tag in {"link", "img", "script", "iframe"}:
|
|
172
|
+
resource = values.get("href") or values.get("src")
|
|
173
|
+
if resource and resource.startswith(("http://", "https://", "//")):
|
|
174
|
+
self.external_resources.append(resource)
|
|
175
|
+
if tag.startswith("h") and len(tag) == 2 and tag[1].isdigit():
|
|
176
|
+
self.headings.append(int(tag[1]))
|
|
177
|
+
if identifier := values.get("id"):
|
|
178
|
+
self.ids.add(identifier)
|
|
179
|
+
if labelled_by := values.get("aria-labelledby"):
|
|
180
|
+
self.labelled_by.extend(labelled_by.split())
|
|
181
|
+
href = values.get("href", "")
|
|
182
|
+
if href == "#main":
|
|
183
|
+
self.skip_main = True
|
|
184
|
+
if href.startswith("#") and len(href) > 1:
|
|
185
|
+
self.fragments.append(href[1:])
|
|
186
|
+
|
|
187
|
+
def handle_endtag(self, tag: str) -> None:
|
|
188
|
+
if tag == "title":
|
|
189
|
+
self.in_title = False
|
|
190
|
+
|
|
191
|
+
def handle_data(self, data: str) -> None:
|
|
192
|
+
if self.in_title:
|
|
193
|
+
self.title += data
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def validate_static_html(content: str) -> None:
|
|
197
|
+
parser = _StructureParser()
|
|
198
|
+
parser.feed(content)
|
|
199
|
+
failures = []
|
|
200
|
+
if parser.lang != "en":
|
|
201
|
+
failures.append("html lang must be en")
|
|
202
|
+
if not parser.title.strip():
|
|
203
|
+
failures.append("title must be non-empty")
|
|
204
|
+
if not parser.main:
|
|
205
|
+
failures.append("main landmark must have id main")
|
|
206
|
+
if not parser.skip_main:
|
|
207
|
+
failures.append("skip link must target #main")
|
|
208
|
+
if parser.headings.count(1) != 1:
|
|
209
|
+
failures.append("page must contain one h1")
|
|
210
|
+
if parser.headings and any(
|
|
211
|
+
current > previous + 1
|
|
212
|
+
for previous, current in zip(parser.headings, parser.headings[1:])
|
|
213
|
+
):
|
|
214
|
+
failures.append("heading levels must not skip")
|
|
215
|
+
missing = sorted((set(parser.fragments) | set(parser.labelled_by)) - parser.ids)
|
|
216
|
+
if missing:
|
|
217
|
+
failures.append("missing referenced id(s): " + ", ".join(missing))
|
|
218
|
+
if parser.scripts:
|
|
219
|
+
failures.append("scripts are not allowed")
|
|
220
|
+
if parser.external_resources:
|
|
221
|
+
failures.append("external runtime resources are not allowed")
|
|
222
|
+
if parser.images_without_alt:
|
|
223
|
+
failures.append("every image must have alt text")
|
|
224
|
+
if failures:
|
|
225
|
+
raise ConfigurationError("static demo structure failed: " + "; ".join(failures))
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _link(output: Path, target: str) -> str:
|
|
229
|
+
if target.startswith("https://"):
|
|
230
|
+
return target
|
|
231
|
+
path, separator, fragment = target.partition("#")
|
|
232
|
+
relative = os.path.relpath(path, output.parent).replace(os.sep, "/")
|
|
233
|
+
return relative + (f"#{fragment}" if separator else "")
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def render_static_demo(evidence: DemoEvidence, output: Path) -> str:
|
|
237
|
+
esc = html.escape
|
|
238
|
+
|
|
239
|
+
def preformatted(value: str) -> str:
|
|
240
|
+
escaped = esc(value)
|
|
241
|
+
return re.sub(
|
|
242
|
+
r" +(?=\n|$)",
|
|
243
|
+
lambda match: " " * len(match.group(0)),
|
|
244
|
+
escaped,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
prerequisite_items = "".join(f"<li>{esc(item)}</li>" for item in evidence.prerequisites)
|
|
248
|
+
limit_items = "".join(f"<li>{esc(item)}</li>" for item in evidence.limits)
|
|
249
|
+
state_explanations = {
|
|
250
|
+
"before": "Source and README agree. The gate has nothing to repair.",
|
|
251
|
+
"drift": "The source changed; the README did not. The gate names the stale binding.",
|
|
252
|
+
"repaired": "The declared region is regenerated, then the same check passes.",
|
|
253
|
+
}
|
|
254
|
+
state_cards: list[str] = []
|
|
255
|
+
for state in evidence.states:
|
|
256
|
+
steps = []
|
|
257
|
+
for step in state.steps:
|
|
258
|
+
steps.append(
|
|
259
|
+
'<div class="step">'
|
|
260
|
+
f'<p><code>{esc(step.command)}</code> <span>exit {step.exit_code}</span></p>'
|
|
261
|
+
f'<pre aria-label="Output from {esc(step.command)}"><code>{preformatted(step.output)}</code></pre>'
|
|
262
|
+
"</div>"
|
|
263
|
+
)
|
|
264
|
+
state_cards.append(
|
|
265
|
+
f'<article class="state {esc(state.id)}" aria-labelledby="state-{esc(state.id)}">'
|
|
266
|
+
f'<div class="state-heading"><span class="state-index">0{len(state_cards) + 1}</span>'
|
|
267
|
+
f'<h3 id="state-{esc(state.id)}">{esc(state.label.split(". ", 1)[-1])}</h3></div>'
|
|
268
|
+
f'<p class="state-explanation">{esc(state_explanations[state.id])}</p>'
|
|
269
|
+
+ "".join(steps)
|
|
270
|
+
+ "</article>"
|
|
271
|
+
)
|
|
272
|
+
next_href = _link(output, evidence.next_step_href)
|
|
273
|
+
content = f"""<!doctype html>
|
|
274
|
+
<html lang="en">
|
|
275
|
+
<head>
|
|
276
|
+
<meta charset="utf-8">
|
|
277
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
278
|
+
<title>{esc(evidence.title)}</title>
|
|
279
|
+
<style>
|
|
280
|
+
:root {{ color-scheme: light; --ink: #20242c; --muted: #667085; --paper: #f7f8fa; --panel: #ffffff; --line: #d9dde5; --binding: #6558d3; --binding-soft: #eeecff; --bad: #c9342f; --bad-soft: #fff0ef; --good: #22734b; --good-soft: #eaf7f0; --code: #171a21; }}
|
|
281
|
+
* {{ box-sizing: border-box; }}
|
|
282
|
+
html {{ scroll-behavior: smooth; }}
|
|
283
|
+
body {{ margin: 0; background: var(--paper); color: var(--ink); font: 16px/1.55 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }}
|
|
284
|
+
a {{ color: inherit; text-underline-offset: .22em; }}
|
|
285
|
+
.skip {{ position: absolute; left: 1rem; top: -5rem; z-index: 5; background: var(--ink); color: white; padding: .7rem 1rem; }}
|
|
286
|
+
.skip:focus {{ top: 1rem; }}
|
|
287
|
+
.wrap, main {{ width: min(72rem, calc(100% - 2.5rem)); margin: 0 auto; }}
|
|
288
|
+
header {{ background: var(--panel); border-bottom: 1px solid var(--line); }}
|
|
289
|
+
.hero {{ display: grid; grid-template-columns: minmax(0, .9fr) minmax(28rem, 1.1fr); gap: clamp(2rem, 5vw, 4.5rem); align-items: center; padding: 3.25rem 0; }}
|
|
290
|
+
h1 {{ max-width: 14ch; margin: .45rem 0 1rem; font-size: clamp(2.5rem, 5vw, 4.5rem); line-height: .98; letter-spacing: -.055em; }}
|
|
291
|
+
h2 {{ margin: 0 0 .75rem; font-size: clamp(1.65rem, 3vw, 2.45rem); line-height: 1.08; letter-spacing: -.035em; }}
|
|
292
|
+
h3 {{ margin: 0; font-size: 1.05rem; letter-spacing: -.015em; }}
|
|
293
|
+
.eyebrow, .digest, .state-index, .node-label {{ font: 700 .75rem/1.2 ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .1em; text-transform: uppercase; }}
|
|
294
|
+
.eyebrow {{ color: var(--binding); }}
|
|
295
|
+
.hero-copy {{ min-width: 0; }}
|
|
296
|
+
.hero-copy > p:not(.eyebrow):not(.digest) {{ max-width: 34rem; color: var(--muted); font-size: 1.02rem; overflow-wrap: anywhere; }}
|
|
297
|
+
.digest {{ margin-top: 1.35rem; color: #8b93a3; overflow-wrap: anywhere; word-break: break-all; text-transform: none; letter-spacing: .01em; font-size: .67rem; }}
|
|
298
|
+
.binding {{ position: relative; padding: 1rem; border: 1px solid var(--line); border-radius: .65rem; background: #fafaff; }}
|
|
299
|
+
.binding-title {{ margin: 0 0 .75rem; color: var(--muted); font-size: .8rem; }}
|
|
300
|
+
.node {{ padding: .8rem .9rem; border: 1px solid #d5d1fb; border-radius: .4rem; background: var(--panel); }}
|
|
301
|
+
.node-label {{ display: block; margin-bottom: .4rem; color: var(--binding); }}
|
|
302
|
+
.node code {{ display: block; overflow-x: auto; color: var(--ink); font-size: .84rem; }}
|
|
303
|
+
.tether {{ display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: .6rem; margin: .55rem 0; color: var(--bad); }}
|
|
304
|
+
.tether::before, .tether::after {{ content: ""; border-top: 2px dashed currentColor; }}
|
|
305
|
+
.tether span {{ padding: .25rem .5rem; border: 1px solid currentColor; border-radius: 999px; background: var(--bad-soft); font: 700 .67rem/1 ui-monospace, monospace; text-transform: uppercase; letter-spacing: .06em; }}
|
|
306
|
+
.mismatch {{ color: var(--bad); }}
|
|
307
|
+
main {{ padding: 2.5rem 0 2rem; }}
|
|
308
|
+
.intro-grid {{ display: grid; grid-template-columns: 1.2fr .8fr; gap: 3rem; padding-bottom: 2.5rem; border-bottom: 1px solid var(--line); }}
|
|
309
|
+
.intro-grid p {{ max-width: 44rem; font-size: 1.15rem; }}
|
|
310
|
+
.prerequisites {{ margin: 0; padding: 1rem 1rem 1rem 2.2rem; border: 1px solid var(--line); border-radius: .5rem; background: var(--panel); }}
|
|
311
|
+
.section-kicker {{ margin: 0 0 .55rem; color: var(--binding); font: 700 .72rem/1.2 ui-monospace, monospace; letter-spacing: .08em; text-transform: uppercase; }}
|
|
312
|
+
.procedure {{ padding: 2.75rem 0; }}
|
|
313
|
+
.procedure-lead {{ max-width: 43rem; margin: 0 0 1.35rem; color: var(--muted); }}
|
|
314
|
+
.states {{ display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); border: 1px solid var(--line); background: var(--line); gap: 1px; }}
|
|
315
|
+
.state {{ position: relative; background: var(--panel); padding: 1.2rem; min-width: 0; }}
|
|
316
|
+
.state::before {{ content: ""; position: absolute; inset: 0 auto auto 0; width: 100%; height: .25rem; background: var(--binding); }}
|
|
317
|
+
.state.drift::before {{ background: var(--bad); }} .state.repaired::before {{ background: var(--good); }}
|
|
318
|
+
.state-heading {{ display: flex; align-items: baseline; gap: .65rem; margin-bottom: .65rem; }}
|
|
319
|
+
.state-index {{ color: var(--muted); }}
|
|
320
|
+
.state-explanation {{ min-height: 4.65rem; margin: 0; color: var(--muted); font-size: .9rem; }}
|
|
321
|
+
.step {{ margin-top: .85rem; }}
|
|
322
|
+
.step > p {{ margin: 0; padding: .55rem .7rem; border: 1px solid var(--line); border-bottom: 0; background: #f5f6f8; }}
|
|
323
|
+
.step span {{ float: right; color: var(--muted); font: 700 .75rem/1.8 ui-monospace, monospace; text-transform: uppercase; }}
|
|
324
|
+
code, pre {{ font: .84rem/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; }}
|
|
325
|
+
pre {{ min-height: 7rem; margin: 0; overflow: auto; padding: .8rem; background: var(--code); color: #e8eaf0; white-space: pre-wrap; }}
|
|
326
|
+
.proof {{ display: grid; grid-template-columns: .75fr 1.25fr; gap: 3rem; padding: 2.75rem 0; border-top: 1px solid var(--line); }}
|
|
327
|
+
.proof ul {{ margin: 0; padding-left: 1.2rem; }}
|
|
328
|
+
.proof li + li {{ margin-top: .8rem; }}
|
|
329
|
+
.next-section {{ display: flex; justify-content: space-between; align-items: center; gap: 2rem; margin-top: 1rem; padding: 1.5rem; border: 1px solid #d5d1fb; border-radius: .65rem; background: var(--binding-soft); }}
|
|
330
|
+
.next-section h2 {{ max-width: 15ch; font-size: 2rem; }}
|
|
331
|
+
.next {{ flex: none; display: inline-block; padding: .75rem 1rem; border: 1px solid var(--binding); border-radius: .4rem; background: var(--binding); color: white; font-weight: 700; text-decoration: none; }}
|
|
332
|
+
.next:hover {{ background: #5145bd; }}
|
|
333
|
+
@media (max-width: 900px) {{ .hero {{ grid-template-columns: 1fr; }} .binding {{ max-width: 38rem; }} .states {{ grid-template-columns: 1fr; }} .state-explanation {{ min-height: 0; }} .intro-grid, .proof {{ grid-template-columns: 1fr; gap: 1.5rem; }} }}
|
|
334
|
+
@media (max-width: 560px) {{ .wrap, main {{ width: min(100% - 2rem, 72rem); }} .hero {{ padding: 2.25rem 0; }} h1 {{ font-size: 2.65rem; }} .binding {{ padding: .75rem; }} .next-section {{ align-items: flex-start; flex-direction: column; }} }}
|
|
335
|
+
@media (prefers-reduced-motion: reduce) {{ *, *::before, *::after {{ scroll-behavior: auto !important; }} }}
|
|
336
|
+
</style>
|
|
337
|
+
</head>
|
|
338
|
+
<body>
|
|
339
|
+
<a class="skip" href="#main">Skip to demonstration</a>
|
|
340
|
+
<header>
|
|
341
|
+
<div class="wrap hero">
|
|
342
|
+
<div class="hero-copy">
|
|
343
|
+
<p class="eyebrow">Recorded, deterministic proof</p>
|
|
344
|
+
<h1>{esc(evidence.title)}</h1>
|
|
345
|
+
<p>{esc(evidence.value)}</p>
|
|
346
|
+
<p class="digest">Evidence sha256: {evidence.digest}</p>
|
|
347
|
+
</div>
|
|
348
|
+
<div class="binding" aria-label="A source command no longer matches its README claim">
|
|
349
|
+
<p class="binding-title">Binding: <code>public-command</code></p>
|
|
350
|
+
<div class="node"><span class="node-label">Source · command.txt</span><code>sourcebound check --changed</code></div>
|
|
351
|
+
<div class="tether mismatch"><span>mismatch</span></div>
|
|
352
|
+
<div class="node"><span class="node-label">Document · README.md</span><code>sourcebound check</code></div>
|
|
353
|
+
</div>
|
|
354
|
+
</div>
|
|
355
|
+
</header>
|
|
356
|
+
<main id="main">
|
|
357
|
+
<section class="procedure" aria-labelledby="procedure"><p class="section-kicker">The complete loop</p><h2 id="procedure">One binding. Three observable states.</h2><p class="procedure-lead">The source changes in state two. The document is left untouched until the declared repair runs in state three.</p><div class="states">{''.join(state_cards)}</div></section>
|
|
358
|
+
<section class="intro-grid" aria-labelledby="intended-reader">
|
|
359
|
+
<div><p class="section-kicker">Why this demonstration exists</p><h2 id="intended-reader">See the failure before adding the gate.</h2><p>{esc(evidence.intended_reader)}</p></div>
|
|
360
|
+
<div><p class="section-kicker" id="prerequisites">What is running</p><ul class="prerequisites" aria-labelledby="prerequisites">{prerequisite_items}</ul></div>
|
|
361
|
+
</section>
|
|
362
|
+
<section class="proof" aria-labelledby="limits"><div><p class="section-kicker">Evidence boundary</p><h2 id="limits">What this proves.</h2></div><ul>{limit_items}</ul></section>
|
|
363
|
+
<section class="next-section" aria-labelledby="next-step"><h2 id="next-step">Try the same loop in a repository.</h2><a class="next" href="{esc(next_href)}">{esc(evidence.next_step_label)}</a></section>
|
|
364
|
+
</main>
|
|
365
|
+
</body>
|
|
366
|
+
</html>
|
|
367
|
+
"""
|
|
368
|
+
validate_static_html(content)
|
|
369
|
+
return content
|
clean_docs/doctor.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import platform
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from clean_docs import __version__
|
|
10
|
+
from clean_docs.audit import audit
|
|
11
|
+
from clean_docs.errors import CleanDocsError, ConfigurationError
|
|
12
|
+
from clean_docs.execution import resolve_argv
|
|
13
|
+
from clean_docs.manifest import load_manifest
|
|
14
|
+
from clean_docs.mdx import parser_availability
|
|
15
|
+
from clean_docs.standard import load_default_pack
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class DoctorCheck:
|
|
20
|
+
name: str
|
|
21
|
+
ok: bool
|
|
22
|
+
detail: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class DiagnosticBundle:
|
|
27
|
+
ref: str | None
|
|
28
|
+
checks: tuple[DoctorCheck, ...]
|
|
29
|
+
bindings: int | None
|
|
30
|
+
commands: int | None
|
|
31
|
+
plugins: tuple[str, ...]
|
|
32
|
+
deprecations: tuple[str, ...]
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def ok(self) -> bool:
|
|
36
|
+
return all(check.ok for check in self.checks)
|
|
37
|
+
|
|
38
|
+
def as_dict(self) -> dict[str, object]:
|
|
39
|
+
return {
|
|
40
|
+
"schema": "sourcebound.diagnostic.v2",
|
|
41
|
+
"ok": self.ok,
|
|
42
|
+
"version": __version__,
|
|
43
|
+
"runtime": {
|
|
44
|
+
"python": platform.python_version(),
|
|
45
|
+
"implementation": platform.python_implementation(),
|
|
46
|
+
"system": platform.system(),
|
|
47
|
+
"machine": platform.machine(),
|
|
48
|
+
},
|
|
49
|
+
"repository": {
|
|
50
|
+
"ref": self.ref,
|
|
51
|
+
"bindings": self.bindings,
|
|
52
|
+
"commands": self.commands,
|
|
53
|
+
"plugins": list(self.plugins),
|
|
54
|
+
},
|
|
55
|
+
"checks": [
|
|
56
|
+
{"name": check.name, "ok": check.ok, "detail": check.detail}
|
|
57
|
+
for check in self.checks
|
|
58
|
+
],
|
|
59
|
+
"included_data": [
|
|
60
|
+
"runtime versions",
|
|
61
|
+
"repository ref",
|
|
62
|
+
"manifest counts and plugin ids",
|
|
63
|
+
"doctor check results",
|
|
64
|
+
],
|
|
65
|
+
"excluded_data": [
|
|
66
|
+
"environment variables",
|
|
67
|
+
"credentials",
|
|
68
|
+
"document contents",
|
|
69
|
+
"source contents",
|
|
70
|
+
"command arguments",
|
|
71
|
+
],
|
|
72
|
+
"deprecations": list(self.deprecations),
|
|
73
|
+
"execution": {
|
|
74
|
+
"declared_processes_run": 0,
|
|
75
|
+
"network_isolation": "not-provided",
|
|
76
|
+
"network_observation": "not-instrumented",
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _git_repository(root: Path) -> DoctorCheck:
|
|
82
|
+
proc = subprocess.run(
|
|
83
|
+
["git", "-C", str(root), "rev-parse", "--is-inside-work-tree"],
|
|
84
|
+
text=True,
|
|
85
|
+
capture_output=True,
|
|
86
|
+
timeout=30,
|
|
87
|
+
check=False,
|
|
88
|
+
)
|
|
89
|
+
return DoctorCheck(
|
|
90
|
+
"git-repository",
|
|
91
|
+
proc.returncode == 0 and proc.stdout.strip() == "true",
|
|
92
|
+
proc.stderr.strip() or proc.stdout.strip() or "git did not identify a worktree",
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def diagnose(root: Path, manifest_path: Path) -> tuple[DoctorCheck, ...]:
|
|
97
|
+
root = root.resolve()
|
|
98
|
+
checks = [_git_repository(root)]
|
|
99
|
+
try:
|
|
100
|
+
pack = load_default_pack()
|
|
101
|
+
checks.append(DoctorCheck(
|
|
102
|
+
"default-policy-pack",
|
|
103
|
+
True,
|
|
104
|
+
f"{pack['profile']} pack version {pack['pack_version']}",
|
|
105
|
+
))
|
|
106
|
+
except CleanDocsError as exc:
|
|
107
|
+
checks.append(DoctorCheck("default-policy-pack", False, str(exc)))
|
|
108
|
+
try:
|
|
109
|
+
report = audit(root)
|
|
110
|
+
checks.append(DoctorCheck(
|
|
111
|
+
"documentation-audit",
|
|
112
|
+
report.ok,
|
|
113
|
+
f"{len(report.documents)} active documents; {len(report.findings)} new findings; "
|
|
114
|
+
f"{sum(count for _rule, count in report.advisory_totals)} advisory candidates; "
|
|
115
|
+
f"{len(report.baselined_findings)} baselined; {len(report.stale_baseline)} stale; "
|
|
116
|
+
f"{len(report.unsupported_documents)} unsupported",
|
|
117
|
+
))
|
|
118
|
+
mdx_documents = tuple(
|
|
119
|
+
path
|
|
120
|
+
for path in (*report.documents, *report.unsupported_documents)
|
|
121
|
+
if path.lower().endswith(".mdx")
|
|
122
|
+
)
|
|
123
|
+
parser_ok, parser_detail = parser_availability()
|
|
124
|
+
checks.append(
|
|
125
|
+
DoctorCheck(
|
|
126
|
+
"mdx-parser",
|
|
127
|
+
parser_ok if mdx_documents else True,
|
|
128
|
+
(
|
|
129
|
+
f"{parser_detail}; {len(mdx_documents)} tracked MDX document(s), "
|
|
130
|
+
f"{len(report.unsupported_documents)} unsupported"
|
|
131
|
+
if mdx_documents
|
|
132
|
+
else "not required: no tracked MDX documents"
|
|
133
|
+
),
|
|
134
|
+
)
|
|
135
|
+
)
|
|
136
|
+
except CleanDocsError as exc:
|
|
137
|
+
checks.append(DoctorCheck("documentation-audit", False, str(exc)))
|
|
138
|
+
try:
|
|
139
|
+
manifest = load_manifest(manifest_path)
|
|
140
|
+
checks.append(DoctorCheck(
|
|
141
|
+
"manifest",
|
|
142
|
+
True,
|
|
143
|
+
f"version {manifest.version}; {len(manifest.bindings)} bindings",
|
|
144
|
+
))
|
|
145
|
+
for command in manifest.commands:
|
|
146
|
+
executable = resolve_argv(command.argv)[0]
|
|
147
|
+
available = shutil.which(executable) is not None or (
|
|
148
|
+
(root / executable).is_file() and "/" in executable
|
|
149
|
+
)
|
|
150
|
+
checks.append(DoctorCheck(
|
|
151
|
+
f"command:{command.id}",
|
|
152
|
+
available,
|
|
153
|
+
executable if available else f"executable not found: {executable}",
|
|
154
|
+
))
|
|
155
|
+
except ConfigurationError as exc:
|
|
156
|
+
checks.append(DoctorCheck("manifest", False, str(exc)))
|
|
157
|
+
return tuple(checks)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def build_diagnostic_bundle(root: Path, manifest_path: Path) -> DiagnosticBundle:
|
|
161
|
+
root = root.resolve()
|
|
162
|
+
checks = diagnose(root, manifest_path)
|
|
163
|
+
ref = None
|
|
164
|
+
proc = subprocess.run(
|
|
165
|
+
["git", "-C", str(root), "rev-parse", "HEAD"],
|
|
166
|
+
text=True,
|
|
167
|
+
capture_output=True,
|
|
168
|
+
timeout=30,
|
|
169
|
+
check=False,
|
|
170
|
+
)
|
|
171
|
+
if proc.returncode == 0:
|
|
172
|
+
ref = proc.stdout.strip()
|
|
173
|
+
try:
|
|
174
|
+
manifest = load_manifest(manifest_path)
|
|
175
|
+
bindings = len(manifest.bindings)
|
|
176
|
+
commands = len(manifest.commands)
|
|
177
|
+
plugins = tuple(item.id for item in manifest.plugins)
|
|
178
|
+
deprecations = manifest.deprecations
|
|
179
|
+
except ConfigurationError:
|
|
180
|
+
bindings = None
|
|
181
|
+
commands = None
|
|
182
|
+
plugins = ()
|
|
183
|
+
deprecations = ()
|
|
184
|
+
return DiagnosticBundle(ref, checks, bindings, commands, plugins, deprecations)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Project a manifest into an llms.txt index of bound and declared canonical docs."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from urllib.parse import quote
|
|
8
|
+
|
|
9
|
+
from clean_docs.errors import ConfigurationError
|
|
10
|
+
from clean_docs.models import Manifest
|
|
11
|
+
from clean_docs.regions import atomic_write
|
|
12
|
+
|
|
13
|
+
DEFAULT_TITLE = "Repository documentation"
|
|
14
|
+
DEFAULT_SUMMARY = (
|
|
15
|
+
"Index of repository documents with source-bound facts. Sourcebound checks the named bindings "
|
|
16
|
+
"for drift."
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _bound_facts(manifest: Manifest) -> dict[str, list[str]]:
|
|
21
|
+
groups: dict[str, list[str]] = {}
|
|
22
|
+
for binding in manifest.bindings:
|
|
23
|
+
groups.setdefault(binding.doc.as_posix(), []).append(binding.id)
|
|
24
|
+
return {doc: sorted(ids) for doc, ids in sorted(groups.items())}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _indexed_documents(manifest: Manifest) -> dict[str, list[str]]:
|
|
28
|
+
documents = _bound_facts(manifest)
|
|
29
|
+
projection = manifest.projections.llms_txt if manifest.projections else None
|
|
30
|
+
if projection is not None:
|
|
31
|
+
for path in projection.include:
|
|
32
|
+
documents.setdefault(path.as_posix(), [])
|
|
33
|
+
return dict(sorted(documents.items()))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _one_line(value: str, name: str) -> str:
|
|
37
|
+
if not value.strip() or "\n" in value or "\r" in value:
|
|
38
|
+
raise ConfigurationError(f"llms.txt {name} must be one non-empty line")
|
|
39
|
+
return value.strip()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _document_metadata(
|
|
43
|
+
manifest: Manifest,
|
|
44
|
+
document: str,
|
|
45
|
+
documents: dict[str, bytes] | None = None,
|
|
46
|
+
output_path: Path | None = None,
|
|
47
|
+
) -> tuple[str, str]:
|
|
48
|
+
repository_root = manifest.path.parent.resolve()
|
|
49
|
+
path = repository_root / document
|
|
50
|
+
if documents is not None and document in documents:
|
|
51
|
+
content = documents[document]
|
|
52
|
+
else:
|
|
53
|
+
try:
|
|
54
|
+
content = path.read_bytes()
|
|
55
|
+
except OSError as exc:
|
|
56
|
+
raise ConfigurationError(f"cannot index document {document}: {exc}") from exc
|
|
57
|
+
if output_path is None:
|
|
58
|
+
link = document
|
|
59
|
+
else:
|
|
60
|
+
link = os.path.relpath(path, output_path.resolve().parent).replace(os.sep, "/")
|
|
61
|
+
return quote(link, safe="/"), hashlib.sha256(content).hexdigest()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def render_llms_txt(
|
|
65
|
+
manifest: Manifest,
|
|
66
|
+
*,
|
|
67
|
+
title: str | None = None,
|
|
68
|
+
summary: str | None = None,
|
|
69
|
+
documents: dict[str, bytes] | None = None,
|
|
70
|
+
output_path: Path | None = None,
|
|
71
|
+
) -> str:
|
|
72
|
+
"""Render an llms.txt projection without writing it."""
|
|
73
|
+
heading = _one_line(title or DEFAULT_TITLE, "title")
|
|
74
|
+
blurb = _one_line(summary or DEFAULT_SUMMARY, "summary")
|
|
75
|
+
lines = [f"# {heading}", "", f"> {blurb}", "", "## Canonical documentation", ""]
|
|
76
|
+
for doc, ids in _indexed_documents(manifest).items():
|
|
77
|
+
link, digest = _document_metadata(manifest, doc, documents, output_path)
|
|
78
|
+
status = f"bindings: {', '.join(ids)}" if ids else "declared canonical context"
|
|
79
|
+
lines.append(
|
|
80
|
+
f"- [{doc}]({link}): {status}; sha256: {digest}"
|
|
81
|
+
)
|
|
82
|
+
return "\n".join(lines) + "\n"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def emit_llms_txt(
|
|
86
|
+
manifest: Manifest,
|
|
87
|
+
out_path: Path,
|
|
88
|
+
*,
|
|
89
|
+
title: str | None = None,
|
|
90
|
+
summary: str | None = None,
|
|
91
|
+
) -> Path:
|
|
92
|
+
"""Write an llms.txt index derived from the manifest and return its path."""
|
|
93
|
+
atomic_write(
|
|
94
|
+
out_path,
|
|
95
|
+
render_llms_txt(
|
|
96
|
+
manifest,
|
|
97
|
+
title=title,
|
|
98
|
+
summary=summary,
|
|
99
|
+
output_path=out_path,
|
|
100
|
+
),
|
|
101
|
+
)
|
|
102
|
+
return out_path
|