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,7 +11,8 @@ driftcheck --json # machine-readable
driftcheck --fix # auto-fix drifts in documentation files
```

Checks (v0.1.10):
Checks (v0.1.11):
- Terraform: `versions.tf` `required_providers` block `version` vs README mentions — handles both `required_providers = {` and `required_providers {` formats
- Maven: `pom.xml` `java.version`, `maven.compiler.source`, `maven.compiler.target`, `release` vs README mentions — major-version comparison
- 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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "driftcheck"
version = "0.1.10"
version = "0.1.11"
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"
Expand Down
2 changes: 1 addition & 1 deletion src/driftcheck/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
"""driftcheck — detect version drift between docs and toolchain."""
__version__ = "0.1.10"
__version__ = "0.1.11"
7 changes: 5 additions & 2 deletions src/driftcheck/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,17 +38,18 @@ def main(argv=None) -> int:
docker_drifts = result.get("docker_drifts", [])
java_drifts = result.get("java_drifts", [])
maven_drifts = result.get("maven_drifts", [])
terraform_drifts = result.get("terraform_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 and not docker_drifts and not java_drifts and not maven_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 and not maven_drifts and not terraform_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 and not docker_drifts and not java_drifts and not maven_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 and not maven_drifts and not terraform_drifts:
parts = []
if tv: parts.append(f"Rust {tv}")
if cv: parts.append(f"Rust(Cargo) {cv}")
Expand Down Expand Up @@ -89,6 +90,8 @@ def main(argv=None) -> int:
print(f"driftcheck: {d['file']}: Java {d['doc_version']} → should be {d['gradle_version']} (build.gradle)")
for d in maven_drifts:
print(f"driftcheck: {d['file']}: Java {d['doc_version']} → should be {d['maven_version']} (pom.xml)")
for d in terraform_drifts:
print(f"driftcheck: {d['file']}: Terraform {d['provider']} {d['doc_version']} → should be {d['terraform_version']}")
return 1

if __name__ == "__main__":
Expand Down
54 changes: 54 additions & 0 deletions src/driftcheck/detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,51 @@ def find_maven_drift(pom_text: str, docs: dict[str, str]) -> list[dict]:
return drifts


# ---------------------------------------------------------------------------
# Terraform drift: versions.tf provider versions vs README
# ---------------------------------------------------------------------------
TERRAFORM_PROVIDER_RE = re.compile(r'required_providers\s*=?\s*\{[^}]*source\s*=\s*"(?P<source>[^"]+)"[^}]*version\s*=\s*"(?P<ver>[^"]+)"', re.S)
TERRAFORM_VER_RE = re.compile(r'(?:provider|terraform|version)\s+"?(?P<ver>\d+\.\d+(?:\.\d+)?)"?', re.I)

def parse_terraform_provider_versions(text: str) -> dict[str, str]:
"""Return {source: version} map of required_providers in versions.tf."""
result = {}
for m in TERRAFORM_PROVIDER_RE.finditer(text):
result[m.group("source")] = m.group("ver")
return result

def find_terraform_drift(terraform_files: dict[str, str], docs: dict[str, str]) -> list[dict]:
"""Detect drift between Terraform provider versions and README mentions."""
all_providers: dict[str, str] = {}
for fname, content in terraform_files.items():
for source, ver in parse_terraform_provider_versions(content).items():
all_providers[source] = ver

if not all_providers:
return []

drifts = []
for fname, content in docs.items():
for m in TERRAFORM_VER_RE.finditer(content):
ver = m.group("ver")
# Check if this version matches any provider version
for source, tf_ver in all_providers.items():
if ver != tf_ver and ver.split(".")[:2] == tf_ver.split(".")[:2]:
# Same major.minor, different patch — skip
continue
if ver != tf_ver:
drifts.append({
"file": fname,
"doc_version": ver,
"terraform_version": tf_ver,
"provider": source,
"pos": m.start(),
})
break
break # one per file
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 @@ -630,6 +675,13 @@ def scan_repo(root: Path = Path(".")) -> dict:
if p.is_file():
maven_files[str(p.relative_to(root))] = p.read_text(encoding="utf-8", errors="replace")

# Terraform files
terraform_files = {}
for pattern in ["versions.tf", "*.tf", "terraform/*.tf"]:
for p in root.glob(pattern):
if p.is_file():
terraform_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 @@ -642,6 +694,7 @@ def scan_repo(root: Path = Path(".")) -> dict:
docker_drifts = find_docker_drift(dockerfiles, docs)
java_drifts = find_java_drift("\n".join(gradle_files.values()), docs)
maven_drifts = find_maven_drift("\n".join(maven_files.values()), docs)
terraform_drifts = find_terraform_drift(terraform_files, docs)

return {
"toolchain_version": parse_toolchain_version(toolchain_text),
Expand All @@ -661,4 +714,5 @@ def scan_repo(root: Path = Path(".")) -> dict:
"docker_drifts": docker_drifts,
"java_drifts": java_drifts,
"maven_drifts": maven_drifts,
"terraform_drifts": terraform_drifts,
}
38 changes: 38 additions & 0 deletions tests/test_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -458,3 +458,41 @@ def test_java_drift_included_in_scan():
assert "java_drifts" in result
assert len(result["java_drifts"]) == 1


# ---- Terraform drift tests ----
from driftcheck.detector import find_terraform_drift, parse_terraform_provider_versions

def test_parse_terraform_provider_versions():
tf = 'required_providers {\n aws = {\n source = "hashicorp/aws"\n version = "5.45.0"\n }\n}'
result = parse_terraform_provider_versions(tf)
assert result == {"hashicorp/aws": "5.45.0"}

def test_parse_terraform_no_providers():
tf = 'resource "aws_instance" "example" {\n ami = "abc"\n}'
assert parse_terraform_provider_versions(tf) == {}

def test_terraform_no_drift():
tf = 'required_providers {\n aws = { source = "hashicorp/aws", version = "5.45.0" }\n}'
docs = {"README.md": "AWS provider 5.45.0"}
assert find_terraform_drift({"versions.tf": tf}, docs) == []

def test_terraform_detects_drift():
tf = 'required_providers {\n aws = { source = "hashicorp/aws", version = "5.45.0" }\n}'
docs = {"README.md": "AWS provider 5.40.0"}
drifts = find_terraform_drift({"versions.tf": tf}, docs)
assert len(drifts) == 1
assert drifts[0]["doc_version"] == "5.40.0"
assert drifts[0]["terraform_version"] == "5.45.0"

def test_terraform_no_files_returns_empty():
assert find_terraform_drift({}, {"README.md": "AWS 5.45.0"}) == []

def test_terraform_drift_included_in_scan():
with tempfile.TemporaryDirectory() as td:
root = Path(td)
(root / "versions.tf").write_text('required_providers {\n aws = { source = "hashicorp/aws", version = "5.45.0" }\n}')
(root / "README.md").write_text("AWS provider 5.40.0")
result = scan_repo(root)
assert "terraform_drifts" in result
assert len(result["terraform_drifts"]) == 1

Loading