Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 41 additions & 3 deletions internal/engine/coverage_summary_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package engine
// and excluded from the internal blind-spot count (unresolved) and gap tally.

import (
"fmt"
"testing"

"github.com/enola-labs/enola/internal/facts"
Expand All @@ -28,12 +29,49 @@ func svcCoverage(name string, resolved, unresolved, external int) facts.Fact {
}
}

// withDeps attaches n resolved outbound cross-repo dependencies to a service node.
// Without this, every fixture has outbound == 0, which is the one input that tells
// a coverage gap apart from a merely partially-covered service.
func withDeps(f facts.Fact, n int) facts.Fact {
for i := 0; i < n; i++ {
f.Relations = append(f.Relations, facts.Relation{
Kind: facts.RelDependsOn,
Target: fmt.Sprintf("dep-%d", i),
})
}
return f
}

// A service with a resolved outbound edge is "partial coverage", which both
// coverage_report and the coverage explainer classify as connected — not a gap.
// The receipt must agree with them.
func TestCoverageSummary_PartiallyCoveredServiceIsNotAGap(t *testing.T) {
st := facts.NewStore()
st.Add(
withDeps(svcCoverage("partial", 5, 2, 0), 1), // resolved edge + unresolved -> connected
svcCoverage("gap", 0, 3, 0), // no resolved edge + unresolved -> gap
withDeps(svcCoverage("clean", 4, 0, 0), 1), // nothing unresolved -> connected
)

sum := coverageSummary(st)
if sum == nil {
t.Fatal("expected a CoverageSummary")
}
if sum.CoverageGaps != 1 {
t.Errorf("CoverageGaps = %d, want 1 (only the service with no resolved outbound edge)", sum.CoverageGaps)
}
// UnresolvedEdges must keep accumulating across every service, gapped or not.
if sum.UnresolvedEdges != 5 {
t.Errorf("UnresolvedEdges = %d, want 5 (2+3, counted regardless of classification)", sum.UnresolvedEdges)
}
}

