You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

270 lines
11 KiB

"""bruno-sync Step 2/3 (本机 D:\ 路径版, 由 bruno_scan.py 改路径而来): scan Spring controllers -> reconcile with docs repo.
Outputs JSON: {apis: [...], report: {...}}
"""
import json
import os
import re
import sys
if sys.stdout.encoding.lower() != "utf-8":
sys.stdout.reconfigure(encoding="utf-8")
ROOTS = [
"crm-auth/src/main/java", "crm-file/src/main/java", "crm-lead/src/main/java",
"crm-opportunity/src/main/java", "crm-rule/src/main/java",
"crm-preference/src/main/java", "crm-dict/src/main/java",
"crm-customer/src/main/java",
]
PROJECT = r"d:\code\crm-backend-matt"
DOCS = r"D:\code\crm-api-docs"
def strip_comments(src):
src = re.sub(r"/\*.*?\*/", "", src, flags=re.S)
src = re.sub(r"//[^\n]*", "", src)
return src
def extract_balanced(text, open_idx):
"""text[open_idx] == '('. return inner content, end idx after ')'."""
depth = 0
i = open_idx
in_str = None
while i < len(text):
c = text[i]
if in_str:
if c == "\\":
i += 2
continue
if c == in_str:
in_str = None
else:
if c in "\"'":
in_str = c
elif c == "(":
depth += 1
elif c == ")":
depth -= 1
if depth == 0:
return text[open_idx + 1:i], i + 1
i += 1
return text[open_idx + 1:], len(text)
def parse_str_array(body):
"""parse {'a','b'} or "a" -> list of strings; also allow bare string."""
body = body.strip()
if body.startswith("{"):
return re.findall(r'"([^"]*)"', body)
m = re.match(r'^"([^"]*)"$', body)
return [m.group(1)] if m else []
def find_annotations(src, name):
"""find all @Name(...) blocks, return list of inner contents."""
out = []
for m in re.finditer(r"@" + name + r"\s*\(", src):
inner, _ = extract_balanced(src, m.end() - 1)
out.append(inner)
return out
def scan_source():
apis = []
conflicts = []
for root in ROOTS:
base = os.path.join(PROJECT, root)
for dirpath, dirnames, filenames in os.walk(base):
for fn in filenames:
if not fn.endswith("Controller.java"):
continue
path = os.path.join(dirpath, fn)
src = strip_comments(open(path, encoding="utf-8").read())
if "@RestController" not in src:
continue
cls_m = re.search(r"class\s+(\w+)", src)
cls = cls_m.group(1) if cls_m else fn[:-5]
prefixes = []
for rm in re.finditer(r"@RequestMapping\s*\(", src):
inner, end = extract_balanced(src, rm.end() - 1)
if end <= len(src) and "class" in src[end:end + 200]:
vals = parse_str_array(inner) or re.findall(r'"([^"]*)"', inner)
pv = re.search(r'value\s*=\s*', inner)
prefixes = parse_str_array(inner[pv.end():]) if pv else vals
break
prefix = prefixes[0] if prefixes else ""
# class-level tags from @Tag(name=...)
cls_tags = []
head = src[:cls_m.start()]
for tag_inner in find_annotations(head, "Tag"):
nm = re.search(r'name\s*=\s*"([^"]*)"', tag_inner)
if nm:
cls_tags.append(nm.group(1))
# collect all @Operation blocks first (project style: @Operation BEFORE @Mapping)
ops = []
for om in re.finditer(r"@Operation\s*\(", src):
op_inner, op_end = extract_balanced(src, om.end() - 1)
ops.append({"start": om.start(), "end": op_end, "inner": op_inner})
# methods: find mapping annotations; assign the NEAREST @Operation BEFORE them
for mm in re.finditer(r"@(Get|Post|Put|Delete|Request)Mapping\s*(\(|$)", src, re.M):
anno_start = mm.start()
if mm.group(1) == "Request":
inner, end = extract_balanced(src, mm.end() - 1) if mm.group(2) == "(" else ("", mm.end())
meth_m = re.search(r"method\s*=\s*RequestMethod\.(\w+)", inner)
if not meth_m:
continue # class-level @RequestMapping (already consumed) or no method
http = meth_m.group(1)
pv = re.search(r'(?:value\s*=\s*)?"([^"]*)"', inner)
sub = pv.group(1) if pv else ""
else:
http = mm.group(1).upper()
if mm.group(2) == "(":
inner, end = extract_balanced(src, mm.end() - 1)
pv = re.search(r'(?:value\s*=\s*)?"([^"]*)"', inner)
sub = pv.group(1) if pv else ""
else:
end = mm.end()
sub = ""
url = (prefix + sub) if sub.startswith("/") else (prefix + ("/" + sub if sub else ""))
# find owning @Operation: project has BOTH styles.
# forward style: @Operation ... @PreAuthorize? ... @Mapping (customer/lead/opp)
# trailing style: @Mapping ... @PreAuthorize ... @Operation (crm-auth role/resource)
op = None
for cand in reversed(ops):
if cand["end"] <= anno_start:
between = src[cand["end"]:anno_start]
if "{" in between:
continue # a method body separates them -> belongs to an earlier method
op = cand
break
if op is None:
body_brace = src.find("{", end)
if body_brace == -1:
body_brace = len(src)
for cand in ops:
if cand["start"] >= end and cand["start"] < body_brace:
op = cand
break
op_tags = None
summary = ""
if op is not None:
sm = re.search(r'summary\s*=\s*"([^"]*)"', op["inner"])
if sm:
summary = sm.group(1)
tm = re.search(r"tags\s*=\s*(\{[^}]*\}|\"[^\"]*\")", op["inner"], re.S)
if tm:
op_tags = parse_str_array(tm.group(1))
method_tags = []
if op_tags is None:
# method-level @Tag between op end and mapping
back = src[anno_start - 800:anno_start]
t_m = None
for ti in find_annotations(back, "Tag"):
t_m = ti
if t_m:
nm = re.search(r'name\s*=\s*"([^"]*)"', t_m)
if nm:
method_tags = [nm.group(1)]
tags = op_tags if op_tags is not None else (method_tags if method_tags else cls_tags)
if not tags:
tags = []
apis.append({
"file": os.path.relpath(path, PROJECT).replace("\\", "/"),
"className": cls, "http": http, "url": url,
"tags": tags, "summary": summary,
})
# key conflicts (method+url, before tag expansion)
seen = {}
for a in apis:
k = a["http"] + " " + a["url"]
seen.setdefault(k, []).append(a)
for k, v in seen.items():
if len(v) > 1:
conflicts.append({"key": k, "files": [x["file"] for x in v]})
return apis, conflicts
def scan_docs():
"""return [{path, key, generated, folder}] for all .bru files."""
out = []
skip = {"node_modules", ".git", "environments"}
for dirpath, dirnames, filenames in os.walk(DOCS):
dirnames[:] = [d for d in dirnames if d not in skip]
for fn in filenames:
if not fn.endswith(".bru") or fn == "collection.bru" or fn == "folder.bru":
continue
path = os.path.join(dirpath, fn)
content = open(path, encoding="utf-8").read()
gen = bool(re.search(r"tags\s*:\s*\[[^\]]*generated", content, re.S))
key = None
km = re.search(r"docs\s*\{.*?`([A-Z]+)\s+(\S+)`", content, re.S)
if km:
key = km.group(1) + " " + km.group(2)
folder = os.path.basename(dirpath)
out.append({"path": os.path.relpath(path, DOCS).replace("\\", "/"),
"key": key, "generated": gen, "folder": folder})
return out
def main():
apis, conflicts = scan_source()
docs = scan_docs()
# expand source keys: one per (method+url, tag)
src_keys = {}
for a in apis:
tags = a["tags"]
if tags:
tl = list(tags)
else:
# no tag -> urlPattern fallback dir: module[/resource]
seg = [s for s in a["url"].split("/") if s]
if seg and seg[0] == "api":
seg = seg[1:]
tl = ["/".join(seg[:2])]
for t in tl:
k = (a["http"] + " " + a["url"], t)
if k in src_keys:
conflicts.append({"key": k[0] + " @" + str(t), "files": [src_keys[k]["file"], a["file"]]})
else:
src_keys[k] = a
# file keys
file_keys = {}
for d in docs:
if not d["generated"]:
continue
if d["key"] is None:
d["unrecognized"] = True
continue
rel = d["path"]
parts = rel.split("/")
# tag = full dir path between repo root and file
tag = "/".join(parts[:-1])
k = (d["key"], tag)
file_keys[k] = d
new_items = []
del_items = []
keep = 0
for k in src_keys:
if k not in file_keys:
a = src_keys[k]
new_items.append({"key": k[0], "tag": k[1], "summary": a["summary"],
"file": a["file"], "className": a["className"]})
for k, d in file_keys.items():
if k not in src_keys:
del_items.append({"key": k[0], "tag": k[1], "path": d["path"]})
for k in src_keys:
if k in file_keys:
keep += 1
report = {
"source_api_count": len(apis),
"source_key_count": len(src_keys),
"generated_file_count": len([d for d in docs if d["generated"]]),
"private_file_count": len([d for d in docs if not d["generated"]]),
"unrecognized_count": len([d for d in docs if d.get("unrecognized")]),
"key_conflicts": conflicts,
"keep_count": keep,
"new_count": len(new_items),
"delete_file_count": len(set(x["path"] for x in del_items)),
"delete_distinct_url_count": len(set(x["key"] for x in del_items)),
}
out = {"report": report, "new": new_items, "delete": del_items, "docs": docs, "apis": apis}
with open(os.path.join(PROJECT, r".scratch\customer-rework\bruno-recon-d.json"), "w", encoding="utf-8") as f:
json.dump(out, f, ensure_ascii=False, indent=1)
print(json.dumps(report, ensure_ascii=False, indent=1))
if __name__ == "__main__":
main()