feat: 删除pom
Some checks failed
Redis序列化结构检查 / redis-schema-check (push) Has been cancelled

This commit is contained in:
2026-07-13 15:54:38 +08:00
parent 436460cfdb
commit 30e530c0bf
39 changed files with 164 additions and 159 deletions

View File

@@ -0,0 +1,66 @@
package com.codechecker.redis.analyze;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
/**
* 扫描工作树中受包含/排除模块限制的 {@code src/main/java} 下的所有 Java 源文件。
*/
public class FileScanner {
private final Path repoRoot;
private final List<String> includeModules;
private final List<String> excludeModules;
public FileScanner(Path repoRoot, List<String> includeModules, List<String> excludeModules) {
this.repoRoot = repoRoot;
this.includeModules = includeModules;
this.excludeModules = excludeModules;
}
/**
* @return 相对仓库根(/ 分隔)-> 文件内容
*/
public Map<String, String> scan() {
Map<String, String> result = new LinkedHashMap<>();
try (Stream<Path> stream = Files.walk(repoRoot)) {
stream.filter(Files::isRegularFile)
.filter(p -> p.toString().endsWith(".java"))
.forEach(p -> {
String rel = repoRoot.relativize(p).toString().replace('\\', '/');
if (!rel.contains("/src/main/java/")) {
return;
}
if (!moduleAllowed(rel)) {
return;
}
try {
result.put(rel, new String(Files.readAllBytes(p), StandardCharsets.UTF_8));
} catch (IOException e) {
throw new UncheckedIOException(e);
}
});
} catch (IOException e) {
throw new UncheckedIOException(e);
}
return result;
}
boolean moduleAllowed(String relPath) {
String topModule = relPath.contains("/") ? relPath.substring(0, relPath.indexOf('/')) : relPath;
if (excludeModules != null && excludeModules.contains(topModule)) {
return false;
}
if (includeModules == null || includeModules.isEmpty()) {
return true;
}
return includeModules.contains(topModule);
}
}

View File

@@ -0,0 +1,34 @@
package com.codechecker.redis.analyze;
import java.util.regex.Pattern;
/**
* 极简 glob 匹配:{@code *} 与 {@code **} 均匹配任意字符(含空),其余按字面匹配。整串锚定。
*/
public final class GlobMatcher {
private GlobMatcher() {
}
public static boolean matches(String glob, String input) {
if (glob == null || input == null) {
return false;
}
StringBuilder regex = new StringBuilder("^");
int i = 0;
while (i < glob.length()) {
char ch = glob.charAt(i);
if (ch == '*') {
while (i < glob.length() && glob.charAt(i) == '*') {
i++;
}
regex.append(".*");
} else {
regex.append(Pattern.quote(String.valueOf(ch)));
i++;
}
}
regex.append('$');
return Pattern.compile(regex.toString()).matcher(input).matches();
}
}

View File

@@ -0,0 +1,308 @@
package com.codechecker.redis.analyze;
import com.codechecker.redis.config.CheckerConfig;
import com.codechecker.redis.detector.RedisWritePointDetector;
import com.codechecker.redis.detector.WritePoint;
import com.codechecker.redis.diff.ChangeType;
import com.codechecker.redis.diff.SchemaChange;
import com.codechecker.redis.diff.SchemaDiffer;
import com.codechecker.redis.diff.Severity;
import com.codechecker.redis.git.GitDiffScanner;
import com.codechecker.redis.git.GitException;
import com.codechecker.redis.report.CheckReport;
import com.codechecker.redis.schema.JavaSchemaExtractor;
import com.codechecker.redis.schema.SourceIndex;
import com.codechecker.redis.schema.TypeSchema;
import com.github.javaparser.StaticJavaParser;
import com.github.javaparser.ast.CompilationUnit;
import com.github.javaparser.ast.body.TypeDeclaration;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
/**
* 端到端分析编排git diff → 定位写入点 → 双版本 Schema 提取 → 结构对比 → 生成报告。
*/
public class SchemaCheckAnalyzer {
private final CheckerConfig config;
private final Path repoRoot;
public SchemaCheckAnalyzer(CheckerConfig config, Path repoRoot) {
this.config = config;
this.repoRoot = repoRoot;
}
public CheckReport analyze(String oldSha, String newSha) throws GitException {
GitDiffScanner scanner = new GitDiffScanner(repoRoot);
List<String> changedRaw = scanner.changedJavaFiles(oldSha, newSha);
Set<String> changedFiles = new LinkedHashSet<>();
FileScanner fileScanner = new FileScanner(repoRoot, config.getIncludeModules(), config.getExcludeModules());
for (String path : changedRaw) {
if (path.contains("/src/main/java/") && fileScanner.moduleAllowed(path) && !isFileIgnored(path)) {
changedFiles.add(path);
}
}
// 当前工作树(= newSha 检出)内容
Map<String, String> newContents = fileScanner.scan();
// 旧版本内容:以工作树为基线,用 old 版本覆盖变更文件
Map<String, String> oldContents = new LinkedHashMap<>(newContents);
for (String path : changedFiles) {
String oldContent = scanner.fileContentAt(oldSha, path);
if (oldContent == null) {
oldContents.remove(path); // 新增文件在旧版本不存在
} else {
oldContents.put(path, oldContent);
}
}
SourceIndex newIndex = buildIndex(newContents.values());
SourceIndex oldIndex = buildIndex(oldContents.values());
// 变更涉及的类型简单名(用于扩展候选写入点文件)
Set<String> changedTypeNames = new HashSet<>();
for (String path : changedFiles) {
collectTypeNames(newContents.get(path), changedTypeNames);
collectTypeNames(oldContents.get(path), changedTypeNames);
}
Set<String> candidates = new LinkedHashSet<>(changedFiles);
Pattern typePattern = buildTypePattern(changedTypeNames);
if (typePattern != null) {
for (Map.Entry<String, String> e : newContents.entrySet()) {
if (!candidates.contains(e.getKey()) && typePattern.matcher(e.getValue()).find()) {
candidates.add(e.getKey());
}
}
}
Set<String> patterns = new HashSet<>(config.getDetection().getPatterns());
RedisWritePointDetector detectorNew = new RedisWritePointDetector(newIndex, patterns);
RedisWritePointDetector detectorOld = new RedisWritePointDetector(oldIndex, patterns);
JavaSchemaExtractor extractorNew = new JavaSchemaExtractor(newIndex, config.getDetection().getMaxFieldDepth());
JavaSchemaExtractor extractorOld = new JavaSchemaExtractor(oldIndex, config.getDetection().getMaxFieldDepth());
SchemaDiffer differ = new SchemaDiffer();
List<SchemaChange> allChanges = new ArrayList<>();
for (String path : candidates) {
String newContent = newContents.get(path);
if (newContent == null) {
continue;
}
boolean fileChanged = changedFiles.contains(path);
String oldContent = fileChanged ? oldContents.get(path) : newContent;
List<WritePoint> newWps = detectorNew.detect(path, newContent);
List<WritePoint> oldWps = oldContent == null
? new ArrayList<>() : detectorOld.detect(path, oldContent);
Map<String, WritePoint> oldBySig = new LinkedHashMap<>();
for (WritePoint wp : oldWps) {
oldBySig.put(wp.signature(), wp);
}
Set<String> newSigs = new HashSet<>();
for (WritePoint nw : newWps) {
newSigs.add(nw.signature());
if (isKeyIgnored(nw.getResolvedKeyPattern()) || isWriterIgnored(nw)) {
continue;
}
WritePoint ow = oldBySig.get(nw.signature());
if (ow != null) {
TypeSchema oldSchema = extractorOld.extract(ow.getResolvedValueType(), ow.isRootArray());
TypeSchema newSchema = extractorNew.extract(nw.getResolvedValueType(), nw.isRootArray());
List<SchemaChange> changes = differ.diff(oldSchema, newSchema);
double confidence = min(nw.getConfidence(), ow.getConfidence(),
oldSchema.getConfidence(), newSchema.getConfidence());
enrich(changes, nw, confidence);
allChanges.addAll(changes);
} else if (fileChanged) {
SchemaChange c = new SchemaChange(ChangeType.WRITE_POINT_ADDED);
c.setKeyPattern(nw.getResolvedKeyPattern());
c.setWriteLocation(nw.location());
c.setMessage("新增 Redis 写入点value 类型: " + shortType(nw.getResolvedValueType()));
allChanges.add(c);
}
}
if (fileChanged) {
for (WritePoint ow : oldWps) {
if (!newSigs.contains(ow.signature())
&& !isKeyIgnored(ow.getResolvedKeyPattern()) && !isWriterIgnored(ow)) {
SchemaChange c = new SchemaChange(ChangeType.WRITE_POINT_REMOVED);
c.setKeyPattern(ow.getResolvedKeyPattern());
c.setWriteLocation(ow.location());
c.setMessage("删除 Redis 写入点,原 value 类型: " + shortType(ow.getResolvedValueType()));
allChanges.add(c);
}
}
}
}
List<SchemaChange> finalChanges = postProcess(allChanges);
return buildReport(oldSha, newSha, finalChanges);
}
private void enrich(List<SchemaChange> changes, WritePoint wp, double confidence) {
boolean lowConfidence = confidence < config.getDetection().getMinConfidence();
for (SchemaChange c : changes) {
c.setKeyPattern(wp.getResolvedKeyPattern());
c.setWriteLocation(wp.location());
if (lowConfidence) {
c.setSeverity(Severity.P2);
c.setMessage(c.getMessage() + "(低置信度,建议人工确认)");
}
}
}
private List<SchemaChange> postProcess(List<SchemaChange> changes) {
List<SchemaChange> result = new ArrayList<>();
Set<String> seen = new HashSet<>();
for (SchemaChange c : changes) {
applySeverityOverride(c);
if (isSuppressed(c)) {
continue;
}
String dedupKey = c.getChangeType() + "|" + c.getKeyPattern() + "|"
+ c.getWriteLocation() + "|" + c.getFieldPath();
if (seen.add(dedupKey)) {
result.add(c);
}
}
return result;
}
private void applySeverityOverride(SchemaChange c) {
String override = config.getSeverityOverrides().get(c.getChangeType().name());
if (override != null) {
try {
c.setSeverity(Severity.valueOf(override.trim().toUpperCase()));
} catch (IllegalArgumentException ignored) {
// 无效级别忽略
}
}
}
private boolean isSuppressed(SchemaChange c) {
for (CheckerConfig.Suppression s : config.getSuppressions()) {
boolean keyMatch = s.getKeyPattern() == null
|| s.getKeyPattern().equals(c.getKeyPattern());
boolean typeMatch = s.getChangeTypes() == null || s.getChangeTypes().isEmpty()
|| s.getChangeTypes().contains(c.getChangeType().name());
if (keyMatch && typeMatch && (s.getKeyPattern() != null
|| (s.getChangeTypes() != null && !s.getChangeTypes().isEmpty()))) {
return true;
}
}
return false;
}
private CheckReport buildReport(String oldSha, String newSha, List<SchemaChange> changes) {
CheckReport report = new CheckReport();
report.setOldSha(oldSha);
report.setNewSha(newSha);
report.setMode(config.getMode());
report.getChanges().addAll(changes);
boolean blocked = false;
if (config.isBlockMode()) {
Set<String> blockSev = new HashSet<>(config.getBlockSeverities());
for (SchemaChange c : changes) {
if (blockSev.contains(c.getSeverity().name())) {
blocked = true;
break;
}
}
}
report.setBlocked(blocked);
report.setExitCode(blocked ? 1 : 0);
return report;
}
private SourceIndex buildIndex(Iterable<String> contents) {
SourceIndex index = new SourceIndex();
for (String content : contents) {
index.addSource(content);
}
return index;
}
private void collectTypeNames(String content, Set<String> out) {
if (content == null || content.isEmpty()) {
return;
}
try {
CompilationUnit cu = StaticJavaParser.parse(content);
for (TypeDeclaration<?> type : cu.findAll(TypeDeclaration.class)) {
out.add(type.getNameAsString());
}
} catch (RuntimeException ignored) {
// 解析失败跳过
}
}
private Pattern buildTypePattern(Set<String> typeNames) {
List<String> valid = new ArrayList<>();
for (String name : typeNames) {
if (name != null && name.matches("[A-Za-z_][A-Za-z0-9_]*")) {
valid.add(Pattern.quote(name));
}
}
if (valid.isEmpty()) {
return null;
}
return Pattern.compile("\\b(" + String.join("|", valid) + ")\\b");
}
private boolean isFileIgnored(String path) {
for (String glob : config.getIgnore().getFilePatterns()) {
if (GlobMatcher.matches(glob, path)) {
return true;
}
}
return false;
}
private boolean isKeyIgnored(String keyPattern) {
if (keyPattern == null) {
return false;
}
for (String glob : config.getIgnore().getKeyPatterns()) {
if (GlobMatcher.matches(glob, keyPattern)) {
return true;
}
}
return false;
}
private boolean isWriterIgnored(WritePoint wp) {
String sig = wp.getEnclosingClass() + "#" + wp.getEnclosingMethod();
return config.getIgnore().getWriterMethods().contains(sig);
}
private String shortType(String fqn) {
if (fqn == null) {
return "<未解析>";
}
return fqn.contains(".") ? fqn.substring(fqn.lastIndexOf('.') + 1) : fqn;
}
private double min(double... values) {
double m = 1.0;
for (double v : values) {
m = Math.min(m, v);
}
return m;
}
}

