petstore-docs/graph/audit_drift.py

297 lines
12 KiB
Python
Executable File
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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 = 有漂移warning2 = 解析错误。
"""
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()