源码update
All checks were successful
CodeChecker 变更检测 / code-check (push) Successful in 20s

This commit is contained in:
2026-06-09 15:43:04 +08:00
parent c8840e2af0
commit f94c24a0ab
10 changed files with 540 additions and 24 deletions

View File

@@ -3,20 +3,28 @@ package com.codechecker;
import com.codechecker.analyzer.ClassChangeAnalyzer; import com.codechecker.analyzer.ClassChangeAnalyzer;
import com.codechecker.analyzer.EndpointIndexBuilder; import com.codechecker.analyzer.EndpointIndexBuilder;
import com.codechecker.api.analyzer.ApiChangeAnalyzer; import com.codechecker.api.analyzer.ApiChangeAnalyzer;
import com.codechecker.api.analyzer.DtoImpactedApiAnalyzer;
import com.codechecker.api.model.ApiChangeKind;
import com.codechecker.api.model.EndpointChangeReport; import com.codechecker.api.model.EndpointChangeReport;
import com.codechecker.api.notify.ApiChangeNotifier; import com.codechecker.api.notify.ApiChangeNotifier;
import com.codechecker.api.scanner.ApiFileChangeScanner;
import com.codechecker.config.AppConfig; import com.codechecker.config.AppConfig;
import com.codechecker.git.GitChangeScanner; import com.codechecker.git.GitChangeScanner;
import com.codechecker.model.ApiEndpoint; import com.codechecker.model.ApiEndpoint;
import com.codechecker.model.ClassChangeReport; import com.codechecker.model.ClassChangeReport;
import com.codechecker.notify.OverlapNotificationFilter;
import com.codechecker.notify.WeComNotifier; import com.codechecker.notify.WeComNotifier;
import picocli.CommandLine; import picocli.CommandLine;
import picocli.CommandLine.Command; import picocli.CommandLine.Command;
import picocli.CommandLine.Option; import picocli.CommandLine.Option;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set;
import java.util.concurrent.Callable; import java.util.concurrent.Callable;
/** /**
@@ -49,7 +57,7 @@ public class CodeCheckMain implements Callable<Integer> {
System.exit(exitCode); System.exit(exitCode);
} }
/** 主流程:类变更与 API 变更独立检测、分条通知 */ /** 主流程:类变更与 API 变更检测,支持 Dto 跟进与重叠通知策略 */
@Override @Override
public Integer call() throws Exception { public Integer call() throws Exception {
AppConfig appConfig = AppConfig.load(config.toAbsolutePath()); AppConfig appConfig = AppConfig.load(config.toAbsolutePath());
@@ -59,27 +67,34 @@ public class CodeCheckMain implements Callable<Integer> {
} }
GitChangeScanner gitScanner = new GitChangeScanner(repoRoot.toAbsolutePath()); GitChangeScanner gitScanner = new GitChangeScanner(repoRoot.toAbsolutePath());
int totalSent = 0; List<ClassChangeReport> classReports = List.of();
List<EndpointChangeReport> apiReports = List.of();
if (appConfig.isClassCheckEnabled()) { if (appConfig.isClassCheckEnabled()) {
totalSent += executeClassCheck(appConfig, gitScanner); classReports = analyzeClassChanges(appConfig, gitScanner);
} else { } else {
System.out.println("类变更检测已关闭class_check.enabled=false"); System.out.println("类变更检测已关闭class_check.enabled=false");
} }
if (appConfig.isApiCheckEnabled()) { if (appConfig.isApiCheckEnabled()) {
totalSent += executeApiCheck(appConfig, gitScanner); apiReports = analyzeApiChanges(appConfig, gitScanner, classReports);
} else { } else {
System.out.println("API 变更检测已关闭api_check.enabled=false"); System.out.println("API 变更检测已关闭api_check.enabled=false");
} }
OverlapNotificationFilter.FilterResult filtered = OverlapNotificationFilter.apply(
classReports, apiReports, appConfig.getDtoOverlapMode());
int totalSent = sendClassNotifications(appConfig, filtered.classReports())
+ sendApiNotifications(appConfig, filtered.apiReports());
if (totalSent == 0 && appConfig.isOnlyOnChange()) { if (totalSent == 0 && appConfig.isOnlyOnChange()) {
System.out.println("无变更,静默退出"); System.out.println("无变更,静默退出");
} }
return 0; return 0;
} }
private int executeClassCheck(AppConfig appConfig, GitChangeScanner gitScanner) throws Exception { private List<ClassChangeReport> analyzeClassChanges(AppConfig appConfig, GitChangeScanner gitScanner)
throws Exception {
System.out.println("=== 类变更检测 ==="); System.out.println("=== 类变更检测 ===");
EndpointIndexBuilder indexBuilder = new EndpointIndexBuilder(); EndpointIndexBuilder indexBuilder = new EndpointIndexBuilder();
Map<String, ApiEndpoint> endpointIndex = indexBuilder.buildIndex(repoRoot.toAbsolutePath(), appConfig); Map<String, ApiEndpoint> endpointIndex = indexBuilder.buildIndex(repoRoot.toAbsolutePath(), appConfig);
@@ -89,6 +104,55 @@ public class CodeCheckMain implements Callable<Integer> {
List<ClassChangeReport> reports = analyzer.analyze( List<ClassChangeReport> reports = analyzer.analyze(
repoRoot.toAbsolutePath(), appConfig, oldSha, newSha, endpointIndex); repoRoot.toAbsolutePath(), appConfig, oldSha, newSha, endpointIndex);
System.out.println("检测到需通知的类变更数量: " + reports.size()); System.out.println("检测到需通知的类变更数量: " + reports.size());
return reports;
}
private List<EndpointChangeReport> analyzeApiChanges(AppConfig appConfig, GitChangeScanner gitScanner,
List<ClassChangeReport> classReports) throws Exception {
System.out.println("=== API 变更检测 ===");
ApiFileChangeScanner fileScanner = new ApiFileChangeScanner(gitScanner);
Set<String> changedApiFiles = new LinkedHashSet<>(fileScanner.scanChangedFiles(
repoRoot.toAbsolutePath(), appConfig.getAllApiScanDirs(), oldSha, newSha));
ApiChangeAnalyzer analyzer = new ApiChangeAnalyzer(gitScanner);
List<EndpointChangeReport> reports = new ArrayList<>();
if (!changedApiFiles.isEmpty()) {
reports.addAll(analyzer.analyze(repoRoot.toAbsolutePath(), appConfig, oldSha, newSha));
}
if (appConfig.isDtoApiFollowUpEnabled() && !classReports.isEmpty()) {
DtoImpactedApiAnalyzer dtoAnalyzer = new DtoImpactedApiAnalyzer(gitScanner);
List<EndpointChangeReport> followUpReports = dtoAnalyzer.analyze(
repoRoot.toAbsolutePath(), appConfig, oldSha, newSha, classReports, changedApiFiles);
if (!followUpReports.isEmpty()) {
System.out.println("Dto 跟进检测到 API 参数变更数量: " + followUpReports.size());
reports.addAll(followUpReports);
}
}
reports = dedupeApiReports(reports);
System.out.println("检测到需通知的 API 变更数量: " + reports.size());
return reports;
}
private List<EndpointChangeReport> dedupeApiReports(List<EndpointChangeReport> reports) {
Map<String, EndpointChangeReport> merged = new LinkedHashMap<>();
for (EndpointChangeReport report : reports) {
String key = report.getChangeKind() + "|" + report.getHttpMethod() + "|" + report.getUri();
EndpointChangeReport existing = merged.get(key);
if (existing == null) {
merged.put(key, report);
continue;
}
if (report.getChangeKind() == ApiChangeKind.PARAM_CHANGED
&& existing.getChangeKind() == ApiChangeKind.PARAM_CHANGED) {
report.getParameterChanges().forEach(existing::addParameterChange);
}
}
return new ArrayList<>(merged.values());
}
private int sendClassNotifications(AppConfig appConfig, List<ClassChangeReport> reports) {
if (reports.isEmpty()) { if (reports.isEmpty()) {
return 0; return 0;
} }
@@ -100,12 +164,7 @@ public class CodeCheckMain implements Callable<Integer> {
return reports.size(); return reports.size();
} }
private int executeApiCheck(AppConfig appConfig, GitChangeScanner gitScanner) throws Exception { private int sendApiNotifications(AppConfig appConfig, List<EndpointChangeReport> reports) {
System.out.println("=== API 变更检测 ===");
ApiChangeAnalyzer analyzer = new ApiChangeAnalyzer(gitScanner);
List<EndpointChangeReport> reports = analyzer.analyze(
repoRoot.toAbsolutePath(), appConfig, oldSha, newSha);
System.out.println("检测到需通知的 API 变更数量: " + reports.size());
if (reports.isEmpty()) { if (reports.isEmpty()) {
return 0; return 0;
} }

View File

@@ -34,7 +34,7 @@ public class ApiChangeAnalyzer {
EndpointSnapshotParser parser = new EndpointSnapshotParser(config.isApiExcludeFrameworkParams()); EndpointSnapshotParser parser = new EndpointSnapshotParser(config.isApiExcludeFrameworkParams());
ParameterDiffEngine parameterDiffEngine = new ParameterDiffEngine( ParameterDiffEngine parameterDiffEngine = new ParameterDiffEngine(
repoRoot, buildSearchDirs(config)); repoRoot, buildSearchDirs(config), gitScanner, oldSha, newSha);
EndpointDiffEngine endpointDiffEngine = new EndpointDiffEngine(parameterDiffEngine); EndpointDiffEngine endpointDiffEngine = new EndpointDiffEngine(parameterDiffEngine);
List<EndpointSnapshot> oldSnapshots = new ArrayList<>(); List<EndpointSnapshot> oldSnapshots = new ArrayList<>();

View File

@@ -0,0 +1,133 @@
package com.codechecker.api.analyzer;
import com.codechecker.api.model.ApiChangeKind;
import com.codechecker.api.model.EndpointChangeReport;
import com.codechecker.api.model.EndpointSnapshot;
import com.codechecker.api.model.ParameterChange;
import com.codechecker.api.parser.EndpointSnapshotParser;
import com.codechecker.config.AppConfig;
import com.codechecker.git.GitChangeScanner;
import com.codechecker.model.ApiEndpoint;
import com.codechecker.model.ClassChangeReport;
import com.codechecker.model.ClassType;
import java.io.IOException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 类变更Dto 字段)后,对受影响的 Controller 继续 API 参数 diff产出 PARAM_CHANGED 报告。
*/
public class DtoImpactedApiAnalyzer {
private final GitChangeScanner gitScanner;
public DtoImpactedApiAnalyzer(GitChangeScanner gitScanner) {
this.gitScanner = gitScanner;
}
public List<EndpointChangeReport> analyze(Path repoRoot, AppConfig config,
String oldSha, String newSha,
List<ClassChangeReport> classReports,
Set<String> alreadyScannedFiles) throws IOException {
Map<String, Set<String>> controllerToDtos = collectImpactedControllers(classReports, alreadyScannedFiles);
if (controllerToDtos.isEmpty()) {
return List.of();
}
EndpointSnapshotParser parser = new EndpointSnapshotParser(config.isApiExcludeFrameworkParams());
ParameterDiffEngine parameterDiffEngine = new ParameterDiffEngine(
repoRoot, buildSearchDirs(config), gitScanner, oldSha, newSha);
EndpointDiffEngine endpointDiffEngine = new EndpointDiffEngine(parameterDiffEngine);
List<EndpointSnapshot> oldSnapshots = new ArrayList<>();
List<EndpointSnapshot> newSnapshots = new ArrayList<>();
for (String path : controllerToDtos.keySet()) {
boolean feign = isFeignPath(path, config);
String oldSource = gitScanner.readFileAtCommit(oldSha, path);
String newSource = gitScanner.readFileAtCommit(newSha, path);
oldSnapshots.addAll(parser.parseSource(oldSource, path, feign));
newSnapshots.addAll(parser.parseSource(newSource, path, feign));
}
List<EndpointChangeReport> reports = new ArrayList<>();
for (EndpointChangeReport report : endpointDiffEngine.diff(oldSnapshots, newSnapshots)) {
if (report.getChangeKind() != ApiChangeKind.PARAM_CHANGED || !report.hasParameterChanges()) {
continue;
}
String relatedDto = findRelatedDto(report, controllerToDtos);
if (relatedDto == null) {
continue;
}
reports.add(EndpointChangeReport.dtoFollowUp(report, relatedDto));
}
return reports;
}
private Map<String, Set<String>> collectImpactedControllers(List<ClassChangeReport> classReports,
Set<String> alreadyScannedFiles) {
Map<String, Set<String>> controllerToDtos = new LinkedHashMap<>();
for (ClassChangeReport report : classReports) {
if (report.getClassType() != ClassType.DTO || report.getFieldChanges().isEmpty()) {
continue;
}
Set<String> dtoNames = dtoNames(report);
for (ApiEndpoint endpoint : report.getInputImpactEndpoints()) {
String controllerFile = endpoint.getSourceFile();
if (alreadyScannedFiles.contains(controllerFile)) {
continue;
}
controllerToDtos.computeIfAbsent(controllerFile, k -> new LinkedHashSet<>()).addAll(dtoNames);
}
}
return controllerToDtos;
}
private String findRelatedDto(EndpointChangeReport report, Map<String, Set<String>> controllerToDtos) {
Set<String> impactedDtos = controllerToDtos.getOrDefault(report.getSourceFile(), Set.of());
for (ParameterChange change : report.getParameterChanges()) {
if (!"body".equals(change.getSource())) {
continue;
}
String parentDto = change.getParentDto();
if (parentDto != null && impactedDtos.contains(parentDto)) {
return parentDto;
}
}
return null;
}
private Set<String> dtoNames(ClassChangeReport report) {
Set<String> names = new LinkedHashSet<>();
names.add(report.getClassName());
if (report.getOldClassName() != null && !report.getOldClassName().isBlank()) {
names.add(report.getOldClassName());
}
return names;
}
private List<String> buildSearchDirs(AppConfig config) {
List<String> dirs = new ArrayList<>();
dirs.addAll(config.getModelDirs());
dirs.addAll(config.getAllApiScanDirs());
return dirs;
}
private boolean isFeignPath(String path, AppConfig config) {
String normalized = path.replace('\\', '/');
for (String dir : config.getApiFeignScanDirs()) {
String prefix = dir.replace('\\', '/');
if (!prefix.endsWith("/")) {
prefix = prefix + "/";
}
if (normalized.startsWith(prefix)) {
return true;
}
}
return false;
}
}

View File

@@ -6,6 +6,7 @@ import com.codechecker.api.model.MethodParameterSnapshot;
import com.codechecker.api.model.ParameterChange; import com.codechecker.api.model.ParameterChange;
import com.codechecker.api.parser.NestedDtoFieldParser; import com.codechecker.api.parser.NestedDtoFieldParser;
import com.codechecker.api.parser.NestedFieldInfo; import com.codechecker.api.parser.NestedFieldInfo;
import com.codechecker.git.GitChangeScanner;
import com.codechecker.model.FieldChange; import com.codechecker.model.FieldChange;
import com.codechecker.model.FieldInfo; import com.codechecker.model.FieldInfo;
@@ -31,8 +32,9 @@ public class ParameterDiffEngine {
private final NestedDtoFieldParser nestedDtoFieldParser; private final NestedDtoFieldParser nestedDtoFieldParser;
private final FieldDiffEngine fieldDiffEngine = new FieldDiffEngine(); private final FieldDiffEngine fieldDiffEngine = new FieldDiffEngine();
public ParameterDiffEngine(Path repoRoot, List<String> searchDirs) { public ParameterDiffEngine(Path repoRoot, List<String> searchDirs,
this.nestedDtoFieldParser = new NestedDtoFieldParser(repoRoot, searchDirs); GitChangeScanner gitScanner, String oldSha, String newSha) {
this.nestedDtoFieldParser = new NestedDtoFieldParser(repoRoot, searchDirs, gitScanner, oldSha, newSha);
} }
public List<ParameterChange> diff(EndpointSnapshot oldSnap, EndpointSnapshot newSnap) throws IOException { public List<ParameterChange> diff(EndpointSnapshot oldSnap, EndpointSnapshot newSnap) throws IOException {
@@ -175,8 +177,8 @@ public class ParameterDiffEngine {
private List<ParameterChange> diffBodyDto(MethodParameterSnapshot oldParam, private List<ParameterChange> diffBodyDto(MethodParameterSnapshot oldParam,
MethodParameterSnapshot newParam) throws IOException { MethodParameterSnapshot newParam) throws IOException {
List<NestedFieldInfo> oldFields = nestedDtoFieldParser.parseNestedFields(oldParam.getDtoClassName()); List<NestedFieldInfo> oldFields = nestedDtoFieldParser.parseNestedFieldsAtOldCommit(oldParam.getDtoClassName());
List<NestedFieldInfo> newFields = nestedDtoFieldParser.parseNestedFields(newParam.getDtoClassName()); List<NestedFieldInfo> newFields = nestedDtoFieldParser.parseNestedFieldsAtNewCommit(newParam.getDtoClassName());
List<FieldChange> fieldChanges = fieldDiffEngine.diff(toFieldInfo(oldFields), toFieldInfo(newFields)); List<FieldChange> fieldChanges = fieldDiffEngine.diff(toFieldInfo(oldFields), toFieldInfo(newFields));
List<ParameterChange> result = new ArrayList<>(); List<ParameterChange> result = new ArrayList<>();
for (FieldChange fc : fieldChanges) { for (FieldChange fc : fieldChanges) {
@@ -206,7 +208,7 @@ public class ParameterDiffEngine {
private List<ParameterChange> addedBodyChanges(MethodParameterSnapshot param) throws IOException { private List<ParameterChange> addedBodyChanges(MethodParameterSnapshot param) throws IOException {
List<ParameterChange> list = new ArrayList<>(); List<ParameterChange> list = new ArrayList<>();
for (NestedFieldInfo field : nestedDtoFieldParser.parseNestedFields(param.getDtoClassName())) { for (NestedFieldInfo field : nestedDtoFieldParser.parseNestedFieldsAtNewCommit(param.getDtoClassName())) {
list.add(ParameterChange.added(field.getPath(), field.getType(), field.getDescription(), list.add(ParameterChange.added(field.getPath(), field.getType(), field.getDescription(),
"body", param.getName(), param.getDtoClassName(), field.getPath())); "body", param.getName(), param.getDtoClassName(), field.getPath()));
} }
@@ -215,7 +217,7 @@ public class ParameterDiffEngine {
private List<ParameterChange> removedBodyChanges(MethodParameterSnapshot param) throws IOException { private List<ParameterChange> removedBodyChanges(MethodParameterSnapshot param) throws IOException {
List<ParameterChange> list = new ArrayList<>(); List<ParameterChange> list = new ArrayList<>();
for (NestedFieldInfo field : nestedDtoFieldParser.parseNestedFields(param.getDtoClassName())) { for (NestedFieldInfo field : nestedDtoFieldParser.parseNestedFieldsAtOldCommit(param.getDtoClassName())) {
list.add(ParameterChange.removed(field.getPath(), field.getType(), field.getDescription(), list.add(ParameterChange.removed(field.getPath(), field.getType(), field.getDescription(),
"body", param.getName(), param.getDtoClassName(), field.getPath())); "body", param.getName(), param.getDtoClassName(), field.getPath()));
} }

View File

@@ -15,11 +15,20 @@ public class EndpointChangeReport {
private final String sourceFile; private final String sourceFile;
private final String controllerClass; private final String controllerClass;
private final String endpointDescription; private final String endpointDescription;
private final boolean dtoFollowUp;
private final String relatedDtoClassName;
private final List<ParameterChange> parameterChanges = new ArrayList<>(); private final List<ParameterChange> parameterChanges = new ArrayList<>();
public EndpointChangeReport(ApiChangeKind changeKind, String httpMethod, String oldHttpMethod, public EndpointChangeReport(ApiChangeKind changeKind, String httpMethod, String oldHttpMethod,
String uri, String oldUri, String sourceFile, String controllerClass, String uri, String oldUri, String sourceFile, String controllerClass,
String endpointDescription) { String endpointDescription) {
this(changeKind, httpMethod, oldHttpMethod, uri, oldUri, sourceFile, controllerClass,
endpointDescription, false, null);
}
public EndpointChangeReport(ApiChangeKind changeKind, String httpMethod, String oldHttpMethod,
String uri, String oldUri, String sourceFile, String controllerClass,
String endpointDescription, boolean dtoFollowUp, String relatedDtoClassName) {
this.changeKind = changeKind; this.changeKind = changeKind;
this.httpMethod = httpMethod; this.httpMethod = httpMethod;
this.oldHttpMethod = oldHttpMethod; this.oldHttpMethod = oldHttpMethod;
@@ -28,6 +37,25 @@ public class EndpointChangeReport {
this.sourceFile = sourceFile; this.sourceFile = sourceFile;
this.controllerClass = controllerClass; this.controllerClass = controllerClass;
this.endpointDescription = endpointDescription == null ? "" : endpointDescription; this.endpointDescription = endpointDescription == null ? "" : endpointDescription;
this.dtoFollowUp = dtoFollowUp;
this.relatedDtoClassName = relatedDtoClassName;
}
/** 基于已有报告创建 Dto 跟进产生的副本 */
public static EndpointChangeReport dtoFollowUp(EndpointChangeReport source, String relatedDtoClassName) {
EndpointChangeReport copy = new EndpointChangeReport(
source.getChangeKind(),
source.getHttpMethod(),
source.getOldHttpMethod(),
source.getUri(),
source.getOldUri(),
source.getSourceFile(),
source.getControllerClass(),
source.getEndpointDescription(),
true,
relatedDtoClassName);
source.getParameterChanges().forEach(copy::addParameterChange);
return copy;
} }
public ApiChangeKind getChangeKind() { public ApiChangeKind getChangeKind() {
@@ -73,4 +101,12 @@ public class EndpointChangeReport {
public boolean hasParameterChanges() { public boolean hasParameterChanges() {
return !parameterChanges.isEmpty(); return !parameterChanges.isEmpty();
} }
public boolean isDtoFollowUp() {
return dtoFollowUp;
}
public String getRelatedDtoClassName() {
return relatedDtoClassName;
}
} }

View File

@@ -1,5 +1,7 @@
package com.codechecker.api.parser; package com.codechecker.api.parser;
import com.codechecker.git.GitChangeScanner;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
@@ -27,6 +29,24 @@ public class JavaSourceLocator {
return Optional.of(Files.readString(path.get())); return Optional.of(Files.readString(path.get()));
} }
public Optional<String> readSourceAtCommit(GitChangeScanner gitScanner, String sha,
String simpleClassName) throws IOException {
Optional<String> relativePath = findRelativePath(simpleClassName);
if (relativePath.isEmpty()) {
return Optional.empty();
}
String source = gitScanner.readFileAtCommit(sha, relativePath.get());
if (source == null || source.isBlank()) {
return Optional.empty();
}
return Optional.of(source);
}
public Optional<String> findRelativePath(String simpleClassName) throws IOException {
Optional<Path> path = findFile(simpleClassName);
return path.map(p -> repoRoot.relativize(p).toString().replace('\\', '/'));
}
public Optional<Path> findFile(String simpleClassName) throws IOException { public Optional<Path> findFile(String simpleClassName) throws IOException {
String fileName = simpleClassName + ".java"; String fileName = simpleClassName + ".java";
for (String dir : searchDirs) { for (String dir : searchDirs) {

View File

@@ -1,5 +1,6 @@
package com.codechecker.api.parser; package com.codechecker.api.parser;
import com.codechecker.git.GitChangeScanner;
import com.codechecker.model.FieldInfo; import com.codechecker.model.FieldInfo;
import com.codechecker.parser.ClassFieldParser; import com.codechecker.parser.ClassFieldParser;
import com.codechecker.parser.TypeNameUtils; import com.codechecker.parser.TypeNameUtils;
@@ -25,25 +26,40 @@ public class NestedDtoFieldParser {
private final ClassFieldParser classFieldParser = new ClassFieldParser(); private final ClassFieldParser classFieldParser = new ClassFieldParser();
private final JavaSourceLocator sourceLocator; private final JavaSourceLocator sourceLocator;
private final GitChangeScanner gitScanner;
private final String oldSha;
private final String newSha;
public NestedDtoFieldParser(Path repoRoot, List<String> searchDirs) { public NestedDtoFieldParser(Path repoRoot, List<String> searchDirs,
GitChangeScanner gitScanner, String oldSha, String newSha) {
this.sourceLocator = new JavaSourceLocator(repoRoot, searchDirs); this.sourceLocator = new JavaSourceLocator(repoRoot, searchDirs);
this.gitScanner = gitScanner;
this.oldSha = oldSha;
this.newSha = newSha;
} }
public List<NestedFieldInfo> parseNestedFields(String dtoClassName) throws IOException { public List<NestedFieldInfo> parseNestedFieldsAtOldCommit(String dtoClassName) throws IOException {
return parseNestedFields(dtoClassName, oldSha);
}
public List<NestedFieldInfo> parseNestedFieldsAtNewCommit(String dtoClassName) throws IOException {
return parseNestedFields(dtoClassName, newSha);
}
private List<NestedFieldInfo> parseNestedFields(String dtoClassName, String sha) throws IOException {
Set<String> visiting = new HashSet<>(); Set<String> visiting = new HashSet<>();
List<NestedFieldInfo> result = new ArrayList<>(); List<NestedFieldInfo> result = new ArrayList<>();
collectFields(dtoClassName, "", visiting, result); collectFields(dtoClassName, "", visiting, result, sha);
return result; return result;
} }
private void collectFields(String className, String prefix, Set<String> visiting, private void collectFields(String className, String prefix, Set<String> visiting,
List<NestedFieldInfo> out) throws IOException { List<NestedFieldInfo> out, String sha) throws IOException {
if (className == null || className.isBlank() || visiting.contains(className)) { if (className == null || className.isBlank() || visiting.contains(className)) {
return; return;
} }
visiting.add(className); visiting.add(className);
Optional<String> source = sourceLocator.readSourceBySimpleName(className); Optional<String> source = readSource(className, sha);
if (source.isEmpty()) { if (source.isEmpty()) {
visiting.remove(className); visiting.remove(className);
return; return;
@@ -56,12 +72,19 @@ public class NestedDtoFieldParser {
out.add(new NestedFieldInfo(path, field.getType(), field.getDescription())); out.add(new NestedFieldInfo(path, field.getType(), field.getDescription()));
} else { } else {
out.add(new NestedFieldInfo(path, field.getType(), field.getDescription())); out.add(new NestedFieldInfo(path, field.getType(), field.getDescription()));
collectFields(simpleType, path, visiting, out); collectFields(simpleType, path, visiting, out, sha);
} }
} }
visiting.remove(className); visiting.remove(className);
} }
private Optional<String> readSource(String className, String sha) throws IOException {
if (sha != null && gitScanner != null) {
return sourceLocator.readSourceAtCommit(gitScanner, sha, className);
}
return sourceLocator.readSourceBySimpleName(className);
}
private boolean isLeafType(String simpleType) { private boolean isLeafType(String simpleType) {
return LEAF_TYPES.contains(simpleType) || simpleType.endsWith("[]"); return LEAF_TYPES.contains(simpleType) || simpleType.endsWith("[]");
} }

View File

@@ -25,10 +25,12 @@ public class AppConfig {
private boolean wecomEnabled = true; private boolean wecomEnabled = true;
private boolean onlyOnChange = true; private boolean onlyOnChange = true;
private boolean dtoApiFollowUpEnabled = true;
private boolean apiCheckEnabled = true; private boolean apiCheckEnabled = true;
private boolean apiExcludeFrameworkParams = true; private boolean apiExcludeFrameworkParams = true;
private List<String> apiControllerScanDirs = new ArrayList<>(); private List<String> apiControllerScanDirs = new ArrayList<>();
private List<String> apiFeignScanDirs = new ArrayList<>(); private List<String> apiFeignScanDirs = new ArrayList<>();
private DtoOverlapMode dtoOverlapMode = DtoOverlapMode.BOTH;
/** 从 YAML 文件加载配置 */ /** 从 YAML 文件加载配置 */
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
@@ -49,6 +51,9 @@ public class AppConfig {
Map<String, Object> classCheck = mapOrEmpty(root.get("class_check")); Map<String, Object> classCheck = mapOrEmpty(root.get("class_check"));
config.classCheckEnabled = boolOrDefault(classCheck.get("enabled"), true); config.classCheckEnabled = boolOrDefault(classCheck.get("enabled"), true);
Map<String, Object> dtoApiFollowUp = mapOrEmpty(classCheck.get("dto_api_follow_up"));
config.dtoApiFollowUpEnabled = boolOrDefault(dtoApiFollowUp.get("enabled"), true);
Map<String, Object> conversion = mapOrEmpty(classCheck.get("dto_entity_conversion")); Map<String, Object> conversion = mapOrEmpty(classCheck.get("dto_entity_conversion"));
config.dtoEntityConversionEnabled = boolOrDefault(conversion.get("enabled"), true); config.dtoEntityConversionEnabled = boolOrDefault(conversion.get("enabled"), true);
@@ -64,6 +69,7 @@ public class AppConfig {
Map<String, Object> notify = mapOrEmpty(root.get("notify")); Map<String, Object> notify = mapOrEmpty(root.get("notify"));
config.onlyOnChange = boolOrDefault(notify.get("only_on_change"), true); config.onlyOnChange = boolOrDefault(notify.get("only_on_change"), true);
config.dtoOverlapMode = DtoOverlapMode.fromString(stringOrEmpty(notify.get("dto_overlap_mode")));
Map<String, Object> apiCheck = mapOrEmpty(root.get("api_check")); Map<String, Object> apiCheck = mapOrEmpty(root.get("api_check"));
config.apiCheckEnabled = boolOrDefault(apiCheck.get("enabled"), true); config.apiCheckEnabled = boolOrDefault(apiCheck.get("enabled"), true);
@@ -169,11 +175,21 @@ public class AppConfig {
return onlyOnChange; return onlyOnChange;
} }
/** Dto 类变更后是否继续检测受影响接口的 API 参数变更 */
public boolean isDtoApiFollowUpEnabled() {
return dtoApiFollowUpEnabled;
}
/** API 变更检测总开关 */ /** API 变更检测总开关 */
public boolean isApiCheckEnabled() { public boolean isApiCheckEnabled() {
return apiCheckEnabled; return apiCheckEnabled;
} }
/** Dto 类变更与 API 参数变更重叠时的通知策略 */
public DtoOverlapMode getDtoOverlapMode() {
return dtoOverlapMode;
}
/** 是否排除 Spring 框架注入参数 */ /** 是否排除 Spring 框架注入参数 */
public boolean isApiExcludeFrameworkParams() { public boolean isApiExcludeFrameworkParams() {
return apiExcludeFrameworkParams; return apiExcludeFrameworkParams;

View File

@@ -0,0 +1,24 @@
package com.codechecker.config;
/**
* Dto 类变更与 API 参数变更重叠时的通知策略。
*/
public enum DtoOverlapMode {
/** 仅发类变更通知,抑制重叠的 API 参数通知 */
CLASS_ONLY,
/** 仅发 API 参数通知,抑制重叠的类变更通知 */
API_ONLY,
/** 两类通知均发送 */
BOTH;
public static DtoOverlapMode fromString(String value) {
if (value == null || value.isBlank()) {
return BOTH;
}
try {
return DtoOverlapMode.valueOf(value.trim().toUpperCase());
} catch (IllegalArgumentException ex) {
return BOTH;
}
}
}

View File

@@ -0,0 +1,203 @@
package com.codechecker.notify;
import com.codechecker.api.model.ApiChangeKind;
import com.codechecker.api.model.EndpointChangeReport;
import com.codechecker.api.model.ParameterChange;
import com.codechecker.config.DtoOverlapMode;
import com.codechecker.model.ApiEndpoint;
import com.codechecker.model.ClassChangeReport;
import com.codechecker.model.ClassType;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
/**
* 按配置过滤 Dto 类变更与 API 参数变更的重叠通知。
*/
public class OverlapNotificationFilter {
public static final class FilterResult {
private final List<ClassChangeReport> classReports;
private final List<EndpointChangeReport> apiReports;
public FilterResult(List<ClassChangeReport> classReports, List<EndpointChangeReport> apiReports) {
this.classReports = classReports;
this.apiReports = apiReports;
}
public List<ClassChangeReport> classReports() {
return classReports;
}
public List<EndpointChangeReport> apiReports() {
return apiReports;
}
}
public static FilterResult apply(List<ClassChangeReport> classReports,
List<EndpointChangeReport> apiReports,
DtoOverlapMode mode) {
if (mode == DtoOverlapMode.BOTH) {
return new FilterResult(classReports, apiReports);
}
Set<OverlapKey> overlapKeys = buildOverlapKeys(classReports);
if (overlapKeys.isEmpty()) {
return new FilterResult(classReports, apiReports);
}
if (mode == DtoOverlapMode.CLASS_ONLY) {
return new FilterResult(classReports, filterApiReports(apiReports, overlapKeys));
}
Set<OverlapKey> apiOverlapKeys = buildApiOverlapKeys(apiReports);
return new FilterResult(filterClassReportsForApiOnly(classReports, apiOverlapKeys), apiReports);
}
private static Set<OverlapKey> buildOverlapKeys(List<ClassChangeReport> classReports) {
Set<OverlapKey> keys = new LinkedHashSet<>();
for (ClassChangeReport report : classReports) {
if (report.getClassType() != ClassType.DTO) {
continue;
}
if (!hasDtoFieldChanges(report)) {
continue;
}
Set<String> dtoNames = dtoNames(report);
for (ApiEndpoint endpoint : report.getInputImpactEndpoints()) {
for (String dtoName : dtoNames) {
keys.add(new OverlapKey(dtoName, endpoint.endpointKey()));
}
}
}
return keys;
}
private static List<EndpointChangeReport> filterApiReports(List<EndpointChangeReport> apiReports,
Set<OverlapKey> overlapKeys) {
List<EndpointChangeReport> kept = new ArrayList<>();
for (EndpointChangeReport report : apiReports) {
if (!matchesOverlap(report, overlapKeys)) {
kept.add(report);
}
}
return kept;
}
private static List<ClassChangeReport> filterClassReportsForApiOnly(List<ClassChangeReport> classReports,
Set<OverlapKey> apiOverlapKeys) {
List<ClassChangeReport> kept = new ArrayList<>();
for (ClassChangeReport report : classReports) {
if (!shouldSuppressClassForApiOnly(report, apiOverlapKeys)) {
kept.add(report);
}
}
return kept;
}
private static Set<OverlapKey> buildApiOverlapKeys(List<EndpointChangeReport> apiReports) {
Set<OverlapKey> keys = new LinkedHashSet<>();
for (EndpointChangeReport report : apiReports) {
if (report.getChangeKind() != ApiChangeKind.PARAM_CHANGED) {
continue;
}
String endpointKey = report.getHttpMethod() + " " + report.getUri();
for (ParameterChange change : report.getParameterChanges()) {
if (!"body".equals(change.getSource())) {
continue;
}
String parentDto = change.getParentDto();
if (parentDto != null && !parentDto.isBlank()) {
keys.add(new OverlapKey(parentDto, endpointKey));
}
}
if (report.isDtoFollowUp()) {
String relatedDto = report.getRelatedDtoClassName();
if (relatedDto != null && !relatedDto.isBlank()) {
keys.add(new OverlapKey(relatedDto, endpointKey));
}
}
}
return keys;
}
private static boolean shouldSuppressClassForApiOnly(ClassChangeReport report,
Set<OverlapKey> apiOverlapKeys) {
if (report.getClassType() != ClassType.DTO || !hasDtoFieldChanges(report)) {
return false;
}
for (ApiEndpoint endpoint : report.getInputImpactEndpoints()) {
for (String dtoName : dtoNames(report)) {
if (apiOverlapKeys.contains(new OverlapKey(dtoName, endpoint.endpointKey()))) {
return true;
}
}
}
return false;
}
private static boolean matchesOverlap(EndpointChangeReport report, Set<OverlapKey> overlapKeys) {
if (report.getChangeKind() != ApiChangeKind.PARAM_CHANGED) {
return false;
}
String endpointKey = report.getHttpMethod() + " " + report.getUri();
for (ParameterChange change : report.getParameterChanges()) {
if (!"body".equals(change.getSource())) {
continue;
}
String parentDto = change.getParentDto();
if (parentDto == null || parentDto.isBlank()) {
continue;
}
if (overlapKeys.contains(new OverlapKey(parentDto, endpointKey))) {
return true;
}
}
if (report.isDtoFollowUp()) {
String relatedDto = report.getRelatedDtoClassName();
if (relatedDto != null && overlapKeys.contains(new OverlapKey(relatedDto, endpointKey))) {
return true;
}
}
return false;
}
private static boolean hasDtoFieldChanges(ClassChangeReport report) {
return !report.getFieldChanges().isEmpty();
}
private static Set<String> dtoNames(ClassChangeReport report) {
Set<String> names = new LinkedHashSet<>();
names.add(report.getClassName());
if (report.getOldClassName() != null && !report.getOldClassName().isBlank()) {
names.add(report.getOldClassName());
}
return names;
}
private static final class OverlapKey {
private final String dtoClassName;
private final String endpointKey;
private OverlapKey(String dtoClassName, String endpointKey) {
this.dtoClassName = dtoClassName;
this.endpointKey = endpointKey;
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (!(obj instanceof OverlapKey)) {
return false;
}
OverlapKey other = (OverlapKey) obj;
return dtoClassName.equals(other.dtoClassName) && endpointKey.equals(other.endpointKey);
}
@Override
public int hashCode() {
return dtoClassName.hashCode() * 31 + endpointKey.hashCode();
}
}
}