mirror of
https://github.com/KAMKEEL/CustomNPC-Plus.git
synced 2026-11-10 00:23:44 +00:00
489 lines
17 KiB
Python
489 lines
17 KiB
Python
"""
|
|
batch_migration_diff.py — Batch Migration Diff
|
|
================================================
|
|
|
|
Runs migration-diff logic on ALL migrated files in core/ and produces
|
|
one unified JSON with every file's verdict (PASS/WARN/FAIL/NO_COUNTERPART).
|
|
|
|
Usage:
|
|
python tools/migration/batch_migration_diff.py
|
|
python tools/migration/batch_migration_diff.py --json
|
|
python tools/migration/batch_migration_diff.py --output tools/migration/_batch_diffs.json
|
|
python tools/migration/batch_migration_diff.py --mc-root src/main/java --core-root core/src/main/java
|
|
python tools/migration/batch_migration_diff.py --classifications tools/migration/mc_type_classifications_original.json
|
|
python tools/migration/batch_migration_diff.py --project-root /path/to/project
|
|
"""
|
|
|
|
import sys
|
|
import os
|
|
import json
|
|
import argparse
|
|
import difflib
|
|
import re
|
|
from pathlib import Path
|
|
from datetime import datetime, timezone
|
|
|
|
# Add script directory to path so we can import analyze.py and pa_rules.py
|
|
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
if str(SCRIPT_DIR) not in sys.path:
|
|
sys.path.insert(0, str(SCRIPT_DIR))
|
|
|
|
|
|
def auto_detect_project_root():
|
|
"""Auto-detect project root from script location (tools/migration/ -> root)."""
|
|
return SCRIPT_DIR.parent.parent
|
|
|
|
|
|
def _try_import_analyze():
|
|
"""Try to import functions from analyze.py. Returns a dict of functions or None."""
|
|
try:
|
|
from analyze import (
|
|
setup_treesitter_parser,
|
|
_load_classifications,
|
|
_extract_method_bodies,
|
|
_normalized_signature_key,
|
|
normalize_text,
|
|
_scan_unmigrated_types,
|
|
)
|
|
|
|
return {
|
|
"setup_treesitter_parser": setup_treesitter_parser,
|
|
"_load_classifications": _load_classifications,
|
|
"_extract_method_bodies": _extract_method_bodies,
|
|
"_normalized_signature_key": _normalized_signature_key,
|
|
"normalize_text": normalize_text,
|
|
"_scan_unmigrated_types": _scan_unmigrated_types,
|
|
}
|
|
except ImportError as e:
|
|
print(f"WARNING: Could not import from analyze.py: {e}", file=sys.stderr)
|
|
return None
|
|
|
|
|
|
def _normalize_classifications(data):
|
|
"""Normalize both mc_type_mappings.json and mc_type_classifications_original.json formats."""
|
|
types = data.get("types") or data.get("mappings") or {}
|
|
normalized = {}
|
|
for type_name, info in types.items():
|
|
if type_name.startswith("_"):
|
|
normalized[type_name] = info
|
|
continue
|
|
entry = dict(info)
|
|
if "pa_name" in entry and "pa" not in entry:
|
|
entry["pa"] = entry["pa_name"]
|
|
if "pa_package" in entry and "package" not in entry:
|
|
entry["package"] = entry["pa_package"]
|
|
if "service_name" in entry and "service" not in entry:
|
|
entry["service"] = entry["service_name"]
|
|
normalized[type_name] = entry
|
|
return normalized
|
|
|
|
|
|
def _load_classifications_fallback(project_root, classifications_path):
|
|
cls_file = Path(project_root) / classifications_path
|
|
if not cls_file.exists():
|
|
raise FileNotFoundError(f"Classifications file not found: {cls_file}")
|
|
with open(cls_file, "r", encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
return _normalize_classifications(data)
|
|
|
|
|
|
def _scan_unmigrated_types_fallback(file_text, classifications):
|
|
"""Scan file text for MC type names that should have been replaced. Standalone fallback."""
|
|
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
|
|
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 _resolve_classifications_path(args):
|
|
"""Resolve which classifications file to use."""
|
|
if args.classifications:
|
|
return args.classifications
|
|
# Prefer mc_type_mappings.json if it exists
|
|
project_root = (
|
|
Path(args.project_root) if args.project_root else auto_detect_project_root()
|
|
)
|
|
mappings_path = project_root / "tools" / "migration" / "mc_type_mappings.json"
|
|
if mappings_path.exists():
|
|
return "tools/migration/mc_type_mappings.json"
|
|
# Fallback to original classifications
|
|
original_path = (
|
|
project_root / "tools" / "migration" / "mc_type_classifications_original.json"
|
|
)
|
|
if original_path.exists():
|
|
return "tools/migration/mc_type_classifications_original.json"
|
|
# Default
|
|
return "tools/migration/mc_type_classifications.json"
|
|
|
|
|
|
def run_single_diff(old_file, new_file, parser, classifications, analyze_fns):
|
|
"""
|
|
Run migration-diff logic on a single file pair.
|
|
Returns a result dict with verdict, unmigrated, missing_methods, semantic_body_diffs.
|
|
"""
|
|
result = {
|
|
"unmigrated": [],
|
|
"missing_methods": [],
|
|
"semantic_body_diffs": [],
|
|
}
|
|
|
|
# Step 1: Scan for unmigrated types in new file
|
|
new_text = new_file.read_text(encoding="utf-8", errors="replace")
|
|
if analyze_fns:
|
|
unmigrated = analyze_fns["_scan_unmigrated_types"](new_text, classifications)
|
|
else:
|
|
unmigrated = _scan_unmigrated_types_fallback(new_text, classifications)
|
|
result["unmigrated"] = unmigrated
|
|
|
|
# Step 2 & 3: Method-level analysis (requires tree-sitter)
|
|
if parser is not None and analyze_fns:
|
|
try:
|
|
_extract = analyze_fns["_extract_method_bodies"]
|
|
_norm_key = analyze_fns["_normalized_signature_key"]
|
|
_norm_text = analyze_fns["normalize_text"]
|
|
|
|
old_methods, _ = _extract(old_file, parser)
|
|
new_methods, _ = _extract(new_file, parser)
|
|
|
|
# Build maps by normalized key
|
|
old_method_map = {}
|
|
for m in old_methods:
|
|
key = _norm_key(m, classifications)
|
|
old_method_map[key] = m
|
|
|
|
new_method_map = {}
|
|
for m in new_methods:
|
|
key = _norm_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]
|
|
result["missing_methods"].append(
|
|
{
|
|
"name": m["name"],
|
|
"signature": m["signature_text"],
|
|
"line": m["line"],
|
|
}
|
|
)
|
|
|
|
# Semantic body 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")
|
|
if old_body is None or new_body is None:
|
|
continue
|
|
|
|
norm_old = _norm_text(old_body, classifications)
|
|
norm_new = _norm_text(new_body, classifications)
|
|
|
|
norm_old_lines = norm_old.strip().splitlines()
|
|
norm_new_lines = norm_new.strip().splitlines()
|
|
|
|
matcher = difflib.SequenceMatcher(None, norm_old_lines, norm_new_lines)
|
|
ratio = matcher.ratio()
|
|
|
|
diff_lines = list(
|
|
difflib.unified_diff(
|
|
norm_old_lines,
|
|
norm_new_lines,
|
|
fromfile="old (normalized)",
|
|
tofile="new (normalized)",
|
|
lineterm="",
|
|
)
|
|
)
|
|
change_lines = [
|
|
l
|
|
for l in diff_lines
|
|
if (l.startswith("+") or l.startswith("-"))
|
|
and not l.startswith("+++")
|
|
and not l.startswith("---")
|
|
]
|
|
|
|
if ratio < 0.85 or len(change_lines) > 0:
|
|
result["semantic_body_diffs"].append(
|
|
{
|
|
"name": old_m["name"],
|
|
"signature": old_m["signature_text"],
|
|
"similarity_ratio": round(ratio, 3),
|
|
"diff_line_count": len(change_lines),
|
|
"diff_preview": diff_lines[:10],
|
|
}
|
|
)
|
|
except Exception as e:
|
|
result["note"] = f"tree-sitter analysis error: {str(e)}"
|
|
else:
|
|
result["note"] = "tree-sitter not available"
|
|
|
|
# Verdict
|
|
if result["missing_methods"]:
|
|
result["verdict"] = "FAIL"
|
|
result["verdict_reason"] = f"{len(result['missing_methods'])} missing method(s)"
|
|
elif result["unmigrated"] or result["semantic_body_diffs"]:
|
|
parts = []
|
|
if result["unmigrated"]:
|
|
parts.append(f"{len(result['unmigrated'])} unmigrated type(s)")
|
|
if result["semantic_body_diffs"]:
|
|
parts.append(f"{len(result['semantic_body_diffs'])} body diff(s)")
|
|
result["verdict"] = "WARN"
|
|
result["verdict_reason"] = ", ".join(parts)
|
|
else:
|
|
result["verdict"] = "PASS"
|
|
result["verdict_reason"] = (
|
|
"all methods present, no unmigrated types, no body drift"
|
|
)
|
|
|
|
return result
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="Batch migration-diff: run migration-diff on ALL core/ files"
|
|
)
|
|
parser.add_argument(
|
|
"--mc-root",
|
|
default="src/main/java",
|
|
help="MC 1.7.10 source root (default: src/main/java)",
|
|
)
|
|
parser.add_argument(
|
|
"--core-root",
|
|
default="core/src/main/java",
|
|
help="Core source root (default: core/src/main/java)",
|
|
)
|
|
parser.add_argument(
|
|
"--classifications",
|
|
help="Path to classifications JSON (relative to project root)",
|
|
)
|
|
parser.add_argument(
|
|
"--output",
|
|
default="tools/migration/_batch_diffs.json",
|
|
help="Output JSON file path (relative to project root)",
|
|
)
|
|
parser.add_argument(
|
|
"--json",
|
|
dest="json_output",
|
|
action="store_true",
|
|
help="Print JSON to stdout instead of human-readable output",
|
|
)
|
|
parser.add_argument(
|
|
"--project-root",
|
|
help="Project root (auto-detected if omitted)",
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
project_root = (
|
|
Path(args.project_root) if args.project_root else auto_detect_project_root()
|
|
)
|
|
|
|
# Resolve classifications path
|
|
classifications_path = _resolve_classifications_path(args)
|
|
|
|
# Try to import from analyze.py
|
|
analyze_fns = _try_import_analyze()
|
|
|
|
# Load classifications
|
|
if analyze_fns:
|
|
try:
|
|
classifications = analyze_fns["_load_classifications"](
|
|
project_root, classifications_path
|
|
)
|
|
if not classifications:
|
|
classifications = _load_classifications_fallback(
|
|
project_root, classifications_path
|
|
)
|
|
except (FileNotFoundError, KeyError):
|
|
classifications = _load_classifications_fallback(
|
|
project_root, classifications_path
|
|
)
|
|
else:
|
|
classifications = _load_classifications_fallback(
|
|
project_root, classifications_path
|
|
)
|
|
|
|
# Setup tree-sitter parser
|
|
ts_parser = None
|
|
if analyze_fns:
|
|
try:
|
|
ts_parser, _ = analyze_fns["setup_treesitter_parser"]()
|
|
except Exception:
|
|
pass
|
|
|
|
# Enumerate all .java files in core-root
|
|
core_root = project_root / args.core_root
|
|
mc_root = project_root / args.mc_root
|
|
|
|
if not core_root.exists():
|
|
print(f"ERROR: Core root not found: {core_root}", file=sys.stderr)
|
|
return 1
|
|
|
|
core_files = sorted(core_root.rglob("*.java"))
|
|
total_files = len(core_files)
|
|
|
|
results = []
|
|
summary = {"PASS": [], "WARN": [], "FAIL": [], "NO_COUNTERPART": []}
|
|
files_with_counterpart = 0
|
|
files_without_counterpart = 0
|
|
|
|
for core_file in core_files:
|
|
rel_path = core_file.relative_to(core_root)
|
|
# Normalize to forward slashes for consistency
|
|
rel_path_str = str(rel_path).replace("\\", "/")
|
|
|
|
mc_file = mc_root / rel_path
|
|
|
|
if mc_file.exists():
|
|
files_with_counterpart += 1
|
|
diff_result = run_single_diff(
|
|
mc_file, core_file, ts_parser, classifications, analyze_fns
|
|
)
|
|
|
|
entry = {
|
|
"relative_path": rel_path_str,
|
|
"old_file": str(Path(args.mc_root) / rel_path).replace("\\", "/"),
|
|
"new_file": str(Path(args.core_root) / rel_path).replace("\\", "/"),
|
|
"verdict": diff_result["verdict"],
|
|
"verdict_reason": diff_result["verdict_reason"],
|
|
"unmigrated_count": len(diff_result["unmigrated"]),
|
|
"missing_methods_count": len(diff_result["missing_methods"]),
|
|
"semantic_diffs_count": len(diff_result["semantic_body_diffs"]),
|
|
"unmigrated": diff_result["unmigrated"],
|
|
"missing_methods": diff_result["missing_methods"],
|
|
"semantic_body_diffs": diff_result["semantic_body_diffs"],
|
|
}
|
|
if "note" in diff_result:
|
|
entry["note"] = diff_result["note"]
|
|
|
|
results.append(entry)
|
|
summary[diff_result["verdict"]].append(rel_path_str)
|
|
else:
|
|
files_without_counterpart += 1
|
|
entry = {
|
|
"relative_path": rel_path_str,
|
|
"verdict": "NO_COUNTERPART",
|
|
"verdict_reason": "No mc1710 original found",
|
|
"new_file": str(Path(args.core_root) / rel_path).replace("\\", "/"),
|
|
}
|
|
results.append(entry)
|
|
summary["NO_COUNTERPART"].append(rel_path_str)
|
|
|
|
# Build output
|
|
output = {
|
|
"_meta": {
|
|
"generated": datetime.now(timezone.utc).isoformat(),
|
|
"mc_root": args.mc_root,
|
|
"core_root": args.core_root,
|
|
"classifications_source": classifications_path,
|
|
"total_files_in_core": total_files,
|
|
"files_with_counterpart": files_with_counterpart,
|
|
"files_without_counterpart": files_without_counterpart,
|
|
"passed": len(summary["PASS"]),
|
|
"warned": len(summary["WARN"]),
|
|
"failed": len(summary["FAIL"]),
|
|
},
|
|
"results": results,
|
|
"summary": summary,
|
|
}
|
|
|
|
if args.json_output:
|
|
json.dump(output, sys.stdout, indent=2)
|
|
print()
|
|
return 0
|
|
|
|
# Human-readable output
|
|
print()
|
|
print(f"Batch Migration Diff — {total_files} core files analyzed")
|
|
print(f" With counterpart: {files_with_counterpart}")
|
|
print(f" Without counterpart: {files_without_counterpart}")
|
|
print()
|
|
print(f" PASS: {len(summary['PASS']):>4}")
|
|
print(f" WARN: {len(summary['WARN']):>4}")
|
|
print(f" FAIL: {len(summary['FAIL']):>4}")
|
|
print()
|
|
|
|
if summary["FAIL"]:
|
|
print("FAILED FILES:")
|
|
for rel in summary["FAIL"]:
|
|
entry = next(r for r in results if r["relative_path"] == rel)
|
|
print(f" {rel}")
|
|
if entry.get("missing_methods"):
|
|
methods = ", ".join(m["name"] for m in entry["missing_methods"])
|
|
print(
|
|
f" - {len(entry['missing_methods'])} missing methods: {methods}"
|
|
)
|
|
if entry.get("unmigrated"):
|
|
types = ", ".join(u["mc_name"] for u in entry["unmigrated"])
|
|
print(f" - {len(entry['unmigrated'])} unmigrated types: {types}")
|
|
print()
|
|
|
|
if summary["WARN"]:
|
|
shown = min(5, len(summary["WARN"]))
|
|
print(f"WARNED FILES (showing first {shown}):")
|
|
for rel in summary["WARN"][:shown]:
|
|
entry = next(r for r in results if r["relative_path"] == rel)
|
|
parts = []
|
|
if entry.get("semantic_body_diffs"):
|
|
sims = [
|
|
str(round(d["similarity_ratio"] * 100)) + "%"
|
|
for d in entry["semantic_body_diffs"]
|
|
]
|
|
parts.append(
|
|
f"{len(entry['semantic_body_diffs'])} body diffs (similarity: {', '.join(sims)})"
|
|
)
|
|
if entry.get("unmigrated"):
|
|
parts.append(f"{len(entry['unmigrated'])} unmigrated types")
|
|
print(f" {rel}")
|
|
for part in parts:
|
|
print(f" ~ {part}")
|
|
if len(summary["WARN"]) > shown:
|
|
print(f" ... and {len(summary['WARN']) - shown} more")
|
|
print()
|
|
|
|
# Write JSON output file
|
|
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)
|
|
print(f"Results written to: {args.output}")
|
|
print()
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|