Coverage for src/rhiza_hooks/check_makefile_targets.py: 100%
46 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-07-30 04:36 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-07-30 04:36 +0000
1#!/usr/bin/env python3
2"""Check that Makefile contains expected targets for rhiza projects."""
4from __future__ import annotations
6import argparse
7import re
8import sys
9from pathlib import Path
11# Common targets expected in rhiza-based projects
12RECOMMENDED_TARGETS = {
13 "install",
14 "test",
15 "fmt",
16 "help",
17}
19# Pattern to match Makefile target definitions.
20#
21# A rule is `name:` or, for double-colon rules, `name::`. Variable assignments
22# (`name := ...`, `name ::= ...`) must NOT be mistaken for targets, so the
23# colon-run is matched possessively (`:++`, Python 3.11+) and a following `=`
24# is rejected with a negative lookahead — `:++` cannot backtrack to a shorter
25# run to dodge the lookahead, so `name :=` and `name ::=` are excluded while
26# `name:` and `name::` still match. Leading `[a-zA-Z_]` already excludes
27# dot-special targets (`.PHONY`) and pattern rules (`%.o`).
28TARGET_PATTERN = re.compile(r"^([a-zA-Z_][a-zA-Z0-9_-]*)[ \t]*:++(?!=)", re.MULTILINE)
31def extract_targets(content: str) -> set[str]:
32 """Extract target names from Makefile content.
34 Args:
35 content: Contents of a Makefile
37 Returns:
38 Set of target names found
39 """
40 matches = TARGET_PATTERN.findall(content)
41 return set(matches)
44def check_makefile(filepath: Path, recommended: set[str] = RECOMMENDED_TARGETS) -> list[str]:
45 """Check a Makefile for recommended targets.
47 Args:
48 filepath: Path to the Makefile
49 recommended: Target names that must be present (defaults to RECOMMENDED_TARGETS)
51 Returns:
52 List of warning messages (empty if all recommended targets exist)
53 """
54 warnings: list[str] = []
56 try:
57 content = filepath.read_text()
58 except FileNotFoundError:
59 return [f"File not found: {filepath}"]
61 targets = extract_targets(content)
63 # Only check the main Makefile for recommended targets
64 if filepath.name == "Makefile":
65 missing = recommended - targets
66 if missing:
67 warnings.append(f"Missing recommended targets: {', '.join(sorted(missing))}")
69 return warnings
72def resolve_recommended_targets(targets: list[str] | None, extra_targets: list[str] | None) -> set[str]:
73 """Build the effective set of required targets from the CLI options.
75 Args:
76 targets: Values of ``--target``. When non-empty they *replace* the defaults.
77 extra_targets: Values of ``--extend-target``, always *added* to the active set.
79 Returns:
80 The set of target names a Makefile is expected to define.
81 """
82 base = set(targets) if targets else set(RECOMMENDED_TARGETS)
83 return base | set(extra_targets or [])
86def main(argv: list[str] | None = None) -> int:
87 """Main entry point for the hook."""
88 parser = argparse.ArgumentParser(description="Check Makefile for recommended targets")
89 parser.add_argument(
90 "filenames",
91 nargs="*",
92 help="Filenames to check",
93 )
94 parser.add_argument(
95 "--strict",
96 action="store_true",
97 help="Exit with error if recommended targets are missing",
98 )
99 parser.add_argument(
100 "--target",
101 action="append",
102 metavar="NAME",
103 help="Required target name; repeatable. When given, replaces the default set.",
104 )
105 parser.add_argument(
106 "--extend-target",
107 action="append",
108 metavar="NAME",
109 help="Extra required target name; repeatable. Added on top of the active set.",
110 )
111 args = parser.parse_args(argv)
113 recommended = resolve_recommended_targets(args.target, args.extend_target)
115 retval = 0
116 for filename in args.filenames:
117 filepath = Path(filename)
118 warnings = check_makefile(filepath, recommended)
119 if warnings:
120 print(f"{filename}:")
121 for warning in warnings:
122 print(f" - {warning}")
123 if args.strict:
124 retval = 1
126 return retval
129if __name__ == "__main__": # pragma: no mutate
130 sys.exit(main())