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

1"""The Go language layer: go.mk, as tasks. 

2 

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. 

6 

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

14 

15from __future__ import annotations 

16 

17import shutil 

18 

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 

25 

26from ..config import Config 

27from ..spec import Failed, Guard, have, task 

28from ..uv import capture, tool 

29from .quality import install_hooks 

30 

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. 

39 

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

45 

46COVERAGE_PROFILE = "_tests/coverage.out" 

47"""Where ``go test -coverprofile`` writes, spelled with forward slashes on every OS. 

48 

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

53 

54MANIFEST = Guard(file="go.mod", reason="no go.mod") 

55"""What every Go gate is guarded on: the module file, not a source folder.""" 

56 

57 

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. 

61 

62 Args: 

63 cfg: The resolved config. 

64 

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

70 

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

76 

77 install_hooks(cfg) 

78 

79 

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/``. 

83 

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. 

87 

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

100 

101 

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. 

112 

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) 

119 

120 

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. 

131 

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. 

136 

137 ``-covermode=atomic`` because the default ``set`` mode is not race-safe and ``test`` 

138 runs a race build. 

139 

140 Args: 

141 cfg: The resolved config. 

142 

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 ) 

169 

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

177 

178 

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. 

189 

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) 

195 

196 

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. 

207 

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. 

212 

213 Args: 

214 cfg: The resolved config. 

215 """ 

216 tool(_tool_path(cfg, "revive"), "-config", "revive.toml", "-set_exit_status", "./...", cwd=cfg.root) 

217 

218 

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. 

229 

230 Args: 

231 cfg: The resolved config. 

232 """ 

233 tool(_tool_path(cfg, "govulncheck"), "./...", cwd=cfg.root) 

234 

235 

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. 

246 

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. 

251 

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) 

261 

262 

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``. 

273 

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. 

276 

277 Args: 

278 cfg: The resolved config. 

279 """ 

280 tool("go", "mod", "tidy", "-diff", cwd=cfg.root) 

281 

282 

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. 

292 

293 Args: 

294 cfg: Unused; the prerequisites do the work. 

295 """ 

296 

297 

298def _cobertura(cfg: Config, profile: Path, target: Path) -> None: 

299 """Convert a Go coverage profile to Cobertura XML. 

300 

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. 

305 

306 Args: 

307 cfg: The resolved config. 

308 profile: The ``go test -coverprofile`` output. 

309 target: Where to write the Cobertura XML. 

310 

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

325 

326 

327def _total_coverage(cfg: Config) -> float | None: 

328 """Return the total coverage percentage, or None when it cannot be read. 

329 

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. 

332 

333 Args: 

334 cfg: The resolved config. 

335 

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 

350 

351 

352def _bin_dir(cfg: Config) -> Path: 

353 """Return the directory ``go-tools`` installs into. 

354 

355 Args: 

356 cfg: The resolved config. 

357 

358 Returns: 

359 ``<root>/bin``. 

360 """ 

361 return cfg.root / "bin" 

362 

363 

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/``. 

366 

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. 

370 

371 Args: 

372 cfg: The resolved config. 

373 name: The tool's binary name. 

374 

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)