| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289 |
- #!/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())
|