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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ driftcheck --json # machine-readable
driftcheck --fix # auto-fix drifts in documentation files
```

Checks (v0.1.8):
Checks (v0.1.9):
- Docker: `Dockerfile` `FROM <image>:<tag>` vs `README.md` / `docs/README*.md` / `CONTRIBUTING*.md` — handles variant tags (`24` matches `24-slim`, `24-alpine`), multi-stage builds (`FROM golang:1.23 AS builder` → `FROM alpine:3.21`)
- Java/Gradle: `build.gradle` `sourceCompatibility`, `jvmTarget`, `JavaVersion.VERSION_*` vs README mentions
- 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
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"

[project]
name = "driftcheck"
version = "0.1.8"
description = "Detect version drift between docs and toolchain files (README vs rust-toolchain.toml, package.json, etc.)"
version = "0.1.9"
description = "Detect version drift between docs and toolchain files (README vs Dockerfile, build.gradle, rust-toolchain.toml, package.json, etc.)"
readme = "README.md"
requires-python = ">=3.10"
license = { text = "MIT" }
Expand Down
14 changes: 10 additions & 4 deletions src/driftcheck/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ def main(argv=None) -> int:

if args.as_json:
print(json.dumps(result, indent=2))
has_blocking = (result.get("drifts") or result.get("rust_drifts") or result.get("node_drifts") or result.get("python_drifts") or result.get("go_drifts") or result.get("count_drifts") or result.get("actions_drifts") or result.get("lineending_drifts"))
has_blocking = (result.get("drifts") or result.get("rust_drifts") or result.get("node_drifts") or result.get("python_drifts") or result.get("go_drifts") or result.get("count_drifts") or result.get("actions_drifts") or result.get("lineending_drifts") or result.get("docker_drifts") or result.get("java_drifts"))
return 1 if has_blocking else 0

drifts = result.get("drifts", [])
Expand All @@ -35,17 +35,19 @@ def main(argv=None) -> int:
actions_drifts = result.get("actions_drifts", [])
lineending_drifts = result.get("lineending_drifts", [])
external_resource_drifts = result.get("external_resource_drifts", [])
docker_drifts = result.get("docker_drifts", [])
java_drifts = result.get("java_drifts", [])
tv = result.get("toolchain_version")
cv = result.get("cargo_rust_version")
nv = result.get("package_node")
pv = result.get("pyproject_python")
gv = result.get("gomod_version")

if not tv and not cv and not nv and not pv and not gv and not count_drifts and not actions_drifts and not lineending_drifts and not external_resource_drifts:
if not tv and not cv and not nv and not pv and not gv and not count_drifts and not actions_drifts and not lineending_drifts and not external_resource_drifts and not docker_drifts and not java_drifts:
print("driftcheck: no toolchain version found")
return 0

if not drifts and not rust_drifts and not node_drifts and not python_drifts and not go_drifts and not count_drifts and not actions_drifts and not lineending_drifts:
if not drifts and not rust_drifts and not node_drifts and not python_drifts and not go_drifts and not count_drifts and not actions_drifts and not lineending_drifts and not docker_drifts and not java_drifts:
parts = []
if tv: parts.append(f"Rust {tv}")
if cv: parts.append(f"Rust(Cargo) {cv}")
Expand All @@ -60,7 +62,7 @@ def main(argv=None) -> int:
return 0
print(base)
return 0

for d in drifts:
print(f"driftcheck: {d['file']}: Rust {d['doc_version']} → should be {d['toolchain_version']}")
for d in rust_drifts:
Expand All @@ -80,6 +82,10 @@ def main(argv=None) -> int:
print(f"driftcheck: {d['file']}: {d['detail']}")
for d in external_resource_drifts:
print(f"driftcheck: info: {d['file']}: {d['detail']} ({d['url']})")
for d in docker_drifts:
print(f"driftcheck: {d['file']}: {d['doc_image']} → should be {d['dockerfile_image']} (Dockerfile)")
for d in java_drifts:
print(f"driftcheck: {d['file']}: Java {d['doc_version']} → should be {d['gradle_version']} (build.gradle)")
return 1

if __name__ == "__main__":
Expand Down
124 changes: 124 additions & 0 deletions src/driftcheck/detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,112 @@ def find_external_resource_drift(root: Path) -> list[dict]:
break # one per file
return drifts

# ---------------------------------------------------------------------------
# Docker drift: Dockerfile FROM <image>:<tag> vs README mentions
# ---------------------------------------------------------------------------
DOCKER_FROM_RE = re.compile(r'^FROM\s+(?:--\S+\s+)?(?P<image>[\w.\-/]+):(?P<tag>[\w.\-]+)', re.MULTILINE | re.I)
DOCKER_TAG_RE = re.compile(r'(?:Docker|image)\s+(?P<image>[\w.\-]+):(?P<tag>[\w.\-]+)|(?:Docker|image)\s+(?:version|tag)?\s+(?P<tag2>[\d.]+[\w.\-]*)', re.I)

def parse_dockerfile_from(text: str) -> dict[str, str]:
"""Return {image: tag} map of FROM instructions in a Dockerfile."""
result = {}
for m in DOCKER_FROM_RE.finditer(text):
result[m.group("image").lower()] = m.group("tag")
return result

def find_docker_drift(dockerfiles: dict[str, str], docs: dict[str, str]) -> list[dict]:
"""Detect drift between Dockerfile FROM tags and README mentions.

Scans docs for patterns like 'node:24' or 'Docker node 22' and compares
to the actual FROM tag in the Dockerfile. Returns drifts where the doc
mentions a different version than what the Dockerfile pins.
"""
# Collect all FROM tags across dockerfiles
all_from: dict[str, str] = {}
for fname, content in dockerfiles.items():
for image, tag in parse_dockerfile_from(content).items():
all_from[image] = tag

if not all_from:
return []

def tags_match(doc_tag: str, from_tag: str) -> bool:
"""Return True when tags are equivalent (handles '24' vs '24-slim')."""
if doc_tag == from_tag:
return True
# '24' matches '24-slim', '24-alpine', etc.
if from_tag.startswith(doc_tag + "-"):
return True
# '24-slim' matches '24'
if doc_tag.startswith(from_tag + "-"):
return True
return False

drifts = []
for fname, content in docs.items():
for m in DOCKER_TAG_RE.finditer(content):
img = (m.group("image") or "").lower()
tag = m.group("tag") or m.group("tag2")
if not tag:
continue
# Match by image name (node, python, golang, etc.)
for from_img, from_tag in all_from.items():
if img and img not in from_img and from_img not in img:
continue
if not tags_match(tag, from_tag):
drifts.append({
"file": fname,
"doc_image": f"{img or from_img}:{tag}",
"dockerfile_image": f"{from_img}:{from_tag}",
"pos": m.start(),
})
break
break # one per file
return drifts


# ---------------------------------------------------------------------------
# Java/Gradle drift: build.gradle sourceCompatibility vs README
# ---------------------------------------------------------------------------
GRADLE_JAVA_RE = re.compile(r'sourceCompatibility\s*=\s*["\']?(?P<ver>\d+(?:\.\d+)?)["\']?|JavaVersion\.VERSION_(?P<ver2>_\d+|(?:\d+))', re.I)
GRADLE_KOTLIN_RE = re.compile(r'jvmTarget\s*=\s*["\']?(?P<ver>\d+(?:\.\d+)?)["\']?', re.I)
JAVA_DOC_RE = re.compile(r'(?:Java|JDK|JRE)\s+(?P<ver>\d+(?:\.\d+)?)', re.I)

def parse_gradle_java_version(text: str) -> str | None:
"""Parse Java version from build.gradle sourceCompatibility or jvmTarget."""
m = GRADLE_JAVA_RE.search(text)
if m:
v = m.group("ver") or m.group("ver2")
if v:
return v.replace("_", "")
m = GRADLE_KOTLIN_RE.search(text)
if m:
return m.group("ver")
return None

def find_java_drift(gradle_text: str, docs: dict[str, str]) -> list[dict]:
"""Detect drift between build.gradle Java version and README mentions."""
jv = parse_gradle_java_version(gradle_text)
if not jv:
return []
drifts = []
for fname, content in docs.items():
for m in JAVA_DOC_RE.finditer(content):
dv = m.group("ver")
# Normalize: "17.0.1" -> "17" for comparison with sourceCompatibility
dv_major = dv.split(".")[0]
jv_major = jv.split(".")[0]
if dv_major != jv_major:
drifts.append({
"file": fname,
"doc_version": dv,
"gradle_version": jv,
"pos": m.start(),
})
break
return drifts


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 @@ -470,6 +576,20 @@ def scan_repo(root: Path = Path(".")) -> dict:
cargo_path = root / "Cargo.toml"
cargo_text = cargo_path.read_text(encoding="utf-8", errors="replace") if cargo_path.exists() else ""

# Dockerfiles
dockerfiles = {}
for pattern in ["Dockerfile", "Dockerfile.*", "docker/Dockerfile", "docker/Dockerfile.*"]:
for p in root.glob(pattern):
if p.is_file():
dockerfiles[str(p.relative_to(root))] = p.read_text(encoding="utf-8", errors="replace")

# Gradle build files
gradle_files = {}
for pattern in ["build.gradle", "build.gradle.kts", "gradle/build.gradle", "gradle/build.gradle.kts"]:
for p in root.glob(pattern):
if p.is_file():
gradle_files[str(p.relative_to(root))] = p.read_text(encoding="utf-8", errors="replace")

rust_drifts = find_rust_drift(toolchain_text, docs)
rust_drifts_multi = find_rust_drift_multi(toolchain_text, cargo_text, docs)
node_drifts = find_node_drift(package_text, docs)
Expand All @@ -479,6 +599,8 @@ def scan_repo(root: Path = Path(".")) -> dict:
actions_drifts = find_actions_node_drift(root)
lineending_drifts = find_lineending_drift(root)
external_resource_drifts = find_external_resource_drift(root)
docker_drifts = find_docker_drift(dockerfiles, docs)
java_drifts = find_java_drift("\n".join(gradle_files.values()), docs)

return {
"toolchain_version": parse_toolchain_version(toolchain_text),
Expand All @@ -495,4 +617,6 @@ def scan_repo(root: Path = Path(".")) -> dict:
"actions_drifts": actions_drifts,
"lineending_drifts": lineending_drifts,
"external_resource_drifts": external_resource_drifts,
"docker_drifts": docker_drifts,
"java_drifts": java_drifts,
}
84 changes: 84 additions & 0 deletions tests/test_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -333,3 +333,87 @@ def test_count_drift_still_catches_wrong_total():
assert len(drifts) == 1
assert drifts[0]["doc_count"] == "32"


# ---- Docker drift tests ----
from driftcheck.detector import find_docker_drift, parse_dockerfile_from

def test_parse_dockerfile_from_basic():
df = "FROM node:24-slim\nWORKDIR /app\nCOPY . ."
result = parse_dockerfile_from(df)
assert result == {"node": "24-slim"}

def test_parse_dockerfile_from_multi_stage():
df = "FROM golang:1.23 AS builder\nRUN go build\nFROM alpine:3.21\nCOPY --from=builder /app ."
result = parse_dockerfile_from(df)
assert result == {"golang": "1.23", "alpine": "3.21"}

def test_docker_no_drift():
dockerfiles = {"Dockerfile": "FROM node:24-slim"}
docs = {"README.md": "Docker node:24 image"}
assert find_docker_drift(dockerfiles, docs) == []

def test_docker_detects_drift():
dockerfiles = {"Dockerfile": "FROM node:24-slim"}
docs = {"README.md": "Docker node:22 image"}
drifts = find_docker_drift(dockerfiles, docs)
assert len(drifts) == 1
assert drifts[0]["doc_image"] == "node:22"
assert drifts[0]["dockerfile_image"] == "node:24-slim"

def test_docker_no_dockerfile_returns_empty():
assert find_docker_drift({}, {"README.md": "node:22"}) == []

def test_docker_drift_included_in_scan():
with tempfile.TemporaryDirectory() as td:
root = Path(td)
(root / "Dockerfile").write_text("FROM python:3.12-slim")
(root / "README.md").write_text("Docker python:3.11 base image")
result = scan_repo(root)
assert "docker_drifts" in result
assert len(result["docker_drifts"]) == 1


# ---- Java/Gradle drift tests ----
from driftcheck.detector import find_java_drift, parse_gradle_java_version

def test_parse_gradle_source_compatibility():
gradle = "sourceCompatibility = '17'\n"
assert parse_gradle_java_version(gradle) == "17"

def test_parse_gradle_jvm_target():
gradle = 'jvmTarget = "21"\n'
assert parse_gradle_java_version(gradle) == "21"

def test_parse_gradle_java_version_enum():
gradle = "sourceCompatibility = JavaVersion.VERSION_21\n"
assert parse_gradle_java_version(gradle) == "21"

def test_parse_gradle_no_java_info():
gradle = "plugins { id 'java' }\n"
assert parse_gradle_java_version(gradle) is None

def test_java_no_drift():
gradle = "sourceCompatibility = '17'"
docs = {"README.md": "Requires Java 17"}
assert find_java_drift(gradle, docs) == []

def test_java_detects_drift():
gradle = "sourceCompatibility = '17'"
docs = {"README.md": "Requires Java 11"}
drifts = find_java_drift(gradle, docs)
assert len(drifts) == 1
assert drifts[0]["doc_version"] == "11"
assert drifts[0]["gradle_version"] == "17"

def test_java_no_gradle_returns_empty():
assert find_java_drift("", {"README.md": "Java 17"}) == []

def test_java_drift_included_in_scan():
with tempfile.TemporaryDirectory() as td:
root = Path(td)
(root / "build.gradle").write_text("sourceCompatibility = '21'\n")
(root / "README.md").write_text("Requires Java 17 to build")
result = scan_repo(root)
assert "java_drifts" in result
assert len(result["java_drifts"]) == 1

Loading