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
docs_drift/fix/edits.py
ADDED
|
@@ -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()
|
docs_drift/fix/model.py
ADDED
|
@@ -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
|
docs_drift/fix/rules.py
ADDED
|
@@ -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
|
docs_drift/git_safety.py
ADDED
|
@@ -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;
|