Coverage for plugin/scripts/language_profile.py: 100%

107 statements  

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

1#!/usr/bin/env python3 

2"""Report a repo's language and the facts that follow from it — one place, not five. 

3 

4`/rhiza:init` learned Go and Rust, and nothing downstream did. `/quality` ran gates 

5named for `src/` and `pyproject.toml`, `design-analysis` reached for `radon`, and 

6`render_badges` could only describe a Python project — so a synced Go repo scored as 

7*broken* rather than as *a Go repo*, which is the same category error `/quality` 

8already guards against for unsynced repos. 

9 

10The cause was that "what this language looks like" was written down independently 

11wherever it was needed. This module is the single answer: each language's manifest, 

12source root, lockfile, toolchain pin, and the complexity tooling its ecosystem 

13actually ships. Adding a language is one entry here plus its tests, and the consumers 

14follow. 

15 

16**Facts only.** Everything here is a property of the language ecosystem — that a Rust 

17crate is described by `Cargo.toml`, that `radon` is a Python tool. Nothing about which 

18`make` targets a rhiza template provides lives here: those vary by template and 

19profile, are discovered at runtime by `check_make_targets.py`, and asserting them from 

20a table is how prose starts lying about repos it has never seen. 

21 

22Detection prefers what the repo declares over what it looks like: an explicit 

23`--language`, then `language:` in `.rhiza/template.yml`, then the manifest on disk, 

24and only then a census of the source files themselves. 

25 

26The census exists because two of the first three signals assume a managed repo, and 

27`/quality` gained a degraded mode for repos that are not one. This repo was the proof: 

28unambiguously Python, gated by mypy and interrogate, and undetectable — its version 

29lives in `.claude-plugin/` manifests, so there is no `pyproject.toml` to find. 

30 

31It is last, and it is deliberately timid. It requires a **strict majority** of the 

32counted files, so a polyglot repo with no manifest stays `unknown` rather than being 

33resolved by a plurality; and because a census says nothing about *where* the code 

34sits, it reports the repo root as the source root instead of a conventional `src/` 

35that may not exist. A wrong language is worse than an absent one — it produces a 

36confident scorecard measuring the wrong things — so every widening here is one the 

37`reason` string admits to. 

38 

39Usage: 

40 uv run --python 3.12 --no-project python \\ 

41 scripts/language_profile.py [TARGET] [--language python|go|rust] [--json] 

42 

43Exit codes: 

44 0 a language was determined 

45 1 the language could not be determined (or is not one this plugin knows) 

46""" 

47 

48from __future__ import annotations 

49 

50import argparse 

51import json 

52import os 

53import sys 

54from dataclasses import dataclass, field, replace 

55from pathlib import Path 

56 

57 

58@dataclass(frozen=True) 

59class Language: 

60 """The facts about one language ecosystem that the commands need.""" 

61 

62 name: str 

63 manifest: str 

64 """The file that declares the project — the analogue of `pyproject.toml`.""" 

65 source_root: str 

66 """Where the code lives, relative to the repo root. `.` when it isn't nested.""" 

67 lockfile: str | None 

68 toolchain_pin: str | None 

69 """Where the language version is pinned, when it is pinned in a file of its own.""" 

70 complexity: tuple[str, ...] = () 

71 """Commands that yield complexity evidence. May not be installed; the caller falls 

72 back to reading the code, exactly as `design-analysis.md` already does for radon.""" 

73 graph: tuple[str, ...] = () 

74 """Commands that expose the dependency/import graph.""" 

75 test_layout: bool = False 

76 """Whether `check_test_layout.py`'s 1:1 mirror rule applies. It is written around 

77 Python module and class naming, so it is not portable by assertion.""" 

78 aliases: tuple[str, ...] = field(default_factory=tuple) 

79 

80 

