From c022f3ab9e9cb7aa97af444a7aa53093d123affb Mon Sep 17 00:00:00 2001 From: bladehan1 Date: Fri, 29 May 2026 12:33:12 +0800 Subject: [PATCH] test(config): add reference.conf to bean parity gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ConfigParityGateTest as the single build-time gate validating that reference.conf and its *Config beans stay in lockstep. Five sub-gates run against every entry in SECTIONS: 1) hoconKeysAreBound — every HOCON key has a matching writable bean property or is in the per-section HOCON-orphan allowlist with a rationale comment. 2) beanPropertiesHaveHoconKeys — every writable bean property has a matching HOCON key or is in the per-section bean-orphan allowlist. 3) defaultValuesMatch — every supported-type bean property has a default value equal to the reference.conf value; types outside the dispatcher matrix are a hard failure (no silent escape). 4) allowlistEntriesAreLive — every per-section allowlist entry still resolves to a live HOCON path or writable bean property; prevents allowlist rot when a key is renamed or removed. 5) everyReferenceConfTopLevelKeyIsCovered — meta-gate closing the "new top-level section sneaks in" hole. ConfigParityCheck holds the shared recursive walkers, type dispatcher, shape-mismatch guard (a nested *Config property must see a HOCON OBJECT), and per-section + cross-section accounting. Default-value mismatch messages stamp both sides with the runtime type so that an Integer(10) vs Long(10) divergence is no longer visually identical. Scope: gates validate only the SECTIONS bucket. Top-level keys outside SECTIONS (crypto, enery, localwitness, net, seed, trx) are counted for file-coverage alignment but their subtrees are not value-validated — they hang off MiscConfig manual-read paths or non-bean roots. Each helper provides a (label, Config, ...) overload that walks a caller-supplied Config without bumping the production AGGREGATES, so unit tests of the gate itself stay isolated from the gate's own coverage totals. --- .../core/config/args/ConfigParityCheck.java | 713 ++++++++++++++++++ .../config/args/ConfigParityGateTest.java | 238 ++++++ 2 files changed, 951 insertions(+) create mode 100644 common/src/test/java/org/tron/core/config/args/ConfigParityCheck.java create mode 100644 common/src/test/java/org/tron/core/config/args/ConfigParityGateTest.java diff --git a/common/src/test/java/org/tron/core/config/args/ConfigParityCheck.java b/common/src/test/java/org/tron/core/config/args/ConfigParityCheck.java new file mode 100644 index 0000000000..051ebeaef0 --- /dev/null +++ b/common/src/test/java/org/tron/core/config/args/ConfigParityCheck.java @@ -0,0 +1,713 @@ +package org.tron.core.config.args; + +import com.typesafe.config.Config; +import com.typesafe.config.ConfigFactory; +import com.typesafe.config.ConfigObject; +import com.typesafe.config.ConfigValue; +import com.typesafe.config.ConfigValueType; +import java.beans.Introspector; +import java.beans.PropertyDescriptor; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; +import lombok.extern.slf4j.Slf4j; + +/** + * Shared helpers for reference.conf <-> {@code *Config} bean parity tests. + * Asserts that every HOCON key under a section binds to a writable bean + * property and matches the bean's default. Drift fails the build at PR time + * instead of waiting for {@code ConfigBeanFactory} to throw at startup. + *

