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/visuals.py ADDED
@@ -0,0 +1,346 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import html
5
+ import json
6
+ import os
7
+ import re
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import yaml
13
+
14
+ from clean_docs.errors import ConfigurationError
15
+ from clean_docs.models import VisualProjection
16
+
17
+
18
+ VISUAL_SCHEMA = "sourcebound.visual.v1"
19
+ VISUAL_KEYS = {
20
+ "schema",
21
+ "id",
22
+ "kind",
23
+ "src",
24
+ "src_dark",
25
+ "width",
26
+ "height",
27
+ "alt",
28
+ "caption",
29
+ "description",
30
+ "annotations",
31
+ }
32
+ ANNOTATION_KEYS = {"id", "x", "y", "title", "description"}
33
+ IDENTIFIER = re.compile(r"[a-z][a-z0-9]*(?:-[a-z0-9]+)*")
34
+ REMOTE_SOURCE = re.compile(r"https://", re.IGNORECASE)
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class VisualAnnotation:
39
+ id: str
40
+ x: float
41
+ y: float
42
+ title: str
43
+ description: str
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class VisualRecord:
48
+ id: str
49
+ kind: str
50
+ src: str
51
+ src_dark: str | None
52
+ width: int
53
+ height: int
54
+ alt: str
55
+ caption: str
56
+ description: str
57
+ annotations: tuple[VisualAnnotation, ...]
58
+ digest: str
59
+
60
+
61
+ def _mapping(value: Any, where: str) -> dict[str, Any]:
62
+ if not isinstance(value, dict):
63
+ raise ConfigurationError(f"{where} must be a mapping")
64
+ return value
65
+
66
+
67
+ def _keys(value: dict[str, Any], allowed: set[str], where: str) -> None:
68
+ unknown = sorted(set(value) - allowed)
69
+ if unknown:
70
+ raise ConfigurationError(f"{where} has unknown key(s): {', '.join(unknown)}")
71
+
72
+
73
+ def _identifier(value: Any, where: str) -> str:
74
+ if not isinstance(value, str) or IDENTIFIER.fullmatch(value) is None:
75
+ raise ConfigurationError(f"{where} must be a kebab-case identifier")
76
+ return value
77
+
78
+
79
+ def _text(value: Any, where: str, *, one_line: bool = False) -> str:
80
+ if not isinstance(value, str) or not value.strip():
81
+ raise ConfigurationError(f"{where} must be non-empty text")
82
+ result = value.strip()
83
+ if one_line and ("\n" in result or "\r" in result):
84
+ raise ConfigurationError(f"{where} must be one line")
85
+ return result
86
+
87
+
88
+ def _dimension(value: Any, where: str) -> int:
89
+ if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
90
+ raise ConfigurationError(f"{where} must be a positive integer")
91
+ return value
92
+
93
+
94
+ def _coordinate(value: Any, where: str) -> float:
95
+ if (
96
+ isinstance(value, bool)
97
+ or not isinstance(value, (int, float))
98
+ or not 0 <= float(value) <= 100
99
+ ):
100
+ raise ConfigurationError(f"{where} must be a number from 0 through 100")
101
+ return float(value)
102
+
103
+
104
+ def _source(value: Any, where: str) -> str:
105
+ source = _text(value, where, one_line=True)
106
+ if any(character.isspace() for character in source) or any(
107
+ character in source for character in "<>()"
108
+ ):
109
+ raise ConfigurationError(
110
+ f"{where} must not contain whitespace or Markdown link delimiters"
111
+ )
112
+ if REMOTE_SOURCE.match(source):
113
+ return source
114
+ path = Path(source)
115
+ if path.is_absolute() or ".." in path.parts:
116
+ raise ConfigurationError(f"{where} must be HTTPS or repository-relative")
117
+ return path.as_posix()
118
+
119
+
120
+ def load_visual_record(path: Path, expected_id: str) -> VisualRecord:
121
+ try:
122
+ content = path.read_bytes()
123
+ except OSError as exc:
124
+ raise ConfigurationError(f"cannot read visual record {path}: {exc}") from exc
125
+ try:
126
+ if path.suffix.lower() == ".json":
127
+ raw = json.loads(content)
128
+ else:
129
+ raw = yaml.safe_load(content)
130
+ except (json.JSONDecodeError, yaml.YAMLError, UnicodeError) as exc:
131
+ raise ConfigurationError(f"invalid visual record {path}: {exc}") from exc
132
+ data = _mapping(raw, f"visual record {path}")
133
+ _keys(data, VISUAL_KEYS, f"visual record {path}")
134
+ if data.get("schema") != VISUAL_SCHEMA:
135
+ raise ConfigurationError(
136
+ f"visual record {path} must use schema {VISUAL_SCHEMA}"
137
+ )
138
+ record_id = _identifier(data.get("id"), f"visual record {path}.id")
139
+ if record_id != expected_id:
140
+ raise ConfigurationError(
141
+ f"visual projection id {expected_id!r} does not match record id {record_id!r}"
142
+ )
143
+ kind = data.get("kind")
144
+ if kind not in {"diagram", "screenshot"}:
145
+ raise ConfigurationError(
146
+ f"visual record {path}.kind must be diagram or screenshot"
147
+ )
148
+ annotations_raw = data.get("annotations", [])
149
+ if not isinstance(annotations_raw, list):
150
+ raise ConfigurationError(f"visual record {path}.annotations must be a list")
151
+ annotations: list[VisualAnnotation] = []
152
+ annotation_ids: set[str] = set()
153
+ for index, raw_annotation in enumerate(annotations_raw):
154
+ where = f"visual record {path}.annotations[{index}]"
155
+ item = _mapping(raw_annotation, where)
156
+ _keys(item, ANNOTATION_KEYS, where)
157
+ annotation_id = _identifier(item.get("id"), f"{where}.id")
158
+ if annotation_id in annotation_ids:
159
+ raise ConfigurationError(f"duplicate visual annotation id: {annotation_id}")
160
+ annotation_ids.add(annotation_id)
161
+ annotations.append(
162
+ VisualAnnotation(
163
+ annotation_id,
164
+ _coordinate(item.get("x"), f"{where}.x"),
165
+ _coordinate(item.get("y"), f"{where}.y"),
166
+ _text(item.get("title"), f"{where}.title", one_line=True),
167
+ _text(item.get("description"), f"{where}.description"),
168
+ )
169
+ )
170
+ src_dark_raw = data.get("src_dark")
171
+ return VisualRecord(
172
+ id=record_id,
173
+ kind=kind,
174
+ src=_source(data.get("src"), f"visual record {path}.src"),
175
+ src_dark=(
176
+ _source(src_dark_raw, f"visual record {path}.src_dark")
177
+ if src_dark_raw is not None
178
+ else None
179
+ ),
180
+ width=_dimension(data.get("width"), f"visual record {path}.width"),
181
+ height=_dimension(data.get("height"), f"visual record {path}.height"),
182
+ alt=_text(data.get("alt"), f"visual record {path}.alt", one_line=True),
183
+ caption=_text(
184
+ data.get("caption"), f"visual record {path}.caption", one_line=True
185
+ ),
186
+ description=_text(
187
+ data.get("description"), f"visual record {path}.description"
188
+ ),
189
+ annotations=tuple(annotations),
190
+ digest=hashlib.sha256(content).hexdigest(),
191
+ )
192
+
193
+
194
+ def _asset(root: Path, output: Path, source: str) -> str:
195
+ if REMOTE_SOURCE.match(source):
196
+ return source
197
+ asset = root / source
198
+ try:
199
+ asset.resolve().relative_to(root.resolve())
200
+ except ValueError as exc:
201
+ raise ConfigurationError(f"visual asset escapes the repository: {source}") from exc
202
+ if not asset.is_file():
203
+ raise ConfigurationError(f"visual asset does not exist: {source}")
204
+ return os.path.relpath(asset, (root / output).parent).replace(os.sep, "/")
205
+
206
+
207
+ def _attribute(value: str) -> str:
208
+ return html.escape(value, quote=True)
209
+
210
+
211
+ def _html_text(value: str) -> str:
212
+ return html.escape(value).replace("\n", "<br />\n")
213
+
214
+
215
+ def _style(output: Path, html_value: str, mdx_value: str) -> str:
216
+ if output.suffix.lower() == ".mdx":
217
+ return f"style={{{{{mdx_value}}}}}"
218
+ return f'style="{html_value}"'
219
+
220
+
221
+ def render_human_visual(
222
+ root: Path,
223
+ projection: VisualProjection,
224
+ record: VisualRecord,
225
+ ) -> str:
226
+ src = _asset(root, projection.human_output, record.src)
227
+ dark = (
228
+ _asset(root, projection.human_output, record.src_dark)
229
+ if record.src_dark
230
+ else None
231
+ )
232
+ visual_id = f"visual-{record.id}"
233
+ receipt = (
234
+ f"generated by Sourcebound from {projection.source.as_posix()}; "
235
+ f"sha256: {record.digest}"
236
+ )
237
+ comment = (
238
+ f"{{/* {receipt} */}}"
239
+ if projection.human_output.suffix.lower() == ".mdx"
240
+ else f"<!-- {receipt} -->"
241
+ )
242
+ lines = [
243
+ comment,
244
+ f'<figure id="{visual_id}" data-sourcebound-visual="{VISUAL_SCHEMA}">',
245
+ " <div "
246
+ + _style(
247
+ projection.human_output,
248
+ "position: relative; display: inline-block; max-width: 100%;",
249
+ " position: 'relative', display: 'inline-block', maxWidth: '100%' ",
250
+ )
251
+ + ">",
252
+ " <picture>",
253
+ ]
254
+ if dark:
255
+ source_set = "srcSet" if projection.human_output.suffix.lower() == ".mdx" else "srcset"
256
+ lines.append(
257
+ f' <source media="(prefers-color-scheme: dark)" '
258
+ f'{source_set}="{_attribute(dark)}" />'
259
+ )
260
+ image_style = _style(
261
+ projection.human_output,
262
+ "display: block; max-width: 100%; height: auto;",
263
+ " display: 'block', maxWidth: '100%', height: 'auto' ",
264
+ )
265
+ lines.extend([
266
+ f' <img src="{_attribute(src)}" alt="{_attribute(record.alt)}" '
267
+ f'width="{record.width}" height="{record.height}" '
268
+ f"{image_style} />",
269
+ " </picture>",
270
+ ])
271
+ for number, annotation in enumerate(record.annotations, start=1):
272
+ anchor = f"{visual_id}-annotation-{annotation.id}"
273
+ marker_style = _style(
274
+ projection.human_output,
275
+ f"position: absolute; left: {annotation.x:g}%; top: {annotation.y:g}%; "
276
+ "transform: translate(-50%, -50%); display: inline-flex; "
277
+ "align-items: center; justify-content: center; width: 1.75rem; "
278
+ "height: 1.75rem; border: 2px solid currentColor; border-radius: 999px; "
279
+ "background: Canvas; color: CanvasText; font-weight: 700;",
280
+ " position: 'absolute', "
281
+ f"left: '{annotation.x:g}%', top: '{annotation.y:g}%', "
282
+ "transform: 'translate(-50%, -50%)', display: 'inline-flex', "
283
+ "alignItems: 'center', justifyContent: 'center', width: '1.75rem', "
284
+ "height: '1.75rem', border: '2px solid currentColor', "
285
+ "borderRadius: '999px', background: 'Canvas', color: 'CanvasText', "
286
+ "fontWeight: 700 ",
287
+ )
288
+ lines.append(
289
+ f' <a href="#{anchor}" aria-label="{number}: '
290
+ f'{_attribute(annotation.title)}" {marker_style}>{number}</a>'
291
+ )
292
+ lines.extend([
293
+ " </div>",
294
+ f" <figcaption>{_html_text(record.caption)}</figcaption>",
295
+ f' <p><strong>Description:</strong> {_html_text(record.description)}</p>',
296
+ ])
297
+ if record.annotations:
298
+ lines.append(' <ol aria-label="Annotation key">')
299
+ for annotation in record.annotations:
300
+ anchor = f"{visual_id}-annotation-{annotation.id}"
301
+ lines.append(
302
+ f' <li id="{anchor}"><strong>{_html_text(annotation.title)}</strong>: '
303
+ f'{_html_text(annotation.description)}</li>'
304
+ )
305
+ lines.append(" </ol>")
306
+ lines.extend(["</figure>", ""])
307
+ return "\n".join(lines)
308
+
309
+
310
+ def render_agent_visual(
311
+ root: Path,
312
+ projection: VisualProjection,
313
+ record: VisualRecord,
314
+ ) -> str:
315
+ src = _asset(root, projection.agent_output, record.src)
316
+ lines = [
317
+ f"# Visual: {record.id}",
318
+ "",
319
+ f"- Schema: `{VISUAL_SCHEMA}`",
320
+ f"- Kind: `{record.kind}`",
321
+ f"- Canonical record: `{projection.source.as_posix()}`",
322
+ f"- Record sha256: `{record.digest}`",
323
+ f"- Source image: [{record.src}]({src})",
324
+ ]
325
+ if record.src_dark:
326
+ dark = _asset(root, projection.agent_output, record.src_dark)
327
+ lines.append(f"- Dark source image: [{record.src_dark}]({dark})")
328
+ lines.extend([
329
+ f"- Intrinsic size: {record.width} × {record.height}",
330
+ f"- Alternative text: {record.alt}",
331
+ f"- Caption: {record.caption}",
332
+ "",
333
+ "## Complete text equivalent",
334
+ "",
335
+ record.description,
336
+ ])
337
+ if record.annotations:
338
+ lines.extend(["", "## Annotations", ""])
339
+ for number, annotation in enumerate(record.annotations, start=1):
340
+ lines.extend([
341
+ f"{number}. **{annotation.title}** "
342
+ f"(`x={annotation.x:g}%`, `y={annotation.y:g}%`)",
343
+ f" {annotation.description}",
344
+ ])
345
+ lines.append("")
346
+ return "\n".join(lines)
@@ -0,0 +1,170 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import sys
7
+ from dataclasses import dataclass
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from clean_docs.policy import PolicyFinding
13
+
14
+
15
+ CODE_EXTENSIONS = {
16
+ ".py", ".js", ".ts", ".tsx", ".jsx", ".sh", ".go", ".rs", ".rb", ".java",
17
+ ".sql", ".c", ".cpp", ".swift",
18
+ }
19
+ DATA_EXTENSIONS = {".json", ".jsonl", ".csv", ".yaml", ".yml", ".toml", ".lock", ".plist"}
20
+ EXEMPT_PATH_PARTS = (
21
+ "narrative_content", "golden_corpus", "/gold/", "fixtures", "corpus", ".venv/",
22
+ "node_modules/", "quality-gate", "quality_gate",
23
+ )
24
+ SECRET_RULES = (
25
+ ("secret-aws-key", re.compile(r"\bAKIA[0-9A-Z]{16}\b"), "all"),
26
+ ("secret-github-token", re.compile(r"\b(gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})"), "all"),
27
+ # Fail closed: a long sk- kebab slug can be redacted rather than missed.
28
+ ("secret-openai-key", re.compile(r"\bsk-[A-Za-z0-9_-]{20,}\b"), "all"),
29
+ ("secret-pem-private", re.compile(r"-----BEGIN [A-Z ]{0,20}PRIVATE KEY-----"), "all"),
30
+ ("secret-jwt", re.compile(r"\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b"), "all"),
31
+ ("secret-auth-header-json", re.compile(r'"(authorization|api[_-]?key|apikey|token|secret|password)"\s*:\s*"(?=[^"]*[0-9])(Bearer |Basic |Token )?[A-Za-z0-9_./+=-]{16,}"', re.I), "all"),
32
+ )
33
+ RULES = (
34
+ ("delve", re.compile(r"\bdelv(e|es|ing)\b", re.I), "all"),
35
+ ("important-to-note", re.compile(r"\bit('| i)s important to note\b", re.I), "all"),
36
+ ("fast-paced", re.compile(r"in today's fast-paced", re.I), "all"),
37
+ ("as-an-ai", re.compile(r"\bas an ai\b", re.I), "all"),
38
+ ("assistant-voice", re.compile(r"\b(great question|i'd be happy to|certainly!)\B", re.I), "all"),
39
+ ("marketing-register", re.compile(r"\b(seamlessly integrat|game.chang|revolutioniz|rich tapestry)", re.I), "all"),
40
+ ("utilize-verb", re.compile(r"\butiliz(e|es|ed|ing)\b", re.I), "all"),
41
+ ("in-conclusion", re.compile(r"^\s*in conclusion,", re.I | re.M), "all"),
42
+ ("hedge-stack", re.compile(r"\b(might|may|could)\s+(potentially|possibly|perhaps)\b", re.I), "all"),
43
+ ("unverified-claim", re.compile(r"\bshould (now )?work\b", re.I), "all"),
44
+ ("bare-except", re.compile(r"^\s*except\s*:\s*(#.*)?$", re.M), "code"),
45
+ ("swallowed-exception", re.compile(r"except[^\n]*:\s*(\n\s*)?pass\b"), "code"),
46
+ ("placeholder-cred", re.compile(r"(your[_-]api[_-]key|changeme|<your_|sk-xxxx|lorem ipsum)", re.I), "code"),
47
+ ("deferred-work-marker", re.compile(r"\b(TODO|FIXME|XXX|HACK)\b"), "code"),
48
+ ("comment-meta-narration", re.compile(r"(#\s*this (function|method|class|file)\b|\"\"\"\s*This (function|method|class)\b)", re.I), "code"),
49
+ ("emoji-in-code", re.compile(r"[\U0001F300-\U0001FAFF✅❌✨]"), "code"),
50
+ ) + SECRET_RULES
51
+ PRAGMA = re.compile(r"slop-ok:\s*\S+")
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class WriteGateResult:
56
+ path: str
57
+ findings: tuple[PolicyFinding, ...]
58
+ overridden: tuple[PolicyFinding, ...]
59
+
60
+
61
+ def redact_secrets(text: str) -> tuple[str, tuple[str, ...]]:
62
+ redacted = text
63
+ rules: list[str] = []
64
+ for rule_id, pattern, _applies in SECRET_RULES:
65
+ if pattern.search(redacted):
66
+ rules.append(rule_id)
67
+ redacted = pattern.sub("[REDACTED]", redacted)
68
+ return redacted, tuple(rules)
69
+
70
+
71
+ def _new_content(tool_name: str, tool_input: dict[str, Any]) -> str:
72
+ if tool_name == "Write":
73
+ return str(tool_input.get("content", ""))
74
+ if tool_name == "Edit":
75
+ return str(tool_input.get("new_string", ""))
76
+ if tool_name == "MultiEdit":
77
+ return "\n".join(str(edit.get("new_string", "")) for edit in tool_input.get("edits", []))
78
+ if tool_name == "NotebookEdit":
79
+ return str(tool_input.get("new_source", ""))
80
+ return ""
81
+
82
+
83
+ def _file_class(path: str) -> str:
84
+ extension = Path(path).suffix.lower()
85
+ if extension in CODE_EXTENSIONS:
86
+ return "code"
87
+ if extension in DATA_EXTENSIONS:
88
+ return "data"
89
+ return "prose"
90
+
91
+
92
+ def evaluate_write(payload: dict[str, Any]) -> WriteGateResult:
93
+ """Evaluate one pre-write hook payload without reading or writing the target file."""
94
+ tool_name = str(payload.get("tool_name", ""))
95
+ tool_input = payload.get("tool_input", {})
96
+ if not isinstance(tool_input, dict):
97
+ tool_input = {}
98
+ path = str(tool_input.get("file_path", "") or tool_input.get("notebook_path", ""))
99
+ if any(part in path for part in EXEMPT_PATH_PARTS) or _file_class(path) == "data":
100
+ return WriteGateResult(path, (), ())
101
+ content = _new_content(tool_name, tool_input)
102
+ if not content:
103
+ return WriteGateResult(path, (), ())
104
+ lines = content.splitlines()
105
+ findings: list[PolicyFinding] = []
106
+ overridden: list[PolicyFinding] = []
107
+ file_class = _file_class(path)
108
+ for rule_id, pattern, applies in RULES:
109
+ if applies == "code" and file_class != "code":
110
+ continue
111
+ for match in pattern.finditer(content):
112
+ line_number = content.count("\n", 0, match.start()) + 1
113
+ line = lines[line_number - 1] if line_number <= len(lines) else ""
114
+ finding = PolicyFinding(path, line_number, rule_id, line.strip()[:90])
115
+ (overridden if PRAGMA.search(line) else findings).append(finding)
116
+ return WriteGateResult(path, tuple(findings), tuple(overridden))
117
+
118
+
119
+ def _log(path: Path, entry: dict[str, Any]) -> None:
120
+ try:
121
+ entry["ts"] = datetime.now(timezone.utc).isoformat()
122
+ entry["cwd"] = os.getcwd()
123
+ path.parent.mkdir(parents=True, exist_ok=True)
124
+ with path.open("a", encoding="utf-8") as handle:
125
+ handle.write(json.dumps(entry) + "\n")
126
+ except OSError:
127
+ pass
128
+
129
+
130
+ def main() -> int:
131
+ try:
132
+ payload = json.load(sys.stdin)
133
+ except (json.JSONDecodeError, ValueError):
134
+ return 0
135
+ if not isinstance(payload, dict):
136
+ return 0
137
+ result = evaluate_write(payload)
138
+ log_path = Path(os.path.expanduser("~/.claude/quality_gate_log.jsonl"))
139
+ if result.overridden:
140
+ _log(log_path, {
141
+ "file": result.path,
142
+ "action": "override",
143
+ "hits": [
144
+ {"rule": item.rule, "line": item.line, "snippet": item.detail}
145
+ for item in result.overridden
146
+ ],
147
+ })
148
+ if not result.findings:
149
+ return 0
150
+ _log(log_path, {
151
+ "file": result.path,
152
+ "action": "block",
153
+ "hits": [
154
+ {"rule": item.rule, "line": item.line, "snippet": item.detail}
155
+ for item in result.findings
156
+ ],
157
+ })
158
+ sys.stderr.write(
159
+ f"QUALITY GATE: {len(result.findings)} slop pattern(s) blocked this write to {result.path}.\n"
160
+ "Rewrite the flagged content without the pattern -- do not paraphrase around "
161
+ "it with equivalent filler; state things plainly or cut them.\n"
162
+ )
163
+ for finding in result.findings[:10]:
164
+ sys.stderr.write(f" [{finding.rule}] line {finding.line}: {finding.detail}\n")
165
+ sys.stderr.write(
166
+ "If a flagged line is INTENTIONAL content (test data, authored persona text), "
167
+ "append `slop-ok: <reason>` to that exact line -- overrides are logged and "
168
+ "reviewed at retro.\n"
169
+ )
170
+ return 2
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Owen Schoeniger.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,109 @@
1
+ Metadata-Version: 2.2
2
+ Name: sourcebound
3
+ Version: 1.2.1
4
+ Summary: Bind documentation claims to source evidence and fail CI when they drift.
5
+ License: MIT
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Requires-Dist: PyYAML>=6.0
10
+ Requires-Dist: tomli>=2.0; python_version < "3.11"
11
+ Provides-Extra: dev
12
+ Requires-Dist: build==1.2.2.post1; extra == "dev"
13
+ Requires-Dist: mypy>=1.10; extra == "dev"
14
+ Requires-Dist: pytest>=8.0; extra == "dev"
15
+ Requires-Dist: ruff>=0.6; extra == "dev"
16
+ Requires-Dist: setuptools==75.8.0; extra == "dev"
17
+ Requires-Dist: types-PyYAML>=6.0; extra == "dev"
18
+ Requires-Dist: wheel==0.45.1; extra == "dev"
19
+
20
+ # Sourcebound
21
+
22
+ <!-- sourcebound:policy register-v2 -->
23
+ <!-- sourcebound:purpose -->
24
+ Sourcebound is a documentation engine and CLI for maintainers who need code and prose to change together. It binds selected claims to their defining sources, so drifted documentation fails locally and in CI instead of reaching readers.
25
+ <!-- sourcebound:end purpose -->
26
+
27
+ [![CI](https://github.com/owieschon/sourcebound/actions/workflows/ci.yml/badge.svg)](https://github.com/owieschon/sourcebound/actions/workflows/ci.yml) [![Release](https://img.shields.io/github/v/release/owieschon/sourcebound?display_name=tag&sort=semver)](https://github.com/owieschon/sourcebound/releases/latest) [![License: MIT](https://img.shields.io/badge/license-MIT-25225f.svg)](LICENSE)
28
+
29
+ **[Install the stable release and catch your first stale claim](docs/learn/tutorial-catch-a-lying-doc.md)**.
30
+
31
+ The final `sourcebound verify` command prints a [`sourcebound.outcome.v2` receipt](docs/SUPPORT.md#record-local-outcomes) with `"ok": true`.
32
+
33
+ Before adoption, `audit` reports bounded repository-neutral advisories. A manifest turns integrity checks into gates; policy markers opt compatible writing rules into specific documents. Neither authorizes Sourcebound to flatten repository-native forms.
34
+
35
+ | If you need to... | Start with | You will leave with... |
36
+ | --- | --- | --- |
37
+ | Try the repair loop | [Runnable tutorial](docs/learn/tutorial-catch-a-lying-doc.md) | A failed drift check and a repaired page |
38
+ | Choose a command | [CLI reference](docs/CLI.md) | The command and its write boundary |
39
+ | Configure a binding | [Manifest reference](docs/REFERENCE.md) | A source-bound fact with the right depth |
40
+ | Review a pull request | [Coverage-stating verdict](docs/CLI.md#pull-request-verdicts) | One pinned state with gaps, skips, and non-claims visible |
41
+ | Turn a review issue into tests | [Improvement candidates](docs/IMPROVEMENTS.md) | Separate documentation and product tests without gate authority |
42
+ | Evaluate human or agent tasks | [Evaluation guide](docs/EVALUATION.md) | A replayable result bound to its context and scorer |
43
+ | Build grounded release notes | [Release guide](docs/RELEASES.md) | A factual delta with source evidence |
44
+ | Measure recurring operational problems | [Opt-in feedback loop](docs/FEEDBACK.md) | Bounded envelopes and a receipted improvement case |
45
+ | Understand trust boundaries | [Security model](docs/SECURITY_MODEL.md) | The process and host guarantees |
46
+
47
+ ## Why Sourcebound exists
48
+
49
+ <!-- sourcebound:begin product-overview -->
50
+ A stale sentence does not fail loudly. It keeps a straight face after the code has moved on, and reviewers have no mechanical way to identify the false claim. Sourcebound gives each protected fact a source, then checks that relationship again in CI.
51
+
52
+ Declared sources own the protected facts. A packaged policy enforces the deterministic form floor; authored judgment still owns motivation, pedagogy, and voice. Static adapters read common code and schema formats, while declared commands run under explicit process controls. The engine can repair bound regions, rank static count and column candidates, enforce accepted source-claim relationships, and project canonical text and visual records into purpose-built human and agent surfaces with local receipts.
53
+ <!-- sourcebound:end product-overview -->
54
+
55
+ Human review can improve a sentence. It cannot make the sentence fail when its defining source changes. The [deterministic seam](docs/learn/deep-dive-the-deterministic-seam.md) explains how Sourcebound separates source evidence, optional phrasing, and gate authority.
56
+
57
+ ## Install in the repository you want to protect
58
+
59
+ From that repository, download the latest stable wheel, install it in an isolated environment, and run the manifest-free audit:
60
+
61
+ ```bash
62
+ release_dir="$(mktemp -d)"
63
+ gh release download --repo owieschon/sourcebound \
64
+ --pattern 'sourcebound-*-py3-none-any.whl' --dir "$release_dir"
65
+ python3 -m venv .venv
66
+ source .venv/bin/activate
67
+ python -m pip install "$release_dir"/sourcebound-*.whl
68
+ sourcebound audit
69
+ ```
70
+
71
+ After reviewing the assessment, inspect the files that `init` proposes before accepting its gate:
72
+
73
+ ```bash
74
+ sourcebound init --no-model
75
+ git diff -- .sourcebound.yml .sourcebound/repository-surface.md README.md llms.txt
76
+ sourcebound check
77
+ sourcebound verify
78
+ ```
79
+
80
+ An established, unregistered README stays byte-for-byte authored. Init writes its detected catalog to `.sourcebound/repository-surface.md`; a new README or one that adopted the register may own that region directly.
81
+
82
+ After a bound source changes, run `check`, then use `drive` for a declared repair. Run `project` when a declared projection depends on the repaired document, then run `verify`. The [tutorial](docs/learn/tutorial-catch-a-lying-doc.md) shows the failure before the repair; the [support guide](docs/SUPPORT.md) covers mature-repository adoption.
83
+
84
+ ## How the pieces fit
85
+
86
+ Three inputs stay separate before the deterministic core:
87
+
88
+ - **Authored intent** records why a surface matters. Sourcebound preserves that purpose; it does not infer its priority or turn judgment into gate authority.
89
+ - **Repository contract** declares sources, binding mechanisms, process limits, and projections. Policy markers scope compatible form checks; they do not certify voice.
90
+ - **Change state** combines base and head refs with that contract to produce an immutable impact plan. Static adapters and bounded commands produce typed evidence. Each mechanism proves only its declared relationship; accepted source-claim checks are separate, and unbound prose stays visibly unknown.
91
+
92
+ The core exposes four job-specific exits:
93
+
94
+ 1. **Repair bounded prose.** `drive` writes only planned regions. `project` runs separately when a declared output depends on changed documentation.
95
+ 2. **Reject stale changes.** `check` and `verdict` are read-only. The verdict names changed, bound, unbound, and skipped surfaces.
96
+ 3. **Publish agent context.** `project` writes declared outputs such as `llms.txt` and context bundles.
97
+ 4. **Record local state.** `verify` emits its own outcome receipt.
98
+
99
+ `verdict` and `verify` produce independent receipts. Neither certifies unbound or judgment prose. The [product contract](SOURCEBOUND_SPEC.md) defines each authority boundary.
100
+
101
+ ## Current boundaries
102
+
103
+ - Catalog coverage detects source additions, removals, and replacements; it does not validate prose.
104
+ - Source-claim discovery ranks static count and identifier-set candidates. A candidate remains advisory until the repository accepts its exact document and source relationship.
105
+ - Declared processes use time, I/O, and environment controls. The host owns network isolation; see the [security model](docs/SECURITY_MODEL.md).
106
+ - The manifest decides what Sourcebound evaluates. Authored purpose records goals; Sourcebound does not infer or certify them.
107
+ - Feedback is off by default. Enabled runs queue bounded local envelopes; only an explicit `feedback flush` contacts the configured sink, and delivery cannot change a gate result.
108
+
109
+ Use the [learning path](docs/learn/index.md) for examples. The [product contract](SOURCEBOUND_SPEC.md) owns parser, write-boundary, and exit-code details.