Compare commits

...

2 Commits

Author SHA1 Message Date
14f9f1fce7 feat: 1、Vo包装识别增强 2、去除 位置&类型 的色块
All checks were successful
序列化结构检查 / serialization-schema-check (push) Has been skipped
2026-07-15 15:45:35 +08:00
4dd4944107 feat: md渲染优化 2026-07-15 14:40:00 +08:00
9 changed files with 557 additions and 63 deletions

View File

@@ -274,10 +274,12 @@ public class SchemaCheckAnalyzer {
paths.add(c.getOldValue()); paths.add(c.getOldValue());
} }
if (c.getChangeType() == ChangeType.WRAPPER_ADDED if (c.getChangeType() == ChangeType.WRAPPER_ADDED
&& c.getFieldPath() != null) { || c.getChangeType() == ChangeType.WRAPPER_REMOVED) {
if (c.getFieldPath() != null) {
paths.add(c.getFieldPath()); paths.add(c.getFieldPath());
} }
} }
}
return paths; return paths;
} }

View File

@@ -7,6 +7,7 @@ public enum ChangeType {
FIELD_REMOVED(Severity.P0, "字段删除"), FIELD_REMOVED(Severity.P0, "字段删除"),
TYPE_CHANGED(Severity.P0, "字段类型变更"), TYPE_CHANGED(Severity.P0, "字段类型变更"),
WRAPPER_ADDED(Severity.P0, "新增包装层"), WRAPPER_ADDED(Severity.P0, "新增包装层"),
WRAPPER_REMOVED(Severity.P0, "删除包装层"),
FIELD_PATH_MOVED(Severity.P0, "字段路径迁移"), FIELD_PATH_MOVED(Severity.P0, "字段路径迁移"),
FIELD_ADDED(Severity.P1, "新增字段"), FIELD_ADDED(Severity.P1, "新增字段"),
KEY_PATTERN_CHANGED(Severity.P1, "Key 模式变更"), KEY_PATTERN_CHANGED(Severity.P1, "Key 模式变更"),

View File

@@ -58,16 +58,21 @@ public class SchemaDiffer {
added.removeAll(oldLeaves.keySet()); added.removeAll(oldLeaves.keySet());
added.removeAll(typeChangedPaths); added.removeAll(typeChangedPaths);
// 路径迁移检测removed 的路径是某 added 路径的后缀) // 路径迁移
// - 下沉(加包装):旧路径是新路径后缀,如 dbName → vo.dbName
// - 上提(拆包装):新路径是旧路径后缀,如 vo.dbName → dbName
List<String[]> moves = new ArrayList<>(); List<String[]> moves = new ArrayList<>();
Set<String> matchedRemoved = new LinkedHashSet<>(); Set<String> matchedRemoved = new LinkedHashSet<>();
Set<String> matchedAdded = new LinkedHashSet<>(); Set<String> matchedAdded = new LinkedHashSet<>();
for (String r : removed) { for (String r : removed) {
if (matchedRemoved.contains(r)) {
continue;
}
for (String a : added) { for (String a : added) {
if (matchedAdded.contains(a)) { if (matchedAdded.contains(a)) {
continue; continue;
} }
if (isSuffix(a, r)) { if (isSuffix(a, r) || isSuffix(r, a)) {
moves.add(new String[]{r, a}); moves.add(new String[]{r, a});
matchedRemoved.add(r); matchedRemoved.add(r);
matchedAdded.add(a); matchedAdded.add(a);
@@ -76,16 +81,16 @@ public class SchemaDiffer {
} }
} }
// 包装层检测:多个迁移共享同一新前缀 // 新增包装层:多个迁移共享同一新前缀
Map<String, Integer> prefixCount = new LinkedHashMap<>(); Map<String, Integer> newPrefixCount = new LinkedHashMap<>();
for (String[] move : moves) { for (String[] move : moves) {
String a = move[1]; String a = move[1];
if (a.contains(".")) { if (a.contains(".")) {
String prefix = a.substring(0, a.indexOf('.')); String prefix = a.substring(0, a.indexOf('.'));
prefixCount.merge(prefix, 1, Integer::sum); newPrefixCount.merge(prefix, 1, Integer::sum);
} }
} }
for (Map.Entry<String, Integer> e : prefixCount.entrySet()) { for (Map.Entry<String, Integer> e : newPrefixCount.entrySet()) {
if (e.getValue() >= 2) { if (e.getValue() >= 2) {
SchemaChange c = new SchemaChange(ChangeType.WRAPPER_ADDED); SchemaChange c = new SchemaChange(ChangeType.WRAPPER_ADDED);
c.setFieldPath(e.getKey()); c.setFieldPath(e.getKey());
@@ -95,6 +100,25 @@ public class SchemaDiffer {
} }
} }
// 删除包装层:多个迁移共享同一旧前缀
Map<String, Integer> oldPrefixCount = new LinkedHashMap<>();
for (String[] move : moves) {
String r = move[0];
if (r.contains(".")) {
String prefix = r.substring(0, r.indexOf('.'));
oldPrefixCount.merge(prefix, 1, Integer::sum);
}
}
for (Map.Entry<String, Integer> e : oldPrefixCount.entrySet()) {
if (e.getValue() >= 2) {
SchemaChange c = new SchemaChange(ChangeType.WRAPPER_REMOVED);
c.setFieldPath(e.getKey());
c.setOldValue(e.getKey());
c.setMessage("删除包装层 " + e.getKey() + ",其下字段被提升为外层(影响 " + e.getValue() + " 个字段)");
changes.add(c);
}
}
for (String[] move : moves) { for (String[] move : moves) {
SchemaChange c = new SchemaChange(ChangeType.FIELD_PATH_MOVED); SchemaChange c = new SchemaChange(ChangeType.FIELD_PATH_MOVED);
c.setFieldPath(move[1]); c.setFieldPath(move[1]);

View File

@@ -1,10 +1,12 @@
package com.codechecker.cache.key; package com.codechecker.cache.key;
import com.codechecker.cache.schema.SourceIndex; import com.codechecker.cache.schema.SourceIndex;
import com.github.javaparser.Position;
import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration; import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration;
import com.github.javaparser.ast.body.FieldDeclaration; import com.github.javaparser.ast.body.FieldDeclaration;
import com.github.javaparser.ast.body.MethodDeclaration; import com.github.javaparser.ast.body.MethodDeclaration;
import com.github.javaparser.ast.body.VariableDeclarator; import com.github.javaparser.ast.body.VariableDeclarator;
import com.github.javaparser.ast.expr.AssignExpr;
import com.github.javaparser.ast.expr.BinaryExpr; import com.github.javaparser.ast.expr.BinaryExpr;
import com.github.javaparser.ast.expr.Expression; import com.github.javaparser.ast.expr.Expression;
import com.github.javaparser.ast.expr.FieldAccessExpr; import com.github.javaparser.ast.expr.FieldAccessExpr;
@@ -17,6 +19,8 @@ import java.util.Optional;
/** /**
* 尽力将 Redis key 表达式静态推断为一个「key 模式」,动态部分以 {@code *} 表示。 * 尽力将 Redis key 表达式静态推断为一个「key 模式」,动态部分以 {@code *} 表示。
* <p>
* 对局部变量名:先查同名静态常量 / 同名方法 return再回溯方法内使用点之前最近一次声明或赋值。
*/ */
public class RedisKeyResolver { public class RedisKeyResolver {
@@ -52,12 +56,17 @@ public class RedisKeyResolver {
} }
if (expr instanceof NameExpr) { if (expr instanceof NameExpr) {
String name = ((NameExpr) expr).getNameAsString(); String name = ((NameExpr) expr).getNameAsString();
// 1) 静态常量 2) 同名方法 return 3) 局部变量定值(不改变既有优先级)
String constVal = lookupConstant(enclosingClass, name, context, depth); String constVal = lookupConstant(enclosingClass, name, context, depth);
if (constVal != null) { if (constVal != null) {
return constVal; return constVal;
} }
String methodVal = lookupMethodReturn(enclosingClass, name, context, depth); String methodVal = lookupMethodReturn(enclosingClass, name, context, depth);
return methodVal != null ? methodVal : "*"; if (methodVal != null) {
return methodVal;
}
String localVal = lookupLocalBinding(expr, name, enclosingClass, context, depth);
return localVal != null ? localVal : "*";
} }
if (expr instanceof FieldAccessExpr) { if (expr instanceof FieldAccessExpr) {
FieldAccessExpr fae = (FieldAccessExpr) expr; FieldAccessExpr fae = (FieldAccessExpr) expr;
@@ -94,6 +103,76 @@ public class RedisKeyResolver {
return "*"; return "*";
} }
/**
* 在包围方法内,取使用点之前对 {@code name} 最近一次声明初始化或赋值的右侧表达式。
*/
private String lookupLocalBinding(Expression useSite, String name,
ClassOrInterfaceDeclaration enclosingClass,
SourceIndex.IndexedType context, int depth) {
if (useSite == null || name == null || name.isEmpty()) {
return null;
}
MethodDeclaration method = useSite.findAncestor(MethodDeclaration.class).orElse(null);
if (method == null || !method.getBody().isPresent()) {
return null;
}
Position usePos = useSite.getBegin().orElse(null);
Expression bestRhs = null;
Position bestPos = null;
for (VariableDeclarator var : method.findAll(VariableDeclarator.class)) {
if (!name.equals(var.getNameAsString()) || !var.getInitializer().isPresent()) {
continue;
}
Position pos = var.getBegin().orElse(null);
if (!isUsableBinding(pos, usePos, bestPos)) {
continue;
}
bestPos = pos;
bestRhs = var.getInitializer().get();
}
for (AssignExpr assign : method.findAll(AssignExpr.class)) {
if (!(assign.getTarget() instanceof NameExpr)) {
continue;
}
if (!name.equals(((NameExpr) assign.getTarget()).getNameAsString())) {
continue;
}
Position pos = assign.getBegin().orElse(null);
if (!isUsableBinding(pos, usePos, bestPos)) {
continue;
}
bestPos = pos;
bestRhs = assign.getValue();
}
if (bestRhs == null) {
return null;
}
// 避免自引用String key = key陷入无意义递归
if (bestRhs instanceof NameExpr
&& name.equals(((NameExpr) bestRhs).getNameAsString())) {
return null;
}
return resolveExpr(bestRhs, enclosingClass, context, depth + 1);
}
/** 候选须严格在使用点之前,并在多个候选中取最近一次。 */
private static boolean isUsableBinding(Position candidate, Position usePos, Position bestPos) {
if (candidate == null) {
return bestPos == null;
}
if (usePos != null && !candidate.isBefore(usePos)) {
return false;
}
if (bestPos == null) {
return true;
}
return candidate.isAfter(bestPos);
}
private String lookupConstant(ClassOrInterfaceDeclaration clazz, String name, private String lookupConstant(ClassOrInterfaceDeclaration clazz, String name,
SourceIndex.IndexedType context, int depth) { SourceIndex.IndexedType context, int depth) {
if (clazz == null) { if (clazz == null) {

View File

@@ -1,5 +1,6 @@
package com.codechecker.cache.report; package com.codechecker.cache.report;
import com.codechecker.cache.diff.ChangeType;
import com.codechecker.cache.diff.SchemaChange; import com.codechecker.cache.diff.SchemaChange;
import com.codechecker.cache.diff.Severity; import com.codechecker.cache.diff.Severity;
@@ -14,7 +15,7 @@ import java.util.Set;
/** /**
* 将 {@link CheckReport} 渲染为企微 Markdown / 控制台文本。 * 将 {@link CheckReport} 渲染为企微 Markdown / 控制台文本。
* <ul> * <ul>
* <li>企微:按 key 展示位置/类型/序列化骨架变更,不含字段明细,不分 P0/P1/P2</li> * <li>企微:按 key 展示位置/类型/序列化骨架变更;类型变更另附摘要行,不分 P0/P1/P2</li>
* <li>未解析 key 展示源码表达式 + 灰色「key 无法解析」提示</li> * <li>未解析 key 展示源码表达式 + 灰色「key 无法解析」提示</li>
* <li>多 key 优先拼成一条;超过企微上限则按 key 拆成多条</li> * <li>多 key 优先拼成一条;超过企微上限则按 key 拆成多条</li>
* <li>CI先打字段明细再完整输出企微 Markdown拆分后的每条</li> * <li>CI先打字段明细再完整输出企微 Markdown拆分后的每条</li>
@@ -128,26 +129,92 @@ public class ReportBuilder {
String oldJson = nvl(kc.getOldSkeletonJson()); String oldJson = nvl(kc.getOldSkeletonJson());
String newJson = nvl(kc.getNewSkeletonJson()); String newJson = nvl(kc.getNewSkeletonJson());
Set<String> oldHighlight = SkeletonAnnotator.pathsForOldSkeleton(kc.getFieldDetails()); Set<String> oldHighlight = SkeletonAnnotator.pathsForOldSkeleton(kc.getFieldDetails());
Set<String> wrappersRemoved = SkeletonAnnotator.pathsForWrapperRemoved(kc.getFieldDetails());
Set<String> newGreen = SkeletonAnnotator.pathsForNewSkeleton(kc.getFieldDetails()); Set<String> newGreen = SkeletonAnnotator.pathsForNewSkeleton(kc.getFieldDetails());
Set<String> newOrange = SkeletonAnnotator.pathsForTypeChanged(kc.getFieldDetails()); Set<String> wrappersAdded = SkeletonAnnotator.pathsForWrapperAdded(kc.getFieldDetails());
Set<String> typeGray = SkeletonAnnotator.pathsForTypeChanged(kc.getFieldDetails());
String oldRendered = oldJson.isEmpty() String oldRendered = oldJson.isEmpty()
? "" : "" + SkeletonAnnotator.annotateOldForWecom(oldJson, oldHighlight) + ""; ? "" : "" + SkeletonAnnotator.annotateOldForWecom(
oldJson, oldHighlight, typeGray, wrappersRemoved) + "";
String newRendered = newJson.isEmpty() String newRendered = newJson.isEmpty()
? "" : "" + SkeletonAnnotator.annotateNewForWecom(newJson, newGreen, newOrange) + ""; ? "" : "" + SkeletonAnnotator.annotateNewForWecom(
newJson, newGreen, typeGray, wrappersAdded) + "";
if (oldJson.isEmpty() && !newJson.isEmpty()) { if (oldJson.isEmpty() && !newJson.isEmpty()) {
sb.append(" > **value 新增为:** ").append(newRendered).append("\n\n"); sb.append(" > **value 新增为:** ").append(newRendered).append('\n');
} else if (!oldJson.isEmpty() && newJson.isEmpty()) { } else if (!oldJson.isEmpty() && newJson.isEmpty()) {
sb.append(" > **value 原结构:** ").append(oldRendered).append("(已删除写入)\n\n"); sb.append(" > **value 原结构:** ").append(oldRendered).append("(已删除写入)\n");
} else { } else {
sb.append(" > **value值由** ").append(oldRendered).append('\n'); sb.append(" > **value值由** ").append(oldRendered).append('\n');
sb.append(" > **变更为:** ").append(newRendered).append("\n\n"); sb.append(" > **变更为:** ").append(newRendered).append('\n');
} }
appendTypeChangeSummary(sb, kc.getFieldDetails());
sb.append('\n');
return sb.toString(); return sb.toString();
} }
/**
* 类型变更单独一行摘要,避免仅靠骨架颜色/占位难以识别。
* 例:{@code > **类型变更**: amount <font color="warning">BigDecimal → Integer</font>}
*/
private void appendTypeChangeSummary(StringBuilder sb, List<SchemaChange> details) {
if (details == null || details.isEmpty()) {
return;
}
List<String> parts = new ArrayList<>();
for (SchemaChange c : details) {
if (c == null || c.getChangeType() != ChangeType.TYPE_CHANGED) {
continue;
}
String path = nvl(c.getFieldPath());
if (path.isEmpty()) {
continue;
}
String oldType = displayJavaType(c.getOldValue());
String newType = displayJavaType(c.getNewValue());
StringBuilder part = new StringBuilder();
// 字段名用普通文本(避免反引号被企微渲染成色块)
part.append(path);
if (!oldType.isEmpty() || !newType.isEmpty()) {
part.append(" <font color=\"warning\">")
.append(oldType.isEmpty() ? "?" : oldType)
.append("")
.append(newType.isEmpty() ? "?" : newType)
.append("</font>");
}
parts.add(part.toString());
}
if (parts.isEmpty()) {
return;
}
sb.append(" > **类型变更**: ");
for (int i = 0; i < parts.size(); i++) {
if (i > 0) {
sb.append("");
}
sb.append(parts.get(i));
}
sb.append('\n');
}
/** 将 {@code BigDecimal/NUMBER} 转为展示用 {@code BigDecimal}。 */
private static String displayJavaType(String typeLabel) {
if (typeLabel == null) {
return "";
}
String s = typeLabel.trim();
if (s.isEmpty()) {
return "";
}
int slash = s.indexOf('/');
if (slash > 0) {
s = s.substring(0, slash).trim();
}
return s;
}
/** /**
* Key 行:展示解析后的模式或未解析时的源码表达式;未解析时追加灰色提示。 * Key 行:展示解析后的模式或未解析时的源码表达式;未解析时追加灰色提示。
* 反引号仅包裹 key 文本,避免与加粗/颜色嵌套冲突 * 反引号内仍须转义 {@code *},否则企微会把 {@code *:*} 当成斜体吃掉通配符
*/ */
private void appendKeyLine(StringBuilder sb, String displayKey, String keyExpression, private void appendKeyLine(StringBuilder sb, String displayKey, String keyExpression,
boolean unresolved) { boolean unresolved) {
@@ -157,17 +224,28 @@ public class ReportBuilder {
if (keyText.isEmpty()) { if (keyText.isEmpty()) {
keyText = "unknown-key"; keyText = "unknown-key";
} }
sb.append("- Key --> `").append(keyText).append('`'); sb.append("- Key --> `").append(escapeWeComCode(keyText)).append('`');
if (unresolved) { if (unresolved) {
sb.append(" <font color=\"comment\">key 无法解析)</font>"); sb.append(" <font color=\"comment\">key 无法解析)</font>");
} }
sb.append('\n'); sb.append('\n');
} }
/** 位置、类型作为每个 Key 块的通用项。 */ /**
* 企微 markdown 代码片段内的转义:{@code *} 会触发斜体(即使包在反引号里)。
*/
static String escapeWeComCode(String text) {
if (text == null || text.isEmpty()) {
return "";
}
// 反斜杠转义对企微不可靠;用全角 保留通配语义且不被吃掉
return text.replace("*", "");
}
/** 位置、类型作为每个 Key 块的通用项(不加反引号,避免企微渲染成色块)。 */
private void appendMetaLines(StringBuilder sb, String writeLocation, String valueType) { private void appendMetaLines(StringBuilder sb, String writeLocation, String valueType) {
sb.append(" > **位置**: `").append(nvl(writeLocation)).append("`\n"); sb.append(" > **位置**: ").append(nvl(writeLocation)).append('\n');
sb.append(" > **类型**: `").append(nvl(valueType)).append("`\n"); sb.append(" > **类型**: ").append(nvl(valueType)).append('\n');
} }
private boolean isUnresolvedKey(String keyPattern) { private boolean isUnresolvedKey(String keyPattern) {
@@ -203,7 +281,7 @@ public class ReportBuilder {
for (SchemaChange c : list) { for (SchemaChange c : list) {
sb.append("- **").append(c.getChangeType().getLabel()).append("**"); sb.append("- **").append(c.getChangeType().getLabel()).append("**");
if (c.getKeyPattern() != null) { if (c.getKeyPattern() != null) {
sb.append(" `").append(c.getKeyPattern()).append('`'); sb.append(" `").append(escapeWeComCode(c.getKeyPattern())).append('`');
} }
sb.append('\n'); sb.append('\n');
if (c.getWriteLocation() != null) { if (c.getWriteLocation() != null) {
@@ -251,8 +329,8 @@ public class ReportBuilder {
+ trimmed.substring(colonEn + 1).trim(); + trimmed.substring(colonEn + 1).trim();
} }
String[] knownPrefixes = { String[] knownPrefixes = {
"删除字段 ", "新增字段 ", "字段路径迁移 ", "新增包装层 ", "新增缓存写入点,", "删除字段 ", "新增字段 ", "字段路径迁移 ", "新增包装层 ", "删除包装层 ",
"删除缓存写入点,原 value 类型: " "新增缓存写入点,", "删除缓存写入点,原 value 类型: "
}; };
for (String prefix : knownPrefixes) { for (String prefix : knownPrefixes) {
if (trimmed.startsWith(prefix)) { if (trimmed.startsWith(prefix)) {

View File

@@ -12,25 +12,28 @@ import java.util.Set;
/** /**
* 在骨架 JSON 中为改动字段加企微颜色标注(仅改动片段染色,其余明文)。 * 在骨架 JSON 中为改动字段加企微颜色标注(仅改动片段染色,其余明文)。
* <ul> * <ul>
* <li>删除字段 → 旧骨架,橙色 {@code warning}</li> * <li>删除包装层 → 旧骨架优先整段标橙(先于子路径)</li>
* <li>新增字段 / 新增包装层骨架,绿色 {@code info}</li> * <li>删除字段 / 路径迁移旧侧骨架,色 {@code warning}</li>
* <li>路径迁移 → 旧路径橙、新路径绿</li> * <li>新增包装层 → 新骨架优先整体标绿(先于子路径</li>
* <li>类型变更 → 新骨架均为橙色 {@code warning}(提高识别度)</li> * <li>新增字段 / 路径迁移新侧 → 新骨架,绿色 {@code info}</li>
* <li>类型变更(修改类)→ 新旧骨架均为灰色 {@code comment}</li>
* </ul> * </ul>
*/ */
final class SkeletonAnnotator { final class SkeletonAnnotator {
/** 企微橙:删除 / 类型变更 / 旧侧变更 */ /** 企微橙:删除 / 路径迁移旧侧 / 删除包装层 */
static final String COLOR_REMOVE = "warning"; static final String COLOR_REMOVE = "warning";
/** 企微绿:新增 / 新侧路径迁移 */ /** 企微绿:新增 / 新侧路径迁移 */
static final String COLOR_ADD = "info"; static final String COLOR_ADD = "info";
/** 企微灰:类型变更(修改类) */
static final String COLOR_TYPE = "comment";
private static final String FONT_CLOSE = "</font>"; private static final String FONT_CLOSE = "</font>";
private SkeletonAnnotator() { private SkeletonAnnotator() {
} }
/** 旧骨架应橙色标注的路径:删除、路径迁移旧侧类型变更。 */ /** 旧骨架应橙色标注的路径:删除、路径迁移旧侧(不含类型变更与包装层删除)。 */
static Set<String> pathsForOldSkeleton(List<SchemaChange> details) { static Set<String> pathsForOldSkeleton(List<SchemaChange> details) {
Set<String> paths = new LinkedHashSet<>(); Set<String> paths = new LinkedHashSet<>();
if (details == null) { if (details == null) {
@@ -40,8 +43,7 @@ final class SkeletonAnnotator {
if (c == null || c.getChangeType() == null) { if (c == null || c.getChangeType() == null) {
continue; continue;
} }
if (c.getChangeType() == ChangeType.FIELD_REMOVED if (c.getChangeType() == ChangeType.FIELD_REMOVED) {
|| c.getChangeType() == ChangeType.TYPE_CHANGED) {
addIfPresent(paths, c.getFieldPath()); addIfPresent(paths, c.getFieldPath());
} else if (c.getChangeType() == ChangeType.FIELD_PATH_MOVED) { } else if (c.getChangeType() == ChangeType.FIELD_PATH_MOVED) {
addIfPresent(paths, c.getOldValue()); addIfPresent(paths, c.getOldValue());
@@ -50,7 +52,35 @@ final class SkeletonAnnotator {
return paths; return paths;
} }
/** 新骨架应绿色标注的路径:新增、包装层路径迁移新侧(不含类型变更)。 */ /** 删除包装层路径(旧骨架优先整段标橙)。 */
static Set<String> pathsForWrapperRemoved(List<SchemaChange> details) {
Set<String> paths = new LinkedHashSet<>();
if (details == null) {
return paths;
}
for (SchemaChange c : details) {
if (c != null && c.getChangeType() == ChangeType.WRAPPER_REMOVED) {
addIfPresent(paths, c.getFieldPath());
}
}
return paths;
}
/** 新增包装层路径(新骨架优先整段标绿)。 */
static Set<String> pathsForWrapperAdded(List<SchemaChange> details) {
Set<String> paths = new LinkedHashSet<>();
if (details == null) {
return paths;
}
for (SchemaChange c : details) {
if (c != null && c.getChangeType() == ChangeType.WRAPPER_ADDED) {
addIfPresent(paths, c.getFieldPath());
}
}
return paths;
}
/** 新骨架应绿色标注的路径:新增字段、路径迁移新侧(不含包装层与类型变更)。 */
static Set<String> pathsForNewSkeleton(List<SchemaChange> details) { static Set<String> pathsForNewSkeleton(List<SchemaChange> details) {
Set<String> paths = new LinkedHashSet<>(); Set<String> paths = new LinkedHashSet<>();
if (details == null) { if (details == null) {
@@ -60,8 +90,7 @@ final class SkeletonAnnotator {
if (c == null || c.getChangeType() == null) { if (c == null || c.getChangeType() == null) {
continue; continue;
} }
if (c.getChangeType() == ChangeType.FIELD_ADDED if (c.getChangeType() == ChangeType.FIELD_ADDED) {
|| c.getChangeType() == ChangeType.WRAPPER_ADDED) {
addIfPresent(paths, c.getFieldPath()); addIfPresent(paths, c.getFieldPath());
} else if (c.getChangeType() == ChangeType.FIELD_PATH_MOVED) { } else if (c.getChangeType() == ChangeType.FIELD_PATH_MOVED) {
addIfPresent(paths, c.getFieldPath()); addIfPresent(paths, c.getFieldPath());
@@ -73,7 +102,7 @@ final class SkeletonAnnotator {
return paths; return paths;
} }
/** 新骨架应橙色标注的路径:类型变更(与旧侧同色,便于识别)。 */ /** 类型变更路径:新旧骨架均用灰色标注。 */
static Set<String> pathsForTypeChanged(List<SchemaChange> details) { static Set<String> pathsForTypeChanged(List<SchemaChange> details) {
Set<String> paths = new LinkedHashSet<>(); Set<String> paths = new LinkedHashSet<>();
if (details == null) { if (details == null) {
@@ -88,30 +117,53 @@ final class SkeletonAnnotator {
} }
/** /**
* 标注旧骨架改动字段(删除 / 类型变更 → 橙色) * 标注旧骨架:删除等橙色;类型变更灰色
*/ */
static String annotateOldForWecom(String json, Set<String> highlightPaths) { static String annotateOldForWecom(String json, Set<String> orangePaths) {
return annotateForWecom(json, highlightPaths, COLOR_REMOVE); return annotateOldForWecom(json, orangePaths, null, null);
}
static String annotateOldForWecom(String json, Set<String> orangePaths, Set<String> grayPaths) {
return annotateOldForWecom(json, orangePaths, grayPaths, null);
} }
/** /**
* 标注新骨架:类型变更先着橙色,其余新增着绿色。 * @param wrapperRemovedPaths 删除的包装层,优先于普通橙路径标注
*/
static String annotateOldForWecom(String json, Set<String> orangePaths,
Set<String> grayPaths, Set<String> wrapperRemovedPaths) {
String result = annotateForWecom(json, grayPaths, COLOR_TYPE);
result = annotateForWecom(result, wrapperRemovedPaths, COLOR_REMOVE);
return annotateForWecom(result, orangePaths, COLOR_REMOVE);
}
/**
* 标注新骨架:类型变更灰色,新增绿色。
*/ */
static String annotateNewForWecom(String json, Set<String> greenPaths) { static String annotateNewForWecom(String json, Set<String> greenPaths) {
return annotateNewForWecom(json, greenPaths, null); return annotateNewForWecom(json, greenPaths, null, null);
} }
/** /**
* 标注新骨架改动字段(类型变更 → 色;新增 / 迁移新侧 → 绿)。 * 标注新骨架(类型变更 → 色;包装层先绿;其余新增/迁移 → 绿)。
*/ */
static String annotateNewForWecom(String json, Set<String> greenPaths, Set<String> orangePaths) { static String annotateNewForWecom(String json, Set<String> greenPaths, Set<String> grayTypePaths) {
String result = annotateForWecom(json, orangePaths, COLOR_REMOVE); return annotateNewForWecom(json, greenPaths, grayTypePaths, null);
}
/**
* @param wrapperPaths 包装层路径,优先于 {@code greenPaths} 标注,使 {@code "vo":{...}} 能整段染色
*/
static String annotateNewForWecom(String json, Set<String> greenPaths,
Set<String> grayTypePaths, Set<String> wrapperPaths) {
String result = annotateForWecom(json, grayTypePaths, COLOR_TYPE);
result = annotateForWecom(result, wrapperPaths, COLOR_ADD);
return annotateForWecom(result, greenPaths, COLOR_ADD); return annotateForWecom(result, greenPaths, COLOR_ADD);
} }
/** /**
* 仅标注改动字段:其余正文保持普通文本。 * 仅标注改动字段:其余正文保持普通文本。
* 改动片段格式:{@code <font color="warning|info">"field":value</font>} * 改动片段格式:{@code <font color="warning|info|comment">"field":value</font>}
*/ */
static String annotateForWecom(String json, Set<String> highlightPaths) { static String annotateForWecom(String json, Set<String> highlightPaths) {
return annotateForWecom(json, highlightPaths, COLOR_REMOVE); return annotateForWecom(json, highlightPaths, COLOR_REMOVE);

View File

@@ -40,6 +40,41 @@ class SchemaDifferTest {
assertEquals(2, moved, "dbName 与 linkList[].id 均应迁移"); assertEquals(2, moved, "dbName 与 linkList[].id 均应迁移");
} }
@Test
void detectsWrapperRemovalAndUpwardMoves() {
TypeSchema oldS = new TypeSchema("CacheEnvelope");
oldS.add(new FieldSchema("vo", JsonType.OBJECT, "TenantVO"));
oldS.add(new FieldSchema("vo.dbName", JsonType.STRING, "String"));
oldS.add(new FieldSchema("vo.linkList", JsonType.ARRAY, "List"));
oldS.add(new FieldSchema("vo.linkList[]", JsonType.OBJECT, "TenantLinkModel"));
oldS.add(new FieldSchema("vo.linkList[].id", JsonType.STRING, "String"));
TypeSchema newS = new TypeSchema("TenantVO");
newS.add(new FieldSchema("dbName", JsonType.STRING, "String"));
newS.add(new FieldSchema("linkList", JsonType.ARRAY, "List"));
newS.add(new FieldSchema("linkList[]", JsonType.OBJECT, "TenantLinkModel"));
newS.add(new FieldSchema("linkList[].id", JsonType.STRING, "String"));
List<SchemaChange> changes = new SchemaDiffer().diff(oldS, newS);
List<ChangeType> types = changes.stream().map(SchemaChange::getChangeType).collect(Collectors.toList());
assertTrue(types.contains(ChangeType.WRAPPER_REMOVED), "应检测到包装层删除");
assertTrue(types.contains(ChangeType.FIELD_PATH_MOVED), "应检测到字段上提迁移");
assertTrue(types.stream().noneMatch(t -> t == ChangeType.FIELD_ADDED),
"上提字段不应再被当成新增");
assertTrue(types.stream().noneMatch(t -> t == ChangeType.FIELD_REMOVED),
"上提字段不应再被当成删除");
SchemaChange wrapper = changes.stream()
.filter(c -> c.getChangeType() == ChangeType.WRAPPER_REMOVED)
.findFirst()
.orElseThrow(IllegalStateException::new);
assertEquals("vo", wrapper.getFieldPath());
long moved = changes.stream().filter(c -> c.getChangeType() == ChangeType.FIELD_PATH_MOVED).count();
assertEquals(2, moved, "vo.dbName / vo.linkList[].id 均应上提");
}
@Test @Test
void detectsTypeChange() { void detectsTypeChange() {
TypeSchema oldS = new TypeSchema("A"); TypeSchema oldS = new TypeSchema("A");

View File

@@ -65,4 +65,91 @@ class RedisKeyResolverTest {
buildCall, clazz, index.get("jnpf.util.TenantDbContentCacheHelper")); buildCall, clazz, index.get("jnpf.util.TenantDbContentCacheHelper"));
assertEquals("tenant:db:content:*", pattern); assertEquals("tenant:db:content:*", pattern);
} }
@Test
void resolvesLocalVariableAssignedFromStringFormat() {
String source = ""
+ "package demo;\n"
+ "public class CombinationRepo {\n"
+ " private static final String COMBINATION_QUERY_KEY = "
+ "\"data_analysis:combination_query_key:%s:%s\";\n"
+ " public void write(String tenantId, String hashKey) {\n"
+ " String key = String.format(COMBINATION_QUERY_KEY, tenantId, hashKey);\n"
+ " redisSet(key);\n"
+ " }\n"
+ " void redisSet(String k) {}\n"
+ "}\n";
SourceIndex index = new SourceIndex();
index.addSource(source);
CompilationUnit cu = StaticJavaParser.parse(source);
ClassOrInterfaceDeclaration clazz = cu.getType(0).asClassOrInterfaceDeclaration();
// redisSet(key) 的实参 key —— 局部变量使用点
MethodCallExpr redisSet = cu.findAll(MethodCallExpr.class).stream()
.filter(m -> "redisSet".equals(m.getNameAsString()))
.findFirst()
.orElseThrow(IllegalStateException::new);
Expression keyArg = redisSet.getArgument(0);
String pattern = new RedisKeyResolver(index).resolve(
keyArg, clazz, index.get("demo.CombinationRepo"));
assertEquals("data_analysis:combination_query_key:*:*", pattern);
}
@Test
void localReassignmentUsesNearestBindingBeforeUse() {
String source = ""
+ "package demo;\n"
+ "public class ReassignRepo {\n"
+ " private static final String A = \"prefix:a:%s\";\n"
+ " private static final String B = \"prefix:b:%s\";\n"
+ " public void write(String id) {\n"
+ " String key = String.format(A, id);\n"
+ " key = String.format(B, id);\n"
+ " redisSet(key);\n"
+ " }\n"
+ " void redisSet(String k) {}\n"
+ "}\n";
SourceIndex index = new SourceIndex();
index.addSource(source);
CompilationUnit cu = StaticJavaParser.parse(source);
ClassOrInterfaceDeclaration clazz = cu.getType(0).asClassOrInterfaceDeclaration();
MethodCallExpr redisSet = cu.findAll(MethodCallExpr.class).stream()
.filter(m -> "redisSet".equals(m.getNameAsString()))
.findFirst()
.orElseThrow(IllegalStateException::new);
String pattern = new RedisKeyResolver(index).resolve(
redisSet.getArgument(0), clazz, index.get("demo.ReassignRepo"));
assertEquals("prefix:b:*", pattern);
}
@Test
void staticConstantStillPreferredOverLocalNameCollision() {
// 局部变量名与静态常量同名时:仍优先静态常量(保持旧语义)
String source = ""
+ "package demo;\n"
+ "public class Collision {\n"
+ " private static final String KEY = \"const:prefix:%s\";\n"
+ " public void write(String id) {\n"
+ " String KEY = \"local:\" + id;\n"
+ " redisSet(KEY);\n"
+ " }\n"
+ " void redisSet(String k) {}\n"
+ "}\n";
SourceIndex index = new SourceIndex();
index.addSource(source);
CompilationUnit cu = StaticJavaParser.parse(source);
ClassOrInterfaceDeclaration clazz = cu.getType(0).asClassOrInterfaceDeclaration();
MethodCallExpr redisSet = cu.findAll(MethodCallExpr.class).stream()
.filter(m -> "redisSet".equals(m.getNameAsString()))
.findFirst()
.orElseThrow(IllegalStateException::new);
String pattern = new RedisKeyResolver(index).resolve(
redisSet.getArgument(0), clazz, index.get("demo.Collision"));
assertEquals("const:prefix:%s", pattern);
}
} }

View File

@@ -5,6 +5,7 @@ import com.codechecker.cache.diff.SchemaChange;
import com.codechecker.cache.diff.Severity; import com.codechecker.cache.diff.Severity;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
@@ -60,8 +61,8 @@ class ReportBuilderTest {
assertTrue(md.contains("- Key --> `saas:period-config:migration:current`")); assertTrue(md.contains("- Key --> `saas:period-config:migration:current`"));
assertFalse(md.contains("key 无法解析)")); assertFalse(md.contains("key 无法解析)"));
assertTrue(md.contains("> **位置**: `SaasPeriodConfigMigrationRedisSupport#putCurrent:41`")); assertTrue(md.contains("> **位置**: SaasPeriodConfigMigrationRedisSupport#putCurrent:41"));
assertTrue(md.contains("> **类型**: `MigrationCurrentVo`")); assertTrue(md.contains("> **类型**: MigrationCurrentVo"));
int oldSection = md.indexOf("> **value值由**"); int oldSection = md.indexOf("> **value值由**");
int newSection = md.indexOf("> **变更为:**"); int newSection = md.indexOf("> **变更为:**");
@@ -112,7 +113,7 @@ class ReportBuilderTest {
} }
@Test @Test
void typeChangedHighlightedOrangeInBothSkeletons() { void typeChangedHighlightedGrayInBothSkeletons() {
SchemaChange typeChanged = new SchemaChange(ChangeType.TYPE_CHANGED); SchemaChange typeChanged = new SchemaChange(ChangeType.TYPE_CHANGED);
typeChanged.setFieldPath("amount"); typeChanged.setFieldPath("amount");
typeChanged.setOldValue("BigDecimal/NUMBER"); typeChanged.setOldValue("BigDecimal/NUMBER");
@@ -125,20 +126,22 @@ class ReportBuilderTest {
Set<String> oldPaths = SkeletonAnnotator.pathsForOldSkeleton(details); Set<String> oldPaths = SkeletonAnnotator.pathsForOldSkeleton(details);
Set<String> newGreen = SkeletonAnnotator.pathsForNewSkeleton(details); Set<String> newGreen = SkeletonAnnotator.pathsForNewSkeleton(details);
Set<String> newOrange = SkeletonAnnotator.pathsForTypeChanged(details); Set<String> typeGray = SkeletonAnnotator.pathsForTypeChanged(details);
assertEquals(Collections.singleton("amount"), oldPaths); assertTrue(oldPaths.isEmpty());
assertTrue(newGreen.isEmpty()); assertTrue(newGreen.isEmpty());
assertEquals(Collections.singleton("amount"), newOrange); assertEquals(Collections.singleton("amount"), typeGray);
String oldMd = SkeletonAnnotator.annotateOldForWecom(oldJson, oldPaths); String oldMd = SkeletonAnnotator.annotateOldForWecom(oldJson, oldPaths, typeGray);
String newMd = SkeletonAnnotator.annotateNewForWecom(newJson, newGreen, newOrange); String newMd = SkeletonAnnotator.annotateNewForWecom(newJson, newGreen, typeGray);
assertTrue(oldMd.contains("<font color=\"warning\">\"amount\":0</font>")); assertTrue(oldMd.contains("<font color=\"comment\">\"amount\":0</font>"));
assertFalse(oldMd.contains("<font color=\"warning\">\"name\":\"\"</font>")); assertFalse(oldMd.contains("<font color=\"warning\">\"amount\""));
assertTrue(newMd.contains("<font color=\"warning\">\"amount\":\"\"</font>")); assertFalse(oldMd.contains("<font color=\"comment\">\"name\":\"\"</font>"));
assertTrue(newMd.contains("<font color=\"comment\">\"amount\":\"\"</font>"));
assertFalse(newMd.contains("<font color=\"info\">\"amount\"")); assertFalse(newMd.contains("<font color=\"info\">\"amount\""));
assertFalse(newMd.contains("<font color=\"warning\">\"name\":\"\"</font>")); assertFalse(newMd.contains("<font color=\"warning\">\"amount\""));
assertFalse(newMd.contains("<font color=\"comment\">\"name\":\"\"</font>"));
CheckReport report = baseReport(); CheckReport report = baseReport();
KeyStructureChange key = new KeyStructureChange(); KeyStructureChange key = new KeyStructureChange();
@@ -157,9 +160,124 @@ class ReportBuilderTest {
assertTrue(newSection > oldSection); assertTrue(newSection > oldSection);
String oldPart = md.substring(oldSection, newSection); String oldPart = md.substring(oldSection, newSection);
String newPart = md.substring(newSection); String newPart = md.substring(newSection);
assertTrue(oldPart.contains("<font color=\"warning\">\"amount\":0</font>")); assertTrue(oldPart.contains("<font color=\"comment\">\"amount\":0</font>"));
assertTrue(newPart.contains("<font color=\"warning\">\"amount\":\"\"</font>")); assertTrue(newPart.contains("<font color=\"comment\">\"amount\":\"\"</font>"));
assertFalse(newPart.contains("<font color=\"info\">\"amount\"")); assertFalse(newPart.contains("<font color=\"info\">\"amount\""));
assertFalse(oldPart.contains("<font color=\"warning\">\"amount\""));
assertTrue(md.contains("> **类型变更**: amount <font color=\"warning\">BigDecimal → String</font>"),
"应包含类型变更摘要行,实际 markdown:\n" + md);
}
@Test
void wrapperAddedHighlightedBeforeNestedMovedFields() {
SchemaChange wrapper = new SchemaChange(ChangeType.WRAPPER_ADDED);
wrapper.setFieldPath("vo");
wrapper.setMessage("新增包装层 vo");
SchemaChange movedDb = new SchemaChange(ChangeType.FIELD_PATH_MOVED);
movedDb.setFieldPath("vo.dbName");
movedDb.setOldValue("dbName");
movedDb.setNewValue("vo.dbName");
SchemaChange movedLink = new SchemaChange(ChangeType.FIELD_PATH_MOVED);
movedLink.setFieldPath("vo.linkList[].id");
movedLink.setOldValue("linkList[].id");
movedLink.setNewValue("vo.linkList[].id");
List<SchemaChange> details = new ArrayList<>();
details.add(wrapper);
details.add(movedDb);
details.add(movedLink);
String oldJson = "{\"dbName\":\"\",\"linkList\":[{\"id\":\"\"}]}";
String newJson = "{\"vo\":{\"dbName\":\"\",\"linkList\":[{\"id\":\"\"}]}}";
Set<String> wrappers = SkeletonAnnotator.pathsForWrapperAdded(details);
Set<String> newGreen = SkeletonAnnotator.pathsForNewSkeleton(details);
assertEquals(Collections.singleton("vo"), wrappers);
assertTrue(newGreen.contains("vo.dbName"));
assertFalse(newGreen.contains("vo"), "包装层不应再混入普通新增路径");
// 先标包装层:整段 vo 对象应绿;子路径随后因已在 font 内可跳过
String newMd = SkeletonAnnotator.annotateNewForWecom(newJson, newGreen, null, wrappers);
assertTrue(newMd.contains("<font color=\"info\">\"vo\":{"), newMd);
assertTrue(newMd.contains("</font>"), newMd);
CheckReport report = baseReport();
KeyStructureChange key = new KeyStructureChange();
key.setKeyPattern("tenant:db:content:*");
key.setWriteLocation("TenantDbContentCacheHelper#cacheNotFound:62");
key.setValueType("TenantVO");
key.setOldSkeletonJson(oldJson);
key.setNewSkeletonJson(newJson);
key.getFieldDetails().addAll(details);
report.getKeyChanges().add(key);
String md = new ReportBuilder("[序列化结构变更]").toMarkdown(report);
int newSection = md.indexOf("> **变更为:**");
assertTrue(newSection >= 0);
String newPart = md.substring(newSection);
assertTrue(newPart.contains("<font color=\"info\">\"vo\":{"),
"包装层 vo 应整段绿色,实际:\n" + newPart);
}
@Test
void wrapperRemovedHighlightedBeforeNestedMovedFieldsOnOldSkeleton() {
SchemaChange wrapper = new SchemaChange(ChangeType.WRAPPER_REMOVED);
wrapper.setFieldPath("vo");
wrapper.setOldValue("vo");
wrapper.setMessage("删除包装层 vo");
SchemaChange movedDb = new SchemaChange(ChangeType.FIELD_PATH_MOVED);
movedDb.setFieldPath("dbName");
movedDb.setOldValue("vo.dbName");
movedDb.setNewValue("dbName");
SchemaChange movedLink = new SchemaChange(ChangeType.FIELD_PATH_MOVED);
movedLink.setFieldPath("linkList[].id");
movedLink.setOldValue("vo.linkList[].id");
movedLink.setNewValue("linkList[].id");
List<SchemaChange> details = new ArrayList<>();
details.add(wrapper);
details.add(movedDb);
details.add(movedLink);
String oldJson = "{\"vo\":{\"dbName\":\"\",\"linkList\":[{\"id\":\"\"}]}}";
String newJson = "{\"dbName\":\"\",\"linkList\":[{\"id\":\"\"}]}";
Set<String> wrappersRemoved = SkeletonAnnotator.pathsForWrapperRemoved(details);
Set<String> oldOrange = SkeletonAnnotator.pathsForOldSkeleton(details);
Set<String> newGreen = SkeletonAnnotator.pathsForNewSkeleton(details);
assertEquals(Collections.singleton("vo"), wrappersRemoved);
assertTrue(oldOrange.contains("vo.dbName"));
assertTrue(newGreen.contains("dbName"));
assertFalse(newGreen.contains("vo"));
String oldMd = SkeletonAnnotator.annotateOldForWecom(oldJson, oldOrange, null, wrappersRemoved);
assertTrue(oldMd.contains("<font color=\"warning\">\"vo\":{"), oldMd);
String newMd = SkeletonAnnotator.annotateNewForWecom(newJson, newGreen, null, null);
assertTrue(newMd.contains("<font color=\"info\">\"dbName\":\"\"</font>"), newMd);
assertFalse(newMd.contains("<font color=\"info\">\"vo\""), newMd);
CheckReport report = baseReport();
KeyStructureChange key = new KeyStructureChange();
key.setKeyPattern("tenant:db:content:");
key.setWriteLocation("TenantDbContentCacheHelper#cacheNotFound:62");
key.setValueType("TenantVO");
key.setOldSkeletonJson(oldJson);
key.setNewSkeletonJson(newJson);
key.getFieldDetails().addAll(details);
report.getKeyChanges().add(key);
String md = new ReportBuilder("[序列化结构变更]").toMarkdown(report);
int oldSection = md.indexOf("> **value值由**");
int newSection = md.indexOf("> **变更为:**");
assertTrue(oldSection >= 0 && newSection > oldSection);
assertTrue(md.substring(oldSection, newSection).contains("<font color=\"warning\">\"vo\":{"),
"旧骨架应整段橙标 vo实际:\n" + md);
} }
@Test @Test
@@ -189,8 +307,8 @@ class ReportBuilderTest {
assertTrue(console.contains("**删除字段**: x")); assertTrue(console.contains("**删除字段**: x"));
assertTrue(console.contains("<font color=\"warning\">\"x\":\"\"</font>")); assertTrue(console.contains("<font color=\"warning\">\"x\":\"\"</font>"));
assertFalse(console.contains("<font color=\"info\">\"x\":\"\"</font>")); assertFalse(console.contains("<font color=\"info\">\"x\":\"\"</font>"));
assertTrue(console.contains("> **位置**: `")); assertTrue(console.contains("> **位置**: "));
assertTrue(console.contains("> **类型**: `")); assertTrue(console.contains("> **类型**: "));
assertTrue(console.contains("> **value值由**")); assertTrue(console.contains("> **value值由**"));
assertTrue(console.contains("> **变更为:**")); assertTrue(console.contains("> **变更为:**"));
assertFalse(console.contains("`{\"x\":\"\"}`")); assertFalse(console.contains("`{\"x\":\"\"}`"));
@@ -214,8 +332,8 @@ class ReportBuilderTest {
String md = new ReportBuilder("[序列化结构变更]").toMarkdown(report); String md = new ReportBuilder("[序列化结构变更]").toMarkdown(report);
assertTrue(md.contains("- Key --> `req.getKey()` <font color=\"comment\">key 无法解析)</font>")); assertTrue(md.contains("- Key --> `req.getKey()` <font color=\"comment\">key 无法解析)</font>"));
assertTrue(md.contains("> **位置**: `ClockInXxxService#export:128`")); assertTrue(md.contains("> **位置**: ClockInXxxService#export:128"));
assertTrue(md.contains("> **类型**: `List<ClockInExportVo>`")); assertTrue(md.contains("> **类型**: List<ClockInExportVo>"));
assertFalse(md.contains("`unknown-key`")); assertFalse(md.contains("`unknown-key`"));
} }
@@ -267,6 +385,24 @@ class ReportBuilderTest {
assertTrue(console.contains("超长已按 key 拆为 2 条")); assertTrue(console.contains("超长已按 key 拆为 2 条"));
} }
@Test
void wildcardAsteriskInKeyEscapedForWeCom() {
assertEquals("data_analysis:combination_query_key::",
ReportBuilder.escapeWeComCode("data_analysis:combination_query_key:*:*"));
CheckReport report = baseReport();
KeyStructureChange key = simpleKey(
"data_analysis:combination_query_key:*:*",
"{\"a\":0}", "{\"a\":0,\"b\":0}", "b");
report.getKeyChanges().add(key);
String md = new ReportBuilder("[序列化结构变更]").toMarkdown(report);
assertTrue(md.contains("- Key --> `data_analysis:combination_query_key::`"),
"通配符 * 应转成全角*,实际:\n" + md);
assertFalse(md.contains("combination_query_key:*:*"),
"不应再输出未转义的 *:*, 实际:\n" + md);
}
private static CheckReport baseReport() { private static CheckReport baseReport() {
CheckReport report = new CheckReport(); CheckReport report = new CheckReport();
report.setRepository("jnpf-java-cloud"); report.setRepository("jnpf-java-cloud");