+ * {@code [parity-*]} audit log lines land in the JUnit XML {@code } + * when the gate runs in isolation; if mixed with tests that boot a tron main, + * production logback may redirect them to {@code logs/} on disk. + */ +@Slf4j(topic = "test") +final class ConfigParityCheck { + + private ConfigParityCheck() { + + } + + private static Map writablePropertyDescriptors(Class beanClass) { + try { + Map m = new TreeMap<>(); + for (PropertyDescriptor pd : + Introspector.getBeanInfo(beanClass, Object.class).getPropertyDescriptors()) { + if (pd.getWriteMethod() != null) { + m.put(pd.getName(), pd); + } + } + return m; + } catch (java.beans.IntrospectionException e) { + throw new AssertionError("Introspector failed on " + beanClass.getName(), e); + } + } + + /** + * {@code shapeMismatches}: HOCON key matches a bean property of nested + * {@code *Config} type but the HOCON value is not OBJECT — walker cannot + * recurse, and downstream binding would throw {@code WrongType}. + */ + private static final class OrphanCounters { + int total; + int bound; + final Set orphans = new TreeSet<>(); + final Set allowlisted = new TreeSet<>(); + final Set shapeMismatches = new TreeSet<>(); + } + + /** + * Fails when reference.conf has keys under {@code sectionPath} (recursively + * through nested {@code *Config} sub-sections) that the bean cannot bind. + * Recurses into a sub-config when the property type satisfies + * {@link #isRecursiveConfigBean} AND the HOCON value is an OBJECT. + */ + static void assertNoHoconOrphans( + String sectionPath, Class beanClass, Set allowedHoconOrphans) { + Config section = ConfigFactory.defaultReference().getConfig(sectionPath); + OrphanCounters c = walkAndLogHoconOrphans( + sectionPath, section, beanClass, allowedHoconOrphans); + AGGREGATES.hoconKey += c.total; + AGGREGATES.hoconBound += c.bound; + AGGREGATES.hoconAllowlisted += c.allowlisted.size(); + AGGREGATES.beans.add(beanClass); + failOnHoconOrphans(sectionPath, beanClass, c); + } + + /** Overload for meta-tests: walks the supplied Config directly, skips AGGREGATES. */ + static void assertNoHoconOrphans( + String label, Config section, Class beanClass, + Set allowedHoconOrphans) { + OrphanCounters c = walkAndLogHoconOrphans( + label, section, beanClass, allowedHoconOrphans); + failOnHoconOrphans(label, beanClass, c); + } + + private static OrphanCounters walkAndLogHoconOrphans( + String label, Config section, Class beanClass, Set allowed) { + OrphanCounters c = new OrphanCounters(); + walkHoconOrphans(beanClass, section, "", allowed, c); + logger.info("[parity-hocon] {} -> {}: hoconKey={}, bound={}, allowlisted={}{}", + label, beanClass.getSimpleName(), c.total, c.bound, + c.allowlisted.size(), c.allowlisted.isEmpty() ? "" : " " + c.allowlisted); + return c; + } + + private static void failOnHoconOrphans( + String label, Class beanClass, OrphanCounters c) { + if (!c.shapeMismatches.isEmpty()) { + throw new AssertionError( + "reference.conf has " + label + ".* keys whose HOCON value " + + "shape does not match the bean property type (expected OBJECT " + + "for nested *Config bean): " + c.shapeMismatches); + } + if (!c.orphans.isEmpty()) { + throw new AssertionError( + "reference.conf has " + label + ".* keys with no matching " + + beanClass.getSimpleName() + " property (at any nesting level) " + + "and not in allowlist: " + c.orphans); + } + } + + private static void walkHoconOrphans( + Class beanClass, Config section, String prefix, + Set allowed, OrphanCounters c) { + Map props = writablePropertyDescriptors(beanClass); + for (String key : new TreeSet<>(section.root().keySet())) { + c.total++; + String qualified = prefix + key; + PropertyDescriptor pd = props.get(key); + if (pd == null) { + if (allowed.contains(qualified)) { + c.allowlisted.add(qualified); + } else { + c.orphans.add(qualified); + } + continue; + } + c.bound++; + Class type = pd.getPropertyType(); + if (isRecursiveConfigBean(type)) { + ConfigValueType valueType = section.root().get(key).valueType(); + if (valueType != ConfigValueType.OBJECT) { + c.shapeMismatches.add(qualified + " (bean type " + + type.getSimpleName() + " requires OBJECT, got " + valueType + ")"); + continue; + } + walkHoconOrphans(type, section.getConfig(key), qualified + ".", allowed, c); + } + } + } + + /** + * Fails when a writable bean property (reachable from {@code beanClass} + * through nested {@code *Config} recursion) has no HOCON key under + * {@code sectionPath} and is not in {@code allowedBeanOrphans}. + */ + static void assertNoBeanOrphans( + String sectionPath, Class beanClass, Set allowedBeanOrphans) { + Config section = ConfigFactory.defaultReference().getConfig(sectionPath); + OrphanCounters c = walkAndLogBeanOrphans( + sectionPath, section, beanClass, allowedBeanOrphans); + AGGREGATES.beanKey += c.total; + AGGREGATES.beanHasKey += c.bound; + AGGREGATES.beanAllowlisted += c.allowlisted.size(); + AGGREGATES.beans.add(beanClass); + failOnBeanOrphans(sectionPath, beanClass, c); + } + + /** Overload for meta-tests: walks the supplied Config directly, skips AGGREGATES. */ + static void assertNoBeanOrphans( + String label, Config section, Class beanClass, + Set allowedBeanOrphans) { + OrphanCounters c = walkAndLogBeanOrphans( + label, section, beanClass, allowedBeanOrphans); + failOnBeanOrphans(label, beanClass, c); + } + + private static OrphanCounters walkAndLogBeanOrphans( + String label, Config section, Class beanClass, Set allowed) { + OrphanCounters c = new OrphanCounters(); + walkBeanOrphans(beanClass, section, "", allowed, c); + logger.info("[parity-bean] {} -> {}: beanKey={}, hasKey={}, allowlisted={}{}", + label, beanClass.getSimpleName(), c.total, c.bound, + c.allowlisted.size(), c.allowlisted.isEmpty() ? "" : " " + c.allowlisted); + return c; + } + + private static void failOnBeanOrphans( + String label, Class beanClass, OrphanCounters c) { + if (!c.shapeMismatches.isEmpty()) { + throw new AssertionError( + beanClass.getSimpleName() + " has nested *Config properties whose " + + "HOCON value shape under " + label + ".* is not OBJECT: " + + c.shapeMismatches); + } + if (!c.orphans.isEmpty()) { + throw new AssertionError( + beanClass.getSimpleName() + " has properties with no matching " + + label + ".* HOCON key (at any nesting level) " + + "and not in allowlist: " + c.orphans); + } + } + + private static void walkBeanOrphans( + Class beanClass, Config section, String prefix, + Set allowed, OrphanCounters c) { + Set keys = section.root().keySet(); + Map props = writablePropertyDescriptors(beanClass); + for (Map.Entry e : props.entrySet()) { + c.total++; + String name = e.getKey(); + String qualified = prefix + name; + PropertyDescriptor pd = e.getValue(); + if (!keys.contains(name)) { + if (allowed.contains(qualified)) { + c.allowlisted.add(qualified); + } else { + c.orphans.add(qualified); + } + continue; + } + c.bound++; + Class type = pd.getPropertyType(); + if (isRecursiveConfigBean(type)) { + ConfigValueType valueType = section.root().get(name).valueType(); + if (valueType != ConfigValueType.OBJECT) { + c.shapeMismatches.add(qualified + " (bean type " + + type.getSimpleName() + " requires OBJECT, got " + valueType + ")"); + continue; + } + walkBeanOrphans(type, section.getConfig(name), qualified + ".", allowed, c); + } + } + } + + /** Build an immutable allowlist from string literals. */ + static Set allowlist(String... names) { + Set s = new HashSet<>(Arrays.asList(names)); + return Collections.unmodifiableSet(s); + } + + /** + * Fails when an allowlist entry no longer resolves to a live target. Prevents + * allowlist rot: a renamed/removed key/property must drop its grandfathering + * entry in the same PR (cf. Cassandra's PROPERTIES_TO_IGNORE long-term decay). + */ + static void assertAllowlistEntriesAreLive( + String sectionPath, Class beanClass, + Set allowedHoconOrphans, + Set allowedBeanOrphans, + Set allowedDivergent) { + Config section = ConfigFactory.defaultReference().getConfig(sectionPath); + runAllowlistEntriesAreLive(sectionPath, section, beanClass, + allowedHoconOrphans, allowedBeanOrphans, allowedDivergent); + } + + /** Overload for meta-tests: see {@link #assertNoHoconOrphans(String, Config, Class, Set)}. */ + static void assertAllowlistEntriesAreLive( + String label, Config section, Class beanClass, + Set allowedHoconOrphans, + Set allowedBeanOrphans, + Set allowedDivergent) { + runAllowlistEntriesAreLive(label, section, beanClass, + allowedHoconOrphans, allowedBeanOrphans, allowedDivergent); + } + + private static void runAllowlistEntriesAreLive( + String label, Config section, Class beanClass, + Set allowedHoconOrphans, + Set allowedBeanOrphans, + Set allowedDivergent) { + List dead = new ArrayList<>(); + + for (String k : allowedHoconOrphans) { + if (!section.hasPath(k)) { + dead.add("hoconOrphan: " + k + " (no longer in reference.conf[" + label + "])"); + } + } + for (String k : allowedBeanOrphans) { + if (!beanPropertyExists(beanClass, k)) { + dead.add("beanOrphan: " + k + " (no longer a writable property of " + + beanClass.getSimpleName() + ")"); + } + } + for (String k : allowedDivergent) { + boolean hoconLive = section.hasPath(k); + boolean beanLive = beanPropertyExists(beanClass, k); + if (!hoconLive || !beanLive) { + dead.add("divergent: " + k + + " (hocon=" + (hoconLive ? "live" : "dead") + + ", bean=" + (beanLive ? "live" : "dead") + ")"); + } + } + + logger.info("[parity-sweep] {} -> {}: hoconOrphans={}, beanOrphans={}, divergent={}, dead={}", + label, beanClass.getSimpleName(), + allowedHoconOrphans.size(), allowedBeanOrphans.size(), + allowedDivergent.size(), dead.size()); + + if (!dead.isEmpty()) { + throw new AssertionError( + "Dead allowlist entries on " + label + " / " + + beanClass.getSimpleName() + " — drop them or restore the " + + "underlying key/property:\n " + String.join("\n ", dead)); + } + } + + /** True iff dotted {@code qualifiedName} resolves to a writable bean property. */ + private static boolean beanPropertyExists(Class beanClass, String qualifiedName) { + Class cursor = beanClass; + String[] segments = qualifiedName.split("\\."); + for (int i = 0; i < segments.length; i++) { + PropertyDescriptor pd = writablePropertyDescriptors(cursor).get(segments[i]); + if (pd == null) { + return false; + } + if (i == segments.length - 1) { + return true; + } + Class type = pd.getPropertyType(); + if (!isRecursiveConfigBean(type)) { + return false; + } + cursor = type; + } + return true; + } + + /** Sentinel: property type outside the dispatcher matrix — hard failure. */ + private static final Object SKIP = new Object(); + + /** Sentinel: property type is a nested {@code *Config} bean to recurse into. */ + private static final Object RECURSE = new Object(); + + /** + * Asserts every writable bean property has a default value equal to its + * reference.conf value. Supported scalar types: {@code int / long / boolean / + * double / float} (and boxed forms), {@code String}, {@code List}. Nested + * {@code *Config} beans are recursed into and matched by dotted name. + *

+ * Skipped: properties with no HOCON key at the current scope, and properties + * named in {@code allowedDivergent} (intentional asymmetry). Properties whose + * type isn't in the dispatcher matrix fail — no silent escape; extend the + * dispatcher or (if genuinely uncomparable) re-introduce a per-section + * {@code typeSkip} allowlist. + */ + static void assertDefaultValuesMatch( + String sectionPath, Class beanClass, Set allowedDivergent) { + Config section = ConfigFactory.defaultReference().getConfig(sectionPath); + Counters c = new Counters(); + List mismatches = runDefaultValuesMatch( + sectionPath, section, beanClass, allowedDivergent, c); + + AGGREGATES.defBeanKey += c.total; + AGGREGATES.defMatched += c.matched; + AGGREGATES.defHoconRecursedKey += c.recursed; + AGGREGATES.defSkipAllow += c.skipAllow.size(); + AGGREGATES.defSkipNoKey += c.skipNoKey.size(); + AGGREGATES.beans.add(beanClass); + + failOnDefaultValueMismatches(sectionPath, beanClass, mismatches); + } + + /** Overload for meta-tests: see {@link #assertNoHoconOrphans(String, Config, Class, Set)}. */ + static void assertDefaultValuesMatch( + String label, Config section, Class beanClass, + Set allowedDivergent) { + Counters c = new Counters(); + List mismatches = runDefaultValuesMatch( + label, section, beanClass, allowedDivergent, c); + failOnDefaultValueMismatches(label, beanClass, mismatches); + } + + private static List runDefaultValuesMatch( + String label, Config section, Class beanClass, + Set allowedDivergent, Counters c) { + Object bean; + try { + bean = beanClass.getDeclaredConstructor().newInstance(); + } catch (ReflectiveOperationException e) { + throw new AssertionError("cannot instantiate " + beanClass.getName(), e); + } + List mismatches = new ArrayList<>(); + compareBean(beanClass, bean, section, "", allowedDivergent, mismatches, c); + logger.info("[parity-default] {} -> {}: beanKey={}, matched={}, hoconRecursedKey={}, " + + "divergent-allow={}{}, skip-no-key={}{}", + label, beanClass.getSimpleName(), + c.total, c.matched, c.recursed, + c.skipAllow.size(), c.skipAllow.isEmpty() ? "" : " " + c.skipAllow, + c.skipNoKey.size(), c.skipNoKey.isEmpty() ? "" : " " + c.skipNoKey); + return mismatches; + } + + private static void failOnDefaultValueMismatches( + String label, Class beanClass, List mismatches) { + if (!mismatches.isEmpty()) { + throw new AssertionError( + "Default-value drift between " + beanClass.getSimpleName() + + " and reference.conf[" + label + "]:\n " + + String.join("\n ", mismatches)); + } + } + + /** + * Per-walk accounting. Invariant: {@code total == matched + recursed + + * skipAllow.size() + skipNoKey.size() + mismatches.size()}. Adding a loop + * exit without bumping a counter silently hides coverage drift. + */ + private static final class Counters { + int total; + int matched; + int recursed; + final Set skipAllow = new TreeSet<>(); + final Set skipNoKey = new TreeSet<>(); + } + + private static void compareBean( + Class beanClass, Object beanDefault, Config section, String prefix, + Set allowedDivergent, List mismatches, Counters c) { + PropertyDescriptor[] pds; + try { + pds = Introspector.getBeanInfo(beanClass, Object.class).getPropertyDescriptors(); + } catch (java.beans.IntrospectionException e) { + throw new AssertionError(e); + } + for (PropertyDescriptor pd : pds) { + if (pd.getWriteMethod() == null) { + // @Setter(NONE) for manual post-bind reads — orphan checks cover this side. + continue; + } + c.total++; + String name = pd.getName(); + String qualified = prefix + name; + if (pd.getReadMethod() == null) { + // Write-only property: ConfigBeanFactory binds but nothing reads it back. + mismatches.add(qualified + ": bean property is write-only " + + "(setter present, no getter) — default value cannot be verified " + + "and the bound value cannot be observed; add a getter or drop the field"); + continue; + } + if (allowedDivergent.contains(qualified)) { + c.skipAllow.add(qualified); + continue; + } + if (!section.hasPath(name)) { + c.skipNoKey.add(qualified); + continue; + } + Class type = pd.getPropertyType(); + // Shape guard: nested *Config bean expects HOCON OBJECT; surface as a + // clean mismatch instead of letting getConfig(name) throw WrongType. + if (isRecursiveConfigBean(type)) { + ConfigValueType valueType = section.root().get(name).valueType(); + if (valueType != ConfigValueType.OBJECT) { + mismatches.add(qualified + ": bean type " + type.getSimpleName() + + " requires HOCON OBJECT, got " + valueType); + continue; + } + } + Object hoconValue; + try { + hoconValue = readTypedHoconValue(section, name, type); + } catch (RuntimeException e) { + mismatches.add(qualified + ": type-incompatible HOCON value (" + + e.getClass().getSimpleName() + ": " + e.getMessage() + ")"); + continue; + } + if (hoconValue == RECURSE) { + c.recursed++; + Object nested; + try { + nested = pd.getReadMethod().invoke(beanDefault); + } catch (ReflectiveOperationException e) { + throw new AssertionError( + "cannot read " + qualified + " on " + beanClass.getName(), e); + } + if (nested == null) { + mismatches.add(qualified + ": nested " + type.getSimpleName() + + " field is null on a freshly-constructed " + beanClass.getSimpleName() + + " — initialize the field inline (= new " + type.getSimpleName() + "())"); + continue; + } + compareBean(type, nested, section.getConfig(name), qualified + ".", + allowedDivergent, mismatches, c); + continue; + } + if (hoconValue == SKIP) { + mismatches.add(qualified + ": Java type " + type.getSimpleName() + + " not in readTypedHoconValue dispatcher — extend the dispatcher, " + + "or re-introduce a per-section typeSkip allowlist if the type " + + "genuinely cannot be value-compared"); + continue; + } + Object actualDefault; + try { + actualDefault = pd.getReadMethod().invoke(beanDefault); + } catch (ReflectiveOperationException e) { + throw new AssertionError( + "cannot read " + qualified + " on " + beanClass.getName(), e); + } + if (!Objects.equals(actualDefault, hoconValue)) { + // Stamp the runtime type on each side so e.g. Integer(10) vs Long(10) + // doesn't look like `bean=10, reference.conf=10`. + mismatches.add(qualified + ": bean=" + format(actualDefault) + + " (" + typeOf(actualDefault) + ")" + + ", reference.conf=" + format(hoconValue) + + " (" + typeOf(hoconValue) + ")"); + continue; + } + c.matched++; + } + } + + /** Type dispatcher. Returns {@link #RECURSE} for nested *Config, {@link #SKIP} otherwise. */ + private static Object readTypedHoconValue(Config cfg, String path, Class type) { + if (type == int.class || type == Integer.class) { + return cfg.getInt(path); + } + if (type == long.class || type == Long.class) { + return cfg.getLong(path); + } + if (type == boolean.class || type == Boolean.class) { + return cfg.getBoolean(path); + } + if (type == double.class || type == Double.class) { + return cfg.getDouble(path); + } + if (type == float.class || type == Float.class) { + return (float) cfg.getDouble(path); + } + if (type == String.class) { + return cfg.getString(path); + } + if (type == List.class) { + return cfg.getList(path).unwrapped(); + } + if (isRecursiveConfigBean(type) && cfg.hasPath(path)) { + return RECURSE; + } + return SKIP; + } + + /** + * Recursion gate: a non-array/enum/interface class under {@code org.tron.*} + * with a default constructor. Keeps the walker inside project-owned beans. + */ + private static boolean isRecursiveConfigBean(Class type) { + if (type.isPrimitive() || type.isArray() || type.isEnum() || type.isInterface()) { + return false; + } + if (!type.getName().startsWith("org.tron.")) { + return false; + } + try { + type.getDeclaredConstructor(); + return true; + } catch (NoSuchMethodException e) { + return false; + } + } + + /** + * Cross-section accumulators. Bumped by each helper at the end of its work + * (before throwing) so partial coverage is still reflected. + * {@link #logAggregateSummary} emits one summary line per gate plus + * independently-computed reference totals as a sanity check. + */ + private static final class Aggregates { + int hoconKey; + int hoconBound; + int hoconAllowlisted; + int beanKey; + int beanHasKey; + int beanAllowlisted; + int defBeanKey; + int defMatched; + int defHoconRecursedKey; + int defSkipAllow; + int defSkipNoKey; + // root-level bean classes touched by any helper; recursion walks nested *Config on its own. + final Set> beans = new LinkedHashSet<>(); + } + + private static final Aggregates AGGREGATES = new Aggregates(); + + /** Reset accumulators. Call from {@code @BeforeClass} for clean re-runs in the same JVM. */ + static void resetAggregates() { + AGGREGATES.hoconKey = 0; + AGGREGATES.hoconBound = 0; + AGGREGATES.hoconAllowlisted = 0; + AGGREGATES.beanKey = 0; + AGGREGATES.beanHasKey = 0; + AGGREGATES.beanAllowlisted = 0; + AGGREGATES.defBeanKey = 0; + AGGREGATES.defMatched = 0; + AGGREGATES.defHoconRecursedKey = 0; + AGGREGATES.defSkipAllow = 0; + AGGREGATES.defSkipNoKey = 0; + AGGREGATES.beans.clear(); + } + + /** + * Emit per-gate totals + file-coverage alignment + * {@code file-hoconKey == checkSection + cantCheckSection} and bean-tree + * alignment across {@code parity-bean} / {@code parity-default} / the + * independently-counted registry total. Reviewers can sum columns visually + * to spot a walker that silently skipped a property. + * + * @param checkSectionTopLevels top-level keys hosting a registered Section + * @param cantCheckSectionTopLevels remaining top-level keys (out of parity scope) + */ + static void logAggregateSummary( + Set checkSectionTopLevels, + Set cantCheckSectionTopLevels) { + ConfigObject refRoot = ConfigFactory.parseResources("reference.conf").root(); + int hoconKeyInFile = countHoconKeysRecursive(refRoot); + int checkSectionKey = sumTopLevelSubtreeSize(refRoot, checkSectionTopLevels); + int cantCheckSectionKey = sumTopLevelSubtreeSize(refRoot, cantCheckSectionTopLevels); + + int beanKeyInRegistry = 0; + for (Class b : AGGREGATES.beans) { + beanKeyInRegistry += countBeanSettersRecursive(b); + } + + logger.info("[parity-summary] parity-hocon : hoconKey={}, bound={}, allowlisted={}", + AGGREGATES.hoconKey, AGGREGATES.hoconBound, AGGREGATES.hoconAllowlisted); + logger.info("[parity-summary] parity-bean : beanKey={}, hasKey={}, allowlisted={}", + AGGREGATES.beanKey, AGGREGATES.beanHasKey, AGGREGATES.beanAllowlisted); + logger.info("[parity-summary] parity-default: beanKey={}, matched={}, hoconRecursedKey={}, " + + "divergent-allow={}, skip-no-key={}", + AGGREGATES.defBeanKey, AGGREGATES.defMatched, AGGREGATES.defHoconRecursedKey, + AGGREGATES.defSkipAllow, AGGREGATES.defSkipNoKey); + logger.info("[parity-summary] checkSection {} top-levels {}: hoconKey={} " + + "(= parity-hocon-walked({}) + path-segments-and-internal({}))", + checkSectionTopLevels.size(), checkSectionTopLevels, checkSectionKey, + AGGREGATES.hoconKey, checkSectionKey - AGGREGATES.hoconKey); + logger.info("[parity-summary] cantCheckSection {} top-levels {}: hoconKey={} " + + "(validation skipped; not in checkSection scope)", + cantCheckSectionTopLevels.size(), cantCheckSectionTopLevels, + cantCheckSectionKey); + logger.info("[parity-summary] hocon-align : file-hoconKey({}) = " + + "checkSection({}) + cantCheckSection({})", + hoconKeyInFile, checkSectionKey, cantCheckSectionKey); + logger.info("[parity-summary] bean-align : registry-beanKey({}, across {} bean classes) " + + "= parity-bean({}) = parity-default({})", + beanKeyInRegistry, AGGREGATES.beans.size(), + AGGREGATES.beanKey, AGGREGATES.defBeanKey); + } + + private static int sumTopLevelSubtreeSize(ConfigObject refRoot, Set topLevelKeys) { + int n = 0; + for (String k : topLevelKeys) { + if (!refRoot.containsKey(k)) { + continue; + } + n++; + ConfigValue v = refRoot.get(k); + if (v.valueType() == ConfigValueType.OBJECT) { + n += countHoconKeysRecursive((ConfigObject) v); + } + } + return n; + } + + private static int countHoconKeysRecursive(ConfigObject obj) { + int n = 0; + for (String k : obj.keySet()) { + n++; + ConfigValue v = obj.get(k); + if (v.valueType() == ConfigValueType.OBJECT) { + n += countHoconKeysRecursive((ConfigObject) v); + } + } + return n; + } + + private static int countBeanSettersRecursive(Class beanClass) { + int n = 0; + for (PropertyDescriptor pd : writablePropertyDescriptors(beanClass).values()) { + n++; + Class t = pd.getPropertyType(); + if (isRecursiveConfigBean(t)) { + n += countBeanSettersRecursive(t); + } + } + return n; + } + + private static String typeOf(Object o) { + return o == null ? "null" : o.getClass().getSimpleName(); + } + + private static String format(Object o) { + if (o == null) { + return "null"; + } + if (o instanceof String) { + return "\"" + o + "\""; + } + if (o instanceof List) { + List list = (List) o; + StringBuilder sb = new StringBuilder("["); + for (int i = 0; i < list.size(); i++) { + if (i > 0) { + sb.append(", "); + } + sb.append(format(list.get(i))); + } + sb.append("]"); + return sb.toString(); + } + return String.valueOf(o); + } +} diff --git a/common/src/test/java/org/tron/core/config/args/ConfigParityGateTest.java b/common/src/test/java/org/tron/core/config/args/ConfigParityGateTest.java new file mode 100644 index 0000000000..67b02e556a --- /dev/null +++ b/common/src/test/java/org/tron/core/config/args/ConfigParityGateTest.java @@ -0,0 +1,238 @@ +package org.tron.core.config.args; + +import com.typesafe.config.Config; +import com.typesafe.config.ConfigFactory; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; + +/** + * Build-time gate that pins every (section, bean) tuple in {@link #SECTIONS} + * so the entire reference.conf <-> {@code *Config} contract is managed in + * one place. Drift fails the build at PR time instead of waiting for + * {@code ConfigBeanFactory} to throw at process startup. + *

+ * Per-section {@code *ConfigTest} files cover behavioural tests (defaults, + * clamps, alias fallbacks); they do not own parity. Adding a new + * {@code *Config} bean: add a {@link Section} entry below. Adding an + * allowlist entry: include an inline rationale comment; new keys are + * expected to bind 1:1 via {@code ConfigBeanFactory} without exception. + */ +public class ConfigParityGateTest { + + private static final class Section { + final String path; + final Class bean; + final Set hoconOrphans; + final Set beanOrphans; + final Set divergent; + + Section(String path, Class bean, + Set hoconOrphans, Set beanOrphans, + Set divergent) { + this.path = path; + this.bean = bean; + this.hoconOrphans = hoconOrphans; + this.beanOrphans = beanOrphans; + this.divergent = divergent; + } + } + + // legacy acronym casing; normalizeNonStandardKeys renames PBFT -> Pbft before bind + private static final Set COMMITTEE_HOCON_ORPHANS = + ConfigParityCheck.allowlist( + "allowPBFT", + "pBFTExpireNum" + ); + + private static final Set COMMITTEE_BEAN_ORPHANS = + ConfigParityCheck.allowlist( + "allowPbft", // bound from HOCON allowPBFT via normalize hook + "pbftExpireNum" // bound from HOCON pBFTExpireNum via normalize hook + ); + + // native: Java reserved word; bound to bean field nativeQueue, read manually after bind. + // topics: list items have optional fields; EventConfig binds the list manually with + // TOPIC_DEFAULTS fallback (field uses @Setter(NONE)). + private static final Set EVENT_HOCON_ORPHANS = + ConfigParityCheck.allowlist( + "native", + "topics" + ); + + // FilterConfig: reference.conf ships [""] as a schema placeholder so operators see + // the expected element type; bean default is [] (genuinely empty). Both mean "no filter". + private static final Set EVENT_DIVERGENT_DEFAULTS = + ConfigParityCheck.allowlist( + "filter.contractAddress", // bean=[] vs reference.conf=[""] schema placeholder + "filter.contractTopic" // bean=[] vs reference.conf=[""] schema placeholder + ); + + // Genesis fields are mainnet seed data with no sensible in-Java default. + private static final Set GENESIS_DIVERGENT_DEFAULTS = + ConfigParityCheck.allowlist( + "timestamp", // mainnet genesis timestamp, no in-Java default + "parentHash", // mainnet genesis parentHash, no in-Java default + "assets", // seed accounts (Zion / Sun / Blackhole); bean ships empty list + "witnesses" // 27 standby witness nodes; bean ships empty list + ); + + private static final Set NODE_HOCON_ORPHANS = + ConfigParityCheck.allowlist( + "isOpenFullTcpDisconnect", // normalized to bean field openFullTcpDisconnect + "metrics" // delegated to MetricsConfig.fromConfig + ); + + private static final Set NODE_BEAN_ORPHANS = + ConfigParityCheck.allowlist( + "openFullTcpDisconnect" // HOCON ships isOpenFullTcpDisconnect; renamed + ); + + private static final Set NODE_DIVERGENT_DEFAULTS = + ConfigParityCheck.allowlist( + "fastForward" // seed node list, no Java-side default + ); + + // Top-level meta-gate: every reference.conf top-level key must be covered by a + // Section entry above or listed here with a rationale. Closes the "new section + // sneaks in" hole. See everyReferenceConfTopLevelKeyIsCovered. + private static final Set TOP_LEVEL_NON_BEAN = + ConfigParityCheck.allowlist( + "crypto", // MiscConfig.cryptoEngine manual-read root + "enery", // MiscConfig manual-read root (preserves historical typo of "energy") + "localwitness", // bound by LocalWitnessConfig, not in the *ConfigBean factory pattern + "net", // deprecated wrapper for net.type; intentionally empty in reference.conf + "seed", // MiscConfig.seedNodeIpList manual-read root (seed.node.ip.list) + "trx" // MiscConfig.trxReferenceBlock manual-read root (trx.reference.block) + ); + + private static final List

SECTIONS; + + static { + Set empty = Collections.emptySet(); + List
s = new ArrayList<>(); + // ctor args: (path, beanClass, hoconOrphans, beanOrphans, divergent) + s.add(new Section("block", BlockConfig.class, + empty, empty, empty)); + s.add(new Section("committee", CommitteeConfig.class, + COMMITTEE_HOCON_ORPHANS, COMMITTEE_BEAN_ORPHANS, empty)); + s.add(new Section("event.subscribe", EventConfig.class, + EVENT_HOCON_ORPHANS, empty, EVENT_DIVERGENT_DEFAULTS)); + s.add(new Section("genesis.block", GenesisConfig.class, + empty, empty, GENESIS_DIVERGENT_DEFAULTS)); + s.add(new Section("node", NodeConfig.class, + NODE_HOCON_ORPHANS, NODE_BEAN_ORPHANS, NODE_DIVERGENT_DEFAULTS)); + s.add(new Section("node.metrics", MetricsConfig.class, + empty, empty, empty)); + s.add(new Section("rate.limiter", RateLimiterConfig.class, + empty, empty, empty)); + s.add(new Section("storage", StorageConfig.class, + empty, empty, empty)); + s.add(new Section("vm", VmConfig.class, + empty, empty, empty)); + SECTIONS = Collections.unmodifiableList(s); + } + + @BeforeClass + public static void resetAggregates() { + ConfigParityCheck.resetAggregates(); + } + + /** Emit cross-section [parity-summary] totals + file-coverage alignment. */ + @AfterClass + public static void logAggregateSummary() { + Set checkSectionTopLevels = new TreeSet<>(); + for (Section s : SECTIONS) { + checkSectionTopLevels.add(s.path.split("\\.", 2)[0]); + } + ConfigParityCheck.logAggregateSummary( + checkSectionTopLevels, TOP_LEVEL_NON_BEAN); + } + + @Test + public void hoconKeysAreBound() { + for (Section s : SECTIONS) { + ConfigParityCheck.assertNoHoconOrphans(s.path, s.bean, s.hoconOrphans); + } + } + + @Test + public void beanPropertiesHaveHoconKeys() { + for (Section s : SECTIONS) { + ConfigParityCheck.assertNoBeanOrphans(s.path, s.bean, s.beanOrphans); + } + } + + @Test + public void defaultValuesMatch() { + List failures = new ArrayList<>(); + for (Section s : SECTIONS) { + try { + ConfigParityCheck.assertDefaultValuesMatch( + s.path, s.bean, s.divergent); + } catch (AssertionError e) { + failures.add(e.getMessage()); + } + } + if (!failures.isEmpty()) { + throw new AssertionError( + failures.size() + " section(s) failed default-value parity:\n\n" + + String.join("\n\n", failures)); + } + } + + /** + * Fails when any allowlist entry no longer resolves to a live HOCON path or + * bean property — i.e. the underlying key/property was renamed or removed + * but the grandfathering entry was left behind. + */ + @Test + public void allowlistEntriesAreLive() { + List failures = new ArrayList<>(); + for (Section s : SECTIONS) { + try { + ConfigParityCheck.assertAllowlistEntriesAreLive( + s.path, s.bean, s.hoconOrphans, s.beanOrphans, s.divergent); + } catch (AssertionError e) { + failures.add(e.getMessage()); + } + } + if (!failures.isEmpty()) { + throw new AssertionError( + failures.size() + " section(s) have dead allowlist entries:\n\n" + + String.join("\n\n", failures)); + } + } + + /** + * Fails when reference.conf grows a top-level key not covered by a Section + * or {@link #TOP_LEVEL_NON_BEAN}. Uses {@code parseResources} so JVM system + * properties don't pollute the top-level key set. + */ + @Test + public void everyReferenceConfTopLevelKeyIsCovered() { + Config refFile = ConfigFactory.parseResources("reference.conf"); + Set topKeys = new TreeSet<>(refFile.root().keySet()); + Set covered = new TreeSet<>(); + for (Section s : SECTIONS) { + covered.add(s.path.split("\\.", 2)[0]); + } + covered.addAll(TOP_LEVEL_NON_BEAN); + + Set orphans = new TreeSet<>(topKeys); + orphans.removeAll(covered); + if (!orphans.isEmpty()) { + throw new AssertionError( + "reference.conf has top-level keys not covered by SECTIONS and not in " + + "TOP_LEVEL_NON_BEAN: " + orphans + + ". Either add a new Section entry (preferred — auto-binds via " + + "*Config bean) or register the key under TOP_LEVEL_NON_BEAN with " + + "an inline rationale."); + } + } +}