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

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`)
Checks (v0.1.12):
- CircleCI: `.circleci/config.yml` docker image tags vs README mentions — handles variant tags (`24` matches `24-slim`)
- Terraform: `versions.tf` `required_providers` block `version` vs README mentions
- Maven: `pom.xml` `java.version`, `maven.compiler.source`, `maven.compiler.target`, `release` vs README mentions
- Docker: `Dockerfile` `FROM <image>:<tag>` vs README mentions
- 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.
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.11"
version = "0.1.12"
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.11"
__version__ = "0.1.12"
7 changes: 5 additions & 2 deletions src/driftcheck/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,17 +39,18 @@ def main(argv=None) -> int:
java_drifts = result.get("java_drifts", [])
maven_drifts = result.get("maven_drifts", [])
terraform_drifts = result.get("terraform_drifts", [])
circleci_drifts = result.get("circleci_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 and not terraform_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 and not circleci_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 and not terraform_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 and not circleci_drifts:
parts = []
if tv: parts.append(f"Rust {tv}")
if cv: parts.append(f"Rust(Cargo) {cv}")
Expand Down Expand Up @@ -92,6 +93,8 @@ def main(argv=None) -> int:
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']}")
for d in circleci_drifts:
print(f"driftcheck: {d['file']}: {d['doc_image']} → should be {d['circleci_image']} (CircleCI)")
return 1

if __name__ == "__main__":
Expand Down
64 changes: 64 additions & 0 deletions src/driftcheck/detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -538,6 +538,61 @@ def find_terraform_drift(terraform_files: dict[str, str], docs: dict[str, str])
return drifts


# ---------------------------------------------------------------------------
# CircleCI drift: .circleci/config.yml docker image tags vs README
# ---------------------------------------------------------------------------
CIRCLECI_IMAGE_RE = re.compile(r'image:\s*(?P<image>[\w.\-/]+):(?P<tag>[\w.\-]+)')
CIRCLECI_VER_RE = re.compile(r'(?:image|docker|version)\s+(?P<image>[\w.\-/]+):(?P<tag>[\w.\-]+)|(?:version|tag)\s+(?P<tag2>[\d.]+[\w.\-]*)', re.I)

def parse_circleci_images(text: str) -> dict[str, str]:
"""Return {image: tag} map of docker images in CircleCI config."""
result = {}
for m in CIRCLECI_IMAGE_RE.finditer(text):
result[m.group("image")] = m.group("tag")
return result

def find_circleci_drift(circleci_files: dict[str, str], docs: dict[str, str]) -> list[dict]:
"""Detect drift between CircleCI docker image tags and README mentions."""
all_images: dict[str, str] = {}
for fname, content in circleci_files.items():
for image, tag in parse_circleci_images(content).items():
all_images[image] = tag

if not all_images:
return []

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

drifts = []
for fname, content in docs.items():
for m in CIRCLECI_VER_RE.finditer(content):
img = (m.group("image") or "").lower()
tag = m.group("tag") or m.group("tag2")
if not tag:
continue
for ci_img, ci_tag in all_images.items():
if img and img not in ci_img and ci_img not in img:
continue
if not tags_match(tag, ci_tag):
drifts.append({
"file": fname,
"doc_image": f"{img or ci_img}:{tag}",
"circleci_image": f"{ci_img}:{ci_tag}",
"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 @@ -682,6 +737,13 @@ def scan_repo(root: Path = Path(".")) -> dict:
if p.is_file():
terraform_files[str(p.relative_to(root))] = p.read_text(encoding="utf-8", errors="replace")

# CircleCI config files
circleci_files = {}
for pattern in [".circleci/config.yml", ".circleci/config.yaml"]:
for p in root.glob(pattern):
if p.is_file():
circleci_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 @@ -695,6 +757,7 @@ def scan_repo(root: Path = Path(".")) -> dict:
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)
circleci_drifts = find_circleci_drift(circleci_files, docs)

return {
"toolchain_version": parse_toolchain_version(toolchain_text),
Expand All @@ -715,4 +778,5 @@ def scan_repo(root: Path = Path(".")) -> dict:
"java_drifts": java_drifts,
"maven_drifts": maven_drifts,
"terraform_drifts": terraform_drifts,
"circleci_drifts": circleci_drifts,
}
39 changes: 39 additions & 0 deletions tests/test_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -496,3 +496,42 @@ def test_terraform_drift_included_in_scan():
assert "terraform_drifts" in result
assert len(result["terraform_drifts"]) == 1


# ---- CircleCI drift tests ----
from driftcheck.detector import find_circleci_drift, parse_circleci_images

def test_parse_circleci_images():
yml = 'jobs:\n build:\n docker:\n - image: cimg/node:24.2.0'
result = parse_circleci_images(yml)
assert result == {"cimg/node": "24.2.0"}

def test_parse_circleci_no_images():
yml = 'jobs:\n build:\n steps:\n - checkout'
assert parse_circleci_images(yml) == {}

def test_circleci_no_drift():
yml = 'jobs:\n build:\n docker:\n - image: cimg/node:24.2.0'
docs = {"README.md": "Docker cimg/node:24.2.0 image"}
assert find_circleci_drift({".circleci/config.yml": yml}, docs) == []

def test_circleci_detects_drift():
yml = 'jobs:\n build:\n docker:\n - image: cimg/node:24.2.0'
docs = {"README.md": "Docker cimg/node:22 image"}
drifts = find_circleci_drift({".circleci/config.yml": yml}, docs)
assert len(drifts) == 1
assert drifts[0]["doc_image"] == "cimg/node:22"
assert drifts[0]["circleci_image"] == "cimg/node:24.2.0"

def test_circleci_no_files_returns_empty():
assert find_circleci_drift({}, {"README.md": "node:24"}) == []

def test_circleci_drift_included_in_scan():
with tempfile.TemporaryDirectory() as td:
root = Path(td)
(root / ".circleci").mkdir(parents=True)
(root / ".circleci" / "config.yml").write_text('jobs:\n build:\n docker:\n - image: cimg/node:24.2.0')
(root / "README.md").write_text("Docker cimg/node:22 base image")
result = scan_repo(root)
assert "circleci_drifts" in result
assert len(result["circleci_drifts"]) == 1

Loading