From 89a48a98aaff62d5a2617a68b04ef6f4219aa18a Mon Sep 17 00:00:00 2001 From: dongzi Date: Tue, 14 Jul 2026 18:05:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20w06=20=E7=B1=BB=E5=9E=8B=E6=8E=A8?= =?UTF-8?q?=E6=96=AD=E5=A2=9E=E5=BC=BA=E8=BE=85=E5=8A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/实施方案.md | 17 +- docs/配置说明.md | 21 ++ .../cache/analyze/SchemaCheckAnalyzer.java | 92 +++++++++ .../cache/config/CheckerConfig.java | 10 + .../cache/config/ConfigLoader.java | 1 + .../cache/detector/CacheReadHint.java | 92 +++++++++ .../cache/detector/CacheReadHintDetector.java | 189 ++++++++++++++++++ src/main/resources/default-config.yaml | 4 +- .../detector/CacheReadHintDetectorTest.java | 71 +++++++ 9 files changed, 494 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/codechecker/cache/detector/CacheReadHint.java create mode 100644 src/main/java/com/codechecker/cache/detector/CacheReadHintDetector.java create mode 100644 src/test/java/com/codechecker/cache/detector/CacheReadHintDetectorTest.java diff --git a/docs/实施方案.md b/docs/实施方案.md index d20a472..84f94c0 100644 --- a/docs/实施方案.md +++ b/docs/实施方案.md @@ -282,9 +282,8 @@ git diff --name-only {old-sha} {new-sha} -- '*.java' | W01 | `redisUtil.insert(key, JSON.toJSONString(expr), ttl)` | key 表达式、value 表达式 | ✅ | | W02 | `redisTemplate.opsForValue().set(key, JSON.toJSONString(expr), ...)` | 同上 | ✅ | | W03 | `stringRedisTemplate.opsForValue().set(key, JsonUtil.getObjectToString(expr), ...)` | 同上 | ✅ | -| W04 | `redisTemplate.opsForValue().set(key, expr, ...)` 且 expr 非字面量 | 直写对象类型 | ✅| +| W04 | `redisTemplate.opsForValue().set(key, expr, ...)` 且 expr 非字面量 | 直写对象类型 | ✅ | | W05 | `redisTemplate.opsForHash().put(key, field, expr)` | Hash 写出 value 类型 | ✅ | -| W06 | `JSON.parseObject(cacheValue, Xxx.class)` | 辅助反向确认读取类型 | 未做 | **忽略规则**(自动): @@ -292,6 +291,19 @@ git diff --name-only {old-sha} {new-sha} -- '*.java' - 方法名含 `setIfAbsent`、`increment`、`delete`、`remove`、`expire` 等 - key 匹配 `ignore.key_patterns` 配置(锁 / token / 登录计数等) +#### Step 4b:读侧类型辅助(W06,非写入模式) + +W06 **不产生独立告警**,只扫描反序列化调用,用读到的 `Xxx.class` **补强**同文件(或同 key)写入点的 value 类型 / 根数组标记。 + +| 匹配示例 | 作用 | +|----------|------| +| `JSON.parseObject(raw, Xxx.class)` | 补强对象类型 | +| `JSON.parseArray(raw, Xxx.class)` / `JsonUtil.getJsonToList` | 补强 `List`(rootArray) | +| `JsonUtil.getJsonToBean(raw, Xxx.class)` | 同上 | +| 可关联到 `redisUtil.getString(key)` / `opsForValue().get(key)` | 同时补强 key 模式 | + +开关:`detection.read_hints_enabled`(默认 `true`)。优先级:`manual_mappings` > W06 补强 > 写侧 AST 推断。 + #### Step 5:类型推断 对每个写入点的 `expr`,使用 JavaParser Symbol Solver 推断类型: @@ -512,6 +524,7 @@ notify: | 忽略规则完善 | 锁/计数器/token/字面量/setIfAbsent | ✅ | | 多模块性能 | 并行读文件、索引批量装载、`manual_mappings` | ✅ | | 企微高亮 | 删除橙 `warning` / 新增绿 `info`;位置+类型通用项 | ✅ | +| W06 读侧辅助 | `parseObject`/`parseArray` 等补强写入 value 类型 | ✅ | ### Phase 3 — 运营 diff --git a/docs/配置说明.md b/docs/配置说明.md index 919d316..62a93ff 100644 --- a/docs/配置说明.md +++ b/docs/配置说明.md @@ -43,6 +43,7 @@ include_modules: 由 `redisCheck` 仓库维护,随 jar 发布,默认包含: - `detection.patterns`:**W01~W05**(JSON 字符串写入 + Template 直写 + Hash) +- `detection.read_hints_enabled`:W06 读侧反序列化类型辅助(默认 true) - `ignore.key_patterns`(锁 / 计数器 / token) - `detection.min_confidence`、`max_field_depth` - `mode: notify`、`enabled: true` @@ -106,6 +107,9 @@ detection: - W04 # redisTemplate 直写对象 - W05 # opsForHash().put + # W06:读侧反序列化类型辅助(不产生独立告警) + read_hints_enabled: true + # 类型推断最低置信度,低于此值标记为低置信度提示 min_confidence: 0.6 @@ -211,6 +215,23 @@ detection: patterns: [W01, W02, W03] ``` +### 3.5.1 detection.read_hints_enabled(W06 辅助) + +| 字段 | 类型 | 默认 | 说明 | +|------|------|------|------| +| `read_hints_enabled` | boolean | true | 扫描 `parseObject` / `parseArray` / `getJsonToBean` 等,补强同文件或同 key 写入点的 value 类型 | + +- **不是写入模式**:不会单独因为「多了一处 parse」而告警 +- 能关联到 `redis get(key)` 时,还可补强 unresolved key +- 覆盖优先级:`manual_mappings` > W06 > 写侧 AST + +关闭示例: + +```yaml +detection: + read_hints_enabled: false +``` + ### 3.6 manual_mappings 当自动推断不准确时使用。匹配优先级 **高于** 自动推断(按 `类全名#方法名` 覆盖 key 模式与 value 类型)。 diff --git a/src/main/java/com/codechecker/cache/analyze/SchemaCheckAnalyzer.java b/src/main/java/com/codechecker/cache/analyze/SchemaCheckAnalyzer.java index 09c969b..8c6f04d 100644 --- a/src/main/java/com/codechecker/cache/analyze/SchemaCheckAnalyzer.java +++ b/src/main/java/com/codechecker/cache/analyze/SchemaCheckAnalyzer.java @@ -1,6 +1,8 @@ package com.codechecker.cache.analyze; import com.codechecker.cache.config.CheckerConfig; +import com.codechecker.cache.detector.CacheReadHint; +import com.codechecker.cache.detector.CacheReadHintDetector; import com.codechecker.cache.detector.RedisWritePointDetector; import com.codechecker.cache.detector.WritePoint; import com.codechecker.cache.diff.ChangeType; @@ -111,6 +113,10 @@ public class SchemaCheckAnalyzer { List newWps = detectorNew.detect(path, newContent); List oldWps = oldContent == null ? new ArrayList<>() : detectorOld.detect(path, oldContent); + applyReadHints(newWps, path, newContent, newIndex); + if (oldContent != null) { + applyReadHints(oldWps, path, oldContent, oldIndex); + } newWps.forEach(this::applyManualMappings); oldWps.forEach(this::applyManualMappings); @@ -425,6 +431,92 @@ public class SchemaCheckAnalyzer { } } + /** + * W06:用同文件读侧反序列化类型提示,补强低置信度 / 缺类型的写入点。 + * manual_mappings 仍在其后执行,可覆盖本补强结果。 + */ + private void applyReadHints(List writePoints, String path, String content, + SourceIndex index) { + if (!config.getDetection().isReadHintsEnabled() + || writePoints == null || writePoints.isEmpty()) { + return; + } + List hints = new CacheReadHintDetector(index).detect(path, content); + if (hints.isEmpty()) { + return; + } + for (WritePoint wp : writePoints) { + enrichWritePointFromHints(wp, hints); + } + } + + private void enrichWritePointFromHints(WritePoint wp, List hints) { + boolean needType = wp.getResolvedValueType() == null || wp.getResolvedValueType().isEmpty() + || wp.getConfidence() < config.getDetection().getMinConfidence(); + boolean needKey = isUnresolvedKey(wp.getResolvedKeyPattern()); + if (!needType && !needKey) { + return; + } + + CacheReadHint best = null; + int bestScore = -1; + for (CacheReadHint hint : hints) { + int score = scoreHint(wp, hint); + if (score > bestScore) { + bestScore = score; + best = hint; + } + } + if (best == null || bestScore <= 0) { + return; + } + if (needType && best.getResolvedValueType() != null) { + wp.setResolvedValueType(best.getResolvedValueType()); + wp.setRootArray(best.isRootArray()); + wp.setConfidence(Math.max(wp.getConfidence(), best.getConfidence())); + } + if (needKey && best.getResolvedKeyPattern() != null + && !isUnresolvedKey(best.getResolvedKeyPattern())) { + wp.setResolvedKeyPattern(best.getResolvedKeyPattern()); + if (best.getKeyExpression() != null) { + // 保留写入侧原始表达式,仅补 key 模式 + } + } + } + + /** 匹配得分:同 key > 同类同方法 > 同类;无交集则 0。 */ + private int scoreHint(WritePoint wp, CacheReadHint hint) { + if (hint.getResolvedValueType() == null) { + return 0; + } + boolean sameClass = wp.getEnclosingClass() != null + && wp.getEnclosingClass().equals(hint.getEnclosingClass()); + if (!sameClass) { + // 跨文件仅允许 key 模式已解析且一致 + if (wp.getResolvedKeyPattern() != null + && wp.getResolvedKeyPattern().equals(hint.getResolvedKeyPattern()) + && !isUnresolvedKey(wp.getResolvedKeyPattern())) { + return 40; + } + return 0; + } + int score = 10; + if (wp.getEnclosingMethod() != null + && wp.getEnclosingMethod().equals(hint.getEnclosingMethod())) { + score += 20; + } + if (wp.getResolvedKeyPattern() != null + && wp.getResolvedKeyPattern().equals(hint.getResolvedKeyPattern()) + && !isUnresolvedKey(wp.getResolvedKeyPattern())) { + score += 50; + } else if (hint.getResolvedKeyPattern() != null + && !isUnresolvedKey(hint.getResolvedKeyPattern()) + && isUnresolvedKey(wp.getResolvedKeyPattern())) { + score += 30; + } + return score; + } + private void collectTypeNames(String content, Set out) { if (content == null || content.isEmpty()) { return; diff --git a/src/main/java/com/codechecker/cache/config/CheckerConfig.java b/src/main/java/com/codechecker/cache/config/CheckerConfig.java index f0c32ac..3390560 100644 --- a/src/main/java/com/codechecker/cache/config/CheckerConfig.java +++ b/src/main/java/com/codechecker/cache/config/CheckerConfig.java @@ -110,6 +110,8 @@ public class CheckerConfig { private List patterns = new ArrayList<>(); private double minConfidence = 0.6; private int maxFieldDepth = 8; + /** W06:是否启用读侧反序列化类型辅助补强 */ + private boolean readHintsEnabled = true; public List getPatterns() { return patterns; @@ -134,6 +136,14 @@ public class CheckerConfig { public void setMaxFieldDepth(int maxFieldDepth) { this.maxFieldDepth = maxFieldDepth; } + + public boolean isReadHintsEnabled() { + return readHintsEnabled; + } + + public void setReadHintsEnabled(boolean readHintsEnabled) { + this.readHintsEnabled = readHintsEnabled; + } } public static class ManualMapping { diff --git a/src/main/java/com/codechecker/cache/config/ConfigLoader.java b/src/main/java/com/codechecker/cache/config/ConfigLoader.java index b275b20..8029ce3 100644 --- a/src/main/java/com/codechecker/cache/config/ConfigLoader.java +++ b/src/main/java/com/codechecker/cache/config/ConfigLoader.java @@ -100,6 +100,7 @@ public final class ConfigLoader { d.setPatterns(strList(detection.get("patterns"))); d.setMinConfidence(dbl(detection, "min_confidence", 0.6)); d.setMaxFieldDepth((int) lng(detection, "max_field_depth", 8)); + d.setReadHintsEnabled(bool(detection, "read_hints_enabled", true)); Map severityOverrides = asMap(map.get("severity_overrides")); Map so = new LinkedHashMap<>(); diff --git a/src/main/java/com/codechecker/cache/detector/CacheReadHint.java b/src/main/java/com/codechecker/cache/detector/CacheReadHint.java new file mode 100644 index 0000000..3cead8c --- /dev/null +++ b/src/main/java/com/codechecker/cache/detector/CacheReadHint.java @@ -0,0 +1,92 @@ +package com.codechecker.cache.detector; + +/** + * 读侧反序列化类型提示(W06):从 parseObject / getJsonToBean 等推断「缓存 value 被当成什么类型用」。 + * 不单独产生告警,仅用于补强同文件/同 key 写入点的 value 类型。 + */ +public class CacheReadHint { + + private String filePath; + private int lineNumber; + private String enclosingClass; + private String enclosingMethod; + /** 推断出的 key 模式;无法关联 redis get 时为 null */ + private String resolvedKeyPattern; + private String keyExpression; + /** value 元素/对象 FQN */ + private String resolvedValueType; + private boolean rootArray; + private double confidence = 0.7; + + public String getFilePath() { + return filePath; + } + + public void setFilePath(String filePath) { + this.filePath = filePath; + } + + public int getLineNumber() { + return lineNumber; + } + + public void setLineNumber(int lineNumber) { + this.lineNumber = lineNumber; + } + + public String getEnclosingClass() { + return enclosingClass; + } + + public void setEnclosingClass(String enclosingClass) { + this.enclosingClass = enclosingClass; + } + + public String getEnclosingMethod() { + return enclosingMethod; + } + + public void setEnclosingMethod(String enclosingMethod) { + this.enclosingMethod = enclosingMethod; + } + + public String getResolvedKeyPattern() { + return resolvedKeyPattern; + } + + public void setResolvedKeyPattern(String resolvedKeyPattern) { + this.resolvedKeyPattern = resolvedKeyPattern; + } + + public String getKeyExpression() { + return keyExpression; + } + + public void setKeyExpression(String keyExpression) { + this.keyExpression = keyExpression; + } + + public String getResolvedValueType() { + return resolvedValueType; + } + + public void setResolvedValueType(String resolvedValueType) { + this.resolvedValueType = resolvedValueType; + } + + public boolean isRootArray() { + return rootArray; + } + + public void setRootArray(boolean rootArray) { + this.rootArray = rootArray; + } + + public double getConfidence() { + return confidence; + } + + public void setConfidence(double confidence) { + this.confidence = confidence; + } +} diff --git a/src/main/java/com/codechecker/cache/detector/CacheReadHintDetector.java b/src/main/java/com/codechecker/cache/detector/CacheReadHintDetector.java new file mode 100644 index 0000000..ec2b009 --- /dev/null +++ b/src/main/java/com/codechecker/cache/detector/CacheReadHintDetector.java @@ -0,0 +1,189 @@ +package com.codechecker.cache.detector; + +import com.codechecker.cache.key.RedisKeyResolver; +import com.codechecker.cache.schema.SourceIndex; +import com.github.javaparser.StaticJavaParser; +import com.github.javaparser.ast.CompilationUnit; +import com.github.javaparser.ast.body.CallableDeclaration; +import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration; +import com.github.javaparser.ast.body.VariableDeclarator; +import com.github.javaparser.ast.expr.ClassExpr; +import com.github.javaparser.ast.expr.Expression; +import com.github.javaparser.ast.expr.MethodCallExpr; +import com.github.javaparser.ast.expr.NameExpr; +import com.github.javaparser.ast.type.ClassOrInterfaceType; +import com.github.javaparser.ast.type.Type; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.Set; + +/** + * W06:扫描读侧反序列化,提取「缓存字符串 → 业务类型」提示,供写入点类型补强。 + *