81_LANGUAGES: dict[str, Language] = { 

82 "python": Language( 

83 name="python", 

84 manifest="pyproject.toml", 

85 source_root="src", 

86 lockfile="uv.lock", 

87 toolchain_pin=".python-version", 

88 complexity=("uvx radon cc {src} -a -s", "uvx radon mi {src} -s"), 

89 graph=("uvx pydeps {src} --max-bacon=2 --no-show",), 

90 test_layout=True, 

91 ), 

92 "go": Language( 

93 name="go", 

94 manifest="go.mod", 

95 source_root=".", 

96 lockfile="go.sum", 

97 # Go pins its toolchain inside go.mod (the `go` directive), not beside it. 

98 toolchain_pin=None, 

99 complexity=("gocyclo -avg -over 15 .", "go vet ./..."), 

100 graph=("go mod graph", "go list -deps ./..."), 

101 aliases=("golang",), 

102 ), 

103 "rust": Language( 

104 name="rust", 

105 manifest="Cargo.toml", 

106 source_root="src", 

107 lockfile="Cargo.lock", 

108 toolchain_pin="rust-toolchain.toml", 

109 # clippy's cognitive_complexity is the closest analogue to radon's CC that the 

110 # stock toolchain ships; it is allow-by-default, hence the explicit -W. 

111 complexity=("cargo clippy --all-targets -- -W clippy::cognitive_complexity",), 

112 graph=("cargo tree --edges normal",), 

113 ), 

114} 

115 

116# Manifest -> language, for detecting a repo that has no pointer. Ordered, so a 

117# pyo3/maturin repo carrying both Cargo.toml and pyproject.toml resolves to rust — 

118# the Cargo manifest is the one that makes it a crate. 

119_BY_MANIFEST = (("Cargo.toml", "rust"), ("go.mod", "go"), ("pyproject.toml", "python")) 

120 

121# Suffix -> language, for the census of last resort. 

122_BY_SUFFIX = {".py": "python", ".go": "go", ".rs": "rust"} 

123 

124# Directories the census never descends into. Every one of them holds code that is not 

125# this repo's own — vendored dependencies, virtualenvs, or build output — and counting 

126# it would let a Python virtualenv decide that a Go repo is Python. 

127_CENSUS_SKIP = frozenset( 

128 {"node_modules", "vendor", "target", "build", "dist", "venv", "__pycache__", "_book"} 

129) 

130 

131 

132def languages() -> tuple[str, ...]: 

133 """Return every language name this plugin knows, in registry order.""" 

134 return tuple(_LANGUAGES) 

135 

136 

137def resolve(name: str) -> Language | None: 

138 """Return the profile for *name* (or one of its aliases), or None.""" 

139 key = name.strip().lower() 

140 for language in _LANGUAGES.values(): 

141 if key == language.name or key in language.aliases: 

142 return language 

143 return None 

144 

145 

146def declared_language(target: Path) -> str | None: 

147 """Return the `language:` declared in `.rhiza/template.yml`, if any. 

148 

149 Read with a deliberately small regex rather than the bundled YAML reader: this 

150 needs one top-level scalar, and a malformed pointer elsewhere in the file should 

151 not stop the language being found. 

152 """ 

153 pointer = target / ".rhiza" / "template.yml" 

154 if not pointer.is_file(): 

155 return None 

156 for line in pointer.read_text(encoding="utf-8", errors="ignore").splitlines(): 

157 key, sep, value = line.partition(":") 

158 if sep and key.strip() == "language": 

159 return value.strip().strip("\"'") or None 

160 return None 

161 

162 

163def census(target: Path) -> tuple[str, dict[str, int]] | None: 

164 """Count *target*'s source files by language; return the strict-majority winner. 

165 

166 Returns the winning language and the full tally (so the caller can report what it 

167 saw), or None when nothing was counted or no language holds a strict majority. 

168 Requiring a majority rather than a plurality is what keeps a repo with a handful of 

169 build scripts in another language from being misread as that language. 

170 """ 

171 counts: dict[str, int] = {} 

172 for _root, dirnames, filenames in os.walk(target): 

173 # Prune in place so os.walk never descends — cheaper than filtering after, and 

174 # it keeps a large node_modules from dominating the walk. 

175 dirnames[:] = [d for d in dirnames if d not in _CENSUS_SKIP and not d.startswith(".")] 

176 for filename in filenames: 

177 name = _BY_SUFFIX.get(Path(filename).suffix) 

