Files
Bitsy 82ffd7cf4d feat: migrate to Compose Multiplatform 1.12.0 + SDL3 3.4.16
Upstream baseline
- Pin both vendored refs to the STABLE v1.12.0 tag (core + umbrella); the
  native and JVM-parity legs are back in lockstep, no dev-build skew.
- Catalog: compose 1.12.0, composeRuntime 1.12.0. material3 stays
  1.12.0-alpha03 - that IS the version CMP 1.12.0 ships (its own train).
- Re-synced 1560 vendored files; drift + vendor-clean both clean.
- Retire the SelectionLayout.kt manual vendor: upstream fixed the same
  out-of-bounds at the source (lastSlot = currentSlot, not currentSlot + 1),
  so the fork's index clamps are redundant. Back to verbatim vendoring.
- Re-stamp 10 manual vendors to @ v1.12.0 (upstream unchanged base..pin).
- Regenerate the klib API dumps. These had already drifted on main (the last
  dump predates the scrollbar / IME changes), so the diff covers that too.

SDL3 3.4.16, skiko fork 0.150.1-mingw.2
- Rebuild the static libSDL3.a from release-3.4.16.
- CMP 1.12.0 uses skiko 0.150.1, unchanged, so the fork's Skia base still
  matches; bump to the -mingw.2 build and centralise it as `skikoMingw` in
  the version catalog instead of three hardcoded copies.
- Resolve the fork from the PUBLIC maven.bitsycore.com/releases instead of
  the credentialed GitHub Packages repo: building the Windows target no
  longer needs a GitHub token.

Gradle module paths mirror the directory
- Drop every projectDir redirect: :ui -> :compose:ui:ui,
  :animation-core -> :compose:animation:animation-core, and so on.
- INVARIANT (documented in settings.gradle.kts + CLAUDE.md): the leaf
  directory IS the published artifactId. KMP derives a target publication's
  coordinate as <project.name>-<target>, and a target disabled on the
  building host (all Apple targets on Windows) has no publication object to
  retarget - while the root kotlinMultiplatform metadata, which the WINDOWS
  publish job owns, still emits an available-at for it. A mismatched leaf
  silently ships a root module pointing at a nonexistent <leaf>-macosarm64.
  So compose/desktop/native/window and components/resources/library are
  renamed to their artifactIds. Published coordinates are unchanged.

Manual-vendor audit
- New scripts/compose-fork/audit-exclusions.py: classifies every `!` manifest
  exclusion as manual vendor (VENDOR-BASE, drift-tracked), reimplementation
  (VENDOR-REIMPL, deliberately not tracked), or orphan, and fails on any
  unclassified one.
- 14 project reimplementations were invisible to the drift checker by
  accident; mark them VENDOR-REIMPL. None needed reconciling (all verified
  <= 0.53 similarity to their upstream counterparts, none changed upstream
  between the old pin and v1.12.0).

Fix the JVM apidemo build
- Declare components-resources on jvmMain (decodeToImageBitmap /
  decodeToSvgPainter) and add the two missing cross-package imports. The JVM
  parity target had never compiled.

Verified on Windows: both native executables link and render, apidemo JVM
builds and launches, apiCheck green, common metadata compiles (the Windows
publish gate), publishToMavenLocal emits the same coordinates as before.
2026-09-12 00:03:13 +02:00