View File

@@ -0,0 +1,98 @@
package com.codechecker.redis.cli;
import com.codechecker.redis.analyze.SchemaCheckAnalyzer;
import com.codechecker.redis.config.CheckerConfig;
import com.codechecker.redis.config.ConfigLoader;
import com.codechecker.redis.notify.WeComNotifier;
import com.codechecker.redis.report.CheckReport;
import com.codechecker.redis.report.ReportBuilder;
import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.concurrent.Callable;
/**
* 命令行入口。退出码0 通过 / 1 阻断 / 2 执行错误。
*/
@Command(name = "redis-schema-checker",
mixinStandardHelpOptions = true,
version = "redis-schema-checker 1.0.0",
description = "检测两次提交间 Redis value 序列化结构变更并通过企微机器人通知。")
public class RedisSchemaCheckerMain implements Callable<Integer> {
@Option(names = "--config", required = true, description = "业务仓库检测配置文件路径")
private Path configPath;
@Option(names = "--repo-root", required = true, description = "被检测仓库根目录")
private Path repoRoot;
@Option(names = "--old-sha", required = true, description = "对比基准提交")
private String oldSha;
@Option(names = "--new-sha", required = true, description = "当前提交")
private String newSha;
@Option(names = "--branch", description = "分支名(用于报告展示)")
private String branch;
@Option(names = "--modifier", description = "提交人")
private String modifier;
@Option(names = "--modify-time", description = "提交时间")
private String modifyTime;
@Option(names = "--repository", description = "仓库名(默认取仓库目录名)")
private String repository;
@Option(names = "--dry-run", description = "只输出报告,不发送企微通知")
private boolean dryRun;
@Override
public Integer call() {
try {
CheckerConfig config = ConfigLoader.load(configPath);
if (oldSha == null || oldSha.trim().isEmpty()) {
System.out.println("[redis-schema-checker] 无对比基准提交,跳过检测。");
return 0;
}
Path root = repoRoot.toAbsolutePath().normalize();
SchemaCheckAnalyzer analyzer = new SchemaCheckAnalyzer(config, root);
CheckReport report = analyzer.analyze(oldSha, newSha);
report.setBranch(branch);
report.setModifier(modifier);
report.setModifyTime(modifyTime);
report.setRepository(repository != null ? repository : root.getFileName().toString());
ReportBuilder builder = new ReportBuilder(config.getNotify().getTitlePrefix());
System.out.println(builder.toConsole(report));
boolean shouldNotify = config.getNotify().isEnabled()
&& (report.hasChanges() || config.getNotify().isNotifyOnClean());
if (shouldNotify && !dryRun) {
String webhook = System.getenv(config.getNotify().getWebhookEnv());
boolean ok = new WeComNotifier().sendMarkdown(webhook, builder.toMarkdown(report));
System.out.println("[redis-schema-checker] 企微通知发送: " + (ok ? "成功" : "失败/跳过"));
}
if (report.isBlocked()) {
System.out.println("[redis-schema-checker] block 模式命中流水线将被阻断exit 1");
}
return report.getExitCode();
} catch (Exception e) {
System.err.println("[redis-schema-checker] 执行错误: " + e.getMessage());
e.printStackTrace();
return 2;
}
}
public static void main(String[] args) {
int exitCode = new CommandLine(new RedisSchemaCheckerMain()).execute(args);
System.exit(exitCode);
}
}

View File

