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
« 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.
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.
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::
14 %: $(UVX) FORCE
15 @$(UVX) $(RHIZA_TASK) $(RHIZA_TASK_GOAL)
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.
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"""
29from __future__ import annotations
31import re
32from dataclasses import dataclass
33from pathlib import Path
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)
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)
63# `include a.mk b/*.mk` and its optional forms (`-include`, `sinclude`).
64INCLUDE_PATTERN = re.compile(r"^\s*(?:-|s)?include\s+(.+)$", re.MULTILINE)
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 = ("$", "`")
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 = ("*", "?")
75@dataclass(frozen=True)
76class MakefileTargets:
77 """What a set of makefiles can build: the names they define, and whether any name goes.
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 """
86 names: frozenset[str]
87 catch_all: bool
89 def defines(self, target: str) -> bool:
90 """Report whether *target* can be built.
92 A named rule defines it:
94 >>> MakefileTargets(frozenset({"test"}), catch_all=False).defines("test")
95 True
96 >>> MakefileTargets(frozenset({"test"}), catch_all=False).defines("fmt")
97 False
99 So does a catch-all rule, whatever the name:
101 >>> MakefileTargets(frozenset(), catch_all=True).defines("anything")
102 True
103 """
104 return self.catch_all or target in self.names
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)
110 def __bool__(self) -> bool:
111 """Report whether anything was found — a repo with no makefile at all is falsy.
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
120def extract_targets(content: str) -> MakefileTargets:
121 """Extract what one makefile's content defines.
123 Args:
124 content: Contents of a Makefile
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 )
135def _expand_include_token(token: str, base: Path) -> list[Path]:
136 """Resolve one whitespace-separated word of an ``include`` directive to paths.
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]
149def _include_paths(content: str, base: Path) -> list[Path]:
150 """Resolve the include directives in one makefile to concrete paths.
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 ]
165def collect_targets(repo_root: Path) -> MakefileTargets:
166 """Return everything the root Makefile and its includes define, transitively.
168 Args:
169 repo_root: Root directory of the repository.
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"]
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))
192 return targets