feat: V1.1 - 注释补充
This commit is contained in:
@@ -12,6 +12,7 @@ import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* 扫描工作树中受包含/排除模块限制的 {@code src/main/java} 下的所有 Java 源文件。
|
||||
* <p>返回路径 → 源码内容映射,供 {@link SchemaCheckAnalyzer} 构建新旧 {@code SourceIndex}。</p>
|
||||
*/
|
||||
public class FileScanner {
|
||||
|
||||
|
||||
@@ -10,6 +10,11 @@ public final class GlobMatcher {
|
||||
private GlobMatcher() {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param glob glob 模式(支持 {@code *} / {@code **})
|
||||
* @param input 待匹配字符串
|
||||
* @return 是否整串匹配
|
||||
*/
|
||||
public static boolean matches(String glob, String input) {
|
||||
if (glob == null || input == null) {
|
||||
return false;
|
||||
|
||||
@@ -47,6 +47,14 @@ public class SchemaCheckAnalyzer {
|
||||
this.repoRoot = repoRoot;
|
||||
}
|
||||
|
||||
/**
|
||||
* 对比两个提交之间的序列化结构变更。
|
||||
*
|
||||
* @param oldSha 基准提交
|
||||
* @param newSha 当前提交(通常与工作树一致)
|
||||
* @return 检测报告(含字段明细与按 Key/Topic 聚合结果)
|
||||
* @throws GitException git 命令失败时
|
||||
*/
|
||||
public CheckReport analyze(String oldSha, String newSha) throws GitException {
|
||||
GitDiffScanner scanner = new GitDiffScanner(repoRoot);
|
||||
|
||||
|
||||
@@ -15,7 +15,9 @@ import java.util.List;
|
||||
import java.util.concurrent.Callable;
|
||||
|
||||
/**
|
||||
* 命令行入口。退出码:0 通过 / 1 阻断 / 2 执行错误。
|
||||
* 序列化结构检测工具的命令行入口(picocli)。
|
||||
* <p>加载配置 → 对比 {@code old-sha}/{@code new-sha} → 控制台输出报告 → 可选企微通知。</p>
|
||||
* <p>退出码:{@code 0} 通过或跳过;{@code 1} block 模式检测到变更;{@code 2} 执行错误。</p>
|
||||
*/
|
||||
@Command(name = "serialization-schema-checker",
|
||||
mixinStandardHelpOptions = true,
|
||||
|
||||
@@ -36,6 +36,7 @@ public class CheckerConfig {
|
||||
|
||||
private List<String> excludeModules = new ArrayList<>();
|
||||
|
||||
/** 企微通知相关配置。 */
|
||||
public static class Notify {
|
||||
private boolean enabled = true;
|
||||
/** 企微机器人 Webhook 完整 URL */
|
||||
@@ -76,6 +77,7 @@ public class CheckerConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/** 忽略规则:key / 文件路径 / 写入方法 / MQ destination。 */
|
||||
public static class Ignore {
|
||||
private List<String> keyPatterns = new ArrayList<>();
|
||||
private List<String> filePatterns = new ArrayList<>();
|
||||
@@ -116,6 +118,7 @@ public class CheckerConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/** 检测模式与推断参数(Redis W*、MQ MQ*、读侧补强开关等)。 */
|
||||
public static class Detection {
|
||||
private List<String> patterns = new ArrayList<>();
|
||||
/** MQ 投递检测模式:MQ01~MQ05、MQ-K01~MQ-K04 */
|
||||
@@ -176,6 +179,9 @@ public class CheckerConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 人工补充映射:在自动推断不准时指定写入方法对应的 key 模式与 value 类型。
|
||||
*/
|
||||
public static class ManualMapping {
|
||||
private String id;
|
||||
private String writerMethod;
|
||||
@@ -224,6 +230,9 @@ public class CheckerConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 抑制规则:对已知误报按 key 模式和/或变更类型跳过告警。
|
||||
*/
|
||||
public static class Suppression {
|
||||
private String id;
|
||||
private String writerMethod;
|
||||
|
||||
@@ -22,6 +22,12 @@ public final class ConfigLoader {
|
||||
private ConfigLoader() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载并合并配置。
|
||||
*
|
||||
* @param businessConfigPath 业务仓 YAML;为 {@code null} 或不存在时仅使用 jar 内默认配置
|
||||
* @return 绑定后的运行配置
|
||||
*/
|
||||
public static CheckerConfig load(Path businessConfigPath) {
|
||||
Map<String, Object> merged = loadDefault();
|
||||
if (businessConfigPath != null && Files.exists(businessConfigPath)) {
|
||||
|
||||
@@ -9,7 +9,8 @@ import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 无业务 JSON 结构的标量 / 裸字节类型(Redis / MQ 共用)。这些类型非序列化,直接跳过。
|
||||
* 无业务 JSON 结构的标量 / 裸字节类型判定(Redis / MQ 检测共用)。
|
||||
* <p>命中则不应建立写入点,避免对 String/数字等无结构缓存误报。</p>
|
||||
*/
|
||||
final class BareValueTypes {
|
||||
|
||||
@@ -21,10 +22,21 @@ final class BareValueTypes {
|
||||
private BareValueTypes() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断一个 Java 类型是否属于「无业务结构」的标量/裸类型集合。
|
||||
*
|
||||
* @param simple simple
|
||||
* @return boolean
|
||||
*/
|
||||
static boolean isBareSimpleName(String simple) {
|
||||
return simple != null && BARE.contains(simple);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 AST Type 取出简单名后调用
|
||||
* @param type type
|
||||
* @return boolean
|
||||
*/
|
||||
static boolean isBareType(Type type) {
|
||||
if (type == null) {
|
||||
return false;
|
||||
@@ -44,6 +56,11 @@ final class BareValueTypes {
|
||||
return "byte[]".equals(asString) || isBareSimpleName(asString);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 FQN 截取最后一段再调用
|
||||
* @param fqn fqn
|
||||
* @return boolean
|
||||
*/
|
||||
static boolean isBareFqn(String fqn) {
|
||||
if (fqn == null || fqn.isEmpty()) {
|
||||
return false;
|
||||
|
||||
@@ -37,8 +37,6 @@ public class CacheReadHintDetector {
|
||||
"parseObject", "parse", "getJsonToBean", "toJavaObject", "readValue"));
|
||||
private static final Set<String> ARRAY_PARSE = new HashSet<>(Arrays.asList(
|
||||
"parseArray", "getJsonToList", "parseArrayObject"));
|
||||
private static final Set<String> REDIS_GET = new HashSet<>(Arrays.asList(
|
||||
"get", "getString", "opsForValue"));
|
||||
|
||||
private final SourceIndex index;
|
||||
private final RedisKeyResolver keyResolver;
|
||||
@@ -48,6 +46,13 @@ public class CacheReadHintDetector {
|
||||
this.keyResolver = new RedisKeyResolver(index);
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描读侧反序列化调用,提取可用于补强写入点的类型提示。
|
||||
*
|
||||
* @param filePath 仓库内相对路径
|
||||
* @param content 源码全文
|
||||
* @return 提示列表(不单独告警)
|
||||
*/
|
||||
public List<CacheReadHint> detect(String filePath, String content) {
|
||||
List<CacheReadHint> result = new ArrayList<>();
|
||||
if (content == null || content.isEmpty()) {
|
||||
@@ -69,6 +74,13 @@ public class CacheReadHintDetector {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试从单次方法调用提取读侧类型提示;非目标 API 或无法解析类型时返回 null。
|
||||
* <p>
|
||||
* 识别 {@code parseObject/parseArray/...} 且第 2 参为 {@code Xxx.class} 的调用,
|
||||
* 解析出业务类型 FQN;若第 1 参能追溯到 Redis get,则附带 key 并将置信度提至 0.85,
|
||||
* 否则仅保留类型提示(置信度 0.7)。结果供写入点类型补强,不单独告警。
|
||||
*/
|
||||
private CacheReadHint tryParseHint(MethodCallExpr mce, String filePath) {
|
||||
String name = mce.getNameAsString();
|
||||
boolean array = ARRAY_PARSE.contains(name);
|
||||
@@ -98,6 +110,7 @@ public class CacheReadHintDetector {
|
||||
: enclosing.getFullyQualifiedName().orElse(enclosing.getNameAsString());
|
||||
SourceIndex.IndexedType context = index.get(enclosingFqn);
|
||||
|
||||
// 优先带包前缀解析(如 com.foo.Bar.class),失败再退回简单类名 + import/同包
|
||||
String fqn = index.resolveFqn(((ClassOrInterfaceType) type).getNameWithScope(), context);
|
||||
if (fqn == null) {
|
||||
fqn = index.resolveFqn(((ClassOrInterfaceType) type).getNameAsString(), context);
|
||||
@@ -116,12 +129,12 @@ public class CacheReadHintDetector {
|
||||
hint.setRootArray(array || "parseArray".equals(name) || "getJsonToList".equals(name));
|
||||
hint.setConfidence(0.7);
|
||||
|
||||
// 第 1 参:原始 JSON 字符串;能关联到 redis.get(key) 时补 key,便于与写入点按 key 匹配
|
||||
Expression rawExpr = mce.getArgument(0);
|
||||
Optional<RedisGetRef> getRef = findRedisGetForVar(rawExpr, mce);
|
||||
if (getRef.isPresent()) {
|
||||
hint.setKeyExpression(getRef.get().keyExpr.toString());
|
||||
hint.setResolvedKeyPattern(keyResolver.resolve(
|
||||
getRef.get().keyExpr, enclosing, context));
|
||||
hint.setResolvedKeyPattern(keyResolver.resolve(getRef.get().keyExpr, enclosing, context));
|
||||
hint.setConfidence(0.85);
|
||||
}
|
||||
return hint;
|
||||
@@ -155,27 +168,30 @@ public class CacheReadHintDetector {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断调用是否为 Redis 读 key,是则取出第 1 参作为 key 表达式。
|
||||
* <p>
|
||||
* 覆盖常见形态:{@code redisUtil.get/getString(key)}、
|
||||
* {@code redisTemplate.opsForValue().get(key)}、{@code boundValueOps.get(key)}。
|
||||
* {@code getString} 即使接收者名不含 redis 也接受(RedisUtil 习惯命名);
|
||||
* 普通 {@code get} 依赖 scope 命中 redis / opsForValue / boundValueOps 启发式。
|
||||
* 无法识别时返回 empty,不抛错。
|
||||
*/
|
||||
private Optional<RedisGetRef> extractGetKey(MethodCallExpr call) {
|
||||
String name = call.getNameAsString();
|
||||
// 接收者字符串小写后做子串匹配,避免绑定具体类型
|
||||
String scope = call.getScope().map(Expression::toString).orElse("").toLowerCase(Locale.ROOT);
|
||||
boolean redisScope = scope.contains("redis") || scope.contains("opsforvalue")
|
||||
|| scope.contains("boundvalueops");
|
||||
boolean redisScope = scope.contains("redis") || scope.contains("opsforvalue") || scope.contains("boundvalueops");
|
||||
if ("get".equals(name) || "getString".equals(name)) {
|
||||
if (!redisScope && !REDIS_GET.contains(name)) {
|
||||
// getString 也常见于 RedisUtil
|
||||
if (!"getString".equals(name)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
// 非 Redis scope:仅放行 getString(RedisUtil 习惯);裸 get(如 map.get)丢弃
|
||||
if (!redisScope && !"getString".equals(name)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
if (call.getArguments().isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(new RedisGetRef(call.getArgument(0)));
|
||||
}
|
||||
// redisTemplate.opsForValue().get(key)
|
||||
if ("get".equals(name) && scope.contains("opsforvalue") && !call.getArguments().isEmpty()) {
|
||||
return Optional.of(new RedisGetRef(call.getArgument(0)));
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,13 @@ public class MqReadHintDetector {
|
||||
this.keyResolver = new RedisKeyResolver(index);
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描 MQ 消费侧(Listener / parse),提取 destination → 消息体类型提示。
|
||||
*
|
||||
* @param filePath 仓库内相对路径
|
||||
* @param content 源码全文
|
||||
* @return 提示列表(用于补强生产侧投递点,不单独告警)
|
||||
*/
|
||||
public List<CacheReadHint> detect(String filePath, String content) {
|
||||
List<CacheReadHint> result = new ArrayList<>();
|
||||
if (content == null || content.isEmpty()) {
|
||||
|
||||
@@ -67,6 +67,13 @@ public class MqWritePointDetector {
|
||||
this.keyResolver = new RedisKeyResolver(index);
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描单个 Java 源文件中的 RocketMQ / Kafka 生产侧投递点。
|
||||
*
|
||||
* @param filePath 仓库内相对路径(仅写入报告)
|
||||
* @param content 源码全文
|
||||
* @return 命中的投递点列表(可能为空)
|
||||
*/
|
||||
public List<WritePoint> detect(String filePath, String content) {
|
||||
List<WritePoint> result = new ArrayList<>();
|
||||
if (content == null || content.isEmpty() || enabledPatterns.isEmpty()) {
|
||||
|
||||
@@ -61,6 +61,13 @@ public class RedisWritePointDetector {
|
||||
this.keyResolver = new RedisKeyResolver(index);
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描单个 Java 源文件中的 Redis 写入点。
|
||||
*
|
||||
* @param filePath 仓库内相对路径(仅写入报告)
|
||||
* @param content 源码全文
|
||||
* @return 命中的写入点列表(可能为空)
|
||||
*/
|
||||
public List<WritePoint> detect(String filePath, String content) {
|
||||
List<WritePoint> result = new ArrayList<>();
|
||||
if (content == null || content.isEmpty()) {
|
||||
|
||||
@@ -36,6 +36,7 @@ public class WritePoint {
|
||||
return keyExpression == null ? "" : keyExpression.replaceAll("\\s+", "");
|
||||
}
|
||||
|
||||
/** @return 是否为 MQ 通道(RocketMQ / Kafka) */
|
||||
public boolean isMq() {
|
||||
return CHANNEL_ROCKETMQ.equals(channel) || CHANNEL_KAFKA.equals(channel);
|
||||
}
|
||||
@@ -136,6 +137,9 @@ public class WritePoint {
|
||||
this.channel = channel == null || channel.isEmpty() ? CHANNEL_REDIS : channel;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return 展示用写入位置,形如 {@code SimpleClass#method:line}
|
||||
*/
|
||||
public String location() {
|
||||
String simpleClass = enclosingClass;
|
||||
if (simpleClass != null && simpleClass.contains(".")) {
|
||||
|
||||
@@ -2,6 +2,8 @@ package com.codechecker.cache.diff;
|
||||
|
||||
/**
|
||||
* 结构变更类型及其默认严重级别。
|
||||
* <p>用于 Diff 结果分类与报告展示标签;实际严重级别可被 {@code severity_overrides} 覆盖。</p>
|
||||
* @author DD
|
||||
*/
|
||||
public enum ChangeType {
|
||||
FIELD_REMOVED(Severity.P0, "字段删除"),
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
package com.codechecker.cache.diff;
|
||||
|
||||
/**
|
||||
* 一条结构变更记录。
|
||||
* 一条结构变更记录:关联 key/destination、写入位置、字段路径与前后取值说明。
|
||||
* <p>由 {@link SchemaDiffer} 或分析器在「新增/删除写入点」时生成,再进入报告明细。</p>
|
||||
*/
|
||||
public class SchemaChange {
|
||||
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
package com.codechecker.cache.diff;
|
||||
|
||||
/**
|
||||
* 变更严重级别。
|
||||
* 变更严重级别(默认由 {@link ChangeType} 给出,可被配置覆盖)。
|
||||
* <ul>
|
||||
* <li>{@link #P0} — 高危:字段删除、类型变更、包装层/路径迁移等</li>
|
||||
* <li>{@link #P1} — 中危:新增字段、删除写入点等</li>
|
||||
* <li>{@link #P2} — 低危/提示:新增写入点、低置信度变更等</li>
|
||||
* </ul>
|
||||
*/
|
||||
public enum Severity {
|
||||
/** 高危结构破坏 */
|
||||
P0,
|
||||
/** 中危兼容性风险 */
|
||||
P1,
|
||||
/** 低危或需人工确认 */
|
||||
P2
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
package com.codechecker.cache.git;
|
||||
|
||||
/**
|
||||
* Git 操作异常。
|
||||
* Git 命令执行失败时抛出的受检异常(如 {@code git diff}/{@code git show} 非零退出)。
|
||||
*/
|
||||
public class GitException extends Exception {
|
||||
|
||||
/** @param message 失败说明(通常含命令与 stderr 摘要) */
|
||||
public GitException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
/** @param message 失败说明 @param cause 底层原因 */
|
||||
public GitException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,14 @@ public class RedisKeyResolver {
|
||||
this.index = index;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 key / destination 表达式推断为静态模式字符串。
|
||||
*
|
||||
* @param keyExpr AST 表达式(字面量、常量拼接、format 等)
|
||||
* @param enclosingClass 所在类(用于查常量/方法)
|
||||
* @param context 类型索引上下文,可为 {@code null}
|
||||
* @return 规范化模式;动态段为 {@code *},无法推断时多为 {@code *} 或 {@code unknown-key} 上游处理
|
||||
*/
|
||||
public String resolve(Expression keyExpr, ClassOrInterfaceDeclaration enclosingClass,
|
||||
SourceIndex.IndexedType context) {
|
||||
String raw = resolveExpr(keyExpr, enclosingClass, context, 0);
|
||||
|
||||
@@ -13,7 +13,8 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 企业微信群机器人通知(markdown 消息)。
|
||||
* 企业微信群机器人通知:发送 markdown 消息(单条或多条按序发送)。
|
||||
* <p>Webhook 为空时跳过;HTTP/errcode 失败时打 stderr 日志并返回 false。</p>
|
||||
*/
|
||||
public class WeComNotifier {
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@ import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 一次检测的完整结果。
|
||||
* 一次检测的完整结果:元信息、字段级 {@link SchemaChange} 列表、按 Key/Topic 聚合的
|
||||
* {@link KeyStructureChange},以及是否阻断流水线与建议退出码。
|
||||
*/
|
||||
public class CheckReport {
|
||||
|
||||
@@ -24,10 +25,12 @@ public class CheckReport {
|
||||
private boolean blocked;
|
||||
private int exitCode;
|
||||
|
||||
/** @return 是否存在字段级或 Key 级结构变更 */
|
||||
public boolean hasChanges() {
|
||||
return !changes.isEmpty() || !keyChanges.isEmpty();
|
||||
}
|
||||
|
||||
/** @return 字段明细中指定严重级别的条数 */
|
||||
public long count(Severity severity) {
|
||||
return changes.stream().filter(c -> c.getSeverity() == severity).count();
|
||||
}
|
||||
|
||||
@@ -75,10 +75,12 @@ public class KeyStructureChange {
|
||||
this.channel = channel == null || channel.isEmpty() ? "REDIS" : channel;
|
||||
}
|
||||
|
||||
/** @return 是否为 MQ 通道 */
|
||||
public boolean isMq() {
|
||||
return "ROCKETMQ".equals(channel) || "KAFKA".equals(channel);
|
||||
}
|
||||
|
||||
/** @return 企微展示用通道名(RocketMQ / Kafka / Redis) */
|
||||
public String channelDisplay() {
|
||||
if ("ROCKETMQ".equals(channel)) {
|
||||
return "RocketMQ";
|
||||
@@ -117,6 +119,11 @@ public class KeyStructureChange {
|
||||
return fieldDetails;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用更严重的级别抬升当前聚合严重度(ordinal 更小者优先)。
|
||||
*
|
||||
* @param candidate 候选级别;为 null 时忽略
|
||||
*/
|
||||
public void raiseSeverity(Severity candidate) {
|
||||
if (candidate == null) {
|
||||
return;
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
package com.codechecker.cache.schema;
|
||||
|
||||
/**
|
||||
* 序列化后 JSON 值的粗粒度类型。
|
||||
* 序列化后 JSON 值的粗粒度类型,用于 Schema 展开与骨架占位符选择。
|
||||
*/
|
||||
public enum JsonType {
|
||||
/** JSON 对象 */
|
||||
OBJECT,
|
||||
/** JSON 数组 */
|
||||
ARRAY,
|
||||
/** Map 结构 */
|
||||
MAP,
|
||||
/** 字符串 */
|
||||
STRING,
|
||||
/** 数值 */
|
||||
NUMBER,
|
||||
/** 布尔 */
|
||||
BOOLEAN,
|
||||
/** 无法判定 */
|
||||
UNKNOWN
|
||||
}
|
||||
|
||||
@@ -16,6 +16,11 @@ public class SkeletonJsonRenderer {
|
||||
/** 单侧骨架默认最大长度(企微 markdown 总长约 4096)。 */
|
||||
public static final int DEFAULT_MAX_LEN = 1500;
|
||||
|
||||
/**
|
||||
* 渲染完整骨架 JSON,不做长度截断。
|
||||
*
|
||||
* @param schema 扁平 Schema;空或 null 时返回 {@code "{}"}
|
||||
*/
|
||||
public String render(TypeSchema schema) {
|
||||
return render(schema, null, Integer.MAX_VALUE);
|
||||
}
|
||||
|
||||
@@ -32,10 +32,12 @@ public class TypeSchema {
|
||||
return fields;
|
||||
}
|
||||
|
||||
/** 按字段 path 放入(同 path 后者覆盖)。 */
|
||||
public void add(FieldSchema field) {
|
||||
fields.put(field.getPath(), field);
|
||||
}
|
||||
|
||||
/** @return 是否无任何可展开字段(空 Schema / 无法解析类型时为 true) */
|
||||
public boolean isEmpty() {
|
||||
return fields.isEmpty();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user