feat: w06 类型推断增强辅助
All checks were successful
缓存序列化结构检查 / cache-schema-check (push) Has been skipped

This commit is contained in:
2026-07-14 18:05:08 +08:00
parent c780b269d6
commit 89a48a98aa
9 changed files with 494 additions and 3 deletions

View File

@@ -282,9 +282,8 @@ git diff --name-only {old-sha} {new-sha} -- '*.java'
| W01 | `redisUtil.insert(key, JSON.toJSONString(expr), ttl)` | key 表达式、value 表达式 | ✅ | | W01 | `redisUtil.insert(key, JSON.toJSONString(expr), ttl)` | key 表达式、value 表达式 | ✅ |
| W02 | `redisTemplate.opsForValue().set(key, JSON.toJSONString(expr), ...)` | 同上 | ✅ | | W02 | `redisTemplate.opsForValue().set(key, JSON.toJSONString(expr), ...)` | 同上 | ✅ |
| W03 | `stringRedisTemplate.opsForValue().set(key, JsonUtil.getObjectToString(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 类型 | ✅ | | 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` - 方法名含 `setIfAbsent``increment``delete``remove``expire`
- key 匹配 `ignore.key_patterns` 配置(锁 / token / 登录计数等) - 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<Xxx>`rootArray |
| `JsonUtil.getJsonToBean(raw, Xxx.class)` | 同上 |
| 可关联到 `redisUtil.getString(key)` / `opsForValue().get(key)` | 同时补强 key 模式 |
开关:`detection.read_hints_enabled`(默认 `true`)。优先级:`manual_mappings` > W06 补强 > 写侧 AST 推断。
#### Step 5类型推断 #### Step 5类型推断
对每个写入点的 `expr`,使用 JavaParser Symbol Solver 推断类型: 对每个写入点的 `expr`,使用 JavaParser Symbol Solver 推断类型:
@@ -512,6 +524,7 @@ notify:
| 忽略规则完善 | 锁/计数器/token/字面量/setIfAbsent | ✅ | | 忽略规则完善 | 锁/计数器/token/字面量/setIfAbsent | ✅ |
| 多模块性能 | 并行读文件、索引批量装载、`manual_mappings` | ✅ | | 多模块性能 | 并行读文件、索引批量装载、`manual_mappings` | ✅ |
| 企微高亮 | 删除橙 `warning` / 新增绿 `info`;位置+类型通用项 | ✅ | | 企微高亮 | 删除橙 `warning` / 新增绿 `info`;位置+类型通用项 | ✅ |
| W06 读侧辅助 | `parseObject`/`parseArray` 等补强写入 value 类型 | ✅ |
### Phase 3 — 运营 ### Phase 3 — 运营

View File

@@ -43,6 +43,7 @@ include_modules:
`redisCheck` 仓库维护,随 jar 发布,默认包含: `redisCheck` 仓库维护,随 jar 发布,默认包含:
- `detection.patterns`**W01~W05**JSON 字符串写入 + Template 直写 + Hash - `detection.patterns`**W01~W05**JSON 字符串写入 + Template 直写 + Hash
- `detection.read_hints_enabled`W06 读侧反序列化类型辅助(默认 true
- `ignore.key_patterns`(锁 / 计数器 / token - `ignore.key_patterns`(锁 / 计数器 / token
- `detection.min_confidence``max_field_depth` - `detection.min_confidence``max_field_depth`
- `mode: notify``enabled: true` - `mode: notify``enabled: true`
@@ -106,6 +107,9 @@ detection:
- W04 # redisTemplate 直写对象 - W04 # redisTemplate 直写对象
- W05 # opsForHash().put - W05 # opsForHash().put
# W06读侧反序列化类型辅助不产生独立告警
read_hints_enabled: true
# 类型推断最低置信度,低于此值标记为低置信度提示 # 类型推断最低置信度,低于此值标记为低置信度提示
min_confidence: 0.6 min_confidence: 0.6
@@ -211,6 +215,23 @@ detection:
patterns: [W01, W02, W03] patterns: [W01, W02, W03]
``` ```
### 3.5.1 detection.read_hints_enabledW06 辅助)
| 字段 | 类型 | 默认 | 说明 |
|------|------|------|------|
| `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 ### 3.6 manual_mappings
当自动推断不准确时使用。匹配优先级 **高于** 自动推断(按 `类全名#方法名` 覆盖 key 模式与 value 类型)。 当自动推断不准确时使用。匹配优先级 **高于** 自动推断(按 `类全名#方法名` 覆盖 key 模式与 value 类型)。

View File

