From aecbc3507b55ea86b727e8f547d3d204a5605554 Mon Sep 17 00:00:00 2001 From: Yunare Maia Date: Fri, 4 Sep 2026 17:59:52 +0000 Subject: [PATCH] feat(detector): add CircleCI drift detection (v0.1.12) Detects docker image tag drift between .circleci/config.yml and README mentions. Handles variant tags ('24' matches '24-slim'). 6 new tests covering parsing, no-drift, drift detection, and scan integration. --- README.md | 9 +++--- pyproject.toml | 2 +- src/driftcheck/__init__.py | 2 +- src/driftcheck/cli.py | 7 +++-- src/driftcheck/detector.py | 64 ++++++++++++++++++++++++++++++++++++++ tests/test_detector.py | 39 +++++++++++++++++++++++ 6 files changed, 115 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 0167d84..8cb863f 100644 --- a/README.md +++ b/README.md @@ -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 :` 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 :` 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. diff --git a/pyproject.toml b/pyproject.toml index d13d266..4681682 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/driftcheck/__init__.py b/src/driftcheck/__init__.py index 7e8172e..b0c23df 100644 --- a/src/driftcheck/__init__.py +++ b/src/driftcheck/__init__.py @@ -1,2 +1,2 @@ """driftcheck — detect version drift between docs and toolchain.""" -__version__ = "0.1.11" +__version__ = "0.1.12" diff --git a/src/driftcheck/cli.py b/src/driftcheck/cli.py index 268cffc..87273ba 100644 --- a/src/driftcheck/cli.py +++ b/src/driftcheck/cli.py @@ -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}") @@ -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__": diff --git a/src/driftcheck/detector.py b/src/driftcheck/detector.py index 8504a6a..c3f765e 100644 --- a/src/driftcheck/detector.py +++ b/src/driftcheck/detector.py @@ -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[\w.\-/]+):(?P[\w.\-]+)') +CIRCLECI_VER_RE = re.compile(r'(?:image|docker|version)\s+(?P[\w.\-/]+):(?P[\w.\-]+)|(?:version|tag)\s+(?P[\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 = [] @@ -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) @@ -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), @@ -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, } \ No newline at end of file diff --git a/tests/test_detector.py b/tests/test_detector.py index 94dc7c4..5309e9f 100644 --- a/tests/test_detector.py +++ b/tests/test_detector.py @@ -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 +