a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
290 lines
10 KiB
Go
290 lines
10 KiB
Go
package analysis
|
|
|
|
import (
|
|
"sort"
|
|
|
|
"github.com/zzet/gortex/internal/graph"
|
|
)
|
|
|
|
// connectivity.go reports the connectivity *health of the graph itself* —
|
|
// a diagnostic for extraction/indexing quality, not a code-quality
|
|
// finding.
|
|
//
|
|
// This is deliberately DISTINCT from dead-code analysis (FindDeadCode):
|
|
//
|
|
// - Dead-code analysis reports symbols with zero *incoming usage*
|
|
// edges — genuinely unreachable code. Such a symbol is still a
|
|
// normally extracted node: its file `defines` it, a method is
|
|
// `member_of` its type. The finding is actionable — the code is
|
|
// unused and can be removed.
|
|
//
|
|
// - This analyzer reports *isolated* nodes — nodes with zero edges of
|
|
// *any* kind, structural edges included. A normally extracted
|
|
// function or method always carries at least the structural edge
|
|
// from its file (`defines`); a method additionally a `member_of`
|
|
// edge to its type. A node with zero total edges therefore almost
|
|
// never reflects "unused code" — it reflects that the extractor
|
|
// never processed the symbol (or its file). The finding is a graph
|
|
// *quality* signal: localise the extraction gap, do not delete the
|
|
// code.
|
|
//
|
|
// The isolated/leaf classification reuses graph.ClassifyZeroEdge — the
|
|
// same zero-edge classification used for per-symbol caveats — so the
|
|
// definition of "isolated" stays in lockstep with the rest of Gortex.
|
|
|
|
// ConnectivityFileEntry attributes dead-weight (isolated + leaf) nodes
|
|
// to a single source file, so an extraction gap can be localised.
|
|
type ConnectivityFileEntry struct {
|
|
FilePath string `json:"file_path"`
|
|
// Isolated is the count of zero-edge nodes contributed by this file.
|
|
Isolated int `json:"isolated"`
|
|
// Leaf is the count of degree-1 nodes contributed by this file.
|
|
Leaf int `json:"leaf"`
|
|
// DeadWeight is Isolated+Leaf — the rank key.
|
|
DeadWeight int `json:"dead_weight"`
|
|
}
|
|
|
|
// ConnectivityKindEntry breaks the isolated/leaf counts down by node
|
|
// kind, so a gap concentrated in one kind (e.g. only methods) is
|
|
// visible.
|
|
type ConnectivityKindEntry struct {
|
|
Kind string `json:"kind"`
|
|
Total int `json:"total"`
|
|
Isolated int `json:"isolated"`
|
|
Leaf int `json:"leaf"`
|
|
}
|
|
|
|
// GraphConnectivityReport is the structured connectivity-health report
|
|
// for a set of graph nodes.
|
|
type GraphConnectivityReport struct {
|
|
// NominalNodes is the total node count — the graph's reported size.
|
|
NominalNodes int `json:"nominal_nodes"`
|
|
// EffectiveNodes is the count of nodes with at least one edge — the
|
|
// graph's *connected* size. The two diverge when the extractor
|
|
// dropped edges.
|
|
EffectiveNodes int `json:"effective_nodes"`
|
|
// EffectiveRatio is EffectiveNodes/NominalNodes (1.0 when every node
|
|
// is connected, 0.0 for an empty graph).
|
|
EffectiveRatio float64 `json:"effective_ratio"`
|
|
|
|
// Isolated is the count of nodes with zero edges of any kind —
|
|
// structural edges included. The headline extraction-gap signal.
|
|
Isolated int `json:"isolated"`
|
|
// Leaf is the count of degree-1 nodes (exactly one edge, in or out).
|
|
Leaf int `json:"leaf"`
|
|
// SourceOnly is the count of nodes with only outgoing edges.
|
|
SourceOnly int `json:"source_only"`
|
|
// SinkOnly is the count of nodes with only incoming edges.
|
|
SinkOnly int `json:"sink_only"`
|
|
|
|
// ByKind breaks the totals down by node kind (only kinds that
|
|
// contributed at least one node are listed).
|
|
ByKind []ConnectivityKindEntry `json:"by_kind"`
|
|
// DeadWeightByFile ranks source files by their isolated+leaf node
|
|
// contribution, so an extraction gap can be localised.
|
|
DeadWeightByFile []ConnectivityFileEntry `json:"dead_weight_by_file"`
|
|
|
|
// Note explains, in human-readable form, how this report differs
|
|
// from a dead-code finding — so a reader does not mistake an
|
|
// isolated node for unused code.
|
|
Note string `json:"note"`
|
|
}
|
|
|
|
// connectivityNote is the standing human-readable caveat distinguishing
|
|
// this analyzer from dead-code analysis.
|
|
const connectivityNote = "Connectivity health is a graph-EXTRACTION diagnostic, not a " +
|
|
"code-quality finding. Isolated nodes have zero edges of ANY kind " +
|
|
"(structural `defines`/`member_of` included) — a normally extracted " +
|
|
"symbol always has at least a structural edge, so an isolated node " +
|
|
"signals the indexer mis-extracted the symbol, NOT that the code is " +
|
|
"unused. This is distinct from dead code (analyze kind=dead_code), " +
|
|
"which reports symbols with zero INCOMING usage edges — genuinely " +
|
|
"unreachable code that is safe to remove."
|
|
|
|
// GraphConnectivity computes the connectivity-health report over the
|
|
// supplied nodes. The caller passes the node slice (e.g. a
|
|
// workspace-scoped slice) and the graph the nodes belong to; edge
|
|
// lookups go through g so the report reflects the live edge set.
|
|
//
|
|
// fileLimit caps how many files DeadWeightByFile carries — files are
|
|
// ranked by dead-weight descending, ties broken by path; pass 0 or a
|
|
// negative value for no cap.
|
|
//
|
|
// Backends that implement graph.NodeDegreeAggregator serve every
|
|
// per-node count from one bulk pass; the fallback path runs
|
|
// the legacy per-node GetInEdges + GetOutEdges + ClassifyZeroEdge
|
|
// trio. The arithmetic is identical either way — the capability
|
|
// inlines ClassifyZeroEdge's "no incoming usage edge" check into the
|
|
// same row.
|
|
func GraphConnectivity(g graph.Store, nodes []*graph.Node, fileLimit int) GraphConnectivityReport {
|
|
report := GraphConnectivityReport{Note: connectivityNote}
|
|
if g == nil {
|
|
return report
|
|
}
|
|
|
|
type kindAgg struct {
|
|
total int
|
|
isolated int
|
|
leaf int
|
|
}
|
|
type fileAgg struct {
|
|
isolated int
|
|
leaf int
|
|
}
|
|
byKind := map[graph.NodeKind]*kindAgg{}
|
|
byFile := map[string]*fileAgg{}
|
|
|
|
// Bulk per-node count fetch when the backend supports it; one
|
|
// bulk pair vs. 3N per-node round-trips for the legacy path
|
|
// (the killer on a disk backend — see the NodeDegreeAggregator doc-comment
|
|
// for the workspace-scale numbers). Returns a map keyed on node ID
|
|
// or nil when the capability isn't available; the fallback path
|
|
// re-queries per node via the closure below.
|
|
counts := collectConnectivityCounts(g, nodes)
|
|
|
|
for _, n := range nodes {
|
|
if n == nil {
|
|
continue
|
|
}
|
|
report.NominalNodes++
|
|
|
|
ka := byKind[n.Kind]
|
|
if ka == nil {
|
|
ka = &kindAgg{}
|
|
byKind[n.Kind] = ka
|
|
}
|
|
ka.total++
|
|
|
|
var inCount, outCount int
|
|
if counts != nil {
|
|
row := counts[n.ID]
|
|
inCount = row.InCount
|
|
outCount = row.OutCount
|
|
} else {
|
|
inCount = len(g.GetInEdges(n.ID))
|
|
outCount = len(g.GetOutEdges(n.ID))
|
|
}
|
|
degree := inCount + outCount
|
|
|
|
if degree > 0 {
|
|
report.EffectiveNodes++
|
|
}
|
|
|
|
// Isolated == zero edges of any kind. ClassifyZeroEdge returns
|
|
// ZeroEdgePossibleExtractionGap for exactly this case (for a
|
|
// known node), so the "isolated" definition stays bound to the
|
|
// shared zero-edge classification used for per-symbol caveats.
|
|
// We derive it from the counts directly; the underlying
|
|
// classifier's check is in == 0 && out == 0 for a known id.
|
|
isolated := degree == 0
|
|
leaf := degree == 1
|
|
|
|
if isolated {
|
|
report.Isolated++
|
|
ka.isolated++
|
|
}
|
|
if leaf {
|
|
report.Leaf++
|
|
ka.leaf++
|
|
}
|
|
if degree > 0 && inCount == 0 {
|
|
report.SourceOnly++
|
|
}
|
|
if degree > 0 && outCount == 0 {
|
|
report.SinkOnly++
|
|
}
|
|
|
|
// Dead-weight attribution: an isolated or leaf node is a
|
|
// candidate extraction gap; tally it against its source file
|
|
// so the gap can be localised.
|
|
if isolated || leaf {
|
|
fa := byFile[n.FilePath]
|
|
if fa == nil {
|
|
fa = &fileAgg{}
|
|
byFile[n.FilePath] = fa
|
|
}
|
|
if isolated {
|
|
fa.isolated++
|
|
}
|
|
if leaf {
|
|
fa.leaf++
|
|
}
|
|
}
|
|
}
|
|
|
|
if report.NominalNodes > 0 {
|
|
report.EffectiveRatio = float64(report.EffectiveNodes) / float64(report.NominalNodes)
|
|
}
|
|
|
|
// Per-kind breakdown — only kinds that contributed a node, sorted
|
|
// by kind name for deterministic output.
|
|
report.ByKind = make([]ConnectivityKindEntry, 0, len(byKind))
|
|
for kind, agg := range byKind {
|
|
report.ByKind = append(report.ByKind, ConnectivityKindEntry{
|
|
Kind: string(kind),
|
|
Total: agg.total,
|
|
Isolated: agg.isolated,
|
|
Leaf: agg.leaf,
|
|
})
|
|
}
|
|
sort.Slice(report.ByKind, func(i, j int) bool {
|
|
return report.ByKind[i].Kind < report.ByKind[j].Kind
|
|
})
|
|
|
|
// Dead-weight attribution by file — ranked by dead-weight
|
|
// descending, ties broken by path so output is deterministic.
|
|
report.DeadWeightByFile = make([]ConnectivityFileEntry, 0, len(byFile))
|
|
for path, agg := range byFile {
|
|
report.DeadWeightByFile = append(report.DeadWeightByFile, ConnectivityFileEntry{
|
|
FilePath: path,
|
|
Isolated: agg.isolated,
|
|
Leaf: agg.leaf,
|
|
DeadWeight: agg.isolated + agg.leaf,
|
|
})
|
|
}
|
|
sort.Slice(report.DeadWeightByFile, func(i, j int) bool {
|
|
if report.DeadWeightByFile[i].DeadWeight != report.DeadWeightByFile[j].DeadWeight {
|
|
return report.DeadWeightByFile[i].DeadWeight > report.DeadWeightByFile[j].DeadWeight
|
|
}
|
|
return report.DeadWeightByFile[i].FilePath < report.DeadWeightByFile[j].FilePath
|
|
})
|
|
if fileLimit > 0 && len(report.DeadWeightByFile) > fileLimit {
|
|
report.DeadWeightByFile = report.DeadWeightByFile[:fileLimit]
|
|
}
|
|
|
|
return report
|
|
}
|
|
|
|
// collectConnectivityCounts returns per-node in/out/usage counts for
|
|
// the supplied node slice via the backend's NodeDegreeAggregator
|
|
// capability. Returns nil when the backend doesn't implement the
|
|
// capability — GraphConnectivity then falls back to the legacy
|
|
// per-node g.GetInEdges/g.GetOutEdges path so semantics never differ.
|
|
//
|
|
// We pass UsageInboundEdgeKinds so the server fills UsageInCount —
|
|
// today GraphConnectivity only consumes In/Out totals, but the usage
|
|
// count rides on the same row at no extra round-trip cost and makes
|
|
// the capability self-contained for callers that need it next.
|
|
func collectConnectivityCounts(g graph.Store, nodes []*graph.Node) map[string]graph.NodeDegreeRow {
|
|
agg, ok := g.(graph.NodeDegreeAggregator)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
ids := make([]string, 0, len(nodes))
|
|
for _, n := range nodes {
|
|
if n == nil || n.ID == "" {
|
|
continue
|
|
}
|
|
ids = append(ids, n.ID)
|
|
}
|
|
if len(ids) == 0 {
|
|
return map[string]graph.NodeDegreeRow{}
|
|
}
|
|
rows := agg.NodeDegreeCounts(ids, graph.UsageInboundEdgeKinds())
|
|
out := make(map[string]graph.NodeDegreeRow, len(rows))
|
|
for _, r := range rows {
|
|
out[r.NodeID] = r
|
|
}
|
|
return out
|
|
}
|