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,474 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ import hashlib
5
+ import json
6
+ import re
7
+ import subprocess
8
+ import sys
9
+ from dataclasses import asdict, dataclass
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import yaml
14
+
15
+ from clean_docs.errors import ConfigurationError
16
+
17
+ if sys.version_info >= (3, 11):
18
+ import tomllib
19
+ else:
20
+ import tomli as tomllib
21
+
22
+
23
+ SKIP_PARTS = {".git", ".venv", "build", "dist", "node_modules", "__pycache__"}
24
+ SKIP_PATHS = {".sourcebound.yml", ".sourcebound-residue.yml", "llms.txt"}
25
+ LANGUAGES = {
26
+ ".py": "Python",
27
+ ".ts": "TypeScript",
28
+ ".tsx": "TypeScript",
29
+ ".js": "JavaScript",
30
+ ".jsx": "JavaScript",
31
+ ".go": "Go",
32
+ ".rs": "Rust",
33
+ }
34
+ LINK = re.compile(r"(?<!!)\[[^\]]+\]\(([^)]+)\)")
35
+ TS_EXPORT = re.compile(
36
+ r"^\s*export\s+(?:default\s+)?(?:async\s+)?(?:abstract\s+)?"
37
+ r"(?:function|class|const|interface|type|enum)\s+([A-Za-z_$][\w$]*)",
38
+ re.M,
39
+ )
40
+ TS_CLI_COMMAND = re.compile(r"\.command\(\s*['\"]([^'\"]+)['\"]")
41
+ TS_CLI_OPTION = re.compile(r"\.option\(\s*['\"]([^'\"]+)['\"]")
42
+ TS_MCP_TOOL = re.compile(r"\.(?:tool|registerTool)\(\s*['\"]([^'\"]+)['\"]")
43
+ HTTP_METHODS = {"get", "put", "post", "delete", "patch", "head", "options", "trace"}
44
+ PYTHON_TOOLING_MODULES = {"conftest.py", "noxfile.py", "setup.py"}
45
+ PUBLIC_SURFACE_KINDS = frozenset(
46
+ {
47
+ "api-endpoint",
48
+ "api-symbol",
49
+ "cli-command",
50
+ "cli-option",
51
+ "config-key",
52
+ "mcp-tool",
53
+ "package",
54
+ "package-script",
55
+ "runtime-constraint",
56
+ "schema",
57
+ }
58
+ )
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class InventoryItem:
63
+ id: str
64
+ kind: str
65
+ name: str
66
+ source: str
67
+ locator: str
68
+ adapter: str
69
+ digest: str
70
+ coverage: str
71
+ coverage_reason: str | None = None
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class InventoryReport:
76
+ languages: tuple[str, ...]
77
+ items: tuple[InventoryItem, ...]
78
+
79
+ def as_dict(self) -> dict[str, object]:
80
+ return {
81
+ "schema": "sourcebound.inventory.v1",
82
+ "languages": list(self.languages),
83
+ "items": [asdict(item) for item in self.items],
84
+ "counts": {
85
+ state: sum(item.coverage == state for item in self.items)
86
+ for state in ("bound", "cataloged", "ignored", "standard-gap")
87
+ },
88
+ }
89
+
90
+
91
+ def _repository_files(root: Path) -> list[Path]:
92
+ proc = subprocess.run(
93
+ ["git", "-C", str(root), "ls-files", "--cached", "--others", "--exclude-standard", "-z"],
94
+ capture_output=True,
95
+ timeout=30,
96
+ check=False,
97
+ )
98
+ if proc.returncode == 0:
99
+ candidates = [root / item for item in proc.stdout.decode().split("\0") if item]
100
+ else:
101
+ candidates = list(root.rglob("*"))
102
+ return sorted(
103
+ path
104
+ for path in candidates
105
+ if path.is_file()
106
+ and not set(path.relative_to(root).parts) & SKIP_PARTS
107
+ and "docs/archive" not in path.relative_to(root).as_posix()
108
+ and path.relative_to(root).as_posix() not in SKIP_PATHS
109
+ )
110
+
111
+
112
+ def _digest(value: str) -> str:
113
+ return hashlib.sha256(value.encode("utf-8")).hexdigest()
114
+
115
+
116
+ def _identifier(kind: str, source: str, locator: str) -> str:
117
+ return f"{kind}:{source}:{locator}"
118
+
119
+
120
+ def _item(
121
+ kind: str,
122
+ name: str,
123
+ source: str,
124
+ locator: str,
125
+ adapter: str,
126
+ evidence: str,
127
+ ) -> dict[str, str]:
128
+ return {
129
+ "id": _identifier(kind, source, locator),
130
+ "kind": kind,
131
+ "name": name,
132
+ "source": source,
133
+ "locator": locator,
134
+ "adapter": adapter,
135
+ "digest": _digest(evidence),
136
+ }
137
+
138
+
139
+ def _python_evidence(text: str, node: ast.AST) -> str:
140
+ """Return source-bound evidence that is stable across CPython AST versions."""
141
+ parts = []
142
+ decorators = getattr(node, "decorator_list", ())
143
+ for decorator in decorators:
144
+ segment = ast.get_source_segment(text, decorator)
145
+ if segment is not None:
146
+ parts.append(segment)
147
+ segment = ast.get_source_segment(text, node)
148
+ if segment is not None:
149
+ parts.append(segment)
150
+ return "\n".join(parts)
151
+
152
+
153
+ def _python_items(path: str, text: str) -> list[dict[str, str]]:
154
+ try:
155
+ tree = ast.parse(text, filename=path)
156
+ except SyntaxError:
157
+ return []
158
+ items: list[dict[str, str]] = []
159
+ is_test = Path(path).name.startswith("test_") or "/tests/" in f"/{path}"
160
+ if not is_test and Path(path).name not in PYTHON_TOOLING_MODULES:
161
+ for node in tree.body:
162
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) and not node.name.startswith("_"):
163
+ items.append(_item("api-symbol", node.name, path, node.name, "python-ast", _python_evidence(text, node)))
164
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
165
+ decorators = [ast.unparse(item).lower() for item in node.decorator_list]
166
+ if any("tool" in item for item in decorators):
167
+ items.append(_item("mcp-tool", node.name, path, node.name, "python-ast", _python_evidence(text, node)))
168
+ for decorator in node.decorator_list:
169
+ if not isinstance(decorator, ast.Call) or not isinstance(
170
+ decorator.func, ast.Attribute
171
+ ):
172
+ continue
173
+ if decorator.func.attr != "command":
174
+ continue
175
+ command_name = node.name
176
+ if (
177
+ decorator.args
178
+ and isinstance(decorator.args[0], ast.Constant)
179
+ and isinstance(decorator.args[0].value, str)
180
+ ):
181
+ command_name = decorator.args[0].value
182
+ items.append(
183
+ _item(
184
+ "cli-command",
185
+ command_name,
186
+ path,
187
+ command_name,
188
+ "python-cli-framework",
189
+ _python_evidence(text, node),
190
+ )
191
+ )
192
+ if isinstance(node, ast.ClassDef) and any(
193
+ "basesettings" in ast.unparse(base).lower() for base in node.bases
194
+ ):
195
+ for field in node.body:
196
+ if isinstance(field, (ast.Assign, ast.AnnAssign)):
197
+ target = field.target if isinstance(field, ast.AnnAssign) else field.targets[0]
198
+ if isinstance(target, ast.Name) and not target.id.startswith("_"):
199
+ items.append(
200
+ _item(
201
+ "config-key",
202
+ target.id,
203
+ path,
204
+ f"{node.name}.{target.id}",
205
+ "python-settings-ast",
206
+ _python_evidence(text, field),
207
+ )
208
+ )
209
+ for candidate in ast.walk(tree):
210
+ if not isinstance(candidate, ast.Call) or not isinstance(candidate.func, ast.Attribute):
211
+ continue
212
+ if not candidate.args or not isinstance(candidate.args[0], ast.Constant) or not isinstance(candidate.args[0].value, str):
213
+ continue
214
+ value = candidate.args[0].value
215
+ if candidate.func.attr == "add_parser":
216
+ items.append(_item("cli-command", value, path, value, "argparse-ast", _python_evidence(text, candidate)))
217
+ elif candidate.func.attr == "add_argument" and value.startswith("-"):
218
+ items.append(_item("cli-option", value, path, value, "argparse-ast", _python_evidence(text, candidate)))
219
+ return items
220
+
221
+
222
+ def _structured(path: Path, text: str) -> Any:
223
+ try:
224
+ if path.suffix.lower() == ".json":
225
+ return json.loads(text)
226
+ if path.suffix.lower() in {".yaml", ".yml"}:
227
+ return yaml.safe_load(text)
228
+ if path.name == "pyproject.toml":
229
+ return tomllib.loads(text)
230
+ except (json.JSONDecodeError, yaml.YAMLError, tomllib.TOMLDecodeError):
231
+ return None
232
+ return None
233
+
234
+
235
+ def _structured_items(path: str, data: Any) -> list[dict[str, str]]:
236
+ if not isinstance(data, dict):
237
+ return []
238
+ items: list[dict[str, str]] = []
239
+ if path == "pyproject.toml" and isinstance(data.get("project"), dict):
240
+ project = data["project"]
241
+ name = str(project.get("name", "Python package"))
242
+ version = str(project.get("version", "unknown"))
243
+ items.append(_item("package", name, path, "project", "python-package", f"{name}:{version}"))
244
+ scripts = project.get("scripts")
245
+ if isinstance(scripts, dict):
246
+ for command, target in sorted(scripts.items()):
247
+ items.append(
248
+ _item(
249
+ "cli-command",
250
+ str(command),
251
+ path,
252
+ f"project.scripts.{command}",
253
+ "python-package",
254
+ str(target),
255
+ )
256
+ )
257
+ requires_python = project.get("requires-python")
258
+ if isinstance(requires_python, str):
259
+ items.append(
260
+ _item(
261
+ "runtime-constraint",
262
+ f"Python {requires_python}",
263
+ path,
264
+ "project.requires-python",
265
+ "python-package",
266
+ requires_python,
267
+ )
268
+ )
269
+ if Path(path).name == "package.json":
270
+ name = str(data.get("name", "JavaScript package"))
271
+ version = str(data.get("version", "unknown"))
272
+ items.append(_item("package", name, path, "package", "node-package", f"{name}:{version}"))
273
+ if data.get("type") == "module":
274
+ items.append(
275
+ _item(
276
+ "runtime-constraint",
277
+ "ES modules",
278
+ path,
279
+ "type",
280
+ "node-package",
281
+ "module",
282
+ )
283
+ )
284
+ engines = data.get("engines")
285
+ if isinstance(engines, dict):
286
+ for runtime, constraint in sorted(engines.items()):
287
+ items.append(
288
+ _item(
289
+ "runtime-constraint",
290
+ f"{runtime} {constraint}",
291
+ path,
292
+ f"engines.{runtime}",
293
+ "node-package",
294
+ str(constraint),
295
+ )
296
+ )
297
+ scripts = data.get("scripts")
298
+ if isinstance(scripts, dict):
299
+ for script, command in sorted(scripts.items()):
300
+ if str(script).startswith("//"):
301
+ continue
302
+ kind = "test-runner" if script == "test" or script.startswith("test:") else "package-script"
303
+ items.append(_item(kind, str(script), path, f"scripts.{script}", "node-package", str(command)))
304
+ binaries = data.get("bin")
305
+ if isinstance(binaries, str):
306
+ items.append(_item("cli-command", name, path, "bin", "node-package", binaries))
307
+ elif isinstance(binaries, dict):
308
+ for command, target in sorted(binaries.items()):
309
+ items.append(_item("cli-command", str(command), path, f"bin.{command}", "node-package", str(target)))
310
+ if "openapi" in data and isinstance(data.get("paths"), dict):
311
+ for route, operations in sorted(data["paths"].items()):
312
+ if not isinstance(operations, dict):
313
+ continue
314
+ for method in sorted(set(operations) & HTTP_METHODS):
315
+ locator = f"{method.upper()} {route}"
316
+ items.append(_item("api-endpoint", locator, path, locator, "openapi", json.dumps(operations[method], sort_keys=True)))
317
+ if "$schema" in data or path.endswith(".schema.json"):
318
+ name = str(data.get("title") or data.get("$id") or Path(path).stem)
319
+ items.append(_item("schema", name, path, name, "json-schema", json.dumps(data, sort_keys=True)))
320
+ properties = data.get("properties")
321
+ if isinstance(properties, dict):
322
+ for key, value in sorted(properties.items()):
323
+ items.append(
324
+ _item(
325
+ "config-key",
326
+ str(key),
327
+ path,
328
+ f"properties.{key}",
329
+ "json-schema",
330
+ json.dumps(value, sort_keys=True),
331
+ )
332
+ )
333
+ return items
334
+
335
+
336
+ def _coverage_ignores(root: Path, identifiers: set[str]) -> dict[str, str]:
337
+ ignore_path = root / ".sourcebound-ignore.yml"
338
+ if not ignore_path.exists():
339
+ return {}
340
+ try:
341
+ raw = yaml.safe_load(ignore_path.read_text(encoding="utf-8"))
342
+ except OSError as exc:
343
+ raise ConfigurationError(f"cannot read coverage policy {ignore_path}") from exc
344
+ except yaml.YAMLError as exc:
345
+ raise ConfigurationError(f"invalid coverage policy {ignore_path}") from exc
346
+ if not isinstance(raw, dict) or set(raw) != {"version", "ignore"}:
347
+ raise ConfigurationError("coverage policy must contain only version and ignore")
348
+ if raw["version"] != 1 or not isinstance(raw["ignore"], list):
349
+ raise ConfigurationError("coverage policy must use version 1 and an ignore list")
350
+ ignored: dict[str, str] = {}
351
+ for index, record in enumerate(raw["ignore"]):
352
+ if not isinstance(record, dict) or set(record) != {"id", "reason"}:
353
+ raise ConfigurationError(f"coverage ignore {index} must contain id and reason")
354
+ identifier = record["id"]
355
+ reason = record["reason"]
356
+ if not isinstance(identifier, str) or identifier not in identifiers:
357
+ raise ConfigurationError(f"coverage ignore {index} names an unknown inventory id")
358
+ if not isinstance(reason, str) or len(reason.strip()) < 12:
359
+ raise ConfigurationError(f"coverage ignore {index} needs a specific reason")
360
+ if identifier in ignored:
361
+ raise ConfigurationError(f"coverage ignore {index} duplicates an inventory id")
362
+ ignored[identifier] = reason.strip()
363
+ return ignored
364
+
365
+
366
+ def _coverage(root: Path, identifiers: set[str]) -> tuple[set[tuple[str, str]], bool, dict[str, str]]:
367
+ direct_locators: set[tuple[str, str]] = set()
368
+ cataloged = False
369
+ manifest = root / ".sourcebound.yml"
370
+ if manifest.exists():
371
+ try:
372
+ data = yaml.safe_load(manifest.read_text(encoding="utf-8"))
373
+ bindings = data.get("bindings", []) if isinstance(data, dict) else []
374
+ for binding in bindings if isinstance(bindings, list) else []:
375
+ if not isinstance(binding, dict):
376
+ continue
377
+ source = binding.get("source")
378
+ source_data = source if isinstance(source, dict) else {}
379
+ path = source_data.get("path")
380
+ if binding.get("extractor") in {
381
+ "repository-inventory",
382
+ "repository-overview",
383
+ } and path == ".":
384
+ cataloged = True
385
+ elif isinstance(path, str):
386
+ locator = source_data.get("symbol") or source_data.get("pointer")
387
+ if isinstance(locator, str):
388
+ direct_locators.add((Path(path).as_posix(), locator))
389
+ except (OSError, yaml.YAMLError):
390
+ pass
391
+ return direct_locators, cataloged, _coverage_ignores(root, identifiers)
392
+
393
+
394
+ def scan_inventory(root: Path) -> InventoryReport:
395
+ root = root.resolve()
396
+ raw_items: list[dict[str, str]] = []
397
+ languages: set[str] = set()
398
+ for file_path in _repository_files(root):
399
+ relative = file_path.relative_to(root).as_posix()
400
+ language = LANGUAGES.get(file_path.suffix.lower())
401
+ if language:
402
+ languages.add(language)
403
+ try:
404
+ text = file_path.read_text(encoding="utf-8")
405
+ except (OSError, UnicodeDecodeError):
406
+ continue
407
+ if file_path.suffix.lower() == ".py":
408
+ raw_items.extend(_python_items(relative, text))
409
+ if file_path.suffix.lower() in {".ts", ".tsx", ".js", ".jsx"}:
410
+ adapter = (
411
+ "typescript-static"
412
+ if file_path.suffix.lower() in {".ts", ".tsx"}
413
+ else "javascript-static"
414
+ )
415
+ for name in TS_EXPORT.findall(text):
416
+ raw_items.append(_item("api-symbol", name, relative, name, adapter, name))
417
+ for name in TS_CLI_COMMAND.findall(text):
418
+ raw_items.append(
419
+ _item("cli-command", name, relative, name, "typescript-cli-framework", name)
420
+ )
421
+ for name in TS_CLI_OPTION.findall(text):
422
+ raw_items.append(
423
+ _item("cli-option", name, relative, name, "typescript-cli-framework", name)
424
+ )
425
+ for name in TS_MCP_TOOL.findall(text):
426
+ raw_items.append(_item("mcp-tool", name, relative, name, "typescript-mcp", name))
427
+ data = _structured(file_path, text)
428
+ if data is not None:
429
+ raw_items.extend(_structured_items(relative, data))
430
+ if file_path.suffix.lower() == ".md":
431
+ raw_items.append(_item("document", relative, relative, "document", "markdown", text))
432
+ for line_number, line in enumerate(text.splitlines(), start=1):
433
+ for target in LINK.findall(line):
434
+ raw_items.append(_item("doc-link", target, relative, f"line {line_number}", "markdown-links", target))
435
+ if (
436
+ file_path.name.startswith("test_") and file_path.suffix == ".py"
437
+ ) or file_path.name.endswith((".test.ts", ".spec.ts", ".test.js", ".spec.js")):
438
+ raw_items.append(_item("test-suite", relative, relative, "file", "test-files", text))
439
+ declaration_symbols = {
440
+ (item["name"], item["source"][:-5])
441
+ for item in raw_items
442
+ if item["kind"] == "api-symbol" and item["source"].endswith(".d.ts")
443
+ }
444
+ raw_items = [
445
+ item
446
+ for item in raw_items
447
+ if not (
448
+ item["kind"] == "api-symbol"
449
+ and not item["source"].endswith(".d.ts")
450
+ and (item["name"], str(Path(item["source"]).with_suffix("")))
451
+ in declaration_symbols
452
+ )
453
+ ]
454
+ unique = {item["id"]: item for item in raw_items}
455
+ direct_locators, cataloged, ignored = _coverage(root, set(unique))
456
+ items = []
457
+ for identifier in sorted(unique):
458
+ item = unique[identifier]
459
+ if identifier in ignored:
460
+ coverage = "ignored"
461
+ elif (item["source"], item["locator"]) in direct_locators:
462
+ coverage = "bound"
463
+ elif cataloged:
464
+ coverage = "cataloged"
465
+ else:
466
+ coverage = "standard-gap"
467
+ items.append(
468
+ InventoryItem(
469
+ **item,
470
+ coverage=coverage,
471
+ coverage_reason=ignored.get(identifier),
472
+ )
473
+ )
474
+ return InventoryReport(tuple(sorted(languages)), tuple(items))
@@ -0,0 +1,157 @@
1
+ """Run declared processes in disposable repository copies with bounded I/O."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+ import tempfile
9
+ import threading
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+
13
+ from clean_docs.errors import ExtractionError
14
+ from clean_docs.snapshot import RepositorySnapshot
15
+ from clean_docs.write_gate import redact_secrets
16
+
17
+
18
+ MAX_PROCESS_IO_BYTES = 1_000_000
19
+ COPY_IGNORES = (".git", ".venv", "node_modules", "__pycache__")
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class IsolatedProcessResult:
24
+ returncode: int
25
+ stdout: str
26
+ stderr: str
27
+
28
+
29
+ def _isolated_copy(source: Path, destination: Path) -> None:
30
+ shutil.copytree(
31
+ source,
32
+ destination,
33
+ symlinks=True,
34
+ ignore=shutil.ignore_patterns(*COPY_IGNORES),
35
+ )
36
+ for path in destination.rglob("*"):
37
+ # Known limit by design: reject every symlink rather than preserving one
38
+ # that could resolve outside this disposable snapshot. Repositories that
39
+ # require symlinks must use --no-exec or static paths instead.
40
+ if path.is_symlink():
41
+ raise ExtractionError(
42
+ "process snapshot contains a symbolic link: "
43
+ f"{path.relative_to(destination)}"
44
+ )
45
+
46
+
47
+ def _safe_output(label: str, stdout: bytes, stderr: bytes) -> IsolatedProcessResult:
48
+ rendered_stdout = stdout.decode("utf-8", errors="replace")
49
+ rendered_stderr = stderr.decode("utf-8", errors="replace")
50
+ _, stdout_flags = redact_secrets(rendered_stdout)
51
+ _, stderr_flags = redact_secrets(rendered_stderr)
52
+ flags = (*stdout_flags, *stderr_flags)
53
+ if flags:
54
+ raise ExtractionError(
55
+ f"{label} output contains secret-like data ({flags[0]})"
56
+ )
57
+ return IsolatedProcessResult(0, rendered_stdout, rendered_stderr)
58
+
59
+
60
+ def _sandbox_environment(home: Path, temp: Path) -> dict[str, str]:
61
+ return {
62
+ "HOME": str(home),
63
+ "TMPDIR": str(temp),
64
+ "PATH": os.environ.get("PATH", ""),
65
+ "NO_COLOR": "1",
66
+ }
67
+
68
+
69
+ def run_isolated_process(
70
+ snapshot: RepositorySnapshot,
71
+ argv: tuple[str, ...],
72
+ *,
73
+ label: str,
74
+ timeout_seconds: int,
75
+ input_text: str = "",
76
+ ) -> IsolatedProcessResult:
77
+ request = input_text.encode("utf-8")
78
+ if len(request) > MAX_PROCESS_IO_BYTES:
79
+ raise ExtractionError(
80
+ f"{label} input exceeds {MAX_PROCESS_IO_BYTES} bytes"
81
+ )
82
+ with snapshot.materialized_root() as source, tempfile.TemporaryDirectory(
83
+ prefix="sourcebound-process-"
84
+ ) as temporary:
85
+ sandbox = Path(temporary)
86
+ worktree = sandbox / "repository"
87
+ _isolated_copy(source, worktree)
88
+ home = sandbox / "home"
89
+ temp = sandbox / "tmp"
90
+ home.mkdir()
91
+ temp.mkdir()
92
+ environment = _sandbox_environment(home, temp)
93
+ try:
94
+ process = subprocess.Popen(
95
+ argv,
96
+ cwd=worktree,
97
+ env=environment,
98
+ stdin=subprocess.PIPE,
99
+ stdout=subprocess.PIPE,
100
+ stderr=subprocess.PIPE,
101
+ )
102
+ except OSError as exc:
103
+ raise ExtractionError(f"{label} failed to run: {exc}") from exc
104
+
105
+ stdout = bytearray()
106
+ stderr = bytearray()
107
+ total = 0
108
+ lock = threading.Lock()
109
+ exceeded = threading.Event()
110
+
111
+ def drain(stream: object, destination: bytearray) -> None:
112
+ nonlocal total
113
+ reader = stream
114
+ while True:
115
+ chunk = reader.read(64 * 1024) # type: ignore[attr-defined]
116
+ if not chunk:
117
+ return
118
+ with lock:
119
+ total += len(chunk)
120
+ if total > MAX_PROCESS_IO_BYTES:
121
+ exceeded.set()
122
+ process.kill()
123
+ return
124
+ destination.extend(chunk)
125
+
126
+ assert process.stdout is not None
127
+ assert process.stderr is not None
128
+ readers = (
129
+ threading.Thread(target=drain, args=(process.stdout, stdout), daemon=True),
130
+ threading.Thread(target=drain, args=(process.stderr, stderr), daemon=True),
131
+ )
132
+ for reader in readers:
133
+ reader.start()
134
+ try:
135
+ assert process.stdin is not None
136
+ process.stdin.write(request)
137
+ process.stdin.close()
138
+ process.wait(timeout=timeout_seconds)
139
+ except subprocess.TimeoutExpired as exc:
140
+ process.kill()
141
+ process.wait()
142
+ raise ExtractionError(
143
+ f"{label} timed out after {timeout_seconds} seconds"
144
+ ) from exc
145
+ except OSError as exc:
146
+ process.kill()
147
+ process.wait()
148
+ raise ExtractionError(f"{label} failed during execution: {exc}") from exc
149
+ finally:
150
+ for reader in readers:
151
+ reader.join(timeout=5)
152
+ if exceeded.is_set():
153
+ raise ExtractionError(
154
+ f"{label} output exceeds {MAX_PROCESS_IO_BYTES} bytes"
155
+ )
156
+ result = _safe_output(label, bytes(stdout), bytes(stderr))
157
+ return IsolatedProcessResult(process.returncode, result.stdout, result.stderr)