Coverage for scripts / ci / verify-manifest-tools.py: 24%

140 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2026-04-03 18:53 +0000

1#!/usr/bin/env python3 

2"""Verify installed tools against the manifest inside a container image.""" 

3 

4from __future__ import annotations 

5 

6import argparse 

7import json 

8import os 

9import re 

10import subprocess 

11import sys 

12from collections.abc import Iterable 

13from typing import Any 

14 

15_VERSION_RE = re.compile(r"\d+(?:\.\d+){1,3}") 

16 

17 

18def _run(cmd: list[str]) -> tuple[int, str]: 

19 try: 

20 result = subprocess.run( 

21 cmd, 

22 check=False, 

23 capture_output=True, 

24 text=True, 

25 timeout=10, 

26 ) 

27 except FileNotFoundError: 

28 return 127, "" 

29 except subprocess.TimeoutExpired as exc: 

30 stdout = ( 

31 exc.stdout.decode() if isinstance(exc.stdout, bytes) else (exc.stdout or "") 

32 ) 

33 stderr = ( 

34 exc.stderr.decode() if isinstance(exc.stderr, bytes) else (exc.stderr or "") 

35 ) 

36 output = stdout + stderr 

37 if not output: 

38 output = "Command timed out" 

39 return 124, output.strip() 

40 output = (result.stdout or "") + (result.stderr or "") 

41 return result.returncode, output.strip() 

42 

43 

44def _parse_version(output: str, tool_name: str) -> str | None: 

45 if tool_name == "clippy": 

46 match = re.search(r"clippy\s+0\.1\.(\d+)", output, re.IGNORECASE) 

47 if match: 

48 return f"1.{match.group(1)}.0" 

49 match = _VERSION_RE.search(output) 

50 if not match: 

51 return None 

52 return match.group(0) 

53 

54 

55def _tool_command( 

56 tool_name: str, 

57 install: dict[str, Any], 

58 tool_entry: dict[str, Any] | None = None, 

59 *, 

60 manifest_version: int = 1, 

61) -> list[str]: 

62 # v2: version_command at top level only; v1 compat: fall back to install 

63 version_command: list[str] | None = None 

64 if manifest_version >= 2 and tool_entry is not None: 

65 version_command = tool_entry.get("version_command") 

66 if not isinstance(version_command, list) or not version_command: 

67 raise ValueError( 

68 f"v2 manifest: tool {tool_name!r} requires a non-empty " 

69 f"'version_command' list, got {version_command!r}", 

70 ) 

71 bad = [t for t in version_command if not isinstance(t, str) or not t.strip()] 

72 if bad: 

73 raise ValueError( 

74 f"v2 manifest: tool {tool_name!r} has invalid " 

75 f"version_command tokens: {bad!r}", 

76 ) 

77 else: 

78 version_command = ( 

79 tool_entry.get("version_command") if tool_entry else None 

80 ) or install.get("version_command", []) 

81 if isinstance(version_command, list) and version_command: 

82 bad = [t for t in version_command if not isinstance(t, str) or not t.strip()] 

83 if bad: 

84 raise ValueError( 

85 f"tool {tool_name!r} has invalid version_command tokens: {bad!r}", 

86 ) 

87 return version_command 

88 

89 return _fallback_version_command(tool_name, install) 

90 

91 

92def _fallback_version_command( 

93 tool_name: str, 

94 install: dict[str, Any], 

95) -> list[str]: 

96 """Resolve a version command when no explicit version_command is set. 

97 

98 Uses tool-specific overrides for known tools that don't follow the 

99 standard ``<binary> --version`` pattern. Falls back to the install 

100 block's ``bin`` field (or the tool name) plus ``--version``. 

101 

102 Args: 

103 tool_name: Canonical tool name. 

104 install: The ``install`` block from the manifest entry. 

105 

106 Returns: 

107 A list of strings suitable for ``subprocess.run``. 

108 """ 

109 bin_name = install.get("bin") if isinstance(install, dict) else None 

110 

111 if tool_name == "cargo_audit": 

112 return ["cargo", "audit", "--version"] 

113 if tool_name == "cargo_deny": 

114 return ["cargo", "deny", "--version"] 

115 if tool_name == "clippy": 

116 return ["cargo", "clippy", "--version"] 

117 if tool_name == "markdownlint": 

118 return [bin_name or "markdownlint-cli2", "--version"] 

119 if tool_name == "gitleaks": 

120 return [bin_name or "gitleaks", "version"] 

121 if tool_name == "rustfmt": 

