From b111af4224a15bc4ccbb114c31867d6c29eba317 Mon Sep 17 00:00:00 2001 From: Jim Bethancourt Date: Wed, 23 Sep 2026 20:21:51 -0500 Subject: [PATCH 1/4] #215 Fix computeSealedDepth to handle cycle-safe ancestor traversal --- .../metrics/GraphMetricsCollector.java | 18 +++- .../GraphMetricsCollectorSealedDepthTest.java | 96 +++++++++++++++++++ 2 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 codebase-graph-builder/src/test/java/org/hjug/graphbuilder/metrics/GraphMetricsCollectorSealedDepthTest.java diff --git a/codebase-graph-builder/src/main/java/org/hjug/graphbuilder/metrics/GraphMetricsCollector.java b/codebase-graph-builder/src/main/java/org/hjug/graphbuilder/metrics/GraphMetricsCollector.java index eabbc038..848c0f78 100644 --- a/codebase-graph-builder/src/main/java/org/hjug/graphbuilder/metrics/GraphMetricsCollector.java +++ b/codebase-graph-builder/src/main/java/org/hjug/graphbuilder/metrics/GraphMetricsCollector.java @@ -287,11 +287,26 @@ private void computeKotlinDerivedMetrics() { } private int computeSealedDepth(ClassMetrics metrics) { + return computeSealedDepth(metrics, new HashSet<>()); + } + + /** + * Cycle-safe variant of {@link #computeSealedDepth(ClassMetrics)}. The + * {@code visiting} set tracks the FQNs on the current ancestor-traversal + * path; re-entering a class already on the path means the collected + * ancestor data contains a cycle (issue #215: Java {@code implements} + * chains recorded in a cycle, e.g. by type attribution without the full + * classpath), so the traversal stops instead of recursing forever. + */ + private int computeSealedDepth(ClassMetrics metrics, Set visiting) { Set ancestors = metrics.getSealedHierarchyAncestors(); if (ancestors.isEmpty()) { // No sealed hierarchy ancestors: depth 1 if sealed, 0 otherwise return metrics.isSealed() ? 1 : 0; } + if (!visiting.add(metrics.getFullyQualifiedName())) { + return 0; + } // Has sealed hierarchy ancestors: traverse them first int maxAncestorDepth = 0; boolean hasObservableAncestor = false; @@ -303,8 +318,9 @@ private int computeSealedDepth(ClassMetrics metrics) { continue; } hasObservableAncestor = true; - maxAncestorDepth = Math.max(maxAncestorDepth, computeSealedDepth(ancestor)); + maxAncestorDepth = Math.max(maxAncestorDepth, computeSealedDepth(ancestor, visiting)); } + visiting.remove(metrics.getFullyQualifiedName()); if (!hasObservableAncestor) { // All ancestors are external; preserve depth 2 to record relationship return 2; diff --git a/codebase-graph-builder/src/test/java/org/hjug/graphbuilder/metrics/GraphMetricsCollectorSealedDepthTest.java b/codebase-graph-builder/src/test/java/org/hjug/graphbuilder/metrics/GraphMetricsCollectorSealedDepthTest.java new file mode 100644 index 00000000..105c9df2 --- /dev/null +++ b/codebase-graph-builder/src/test/java/org/hjug/graphbuilder/metrics/GraphMetricsCollectorSealedDepthTest.java @@ -0,0 +1,96 @@ +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 + * issue #215: + * {@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). + * + *

