Coverage for src/rhiza_hooks/_makefile.py: 100%

46 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-15 06:14 +0000

1#!/usr/bin/env python3 

2"""Read the targets a repository's makefiles define. 

3 

4Two hooks need this: ``check_makefile_targets`` asks whether the *recommended* 

5targets are present, and ``check_workflow_make_targets`` asks whether every target 

6CI *invokes* exists. Both questions start from the same parse, so the parser lives 

7here rather than in either hook. 

8 

9Both are really the same question — "is this name defined?" — and since rhiza v1.4.0 

10neither can be answered from a list of names alone. The root Makefile is a shim whose 

11only real rule is ``help``; everything else is served by a catch-all pattern rule that 

12forwards the goal to the ``rhiza-task`` CLI:: 

13 

14 %: $(UVX) FORCE 

15 @$(UVX) $(RHIZA_TASK) $(RHIZA_TASK_GOAL) 

16 

17``make test`` works there, and no ``test:`` rule exists. So the parse yields 

18:class:`MakefileTargets`, which carries the catch-all flag alongside the names and 

19answers the question itself in :meth:`MakefileTargets.defines`. Leaving each hook to 

20apply the flag would be leaving each hook to forget it. 

21 

22The placement is deliberate. A console script's public surface is its ``main()``; 

23when one entrypoint imports a helper out of another, the dependency between the two 

24CLIs is invisible from either one's interface, and a change made for the sake of one 

25hook's command line silently reaches the other. A ``_``-prefixed leaf module states 

26the shared part explicitly and imports nothing from the package. 

27""" 

28 

29from __future__ import annotations 

30 

31import re 

32from dataclasses import dataclass 

33from pathlib import Path 

34 

35# Pattern to match Makefile target definitions. 

36# 

37# A rule is `name:` or, for double-colon rules, `name::`. Variable assignments 

38# (`name := ...`, `name ::= ...`) must NOT be mistaken for targets, so the 

39# colon-run is matched possessively (`:++`, Python 3.11+) and a following `=` 

40# is rejected with a negative lookahead — `:++` cannot backtrack to a shorter 

41# run to dodge the lookahead, so `name :=` and `name ::=` are excluded while 

42# `name:` and `name::` still match. Leading `[a-zA-Z_]` already excludes 

43# dot-special targets (`.PHONY`) and pattern rules (`%.o`). 

44TARGET_PATTERN = re.compile(r"^([a-zA-Z_][a-zA-Z0-9_-]*)[ \t]*:++(?!=)", re.MULTILINE) 

45 

46# A catch-all rule: the pattern rule whose stem is the *whole* target name, so make 

47# can build any name at all. `TARGET_PATTERN` cannot see it — it excludes pattern 

48# rules on purpose — but its presence changes what "defined" means for every target, 

49# which is why it is parsed here and not in either hook. 

50# 

51# Written as a stem of exactly `%`: a suffix rule (`%.o: %.c`) matches only names 

52# ending in `.o`, so the `[ \t]*` between `%` and the colon-run is what separates the 

53# two. Double-colon (`%::`) is match-anything too, and `:++(?!=)` rejects `%:=` for 

54# the same reason `TARGET_PATTERN` does. 

55# 

56# `.DEFAULT:` is make's other any-target escape hatch and is deliberately *not* 

57# matched. `%:` says make can build any name; `.DEFAULT:` is more often a recipe that 

58# prints an error, and treating that as "everything is defined" would suppress real 

59# reports. `.DEFAULT_GOAL := help` is unaffected either way — the colon-run has to 

60# follow the name immediately. 

61CATCH_ALL_PATTERN = re.compile(r"^%[ \t]*:++(?!=)", re.MULTILINE) 

62 

63# `include a.mk b/*.mk` and its optional forms (`-include`, `sinclude`). 

64INCLUDE_PATTERN = re.compile(r"^\s*(?:-|s)?include\s+(.+)$", re.MULTILINE) 

65 

66# A token holding one of these names something only make or a shell can expand, so 

67# it cannot be resolved at parse time — not to a path, and not to a target name. 

68VARIABLE_CHARS = ("$", "`") 

69 

70# A token holding one of these is a glob. That *is* resolvable against the 

71# filesystem (which is what an include needs) but never to a literal target name. 

72GLOB_CHARS = ("*", "?") 

73 

74 

75@dataclass(frozen=True) 

76class MakefileTargets: 

