Skip to content

Commit 1e59a97

Browse files
committed
Add CLDC11 API compatibility workflow
1 parent dd34253 commit 1e59a97

File tree

2 files changed

+338
-0
lines changed

2 files changed

+338
-0
lines changed
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
name: CLDC11 API Compatibility
2+
3+
on:
4+
push:
5+
paths:
6+
- 'Ports/CLDC11/**'
7+
- 'vm/JavaAPI/**'
8+
- '.github/workflows/cldc11-api-compatibility.yml'
9+
- 'scripts/cldc11_api_compat_check.py'
10+
pull_request:
11+
paths:
12+
- 'Ports/CLDC11/**'
13+
- 'vm/JavaAPI/**'
14+
- '.github/workflows/cldc11-api-compatibility.yml'
15+
- 'scripts/cldc11_api_compat_check.py'
16+
17+
jobs:
18+
api-compatibility:
19+
runs-on: ubuntu-latest
20+
21+
steps:
22+
- name: Checkout repository
23+
uses: actions/checkout@v4
24+
25+
- name: Set up JDK 8 for builds
26+
uses: actions/setup-java@v4
27+
with:
28+
distribution: temurin
29+
java-version: '8'
30+
31+
- name: Compile CLDC11 classes
32+
run: |
33+
mkdir -p Ports/CLDC11/build/classes
34+
find Ports/CLDC11/src/java -name '*.java' > cldc11-sources.txt
35+
javac -encoding windows-1252 -source 1.6 -target 1.6 -XDignore.symbol.file \
36+
-d Ports/CLDC11/build/classes @cldc11-sources.txt
37+
38+
- name: Build vm/JavaAPI classes
39+
run: mvn -f vm/pom.xml -pl JavaAPI -am package -DskipTests
40+
41+
- name: Set up JDK 11 for compatibility checks
42+
uses: actions/setup-java@v4
43+
with:
44+
distribution: temurin
45+
java-version: '11'
46+
47+
- name: Validate CLDC11 API subset
48+
run: |
49+
python scripts/cldc11_api_compat_check.py \
50+
--cldc-classes Ports/CLDC11/build/classes \
51+
--javaapi-classes vm/JavaAPI/target/classes \
52+
--extra-report cldc11-extra-apis.txt
53+
54+
- name: Upload extra API report
55+
if: always()
56+
uses: actions/upload-artifact@v4
57+
with:
58+
name: cldc11-extra-apis
59+
path: cldc11-extra-apis.txt

