diff --git a/.gitea/checker/api-templates/README.md b/.gitea/checker/api-templates/README.md index 89b2a3f..0dd94e8 100644 --- a/.gitea/checker/api-templates/README.md +++ b/.gitea/checker/api-templates/README.md @@ -51,7 +51,7 @@ WeComNotifier ────────► ApiChangeNotifier(新 **方法指纹建议**(用于跨 commit 匹配同一接口): ``` -controller源文件 + 方法名 + 参数类型签名 +controller源文件 + 方法名 + 参数槽位(如 0:query,1:body;不含类型与绑定名) ``` 仅 URI 匹配在「改路径」场景会失效,需指纹辅助。 diff --git a/.gitea/checker/api-templates/api-path-change.md b/.gitea/checker/api-templates/api-path-change.md index 626d14e..5b56af7 100644 --- a/.gitea/checker/api-templates/api-path-change.md +++ b/.gitea/checker/api-templates/api-path-change.md @@ -77,7 +77,7 @@ 1. 解析旧/新 commit 下同一 Controller 源码 AST 2. 提取每个方法的 `httpMethod` + `uri`(已有 `EndpointParser` 逻辑) -3. 用**方法指纹**(类文件 + 方法名 + 参数类型签名)匹配新旧接口 +3. 用**方法指纹**(类文件 + 方法名 + 参数槽位,如 `0:query,1:query`;不含类型与绑定名)匹配新旧接口 4. 指纹相同且 URI 不同 → **修改路径** 5. 仅旧有新无 → **删除**;仅新有旧无 → **新增** diff --git a/.gitea/checker/src/main/java/com/codechecker/api/analyzer/ParameterDiffEngine.java b/.gitea/checker/src/main/java/com/codechecker/api/analyzer/ParameterDiffEngine.java index 5b60ebf..f880d94 100644 --- a/.gitea/checker/src/main/java/com/codechecker/api/analyzer/ParameterDiffEngine.java +++ b/.gitea/checker/src/main/java/com/codechecker/api/analyzer/ParameterDiffEngine.java @@ -12,12 +12,20 @@ import com.codechecker.model.FieldInfo; import java.io.IOException; import java.nio.file.Path; import java.util.ArrayList; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; /** - * 接口入参 diff(普通参数 + RequestBody 嵌套 Dto 字段),与类变更 FieldDiffEngine 解耦。 + * 接口入参 diff(普通参数 + RequestBody 嵌套 Dto 字段)。 + * + * path/query 规则: + * - 形参名+类型相同,仅绑定名变 → 重命名 + * - 形参名+绑定名相同,仅类型变 → 类型变更 + * - 仅形参名变(绑定名不变)→ 不通知 + * - 类型与绑定名同时变,或三者都变 → 先删除后新增 */ public class ParameterDiffEngine { private final NestedDtoFieldParser nestedDtoFieldParser; @@ -28,38 +36,143 @@ public class ParameterDiffEngine { } public List diff(EndpointSnapshot oldSnap, EndpointSnapshot newSnap) throws IOException { - Map oldParams = toParamMap(oldSnap); - Map newParams = toParamMap(newSnap); + List changes = new ArrayList<>(); + changes.addAll(diffBodyParams(oldSnap, newSnap)); + changes.addAll(diffBindingParams(oldSnap, newSnap)); + return changes; + } + + private List diffBodyParams(EndpointSnapshot oldSnap, EndpointSnapshot newSnap) + throws IOException { + Map oldParams = filterBySource(oldSnap, "body"); + Map newParams = filterBySource(newSnap, "body"); List changes = new ArrayList<>(); for (Map.Entry entry : newParams.entrySet()) { MethodParameterSnapshot oldParam = oldParams.get(entry.getKey()); MethodParameterSnapshot newParam = entry.getValue(); if (oldParam == null) { - changes.addAll(addedChanges(newParam)); - } else if ("body".equals(newParam.getSource())) { + changes.addAll(addedBodyChanges(newParam)); + } else { changes.addAll(diffBodyDto(oldParam, newParam)); - } else if (!oldParam.getType().equals(newParam.getType())) { - changes.add(ParameterChange.modified( - newParam.getName(), - newParam.getType(), - newParam.getDescription(), - oldParam.getType() + " → " + newParam.getType(), - newParam.getSource(), - null, - null, - null - )); } } for (Map.Entry entry : oldParams.entrySet()) { if (!newParams.containsKey(entry.getKey())) { - changes.addAll(removedChanges(entry.getValue())); + changes.addAll(removedBodyChanges(entry.getValue())); } } return changes; } + private List diffBindingParams(EndpointSnapshot oldSnap, EndpointSnapshot newSnap) { + Map oldParams = filterBindingParams(oldSnap); + Map newParams = filterBindingParams(newSnap); + List changes = new ArrayList<>(); + + List unmatchedOld = new ArrayList<>(); + List unmatchedNew = new ArrayList<>(); + + for (Map.Entry entry : newParams.entrySet()) { + MethodParameterSnapshot oldParam = oldParams.get(entry.getKey()); + MethodParameterSnapshot newParam = entry.getValue(); + if (oldParam == null) { + unmatchedNew.add(newParam); + } else if (!oldParam.getType().equals(newParam.getType())) { + changes.add(ParameterChange.modified( + newParam.displayName(), + newParam.getType(), + newParam.getDescription(), + oldParam.getType() + " → " + newParam.getType(), + newParam.getSource(), + null, null, null)); + } + } + for (Map.Entry entry : oldParams.entrySet()) { + if (!newParams.containsKey(entry.getKey())) { + unmatchedOld.add(entry.getValue()); + } + } + + pairRenamedBindingParams(unmatchedOld, unmatchedNew, changes); + + for (MethodParameterSnapshot removed : unmatchedOld) { + changes.add(ParameterChange.removed( + removed.displayName(), removed.getType(), removed.getDescription(), + removed.getSource(), null, null, null)); + } + for (MethodParameterSnapshot added : unmatchedNew) { + changes.add(ParameterChange.added( + added.displayName(), added.getType(), added.getDescription(), + added.getSource(), null, null, null)); + } + return changes; + } + + /** 形参名+类型相同,仅绑定名变化 → 重命名 */ + private void pairRenamedBindingParams(List unmatchedOld, + List unmatchedNew, + List changes) { + Set pairedOld = new HashSet<>(); + Set pairedNew = new HashSet<>(); + + for (MethodParameterSnapshot oldParam : unmatchedOld) { + if (pairedOld.contains(oldParam)) { + continue; + } + for (MethodParameterSnapshot newParam : unmatchedNew) { + if (pairedNew.contains(newParam)) { + continue; + } + if (!oldParam.getSource().equals(newParam.getSource())) { + continue; + } + if (!oldParam.getName().equals(newParam.getName())) { + continue; + } + if (!oldParam.getType().equals(newParam.getType())) { + continue; + } + changes.add(ParameterChange.renamed( + oldParam.getBindingName(), + newParam.getBindingName(), + newParam.getType(), + newParam.getDescription(), + newParam.getSource(), + null, null, null)); + pairedOld.add(oldParam); + pairedNew.add(newParam); + break; + } + } + unmatchedOld.removeIf(pairedOld::contains); + unmatchedNew.removeIf(pairedNew::contains); + } + + private Map filterBindingParams(EndpointSnapshot snap) { + Map map = new LinkedHashMap<>(); + for (MethodParameterSnapshot p : snap.getParameters()) { + if (isBindingParam(p)) { + map.put(p.identityKey(), p); + } + } + return map; + } + + private Map filterBySource(EndpointSnapshot snap, String source) { + Map map = new LinkedHashMap<>(); + for (MethodParameterSnapshot p : snap.getParameters()) { + if (source.equals(p.getSource())) { + map.put(p.identityKey(), p); + } + } + return map; + } + + private boolean isBindingParam(MethodParameterSnapshot param) { + return "path".equals(param.getSource()) || "query".equals(param.getSource()); + } + private List diffBodyDto(MethodParameterSnapshot oldParam, MethodParameterSnapshot newParam) throws IOException { List oldFields = nestedDtoFieldParser.parseNestedFields(oldParam.getDtoClassName()); @@ -91,45 +204,29 @@ public class ParameterDiffEngine { } } - private List addedChanges(MethodParameterSnapshot param) throws IOException { - if ("body".equals(param.getSource())) { - List list = new ArrayList<>(); - for (NestedFieldInfo field : nestedDtoFieldParser.parseNestedFields(param.getDtoClassName())) { - list.add(ParameterChange.added(field.getPath(), field.getType(), field.getDescription(), - "body", param.getName(), param.getDtoClassName(), field.getPath())); - } - return list; - } - return List.of(ParameterChange.added(param.getName(), param.getType(), param.getDescription(), - param.getSource(), null, null, null)); - } - - private List removedChanges(MethodParameterSnapshot param) throws IOException { - if ("body".equals(param.getSource())) { - List list = new ArrayList<>(); - for (NestedFieldInfo field : nestedDtoFieldParser.parseNestedFields(param.getDtoClassName())) { - list.add(ParameterChange.removed(field.getPath(), field.getType(), field.getDescription(), - "body", param.getName(), param.getDtoClassName(), field.getPath())); - } - return list; - } - return List.of(ParameterChange.removed(param.getName(), param.getType(), param.getDescription(), - param.getSource(), null, null, null)); - } - - private Map toParamMap(EndpointSnapshot snap) { - Map map = new LinkedHashMap<>(); - for (MethodParameterSnapshot p : snap.getParameters()) { - map.put(p.identityKey(), p); - } - return map; - } - - private List toFieldInfo(List nested) { - List list = new ArrayList<>(); - for (NestedFieldInfo info : nested) { - list.add(new FieldInfo(info.getPath(), info.getType(), info.getDescription())); + private List addedBodyChanges(MethodParameterSnapshot param) throws IOException { + List list = new ArrayList<>(); + for (NestedFieldInfo field : nestedDtoFieldParser.parseNestedFields(param.getDtoClassName())) { + list.add(ParameterChange.added(field.getPath(), field.getType(), field.getDescription(), + "body", param.getName(), param.getDtoClassName(), field.getPath())); } return list; } + + private List removedBodyChanges(MethodParameterSnapshot param) throws IOException { + List list = new ArrayList<>(); + for (NestedFieldInfo field : nestedDtoFieldParser.parseNestedFields(param.getDtoClassName())) { + list.add(ParameterChange.removed(field.getPath(), field.getType(), field.getDescription(), + "body", param.getName(), param.getDtoClassName(), field.getPath())); + } + return list; + } + + private List toFieldInfo(List nested) { + List result = new ArrayList<>(); + for (NestedFieldInfo info : nested) { + result.add(new FieldInfo(info.getPath(), info.getType(), info.getDescription())); + } + return result; + } } diff --git a/.gitea/checker/src/main/java/com/codechecker/api/model/EndpointSnapshot.java b/.gitea/checker/src/main/java/com/codechecker/api/model/EndpointSnapshot.java index 04e3e6a..cff7b38 100644 --- a/.gitea/checker/src/main/java/com/codechecker/api/model/EndpointSnapshot.java +++ b/.gitea/checker/src/main/java/com/codechecker/api/model/EndpointSnapshot.java @@ -2,7 +2,6 @@ package com.codechecker.api.model; import java.util.ArrayList; import java.util.List; -import java.util.stream.Collectors; /** * 单个 HTTP/Feign 接口快照。 @@ -32,9 +31,13 @@ public class EndpointSnapshot { public static String buildFingerprint(String sourceFile, String methodName, List parameters) { - String sig = parameters.stream() - .map(p -> p.getType()) - .collect(Collectors.joining(",")); + StringBuilder sig = new StringBuilder(); + for (int i = 0; i < parameters.size(); i++) { + if (i > 0) { + sig.append(','); + } + sig.append(parameters.get(i).fingerprintSlotKey(i)); + } return sourceFile + "#" + methodName + "#" + sig; } diff --git a/.gitea/checker/src/main/java/com/codechecker/api/model/MethodParameterSnapshot.java b/.gitea/checker/src/main/java/com/codechecker/api/model/MethodParameterSnapshot.java index 606aea8..2c92334 100644 --- a/.gitea/checker/src/main/java/com/codechecker/api/model/MethodParameterSnapshot.java +++ b/.gitea/checker/src/main/java/com/codechecker/api/model/MethodParameterSnapshot.java @@ -5,15 +5,17 @@ package com.codechecker.api.model; */ public class MethodParameterSnapshot { private final String name; + private final String bindingName; private final String type; private final String source; private final boolean required; private final String description; private final String dtoClassName; - public MethodParameterSnapshot(String name, String type, String source, + public MethodParameterSnapshot(String name, String bindingName, String type, String source, boolean required, String description, String dtoClassName) { this.name = name; + this.bindingName = bindingName == null || bindingName.isBlank() ? name : bindingName; this.type = type; this.source = source; this.required = required; @@ -25,6 +27,11 @@ public class MethodParameterSnapshot { return name; } + /** 对外绑定名(@PathVariable / @RequestParam 的 value/name,缺省为形参名) */ + public String getBindingName() { + return bindingName; + } + public String getType() { return type; } @@ -46,7 +53,24 @@ public class MethodParameterSnapshot { return dtoClassName; } + /** 接口指纹槽位:序号 + 参数来源,不含类型与绑定名,避免类型/绑定变更导致误配对 */ + public String fingerprintSlotKey(int index) { + return index + ":" + source; + } + + /** path/query 按绑定名匹配,避免仅 Java 形参重命名误报 */ public String identityKey() { + if ("path".equals(source) || "query".equals(source)) { + return source + ":" + bindingName; + } return source + ":" + name; } + + /** 通知展示名:path/query 展示绑定名 */ + public String displayName() { + if ("path".equals(source) || "query".equals(source)) { + return bindingName; + } + return name; + } } diff --git a/.gitea/checker/src/main/java/com/codechecker/api/notify/ApiChangeNotifier.java b/.gitea/checker/src/main/java/com/codechecker/api/notify/ApiChangeNotifier.java index 484311a..4e32d81 100644 --- a/.gitea/checker/src/main/java/com/codechecker/api/notify/ApiChangeNotifier.java +++ b/.gitea/checker/src/main/java/com/codechecker/api/notify/ApiChangeNotifier.java @@ -205,6 +205,9 @@ public class ApiChangeNotifier { case RENAMED: tag = MarkdownStyles.colorWarning("[重命名]"); break; + case MODIFIED: + tag = MarkdownStyles.colorWarning(change.isBodyField() ? "[修改]" : "[类型变更]"); + break; default: tag = MarkdownStyles.colorWarning("[修改]"); break; @@ -214,19 +217,23 @@ public class ApiChangeNotifier { ? MarkdownStyles.colorComment("(无说明)") : MarkdownStyles.colorComment(change.getDescription()); StringBuilder line = new StringBuilder(); - line.append(MarkdownStyles.quoteLine(tag + " " + name + " 说明: " + desc)); if (change.getChangeType() == ParameterChange.ChangeType.RENAMED) { - line = new StringBuilder(); - line.append(MarkdownStyles.quoteLine(tag + " " - + MarkdownStyles.colorComment(MarkdownStyles.safe(change.getOldName())) + " → " - + MarkdownStyles.colorInfo(MarkdownStyles.safe(change.getParamName())) - + " 说明: " + desc)); + line.append(tag).append(" ") + .append(MarkdownStyles.colorComment(MarkdownStyles.safe(change.getOldName()))).append(" → ") + .append(MarkdownStyles.colorInfo(MarkdownStyles.safe(change.getParamName()))) + .append(" 说明: ").append(desc); + } else { + line.append(tag).append(" ").append(name).append(" 说明: ").append(desc); } + appendParameterType(line, change); + return MarkdownStyles.quoteLine(line.toString()); + } + + private void appendParameterType(StringBuilder line, ParameterChange change) { String typePart = resolveTypePart(change); if (!typePart.isBlank()) { - return line + "\n" + MarkdownStyles.quoteKv("类型", typePart); + line.append(" 类型: ").append(typePart); } - return line.toString(); } private String formatUriWithMethod(String httpMethod, String uri, boolean isNew) { diff --git a/.gitea/checker/src/main/java/com/codechecker/api/parser/EndpointSnapshotParser.java b/.gitea/checker/src/main/java/com/codechecker/api/parser/EndpointSnapshotParser.java index 3031d5e..7303000 100644 --- a/.gitea/checker/src/main/java/com/codechecker/api/parser/EndpointSnapshotParser.java +++ b/.gitea/checker/src/main/java/com/codechecker/api/parser/EndpointSnapshotParser.java @@ -106,6 +106,7 @@ public class EndpointSnapshotParser { } private List extractParameters(MethodDeclaration method) { + Map paramDescriptions = MethodParamJavadocExtractor.extract(method); List params = new ArrayList<>(); for (Parameter parameter : method.getParameters()) { String typeName = TypeNameUtils.typeToString(parameter.getType()); @@ -114,20 +115,43 @@ public class EndpointSnapshotParser { continue; } String source = resolveParamSource(parameter); + String paramName = parameter.getNameAsString(); + String bindingName = resolveBindingName(parameter, source, paramName); boolean required = resolveRequired(parameter, source); String dtoName = "body".equals(source) ? simple : ""; + String description = paramDescriptions.getOrDefault(paramName, ""); params.add(new MethodParameterSnapshot( - parameter.getNameAsString(), + paramName, + bindingName, typeName, source, required, - "", + description, dtoName )); } return params; } + private String resolveBindingName(Parameter parameter, String source, String paramName) { + if (!"path".equals(source) && !"query".equals(source)) { + return paramName; + } + String annName = "path".equals(source) ? "PathVariable" : "RequestParam"; + for (AnnotationExpr ann : parameter.getAnnotations()) { + if (!annName.equals(ann.getNameAsString())) { + continue; + } + List bindings = readStringArray(ann, "value", "name"); + for (String binding : bindings) { + if (binding != null && !binding.isBlank()) { + return binding; + } + } + } + return paramName; + } + private String resolveParamSource(Parameter parameter) { for (AnnotationExpr ann : parameter.getAnnotations()) { String name = ann.getNameAsString(); diff --git a/.gitea/checker/src/main/java/com/codechecker/api/parser/MethodParamJavadocExtractor.java b/.gitea/checker/src/main/java/com/codechecker/api/parser/MethodParamJavadocExtractor.java new file mode 100644 index 0000000..a3dbcd7 --- /dev/null +++ b/.gitea/checker/src/main/java/com/codechecker/api/parser/MethodParamJavadocExtractor.java @@ -0,0 +1,67 @@ +package com.codechecker.api.parser; + +import com.github.javaparser.ast.body.MethodDeclaration; +import com.github.javaparser.ast.comments.JavadocComment; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 从方法 Javadoc 的 @param 标签提取形参说明(按 Java 形参名匹配)。 + */ +public final class MethodParamJavadocExtractor { + private static final Pattern PARAM_TAG = Pattern.compile( + "@param\\s+(\\w+)\\s+(.+?)(?=\\s*@param\\s+|\\s*@return\\s+|\\s*@throws\\s+|\\s*@see\\s+|\\s*\\*/|$)", + Pattern.DOTALL); + + private MethodParamJavadocExtractor() { + } + + public static Map extract(MethodDeclaration method) { + Map descriptions = new HashMap<>(); + if (method == null) { + return descriptions; + } + Optional javadoc = method.getJavadocComment(); + if (javadoc.isEmpty()) { + return descriptions; + } + String raw = javadoc.get().getContent(); + if (raw == null || raw.isBlank()) { + return descriptions; + } + String normalized = raw.replace('\r', '\n'); + Matcher matcher = PARAM_TAG.matcher(normalized); + while (matcher.find()) { + String paramName = matcher.group(1).trim(); + String desc = cleanDescription(matcher.group(2)); + if (!paramName.isBlank()) { + descriptions.put(paramName, desc); + } + } + return descriptions; + } + + private static String cleanDescription(String text) { + if (text == null) { + return ""; + } + StringBuilder sb = new StringBuilder(); + for (String line : text.split("\n")) { + String trimmed = line.trim(); + if (trimmed.startsWith("*")) { + trimmed = trimmed.substring(1).trim(); + } + if (!trimmed.isEmpty()) { + if (sb.length() > 0) { + sb.append(' '); + } + sb.append(trimmed); + } + } + return sb.toString().trim(); + } +}