430 lines
18 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Compose API coverage + fidelity - one script, two questions, per upstream module:
1. COVERAGE how much of each upstream module's public API do we also expose?
(per-module and per-package tables with percentages)
2. FIDELITY do we expose anything under the mirrored package prefixes that
upstream does NOT have? (invented / divergent public surface - the old
compose-fidelity-check.py, now folded in)
Both sides are Kotlin binary-compatibility-validator klib dumps, so "covered"
means the declaration exists with a compatible public shape: each dump line
carries a trailing `// <decl-id>|<mangle>` comment and we compare normalised
<decl-id> sets.
Inputs
ours: **/api/*.klib.api in this repo (regenerate: ./gradlew apiDump)
upstream: the ref clones scripts/compose-fork/sync.sh maintains -
../cmp-ref JetBrains/compose-multiplatform-core ($CMP_REF)
../cmp-ref-<name> any other repo ($CMP_REF_<NAME>)
Usage
python3 scripts/compose-coverage.py # per-module table + fidelity summary
python3 scripts/compose-coverage.py --missing # list upstream decls we DON'T cover
python3 scripts/compose-coverage.py --missing ui-util # ...for one module only
python3 scripts/compose-coverage.py --packages # per-package breakdown
python3 scripts/compose-coverage.py --extras # list every divergent decl
python3 scripts/compose-coverage.py --all # with --packages: include packages upstream lacks
Output is informational, not a hard gate (exit 0 unless inputs are missing) -
review divergences against CLAUDE.md's vendoring/fidelity rules. Known quirks
of upstream's checked-in dumps: some lag their own sources or were generated
with different BCV versions/filters (e.g. material3 misses recent experimental
API its own sources have; ui-backhandler's dump holds nothing but $stable
noise), so "extras" are review candidates, not automatically invented API.
"""
import argparse
import os
import re
import subprocess
import sys
from pathlib import Path
kRoot = Path(__file__).resolve().parent.parent
# ==================
# MARK: Targets - add a line here to track a new upstream repo / module
# ==================
# repo last path segment of the upstream repo URL. Resolves to the sync.sh
# ref clone: ../cmp-ref for compose-multiplatform-core, ../cmp-ref-<repo>
# for anything else - overridable via CMP_REF / CMP_REF_<REPO>.
# upstream module dir inside that repo; its api/<name>.klib.api is the reference.
# ours module dir in THIS repo whose dump mirrors it. Several upstream
# modules may map to one of ours (ui-graphics / ui-text merge into :ui).
kCoreRepo = "compose-multiplatform-core"
kUmbrellaRepo = "compose-multiplatform"
kTargets = [
# repo upstream module dir our module dir
(kCoreRepo, "compose/ui/ui-util", "compose/ui/ui-util"),
(kCoreRepo, "compose/ui/ui-geometry", "compose/ui/ui-geometry"),
(kCoreRepo, "compose/ui/ui-unit", "compose/ui/ui-unit"),
(kCoreRepo, "compose/ui/ui-backhandler", "compose/ui/ui-backhandler"),
(kCoreRepo, "compose/ui/ui", "compose/ui/ui"),
(kCoreRepo, "compose/ui/ui-graphics", "compose/ui/ui"),
(kCoreRepo, "compose/ui/ui-text", "compose/ui/ui"),
(kCoreRepo, "compose/animation/animation-core", "compose/animation/animation-core"),
(kCoreRepo, "compose/animation/animation", "compose/animation/animation"),
(kCoreRepo, "compose/animation/animation-graphics", "compose/animation/animation-graphics"),
(kCoreRepo, "compose/foundation/foundation-layout", "compose/foundation/foundation-layout"),
(kCoreRepo, "compose/foundation/foundation", "compose/foundation/foundation"),
(kCoreRepo, "compose/material/material-ripple", "compose/material/material-ripple"),
(kCoreRepo, "compose/material3/material3", "compose/material3/material3"),
(kCoreRepo, "navigation3/navigation3-ui", "navigation3/navigation3-ui"),
# Upstream keeps `library` as the leaf dir and sets artifactId by hand; the
# port renames the dir to the artifactId (leaf == project name == artifactId).
(kUmbrellaRepo, "components/resources/library", "components/resources/components-resources"),
]
# Packages under these prefixes are MIRRORED surface: anything we expose here
# that no upstream target has is divergent/invented API and shows up in the
# fidelity report. Project packages (com.compose.sdl.*, material symbols, ...)
# are deliberately absent - they never count as divergent.
kMirroredPrefixes = (
"androidx.compose.",
"androidx.navigation3.",
"org.jetbrains.compose.resources",
)
# The K/N targets this port builds. Upstream dumps are merged across THEIR
# targets (ios / js / macos / wasm) with `// Targets: [...]` sections gating
# target-specific decls; anything gated to targets outside this set (e.g.
# ios-only enableTraceOSLog) can never exist here and is excluded from the
# comparison, so the tables reflect the COMMON surface we can actually mirror.
kOurTargets = {"linuxX64", "linuxArm64", "macosArm64", "mingwX64"}
# ==================
# MARK: klib.api parsing
# ==================
# Property accessors collapse onto the property, constructors onto the class,
# so ours/upstream compare on stable names.
kAccessorRe = re.compile(r"\.<(get|set)-[^>]+>$")
kCtorRe = re.compile(r"\.<init>.*$")
# Merged-dump structure markers: the first `// Targets:` line is the file
# header (every target the dump covers); later ones gate the next declaration
# (and its whole block, for classes) to a subset. `// Alias:` names groups.
kTargetSectionRe = re.compile(r"^// Targets: \[([^\]]*)\]")
kAliasRe = re.compile(r"^// Alias: (\w+) => \[([^\]]*)\]")
def parseTargetList(inText, inAliases):
"""'ios, macosArm64' -> the expanded set of concrete target names."""
vSet = set()
for vName in (vPart.strip() for vPart in inText.split(",")):
if vName:
vSet |= inAliases.get(vName, {vName})
return vSet
def declIds(inPath):
"""Extract the normalised public decl-ids from a BCV .klib.api dump as
(ids, skippedIds). A decl line ends in `// <decl-id>|<mangle>`; header
comments have no `|` and no `<pkg>/<name>` shape, so requiring both filters
them out. Declarations gated by a `// Targets: [...]` section that shares
no target with kOurTargets (ios / js / wasm-only API) can never exist on
this port: they land in skippedIds - out of the coverage denominator but
still known-upstream for the fidelity check."""
vIds = set()
vSkipped = set()
vAliases = {}
vAllTargets = None # the file header's full target list; None until seen
vScopes = [] # target set in force for each currently open block
vPending = None # targets a section comment set for the NEXT decl
for vLine in inPath.read_text(encoding="utf-8", errors="ignore").splitlines():
vStripped = vLine.strip()
vAlias = kAliasRe.match(vStripped)
if vAlias:
vAliases[vAlias.group(1)] = parseTargetList(vAlias.group(2), vAliases)
continue
vSection = kTargetSectionRe.match(vStripped)
if vSection:
vTargets = parseTargetList(vSection.group(1), vAliases)
if vAllTargets is None:
vAllTargets = vTargets
else:
vPending = vTargets
continue
vIdx = vLine.find("// ")
vCode = vLine[:vIdx] if vIdx >= 0 else vLine
vComment = vLine[vIdx + 3:] if vIdx >= 0 else ""
vEffective = vPending if vPending is not None else (vScopes[-1] if vScopes else vAllTargets)
if "|" in vComment:
vId = vComment.split("|", 1)[0].strip()
if "/" in vId and "$stable" not in vId: # skip headers + stability props
vId = kCtorRe.sub("", kAccessorRe.sub("", vId))
if vEffective is not None and not (vEffective & kOurTargets):
vSkipped.add(vId)
else:
vIds.add(vId)
# Track blocks on the code part only - mangle comments contain `{}`.
for _ in range(vCode.count("{")):
vScopes.append(vEffective)
for _ in range(vCode.count("}")):
if vScopes:
vScopes.pop()
if vCode.strip() or "|" in vComment:
vPending = None # a section comment only scopes the decl right after it
return vIds, vSkipped
def packageOf(inId):
"""'androidx.compose.ui.unit/Density.toDpSize' -> 'androidx.compose.ui.unit'."""
return inId.split("/", 1)[0]
# Module dir segments too generic to identify a target on their own.
kGenericSegments = {"library", "core"}
def targetLabel(inModule):
"""Short display label for an upstream module dir: its last path segment,
plus the parent segment when the last one alone is generic
('components/resources/library' -> 'resources/library')."""
vParts = inModule.split("/")
if vParts[-1] in kGenericSegments and len(vParts) > 1:
return vParts[-2] + "/" + vParts[-1]
return vParts[-1]
# ==================
# MARK: Input discovery
# ==================
def refCloneDir(inRepo):
"""Resolve the local ref-clone dir for an upstream repo name, mirroring
scripts/compose-fork/sync.py: ../cmp-ref for compose-multiplatform-core
(override $CMP_REF), ../cmp-ref-<repo> otherwise (override $CMP_REF_<REPO>)."""
if inRepo == kCoreRepo:
return Path(os.environ.get("CMP_REF") or (kRoot.parent / "cmp-ref"))
vEnv = os.environ.get("CMP_REF_" + re.sub(r"[^A-Za-z0-9]+", "_", inRepo).upper())
return Path(vEnv) if vEnv else kRoot.parent / ("cmp-ref-" + inRepo)
def refHead(inDir):
"""Short HEAD of a ref clone, or '?' when git can't answer."""
try:
vOut = subprocess.run(["git", "-C", str(inDir), "rev-parse", "--short", "HEAD"],
capture_output=True, text=True)
return vOut.stdout.strip() or "?"
except OSError:
return "?"
def upstreamApiFile(inRepo, inModule):
"""Path to the single .klib.api reference dump of an upstream module, or
None when the clone / dump isn't there (sparse checkout not synced)."""
vApiDir = refCloneDir(inRepo) / inModule / "api"
vFiles = sorted(vApiDir.glob("*.klib.api"))
return vFiles[0] if vFiles else None
def loadOurDumps():
"""All of this repo's api/*.klib.api dumps (any nesting depth, skipping
build intermediates and libs/), as {module-dir-posix: {decl-ids}}."""
vDumps = {}
for vFile in sorted(kRoot.rglob("*.klib.api")):
vRel = vFile.relative_to(kRoot)
if vFile.parent.name != "api" or {"build", "libs", ".git"} & set(vRel.parts):
continue
vDumps[vFile.parent.parent.relative_to(kRoot).as_posix()] = declIds(vFile)[0]
return vDumps
# ==================
# MARK: Report
# ==================
def bar(inPct, inWidth=20):
"""Render a 0-100 percentage as a #/. progress bar."""
vFilled = int(round(inPct * inWidth / 100))
return "#" * vFilled + "." * (inWidth - vFilled)
def main():
"""Load both ABI universes, print the per-module coverage table, then the
optional per-package table and divergent-decl listing."""
vParser = argparse.ArgumentParser(description="Compose API coverage + fidelity vs upstream klib dumps.")
vParser.add_argument("--missing", nargs="?", const="", metavar="MODULE",
help="list upstream decls we don't cover, grouped by package (optionally one module label, e.g. ui-util)")
vParser.add_argument("--packages", action="store_true", help="per-package breakdown table")
vParser.add_argument("--extras", action="store_true", help="list every divergent decl (old fidelity-check output)")
vParser.add_argument("--all", action="store_true", help="with --packages: include our packages upstream lacks")
vArgs = vParser.parse_args()
# ============
# Load upstream refs, target by target
vUpstreamByTarget = {} # {target-label: {decl-ids}}
vOursDirByTarget = {} # {target-label: our module dir}
vMissingRefs = []
vSkippedUnion = set()
for vRepo, vModule, vOursDir in kTargets:
vLabel = targetLabel(vModule)
vFile = upstreamApiFile(vRepo, vModule)
if vFile is None:
vMissingRefs.append((vRepo, vModule))
continue
vUpstreamByTarget[vLabel], vSkipped = declIds(vFile)
vSkippedUnion |= vSkipped
vOursDirByTarget[vLabel] = vOursDir
if not vUpstreamByTarget:
print("!! No upstream klib.api dumps found -- run scripts/compose-fork/sync.sh first "
+ "(or point CMP_REF / CMP_REF_<REPO> at the clones).", file=sys.stderr)
return 1
vAllUpstream = set().union(*vUpstreamByTarget.values())
# ============
# Load our dumps
vOurDumps = loadOurDumps()
if not vOurDumps:
print("!! No local api/*.klib.api dumps -- run `./gradlew apiDump` first.", file=sys.stderr)
return 1
vOurs = set().union(*vOurDumps.values())
vOursMirrored = {vId for vId in vOurs if vId.startswith(kMirroredPrefixes)}
# Fidelity baseline includes target-gated upstream decls: implementing one
# of those is coverage beyond our targets, not invented API.
vDivergent = vOursMirrored - vAllUpstream - vSkippedUnion
# ============
# Warnings - a target whose module has no local dump reports fake 0%
for vRepo, vModule in vMissingRefs:
print(f"!! upstream dump missing: {refCloneDir(vRepo) / vModule}/api/*.klib.api "
+ "-- re-run scripts/compose-fork/sync.sh (target skipped)", file=sys.stderr)
vNoDump = sorted({vDir for vDir in vOursDirByTarget.values() if vDir not in vOurDumps})
for vDir in vNoDump:
print(f"!! no local dump for {vDir} -- its rows undercount; run `./gradlew apiDump`", file=sys.stderr)
if vMissingRefs or vNoDump:
print(file=sys.stderr)
vRepoNotes = ", ".join(f"{vRepo} @ {refHead(refCloneDir(vRepo))}"
for vRepo in dict.fromkeys(vT[0] for vT in kTargets))
print(f"Upstream: {len(vUpstreamByTarget)} modules, {len(vAllUpstream)} decls"
+ f" ({len(vSkippedUnion)} decls for targets we don't build excluded) ({vRepoNotes})")
print(f"Ours: {len(vOurDumps)} dumps, {len(vOurs)} decls ({len(vOursMirrored)} under mirrored prefixes)")
print()
# ============
# Attribute each divergent decl to the target owning its package
vPkgOwner = {} # {pkg: target-label with the most decls in pkg}
vPkgCount = {}
for vLabel, vIds in vUpstreamByTarget.items():
for vId in vIds:
vPkg = packageOf(vId)
vCount = vPkgCount.get((vPkg, vLabel), 0) + 1
vPkgCount[(vPkg, vLabel)] = vCount
if vCount > vPkgCount.get((vPkg, vPkgOwner.get(vPkg)), 0):
vPkgOwner[vPkg] = vLabel
vExtrasByTarget = {}
vOrphanExtras = set() # divergent decls in packages NO upstream target has
for vId in vDivergent:
vOwner = vPkgOwner.get(packageOf(vId))
if vOwner is None:
vOrphanExtras.add(vId)
else:
vExtrasByTarget.setdefault(vOwner, set()).add(vId)
# ============
# Per-module table (kTargets order)
vFmt = " {:<38} {:<28} {:>6} / {:>6} {:>5} {} {:>6}"
print(vFmt.format("upstream module", "ours", "cover", "total", "cov", " " * 20, "extras"))
print(" " + "-" * 118)
vTotalCov, vTotalUp = 0, 0
for vRepo, vModule, vOursDir in kTargets:
vLabel = targetLabel(vModule)
if vLabel not in vUpstreamByTarget:
continue
vUp = vUpstreamByTarget[vLabel]
vCov = len(vUp & vOurs)
vPct = 100.0 * vCov / len(vUp) if vUp else 0.0
vTotalCov += vCov
vTotalUp += len(vUp)
vPctStr = f"{vPct:.0f}%" if vUp else "-"
vOursNote = vOursDir.rsplit("/", 1)[-1] + (" (NO DUMP)" if vOursDir in vNoDump else "")
print(vFmt.format(vModule, vOursNote, vCov, len(vUp), vPctStr,
bar(vPct), len(vExtrasByTarget.get(vLabel, ()))))
print(" " + "-" * 118)
vOverall = 100.0 * vTotalCov / vTotalUp if vTotalUp else 0.0
print(vFmt.format("TOTAL", "", vTotalCov, vTotalUp, f"{vOverall:.0f}%", bar(vOverall), len(vDivergent)))
print()
# ============
# Missing decl listing (--missing [MODULE])
if vArgs.missing is not None:
if vArgs.missing and vArgs.missing not in vUpstreamByTarget:
print(f"!! unknown module label '{vArgs.missing}' -- valid: "
+ " ".join(vUpstreamByTarget), file=sys.stderr)
return 1
for vRepo, vModule, vOursDir in kTargets:
vLabel = targetLabel(vModule)
if vLabel not in vUpstreamByTarget or (vArgs.missing and vLabel != vArgs.missing):
continue
vMissing = sorted(vUpstreamByTarget[vLabel] - vOurs)
if not vMissing:
continue
print(f"Missing from {vLabel} -- upstream has, we don't ({len(vMissing)} decls):")
vByPkg = {}
for vId in vMissing:
vByPkg.setdefault(packageOf(vId), []).append(vId)
for vPkg in sorted(vByPkg):
print(f" [{vPkg}] ({len(vByPkg[vPkg])})")
for vId in vByPkg[vPkg]:
print(f" {vId.split('/', 1)[1]}")
print()
# ============
# Per-package table (--packages)
if vArgs.packages:
vUpByPkg = {} # {pkg: {decl-ids}}
vPkgModules = {} # {pkg: {target-labels}}
for vLabel, vIds in vUpstreamByTarget.items():
for vId in vIds:
vUpByPkg.setdefault(packageOf(vId), set()).add(vId)
vPkgModules.setdefault(packageOf(vId), set()).add(vLabel)
vRows = []
for vPkg, vUp in vUpByPkg.items():
vCov = len(vUp & vOurs)
vExtra = sum(1 for vId in vDivergent if packageOf(vId) == vPkg)
vRows.append((vPkg, vCov, len(vUp), vExtra, ",".join(sorted(vPkgModules[vPkg]))))
if vArgs.all:
for vPkg in {packageOf(vId) for vId in vOursMirrored} - set(vUpByPkg):
vExtra = sum(1 for vId in vOursMirrored if packageOf(vId) == vPkg)
vRows.append((vPkg, 0, 0, vExtra, "(not upstream)"))
vRows.sort(key=lambda vRow: -vRow[2])
vPkgFmt = " {:<52} {:>6} / {:>6} {:>5} {} {:>6} {}"
print(vPkgFmt.format("package", "cover", "total", "cov", " " * 20, "extras", "upstream module"))
print(" " + "-" * 130)
vPkgCov = vPkgUp = 0
for vPkg, vCov, vUp, vExtra, vMods in vRows:
vPct = 100.0 * vCov / vUp if vUp else 0.0
vPkgCov += vCov
vPkgUp += vUp
print(vPkgFmt.format(vPkg, vCov, vUp, f"{vPct:.0f}%" if vUp else "-", bar(vPct), vExtra, vMods))
vPkgPct = 100.0 * vPkgCov / vPkgUp if vPkgUp else 0.0
print(" " + "-" * 130)
print(vPkgFmt.format("TOTAL", vPkgCov, vPkgUp, f"{vPkgPct:.0f}%", bar(vPkgPct), len(vDivergent), ""))
print()
# ============
# Fidelity: divergent decl listing (--extras) or one-line summary
if vArgs.extras:
print(f"Divergent -- under mirrored prefixes but not upstream ({len(vDivergent)} decls):")
vByPkg = {}
for vId in sorted(vDivergent):
vByPkg.setdefault(packageOf(vId), []).append(vId)
for vPkg in sorted(vByPkg):
vOwner = vPkgOwner.get(vPkg, "(package not upstream)")
print(f" [{vPkg}] ({len(vByPkg[vPkg])}) -> {vOwner}")
for vId in vByPkg[vPkg]:
print(f" {vId.split('/', 1)[1]}")
else:
vOrphanNote = f" ({len(vOrphanExtras)} in packages upstream lacks)" if vOrphanExtras else ""
print(f"Fidelity: {len(vDivergent)} decls under mirrored prefixes not found upstream{vOrphanNote}"
+ " -- run with --extras to list them.")
return 0
if __name__ == "__main__":
sys.exit(main())