Coverage for src/rhiza_task/tasks/go.py: 100%
97 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 04:13 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 04:13 +0000
1"""The Go language layer: go.mk, as tasks.
3The third sibling of python.py and rust.py, with the same gate names for the same reason:
4``rhiza_ci.yml`` calls ``make security`` without knowing the language, and ``book``
5consumes ``_tests/`` whatever produced it.
7Two differences from the Rust layer are Go's own, not this port's. There is no ``rustup``
8step, because ``go.mod``'s ``go`` and ``toolchain`` directives make the go command
9download a matching toolchain itself. And the helper tools are ordinary modules installed
10with ``go install`` rather than cargo subcommands, so they land in a directory this module
11has to name -- ``bin/``, the same one the Makefile shim provisions uv into, rather than
12whatever the developer's ``GOPATH`` happens to be.
13"""
15from __future__ import annotations
17import shutil
19# The one call below is a fixed argument vector, and `shell=True` appears nowhere -- which is what
20# bandit's B404 asks about. The reason sits here rather than on the suppression comment itself:
21# bandit reads everything after that marker as a comma-separated list of test IDs, so a trailing
22# explanation becomes one `Test in comment:` warning per word.
23import subprocess # nosec B404
24from pathlib import Path
26from ..config import Config
27from ..spec import Failed, Guard, have, task
28from ..uv import capture, tool
29from .quality import install_hooks
31GO_TOOLS = (
32 "github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest",
33 "golang.org/x/vuln/cmd/govulncheck@latest",
34 "github.com/google/go-licenses@latest",
35 "github.com/boumenot/gocover-cobertura@latest",
36 "github.com/mgechev/revive@latest",
37)
38"""What ``go-tools`` installs, as go.mk lists them.
40The versions are ``@latest`` because go.mk's are: it holds each in its own
41``*_VERSION ?=`` variable so that Renovate has one line to bump, and every one of those
42lines currently says ``latest``. Pinning them is a decision for the template to make in
43one place, not for this port to make silently on the way past.
44"""
46COVERAGE_PROFILE = "_tests/coverage.out"
47"""Where ``go test -coverprofile`` writes, spelled with forward slashes on every OS.
49Not ``Path.relative_to``: this string is an *argument to go*, not a filesystem operation,
50and a backslash-separated path is a different argument. go accepts the forward-slash
51spelling on Windows, and the gates run there.
52"""
54MANIFEST = Guard(file="go.mod", reason="no go.mod")
55"""What every Go gate is guarded on: the module file, not a source folder."""
58@task("install", "install the toolchain and download dependencies", section="Go", layer="go", needs=("setup",))
59def install(cfg: Config) -> None:
60 """Download the module's dependencies and install the git hooks.
62 Args:
63 cfg: The resolved config.
65 Raises:
66 Failed: When go is absent, or a step exits non-zero.
67 """
68 if not have("go"):
69 raise Failed(1, "go not found -- install it from https://go.dev/doc/install (or: brew install go)")
71 if (cfg.root / "go.mod").is_file():
72 print("[INFO] downloading dependencies")
73 tool("go", "mod", "download", cwd=cfg.root)
74 else:
75 print("[WARN] no go.mod; skipping download")
77 install_hooks(cfg)
80@task("go-tools", "install the Go tools the gates need", section="Go", layer="go")
81def go_tools(cfg: Config) -> None:
82 """Install each missing tool into the repository's ``bin/``.
84 ``GOBIN`` rather than the developer's ``GOPATH``, so a gate never depends on what
85 happens to be installed globally -- go.mk's reason, and the same directory the Makefile
86 shim uses for uv.
88 Args:
89 cfg: The resolved config.
90 """
91 target = _bin_dir(cfg)
92 target.mkdir(parents=True, exist_ok=True)
93 for spec in GO_TOOLS:
94 name = spec.rsplit("@", 1)[0].rsplit("/", 1)[-1]
95 if (target / name).exists():
96 continue
97 print(f"[INFO] installing {name}")
98 tool("go", "install", spec, cwd=cfg.root, env={"GOBIN": str(target)})
99 print(f"[INFO] all Go tools available in {target}")
102@task(
103 "test",
104 "run the test suite",
105 section="Go",
106 layer="go",
107 needs=("install",),
108 guards=(MANIFEST,),
109)
110def test(cfg: Config) -> None:
111 """Run ``go test ./...`` with the race detector and shuffled order.
113 Args:
114 cfg: The resolved config.
115 """
116 reports = cfg.root / "_tests"
117 reports.mkdir(parents=True, exist_ok=True)
118 tool("go", "test", "./...", *cfg.go_test_flags, *cfg.go_flags, cwd=cfg.root)
121@task(
122 "coverage",
123 "measure coverage and write _tests/coverage.xml",
124 section="Go",
125 layer="go",
126 needs=("install", "go-tools"),
127 guards=(MANIFEST,),
128)
129def coverage(cfg: Config) -> None:
130 """Measure coverage, convert it to Cobertura, and enforce the floor.
132 Three steps because Go's tooling splits them, and a fourth thing go.mk does in awk:
133 ``go test`` has no ``--fail-under``, so the floor is enforced by reading the ``total:``
134 line out of ``go tool cover -func``. That awk one-liner is the whole reason this is a
135 task body rather than three argument vectors.
137 ``-covermode=atomic`` because the default ``set`` mode is not race-safe and ``test``
138 runs a race build.
140 Args:
141 cfg: The resolved config.
143 Raises:
144 Failed: When coverage is below ``coverage_fail_under``.
145 """
146 reports = cfg.root / "_tests"
147 (reports / "html-coverage").mkdir(parents=True, exist_ok=True)
148 profile = cfg.root / COVERAGE_PROFILE
149 print(f"[INFO] measuring coverage (floor: {cfg.coverage_fail_under}%)")
150 tool(
151 "go",
152 "test",
153 "./...",
154 "-covermode=atomic",
155 f"-coverprofile={COVERAGE_PROFILE}",
156 *cfg.go_flags,
157 cwd=cfg.root,
158 )
159 _cobertura(cfg, profile, reports / "coverage.xml")
160 tool(
161 "go",
162 "tool",
163 "cover",
164 f"-html={COVERAGE_PROFILE}",
165 "-o",
166 "_tests/html-coverage/index.html",
167 cwd=cfg.root,
168 )
170 measured = _total_coverage(cfg)
171 if measured is None:
172 print("[WARN] could not read a total from `go tool cover -func`; floor not enforced")
173 return
174 if measured < cfg.coverage_fail_under:
175 raise Failed(1, f"coverage {measured:.1f}% is below the {cfg.coverage_fail_under}% floor")
176 print(f"[INFO] coverage {measured:.1f}% (floor: {cfg.coverage_fail_under}%)")
179@task(
180 "typecheck",
181 "vet and lint (the compiler already type-checks)",
182 section="Go",
183 layer="go",
184 needs=("install", "go-tools"),
185 guards=(MANIFEST,),
186)
187def typecheck(cfg: Config) -> None:
188 """Run ``go vet`` and golangci-lint.
190 Args:
191 cfg: The resolved config.
192 """
193 tool("go", "vet", "./...", *cfg.go_flags, cwd=cfg.root)
194 tool(_tool_path(cfg, "golangci-lint"), "run", cwd=cfg.root)
197@task(
198 "docs-coverage",
199 "fail on any undocumented exported item",
200 section="Go",
201 layer="go",
202 needs=("install", "go-tools"),
203 guards=(MANIFEST,),
204)
205def docs_coverage(cfg: Config) -> None:
206 """Run revive's ``exported`` rule over the module.
208 The closest analogue of interrogate that Go has: pass/fail on a missing doc comment
209 rather than a percentage, exactly as rust-core's ``-D missing_docs`` is. ``revive.toml``
210 is what enables that rule and no other, so its absence is a configuration gap rather
211 than something to paper over -- revive says so itself.
213 Args:
214 cfg: The resolved config.
215 """
216 tool(_tool_path(cfg, "revive"), "-config", "revive.toml", "-set_exit_status", "./...", cwd=cfg.root)
219@task(
220 "security",
221 "scan dependencies for known vulnerabilities",
222 section="Go",
223 layer="go",
224 needs=("install", "go-tools"),
225 guards=(MANIFEST,),
226)
227def security(cfg: Config) -> None:
228 """Run govulncheck over the module.
230 Args:
231 cfg: The resolved config.
232 """
233 tool(_tool_path(cfg, "govulncheck"), "./...", cwd=cfg.root)
236@task(
237 "license",
238 "run the licence compliance scan",
239 section="Go",
240 layer="go",
241 needs=("install", "go-tools"),
242 guards=(MANIFEST,),
243)
244def license_(cfg: Config) -> None:
245 """Run go-licenses, ignoring the module's own packages.
247 ``--ignore $(go list -m)`` is the load-bearing part, and it was found by rhiza's e2e
248 suite rather than by reading the tool's help: go-licenses walks the project's own
249 packages alongside its dependencies, so without it a repo with no LICENSE file of its
250 own fails the gate on *itself* -- which every freshly synced project is.
252 Args:
253 cfg: The resolved config.
254 """
255 args = ["check", "./..."]
256 if module := capture("go", "list", "-m", cwd=cfg.root):
257 args += ["--ignore", module]
258 else:
259 print("[WARN] could not read the module path; go-licenses may fail on the project itself")
260 tool(_tool_path(cfg, "go-licenses"), *args, cwd=cfg.root)
263@task(
264 "deps",
265 "report dependency drift",
266 section="Go",
267 layer="go",
268 needs=("install",),
269 guards=(MANIFEST,),
270)
271def deps(cfg: Config) -> None:
272 """Run ``go mod tidy -diff``.
274 Both halves of deptry's job in one command, and no tool to install: it reports what
275 tidy *would* change -- an unused requirement or a missing one -- and exits non-zero.
277 Args:
278 cfg: The resolved config.
279 """
280 tool("go", "mod", "tidy", "-diff", cwd=cfg.root)
283@task(
284 "all",
285 "run every gate, as CI does",
286 section="Go",
287 layer="go",
288 needs=("fmt", "test", "docs-coverage", "security", "deps", "license", "typecheck", "rhiza-test"),
289)
290def all_(cfg: Config) -> None:
291 """Aggregate, with go.mk's prerequisite list. The body is empty because ``needs`` is it.
293 Args:
294 cfg: Unused; the prerequisites do the work.
295 """
298def _cobertura(cfg: Config, profile: Path, target: Path) -> None:
299 """Convert a Go coverage profile to Cobertura XML.
301 The one recipe in the whole port that genuinely needs a pipe: gocover-cobertura reads
302 stdin and writes stdout, so there is no argument vector that expresses it. Handled here
303 with two file handles rather than by giving :mod:`~rhiza_task.uv` a redirection feature
304 nothing else would use -- and still no shell.
306 Args:
307 cfg: The resolved config.
308 profile: The ``go test -coverprofile`` output.
309 target: Where to write the Cobertura XML.
311 Raises:
312 Failed: When the conversion exits non-zero.
313 """
314 converter = _tool_path(cfg, "gocover-cobertura")
315 print(f"[INFO] writing {target.name}")
316 with profile.open("rb") as source, target.open("wb") as out:
317 code = subprocess.call( # noqa: S603 # nosec B603
318 [shutil.which(converter) or converter],
319 cwd=cfg.root,
320 stdin=source,
321 stdout=out,
322 )
323 if code:
324 raise Failed(code, "gocover-cobertura failed")
327def _total_coverage(cfg: Config) -> float | None:
328 """Return the total coverage percentage, or None when it cannot be read.
330 Replaces go.mk's awk over ``go tool cover -func``: the total line is the only place Go
331 reports a single number, and ``go test`` has no floor of its own to set.
333 Args:
334 cfg: The resolved config.
336 Returns:
337 The percentage, or None.
338 """
339 report = capture("go", "tool", "cover", f"-func={COVERAGE_PROFILE}", cwd=cfg.root)
340 for line in reversed(report.splitlines()):
341 if line.startswith("total:"):
342 try:
343 return float(line.split()[-1].rstrip("%"))
344 except ValueError:
345 # A total line whose last field is not a number reads the same as no total
346 # line at all: None, and the caller warns that the floor went unenforced.
347 # Guessing a number here would be worse than admitting the miss.
348 return None
349 return None
352def _bin_dir(cfg: Config) -> Path:
353 """Return the directory ``go-tools`` installs into.
355 Args:
356 cfg: The resolved config.
358 Returns:
359 ``<root>/bin``.
360 """
361 return cfg.root / "bin"
364def _tool_path(cfg: Config, name: str) -> str:
365 """Return how to invoke a Go tool: from PATH if it is there, else from ``bin/``.
367 go.mk always spells these ``$(GO_BIN_DIR)/<name>``, which is right for CI and wrong for
368 a developer who installed golangci-lint through their package manager. Preferring PATH
369 costs nothing -- ``go-tools`` only ever fills the gaps.
371 Args:
372 cfg: The resolved config.
373 name: The tool's binary name.
375 Returns:
376 The name, when it is on PATH, else the absolute path under ``bin/``.
377 """
378 return name if shutil.which(name) else str(_bin_dir(cfg) / name)