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

80 statements  

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

1#!/usr/bin/env python3 

2"""Check that the Go version is consistent across project files. 

3 

4A Go project states its version in up to three places: 

5 

6* ``go.mod`` — the ``go`` directive, the minimum language version the module 

7 requires; 

8* ``go.mod`` — the optional ``toolchain`` directive, the toolchain the ``go`` 

9 command switches to for this module; 

10* ``.go-version`` — the toolchain pin honoured by goenv and ``actions/setup-go``. 

11 

12The hook enforces the three relationships between them: the ``toolchain`` 

13directive may not be below the ``go`` directive (the go command itself rejects 

14that), ``.go-version`` may not be below the ``go`` directive (the pinned 

15toolchain could not build the module), and ``.go-version`` must name the same 

16version as the ``toolchain`` directive when both are present. 

17 

18Values that are not dotted-numeric (``toolchain default``, ``toolchain local``) 

19carry no version number, so they are accepted without comparison. A leading 

20``go`` prefix (``go1.22.5``) is stripped before parsing. 

21""" 

22 

23from __future__ import annotations 

24 

25import argparse 

26import re 

27import sys 

28from pathlib import Path 

29 

30from rhiza_hooks._repo import find_repo_root 

31from rhiza_hooks._version import parse_version, same_version, version_at_least 

32 

33GO_MOD_FILE = "go.mod" 

34GO_VERSION_FILE = ".go-version" 

35 

36# Top-level ``go``/``toolchain`` directives. Anchored and requiring whitespace 

37# after the keyword, so a module path such as ``go.uber.org/zap v1.27.0`` inside 

38# a require block never matches. 

39_DIRECTIVE = re.compile(r"^(go|toolchain)\s+(\S+)") 

40 

41 

42def _normalize(value: str) -> str: 

43 """Strip surrounding whitespace and the ``go`` version prefix (``go1.22`` -> ``1.22``).""" 

44 return value.strip().removeprefix("go") 

45 

46 

47def parse_go_mod(text: str) -> dict[str, str]: 

48 r"""Extract the ``go`` and ``toolchain`` directives from ``go.mod`` text. 

49 

50 Parenthesised blocks (``require (`` … ``)``) are skipped so their contents 

51 can never be mistaken for a top-level directive. A directive repeated at top 

52 level — which the go command rejects anyway — keeps its last occurrence. 

53 

54 Args: 

55 text: Full contents of a ``go.mod`` file. 

56 

57 Returns: 

58 Mapping of directive name to its normalized value, containing only the 

59 directives actually present. 

60 

61 The ``go`` prefix is stripped, so both directives compare as plain versions: 

62 

63 >>> parse_go_mod("module example.com/m\n\ngo 1.22\ntoolchain go1.22.5\n") 

64 {'go': '1.22', 'toolchain': '1.22.5'} 

65 

66 A directive-shaped line inside a parenthesised block belongs to that block, 

67 never to the file: 

68 

69 >>> parse_go_mod("module m\n\nrequire (\n\tgo 9.99\n)\n\ngo 1.21\n") 

70 {'go': '1.21'} 

71 

72 Absent directives are absent from the result rather than defaulted: 

73 

74 >>> parse_go_mod("module m\n") 

75 {} 

76 """ 

77 directives: dict[str, str] = {} 

78 in_block = False 

79 

80 for raw_line in text.splitlines(): 

81 line = raw_line.split("//", 1)[0].strip() 

82 

83 if in_block: 

84 in_block = line != ")" 

85 continue 

86 if line.endswith("("): 

87 in_block = True 

88 continue 

89 

90 match = _DIRECTIVE.match(line) 

91 if match is not None: 

92 directives[match.group(1)] = _normalize(match.group(2)) 

93 

94 return directives 

95 

96 

97def get_go_mod_directives(repo_root: Path) -> dict[str, str]: 

98 """Read the ``go`` and ``toolchain`` directives from the repository's ``go.mod``. 

99 

100 Args: 

101 repo_root: Root directory of the repository. 

102 

103 Returns: 

104 Mapping of directive name to value; empty when ``go.mod`` is missing or 

105 unreadable. 

106 """ 

107 path = repo_root / GO_MOD_FILE 

108 if not path.exists(): 

109 return {} 

110 try: 

111 text = path.read_text(encoding="utf-8") 

112 except (OSError, UnicodeDecodeError): 

