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/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)
|
clean_docs/write_gate.py
ADDED
|
@@ -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
|
+
[](https://github.com/owieschon/sourcebound/actions/workflows/ci.yml) [](https://github.com/owieschon/sourcebound/releases/latest) [](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.
|