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/phrasing.py ADDED
@@ -0,0 +1,448 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import re
7
+ import tempfile
8
+ from dataclasses import asdict, dataclass
9
+ from pathlib import Path
10
+ from typing import Any, Protocol
11
+
12
+ import yaml
13
+
14
+ from clean_docs.errors import ConfigurationError
15
+ from clean_docs.execution import run_bounded_command
16
+ from clean_docs.inventory import InventoryItem
17
+ from clean_docs.regions import atomic_write
18
+ from clean_docs.standard import load_default_pack
19
+ from clean_docs.write_gate import SECRET_RULES, redact_secrets
20
+
21
+
22
+ INJECTION_RULES = (
23
+ re.compile(r"\bignore (?:all |any )?(?:previous|prior) instructions?\b", re.I),
24
+ re.compile(r"\b(?:reveal|disclose|print|return) (?:the )?(?:system prompt|secrets?)\b", re.I),
25
+ re.compile(r"\b(?:override|change|remove|modify) (?:the )?(?:system|required findings?|gate results?)\b", re.I),
26
+ )
27
+ SKIP_PARTS = {".git", ".venv", "node_modules", "docs/archive"}
28
+ MAX_CONTEXT_BYTES = 32_000
29
+ MAX_FILE_BYTES = 8_000
30
+ MAX_PROVIDER_OUTPUT_BYTES = 1_000_000
31
+ MAX_PROVIDER_INPUT_BYTES = 1_000_000
32
+ DEFAULT_PROVIDER_TIMEOUT_SECONDS = 120
33
+ MAX_PROVIDER_TIMEOUT_SECONDS = 3600
34
+ MODEL_KEYS = {"adapter", "name", "response", "argv", "timeout_seconds", "env"}
35
+ TEMPLATE_KINDS = {
36
+ "exposes": {
37
+ "api-endpoint", "api-symbol", "cli-command", "cli-option", "mcp-tool", "schema",
38
+ },
39
+ "provides": {
40
+ "api-endpoint", "api-symbol", "cli-command", "cli-option", "mcp-tool", "package",
41
+ "package-script", "schema", "test-runner", "test-suite",
42
+ },
43
+ "tests": {"test-runner", "test-suite"},
44
+ }
45
+
46
+
47
+ class PhrasingProvider(Protocol):
48
+ name: str
49
+
50
+ def complete(self, prompt: str) -> str: ...
51
+
52
+
53
+ @dataclass
54
+ class MockProvider:
55
+ response: str
56
+ name: str = "mock"
57
+ last_prompt: str = ""
58
+
59
+ def complete(self, prompt: str) -> str:
60
+ self.last_prompt = prompt
61
+ return self.response
62
+
63
+
64
+ @dataclass
65
+ class RecordedProvider(MockProvider):
66
+ name: str = "recorded"
67
+
68
+
69
+ @dataclass
70
+ class CommandPhrasingProvider:
71
+ """Run an explicitly configured init proposer without granting it write authority."""
72
+
73
+ argv: tuple[str, ...]
74
+ name: str
75
+ root: Path
76
+ timeout_seconds: int = DEFAULT_PROVIDER_TIMEOUT_SECONDS
77
+ env_names: tuple[str, ...] = ()
78
+ last_prompt: str | None = None
79
+ last_response: str | None = None
80
+ last_duration_seconds: float | None = None
81
+ last_prompt_bytes: int | None = None
82
+ last_response_bytes: int | None = None
83
+ last_error: str | None = None
84
+
85
+ @property
86
+ def configuration_sha256(self) -> str:
87
+ return hashlib.sha256(
88
+ json.dumps(
89
+ {
90
+ "argv": self.argv,
91
+ "timeout_seconds": self.timeout_seconds,
92
+ "env": self.env_names,
93
+ },
94
+ sort_keys=True,
95
+ separators=(",", ":"),
96
+ ).encode()
97
+ ).hexdigest()
98
+
99
+ def complete(self, prompt: str) -> str:
100
+ self.last_prompt = prompt
101
+ request = prompt.encode()
102
+ environment = {"NO_COLOR": "1", "PATH": os.defpath}
103
+ for name in self.env_names:
104
+ value = os.environ.get(name)
105
+ if value is None:
106
+ raise ConfigurationError(f"init proposer environment variable is unset: {name}")
107
+ environment[name] = value
108
+ try:
109
+ result = run_bounded_command(
110
+ self.argv,
111
+ environment=environment,
112
+ input_bytes=request,
113
+ timeout_seconds=self.timeout_seconds,
114
+ max_input_bytes=MAX_PROVIDER_INPUT_BYTES,
115
+ max_output_bytes=MAX_PROVIDER_OUTPUT_BYTES,
116
+ prefix="sourcebound-init-proposer",
117
+ )
118
+ except ConfigurationError as exc:
119
+ self.last_error = str(exc)
120
+ raise
121
+ try:
122
+ response = result.stdout.decode("utf-8")
123
+ except UnicodeDecodeError as exc:
124
+ self.last_error = "init proposer returned non-UTF-8 output"
125
+ raise ConfigurationError(self.last_error) from exc
126
+ self.last_response = response
127
+ self.last_duration_seconds = result.duration_seconds
128
+ self.last_prompt_bytes = len(request)
129
+ self.last_response_bytes = len(result.stdout)
130
+ return response
131
+
132
+
133
+ def load_command_phrasing_provider(path: Path, root: Path) -> CommandPhrasingProvider:
134
+ try:
135
+ raw = yaml.safe_load(path.read_text(encoding="utf-8"))
136
+ except (OSError, yaml.YAMLError) as exc:
137
+ raise ConfigurationError(f"cannot read init proposer config {path}: {exc}") from exc
138
+ if not isinstance(raw, dict):
139
+ raise ConfigurationError("init proposer config must be a mapping")
140
+ unknown = sorted(set(raw) - MODEL_KEYS)
141
+ if unknown:
142
+ raise ConfigurationError(
143
+ "init proposer config has unknown key(s): " + ", ".join(unknown)
144
+ )
145
+ if raw.get("adapter") != "command":
146
+ raise ConfigurationError("init proposer config.adapter must be command")
147
+ name = raw.get("name")
148
+ if not isinstance(name, str) or not name:
149
+ raise ConfigurationError("init proposer config.name must be non-empty")
150
+ if "response" in raw:
151
+ raise ConfigurationError(
152
+ "init proposer config.response is only valid for recorded adapters"
153
+ )
154
+ argv = raw.get("argv")
155
+ if (
156
+ not isinstance(argv, list)
157
+ or not argv
158
+ or not all(isinstance(value, str) and value for value in argv)
159
+ ):
160
+ raise ConfigurationError("init proposer config.argv must be a non-empty string list")
161
+ timeout_seconds = raw.get("timeout_seconds", DEFAULT_PROVIDER_TIMEOUT_SECONDS)
162
+ if (
163
+ not isinstance(timeout_seconds, int)
164
+ or isinstance(timeout_seconds, bool)
165
+ or not 1 <= timeout_seconds <= MAX_PROVIDER_TIMEOUT_SECONDS
166
+ ):
167
+ raise ConfigurationError(
168
+ "init proposer config.timeout_seconds must be an integer from 1 to "
169
+ f"{MAX_PROVIDER_TIMEOUT_SECONDS}"
170
+ )
171
+ env = raw.get("env", [])
172
+ if not isinstance(env, list) or not all(
173
+ isinstance(value, str) and value and value.isidentifier() for value in env
174
+ ) or len(set(env)) != len(env):
175
+ raise ConfigurationError("init proposer config.env must be unique environment variable names")
176
+ return CommandPhrasingProvider(tuple(argv), name, root, timeout_seconds, tuple(env))
177
+
178
+
179
+ def write_command_proposer_transcript(
180
+ root: Path,
181
+ path: Path,
182
+ provider: CommandPhrasingProvider,
183
+ *,
184
+ state: str,
185
+ outcome: str,
186
+ detail: str,
187
+ record: ModelRecord | None = None,
188
+ ) -> None:
189
+ target = validate_command_proposer_transcript_path(root, path)
190
+ response = provider.last_response
191
+ redacted_response = redact_secrets(response)[0] if response is not None else None
192
+ candidates: list[dict[str, object]] = []
193
+ if response is not None:
194
+ try:
195
+ raw = json.loads(response)
196
+ except json.JSONDecodeError:
197
+ raw = None
198
+ if isinstance(raw, dict) and isinstance(raw.get("drafts"), list):
199
+ candidates = [
200
+ {
201
+ "fact_id": item.get("fact_id") if isinstance(item, dict) else None,
202
+ "template": item.get("template") if isinstance(item, dict) else None,
203
+ "decision": "accepted" if outcome == "accept" else "rejected",
204
+ }
205
+ for item in raw["drafts"]
206
+ ]
207
+ payload: dict[str, Any] = {
208
+ "schema": "sourcebound.init-proposer-transcript.v1",
209
+ "state": state,
210
+ "outcome": outcome,
211
+ "detail": detail,
212
+ "provider": {
213
+ "name": provider.name,
214
+ "configuration_sha256": provider.configuration_sha256,
215
+ "timeout_seconds": provider.timeout_seconds,
216
+ "env_names": list(provider.env_names),
217
+ "granted_env_names": sorted({"PATH", "NO_COLOR", *provider.env_names}),
218
+ },
219
+ "cost": {
220
+ "prompt_bytes": provider.last_prompt_bytes,
221
+ "response_bytes": provider.last_response_bytes,
222
+ "duration_seconds": provider.last_duration_seconds,
223
+ },
224
+ "prompt": (
225
+ redact_secrets(provider.last_prompt)[0]
226
+ if provider.last_prompt is not None
227
+ else None
228
+ ),
229
+ "prompt_sha256": (
230
+ hashlib.sha256(provider.last_prompt.encode()).hexdigest()
231
+ if provider.last_prompt is not None
232
+ else None
233
+ ),
234
+ "response": redacted_response,
235
+ "response_sha256": (
236
+ hashlib.sha256(response.encode()).hexdigest() if response is not None else None
237
+ ),
238
+ "candidates": candidates,
239
+ "model_record": record.as_dict() if record is not None else None,
240
+ }
241
+ atomic_write(target, json.dumps(payload, indent=2, sort_keys=True) + "\n")
242
+
243
+
244
+ def validate_command_proposer_transcript_path(root: Path, path: Path) -> Path:
245
+ if path.is_absolute() or ".." in path.parts:
246
+ raise ConfigurationError("init proposer transcript must stay inside the repository")
247
+ target = (root / path).resolve()
248
+ if not target.is_relative_to(root.resolve()):
249
+ raise ConfigurationError("init proposer transcript must stay inside the repository")
250
+ return target
251
+
252
+
253
+ def prepare_command_proposer_transcript_path(root: Path, path: Path) -> Path:
254
+ """Prove the transcript target is writable without leaving repository state behind."""
255
+ target = validate_command_proposer_transcript_path(root, path)
256
+ if target.exists() and target.is_dir():
257
+ raise ConfigurationError("init proposer transcript path is a directory")
258
+ created: list[Path] = []
259
+ cursor = target.parent
260
+ missing: list[Path] = []
261
+ while not cursor.exists():
262
+ missing.append(cursor)
263
+ if cursor == root:
264
+ break
265
+ cursor = cursor.parent
266
+ try:
267
+ target.parent.mkdir(parents=True, exist_ok=True)
268
+ for parent in reversed(missing):
269
+ if parent.exists():
270
+ created.append(parent)
271
+ descriptor, temporary = tempfile.mkstemp(prefix=f".{target.name}.", dir=target.parent)
272
+ os.close(descriptor)
273
+ Path(temporary).unlink()
274
+ except OSError as exc:
275
+ raise ConfigurationError(f"cannot write init proposer transcript {path}: {exc}") from exc
276
+ finally:
277
+ for parent in sorted(created, key=lambda item: len(item.parts), reverse=True):
278
+ try:
279
+ parent.rmdir()
280
+ except OSError:
281
+ pass
282
+ return target
283
+
284
+
285
+ @dataclass(frozen=True)
286
+ class GroundedDraft:
287
+ fact_id: str
288
+ template: str
289
+ text: str
290
+
291
+
292
+ @dataclass(frozen=True)
293
+ class ModelRecord:
294
+ provider: str
295
+ prompt_sha256: str
296
+ response_sha256: str
297
+ context_flags: tuple[str, ...]
298
+ drafts: tuple[GroundedDraft, ...]
299
+
300
+ def as_dict(self) -> dict[str, object]:
301
+ return {
302
+ "provider": self.provider,
303
+ "prompt_sha256": self.prompt_sha256,
304
+ "response_sha256": self.response_sha256,
305
+ "context_flags": list(self.context_flags),
306
+ "drafts": [asdict(draft) for draft in self.drafts],
307
+ }
308
+
309
+
310
+ def _markdown_files(root: Path) -> list[Path]:
311
+ return sorted(
312
+ path
313
+ for path in root.rglob("*.md")
314
+ if path.is_file()
315
+ and not any(part in path.relative_to(root).as_posix() for part in SKIP_PARTS)
316
+ )
317
+
318
+
319
+ def _sanitize(path: str, text: str) -> tuple[str, list[str]]:
320
+ lines: list[str] = []
321
+ flags: list[str] = []
322
+ for line_number, original in enumerate(text.splitlines(), start=1):
323
+ line = original
324
+ injection = any(rule.search(original) for rule in INJECTION_RULES)
325
+ if injection:
326
+ flags.append(f"prompt-injection:{path}:{line_number}")
327
+ for rule_id, pattern, _applies in SECRET_RULES:
328
+ if pattern.search(original):
329
+ flags.append(f"{rule_id}:{path}:{line_number}")
330
+ line = pattern.sub("[REDACTED]", line)
331
+ lines.append("[BLOCKED UNTRUSTED INSTRUCTION]" if injection else line)
332
+ return "\n".join(lines), flags
333
+
334
+
335
+ def _context(root: Path) -> tuple[list[dict[str, str]], tuple[str, ...]]:
336
+ remaining = MAX_CONTEXT_BYTES
337
+ documents: list[dict[str, str]] = []
338
+ flags: list[str] = []
339
+ for path in _markdown_files(root):
340
+ if remaining <= 0:
341
+ break
342
+ relative = path.relative_to(root).as_posix()
343
+ try:
344
+ raw = path.read_bytes()[:MAX_FILE_BYTES]
345
+ text = raw.decode("utf-8")
346
+ except (OSError, UnicodeDecodeError):
347
+ continue
348
+ sanitized, found = _sanitize(relative, text)
349
+ encoded = sanitized.encode("utf-8")[:remaining]
350
+ documents.append({"path": relative, "content": encoded.decode("utf-8", "ignore")})
351
+ flags.extend(found)
352
+ remaining -= len(encoded)
353
+ return documents, tuple(sorted(set(flags)))
354
+
355
+
356
+ def _prompt(root: Path, facts: tuple[InventoryItem, ...]) -> tuple[str, tuple[str, ...]]:
357
+ pack = load_default_pack()
358
+ documents, context_flags = _context(root)
359
+ flags = list(context_flags)
360
+ safe_facts: list[dict[str, object]] = []
361
+ for index, fact in enumerate(facts):
362
+ record: dict[str, object] = {}
363
+ for field, value in asdict(fact).items():
364
+ if not isinstance(value, str):
365
+ record[field] = value
366
+ continue
367
+ sanitized, found = _sanitize(f"inventory:{index}:{field}", value)
368
+ record[field] = sanitized
369
+ flags.extend(found)
370
+ safe_facts.append(record)
371
+ payload = {
372
+ "schema": "sourcebound.phrasing-request.v1",
373
+ "task": "Select prose templates for known facts. Do not add facts or prose.",
374
+ "standard": {
375
+ "constraint": pack["generation"]["constraint"],
376
+ "checklist": pack["checklist"],
377
+ "voice": pack["generation"]["voice"],
378
+ "purpose_contract": pack["generation"]["purpose_contract"],
379
+ "precedence": pack["generation"]["precedence"],
380
+ "exemplars": pack["generation"]["exemplars"],
381
+ },
382
+ "allowed_templates": {
383
+ template: sorted(kinds) for template, kinds in sorted(TEMPLATE_KINDS.items())
384
+ },
385
+ "facts": safe_facts,
386
+ "repository_context": documents,
387
+ "response_schema": {
388
+ "drafts": [{"fact_id": "known fact id", "template": "allowed template"}],
389
+ },
390
+ }
391
+ return json.dumps(payload, sort_keys=True, separators=(",", ":")), tuple(sorted(set(flags)))
392
+
393
+
394
+ def _draft_text(fact: InventoryItem, template: str) -> str:
395
+ name = " ".join(fact.name.split()).replace("`", "'")
396
+ label = fact.kind.replace("-", " ")
397
+ if template == "tests":
398
+ return f"The repository tests `{name}` through a {label}."
399
+ return f"The repository {template} `{name}` as a {label}."
400
+
401
+
402
+ def _parse_response(response: str, facts: tuple[InventoryItem, ...]) -> tuple[GroundedDraft, ...]:
403
+ try:
404
+ raw = json.loads(response)
405
+ except json.JSONDecodeError as exc:
406
+ raise ConfigurationError("model response is not valid JSON") from exc
407
+ if not isinstance(raw, dict) or set(raw) != {"drafts"} or not isinstance(raw["drafts"], list):
408
+ raise ConfigurationError("model response does not match the grounded draft schema")
409
+ if len(raw["drafts"]) > 5:
410
+ raise ConfigurationError("model response exceeds the grounded draft limit")
411
+ by_id = {fact.id: fact for fact in facts}
412
+ drafts: list[GroundedDraft] = []
413
+ seen: set[str] = set()
414
+ for candidate in raw["drafts"]:
415
+ if not isinstance(candidate, dict) or set(candidate) != {"fact_id", "template"}:
416
+ raise ConfigurationError("model response contains an invalid grounded draft")
417
+ fact_id = candidate.get("fact_id")
418
+ template = candidate.get("template")
419
+ if not isinstance(fact_id, str) or fact_id not in by_id or fact_id in seen:
420
+ raise ConfigurationError("model response references an unsupported or duplicate fact")
421
+ fact = by_id[fact_id]
422
+ if not isinstance(template, str) or fact.kind not in TEMPLATE_KINDS.get(template, set()):
423
+ raise ConfigurationError("model response maps a fact to an unsupported template")
424
+ seen.add(fact_id)
425
+ drafts.append(GroundedDraft(fact_id, template, _draft_text(fact, template)))
426
+ return tuple(drafts)
427
+
428
+
429
+ def build_model_record(
430
+ root: Path,
431
+ facts: tuple[InventoryItem, ...],
432
+ provider: PhrasingProvider,
433
+ ) -> ModelRecord:
434
+ prompt, flags = _prompt(root.resolve(), facts)
435
+ try:
436
+ response = provider.complete(prompt)
437
+ except Exception as exc:
438
+ raise ConfigurationError("phrasing provider failed before any repository write") from exc
439
+ if not isinstance(response, str):
440
+ raise ConfigurationError("phrasing provider returned a non-text response")
441
+ drafts = _parse_response(response, facts)
442
+ return ModelRecord(
443
+ provider=provider.name,
444
+ prompt_sha256=hashlib.sha256(prompt.encode()).hexdigest(),
445
+ response_sha256=hashlib.sha256(response.encode()).hexdigest(),
446
+ context_flags=flags,
447
+ drafts=drafts,
448
+ )
clean_docs/plugins.py ADDED
@@ -0,0 +1,249 @@
1
+ """Run versioned extension commands against disposable repository snapshots."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from clean_docs.errors import ConfigurationError, ExtractionError
12
+ from clean_docs.execution import resolve_argv
13
+ from clean_docs.inventory import InventoryItem, InventoryReport, scan_inventory
14
+ from clean_docs.isolation import MAX_PROCESS_IO_BYTES, run_isolated_process
15
+ from clean_docs.manifest import load_manifest
16
+ from clean_docs.models import (
17
+ PLUGIN_API_VERSION,
18
+ EvidenceValue,
19
+ PluginSpec,
20
+ Provenance,
21
+ RegionBinding,
22
+ )
23
+ from clean_docs.policy import PolicyFinding
24
+ from clean_docs.snapshot import RepositorySnapshot
25
+
26
+
27
+ MAX_PLUGIN_OUTPUT_BYTES = MAX_PROCESS_IO_BYTES
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class PluginResponse:
32
+ plugin: str
33
+ operation: str
34
+ result: dict[str, Any]
35
+
36
+
37
+ def _run_plugin(
38
+ snapshot: RepositorySnapshot,
39
+ plugin: PluginSpec,
40
+ operation: str,
41
+ payload: dict[str, Any],
42
+ ) -> PluginResponse:
43
+ if operation not in plugin.interfaces:
44
+ raise ConfigurationError(
45
+ f"plugin {plugin.id} does not implement the {operation} interface"
46
+ )
47
+ request = json.dumps(
48
+ {
49
+ "schema": "sourcebound.plugin-request.v1",
50
+ "api_version": PLUGIN_API_VERSION,
51
+ "operation": operation,
52
+ "snapshot": snapshot.label,
53
+ "payload": payload,
54
+ },
55
+ sort_keys=True,
56
+ separators=(",", ":"),
57
+ )
58
+ proc = run_isolated_process(
59
+ snapshot,
60
+ resolve_argv(plugin.argv),
61
+ label=f"plugin {plugin.id}",
62
+ timeout_seconds=plugin.timeout_seconds,
63
+ input_text=request,
64
+ )
65
+ if proc.returncode != 0:
66
+ detail = proc.stderr.strip() or proc.stdout.strip() or "no output"
67
+ raise ExtractionError(f"plugin {plugin.id} exited {proc.returncode}: {detail[:500]}")
68
+ try:
69
+ raw = json.loads(proc.stdout)
70
+ except json.JSONDecodeError as exc:
71
+ raise ExtractionError(f"plugin {plugin.id} returned invalid JSON: {exc}") from exc
72
+ if (
73
+ not isinstance(raw, dict)
74
+ or set(raw) != {"schema", "api_version", "result"}
75
+ or raw.get("schema") != "sourcebound.plugin-response.v1"
76
+ or raw.get("api_version") != PLUGIN_API_VERSION
77
+ or not isinstance(raw.get("result"), dict)
78
+ ):
79
+ raise ExtractionError(
80
+ f"plugin {plugin.id} must return sourcebound.plugin-response.v1 at API version 1"
81
+ )
82
+ return PluginResponse(plugin.id, operation, raw["result"])
83
+
84
+
85
+ def _digest(value: Any) -> str:
86
+ normalized = json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
87
+ return hashlib.sha256(normalized.encode()).hexdigest()
88
+
89
+
90
+ def extract_plugin(
91
+ snapshot: RepositorySnapshot, binding: RegionBinding, plugin: PluginSpec
92
+ ) -> EvidenceValue:
93
+ response = _run_plugin(
94
+ snapshot,
95
+ plugin,
96
+ "extractor",
97
+ {
98
+ "binding": binding.id,
99
+ "source": binding.source.path.as_posix(),
100
+ "renderer": binding.renderer,
101
+ "columns": list(binding.columns),
102
+ },
103
+ )
104
+ if set(response.result) != {"kind", "value"}:
105
+ raise ExtractionError(f"plugin {plugin.id} extractor result must contain kind and value")
106
+ kind = response.result["kind"]
107
+ if kind not in {"list", "mapping", "scalar", "table", "text"}:
108
+ raise ExtractionError(f"plugin {plugin.id} returned unsupported evidence kind: {kind}")
109
+ value = response.result["value"]
110
+ return EvidenceValue(
111
+ kind,
112
+ value,
113
+ Provenance(
114
+ snapshot.label,
115
+ binding.source.path.as_posix(),
116
+ binding.id,
117
+ f"plugin:{plugin.id}@{plugin.api_version}",
118
+ _digest(value),
119
+ ),
120
+ )
121
+
122
+
123
+ def render_plugin(
124
+ snapshot: RepositorySnapshot,
125
+ binding: RegionBinding,
126
+ plugin: PluginSpec,
127
+ evidence: EvidenceValue,
128
+ ) -> str:
129
+ response = _run_plugin(
130
+ snapshot,
131
+ plugin,
132
+ "renderer",
133
+ {
134
+ "binding": binding.id,
135
+ "kind": evidence.kind,
136
+ "value": evidence.value,
137
+ },
138
+ )
139
+ if set(response.result) != {"content"} or not isinstance(
140
+ response.result["content"], str
141
+ ):
142
+ raise ExtractionError(f"plugin {plugin.id} renderer result must contain text content")
143
+ return response.result["content"]
144
+
145
+
146
+ def check_plugin_policies(
147
+ snapshot: RepositorySnapshot,
148
+ plugins: tuple[PluginSpec, ...],
149
+ documents: dict[str, str],
150
+ ) -> list[PolicyFinding]:
151
+ findings: list[PolicyFinding] = []
152
+ for plugin in plugins:
153
+ if "policy" not in plugin.interfaces:
154
+ continue
155
+ response = _run_plugin(
156
+ snapshot,
157
+ plugin,
158
+ "policy",
159
+ {"documents": documents},
160
+ )
161
+ raw_findings = response.result.get("findings")
162
+ if set(response.result) != {"findings"} or not isinstance(raw_findings, list):
163
+ raise ExtractionError(f"plugin {plugin.id} policy result must contain a findings list")
164
+ for index, raw in enumerate(raw_findings):
165
+ if not isinstance(raw, dict) or set(raw) != {"doc", "line", "rule", "detail"}:
166
+ raise ExtractionError(
167
+ f"plugin {plugin.id} policy finding {index} must contain doc, line, rule, and detail"
168
+ )
169
+ if (
170
+ not isinstance(raw["doc"], str)
171
+ or not isinstance(raw["line"], int)
172
+ or raw["line"] < 1
173
+ or not isinstance(raw["rule"], str)
174
+ or not isinstance(raw["detail"], str)
175
+ ):
176
+ raise ExtractionError(f"plugin {plugin.id} policy finding {index} is invalid")
177
+ findings.append(
178
+ PolicyFinding(raw["doc"], raw["line"], raw["rule"], raw["detail"])
179
+ )
180
+ return findings
181
+
182
+
183
+ def discover_plugin_items(
184
+ snapshot: RepositorySnapshot, plugins: tuple[PluginSpec, ...]
185
+ ) -> tuple[InventoryItem, ...]:
186
+ items: list[InventoryItem] = []
187
+ identifiers: set[str] = set()
188
+ for plugin in plugins:
189
+ if "discoverer" not in plugin.interfaces:
190
+ continue
191
+ response = _run_plugin(snapshot, plugin, "discoverer", {})
192
+ raw_items = response.result.get("items")
193
+ if set(response.result) != {"items"} or not isinstance(raw_items, list):
194
+ raise ExtractionError(f"plugin {plugin.id} discoverer result must contain an items list")
195
+ for index, raw in enumerate(raw_items):
196
+ if not isinstance(raw, dict) or set(raw) != {
197
+ "kind", "name", "source", "locator", "evidence"
198
+ }:
199
+ raise ExtractionError(
200
+ f"plugin {plugin.id} item {index} must contain kind, name, source, locator, and evidence"
201
+ )
202
+ if not all(
203
+ isinstance(raw[key], str) and raw[key]
204
+ for key in ("kind", "name", "source", "locator")
205
+ ):
206
+ raise ExtractionError(f"plugin {plugin.id} item {index} has invalid string fields")
207
+ source = Path(raw["source"])
208
+ if source.is_absolute() or ".." in source.parts:
209
+ raise ExtractionError(f"plugin {plugin.id} item {index} source escapes the repository")
210
+ identifier = f"{raw['kind']}:{raw['source']}:{raw['locator']}"
211
+ if identifier in identifiers:
212
+ raise ExtractionError(
213
+ f"plugin {plugin.id} item {index} duplicates inventory id {identifier}"
214
+ )
215
+ identifiers.add(identifier)
216
+ items.append(
217
+ InventoryItem(
218
+ identifier,
219
+ raw["kind"],
220
+ raw["name"],
221
+ raw["source"],
222
+ raw["locator"],
223
+ f"plugin:{plugin.id}",
224
+ _digest(raw["evidence"]),
225
+ "standard-gap",
226
+ )
227
+ )
228
+ return tuple(sorted(items, key=lambda item: item.id))
229
+
230
+
231
+ def merge_plugin_inventory(
232
+ base: tuple[InventoryItem, ...], additions: tuple[InventoryItem, ...]
233
+ ) -> tuple[InventoryItem, ...]:
234
+ merged = {item.id: item for item in base}
235
+ for item in additions:
236
+ if item.id in merged:
237
+ raise ExtractionError(f"plugin inventory id collides with core evidence: {item.id}")
238
+ merged[item.id] = item
239
+ return tuple(merged[key] for key in sorted(merged))
240
+
241
+
242
+ def scan_extended_inventory(root: Path) -> InventoryReport:
243
+ base = scan_inventory(root)
244
+ manifest_path = root / ".sourcebound.yml"
245
+ if not manifest_path.is_file():
246
+ return base
247
+ manifest = load_manifest(manifest_path)
248
+ additions = discover_plugin_items(RepositorySnapshot(root), manifest.plugins)
249
+ return InventoryReport(base.languages, merge_plugin_inventory(base.items, additions))