From 14f9f1fce78aeb0c1bc81137cdd697f01baba6af Mon Sep 17 00:00:00 2001 From: dongzi Date: Wed, 15 Jul 2026 15:45:35 +0800 Subject: [PATCH] =?UTF-8?q?feat:=201=E3=80=81Vo=E5=8C=85=E8=A3=85=E8=AF=86?= =?UTF-8?q?=E5=88=AB=E5=A2=9E=E5=BC=BA=202=E3=80=81=E5=8E=BB=E9=99=A4=20?= =?UTF-8?q?=E4=BD=8D=E7=BD=AE&=E7=B1=BB=E5=9E=8B=20=E7=9A=84=E8=89=B2?= =?UTF-8?q?=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cache/analyze/SchemaCheckAnalyzer.java | 6 +- .../codechecker/cache/diff/ChangeType.java | 1 + .../codechecker/cache/diff/SchemaDiffer.java | 36 ++++- .../cache/key/RedisKeyResolver.java | 81 +++++++++- .../cache/report/ReportBuilder.java | 36 +++-- .../cache/report/SkeletonAnnotator.java | 68 +++++++-- .../cache/diff/SchemaDifferTest.java | 35 +++++ .../cache/key/RedisKeyResolverTest.java | 87 +++++++++++ .../cache/report/ReportBuilderTest.java | 143 +++++++++++++++++- 9 files changed, 456 insertions(+), 37 deletions(-) diff --git a/src/main/java/com/codechecker/cache/analyze/SchemaCheckAnalyzer.java b/src/main/java/com/codechecker/cache/analyze/SchemaCheckAnalyzer.java index 8c6f04d..a679f8e 100644 --- a/src/main/java/com/codechecker/cache/analyze/SchemaCheckAnalyzer.java +++ b/src/main/java/com/codechecker/cache/analyze/SchemaCheckAnalyzer.java @@ -274,8 +274,10 @@ public class SchemaCheckAnalyzer { paths.add(c.getOldValue()); } if (c.getChangeType() == ChangeType.WRAPPER_ADDED - && c.getFieldPath() != null) { - paths.add(c.getFieldPath()); + || c.getChangeType() == ChangeType.WRAPPER_REMOVED) { + if (c.getFieldPath() != null) { + paths.add(c.getFieldPath()); + } } } return paths; diff --git a/src/main/java/com/codechecker/cache/diff/ChangeType.java b/src/main/java/com/codechecker/cache/diff/ChangeType.java index e8c6392..116a890 100644 --- a/src/main/java/com/codechecker/cache/diff/ChangeType.java +++ b/src/main/java/com/codechecker/cache/diff/ChangeType.java @@ -7,6 +7,7 @@ public enum ChangeType { FIELD_REMOVED(Severity.P0, "字段删除"), TYPE_CHANGED(Severity.P0, "字段类型变更"), WRAPPER_ADDED(Severity.P0, "新增包装层"), + WRAPPER_REMOVED(Severity.P0, "删除包装层"), FIELD_PATH_MOVED(Severity.P0, "字段路径迁移"), FIELD_ADDED(Severity.P1, "新增字段"), KEY_PATTERN_CHANGED(Severity.P1, "Key 模式变更"), diff --git a/src/main/java/com/codechecker/cache/diff/SchemaDiffer.java b/src/main/java/com/codechecker/cache/diff/SchemaDiffer.java index ebe1c0e..1b59f8e 100644 --- a/src/main/java/com/codechecker/cache/diff/SchemaDiffer.java +++ b/src/main/java/com/codechecker/cache/diff/SchemaDiffer.java @@ -58,16 +58,21 @@ public class SchemaDiffer { added.removeAll(oldLeaves.keySet()); added.removeAll(typeChangedPaths); - // 路径迁移检测(removed 的路径是某 added 路径的后缀) + // 路径迁移: + // - 下沉(加包装):旧路径是新路径后缀,如 dbName → vo.dbName + // - 上提(拆包装):新路径是旧路径后缀,如 vo.dbName → dbName List moves = new ArrayList<>(); Set matchedRemoved = new LinkedHashSet<>(); Set matchedAdded = new LinkedHashSet<>(); for (String r : removed) { + if (matchedRemoved.contains(r)) { + continue; + } for (String a : added) { if (matchedAdded.contains(a)) { continue; } - if (isSuffix(a, r)) { + if (isSuffix(a, r) || isSuffix(r, a)) { moves.add(new String[]{r, a}); matchedRemoved.add(r); matchedAdded.add(a); @@ -76,16 +81,16 @@ public class SchemaDiffer { } } - // 包装层检测:多个迁移共享同一新前缀 - Map prefixCount = new LinkedHashMap<>(); + // 新增包装层:多个迁移共享同一新前缀 + Map newPrefixCount = 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); + newPrefixCount.merge(prefix, 1, Integer::sum); } } - for (Map.Entry e : prefixCount.entrySet()) { + for (Map.Entry e : newPrefixCount.entrySet()) { if (e.getValue() >= 2) { SchemaChange c = new SchemaChange(ChangeType.WRAPPER_ADDED); c.setFieldPath(e.getKey()); @@ -95,6 +100,25 @@ public class SchemaDiffer { } } + // 删除包装层:多个迁移共享同一旧前缀 + Map 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 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) { SchemaChange c = new SchemaChange(ChangeType.FIELD_PATH_MOVED); c.setFieldPath(move[1]); diff --git a/src/main/java/com/codechecker/cache/key/RedisKeyResolver.java b/src/main/java/com/codechecker/cache/key/RedisKeyResolver.java index baf8d05..8052dcc 100644 --- a/src/main/java/com/codechecker/cache/key/RedisKeyResolver.java +++ b/src/main/java/com/codechecker/cache/key/RedisKeyResolver.java @@ -1,10 +1,12 @@ package com.codechecker.cache.key; import com.codechecker.cache.schema.SourceIndex; +import com.github.javaparser.Position; 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.AssignExpr; import com.github.javaparser.ast.expr.BinaryExpr; import com.github.javaparser.ast.expr.Expression; import com.github.javaparser.ast.expr.FieldAccessExpr; @@ -17,6 +19,8 @@ import java.util.Optional; /** * 尽力将 Redis key 表达式静态推断为一个「key 模式」,动态部分以 {@code *} 表示。 + *

+ * 对局部变量名:先查同名静态常量 / 同名方法 return,再回溯方法内使用点之前最近一次声明或赋值。 */ public class RedisKeyResolver { @@ -52,12 +56,17 @@ public class RedisKeyResolver { } if (expr instanceof NameExpr) { String name = ((NameExpr) expr).getNameAsString(); + // 1) 静态常量 2) 同名方法 return 3) 局部变量定值(不改变既有优先级) String constVal = lookupConstant(enclosingClass, name, context, depth); if (constVal != null) { return constVal; } 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) { FieldAccessExpr fae = (FieldAccessExpr) expr; @@ -94,6 +103,76 @@ public class RedisKeyResolver { 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, SourceIndex.IndexedType context, int depth) { if (clazz == null) { diff --git a/src/main/java/com/codechecker/cache/report/ReportBuilder.java b/src/main/java/com/codechecker/cache/report/ReportBuilder.java index c8e1610..4785fc2 100644 --- a/src/main/java/com/codechecker/cache/report/ReportBuilder.java +++ b/src/main/java/com/codechecker/cache/report/ReportBuilder.java @@ -129,12 +129,16 @@ public class ReportBuilder { String oldJson = nvl(kc.getOldSkeletonJson()); String newJson = nvl(kc.getNewSkeletonJson()); Set oldHighlight = SkeletonAnnotator.pathsForOldSkeleton(kc.getFieldDetails()); + Set wrappersRemoved = SkeletonAnnotator.pathsForWrapperRemoved(kc.getFieldDetails()); Set newGreen = SkeletonAnnotator.pathsForNewSkeleton(kc.getFieldDetails()); + Set wrappersAdded = SkeletonAnnotator.pathsForWrapperAdded(kc.getFieldDetails()); Set typeGray = SkeletonAnnotator.pathsForTypeChanged(kc.getFieldDetails()); String oldRendered = oldJson.isEmpty() - ? "" : "“" + SkeletonAnnotator.annotateOldForWecom(oldJson, oldHighlight, typeGray) + "”"; + ? "" : "“" + SkeletonAnnotator.annotateOldForWecom( + oldJson, oldHighlight, typeGray, wrappersRemoved) + "”"; String newRendered = newJson.isEmpty() - ? "" : "“" + SkeletonAnnotator.annotateNewForWecom(newJson, newGreen, typeGray) + "”"; + ? "" : "“" + SkeletonAnnotator.annotateNewForWecom( + newJson, newGreen, typeGray, wrappersAdded) + "”"; if (oldJson.isEmpty() && !newJson.isEmpty()) { sb.append(" > **value 新增为:** ").append(newRendered).append('\n'); } else if (!oldJson.isEmpty() && newJson.isEmpty()) { @@ -210,7 +214,7 @@ public class ReportBuilder { /** * Key 行:展示解析后的模式或未解析时的源码表达式;未解析时追加灰色提示。 - * 反引号仅包裹 key 文本,避免与加粗/颜色嵌套冲突。 + * 反引号内仍须转义 {@code *},否则企微会把 {@code *:*} 当成斜体吃掉通配符。 */ private void appendKeyLine(StringBuilder sb, String displayKey, String keyExpression, boolean unresolved) { @@ -220,18 +224,28 @@ public class ReportBuilder { if (keyText.isEmpty()) { keyText = "unknown-key"; } - sb.append("- Key --> `").append(keyText).append('`'); + sb.append("- Key --> `").append(escapeWeComCode(keyText)).append('`'); if (unresolved) { sb.append(" (key 无法解析)"); -// sb.append(" (key 无法解析)"); } 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) { - sb.append(" > **位置**: `").append(nvl(writeLocation)).append("`\n"); - sb.append(" > **类型**: `").append(nvl(valueType)).append("`\n"); + sb.append(" > **位置**: ").append(nvl(writeLocation)).append('\n'); + sb.append(" > **类型**: ").append(nvl(valueType)).append('\n'); } private boolean isUnresolvedKey(String keyPattern) { @@ -267,7 +281,7 @@ public class ReportBuilder { for (SchemaChange c : list) { sb.append("- **").append(c.getChangeType().getLabel()).append("**"); if (c.getKeyPattern() != null) { - sb.append(" `").append(c.getKeyPattern()).append('`'); + sb.append(" `").append(escapeWeComCode(c.getKeyPattern())).append('`'); } sb.append('\n'); if (c.getWriteLocation() != null) { @@ -315,8 +329,8 @@ public class ReportBuilder { + trimmed.substring(colonEn + 1).trim(); } String[] knownPrefixes = { - "删除字段 ", "新增字段 ", "字段路径迁移 ", "新增包装层 ", "新增缓存写入点,", - "删除缓存写入点,原 value 类型: " + "删除字段 ", "新增字段 ", "字段路径迁移 ", "新增包装层 ", "删除包装层 ", + "新增缓存写入点,", "删除缓存写入点,原 value 类型: " }; for (String prefix : knownPrefixes) { if (trimmed.startsWith(prefix)) { diff --git a/src/main/java/com/codechecker/cache/report/SkeletonAnnotator.java b/src/main/java/com/codechecker/cache/report/SkeletonAnnotator.java index f2003ca..bae1ba0 100644 --- a/src/main/java/com/codechecker/cache/report/SkeletonAnnotator.java +++ b/src/main/java/com/codechecker/cache/report/SkeletonAnnotator.java @@ -12,15 +12,16 @@ import java.util.Set; /** * 在骨架 JSON 中为改动字段加企微颜色标注(仅改动片段染色,其余明文)。 *

    - *
  • 删除字段 → 旧骨架,橙色 {@code warning}
  • - *
  • 新增字段 / 新增包装层 → 新骨架,绿色 {@code info}
  • - *
  • 路径迁移 → 旧路径橙、新路径绿
  • + *
  • 删除包装层 → 旧骨架优先整段标橙(先于子路径)
  • + *
  • 删除字段 / 路径迁移旧侧 → 旧骨架,橙色 {@code warning}
  • + *
  • 新增包装层 → 新骨架优先整体标绿(先于子路径)
  • + *
  • 新增字段 / 路径迁移新侧 → 新骨架,绿色 {@code info}
  • *
  • 类型变更(修改类)→ 新旧骨架均为灰色 {@code comment}
  • *
*/ final class SkeletonAnnotator { - /** 企微橙:删除 / 路径迁移旧侧 */ + /** 企微橙:删除 / 路径迁移旧侧 / 删除包装层 */ static final String COLOR_REMOVE = "warning"; /** 企微绿:新增 / 新侧路径迁移 */ static final String COLOR_ADD = "info"; @@ -32,7 +33,7 @@ final class SkeletonAnnotator { private SkeletonAnnotator() { } - /** 旧骨架应橙色标注的路径:删除、路径迁移旧侧(不含类型变更)。 */ + /** 旧骨架应橙色标注的路径:删除、路径迁移旧侧(不含类型变更与包装层删除)。 */ static Set pathsForOldSkeleton(List details) { Set paths = new LinkedHashSet<>(); if (details == null) { @@ -51,7 +52,35 @@ final class SkeletonAnnotator { return paths; } - /** 新骨架应绿色标注的路径:新增、包装层、路径迁移新侧(不含类型变更)。 */ + /** 删除包装层路径(旧骨架优先整段标橙)。 */ + static Set pathsForWrapperRemoved(List details) { + Set 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 pathsForWrapperAdded(List details) { + Set 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 pathsForNewSkeleton(List details) { Set paths = new LinkedHashSet<>(); if (details == null) { @@ -61,8 +90,7 @@ final class SkeletonAnnotator { if (c == null || c.getChangeType() == null) { continue; } - if (c.getChangeType() == ChangeType.FIELD_ADDED - || c.getChangeType() == ChangeType.WRAPPER_ADDED) { + if (c.getChangeType() == ChangeType.FIELD_ADDED) { addIfPresent(paths, c.getFieldPath()); } else if (c.getChangeType() == ChangeType.FIELD_PATH_MOVED) { addIfPresent(paths, c.getFieldPath()); @@ -92,11 +120,20 @@ final class SkeletonAnnotator { * 标注旧骨架:删除等橙色;类型变更灰色。 */ static String annotateOldForWecom(String json, Set orangePaths) { - return annotateOldForWecom(json, orangePaths, null); + return annotateOldForWecom(json, orangePaths, null, null); } static String annotateOldForWecom(String json, Set orangePaths, Set grayPaths) { + return annotateOldForWecom(json, orangePaths, grayPaths, null); + } + + /** + * @param wrapperRemovedPaths 删除的包装层,优先于普通橙路径标注 + */ + static String annotateOldForWecom(String json, Set orangePaths, + Set grayPaths, Set wrapperRemovedPaths) { String result = annotateForWecom(json, grayPaths, COLOR_TYPE); + result = annotateForWecom(result, wrapperRemovedPaths, COLOR_REMOVE); return annotateForWecom(result, orangePaths, COLOR_REMOVE); } @@ -104,14 +141,23 @@ final class SkeletonAnnotator { * 标注新骨架:类型变更灰色,新增绿色。 */ static String annotateNewForWecom(String json, Set greenPaths) { - return annotateNewForWecom(json, greenPaths, null); + return annotateNewForWecom(json, greenPaths, null, null); } /** - * 标注新骨架改动字段(类型变更 → 灰色;新增 / 迁移新侧 → 绿色)。 + * 标注新骨架(类型变更 → 灰色;包装层先绿;其余新增/迁移 → 绿)。 */ static String annotateNewForWecom(String json, Set greenPaths, Set grayTypePaths) { + return annotateNewForWecom(json, greenPaths, grayTypePaths, null); + } + + /** + * @param wrapperPaths 包装层路径,优先于 {@code greenPaths} 标注,使 {@code "vo":{...}} 能整段染色 + */ + static String annotateNewForWecom(String json, Set greenPaths, + Set grayTypePaths, Set wrapperPaths) { String result = annotateForWecom(json, grayTypePaths, COLOR_TYPE); + result = annotateForWecom(result, wrapperPaths, COLOR_ADD); return annotateForWecom(result, greenPaths, COLOR_ADD); } diff --git a/src/test/java/com/codechecker/cache/diff/SchemaDifferTest.java b/src/test/java/com/codechecker/cache/diff/SchemaDifferTest.java index 74c7e81..f395a9d 100644 --- a/src/test/java/com/codechecker/cache/diff/SchemaDifferTest.java +++ b/src/test/java/com/codechecker/cache/diff/SchemaDifferTest.java @@ -40,6 +40,41 @@ class SchemaDifferTest { 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 changes = new SchemaDiffer().diff(oldS, newS); + List 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 void detectsTypeChange() { TypeSchema oldS = new TypeSchema("A"); diff --git a/src/test/java/com/codechecker/cache/key/RedisKeyResolverTest.java b/src/test/java/com/codechecker/cache/key/RedisKeyResolverTest.java index b308907..017a0db 100644 --- a/src/test/java/com/codechecker/cache/key/RedisKeyResolverTest.java +++ b/src/test/java/com/codechecker/cache/key/RedisKeyResolverTest.java @@ -65,4 +65,91 @@ class RedisKeyResolverTest { buildCall, clazz, index.get("jnpf.util.TenantDbContentCacheHelper")); 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); + } } diff --git a/src/test/java/com/codechecker/cache/report/ReportBuilderTest.java b/src/test/java/com/codechecker/cache/report/ReportBuilderTest.java index 107c4ca..5d5c2d0 100644 --- a/src/test/java/com/codechecker/cache/report/ReportBuilderTest.java +++ b/src/test/java/com/codechecker/cache/report/ReportBuilderTest.java @@ -5,6 +5,7 @@ import com.codechecker.cache.diff.SchemaChange; import com.codechecker.cache.diff.Severity; import org.junit.jupiter.api.Test; +import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashSet; import java.util.List; @@ -60,8 +61,8 @@ class ReportBuilderTest { assertTrue(md.contains("- Key --> `saas:period-config:migration:current`")); assertFalse(md.contains("(key 无法解析)")); - assertTrue(md.contains("> **位置**: `SaasPeriodConfigMigrationRedisSupport#putCurrent:41`")); - assertTrue(md.contains("> **类型**: `MigrationCurrentVo`")); + assertTrue(md.contains("> **位置**: SaasPeriodConfigMigrationRedisSupport#putCurrent:41")); + assertTrue(md.contains("> **类型**: MigrationCurrentVo")); int oldSection = md.indexOf("> **value值由:**"); int newSection = md.indexOf("> **变更为:**"); @@ -167,6 +168,118 @@ class ReportBuilderTest { "应包含类型变更摘要行,实际 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 details = new ArrayList<>(); + details.add(wrapper); + details.add(movedDb); + details.add(movedLink); + + String oldJson = "{\"dbName\":\"\",\"linkList\":[{\"id\":\"\"}]}"; + String newJson = "{\"vo\":{\"dbName\":\"\",\"linkList\":[{\"id\":\"\"}]}}"; + + Set wrappers = SkeletonAnnotator.pathsForWrapperAdded(details); + Set 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("\"vo\":{"), newMd); + assertTrue(newMd.contains(""), 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("\"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 details = new ArrayList<>(); + details.add(wrapper); + details.add(movedDb); + details.add(movedLink); + + String oldJson = "{\"vo\":{\"dbName\":\"\",\"linkList\":[{\"id\":\"\"}]}}"; + String newJson = "{\"dbName\":\"\",\"linkList\":[{\"id\":\"\"}]}"; + + Set wrappersRemoved = SkeletonAnnotator.pathsForWrapperRemoved(details); + Set oldOrange = SkeletonAnnotator.pathsForOldSkeleton(details); + Set 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("\"vo\":{"), oldMd); + + String newMd = SkeletonAnnotator.annotateNewForWecom(newJson, newGreen, null, null); + assertTrue(newMd.contains("\"dbName\":\"\""), newMd); + assertFalse(newMd.contains("\"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("\"vo\":{"), + "旧骨架应整段橙标 vo,实际:\n" + md); + } + @Test void consoleContainsFieldDetailsAndWecomMarkdown() { CheckReport report = new CheckReport(); @@ -194,8 +307,8 @@ class ReportBuilderTest { assertTrue(console.contains("**删除字段**: x")); assertTrue(console.contains("\"x\":\"\"")); assertFalse(console.contains("\"x\":\"\"")); - assertTrue(console.contains("> **位置**: `")); - assertTrue(console.contains("> **类型**: `")); + assertTrue(console.contains("> **位置**: ")); + assertTrue(console.contains("> **类型**: ")); assertTrue(console.contains("> **value值由:**")); assertTrue(console.contains("> **变更为:**")); assertFalse(console.contains("`{\"x\":\"\"}`")); @@ -219,8 +332,8 @@ class ReportBuilderTest { String md = new ReportBuilder("[序列化结构变更]").toMarkdown(report); assertTrue(md.contains("- Key --> `req.getKey()` (key 无法解析)")); - assertTrue(md.contains("> **位置**: `ClockInXxxService#export:128`")); - assertTrue(md.contains("> **类型**: `List`")); + assertTrue(md.contains("> **位置**: ClockInXxxService#export:128")); + assertTrue(md.contains("> **类型**: List")); assertFalse(md.contains("`unknown-key`")); } @@ -272,6 +385,24 @@ class ReportBuilderTest { 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() { CheckReport report = new CheckReport(); report.setRepository("jnpf-java-cloud");