@@ -0,0 +1,332 @@
package com.codechecker.redis.config;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 检测器运行配置。由 {@link ConfigLoader} 加载 jar 内默认配置并与业务配置深度合并后构建。
*/
public class CheckerConfig {
/** notify | block */
private String mode = "notify";
private List<String> blockSeverities = new ArrayList<>();
private boolean scanTestSources = false;
private List<String> sourceRoots = new ArrayList<>();
private Notify notify = new Notify();
private Ignore ignore = new Ignore();
private Detection detection = new Detection();
private Map<String, String> severityOverrides = new LinkedHashMap<>();
private List<ManualMapping> manualMappings = new ArrayList<>();
private List<Suppression> suppressions = new ArrayList<>();
private List<String> includeModules = new ArrayList<>();
private List<String> excludeModules = new ArrayList<>();
public static class Notify {
private boolean enabled = true;
private String webhookEnv = "WECOM_ROBOT_WEBHOOK";
private boolean notifyOnClean = false;
private String titlePrefix = "[Redis结构变更]";
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public String getWebhookEnv() {
return webhookEnv;
}
public void setWebhookEnv(String webhookEnv) {
this.webhookEnv = webhookEnv;
}
public boolean isNotifyOnClean() {
return notifyOnClean;
}
public void setNotifyOnClean(boolean notifyOnClean) {
this.notifyOnClean = notifyOnClean;
}
public String getTitlePrefix() {
return titlePrefix;
}
public void setTitlePrefix(String titlePrefix) {
this.titlePrefix = titlePrefix;
}
}
public static class Ignore {
private List<String> keyPatterns = new ArrayList<>();
private List<String> filePatterns = new ArrayList<>();
private List<String> writerMethods = new ArrayList<>();
public List<String> getKeyPatterns() {
return keyPatterns;
}
public void setKeyPatterns(List<String> keyPatterns) {
this.keyPatterns = keyPatterns;
}
public List<String> getFilePatterns() {
return filePatterns;
}
public void setFilePatterns(List<String> filePatterns) {
this.filePatterns = filePatterns;
}
public List<String> getWriterMethods() {
return writerMethods;
}
public void setWriterMethods(List<String> writerMethods) {
this.writerMethods = writerMethods;
}
}
public static class Detection {
private List<String> patterns = new ArrayList<>();
private double minConfidence = 0.6;
private int maxFieldDepth = 8;
public List<String> getPatterns() {
return patterns;
}
public void setPatterns(List<String> patterns) {
this.patterns = patterns;
}
public double getMinConfidence() {
return minConfidence;
}
public void setMinConfidence(double minConfidence) {
this.minConfidence = minConfidence;
}
public int getMaxFieldDepth() {
return maxFieldDepth;
}
public void setMaxFieldDepth(int maxFieldDepth) {
this.maxFieldDepth = maxFieldDepth;
}
}
public static class ManualMapping {
private String id;
private String writerMethod;
private String keyPattern;
private String valueType;
private String description;
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getWriterMethod() {
return writerMethod;
}
public void setWriterMethod(String writerMethod) {
this.writerMethod = writerMethod;
}
public String getKeyPattern() {
return keyPattern;
}
public void setKeyPattern(String keyPattern) {
this.keyPattern = keyPattern;
}
public String getValueType() {
return valueType;
}
public void setValueType(String valueType) {
this.valueType = valueType;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
}
public static class Suppression {
private String id;
private String writerMethod;
private String keyPattern;
private List<String> changeTypes = new ArrayList<>();
private String reason;
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getWriterMethod() {
return writerMethod;
}
public void setWriterMethod(String writerMethod) {
this.writerMethod = writerMethod;
}
public String getKeyPattern() {
return keyPattern;
}
public void setKeyPattern(String keyPattern) {
this.keyPattern = keyPattern;
}
public List<String> getChangeTypes() {
return changeTypes;
}
public void setChangeTypes(List<String> changeTypes) {
this.changeTypes = changeTypes;
}
public String getReason() {
return reason;
}
public void setReason(String reason) {
this.reason = reason;
}
}
public String getMode() {
return mode;
}
public void setMode(String mode) {
this.mode = mode;
}
public List<String> getBlockSeverities() {
return blockSeverities;
}
public void setBlockSeverities(List<String> blockSeverities) {
this.blockSeverities = blockSeverities;
}
public boolean isScanTestSources() {
return scanTestSources;
}
public void setScanTestSources(boolean scanTestSources) {
this.scanTestSources = scanTestSources;
}
public List<String> getSourceRoots() {
return sourceRoots;
}
public void setSourceRoots(List<String> sourceRoots) {
this.sourceRoots = sourceRoots;
}
public Notify getNotify() {
return notify;
}
public void setNotify(Notify notify) {
this.notify = notify;
}
public Ignore getIgnore() {
return ignore;
}
public void setIgnore(Ignore ignore) {
this.ignore = ignore;
}
public Detection getDetection() {
return detection;
}
public void setDetection(Detection detection) {
this.detection = detection;
}
public Map<String, String> getSeverityOverrides() {
return severityOverrides;
}
public void setSeverityOverrides(Map<String, String> severityOverrides) {
this.severityOverrides = severityOverrides;
}
public List<ManualMapping> getManualMappings() {
return manualMappings;
}
public void setManualMappings(List<ManualMapping> manualMappings) {
this.manualMappings = manualMappings;
}
public List<Suppression> getSuppressions() {
return suppressions;
}
public void setSuppressions(List<Suppression> suppressions) {
this.suppressions = suppressions;
}
public List<String> getIncludeModules() {
return includeModules;
}
public void setIncludeModules(List<String> includeModules) {
this.includeModules = includeModules;
}
public List<String> getExcludeModules() {
return excludeModules;
}
public void setExcludeModules(List<String> excludeModules) {
this.excludeModules = excludeModules;
}
public boolean isBlockMode() {
return "block".equalsIgnoreCase(mode);
}
}

View File

@@ -0,0 +1,195 @@
package com.codechecker.redis.config;
import org.yaml.snakeyaml.Yaml;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 双层配置加载器:先读取 jar 内 {@code default-config.yaml},再与业务仓库配置深度合并(业务配置优先)。
*/
public final class ConfigLoader {
private static final String DEFAULT_CONFIG_RESOURCE = "default-config.yaml";
private ConfigLoader() {
}
public static CheckerConfig load(Path businessConfigPath) {
Map<String, Object> merged = loadDefault();
if (businessConfigPath != null && Files.exists(businessConfigPath)) {
Map<String, Object> business = loadYaml(businessConfigPath);
merged = deepMerge(merged, business);
}
return bind(merged);
}
@SuppressWarnings("unchecked")
static Map<String, Object> loadDefault() {
try (InputStream in = ConfigLoader.class.getClassLoader().getResourceAsStream(DEFAULT_CONFIG_RESOURCE)) {
if (in == null) {
throw new IllegalStateException("jar 内缺少 default-config.yaml");
}
Object obj = new Yaml().load(in);
return obj == null ? new LinkedHashMap<>() : (Map<String, Object>) obj;
} catch (IOException e) {
throw new IllegalStateException("读取默认配置失败", e);
}
}
@SuppressWarnings("unchecked")
static Map<String, Object> loadYaml(Path path) {
try (InputStream in = Files.newInputStream(path)) {
Object obj = new Yaml().load(in);
return obj == null ? new LinkedHashMap<>() : (Map<String, Object>) obj;
} catch (IOException e) {
throw new IllegalStateException("读取业务配置失败: " + path, e);
}
}
@SuppressWarnings("unchecked")
static Map<String, Object> deepMerge(Map<String, Object> base, Map<String, Object> override) {
Map<String, Object> result = new LinkedHashMap<>(base);
for (Map.Entry<String, Object> entry : override.entrySet()) {
String key = entry.getKey();
Object overrideValue = entry.getValue();
Object baseValue = result.get(key);
if (baseValue instanceof Map && overrideValue instanceof Map) {
result.put(key, deepMerge((Map<String, Object>) baseValue, (Map<String, Object>) overrideValue));
} else {
// 标量、列表:业务配置直接覆盖
result.put(key, overrideValue);
}
}
return result;
}
@SuppressWarnings("unchecked")
private static CheckerConfig bind(Map<String, Object> map) {
CheckerConfig config = new CheckerConfig();
config.setMode(str(map, "mode", "notify"));
config.setBlockSeverities(strList(map.get("block_severities")));
config.setScanTestSources(bool(map, "scan_test_sources", false));
config.setSourceRoots(strList(map.get("source_roots")));
config.setIncludeModules(strList(map.get("include_modules")));
config.setExcludeModules(strList(map.get("exclude_modules")));
Map<String, Object> notify = asMap(map.get("notify"));
CheckerConfig.Notify n = config.getNotify();
n.setEnabled(bool(notify, "enabled", true));
n.setWebhookEnv(str(notify, "webhook_env", "WECOM_ROBOT_WEBHOOK"));
n.setNotifyOnClean(bool(notify, "notify_on_clean", false));
n.setTitlePrefix(str(notify, "title_prefix", "[Redis结构变更]"));
Map<String, Object> ignore = asMap(map.get("ignore"));
CheckerConfig.Ignore ig = config.getIgnore();
ig.setKeyPatterns(strList(ignore.get("key_patterns")));
ig.setFilePatterns(strList(ignore.get("file_patterns")));
ig.setWriterMethods(strList(ignore.get("writer_methods")));
Map<String, Object> detection = asMap(map.get("detection"));
CheckerConfig.Detection d = config.getDetection();
d.setPatterns(strList(detection.get("patterns")));
d.setMinConfidence(dbl(detection, "min_confidence", 0.6));
d.setMaxFieldDepth((int) lng(detection, "max_field_depth", 8));
Map<String, Object> severityOverrides = asMap(map.get("severity_overrides"));
Map<String, String> so = new LinkedHashMap<>();
for (Map.Entry<String, Object> e : severityOverrides.entrySet()) {
so.put(e.getKey(), String.valueOf(e.getValue()));
}
config.setSeverityOverrides(so);
List<CheckerConfig.ManualMapping> mappings = new ArrayList<>();
for (Object item : asList(map.get("manual_mappings"))) {
Map<String, Object> m = asMap(item);
CheckerConfig.ManualMapping mm = new CheckerConfig.ManualMapping();
mm.setId(str(m, "id", null));
mm.setWriterMethod(str(m, "writer_method", null));
mm.setKeyPattern(str(m, "key_pattern", null));
mm.setValueType(str(m, "value_type", null));
mm.setDescription(str(m, "description", null));
mappings.add(mm);
}
config.setManualMappings(mappings);
List<CheckerConfig.Suppression> suppressions = new ArrayList<>();
for (Object item : asList(map.get("suppressions"))) {
Map<String, Object> m = asMap(item);
CheckerConfig.Suppression sp = new CheckerConfig.Suppression();
sp.setId(str(m, "id", null));
sp.setWriterMethod(str(m, "writer_method", null));
sp.setKeyPattern(str(m, "key_pattern", null));
sp.setChangeTypes(strList(m.get("change_types")));
sp.setReason(str(m, "reason", null));
suppressions.add(sp);
}
config.setSuppressions(suppressions);
return config;
}
@SuppressWarnings("unchecked")
private static Map<String, Object> asMap(Object obj) {
if (obj instanceof Map) {
return (Map<String, Object>) obj;
}
return new LinkedHashMap<>();
}
private static List<Object> asList(Object obj) {
if (obj instanceof List) {
return (List<Object>) obj;
}
return new ArrayList<>();
}
private static List<String> strList(Object obj) {
List<String> result = new ArrayList<>();
if (obj instanceof List) {
for (Object o : (List<?>) obj) {
if (o != null) {
result.add(String.valueOf(o));
}
}
}
return result;
}
private static String str(Map<String, Object> map, String key, String def) {
Object v = map.get(key);
return v == null ? def : String.valueOf(v);
}
private static boolean bool(Map<String, Object> map, String key, boolean def) {
Object v = map.get(key);
if (v instanceof Boolean) {
return (Boolean) v;
}
return v == null ? def : Boolean.parseBoolean(String.valueOf(v));
}
private static double dbl(Map<String, Object> map, String key, double def) {
Object v = map.get(key);
if (v instanceof Number) {
return ((Number) v).doubleValue();
}
return v == null ? def : Double.parseDouble(String.valueOf(v));
}
private static long lng(Map<String, Object> map, String key, long def) {
Object v = map.get(key);
if (v instanceof Number) {
return ((Number) v).longValue();
}
return v == null ? def : Long.parseLong(String.valueOf(v));
}
}

View File

@@ -0,0 +1,234 @@
package com.codechecker.redis.detector;
import com.codechecker.redis.key.RedisKeyResolver;
import com.codechecker.redis.schema.SourceIndex;
import com.github.javaparser.StaticJavaParser;
import com.github.javaparser.ast.CompilationUnit;
import com.github.javaparser.ast.body.CallableDeclaration;
import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration;
import com.github.javaparser.ast.body.FieldDeclaration;
import com.github.javaparser.ast.body.MethodDeclaration;
import com.github.javaparser.ast.body.Parameter;
import com.github.javaparser.ast.body.TypeDeclaration;
import com.github.javaparser.ast.body.VariableDeclarator;
import com.github.javaparser.ast.expr.Expression;
import com.github.javaparser.ast.expr.MethodCallExpr;
import com.github.javaparser.ast.expr.NameExpr;
import com.github.javaparser.ast.expr.ObjectCreationExpr;
import com.github.javaparser.ast.type.ClassOrInterfaceType;
import com.github.javaparser.ast.type.Type;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
/**
* 从单个 Java 源文件中检测 Redis value 写入点W01~W03JSON 字符串写入)。
*/
public class RedisWritePointDetector {
private static final Set<String> SERIALIZE_METHODS = new HashSet<>(Arrays.asList(
"toJSONString", "toJsonString", "getObjectToString", "toJsonStr", "writeValueAsString"));
private static final Set<String> WRITE_METHODS = new HashSet<>(Arrays.asList("set", "insert"));
private static final Set<String> COLLECTION_SIMPLE = new HashSet<>(Arrays.asList(
"List", "ArrayList", "LinkedList", "Set", "HashSet", "Collection"));
private final SourceIndex index;
private final Set<String> enabledPatterns;
private final RedisKeyResolver keyResolver;
public RedisWritePointDetector(SourceIndex index, Set<String> enabledPatterns) {
this.index = index;
this.enabledPatterns = enabledPatterns;
this.keyResolver = new RedisKeyResolver(index);
}
public List<WritePoint> detect(String filePath, String content) {
List<WritePoint> result = new ArrayList<>();
if (content == null || content.isEmpty()) {
return result;
}
CompilationUnit cu;
try {
cu = StaticJavaParser.parse(content);
} catch (RuntimeException e) {
return result;
}
for (MethodCallExpr mce : cu.findAll(MethodCallExpr.class)) {
String method = mce.getNameAsString();
if (!WRITE_METHODS.contains(method)) {
continue;
}
String scope = mce.getScope().map(Expression::toString).orElse("");
if (!isRedisScope(scope)) {
continue;
}
if (mce.getArguments().size() < 2) {
continue;
}
Expression valueArg = mce.getArgument(1);
Expression serialized = unwrapSerializer(valueArg);
if (serialized == null) {
continue; // 非 JSON 字符串写入W04/W05 归后续阶段)
}
String pattern = classify(method, valueArg);
if (!enabledPatterns.contains(pattern)) {
continue;
}
WritePoint wp = new WritePoint();
wp.setFilePath(filePath);
wp.setLineNumber(mce.getBegin().map(p -> p.line).orElse(0));
wp.setPattern(pattern);
wp.setKeyExpression(mce.getArgument(0).toString());
wp.setValueExpression(valueArg.toString());
fillEnclosing(mce, wp);
SourceIndex.IndexedType context = index.get(wp.getEnclosingClass());
ClassOrInterfaceDeclaration enclosingDecl = mce
.findAncestor(ClassOrInterfaceDeclaration.class).orElse(null);
wp.setResolvedKeyPattern(keyResolver.resolve(mce.getArgument(0), enclosingDecl, context));
InferredType inferred = inferType(serialized, mce, context);
if (inferred != null) {
wp.setResolvedValueType(inferred.fqn);
wp.setRootArray(inferred.isArray);
wp.setConfidence(inferred.fqn == null ? 0.4 : 1.0);
} else {
wp.setConfidence(0.4);
}
result.add(wp);
}
return result;
}
private boolean isRedisScope(String scope) {
String lower = scope.toLowerCase();
return lower.contains("redis") || lower.contains("opsforvalue") || lower.contains("boundvalueops");
}
private Expression unwrapSerializer(Expression valueArg) {
if (valueArg instanceof MethodCallExpr) {
MethodCallExpr call = (MethodCallExpr) valueArg;
if (SERIALIZE_METHODS.contains(call.getNameAsString()) && !call.getArguments().isEmpty()) {
return call.getArgument(0);
}
}
return null;
}
private String classify(String method, Expression valueArg) {
String serializer = valueArg instanceof MethodCallExpr
? ((MethodCallExpr) valueArg).getNameAsString() : "";
if ("insert".equals(method)) {
return "W01";
}
if ("getObjectToString".equals(serializer)) {
return "W03";
}
return "W02";
}
private void fillEnclosing(MethodCallExpr mce, WritePoint wp) {
Optional<ClassOrInterfaceDeclaration> clazz = mce.findAncestor(ClassOrInterfaceDeclaration.class);
wp.setEnclosingClass(clazz.map(this::fqnOf).orElse("<unknown>"));
Optional<CallableDeclaration> method = mce.findAncestor(CallableDeclaration.class);
wp.setEnclosingMethod(method.map(NodeName::of).orElse("<unknown>"));
}
private String fqnOf(ClassOrInterfaceDeclaration decl) {
return decl.getFullyQualifiedName().orElse(decl.getNameAsString());
}
private InferredType inferType(Expression expr, MethodCallExpr contextCall, SourceIndex.IndexedType context) {
if (expr instanceof ObjectCreationExpr) {
ClassOrInterfaceType t = ((ObjectCreationExpr) expr).getType();
return resolveTypeNode(t, context);
}
if (expr instanceof NameExpr) {
String name = ((NameExpr) expr).getNameAsString();
Type declared = findVariableType(name, contextCall);
if (declared != null) {
return resolveTypeNode(declared, context);
}
return null;
}
return null;
}
private Type findVariableType(String name, MethodCallExpr contextCall) {
Optional<CallableDeclaration> callable = contextCall.findAncestor(CallableDeclaration.class);
if (callable.isPresent()) {
CallableDeclaration<?> decl = callable.get();
// 局部变量
for (VariableDeclarator var : decl.findAll(VariableDeclarator.class)) {
if (var.getNameAsString().equals(name)) {
return var.getType();
}
}
// 方法参数
for (Parameter p : decl.getParameters()) {
if (p.getNameAsString().equals(name)) {
return p.getType();
}
}
}
// 类字段
Optional<ClassOrInterfaceDeclaration> clazz = contextCall.findAncestor(ClassOrInterfaceDeclaration.class);
if (clazz.isPresent()) {
for (FieldDeclaration field : clazz.get().getFields()) {
for (VariableDeclarator var : field.getVariables()) {
if (var.getNameAsString().equals(name)) {
return var.getType();
}
}
}
}
return null;
}
private InferredType resolveTypeNode(Type type, SourceIndex.IndexedType context) {
if (!(type instanceof ClassOrInterfaceType)) {
return new InferredType(null, false);
}
ClassOrInterfaceType cit = (ClassOrInterfaceType) type;
String simple = cit.getNameAsString();
if (COLLECTION_SIMPLE.contains(simple)) {
Optional<Type> arg = cit.getTypeArguments().filter(a -> !a.isEmpty()).map(a -> a.get(0));
if (arg.isPresent() && arg.get() instanceof ClassOrInterfaceType) {
String elementFqn = resolveFqn((ClassOrInterfaceType) arg.get(), context);
return new InferredType(elementFqn, true);
}
return new InferredType(null, true);
}
return new InferredType(resolveFqn(cit, context), false);
}
private String resolveFqn(ClassOrInterfaceType cit, SourceIndex.IndexedType context) {
String fqn = index.resolveFqn(cit.getNameWithScope(), context);
if (fqn == null) {
fqn = index.resolveFqn(cit.getNameAsString(), context);
}
return fqn;
}
private static final class InferredType {
final String fqn;
final boolean isArray;
InferredType(String fqn, boolean isArray) {
this.fqn = fqn;
this.isArray = isArray;
}
}
private static final class NodeName {
static String of(CallableDeclaration<?> decl) {
return decl.getNameAsString();
}
}
}

View File

@@ -0,0 +1,127 @@
package com.codechecker.redis.detector;
/**
* 一个 Redis value 写入点的静态描述。
*/
public class WritePoint {
private String filePath;
private int lineNumber;
private String enclosingClass;
private String enclosingMethod;
private String pattern;
private String keyExpression;
private String resolvedKeyPattern;
private String valueExpression;
private String resolvedValueType;
private boolean rootArray;
private double confidence = 1.0;
/** 稳定标识:用于在 old/new 两个版本间配对同一写入点 */
public String signature() {
return enclosingClass + "#" + enclosingMethod + "|" + normalizeKey();
}
private String normalizeKey() {
return keyExpression == null ? "" : keyExpression.replaceAll("\\s+", "");
}
public String getFilePath() {
return filePath;
}
public void setFilePath(String filePath) {
this.filePath = filePath;
}
public int getLineNumber() {
return lineNumber;
}
public void setLineNumber(int lineNumber) {
this.lineNumber = lineNumber;
}
public String getEnclosingClass() {
return enclosingClass;
}
public void setEnclosingClass(String enclosingClass) {
this.enclosingClass = enclosingClass;
}
public String getEnclosingMethod() {
return enclosingMethod;
}
public void setEnclosingMethod(String enclosingMethod) {
this.enclosingMethod = enclosingMethod;
}
public String getPattern() {
return pattern;
}
public void setPattern(String pattern) {
this.pattern = pattern;
}
public String getKeyExpression() {
return keyExpression;
}
public void setKeyExpression(String keyExpression) {
this.keyExpression = keyExpression;
}
public String getResolvedKeyPattern() {
return resolvedKeyPattern;
}
public void setResolvedKeyPattern(String resolvedKeyPattern) {
this.resolvedKeyPattern = resolvedKeyPattern;
}
public String getValueExpression() {
return valueExpression;
}
public void setValueExpression(String valueExpression) {
this.valueExpression = valueExpression;
}
public String getResolvedValueType() {
return resolvedValueType;
}
public void setResolvedValueType(String resolvedValueType) {
this.resolvedValueType = resolvedValueType;
}
public boolean isRootArray() {
return rootArray;
}
public void setRootArray(boolean rootArray) {
this.rootArray = rootArray;
}
public double getConfidence() {
return confidence;
}
public void setConfidence(double confidence) {
this.confidence = confidence;
}
public String location() {
String simpleClass = enclosingClass;
if (simpleClass != null && simpleClass.contains(".")) {
simpleClass = simpleClass.substring(simpleClass.lastIndexOf('.') + 1);
}
return simpleClass + "#" + enclosingMethod + ":" + lineNumber;
}
}

View File

@@ -0,0 +1,32 @@
package com.codechecker.redis.diff;
/**
* 结构变更类型及其默认严重级别。
*/
public enum ChangeType {
FIELD_REMOVED(Severity.P0, "字段删除"),
TYPE_CHANGED(Severity.P0, "字段类型变更"),
WRAPPER_ADDED(Severity.P0, "新增包装层"),
FIELD_PATH_MOVED(Severity.P0, "字段路径迁移"),
FIELD_ADDED(Severity.P1, "新增字段"),
KEY_PATTERN_CHANGED(Severity.P1, "Key 模式变更"),
WRITE_POINT_REMOVED(Severity.P1, "删除写入点"),
WRITE_POINT_ADDED(Severity.P2, "新增写入点"),
LOW_CONFIDENCE(Severity.P2, "低置信度结构变更");
private final Severity defaultSeverity;
private final String label;
ChangeType(Severity defaultSeverity, String label) {
this.defaultSeverity = defaultSeverity;
this.label = label;
}
public Severity getDefaultSeverity() {
return defaultSeverity;
}
public String getLabel() {
return label;
}
}

View File

@@ -0,0 +1,85 @@
package com.codechecker.redis.diff;
/**
* 一条结构变更记录。
*/
public class SchemaChange {
private Severity severity;
private ChangeType changeType;
private String keyPattern;
private String writeLocation;
private String fieldPath;
private String oldValue;
private String newValue;
private String message;
public SchemaChange(ChangeType changeType) {
this.changeType = changeType;
this.severity = changeType.getDefaultSeverity();
}
public Severity getSeverity() {
return severity;
}
public void setSeverity(Severity severity) {
this.severity = severity;
}
public ChangeType getChangeType() {
return changeType;
}
public void setChangeType(ChangeType changeType) {
this.changeType = changeType;
}
public String getKeyPattern() {
return keyPattern;
}
public void setKeyPattern(String keyPattern) {
this.keyPattern = keyPattern;
}
public String getWriteLocation() {
return writeLocation;
}
public void setWriteLocation(String writeLocation) {
this.writeLocation = writeLocation;
}
public String getFieldPath() {
return fieldPath;
}
public void setFieldPath(String fieldPath) {
this.fieldPath = fieldPath;
}
public String getOldValue() {
return oldValue;
}
public void setOldValue(String oldValue) {
this.oldValue = oldValue;
}
public String getNewValue() {
return newValue;
}
public void setNewValue(String newValue) {
this.newValue = newValue;
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
}

View File

@@ -0,0 +1,151 @@
package com.codechecker.redis.diff;
import com.codechecker.redis.schema.FieldSchema;
import com.codechecker.redis.schema.JsonType;
import com.codechecker.redis.schema.TypeSchema;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 对比两个版本的 {@link TypeSchema},输出结构变更列表。基于叶子字段路径集合进行差异分析。
*/
public class SchemaDiffer {
/**
* @return 结构变更列表(不含 keyPattern/location 上下文,由调用方补充)
*/
public List<SchemaChange> diff(TypeSchema oldSchema, TypeSchema newSchema) {
List<SchemaChange> changes = new ArrayList<>();
Map<String, JsonType> oldLeaves = leaves(oldSchema);
Map<String, JsonType> newLeaves = leaves(newSchema);
Set<String> removed = new LinkedHashSet<>(oldLeaves.keySet());
removed.removeAll(newLeaves.keySet());
Set<String> added = new LinkedHashSet<>(newLeaves.keySet());
added.removeAll(oldLeaves.keySet());
// 类型变更(同路径)
for (String path : oldLeaves.keySet()) {
if (newLeaves.containsKey(path)) {
JsonType oldType = oldLeaves.get(path);
JsonType newType = newLeaves.get(path);
if (oldType != newType) {
SchemaChange c = new SchemaChange(ChangeType.TYPE_CHANGED);
c.setFieldPath(path);
c.setOldValue(oldType.name());
c.setNewValue(newType.name());
c.setMessage("字段 " + path + " 类型由 " + oldType + " 变为 " + newType);
changes.add(c);
}
}
}
// 路径迁移检测removed 的路径是某 added 路径的后缀)
List<String[]> moves = new ArrayList<>();
Set<String> matchedRemoved = new LinkedHashSet<>();
Set<String> matchedAdded = new LinkedHashSet<>();
for (String r : removed) {
for (String a : added) {
if (matchedAdded.contains(a)) {
continue;
}
if (isSuffix(a, r)) {
moves.add(new String[]{r, a});
matchedRemoved.add(r);
matchedAdded.add(a);
break;
}
}
}
// 包装层检测:多个迁移共享同一新前缀
Map<String, Integer> prefixCount = new LinkedHashMap<>();
for (String[] move : moves) {
String a = move[1];
if (a.contains(".")) {
String prefix = a.substring(0, a.indexOf('.'));
prefixCount.merge(prefix, 1, Integer::sum);
}
}
for (Map.Entry<String, Integer> e : prefixCount.entrySet()) {
if (e.getValue() >= 2) {
SchemaChange c = new SchemaChange(ChangeType.WRAPPER_ADDED);
c.setFieldPath(e.getKey());
c.setNewValue(e.getKey());
c.setMessage("新增包装层 " + e.getKey() + ",原顶层字段被下移至该层(影响 " + e.getValue() + " 个字段)");
changes.add(c);
}
}
for (String[] move : moves) {
SchemaChange c = new SchemaChange(ChangeType.FIELD_PATH_MOVED);
c.setFieldPath(move[1]);
c.setOldValue(move[0]);
c.setNewValue(move[1]);
c.setMessage("字段路径迁移:" + move[0] + "" + move[1]);
changes.add(c);
}
// 剩余删除
for (String r : removed) {
if (matchedRemoved.contains(r)) {
continue;
}
SchemaChange c = new SchemaChange(ChangeType.FIELD_REMOVED);
c.setFieldPath(r);
c.setOldValue(oldLeaves.get(r).name());
c.setMessage("删除字段 " + r);
changes.add(c);
}
// 剩余新增
for (String a : added) {
if (matchedAdded.contains(a)) {
continue;
}
SchemaChange c = new SchemaChange(ChangeType.FIELD_ADDED);
c.setFieldPath(a);
c.setNewValue(newLeaves.get(a).name());
c.setMessage("新增字段 " + a);
changes.add(c);
}
return changes;
}
private Map<String, JsonType> leaves(TypeSchema schema) {
Map<String, JsonType> result = new LinkedHashMap<>();
for (FieldSchema f : schema.getFields().values()) {
if (f.getJsonType() != JsonType.OBJECT && f.getJsonType() != JsonType.ARRAY) {
result.put(f.getPath(), f.getJsonType());
}
}
return result;
}
/**
* 判断 full 的按段后缀是否等于 suffix如 vo.dbName 的后缀是 dbName
*/
private boolean isSuffix(String full, String suffix) {
if (full.equals(suffix)) {
return false;
}
String[] fullSeg = full.split("\\.");
String[] sufSeg = suffix.split("\\.");
if (sufSeg.length >= fullSeg.length) {
return false;
}
for (int i = 1; i <= sufSeg.length; i++) {
if (!fullSeg[fullSeg.length - i].equals(sufSeg[sufSeg.length - i])) {
return false;
}
}
return true;
}
}

View File

@@ -0,0 +1,10 @@
package com.codechecker.redis.diff;
/**
* 变更严重级别。
*/
public enum Severity {
P0,
P1,
P2
}

View File

@@ -0,0 +1,117 @@
package com.codechecker.redis.git;
import java.io.BufferedReader;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
/**
* 基于 git 命令的差异扫描与双版本文件内容读取。
*
* <p>通过 {@code git diff --name-only} 获取变更的 .java 文件,通过 {@code git show sha:path}
* 读取指定提交下的文件内容,避免检出两个完整 worktree。</p>
*/
public class GitDiffScanner {
private final Path repoRoot;
public GitDiffScanner(Path repoRoot) {
this.repoRoot = repoRoot;
}
/**
* 返回 old..new 之间变更的 Java 文件路径(相对仓库根,使用 / 分隔)。
*/
public List<String> changedJavaFiles(String oldSha, String newSha) throws GitException {
List<String> lines = runLines(
"git", "diff", "--name-only", "--diff-filter=ACMR", oldSha, newSha, "--", "*.java");
List<String> result = new ArrayList<>();
for (String line : lines) {
String trimmed = line.trim();
if (!trimmed.isEmpty() && trimmed.endsWith(".java")) {
result.add(trimmed);
}
}
return result;
}
/**
* 读取某提交下指定文件的内容;文件在该提交不存在时返回 null。
*/
public String fileContentAt(String sha, String path) throws GitException {
try {
ProcessResult pr = run("git", "show", sha + ":" + path);
if (pr.exitCode != 0) {
return null;
}
return pr.stdout;
} catch (GitException e) {
return null;
}
}
private List<String> runLines(String... command) throws GitException {
ProcessResult pr = run(command);
if (pr.exitCode != 0) {
throw new GitException("git 命令执行失败(exit=" + pr.exitCode + "): " + String.join(" ", command)
+ "\n" + pr.stderr);
}
List<String> lines = new ArrayList<>();
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(new java.io.ByteArrayInputStream(
pr.stdout.getBytes(StandardCharsets.UTF_8)), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
lines.add(line);
}
} catch (IOException e) {
throw new GitException("读取 git 输出失败", e);
}
return lines;
}
private ProcessResult run(String... command) throws GitException {
ProcessBuilder pb = new ProcessBuilder(command);
pb.directory(repoRoot.toFile());
pb.redirectErrorStream(false);
try {
Process process = pb.start();
String stdout = readStream(process.getInputStream());
String stderr = readStream(process.getErrorStream());
int exit = process.waitFor();
return new ProcessResult(exit, stdout, stderr);
} catch (IOException e) {
throw new GitException("无法启动 git 进程: " + String.join(" ", command), e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new GitException("git 进程被中断", e);
}
}
private static String readStream(InputStream in) throws IOException {
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
byte[] chunk = new byte[8192];
int read;
while ((read = in.read(chunk)) != -1) {
buffer.write(chunk, 0, read);
}
return new String(buffer.toByteArray(), StandardCharsets.UTF_8);
}
private static final class ProcessResult {
final int exitCode;
final String stdout;
final String stderr;
ProcessResult(int exitCode, String stdout, String stderr) {
this.exitCode = exitCode;
this.stdout = stdout;
this.stderr = stderr;
}
}
}

View File

@@ -0,0 +1,15 @@
package com.codechecker.redis.git;
/**
* Git 操作异常。
*/
public class GitException extends Exception {
public GitException(String message) {
super(message);
}
public GitException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -0,0 +1,143 @@
package com.codechecker.redis.key;
import com.codechecker.redis.schema.SourceIndex;
import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration;
import com.github.javaparser.ast.body.FieldDeclaration;
import com.github.javaparser.ast.body.MethodDeclaration;
import com.github.javaparser.ast.body.VariableDeclarator;
import com.github.javaparser.ast.expr.BinaryExpr;
import com.github.javaparser.ast.expr.Expression;
import com.github.javaparser.ast.expr.FieldAccessExpr;
import com.github.javaparser.ast.expr.MethodCallExpr;
import com.github.javaparser.ast.expr.NameExpr;
import com.github.javaparser.ast.expr.StringLiteralExpr;
import com.github.javaparser.ast.stmt.ReturnStmt;
import java.util.Optional;
/**
* 尽力将 Redis key 表达式静态推断为一个「key 模式」,动态部分以 {@code *} 表示。
*/
public class RedisKeyResolver {
private static final int MAX_DEPTH = 6;
private final SourceIndex index;
public RedisKeyResolver(SourceIndex index) {
this.index = index;
}
public String resolve(Expression keyExpr, ClassOrInterfaceDeclaration enclosingClass,
SourceIndex.IndexedType context) {
String raw = resolveExpr(keyExpr, enclosingClass, context, 0);
return normalize(raw);
}
private String resolveExpr(Expression expr, ClassOrInterfaceDeclaration enclosingClass,
SourceIndex.IndexedType context, int depth) {
if (expr == null || depth > MAX_DEPTH) {
return "*";
}
if (expr instanceof StringLiteralExpr) {
return ((StringLiteralExpr) expr).asString();
}
if (expr instanceof BinaryExpr) {
BinaryExpr be = (BinaryExpr) expr;
if (be.getOperator() == BinaryExpr.Operator.PLUS) {
return resolveExpr(be.getLeft(), enclosingClass, context, depth + 1)
+ resolveExpr(be.getRight(), enclosingClass, context, depth + 1);
}
return "*";
}
if (expr instanceof NameExpr) {
String name = ((NameExpr) expr).getNameAsString();
String constVal = lookupConstant(enclosingClass, name);
if (constVal != null) {
return constVal;
}
String methodVal = lookupMethodReturn(enclosingClass, name, context, depth);
return methodVal != null ? methodVal : "*";
}
if (expr instanceof FieldAccessExpr) {
FieldAccessExpr fae = (FieldAccessExpr) expr;
String fieldName = fae.getNameAsString();
String scope = fae.getScope().toString();
String external = lookupExternalConstant(scope, fieldName, context);
if (external != null) {
return external;
}
String local = lookupConstant(enclosingClass, fieldName);
return local != null ? local : "*";
}
if (expr instanceof MethodCallExpr) {
MethodCallExpr call = (MethodCallExpr) expr;
String name = call.getNameAsString();
if ("format".equals(name) && !call.getArguments().isEmpty()) {
String fmt = resolveExpr(call.getArgument(0), enclosingClass, context, depth + 1);
return fmt.replaceAll("%[-0-9.]*[sdxDX]", "*");
}
// buildCacheKey(...) 等本类方法:解析其 return 表达式
String methodVal = lookupMethodReturn(enclosingClass, name, context, depth);
return methodVal != null ? methodVal : "*";
}
return "*";
}
private String lookupConstant(ClassOrInterfaceDeclaration clazz, String name) {
if (clazz == null) {
return null;
}
for (FieldDeclaration field : clazz.getFields()) {
for (VariableDeclarator var : field.getVariables()) {
if (var.getNameAsString().equals(name)) {
Optional<Expression> init = var.getInitializer();
if (init.isPresent() && init.get() instanceof StringLiteralExpr) {
return ((StringLiteralExpr) init.get()).asString();
}
}
}
}
return null;
}
private String lookupExternalConstant(String scopeName, String fieldName, SourceIndex.IndexedType context) {
String fqn = index.resolveFqn(scopeName, context);
if (fqn == null) {
return null;
}
SourceIndex.IndexedType type = index.get(fqn);
if (type == null) {
return null;
}
return lookupConstant(type.getDeclaration(), fieldName);
}
private String lookupMethodReturn(ClassOrInterfaceDeclaration clazz, String methodName,
SourceIndex.IndexedType context, int depth) {
if (clazz == null || depth > MAX_DEPTH) {
return null;
}
for (MethodDeclaration method : clazz.getMethods()) {
if (method.getNameAsString().equals(methodName) && method.getBody().isPresent()) {
for (ReturnStmt ret : method.getBody().get().findAll(ReturnStmt.class)) {
if (ret.getExpression().isPresent()) {
return resolveExpr(ret.getExpression().get(), clazz, context, depth + 1);
}
}
}
}
return null;
}
private String normalize(String raw) {
if (raw == null || raw.isEmpty()) {
return "unknown-key";
}
String collapsed = raw.replaceAll("\\*+", "*");
if (collapsed.equals("*")) {
return "unknown-key";
}
return collapsed;
}
}

View File

@@ -0,0 +1,58 @@
package com.codechecker.redis.notify;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 企业微信群机器人通知markdown 消息)。
*/
public class WeComNotifier {
private final ObjectMapper mapper = new ObjectMapper();
private final HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
/**
* @return 是否发送成功
*/
public boolean sendMarkdown(String webhookUrl, String markdown) {
if (webhookUrl == null || webhookUrl.trim().isEmpty()) {
System.err.println("[WeComNotifier] 未配置 webhook跳过通知");
return false;
}
try {
Map<String, Object> md = new LinkedHashMap<>();
md.put("content", markdown);
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("msgtype", "markdown");
payload.put("markdown", md);
String body = mapper.writeValueAsString(payload);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(webhookUrl))
.timeout(Duration.ofSeconds(15))
.header("Content-Type", "application/json; charset=utf-8")
.POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8))
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
return true;
}
System.err.println("[WeComNotifier] 通知失败, HTTP " + response.statusCode() + ": " + response.body());
return false;
} catch (Exception e) {
System.err.println("[WeComNotifier] 通知异常: " + e.getMessage());
return false;
}
}
}