178 if name is not None: 

179 counts[name] = counts.get(name, 0) + 1 

180 if not counts: 

181 return None 

182 winner = max(counts, key=lambda key: counts[key]) 

183 if counts[winner] * 2 <= sum(counts.values()): 

184 return None 

185 return winner, counts 

186 

187 

188def detect(target: Path, explicit: str | None = None) -> tuple[Language | None, str]: 

189 """Determine *target*'s language; return the profile and how it was determined.""" 

190 if explicit: 

191 return resolve(explicit), f"--language {explicit}" 

192 declared = declared_language(target) 

193 if declared: 

194 return resolve(declared), f".rhiza/template.yml declares language: {declared}" 

195 for manifest, name in _BY_MANIFEST: 

196 if (target / manifest).is_file(): 

197 return _LANGUAGES[name], f"found {manifest}" 

198 counted = census(target) 

199 if counted is not None: 

200 winner, counts = counted 

201 tally = ", ".join(f"{name} {n}" for name, n in sorted(counts.items())) 

202 # The census located files, not a layout, so the repo root is the only source 

203 # root it can honestly claim — `src/` may not exist at all, as it doesn't here. 

204 return replace(_LANGUAGES[winner], source_root="."), f"source-file census ({tally})" 

205 return ( 

206 None, 

207 "no --language, no .rhiza/template.yml, no recognised manifest, " 

208 "and no clear majority in a source-file census", 

209 ) 

210 

211 

212def facts(language: Language, target: Path) -> dict[str, object]: 

213 """Render *language* as a flat dict, with its commands' `{src}` filled in.""" 

214 src = language.source_root 

215 return { 

216 "language": language.name, 

217 "manifest": language.manifest, 

218 "manifest_present": (target / language.manifest).is_file(), 

219 "source_root": src, 

220 "lockfile": language.lockfile, 

221 "toolchain_pin": language.toolchain_pin, 

222 "complexity": [c.format(src=src) for c in language.complexity], 

223 "graph": [g.format(src=src) for g in language.graph], 

224 "test_layout_applies": language.test_layout, 

225 } 

226 

227 

228def _report(data: dict[str, object], reason: str) -> str: 

229 """Render the human-readable summary.""" 

230 lines = [f"language: {data['language']} ({reason})"] 

231 present = "present" if data["manifest_present"] else "MISSING" 

232 lines.append(f" manifest {data['manifest']} ({present})") 

233 lines.append(f" source root {data['source_root']}") 

234 lines.append(f" lockfile {data['lockfile'] or '—'}") 

235 lines.append(f" toolchain pin {data['toolchain_pin'] or '— (declared in the manifest)'}") 

236 lines.append(f" test layout {'applies' if data['test_layout_applies'] else 'not portable'}") 

237 for label, key in (("complexity", "complexity"), ("graph", "graph")): 

238 for command in data[key]: # type: ignore[attr-defined] 

239 lines.append(f" {label:<13} $ {command}") 

240 return "\n".join(lines) 

241 

242 

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

244 """Entry point: detect the language and report its facts.""" 

245 parser = argparse.ArgumentParser(description="Report a repo's language and its facts.") 

246 parser.add_argument("target", nargs="?", default=".", help="Repo root (default: cwd).") 

247 parser.add_argument("--language", help=f"Override detection ({', '.join(languages())}).") 

248 parser.add_argument("--json", action="store_true", help="Emit the facts as JSON.") 

249 args = parser.parse_args(argv) 

250 

251 target = Path(args.target).resolve() 

252 language, reason = detect(target, args.language) 

253 if language is None: 

254 message = f"could not determine the language: {reason}" 

255 if args.json: 

256 print(json.dumps({"language": None, "reason": reason}, indent=2)) 

257 else: 

258 print(message, file=sys.stderr) 

259 print(f"known languages: {', '.join(languages())}", file=sys.stderr) 

260 return 1 

261 

262 data = facts(language, target) 

263 print(json.dumps(data, indent=2) if args.json else _report(data, reason)) 

264 return 0 

265 

266 

267if __name__ == "__main__": 

268 raise SystemExit(main())