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
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)])