View File

@@ -0,0 +1,109 @@
package com.codechecker.redis.report;
import com.codechecker.redis.diff.SchemaChange;
import com.codechecker.redis.diff.Severity;
import java.util.ArrayList;
import java.util.List;
/**
* 一次检测的完整结果。
*/
public class CheckReport {
private String repository;
private String branch;
private String oldSha;
private String newSha;
private String modifier;
private String modifyTime;
private String mode;
private final List<SchemaChange> changes = new ArrayList<>();
private boolean blocked;
private int exitCode;
public boolean hasChanges() {
return !changes.isEmpty();
}
public long count(Severity severity) {
return changes.stream().filter(c -> c.getSeverity() == severity).count();
}
public String getRepository() {
return repository;
}
public void setRepository(String repository) {
this.repository = repository;
}
public String getBranch() {
return branch;
}
public void setBranch(String branch) {
this.branch = branch;
}
public String getOldSha() {
return oldSha;
}
public void setOldSha(String oldSha) {
this.oldSha = oldSha;
}
public String getNewSha() {
return newSha;
}
public void setNewSha(String newSha) {
this.newSha = newSha;
}
public String getModifier() {
return modifier;
}
public void setModifier(String modifier) {
this.modifier = modifier;
}
public String getModifyTime() {
return modifyTime;
}
public void setModifyTime(String modifyTime) {
this.modifyTime = modifyTime;
}
public String getMode() {
return mode;
}
public void setMode(String mode) {
this.mode = mode;
}
public List<SchemaChange> getChanges() {
return changes;
}
public boolean isBlocked() {
return blocked;
}
public void setBlocked(boolean blocked) {
this.blocked = blocked;
}
public int getExitCode() {
return exitCode;
}
public void setExitCode(int exitCode) {
this.exitCode = exitCode;
}
}

