docs-drift 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- docs_drift/__init__.py +7 -0
- docs_drift/analysis.py +52 -0
- docs_drift/baseline.py +70 -0
- docs_drift/cli.py +871 -0
- docs_drift/collaboration.py +282 -0
- docs_drift/config.py +113 -0
- docs_drift/docs/__init__.py +1 -0
- docs_drift/docs/defaults.py +250 -0
- docs_drift/docs/discover.py +172 -0
- docs_drift/docs/extract_blocks.py +596 -0
- docs_drift/docs/extract_inline.py +561 -0
- docs_drift/docs/pipeline.py +234 -0
- docs_drift/docs/preprocess.py +71 -0
- docs_drift/docs/sections.py +247 -0
- docs_drift/errors.py +119 -0
- docs_drift/fix/__init__.py +1 -0
- docs_drift/fix/edits.py +124 -0
- docs_drift/fix/model.py +139 -0
- docs_drift/fix/rules.py +232 -0
- docs_drift/git_safety.py +35 -0
- docs_drift/graph/queries/evidence_path.cypher +6 -0
- docs_drift/history.py +412 -0
- docs_drift/history_cache.py +62 -0
- docs_drift/ids.py +50 -0
- docs_drift/index/__init__.py +211 -0
- docs_drift/index/base.py +239 -0
- docs_drift/index/config.py +185 -0
- docs_drift/index/js_cli.py +211 -0
- docs_drift/index/js_routes.py +68 -0
- docs_drift/index/js_symbols.py +219 -0
- docs_drift/index/python_cli.py +370 -0
- docs_drift/index/python_routes.py +102 -0
- docs_drift/index/python_source.py +20 -0
- docs_drift/index/python_symbols.py +335 -0
- docs_drift/index/queries/javascript.scm +4 -0
- docs_drift/index/queries/typescript.scm +4 -0
- docs_drift/index/routes_common.py +16 -0
- docs_drift/index/tasks.py +131 -0
- docs_drift/index/ts_parser.py +20 -0
- docs_drift/limits.py +18 -0
- docs_drift/llm/__init__.py +7 -0
- docs_drift/llm/candidates.py +81 -0
- docs_drift/llm/client.py +185 -0
- docs_drift/llm/pricing.py +54 -0
- docs_drift/llm/privacy.py +63 -0
- docs_drift/llm/prompts/rewrite_v1.md +9 -0
- docs_drift/llm/prompts/verify_v1.md +12 -0
- docs_drift/llm/prompts.py +28 -0
- docs_drift/llm/retrieve.py +60 -0
- docs_drift/llm/types.py +68 -0
- docs_drift/llm/verify.py +179 -0
- docs_drift/lsp.py +199 -0
- docs_drift/match/__init__.py +5 -0
- docs_drift/match/commands.py +201 -0
- docs_drift/match/defaults.py +79 -0
- docs_drift/match/engine.py +319 -0
- docs_drift/match/rename.py +69 -0
- docs_drift/models.py +249 -0
- docs_drift/ownership.py +70 -0
- docs_drift/policy.py +81 -0
- docs_drift/portfolio.py +147 -0
- docs_drift/py.typed +0 -0
- docs_drift/report/__init__.py +38 -0
- docs_drift/report/common.py +31 -0
- docs_drift/report/github.py +22 -0
- docs_drift/report/html.py +106 -0
- docs_drift/report/json.py +17 -0
- docs_drift/report/junit.py +30 -0
- docs_drift/report/markdown.py +58 -0
- docs_drift/report/partials/body.html +19 -0
- docs_drift/report/partials/report.css +12 -0
- docs_drift/report/partials/script.js +95 -0
- docs_drift/report/portfolio.html +14 -0
- docs_drift/report/report.html +8 -0
- docs_drift/report/sarif.py +68 -0
- docs_drift/report/terminal.py +90 -0
- docs_drift/report/vendor/CYTOSCAPE-LICENSE +19 -0
- docs_drift/report/vendor/cytoscape.min.js +32 -0
- docs_drift/report/workflow.html +12 -0
- docs_drift/review.py +113 -0
- docs_drift/safe_files.py +19 -0
- docs_drift/scanning.py +202 -0
- docs_drift/schemas/SARIF-LICENSE.md +7 -0
- docs_drift/schemas/SARIF-NOTICES.txt +3 -0
- docs_drift/schemas/sarif-schema-2.1.0.json +3389 -0
- docs_drift/schemas/scan-result.v1.json +1024 -0
- docs_drift/setup.py +130 -0
- docs_drift/snapshots.py +100 -0
- docs_drift/support.py +31 -0
- docs_drift/watch.py +157 -0
- docs_drift/web/__init__.py +1 -0
- docs_drift/web/__main__.py +23 -0
- docs_drift/web/app.py +289 -0
- docs_drift/web/cleanup.py +23 -0
- docs_drift/web/examples/clean-repo.json +646 -0
- docs_drift/web/examples/fastapi-svc.json +723 -0
- docs_drift/web/examples/files-to-prompt.json +3366 -0
- docs_drift/web/examples/judge-fixture.json +226 -0
- docs_drift/web/examples/manifest.json +89 -0
- docs_drift/web/examples/node-tool.json +823 -0
- docs_drift/web/examples/py-cli.json +1267 -0
- docs_drift/web/examples/rich-cli-reviewed.json +7855 -0
- docs_drift/web/examples/rich-cli.json +8010 -0
- docs_drift/web/examples/verification.json +16 -0
- docs_drift/web/git.py +274 -0
- docs_drift/web/jobs.py +352 -0
- docs_drift/web/settings.py +50 -0
- docs_drift/web/static/demo.css +37 -0
- docs_drift/web/static/demo.js +184 -0
- docs_drift/web/static/evidence/correction-verification.json +28 -0
- docs_drift/web/static/evidence/flow-highlights-2026-10-22.png +0 -0
- docs_drift/web/static/evidence/fresh-scan.json +7855 -0
- docs_drift/web/static/evidence/review.json +44 -0
- docs_drift/web/static/evidence/rich-cli-correction.patch +20 -0
- docs_drift/web/static/evidence/runtime.json +26 -0
- docs_drift/web/static/evidence/saved-scan.json +8010 -0
- docs_drift/web/static/evidence/usage-card-supplied.png +0 -0
- docs_drift/web/templates/base.html +34 -0
- docs_drift/web/templates/case.html +13 -0
- docs_drift/web/templates/landing.html +90 -0
- docs_drift/web/templates/scan.html +2 -0
- docs_drift/web/templates/voice.html +1 -0
- docs_drift/web/validation.py +47 -0
- docs_drift/web/worker.py +152 -0
- docs_drift/workflow_cli.py +96 -0
- docs_drift/workspaces.py +128 -0
- docs_drift-0.1.0.dist-info/METADATA +331 -0
- docs_drift-0.1.0.dist-info/RECORD +131 -0
- docs_drift-0.1.0.dist-info/WHEEL +4 -0
- docs_drift-0.1.0.dist-info/entry_points.txt +2 -0
- docs_drift-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""Reviewable PR comparisons, bounded support history and verified local fix branches."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, cast
|
|
7
|
+
|
|
8
|
+
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
|
9
|
+
|
|
10
|
+
from docs_drift.analysis import changed_paths
|
|
11
|
+
from docs_drift.config import Config
|
|
12
|
+
from docs_drift.fix.edits import apply_file, unified_patch
|
|
13
|
+
from docs_drift.fix.rules import plan_rule_fixes
|
|
14
|
+
from docs_drift.git_safety import checkout_options
|
|
15
|
+
from docs_drift.history import _run
|
|
16
|
+
from docs_drift.history_cache import CACHE_DISABLED
|
|
17
|
+
from docs_drift.llm.privacy import mask_secrets, redact_result, redact_value
|
|
18
|
+
from docs_drift.models import Evidence, ScanResult
|
|
19
|
+
from docs_drift.ownership import coverage_report
|
|
20
|
+
from docs_drift.report import render
|
|
21
|
+
from docs_drift.review import ReviewBook, review_status
|
|
22
|
+
from docs_drift.scanning import run_scan
|
|
23
|
+
from docs_drift.snapshots import materialize_snapshot, resolve_revision, scan_snapshot
|
|
24
|
+
|
|
25
|
+
MAX_HISTORY_COMMITS = 50
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def static_scan(repo: Path, config: Config) -> ScanResult:
|
|
29
|
+
token = CACHE_DISABLED.set(True)
|
|
30
|
+
try:
|
|
31
|
+
return run_scan(repo, config, no_cache=True)
|
|
32
|
+
finally:
|
|
33
|
+
CACHE_DISABLED.reset(token)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def pr_comparison(repo: Path, config: Config, base: str) -> tuple[ScanResult, ScanResult, dict[str, Any]]:
|
|
37
|
+
base_sha = resolve_revision(repo, base)
|
|
38
|
+
before = scan_snapshot(repo, base_sha, config)
|
|
39
|
+
current = static_scan(repo, config)
|
|
40
|
+
previous = {f.claim.claim_id: f for f in before.findings}
|
|
41
|
+
changed = changed_paths(repo, base_sha, "HEAD")
|
|
42
|
+
selected = []
|
|
43
|
+
observations = []
|
|
44
|
+
for finding in current.findings:
|
|
45
|
+
old = previous.get(finding.claim.claim_id)
|
|
46
|
+
if not finding.is_drift or (
|
|
47
|
+
old and (old.verdict, old.suggestion, old.reasons) == (finding.verdict, finding.suggestion, finding.reasons)
|
|
48
|
+
):
|
|
49
|
+
continue
|
|
50
|
+
if old is None and finding.claim.doc_path not in changed:
|
|
51
|
+
continue
|
|
52
|
+
evidence = list(finding.evidence)
|
|
53
|
+
if old and old.matched_symbol:
|
|
54
|
+
symbol = old.matched_symbol
|
|
55
|
+
evidence.append(
|
|
56
|
+
Evidence(
|
|
57
|
+
file=symbol.file,
|
|
58
|
+
line=symbol.line,
|
|
59
|
+
text=f"Statically supported at base {base_sha}: {symbol.qualified_name}",
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
selected.append(finding.model_copy(update={"evidence": evidence}))
|
|
63
|
+
observations.append(
|
|
64
|
+
{
|
|
65
|
+
"claim_id": finding.claim.claim_id,
|
|
66
|
+
"doc_path": finding.claim.doc_path,
|
|
67
|
+
"line": finding.claim.line,
|
|
68
|
+
"before": old.verdict.value if old else "not-present",
|
|
69
|
+
"after": finding.verdict.value,
|
|
70
|
+
"supported_at_base": bool(old and old.verdict == "ok"),
|
|
71
|
+
"divergence_range": {"after": base_sha, "through": current.head_sha},
|
|
72
|
+
"attributed_commit": finding.introduced_by.sha if finding.introduced_by else None,
|
|
73
|
+
}
|
|
74
|
+
)
|
|
75
|
+
return (
|
|
76
|
+
current,
|
|
77
|
+
current.model_copy(update={"findings": selected}),
|
|
78
|
+
{
|
|
79
|
+
"schema_version": 1,
|
|
80
|
+
"base_sha": base_sha,
|
|
81
|
+
"head_sha": current.head_sha,
|
|
82
|
+
"changed_paths": changed,
|
|
83
|
+
"observations": observations,
|
|
84
|
+
"warnings": [*before.warnings, *current.warnings],
|
|
85
|
+
"limits": "Only new/changed drift at the current checkout is selected. A base/head range is not an exact "
|
|
86
|
+
"breaking-commit attribution. Static support is not runtime behavior.",
|
|
87
|
+
},
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def write_pr_report( # noqa: PLR0913 - report inputs and optional audit context.
|
|
92
|
+
result: ScanResult,
|
|
93
|
+
comparison: dict[str, Any],
|
|
94
|
+
output: Path,
|
|
95
|
+
reviews: ReviewBook | None = None,
|
|
96
|
+
*,
|
|
97
|
+
important: list[str] | None = None,
|
|
98
|
+
full_result: ScanResult | None = None,
|
|
99
|
+
) -> dict[str, Any]:
|
|
100
|
+
if output.exists():
|
|
101
|
+
raise ValueError("Choose a new report directory; previous review artifacts are preserved.")
|
|
102
|
+
active = review_status(full_result or result, reviews) if reviews else {}
|
|
103
|
+
coverage = coverage_report(full_result or result, important)
|
|
104
|
+
owners = {row["path"]: row["owners"] for row in coverage["documents"]}
|
|
105
|
+
audit = {key: entry.model_dump(mode="json") for key, entry in active.items()}
|
|
106
|
+
actionable = result.model_copy(
|
|
107
|
+
update={
|
|
108
|
+
"findings": [
|
|
109
|
+
f
|
|
110
|
+
for f in result.findings
|
|
111
|
+
if f.claim.claim_id not in active or active[f.claim.claim_id].decision == "stale"
|
|
112
|
+
]
|
|
113
|
+
}
|
|
114
|
+
)
|
|
115
|
+
token = CACHE_DISABLED.set(True)
|
|
116
|
+
try:
|
|
117
|
+
plan = plan_rule_fixes(actionable)
|
|
118
|
+
finally:
|
|
119
|
+
CACHE_DISABLED.reset(token)
|
|
120
|
+
patch = unified_patch(result.repo_path, plan)
|
|
121
|
+
if mask_secrets(patch) != patch:
|
|
122
|
+
patch = ""
|
|
123
|
+
comparison = {
|
|
124
|
+
**comparison,
|
|
125
|
+
"warnings": [
|
|
126
|
+
*comparison["warnings"],
|
|
127
|
+
"Patch omitted: masking would change exact source bytes. Review locally.",
|
|
128
|
+
],
|
|
129
|
+
}
|
|
130
|
+
comparison = redact_value(comparison)
|
|
131
|
+
coverage = redact_value(coverage)
|
|
132
|
+
active = {
|
|
133
|
+
key: entry.model_copy(update={"reason": mask_secrets(entry.reason), "reviewer": mask_secrets(entry.reviewer)})
|
|
134
|
+
for key, entry in active.items()
|
|
135
|
+
}
|
|
136
|
+
audit = {key: entry.model_dump(mode="json") for key, entry in active.items()}
|
|
137
|
+
payload = {
|
|
138
|
+
**comparison,
|
|
139
|
+
"reviews": audit,
|
|
140
|
+
"coverage": coverage,
|
|
141
|
+
"fixable_edits": len(plan.edits),
|
|
142
|
+
"fix_suggestions": plan.suggestions,
|
|
143
|
+
}
|
|
144
|
+
payload = cast(dict[str, Any], redact_value(payload))
|
|
145
|
+
output.mkdir(parents=True)
|
|
146
|
+
for format, name in [("json", "pr.json"), ("markdown", "pr.md"), ("github", "annotations.txt")]:
|
|
147
|
+
(output / name).write_text(render(actionable, format), encoding="utf-8")
|
|
148
|
+
(output / "review.json").write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
|
149
|
+
(output / "fix.patch").write_bytes(patch.encode())
|
|
150
|
+
environment = Environment(loader=FileSystemLoader(Path(__file__).parent / "report"), autoescape=select_autoescape())
|
|
151
|
+
(output / "pr.html").write_text(
|
|
152
|
+
environment.get_template("workflow.html").render(
|
|
153
|
+
result=redact_result(result),
|
|
154
|
+
comparison=comparison,
|
|
155
|
+
reviews=active,
|
|
156
|
+
owners=owners,
|
|
157
|
+
coverage=coverage,
|
|
158
|
+
fixable=len(plan.edits),
|
|
159
|
+
),
|
|
160
|
+
encoding="utf-8",
|
|
161
|
+
)
|
|
162
|
+
return payload
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def support_history(repo: Path, config: Config, claim_id: str, max_commits: int = 20) -> dict[str, Any]:
|
|
166
|
+
if not 1 <= max_commits <= MAX_HISTORY_COMMITS:
|
|
167
|
+
raise ValueError("History scans must be bounded to 1-50 commits.")
|
|
168
|
+
history = _run(repo, ["rev-list", "--first-parent", f"--max-count={max_commits}", "HEAD"])
|
|
169
|
+
if not history:
|
|
170
|
+
raise ValueError("Cannot traverse history; fetch the relevant commits first.")
|
|
171
|
+
observations = []
|
|
172
|
+
last_supported = first_drift = None
|
|
173
|
+
newer = None
|
|
174
|
+
for sha in history.stdout.splitlines():
|
|
175
|
+
scan = scan_snapshot(repo, sha, config)
|
|
176
|
+
finding = next((f for f in scan.findings if f.claim.claim_id == claim_id), None)
|
|
177
|
+
if finding is None or finding.verdict == "unverifiable":
|
|
178
|
+
observations.append({"sha": sha, "verdict": "absent-or-unverifiable"})
|
|
179
|
+
break
|
|
180
|
+
observations.append({"sha": sha, "verdict": finding.verdict.value})
|
|
181
|
+
if finding.verdict == "ok":
|
|
182
|
+
last_supported, first_drift = sha, newer
|
|
183
|
+
break
|
|
184
|
+
newer = sha
|
|
185
|
+
return {
|
|
186
|
+
"schema_version": 1,
|
|
187
|
+
"claim_id": claim_id,
|
|
188
|
+
"last_statically_supported": last_supported,
|
|
189
|
+
"first_observed_drift": first_drift,
|
|
190
|
+
"observations": observations,
|
|
191
|
+
"max_commits": max_commits,
|
|
192
|
+
"limits": "First-parent, bounded static scans. Missing claims or unavailable coverage stop attribution; "
|
|
193
|
+
"null means unknown. No runtime or complete-history claim.",
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def fix_branch( # noqa: PLR0912, PLR0915 - explicit fail-closed proposal stages.
|
|
198
|
+
repo: Path, config: Config, claim_id: str, branch: str, destination: Path
|
|
199
|
+
) -> dict[str, Any]:
|
|
200
|
+
status = _run(repo, [*checkout_options(repo), "status", "--porcelain"])
|
|
201
|
+
if status is None:
|
|
202
|
+
raise ValueError("Cannot determine source checkout status; Git inspection failed.")
|
|
203
|
+
if status.stdout.strip():
|
|
204
|
+
raise ValueError("A clean source checkout is required; preserve or commit your current work first.")
|
|
205
|
+
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._/-]{0,120}", branch) or ".." in branch:
|
|
206
|
+
raise ValueError("Use a valid local branch name without options or '..'.")
|
|
207
|
+
if destination.exists() or destination.resolve().is_relative_to(repo.resolve()):
|
|
208
|
+
raise ValueError("Use a new worktree directory outside the source checkout.")
|
|
209
|
+
result = static_scan(repo, config)
|
|
210
|
+
selected = [f for f in result.findings if f.claim.claim_id == claim_id and f.is_drift]
|
|
211
|
+
if not selected:
|
|
212
|
+
raise ValueError("Finding is absent or no longer drift; scan again.")
|
|
213
|
+
token = CACHE_DISABLED.set(True)
|
|
214
|
+
try:
|
|
215
|
+
plan = plan_rule_fixes(result.model_copy(update={"findings": selected}))
|
|
216
|
+
finally:
|
|
217
|
+
CACHE_DISABLED.reset(token)
|
|
218
|
+
if not plan.edits:
|
|
219
|
+
raise ValueError("No certain rule edit is available; review suggestions manually. No branch was created.")
|
|
220
|
+
created = _run(repo, ["worktree", "add", "--no-checkout", "-b", branch, str(destination.resolve()), "HEAD"])
|
|
221
|
+
if created is None:
|
|
222
|
+
raise ValueError("Cannot create the isolated fix worktree; check branch/destination availability.")
|
|
223
|
+
materialize_snapshot(repo, "HEAD", destination)
|
|
224
|
+
if _run(destination, ["read-tree", "HEAD"]) is None:
|
|
225
|
+
raise ValueError("Cannot initialize proposal index; nothing was pushed.")
|
|
226
|
+
fresh = static_scan(destination, config)
|
|
227
|
+
token = CACHE_DISABLED.set(True)
|
|
228
|
+
try:
|
|
229
|
+
plan = plan_rule_fixes(
|
|
230
|
+
fresh.model_copy(
|
|
231
|
+
update={"findings": [f for f in fresh.findings if f.claim.claim_id == claim_id and f.is_drift]}
|
|
232
|
+
)
|
|
233
|
+
)
|
|
234
|
+
finally:
|
|
235
|
+
CACHE_DISABLED.reset(token)
|
|
236
|
+
if not plan.edits:
|
|
237
|
+
raise ValueError("Proposal snapshot has no certain edit; inspect the worktree. Nothing was pushed.")
|
|
238
|
+
for name in sorted({edit.file for edit in plan.edits}):
|
|
239
|
+
apply_file(destination, plan, name)
|
|
240
|
+
after = static_scan(destination, config)
|
|
241
|
+
for edit in plan.edits:
|
|
242
|
+
candidates = [f for f in after.findings if f.claim.doc_path == edit.file and f.claim.line == edit.line]
|
|
243
|
+
if not candidates or any(f.verdict != "ok" for f in candidates):
|
|
244
|
+
raise ValueError(
|
|
245
|
+
f"Verification failed; inspect the uncommitted worktree at {destination}. Nothing was pushed."
|
|
246
|
+
)
|
|
247
|
+
files = sorted({edit.file for edit in plan.edits})
|
|
248
|
+
for name in files:
|
|
249
|
+
blob = _run(destination, ["hash-object", "-w", "--no-filters", "--", name])
|
|
250
|
+
mode = _run(destination, ["ls-files", "--stage", "--", name])
|
|
251
|
+
if blob is None or mode is None or not mode.stdout.strip():
|
|
252
|
+
raise ValueError("Cannot stage the local proposal; nothing was pushed.")
|
|
253
|
+
staged = _run(destination, ["update-index", "--cacheinfo", mode.stdout.split()[0], blob.stdout.strip(), name])
|
|
254
|
+
if staged is None:
|
|
255
|
+
raise ValueError("Cannot stage the local proposal; nothing was pushed.")
|
|
256
|
+
committed = _run(
|
|
257
|
+
destination,
|
|
258
|
+
[
|
|
259
|
+
*checkout_options(destination),
|
|
260
|
+
"-c",
|
|
261
|
+
"user.name=docs-drift",
|
|
262
|
+
"-c",
|
|
263
|
+
"user.email=docs-drift@localhost",
|
|
264
|
+
"-c",
|
|
265
|
+
"commit.gpgsign=false",
|
|
266
|
+
"commit",
|
|
267
|
+
"-m",
|
|
268
|
+
f"Review documentation fix {claim_id[:16]}",
|
|
269
|
+
],
|
|
270
|
+
)
|
|
271
|
+
if committed is None:
|
|
272
|
+
raise ValueError(f"Cannot save the local proposal; inspect {destination}. Nothing was pushed.")
|
|
273
|
+
return {
|
|
274
|
+
"schema_version": 1,
|
|
275
|
+
"branch": branch,
|
|
276
|
+
"worktree": str(destination.resolve()),
|
|
277
|
+
"commit": resolve_revision(destination, "HEAD"),
|
|
278
|
+
"files": files,
|
|
279
|
+
"verified": True,
|
|
280
|
+
"pushed": False,
|
|
281
|
+
"approval": "Human review required; this tool-authored local proposal was not merged.",
|
|
282
|
+
}
|
docs_drift/config.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Validated configuration loaded exclusively from TOML text."""
|
|
2
|
+
|
|
3
|
+
import tomllib
|
|
4
|
+
import warnings
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator
|
|
9
|
+
|
|
10
|
+
from docs_drift.safe_files import metadata_text
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ConfigWarning(UserWarning):
|
|
14
|
+
"""An unrecognized configuration key was ignored."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ConfigError(ValueError):
|
|
18
|
+
"""A selected configuration source could not be loaded."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ConfigModel(BaseModel):
|
|
22
|
+
"""Ignore unknown keys after making them visible to the user."""
|
|
23
|
+
|
|
24
|
+
model_config = ConfigDict(extra="ignore")
|
|
25
|
+
|
|
26
|
+
@model_validator(mode="before")
|
|
27
|
+
@classmethod
|
|
28
|
+
def warn_unknown_keys(cls, data: object) -> object:
|
|
29
|
+
if isinstance(data, dict):
|
|
30
|
+
for key in data:
|
|
31
|
+
if key not in cls.model_fields:
|
|
32
|
+
warnings.warn(
|
|
33
|
+
f"Unknown configuration key '{cls.__name__}.{key}'; ignored. Check the spelling or remove it.",
|
|
34
|
+
ConfigWarning,
|
|
35
|
+
stacklevel=2,
|
|
36
|
+
)
|
|
37
|
+
return data
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class LLMConfig(ConfigModel):
|
|
41
|
+
"""Optional prose assessment settings."""
|
|
42
|
+
|
|
43
|
+
enabled: bool = False
|
|
44
|
+
model: str | None = None
|
|
45
|
+
max_claims: int = Field(default=40, ge=0, le=1000)
|
|
46
|
+
concurrency: int = Field(default=4, ge=1, le=16)
|
|
47
|
+
timeout: float = Field(default=60, gt=0, le=600)
|
|
48
|
+
budget: float = Field(default=0.50, ge=0, allow_inf_nan=False)
|
|
49
|
+
redact_paths: list[str] = Field(default_factory=list)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class HistoryConfig(ConfigModel):
|
|
53
|
+
"""Limits for static git history inspection."""
|
|
54
|
+
|
|
55
|
+
max_commits: int = Field(default=2000, ge=0)
|
|
56
|
+
show_emails: bool = False
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class Config(ConfigModel):
|
|
60
|
+
"""Schema shared by .docs-drift.toml and [tool.docs-drift]."""
|
|
61
|
+
|
|
62
|
+
docs: list[str] = Field(default_factory=lambda: ["README.md", "docs/**/*.md", "*.md"])
|
|
63
|
+
# Changelogs describe the past on purpose, so old claims there are not drift.
|
|
64
|
+
exclude: list[str] = Field(
|
|
65
|
+
default_factory=lambda: [
|
|
66
|
+
"node_modules/**",
|
|
67
|
+
".venv/**",
|
|
68
|
+
"vendor/**",
|
|
69
|
+
"CHANGELOG.md",
|
|
70
|
+
"**/CHANGELOG.md",
|
|
71
|
+
]
|
|
72
|
+
)
|
|
73
|
+
languages: Literal["auto"] | list[str] = "auto"
|
|
74
|
+
ignore: list[str] = Field(default_factory=list)
|
|
75
|
+
min_confidence: float = Field(default=0.6, ge=0.0, le=1.0)
|
|
76
|
+
llm: LLMConfig = Field(default_factory=LLMConfig)
|
|
77
|
+
history: HistoryConfig = Field(default_factory=HistoryConfig)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _read_toml(path: Path) -> dict[str, object]:
|
|
81
|
+
try:
|
|
82
|
+
return tomllib.loads(metadata_text(path))
|
|
83
|
+
except (OSError, UnicodeError, tomllib.TOMLDecodeError) as exc:
|
|
84
|
+
raise ConfigError(f"Cannot read configuration at {path}: {exc}. Fix the file or path.") from exc
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def load_config(repo: Path | None = None, config_path: Path | None = None) -> Config:
|
|
88
|
+
"""Select one source by precedence; omitted fields use schema defaults.
|
|
89
|
+
|
|
90
|
+
Explicit paths are relative to the process working directory. Implicit
|
|
91
|
+
.docs-drift.toml and pyproject.toml paths are relative to the selected repo.
|
|
92
|
+
A broken selected source is an error, never a reason to silently fall back.
|
|
93
|
+
"""
|
|
94
|
+
root = Path.cwd() if repo is None else repo
|
|
95
|
+
source: Path | None = None
|
|
96
|
+
data: object = {}
|
|
97
|
+
if config_path is not None:
|
|
98
|
+
source = config_path
|
|
99
|
+
data = _read_toml(source)
|
|
100
|
+
elif (root / ".docs-drift.toml").exists():
|
|
101
|
+
source = root / ".docs-drift.toml"
|
|
102
|
+
data = _read_toml(source)
|
|
103
|
+
elif (root / "pyproject.toml").exists():
|
|
104
|
+
source = root / "pyproject.toml"
|
|
105
|
+
project = _read_toml(source)
|
|
106
|
+
tool = project.get("tool", {})
|
|
107
|
+
if not isinstance(tool, dict):
|
|
108
|
+
raise ConfigError(f"Invalid configuration in {source}: make [tool] a TOML table.")
|
|
109
|
+
data = tool.get("docs-drift", {})
|
|
110
|
+
try:
|
|
111
|
+
return Config.model_validate(data)
|
|
112
|
+
except ValidationError as exc:
|
|
113
|
+
raise ConfigError(f"Invalid configuration in {source}: {exc}. Correct the listed fields.") from exc
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Read documentation as text and retain its original source locations."""
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""Normalize and link explicit default claims to nearby documented settings."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections import Counter
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from docs_drift.docs.sections import ParsedDoc, is_historical_span
|
|
8
|
+
from docs_drift.ids import make_claim_id
|
|
9
|
+
from docs_drift.models import Claim, ClaimKind, Extraction
|
|
10
|
+
|
|
11
|
+
_DURATION = re.compile(r"^(?P<number>\d+(?:\.\d+)?)\s*(?P<unit>s|sec(?:ond)?s?|m|min(?:ute)?s?|h|hours?)$", re.I)
|
|
12
|
+
_SIZE = re.compile(r"^(?P<number>\d+(?:\.\d+)?)\s*(?P<unit>MiB|MB|KiB|KB|GiB|GB|B)$", re.I)
|
|
13
|
+
_NUMBER = re.compile(r"^[-+]?\d+(?:\.\d+)?$")
|
|
14
|
+
_DEFAULT_PHRASE = re.compile(
|
|
15
|
+
r"\bdefaults?\s+to\s+(?P<value>`[^`]+`|[^,;.)]+)|"
|
|
16
|
+
r"\bdefault\s*(?::|is)\s*(?P<colon>`[^`]+`|[^,;.)]+)|"
|
|
17
|
+
r"\([^()]*?\bdefault\s*:?\s*(?P<paren>`[^`]+`|[^()]+)\)|"
|
|
18
|
+
r"\bif\s+unset,?\s+uses?\s+(?P<unset>`[^`]+`|[^,;.)]+)",
|
|
19
|
+
re.I,
|
|
20
|
+
)
|
|
21
|
+
_HELP_DEFAULT = re.compile(r"\[\s*default\s*:\s*(?P<value>[^\]]+)\]", re.I)
|
|
22
|
+
_LOG_LEVELS = {"trace", "debug", "info", "warn", "warning", "error", "fatal", "critical"}
|
|
23
|
+
MAX_DEFAULT_LINK_DISTANCE = 2
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class NormalizedValue:
|
|
28
|
+
"""Typed comparison form; ``None`` from equals means the units are insufficient."""
|
|
29
|
+
|
|
30
|
+
kind: str
|
|
31
|
+
value: str
|
|
32
|
+
unit: str | None
|
|
33
|
+
raw: str
|
|
34
|
+
|
|
35
|
+
def equals(self, other: "NormalizedValue") -> bool | None:
|
|
36
|
+
if self.kind == "unknown" or other.kind == "unknown":
|
|
37
|
+
return None
|
|
38
|
+
if self.kind != other.kind:
|
|
39
|
+
if {self.kind, other.kind} <= {"number", "duration", "size"}:
|
|
40
|
+
return None
|
|
41
|
+
return False
|
|
42
|
+
if self.kind in {"duration", "size"} and (self.unit is None or other.unit is None):
|
|
43
|
+
return None
|
|
44
|
+
return self.value == other.value and self.unit == other.unit
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def normalize_value(raw: str, *, subject: str = "") -> NormalizedValue: # noqa: PLR0911
|
|
48
|
+
"""Convert only unambiguous literal forms; keep unknown text comparable as text."""
|
|
49
|
+
text = raw.strip()
|
|
50
|
+
if len(text) >= 2 and text[0] == text[-1] and text[0] in {'"', "'", "`"}: # noqa: PLR2004
|
|
51
|
+
text = text[1:-1].strip()
|
|
52
|
+
folded = text.casefold()
|
|
53
|
+
if folded in {"none", "null", "unset", "empty", '""', "''"} or text == "":
|
|
54
|
+
return NormalizedValue("none", "", None, raw)
|
|
55
|
+
boolean = {
|
|
56
|
+
"true": "true",
|
|
57
|
+
"yes": "true",
|
|
58
|
+
"on": "true",
|
|
59
|
+
"enabled": "true",
|
|
60
|
+
"1": "true",
|
|
61
|
+
"false": "false",
|
|
62
|
+
"no": "false",
|
|
63
|
+
"off": "false",
|
|
64
|
+
"disabled": "false",
|
|
65
|
+
"0": "false",
|
|
66
|
+
}
|
|
67
|
+
if folded in boolean and (
|
|
68
|
+
folded not in {"0", "1"}
|
|
69
|
+
or re.search(r"(?:debug|enabled|active|secure|verbose|disabled|feature|flag|bool)", subject, re.I)
|
|
70
|
+
):
|
|
71
|
+
return NormalizedValue("boolean", boolean[folded], None, raw)
|
|
72
|
+
duration = _DURATION.fullmatch(text)
|
|
73
|
+
if duration:
|
|
74
|
+
number = float(duration.group("number"))
|
|
75
|
+
unit = duration.group("unit").casefold()
|
|
76
|
+
multiplier = 1 if unit.startswith("s") else 60 if unit.startswith("m") else 3600
|
|
77
|
+
return NormalizedValue("duration", _number_text(number * multiplier), "s", raw)
|
|
78
|
+
size = _SIZE.fullmatch(text)
|
|
79
|
+
if size:
|
|
80
|
+
unit = size.group("unit")
|
|
81
|
+
factors = {"b": 1, "kb": 1000, "mb": 1000**2, "gb": 1000**3, "kib": 1024, "mib": 1024**2, "gib": 1024**3}
|
|
82
|
+
return NormalizedValue("size", _number_text(float(size.group("number")) * factors[unit.casefold()]), "B", raw)
|
|
83
|
+
if _NUMBER.fullmatch(text):
|
|
84
|
+
number = float(text)
|
|
85
|
+
if number in {0.0, 1.0} and folded in {"0", "0.0", "1", "1.0"}:
|
|
86
|
+
return NormalizedValue("boolean", "true" if number == 1 else "false", None, raw)
|
|
87
|
+
return NormalizedValue("number", _number_text(number), None, raw)
|
|
88
|
+
# Path normalization is limited to path-like strings; it does not resolve or touch disk.
|
|
89
|
+
if text.startswith(("~/", "/", "./", "../")) or "/" in text or "\\" in text:
|
|
90
|
+
path = re.sub(r"/{2,}", "/", text).rstrip("/")
|
|
91
|
+
return NormalizedValue("path", path, None, raw)
|
|
92
|
+
string = (
|
|
93
|
+
text.casefold()
|
|
94
|
+
if (subject and subject.casefold().endswith(("log_level", "loglevel"))) or folded in _LOG_LEVELS
|
|
95
|
+
else text
|
|
96
|
+
)
|
|
97
|
+
return NormalizedValue("string", string, None, raw)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _number_text(number: float) -> str:
|
|
101
|
+
return str(int(number)) if number.is_integer() else format(number, ".12g")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass(frozen=True)
|
|
105
|
+
class DefaultsResult:
|
|
106
|
+
claims: list[Claim]
|
|
107
|
+
debug_notes: list[str]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _default_claim( # noqa: PLR0913, PLR0917
|
|
111
|
+
parsed: ParsedDoc,
|
|
112
|
+
subject: Claim,
|
|
113
|
+
raw_value: str,
|
|
114
|
+
raw_text: str,
|
|
115
|
+
line: int,
|
|
116
|
+
column: int,
|
|
117
|
+
extraction: Extraction,
|
|
118
|
+
occurrence: int,
|
|
119
|
+
) -> Claim:
|
|
120
|
+
normalized = normalize_value(raw_value, subject=subject.value)
|
|
121
|
+
section = parsed.line_to_section[line]
|
|
122
|
+
return Claim(
|
|
123
|
+
claim_id=make_claim_id(
|
|
124
|
+
parsed.doc.path,
|
|
125
|
+
section.heading_path,
|
|
126
|
+
ClaimKind.default_value,
|
|
127
|
+
f"{subject.kind.value}:{subject.value}:{normalized.kind}:{normalized.value}:{normalized.unit}",
|
|
128
|
+
occurrence,
|
|
129
|
+
),
|
|
130
|
+
kind=ClaimKind.default_value,
|
|
131
|
+
raw_text=raw_text.strip() or raw_value,
|
|
132
|
+
value=subject.value,
|
|
133
|
+
qualifier=subject.kind.value,
|
|
134
|
+
expected_default=_display_value(normalized),
|
|
135
|
+
doc_path=parsed.doc.path,
|
|
136
|
+
line=line,
|
|
137
|
+
column=column,
|
|
138
|
+
section_id=section.section_id,
|
|
139
|
+
extraction=extraction,
|
|
140
|
+
extraction_confidence=0.8 if extraction == Extraction.table else 0.55,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _display_value(value: NormalizedValue) -> str:
|
|
145
|
+
if value.kind == "duration":
|
|
146
|
+
return f"{value.value}s"
|
|
147
|
+
if value.kind == "size":
|
|
148
|
+
return f"{value.value}B"
|
|
149
|
+
if value.kind == "none":
|
|
150
|
+
return "none"
|
|
151
|
+
return value.value
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def extract_defaults(parsed: ParsedDoc, claims: list[Claim]) -> DefaultsResult: # noqa: PLR0912, PLR0915
|
|
155
|
+
"""Link prose, table, and help defaults only to a unique nearby subject."""
|
|
156
|
+
subjects = [
|
|
157
|
+
claim for claim in claims if claim.kind in {ClaimKind.cli_flag, ClaimKind.env_var, ClaimKind.config_key}
|
|
158
|
+
]
|
|
159
|
+
found: list[tuple[Claim, str, str, int, int, Extraction]] = []
|
|
160
|
+
notes: list[str] = []
|
|
161
|
+
for subject in subjects:
|
|
162
|
+
if subject.expected_default is not None:
|
|
163
|
+
found.append(
|
|
164
|
+
(subject, subject.expected_default, subject.raw_text, subject.line, subject.column, subject.extraction)
|
|
165
|
+
)
|
|
166
|
+
lines = parsed.lines
|
|
167
|
+
for line_number, line in enumerate(lines, 1):
|
|
168
|
+
section = parsed.line_to_section.get(line_number)
|
|
169
|
+
if section is None or is_historical_span(section, line_number):
|
|
170
|
+
continue
|
|
171
|
+
same_section = [item for item in subjects if item.section_id == section.section_id]
|
|
172
|
+
for match in _DEFAULT_PHRASE.finditer(line):
|
|
173
|
+
raw_value = (
|
|
174
|
+
next((part for part in match.groupdict().values() if part), "").strip().strip("`\"'").rstrip("].")
|
|
175
|
+
)
|
|
176
|
+
sentence = next(
|
|
177
|
+
(
|
|
178
|
+
item
|
|
179
|
+
for item in re.finditer(r"[^.!?]+(?:[.!?](?=\s|$)|$)", line)
|
|
180
|
+
if item.start() <= match.start() <= item.end()
|
|
181
|
+
),
|
|
182
|
+
None,
|
|
183
|
+
)
|
|
184
|
+
start = sentence.start() if sentence else 0
|
|
185
|
+
end = sentence.end() if sentence else len(line)
|
|
186
|
+
nearby = [item for item in same_section if item.line == line_number and start <= item.column - 1 <= end]
|
|
187
|
+
# Fixture and prose docs often introduce a setting in one bullet and state its default in the next.
|
|
188
|
+
# Cross-line linking is allowed only for a unique subject within two lines, keeping ambiguity quiet.
|
|
189
|
+
if not nearby:
|
|
190
|
+
nearby = [item for item in same_section if abs(item.line - line_number) <= MAX_DEFAULT_LINK_DISTANCE]
|
|
191
|
+
if len(nearby) > 1:
|
|
192
|
+
notes.append(f"{parsed.doc.path}:{line_number}: multiple nearby default subjects; skipped")
|
|
193
|
+
continue
|
|
194
|
+
if not raw_value:
|
|
195
|
+
continue
|
|
196
|
+
if not nearby:
|
|
197
|
+
continue
|
|
198
|
+
distances = sorted(
|
|
199
|
+
((abs(item.column - 1 - match.start()), item) for item in nearby), key=lambda pair: pair[0]
|
|
200
|
+
)
|
|
201
|
+
if len(distances) > 1 and distances[0][0] == distances[1][0]:
|
|
202
|
+
notes.append(f"{parsed.doc.path}:{line_number}: equally near default subjects; skipped")
|
|
203
|
+
continue
|
|
204
|
+
subject = distances[0][1]
|
|
205
|
+
found.append((subject, raw_value, match.group(), line_number, match.start() + 1, Extraction.prose_pattern))
|
|
206
|
+
# A port number has no safe subject unless the section documents exactly one port-like name.
|
|
207
|
+
if not line.lstrip().startswith("|") and re.search(
|
|
208
|
+
r"\b(?:listens?\s+on\s+port|localhost|127\.0\.0\.1):?\s*\d+", line, re.I
|
|
209
|
+
):
|
|
210
|
+
port_match = re.search(r"(?:port\s+|localhost:|127\.0\.0\.1:)(\d{2,5})", line, re.I)
|
|
211
|
+
port_subjects = [
|
|
212
|
+
item for item in same_section if "port" in item.value.casefold() or item.value.upper().endswith("_PORT")
|
|
213
|
+
]
|
|
214
|
+
if port_match and len(port_subjects) == 1:
|
|
215
|
+
found.append(
|
|
216
|
+
(
|
|
217
|
+
port_subjects[0],
|
|
218
|
+
port_match.group(1),
|
|
219
|
+
port_match.group(),
|
|
220
|
+
line_number,
|
|
221
|
+
port_match.start() + 1,
|
|
222
|
+
Extraction.prose_pattern,
|
|
223
|
+
)
|
|
224
|
+
)
|
|
225
|
+
elif port_match and len(port_subjects) > 1:
|
|
226
|
+
notes.append(f"{parsed.doc.path}:{line_number}: multiple port subjects; default skipped")
|
|
227
|
+
for match in _HELP_DEFAULT.finditer(line):
|
|
228
|
+
raw_value = match.group("value").strip().strip("`\"'")
|
|
229
|
+
flags = [item for item in same_section if item.kind == ClaimKind.cli_flag and item.line == line_number]
|
|
230
|
+
flag_match = re.search(r"--?[A-Za-z][\w-]*", line)
|
|
231
|
+
if flags and flag_match and raw_value:
|
|
232
|
+
subject = next((item for item in flags if item.value == flag_match.group()), flags[0])
|
|
233
|
+
found.append((subject, raw_value, match.group(), line_number, match.start() + 1, Extraction.code_block))
|
|
234
|
+
counts: Counter[tuple[str, str, str]] = Counter()
|
|
235
|
+
result = []
|
|
236
|
+
unique: set[tuple[str, str, str, str]] = set()
|
|
237
|
+
for subject, raw_value, raw_text, claim_line, column, extraction in found:
|
|
238
|
+
section = parsed.line_to_section.get(claim_line)
|
|
239
|
+
if section is None or is_historical_span(section, claim_line):
|
|
240
|
+
continue
|
|
241
|
+
normalized = normalize_value(raw_value, subject=subject.value)
|
|
242
|
+
identity = (subject.section_id, subject.kind.value, subject.value, _display_value(normalized))
|
|
243
|
+
if identity in unique:
|
|
244
|
+
continue
|
|
245
|
+
unique.add(identity)
|
|
246
|
+
key = (subject.section_id, subject.kind.value, subject.value)
|
|
247
|
+
occurrence = counts[key]
|
|
248
|
+
counts[key] += 1
|
|
249
|
+
result.append(_default_claim(parsed, subject, raw_value, raw_text, claim_line, column, extraction, occurrence))
|
|
250
|
+
return DefaultsResult(result, notes)
|