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,172 @@
1
+ """Discover bounded documentation files without importing or executing repository code."""
2
+
3
+ import hashlib
4
+ import os
5
+ import subprocess
6
+ import warnings
7
+ from collections.abc import Iterator, Sequence
8
+ from dataclasses import dataclass
9
+ from pathlib import Path, PurePosixPath
10
+
11
+ from pathspec import GitIgnoreSpec
12
+
13
+ from docs_drift.config import Config
14
+ from docs_drift.models import DocFile, DocFormat
15
+
16
+ MAX_DOC_BYTES = 2_000_000
17
+
18
+
19
+ class DiscoveryWarning(UserWarning):
20
+ """A documentation candidate or discovery mechanism could not be used."""
21
+
22
+
23
+ def _warn(message: str) -> None:
24
+ warnings.warn(message, DiscoveryWarning, stacklevel=3)
25
+
26
+
27
+ def _globs(patterns: Sequence[str]) -> GitIgnoreSpec:
28
+ # Anchor configured globs to the scan root; **/ may match zero directories.
29
+ return GitIgnoreSpec.from_lines("/" + pattern.replace("\\", "/").lstrip("/") for pattern in patterns)
30
+
31
+
32
+ def _tracked_paths(repo: Path) -> list[str] | None:
33
+ # A directory inside some unrelated parent checkout is not itself a repo root.
34
+ if not (repo / ".git").exists():
35
+ return None
36
+ env = {key: value for key, value in os.environ.items() if not key.upper().startswith("GIT_")}
37
+ env["GIT_OPTIONAL_LOCKS"] = "0"
38
+ # Repository-configured fsmonitor commands/hooks must never run during a scan.
39
+ command = [
40
+ "git",
41
+ "--no-pager",
42
+ "-c",
43
+ "core.fsmonitor=false",
44
+ "-c",
45
+ f"core.hooksPath={os.devnull}",
46
+ "-C",
47
+ str(repo),
48
+ "ls-files",
49
+ "-z",
50
+ "--cached",
51
+ "--exclude-standard",
52
+ ]
53
+ try:
54
+ tracked = subprocess.run(command, check=True, capture_output=True, env=env, timeout=5).stdout
55
+ ignored = subprocess.run([*command, "--ignored"], check=True, capture_output=True, env=env, timeout=5).stdout
56
+ except (OSError, subprocess.SubprocessError):
57
+ _warn("Cannot list tracked documentation with Git; walking the repository and its .gitignore files instead.")
58
+ return None
59
+ ignored_paths = set(ignored.split(b"\0"))
60
+ return [os.fsdecode(path) for path in tracked.split(b"\0") if path and path not in ignored_paths]
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class _IgnoreLayer:
65
+ directory: PurePosixPath
66
+ spec: GitIgnoreSpec
67
+
68
+
69
+ def _ignored(path: str, is_directory: bool, layers: tuple[_IgnoreLayer, ...]) -> bool:
70
+ ignored = False
71
+ for layer in layers:
72
+ relative = PurePosixPath(path).relative_to(layer.directory).as_posix()
73
+ result = layer.spec.check_file(relative + ("/" if is_directory else ""))
74
+ if result.include is not None:
75
+ ignored = result.include
76
+ return ignored
77
+
78
+
79
+ def _inside(repo: Path, path: Path) -> bool:
80
+ return not path.is_symlink() and path.resolve().is_relative_to(repo)
81
+
82
+
83
+ def _walk(repo: Path, directory: Path, layers: tuple[_IgnoreLayer, ...] = ()) -> Iterator[str]:
84
+ ignore_file = directory / ".gitignore"
85
+ try:
86
+ if ignore_file.is_file() and _inside(repo, ignore_file):
87
+ spec = GitIgnoreSpec.from_lines(ignore_file.read_text(encoding="utf-8", errors="replace").splitlines())
88
+ layers = (*layers, _IgnoreLayer(PurePosixPath(directory.relative_to(repo).as_posix()), spec))
89
+ children = sorted(directory.iterdir())
90
+ except OSError:
91
+ _warn(f"Cannot read directory {directory}; check its permissions.")
92
+ return
93
+ vendors = {".git", ".hg", ".svn", ".venv", "venv", "node_modules", "vendor", "__pycache__", ".docs-drift"}
94
+ for child in children:
95
+ relative = child.relative_to(repo).as_posix()
96
+ if not _inside(repo, child):
97
+ continue
98
+ is_directory = child.is_dir()
99
+ if _ignored(relative, is_directory, layers):
100
+ continue
101
+ if is_directory:
102
+ if child.name not in vendors:
103
+ yield from _walk(repo, child, layers)
104
+ elif child.is_file():
105
+ yield relative
106
+
107
+
108
+ def _format(path: str) -> DocFormat | None:
109
+ return {
110
+ ".md": DocFormat.markdown,
111
+ ".markdown": DocFormat.markdown,
112
+ ".mdx": DocFormat.mdx,
113
+ ".rst": DocFormat.rst,
114
+ ".txt": DocFormat.text,
115
+ ".text": DocFormat.text,
116
+ }.get(PurePosixPath(path).suffix.casefold())
117
+
118
+
119
+ def _read_bytes(repo: Path, relative: str) -> bytes:
120
+ path = repo / relative
121
+ if not _inside(repo, path) or not path.is_file():
122
+ raise ValueError(f"{relative} is not a regular file inside the repository. Rediscover documentation.")
123
+ if path.stat().st_size > MAX_DOC_BYTES:
124
+ raise ValueError(f"{relative} exceeds 2 MB and was skipped. Narrow the configured documentation globs.")
125
+ with path.open("rb") as stream:
126
+ content = stream.read(MAX_DOC_BYTES + 1)
127
+ if len(content) > MAX_DOC_BYTES:
128
+ raise ValueError(f"{relative} exceeds 2 MB and was skipped. Narrow the configured documentation globs.")
129
+ return content
130
+
131
+
132
+ def discover_docs(repo: Path, config: Config) -> list[DocFile]:
133
+ """Select tracked files in Git roots, otherwise walk with layered .gitignore rules."""
134
+ root = repo.resolve()
135
+ if not root.is_dir():
136
+ raise ValueError("Choose an existing repository directory before discovering documentation.")
137
+ patterns = config.docs
138
+ if patterns == Config().docs:
139
+ from docs_drift.workspaces import docs_patterns # noqa: PLC0415 - optional dependency or shared pipeline import.
140
+
141
+ try:
142
+ patterns = [*patterns, *docs_patterns(root)]
143
+ except OSError as exc:
144
+ # Documentation-only discovery can still use its bounded walk.
145
+ # Full scans will reject the unavailable source index separately.
146
+ _warn(f"Cannot inspect documentation-site markers: {exc}")
147
+ includes, excludes = _globs(patterns), _globs(config.exclude)
148
+ paths = _tracked_paths(root)
149
+ candidates = _walk(root, root) if paths is None else iter(paths)
150
+ docs = []
151
+ for relative in sorted(set(candidates)):
152
+ format_ = _format(relative)
153
+ if format_ is None or not includes.match_file(relative) or excludes.match_file(relative):
154
+ continue
155
+ try:
156
+ content = _read_bytes(root, relative)
157
+ except (OSError, ValueError) as exc:
158
+ _warn(f"Cannot read documentation {relative}: {exc}")
159
+ continue
160
+ try:
161
+ docs.append(DocFile(path=relative, content_hash=hashlib.sha256(content).hexdigest(), format=format_))
162
+ except ValueError:
163
+ _warn(f"Cannot read documentation {relative!r}: unsupported path; skipped.")
164
+ return docs
165
+
166
+
167
+ def read_doc(repo: Path, doc: DocFile) -> str:
168
+ """Read the discovered byte snapshot as UTF-8 with replacement, detecting intervening edits."""
169
+ content = _read_bytes(repo.resolve(), doc.path)
170
+ if hashlib.sha256(content).hexdigest() != doc.content_hash:
171
+ raise ValueError(f"{doc.path} changed since discovery. Rediscover documentation before parsing it.")
172
+ return content.decode("utf-8", errors="replace")