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
« 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."""
4from __future__ import annotations
6import argparse
7import json
8import os
9import re
10import subprocess
11import sys
12from collections.abc import Iterable
13from typing import Any
15_VERSION_RE = re.compile(r"\d+(?:\.\d+){1,3}")
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()
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)
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
89 return _fallback_version_command(tool_name, install)
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.
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``.
102 Args:
103 tool_name: Canonical tool name.
104 install: The ``install`` block from the manifest entry.
106 Returns:
107 A list of strings suitable for ``subprocess.run``.
108 """
109 bin_name = install.get("bin") if isinstance(install, dict) else None
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"]
130 # Default: use package binary name if provided, else tool name.
131 return [bin_name or tool_name, "--version"]
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
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
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()
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)
195 if not tools:
196 print(f"No tools found for tiers {tiers} in {args.manifest}")
197 return 2
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
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
220 actual = _parse_version(output, name)
221 if not actual:
222 failures.append(f"{name}: failed to parse version from '{output}'")
223 continue
225 if actual != expected:
226 failures.append(
227 f"{name}: version mismatch (expected {expected}, got {actual})",
228 )
230 if failures:
231 print("Tool verification failed:")
232 for item in failures:
233 print(f" - {item}")
234 return 1
236 print(f"Verified {len(tools)} tool(s) against manifest tiers: {', '.join(tiers)}")
237 return 0
240if __name__ == "__main__":
241 sys.exit(main())