典型模式:

+ *
+ * String raw = redisUtil.getString(key);
+ * Foo vo = JSON.parseObject(raw, Foo.class);
+ * List<Foo> list = JSON.parseArray(raw, Foo.class);
+ * 
+ */ +public class CacheReadHintDetector { + + private static final Set OBJECT_PARSE = new HashSet<>(Arrays.asList( + "parseObject", "parse", "getJsonToBean", "toJavaObject", "readValue")); + private static final Set ARRAY_PARSE = new HashSet<>(Arrays.asList( + "parseArray", "getJsonToList", "parseArrayObject")); + private static final Set REDIS_GET = new HashSet<>(Arrays.asList( + "get", "getString", "opsForValue")); + + private final SourceIndex index; + private final RedisKeyResolver keyResolver; + + public CacheReadHintDetector(SourceIndex index) { + this.index = index; + this.keyResolver = new RedisKeyResolver(index); + } + + public List detect(String filePath, String content) { + List result = new ArrayList<>(); + if (content == null || content.isEmpty()) { + return result; + } + CompilationUnit cu; + try { + cu = StaticJavaParser.parse(content); + } catch (RuntimeException e) { + return result; + } + + for (MethodCallExpr mce : cu.findAll(MethodCallExpr.class)) { + CacheReadHint hint = tryParseHint(mce, filePath); + if (hint != null) { + result.add(hint); + } + } + return result; + } + + private CacheReadHint tryParseHint(MethodCallExpr mce, String filePath) { + String name = mce.getNameAsString(); + boolean array = ARRAY_PARSE.contains(name); + boolean object = OBJECT_PARSE.contains(name); + if (!array && !object) { + return null; + } + if (mce.getArguments().size() < 2) { + return null; + } + + Expression classArg = mce.getArgument(1); + // readValue(str, TypeReference) 等暂不支持;要求 ClassLiteral Xxx.class + if (!(classArg instanceof ClassExpr)) { + // 部分 API:parseObject(str, Xxx.class, Feature...) 仍是第 2 参 + return null; + } + Type type = ((ClassExpr) classArg).getType(); + if (!(type instanceof ClassOrInterfaceType)) { + return null; + } + + ClassOrInterfaceDeclaration enclosing = mce + .findAncestor(ClassOrInterfaceDeclaration.class).orElse(null); + String enclosingFqn = enclosing == null + ? "" + : enclosing.getFullyQualifiedName().orElse(enclosing.getNameAsString()); + SourceIndex.IndexedType context = index.get(enclosingFqn); + + String fqn = index.resolveFqn(((ClassOrInterfaceType) type).getNameWithScope(), context); + if (fqn == null) { + fqn = index.resolveFqn(((ClassOrInterfaceType) type).getNameAsString(), context); + } + if (fqn == null) { + return null; + } + + CacheReadHint hint = new CacheReadHint(); + hint.setFilePath(filePath); + hint.setLineNumber(mce.getBegin().map(p -> p.line).orElse(0)); + hint.setEnclosingClass(enclosingFqn); + hint.setEnclosingMethod(mce.findAncestor(CallableDeclaration.class) + .map(CallableDeclaration::getNameAsString).orElse("")); + hint.setResolvedValueType(fqn); + hint.setRootArray(array || "parseArray".equals(name) || "getJsonToList".equals(name)); + hint.setConfidence(0.7); + + Expression rawExpr = mce.getArgument(0); + Optional getRef = findRedisGetForVar(rawExpr, mce); + if (getRef.isPresent()) { + hint.setKeyExpression(getRef.get().keyExpr.toString()); + hint.setResolvedKeyPattern(keyResolver.resolve( + getRef.get().keyExpr, enclosing, context)); + hint.setConfidence(0.85); + } + return hint; + } + + /** + * 若 parse 的第 1 参是局部变量,追溯其是否来自 redis get(key)。 + */ + private Optional findRedisGetForVar(Expression rawExpr, MethodCallExpr parseCall) { + if (!(rawExpr instanceof NameExpr)) { + // 直接 parseObject(redis.get(key), Xxx.class) + if (rawExpr instanceof MethodCallExpr) { + return extractGetKey((MethodCallExpr) rawExpr); + } + return Optional.empty(); + } + String varName = ((NameExpr) rawExpr).getNameAsString(); + Optional callable = parseCall.findAncestor(CallableDeclaration.class); + if (!callable.isPresent()) { + return Optional.empty(); + } + for (VariableDeclarator var : callable.get().findAll(VariableDeclarator.class)) { + if (!var.getNameAsString().equals(varName) || !var.getInitializer().isPresent()) { + continue; + } + Expression init = var.getInitializer().get(); + if (init instanceof MethodCallExpr) { + return extractGetKey((MethodCallExpr) init); + } + } + return Optional.empty(); + } + + private Optional 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"); + if ("get".equals(name) || "getString".equals(name)) { + if (!redisScope && !REDIS_GET.contains(name)) { + // getString 也常见于 RedisUtil + if (!"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(); + } + + private static final class RedisGetRef { + final Expression keyExpr; + + RedisGetRef(Expression keyExpr) { + this.keyExpr = keyExpr; + } + } +} diff --git a/src/main/resources/default-config.yaml b/src/main/resources/default-config.yaml index 09ff526..9e156e7 100644 --- a/src/main/resources/default-config.yaml +++ b/src/main/resources/default-config.yaml @@ -44,7 +44,9 @@ detection: - W03 # stringRedisTemplate.opsForValue().set(key, JsonUtil.getObjectToString(x), ...) - W04 # redisTemplate.opsForValue().set(key, obj, ...) - W05 # redisTemplate.opsForHash().put(key, field, obj) - # 类型推断最低置信度,低于此值降级为 P2 提示 + # W06 读侧辅助:用 parseObject / getJsonToBean 等补强写入点 value 类型(非写入模式) + read_hints_enabled: true + # 类型推断最低置信度,低于此值标记为低置信度提示 min_confidence: 0.6 # 字段展开最大深度(防止循环引用) max_field_depth: 8 diff --git a/src/test/java/com/codechecker/cache/detector/CacheReadHintDetectorTest.java b/src/test/java/com/codechecker/cache/detector/CacheReadHintDetectorTest.java new file mode 100644 index 0000000..8c8f811 --- /dev/null +++ b/src/test/java/com/codechecker/cache/detector/CacheReadHintDetectorTest.java @@ -0,0 +1,71 @@ +package com.codechecker.cache.detector; + +import com.codechecker.cache.schema.SourceIndex; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CacheReadHintDetectorTest { + + @Test + void detectsParseObjectWithRedisGetPairing() { + String vo = "" + + "package demo.model;\n" + + "public class FooVo { private String name; }\n"; + String source = "" + + "package demo;\n" + + "import demo.model.FooVo;\n" + + "public class CacheService {\n" + + " private RedisUtil redisUtil;\n" + + " private static final String KEY = \"demo:foo:\";\n" + + " public FooVo load(String id) {\n" + + " String raw = redisUtil.getString(KEY + id);\n" + + " return JSON.parseObject(raw, FooVo.class);\n" + + " }\n" + + " public void save(String id, FooVo vo) {\n" + + " redisUtil.insert(KEY + id, JSON.toJSONString(vo), 60);\n" + + " }\n" + + "}\n"; + + SourceIndex index = new SourceIndex(); + index.addSource(vo); + index.addSource(source); + + List hints = new CacheReadHintDetector(index) + .detect("CacheService.java", source); + assertEquals(1, hints.size()); + CacheReadHint hint = hints.get(0); + assertEquals("demo.model.FooVo", hint.getResolvedValueType()); + assertFalse(hint.isRootArray()); + assertEquals("demo:foo:*", hint.getResolvedKeyPattern()); + } + + @Test + void detectsParseArray() { + String vo = "" + + "package demo.model;\n" + + "public class ItemVo { private int rank; }\n"; + String source = "" + + "package demo;\n" + + "import demo.model.ItemVo;\n" + + "public class ListCache {\n" + + " public List load(String raw) {\n" + + " return JSON.parseArray(raw, ItemVo.class);\n" + + " }\n" + + "}\n"; + + SourceIndex index = new SourceIndex(); + index.addSource(vo); + index.addSource(source); + + List hints = new CacheReadHintDetector(index) + .detect("ListCache.java", source); + assertEquals(1, hints.size()); + assertTrue(hints.get(0).isRootArray()); + assertEquals("demo.model.ItemVo", hints.get(0).getResolvedValueType()); + } +}