chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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())
|
||||
@@ -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())
|
||||
Executable
+669
@@ -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))
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user