Explorar o código

Vendor dependencies per platform instead of Linux-x86_64 only

vendor_chattolib.sh ran `uv pip install --target` without a platform
target, so the committed tree carried whatever wheels the machine
running the script happened to need. It was built on Linux x86_64,
which made vendor/ unusable everywhere else: on macOS the import chain
chattolib -> connectrpc -> pyqwest dies with "slice is not valid mach-o
file", so the plugin could not even be imported, let alone tested.

Only three of the fourteen packages ship compiled code (pyqwest,
protobuf, protobuf-py-ext, ~16 MB), the rest is pure Python (~8 MB).
Split the tree accordingly:

    vendor/common/              shared by all platforms
    vendor/platform/<tag>/      compiled extensions per platform

The script now builds one tree per platform via --python-platform (all
seven common targets have upstream wheels; linux-x86_64, linux-aarch64
and macos-arm64 are vendored by default) and vendor_split.py separates
pure from binary distributions using the Root-Is-Purelib marker,
moving files per dist-info/RECORD so the shared google/ namespace lands
in the right place. It verifies afterwards that the shared part really
is byte-identical across platforms, and that chattolib imports.

Wheels are resolved for Python 3.11, where the binary packages still
publish cp310-abi3 wheels; the stable ABI is forward compatible, so a
single build covers 3.11 through 3.14 (verified by importing the abi3
build on 3.14). Resolving for 3.12+ would pull version-locked cp312
wheels instead.

vendor_path.py picks the matching directory at import time and raises a
comprehensible ImportError naming the available platforms when the
current one was not vendored. --only-binary :all: prevents uv from
building a source distribution for the host while pretending to target
another platform.
Paul Klumpp hai 1 semana
pai
achega
2ce3dca698
Modificáronse 6 ficheiros con 436 adicións e 104 borrados
  1. 80 65
      VENDORING.md
  2. 8 9
      adapter.py
  3. 8 8
      platform_config.py
  4. 97 22
      vendor_chattolib.sh
  5. 106 0
      vendor_path.py
  6. 137 0
      vendor_split.py

+ 80 - 65
VENDORING.md

@@ -1,91 +1,106 @@
 # Vendoring chattolib
 
-This plugin includes a **vendored copy of chattolib** in the `chattolib_vendor/` directory.
-This means you don't need to install chattolib separately — it's bundled with the plugin.
+This plugin ships a **vendored copy of chattolib and all its dependencies** in
+`vendor/`. Nothing has to be installed separately — the plugin brings its own
+dependency tree.
 
 ## Why Vendoring?
 
 - **No dependencies to install**: `hermes plugin install` works immediately
-- **No lazy-import complexity**: No need for `ensure()` or `lazy_import` mechanisms
-- **Isolated**: The vendored chattolib won't conflict with any system-installed version
-- **Offline-friendly**: Works in air-gapped environments
+- **No lazy-import complexity**: no `ensure()` or `lazy_import` mechanisms
+- **Isolated**: the vendored copy cannot conflict with a system-installed one
+- **Offline-friendly**: works in air-gapped environments
 
-## Updating the Vendored chattolib
+## Layout
 
-To update to a new version of chattolib:
+Three of the vendored packages contain compiled extension modules and are
+therefore platform-specific: `pyqwest` (~15 MB, the HTTP core used by
+`connectrpc`), `protobuf` and `protobuf-py-ext`. Everything else is pure
+Python. The tree is split accordingly, so the shared part is stored once:
 