View File

@@ -0,0 +1,86 @@
package com.codechecker.redis.report;
import com.codechecker.redis.diff.SchemaChange;
import com.codechecker.redis.diff.Severity;
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
/**
* 将 {@link CheckReport} 渲染为企微 Markdown / 控制台文本。
*/
public class ReportBuilder {
private final String titlePrefix;
public ReportBuilder(String titlePrefix) {
this.titlePrefix = titlePrefix == null ? "[Redis结构变更]" : titlePrefix;
}
public String toMarkdown(CheckReport report) {
StringBuilder sb = new StringBuilder();
sb.append("## ").append(titlePrefix).append(' ')
.append(nvl(report.getRepository())).append('\n');
sb.append("> 分支: ").append(nvl(report.getBranch())).append('\n');
sb.append("> 提交: ").append(shortSha(report.getOldSha()))
.append("").append(shortSha(report.getNewSha())).append('\n');
sb.append("> 提交人: ").append(nvl(report.getModifier())).append('\n');
sb.append("> 时间: ").append(nvl(report.getModifyTime())).append('\n');
sb.append("> 模式: ").append(nvl(report.getMode()));
if (report.isBlocked()) {
sb.append("(已阻断)");
}
sb.append('\n');
sb.append("> 汇总: P0=").append(report.count(Severity.P0))
.append(" P1=").append(report.count(Severity.P1))
.append(" P2=").append(report.count(Severity.P2)).append("\n\n");
Map<Severity, List<SchemaChange>> grouped = new EnumMap<>(Severity.class);
for (SchemaChange c : report.getChanges()) {
grouped.computeIfAbsent(c.getSeverity(), k -> new ArrayList<>()).add(c);
}
for (Severity severity : Severity.values()) {
List<SchemaChange> list = grouped.get(severity);
if (list == null || list.isEmpty()) {
continue;
}
sb.append("### ").append(severity).append('\n');
for (SchemaChange c : list) {
sb.append("- **").append(c.getChangeType().getLabel()).append("**");
if (c.getKeyPattern() != null) {
sb.append(" `").append(c.getKeyPattern()).append('`');
}
sb.append('\n');
if (c.getWriteLocation() != null) {
sb.append(" - 位置: ").append(c.getWriteLocation()).append('\n');
}
if (c.getMessage() != null) {
sb.append(" - ").append(c.getMessage()).append('\n');
}
}
sb.append('\n');
}
return sb.toString();
}
public String toConsole(CheckReport report) {
if (!report.hasChanges()) {
return "未检测到 Redis 序列化结构变更。";
}
return toMarkdown(report);
}
private String shortSha(String sha) {
if (sha == null) {
return "";
}
return sha.length() > 8 ? sha.substring(0, 8) : sha;
}
private String nvl(String s) {
return s == null ? "" : s;
}
}

