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

1#!/usr/bin/env python3 

2"""Check that Makefile contains expected targets for rhiza projects.""" 

3 

4from __future__ import annotations 

5 

6import argparse 

7import re 

8import sys 

9from pathlib import Path 

10 

11# Common targets expected in rhiza-based projects 

12RECOMMENDED_TARGETS = { 

13 "install", 

14 "test", 

15 "fmt", 

16 "help", 

17} 

18 

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) 

29 

30 

31def extract_targets(content: str) -> set[str]: 

32 """Extract target names from Makefile content. 

33 

34 Args: 

35 content: Contents of a Makefile 

36 

37 Returns: 

38 Set of target names found 

39 """ 

40 matches = TARGET_PATTERN.findall(content) 

41 return set(matches) 

42 

43 

44def check_makefile(filepath: Path, recommended: set[str] = RECOMMENDED_TARGETS) -> list[str]: 

45 """Check a Makefile for recommended targets. 

46 

47 Args: 

48 filepath: Path to the Makefile 

49 recommended: Target names that must be present (defaults to RECOMMENDED_TARGETS) 

50 

51 Returns: 

52 List of warning messages (empty if all recommended targets exist) 

53 """ 

54 warnings: list[str] = [] 

55 

56 try: 

57 content = filepath.read_text() 

58 except FileNotFoundError: 

59 return [f"File not found: {filepath}"] 

60 

61 targets = extract_targets(content) 

62 

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))}") 

68 

69 return warnings 

70 

71 

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. 

74 

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. 

78 

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 []) 

84 

85 

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) 

112 

113 recommended = resolve_recommended_targets(args.target, args.extend_target) 

114 

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 

125 

126 return retval 

127 

128 

129if __name__ == "__main__": # pragma: no mutate 

130 sys.exit(main())