-```bash
-# Run the vendoring script
-./vendor_chattolib.sh [version]
-
-# Example: update to 0.5.0
-./vendor_chattolib.sh 0.5.0
-
-# Or use default (latest known stable)
-./vendor_chattolib.sh
+```
+vendor/
+├── __init__.py
+├── common/                    # pure-Python packages (~8 MB), all platforms
+│   ├── chattolib/
+│   ├── connectrpc/
+│   ├── httpx/ httpcore/ h11/ anyio/ idna/ certifi/
+│   └── opentelemetry/ protobuf/ typing_extensions.py
+└── platform/                  # compiled extensions, ~16 MB per platform
+    ├── linux-x86_64/          #   pyqwest/, protobuf_ext/, google/
+    ├── linux-aarch64/
+    └── macos-arm64/
 ```
 
-The script will:
-1. Download the chattolib wheel from PyPI
-2. Extract it to `chattolib_vendor/`
-3. Also download the `websockets` dependency (needed for realtime features)
+`vendor_path.py` derives the platform tag from `platform.system()` /
+`platform.machine()` at import time and prepends
+`vendor/platform/<tag>` and `vendor/common` to `sys.path` — platform directory
+first, so the right binaries win. Both `adapter.py` and `platform_config.py`
+call `setup_vendor_path()` before importing anything from `chattolib`.
 
-## Development without Vendoring
+If the current platform was not vendored, the import fails with an explicit
+message naming the available platforms — rather than a cryptic `dlopen` error
+about an invalid Mach-O/ELF file.
 
-If you're developing and want to use your system-installed chattolib instead:
+## Updating or rebuilding
 
 ```bash
-pip install chattolib[realtime]
-```
+# Default platform set: linux-x86_64, linux-aarch64, macos-arm64
+./vendor_chattolib.sh
 
-The adapter will automatically fall back to the system-installed version if the vendored copy is not found.
+# A single platform
+PLATFORMS="linux-x86_64" ./vendor_chattolib.sh
 
-## Structure
+# Add another one (see the table below for valid tags)
+PLATFORMS="linux-x86_64 linux-aarch64 macos-arm64 windows-amd64" ./vendor_chattolib.sh
 
-```
-chattolib_vendor/
-├── __init__.py           # Marker file
-├── chattolib/           # The chattolib package
-│   ├── __init__.py
-│   ├── src/
-│   │   └── chattolib/
-│   │       ├── _pb/        # Protobuf generated files
-│   │       ├── _transport.py
-│   │       ├── client.py
-│   │       ├── realtime.py
-│   │       ├── types.py
-│   │       └── ...
-│   └── ...
-└── websockets/          # Dependency for realtime features
-    └── ...
+# Pin a specific chattolib version
+CHATTOLIB_SPEC="chattolib==0.4.20" ./vendor_chattolib.sh
 ```
 
-## License
+The script wipes `vendor/`, installs the dependency tree once per platform via
+`uv pip install --python-platform …`, splits the result with `vendor_split.py`,
+and verifies that `import chattolib` works on the machine you ran it on.
+Afterwards: `git add vendor/`.
 
-The vendored chattolib is licensed under **MPL-2.0 + Apache-2.0** (see `chattolib/LICENSE`).
-The original source is available at: https://github.com/chattocorp/chatto
+### Supported platform tags
 
-## Manual Vendoring (Alternative)
+| Tag                  | uv `--python-platform`         | vendored by default |
+| -------------------- | ------------------------------ | ------------------- |
+| `linux-x86_64`       | `x86_64-unknown-linux-gnu`     | ✅                  |
+| `linux-aarch64`      | `aarch64-unknown-linux-gnu`    | ✅                  |
+| `macos-arm64`        | `aarch64-apple-darwin`         | ✅                  |
+| `linux-x86_64-musl`  | `x86_64-unknown-linux-musl`    | —                   |
+| `linux-aarch64-musl` | `aarch64-unknown-linux-musl`   | —                   |
+| `macos-x86_64`       | `x86_64-apple-darwin`          | —                   |
+| `windows-amd64`      | `x86_64-pc-windows-msvc`       | —                   |
 
-If the script doesn't work for your environment:
+All seven are available as binary wheels upstream; only the default three are
+committed, to keep the repository at a reasonable size (~59 MB for `vendor/`).
+
+### Python versions
+
+The script resolves wheels for **Python 3.11** (`PYTHON_VERSION`), which is the
+project minimum. At that version the three binary packages still provide
+`cp310-abi3` wheels, and the stable ABI is forward compatible — so one build
+covers 3.11 through 3.14. Resolving for 3.12+ would instead pull
+version-specific wheels (`cp312-…`) that only work on that exact version.
+
+**If you raise `PYTHON_VERSION`, check that the resulting `.so` files are still
+named `*.abi3.so`.** If they are not, the vendored tree silently becomes
+Python-version-specific.
+
+## Development without vendoring
 
 ```bash
