repo_contract: launcher gate (icon ink radius, compliant label, hidden debug variants) + hook_sync
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run

Flutter repos now fail the repo contract when a launcher icon's ink is not on
the shared 340px circle (png_ink), the launcher label breaks the naming rule
(capital, ASCII letters, <= 10 chars, <= 522 wide in GoogleSansFlex), or an
applicationIdSuffix variant still carries the LAUNCHER category. hook_sync
rewrites a repo's stale repo-contract `files:` line to the widened template.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017k9j5dbjenpfZj7dh2faPN
This commit is contained in:
Krzysztof Rudnicki 2026-10-23 14:54:15 +02:00
parent 20b2cdbc04
commit d1ca1e3d5b
21 changed files with 2212 additions and 5 deletions

View File

@ -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

View File

@ -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 [<app dir>]:` for a nested app).
1. **Launcher name** (`launcher_label.py`): `<application android:label>`
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/<name>` 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/<variant>/AndroidManifest.xml` removing the LAUNCHER
category (`tools:node="remove"` on the `<category>`) 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 = "<reason>"` 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

View File

@ -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:
<activity android:name=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"
tools:node="remove"/>
</intent-filter>
</activity>
"""
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 <category>)'
)
]
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 <category android:name="{LAUNCHER}"'
f' tools:node="remove"/> 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

View File

@ -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]]:

View File

@ -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"""(?<!\w)(['"]?)""" + k + r"\1\s*(?:to\b|:)\s*")
for m in _MAP.finditer(body):
found += [
(m.start(), _literal(m.group(1)[p.end() :]))
for p in pair.finditer(m.group(1))
]
return found
def _first(item: tuple[int, str | None]) -> 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

View File

@ -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())

View File

@ -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)

157
repo_contract/launcher.py Normal file
View File

@ -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 = "<why this app cannot ship the PNG foreground>"
"""
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 = "<reason>" 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)]

View File

@ -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 `<application android:label>` 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/<name>` (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 <string name="{name}"> 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/<name> or ${<placeholder>}"
)
]
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}: <application> 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"<activity {name}> android:label", own)
return out

154
repo_contract/png_ink.py Normal file
View File

@ -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

View File

@ -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)$

View File

@ -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 = """ <activity android:name="{name}"{extra}>
<intent-filter>
<action android:name="android.intent.action.MAIN"{main}/>
<category android:name="android.intent.category.LAUNCHER"{tag}/>
</intent-filter>
</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"<manifest {NS}>\n <application{attr}>\n{body} </application>\n</manifest>\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"<manifest {NS} {TOOLS}>\n <application>\n{body} </application>\n</manifest>\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)

View File

@ -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

View File

@ -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") == []

View File

@ -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

View File

@ -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"<activity .MainActivity> 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}"]

View File

@ -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 = "<reason>" needs a non-empty reason',
),
(
"[launcher_icon]\nskip = true\n",
'[launcher_icon] skip = "<reason>" needs a non-empty reason',
),
(
"[launcher_icon]\n",
'[launcher_icon] skip = "<reason>" 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"]

View File

@ -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(),
'<resources><string name="app_name">My App</string></resources>',
)
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(),
'<resources><string name="other">X</string></resources>',
)
assert ll.problems(app) == [
(
f'android:label @string/app_name: no <string name="app_name"> 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/<name> or ${<placeholder>}"
)
]
def test_no_label(tmp_path: Path) -> None:
assert ll.problems(flutter_app(tmp_path, label=None)) == [
f"{MAIN}: <application> 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(), "<manifest/>")
assert ll.problems(app) == [
f"{MAIN}: <application> has no android:label; cannot resolve the launcher name"
]

View File

@ -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) == [
(
"<activity .MainActivity> android:label 'punch me' must contain only ASCII"
" letters (no spaces, _ or symbols)"
),
(
"<activity .MainActivity> 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, '<resources><string name="launcher">My App</string></resources>'
)
got = ll.problems(app)
assert got[0].startswith(
"<activity .MainActivity> 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(
" </application>",
' <activity android:name=".Settings" android:label="settings page"/>\n'
' <activity android:name=".Share" android:label="share it">\n'
" <intent-filter>\n"
' <category android:name="android.intent.category.LAUNCHER"/>\n'
" </intent-filter>\n"
" </activity>\n"
" </application>",
)
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"""<manifest {NS}><application android:label="Demo">
<activity-alias android:name=".Alias" android:label="alias">
<intent-filter><action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/></intent-filter>
</activity-alias></application></manifest>"""
write(app, MAIN.as_posix(), body)
assert ll.problems(app) == [
"<activity .Alias> android:label 'alias' must start with a capital letter"
]

View File

@ -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 <category android:name="android.intent.category.LAUNCHER"'
' tools:node="remove"/> 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 <category>)'
)
]
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(
'<activity android:name=".MainActivity">',
'<activity android:name=".MainActivity" tools:node="remove">',
)
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("<intent-filter>", f"<intent-filter{REMOVE}>"),
)
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"<manifest {NS}><application/></manifest>")
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(), "<manifest")
assert am.variant_problems(app) == []
def test_activity_alias_is_a_launcher_too(tmp_path: Path) -> None:
app = sandbox_app(tmp_path)
body = (
manifest()
.replace("<activity ", "<activity-alias ")
.replace("</activity>", "</activity-alias>")
)
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) == []

View File

@ -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:")