scripts/cldc11_api_compat_check.py

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Validate that the CLDC11 API surface is a binary compatible subset of Java SE 11
4+
and the vm/JavaAPI project.
5+
6+
The script compares public and protected methods and fields using `javap -s -public`.
7+
It expects pre-built class directories for the CLDC11 and JavaAPI projects.
8+
"""
9+
from __future__ import annotations
10+
11+
import argparse
12+
import os
13+
import subprocess
14+
import sys
15+
from dataclasses import dataclass, field
16+
from typing import Dict, Iterable, List, Optional, Set, Tuple
17+
18+
19+
Member = Tuple[str, str, bool, str]
20+
"""
21+
A member descriptor in the form `(name, descriptor, is_static, kind)`.
22+
`kind` is either `method` or `field`.
23+
"""
24+
25+
26+
@dataclass
27+
class ApiSurface:
28+
"""Collection of public/protected API members for a class."""
29+
30+
methods: Set[Member] = field(default_factory=set)
31+
fields: Set[Member] = field(default_factory=set)
32+
33+
def add(self, member: Member) -> None:
34+
if member[3] == "method":
35+
self.methods.add(member)
36+
else:
37+
self.fields.add(member)
38+
39+
def missing_from(self, other: "ApiSurface") -> Tuple[Set[Member], Set[Member]]:
40+
return self.methods - other.methods, self.fields - other.fields
41+
42+
def extras_over(self, other: "ApiSurface") -> Tuple[Set[Member], Set[Member]]:
43+
return self.methods - other.methods, self.fields - other.fields
44+
45+
46+
class JavapError(RuntimeError):
47+
pass
48+
49+
50+
def discover_classes(root: str) -> List[str]:
51+
classes: List[str] = []
52+
for base, _, files in os.walk(root):
53+
for filename in files:
54+
if not filename.endswith(".class"):
55+
continue
56+
if filename in {"module-info.class", "package-info.class"}:
57+
continue
58+
full_path = os.path.join(base, filename)
59+
rel_path = os.path.relpath(full_path, root)
60+
binary_name = rel_path[:-6].replace(os.sep, ".")
61+
classes.append(binary_name)
62+
return classes
63+
64+
65+
def run_javap(target: str, javap_cmd: str) -> str:
66+
proc = subprocess.run(
67+
[javap_cmd, "-public", "-s", target],
68+
stdout=subprocess.PIPE,
69+
stderr=subprocess.PIPE,
70+
text=True,
71+
)
72+
if proc.returncode != 0:
73+
raise JavapError(proc.stderr.strip() or proc.stdout.strip())
74+
return proc.stdout
75+
76+
77+
def parse_members(javap_output: str) -> ApiSurface:
78+
api = ApiSurface()
79+
pending: Optional[Tuple[str, bool, str]] = None # name, is_static, kind
80+
81+
for raw_line in javap_output.splitlines():
82+
line = raw_line.strip()
83+
if not line or line.startswith("Compiled from"):
84+
continue
85+
if line.endswith("{"):
86+
continue
87+
if line.startswith("descriptor:"):
88+
if pending is None:
89+
continue
90+
descriptor = line.split(":", 1)[1].strip()
91+
name, is_static, kind = pending
92+
api.add((name, descriptor, is_static, kind))
93+
pending = None
94+
continue
95+
96+
if line.startswith("Runtime") or line.startswith("Signature:") or line.startswith("Exceptions:"):
97+
pending = None
98+
continue
99+
100+
if "(" in line or line.endswith(";"):
101+
if line.startswith("//"):
102+
continue
103+
if line.endswith(" class"):
104+
continue
105+
if line.endswith("interface"):
106+
continue
107+
108+
is_static = " static " in f" {line} "
109+
if "(" in line:
110+
name_section = line.split("(")[0].strip()
111+
name = name_section.split()[-1]
112+
kind = "method"
113+
else:
114+
name = line.rstrip(";").split()[-1]
115+
kind = "field"
116+
pending = (name, is_static, kind)
117+
118+
return api
119+
120+
121+
def collect_class_api_from_file(class_name: str, classes_root: str, javap_cmd: str) -> ApiSurface:
122+
class_path = os.path.join(classes_root, *class_name.split(".")) + ".class"
123+
output = run_javap(class_path, javap_cmd)
124+
return parse_members(output)
125+
126+
127+
def collect_class_api_from_jdk(class_name: str, javap_cmd: str) -> ApiSurface:
128+
output = run_javap(class_name, javap_cmd)
129+
return parse_members(output)
130+
131+
132+
def format_member(member: Member) -> str:
133+
name, descriptor, is_static, kind = member
134+
static_prefix = "static " if is_static else ""
135+
return f"{kind}: {static_prefix}{name} {descriptor}"
136+
137+
138+
def ensure_subset(
139+
source_classes: List[str],
140+
source_root: str,
141+
target_lookup,
142+
target_label: str,
143+
javap_cmd: str,
144+
) -> Tuple[bool, List[str]]:
145+
ok = True
146+
messages: List[str] = []
147+
148+
for class_name in sorted(source_classes):
149+
try:
150+
source_api = collect_class_api_from_file(class_name, source_root, javap_cmd)
151+
except JavapError as exc:
152+
ok = False
153+
messages.append(f"Failed to read {class_name} from {source_root}: {exc}")
154+
continue
155+
156+
target_api = target_lookup(class_name)
157+
if target_api is None:
158+
ok = False
159+
messages.append(f"Missing class in {target_label}: {class_name}")
160+
continue
161+
162+
missing_methods, missing_fields = source_api.missing_from(target_api)
163+
if missing_methods or missing_fields:
164+
ok = False
165+
messages.append(f"Incompatibilities for {class_name} against {target_label}:")
166+
for member in sorted(missing_methods | missing_fields):
167+
messages.append(f" - {format_member(member)}")
168+
169+
return ok, messages
170+
171+
172+
def collect_javaapi_map(javaapi_root: str, javap_cmd: str) -> Dict[str, ApiSurface]:
173+
classes = discover_classes(javaapi_root)
174+
api_map: Dict[str, ApiSurface] = {}
175+
for class_name in classes:
176+
api_map[class_name] = collect_class_api_from_file(class_name, javaapi_root, javap_cmd)
177+
return api_map
178+
179+
180+
def write_extra_report(
181+
cldc_classes: Dict[str, ApiSurface],
182+
javaapi_classes: Dict[str, ApiSurface],
183+
report_path: str,
184+
) -> None:
185+
lines: List[str] = [
186+
"Extra APIs present in vm/JavaAPI but not in CLDC11",
187+
"",
188+
]
189+
190+
extra_classes = sorted(set(javaapi_classes) - set(cldc_classes))
191+
if extra_classes:
192+
lines.append("Classes only in vm/JavaAPI:")
193+
lines.extend([f" - {name}" for name in extra_classes])
194+
lines.append("")
195+
196+
shared_classes = set(javaapi_classes) & set(cldc_classes)
197+
extra_members: List[str] = []
198+
for class_name in sorted(shared_classes):
199+
javaapi_api = javaapi_classes[class_name]
200+
cldc_api = cldc_classes[class_name]
201+
extra_methods, extra_fields = javaapi_api.extras_over(cldc_api)
202+
if not extra_methods and not extra_fields:
203+
continue
204+
extra_members.append(class_name)
205+
extra_members.append("")
206+
for member in sorted(extra_methods | extra_fields):
207+
extra_members.append(f" + {format_member(member)}")
208+
extra_members.append("")
209+
210+
if extra_members:
211+
lines.append("Additional members on classes shared between vm/JavaAPI and CLDC11:")
212+
lines.append("")
213+
lines.extend(extra_members)
214+
215+
if len(lines) == 2:
216+
lines.append("No extra APIs detected.")
217+
218+
os.makedirs(os.path.dirname(os.path.abspath(report_path)) or ".", exist_ok=True)
219+
with open(report_path, "w", encoding="utf-8") as handle:
220+
handle.write("\n".join(lines).rstrip() + "\n")
221+
222+
223+
def main(argv: Optional[Iterable[str]] = None) -> int:
224+
parser = argparse.ArgumentParser(description=__doc__)
225+
parser.add_argument("--cldc-classes", required=True, help="Path to compiled CLDC11 classes directory")
226+
parser.add_argument("--javaapi-classes", required=True, help="Path to compiled vm/JavaAPI classes directory")
227+
parser.add_argument("--extra-report", required=True, help="File path to write the extra API report")
228+
parser.add_argument(
229+
"--javap",
230+
default=os.path.join(os.environ.get("JAVA_HOME", ""), "bin", "javap"),
231+
help="Path to the javap executable from Java SE 11",
232+
)
233+
args = parser.parse_args(argv)
234+
235+
javap_cmd = args.javap or "javap"
236+
237+
cldc_classes = discover_classes(args.cldc_classes)
238+
if not cldc_classes:
239+
print(f"No class files found under {args.cldc_classes}", file=sys.stderr)
240+
return 1
241+
242+
javaapi_map = collect_javaapi_map(args.javaapi_classes, javap_cmd)
243+
244+
def jdk_lookup(name: str) -> Optional[ApiSurface]:
245+
try:
246+
return collect_class_api_from_jdk(name, javap_cmd)
247+
except JavapError:
248+
return None
249+
250+
def javaapi_lookup(name: str) -> Optional[ApiSurface]:
251+
return javaapi_map.get(name)
252+
253+
java_ok, java_messages = ensure_subset(
254+
cldc_classes,
255+
args.cldc_classes,
256+
jdk_lookup,
257+
"Java SE 11",
258+
javap_cmd,
259+
)
260+
261+
api_ok, api_messages = ensure_subset(
262+
cldc_classes,
263+
args.cldc_classes,
264+
javaapi_lookup,
265+
"vm/JavaAPI",
266+
javap_cmd,
267+
)
268+
269+
cldc_map = {name: collect_class_api_from_file(name, args.cldc_classes, javap_cmd) for name in cldc_classes}
270+
write_extra_report(cldc_map, javaapi_map, args.extra_report)
271+
272+
messages = java_messages + api_messages
273+
if messages:
274+
print("\n".join(messages), file=sys.stderr)
275+
return 0 if java_ok and api_ok else 1
276+
277+
278+
if __name__ == "__main__":
279+
raise SystemExit(main())

0 commit comments

Comments
 (0)