#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ gen_refgraph.py — STM32F4-Base 项目文件引用关系图生成器 ===================================================================== 功能: 扫描仓库内全部 C 源文件(.c/.h),解析 #include 指令,按 Keil 工程 (MDK-ARM/STM32F407-Demo.uvprojx) 与 LVGL 模拟器 (simulator/CMakeLists.txt) 的包含路径将引用解析到具体文件,构建文件级引用关系图,输出 Graphviz DOT / SVG / PNG 与结构化 JSON 数据。 产物(默认输出到 docs/images/,随脚本一并提交,项目变化后重跑更新): 引用关系图.dot / .svg / .png —— 全量文件级引用图(一手指方+第三方) 引用关系图_模块.dot / .svg —— 模块级概览图(目录/库聚合) 引用关系图.json —— 结构化数据(节点/边/未解析引用) 用法(仓库根目录): & '.venv\\Scripts\\python.exe' tools/refgraph/gen_refgraph.py 或双击/运行 tools/refgraph/gen_refgraph.bat 选项: --root PATH 仓库根目录(默认:脚本上两级目录) --outdir PATH 产物目录(默认 /docs/images) --views a,b 要生成的视图,可选 full / module(默认两者) --edge-scope S project = 仅第一方代码发起的引用边(默认,图最清晰); all = 含第三方内部引用边(完整但密集,淡显) --no-render 只生成 .dot/.json,不调用 Graphviz 渲染 --quiet 只输出关键行 引用解析规则(与编译器搜索顺序一致): 1. "..." 引用优先按所在文件目录解析,再按包含路径顺序查找; 2. <...> 引用按包含路径顺序查找; 3. 仍解析不到 → 归入“外部/系统头文件”(如 stdio.h/stdint.h), 明细见 JSON 的 unresolved 字段。 注:刻意不做“基名全库兜底”——仓库内存在同名系统头文件 (rapidjson 自带 msinttypes/stdint.h)时会把 误连到 库内部文件产生假边;编译器的系统目录不在仓库内,统一归外部。 说明: - 纯标准库实现,无第三方依赖;渲染依赖 Graphviz(dot 可执行文件), 未安装时自动跳过渲染(仍产出 .dot,可用任意 Graphviz 渲染)。 - 文本层解析(不预编译):#if 0 内的引用、MCU/模拟器双端平台差异 引用均按“文件存在引用”计入,完整呈现项目级引用面。 - 第一方代码 = App/、Inc/、Src/、test/、simulator/(SDL2 与 build 除外)、 Drivers/BSP/(NET/lftpd 除外,其为移植的第三方组件); 其余(Lib/、Middlewares/、Drivers/CMSIS、HAL、lftpd、SDL2)= 第三方。 维护: simulator/CMakeLists.txt 的 include_directories 若变化,请同步下方 SIM_INCLUDE_DIRS;Keil 工程包含路径变化则自动跟随(解析自 uvprojx)。 """ from __future__ import annotations import argparse import datetime as _dt import json import os import re import shutil import subprocess import sys # --------------------------------------------------------------------------- # 常量:第一方判定 / 排除目录 / 模块与库划分 # --------------------------------------------------------------------------- # 文件索引时整体跳过的目录(按相对路径前缀,'/' 分隔) EXCLUDE_PREFIXES = ("MDK-ARM", "docs", "simulator/build") # 按目录名跳过(任意层级) EXCLUDE_DIRNAMES = (".git", ".venv", "node_modules") # simulator/CMakeLists.txt 的 include_directories(相对仓库根;保持同步) SIM_INCLUDE_DIRS = ( "simulator", "simulator/sim", "Lib/lvgl", "Lib/lvgl/src", "Drivers/BSP/UI", "Drivers/BSP/NET", "Drivers/BSP/NETCFG", "App", "App/log", "Drivers/BSP", "simulator/SDL2/SDL2-2.32.10/include", ) # 模块级视图:第一方模块(长前缀优先匹配) FP_MODULES = ( ("App/log", "App/log"), ("App/task", "App/task"), ("App/util", "App/util"), ("App", "App"), ("test", "test"), ("simulator", "simulator"), ("Drivers/BSP/CH395F", "BSP/CH395F"), ("Drivers/BSP/GD5F2GQ5UE", "BSP/GD5F2GQ5UE"), ("Drivers/BSP/NETCFG", "BSP/NETCFG"), ("Drivers/BSP/NET", "BSP/NET"), ("Drivers/BSP/RS485", "BSP/RS485"), ("Drivers/BSP/SD2506", "BSP/SD2506"), ("Drivers/BSP/TPAFE5160", "BSP/TPAFE5160"), ("Drivers/BSP/UI", "BSP/UI"), ("Drivers/BSP", "BSP(公共)"), # Src/ 与 Inc/ 为 CubeMX 配对生成(main.c↔main.h 等),模块级合并 ("Src", "Src/Inc (CubeMX)"), ("Inc", "Src/Inc (CubeMX)"), ) # 模块级视图:第三方库(长前缀优先匹配) TP_LIBS = ( ("Lib/lvgl", "LVGL"), ("Middlewares/Third_Party/FreeRTOS", "FreeRTOS"), ("Drivers/CMSIS", "CMSIS"), ("Drivers/STM32F4xx_HAL_Driver", "HAL"), ("Lib/FatFs", "FatFs"), ("Lib/dhara", "dhara"), ("Drivers/BSP/NET/lftpd", "lftpd"), ("simulator/SDL2", "SDL2"), ) EXT_NODE = "外部/系统头文件" # 颜色方案 C_FP, C_FP_BORDER = "#dbeafe", "#1d4ed8" C_TP, C_TP_BORDER = "#f3f4f6", "#9ca3af" C_EXT, C_EXT_BORDER = "#fef3c7", "#d97706" E_FPFP, E_FPFP_W = "#2563eb", 1.0 E_FPTP, E_FPTP_W = "#94a3b8", 0.7 E_TPTP, E_TPTP_W = "#e2e8f0", 0.5 E_EXT, E_EXT_W = "#f59e0b", 0.6 # --------------------------------------------------------------------------- # 读取 / 编码 # --------------------------------------------------------------------------- def read_text(path: str) -> str: """按项目约定读文本:UTF-8(含 BOM)→ GBK → Latin-1 兜底。""" with open(path, "rb") as f: data = f.read() if data.startswith(b"\xef\xbb\xbf"): data = data[3:] for enc in ("utf-8", "gbk", "latin-1"): try: return data.decode(enc) except UnicodeDecodeError: continue return data.decode("utf-8", errors="replace") # 理论不可达 # --------------------------------------------------------------------------- # 工程信息:包含路径 / 文件索引 / 分类 # --------------------------------------------------------------------------- def classify(rel: str) -> str: """rel 为 '/' 分隔相对路径;返回 'fp'(第一方) 或 'tp'(第三方)。""" if rel.startswith(("App/", "Inc/", "Src/", "test/")): return "fp" if rel.startswith("simulator/"): if rel.startswith(("simulator/SDL2/", "simulator/build/")): return "tp" return "fp" if rel.startswith("Drivers/BSP/"): if rel.startswith("Drivers/BSP/NET/lftpd/"): return "tp" return "fp" return "tp" def load_include_dirs(root: str) -> list[str]: """合并 Keil 工程(uvprojx)与模拟器的包含目录,按序去重。""" dirs: list[str] = [] seen: set[str] = set() def add(p: str) -> None: ap = os.path.normpath(p) if ap not in seen and os.path.isdir(ap): seen.add(ap) dirs.append(ap) uvprojx = os.path.join(root, "MDK-ARM", "STM32F407-Demo.uvprojx") if os.path.isfile(uvprojx): text = read_text(uvprojx) for m in re.finditer(r"(.*?)", text, re.S): for part in m.group(1).replace("\\", "/").split(";"): part = part.strip() if part: add(os.path.join(root, "MDK-ARM", part)) for part in SIM_INCLUDE_DIRS: add(os.path.join(root, *part.split("/"))) return dirs def build_index(root: str) -> list[tuple[str, str]]: """收集仓库内全部 .c/.h 文件 → [(绝对路径, 相对路径)]。""" out: list[tuple[str, str]] = [] for dirpath, dirnames, filenames in os.walk(root): rel_dir = os.path.relpath(dirpath, root).replace(os.sep, "/") if rel_dir == ".": rel_dir = "" if any(seg in EXCLUDE_DIRNAMES for seg in rel_dir.split("/")): dirnames[:] = [] continue if any(rel_dir == p or rel_dir.startswith(p + "/") for p in EXCLUDE_PREFIXES): dirnames[:] = [] continue for fn in sorted(filenames): if fn.lower().endswith((".c", ".h")): out.append((os.path.join(dirpath, fn), f"{rel_dir}/{fn}" if rel_dir else fn)) return out class Resolver: """#include 名称 → 仓库内文件 的解析器(顺序与编译器一致)。 仅按“文件目录 + 包含路径”解析(编译器实际使用的搜索顺序)。 刻意不做“基名全库兜底”:仓库内恰好存在同名的系统头文件 (如 rapidjson 自带的 msinttypes/stdint.h)时会把 之类误连到库内部文件,产生虚假引用边。解析不到的 include 一律归入“外部/系统头文件”,与编译器的系统目录行为一致。 """ def __init__(self, root: str, files: list[tuple[str, str]], inc_dirs: list[str]): self.root = root self.inc_dirs = inc_dirs # (包含目录, 相对名小写) → 绝对路径 self.by_increl: dict[tuple[str, str], str] = {} for d in inc_dirs: dl = d.lower() for ap, _ in files: apl = ap.lower() if apl.startswith(dl + os.sep): rel = ap[len(d):].lstrip(os.sep).replace(os.sep, "/") key = (d, rel.lower()) self.by_increl.setdefault(key, ap) def resolve(self, name: str, from_file: str, quoted: bool) -> str | None: name = name.strip() if not name: return None fname = name.replace("/", os.sep) # 1) 所在文件目录("..." 标准行为;<...> 亦允许相对,如 "../x.h") if os.path.isabs(name): return None cand = os.path.normpath(os.path.join(os.path.dirname(from_file), fname)) if os.path.isfile(cand): return cand # 2) 包含路径顺序查找(精确相对名;Windows 不区分大小写) for d in self.inc_dirs: cand = os.path.normpath(os.path.join(d, fname)) if os.path.isfile(cand): return cand hit = self.by_increl.get((d, name.lower())) if hit: return hit # 3) 仓库根 cand = os.path.normpath(os.path.join(self.root, fname)) if os.path.isfile(cand): return cand return None # --------------------------------------------------------------------------- # #include 解析 # --------------------------------------------------------------------------- INC_RE = re.compile(r'^\s*#\s*include\s*(?:"(?P[^"\r\n]*)"|<(?P[^>\r\n]+)>)') def parse_includes(path: str) -> list[tuple[str, bool, int]]: """返回 [(include名, 是否引号式, 行号)];处理行尾反斜杠续行。""" text = read_text(path) lines = text.splitlines() n = len(lines) out: list[tuple[str, bool, int]] = [] i = 0 while i < n: line = lines[i] if line.lstrip()[:1] != "#": i += 1 continue m = INC_RE.match(line) if not m: i += 1 continue # 续行拼接后再匹配 logical, j = line, i while logical.rstrip().endswith("\\") and j + 1 < n: logical = logical.rstrip()[:-1] + lines[j + 1] j += 1 m = INC_RE.match(logical) if m: quoted = m.group("q") is not None name = (m.group("q") if quoted else m.group("a") or "").strip() if name: out.append((name, quoted, i + 1)) i = j + 1 return out # --------------------------------------------------------------------------- # DOT 输出 # --------------------------------------------------------------------------- def dot_escape(s: str) -> str: return s.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n") def file_module(rel: str) -> str: for prefix, label in FP_MODULES: if rel == prefix or rel.startswith(prefix + "/"): return label return "其他(第一方)" def file_lib(rel: str) -> str: for prefix, label in TP_LIBS: if rel == prefix or rel.startswith(prefix + "/"): return label return "其他(第三方)" class Graph: def __init__(self) -> None: self.nodes: dict[str, dict] = {} # rel -> {cls, label, ...} self.edges: dict[tuple[str, str], dict] = {} # (from,to) -> {kind,...} self.edge_list: list[dict] = [] # JSON 用(含 include 明细) def add_node(self, rel: str, cls: str) -> None: if rel not in self.nodes: self.nodes[rel] = {"path": rel, "class": cls} def add_edge(self, src: str, dst: str, kind: str, include: str | None = None) -> None: key = (src, dst) if key not in self.edges: self.edges[key] = {"kind": kind} self.edge_list.append({"from": src, "to": dst, "kind": kind, "include": include}) # ---- 全量文件级视图 ---- def emit_dot_full(self, title: str, edge_scope: str) -> str: # project 模式:只显示第一方文件发起的边(第一方 → 任意), # 隐藏第三方内部引用网(tp→tp / tp→外部),节点随之收敛。 fp_nodes = {r for r, nd in self.nodes.items() if nd["class"] == "fp"} if edge_scope == "project": vis_edges = {k: v for k, v in self.edges.items() if k[0] in fp_nodes} vis_nodes = set(fp_nodes) | {d for (_, d) in vis_edges} else: vis_edges, vis_nodes = self.edges, set(self.nodes) L: list[str] = [] L.append("digraph refgraph_full {") L.append( ' graph [rankdir=LR, bgcolor="white", labelloc=t, ' f'label="{dot_escape(title)}", fontname="Microsoft YaHei", fontsize=18];' ) L.append(' node [fontname="Microsoft YaHei", fontsize=10, shape=box, style="rounded,filled"];') L.append(" edge [fontname=\"Microsoft YaHei\", fontsize=8];") for rel in sorted(vis_nodes): nd = self.nodes[rel] cls = nd["class"] if cls == "fp": fill, border = C_FP, C_FP_BORDER elif cls == "ext": fill, border = C_EXT, C_EXT_BORDER L.append(f' "{dot_escape(EXT_NODE)}" [shape=ellipse, fillcolor="{fill}", color="{border}", fontsize=12];') continue else: fill, border = C_TP, C_TP_BORDER L.append(f' "{dot_escape(rel)}" [fillcolor="{fill}", color="{border}"];') # 边 for (src, dst), ed in sorted(vis_edges.items()): kind = ed["kind"] if kind == "fp-fp": spec = f'color="{E_FPFP}", penwidth={E_FPFP_W}' elif kind == "fp-tp": spec = f'color="{E_FPTP}", penwidth={E_FPTP_W}' elif kind == "fp-ext": spec = f'color="{E_EXT}", penwidth={E_EXT_W}, style=dashed' elif kind == "tp-tp": spec = f'color="{E_TPTP}", penwidth={E_TPTP_W}, style=dotted' else: # tp-ext spec = f'color="{E_EXT}", penwidth={E_EXT_W * 0.7}, style=dashed' dst_q = dot_escape(EXT_NODE) if dst == EXT_NODE else dot_escape(dst) L.append(f' "{dot_escape(src)}" -> "{dst_q}" [{spec}];') L.append("}") return "\n".join(L) + "\n" # ---- 模块级视图 ---- def emit_dot_module(self, title: str) -> str: mod_edges: dict[tuple[str, str], dict] = {} mod_nodes: dict[str, str] = {} # label -> cls for rel, nd in self.nodes.items(): if nd["class"] == "fp": mod_nodes.setdefault(file_module(rel), "fp") elif nd["class"] == "tp": mod_nodes.setdefault(file_lib(rel), "tp") if EXT_NODE in self.nodes: mod_nodes[EXT_NODE] = "ext" for (src, dst), ed in self.edges.items(): if dst == EXT_NODE: msrc = file_module(src) if self.nodes[src]["class"] == "fp" else file_lib(src) key = (msrc, EXT_NODE) else: sc = self.nodes[src]["class"] msrc = file_module(src) if sc == "fp" else file_lib(src) mc = self.nodes[dst]["class"] mdst = file_module(dst) if mc == "fp" else file_lib(dst) key = (msrc, mdst) if key[0] == key[1]: continue e = mod_edges.setdefault(key, {"n": 0, "kind": ed["kind"]}) e["n"] += 1 L: list[str] = [] L.append("digraph refgraph_module {") L.append( ' graph [rankdir=LR, bgcolor="white", labelloc=t, ' f'label="{dot_escape(title)}", fontname="Microsoft YaHei", fontsize=18];' ) L.append(' node [fontname="Microsoft YaHei", fontsize=12, shape=box, style="rounded,filled"];') L.append(" edge [fontname=\"Microsoft YaHei\", fontsize=10, fontcolor=\"#334155\"];") for label, cls in sorted(mod_nodes.items()): if cls == "fp": fill, border = C_FP, C_FP_BORDER elif cls == "ext": fill, border = C_EXT, C_EXT_BORDER L.append(f' "{dot_escape(label)}" [shape=ellipse, fillcolor="{fill}", color="{border}"];') continue else: fill, border = C_TP, C_TP_BORDER L.append(f' "{dot_escape(label)}" [fillcolor="{fill}", color="{border}"];') for (s, d), e in sorted(mod_edges.items()): strong = e["kind"] in ("fp-fp", "fp-tp") color = "#2563eb" if strong else "#94a3b8" w = 1.2 if strong else 0.7 lbl = f"×{e['n']}" ds = dot_escape(EXT_NODE) if d == EXT_NODE else dot_escape(d) L.append(f' "{dot_escape(s)}" -> "{ds}" [color="{color}", penwidth={w}, label="{dot_escape(lbl)}"];') L.append("}") return "\n".join(L) + "\n" # --------------------------------------------------------------------------- # Graphviz 渲染 # --------------------------------------------------------------------------- def find_dot() -> str | None: """定位 Graphviz dot:DTU_GRAPHVIZ_DOT(显式)→ PATH。 显式变量已设置则严格使用(无效立即报错,不静默回退);未设置时只查 PATH,未找到返回 None 并降级为"只生成 .dot、不渲染"(保持既有语义)。 见 docs/环境与部署/本地工具路径环境变量方案.md §1。 """ explicit = os.environ.get("DTU_GRAPHVIZ_DOT", "").strip() if explicit: if not os.path.isfile(explicit): sys.exit("ERROR: DTU_GRAPHVIZ_DOT 已设置但不存在: {}(清除该变量可改用 PATH 查找)".format(explicit)) return explicit return shutil.which("dot") def render(dot: str, dotfile: str, outfiles: dict[str, str]) -> bool: """outfiles: {格式: 输出路径},如 {'svg': ..., 'png': ...}""" ok = True for fmt, outp in outfiles.items(): argv = [dot, "-T" + fmt] if fmt == "png": argv += ["-Gdpi=130"] argv += [dotfile, "-o", outp] r = subprocess.run(argv, capture_output=True, text=True) if r.returncode != 0: ok = False print(f"[refgraph] 渲染失败 {fmt}: {r.stderr.strip()[:300]}", file=sys.stderr) return ok # --------------------------------------------------------------------------- # 主流程 # --------------------------------------------------------------------------- def build(root: str) -> tuple[Graph, dict]: files = build_index(root) inc_dirs = load_include_dirs(root) resolver = Resolver(root, files, inc_dirs) g = Graph() unresolved: dict[str, list[str]] = {} stats = { "files_indexed": len(files), "includes_parsed": 0, "resolved": 0, "unresolved": 0, "nodes_fp": 0, "nodes_tp": 0, "ext_names": 0, } ext_names: set[str] = set() # 先登记全部第一方文件为节点(含无引用的孤立文件) for ap, rel in files: if classify(rel) == "fp": g.add_node(rel, "fp") stats["nodes_fp"] += 1 for ap, rel in files: src_cls = classify(rel) for name, quoted, lineno in parse_includes(ap): stats["includes_parsed"] += 1 target = resolver.resolve(name, ap, quoted) if target: # 相对路径 trel = os.path.relpath(target, root).replace(os.sep, "/") tcls = classify(trel) g.add_node(rel, src_cls) g.add_node(trel, tcls) kind = ("fp-fp" if (src_cls == "fp" and tcls == "fp") else "fp-tp" if (src_cls == "fp" and tcls == "tp") else "tp-tp") g.add_edge(rel, trel, kind, name) stats["resolved"] += 1 else: stats["unresolved"] += 1 ext_names.add(name) g.add_node(rel, src_cls) g.add_node(EXT_NODE, "ext") g.add_edge(rel, EXT_NODE, "fp-ext" if src_cls == "fp" else "tp-ext", name) unresolved.setdefault(rel, []).append(name) # 节点计数修正(tp 节点 = 实际出现的第三方文件节点) stats["nodes_tp"] = sum(1 for nd in g.nodes.values() if nd["class"] == "tp") if EXT_NODE not in g.nodes else \ sum(1 for r, nd in g.nodes.items() if nd["class"] == "tp") stats["ext_names"] = len(ext_names) stats["include_dirs"] = [os.path.relpath(d, root).replace(os.sep, "/") for d in inc_dirs] meta = { "unresolved_by_file": {k: sorted(set(v)) for k, v in sorted(unresolved.items())}, "ext_names_sorted": sorted(ext_names), } return g, {**stats, **meta} def main() -> int: if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8", errors="replace") sys.stderr.reconfigure(encoding="utf-8", errors="replace") here = os.path.dirname(os.path.abspath(__file__)) ap = argparse.ArgumentParser(description="STM32F4-Base 文件引用关系图生成器") ap.add_argument("--root", default=os.path.dirname(os.path.dirname(here)), help="仓库根目录(默认:脚本上两级目录)") ap.add_argument("--outdir", default=None, help="产物目录(默认 /docs/images)") ap.add_argument("--views", default="full,module", help="full / module,逗号分隔") ap.add_argument("--edge-scope", choices=("project", "all"), default="project", help="project=仅第一方发起的引用边(默认);all=含第三方内部引用边") ap.add_argument("--no-render", action="store_true", help="不调用 Graphviz 渲染") ap.add_argument("--quiet", action="store_true", help="精简输出") args = ap.parse_args() root = os.path.abspath(args.root) outdir = os.path.abspath(args.outdir or os.path.join(root, "docs", "images")) os.makedirs(outdir, exist_ok=True) views = [v.strip() for v in args.views.split(",") if v.strip()] g, stats = build(root) title = ("STM32F4-Base 项目文件引用关系图(项目代码视图)" if args.edge_scope == "project" else "STM32F4-Base 项目文件引用关系图(全量视图)") written: list[str] = [] if "full" in views: dot_full = g.emit_dot_full(title, args.edge_scope) pf = os.path.join(outdir, "引用关系图.dot") with open(pf, "w", encoding="utf-8", newline="\n") as f: f.write(dot_full) written.append(pf) if "module" in views: dm = g.emit_dot_module("STM32F4-Base 模块引用概览") pm = os.path.join(outdir, "引用关系图_模块.dot") with open(pm, "w", encoding="utf-8", newline="\n") as f: f.write(dm) written.append(pm) # JSON 数据(末尾补单个换行:与 pre-commit end-of-file-fixer 口径一致, # 避免每次重生成都触发该钩子的"首次修复"抖动,模式同 make_font.py) pj = os.path.join(outdir, "引用关系图.json") payload = { "title": title, "nodes": sorted(g.nodes.values(), key=lambda x: x["path"]), "edges": g.edge_list, "unresolved": stats["unresolved_by_file"], "stats": {k: v for k, v in stats.items() if k not in ("unresolved_by_file", "ext_names_sorted", "include_dirs")}, "include_dirs": stats["include_dirs"], } with open(pj, "w", encoding="utf-8", newline="\n") as f: json.dump(payload, f, ensure_ascii=False, indent=1) f.write("\n") written.append(pj) # 渲染 if not args.no_render: dot = find_dot() if dot is None: print("[refgraph] 未找到 Graphviz(dot),跳过渲染;.dot 已生成," "安装 Graphviz 后重跑即可出图。", file=sys.stderr) else: if "full" in views: render(dot, written[0] if written and written[0].endswith(".dot") else os.path.join(outdir, "引用关系图.dot"), {"svg": os.path.join(outdir, "引用关系图.svg"), "png": os.path.join(outdir, "引用关系图.png")}) if "module" in views: pm = os.path.join(outdir, "引用关系图_模块.dot") render(dot, pm, {"svg": os.path.join(outdir, "引用关系图_模块.svg")}) # 报告 n_edges = len(g.edges) k = lambda s: print(s) if not args.quiet else None k("=" * 62) k("STM32F4-Base 文件引用关系图 生成报告") k(f" 生成时间 : {_dt.datetime.now():%Y-%m-%d %H:%M:%S}") k(f" 索引文件 : {stats['files_indexed']} 个 .c/.h") k(f" 解析 include : {stats['includes_parsed']} 条(解析成功 {stats['resolved']}," f"外部/系统 {stats['unresolved']})") k(f" 节点 : 第一方 {stats['nodes_fp']} + 第三方 {stats['nodes_tp']}" + (f" + 外部聚合 1" if EXT_NODE in g.nodes else "")) k(f" 边(去重) : {n_edges} 条(含边明细 {len(g.edge_list)} 条)") k(f" 外部头文件 : {stats['ext_names']} 种,如 " + ", ".join(stats["ext_names_sorted"][:8])) k("-" * 62) k(" 产物:") for w in written: k(f" {os.path.relpath(w, root)}") if not args.no_render: for name in ("引用关系图.svg", "引用关系图.png", "引用关系图_模块.svg"): p = os.path.join(outdir, name) if os.path.isfile(p): k(f" {os.path.relpath(p, root)} ({os.path.getsize(p)//1024} KB)") k("=" * 62) # 被引用最多的第一方文件 Top10(概览用) ref_cnt: dict[str, int] = {} for (s, d) in g.edges: if g.nodes.get(d, {}).get("class") == "fp": ref_cnt[d] = ref_cnt.get(d, 0) + 1 top = sorted(ref_cnt.items(), key=lambda x: -x[1])[:10] if top and not args.quiet: k(" 被引用最多的第一方文件(Top10):") for rel, c in top: k(f" {c:4d} {rel}") k("=" * 62) return 0 if __name__ == "__main__": raise SystemExit(main())