sourcebound 1.2.1rc1__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.1rc1.dist-info/LICENSE +21 -0
- sourcebound-1.2.1rc1.dist-info/METADATA +109 -0
- sourcebound-1.2.1rc1.dist-info/RECORD +71 -0
- sourcebound-1.2.1rc1.dist-info/WHEEL +5 -0
- sourcebound-1.2.1rc1.dist-info/entry_points.txt +2 -0
- sourcebound-1.2.1rc1.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)
|