77 """What a set of makefiles can build: the names they define, and whether any name goes. 

78 

79 The two facts have to travel together. A repo whose Makefile is nothing but a 

80 catch-all rule defines almost no names and can still build every one a caller 

81 asks for, so a bare set of names is not enough to answer "is this target 

82 defined?" — and both hooks ask exactly that question. :meth:`defines` is where 

83 the answer lives, so the two cannot drift apart. 

84 """ 

85 

86 names: frozenset[str] 

87 catch_all: bool 

88 

89 def defines(self, target: str) -> bool: 

90 """Report whether *target* can be built. 

91 

92 A named rule defines it: 

93 

94 >>> MakefileTargets(frozenset({"test"}), catch_all=False).defines("test") 

95 True 

96 >>> MakefileTargets(frozenset({"test"}), catch_all=False).defines("fmt") 

97 False 

98 

99 So does a catch-all rule, whatever the name: 

100 

101 >>> MakefileTargets(frozenset(), catch_all=True).defines("anything") 

102 True 

103 """ 

104 return self.catch_all or target in self.names 

105 

106 def __or__(self, other: MakefileTargets) -> MakefileTargets: 

107 """Merge what two makefiles define, so an ``include`` contributes both halves.""" 

108 return MakefileTargets(self.names | other.names, self.catch_all or other.catch_all) 

109 

110 def __bool__(self) -> bool: 

111 """Report whether anything was found — a repo with no makefile at all is falsy. 

112 

113 Both hooks decline to report against nothing: with no rules to compare, every 

114 invocation looks undefined, which is noise rather than signal. A lone 

115 catch-all rule *is* something, so it is truthy. 

116 """ 

117 return bool(self.names) or self.catch_all 

118 

119 

120def extract_targets(content: str) -> MakefileTargets: 

121 """Extract what one makefile's content defines. 

122 

123 Args: 

124 content: Contents of a Makefile 

125 

126 Returns: 

127 The named targets found, and whether a catch-all rule is present. 

128 """ 

129 return MakefileTargets( 

130 names=frozenset(TARGET_PATTERN.findall(content)), 

131 catch_all=CATCH_ALL_PATTERN.search(content) is not None, 

132 ) 

133 

134 

135def _expand_include_token(token: str, base: Path) -> list[Path]: 

136 """Resolve one whitespace-separated word of an ``include`` directive to paths. 

137 

138 A variable-driven token yields nothing: its value is not knowable here. A glob is 

139 expanded against the filesystem; anything else is a literal path, returned whether 

140 or not it exists — the caller skips what it cannot read. 

141 """ 

142 if any(char in token for char in VARIABLE_CHARS): 

143 return [] 

144 if any(char in token for char in GLOB_CHARS): 

145 return sorted(base.glob(token)) 

146 return [base / token] 

147 

148 

149def _include_paths(content: str, base: Path) -> list[Path]: 

150 """Resolve the include directives in one makefile to concrete paths. 

151 

152 Globs are expanded and missing files simply yield nothing, which matches make's 

153 own behaviour for the optional (``-include``) form and is harmless for the 

154 mandatory one — a Makefile whose include is missing is broken in a way this hook 

155 is not trying to report. 

156 """ 

157 return [ 

158 path 

159 for directive in INCLUDE_PATTERN.findall(content) 

160 for token in directive.split() 

161 for path in _expand_include_token(token, base) 

162 ] 

163 

164 

165def collect_targets(repo_root: Path) -> MakefileTargets: 

166 """Return everything the root Makefile and its includes define, transitively. 

167 

168 Args: 

169 repo_root: Root directory of the repository. 

170 

171 Returns: 

172 The named targets and the catch-all flag, merged across every makefile 

173 reached. Falsy when there is no Makefile at all. 

174 """ 

175 targets = MakefileTargets(frozenset(), catch_all=False) 

176 seen: set[Path] = set() 

177 queue = [repo_root / "Makefile"] 

178 

179 while queue: 

180 path = queue.pop() 

181 resolved = path.resolve() 

182 if resolved in seen or not path.is_file(): 

183 continue 

184 seen.add(resolved) 

185 try: 

186 content = path.read_text(encoding="utf-8") 

187 except (OSError, UnicodeDecodeError): 

188 continue 

189 targets |= extract_targets(content) 

190 queue.extend(_include_paths(content, repo_root)) 

191 

192 return targets