chore: import upstream snapshot with attribution
Module Market Check / Validate modules/ (push) Failing after 1s
Android CI Build / check (push) Failing after 1s
Module Market Publish / Generate submissions.json (push) Failing after 1s
Android CI Build / package-apks (push) Has been skipped

This commit is contained in:
wehub-resource-sync
2026-07-13 12:29:44 +08:00
commit f4ed7901c3
4741 changed files with 919144 additions and 0 deletions
+278
View File
@@ -0,0 +1,278 @@
#!/usr/bin/env python3
"""Audit Compose UI files for design-system migration debt.
This script is intentionally non-failing by default. It reports legacy patterns
so migrations can reduce the count over time without blocking unrelated work.
Use --enforce-baseline with .github/scripts/ui_design_allowlist.txt to block new debt
while allowing documented historical debt to remain during migration.
"""
from __future__ import annotations
import argparse
import re
from collections import Counter, defaultdict
from dataclasses import dataclass
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
DEFAULT_TARGETS = (
REPO_ROOT / "app/src/main/java/com/webtoapp/ui/screens",
REPO_ROOT / "app/src/main/java/com/webtoapp/ui/components",
)
DEFAULT_ALLOWLIST = REPO_ROOT / ".github/scripts/ui_design_allowlist.txt"
@dataclass(frozen=True)
class Rule:
key: str
label: str
pattern: re.Pattern[str]
RULES = (
Rule(
key="hardcoded_color",
label="Direct Color(0x...)",
pattern=re.compile(r"\bColor\s*\(\s*0x[0-9A-Fa-f_]+"),
),
Rule(
key="raw_corner_shape",
label="Raw RoundedCornerShape(dp)",
pattern=re.compile(r"\bRoundedCornerShape\s*\(\s*\d+(?:\.\d+)?\.dp"),
),
Rule(
key="raw_card",
label="Raw Card/ElevatedCard/OutlinedCard",
pattern=re.compile(r"(?<!Enhanced)(?<!Wta)\b(?:Card|ElevatedCard|OutlinedCard)\s*\("),
),
Rule(
key="raw_surface",
label="Raw Surface",
pattern=re.compile(r"(?<!Wta)\bSurface\s*\("),
),
Rule(
key="raw_button",
label="Raw Button",
pattern=re.compile(r"(?<!Icon)(?<!Text)(?<!Outlined)(?<!Tonal)(?<!Premium)(?<!Wta)\bButton\s*\("),
),
)
def iter_kotlin_files(target: Path) -> list[Path]:
if target.is_file():
return [target]
return sorted(target.rglob("*.kt"))
def resolve_target(target_text: str) -> Path:
target = Path(target_text)
if not target.is_absolute():
target = REPO_ROOT / target
return target
def display_targets(targets: list[Path]) -> str:
labels = []
for target in targets:
labels.append(
str(target.relative_to(REPO_ROOT))
if target.is_relative_to(REPO_ROOT)
else str(target)
)
return ", ".join(labels)
def strip_line_comment(line: str) -> str:
return line.split("//", 1)[0]
def audit_file(path: Path) -> dict[str, list[int]]:
findings: dict[str, list[int]] = defaultdict(list)
try:
lines = path.read_text(encoding="utf-8").splitlines()
except UnicodeDecodeError:
lines = path.read_text(errors="ignore").splitlines()
for line_no, line in enumerate(lines, start=1):
candidate = strip_line_comment(line)
for rule in RULES:
if rule.pattern.search(candidate):
findings[rule.key].append(line_no)
return findings
def relative_repo_path(path: Path) -> str:
if path.is_relative_to(REPO_ROOT):
return path.relative_to(REPO_ROOT).as_posix()
return path.as_posix()
def load_allowlist(path: Path) -> dict[str, Counter[str]]:
allowlist: dict[str, Counter[str]] = defaultdict(Counter)
if not path.exists():
return allowlist
for line_no, raw_line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
line = raw_line.strip()
if not line or line.startswith("#"):
continue
parts = line.split("|")
if len(parts) != 3:
raise ValueError(f"Invalid allowlist line {line_no}: {raw_line}")
rel_path, rule_key, count_text = parts
if rule_key not in {rule.key for rule in RULES}:
raise ValueError(f"Unknown rule '{rule_key}' on allowlist line {line_no}")
try:
count = int(count_text)
except ValueError as exc:
raise ValueError(f"Invalid count '{count_text}' on allowlist line {line_no}") from exc
if count < 0:
raise ValueError(f"Negative count on allowlist line {line_no}")
allowlist[rel_path][rule_key] = count
return allowlist
def write_allowlist(path: Path, current_counts: dict[str, Counter[str]]) -> None:
lines = [
"# WebToApp UI design-system historical debt baseline.",
"# Format: relative/path.kt|rule_key|allowed_count",
"# Regenerate only after intentionally accepting a new migration checkpoint.",
]
for rel_path in sorted(current_counts):
counts = current_counts[rel_path]
for rule in RULES:
count = counts[rule.key]
if count > 0:
lines.append(f"{rel_path}|{rule.key}|{count}")
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def find_baseline_violations(
current_counts: dict[str, Counter[str]],
allowlist: dict[str, Counter[str]],
) -> list[tuple[str, str, int, int]]:
violations: list[tuple[str, str, int, int]] = []
for rel_path in sorted(current_counts):
counts = current_counts[rel_path]
allowed = allowlist.get(rel_path, Counter())
for rule in RULES:
current = counts[rule.key]
baseline = allowed[rule.key]
if current > baseline:
violations.append((rel_path, rule.key, current, baseline))
return violations
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"targets",
nargs="*",
help="Files or directories to scan. Defaults to app ui/screens and ui/components.",
)
parser.add_argument(
"--fail-on-findings",
action="store_true",
help="Exit with status 1 if any finding is detected.",
)
parser.add_argument(
"--enforce-baseline",
action="store_true",
help="Exit with status 1 only when current findings exceed the allowlist baseline.",
)
parser.add_argument(
"--allowlist",
default=str(DEFAULT_ALLOWLIST),
help="Baseline allowlist file for --enforce-baseline.",
)
parser.add_argument(
"--write-allowlist",
help="Write the current finding counts as an allowlist baseline.",
)
parser.add_argument(
"--top",
type=int,
default=20,
help="Number of highest-debt files to print.",
)
args = parser.parse_args()
targets = [resolve_target(target) for target in args.targets] if args.targets else list(DEFAULT_TARGETS)
files = sorted(
{file_path.resolve() for target in targets for file_path in iter_kotlin_files(target)}
)
totals: Counter[str] = Counter()
per_file: list[tuple[Path, Counter[str], dict[str, list[int]]]] = []
current_counts: dict[str, Counter[str]] = {}
for file_path in files:
findings = audit_file(file_path)
counts = Counter({key: len(lines) for key, lines in findings.items()})
if counts:
totals.update(counts)
per_file.append((file_path, counts, findings))
current_counts[relative_repo_path(file_path)] = counts
print("WebToApp UI design-system audit")
print(f"Targets: {display_targets(targets)}")
print(f"Files scanned: {len(files)}")
print()
if args.write_allowlist:
write_path = Path(args.write_allowlist)
if not write_path.is_absolute():
write_path = REPO_ROOT / write_path
write_allowlist(write_path, current_counts)
print(f"Allowlist written: {relative_repo_path(write_path)}")
print()
if not totals:
print("No findings.")
return 0
print("Totals:")
for rule in RULES:
print(f" {rule.label}: {totals[rule.key]}")
print()
print(f"Top {args.top} files:")
per_file.sort(key=lambda item: sum(item[1].values()), reverse=True)
for file_path, counts, findings in per_file[: args.top]:
rel = file_path.relative_to(REPO_ROOT)
total = sum(counts.values())
detail = ", ".join(
f"{rule.key}={counts[rule.key]}"
for rule in RULES
if counts[rule.key]
)
first_lines = []
for rule in RULES:
lines = findings.get(rule.key)
if lines:
first_lines.append(f"{rule.key}@{','.join(map(str, lines[:5]))}")
print(f" {rel}: {total} ({detail})")
print(f" first: {'; '.join(first_lines)}")
if args.enforce_baseline:
allowlist_path = Path(args.allowlist)
if not allowlist_path.is_absolute():
allowlist_path = REPO_ROOT / allowlist_path
allowlist = load_allowlist(allowlist_path)
violations = find_baseline_violations(current_counts, allowlist)
print()
if violations:
print("Baseline enforcement failed:")
for rel_path, rule_key, current, baseline in violations[:50]:
print(f" {rel_path}|{rule_key}: current={current}, allowed={baseline}")
return 1
print("Baseline enforcement passed.")
return 1 if args.fail_on_findings else 0
if __name__ == "__main__":
raise SystemExit(main())
+604
View File
@@ -0,0 +1,604 @@
#!/usr/bin/env python3
"""
Generate `modules/submissions.json` for the WebToApp Module Market.
Why: the in-app market only renders modules that have a corresponding
submission entry in this file. The repository contract is that an entry
is published only when the module has actually landed on `main`, either
by a merged PR (the normal path) or by a maintainer direct push (rare,
used for seeding the initial example modules).
This script is what enforces the "merged PRs only" promise. It walks
every module folder under `modules/`, asks `git log` for the commit that
introduced or last touched it, then queries the GitHub REST API for the
PR associated with that commit. If the PR is found and merged, we record
PR number, merge timestamp, and the merger's GitHub identity. If no PR
is found, the commit is taken as a direct push and we record the
commit's author timestamp instead — but only when the author is a
known maintainer login passed via `--maintainers`. Anything else (a
direct push by a non-maintainer that somehow got through, an orphan
folder added without a commit) ends up unrecorded, and the in-app
market hides it.
Run locally (no network access — uses cache only):
python3 .github/scripts/ci/generate_submissions.py --offline
Run in CI:
python3 .github/scripts/ci/generate_submissions.py \\
--owner shiaho777 --repo web-to-app \\
--maintainers shiaho777 \\
--token "$GITHUB_TOKEN"
Exit codes:
0 = file was generated successfully (may have committed nothing if
nothing changed)
1 = a fatal error occurred
"""
from __future__ import annotations
import argparse
import json
import os
import re
import subprocess
import sys
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
REPO_ROOT = Path(__file__).resolve().parents[3]
MODULES_DIR = REPO_ROOT / "modules"
SUBMISSIONS_PATH = MODULES_DIR / "submissions.json"
REGISTRY_PATH = MODULES_DIR / "registry.json"
# ───────────────────────── git helpers ─────────────────────────────────
def _git(*args: str) -> str:
"""Run a git command relative to the repo root and return stdout."""
result = subprocess.run(
["git", *args],
cwd=str(REPO_ROOT),
check=True,
capture_output=True,
text=True,
)
return result.stdout
def _last_commit_for(path: Path) -> str | None:
"""Return the SHA of the most recent commit that touched `path`."""
try:
out = _git("log", "-n", "1", "--format=%H", "--", str(path.relative_to(REPO_ROOT)))
sha = out.strip()
return sha or None
except subprocess.CalledProcessError:
return None
def _commit_metadata(sha: str) -> dict[str, str]:
"""
Fetch author name, email, ISO timestamp, and subject for a commit.
Used as a fallback when the GitHub API can't tell us about a PR.
"""
out = _git("show", "-s", "--format=%an%n%ae%n%aI%n%s", sha)
parts = out.splitlines()
return {
"author_name": parts[0] if len(parts) > 0 else "",
"author_email": parts[1] if len(parts) > 1 else "",
"author_date": parts[2] if len(parts) > 2 else "",
"subject": parts[3] if len(parts) > 3 else "",
}
def _all_commits_for(path: Path) -> list[str]:
"""Return every commit SHA that touched `path`, newest first."""
try:
out = _git("log", "--format=%H", "--", str(path.relative_to(REPO_ROOT)))
return [line.strip() for line in out.splitlines() if line.strip()]
except subprocess.CalledProcessError:
return []
# ───────────────────────── GitHub API helpers ──────────────────────────
@dataclass
class GitHubClient:
"""Tiny GitHub REST client that uses urllib so we stay stdlib-only."""
owner: str
repo: str
token: str | None = None
timeout: int = 15
def _request(self, url: str) -> Any:
headers = {
"Accept": "application/vnd.github+json",
"User-Agent": "webtoapp-submissions-generator",
"X-GitHub-Api-Version": "2022-11-28",
}
if self.token:
headers["Authorization"] = f"Bearer {self.token}"
req = urllib.request.Request(url, headers=headers)
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
return json.loads(resp.read().decode("utf-8"))
def pulls_for_commit(self, sha: str) -> list[dict[str, Any]]:
"""Return PRs associated with the given commit SHA."""
url = (
f"https://api.github.com/repos/{self.owner}/{self.repo}"
f"/commits/{sha}/pulls"
)
try:
return self._request(url) or []
except urllib.error.HTTPError as e:
# 404 just means no PR was associated with this commit, which
# is the normal case for a maintainer direct-push. Anything
# else is worth a note in the log.
if e.code == 404:
return []
print(f"⚠️ GitHub API error for commit {sha[:7]}: {e}", file=sys.stderr)
return []
except urllib.error.URLError as e:
print(f"⚠️ GitHub API URLError for commit {sha[:7]}: {e}", file=sys.stderr)
return []
def user(self, login: str) -> dict[str, Any] | None:
"""Resolve a GitHub login to its display name / avatar URL."""
url = f"https://api.github.com/users/{urllib.parse.quote(login)}"
try:
return self._request(url)
except urllib.error.HTTPError:
return None
except urllib.error.URLError:
return None
def commit_author_login(self, sha: str) -> str:
"""Resolve the GitHub login of a commit's author via the commit API."""
url = f"https://api.github.com/repos/{self.owner}/{self.repo}/commits/{sha}"
try:
commit_info = self._request(url)
author = commit_info.get("author") or {}
return author.get("login") or ""
except (urllib.error.HTTPError, urllib.error.URLError):
return ""
# ───────────────────────── core logic ──────────────────────────────────
@dataclass
class Contributor:
"""A secondary contributor to a module (everyone except the original submitter)."""
login: str = ""
name: str = ""
avatar: str = ""
profile: str = ""
def to_json(self) -> dict[str, str]:
out: dict[str, str] = {}
if self.login:
out["login"] = self.login
if self.name:
out["name"] = self.name
if self.avatar:
out["avatarUrl"] = self.avatar
if self.profile:
out["profileUrl"] = self.profile
return out
@dataclass
class Submission:
"""In-memory representation of a single `submissions.json` entry."""
pr_number: int | None = None
pr_url: str | None = None
submitted_at: str | None = None
direct: bool = False
submitter_login: str = ""
submitter_name: str = ""
submitter_avatar: str = ""
submitter_profile: str = ""
contributors: list[Contributor] = field(default_factory=list)
def to_json(self) -> dict[str, Any]:
out: dict[str, Any] = {}
if self.pr_number is not None:
out["prNumber"] = self.pr_number
if self.pr_url:
out["prUrl"] = self.pr_url
if self.submitted_at:
out["submittedAt"] = self.submitted_at
if self.direct:
out["direct"] = True
submitter: dict[str, str] = {}
if self.submitter_login:
submitter["login"] = self.submitter_login
if self.submitter_name:
submitter["name"] = self.submitter_name
if self.submitter_avatar:
submitter["avatarUrl"] = self.submitter_avatar
if self.submitter_profile:
submitter["profileUrl"] = self.submitter_profile
if submitter:
out["submitter"] = submitter
if self.contributors:
out["contributors"] = [c.to_json() for c in self.contributors if c.login]
return out
def _read_existing_submissions() -> dict[str, Any]:
"""Read the previous run's output so unchanged modules don't re-fetch."""
if not SUBMISSIONS_PATH.is_file():
return {}
try:
data = json.loads(SUBMISSIONS_PATH.read_text(encoding="utf-8"))
return data.get("submissions") or {}
except (json.JSONDecodeError, OSError):
return {}
def _read_registry() -> list[dict[str, Any]]:
"""Read the registry to know which `id` ↔ `path` pairs to attribute."""
if not REGISTRY_PATH.is_file():
return []
try:
data = json.loads(REGISTRY_PATH.read_text(encoding="utf-8"))
modules = data.get("modules")
if isinstance(modules, list):
return [m for m in modules if isinstance(m, dict)]
except (json.JSONDecodeError, OSError):
pass
return []
def _resolve_for_path(
module_path: Path,
client: GitHubClient | None,
maintainers: set[str],
cache: dict[str, Any],
) -> Submission | None:
"""Build a Submission for the module folder at `module_path`."""
sha = _last_commit_for(module_path)
if sha is None:
return None
# We attribute to the *introducing* commit when possible — that's the
# one that landed the module on main. The simplest proxy in `git log`
# is the oldest commit that touched the folder.
introducing_sha: str | None = None
try:
out = _git(
"log",
"--diff-filter=A",
"--format=%H",
"--",
str(module_path.relative_to(REPO_ROOT) / "module.json"),
)
intro_lines = [line.strip() for line in out.splitlines() if line.strip()]
if intro_lines:
introducing_sha = intro_lines[-1]
except subprocess.CalledProcessError:
pass
target_sha = introducing_sha or sha
# In offline mode, fall back to git metadata only.
if client is None:
meta = _commit_metadata(target_sha)
# We still gate maintainer direct-pushes by the configured allowlist,
# so a stranger committing directly without a PR isn't silently
# promoted into the catalog.
login = "" # unknown without GitHub API
if not login and not maintainers:
return None
return Submission(
direct=True,
submitted_at=meta["author_date"],
submitter_login=meta["author_name"],
submitter_name=meta["author_name"],
)
pulls = client.pulls_for_commit(target_sha)
merged_pulls = [
p for p in pulls
if p.get("merged_at") and p.get("base", {}).get("ref") == "main"
]
if merged_pulls:
# Prefer the earliest-merged PR — that is the one that introduced
# the module to main. Subsequent updates also produce PRs but they
# are version bumps, not the original submission.
pr = sorted(merged_pulls, key=lambda p: p.get("merged_at", ""))[0]
author = pr.get("user") or {}
login = author.get("login") or ""
avatar = author.get("avatar_url") or ""
profile = author.get("html_url") or (
f"https://github.com/{login}" if login else ""
)
# The PR payload doesn't always carry the user's display name; fetch
# it lazily and cache so we don't repeat the call for the same login.
display_name = ""
if login:
cached = cache.setdefault("users", {}).get(login)
if cached is None:
resolved = client.user(login)
cached = {
"name": (resolved or {}).get("name") or login,
"avatar": (resolved or {}).get("avatar_url") or avatar,
}
cache["users"][login] = cached
display_name = cached.get("name") or login
return Submission(
pr_number=pr.get("number"),
pr_url=pr.get("html_url"),
submitted_at=pr.get("merged_at"),
direct=False,
submitter_login=login,
submitter_name=display_name,
submitter_avatar=avatar,
submitter_profile=profile,
)
# No merged PR was found. This is a direct push to main. The main branch
# is branch-protected, so a direct push has already been authorised by a
# maintainer (or admin) — we trust that gate rather than re-checking the
# GitHub login, which is unreliable for commits whose author email isn't
# linked to a GitHub account (the API returns `author: null` and we'd
# silently hide legitimate maintainer pushes).
meta = _commit_metadata(target_sha)
# Try to resolve the GitHub login from the commit's email when possible.
# `git log` doesn't carry the GitHub login, so we look it up by querying
# the commit endpoint, which embeds the GH author when GitHub knows them.
login = ""
try:
url = f"https://api.github.com/repos/{client.owner}/{client.repo}/commits/{target_sha}"
commit_info = client._request(url)
author = commit_info.get("author") or {}
login = author.get("login") or ""
except (urllib.error.HTTPError, urllib.error.URLError):
pass
if not login:
# Commit email isn't linked to a GitHub account, so the API can't
# tell us who authored it. The push already cleared branch
# protection, so attribute it to the repo owner as a fallback.
login = client.owner
if maintainers and login.lower() not in {m.lower() for m in maintainers}:
# We resolved a login and it isn't on the maintainer allowlist.
# Branch protection should prevent strangers from direct-pushing,
# but keep the check as defence in depth for unprotected repos.
return None
avatar = ""
profile = ""
display_name = login or meta["author_name"]
if login:
cached = cache.setdefault("users", {}).get(login)
if cached is None:
resolved = client.user(login)
cached = {
"name": (resolved or {}).get("name") or login,
"avatar": (resolved or {}).get("avatar_url") or "",
"profile": (resolved or {}).get("html_url") or f"https://github.com/{login}",
}
cache["users"][login] = cached
display_name = cached.get("name") or login
avatar = cached.get("avatar") or ""
profile = cached.get("profile") or f"https://github.com/{login}"
return Submission(
direct=True,
submitted_at=meta["author_date"],
submitter_login=login,
submitter_name=display_name,
submitter_avatar=avatar,
submitter_profile=profile,
)
def _resolve_contributors(
module_path: Path,
submitter_login: str,
client: GitHubClient | None,
cache: dict[str, Any],
) -> list[Contributor]:
"""
Collect secondary contributors for a module folder.
Walks every commit that touched the folder, resolves each distinct
author's GitHub login via the commit API, and returns everyone except
the original submitter (and bots/github-actions). Offline mode and
unresolvable logins yield an empty list — contributors are a
best-effort enrichment, never a hard requirement.
"""
if client is None:
return []
shas = _all_commits_for(module_path)
seen_logins: set[str] = set()
if submitter_login:
seen_logins.add(submitter_login.lower())
seen_logins.add("github-actions[bot]")
seen_logins.add("web-flow")
contributors: list[Contributor] = []
users = cache.setdefault("users", {})
for sha in shas:
login = client.commit_author_login(sha)
if not login or login.lower() in seen_logins:
continue
seen_logins.add(login.lower())
cached = users.get(login)
if cached is None:
resolved = client.user(login)
cached = {
"name": (resolved or {}).get("name") or login,
"avatar": (resolved or {}).get("avatar_url") or "",
"profile": (resolved or {}).get("html_url") or f"https://github.com/{login}",
}
users[login] = cached
contributors.append(Contributor(
login=login,
name=cached.get("name") or login,
avatar=cached.get("avatar") or "",
profile=cached.get("profile") or f"https://github.com/{login}",
))
return contributors
# ───────────────────────── CLI entry ───────────────────────────────────
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__.split("\n\n", 1)[0])
parser.add_argument("--owner", default=os.environ.get("REPO_OWNER", "shiaho777"))
parser.add_argument("--repo", default=os.environ.get("REPO_NAME", "web-to-app"))
parser.add_argument("--token", default=os.environ.get("GITHUB_TOKEN"))
parser.add_argument(
"--maintainers",
default=os.environ.get("MAINTAINERS", ""),
help=(
"Comma-separated GitHub logins allowed to seed modules via "
"direct push (no PR). Anything else without a merged PR is "
"deliberately hidden from the catalog."
),
)
parser.add_argument(
"--offline",
action="store_true",
help=(
"Skip the GitHub API entirely; useful for smoke-testing the "
"script locally. Falls back to git metadata only."
),
)
parser.add_argument(
"--check",
action="store_true",
help=(
"Generate to a temp file and exit with code 1 if the result "
"differs from the committed file. Used as a guard rail in CI "
"for PRs that touch modules/."
),
)
args = parser.parse_args()
if not MODULES_DIR.is_dir():
print(f"{MODULES_DIR} does not exist", file=sys.stderr)
return 1
maintainers: set[str] = {
m.strip() for m in args.maintainers.split(",") if m.strip()
}
client: GitHubClient | None
if args.offline:
client = None
else:
client = GitHubClient(owner=args.owner, repo=args.repo, token=args.token)
registry = _read_registry()
# Map registry path → id so we attribute each folder to its catalog id.
path_to_id: dict[str, str] = {}
for entry in registry:
p = entry.get("path")
i = entry.get("id")
if isinstance(p, str) and isinstance(i, str):
path_to_id[p] = i
# Walk every kebab-case folder; the validator already ensures the layout.
folders = sorted(
p for p in MODULES_DIR.iterdir()
if p.is_dir() and not p.name.startswith(".")
)
cache: dict[str, Any] = {}
submissions: dict[str, Any] = {}
skipped: list[str] = []
for folder in folders:
module_id = path_to_id.get(folder.name)
if not module_id:
# Folder without a registry entry — the validator will already
# be screaming about this; we just skip it.
skipped.append(folder.name)
continue
submission = _resolve_for_path(folder, client, maintainers, cache)
if submission is None:
# Hidden by design: no merged PR and not a maintainer direct
# push. Surface this in the log so reviewers can tell what's
# going on.
skipped.append(folder.name)
continue
submission.contributors = _resolve_contributors(
folder, submission.submitter_login, client, cache
)
submissions[module_id] = submission.to_json()
output = {
"schema": 1,
"submissions": submissions,
}
if not args.check:
existing_text = SUBMISSIONS_PATH.read_text(encoding="utf-8") if SUBMISSIONS_PATH.is_file() else ""
try:
existing_obj = json.loads(existing_text) if existing_text else {}
except json.JSONDecodeError:
existing_obj = {}
existing_subs = existing_obj.get("submissions") or {}
if existing_subs == submissions and "generatedAt" in existing_obj:
output["generatedAt"] = existing_obj["generatedAt"]
else:
output["generatedAt"] = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
else:
output["generatedAt"] = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
ordered = {
"schema": output["schema"],
"generatedAt": output["generatedAt"],
"submissions": output["submissions"],
}
serialised = json.dumps(ordered, indent=2, ensure_ascii=False) + "\n"
if args.check:
# Diff against the committed file. We tolerate a missing file by
# treating it as empty. `generatedAt` is excluded from the diff
# so check mode is deterministic.
existing_text = SUBMISSIONS_PATH.read_text(encoding="utf-8") if SUBMISSIONS_PATH.is_file() else ""
try:
existing_obj = json.loads(existing_text) if existing_text else {}
except json.JSONDecodeError:
existing_obj = {}
existing_subs = existing_obj.get("submissions") or {}
if existing_subs == submissions:
print(f"✅ submissions.json is up to date ({len(submissions)} entries)")
if skipped:
print(f" ({len(skipped)} folder(s) hidden from catalog: {', '.join(skipped)})")
return 0
print("❌ submissions.json is stale — re-run generate_submissions.py and commit the result.", file=sys.stderr)
return 1
SUBMISSIONS_PATH.write_text(serialised, encoding="utf-8")
print(f"✅ Wrote {SUBMISSIONS_PATH.relative_to(REPO_ROOT)} with {len(submissions)} entries")
if skipped:
print(f" ({len(skipped)} folder(s) hidden from catalog: {', '.join(skipped)})")
return 0
if __name__ == "__main__":
sys.exit(main())
+669
View File
@@ -0,0 +1,669 @@
#!/usr/bin/env python3
"""
Validate the WebToApp Module Market catalog at `modules/`.
This is the source of truth for what `modules/README.md` claims gets
checked at CI time. Every rule here corresponds to a real failure mode
that would either reject a module at install time or quietly waste space
on disk.
The validator is intentionally written with only Python's standard
library — no pip install step, no extra container — so the CI job stays
fast and deterministic.
Run locally:
python3 .github/scripts/ci/validate_modules.py
Exit codes:
0 = catalog is valid
1 = at least one error was found (CI fails)
"""
from __future__ import annotations
import json
import os
import re
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Iterable
# ───────────────────────── enum values (must mirror the Kotlin source) ─
# `ModuleCategory` enum in `core/extension/ExtensionModule.kt`.
ALLOWED_CATEGORIES: set[str] = {
"CONTENT_FILTER", "CONTENT_ENHANCE", "STYLE_MODIFIER", "THEME",
"FUNCTION_ENHANCE", "AUTOMATION", "NAVIGATION", "DATA_EXTRACT",
"DATA_SAVE", "INTERACTION", "ACCESSIBILITY", "MEDIA", "VIDEO",
"IMAGE", "AUDIO", "SECURITY", "ANTI_TRACKING", "SOCIAL", "SHOPPING",
"READING", "TRANSLATE", "DEVELOPER", "OTHER",
}
# `ModuleRunTime` enum.
ALLOWED_RUN_AT: set[str] = {
"DOCUMENT_START", "DOCUMENT_END", "DOCUMENT_IDLE",
"CONTEXT_MENU", "BEFORE_UNLOAD",
}
# `ModulePermission` enum.
ALLOWED_PERMISSIONS: set[str] = {
"DOM_ACCESS", "DOM_OBSERVE", "CSS_INJECT", "STORAGE", "COOKIE",
"INDEXED_DB", "CACHE", "NETWORK", "WEBSOCKET", "FETCH_INTERCEPT",
"CLIPBOARD", "NOTIFICATION", "ALERT", "KEYBOARD", "MOUSE", "TOUCH",
"LOCATION", "CAMERA", "MICROPHONE", "DEVICE_INFO", "MEDIA",
"FULLSCREEN", "PICTURE_IN_PICTURE", "SCREEN_CAPTURE", "DOWNLOAD",
"FILE_ACCESS", "EVAL", "IFRAME", "WINDOW_OPEN", "HISTORY",
"NAVIGATION",
}
# `ConfigItemType` enum.
ALLOWED_CONFIG_TYPES: set[str] = {
"TEXT", "TEXTAREA", "NUMBER", "BOOLEAN", "SELECT", "MULTI_SELECT",
"RADIO", "CHECKBOX", "COLOR", "URL", "EMAIL", "PASSWORD", "REGEX",
"CSS_SELECTOR", "JAVASCRIPT", "JSON", "RANGE", "DATE", "TIME",
"DATETIME", "FILE", "IMAGE",
}
ALLOWED_SOURCE_TYPES: set[str] = {"CUSTOM", "CHROME_EXTENSION"}
STORE_ID_RE = re.compile(r"^[a-p]{32}$")
# ───────────────────────── regex helpers ───────────────────────────────
KEBAB_CASE_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
SEMVER_RE = re.compile(r"^\d+(?:\.\d+){0,3}(?:[-+][\w.-]+)?$")
# Files we let people drop in besides the three the runtime actually
# downloads (module.json, main.js, style.css).
ALLOWED_EXTRA_FILES: set[str] = {
"README.md", # nice for module pages on GitHub
"CHANGELOG.md",
"LICENSE",
"LICENSE.md",
".gitkeep",
}
# Image files contributors may drop next to `main.js` for use as the
# module icon. The CI generator does not download these — they are served
# via the same GitHub raw / jsDelivr fallback the runtime already uses.
ALLOWED_ICON_FILES: set[str] = {
"icon.png", "icon.svg", "icon.webp", "icon.jpg", "icon.jpeg",
}
# Maximum size for an inlined icon. Larger icons should be hosted off-repo
# and referenced via an absolute `iconUrl`. 256 KB is a forgiving limit:
# a 256x256 PNG with reasonable compression sits comfortably below it.
MAX_ICON_BYTES = 256 * 1024
@dataclass
class Report:
"""Accumulates diagnostics and pretty-prints them at the end."""
errors: list[str] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
def error(self, where: str, message: str) -> None:
self.errors.append(f"{where}: {message}")
def warning(self, where: str, message: str) -> None:
self.warnings.append(f" ⚠️ {where}: {message}")
def ok(self) -> bool:
return not self.errors
def render(self) -> str:
lines: list[str] = []
if self.errors:
lines.append(f"\n{len(self.errors)} error(s):")
lines.extend(self.errors)
if self.warnings:
lines.append(f"\n{len(self.warnings)} warning(s):")
lines.extend(self.warnings)
if not lines:
lines.append("\nAll module checks passed.")
return "\n".join(lines)
# ───────────────────────── primitive checks ────────────────────────────
def _is_str(value: Any) -> bool:
return isinstance(value, str)
def _expect_str(report: Report, where: str, value: Any, field: str) -> str | None:
if value is None:
return None
if not _is_str(value):
report.error(where, f"`{field}` must be a string, got {type(value).__name__}")
return None
return value
def _expect_list_of_str(report: Report, where: str, value: Any, field: str) -> list[str]:
if value is None:
return []
if not isinstance(value, list) or any(not _is_str(v) for v in value):
report.error(where, f"`{field}` must be a list of strings")
return []
return value
def _validate_url_matches(
report: Report, where: str, url_matches: Any
) -> None:
if url_matches is None:
return
if not isinstance(url_matches, list):
report.error(where, "`urlMatches` must be a list")
return
for i, rule in enumerate(url_matches):
rule_loc = f"{where}::urlMatches[{i}]"
if not isinstance(rule, dict):
report.error(rule_loc, "must be an object with at least `pattern`")
continue
pattern = rule.get("pattern")
if not _is_str(pattern) or not pattern:
report.error(rule_loc, "`pattern` is required and must be a non-empty string")
for flag in ("isRegex", "exclude"):
if flag in rule and not isinstance(rule[flag], bool):
report.error(rule_loc, f"`{flag}` must be a boolean")
def _validate_author(report: Report, where: str, author: Any) -> None:
if author is None:
return
if not isinstance(author, dict):
report.error(where, "`author` must be an object")
return
name = author.get("name")
if not _is_str(name) or not name.strip():
report.error(where, "`author.name` is required")
for opt_key in ("email", "url", "qq"):
if opt_key in author and author[opt_key] is not None and not _is_str(author[opt_key]):
report.error(where, f"`author.{opt_key}` must be a string")
# ───────────────────────── manifest checks ─────────────────────────────
def _validate_module_json(
report: Report, folder: Path, manifest: dict[str, Any]
) -> None:
"""Validate a per-module `module.json`."""
where = f"modules/{folder.name}/module.json"
# Required fields.
if not _is_str(manifest.get("id")) or not manifest["id"].strip():
report.error(where, "`id` is required and must be a non-empty string")
if not _is_str(manifest.get("name")) or not manifest["name"].strip():
report.error(where, "`name` is required and must be a non-empty string")
# Enums (with a friendly hint when wrong).
category = manifest.get("category", "OTHER")
if not _is_str(category) or category not in ALLOWED_CATEGORIES:
report.error(
where,
f"`category` must be one of {sorted(ALLOWED_CATEGORIES)}, got {category!r}",
)
run_at = manifest.get("runAt", "DOCUMENT_END")
if not _is_str(run_at) or run_at not in ALLOWED_RUN_AT:
report.error(
where,
f"`runAt` must be one of {sorted(ALLOWED_RUN_AT)}, got {run_at!r}",
)
# Version (object form).
version = manifest.get("version")
if version is None:
report.error(where, "`version` is required (object with `code`, `name`, `changelog`)")
elif not isinstance(version, dict):
report.error(where, "`version` must be an object — see modules/README.md schema")
else:
v_code = version.get("code")
if not isinstance(v_code, int) or v_code < 1:
report.error(where, "`version.code` must be a positive integer")
v_name = version.get("name")
if not _is_str(v_name) or not SEMVER_RE.match(v_name or ""):
report.error(where, f"`version.name` must be semver-shaped, got {v_name!r}")
if "changelog" in version and version["changelog"] is not None and not _is_str(version["changelog"]):
report.error(where, "`version.changelog` must be a string")
# Permissions.
for perm in _expect_list_of_str(report, where, manifest.get("permissions"), "permissions"):
if perm not in ALLOWED_PERMISSIONS:
report.error(where, f"unknown permission {perm!r}")
# urlMatches and author.
_validate_url_matches(report, where, manifest.get("urlMatches"))
_validate_author(report, where, manifest.get("author"))
# configItems.
config_items = manifest.get("configItems", [])
if config_items is None:
config_items = []
if not isinstance(config_items, list):
report.error(where, "`configItems` must be a list")
return
seen_keys: set[str] = set()
for i, item in enumerate(config_items):
item_loc = f"{where}::configItems[{i}]"
if not isinstance(item, dict):
report.error(item_loc, "must be an object")
continue
key = item.get("key")
if not _is_str(key) or not key.strip():
report.error(item_loc, "`key` is required")
elif key in seen_keys:
report.error(item_loc, f"duplicate key {key!r}")
else:
seen_keys.add(key)
if not _is_str(item.get("name")) or not item["name"].strip():
report.error(item_loc, "`name` is required")
type_ = item.get("type", "TEXT")
if not _is_str(type_) or type_ not in ALLOWED_CONFIG_TYPES:
report.error(item_loc, f"`type` must be one of {sorted(ALLOWED_CONFIG_TYPES)}")
if "required" in item and not isinstance(item["required"], bool):
report.error(item_loc, "`required` must be a boolean")
for str_field in ("description", "defaultValue", "placeholder", "validation"):
if str_field in item and item[str_field] is not None and not _is_str(item[str_field]):
report.error(item_loc, f"`{str_field}` must be a string")
# SELECT / MULTI_SELECT / RADIO need `options`.
if type_ in {"SELECT", "MULTI_SELECT", "RADIO"}:
options = item.get("options")
if not isinstance(options, list) or not options:
report.error(item_loc, f"`options` is required for type {type_}")
elif not all(_is_str(o) for o in options):
report.error(item_loc, f"`options` for type {type_} must be a list of strings; the app parses ModuleConfigItem.options as List<String>, so object forms like [{{\"value\",\"label\"}}] fail Gson deserialization")
# ───────────────────────── registry checks ─────────────────────────────
def _validate_registry_entry(
report: Report, entry: dict[str, Any], index: int
) -> None:
where = f"registry.json::modules[{index}]"
for required in ("id", "path", "name"):
if not _is_str(entry.get(required)) or not entry[required].strip():
report.error(where, f"`{required}` is required and must be a non-empty string")
path = entry.get("path", "")
if _is_str(path) and not KEBAB_CASE_RE.match(path):
report.error(where, f"`path` must be kebab-case, got {path!r}")
version = entry.get("version")
if not _is_str(version) or not SEMVER_RE.match(version or ""):
report.error(where, f"`version` must be a semver string, got {version!r}")
category = entry.get("category", "OTHER")
if not _is_str(category) or category not in ALLOWED_CATEGORIES:
report.error(where, f"`category` must be one of the allowed values, got {category!r}")
run_at = entry.get("runAt", "DOCUMENT_END")
if not _is_str(run_at) or run_at not in ALLOWED_RUN_AT:
report.error(where, f"`runAt` must be one of the allowed values, got {run_at!r}")
for perm in _expect_list_of_str(report, where, entry.get("permissions"), "permissions"):
if perm not in ALLOWED_PERMISSIONS:
report.error(where, f"unknown permission {perm!r}")
_validate_url_matches(report, where, entry.get("urlMatches"))
_validate_author(report, where, entry.get("author"))
if "minAppVersion" in entry and not isinstance(entry["minAppVersion"], int):
report.error(where, "`minAppVersion` must be an integer")
if "hasCss" in entry and not isinstance(entry["hasCss"], bool):
report.error(where, "`hasCss` must be a boolean")
icon_url = entry.get("iconUrl")
if icon_url is not None and not _is_str(icon_url):
report.error(where, "`iconUrl` must be a string when present")
source_type = entry.get("sourceType", "CUSTOM")
if not _is_str(source_type) or source_type not in ALLOWED_SOURCE_TYPES:
report.error(where, f"`sourceType` must be one of {sorted(ALLOWED_SOURCE_TYPES)}, got {source_type!r}")
if source_type == "CHROME_EXTENSION":
store_id = entry.get("storeId")
if not _is_str(store_id) or not STORE_ID_RE.match(store_id or ""):
report.error(where, "`storeId` is required for CHROME_EXTENSION and must be a 32-char Chrome Web Store ID")
def _validate_registry(report: Report, registry: dict[str, Any]) -> list[dict[str, Any]]:
where = "registry.json"
schema = registry.get("schema")
if schema != 1:
report.error(where, f"`schema` must be 1 (got {schema!r}); future versions need a parser update")
if "updatedAt" in registry and not _is_str(registry["updatedAt"]):
report.error(where, "`updatedAt` must be a string")
modules = registry.get("modules")
if not isinstance(modules, list):
report.error(where, "`modules` must be a list")
return []
seen_ids: set[str] = set()
seen_paths: set[str] = set()
for i, entry in enumerate(modules):
if not isinstance(entry, dict):
report.error(f"{where}::modules[{i}]", "must be an object")
continue
_validate_registry_entry(report, entry, i)
eid = entry.get("id") if _is_str(entry.get("id")) else None
if eid:
if eid in seen_ids:
report.error(f"{where}::modules[{i}]", f"duplicate id {eid!r}")
seen_ids.add(eid)
epath = entry.get("path") if _is_str(entry.get("path")) else None
if epath:
if epath in seen_paths:
report.error(f"{where}::modules[{i}]", f"duplicate path {epath!r}")
seen_paths.add(epath)
return [m for m in modules if isinstance(m, dict)]
# ───────────────────────── cross-file consistency ─────────────────────
def _validate_cross_consistency(
report: Report,
entry: dict[str, Any],
manifest: dict[str, Any],
folder: Path,
) -> None:
"""`module.json` and `registry.json` must agree on the shared fields."""
where = f"modules/{folder.name}"
# id
if entry.get("id") != manifest.get("id"):
report.error(
where,
f"`id` mismatch: registry says {entry.get('id')!r}, manifest says {manifest.get('id')!r}",
)
# name
if entry.get("name") != manifest.get("name"):
report.error(
where,
f"`name` mismatch: registry says {entry.get('name')!r}, manifest says {manifest.get('name')!r}",
)
# version: registry stores the string, manifest stores an object.
reg_version = entry.get("version")
man_version_obj = manifest.get("version") or {}
man_version = man_version_obj.get("name") if isinstance(man_version_obj, dict) else None
if reg_version != man_version:
report.error(
where,
f"`version` mismatch: registry={reg_version!r}, module.json::version.name={man_version!r}",
)
# runAt
if entry.get("runAt") and manifest.get("runAt") and entry["runAt"] != manifest["runAt"]:
report.error(
where,
f"`runAt` mismatch: registry={entry['runAt']!r}, manifest={manifest['runAt']!r}",
)
# permissions: registry should be a superset (the listing surface) of manifest.
reg_perms = set(entry.get("permissions") or [])
man_perms = set(manifest.get("permissions") or [])
missing_in_registry = man_perms - reg_perms
if missing_in_registry:
report.error(
where,
f"manifest declares permissions {sorted(missing_in_registry)} that registry.json does not list",
)
# hasCss must match style.css presence on disk.
has_css_flag = bool(entry.get("hasCss"))
style_present = (folder / "style.css").is_file()
if has_css_flag and not style_present:
report.error(
where,
"registry says `hasCss: true` but no `style.css` file is present",
)
if style_present and not has_css_flag:
report.error(
where,
"`style.css` exists but registry has `hasCss: false` — it will not be downloaded",
)
# ───────────────────────── per-folder structural checks ───────────────
def _validate_folder_layout(report: Report, folder: Path) -> None:
"""Each module folder must look like the schema says it does."""
where = f"modules/{folder.name}"
if not KEBAB_CASE_RE.match(folder.name):
report.error(where, f"folder name must be kebab-case, got {folder.name!r}")
if not (folder / "module.json").is_file():
report.error(where, "missing required `module.json`")
if not (folder / "main.js").is_file():
report.error(where, "missing required `main.js`")
# Flag stray files. The runtime ignores them, so they only bloat the repo.
expected = {"module.json", "main.js", "style.css"}
for child in folder.iterdir():
if child.name in expected or child.name in ALLOWED_EXTRA_FILES:
continue
if child.name in ALLOWED_ICON_FILES:
# Icons are size-checked separately further below.
continue
if child.is_dir():
report.warning(
where,
f"unexpected sub-directory `{child.name}/` — the runtime won't read it",
)
else:
report.warning(
where,
f"unexpected file `{child.name}` — the runtime won't download it",
)
def _validate_icon_coherence(
report: Report, folder: Path, entry: dict[str, Any] | None
) -> None:
"""
Cross-check the contributor-supplied icon against the registry.
The contract:
- If `iconUrl` is missing/empty, the app falls back to the first letter
of the module name. No icon file is needed.
- If `iconUrl` looks like a relative path, the corresponding file must
actually exist next to `main.js` and stay within the size budget.
- If `iconUrl` is absolute (starts with `http://` or `https://`), we
defer to the contributor's hosting and do not check the file system.
- The file name must come from `ALLOWED_ICON_FILES` so we don't end up
shipping random binaries through the catalog.
"""
where = f"modules/{folder.name}"
icon_url = (entry or {}).get("iconUrl")
icon_files_present = sorted(
f for f in ALLOWED_ICON_FILES if (folder / f).is_file()
)
# Warn about extra random icon files when the registry doesn't reference
# them — the runtime won't fetch a `logo.png` it has no way to know about.
if not icon_url and icon_files_present:
report.warning(
where,
f"icon file(s) {icon_files_present} present but registry has no "
"`iconUrl` — they won't be used by the runtime",
)
if not _is_str(icon_url) or not icon_url.strip():
return
icon_url = icon_url.strip()
if icon_url.startswith("http://") or icon_url.startswith("https://"):
if icon_url.startswith("http://"):
report.warning(where, "`iconUrl` uses http://; prefer https for catalog assets")
return
# Relative path case. Reject path traversal and force the file to live
# inside the module folder with a known extension.
normalised = icon_url.lstrip("./")
if normalised.startswith("/") or ".." in normalised.split("/"):
report.error(where, f"`iconUrl` must be a path inside the module folder, got {icon_url!r}")
return
if normalised not in ALLOWED_ICON_FILES:
report.error(
where,
f"`iconUrl` must reference one of {sorted(ALLOWED_ICON_FILES)}, got {icon_url!r}",
)
return
icon_path = folder / normalised
if not icon_path.is_file():
report.error(where, f"`iconUrl` points at {normalised!r} but no such file exists")
return
size = icon_path.stat().st_size
if size > MAX_ICON_BYTES:
report.error(
where,
f"`{normalised}` is {size // 1024} KB, over the {MAX_ICON_BYTES // 1024} KB cap; "
"trim the icon or host it off-repo via an absolute URL",
)
def _validate_main_js(report: Report, folder: Path) -> None:
"""Cheap heuristics on `main.js`."""
main_js = folder / "main.js"
if not main_js.is_file():
return
where = f"modules/{folder.name}/main.js"
try:
content = main_js.read_text(encoding="utf-8")
except UnicodeDecodeError:
report.error(where, "must be UTF-8")
return
if not content.strip():
report.error(where, "is empty")
return
if len(content) > 512 * 1024:
report.warning(where, f"is large ({len(content) // 1024} KB) — consider trimming")
# No top-level `return` — the IIFE wrapper turns this into a syntax error.
for lineno, line in enumerate(content.splitlines(), start=1):
stripped = line.strip()
if stripped.startswith("return ") or stripped == "return;" or stripped == "return":
# Could still be inside a function literal at line 1; we can't
# parse JS exactly, but this is a strong smell.
indent = len(line) - len(line.lstrip())
if indent == 0:
report.error(
where,
f"line {lineno}: top-level `return` — the IIFE wrapper would make this a syntax error",
)
break
# If `getConfig` is referenced but no configItems are declared in the
# manifest, the user will see "undefined" defaults silently.
manifest_path = folder / "module.json"
if manifest_path.is_file() and "getConfig(" in content:
try:
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
if not manifest.get("configItems"):
report.warning(
where,
"uses `getConfig(...)` but `module.json` declares no `configItems`",
)
except (json.JSONDecodeError, OSError):
# The JSON-level checks elsewhere will report the actual cause.
pass
# ───────────────────────── entry point ─────────────────────────────────
def _load_json(report: Report, path: Path, where: str) -> dict[str, Any] | None:
try:
return json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError:
report.error(where, f"file not found: {path}")
except json.JSONDecodeError as e:
report.error(where, f"invalid JSON: {e.msg} (line {e.lineno}, col {e.colno})")
except UnicodeDecodeError:
report.error(where, "file must be UTF-8")
return None
def main(repo_root: Path) -> int:
modules_dir = repo_root / "modules"
if not modules_dir.is_dir():
print(f"{modules_dir} does not exist", file=sys.stderr)
return 1
report = Report()
# 1. Parse + validate the registry.
registry = _load_json(report, modules_dir / "registry.json", "registry.json")
registry_entries: list[dict[str, Any]] = []
if isinstance(registry, dict):
registry_entries = _validate_registry(report, registry)
# 2. Walk module folders.
folders = sorted(
p for p in modules_dir.iterdir()
if p.is_dir() and not p.name.startswith(".")
)
folder_paths = {p.name for p in folders}
registry_paths = {e["path"] for e in registry_entries if _is_str(e.get("path"))}
custom_paths = {e["path"] for e in registry_entries if _is_str(e.get("path")) and e.get("sourceType", "CUSTOM") != "CHROME_EXTENSION"}
# Folders missing a registry entry.
for orphan in folder_paths - registry_paths:
report.error(f"modules/{orphan}", "module folder has no entry in registry.json")
# Registry entries pointing at non-existent folders (CHROME_EXTENSION entries don't need folders).
for ghost in custom_paths - folder_paths:
report.error("registry.json", f"entry refers to missing folder modules/{ghost}/")
# 3. Per-folder validation + cross-file consistency.
entries_by_path = {e["path"]: e for e in registry_entries if _is_str(e.get("path")) and e.get("sourceType", "CUSTOM") != "CHROME_EXTENSION"}
for folder in folders:
_validate_folder_layout(report, folder)
_validate_main_js(report, folder)
_validate_icon_coherence(report, folder, entries_by_path.get(folder.name))
manifest_path = folder / "module.json"
manifest = _load_json(report, manifest_path, f"modules/{folder.name}/module.json")
if isinstance(manifest, dict):
_validate_module_json(report, folder, manifest)
entry = entries_by_path.get(folder.name)
if entry:
_validate_cross_consistency(report, entry, manifest, folder)
print(report.render())
return 0 if report.ok() else 1
if __name__ == "__main__":
repo = Path(__file__).resolve().parents[3]
sys.exit(main(repo))
+128
View File
@@ -0,0 +1,128 @@
# WebToApp UI design-system historical debt baseline.
# Format: relative/path.kt|rule_key|allowed_count
# Regenerate only after intentionally accepting a new migration checkpoint.
app/src/main/java/com/webtoapp/ui/components/ActivationCodeCard.kt|raw_corner_shape|12
app/src/main/java/com/webtoapp/ui/components/ActivationCodeCard.kt|raw_surface|6
app/src/main/java/com/webtoapp/ui/components/ActivationCodeCard.kt|raw_button|1
app/src/main/java/com/webtoapp/ui/components/AutoRefreshCountdownOverlay.kt|raw_corner_shape|1
app/src/main/java/com/webtoapp/ui/components/AutoRefreshCountdownOverlay.kt|raw_surface|1
app/src/main/java/com/webtoapp/ui/components/AutoStartCard.kt|raw_corner_shape|6
app/src/main/java/com/webtoapp/ui/components/AutoStartCard.kt|raw_card|1
app/src/main/java/com/webtoapp/ui/components/AutoStartCard.kt|raw_surface|6
app/src/main/java/com/webtoapp/ui/components/BgmCard.kt|raw_surface|2
app/src/main/java/com/webtoapp/ui/components/BgmSelector.kt|raw_corner_shape|3
app/src/main/java/com/webtoapp/ui/components/BgmSelector.kt|raw_surface|9
app/src/main/java/com/webtoapp/ui/components/BrowserDisguiseConfigCard.kt|raw_corner_shape|10
app/src/main/java/com/webtoapp/ui/components/BrowserDisguiseConfigCard.kt|raw_surface|5
app/src/main/java/com/webtoapp/ui/components/CodeSnippetSelector.kt|raw_corner_shape|10
app/src/main/java/com/webtoapp/ui/components/CodeSnippetSelector.kt|raw_surface|8
app/src/main/java/com/webtoapp/ui/components/ColorPickerDialog.kt|raw_corner_shape|2
app/src/main/java/com/webtoapp/ui/components/DisguiseConfigCard.kt|raw_corner_shape|8
app/src/main/java/com/webtoapp/ui/components/DisguiseConfigCard.kt|raw_surface|5
app/src/main/java/com/webtoapp/ui/components/DnsConfigCard.kt|raw_corner_shape|2
app/src/main/java/com/webtoapp/ui/components/DnsConfigCard.kt|raw_card|1
app/src/main/java/com/webtoapp/ui/components/EncryptionConfigCard.kt|raw_corner_shape|1
app/src/main/java/com/webtoapp/ui/components/EncryptionConfigCard.kt|raw_card|1
app/src/main/java/com/webtoapp/ui/components/EnhancedActivationDialog.kt|raw_corner_shape|6
app/src/main/java/com/webtoapp/ui/components/EnhancedActivationDialog.kt|raw_surface|3
app/src/main/java/com/webtoapp/ui/components/ForcedRunConfigCard.kt|raw_card|4
app/src/main/java/com/webtoapp/ui/components/ForcedRunCountdownOverlay.kt|raw_corner_shape|1
app/src/main/java/com/webtoapp/ui/components/ForcedRunCountdownOverlay.kt|raw_surface|1
app/src/main/java/com/webtoapp/ui/components/IconGeneratorDialog.kt|raw_corner_shape|7
app/src/main/java/com/webtoapp/ui/components/IconGeneratorDialog.kt|raw_surface|1
app/src/main/java/com/webtoapp/ui/components/IconLibraryDialog.kt|raw_corner_shape|4
app/src/main/java/com/webtoapp/ui/components/IconLibraryDialog.kt|raw_card|1
app/src/main/java/com/webtoapp/ui/components/IconLibraryDialog.kt|raw_surface|2
app/src/main/java/com/webtoapp/ui/components/IconPickerWithLibrary.kt|raw_surface|1
app/src/main/java/com/webtoapp/ui/components/IsolationConfigCard.kt|raw_corner_shape|2
app/src/main/java/com/webtoapp/ui/components/IsolationConfigCard.kt|raw_card|1
app/src/main/java/com/webtoapp/ui/components/LanguageSelector.kt|raw_card|2
app/src/main/java/com/webtoapp/ui/components/LiquidGlassEffect.kt|hardcoded_color|4
app/src/main/java/com/webtoapp/ui/components/LongPressMenu.kt|hardcoded_color|6
app/src/main/java/com/webtoapp/ui/components/LongPressMenu.kt|raw_corner_shape|10
app/src/main/java/com/webtoapp/ui/components/LongPressMenu.kt|raw_surface|8
app/src/main/java/com/webtoapp/ui/components/LrcEditorDialog.kt|raw_corner_shape|2
app/src/main/java/com/webtoapp/ui/components/LrcEditorDialog.kt|raw_surface|5
app/src/main/java/com/webtoapp/ui/components/ManualLrcAligner.kt|raw_corner_shape|3
app/src/main/java/com/webtoapp/ui/components/ManualLrcAligner.kt|raw_card|2
app/src/main/java/com/webtoapp/ui/components/ManualLrcAligner.kt|raw_surface|4
app/src/main/java/com/webtoapp/ui/components/NotificationConfigCard.kt|raw_corner_shape|1
app/src/main/java/com/webtoapp/ui/components/NotificationConfigCard.kt|raw_card|1
app/src/main/java/com/webtoapp/ui/components/OnlineMusicSearchDialog.kt|raw_corner_shape|5
app/src/main/java/com/webtoapp/ui/components/OnlineMusicSearchDialog.kt|raw_card|2
app/src/main/java/com/webtoapp/ui/components/OnlineMusicSearchDialog.kt|raw_surface|3
app/src/main/java/com/webtoapp/ui/components/PremiumComponents.kt|raw_button|1
app/src/main/java/com/webtoapp/ui/components/QrCodeShareDialog.kt|raw_corner_shape|6
app/src/main/java/com/webtoapp/ui/components/QrCodeShareDialog.kt|raw_card|1
app/src/main/java/com/webtoapp/ui/components/RuntimeScreenComponents.kt|raw_corner_shape|5
app/src/main/java/com/webtoapp/ui/components/RuntimeScreenComponents.kt|raw_card|2
app/src/main/java/com/webtoapp/ui/components/RuntimeScreenComponents.kt|raw_surface|4
app/src/main/java/com/webtoapp/ui/components/SampleProjectCard.kt|raw_corner_shape|3
app/src/main/java/com/webtoapp/ui/components/Shimmer.kt|hardcoded_color|2
app/src/main/java/com/webtoapp/ui/components/Shimmer.kt|raw_corner_shape|7
app/src/main/java/com/webtoapp/ui/components/StatusBarConfigCard.kt|raw_corner_shape|6
app/src/main/java/com/webtoapp/ui/components/StatusBarImageCropper.kt|raw_corner_shape|3
app/src/main/java/com/webtoapp/ui/components/StatusBarImageCropper.kt|raw_card|2
app/src/main/java/com/webtoapp/ui/components/TypedSampleProjectCard.kt|raw_corner_shape|3
app/src/main/java/com/webtoapp/ui/components/VideoTrimmer.kt|raw_surface|1
app/src/main/java/com/webtoapp/ui/components/VirtualNavigationBar.kt|hardcoded_color|2
app/src/main/java/com/webtoapp/ui/components/VirtualNavigationBar.kt|raw_surface|1
app/src/main/java/com/webtoapp/ui/components/WtaCodeEditorDialog.kt|raw_surface|5
app/src/main/java/com/webtoapp/ui/components/WtaErrorView.kt|raw_corner_shape|1
app/src/main/java/com/webtoapp/ui/components/WtaErrorView.kt|raw_surface|1
app/src/main/java/com/webtoapp/ui/components/announcement/AnnouncementTemplates.kt|hardcoded_color|28
app/src/main/java/com/webtoapp/ui/components/announcement/AnnouncementTemplates.kt|raw_corner_shape|13
app/src/main/java/com/webtoapp/ui/components/announcement/AnnouncementTemplates.kt|raw_card|3
app/src/main/java/com/webtoapp/ui/components/announcement/AnnouncementTemplates.kt|raw_surface|1
app/src/main/java/com/webtoapp/ui/components/announcement/AnnouncementTemplates.kt|raw_button|1
app/src/main/java/com/webtoapp/ui/screens/AboutScreen.kt|raw_corner_shape|1
app/src/main/java/com/webtoapp/ui/screens/AiSettingsScreen.kt|raw_corner_shape|2
app/src/main/java/com/webtoapp/ui/screens/AiSettingsScreen.kt|raw_surface|10
app/src/main/java/com/webtoapp/ui/screens/BatchImportDialog.kt|raw_surface|2
app/src/main/java/com/webtoapp/ui/screens/CreateAppScreen.kt|raw_corner_shape|2
app/src/main/java/com/webtoapp/ui/screens/CreateAppScreen.kt|raw_surface|1
app/src/main/java/com/webtoapp/ui/screens/CreateAppScreen.kt|raw_button|1
app/src/main/java/com/webtoapp/ui/screens/CreateAppSplashCard.kt|raw_surface|2
app/src/main/java/com/webtoapp/ui/screens/CreateAppUserScripts.kt|raw_corner_shape|1
app/src/main/java/com/webtoapp/ui/screens/CreateAppUserScripts.kt|raw_card|1
app/src/main/java/com/webtoapp/ui/screens/CreateAppUserScripts.kt|raw_surface|1
app/src/main/java/com/webtoapp/ui/screens/CreateAppWebViewCards.kt|raw_corner_shape|13
app/src/main/java/com/webtoapp/ui/screens/CreateAppWebViewCards.kt|raw_surface|8
app/src/main/java/com/webtoapp/ui/screens/CreateFrontendAppScreen.kt|raw_corner_shape|3
app/src/main/java/com/webtoapp/ui/screens/CreateFrontendAppScreen.kt|raw_surface|2
app/src/main/java/com/webtoapp/ui/screens/CreateGalleryAppScreen.kt|raw_corner_shape|7
app/src/main/java/com/webtoapp/ui/screens/CreateGalleryAppScreen.kt|raw_surface|5
app/src/main/java/com/webtoapp/ui/screens/CreateGoAppScreen.kt|raw_corner_shape|7
app/src/main/java/com/webtoapp/ui/screens/CreateGoAppScreen.kt|raw_surface|8
app/src/main/java/com/webtoapp/ui/screens/CreateHtmlAppScreen.kt|raw_corner_shape|6
app/src/main/java/com/webtoapp/ui/screens/CreateHtmlAppScreen.kt|raw_surface|1
app/src/main/java/com/webtoapp/ui/screens/CreateMediaAppScreen.kt|raw_corner_shape|16
app/src/main/java/com/webtoapp/ui/screens/CreateMediaAppScreen.kt|raw_surface|7
app/src/main/java/com/webtoapp/ui/screens/CreateMultiWebAppScreen.kt|raw_corner_shape|10
app/src/main/java/com/webtoapp/ui/screens/CreateMultiWebAppScreen.kt|raw_card|2
app/src/main/java/com/webtoapp/ui/screens/CreateMultiWebAppScreen.kt|raw_surface|3
app/src/main/java/com/webtoapp/ui/screens/CreateMultiWebAppScreen.kt|raw_button|2
app/src/main/java/com/webtoapp/ui/screens/CreateOfflinePackScreen.kt|raw_corner_shape|3
app/src/main/java/com/webtoapp/ui/screens/CreateOfflinePackScreen.kt|raw_surface|2
app/src/main/java/com/webtoapp/ui/screens/CreatePhpAppScreen.kt|raw_corner_shape|3
app/src/main/java/com/webtoapp/ui/screens/CreatePhpAppScreen.kt|raw_surface|4
app/src/main/java/com/webtoapp/ui/screens/CreatePythonAppScreen.kt|raw_corner_shape|8
app/src/main/java/com/webtoapp/ui/screens/CreatePythonAppScreen.kt|raw_surface|9
app/src/main/java/com/webtoapp/ui/screens/DeviceDisguiseCard.kt|raw_corner_shape|8
app/src/main/java/com/webtoapp/ui/screens/DeviceDisguiseCard.kt|raw_surface|6
app/src/main/java/com/webtoapp/ui/screens/HomeScreen.kt|raw_corner_shape|1
app/src/main/java/com/webtoapp/ui/screens/HomeScreen.kt|raw_surface|1
app/src/main/java/com/webtoapp/ui/screens/HostsAdBlockScreen.kt|raw_surface|1
app/src/main/java/com/webtoapp/ui/screens/InstallProjectDepsCard.kt|raw_corner_shape|2
app/src/main/java/com/webtoapp/ui/screens/LinuxEnvironmentScreen.kt|raw_corner_shape|11
app/src/main/java/com/webtoapp/ui/screens/PermissionConfigScreen.kt|raw_corner_shape|5
app/src/main/java/com/webtoapp/ui/screens/PermissionConfigScreen.kt|raw_surface|5
app/src/main/java/com/webtoapp/ui/screens/PlayStoreScreen.kt|hardcoded_color|7
app/src/main/java/com/webtoapp/ui/screens/PlayStoreScreen.kt|raw_corner_shape|3
app/src/main/java/com/webtoapp/ui/screens/PlayStoreScreen.kt|raw_surface|4
app/src/main/java/com/webtoapp/ui/screens/PortManagerScreen.kt|raw_corner_shape|6
app/src/main/java/com/webtoapp/ui/screens/PortManagerScreen.kt|raw_surface|3
app/src/main/java/com/webtoapp/ui/screens/RuntimeDepsScreen.kt|raw_surface|1
app/src/main/java/com/webtoapp/ui/screens/StatsScreen.kt|raw_corner_shape|4
app/src/main/java/com/webtoapp/ui/screens/StatsScreen.kt|raw_surface|4
app/src/main/java/com/webtoapp/ui/screens/WordPressSettingsScreen.kt|raw_corner_shape|3