← Files Arabic DOCX RTLARCHIVED FILE

scripts/check_plugin_submission.py

12 KB · Oct 2, 2026 · 00:32 UTC

↓ Download file

#!/usr/bin/env python3
"""Validate local, directory-facing plugin submission requirements.

The checker reports categories and relative paths only.  It deliberately does
not echo source text, prompts, URLs, or other values that could end up in a
public CI log.
"""

from __future__ import annotations

import argparse
import hashlib
import json
import re
import sys
from pathlib import Path
from urllib.parse import urlparse

from PIL import Image, UnidentifiedImageError


MANIFEST_PATH = Path(".codex-plugin/plugin.json")
REQUIRED_SUBMISSION_FILES = (
    Path("submission/listing.en.md"),
    Path("submission/listing.ar.md"),
    Path("submission/reviewer-tests.json"),
    Path("submission/availability.md"),
    Path("submission/release-notes.md"),
)
PLACEHOLDER_RE = re.compile(
    r"\[\s*(?:TODO|INSERT)\b[^\]]*\]|\bCHANGE" + r"ME\b|\byour[-_ ](?:name|email|url)[-_ ]here\b",
    re.IGNORECASE,
)
COMMERCE_RE = re.compile(
    r"\b(?:buy|purchase|checkout|subscribe|subscription|paid plan|upgrade)\b",
    re.IGNORECASE,
)
MIN_IMAGE_EDGE = 48
MAX_IMAGE_EDGE = 4096
MAX_IMAGE_BYTES = 5 * 1024 * 1024


def finding(category: str, path: str, detail: str) -> dict[str, str]:
    return {"category": category, "path": path, "detail": detail}


def is_https_url(value: object) -> bool:
    if not isinstance(value, str) or not value.strip():
        return False
    parsed = urlparse(value)
    return parsed.scheme == "https" and bool(parsed.netloc)


def safe_asset_path(root: Path, value: object) -> Path | None:
    if not isinstance(value, str) or not value.strip():
        return None
    candidate = (root / value).resolve()
    try:
        candidate.relative_to(root.resolve())
    except ValueError:
        return None
    return candidate


def png_dimensions(path: Path) -> tuple[int, int] | None:
    try:
        with Image.open(path) as image:
            if image.format != "PNG":
                return None
            dimensions = image.size
            image.verify()
        if all(MIN_IMAGE_EDGE <= edge <= MAX_IMAGE_EDGE for edge in dimensions):
            with Image.open(path) as image:
                image.load()
    except (OSError, UnidentifiedImageError, SyntaxError, Image.DecompressionBombError):
        return None
    return dimensions


def file_sha256(path: Path) -> str | None:
    digest = hashlib.sha256()
    try:
        with path.open("rb") as stream:
            for chunk in iter(lambda: stream.read(1024 * 1024), b""):
                digest.update(chunk)
    except OSError:
        return None
    return digest.hexdigest()


def scan_text_file(path: Path, root: Path) -> list[dict[str, str]]:
    relative = path.relative_to(root).as_posix()
    try:
        text = path.read_text(encoding="utf-8")
    except (OSError, UnicodeError):
        return [finding("submission-file-unreadable", relative, "submission file could not be read")]

    findings: list[dict[str, str]] = []
    if PLACEHOLDER_RE.search(text):
        findings.append(finding("unfinished-placeholder", relative, "unfinished placeholder found"))
    if COMMERCE_RE.search(text):
        findings.append(
            finding("digital-commerce-language", relative, "directory-facing commerce language found")
        )
    return findings


