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/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
|
+
)
|