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
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: "&#32;" * 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,4 @@
1
+ from clean_docs.emit.llms_txt import emit_llms_txt, render_llms_txt
2
+ from clean_docs.emit.stepwise import emit_stepwise_skill
3
+
4
+ __all__ = ["emit_llms_txt", "emit_stepwise_skill", "render_llms_txt"]
@@ -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