def scan_reviewer_tests(path: Path, root: Path) -> list[dict[str, str]]:
    relative = path.relative_to(root).as_posix()
    try:
        value = json.loads(path.read_text(encoding="utf-8"))
    except (OSError, UnicodeError, json.JSONDecodeError):
        return [finding("reviewer-tests-invalid", relative, "reviewer test file is not valid JSON")]

    if not isinstance(value, dict):
        return [finding("reviewer-tests-invalid", relative, "reviewer test file must contain an object")]

    findings: list[dict[str, str]] = []
    positive = value.get("positive")
    negative = value.get("negative")
    if not isinstance(positive, list) or len(positive) != 5:
        findings.append(finding("reviewer-positive-count", relative, "exactly five positive cases are required"))
    if not isinstance(negative, list) or len(negative) != 3:
        findings.append(finding("reviewer-negative-count", relative, "exactly three negative cases are required"))

    for group_name, cases in (("positive", positive), ("negative", negative)):
        if not isinstance(cases, list):
            continue
        for index, case in enumerate(cases, start=1):
            if not isinstance(case, dict) or not all(
                isinstance(case.get(field), str) and case[field].strip()
                for field in ("id", "prompt", "expected")
            ):
                findings.append(
                    finding(
                        "reviewer-case-invalid",
                        relative,
                        f"{group_name} case {index} lacks the required shape",
                    )
                )
    return findings