113 # A directory at the path, permission denied, a race between exists() and 

114 # read, or bytes that are not UTF-8: treat as "unspecified" rather than 

115 # crashing the hook. 

116 return {} 

117 return parse_go_mod(text) 

118 

119 

120def get_go_version_file(repo_root: Path) -> str | None: 

121 """Read the toolchain pin from ``.go-version``. 

122 

123 Args: 

124 repo_root: Root directory of the repository. 

125 

126 Returns: 

127 The normalized version string, or None if the file is missing, 

128 unreadable, or empty. 

129 """ 

130 path = repo_root / GO_VERSION_FILE 

131 if not path.exists(): 

132 return None 

133 try: 

134 text = path.read_text(encoding="utf-8") 

135 except (OSError, UnicodeDecodeError): 

136 return None 

137 return _normalize(text) or None 

138 

139 

140def _is_below(source_value: str | None, minimum_value: str | None) -> bool: 

141 """Whether *source_value* names a version strictly below *minimum_value*. 

142 

143 False whenever there is nothing to compare: either side absent (the project 

144 does not declare it) or not dotted-numeric (``toolchain default``). 

145 

146 Args: 

147 source_value: Raw version text being checked, or None if undeclared. 

148 minimum_value: Raw version text of the lower bound, or None if undeclared. 

149 

150 Returns: 

151 True only when both sides carry a version number and *source_value* is 

152 the lower of the two. 

153 """ 

154 if source_value is None or minimum_value is None: 

155 return False 

156 source = parse_version(source_value) 

157 minimum = parse_version(minimum_value) 

158 if source is None or minimum is None: 

159 return False 

160 return not version_at_least(source, minimum) 

161 

162 

163def _check_at_least( 

164 source_label: str, 

165 source_value: str | None, 

166 minimum_label: str, 

167 minimum_value: str | None, 

168) -> list[str]: 

169 """Report an error when *source_value* names a version below *minimum_value*. 

170 

171 Accepts None on either side so callers need no presence guard of their own — 

172 an undeclared version simply yields no error. 

173 """ 

174 if not _is_below(source_value, minimum_value): 

175 return [] 

176 return [f"Go version mismatch: {source_label} is {source_value}, which is below {minimum_label} {minimum_value}"] 

177 

178 

179def _check_pin_matches_toolchain(pinned: str | None, toolchain: str | None) -> list[str]: 

180 """Report a disagreement between ``.go-version`` and the ``toolchain`` directive.""" 

181 if pinned is None or toolchain is None or same_version(pinned, toolchain): 

182 return [] 

183 return [f"Go version mismatch: .go-version pins {pinned}, but the go.mod toolchain directive pins {toolchain}"] 

184 

185 

186def check_version_consistency(repo_root: Path) -> list[str]: 

187 """Check Go version consistency across project files. 

188 

189 Args: 

190 repo_root: Root directory of the repository. 

191 

192 Returns: 

193 List of error messages (empty if consistent, or if the repository 

194 declares no Go versions at all). 

195 """ 

196 directives = get_go_mod_directives(repo_root) 

197 go_directive = directives.get("go") 

198 toolchain = directives.get("toolchain") 

199 pinned = get_go_version_file(repo_root) 

200 

201 # Each helper tolerates an undeclared (None) side, so the three relationships 

202 # read as a flat list rather than a nest of presence guards. 

203 return [ 

204 *_check_at_least("go.mod toolchain", toolchain, "the go.mod go directive", go_directive), 

205 *_check_at_least(".go-version", pinned, "the go.mod go directive", go_directive), 

206 *_check_pin_matches_toolchain(pinned, toolchain), 

207 ] 

208 

209 

210def main(argv: list[str] | None = None) -> int: 

211 """Main entry point for the hook.""" 

212 parser = argparse.ArgumentParser(description="Check Go version consistency") 

213 parser.add_argument( 

214 "filenames", 

215 nargs="*", 

216 help="Filenames (ignored, checks repo root)", 

217 ) 

218 parser.parse_args(argv) # validate/consume pre-commit's filename args; result unused 

219 

220 repo_root = find_repo_root() 

221 errors = check_version_consistency(repo_root) 

222 

223 if errors: 

224 for error in errors: 

225 print(f"ERROR: {error}", file=sys.stderr) 

226 return 1 

227 

228 return 0 

229 

230 

231if __name__ == "__main__": # pragma: no mutate 

232 sys.exit(main())