Files

2499 lines
85 KiB
Python

#!/usr/bin/env python3
"""
analyze.py — Unified Diagnostic Toolchain for Migration Analysis
====================================================================
Comprehensive subcommand interface for querying, analyzing, and validating
the CustomNPC-Plus migration workflow.
Subcommands:
query-type Query MC type from index (P0)
query-file Query file from index (P0)
extract-signatures Extract method signatures from Java files (P0)
surface-miner Find minimal interface surface for a type (P0)
type-usage Show all members of an MC type used within a directory scope (P0)
diff-signatures Compare signatures between two Java files (P3)
migration-diff Compare mc1710 original vs migrated core/ file — PASS/WARN/FAIL verdict (P3)
coverage-report Migration coverage analysis (P3)
classify Classify MC type for migration strategy (P0)
classify-all Classify ALL MC types in index and output as JSON (P0)
generate-mappings Generate mc_type_mappings.json from curated classifications
Usage:
python analyze.py query-type EntityPlayer
python analyze.py query-type EntityPlayer --fqn net.minecraft.entity.player.EntityPlayer
python analyze.py query-file src/main/java/noppes/npcs/controllers/QuestController.java --readiness
python analyze.py extract-signatures platform-api/src/main/java/noppes/npcs/api/INbt.java --json
python analyze.py surface-miner NBTTagCompound --scope core/src/main/java
python analyze.py surface-miner EntityPlayer --compare-existing platform-api/.../IPlayer.java
python analyze.py type-usage EntityPlayer --scope noppes/npcs/controllers/
python analyze.py diff-signatures --old file1.java --new file2.java
python analyze.py migration-diff --old mc1710/FactionController.java --new core/FactionController.java
python analyze.py coverage-report --detailed
python analyze.py classify MinecraftServer
python analyze.py classify MinecraftServer --surface
python analyze.py classify --file PlayerDataController.java
python analyze.py classify-all
python analyze.py classify-all --output /tmp/classes.json
python analyze.py classify-all --json
"""
import sys
import re
import json
import difflib
import argparse
from pathlib import Path
from collections import defaultdict
from datetime import datetime, timezone
MC_PREFIXES = ("net.minecraft.", "cpw.mods.", "net.minecraftforge.")
from pa_rules import EXISTING_MAPPINGS as KNOWN_PA
from pa_rules import classify_type as _classify_type, get_pa_name, get_service_target
# ============================================================
# Utilities
# ============================================================
def auto_detect_project_root():
"""Auto-detect project root from script location (tools/migration/ -> root)."""
script_dir = Path(__file__).resolve().parent
return script_dir.parent.parent
def load_index(project_root, index_path="tools/migration/mc_usage_index.json"):
"""Load the MC usage index JSON. Raises FileNotFoundError with clear message."""
index_file = Path(project_root) / index_path
if not index_file.exists():
raise FileNotFoundError(
f"Index not found: {index_file}\n"
f"Run: python tools/migration/scan_codebase.py"
)
with open(index_file, "r", encoding="utf-8") as f:
return json.load(f)
def resolve_java_file(project_root, path_hint):
"""Resolve a Java file path — handles both relative and absolute paths."""
p = Path(path_hint)
if p.is_absolute() and p.exists():
return p
resolved = Path(project_root) / path_hint
if resolved.exists():
return resolved
# Try normalizing slashes
normalized = path_hint.replace("\\", "/")
resolved = Path(project_root) / normalized
if resolved.exists():
return resolved
return None
def setup_treesitter_parser():
"""Initialize tree-sitter parser with Java grammar. Returns (parser, Language) or (None, None)."""
try:
import tree_sitter_java as tsjava
from tree_sitter import Language, Parser
except ImportError:
return None, None
lang = Language(tsjava.language())
parser = Parser(lang)
return parser, lang
def _node_text(node, source_bytes):
"""Extract text from a tree-sitter node."""
if node is None:
return ""
return source_bytes[node.start_byte : node.end_byte].decode(
"utf-8", errors="replace"
)
def extract_signatures_from_file(file_path, parser, public_only=False):
"""
Extract all method/constructor signatures from a Java file using tree-sitter.
Returns a list of dicts:
{
"name": str,
"return_type": str, (empty for constructors)
"params": [{"type": str, "name": str}, ...],
"modifiers": [str, ...],
"annotations": [str, ...],
"line": int,
"kind": "method" | "constructor",
"javadoc": str | None,
}
"""
source_bytes = Path(file_path).read_bytes()
tree = parser.parse(source_bytes)
signatures = []
_walk_for_signatures(tree.root_node, source_bytes, signatures, public_only)
return signatures
def _walk_for_signatures(node, source_bytes, signatures, public_only):
"""Walk AST collecting method/constructor declarations."""
if node.type in ("method_declaration", "constructor_declaration"):
sig = _extract_single_signature(node, source_bytes, public_only)
if sig is not None:
signatures.append(sig)
for child in node.children:
_walk_for_signatures(child, source_bytes, signatures, public_only)
_MODIFIER_KEYWORDS = frozenset(
{
"public",
"private",
"protected",
"static",
"final",
"abstract",
"synchronized",
"native",
"default",
"strictfp",
}
)
def _extract_single_signature(node, source_bytes, public_only):
"""Extract signature from a method_declaration or constructor_declaration node."""
is_constructor = node.type == "constructor_declaration"
# Collect modifiers and annotations
modifiers = []
annotations = []
for child in node.children:
if child.type == "modifiers":
for mod_child in child.children:
if mod_child.type in ("marker_annotation", "annotation"):
annotations.append(_node_text(mod_child, source_bytes))
else:
txt = _node_text(mod_child, source_bytes).strip()
if txt in _MODIFIER_KEYWORDS:
modifiers.append(txt)
# Filter by visibility
if public_only:
has_access = any(m in ("public", "private", "protected") for m in modifiers)
if has_access and "public" not in modifiers:
return None
# In interfaces, no-access-modifier means public — allow through
# Name
name_node = node.child_by_field_name("name")
name = _node_text(name_node, source_bytes) if name_node else "?"
# Return type (not for constructors)
return_type = ""
if not is_constructor:
type_node = node.child_by_field_name("type")
return_type = _node_text(type_node, source_bytes) if type_node else "void"
# Parameters
params = []
params_node = node.child_by_field_name("parameters")
if params_node:
for param_child in params_node.children:
if param_child.type in ("formal_parameter", "spread_parameter"):
p_type_node = param_child.child_by_field_name("type")
p_name_node = param_child.child_by_field_name("name")
p_type = _node_text(p_type_node, source_bytes) if p_type_node else "?"
# Handle varargs
if param_child.type == "spread_parameter":
p_type += "..."
p_name = _node_text(p_name_node, source_bytes) if p_name_node else "?"
# Handle array dimensions on name
dims_node = param_child.child_by_field_name("dimensions")
if dims_node:
p_type += _node_text(dims_node, source_bytes)
params.append({"type": p_type, "name": p_name})
# Javadoc — look for block_comment immediately before this node
javadoc = None
prev = node.prev_named_sibling
if prev and prev.type == "block_comment":
comment_text = _node_text(prev, source_bytes)
if comment_text.startswith("/**"):
javadoc = comment_text
line = node.start_point[0] + 1
return {
"name": name,
"return_type": return_type,
"params": params,
"modifiers": modifiers,
"annotations": annotations,
"line": line,
"kind": "constructor" if is_constructor else "method",
"javadoc": javadoc,
}
def format_signature(sig):
"""Format a signature dict into a human-readable one-liner."""
parts = []
if sig["annotations"]:
parts.append(" ".join(sig["annotations"]))
if sig["modifiers"]:
parts.append(" ".join(sig["modifiers"]))
if sig["return_type"]:
parts.append(sig["return_type"])
param_str = ", ".join(f"{p['type']} {p['name']}" for p in sig["params"])
parts.append(f"{sig['name']}({param_str})")
return " ".join(parts)
def signature_key(sig):
"""Create a comparison key for a signature (name + param types, ignoring param names)."""
param_types = tuple(p["type"] for p in sig["params"])
return (sig["name"], param_types)
# ============================================================
# SUBCOMMAND: query-type (P0)
# ============================================================
def cmd_query_type(args):
"""Query MC type from index — imports, method calls, field accesses, hierarchy, PA status."""
project_root = (
Path(args.project_root) if args.project_root else auto_detect_project_root()
)
index = load_index(project_root, args.index)
# Find the type by FQN or short name
type_key = _find_type_key(index, args.type, args.fqn)
if type_key is None:
print(
f"ERROR: Type '{args.fqn or args.type}' not found in index", file=sys.stderr
)
return 1
ti = index["types"][type_key]
if args.json_output:
json.dump({"fqn": type_key, **ti}, sys.stdout, indent=2)
print()
return 0
# Human-readable output
print()
print("=" * 80)
print(f"TYPE: {ti['shortName']} ({type_key})")
print("=" * 80)
print(f" Package: {ti['package']}")
print(f" PA Abstraction: {ti.get('paAbstraction') or 'NONE'}")
print(f" Imported by: {ti['importCount']} files")
print()
# Method calls
if ti.get("methodCalls") and not args.field_accesses_only:
sorted_methods = sorted(
ti["methodCalls"].items(), key=lambda x: x[1]["count"], reverse=True
)
limit = args.top if args.top else len(sorted_methods)
print(f" METHOD CALLS ({len(ti['methodCalls'])} unique methods):")
print(f" {'Method':<40} {'Calls':>6} {'Files':>6}")
print(f" {'-' * 40} {'-' * 6} {'-' * 6}")
for method, data in sorted_methods[:limit]:
print(f" {method:<40} {data['count']:>6} {len(data['files']):>6}")
if args.show_files:
for f in data["files"][:3]:
print(f" - {f}")
if len(data["files"]) > 3:
print(f" ... and {len(data['files']) - 3} more")
print()
# Static calls
if ti.get("staticCalls") and not args.field_accesses_only:
sorted_statics = sorted(
ti["staticCalls"].items(), key=lambda x: x[1]["count"], reverse=True
)
limit = args.top if args.top else len(sorted_statics)
print(f" STATIC CALLS ({len(ti['staticCalls'])} unique methods):")
print(f" {'Method':<40} {'Calls':>6} {'Files':>6}")
print(f" {'-' * 40} {'-' * 6} {'-' * 6}")
for method, data in sorted_statics[:limit]:
print(f" {method:<40} {data['count']:>6} {len(data['files']):>6}")
print()
# Field accesses
if ti.get("fieldAccesses") and not args.methods_only:
sorted_fields = sorted(
ti["fieldAccesses"].items(), key=lambda x: x[1]["count"], reverse=True
)
limit = args.top if args.top else len(sorted_fields)
print(f" FIELD ACCESSES ({len(ti['fieldAccesses'])} unique fields):")
print(f" {'Field':<40} {'Accesses':>8} {'Files':>6}")
print(f" {'-' * 40} {'-' * 8} {'-' * 6}")
for field, data in sorted_fields[:limit]:
print(f" {field:<40} {data['count']:>8} {len(data['files']):>6}")
print()
# Hierarchy
extends = index["hierarchy"]["extends"].get(type_key, [])
implements = index["hierarchy"]["implements"].get(type_key, [])
if extends:
print(f" EXTENDED BY ({len(extends)} classes):")
for entry in extends[:10]:
print(f" {entry['class']:<40} in {entry['file']}")
if len(extends) > 10:
print(f" ... and {len(extends) - 10} more")
print()
if implements:
print(f" IMPLEMENTED BY ({len(implements)} classes):")
for entry in implements[:10]:
print(f" {entry['class']:<40} in {entry['file']}")
if len(implements) > 10:
print(f" ... and {len(implements) - 10} more")
print()
# Usage summary
usage = ti.get("usage", {})
print(" USAGE SUMMARY:")
print(f" Constructors: {len(usage.get('constructors', []))}")
print(f" Casts: {len(usage.get('casts', []))}")
print(f" instanceof: {len(usage.get('instanceof', []))}")
print()
return 0
def _find_type_key(index, short_name, fqn=None):
"""Find type key in index by FQN or short name."""
if fqn:
return fqn if fqn in index["types"] else None
# Search by short name — exact suffix match
for key in index["types"]:
if key.endswith("." + short_name) or key == short_name:
return key
return None
def add_query_type_args(subparsers):
p = subparsers.add_parser("query-type", help="Query MC type from index (P0)")
p.add_argument("type", help="Type simple name (e.g. EntityPlayer)")
p.add_argument(
"--fqn", help="Exact fully-qualified name (overrides simple name search)"
)
p.add_argument(
"--methods-only", action="store_true", help="Show method/static calls only"
)
p.add_argument(
"--field-accesses-only", action="store_true", help="Show field accesses only"
)
p.add_argument(
"--show-files", action="store_true", help="Show file list for each method"
)
p.add_argument(
"--top", type=int, default=0, help="Limit output to top N entries (0=all)"
)
p.add_argument(
"--json", dest="json_output", action="store_true", help="Output as JSON"
)
p.add_argument(
"--index",
default="tools/migration/mc_usage_index.json",
help="Path to index JSON (relative to project root)",
)
p.add_argument("--project-root", help="Project root (auto-detected if omitted)")
p.set_defaults(func=cmd_query_type)
# ============================================================
# SUBCOMMAND: query-file (P0)
# ============================================================
def cmd_query_file(args):
"""Query file from index — MC types used, readiness score, blocking items."""
project_root = (
Path(args.project_root) if args.project_root else auto_detect_project_root()
)
index = load_index(project_root, args.index)
# Normalize and find file
file_hint = args.file.replace("\\", "/")
file_key = _find_file_key(index, file_hint)
if file_key is None:
print(f"ERROR: File '{args.file}' not found in index", file=sys.stderr)
# Show closest matches
basename = file_hint.split("/")[-1]
candidates = [f for f in index["files"] if basename in f]
if candidates:
print("Did you mean:", file=sys.stderr)
for c in candidates[:5]:
print(f" {c}", file=sys.stderr)
return 1
fd = index["files"][file_key]
if args.json_output:
output = {"file": file_key, **fd}
if args.readiness:
output["readiness"] = _calc_readiness(fd)
json.dump(output, sys.stdout, indent=2)
print()
return 0
# Human-readable
print()
print("=" * 80)
print(f"FILE: {file_key}")
print("=" * 80)
print(f" MC Import Count: {fd['mcImportCount']}")
print(f" MC Types: {', '.join(fd['mcTypes'][:10])}")
if len(fd["mcTypes"]) > 10:
print(f" ... and {len(fd['mcTypes']) - 10} more")
print()
# Readiness score
if args.readiness:
readiness = _calc_readiness(fd)
print(f" MIGRATION READINESS: {readiness['score']:.1f}%")
print(f" Abstracted: {readiness['abstracted']}/{readiness['total']}")
if readiness["blocking"]:
print(
f" Blocking: {', '.join(t.rsplit('.', 1)[-1] for t in readiness['blocking'][:5])}"
)
if len(readiness["blocking"]) > 5:
print(f" ... and {len(readiness['blocking']) - 5} more")
print()
# Detailed type listing
print(" MC TYPES USED:")
print(f" {'Type':<35} {'PA Mapping'}")
print(f" {'-' * 35} {'-' * 30}")
for fqn in sorted(fd["mcFqns"]):
short = fqn.rsplit(".", 1)[-1]
pa = KNOWN_PA.get(fqn)
pa_str = f"-> {pa}" if pa else "(NOT ABSTRACTED)"
print(f" {short:<35} {pa_str}")
print()
return 0
def _find_file_key(index, file_hint):
"""Find file key in index by exact or suffix match."""
if file_hint in index["files"]:
return file_hint
# Exact suffix match
for fpath in index["files"]:
if fpath.endswith(file_hint) or file_hint.endswith(fpath):
return fpath
# Partial match from end (at least filename + one directory)
hint_parts = file_hint.replace("\\", "/").split("/")
for fpath in index["files"]:
fpath_parts = fpath.split("/")
match_len = 0
for hp, fp in zip(reversed(hint_parts), reversed(fpath_parts)):
if hp == fp:
match_len += 1
else:
break
if match_len >= 2:
return fpath
return None
def _calc_readiness(fd):
"""Calculate migration readiness score for a file."""
mc_fqns = fd.get("mcFqns", [])
if not mc_fqns:
return {"score": 100.0, "abstracted": 0, "total": 0, "blocking": []}
abstracted = sum(1 for fqn in mc_fqns if KNOWN_PA.get(fqn))
blocking = [fqn for fqn in mc_fqns if not KNOWN_PA.get(fqn)]
score = abstracted / len(mc_fqns) * 100
return {
"score": score,
"abstracted": abstracted,
"total": len(mc_fqns),
"blocking": blocking,
}
def add_query_file_args(subparsers):
p = subparsers.add_parser("query-file", help="Query file from index (P0)")
p.add_argument("file", help="File path (relative or absolute)")
p.add_argument(
"--readiness", action="store_true", help="Show migration readiness score"
)
p.add_argument(
"--json", dest="json_output", action="store_true", help="Output as JSON"
)
p.add_argument(
"--index",
default="tools/migration/mc_usage_index.json",
help="Path to index JSON",
)
p.add_argument("--project-root", help="Project root (auto-detected if omitted)")
p.set_defaults(func=cmd_query_file)
# ============================================================
# SUBCOMMAND: extract-signatures (P0)
# ============================================================
def cmd_extract_signatures(args):
"""Extract full method signatures from a Java file using tree-sitter."""
parser, lang = setup_treesitter_parser()
if parser is None:
print("ERROR: tree-sitter-java not installed", file=sys.stderr)
print("Run: pip install tree-sitter tree-sitter-java", file=sys.stderr)
return 1
project_root = (
Path(args.project_root) if args.project_root else auto_detect_project_root()
)
java_file = resolve_java_file(project_root, args.file)
if java_file is None or not java_file.exists():
print(f"ERROR: File not found: {args.file}", file=sys.stderr)
return 1
signatures = extract_signatures_from_file(
java_file, parser, public_only=args.public_only
)
if args.json_output:
json.dump(
{
"file": str(java_file),
"total_methods": len(signatures),
"signatures": signatures,
},
sys.stdout,
indent=2,
)
print()
return 0
# Human-readable
rel = java_file.name
print()
print("=" * 80)
print(f"SIGNATURES: {rel}")
print("=" * 80)
print(f" Total methods: {len(signatures)}")
print()
if not signatures:
print(" (no methods found)")
return 0
for sig in signatures:
line_str = f"L{sig['line']}"
kind_str = f"[{sig['kind']}]" if sig["kind"] == "constructor" else ""
formatted = format_signature(sig)
print(f" {line_str:>5} {kind_str:<14} {formatted}")
print()
return 0
def add_extract_signatures_args(subparsers):
p = subparsers.add_parser(
"extract-signatures", help="Extract method signatures from Java files (P0)"
)
p.add_argument("file", help="Path to Java file")
p.add_argument(
"--public-only", action="store_true", help="Show only public methods"
)
p.add_argument(
"--json", dest="json_output", action="store_true", help="Output as JSON"
)
p.add_argument("--project-root", help="Project root (auto-detected if omitted)")
p.set_defaults(func=cmd_extract_signatures)
# ============================================================
# SUBCOMMAND: surface-miner (P0)
# ============================================================
def cmd_surface_miner(args):
"""
Find minimal interface surface for a type based on actual codebase usage.
Examines the index for all method calls and field accesses to the given type,
producing the minimal set needed for a PA interface.
"""
project_root = (
Path(args.project_root) if args.project_root else auto_detect_project_root()
)
index = load_index(project_root, args.index)
# Find the type
type_key = _find_type_key(index, args.type, args.fqn)
if type_key is None:
print(
f"ERROR: Type '{args.fqn or args.type}' not found in index", file=sys.stderr
)
return 1
ti = index["types"][type_key]
# Scope filtering
scope = args.scope.replace("\\", "/") if args.scope else None
def _filter_files(files):
if not scope:
return files
return [f for f in files if f.startswith(scope) or scope in f]
# Gather methods
methods = {}
for method, data in ti.get("methodCalls", {}).items():
files = _filter_files(data["files"])
if files:
methods[method] = {"count": len(files), "files": files, "kind": "instance"}
for method, data in ti.get("staticCalls", {}).items():
files = _filter_files(data["files"])
if files:
methods[method] = {"count": len(files), "files": files, "kind": "static"}
# Gather fields
fields = {}
for field, data in ti.get("fieldAccesses", {}).items():
files = _filter_files(data["files"])
if files:
fields[field] = {"count": len(files), "files": files}
# Compare with existing PA interface
existing_methods = set()
if args.compare_existing:
parser, lang = setup_treesitter_parser()
if parser:
pa_file = resolve_java_file(project_root, args.compare_existing)
if pa_file and pa_file.exists():
pa_sigs = extract_signatures_from_file(pa_file, parser)
existing_methods = {s["name"] for s in pa_sigs}
if args.json_output:
output = {
"type": type_key,
"pa_abstraction": ti.get("paAbstraction"),
"methods": methods,
"fields": fields,
}
if existing_methods:
output["existing_pa_methods"] = sorted(existing_methods)
output["missing_from_pa"] = sorted(set(methods.keys()) - existing_methods)
output["unused_in_pa"] = sorted(existing_methods - set(methods.keys()))
json.dump(output, sys.stdout, indent=2, default=list)
print()
return 0
# Human-readable
print()
print("=" * 80)
print(f"MINIMAL SURFACE: {ti['shortName']} ({type_key})")
print("=" * 80)
print(f" PA Abstraction: {ti.get('paAbstraction') or 'NONE'}")
scope_str = f" (scoped to {scope})" if scope else ""
print(f" Scope: all usage sites{scope_str}")
print()
# Methods
sorted_methods = sorted(methods.items(), key=lambda x: x[1]["count"], reverse=True)
print(f" METHODS ({len(sorted_methods)} unique):")
print(f" {'Method':<40} {'Kind':<8} {'Usage':>6}")
print(f" {'-' * 40} {'-' * 8} {'-' * 6}")
for method, data in sorted_methods:
marker = ""
if existing_methods:
marker = " [PA]" if method in existing_methods else " [MISSING]"
print(f" {method:<40} {data['kind']:<8} {data['count']:>6}{marker}")
print()
# Fields
if fields:
sorted_fields = sorted(
fields.items(), key=lambda x: x[1]["count"], reverse=True
)
print(f" FIELDS ({len(sorted_fields)} unique):")
print(f" {'Field':<40} {'Usage':>6}")
print(f" {'-' * 40} {'-' * 6}")
for field, data in sorted_fields:
print(f" {field:<40} {data['count']:>6}")
print()
# Comparison summary
if existing_methods:
used_methods = set(methods.keys())
missing = used_methods - existing_methods
unused = existing_methods - used_methods
covered = used_methods & existing_methods
print(f" COMPARISON WITH EXISTING PA INTERFACE:")
print(f" Covered by PA: {len(covered)}/{len(used_methods)} methods")
print(f" Missing from PA: {len(missing)}")
if missing:
for m in sorted(missing):
print(f" + {m}")
print(f" Unused in PA: {len(unused)}")
if unused:
for m in sorted(unused):
print(f" - {m}")
print()
return 0
def add_surface_miner_args(subparsers):
p = subparsers.add_parser(
"surface-miner", help="Find minimal interface surface for a type (P0)"
)
p.add_argument("type", help="MC type simple name (e.g. NBTTagCompound)")
p.add_argument("--fqn", help="Exact FQN")
p.add_argument(
"--scope",
help="Limit analysis to files under this path prefix (e.g. core/src/main/java)",
)
p.add_argument("--compare-existing", help="Compare with existing PA interface file")
p.add_argument(
"--json", dest="json_output", action="store_true", help="Output as JSON"
)
p.add_argument(
"--index",
default="tools/migration/mc_usage_index.json",
help="Path to index JSON",
)
p.add_argument("--project-root", help="Project root (auto-detected if omitted)")
p.set_defaults(func=cmd_surface_miner)
# ============================================================
# SUBCOMMAND: type-usage (P0)
# ============================================================
def cmd_type_usage(args):
"""Show ALL members of an MC type used within files under a given directory scope."""
project_root = (
Path(args.project_root) if args.project_root else auto_detect_project_root()
)
index = load_index(project_root, args.index)
# Find the type
type_key = _find_type_key(index, args.type, args.fqn)
if type_key is None:
print(
f"ERROR: Type '{args.fqn or args.type}' not found in index", file=sys.stderr
)
return 1
ti = index["types"][type_key]
# Scope filtering
scope = args.scope.replace("\\", "/") if args.scope else None
def _filter_files(files):
if not scope:
return files
return [f for f in files if f.startswith(scope) or scope in f]
def _filter_usage_entries(entries):
"""Filter usage entries (list of {file, line} dicts) by scope, return file list."""
if not entries:
return []
files = [e["file"] for e in entries if isinstance(e, dict) and "file" in e]
if scope:
files = [f for f in files if f.startswith(scope) or scope in f]
return files
# Gather method calls (scoped)
method_calls = {}
for method, data in ti.get("methodCalls", {}).items():
files = _filter_files(data["files"])
if files:
method_calls[method] = {"count": len(files), "files": files}
# Gather static calls (scoped)
static_calls = {}
for method, data in ti.get("staticCalls", {}).items():
files = _filter_files(data["files"])
if files:
static_calls[method] = {"count": len(files), "files": files}
# Gather field accesses (scoped)
field_accesses = {}
for field, data in ti.get("fieldAccesses", {}).items():
files = _filter_files(data["files"])
if files:
field_accesses[field] = {"count": len(files), "files": files}
# Gather constructors, casts, instanceof (scoped)
usage = ti.get("usage", {})
constructor_files = _filter_usage_entries(usage.get("constructors", []))
cast_files = _filter_usage_entries(usage.get("casts", []))
instanceof_files = _filter_usage_entries(usage.get("instanceof", []))
# Count occurrences per file for casts/instanceof/constructors
def _count_per_file(file_list):
counts = defaultdict(int)
for f in file_list:
counts[f] += 1
return counts
constructor_counts = _count_per_file(constructor_files)
cast_counts = _count_per_file(cast_files)
instanceof_counts = _count_per_file(instanceof_files)
# Compute total files in scope
all_scope_files = set()
for data in method_calls.values():
all_scope_files.update(data["files"])
for data in static_calls.values():
all_scope_files.update(data["files"])
for data in field_accesses.values():
all_scope_files.update(data["files"])
all_scope_files.update(constructor_counts.keys())
all_scope_files.update(cast_counts.keys())
all_scope_files.update(instanceof_counts.keys())
total_files_in_scope = len(all_scope_files)
# Count total files globally for this type
all_global_files = set()
for data in ti.get("methodCalls", {}).values():
all_global_files.update(data["files"])
for data in ti.get("staticCalls", {}).values():
all_global_files.update(data["files"])
for data in ti.get("fieldAccesses", {}).values():
all_global_files.update(data["files"])
for e in usage.get("constructors", []):
if isinstance(e, dict) and "file" in e:
all_global_files.add(e["file"])
for e in usage.get("casts", []):
if isinstance(e, dict) and "file" in e:
all_global_files.add(e["file"])
for e in usage.get("instanceof", []):
if isinstance(e, dict) and "file" in e:
all_global_files.add(e["file"])
# Also include importedBy if available
imported_by = ti.get("importedBy", [])
if imported_by:
all_global_files.update(imported_by)
total_files_global = (
len(all_global_files) if all_global_files else ti.get("importCount", 0)
)
# Apply --top limit
top = args.top if args.top else 0
if args.json_output:
output = {
"type": type_key,
"shortName": ti["shortName"],
"paAbstraction": ti.get("paAbstraction"),
"scope": scope or "(all)",
"totalFilesInScope": total_files_in_scope,
"totalFilesGlobal": total_files_global,
"methodCalls": method_calls,
"staticCalls": static_calls,
"fieldAccesses": field_accesses,
"constructors": {
"count": len(constructor_files),
"files": sorted(constructor_counts.keys()),
},
"casts": {
"count": len(cast_files),
"files": sorted(cast_counts.keys()),
},
"instanceof": {
"count": len(instanceof_files),
"files": sorted(instanceof_counts.keys()),
},
}
json.dump(output, sys.stdout, indent=2)
print()
return 0
# Human-readable output
scope_label = scope if scope else "(all files)"
print()
print("=" * 80)
print(f"TYPE USAGE: {ti['shortName']} in {scope_label}")
print("=" * 80)
print(f" PA Abstraction: {ti.get('paAbstraction') or 'NONE'}")
print(f" Files in scope: {total_files_in_scope} (of {total_files_global} total)")
print()
# Method calls
total_method_calls = sum(d["count"] for d in method_calls.values())
sorted_methods = sorted(
method_calls.items(), key=lambda x: x[1]["count"], reverse=True
)
limit_methods = sorted_methods[:top] if top else sorted_methods
print(
f" METHOD CALLS ({len(method_calls)} unique, {total_method_calls} total calls):"
)
if limit_methods:
print(f" {'Method':<40} {'Calls':>5} {'Files':>5}")
print(f" {'-' * 40} {'-' * 5} {'-' * 5}")
for method, data in limit_methods:
print(f" {method:<40} {data['count']:>5} {len(data['files']):>5}")
for f in data["files"][:5]:
print(f" - {f}")
if len(data["files"]) > 5:
print(f" ... and {len(data['files']) - 5} more")
else:
print(" (none)")
print()
# Static calls
total_static_calls = sum(d["count"] for d in static_calls.values())
sorted_statics = sorted(
static_calls.items(), key=lambda x: x[1]["count"], reverse=True
)
limit_statics = sorted_statics[:top] if top else sorted_statics
if limit_statics:
print(
f" STATIC CALLS ({len(static_calls)} unique, {total_static_calls} total calls):"
)
print(f" {'Method':<40} {'Calls':>5} {'Files':>5}")
print(f" {'-' * 40} {'-' * 5} {'-' * 5}")
for method, data in limit_statics:
print(f" {method:<40} {data['count']:>5} {len(data['files']):>5}")
for f in data["files"][:5]:
print(f" - {f}")
if len(data["files"]) > 5:
print(f" ... and {len(data['files']) - 5} more")
print()
else:
print(" STATIC CALLS: (none)")
print()
# Field accesses
total_field_accesses = sum(d["count"] for d in field_accesses.values())
sorted_fields = sorted(
field_accesses.items(), key=lambda x: x[1]["count"], reverse=True
)
limit_fields = sorted_fields[:top] if top else sorted_fields
if limit_fields:
print(
f" FIELD ACCESSES ({len(field_accesses)} unique, {total_field_accesses} total accesses):"
)
print(f" {'Field':<40} {'Accesses':>8} {'Files':>5}")
print(f" {'-' * 40} {'-' * 8} {'-' * 5}")
for field, data in limit_fields:
print(f" {field:<40} {data['count']:>8} {len(data['files']):>5}")
for f in data["files"][:5]:
print(f" - {f}")
if len(data["files"]) > 5:
print(f" ... and {len(data['files']) - 5} more")
print()
else:
print(" FIELD ACCESSES: (none)")
print()
# Constructors
print(
f" CONSTRUCTORS: {len(constructor_files)} occurrences"
+ (f" in {len(constructor_counts)} files" if constructor_files else "")
)
if constructor_counts:
for f in sorted(constructor_counts.keys()):
print(f" - {f} ({constructor_counts[f]})")
print()
# Casts
print(
f" CASTS: {len(cast_files)} occurrences"
+ (f" in {len(cast_counts)} files" if cast_files else "")
)
if cast_counts:
for f in sorted(cast_counts.keys()):
print(f" - {f} ({cast_counts[f]})")
print()
# instanceof
print(
f" INSTANCEOF: {len(instanceof_files)} occurrences"
+ (f" in {len(instanceof_counts)} files" if instanceof_files else "")
)
if instanceof_counts:
for f in sorted(instanceof_counts.keys()):
print(f" - {f} ({instanceof_counts[f]})")
print()
return 0
def add_type_usage_args(subparsers):
p = subparsers.add_parser(
"type-usage",
help="Show all members of an MC type used within a directory scope (P0)",
)
p.add_argument("type", help="MC type simple name (e.g. EntityPlayer)")
p.add_argument("--fqn", help="Exact FQN override")
p.add_argument(
"--scope",
help="Directory path prefix filter (e.g. noppes/npcs/controllers/)",
)
p.add_argument(
"--top",
type=int,
default=0,
help="Limit methods/fields to top N by count (0=all)",
)
p.add_argument(
"--json",
dest="json_output",
action="store_true",
help="Machine-readable JSON output",
)
p.add_argument(
"--index",
default="tools/migration/mc_usage_index.json",
help="Path to index JSON (relative to project root)",
)
p.add_argument("--project-root", help="Project root (auto-detected if omitted)")
p.set_defaults(func=cmd_type_usage)
# ============================================================
# SUBCOMMAND: diff-signatures (P3)
# ============================================================
def cmd_diff_signatures(args):
"""Compare method signatures between two Java files."""
parser, lang = setup_treesitter_parser()
if parser is None:
print("ERROR: tree-sitter-java not installed", file=sys.stderr)
print("Run: pip install tree-sitter tree-sitter-java", file=sys.stderr)
return 1
project_root = (
Path(args.project_root) if args.project_root else auto_detect_project_root()
)
old_file = resolve_java_file(project_root, args.old)
new_file = resolve_java_file(project_root, args.new)
if old_file is None or not old_file.exists():
print(f"ERROR: Old file not found: {args.old}", file=sys.stderr)
return 1
if new_file is None or not new_file.exists():
print(f"ERROR: New file not found: {args.new}", file=sys.stderr)
return 1
old_sigs = extract_signatures_from_file(old_file, parser)
new_sigs = extract_signatures_from_file(new_file, parser)
# Build maps by signature key
old_map = {}
for sig in old_sigs:
key = signature_key(sig)
old_map[key] = sig
new_map = {}
for sig in new_sigs:
key = signature_key(sig)
new_map[key] = sig
old_keys = set(old_map.keys())
new_keys = set(new_map.keys())
added = new_keys - old_keys
removed = old_keys - new_keys
common = old_keys & new_keys
# Detect changed signatures (same key, different return type or modifiers)
changed = []
for key in common:
o = old_map[key]
n = new_map[key]
if o["return_type"] != n["return_type"] or o["modifiers"] != n["modifiers"]:
changed.append((o, n))
if args.json_output:
output = {
"old_file": str(old_file),
"new_file": str(new_file),
"added": [new_map[k] for k in sorted(added)],
"removed": [old_map[k] for k in sorted(removed)],
"changed": [{"old": o, "new": n} for o, n in changed],
"unchanged": len(common) - len(changed),
}
json.dump(output, sys.stdout, indent=2)
print()
return 0
# Human-readable
print()
print("=" * 80)
print("SIGNATURE DIFF")
print("=" * 80)
print(f" Old: {old_file.name} ({len(old_sigs)} methods)")
print(f" New: {new_file.name} ({len(new_sigs)} methods)")
print()
if added:
print(f" ADDED ({len(added)}):")
for key in sorted(added):
print(f" + {format_signature(new_map[key])}")
print()
if removed:
print(f" REMOVED ({len(removed)}):")
for key in sorted(removed):
print(f" - {format_signature(old_map[key])}")
print()
if changed:
print(f" CHANGED ({len(changed)}):")
for old_sig, new_sig in changed:
print(f" OLD: {format_signature(old_sig)}")
print(f" NEW: {format_signature(new_sig)}")
print()
if not added and not removed and not changed:
print(" No differences found.")
print()
print(
f" Summary: +{len(added)} added, -{len(removed)} removed, "
f"~{len(changed)} changed, ={len(common) - len(changed)} unchanged"
)
print()
return 0
def add_diff_signatures_args(subparsers):
p = subparsers.add_parser(
"diff-signatures", help="Compare method signatures between two Java files (P3)"
)
p.add_argument("--old", required=True, help="Path to old/original Java file")
p.add_argument("--new", required=True, help="Path to new/updated Java file")
p.add_argument(
"--json", dest="json_output", action="store_true", help="Output as JSON"
)
p.add_argument("--project-root", help="Project root (auto-detected if omitted)")
p.set_defaults(func=cmd_diff_signatures)
# ============================================================
# SUBCOMMAND: coverage-report (P3)
# ============================================================
def cmd_coverage_report(args):
"""Migration coverage analysis — % types abstracted, breakdown by subsystem, blocking."""
project_root = (
Path(args.project_root) if args.project_root else auto_detect_project_root()
)
index = load_index(project_root, args.index)
summary = index.get("summary", {})
types = index.get("types", {})
files = index.get("files", {})
# Basic stats
total_files = summary.get("totalFiles", len(files))
files_with_deps = summary.get("filesWithMcDeps", len(files))
total_mc_imports = summary.get("totalMcImports", 0)
unique_types = summary.get("uniqueMcTypes", len(types))
# Abstraction coverage
abstracted_types = []
blocking_types = []
for fqn, ti in types.items():
if ti.get("paAbstraction"):
abstracted_types.append((fqn, ti))
else:
blocking_types.append((fqn, ti))
abstracted = len(abstracted_types)
not_abstracted = len(blocking_types)
total_types = abstracted + not_abstracted
coverage_pct = (abstracted / total_types * 100) if total_types > 0 else 0
# Import-weighted coverage
abstracted_imports = sum(ti["importCount"] for _, ti in abstracted_types)
total_import_count = sum(ti["importCount"] for ti in types.values())
import_coverage_pct = (
(abstracted_imports / total_import_count * 100) if total_import_count > 0 else 0
)
# Per-package breakdown
by_package = defaultdict(lambda: {"types": 0, "abstracted": 0, "imports": 0})
for fqn, ti in types.items():
pkg = ti["package"]
by_package[pkg]["types"] += 1
by_package[pkg]["imports"] += ti["importCount"]
if ti.get("paAbstraction"):
by_package[pkg]["abstracted"] += 1
if args.json_output:
output = {
"totalFiles": total_files,
"filesWithMcDeps": files_with_deps,
"totalMcImports": total_mc_imports,
"uniqueMcTypes": unique_types,
"abstractionCoverage": {
"typeCoverage": f"{coverage_pct:.1f}%",
"importCoverage": f"{import_coverage_pct:.1f}%",
"abstracted": abstracted,
"notAbstracted": not_abstracted,
},
"topBlockingTypes": [
{
"fqn": fqn,
"shortName": ti["shortName"],
"importCount": ti["importCount"],
}
for fqn, ti in sorted(
blocking_types, key=lambda x: x[1]["importCount"], reverse=True
)[:20]
],
"byPackage": {
pkg: data
for pkg, data in sorted(
by_package.items(), key=lambda x: x[1]["imports"], reverse=True
)
},
}
json.dump(output, sys.stdout, indent=2)
print()
return 0
# Human-readable
print()
print("=" * 80)
print("MIGRATION COVERAGE REPORT")
print("=" * 80)
print()
print(" OVERALL STATISTICS:")
print(f" Total files scanned: {total_files:>6,}")
print(f" Files with MC deps: {files_with_deps:>6,}")
print(f" Total MC imports: {total_mc_imports:>6,}")
print(f" Unique MC types: {unique_types:>6}")
print()
print(" ABSTRACTION COVERAGE:")
print(f" Type coverage: {abstracted}/{total_types} ({coverage_pct:.1f}%)")
print(
f" Import coverage: {abstracted_imports}/{total_import_count} ({import_coverage_pct:.1f}%)"
)
print()
# Top blocking types
sorted_blocking = sorted(
blocking_types, key=lambda x: x[1]["importCount"], reverse=True
)
limit = 20 if args.detailed else 10
print(f" TOP BLOCKING TYPES (no PA abstraction):")
print(f" {'#':>4} {'Type':<40} {'Imports':>7}")
print(f" {'':4} {'-' * 40} {'-' * 7}")
for i, (fqn, ti) in enumerate(sorted_blocking[:limit]):
print(f" {i + 1:>4}. {ti['shortName']:<40} {ti['importCount']:>7}")
if len(sorted_blocking) > limit:
print(f" {'':4} ... and {len(sorted_blocking) - limit} more")
print()
# Package breakdown (detailed)
if args.detailed:
sorted_packages = sorted(
by_package.items(), key=lambda x: x[1]["imports"], reverse=True
)
print(f" PACKAGE BREAKDOWN:")
print(f" {'Package':<45} {'Types':>5} {'Abstr':>5} {'Imports':>7}")
print(f" {'-' * 45} {'-' * 5} {'-' * 5} {'-' * 7}")
for pkg, data in sorted_packages[:20]:
print(
f" {pkg:<45} {data['types']:>5} {data['abstracted']:>5} {data['imports']:>7}"
)
print()
# Top files (detailed)
if args.detailed:
sorted_files = sorted(
files.items(), key=lambda x: x[1]["mcImportCount"], reverse=True
)
print(f" TOP FILES BY MC DEPENDENCY:")
print(f" {'File':<60} {'MC Types':>8}")
print(f" {'-' * 60} {'-' * 8}")
for fpath, fd in sorted_files[:15]:
print(f" {fpath:<60} {fd['mcImportCount']:>8}")
print()
return 0
def add_coverage_report_args(subparsers):
p = subparsers.add_parser(
"coverage-report", help="Migration coverage analysis (P3)"
)
p.add_argument(
"--detailed",
action="store_true",
help="Show detailed breakdown (packages, files)",
)
p.add_argument(
"--json", dest="json_output", action="store_true", help="Output as JSON"
)
p.add_argument(
"--index",
default="tools/migration/mc_usage_index.json",
help="Path to index JSON",
)
p.add_argument("--project-root", help="Project root (auto-detected if omitted)")
p.set_defaults(func=cmd_coverage_report)
# ============================================================
# SUBCOMMAND: classify (P0)
# ============================================================
def _gather_surface_data(index, type_key):
"""Build surface_data dict from index for auto-classification."""
ti = index["types"][type_key]
methods = []
for method, data in ti.get("methodCalls", {}).items():
methods.append({"name": method, "kind": "instance", "count": data["count"]})
for method, data in ti.get("staticCalls", {}).items():
methods.append({"name": method, "kind": "static", "count": data["count"]})
return {"methods": methods}
def _format_classification(result, simple_name):
"""Format a classification result for human-readable output."""
lines = []
cls = result["classification"]
lines.append(f" {simple_name:<30} {cls}")
reason = result["reason"].replace("\u2192", "->")
lines.append(f" Reason: {reason}")
if result.get("pa_name"):
lines.append(f" PA Name: {result['pa_name']}")
if result.get("target"):
lines.append(f" Target: {result['target']}")
if result.get("service_name"):
lines.append(f" Service: {result['service_name']}")
if result.get("transform_patterns"):
lines.append(f" Patterns ({len(result['transform_patterns'])}):")
for pattern, replacement in result["transform_patterns"]:
lines.append(f" {pattern}")
lines.append(f" -> {replacement}")
return "\n".join(lines)
def cmd_classify(args):
"""Classify MC types using pa_rules — single type or file mode."""
project_root = (
Path(args.project_root) if args.project_root else auto_detect_project_root()
)
if args.file:
return _classify_file_mode(args, project_root)
if not args.type:
print("ERROR: Provide a type name or --file", file=sys.stderr)
return 1
return _classify_single_mode(args, project_root)
def _classify_single_mode(args, project_root):
"""Classify a single MC type."""
simple_name = args.type
surface_data = None
if args.surface or args.fqn:
try:
index = load_index(project_root, args.index)
type_key = _find_type_key(index, simple_name, args.fqn)
if type_key and args.surface:
surface_data = _gather_surface_data(index, type_key)
except FileNotFoundError:
if args.surface:
print(
"WARNING: Index not found, --surface data unavailable",
file=sys.stderr,
)
result = _classify_type(simple_name, surface_data)
if args.json_output:
output = {"type": simple_name, **result}
json.dump(output, sys.stdout, indent=2)
print()
return 0
print()
print("=" * 60)
print("CLASSIFY")
print("=" * 60)
print(_format_classification(result, simple_name))
print()
return 0
def _classify_file_mode(args, project_root):
"""Classify all MC types in a file — migration plan."""
index = load_index(project_root, args.index)
file_hint = args.file.replace("\\", "/")
file_key = _find_file_key(index, file_hint)
if file_key is None:
print(f"ERROR: File '{args.file}' not found in index", file=sys.stderr)
basename = file_hint.split("/")[-1]
candidates = [f for f in index["files"] if basename in f]
if candidates:
print("Did you mean:", file=sys.stderr)
for c in candidates[:5]:
print(f" {c}", file=sys.stderr)
return 1
fd = index["files"][file_key]
mc_fqns = fd.get("mcFqns", [])
if not mc_fqns:
if args.json_output:
json.dump({"file": file_key, "types": [], "plan": []}, sys.stdout, indent=2)
print()
else:
print(f"\n {file_key}: No MC types found.\n")
return 0
results = []
for fqn in sorted(mc_fqns):
simple = fqn.rsplit(".", 1)[-1]
surface_data = None
if args.surface:
type_key = _find_type_key(index, simple)
if type_key:
surface_data = _gather_surface_data(index, type_key)
classification = _classify_type(simple, surface_data)
results.append({"fqn": fqn, "simple": simple, **classification})
if args.json_output:
json.dump({"file": file_key, "plan": results}, sys.stdout, indent=2)
print()
return 0
print()
print("=" * 80)
print(f"MIGRATION PLAN: {file_key}")
print("=" * 80)
print(f" MC Types: {len(mc_fqns)}")
print()
by_cls = defaultdict(list)
for r in results:
by_cls[r["classification"]].append(r)
order = ["EXISTING", "INTERFACE", "SERVICE", "TRANSFORM", "SUPPRESS", "AUTO"]
for cls in order:
items = by_cls.get(cls, [])
if not items:
continue
print(f" {cls} ({len(items)}):")
for item in items:
pa_str = f" -> {item['pa_name']}" if item.get("pa_name") else ""
svc_str = f" [{item['service_name']}]" if item.get("service_name") else ""
print(f" {item['simple']:<30}{pa_str}{svc_str}")
print(f" {item['reason'].replace(chr(0x2192), '->')}")
print()
return 0
def add_classify_args(subparsers):
p = subparsers.add_parser(
"classify", help="Classify MC type for migration strategy (P0)"
)
p.add_argument(
"type",
nargs="?",
default=None,
help="MC type simple name (e.g. MinecraftServer)",
)
p.add_argument(
"--file", help="Classify all MC types in a file (migration plan mode)"
)
p.add_argument("--fqn", help="Exact FQN override")
p.add_argument(
"--surface",
action="store_true",
help="Gather surface data from index for auto-classification",
)
p.add_argument(
"--json", dest="json_output", action="store_true", help="Output as JSON"
)
p.add_argument(
"--index",
default="tools/migration/mc_usage_index.json",
help="Path to index JSON (relative to project root)",
)
p.add_argument("--project-root", help="Project root (auto-detected if omitted)")
p.set_defaults(func=cmd_classify)
# ============================================================
# SUBCOMMAND: classify-all (P0)
# ============================================================
_CLASSIFY_ORDER = ["EXISTING", "INTERFACE", "SERVICE", "TRANSFORM", "SUPPRESS", "AUTO"]
_CLASSIFY_LABELS = {
"EXISTING": "already have PA abstractions",
"INTERFACE": "become standalone PA interfaces",
"SERVICE": "add methods to PlatformService or domain services",
"TRANSFORM": "handle via Static-Transform regex",
"SUPPRESS": "never abstracted, stay in mc1710",
"AUTO": "not explicitly classified, use heuristics if surface data available",
}
_LARGE_GROUP_CAP = 10
def _format_type_line(cls, entry):
"""Format a single type line based on classification."""
name = entry["shortName"]
if cls == "EXISTING" or cls == "INTERFACE":
pa = entry.get("pa_name") or "?"
return f" {name:<30} -> {pa}"
if cls == "SERVICE":
svc = entry.get("service_name") or "PlatformService"
return f" {name:<30} -> {svc}"
if cls == "TRANSFORM":
patterns = entry.get("transform_patterns") or []
return f" {name:<30} -> ({len(patterns)} patterns)"
return f" {name}"
def _map_line(cls, entry):
"""Build a single line for the flat map file."""
name = entry["shortName"]
if cls in ("EXISTING", "INTERFACE"):
return f"{name} -> {entry.get('pa_name') or '?'}"
if cls == "SERVICE":
return f"{name} -> {entry.get('service_name') or 'PlatformService'}"
if cls == "TRANSFORM":
n = len(entry.get("transform_patterns") or [])
return f"{name} -> ({n} patterns)"
return name
def _write_map_file(map_path, grouped):
"""Write the flat name->mapping text file (all entries, uncapped)."""
map_path.parent.mkdir(parents=True, exist_ok=True)
with open(map_path, "w", encoding="utf-8") as f:
first = True
for cls in _CLASSIFY_ORDER:
items = grouped.get(cls, [])
if not items:
continue
if not first:
f.write("\n")
first = False
f.write(f"{cls}:\n")
for entry in items:
f.write(f"{_map_line(cls, entry)}\n")
def cmd_classify_all(args):
"""Classify ALL MC types in the index and output results as JSON."""
project_root = (
Path(args.project_root) if args.project_root else auto_detect_project_root()
)
index = load_index(project_root, args.index)
types = index.get("types", {})
total = len(types)
results = {}
grouped = defaultdict(list)
for fqn, ti in types.items():
simple_name = ti["shortName"]
surface_data = _gather_surface_data(index, fqn)
if not surface_data["methods"]:
surface_data = None
result = _classify_type(simple_name, surface_data)
classification = result["classification"]
entry = {
"shortName": simple_name,
"classification": classification,
"reason": result["reason"],
"pa_name": result.get("pa_name"),
"target": result.get("target"),
"import_count": ti.get("importCount", 0),
"file_count": len(ti.get("importedBy", [])),
}
if result.get("service_name"):
entry["service_name"] = result["service_name"]
if result.get("transform_patterns"):
entry["transform_patterns"] = [
[p, r] for p, r in result["transform_patterns"]
]
results[fqn] = entry
grouped[classification].append({"fqn": fqn, **entry})
for cls in grouped:
grouped[cls].sort(key=lambda e: e["import_count"], reverse=True)
timestamp = datetime.now(timezone.utc).isoformat()
classified_counts = {
cls: len(grouped[cls]) for cls in _CLASSIFY_ORDER if grouped.get(cls)
}
json_grouped = {}
for cls in _CLASSIFY_ORDER:
items = grouped.get(cls, [])
if not items:
continue
json_items = []
for e in items:
item = {"fqn": e["fqn"], "shortName": e["shortName"]}
if cls in ("EXISTING", "INTERFACE", "AUTO"):
item["pa_name"] = e.get("pa_name")
if cls == "SERVICE":
item["service_name"] = e.get("service_name")
if cls == "TRANSFORM":
item["pattern_count"] = len(e.get("transform_patterns") or [])
item["import_count"] = e["import_count"]
json_items.append(item)
json_grouped[cls] = json_items
output = {
"metadata": {
"timestamp": timestamp,
"total_types": total,
"classified": classified_counts,
},
"grouped_by_classification": json_grouped,
"types": results,
}
output_path = Path(args.output)
if not output_path.is_absolute():
output_path = Path(project_root) / args.output
output_path.parent.mkdir(parents=True, exist_ok=True)
with open(output_path, "w", encoding="utf-8") as f:
json.dump(output, f, indent=2, ensure_ascii=False)
f.write("\n")
map_path = Path(args.map_file)
if not map_path.is_absolute():
map_path = Path(project_root) / args.map_file
_write_map_file(map_path, grouped)
if args.json_stdout:
json.dump(output, sys.stdout, indent=2, ensure_ascii=True)
print()
else:
print()
print("=" * 80)
print("CLASSIFY ALL MC TYPES")
print("=" * 80)
print(f" Processing {total} types from index...")
print()
print(" CLASSIFICATION SUMMARY:")
for cls in _CLASSIFY_ORDER:
count = classified_counts.get(cls, 0)
if count > 0:
print(f" {cls:<14}{count:>4} types")
print()
print(f" {'_' * 78}")
for cls in _CLASSIFY_ORDER:
items = grouped.get(cls, [])
if not items:
continue
label = _CLASSIFY_LABELS.get(cls, "")
print()
print(f" {cls} ({len(items)} types -- {label}):")
cap = _LARGE_GROUP_CAP if len(items) > _LARGE_GROUP_CAP + 3 else len(items)
for entry in items[:cap]:
print(_format_type_line(cls, entry))
remaining = len(items) - cap
if remaining > 0:
print(f" ... ({remaining} more)")
print()
print(f" {'_' * 78}")
print(f" Output JSON written: {output_path}")
print(f" Map file written: {map_path}")
print()
return 0
def add_classify_all_args(subparsers):
p = subparsers.add_parser(
"classify-all", help="Classify ALL MC types in index and output as JSON (P0)"
)
p.add_argument(
"--output",
default="tools/migration/classification_results.json",
help="Output JSON file path (default: tools/migration/classification_results.json)",
)
p.add_argument(
"--map-file",
default="tools/migration/pa_classifications_map.txt",
help="Output flat map file (default: tools/migration/pa_classifications_map.txt)",
)
p.add_argument(
"--json",
dest="json_stdout",
action="store_true",
help="Output summary to stdout as JSON instead of human-readable text",
)
p.add_argument(
"--index",
default="tools/migration/mc_usage_index.json",
help="Path to index JSON (relative to project root)",
)
p.add_argument("--project-root", help="Project root (auto-detected if omitted)")
p.set_defaults(func=cmd_classify_all)
# ============================================================
# Normalization utilities for migration-diff
# ============================================================
def _load_classifications(project_root, classifications_path):
"""Load the mc_type_classifications.json file. Returns the 'types' dict."""
cls_file = Path(project_root) / classifications_path
if not cls_file.exists():
raise FileNotFoundError(
f"Classifications file not found: {cls_file}\n"
f"Expected at: {classifications_path}"
)
with open(cls_file, "r", encoding="utf-8") as f:
data = json.load(f)
return data.get("types", {})
def normalize_text(text, classifications):
"""
Normalize text by replacing MC type names with their PA equivalents.
For EXISTING/INTERFACE/SERVICE entries (those with 'pa' or 'service' field):
- Whole-word replace MC simple name -> PA name
For TRANSFORM entries:
- Apply each regex pattern from the 'patterns' list
This is used to normalize BOTH old and new file content so that pure
type migrations vanish from diffs.
"""
result = text
# Phase 1: TRANSFORM patterns (apply regex replacements)
for type_name, info in classifications.items():
if type_name.startswith("_"):
continue
category = info.get("category", "")
if category == "TRANSFORM":
patterns = info.get("patterns", [])
for pat in patterns:
if len(pat) >= 2:
try:
result = re.sub(pat[0], pat[1], result)
except re.error:
pass
# Phase 2: EXISTING/INTERFACE/SERVICE whole-word replacements
for type_name, info in classifications.items():
if type_name.startswith("_"):
continue
category = info.get("category", "")
if category not in ("EXISTING", "INTERFACE", "SERVICE"):
continue
pa_name = info.get("pa") or info.get("service")
if not pa_name:
continue
# Skip very short names to avoid false positives
if len(type_name) < 4:
continue
# Whole-word replacement
result = re.sub(r"\b" + re.escape(type_name) + r"\b", pa_name, result)
return result
# ============================================================
# SUBCOMMAND: migration-diff (P3)
# ============================================================
def _extract_method_bodies(file_path, parser):
"""
Extract method signatures AND bodies from a Java file using tree-sitter.
Returns a list of dicts:
{
"name": str,
"params": [{"type": str, "name": str}, ...],
"line": int,
"body": str | None,
"signature_text": str,
}
"""
source_bytes = Path(file_path).read_bytes()
tree = parser.parse(source_bytes)
methods = []
_walk_for_method_bodies(tree.root_node, source_bytes, methods)
return methods, source_bytes
def _walk_for_method_bodies(node, source_bytes, methods):
"""Walk AST collecting method declarations with bodies."""
if node.type in ("method_declaration", "constructor_declaration"):
is_constructor = node.type == "constructor_declaration"
name_node = node.child_by_field_name("name")
name = _node_text(name_node, source_bytes) if name_node else "?"
# Return type
return_type = ""
if not is_constructor:
type_node = node.child_by_field_name("type")
return_type = _node_text(type_node, source_bytes) if type_node else "void"
# Parameters
params = []
params_node = node.child_by_field_name("parameters")
if params_node:
for param_child in params_node.children:
if param_child.type in ("formal_parameter", "spread_parameter"):
p_type_node = param_child.child_by_field_name("type")
p_name_node = param_child.child_by_field_name("name")
p_type = (
_node_text(p_type_node, source_bytes) if p_type_node else "?"
)
if param_child.type == "spread_parameter":
p_type += "..."
p_name = (
_node_text(p_name_node, source_bytes) if p_name_node else "?"
)
dims_node = param_child.child_by_field_name("dimensions")
if dims_node:
p_type += _node_text(dims_node, source_bytes)
params.append({"type": p_type, "name": p_name})
# Body
body_node = node.child_by_field_name("body")
body_text = _node_text(body_node, source_bytes) if body_node else None
# Build a readable signature
param_str = ", ".join(f"{p['type']} {p['name']}" for p in params)
sig_text = f"{return_type + ' ' if return_type else ''}{name}({param_str})"
methods.append(
{
"name": name,
"return_type": return_type,
"params": params,
"line": node.start_point[0] + 1,
"body": body_text,
"signature_text": sig_text.strip(),
}
)
for child in node.children:
_walk_for_method_bodies(child, source_bytes, methods)
def _normalized_signature_key(sig, classifications):
"""Create a signature key with normalized param types (MC -> PA)."""
normalized_params = []
for p in sig["params"]:
ptype = p["type"]
for type_name, info in classifications.items():
if type_name.startswith("_"):
continue
category = info.get("category", "")
if category not in ("EXISTING", "INTERFACE", "SERVICE"):
continue
pa_name = info.get("pa") or info.get("service")
if not pa_name:
continue
if len(type_name) < 4:
continue
ptype = re.sub(r"\b" + re.escape(type_name) + r"\b", pa_name, ptype)
normalized_params.append(ptype)
return (sig["name"], tuple(normalized_params))
def _scan_unmigrated_types(file_text, classifications):
"""
Scan file text for whole-word occurrences of MC type names that should
have been replaced (EXISTING, INTERFACE, SERVICE with pa/service field).
Returns a list of dicts: { "mc_name": str, "pa_name": str, "lines": [int], "in_import": bool }
"""
results = []
lines = file_text.split("\n")
for type_name, info in classifications.items():
if type_name.startswith("_"):
continue
category = info.get("category", "")
if category not in ("EXISTING", "INTERFACE", "SERVICE"):
continue
pa_name = info.get("pa") or info.get("service")
if not pa_name:
continue
# Skip short names to avoid false positives
if len(type_name) < 4:
continue
pattern = re.compile(r"\b" + re.escape(type_name) + r"\b")
found_lines = []
has_import = False
for i, line in enumerate(lines, 1):
if pattern.search(line):
if line.strip().startswith("import "):
has_import = True
found_lines.append(i)
if found_lines:
results.append(
{
"mc_name": type_name,
"pa_name": pa_name,
"lines": found_lines,
"in_import": has_import,
}
)
return results
def cmd_migration_diff(args):
"""Compare an original mc1710 Java file against a migrated core/ file."""
parser, lang = setup_treesitter_parser()
if parser is None:
print("ERROR: tree-sitter-java not installed", file=sys.stderr)
print("Run: pip install tree-sitter tree-sitter-java", file=sys.stderr)
return 1
project_root = (
Path(args.project_root) if args.project_root else auto_detect_project_root()
)
# Load classifications
classifications_path = args.classifications
classifications = _load_classifications(project_root, classifications_path)
# Resolve files
old_file = resolve_java_file(project_root, args.old)
new_file = resolve_java_file(project_root, args.new)
if old_file is None or not old_file.exists():
print(f"ERROR: Old file not found: {args.old}", file=sys.stderr)
return 1
if new_file is None or not new_file.exists():
print(f"ERROR: New file not found: {args.new}", file=sys.stderr)
return 1
# Count classification types
type_count = sum(1 for k in classifications if not k.startswith("_"))
# ── STEP 1: UNMIGRATED types scan on new file ──
new_text = new_file.read_text(encoding="utf-8", errors="replace")
unmigrated = _scan_unmigrated_types(new_text, classifications)
# ── STEP 2: MISSING_METHOD detection ──
old_methods, _ = _extract_method_bodies(old_file, parser)
new_methods, _ = _extract_method_bodies(new_file, parser)
# Build maps by normalized key
old_method_map = {}
for m in old_methods:
key = _normalized_signature_key(m, classifications)
old_method_map[key] = m
new_method_map = {}
for m in new_methods:
key = _normalized_signature_key(m, classifications)
new_method_map[key] = m
old_keys = set(old_method_map.keys())
new_keys = set(new_method_map.keys())
missing_methods = []
for key in sorted(old_keys - new_keys):
m = old_method_map[key]
missing_methods.append(
{
"name": m["name"],
"signature": m["signature_text"],
"line": m["line"],
}
)
# ── STEP 3: SEMANTIC_BODY_DIFF detection ──
semantic_diffs = []
common_keys = old_keys & new_keys
for key in sorted(common_keys):
old_m = old_method_map[key]
new_m = new_method_map[key]
old_body = old_m.get("body")
new_body = new_m.get("body")
# Skip methods without bodies (abstract/interface)
if old_body is None or new_body is None:
continue
# Normalize both bodies
norm_old = normalize_text(old_body, classifications)
norm_new = normalize_text(new_body, classifications)
# Strip whitespace for comparison
norm_old_lines = norm_old.strip().splitlines()
norm_new_lines = norm_new.strip().splitlines()
# Use SequenceMatcher for similarity ratio
matcher = difflib.SequenceMatcher(None, norm_old_lines, norm_new_lines)
ratio = matcher.ratio()
# Generate unified diff for detail
diff_lines = list(
difflib.unified_diff(
norm_old_lines,
norm_new_lines,
fromfile="old (normalized)",
tofile="new (normalized)",
lineterm="",
)
)
# Filter to actual change lines (not headers)
change_lines = [l for l in diff_lines if l.startswith("+") or l.startswith("-")]
change_lines = [
l
for l in change_lines
if not l.startswith("+++") and not l.startswith("---")
]
if ratio < 0.85 or len(change_lines) > 0:
semantic_diffs.append(
{
"name": old_m["name"],
"signature": old_m["signature_text"],
"ratio": round(ratio, 3),
"diff_lines": diff_lines[:10],
"total_diff_lines": len(change_lines),
"old_line": old_m["line"],
"new_line": new_m["line"],
}
)
# ── VERDICT ──
if missing_methods:
verdict = "FAIL"
verdict_reason = f"{len(missing_methods)} missing method(s)"
elif unmigrated or semantic_diffs:
verdict = "WARN"
parts = []
if unmigrated:
parts.append(f"{len(unmigrated)} unmigrated type(s)")
if semantic_diffs:
parts.append(f"{len(semantic_diffs)} body diff(s)")
verdict_reason = ", ".join(parts)
else:
verdict = "PASS"
verdict_reason = "all methods present, no unmigrated types, no body drift"
note = (
"NOTE: migration-diff cannot detect subtle semantic drift (algorithmic changes, "
"logic reordering, renamed variables). Manual review is always recommended "
"when WARN or FAIL."
)
# ── OUTPUT ──
if args.json_output:
output = {
"old_file": str(old_file),
"new_file": str(new_file),
"unmigrated": unmigrated,
"missing_methods": missing_methods,
"semantic_body_diffs": [
{
"name": d["name"],
"signature": d["signature"],
"similarity_ratio": d["ratio"],
"diff_line_count": d["total_diff_lines"],
"diff_preview": d["diff_lines"],
}
for d in semantic_diffs
],
"verdict": verdict,
"verdict_reason": verdict_reason,
}
json.dump(output, sys.stdout, indent=2)
print()
return 0
# Human-readable output
print()
print("=" * 80)
print("MIGRATION DIFF")
print("=" * 80)
print(f" Old: {old_file.name} (mc1710)")
print(f" New: {new_file.name} (core)")
print(f" Classifications: {Path(classifications_path).name} ({type_count} types)")
print()
# Unmigrated types
if unmigrated:
print(f" UNMIGRATED TYPES ({len(unmigrated)} found in new file):")
for u in unmigrated:
line_str = ", ".join(str(l) for l in u["lines"][:10])
if len(u["lines"]) > 10:
line_str += f" ... ({len(u['lines'])} total)"
import_note = " [import not cleaned]" if u["in_import"] else ""
print(
f" ! {u['mc_name']} -> should be {u['pa_name']} "
f"(found on line {line_str}){import_note}"
)
print()
else:
print(" UNMIGRATED TYPES: None [OK]")
print()
# Missing methods
if missing_methods:
print(
f" MISSING METHODS ({len(missing_methods)} found in old, absent in new):"
)
for m in missing_methods:
print(f" - {m['signature']} [line {m['line']} in old]")
print()
else:
print(" MISSING METHODS: None [OK]")
print()
# Semantic body diffs
if semantic_diffs:
print(
f" SEMANTIC BODY DIFFS ({len(semantic_diffs)} methods have non-migration body changes):"
)
for d in semantic_diffs:
print(f" ~ {d['signature']}")
print(
f" Similarity: {d['ratio']:.1%} "
f"[{d['total_diff_lines']} diff line(s)]"
)
# Show up to 10 diff lines
shown = 0
for dl in d["diff_lines"]:
if shown >= 10:
remaining = len(d["diff_lines"]) - 10
print(f" ... ({remaining} more diff lines)")
break
print(f" {dl}")
shown += 1
print(
f" [!] Manual review required -- could be logic change or safe refactor"
)
print()
else:
print(" SEMANTIC BODY DIFFS: None [OK]")
print()
# Verdict
print(" " + "-" * 78)
print(
f" VERDICT: {verdict} "
f"[{len(unmigrated)} unmigrated type(s), "
f"{len(missing_methods)} missing method(s), "
f"{len(semantic_diffs)} body diff(s)]"
)
print(f" {note}")
print("=" * 80)
print()
return 0
def add_migration_diff_args(subparsers):
p = subparsers.add_parser(
"migration-diff",
help="Compare mc1710 original vs migrated core/ file — PASS/WARN/FAIL verdict (P3)",
)
p.add_argument("--old", required=True, help="Path to original mc1710 Java file")
p.add_argument("--new", required=True, help="Path to migrated core/ Java file")
p.add_argument(
"--classifications",
default="tools/migration/mc_type_classifications.json",
help="Path to classifications JSON (relative to project root)",
)
p.add_argument(
"--json", dest="json_output", action="store_true", help="Output as JSON"
)
p.add_argument("--project-root", help="Project root (auto-detected if omitted)")
p.set_defaults(func=cmd_migration_diff)
# ============================================================
# generate-mappings: Build mc_type_mappings.json from curated classifications
# ============================================================
def cmd_generate_mappings(args):
"""Read mc_type_classifications_original.json and produce mc_type_mappings.json."""
project_root = (
Path(args.project_root) if args.project_root else auto_detect_project_root()
)
cls_path = Path(args.classifications)
if not cls_path.is_absolute():
cls_path = project_root / args.classifications
if not cls_path.exists():
print(f"ERROR: Classifications file not found: {cls_path}", file=sys.stderr)
return 1
with open(cls_path, "r", encoding="utf-8") as f:
cls_data = json.load(f)
types = cls_data.get("types", {})
allowed_categories = None
if args.category:
allowed_categories = set(c.upper() for c in args.category)
mappings = {}
category_counts = defaultdict(int)
for type_name, info in types.items():
if type_name.startswith("__"):
continue
category = info.get("category", "")
if not category:
continue
if allowed_categories and category not in allowed_categories:
continue
category_counts[category] += 1
imports = info.get("imports", 0)
fqn = info.get("fqn", "")
if category == "EXISTING":
pa_name = info.get("pa", "")
pa_package = info.get("package", "")
pa_fqn = f"{pa_package}.{pa_name}" if pa_package and pa_name else ""
entry = {
"fqn": fqn,
"category": category,
"pa_name": pa_name,
"pa_package": pa_package,
"pa_fqn": pa_fqn,
"imports": imports,
"swap": [type_name, pa_name, fqn, pa_fqn],
}
elif category == "INTERFACE":
pa_name = info.get("pa", "")
pa_package = info.get("package", "")
pa_fqn = f"{pa_package}.{pa_name}" if pa_package and pa_name else ""
entry = {
"fqn": fqn,
"category": category,
"pa_name": pa_name,
"pa_package": pa_package,
"pa_fqn": pa_fqn,
"imports": imports,
"swap": [type_name, pa_name, fqn, pa_fqn],
}
if "methods" in info:
entry["methods"] = info["methods"]
if "constructors" in info:
entry["constructors"] = info["constructors"]
elif category == "SERVICE":
entry = {
"fqn": fqn,
"category": category,
"service_target": info.get("service", ""),
"imports": imports,
"methods": info.get("methods", {}),
"swap": None,
}
elif category == "TRANSFORM":
entry = {
"fqn": fqn,
"category": category,
"patterns": info.get("patterns", []),
"imports": imports,
"swap": None,
}
elif category == "SUPPRESS":
entry = {
"fqn": fqn,
"category": category,
"reason": info.get("reason", ""),
"imports": imports,
"swap": None,
}
else:
entry = {
"fqn": fqn,
"category": category,
"imports": imports,
"swap": None,
}
mappings[type_name] = entry
timestamp = datetime.now(timezone.utc).isoformat()
total_types = len(mappings)
cat_order = ["EXISTING", "INTERFACE", "SERVICE", "TRANSFORM", "SUPPRESS"]
categories_summary = {}
for cat in cat_order:
if category_counts.get(cat, 0) > 0:
categories_summary[cat] = category_counts[cat]
for cat, count in sorted(category_counts.items()):
if cat not in categories_summary and count > 0:
categories_summary[cat] = count
output = {
"_meta": {
"generated": timestamp,
"source": cls_path.name,
"total_types": total_types,
"categories": categories_summary,
},
"mappings": mappings,
}
if args.json_output:
json.dump(output, sys.stdout, indent=2, ensure_ascii=True)
print()
else:
output_path = Path(args.output)
if not output_path.is_absolute():
output_path = project_root / args.output
output_path.parent.mkdir(parents=True, exist_ok=True)
with open(output_path, "w", encoding="utf-8") as f:
json.dump(output, f, indent=2, ensure_ascii=False)
f.write("\n")
print()
print("=" * 80)
print("GENERATE MAPPINGS")
print("=" * 80)
print(f" Source: {cls_path.name}")
print(f" Total types: {total_types}")
print()
print(" CATEGORY BREAKDOWN:")
for cat in cat_order:
count = categories_summary.get(cat, 0)
if count > 0:
print(f" {cat:<14}{count:>4} types")
print()
print(f" Output written: {output_path}")
print("=" * 80)
print()
return 0
def add_generate_mappings_args(subparsers):
p = subparsers.add_parser(
"generate-mappings",
help="Generate mc_type_mappings.json from curated classifications",
)
p.add_argument(
"--output",
default="tools/migration/mc_type_mappings.json",
help="Output JSON file path (default: tools/migration/mc_type_mappings.json)",
)
p.add_argument(
"--classifications",
default="tools/migration/mc_type_classifications_original.json",
help="Path to source classifications JSON (relative to project root)",
)
p.add_argument(
"--json",
dest="json_output",
action="store_true",
help="Print output to stdout as JSON instead of writing to file",
)
p.add_argument(
"--category",
nargs="+",
help="Filter to specific categories (e.g. EXISTING INTERFACE)",
)
p.add_argument("--project-root", help="Project root (auto-detected if omitted)")
p.set_defaults(func=cmd_generate_mappings)
# ============================================================
# Main CLI
# ============================================================
def main():
parser = argparse.ArgumentParser(
prog="analyze.py",
description="Unified Diagnostic Toolchain for CustomNPC-Plus Migration Analysis",
)
subparsers = parser.add_subparsers(dest="command", help="Available subcommands")
# Register all subcommands
add_query_type_args(subparsers)
add_query_file_args(subparsers)
add_extract_signatures_args(subparsers)
add_surface_miner_args(subparsers)
add_type_usage_args(subparsers)
add_diff_signatures_args(subparsers)
add_migration_diff_args(subparsers)
add_coverage_report_args(subparsers)
add_classify_args(subparsers)
add_classify_all_args(subparsers)
add_generate_mappings_args(subparsers)
args = parser.parse_args()
if not args.command:
parser.print_help()
return 1
try:
return args.func(args)
except FileNotFoundError as e:
print(f"ERROR: {e}", file=sys.stderr)
return 1
except Exception as e:
print(f"ERROR: {e}", file=sys.stderr)
import traceback
traceback.print_exc(file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())