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/audit.py
ADDED
|
@@ -0,0 +1,1120 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import posixpath
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from urllib.parse import unquote
|
|
11
|
+
|
|
12
|
+
from clean_docs.applicability import (
|
|
13
|
+
DocumentProfile,
|
|
14
|
+
classify_document,
|
|
15
|
+
frontmatter_error,
|
|
16
|
+
role_override_error,
|
|
17
|
+
)
|
|
18
|
+
from clean_docs.corpus import _git_visible_markdown, _is_document_candidate, scan_corpus
|
|
19
|
+
from clean_docs.errors import ConfigurationError
|
|
20
|
+
from clean_docs.mdx import (
|
|
21
|
+
MdxDocument,
|
|
22
|
+
MdxParserError,
|
|
23
|
+
parse_mdx_documents,
|
|
24
|
+
parser_availability,
|
|
25
|
+
)
|
|
26
|
+
from clean_docs.policy import REGISTER_PROFILE, check_document
|
|
27
|
+
from clean_docs.regions import atomic_write
|
|
28
|
+
from clean_docs.residue import scan_residue
|
|
29
|
+
from clean_docs.standard import load_default_pack
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
LINK = re.compile(
|
|
33
|
+
r"\[[^\]]+\]\(\s*(<[^>\n]*>|\[[^\]\n]+\]|\{[^}\n]+\}|[^)\s]+)"
|
|
34
|
+
)
|
|
35
|
+
HEADING = re.compile(r"^#{2,}\s+(.+?)\s*$")
|
|
36
|
+
IDENTITY_HEADING = re.compile(r"^#{1,6}\s+(.+?)\s*$")
|
|
37
|
+
PURPOSE_BLOCK = re.compile(
|
|
38
|
+
r"<!-- sourcebound:purpose -->\s*(.*?)\s*<!-- sourcebound:end purpose -->",
|
|
39
|
+
re.DOTALL,
|
|
40
|
+
)
|
|
41
|
+
STOCK_PURPOSE_OPENING = re.compile(
|
|
42
|
+
r"^Use this (?:guide|model|page|path|policy|reference|specification) when\b",
|
|
43
|
+
re.IGNORECASE,
|
|
44
|
+
)
|
|
45
|
+
ALLOW = re.compile(
|
|
46
|
+
r'<!--\s*sourcebound:allow\s+([a-z][a-z-]+)\s+reason="([^"]+)"\s*-->'
|
|
47
|
+
)
|
|
48
|
+
AUDIT_BASELINE_SCHEMA_V1 = "sourcebound.audit-baseline.v1"
|
|
49
|
+
AUDIT_BASELINE_SCHEMA = "sourcebound.audit-baseline.v2"
|
|
50
|
+
AUDIT_BASELINE_PATH = Path(".sourcebound/audit-baseline.json")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _is_test_fixture_path(value: str) -> bool:
|
|
54
|
+
path = Path(value)
|
|
55
|
+
parts = set(path.parts)
|
|
56
|
+
name = path.name
|
|
57
|
+
return bool(
|
|
58
|
+
parts & {"test", "tests", "__tests__", "fixtures", "__fixtures__"}
|
|
59
|
+
or ".test." in name
|
|
60
|
+
or ".spec." in name
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class AuditFinding:
|
|
66
|
+
rule: str
|
|
67
|
+
path: str
|
|
68
|
+
line: int
|
|
69
|
+
detail: str
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class AuditReport:
|
|
74
|
+
documents: tuple[str, ...]
|
|
75
|
+
ignored_documents: tuple[str, ...]
|
|
76
|
+
findings: tuple[AuditFinding, ...]
|
|
77
|
+
baselined_findings: tuple[AuditFinding, ...] = ()
|
|
78
|
+
stale_baseline: tuple[AuditFinding, ...] = ()
|
|
79
|
+
unsupported_documents: tuple[str, ...] = ()
|
|
80
|
+
advisories: tuple[AuditFinding, ...] = ()
|
|
81
|
+
advisory_totals: tuple[tuple[str, int], ...] = ()
|
|
82
|
+
document_profiles: tuple[DocumentProfile, ...] = ()
|
|
83
|
+
repository_integrity_enforced: bool = False
|
|
84
|
+
policy_preview: bool = False
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def ok(self) -> bool:
|
|
88
|
+
return not self.findings and not self.stale_baseline
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class _BaselineIdentity:
|
|
93
|
+
finding: AuditFinding
|
|
94
|
+
normalized: str
|
|
95
|
+
section_anchor: str
|
|
96
|
+
duplicate_ordinal: int
|
|
97
|
+
fingerprint: str
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True)
|
|
101
|
+
class _LoadedBaseline:
|
|
102
|
+
schema: str
|
|
103
|
+
identities: tuple[_BaselineIdentity, ...]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _normalized_finding_content(finding: AuditFinding) -> str:
|
|
107
|
+
return " ".join(finding.detail.split())
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def finding_fingerprint(
|
|
111
|
+
finding: AuditFinding,
|
|
112
|
+
*,
|
|
113
|
+
section_anchor: str = "__document__",
|
|
114
|
+
duplicate_ordinal: int = 1,
|
|
115
|
+
) -> str:
|
|
116
|
+
payload = json.dumps(
|
|
117
|
+
{
|
|
118
|
+
"duplicate_ordinal": duplicate_ordinal,
|
|
119
|
+
"normalized": _normalized_finding_content(finding),
|
|
120
|
+
"path": finding.path,
|
|
121
|
+
"rule": finding.rule,
|
|
122
|
+
"section_anchor": section_anchor,
|
|
123
|
+
},
|
|
124
|
+
sort_keys=True,
|
|
125
|
+
separators=(",", ":"),
|
|
126
|
+
)
|
|
127
|
+
return hashlib.sha256(payload.encode()).hexdigest()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _finding_order(finding: AuditFinding) -> tuple[str, int, str, str]:
|
|
131
|
+
return (finding.path, finding.line, finding.rule, finding.detail)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _legacy_finding_fingerprint(finding: AuditFinding) -> str:
|
|
135
|
+
payload = json.dumps(
|
|
136
|
+
{
|
|
137
|
+
"detail": finding.detail,
|
|
138
|
+
"line": finding.line,
|
|
139
|
+
"path": finding.path,
|
|
140
|
+
"rule": finding.rule,
|
|
141
|
+
},
|
|
142
|
+
sort_keys=True,
|
|
143
|
+
separators=(",", ":"),
|
|
144
|
+
)
|
|
145
|
+
return hashlib.sha256(payload.encode()).hexdigest()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _section_anchor(root: Path | None, finding: AuditFinding) -> str:
|
|
149
|
+
if root is None:
|
|
150
|
+
return "__document__"
|
|
151
|
+
try:
|
|
152
|
+
text = (root / finding.path).read_text(encoding="utf-8")
|
|
153
|
+
if Path(finding.path).suffix.lower() == ".mdx":
|
|
154
|
+
text = parse_mdx_documents({finding.path: text})[0][
|
|
155
|
+
finding.path
|
|
156
|
+
].policy_text(text)
|
|
157
|
+
lines = text.splitlines()
|
|
158
|
+
except (OSError, MdxParserError, KeyError):
|
|
159
|
+
return "__document__"
|
|
160
|
+
anchor = "__document__"
|
|
161
|
+
for line in lines[: finding.line]:
|
|
162
|
+
if match := IDENTITY_HEADING.match(line):
|
|
163
|
+
anchor = re.sub(
|
|
164
|
+
r"-+",
|
|
165
|
+
"-",
|
|
166
|
+
re.sub(r"[^a-z0-9]+", "-", match.group(1).lower()),
|
|
167
|
+
).strip("-") or "__document__"
|
|
168
|
+
return anchor
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _baseline_identities(
|
|
172
|
+
findings: tuple[AuditFinding, ...],
|
|
173
|
+
*,
|
|
174
|
+
root: Path | None,
|
|
175
|
+
) -> tuple[_BaselineIdentity, ...]:
|
|
176
|
+
prepared = [
|
|
177
|
+
(
|
|
178
|
+
finding,
|
|
179
|
+
_normalized_finding_content(finding),
|
|
180
|
+
_section_anchor(root, finding),
|
|
181
|
+
)
|
|
182
|
+
for finding in findings
|
|
183
|
+
]
|
|
184
|
+
prepared.sort(
|
|
185
|
+
key=lambda item: (
|
|
186
|
+
item[0].path,
|
|
187
|
+
item[0].rule,
|
|
188
|
+
item[1],
|
|
189
|
+
item[2],
|
|
190
|
+
item[0].line,
|
|
191
|
+
item[0].detail,
|
|
192
|
+
)
|
|
193
|
+
)
|
|
194
|
+
counts: dict[tuple[str, str, str, str], int] = {}
|
|
195
|
+
identities: list[_BaselineIdentity] = []
|
|
196
|
+
for finding, normalized, section_anchor in prepared:
|
|
197
|
+
key = (finding.path, finding.rule, normalized, section_anchor)
|
|
198
|
+
duplicate_ordinal = counts.get(key, 0) + 1
|
|
199
|
+
counts[key] = duplicate_ordinal
|
|
200
|
+
identities.append(
|
|
201
|
+
_BaselineIdentity(
|
|
202
|
+
finding,
|
|
203
|
+
normalized,
|
|
204
|
+
section_anchor,
|
|
205
|
+
duplicate_ordinal,
|
|
206
|
+
finding_fingerprint(
|
|
207
|
+
finding,
|
|
208
|
+
section_anchor=section_anchor,
|
|
209
|
+
duplicate_ordinal=duplicate_ordinal,
|
|
210
|
+
),
|
|
211
|
+
)
|
|
212
|
+
)
|
|
213
|
+
return tuple(identities)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _bounded_advisories(
|
|
217
|
+
findings: list[AuditFinding],
|
|
218
|
+
*,
|
|
219
|
+
per_rule: int = 3,
|
|
220
|
+
) -> tuple[tuple[AuditFinding, ...], tuple[tuple[str, int], ...]]:
|
|
221
|
+
totals: dict[str, int] = {}
|
|
222
|
+
selected: list[AuditFinding] = []
|
|
223
|
+
emitted: dict[str, int] = {}
|
|
224
|
+
for finding in sorted(findings, key=_finding_order):
|
|
225
|
+
totals[finding.rule] = totals.get(finding.rule, 0) + 1
|
|
226
|
+
count = emitted.get(finding.rule, 0)
|
|
227
|
+
if count < per_rule:
|
|
228
|
+
selected.append(finding)
|
|
229
|
+
emitted[finding.rule] = count + 1
|
|
230
|
+
return tuple(selected), tuple(sorted(totals.items()))
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def render_audit_baseline(
|
|
234
|
+
findings: tuple[AuditFinding, ...],
|
|
235
|
+
*,
|
|
236
|
+
root: Path | None = None,
|
|
237
|
+
) -> str:
|
|
238
|
+
entries = []
|
|
239
|
+
for identity in _baseline_identities(findings, root=root):
|
|
240
|
+
finding = identity.finding
|
|
241
|
+
entries.append({
|
|
242
|
+
"fingerprint": identity.fingerprint,
|
|
243
|
+
"rule": finding.rule,
|
|
244
|
+
"path": finding.path,
|
|
245
|
+
"line_hint": finding.line,
|
|
246
|
+
"detail": finding.detail,
|
|
247
|
+
"normalized": identity.normalized,
|
|
248
|
+
"section_anchor": identity.section_anchor,
|
|
249
|
+
"duplicate_ordinal": identity.duplicate_ordinal,
|
|
250
|
+
})
|
|
251
|
+
return json.dumps(
|
|
252
|
+
{"schema": AUDIT_BASELINE_SCHEMA, "findings": entries},
|
|
253
|
+
indent=2,
|
|
254
|
+
) + "\n"
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _load_audit_baseline(path: Path) -> _LoadedBaseline:
|
|
258
|
+
if path.is_symlink():
|
|
259
|
+
raise ConfigurationError(f"audit baseline cannot be a symbolic link: {path}")
|
|
260
|
+
try:
|
|
261
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
262
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
263
|
+
raise ConfigurationError(f"cannot read audit baseline {path}: {exc}") from exc
|
|
264
|
+
if not isinstance(raw, dict) or raw.get("schema") not in {
|
|
265
|
+
AUDIT_BASELINE_SCHEMA_V1,
|
|
266
|
+
AUDIT_BASELINE_SCHEMA,
|
|
267
|
+
}:
|
|
268
|
+
raise ConfigurationError(f"audit baseline has an unsupported schema: {path}")
|
|
269
|
+
schema = raw["schema"]
|
|
270
|
+
entries = raw.get("findings")
|
|
271
|
+
if not isinstance(entries, list):
|
|
272
|
+
raise ConfigurationError(f"audit baseline findings must be a list: {path}")
|
|
273
|
+
identities: list[_BaselineIdentity] = []
|
|
274
|
+
fingerprints: set[str] = set()
|
|
275
|
+
identity_keys: set[tuple[str, str, str, str, int]] = set()
|
|
276
|
+
for index, entry in enumerate(entries):
|
|
277
|
+
if not isinstance(entry, dict):
|
|
278
|
+
raise ConfigurationError(f"audit baseline finding {index} must be an object")
|
|
279
|
+
expected_keys = (
|
|
280
|
+
{"fingerprint", "rule", "path", "line", "detail"}
|
|
281
|
+
if schema == AUDIT_BASELINE_SCHEMA_V1
|
|
282
|
+
else {
|
|
283
|
+
"fingerprint",
|
|
284
|
+
"rule",
|
|
285
|
+
"path",
|
|
286
|
+
"line_hint",
|
|
287
|
+
"detail",
|
|
288
|
+
"normalized",
|
|
289
|
+
"section_anchor",
|
|
290
|
+
"duplicate_ordinal",
|
|
291
|
+
}
|
|
292
|
+
)
|
|
293
|
+
if set(entry) != expected_keys:
|
|
294
|
+
raise ConfigurationError(
|
|
295
|
+
f"audit baseline finding {index} has fields that do not match {schema}"
|
|
296
|
+
)
|
|
297
|
+
if not all(
|
|
298
|
+
isinstance(entry[key], str)
|
|
299
|
+
for key in ("fingerprint", "rule", "path", "detail")
|
|
300
|
+
):
|
|
301
|
+
raise ConfigurationError(f"audit baseline finding {index} has an invalid string field")
|
|
302
|
+
line_key = "line" if schema == AUDIT_BASELINE_SCHEMA_V1 else "line_hint"
|
|
303
|
+
if (
|
|
304
|
+
not isinstance(entry[line_key], int)
|
|
305
|
+
or isinstance(entry[line_key], bool)
|
|
306
|
+
or entry[line_key] < 1
|
|
307
|
+
):
|
|
308
|
+
raise ConfigurationError(f"audit baseline finding {index} has an invalid line")
|
|
309
|
+
finding = AuditFinding(
|
|
310
|
+
entry["rule"],
|
|
311
|
+
entry["path"],
|
|
312
|
+
entry[line_key],
|
|
313
|
+
entry["detail"],
|
|
314
|
+
)
|
|
315
|
+
if schema == AUDIT_BASELINE_SCHEMA_V1:
|
|
316
|
+
normalized = _normalized_finding_content(finding)
|
|
317
|
+
section_anchor = "__legacy_line__"
|
|
318
|
+
duplicate_ordinal = 1
|
|
319
|
+
fingerprint = _legacy_finding_fingerprint(finding)
|
|
320
|
+
else:
|
|
321
|
+
if not all(
|
|
322
|
+
isinstance(entry[key], str)
|
|
323
|
+
for key in ("normalized", "section_anchor")
|
|
324
|
+
):
|
|
325
|
+
raise ConfigurationError(
|
|
326
|
+
f"audit baseline finding {index} has invalid identity material"
|
|
327
|
+
)
|
|
328
|
+
duplicate_ordinal = entry["duplicate_ordinal"]
|
|
329
|
+
if (
|
|
330
|
+
not isinstance(duplicate_ordinal, int)
|
|
331
|
+
or isinstance(duplicate_ordinal, bool)
|
|
332
|
+
or duplicate_ordinal < 1
|
|
333
|
+
):
|
|
334
|
+
raise ConfigurationError(
|
|
335
|
+
f"audit baseline finding {index} has an invalid duplicate ordinal"
|
|
336
|
+
)
|
|
337
|
+
normalized = entry["normalized"]
|
|
338
|
+
section_anchor = entry["section_anchor"]
|
|
339
|
+
if normalized != _normalized_finding_content(finding):
|
|
340
|
+
raise ConfigurationError(
|
|
341
|
+
f"audit baseline finding {index} normalized content does not match"
|
|
342
|
+
)
|
|
343
|
+
fingerprint = finding_fingerprint(
|
|
344
|
+
finding,
|
|
345
|
+
section_anchor=section_anchor,
|
|
346
|
+
duplicate_ordinal=duplicate_ordinal,
|
|
347
|
+
)
|
|
348
|
+
if entry["fingerprint"] != fingerprint:
|
|
349
|
+
raise ConfigurationError(f"audit baseline finding {index} fingerprint does not match")
|
|
350
|
+
if fingerprint in fingerprints:
|
|
351
|
+
raise ConfigurationError(f"audit baseline has duplicate finding {fingerprint}")
|
|
352
|
+
identity_key = (
|
|
353
|
+
finding.path,
|
|
354
|
+
finding.rule,
|
|
355
|
+
normalized,
|
|
356
|
+
section_anchor,
|
|
357
|
+
duplicate_ordinal,
|
|
358
|
+
)
|
|
359
|
+
if schema == AUDIT_BASELINE_SCHEMA and identity_key in identity_keys:
|
|
360
|
+
raise ConfigurationError(
|
|
361
|
+
f"audit baseline has duplicate identity at finding {index}"
|
|
362
|
+
)
|
|
363
|
+
fingerprints.add(fingerprint)
|
|
364
|
+
identity_keys.add(identity_key)
|
|
365
|
+
identities.append(
|
|
366
|
+
_BaselineIdentity(
|
|
367
|
+
finding,
|
|
368
|
+
normalized,
|
|
369
|
+
section_anchor,
|
|
370
|
+
duplicate_ordinal,
|
|
371
|
+
fingerprint,
|
|
372
|
+
)
|
|
373
|
+
)
|
|
374
|
+
identities.sort(key=lambda item: item.fingerprint)
|
|
375
|
+
return _LoadedBaseline(schema, tuple(identities))
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _tracked_markdown(root: Path) -> list[Path]:
|
|
379
|
+
visible = _git_visible_markdown(root)
|
|
380
|
+
if visible is not None:
|
|
381
|
+
return [
|
|
382
|
+
relative
|
|
383
|
+
for path in visible
|
|
384
|
+
if _is_document_candidate(
|
|
385
|
+
relative := path.relative_to(root),
|
|
386
|
+
fallback=False,
|
|
387
|
+
)
|
|
388
|
+
]
|
|
389
|
+
return sorted(
|
|
390
|
+
relative
|
|
391
|
+
for pattern in ("*.md", "*.mdx")
|
|
392
|
+
for path in root.rglob(pattern)
|
|
393
|
+
if _is_document_candidate(
|
|
394
|
+
relative := path.relative_to(root),
|
|
395
|
+
fallback=True,
|
|
396
|
+
)
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _repository_entries(root: Path) -> set[str]:
|
|
401
|
+
try:
|
|
402
|
+
proc = subprocess.run(
|
|
403
|
+
[
|
|
404
|
+
"git",
|
|
405
|
+
"-C",
|
|
406
|
+
str(root),
|
|
407
|
+
"ls-files",
|
|
408
|
+
"--cached",
|
|
409
|
+
"--others",
|
|
410
|
+
"--exclude-standard",
|
|
411
|
+
"-z",
|
|
412
|
+
],
|
|
413
|
+
capture_output=True,
|
|
414
|
+
timeout=20,
|
|
415
|
+
check=False,
|
|
416
|
+
)
|
|
417
|
+
except (subprocess.SubprocessError, OSError):
|
|
418
|
+
proc = None
|
|
419
|
+
if proc is not None and proc.returncode == 0:
|
|
420
|
+
return {
|
|
421
|
+
path
|
|
422
|
+
for path in proc.stdout.decode(errors="surrogateescape").split("\0")
|
|
423
|
+
if path
|
|
424
|
+
}
|
|
425
|
+
return {
|
|
426
|
+
path.relative_to(root).as_posix()
|
|
427
|
+
for path in root.rglob("*")
|
|
428
|
+
if path.is_file()
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _hidden_document(relative: Path) -> bool:
|
|
433
|
+
hidden = [part for part in relative.parts if part.startswith(".")]
|
|
434
|
+
return bool(hidden) and relative.parts[0] != ".agents"
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _allowances(lines: list[str]) -> set[str]:
|
|
438
|
+
allowed: set[str] = set()
|
|
439
|
+
for line in lines:
|
|
440
|
+
match = ALLOW.search(line)
|
|
441
|
+
if match and len(match.group(2).strip()) >= 12:
|
|
442
|
+
allowed.add(match.group(1))
|
|
443
|
+
return allowed
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _allowance_records(lines: list[str]) -> list[tuple[int, str, str]]:
|
|
447
|
+
records: list[tuple[int, str, str]] = []
|
|
448
|
+
for line_number, line in enumerate(lines, start=1):
|
|
449
|
+
if match := ALLOW.search(line):
|
|
450
|
+
records.append((line_number, match.group(1), match.group(2).strip()))
|
|
451
|
+
return records
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _section_ranges(lines: list[str]) -> list[tuple[str, int, int, set[str]]]:
|
|
455
|
+
headings = [
|
|
456
|
+
(index, match.group(1))
|
|
457
|
+
for index, line in enumerate(lines)
|
|
458
|
+
if (match := HEADING.match(line))
|
|
459
|
+
]
|
|
460
|
+
sections = []
|
|
461
|
+
for position, (start, title) in enumerate(headings):
|
|
462
|
+
end = headings[position + 1][0] if position + 1 < len(headings) else len(lines)
|
|
463
|
+
sections.append((title, start + 1, end - start, _allowances(lines[start:end])))
|
|
464
|
+
return sections
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _local_link(target: str) -> bool:
|
|
468
|
+
return not target.startswith(("#", "http://", "https://", "mailto:", "data:"))
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _mask_inline_code(line: str) -> str:
|
|
472
|
+
result = list(line)
|
|
473
|
+
index = 0
|
|
474
|
+
while index < len(line):
|
|
475
|
+
if line[index] != "`" or (index > 0 and line[index - 1] == "\\"):
|
|
476
|
+
index += 1
|
|
477
|
+
continue
|
|
478
|
+
width = 1
|
|
479
|
+
while index + width < len(line) and line[index + width] == "`":
|
|
480
|
+
width += 1
|
|
481
|
+
end = line.find("`" * width, index + width)
|
|
482
|
+
if end == -1:
|
|
483
|
+
index += width
|
|
484
|
+
continue
|
|
485
|
+
for position in range(index, end + width):
|
|
486
|
+
result[position] = " "
|
|
487
|
+
index = end + width
|
|
488
|
+
return "".join(result)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _markdown_links(lines: list[str]) -> list[tuple[int, str]]:
|
|
492
|
+
links: list[tuple[int, str]] = []
|
|
493
|
+
fence: tuple[str, int] | None = None
|
|
494
|
+
for line_number, line in enumerate(lines, start=1):
|
|
495
|
+
fence_match = re.match(r"^\s{0,3}(`{3,}|~{3,})", line)
|
|
496
|
+
if fence_match:
|
|
497
|
+
marker = fence_match.group(1)
|
|
498
|
+
if fence is None:
|
|
499
|
+
fence = (marker[0], len(marker))
|
|
500
|
+
elif marker[0] == fence[0] and len(marker) >= fence[1]:
|
|
501
|
+
fence = None
|
|
502
|
+
continue
|
|
503
|
+
if fence is not None:
|
|
504
|
+
continue
|
|
505
|
+
visible = _mask_inline_code(line)
|
|
506
|
+
for match in LINK.finditer(visible):
|
|
507
|
+
links.append((line_number, match.group(1)))
|
|
508
|
+
return links
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _placeholder_link_target(target: str) -> bool:
|
|
512
|
+
candidate = target.strip()
|
|
513
|
+
if candidate in {"...", "…"} or "…" in candidate:
|
|
514
|
+
return True
|
|
515
|
+
if re.search(r"<(?:[A-Za-z][A-Za-z0-9_-]*)>", candidate):
|
|
516
|
+
return True
|
|
517
|
+
if re.search(r"\{(?:[A-Za-z][A-Za-z0-9_-]*)}", candidate):
|
|
518
|
+
return True
|
|
519
|
+
if (
|
|
520
|
+
candidate.startswith("[")
|
|
521
|
+
and candidate.endswith("]")
|
|
522
|
+
and re.search(r"\s", candidate[1:-1])
|
|
523
|
+
):
|
|
524
|
+
return True
|
|
525
|
+
if (
|
|
526
|
+
candidate.startswith("<")
|
|
527
|
+
and candidate.endswith(">")
|
|
528
|
+
and re.search(r"\s", candidate[1:-1])
|
|
529
|
+
):
|
|
530
|
+
return True
|
|
531
|
+
return False
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _entry_exists(entries: set[str], candidate: str) -> bool:
|
|
535
|
+
normalized = candidate.rstrip("/")
|
|
536
|
+
return normalized in entries or any(
|
|
537
|
+
entry.startswith(normalized + "/") for entry in entries
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _link_target_exists(
|
|
542
|
+
root: Path,
|
|
543
|
+
source: Path,
|
|
544
|
+
raw_target: str,
|
|
545
|
+
entries: set[str],
|
|
546
|
+
) -> bool:
|
|
547
|
+
target = unquote(raw_target.split("#", 1)[0].split("?", 1)[0]).strip()
|
|
548
|
+
if target.startswith("<") and target.endswith(">"):
|
|
549
|
+
target = target[1:-1]
|
|
550
|
+
if not target or not _local_link(target):
|
|
551
|
+
return True
|
|
552
|
+
repository_root = target.startswith("/")
|
|
553
|
+
if repository_root:
|
|
554
|
+
candidate = posixpath.normpath(target.lstrip("/"))
|
|
555
|
+
else:
|
|
556
|
+
candidate = posixpath.normpath(
|
|
557
|
+
posixpath.join(source.parent.as_posix(), target)
|
|
558
|
+
)
|
|
559
|
+
if candidate == ".." or candidate.startswith("../"):
|
|
560
|
+
return False
|
|
561
|
+
candidates = [candidate]
|
|
562
|
+
if not Path(candidate).suffix:
|
|
563
|
+
candidates.extend(
|
|
564
|
+
(
|
|
565
|
+
candidate + ".md",
|
|
566
|
+
candidate + ".mdx",
|
|
567
|
+
posixpath.join(candidate, "README.md"),
|
|
568
|
+
posixpath.join(candidate, "index.md"),
|
|
569
|
+
posixpath.join(candidate, "index.mdx"),
|
|
570
|
+
)
|
|
571
|
+
)
|
|
572
|
+
if any(_entry_exists(entries, item) for item in candidates):
|
|
573
|
+
return True
|
|
574
|
+
if any((root / item).exists() for item in candidates):
|
|
575
|
+
return True
|
|
576
|
+
# A leading slash can address an application or publication mount. Without
|
|
577
|
+
# a declared mount, treating it as a repository path creates false blockers.
|
|
578
|
+
return repository_root
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def _outside_fences(lines: list[str]) -> list[tuple[int, str]]:
|
|
582
|
+
result: list[tuple[int, str]] = []
|
|
583
|
+
in_fence = False
|
|
584
|
+
for line_number, line in enumerate(lines, start=1):
|
|
585
|
+
if line.startswith("```"):
|
|
586
|
+
in_fence = not in_fence
|
|
587
|
+
continue
|
|
588
|
+
if not in_fence:
|
|
589
|
+
result.append((line_number, line))
|
|
590
|
+
return result
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def _page_type(relative: Path, text: str) -> str:
|
|
594
|
+
name = relative.name.upper()
|
|
595
|
+
if relative.name == "README.md":
|
|
596
|
+
return "readme"
|
|
597
|
+
if (
|
|
598
|
+
"REFERENCE" in name
|
|
599
|
+
or name in {
|
|
600
|
+
"STANDARD.MD",
|
|
601
|
+
"SOURCEBOUND_SPEC.MD",
|
|
602
|
+
"DECISION_LOG.MD",
|
|
603
|
+
"CLI.MD",
|
|
604
|
+
"RELEASES.MD",
|
|
605
|
+
"SURFACE.MD",
|
|
606
|
+
}
|
|
607
|
+
or text.lstrip().lower().startswith("# reference")
|
|
608
|
+
):
|
|
609
|
+
return "reference"
|
|
610
|
+
return "guide"
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def _section_depth_findings(
|
|
614
|
+
normalized: str,
|
|
615
|
+
lines: list[str],
|
|
616
|
+
*,
|
|
617
|
+
require_routes: bool,
|
|
618
|
+
require_depth_links: bool,
|
|
619
|
+
) -> list[AuditFinding]:
|
|
620
|
+
findings: list[AuditFinding] = []
|
|
621
|
+
text = "\n".join(lines)
|
|
622
|
+
allowances = _allowances(lines)
|
|
623
|
+
if (
|
|
624
|
+
require_routes
|
|
625
|
+
and normalized == "README.md"
|
|
626
|
+
and "readme-routing" not in allowances
|
|
627
|
+
):
|
|
628
|
+
if not all(
|
|
629
|
+
marker in text
|
|
630
|
+
for marker in ("If you need to", "Start with", "You will leave with")
|
|
631
|
+
):
|
|
632
|
+
findings.append(AuditFinding(
|
|
633
|
+
"readme-routing",
|
|
634
|
+
normalized,
|
|
635
|
+
1,
|
|
636
|
+
"add the required job-to-page routing table near the first action",
|
|
637
|
+
))
|
|
638
|
+
in_fence = False
|
|
639
|
+
fence_start = 0
|
|
640
|
+
for line_number, line in enumerate(lines, start=1):
|
|
641
|
+
if line.startswith("```"):
|
|
642
|
+
if in_fence and line_number - fence_start > 13:
|
|
643
|
+
findings.append(AuditFinding(
|
|
644
|
+
"readme-reference-depth",
|
|
645
|
+
normalized,
|
|
646
|
+
fence_start,
|
|
647
|
+
"move configuration or schema examples over 12 lines to a reference page",
|
|
648
|
+
))
|
|
649
|
+
else:
|
|
650
|
+
fence_start = line_number
|
|
651
|
+
in_fence = not in_fence
|
|
652
|
+
table_start = 0
|
|
653
|
+
table_lines = 0
|
|
654
|
+
for line_number, line in enumerate([*lines, ""], start=1):
|
|
655
|
+
if line.startswith("|"):
|
|
656
|
+
if table_lines == 0:
|
|
657
|
+
table_start = line_number
|
|
658
|
+
table_lines += 1
|
|
659
|
+
else:
|
|
660
|
+
if table_lines > 14:
|
|
661
|
+
findings.append(AuditFinding(
|
|
662
|
+
"readme-reference-depth",
|
|
663
|
+
normalized,
|
|
664
|
+
table_start,
|
|
665
|
+
"move lookup tables over 12 data rows to a reference page",
|
|
666
|
+
))
|
|
667
|
+
table_lines = 0
|
|
668
|
+
if (
|
|
669
|
+
require_depth_links
|
|
670
|
+
and "elaboration-depth" not in allowances
|
|
671
|
+
and (normalized == "README.md" or normalized.startswith("docs/learn/"))
|
|
672
|
+
):
|
|
673
|
+
for title, section_line, _count, _allowances_for_section in _section_ranges(lines):
|
|
674
|
+
start = section_line - 1
|
|
675
|
+
following = [
|
|
676
|
+
index
|
|
677
|
+
for index, line in enumerate(lines[start + 1:], start=start + 1)
|
|
678
|
+
if HEADING.match(line)
|
|
679
|
+
]
|
|
680
|
+
end = following[0] if following else len(lines)
|
|
681
|
+
body = "\n".join(lines[start + 1:end])
|
|
682
|
+
words = re.findall(r"\b[\w'-]+\b", re.sub(r"`[^`]+`", "", body))
|
|
683
|
+
deeper_link = any(
|
|
684
|
+
target.split("#", 1)[0].endswith((".md", "/"))
|
|
685
|
+
for target in LINK.findall(body)
|
|
686
|
+
)
|
|
687
|
+
if len(words) > 80 and not deeper_link:
|
|
688
|
+
findings.append(AuditFinding(
|
|
689
|
+
"elaboration-depth",
|
|
690
|
+
normalized,
|
|
691
|
+
section_line,
|
|
692
|
+
f"{title!r} has {len(words)} words without a deeper-page route",
|
|
693
|
+
))
|
|
694
|
+
return findings
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def _assurance_findings(documents: dict[str, str]) -> list[AuditFinding]:
|
|
698
|
+
rules = (
|
|
699
|
+
(
|
|
700
|
+
"model-authority",
|
|
701
|
+
"docs/learn/deep-dive-the-deterministic-seam.md",
|
|
702
|
+
re.compile(
|
|
703
|
+
r"(?:deterministic code.{0,80}(?:owns|decides)|"
|
|
704
|
+
r"models? may select|models?.{0,80}(?:cannot|never).{0,40}gate)",
|
|
705
|
+
re.I,
|
|
706
|
+
),
|
|
707
|
+
),
|
|
708
|
+
(
|
|
709
|
+
"execution-boundary",
|
|
710
|
+
"docs/SECURITY_MODEL.md",
|
|
711
|
+
re.compile(
|
|
712
|
+
r"(?:does not (?:execute|revoke)|without (?:importing|executing) "
|
|
713
|
+
r"(?:repository|target) code)",
|
|
714
|
+
re.I,
|
|
715
|
+
),
|
|
716
|
+
),
|
|
717
|
+
)
|
|
718
|
+
findings: list[AuditFinding] = []
|
|
719
|
+
for _boundary, canonical, pattern in rules:
|
|
720
|
+
for doc, text in documents.items():
|
|
721
|
+
if (
|
|
722
|
+
doc == canonical
|
|
723
|
+
or REGISTER_PROFILE not in text
|
|
724
|
+
or "assurance-dedup" in _allowances(text.splitlines())
|
|
725
|
+
):
|
|
726
|
+
continue
|
|
727
|
+
for line_number, line in _outside_fences(text.splitlines()):
|
|
728
|
+
if pattern.search(line):
|
|
729
|
+
findings.append(AuditFinding(
|
|
730
|
+
"assurance-dedup",
|
|
731
|
+
doc,
|
|
732
|
+
line_number,
|
|
733
|
+
f"link to {canonical} instead of restating this authority boundary",
|
|
734
|
+
))
|
|
735
|
+
return findings
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def _purpose_template_findings(documents: dict[str, str]) -> list[AuditFinding]:
|
|
739
|
+
matches: list[str] = []
|
|
740
|
+
for doc, text in documents.items():
|
|
741
|
+
if REGISTER_PROFILE not in text:
|
|
742
|
+
continue
|
|
743
|
+
block = PURPOSE_BLOCK.search(text)
|
|
744
|
+
if block and STOCK_PURPOSE_OPENING.search(" ".join(block.group(1).split())):
|
|
745
|
+
matches.append(doc)
|
|
746
|
+
if len(matches) < 3:
|
|
747
|
+
return []
|
|
748
|
+
return [
|
|
749
|
+
AuditFinding(
|
|
750
|
+
"purpose-template",
|
|
751
|
+
doc,
|
|
752
|
+
next(
|
|
753
|
+
(
|
|
754
|
+
line_number + 1
|
|
755
|
+
for line_number, line in enumerate(
|
|
756
|
+
documents[doc].splitlines(), start=1
|
|
757
|
+
)
|
|
758
|
+
if line.strip() == "<!-- sourcebound:purpose -->"
|
|
759
|
+
),
|
|
760
|
+
1,
|
|
761
|
+
),
|
|
762
|
+
"replace the repeated 'Use this ... when' shell with a reader situation specific to this page",
|
|
763
|
+
)
|
|
764
|
+
for doc in matches
|
|
765
|
+
]
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def _scan_audit(root: Path, *, preview_policy: bool = False) -> AuditReport:
|
|
769
|
+
root = root.resolve()
|
|
770
|
+
repository_integrity_enforced = (root / ".sourcebound.yml").is_file()
|
|
771
|
+
pack = load_default_pack()
|
|
772
|
+
repository_entries = _repository_entries(root)
|
|
773
|
+
tracked_documents = _tracked_markdown(root)
|
|
774
|
+
mdx_sources: dict[str, str] = {}
|
|
775
|
+
mdx_failures: dict[str, str] = {}
|
|
776
|
+
parsed_mdx: dict[str, MdxDocument] = {}
|
|
777
|
+
for relative in tracked_documents:
|
|
778
|
+
if relative.suffix.lower() != ".mdx":
|
|
779
|
+
continue
|
|
780
|
+
normalized = relative.as_posix()
|
|
781
|
+
try:
|
|
782
|
+
mdx_sources[normalized] = (root / relative).read_text(encoding="utf-8")
|
|
783
|
+
except (OSError, UnicodeError) as exc:
|
|
784
|
+
mdx_failures[normalized] = f"cannot read MDX: {exc}"
|
|
785
|
+
if mdx_sources:
|
|
786
|
+
available, detail = parser_availability()
|
|
787
|
+
if not available:
|
|
788
|
+
mdx_failures.update(
|
|
789
|
+
(path, detail) for path in mdx_sources
|
|
790
|
+
)
|
|
791
|
+
else:
|
|
792
|
+
try:
|
|
793
|
+
parsed_mdx, parser_failures = parse_mdx_documents(mdx_sources)
|
|
794
|
+
except MdxParserError as exc:
|
|
795
|
+
parser_failures = {
|
|
796
|
+
path: str(exc) for path in mdx_sources
|
|
797
|
+
}
|
|
798
|
+
mdx_failures.update(parser_failures)
|
|
799
|
+
unsupported: set[str] = set()
|
|
800
|
+
section_limit = int(pack["policy"]["section_max_lines"])
|
|
801
|
+
active: list[str] = []
|
|
802
|
+
ignored: list[str] = []
|
|
803
|
+
active_texts: dict[str, str] = {}
|
|
804
|
+
profiles: dict[str, DocumentProfile] = {}
|
|
805
|
+
invalid_roles: set[str] = set()
|
|
806
|
+
findings: list[AuditFinding] = []
|
|
807
|
+
advisories: list[AuditFinding] = []
|
|
808
|
+
for relative in tracked_documents:
|
|
809
|
+
normalized = relative.as_posix()
|
|
810
|
+
if "archive" in relative.parts or _hidden_document(relative):
|
|
811
|
+
ignored.append(normalized)
|
|
812
|
+
continue
|
|
813
|
+
path = root / relative
|
|
814
|
+
try:
|
|
815
|
+
text = (
|
|
816
|
+
mdx_sources[normalized]
|
|
817
|
+
if relative.suffix.lower() == ".mdx"
|
|
818
|
+
else path.read_text(encoding="utf-8")
|
|
819
|
+
)
|
|
820
|
+
except (OSError, UnicodeError) as exc:
|
|
821
|
+
candidate = AuditFinding("unreadable-document", normalized, 1, str(exc))
|
|
822
|
+
(findings if repository_integrity_enforced else advisories).append(candidate)
|
|
823
|
+
continue
|
|
824
|
+
mdx_document = parsed_mdx.get(normalized)
|
|
825
|
+
if relative.suffix.lower() == ".mdx" and mdx_document is None:
|
|
826
|
+
unsupported.add(normalized)
|
|
827
|
+
candidate = AuditFinding(
|
|
828
|
+
"unsupported-mdx",
|
|
829
|
+
normalized,
|
|
830
|
+
1,
|
|
831
|
+
mdx_failures.get(normalized, "MDX parser did not return a result"),
|
|
832
|
+
)
|
|
833
|
+
(findings if repository_integrity_enforced else advisories).append(candidate)
|
|
834
|
+
continue
|
|
835
|
+
active.append(normalized)
|
|
836
|
+
lines = text.splitlines()
|
|
837
|
+
policy_text = (
|
|
838
|
+
mdx_document.policy_text(text) if mdx_document is not None else text
|
|
839
|
+
)
|
|
840
|
+
policy_lines = policy_text.splitlines()
|
|
841
|
+
active_texts[normalized] = policy_text
|
|
842
|
+
profile = classify_document(relative, policy_text)
|
|
843
|
+
profiles[normalized] = profile
|
|
844
|
+
role_error = role_override_error(text)
|
|
845
|
+
structure_error = frontmatter_error(text)
|
|
846
|
+
if role_error or structure_error:
|
|
847
|
+
invalid_roles.add(normalized)
|
|
848
|
+
findings.append(AuditFinding(
|
|
849
|
+
(
|
|
850
|
+
"invalid-document-role"
|
|
851
|
+
if role_error
|
|
852
|
+
else "malformed-frontmatter"
|
|
853
|
+
),
|
|
854
|
+
normalized,
|
|
855
|
+
1,
|
|
856
|
+
role_error or structure_error or "invalid document structure",
|
|
857
|
+
))
|
|
858
|
+
allowances = _allowances(policy_lines)
|
|
859
|
+
evaluate_policy = profile.registered or preview_policy
|
|
860
|
+
if preview_policy and not profile.registered:
|
|
861
|
+
policy_text = f"{policy_text.rstrip()}\n\n{REGISTER_PROFILE}\n"
|
|
862
|
+
for item in (
|
|
863
|
+
()
|
|
864
|
+
if role_error or not evaluate_policy
|
|
865
|
+
else check_document(
|
|
866
|
+
normalized,
|
|
867
|
+
policy_text,
|
|
868
|
+
pack,
|
|
869
|
+
semantic_text=text,
|
|
870
|
+
)
|
|
871
|
+
):
|
|
872
|
+
if not profile.applies(item.rule):
|
|
873
|
+
continue
|
|
874
|
+
candidate = AuditFinding(item.rule, item.doc, item.line, item.detail)
|
|
875
|
+
(findings if profile.registered else advisories).append(candidate)
|
|
876
|
+
page_type = _page_type(relative, text)
|
|
877
|
+
if (
|
|
878
|
+
not role_error
|
|
879
|
+
and evaluate_policy
|
|
880
|
+
and profile.role in {"overview", "task", "tutorial"}
|
|
881
|
+
):
|
|
882
|
+
doc_limit = (
|
|
883
|
+
int(pack["policy"]["readme_max_lines"])
|
|
884
|
+
if page_type == "readme"
|
|
885
|
+
else int(pack["policy"]["guide_max_lines"])
|
|
886
|
+
)
|
|
887
|
+
for allowance_line, rule, reason in _allowance_records(policy_lines):
|
|
888
|
+
if rule in {"doc-length", "section-length"} and not re.search(
|
|
889
|
+
r"\b(?:cut|moved|split|linked|reference)\b", reason, re.I
|
|
890
|
+
):
|
|
891
|
+
candidate = AuditFinding(
|
|
892
|
+
"invalid-length-allowance",
|
|
893
|
+
normalized,
|
|
894
|
+
allowance_line,
|
|
895
|
+
"replace comprehensiveness rationale with a subtraction receipt",
|
|
896
|
+
)
|
|
897
|
+
advisories.append(candidate)
|
|
898
|
+
if (
|
|
899
|
+
len(lines) > doc_limit
|
|
900
|
+
and "doc-length" not in allowances
|
|
901
|
+
):
|
|
902
|
+
candidate = AuditFinding(
|
|
903
|
+
"doc-length",
|
|
904
|
+
normalized,
|
|
905
|
+
1,
|
|
906
|
+
f"{len(lines)} lines exceeds the {page_type} budget of {doc_limit}; move a second job behind a link",
|
|
907
|
+
)
|
|
908
|
+
advisories.append(candidate)
|
|
909
|
+
for title, section_line, count, section_allowances in _section_ranges(
|
|
910
|
+
policy_lines
|
|
911
|
+
):
|
|
912
|
+
if count > section_limit and "section-length" not in section_allowances:
|
|
913
|
+
candidate = AuditFinding(
|
|
914
|
+
"section-length",
|
|
915
|
+
normalized,
|
|
916
|
+
section_line,
|
|
917
|
+
f"{title!r} is {count} lines; move its second job behind a link",
|
|
918
|
+
)
|
|
919
|
+
advisories.append(candidate)
|
|
920
|
+
for candidate in _section_depth_findings(
|
|
921
|
+
normalized,
|
|
922
|
+
policy_lines,
|
|
923
|
+
require_routes=bool(pack["policy"].get("require_readme_routes")),
|
|
924
|
+
require_depth_links=bool(pack["policy"].get("require_depth_links")),
|
|
925
|
+
):
|
|
926
|
+
if (
|
|
927
|
+
not role_error
|
|
928
|
+
and evaluate_policy
|
|
929
|
+
and profile.applies(candidate.rule)
|
|
930
|
+
):
|
|
931
|
+
(findings if profile.registered else advisories).append(candidate)
|
|
932
|
+
document_links = (
|
|
933
|
+
[(link.line, link.url) for link in mdx_document.links]
|
|
934
|
+
if mdx_document is not None
|
|
935
|
+
else _markdown_links(lines)
|
|
936
|
+
)
|
|
937
|
+
for line_number, target in document_links:
|
|
938
|
+
if (
|
|
939
|
+
_placeholder_link_target(target)
|
|
940
|
+
and profile.role in {"agent-procedure", "template"}
|
|
941
|
+
):
|
|
942
|
+
advisories.append(
|
|
943
|
+
AuditFinding(
|
|
944
|
+
"placeholder-link",
|
|
945
|
+
normalized,
|
|
946
|
+
line_number,
|
|
947
|
+
f"template destination is unresolved: {target}",
|
|
948
|
+
)
|
|
949
|
+
)
|
|
950
|
+
continue
|
|
951
|
+
if not _link_target_exists(root, relative, target, repository_entries):
|
|
952
|
+
candidate = AuditFinding(
|
|
953
|
+
"broken-local-link",
|
|
954
|
+
normalized,
|
|
955
|
+
line_number,
|
|
956
|
+
f"target does not exist: {target}",
|
|
957
|
+
)
|
|
958
|
+
(
|
|
959
|
+
findings
|
|
960
|
+
if repository_integrity_enforced or profile.registered
|
|
961
|
+
else advisories
|
|
962
|
+
).append(candidate)
|
|
963
|
+
# These comparisons require editorial ownership knowledge. They remain
|
|
964
|
+
# visible, but they cannot reject a repository from token overlap alone.
|
|
965
|
+
advisories.extend(_assurance_findings(active_texts))
|
|
966
|
+
for candidate in _purpose_template_findings(active_texts):
|
|
967
|
+
if candidate.path in invalid_roles:
|
|
968
|
+
continue
|
|
969
|
+
scoped_profile = profiles.get(candidate.path)
|
|
970
|
+
if scoped_profile is None or not scoped_profile.applies(candidate.rule):
|
|
971
|
+
continue
|
|
972
|
+
(findings if scoped_profile.registered else advisories).append(candidate)
|
|
973
|
+
corpus_rule_names = {
|
|
974
|
+
"surface": "process-artifact",
|
|
975
|
+
"audience": "audience",
|
|
976
|
+
"provenance": "provenance",
|
|
977
|
+
"near-dup": "near-duplicate",
|
|
978
|
+
"restatement": "restatement",
|
|
979
|
+
}
|
|
980
|
+
for corpus_finding in scan_corpus(
|
|
981
|
+
root,
|
|
982
|
+
include_lengths=False,
|
|
983
|
+
prepared_documents=active_texts,
|
|
984
|
+
):
|
|
985
|
+
corpus_rule = corpus_rule_names.get(corpus_finding.rule)
|
|
986
|
+
if corpus_rule is None:
|
|
987
|
+
continue
|
|
988
|
+
corpus_profile = profiles.get(corpus_finding.doc)
|
|
989
|
+
if corpus_profile is None:
|
|
990
|
+
continue
|
|
991
|
+
if corpus_rule == "audience" and corpus_profile.role in {
|
|
992
|
+
"agent-procedure",
|
|
993
|
+
"template",
|
|
994
|
+
}:
|
|
995
|
+
continue
|
|
996
|
+
if corpus_rule == "provenance" and corpus_profile.role in {"evidence", "plan"}:
|
|
997
|
+
continue
|
|
998
|
+
if corpus_rule in {"near-duplicate", "restatement"}:
|
|
999
|
+
if corpus_profile.role in {"agent-procedure", "evidence", "plan", "template"}:
|
|
1000
|
+
continue
|
|
1001
|
+
counterpart = re.search(r"overlap with (.+):\d+", corpus_finding.detail)
|
|
1002
|
+
if counterpart:
|
|
1003
|
+
other = profiles.get(counterpart.group(1))
|
|
1004
|
+
if other is not None and other.role != corpus_profile.role:
|
|
1005
|
+
continue
|
|
1006
|
+
corpus_text = active_texts.get(corpus_finding.doc)
|
|
1007
|
+
if corpus_text is None:
|
|
1008
|
+
continue
|
|
1009
|
+
if corpus_rule in _allowances(corpus_text.splitlines()):
|
|
1010
|
+
continue
|
|
1011
|
+
candidate = AuditFinding(
|
|
1012
|
+
corpus_rule,
|
|
1013
|
+
corpus_finding.doc,
|
|
1014
|
+
corpus_finding.line,
|
|
1015
|
+
corpus_finding.detail,
|
|
1016
|
+
)
|
|
1017
|
+
if not any(
|
|
1018
|
+
existing.rule == candidate.rule
|
|
1019
|
+
and existing.path == candidate.path
|
|
1020
|
+
and existing.line == candidate.line
|
|
1021
|
+
for existing in [*findings, *advisories]
|
|
1022
|
+
):
|
|
1023
|
+
advisories.append(candidate)
|
|
1024
|
+
for residue_finding in scan_residue(root):
|
|
1025
|
+
candidate = AuditFinding(
|
|
1026
|
+
residue_finding.rule,
|
|
1027
|
+
residue_finding.doc,
|
|
1028
|
+
residue_finding.line,
|
|
1029
|
+
residue_finding.detail,
|
|
1030
|
+
)
|
|
1031
|
+
residue_profile = profiles.get(residue_finding.doc)
|
|
1032
|
+
fixture_machine_path = (
|
|
1033
|
+
residue_finding.rule == "local-path-residue"
|
|
1034
|
+
and _is_test_fixture_path(residue_finding.doc)
|
|
1035
|
+
)
|
|
1036
|
+
residue_blocks = not fixture_machine_path and (
|
|
1037
|
+
repository_integrity_enforced
|
|
1038
|
+
or residue_profile is not None and residue_profile.registered
|
|
1039
|
+
)
|
|
1040
|
+
(
|
|
1041
|
+
findings
|
|
1042
|
+
if residue_blocks
|
|
1043
|
+
else advisories
|
|
1044
|
+
).append(candidate)
|
|
1045
|
+
findings.sort(key=lambda item: (item.path, item.line, item.rule))
|
|
1046
|
+
bounded_advisories, advisory_totals = _bounded_advisories(advisories)
|
|
1047
|
+
return AuditReport(
|
|
1048
|
+
tuple(active),
|
|
1049
|
+
tuple(ignored),
|
|
1050
|
+
tuple(findings),
|
|
1051
|
+
unsupported_documents=tuple(sorted(unsupported)),
|
|
1052
|
+
advisories=bounded_advisories,
|
|
1053
|
+
advisory_totals=advisory_totals,
|
|
1054
|
+
document_profiles=tuple(
|
|
1055
|
+
profiles[path] for path in sorted(profiles)
|
|
1056
|
+
),
|
|
1057
|
+
repository_integrity_enforced=repository_integrity_enforced,
|
|
1058
|
+
policy_preview=preview_policy,
|
|
1059
|
+
)
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
def audit(
|
|
1063
|
+
root: Path,
|
|
1064
|
+
*,
|
|
1065
|
+
use_baseline: bool = True,
|
|
1066
|
+
preview_policy: bool = False,
|
|
1067
|
+
) -> AuditReport:
|
|
1068
|
+
root = root.resolve()
|
|
1069
|
+
report = _scan_audit(root, preview_policy=preview_policy)
|
|
1070
|
+
baseline_path = root / AUDIT_BASELINE_PATH
|
|
1071
|
+
if not use_baseline or not baseline_path.exists():
|
|
1072
|
+
return report
|
|
1073
|
+
baseline = _load_audit_baseline(baseline_path)
|
|
1074
|
+
if baseline.schema == AUDIT_BASELINE_SCHEMA_V1:
|
|
1075
|
+
current = {
|
|
1076
|
+
_legacy_finding_fingerprint(item): item
|
|
1077
|
+
for item in report.findings
|
|
1078
|
+
}
|
|
1079
|
+
else:
|
|
1080
|
+
current = {
|
|
1081
|
+
identity.fingerprint: identity.finding
|
|
1082
|
+
for identity in _baseline_identities(report.findings, root=root)
|
|
1083
|
+
}
|
|
1084
|
+
recorded = {
|
|
1085
|
+
identity.fingerprint: identity.finding
|
|
1086
|
+
for identity in baseline.identities
|
|
1087
|
+
}
|
|
1088
|
+
matched = tuple(sorted(
|
|
1089
|
+
(current[fingerprint] for fingerprint in current.keys() & recorded.keys()),
|
|
1090
|
+
key=_finding_order,
|
|
1091
|
+
))
|
|
1092
|
+
active = tuple(sorted(
|
|
1093
|
+
(current[fingerprint] for fingerprint in current.keys() - recorded.keys()),
|
|
1094
|
+
key=_finding_order,
|
|
1095
|
+
))
|
|
1096
|
+
stale = tuple(sorted(
|
|
1097
|
+
(recorded[fingerprint] for fingerprint in recorded.keys() - current.keys()),
|
|
1098
|
+
key=_finding_order,
|
|
1099
|
+
))
|
|
1100
|
+
return AuditReport(
|
|
1101
|
+
documents=report.documents,
|
|
1102
|
+
ignored_documents=report.ignored_documents,
|
|
1103
|
+
findings=active,
|
|
1104
|
+
baselined_findings=matched,
|
|
1105
|
+
stale_baseline=stale,
|
|
1106
|
+
unsupported_documents=report.unsupported_documents,
|
|
1107
|
+
advisories=report.advisories,
|
|
1108
|
+
advisory_totals=report.advisory_totals,
|
|
1109
|
+
document_profiles=report.document_profiles,
|
|
1110
|
+
repository_integrity_enforced=report.repository_integrity_enforced,
|
|
1111
|
+
policy_preview=report.policy_preview,
|
|
1112
|
+
)
|
|
1113
|
+
|
|
1114
|
+
|
|
1115
|
+
def write_audit_baseline(root: Path) -> Path:
|
|
1116
|
+
root = root.resolve()
|
|
1117
|
+
path = root / AUDIT_BASELINE_PATH
|
|
1118
|
+
raw_report = audit(root, use_baseline=False)
|
|
1119
|
+
atomic_write(path, render_audit_baseline(raw_report.findings, root=root))
|
|
1120
|
+
return path
|