Files
2026-07-07 10:32:20 +01:00

290 lines
7.8 KiB
Python
Executable File

#!/usr/bin/env python3
"""Scan project Makefiles for development tools and report PATH availability."""
from __future__ import annotations
import os
import re
import shutil
import sys
from collections import defaultdict
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
FAIL_GROUPS = frozenset({
"Core build and test",
"Go codestyle (install via: make go-tools)",
"Protocol buffers (install via: make -C service go-tools-all)",
})
SKIP_DIRS = frozenset({
".git",
"node_modules",
"vendor",
"dist",
"reports",
"webui",
})
SKIP_COMMANDS = frozenset({
".", ":", "[", "bash", "case", "cat", "cd", "chmod", "chown", "cp", "curl",
"do", "done", "echo", "elif", "else", "esac", "exit", "false", "fi", "for",
"fuser", "grep", "head", "if", "kill", "killall", "lsof", "make", "mkdir",
"mv", "objdump", "pwd", "rm", "sed", "set", "sh", "sleep", "tail", "test",
"then", "touch", "trap", "true", "unzip", "while",
})
SKIP_PREFIXES = ("./", "../", "-", "$(")
GO_INSTALL_RE = re.compile(r"""go\s+install\s+(?:["'])([^"']+)(?:["'])""")
TOOL_GROUPS: list[tuple[str, frozenset[str]]] = [
(
"Core build and test",
frozenset({"go", "npm", "npx", "node", "python3"}),
),
(
"Go codestyle (install via: make go-tools)",
frozenset({"gocyclo", "gocritic"}),
),
(
"Protocol buffers (install via: make -C service go-tools-all)",
frozenset({"buf", "protoc-gen-go"}),
),
(
"Containers and packaging (optional)",
frozenset({"buildah", "docker", "podman", "podman-compose"}),
),
]
class Color:
RESET = "\033[0m"
BOLD = "\033[1m"
GREEN = "\033[32m"
RED = "\033[31m"
ORANGE = "\033[38;5;208m"
DIM = "\033[2m"
@classmethod
def ok(cls, text: str) -> str:
return f"{cls.BOLD}{cls.GREEN}{text}{cls.RESET}"
@classmethod
def fail(cls, text: str) -> str:
return f"{cls.BOLD}{cls.RED}{text}{cls.RESET}"
@classmethod
def warn(cls, text: str) -> str:
return f"{cls.BOLD}{cls.ORANGE}{text}{cls.RESET}"
@classmethod
def dim(cls, text: str) -> str:
return f"{cls.DIM}{text}{cls.RESET}"
def colors_enabled() -> bool:
if os.environ.get("NO_COLOR"):
return False
if "--no-color" in sys.argv:
return False
return sys.stdout.isatty()
def paint_ok(text: str) -> str:
return Color.ok(text) if colors_enabled() else text
def paint_fail(text: str) -> str:
return Color.fail(text) if colors_enabled() else text
def paint_warn(text: str) -> str:
return Color.warn(text) if colors_enabled() else text
def paint_dim(text: str) -> str:
return Color.dim(text) if colors_enabled() else text
def find_makefiles(root: Path) -> list[Path]:
makefiles: list[Path] = []
for path in root.rglob("Makefile"):
rel_parts = path.relative_to(root).parts
if any(part.startswith(".") or part in SKIP_DIRS for part in rel_parts):
continue
makefiles.append(path)
return sorted(makefiles)
def tool_from_go_install(package: str) -> str:
return package.rstrip("/").rsplit("/", 1)[-1]
def first_command_token(line: str) -> str | None:
line = line.strip()
if not line or line.startswith("#"):
return None
if line.startswith("$(call") or line.startswith("$(MAKE)"):
return None
for token in line.split():
if "=" in token and not token.startswith("./"):
continue
if token.startswith(SKIP_PREFIXES):
return None
return token.strip("\"'")
return None
def collect_tools(makefiles: list[Path]) -> dict[str, set[str]]:
tools: dict[str, set[str]] = defaultdict(set)
for makefile in makefiles:
rel = makefile.relative_to(ROOT).as_posix()
try:
lines = makefile.read_text(encoding="utf-8", errors="replace").splitlines()
except OSError:
continue
for line in lines:
if not line.startswith("\t"):
continue
recipe = line.lstrip("\t@")
for package in GO_INSTALL_RE.findall(recipe):
tools[tool_from_go_install(package)].add(rel)
if "python -c" in recipe or recipe.startswith("python "):
tools["python3"].add(rel)
command = first_command_token(recipe)
if command is None:
continue
command = command.lower()
if command in SKIP_COMMANDS or command == "python":
continue
if "/" in command and command not in {"podman-compose"}:
continue
tools[command].add(rel)
return dict(tools)
def resolve_python() -> tuple[bool, str | None]:
for name in ("python3", "python"):
path = shutil.which(name)
if path is not None:
return True, path
return False, None
def resolve_tool(name: str) -> tuple[bool, str | None]:
if name == "python3":
return resolve_python()
path = shutil.which(name)
return path is not None, path
def group_tools(tools: dict[str, set[str]]) -> list[tuple[str, list[str]]]:
grouped: list[tuple[str, list[str]]] = []
assigned: set[str] = set()
for title, members in TOOL_GROUPS:
present = sorted(tool for tool in tools if tool in members)
if present:
grouped.append((title, present))
assigned.update(present)
remaining = sorted(tool for tool in tools if tool not in assigned)
if remaining:
grouped.append(("Other tools found in Makefiles", remaining))
return grouped
def format_status(found: bool, path: str | None, *, required: bool) -> str:
if found:
return paint_ok(f"OK {path}")
if required:
return paint_fail("MISSING")
return paint_warn("MISSING")
def print_group(
title: str,
tool_names: list[str],
tools: dict[str, set[str]],
verbose: bool,
) -> tuple[int, int, list[str]]:
required = title in FAIL_GROUPS
print(title)
available = 0
missing: list[str] = []
for name in tool_names:
found, path = resolve_tool(name)
available += int(found)
if not found:
missing.append(name)
print(f" {name:<16} {format_status(found, path, required=required)}")
if verbose:
for source in sorted(tools[name]):
print(paint_dim(f" referenced in {source}"))
print()
return available, len(tool_names), missing
def main() -> int:
verbose = "--verbose" in sys.argv or "-v" in sys.argv
makefiles = find_makefiles(ROOT)
tools = collect_tools(makefiles)
print("Development environment check")
print(f"Scanned {len(makefiles)} Makefile(s) under {ROOT}")
print()
total_available = 0
total_checked = 0
required_missing: list[str] = []
optional_missing: list[str] = []
for title, tool_names in group_tools(tools):
available, checked, missing = print_group(title, tool_names, tools, verbose)
total_available += available
total_checked += checked
if title in FAIL_GROUPS:
required_missing.extend(missing)
else:
optional_missing.extend(missing)
summary = f"Summary: {total_available}/{total_checked} tools available on PATH"
if required_missing:
print(paint_fail(summary))
elif optional_missing:
print(paint_warn(summary))
else:
print(paint_ok(summary))
if required_missing:
print()
print(paint_fail("Missing required tools: " + ", ".join(required_missing)))
print(paint_dim("Install the missing tools for your platform, then re-run: make devcheck"))
return 1
if optional_missing:
print()
print(paint_warn("Missing optional tools: " + ", ".join(optional_missing)))
return 0
if __name__ == "__main__":
sys.exit(main())