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
@@ -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