Skip to content

Commit 16a6556

Browse files
Merge pull request #220 from refactorfirst/#215-handle-stack-overflow-sealed-depth
#215 Fix computeSealedDepth to handle cycle-safe ancestor traversal
2 parents 18e4000 + 970c668 commit 16a6556

3 files changed

Lines changed: 280 additions & 9 deletions

File tree

‎codebase-graph-builder/src/main/java/org/hjug/graphbuilder/metrics/ClassMetrics.java‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,14 @@ void freeze() {
149149
* sealed class has depth=1; each indirect descendant nests deeper.
150150
* Computed post-walk in
151151
* {@link GraphMetricsCollector#finalizeMetrics()}.
152+
*
153+
* <p>Cyclic-depth semantics: the depth is the longest acyclic
154+
* ancestor path terminating at a sealed root within the analyzed
155+
* batch; a class with no such path (including a cycle member whose
156+
* ancestor paths do not reach a sealed root, or a chain dead-ending at a
157+
* non-sealed in-batch class) has depth 0. Ancestors
158+
* absent from the batch preserve a minimum depth of 2 as a
159+
* relationship signal. (Cycle safety: issue #215.)
152160
*/
153161
@Getter
154162
private int sealedHierarchyDepth;

‎codebase-graph-builder/src/main/java/org/hjug/graphbuilder/metrics/GraphMetricsCollector.java‎

Lines changed: 59 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -261,9 +261,12 @@ public void finalizeMetrics() {
261261
* <ul>
262262
* <li>{@link ClassMetrics#setSealedHierarchyDepth(int)} — root sealed
263263
* class has depth 1; each direct permittee inherits depth 2, and
264-
* so on. Computed by walking up the sealed-hierarchy ancestor
265-
* chain until reaching a class whose {@link ClassMetrics#isSealed()}
266-
* flag is {@code false}.</li>
264+
* so on. The depth is the longest acyclic ancestor path that
265+
* terminates at a sealed root; classes with no such path (including
266+
* cycle members without a path to a sealed root and non-sealed
267+
* dead-ends) get depth 0 and are not marked.
268+
* Third-party ancestors absent from the batch preserve a minimum
269+
* depth of 2 as a relationship signal. (Cycle safety: issue #215.)</li>
267270
* <li>{@link ClassMetrics#setHasExplicitLogic(boolean)} — true when a
268271
* Kotlin {@code data class} declares any non-accessor method,
269272
* feeding the {@code (hasExplicitLogic || WMC > 14)} criterion of
@@ -286,30 +289,77 @@ private void computeKotlinDerivedMetrics() {
286289
}
287290
}
288291

292+
/**
293+
* Computes a class's depth in the collected sealed hierarchy.
294+
*
295+
* @return {@code 0} for a non-sealed class with no recorded ancestors, {@code 1}
296+
* for a sealed class with no recorded ancestors, {@code 2} when all
297+
* recorded ancestors are outside this collector, or one more than
298+
* the greatest depth among ancestors present in this collector
299+
*/
289300
private int computeSealedDepth(ClassMetrics metrics) {
301+
return computeSealedDepth(metrics, new HashSet<>());
302+
}
303+
304+
/**
305+
* Cycle-safe variant of {@link #computeSealedDepth(ClassMetrics)}. The
306+
* {@code visiting} set tracks the FQNs on the current ancestor-traversal
307+
* path; re-entering a class already on the path means the collected
308+
* ancestor data contains a cycle (issue #215: Java {@code implements}
309+
* chains recorded in a cycle, e.g. by type attribution without the full
310+
* classpath), so the traversal stops instead of recursing forever.
311+
*
312+
* <p><b>Cyclic-depth semantics:</b> a class's depth is the length of the
313+
* longest acyclic ancestor path that terminates at a sealed root
314+
* ({@code isSealed() == true} with no ancestors, depth 1) within the
315+
* analyzed batch. A class with no such path — including a cycle member
316+
* whose ancestor paths do not reach a sealed root, a class whose only
317+
* paths enter a cycle, or a class whose chain dead-ends at a non-sealed
318+
* class in the batch — has depth 0: it is not an observable member of any
319+
* sealed hierarchy. Ancestors absent from the batch
320+
* (third-party) preserve the minimum depth of 2 as a relationship signal.
321+
*
322+
* @param visiting the class FQNs on the current ancestor-traversal path
323+
* @return {@code 0} when this class is already on that path; otherwise its
324+
* depth from the collected ancestors
325+
*/
326+
private int computeSealedDepth(ClassMetrics metrics, Set<String> visiting) {
290327
Set<String> ancestors = metrics.getSealedHierarchyAncestors();
291328
if (ancestors.isEmpty()) {
292329
// No sealed hierarchy ancestors: depth 1 if sealed, 0 otherwise
293330
return metrics.isSealed() ? 1 : 0;
294331
}
332+
if (!visiting.add(metrics.getFullyQualifiedName())) {
333+
// Cycle: this path has no valid depth contribution
334+
return 0;
335+
}
295336
// Has sealed hierarchy ancestors: traverse them first
296337
int maxAncestorDepth = 0;
297-
boolean hasObservableAncestor = false;
338+
boolean hasExternalAncestor = false;
298339
for (String ancestorFqn : ancestors) {
299340
ClassMetrics ancestor = classMetrics.get(ancestorFqn);
300341
if (ancestor == null) {
301342
// Ancestor not in this codebase batch (third-party): preserve
302343
// minimum depth of 2 to record the relationship
344+
hasExternalAncestor = true;
303345
continue;
304346
}
305-
hasObservableAncestor = true;
306-
maxAncestorDepth = Math.max(maxAncestorDepth, computeSealedDepth(ancestor));
347+
// Dead-end paths (depth 0) contribute nothing; only paths that
348+
// reach a sealed root propagate a positive depth upward
349+
maxAncestorDepth = Math.max(maxAncestorDepth, computeSealedDepth(ancestor, visiting));
350+
}
351+
visiting.remove(metrics.getFullyQualifiedName());
352+
if (maxAncestorDepth > 0) {
353+
return maxAncestorDepth + 1;
307354
}
308-
if (!hasObservableAncestor) {
309-
// All ancestors are external; preserve depth 2 to record relationship
355+
if (hasExternalAncestor) {
356+
// No sealed root reachable in-batch, but external ancestors may
357+
// hold the root; preserve depth 2 to record relationship
310358
return 2;
311359
}
312-
return maxAncestorDepth + 1;
360+
// No ancestor path reaches a sealed root (cycle or non-sealed
361+
// dead-end): not a member of any observable sealed hierarchy
362+
return 0;
313363
}
314364

315365
/**
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
package org.hjug.graphbuilder.metrics;
2+
3+
import static org.junit.jupiter.api.Assertions.*;
4+
5+
import org.jgrapht.graph.DefaultDirectedWeightedGraph;
6+
import org.jgrapht.graph.DefaultWeightedEdge;
7+
import org.junit.jupiter.api.DisplayName;
8+
import org.junit.jupiter.api.Test;
9+
10+
/**
11+
* Regression tests for
12+
* <a href="https://github.com/refactorfirst/RefactorFirst/issues/215">issue #215</a>:
13+
* {@code StackOverflowError} in
14+
* {@link GraphMetricsCollector}'s {@code computeSealedDepth} when the collected
15+
* sealed-hierarchy ancestor data contains a cycle (e.g. a Java {@code implements}
16+
* chain recorded in a cycle by type attribution without the full classpath).
17+
*
18+
* <p>Also defines the <b>cyclic-depth semantics</b> of the public
19+
* {@link ClassMetrics#getSealedHierarchyDepth()} metric:
20+
*
21+
* <p>A class's depth is the length of the longest acyclic ancestor path that
22+
* terminates at a sealed root ({@code isSealed() == true} with no ancestors,
23+
* depth 1) <em>within the analyzed batch</em>. A class with no such path —
24+
* including a cycle member whose ancestor paths do not reach a sealed root,
25+
* a class whose only ancestor paths enter a cycle, or a class whose chain
26+
* dead-ends at a non-sealed class in the batch — has depth 0: it is not a
27+
* member of any observable sealed hierarchy. External ancestors (not present
28+
* in the batch) preserve the pre-existing minimum depth of 2 as a relationship
29+
* signal. When both cyclic and valid paths exist, the valid path's depth wins.
30+
*/
31+
class GraphMetricsCollectorSealedDepthTest {
32+
33+
private GraphMetricsCollector newCollector() {
34+
return new GraphMetricsCollector(
35+
new DefaultDirectedWeightedGraph<>(DefaultWeightedEdge.class),
36+
new DefaultDirectedWeightedGraph<>(DefaultWeightedEdge.class));
37+
}
38+
39+
// --- Termination (issue #215 regression) -------------------------------
40+
41+
@DisplayName("a two-class ancestor cycle does not overflow the stack")
42+
@Test
43+
void twoClassAncestorCycle_terminates() {
44+
GraphMetricsCollector collector = newCollector();
45+
collector.getOrCreateClassMetrics("com.example.A").addSealedHierarchyAncestor("com.example.B");
46+
collector.getOrCreateClassMetrics("com.example.B").addSealedHierarchyAncestor("com.example.A");
47+
48+
assertDoesNotThrow(collector::finalizeMetrics, "cyclic sealed-hierarchy ancestors must not StackOverflowError");
49+
}
50+
51+
@DisplayName("a self-referencing ancestor does not overflow the stack")
52+
@Test
53+
void selfReferencingAncestor_terminates() {
54+
GraphMetricsCollector collector = newCollector();
55+
collector.getOrCreateClassMetrics("com.example.A").addSealedHierarchyAncestor("com.example.A");
56+
57+
assertDoesNotThrow(
58+
collector::finalizeMetrics, "self-referencing sealed-hierarchy ancestor must not StackOverflowError");
59+
}
60+
61+
// --- Cyclic-depth semantics ---------------------------------------------
62+
63+
@DisplayName("cycle members have depth 0: a cycle has no sealed root")
64+
@Test
65+
void twoClassAncestorCycle_membersHaveDepthZero() {
66+
GraphMetricsCollector collector = newCollector();
67+
ClassMetrics a = collector.getOrCreateClassMetrics("com.example.A");
68+
ClassMetrics b = collector.getOrCreateClassMetrics("com.example.B");
69+
a.addSealedHierarchyAncestor("com.example.B");
70+
b.addSealedHierarchyAncestor("com.example.A");
71+
72+
collector.finalizeMetrics();
73+
74+
assertEquals(0, a.getSealedHierarchyDepth(), "cycle member A has no path to a sealed root");
75+
assertEquals(0, b.getSealedHierarchyDepth(), "cycle member B has no path to a sealed root");
76+
}
77+
78+
@DisplayName("a self-referencing class has depth 0")
79+
@Test
80+
void selfReferencingAncestor_hasDepthZero() {
81+
GraphMetricsCollector collector = newCollector();
82+
ClassMetrics a = collector.getOrCreateClassMetrics("com.example.A");
83+
a.addSealedHierarchyAncestor("com.example.A");
84+
85+
collector.finalizeMetrics();
86+
87+
assertEquals(0, a.getSealedHierarchyDepth());
88+
}
89+
90+
@DisplayName("members of a three-class cycle all have depth 0")
91+
@Test
92+
void threeClassAncestorCycle_membersHaveDepthZero() {
93+
GraphMetricsCollector collector = newCollector();
94+
ClassMetrics a = collector.getOrCreateClassMetrics("com.example.A");
95+
ClassMetrics b = collector.getOrCreateClassMetrics("com.example.B");
96+
ClassMetrics c = collector.getOrCreateClassMetrics("com.example.C");
97+
a.addSealedHierarchyAncestor("com.example.B");
98+
b.addSealedHierarchyAncestor("com.example.C");
99+
c.addSealedHierarchyAncestor("com.example.A");
100+
101+
collector.finalizeMetrics();
102+
103+
assertEquals(0, a.getSealedHierarchyDepth());
104+
assertEquals(0, b.getSealedHierarchyDepth());
105+
assertEquals(0, c.getSealedHierarchyDepth());
106+
}
107+
108+
@DisplayName("a class whose only ancestor path enters a cycle has depth 0")
109+
@Test
110+
void descendantOfCycle_hasDepthZero() {
111+
GraphMetricsCollector collector = newCollector();
112+
ClassMetrics a = collector.getOrCreateClassMetrics("com.example.A");
113+
ClassMetrics b = collector.getOrCreateClassMetrics("com.example.B");
114+
ClassMetrics e = collector.getOrCreateClassMetrics("com.example.E");
115+
a.addSealedHierarchyAncestor("com.example.B");
116+
b.addSealedHierarchyAncestor("com.example.A");
117+
e.addSealedHierarchyAncestor("com.example.A");
118+
119+
collector.finalizeMetrics();
120+
121+
assertEquals(0, e.getSealedHierarchyDepth(), "E's only path dead-ends in the A<->B cycle");
122+
}
123+
124+
@DisplayName("a valid sealed-root path wins over a cyclic path")
125+
@Test
126+
void mixedCyclicAndValidPaths_validPathWins() {
127+
GraphMetricsCollector collector = newCollector();
128+
ClassMetrics root = collector.getOrCreateClassMetrics("com.example.Shape");
129+
root.setSealed(true);
130+
ClassMetrics a = collector.getOrCreateClassMetrics("com.example.A");
131+
ClassMetrics b = collector.getOrCreateClassMetrics("com.example.B");
132+
ClassMetrics e = collector.getOrCreateClassMetrics("com.example.Circle");
133+
a.addSealedHierarchyAncestor("com.example.B");
134+
b.addSealedHierarchyAncestor("com.example.A");
135+
// Circle lists both a cyclic ancestor and a genuine sealed root
136+
e.addSealedHierarchyAncestor("com.example.A");
137+
e.addSealedHierarchyAncestor("com.example.Shape");
138+
139+
collector.finalizeMetrics();
140+
141+
assertEquals(
142+
2,
143+
e.getSealedHierarchyDepth(),
144+
"Circle's depth comes from the Shape root path; the cyclic path contributes nothing");
145+
assertEquals(1, root.getSealedHierarchyDepth());
146+
}
147+
148+
@DisplayName("a cycle member with a sealed-root path receives its valid depth")
149+
@Test
150+
void cycleMemberWithSealedRootPath_hasValidDepth() {
151+
GraphMetricsCollector collector = newCollector();
152+
ClassMetrics root = collector.getOrCreateClassMetrics("com.example.Shape");
153+
root.setSealed(true);
154+
ClassMetrics a = collector.getOrCreateClassMetrics("com.example.A");
155+
ClassMetrics b = collector.getOrCreateClassMetrics("com.example.B");
156+
a.addSealedHierarchyAncestor("com.example.B");
157+
b.addSealedHierarchyAncestor("com.example.A");
158+
b.addSealedHierarchyAncestor("com.example.Shape");
159+
160+
collector.finalizeMetrics();
161+
162+
assertEquals(3, a.getSealedHierarchyDepth(), "A reaches the Shape root through B");
163+
assertEquals(2, b.getSealedHierarchyDepth(), "B reaches the Shape root directly");
164+
}
165+
166+
@DisplayName("a chain ending at a non-sealed class in the batch has depth 0")
167+
@Test
168+
void chainEndingAtNonSealedClass_hasDepthZero() {
169+
GraphMetricsCollector collector = newCollector();
170+
collector.getOrCreateClassMetrics("com.example.PlainInterface");
171+
ClassMetrics impl = collector.getOrCreateClassMetrics("com.example.Impl");
172+
impl.addSealedHierarchyAncestor("com.example.PlainInterface");
173+
174+
collector.finalizeMetrics();
175+
176+
assertEquals(0, impl.getSealedHierarchyDepth(), "no sealed root is reachable, so no depth is recorded");
177+
}
178+
179+
// --- Unchanged semantics -------------------------------------------------
180+
181+
@DisplayName("acyclic sealed hierarchy depths are unchanged by cycle protection")
182+
@Test
183+
void acyclicHierarchy_depthsUnchanged() {
184+
GraphMetricsCollector collector = newCollector();
185+
ClassMetrics root = collector.getOrCreateClassMetrics("com.example.Shape");
186+
root.setSealed(true);
187+
ClassMetrics child = collector.getOrCreateClassMetrics("com.example.Circle");
188+
child.addSealedHierarchyAncestor("com.example.Shape");
189+
ClassMetrics grandchild = collector.getOrCreateClassMetrics("com.example.UnitCircle");
190+
grandchild.addSealedHierarchyAncestor("com.example.Circle");
191+
192+
collector.finalizeMetrics();
193+
194+
assertEquals(1, root.getSealedHierarchyDepth());
195+
assertEquals(2, child.getSealedHierarchyDepth());
196+
assertEquals(3, grandchild.getSealedHierarchyDepth());
197+
}
198+
199+
@DisplayName("external-only ancestors keep minimum depth 2 (relationship signal)")
200+
@Test
201+
void externalOnlyAncestors_keepMinimumDepthTwo() {
202+
GraphMetricsCollector collector = newCollector();
203+
ClassMetrics impl = collector.getOrCreateClassMetrics("com.example.Circle");
204+
impl.addSealedHierarchyAncestor("com.thirdparty.Shape"); // not in batch
205+
206+
collector.finalizeMetrics();
207+
208+
assertEquals(
209+
2,
210+
impl.getSealedHierarchyDepth(),
211+
"external ancestors preserve minimum depth of 2 to record the relationship");
212+
}
213+
}

0 commit comments

Comments
 (0)