Browse Source

chore: 新增 tools/check_circular_deps.py 静态检查,禁止后端 eager 循环依赖

解析 cfc-backend/src/main/java 全部 Java 的 @Resource/@Autowired 字段注入、
setter 注入、构造器注入;@Resource 按 by-name(失败回退 by-type)、
其余按 by-type + 接口实现回退解析 Bean,构建依赖图并区分 eager 边与 @Lazy 边。

输出三段:
- [1] eager 循环依赖清单:必须为空,非空退出码 1(阻断提交)
- [2] @Lazy 掩盖的设计环台账:既有 30+ 条设计债仅登记不阻断
- [3] @Lazy 注入点清单:供人工复核字段名与 Bean 名是否严格一致

同时输出接口多实现的保守全连边提示与未解析注入点统计,便于人工复核。
.gitignore 增加例外规则,使该脚本可入库(仓库原 *.py 规则会忽略它)。
Sisyphus 3 weeks ago
parent
commit
2b0c7ef2cb
2 changed files with 844 additions and 0 deletions
  1. 1 0
      .gitignore
  2. 843 0
      tools/check_circular_deps.py

+ 1 - 0
.gitignore

@@ -62,6 +62,7 @@ worktrees/
 
 # Python
 *.py
+!tools/check_circular_deps.py
 __pycache__/
 
 # OpenCode

+ 843 - 0
tools/check_circular_deps.py