View File

@@ -0,0 +1,83 @@
package com.codechecker.redis.schema;
import com.github.javaparser.ast.body.FieldDeclaration;
import com.github.javaparser.ast.expr.AnnotationExpr;
import com.github.javaparser.ast.expr.MemberValuePair;
import com.github.javaparser.ast.expr.NormalAnnotationExpr;
import com.github.javaparser.ast.expr.SingleMemberAnnotationExpr;
import com.github.javaparser.ast.expr.StringLiteralExpr;
/**
* 处理 Fastjson / Jackson 序列化相关注解:字段忽略与字段名映射。
*/
public final class AnnotationSupport {
private AnnotationSupport() {
}
/**
* 字段是否参与序列化(未被 @JSONField(serialize=false)/@JsonIgnore 等排除)。
*/
public static boolean isSerialized(FieldDeclaration field) {
for (AnnotationExpr annotation : field.getAnnotations()) {
String name = annotation.getNameAsString();
if (name.equals("JsonIgnore")) {
return false;
}
if (name.equals("JSONField") && annotation instanceof NormalAnnotationExpr) {
for (MemberValuePair pair : ((NormalAnnotationExpr) annotation).getPairs()) {
if (pair.getNameAsString().equals("serialize")
&& pair.getValue().toString().equals("false")) {
return false;
}
}
}
}
return true;
}
/**
* 解析字段序列化后的 JSON 名称:优先 @JSONField(name=)/@JsonProperty(),否则用原字段名。
*/
public static String jsonName(FieldDeclaration field, String defaultName) {
for (AnnotationExpr annotation : field.getAnnotations()) {
String name = annotation.getNameAsString();
if (name.equals("JsonProperty")) {
String v = singleStringValue(annotation);
if (v != null && !v.isEmpty()) {
return v;
}
}
if (name.equals("JSONField") && annotation instanceof NormalAnnotationExpr) {
for (MemberValuePair pair : ((NormalAnnotationExpr) annotation).getPairs()) {
if (pair.getNameAsString().equals("name")
&& pair.getValue() instanceof StringLiteralExpr) {
String v = ((StringLiteralExpr) pair.getValue()).asString();
if (!v.isEmpty()) {
return v;
}
}
}
}
}
return defaultName;
}
private static String singleStringValue(AnnotationExpr annotation) {
if (annotation instanceof SingleMemberAnnotationExpr) {
if (((SingleMemberAnnotationExpr) annotation).getMemberValue() instanceof StringLiteralExpr) {
return ((StringLiteralExpr) ((SingleMemberAnnotationExpr) annotation)
.getMemberValue()).asString();
}
}
if (annotation instanceof NormalAnnotationExpr) {
for (MemberValuePair pair : ((NormalAnnotationExpr) annotation).getPairs()) {
if (pair.getNameAsString().equals("value")
&& pair.getValue() instanceof StringLiteralExpr) {
return ((StringLiteralExpr) pair.getValue()).asString();
}
}
}
return null;
}
}

