Ctrl K
ComunidadGuías…

scripts/release_compliance.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env python3
"""Generate release dependency attribution and enforce a small license policy."""

from __future__ import annotations

import argparse
import json
import re
from collections import deque
from pathlib import Path
from typing import Any

RUNTIME_ROOTS = {"firecrab-api", "firecrab-helper", "firecrab-cli"}
NOTICE_PREFIXES = ("LICENSE", "LICENCE", "COPYING", "NOTICE", "COPYRIGHT", "AUTHORS")

# Keep the mechanical release gate deliberately small and fail closed. These are
# the SPDX identifiers present in the current shipped runtime dependency corpus,
# plus the LGPL family already permitted by the repository policy. Adding a new
# runtime license should require an explicit policy review rather than silently
# passing because its spelling happens not to contain "GPL".
ALLOWED_LICENSE_IDS = {
    "0BSD",
    "Apache-2.0",
    "BSD-2-Clause",
    "BSD-3-Clause",
    "BSL-1.0",
    "CDLA-Permissive-2.0",
    "ISC",
    "LGPL-2.0-only",
    "LGPL-2.0-or-later",
    "LGPL-2.1-only",
    "LGPL-2.1-or-later",
    "LGPL-3.0-only",
    "LGPL-3.0-or-later",
    "MIT",
    "Unlicense",
    "Unicode-3.0",
    "Zlib",
}
ALLOWED_LICENSE_EXCEPTIONS = {"LLVM-exception"}
LEGACY_LICENSE_EXPRESSIONS = {
    "Apache-2.0/MIT": "Apache-2.0 OR MIT",
    "MIT/Apache-2.0": "MIT OR Apache-2.0",
}
_LICENSE_TOKEN_RE = re.compile(
    r"\s*(\(|\)|AND\b|OR\b|WITH\b|[A-Za-z0-9][A-Za-z0-9.+-]*)",
    re.IGNORECASE,
)


def _read_json(path: str | Path) -> dict[str, Any]:
    with open(path, encoding="utf-8") as stream:
        return json.load(stream)


def _packages_by_id(metadata: dict[str, Any]) -> dict[str, dict[str, Any]]:
    return {package["id"]: package for package in metadata.get("packages", [])}


def _is_proc_macro(package: dict[str, Any]) -> bool:
    return any(
        "proc-macro" in target.get("kind", [])
        for target in package.get("targets", [])
    )


def cargo_sets(metadata: dict[str, Any]) -> tuple[set[str], set[str]]:
    """Return external (runtime, build/test-only) Cargo package IDs.

    The metadata should be produced with ``cargo metadata --filter-platform`` for
    a shipped target. Runtime traversal follows normal dependency edges from the
    distributed host binaries and browser module. A second traversal follows every dependency kind;
    anything reachable there but not at runtime is classified build/test-only.
    Proc-macro crates are compile-time even when Cargo represents their edge as a
    normal dependency.
    """
    packages = _packages_by_id(metadata)
    workspace = set(metadata.get("workspace_members", []))
    resolve = metadata.get("resolve") or {}
    nodes = {node["id"]: node for node in resolve.get("nodes", [])}

    roots = {
        package_id
        for package_id in workspace
        if packages.get(package_id, {}).get("name") in RUNTIME_ROOTS
    }
    if not roots:
        raise ValueError(
            "Cargo metadata does not contain the shipped FireCrab workspace roots"
        )

    def closure(*, runtime_only: bool) -> set[str]:
        visited: set[str] = set()
        queue: deque[str] = deque(roots)
        while queue:
            package_id = queue.popleft()
            if package_id in visited:
                continue
            visited.add(package_id)
            node = nodes.get(package_id)
            if not node:
                continue
            for dep in node.get("deps", []):
                dep_id = dep["pkg"]
                kinds = dep.get("dep_kinds") or [{"kind": None}]
                if runtime_only:
                    if not any(
                        kind.get("kind") in (None, "normal") for kind in kinds
                    ):
                        continue
                    if _is_proc_macro(packages.get(dep_id, {})):
                        continue
                queue.append(dep_id)
        return visited

    runtime = closure(runtime_only=True) - workspace
    reachable = closure(runtime_only=False) - workspace
    return runtime, reachable - runtime


