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.
Files changed (131) hide show
  1. docs_drift/__init__.py +7 -0
  2. docs_drift/analysis.py +52 -0
  3. docs_drift/baseline.py +70 -0
  4. docs_drift/cli.py +871 -0
  5. docs_drift/collaboration.py +282 -0
  6. docs_drift/config.py +113 -0
  7. docs_drift/docs/__init__.py +1 -0
  8. docs_drift/docs/defaults.py +250 -0
  9. docs_drift/docs/discover.py +172 -0
  10. docs_drift/docs/extract_blocks.py +596 -0
  11. docs_drift/docs/extract_inline.py +561 -0
  12. docs_drift/docs/pipeline.py +234 -0
  13. docs_drift/docs/preprocess.py +71 -0
  14. docs_drift/docs/sections.py +247 -0
  15. docs_drift/errors.py +119 -0
  16. docs_drift/fix/__init__.py +1 -0
  17. docs_drift/fix/edits.py +124 -0
  18. docs_drift/fix/model.py +139 -0
  19. docs_drift/fix/rules.py +232 -0
  20. docs_drift/git_safety.py +35 -0
  21. docs_drift/graph/queries/evidence_path.cypher +6 -0
  22. docs_drift/history.py +412 -0
  23. docs_drift/history_cache.py +62 -0
  24. docs_drift/ids.py +50 -0
  25. docs_drift/index/__init__.py +211 -0
  26. docs_drift/index/base.py +239 -0
  27. docs_drift/index/config.py +185 -0
  28. docs_drift/index/js_cli.py +211 -0
  29. docs_drift/index/js_routes.py +68 -0
  30. docs_drift/index/js_symbols.py +219 -0
  31. docs_drift/index/python_cli.py +370 -0
  32. docs_drift/index/python_routes.py +102 -0
  33. docs_drift/index/python_source.py +20 -0
  34. docs_drift/index/python_symbols.py +335 -0
  35. docs_drift/index/queries/javascript.scm +4 -0
  36. docs_drift/index/queries/typescript.scm +4 -0
  37. docs_drift/index/routes_common.py +16 -0
  38. docs_drift/index/tasks.py +131 -0
  39. docs_drift/index/ts_parser.py +20 -0
  40. docs_drift/limits.py +18 -0
  41. docs_drift/llm/__init__.py +7 -0
  42. docs_drift/llm/candidates.py +81 -0
  43. docs_drift/llm/client.py +185 -0
  44. docs_drift/llm/pricing.py +54 -0
  45. docs_drift/llm/privacy.py +63 -0
  46. docs_drift/llm/prompts/rewrite_v1.md +9 -0
  47. docs_drift/llm/prompts/verify_v1.md +12 -0
  48. docs_drift/llm/prompts.py +28 -0
  49. docs_drift/llm/retrieve.py +60 -0
  50. docs_drift/llm/types.py +68 -0
  51. docs_drift/llm/verify.py +179 -0
  52. docs_drift/lsp.py +199 -0
  53. docs_drift/match/__init__.py +5 -0
  54. docs_drift/match/commands.py +201 -0
  55. docs_drift/match/defaults.py +79 -0
  56. docs_drift/match/engine.py +319 -0
  57. docs_drift/match/rename.py +69 -0
  58. docs_drift/models.py +249 -0
  59. docs_drift/ownership.py +70 -0
  60. docs_drift/policy.py +81 -0
  61. docs_drift/portfolio.py +147 -0
  62. docs_drift/py.typed +0 -0
  63. docs_drift/report/__init__.py +38 -0
  64. docs_drift/report/common.py +31 -0
  65. docs_drift/report/github.py +22 -0
  66. docs_drift/report/html.py +106 -0
  67. docs_drift/report/json.py +17 -0
  68. docs_drift/report/junit.py +30 -0
  69. docs_drift/report/markdown.py +58 -0
  70. docs_drift/report/partials/body.html +19 -0
  71. docs_drift/report/partials/report.css +12 -0
  72. docs_drift/report/partials/script.js +95 -0
  73. docs_drift/report/portfolio.html +14 -0
  74. docs_drift/report/report.html +8 -0
  75. docs_drift/report/sarif.py +68 -0
  76. docs_drift/report/terminal.py +90 -0
  77. docs_drift/report/vendor/CYTOSCAPE-LICENSE +19 -0
  78. docs_drift/report/vendor/cytoscape.min.js +32 -0
  79. docs_drift/report/workflow.html +12 -0
  80. docs_drift/review.py +113 -0
  81. docs_drift/safe_files.py +19 -0
  82. docs_drift/scanning.py +202 -0
  83. docs_drift/schemas/SARIF-LICENSE.md +7 -0
  84. docs_drift/schemas/SARIF-NOTICES.txt +3 -0
  85. docs_drift/schemas/sarif-schema-2.1.0.json +3389 -0
  86. docs_drift/schemas/scan-result.v1.json +1024 -0
  87. docs_drift/setup.py +130 -0
  88. docs_drift/snapshots.py +100 -0
  89. docs_drift/support.py +31 -0
  90. docs_drift/watch.py +157 -0
  91. docs_drift/web/__init__.py +1 -0
  92. docs_drift/web/__main__.py +23 -0
  93. docs_drift/web/app.py +289 -0
  94. docs_drift/web/cleanup.py +23 -0
  95. docs_drift/web/examples/clean-repo.json +646 -0
  96. docs_drift/web/examples/fastapi-svc.json +723 -0
  97. docs_drift/web/examples/files-to-prompt.json +3366 -0
  98. docs_drift/web/examples/judge-fixture.json +226 -0
  99. docs_drift/web/examples/manifest.json +89 -0
  100. docs_drift/web/examples/node-tool.json +823 -0
  101. docs_drift/web/examples/py-cli.json +1267 -0
  102. docs_drift/web/examples/rich-cli-reviewed.json +7855 -0
  103. docs_drift/web/examples/rich-cli.json +8010 -0
  104. docs_drift/web/examples/verification.json +16 -0
  105. docs_drift/web/git.py +274 -0
  106. docs_drift/web/jobs.py +352 -0
  107. docs_drift/web/settings.py +50 -0
  108. docs_drift/web/static/demo.css +37 -0
  109. docs_drift/web/static/demo.js +184 -0
  110. docs_drift/web/static/evidence/correction-verification.json +28 -0
  111. docs_drift/web/static/evidence/flow-highlights-2026-10-22.png +0 -0
  112. docs_drift/web/static/evidence/fresh-scan.json +7855 -0
  113. docs_drift/web/static/evidence/review.json +44 -0
  114. docs_drift/web/static/evidence/rich-cli-correction.patch +20 -0
  115. docs_drift/web/static/evidence/runtime.json +26 -0
  116. docs_drift/web/static/evidence/saved-scan.json +8010 -0
  117. docs_drift/web/static/evidence/usage-card-supplied.png +0 -0
  118. docs_drift/web/templates/base.html +34 -0
  119. docs_drift/web/templates/case.html +13 -0
  120. docs_drift/web/templates/landing.html +90 -0
  121. docs_drift/web/templates/scan.html +2 -0
  122. docs_drift/web/templates/voice.html +1 -0
  123. docs_drift/web/validation.py +47 -0
  124. docs_drift/web/worker.py +152 -0
  125. docs_drift/workflow_cli.py +96 -0
  126. docs_drift/workspaces.py +128 -0
  127. docs_drift-0.1.0.dist-info/METADATA +331 -0
  128. docs_drift-0.1.0.dist-info/RECORD +131 -0
  129. docs_drift-0.1.0.dist-info/WHEEL +4 -0
  130. docs_drift-0.1.0.dist-info/entry_points.txt +2 -0
  131. 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)