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
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
"""Prove that one static documentation check depends on one selected source fact."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
import subprocess
|
|
10
|
+
import tempfile
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from clean_docs.claims import extract_source_facts, scan_source_claims
|
|
15
|
+
from clean_docs.errors import ConfigurationError
|
|
16
|
+
from clean_docs.models import SourceClaimCheck
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
PROPOSAL_SCHEMA = "sourcebound.binding-proposal.v1"
|
|
20
|
+
FACT_SCHEMA = "sourcebound.mutation-target.v1"
|
|
21
|
+
RECEIPT_SCHEMA = "sourcebound.binding-sensitivity.v1"
|
|
22
|
+
GENERATOR = "python-identifier-set-key@1"
|
|
23
|
+
MAX_INPUT_BYTES = 1_048_576
|
|
24
|
+
ID = re.compile(r"^[a-z][a-z0-9-]*$")
|
|
25
|
+
HEX_SHA256 = re.compile(r"^[0-9a-f]{64}$")
|
|
26
|
+
HEX_COMMIT = re.compile(r"^[0-9a-f]{40,64}$")
|
|
27
|
+
PROPOSAL_KEYS = {"schema", "repository_commit", "relationship"}
|
|
28
|
+
RELATIONSHIP_KEYS = {
|
|
29
|
+
"id",
|
|
30
|
+
"kind",
|
|
31
|
+
"doc",
|
|
32
|
+
"anchor",
|
|
33
|
+
"subject",
|
|
34
|
+
"source",
|
|
35
|
+
"locator",
|
|
36
|
+
}
|
|
37
|
+
FACT_KEYS = {
|
|
38
|
+
"schema",
|
|
39
|
+
"repository_commit",
|
|
40
|
+
"selection_basis",
|
|
41
|
+
"kind",
|
|
42
|
+
"source",
|
|
43
|
+
"locator",
|
|
44
|
+
"member",
|
|
45
|
+
"value_sha256",
|
|
46
|
+
}
|
|
47
|
+
FACT_SELECTION_BASES = {"configured-source-claim", "frozen-evaluation-fact"}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class UnsupportedMutation(Exception):
|
|
51
|
+
"""The selected static fact has no safe first-party mutation."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _canonical_sha256(value: object) -> str:
|
|
55
|
+
encoded = json.dumps(
|
|
56
|
+
value,
|
|
57
|
+
sort_keys=True,
|
|
58
|
+
separators=(",", ":"),
|
|
59
|
+
ensure_ascii=False,
|
|
60
|
+
).encode()
|
|
61
|
+
return hashlib.sha256(encoded).hexdigest()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _bytes_sha256(value: bytes) -> str:
|
|
65
|
+
return hashlib.sha256(value).hexdigest()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _mapping(value: object, where: str) -> dict[str, Any]:
|
|
69
|
+
if not isinstance(value, dict):
|
|
70
|
+
raise ConfigurationError(f"{where} must be a mapping")
|
|
71
|
+
return value
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _exact_keys(data: dict[str, Any], expected: set[str], where: str) -> None:
|
|
75
|
+
missing = sorted(expected - set(data))
|
|
76
|
+
unknown = sorted(set(data) - expected)
|
|
77
|
+
if missing:
|
|
78
|
+
raise ConfigurationError(f"{where} is missing key(s): {', '.join(missing)}")
|
|
79
|
+
if unknown:
|
|
80
|
+
raise ConfigurationError(f"{where} has unknown key(s): {', '.join(unknown)}")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _nonempty(value: object, where: str) -> str:
|
|
84
|
+
if not isinstance(value, str) or not value:
|
|
85
|
+
raise ConfigurationError(f"{where} must be a non-empty string")
|
|
86
|
+
if len(value) > 4096 or "\0" in value:
|
|
87
|
+
raise ConfigurationError(f"{where} exceeds the supported text boundary")
|
|
88
|
+
return value
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _relative(value: object, where: str) -> Path:
|
|
92
|
+
raw = _nonempty(value, where)
|
|
93
|
+
path = Path(raw)
|
|
94
|
+
if path == Path(".") or path.is_absolute() or ".." in path.parts or ":" in raw:
|
|
95
|
+
raise ConfigurationError(f"{where} must stay inside the repository")
|
|
96
|
+
return path
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _validate_proposal(raw: object) -> dict[str, Any]:
|
|
100
|
+
proposal = _mapping(raw, "proposal")
|
|
101
|
+
_exact_keys(proposal, PROPOSAL_KEYS, "proposal")
|
|
102
|
+
if proposal["schema"] != PROPOSAL_SCHEMA:
|
|
103
|
+
raise ConfigurationError(f"proposal.schema must be {PROPOSAL_SCHEMA}")
|
|
104
|
+
commit = _nonempty(proposal["repository_commit"], "proposal.repository_commit")
|
|
105
|
+
if not HEX_COMMIT.fullmatch(commit):
|
|
106
|
+
raise ConfigurationError("proposal.repository_commit must be a full hexadecimal commit")
|
|
107
|
+
relationship = _mapping(proposal["relationship"], "proposal.relationship")
|
|
108
|
+
_exact_keys(relationship, RELATIONSHIP_KEYS, "proposal.relationship")
|
|
109
|
+
identifier = _nonempty(relationship["id"], "proposal.relationship.id")
|
|
110
|
+
if len(identifier) > 128 or not ID.fullmatch(identifier):
|
|
111
|
+
raise ConfigurationError("proposal.relationship.id must be kebab-case")
|
|
112
|
+
if relationship["kind"] != "identifier-set":
|
|
113
|
+
raise ConfigurationError(
|
|
114
|
+
"proposal.relationship.kind must be identifier-set in this release"
|
|
115
|
+
)
|
|
116
|
+
source = _relative(relationship["source"], "proposal.relationship.source")
|
|
117
|
+
if source.suffix != ".py":
|
|
118
|
+
raise ConfigurationError(
|
|
119
|
+
"proposal.relationship.source must be a Python file in this release"
|
|
120
|
+
)
|
|
121
|
+
doc = _relative(relationship["doc"], "proposal.relationship.doc")
|
|
122
|
+
if doc.suffix.lower() not in {".md", ".mdx"}:
|
|
123
|
+
raise ConfigurationError(
|
|
124
|
+
"proposal.relationship.doc must be Markdown or MDX in this release"
|
|
125
|
+
)
|
|
126
|
+
for field in ("anchor", "subject", "locator"):
|
|
127
|
+
_nonempty(relationship[field], f"proposal.relationship.{field}")
|
|
128
|
+
return proposal
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _validate_fact(raw: object) -> dict[str, Any]:
|
|
132
|
+
fact = _mapping(raw, "fact")
|
|
133
|
+
_exact_keys(fact, FACT_KEYS, "fact")
|
|
134
|
+
if fact["schema"] != FACT_SCHEMA:
|
|
135
|
+
raise ConfigurationError(f"fact.schema must be {FACT_SCHEMA}")
|
|
136
|
+
commit = _nonempty(fact["repository_commit"], "fact.repository_commit")
|
|
137
|
+
if not HEX_COMMIT.fullmatch(commit):
|
|
138
|
+
raise ConfigurationError("fact.repository_commit must be a full hexadecimal commit")
|
|
139
|
+
if fact["selection_basis"] not in FACT_SELECTION_BASES:
|
|
140
|
+
raise ConfigurationError(
|
|
141
|
+
"fact.selection_basis must be configured-source-claim or "
|
|
142
|
+
"frozen-evaluation-fact"
|
|
143
|
+
)
|
|
144
|
+
if fact["kind"] != "identifier-set":
|
|
145
|
+
raise ConfigurationError("fact.kind must be identifier-set in this release")
|
|
146
|
+
_relative(fact["source"], "fact.source")
|
|
147
|
+
for field in ("locator", "member"):
|
|
148
|
+
_nonempty(fact[field], f"fact.{field}")
|
|
149
|
+
digest = _nonempty(fact["value_sha256"], "fact.value_sha256")
|
|
150
|
+
if not HEX_SHA256.fullmatch(digest):
|
|
151
|
+
raise ConfigurationError("fact.value_sha256 must be a lowercase SHA-256 digest")
|
|
152
|
+
return fact
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def decode_json_object(raw: bytes, where: str) -> dict[str, Any]:
|
|
156
|
+
if len(raw) > MAX_INPUT_BYTES:
|
|
157
|
+
raise ConfigurationError(f"{where} exceeds {MAX_INPUT_BYTES} bytes")
|
|
158
|
+
try:
|
|
159
|
+
value = json.loads(raw)
|
|
160
|
+
except json.JSONDecodeError as exc:
|
|
161
|
+
raise ConfigurationError(f"{where} is not valid JSON: {exc}") from exc
|
|
162
|
+
return _mapping(value, where)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def load_json_object(path: Path, where: str) -> tuple[dict[str, Any], bytes]:
|
|
166
|
+
try:
|
|
167
|
+
raw = path.read_bytes()
|
|
168
|
+
except OSError as exc:
|
|
169
|
+
raise ConfigurationError(f"cannot read {where} {path}: {exc}") from exc
|
|
170
|
+
return decode_json_object(raw, where), raw
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _git(root: Path, *args: str) -> bytes:
|
|
174
|
+
try:
|
|
175
|
+
process = subprocess.run(
|
|
176
|
+
["git", "-C", str(root), *args],
|
|
177
|
+
capture_output=True,
|
|
178
|
+
timeout=30,
|
|
179
|
+
check=False,
|
|
180
|
+
)
|
|
181
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
182
|
+
raise ConfigurationError(f"cannot inspect repository state: {exc}") from exc
|
|
183
|
+
if process.returncode != 0:
|
|
184
|
+
detail = process.stderr.decode(errors="replace").strip()
|
|
185
|
+
raise ConfigurationError(detail or f"git {' '.join(args)} failed")
|
|
186
|
+
return process.stdout
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _worktree_status(root: Path, excluded: Path | None) -> bytes:
|
|
190
|
+
arguments = [
|
|
191
|
+
"status",
|
|
192
|
+
"--porcelain=v1",
|
|
193
|
+
"-z",
|
|
194
|
+
"--untracked-files=all",
|
|
195
|
+
]
|
|
196
|
+
if excluded is not None:
|
|
197
|
+
try:
|
|
198
|
+
relative = excluded.resolve().relative_to(root)
|
|
199
|
+
except ValueError:
|
|
200
|
+
relative = None
|
|
201
|
+
if relative is not None:
|
|
202
|
+
arguments.extend(("--", ".", f":(exclude){relative.as_posix()}"))
|
|
203
|
+
return _git(root, *arguments)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _tracked_text(root: Path, commit: str, path: Path) -> tuple[str, str, str]:
|
|
207
|
+
relative = path.as_posix()
|
|
208
|
+
tree = _git(root, "ls-tree", "-z", commit, "--", relative)
|
|
209
|
+
if not tree:
|
|
210
|
+
raise ConfigurationError(f"selected path is not tracked at {commit}: {relative}")
|
|
211
|
+
header, separator, _name = tree.partition(b"\t")
|
|
212
|
+
if not separator:
|
|
213
|
+
raise ConfigurationError(f"cannot inspect selected path mode: {relative}")
|
|
214
|
+
fields = header.split(b" ")
|
|
215
|
+
if len(fields) != 3:
|
|
216
|
+
raise ConfigurationError(f"cannot inspect selected path object: {relative}")
|
|
217
|
+
mode, _kind, object_id = fields
|
|
218
|
+
if mode not in {b"100644", b"100755"}:
|
|
219
|
+
raise ConfigurationError(f"selected path is not a regular file: {relative}")
|
|
220
|
+
content = _git(root, "show", f"{commit}:{relative}")
|
|
221
|
+
try:
|
|
222
|
+
return (
|
|
223
|
+
content.decode("utf-8"),
|
|
224
|
+
_bytes_sha256(content),
|
|
225
|
+
object_id.decode(),
|
|
226
|
+
)
|
|
227
|
+
except UnicodeDecodeError as exc:
|
|
228
|
+
raise ConfigurationError(f"selected path is not UTF-8 text: {relative}") from exc
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _assignment(tree: ast.Module, symbol: str) -> ast.AST:
|
|
232
|
+
for node in tree.body:
|
|
233
|
+
if isinstance(node, ast.Assign):
|
|
234
|
+
if any(
|
|
235
|
+
isinstance(target, ast.Name) and target.id == symbol
|
|
236
|
+
for target in node.targets
|
|
237
|
+
):
|
|
238
|
+
return node.value
|
|
239
|
+
if (
|
|
240
|
+
isinstance(node, ast.AnnAssign)
|
|
241
|
+
and isinstance(node.target, ast.Name)
|
|
242
|
+
and node.target.id == symbol
|
|
243
|
+
and node.value is not None
|
|
244
|
+
):
|
|
245
|
+
return node.value
|
|
246
|
+
raise UnsupportedMutation(f"static assignment not found: {symbol}")
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _mapping_node(text: str, locator: str) -> ast.Dict:
|
|
250
|
+
if not locator.endswith("#keys"):
|
|
251
|
+
raise UnsupportedMutation("identifier-set locator must end in #keys")
|
|
252
|
+
parts = locator.removesuffix("#keys").split(".")
|
|
253
|
+
if len(parts) not in {1, 2} or any(not part for part in parts):
|
|
254
|
+
raise UnsupportedMutation("identifier-set locator shape is unsupported")
|
|
255
|
+
try:
|
|
256
|
+
value = _assignment(ast.parse(text), parts[0])
|
|
257
|
+
except SyntaxError as exc:
|
|
258
|
+
raise UnsupportedMutation(f"selected Python source does not parse: {exc}") from exc
|
|
259
|
+
if len(parts) == 1 and isinstance(value, ast.Dict):
|
|
260
|
+
return value
|
|
261
|
+
if len(parts) == 2 and isinstance(value, ast.Call):
|
|
262
|
+
matches = [
|
|
263
|
+
keyword.value
|
|
264
|
+
for keyword in value.keywords
|
|
265
|
+
if keyword.arg == parts[1] and isinstance(keyword.value, ast.Dict)
|
|
266
|
+
]
|
|
267
|
+
if len(matches) == 1:
|
|
268
|
+
return matches[0]
|
|
269
|
+
raise UnsupportedMutation("selected identifier set is not a static mapping")
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _mutate_identifier(
|
|
273
|
+
text: str,
|
|
274
|
+
locator: str,
|
|
275
|
+
member: str,
|
|
276
|
+
) -> tuple[str, dict[str, object]]:
|
|
277
|
+
mapping = _mapping_node(text, locator)
|
|
278
|
+
matches = [
|
|
279
|
+
key
|
|
280
|
+
for key in mapping.keys
|
|
281
|
+
if isinstance(key, ast.Constant) and key.value == member
|
|
282
|
+
]
|
|
283
|
+
if len(matches) != 1:
|
|
284
|
+
raise UnsupportedMutation(
|
|
285
|
+
f"selected member must resolve to exactly one mapping key; found {len(matches)}"
|
|
286
|
+
)
|
|
287
|
+
key = matches[0]
|
|
288
|
+
if (
|
|
289
|
+
key.end_lineno is None
|
|
290
|
+
or key.end_col_offset is None
|
|
291
|
+
or key.lineno != key.end_lineno
|
|
292
|
+
):
|
|
293
|
+
raise UnsupportedMutation("multiline mapping keys are unsupported")
|
|
294
|
+
replacement_value = f"{member}__clean_docs_probe"
|
|
295
|
+
existing = {
|
|
296
|
+
candidate.value
|
|
297
|
+
for candidate in mapping.keys
|
|
298
|
+
if isinstance(candidate, ast.Constant) and isinstance(candidate.value, str)
|
|
299
|
+
}
|
|
300
|
+
if replacement_value in existing:
|
|
301
|
+
raise UnsupportedMutation("deterministic replacement key already exists")
|
|
302
|
+
source = text.encode("utf-8")
|
|
303
|
+
lines = source.splitlines(keepends=True)
|
|
304
|
+
start = sum(len(line) for line in lines[: key.lineno - 1]) + key.col_offset
|
|
305
|
+
end = sum(len(line) for line in lines[: key.end_lineno - 1]) + key.end_col_offset
|
|
306
|
+
replacement = json.dumps(replacement_value).encode()
|
|
307
|
+
mutated_bytes = source[:start] + replacement + source[end:]
|
|
308
|
+
try:
|
|
309
|
+
mutated = mutated_bytes.decode("utf-8")
|
|
310
|
+
ast.parse(mutated)
|
|
311
|
+
except (UnicodeDecodeError, SyntaxError) as exc:
|
|
312
|
+
raise UnsupportedMutation(f"generated mutation is not valid Python: {exc}") from exc
|
|
313
|
+
plan = {
|
|
314
|
+
"generator": GENERATOR,
|
|
315
|
+
"class": "rename-mapping-member",
|
|
316
|
+
"member": member,
|
|
317
|
+
"replacement": replacement_value,
|
|
318
|
+
"reversal": {
|
|
319
|
+
"byte_range": {
|
|
320
|
+
"start": start,
|
|
321
|
+
"end": start + len(replacement),
|
|
322
|
+
},
|
|
323
|
+
"replacement": json.dumps(member),
|
|
324
|
+
},
|
|
325
|
+
"line": key.lineno,
|
|
326
|
+
"byte_range": {"start": start, "end": end},
|
|
327
|
+
"before_sha256": _bytes_sha256(source),
|
|
328
|
+
"after_sha256": _bytes_sha256(mutated_bytes),
|
|
329
|
+
"target_execution_required": False,
|
|
330
|
+
}
|
|
331
|
+
return mutated, {**plan, "plan_sha256": _canonical_sha256(plan)}
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _relationship_check(relationship: dict[str, Any]) -> SourceClaimCheck:
|
|
335
|
+
return SourceClaimCheck(
|
|
336
|
+
id=relationship["id"],
|
|
337
|
+
kind=relationship["kind"],
|
|
338
|
+
doc=Path(relationship["doc"]),
|
|
339
|
+
anchor=relationship["anchor"],
|
|
340
|
+
subject=relationship["subject"],
|
|
341
|
+
source=Path(relationship["source"]),
|
|
342
|
+
locator=relationship["locator"],
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _observed_status(root: Path, check: SourceClaimCheck) -> tuple[str, dict[str, object] | None]:
|
|
347
|
+
report = scan_source_claims(root, (check,), discover=False)
|
|
348
|
+
if report.missing or len(report.results) != 1:
|
|
349
|
+
return "missing", None
|
|
350
|
+
result = report.results[0]
|
|
351
|
+
return result.status, result.as_dict()
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _base_receipt(
|
|
355
|
+
*,
|
|
356
|
+
state: str,
|
|
357
|
+
proposal: dict[str, Any],
|
|
358
|
+
fact: dict[str, Any],
|
|
359
|
+
proposal_bytes: bytes,
|
|
360
|
+
fact_bytes: bytes,
|
|
361
|
+
fact_file_sha256: str,
|
|
362
|
+
commit: str,
|
|
363
|
+
head_after: str,
|
|
364
|
+
status_before: bytes,
|
|
365
|
+
status_after: bytes,
|
|
366
|
+
detail: str,
|
|
367
|
+
) -> dict[str, object]:
|
|
368
|
+
return {
|
|
369
|
+
"schema": RECEIPT_SCHEMA,
|
|
370
|
+
"state": state,
|
|
371
|
+
"sensitive": state == "sensitive",
|
|
372
|
+
"semantic_relationship_authorized": False,
|
|
373
|
+
"detail": detail,
|
|
374
|
+
"repository": {
|
|
375
|
+
"commit": commit,
|
|
376
|
+
"head_after": head_after,
|
|
377
|
+
"clean_before": not status_before,
|
|
378
|
+
"clean_after": not status_after,
|
|
379
|
+
"status_before_sha256": _bytes_sha256(status_before),
|
|
380
|
+
"status_after_sha256": _bytes_sha256(status_after),
|
|
381
|
+
"caller_worktree_unchanged": status_before == status_after,
|
|
382
|
+
"caller_repository_unchanged": (
|
|
383
|
+
status_before == status_after and head_after == commit
|
|
384
|
+
),
|
|
385
|
+
},
|
|
386
|
+
"inputs": {
|
|
387
|
+
"proposal_sha256": _bytes_sha256(proposal_bytes),
|
|
388
|
+
"fact_sha256": _bytes_sha256(fact_bytes),
|
|
389
|
+
"expected_fact_file_sha256": fact_file_sha256,
|
|
390
|
+
"relationship": proposal["relationship"],
|
|
391
|
+
"fact_selection_basis": fact["selection_basis"],
|
|
392
|
+
"fact_value_sha256": fact["value_sha256"],
|
|
393
|
+
},
|
|
394
|
+
"execution": {
|
|
395
|
+
"disposable_copy": True,
|
|
396
|
+
"target_code_executed": False,
|
|
397
|
+
"repository_commands_executed": False,
|
|
398
|
+
"caller_files_written": 0,
|
|
399
|
+
},
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def evaluate_binding_sensitivity(
|
|
404
|
+
root: Path,
|
|
405
|
+
proposal_raw: object,
|
|
406
|
+
fact_raw: object,
|
|
407
|
+
*,
|
|
408
|
+
proposal_bytes: bytes,
|
|
409
|
+
fact_bytes: bytes,
|
|
410
|
+
expected_fact_file_sha256: str,
|
|
411
|
+
excluded_worktree_path: Path | None = None,
|
|
412
|
+
) -> dict[str, object]:
|
|
413
|
+
"""Return a read-only dependency-sensitivity receipt for one static relationship."""
|
|
414
|
+
root = root.resolve()
|
|
415
|
+
proposal = _validate_proposal(proposal_raw)
|
|
416
|
+
fact = _validate_fact(fact_raw)
|
|
417
|
+
if len(proposal_bytes) > MAX_INPUT_BYTES or len(fact_bytes) > MAX_INPUT_BYTES:
|
|
418
|
+
raise ConfigurationError(
|
|
419
|
+
f"proposal and fact inputs must each be at most {MAX_INPUT_BYTES} bytes"
|
|
420
|
+
)
|
|
421
|
+
if not HEX_SHA256.fullmatch(expected_fact_file_sha256):
|
|
422
|
+
raise ConfigurationError("expected fact file digest must be a lowercase SHA-256")
|
|
423
|
+
observed_fact_file_sha256 = _bytes_sha256(fact_bytes)
|
|
424
|
+
if observed_fact_file_sha256 != expected_fact_file_sha256:
|
|
425
|
+
raise ConfigurationError("fact file SHA-256 does not match the expected digest")
|
|
426
|
+
relationship = proposal["relationship"]
|
|
427
|
+
if proposal["repository_commit"] != fact["repository_commit"]:
|
|
428
|
+
raise ConfigurationError("proposal and fact must bind the same repository commit")
|
|
429
|
+
for field in ("kind", "source", "locator"):
|
|
430
|
+
if relationship[field] != fact[field]:
|
|
431
|
+
raise ConfigurationError(f"proposal relationship and fact disagree on {field}")
|
|
432
|
+
|
|
433
|
+
commit = _git(root, "rev-parse", "HEAD").decode().strip()
|
|
434
|
+
if commit != proposal["repository_commit"]:
|
|
435
|
+
raise ConfigurationError("repository HEAD does not match the proposal commit")
|
|
436
|
+
status_before = _worktree_status(root, excluded_worktree_path)
|
|
437
|
+
if status_before:
|
|
438
|
+
raise ConfigurationError("binding sensitivity requires a clean caller worktree")
|
|
439
|
+
|
|
440
|
+
source_path = _relative(relationship["source"], "proposal.relationship.source")
|
|
441
|
+
doc_path = _relative(relationship["doc"], "proposal.relationship.doc")
|
|
442
|
+
source_text, source_blob_sha256, source_blob_id = _tracked_text(
|
|
443
|
+
root,
|
|
444
|
+
commit,
|
|
445
|
+
source_path,
|
|
446
|
+
)
|
|
447
|
+
doc_text, doc_blob_sha256, doc_blob_id = _tracked_text(
|
|
448
|
+
root,
|
|
449
|
+
commit,
|
|
450
|
+
doc_path,
|
|
451
|
+
)
|
|
452
|
+
facts = [
|
|
453
|
+
candidate
|
|
454
|
+
for candidate in extract_source_facts(source_path.as_posix(), source_text)
|
|
455
|
+
if candidate.kind == fact["kind"] and candidate.locator == fact["locator"]
|
|
456
|
+
]
|
|
457
|
+
status_after: bytes = status_before
|
|
458
|
+
base = {
|
|
459
|
+
"source_blob_sha256": source_blob_sha256,
|
|
460
|
+
"source_blob_id": source_blob_id,
|
|
461
|
+
"document_blob_sha256": doc_blob_sha256,
|
|
462
|
+
"document_blob_id": doc_blob_id,
|
|
463
|
+
}
|
|
464
|
+
if len(facts) != 1 or facts[0].digest != fact["value_sha256"]:
|
|
465
|
+
status_after = _worktree_status(root, excluded_worktree_path)
|
|
466
|
+
head_after = _git(root, "rev-parse", "HEAD").decode().strip()
|
|
467
|
+
return {
|
|
468
|
+
**_base_receipt(
|
|
469
|
+
state="invalid",
|
|
470
|
+
proposal=proposal,
|
|
471
|
+
fact=fact,
|
|
472
|
+
proposal_bytes=proposal_bytes,
|
|
473
|
+
fact_bytes=fact_bytes,
|
|
474
|
+
fact_file_sha256=expected_fact_file_sha256,
|
|
475
|
+
commit=commit,
|
|
476
|
+
head_after=head_after,
|
|
477
|
+
status_before=status_before,
|
|
478
|
+
status_after=status_after,
|
|
479
|
+
detail="the frozen fact does not match the selected source evidence",
|
|
480
|
+
),
|
|
481
|
+
"baseline": base,
|
|
482
|
+
"mutation": None,
|
|
483
|
+
"mutated": None,
|
|
484
|
+
}
|
|
485
|
+
fact_value = facts[0].value
|
|
486
|
+
if not isinstance(fact_value, tuple):
|
|
487
|
+
raise ConfigurationError("selected identifier-set fact did not resolve to names")
|
|
488
|
+
if fact["member"] not in fact_value:
|
|
489
|
+
status_after = _worktree_status(root, excluded_worktree_path)
|
|
490
|
+
head_after = _git(root, "rev-parse", "HEAD").decode().strip()
|
|
491
|
+
return {
|
|
492
|
+
**_base_receipt(
|
|
493
|
+
state="invalid",
|
|
494
|
+
proposal=proposal,
|
|
495
|
+
fact=fact,
|
|
496
|
+
proposal_bytes=proposal_bytes,
|
|
497
|
+
fact_bytes=fact_bytes,
|
|
498
|
+
fact_file_sha256=expected_fact_file_sha256,
|
|
499
|
+
commit=commit,
|
|
500
|
+
head_after=head_after,
|
|
501
|
+
status_before=status_before,
|
|
502
|
+
status_after=status_after,
|
|
503
|
+
detail="the selected member is absent from the frozen fact",
|
|
504
|
+
),
|
|
505
|
+
"baseline": {**base, "source_fact": facts[0].digest},
|
|
506
|
+
"mutation": None,
|
|
507
|
+
"mutated": None,
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
check = _relationship_check(relationship)
|
|
511
|
+
with tempfile.TemporaryDirectory(prefix="sourcebound-sensitivity-") as temporary:
|
|
512
|
+
disposable = Path(temporary)
|
|
513
|
+
disposable_source = disposable / source_path
|
|
514
|
+
disposable_doc = disposable / doc_path
|
|
515
|
+
disposable_source.parent.mkdir(parents=True, exist_ok=True)
|
|
516
|
+
disposable_doc.parent.mkdir(parents=True, exist_ok=True)
|
|
517
|
+
disposable_source.write_text(source_text, encoding="utf-8")
|
|
518
|
+
disposable_doc.write_text(doc_text, encoding="utf-8")
|
|
519
|
+
baseline_status, baseline_result = _observed_status(disposable, check)
|
|
520
|
+
if baseline_status != "current":
|
|
521
|
+
status_after = _worktree_status(root, excluded_worktree_path)
|
|
522
|
+
head_after = _git(root, "rev-parse", "HEAD").decode().strip()
|
|
523
|
+
return {
|
|
524
|
+
**_base_receipt(
|
|
525
|
+
state="invalid",
|
|
526
|
+
proposal=proposal,
|
|
527
|
+
fact=fact,
|
|
528
|
+
proposal_bytes=proposal_bytes,
|
|
529
|
+
fact_bytes=fact_bytes,
|
|
530
|
+
fact_file_sha256=expected_fact_file_sha256,
|
|
531
|
+
commit=commit,
|
|
532
|
+
head_after=head_after,
|
|
533
|
+
status_before=status_before,
|
|
534
|
+
status_after=status_after,
|
|
535
|
+
detail=f"the proposed relationship baseline is {baseline_status}, not current",
|
|
536
|
+
),
|
|
537
|
+
"baseline": {**base, "result": baseline_result},
|
|
538
|
+
"mutation": None,
|
|
539
|
+
"mutated": None,
|
|
540
|
+
}
|
|
541
|
+
try:
|
|
542
|
+
mutated_text, mutation = _mutate_identifier(
|
|
543
|
+
source_text,
|
|
544
|
+
fact["locator"],
|
|
545
|
+
fact["member"],
|
|
546
|
+
)
|
|
547
|
+
except UnsupportedMutation as exc:
|
|
548
|
+
status_after = _worktree_status(root, excluded_worktree_path)
|
|
549
|
+
head_after = _git(root, "rev-parse", "HEAD").decode().strip()
|
|
550
|
+
return {
|
|
551
|
+
**_base_receipt(
|
|
552
|
+
state="unsupported",
|
|
553
|
+
proposal=proposal,
|
|
554
|
+
fact=fact,
|
|
555
|
+
proposal_bytes=proposal_bytes,
|
|
556
|
+
fact_bytes=fact_bytes,
|
|
557
|
+
fact_file_sha256=expected_fact_file_sha256,
|
|
558
|
+
commit=commit,
|
|
559
|
+
head_after=head_after,
|
|
560
|
+
status_before=status_before,
|
|
561
|
+
status_after=status_after,
|
|
562
|
+
detail=str(exc),
|
|
563
|
+
),
|
|
564
|
+
"baseline": {**base, "result": baseline_result},
|
|
565
|
+
"mutation": None,
|
|
566
|
+
"mutated": None,
|
|
567
|
+
}
|
|
568
|
+
disposable_source.write_text(mutated_text, encoding="utf-8")
|
|
569
|
+
mutated_status, mutated_result = _observed_status(disposable, check)
|
|
570
|
+
|
|
571
|
+
status_after = _worktree_status(root, excluded_worktree_path)
|
|
572
|
+
head_after = _git(root, "rev-parse", "HEAD").decode().strip()
|
|
573
|
+
if status_after != status_before or head_after != commit:
|
|
574
|
+
state = "invalid"
|
|
575
|
+
detail = "the caller repository changed during sensitivity verification"
|
|
576
|
+
elif mutated_status == "drift":
|
|
577
|
+
state = "sensitive"
|
|
578
|
+
detail = "the selected relationship became stale after the independent fact mutation"
|
|
579
|
+
elif mutated_status == "current":
|
|
580
|
+
state = "insensitive"
|
|
581
|
+
detail = "the selected relationship stayed current after the independent fact mutation"
|
|
582
|
+
else:
|
|
583
|
+
state = "invalid"
|
|
584
|
+
detail = f"the mutated relationship resolved to {mutated_status}"
|
|
585
|
+
return {
|
|
586
|
+
**_base_receipt(
|
|
587
|
+
state=state,
|
|
588
|
+
proposal=proposal,
|
|
589
|
+
fact=fact,
|
|
590
|
+
proposal_bytes=proposal_bytes,
|
|
591
|
+
fact_bytes=fact_bytes,
|
|
592
|
+
fact_file_sha256=expected_fact_file_sha256,
|
|
593
|
+
commit=commit,
|
|
594
|
+
head_after=head_after,
|
|
595
|
+
status_before=status_before,
|
|
596
|
+
status_after=status_after,
|
|
597
|
+
detail=detail,
|
|
598
|
+
),
|
|
599
|
+
"baseline": {**base, "result": baseline_result},
|
|
600
|
+
"mutation": mutation,
|
|
601
|
+
"mutated": {"result": mutated_result},
|
|
602
|
+
}
|