Files
computer-playbook/tests/integration/workflows/test_dependabot_coverage.py
T
kevinveenbirkenbachandClaude Opus 4.7 623035618c refactor(cache): route every yaml call in tests/ through utils.cache.yaml
Stops every direct `yaml.safe_load`/`safe_load_all`/`safe_dump`/`dump`
call across the test corpus from re-parsing files the production cache
already holds. Each migrated test loads role meta, group_vars, inventory
fixtures, etc. through the shared cache, so a single `make test` run
parses every YAML file at most once across all suites.

Cache module changes (utils/cache/yaml.py):
  - Unify single- and multi-doc cache: one `_CACHE: Dict[sig, list]`
    holding the `safe_load_all` payload. Single-doc helpers
    (load_yaml / load_yaml_any) unwrap docs[0] and raise on >1
    document, preserving the old strict semantics. A file consumed
    both as single-doc and multi-doc is now parsed exactly once.
  - Add load_yaml_all (path-cached) and load_yaml_all_str (string,
    no cache) so multi-doc consumers no longer need a direct
    `yaml.safe_load_all` plus a separate file read.
  - dump_yaml now accepts any safe-dumpable structure (dict, list,
    scalar) so test fixtures writing list-of-tasks YAML can use it.

Lint guard (tests/lint/repository/test_no_direct_yaml_calls.py):
  - Extend the scanned tree to include `tests/` so future test code
    is forced through the cache, with the existing
    `# noqa: direct-yaml` per-call opt-out for genuine raw-yaml
    fixture needs.

Test corpus:
  - 89 test files migrated to load_yaml / load_yaml_any /
    load_yaml_str / load_yaml_all / load_yaml_all_str / dump_yaml /
    dump_yaml_str. No `# noqa` shortcuts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 12:16:20 +02:00

301 lines
10 KiB
Python

