CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Successful in 15s
Android / Core and FFI clippy and tests (push) Successful in 27s
CI & Build / integration (push) Successful in 1m39s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 7m55s
Android / Build the server image (push) Successful in 6s
Milestone 546 "Bring the repo to the comment standard (rule 32) and compose defaults (rule 198)", step #6223 "Comment standard sweep: Android client". KDoc, comments, manifest and resource notes, Gradle and TOML comments and the ffi crate's docs now state what the code does and the non-obvious why in the present tense. Issue, Scribe-note, milestone (M13, M304, M315, M12) and family-idea references are gone, as are comparisons to Minstrel and the narration of what used to be: the capture sheet the editor replaced, the per-note colour story, the operator's device-pass feedback, the bindgen-as-[[bin]] failure, the 0.60 edge alpha before it was re-solved, and the checker bugs behind check-strings/check-symbols. Hazards stay as facts: the signing key that can't be rotated, the alias that keeps the earlier name, PKCS12's single password, unwind over abort, the UTC date-picker read, the DerivedTint/colors.ts mirror. One stale claim is gone: DerivedTint said the web had no test runner, but notes/grammar.test.ts runs the same tint cases. detekt.yml said "these two places" over a list of five. 49 files, comments only: a comment-stripping diff of every changed file against HEAD is identical. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
189 lines
7.4 KiB
Python
Executable File
189 lines
7.4 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Flag capitalised identifiers that are neither imported nor declared locally.
|
|
|
|
ktlint and detekt parse Kotlin without resolving symbols, so a *missing import*
|
|
is invisible to them and both pass a file that cannot compile.
|
|
|
|
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 — so a function moved between two objects
|
|
and missed at a call site is caught.
|
|
|
|
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 would report each one as unknown.
|
|
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"(?<![\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())
|