sourcebound 1.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. clean_docs/__init__.py +26 -0
  2. clean_docs/__main__.py +3 -0
  3. clean_docs/accessibility.py +182 -0
  4. clean_docs/adapters/__init__.py +1 -0
  5. clean_docs/adapters/event_capture.py +56 -0
  6. clean_docs/adapters/mdx_dependencies.json +714 -0
  7. clean_docs/adapters/mdx_parser.mjs +29992 -0
  8. clean_docs/applicability.py +330 -0
  9. clean_docs/audit.py +1120 -0
  10. clean_docs/bootstrap.py +507 -0
  11. clean_docs/capabilities.py +200 -0
  12. clean_docs/changed.py +452 -0
  13. clean_docs/claims.py +840 -0
  14. clean_docs/cli.py +1612 -0
  15. clean_docs/context.py +307 -0
  16. clean_docs/corpus.py +377 -0
  17. clean_docs/demo.py +369 -0
  18. clean_docs/doctor.py +184 -0
  19. clean_docs/emit/__init__.py +4 -0
  20. clean_docs/emit/llms_txt.py +102 -0
  21. clean_docs/emit/stepwise.py +168 -0
  22. clean_docs/engine.py +324 -0
  23. clean_docs/errors.py +20 -0
  24. clean_docs/evaluation.py +867 -0
  25. clean_docs/execution.py +138 -0
  26. clean_docs/explain.py +123 -0
  27. clean_docs/extractors/__init__.py +19 -0
  28. clean_docs/extractors/command.py +51 -0
  29. clean_docs/extractors/inventory.py +176 -0
  30. clean_docs/extractors/json_pointer.py +84 -0
  31. clean_docs/extractors/python_literal.py +104 -0
  32. clean_docs/extractors/static.py +111 -0
  33. clean_docs/feedback.py +1390 -0
  34. clean_docs/impact.py +1624 -0
  35. clean_docs/improvements.py +1178 -0
  36. clean_docs/inventory.py +474 -0
  37. clean_docs/isolation.py +157 -0
  38. clean_docs/manifest.py +898 -0
  39. clean_docs/mdx.py +272 -0
  40. clean_docs/migration.py +121 -0
  41. clean_docs/models.py +194 -0
  42. clean_docs/outcomes.py +296 -0
  43. clean_docs/performance.py +123 -0
  44. clean_docs/phrasing.py +448 -0
  45. clean_docs/plugins.py +249 -0
  46. clean_docs/policy.py +536 -0
  47. clean_docs/projections.py +255 -0
  48. clean_docs/regions.py +75 -0
  49. clean_docs/release.py +232 -0
  50. clean_docs/renderers.py +57 -0
  51. clean_docs/residue.py +311 -0
  52. clean_docs/review_contracts.py +862 -0
  53. clean_docs/review_ledger.py +297 -0
  54. clean_docs/review_limits.py +9 -0
  55. clean_docs/sensitivity.py +602 -0
  56. clean_docs/snapshot.py +212 -0
  57. clean_docs/standard.py +281 -0
  58. clean_docs/standards/default.json +309 -0
  59. clean_docs/standards/exemplars.md +87 -0
  60. clean_docs/standards/v0-migrations.json +26 -0
  61. clean_docs/symbols.py +47 -0
  62. clean_docs/templates.py +96 -0
  63. clean_docs/verdict.py +1397 -0
  64. clean_docs/visuals.py +346 -0
  65. clean_docs/write_gate.py +170 -0
  66. sourcebound-1.2.1.dist-info/LICENSE +21 -0
  67. sourcebound-1.2.1.dist-info/METADATA +109 -0
  68. sourcebound-1.2.1.dist-info/RECORD +71 -0
  69. sourcebound-1.2.1.dist-info/WHEEL +5 -0
  70. sourcebound-1.2.1.dist-info/entry_points.txt +2 -0
  71. sourcebound-1.2.1.dist-info/top_level.txt +1 -0
@@ -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
+ }