|
@@ -0,0 +1,289 @@
|
|
|
|
|
+#!/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())
|