sourcebound 1.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- clean_docs/__init__.py +26 -0
- clean_docs/__main__.py +3 -0
- clean_docs/accessibility.py +182 -0
- clean_docs/adapters/__init__.py +1 -0
- clean_docs/adapters/event_capture.py +56 -0
- clean_docs/adapters/mdx_dependencies.json +714 -0
- clean_docs/adapters/mdx_parser.mjs +29992 -0
- clean_docs/applicability.py +330 -0
- clean_docs/audit.py +1120 -0
- clean_docs/bootstrap.py +507 -0
- clean_docs/capabilities.py +200 -0
- clean_docs/changed.py +452 -0
- clean_docs/claims.py +840 -0
- clean_docs/cli.py +1612 -0
- clean_docs/context.py +307 -0
- clean_docs/corpus.py +377 -0
- clean_docs/demo.py +369 -0
- clean_docs/doctor.py +184 -0
- clean_docs/emit/__init__.py +4 -0
- clean_docs/emit/llms_txt.py +102 -0
- clean_docs/emit/stepwise.py +168 -0
- clean_docs/engine.py +324 -0
- clean_docs/errors.py +20 -0
- clean_docs/evaluation.py +867 -0
- clean_docs/execution.py +138 -0
- clean_docs/explain.py +123 -0
- clean_docs/extractors/__init__.py +19 -0
- clean_docs/extractors/command.py +51 -0
- clean_docs/extractors/inventory.py +176 -0
- clean_docs/extractors/json_pointer.py +84 -0
- clean_docs/extractors/python_literal.py +104 -0
- clean_docs/extractors/static.py +111 -0
- clean_docs/feedback.py +1390 -0
- clean_docs/impact.py +1624 -0
- clean_docs/improvements.py +1178 -0
- clean_docs/inventory.py +474 -0
- clean_docs/isolation.py +157 -0
- clean_docs/manifest.py +898 -0
- clean_docs/mdx.py +272 -0
- clean_docs/migration.py +121 -0
- clean_docs/models.py +194 -0
- clean_docs/outcomes.py +296 -0
- clean_docs/performance.py +123 -0
- clean_docs/phrasing.py +448 -0
- clean_docs/plugins.py +249 -0
- clean_docs/policy.py +536 -0
- clean_docs/projections.py +255 -0
- clean_docs/regions.py +75 -0
- clean_docs/release.py +232 -0
- clean_docs/renderers.py +57 -0
- clean_docs/residue.py +311 -0
- clean_docs/review_contracts.py +862 -0
- clean_docs/review_ledger.py +297 -0
- clean_docs/review_limits.py +9 -0
- clean_docs/sensitivity.py +602 -0
- clean_docs/snapshot.py +212 -0
- clean_docs/standard.py +281 -0
- clean_docs/standards/default.json +309 -0
- clean_docs/standards/exemplars.md +87 -0
- clean_docs/standards/v0-migrations.json +26 -0
- clean_docs/symbols.py +47 -0
- clean_docs/templates.py +96 -0
- clean_docs/verdict.py +1397 -0
- clean_docs/visuals.py +346 -0
- clean_docs/write_gate.py +170 -0
- sourcebound-1.2.1.dist-info/LICENSE +21 -0
- sourcebound-1.2.1.dist-info/METADATA +109 -0
- sourcebound-1.2.1.dist-info/RECORD +71 -0
- sourcebound-1.2.1.dist-info/WHEEL +5 -0
- sourcebound-1.2.1.dist-info/entry_points.txt +2 -0
- sourcebound-1.2.1.dist-info/top_level.txt +1 -0
clean_docs/context.py
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""Compile bounded, source-addressed context without a model or retrieval index."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
import subprocess
|
|
9
|
+
from dataclasses import asdict, dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from clean_docs.errors import ConfigurationError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
REQUEST_SCHEMA = "sourcebound.context-request.v1"
|
|
17
|
+
BUNDLE_SCHEMA = "sourcebound.context-bundle.v1"
|
|
18
|
+
KINDS = {
|
|
19
|
+
"example",
|
|
20
|
+
"fact",
|
|
21
|
+
"history",
|
|
22
|
+
"hypothesis",
|
|
23
|
+
"instruction",
|
|
24
|
+
"policy",
|
|
25
|
+
"projection",
|
|
26
|
+
}
|
|
27
|
+
AUTHORITIES = {
|
|
28
|
+
"accepted-policy": 50,
|
|
29
|
+
"direct-evidence": 40,
|
|
30
|
+
"generated": 30,
|
|
31
|
+
"repository-doc": 20,
|
|
32
|
+
"hypothesis": 10,
|
|
33
|
+
}
|
|
34
|
+
SHA = re.compile(r"^[0-9a-f]{40}$")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class ContextItem:
|
|
39
|
+
id: str
|
|
40
|
+
kind: str
|
|
41
|
+
path: str
|
|
42
|
+
locator: str
|
|
43
|
+
source_commit: str
|
|
44
|
+
source_sha256: str
|
|
45
|
+
authority: str
|
|
46
|
+
relationship: str
|
|
47
|
+
inclusion_reason: str
|
|
48
|
+
rank: int
|
|
49
|
+
instruction_allowed: bool
|
|
50
|
+
bytes: int
|
|
51
|
+
content: str
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class ExcludedContext:
|
|
56
|
+
id: str
|
|
57
|
+
path: str
|
|
58
|
+
reason: str
|
|
59
|
+
required: bool
|
|
60
|
+
source_sha256: str
|
|
61
|
+
bytes: int
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class ContextBundle:
|
|
66
|
+
repository_commit: str
|
|
67
|
+
budget_bytes: int
|
|
68
|
+
used_bytes: int
|
|
69
|
+
rejected_bytes: int
|
|
70
|
+
status: str
|
|
71
|
+
items: tuple[ContextItem, ...]
|
|
72
|
+
excluded: tuple[ExcludedContext, ...]
|
|
73
|
+
digest: str
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def ok(self) -> bool:
|
|
77
|
+
return self.status == "current"
|
|
78
|
+
|
|
79
|
+
def as_dict(self) -> dict[str, object]:
|
|
80
|
+
return {
|
|
81
|
+
"schema": BUNDLE_SCHEMA,
|
|
82
|
+
"repository_commit": self.repository_commit,
|
|
83
|
+
"budget": {
|
|
84
|
+
"bytes": self.budget_bytes,
|
|
85
|
+
"used": self.used_bytes,
|
|
86
|
+
"rejected": self.rejected_bytes,
|
|
87
|
+
},
|
|
88
|
+
"status": self.status,
|
|
89
|
+
"items": [asdict(item) for item in self.items],
|
|
90
|
+
"excluded": [asdict(item) for item in self.excluded],
|
|
91
|
+
"digest": self.digest,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _mapping(value: Any, where: str) -> dict[str, Any]:
|
|
96
|
+
if not isinstance(value, dict):
|
|
97
|
+
raise ConfigurationError(f"{where} must be an object")
|
|
98
|
+
return value
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _git_commit(root: Path) -> str:
|
|
102
|
+
process = subprocess.run(
|
|
103
|
+
["git", "-C", str(root), "rev-parse", "HEAD"],
|
|
104
|
+
text=True,
|
|
105
|
+
capture_output=True,
|
|
106
|
+
timeout=30,
|
|
107
|
+
check=False,
|
|
108
|
+
)
|
|
109
|
+
if process.returncode != 0:
|
|
110
|
+
raise ConfigurationError("context compilation requires a git commit")
|
|
111
|
+
return process.stdout.strip()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _source_text(
|
|
115
|
+
root: Path,
|
|
116
|
+
*,
|
|
117
|
+
commit: str,
|
|
118
|
+
path: str,
|
|
119
|
+
start_line: int,
|
|
120
|
+
end_line: int,
|
|
121
|
+
) -> str:
|
|
122
|
+
relative = Path(path)
|
|
123
|
+
if relative.is_absolute() or ".." in relative.parts:
|
|
124
|
+
raise ConfigurationError(f"context path must stay inside the repository: {path}")
|
|
125
|
+
process = subprocess.run(
|
|
126
|
+
["git", "-C", str(root), "show", f"{commit}:{relative.as_posix()}"],
|
|
127
|
+
capture_output=True,
|
|
128
|
+
timeout=30,
|
|
129
|
+
check=False,
|
|
130
|
+
)
|
|
131
|
+
if process.returncode != 0:
|
|
132
|
+
raise ConfigurationError(
|
|
133
|
+
f"context path does not exist at {commit}: {path}"
|
|
134
|
+
)
|
|
135
|
+
try:
|
|
136
|
+
lines = process.stdout.decode("utf-8").splitlines()
|
|
137
|
+
except UnicodeDecodeError as exc:
|
|
138
|
+
raise ConfigurationError(f"context path is not UTF-8: {path}") from exc
|
|
139
|
+
if start_line < 1 or end_line < start_line or end_line > len(lines):
|
|
140
|
+
raise ConfigurationError(
|
|
141
|
+
f"context locator is outside {path}: L{start_line}-L{end_line}"
|
|
142
|
+
)
|
|
143
|
+
return "\n".join(lines[start_line - 1:end_line]) + "\n"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _canonical_digest(payload: dict[str, object]) -> str:
|
|
147
|
+
return hashlib.sha256(
|
|
148
|
+
json.dumps(
|
|
149
|
+
payload,
|
|
150
|
+
sort_keys=True,
|
|
151
|
+
separators=(",", ":"),
|
|
152
|
+
ensure_ascii=False,
|
|
153
|
+
).encode()
|
|
154
|
+
).hexdigest()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def compile_context(root: Path, request_path: Path) -> ContextBundle:
|
|
158
|
+
root = root.resolve()
|
|
159
|
+
try:
|
|
160
|
+
raw = json.loads(request_path.read_text(encoding="utf-8"))
|
|
161
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
162
|
+
raise ConfigurationError(f"cannot read context request {request_path}: {exc}") from exc
|
|
163
|
+
request = _mapping(raw, "context request")
|
|
164
|
+
if set(request) != {"schema", "repository_commit", "budget_bytes", "items"}:
|
|
165
|
+
raise ConfigurationError("context request has unsupported fields")
|
|
166
|
+
if request.get("schema") != REQUEST_SCHEMA:
|
|
167
|
+
raise ConfigurationError("context request has an unsupported schema")
|
|
168
|
+
commit = request.get("repository_commit")
|
|
169
|
+
if not isinstance(commit, str) or not SHA.fullmatch(commit):
|
|
170
|
+
raise ConfigurationError("context request repository_commit must be a full SHA")
|
|
171
|
+
observed_commit = _git_commit(root)
|
|
172
|
+
if commit != observed_commit:
|
|
173
|
+
raise ConfigurationError(
|
|
174
|
+
f"context request commit is {commit}, but repository is {observed_commit}"
|
|
175
|
+
)
|
|
176
|
+
budget = request.get("budget_bytes")
|
|
177
|
+
if not isinstance(budget, int) or isinstance(budget, bool) or budget < 1:
|
|
178
|
+
raise ConfigurationError("context request budget_bytes must be positive")
|
|
179
|
+
raw_items = request.get("items")
|
|
180
|
+
if not isinstance(raw_items, list) or not raw_items:
|
|
181
|
+
raise ConfigurationError("context request items must be non-empty")
|
|
182
|
+
|
|
183
|
+
prepared: list[tuple[dict[str, Any], str, int]] = []
|
|
184
|
+
seen: set[str] = set()
|
|
185
|
+
for index, value in enumerate(raw_items):
|
|
186
|
+
item = _mapping(value, f"context request item {index}")
|
|
187
|
+
expected = {
|
|
188
|
+
"id",
|
|
189
|
+
"kind",
|
|
190
|
+
"path",
|
|
191
|
+
"start_line",
|
|
192
|
+
"end_line",
|
|
193
|
+
"authority",
|
|
194
|
+
"relationship",
|
|
195
|
+
"reason",
|
|
196
|
+
"rank",
|
|
197
|
+
"required",
|
|
198
|
+
"instruction",
|
|
199
|
+
}
|
|
200
|
+
if set(item) != expected:
|
|
201
|
+
raise ConfigurationError(f"context request item {index} has unsupported fields")
|
|
202
|
+
identifier = item.get("id")
|
|
203
|
+
if not isinstance(identifier, str) or not identifier:
|
|
204
|
+
raise ConfigurationError(f"context request item {index} needs an id")
|
|
205
|
+
if identifier in seen:
|
|
206
|
+
raise ConfigurationError(f"duplicate context item id: {identifier}")
|
|
207
|
+
seen.add(identifier)
|
|
208
|
+
if item.get("kind") not in KINDS:
|
|
209
|
+
raise ConfigurationError(f"context request item {identifier} has an invalid kind")
|
|
210
|
+
authority = item.get("authority")
|
|
211
|
+
if authority not in AUTHORITIES:
|
|
212
|
+
raise ConfigurationError(
|
|
213
|
+
f"context request item {identifier} has an invalid authority"
|
|
214
|
+
)
|
|
215
|
+
for field in ("path", "relationship", "reason"):
|
|
216
|
+
if not isinstance(item.get(field), str) or not item[field]:
|
|
217
|
+
raise ConfigurationError(
|
|
218
|
+
f"context request item {identifier}.{field} must be non-empty"
|
|
219
|
+
)
|
|
220
|
+
for field in ("start_line", "end_line", "rank"):
|
|
221
|
+
if not isinstance(item.get(field), int) or isinstance(item[field], bool):
|
|
222
|
+
raise ConfigurationError(
|
|
223
|
+
f"context request item {identifier}.{field} must be an integer"
|
|
224
|
+
)
|
|
225
|
+
for field in ("required", "instruction"):
|
|
226
|
+
if not isinstance(item.get(field), bool):
|
|
227
|
+
raise ConfigurationError(
|
|
228
|
+
f"context request item {identifier}.{field} must be boolean"
|
|
229
|
+
)
|
|
230
|
+
content = _source_text(
|
|
231
|
+
root,
|
|
232
|
+
commit=commit,
|
|
233
|
+
path=item["path"],
|
|
234
|
+
start_line=item["start_line"],
|
|
235
|
+
end_line=item["end_line"],
|
|
236
|
+
)
|
|
237
|
+
prepared.append((item, content, len(content.encode())))
|
|
238
|
+
|
|
239
|
+
prepared.sort(
|
|
240
|
+
key=lambda row: (
|
|
241
|
+
not row[0]["required"],
|
|
242
|
+
-AUTHORITIES[row[0]["authority"]],
|
|
243
|
+
-row[0]["rank"],
|
|
244
|
+
row[0]["id"],
|
|
245
|
+
)
|
|
246
|
+
)
|
|
247
|
+
included: list[ContextItem] = []
|
|
248
|
+
excluded: list[ExcludedContext] = []
|
|
249
|
+
used = 0
|
|
250
|
+
unknown = False
|
|
251
|
+
for item, content, byte_count in prepared:
|
|
252
|
+
if used + byte_count > budget:
|
|
253
|
+
reason = "required-over-budget" if item["required"] else "budget-exhausted"
|
|
254
|
+
excluded.append(
|
|
255
|
+
ExcludedContext(
|
|
256
|
+
item["id"],
|
|
257
|
+
item["path"],
|
|
258
|
+
reason,
|
|
259
|
+
item["required"],
|
|
260
|
+
hashlib.sha256(content.encode()).hexdigest(),
|
|
261
|
+
byte_count,
|
|
262
|
+
)
|
|
263
|
+
)
|
|
264
|
+
unknown = unknown or item["required"]
|
|
265
|
+
continue
|
|
266
|
+
instruction_allowed = (
|
|
267
|
+
item["instruction"]
|
|
268
|
+
and item["kind"] in {"instruction", "policy"}
|
|
269
|
+
and item["authority"] == "accepted-policy"
|
|
270
|
+
)
|
|
271
|
+
included.append(
|
|
272
|
+
ContextItem(
|
|
273
|
+
item["id"],
|
|
274
|
+
item["kind"],
|
|
275
|
+
item["path"],
|
|
276
|
+
f"L{item['start_line']}-L{item['end_line']}",
|
|
277
|
+
commit,
|
|
278
|
+
hashlib.sha256(content.encode()).hexdigest(),
|
|
279
|
+
item["authority"],
|
|
280
|
+
item["relationship"],
|
|
281
|
+
item["reason"],
|
|
282
|
+
item["rank"],
|
|
283
|
+
instruction_allowed,
|
|
284
|
+
byte_count,
|
|
285
|
+
content,
|
|
286
|
+
)
|
|
287
|
+
)
|
|
288
|
+
used += byte_count
|
|
289
|
+
status = "unknown" if unknown or not included else "current"
|
|
290
|
+
unsigned: dict[str, object] = {
|
|
291
|
+
"schema": BUNDLE_SCHEMA,
|
|
292
|
+
"repository_commit": commit,
|
|
293
|
+
"budget": {"bytes": budget, "used": used},
|
|
294
|
+
"status": status,
|
|
295
|
+
"items": [asdict(item) for item in included],
|
|
296
|
+
"excluded": [asdict(item) for item in excluded],
|
|
297
|
+
}
|
|
298
|
+
return ContextBundle(
|
|
299
|
+
commit,
|
|
300
|
+
budget,
|
|
301
|
+
used,
|
|
302
|
+
sum(item.bytes for item in excluded),
|
|
303
|
+
status,
|
|
304
|
+
tuple(included),
|
|
305
|
+
tuple(excluded),
|
|
306
|
+
_canonical_digest(unsigned),
|
|
307
|
+
)
|
clean_docs/corpus.py
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Iterable
|
|
9
|
+
from collections.abc import Mapping
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from clean_docs.mdx import MdxParserError, parse_mdx
|
|
13
|
+
from clean_docs.policy import PolicyFinding
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
DOC_MAX_LINES = 120
|
|
17
|
+
SECTION_MAX_LINES = 40
|
|
18
|
+
MIN_PARA_TOKENS = 8
|
|
19
|
+
NEAR_DUP = 0.80
|
|
20
|
+
RESTATEMENT = 0.60
|
|
21
|
+
POSTINGS_CAP = 200
|
|
22
|
+
|
|
23
|
+
PROCESS_RE = re.compile(
|
|
24
|
+
r"(REPORT|HANDOFF|DISPATCH|BLOCKED|STATUS|PROGRESS|RECEIPT|FINDINGS"
|
|
25
|
+
r"|WEEK\d|WAVE\d|EXECUTION_PLAN|EXECUTOR|RETRO|_AUDIT)",
|
|
26
|
+
re.IGNORECASE,
|
|
27
|
+
)
|
|
28
|
+
CHANGELOG_RE = re.compile(r"(CHANGELOG|DECISION_LOG|PROGRAM_REPORT|RETRO)", re.IGNORECASE)
|
|
29
|
+
HARNESS_RE = re.compile(
|
|
30
|
+
r"\b(next executor|pick up this branch|worktree|STOP condition|tripwire"
|
|
31
|
+
r"|do not edit|mid-edit|reconciles exactly|diff budget|skeptical reviewer"
|
|
32
|
+
r"|origin/main|git log|killed PID|baseline SHA|DoD table)\b",
|
|
33
|
+
re.IGNORECASE,
|
|
34
|
+
)
|
|
35
|
+
PROVENANCE_RE = re.compile(
|
|
36
|
+
r"(\((?:Program|Wave|Harvest|Dispatch|WS-[A-Za-z]+)\s*\d*\)"
|
|
37
|
+
r"|verified after authoring|\$\d+\.\d{6})"
|
|
38
|
+
)
|
|
39
|
+
HARNESS_HITS = 3
|
|
40
|
+
STOPWORDS = frozenset(
|
|
41
|
+
"the a an and or but of to in on at for with without from into is are was were be been "
|
|
42
|
+
"being it its this that these those as by not no also than then so such can may must will "
|
|
43
|
+
"which when where what who how why each any all one two per via if do does done only same "
|
|
44
|
+
"here there they them their you your we our us".split()
|
|
45
|
+
)
|
|
46
|
+
WORD_RE = re.compile(r"[a-z][a-z0-9-]{2,}")
|
|
47
|
+
FENCE_RE = re.compile(r"^```")
|
|
48
|
+
HTML_COMMENT_RE = re.compile(r"^\s*<!--.*?-->\s*$")
|
|
49
|
+
FALLBACK_SKIP_PARTS = frozenset({
|
|
50
|
+
".git",
|
|
51
|
+
".nox",
|
|
52
|
+
".pytest_cache",
|
|
53
|
+
".tox",
|
|
54
|
+
".venv",
|
|
55
|
+
"__pycache__",
|
|
56
|
+
"build",
|
|
57
|
+
"dist",
|
|
58
|
+
"node_modules",
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _is_document_candidate(relative: Path, *, fallback: bool) -> bool:
|
|
63
|
+
"""Return whether a Markdown path can belong to the reader-facing corpus."""
|
|
64
|
+
parts = relative.parts
|
|
65
|
+
packaged_standard = any(
|
|
66
|
+
parts[index:index + 2] == ("clean_docs", "standards")
|
|
67
|
+
for index in range(len(parts) - 1)
|
|
68
|
+
)
|
|
69
|
+
return not (
|
|
70
|
+
parts[:2] == ("tests", "fixtures")
|
|
71
|
+
or packaged_standard
|
|
72
|
+
or ".fixture." in relative.name.lower()
|
|
73
|
+
or (fallback and bool(set(parts) & FALLBACK_SKIP_PARTS))
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _git_visible_markdown(root: Path) -> list[Path] | None:
|
|
78
|
+
try:
|
|
79
|
+
proc = subprocess.run(
|
|
80
|
+
[
|
|
81
|
+
"git",
|
|
82
|
+
"-C",
|
|
83
|
+
str(root),
|
|
84
|
+
"ls-files",
|
|
85
|
+
"--cached",
|
|
86
|
+
"--others",
|
|
87
|
+
"--exclude-standard",
|
|
88
|
+
"--",
|
|
89
|
+
"*.md",
|
|
90
|
+
"*.mdx",
|
|
91
|
+
],
|
|
92
|
+
capture_output=True,
|
|
93
|
+
text=True,
|
|
94
|
+
timeout=20,
|
|
95
|
+
check=False,
|
|
96
|
+
)
|
|
97
|
+
except (subprocess.SubprocessError, OSError):
|
|
98
|
+
return None
|
|
99
|
+
if proc.returncode != 0:
|
|
100
|
+
return None
|
|
101
|
+
candidates = sorted(
|
|
102
|
+
{
|
|
103
|
+
root / line
|
|
104
|
+
for line in proc.stdout.splitlines()
|
|
105
|
+
if line.strip() and (root / line).is_file()
|
|
106
|
+
},
|
|
107
|
+
key=lambda path: (path.is_symlink(), path.as_posix()),
|
|
108
|
+
)
|
|
109
|
+
canonical: dict[Path, Path] = {}
|
|
110
|
+
for path in candidates:
|
|
111
|
+
try:
|
|
112
|
+
identity = path.resolve(strict=True)
|
|
113
|
+
except OSError:
|
|
114
|
+
identity = path.absolute()
|
|
115
|
+
canonical.setdefault(identity, path)
|
|
116
|
+
return sorted(canonical.values())
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _hidden_document(relative: Path) -> bool:
|
|
120
|
+
hidden = [part for part in relative.parts if part.startswith(".")]
|
|
121
|
+
return bool(hidden) and relative.parts[0] != ".agents"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def list_documents(root: Path) -> list[Path]:
|
|
125
|
+
"""Return the reader-facing Markdown surface used by the Version 0 linter."""
|
|
126
|
+
if root.is_file():
|
|
127
|
+
return [root]
|
|
128
|
+
visible = _git_visible_markdown(root)
|
|
129
|
+
if visible is not None:
|
|
130
|
+
return [
|
|
131
|
+
path
|
|
132
|
+
for path in visible
|
|
133
|
+
if "archive" not in path.relative_to(root).parts
|
|
134
|
+
and _is_document_candidate(path.relative_to(root), fallback=False)
|
|
135
|
+
and not _hidden_document(path.relative_to(root))
|
|
136
|
+
]
|
|
137
|
+
return sorted(
|
|
138
|
+
path
|
|
139
|
+
for pattern in ("*.md", "*.mdx")
|
|
140
|
+
for path in root.rglob(pattern)
|
|
141
|
+
if _is_document_candidate(path.relative_to(root), fallback=True)
|
|
142
|
+
and "archive" not in path.relative_to(root).parts
|
|
143
|
+
and ".sourcebound" not in path.relative_to(root).parts
|
|
144
|
+
and not _hidden_document(path.relative_to(root))
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _read(path: Path) -> str:
|
|
149
|
+
try:
|
|
150
|
+
text = path.read_text(encoding="utf-8")
|
|
151
|
+
except OSError:
|
|
152
|
+
return ""
|
|
153
|
+
if path.suffix.lower() == ".mdx":
|
|
154
|
+
try:
|
|
155
|
+
return parse_mdx(text).policy_text(text)
|
|
156
|
+
except MdxParserError:
|
|
157
|
+
return ""
|
|
158
|
+
return text
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _content_tokens(text: str) -> frozenset[str]:
|
|
162
|
+
return frozenset(word for word in WORD_RE.findall(text.lower()) if word not in STOPWORDS)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _paragraphs(text: str) -> list[tuple[int, str]]:
|
|
166
|
+
result: list[tuple[int, str]] = []
|
|
167
|
+
buffer: list[str] = []
|
|
168
|
+
in_fence = False
|
|
169
|
+
start = 1
|
|
170
|
+
for line_number, raw in enumerate(text.splitlines(), start=1):
|
|
171
|
+
if FENCE_RE.match(raw):
|
|
172
|
+
in_fence = not in_fence
|
|
173
|
+
continue
|
|
174
|
+
if in_fence:
|
|
175
|
+
continue
|
|
176
|
+
if not raw.strip():
|
|
177
|
+
if buffer:
|
|
178
|
+
result.append((start, " ".join(buffer)))
|
|
179
|
+
buffer = []
|
|
180
|
+
continue
|
|
181
|
+
if HTML_COMMENT_RE.match(raw):
|
|
182
|
+
if buffer:
|
|
183
|
+
result.append((start, " ".join(buffer)))
|
|
184
|
+
buffer = []
|
|
185
|
+
continue
|
|
186
|
+
if not buffer:
|
|
187
|
+
start = line_number
|
|
188
|
+
stripped = raw.lstrip()
|
|
189
|
+
if stripped.startswith(("#", "|", ">")):
|
|
190
|
+
if buffer:
|
|
191
|
+
result.append((start, " ".join(buffer)))
|
|
192
|
+
buffer = []
|
|
193
|
+
continue
|
|
194
|
+
buffer.append(stripped)
|
|
195
|
+
if buffer:
|
|
196
|
+
result.append((start, " ".join(buffer)))
|
|
197
|
+
return result
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _sections(text: str) -> list[tuple[str, int, int]]:
|
|
201
|
+
lines = text.splitlines()
|
|
202
|
+
headings = [
|
|
203
|
+
(index + 1, line)
|
|
204
|
+
for index, line in enumerate(lines)
|
|
205
|
+
if re.match(r"^#{2,} ", line)
|
|
206
|
+
]
|
|
207
|
+
result = []
|
|
208
|
+
for position, (line_number, title) in enumerate(headings):
|
|
209
|
+
end = headings[position + 1][0] if position + 1 < len(headings) else len(lines) + 1
|
|
210
|
+
result.append((title.strip("# ").strip(), line_number, end - line_number))
|
|
211
|
+
return result
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _duplicate_findings(
|
|
215
|
+
paragraphs: list[tuple[str, int, frozenset[str], str]],
|
|
216
|
+
index: dict[str, list[int]],
|
|
217
|
+
) -> list[PolicyFinding]:
|
|
218
|
+
seen_pairs: set[tuple[int, int]] = set()
|
|
219
|
+
findings = []
|
|
220
|
+
for postings in index.values():
|
|
221
|
+
if len(postings) > POSTINGS_CAP or len(postings) < 2:
|
|
222
|
+
continue
|
|
223
|
+
for left_position, left_id in enumerate(postings):
|
|
224
|
+
for right_id in postings[left_position + 1:]:
|
|
225
|
+
pair = (left_id, right_id)
|
|
226
|
+
if pair in seen_pairs:
|
|
227
|
+
continue
|
|
228
|
+
seen_pairs.add(pair)
|
|
229
|
+
left_tokens = paragraphs[left_id][2]
|
|
230
|
+
right_tokens = paragraphs[right_id][2]
|
|
231
|
+
intersection = len(left_tokens & right_tokens)
|
|
232
|
+
if intersection < 4:
|
|
233
|
+
continue
|
|
234
|
+
overlap = intersection / len(left_tokens | right_tokens)
|
|
235
|
+
if overlap < RESTATEMENT:
|
|
236
|
+
continue
|
|
237
|
+
left = paragraphs[left_id]
|
|
238
|
+
right = paragraphs[right_id]
|
|
239
|
+
same_document = left[0] == right[0]
|
|
240
|
+
rule = "near-dup" if overlap >= NEAR_DUP or not same_document else "restatement"
|
|
241
|
+
location = f"{right[0]}:{right[1]}"
|
|
242
|
+
detail = f"~{overlap:.0%} overlap with {location}"
|
|
243
|
+
if not same_document:
|
|
244
|
+
detail += " (different doc -- pick one canonical home)"
|
|
245
|
+
findings.append(PolicyFinding(left[0], left[1], rule, detail))
|
|
246
|
+
return findings
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def scan_corpus(
|
|
250
|
+
root: Path,
|
|
251
|
+
*,
|
|
252
|
+
include_lengths: bool = True,
|
|
253
|
+
prepared_documents: Mapping[str, str] | None = None,
|
|
254
|
+
) -> list[PolicyFinding]:
|
|
255
|
+
"""Run the tuned Version 0 corpus rules with stable finding identifiers."""
|
|
256
|
+
root = root.resolve()
|
|
257
|
+
base = root if root.is_dir() else root.parent
|
|
258
|
+
findings: list[PolicyFinding] = []
|
|
259
|
+
paragraph_index: dict[str, list[int]] = {}
|
|
260
|
+
paragraphs: list[tuple[str, int, frozenset[str], str]] = []
|
|
261
|
+
|
|
262
|
+
document_rows = (
|
|
263
|
+
(
|
|
264
|
+
os.path.relpath(path, base),
|
|
265
|
+
path.name,
|
|
266
|
+
_read(path),
|
|
267
|
+
)
|
|
268
|
+
for path in list_documents(root)
|
|
269
|
+
) if prepared_documents is None else (
|
|
270
|
+
(relative, Path(relative).name, text)
|
|
271
|
+
for relative, text in sorted(prepared_documents.items())
|
|
272
|
+
)
|
|
273
|
+
for relative, name, text in document_rows:
|
|
274
|
+
if PROCESS_RE.search(name):
|
|
275
|
+
findings.append(PolicyFinding(
|
|
276
|
+
relative,
|
|
277
|
+
1,
|
|
278
|
+
"surface",
|
|
279
|
+
"process-artifact name on the reader-facing doc surface",
|
|
280
|
+
))
|
|
281
|
+
if not CHANGELOG_RE.search(name):
|
|
282
|
+
harness_hits = len(HARNESS_RE.findall(text))
|
|
283
|
+
if harness_hits >= HARNESS_HITS:
|
|
284
|
+
findings.append(PolicyFinding(
|
|
285
|
+
relative,
|
|
286
|
+
1,
|
|
287
|
+
"audience",
|
|
288
|
+
f"{harness_hits} harness/agent-address terms -- audience reads as an agent",
|
|
289
|
+
))
|
|
290
|
+
provenance = PROVENANCE_RE.findall(text)
|
|
291
|
+
if provenance:
|
|
292
|
+
match = PROVENANCE_RE.search(text)
|
|
293
|
+
assert match is not None
|
|
294
|
+
line = text.count("\n", 0, match.start()) + 1
|
|
295
|
+
findings.append(PolicyFinding(
|
|
296
|
+
relative,
|
|
297
|
+
line,
|
|
298
|
+
"provenance",
|
|
299
|
+
f"{len(provenance)} provenance/receipt marks in a reference doc "
|
|
300
|
+
f"(e.g. {match.group(0)!r})",
|
|
301
|
+
))
|
|
302
|
+
if include_lengths:
|
|
303
|
+
line_count = text.count("\n") + 1
|
|
304
|
+
if line_count > DOC_MAX_LINES and not PROCESS_RE.search(name):
|
|
305
|
+
findings.append(PolicyFinding(
|
|
306
|
+
relative,
|
|
307
|
+
1,
|
|
308
|
+
"doc-length",
|
|
309
|
+
f"{line_count} lines (> {DOC_MAX_LINES}) -- justify one file or split",
|
|
310
|
+
))
|
|
311
|
+
for title, line_number, count in _sections(text):
|
|
312
|
+
if count > SECTION_MAX_LINES:
|
|
313
|
+
findings.append(PolicyFinding(
|
|
314
|
+
relative,
|
|
315
|
+
line_number,
|
|
316
|
+
"section-length",
|
|
317
|
+
f"section {title!r} is {count} lines (> {SECTION_MAX_LINES})",
|
|
318
|
+
))
|
|
319
|
+
for start, paragraph in _paragraphs(text):
|
|
320
|
+
tokens = _content_tokens(paragraph)
|
|
321
|
+
if len(tokens) < MIN_PARA_TOKENS:
|
|
322
|
+
continue
|
|
323
|
+
paragraph_id = len(paragraphs)
|
|
324
|
+
paragraphs.append((relative, start, tokens, paragraph))
|
|
325
|
+
for token in tokens:
|
|
326
|
+
paragraph_index.setdefault(token, []).append(paragraph_id)
|
|
327
|
+
|
|
328
|
+
findings.extend(_duplicate_findings(paragraphs, paragraph_index))
|
|
329
|
+
order = {
|
|
330
|
+
"surface": 0,
|
|
331
|
+
"audience": 1,
|
|
332
|
+
"provenance": 2,
|
|
333
|
+
"near-dup": 3,
|
|
334
|
+
"doc-length": 4,
|
|
335
|
+
"section-length": 5,
|
|
336
|
+
"restatement": 6,
|
|
337
|
+
}
|
|
338
|
+
findings.sort(key=lambda finding: (
|
|
339
|
+
order.get(finding.rule, 9), finding.doc, finding.line
|
|
340
|
+
))
|
|
341
|
+
return findings
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def findings_as_json(findings: Iterable[PolicyFinding]) -> list[dict[str, object]]:
|
|
345
|
+
return [
|
|
346
|
+
{
|
|
347
|
+
"check": finding.rule,
|
|
348
|
+
"file": finding.doc,
|
|
349
|
+
"line": finding.line,
|
|
350
|
+
"detail": finding.detail,
|
|
351
|
+
}
|
|
352
|
+
for finding in findings
|
|
353
|
+
]
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def main(argv: list[str] | None = None) -> int:
|
|
357
|
+
"""Run the Version 0 command-line contract through the packaged engine."""
|
|
358
|
+
arguments = list(sys.argv[1:] if argv is None else argv)
|
|
359
|
+
as_json = "--json" in arguments
|
|
360
|
+
positional = [argument for argument in arguments if argument != "--json"]
|
|
361
|
+
root = Path(positional[0] if positional else os.getcwd()).resolve()
|
|
362
|
+
findings = scan_corpus(root)
|
|
363
|
+
if as_json:
|
|
364
|
+
print(json.dumps(findings_as_json(findings), indent=2))
|
|
365
|
+
return 1 if findings else 0
|
|
366
|
+
if not findings:
|
|
367
|
+
print(f"doc-hygiene: clean ({root})")
|
|
368
|
+
return 0
|
|
369
|
+
counts: dict[str, int] = {}
|
|
370
|
+
for finding in findings:
|
|
371
|
+
counts[finding.rule] = counts.get(finding.rule, 0) + 1
|
|
372
|
+
print(f"doc-hygiene: {len(findings)} finding(s) in {root}")
|
|
373
|
+
print(" " + ", ".join(f"{key}={value}" for key, value in sorted(counts.items())))
|
|
374
|
+
print()
|
|
375
|
+
for finding in findings:
|
|
376
|
+
print(f" [{finding.rule}] {finding.doc}:{finding.line} {finding.detail}")
|
|
377
|
+
return 1
|