sourcebound 1.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. clean_docs/__init__.py +26 -0
  2. clean_docs/__main__.py +3 -0
  3. clean_docs/accessibility.py +182 -0
  4. clean_docs/adapters/__init__.py +1 -0
  5. clean_docs/adapters/event_capture.py +56 -0
  6. clean_docs/adapters/mdx_dependencies.json +714 -0
  7. clean_docs/adapters/mdx_parser.mjs +29992 -0
  8. clean_docs/applicability.py +330 -0
  9. clean_docs/audit.py +1120 -0
  10. clean_docs/bootstrap.py +507 -0
  11. clean_docs/capabilities.py +200 -0
  12. clean_docs/changed.py +452 -0
  13. clean_docs/claims.py +840 -0
  14. clean_docs/cli.py +1612 -0
  15. clean_docs/context.py +307 -0
  16. clean_docs/corpus.py +377 -0
  17. clean_docs/demo.py +369 -0
  18. clean_docs/doctor.py +184 -0
  19. clean_docs/emit/__init__.py +4 -0
  20. clean_docs/emit/llms_txt.py +102 -0
  21. clean_docs/emit/stepwise.py +168 -0
  22. clean_docs/engine.py +324 -0
  23. clean_docs/errors.py +20 -0
  24. clean_docs/evaluation.py +867 -0
  25. clean_docs/execution.py +138 -0
  26. clean_docs/explain.py +123 -0
  27. clean_docs/extractors/__init__.py +19 -0
  28. clean_docs/extractors/command.py +51 -0
  29. clean_docs/extractors/inventory.py +176 -0
  30. clean_docs/extractors/json_pointer.py +84 -0
  31. clean_docs/extractors/python_literal.py +104 -0
  32. clean_docs/extractors/static.py +111 -0
  33. clean_docs/feedback.py +1390 -0
  34. clean_docs/impact.py +1624 -0
  35. clean_docs/improvements.py +1178 -0
  36. clean_docs/inventory.py +474 -0
  37. clean_docs/isolation.py +157 -0
  38. clean_docs/manifest.py +898 -0
  39. clean_docs/mdx.py +272 -0
  40. clean_docs/migration.py +121 -0
  41. clean_docs/models.py +194 -0
  42. clean_docs/outcomes.py +296 -0
  43. clean_docs/performance.py +123 -0
  44. clean_docs/phrasing.py +448 -0
  45. clean_docs/plugins.py +249 -0
  46. clean_docs/policy.py +536 -0
  47. clean_docs/projections.py +255 -0
  48. clean_docs/regions.py +75 -0
  49. clean_docs/release.py +232 -0
  50. clean_docs/renderers.py +57 -0
  51. clean_docs/residue.py +311 -0
  52. clean_docs/review_contracts.py +862 -0
  53. clean_docs/review_ledger.py +297 -0
  54. clean_docs/review_limits.py +9 -0
  55. clean_docs/sensitivity.py +602 -0
  56. clean_docs/snapshot.py +212 -0
  57. clean_docs/standard.py +281 -0
  58. clean_docs/standards/default.json +309 -0
  59. clean_docs/standards/exemplars.md +87 -0
  60. clean_docs/standards/v0-migrations.json +26 -0
  61. clean_docs/symbols.py +47 -0
  62. clean_docs/templates.py +96 -0
  63. clean_docs/verdict.py +1397 -0
  64. clean_docs/visuals.py +346 -0
  65. clean_docs/write_gate.py +170 -0
  66. sourcebound-1.2.1.dist-info/LICENSE +21 -0
  67. sourcebound-1.2.1.dist-info/METADATA +109 -0
  68. sourcebound-1.2.1.dist-info/RECORD +71 -0
  69. sourcebound-1.2.1.dist-info/WHEEL +5 -0
  70. sourcebound-1.2.1.dist-info/entry_points.txt +2 -0
  71. sourcebound-1.2.1.dist-info/top_level.txt +1 -0