import fnmatch
import os
import subprocess
import unittest
from pathlib import Path
from typing import Iterable
from utils.cache.yaml import load_yaml_any
REPO_ROOT = Path(__file__).resolve().parents[3]
DEPENDABOT_PATH = REPO_ROOT / ".github" / "dependabot.yml"
# Directories skipped when walking the repository
SKIP_DIRS = {
".git",
".venv",
"venv",
"__pycache__",
"node_modules",
".tox",
"build",
"dist",
}
# Hidden directories that may contain real dependency manifests
SCANNED_HIDDEN_DIRS = {
".github",
".devcontainer",
}
# File suffixes that indicate generated/template files, not real dependency files
SKIP_SUFFIXES = (".j2",)
GITIGNORE_PATH = REPO_ROOT / ".gitignore"
# Mapping: Dependabot ecosystem name → indicator filename patterns (fnmatch-style)
ECOSYSTEM_FILENAME_INDICATORS: dict[str, list[str]] = {
"pip": [
"pyproject.toml",
"requirements.txt",
"requirements*.txt",
"setup.py",
"setup.cfg",
"Pipfile",
],
"uv": ["uv.lock"],
"npm": ["package.json"],
"bun": ["bun.lockb"],
"cargo": ["Cargo.toml"],
"rust-toolchain": ["rust-toolchain.toml", "rust-toolchain"],
"composer": ["composer.json"],
"gomod": ["go.mod"],
"maven": ["pom.xml"],
"gradle": ["build.gradle", "build.gradle.kts"],
"nuget": ["*.csproj", "*.fsproj", "*.vbproj"],
"dotnet-sdk": ["global.json"],
"mix": ["mix.exs"],
"swift": ["Package.swift"],
"pub": ["pubspec.yaml"],
"conda": ["environment.yml"],
"julia": ["Project.toml", "JuliaProject.toml"],
"elm": ["elm.json"],
"bazel": ["MODULE.bazel", "WORKSPACE", "WORKSPACE.bazel"],
"vcpkg": ["vcpkg.json"],
"gitsubmodule": [".gitmodules"],
"bundler": ["Gemfile", "gems.rb"],
"terraform": ["*.tf"],
"opentofu": ["*.tf"],
"helm": ["Chart.yaml"],
"pre-commit": [".pre-commit-config.yaml"],
"docker": ["Dockerfile", "*.dockerfile", "Dockerfile.*"],
"docker-compose": [
"docker-compose.yml",
"docker-compose.yaml",
"compose.yml",
"compose.yaml",
],
}
# Mapping: Dependabot ecosystem name → repository-relative path patterns
ECOSYSTEM_PATH_INDICATORS: dict[str, list[str]] = {
"github-actions": [
".github/workflows/*.yml",
".github/workflows/*.yaml",
"action.yml",
"action.yaml",
],
"devcontainers": [
".devcontainer/devcontainer.json",
".devcontainer/*/devcontainer.json",
],
}
# Ecosystem groups where any one active member covers the shared file type.
# E.g. terraform and opentofu both consume *.tf — either being active is sufficient.
EQUIVALENT_ECOSYSTEMS: list[frozenset[str]] = [
frozenset({"terraform", "opentofu"}),
]
def _dir_covers(dep_dir: str, file_rel_dir: str) -> bool:
"""Return True if a Dependabot directory pattern covers a file's relative directory.
dep_dir is as written in dependabot.yml (e.g. '/', '/**', '/roles/foo/').
file_rel_dir is the directory of the file relative to REPO_ROOT (e.g. 'roles/foo').
An empty file_rel_dir means the file is at the repo root.
For many ecosystems GitHub's Dependabot treats '/' as "search recursively
from root", which means it covers all sub-directories too.
"""
norm_dep = "/" + dep_dir.strip("/") if dep_dir.strip("/") else "/"
norm_file = "/" + file_rel_dir.strip("/") if file_rel_dir else "/"
# '/' and '/**' are both "cover the entire repository"
if norm_dep in ("/", "/**"):
return True
return norm_file == norm_dep or norm_file.startswith(norm_dep + "/")
def _load_active_entries() -> list[dict]:
data = load_yaml_any(DEPENDABOT_PATH)
return data.get("updates", [])
def _get_entry_dirs(entry: dict) -> list[str]:
if "directories" in entry:
return entry["directories"]
return [entry.get("directory", "/")]
def _matching_ecosystems(rel_file: str, filename: str) -> set[str]:
matched = {
ecosystem
for ecosystem, patterns in ECOSYSTEM_FILENAME_INDICATORS.items()
if any(fnmatch.fnmatch(filename, pattern) for pattern in patterns)
}
matched.update(
ecosystem
for ecosystem, patterns in ECOSYSTEM_PATH_INDICATORS.items()
if any(fnmatch.fnmatch(rel_file, pattern) for pattern in patterns)
)
return matched
def _list_candidate_files() -> Iterable[str]:
"""Yield repo-relative file paths to inspect for ecosystem coverage.
Prefers `git ls-files` (only tracked files, matches Dependabot's view of
the repo). Falls back to os.walk with an explicit untracked-skip list when
git is unavailable or REPO_ROOT is not a git checkout (e.g. inside the
test-integration container where /opt/src/infinito is a bind mount of the
source tree without .git/).
"""
try:
result = subprocess.run(
["git", "-C", str(REPO_ROOT), "ls-files", "-z"],
check=True,
capture_output=True,
)
return [p for p in result.stdout.decode().split("\0") if p]
except (FileNotFoundError, subprocess.CalledProcessError):
return _walk_fallback()
def _load_gitignore_patterns() -> list[str]:
"""Return non-comment, non-blank, non-negated lines from the repo .gitignore.
The fallback walker uses these as fnmatch patterns to mimic git's view of
which files are tracked. We deliberately ignore negation (`!`) — the repo
.gitignore contains none, and supporting it would require a real
pathspec implementation.
"""
if not GITIGNORE_PATH.is_file():
return []
patterns: list[str] = []
for raw in GITIGNORE_PATH.read_text().splitlines():
line = raw.strip()
if not line or line.startswith("#") or line.startswith("!"):
continue
patterns.append(line.rstrip("/"))
return patterns
def _is_gitignored(rel_file: str, filename: str, patterns: Iterable[str]) -> bool:
"""Best-effort fnmatch-based check that mirrors a tracked-file filter.
A pattern matches if it equals (or fnmatch-matches) either the basename or
the repo-relative path. Sufficient for the simple patterns this repo ships
in .gitignore (filenames, `*.ext` globs, and short directory names).
"""
for pattern in patterns:
if fnmatch.fnmatch(filename, pattern) or fnmatch.fnmatch(rel_file, pattern):
return True
return False
def _walk_fallback() -> list[str]:
patterns = _load_gitignore_patterns()
files: list[str] = []
for root, dirs, filenames in os.walk(REPO_ROOT):
rel_root = os.path.relpath(root, REPO_ROOT)
top_segment = "" if rel_root == "." else rel_root.split(os.sep, 1)[0]
dirs[:] = [
d
for d in dirs
if d not in SKIP_DIRS
and not (d.startswith(".") and d not in SCANNED_HIDDEN_DIRS)
and not _is_gitignored(
d if rel_root == "." else os.path.join(rel_root, d), d, patterns
)
]
if top_segment in SKIP_DIRS:
continue
for filename in filenames:
rel_file = filename if rel_root == "." else os.path.join(rel_root, filename)
if _is_gitignored(rel_file, filename, patterns):
continue
files.append(rel_file)
return files
class TestDependabotCoverage(unittest.TestCase):
def test_all_dependency_files_are_covered(self):
"""Every dependency file found in the repository must be covered by an
active Dependabot entry. The test fails when a new ecosystem file is
added (e.g. a Gemfile or Cargo.toml inside a role) but the matching
ecosystem is still commented-out in .github/dependabot.yml."""
active = _load_active_entries()
# Build lookup: ecosystem → [directory patterns]
ecosystem_dirs: dict[str, list[str]] = {}
for entry in active:
eco = entry["package-ecosystem"]
ecosystem_dirs.setdefault(eco, []).extend(_get_entry_dirs(entry))
uncovered: list[str] = []
# Only scan files actually tracked by git. Dependabot operates on the
# committed tree, so untracked working-copy artefacts (stray host config,
# editor scratch files, etc.) must not influence coverage. Containerised
# test runs bind-mount the source tree without .git/, so git may be
# unavailable — fall back to os.walk plus an explicit untracked skip
# list when that happens.
candidate_files = _list_candidate_files()
for rel_file in candidate_files:
if not rel_file:
continue
filename = os.path.basename(rel_file)
rel_dir = os.path.dirname(rel_file)
top_segment = rel_file.split("/", 1)[0]
if top_segment in SKIP_DIRS:
continue
if (
top_segment.startswith(".")
and top_segment not in SCANNED_HIDDEN_DIRS
and top_segment != filename
):
continue
if any(filename.endswith(s) for s in SKIP_SUFFIXES):
continue
for ecosystem in sorted(_matching_ecosystems(rel_file, filename)):
# Collect all ecosystem names that are equivalent for this file
candidates: set[str] = {ecosystem}
for group in EQUIVALENT_ECOSYSTEMS:
if ecosystem in group:
candidates |= group
covered = any(
any(
_dir_covers(dep_dir, rel_dir)
for dep_dir in ecosystem_dirs.get(eco, [])
)
for eco in candidates
)
if not covered:
uncovered.append(f"{rel_file} → ecosystem: {ecosystem}")
if uncovered:
self.fail(
"The following dependency files exist but are NOT covered by an active"
" Dependabot entry in .github/dependabot.yml.\n"
"Enable the matching ecosystem or add the directory to an existing entry:\n\n"
+ "\n".join(f" {entry}" for entry in sorted(uncovered))
)
if __name__ == "__main__":
unittest.main()