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/inventory.py
ADDED
|
@@ -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))
|
clean_docs/isolation.py
ADDED
|
@@ -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)
|