Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

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
}