The depth computation must track the classes already on the current + * traversal path and stop when one repeats, instead of recursing forever. + */ +class GraphMetricsCollectorSealedDepthTest { + + private GraphMetricsCollector newCollector() { + return new GraphMetricsCollector( + new DefaultDirectedWeightedGraph<>(DefaultWeightedEdge.class), + new DefaultDirectedWeightedGraph<>(DefaultWeightedEdge.class)); + } + + @DisplayName("a two-class ancestor cycle does not overflow the stack") + @Test + void twoClassAncestorCycle_terminates() { + 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"); + + assertDoesNotThrow(collector::finalizeMetrics, "cyclic sealed-hierarchy ancestors must not StackOverflowError"); + + assertTrue(a.getSealedHierarchyDepth() > 0, "A is in a hierarchy; a finite depth must be recorded"); + assertTrue(b.getSealedHierarchyDepth() > 0, "B is in a hierarchy; a finite depth must be recorded"); + assertEquals( + a.getSealedHierarchyDepth(), + b.getSealedHierarchyDepth(), + "symmetric two-class cycle must yield equal depths"); + } + + @DisplayName("a self-referencing ancestor does not overflow the stack") + @Test + void selfReferencingAncestor_terminates() { + GraphMetricsCollector collector = newCollector(); + ClassMetrics a = collector.getOrCreateClassMetrics("com.example.A"); + a.addSealedHierarchyAncestor("com.example.A"); + + assertDoesNotThrow( + collector::finalizeMetrics, "self-referencing sealed-hierarchy ancestor must not StackOverflowError"); + + assertTrue(a.getSealedHierarchyDepth() > 0, "a finite depth must be recorded for a self loop"); + } + + @DisplayName("a three-class ancestor cycle does not overflow the stack") + @Test + void threeClassAncestorCycle_terminates() { + 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"); + + assertDoesNotThrow(collector::finalizeMetrics, "cyclic sealed-hierarchy ancestors must not StackOverflowError"); + + assertTrue(a.getSealedHierarchyDepth() > 0); + assertTrue(b.getSealedHierarchyDepth() > 0); + assertTrue(c.getSealedHierarchyDepth() > 0); + } + + @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()); + } +} From 3643b6de06ab87aeb945d781e1a9d9e82281a03a Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Thu, 24 Sep 2026 01:35:23 +0000 Subject: [PATCH 2/4] Document sealed hierarchy depth calculation and cycle handling in GraphMetricsCollector --- .../graphbuilder/metrics/GraphMetricsCollector.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/codebase-graph-builder/src/main/java/org/hjug/graphbuilder/metrics/GraphMetricsCollector.java b/codebase-graph-builder/src/main/java/org/hjug/graphbuilder/metrics/GraphMetricsCollector.java index 848c0f78..2f65fd69 100644 --- a/codebase-graph-builder/src/main/java/org/hjug/graphbuilder/metrics/GraphMetricsCollector.java +++ b/codebase-graph-builder/src/main/java/org/hjug/graphbuilder/metrics/GraphMetricsCollector.java @@ -286,6 +286,14 @@ private void computeKotlinDerivedMetrics() { } } + /** + * Computes a class's depth in the collected sealed hierarchy. + * + * @return {@code 0} for a non-sealed class with no recorded ancestors, {@code 1} + * for a sealed class with no recorded ancestors, {@code 2} when all + * recorded ancestors are outside this collector, or one more than + * the greatest depth among ancestors present in this collector + */ private int computeSealedDepth(ClassMetrics metrics) { return computeSealedDepth(metrics, new HashSet<>()); } @@ -297,6 +305,10 @@ private int computeSealedDepth(ClassMetrics metrics) { * ancestor data contains a cycle (issue #215: Java {@code implements} * chains recorded in a cycle, e.g. by type attribution without the full * classpath), so the traversal stops instead of recursing forever. + * + * @param visiting the class FQNs on the current ancestor-traversal path + * @return {@code 0} when this class is already on that path; otherwise its + * depth from the collected ancestors */ private int computeSealedDepth(ClassMetrics metrics, Set visiting) { Set ancestors = metrics.getSealedHierarchyAncestors(); From f57a0feab4c2073bc9bd5eba24fc180dd52f7d62 Mon Sep 17 00:00:00 2001 From: Jim Bethancourt Date: Thu, 24 Sep 2026 07:43:50 -0500 Subject: [PATCH 3/4] #215 Defined cyclic-depth semantics before downstream consumers rely on the public metric per recommendation by Devin --- .../graphbuilder/metrics/ClassMetrics.java | 7 + .../metrics/GraphMetricsCollector.java | 36 ++++- .../GraphMetricsCollectorSealedDepthTest.java | 139 +++++++++++++++--- 3 files changed, 154 insertions(+), 28 deletions(-) diff --git a/codebase-graph-builder/src/main/java/org/hjug/graphbuilder/metrics/ClassMetrics.java b/codebase-graph-builder/src/main/java/org/hjug/graphbuilder/metrics/ClassMetrics.java index 34da1947..8ccb3711 100644 --- a/codebase-graph-builder/src/main/java/org/hjug/graphbuilder/metrics/ClassMetrics.java +++ b/codebase-graph-builder/src/main/java/org/hjug/graphbuilder/metrics/ClassMetrics.java @@ -149,6 +149,13 @@ void freeze() { * sealed class has depth=1; each indirect descendant nests deeper. * Computed post-walk in * {@link GraphMetricsCollector#finalizeMetrics()}. + * + *

Cyclic-depth semantics: the depth is the longest acyclic + * ancestor path terminating at a sealed root within the analyzed + * batch; a class with no such path (cycle member, or a chain + * dead-ending at a non-sealed in-batch class) has depth 0. Ancestors + * absent from the batch preserve a minimum depth of 2 as a + * relationship signal. (Cycle safety: issue #215.) */ @Getter private int sealedHierarchyDepth; diff --git a/codebase-graph-builder/src/main/java/org/hjug/graphbuilder/metrics/GraphMetricsCollector.java b/codebase-graph-builder/src/main/java/org/hjug/graphbuilder/metrics/GraphMetricsCollector.java index 848c0f78..d4a28028 100644 --- a/codebase-graph-builder/src/main/java/org/hjug/graphbuilder/metrics/GraphMetricsCollector.java +++ b/codebase-graph-builder/src/main/java/org/hjug/graphbuilder/metrics/GraphMetricsCollector.java @@ -261,9 +261,11 @@ public void finalizeMetrics() { *

    *
  • {@link ClassMetrics#setSealedHierarchyDepth(int)} — root sealed * class has depth 1; each direct permittee inherits depth 2, and - * so on. Computed by walking up the sealed-hierarchy ancestor - * chain until reaching a class whose {@link ClassMetrics#isSealed()} - * flag is {@code false}.
  • + * so on. The depth is the longest acyclic ancestor path that + * terminates at a sealed root; classes with no such path (cycle + * members, non-sealed dead-ends) get depth 0 and are not marked. + * Third-party ancestors absent from the batch preserve a minimum + * depth of 2 as a relationship signal. (Cycle safety: issue #215.) *
  • {@link ClassMetrics#setHasExplicitLogic(boolean)} — true when a * Kotlin {@code data class} declares any non-accessor method, * feeding the {@code (hasExplicitLogic || WMC > 14)} criterion of @@ -297,6 +299,15 @@ private int computeSealedDepth(ClassMetrics metrics) { * ancestor data contains a cycle (issue #215: Java {@code implements} * chains recorded in a cycle, e.g. by type attribution without the full * classpath), so the traversal stops instead of recursing forever. + * + *

    Cyclic-depth semantics: 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) within the + * analyzed batch. A class with no such path — a cycle member, a class + * whose only 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 an observable + * member of any sealed hierarchy. Ancestors absent from the batch + * (third-party) preserve the minimum depth of 2 as a relationship signal. */ private int computeSealedDepth(ClassMetrics metrics, Set visiting) { Set ancestors = metrics.getSealedHierarchyAncestors(); @@ -305,27 +316,36 @@ private int computeSealedDepth(ClassMetrics metrics, Set visiting) { return metrics.isSealed() ? 1 : 0; } if (!visiting.add(metrics.getFullyQualifiedName())) { + // Cycle: this path has no valid depth contribution return 0; } // Has sealed hierarchy ancestors: traverse them first int maxAncestorDepth = 0; - boolean hasObservableAncestor = false; + boolean hasExternalAncestor = false; for (String ancestorFqn : ancestors) { ClassMetrics ancestor = classMetrics.get(ancestorFqn); if (ancestor == null) { // Ancestor not in this codebase batch (third-party): preserve // minimum depth of 2 to record the relationship + hasExternalAncestor = true; continue; } - hasObservableAncestor = true; + // Dead-end paths (depth 0) contribute nothing; only paths that + // reach a sealed root propagate a positive depth upward maxAncestorDepth = Math.max(maxAncestorDepth, computeSealedDepth(ancestor, visiting)); } visiting.remove(metrics.getFullyQualifiedName()); - if (!hasObservableAncestor) { - // All ancestors are external; preserve depth 2 to record relationship + if (maxAncestorDepth > 0) { + return maxAncestorDepth + 1; + } + if (hasExternalAncestor) { + // No sealed root reachable in-batch, but external ancestors may + // hold the root; preserve depth 2 to record relationship return 2; } - return maxAncestorDepth + 1; + // No ancestor path reaches a sealed root (cycle or non-sealed + // dead-end): not a member of any observable sealed hierarchy + return 0; } /** diff --git a/codebase-graph-builder/src/test/java/org/hjug/graphbuilder/metrics/GraphMetricsCollectorSealedDepthTest.java b/codebase-graph-builder/src/test/java/org/hjug/graphbuilder/metrics/GraphMetricsCollectorSealedDepthTest.java index 105c9df2..3228be82 100644 --- a/codebase-graph-builder/src/test/java/org/hjug/graphbuilder/metrics/GraphMetricsCollectorSealedDepthTest.java +++ b/codebase-graph-builder/src/test/java/org/hjug/graphbuilder/metrics/GraphMetricsCollectorSealedDepthTest.java @@ -15,8 +15,18 @@ * 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). * - *

    The depth computation must track the classes already on the current - * traversal path and stop when one repeats, instead of recursing forever. + *

    Also defines the cyclic-depth semantics of the public + * {@link ClassMetrics#getSealedHierarchyDepth()} metric: + * + *

    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) within the analyzed batch. A class with no such path — + * cycle members, classes whose only ancestor paths enter a cycle, or classes + * 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 { @@ -26,41 +36,60 @@ private GraphMetricsCollector newCollector() { 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"); - assertDoesNotThrow(collector::finalizeMetrics, "cyclic sealed-hierarchy ancestors must not StackOverflowError"); + collector.finalizeMetrics(); - assertTrue(a.getSealedHierarchyDepth() > 0, "A is in a hierarchy; a finite depth must be recorded"); - assertTrue(b.getSealedHierarchyDepth() > 0, "B is in a hierarchy; a finite depth must be recorded"); - assertEquals( - a.getSealedHierarchyDepth(), - b.getSealedHierarchyDepth(), - "symmetric two-class cycle must yield equal depths"); + 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 ancestor does not overflow the stack") + @DisplayName("a self-referencing class has depth 0") @Test - void selfReferencingAncestor_terminates() { + void selfReferencingAncestor_hasDepthZero() { GraphMetricsCollector collector = newCollector(); ClassMetrics a = collector.getOrCreateClassMetrics("com.example.A"); a.addSealedHierarchyAncestor("com.example.A"); - assertDoesNotThrow( - collector::finalizeMetrics, "self-referencing sealed-hierarchy ancestor must not StackOverflowError"); + collector.finalizeMetrics(); - assertTrue(a.getSealedHierarchyDepth() > 0, "a finite depth must be recorded for a self loop"); + assertEquals(0, a.getSealedHierarchyDepth()); } - @DisplayName("a three-class ancestor cycle does not overflow the stack") + @DisplayName("members of a three-class cycle all have depth 0") @Test - void threeClassAncestorCycle_terminates() { + void threeClassAncestorCycle_membersHaveDepthZero() { GraphMetricsCollector collector = newCollector(); ClassMetrics a = collector.getOrCreateClassMetrics("com.example.A"); ClassMetrics b = collector.getOrCreateClassMetrics("com.example.B"); @@ -69,13 +98,68 @@ void threeClassAncestorCycle_terminates() { b.addSealedHierarchyAncestor("com.example.C"); c.addSealedHierarchyAncestor("com.example.A"); - assertDoesNotThrow(collector::finalizeMetrics, "cyclic sealed-hierarchy ancestors must not StackOverflowError"); + 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"); - assertTrue(a.getSealedHierarchyDepth() > 0); - assertTrue(b.getSealedHierarchyDepth() > 0); - assertTrue(c.getSealedHierarchyDepth() > 0); + 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 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() { @@ -93,4 +177,19 @@ void acyclicHierarchy_depthsUnchanged() { 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"); + } } From 970c66858bfc6b6395404273fd0db04593d8f060 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Thu, 24 Sep 2026 13:03:17 +0000 Subject: [PATCH 4/4] Clarify sealed hierarchy depth semantics for cycle members and test paths to sealed roots --- .../graphbuilder/metrics/ClassMetrics.java | 5 ++-- .../metrics/GraphMetricsCollector.java | 14 +++++---- .../GraphMetricsCollectorSealedDepthTest.java | 30 +++++++++++++++---- 3 files changed, 35 insertions(+), 14 deletions(-) diff --git a/codebase-graph-builder/src/main/java/org/hjug/graphbuilder/metrics/ClassMetrics.java b/codebase-graph-builder/src/main/java/org/hjug/graphbuilder/metrics/ClassMetrics.java index 8ccb3711..67b5b90f 100644 --- a/codebase-graph-builder/src/main/java/org/hjug/graphbuilder/metrics/ClassMetrics.java +++ b/codebase-graph-builder/src/main/java/org/hjug/graphbuilder/metrics/ClassMetrics.java @@ -152,8 +152,9 @@ void freeze() { * *

    Cyclic-depth semantics: the depth is the longest acyclic * ancestor path terminating at a sealed root within the analyzed - * batch; a class with no such path (cycle member, or a chain - * dead-ending at a non-sealed in-batch class) has depth 0. Ancestors + * batch; a class with no such path (including a cycle member whose + * ancestor paths do not reach a sealed root, or a chain dead-ending at a + * non-sealed in-batch class) has depth 0. Ancestors * absent from the batch preserve a minimum depth of 2 as a * relationship signal. (Cycle safety: issue #215.) */ diff --git a/codebase-graph-builder/src/main/java/org/hjug/graphbuilder/metrics/GraphMetricsCollector.java b/codebase-graph-builder/src/main/java/org/hjug/graphbuilder/metrics/GraphMetricsCollector.java index bbf09ff4..7960b4fb 100644 --- a/codebase-graph-builder/src/main/java/org/hjug/graphbuilder/metrics/GraphMetricsCollector.java +++ b/codebase-graph-builder/src/main/java/org/hjug/graphbuilder/metrics/GraphMetricsCollector.java @@ -262,8 +262,9 @@ public void finalizeMetrics() { *

  • {@link ClassMetrics#setSealedHierarchyDepth(int)} — root sealed * class has depth 1; each direct permittee inherits depth 2, and * so on. The depth is the longest acyclic ancestor path that - * terminates at a sealed root; classes with no such path (cycle - * members, non-sealed dead-ends) get depth 0 and are not marked. + * terminates at a sealed root; classes with no such path (including + * cycle members without a path to a sealed root and non-sealed + * dead-ends) get depth 0 and are not marked. * Third-party ancestors absent from the batch preserve a minimum * depth of 2 as a relationship signal. (Cycle safety: issue #215.)
  • *
  • {@link ClassMetrics#setHasExplicitLogic(boolean)} — true when a @@ -311,10 +312,11 @@ private int computeSealedDepth(ClassMetrics metrics) { *

    Cyclic-depth semantics: 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) within the - * analyzed batch. A class with no such path — a cycle member, a class - * whose only 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 an observable - * member of any sealed hierarchy. Ancestors absent from the batch + * analyzed batch. A class with no such path — including a cycle member + * whose ancestor paths do not reach a sealed root, a class whose only + * 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 an observable member of any + * sealed hierarchy. Ancestors absent from the batch * (third-party) preserve the minimum depth of 2 as a relationship signal. * * @param visiting the class FQNs on the current ancestor-traversal path diff --git a/codebase-graph-builder/src/test/java/org/hjug/graphbuilder/metrics/GraphMetricsCollectorSealedDepthTest.java b/codebase-graph-builder/src/test/java/org/hjug/graphbuilder/metrics/GraphMetricsCollectorSealedDepthTest.java index 3228be82..7d66899e 100644 --- a/codebase-graph-builder/src/test/java/org/hjug/graphbuilder/metrics/GraphMetricsCollectorSealedDepthTest.java +++ b/codebase-graph-builder/src/test/java/org/hjug/graphbuilder/metrics/GraphMetricsCollectorSealedDepthTest.java @@ -21,12 +21,12 @@ *

    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) within the analyzed batch. A class with no such path — - * cycle members, classes whose only ancestor paths enter a cycle, or classes - * 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. + * 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 { @@ -145,6 +145,24 @@ void mixedCyclicAndValidPaths_validPathWins() { 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() {