Просмотр исходного кода

test(S-cfc-93): RED 契约测试 guhe.report.personalAssert 个人层双断言(键名级+内容级) 7红2绿

Sisyphus Agent 4 дней назад
Родитель
Сommit
b7c0f49eaa

+ 536 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/GuheReportPersonalAssertContractTest.java

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