Repository navigation
#215 Fix computeSealedDepth to handle cycle-safe ancestor traversal #220
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
b111af4
#215 Fix computeSealedDepth to handle cycle-safe ancestor traversal
jimbethancourt 3643b6d
Document sealed hierarchy depth calculation and cycle handling in Gra…
coderabbitai[bot] f57a0fe
#215 Defined cyclic-depth semantics before downstream consumers rely …
jimbethancourt e0f3354
Merge remote-tracking branch 'origin/#215-handle-stack-overflow-seale…
jimbethancourt 970c668
Clarify sealed hierarchy depth semantics for cycle members and test p…
coderabbitai[bot] File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
213 changes: 213 additions & 0 deletions
213
...der/src/test/java/org/hjug/graphbuilder/metrics/GraphMetricsCollectorSealedDepthTest.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,213 @@ | ||
| package org.hjug.graphbuilder.metrics; | ||
|
|
||
| import static org.junit.jupiter.api.Assertions.*; | ||
|
|
||
| import org.jgrapht.graph.DefaultDirectedWeightedGraph; | ||
| import org.jgrapht.graph.DefaultWeightedEdge; | ||
| import org.junit.jupiter.api.DisplayName; | ||
| import org.junit.jupiter.api.Test; | ||
|
|
||
| /** | ||
| * Regression tests for | ||
| * <a href="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/refactorfirst/RefactorFirst/issues/215">issue #215</a>: | ||
| * {@code StackOverflowError} in | ||
| * {@link GraphMetricsCollector}'s {@code computeSealedDepth} when the collected | ||
| * sealed-hierarchy ancestor data contains a cycle (e.g. a Java {@code implements} | ||
| * chain recorded in a cycle by type attribution without the full classpath). | ||
| * | ||
| * <p>Also defines the <b>cyclic-depth semantics</b> of the public | ||
| * {@link ClassMetrics#getSealedHierarchyDepth()} metric: | ||
| * | ||
| * <p>A class's depth is the length of the longest acyclic ancestor path that | ||
| * terminates at a sealed root ({@code isSealed() == true} with no ancestors, | ||
| * depth 1) <em>within the analyzed batch</em>. A class with no such path — | ||
| * including a cycle member whose ancestor paths do not reach a sealed root, | ||
| * a class whose only ancestor paths enter a cycle, or a class whose chain | ||
| * dead-ends at a non-sealed class in the batch — has depth 0: it is not a | ||
| * member of any observable sealed hierarchy. External ancestors (not present | ||
| * in the batch) preserve the pre-existing minimum depth of 2 as a relationship | ||
| * signal. When both cyclic and valid paths exist, the valid path's depth wins. | ||
| */ | ||
| class GraphMetricsCollectorSealedDepthTest { | ||
|
|
||
| private GraphMetricsCollector newCollector() { | ||
| return new GraphMetricsCollector( | ||
| new DefaultDirectedWeightedGraph<>(DefaultWeightedEdge.class), | ||
| new DefaultDirectedWeightedGraph<>(DefaultWeightedEdge.class)); | ||
| } | ||
|
|
||
| // --- Termination (issue #215 regression) ------------------------------- | ||
|
|
||
| @DisplayName("a two-class ancestor cycle does not overflow the stack") | ||
| @Test | ||
| void twoClassAncestorCycle_terminates() { | ||
| GraphMetricsCollector collector = newCollector(); | ||
| collector.getOrCreateClassMetrics("com.example.A").addSealedHierarchyAncestor("com.example.B"); | ||
| collector.getOrCreateClassMetrics("com.example.B").addSealedHierarchyAncestor("com.example.A"); | ||
|
|
||
| assertDoesNotThrow(collector::finalizeMetrics, "cyclic sealed-hierarchy ancestors must not StackOverflowError"); | ||
| } | ||
|
|
||
| @DisplayName("a self-referencing ancestor does not overflow the stack") | ||
| @Test | ||
| void selfReferencingAncestor_terminates() { | ||
| GraphMetricsCollector collector = newCollector(); | ||
| collector.getOrCreateClassMetrics("com.example.A").addSealedHierarchyAncestor("com.example.A"); | ||
|
|
||
| assertDoesNotThrow( | ||
| collector::finalizeMetrics, "self-referencing sealed-hierarchy ancestor must not StackOverflowError"); | ||
| } | ||
|
|
||
| // --- Cyclic-depth semantics --------------------------------------------- | ||
|
|
||
| @DisplayName("cycle members have depth 0: a cycle has no sealed root") | ||
| @Test | ||
| void twoClassAncestorCycle_membersHaveDepthZero() { | ||
| GraphMetricsCollector collector = newCollector(); | ||
| ClassMetrics a = collector.getOrCreateClassMetrics("com.example.A"); | ||
| ClassMetrics b = collector.getOrCreateClassMetrics("com.example.B"); | ||
| a.addSealedHierarchyAncestor("com.example.B"); | ||
| b.addSealedHierarchyAncestor("com.example.A"); | ||
|
|
||
| collector.finalizeMetrics(); | ||
|
|
||
| assertEquals(0, a.getSealedHierarchyDepth(), "cycle member A has no path to a sealed root"); | ||
| assertEquals(0, b.getSealedHierarchyDepth(), "cycle member B has no path to a sealed root"); | ||
| } | ||
|
|
||
| @DisplayName("a self-referencing class has depth 0") | ||
| @Test | ||
| void selfReferencingAncestor_hasDepthZero() { | ||
| GraphMetricsCollector collector = newCollector(); | ||
| ClassMetrics a = collector.getOrCreateClassMetrics("com.example.A"); | ||
| a.addSealedHierarchyAncestor("com.example.A"); | ||
|
|
||
| collector.finalizeMetrics(); | ||
|
|
||
| assertEquals(0, a.getSealedHierarchyDepth()); | ||
| } | ||
|
|
||
| @DisplayName("members of a three-class cycle all have depth 0") | ||
| @Test | ||
| void threeClassAncestorCycle_membersHaveDepthZero() { | ||
| GraphMetricsCollector collector = newCollector(); | ||
| ClassMetrics a = collector.getOrCreateClassMetrics("com.example.A"); | ||
| ClassMetrics b = collector.getOrCreateClassMetrics("com.example.B"); | ||
| ClassMetrics c = collector.getOrCreateClassMetrics("com.example.C"); | ||
| a.addSealedHierarchyAncestor("com.example.B"); | ||
| b.addSealedHierarchyAncestor("com.example.C"); | ||
| c.addSealedHierarchyAncestor("com.example.A"); | ||
|
|
||
| collector.finalizeMetrics(); | ||
|
|
||
| assertEquals(0, a.getSealedHierarchyDepth()); | ||
| assertEquals(0, b.getSealedHierarchyDepth()); | ||
| assertEquals(0, c.getSealedHierarchyDepth()); | ||
| } | ||
|
|
||
| @DisplayName("a class whose only ancestor path enters a cycle has depth 0") | ||
| @Test | ||
| void descendantOfCycle_hasDepthZero() { | ||
| GraphMetricsCollector collector = newCollector(); | ||
| ClassMetrics a = collector.getOrCreateClassMetrics("com.example.A"); | ||
| ClassMetrics b = collector.getOrCreateClassMetrics("com.example.B"); | ||
| ClassMetrics e = collector.getOrCreateClassMetrics("com.example.E"); | ||
| a.addSealedHierarchyAncestor("com.example.B"); | ||
| b.addSealedHierarchyAncestor("com.example.A"); | ||
| e.addSealedHierarchyAncestor("com.example.A"); | ||
|
|
||
| collector.finalizeMetrics(); | ||
|
|
||
| assertEquals(0, e.getSealedHierarchyDepth(), "E's only path dead-ends in the A<->B cycle"); | ||
| } | ||
|
|
||
| @DisplayName("a valid sealed-root path wins over a cyclic path") | ||
| @Test | ||
| void mixedCyclicAndValidPaths_validPathWins() { | ||
| GraphMetricsCollector collector = newCollector(); | ||
| ClassMetrics root = collector.getOrCreateClassMetrics("com.example.Shape"); | ||
| root.setSealed(true); | ||
| ClassMetrics a = collector.getOrCreateClassMetrics("com.example.A"); | ||
| ClassMetrics b = collector.getOrCreateClassMetrics("com.example.B"); | ||
| ClassMetrics e = collector.getOrCreateClassMetrics("com.example.Circle"); | ||
| a.addSealedHierarchyAncestor("com.example.B"); | ||
| b.addSealedHierarchyAncestor("com.example.A"); | ||
| // Circle lists both a cyclic ancestor and a genuine sealed root | ||
| e.addSealedHierarchyAncestor("com.example.A"); | ||
| e.addSealedHierarchyAncestor("com.example.Shape"); | ||
|
|
||
| collector.finalizeMetrics(); | ||
|
|
||
| assertEquals( | ||
| 2, | ||
| e.getSealedHierarchyDepth(), | ||
| "Circle's depth comes from the Shape root path; the cyclic path contributes nothing"); | ||
| assertEquals(1, root.getSealedHierarchyDepth()); | ||
| } | ||
|
|
||
| @DisplayName("a cycle member with a sealed-root path receives its valid depth") | ||
| @Test | ||
| void cycleMemberWithSealedRootPath_hasValidDepth() { | ||
| GraphMetricsCollector collector = newCollector(); | ||
| ClassMetrics root = collector.getOrCreateClassMetrics("com.example.Shape"); | ||
| root.setSealed(true); | ||
| ClassMetrics a = collector.getOrCreateClassMetrics("com.example.A"); | ||
| ClassMetrics b = collector.getOrCreateClassMetrics("com.example.B"); | ||
| a.addSealedHierarchyAncestor("com.example.B"); | ||
| b.addSealedHierarchyAncestor("com.example.A"); | ||
| b.addSealedHierarchyAncestor("com.example.Shape"); | ||
|
|
||
| collector.finalizeMetrics(); | ||
|
|
||
| assertEquals(3, a.getSealedHierarchyDepth(), "A reaches the Shape root through B"); | ||
| assertEquals(2, b.getSealedHierarchyDepth(), "B reaches the Shape root directly"); | ||
| } | ||
|
|
||
| @DisplayName("a chain ending at a non-sealed class in the batch has depth 0") | ||
| @Test | ||
| void chainEndingAtNonSealedClass_hasDepthZero() { | ||
| GraphMetricsCollector collector = newCollector(); | ||
| collector.getOrCreateClassMetrics("com.example.PlainInterface"); | ||
| ClassMetrics impl = collector.getOrCreateClassMetrics("com.example.Impl"); | ||
| impl.addSealedHierarchyAncestor("com.example.PlainInterface"); | ||
|
|
||
| collector.finalizeMetrics(); | ||
|
|
||
| assertEquals(0, impl.getSealedHierarchyDepth(), "no sealed root is reachable, so no depth is recorded"); | ||
| } | ||
|
|
||
| // --- Unchanged semantics ------------------------------------------------- | ||
|
|
||
| @DisplayName("acyclic sealed hierarchy depths are unchanged by cycle protection") | ||
| @Test | ||
| void acyclicHierarchy_depthsUnchanged() { | ||
| GraphMetricsCollector collector = newCollector(); | ||
| ClassMetrics root = collector.getOrCreateClassMetrics("com.example.Shape"); | ||
| root.setSealed(true); | ||
| ClassMetrics child = collector.getOrCreateClassMetrics("com.example.Circle"); | ||
| child.addSealedHierarchyAncestor("com.example.Shape"); | ||
| ClassMetrics grandchild = collector.getOrCreateClassMetrics("com.example.UnitCircle"); | ||
| grandchild.addSealedHierarchyAncestor("com.example.Circle"); | ||
|
|
||
| collector.finalizeMetrics(); | ||
|
|
||
| assertEquals(1, root.getSealedHierarchyDepth()); | ||
| assertEquals(2, child.getSealedHierarchyDepth()); | ||
| assertEquals(3, grandchild.getSealedHierarchyDepth()); | ||
| } | ||
|
|
||
| @DisplayName("external-only ancestors keep minimum depth 2 (relationship signal)") | ||
| @Test | ||
| void externalOnlyAncestors_keepMinimumDepthTwo() { | ||
| GraphMetricsCollector collector = newCollector(); | ||
| ClassMetrics impl = collector.getOrCreateClassMetrics("com.example.Circle"); | ||
| impl.addSealedHierarchyAncestor("com.thirdparty.Shape"); // not in batch | ||
|
|
||
| collector.finalizeMetrics(); | ||
|
|
||
| assertEquals( | ||
| 2, | ||
| impl.getSealedHierarchyDepth(), | ||
| "external ancestors preserve minimum depth of 2 to record the relationship"); | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔍 Cyclic depths lack a hierarchy root
For a cycle
A ↔ B, both classes get depth 2; a class extendingAgets depth 3. These values have no sealed root, though detectLargeSealedHierarchy currently ignores depth. Define cyclic-depth semantics before downstream consumers rely on the public metric.Was this helpful? React with 👍 or 👎 to provide feedback.