#!/usr/bin/env python3 """ 宠小它 本体—代码漂移审计脚本。 扫描 backend/src/main/java/com/petstore/controller/*.java,提取所有 HTTP 端点 (类级 @RequestMapping + 方法级 @GetMapping/@PostMapping/@PutMapping/@DeleteMapping), 对比 docs/graph/ontology.jsonl 中 Action 的 entrypoints,报告: 1. code-only:代码里有但本体未录入的端点(drift:应补录本体或确认废弃) 2. ontology-only:本体里有但代码里找不到的端点(drift:可能是已删除接口或路径写错) 3. entity-coverage:扫描 backend/src/main/java/com/petstore/entity/*.java,对比 ontology.jsonl 的 Entity, 报告未录入的实体类。 用法: python3 docs/graph/audit_drift.py [repo_root] 默认 repo_root = . (当前工作目录,应能访问 backend/ 和 docs/)。 退出码:0 = 无漂移;1 = 有漂移(warning);2 = 解析错误。 """ import json import os import re import sys from collections import defaultdict VALID_TYPES = {"Entity", "Action"} def find_controller_files(repo_root): ctrl_dir = os.path.join(repo_root, "backend", "src", "main", "java", "com", "petstore", "controller") if not os.path.isdir(ctrl_dir): return [] return sorted( os.path.join(ctrl_dir, f) for f in os.listdir(ctrl_dir) if f.endswith(".java") ) def find_entity_files(repo_root): ent_dir = os.path.join(repo_root, "backend", "src", "main", "java", "com", "petstore", "entity") if not os.path.isdir(ent_dir): return [] return sorted( os.path.join(ent_dir, f) for f in os.listdir(ent_dir) if f.endswith(".java") ) # 类级 @RequestMapping("/api/xxx") CLASS_MAPPING_RE = re.compile(r'@RequestMapping\s*\(\s*(?:value\s*=\s*)?"([^"]+)"', re.MULTILINE) # 方法级 mapping 注解 METHOD_MAPPING_RE = re.compile( r'@(GetMapping|PostMapping|PutMapping|DeleteMapping)\s*\(\s*(?:value\s*=\s*)?"([^"]*)"', re.MULTILINE, ) # 方法签名(紧跟 mapping 注解后的 public 方法) METHOD_SIG_RE = re.compile( r'public\s+\S+\s+(\w+)\s*\(', re.MULTILINE, ) # 类名 CLASS_NAME_RE = re.compile(r'class\s+(\w+)\b') def extract_endpoints(java_path): """从单个 controller 文件提取 HTTP 端点:返回 [(http_method, full_path, java_method_name), ...]""" with open(java_path, "r", encoding="utf-8") as f: content = f.read() class_name_match = CLASS_NAME_RE.search(content) class_name = class_name_match.group(1) if class_name_match else os.path.basename(java_path) # 类级基础路径 class_path = "" cm = CLASS_MAPPING_RE.search(content) if cm: class_path = cm.group(1) endpoints = [] # 按方法级 mapping 注解切分 # 找所有方法级 mapping 注解的位置 for m in METHOD_MAPPING_RE.finditer(content): annotation = m.group(1) method_path = m.group(2) http_method = { "GetMapping": "GET", "PostMapping": "POST", "PutMapping": "PUT", "DeleteMapping": "DELETE", }[annotation] # 在该注解之后找最近的方法签名 rest = content[m.end():] sig = METHOD_SIG_RE.search(rest) java_method = sig.group(1) if sig else "?" # 拼接完整路径 full = class_path.rstrip("/") + "/" + method_path.lstrip("/") if method_path else class_path # 去掉多余斜杠 full = re.sub(r"/+", "/", full) # 处理通配符路径(如 /api/upload/image/**)——保留通配符用于匹配 endpoints.append((http_method, full, java_method, class_name)) return endpoints def extract_entity_classes(java_path): """从实体文件提取类名""" with open(java_path, "r", encoding="utf-8") as f: content = f.read() m = re.search(r'@Entity\b', content) if not m: return None cm = CLASS_NAME_RE.search(content) return cm.group(1) if cm else None def load_ontology(repo_root): """加载 ontology.jsonl,返回 actions 与 entities 的端点/类名集合""" jsonl_path = os.path.join(repo_root, "docs", "graph", "ontology.jsonl") if not os.path.exists(jsonl_path): print(f"ERROR: {jsonl_path} not found") sys.exit(2) action_endpoints = set() # (HTTP_METHOD, path_pattern) action_endpoints_by_id = defaultdict(list) entity_class_names = set() # PascalCase 类名 entity_ids = {} # class_name -> entity_id non_jpa_entity_class_names = set() # 本体里标 jpa:false 的(不要求代码有 @Entity 类) with open(jsonl_path, "r", encoding="utf-8") as f: for line in f: line = line.strip() if not line: continue try: obj = json.loads(line) except json.JSONDecodeError: continue if obj.get("type") == "Action": entrypoints = obj.get("entrypoints", []) eid = obj.get("id", "?") for ep in entrypoints: if isinstance(ep, str) and (" " in ep or ep.startswith(("GET", "POST", "PUT", "DELETE"))): # 格式 "POST /api/report/create" 或 "ReportController#create" parts = ep.split(" ", 1) if len(parts) == 2 and parts[0] in ("GET", "POST", "PUT", "DELETE"): action_endpoints.add((parts[0], parts[1])) action_endpoints_by_id[eid].append((parts[0], parts[1])) elif obj.get("type") == "Entity": eid = obj.get("id", "") # 从 entity:store 推 PascalCase Store if eid.startswith("entity:"): class_name = "".join(p.capitalize() for p in eid[7:].split("_")) # jpa:false 表示本体内建模为领域实体但代码里不是 JPA @Entity(如字段承载或 record) if obj.get("jpa") is False: non_jpa_entity_class_names.add(class_name) else: entity_class_names.add(class_name) entity_ids[class_name] = eid return action_endpoints, action_endpoints_by_id, entity_class_names, entity_ids, non_jpa_entity_class_names def path_matches_ontology_pattern(code_path, ontology_path): """代码路径是否匹配本体里的路径模式(支持 ** 通配符)""" if code_path == ontology_path: return True # 处理 ** 通配符 if "**" in ontology_path: # 转换为正则:** → .*, * → [^/]* pattern = re.escape(ontology_path).replace(r"\*\*", ".*").replace(r"\*", r"[^/]*") return re.fullmatch(pattern, code_path) is not None # 处理单 * 通配符(如 /api/report/*/suggestion) if "*" in ontology_path: pattern = re.escape(ontology_path).replace(r"\*", r"[^/]*") return re.fullmatch(pattern, code_path) is not None return False def main(): repo_root = sys.argv[1] if len(sys.argv) > 1 else "." repo_root = os.path.abspath(repo_root) action_endpoints, action_endpoints_by_id, entity_class_names, entity_ids, non_jpa_entity_class_names = load_ontology(repo_root) # === 1. Controller 端点漂移 === controller_files = find_controller_files(repo_root) code_endpoints = [] # (http_method, path, java_method, class_name, file) for cf in controller_files: for http_method, path, java_method, class_name in extract_endpoints(cf): code_endpoints.append((http_method, path, java_method, class_name, cf)) # 对比 code_only = [] # 代码里有,本体没有 for http_method, path, java_method, class_name, cf in code_endpoints: found = False for ont_method, ont_path in action_endpoints: if ont_method == http_method and path_matches_ontology_pattern(path, ont_path): found = True break if not found: code_only.append((http_method, path, java_method, class_name, cf)) ontology_only = [] # 本体里有,代码没有 for ont_method, ont_path in action_endpoints: found = False for http_method, path, java_method, class_name, cf in code_endpoints: if http_method == ont_method and path_matches_ontology_pattern(path, ont_path): found = True break if not found: ontology_only.append((ont_method, ont_path)) # === 2. Entity 类覆盖 === entity_files = find_entity_files(repo_root) code_entity_classes = set() for ef in entity_files: cn = extract_entity_classes(ef) if cn: code_entity_classes.add(cn) entities_missing_in_ontology = code_entity_classes - entity_class_names entities_missing_in_code = entity_class_names - code_entity_classes # non-jpa 本体实体(如 HighlightVideo 字段承载、SessionToken record)不算 extra drift # 它们在本体里是领域实体,但代码里不是 @Entity 类,单独列出作为信息项 non_jpa_in_ontology = non_jpa_entity_class_names # === 输出报告 === print("=" * 60) print("宠小它 本体—代码漂移审计") print("=" * 60) print(f"\n扫描 controller 文件: {len(controller_files)}") print(f"代码 HTTP 端点数: {len(code_endpoints)}") print(f"本体 Action 端点数: {len(action_endpoints)}") print(f"扫描 entity 文件: {len(entity_files)}") print(f"代码 Entity 类数: {len(code_entity_classes)}") print(f"本体 Entity 数(JPA): {len(entity_class_names)}") print(f"本体 Entity 数(非 JPA/领域实体): {len(non_jpa_in_ontology)}") print("\n--- Controller 端点漂移 ---") if code_only: print(f"\n⚠️ code-only(代码里有,本体未录入): {len(code_only)} 个") for http_method, path, java_method, class_name, cf in sorted(code_only): rel = os.path.relpath(cf, repo_root) print(f" {http_method:6s} {path:40s} {class_name}#{java_method} [{rel}]") else: print("\n✅ 无 code-only 漂移(所有代码端点都已录入本体)") if ontology_only: print(f"\n⚠️ ontology-only(本体里有,代码找不到): {len(ontology_only)} 个") for ont_method, ont_path in sorted(ontology_only): print(f" {ont_method:6s} {ont_path}") else: print("\n✅ 无 ontology-only 漂移(所有本体端点都有对应代码)") print("\n--- Entity 类覆盖 ---") if entities_missing_in_ontology: print(f"\n⚠️ 代码里有 Entity 类但本体未录入: {len(entities_missing_in_ontology)} 个") for cn in sorted(entities_missing_in_ontology): print(f" {cn}") else: print("\n✅ 所有代码 Entity 类都已录入本体") if entities_missing_in_code: print(f"\n⚠️ 本体里有 Entity 但代码找不到: {len(entities_missing_in_code)} 个") for cn in sorted(entities_missing_in_code): print(f" {cn} (本体 ID: {entity_ids.get(cn, '?')})") else: print("✅ 所有本体 Entity 都有对应代码类") if non_jpa_in_ontology: print(f"\nℹ️ 本体非 JPA 领域实体(代码里不是 @Entity 类,正常): {len(non_jpa_in_ontology)} 个") for cn in sorted(non_jpa_in_ontology): print(f" {cn}") # === 退出码 === has_drift = bool(code_only or ontology_only or entities_missing_in_ontology or entities_missing_in_code) if has_drift: print("\n" + "=" * 60) print("漂移汇总: code-only={} ontology-only={} entity-missing={} entity-extra={}".format( len(code_only), len(ontology_only), len(entities_missing_in_ontology), len(entities_missing_in_code) )) print("=" * 60) sys.exit(1) else: print("\n" + "=" * 60) print("✅ 无漂移:本体与代码完全对齐") print("=" * 60) sys.exit(0) if __name__ == "__main__": main()