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/manifest.py ADDED
@@ -0,0 +1,898 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import yaml
9
+
10
+ from clean_docs.errors import ConfigurationError
11
+ from clean_docs.execution import PYTHON_EXECUTABLE_TOKEN
12
+ from clean_docs.models import (
13
+ Assertion,
14
+ Binding,
15
+ ClaimBinding,
16
+ CommandSpec,
17
+ ContextBundleProjection,
18
+ LlmsTxtProjection,
19
+ Manifest,
20
+ PLUGIN_API_VERSION,
21
+ PLUGIN_INTERFACES,
22
+ PluginSpec,
23
+ PublicDisposition,
24
+ ProjectionConfig,
25
+ RegionBinding,
26
+ ReviewContract,
27
+ ReviewLocator,
28
+ Source,
29
+ SourceClaimCheck,
30
+ SymbolBinding,
31
+ StaticDemoProjection,
32
+ VisualProjection,
33
+ )
34
+ from clean_docs.review_limits import (
35
+ MAX_REVIEW_CONTRACTS,
36
+ MAX_REVIEW_LOCATORS,
37
+ MAX_REVIEW_LOCATORS_PER_CONTRACT,
38
+ MAX_REVIEW_UNIQUE_PATHS,
39
+ )
40
+
41
+ ROOT_KEYS = {
42
+ "version",
43
+ "bindings",
44
+ "execution",
45
+ "plugins",
46
+ "projections",
47
+ "review_contracts",
48
+ "public_dispositions",
49
+ "source_claim_checks",
50
+ }
51
+ BINDING_KEYS = {
52
+ "id", "type", "doc", "region", "anchor", "extractor", "source", "renderer",
53
+ "columns", "language", "command", "assertion",
54
+ }
55
+ SOURCE_KEYS = {"path", "symbol", "pointer", "glob"}
56
+ EXTRACTORS = {
57
+ "file", "json", "path", "python-literal", "repository-inventory",
58
+ "repository-overview", "structured-data",
59
+ }
60
+ RENDERERS = {
61
+ "fenced-text", "markdown-fragment", "markdown-list", "markdown-table", "scalar"
62
+ }
63
+ EXECUTION_KEYS = {"commands", "allowed_commands"}
64
+ COMMAND_KEYS = {"argv", "timeout_seconds"}
65
+ LEGACY_COMMAND_KEYS = COMMAND_KEYS | {"network"}
66
+ ASSERTION_KEYS = {"json_path", "operator", "expected", "prose"}
67
+ PROJECTION_KEYS = {"llms_txt", "bundles", "demo", "visuals"}
68
+ LLMS_TXT_KEYS = {"output", "title", "summary", "include"}
69
+ BUNDLE_KEYS = {"id", "output", "include"}
70
+ DEMO_KEYS = {"output", "evidence"}
71
+ VISUAL_KEYS = {"id", "source", "human_output", "agent_output"}
72
+ PLUGIN_KEYS = {"id", "api_version", "interfaces", "argv", "timeout_seconds"}
73
+ SOURCE_CLAIM_CHECK_KEYS = {
74
+ "id",
75
+ "kind",
76
+ "doc",
77
+ "anchor",
78
+ "subject",
79
+ "source",
80
+ "locator",
81
+ }
82
+ SOURCE_CLAIM_KINDS = {"count", "identifier-set"}
83
+ REVIEW_CONTRACT_KEYS = {"id", "mode", "sources", "targets"}
84
+ REVIEW_LOCATOR_KEYS = {"id", "path", "extractor", "locator"}
85
+ REVIEW_EXTRACTORS = {"markdown-section", "python-symbol", "structured-data"}
86
+ REVIEW_EXTRACTOR_SUFFIXES = {
87
+ "markdown-section": {".md", ".mdx"},
88
+ "python-symbol": {".py"},
89
+ "structured-data": {".json", ".toml", ".yaml", ".yml"},
90
+ }
91
+ PUBLIC_DISPOSITION_KEYS = {
92
+ "base", "kind", "subject", "documentation", "replacement", "reason"
93
+ }
94
+ MANIFEST_REFERENCE = (
95
+ {
96
+ "binding": "region",
97
+ "required": "id, type, doc, region, extractor, source, renderer",
98
+ "verifies": "Generated content matches source evidence",
99
+ },
100
+ {
101
+ "binding": "claim",
102
+ "required": "id, type, doc, anchor, command, assertion",
103
+ "verifies": "Command output; declared reader-facing prose when configured",
104
+ },
105
+ {
106
+ "binding": "symbol",
107
+ "required": "id, type, doc, anchor, source",
108
+ "verifies": "A source path or Python symbol still exists",
109
+ },
110
+ )
111
+
112
+
113
+ def _mapping(value: Any, where: str) -> dict[str, Any]:
114
+ if not isinstance(value, dict):
115
+ raise ConfigurationError(f"{where} must be a mapping")
116
+ return value
117
+
118
+
119
+ def _reject_unknown(value: dict[str, Any], allowed: set[str], where: str) -> None:
120
+ unknown = sorted(set(value) - allowed)
121
+ if unknown:
122
+ raise ConfigurationError(f"{where} has unknown key(s): {', '.join(unknown)}")
123
+
124
+
125
+ def _relative_path(raw: Any, where: str) -> Path:
126
+ if not isinstance(raw, str) or not raw.strip():
127
+ raise ConfigurationError(f"{where} must be a non-empty path")
128
+ path = Path(raw)
129
+ if path.is_absolute() or ".." in path.parts:
130
+ raise ConfigurationError(f"{where} must stay inside the repository: {raw}")
131
+ return path
132
+
133
+
134
+ def _one_line(raw: Any, where: str) -> str | None:
135
+ if raw is None:
136
+ return None
137
+ if not isinstance(raw, str) or not raw.strip() or "\n" in raw or "\r" in raw:
138
+ raise ConfigurationError(f"{where} must be one non-empty line")
139
+ return raw.strip()
140
+
141
+
142
+ def _required_string(raw: Any, where: str) -> str:
143
+ if (
144
+ not isinstance(raw, str)
145
+ or not raw.strip()
146
+ or "\n" in raw
147
+ or "\r" in raw
148
+ ):
149
+ raise ConfigurationError(f"{where} must be one non-empty line")
150
+ return raw.strip()
151
+
152
+
153
+ def _valid_json_pointer(pointer: str) -> bool:
154
+ if not pointer.startswith("/"):
155
+ return False
156
+ index = 0
157
+ while index < len(pointer):
158
+ if pointer[index] != "~":
159
+ index += 1
160
+ continue
161
+ if index + 1 >= len(pointer) or pointer[index + 1] not in {"0", "1"}:
162
+ return False
163
+ index += 2
164
+ return True
165
+
166
+
167
+ def _load_review_locator(raw: Any, where: str) -> ReviewLocator:
168
+ data = _mapping(raw, where)
169
+ _reject_unknown(data, REVIEW_LOCATOR_KEYS, where)
170
+ locator_id = _required_string(data.get("id"), f"{where}.id")
171
+ path = _relative_path(data.get("path"), f"{where}.path")
172
+ extractor = data.get("extractor")
173
+ if extractor not in REVIEW_EXTRACTORS:
174
+ raise ConfigurationError(
175
+ f"{where}.extractor must be one of: "
176
+ + ", ".join(sorted(REVIEW_EXTRACTORS))
177
+ )
178
+ locator = _required_string(data.get("locator"), f"{where}.locator")
179
+ suffixes = REVIEW_EXTRACTOR_SUFFIXES[extractor]
180
+ if path.suffix.lower() not in suffixes:
181
+ expected = ", ".join(sorted(suffixes))
182
+ raise ConfigurationError(
183
+ f"{where}.path must end with one of {expected} for {extractor}"
184
+ )
185
+ if extractor == "python-symbol" and not all(
186
+ part.isidentifier() for part in locator.split(".")
187
+ ):
188
+ raise ConfigurationError(
189
+ f"{where}.locator must be a dotted Python identifier for python-symbol"
190
+ )
191
+ if extractor == "markdown-section" and (
192
+ not locator.startswith("#")
193
+ or len(locator) == 1
194
+ or "#" in locator[1:]
195
+ or any(character.isspace() for character in locator)
196
+ ):
197
+ raise ConfigurationError(
198
+ f"{where}.locator must be a #fragment anchor for markdown-section"
199
+ )
200
+ if extractor == "structured-data" and not _valid_json_pointer(locator):
201
+ raise ConfigurationError(
202
+ f"{where}.locator must be a JSON Pointer starting with / for structured-data"
203
+ )
204
+ return ReviewLocator(locator_id, path, extractor, locator)
205
+
206
+
207
+ def _load_review_contracts(raw: Any) -> tuple[ReviewContract, ...]:
208
+ if not isinstance(raw, list):
209
+ raise ConfigurationError("review_contracts must be a list")
210
+ if len(raw) > MAX_REVIEW_CONTRACTS:
211
+ raise ConfigurationError(
212
+ "review_contracts must contain at most "
213
+ f"{MAX_REVIEW_CONTRACTS} contracts"
214
+ )
215
+ contracts: list[ReviewContract] = []
216
+ contract_ids: set[str] = set()
217
+ total_locators = 0
218
+ unique_paths: set[Path] = set()
219
+ for index, raw_contract in enumerate(raw):
220
+ where = f"review_contracts[{index}]"
221
+ data = _mapping(raw_contract, where)
222
+ _reject_unknown(data, REVIEW_CONTRACT_KEYS, where)
223
+ contract_id = _required_string(data.get("id"), f"{where}.id")
224
+ if contract_id in contract_ids:
225
+ raise ConfigurationError(f"duplicate review contract id: {contract_id}")
226
+ contract_ids.add(contract_id)
227
+ mode = data.get("mode")
228
+ if mode != "observe":
229
+ raise ConfigurationError(f"{where}.mode must be observe")
230
+
231
+ locator_ids: set[str] = set()
232
+ groups: dict[str, tuple[ReviewLocator, ...]] = {}
233
+ for group in ("sources", "targets"):
234
+ raw_locators = data.get(group)
235
+ if not isinstance(raw_locators, list) or not raw_locators:
236
+ raise ConfigurationError(f"{where}.{group} must be a non-empty list")
237
+ locators = tuple(
238
+ _load_review_locator(
239
+ raw_locator,
240
+ f"{where}.{group}[{locator_index}]",
241
+ )
242
+ for locator_index, raw_locator in enumerate(raw_locators)
243
+ )
244
+ for locator in locators:
245
+ if locator.id in locator_ids:
246
+ raise ConfigurationError(
247
+ f"duplicate review locator id in {contract_id}: {locator.id}"
248
+ )
249
+ locator_ids.add(locator.id)
250
+ identities = {
251
+ (locator.path, locator.extractor, locator.locator)
252
+ for locator in locators
253
+ }
254
+ if len(identities) != len(locators):
255
+ raise ConfigurationError(
256
+ f"review contract {contract_id} {group} must not repeat "
257
+ "the same path, extractor, and locator"
258
+ )
259
+ groups[group] = locators
260
+ contract_locator_count = len(groups["sources"]) + len(groups["targets"])
261
+ if contract_locator_count > MAX_REVIEW_LOCATORS_PER_CONTRACT:
262
+ raise ConfigurationError(
263
+ f"review contract {contract_id} must contain at most "
264
+ f"{MAX_REVIEW_LOCATORS_PER_CONTRACT} locators"
265
+ )
266
+ total_locators += contract_locator_count
267
+ if total_locators > MAX_REVIEW_LOCATORS:
268
+ raise ConfigurationError(
269
+ "review_contracts must contain at most "
270
+ f"{MAX_REVIEW_LOCATORS} locators"
271
+ )
272
+ unique_paths.update(
273
+ locator.path
274
+ for locator in groups["sources"] + groups["targets"]
275
+ )
276
+ if len(unique_paths) > MAX_REVIEW_UNIQUE_PATHS:
277
+ raise ConfigurationError(
278
+ "review_contracts must reference at most "
279
+ f"{MAX_REVIEW_UNIQUE_PATHS} unique paths"
280
+ )
281
+ source_identities = {
282
+ (locator.path, locator.extractor, locator.locator)
283
+ for locator in groups["sources"]
284
+ }
285
+ target_identities = {
286
+ (locator.path, locator.extractor, locator.locator)
287
+ for locator in groups["targets"]
288
+ }
289
+ if source_identities & target_identities:
290
+ raise ConfigurationError(
291
+ f"review contract {contract_id} source and target locators "
292
+ "must not have the same path, extractor, and locator"
293
+ )
294
+ contracts.append(
295
+ ReviewContract(
296
+ id=contract_id,
297
+ mode=mode,
298
+ sources=groups["sources"],
299
+ targets=groups["targets"],
300
+ )
301
+ )
302
+ return tuple(contracts)
303
+
304
+
305
+ def _load_public_dispositions(
306
+ raw: Any,
307
+ *,
308
+ root: Path,
309
+ ) -> tuple[PublicDisposition, ...]:
310
+ if raw is None:
311
+ return ()
312
+ if not isinstance(raw, list):
313
+ raise ConfigurationError("public_dispositions must be a list")
314
+ dispositions: list[PublicDisposition] = []
315
+ subjects: set[tuple[str, str]] = set()
316
+ for index, record in enumerate(raw):
317
+ where = f"public_dispositions[{index}]"
318
+ data = _mapping(record, where)
319
+ _reject_unknown(data, PUBLIC_DISPOSITION_KEYS, where)
320
+ base = _required_string(data.get("base"), f"{where}.base")
321
+ if re.fullmatch(r"[0-9a-f]{40}", base) is None:
322
+ raise ConfigurationError(f"{where}.base must be a full Git SHA")
323
+ kind = _required_string(data.get("kind"), f"{where}.kind")
324
+ if kind not in {"event", "artifact"}:
325
+ raise ConfigurationError(f"{where}.kind must be event or artifact")
326
+ subject = _required_string(data.get("subject"), f"{where}.subject")
327
+ if re.fullmatch(r"[0-9a-f]{64}", subject) is None:
328
+ raise ConfigurationError(f"{where}.subject must be a SHA-256")
329
+ if (kind, subject) in subjects:
330
+ raise ConfigurationError(
331
+ f"duplicate public disposition subject: {kind}:{subject}"
332
+ )
333
+ subjects.add((kind, subject))
334
+ documentation = _relative_path(
335
+ data.get("documentation"), f"{where}.documentation"
336
+ )
337
+ if documentation.suffix.lower() not in {".md", ".mdx"}:
338
+ raise ConfigurationError(f"{where}.documentation must be Markdown or MDX")
339
+ replacement = _required_string(data.get("replacement"), f"{where}.replacement")
340
+ reason = _required_string(data.get("reason"), f"{where}.reason")
341
+ if len(reason) < 12:
342
+ raise ConfigurationError(f"{where}.reason needs a specific explanation")
343
+ try:
344
+ documentation_text = (root / documentation).read_text(encoding="utf-8")
345
+ except OSError as exc:
346
+ raise ConfigurationError(
347
+ f"{where}.documentation cannot be read: {documentation}"
348
+ ) from exc
349
+ if replacement not in documentation_text:
350
+ raise ConfigurationError(
351
+ f"{where}.documentation must name replacement {replacement!r}"
352
+ )
353
+ dispositions.append(
354
+ PublicDisposition(base, kind, subject, documentation, replacement, reason)
355
+ )
356
+ return tuple(dispositions)
357
+
358
+
359
+ def _load_projections(raw: Any, bound_docs: set[Path]) -> ProjectionConfig | None:
360
+ if raw is None:
361
+ return None
362
+ data = _mapping(raw, "projections")
363
+ _reject_unknown(data, PROJECTION_KEYS, "projections")
364
+ llms_txt = None
365
+ raw_llms = data.get("llms_txt")
366
+ if raw_llms is not None:
367
+ item = _mapping(raw_llms, "projections.llms_txt")
368
+ _reject_unknown(item, LLMS_TXT_KEYS, "projections.llms_txt")
369
+ raw_include = item.get("include", [])
370
+ if not isinstance(raw_include, list) or not all(
371
+ isinstance(path, str) for path in raw_include
372
+ ):
373
+ raise ConfigurationError("projections.llms_txt.include must be a path list")
374
+ include = tuple(
375
+ _relative_path(path, "projections.llms_txt.include") for path in raw_include
376
+ )
377
+ if len(set(include)) != len(include):
378
+ raise ConfigurationError("projections.llms_txt.include must not contain duplicates")
379
+ llms_txt = LlmsTxtProjection(
380
+ output=_relative_path(item.get("output"), "projections.llms_txt.output"),
381
+ title=_one_line(item.get("title"), "projections.llms_txt.title"),
382
+ summary=_one_line(item.get("summary"), "projections.llms_txt.summary"),
383
+ include=include,
384
+ )
385
+ if llms_txt.output in include:
386
+ raise ConfigurationError(
387
+ "projections.llms_txt.output cannot also be a source document"
388
+ )
389
+ bundles: list[ContextBundleProjection] = []
390
+ raw_bundles = data.get("bundles", [])
391
+ if not isinstance(raw_bundles, list):
392
+ raise ConfigurationError("projections.bundles must be a list")
393
+ bundle_ids: set[str] = set()
394
+ outputs = {llms_txt.output} if llms_txt else set()
395
+ for index, raw_bundle in enumerate(raw_bundles):
396
+ where = f"projections.bundles[{index}]"
397
+ item = _mapping(raw_bundle, where)
398
+ _reject_unknown(item, BUNDLE_KEYS, where)
399
+ bundle_id = item.get("id")
400
+ if not isinstance(bundle_id, str) or not bundle_id.strip():
401
+ raise ConfigurationError(f"{where}.id must be a non-empty string")
402
+ if bundle_id in bundle_ids:
403
+ raise ConfigurationError(f"duplicate context bundle id: {bundle_id}")
404
+ bundle_ids.add(bundle_id)
405
+ output = _relative_path(item.get("output"), f"{where}.output")
406
+ if output in outputs:
407
+ raise ConfigurationError(f"duplicate projection output: {output}")
408
+ outputs.add(output)
409
+ raw_include = item.get("include")
410
+ if (
411
+ not isinstance(raw_include, list)
412
+ or not raw_include
413
+ or not all(isinstance(path, str) for path in raw_include)
414
+ ):
415
+ raise ConfigurationError(f"{where}.include must be a non-empty path list")
416
+ include = tuple(_relative_path(path, f"{where}.include") for path in raw_include)
417
+ if len(set(include)) != len(include):
418
+ raise ConfigurationError(f"{where}.include must not contain duplicates")
419
+ unknown = sorted(path.as_posix() for path in include if path not in bound_docs)
420
+ if unknown:
421
+ raise ConfigurationError(
422
+ f"{where}.include names unbound document(s): {', '.join(unknown)}"
423
+ )
424
+ if output in include:
425
+ raise ConfigurationError(f"{where}.output cannot also be a source document")
426
+ bundles.append(ContextBundleProjection(bundle_id, output, include))
427
+ demo = None
428
+ raw_demo = data.get("demo")
429
+ if raw_demo is not None:
430
+ item = _mapping(raw_demo, "projections.demo")
431
+ _reject_unknown(item, DEMO_KEYS, "projections.demo")
432
+ output = _relative_path(item.get("output"), "projections.demo.output")
433
+ evidence = _relative_path(item.get("evidence"), "projections.demo.evidence")
434
+ if output.suffix.lower() != ".html":
435
+ raise ConfigurationError("projections.demo.output must be an HTML file")
436
+ if output in outputs:
437
+ raise ConfigurationError(f"duplicate projection output: {output}")
438
+ outputs.add(output)
439
+ demo = StaticDemoProjection(output, evidence)
440
+ visuals: list[VisualProjection] = []
441
+ raw_visuals = data.get("visuals", [])
442
+ if not isinstance(raw_visuals, list):
443
+ raise ConfigurationError("projections.visuals must be a list")
444
+ visual_ids: set[str] = set()
445
+ for index, raw_visual in enumerate(raw_visuals):
446
+ where = f"projections.visuals[{index}]"
447
+ item = _mapping(raw_visual, where)
448
+ _reject_unknown(item, VISUAL_KEYS, where)
449
+ visual_id = item.get("id")
450
+ if (
451
+ not isinstance(visual_id, str)
452
+ or re.fullmatch(r"[a-z][a-z0-9]*(?:-[a-z0-9]+)*", visual_id) is None
453
+ ):
454
+ raise ConfigurationError(f"{where}.id must be a kebab-case identifier")
455
+ if visual_id in visual_ids:
456
+ raise ConfigurationError(f"duplicate visual projection id: {visual_id}")
457
+ visual_ids.add(visual_id)
458
+ source = _relative_path(item.get("source"), f"{where}.source")
459
+ if source.suffix.lower() not in {".json", ".yaml", ".yml"}:
460
+ raise ConfigurationError(f"{where}.source must be JSON or YAML")
461
+ human_output = _relative_path(
462
+ item.get("human_output"), f"{where}.human_output"
463
+ )
464
+ if human_output.suffix.lower() not in {".md", ".mdx"}:
465
+ raise ConfigurationError(f"{where}.human_output must be Markdown or MDX")
466
+ agent_output = _relative_path(
467
+ item.get("agent_output"), f"{where}.agent_output"
468
+ )
469
+ if agent_output.suffix.lower() != ".md":
470
+ raise ConfigurationError(f"{where}.agent_output must be Markdown")
471
+ if human_output == agent_output:
472
+ raise ConfigurationError(f"{where} outputs must be distinct")
473
+ for output in (human_output, agent_output):
474
+ if output in outputs:
475
+ raise ConfigurationError(f"duplicate projection output: {output}")
476
+ if output in bound_docs:
477
+ raise ConfigurationError(
478
+ f"{where} output cannot replace a bound document: {output}"
479
+ )
480
+ if output == source:
481
+ raise ConfigurationError(f"{where}.source cannot also be an output")
482
+ outputs.add(output)
483
+ visuals.append(
484
+ VisualProjection(visual_id, source, human_output, agent_output)
485
+ )
486
+ if llms_txt is None and not bundles and demo is None and not visuals:
487
+ raise ConfigurationError(
488
+ "projections must configure llms_txt, a bundle, a demo, or visuals"
489
+ )
490
+ return ProjectionConfig(
491
+ llms_txt=llms_txt,
492
+ bundles=tuple(bundles),
493
+ demo=demo,
494
+ visuals=tuple(visuals),
495
+ )
496
+
497
+
498
+ def load_manifest(path: Path) -> Manifest:
499
+ try:
500
+ raw = yaml.safe_load(path.read_text(encoding="utf-8"))
501
+ except OSError as exc:
502
+ raise ConfigurationError(f"cannot read manifest {path}: {exc}") from exc
503
+ except yaml.YAMLError as exc:
504
+ raise ConfigurationError(f"invalid YAML in {path}: {exc}") from exc
505
+
506
+ root = _mapping(raw, "manifest")
507
+ _reject_unknown(root, ROOT_KEYS, "manifest")
508
+ version = root.get("version")
509
+ if version not in {1, 2}:
510
+ raise ConfigurationError("manifest version must be 1 or 2")
511
+ raw_bindings = root.get("bindings")
512
+ if not isinstance(raw_bindings, list) or not raw_bindings:
513
+ raise ConfigurationError("manifest bindings must be a non-empty list")
514
+
515
+ plugins: list[PluginSpec] = []
516
+ raw_plugins = root.get("plugins", [])
517
+ if not isinstance(raw_plugins, list):
518
+ raise ConfigurationError("manifest plugins must be a list")
519
+ plugin_ids: set[str] = set()
520
+ for index, raw_plugin in enumerate(raw_plugins):
521
+ where = f"plugins[{index}]"
522
+ plugin_data = _mapping(raw_plugin, where)
523
+ _reject_unknown(plugin_data, PLUGIN_KEYS, where)
524
+ plugin_id = plugin_data.get("id")
525
+ if not isinstance(plugin_id, str) or not plugin_id or not plugin_id.replace("-", "").isalnum():
526
+ raise ConfigurationError(f"{where}.id must contain letters, numbers, and hyphens")
527
+ if plugin_id in plugin_ids:
528
+ raise ConfigurationError(f"duplicate plugin id: {plugin_id}")
529
+ plugin_ids.add(plugin_id)
530
+ api_version = plugin_data.get("api_version")
531
+ if api_version != PLUGIN_API_VERSION:
532
+ raise ConfigurationError(
533
+ f"plugin {plugin_id} API version {api_version} is incompatible; "
534
+ f"sourcebound supports {PLUGIN_API_VERSION}"
535
+ )
536
+ interfaces = plugin_data.get("interfaces")
537
+ if (
538
+ not isinstance(interfaces, list)
539
+ or not interfaces
540
+ or not all(isinstance(item, str) and item in PLUGIN_INTERFACES for item in interfaces)
541
+ or len(set(interfaces)) != len(interfaces)
542
+ ):
543
+ raise ConfigurationError(
544
+ f"{where}.interfaces must be unique values from: "
545
+ + ", ".join(sorted(PLUGIN_INTERFACES))
546
+ )
547
+ argv = plugin_data.get("argv")
548
+ if not isinstance(argv, list) or not argv or not all(
549
+ isinstance(item, str) and item for item in argv
550
+ ):
551
+ raise ConfigurationError(f"{where}.argv must be a non-empty string list")
552
+ timeout = plugin_data.get("timeout_seconds", 30)
553
+ if not isinstance(timeout, int) or not 1 <= timeout <= 120:
554
+ raise ConfigurationError(f"{where}.timeout_seconds must be 1..120")
555
+ plugins.append(
556
+ PluginSpec(plugin_id, api_version, tuple(interfaces), tuple(argv), timeout)
557
+ )
558
+
559
+ commands: list[CommandSpec] = []
560
+ deprecations: list[str] = []
561
+ execution = root.get("execution")
562
+ if execution is not None:
563
+ execution_data = _mapping(execution, "execution")
564
+ _reject_unknown(execution_data, EXECUTION_KEYS, "execution")
565
+ if execution_data.get("commands", "deny") != "deny":
566
+ raise ConfigurationError("execution.commands must be deny")
567
+ allowed = _mapping(execution_data.get("allowed_commands", {}), "execution.allowed_commands")
568
+ for command_id, raw_command in allowed.items():
569
+ where = f"execution.allowed_commands.{command_id}"
570
+ command = _mapping(raw_command, where)
571
+ _reject_unknown(
572
+ command,
573
+ LEGACY_COMMAND_KEYS if version == 1 else COMMAND_KEYS,
574
+ where,
575
+ )
576
+ argv = command.get("argv")
577
+ timeout = command.get("timeout_seconds", 30)
578
+ if not isinstance(argv, list) or not argv or not all(isinstance(item, str) and item for item in argv):
579
+ raise ConfigurationError(f"execution.allowed_commands.{command_id}.argv must be a non-empty string list")
580
+ if PYTHON_EXECUTABLE_TOKEN in argv[1:]:
581
+ raise ConfigurationError(
582
+ f"execution.allowed_commands.{command_id}.argv may use "
583
+ f"{PYTHON_EXECUTABLE_TOKEN} only as its executable"
584
+ )
585
+ if not isinstance(timeout, int) or not 1 <= timeout <= 300:
586
+ raise ConfigurationError(f"execution.allowed_commands.{command_id}.timeout_seconds must be 1..300")
587
+ if version == 1 and command.get("network", False) is not False:
588
+ raise ConfigurationError(f"execution.allowed_commands.{command_id}.network must be false")
589
+ if version == 1 and "network" in command:
590
+ deprecations.append(f"{where}.network")
591
+ commands.append(CommandSpec(command_id, tuple(argv), timeout))
592
+
593
+ source_claim_checks: list[SourceClaimCheck] = []
594
+ raw_source_claim_checks = root.get("source_claim_checks", [])
595
+ if not isinstance(raw_source_claim_checks, list):
596
+ raise ConfigurationError("source_claim_checks must be a list")
597
+ source_claim_ids: set[str] = set()
598
+ for index, raw_check in enumerate(raw_source_claim_checks):
599
+ where = f"source_claim_checks[{index}]"
600
+ check = _mapping(raw_check, where)
601
+ _reject_unknown(check, SOURCE_CLAIM_CHECK_KEYS, where)
602
+ check_id = check.get("id")
603
+ if not isinstance(check_id, str) or not check_id.strip():
604
+ raise ConfigurationError(f"{where}.id must be a non-empty string")
605
+ if check_id in source_claim_ids:
606
+ raise ConfigurationError(f"duplicate source claim check id: {check_id}")
607
+ source_claim_ids.add(check_id)
608
+ kind = check.get("kind")
609
+ if kind not in SOURCE_CLAIM_KINDS:
610
+ raise ConfigurationError(
611
+ f"{where}.kind must be one of: {', '.join(sorted(SOURCE_CLAIM_KINDS))}"
612
+ )
613
+ anchor = check.get("anchor")
614
+ if not isinstance(anchor, str) or not anchor.strip():
615
+ raise ConfigurationError(f"{where}.anchor must be a non-empty string")
616
+ subject = check.get("subject")
617
+ if (
618
+ not isinstance(subject, str)
619
+ or not subject.strip()
620
+ or "\n" in subject
621
+ or "\r" in subject
622
+ ):
623
+ raise ConfigurationError(f"{where}.subject must be one non-empty line")
624
+ locator = check.get("locator")
625
+ if (
626
+ not isinstance(locator, str)
627
+ or not locator.strip()
628
+ or "\n" in locator
629
+ or "\r" in locator
630
+ ):
631
+ raise ConfigurationError(f"{where}.locator must be one non-empty line")
632
+ required_suffix = "#count" if kind == "count" else "#keys"
633
+ if not locator.endswith(required_suffix):
634
+ raise ConfigurationError(
635
+ f"{where}.locator must end with {required_suffix} for {kind}"
636
+ )
637
+ source_claim_checks.append(
638
+ SourceClaimCheck(
639
+ id=check_id,
640
+ kind=kind,
641
+ doc=_relative_path(check.get("doc"), f"{where}.doc"),
642
+ anchor=anchor.strip(),
643
+ subject=subject.strip(),
644
+ source=_relative_path(check.get("source"), f"{where}.source"),
645
+ locator=locator.strip(),
646
+ )
647
+ )
648
+
649
+ bindings: list[Binding] = []
650
+ ids: set[str] = set()
651
+ for index, item in enumerate(raw_bindings):
652
+ where = f"bindings[{index}]"
653
+ data = _mapping(item, where)
654
+ _reject_unknown(data, BINDING_KEYS, where)
655
+ binding_id = data.get("id")
656
+ if not isinstance(binding_id, str) or not binding_id.strip():
657
+ raise ConfigurationError(f"{where}.id must be a non-empty string")
658
+ if binding_id in ids:
659
+ raise ConfigurationError(f"duplicate binding id: {binding_id}")
660
+ ids.add(binding_id)
661
+ binding_type = data.get("type")
662
+ if binding_type not in {item["binding"] for item in MANIFEST_REFERENCE}:
663
+ raise ConfigurationError(f"{where}.type must be region, claim, or symbol")
664
+ if binding_type == "claim":
665
+ if data.get("extractor") != "command":
666
+ raise ConfigurationError(f"{where}.extractor must be command for a claim")
667
+ command_ref = data.get("command")
668
+ if not isinstance(command_ref, str) or command_ref not in {item.id for item in commands}:
669
+ raise ConfigurationError(f"{where}.command must name an allowed command")
670
+ assertion_data = _mapping(data.get("assertion"), f"{where}.assertion")
671
+ _reject_unknown(assertion_data, ASSERTION_KEYS, f"{where}.assertion")
672
+ json_path = assertion_data.get("json_path")
673
+ if not isinstance(json_path, str) or not json_path.startswith("$."):
674
+ raise ConfigurationError(f"{where}.assertion.json_path must start with $.")
675
+ if assertion_data.get("operator") != "equals":
676
+ raise ConfigurationError(f"{where}.assertion.operator must be equals")
677
+ prose = assertion_data.get("prose")
678
+ if prose is not None and (not isinstance(prose, str) or not prose.strip()):
679
+ raise ConfigurationError(f"{where}.assertion.prose must be non-empty when configured")
680
+ expected_text = json.dumps(assertion_data.get("expected"), sort_keys=True)
681
+ if prose is not None and expected_text not in prose:
682
+ raise ConfigurationError(
683
+ f"{where}.assertion.prose must include the JSON representation of expected"
684
+ )
685
+ anchor = data.get("anchor")
686
+ if not isinstance(anchor, str) or not anchor:
687
+ raise ConfigurationError(f"{where}.anchor must be non-empty")
688
+ bindings.append(ClaimBinding(
689
+ id=binding_id,
690
+ doc=_relative_path(data.get("doc"), f"{where}.doc"),
691
+ anchor=anchor,
692
+ extractor="command",
693
+ command=command_ref,
694
+ assertion=Assertion(
695
+ json_path,
696
+ "equals",
697
+ assertion_data.get("expected"),
698
+ prose,
699
+ ),
700
+ ))
701
+ continue
702
+
703
+ source_data = _mapping(data.get("source"), f"{where}.source")
704
+ _reject_unknown(source_data, SOURCE_KEYS, f"{where}.source")
705
+ if binding_type == "symbol":
706
+ symbol = source_data.get("symbol")
707
+ if symbol is not None and (not isinstance(symbol, str) or not symbol.isidentifier()):
708
+ raise ConfigurationError(f"{where}.source.symbol must be a Python identifier")
709
+ anchor = data.get("anchor")
710
+ if not isinstance(anchor, str) or not anchor:
711
+ raise ConfigurationError(f"{where}.anchor must be non-empty")
712
+ bindings.append(SymbolBinding(
713
+ id=binding_id,
714
+ doc=_relative_path(data.get("doc"), f"{where}.doc"),
715
+ anchor=anchor,
716
+ source=Source(
717
+ path=_relative_path(source_data.get("path"), f"{where}.source.path"),
718
+ symbol=symbol,
719
+ ),
720
+ ))
721
+ continue
722
+
723
+ extractor = data.get("extractor")
724
+ if not isinstance(extractor, str):
725
+ raise ConfigurationError(f"{where}.extractor must be a non-empty string")
726
+ plugin_extractor = (
727
+ extractor.removeprefix("plugin:")
728
+ if extractor.startswith("plugin:")
729
+ else None
730
+ )
731
+ if extractor not in EXTRACTORS and plugin_extractor is None:
732
+ raise ConfigurationError(
733
+ f"{where}.extractor must be one of: {', '.join(sorted(EXTRACTORS))}"
734
+ )
735
+ if plugin_extractor is not None:
736
+ plugin_spec = next(
737
+ (item for item in plugins if item.id == plugin_extractor), None
738
+ )
739
+ if plugin_spec is None or "extractor" not in plugin_spec.interfaces:
740
+ raise ConfigurationError(
741
+ f"{where}.extractor names plugin {plugin_extractor} without an extractor interface"
742
+ )
743
+ renderer = data.get("renderer")
744
+ if not isinstance(renderer, str):
745
+ raise ConfigurationError(f"{where}.renderer must be a non-empty string")
746
+ plugin_renderer = (
747
+ renderer.removeprefix("plugin:")
748
+ if renderer.startswith("plugin:")
749
+ else None
750
+ )
751
+ if renderer not in RENDERERS and plugin_renderer is None:
752
+ raise ConfigurationError(
753
+ f"{where}.renderer must be one of: {', '.join(sorted(RENDERERS))}"
754
+ )
755
+ if plugin_renderer is not None:
756
+ renderer_spec = next(
757
+ (item for item in plugins if item.id == plugin_renderer), None
758
+ )
759
+ if renderer_spec is None or "renderer" not in renderer_spec.interfaces:
760
+ raise ConfigurationError(
761
+ f"{where}.renderer names plugin {plugin_renderer} without a renderer interface"
762
+ )
763
+
764
+ symbol = source_data.get("symbol")
765
+ pointer = source_data.get("pointer")
766
+ source_glob = source_data.get("glob")
767
+ source_path = source_data.get("path")
768
+ if plugin_extractor is not None:
769
+ if any(value is not None for value in (symbol, pointer, source_glob)):
770
+ raise ConfigurationError(
771
+ f"{where}.source.path is the only valid plugin extractor source field"
772
+ )
773
+ elif extractor == "python-literal":
774
+ if not isinstance(symbol, str) or not symbol.isidentifier():
775
+ raise ConfigurationError(f"{where}.source.symbol must be a Python identifier")
776
+ if pointer is not None or source_glob is not None:
777
+ raise ConfigurationError(f"{where}.source has fields invalid for python-literal")
778
+ elif extractor == "json":
779
+ if symbol is not None:
780
+ raise ConfigurationError(f"{where}.source.symbol is only valid for python-literal")
781
+ if not isinstance(pointer, str) or not pointer.startswith("/"):
782
+ raise ConfigurationError(
783
+ f"{where}.source.pointer must be a JSON Pointer starting with /"
784
+ )
785
+ elif extractor == "structured-data":
786
+ if pointer is not None and (
787
+ not isinstance(pointer, str) or not pointer.startswith("/")
788
+ ):
789
+ raise ConfigurationError(f"{where}.source.pointer must start with /")
790
+ if symbol is not None or source_glob is not None:
791
+ raise ConfigurationError(f"{where}.source has fields invalid for structured-data")
792
+ elif extractor == "file":
793
+ if any(value is not None for value in (symbol, pointer, source_glob)):
794
+ raise ConfigurationError(f"{where}.source.path is the only valid file source field")
795
+ elif extractor in {"repository-inventory", "repository-overview"}:
796
+ if source_path != "." or any(
797
+ value is not None for value in (symbol, pointer, source_glob)
798
+ ):
799
+ raise ConfigurationError(
800
+ f"{where}.source.path must be . for {extractor}"
801
+ )
802
+ else:
803
+ if not isinstance(source_glob, str) or not source_glob:
804
+ raise ConfigurationError(f"{where}.source.glob must be non-empty")
805
+ glob_path = Path(source_glob)
806
+ if glob_path.is_absolute() or ".." in glob_path.parts:
807
+ raise ConfigurationError(f"{where}.source.glob must stay inside the repository")
808
+ if any(value is not None for value in (symbol, pointer, source_path)):
809
+ raise ConfigurationError(f"{where}.source.glob is the only valid path source field")
810
+
811
+ compatible = {
812
+ "file": {"fenced-text", "scalar"},
813
+ "json": {"markdown-table"},
814
+ "path": {"markdown-list"},
815
+ "python-literal": {"markdown-fragment", "markdown-table", "scalar"},
816
+ "repository-inventory": {"markdown-table"},
817
+ "repository-overview": {"markdown-fragment"},
818
+ "structured-data": {"markdown-list", "markdown-table", "scalar"},
819
+ }
820
+ if (
821
+ plugin_extractor is None
822
+ and plugin_renderer is None
823
+ and renderer not in compatible[extractor]
824
+ ):
825
+ raise ConfigurationError(
826
+ f"{where}.renderer {renderer} is incompatible with extractor {extractor}"
827
+ )
828
+ language = data.get("language")
829
+ if language is not None and (
830
+ renderer != "fenced-text" or not isinstance(language, str)
831
+ ):
832
+ raise ConfigurationError(f"{where}.language is only valid for fenced-text")
833
+ region = data.get("region")
834
+ if not isinstance(region, str) or not region.strip():
835
+ raise ConfigurationError(f"{where}.region must be a non-empty string")
836
+ columns = data.get("columns", [])
837
+ if renderer == "markdown-table":
838
+ if (
839
+ not isinstance(columns, list)
840
+ or not columns
841
+ or not all(isinstance(column, str) and column for column in columns)
842
+ or len(set(columns)) != len(columns)
843
+ ):
844
+ raise ConfigurationError(f"{where}.columns must be unique non-empty strings")
845
+ elif columns:
846
+ raise ConfigurationError(f"{where}.columns is only valid for markdown-table")
847
+ bindings.append(RegionBinding(
848
+ id=binding_id,
849
+ doc=_relative_path(data.get("doc"), f"{where}.doc"),
850
+ region=region,
851
+ extractor=extractor,
852
+ source=Source(
853
+ path=Path(".") if extractor == "path" else _relative_path(
854
+ source_path, f"{where}.source.path"
855
+ ),
856
+ symbol=symbol,
857
+ pointer=pointer,
858
+ glob=source_glob,
859
+ ),
860
+ renderer=renderer,
861
+ columns=tuple(columns),
862
+ language=language,
863
+ ))
864
+ projections = _load_projections(
865
+ root.get("projections"), {binding.doc for binding in bindings}
866
+ )
867
+ review_contracts = _load_review_contracts(root.get("review_contracts", []))
868
+ public_dispositions = _load_public_dispositions(
869
+ root.get("public_dispositions"), root=path.parent
870
+ )
871
+ projection_outputs: set[Path] = set()
872
+ if projections is not None:
873
+ if projections.llms_txt is not None:
874
+ projection_outputs.add(projections.llms_txt.output)
875
+ projection_outputs.update(bundle.output for bundle in projections.bundles)
876
+ if projections.demo is not None:
877
+ projection_outputs.add(projections.demo.output)
878
+ for visual in projections.visuals:
879
+ projection_outputs.update((visual.human_output, visual.agent_output))
880
+ for contract in review_contracts:
881
+ for target in contract.targets:
882
+ if target.path in projection_outputs:
883
+ raise ConfigurationError(
884
+ f"review contract {contract.id} target {target.id} "
885
+ f"cannot be generated projection output {target.path}"
886
+ )
887
+ return Manifest(
888
+ path=path,
889
+ version=version,
890
+ bindings=tuple(bindings),
891
+ commands=tuple(commands),
892
+ plugins=tuple(plugins),
893
+ projections=projections,
894
+ source_claim_checks=tuple(source_claim_checks),
895
+ review_contracts=review_contracts,
896
+ public_dispositions=public_dispositions,
897
+ deprecations=tuple(deprecations),
898
+ )