def merge_cargo_sets(
    metadatas: list[dict[str, Any]],
) -> tuple[set[str], set[str]]:
    """Union dependency classifications across all shipped host and browser targets."""
    runtime: set[str] = set()
    reachable: set[str] = set()
    for metadata in metadatas:
        target_runtime, target_build = cargo_sets(metadata)
        runtime |= target_runtime
        reachable |= target_runtime | target_build
    return runtime, reachable - runtime


def npm_sets(
    lock: dict[str, Any],
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
    packages = lock.get("packages")
    if not isinstance(packages, dict):
        raise ValueError("frontend package-lock must use lockfileVersion 3 packages")

    runtime: list[dict[str, Any]] = []
    build_only: list[dict[str, Any]] = []
    for path, entry in packages.items():
        if not path or not isinstance(entry, dict):
            continue
        record = {
            "path": path,
            "name": _npm_name(path),
            "version": entry.get("version", "unknown"),
            "license": entry.get("license"),
            "source": entry.get("resolved"),
        }
        (build_only if entry.get("dev") else runtime).append(record)
    return runtime, build_only


def _npm_name(path: str) -> str:
    marker = "node_modules/"
    return path.rsplit(marker, 1)[-1] if marker in path else path


def _license_tokens(expression: str) -> list[str] | None:
    tokens: list[str] = []
    position = 0
    while position < len(expression):
        match = _LICENSE_TOKEN_RE.match(expression, position)
        if match is None:
            return None
        token = match.group(1)
        tokens.append(token.upper() if token.upper() in {"AND", "OR", "WITH"} else token)
        position = match.end()
    return tokens


def _evaluate_license_expression(tokens: list[str]) -> bool:
    position = 0

    def parse_expression() -> bool:
        nonlocal position
        value = parse_term()
        while position < len(tokens) and tokens[position] == "OR":
            position += 1
            value = parse_term() or value
        return value

    def parse_term() -> bool:
        nonlocal position
        value = parse_factor()
        while position < len(tokens) and tokens[position] == "AND":
            position += 1
            value = parse_factor() and value
        return value

    def parse_factor() -> bool:
        nonlocal position
        if position >= len(tokens):
            raise ValueError("unexpected end of license expression")
        token = tokens[position]
        if token == "(":
            position += 1
            value = parse_expression()
            if position >= len(tokens) or tokens[position] != ")":
                raise ValueError("unbalanced license expression")
            position += 1
            return value
        if token in {"AND", "OR", "WITH", ")"}:
            raise ValueError(f"unexpected token {token!r}")
        position += 1
        allowed = token in ALLOWED_LICENSE_IDS
        if position < len(tokens) and tokens[position] == "WITH":
            position += 1
            if position >= len(tokens):
                raise ValueError("missing license exception")
            exception = tokens[position]
            if exception in {"AND", "OR", "WITH", "(", ")"}:
                raise ValueError("invalid license exception")
            position += 1
            allowed = allowed and exception in ALLOWED_LICENSE_EXCEPTIONS
        return allowed

    result = parse_expression()
    if position != len(tokens):
        raise ValueError("trailing tokens in license expression")
    return result


def license_allowed(expression: str | None) -> bool:
    """Apply the repo's fail-closed mechanical release-license policy.

    Runtime license metadata must be a recognized expression composed from the
    reviewed SPDX identifiers above. OR branches pass when at least one reviewed
    choice is available; AND requires every component to be reviewed. Unknown
    identifiers, unresolved LicenseRefs, proprietary labels, malformed legacy GPL
    spellings, and blocked GPL/AGPL-only branches therefore fail closed.
    """
    if not expression:
        return False
    expression = expression.strip()
    if not expression:
        return False
    expression = LEGACY_LICENSE_EXPRESSIONS.get(expression, expression)
    tokens = _license_tokens(expression)
    if not tokens:
        return False
    try:
        return _evaluate_license_expression(tokens)
    except ValueError:
        return False


def _license_files(root: Path, explicit: str | None = None) -> list[Path]:
    candidates: list[Path] = []
    if explicit:
        path = root / explicit
        if path.is_file():
            candidates.append(path)
    if root.is_dir():
        for path in sorted(root.iterdir()):
            if path.is_file() and path.name.upper().startswith(NOTICE_PREFIXES):
                candidates.append(path)
    unique: list[Path] = []
    seen: set[Path] = set()
    for path in candidates:
        resolved = path.resolve()
        if resolved not in seen:
            seen.add(resolved)
            unique.append(path)
    return unique


def _read_notice(path: Path) -> str | None:
    try:
        data = path.read_text(encoding="utf-8")
    except (OSError, UnicodeDecodeError):
        return None
    if len(data) > 512_000:
        return None
    return data.rstrip()


def cargo_records(
    metadatas: list[dict[str, Any]], runtime_ids: set[str], build_ids: set[str]
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
    packages: dict[str, dict[str, Any]] = {}
    for metadata in metadatas:
        packages.update(_packages_by_id(metadata))

    def record(package_id: str) -> dict[str, Any]:
        package = packages[package_id]
        root = Path(package["manifest_path"]).parent
        notices = []
        for path in _license_files(root, package.get("license_file")):
            text = _read_notice(path)
            if text:
                notices.append({"name": path.name, "text": text})
        license_value = package.get("license")
        if not license_value and package.get("license_file"):
            license_value = f"LicenseRef-file:{Path(package['license_file']).name}"
        return {
            "ecosystem": "cargo",
            "name": package["name"],
            "version": package["version"],
            "license": license_value,
            "source": package.get("source") or package.get("repository"),
            "notices": notices,
        }

    missing = (runtime_ids | build_ids) - set(packages)
    if missing:
        raise ValueError(
            "Cargo metadata package table is missing resolved IDs: "
            + ", ".join(sorted(missing))
        )

    return (
        [record(package_id) for package_id in sorted(runtime_ids)],
        [record(package_id) for package_id in sorted(build_ids)],
    )


def npm_records(
    records: list[dict[str, Any]], frontend_root: Path
) -> list[dict[str, Any]]:
    output: list[dict[str, Any]] = []
    for item in records:
        notices = []
        root = frontend_root / item["path"]
        package_json = root / "package.json"
        if not item.get("license") and package_json.is_file():
            try:
                package_data = json.loads(package_json.read_text(encoding="utf-8"))
                declared = package_data.get("license")
                if isinstance(declared, dict):
                    declared = declared.get("type")
                if isinstance(declared, str):
                    item = {**item, "license": declared}
            except (OSError, json.JSONDecodeError):
                pass
        for path in _license_files(root):
            text = _read_notice(path)
            if text:
                notices.append({"name": path.name, "text": text})
        output.append({"ecosystem": "npm", **item, "notices": notices})
    return output


def _sort_key(item: dict[str, Any]) -> tuple[str, str, str]:
    return (item["ecosystem"], item["name"].lower(), str(item["version"]))


def validate_runtime(records: list[dict[str, Any]]) -> list[str]:
    failures = []
    for item in records:
        if not license_allowed(item.get("license")):
            failures.append(
                f"{item['ecosystem']}:{item['name']}@{item['version']}: "
                f"incompatible or missing license {item.get('license')!r}"
            )
    return failures


def render_notices(
    runtime: list[dict[str, Any]], build_only: list[dict[str, Any]]
) -> str:
    lines = [
        "FireCrab third-party notices",
        "============================",
        "",
        "This file is generated from target-filtered Cargo metadata and the frontend",
        "npm lockfile during the release build. Runtime entries are reachable by the",
        "distributed host binaries, the React dashboard, or non-dev npm packages.",
        "Build/test-only dependencies are inventoried separately at the end.",
        "",
        f"Runtime dependencies: {len(runtime)}",
        f"Build/test-only dependencies: {len(build_only)}",
        "",
    ]
    for item in sorted(runtime, key=_sort_key):
        lines.extend(
            [
                "-" * 78,
                f"{item['ecosystem']} :: {item['name']} {item['version']}",
                f"License: {item.get('license') or 'UNKNOWN'}",
            ]
        )
        if item.get("source"):
            lines.append(f"Source: {item['source']}")
        notices = item.get("notices") or []
        if notices:
            for notice in notices:
                lines.extend(["", f"[{notice['name']}]", notice["text"]])
        else:
            lines.append("Notice text: not present in the installed package payload")
        lines.append("")

    lines.extend(["=" * 78, "Build/test-only inventory", "=" * 78, ""])
    for item in sorted(build_only, key=_sort_key):
        lines.append(
            f"{item['ecosystem']} :: {item['name']} {item['version']} :: "
            f"{item.get('license') or 'UNKNOWN'}"
        )
    return "\n".join(lines).rstrip() + "\n"


def inventory(
    runtime: list[dict[str, Any]], build_only: list[dict[str, Any]]
) -> dict[str, Any]:
    def compact(item: dict[str, Any]) -> dict[str, Any]:
        return {
            key: item.get(key)
            for key in ("ecosystem", "name", "version", "license", "source")
        }

    return {
        "schemaVersion": 1,
        "runtime": [compact(item) for item in sorted(runtime, key=_sort_key)],
        "buildTestOnly": [
            compact(item) for item in sorted(build_only, key=_sort_key)
        ],
    }


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument(
        "--cargo-metadata",
        required=True,
        action="append",
        help="target-filtered Cargo metadata JSON; repeat for each shipped target",
    )
    parser.add_argument("--frontend-lock", required=True)
    parser.add_argument("--frontend-root", required=True)
    parser.add_argument("--notices-out", required=True)
    parser.add_argument("--inventory-out", required=True)
    parser.add_argument("--deny-incompatible", action="store_true")
    args = parser.parse_args(argv)

    cargo_metadatas = [_read_json(path) for path in args.cargo_metadata]
    lock = _read_json(args.frontend_lock)
    runtime_ids, build_ids = merge_cargo_sets(cargo_metadatas)
    cargo_runtime, cargo_build = cargo_records(
        cargo_metadatas, runtime_ids, build_ids
    )
    npm_runtime_raw, npm_build_raw = npm_sets(lock)
    npm_runtime = npm_records(npm_runtime_raw, Path(args.frontend_root))
    npm_build = npm_records(npm_build_raw, Path(args.frontend_root))

    runtime = cargo_runtime + npm_runtime
    build_only = cargo_build + npm_build
    failures = validate_runtime(runtime)

    notices_out = Path(args.notices_out)
    inventory_out = Path(args.inventory_out)
    notices_out.parent.mkdir(parents=True, exist_ok=True)
    inventory_out.parent.mkdir(parents=True, exist_ok=True)
    notices_out.write_text(render_notices(runtime, build_only), encoding="utf-8")
    inventory_out.write_text(
        json.dumps(inventory(runtime, build_only), indent=2, sort_keys=True) + "\n",
        encoding="utf-8",
    )

    print(
        f"release compliance: {len(runtime)} runtime, "
        f"{len(build_only)} build/test-only dependencies"
    )
    if failures:
        for failure in failures:
            print(f"ERROR: {failure}")
        return 1 if args.deny_incompatible else 0
    return 0


if __name__ == "__main__":
    raise SystemExit(main())