@@ -0,0 +1,843 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+tools/check_circular_deps.py — CFC 后端 Spring 循环依赖静态检查
+
+用途
+----
+在提交前静态解析 cfc-backend/src/main/java 的全部 Java 源码,构建「Bean 依赖图」,
+区分 **eager 边**(启动期立即实例化)与 **@Lazy 边**(延迟代理,启动期不实例化),
+输出:
+
+  1. eager 循环依赖清单 —— 启动期致命,必须为空;非空时退出码 1(阻断提交)
+  2. @Lazy 掩盖的设计环台账 —— 允许存在(既有 30+ 条),仅登记不阻断
+  3. @Lazy 注入点清单 —— 供人工复核「@Lazy 仅在字段名与 Bean 名严格一致时使用」
+
+解析能力
+--------
+  * 字段注入:@Resource / @Autowired(含 @Lazy、@Qualifier、@Resource(name="..."))
+  * setter 注入:方法上的 @Autowired / @Resource
+  * 构造器注入:构造器上的 @Autowired / @Resource
+  * @Resource 按 by-name 解析(by-name 失败回退 by-type)
+  * @Autowired 按 by-type 解析;接口类型回退到「唯一实现类」
+  * Bean 识别:@Service / @Component / @Repository / @Controller / @RestController /
+    @Configuration / com.etotem.cfc.mapper 下的 Mapper 接口
+
+退出码
+------
+  0 —— 未发现 eager 循环依赖
+  1 —— 发现 eager 循环依赖(必须从设计上打破,禁止用 @Lazy 掩盖新环)
+  2 —— 脚本自身错误(源码目录不存在等)
+
+用法
+----
+  python3 tools/check_circular_deps.py
+"""
+
+import os
+import re
+import sys
+from collections import defaultdict
+
+# --------------------------------------------------------------------------- #
+# 路径与常量
+# --------------------------------------------------------------------------- #
+
+ROOT = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir))
+SRC_DIR = os.path.join(ROOT, "cfc-backend", "src", "main", "java")
+
+STEREOTYPE_SIMPLE = {"Service", "Component", "Repository", "Controller",
+                     "RestController", "Configuration"}
+MAPPER_PACKAGE_PREFIX = "com.etotem.cfc.mapper"
+
+INJECT_ANNOTATIONS = ("@Resource", "@Autowired")
+LAZY_ANNOTATION = "@Lazy"
+
+# 集合/容器注入:Spring 注入的是「多个候选」,无法归到单一 Bean 节点,解析时跳过
+COLLECTION_TYPES = {
+    "List", "Set", "Map", "Collection", "Iterable", "ArrayList", "LinkedList",
+    "HashSet", "TreeSet", "HashMap", "LinkedHashMap", "TreeMap", "Optional",
+    "ObjectProvider", "Multimap",
+}
+
+
+# --------------------------------------------------------------------------- #
+# 源码预处理
+# --------------------------------------------------------------------------- #
+
+def strip_comments(src):
+    """把注释替换为等长空格(保留换行与偏移),避免注释中的注解造成误判。"""
+    out = list(src)
+    i, n = 0, len(src)
+    state = None  # None / 'line' / 'block' / 'str' / 'char'
+    while i < n:
+        c = src[i]
+        nxt = src[i + 1] if i + 1 < n else ""
+        if state is None:
+            if c == "/" and nxt == "/":
+                state = "line"
+                out[i] = out[i + 1] = " "
+                i += 2
+                continue
+            if c == "/" and nxt == "*":
+                state = "block"
+                out[i] = out[i + 1] = " "
+                i += 2
+                continue
+            if c == '"':
+                state = "str"
+                i += 1
+                continue
+            if c == "'":
+                state = "char"
+                i += 1
+                continue
+            i += 1
+            continue
+        if state == "line":
+            if c == "\n":
+                state = None
+            else:
+                out[i] = " "
+            i += 1
+            continue
+        if state == "block":
+            if c == "*" and nxt == "/":
+                out[i] = out[i + 1] = " "
+                state = None
+                i += 2
+                continue
+            if c != "\n":
+                out[i] = " "
+            i += 1
+            continue
+        if state in ("str", "char"):
+            quote = '"' if state == "str" else "'"
+            if c == "\\":
+                i += 2
+                continue
+            if c == quote:
+                state = None
+            i += 1
+            continue
+    return "".join(out)
+
+
+def decapitalize(name):
+    """Spring 默认 Bean 名:首字母小写(前两字母均大写则保持原样)。"""
+    if not name:
+        return name
+    if len(name) > 1 and name[0].isupper() and name[1].isupper():
+        return name
+    return name[0].lower() + name[1:]
+
+
+def strip_generics(type_text):
+    """List<Foo> -> List;Map<String, Foo> -> Map"""
+    depth = 0
+    out = []
+    for ch in type_text:
+        if ch == "<":
+            depth += 1
+            continue
+        if ch == ">":
+            depth = max(0, depth - 1)
+            continue
+        if depth == 0:
+            out.append(ch)
+    return "".join(out).strip()
+
+
+def simple_type(type_text):
+    """取类型简单名(去包名、泛型、数组)"""
+    t = strip_generics(type_text).strip()
+    t = t.split(".")[-1].strip()
+    while t.endswith("[]"):
+        t = t[:-2].strip()
+    t = t.replace("...", "").strip()
+    return t
+
+
+# --------------------------------------------------------------------------- #
+# 类型声明解析
+# --------------------------------------------------------------------------- #
+
+TYPE_DECL_RE = re.compile(
+    r"(?P<prefix>[^{};]*?)\b(?P<kind>class|interface|enum)\s+(?P<name>\w+)"
+    r"(?P<rest>[^{};]*?)\{"
+)
+
+
+class TypeDecl(object):
+    __slots__ = ("name", "kind", "package", "file", "prefix", "rest", "body_start", "body_end")
+
+    def __init__(self, name, kind, package, file, prefix, rest, body_start, body_end):
+        self.name = name
+        self.kind = kind
+        self.package = package
+        self.file = file
+        self.prefix = prefix
+        self.rest = rest
+        self.body_start = body_start
+        self.body_end = body_end
+
+
+def parse_package(src):
+    m = re.search(r"^\s*package\s+([\w.]+)\s*;", src, re.M)
+    return m.group(1) if m else ""
+
+
+def parse_type_decls(src, file_path):
+    decls = []
+    package = parse_package(src)
+    for m in TYPE_DECL_RE.finditer(src):
+        prefix = m.group("prefix")
+        # 仅认列 0 开始的顶层声明(缩进声明视为方法体内的局部类/匿名类,跳过)
+        last_line = prefix.split("\n")[-1] if prefix else ""
+        if last_line[:1] in (" ", "\t"):
+            continue
+        body_start = m.end()
+        depth = 1
+        i = body_start
+        while i < len(src) and depth > 0:
+            if src[i] == "{":
+                depth += 1
+            elif src[i] == "}":
+                depth -= 1
+            i += 1
+        decls.append(TypeDecl(
+            name=m.group("name"),
+            kind=m.group("kind"),
+            package=package,
+            file=file_path,
+            prefix=prefix,
+            rest=m.group("rest"),
+            body_start=body_start,
+            body_end=i - 1,
+        ))
+    return decls
+
+
+def prefix_annotation_names(prefix):
+    """提取声明前注解块中的注解简单名(兼容全限定写法)"""
+    return [m.split(".")[-1] for m in re.findall(r"@\s*([\w.]+)", prefix)]
+
+
+def is_stereotype(prefix):
+    """类声明前的注解块里是否含 Spring stereotype 注解"""
+    return bool(STEREOTYPE_SIMPLE & set(prefix_annotation_names(prefix)))
+
+
+def stereotype_name(prefix):
+    """解析 @Service("xxx") / @Component("xxx") 显式 Bean 名"""
+    for m in re.finditer(r"@\s*([\w.]+)(?:\s*\(\s*\"([^\"]+)\"\s*\))", prefix):
+        if m.group(1).split(".")[-1] in STEREOTYPE_SIMPLE:
+            return m.group(2)
+    return None
+
+
+# --------------------------------------------------------------------------- #
+# 注入点解析
+# --------------------------------------------------------------------------- #
+
+class InjectionPoint(object):
+    __slots__ = ("owner", "kind", "field", "type", "annotation", "lazy", "file", "line", "explicit_name")
+
+    def __init__(self, owner, kind, field, type_, annotation, lazy, file, line, explicit_name=None):
+        self.owner = owner
+        self.kind = kind            # field / setter / constructor
+        self.field = field
+        self.type = type_
+        self.annotation = annotation  # @Resource / @Autowired
+        self.lazy = lazy
+        self.file = file
+        self.line = line
+        self.explicit_name = explicit_name
+
+    def describe(self):
+        loc = "%s:%d" % (os.path.relpath(self.file, ROOT), self.line)
+        return "%s.%s  (%s %s%s) -> %s  [%s]" % (
+            self.owner, self.field, self.annotation,
+            "@Lazy " if self.lazy else "",
+            self.type, loc, self.kind)
+
+
+ANNOTATION_RE = re.compile(
+    r"\s*@\s*(?P<name>[\w.]+)(?:\s*\((?P<args>(?:[^()]|\([^()]*\))*)\))?")
+
+
+def skip_annotations(src, pos):
+    """跳过紧邻的注解块,返回 (新位置, 注解列表)"""
+    anns = []
+    while True:
+        m = ANNOTATION_RE.match(src, pos)
+        if not m:
+            break
+        anns.append((m.group("name"), m.group("args") or ""))
+        pos = m.end()
+    return pos, anns
+
+
+def find_next_token(src, pos, tokens=(";", "{", "=")):
+    """找到下一个顶层分隔符(跳过字符串/字符字面量与括号)"""
+    depth = 0
+    i = pos
+    n = len(src)
+    while i < n:
+        c = src[i]
+        if c in "\"'":
+            quote = c
+            i += 1
+            while i < n:
+                if src[i] == "\\":
+                    i += 2
+                    continue
+                if src[i] == quote:
+                    break
+                i += 1
+            i += 1
+            continue
+        if c in "([{":
+            depth += 1
+        elif c in ")]}":
+            depth -= 1
+        elif depth == 0 and c in tokens:
+            return c, i
+        i += 1
+    return None, n
+
+
+def parse_field_decl(text):
+    """'private Foo bar' -> ('Foo', 'bar');'private List<Foo> bars' -> ('List', 'bars')"""
+    text = text.strip()
+    if not text:
+        return None, None
+    text = re.sub(r"^(?:public|protected|private|static|final|transient|volatile)\s+", "", text)
+    text = text.strip()
+    if not text:
+        return None, None
+    # 泛型整体作为一个 token
+    m = re.match(r"^(?P<type>[\w.]+(?:\s*<.*?>)?(?:\s*\[\s*\])*)\s+(?P<name>\w+)\s*$", text, re.S)
+    if not m:
+        return None, None
+    return m.group("type"), m.group("name")
+
+
+def split_params(param_text):
+    """按顶层逗号切分参数列表"""
+    parts, buf, depth = [], [], 0
+    for ch in param_text:
+        if ch in "<([":
+            depth += 1
+        elif ch in ">)]":
+            depth -= 1
+        if ch == "," and depth == 0:
+            parts.append("".join(buf))
+            buf = []
+        else:
+            buf.append(ch)
+    if "".join(buf).strip():
+        parts.append("".join(buf))
+    return parts
+
+
+def parse_method_params(text):
+    """解析方法/构造器参数 -> [(type, name), ...]"""
+    idx = text.find("(")
+    if idx < 0:
+        return []
+    start = idx + 1
+    depth = 1
+    i = start
+    while i < len(text) and depth > 0:
+        if text[i] in "([{":
+            depth += 1
+        elif text[i] in ")]}":
+            depth -= 1
+        i += 1
+    inner = text[start:i - 1]
+    result = []
+    for raw in split_params(inner):
+        p = raw.strip()
+        if not p:
+            continue
+        p = re.sub(r"^(?:final|@\w+(\([^)]*\))?)\s+", "", p)
+        p = re.sub(r"^(?:public|protected|private)\s+", "", p).strip()
+        tokens = re.findall(r"[\w.]+(?:\s*<.*?>)?(?:\s*\[\s*\])?|\w+", p)
+        if not tokens:
+            continue
+        if len(tokens) == 1:
+            result.append((tokens[0], tokens[0]))
+        else:
+            result.append((" ".join(tokens[:-1]), tokens[-1]))
+    return result
+
+
+def scan_class_body(src, decl):
+    """扫描类体(顶层成员)中的注入点"""
+    points = []
+    body = src[decl.body_start:decl.body_end]
+    offset = decl.body_start
+    i = 0
+    depth = 0
+    n = len(body)
+    while i < n:
+        c = body[i]
+        if c in "\"'":
+            quote = c
+            i += 1
+            while i < n:
+                if body[i] == "\\":
+                    i += 2
+                    continue
+                if body[i] == quote:
+                    break
+                i += 1
+            i += 1
+            continue
+        if c == "{":
+            depth += 1
+            i += 1
+            continue
+        if c == "}":
+            depth -= 1
+            i += 1
+            continue
+        if depth == 0 and c == "@":
+            # 收集连续的注解块(顺序不限:@Lazy @Autowired / @Autowired @Lazy / @Resource @Lazy)
+            block_start = i
+            pos = i
+            block = []
+            while True:
+                m = ANNOTATION_RE.match(body, pos)
+                if not m:
+                    break
+                block.append((m.group("name"), m.group("args") or ""))
+                pos = m.end()
+            if not block:
+                i += 1
+                continue
+            # 注解名归一为简单名(兼容 @org.springframework.context.annotation.Lazy 等全限定写法)
+            simple_names = [a[0].split(".")[-1] for a in block]
+            inject_ann = None
+            if "Resource" in simple_names:
+                inject_ann = "Resource"
+            elif "Autowired" in simple_names:
+                inject_ann = "Autowired"
+            if inject_ann is None:
+                i = max(pos, block_start + 1)
+                continue
+            lazy = "Lazy" in simple_names
+            explicit_name = None
+            for aname, aargs in block:
+                if aname.split(".")[-1] in ("Resource", "Autowired"):
+                    mn = re.search(r"name\s*=\s*\"([^\"]+)\"", aargs or "")
+                    if mn:
+                        explicit_name = mn.group(1)
+            after = pos
+            # 找到声明
+            tok, tok_pos = find_next_token(body, after)
+            if tok is None:
+                i = after
+                continue
+            seg = body[after:tok_pos]
+            if tok in (";", "="):
+                type_text, field = parse_field_decl(seg)
+                if type_text and field:
+                    points.append(InjectionPoint(
+                        owner=decl.name, kind="field", field=field,
+                        type_=simple_type(type_text), annotation="@" + inject_ann,
+                        lazy=lazy, file=decl.file,
+                        line=src.count("\n", 0, offset + after) + 1,
+                        explicit_name=explicit_name))
+            elif tok == "{":
+                params = parse_method_params(seg)
+                is_ctor = re.search(r"\b%s\s*\(" % re.escape(decl.name), seg) is not None
+                for ptype, pname in params:
+                    if ptype.startswith("this"):
+                        continue
+                    points.append(InjectionPoint(
+                        owner=decl.name, kind="constructor" if is_ctor else "setter",
+                        field=pname, type_=simple_type(ptype), annotation="@" + inject_ann,
+                        lazy=lazy, file=decl.file,
+                        line=src.count("\n", 0, offset + after) + 1,
+                        explicit_name=explicit_name))
+            i = max(after, block_start + 1)
+            continue
+        i += 1
+    return points
+
+
+# --------------------------------------------------------------------------- #
+# Bean 注册表与依赖解析
+# --------------------------------------------------------------------------- #
+
+class Registry(object):
+    def __init__(self):
+        self.bean_by_name = {}          # bean name -> TypeDecl
+        self.classes_by_simple = defaultdict(list)   # simple name -> [TypeDecl]
+        self.interfaces = {}            # simple name -> TypeDecl(kind=interface)
+        self.implementations = defaultdict(list)     # interface simple name -> [TypeDecl]
+
+    def add(self, decl, bean_name):
+        self.bean_by_name[bean_name] = decl
+        self.classes_by_simple[decl.name].append(decl)
+        if decl.kind == "interface":
+            self.interfaces[decl.name] = decl
+
+    def link(self):
+        for decl in list(self.classes_by_simple.values()):
+            pass
+        for names in self.classes_by_simple.values():
+            for decl in names:
+                rest = decl.rest or ""
+                m = re.search(r"\bimplements\s+([^{]+)", rest)
+                if m:
+                    for iface in split_params(m.group(1)):
+                        iface = re.sub(r"<.*?>", "", iface).strip().split(".")[-1]
+                        if iface:
+                            self.implementations[iface].append(decl)
+                m2 = re.search(r"\bextends\s+([^{]+)", rest)
+                if m2 and decl.kind == "interface":
+                    for parent in split_params(m2.group(1)):
+                        parent = re.sub(r"<.*?>", "", parent).strip().split(".")[-1]
+                        if parent:
+                            self.implementations[parent].append(decl)
+
+    def resolve(self, point):
+        """返回 (目标 TypeDecl 列表, 解析方式);多实现时保守全连边(宁可多报不漏报)"""
+        if point.type in COLLECTION_TYPES:
+            return [], "collection"
+        # 1) 显式 name:@Resource(name="x") / @Autowired(name="x")
+        if point.explicit_name and point.explicit_name in self.bean_by_name:
+            return [self.bean_by_name[point.explicit_name]], "by-name(explicit)"
+        # 2) @Resource by-name:字段名 = Bean 名(本项目强约束)
+        if point.annotation == "@Resource" and point.field in self.bean_by_name:
+            return [self.bean_by_name[point.field]], "by-name"
+        # 3) by-type:类型简单名直接命中 Bean 类
+        cands = self.classes_by_simple.get(point.type, [])
+        if len(cands) == 1:
+            return cands, "by-type"
+        if len(cands) > 1:
+            return [], "ambiguous-type"
+        # 4) 接口 → 实现类
+        impls = self.implementations.get(point.type, [])
+        if len(impls) == 1:
+            return impls, "by-type(iface->impl)"
+        if len(impls) > 1:
+            primary = [d for d in impls if "@Primary" in (d.prefix or "")]
+            if len(primary) == 1:
+                return primary, "by-primary"
+            return impls, "ambiguous-iface"
+        return [], "unresolved"
+
+
+# --------------------------------------------------------------------------- #
+# 图算法
+# --------------------------------------------------------------------------- #
+
+def tarjan_scc(nodes, adj):
+    """返回 SCC 列表(迭代版 Tarjan)"""
+    index = {}
+    low = {}
+    on_stack = {}
+    stack = []
+    result = []
+    counter = [0]
+
+    for root in nodes:
+        if root in index:
+            continue
+        work = [(root, iter(adj.get(root, ())))]
+        index[root] = low[root] = counter[0]
+        counter[0] += 1
+        stack.append(root)
+        on_stack[root] = True
+        while work:
+            v, it = work[-1]
+            advanced = False
+            for w in it:
+                if w not in index:
+                    index[w] = low[w] = counter[0]
+                    counter[0] += 1
+                    stack.append(w)
+                    on_stack[w] = True
+                    work.append((w, iter(adj.get(w, ()))))
+                    advanced = True
+                    break
+                if on_stack.get(w):
+                    low[v] = min(low[v], index[w])
+            if advanced:
+                continue
+            work.pop()
+            if work:
+                u = work[-1][0]
+                low[u] = min(low[u], low[v])
+            if low[v] == index[v]:
+                comp = []
+                while True:
+                    w = stack.pop()
+                    on_stack[w] = False
+                    comp.append(w)
+                    if w == v:
+                        break
+                result.append(comp)
+    return result
+
+
+def find_cycle_within(nodes, adj):
+    """在给定节点集合内寻找一条简单环(DFS),返回路径列表"""
+    node_set = set(nodes)
+    for start in sorted(nodes):
+        stack = [(start, [start])]
+        visited = set()
+        while stack:
+            node, path = stack.pop()
+            for nxt in adj.get(node, ()):
+                if nxt == start:
+                    return path + [start]
+                if nxt in node_set and nxt not in visited:
+                    visited.add(nxt)
+                    stack.append((nxt, path + [nxt]))
+    return None
+
+
+def enumerate_cycles(nodes, adj, limit=100):
+    """枚举节点集合内的全部简单环(从环上最小节点起步,保证每环只枚举一次)"""
+    node_set = set(nodes)
+    cycles = []
+    for start in sorted(nodes):
+        if len(cycles) >= limit:
+            break
+        stack = [(start, [start])]
+        while stack:
+            if len(cycles) >= limit:
+                break
+            node, path = stack.pop()
+            for nxt in sorted(adj.get(node, ())):
+                if nxt == start:
+                    cycles.append(path + [start])
+                    if len(cycles) >= limit:
+                        break
+                elif nxt in node_set and nxt not in path and nxt >= start:
+                    stack.append((nxt, path + [nxt]))
+    return cycles
+
+
+def find_path(src, dst, adj):
+    """BFS 最短路径 src → dst(含端点),不可达返回 None"""
+    if src == dst:
+        return [src]
+    prev = {src: None}
+    queue = [src]
+    head = 0
+    while head < len(queue):
+        node = queue[head]
+        head += 1
+        for nxt in sorted(adj.get(node, ())):
+            if nxt in prev:
+                continue
+            prev[nxt] = node
+            if nxt == dst:
+                path = [dst]
+                cur = node
+                while cur is not None:
+                    path.append(cur)
+                    cur = prev[cur]
+                path.reverse()
+                return path
+            queue.append(nxt)
+    return None
+
+
+# --------------------------------------------------------------------------- #
+# 主流程
+# --------------------------------------------------------------------------- #
+
+def collect_types():
+    registry = Registry()
+    decls = []
+    for dirpath, _dirnames, filenames in os.walk(SRC_DIR):
+        for fn in filenames:
+            if not fn.endswith(".java"):
+                continue
+            path = os.path.join(dirpath, fn)
+            with open(path, "r", encoding="utf-8", errors="replace") as fh:
+                raw = fh.read()
+            src = strip_comments(raw)
+            file_decls = parse_type_decls(src, path)
+            for d in file_decls:
+                decls.append((src, d))
+                # Mapper 接口(MyBatis-Plus 扫描 com.etotem.cfc.mapper)
+                if d.kind == "interface" and d.package.startswith(MAPPER_PACKAGE_PREFIX):
+                    registry.add(d, decapitalize(d.name))
+                elif is_stereotype(d.prefix):
+                    explicit = stereotype_name(d.prefix)
+                    registry.add(d, explicit or decapitalize(d.name))
+    registry.link()
+    return registry, decls
+
+
+def build_graph(registry, decls):
+    edges = []        # (owner, target, point)
+    skipped = 0       # 实体/POJO/无法解析(非 Bean,忽略)
+    ambiguous = []    # 接口多实现,按保守全连边(可能多报)
+    for src, decl in decls:
+        if decl.name not in registry.classes_by_simple:
+            continue
+        if len(registry.classes_by_simple[decl.name]) != 1:
+            continue
+        if not (is_stereotype(decl.prefix) or
+                (decl.kind == "interface" and decl.package.startswith(MAPPER_PACKAGE_PREFIX))):
+            continue
+        points = scan_class_body(src, decl)
+        for p in points:
+            targets, how = registry.resolve(p)
+            if not targets:
+                if how in ("ambiguous-type",):
+                    ambiguous.append((p, how))
+                else:
+                    skipped += 1
+                continue
+            if how == "ambiguous-iface":
+                ambiguous.append((p, how))
+            for t in targets:
+                edges.append((decl, t, p))
+    return edges, skipped, ambiguous
+
+
+def main():
+    if not os.path.isdir(SRC_DIR):
+        sys.stderr.write("[check_circular_deps] 源码目录不存在: %s\n" % SRC_DIR)
+        return 2
+
+    registry, decls = collect_types()
+    edges, skipped, ambiguous = build_graph(registry, decls)
+
+    # 邻接表
+    eager_adj = defaultdict(set)
+    full_adj = defaultdict(set)
+    edge_info = {}
+    for owner, target, p in edges:
+        if owner.name == target.name:
+            continue
+        kind = "lazy" if p.lazy else "eager"
+        eager_adj[owner.name].add(target.name) if kind == "eager" else None
+        full_adj[owner.name].add(target.name)
+        edge_info[(owner.name, target.name)] = (kind, p)
+
+    class_nodes = set()
+    for decls_of_same_simple_name in registry.classes_by_simple.values():
+        for decl in decls_of_same_simple_name:
+            class_nodes.add(decl.name)
+
+    # ---- 1. eager 环(全部简单环) ----
+    eager_sccs = tarjan_scc(class_nodes, eager_adj)
+    eager_cycles = []
+    for comp in eager_sccs:
+        if len(comp) > 1:
+            sub = {n: [t for t in eager_adj.get(n, ()) if t in comp] for n in comp}
+            eager_cycles.extend(enumerate_cycles(comp, sub))
+        elif comp:
+            node = comp[0]
+            if node in eager_adj.get(node, ()):
+                eager_cycles.append([node, node])
+
+    # ---- 2. @Lazy 掩盖的设计环:逐条 @Lazy 边判断是否位于某个环上 ----
+    design_cycles = []
+    seen_design = set()
+    for (u, v), (kind, _p) in sorted(edge_info.items()):
+        if kind != "lazy":
+            continue
+        path = find_path(v, u, full_adj)
+        if not path:
+            continue  # 该 @Lazy 边不在任何环上
+        cycle = [u] + path
+        key = frozenset(cycle[:-1])
+        if key in seen_design:
+            continue
+        seen_design.add(key)
+        design_cycles.append(cycle)
+
+    # ---- 3. @Lazy 注入点清单 ----
+    lazy_points = [p for (_o, _t, p) in edges if p.lazy]
+    lazy_points.sort(key=lambda p: (p.owner, p.field))
+
+    # ---- 输出 ----
+    out = []
+    out.append("=" * 72)
+    out.append("CFC 后端循环依赖静态检查 (tools/check_circular_deps.py)")
+    out.append("=" * 72)
+    out.append("源码目录      : %s" % os.path.relpath(SRC_DIR, ROOT))
+    out.append("Java 文件数   : %d" % sum(1 for _d, _f, files in os.walk(SRC_DIR) for f in files if f.endswith(".java")))
+    out.append("识别 Bean 数  : %d" % len(registry.bean_by_name))
+    out.append("注入点总数    : %d (eager %d / lazy %d)"
+               % (len(edges), len(edges) - len(lazy_points), len(lazy_points)))
+
+    out.append("[1] Eager 循环依赖(启动期致命,必须为空)")
+    if eager_cycles:
+        for i, cyc in enumerate(eager_cycles, 1):
+            out.append("  ✗ 环 %d: %s" % (i, " → ".join(cyc)))
+            for a, b in zip(cyc, cyc[1:]):
+                info = edge_info.get((a, b))
+                if info:
+                    out.append("      %s --%s--> %s" % (a, info[0], b))
+    else:
+        out.append("  ✅ 未发现 eager 循环依赖")
+    out.append("")
+
+    out.append("[2] @Lazy 掩盖的设计环台账(既有设计债,仅登记不阻断)")
+    if design_cycles:
+        for i, cyc in enumerate(design_cycles, 1):
+            lazy_edges = []
+            for a, b in zip(cyc, cyc[1:]):
+                info = edge_info.get((a, b))
+                if info and info[0] == "lazy":
+                    lazy_edges.append("%s → %s" % (a, b))
+            out.append("  • 环 %d: %s" % (i, " → ".join(cyc)))
+            if lazy_edges:
+                out.append("      @Lazy 断点: %s" % ", ".join(lazy_edges))
+    else:
+        out.append("  (无)")
+    out.append("")
+
+    out.append("[3] @Lazy 注入点清单(字段名须与 Bean 名严格一致)")
+    if lazy_points:
+        for p in lazy_points:
+            out.append("  - %s.%s  (%s %s) -> %s  [%s:%d]"
+                       % (p.owner, p.field, p.annotation,
+                          "@Lazy" if p.lazy else "", p.type,
+                          os.path.relpath(p.file, ROOT), p.line))
+    else:
+        out.append("  (无)")
+    out.append("")
+
+    if ambiguous:
+        out.append("[4] 接口多实现(按保守全连边,可能产生额外环提示):%d 条" % len(ambiguous))
+        seen = {}
+        for p, how in ambiguous:
+            key = (p.owner, p.field, p.type)
+            seen.setdefault(key, p)
+        for p in list(seen.values())[:15]:
+            out.append("  - %s.%s -> %s  (ambiguous-iface)" % (p.owner, p.field, p.type))
+    else:
+        out.append("[4] 所有注入点均唯一解析到 Bean")
+    if skipped:
+        out.append("    另有 %d 个注入点未解析(实体/POJO/容器类型,正常忽略)" % skipped)
+    out.append("")
+
+    if eager_cycles:
+        out.append("结论: ❌ 发现 %d 条 eager 循环依赖 —— 必须从设计上打破"
+                   "(抽窄接口 / 下沉 / 事件解耦),禁止用 @Lazy 掩盖新环。" % len(eager_cycles))
+        out.append("=" * 72)
+        print("\n".join(out))
+        return 1
+
+    out.append("结论: ✅ eager 环 = 0(通过)")
+    out.append("=" * 72)
+    print("\n".join(out))
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())