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/mdx.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import tempfile
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from importlib.resources import files
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
MDX_PARSE_SCHEMA = "sourcebound.mdx-parse.v1"
|
|
18
|
+
MDX_PARSE_BATCH_SCHEMA = "sourcebound.mdx-parse-batch.v1"
|
|
19
|
+
MDX_PARSE_REQUEST_SCHEMA = "sourcebound.mdx-parse-request.v1"
|
|
20
|
+
MDX_PARSER_ID = "@mdx-js/mdx@3.1.1"
|
|
21
|
+
MDX_CONTROL = re.compile(
|
|
22
|
+
r"^(?P<indent>\s*)\{/\*\s*(?P<body>sourcebound:.*?)\s*\*/\}\s*$"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class MdxParserError(RuntimeError):
|
|
27
|
+
"""The first-party MDX parser could not establish a structural result."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class MdxLink:
|
|
32
|
+
line: int
|
|
33
|
+
column: int
|
|
34
|
+
url: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class MdxNode:
|
|
39
|
+
type: str
|
|
40
|
+
start_line: int
|
|
41
|
+
start_column: int
|
|
42
|
+
start_byte: int
|
|
43
|
+
end_line: int
|
|
44
|
+
end_column: int
|
|
45
|
+
end_byte: int
|
|
46
|
+
name: str | None = None
|
|
47
|
+
url: str | None = None
|
|
48
|
+
depth: int | None = None
|
|
49
|
+
text: str | None = None
|
|
50
|
+
language: str | None = None
|
|
51
|
+
meta: str | None = None
|
|
52
|
+
alt: str | None = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class MdxDocument:
|
|
57
|
+
digest: str
|
|
58
|
+
masked_text: str
|
|
59
|
+
links: tuple[MdxLink, ...]
|
|
60
|
+
nodes: tuple[MdxNode, ...]
|
|
61
|
+
parser: str = MDX_PARSER_ID
|
|
62
|
+
|
|
63
|
+
def policy_text(self, source: str) -> str:
|
|
64
|
+
source_lines = source.splitlines(keepends=True)
|
|
65
|
+
masked_lines = self.masked_text.splitlines(keepends=True)
|
|
66
|
+
if len(source_lines) != len(masked_lines):
|
|
67
|
+
raise MdxParserError("MDX parser changed the document line structure")
|
|
68
|
+
for index, line in enumerate(source_lines):
|
|
69
|
+
content = line.rstrip("\r\n")
|
|
70
|
+
ending = line[len(content):]
|
|
71
|
+
match = MDX_CONTROL.match(content)
|
|
72
|
+
if match:
|
|
73
|
+
masked_lines[index] = (
|
|
74
|
+
f"{match.group('indent')}<!-- {match.group('body').strip()} -->"
|
|
75
|
+
f"{ending}"
|
|
76
|
+
)
|
|
77
|
+
return "".join(masked_lines)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def parser_path() -> Path:
|
|
81
|
+
return Path(str(files("clean_docs.adapters").joinpath("mdx_parser.mjs")))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def parser_availability() -> tuple[bool, str]:
|
|
85
|
+
node = shutil.which("node")
|
|
86
|
+
if node is None:
|
|
87
|
+
return False, "Node.js executable not found"
|
|
88
|
+
try:
|
|
89
|
+
version_process = subprocess.run(
|
|
90
|
+
[node, "--version"],
|
|
91
|
+
text=True,
|
|
92
|
+
capture_output=True,
|
|
93
|
+
timeout=5,
|
|
94
|
+
check=False,
|
|
95
|
+
)
|
|
96
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
97
|
+
return False, f"cannot inspect Node.js runtime: {exc}"
|
|
98
|
+
match = re.fullmatch(r"v(\d+)\.\d+\.\d+\s*", version_process.stdout)
|
|
99
|
+
if version_process.returncode != 0 or match is None:
|
|
100
|
+
return False, "Node.js runtime did not report a semantic version"
|
|
101
|
+
if int(match.group(1)) < 20:
|
|
102
|
+
return False, f"Node.js 20 or newer is required; found {version_process.stdout.strip()}"
|
|
103
|
+
bundled = parser_path()
|
|
104
|
+
if not bundled.is_file():
|
|
105
|
+
return False, "bundled MDX parser not found"
|
|
106
|
+
return True, f"{MDX_PARSER_ID} via {node}"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _integer(value: Any, field: str, *, minimum: int = 0) -> int:
|
|
110
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < minimum:
|
|
111
|
+
raise MdxParserError(f"MDX parser returned invalid {field}")
|
|
112
|
+
return value
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _string(value: Any, field: str) -> str:
|
|
116
|
+
if not isinstance(value, str):
|
|
117
|
+
raise MdxParserError(f"MDX parser returned invalid {field}")
|
|
118
|
+
return value
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _node(raw: Any) -> MdxNode:
|
|
122
|
+
if not isinstance(raw, dict):
|
|
123
|
+
raise MdxParserError("MDX parser returned an invalid node")
|
|
124
|
+
start = raw.get("start")
|
|
125
|
+
end = raw.get("end")
|
|
126
|
+
if not isinstance(start, dict) or not isinstance(end, dict):
|
|
127
|
+
raise MdxParserError("MDX parser returned a node without positions")
|
|
128
|
+
name = raw.get("name")
|
|
129
|
+
url = raw.get("url")
|
|
130
|
+
text = raw.get("text")
|
|
131
|
+
language = raw.get("language")
|
|
132
|
+
meta = raw.get("meta")
|
|
133
|
+
alt = raw.get("alt")
|
|
134
|
+
depth = raw.get("depth")
|
|
135
|
+
if name is not None and not isinstance(name, str):
|
|
136
|
+
raise MdxParserError("MDX parser returned an invalid node name")
|
|
137
|
+
if url is not None and not isinstance(url, str):
|
|
138
|
+
raise MdxParserError("MDX parser returned an invalid node URL")
|
|
139
|
+
if text is not None and not isinstance(text, str):
|
|
140
|
+
raise MdxParserError("MDX parser returned invalid heading text")
|
|
141
|
+
if language is not None and not isinstance(language, str):
|
|
142
|
+
raise MdxParserError("MDX parser returned invalid code language")
|
|
143
|
+
if meta is not None and not isinstance(meta, str):
|
|
144
|
+
raise MdxParserError("MDX parser returned invalid code metadata")
|
|
145
|
+
if alt is not None and not isinstance(alt, str):
|
|
146
|
+
raise MdxParserError("MDX parser returned invalid image alternative text")
|
|
147
|
+
if depth is not None:
|
|
148
|
+
depth = _integer(depth, "heading depth", minimum=1)
|
|
149
|
+
return MdxNode(
|
|
150
|
+
type=_string(raw.get("type"), "node type"),
|
|
151
|
+
start_line=_integer(start.get("line"), "start line", minimum=1),
|
|
152
|
+
start_column=_integer(start.get("column"), "start column", minimum=1),
|
|
153
|
+
start_byte=_integer(start.get("byte"), "start byte"),
|
|
154
|
+
end_line=_integer(end.get("line"), "end line", minimum=1),
|
|
155
|
+
end_column=_integer(end.get("column"), "end column", minimum=1),
|
|
156
|
+
end_byte=_integer(end.get("byte"), "end byte"),
|
|
157
|
+
name=name,
|
|
158
|
+
url=url,
|
|
159
|
+
depth=depth,
|
|
160
|
+
text=text,
|
|
161
|
+
language=language,
|
|
162
|
+
meta=meta,
|
|
163
|
+
alt=alt,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _parse_payload(payload: Any, text: str) -> MdxDocument:
|
|
168
|
+
if not isinstance(payload, dict) or payload.get("schema") != MDX_PARSE_SCHEMA:
|
|
169
|
+
raise MdxParserError("MDX parser returned an unsupported document schema")
|
|
170
|
+
if payload.get("parser") != MDX_PARSER_ID:
|
|
171
|
+
raise MdxParserError("MDX parser identity does not match the packaged adapter")
|
|
172
|
+
digest = hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
173
|
+
if payload.get("digest") != digest:
|
|
174
|
+
raise MdxParserError("MDX parser input digest does not match")
|
|
175
|
+
masked = _string(payload.get("masked"), "masked document")
|
|
176
|
+
if masked.count("\n") != text.count("\n"):
|
|
177
|
+
raise MdxParserError("MDX parser changed the document line structure")
|
|
178
|
+
raw_links = payload.get("links")
|
|
179
|
+
raw_nodes = payload.get("nodes")
|
|
180
|
+
if not isinstance(raw_links, list) or not isinstance(raw_nodes, list):
|
|
181
|
+
raise MdxParserError("MDX parser omitted semantic results")
|
|
182
|
+
links: list[MdxLink] = []
|
|
183
|
+
for raw in raw_links:
|
|
184
|
+
if not isinstance(raw, dict):
|
|
185
|
+
raise MdxParserError("MDX parser returned an invalid link")
|
|
186
|
+
links.append(
|
|
187
|
+
MdxLink(
|
|
188
|
+
line=_integer(raw.get("line"), "link line", minimum=1),
|
|
189
|
+
column=_integer(raw.get("column"), "link column", minimum=1),
|
|
190
|
+
url=_string(raw.get("url"), "link URL"),
|
|
191
|
+
)
|
|
192
|
+
)
|
|
193
|
+
return MdxDocument(
|
|
194
|
+
digest=digest,
|
|
195
|
+
masked_text=masked,
|
|
196
|
+
links=tuple(links),
|
|
197
|
+
nodes=tuple(_node(raw) for raw in raw_nodes),
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def parse_mdx_documents(
|
|
202
|
+
documents: Mapping[str, str],
|
|
203
|
+
) -> tuple[dict[str, MdxDocument], dict[str, str]]:
|
|
204
|
+
available, detail = parser_availability()
|
|
205
|
+
if not available:
|
|
206
|
+
raise MdxParserError(detail)
|
|
207
|
+
environment = {
|
|
208
|
+
"LC_ALL": "C",
|
|
209
|
+
"PATH": os.environ.get("PATH", ""),
|
|
210
|
+
"TZ": "UTC",
|
|
211
|
+
}
|
|
212
|
+
with tempfile.TemporaryDirectory(prefix="sourcebound-mdx-") as working:
|
|
213
|
+
try:
|
|
214
|
+
process = subprocess.run(
|
|
215
|
+
["node", "--no-warnings", str(parser_path())],
|
|
216
|
+
input=json.dumps(
|
|
217
|
+
{
|
|
218
|
+
"schema": MDX_PARSE_REQUEST_SCHEMA,
|
|
219
|
+
"documents": [
|
|
220
|
+
{"id": identifier, "text": text}
|
|
221
|
+
for identifier, text in sorted(documents.items())
|
|
222
|
+
],
|
|
223
|
+
},
|
|
224
|
+
separators=(",", ":"),
|
|
225
|
+
),
|
|
226
|
+
text=True,
|
|
227
|
+
capture_output=True,
|
|
228
|
+
cwd=working,
|
|
229
|
+
env=environment,
|
|
230
|
+
timeout=20,
|
|
231
|
+
check=False,
|
|
232
|
+
)
|
|
233
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
234
|
+
raise MdxParserError(f"MDX parser failed: {exc}") from exc
|
|
235
|
+
if process.returncode != 0:
|
|
236
|
+
detail = process.stderr.strip() or "parser exited without a diagnostic"
|
|
237
|
+
raise MdxParserError(f"MDX parser failed: {detail}")
|
|
238
|
+
try:
|
|
239
|
+
payload = json.loads(process.stdout)
|
|
240
|
+
except json.JSONDecodeError as exc:
|
|
241
|
+
raise MdxParserError("MDX parser returned invalid JSON") from exc
|
|
242
|
+
if not isinstance(payload, dict) or payload.get("schema") != MDX_PARSE_BATCH_SCHEMA:
|
|
243
|
+
raise MdxParserError("MDX parser returned an unsupported batch schema")
|
|
244
|
+
raw_results = payload.get("documents")
|
|
245
|
+
if not isinstance(raw_results, list):
|
|
246
|
+
raise MdxParserError("MDX parser omitted batch results")
|
|
247
|
+
parsed: dict[str, MdxDocument] = {}
|
|
248
|
+
errors: dict[str, str] = {}
|
|
249
|
+
for raw in raw_results:
|
|
250
|
+
if not isinstance(raw, dict) or not isinstance(raw.get("id"), str):
|
|
251
|
+
raise MdxParserError("MDX parser returned an invalid batch item")
|
|
252
|
+
identifier = raw["id"]
|
|
253
|
+
if identifier not in documents or identifier in parsed or identifier in errors:
|
|
254
|
+
raise MdxParserError("MDX parser returned an unexpected batch item")
|
|
255
|
+
if raw.get("ok") is True:
|
|
256
|
+
parsed[identifier] = _parse_payload(raw.get("result"), documents[identifier])
|
|
257
|
+
elif raw.get("ok") is False and isinstance(raw.get("error"), str):
|
|
258
|
+
errors[identifier] = raw["error"]
|
|
259
|
+
else:
|
|
260
|
+
raise MdxParserError("MDX parser returned an invalid batch state")
|
|
261
|
+
if set(parsed) | set(errors) != set(documents):
|
|
262
|
+
raise MdxParserError("MDX parser did not account for every document")
|
|
263
|
+
return parsed, errors
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def parse_mdx(text: str) -> MdxDocument:
|
|
267
|
+
parsed, errors = parse_mdx_documents({"document.mdx": text})
|
|
268
|
+
if errors:
|
|
269
|
+
raise MdxParserError(
|
|
270
|
+
f"MDX syntax is not structurally valid: {errors['document.mdx']}"
|
|
271
|
+
)
|
|
272
|
+
return parsed["document.mdx"]
|
clean_docs/migration.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Migrate prior manifests with an exact backup and rollback path."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import difflib
|
|
6
|
+
import hashlib
|
|
7
|
+
from dataclasses import asdict, dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import yaml
|
|
12
|
+
|
|
13
|
+
from clean_docs.errors import ConfigurationError
|
|
14
|
+
from clean_docs.regions import atomic_write
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class MigrationPlan:
|
|
19
|
+
source_version: int
|
|
20
|
+
target_version: int
|
|
21
|
+
original_sha256: str
|
|
22
|
+
migrated_sha256: str
|
|
23
|
+
original: str
|
|
24
|
+
migrated: str
|
|
25
|
+
diff: str
|
|
26
|
+
|
|
27
|
+
def as_dict(self) -> dict[str, object]:
|
|
28
|
+
payload = asdict(self)
|
|
29
|
+
payload["schema"] = "sourcebound.manifest-migration.v1"
|
|
30
|
+
payload["backup"] = ".sourcebound.yml.v0.bak"
|
|
31
|
+
return payload
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _mapping(value: Any) -> dict[str, Any]:
|
|
35
|
+
if not isinstance(value, dict):
|
|
36
|
+
raise ConfigurationError("manifest migration source must be a mapping")
|
|
37
|
+
return value
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def build_migration_plan(path: Path) -> MigrationPlan:
|
|
41
|
+
try:
|
|
42
|
+
original = path.read_text(encoding="utf-8")
|
|
43
|
+
except OSError as exc:
|
|
44
|
+
raise ConfigurationError(f"cannot read manifest {path}: {exc}") from exc
|
|
45
|
+
try:
|
|
46
|
+
raw = _mapping(yaml.safe_load(original))
|
|
47
|
+
except yaml.YAMLError as exc:
|
|
48
|
+
raise ConfigurationError(f"invalid YAML in {path}: {exc}") from exc
|
|
49
|
+
source_version = raw.get("version")
|
|
50
|
+
if source_version not in {0, 1}:
|
|
51
|
+
raise ConfigurationError("manifest migration requires source version 0 or 1")
|
|
52
|
+
migrated_data = dict(raw)
|
|
53
|
+
target_version = source_version + 1
|
|
54
|
+
migrated_data["version"] = target_version
|
|
55
|
+
if source_version == 1:
|
|
56
|
+
execution = migrated_data.get("execution")
|
|
57
|
+
if isinstance(execution, dict):
|
|
58
|
+
allowed = execution.get("allowed_commands")
|
|
59
|
+
if isinstance(allowed, dict):
|
|
60
|
+
for command in allowed.values():
|
|
61
|
+
if isinstance(command, dict):
|
|
62
|
+
command.pop("network", None)
|
|
63
|
+
migrated = yaml.safe_dump(migrated_data, sort_keys=False, allow_unicode=True)
|
|
64
|
+
diff = "".join(
|
|
65
|
+
difflib.unified_diff(
|
|
66
|
+
original.splitlines(keepends=True),
|
|
67
|
+
migrated.splitlines(keepends=True),
|
|
68
|
+
fromfile=path.name,
|
|
69
|
+
tofile=f"{path.name} (version {target_version})",
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
return MigrationPlan(
|
|
73
|
+
source_version,
|
|
74
|
+
target_version,
|
|
75
|
+
hashlib.sha256(original.encode()).hexdigest(),
|
|
76
|
+
hashlib.sha256(migrated.encode()).hexdigest(),
|
|
77
|
+
original,
|
|
78
|
+
migrated,
|
|
79
|
+
diff,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def backup_path(path: Path, source_version: int = 0) -> Path:
|
|
84
|
+
return path.with_name(path.name + f".v{source_version}.bak")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def apply_migration(path: Path, plan: MigrationPlan) -> Path:
|
|
88
|
+
backup = backup_path(path, plan.source_version)
|
|
89
|
+
if backup.exists():
|
|
90
|
+
raise ConfigurationError(f"migration backup already exists: {backup}")
|
|
91
|
+
try:
|
|
92
|
+
current = path.read_text(encoding="utf-8")
|
|
93
|
+
except OSError as exc:
|
|
94
|
+
raise ConfigurationError(f"cannot read manifest {path}: {exc}") from exc
|
|
95
|
+
if hashlib.sha256(current.encode()).hexdigest() != plan.original_sha256:
|
|
96
|
+
raise ConfigurationError("manifest changed after migration planning")
|
|
97
|
+
atomic_write(backup, plan.original)
|
|
98
|
+
try:
|
|
99
|
+
atomic_write(path, plan.migrated)
|
|
100
|
+
except OSError:
|
|
101
|
+
atomic_write(path, plan.original)
|
|
102
|
+
raise
|
|
103
|
+
return backup
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def rollback_migration(path: Path) -> None:
|
|
107
|
+
backups = sorted(path.parent.glob(path.name + ".v*.bak"))
|
|
108
|
+
if len(backups) != 1:
|
|
109
|
+
raise ConfigurationError(
|
|
110
|
+
f"manifest rollback requires one backup, found {len(backups)}"
|
|
111
|
+
)
|
|
112
|
+
backup = backups[0]
|
|
113
|
+
try:
|
|
114
|
+
original = backup.read_text(encoding="utf-8")
|
|
115
|
+
except OSError as exc:
|
|
116
|
+
raise ConfigurationError(f"cannot read migration backup {backup}: {exc}") from exc
|
|
117
|
+
atomic_write(path, original)
|
|
118
|
+
try:
|
|
119
|
+
backup.unlink()
|
|
120
|
+
except OSError as exc:
|
|
121
|
+
raise ConfigurationError(f"cannot remove migration backup {backup}: {exc}") from exc
|
clean_docs/models.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
PLUGIN_API_VERSION = 1
|
|
9
|
+
PLUGIN_INTERFACES = frozenset({"discoverer", "extractor", "policy", "renderer"})
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class Source:
|
|
14
|
+
path: Path
|
|
15
|
+
symbol: str | None = None
|
|
16
|
+
pointer: str | None = None
|
|
17
|
+
glob: str | None = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class RegionBinding:
|
|
22
|
+
id: str
|
|
23
|
+
doc: Path
|
|
24
|
+
region: str
|
|
25
|
+
extractor: str
|
|
26
|
+
source: Source
|
|
27
|
+
renderer: str
|
|
28
|
+
columns: tuple[str, ...]
|
|
29
|
+
language: str | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class Assertion:
|
|
34
|
+
path: str
|
|
35
|
+
operator: str
|
|
36
|
+
expected: Any
|
|
37
|
+
prose: str | None = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class ClaimBinding:
|
|
42
|
+
id: str
|
|
43
|
+
doc: Path
|
|
44
|
+
anchor: str
|
|
45
|
+
extractor: str
|
|
46
|
+
command: str
|
|
47
|
+
assertion: Assertion
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class SymbolBinding:
|
|
52
|
+
id: str
|
|
53
|
+
doc: Path
|
|
54
|
+
anchor: str
|
|
55
|
+
source: Source
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
Binding = RegionBinding | ClaimBinding | SymbolBinding
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class SourceClaimCheck:
|
|
63
|
+
id: str
|
|
64
|
+
kind: str
|
|
65
|
+
doc: Path
|
|
66
|
+
anchor: str
|
|
67
|
+
subject: str
|
|
68
|
+
source: Path
|
|
69
|
+
locator: str
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class CommandSpec:
|
|
74
|
+
id: str
|
|
75
|
+
argv: tuple[str, ...]
|
|
76
|
+
timeout_seconds: int
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True)
|
|
80
|
+
class PluginSpec:
|
|
81
|
+
id: str
|
|
82
|
+
api_version: int
|
|
83
|
+
interfaces: tuple[str, ...]
|
|
84
|
+
argv: tuple[str, ...]
|
|
85
|
+
timeout_seconds: int
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True)
|
|
89
|
+
class LlmsTxtProjection:
|
|
90
|
+
output: Path
|
|
91
|
+
title: str | None = None
|
|
92
|
+
summary: str | None = None
|
|
93
|
+
include: tuple[Path, ...] = ()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True)
|
|
97
|
+
class ContextBundleProjection:
|
|
98
|
+
id: str
|
|
99
|
+
output: Path
|
|
100
|
+
include: tuple[Path, ...]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass(frozen=True)
|
|
104
|
+
class StaticDemoProjection:
|
|
105
|
+
output: Path
|
|
106
|
+
evidence: Path
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True)
|
|
110
|
+
class VisualProjection:
|
|
111
|
+
id: str
|
|
112
|
+
source: Path
|
|
113
|
+
human_output: Path
|
|
114
|
+
agent_output: Path
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass(frozen=True)
|
|
118
|
+
class ReviewLocator:
|
|
119
|
+
id: str
|
|
120
|
+
path: Path
|
|
121
|
+
extractor: str
|
|
122
|
+
locator: str
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass(frozen=True)
|
|
126
|
+
class ReviewContract:
|
|
127
|
+
id: str
|
|
128
|
+
mode: str
|
|
129
|
+
sources: tuple[ReviewLocator, ...]
|
|
130
|
+
targets: tuple[ReviewLocator, ...]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass(frozen=True)
|
|
134
|
+
class PublicDisposition:
|
|
135
|
+
"""A narrowly scoped, documented disposition for one historical finding."""
|
|
136
|
+
|
|
137
|
+
base: str
|
|
138
|
+
kind: str
|
|
139
|
+
subject: str
|
|
140
|
+
documentation: Path
|
|
141
|
+
replacement: str
|
|
142
|
+
reason: str
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass(frozen=True)
|
|
146
|
+
class ProjectionConfig:
|
|
147
|
+
llms_txt: LlmsTxtProjection | None = None
|
|
148
|
+
bundles: tuple[ContextBundleProjection, ...] = ()
|
|
149
|
+
demo: StaticDemoProjection | None = None
|
|
150
|
+
visuals: tuple[VisualProjection, ...] = ()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@dataclass(frozen=True)
|
|
154
|
+
class Manifest:
|
|
155
|
+
path: Path
|
|
156
|
+
version: int
|
|
157
|
+
bindings: tuple[Binding, ...]
|
|
158
|
+
commands: tuple[CommandSpec, ...] = ()
|
|
159
|
+
plugins: tuple[PluginSpec, ...] = ()
|
|
160
|
+
projections: ProjectionConfig | None = None
|
|
161
|
+
source_claim_checks: tuple[SourceClaimCheck, ...] = ()
|
|
162
|
+
review_contracts: tuple[ReviewContract, ...] = ()
|
|
163
|
+
public_dispositions: tuple[PublicDisposition, ...] = ()
|
|
164
|
+
deprecations: tuple[str, ...] = ()
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@dataclass(frozen=True)
|
|
168
|
+
class Provenance:
|
|
169
|
+
ref: str
|
|
170
|
+
path: str
|
|
171
|
+
locator: str
|
|
172
|
+
extractor: str
|
|
173
|
+
digest: str
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@dataclass(frozen=True)
|
|
177
|
+
class EvidenceValue:
|
|
178
|
+
kind: str
|
|
179
|
+
value: Any
|
|
180
|
+
provenance: Provenance
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@dataclass(frozen=True)
|
|
184
|
+
class BindingResult:
|
|
185
|
+
binding_id: str
|
|
186
|
+
doc: str
|
|
187
|
+
changed: bool
|
|
188
|
+
expected: str
|
|
189
|
+
observed: str
|
|
190
|
+
diff: str
|
|
191
|
+
provenance: Provenance
|
|
192
|
+
binding_type: str = "region"
|
|
193
|
+
state: str | None = None
|
|
194
|
+
prose_checked: bool = False
|