diff --git a/Makefile b/Makefile index 21a08f3..9fd420e 100644 --- a/Makefile +++ b/Makefile @@ -73,4 +73,7 @@ clean: config-tool: cd service && go run cmd/config-tool/main.go -.PHONY: proto service windows-resources windows-msi +devcheck: + python3 scripts/devcheck.py $(ARGS) + +.PHONY: proto service windows-resources windows-msi devcheck diff --git a/scripts/devcheck.py b/scripts/devcheck.py new file mode 100755 index 0000000..4a45f19 --- /dev/null +++ b/scripts/devcheck.py @@ -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())