clean_docs/claims.py ADDED
@@ -0,0 +1,840 @@
1
+ """Discover and verify bounded source-to-prose claim relationships."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import hashlib
7
+ import json
8
+ import re
9
+ import subprocess
10
+ from dataclasses import asdict, dataclass
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from clean_docs.applicability import classify_document
15
+ from clean_docs.models import BindingResult, Provenance, SourceClaimCheck
16
+
17
+
18
+ CLAIM_REPORT_SCHEMA = "sourcebound.source-claims.v1"
19
+ MAX_RANKED_CANDIDATES = 100
20
+ SKIP_PARTS = {
21
+ ".git",
22
+ ".venv",
23
+ "__pycache__",
24
+ "build",
25
+ "dist",
26
+ "node_modules",
27
+ }
28
+ HEADING = re.compile(r"^(#{1,6})\s+(.+?)\s*$")
29
+ NUMBER = re.compile(r"(?<![\w.])(\d[\d,]*)(?![\w.])")
30
+ WORD = re.compile(r"[A-Za-z][A-Za-z0-9_-]*")
31
+ TABLE_KEY = re.compile(r"^\s*\|?\s*`?([A-Za-z_][A-Za-z0-9_]*)`?\s*\|")
32
+ STOP_SUBJECTS = {
33
+ "a",
34
+ "an",
35
+ "and",
36
+ "as",
37
+ "at",
38
+ "by",
39
+ "for",
40
+ "from",
41
+ "in",
42
+ "of",
43
+ "on",
44
+ "or",
45
+ "the",
46
+ "to",
47
+ "with",
48
+ }
49
+ OWNERSHIP_STOP = STOP_SUBJECTS | {
50
+ "api",
51
+ "backend",
52
+ "catalog",
53
+ "config",
54
+ "configuration",
55
+ "data",
56
+ "doc",
57
+ "docs",
58
+ "guide",
59
+ "index",
60
+ "readme",
61
+ "reference",
62
+ "script",
63
+ "scripts",
64
+ "service",
65
+ "settings",
66
+ "skill",
67
+ "test",
68
+ "tests",
69
+ "util",
70
+ "utils",
71
+ }
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class SourceFact:
76
+ kind: str
77
+ source: str
78
+ locator: str
79
+ line: int
80
+ subjects: tuple[str, ...]
81
+ value: int | tuple[str, ...]
82
+ digest: str
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class DocumentClaim:
87
+ kind: str
88
+ doc: str
89
+ anchor: str
90
+ line: int
91
+ subject: str
92
+ value: int | tuple[str, ...]
93
+ digest: str
94
+
95
+
96
+ @dataclass(frozen=True)
97
+ class SourceClaimResult:
98
+ id: str
99
+ kind: str
100
+ doc: str
101
+ anchor: str
102
+ line: int
103
+ subject: str
104
+ source: str
105
+ locator: str
106
+ source_line: int
107
+ document_value: int | tuple[str, ...]
108
+ source_value: int | tuple[str, ...]
109
+ status: str
110
+ authority: str
111
+ rank: int
112
+ document_digest: str
113
+ source_digest: str
114
+ detail: str
115
+
116
+ def as_dict(self) -> dict[str, object]:
117
+ payload = asdict(self)
118
+ for key in ("document_value", "source_value"):
119
+ value = payload[key]
120
+ if isinstance(value, tuple):
121
+ payload[key] = list(value)
122
+ return payload
123
+
124
+
125
+ @dataclass(frozen=True)
126
+ class MissingSourceClaim:
127
+ id: str
128
+ kind: str
129
+ doc: str
130
+ anchor: str
131
+ subject: str
132
+ source: str
133
+ locator: str
134
+ detail: str
135
+
136
+
137
+ @dataclass(frozen=True)
138
+ class SourceClaimReport:
139
+ authority: str
140
+ results: tuple[SourceClaimResult, ...]
141
+ candidates: tuple[SourceClaimResult, ...]
142
+ missing: tuple[MissingSourceClaim, ...]
143
+ candidate_totals: tuple[tuple[str, int], ...]
144
+
145
+ @property
146
+ def ok(self) -> bool:
147
+ return not self.missing and not any(
148
+ result.status == "drift" for result in self.results
149
+ )
150
+
151
+ def as_dict(self) -> dict[str, object]:
152
+ candidate_population = sum(count for _status, count in self.candidate_totals)
153
+ return {
154
+ "schema": CLAIM_REPORT_SCHEMA,
155
+ "ok": self.ok,
156
+ "authority": self.authority,
157
+ "results": [result.as_dict() for result in self.results],
158
+ "candidates": [result.as_dict() for result in self.candidates],
159
+ "missing": [asdict(item) for item in self.missing],
160
+ "candidate_totals": dict(self.candidate_totals),
161
+ "candidate_population": candidate_population,
162
+ "candidate_shown": len(self.candidates),
163
+ "candidate_truncated": max(
164
+ 0, candidate_population - len(self.candidates)
165
+ ),
166
+ }
167
+
168
+
169
+ def _digest(value: Any) -> str:
170
+ normalized = json.dumps(
171
+ value,
172
+ sort_keys=True,
173
+ separators=(",", ":"),
174
+ ensure_ascii=False,
175
+ )
176
+ return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
177
+
178
+
179
+ def _subject(value: str) -> str:
180
+ normalized = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
181
+ if normalized.endswith("ies") and len(normalized) > 4:
182
+ return normalized[:-3] + "y"
183
+ if normalized.endswith(("sses", "xes", "zes", "ches", "shes")):
184
+ return normalized[:-2]
185
+ if normalized.endswith("s") and not normalized.endswith("ss") and len(normalized) > 3:
186
+ return normalized[:-1]
187
+ return normalized
188
+
189
+
190
+ def _tokens(value: str) -> tuple[str, ...]:
191
+ parts = (
192
+ re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", value)
193
+ .replace("_", " ")
194
+ .replace("-", " ")
195
+ )
196
+ return tuple(
197
+ sorted(
198
+ {
199
+ normalized
200
+ for token in WORD.findall(parts)
201
+ if (normalized := _subject(token)) and normalized not in STOP_SUBJECTS
202
+ }
203
+ )
204
+ )
205
+
206
+
207
+ def _slug(value: str) -> str:
208
+ visible = re.sub(r"[`*_]", "", value)
209
+ return re.sub(r"[^a-z0-9 -]", "", visible.lower()).replace(" ", "-").strip("-")
210
+
211
+
212
+ def _repository_files(root: Path) -> list[Path]:
213
+ try:
214
+ proc = subprocess.run(
215
+ [
216
+ "git",
217
+ "-C",
218
+ str(root),
219
+ "ls-files",
220
+ "--cached",
221
+ "--others",
222
+ "--exclude-standard",
223
+ "-z",
224
+ ],
225
+ capture_output=True,
226
+ timeout=30,
227
+ check=False,
228
+ )
229
+ except (OSError, subprocess.TimeoutExpired):
230
+ proc = None
231
+ if proc is not None and proc.returncode == 0:
232
+ candidates = [
233
+ root / item for item in proc.stdout.decode(errors="surrogateescape").split("\0")
234
+ if item
235
+ ]
236
+ else:
237
+ candidates = list(root.rglob("*"))
238
+ return sorted(
239
+ path
240
+ for path in candidates
241
+ if _inside_regular_file(root, path)
242
+ and not set(path.relative_to(root).parts) & SKIP_PARTS
243
+ and "docs/archive" not in path.relative_to(root).as_posix()
244
+ )
245
+
246
+
247
+ def _inside_regular_file(root: Path, path: Path) -> bool:
248
+ if path.is_symlink() or not path.is_file():
249
+ return False
250
+ try:
251
+ path.resolve(strict=True).relative_to(root)
252
+ except (OSError, ValueError):
253
+ return False
254
+ return True
255
+
256
+
257
+ def _assignment(node: ast.AST) -> tuple[str, ast.AST] | None:
258
+ if isinstance(node, ast.Assign):
259
+ target = next(
260
+ (
261
+ item.id
262
+ for item in node.targets
263
+ if isinstance(item, ast.Name)
264
+ ),
265
+ None,
266
+ )
267
+ return (target, node.value) if target is not None else None
268
+ if (
269
+ isinstance(node, ast.AnnAssign)
270
+ and isinstance(node.target, ast.Name)
271
+ and node.value is not None
272
+ ):
273
+ return node.target.id, node.value
274
+ return None
275
+
276
+
277
+ def _sequence_length(node: ast.AST) -> int | None:
278
+ if isinstance(node, (ast.List, ast.Tuple)):
279
+ return len(node.elts)
280
+ candidates: list[ast.AST | None]
281
+ if isinstance(node, ast.Set):
282
+ candidates = list(node.elts)
283
+ elif isinstance(node, ast.Dict):
284
+ candidates = list(node.keys)
285
+ else:
286
+ return None
287
+ if candidates:
288
+ keys: set[object] = set()
289
+ for key in candidates:
290
+ if key is None:
291
+ return None
292
+ try:
293
+ value = ast.literal_eval(key)
294
+ hash(value)
295
+ except (TypeError, ValueError):
296
+ return None
297
+ keys.add(value)
298
+ return len(keys)
299
+ return 0
300
+
301
+
302
+ def _mapping_keys(node: ast.AST) -> tuple[str, ...] | None:
303
+ if not isinstance(node, ast.Dict):
304
+ return None
305
+ keys: list[str] = []
306
+ for key in node.keys:
307
+ if not isinstance(key, ast.Constant) or not isinstance(key.value, str):
308
+ return None
309
+ if not key.value.startswith("_"):
310
+ keys.append(key.value)
311
+ return tuple(sorted(set(keys)))
312
+
313
+
314
+ def _keyword(node: ast.Call, name: str) -> ast.AST | None:
315
+ return next(
316
+ (
317
+ item.value
318
+ for item in node.keywords
319
+ if item.arg == name
320
+ ),
321
+ None,
322
+ )
323
+
324
+
325
+ def _source_facts(path: str, text: str) -> list[SourceFact]:
326
+ try:
327
+ tree = ast.parse(text, filename=path)
328
+ except SyntaxError:
329
+ return []
330
+ facts: list[SourceFact] = []
331
+ for node in tree.body:
332
+ assigned = _assignment(node)
333
+ if assigned is None:
334
+ continue
335
+ symbol, value = assigned
336
+ symbol_subjects = set(_tokens(symbol))
337
+ if isinstance(value, ast.Call):
338
+ name_value = _keyword(value, "name")
339
+ if (
340
+ isinstance(name_value, ast.Constant)
341
+ and isinstance(name_value.value, str)
342
+ ):
343
+ symbol_subjects.update(_tokens(name_value.value))
344
+ count = _sequence_length(value)
345
+ if count is not None:
346
+ facts.append(
347
+ SourceFact(
348
+ "count",
349
+ path,
350
+ f"{symbol}#count",
351
+ node.lineno,
352
+ tuple(sorted(symbol_subjects)),
353
+ count,
354
+ _digest(count),
355
+ )
356
+ )
357
+ if isinstance(value, (ast.List, ast.Tuple, ast.Set)):
358
+ totals: dict[str, int] = {}
359
+ seen: dict[str, int] = {}
360
+ for item in value.elts:
361
+ if not isinstance(item, ast.Call):
362
+ continue
363
+ for keyword in item.keywords:
364
+ if keyword.arg is None:
365
+ continue
366
+ length = _sequence_length(keyword.value)
367
+ if length is None:
368
+ continue
369
+ totals[keyword.arg] = totals.get(keyword.arg, 0) + length
370
+ seen[keyword.arg] = seen.get(keyword.arg, 0) + 1
371
+ for keyword_name, total in sorted(totals.items()):
372
+ if seen[keyword_name] != len(value.elts):
373
+ continue
374
+ facts.append(
375
+ SourceFact(
376
+ "count",
377
+ path,
378
+ f"{symbol}.{keyword_name}#count",
379
+ node.lineno,
380
+ tuple(sorted(set(_tokens(keyword_name)) | symbol_subjects)),
381
+ total,
382
+ _digest(total),
383
+ )
384
+ )
385
+ direct_keys = _mapping_keys(value)
386
+ if direct_keys is not None:
387
+ facts.append(
388
+ SourceFact(
389
+ "identifier-set",
390
+ path,
391
+ f"{symbol}#keys",
392
+ node.lineno,
393
+ tuple(sorted(symbol_subjects)),
394
+ direct_keys,
395
+ _digest(direct_keys),
396
+ )
397
+ )
398
+ if isinstance(value, ast.Call):
399
+ for keyword in value.keywords:
400
+ if keyword.arg is None:
401
+ continue
402
+ keys = _mapping_keys(keyword.value)
403
+ if keys is None:
404
+ continue
405
+ facts.append(
406
+ SourceFact(
407
+ "identifier-set",
408
+ path,
409
+ f"{symbol}.{keyword.arg}#keys",
410
+ node.lineno,
411
+ tuple(sorted(symbol_subjects)),
412
+ keys,
413
+ _digest(keys),
414
+ )
415
+ )
416
+ return facts
417
+
418
+
419
+ def extract_source_facts(path: str, text: str) -> tuple[SourceFact, ...]:
420
+ """Read supported facts from Python text without importing or executing it."""
421
+ return tuple(_source_facts(path, text))
422
+
423
+
424
+ def _count_claims(path: str, lines: list[str]) -> list[DocumentClaim]:
425
+ claims: list[DocumentClaim] = []
426
+ anchor = ""
427
+ in_fence = False
428
+ for line_number, line in enumerate(lines, start=1):
429
+ if line.startswith("```"):
430
+ in_fence = not in_fence
431
+ continue
432
+ if match := HEADING.match(line):
433
+ anchor = _slug(match.group(2))
434
+ continue
435
+ if in_fence:
436
+ continue
437
+ for number in NUMBER.finditer(line):
438
+ value = int(number.group(1).replace(",", ""))
439
+ tail = line[number.end():]
440
+ boundary = re.search(r"[().,;:]", tail)
441
+ if boundary:
442
+ tail = tail[:boundary.start()]
443
+ words = WORD.findall(tail)[:3]
444
+ if not words or not words[-1].lower().endswith("s"):
445
+ continue
446
+ subject = words[-1].lower()
447
+ if not _subject(subject) or _subject(subject) in STOP_SUBJECTS:
448
+ continue
449
+ claims.append(
450
+ DocumentClaim(
451
+ "count",
452
+ path,
453
+ anchor,
454
+ line_number,
455
+ subject,
456
+ value,
457
+ _digest(line),
458
+ )
459
+ )
460
+ return claims
461
+
462
+
463
+ def _table_subject(title: str) -> str:
464
+ inline = re.findall(r"`([^`]+)`", title)
465
+ if inline:
466
+ return _subject(inline[-1].split(".")[-1])
467
+ words = WORD.findall(title)
468
+ return _subject(words[0]) if words else ""
469
+
470
+
471
+ def _table_claims(path: str, lines: list[str]) -> list[DocumentClaim]:
472
+ claims: list[DocumentClaim] = []
473
+ parent_title = ""
474
+ parent_anchor = ""
475
+ collecting = False
476
+ table_subject = ""
477
+ table_anchor = ""
478
+ table_line = 0
479
+ keys: list[str] = []
480
+
481
+ def flush() -> None:
482
+ if table_subject and keys:
483
+ value = tuple(sorted(set(keys)))
484
+ claims.append(
485
+ DocumentClaim(
486
+ "identifier-set",
487
+ path,
488
+ table_anchor,
489
+ table_line,
490
+ table_subject,
491
+ value,
492
+ _digest(value),
493
+ )
494
+ )
495
+
496
+ for line_number, line in enumerate([*lines, "# end"], start=1):
497
+ heading = HEADING.match(line)
498
+ if heading:
499
+ title = heading.group(2)
500
+ if collecting:
501
+ flush()
502
+ collecting = False
503
+ keys = []
504
+ if _subject(title) == "column":
505
+ collecting = True
506
+ table_subject = _table_subject(parent_title)
507
+ table_anchor = parent_anchor
508
+ table_line = line_number + 1
509
+ else:
510
+ parent_title = title
511
+ parent_anchor = _slug(title)
512
+ continue
513
+ if not collecting:
514
+ continue
515
+ match = TABLE_KEY.match(line)
516
+ if match and match.group(1).lower() not in {"column", "type", "nullable"}:
517
+ keys.append(match.group(1))
518
+ return claims
519
+
520
+
521
+ def _relationship_rank(claim: DocumentClaim, fact: SourceFact) -> int:
522
+ doc = claim.doc
523
+ source = fact.source
524
+ anchor = claim.anchor
525
+ doc_parts = Path(doc).parent.parts
526
+ source_parts = Path(source).parent.parts
527
+ common = 0
528
+ for left, right in zip(doc_parts, source_parts):
529
+ if left != right:
530
+ break
531
+ common += 1
532
+ doc_stem = set(_tokens(Path(doc).stem)) - OWNERSHIP_STOP
533
+ anchor_context = set(_tokens(anchor))
534
+ source_context = set(_tokens(Path(source).stem)) - OWNERSHIP_STOP
535
+ locator_parts = fact.locator.rsplit("#", 1)[0].split(".")
536
+ root_subject = _subject(locator_parts[0])
537
+ claim_subject = _subject(claim.subject)
538
+ exact_root = claim_subject == root_subject
539
+ exact_nested = (
540
+ len(locator_parts) > 1
541
+ and claim_subject == _subject(locator_parts[-1])
542
+ )
543
+ same_parent = Path(doc).parent == Path(source).parent
544
+ stem_overlap = bool(doc_stem & source_context)
545
+ anchor_match = claim_subject in anchor_context
546
+ exact_locator = exact_root or exact_nested
547
+ if claim.kind == "identifier-set":
548
+ ownership = same_parent or stem_overlap or (exact_locator and anchor_match)
549
+ else:
550
+ ownership = same_parent or stem_overlap
551
+ if not ownership:
552
+ return 0
553
+ if not exact_locator and not (
554
+ same_parent and (stem_overlap or anchor_match)
555
+ ):
556
+ return 0
557
+
558
+ rank = min(common, 2) * 10
559
+ rank += 100 if same_parent else 0
560
+ rank += 300 if stem_overlap else 0
561
+ rank += 100 if anchor_match else 0
562
+ if exact_locator:
563
+ rank += 200
564
+ elif claim_subject in _tokens(locator_parts[0]):
565
+ rank += 50 if len(locator_parts) == 1 else 25
566
+ return rank
567
+
568
+
569
+ def _result(
570
+ *,
571
+ identifier: str,
572
+ claim: DocumentClaim,
573
+ fact: SourceFact,
574
+ authority: str,
575
+ rank: int,
576
+ ) -> SourceClaimResult:
577
+ status = "current" if claim.value == fact.value else "drift"
578
+ if claim.kind == "count":
579
+ detail = (
580
+ f"document says {claim.value} {claim.subject}; "
581
+ f"{fact.source}#{fact.locator} contains {fact.value}"
582
+ )
583
+ else:
584
+ assert isinstance(claim.value, tuple)
585
+ assert isinstance(fact.value, tuple)
586
+ document_keys = set(claim.value)
587
+ source_keys = set(fact.value)
588
+ missing = sorted(source_keys - document_keys)
589
+ extra = sorted(document_keys - source_keys)
590
+ detail = (
591
+ f"document/source identifier sets differ; missing from document: {missing}; "
592
+ f"not in source: {extra}"
593
+ )
594
+ return SourceClaimResult(
595
+ identifier,
596
+ claim.kind,
597
+ claim.doc,
598
+ claim.anchor,
599
+ claim.line,
600
+ claim.subject,
601
+ fact.source,
602
+ fact.locator,
603
+ fact.line,
604
+ claim.value,
605
+ fact.value,
606
+ status,
607
+ authority,
608
+ rank,
609
+ claim.digest,
610
+ fact.digest,
611
+ detail,
612
+ )
613
+
614
+
615
+ def _candidate_id(claim: DocumentClaim, fact: SourceFact) -> str:
616
+ payload = [
617
+ claim.kind,
618
+ claim.doc,
619
+ claim.anchor,
620
+ claim.subject,
621
+ fact.source,
622
+ fact.locator,
623
+ ]
624
+ return hashlib.sha256(
625
+ json.dumps(payload, separators=(",", ":")).encode()
626
+ ).hexdigest()
627
+
628
+
629
+ def _totals(results: list[SourceClaimResult]) -> tuple[tuple[str, int], ...]:
630
+ totals: dict[str, int] = {}
631
+ for result in results:
632
+ totals[result.status] = totals.get(result.status, 0) + 1
633
+ return tuple(sorted(totals.items()))
634
+
635
+
636
+ def scan_source_claims(
637
+ root: Path,
638
+ checks: tuple[SourceClaimCheck, ...] = (),
639
+ *,
640
+ discover: bool = True,
641
+ ) -> SourceClaimReport:
642
+ root = root.resolve()
643
+ facts: list[SourceFact] = []
644
+ claims: list[DocumentClaim] = []
645
+ accepted_docs = {check.doc.as_posix() for check in checks}
646
+ accepted_sources = {check.source.as_posix() for check in checks}
647
+ scoped_paths = accepted_docs | accepted_sources
648
+ paths = (
649
+ _repository_files(root)
650
+ if discover
651
+ else [
652
+ root / relative
653
+ for relative in sorted(scoped_paths)
654
+ if _inside_regular_file(root, root / relative)
655
+ ]
656
+ )
657
+ for path in paths:
658
+ relative = path.relative_to(root).as_posix()
659
+ try:
660
+ text = path.read_text(encoding="utf-8")
661
+ except (OSError, UnicodeDecodeError):
662
+ continue
663
+ if path.suffix == ".py":
664
+ facts.extend(_source_facts(relative, text))
665
+ elif path.suffix.lower() in {".md", ".mdx"}:
666
+ lines = text.splitlines()
667
+ profile = classify_document(Path(relative), text)
668
+ discoverable = discover and profile.role not in {
669
+ "architecture",
670
+ "evidence",
671
+ "plan",
672
+ "template",
673
+ }
674
+ if not discoverable and relative not in accepted_docs:
675
+ continue
676
+ claims.extend(_count_claims(relative, lines))
677
+ claims.extend(_table_claims(relative, lines))
678
+
679
+ fact_index = {
680
+ (fact.kind, fact.source, fact.locator): fact
681
+ for fact in facts
682
+ }
683
+ results: list[SourceClaimResult] = []
684
+ missing: list[MissingSourceClaim] = []
685
+ accepted_relationships: set[tuple[str, str, str, str, str, str]] = set()
686
+ for check in checks:
687
+ relationship = (
688
+ check.kind,
689
+ check.doc.as_posix(),
690
+ check.anchor,
691
+ _subject(check.subject),
692
+ check.source.as_posix(),
693
+ check.locator,
694
+ )
695
+ accepted_relationships.add(relationship)
696
+ fact = fact_index.get((check.kind, check.source.as_posix(), check.locator))
697
+ accepted_claims = [
698
+ claim
699
+ for claim in claims
700
+ if claim.kind == check.kind
701
+ and claim.doc == check.doc.as_posix()
702
+ and claim.anchor == check.anchor
703
+ and _subject(claim.subject) == _subject(check.subject)
704
+ ]
705
+ if fact is None or len(accepted_claims) != 1:
706
+ reason = (
707
+ "source locator was not found"
708
+ if fact is None
709
+ else f"document relationship resolved to {len(accepted_claims)} claims"
710
+ )
711
+ missing.append(
712
+ MissingSourceClaim(
713
+ check.id,
714
+ check.kind,
715
+ check.doc.as_posix(),
716
+ check.anchor,
717
+ check.subject,
718
+ check.source.as_posix(),
719
+ check.locator,
720
+ reason,
721
+ )
722
+ )
723
+ continue
724
+ results.append(
725
+ _result(
726
+ identifier=check.id,
727
+ claim=accepted_claims[0],
728
+ fact=fact,
729
+ authority="enforced",
730
+ rank=10_000,
731
+ )
732
+ )
733
+
734
+ candidates: list[SourceClaimResult] = []
735
+ for claim in claims if discover else ():
736
+ fact_matches = [
737
+ fact
738
+ for fact in facts
739
+ if fact.kind == claim.kind and _subject(claim.subject) in fact.subjects
740
+ ]
741
+ if not fact_matches:
742
+ continue
743
+ ranked = sorted(
744
+ (
745
+ (_relationship_rank(claim, fact), fact)
746
+ for fact in fact_matches
747
+ ),
748
+ key=lambda item: (-item[0], item[1].source, item[1].locator),
749
+ )
750
+ best_rank = ranked[0][0]
751
+ if best_rank < 100:
752
+ continue
753
+ best = [fact for rank, fact in ranked if rank == best_rank]
754
+ if len(best) != 1:
755
+ continue
756
+ fact = best[0]
757
+ relationship = (
758
+ claim.kind,
759
+ claim.doc,
760
+ claim.anchor,
761
+ _subject(claim.subject),
762
+ fact.source,
763
+ fact.locator,
764
+ )
765
+ if relationship in accepted_relationships:
766
+ continue
767
+ candidates.append(
768
+ _result(
769
+ identifier=_candidate_id(claim, fact),
770
+ claim=claim,
771
+ fact=fact,
772
+ authority="assessment",
773
+ rank=best_rank,
774
+ )
775
+ )
776
+ candidates.sort(
777
+ key=lambda item: (
778
+ -item.rank,
779
+ item.status != "drift",
780
+ item.doc,
781
+ item.line,
782
+ item.source,
783
+ item.locator,
784
+ )
785
+ )
786
+ bounded = tuple(candidates[:MAX_RANKED_CANDIDATES])
787
+ return SourceClaimReport(
788
+ "enforced" if checks else "assessment",
789
+ tuple(sorted(results, key=lambda item: item.id)),
790
+ bounded,
791
+ tuple(sorted(missing, key=lambda item: item.id)),
792
+ _totals(candidates),
793
+ )
794
+
795
+
796
+ def claim_binding_results(
797
+ report: SourceClaimReport,
798
+ *,
799
+ ref: str,
800
+ ) -> list[BindingResult]:
801
+ """Project accepted claim checks into the existing read-only check contract."""
802
+ results = [
803
+ BindingResult(
804
+ binding_id=item.id,
805
+ doc=item.doc,
806
+ changed=item.status == "drift",
807
+ expected=json.dumps(item.source_value, sort_keys=True),
808
+ observed=json.dumps(item.document_value, sort_keys=True),
809
+ diff="" if item.status == "current" else item.detail + "\n",
810
+ provenance=Provenance(
811
+ ref,
812
+ item.source,
813
+ item.locator,
814
+ f"source-claim-{item.kind}@1",
815
+ item.source_digest,
816
+ ),
817
+ binding_type="source-claim",
818
+ )
819
+ for item in report.results
820
+ ]
821
+ for item in report.missing:
822
+ results.append(
823
+ BindingResult(
824
+ binding_id=item.id,
825
+ doc=item.doc,
826
+ changed=True,
827
+ expected="accepted source claim relationship",
828
+ observed="missing",
829
+ diff=f"accepted source claim {item.id} cannot be verified: {item.detail}\n",
830
+ provenance=Provenance(
831
+ ref,
832
+ item.source,
833
+ item.locator,
834
+ f"source-claim-{item.kind}@1",
835
+ _digest([item.source, item.locator]),
836
+ ),
837
+ binding_type="source-claim",
838
+ )
839
+ )
840
+ return sorted(results, key=lambda item: item.binding_id)