Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,13 @@ driftcheck --json # machine-readable
driftcheck --fix # auto-fix drifts in documentation files
```

Checks (v0.1.4):
Checks (v0.1.5):
- Rust: `rust-toolchain.toml` `channel` **and** `Cargo.toml` `rust-version` vs `README.md` / `docs/README*.md` / `CONTRIBUTING*.md`
- Minor-aware: `channel = "1.96"` matches docs that say `Rust 1.96.1` (patch differences ignored); a real drift is a different major/minor.
- Node: `package.json` `engines.node` vs README
- Python: `pyproject.toml` `requires-python` vs README
- Go: `go.mod` `go` directive vs README
- Line endings: missing `* text=auto eol=lf` in `.gitattributes` (causes CRLF working-tree drift on Windows `core.autocrlf=true`)
- Extensible: add more toolchain sources in `driftcheck/detector.py`

Inspired by fixing https://github.com/tinyhumansai/openhuman/issues/5781 (6 READMEs drifted).
47 changes: 47 additions & 0 deletions src/driftcheck/detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,10 @@ def find_python_drift(pyproject_text: str, docs: dict[str, str]) -> list[dict]:
)
GO_MOD_RE = re.compile(r'^\s*go\s+(?P<ver>[0-9]+\.[0-9]+)', re.MULTILINE)


EOL_ATTR_RE = re.compile(r'^\s*\*?\s*text\s*=\s*auto', re.MULTILINE)
EOL_LINE_RE = re.compile(r'^\s*\*.*eol\s*=\s*lf', re.MULTILINE)

def parse_go_version_from_gomod(text: str) -> str | None:
m = GO_MOD_RE.search(text)
return m.group("ver") if m else None
Expand All @@ -182,6 +186,32 @@ def find_go_drift(gomod_text: str, docs: dict[str, str]) -> list[dict]:
return drifts



def find_lineending_drift(root: Path) -> list[dict]:
"""Detect missing CRLF-safe .gitattributes.

A repo that ships text source but lacks `* text=auto eol=lf` in
.gitattributes can check out with CRLF working-tree bytes on Windows
(core.autocrlf=true) while the index stores LF -- silently breaking
byte-exact checks. Returns a drift if .gitattributes is absent or does
not normalize line endings.
"""
ga = root / ".gitattributes"
if not ga.exists():
return [{
"file": ".gitattributes",
"kind": "lineending",
"detail": "missing .gitattributes with `* text=auto eol=lf`",
}]
text = ga.read_text(encoding="utf-8", errors="replace")
if not (EOL_ATTR_RE.search(text) and EOL_LINE_RE.search(text)):
return [{
"file": ".gitattributes",
"kind": "lineending",
"detail": ".gitattributes does not set `* text=auto eol=lf`",
}]
return []

def apply_fixes(root: Path, result: dict) -> list[str]:
"""Apply fixes for all detected drifts. Returns list of fixed file paths."""
fixed = []
Expand Down Expand Up @@ -238,6 +268,21 @@ def repl(match):
if fix_in_file(fpath, d["doc_version"], d["gomod_version"], [GO_RE]):
fixed.append(d["file"])


# Line-ending drifts: ensure .gitattributes normalizes CRLF
for d in result.get("lineending_drifts", []):
ga = root / d["file"]
needed = "* text=auto eol=lf\n"
if not ga.exists():
ga.write_text("# Normalize line endings so working-tree bytes match the index on every platform\n" + needed)
fixed.append(d["file"])
else:
text = ga.read_text(encoding="utf-8", errors="replace")
if "text=auto eol=lf" not in text:
text = text.rstrip("\n") + "\n\n# Normalize line endings (added by driftcheck --fix)\n" + needed
ga.write_text(text, encoding="utf-8")
fixed.append(d["file"])

return fixed


Expand Down Expand Up @@ -266,6 +311,7 @@ def scan_repo(root: Path = Path(".")) -> dict:
node_drifts = find_node_drift(package_text, docs)
python_drifts = find_python_drift(pyproject_text, docs)
go_drifts = find_go_drift(gomod_text, docs)
lineending_drifts = find_lineending_drift(root)

return {
"toolchain_version": parse_toolchain_version(toolchain_text),
Expand All @@ -278,4 +324,5 @@ def scan_repo(root: Path = Path(".")) -> dict:
"node_drifts": node_drifts,
"python_drifts": python_drifts,
"go_drifts": go_drifts,
"lineending_drifts": lineending_drifts,
}
38 changes: 37 additions & 1 deletion tests/test_detector.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""TDD for driftcheck — RED first, then GREEN."""

from driftcheck.detector import find_rust_drift
from driftcheck.detector import find_rust_drift, scan_repo, apply_fixes

def test_no_drift():
toolchain = 'channel = "1.96.1"'
Expand Down Expand Up @@ -150,3 +150,39 @@ def test_apply_fixes_idempotent_when_no_drift():
(root / "README.md").write_text("Rust 1.96.1")
fixed = apply_fixes(root, {"drifts": [], "node_drifts": [], "python_drifts": [], "go_drifts": []})
assert fixed == []


def test_lineending_drift_detected_when_missing():
from pathlib import Path
import tempfile
with tempfile.TemporaryDirectory() as td:
root = Path(td)
(root / "README.md").write_text("project")
r = scan_repo(root)
assert r["lineending_drifts"], "expected a lineending drift when .gitattributes is absent"
assert r["lineending_drifts"][0]["file"] == ".gitattributes"


def test_lineending_drift_apply_fix_creates_gitattributes():
from pathlib import Path
import tempfile
with tempfile.TemporaryDirectory() as td:
root = Path(td)
(root / "README.md").write_text("project")
r = scan_repo(root)
fixed = apply_fixes(root, r)
assert ".gitattributes" in fixed
assert "text=auto eol=lf" in (root / ".gitattributes").read_text()
# re-scan should be clean
assert scan_repo(root)["lineending_drifts"] == []


def test_lineending_drift_absent_when_present():
from pathlib import Path
import tempfile
with tempfile.TemporaryDirectory() as td:
root = Path(td)
(root / "README.md").write_text("project")
(root / ".gitattributes").write_text("* text=auto eol=lf\n")
r = scan_repo(root)
assert r["lineending_drifts"] == []
Loading