#!/usr/bin/env python3 """Compare a JAR's .class entries to decompiled .java tree (Maven src/main/java).""" from __future__ import annotations import argparse import sys import zipfile from pathlib import Path def jar_class_entries(jar_path: Path) -> set[str]: """FQN-like names for each .class (uses /), inner as Outer$Inner.""" out: set[str] = set() with zipfile.ZipFile(jar_path, "r") as zf: for name in zf.namelist(): if not name.endswith(".class") or "META-INF" in name: continue rel = name[:-6] # drop .class out.add(rel.replace("/", ".")) return out def java_fqns(src_root: Path) -> set[str]: """Best-effort FQN from path .../src/main/java/a/b/C.java -> a.b.C.""" src_root = src_root.resolve() java_root = src_root / "src/main/java" if not java_root.is_dir(): if src_root.name == "java" and (src_root.parent / "main").exists(): java_root = src_root else: return set() fqns: set[str] = set() for f in java_root.rglob("*.java"): rel = f.relative_to(java_root).with_suffix("") fqns.add(".".join(rel.parts)) return fqns def outer_java_for_class(fqn: str, java_fqns: set[str]) -> str | None: """Map class FQN to expected .java outer name (strip $ inner).""" if "$" in fqn: outer = fqn.split("$", 1)[0] else: outer = fqn if outer in java_fqns: return outer return None def analyze(jar: Path, src_module_roots: list[Path]) -> dict: classes = jar_class_entries(jar) all_java: set[str] = set() for root in src_module_roots: if root.is_dir(): all_java |= java_fqns(root) missing_outer: list[str] = [] for c in sorted(classes): if c.startswith("module-info"): continue if outer_java_for_class(c, all_java) is None: missing_outer.append(c) # "extra" java: outer types not present as any class in jar (rough) jar_outers = {x.split("$", 1)[0] for x in classes if not x.startswith("module-info")} extra_java = sorted(x for x in all_java if x not in jar_outers and "$" not in x) return { "jar": str(jar), "class_count": len(classes), "java_outer_count": len(all_java), "missing_classes": missing_outer, "possibly_extra_sources": extra_java[:200], # cap "possibly_extra_sources_total": len(extra_java), } def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("jar", type=Path) ap.add_argument("src_roots", nargs="+", type=Path, help="Module dirs containing src/main/java") args = ap.parse_args() r = analyze(args.jar, args.src_roots) print(f"JAR: {r['jar']}") print(f"Classes in JAR: {r['class_count']}; outer .java types under src/main/java: {r['java_outer_count']}") miss = r["missing_classes"] print(f"Missing (no matching outer .java): {len(miss)}") for line in miss[:80]: print(f" - {line}") if len(miss) > 80: print(f" ... and {len(miss) - 80} more") ex = r["possibly_extra_sources"] tot = r["possibly_extra_sources_total"] print(f"Possibly extra .java (not as outer in JAR): {tot} (showing up to {len(ex)})") for line in ex[:40]: print(f" + {line}") if tot > 40: print(f" ...") return 1 if miss else 0 if __name__ == "__main__": sys.exit(main())