`785ebdb` failed at compileDebugKotlin with two `Unresolved reference 'dismiss'`. Splitting the reminder notification code into its own object, I removed `dismiss` from `Reminders` and never pasted it into `ReminderNotification`. The call sites were correctly qualified; the function simply was not there. All four local gates passed it, and `check-symbols.py` passed it for a reason it documented about itself: it only resolved the LEADING segment of a dotted expression, because that is the part a regex can resolve. `ReminderNotification` existed, so `ReminderNotification.dismiss(...)` looked fine. That was a real gap rather than an inherent one, so the checker now indexes the members of every `object` declared in the package and verifies `Foo.bar` against them. Brace-counted, not regex-matched — an object body is full of nested braces from lambdas and apply blocks, and no regex closes correctly over them. Verified by deleting `dismiss` from a copy of the tree again: it reports the same two call sites the Kotlin compiler did. What it still cannot see is narrowed and written down rather than left implied — members of anything declared outside this package, members reached through a variable rather than a type name, and every question about types.
190 lines
7.4 KiB
Python
Executable File
190 lines
7.4 KiB
Python
Executable File
#!/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:
|
|
decl = re.match(
|
|
r"\s*(?:@\w+\s+)*(?:public |private |internal |protected )?"
|
|
r"(?:const |lateinit |inline |suspend )*(?:fun|val|var)\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"(?<![\w.])@?([A-Z][A-Za-z0-9_]*)\b", src):
|
|
name = match.group(1)
|
|
if name in known:
|
|
continue
|
|
line = src[: match.start()].count("\n") + 1
|
|
print(f"{os.path.relpath(path, root)}:{line}: unresolved '{name}'")
|
|
problems += 1
|
|
|
|
# Members of objects declared in this package.
|
|
for match in re.finditer(r"(?<![\w.])([A-Z][A-Za-z0-9_]*)\.(\w+)", src):
|
|
owner, member = match.group(1), match.group(2)
|
|
if owner not in objects or member in objects[owner]:
|
|
continue
|
|
line = src[: match.start()].count("\n") + 1
|
|
print(
|
|
f"{os.path.relpath(path, root)}:{line}: "
|
|
f"'{owner}' has no member '{member}'"
|
|
)
|
|
problems += 1
|
|
|
|
print(f"\n{len(files)} files, {problems} unresolved")
|
|
return 1 if problems else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|