Coverage for plugin/scripts/_make_targets_chain.py: 100%
37 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-10-26 04:21 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-10-26 04:21 +0000
1"""The static half of ``check_make_targets.py``: read a repo's makefiles as text.
3Split from the orchestrator by instrument, the same line ``_make_targets_runner`` was cut
4along. Everything here reads files and nothing runs: which makefile a repo has, the chain
5of files it ``include``s, and the targets those files document with the ``## description``
6convention. Asking ``make -n`` what resolves is behaviour, and stays with the
7orchestrator — a makefile can answer a target this parser never sees (a shim's ``%:``
8catch-all) and document one make cannot build, so the two answers are kept apart rather
9than blended.
11Like ``_make_targets_runner`` it never imports its orchestrator; the dependency runs one
12way.
13"""
15from __future__ import annotations
17import re
18from pathlib import Path
20_MAKEFILES = ("Makefile", "makefile", "GNUmakefile")
21# A self-documenting target — `test: ## Run the suite` — the convention every rhiza
22# Makefile uses for `make help`. Undocumented internal targets are deliberately not
23# matched: they are not gates anyone meant to expose. `test::` (a double-colon rule,
24# which rust.mk uses) counts too.
25_DOCUMENTED = re.compile(r"^([a-z][a-z0-9_-]*)::?.*?##\s*(.+)$", re.MULTILINE)
26# An `include`/`-include` line, with its (possibly glob) operands.
27_INCLUDE = re.compile(r"^\s*-?include\s+(.+?)\s*$", re.MULTILINE)
28# How deep to follow includes. Makefile -> .rhiza/rhiza.mk -> .rhiza/make.d/*.mk is two,
29# so three leaves room without risking a pathological chain.
30_INCLUDE_DEPTH = 3
33def find_makefile(target_dir: Path) -> Path | None:
34 """Return the repo's makefile, or None when there isn't one."""
35 return next((target_dir / n for n in _MAKEFILES if (target_dir / n).is_file()), None)
38def makefile_chain(target_dir: Path, *, depth: int = _INCLUDE_DEPTH) -> list[Path]:
39 """Return the repo's makefile plus the files it ``include``s, in reading order.
41 **Reading only the root makefile finds nothing on a pre-v1.4 repo.** Up to template
42 v1.3 a synced repo's `Makefile` was a stub — a few variables and
43 `include .rhiza/rhiza.mk` — which in turn ended with `-include .rhiza/make.d/*.mk`,
44 and *that* is where every gate lived. Probing was unaffected (``make -n`` follows
45 includes itself), but discovery read one file where make reads a dozen, so a synced
46 Rust repo reported zero discovered targets while `.rhiza/make.d/rust.mk` was sitting
47 there defining `deps`, `license` and `coverage`. The mechanism that exists to stop
48 `/quality` reporting "nothing could be checked" was doing exactly that.
50 Template v1.4 retired that layer: neither file is shipped any more, the gates are
51 `rhiza-task` tasks, and the `Makefile` is a shim that forwards to the pinned CLI.
52 Following includes is kept because a repo may pin any ref, so both shapes are live —
53 and on a current one the chain is simply the root makefile plus whatever `local.mk`
54 the repo added itself.
56 Globs are expanded and each file is visited once. An operand containing `$` is
57 skipped: it is a make variable this parser cannot resolve, and guessing is worse
58 than omitting.
59 """
60 root = find_makefile(target_dir)
61 if root is None:
62 return []
63 chain: list[Path] = []
64 seen: set[Path] = set()
66 def _walk(path: Path, remaining: int) -> None:
67 resolved = path.resolve()
68 if resolved in seen or not path.is_file():
69 return
70 seen.add(resolved)
71 chain.append(path)
72 if remaining <= 0:
73 return
74 for operands in _INCLUDE.findall(path.read_text(encoding="utf-8", errors="ignore")):
75 for operand in operands.split():
76 if "$" in operand:
77 continue
78 for included in sorted(target_dir.glob(operand)):
79 _walk(included, remaining - 1)
81 _walk(root, depth)
82 return chain
85def documented_targets(target_dir: Path) -> dict[str, str]:
86 """Return the repo's self-documenting `make` targets, mapped to their descriptions.
88 The gate list in the prose is the **Python** profile. A Go or Rust repo synced from
89 a sibling template offers a different set, and naming those from a table here would
90 mean asserting targets for templates this plugin has never seen — the failure mode
91 that had `/quality` scoring repos against gates that did not exist.
93 So they are discovered instead, from the ``target: ## description`` convention every
94 rhiza Makefile uses to build ``make help`` — across the whole include chain, because
95 that is where make itself looks (see :func:`makefile_chain`). What comes back is what
96 the repo really offers, whatever language it is.
97 """
98 found: dict[str, str] = {}
99 for makefile in makefile_chain(target_dir):
100 for name, description in _DOCUMENTED.findall(
101 makefile.read_text(encoding="utf-8", errors="ignore")
102 ):
103 found.setdefault(name, description.strip())
104 return found