diff --git a/README.md b/README.md index a9a7f68..8e217ed 100644 --- a/README.md +++ b/README.md @@ -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 :` 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 diff --git a/pyproject.toml b/pyproject.toml index 3334b9c..755af65 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" } diff --git a/src/driftcheck/cli.py b/src/driftcheck/cli.py index de45f16..6b2e9bc 100644 --- a/src/driftcheck/cli.py +++ b/src/driftcheck/cli.py @@ -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", []) @@ -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}") @@ -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: @@ -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__": diff --git a/src/driftcheck/detector.py b/src/driftcheck/detector.py index b0bd1a2..93437e8 100644 --- a/src/driftcheck/detector.py +++ b/src/driftcheck/detector.py @@ -354,6 +354,112 @@ def find_external_resource_drift(root: Path) -> list[dict]: break # one per file return drifts +# --------------------------------------------------------------------------- +# Docker drift: Dockerfile FROM : vs README mentions +# --------------------------------------------------------------------------- +DOCKER_FROM_RE = re.compile(r'^FROM\s+(?:--\S+\s+)?(?P[\w.\-/]+):(?P[\w.\-]+)', re.MULTILINE | re.I) +DOCKER_TAG_RE = re.compile(r'(?:Docker|image)\s+(?P[\w.\-]+):(?P[\w.\-]+)|(?:Docker|image)\s+(?:version|tag)?\s+(?P[\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\d+(?:\.\d+)?)["\']?|JavaVersion\.VERSION_(?P_\d+|(?:\d+))', re.I) +GRADLE_KOTLIN_RE = re.compile(r'jvmTarget\s*=\s*["\']?(?P\d+(?:\.\d+)?)["\']?', re.I) +JAVA_DOC_RE = re.compile(r'(?:Java|JDK|JRE)\s+(?P\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 = [] @@ -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) @@ -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), @@ -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, } \ No newline at end of file diff --git a/tests/test_detector.py b/tests/test_detector.py index 5d30c35..7cb25c9 100644 --- a/tests/test_detector.py +++ b/tests/test_detector.py @@ -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 +