sourcebound 1.2.1__py3-none-any.whl

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