|
|
@@ -0,0 +1,536 @@
|
|
|
+package com.etotem.cfc.unit;
|
|
|
+
|
|
|
+import org.junit.jupiter.api.DisplayName;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+
|
|
|
+import java.io.File;
|
|
|
+import java.lang.reflect.Field;
|
|
|
+import java.lang.reflect.Method;
|
|
|
+import java.lang.reflect.Modifier;
|
|
|
+import java.net.JarURLConnection;
|
|
|
+import java.net.URL;
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.Arrays;
|
|
|
+import java.util.Collection;
|
|
|
+import java.util.Enumeration;
|
|
|
+import java.util.HashSet;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Set;
|
|
|
+import java.util.jar.JarFile;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.assertNotNull;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
|
+import static org.junit.jupiter.api.Assertions.fail;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 契约级单测:报告解析结果写库前「个人层双断言」(能力键 {@code guhe.report.personalAssert},T2 / L2-4)。
|
|
|
+ *
|
|
|
+ * <p><b>契约来源</b>(2026-11-08-浠艾福平台程序修改计划.md):
|
|
|
+ * <ul>
|
|
|
+ * <li><b>§三任务分解 · P0 · T2 个人层双断言(L2-4)</b>:<b>写库前</b>键名级
|
|
|
+ * ({@code _PERSONAL_KEYS})+ 字符串内容级(页码残段 / {@code ND 参考} 扫全文);
|
|
|
+ * <b>正样本</b>:「大肠杆菌改善方式以『苦丁茶』开头」。</li>
|
|
|
+ * <li><b>§一 L2 事实与知识 ④</b>:个人层双断言(键名级 + 内容级)为 <b>P0</b>。</li>
|
|
|
+ * <li><b>§2.1 分页残段清洗</b>:残段样例 {@code 34/167 0.0033 参考 0-0.1746% ND}——
|
|
|
+ * 个人层丰度不再混入知识层;内容级断言即拦截此类残段/ {@code ND 参考} 混入。</li>
|
|
|
+ * </ul>
|
|
|
+ * <blockquote>
|
|
|
+ * 契约语义:报告解析结果中「个人层」数据项(如简写:菌名 → 改善方式)在<b>写库前</b>必须通过双断言——
|
|
|
+ * <b>键名级</b>:入库键名必须属于个人层键白名单 {@code _PERSONAL_KEYS}(知识层键/未知键不得混入个人层);
|
|
|
+ * <b>内容级</b>:字符串值全文扫描不得含分页残段({@code \d{1,3}/16\d} 形如 {@code 34/167 ...})
|
|
|
+ * 或 {@code ND 参考} 模式;合法个人层内容(正样本「大肠杆菌改善方式以『苦丁茶』开头」)不得被误杀。
|
|
|
+ * </blockquote></p>
|
|
|
+ *
|
|
|
+ * <p><b>平台现状(C 档探测,src/main 现状核实)</b>:
|
|
|
+ * <ul>
|
|
|
+ * <li>{@code _PERSONAL_KEYS} / {@code personalKeys} / 「个人层」在 src/main <b>全库 0 引用</b>;</li>
|
|
|
+ * <li>{@code PdfParseService} 仅对 {@code 改善方式} 行做<b>解析跳过</b>({@code nameLine.startsWith("改善方式")}),
|
|
|
+ * 无写库前键名/内容断言;个人层双断言入口(键名级 / 内容级)均不存在;</li>
|
|
|
+ * <li>{@code reportFormat} 仅 {@code "beijing"} / {@code "guhe"},无版本维度(与同批 T12 探测一致)。</li>
|
|
|
+ * </ul>
|
|
|
+ * 因此本测试以「候选包扫描 + 反射结构契约 + 正样本语义锚点」探测契约缺失:
|
|
|
+ * 能编译通过、能运行、断言失败(红),失败断言原文清晰可贴。</p>
|
|
|
+ *
|
|
|
+ * <p><b>RED 预期</b>:正常流(键集存在 / 键集覆盖个人层键 / 键名级断言入口)与边界流
|
|
|
+ * (残段 / {@code ND 参考} 扫描模式 / 内容级断言入口 / 正样本放行)用例均应失败(红)——
|
|
|
+ * 个人层双断言实现未落地;宿主结构锚点(报告解析入口)已存在,作为说明性绿 + GREEN 挂载点。</p>
|
|
|
+ *
|
|
|
+ * <p><b>GREEN 升级指引</b>:实现在 L2 层(报告解析结果 → 写库链路)落地四件事后本测试自动转绿——
|
|
|
+ * <ol>
|
|
|
+ * <li>声明个人层键白名单常量 {@code _PERSONAL_KEYS}(静态 Set/List/String[],字段名含
|
|
|
+ * {@code PERSONAL_KEYS} / {@code personalKeys} / 「个人层键」),值覆盖个人层键
|
|
|
+ * (含「改善方式」等报告个人层数据键);</li>
|
|
|
+ * <li>提供<b>键名级断言</b>入口(方法名含 {@code assert} / {@code validate} / {@code check}
|
|
|
+ * 与 {@code personal} / {@code PersonalKey}),写库前判定待入库键名 {@code ∈ _PERSONAL_KEYS};</li>
|
|
|
+ * <li>声明<b>内容级扫描模式</b>常量(静态 String/Pattern,值含 {@code \d{1,3}/16\d} 残段模式
|
|
|
+ * 或 {@code ND 参考} 标记 / 样例 {@code 34/167}),并提供<b>内容级断言</b>入口
|
|
|
+ * (方法名含 {@code assert} / {@code scan} / {@code validate} 与 {@code tail} / {@code nd} /
|
|
|
+ * {@code 残段} / {@code personal}),扫全文判定不含残段与 {@code ND 参考};</li>
|
|
|
+ * <li>正样本语义:以「大肠杆菌改善方式以『苦丁茶』开头」为输入,内容级断言应放行(不误杀)。</li>
|
|
|
+ * </ol>
|
|
|
+ * 届时可将结构探测升级为行为级验证(反射调用断言入口,正样本返回通过、含 {@code 34/167 ... ND} 的
|
|
|
+ * 负样本返回拒绝)。</p>
|
|
|
+ *
|
|
|
+ * <p><b>权限流说明</b>:本能力为报告解析 → 写库的内部 L2 数据链路(个人层双断言),
|
|
|
+ * 无用户权限维度,「权限(适用时)」三流中的权限流不适用,以说明性用例标注。</p>
|
|
|
+ */
|
|
|
+class GuheReportPersonalAssertContractTest {
|
|
|
+
|
|
|
+ /** 能力键 */
|
|
|
+ private static final String CAP_KEY = "guhe.report.personalAssert";
|
|
|
+
|
|
|
+ /** 契约要求声明的个人层键白名单常量名(T2 / L2-4,键名级锚点) */
|
|
|
+ private static final String PERSONAL_KEYS_CONST = "_PERSONAL_KEYS";
|
|
|
+
|
|
|
+ /** 个人层键集名称语义探针(大小写不敏感) */
|
|
|
+ private static final List<String> PERSONAL_KEYS_NAME_FRAGMENTS = Arrays.asList(
|
|
|
+ "personal_keys", "personalkeys", "个人层键", "个人键");
|
|
|
+
|
|
|
+ /** 个人层键集内容探针:契约要求覆盖的个人层键(正样本「改善方式」键名) */
|
|
|
+ private static final List<String> PERSONAL_KEY_VALUE_FRAGMENTS = Arrays.asList(
|
|
|
+ "改善方式", "大肠杆菌");
|
|
|
+
|
|
|
+ /** 键名级断言入口方法名探针(大小写不敏感;assert/validate/check + personal/key) */
|
|
|
+ private static final List<String> KEY_ASSERT_METHOD_FRAGMENTS = Arrays.asList(
|
|
|
+ "assertpersonalkey", "validatepersonalkey", "checkpersonalkey",
|
|
|
+ "assertpersonalkeys", "validatepersonalkeys", "checkpersonalkeys",
|
|
|
+ "assertpersonallayer", "validatepersonallayer", "assertpersonalkeyname");
|
|
|
+
|
|
|
+ /** 内容级断言入口方法名探针(大小写不敏感;assert/scan/validate + tail/nd/残段/personal) */
|
|
|
+ private static final List<String> CONTENT_ASSERT_METHOD_FRAGMENTS = Arrays.asList(
|
|
|
+ "assertnotail", "assertnotailfragment", "scantail", "assertnodreference",
|
|
|
+ "scanndreference", "assertnopagetail", "scanpagetail", "validatenodtail",
|
|
|
+ "assertpersonalcontent", "scanpersonalcontent", "assertcontentclean");
|
|
|
+
|
|
|
+ /** 内容级扫描模式常量名探针(大小写不敏感) */
|
|
|
+ private static final List<String> CONTENT_MARK_NAME_FRAGMENTS = Arrays.asList(
|
|
|
+ "tail", "残段", "nd", "reference", "分页");
|
|
|
+
|
|
|
+ /** 残段样例(§2.1:{@code 34/167 0.0033 参考 0-0.1746% ND}) */
|
|
|
+ private static final String TAIL_SAMPLE = "34/167";
|
|
|
+
|
|
|
+ /** 残段正则模式片段({@code \d{1,3}/16\d};Java 字符串中 { 需转义) */
|
|
|
+ private static final String TAIL_REGEX_FRAGMENT = "/16\\d";
|
|
|
+
|
|
|
+ /** {@code ND 参考} 模式(§2.1 / T2 内容级扫全文锚点) */
|
|
|
+ private static final String ND_REFERENCE_FRAGMENT = "ND 参考";
|
|
|
+
|
|
|
+ /** 正样本(T2):合法个人层内容,内容级断言不得误杀 */
|
|
|
+ private static final String POSITIVE_SAMPLE = "大肠杆菌改善方式以『苦丁茶』开头";
|
|
|
+
|
|
|
+ /** 需扫描的候选包(不假定实现包路径,扫全部候选包) */
|
|
|
+ private static final List<String> SCAN_PACKAGES = Arrays.asList(
|
|
|
+ "com.etotem.cfc.service",
|
|
|
+ "com.etotem.cfc.service.attribution",
|
|
|
+ "com.etotem.cfc.constant",
|
|
|
+ "com.etotem.cfc.dto",
|
|
|
+ "com.etotem.cfc.entity");
|
|
|
+
|
|
|
+ // ------------------------------------------------------------------
|
|
|
+ // 正常流:个人层键白名单 _PERSONAL_KEYS(T2 键名级锚点)
|
|
|
+ // ------------------------------------------------------------------
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-1:L2 层应声明个人层键白名单 _PERSONAL_KEYS(键名级锚点)")
|
|
|
+ void personalKeys_constantDeclared() {
|
|
|
+ String fqcn = findClassDeclaringPersonalKeysConstant();
|
|
|
+ assertTrue(fqcn != null,
|
|
|
+ "[" + CAP_KEY + "] 候选包(service/constant/dto/entity)应声明个人层键白名单常量 "
|
|
|
+ + PERSONAL_KEYS_CONST + "(静态 Set/List/String[],名含 PERSONAL_KEYS / 个人层键)"
|
|
|
+ + "作为写库前键名级断言的键白名单(T2 个人层双断言-键名级,L2-4)。"
|
|
|
+ + "当前 src/main 全库 0 引用「_PERSONAL_KEYS / personalKeys / 个人层」,实现未落地。");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-2:_PERSONAL_KEYS 白名单应覆盖个人层键(含「改善方式」)")
|
|
|
+ void personalKeys_coversPersonalLayerKeys() {
|
|
|
+ Object keyset = readPersonalKeysConstant();
|
|
|
+ if (keyset == null) {
|
|
|
+ fail("[" + CAP_KEY + "] 个人层键白名单 _PERSONAL_KEYS 不存在,无法验证键覆盖(T2 键名级)。"
|
|
|
+ + "实现未落地,GREEN 时声明并覆盖个人层键即可。");
|
|
|
+ }
|
|
|
+ String dump = String.valueOf(keyset);
|
|
|
+ assertTrue(dump.contains(PERSONAL_KEY_VALUE_FRAGMENTS.get(0)),
|
|
|
+ "[" + CAP_KEY + "] 个人层键白名单应覆盖个人层键「改善方式」(正样本键名,"
|
|
|
+ + "T2:个人层双断言-键名级)。当前白名单=" + dump + "。");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ------------------------------------------------------------------
|
|
|
+ // 正常流:键名级断言入口(写库前校验入库键名 ∈ _PERSONAL_KEYS)
|
|
|
+ // ------------------------------------------------------------------
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-3:L2 层应提供写库前键名级断言入口(滥用键名应被拒绝)")
|
|
|
+ void personalKey_assertEntryExists() {
|
|
|
+ boolean hasEntry = anyClassHasMethodNamedLike(KEY_ASSERT_METHOD_FRAGMENTS);
|
|
|
+ assertTrue(hasEntry,
|
|
|
+ "[" + CAP_KEY + "] 候选包应提供写库前「键名级断言」入口"
|
|
|
+ + "(方法名含 assert/validate/check + personalKey(s) / personalLayer,"
|
|
|
+ + "T2 个人层双断言-键名级,L2-4):判定待入库键名 ∈ _PERSONAL_KEYS,"
|
|
|
+ + "知识层键/未知键不得混入个人层数据。"
|
|
|
+ + "当前无键名级断言入口,写库前键名不受约束。");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ------------------------------------------------------------------
|
|
|
+ // 边界流:内容级扫描模式(页码残段 / ND 参考,T2 内容级锚点)
|
|
|
+ // ------------------------------------------------------------------
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("边界流-1:L2 层应声明分页残段扫描模式(\\d{1,3}/16\\d,样例 34/167)")
|
|
|
+ void contentScan_tailPatternDeclared() {
|
|
|
+ boolean hasMark = anyClassHasStringConstantContaining(new String[]{TAIL_SAMPLE, TAIL_REGEX_FRAGMENT},
|
|
|
+ CONTENT_MARK_NAME_FRAGMENTS);
|
|
|
+ assertTrue(hasMark,
|
|
|
+ "[" + CAP_KEY + "] 候选包应声明分页残段扫描模式常量(静态 String/Pattern,"
|
|
|
+ + "值含 " + TAIL_SAMPLE + " 或 " + TAIL_REGEX_FRAGMENT + ",名含 tail/残段/分页,"
|
|
|
+ + "§2.1 样例 34/167 0.0033 参考 0-0.1746% ND):"
|
|
|
+ + "内容级断言据此拦截分页拼接残段混入个人层丰度(T2 内容级,L2-4)。"
|
|
|
+ + "当前无残段扫描模式常量,实现未落地。");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("边界流-2:L2 层应声明 ND 参考 扫描模式(§2.1 内容级锚点)")
|
|
|
+ void contentScan_ndReferencePatternDeclared() {
|
|
|
+ boolean hasMark = anyClassHasStringConstantContaining(new String[]{ND_REFERENCE_FRAGMENT},
|
|
|
+ CONTENT_MARK_NAME_FRAGMENTS);
|
|
|
+ assertTrue(hasMark,
|
|
|
+ "[" + CAP_KEY + "] 候选包应声明「ND 参考」扫描模式常量(静态 String/Pattern,值含 "
|
|
|
+ + ND_REFERENCE_FRAGMENT + ",名含 nd/reference/参考,"
|
|
|
+ + "§2.1 残段样例与 T2 内容级扫全文锚点):"
|
|
|
+ + "内容级断言据此拦截「参考 … ND」标记混入个人层数据。"
|
|
|
+ + "当前无 ND 参考 扫描模式常量,实现未落地。");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ------------------------------------------------------------------
|
|
|
+ // 边界流:内容级断言入口(写库前全文扫描,不误杀正样本)
|
|
|
+ // ------------------------------------------------------------------
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("边界流-3:L2 层应提供写库前字符串内容级断言入口(扫全文残段 / ND 参考)")
|
|
|
+ void personalContent_assertEntryExists() {
|
|
|
+ boolean hasEntry = anyClassHasMethodNamedLike(CONTENT_ASSERT_METHOD_FRAGMENTS);
|
|
|
+ assertTrue(hasEntry,
|
|
|
+ "[" + CAP_KEY + "] 候选包应提供写库前「字符串内容级断言」入口"
|
|
|
+ + "(方法名含 assert/scan/validate + tail/nd/残段/personalContent,"
|
|
|
+ + "T2 个人层双断言-内容级,L2-4):对个人层字符串值扫全文,"
|
|
|
+ + "断言不含分页残段(34/167 …)与「ND 参考」模式。"
|
|
|
+ + "当前无内容级断言入口,个人层写库无内容防线。");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("边界流-4(正样本):内容级断言不得误杀「大肠杆菌改善方式以『苦丁茶』开头」")
|
|
|
+ void personalContent_positiveSampleNotFalselyRejected() {
|
|
|
+ Object result = invokeContentAssertOnPositiveSample();
|
|
|
+ assertTrue(Boolean.TRUE.equals(result),
|
|
|
+ "[" + CAP_KEY + "] 内容级断言对正样本「" + POSITIVE_SAMPLE + "」应放行(返回 true),"
|
|
|
+ + "不得误判为分页残段 / ND 参考 混入(T2 正样本,L2-4)。"
|
|
|
+ + "正样本为合法个人层内容,仅当扫描到 34/167 残段或「ND 参考」模式时才应拒绝。"
|
|
|
+ + (result == null
|
|
|
+ ? "当前无内容级断言入口可调用,正样本放行语义未落地。"
|
|
|
+ : "当前入口返回 " + result + ",正样本被误判。"));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ------------------------------------------------------------------
|
|
|
+ // 结构契约:报告解析-写库链路入口(已存在 → 说明性绿,GREEN 挂载点)
|
|
|
+ // ------------------------------------------------------------------
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("结构契约-1:报告解析入口 PdfParseService.parseText 应存在(写库前双断言挂载点)")
|
|
|
+ void pdfParseService_hasParseTextPipeline() {
|
|
|
+ Class<?> clazz = loadClass("com.etotem.cfc.service.PdfParseService");
|
|
|
+ if (clazz == null) {
|
|
|
+ fail("[" + CAP_KEY + "] PdfParseService 类不存在(L1 文本解析入口),"
|
|
|
+ + "无法验证 parseText 管线(GREEN 挂载点)。");
|
|
|
+ }
|
|
|
+ boolean found = false;
|
|
|
+ for (Method m : clazz.getDeclaredMethods()) {
|
|
|
+ if (m.getName().equals("parseText")) {
|
|
|
+ found = true;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ assertTrue(found,
|
|
|
+ "[" + CAP_KEY + "] PdfParseService 应提供 parseText 管线入口"
|
|
|
+ + "(T2 写库前个人层双断言挂载点;GREEN 时在解析结果写库前调用"
|
|
|
+ + "键名级 + 内容级断言即可)。");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ------------------------------------------------------------------
|
|
|
+ // 权限流(不适用):报告解析-写库内部数据链路,无用户权限维度
|
|
|
+ // ------------------------------------------------------------------
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("权限流:本能力为报告解析→写库内部 L2 数据链路(个人层双断言),权限流不适用")
|
|
|
+ void permissionFlow_notApplicable() {
|
|
|
+ // 说明性用例:契约 AC 为「写库前键名级 + 内容级双断言」内部数据链路,无用户权限校验维度。
|
|
|
+ // 不产生断言失败;仅为三流覆盖标注「不适用」。
|
|
|
+ assertTrue(true, "权限流不适用:" + CAP_KEY + " 为报告解析→写库内部 L2 数据链路,无用户权限维度。");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ------------------------------------------------------------------
|
|
|
+ // 反射/资源辅助(纯 JUnit5 + 反射,无 Spring / DB / Mockito)
|
|
|
+ // ------------------------------------------------------------------
|
|
|
+
|
|
|
+ /** 在候选包内查找声明了个人层键白名单常量的类(返回 FQCN;找不到返回 null) */
|
|
|
+ private String findClassDeclaringPersonalKeysConstant() {
|
|
|
+ for (String pkg : SCAN_PACKAGES) {
|
|
|
+ for (String fqcn : scanClassNames(pkg)) {
|
|
|
+ Class<?> clazz = loadClass(fqcn);
|
|
|
+ if (clazz == null) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ if (hasPersonalKeysConstant(clazz)) {
|
|
|
+ return fqcn;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 读取个人层键白名单常量值(Set/List/Collection/String[];找不到返回 null) */
|
|
|
+ private Object readPersonalKeysConstant() {
|
|
|
+ for (String pkg : SCAN_PACKAGES) {
|
|
|
+ for (String fqcn : scanClassNames(pkg)) {
|
|
|
+ Class<?> clazz = loadClass(fqcn);
|
|
|
+ if (clazz == null) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ for (Field f : clazz.getDeclaredFields()) {
|
|
|
+ if (!isStaticFinalCollectionLike(f)) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ String name = f.getName().toLowerCase();
|
|
|
+ boolean nameMatch = false;
|
|
|
+ for (String nf : PERSONAL_KEYS_NAME_FRAGMENTS) {
|
|
|
+ if (name.contains(nf)) {
|
|
|
+ nameMatch = true;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (!nameMatch) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ f.setAccessible(true);
|
|
|
+ return f.get(null);
|
|
|
+ } catch (Exception ignored) {
|
|
|
+ // 常量读取失败 → 跳过该字段
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 字段是否为静态 final 且类型为 Set/List/Collection/String[](个人层键白名单容器) */
|
|
|
+ private boolean isStaticFinalCollectionLike(Field f) {
|
|
|
+ if (!Modifier.isStatic(f.getModifiers()) || !Modifier.isFinal(f.getModifiers())) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ Class<?> t = f.getType();
|
|
|
+ return Collection.class.isAssignableFrom(t) || t.isArray()
|
|
|
+ || t == Set.class || t == List.class;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 是否存在声明了个人层键白名单常量的字段(名含 PERSONAL_KEYS / 个人层键,值为集合/数组容器) */
|
|
|
+ private boolean hasPersonalKeysConstant(Class<?> clazz) {
|
|
|
+ for (Field f : clazz.getDeclaredFields()) {
|
|
|
+ if (!isStaticFinalCollectionLike(f)) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ String name = f.getName().toLowerCase();
|
|
|
+ for (String nf : PERSONAL_KEYS_NAME_FRAGMENTS) {
|
|
|
+ if (name.contains(nf)) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 反射调用内容级断言入口(单 String 参数、boolean 返回);无入口返回 null */
|
|
|
+ private Object invokeContentAssertOnPositiveSample() {
|
|
|
+ Class<?> hit = findClassWithContentAssertEntry();
|
|
|
+ if (hit == null) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ for (Method m : hit.getDeclaredMethods()) {
|
|
|
+ String name = m.getName().toLowerCase();
|
|
|
+ boolean entryLike = false;
|
|
|
+ for (String frag : CONTENT_ASSERT_METHOD_FRAGMENTS) {
|
|
|
+ if (name.contains(frag)) {
|
|
|
+ entryLike = true;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (!entryLike) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ Class<?>[] params = m.getParameterTypes();
|
|
|
+ if (params.length == 1 && params[0] == String.class) {
|
|
|
+ try {
|
|
|
+ Object target = Modifier.isStatic(m.getModifiers()) ? null : newInstance(hit);
|
|
|
+ if (!Modifier.isStatic(m.getModifiers()) && target == null) {
|
|
|
+ return null; // 无法实例化(无默认构造)→ 按「不可调用」处理
|
|
|
+ }
|
|
|
+ m.setAccessible(true);
|
|
|
+ return m.invoke(target, POSITIVE_SAMPLE);
|
|
|
+ } catch (Exception ignored) {
|
|
|
+ return null; // 调用失败 → 按「不可调用」处理
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 查找提供内容级断言入口的候选类(无入口返回 null) */
|
|
|
+ private Class<?> findClassWithContentAssertEntry() {
|
|
|
+ for (String pkg : SCAN_PACKAGES) {
|
|
|
+ for (String fqcn : scanClassNames(pkg)) {
|
|
|
+ Class<?> clazz = loadClass(fqcn);
|
|
|
+ if (clazz == null) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ for (Method m : clazz.getDeclaredMethods()) {
|
|
|
+ String name = m.getName().toLowerCase();
|
|
|
+ for (String frag : CONTENT_ASSERT_METHOD_FRAGMENTS) {
|
|
|
+ if (name.contains(frag)) {
|
|
|
+ return clazz;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 任一候选类存在方法名(忽略大小写)包含任一片段 */
|
|
|
+ private boolean anyClassHasMethodNamedLike(List<String> fragments) {
|
|
|
+ for (String pkg : SCAN_PACKAGES) {
|
|
|
+ for (String fqcn : scanClassNames(pkg)) {
|
|
|
+ Class<?> clazz = loadClass(fqcn);
|
|
|
+ if (clazz == null) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ for (Method m : clazz.getDeclaredMethods()) {
|
|
|
+ String name = m.getName().toLowerCase();
|
|
|
+ for (String frag : fragments) {
|
|
|
+ if (name.contains(frag)) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 任一候选类存在静态 String/Pattern 常量:值包含任一 valueFragment,
|
|
|
+ * 且字段名(忽略大小写)含任一 nameFragment —— 残段 / ND 参考 扫描模式常量探针。
|
|
|
+ */
|
|
|
+ private boolean anyClassHasStringConstantContaining(String[] valueFragments, List<String> nameFragments) {
|
|
|
+ for (String pkg : SCAN_PACKAGES) {
|
|
|
+ for (String fqcn : scanClassNames(pkg)) {
|
|
|
+ Class<?> clazz = loadClass(fqcn);
|
|
|
+ if (clazz == null) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ for (Field f : clazz.getDeclaredFields()) {
|
|
|
+ if (!Modifier.isStatic(f.getModifiers()) || !Modifier.isFinal(f.getModifiers())) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ if (f.getType() != String.class && !f.getType().getName().contains("Pattern")) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ String name = f.getName().toLowerCase();
|
|
|
+ boolean nameMatch = false;
|
|
|
+ for (String nf : nameFragments) {
|
|
|
+ if (name.contains(nf)) {
|
|
|
+ nameMatch = true;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (!nameMatch) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ f.setAccessible(true);
|
|
|
+ Object v = f.get(null);
|
|
|
+ if (!(v instanceof String)) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ for (String vf : valueFragments) {
|
|
|
+ if (((String) v).contains(vf)) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch (Exception ignored) {
|
|
|
+ // 常量读取失败 → 跳过
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 实例化(优先无参构造;失败返回 null) */
|
|
|
+ private Object newInstance(Class<?> clazz) {
|
|
|
+ try {
|
|
|
+ return clazz.getDeclaredConstructor().newInstance();
|
|
|
+ } catch (Exception e) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 扫描包内所有类全限定名(classpath 资源;支持 file: 目录与 jar: 两种形态) */
|
|
|
+ private List<String> scanClassNames(String pkg) {
|
|
|
+ List<String> names = new ArrayList<String>();
|
|
|
+ try {
|
|
|
+ String path = pkg.replace('.', '/');
|
|
|
+ ClassLoader cl = Thread.currentThread().getContextClassLoader();
|
|
|
+ Enumeration<URL> resources = cl.getResources(path);
|
|
|
+ while (resources.hasMoreElements()) {
|
|
|
+ URL url = resources.nextElement();
|
|
|
+ String protocol = url.getProtocol();
|
|
|
+ if ("file".equals(protocol)) {
|
|
|
+ File dir = new File(url.toURI());
|
|
|
+ File[] files = dir.listFiles();
|
|
|
+ if (files == null) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ for (File f : files) {
|
|
|
+ if (f.isFile() && f.getName().endsWith(".class")) {
|
|
|
+ names.add(pkg + "." + f.getName().substring(0, f.getName().length() - 6));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } else if ("jar".equals(protocol)) {
|
|
|
+ JarURLConnection conn = (JarURLConnection) url.openConnection();
|
|
|
+ try (JarFile jar = conn.getJarFile()) {
|
|
|
+ Enumeration<java.util.jar.JarEntry> entries = jar.entries();
|
|
|
+ while (entries.hasMoreElements()) {
|
|
|
+ java.util.jar.JarEntry e = entries.nextElement();
|
|
|
+ String n = e.getName();
|
|
|
+ if (n.startsWith(path + "/") && n.endsWith(".class")
|
|
|
+ && n.indexOf('/', path.length() + 1) < 0) {
|
|
|
+ names.add(n.substring(0, n.length() - 6).replace('/', '.'));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch (Exception ignored) {
|
|
|
+ // 包扫描失败 → 返回已收集列表(空),结构探测按缺失处理
|
|
|
+ }
|
|
|
+ Set<String> unique = new HashSet<String>(names);
|
|
|
+ return new ArrayList<String>(unique);
|
|
|
+ }
|
|
|
+
|
|
|
+ private Class<?> loadClass(String name) {
|
|
|
+ try {
|
|
|
+ return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
|
|
|
+ } catch (ClassNotFoundException e) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|