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
« 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.
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.
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.
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.
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.
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.
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.
39Usage:
40 uv run --python 3.12 --no-project python \\
41 scripts/language_profile.py [TARGET] [--language python|go|rust] [--json]
43Exit codes:
44 0 a language was determined
45 1 the language could not be determined (or is not one this plugin knows)
46"""
48from __future__ import annotations
50import argparse
51import json
52import os
53import sys
54from dataclasses import dataclass, field, replace
55from pathlib import Path
58@dataclass(frozen=True)
59class Language:
60 """The facts about one language ecosystem that the commands need."""
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)
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}
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"))
121# Suffix -> language, for the census of last resort.
122_BY_SUFFIX = {".py": "python", ".go": "go", ".rs": "rust"}
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)
132def languages() -> tuple[str, ...]:
133 """Return every language name this plugin knows, in registry order."""
134 return tuple(_LANGUAGES)
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
146def declared_language(target: Path) -> str | None:
147 """Return the `language:` declared in `.rhiza/template.yml`, if any.
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
163def census(target: Path) -> tuple[str, dict[str, int]] | None:
164 """Count *target*'s source files by language; return the strict-majority winner.
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
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 )
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 }
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)
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)
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
262 data = facts(language, target)
263 print(json.dumps(data, indent=2) if args.json else _report(data, reason))
264 return 0
267if __name__ == "__main__":
268 raise SystemExit(main())