"""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()