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