drukscode-v2/scripts/check_config_field_drift.py
OpenCode AI e9899d2e6d
Some checks failed
Android CI Build / check (push) Has been cancelled
Android CI Build / package-apks (push) Has been cancelled
Docs Deploy / build (push) Has been cancelled
Module Market Check / Validate modules/ (push) Has been cancelled
Module Market Publish / Generate submissions.json (push) Has been cancelled
Docs Deploy / deploy (push) Has been cancelled
Initial DruksCode V2: fresh fork of web-to-app (f3b488e) with DruksCode identity
- Package rename com.webtoapp -> br.com.drukstech.codeapp
- Product rename WebToApp/web-to-app -> DruksCode/drukscode
- Design system Wta* -> Dkc*
- applicationId br.com.drukstech.codeapp (.play for gplay flavor)
- rootProject.name DruksCode
- Fresh git history (upstream kept as remote for sync)
2026-09-12 02:42:02 +02:00

185 lines
6.0 KiB
Python

#!/usr/bin/env python3
import argparse
import json
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
PAYLOAD_FILE = ROOT / "app/src/main/java/br/com/drukstech/codeapp/core/apkbuilder/ApkConfigJsonFactory.kt"
APKCONFIG_FILE = ROOT / "app/src/main/java/br/com/drukstech/codeapp/core/apkbuilder/ApkConfig.kt"
SHELLCONFIG_FILE = ROOT / "app/src/main/java/br/com/drukstech/codeapp/core/shell/ShellModeManager.kt"
ALLOWLIST_FILE = ROOT / "scripts/config_field_drift_allowlist.json"
OBJECT_RETURN_PAYLOAD_CLASSES = ("BackgroundRunConfig", "NotificationConfig")
PAYLOAD_FUNCS = [
"toShellPayload",
"webViewConfigPayload",
"floatingWindowConfigPayload",
"errorPageConfigPayload",
"mediaConfigPayload",
"htmlConfigPayload",
"galleryConfigPayload",
"autoStartConfigPayload",
"isolationConfigPayload",
"backgroundRunConfigPayload",
"notificationConfigPayload",
"networkTrustConfigPayload",
"wordpressConfigPayload",
"nodejsConfigPayload",
"phpAppConfigPayload",
"pythonAppConfigPayload",
"goAppConfigPayload",
"multiWebConfigPayload",
]
PAIR_KEY = re.compile(r'"([A-Za-z][A-Za-z0-9_]*)"\s+to\b')
SERIAL_NAME = re.compile(r'@SerializedName\(\s*"([A-Za-z][A-Za-z0-9_]*)"\s*\)')
FUN_HEADER = re.compile(r"fun\s+ApkConfig\.(\w+)\s*\(\s*\)\s*:\s*([A-Za-z<>?,\s]+)\s*[={]")
def extract_payload_keys(text: str) -> set[str]:
lines = text.splitlines()
func_ranges: list[tuple[str, int, int]] = []
headers: list[tuple[int, str, str]] = []
for idx, line in enumerate(lines):
m = FUN_HEADER.search(line)
if not m:
continue
name = m.group(1)
if not name.endswith("Payload"):
continue
headers.append((idx, name, m.group(2)))
for i, (idx, name, _ret) in enumerate(headers):
end = headers[i + 1][0] if i + 1 < len(headers) else len(lines)
func_ranges.append((name, idx, end))
keys: set[str] = set()
for name, start, end in func_ranges:
if name not in PAYLOAD_FUNCS:
continue
body = "\n".join(lines[start:end])
keys.update(PAIR_KEY.findall(body))
return keys
def extract_object_returned_keys(text: str) -> set[str]:
keys: set[str] = set()
for cls in OBJECT_RETURN_PAYLOAD_CLASSES:
pattern = re.compile(rf"data class {cls}\s*\((.*?)\n\)", re.DOTALL)
m = pattern.search(text)
if not m:
continue
body = m.group(1)
for prop in re.finditer(r"^\s*val\s+(\w+)\s*:", body, re.MULTILINE):
keys.add(prop.group(1))
return keys
def extract_shell_keys(text: str) -> set[str]:
return set(SERIAL_NAME.findall(text))
def load_allowlist() -> tuple[set[str], dict[str, str]]:
if not ALLOWLIST_FILE.is_file():
return set(), {}
data = json.loads(ALLOWLIST_FILE.read_text(encoding="utf-8"))
entries = data.get("allowlist", [])
keys: set[str] = set()
reasons: dict[str, str] = {}
for entry in entries:
key = entry.get("key")
reason = (entry.get("reason") or "").strip()
if not key:
continue
keys.add(key)
reasons[key] = reason
return keys, reasons
def main() -> int:
parser = argparse.ArgumentParser(description="Detect field-name drift between ApkConfig payload and ShellConfig @SerializedName")
parser.add_argument("--payload-file", type=Path, default=PAYLOAD_FILE)
parser.add_argument("--apkconfig-file", type=Path, default=APKCONFIG_FILE)
parser.add_argument("--shellconfig-file", type=Path, default=SHELLCONFIG_FILE)
parser.add_argument("--allowlist-file", type=Path, default=ALLOWLIST_FILE)
args = parser.parse_args()
errors: list[str] = []
warnings: list[str] = []
for f in (args.payload_file, args.apkconfig_file, args.shellconfig_file):
if not f.is_file():
errors.append(f"source file missing: {f}")
if errors:
for e in errors:
print(f"ERROR: {e}")
return 1
payload_text = args.payload_file.read_text(encoding="utf-8")
apkconfig_text = args.apkconfig_file.read_text(encoding="utf-8")
shell_text = args.shellconfig_file.read_text(encoding="utf-8")
payload_keys = extract_payload_keys(payload_text)
payload_keys |= extract_object_returned_keys(apkconfig_text)
shell_keys = extract_shell_keys(shell_text)
allow_keys, allow_reasons = load_allowlist()
allow_no_reason = sorted(k for k in allow_keys if not allow_reasons.get(k))
if allow_no_reason:
errors.append(
"allowlist entries missing 'reason' (every entry must document why it is exempt): "
+ ", ".join(allow_no_reason)
)
stale_allow = sorted(k for k in allow_keys if k not in payload_keys and k not in shell_keys)
if stale_allow:
warnings.append(
"allowlist contains keys present in neither side (stale, remove them): "
+ ", ".join(stale_allow)
)
only_payload = sorted((payload_keys - shell_keys) - allow_keys)
only_shell = sorted((shell_keys - payload_keys) - allow_keys)
for k in only_payload:
errors.append(
f"payload writes key '{k}' but ShellConfig has no matching @SerializedName "
"(serialized but runtime never receives)"
)
for k in only_shell:
errors.append(
f"ShellConfig declares @SerializedName('{k}') but no payload writes it "
"(runtime field exists but export never emits)"
)
if warnings:
for w in warnings:
print(f"WARN: {w}")
if errors:
for e in errors:
print(f"ERROR: {e}")
print(
f"check_config_field_drift: FAILED "
f"(payload_only={len(only_payload)} shell_only={len(only_shell)} "
f"allowlist={len(allow_keys)})"
)
return 1
print(
f"check_config_field_drift: OK "
f"(payload={len(payload_keys)} shell={len(shell_keys)} "
f"allowlist={len(allow_keys)} shared={len(payload_keys & shell_keys)})"
)
return 0
if __name__ == "__main__":
sys.exit(main())