#!/usr/bin/env python3 """Flag capitalised identifiers that are neither imported nor declared locally. This exists because ktlint and detekt are both structurally blind to it: they parse Kotlin without resolving symbols, so a *missing import* is invisible to them and both pass a file that cannot compile. The first sync-screen push failed in CI on exactly that (`Unresolved reference 'Build'` — `android.os.Build` was lost in a file split), after a clean local analyzer run. Not a type checker and not trying to be. `compileDebugKotlin` in CI is the real one; this is a cheap pre-push filter for the single mistake that survives every other local gate. It errs toward false positives — anything it cannot account for is reported rather than assumed fine. It also checks MEMBERS of this package's own `object` declarations — `Foo.bar()` where `Foo` is an object declared here. That case was added after moving a function between two objects and forgetting to paste it into the second: the call site read `Other.thing()`, resolved fine as far as the leading token, and failed in CI (785ebdb). Still NOT caught, so a clean run is not over-read: members of anything declared outside this package, members reached through a variable rather than a type name, and every question about types. Those are what `compileDebugKotlin` is for. python3 android/tools/check-symbols.py [source-root] Exits non-zero when something is unaccounted for. """ import collections import os import re import sys DEFAULT_ROOT = os.path.join( os.path.dirname(os.path.abspath(__file__)), "..", "app", "src", "main", "java" ) # Available without an import: kotlin.* and kotlin.collections.*, plus # java.lang.* which Kotlin/JVM also imports by default. IMPLICIT = set( """ String Int Long Short Byte Boolean Char Float Double Unit Any Nothing Number UInt ULong UShort UByte Array List Set Map MutableList MutableSet MutableMap Collection Iterable Iterator Sequence Pair Triple Comparable Comparator Throwable Exception RuntimeException IllegalArgumentException IllegalStateException Error Result Regex StringBuilder CharSequence Enum Annotation Function Deprecated Suppress OptIn JvmStatic JvmField JvmName JvmOverloads Volatile Synchronized Throws Target Retention Repeatable MustBeDocumented System Math Object Class Thread Runnable Void Integer Character StringBuffer """.split() ) # `R` is generated at build time and never imported from the app's own package. GENERATED = {"R"} DECL = re.compile( r"^\s*(?:@\w+\s+)*(?:public |private |internal |protected )?" r"(?:expect |actual |external |abstract |final |open |sealed |data |value |" r"inline |enum |annotation |fun |companion |const |lateinit )*" r"(?:class|interface|object|typealias|fun|val|var)\s+" r"(?:<[^>]*>\s*)?([A-Za-z_]\w*)", re.M, ) def strip(src: str) -> str: """Blank out comments and string literals. Order matters: raw strings before block comments, and line comments must NOT use DOTALL — `//.*` with re.S eats from the first comment to end of file, which silently empties the input and makes the whole check pass vacuously. """ src = re.sub(r'"""(?:.|\n)*?"""', '""', src) src = re.sub(r"/\*(?:.|\n)*?\*/", " ", src) src = re.sub(r"//[^\n]*", " ", src) src = re.sub(r'"(?:\\.|[^"\\\n])*"', '""', src) return src def object_members(src: str) -> dict: """Map each `object Foo` declared here to the names declared directly in it. Brace-counted rather than regex-matched: an object body contains nested braces (lambdas, apply blocks, companions) and no regex closes correctly over them. Only top-level members count — anything nested deeper is not reachable as `Foo.member` anyway. """ members = {} for match in re.finditer(r"^(?:internal |private )?object (\w+)\s*\{", src, re.M): name = match.group(1) depth = 0 body_start = match.end() - 1 for i in range(body_start, len(src)): if src[i] == "{": depth += 1 elif src[i] == "}": depth -= 1 if depth == 0: break body = src[body_start + 1 : i] own = set() depth = 0 for line in body.splitlines(): if depth == 0: # Nested TYPES count as members too: `Foo.Bar` where Bar is a # data class inside object Foo is an ordinary reference, and # leaving them out made the checker report four false positives # the first time an object held one. decl = re.match( r"\s*(?:@\w+\s+)*(?:public |private |internal |protected )?" r"(?:const |lateinit |inline |suspend |data |sealed |enum |value |abstract |open )*" r"(?:fun|val|var|class|object|interface)\s+" r"(?:<[^>]*>\s*)?(\w+)", line, ) if decl: own.add(decl.group(1)) depth += line.count("{") - line.count("}") members[name] = own return members def main() -> int: root = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_ROOT files = [ os.path.join(d, n) for d, _, names in os.walk(root) for n in names if n.endswith(".kt") ] declared = collections.defaultdict(set) objects = {} parsed = {} for path in files: with open(path, encoding="utf-8") as fh: raw = fh.read() package = re.search(r"^package\s+([\w.]+)", raw, re.M).group(1) src = strip(raw) parsed[path] = (package, src, raw) objects.update(object_members(src)) for match in DECL.finditer(src): declared[package].add(match.group(1)) # Enum entries are declarations too; DECL only sees the class itself. for match in re.finditer(r"enum class \w+[^{]*\{([^};]*)", src): for entry in match.group(1).split(","): name = entry.strip().split("(")[0].strip() if re.fullmatch(r"[A-Z]\w*", name): declared[package].add(name) problems = 0 for path in sorted(files): package, src, raw = parsed[path] imported = set() for match in re.finditer(r"^import\s+([\w.]+)(?:\s+as\s+(\w+))?", raw, re.M): imported.add(match.group(2) or match.group(1).split(".")[-1]) # Type parameters are declared inline at their use site. type_params = set() for match in re.finditer(r"(?:fun|class|interface)\s*<([^>]*)>", src): type_params |= set( re.findall(r"\b([A-Z]\w*)\b(?=\s*(?::|,|$))", match.group(1)) ) known = imported | declared[package] | IMPLICIT | GENERATED | type_params # Capitalised tokens NOT preceded by a dot: `Icons.Filled` resolves # through `Icons`, so only the leading segment needs to be accounted for. for match in re.finditer(r"(?