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
docs_drift/history.py
ADDED
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
"""Bounded, text-only Git history queries for change attribution and rename evidence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import subprocess
|
|
9
|
+
import tempfile
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from docs_drift.config import HistoryConfig
|
|
16
|
+
from docs_drift.history_cache import QueryCache
|
|
17
|
+
from docs_drift.limits import MAX_GIT_OUTPUT, MAX_LINE_CHARS
|
|
18
|
+
from docs_drift.models import CommitRef, Finding
|
|
19
|
+
|
|
20
|
+
_META = re.compile(r"^@@@([0-9a-f]{40,64})\x1f([0-9a-f]{4,64})\x1f([^\x1f]*)\x1f([^\x1f]*)\x1f([^\x1f]*)\x1f(.*)$")
|
|
21
|
+
_PATH = re.compile(r"^diff --git a/(.*?) b/(.*?)$")
|
|
22
|
+
_SOURCE_GLOBS = (
|
|
23
|
+
"*.py",
|
|
24
|
+
"*.js",
|
|
25
|
+
"*.mjs",
|
|
26
|
+
"*.cjs",
|
|
27
|
+
"*.ts",
|
|
28
|
+
"*.tsx",
|
|
29
|
+
"*.toml",
|
|
30
|
+
"*.json",
|
|
31
|
+
"*.yaml",
|
|
32
|
+
"*.yml",
|
|
33
|
+
"*.ini",
|
|
34
|
+
"*.cfg",
|
|
35
|
+
"Makefile",
|
|
36
|
+
"justfile",
|
|
37
|
+
)
|
|
38
|
+
_COMMAND_TIMEOUT_SECONDS = 12
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _rename_shape(line: str, name: re.Pattern[str]) -> str:
|
|
42
|
+
normalized = name.sub("__DOCS_DRIFT_RENAMED__", line[1:].strip())
|
|
43
|
+
if len(normalized) > MAX_LINE_CHARS:
|
|
44
|
+
return normalized
|
|
45
|
+
try:
|
|
46
|
+
statements = ast.parse(normalized).body
|
|
47
|
+
except (SyntaxError, ValueError, RecursionError):
|
|
48
|
+
return normalized
|
|
49
|
+
if len(statements) != 1:
|
|
50
|
+
return normalized
|
|
51
|
+
statement = statements[0]
|
|
52
|
+
if isinstance(statement, (ast.Assign, ast.AnnAssign)):
|
|
53
|
+
targets = statement.targets if isinstance(statement, ast.Assign) else [statement.target]
|
|
54
|
+
if (
|
|
55
|
+
len(targets) == 1
|
|
56
|
+
and isinstance(targets[0], ast.Name)
|
|
57
|
+
and targets[0].id == "__DOCS_DRIFT_RENAMED__"
|
|
58
|
+
and isinstance(statement.value, ast.Constant)
|
|
59
|
+
):
|
|
60
|
+
# A field can be renamed and have its literal default changed in
|
|
61
|
+
# the same edit. Preserve target/type structure, not the value.
|
|
62
|
+
statement.value = ast.Constant(value="__DOCS_DRIFT_DEFAULT__")
|
|
63
|
+
return ast.dump(statement, include_attributes=False)
|
|
64
|
+
return normalized
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class HistoryEvidence:
|
|
69
|
+
"""A same-hunk textual replacement that can corroborate a rename."""
|
|
70
|
+
|
|
71
|
+
old_name: str
|
|
72
|
+
new_name: str
|
|
73
|
+
commit: CommitRef
|
|
74
|
+
file: str
|
|
75
|
+
hunk_excerpt: str
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class HistoryMatch:
|
|
80
|
+
"""Attribution plus optional replacement evidence and a non-fatal warning."""
|
|
81
|
+
|
|
82
|
+
commit: CommitRef | None
|
|
83
|
+
rename: HistoryEvidence | None = None
|
|
84
|
+
warning: str | None = None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _run(repo: Path, args: list[str]) -> subprocess.CompletedProcess[str] | None:
|
|
88
|
+
partial = args[:1] == ["__bounded_history__"]
|
|
89
|
+
if partial:
|
|
90
|
+
args = args[1:]
|
|
91
|
+
env = {key: value for key, value in os.environ.items() if not key.upper().startswith("GIT_")}
|
|
92
|
+
env["GIT_OPTIONAL_LOCKS"] = "0"
|
|
93
|
+
env.update(
|
|
94
|
+
GIT_CONFIG_GLOBAL=os.devnull,
|
|
95
|
+
GIT_CONFIG_SYSTEM=os.devnull,
|
|
96
|
+
GIT_CONFIG_NOSYSTEM="1",
|
|
97
|
+
GIT_TERMINAL_PROMPT="0",
|
|
98
|
+
GIT_LFS_SKIP_SMUDGE="1",
|
|
99
|
+
)
|
|
100
|
+
try:
|
|
101
|
+
# File-backed output avoids waiting forever on a pipe inherited by a lazy
|
|
102
|
+
# partial-clone fetch after the parent git process times out.
|
|
103
|
+
with tempfile.TemporaryFile() as output:
|
|
104
|
+
process = subprocess.Popen(
|
|
105
|
+
["git", "-C", str(repo), "-c", "core.fsmonitor=false", "-c", f"core.hooksPath={os.devnull}", *args],
|
|
106
|
+
stdout=output,
|
|
107
|
+
stderr=subprocess.DEVNULL,
|
|
108
|
+
env=env,
|
|
109
|
+
)
|
|
110
|
+
try:
|
|
111
|
+
deadline = time.monotonic() + _COMMAND_TIMEOUT_SECONDS
|
|
112
|
+
while process.poll() is None:
|
|
113
|
+
if time.monotonic() >= deadline or output.seek(0, 2) > MAX_GIT_OUTPUT:
|
|
114
|
+
raise subprocess.TimeoutExpired(process.args, _COMMAND_TIMEOUT_SECONDS)
|
|
115
|
+
time.sleep(0.02)
|
|
116
|
+
except subprocess.TimeoutExpired:
|
|
117
|
+
process.kill()
|
|
118
|
+
process.wait(timeout=2)
|
|
119
|
+
if partial:
|
|
120
|
+
output.seek(0)
|
|
121
|
+
prefix = output.read(MAX_GIT_OUTPUT).decode("utf-8", "replace")
|
|
122
|
+
prefix = prefix.rpartition("\n@@@")[0]
|
|
123
|
+
return subprocess.CompletedProcess(process.args, 0, "@@TRUNCATED_HISTORY@@\n" + prefix, "")
|
|
124
|
+
return None
|
|
125
|
+
if process.returncode:
|
|
126
|
+
return None
|
|
127
|
+
output.seek(0)
|
|
128
|
+
data = output.read(8_000_001)
|
|
129
|
+
if len(data) > MAX_GIT_OUTPUT:
|
|
130
|
+
return None
|
|
131
|
+
return subprocess.CompletedProcess(process.args, process.returncode, data.decode("utf-8", "replace"), "")
|
|
132
|
+
except (OSError, subprocess.SubprocessError):
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _commit(metadata: re.Match[str], show_emails: bool) -> CommitRef:
|
|
137
|
+
sha, short_sha, author, email, date, subject = metadata.groups()
|
|
138
|
+
return CommitRef(
|
|
139
|
+
sha=sha,
|
|
140
|
+
short_sha=short_sha,
|
|
141
|
+
author_name=author or "Unknown author",
|
|
142
|
+
author_email=email if show_emails else None,
|
|
143
|
+
date=datetime.fromisoformat(date),
|
|
144
|
+
summary=subject or "(no subject)",
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _chunks(text: str) -> list[tuple[re.Match[str], str]]:
|
|
149
|
+
result = []
|
|
150
|
+
current: re.Match[str] | None = None
|
|
151
|
+
buffer: list[str] = []
|
|
152
|
+
for line in text.splitlines():
|
|
153
|
+
if line.startswith("@@@"):
|
|
154
|
+
if current is not None:
|
|
155
|
+
result.append((current, "\n".join(buffer)))
|
|
156
|
+
current = _META.match(line)
|
|
157
|
+
buffer = []
|
|
158
|
+
elif current is not None:
|
|
159
|
+
buffer.append(line)
|
|
160
|
+
if current is not None:
|
|
161
|
+
result.append((current, "\n".join(buffer)))
|
|
162
|
+
return result
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class GitHistory:
|
|
166
|
+
"""Cache shallow-clone checks and document blame while querying only Git text."""
|
|
167
|
+
|
|
168
|
+
def __init__(self, repo: Path, config: HistoryConfig | None = None) -> None:
|
|
169
|
+
self.repo = repo.resolve()
|
|
170
|
+
self.config = config or HistoryConfig()
|
|
171
|
+
self._shallow: bool | None = None
|
|
172
|
+
self._blame: dict[tuple[str, int], CommitRef | None] = {}
|
|
173
|
+
self._revision_range: str | None = None
|
|
174
|
+
self.warnings: list[str] = []
|
|
175
|
+
self._queries = QueryCache(self.repo, _run)
|
|
176
|
+
self._prepared: list[tuple[re.Match[str], str]] | None = None
|
|
177
|
+
|
|
178
|
+
def prepare(self) -> None:
|
|
179
|
+
"""Read one bounded newest-first patch stream, avoiding a Git process per claim."""
|
|
180
|
+
if self.shallow() is None or self.config.max_commits == 0:
|
|
181
|
+
self._prepared = []
|
|
182
|
+
return
|
|
183
|
+
listing = self._queries.query(["rev-list", f"--max-count={self.config.max_commits + 1}", "HEAD"])
|
|
184
|
+
revisions = listing.stdout.splitlines() if listing else []
|
|
185
|
+
self._revision_range = f"{revisions[-1]}..HEAD" if len(revisions) > self.config.max_commits else "HEAD"
|
|
186
|
+
result = self._queries.query(
|
|
187
|
+
[
|
|
188
|
+
"__bounded_history__",
|
|
189
|
+
"log",
|
|
190
|
+
"--no-ext-diff",
|
|
191
|
+
"--no-textconv",
|
|
192
|
+
f"--max-count={self.config.max_commits}",
|
|
193
|
+
"-p",
|
|
194
|
+
"--format=@@@%H%x1f%h%x1f%an%x1f%ae%x1f%aI%x1f%s",
|
|
195
|
+
self._revision_range,
|
|
196
|
+
"--",
|
|
197
|
+
*_SOURCE_GLOBS,
|
|
198
|
+
]
|
|
199
|
+
)
|
|
200
|
+
self._prepared = _chunks(result.stdout) if result else []
|
|
201
|
+
if result is None or result.stdout.startswith("@@TRUNCATED_HISTORY@@"):
|
|
202
|
+
self.warnings.append(
|
|
203
|
+
"History patch stream exceeded the 8 MB/12 second budget; older attribution is unavailable."
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
def shallow(self) -> bool | None:
|
|
207
|
+
if self._shallow is None:
|
|
208
|
+
result = self._queries.query(["rev-parse", "--is-shallow-repository"])
|
|
209
|
+
if result is None:
|
|
210
|
+
self._shallow = None
|
|
211
|
+
warning = (
|
|
212
|
+
"This folder isn't a git repository, so commit history is unavailable; "
|
|
213
|
+
"findings will not include 'introduced by'."
|
|
214
|
+
)
|
|
215
|
+
if warning not in self.warnings:
|
|
216
|
+
self.warnings.append(warning)
|
|
217
|
+
else:
|
|
218
|
+
self._shallow = result.stdout.strip().casefold() == "true"
|
|
219
|
+
if self._shallow:
|
|
220
|
+
self.warnings.append("Git history is shallow and may be truncated; use checkout fetch-depth: 0.")
|
|
221
|
+
return self._shallow
|
|
222
|
+
|
|
223
|
+
def search(self, old_name: str, new_names: tuple[str, ...] = ()) -> HistoryMatch: # noqa: PLR0912 - evidence guards.
|
|
224
|
+
"""Find the newest commit that removes an old token from source text."""
|
|
225
|
+
if self.shallow() is None:
|
|
226
|
+
return HistoryMatch(None, warning=self.warnings[0])
|
|
227
|
+
if self.config.max_commits == 0:
|
|
228
|
+
return HistoryMatch(None)
|
|
229
|
+
if self._revision_range is None:
|
|
230
|
+
listing = self._queries.query(["rev-list", f"--max-count={self.config.max_commits + 1}", "HEAD"])
|
|
231
|
+
revisions = listing.stdout.splitlines() if listing else []
|
|
232
|
+
self._revision_range = f"{revisions[-1]}..HEAD" if len(revisions) > self.config.max_commits else "HEAD"
|
|
233
|
+
result = (
|
|
234
|
+
self._queries.query(
|
|
235
|
+
[
|
|
236
|
+
"log",
|
|
237
|
+
"--no-ext-diff",
|
|
238
|
+
"--no-textconv",
|
|
239
|
+
f"--max-count={self.config.max_commits}",
|
|
240
|
+
"-p",
|
|
241
|
+
"--format=@@@%H%x1f%h%x1f%an%x1f%ae%x1f%aI%x1f%s",
|
|
242
|
+
*(["-G", f"[\"']{re.escape(old_name)}[\"']"] if old_name.startswith("/") else [f"-S{old_name}"]),
|
|
243
|
+
self._revision_range,
|
|
244
|
+
"--",
|
|
245
|
+
*_SOURCE_GLOBS,
|
|
246
|
+
],
|
|
247
|
+
)
|
|
248
|
+
if self._prepared is None
|
|
249
|
+
else None
|
|
250
|
+
)
|
|
251
|
+
if result is None and self._prepared is None:
|
|
252
|
+
warning = "Git history lookup failed or timed out; no introducing commit was attached."
|
|
253
|
+
self.warnings.append(warning)
|
|
254
|
+
return HistoryMatch(None, warning=warning)
|
|
255
|
+
old_pattern = re.compile(rf"(?<![\w]){re.escape(old_name)}(?![\w])")
|
|
256
|
+
chunks = self._prepared if self._prepared is not None else _chunks(result.stdout if result else "")
|
|
257
|
+
for meta, patch in chunks:
|
|
258
|
+
if old_name not in patch:
|
|
259
|
+
continue
|
|
260
|
+
if not old_name.startswith("/"):
|
|
261
|
+
removed_occurrences = sum(
|
|
262
|
+
line[1:].count(old_name)
|
|
263
|
+
for line in patch.splitlines()
|
|
264
|
+
if line.startswith("-") and not line.startswith("---")
|
|
265
|
+
)
|
|
266
|
+
added_occurrences = sum(
|
|
267
|
+
line[1:].count(old_name)
|
|
268
|
+
for line in patch.splitlines()
|
|
269
|
+
if line.startswith("+") and not line.startswith("+++")
|
|
270
|
+
)
|
|
271
|
+
if removed_occurrences == added_occurrences:
|
|
272
|
+
continue
|
|
273
|
+
commit = _commit(meta, self.config.show_emails)
|
|
274
|
+
file_name = ""
|
|
275
|
+
hunk: list[str] = []
|
|
276
|
+
removed = False
|
|
277
|
+
for line in [*patch.splitlines(), "@@ END"]:
|
|
278
|
+
path_match = _PATH.match(line)
|
|
279
|
+
if path_match:
|
|
280
|
+
file_name = path_match.group(2)
|
|
281
|
+
if line.startswith("@@"):
|
|
282
|
+
if removed:
|
|
283
|
+
evidence = self._rename_evidence(old_name, new_names, commit, file_name, hunk)
|
|
284
|
+
if evidence:
|
|
285
|
+
return HistoryMatch(commit, evidence)
|
|
286
|
+
return HistoryMatch(commit)
|
|
287
|
+
hunk = []
|
|
288
|
+
removed = False
|
|
289
|
+
continue
|
|
290
|
+
hunk.append(line)
|
|
291
|
+
if line.startswith("-") and not line.startswith("---") and old_pattern.search(line):
|
|
292
|
+
removed = True
|
|
293
|
+
return HistoryMatch(None)
|
|
294
|
+
|
|
295
|
+
@staticmethod
|
|
296
|
+
def _rename_evidence(
|
|
297
|
+
old_name: str, new_names: tuple[str, ...], commit: CommitRef, file_name: str, lines: list[str]
|
|
298
|
+
) -> HistoryEvidence | None:
|
|
299
|
+
added = [line for line in lines if line.startswith("+") and not line.startswith("+++")]
|
|
300
|
+
old_pattern = re.compile(rf"(?<![\w]){re.escape(old_name)}(?![\w])")
|
|
301
|
+
removed_shapes = {
|
|
302
|
+
_rename_shape(line, old_pattern)
|
|
303
|
+
for line in lines
|
|
304
|
+
if line.startswith("-") and not line.startswith("---") and old_pattern.search(line)
|
|
305
|
+
}
|
|
306
|
+
added_text = "\n".join(added)
|
|
307
|
+
for candidate in new_names:
|
|
308
|
+
if candidate == old_name or candidate not in added_text:
|
|
309
|
+
continue
|
|
310
|
+
candidate_pattern = re.compile(rf"(?<![\w]){re.escape(candidate)}(?![\w])")
|
|
311
|
+
# Sharing a large diff hunk does not establish a replacement. Require
|
|
312
|
+
# matching text or a matching assignment with a changed literal
|
|
313
|
+
# default; otherwise retain only removal attribution.
|
|
314
|
+
if any(
|
|
315
|
+
candidate_pattern.search(line) and _rename_shape(line, candidate_pattern) in removed_shapes
|
|
316
|
+
for line in added
|
|
317
|
+
):
|
|
318
|
+
excerpt = "\n".join([line for line in lines if line.startswith(("-", "+"))][:8])
|
|
319
|
+
return HistoryEvidence(old_name, candidate, commit, file_name, excerpt)
|
|
320
|
+
return None
|
|
321
|
+
|
|
322
|
+
def introduced_for(self, finding: Finding) -> HistoryMatch:
|
|
323
|
+
return self.search(finding.claim.value)
|
|
324
|
+
|
|
325
|
+
def default_change(self, finding: Finding) -> tuple[CommitRef | None, str]:
|
|
326
|
+
"""Find a declaration changing the documented default to its current value."""
|
|
327
|
+
symbol = finding.matched_symbol
|
|
328
|
+
expected = finding.claim.expected_default
|
|
329
|
+
if symbol is None or expected is None or symbol.default is None or self.shallow() is None:
|
|
330
|
+
return None, ""
|
|
331
|
+
result = (
|
|
332
|
+
self._queries.query(
|
|
333
|
+
[
|
|
334
|
+
"log",
|
|
335
|
+
"--no-ext-diff",
|
|
336
|
+
f"--max-count={self.config.max_commits}",
|
|
337
|
+
"-p",
|
|
338
|
+
"--format=@@@%H%x1f%h%x1f%an%x1f%ae%x1f%aI%x1f%s",
|
|
339
|
+
"-G",
|
|
340
|
+
re.escape(symbol.name),
|
|
341
|
+
"--",
|
|
342
|
+
symbol.file,
|
|
343
|
+
],
|
|
344
|
+
)
|
|
345
|
+
if self._prepared is None
|
|
346
|
+
else None
|
|
347
|
+
)
|
|
348
|
+
if result is None and self._prepared is None:
|
|
349
|
+
return None, ""
|
|
350
|
+
old = re.compile(rf"(?<![\w]){re.escape(expected.strip(chr(96) + chr(34) + chr(39)))}(?![\w])")
|
|
351
|
+
new = re.compile(rf"(?<![\w]){re.escape(symbol.default.strip(chr(96) + chr(34) + chr(39)))}(?![\w])")
|
|
352
|
+
chunks = self._prepared if self._prepared is not None else _chunks(result.stdout if result else "")
|
|
353
|
+
for metadata, patch in chunks:
|
|
354
|
+
selected_patch = patch
|
|
355
|
+
if self._prepared is not None:
|
|
356
|
+
selected_patch = "\n".join(
|
|
357
|
+
block
|
|
358
|
+
for block in re.split(r"(?m)(?=^diff --git )", patch)
|
|
359
|
+
if re.search(rf"(?m)^\+\+\+ b/{re.escape(symbol.file)}$", block)
|
|
360
|
+
)
|
|
361
|
+
if symbol.name not in selected_patch:
|
|
362
|
+
continue
|
|
363
|
+
# Check each hunk independently, avoiding unrelated defaults in the same commit.
|
|
364
|
+
for hunk in re.split(r"(?m)^@@.*@@.*$", selected_patch):
|
|
365
|
+
removed = [line for line in hunk.splitlines() if line.startswith("-") and not line.startswith("---")]
|
|
366
|
+
added = [line for line in hunk.splitlines() if line.startswith("+") and not line.startswith("+++")]
|
|
367
|
+
if any(symbol.name in line and old.search(line) for line in removed) and any(
|
|
368
|
+
symbol.name in line and new.search(line) for line in added
|
|
369
|
+
):
|
|
370
|
+
return _commit(metadata, self.config.show_emails), "\n".join([*removed, *added])
|
|
371
|
+
return None, ""
|
|
372
|
+
|
|
373
|
+
def last_touched(self, doc_path: str, line: int) -> CommitRef | None:
|
|
374
|
+
"""Blame the committed snapshot, independent of checkout newline conversion or uncommitted edits."""
|
|
375
|
+
key = doc_path, line
|
|
376
|
+
if key in self._blame:
|
|
377
|
+
return self._blame[key]
|
|
378
|
+
if self.shallow() is None:
|
|
379
|
+
return None
|
|
380
|
+
result = self._queries.query(["blame", "--line-porcelain", f"-L{line},{line}", "HEAD", "--", doc_path])
|
|
381
|
+
if result is None:
|
|
382
|
+
self._blame[key] = None
|
|
383
|
+
return None
|
|
384
|
+
lines = result.stdout.splitlines()
|
|
385
|
+
commit_match = re.match(r"^([0-9a-f]{40,64})\s+\d+\s+\d+", lines[0]) if lines else None
|
|
386
|
+
values: dict[str, str] = {}
|
|
387
|
+
for item in lines[1:]:
|
|
388
|
+
if item.startswith("\t"):
|
|
389
|
+
break
|
|
390
|
+
name, _, value = item.partition(" ")
|
|
391
|
+
values[name] = value
|
|
392
|
+
if not commit_match:
|
|
393
|
+
self._blame[key] = None
|
|
394
|
+
return None
|
|
395
|
+
sha = commit_match.group(1)
|
|
396
|
+
author = values.get("author", "Unknown author")
|
|
397
|
+
email = values.get("author-mail", "").strip("<>")
|
|
398
|
+
timestamp = values.get("author-time", "0")
|
|
399
|
+
date = datetime.fromtimestamp(int(timestamp), tz=UTC)
|
|
400
|
+
commit = CommitRef(
|
|
401
|
+
sha=sha,
|
|
402
|
+
short_sha=sha[:7],
|
|
403
|
+
author_name=author,
|
|
404
|
+
author_email=email if self.config.show_emails else None,
|
|
405
|
+
date=date,
|
|
406
|
+
summary=values.get("summary", "(no subject)"),
|
|
407
|
+
)
|
|
408
|
+
self._blame[key] = commit
|
|
409
|
+
return commit
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
__all__ = ["GitHistory", "HistoryEvidence", "HistoryMatch"]
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Bounded snapshot-keyed Git text cache; public workers disable repository caches."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from contextvars import ContextVar
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
MAX_ENTRIES = 128
|
|
12
|
+
MAX_CACHE_BYTES = 128_000_000
|
|
13
|
+
MAX_ENTRY_BYTES = 8_000_000
|
|
14
|
+
MAX_MEMORY_ENTRY = 100_000
|
|
15
|
+
CACHE_DISABLED: ContextVar[bool] = ContextVar("history_cache_disabled", default=False)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class QueryCache:
|
|
19
|
+
def __init__(self, repo: Path, run: Callable[[Path, list[str]], subprocess.CompletedProcess[str] | None]):
|
|
20
|
+
self.repo, self.run = repo, run
|
|
21
|
+
self.identity: str | None = None
|
|
22
|
+
self.memory: dict[str, subprocess.CompletedProcess[str] | None] = {}
|
|
23
|
+
self.directory = repo / ".docs-drift/cache/history"
|
|
24
|
+
self.enabled = (
|
|
25
|
+
not CACHE_DISABLED.get()
|
|
26
|
+
and os.getenv("DOCS_DRIFT_NO_HISTORY_CACHE") != "1"
|
|
27
|
+
and self.directory.resolve().is_relative_to(repo)
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
def query(self, args: list[str]) -> subprocess.CompletedProcess[str] | None:
|
|
31
|
+
key = json.dumps(args)
|
|
32
|
+
if key in self.memory:
|
|
33
|
+
return self.memory[key]
|
|
34
|
+
if self.identity is None:
|
|
35
|
+
head = self.run(self.repo, ["rev-parse", "HEAD"])
|
|
36
|
+
shallow = self.repo / ".git/shallow"
|
|
37
|
+
self.identity = (head.stdout.strip() if head else "unversioned") + (
|
|
38
|
+
shallow.read_text() if shallow.is_file() and not shallow.is_symlink() else "full"
|
|
39
|
+
)
|
|
40
|
+
digest = hashlib.sha256(("history-v2" + self.identity + key).encode()).hexdigest()
|
|
41
|
+
path = self.directory / f"{digest}.txt"
|
|
42
|
+
result = None
|
|
43
|
+
if self.enabled and path.is_file() and not path.is_symlink() and path.stat().st_size <= MAX_ENTRY_BYTES:
|
|
44
|
+
result = subprocess.CompletedProcess(args, 0, path.read_text(encoding="utf-8"), "")
|
|
45
|
+
else:
|
|
46
|
+
result = self.run(self.repo, args)
|
|
47
|
+
if result and self.enabled:
|
|
48
|
+
try:
|
|
49
|
+
self.directory.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
path.write_text(result.stdout, encoding="utf-8")
|
|
51
|
+
used = 0
|
|
52
|
+
for index, entry in enumerate(
|
|
53
|
+
sorted(self.directory.glob("*.txt"), key=lambda p: p.stat().st_mtime, reverse=True)
|
|
54
|
+
):
|
|
55
|
+
used += entry.stat().st_size
|
|
56
|
+
if index >= MAX_ENTRIES or used > MAX_CACHE_BYTES:
|
|
57
|
+
entry.unlink(missing_ok=True)
|
|
58
|
+
except OSError:
|
|
59
|
+
pass # Read-only checkouts still scan correctly.
|
|
60
|
+
if len(self.memory) < MAX_ENTRIES and (result is None or len(result.stdout) < MAX_MEMORY_ENTRY):
|
|
61
|
+
self.memory[key] = result
|
|
62
|
+
return result
|
docs_drift/ids.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Pure, versioned identity helpers; callers supply already-normalized claim values."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from pathlib import PurePath, PurePosixPath, PureWindowsPath
|
|
7
|
+
|
|
8
|
+
from docs_drift.limits import CONTROL_START, DELETE_CHARACTER, MAX_PATH_CHARS
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def normalize_repo_path(value: object) -> str:
|
|
12
|
+
"""Normalize separators without touching disk or guessing a repository root."""
|
|
13
|
+
if not isinstance(value, (str, PurePath)):
|
|
14
|
+
raise ValueError("Use a repository-relative path as text or a pathlib path.")
|
|
15
|
+
text = str(value).replace("\\", "/")
|
|
16
|
+
if len(text) > MAX_PATH_CHARS or any(ord(c) < CONTROL_START or ord(c) == DELETE_CHARACTER for c in text):
|
|
17
|
+
raise ValueError(f"Use a repository path under {MAX_PATH_CHARS} characters without control characters.")
|
|
18
|
+
path = PurePosixPath(text)
|
|
19
|
+
if not text or path.is_absolute() or PureWindowsPath(text).drive or ".." in path.parts or str(path) == ".":
|
|
20
|
+
raise ValueError("Use a nonempty repository-relative path without a drive or '..'.")
|
|
21
|
+
return path.as_posix()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _digest(namespace: str, parts: Sequence[object]) -> str:
|
|
25
|
+
encoded = json.dumps([namespace, 1, *parts], ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
|
26
|
+
return f"{namespace}_{hashlib.sha256(encoded).hexdigest()}"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _occurrence(parts: list[object], occurrence: int) -> list[object]:
|
|
30
|
+
if occurrence < 0:
|
|
31
|
+
raise ValueError("Use zero for the first occurrence and a positive counter for duplicates.")
|
|
32
|
+
return [*parts, occurrence] if occurrence else parts
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def make_section_id(doc_path: str | PurePath, heading_path: Sequence[str], occurrence: int = 0) -> str:
|
|
36
|
+
"""The heading trail includes the current heading; duplicate trails get a counter."""
|
|
37
|
+
parts: list[object] = [normalize_repo_path(doc_path), list(heading_path)]
|
|
38
|
+
return _digest("section", _occurrence(parts, occurrence))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def make_claim_id(
|
|
42
|
+
doc_path: str | PurePath, heading_path: Sequence[str], kind: str, value: str, occurrence: int = 0
|
|
43
|
+
) -> str:
|
|
44
|
+
"""Exclude line, column, raw text, and confidence; count repeated kind/value pairs per section."""
|
|
45
|
+
parts: list[object] = [normalize_repo_path(doc_path), list(heading_path), kind, value]
|
|
46
|
+
return _digest("claim", _occurrence(parts, occurrence))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def make_symbol_id(kind: str, qualified_name: str, file: str | PurePath) -> str:
|
|
50
|
+
return _digest("symbol", [kind, qualified_name, normalize_repo_path(file)])
|