diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9c47312..2e543f2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -48,7 +48,7 @@ repos: entry: scripts/check_repo_contract.sh language: system pass_filenames: false - files: ^(CLAUDE\.md|AGENTS\.md|scripts/test_changed\.sh)$ + files: ^(CLAUDE\.md|AGENTS\.md|scripts/test_changed\.sh)$|(^|/)(AndroidManifest\.xml|build\.gradle(\.kts)?|strings\.xml|pubspec\.yaml|repo-contract\.toml|[^/]+\.desktop|assets/icon/[^/]+\.png)$ - id: md-naming name: markdown naming convention diff --git a/repo_contract/README.md b/repo_contract/README.md index 936b16d..ce1e837 100644 --- a/repo_contract/README.md +++ b/repo_contract/README.md @@ -93,6 +93,44 @@ web-relevant file is staged (`web.DEFAULT_FILES`, overridable with degrades to a pass, and the gate defers to that workflow when run inside another GitHub Actions job. +## Flutter apps: the launcher rules + +Decided by kuhy 2026-10-23 (`launcher.py`). They apply to every tracked +`pubspec.yaml` whose `dependencies:` pull in the Flutter SDK and that has an +`android/` dir beside it, at the root or nested (`app/`, screen-locker's +`stronglift_replacement/workout_app`). Violations are prefixed `launcher:` +(or `launcher []:` for a nested app). + +1. **Launcher name** (`launcher_label.py`): `` + of the main manifest matches `^[A-Z][A-Za-z]{0,9}$`, and so does the + `android:label` of any activity or alias with a MAIN + LAUNCHER filter + (it overrides the application label on the launcher). A literal, + `@string/` and `${placeholder}` are all resolved. A placeholder is + checked at every `manifestPlaceholders` assignment in + `android/app/build.gradle[.kts]`, except inside a variant that sets + `applicationIdSuffix`: rule 3 hides that variant from the launcher. + Anything unresolvable fails. The rules and messages mirror testsAndMisc + `python_pkg/app_icons/labels.py`. +2. **Desktop name**: every tracked `*.desktop` file in the repo has a + `[Desktop Entry] Name=` that passes rule 1. +3. **Hidden variants** (`android_manifest.py`): a buildType or flavor whose + own gradle block sets `applicationIdSuffix` needs + `android/app/src//AndroidManifest.xml` removing the LAUNCHER + category (`tools:node="remove"` on the ``) from each launcher + activity. MAIN must stay, so `am start -n` still works. +4. **Icon size** (`png_ink.py`, stdlib only): `assets/icon/icon_foreground.png`, + when present, is 8-bit RGBA 1024x1024. Its furthest inked pixel + (alpha > 127) sits 340px ±1% from the ink-box centre, and that centre is + within 2px of (512, 512). This is the shared generator's `fit.py` + measurement. An app that cannot ship the PNG declares + `[launcher_icon] skip = ""` in a `repo-contract.toml` beside its + pubspec, and the reason is required. + +The repo-contract hook's `files:` (template `pre-commit-snippet.yaml`) also +matches every file these rules read. `install.py` never rewrites an existing +hook, so roll a widened regex out with +`python3 -m repo_contract.hook_sync [--apply] [REPO ...]` (dry run by default). + ## Tests ```bash diff --git a/repo_contract/android_manifest.py b/repo_contract/android_manifest.py new file mode 100644 index 0000000..0595882 --- /dev/null +++ b/repo_contract/android_manifest.py @@ -0,0 +1,169 @@ +"""AndroidManifest.xml reading, and rule 3: suffixed variants stay off the launcher. + +A variant whose gradle block sets `applicationIdSuffix` installs beside the +real app (sandbox, debug), so it must not put a second icon in the launcher. +The statically checkable form is a variant manifest that removes the +LAUNCHER category and keeps MAIN, so `adb shell am start -n` still opens it: + + + + + + + +""" + +from __future__ import annotations + +import xml.etree.ElementTree as ET +from pathlib import Path + +from repo_contract import gradle_blocks +from repo_contract.sources import read_text + +ANDROID = "{http://schemas.android.com/apk/res/android}" +TOOLS = "{http://schemas.android.com/tools}" +TOOLS_DECL = 'xmlns:tools="http://schemas.android.com/tools"' +LAUNCHER = "android.intent.category.LAUNCHER" +MAIN_ACTION = "android.intent.action.MAIN" +APP = Path("android/app") +MAIN = APP / "src/main/AndroidManifest.xml" +GRADLE = (APP / "build.gradle.kts", APP / "build.gradle") +ACTIVITIES = ("activity", "activity-alias") +REMOVED = ("remove", "removeAll") + + +class ManifestError(ValueError): + """A manifest is missing or not valid XML (message is the full reason).""" + + +def parse(app: Path, rel: Path) -> ET.Element: + """Root element of `app/rel`; ManifestError when missing or malformed.""" + text = read_text(app / rel) + if not text: + raise ManifestError(f"{rel} is missing or unreadable") + try: + return ET.fromstring(text) + except ET.ParseError as exc: + raise ManifestError(f"{rel} is not valid XML: {exc}") from exc + + +def gradle_script(app: Path) -> tuple[Path | None, str]: + """(relative path, text) of the app module's build script, if any.""" + for rel in GRADLE: + if (app / rel).is_file(): + return rel, read_text(app / rel) + return None, "" + + +def _short(name: str) -> str: + """`.MainActivity` / `com.x.MainActivity` -> `MainActivity`.""" + return name.rsplit(".", 1)[-1] + + +def _categories(activity: ET.Element) -> list[tuple[ET.Element, ET.Element]]: + """(intent-filter, category) pairs declaring LAUNCHER in `activity`.""" + return [ + (flt, cat) + for flt in activity.iter("intent-filter") + for cat in flt.iter("category") + if cat.get(f"{ANDROID}name") == LAUNCHER + ] + + +def launcher_activities(root: ET.Element) -> list[str]: + """Names of the activities (and aliases) that show in the launcher.""" + return [ + act.get(f"{ANDROID}name", "") + for kind in ACTIVITIES + for act in root.iter(kind) + if _categories(act) + ] + + +def main_launchers(root: ET.Element) -> list[ET.Element]: + """Activities (and aliases) with an intent-filter holding MAIN + LAUNCHER.""" + return [ + act + for kind in ACTIVITIES + for act in root.iter(kind) + if any( + action.get(f"{ANDROID}name") == MAIN_ACTION + for flt, _ in _categories(act) + for action in flt.iter("action") + ) + ] + + +def _removes_main(activity: ET.Element) -> bool: + """Whether the activity, a MAIN intent-filter or MAIN itself is removed.""" + if activity.get(f"{TOOLS}node") in REMOVED: + return True + return any( + node.get(f"{TOOLS}node") in REMOVED + for flt in activity.iter("intent-filter") + for action in flt.iter("action") + if action.get(f"{ANDROID}name") == MAIN_ACTION + for node in (flt, action) + ) + + +def _hides(root: ET.Element, name: str, rel: Path, variant: str) -> list[str]: + """Problems hiding launcher activity `name` in variant manifest `root`.""" + matches = [ + act + for kind in ACTIVITIES + for act in root.iter(kind) + if _short(act.get(f"{ANDROID}name", "")) == _short(name) + ] + if any(_removes_main(act) for act in matches): + return [ + ( + f"variant '{variant}': {rel} removes {MAIN_ACTION} from {name};" + " keep MAIN so `am start -n` still works" + ) + ] + hidden = any( + cat.get(f"{TOOLS}node") == "remove" + for act in matches + for _, cat in _categories(act) + ) + if hidden: + return [] + return [ + ( + f"variant '{variant}': {rel} does not remove the LAUNCHER category from" + f' {name} (tools:node="remove" on the )' + ) + ] + + +def variant_problems(app: Path) -> list[str]: + """Rule 3 for one Flutter app directory.""" + _, gradle = gradle_script(app) + variants = gradle_blocks.suffixed_variants(gradle) + try: + names = launcher_activities(parse(app, MAIN)) + except ManifestError: + return [] # rule 1 already reports the main manifest + if not names: + return [] + out: list[str] = [] + for variant in variants: + rel = APP / "src" / variant / "AndroidManifest.xml" + if not (app / rel).is_file(): + out.append( + f"variant '{variant}' sets applicationIdSuffix but {rel} is missing:" + f' hide it with in {names[0]}\'s intent-filter' + ) + continue + try: + root = parse(app, rel) + except ManifestError as exc: + out.append(str(exc)) + continue + for name in names: + out += _hides(root, name, rel, variant) + return out diff --git a/repo_contract/check.py b/repo_contract/check.py index 1d9ba1e..79b6841 100644 --- a/repo_contract/check.py +++ b/repo_contract/check.py @@ -24,6 +24,7 @@ from repo_contract.contract import ( script_token, verbose_in_script, ) +from repo_contract.launcher import problems as launcher_problems from repo_contract.stacks import detect_stacks, has_tests from repo_contract.web import problems as web_problems @@ -102,11 +103,12 @@ def _check_consistency( def violations(repo: Path) -> tuple[list[str], list[str]]: """(violations, detected stacks) for `repo`. Raises Unreadable. - Website repos also owe the AgentReady gate (web.problems), reported - whatever state the Commands section is in. + Website repos also owe the AgentReady gate (web.problems), and Flutter + apps the launcher rules (launcher.problems), reported whatever state the + Commands section is in. """ found, stacks = _doc_violations(repo) - return found + web_problems(repo), stacks + return found + web_problems(repo) + launcher_problems(repo), stacks def _doc_violations(repo: Path) -> tuple[list[str], list[str]]: diff --git a/repo_contract/gradle_blocks.py b/repo_contract/gradle_blocks.py new file mode 100644 index 0000000..634af58 --- /dev/null +++ b/repo_contract/gradle_blocks.py @@ -0,0 +1,184 @@ +"""Just enough gradle (Kotlin DSL or Groovy) parsing for the launcher rules. + +Not a gradle evaluator: comments are dropped, braces are matched outside +string literals, and each `{ ... }` becomes a Block whose path names its +enclosing headers (`create("sandbox")` -> `sandbox`, `release {` -> `release`). +A variant block is one directly under `buildTypes` or `productFlavors`. +Anything this cannot read as a string literal is reported as None so the +caller fails closed instead of guessing. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass + +VARIANT_PARENTS = ("buildTypes", "productFlavors") +_NAMED = re.compile( + r"""(?:create|getByName|register|maybeCreate|named)\s*(?:<[^>]*>)?\s*""" + r"""\(\s*["']([^"']+)["']\s*\)\s*$""" +) +_IDENT = re.compile(r"([A-Za-z_]\w*)\s*$") +_SUFFIX = re.compile(r"\bapplicationIdSuffix\b") +_MAP = re.compile( + r"manifestPlaceholders\s*(?:\+=|=|\.\s*putAll\s*\()\s*" + r"(?:mutableMapOf|mapOf|hashMapOf)?\s*[\[(]([^\])]*)[\])]" +) + + +@dataclass(frozen=True) +class Block: + """One `{ ... }`: its header path, start offset, and its own text.""" + + path: tuple[str, ...] + start: int + body: str + + +def _string_end(text: str, i: int) -> int: + """Index just past the string literal opening at `i` (EOF if unclosed).""" + quote = '"""' if text.startswith('"""', i) else text[i] + j = i + len(quote) + while j < len(text): + if text[j] == "\\": + j += 2 + elif text.startswith(quote, j): + return j + len(quote) + else: + j += 1 + return len(text) + + +def strip_comments(text: str) -> str: + """`text` without // and /* */ comments; string literals untouched.""" + out, i = [], 0 + while i < len(text): + if text[i] in "\"'": + j = _string_end(text, i) + out.append(text[i:j]) + i = j + elif text.startswith("//", i): + j = text.find("\n", i) + i = len(text) if j < 0 else j + elif text.startswith("/*", i): + j = text.find("*/", i + 2) + i = len(text) if j < 0 else j + 2 + else: + out.append(text[i]) + i += 1 + return "".join(out) + + +def _header(own: list[str]) -> str: + """Block name from the text just before its `{`.""" + tail = re.split(r"[\n;]", "".join(own))[-1] + named = _NAMED.search(tail) or _IDENT.search(tail) + return named.group(1) if named else "?" + + +def blocks(text: str) -> list[Block]: + """Every block of a gradle script (closing order) plus the top level last.""" + clean = strip_comments(text) + stack: list[tuple[tuple[str, ...], int, list[str]]] = [((), 0, [])] + out: list[Block] = [] + i = 0 + while i < len(clean): + ch = clean[i] + if ch in "\"'": + j = _string_end(clean, i) + stack[-1][2].append(clean[i:j]) + i = j + continue + if ch == "{": + stack.append((stack[-1][0] + (_header(stack[-1][2]),), i, [])) + elif ch == "}" and len(stack) > 1: + path, start, own = stack.pop() + out.append(Block(path, start, "".join(own))) + stack[-1][2].append("\n") + elif ch != "}": + stack[-1][2].append(ch) + i += 1 + while stack: + path, start, own = stack.pop() + out.append(Block(path, start, "".join(own))) + return out + + +def variant(block: Block) -> str | None: + """The variant a block configures (`sandbox`, `debug`), else None.""" + if len(block.path) >= 2 and block.path[-2] in VARIANT_PARENTS: + return block.path[-1] + return None + + +def where(block: Block) -> str: + """Human label for a block: `productFlavors.sandbox`, `defaultConfig`.""" + if variant(block): + return ".".join(block.path[-2:]) + if block.path and block.path[-1] == "defaultConfig": + return "defaultConfig" + return ".".join(block.path) or "top level" + + +def _suffixed(block: Block) -> bool: + """Whether `block` is a variant that sets applicationIdSuffix.""" + return variant(block) is not None and bool(_SUFFIX.search(block.body)) + + +def suffixed_variants(text: str) -> list[str]: + """Variants whose own block sets applicationIdSuffix.""" + names = {variant(b) or "" for b in blocks(text) if _suffixed(b)} + return sorted(names) + + +def _literal(text: str) -> str | None: + """The string literal `text` starts with; None for anything else.""" + if not text or text[0] not in "\"'": + return None + end = text.find(text[0], 1) + if end < 0 or "$" in text[1:end] or "\\" in text[1:end]: + return None + return text[1:end] + + +def _values(body: str, key: str) -> list[tuple[int, str | None]]: + """(offset, literal or None) of every assignment to placeholder `key`.""" + k = re.escape(key) + q = r"""(['"])""" + k + r"\1" + direct = ( + rf"manifestPlaceholders\s*\[\s*{q}\s*\]\s*=\s*", + rf"manifestPlaceholders\s*\.\s*put\s*\(\s*{q}\s*,\s*", + rf"manifestPlaceholders\s*\.\s*{k}\s*=\s*", + ) + found = [ + (m.start(), _literal(body[m.end() :])) + for pattern in direct + for m in re.finditer(pattern, body) + ] + pair = re.compile(r"""(? int: + return item[0] + + +def placeholder_values( + text: str, key: str, *, unsuffixed_only: bool = False +) -> list[tuple[str, str | None]]: + """(block label, literal or None) per assignment, by block start. + + `unsuffixed_only` drops assignments inside a suffixed variant's block. + """ + out = [] + for block in sorted(blocks(text), key=lambda b: b.start): + if unsuffixed_only and _suffixed(block): + continue + values = sorted(_values(block.body, key), key=_first) + out += [(where(block), v) for _, v in values] + return out diff --git a/repo_contract/hook_sync.py b/repo_contract/hook_sync.py new file mode 100644 index 0000000..9626290 --- /dev/null +++ b/repo_contract/hook_sync.py @@ -0,0 +1,74 @@ +"""Bring every repo's repo-contract hook `files:` line up to the template. + +Usage: python3 -m repo_contract.hook_sync [--apply] [REPO ...] +(default REPOs: the sweep roots, git repos directly under ~/src and ~). + +Why this exists: `install.py` only checks that the hook id is present +(`precommit.needs_hook`), so widening the template's `files:` regex (as for +the Flutter launcher rules, 2026-10-23) never reaches a repo installed +earlier. This rewrites that one line with `precommit.set_hook_line` and leaves +the rest of the config, comments included, untouched. + +Dry run by default: exit 1 when any repo is stale, 0 when all are current. +With --apply, stale repos are rewritten (not committed) and it exits 0. +""" + +from __future__ import annotations + +import argparse +import sys +from collections import Counter +from pathlib import Path + +if __package__ in (None, ""): # pragma: no cover - plain-script import path + sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from repo_contract.bootstrap import TEMPLATES +from repo_contract.precommit import hook_line, needs_hook, set_hook_line +from repo_contract.sweep import find_repos + +HOOK_ID = "repo-contract" +SNIPPET = TEMPLATES / "pre-commit-snippet.yaml" +KEY = "files" + + +def template_line() -> str: + """The template's `files:` line, exactly as a hook block carries it.""" + lines = SNIPPET.read_text(encoding="utf-8").splitlines() + return next(ln for ln in lines if ln.startswith(f" {KEY}:")) + + +def status(repo: Path) -> str: + """`no hook`, `current` or `stale` for one repo.""" + if needs_hook(repo, HOOK_ID): + return "no hook" + current = hook_line(repo, HOOK_ID, KEY) == template_line() + return "current" if current else "stale" + + +def main(argv: list[str] | None = None, roots: list[Path] | None = None) -> int: + """CLI entry point; see the module docstring for exit codes.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("repos", nargs="*", type=Path) + parser.add_argument("--apply", action="store_true", help="rewrite stale hooks") + args = parser.parse_args(argv) + home = Path.home() + repos = args.repos or find_repos(roots or [home / "src", home]) + counts: Counter[str] = Counter() + for repo in repos: + state = status(repo) + if state == "stale" and args.apply: + set_hook_line(repo, HOOK_ID, KEY, template_line()) + state = "updated" + counts[state] += 1 + label = "would update" if state == "stale" else state + print(f"{label}: {repo}") + print( + f"hook-sync: {counts['stale']} stale, {counts['updated']} updated," + f" {counts['current']} current, {counts['no hook']} without the hook" + ) + return 1 if counts["stale"] else 0 + + +if __name__ == "__main__": # pragma: no cover + sys.exit(main()) diff --git a/repo_contract/label_widths.py b/repo_contract/label_widths.py new file mode 100644 index 0000000..120fcb1 --- /dev/null +++ b/repo_contract/label_widths.py @@ -0,0 +1,33 @@ +"""Advance widths of the ASCII letters in the Pixel launcher's label font. + +Measured from ``GoogleSansFlex-Regular.ttf`` pulled off kuhy's Pixel 6a +(``/product/fonts``) at a 100px em, rounded. Only the numbers are kept, not +the font, which is not redistributable. Kerning is ignored; on every label +measured it moved the total by under 1%. + +Copied verbatim from testsAndMisc `python_pkg/app_icons/_label_widths.py` +(utils must not import another repo); keep the two in step. +""" + +from __future__ import annotations + +from typing import Final + +ADVANCE: Final[dict[str, int]] = { + "a": 54, "b": 60, "c": 54, "d": 60, "e": 56, "f": 38, "g": 60, "h": 56, + "i": 23, "j": 23, "k": 50, "l": 21, "m": 88, "n": 56, "o": 60, "p": 60, + "q": 60, "r": 38, "s": 47, "t": 36, "u": 56, "v": 51, "w": 78, "x": 49, + "y": 51, "z": 48, "A": 67, "B": 61, "C": 74, "D": 70, "E": 55, "F": 53, + "G": 79, "H": 70, "I": 24, "J": 53, "K": 63, "L": 51, "M": 87, "N": 71, + "O": 82, "P": 58, "Q": 82, "R": 59, "S": 55, "T": 54, "U": 65, "V": 63, + "W": 94, "X": 64, "Y": 60, "Z": 57, +} # fmt: skip + +# The widest label seen whole in the app drawer is WakeAlarm (522), and +# FocusOwner (580) lost its last letters there (kuhy, 2026-10-23). +MAX_WIDTH: Final[int] = 522 + + +def label_width(name: str) -> int: + """Return a label's width in font units; only call on ASCII letters.""" + return sum(ADVANCE[char] for char in name) diff --git a/repo_contract/launcher.py b/repo_contract/launcher.py new file mode 100644 index 0000000..8493dfb --- /dev/null +++ b/repo_contract/launcher.py @@ -0,0 +1,157 @@ +"""Launcher rules for every Flutter app in a repo (kuhy, 2026-10-23). + +Applies to each tracked `pubspec.yaml` whose `dependencies:` include the +Flutter SDK and that has an `android/` directory beside it, at the repo root +or nested (screen-locker's workout_app): + +1. the launcher name (`launcher_label.py`); +2. every tracked `*.desktop` file's `[Desktop Entry] Name=` meets the same rule; +3. a suffixed variant stays off the launcher (`android_manifest.py`); +4. `assets/icon/icon_foreground.png` fills the family ink circle + (`png_ink.py`), unless the app's `repo-contract.toml` declares + + [launcher_icon] + skip = "" +""" + +from __future__ import annotations + +import re +import subprocess +import tomllib +from pathlib import Path, PurePosixPath + +from repo_contract import android_manifest, launcher_label, png_ink +from repo_contract.stacks import PRUNE + +CONFIG = Path("repo-contract.toml") +TABLE = "launcher_icon" +ICON = Path("assets/icon/icon_foreground.png") +_SDK = re.compile(r"^\s+sdk:\s*flutter\s*$") + + +class ConfigError(ValueError): + """`[launcher_icon]` exists but is malformed.""" + + +def _tracked(repo: Path, *patterns: str) -> list[PurePosixPath] | None: + """Tracked paths matching `patterns`; None when git cannot list them.""" + done = subprocess.run( + ["git", "-C", str(repo), "ls-files", "-z", "--", *patterns], + capture_output=True, + text=True, + check=False, + ) + if done.returncode != 0: + return None + return [PurePosixPath(p) for p in done.stdout.split("\0") if p] + + +def _needs_flutter(pubspec: Path) -> bool: + """Whether the top-level `dependencies:` block pulls in the Flutter SDK.""" + try: + lines = pubspec.read_text(encoding="utf-8").splitlines() + except (OSError, UnicodeDecodeError): + return False + inside = False + for line in lines: + code = line.split("#", 1)[0].rstrip() # comments never end a block + if not code.strip(): + continue + if not code[0].isspace(): + inside = code == "dependencies:" + elif inside and _SDK.match(code): + return True + return False + + +def flutter_apps(repo: Path) -> list[Path]: + """Android-enabled Flutter app dirs in `repo` (root-only outside git).""" + found = _tracked(repo, "pubspec.yaml", "*/pubspec.yaml") + rels = [PurePosixPath("pubspec.yaml")] if found is None else found + apps = [] + for rel in sorted(rels): + if PRUNE.intersection(rel.parts): + continue + app = repo / rel.parent + if (app / "android").is_dir() and _needs_flutter(app / "pubspec.yaml"): + apps.append(app) + return apps + + +def _desktop_name(text: str) -> list[str]: + """`Name=` values of the `[Desktop Entry]` group (not actions/locales).""" + names, inside = [], False + for line in text.splitlines(): + if line.startswith("["): + inside = line.strip() == "[Desktop Entry]" + elif inside and line.startswith("Name="): + names.append(line[len("Name=") :].strip()) + return names + + +def desktop_problems(repo: Path) -> list[str]: + """Rule 2 over every tracked .desktop file in the repo.""" + out = [] + for rel in _tracked(repo, "*.desktop") or []: + try: + text = (repo / rel).read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + out.append(f"{rel}: unreadable") + continue + names = _desktop_name(text) + if not names: + out.append(f"{rel}: no Name= in [Desktop Entry]") + for name in names: + out += [f"{rel}: Name={p}" for p in launcher_label.label_problems(name)] + return out + + +def icon_skip(app: Path) -> str | None: + """The `[launcher_icon] skip` reason; None when the icon rule applies.""" + path = app / CONFIG + if not path.is_file(): + return None + try: + data = tomllib.loads(path.read_text(encoding="utf-8")) + except tomllib.TOMLDecodeError as exc: + raise ConfigError(f"not valid TOML: {exc}") from exc + if TABLE not in data: + return None + table = data[TABLE] + if not isinstance(table, dict): + raise ConfigError(f"'{TABLE}' must be a table: [{TABLE}]") + unknown = sorted(set(table) - {"skip"}) + if unknown: + raise ConfigError(f"unknown key(s) in [{TABLE}]: {', '.join(unknown)}") + reason = table.get("skip") + if not isinstance(reason, str) or not reason.strip(): + raise ConfigError(f'[{TABLE}] skip = "" needs a non-empty reason') + return reason + + +def _icon(app: Path) -> list[str]: + try: + if icon_skip(app) is not None: + return [] + except ConfigError as exc: + return [f"{CONFIG}: {exc}"] + if not (app / ICON).is_file(): + return [] + return png_ink.icon_problems(app / ICON, str(ICON)) + + +def problems(repo: Path) -> list[str]: + """Every launcher-rule violation in `repo`, prefixed `launcher[ [app]]: `.""" + apps = flutter_apps(repo) + if not apps: + return [] + out = [] + for app in apps: + rel = app.relative_to(repo).as_posix() + prefix = "launcher: " if rel == "." else f"launcher [{rel}]: " + found = launcher_label.problems(app) + found += android_manifest.variant_problems(app) + found += _icon(app) + out += [prefix + p for p in found] + return out + [f"launcher: {p}" for p in desktop_problems(repo)] diff --git a/repo_contract/launcher_label.py b/repo_contract/launcher_label.py new file mode 100644 index 0000000..2211d04 --- /dev/null +++ b/repo_contract/launcher_label.py @@ -0,0 +1,144 @@ +"""Rule 1: the launcher name every Flutter app shows on the phone. + +kuhy's rules (2026-10-23): a name starts with a capital, holds only ASCII +letters (no spaces, underscores or punctuation) and has at most 10 characters, +so the Pixel launcher never truncates it with an ellipsis. Its width in +the launcher font is capped too (`label_widths.py`): FocusOwner, 10 +characters, was still cut. The same rules live +in `~/src/testsAndMisc/python_pkg/app_icons/labels.py` (`label_problems`); +the messages are mirrored here, not imported, because utils must not depend +on another repo. + +The name is `` of the main manifest, plus the +`android:label` of any activity (or alias) whose intent-filter holds MAIN + +LAUNCHER, because that one overrides the application label on the launcher. +Each is resolved from a literal, `@string/` (main res/values/strings.xml) +or `${placeholder}`: every `manifestPlaceholders` assignment in +android/app/build.gradle[.kts] outside a variant that sets applicationIdSuffix +(rule 3 hides those from the launcher, so they may say "SANDBOX"). +Anything that cannot be resolved statically fails: never a silent pass. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +from repo_contract import gradle_blocks +from repo_contract.android_manifest import ( + ANDROID, + MAIN, + ManifestError, + gradle_script, + main_launchers, + parse, +) +from repo_contract.label_widths import MAX_WIDTH, label_width + +MAX_LENGTH = 10 +STRINGS = Path("android/app/src/main/res/values/strings.xml") +_ASCII_LETTERS = re.compile(r"[A-Za-z]+") +_STRING_REF = re.compile(r"@string/(\w+)") +_PLACEHOLDER = re.compile(r"\$\{(\w+)\}") +_UNRESOLVED = "cannot resolve the launcher name" + + +def label_problems(name: str) -> list[str]: + """Every launcher-name rule `name` breaks (app_icons labels.py wording).""" + if not name: + return ["label is empty"] + problems: list[str] = [] + letters_only = _ASCII_LETTERS.fullmatch(name) is not None + if not letters_only: + problems.append( + f"{name!r} must contain only ASCII letters (no spaces, _ or symbols)" + ) + if not name[0].isupper(): + problems.append(f"{name!r} must start with a capital letter") + # The width table only knows ASCII letters; other names already failed. + if letters_only and label_width(name) > MAX_WIDTH: + problems.append( + f"{name!r} is {label_width(name)} wide in the launcher font; " + f"at most {MAX_WIDTH} fit" + ) + if len(name) > MAX_LENGTH: + problems.append( + f"{name!r} has {len(name)} characters; at most {MAX_LENGTH} fit" + ) + return problems + + +def _string(app: Path, attr: str, name: str) -> list[str]: + try: + root = parse(app, STRINGS) + except ManifestError: + return [f"{attr}: {STRINGS} is missing or unreadable; {_UNRESOLVED}"] + for node in root.iter("string"): + if node.get("name") == name: + value = "".join(node.itertext()) + return [f"{attr} = {p}" for p in label_problems(value)] + return [f'{attr}: no in {STRINGS}; {_UNRESOLVED}'] + + +def _placeholder(app: Path, attr: str, key: str) -> list[str]: + rel, gradle = gradle_script(app) + if rel is None: + return [f"{attr}: no android/app/build.gradle(.kts); {_UNRESOLVED}"] + values = gradle_blocks.placeholder_values(gradle, key, unsuffixed_only=True) + if not values: + scope = ( + " for an unsuffixed variant" + if gradle_blocks.placeholder_values(gradle, key) + else "" + ) + return [ + ( + f'{attr}: no manifestPlaceholders["{key}"] assignment{scope}' + f" in {rel}; {_UNRESOLVED}" + ) + ] + out = [] + for where, value in values: + if value is None: + out.append( + f"{attr}: the value set in {where} is not a string literal;" + f" {_UNRESOLVED}" + ) + else: + out += [f"{attr} in {where} = {p}" for p in label_problems(value)] + return out + + +def _resolve(app: Path, attr: str, label: str) -> list[str]: + """Problems with one android:label value; `attr` prefixes each message.""" + if string := _STRING_REF.fullmatch(label): + return _string(app, f"{attr} {label}", string.group(1)) + if placeholder := _PLACEHOLDER.fullmatch(label): + return _placeholder(app, f"{attr} {label}", placeholder.group(1)) + if label.startswith("@") or "${" in label: + return [ + ( + f"{attr} {label!r} cannot be resolved statically:" + " use a literal, @string/ or ${}" + ) + ] + return [f"{attr} {p}" for p in label_problems(label)] + + +def problems(app: Path) -> list[str]: + """Rule 1 for one Flutter app directory.""" + try: + root = parse(app, MAIN) + except ManifestError as exc: + return [f"{exc}; {_UNRESOLVED}"] + application = root.find("application") + label = None if application is None else application.get(f"{ANDROID}label") + if label is None: + return [f"{MAIN}: has no android:label; {_UNRESOLVED}"] + out = _resolve(app, "android:label", label) + for activity in main_launchers(root): + own = activity.get(f"{ANDROID}label") + if own is not None: + name = activity.get(f"{ANDROID}name", "?") + out += _resolve(app, f" android:label", own) + return out diff --git a/repo_contract/png_ink.py b/repo_contract/png_ink.py new file mode 100644 index 0000000..ff80cf2 --- /dev/null +++ b/repo_contract/png_ink.py @@ -0,0 +1,154 @@ +"""Rule 4: every launcher glyph fills the same circle as the shared generator's. + +The shared icon generator (`~/src/testsAndMisc/python_pkg/app_icons`, `fit.py`) +scales each glyph so its furthest inked pixel (alpha > 127) sits exactly +`style.INK_RADIUS` = 340px from the centre of its ink box, on a 1024px canvas, +with that centre on the canvas centre. This module re-measures a committed +`assets/icon/icon_foreground.png` the same way, so a hand-edited or stale +foreground cannot drift back to a different size. + +Stdlib only (zlib + struct): the per-repo CI runner installs nothing, so Pillow +is not an option. Only the generator's own output format is decoded (8-bit +RGBA, non-interlaced, 1024x1024); anything else fails closed with a message. +Only the alpha channel is unfiltered: PNG filters work byte-wise with a +one-pixel offset, so alpha bytes depend only on other alpha bytes. +""" + +from __future__ import annotations + +import math +import struct +import zlib +from pathlib import Path + +CANVAS = 1024 +INK_RADIUS = 340 # app_icons style.INK_RADIUS +RADIUS_TOLERANCE = CANVAS * 0.01 +CENTRE_TOLERANCE = 2.0 +INK_ALPHA = 127 # app_icons fit._INK_ALPHA: above this is ink +_SIG = b"\x89PNG\r\n\x1a\n" +_INKED = bytes(1 if v > INK_ALPHA else 0 for v in range(256)) +_FIX = "regenerate it with ~/src/testsAndMisc/python_pkg/app_icons" + + +class PngError(ValueError): + """The PNG is not one this check can measure.""" + + +def _chunks(data: bytes) -> tuple[tuple[int, ...], bytes]: + """(IHDR fields, concatenated IDAT) of a PNG byte string.""" + if not data.startswith(_SIG): + raise PngError("is not a PNG") + pos, header, idat = len(_SIG), None, [] + while pos + 8 <= len(data): + (size,) = struct.unpack_from(">I", data, pos) + kind, body = data[pos + 4 : pos + 8], data[pos + 8 : pos + 8 + size] + pos += 12 + size + if kind == b"IHDR": + header = struct.unpack(">IIBBBBB", body) + elif kind == b"IDAT": + idat.append(body) + elif kind == b"IEND": + break + if header is None: + raise PngError("has no IHDR chunk") + return header, b"".join(idat) + + +def _paeth(a: int, b: int, c: int) -> int: + p = a + b - c + pa, pb, pc = abs(p - a), abs(p - b), abs(p - c) + if pa <= pb and pa <= pc: + return a + return b if pb <= pc else c + + +def _unfilter(kind: int, cur: bytes, prior: bytes) -> bytes: + """Undo one row's PNG filter on a single channel (neighbour = 1 byte).""" + if kind == 0: + return cur + if kind == 2: + return bytes((x + p) & 255 for x, p in zip(cur, prior)) + if kind not in (1, 3, 4): + raise PngError(f"has an invalid PNG filter type {kind}") + out = bytearray(cur) + for i in range(len(out)): + a = out[i - 1] if i else 0 + if kind == 1: + pred = a + elif kind == 3: + pred = (a + prior[i]) // 2 + else: + pred = _paeth(a, prior[i], prior[i - 1] if i else 0) + out[i] = (out[i] + pred) & 255 + return bytes(out) + + +def alpha_rows(data: bytes) -> list[bytes]: + """The alpha channel of a 1024x1024 8-bit RGBA PNG, one bytes per row.""" + (width, height, depth, colour, _, _, interlace), idat = _chunks(data) + if (width, height) != (CANVAS, CANVAS): + raise PngError(f"is {width}x{height}; the family canvas is {CANVAS}x{CANVAS}") + if depth != 8 or colour != 6 or interlace: + raise PngError( + "must be 8-bit RGBA, non-interlaced " + f"(bit depth {depth}, colour type {colour}, interlace {interlace})" + ) + try: + raw = zlib.decompress(idat) + except zlib.error as exc: + raise PngError(f"has corrupt image data: {exc}") from exc + stride = width * 4 + 1 + if len(raw) != stride * height: + raise PngError("has truncated image data") + rows, prior = [], bytes(width) + for y in range(height): + line = raw[y * stride : (y + 1) * stride] + prior = _unfilter(line[0], line[4::4], prior) + rows.append(prior) + return rows + + +def ink_geometry(path: Path) -> tuple[float, float, float]: + """(radius, cx, cy) exactly as app_icons `fit.ink_geometry` measures it. + + The furthest inked pixel of a row is always its first or last one, so + only those two are measured per row. + """ + spans = [] + for y, row in enumerate(alpha_rows(path.read_bytes())): + mask = row.translate(_INKED) + first = mask.find(1) + if first >= 0: + spans.append((y, first, mask.rfind(1))) + if not spans: + raise PngError(f"has no inked pixel (alpha > {INK_ALPHA})") + cx = (min(s[1] for s in spans) + max(s[2] for s in spans) + 1) / 2 + cy = (spans[0][0] + spans[-1][0] + 1) / 2 + radius = max( + math.hypot(x + 0.5 - cx, y + 0.5 - cy) for y, *ends in spans for x in ends + ) + return radius, cx, cy + + +def icon_problems(path: Path, rel: str) -> list[str]: + """Violations of the family ink circle for the foreground at `path`.""" + try: + radius, cx, cy = ink_geometry(path) + except PngError as exc: + return [f"{rel} {exc}"] + except OSError as exc: + return [f"{rel} is unreadable: {exc}"] + out = [] + if abs(radius - INK_RADIUS) > RADIUS_TOLERANCE: + out.append( + f"{rel}: ink radius {radius:.1f}px, the family circle is {INK_RADIUS}px" + f" ±{RADIUS_TOLERANCE:.1f}px: {_FIX}" + ) + off = math.hypot(cx - CANVAS / 2, cy - CANVAS / 2) + if off > CENTRE_TOLERANCE: + out.append( + f"{rel}: ink centre ({cx:.1f}, {cy:.1f}) is {off:.1f}px from" + f" ({CANVAS // 2}, {CANVAS // 2}); at most {CENTRE_TOLERANCE:.0f}px" + ) + return out diff --git a/repo_contract/templates/pre-commit-snippet.yaml b/repo_contract/templates/pre-commit-snippet.yaml index d788d8e..37b3534 100644 --- a/repo_contract/templates/pre-commit-snippet.yaml +++ b/repo_contract/templates/pre-commit-snippet.yaml @@ -1,9 +1,11 @@ # Add under `repos: - repo: local: hooks:` in a repo's .pre-commit-config.yaml, # after copying check_repo_contract.shim.sh to scripts/check_repo_contract.sh. # pass_filenames is false: the contract is about the repo, not the staged files. +# files: the Commands section, plus everything the Flutter launcher rules read +# (launcher.py). Existing repos pick up a change via repo_contract/hook_sync.py. - id: repo-contract name: repo contract (CLAUDE.md Commands + test_changed) entry: scripts/check_repo_contract.sh language: system pass_filenames: false - files: ^(CLAUDE\.md|AGENTS\.md|scripts/test_changed\.sh)$ + files: ^(CLAUDE\.md|AGENTS\.md|scripts/test_changed\.sh)$|(^|/)(AndroidManifest\.xml|build\.gradle(\.kts)?|strings\.xml|pubspec\.yaml|repo-contract\.toml|[^/]+\.desktop|assets/icon/[^/]+\.png)$ diff --git a/repo_contract/tests/flutter_app.py b/repo_contract/tests/flutter_app.py new file mode 100644 index 0000000..79ad124 --- /dev/null +++ b/repo_contract/tests/flutter_app.py @@ -0,0 +1,75 @@ +"""Builders for minimal Flutter app fixtures: pubspec, manifests, gradle.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +from .conftest import write + +PUBSPEC = "name: demo\ndependencies:\n flutter:\n sdk: flutter\n" +NS = 'xmlns:android="http://schemas.android.com/apk/res/android"' +TOOLS = 'xmlns:tools="http://schemas.android.com/tools"' +ACTIVITY = """ + + + + + +""" +GRADLE = """android { + defaultConfig { + applicationId = "com.kuhy.demo" + } +} +""" +SANDBOX_GRADLE = """android { + productFlavors { + create("daily") { dimension = "store" } + create("sandbox") { applicationIdSuffix = ".sandbox" } + } +} +""" +MAIN = Path("android/app/src/main/AndroidManifest.xml") +REMOVE = ' tools:node="remove"' + + +def manifest( + label: str | None = "Demo", + activity: str = ".MainActivity", + activity_label: str | None = None, +) -> str: + """The main AndroidManifest.xml with one LAUNCHER activity.""" + attr = "" if label is None else f' android:label="{label}"' + extra = "" if activity_label is None else f' android:label="{activity_label}"' + body = ACTIVITY.format(name=activity, main="", tag="", extra=extra) + return f"\n \n{body} \n\n" + + +def hide(activity: str = ".MainActivity", *, main: str = "", tag: str = REMOVE) -> str: + """A variant manifest that removes LAUNCHER (by default) from `activity`.""" + body = ACTIVITY.format(name=activity, main=main, tag=tag, extra="") + return f"\n \n{body} \n\n" + + +def flutter_app( + root: Path, + rel: str = ".", + *, + label: str | None = "Demo", + gradle: str | None = GRADLE, + kts: bool = True, +) -> Path: + """A minimal Android-enabled Flutter app at `root/rel`; returns its dir.""" + app = root / rel + write(app, "pubspec.yaml", PUBSPEC) + write(app, MAIN.as_posix(), manifest(label)) + if gradle is not None: + name = "build.gradle.kts" if kts else "build.gradle" + write(app, f"android/app/{name}", gradle) + return app + + +def track(repo: Path) -> None: + """Stage everything so `git ls-files` sees it.""" + subprocess.run(["git", "-C", str(repo), "add", "-A"], check=True) diff --git a/repo_contract/tests/pngkit.py b/repo_contract/tests/pngkit.py new file mode 100644 index 0000000..be83156 --- /dev/null +++ b/repo_contract/tests/pngkit.py @@ -0,0 +1,92 @@ +"""Tiny RGBA PNG encoder for icon fixtures (stdlib only, like the gate).""" + +from __future__ import annotations + +import math +import struct +import zlib +from collections.abc import Callable +from pathlib import Path + +SIG = b"\x89PNG\r\n\x1a\n" +SIZE = 1024 +INK = bytes((0xB8, 0x86, 0x2E, 0xFF)) +FAINT = bytes((0xB8, 0x86, 0x2E, 0x7F)) # alpha 127: fringe, not ink +CLEAR = bytes(4) + + +def disk_rows( + radius: float, cx: float = 512.0, cy: float = 512.0, size: int = SIZE +) -> list[bytes]: + """RGBA rows of a solid disk; pixel centres within `radius` are inked.""" + rows = [] + for y in range(size): + dy = y + 0.5 - cy + if abs(dy) >= radius: + rows.append(CLEAR * size) + continue + half = math.sqrt(radius * radius - dy * dy) + x0 = max(0, math.ceil(cx - half - 0.5)) + x1 = min(size, math.floor(cx + half - 0.5) + 1) + rows.append(CLEAR * x0 + INK * (x1 - x0) + CLEAR * (size - x1)) + return rows + + +def _paeth(a: int, b: int, c: int) -> int: + p = a + b - c + pa, pb, pc = abs(p - a), abs(p - b), abs(p - c) + if pa <= pb and pa <= pc: + return a + return b if pb <= pc else c + + +def _filter(kind: int, row: bytes, prior: bytes) -> bytes: + """Apply PNG filter `kind` (0-4, 4 bytes per pixel) to one raw row.""" + if kind == 0: + return row + out = bytearray(len(row)) + for i, value in enumerate(row): + a = row[i - 4] if i >= 4 else 0 + b = prior[i] + c = prior[i - 4] if i >= 4 else 0 + pred = (0, a, b, (a + b) // 2, _paeth(a, b, c))[kind] + out[i] = (value - pred) & 255 + return bytes(out) + + +def chunk(kind: bytes, body: bytes) -> bytes: + """One PNG chunk: length, type, body, CRC.""" + crc = struct.pack(">I", zlib.crc32(kind + body)) + return struct.pack(">I", len(body)) + kind + body + crc + + +def encode( + rows: list[bytes], + *, + filters: Callable[[int], int] | None = None, + size: int = SIZE, + depth: int = 8, + colour: int = 6, + interlace: int = 0, + iend: bool = True, +) -> bytes: + """PNG bytes for `rows`; `filters(y)` picks each row's filter type.""" + prior = bytes(len(rows[0])) + raw = bytearray() + for y, row in enumerate(rows): + kind = filters(y) if filters else 0 + raw.append(kind) + raw += _filter(kind, row, prior) + prior = row + ihdr = struct.pack(">IIBBBBB", size, size, depth, colour, 0, 0, interlace) + body = chunk(b"IHDR", ihdr) + chunk(b"IDAT", zlib.compress(bytes(raw), 1)) + return SIG + body + (chunk(b"IEND", b"") if iend else b"") + + +def write_icon( + path: Path, radius: float = 340, cx: float = 512, cy: float = 512 +) -> Path: + """Write a disk icon foreground to `path` (parents created).""" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(encode(disk_rows(radius, cx, cy))) + return path diff --git a/repo_contract/tests/test_gradle_blocks.py b/repo_contract/tests/test_gradle_blocks.py new file mode 100644 index 0000000..5f2cb37 --- /dev/null +++ b/repo_contract/tests/test_gradle_blocks.py @@ -0,0 +1,136 @@ +"""Brace-matched gradle blocks: suffix variants and manifest placeholders.""" + +from __future__ import annotations + +from repo_contract import gradle_blocks as gb + +KTS = """ +// a { comment } with braces +android { + namespace = "com.kuhy.demo" /* { block comment } */ + defaultConfig { + applicationId = "com.kuhy.demo" + manifestPlaceholders["appLabel"] = "Demo" + versionName = "${flutter.versionName}" + } + flavorDimensions += "store" + productFlavors { + create("daily") { + dimension = "store" + manifestPlaceholders["appLabel"] = + "Daily" + } + create("sandbox") { + applicationIdSuffix = ".sandbox" + manifestPlaceholders["appLabel"] = "Demo SANDBOX" + } + } + buildTypes { + release { + signingConfig = signingConfigs.getByName("debug") + } + getByName("debug") { + applicationIdSuffix = ".debug" + } + } +} +""" + +GROOVY = """ +android { + defaultConfig { + manifestPlaceholders = [appLabel: 'Demo', other: "x"] + applicationIdSuffix ".everything" + } + buildTypes { + debug { + applicationIdSuffix '.debug' + manifestPlaceholders.appLabel = "Debug" + } + profile { + manifestPlaceholders.put("appLabel", labelFor("profile")) + } + } + productFlavors { + beta { + manifestPlaceholders += mapOf("appLabel" to "Beta") + } + } +} +""" + + +def test_comments_dropped_strings_kept() -> None: + text = "a = \"// not a comment\" // gone\nb = '/* kept */' /* gone */ c" + assert gb.strip_comments(text) == "a = \"// not a comment\" \nb = '/* kept */' c" + + +def test_escaped_and_triple_quoted_strings() -> None: + text = 'x = "a\\"{" \ny = """{ "quoted" }""" // c\nz { }' + paths = [b.path for b in gb.blocks(text)] + assert ("z",) in paths and len(paths) == 2 + + +def test_block_paths() -> None: + paths = {b.path for b in gb.blocks(KTS)} + assert ("android", "productFlavors", "sandbox") in paths + assert ("android", "buildTypes", "debug") in paths + assert ("android", "buildTypes", "release") in paths + assert ("android", "defaultConfig") in paths + + +def test_braces_inside_strings_ignored() -> None: + blocks = gb.blocks('a { b = "${x}" }\n') + assert [b.path for b in blocks] == [("a",), ()] + + +def test_unbalanced_braces_tolerated() -> None: + assert [b.path for b in gb.blocks("}\na {\n b {\n")] == [ + ("a", "b"), + ("a",), + (), + ] + + +def test_unnamed_header() -> None: + assert gb.blocks("{ }")[0].path == ("?",) + + +def test_suffixed_variants_kts() -> None: + assert gb.suffixed_variants(KTS) == ["debug", "sandbox"] + + +def test_suffixed_variants_groovy_ignores_default_config() -> None: + assert gb.suffixed_variants(GROOVY) == ["debug"] + + +def test_placeholders_kts_every_block() -> None: + assert gb.placeholder_values(KTS, "appLabel") == [ + ("defaultConfig", "Demo"), + ("productFlavors.daily", "Daily"), + ("productFlavors.sandbox", "Demo SANDBOX"), + ] + + +def test_placeholders_groovy_forms() -> None: + assert gb.placeholder_values(GROOVY, "appLabel") == [ + ("defaultConfig", "Demo"), + ("buildTypes.debug", "Debug"), + ("buildTypes.profile", None), + ("productFlavors.beta", "Beta"), + ] + + +def test_placeholder_templates_and_variables_are_not_literals() -> None: + text = 'android {\n manifestPlaceholders["l"] = "A$b"\n}\nmanifestPlaceholders["l"] = name\n' + assert gb.placeholder_values(text, "l") == [("top level", None), ("android", None)] + + +def test_placeholder_unterminated_string_is_not_literal() -> None: + assert gb.placeholder_values('manifestPlaceholders["l"] = "abc', "l") == [ + ("top level", None) + ] + + +def test_placeholder_absent() -> None: + assert gb.placeholder_values(KTS, "missing") == [] diff --git a/repo_contract/tests/test_hook_sync.py b/repo_contract/tests/test_hook_sync.py new file mode 100644 index 0000000..bcd767c --- /dev/null +++ b/repo_contract/tests/test_hook_sync.py @@ -0,0 +1,118 @@ +"""The widened repo-contract hook `files:` and the sync that rolls it out.""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest +import yaml + +from repo_contract import hook_sync +from repo_contract.bootstrap import TEMPLATES + +from .conftest import write + +OLD = r" files: ^(CLAUDE\.md|AGENTS\.md|scripts/test_changed\.sh)$" +CONFIG = """repos: + - repo: local + hooks: + - id: repo-contract + name: repo contract + entry: scripts/check_repo_contract.sh + language: system + pass_filenames: false +{files} + - id: other + entry: x +""" + + +def template_regex() -> re.Pattern[str]: + hooks = yaml.safe_load((TEMPLATES / "pre-commit-snippet.yaml").read_text()) + return re.compile(hooks[0]["files"]) + + +@pytest.mark.parametrize( + "path", + [ + "CLAUDE.md", + "AGENTS.md", + "scripts/test_changed.sh", + "android/app/src/main/AndroidManifest.xml", + "app/android/app/src/sandbox/AndroidManifest.xml", + "android/app/build.gradle", + "android/app/build.gradle.kts", + "android/app/src/main/res/values/strings.xml", + "linux/todo.desktop", + "todo.desktop", + "assets/icon/icon_foreground.png", + "app/assets/icon/icon.png", + "repo-contract.toml", + "focus-owner/repo-contract.toml", + "pubspec.yaml", + ], +) +def test_template_fires_on_launcher_files(path: str) -> None: + assert template_regex().search(path) + + +@pytest.mark.parametrize( + "path", + ["lib/main.dart", "docs/CLAUDE.md", "assets/icon/sub/x.png", "x.desktop.bak"], +) +def test_template_ignores_other_files(path: str) -> None: + assert not template_regex().search(path) + + +def config(repo: Path, files: str = OLD) -> Path: + return write(repo, ".pre-commit-config.yaml", CONFIG.format(files=files)) + + +def test_status(tmp_path: Path) -> None: + assert hook_sync.status(tmp_path) == "no hook" + config(tmp_path) + assert hook_sync.status(tmp_path) == "stale" + config(tmp_path, hook_sync.template_line()) + assert hook_sync.status(tmp_path) == "current" + + +def test_dry_run_changes_nothing( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + before = config(tmp_path).read_text() + assert hook_sync.main([str(tmp_path)]) == 1 + assert (tmp_path / ".pre-commit-config.yaml").read_text() == before + out = capsys.readouterr().out + assert f"would update: {tmp_path}" in out + assert "1 stale, 0 updated, 0 current, 0 without the hook" in out + + +def test_apply_rewrites_only_the_files_line( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + path = config(tmp_path) + assert hook_sync.main(["--apply", str(tmp_path)]) == 0 + assert path.read_text() == CONFIG.format(files=hook_sync.template_line()) + assert f"updated: {tmp_path}" in capsys.readouterr().out + assert hook_sync.main(["--apply", str(tmp_path)]) == 0 + assert "0 stale, 0 updated, 1 current" in capsys.readouterr().out + + +def test_hook_without_files_line_gets_one(tmp_path: Path) -> None: + config(tmp_path, " always_run: false") + assert hook_sync.status(tmp_path) == "stale" + hook_sync.main(["--apply", str(tmp_path)]) + assert hook_sync.status(tmp_path) == "current" + + +def test_default_roots_are_the_sweep_roots( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + (tmp_path / "a" / ".git").mkdir(parents=True) + (tmp_path / "b" / ".git").mkdir(parents=True) + config(tmp_path / "a", hook_sync.template_line()) + assert hook_sync.main([], roots=[tmp_path]) == 0 + out = capsys.readouterr().out + assert f"current: {tmp_path / 'a'}" in out + assert f"no hook: {tmp_path / 'b'}" in out diff --git a/repo_contract/tests/test_label_widths.py b/repo_contract/tests/test_label_widths.py new file mode 100644 index 0000000..e6c662d --- /dev/null +++ b/repo_contract/tests/test_label_widths.py @@ -0,0 +1,68 @@ +"""Rule 1 width cap: the rendered name fits the Pixel app drawer.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from repo_contract import launcher +from repo_contract import launcher_label as ll +from repo_contract.label_widths import ADVANCE, MAX_WIDTH, label_width + +from .conftest import write +from .flutter_app import MAIN, flutter_app, manifest, track + +TOO_WIDE = "'FocusOwner' is 580 wide in the launcher font; at most 522 fit" + + +def test_table_covers_every_ascii_letter() -> None: + assert sorted(ADVANCE) == sorted( + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + ) + + +@pytest.mark.parametrize(("name", "width"), [("WakeAlarm", 522), ("FocusOwner", 580)]) +def test_label_width(name: str, width: int) -> None: + assert label_width(name) == width + + +@pytest.mark.parametrize("name", ["WakeAlarm", "Focuser", "Homer"]) +def test_fitting_names_pass(name: str) -> None: + assert label_width(name) <= MAX_WIDTH + assert ll.label_problems(name) == [] + + +def test_focusowner_is_too_wide() -> None: + assert ll.label_problems("FocusOwner") == [TOO_WIDE] + + +def test_width_sits_between_capital_and_length_rules() -> None: + assert ll.label_problems("wwwwwwwwwww") == [ + "'wwwwwwwwwww' must start with a capital letter", + "'wwwwwwwwwww' is 858 wide in the launcher font; at most 522 fit", + "'wwwwwwwwwww' has 11 characters; at most 10 fit", + ] + + +def test_width_skipped_for_non_letters() -> None: + got = ll.label_problems("Wide Wide W") + assert not any("wide in the launcher font" in p for p in got) + + +def test_app_label_too_wide(tmp_path: Path) -> None: + app = flutter_app(tmp_path, label="FocusOwner") + assert ll.problems(app) == [f"android:label {TOO_WIDE}"] + + +def test_activity_label_too_wide(tmp_path: Path) -> None: + app = flutter_app(tmp_path) + write(app, MAIN.as_posix(), manifest(activity_label="FocusOwner")) + assert ll.problems(app) == [f" android:label {TOO_WIDE}"] + + +def test_desktop_name_too_wide(repo: Path) -> None: + flutter_app(repo) + write(repo, "linux/app.desktop", "[Desktop Entry]\nName=FocusOwner\n") + track(repo) + assert launcher.problems(repo) == [f"launcher: linux/app.desktop: Name={TOO_WIDE}"] diff --git a/repo_contract/tests/test_launcher.py b/repo_contract/tests/test_launcher.py new file mode 100644 index 0000000..20cb120 --- /dev/null +++ b/repo_contract/tests/test_launcher.py @@ -0,0 +1,182 @@ +"""Which repos owe the launcher rules, rule 2 (.desktop), and rule 4 wiring.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from repo_contract import check, launcher + +from .conftest import good_repo, write +from .flutter_app import flutter_app, track +from .pngkit import write_icon + +ICON = "assets/icon/icon_foreground.png" +DESKTOP = "[Desktop Entry]\nType=Application\nName={name}\nName[pl]=Zadania pl\n" + + +def test_root_app_found(repo: Path) -> None: + flutter_app(repo) + track(repo) + assert launcher.flutter_apps(repo) == [repo] + assert launcher.problems(repo) == [] + + +def test_nested_app_found_and_prefixed(repo: Path) -> None: + flutter_app(repo, "tools/workout_app", label="workout_app") + track(repo) + got = launcher.problems(repo) + assert got[0].startswith( + "launcher [tools/workout_app]: android:label 'workout_app'" + ) + assert len(got) == 3 + + +def test_untracked_and_pruned_pubspecs_ignored(repo: Path) -> None: + flutter_app(repo, "build/stale", label="x") + flutter_app(repo, ".utils/vendored", label="x") + flutter_app(repo, "deleted", label="x") + track(repo) + (repo / "deleted/pubspec.yaml").unlink() + flutter_app(repo, "untracked", label="x") + assert launcher.flutter_apps(repo) == [] + + +def test_not_flutter_or_no_android_skipped(repo: Path) -> None: + write(repo, "dart_pkg/pubspec.yaml", "name: p\ndependencies:\n path: 1.9.0\n") + write(repo, "dart_pkg/android/x", "") + write( + repo, + "web_only/pubspec.yaml", + "name: w\ndependencies:\n flutter:\n sdk: flutter\n", + ) + write( + repo, + "dev_only/pubspec.yaml", + "dev_dependencies:\n flutter:\n sdk: flutter\n", + ) + write(repo, "dev_only/android/x", "") + track(repo) + assert launcher.flutter_apps(repo) == [] + + +def test_pubspec_comments_do_not_hide_an_app(repo: Path) -> None: + body = "dependencies: # exact pins\n# column-0 note\n\n flutter:\n sdk: flutter # sdk\n" + write(repo, "a/pubspec.yaml", body) + write(repo, "a/android/x", "") + track(repo) + assert launcher.flutter_apps(repo) == [repo / "a"] + + +def test_not_a_git_repo_checks_root_only(tmp_path: Path) -> None: + flutter_app(tmp_path, label="demo") + flutter_app(tmp_path, "nested", label="demo") + assert launcher.flutter_apps(tmp_path) == [tmp_path] + + +def test_no_flutter_app_no_rules(repo: Path) -> None: + write(repo, "x.desktop", DESKTOP.format(name="bad name")) + track(repo) + assert launcher.problems(repo) == [] + + +def test_desktop_names_checked(repo: Path) -> None: + flutter_app(repo) + write(repo, "linux/good.desktop", DESKTOP.format(name="Todo")) + write(repo, "linux/bad.desktop", DESKTOP.format(name="todo app")) + write( + repo, + "linux/action.desktop", + DESKTOP.format(name="Todo") + "[Desktop Action new]\nName=New window\n", + ) + write(repo, "linux/none.desktop", "[Desktop Entry]\nType=Application\n") + track(repo) + write(repo, "linux/untracked.desktop", DESKTOP.format(name="bad name")) + assert launcher.problems(repo) == [ + ( + "launcher: linux/bad.desktop: Name='todo app' must contain only ASCII" + " letters (no spaces, _ or symbols)" + ), + "launcher: linux/bad.desktop: Name='todo app' must start with a capital letter", + "launcher: linux/none.desktop: no Name= in [Desktop Entry]", + ] + + +def test_unreadable_desktop_file(repo: Path) -> None: + flutter_app(repo) + write(repo, "a.desktop", "x") + track(repo) + (repo / "a.desktop").write_bytes(b"\xff\xfe") + assert launcher.problems(repo) == ["launcher: a.desktop: unreadable"] + + +def test_icon_checked_when_present(repo: Path) -> None: + app = flutter_app(repo) + write_icon(app / ICON, 250) + track(repo) + got = launcher.problems(repo) + assert len(got) == 1 and got[0].startswith(f"launcher: {ICON}: ink radius 2") + + +def test_no_icon_no_icon_rule(repo: Path) -> None: + flutter_app(repo) + track(repo) + assert launcher.problems(repo) == [] + + +def test_icon_skip_with_reason(repo: Path) -> None: + app = flutter_app(repo, "focus-owner") + write_icon(app / ICON, 250) + write(app, "repo-contract.toml", '[launcher_icon]\nskip = "vector drawables"\n') + track(repo) + assert launcher.problems(repo) == [] + + +@pytest.mark.parametrize( + ("body", "message"), + [ + ( + '[launcher_icon]\nskip = ""\n', + '[launcher_icon] skip = "" needs a non-empty reason', + ), + ( + "[launcher_icon]\nskip = true\n", + '[launcher_icon] skip = "" needs a non-empty reason', + ), + ( + "[launcher_icon]\n", + '[launcher_icon] skip = "" needs a non-empty reason', + ), + ( + '[launcher_icon]\nskip = "x"\nwhy = 1\n', + "unknown key(s) in [launcher_icon]: why", + ), + ("launcher_icon = 1\n", "'launcher_icon' must be a table: [launcher_icon]"), + ("[launcher_icon\n", "not valid TOML"), + ], +) +def test_icon_skip_malformed(repo: Path, body: str, message: str) -> None: + app = flutter_app(repo) + write_icon(app / ICON, 250) + write(app, "repo-contract.toml", body) + track(repo) + got = launcher.problems(repo) + assert len(got) == 1 + assert got[0].startswith(f"launcher: repo-contract.toml: {message}") + + +def test_other_tables_do_not_skip(repo: Path) -> None: + app = flutter_app(repo) + write_icon(app / ICON, 250) + write(app, "repo-contract.toml", '[agent_ready]\nskip = "not a site"\n') + track(repo) + assert len(launcher.problems(repo)) == 1 + + +def test_check_reports_launcher_problems(repo: Path) -> None: + good_repo(repo) + flutter_app(repo, label="demo") + track(repo) + found, _ = check.violations(repo) + assert found == ["launcher: android:label 'demo' must start with a capital letter"] diff --git a/repo_contract/tests/test_launcher_label.py b/repo_contract/tests/test_launcher_label.py new file mode 100644 index 0000000..0c39662 --- /dev/null +++ b/repo_contract/tests/test_launcher_label.py @@ -0,0 +1,179 @@ +"""Rule 1: the resolved android:label is a short capitalised ASCII word.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from repo_contract import launcher_label as ll + +from .conftest import write +from .flutter_app import MAIN, flutter_app, manifest + +STRINGS = Path("android/app/src/main/res/values/strings.xml") + + +@pytest.mark.parametrize( + ("name", "expected"), + [ + ("PunchMe", []), + ("DailyLimit", []), + ("", ["label is empty"]), + ("punchme", ["'punchme' must start with a capital letter"]), + ( + "Umbrella?", + ["'Umbrella?' must contain only ASCII letters (no spaces, _ or symbols)"], + ), + ( + "focus_owner", + [ + "'focus_owner' must contain only ASCII letters (no spaces, _ or symbols)", + "'focus_owner' must start with a capital letter", + "'focus_owner' has 11 characters; at most 10 fit", + ], + ), + ( + "Zażółć", + ["'Zażółć' must contain only ASCII letters (no spaces, _ or symbols)"], + ), + ], +) +def test_label_problems_mirror_app_icons(name: str, expected: list[str]) -> None: + assert ll.label_problems(name) == expected + + +def test_literal_label(tmp_path: Path) -> None: + assert ll.problems(flutter_app(tmp_path, label="Demo")) == [] + assert ll.problems(flutter_app(tmp_path, label="demo")) == [ + "android:label 'demo' must start with a capital letter" + ] + + +def test_string_resource(tmp_path: Path) -> None: + app = flutter_app(tmp_path, label="@string/app_name") + write( + app, + STRINGS.as_posix(), + 'My App', + ) + assert ll.problems(app) == [ + ( + "android:label @string/app_name = 'My App' must contain only ASCII letters" + " (no spaces, _ or symbols)" + ) + ] + + +def test_string_resource_missing(tmp_path: Path) -> None: + app = flutter_app(tmp_path, label="@string/app_name") + assert ll.problems(app) == [ + ( + f"android:label @string/app_name: {STRINGS} is missing or unreadable;" + " cannot resolve the launcher name" + ) + ] + write( + app, + STRINGS.as_posix(), + 'X', + ) + assert ll.problems(app) == [ + ( + f'android:label @string/app_name: no in {STRINGS};' + " cannot resolve the launcher name" + ) + ] + + +def test_placeholder_every_assignment_checked(tmp_path: Path) -> None: + gradle = """android { + defaultConfig { manifestPlaceholders["appLabel"] = "Workout" } + productFlavors { + create("daily") { manifestPlaceholders["appLabel"] = "workout_app" } + create("sandbox") { manifestPlaceholders["appLabel"] = "Workout SANDBOX" } + } +} +""" + app = flutter_app(tmp_path, label="${appLabel}", gradle=gradle) + got = ll.problems(app) + assert got[0] == ( + "android:label ${appLabel} in productFlavors.daily = 'workout_app'" + " must contain only ASCII letters (no spaces, _ or symbols)" + ) + assert len(got) == 5 + assert all("defaultConfig" not in p for p in got) + + +def test_placeholder_groovy_passes(tmp_path: Path) -> None: + gradle = "android { defaultConfig { manifestPlaceholders = [appLabel: 'Demo'] } }\n" + app = flutter_app(tmp_path, label="${appLabel}", gradle=gradle, kts=False) + assert ll.problems(app) == [] + + +def test_placeholder_not_literal(tmp_path: Path) -> None: + gradle = 'android { defaultConfig { manifestPlaceholders["appLabel"] = name } }\n' + app = flutter_app(tmp_path, label="${appLabel}", gradle=gradle) + assert ll.problems(app) == [ + ( + "android:label ${appLabel}: the value set in defaultConfig is not a string" + " literal; cannot resolve the launcher name" + ) + ] + + +def test_placeholder_unassigned(tmp_path: Path) -> None: + app = flutter_app(tmp_path, label="${appLabel}") + assert ll.problems(app) == [ + ( + 'android:label ${appLabel}: no manifestPlaceholders["appLabel"] assignment' + " in android/app/build.gradle.kts; cannot resolve the launcher name" + ) + ] + + +def test_placeholder_without_gradle_file(tmp_path: Path) -> None: + app = flutter_app(tmp_path, label="${appLabel}", gradle=None) + assert ll.problems(app) == [ + ( + "android:label ${appLabel}: no android/app/build.gradle(.kts);" + " cannot resolve the launcher name" + ) + ] + + +@pytest.mark.parametrize( + "label", ["Demo ${suffix}", "@string/a/b", "@android:string/x"] +) +def test_unresolvable_forms(tmp_path: Path, label: str) -> None: + assert ll.problems(flutter_app(tmp_path, label=label)) == [ + ( + f"android:label {label!r} cannot be resolved statically:" + " use a literal, @string/ or ${}" + ) + ] + + +def test_no_label(tmp_path: Path) -> None: + assert ll.problems(flutter_app(tmp_path, label=None)) == [ + f"{MAIN}: has no android:label; cannot resolve the launcher name" + ] + + +def test_missing_or_broken_manifest(tmp_path: Path) -> None: + app = flutter_app(tmp_path) + (app / MAIN).unlink() + assert ll.problems(app) == [ + f"{MAIN} is missing or unreadable; cannot resolve the launcher name" + ] + write(app, MAIN.as_posix(), manifest()[:-20]) + got = ll.problems(app) + assert len(got) == 1 and got[0].startswith(f"{MAIN} is not valid XML:") + + +def test_manifest_without_application(tmp_path: Path) -> None: + app = flutter_app(tmp_path) + write(app, MAIN.as_posix(), "") + assert ll.problems(app) == [ + f"{MAIN}: has no android:label; cannot resolve the launcher name" + ] diff --git a/repo_contract/tests/test_launcher_label_scope.py b/repo_contract/tests/test_launcher_label_scope.py new file mode 100644 index 0000000..1854619 --- /dev/null +++ b/repo_contract/tests/test_launcher_label_scope.py @@ -0,0 +1,137 @@ +"""Rule 1 scope: suffixed variants are exempt; launcher activity labels count.""" + +from __future__ import annotations + +from pathlib import Path + +from repo_contract import launcher_label as ll + +from .conftest import write +from .flutter_app import MAIN, NS, flutter_app, manifest + +WORKOUT = """android { + defaultConfig { manifestPlaceholders["appLabel"] = "Workout" } + productFlavors { + create("daily") { manifestPlaceholders["appLabel"] = "Workout" } + create("sandbox") { + applicationIdSuffix = ".sandbox" + manifestPlaceholders["appLabel"] = "Workout SANDBOX" + } + } +} +""" +STRINGS = "android/app/src/main/res/values/strings.xml" + + +def test_suffixed_flavor_label_is_exempt(tmp_path: Path) -> None: + """screen-locker's workout_app: the hidden sandbox may say SANDBOX.""" + app = flutter_app(tmp_path, label="${appLabel}", gradle=WORKOUT) + assert ll.problems(app) == [] + + +def test_unsuffixed_flavor_still_checked(tmp_path: Path) -> None: + gradle = WORKOUT.replace( + 'create("daily") { manifestPlaceholders["appLabel"] = "Workout" }', + 'create("daily") { manifestPlaceholders["appLabel"] = "workout" }', + ) + app = flutter_app(tmp_path, label="${appLabel}", gradle=gradle) + assert ll.problems(app) == [ + ( + "android:label ${appLabel} in productFlavors.daily = 'workout'" + " must start with a capital letter" + ) + ] + + +def test_suffixed_buildtype_label_is_exempt(tmp_path: Path) -> None: + gradle = """android { + defaultConfig { manifestPlaceholders["appLabel"] = "Demo" } + buildTypes { debug { applicationIdSuffix ".debug"; manifestPlaceholders["appLabel"] = "Demo dbg" } } +} +""" + app = flutter_app(tmp_path, label="${appLabel}", gradle=gradle, kts=False) + assert ll.problems(app) == [] + + +def test_only_suffixed_assignments_is_unresolvable(tmp_path: Path) -> None: + gradle = """android { productFlavors { create("sandbox") { + applicationIdSuffix = ".sandbox" + manifestPlaceholders["appLabel"] = "Sandbox" +} } } +""" + app = flutter_app(tmp_path, label="${appLabel}", gradle=gradle) + assert ll.problems(app) == [ + ( + 'android:label ${appLabel}: no manifestPlaceholders["appLabel"] assignment' + " for an unsuffixed variant in android/app/build.gradle.kts;" + " cannot resolve the launcher name" + ) + ] + + +def test_launcher_activity_label_checked(tmp_path: Path) -> None: + app = flutter_app(tmp_path) + write(app, MAIN.as_posix(), manifest(activity_label="punch me")) + assert ll.problems(app) == [ + ( + " android:label 'punch me' must contain only ASCII" + " letters (no spaces, _ or symbols)" + ), + ( + " android:label 'punch me' must start with a" + " capital letter" + ), + ] + + +def test_launcher_activity_label_good(tmp_path: Path) -> None: + app = flutter_app(tmp_path) + write(app, MAIN.as_posix(), manifest(activity_label="PunchMe")) + assert ll.problems(app) == [] + + +def test_launcher_activity_label_resolved_like_the_app_label(tmp_path: Path) -> None: + app = flutter_app(tmp_path) + write(app, MAIN.as_posix(), manifest(activity_label="@string/launcher")) + write( + app, STRINGS, 'My App' + ) + got = ll.problems(app) + assert got[0].startswith( + " android:label @string/launcher = 'My App'" + ) + + +def test_activity_label_also_checked_without_app_label_issue(tmp_path: Path) -> None: + app = flutter_app(tmp_path) + write(app, MAIN.as_posix(), manifest(label="demo", activity_label="Demo")) + assert ll.problems(app) == ["android:label 'demo' must start with a capital letter"] + + +def test_non_launcher_activity_label_ignored(tmp_path: Path) -> None: + app = flutter_app(tmp_path) + body = manifest().replace( + " ", + ' \n' + ' \n' + " \n" + ' \n' + " \n" + " \n" + " ", + ) + write(app, MAIN.as_posix(), body) + assert ll.problems(app) == [] + + +def test_activity_alias_label_checked(tmp_path: Path) -> None: + app = flutter_app(tmp_path) + body = f""" + + + +""" + write(app, MAIN.as_posix(), body) + assert ll.problems(app) == [ + " android:label 'alias' must start with a capital letter" + ] diff --git a/repo_contract/tests/test_launcher_variants.py b/repo_contract/tests/test_launcher_variants.py new file mode 100644 index 0000000..5197f8f --- /dev/null +++ b/repo_contract/tests/test_launcher_variants.py @@ -0,0 +1,133 @@ +"""Rule 3: an applicationIdSuffix variant hides LAUNCHER but keeps MAIN.""" + +from __future__ import annotations + +from pathlib import Path + +from repo_contract import android_manifest as am + +from .conftest import write +from .flutter_app import MAIN, NS, REMOVE, SANDBOX_GRADLE, flutter_app, hide, manifest + +SANDBOX = "android/app/src/sandbox/AndroidManifest.xml" + + +def sandbox_app(root: Path) -> Path: + return flutter_app(root, gradle=SANDBOX_GRADLE) + + +def test_no_suffix_variant_passes(tmp_path: Path) -> None: + assert am.variant_problems(flutter_app(tmp_path)) == [] + + +def test_no_gradle_passes(tmp_path: Path) -> None: + assert am.variant_problems(flutter_app(tmp_path, gradle=None)) == [] + + +def test_hidden_variant_passes(tmp_path: Path) -> None: + app = sandbox_app(tmp_path) + write(app, SANDBOX, hide()) + assert am.variant_problems(app) == [] + + +def test_fully_qualified_activity_name_matches(tmp_path: Path) -> None: + app = sandbox_app(tmp_path) + write(app, SANDBOX, hide("com.kuhy.demo.MainActivity")) + assert am.variant_problems(app) == [] + + +def test_missing_variant_manifest_fails(tmp_path: Path) -> None: + got = am.variant_problems(sandbox_app(tmp_path)) + assert got == [ + ( + f"variant 'sandbox' sets applicationIdSuffix but {SANDBOX} is missing:" + ' hide it with in .MainActivity\'s intent-filter' + ) + ] + + +def test_variant_manifest_without_remove_fails(tmp_path: Path) -> None: + app = sandbox_app(tmp_path) + write(app, SANDBOX, hide(tag="")) + assert am.variant_problems(app) == [ + ( + f"variant 'sandbox': {SANDBOX} does not remove the LAUNCHER category from" + ' .MainActivity (tools:node="remove" on the )' + ) + ] + + +def test_remove_on_other_activity_fails(tmp_path: Path) -> None: + app = sandbox_app(tmp_path) + write(app, SANDBOX, hide(".OtherActivity")) + assert len(am.variant_problems(app)) == 1 + + +def test_removing_main_fails(tmp_path: Path) -> None: + app = sandbox_app(tmp_path) + write(app, SANDBOX, hide(main=REMOVE)) + assert am.variant_problems(app) == [ + ( + f"variant 'sandbox': {SANDBOX} removes android.intent.action.MAIN from" + " .MainActivity; keep MAIN so `am start -n` still works" + ) + ] + + +def test_removing_whole_activity_fails(tmp_path: Path) -> None: + app = sandbox_app(tmp_path) + body = hide().replace( + '', + '', + ) + write(app, SANDBOX, body) + assert "removes android.intent.action.MAIN" in am.variant_problems(app)[0] + + +def test_removing_main_intent_filter_fails(tmp_path: Path) -> None: + app = sandbox_app(tmp_path) + write( + app, + SANDBOX, + hide(tag="").replace("", f""), + ) + assert "removes android.intent.action.MAIN" in am.variant_problems(app)[0] + + +def test_invalid_variant_manifest_fails(tmp_path: Path) -> None: + app = sandbox_app(tmp_path) + write(app, SANDBOX, hide().replace(f" {am.TOOLS_DECL}", "")) + got = am.variant_problems(app) + assert len(got) == 1 and got[0].startswith(f"{SANDBOX} is not valid XML:") + + +def test_main_manifest_without_launcher_needs_nothing(tmp_path: Path) -> None: + app = sandbox_app(tmp_path) + write(app, MAIN.as_posix(), f"") + assert am.variant_problems(app) == [] + + +def test_unreadable_main_manifest_needs_nothing(tmp_path: Path) -> None: + app = sandbox_app(tmp_path) + write(app, MAIN.as_posix(), " None: + app = sandbox_app(tmp_path) + body = ( + manifest() + .replace("", "") + ) + write(app, MAIN.as_posix(), body) + got = am.variant_problems(app) + assert len(got) == 1 and "is missing" in got[0] + + +def test_groovy_buildtype_suffix(tmp_path: Path) -> None: + gradle = "android { buildTypes { debug { applicationIdSuffix '.debug' } } }\n" + app = flutter_app(tmp_path, gradle=gradle, kts=False) + write(app, "android/app/src/debug/AndroidManifest.xml", hide()) + assert am.variant_problems(app) == [] diff --git a/repo_contract/tests/test_png_ink.py b/repo_contract/tests/test_png_ink.py new file mode 100644 index 0000000..fc4ab2c --- /dev/null +++ b/repo_contract/tests/test_png_ink.py @@ -0,0 +1,130 @@ +"""The stdlib PNG reader and the family ink-circle rule (rule 4).""" + +from __future__ import annotations + +import struct +import zlib +from pathlib import Path + +import pytest + +from repo_contract import png_ink + +from .pngkit import CLEAR, FAINT, SIG, SIZE, chunk, disk_rows, encode, write_icon + + +def geometry(tmp_path: Path, data: bytes) -> tuple[float, float, float]: + path = tmp_path / "icon.png" + path.write_bytes(data) + return png_ink.ink_geometry(path) + + +def test_family_disk_measures_340_centred(tmp_path: Path) -> None: + radius, cx, cy = png_ink.ink_geometry(write_icon(tmp_path / "i.png")) + assert (cx, cy) == (512, 512) + assert 338 < radius <= 340 + + +def test_family_icon_passes(tmp_path: Path) -> None: + assert png_ink.icon_problems(write_icon(tmp_path / "i.png"), "i.png") == [] + + +@pytest.mark.parametrize("radius", [380, 300]) +def test_too_big_or_too_small_fails(tmp_path: Path, radius: int) -> None: + got = png_ink.icon_problems(write_icon(tmp_path / "i.png", radius), "i.png") + assert len(got) == 1 + assert got[0].startswith("i.png: ink radius ") + assert "340px" in got[0] and "app_icons" in got[0] + + +def test_within_one_percent_passes(tmp_path: Path) -> None: + assert png_ink.icon_problems(write_icon(tmp_path / "i.png", 349), "i.png") == [] + + +def test_off_centre_fails(tmp_path: Path) -> None: + path = write_icon(tmp_path / "i.png", 340, cx=520) + got = png_ink.icon_problems(path, "i.png") + assert got == [ + "i.png: ink centre (520.0, 512.0) is 8.0px from (512, 512); at most 2px" + ] + + +def test_two_px_off_centre_passes(tmp_path: Path) -> None: + path = write_icon(tmp_path / "i.png", 340, cx=514) + assert png_ink.icon_problems(path, "i.png") == [] + + +def test_every_filter_type_decodes_identically(tmp_path: Path) -> None: + rows = disk_rows(100, 500, 520) + plain = geometry(tmp_path, encode(rows)) + mixed = encode(rows, filters=lambda y: y % 5 if 400 <= y < 640 else 0) + assert geometry(tmp_path, mixed) == plain + + +def test_faint_fringe_is_not_ink(tmp_path: Path) -> None: + rows = disk_rows(100) + rows[0] = FAINT * SIZE + assert geometry(tmp_path, encode(rows)) == geometry( + tmp_path, encode(disk_rows(100)) + ) + + +def test_missing_iend_still_decodes(tmp_path: Path) -> None: + assert geometry(tmp_path, encode(disk_rows(50), iend=False))[0] > 0 + + +def test_ancillary_chunks_skipped(tmp_path: Path) -> None: + data = encode(disk_rows(50)) + with_text = data[:33] + chunk(b"tEXt", b"k\0v") + data[33:] + assert geometry(tmp_path, with_text) == geometry(tmp_path, data) + + +def _ihdr_only(ihdr: bytes) -> bytes: + return SIG + chunk(b"IHDR", ihdr) + chunk(b"IEND", b"") + + +def _header(size: int = SIZE, depth: int = 8, colour: int = 6, il: int = 0) -> bytes: + return struct.pack(">IIBBBBB", size, size, depth, colour, 0, 0, il) + + +@pytest.mark.parametrize( + ("data", "message"), + [ + (b"GIF89a", "is not a PNG"), + (SIG + chunk(b"IEND", b""), "has no IHDR chunk"), + (_ihdr_only(_header(size=512)), "is 512x512; the family canvas is 1024x1024"), + (_ihdr_only(_header(colour=2)), "must be 8-bit RGBA, non-interlaced"), + (_ihdr_only(_header(depth=16)), "must be 8-bit RGBA, non-interlaced"), + (_ihdr_only(_header(il=1)), "must be 8-bit RGBA, non-interlaced"), + ( + SIG + chunk(b"IHDR", _header()) + chunk(b"IDAT", b"not zlib"), + "has corrupt image data", + ), + ( + SIG + chunk(b"IHDR", _header()) + chunk(b"IDAT", zlib.compress(b"\0" * 9)), + "has truncated image data", + ), + (encode([CLEAR * SIZE] * SIZE), "has no inked pixel (alpha > 127)"), + ], +) +def test_undecodable_fails_closed(tmp_path: Path, data: bytes, message: str) -> None: + path = tmp_path / "icon.png" + path.write_bytes(data) + got = png_ink.icon_problems(path, "icon.png") + assert len(got) == 1 + assert got[0].startswith(f"icon.png {message}") + + +def test_bad_filter_type_fails_closed(tmp_path: Path) -> None: + raw = (b"\x07" + CLEAR * SIZE) * SIZE + data = SIG + chunk(b"IHDR", _header()) + chunk(b"IDAT", zlib.compress(raw)) + path = tmp_path / "icon.png" + path.write_bytes(data) + assert png_ink.icon_problems(path, "icon.png") == [ + "icon.png has an invalid PNG filter type 7" + ] + + +def test_unreadable_file_fails_closed(tmp_path: Path) -> None: + got = png_ink.icon_problems(tmp_path / "missing.png", "missing.png") + assert len(got) == 1 and got[0].startswith("missing.png is unreadable:")