func TestCoverageSummary_ExternalBucket(t *testing.T) {
st := facts.NewStore()
st.Add(
svcCoverage("a", 5, 2, 0), // 2 internal unresolved -> a coverage gap
svcCoverage("b", 4, 0, 3), // only external -> NOT a gap
svcCoverage("c", 1, 1, 4), // both -> gap, external counted separately
svcCoverage("a", 5, 2, 0), // 2 internal unresolved -> a coverage gap
svcCoverage("b", 4, 0, 3), // only external -> NOT a gap
svcCoverage("c", 1, 1, 4), // both -> gap, external counted separately
)

sum := coverageSummary(st)
Expand Down
12 changes: 9 additions & 3 deletions internal/engine/receipt.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,19 +105,25 @@ func runGit(repoPath string, args ...string) (string, error) {
// coverageSummary rolls up the per-service edge_coverage counts the cross-repo
// linker records into a single snapshot-level summary. It returns nil for a
// single-repo snapshot (no service nodes), matching the coverage explainer.
//
// A gap is counted through facts.ClassifyService, so the receipt, coverage_report
// and the coverage explainer cannot drift apart. UnresolvedEdges accumulates for
// every service regardless of class — a partially-covered service still has blind
// spots worth reporting, it just is not a gap.
func coverageSummary(store *facts.Store) *facts.CoverageSummary {
services := store.ByKind(facts.KindService)
if len(services) == 0 {
return nil
}
sum := &facts.CoverageSummary{ServicesTotal: len(services)}
for _, svc := range services {
detected := readCoverageField(svc, "detected")
unresolved := readCoverageField(svc, "unresolved")
if unresolved > 0 {
sum.UnresolvedEdges += unresolved
sum.ExternalEdges += readCoverageField(svc, "external")
if facts.ClassifyService(facts.DependsOnCount(svc), detected, unresolved) == facts.ServiceCoverageGap {
sum.CoverageGaps++
sum.UnresolvedEdges += unresolved
}
sum.ExternalEdges += readCoverageField(svc, "external")
}
return sum
}
Expand Down
16 changes: 2 additions & 14 deletions internal/explainers/coverage/coverage.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,11 @@ func (e *CoverageExplainer) Explain(ctx context.Context, store *facts.Store) ([]
continue
}

outbound := dependsOnCount(svc)
outbound := facts.DependsOnCount(svc)
evidence := []facts.Evidence{{Fact: svc.Name, Detail: coverageDetail(cov)}}

var insight facts.Insight
if outbound == 0 {
if facts.ClassifyService(outbound, detected, unresolved) == facts.ServiceCoverageGap {
insight = facts.Insight{
Title: fmt.Sprintf("Coverage gap: service %s appears isolated but has %d unresolved outbound call site(s)",
svc.Name, unresolved),
Expand Down Expand Up @@ -106,18 +106,6 @@ func coverageDetail(cov []coverageEntry) string {
return out
}

// dependsOnCount returns how many resolved outbound (cross-repo) dependencies a
// service node carries.
func dependsOnCount(svc facts.Fact) int {
n := 0
for _, rel := range svc.Relations {
if rel.Kind == facts.RelDependsOn {
n++
}
}
return n
}

// readCoverage extracts the edge_coverage entries from a service node's props,
// tolerating both the in-memory shape ([]map[string]any with int values) and the
// shape that survives a facts.jsonl JSON round-trip ([]any of map[string]any with
Expand Down
53 changes: 53 additions & 0 deletions internal/facts/coverage.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package facts

// Service coverage classifications. A service node's class answers "did enola
// resolve this repo's outbound calls, and if not, is that a blind spot or is the
// repo genuinely a leaf?"
const (
// ServiceConnected: at least one outbound call site resolved to a loaded repo.
// Some call sites may still be unresolved (an unloaded repo, a third-party API);
// that is partial coverage, not a blind spot.
ServiceConnected = "connected"
// ServiceCoverageGap: nothing resolved, yet outbound call sites were detected.
// The repo looks isolated but almost certainly is not — verify against source.
ServiceCoverageGap = "coverage_gap"
// ServiceIsolated: no resolved outbound edges and nothing unresolved to explain
// them away — genuinely a leaf.
ServiceIsolated = "isolated"
)

// ClassifyService is the single definition of a service's cross-repo coverage
// class. The snapshot receipt (internal/engine), coverage_report (internal/server)
// and the coverage explainer all classify through here; they previously each
// carried their own copy and the receipt's had drifted, counting every service
// with any unresolved call site as a gap. Because unresolved is derived as
// detected-resolved-external, that made the metric saturate — on a healthy
// multi-repo snapshot every client has some unresolved call site, so the count
// equalled the number of services and could not signal ill health.
//
// outbound is the count of resolved outbound cross-repo dependencies (see
// DependsOnCount); detected and unresolved are summed across the service's
// edge_coverage entries.
func ClassifyService(outbound, detected, unresolved int) string {
if outbound > 0 {
return ServiceConnected
}
// external-only call sites are expected, not a blind spot, so a service with no
// resolved edges but only external calls stays isolated, not a gap.
if detected > 0 && unresolved > 0 {
return ServiceCoverageGap
}
return ServiceIsolated
}

// DependsOnCount returns how many resolved outbound (cross-repo) dependencies a
// service node carries.
func DependsOnCount(svc Fact) int {
n := 0
for _, rel := range svc.Relations {
if rel.Kind == RelDependsOn {
n++
}
}
return n
}
49 changes: 49 additions & 0 deletions internal/facts/coverage_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package facts

import "testing"

func TestClassifyService(t *testing.T) {
tests := []struct {
name string
outbound, detected, unresolved int
want string
}{
{"resolved edge, everything covered", 1, 4, 0, ServiceConnected},
{"resolved edge plus unresolved is partial coverage, not a gap", 1, 4, 1, ServiceConnected},
{"many unresolved still connected while one edge resolved", 1, 1219, 342, ServiceConnected},
{"nothing resolved but call sites detected", 0, 3, 3, ServiceCoverageGap},
{"genuine leaf", 0, 0, 0, ServiceIsolated},
{"external-only call sites are expected, not a blind spot", 0, 3, 0, ServiceIsolated},
// unresolved is derived as detected-resolved-external, so unresolved>0 with
// detected==0 cannot arise from the linker. A hand-authored or truncated fact
// can still produce it; classify it isolated rather than inventing a gap.
{"unresolved without detected", 0, 0, 2, ServiceIsolated},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := ClassifyService(tt.outbound, tt.detected, tt.unresolved); got != tt.want {
t.Errorf("ClassifyService(%d, %d, %d) = %q, want %q",
tt.outbound, tt.detected, tt.unresolved, got, tt.want)
}
})
}
}

func TestDependsOnCount(t *testing.T) {
svc := Fact{
Kind: KindService,
Name: "svc",
Relations: []Relation{
{Kind: RelDependsOn, Target: "a"},
{Kind: RelCalls, Target: "b"},
{Kind: RelDependsOn, Target: "c"},
{Kind: RelImports, Target: "d"},
},
}
if got := DependsOnCount(svc); got != 2 {
t.Errorf("DependsOnCount = %d, want 2 (only depends_on relations)", got)
}
if got := DependsOnCount(Fact{Kind: KindService, Name: "leaf"}); got != 0 {
t.Errorf("DependsOnCount(no relations) = %d, want 0", got)
}
}
2 changes: 1 addition & 1 deletion internal/facts/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ type ParseError struct {
// without the consumer running coverage_report.
type CoverageSummary struct {
ServicesTotal int `json:"services_total"`
CoverageGaps int `json:"coverage_gaps"` // services with unresolved (internal) outbound call sites
CoverageGaps int `json:"coverage_gaps"` // services classified ServiceCoverageGap: no resolved outbound edge, yet unresolved call sites were detected. A service that resolved some edges is partially covered, not a gap
UnresolvedEdges int `json:"unresolved_edges"` // detected outbound edges that did not resolve to a loaded service (internal blind spots; excludes external)
ExternalEdges int `json:"external_edges,omitempty"` // detected outbound edges to hardcoded external hosts (third-party APIs) — expected, not a blind spot
}
Expand Down
22 changes: 3 additions & 19 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -1437,12 +1437,7 @@ func buildCoverageReport(store *facts.Store, repo string) []serviceCoverage {
continue
}

outbound := 0
for _, rel := range svc.Relations {
if rel.Kind == facts.RelDependsOn {
outbound++
}
}
outbound := facts.DependsOnCount(svc)

cov := readEdgeCoverage(svc)
detected, unresolved, external := 0, 0, 0
Expand All @@ -1452,20 +1447,9 @@ func buildCoverageReport(store *facts.Store, repo string) []serviceCoverage {
external += c.External
}

class := "connected"
if outbound == 0 {
// external-only call sites are expected, not a blind spot, so a service
// with no resolved edges but only external calls stays isolated, not a gap.
if detected > 0 && unresolved > 0 {
class = "coverage_gap"
} else {
class = "isolated"
}
}

out = append(out, serviceCoverage{
Service: svc.Name,
Classification: class,
Classification: facts.ClassifyService(outbound, detected, unresolved),
OutboundEdges: outbound,
EdgeCoverage: cov,
UnresolvedTotal: unresolved,
Expand Down Expand Up @@ -1531,7 +1515,7 @@ func renderCoverageReport(report []serviceCoverage) string {

gaps := 0
for _, sc := range report {
if sc.Classification == "coverage_gap" {
if sc.Classification == facts.ServiceCoverageGap {
gaps++
}
}
Expand Down
Loading