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