@@ -1,6 +1,8 @@
package com.codechecker.cache.analyze; package com.codechecker.cache.analyze;
import com.codechecker.cache.config.CheckerConfig; 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.RedisWritePointDetector;
import com.codechecker.cache.detector.WritePoint; import com.codechecker.cache.detector.WritePoint;
import com.codechecker.cache.diff.ChangeType; import com.codechecker.cache.diff.ChangeType;
@@ -111,6 +113,10 @@ public class SchemaCheckAnalyzer {
List<WritePoint> newWps = detectorNew.detect(path, newContent); List<WritePoint> newWps = detectorNew.detect(path, newContent);
List<WritePoint> oldWps = oldContent == null List<WritePoint> oldWps = oldContent == null
? new ArrayList<>() : detectorOld.detect(path, oldContent); ? new ArrayList<>() : detectorOld.detect(path, oldContent);
applyReadHints(newWps, path, newContent, newIndex);
if (oldContent != null) {
applyReadHints(oldWps, path, oldContent, oldIndex);
}
newWps.forEach(this::applyManualMappings); newWps.forEach(this::applyManualMappings);
oldWps.forEach(this::applyManualMappings); oldWps.forEach(this::applyManualMappings);
@@ -425,6 +431,92 @@ public class SchemaCheckAnalyzer {
} }
} }
/**
* W06用同文件读侧反序列化类型提示补强低置信度 / 缺类型的写入点。
* manual_mappings 仍在其后执行,可覆盖本补强结果。
*/
private void applyReadHints(List<WritePoint> writePoints, String path, String content,
SourceIndex index) {
if (!config.getDetection().isReadHintsEnabled()
|| writePoints == null || writePoints.isEmpty()) {
return;
}
List<CacheReadHint> hints = new CacheReadHintDetector(index).detect(path, content);
if (hints.isEmpty()) {
return;
}
for (WritePoint wp : writePoints) {
enrichWritePointFromHints(wp, hints);
}
}
private void enrichWritePointFromHints(WritePoint wp, List<CacheReadHint> 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<String> out) { private void collectTypeNames(String content, Set<String> out) {
if (content == null || content.isEmpty()) { if (content == null || content.isEmpty()) {
return; return;

View File

@@ -110,6 +110,8 @@ public class CheckerConfig {
private List<String> patterns = new ArrayList<>(); private List<String> patterns = new ArrayList<>();
private double minConfidence = 0.6; private double minConfidence = 0.6;
private int maxFieldDepth = 8; private int maxFieldDepth = 8;
/** W06是否启用读侧反序列化类型辅助补强 */
private boolean readHintsEnabled = true;
public List<String> getPatterns() { public List<String> getPatterns() {
return patterns; return patterns;
@@ -134,6 +136,14 @@ public class CheckerConfig {
public void setMaxFieldDepth(int maxFieldDepth) { public void setMaxFieldDepth(int maxFieldDepth) {
this.maxFieldDepth = maxFieldDepth; this.maxFieldDepth = maxFieldDepth;
} }
public boolean isReadHintsEnabled() {
return readHintsEnabled;
}
public void setReadHintsEnabled(boolean readHintsEnabled) {
this.readHintsEnabled = readHintsEnabled;
}
} }
public static class ManualMapping { public static class ManualMapping {

View File

@@ -100,6 +100,7 @@ public final class ConfigLoader {
d.setPatterns(strList(detection.get("patterns"))); d.setPatterns(strList(detection.get("patterns")));
d.setMinConfidence(dbl(detection, "min_confidence", 0.6)); d.setMinConfidence(dbl(detection, "min_confidence", 0.6));
d.setMaxFieldDepth((int) lng(detection, "max_field_depth", 8)); d.setMaxFieldDepth((int) lng(detection, "max_field_depth", 8));
d.setReadHintsEnabled(bool(detection, "read_hints_enabled", true));
Map<String, Object> severityOverrides = asMap(map.get("severity_overrides")); Map<String, Object> severityOverrides = asMap(map.get("severity_overrides"));
Map<String, String> so = new LinkedHashMap<>(); Map<String, String> so = new LinkedHashMap<>();

View File

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

View File

@@ -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扫描读侧反序列化提取「缓存字符串 → 业务类型」提示,供写入点类型补强。
* <p>典型模式:</p>
* <pre>
* String raw = redisUtil.getString(key);
* Foo vo = JSON.parseObject(raw, Foo.class);
* List&lt;Foo&gt; list = JSON.parseArray(raw, Foo.class);
* </pre>
*/
public class CacheReadHintDetector {
private static final Set<String> OBJECT_PARSE = new HashSet<>(Arrays.asList(
"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;
public CacheReadHintDetector(SourceIndex index) {
this.index = index;
this.keyResolver = new RedisKeyResolver(index);
}
public List<CacheReadHint> detect(String filePath, String content) {
List<CacheReadHint> 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)) {
// 部分 APIparseObject(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
? "<unknown>"
: 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("<unknown>"));
hint.setResolvedValueType(fqn);
hint.setRootArray(array || "parseArray".equals(name) || "getJsonToList".equals(name));
hint.setConfidence(0.7);
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.setConfidence(0.85);
}
return hint;
}
/**
* 若 parse 的第 1 参是局部变量,追溯其是否来自 redis get(key)。
*/
private Optional<RedisGetRef> 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<CallableDeclaration> 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<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");
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;
}
}
}

View File

@@ -44,7 +44,9 @@ detection:
- W03 # stringRedisTemplate.opsForValue().set(key, JsonUtil.getObjectToString(x), ...) - W03 # stringRedisTemplate.opsForValue().set(key, JsonUtil.getObjectToString(x), ...)
- W04 # redisTemplate.opsForValue().set(key, obj, ...) - W04 # redisTemplate.opsForValue().set(key, obj, ...)
- W05 # redisTemplate.opsForHash().put(key, field, obj) - W05 # redisTemplate.opsForHash().put(key, field, obj)
# 类型推断最低置信度,低于此值降级为 P2 提示 # W06 读侧辅助:用 parseObject / getJsonToBean 等补强写入点 value 类型(非写入模式)
read_hints_enabled: true
# 类型推断最低置信度,低于此值标记为低置信度提示
min_confidence: 0.6 min_confidence: 0.6
# 字段展开最大深度(防止循环引用) # 字段展开最大深度(防止循环引用)
max_field_depth: 8 max_field_depth: 8

View File

@@ -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<CacheReadHint> 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<ItemVo> load(String raw) {\n"
+ " return JSON.parseArray(raw, ItemVo.class);\n"
+ " }\n"
+ "}\n";
SourceIndex index = new SourceIndex();
index.addSource(vo);
index.addSource(source);
List<CacheReadHint> 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());
}
}