cab3e935d6
Write SPEC.md v0.1 from the owner interview (requirements, data model, stack, API outline, security, business rules, deployment, milestones, UX sprint brief, contracts). Fill the CLAUDE.md blanks, move the Gradle pin files into android/, point the tools at android/ and server/, and record the git bootstrap exception in the handoff. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
148 lines
5.9 KiB
Python
Executable File
148 lines
5.9 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""Version drift report for android/gradle/libs.versions.toml (Plan 09, D-056).
|
||
|
||
Reads every [versions] entry, finds an artifact that uses it in [libraries] or [plugins], and asks Google Maven and
|
||
Maven Central for that artifact's maven-metadata.xml. Prints one table: current, latest stable, newest (only where a
|
||
pre-release is newer than the latest stable), and how far the current version is behind.
|
||
The Gradle wrapper version is compared with services.gradle.org. Nothing here runs from the build; run it by hand:
|
||
|
||
python3 tools/dependency-report/latest.py
|
||
|
||
Rule (CLAUDE.md › Build and verify): run it at the start of every plan. A component more than one stable minor
|
||
behind gets a line in that plan's report; a toolchain component (Gradle, AGP, Kotlin, JDK LTS) more than one line
|
||
behind triggers a Plan 09-shaped upgrade before the plan starts.
|
||
"""
|
||
import json
|
||
import re
|
||
import sys
|
||
import urllib.request
|
||
import xml.etree.ElementTree as ET
|
||
from pathlib import Path
|
||
|
||
ROOT = Path(__file__).resolve().parents[2] / "android" # the Gradle project lives in android/
|
||
REPOS = ["https://dl.google.com/android/maven2", "https://repo1.maven.org/maven2"]
|
||
# Catalog entries that are SDK levels, not artifacts.
|
||
NOT_ARTIFACTS = {"minSdk", "compileSdk", "targetSdk"}
|
||
# The toolchain is looked up by its Gradle plugin, not by the first library that shares its version.
|
||
PREFERRED = {
|
||
"agp": ("com.android.tools.build", "gradle"),
|
||
"kotlin": ("org.jetbrains.kotlin", "kotlin-gradle-plugin"),
|
||
"ksp": ("com.google.devtools.ksp", "symbol-processing-gradle-plugin"),
|
||
}
|
||
STABLE = re.compile(r"^\d+(\.\d+)*$")
|
||
|
||
|
||
def key(version):
|
||
"""Numeric parts first; a pre-release (anything after the first '-') sorts before its release."""
|
||
base, _, suffix = version.partition("-")
|
||
numbers = [int(p) if p.isdigit() else -1 for p in base.split(".")]
|
||
return numbers, (0, suffix) if suffix else (1, "")
|
||
|
||
|
||
def fetch(url):
|
||
try:
|
||
with urllib.request.urlopen(url, timeout=20) as response:
|
||
return response.read()
|
||
except OSError:
|
||
return None
|
||
|
||
|
||
def versions_of(group, name):
|
||
"""Versions from every repository together: some artifacts (early KSP) also sit, stale, on Google Maven."""
|
||
found = set()
|
||
for repo in REPOS:
|
||
body = fetch(f"{repo}/{group.replace('.', '/')}/{name}/maven-metadata.xml")
|
||
if body:
|
||
found.update(v.text for v in ET.fromstring(body).iter("version") if v.text)
|
||
return sorted(found)
|
||
|
||
|
||
def plugin_marker(plugin_id):
|
||
return plugin_id, f"{plugin_id}.gradle.plugin"
|
||
|
||
|
||
def read_catalog(text):
|
||
"""The subset of TOML the catalog uses (works on Python < 3.11, which has no tomllib): returns the [versions]
|
||
entries in file order and, per version name, the first library or plugin that refers to it."""
|
||
versions, artifacts, section = {}, {}, None
|
||
for raw in text.splitlines():
|
||
line = raw.split("#", 1)[0].strip()
|
||
if not line:
|
||
continue
|
||
if line.startswith("["):
|
||
section = line.strip("[]")
|
||
continue
|
||
if section == "versions":
|
||
match = re.match(r'([\w-]+)\s*=\s*"([^"]+)"', line)
|
||
if match:
|
||
versions[match.group(1)] = match.group(2)
|
||
continue
|
||
ref = re.search(r'version\.ref\s*=\s*"([^"]+)"', line)
|
||
if not ref or ref.group(1) in artifacts:
|
||
continue
|
||
module = re.search(r'module\s*=\s*"([^"]+)"', line)
|
||
plugin = re.search(r'\bid\s*=\s*"([^"]+)"', line)
|
||
if section == "libraries" and module:
|
||
artifacts[ref.group(1)] = tuple(module.group(1).split(":"))
|
||
elif section == "plugins" and plugin:
|
||
artifacts[ref.group(1)] = plugin_marker(plugin.group(1))
|
||
return versions, artifacts
|
||
|
||
|
||
def behind(current, stable):
|
||
if not stable or not STABLE.match(current):
|
||
return ""
|
||
c, s = [int(x) for x in current.split(".")], [int(x) for x in stable.split(".")]
|
||
c += [0] * (3 - len(c))
|
||
s += [0] * (3 - len(s))
|
||
if c[0] < s[0]:
|
||
return f"{s[0] - c[0]} major"
|
||
if c[1] < s[1]:
|
||
return f"{s[1] - c[1]} minor"
|
||
if c[2] < s[2]:
|
||
return "patch"
|
||
return ""
|
||
|
||
|
||
def row(name, coordinate, current, available):
|
||
stable = sorted((v for v in available if STABLE.match(v)), key=key)
|
||
everything = sorted(available, key=key)
|
||
latest = stable[-1] if stable else "?"
|
||
newest = everything[-1] if everything else "?"
|
||
return [name, coordinate, current, latest, newest if newest not in (latest, current) else "", behind(current, latest if stable else "")]
|
||
|
||
|
||
def gradle_row():
|
||
props = (ROOT / "gradle/wrapper/gradle-wrapper.properties").read_text()
|
||
match = re.search(r"gradle-([\d.]+)-(bin|all)\.zip", props)
|
||
current = match.group(1) if match else "?"
|
||
body = fetch("https://services.gradle.org/versions/all")
|
||
available = []
|
||
if body:
|
||
available = [v["version"] for v in json.loads(body) if not v.get("snapshot") and not v.get("nightly")]
|
||
return row("gradle (wrapper)", "services.gradle.org", current, available)
|
||
|
||
|
||
def main():
|
||
versions, artifacts = read_catalog((ROOT / "gradle/libs.versions.toml").read_text())
|
||
rows = [gradle_row()]
|
||
for name, current in versions.items():
|
||
if name in NOT_ARTIFACTS:
|
||
continue
|
||
coordinate = PREFERRED.get(name) or artifacts.get(name)
|
||
if not coordinate:
|
||
rows.append([name, "(no artifact)", str(current), "", "", ""])
|
||
continue
|
||
rows.append(row(name, ":".join(coordinate), str(current), versions_of(*coordinate)))
|
||
header = ["version", "artifact", "current", "latest stable", "newer pre-release", "behind"]
|
||
widths = [max(len(str(r[i])) for r in rows + [header]) for i in range(len(header))]
|
||
print(" | ".join(h.ljust(w) for h, w in zip(header, widths)))
|
||
print("-+-".join("-" * w for w in widths))
|
||
for r in rows:
|
||
print(" | ".join(str(c).ljust(w) for c, w in zip(r, widths)))
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|