122 return [bin_name or "rustfmt", "--version"] 

123 if tool_name == "shellcheck": 

124 return [bin_name or "shellcheck", "--version"] 

125 if tool_name == "taplo": 

126 return [bin_name or "taplo", "--version"] 

127 if tool_name == "actionlint": 

128 return [bin_name or "actionlint", "--version"] 

129 

130 # Default: use package binary name if provided, else tool name. 

131 return [bin_name or tool_name, "--version"] 

132 

133 

134def _load_manifest(path: str) -> tuple[list[dict[str, Any]], int]: 

135 with open(path, encoding="utf-8") as handle: 

136 data = json.load(handle) 

137 if not isinstance(data, dict): 

138 raise ValueError(f"manifest must be a JSON object, got {type(data).__name__}") 

139 tools = data.get("tools", []) 

140 if not isinstance(tools, list): 

141 raise ValueError("manifest tools must be a list") 

142 for i, entry in enumerate(tools): 

143 if not isinstance(entry, dict): 

144 raise ValueError( 

145 f"manifest tools[{i}] must be a dict, got {type(entry).__name__}", 

146 ) 

147 raw_version = data.get("version", 1) 

148 if isinstance(raw_version, bool): 

149 raise ValueError(f"manifest version must be an integer, got {raw_version!r}") 

150 if isinstance(raw_version, int): 

151 manifest_version = raw_version 

152 elif isinstance(raw_version, str) and raw_version.isdigit(): 

153 manifest_version = int(raw_version) 

154 else: 

155 raise ValueError(f"manifest version must be an integer, got {raw_version!r}") 

156 if manifest_version not in {1, 2}: 

157 raise ValueError( 

158 f"unsupported manifest version {manifest_version}, allowed: {{1, 2}}", 

159 ) 

160 return tools, manifest_version 

161 

162 

163def _iter_tools( 

164 tools: list[dict[str, Any]], 

165 tiers: Iterable[str], 

166) -> list[dict[str, Any]]: 

167 allowed = {t.strip() for t in tiers if t.strip()} 

168 selected = [] 

169 for tool in tools: 

170 tier = tool.get("tier", "tools") 

171 if tier in allowed: 

172 selected.append(tool) 

173 return selected 

174 

175 

176def main() -> int: 

177 """Verify tools in manifest.json are installed with correct versions.""" 

178 parser = argparse.ArgumentParser() 

179 parser.add_argument( 

180 "--manifest", 

181 default=os.environ.get("LINTRO_MANIFEST", "lintro/tools/manifest.json"), 

182 help="Path to manifest.json", 

183 ) 

184 parser.add_argument( 

185 "--tiers", 

186 default=os.environ.get("LINTRO_MANIFEST_TIERS", "tools"), 

187 help="Comma-separated tiers to verify (default: tools)", 

188 ) 

189 args = parser.parse_args() 

190 

191 tiers = [t.strip() for t in args.tiers.split(",")] 

192 all_tools, manifest_version = _load_manifest(args.manifest) 

193 tools = _iter_tools(all_tools, tiers) 

194 

195 if not tools: 

196 print(f"No tools found for tiers {tiers} in {args.manifest}") 

197 return 2 

198 

199 failures: list[str] = [] 

200 for tool in tools: 

201 name = str(tool.get("name", "")).strip() 

202 expected = str(tool.get("version", "")).strip() 

203 install = tool.get("install", {}) 

204 if not name or not expected: 

205 failures.append(f"{name or '<unknown>'}: missing name or version") 

206 continue 

207 

208 cmd = _tool_command( 

209 name, 

210 install if isinstance(install, dict) else {}, 

211 tool, 

212 manifest_version=manifest_version, 

213 ) 

214 code, output = _run(cmd) 

215 if code != 0: 

216 cmd_str = " ".join(cmd) 

217 failures.append(f"{name}: command failed with exit code {code} ({cmd_str})") 

218 continue 

219 

220 actual = _parse_version(output, name) 

221 if not actual: 

222 failures.append(f"{name}: failed to parse version from '{output}'") 

223 continue 

224 

225 if actual != expected: 

226 failures.append( 

227 f"{name}: version mismatch (expected {expected}, got {actual})", 

228 ) 

229 

230 if failures: 

231 print("Tool verification failed:") 

232 for item in failures: 

233 print(f" - {item}") 

234 return 1 

235 

236 print(f"Verified {len(tools)} tool(s) against manifest tiers: {', '.join(tiers)}") 

237 return 0 

238 

239 

240if __name__ == "__main__": 

241 sys.exit(main())