#!/usr/bin/env python3 """ Full reactor audit: every *.java -> top-level FQN -> javap -p vs deployment jars/classes. Usage: python3 tools/deploy_javap_audit.py [--workers N] [--write-json PATH] [--write-markdown PATH] """ from __future__ import annotations import argparse import json import os import subprocess import sys import zipfile from concurrent.futures import ThreadPoolExecutor, as_completed from pathlib import Path from typing import Any, Dict, List, Optional, Set, Tuple REACTOR = Path(__file__).resolve().parents[1] DEPLOY_ROOT = REACTOR.parents[1] / "部署包" / "ninca_common_component_organization_01-ninca_common_component_organization" DEPLOY_UNPACK = DEPLOY_ROOT / "ninca-common-component-organization-V2.9.2_20210730" DEPLOY_LIB = DEPLOY_UNPACK / "BOOT-INF" / "lib" DEPLOY_CLASSES = DEPLOY_UNPACK / "BOOT-INF" / "classes" DEPLOY_JARS = [ "cwos-component-organization-interface-v2.9.2_xinghewan.jar", "cwos-component-organization-data-v2.9.2_xinghewan.jar", "cwos-component-organization-service-v2.9.2_xinghewan.jar", "cwos-component-organization-web-v2.9.2_xinghewan.jar", ] def deploy_classpath() -> str: jars = [str(DEPLOY_LIB / j) for j in DEPLOY_JARS] return os.pathsep.join([str(DEPLOY_CLASSES)] + jars) def local_classpath() -> str: parts: List[str] = [] for mod in [ "cwos-component-organization-interface", "cwos-component-organization-data", "cwos-component-organization-service", "cwos-component-organization-web", "cwos-component-organization-starter", ]: tc = REACTOR / mod / "target" / "classes" if tc.is_dir(): parts.append(str(tc)) return os.pathsep.join(parts) def collect_java_files(main_only: bool = True) -> List[Path]: out: List[Path] = [] for root, _, files in os.walk(REACTOR): r = root.replace("\\", "/") if "/target/" in r: continue if main_only and "/src/main/java" not in r: continue for f in files: if f.endswith(".java"): out.append(Path(root) / f) out.sort() return out def fqn_from_path(java_path: Path) -> Optional[str]: try: parts = java_path.resolve().relative_to(REACTOR).parts except ValueError: return None anchor = None for i in range(len(parts) - 1): if parts[i : i + 3] == ("src", "main", "java"): anchor = i + 3 break if anchor is None: return None rel = parts[anchor:] if not rel: return None stem = Path(rel[-1]).stem pkg_parts = rel[:-1] if stem == "package-info": return ".".join(pkg_parts) + ".package-info" return ".".join(pkg_parts) + "." + stem def javap(cp: str, fqn: str) -> Tuple[int, str]: try: p = subprocess.run( ["javap", "-p", "-classpath", cp, fqn], capture_output=True, text=True, timeout=30, ) err = (p.stderr or "").strip() out = (p.stdout or "").strip() if p.returncode != 0: return p.returncode, err or out return 0, out except Exception as e: return 99, str(e) def normalize_javap(text: str) -> str: lines = [] for line in text.replace("\r\n", "\n").split("\n"): lines.append(line.rstrip()) return "\n".join(lines).strip() + "\n" def outer_fqn_from_class_path(entry: str) -> Optional[str]: """BOOT-INF path cn/foo/Bar.class or cn/foo/Bar$1.class -> cn.foo.Bar""" if not entry.endswith(".class") or "META-INF" in entry: return None if not entry.endswith(".class"): return None rel = entry.replace("/", ".")[: -len(".class")] if "$" in rel: rel = rel.split("$", 1)[0] return rel def deploy_outer_classes() -> Set[str]: """Union of outer-type FQNs present in deploy jars + BOOT-INF/classes.""" out: Set[str] = set() if DEPLOY_CLASSES.is_dir(): for p in DEPLOY_CLASSES.rglob("*.class"): rel = p.relative_to(DEPLOY_CLASSES).as_posix() o = outer_fqn_from_class_path(rel) if o: out.add(o) for jname in DEPLOY_JARS: jpath = DEPLOY_LIB / jname if not jpath.is_file(): continue with zipfile.ZipFile(jpath, "r") as z: for name in z.namelist(): o = outer_fqn_from_class_path(name) if o: out.add(o) return out def audit_one( args: Tuple[str, str, str, str] ) -> Dict[str, Any]: rel_file, fqn, d_cp, l_cp = args rc_d, out_d = javap(d_cp, fqn) rc_l, out_l = javap(l_cp, fqn) row: Dict[str, Any] = {"file": rel_file, "fqn": fqn} if rc_l != 0: row["status"] = "MISSING_LOCAL_JAVAP" row["note"] = (out_l or "")[:800] return row if rc_d != 0: row["status"] = "MISSING_DEPLOY_JAVAP" row["note"] = (out_d or "")[:800] return row nd, nl = normalize_javap(out_d), normalize_javap(out_l) if nd == nl: row["status"] = "IDENTICAL" row["note"] = "" else: row["status"] = "DIFFERENT" row["note"] = f"lines deploy={len(nd.splitlines())} local={len(nl.splitlines())}" return row def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--workers", type=int, default=12) ap.add_argument( "--all-sources", action="store_true", help="Include src/test and other trees (deploy JAR will miss most tests).", ) ap.add_argument("--write-json", type=Path, default=None) ap.add_argument("--write-markdown", type=Path, default=None) args = ap.parse_args() if not DEPLOY_LIB.is_dir(): print("Deploy lib not found:", DEPLOY_LIB, file=sys.stderr) return 2 d_cp = deploy_classpath() l_cp = local_classpath() java_list = collect_java_files(main_only=not args.all_sources) tasks: List[Tuple[str, str, str, str]] = [] skipped = 0 for jp in java_list: fqn = fqn_from_path(jp) rel = str(jp.relative_to(REACTOR)) if not fqn: skipped += 1 continue tasks.append((rel, fqn, d_cp, l_cp)) rows: List[Dict[str, Any]] = [] for jp in java_list: fqn = fqn_from_path(jp) rel = str(jp.relative_to(REACTOR)) if not fqn: rows.append( { "file": rel, "fqn": None, "status": "SKIP_PATH", "note": "could not derive FQN", } ) with ThreadPoolExecutor(max_workers=max(1, args.workers)) as ex: futs = {ex.submit(audit_one, t): t for t in tasks} for fut in as_completed(futs): rows.append(fut.result()) # preserve stable sort by file path rows_sorted = sorted(rows, key=lambda r: (r.get("file") or "")) summary_counts: Dict[str, int] = {} for r in rows_sorted: s = r.get("status") or "?" summary_counts[s] = summary_counts.get(s, 0) + 1 source_fqns = {r["fqn"] for r in rows_sorted if r.get("fqn")} deploy_outer = deploy_outer_classes() only_deploy = sorted(deploy_outer - source_fqns) summary = { "reactor": str(REACTOR), "deploy_unpack": str(DEPLOY_UNPACK), "scope_main_java_only": not args.all_sources, "java_files_total": len(java_list), "skipped_non_java_path": skipped, "status_counts": summary_counts, "deploy_only_outer_types": len(only_deploy), } print(json.dumps(summary, indent=2, ensure_ascii=False)) if args.write_json: args.write_json.parent.mkdir(parents=True, exist_ok=True) args.write_json.write_text( json.dumps( {"summary": summary, "rows": rows_sorted, "deploy_only_outer_fqns": only_deploy}, indent=2, ensure_ascii=False, ), encoding="utf-8", ) print("Wrote JSON:", args.write_json) if args.write_markdown: write_markdown_report( args.write_markdown, summary, rows_sorted, only_deploy, ) print("Wrote MD:", args.write_markdown) return 0 def write_markdown_report( path: Path, summary: Dict[str, Any], rows: List[Dict[str, Any]], only_deploy: List[str], ) -> None: path.parent.mkdir(parents=True, exist_ok=True) lines: List[str] = [] lines.append("# 组织组件:全量 Java 文件 vs 部署包 javap 核对报告\n") lines.append(f"- **反应堆:** `{summary['reactor']}`\n") lines.append(f"- **部署解压:** `{summary['deploy_unpack']}`\n") scope = "仅 `src/main/java`(与 Fat JAR 生产字节码对齐)" if summary.get("scope_main_java_only") else "含测试等全部 `.java`(`--all-sources`)" lines.append(f"- **范围:** {scope}\n") lines.append("- **核对方式:** 每个 `*.java` 推导顶层 FQN,`javap -p` 与部署 classpath / 本地 `target/classes` 对比。\n") lines.append("\n## 1. 汇总\n\n") lines.append("| 状态 | 数量 |\n|------|------|\n") for k, v in sorted(summary["status_counts"].items()): lines.append(f"| {k} | {v} |\n") lines.append(f"\n- **部署包中存在、源码树无对应 `.java` 的顶层类型(估):** {summary['deploy_only_outer_types']} 个(见附录 B)。\n") diff_rows = [r for r in rows if r.get("status") == "DIFFERENT"] miss_d = [r for r in rows if r.get("status") == "MISSING_DEPLOY_JAVAP"] miss_l = [r for r in rows if r.get("status") == "MISSING_LOCAL_JAVAP"] lines.append("\n## 2. 解决方案与处置建议(按现象)\n\n") lines.append("### 2.1 `DIFFERENT`(签名不一致)\n\n") lines.append("- **含义:** 同 FQN 下,现场 JAR 与当前编译产物的 **字段/方法列表等对外形状** 不一致(含编译器生成的 synthetic/lambda 差异)。\n") lines.append("- **建议:**\n") lines.append(" 1. 以业务为准明确「权威版本」:若现场为基线,则 **检出与现场一致源码** 或在 CI 中 **与现场 JAR 做契约测试**;若仓库为权威,则 **升版发布** 替换现场。\n") lines.append(" 2. 对差异类做 **关键路径回归**(人员/图库/设备同步 API)。\n") lines.append(" 3. 对纯 lambda/synthetic 差异可辅以 **`javap -c` 抽样** 判断是否仅为编译差异。\n") lines.append(f"\n**本仓 DIFFERENT 数量:** {len(diff_rows)}(完整列表见 JSON `rows` 或下表节选)。\n") lines.append("\n### 2.2 `MISSING_DEPLOY_JAVAP`\n\n") lines.append("- **含义:** 本地可 `javap`,部署 classpath 中 **找不到该类**(多为 **仓库新增类**,现场包尚未包含)。\n") lines.append("- **建议:** 纳入发布变更说明,**部署新 Fat JAR** 或通过配置开关控制新功能。\n") lines.append(f"\n**数量:** {len(miss_d)}\n") lines.append("\n### 2.3 `MISSING_LOCAL_JAVAP`\n\n") lines.append("- **含义:** 源码存在但 **未编译进 target/classes**(工程错误、条件编译、或 `package-info` 等特殊文件)。\n") lines.append("- **建议:** `mvn clean compile`;检查模块归属;`package-info` 可忽略或单独标注。\n") lines.append(f"\n**数量:** {len(miss_l)}\n") lines.append("\n### 2.4 附录 B:部署侧多出类型\n\n") lines.append("- **含义:** 现场 JAR 内含 **Starter 配置类、生成器、旧版独占类等**,当前仓库 **未以 `.java` 形式收录**(尤其 `cwos-component-organization-starter` 仅保留 `OrganizationServer`)。\n") lines.append("- **建议:** 从现场 Fat JAR **反编译或回收历史分支** 补齐 Starter 与缺失资源(MyBatis XML、`component-org/messages*.properties`),使仓库可 **重现现场构建**。\n") lines.append("\n## 3. DIFFERENT / MISSING 文件表(全量)\n\n") lines.append("| 状态 | FQN | 文件 |\n|------|-----|------|\n") for r in rows: st = r.get("status", "") if st in ("IDENTICAL", "SKIP_PATH"): continue fq = r.get("fqn") or "" fn = r.get("file") or "" lines.append(f"| {st} | `{fq}` | `{fn}` |\n") lines.append("\n## 4. 附录 A:IDENTICAL 统计\n\n") lines.append(f"- 与部署 `javap` **完全一致** 的顶层类数量:**{summary['status_counts'].get('IDENTICAL', 0)}**(明细见 JSON)。\n") lines.append("\n## 5. 附录 B:部署包顶层类型(源码无同名 `.java` 路径推导)节选\n\n") for fq in only_deploy[:200]: lines.append(f"- `{fq}`\n") if len(only_deploy) > 200: lines.append(f"\n… 另有 {len(only_deploy) - 200} 项,见 JSON `deploy_only_outer_fqns`。\n") path.write_text("".join(lines), encoding="utf-8") if __name__ == "__main__": sys.exit(main())