21c68521ff
诉求最初是升级 dsh-task-worktree:实测已装 0.4.6 = npm dist-tags.latest,无需升级; 真正的缺口是本仓 skill 看不见插件建的树(.dsh-worktrees/),故按用户追加指令把两套 系统接起来。回收方向刻意不打通:本 skill 永不删插件的树。 脚本 task_worktree.py:新增 plugin 子命令 list/adopt/unadopt,事实源是 git worktree list(不是插件 manifest,免得被绕过);adopt 只补缺失的 .venv 与 simulator/SDL2 联接,已有联接跳过、实体目录绝不碰;锁归属用 pw:<name> 前缀;new 顺带提示另有 N 棵 插件树、不占活跃上限 3。 一并修一处真 bug:lock release --bench-pass 在任务无台账时先 EnvError 退出、锁释放 不掉,导致每个任务的 done 收口连坐失败;现在改为警告后仍然释放。 keil-build:default_lock_task() 增加 .dsh-worktrees/ 反推分支,插件树内的构建归属 pw:<name> 而不是兜底的 build-cli(主树行为未变,本次 @build 实测仍走 build-cli)。 .gitignore 预先忽略 .dsh-worktrees/,让插件的 ensureIgnored() 找到同一字面量、不再 改写这份受控文件。check_doc_links / check_layer_deps / gen_refgraph 三处扫描面同步 排除:仓里再放一份仓会让 doc-links 假红、并让生成的配图字节漂移到 generated-fresh。 文档:并行任务工作树方案新增 §3.4 两套系统边界对照;SKILL.md 加 §F 共存一节,同时把 §B 从 53 行压到 29 行(净 +39,用户此前在飞的改写已 rebase 保留、未被覆盖)。 验证:@build 0 错误 0 警告;scripts/ci_local.py 48 根提交期钩子与 gitlint 全绿; check_doc_links --all --sections-strict 通过(2596 章节引用、无法判定 0);id-check-new 通过;改动文件字节体检无 BOM、CRLF 与 U+FFFD。桥接行为在临时 scratch 仓端到端跑过 (adopt 建两个 reparse、unadopt 只摘联接、pw 锁取放、缺台账也能释放、越界 name 退 2)。
662 lines
27 KiB
Python
662 lines
27 KiB
Python
#!/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 产物目录(默认 <root>/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)时会把 <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")
|
||
# 按目录名跳过(任意层级)
|
||
# .dsh-worktrees = DSH worktree 插件在仓库内建的任务树(整份仓库副本):不排掉的话
|
||
# 每次有人建插件树,本生成器的输出就多出几十条重复节点/边——而 generated-fresh 门禁是
|
||
# 逐字节比对,等于别人的一次「建个隔离空间」把配图 diff 砸在某位同事头上。
|
||
EXCLUDE_DIRNAMES = (".git", ".venv", "node_modules", ".dsh-worktrees")
|
||
|
||
# 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"<IncludePath>(.*?)</IncludePath>", 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)时会把 <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<q>[^"\r\n]*)"|<(?P<a>[^>\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="产物目录(默认 <root>/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())
|