View File

@@ -0,0 +1,56 @@
package com.codechecker.redis.schema;
import java.util.Objects;
/**
* 扁平化后的单个字段节点。path 使用点号分隔,数组元素以 {@code []} 标记。
* 例如:{@code vo.linkList[].id}。
*/
public class FieldSchema {
private final String path;
private final JsonType jsonType;
private final String javaType;
public FieldSchema(String path, JsonType jsonType, String javaType) {
this.path = path;
this.jsonType = jsonType;
this.javaType = javaType;
}
public String getPath() {
return path;
}
public JsonType getJsonType() {
return jsonType;
}
public String getJavaType() {
return javaType;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (!(o instanceof FieldSchema)) {
return false;
}
FieldSchema that = (FieldSchema) o;
return Objects.equals(path, that.path)
&& jsonType == that.jsonType
&& Objects.equals(javaType, that.javaType);
}
@Override
public int hashCode() {
return Objects.hash(path, jsonType, javaType);
}
@Override
public String toString() {
return path + ":" + jsonType + "(" + javaType + ")";
}
}

View File

@@ -0,0 +1,202 @@
package com.codechecker.redis.schema;
import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration;
import com.github.javaparser.ast.body.FieldDeclaration;
import com.github.javaparser.ast.body.VariableDeclarator;
import com.github.javaparser.ast.type.ArrayType;
import com.github.javaparser.ast.type.ClassOrInterfaceType;
import com.github.javaparser.ast.type.PrimitiveType;
import com.github.javaparser.ast.type.Type;
import java.util.Arrays;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.Optional;
import java.util.Set;
/**
* 将 Java 类型递归展开为扁平化 {@link TypeSchema}。仅基于本仓库源码,不解析依赖 jar 内类型。
*/
public class JavaSchemaExtractor {
private static final Set<String> STRING_TYPES = new HashSet<>(Arrays.asList(
"String", "CharSequence", "char", "Character", "UUID",
"Date", "LocalDate", "LocalDateTime", "LocalTime", "Instant", "Timestamp",
"BigDecimal"));
private static final Set<String> NUMBER_TYPES = new HashSet<>(Arrays.asList(
"int", "long", "short", "byte", "double", "float",
"Integer", "Long", "Short", "Byte", "Double", "Float",
"Number", "BigInteger", "AtomicInteger", "AtomicLong"));
private static final Set<String> BOOLEAN_TYPES = new HashSet<>(Arrays.asList(
"boolean", "Boolean"));
private static final Set<String> COLLECTION_TYPES = new HashSet<>(Arrays.asList(
"List", "ArrayList", "LinkedList", "Set", "HashSet", "LinkedHashSet",
"TreeSet", "Collection", "Iterable"));
private static final Set<String> MAP_TYPES = new HashSet<>(Arrays.asList(
"Map", "HashMap", "LinkedHashMap", "TreeMap", "ConcurrentHashMap"));
private final SourceIndex index;
private final int maxDepth;
private int unknownCount;
public JavaSchemaExtractor(SourceIndex index, int maxDepth) {
this.index = index;
this.maxDepth = maxDepth;
}
/**
* 从根类型 FQN 生成 Schema。根类型无法在本仓库解析时返回低置信度空 Schema。
*/
public TypeSchema extract(String rootTypeFqn) {
return extract(rootTypeFqn, false);
}
/**
* @param rootArray 根类型是否为集合value 序列化为 JSON 数组)
*/
public TypeSchema extract(String rootTypeFqn, boolean rootArray) {
unknownCount = 0;
TypeSchema schema = new TypeSchema(rootTypeFqn);
SourceIndex.IndexedType root = index.get(rootTypeFqn);
if (root == null) {
schema.setConfidence(0.4);
return schema;
}
String prefix = rootArray ? "[]" : "";
if (rootArray) {
schema.add(new FieldSchema("[]", JsonType.OBJECT, rootTypeFqn));
}
expandObject(root, prefix, schema, new LinkedHashSet<>(), 0);
schema.setConfidence(unknownCount == 0 ? 1.0 : Math.max(0.5, 1.0 - 0.15 * unknownCount));
return schema;
}
private void expandObject(SourceIndex.IndexedType type, String prefix, TypeSchema schema,
Set<String> ancestors, int depth) {
if (depth > maxDepth || ancestors.contains(type.getFqn())) {
return;
}
Set<String> nextAncestors = new LinkedHashSet<>(ancestors);
nextAncestors.add(type.getFqn());
for (FieldDeclaration field : collectFields(type, new HashSet<>())) {
if (field.isStatic() || field.isTransient()) {
continue;
}
if (!AnnotationSupport.isSerialized(field)) {
continue;
}
for (VariableDeclarator var : field.getVariables()) {
String jsonName = AnnotationSupport.jsonName(field, var.getNameAsString());
String path = prefix.isEmpty() ? jsonName : prefix + "." + jsonName;
expandType(var.getType(), path, type, schema, nextAncestors, depth);
}
}
}
private void expandType(Type type, String path, SourceIndex.IndexedType context,
TypeSchema schema, Set<String> ancestors, int depth) {
if (type instanceof PrimitiveType) {
schema.add(new FieldSchema(path, scalarJsonType(type.asString()), type.asString()));
return;
}
if (type instanceof ArrayType) {
Type component = ((ArrayType) type).getComponentType();
schema.add(new FieldSchema(path, JsonType.ARRAY, type.asString()));
expandType(component, path + "[]", context, schema, ancestors, depth + 1);
return;
}
if (type instanceof ClassOrInterfaceType) {
ClassOrInterfaceType cit = (ClassOrInterfaceType) type;
String simple = cit.getNameAsString();
if (isScalar(simple)) {
schema.add(new FieldSchema(path, scalarJsonType(simple), simple));
return;
}
if (COLLECTION_TYPES.contains(simple)) {
schema.add(new FieldSchema(path, JsonType.ARRAY, simple));
Optional<Type> arg = firstTypeArgument(cit);
if (arg.isPresent()) {
expandType(arg.get(), path + "[]", context, schema, ancestors, depth + 1);
} else {
schema.add(new FieldSchema(path + "[]", JsonType.UNKNOWN, "?"));
}
return;
}
if (MAP_TYPES.contains(simple)) {
// 动态键结构,不展开
schema.add(new FieldSchema(path, JsonType.MAP, simple));
return;
}
// 尝试解析为本仓库对象类型
String fqn = index.resolveFqn(cit.getNameWithScope(), context);
if (fqn == null) {
fqn = index.resolveFqn(simple, context);
}
SourceIndex.IndexedType resolved = fqn == null ? null : index.get(fqn);
if (resolved != null) {
schema.add(new FieldSchema(path, JsonType.OBJECT, fqn));
expandObject(resolved, path, schema, ancestors, depth + 1);
} else {
// 无法解析(可能是枚举/依赖 jar 类型):作为叶子处理
unknownCount++;
schema.add(new FieldSchema(path, JsonType.UNKNOWN, simple));
}
return;
}
schema.add(new FieldSchema(path, JsonType.UNKNOWN, type.asString()));
}
/**
* 收集类自身及父类(本仓库可解析部分)的字段。
*/
private java.util.List<FieldDeclaration> collectFields(SourceIndex.IndexedType type, Set<String> visited) {
java.util.List<FieldDeclaration> result = new java.util.ArrayList<>();
if (type == null || visited.contains(type.getFqn())) {
return result;
}
visited.add(type.getFqn());
ClassOrInterfaceDeclaration decl = type.getDeclaration();
for (FieldDeclaration field : decl.getFields()) {
result.add(field);
}
for (ClassOrInterfaceType parent : decl.getExtendedTypes()) {
String parentFqn = index.resolveFqn(parent.getNameWithScope(), type);
if (parentFqn == null) {
parentFqn = index.resolveFqn(parent.getNameAsString(), type);
}
SourceIndex.IndexedType parentType = parentFqn == null ? null : index.get(parentFqn);
if (parentType != null) {
result.addAll(collectFields(parentType, visited));
}
}
return result;
}
private Optional<Type> firstTypeArgument(ClassOrInterfaceType cit) {
return cit.getTypeArguments()
.filter(args -> !args.isEmpty())
.map(args -> args.get(0));
}
private boolean isScalar(String simpleName) {
return STRING_TYPES.contains(simpleName)
|| NUMBER_TYPES.contains(simpleName)
|| BOOLEAN_TYPES.contains(simpleName);
}
private JsonType scalarJsonType(String simpleName) {
if (NUMBER_TYPES.contains(simpleName)) {
return JsonType.NUMBER;
}
if (BOOLEAN_TYPES.contains(simpleName)) {
return JsonType.BOOLEAN;
}
if (STRING_TYPES.contains(simpleName)) {
return JsonType.STRING;
}
return JsonType.UNKNOWN;
}
}

