utils/repo_contract/gradle_blocks.py
Krzysztof kuhy Rudnicki d1ca1e3d5b
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
repo_contract: launcher gate (icon ink radius, compliant label, hidden debug variants) + hook_sync
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
2026-10-23 14:54:15 +02:00

185 lines
5.9 KiB
Python

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