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
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Project a Sourcebound manifest into a docs-only stepwise skill package."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
from clean_docs.errors import ConfigurationError
|
|
10
|
+
from clean_docs.models import Manifest
|
|
11
|
+
from clean_docs.regions import atomic_write
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
PREAMBLE = (
|
|
15
|
+
"**Read ONLY this file.** Do not read any other reference file until this one tells you to."
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _bound_docs(manifest: Manifest) -> list[str]:
|
|
20
|
+
return sorted({binding.doc.as_posix() for binding in manifest.bindings})
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _config(
|
|
24
|
+
*,
|
|
25
|
+
display_name: str,
|
|
26
|
+
role: str,
|
|
27
|
+
parent_command: str | None,
|
|
28
|
+
command: str | None,
|
|
29
|
+
) -> dict[str, object]:
|
|
30
|
+
cli: dict[str, object] = {"role": role}
|
|
31
|
+
if role == "command":
|
|
32
|
+
if not command:
|
|
33
|
+
raise ConfigurationError("a command name is required when role is command")
|
|
34
|
+
cli["command"] = command
|
|
35
|
+
if parent_command:
|
|
36
|
+
cli["parentCommand"] = parent_command
|
|
37
|
+
elif parent_command or command:
|
|
38
|
+
raise ConfigurationError("command options require role command")
|
|
39
|
+
return {
|
|
40
|
+
"type": "skill",
|
|
41
|
+
"template": "description.md",
|
|
42
|
+
"description": "Keep repository documentation true to its source with Sourcebound",
|
|
43
|
+
"tags": ["documentation", "sourcebound"],
|
|
44
|
+
"cli": cli,
|
|
45
|
+
"references": {"preamble": PREAMBLE},
|
|
46
|
+
"variants": [
|
|
47
|
+
{
|
|
48
|
+
"id": "all",
|
|
49
|
+
"display_name": display_name,
|
|
50
|
+
"tags": ["documentation"],
|
|
51
|
+
"docs_urls": [],
|
|
52
|
+
}
|
|
53
|
+
],
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _description(bound_docs: list[str]) -> str:
|
|
58
|
+
listed = "\n".join(f"- `{doc}`" for doc in bound_docs) or "- No bound documents yet."
|
|
59
|
+
return (
|
|
60
|
+
"# Keep documentation true\n\n"
|
|
61
|
+
"<!-- sourcebound:purpose -->\n"
|
|
62
|
+
"Use this package when repository documentation may have drifted from its sources. "
|
|
63
|
+
"It gives maintainers an ordered audit, bounded repair, and verification path with an "
|
|
64
|
+
"explicit condition for each transition.\n"
|
|
65
|
+
"<!-- sourcebound:end purpose -->\n\n"
|
|
66
|
+
"Bound documents in this repository:\n\n"
|
|
67
|
+
f"{listed}\n"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _step_audit() -> str:
|
|
72
|
+
return (
|
|
73
|
+
"---\nnext_step: 2-repair.md\n---\n\n"
|
|
74
|
+
"# Step 1: audit the corpus\n\n"
|
|
75
|
+
"<!-- sourcebound:purpose -->\n"
|
|
76
|
+
"Use this step before changing docs when the active corpus may contain structural "
|
|
77
|
+
"findings. It gives maintainers a read-only list of repairs that must precede binding "
|
|
78
|
+
"work.\n"
|
|
79
|
+
"<!-- sourcebound:end purpose -->\n\n"
|
|
80
|
+
"## Status\n\n"
|
|
81
|
+
"Emit: `sourcebound: auditing documentation`.\n\n"
|
|
82
|
+
"## Action\n\n"
|
|
83
|
+
"Run the manifest-free corpus audit:\n\n"
|
|
84
|
+
"```bash\nsourcebound audit\n```\n\n"
|
|
85
|
+
"## Decision\n\n"
|
|
86
|
+
"Exit `0`: proceed. Exit `1`: apply each finding's named repair, then rerun this step. "
|
|
87
|
+
"Exit `2` or `3`: repair the configuration or extractor before continuing.\n\n"
|
|
88
|
+
"## Navigation\n\n"
|
|
89
|
+
"When the audit exits `0`, read `2-repair.md`.\n"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _step_repair() -> str:
|
|
94
|
+
return (
|
|
95
|
+
"---\nnext_step: 3-verify.md\n---\n\n"
|
|
96
|
+
"# Step 2: repair bound regions\n\n"
|
|
97
|
+
"<!-- sourcebound:purpose -->\n"
|
|
98
|
+
"Use this step after the corpus audit passes and source-bound regions may be stale. "
|
|
99
|
+
"It repairs declared regions while preserving prose outside their markers.\n"
|
|
100
|
+
"<!-- sourcebound:end purpose -->\n\n"
|
|
101
|
+
"## Status\n\n"
|
|
102
|
+
"Emit: `sourcebound: repairing bound regions`.\n\n"
|
|
103
|
+
"## Action\n\n"
|
|
104
|
+
"Derive the declared regions from source and enforce the packaged standard:\n\n"
|
|
105
|
+
"```bash\nsourcebound drive\n```\n\n"
|
|
106
|
+
"This writes only the regions declared in the manifest. It preserves prose outside the "
|
|
107
|
+
"markers "
|
|
108
|
+
"and refuses a write when policy fails.\n\n"
|
|
109
|
+
"## Decision\n\n"
|
|
110
|
+
"Exit `0`: proceed. A policy finding requires an author to repair the flagged prose and "
|
|
111
|
+
"rerun this step.\n\n"
|
|
112
|
+
"## Navigation\n\n"
|
|
113
|
+
"When `drive` exits `0`, read `3-verify.md`.\n"
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _step_verify() -> str:
|
|
118
|
+
return (
|
|
119
|
+
"---\nnext_step: null\n---\n\n"
|
|
120
|
+
"# Step 3: verify before publishing\n\n"
|
|
121
|
+
"<!-- sourcebound:purpose -->\n"
|
|
122
|
+
"Use this step after repair when you need evidence that the corpus and every binding "
|
|
123
|
+
"are current. It gives maintainers the read-only release gate and the exact stop "
|
|
124
|
+
"condition.\n"
|
|
125
|
+
"<!-- sourcebound:end purpose -->\n\n"
|
|
126
|
+
"## Status\n\n"
|
|
127
|
+
"Emit: `sourcebound: verifying`.\n\n"
|
|
128
|
+
"## Action\n\n"
|
|
129
|
+
"Run the read-only gates:\n\n"
|
|
130
|
+
"```bash\nsourcebound audit\nsourcebound check\n```\n\n"
|
|
131
|
+
"## Decision\n\n"
|
|
132
|
+
"Both commands exit `0`: publishing is allowed. Either exits nonzero: return to the step "
|
|
133
|
+
"that owns the finding and do not publish.\n\n"
|
|
134
|
+
"## Navigation\n\n"
|
|
135
|
+
"This is the final step. It writes nothing.\n"
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def emit_stepwise_skill(
|
|
140
|
+
manifest: Manifest,
|
|
141
|
+
out_dir: Path,
|
|
142
|
+
*,
|
|
143
|
+
display_name: str = "Keep documentation true",
|
|
144
|
+
role: str = "skill",
|
|
145
|
+
parent_command: str | None = None,
|
|
146
|
+
command: str | None = None,
|
|
147
|
+
) -> tuple[Path, ...]:
|
|
148
|
+
"""Write a manifest-derived stepwise skill package and return its files."""
|
|
149
|
+
bound_docs = _bound_docs(manifest)
|
|
150
|
+
files = {
|
|
151
|
+
out_dir / "config.yaml": yaml.safe_dump(
|
|
152
|
+
_config(
|
|
153
|
+
display_name=display_name,
|
|
154
|
+
role=role,
|
|
155
|
+
parent_command=parent_command,
|
|
156
|
+
command=command,
|
|
157
|
+
),
|
|
158
|
+
sort_keys=False,
|
|
159
|
+
allow_unicode=True,
|
|
160
|
+
),
|
|
161
|
+
out_dir / "description.md": _description(bound_docs),
|
|
162
|
+
out_dir / "references/1-audit.md": _step_audit(),
|
|
163
|
+
out_dir / "references/2-repair.md": _step_repair(),
|
|
164
|
+
out_dir / "references/3-verify.md": _step_verify(),
|
|
165
|
+
}
|
|
166
|
+
for path, text in files.items():
|
|
167
|
+
atomic_write(path, text)
|
|
168
|
+
return tuple(sorted(files))
|
clean_docs/engine.py
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import difflib
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from clean_docs.errors import ConfigurationError, ExtractionError
|
|
10
|
+
from clean_docs.execution import ExecutionPolicy
|
|
11
|
+
from clean_docs.extractors import (
|
|
12
|
+
extract_command,
|
|
13
|
+
extract_file,
|
|
14
|
+
extract_json_pointer,
|
|
15
|
+
extract_paths,
|
|
16
|
+
extract_python_literal,
|
|
17
|
+
extract_repository_inventory,
|
|
18
|
+
extract_repository_overview,
|
|
19
|
+
extract_structured,
|
|
20
|
+
)
|
|
21
|
+
from clean_docs.extractors.inventory import _extract_repository_overview_legacy
|
|
22
|
+
from clean_docs.manifest import load_manifest
|
|
23
|
+
from clean_docs.inventory import InventoryItem
|
|
24
|
+
from clean_docs.models import (
|
|
25
|
+
Binding,
|
|
26
|
+
BindingResult,
|
|
27
|
+
ClaimBinding,
|
|
28
|
+
Manifest,
|
|
29
|
+
Provenance,
|
|
30
|
+
RegionBinding,
|
|
31
|
+
SymbolBinding,
|
|
32
|
+
)
|
|
33
|
+
from clean_docs.policy import PolicyFinding, check_documents
|
|
34
|
+
from clean_docs.plugins import check_plugin_policies, extract_plugin, render_plugin
|
|
35
|
+
from clean_docs.regions import atomic_write, replace_region
|
|
36
|
+
from clean_docs.renderers import render
|
|
37
|
+
from clean_docs.snapshot import RepositorySnapshot
|
|
38
|
+
from clean_docs.standard import load_default_pack
|
|
39
|
+
from clean_docs.symbols import resolve_symbol
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _select(bindings: tuple[Binding, ...], binding_id: str | None) -> list[Binding]:
|
|
43
|
+
if binding_id is None:
|
|
44
|
+
return list(bindings)
|
|
45
|
+
selected = [binding for binding in bindings if binding.id == binding_id]
|
|
46
|
+
if not selected:
|
|
47
|
+
raise ConfigurationError(f"unknown binding id: {binding_id}")
|
|
48
|
+
return selected
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _document(root: Path, binding: Binding) -> str:
|
|
52
|
+
try:
|
|
53
|
+
return (root / binding.doc).read_text(encoding="utf-8")
|
|
54
|
+
except OSError as exc:
|
|
55
|
+
raise ConfigurationError(f"cannot read bound document {binding.doc}: {exc}") from exc
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _has_anchor(document: str, anchor: str) -> bool:
|
|
59
|
+
return _anchored_section(document, anchor) is not None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _anchored_section(document: str, anchor: str) -> str | None:
|
|
63
|
+
lines = document.splitlines(keepends=True)
|
|
64
|
+
start: int | None = None
|
|
65
|
+
level = 0
|
|
66
|
+
for index, line in enumerate(lines):
|
|
67
|
+
match = re.match(r"^#{1,6}\s+(.+?)\s*$", line)
|
|
68
|
+
if match:
|
|
69
|
+
heading_level = len(line) - len(line.lstrip("#"))
|
|
70
|
+
if start is not None and heading_level <= level:
|
|
71
|
+
return "".join(lines[start:index])
|
|
72
|
+
slug = re.sub(r"[^a-z0-9 -]", "", match.group(1).lower()).replace(" ", "-")
|
|
73
|
+
if slug == anchor:
|
|
74
|
+
start = index + 1
|
|
75
|
+
level = heading_level
|
|
76
|
+
return None if start is None else "".join(lines[start:])
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _claim_result(root: Path, snapshot: RepositorySnapshot, manifest: Manifest, binding: ClaimBinding) -> BindingResult:
|
|
80
|
+
document = _document(root, binding)
|
|
81
|
+
section = _anchored_section(document, binding.anchor)
|
|
82
|
+
if section is None:
|
|
83
|
+
raise ConfigurationError(f"document anchor not found: {binding.doc}#{binding.anchor}")
|
|
84
|
+
command = next(item for item in manifest.commands if item.id == binding.command)
|
|
85
|
+
evidence = extract_command(snapshot, command, binding.assertion.path)
|
|
86
|
+
prose_current = (
|
|
87
|
+
binding.assertion.prose is None
|
|
88
|
+
or binding.assertion.prose in section
|
|
89
|
+
)
|
|
90
|
+
changed = evidence.value != binding.assertion.expected or not prose_current
|
|
91
|
+
expected = json.dumps(binding.assertion.expected, sort_keys=True)
|
|
92
|
+
observed = json.dumps(evidence.value, sort_keys=True)
|
|
93
|
+
diff_lines = []
|
|
94
|
+
if evidence.value != binding.assertion.expected:
|
|
95
|
+
diff_lines.append(
|
|
96
|
+
f"command pin {binding.doc}#{binding.anchor}: "
|
|
97
|
+
f"expected {expected}, observed {observed}"
|
|
98
|
+
)
|
|
99
|
+
if binding.assertion.prose is not None and not prose_current:
|
|
100
|
+
diff_lines.append(
|
|
101
|
+
f"command pin {binding.doc}#{binding.anchor}: "
|
|
102
|
+
f"anchored prose is missing {binding.assertion.prose!r}"
|
|
103
|
+
)
|
|
104
|
+
diff = "" if not diff_lines else "\n".join(diff_lines) + "\n"
|
|
105
|
+
return BindingResult(
|
|
106
|
+
binding.id, binding.doc.as_posix(), changed, expected, observed, diff,
|
|
107
|
+
evidence.provenance, "command-pin", prose_checked=binding.assertion.prose is not None,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _symbol_result(root: Path, snapshot: RepositorySnapshot, binding: SymbolBinding) -> BindingResult:
|
|
112
|
+
document = _document(root, binding)
|
|
113
|
+
if not _has_anchor(document, binding.anchor):
|
|
114
|
+
raise ConfigurationError(f"document anchor not found: {binding.doc}#{binding.anchor}")
|
|
115
|
+
try:
|
|
116
|
+
evidence = resolve_symbol(snapshot, binding)
|
|
117
|
+
changed = False
|
|
118
|
+
observed = "exists"
|
|
119
|
+
provenance = evidence.provenance
|
|
120
|
+
diff = ""
|
|
121
|
+
except ExtractionError as exc:
|
|
122
|
+
if "not found" not in str(exc) and "cannot read source" not in str(exc):
|
|
123
|
+
raise
|
|
124
|
+
changed = True
|
|
125
|
+
observed = "missing"
|
|
126
|
+
locator = binding.source.symbol or binding.source.path.as_posix()
|
|
127
|
+
provenance = Provenance(
|
|
128
|
+
snapshot.label,
|
|
129
|
+
binding.source.path.as_posix(),
|
|
130
|
+
locator,
|
|
131
|
+
"symbol@1",
|
|
132
|
+
hashlib.sha256(locator.encode()).hexdigest(),
|
|
133
|
+
)
|
|
134
|
+
diff = f"symbol {locator} referenced by {binding.doc}#{binding.anchor} is missing\n"
|
|
135
|
+
return BindingResult(
|
|
136
|
+
binding.id, binding.doc.as_posix(), changed, "exists", observed, diff,
|
|
137
|
+
provenance, "symbol",
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def evaluate(
|
|
142
|
+
root: Path,
|
|
143
|
+
manifest_path: Path,
|
|
144
|
+
*,
|
|
145
|
+
ref: str | None = None,
|
|
146
|
+
binding_id: str | None = None,
|
|
147
|
+
execution_policy: ExecutionPolicy = ExecutionPolicy.TRUSTED,
|
|
148
|
+
inventory_items: tuple[InventoryItem, ...] | None = None,
|
|
149
|
+
) -> list[BindingResult]:
|
|
150
|
+
manifest = load_manifest(manifest_path)
|
|
151
|
+
snapshot = RepositorySnapshot(root=root, ref=ref)
|
|
152
|
+
results: list[BindingResult] = []
|
|
153
|
+
documents: dict[str, str] = {}
|
|
154
|
+
for binding in _select(manifest.bindings, binding_id):
|
|
155
|
+
uses_plugin = (
|
|
156
|
+
isinstance(binding, RegionBinding)
|
|
157
|
+
and (
|
|
158
|
+
binding.extractor.startswith("plugin:")
|
|
159
|
+
or binding.renderer.startswith("plugin:")
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
if execution_policy is ExecutionPolicy.STATIC_ONLY and (
|
|
163
|
+
isinstance(binding, ClaimBinding) or uses_plugin
|
|
164
|
+
):
|
|
165
|
+
locator = (
|
|
166
|
+
binding.command
|
|
167
|
+
if isinstance(binding, ClaimBinding)
|
|
168
|
+
else binding.id
|
|
169
|
+
)
|
|
170
|
+
mechanism = (
|
|
171
|
+
"command-pin" if isinstance(binding, ClaimBinding) else "plugin"
|
|
172
|
+
)
|
|
173
|
+
provenance = Provenance(
|
|
174
|
+
snapshot.label,
|
|
175
|
+
binding.doc.as_posix(),
|
|
176
|
+
locator,
|
|
177
|
+
f"{mechanism}@skipped",
|
|
178
|
+
hashlib.sha256(locator.encode()).hexdigest(),
|
|
179
|
+
)
|
|
180
|
+
results.append(
|
|
181
|
+
BindingResult(
|
|
182
|
+
binding_id=binding.id,
|
|
183
|
+
doc=binding.doc.as_posix(),
|
|
184
|
+
changed=True,
|
|
185
|
+
expected="trusted declared execution",
|
|
186
|
+
observed="skipped by static-only execution policy",
|
|
187
|
+
diff=(
|
|
188
|
+
f"{mechanism} {binding.id} was not evaluated because "
|
|
189
|
+
"repository-declared execution is disabled\n"
|
|
190
|
+
),
|
|
191
|
+
provenance=provenance,
|
|
192
|
+
binding_type=mechanism,
|
|
193
|
+
state="skipped-untrusted-execution",
|
|
194
|
+
)
|
|
195
|
+
)
|
|
196
|
+
continue
|
|
197
|
+
if isinstance(binding, ClaimBinding):
|
|
198
|
+
results.append(_claim_result(root, snapshot, manifest, binding))
|
|
199
|
+
continue
|
|
200
|
+
if isinstance(binding, SymbolBinding):
|
|
201
|
+
results.append(_symbol_result(root, snapshot, binding))
|
|
202
|
+
continue
|
|
203
|
+
assert isinstance(binding, RegionBinding)
|
|
204
|
+
if binding.extractor.startswith("plugin:"):
|
|
205
|
+
plugin_id = binding.extractor.removeprefix("plugin:")
|
|
206
|
+
plugin = next(item for item in manifest.plugins if item.id == plugin_id)
|
|
207
|
+
evidence = extract_plugin(snapshot, binding, plugin)
|
|
208
|
+
else:
|
|
209
|
+
extractors = {
|
|
210
|
+
"file": extract_file,
|
|
211
|
+
"json": extract_json_pointer,
|
|
212
|
+
"path": extract_paths,
|
|
213
|
+
"python-literal": extract_python_literal,
|
|
214
|
+
"repository-inventory": extract_repository_inventory,
|
|
215
|
+
"repository-overview": extract_repository_overview,
|
|
216
|
+
"structured-data": extract_structured,
|
|
217
|
+
}
|
|
218
|
+
if binding.extractor == "repository-overview":
|
|
219
|
+
evidence = extract_repository_overview(
|
|
220
|
+
snapshot,
|
|
221
|
+
binding,
|
|
222
|
+
inventory_items=inventory_items,
|
|
223
|
+
)
|
|
224
|
+
else:
|
|
225
|
+
evidence = extractors[binding.extractor](snapshot, binding)
|
|
226
|
+
if binding.renderer.startswith("plugin:"):
|
|
227
|
+
renderer_id = binding.renderer.removeprefix("plugin:")
|
|
228
|
+
renderer_plugin = next(
|
|
229
|
+
item for item in manifest.plugins if item.id == renderer_id
|
|
230
|
+
)
|
|
231
|
+
rendered = render_plugin(snapshot, binding, renderer_plugin, evidence)
|
|
232
|
+
else:
|
|
233
|
+
rendered = render(evidence, binding)
|
|
234
|
+
doc_path = root / binding.doc
|
|
235
|
+
doc_key = binding.doc.as_posix()
|
|
236
|
+
if doc_key not in documents:
|
|
237
|
+
try:
|
|
238
|
+
documents[doc_key] = doc_path.read_text(encoding="utf-8")
|
|
239
|
+
except OSError as exc:
|
|
240
|
+
raise ConfigurationError(
|
|
241
|
+
f"cannot read bound document {binding.doc}: {exc}"
|
|
242
|
+
) from exc
|
|
243
|
+
observed = documents[doc_key]
|
|
244
|
+
expected = replace_region(observed, binding.region, rendered)
|
|
245
|
+
if observed != expected and binding.extractor == "repository-overview":
|
|
246
|
+
legacy_evidence = _extract_repository_overview_legacy(
|
|
247
|
+
snapshot,
|
|
248
|
+
binding,
|
|
249
|
+
inventory_items=inventory_items,
|
|
250
|
+
)
|
|
251
|
+
legacy_rendered = render(legacy_evidence, binding)
|
|
252
|
+
legacy_expected = replace_region(
|
|
253
|
+
observed, binding.region, legacy_rendered
|
|
254
|
+
)
|
|
255
|
+
if observed == legacy_expected:
|
|
256
|
+
evidence = legacy_evidence
|
|
257
|
+
expected = observed
|
|
258
|
+
documents[doc_key] = expected
|
|
259
|
+
diff = "".join(difflib.unified_diff(
|
|
260
|
+
observed.splitlines(keepends=True),
|
|
261
|
+
expected.splitlines(keepends=True),
|
|
262
|
+
fromfile=binding.doc.as_posix(),
|
|
263
|
+
tofile=f"{binding.doc.as_posix()} (derived)",
|
|
264
|
+
))
|
|
265
|
+
results.append(BindingResult(
|
|
266
|
+
binding_id=binding.id,
|
|
267
|
+
doc=binding.doc.as_posix(),
|
|
268
|
+
changed=observed != expected,
|
|
269
|
+
expected=expected,
|
|
270
|
+
observed=observed,
|
|
271
|
+
diff=diff,
|
|
272
|
+
provenance=evidence.provenance,
|
|
273
|
+
))
|
|
274
|
+
return results
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def write_results(root: Path, results: list[BindingResult]) -> None:
|
|
278
|
+
by_doc: dict[str, str] = {}
|
|
279
|
+
for result in results:
|
|
280
|
+
if result.binding_type != "region":
|
|
281
|
+
continue
|
|
282
|
+
current = by_doc.get(result.doc)
|
|
283
|
+
if current is not None and current != result.observed:
|
|
284
|
+
raise ConfigurationError(
|
|
285
|
+
f"multiple bindings for {result.doc} require a combined document update"
|
|
286
|
+
)
|
|
287
|
+
by_doc[result.doc] = result.expected
|
|
288
|
+
for doc, content in by_doc.items():
|
|
289
|
+
atomic_write(root / doc, content)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def planned_documents(results: list[BindingResult]) -> dict[str, str]:
|
|
293
|
+
documents: dict[str, str] = {}
|
|
294
|
+
for result in results:
|
|
295
|
+
if result.binding_type != "region":
|
|
296
|
+
continue
|
|
297
|
+
current = documents.get(result.doc)
|
|
298
|
+
if current is not None and current != result.observed:
|
|
299
|
+
raise ConfigurationError(f"binding plan for {result.doc} is not sequential")
|
|
300
|
+
documents[result.doc] = result.expected
|
|
301
|
+
return documents
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def drive(
|
|
305
|
+
root: Path,
|
|
306
|
+
manifest_path: Path,
|
|
307
|
+
*,
|
|
308
|
+
ref: str | None = None,
|
|
309
|
+
binding_id: str | None = None,
|
|
310
|
+
) -> tuple[list[BindingResult], list[PolicyFinding]]:
|
|
311
|
+
results = evaluate(root, manifest_path, ref=ref, binding_id=binding_id)
|
|
312
|
+
planned = planned_documents(results)
|
|
313
|
+
manifest = load_manifest(manifest_path)
|
|
314
|
+
findings = check_documents(planned, load_default_pack())
|
|
315
|
+
findings.extend(
|
|
316
|
+
check_plugin_policies(RepositorySnapshot(root, ref), manifest.plugins, planned)
|
|
317
|
+
)
|
|
318
|
+
if findings:
|
|
319
|
+
return results, findings
|
|
320
|
+
write_results(root, results)
|
|
321
|
+
remaining = evaluate(root, manifest_path, ref=ref, binding_id=binding_id)
|
|
322
|
+
if any(result.changed for result in remaining if result.binding_type == "region"):
|
|
323
|
+
raise ConfigurationError("drive wrote documentation but drift remains")
|
|
324
|
+
return results, []
|
clean_docs/errors.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
class CleanDocsError(Exception):
|
|
2
|
+
"""Base error with a stable process exit code."""
|
|
3
|
+
|
|
4
|
+
exit_code = 3
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ConfigurationError(CleanDocsError):
|
|
8
|
+
exit_code = 2
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ExtractionError(CleanDocsError):
|
|
12
|
+
exit_code = 3
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RegionError(CleanDocsError):
|
|
16
|
+
exit_code = 3
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class PolicyError(CleanDocsError):
|
|
20
|
+
exit_code = 1
|