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.
271 lines
11 KiB
271 lines
11 KiB
|
2 days ago
|
"""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()
|