def scan_submission(root: Path | str) -> dict[str, object]:
    root_path = Path(root).resolve()
    findings: list[dict[str, str]] = []
    validated_assets: dict[str, Path] = {}
    manifest_file = root_path / MANIFEST_PATH
    manifest: object = None
    try:
        manifest = json.loads(manifest_file.read_text(encoding="utf-8"))
    except (OSError, UnicodeError, json.JSONDecodeError):
        findings.append(finding("plugin-manifest-invalid", MANIFEST_PATH.as_posix(), "manifest is not valid JSON"))

    interface: object = {}
    if isinstance(manifest, dict):
        interface = manifest.get("interface", {})
    else:
        findings.append(finding("plugin-manifest-invalid", MANIFEST_PATH.as_posix(), "manifest must contain an object"))
    if not isinstance(interface, dict):
        findings.append(finding("plugin-interface-invalid", MANIFEST_PATH.as_posix(), "interface must contain an object"))
        interface = {}

    display_name = interface.get("displayName")
    if not isinstance(display_name, str) or not display_name.strip():
        findings.append(finding("display-name-missing", MANIFEST_PATH.as_posix(), "display name is required"))
    elif len(display_name) > 30:
        findings.append(finding("display-name-too-long", MANIFEST_PATH.as_posix(), "display name exceeds 30 characters"))

    short_description = interface.get("shortDescription")
    if not isinstance(short_description, str) or not short_description.strip():
        findings.append(finding("short-description-missing", MANIFEST_PATH.as_posix(), "short description is required"))
    elif len(short_description) > 30:
        findings.append(
            finding("short-description-too-long", MANIFEST_PATH.as_posix(), "short description exceeds 30 characters")
        )

    long_description = interface.get("longDescription")
    if isinstance(long_description, str) and len(long_description) > 4000:
        findings.append(finding("long-description-too-long", MANIFEST_PATH.as_posix(), "long description exceeds 4000 characters"))

    prompts = interface.get("defaultPrompt")
    if not isinstance(prompts, list):
        findings.append(finding("starter-prompt-shape", MANIFEST_PATH.as_posix(), "starter prompts must be a list"))
    else:
        if len(prompts) > 3:
            findings.append(finding("starter-prompt-count", MANIFEST_PATH.as_posix(), "at most three starter prompts are allowed"))
        normalized: set[str] = set()
        for prompt in prompts:
            if not isinstance(prompt, str) or not prompt.strip():
                findings.append(finding("starter-prompt-shape", MANIFEST_PATH.as_posix(), "starter prompt must be non-empty text"))
                continue
            if len(prompt) > 128:
                findings.append(finding("starter-prompt-too-long", MANIFEST_PATH.as_posix(), "starter prompt exceeds 128 characters"))
            normalized_prompt = " ".join(prompt.casefold().split())
            if normalized_prompt in normalized:
                findings.append(finding("starter-prompt-duplicate", MANIFEST_PATH.as_posix(), "starter prompts must be unique"))
            normalized.add(normalized_prompt)
            if "@" in prompt:
                findings.append(finding("starter-prompt-mention", MANIFEST_PATH.as_posix(), "starter prompt must not contain a mention"))

    for field in ("websiteURL", "privacyPolicyURL", "termsOfServiceURL"):
        if not is_https_url(interface.get(field)):
            findings.append(finding("url-not-https", MANIFEST_PATH.as_posix(), f"{field} must be an HTTPS URL"))
    for field in ("logo", "composerIcon", "logoDark"):
        if field == "logoDark" and field not in interface:
            continue
        asset = safe_asset_path(root_path, interface.get(field))
        if asset is None or not asset.is_file():
            findings.append(finding("asset-missing", MANIFEST_PATH.as_posix(), f"{field} must reference an in-repository file"))
            continue
        try:
            asset_size = asset.stat().st_size
        except OSError:
            findings.append(finding("asset-unreadable", MANIFEST_PATH.as_posix(), f"{field} could not be inspected"))
            continue
        if asset_size > MAX_IMAGE_BYTES:
            findings.append(finding("asset-too-large", MANIFEST_PATH.as_posix(), f"{field} must not exceed 5 MiB"))
            continue
        if asset.suffix.casefold() == ".png":
            dimensions = png_dimensions(asset)
            if dimensions is None:
                findings.append(finding("asset-unreadable", MANIFEST_PATH.as_posix(), f"{field} must reference a decodable PNG image"))
            else:
                width, height = dimensions
                if width != height:
                    findings.append(finding("asset-not-square", MANIFEST_PATH.as_posix(), f"{field} must reference a square image"))
                if not all(MIN_IMAGE_EDGE <= edge <= MAX_IMAGE_EDGE for edge in dimensions):
                    findings.append(finding("asset-dimensions-out-of-range", MANIFEST_PATH.as_posix(), f"{field} dimensions must be between 48 and 4096 pixels"))
                validated_assets[field] = asset

    logo_path = validated_assets.get("logo")
    composer_icon_path = validated_assets.get("composerIcon")
    if logo_path is not None and composer_icon_path is not None:
        logo_hash = file_sha256(logo_path)
        composer_icon_hash = file_sha256(composer_icon_path)
        if logo_hash is None or composer_icon_hash is None:
            findings.append(finding("asset-unreadable", MANIFEST_PATH.as_posix(), "declared assets could not be compared"))
        elif logo_hash == composer_icon_hash:
            findings.append(finding("asset-duplicate", MANIFEST_PATH.as_posix(), "logo and composerIcon must use distinct image content"))

    for relative in REQUIRED_SUBMISSION_FILES:
        path = root_path / relative
        if not path.is_file():
            findings.append(finding("submission-file-missing", relative.as_posix(), "required submission file is missing"))
            continue
        if relative.name == "reviewer-tests.json":
            findings.extend(scan_reviewer_tests(path, root_path))
        else:
            findings.extend(scan_text_file(path, root_path))

    unique = sorted(
        {(item["category"], item["path"], item["detail"]) for item in findings},
        key=lambda item: (item[1], item[0], item[2]),
    )
    serialized = [finding(category, path, detail) for category, path, detail in unique]
    return {
        "ok": not serialized,
        "root": ".",
        "finding_count": len(serialized),
        "findings": serialized,
    }


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(description="Check plugin directory submission materials")
    parser.add_argument("root", nargs="?", default=".", help="repository root")
    args = parser.parse_args(argv)
    result = scan_submission(args.root)
    json.dump(result, sys.stdout, ensure_ascii=False, indent=2)
    sys.stdout.write("\n")
    return 0 if result["ok"] else 1


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

SHA-256: 2537167e2f18e7b72b925a06b6555620c7d18a99593be3dbf8e127a695ebb44f