View File

@@ -0,0 +1,14 @@
package com.codechecker.redis.schema;
/**
* 序列化后 JSON 值的粗粒度类型。
*/
public enum JsonType {
OBJECT,
ARRAY,
MAP,
STRING,
NUMBER,
BOOLEAN,
UNKNOWN
}

View File

@@ -0,0 +1,190 @@
package com.codechecker.redis.schema;
import com.github.javaparser.ParserConfiguration;
import com.github.javaparser.StaticJavaParser;
import com.github.javaparser.ast.CompilationUnit;
import com.github.javaparser.ast.ImportDeclaration;
import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration;
import com.github.javaparser.ast.body.TypeDeclaration;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 一次提交快照下的源码类型索引。仅索引本仓库源码(不含依赖 jar供类型解析与字段展开使用。
*
* <p>由于无法获取业务仓库完整依赖 classpath本索引采用「手动符号解析」而非 JavaParser
* SymbolSolver基于文件内 import、同包、内部类进行 FQN 解析,保证在只读源码场景下的稳定性。</p>
*/
public class SourceIndex {
/** FQN以 . 分隔,含内部类) -> 类型信息 */
private final Map<String, IndexedType> byFqn = new LinkedHashMap<>();
/** 简单类名 -> FQN 列表(兜底解析) */
private final Map<String, List<String>> bySimpleName = new LinkedHashMap<>();
static {
ParserConfiguration config = new ParserConfiguration()
.setLanguageLevel(ParserConfiguration.LanguageLevel.BLEEDING_EDGE);
StaticJavaParser.setConfiguration(config);
}
/**
* 解析并加入一个 Java 源文件内容。解析失败时静默跳过(返回 false
*/
public boolean addSource(String content) {
if (content == null || content.isEmpty()) {
return false;
}
CompilationUnit cu;
try {
cu = StaticJavaParser.parse(content);
} catch (RuntimeException e) {
return false;
}
String packageName = cu.getPackageDeclaration()
.map(pd -> pd.getNameAsString())
.orElse("");
List<String> imports = new ArrayList<>();
for (ImportDeclaration imp : cu.getImports()) {
imports.add((imp.isAsterisk() ? imp.getNameAsString() + ".*" : imp.getNameAsString()));
}
for (TypeDeclaration<?> type : cu.getTypes()) {
registerType(type, packageName, imports, packageName);
}
return true;
}
private void registerType(TypeDeclaration<?> type, String packageName, List<String> imports, String enclosingFqn) {
String simpleName = type.getNameAsString();
String fqn = enclosingFqn.isEmpty() ? simpleName : enclosingFqn + "." + simpleName;
if (type instanceof ClassOrInterfaceDeclaration) {
IndexedType indexed = new IndexedType((ClassOrInterfaceDeclaration) type, packageName, imports, fqn);
byFqn.put(fqn, indexed);
bySimpleName.computeIfAbsent(simpleName, k -> new ArrayList<>()).add(fqn);
}
// 递归内部类型
for (Object member : type.getMembers()) {
if (member instanceof TypeDeclaration) {
registerType((TypeDeclaration<?>) member, packageName, imports, fqn);
}
}
}
public IndexedType get(String fqn) {
if (fqn == null) {
return null;
}
return byFqn.get(fqn.replace('$', '.'));
}
public boolean contains(String fqn) {
return fqn != null && byFqn.containsKey(fqn.replace('$', '.'));
}
/**
* 将简单类名或部分限定名解析为本仓库内的 FQN无法解析时返回 null。
*/
public String resolveFqn(String name, IndexedType context) {
if (name == null || name.isEmpty()) {
return null;
}
String normalized = name.replace('$', '.');
// 1. 已经是本仓库已知 FQN
if (byFqn.containsKey(normalized)) {
return normalized;
}
String simple = normalized.contains(".")
? normalized.substring(normalized.lastIndexOf('.') + 1)
: normalized;
if (context != null) {
// 2. 上下文自身或其内部类
String selfNested = context.getFqn() + "." + simple;
if (byFqn.containsKey(selfNested)) {
return selfNested;
}
// 2b. 上下文的外层链中的内部类
String outer = context.getFqn();
while (outer.contains(".")) {
outer = outer.substring(0, outer.lastIndexOf('.'));
String candidate = outer + "." + simple;
if (byFqn.containsKey(candidate)) {
return candidate;
}
}
// 3. 同包
String samePackage = context.getPackageName().isEmpty()
? simple : context.getPackageName() + "." + simple;
if (byFqn.containsKey(samePackage)) {
return samePackage;
}
// 4. 精确 import
for (String imp : context.getImports()) {
if (imp.endsWith("." + simple)) {
if (byFqn.containsKey(imp)) {
return imp;
}
}
}
// 5. 通配 import
for (String imp : context.getImports()) {
if (imp.endsWith(".*")) {
String pkg = imp.substring(0, imp.length() - 2);
String candidate = pkg + "." + simple;
if (byFqn.containsKey(candidate)) {
return candidate;
}
}
}
}
// 6. 简单名唯一命中兜底
List<String> candidates = bySimpleName.get(simple);
if (candidates != null && candidates.size() == 1) {
return candidates.get(0);
}
return null;
}
public int size() {
return byFqn.size();
}
/**
* 索引中的类型条目。
*/
public static final class IndexedType {
private final ClassOrInterfaceDeclaration declaration;
private final String packageName;
private final List<String> imports;
private final String fqn;
IndexedType(ClassOrInterfaceDeclaration declaration, String packageName, List<String> imports, String fqn) {
this.declaration = declaration;
this.packageName = packageName;
this.imports = imports;
this.fqn = fqn;
}
public ClassOrInterfaceDeclaration getDeclaration() {
return declaration;
}
public String getPackageName() {
return packageName;
}
public List<String> getImports() {
return imports;
}
public String getFqn() {
return fqn;
}
}
}

View File

@@ -0,0 +1,42 @@
package com.codechecker.redis.schema;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 某个 Redis value 类型展开后的扁平化 Schemapath -> {@link FieldSchema}。
*/
public class TypeSchema {
private final String rootType;
private double confidence = 1.0;
private final Map<String, FieldSchema> fields = new LinkedHashMap<>();
public TypeSchema(String rootType) {
this.rootType = rootType;
}
public String getRootType() {
return rootType;
}
public double getConfidence() {
return confidence;
}
public void setConfidence(double confidence) {
this.confidence = confidence;
}
public Map<String, FieldSchema> getFields() {
return fields;
}
public void add(FieldSchema field) {
fields.put(field.getPath(), field);
}
public boolean isEmpty() {
return fields.isEmpty();
}
}