docs-drift 0.1.0__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.
- docs_drift/__init__.py +7 -0
- docs_drift/analysis.py +52 -0
- docs_drift/baseline.py +70 -0
- docs_drift/cli.py +871 -0
- docs_drift/collaboration.py +282 -0
- docs_drift/config.py +113 -0
- docs_drift/docs/__init__.py +1 -0
- docs_drift/docs/defaults.py +250 -0
- docs_drift/docs/discover.py +172 -0
- docs_drift/docs/extract_blocks.py +596 -0
- docs_drift/docs/extract_inline.py +561 -0
- docs_drift/docs/pipeline.py +234 -0
- docs_drift/docs/preprocess.py +71 -0
- docs_drift/docs/sections.py +247 -0
- docs_drift/errors.py +119 -0
- docs_drift/fix/__init__.py +1 -0
- docs_drift/fix/edits.py +124 -0
- docs_drift/fix/model.py +139 -0
- docs_drift/fix/rules.py +232 -0
- docs_drift/git_safety.py +35 -0
- docs_drift/graph/queries/evidence_path.cypher +6 -0
- docs_drift/history.py +412 -0
- docs_drift/history_cache.py +62 -0
- docs_drift/ids.py +50 -0
- docs_drift/index/__init__.py +211 -0
- docs_drift/index/base.py +239 -0
- docs_drift/index/config.py +185 -0
- docs_drift/index/js_cli.py +211 -0
- docs_drift/index/js_routes.py +68 -0
- docs_drift/index/js_symbols.py +219 -0
- docs_drift/index/python_cli.py +370 -0
- docs_drift/index/python_routes.py +102 -0
- docs_drift/index/python_source.py +20 -0
- docs_drift/index/python_symbols.py +335 -0
- docs_drift/index/queries/javascript.scm +4 -0
- docs_drift/index/queries/typescript.scm +4 -0
- docs_drift/index/routes_common.py +16 -0
- docs_drift/index/tasks.py +131 -0
- docs_drift/index/ts_parser.py +20 -0
- docs_drift/limits.py +18 -0
- docs_drift/llm/__init__.py +7 -0
- docs_drift/llm/candidates.py +81 -0
- docs_drift/llm/client.py +185 -0
- docs_drift/llm/pricing.py +54 -0
- docs_drift/llm/privacy.py +63 -0
- docs_drift/llm/prompts/rewrite_v1.md +9 -0
- docs_drift/llm/prompts/verify_v1.md +12 -0
- docs_drift/llm/prompts.py +28 -0
- docs_drift/llm/retrieve.py +60 -0
- docs_drift/llm/types.py +68 -0
- docs_drift/llm/verify.py +179 -0
- docs_drift/lsp.py +199 -0
- docs_drift/match/__init__.py +5 -0
- docs_drift/match/commands.py +201 -0
- docs_drift/match/defaults.py +79 -0
- docs_drift/match/engine.py +319 -0
- docs_drift/match/rename.py +69 -0
- docs_drift/models.py +249 -0
- docs_drift/ownership.py +70 -0
- docs_drift/policy.py +81 -0
- docs_drift/portfolio.py +147 -0
- docs_drift/py.typed +0 -0
- docs_drift/report/__init__.py +38 -0
- docs_drift/report/common.py +31 -0
- docs_drift/report/github.py +22 -0
- docs_drift/report/html.py +106 -0
- docs_drift/report/json.py +17 -0
- docs_drift/report/junit.py +30 -0
- docs_drift/report/markdown.py +58 -0
- docs_drift/report/partials/body.html +19 -0
- docs_drift/report/partials/report.css +12 -0
- docs_drift/report/partials/script.js +95 -0
- docs_drift/report/portfolio.html +14 -0
- docs_drift/report/report.html +8 -0
- docs_drift/report/sarif.py +68 -0
- docs_drift/report/terminal.py +90 -0
- docs_drift/report/vendor/CYTOSCAPE-LICENSE +19 -0
- docs_drift/report/vendor/cytoscape.min.js +32 -0
- docs_drift/report/workflow.html +12 -0
- docs_drift/review.py +113 -0
- docs_drift/safe_files.py +19 -0
- docs_drift/scanning.py +202 -0
- docs_drift/schemas/SARIF-LICENSE.md +7 -0
- docs_drift/schemas/SARIF-NOTICES.txt +3 -0
- docs_drift/schemas/sarif-schema-2.1.0.json +3389 -0
- docs_drift/schemas/scan-result.v1.json +1024 -0
- docs_drift/setup.py +130 -0
- docs_drift/snapshots.py +100 -0
- docs_drift/support.py +31 -0
- docs_drift/watch.py +157 -0
- docs_drift/web/__init__.py +1 -0
- docs_drift/web/__main__.py +23 -0
- docs_drift/web/app.py +289 -0
- docs_drift/web/cleanup.py +23 -0
- docs_drift/web/examples/clean-repo.json +646 -0
- docs_drift/web/examples/fastapi-svc.json +723 -0
- docs_drift/web/examples/files-to-prompt.json +3366 -0
- docs_drift/web/examples/judge-fixture.json +226 -0
- docs_drift/web/examples/manifest.json +89 -0
- docs_drift/web/examples/node-tool.json +823 -0
- docs_drift/web/examples/py-cli.json +1267 -0
- docs_drift/web/examples/rich-cli-reviewed.json +7855 -0
- docs_drift/web/examples/rich-cli.json +8010 -0
- docs_drift/web/examples/verification.json +16 -0
- docs_drift/web/git.py +274 -0
- docs_drift/web/jobs.py +352 -0
- docs_drift/web/settings.py +50 -0
- docs_drift/web/static/demo.css +37 -0
- docs_drift/web/static/demo.js +184 -0
- docs_drift/web/static/evidence/correction-verification.json +28 -0
- docs_drift/web/static/evidence/flow-highlights-2026-10-22.png +0 -0
- docs_drift/web/static/evidence/fresh-scan.json +7855 -0
- docs_drift/web/static/evidence/review.json +44 -0
- docs_drift/web/static/evidence/rich-cli-correction.patch +20 -0
- docs_drift/web/static/evidence/runtime.json +26 -0
- docs_drift/web/static/evidence/saved-scan.json +8010 -0
- docs_drift/web/static/evidence/usage-card-supplied.png +0 -0
- docs_drift/web/templates/base.html +34 -0
- docs_drift/web/templates/case.html +13 -0
- docs_drift/web/templates/landing.html +90 -0
- docs_drift/web/templates/scan.html +2 -0
- docs_drift/web/templates/voice.html +1 -0
- docs_drift/web/validation.py +47 -0
- docs_drift/web/worker.py +152 -0
- docs_drift/workflow_cli.py +96 -0
- docs_drift/workspaces.py +128 -0
- docs_drift-0.1.0.dist-info/METADATA +331 -0
- docs_drift-0.1.0.dist-info/RECORD +131 -0
- docs_drift-0.1.0.dist-info/WHEEL +4 -0
- docs_drift-0.1.0.dist-info/entry_points.txt +2 -0
- docs_drift-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"repo_url": "https://github.com/textualize/rich-cli",
|
|
4
|
+
"sha": "46f4d2469097be395558768714a5f07ebccf1412",
|
|
5
|
+
"name": "rich-cli",
|
|
6
|
+
"verified_values": ["--line-number", "--p"],
|
|
7
|
+
"note": "Manually checked README.md:61 (--line-number) against src/rich_cli/__main__.py:358 (--line-numbers), and README.md:180 (--p) against the --print/-p declaration. Other static findings are not manually certified."
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"repo_url": "https://github.com/simonw/files-to-prompt",
|
|
11
|
+
"sha": "ae06f2039f3a1dc1c9f3b4657fb51459490768f2",
|
|
12
|
+
"name": "files-to-prompt · historical",
|
|
13
|
+
"verified_values": ["c/--cxml"],
|
|
14
|
+
"note": "Historical snapshot, intentionally pinned before the upstream fix: README.md:55 says c/--cxml; files_to_prompt/cli.py declares -c/--cxml. Fixed upstream by f7cf729babc92604b9cd06c3d9cad32daff0f276. Other findings may reflect scanner limitations, not real defects."
|
|
15
|
+
}
|
|
16
|
+
]
|
docs_drift/web/git.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""Bounded partial clone with no user Git config, credentials, hooks or smudge filters."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import tempfile
|
|
8
|
+
import time
|
|
9
|
+
from datetime import UTC, datetime, timedelta
|
|
10
|
+
from pathlib import Path, PurePosixPath
|
|
11
|
+
|
|
12
|
+
from docs_drift.limits import CONTROL_START, DELETE_CHARACTER, MAX_GIT_OUTPUT, MAX_PATH_CHARS
|
|
13
|
+
from docs_drift.web.settings import Settings
|
|
14
|
+
from docs_drift.web.validation import Repository
|
|
15
|
+
|
|
16
|
+
SOURCE_SUFFIXES = {
|
|
17
|
+
".md",
|
|
18
|
+
".mdx",
|
|
19
|
+
".markdown",
|
|
20
|
+
".rst",
|
|
21
|
+
".txt",
|
|
22
|
+
".py",
|
|
23
|
+
".pyi",
|
|
24
|
+
".js",
|
|
25
|
+
".mjs",
|
|
26
|
+
".cjs",
|
|
27
|
+
".jsx",
|
|
28
|
+
".ts",
|
|
29
|
+
".tsx",
|
|
30
|
+
".mts",
|
|
31
|
+
".cts",
|
|
32
|
+
".json",
|
|
33
|
+
".yaml",
|
|
34
|
+
".yml",
|
|
35
|
+
".toml",
|
|
36
|
+
".ini",
|
|
37
|
+
".cfg",
|
|
38
|
+
".sh",
|
|
39
|
+
".mk",
|
|
40
|
+
}
|
|
41
|
+
SOURCE_NAMES = {"Makefile", "GNUmakefile", "justfile", "Dockerfile", "Taskfile.yml"}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class JobError(Exception):
|
|
45
|
+
def __init__(self, kind: str, message: str, hint: str):
|
|
46
|
+
super().__init__(message)
|
|
47
|
+
self.kind, self.hint = kind, hint
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def git_environment(home: Path) -> dict[str, str]:
|
|
51
|
+
# Inherit only execution essentials, never tokens, proxies, SSH agents or model keys.
|
|
52
|
+
env = {
|
|
53
|
+
k: os.environ[k]
|
|
54
|
+
for k in ("PATH", "SYSTEMROOT", "WINDIR", "COMSPEC", "PATHEXT", "TEMP", "TMP", "LANG")
|
|
55
|
+
if k in os.environ
|
|
56
|
+
}
|
|
57
|
+
env.update(
|
|
58
|
+
HOME=str(home),
|
|
59
|
+
USERPROFILE=str(home),
|
|
60
|
+
XDG_CONFIG_HOME=str(home),
|
|
61
|
+
GIT_CONFIG_GLOBAL=os.devnull,
|
|
62
|
+
GIT_CONFIG_SYSTEM=os.devnull,
|
|
63
|
+
GIT_CONFIG_NOSYSTEM="1",
|
|
64
|
+
GIT_TERMINAL_PROMPT="0",
|
|
65
|
+
GIT_LFS_SKIP_SMUDGE="1",
|
|
66
|
+
GIT_OPTIONAL_LOCKS="0",
|
|
67
|
+
PYTHONUTF8="1",
|
|
68
|
+
)
|
|
69
|
+
return env
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def disk_bytes(root: Path) -> int:
|
|
73
|
+
return sum(p.stat().st_size for p in root.rglob("*") if p.is_file() and not p.is_symlink())
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def kill_process(process: subprocess.Popen[bytes]) -> None:
|
|
77
|
+
if process.poll() is not None:
|
|
78
|
+
return
|
|
79
|
+
if os.name == "nt":
|
|
80
|
+
subprocess.run(["taskkill", "/PID", str(process.pid), "/T", "/F"], capture_output=True, check=False)
|
|
81
|
+
else:
|
|
82
|
+
process.kill()
|
|
83
|
+
process.wait(timeout=10)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def git(args: list[str], root: Path, settings: Settings, deadline: float, *, stdin: bytes | None = None) -> bytes:
|
|
87
|
+
creationflags = 0
|
|
88
|
+
if sys.platform == "win32":
|
|
89
|
+
creationflags = subprocess.CREATE_NEW_PROCESS_GROUP
|
|
90
|
+
command = [
|
|
91
|
+
"git",
|
|
92
|
+
"-c",
|
|
93
|
+
f"core.hooksPath={os.devnull}",
|
|
94
|
+
"-c",
|
|
95
|
+
"core.fsmonitor=false",
|
|
96
|
+
"-c",
|
|
97
|
+
"protocol.file.allow=never",
|
|
98
|
+
"-c",
|
|
99
|
+
"protocol.ext.allow=never",
|
|
100
|
+
"-c",
|
|
101
|
+
"protocol.allow=never",
|
|
102
|
+
"-c",
|
|
103
|
+
"protocol.https.allow=always",
|
|
104
|
+
"-c",
|
|
105
|
+
"http.followRedirects=false",
|
|
106
|
+
"-c",
|
|
107
|
+
"credential.helper=",
|
|
108
|
+
"-c",
|
|
109
|
+
"filter.lfs.smudge=",
|
|
110
|
+
"-c",
|
|
111
|
+
"filter.lfs.required=false",
|
|
112
|
+
*args,
|
|
113
|
+
]
|
|
114
|
+
if os.name == "nt":
|
|
115
|
+
command[1:1] = ["-c", "http.sslBackend=openssl"]
|
|
116
|
+
# Temporary files prevent a full pipe from deadlocking our disk/timeout monitor.
|
|
117
|
+
|
|
118
|
+
with tempfile.TemporaryFile() as out, tempfile.TemporaryFile() as err:
|
|
119
|
+
process = subprocess.Popen(
|
|
120
|
+
command,
|
|
121
|
+
cwd=root,
|
|
122
|
+
env=git_environment(root),
|
|
123
|
+
stdin=subprocess.PIPE if stdin else subprocess.DEVNULL,
|
|
124
|
+
stdout=out,
|
|
125
|
+
stderr=err,
|
|
126
|
+
creationflags=creationflags,
|
|
127
|
+
)
|
|
128
|
+
try:
|
|
129
|
+
if stdin and process.stdin:
|
|
130
|
+
process.stdin.write(stdin)
|
|
131
|
+
process.stdin.close()
|
|
132
|
+
while process.poll() is None:
|
|
133
|
+
if time.monotonic() >= deadline:
|
|
134
|
+
raise JobError(
|
|
135
|
+
"timeout",
|
|
136
|
+
"Git took too long to download this repository.",
|
|
137
|
+
"Try a smaller repository or a pre-scanned example.",
|
|
138
|
+
)
|
|
139
|
+
if disk_bytes(root) > settings.max_bytes:
|
|
140
|
+
raise JobError(
|
|
141
|
+
"too_large", "The repository exceeded the 200 MB download limit.", "Try a smaller repository."
|
|
142
|
+
)
|
|
143
|
+
time.sleep(0.1)
|
|
144
|
+
if process.returncode:
|
|
145
|
+
raise JobError(
|
|
146
|
+
"clone",
|
|
147
|
+
"The repository or ref could not be fetched; it may be private, missing or redirected.",
|
|
148
|
+
"Use the canonical public GitHub URL and check the branch or tag.",
|
|
149
|
+
)
|
|
150
|
+
out.seek(0)
|
|
151
|
+
output = out.read(8_000_001)
|
|
152
|
+
if len(output) > MAX_GIT_OUTPUT:
|
|
153
|
+
raise JobError("too_large", "The repository listing is too large.", "Try a smaller repository.")
|
|
154
|
+
return output
|
|
155
|
+
finally:
|
|
156
|
+
kill_process(process)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def resolve(repository: Repository, root: Path, settings: Settings, deadline: float) -> str:
|
|
160
|
+
ref = repository.ref or "HEAD"
|
|
161
|
+
if re.fullmatch(r"[0-9a-fA-F]{40}", ref):
|
|
162
|
+
return ref.lower()
|
|
163
|
+
patterns = ["HEAD"] if ref == "HEAD" else [f"refs/heads/{ref}", f"refs/tags/{ref}", f"refs/tags/{ref}^{{}}"]
|
|
164
|
+
rows = git(["ls-remote", "--exit-code", repository.url, *patterns], root, settings, deadline).decode().splitlines()
|
|
165
|
+
matches = [row.split() for row in rows if row.strip()]
|
|
166
|
+
if not matches:
|
|
167
|
+
raise JobError(
|
|
168
|
+
"clone", "This branch or tag was not found.", "Check the ref or leave it empty for the default branch."
|
|
169
|
+
)
|
|
170
|
+
annotated = [row[0] for row in matches if row[1].endswith("^{}")]
|
|
171
|
+
sha = (annotated or [matches[0][0]])[0]
|
|
172
|
+
if not re.fullmatch(r"[0-9a-f]{40}", sha):
|
|
173
|
+
raise JobError("clone", "GitHub returned an unexpected commit identifier.", "Try again later.")
|
|
174
|
+
return sha
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def checkout( # noqa: PLR0913 - trusted clone configuration, not request-controlled options.
|
|
178
|
+
repository: Repository,
|
|
179
|
+
sha: str,
|
|
180
|
+
root: Path,
|
|
181
|
+
settings: Settings,
|
|
182
|
+
deadline: float,
|
|
183
|
+
*,
|
|
184
|
+
blob_filter: str = "blob:none",
|
|
185
|
+
depth_only: bool = False,
|
|
186
|
+
) -> Path:
|
|
187
|
+
repo = root / "repository"
|
|
188
|
+
repo.mkdir()
|
|
189
|
+
git(["init", "--quiet", "--template=", str(repo)], root, settings, deadline)
|
|
190
|
+
prefix = ["-C", str(repo)]
|
|
191
|
+
git([*prefix, "remote", "add", "origin", repository.url], root, settings, deadline)
|
|
192
|
+
for key, value in (
|
|
193
|
+
("http.followRedirects", "false"),
|
|
194
|
+
("protocol.file.allow", "never"),
|
|
195
|
+
("protocol.ext.allow", "never"),
|
|
196
|
+
("protocol.allow", "never"),
|
|
197
|
+
("protocol.https.allow", "always"),
|
|
198
|
+
("core.hooksPath", os.devnull),
|
|
199
|
+
):
|
|
200
|
+
git([*prefix, "config", key, value], root, settings, deadline)
|
|
201
|
+
if os.name == "nt":
|
|
202
|
+
git([*prefix, "config", "http.sslBackend", "openssl"], root, settings, deadline)
|
|
203
|
+
git([*prefix, "config", "remote.origin.promisor", "true"], root, settings, deadline)
|
|
204
|
+
git([*prefix, "config", "remote.origin.partialclonefilter", blob_filter], root, settings, deadline)
|
|
205
|
+
since = (datetime.now(UTC) - timedelta(days=settings.history_days)).date().isoformat()
|
|
206
|
+
fetch = [
|
|
207
|
+
*prefix,
|
|
208
|
+
"fetch",
|
|
209
|
+
"--quiet",
|
|
210
|
+
"--no-tags",
|
|
211
|
+
"--no-recurse-submodules",
|
|
212
|
+
f"--filter={blob_filter}",
|
|
213
|
+
f"--depth={settings.history_commits}" if depth_only else f"--shallow-since={since}",
|
|
214
|
+
"origin",
|
|
215
|
+
sha,
|
|
216
|
+
]
|
|
217
|
+
try:
|
|
218
|
+
git(fetch, root, settings, deadline)
|
|
219
|
+
except JobError as exc:
|
|
220
|
+
if exc.kind != "clone":
|
|
221
|
+
raise
|
|
222
|
+
# Old inactive projects may have no commits within the date window.
|
|
223
|
+
fetch = [arg for arg in fetch if not arg.startswith("--shallow-since=")]
|
|
224
|
+
fetch.insert(-2, f"--depth={settings.history_commits}")
|
|
225
|
+
git(fetch, root, settings, deadline)
|
|
226
|
+
listing = git([*prefix, "ls-tree", "-r", "-z", sha], root, settings, deadline).split(b"\0")
|
|
227
|
+
entries = [entry for entry in listing if entry]
|
|
228
|
+
if len(entries) > settings.max_files:
|
|
229
|
+
raise JobError(
|
|
230
|
+
"too_large", "The repository exceeds the 20,000 file limit.", "Try a smaller repository or an example."
|
|
231
|
+
)
|
|
232
|
+
selected = []
|
|
233
|
+
for entry in entries:
|
|
234
|
+
meta, raw = entry.split(b"\t", 1)
|
|
235
|
+
path = raw.decode("utf-8", errors="strict")
|
|
236
|
+
parts = PurePosixPath(path).parts
|
|
237
|
+
if (
|
|
238
|
+
len(path) > MAX_PATH_CHARS
|
|
239
|
+
or any(ord(c) < CONTROL_START or ord(c) == DELETE_CHARACTER for c in path)
|
|
240
|
+
or "\\" in path
|
|
241
|
+
or path.startswith(("/", "-"))
|
|
242
|
+
or ".." in parts
|
|
243
|
+
or any(p.lower() == ".git" for p in parts)
|
|
244
|
+
):
|
|
245
|
+
raise JobError(
|
|
246
|
+
"filename",
|
|
247
|
+
"The repository contains unsupported filenames.",
|
|
248
|
+
"Use an example, or scan this repository locally after reviewing its paths.",
|
|
249
|
+
)
|
|
250
|
+
if meta.startswith(b"100") and not any(
|
|
251
|
+
p.startswith(".env") or p in {"node_modules", "vendor", "dist", "build"} for p in parts
|
|
252
|
+
):
|
|
253
|
+
item = PurePosixPath(path)
|
|
254
|
+
if item.suffix.lower() in SOURCE_SUFFIXES or item.name in SOURCE_NAMES:
|
|
255
|
+
# Non-cone sparse patterns are anchored and escaped; never inject patterns through paths.
|
|
256
|
+
selected.append("/" + "".join("\\" + c if c in "*?[]!#\\" else c for c in path))
|
|
257
|
+
if not selected:
|
|
258
|
+
raise JobError(
|
|
259
|
+
"no_docs",
|
|
260
|
+
"No supported documentation or source files were found.",
|
|
261
|
+
"Try a repository with Markdown docs and Python or JavaScript source.",
|
|
262
|
+
)
|
|
263
|
+
git([*prefix, "sparse-checkout", "init", "--no-cone"], root, settings, deadline)
|
|
264
|
+
git(
|
|
265
|
+
[*prefix, "sparse-checkout", "set", "--no-cone", "--stdin"],
|
|
266
|
+
root,
|
|
267
|
+
settings,
|
|
268
|
+
deadline,
|
|
269
|
+
stdin=("\n".join(selected) + "\n").encode(),
|
|
270
|
+
)
|
|
271
|
+
git([*prefix, "checkout", "--quiet", "--detach", sha], root, settings, deadline)
|
|
272
|
+
if disk_bytes(root) > settings.max_bytes:
|
|
273
|
+
raise JobError("too_large", "The repository exceeded the 200 MB limit.", "Try a smaller repository.")
|
|
274
|
+
return repo
|
docs_drift/web/jobs.py
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
"""Bounded replaceable queue, expiring LRU result store, and disposable workers."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
import uuid
|
|
13
|
+
from collections import OrderedDict
|
|
14
|
+
from contextlib import suppress
|
|
15
|
+
from dataclasses import asdict, dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Protocol
|
|
18
|
+
|
|
19
|
+
from docs_drift.limits import CLONE_FAILURE_THRESHOLD, MAX_STORED_BYTES
|
|
20
|
+
from docs_drift.models import ScanResult
|
|
21
|
+
from docs_drift.web.cleanup import remove_workspace
|
|
22
|
+
from docs_drift.web.git import JobError, disk_bytes, git_environment
|
|
23
|
+
from docs_drift.web.settings import Settings
|
|
24
|
+
from docs_drift.web.validation import Repository
|
|
25
|
+
|
|
26
|
+
LOG = logging.getLogger("docs_drift.web")
|
|
27
|
+
LOG.setLevel(logging.INFO)
|
|
28
|
+
if not LOG.handlers:
|
|
29
|
+
LOG.addHandler(logging.StreamHandler())
|
|
30
|
+
LOG.propagate = False
|
|
31
|
+
STAGES = {"queued": 0, "cloning": 10, "indexing": 35, "matching": 60, "history": 80, "done": 100, "failed": 100}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _worker_process_options() -> dict[str, Any]:
|
|
35
|
+
if sys.platform == "win32":
|
|
36
|
+
return {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP}
|
|
37
|
+
return {"start_new_session": True}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class Job:
|
|
42
|
+
scan_id: str
|
|
43
|
+
repo_url: str
|
|
44
|
+
ref: str | None = None
|
|
45
|
+
status: str = "queued"
|
|
46
|
+
created: float = field(default_factory=time.time)
|
|
47
|
+
counts: dict[str, int] = field(default_factory=dict)
|
|
48
|
+
result: ScanResult | None = None
|
|
49
|
+
error: dict[str, str] | None = None
|
|
50
|
+
cached: bool = False
|
|
51
|
+
metrics: dict[str, float] = field(default_factory=dict)
|
|
52
|
+
|
|
53
|
+
def public(self) -> dict[str, Any]:
|
|
54
|
+
return {
|
|
55
|
+
"scan_id": self.scan_id,
|
|
56
|
+
"repo_url": self.repo_url,
|
|
57
|
+
"status": self.status,
|
|
58
|
+
"progress": STAGES[self.status],
|
|
59
|
+
"counts": self.counts,
|
|
60
|
+
"error": self.error,
|
|
61
|
+
"cached": self.cached,
|
|
62
|
+
"result": self.result.model_dump(mode="json") if self.result else None,
|
|
63
|
+
"metrics": self.metrics,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class Store:
|
|
68
|
+
def __init__(self, settings: Settings):
|
|
69
|
+
self.settings = settings
|
|
70
|
+
self.jobs: OrderedDict[str, Job] = OrderedDict()
|
|
71
|
+
self.cache: dict[str, str] = {}
|
|
72
|
+
self.directory = settings.data_dir.resolve() / "results"
|
|
73
|
+
self.directory.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
|
|
75
|
+
def get(self, ident: str) -> Job | None:
|
|
76
|
+
if not re.fullmatch(r"[a-zA-Z0-9-]{1,64}", ident):
|
|
77
|
+
return None
|
|
78
|
+
job = self.jobs.get(ident)
|
|
79
|
+
path = self.directory / f"{ident}.json"
|
|
80
|
+
if job is None and path.is_file() and path.stat().st_size <= MAX_STORED_BYTES:
|
|
81
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
82
|
+
data["result"] = ScanResult.model_validate(data["result"]) if data.get("result") else None
|
|
83
|
+
job = Job(**data)
|
|
84
|
+
if job and time.time() - job.created < self.settings.ttl:
|
|
85
|
+
self.jobs[ident] = job
|
|
86
|
+
self.jobs.move_to_end(ident)
|
|
87
|
+
self.trim()
|
|
88
|
+
return job
|
|
89
|
+
self.jobs.pop(ident, None)
|
|
90
|
+
path.unlink(missing_ok=True)
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
def trim(self) -> None:
|
|
94
|
+
while len(self.jobs) > self.settings.max_results:
|
|
95
|
+
inactive = next((k for k, v in self.jobs.items() if v.status in {"done", "failed"}), None)
|
|
96
|
+
if inactive is None:
|
|
97
|
+
break
|
|
98
|
+
self.jobs.pop(inactive)
|
|
99
|
+
|
|
100
|
+
def save(self, job: Job) -> None:
|
|
101
|
+
self.jobs[job.scan_id] = job
|
|
102
|
+
self.trim()
|
|
103
|
+
if job.status not in {"done", "failed"}:
|
|
104
|
+
return
|
|
105
|
+
data = asdict(job)
|
|
106
|
+
data["result"] = job.result.model_dump(mode="json") if job.result else None
|
|
107
|
+
path = self.directory / f"{job.scan_id}.json"
|
|
108
|
+
temporary = path.with_suffix(".tmp")
|
|
109
|
+
temporary.write_text(json.dumps(data), encoding="utf-8")
|
|
110
|
+
temporary.replace(path)
|
|
111
|
+
if job.result:
|
|
112
|
+
self.cache[self.key(job.repo_url, job.result.head_sha)] = job.scan_id
|
|
113
|
+
(self.directory / "cache.json").write_text(json.dumps(self.cache), encoding="utf-8")
|
|
114
|
+
self.expire()
|
|
115
|
+
|
|
116
|
+
@staticmethod
|
|
117
|
+
def key(url: str, sha: str) -> str:
|
|
118
|
+
return hashlib.sha256((url + "\0" + sha).encode()).hexdigest()
|
|
119
|
+
|
|
120
|
+
def expire(self) -> None:
|
|
121
|
+
files = sorted(
|
|
122
|
+
(p for p in self.directory.glob("*.json") if p.name != "cache.json"),
|
|
123
|
+
key=lambda p: p.stat().st_mtime,
|
|
124
|
+
reverse=True,
|
|
125
|
+
)
|
|
126
|
+
used = 0
|
|
127
|
+
for index, path in enumerate(files):
|
|
128
|
+
used += path.stat().st_size
|
|
129
|
+
if (
|
|
130
|
+
time.time() - path.stat().st_mtime > self.settings.ttl
|
|
131
|
+
or index >= self.settings.max_results
|
|
132
|
+
or used > self.settings.max_bytes
|
|
133
|
+
):
|
|
134
|
+
path.unlink(missing_ok=True)
|
|
135
|
+
self.jobs.pop(path.stem, None)
|
|
136
|
+
self.cache = {key: ident for key, ident in self.cache.items() if (self.directory / f"{ident}.json").is_file()}
|
|
137
|
+
(self.directory / "cache.json").write_text(json.dumps(self.cache), encoding="utf-8")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class QueueBackend(Protocol):
|
|
141
|
+
def submit(self, repository: Repository) -> Job: ...
|
|
142
|
+
async def close(self) -> None: ...
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class LocalQueue:
|
|
146
|
+
def __init__(self, settings: Settings, store: Store, test_runner: Any = None):
|
|
147
|
+
self.settings, self.store = settings, store
|
|
148
|
+
self.semaphore = asyncio.Semaphore(settings.max_concurrent)
|
|
149
|
+
self.tasks: set[asyncio.Task[None]] = set()
|
|
150
|
+
self.test_runner = test_runner # Dependency injection only; no HTTP request can select it.
|
|
151
|
+
self.clone_failures = 0
|
|
152
|
+
self.circuit_until = 0.0
|
|
153
|
+
self.total, self.drift, self.failed = 0, 0, 0
|
|
154
|
+
self.work = settings.data_dir.resolve() / "work"
|
|
155
|
+
self.work.mkdir(parents=True, exist_ok=True)
|
|
156
|
+
cache = store.directory / "cache.json"
|
|
157
|
+
if cache.exists():
|
|
158
|
+
store.cache = json.loads(cache.read_text(encoding="utf-8"))
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def available(self) -> bool:
|
|
162
|
+
return self.settings.live_enabled and time.monotonic() >= self.circuit_until
|
|
163
|
+
|
|
164
|
+
def submit(self, repository: Repository) -> Job:
|
|
165
|
+
if len(self.tasks) >= self.settings.max_queue:
|
|
166
|
+
raise ValueError("The scan queue is full. Try an instant example or retry in a minute.")
|
|
167
|
+
job = Job(uuid.uuid4().hex, repository.url, repository.ref)
|
|
168
|
+
self.store.save(job)
|
|
169
|
+
task = asyncio.create_task(self.run(job))
|
|
170
|
+
self.tasks.add(task)
|
|
171
|
+
task.add_done_callback(self.tasks.discard)
|
|
172
|
+
return job
|
|
173
|
+
|
|
174
|
+
async def run(self, job: Job) -> None: # noqa: PLR0912 - terminal states share mandatory cleanup.
|
|
175
|
+
async with self.semaphore:
|
|
176
|
+
start = time.monotonic()
|
|
177
|
+
workspace = self.work / job.scan_id
|
|
178
|
+
workspace.mkdir(mode=0o700)
|
|
179
|
+
try:
|
|
180
|
+
async with asyncio.timeout(self.settings.scan_timeout):
|
|
181
|
+
if self.test_runner:
|
|
182
|
+
await self.test_runner(job, workspace)
|
|
183
|
+
else:
|
|
184
|
+
await self.bounded_worker(job, workspace)
|
|
185
|
+
if job.result:
|
|
186
|
+
job.status = "done"
|
|
187
|
+
self.total += not job.cached
|
|
188
|
+
self.drift += sum(f.is_drift for f in job.result.findings) if not job.cached else 0
|
|
189
|
+
self.clone_failures = 0
|
|
190
|
+
except TimeoutError:
|
|
191
|
+
job.status, job.error = (
|
|
192
|
+
"failed",
|
|
193
|
+
{
|
|
194
|
+
"kind": "timeout",
|
|
195
|
+
"message": "The scan reached its 120 second time limit.",
|
|
196
|
+
"hint": "Try a smaller repository or an instant example.",
|
|
197
|
+
},
|
|
198
|
+
)
|
|
199
|
+
except JobError as exc:
|
|
200
|
+
job.status, job.error = "failed", {"kind": exc.kind, "message": str(exc), "hint": exc.hint}
|
|
201
|
+
except asyncio.CancelledError:
|
|
202
|
+
job.status, job.error = (
|
|
203
|
+
"failed",
|
|
204
|
+
{
|
|
205
|
+
"kind": "restart",
|
|
206
|
+
"message": "The service restarted during this scan.",
|
|
207
|
+
"hint": "Please submit it again.",
|
|
208
|
+
},
|
|
209
|
+
)
|
|
210
|
+
raise
|
|
211
|
+
except Exception:
|
|
212
|
+
job.status, job.error = (
|
|
213
|
+
"failed",
|
|
214
|
+
{
|
|
215
|
+
"kind": "scan",
|
|
216
|
+
"message": "This scan could not finish safely.",
|
|
217
|
+
"hint": "Try an example or run the CLI locally.",
|
|
218
|
+
},
|
|
219
|
+
)
|
|
220
|
+
finally:
|
|
221
|
+
if job.status == "failed":
|
|
222
|
+
self.failed += 1
|
|
223
|
+
if job.error and job.error["kind"] == "clone":
|
|
224
|
+
self.clone_failures += 1
|
|
225
|
+
if self.clone_failures >= CLONE_FAILURE_THRESHOLD:
|
|
226
|
+
self.circuit_until = time.monotonic() + 300
|
|
227
|
+
job.metrics["duration_seconds"] = round(time.monotonic() - start, 3)
|
|
228
|
+
try:
|
|
229
|
+
self.store.save(job)
|
|
230
|
+
finally:
|
|
231
|
+
# Only trusted UUID workspaces are removed; child symlinks are not followed.
|
|
232
|
+
if workspace.parent.resolve() == self.work and not workspace.is_symlink():
|
|
233
|
+
cleanup = asyncio.create_task(asyncio.to_thread(remove_workspace, self.work, workspace))
|
|
234
|
+
try:
|
|
235
|
+
await asyncio.shield(cleanup)
|
|
236
|
+
except asyncio.CancelledError:
|
|
237
|
+
# Shutdown must wait for deletion already running in
|
|
238
|
+
# a thread; cancelling its await does not stop it.
|
|
239
|
+
await cleanup
|
|
240
|
+
raise
|
|
241
|
+
LOG.info(
|
|
242
|
+
json.dumps(
|
|
243
|
+
{
|
|
244
|
+
"event": "scan_finished",
|
|
245
|
+
"repo": job.repo_url,
|
|
246
|
+
"sha": job.result.head_sha if job.result else None,
|
|
247
|
+
"duration": job.metrics["duration_seconds"],
|
|
248
|
+
"counts": job.result.summary()["by_verdict"] if job.result else {},
|
|
249
|
+
"failure": job.error["kind"] if job.error else None,
|
|
250
|
+
"cached": job.cached,
|
|
251
|
+
}
|
|
252
|
+
)
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
async def bounded_worker(self, job: Job, workspace: Path) -> None:
|
|
256
|
+
async def monitor() -> None:
|
|
257
|
+
while True:
|
|
258
|
+
if await asyncio.to_thread(disk_bytes, workspace) > self.settings.max_bytes:
|
|
259
|
+
raise JobError(
|
|
260
|
+
"too_large",
|
|
261
|
+
"The repository exceeded the 200 MB scan limit.",
|
|
262
|
+
"Choose a smaller repository or a baked example.",
|
|
263
|
+
)
|
|
264
|
+
await asyncio.sleep(0.5)
|
|
265
|
+
|
|
266
|
+
scan = asyncio.create_task(self.worker(job, workspace))
|
|
267
|
+
watch = asyncio.create_task(monitor())
|
|
268
|
+
try:
|
|
269
|
+
done, _ = await asyncio.wait({scan, watch}, return_when=asyncio.FIRST_COMPLETED)
|
|
270
|
+
for task in done:
|
|
271
|
+
task.result()
|
|
272
|
+
finally:
|
|
273
|
+
scan.cancel()
|
|
274
|
+
watch.cancel()
|
|
275
|
+
await asyncio.gather(scan, watch, return_exceptions=True)
|
|
276
|
+
|
|
277
|
+
async def worker(self, job: Job, workspace: Path) -> None:
|
|
278
|
+
|
|
279
|
+
options = _worker_process_options()
|
|
280
|
+
env = git_environment(workspace)
|
|
281
|
+
# Preserve trusted package import configuration, not credentials.
|
|
282
|
+
env["PYTHONPATH"] = str(Path(__file__).parents[2])
|
|
283
|
+
process = await asyncio.create_subprocess_exec(
|
|
284
|
+
sys.executable,
|
|
285
|
+
"-m",
|
|
286
|
+
"docs_drift.web.worker",
|
|
287
|
+
stdin=asyncio.subprocess.PIPE,
|
|
288
|
+
stdout=asyncio.subprocess.PIPE,
|
|
289
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
290
|
+
env=env,
|
|
291
|
+
limit=1_000_000,
|
|
292
|
+
**options,
|
|
293
|
+
)
|
|
294
|
+
assert process.stdin and process.stdout
|
|
295
|
+
config = asdict(self.settings)
|
|
296
|
+
config["data_dir"] = str(self.settings.data_dir)
|
|
297
|
+
process.stdin.write(
|
|
298
|
+
(
|
|
299
|
+
json.dumps({"repo_url": job.repo_url, "ref": job.ref, "workspace": str(workspace), "settings": config})
|
|
300
|
+
+ "\n"
|
|
301
|
+
).encode()
|
|
302
|
+
)
|
|
303
|
+
await process.stdin.drain()
|
|
304
|
+
try:
|
|
305
|
+
while line := await process.stdout.readline():
|
|
306
|
+
data = json.loads(line)
|
|
307
|
+
if "resolved_sha" in data:
|
|
308
|
+
cached_id = self.store.cache.get(self.store.key(job.repo_url, data["resolved_sha"]))
|
|
309
|
+
cached = self.store.get(cached_id) if cached_id else None
|
|
310
|
+
if cached and cached.result:
|
|
311
|
+
job.result, job.cached = cached.result, True
|
|
312
|
+
process.stdin.write(b"stop\n")
|
|
313
|
+
await process.stdin.drain()
|
|
314
|
+
break
|
|
315
|
+
process.stdin.write(b"continue\n")
|
|
316
|
+
await process.stdin.drain()
|
|
317
|
+
elif data.get("status") in STAGES:
|
|
318
|
+
job.status = data["status"]
|
|
319
|
+
job.counts = data.get("counts", job.counts)
|
|
320
|
+
if job.status == "failed":
|
|
321
|
+
job.error = data["error"]
|
|
322
|
+
if job.status == "done":
|
|
323
|
+
job.result = ScanResult.model_validate_json(
|
|
324
|
+
(workspace / "result.json").read_text(encoding="utf-8")
|
|
325
|
+
)
|
|
326
|
+
job.metrics.update({k: data[k] for k in ("clone_seconds", "download_bytes")})
|
|
327
|
+
await process.wait()
|
|
328
|
+
if not job.result and job.status != "failed":
|
|
329
|
+
raise RuntimeError("Worker exited without a result")
|
|
330
|
+
finally:
|
|
331
|
+
if sys.platform != "win32":
|
|
332
|
+
with suppress(ProcessLookupError):
|
|
333
|
+
os.killpg(process.pid, 9)
|
|
334
|
+
if process.returncode is None:
|
|
335
|
+
if os.name == "nt":
|
|
336
|
+
killer = await asyncio.create_subprocess_exec(
|
|
337
|
+
"taskkill",
|
|
338
|
+
"/PID",
|
|
339
|
+
str(process.pid),
|
|
340
|
+
"/T",
|
|
341
|
+
"/F",
|
|
342
|
+
stdout=asyncio.subprocess.DEVNULL,
|
|
343
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
344
|
+
)
|
|
345
|
+
await killer.wait()
|
|
346
|
+
await process.wait()
|
|
347
|
+
|
|
348
|
+
async def close(self) -> None:
|
|
349
|
+
tasks = list(self.tasks)
|
|
350
|
+
for task in tasks:
|
|
351
|
+
task.cancel()
|
|
352
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|