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,138 @@
1
+ """Resolve explicit runtime tokens in allowlisted command arrays."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import signal
7
+ import subprocess
8
+ import sys
9
+ import tempfile
10
+ import threading
11
+ import time
12
+ from dataclasses import dataclass
13
+ from enum import Enum
14
+ from pathlib import Path
15
+
16
+ from clean_docs.errors import ConfigurationError
17
+
18
+
19
+ PYTHON_EXECUTABLE_TOKEN = "{python}"
20
+
21
+
22
+ class ExecutionPolicy(str, Enum):
23
+ TRUSTED = "trusted"
24
+ STATIC_ONLY = "static-only"
25
+
26
+
27
+ def resolve_argv(argv: tuple[str, ...]) -> tuple[str, ...]:
28
+ if argv and argv[0] == PYTHON_EXECUTABLE_TOKEN:
29
+ return (sys.executable, *argv[1:])
30
+ return argv
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class BoundedCommandResult:
35
+ stdout: bytes
36
+ stderr: bytes
37
+ duration_seconds: float
38
+
39
+
40
+ def run_bounded_command(
41
+ argv: tuple[str, ...],
42
+ *,
43
+ environment: dict[str, str],
44
+ input_bytes: bytes,
45
+ timeout_seconds: int,
46
+ max_input_bytes: int,
47
+ max_output_bytes: int,
48
+ prefix: str,
49
+ ) -> BoundedCommandResult:
50
+ """Run an operator-selected command in a disposable directory with bounded I/O."""
51
+ if len(input_bytes) > max_input_bytes:
52
+ raise ConfigurationError(f"{prefix} input exceeds {max_input_bytes} bytes")
53
+ with tempfile.TemporaryDirectory(prefix=f"{prefix}-") as temporary:
54
+ start = time.monotonic()
55
+ try:
56
+ process = subprocess.Popen(
57
+ resolve_argv(argv),
58
+ cwd=Path(temporary),
59
+ env=environment,
60
+ stdin=subprocess.PIPE,
61
+ stdout=subprocess.PIPE,
62
+ stderr=subprocess.PIPE,
63
+ start_new_session=True,
64
+ )
65
+ except OSError as exc:
66
+ raise ConfigurationError(f"{prefix} failed to start: {exc}") from exc
67
+ stdout = bytearray()
68
+ stderr = bytearray()
69
+ total = 0
70
+ lock = threading.Lock()
71
+ exceeded = threading.Event()
72
+
73
+ def stop_process() -> None:
74
+ if process.poll() is not None:
75
+ return
76
+ try:
77
+ # The configured command can start children. Killing its process group
78
+ # keeps an inherited pipe from outliving the deadline and temp directory.
79
+ os.killpg(process.pid, signal.SIGKILL)
80
+ except (AttributeError, OSError):
81
+ process.kill()
82
+
83
+ def drain(stream: object, destination: bytearray) -> None:
84
+ nonlocal total
85
+ while True:
86
+ chunk = stream.read(64 * 1024) # type: ignore[attr-defined]
87
+ if not chunk:
88
+ return
89
+ with lock:
90
+ total += len(chunk)
91
+ if total > max_output_bytes:
92
+ exceeded.set()
93
+ stop_process()
94
+ return
95
+ destination.extend(chunk)
96
+
97
+ def supply_input() -> None:
98
+ try:
99
+ assert process.stdin is not None
100
+ process.stdin.write(input_bytes)
101
+ process.stdin.close()
102
+ except (BrokenPipeError, OSError):
103
+ # A nonzero exit is reported from the process status below. The writer
104
+ # must not hide that status or block the timeout path.
105
+ return
106
+
107
+ assert process.stdin is not None and process.stdout is not None and process.stderr is not None
108
+ readers = (
109
+ threading.Thread(target=drain, args=(process.stdout, stdout), daemon=True),
110
+ threading.Thread(target=drain, args=(process.stderr, stderr), daemon=True),
111
+ )
112
+ for reader in readers:
113
+ reader.start()
114
+ writer = threading.Thread(target=supply_input, daemon=True)
115
+ writer.start()
116
+ try:
117
+ process.wait(timeout=timeout_seconds)
118
+ except subprocess.TimeoutExpired as exc:
119
+ stop_process()
120
+ process.wait()
121
+ raise ConfigurationError(f"{prefix} timed out after {timeout_seconds} seconds") from exc
122
+ except OSError as exc:
123
+ stop_process()
124
+ process.wait()
125
+ raise ConfigurationError(f"{prefix} failed during execution: {exc}") from exc
126
+ finally:
127
+ writer.join(timeout=5)
128
+ for reader in readers:
129
+ reader.join(timeout=5)
130
+ if exceeded.is_set():
131
+ raise ConfigurationError(f"{prefix} output exceeds {max_output_bytes} bytes")
132
+ if process.returncode != 0:
133
+ raise ConfigurationError(f"{prefix} exited {process.returncode}")
134
+ return BoundedCommandResult(
135
+ stdout=bytes(stdout),
136
+ stderr=bytes(stderr),
137
+ duration_seconds=time.monotonic() - start,
138
+ )
clean_docs/explain.py ADDED
@@ -0,0 +1,123 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict, dataclass
4
+ from pathlib import Path
5
+
6
+ from clean_docs.errors import ConfigurationError
7
+ from clean_docs.inventory import scan_inventory
8
+
9
+
10
+ RULES = {
11
+ "audience": (
12
+ "The document addresses a future executor instead of its product reader.",
13
+ "Rewrite it for the reader's current task or move process state under docs/archive.",
14
+ ),
15
+ "broken-local-link": (
16
+ "A relative Markdown link does not resolve from its document.",
17
+ "Fix the target path or remove the link.",
18
+ ),
19
+ "cross-project-residue": (
20
+ "Tracked content contains a token reserved for another repository.",
21
+ "Replace the token with this repository's term or remove the copied residue.",
22
+ ),
23
+ "doc-length": (
24
+ "A document exceeds the packaged line budget.",
25
+ "Split distinct reader tasks or add a reasoned canonical-reference allowance.",
26
+ ),
27
+ "generated-artifact": (
28
+ "Generated runtime residue is tracked as product source.",
29
+ "Remove the artifact and add its pattern to the repository ignore file.",
30
+ ),
31
+ "local-path-residue": (
32
+ "Tracked content contains a machine-specific home path.",
33
+ "Replace it with a repository-relative or portable path.",
34
+ ),
35
+ "near-duplicate": (
36
+ "Two reader-facing documents carry substantially the same content.",
37
+ "Keep one canonical explanation and link to it from the other task surface.",
38
+ ),
39
+ "process-artifact": (
40
+ "A status, handoff, plan, or report remains on the reader-facing surface.",
41
+ "Move process history under docs/archive and link only if readers need it.",
42
+ ),
43
+ "purpose-contract": (
44
+ "The document does not open with one marked BLUF purpose contract.",
45
+ "Put a plain prose block after the H1 that names who should read, the problem, and the resulting capability.",
46
+ ),
47
+ "prohibited-booster": (
48
+ "The prose uses a prohibited booster instead of a verifiable claim.",
49
+ "Remove the booster and state the measured property directly.",
50
+ ),
51
+ "provenance": (
52
+ "Reader-facing reference text contains authoring receipts or run provenance.",
53
+ "Move the receipt to history and leave current truth in the reference.",
54
+ ),
55
+ "restatement": (
56
+ "A fact is restated across reader-facing documents without one canonical home.",
57
+ "Keep the fact in one document and replace sibling copies with links.",
58
+ ),
59
+ "section-length": (
60
+ "A section exceeds the packaged line budget.",
61
+ "Split the section by reader task or add a reasoned canonical-reference allowance.",
62
+ ),
63
+ "unreadable-document": (
64
+ "A tracked Markdown path cannot be read from the current worktree.",
65
+ "Restore the document, remove the stale tracked path, or fix its permissions.",
66
+ ),
67
+ }
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class Explanation:
72
+ id: str
73
+ kind: str
74
+ state: str
75
+ summary: str
76
+ evidence: dict[str, str]
77
+ repair: str
78
+ required: bool
79
+
80
+ def as_dict(self) -> dict[str, object]:
81
+ return asdict(self)
82
+
83
+
84
+ def explain(root: Path, identifier: str) -> Explanation:
85
+ if identifier in RULES:
86
+ summary, repair = RULES[identifier]
87
+ return Explanation(identifier, "policy-rule", "required", summary, {}, repair, True)
88
+ item = next(
89
+ (candidate for candidate in scan_inventory(root).items if candidate.id == identifier),
90
+ None,
91
+ )
92
+ if item is None:
93
+ raise ConfigurationError(f"unknown finding or inventory id: {identifier}")
94
+ evidence = {
95
+ "source": item.source,
96
+ "locator": item.locator,
97
+ "adapter": item.adapter,
98
+ "sha256": item.digest,
99
+ }
100
+ if item.coverage == "bound":
101
+ summary = "The detected surface has a source-specific documentation binding."
102
+ repair = "No repair is required while the binding remains current."
103
+ elif item.coverage == "cataloged":
104
+ summary = (
105
+ "The detected surface is tracked by a repository catalog, but has no "
106
+ "source-specific documentation binding."
107
+ )
108
+ repair = "Add a source-specific binding if readers depend on this surface."
109
+ elif item.coverage == "ignored":
110
+ summary = f"The detected surface is ignored by policy: {item.coverage_reason}"
111
+ repair = "Remove the reasoned ignore when this surface becomes reader-facing."
112
+ else:
113
+ summary = "The detected surface has evidence but no binding or reasoned ignore."
114
+ repair = "Add a source binding or a specific record in .sourcebound-ignore.yml."
115
+ return Explanation(
116
+ item.id,
117
+ "inventory-surface",
118
+ item.coverage,
119
+ summary,
120
+ evidence,
121
+ repair,
122
+ False,
123
+ )
@@ -0,0 +1,19 @@
1
+ from clean_docs.extractors.json_pointer import extract_json_pointer
2
+ from clean_docs.extractors.inventory import (
3
+ extract_repository_inventory,
4
+ extract_repository_overview,
5
+ )
6
+ from clean_docs.extractors.python_literal import extract_python_literal
7
+ from clean_docs.extractors.static import extract_file, extract_paths, extract_structured
8
+
9
+ __all__ = [
10
+ "extract_command",
11
+ "extract_file",
12
+ "extract_json_pointer",
13
+ "extract_paths",
14
+ "extract_python_literal",
15
+ "extract_repository_inventory",
16
+ "extract_repository_overview",
17
+ "extract_structured",
18
+ ]
19
+ from clean_docs.extractors.command import extract_command
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ from typing import Any
6
+
7
+ from clean_docs.errors import ExtractionError
8
+ from clean_docs.execution import resolve_argv
9
+ from clean_docs.isolation import run_isolated_process
10
+ from clean_docs.models import CommandSpec, EvidenceValue, Provenance
11
+ from clean_docs.snapshot import RepositorySnapshot
12
+
13
+
14
+ def _select(value: Any, path: str) -> Any:
15
+ current = value
16
+ for token in path.removeprefix("$.").split("."):
17
+ if not isinstance(current, dict) or token not in current:
18
+ raise ExtractionError(f"command JSON path does not resolve: {path}")
19
+ current = current[token]
20
+ return current
21
+
22
+
23
+ def extract_command(
24
+ snapshot: RepositorySnapshot, command: CommandSpec, json_path: str
25
+ ) -> EvidenceValue:
26
+ proc = run_isolated_process(
27
+ snapshot,
28
+ resolve_argv(command.argv),
29
+ label=f"command {command.id}",
30
+ timeout_seconds=command.timeout_seconds,
31
+ )
32
+ if proc.returncode != 0:
33
+ detail = proc.stderr.strip() or proc.stdout.strip()
34
+ raise ExtractionError(f"command {command.id} exited {proc.returncode}: {detail[:500]}")
35
+ try:
36
+ payload = json.loads(proc.stdout)
37
+ except json.JSONDecodeError as exc:
38
+ raise ExtractionError(f"command {command.id} did not return JSON: {exc}") from exc
39
+ value = _select(payload, json_path)
40
+ normalized = json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
41
+ return EvidenceValue(
42
+ kind="scalar",
43
+ value=value,
44
+ provenance=Provenance(
45
+ ref=snapshot.label,
46
+ path="<command>",
47
+ locator=command.id,
48
+ extractor="command@1",
49
+ digest=hashlib.sha256(normalized.encode()).hexdigest(),
50
+ ),
51
+ )
@@ -0,0 +1,176 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ from pathlib import Path
6
+
7
+ from clean_docs.inventory import InventoryItem, scan_inventory
8
+ from clean_docs.models import EvidenceValue, Provenance, RegionBinding
9
+ from clean_docs.snapshot import RepositorySnapshot
10
+
11
+
12
+ INCLUDED_KINDS = {
13
+ "api-endpoint",
14
+ "api-symbol",
15
+ "cli-command",
16
+ "cli-option",
17
+ "mcp-tool",
18
+ "package",
19
+ "package-script",
20
+ "runtime-constraint",
21
+ "schema",
22
+ "test-runner",
23
+ "test-suite",
24
+ }
25
+
26
+
27
+ def _inventory_rows_from_items(
28
+ items: tuple[InventoryItem, ...],
29
+ ) -> list[dict[str, str]]:
30
+ rows = [
31
+ {
32
+ "kind": item.kind,
33
+ "name": item.name,
34
+ "source": item.source,
35
+ "locator": item.locator,
36
+ "adapter": item.adapter,
37
+ "digest": item.digest,
38
+ }
39
+ for item in items
40
+ if item.kind in INCLUDED_KINDS and not item.adapter.startswith("plugin:")
41
+ ]
42
+ return sorted(
43
+ rows,
44
+ key=lambda item: (
45
+ item["kind"],
46
+ item["name"],
47
+ item["source"],
48
+ item["locator"],
49
+ item["adapter"],
50
+ item["digest"],
51
+ ),
52
+ )
53
+
54
+
55
+ def _inventory_rows(root: Path) -> list[dict[str, str]]:
56
+ return _inventory_rows_from_items(scan_inventory(root).items)
57
+
58
+
59
+ def extract_repository_inventory(
60
+ snapshot: RepositorySnapshot, binding: RegionBinding
61
+ ) -> EvidenceValue:
62
+ with snapshot.materialized_root() as root:
63
+ inventory_rows = _inventory_rows(root)
64
+ rows = [
65
+ {
66
+ "kind": item["kind"],
67
+ "name": item["name"],
68
+ "source": item["source"],
69
+ "locator": item["locator"],
70
+ }
71
+ for item in inventory_rows
72
+ ]
73
+ normalized = json.dumps(rows, sort_keys=True, separators=(",", ":"))
74
+ return EvidenceValue(
75
+ kind="table",
76
+ value=rows,
77
+ provenance=Provenance(
78
+ ref=snapshot.label,
79
+ path=".",
80
+ locator="public-surface",
81
+ extractor="repository-inventory@1",
82
+ digest=hashlib.sha256(normalized.encode("utf-8")).hexdigest(),
83
+ ),
84
+ )
85
+
86
+
87
+ def _inline(value: str) -> str:
88
+ return (
89
+ value.replace("\\", "\\\\")
90
+ .replace("|", "\\|")
91
+ .replace("`", "'")
92
+ .replace("\n", " ")
93
+ )
94
+
95
+
96
+ def _extract_repository_overview(
97
+ snapshot: RepositorySnapshot,
98
+ *,
99
+ include_item_digests: bool,
100
+ extractor: str,
101
+ inventory_items: tuple[InventoryItem, ...] | None = None,
102
+ ) -> EvidenceValue:
103
+ if inventory_items is None:
104
+ with snapshot.materialized_root() as root:
105
+ inventory_rows = _inventory_rows(root)
106
+ else:
107
+ inventory_rows = _inventory_rows_from_items(inventory_items)
108
+ by_kind: dict[str, list[str]] = {}
109
+ for item in inventory_rows:
110
+ by_kind.setdefault(item["kind"], []).append(item["name"])
111
+ lines = [
112
+ "| surface | discovered | examples |",
113
+ "| --- | ---: | --- |",
114
+ ]
115
+ for kind in sorted(by_kind):
116
+ names = sorted(set(by_kind[kind]))
117
+ examples = ", ".join(f"`{_inline(name)}`" for name in names[:3])
118
+ if len(by_kind[kind]) > 3:
119
+ examples += f", and {len(by_kind[kind]) - 3} more"
120
+ lines.append(f"| {_inline(kind)} | {len(by_kind[kind])} | {examples} |")
121
+ receipt_rows = (
122
+ inventory_rows
123
+ if include_item_digests
124
+ else [
125
+ {
126
+ "kind": item["kind"],
127
+ "name": item["name"],
128
+ "source": item["source"],
129
+ "locator": item["locator"],
130
+ "adapter": item["adapter"],
131
+ }
132
+ for item in inventory_rows
133
+ ]
134
+ )
135
+ normalized = json.dumps(receipt_rows, sort_keys=True, separators=(",", ":"))
136
+ digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest()
137
+ lines.extend(("", f"<!-- sourcebound:inventory-sha256 {digest} -->"))
138
+ return EvidenceValue(
139
+ kind="markdown",
140
+ value="\n".join(lines),
141
+ provenance=Provenance(
142
+ ref=snapshot.label,
143
+ path=".",
144
+ locator="public-surface-overview",
145
+ extractor=extractor,
146
+ digest=digest,
147
+ ),
148
+ )
149
+
150
+
151
+ def extract_repository_overview(
152
+ snapshot: RepositorySnapshot,
153
+ binding: RegionBinding,
154
+ *,
155
+ inventory_items: tuple[InventoryItem, ...] | None = None,
156
+ ) -> EvidenceValue:
157
+ return _extract_repository_overview(
158
+ snapshot,
159
+ include_item_digests=False,
160
+ extractor="repository-overview@2",
161
+ inventory_items=inventory_items,
162
+ )
163
+
164
+
165
+ def _extract_repository_overview_legacy(
166
+ snapshot: RepositorySnapshot,
167
+ binding: RegionBinding,
168
+ *,
169
+ inventory_items: tuple[InventoryItem, ...] | None = None,
170
+ ) -> EvidenceValue:
171
+ return _extract_repository_overview(
172
+ snapshot,
173
+ include_item_digests=True,
174
+ extractor="repository-overview@1",
175
+ inventory_items=inventory_items,
176
+ )
@@ -0,0 +1,84 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ from typing import Any
6
+
7
+ from clean_docs.errors import ExtractionError
8
+ from clean_docs.models import EvidenceValue, Provenance, RegionBinding
9
+ from clean_docs.snapshot import RepositorySnapshot
10
+
11
+
12
+ def _decode(token: str) -> str:
13
+ decoded = []
14
+ index = 0
15
+ while index < len(token):
16
+ if token[index] != "~":
17
+ decoded.append(token[index])
18
+ index += 1
19
+ continue
20
+ if index + 1 >= len(token) or token[index + 1] not in {"0", "1"}:
21
+ raise ExtractionError(f"invalid JSON Pointer token: {token!r}")
22
+ decoded.append("~" if token[index + 1] == "0" else "/")
23
+ index += 2
24
+ return "".join(decoded)
25
+
26
+
27
+ def _resolve(value: Any, pointer: str) -> Any:
28
+ current = value
29
+ for raw_token in pointer.removeprefix("/").split("/"):
30
+ token = _decode(raw_token)
31
+ if isinstance(current, dict):
32
+ if token not in current:
33
+ raise ExtractionError(f"JSON Pointer {pointer!r} does not resolve")
34
+ current = current[token]
35
+ elif isinstance(current, list):
36
+ if not token.isdigit() or (len(token) > 1 and token.startswith("0")):
37
+ raise ExtractionError(f"JSON Pointer {pointer!r} does not resolve")
38
+ try:
39
+ index = int(token)
40
+ current = current[index]
41
+ except (ValueError, IndexError) as exc:
42
+ raise ExtractionError(f"JSON Pointer {pointer!r} does not resolve") from exc
43
+ else:
44
+ raise ExtractionError(f"JSON Pointer {pointer!r} traverses a scalar value")
45
+ return current
46
+
47
+
48
+ def _rows(value: Any) -> list[dict[str, Any]]:
49
+ if isinstance(value, list) and all(isinstance(item, dict) for item in value):
50
+ return value
51
+ if isinstance(value, dict) and all(isinstance(item, dict) for item in value.values()):
52
+ rows = []
53
+ for key, item in value.items():
54
+ row = dict(item)
55
+ row.setdefault("key", key)
56
+ rows.append(row)
57
+ return rows
58
+ raise ExtractionError("markdown-table evidence must be a list or mapping of records")
59
+
60
+
61
+ def extract_json_pointer(
62
+ snapshot: RepositorySnapshot, binding: RegionBinding
63
+ ) -> EvidenceValue:
64
+ if binding.source.pointer is None:
65
+ raise ExtractionError("json source requires a pointer")
66
+ text = snapshot.read_text(binding.source.path)
67
+ try:
68
+ document = json.loads(text)
69
+ except json.JSONDecodeError as exc:
70
+ raise ExtractionError(f"cannot parse {binding.source.path}: {exc}") from exc
71
+ value = _rows(_resolve(document, binding.source.pointer))
72
+ normalized = json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
73
+ digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest()
74
+ return EvidenceValue(
75
+ kind="table",
76
+ value=value,
77
+ provenance=Provenance(
78
+ ref=snapshot.label,
79
+ path=binding.source.path.as_posix(),
80
+ locator=binding.source.pointer,
81
+ extractor="json@1",
82
+ digest=digest,
83
+ ),
84
+ )
@@ -0,0 +1,104 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ import hashlib
5
+ import json
6
+ from typing import Any
7
+
8
+ from clean_docs.errors import ExtractionError
9
+ from clean_docs.models import EvidenceValue, Provenance, RegionBinding
10
+ from clean_docs.snapshot import RepositorySnapshot
11
+
12
+
13
+ def _evaluate(node: ast.AST) -> Any:
14
+ if isinstance(node, ast.Constant):
15
+ return node.value
16
+ if isinstance(node, ast.Dict):
17
+ keys = []
18
+ for key in node.keys:
19
+ if key is None:
20
+ raise ExtractionError("python-literal mappings cannot contain unpacking")
21
+ keys.append(key)
22
+ return {_evaluate(key): _evaluate(value) for key, value in zip(keys, node.values)}
23
+ if isinstance(node, (ast.List, ast.Tuple, ast.Set)):
24
+ return [_evaluate(item) for item in node.elts]
25
+ if isinstance(node, ast.Call):
26
+ if node.args:
27
+ raise ExtractionError("python-literal constructor calls may use keyword arguments only")
28
+ if any(keyword.arg is None for keyword in node.keywords):
29
+ raise ExtractionError("python-literal constructor calls cannot contain unpacking")
30
+ return {keyword.arg: _evaluate(keyword.value) for keyword in node.keywords}
31
+ if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
32
+ left, right = _evaluate(node.left), _evaluate(node.right)
33
+ if isinstance(left, str) and isinstance(right, str):
34
+ return left + right
35
+ if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.USub, ast.UAdd)):
36
+ value = _evaluate(node.operand)
37
+ if isinstance(value, (int, float)):
38
+ return -value if isinstance(node.op, ast.USub) else value
39
+ raise ExtractionError(f"unsupported Python syntax in bound literal: {type(node).__name__}")
40
+
41
+
42
+ def _find_assignment(tree: ast.Module, symbol: str) -> ast.AST:
43
+ for node in tree.body:
44
+ if isinstance(node, ast.Assign):
45
+ if any(isinstance(target, ast.Name) and target.id == symbol for target in node.targets):
46
+ return node.value
47
+ if isinstance(node, ast.AnnAssign):
48
+ if isinstance(node.target, ast.Name) and node.target.id == symbol and node.value:
49
+ return node.value
50
+ raise ExtractionError(f"Python symbol not found: {symbol}")
51
+
52
+
53
+ def _rows(value: Any) -> list[dict[str, Any]]:
54
+ if isinstance(value, list) and all(isinstance(item, dict) for item in value):
55
+ return value
56
+ if isinstance(value, dict) and all(isinstance(item, dict) for item in value.values()):
57
+ rows = []
58
+ for key, item in value.items():
59
+ row = dict(item)
60
+ row.setdefault("key", key)
61
+ rows.append(row)
62
+ return rows
63
+ raise ExtractionError("markdown-table evidence must be a list or mapping of records")
64
+
65
+
66
+ def extract_python_literal(
67
+ snapshot: RepositorySnapshot, binding: RegionBinding
68
+ ) -> EvidenceValue:
69
+ if binding.source.symbol is None:
70
+ raise ExtractionError("python-literal source requires a symbol")
71
+ text = snapshot.read_text(binding.source.path)
72
+ try:
73
+ tree = ast.parse(text, filename=binding.source.path.as_posix())
74
+ except SyntaxError as exc:
75
+ raise ExtractionError(f"cannot parse {binding.source.path}: {exc}") from exc
76
+ extracted = _evaluate(_find_assignment(tree, binding.source.symbol))
77
+ value: Any
78
+ kind: str
79
+ if binding.renderer == "markdown-table":
80
+ value = _rows(extracted)
81
+ kind = "table"
82
+ elif binding.renderer == "markdown-fragment" and isinstance(extracted, str):
83
+ value = extracted
84
+ kind = "markdown"
85
+ elif binding.renderer == "scalar" and not isinstance(extracted, (dict, list)):
86
+ value = extracted
87
+ kind = "scalar"
88
+ else:
89
+ raise ExtractionError(
90
+ f"{binding.renderer} cannot render the bound Python literal"
91
+ )
92
+ normalized = json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
93
+ digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest()
94
+ return EvidenceValue(
95
+ kind=kind,
96
+ value=value,
97
+ provenance=Provenance(
98
+ ref=snapshot.label,
99
+ path=binding.source.path.as_posix(),
100
+ locator=binding.source.symbol,
101
+ extractor="python-literal@1",
102
+ digest=digest,
103
+ ),
104
+ )