-# Create directory
-mkdir -p chattolib_vendor
-
-# Download and extract chattolib
-pip download chattolib==0.4.19 --no-deps -d /tmp
-cd /tmp
-unzip chattolib-*.whl -d chattolib_extracted
-cp -r chattolib_extracted/chattolib* ../chattolib_vendor/
-
-# Download websockets
-pip download websockets -d /tmp
-cd /tmp
-unzip websockets-*.whl -d websockets_extracted
-mkdir -p ../chattolib_vendor/websockets
-cp -r websockets_extracted/websockets* ../chattolib_vendor/websockets/
-
-# Add __init__.py
-echo "# Vendored chattolib" > chattolib_vendor/__init__.py
+pip install chattolib
 ```
+
+A system-installed chattolib is only used if `vendor/` is absent — the vendored
+copy is deliberately prepended to `sys.path` and therefore wins.
+
+## License
+
+The vendored chattolib is licensed under **MPL-2.0 + Apache-2.0** (see
+`vendor/common/chattolib-*.dist-info/`). Upstream source:
+https://github.com/chattocorp/chatto

+ 8 - 9
adapter.py

@@ -14,19 +14,18 @@ from __future__ import annotations
 
 import inspect
 import random
-import sys
-import os
-from pathlib import Path
 
 from gateway.platforms.helpers import MessageDeduplicator
 
-# 1. Den absoluten Pfad zum 'vendor'-Ordner in diesem Plugin ermitteln
-current_dir = Path(__file__).parent
-vendor_dir = current_dir / "vendor"
+# Put the vendored dependencies for THIS platform on sys.path before importing
+# anything from chattolib. Imported relatively as part of the plugin package and
+# absolutely when this module is loaded standalone (e.g. by the tests).
+try:
+    from .vendor_path import setup_vendor_path
+except ImportError:  # pragma: no cover - depends on how the module is loaded
+    from vendor_path import setup_vendor_path
 
-# 2. Den vendor-Ordner an den Anfang des Suchpfads (sys.path) setzen
-if str(vendor_dir) not in sys.path:
-    sys.path.insert(0, str(vendor_dir))
+setup_vendor_path()
 
 import asyncio
 import hashlib

+ 8 - 8
platform_config.py

@@ -4,17 +4,17 @@ Chatto Platform Config
 
 """
 
-import sys
 import os
-from pathlib import Path
 
-# 1. Den absoluten Pfad zum 'vendor'-Ordner in diesem Plugin ermitteln
-current_dir = Path(__file__).parent
-vendor_dir = current_dir / "vendor"
+# Put the vendored dependencies for THIS platform on sys.path before importing
+# anything from chattolib. Imported relatively as part of the plugin package and
+# absolutely when this module is loaded standalone (e.g. by the tests).
+try:
+    from .vendor_path import setup_vendor_path
+except ImportError:  # pragma: no cover - depends on how the module is loaded
+    from vendor_path import setup_vendor_path
 
-# 2. Den vendor-Ordner an den Anfang des Suchpfads (sys.path) setzen
-if str(vendor_dir) not in sys.path:
-    sys.path.insert(0, str(vendor_dir))
+setup_vendor_path()
 
 import logging
 from typing import Any, Dict, Generic, Optional, TypeVar

+ 97 - 22
vendor_chattolib.sh

@@ -1,38 +1,113 @@
 #!/bin/bash
 # Vendoring script for hermes-chatto-plugin
-# Downloads dependencies from pyproject.toml using uv and vendors them into chattolib_vendor/
-# Usage: ./vendor_chattolib.sh
+#
+# Downloads chattolib and all its dependencies with uv and vendors them into
+# vendor/. Three of them (pyqwest, protobuf, protobuf-py-ext) ship compiled
+# extension modules, so the tree is split by platform:
+#
+#   vendor/common/                 pure-Python packages, shared
+#   vendor/platform/<tag>/         compiled extensions, one dir per platform
+#
+# vendor_path.py picks the right directory at import time.
+#
+# Usage:
+#   ./vendor_chattolib.sh                       # all default platforms
+#   PLATFORMS="linux-x86_64" ./vendor_chattolib.sh
+#   CHATTOLIB_SPEC="chattolib==0.4.20" ./vendor_chattolib.sh
 
 set -euo pipefail
 
 PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
 VENDOR_DIR="$PLUGIN_DIR/vendor"
-echo "Vendoring dependencies from pyproject.toml using uv..."
+BUILD_DIR="$(mktemp -d)"
+trap 'rm -rf "$BUILD_DIR"' EXIT
+
+CHATTOLIB_SPEC="${CHATTOLIB_SPEC:-chattolib>=0.4.20}"
+
+# Wheels are resolved for this Python version. 3.11 is the project's minimum
+# (pyproject: requires-python) AND the version for which the binary packages
+# still provide cp310-abi3 wheels — those are forward compatible, so one build
+# covers 3.11, 3.12, 3.13 and 3.14. Do not raise this without checking that
+# the resulting .so files are still abi3.
+PYTHON_VERSION="${PYTHON_VERSION:-3.11}"
+
+# Platform tag (as used in vendor/platform/ and by vendor_path.py) mapped to
+# the uv --python-platform target. Add entries here to support more platforms;
+# vendor_path.py derives the same tags from platform.system()/machine().
+platform_target() {
+    case "$1" in
+        linux-x86_64)       echo "x86_64-unknown-linux-gnu" ;;
+        linux-aarch64)      echo "aarch64-unknown-linux-gnu" ;;
+        linux-x86_64-musl)  echo "x86_64-unknown-linux-musl" ;;
+        linux-aarch64-musl) echo "aarch64-unknown-linux-musl" ;;
+        macos-arm64)        echo "aarch64-apple-darwin" ;;
+        macos-x86_64)       echo "x86_64-apple-darwin" ;;
+        windows-amd64)      echo "x86_64-pc-windows-msvc" ;;
+        *)                  return 1 ;;
+    esac
+}
+
+# Default set: Linux (deployment, both architectures) and Apple Silicon (dev).
+DEFAULT_PLATFORMS="linux-x86_64 linux-aarch64 macos-arm64"
+read -r -a PLATFORM_LIST <<< "${PLATFORMS:-$DEFAULT_PLATFORMS}"
+
+if ! command -v uv > /dev/null 2>&1; then
+    echo "❌ uv nicht gefunden — siehe https://docs.astral.sh/uv/" >&2
+    exit 1
+fi
+
+echo "📦 Vendoring '$CHATTOLIB_SPEC' für: ${PLATFORM_LIST[*]}"
+echo "   (Python $PYTHON_VERSION, nur Binär-Wheels — kein Bauen aus Sourcen)"
+
+for tag in "${PLATFORM_LIST[@]}"; do
+    if ! target="$(platform_target "$tag")"; then
+        echo "❌ Unbekannte Plattform '$tag'. Bekannt: linux-x86_64, linux-aarch64," >&2
+        echo "   linux-x86_64-musl, linux-aarch64-musl, macos-arm64, macos-x86_64, windows-amd64" >&2
+        exit 1
+    fi
+    echo "  → $tag ($target)"
+    # --only-binary :all: is required: a source distribution could not be built
+    # for a foreign platform, and building it for the host would silently
+    # produce artifacts for the wrong architecture.
+    uv pip install \
+        --target "$BUILD_DIR/$tag" \
+        --python-platform "$target" \
+        --python-version "$PYTHON_VERSION" \
+        --only-binary :all: \
+        --quiet \
+        "$CHATTOLIB_SPEC"
+done
 
 echo "🧹 Bereinige alten vendor-Ordner..."
 rm -rf "$VENDOR_DIR"
 mkdir -p "$VENDOR_DIR"
 
-# Create a temporary virtual environment
-uv venv .venv --clear
-
-# Set environment variables for the venv
-# shellcheck disable=SC1091
-source .venv/bin/activate
-cd "${PLUGIN_DIR}"
+echo "✂️  Trenne plattformunabhängige von binären Paketen..."
+python3 "$PLUGIN_DIR/vendor_split.py" "$BUILD_DIR" "$VENDOR_DIR" "${PLATFORM_LIST[@]}"
 
-uv pip install --target "$VENDOR_DIR" 'chattolib>=0.4.20'
-echo "📦 Installiere chattolib und alle Abhängigkeiten in den vendor-Ordner..."
-# pip install --target installiert das Paket und ALLE Abhängigkeiten (wie connectrpc)
-# direkt in den angegebenen Ordner, völlig isoliert vom restlichen System.
-
-# Create __init__.py to make it importable
-echo "📄 Erstelle __init__.py..."
+echo "📄 Erstelle __init__.py Marker..."
 touch "$VENDOR_DIR/__init__.py"
-touch "$VENDOR_DIR/chattolib/__init__.py"
 
-echo "Lösche unnötige Meta-Ordner, um Platz zu sparen"
-find "$VENDOR_DIR" -type d -name "__pycache__" -exec rm -rf {} +
+find "$VENDOR_DIR" -type d -name "__pycache__" -exec rm -rf {} + 2> /dev/null || true
+
+echo "🔍 Verifiziere Import für die aktuelle Plattform..."
+if python3 -c "
+import sys
+sys.path.insert(0, '$PLUGIN_DIR')
+from vendor_path import setup_vendor_path, platform_tag
+try:
+    setup_vendor_path()
+except ImportError as exc:
+    print(f'   ⓘ  {exc}')
+    sys.exit(0)
+import chattolib.client
+print(f'   ✓ chattolib importiert ({platform_tag()})')
+"; then
+    :
+else
+    echo "❌ Import-Verifikation fehlgeschlagen" >&2
+    exit 1
+fi
 
-echo "✓ Dependencies vendored successfully to ${PLUGIN_DIR}/vendor/chattolib/"
-echo "do 'git add vendor/' now or later"
+echo "✓ Fertig: $(du -sh "$VENDOR_DIR" | cut -f1) in $VENDOR_DIR"
+echo "  'git add vendor/' nicht vergessen."

+ 106 - 0
vendor_path.py

@@ -0,0 +1,106 @@
+"""Locate the vendored dependency tree matching this interpreter's platform.
+
+``vendor/`` is split into a platform-independent part and one directory per
+supported platform, because three of the vendored packages (pyqwest, protobuf,
+protobuf-py-ext) ship compiled extension modules::
+
+    vendor/
+      common/                    pure-Python packages, shared by all platforms
+      platform/
+        linux-x86_64/            compiled extensions for that platform only
+        linux-aarch64/
+        macos-arm64/
+
+Both directories are prepended to ``sys.path`` (platform first), so the
+vendored copies win over anything installed system-wide. Run
+``./vendor_chattolib.sh`` to (re)build the tree.
+"""
+
+from __future__ import annotations
+
+import platform
+import sys
+from pathlib import Path
+from typing import List
+
+VENDOR_DIR = Path(__file__).parent / "vendor"
+
+# Directory name per (system, machine) — keep in sync with PLATFORMS in
+# vendor_chattolib.sh.
+_MACHINE_ALIASES = {
+    "x86_64": "x86_64",
+    "amd64": "x86_64",
+    "aarch64": "aarch64",
+    "arm64": "aarch64",
+}
+
+
+def _is_musl() -> bool:
+    """True when running against musl libc (Alpine) rather than glibc."""
+    try:
+        libc, _version = platform.libc_ver()
+    except OSError:
+        return False
+    # glibc reports "glibc"; musl reports an empty string.
+    return libc != "glibc"
+
+
+def platform_tag() -> str:
+    """Return the vendor/platform/ subdirectory name for this interpreter."""
+    system = platform.system().lower()
+    machine = _MACHINE_ALIASES.get(platform.machine().lower(), platform.machine().lower())
+
+    if system == "linux":
+        return f"linux-{machine}-musl" if _is_musl() else f"linux-{machine}"
+    if system == "darwin":
+        return f"macos-{'arm64' if machine == 'aarch64' else machine}"
+    if system == "windows":
+        return f"windows-{'amd64' if machine == 'x86_64' else machine}"
+    return f"{system}-{machine}"
+
+
+def available_platforms() -> List[str]:
+    """Platform directories that were actually vendored into this checkout."""
+    platform_root = VENDOR_DIR / "platform"
+    if not platform_root.is_dir():
+        return []
+    return sorted(p.name for p in platform_root.iterdir() if p.is_dir())
+
+
+def setup_vendor_path() -> List[str]:
+    """Prepend the vendored packages for this platform to ``sys.path``.
+
+    Returns the directories that were added. Raises ImportError when this
+    platform was not vendored — a clear message beats the dlopen error you
+    would otherwise get from a foreign-architecture .so file.
+    """
+    tag = platform_tag()
+    platform_dir = VENDOR_DIR / "platform" / tag
+    common_dir = VENDOR_DIR / "common"
+
+    # Legacy flat layout (single platform, everything directly in vendor/).
+    if not platform_dir.is_dir() and not common_dir.is_dir():
+        if (VENDOR_DIR / "chattolib").is_dir():
+            paths = [VENDOR_DIR]
+        else:
+            raise ImportError(
+                f"Chatto: no vendored dependencies found in {VENDOR_DIR}. "
+                f"Run ./vendor_chattolib.sh to build them."
+            )
+    elif not platform_dir.is_dir():
+        raise ImportError(
+            f"Chatto: no vendored dependencies for platform '{tag}'. "
+            f"Available: {', '.join(available_platforms()) or 'none'}. "
+            f"Run 'PLATFORMS={tag} ./vendor_chattolib.sh' to add it."
+        )
+    else:
+        # Platform-specific extensions must shadow anything in common/.
+        paths = [platform_dir, common_dir]
+
+    added = []
+    for path in paths:
+        entry = str(path)
+        if entry not in sys.path:
+            sys.path.insert(0, entry)
+            added.append(entry)
+    return added

+ 137 - 0
vendor_split.py

@@ -0,0 +1,137 @@
+"""Split per-platform install trees into vendor/common/ + vendor/platform/<tag>/.
+
+Called by vendor_chattolib.sh. Each distribution is classified via its
+dist-info/WHEEL marker: ``Root-Is-Purelib: true`` means the wheel contains no
+compiled code and can be shared across platforms; everything else carries
+platform-specific binaries and goes into the per-platform directory.
+
+Files are moved individually (as listed in each dist-info/RECORD) rather than by
+top-level directory, so namespace packages shared by several distributions —
+``google/`` today — end up in the right place.
+
+Usage: vendor_split.py <build_dir> <vendor_dir> <platform_tag>...
+"""
+
+from __future__ import annotations
+
+import csv
+import filecmp
+import shutil
+import sys
+from pathlib import Path
+from typing import Dict, List, Set, Tuple
+
+
+def _is_pure(dist_info: Path) -> bool:
+    wheel = dist_info / "WHEEL"
+    if not wheel.is_file():
+        # No marker: assume platform-specific, which is the safe direction —
+        # the file gets duplicated per platform instead of being shared wrongly.
+        return False
+    for line in wheel.read_text(encoding="utf-8").splitlines():
+        if line.lower().startswith("root-is-purelib:"):
+            return line.split(":", 1)[1].strip().lower() == "true"
+    return False
+
+
+def _record_files(dist_info: Path) -> List[str]:
+    record = dist_info / "RECORD"
+    if not record.is_file():
+        return []
+    with record.open(encoding="utf-8", newline="") as handle:
+        return [row[0] for row in csv.reader(handle) if row and row[0]]
+
+
+def classify(tree: Path) -> Tuple[Set[str], Set[str]]:
+    """Return (pure_files, binary_files) as paths relative to ``tree``."""
+    pure: Set[str] = set()
+    binary: Set[str] = set()
+    for dist_info in sorted(tree.glob("*.dist-info")):
+        target = pure if _is_pure(dist_info) else binary
+        target.update(_record_files(dist_info))
+        target.add(f"{dist_info.name}/")
+    return pure, binary
+
+
+def copy_files(tree: Path, names: Set[str], dest: Path) -> int:
+    copied = 0
+    for name in sorted(names):
+        src = tree / name
+        if name.endswith("/"):
+            if src.is_dir():
+                shutil.copytree(src, dest / name, dirs_exist_ok=True)
+                copied += 1
+            continue
+        if not src.is_file():
+            continue  # RECORD lists entries that --target does not create
+        out = dest / name
+        out.parent.mkdir(parents=True, exist_ok=True)
+        shutil.copy2(src, out)
+        copied += 1
+    return copied
+
+
+def main(argv: List[str]) -> int:
+    if len(argv) < 4:
+        print(__doc__, file=sys.stderr)
+        return 2
+
+    build_dir, vendor_dir, tags = Path(argv[1]), Path(argv[2]), argv[3:]
+    common_dir = vendor_dir / "common"
+    reference: Dict[str, Path] = {}
+
+    for tag in tags:
+        tree = build_dir / tag
+        if not tree.is_dir():
+            print(f"❌ Build-Verzeichnis fehlt: {tree}", file=sys.stderr)
+            return 1
+
+        pure, binary = classify(tree)
+        overlap = {p for p in pure & binary if not p.endswith("/")}
+        if overlap:
+            print(
+                f"❌ {tag}: Dateien gehören zugleich zu einem puren und einem "
+                f"binären Paket: {sorted(overlap)[:5]}",
+                file=sys.stderr,
+            )
+            return 1
+
+        n_bin = copy_files(tree, binary, vendor_dir / "platform" / tag)
+        print(f"   {tag}: {n_bin} plattformspezifische Dateien")
+
+        if not reference:
+            n_pure = copy_files(tree, pure, common_dir)
+            reference = {"tag": tag, "files": pure}  # type: ignore[dict-item]
+            print(f"   common: {n_pure} plattformunabhängige Dateien (aus {tag})")
+        else:
+            # The shared part must really be identical, otherwise common/ would
+            # silently carry one platform's variant for all of them.
+            ref_files: Set[str] = reference["files"]  # type: ignore[assignment]
+            if pure != ref_files:
+                only_here = sorted(pure - ref_files)[:5]
+                only_ref = sorted(ref_files - pure)[:5]
+                print(
+                    f"⚠️  {tag}: pure Paketliste weicht von {reference['tag']} ab "
+                    f"(nur hier: {only_here}, nur dort: {only_ref})",
+                    file=sys.stderr,
+                )
+            mismatched = [
+                name
+                for name in sorted(pure & ref_files)
+                if not name.endswith("/")
+                and (tree / name).is_file()
+                and (common_dir / name).is_file()
+                and not filecmp.cmp(tree / name, common_dir / name, shallow=False)
+            ]
+            if mismatched:
+                print(
+                    f"⚠️  {tag}: {len(mismatched)} gemeinsame Datei(en) unterscheiden "
+                    f"sich inhaltlich, z.B. {mismatched[:3]}",
+                    file=sys.stderr,
+                )
+
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main(sys.argv))