Background
Issue #476 fixed the rule-graph object blow-up (~521k → low tens of thousands of distinct rule objects) by memoizing shared detection-rule subtrees. Memoization is currently opt-in boilerplate added per class:
private static final Supplier<List<IDetectionRule<Tree>>> RULES = Memoize.of(() -> buildRules(null));
@Nonnull public static List<IDetectionRule<Tree>> rules() { return RULES.get(); }
Every rule class is a final utility with a private constructor and a static rules(). Because the memoization lives on static methods, the type system cannot enforce it — a new rule class can silently forget to memoize, reintroducing the blow-up. The aggregate footprint guard (RuleGraphMemoizationTest) will not catch a single un-memoized class among 130+.
A follow-up PR adds a reflective CI guard (asserts X.rules() returns the same List identity across calls) + finishes memoizing the JCA/SSL stragglers. That makes forgetting a build failure. This issue tracks the stronger, structural alternative.
Proposal (Option A): abstract base + instances
Convert each rule class from a static utility into a singleton of an abstract base whose final rules() memoizes and cannot be bypassed:
public abstract class DetectionRuleSet {
private final Supplier<List<IDetectionRule<Tree>>> memo = Memoize.of(() -> buildRules(null));
public final List<IDetectionRule<Tree>> rules() { return memo.get(); }
public List<IDetectionRule<Tree>> rules(@Nullable IDetectionContext ctx) {
return ctx == null ? memo.get() : buildRules(ctx);
}
protected abstract List<IDetectionRule<Tree>> buildRules(@Nullable IDetectionContext ctx);
}
A subclass author only implements buildRules(...) and gets memoization for free — impossible to forget at compile time. The 8 classes that expose both rules() and rules(ctx) fold cleanly into the base's two methods.
Measured blast radius (java module)
| Dimension |
Finding |
| Rule classes to rewrite |
135 — all public final class + private ctor, none already extends/implements (no conflict) |
| Call sites to update |
~417 (408 in src/main, 9 in src/test): X.rules( → X.INSTANCE.rules( |
| Cross-module churn |
Zero — engine/mapper/enricher/output/common/sonar-cryptography-plugin have no .rules( call sites |
| Init-order risk |
None — no rules() calls in static field/initializer blocks; the memo field stays lazy as today |
| Dual-signature classes |
8 (15 parametrized call sites) fold into base rules() + rules(ctx) |
| Python module |
Out of scope — different tree type (IDetectionRule<PythonTree>); would need its own parallel base (24 main + 8 test sites) |
Total ≈ 135 classes + ~417 call sites, purely mechanical, guarded by the existing <60k footprint test + full detection suite.
Trade-off vs. the CI-guard approach
- This issue (structural): compiler makes forgetting impossible; ~550 edits, Java-only.
- CI guard (shipped separately): build fails if forgotten; ~51 edits + 1 test, zero call-site churn, and the same reflective test extends to Python.
Both deliver "every new rule is memoized automatically." This issue is worth doing only if we also want to move off the static-utility style across the rule tree; otherwise the CI guard is sufficient.
Acceptance criteria
Background
Issue #476 fixed the rule-graph object blow-up (~521k → low tens of thousands of distinct rule objects) by memoizing shared detection-rule subtrees. Memoization is currently opt-in boilerplate added per class:
Every rule class is a
finalutility with a private constructor and a staticrules(). Because the memoization lives on static methods, the type system cannot enforce it — a new rule class can silently forget to memoize, reintroducing the blow-up. The aggregate footprint guard (RuleGraphMemoizationTest) will not catch a single un-memoized class among 130+.A follow-up PR adds a reflective CI guard (asserts
X.rules()returns the sameListidentity across calls) + finishes memoizing the JCA/SSL stragglers. That makes forgetting a build failure. This issue tracks the stronger, structural alternative.Proposal (Option A): abstract base + instances
Convert each rule class from a static utility into a singleton of an abstract base whose
final rules()memoizes and cannot be bypassed:A subclass author only implements
buildRules(...)and gets memoization for free — impossible to forget at compile time. The 8 classes that expose bothrules()andrules(ctx)fold cleanly into the base's two methods.Measured blast radius (java module)
public final class+ private ctor, none alreadyextends/implements(no conflict)408insrc/main,9insrc/test):X.rules(→X.INSTANCE.rules(engine/mapper/enricher/output/common/sonar-cryptography-pluginhave no.rules(call sitesrules()calls in static field/initializer blocks; the memo field stays lazy as todayrules()+rules(ctx)IDetectionRule<PythonTree>); would need its own parallel base (24 main + 8 test sites)Total ≈ 135 classes + ~417 call sites, purely mechanical, guarded by the existing
<60kfootprint test + full detection suite.Trade-off vs. the CI-guard approach
Both deliver "every new rule is memoized automatically." This issue is worth doing only if we also want to move off the static-utility style across the rule tree; otherwise the CI guard is sufficient.
Acceptance criteria
DetectionRuleSetabstract base withfinalmemoizingrules()and abstractbuildRules(ctx)RuleGraphMemoizationTeststill green (< 60k distinct objects)