feat: V1.1 - 注释补充

This commit is contained in:
2026-08-03 14:24:41 +08:00
parent a41176c6ce
commit 7cbbfae503
23 changed files with 158 additions and 23 deletions

View File

@@ -12,6 +12,7 @@ import java.util.stream.Stream;
/**
* 扫描工作树中受包含/排除模块限制的 {@code src/main/java} 下的所有 Java 源文件。
* <p>返回路径 → 源码内容映射,供 {@link SchemaCheckAnalyzer} 构建新旧 {@code SourceIndex}。</p>
*/
public class FileScanner {

View File

@@ -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;

View File

@@ -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);

View File

@@ -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,

View File

@@ -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;

View File

@@ -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)) {

View File

@@ -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;

View File

@@ -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)) {
// 非 Redis scope:仅放行 getStringRedisUtil 习惯);裸 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();
}

View File

@@ -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()) {

View File

@@ -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()) {

View File

@@ -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()) {

View File

@@ -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(".")) {

View File

@@ -2,6 +2,8 @@ package com.codechecker.cache.diff;
/**
* 结构变更类型及其默认严重级别。
* <p>用于 Diff 结果分类与报告展示标签;实际严重级别可被 {@code severity_overrides} 覆盖。</p>
* @author DD
*/
public enum ChangeType {
FIELD_REMOVED(Severity.P0, "字段删除"),

View File

@@ -1,7 +1,8 @@
package com.codechecker.cache.diff;
/**
* 一条结构变更记录。
* 一条结构变更记录:关联 key/destination、写入位置、字段路径与前后取值说明
* <p>由 {@link SchemaDiffer} 或分析器在「新增/删除写入点」时生成,再进入报告明细。</p>
*/
public class SchemaChange {

View File

@@ -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
}

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -13,7 +13,8 @@ import java.util.List;
import java.util.Map;
/**
* 企业微信群机器人通知markdown 消息)。
* 企业微信群机器人通知:发送 markdown 消息(单条或多条按序发送)。
* <p>Webhook 为空时跳过HTTP/errcode 失败时打 stderr 日志并返回 false。</p>
*/
public class WeComNotifier {

View File

@@ -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();
}

View File

@@ -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;

View File

@@ -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
}

View File

@@ -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);
}

View File

@@ -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();
}