Files
Nucleus/scripts/fix-explicit-api.py
Elie Gambache 657d907432 feat: freeze public ABI for all published runtime modules
Apply kotlinx binary-compatibility-validator and Kotlin explicitApi() at the
root for every non-example module (same harness as decorated-window-tao).
Commit api/*.api baselines, wire apiCheck into preMerge, and document the
workflow. decorated-window-jewel keeps explicitApi but is skipped by BCV until
ASM can read JVM 25 class files.
2026-08-07 08:14:38 +03:00

320 lines
11 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Apply mechanical fixes for Kotlin explicitApi() diagnostics.
Reads kotlinc error lines from stdin or a file and, for each:
- "Visibility must be specified" → inserts `public ` at the reported column
- "Return type must be specified" → inserts an inferred type annotation when
the RHS is a simple constructor call / reference / literal
Idempotent: skips lines that already start (after indent) with a visibility
modifier or already have a type annotation for the reported property/function.
"""
from __future__ import annotations
import re
import sys
from collections import defaultdict
from pathlib import Path
ERROR_RE = re.compile(
r"^e: file://(?P<path>[^:]+):(?P<line>\d+):(?P<col>\d+) (?P<msg>.*)$"
)
VISIBILITY_PREFIXES = (
"public ",
"private ",
"internal ",
"protected ",
)
# Matches a simple RHS constructor / object ref for type inference:
# Foo(…) / Foo.Bar / some.name.Foo(…)
SIMPLE_CTOR_RE = re.compile(
r"^(?P<type>(?:[A-Za-z_][\w.]*\.)*[A-Za-z_][\w]*)\s*(?:\(|$)"
)
LITERAL_TYPE = {
"true": "Boolean",
"false": "Boolean",
"null": "Nothing?",
}
def parse_errors(lines: list[str]) -> list[tuple[Path, int, int, str]]:
out: list[tuple[Path, int, int, str]] = []
for raw in lines:
m = ERROR_RE.match(raw.strip())
if not m:
continue
out.append(
(
Path(m.group("path")),
int(m.group("line")),
int(m.group("col")),
m.group("msg"),
)
)
return out
def leading_ws(s: str) -> str:
return s[: len(s) - len(s.lstrip(" \t"))]
def has_visibility(s: str) -> bool:
stripped = s.lstrip(" \t")
# Skip annotation-only lines; visibility may be on a later line.
if stripped.startswith("@"):
return True # don't touch annotation lines; caller handles multi-line
return any(stripped.startswith(p) for p in VISIBILITY_PREFIXES)
def insert_public(line: str, col: int) -> str:
"""Insert 'public ' at 1-based column, or after leading indent if col is 1."""
if has_visibility(line):
return line
stripped = line.lstrip(" \t")
# If the line is only annotations, leave it — visibility goes on the decl line.
if stripped.startswith("@") and not re.search(
r"\b(class|object|interface|fun|val|var|enum|typealias|constructor)\b",
stripped,
):
return line
indent = leading_ws(line)
# Prefer inserting after indent rather than absolute column when the
# compiler points at column 1 on an indented member (col can be indent+1).
body = line[len(indent) :]
if has_visibility(body):
return line
# Handle labels like "override fun" / "open class" / "abstract fun" /
# "suspend fun" / "operator fun" / "inline fun" / "data class" etc.
return indent + "public " + body
def infer_type_from_rhs(rhs: str) -> str | None:
rhs = rhs.strip()
if not rhs:
return None
# Strip trailing comments
if "//" in rhs:
rhs = rhs.split("//", 1)[0].rstrip()
if rhs in LITERAL_TYPE:
return LITERAL_TYPE[rhs]
# Numeric literals
if re.fullmatch(r"-?\d+", rhs):
return "Int"
if re.fullmatch(r"-?\d+[lL]", rhs):
return "Long"
if re.fullmatch(r"-?\d+\.\d+[fF]?", rhs):
return "Float" if rhs[-1] in "fF" else "Double"
if re.fullmatch(r'".*"', rhs) or re.fullmatch(r'"""[\s\S]*"""', rhs):
return "String"
if re.fullmatch(r"'.'", rhs):
return "Char"
# ::property reference for property delegates — type unknown
if rhs.startswith("::") or " by " in rhs:
return None
m = SIMPLE_CTOR_RE.match(rhs)
if m:
t = m.group("type")
# Reject factory/function calls whose simple name is camelCase starting
# with a lowercase letter (mutableStateOf, listOf, …) — those are not types.
simple = t.rsplit(".", 1)[-1]
if simple and simple[0].islower():
return None
return t
return None
def insert_return_type(line: str) -> str:
"""
Insert `: Type` on a fun/val/var line that lacks an explicit type.
Handles:
fun name(...) = expr
fun name(...) { # → : Unit (only when we can tell it's Unit — skip)
val name = expr
var name = expr
val name by expr
"""
# Already has a type annotation before = or { or by?
# Rough check: `) : Type` or `name: Type` after the identifier.
if re.search(r"\)\s*:\s*[\w.<]", line):
return line
if re.search(r"\b(val|var)\s+[A-Za-z_]\w*\s*:\s*", line):
return line
# fun name(...): Type already
if re.search(r"\bfun\b.*=", line) and re.search(r"\)\s*:", line):
return line
# Expression-bodied function: fun foo(...) = rhs
m = re.match(
r"^(?P<pre>.*\bfun\b\s+(?:[A-Za-z_]\w*\.)?[A-Za-z_]\w*\s*\([^)]*\))\s*=\s*(?P<rhs>.+?)(?P<trail>\s*)$",
line,
)
if m:
inferred = infer_type_from_rhs(m.group("rhs"))
if inferred:
return f"{m.group('pre')}: {inferred} = {m.group('rhs')}{m.group('trail')}"
return line
# Property: val/var name = rhs or val/var name by rhs
m = re.match(
r"^(?P<pre>.*\b(?P<kv>val|var)\s+(?P<name>[A-Za-z_]\w*))\s*"
r"(?P<op>=|by)\s*(?P<rhs>.+?)(?P<trail>\s*)$",
line,
)
if m:
rhs = m.group("rhs")
op = m.group("op")
if op == "by":
# Delegated property: try `by ::other` → copy type if we can see it
# Leave to a later manual/smarter pass if unknown.
ref = re.match(r"::([A-Za-z_]\w*)\s*$", rhs.strip())
if ref:
# Same-file sibling often has an explicit type; caller can re-run.
return line
inferred = infer_type_from_rhs(rhs)
else:
inferred = infer_type_from_rhs(rhs)
if inferred:
return (
f"{m.group('pre')}: {inferred} {op} {rhs}{m.group('trail')}"
)
return line
# Block-bodied fun with no type — Unit is optional in explicit API for Unit,
# so if the compiler still complained, annotate Unit.
m = re.match(
r"^(?P<pre>.*\bfun\b\s+(?:[A-Za-z_]\w*\.)?[A-Za-z_]\w*\s*\([^)]*\))\s*(?P<body>\{.*)$",
line,
)
if m:
return f"{m.group('pre')}: Unit {m.group('body')}"
return line
def apply_fixes(errors: list[tuple[Path, int, int, str]]) -> dict[str, int]:
# Group by file; apply bottom-up so line numbers stay valid within a file.
by_file: dict[Path, list[tuple[int, int, str]]] = defaultdict(list)
for path, line, col, msg in errors:
by_file[path].append((line, col, msg))
stats = {"visibility": 0, "return_type": 0, "skipped": 0, "files": 0}
for path, items in by_file.items():
if not path.is_file():
print(f"skip missing file: {path}", file=sys.stderr)
stats["skipped"] += len(items)
continue
text = path.read_text(encoding="utf-8")
lines = text.splitlines(keepends=True)
# Sort by line desc, then prefer visibility before return-type on same line
# so we insert public first, then type on the modified line.
items_sorted = sorted(
items,
key=lambda t: (t[0], 0 if "Visibility" in t[2] else 1),
reverse=True,
)
changed = False
# Track which physical lines we already inserted public on (1-based)
public_done: set[int] = set()
for line_no, col, msg in items_sorted:
if line_no < 1 or line_no > len(lines):
stats["skipped"] += 1
continue
idx = line_no - 1
original = lines[idx]
# Preserve newline style
newline = ""
body = original
if original.endswith("\r\n"):
newline = "\r\n"
body = original[:-2]
elif original.endswith("\n"):
newline = "\n"
body = original[:-1]
if "Visibility must be specified" in msg:
if line_no in public_done:
stats["skipped"] += 1
continue
# If this line is only an annotation, put public on the next
# non-annotation declaration line instead.
target_idx = idx
target_body = body
if body.lstrip().startswith("@") and not re.search(
r"\b(class|object|interface|fun|val|var|enum|typealias)\b",
body,
):
j = idx + 1
while j < len(lines):
candidate = lines[j].rstrip("\r\n")
if candidate.lstrip().startswith("@"):
j += 1
continue
if candidate.strip() == "":
j += 1
continue
target_idx = j
target_body = candidate
break
new_body = insert_public(target_body, col)
if new_body != target_body:
t_nl = (
"\r\n"
if lines[target_idx].endswith("\r\n")
else "\n"
if lines[target_idx].endswith("\n")
else ""
)
t_body = lines[target_idx].rstrip("\r\n")
lines[target_idx] = insert_public(t_body, col) + t_nl
public_done.add(target_idx + 1)
changed = True
stats["visibility"] += 1
else:
stats["skipped"] += 1
elif "Return type must be specified" in msg:
new_body = insert_return_type(body)
if new_body != body:
lines[idx] = new_body + newline
changed = True
stats["return_type"] += 1
else:
stats["skipped"] += 1
else:
stats["skipped"] += 1
if changed:
path.write_text("".join(lines), encoding="utf-8")
stats["files"] += 1
print(f"updated {path}")
return stats
def main() -> int:
if len(sys.argv) > 1:
raw = Path(sys.argv[1]).read_text(encoding="utf-8").splitlines()
else:
raw = sys.stdin.read().splitlines()
errors = parse_errors(raw)
if not errors:
print("no explicitApi diagnostics found", file=sys.stderr)
return 1
stats = apply_fixes(errors)
print(
f"done: files={stats['files']} visibility={stats['visibility']} "
f"return_type={stats['return_type']} skipped={stats['skipped']}"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())