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,124 @@
1
+ """Exact-span edits, stale-hash checks, byte-preserving writes and unified patches."""
2
+
3
+ import codecs
4
+ import difflib
5
+ import hashlib
6
+ import os
7
+ import tempfile
8
+ from pathlib import Path
9
+
10
+ from pydantic import Field
11
+
12
+ from docs_drift.models import DecidedBy, PipelineModel, RepoPath
13
+
14
+
15
+ class StaleDocumentError(ValueError):
16
+ """The caller must scan again and obtain fresh spans before writing."""
17
+
18
+
19
+ class TextEdit(PipelineModel):
20
+ file: RepoPath
21
+ line: int = Field(ge=1)
22
+ column: int = Field(ge=1)
23
+ end_line: int = Field(ge=1)
24
+ end_column: int = Field(ge=1)
25
+ old_text: str
26
+ new_text: str
27
+ reason: str
28
+ claim_id: str
29
+ decided_by: DecidedBy = DecidedBy.rules
30
+ replayed: bool = False
31
+
32
+
33
+ class FixPlan(PipelineModel):
34
+ edits: list[TextEdit] = Field(default_factory=list)
35
+ suggestions: list[str] = Field(default_factory=list)
36
+ hashes: dict[str, str] = Field(default_factory=dict)
37
+
38
+
39
+ def safe_path(repo: Path, relative: str) -> Path:
40
+ target = (repo / relative).resolve()
41
+ if not target.is_relative_to(repo.resolve()):
42
+ raise ValueError(f"Refusing to edit {relative}: it resolves outside the repository.")
43
+ return target
44
+
45
+
46
+ def decode(raw: bytes) -> tuple[str, str, bytes]:
47
+ for bom, encoding in (
48
+ (codecs.BOM_UTF8, "utf-8"),
49
+ (codecs.BOM_UTF16_LE, "utf-16-le"),
50
+ (codecs.BOM_UTF16_BE, "utf-16-be"),
51
+ ):
52
+ if raw.startswith(bom):
53
+ return raw[len(bom) :].decode(encoding), encoding, bom
54
+ return raw.decode("utf-8"), "utf-8", b""
55
+
56
+
57
+ def offsets(text: str, edit: TextEdit) -> tuple[int, int]:
58
+ lines = text.splitlines(keepends=True)
59
+ if edit.line > len(lines) or edit.end_line > len(lines):
60
+ raise StaleDocumentError(f"Line spans changed in {edit.file}; scan again.")
61
+ start = sum(map(len, lines[: edit.line - 1])) + edit.column - 1
62
+ end = sum(map(len, lines[: edit.end_line - 1])) + edit.end_column - 1
63
+ if text[start:end] != edit.old_text:
64
+ raise StaleDocumentError(f"The exact edit span changed in {edit.file}; scan again.")
65
+ return start, end
66
+
67
+
68
+ def render_file(repo: Path, plan: FixPlan, relative: str) -> tuple[bytes, bytes]:
69
+ path = safe_path(repo, relative)
70
+ original = path.read_bytes()
71
+ if hashlib.sha256(original).hexdigest() != plan.hashes[relative]:
72
+ raise StaleDocumentError(f"{relative} changed since the scan; scan again before applying edits.")
73
+ text, encoding, bom = decode(original)
74
+ selected = [edit for edit in plan.edits if edit.file == relative]
75
+ spans = sorted([(offsets(text, edit), edit) for edit in selected], key=lambda pair: pair[0], reverse=True)
76
+ last_start = len(text) + 1
77
+ for (start, end), edit in spans:
78
+ if end > last_start:
79
+ raise ValueError(f"Overlapping edits in {relative}; review conflicting suggestions manually.")
80
+ text = text[:start] + edit.new_text + text[end:]
81
+ last_start = start
82
+ return original, bom + text.encode(encoding)
83
+
84
+
85
+ def unified_patch(repo: Path, plan: FixPlan) -> str:
86
+ parts: list[str] = []
87
+ for relative in sorted({edit.file for edit in plan.edits}):
88
+ original, modified = render_file(repo, plan, relative)
89
+ before_text, encoding, bom = decode(original)
90
+ after_text = decode(modified)[0]
91
+ if encoding != "utf-8":
92
+ raise ValueError("Unified patches require UTF-8 documents; use a reviewed exact-span write for UTF-16.")
93
+ prefix = "\ufeff" if bom == codecs.BOM_UTF8 else ""
94
+ before = prefix + before_text
95
+ after = prefix + after_text
96
+ for line in difflib.unified_diff(
97
+ before.splitlines(keepends=True),
98
+ after.splitlines(keepends=True),
99
+ fromfile=f"a/{relative}",
100
+ tofile=f"b/{relative}",
101
+ ):
102
+ parts.append(line)
103
+ if not line.endswith("\n"):
104
+ parts.append("\n\\n")
105
+ return "".join(parts)
106
+
107
+
108
+ def apply_file(repo: Path, plan: FixPlan, relative: str) -> None:
109
+ original, modified = render_file(repo, plan, relative)
110
+ path = safe_path(repo, relative)
111
+ mode = path.stat().st_mode
112
+ temporary: Path | None = None
113
+ try:
114
+ with tempfile.NamedTemporaryFile(dir=path.parent, prefix=".docs-drift-", delete=False) as stream:
115
+ temporary = Path(stream.name)
116
+ stream.write(modified)
117
+ # Check again immediately before replacing; a changed hash requires another scan.
118
+ if path.read_bytes() != original:
119
+ raise StaleDocumentError(f"{relative} changed during review; scan again.")
120
+ os.chmod(temporary, mode)
121
+ temporary.replace(path)
122
+ finally:
123
+ if temporary and temporary.exists():
124
+ temporary.unlink()
@@ -0,0 +1,139 @@
1
+ """Optional minimal sentence rewrites, accepted only after static self-checks."""
2
+
3
+ import asyncio
4
+ import hashlib
5
+ import re
6
+ import time
7
+
8
+ from docs_drift.docs.defaults import extract_defaults
9
+ from docs_drift.docs.extract_inline import extract_claims
10
+ from docs_drift.docs.sections import parse_document
11
+ from docs_drift.fix.edits import FixPlan, TextEdit, decode, safe_path
12
+ from docs_drift.index.base import CodeIndex
13
+ from docs_drift.llm.client import ClientError, RecordedClient
14
+ from docs_drift.llm.pricing import DEFAULT_MODEL, estimate_request
15
+ from docs_drift.llm.prompts import build_request
16
+ from docs_drift.llm.retrieve import retrieve
17
+ from docs_drift.llm.types import Candidate, ModelClient
18
+ from docs_drift.llm.verify import Budget, validate_evidence
19
+ from docs_drift.match import match_claims
20
+ from docs_drift.models import DecidedBy, DocFile, DocFormat, Finding, ScanResult, Verdict
21
+
22
+
23
+ def sentence_span(line: str, column: int) -> tuple[int, str]:
24
+ prefix = re.match(r"\s*(?:(?:[-*+>]|\d+[.)])\s+)?", line)
25
+ offset = prefix.end() if prefix else 0
26
+ for match in re.finditer(r".+?(?:[.!?](?=\s|$)|$)", line[offset:]):
27
+ start = offset + match.start()
28
+ end = offset + match.end()
29
+ if start <= column - 1 < end:
30
+ sentence = match.group().strip()
31
+ return start + len(match.group()) - len(match.group().lstrip()) + 1, sentence
32
+ return offset + 1, line[offset:].strip()
33
+
34
+
35
+ def valid_rewrite(original: str, rewritten: str, finding: Finding, index: CodeIndex, result: ScanResult) -> bool:
36
+ if not rewritten.strip() or "\n" in rewritten or "\r" in rewritten:
37
+ return False
38
+ if len(re.split(r"(?<=[.!?])\s+", rewritten)) > 1:
39
+ return False
40
+ if finding.verdict == Verdict.renamed and finding.suggestion and finding.suggestion not in rewritten:
41
+ return False
42
+ known = {name for symbol in index.symbols for name in (symbol.name, symbol.qualified_name, *symbol.aliases)}
43
+ identifiers = re.compile(
44
+ r"\b[A-Za-z]\w*_[A-Za-z]\w*\b|\b[A-Za-z]\w*(?:\.[A-Za-z]\w*)+\b|"
45
+ r"\b[A-Za-z]\w*(?=\s*\()|\b[A-Z][a-z0-9]+(?:[A-Z][A-Za-z0-9]*)+\b"
46
+ )
47
+ introduced = set(identifiers.findall(rewritten)) - set(identifiers.findall(original))
48
+ if any(name not in known for name in introduced):
49
+ return False
50
+ doc = DocFile(
51
+ path=finding.claim.doc_path,
52
+ content_hash=hashlib.sha256(rewritten.encode()).hexdigest(),
53
+ format=DocFormat.markdown,
54
+ )
55
+ parsed = parse_document(doc, rewritten)
56
+ extracted = extract_claims(parsed).claims
57
+ subjects = [claim for claim in extracted if claim.kind.value != "default_value"]
58
+ defaults = extract_defaults(parsed, subjects).claims
59
+ checked = match_claims([*subjects, *defaults], index, result.repo_path)
60
+ # New uncertain references are rejected as well as proven drift: a rewrite must earn acceptance.
61
+ return all(item.verdict == Verdict.ok for item in checked)
62
+
63
+
64
+ async def add_model_fixes(
65
+ result: ScanResult, index: CodeIndex, plan: FixPlan, client: ModelClient | None = None
66
+ ) -> FixPlan:
67
+ config = result.config_used.llm
68
+ selected_client = client or RecordedClient(config)
69
+ budget = Budget(max(0, config.budget - result.llm_usage.get("cost", 0)))
70
+ semaphore = asyncio.Semaphore(config.concurrency)
71
+ fixed = {edit.claim_id for edit in plan.edits}
72
+ occupied = {(edit.file, edit.line) for edit in plan.edits}
73
+ remaining = [
74
+ item
75
+ for item in result.findings
76
+ if item.is_drift and item.claim.claim_id not in fixed and (item.claim.doc_path, item.claim.line) not in occupied
77
+ ][: config.max_claims]
78
+
79
+ async def propose(finding: Finding) -> TextEdit | None:
80
+ sources = {item.file for item in finding.evidence if item.file in index.by_file}
81
+ if finding.matched_symbol:
82
+ sources.add(finding.matched_symbol.file)
83
+ if not sources:
84
+ return None
85
+ lines = decode(safe_path(result.repo_path, finding.claim.doc_path).read_bytes())[0].splitlines()
86
+ column, original = sentence_span(lines[finding.claim.line - 1], finding.claim.column)
87
+ candidate = Candidate(claim=finding.claim, source_files=sorted(sources))
88
+ snippets = retrieve(result.repo_path, candidate, result.symbols, config.redact_paths)
89
+ if not snippets:
90
+ return None
91
+ data = (
92
+ f"Sentence: {original}\nFinding: {finding.verdict.value}\n"
93
+ f"Evidence-based correction: {finding.suggestion or ''}"
94
+ )
95
+ request = build_request(config.model or DEFAULT_MODEL, data, snippets, rewrite=True)
96
+ async with semaphore:
97
+ estimate = estimate_request(request)
98
+ if not await budget.reserve(estimate):
99
+ plan.suggestions.append(f"{finding.claim.doc_path}:{finding.claim.line}: budget reached")
100
+ return None
101
+ started = time.perf_counter()
102
+ response = await selected_client.complete(request)
103
+ await budget.settle(estimate, response.usage.cost)
104
+ for key in ("input_tokens", "output_tokens", "cache_read_tokens", "cache_write_tokens"):
105
+ result.llm_usage[key] = result.llm_usage.get(key, 0) + float(getattr(response.usage, key))
106
+ result.llm_usage["claims"] = result.llm_usage.get("claims", 0) + 1
107
+ result.llm_usage["seconds"] = result.llm_usage.get("seconds", 0) + time.perf_counter() - started
108
+ rewritten = response.result.suggested_doc_text
109
+ if (
110
+ not rewritten
111
+ or not validate_evidence(response, snippets)
112
+ or not valid_rewrite(original, rewritten, finding, index, result)
113
+ ):
114
+ return None
115
+ return TextEdit(
116
+ file=finding.claim.doc_path,
117
+ line=finding.claim.line,
118
+ column=column,
119
+ end_line=finding.claim.line,
120
+ end_column=column + len(original),
121
+ old_text=original,
122
+ new_text=rewritten,
123
+ reason="Model sentence rewrite passed static self-checks.",
124
+ claim_id=finding.claim.claim_id,
125
+ decided_by=DecidedBy.llm,
126
+ replayed=response.replayed,
127
+ )
128
+
129
+ try:
130
+ async with asyncio.timeout(config.timeout):
131
+ proposed = await asyncio.gather(*(propose(finding) for finding in remaining))
132
+ except TimeoutError:
133
+ plan.suggestions.append("Model fixes exceeded their total timeout; review remaining sentences manually.")
134
+ return plan
135
+ except ClientError:
136
+ raise
137
+ plan.edits.extend(edit for edit in proposed if edit is not None)
138
+ result.llm_usage["cost"] = result.llm_usage.get("cost", 0) + budget.spent
139
+ return plan
@@ -0,0 +1,232 @@
1
+ """Certain replacements only, within the scanner's original literal spans."""
2
+
3
+ import hashlib
4
+ import re
5
+ from decimal import Decimal
6
+ from pathlib import Path
7
+
8
+ from docs_drift.docs.defaults import normalize_value
9
+ from docs_drift.docs.sections import is_historical_span
10
+ from docs_drift.fix.edits import FixPlan, StaleDocumentError, TextEdit, decode, safe_path
11
+ from docs_drift.history import GitHistory, _run
12
+ from docs_drift.models import Claim, ClaimKind, Finding, ScanResult, Verdict
13
+
14
+ _WORD = re.compile(r"'[^']*'|\"[^\"]*\"|\S+")
15
+ MIN_RENAME_FIX_CONFIDENCE = 0.9
16
+ _UNITS = {
17
+ "ms": Decimal(".001"),
18
+ "s": Decimal(1),
19
+ "sec": Decimal(1),
20
+ "second": Decimal(1),
21
+ "seconds": Decimal(1),
22
+ "m": Decimal(60),
23
+ "min": Decimal(60),
24
+ "minute": Decimal(60),
25
+ "minutes": Decimal(60),
26
+ "h": Decimal(3600),
27
+ "hour": Decimal(3600),
28
+ "hours": Decimal(3600),
29
+ "b": Decimal(1),
30
+ "kb": Decimal(1000),
31
+ "mb": Decimal(1000000),
32
+ "gb": Decimal(1000000000),
33
+ "kib": Decimal(1024),
34
+ "mib": Decimal(1048576),
35
+ "gib": Decimal(1073741824),
36
+ }
37
+ _UNIT_VALUE = re.compile(
38
+ r"(?<!\w)(-?\d+(?:\.\d+)?)([\s`\"']*)(milliseconds?|seconds?|minutes?|hours?|MiB|GiB|KiB|MB|GB|KB|ms|sec|min|s|m|h|B)\b",
39
+ re.I,
40
+ )
41
+
42
+
43
+ def _edit(claim: Claim, line: str, old: str, new: str, reason: str) -> TextEdit | None:
44
+ # Never fall back to a file-wide substitution: a unique exact span on this line is required.
45
+ matches = list(re.finditer(rf"(?<![\w-]){re.escape(old)}(?![\w-])", line))
46
+ if len(matches) != 1 or old == new:
47
+ return None
48
+ match = matches[0]
49
+ return TextEdit(
50
+ file=claim.doc_path,
51
+ line=claim.line,
52
+ column=match.start() + 1,
53
+ end_line=claim.line,
54
+ end_column=match.end() + 1,
55
+ old_text=old,
56
+ new_text=new,
57
+ reason=reason,
58
+ claim_id=claim.claim_id,
59
+ )
60
+
61
+
62
+ def _default_edit(finding: Finding, line: str) -> TextEdit | None:
63
+ expected = finding.claim.expected_default
64
+ actual = finding.matched_symbol.default if finding.matched_symbol else finding.suggestion
65
+ if expected is None or actual is None:
66
+ return None
67
+ old = expected.strip("`\"'")
68
+ new = actual.strip("`\"'")
69
+ expected_value = normalize_value(expected, subject=finding.claim.value)
70
+ actual_value = normalize_value(actual, subject=finding.claim.value)
71
+ if expected_value.kind in {"duration", "size"}:
72
+ if actual_value.kind != expected_value.kind:
73
+ return None
74
+ unit_matches = [
75
+ match
76
+ for match in _UNIT_VALUE.finditer(line)
77
+ if normalize_value(match.group(1) + match.group(3), subject=finding.claim.value).equals(expected_value)
78
+ is True
79
+ ]
80
+ if len(unit_matches) != 1:
81
+ return None
82
+ match = unit_matches[0]
83
+ factor = _UNITS.get(match.group(3).casefold())
84
+ if factor is None:
85
+ return None
86
+ old = match.group(1)
87
+ new = format(Decimal(actual_value.value) / factor, "f").rstrip("0").rstrip(".")
88
+ if "." not in format(Decimal(actual_value.value) / factor, "f"):
89
+ new = str(int(Decimal(actual_value.value) / factor))
90
+ return TextEdit(
91
+ file=finding.claim.doc_path,
92
+ line=finding.claim.line,
93
+ column=match.start(1) + 1,
94
+ end_line=finding.claim.line,
95
+ end_column=match.end(1) + 1,
96
+ old_text=old,
97
+ new_text=new,
98
+ reason="Update the default while preserving its documented unit.",
99
+ claim_id=finding.claim.claim_id,
100
+ )
101
+ # The verdict already established comparable defaults. Keep the documented unit and quote syntax.
102
+ number = re.fullmatch(r"(-?\d+(?:\.\d+)?)(?:s|B)?", old)
103
+ if number:
104
+ old = number.group(1)
105
+ return _edit(finding.claim, line, old, new, "Update the documented default.")
106
+
107
+
108
+ def _removed_flag_shape(repo: Path, flag: str) -> tuple[bool, str] | None:
109
+ history = GitHistory(repo).search(flag)
110
+ if not history.commit:
111
+ return None
112
+ patch = _run(repo, ["show", "--no-ext-diff", "--format=", history.commit.sha])
113
+ if patch is None:
114
+ return None
115
+ removed = [line[1:] for line in patch.stdout.splitlines() if line.startswith("-") and flag in line]
116
+ declarations = [line for line in removed if re.search(r"(?:add_argument|\.option|\.Option)\(", line)]
117
+ if len(declarations) != 1:
118
+ return None
119
+ declaration = declarations[0]
120
+ boolean = "store_true" in declaration or "store_false" in declaration
121
+ if ".option(" in declaration and "add_argument" not in declaration:
122
+ flag_string = re.search(r"\.option\(\s*['\"]([^'\"]+)", declaration)
123
+ boolean = flag_string is not None and not re.search(r"[<\[]", flag_string.group(1))
124
+ return boolean, history.commit.short_sha
125
+
126
+
127
+ def _command_edit(repo: Path, finding: Finding, line: str) -> tuple[TextEdit | None, str | None]:
128
+ unknown = set(re.findall(r"--[\w-]+", " ".join(finding.reasons)))
129
+ if len(unknown) != 1:
130
+ return None, None
131
+ flag = next(iter(unknown))
132
+ shape = _removed_flag_shape(repo, flag)
133
+ if shape is None:
134
+ return None, None
135
+ boolean, sha = shape
136
+ tokens = list(_WORD.finditer(line))
137
+ flags = [index for index, token in enumerate(tokens) if token.group().split("=", 1)[0] == flag]
138
+ if len(flags) != 1:
139
+ return None, None
140
+ index = flags[0]
141
+ start, end = tokens[index].span()
142
+ if not boolean and "=" not in tokens[index].group():
143
+ if index + 1 == len(tokens) or tokens[index + 1].group().startswith("-"):
144
+ return None, None
145
+ end = tokens[index + 1].end()
146
+ if end < len(line) and line[end] == " ":
147
+ end += 1
148
+ elif start and line[start - 1] == " ":
149
+ start -= 1
150
+ edit = TextEdit(
151
+ file=finding.claim.doc_path,
152
+ line=finding.claim.line,
153
+ column=start + 1,
154
+ end_line=finding.claim.line,
155
+ end_column=end + 1,
156
+ old_text=line[start:end],
157
+ new_text="",
158
+ reason=f"Remove {flag}, deleted in {sha}.",
159
+ claim_id=finding.claim.claim_id,
160
+ )
161
+ note = f"{flag} was removed in {sha}; check whether this example should change more than this."
162
+ return edit, note
163
+
164
+
165
+ def plan_rule_fixes(result: ScanResult, *, contents: dict[str, str] | None = None) -> FixPlan: # noqa: PLR0912
166
+ plan = FixPlan(hashes={doc.path: doc.content_hash for doc in result.docs})
167
+ texts: dict[str, list[str]] = {}
168
+ for doc in result.docs:
169
+ raw = (
170
+ contents[doc.path].encode("utf-8")
171
+ if contents and doc.path in contents
172
+ else safe_path(result.docs_repo_path or result.repo_path, doc.path).read_bytes()
173
+ )
174
+ if hashlib.sha256(raw).hexdigest() != doc.content_hash:
175
+ raise StaleDocumentError(f"{doc.path} changed since scan; scan again.")
176
+ try:
177
+ texts[doc.path] = decode(raw)[0].splitlines()
178
+ except UnicodeError:
179
+ plan.suggestions.append(f"{doc.path}: encoding is not safely editable; review manually.")
180
+ for finding in result.findings:
181
+ if not finding.is_drift or finding.claim.doc_path not in texts:
182
+ continue
183
+ claim = finding.claim
184
+ line = texts[claim.doc_path][claim.line - 1]
185
+ edit = None
186
+ if (
187
+ finding.verdict == Verdict.renamed
188
+ and finding.suggestion
189
+ and finding.confidence >= MIN_RENAME_FIX_CONFIDENCE
190
+ ):
191
+ replacement = finding.suggestion
192
+ old = claim.value
193
+ if claim.kind == ClaimKind.endpoint:
194
+ old = claim.raw_text.strip("`").split(maxsplit=1)[-1]
195
+ replacement = (
196
+ finding.matched_symbol.raw_path
197
+ if finding.matched_symbol and finding.matched_symbol.raw_path
198
+ else replacement.split(maxsplit=1)[-1]
199
+ )
200
+ edit = _edit(claim, line, old, replacement, "Replace a confirmed renamed identifier.")
201
+ elif finding.verdict == Verdict.default_changed:
202
+ edit = _default_edit(finding, line)
203
+ elif finding.verdict == Verdict.invalid_command:
204
+ edit, note = _command_edit(result.repo_path, finding, line)
205
+ if note:
206
+ plan.suggestions.append(note)
207
+ if edit:
208
+ section = next((item for item in result.sections if item.section_id == claim.section_id), None)
209
+ if section and is_historical_span(section, claim.line):
210
+ continue
211
+ plan.edits.append(edit)
212
+ # Same-section duplicates retain exact locations; historical locations are excluded explicitly.
213
+ for location in claim.locations:
214
+ if location.line == claim.line or (section and is_historical_span(section, location.line)):
215
+ continue
216
+ duplicate = _edit(
217
+ claim.model_copy(update={"line": location.line}),
218
+ texts[claim.doc_path][location.line - 1],
219
+ edit.old_text,
220
+ edit.new_text,
221
+ edit.reason,
222
+ )
223
+ if duplicate:
224
+ plan.edits.append(duplicate)
225
+ else:
226
+ plan.suggestions.append(
227
+ f"{claim.doc_path}:{claim.line}: no certain rule edit for {claim.value!r}; "
228
+ "remove the stale statement or mark it deprecated after review."
229
+ )
230
+ unique = {(edit.file, edit.line, edit.column, edit.end_column, edit.new_text): edit for edit in plan.edits}
231
+ plan.edits = list(unique.values())
232
+ return plan
@@ -0,0 +1,35 @@
1
+ """Git conversion options that prevent repository-defined filter execution."""
2
+
3
+ import os
4
+ import subprocess
5
+ from pathlib import Path
6
+
7
+ from docs_drift.history import _run
8
+
9
+
10
+ def disabled_filters(repo: Path) -> list[str]:
11
+ keys = _run(repo, ["config", "--name-only", "--list"])
12
+ if keys is None:
13
+ raise ValueError("Cannot inspect repository filter configuration safely.")
14
+ names = {key.rsplit(".", 1)[0] for key in keys.stdout.splitlines() if key.startswith("filter.")}
15
+ options = []
16
+ for name in sorted(names):
17
+ options.extend(
18
+ ["-c", name + ".clean=", "-c", name + ".smudge=", "-c", name + ".process=", "-c", name + ".required=false"]
19
+ )
20
+ return options
21
+
22
+
23
+ def checkout_options(repo: Path) -> list[str]:
24
+ """Read conversion configuration without executing filters, hooks or target programs."""
25
+ env = {key: value for key, value in os.environ.items() if not key.upper().startswith("GIT_")}
26
+ value = subprocess.run(
27
+ ["git", "-C", str(repo), "config", "--get", "core.autocrlf"],
28
+ capture_output=True,
29
+ text=True,
30
+ timeout=3,
31
+ check=False,
32
+ env=env,
33
+ ).stdout.strip()
34
+ options = ["-c", "core.autocrlf=" + (value if value in {"true", "false", "input"} else "false")]
35
+ return [*options, *disabled_filters(repo)]
@@ -0,0 +1,6 @@
1
+ // Reference query for consumers importing the exported evidence graph into Cypher.
2
+ // The built-in viewer runs locally in Cytoscape; no graph database is required.
3
+ MATCH path = (section:Section)-[:CONTAINS]->(claim:Claim)-[:REFERS_TO]->(symbol:Symbol)
4
+ -[:DEFINED_IN]->(file:File)-[:CHANGED_BY]->(commit:Commit)
5
+ WHERE claim.id = $claim_id
6
+ RETURN path;