devcheck.py 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289
  1. #!/usr/bin/env python3
  2. """Scan project Makefiles for development tools and report PATH availability."""
  3. from __future__ import annotations
  4. import os
  5. import re
  6. import shutil
  7. import sys
  8. from collections import defaultdict
  9. from pathlib import Path
  10. ROOT = Path(__file__).resolve().parent.parent
  11. FAIL_GROUPS = frozenset({
  12. "Core build and test",
  13. "Go codestyle (install via: make go-tools)",
  14. "Protocol buffers (install via: make -C service go-tools-all)",
  15. })
  16. SKIP_DIRS = frozenset({
  17. ".git",
  18. "node_modules",
  19. "vendor",
  20. "dist",
  21. "reports",
  22. "webui",
  23. })
  24. SKIP_COMMANDS = frozenset({
  25. ".", ":", "[", "bash", "case", "cat", "cd", "chmod", "chown", "cp", "curl",
  26. "do", "done", "echo", "elif", "else", "esac", "exit", "false", "fi", "for",
  27. "fuser", "grep", "head", "if", "kill", "killall", "lsof", "make", "mkdir",
  28. "mv", "objdump", "pwd", "rm", "sed", "set", "sh", "sleep", "tail", "test",
  29. "then", "touch", "trap", "true", "unzip", "while",
  30. })
  31. SKIP_PREFIXES = ("./", "../", "-", "$(")
  32. GO_INSTALL_RE = re.compile(r"""go\s+install\s+(?:["'])([^"']+)(?:["'])""")
  33. TOOL_GROUPS: list[tuple[str, frozenset[str]]] = [
  34. (
  35. "Core build and test",
  36. frozenset({"go", "npm", "npx", "node", "python3"}),
  37. ),
  38. (
  39. "Go codestyle (install via: make go-tools)",
  40. frozenset({"gocyclo", "gocritic"}),
  41. ),
  42. (
  43. "Protocol buffers (install via: make -C service go-tools-all)",
  44. frozenset({"buf", "protoc-gen-go"}),
  45. ),
  46. (
  47. "Containers and packaging (optional)",
  48. frozenset({"buildah", "docker", "podman", "podman-compose"}),
  49. ),
  50. ]
  51. class Color:
  52. RESET = "\033[0m"
  53. BOLD = "\033[1m"
  54. GREEN = "\033[32m"
  55. RED = "\033[31m"
  56. ORANGE = "\033[38;5;208m"
  57. DIM = "\033[2m"
  58. @classmethod
  59. def ok(cls, text: str) -> str:
  60. return f"{cls.BOLD}{cls.GREEN}{text}{cls.RESET}"
  61. @classmethod
  62. def fail(cls, text: str) -> str:
  63. return f"{cls.BOLD}{cls.RED}{text}{cls.RESET}"
  64. @classmethod
  65. def warn(cls, text: str) -> str:
  66. return f"{cls.BOLD}{cls.ORANGE}{text}{cls.RESET}"
  67. @classmethod
  68. def dim(cls, text: str) -> str:
  69. return f"{cls.DIM}{text}{cls.RESET}"
  70. def colors_enabled() -> bool:
  71. if os.environ.get("NO_COLOR"):
  72. return False
  73. if "--no-color" in sys.argv:
  74. return False
  75. return sys.stdout.isatty()
  76. def paint_ok(text: str) -> str:
  77. return Color.ok(text) if colors_enabled() else text
  78. def paint_fail(text: str) -> str:
  79. return Color.fail(text) if colors_enabled() else text
  80. def paint_warn(text: str) -> str:
  81. return Color.warn(text) if colors_enabled() else text
  82. def paint_dim(text: str) -> str:
  83. return Color.dim(text) if colors_enabled() else text
  84. def find_makefiles(root: Path) -> list[Path]:
  85. makefiles: list[Path] = []
  86. for path in root.rglob("Makefile"):
  87. rel_parts = path.relative_to(root).parts
  88. if any(part.startswith(".") or part in SKIP_DIRS for part in rel_parts):
  89. continue
  90. makefiles.append(path)
  91. return sorted(makefiles)
  92. def tool_from_go_install(package: str) -> str:
  93. return package.rstrip("/").rsplit("/", 1)[-1]
  94. def first_command_token(line: str) -> str | None:
  95. line = line.strip()
  96. if not line or line.startswith("#"):
  97. return None
  98. if line.startswith("$(call") or line.startswith("$(MAKE)"):
  99. return None
  100. for token in line.split():
  101. if "=" in token and not token.startswith("./"):
  102. continue
  103. if token.startswith(SKIP_PREFIXES):
  104. return None
  105. return token.strip("\"'")
  106. return None
  107. def collect_tools(makefiles: list[Path]) -> dict[str, set[str]]:
  108. tools: dict[str, set[str]] = defaultdict(set)
  109. for makefile in makefiles:
  110. rel = makefile.relative_to(ROOT).as_posix()
  111. try:
  112. lines = makefile.read_text(encoding="utf-8", errors="replace").splitlines()
  113. except OSError:
  114. continue
  115. for line in lines:
  116. if not line.startswith("\t"):
  117. continue
  118. recipe = line.lstrip("\t@")
  119. for package in GO_INSTALL_RE.findall(recipe):
  120. tools[tool_from_go_install(package)].add(rel)
  121. if "python -c" in recipe or recipe.startswith("python "):
  122. tools["python3"].add(rel)
  123. command = first_command_token(recipe)
  124. if command is None:
  125. continue
  126. command = command.lower()
  127. if command in SKIP_COMMANDS or command == "python":
  128. continue
  129. if "/" in command and command not in {"podman-compose"}:
  130. continue
  131. tools[command].add(rel)
  132. return dict(tools)
  133. def resolve_python() -> tuple[bool, str | None]:
  134. for name in ("python3", "python"):
  135. path = shutil.which(name)
  136. if path is not None:
  137. return True, path
  138. return False, None
  139. def resolve_tool(name: str) -> tuple[bool, str | None]:
  140. if name == "python3":
  141. return resolve_python()
  142. path = shutil.which(name)
  143. return path is not None, path
  144. def group_tools(tools: dict[str, set[str]]) -> list[tuple[str, list[str]]]:
  145. grouped: list[tuple[str, list[str]]] = []
  146. assigned: set[str] = set()
  147. for title, members in TOOL_GROUPS:
  148. present = sorted(tool for tool in tools if tool in members)
  149. if present:
  150. grouped.append((title, present))
  151. assigned.update(present)
  152. remaining = sorted(tool for tool in tools if tool not in assigned)
  153. if remaining:
  154. grouped.append(("Other tools found in Makefiles", remaining))
  155. return grouped
  156. def format_status(found: bool, path: str | None, *, required: bool) -> str:
  157. if found:
  158. return paint_ok(f"OK {path}")
  159. if required:
  160. return paint_fail("MISSING")
  161. return paint_warn("MISSING")
  162. def print_group(
  163. title: str,
  164. tool_names: list[str],
  165. tools: dict[str, set[str]],
  166. verbose: bool,
  167. ) -> tuple[int, int, list[str]]:
  168. required = title in FAIL_GROUPS
  169. print(title)
  170. available = 0
  171. missing: list[str] = []
  172. for name in tool_names:
  173. found, path = resolve_tool(name)
  174. available += int(found)
  175. if not found:
  176. missing.append(name)
  177. print(f" {name:<16} {format_status(found, path, required=required)}")
  178. if verbose:
  179. for source in sorted(tools[name]):
  180. print(paint_dim(f" referenced in {source}"))
  181. print()
  182. return available, len(tool_names), missing
  183. def main() -> int:
  184. verbose = "--verbose" in sys.argv or "-v" in sys.argv
  185. makefiles = find_makefiles(ROOT)
  186. tools = collect_tools(makefiles)
  187. print("Development environment check")
  188. print(f"Scanned {len(makefiles)} Makefile(s) under {ROOT}")
  189. print()
  190. total_available = 0
  191. total_checked = 0
  192. required_missing: list[str] = []
  193. optional_missing: list[str] = []
  194. for title, tool_names in group_tools(tools):
  195. available, checked, missing = print_group(title, tool_names, tools, verbose)
  196. total_available += available
  197. total_checked += checked
  198. if title in FAIL_GROUPS:
  199. required_missing.extend(missing)
  200. else:
  201. optional_missing.extend(missing)
  202. summary = f"Summary: {total_available}/{total_checked} tools available on PATH"
  203. if required_missing:
  204. print(paint_fail(summary))
  205. elif optional_missing:
  206. print(paint_warn(summary))
  207. else:
  208. print(paint_ok(summary))
  209. if required_missing:
  210. print()
  211. print(paint_fail("Missing required tools: " + ", ".join(required_missing)))
  212. print(paint_dim("Install the missing tools for your platform, then re-run: make devcheck"))
  213. return 1
  214. if optional_missing:
  215. print()
  216. print(paint_warn("Missing optional tools: " + ", ".join(optional_missing)))
  217. return 0
  218. if __name__ == "__main__":
  219. sys.exit(main())