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
1175 lines
40 KiB
Go
1175 lines
40 KiB
Go
package analysis
|
|
|
|
import (
|
|
"math"
|
|
"path/filepath"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
"unicode"
|
|
|
|
"github.com/zzet/gortex/internal/graph"
|
|
)
|
|
|
|
// DeadCodeEntry represents a symbol with zero incoming references that is not excluded.
|
|
type DeadCodeEntry struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Kind string `json:"kind"`
|
|
FilePath string `json:"file_path"`
|
|
Line int `json:"start_line"`
|
|
}
|
|
|
|
// HotspotEntry represents a symbol with disproportionately high complexity metrics.
|
|
type HotspotEntry struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Kind string `json:"kind"`
|
|
FilePath string `json:"file_path"`
|
|
Line int `json:"start_line"`
|
|
FanIn int `json:"fan_in"`
|
|
FanOut int `json:"fan_out"`
|
|
CommunityCrossings int `json:"community_crossings"`
|
|
// Betweenness is the node's betweenness-centrality score
|
|
// normalized to 0-100 — how often it sits on a shortest path
|
|
// between other symbols. A bottleneck the call graph routes
|
|
// through scores high here even when its fan-in/out look modest.
|
|
Betweenness float64 `json:"betweenness"`
|
|
ComplexityScore float64 `json:"complexity_score"`
|
|
}
|
|
|
|
// FindDeadCodeOptions controls filtering behavior for dead code analysis.
|
|
//
|
|
// Default behaviour ships only the high-signal kinds: function, method,
|
|
// type, interface. The opt-in flags below let callers pull in the
|
|
// lower-signal kinds (fields, variables, constants) that the graph can
|
|
// represent but can't reliably evaluate due to the absence of intra-
|
|
// function data-flow edges.
|
|
//
|
|
// Kinds that the dead-code analyzer never reports (regardless of flags):
|
|
// param, closure, generic_param, string, enum_member, module, column,
|
|
// table, config_key, flag, event, migration, fixture, todo, team,
|
|
// release, license, resource, kustomization, image, contract,
|
|
// file, package, import. These are either structural (file/package/
|
|
// import), extracted metadata (todo/team/release/license/fixture),
|
|
// infra (resource/kustomization/image/table/column/config_key/flag/
|
|
// event/migration), or function-shape (param/closure/generic_param)
|
|
// — none of them have a meaningful "is this code dead?" answer, and
|
|
// surfacing them drowns the real dead-function signal in noise.
|
|
type FindDeadCodeOptions struct {
|
|
// IncludeVariables includes variable nodes in the results. Default false.
|
|
// Variables are excluded by default because the graph does not track
|
|
// intra-function data flow — local variables always appear "dead" even
|
|
// though Go's compiler enforces their usage. Package-level variables
|
|
// cannot be reliably distinguished from locals in the current graph model.
|
|
IncludeVariables bool
|
|
|
|
// IncludeFields includes struct/class field nodes in the results.
|
|
// Default false. Same graph limitation as variables: a field read
|
|
// inside a function body is captured as EdgeReads on the field node,
|
|
// but the analyzer can't tell a real "field never read" from "graph
|
|
// doesn't see the read because the resolver couldn't pick a
|
|
// candidate." Fields are opt-in for callers that have manually
|
|
// audited their resolver coverage.
|
|
IncludeFields bool
|
|
|
|
// IncludeConstants includes constant nodes (Go const, language
|
|
// constants). Default false — same rationale as variables; the
|
|
// graph can't distinguish "unused constant" from "constant read
|
|
// inside a function body the resolver couldn't trace."
|
|
IncludeConstants bool
|
|
|
|
// IncludeCgoExports includes functions annotated with //export pragma.
|
|
// Default false — CGo-exported functions are called from C, not Go,
|
|
// so they have no incoming Go-level edges.
|
|
// Requires the Go extractor to populate Node.Meta["cgo_export"] = true.
|
|
IncludeCgoExports bool
|
|
|
|
// IncludeLinknameTargets includes functions annotated with //go:linkname.
|
|
// Default false — linkname targets are linked by name from another package
|
|
// and have no visible call edges in the graph.
|
|
// Requires the Go extractor to populate Node.Meta["go_linkname"] = true.
|
|
IncludeLinknameTargets bool
|
|
|
|
// SkipCrossRepoNodes excludes nodes whose RepoPrefix is non-empty.
|
|
// Useful when cross-repo linking is incomplete — functions in secondary
|
|
// repos may lack incoming edges from the primary repo.
|
|
SkipCrossRepoNodes bool
|
|
}
|
|
|
|
// neverDeadCodeKinds enumerates node kinds the dead-code analyzer must
|
|
// never report — regardless of opt-in flags — because the question
|
|
// "is this code dead?" has no meaningful answer for them. Includes
|
|
// structural nodes (file/package/import), function-shape nodes
|
|
// (param/closure/generic_param), extracted metadata (todo/team/
|
|
// release/license), infra surface (table/column/migration/config_key/
|
|
// flag/event/fixture/resource/kustomization/image), package metadata
|
|
// (module), and value-extraction nodes (string/enum_member).
|
|
// Surfacing any of these drowns real dead-function signal in noise.
|
|
var neverDeadCodeKinds = map[graph.NodeKind]bool{
|
|
graph.KindFile: true,
|
|
graph.KindPackage: true,
|
|
graph.KindImport: true,
|
|
graph.KindParam: true,
|
|
graph.KindClosure: true,
|
|
graph.KindGenericParam: true,
|
|
graph.KindString: true,
|
|
graph.KindEnumMember: true,
|
|
graph.KindModule: true,
|
|
graph.KindColumn: true,
|
|
graph.KindTable: true,
|
|
graph.KindConfigKey: true,
|
|
graph.KindFlag: true,
|
|
graph.KindEvent: true,
|
|
graph.KindMigration: true,
|
|
graph.KindFixture: true,
|
|
graph.KindTodo: true,
|
|
graph.KindTeam: true,
|
|
graph.KindRelease: true,
|
|
graph.KindLicense: true,
|
|
graph.KindResource: true,
|
|
graph.KindKustomization: true,
|
|
graph.KindImage: true,
|
|
graph.KindContract: true,
|
|
}
|
|
|
|
// incomingUsageKinds returns the set of incoming edge kinds that count
|
|
// as "this symbol is used" for the given node kind. The per-kind list
|
|
// matters because different shapes are exercised by different edges:
|
|
// a function is used via Calls or References, a type via References /
|
|
// Instantiates / MemberOf, a field via Reads or Writes.
|
|
//
|
|
// Before this split, the analyzer used a single global allowlist
|
|
// {Calls, References, MemberOf, Implements, Instantiates} — which
|
|
// meant struct fields and variables always appeared dead because
|
|
// the resolver records their use as EdgeReads, which wasn't in the
|
|
// allowlist. The result was 5,390 fields flagged across the gortex
|
|
// workspace, drowning out the ~300 real function-level signals.
|
|
func incomingUsageKinds(k graph.NodeKind) []graph.EdgeKind {
|
|
switch k {
|
|
case graph.KindFunction:
|
|
// Calls: invoked as `foo()`. References: passed as a value
|
|
// (`RunE: runClean`). MemberOf: appears in a method-table /
|
|
// receiver mapping. Instantiates: NewFoo() pattern when the
|
|
// receiver type is the function type itself.
|
|
return []graph.EdgeKind{
|
|
graph.EdgeCalls, graph.EdgeReferences,
|
|
graph.EdgeMemberOf, graph.EdgeInstantiates,
|
|
}
|
|
case graph.KindMethod:
|
|
// Same as functions plus: Implements (the method satisfies
|
|
// an interface contract — required by the interface).
|
|
return []graph.EdgeKind{
|
|
graph.EdgeCalls, graph.EdgeReferences,
|
|
graph.EdgeMemberOf, graph.EdgeImplements, graph.EdgeInstantiates,
|
|
}
|
|
case graph.KindType, graph.KindInterface:
|
|
// Types are exercised by References (generic value-position
|
|
// use), Instantiates (struct literal), MemberOf (methods/
|
|
// fields hanging off the type), Implements (a type satisfies
|
|
// this interface), Extends (subclass), Composes (embeds),
|
|
// TypedAs (variable / param / field declared as this type),
|
|
// Returns (function returns this type — the canonical pattern
|
|
// for cross-package type re-export via `type X = pkg.X`).
|
|
return []graph.EdgeKind{
|
|
graph.EdgeReferences, graph.EdgeInstantiates,
|
|
graph.EdgeMemberOf, graph.EdgeImplements,
|
|
graph.EdgeExtends, graph.EdgeComposes,
|
|
graph.EdgeTypedAs, graph.EdgeReturns,
|
|
}
|
|
case graph.KindField:
|
|
// Fields are accessed via Reads/Writes (the dominant pattern)
|
|
// and References (when a struct literal positionally fills the
|
|
// field). MemberOf isn't a "use" — it just attaches the field
|
|
// to its owner type.
|
|
return []graph.EdgeKind{
|
|
graph.EdgeReads, graph.EdgeWrites, graph.EdgeReferences,
|
|
}
|
|
case graph.KindVariable, graph.KindConstant:
|
|
// Same as fields: Reads/Writes dominate; References covers
|
|
// the value-as-arg case.
|
|
return []graph.EdgeKind{
|
|
graph.EdgeReads, graph.EdgeWrites, graph.EdgeReferences,
|
|
}
|
|
}
|
|
// Fallback for any kind not specifically modelled: use the legacy
|
|
// global allowlist so a future KindWidget doesn't silently
|
|
// collapse to "always dead."
|
|
return []graph.EdgeKind{
|
|
graph.EdgeCalls, graph.EdgeReferences,
|
|
graph.EdgeMemberOf, graph.EdgeImplements, graph.EdgeInstantiates,
|
|
}
|
|
}
|
|
|
|
// isEntryPointNode reports whether n was stamped as a framework entry
|
|
// point (Alembic / Next.js / ASP.NET) by the entrypoints detector.
|
|
func isEntryPointNode(n *graph.Node) bool {
|
|
if n == nil || n.Meta == nil {
|
|
return false
|
|
}
|
|
v, _ := n.Meta["entry_point"].(bool)
|
|
return v
|
|
}
|
|
|
|
// candidateNodeKinds enumerates the node kinds FindDeadCode is willing
|
|
// to flag (modulo the opt-in switches for fields / variables /
|
|
// constants). Used both for the per-kind allowlist handed to the
|
|
// DeadCodeCandidator capability and as the source of truth for the
|
|
// Go-fallback loop. Kept in lockstep with neverDeadCodeKinds: a kind
|
|
// MUST appear in exactly one of the two lists.
|
|
var candidateNodeKinds = []graph.NodeKind{
|
|
graph.KindFunction,
|
|
graph.KindMethod,
|
|
graph.KindType,
|
|
graph.KindInterface,
|
|
graph.KindField,
|
|
graph.KindVariable,
|
|
graph.KindConstant,
|
|
}
|
|
|
|
// FindDeadCode returns all symbols with zero incoming calls or references,
|
|
// excluding entry points, test functions, exported symbols, and user-excluded patterns.
|
|
// By default, variables are excluded (see FindDeadCodeOptions for rationale).
|
|
func FindDeadCode(g graph.Store, processes *ProcessResult, excludePatterns []string, opts ...FindDeadCodeOptions) []DeadCodeEntry {
|
|
var opt FindDeadCodeOptions
|
|
if len(opts) > 0 {
|
|
opt = opts[0]
|
|
}
|
|
|
|
// Build set of interface-required method names per type.
|
|
// If a type implements an interface, all methods that the interface
|
|
// requires are alive even if never called directly (they satisfy the
|
|
// contract). We index: typeID → set of required method names.
|
|
// Backends that implement graph.IfaceImplementsScanner serve this
|
|
// from one join; the fallback walks NodesByKind + EdgesByKind
|
|
// just like before.
|
|
ifaceRequiredMethods := buildIfaceRequiredMethods(g)
|
|
|
|
// Pick the candidate-set source. When the backend implements
|
|
// DeadCodeCandidator, the "no incoming usage edge" filter runs
|
|
// inside the store and only the surviving ~hundreds of true
|
|
// candidates are materialized — see graph.DeadCodeCandidator's
|
|
// doc-comment for the 1.3M-row-vs-hundreds rationale. Otherwise
|
|
// the legacy AllNodes + GetInEdgesByNodeIDs fallback runs,
|
|
// identical to the pre-capability path.
|
|
candidates, incomingByID := collectDeadCodeCandidates(g, opt)
|
|
|
|
// Build set of entry point node IDs from processes
|
|
entryPoints := make(map[string]bool)
|
|
if processes != nil {
|
|
for _, proc := range processes.Processes {
|
|
entryPoints[proc.EntryPoint] = true
|
|
// Also consider all nodes that participate in any process
|
|
for _, step := range proc.Steps {
|
|
entryPoints[step.ID] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// Files holding a framework entry point (Alembic migrations,
|
|
// Next.js pages, ASP.NET host files) — every symbol inside is
|
|
// reachable from a runtime, not application-dead. Computed via
|
|
// NodesByKind(KindFile) so on disk backends we don't have to
|
|
// materialise AllNodes() just to find the entry-point files.
|
|
entryPointFiles := make(map[string]bool)
|
|
for n := range g.NodesByKind(graph.KindFile) {
|
|
if n != nil && isEntryPointNode(n) {
|
|
entryPointFiles[n.FilePath] = true
|
|
}
|
|
}
|
|
|
|
var result []DeadCodeEntry
|
|
for _, n := range candidates {
|
|
// Skip kinds the analyzer never reports — structural,
|
|
// extracted metadata, infra, function-shape, and value-only
|
|
// nodes. See neverDeadCodeKinds for the full list and why.
|
|
// (The server-side candidator only ships nodes whose kind is
|
|
// in candidateNodeKinds, but the Go fallback path scans
|
|
// AllNodes so we keep the explicit gate.)
|
|
if neverDeadCodeKinds[n.Kind] {
|
|
continue
|
|
}
|
|
|
|
// Synthetic external-symbol / stub nodes are NOT first-party
|
|
// code. The external-call attribution pass materialises imported
|
|
// stdlib / dependency / external symbols as KindFunction /
|
|
// KindMethod nodes (IDs like "stdlib::fmt::Sprintf",
|
|
// "dep::<mod>::Sym", "external::<path>::Sym") stamped with
|
|
// Meta["external"]=true; the stub layer mints "<kind>::*" IDs for
|
|
// stdlib/external_call/builtin/module targets. By construction
|
|
// these carry only inbound import / member_of links — never a
|
|
// call/reference usage edge — so they ALWAYS look dead. Reporting
|
|
// them buried the real first-party signal under thousands of
|
|
// stdlib/dep entries. Drop them unconditionally.
|
|
if graph.IsStub(n.ID) {
|
|
continue
|
|
}
|
|
if ext, _ := n.Meta["external"].(bool); ext {
|
|
continue
|
|
}
|
|
// Code-generated symbols (RTK Query hooks, protobuf accessors, …) have
|
|
// no hand-written call site by design, so they always look dead — never
|
|
// report them.
|
|
if gen, _ := n.Meta["generated"].(bool); gen {
|
|
continue
|
|
}
|
|
|
|
// Framework entry points, and everything in an entry-point
|
|
// file, are invoked by a runtime — never dead.
|
|
if isEntryPointNode(n) || entryPointFiles[n.FilePath] {
|
|
continue
|
|
}
|
|
|
|
// Skip variables/fields/constants unless explicitly opted in.
|
|
// All three are subject to the same graph limitation: the
|
|
// resolver can't always pick a candidate for intra-function
|
|
// reads, so they look dead even when the code reads them
|
|
// every line. We err toward false-negative (miss a real dead
|
|
// variable) over false-positive (flag every struct field
|
|
// in the repo) — the latter destroys the signal of the
|
|
// function/method results we DO trust.
|
|
if n.Kind == graph.KindVariable && !opt.IncludeVariables {
|
|
continue
|
|
}
|
|
if n.Kind == graph.KindField && !opt.IncludeFields {
|
|
continue
|
|
}
|
|
if n.Kind == graph.KindConstant && !opt.IncludeConstants {
|
|
continue
|
|
}
|
|
|
|
// Skip implicitly-called constructors/initializers.
|
|
// Go: init() is called by the runtime.
|
|
// Python: __init__ is called when a class is instantiated.
|
|
if n.Name == "init" && n.Language == "go" {
|
|
continue
|
|
}
|
|
if n.Name == "__init__" && n.Language == "python" {
|
|
continue
|
|
}
|
|
|
|
// Skip Go main() — it's the binary entry point, called by the runtime.
|
|
// Constrained to KindFunction so (*Foo).main() methods are still checked.
|
|
if n.Name == "main" && n.Language == "go" && n.Kind == graph.KindFunction {
|
|
continue
|
|
}
|
|
|
|
// Skip vendored/generated C header functions — they're used via C
|
|
// macros and linker symbols, invisible to the graph.
|
|
if isVendoredOrGenerated(n.FilePath) {
|
|
continue
|
|
}
|
|
|
|
// Skip functions in Go files with build constraints — only one
|
|
// variant is active per build, so the others always look "dead".
|
|
if n.Language == "go" && hasBuildConstraint(n.FilePath) {
|
|
continue
|
|
}
|
|
|
|
// Re-check the per-kind incoming-edge allowlist when we still
|
|
// have the in-edge map from the Go fallback path. The
|
|
// server-side DeadCodeCandidator has already applied the
|
|
// equivalent filter, so incomingByID is nil for that path and
|
|
// the count check short-circuits to 0 (matching the
|
|
// candidator's contract).
|
|
incomingCount := 0
|
|
if incomingByID != nil {
|
|
allowed := incomingUsageKinds(n.Kind)
|
|
inEdges := incomingByID[n.ID]
|
|
for _, e := range inEdges {
|
|
if slices.Contains(allowed, e.Kind) {
|
|
incomingCount++
|
|
}
|
|
}
|
|
}
|
|
|
|
if incomingCount > 0 {
|
|
continue
|
|
}
|
|
|
|
// For methods with zero incoming edges, check if they exist to satisfy
|
|
// an interface contract. Look up the receiver type via member_of edges
|
|
// and check if any implemented interface requires this method name.
|
|
if n.Kind == graph.KindMethod {
|
|
outEdges := g.GetOutEdges(n.ID)
|
|
for _, e := range outEdges {
|
|
if e.Kind == graph.EdgeMemberOf {
|
|
if required, ok := ifaceRequiredMethods[e.To]; ok {
|
|
if required[n.Name] {
|
|
incomingCount++ // treat as alive
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if incomingCount > 0 {
|
|
continue
|
|
}
|
|
|
|
// Java: a method tagged @Override implements or overrides a
|
|
// supertype member and is reachable through that contract even
|
|
// with no direct caller in the indexed graph. Public overrides
|
|
// are already excluded by visibility; this rescues protected /
|
|
// package-private overrides from a false positive.
|
|
if n.Language == "java" {
|
|
overridden := false
|
|
for _, e := range outEdges {
|
|
if e.Kind == graph.EdgeAnnotated && e.To == javaOverrideAnnoID {
|
|
overridden = true
|
|
break
|
|
}
|
|
}
|
|
if overridden {
|
|
continue
|
|
}
|
|
}
|
|
|
|
// Fallback: well-known standard-library interface methods.
|
|
// If the implements edge wasn't inferred, methods like ServeHTTP,
|
|
// MarshalJSON, String, etc. are still almost certainly alive.
|
|
if isWellKnownInterfaceMethod(n.Name, n.Language) {
|
|
continue
|
|
}
|
|
}
|
|
|
|
// Skip CGo-exported functions (called from C, no Go-level callers).
|
|
if n.Language == "go" && !opt.IncludeCgoExports {
|
|
if cgoExport, ok := n.Meta["cgo_export"].(bool); ok && cgoExport {
|
|
continue
|
|
}
|
|
}
|
|
|
|
// Skip go:linkname targets (linked by name from another package).
|
|
if n.Language == "go" && !opt.IncludeLinknameTargets {
|
|
if linkname, ok := n.Meta["go_linkname"].(bool); ok && linkname {
|
|
continue
|
|
}
|
|
}
|
|
|
|
// Skip nodes from secondary repos when cross-repo linking is incomplete.
|
|
if opt.SkipCrossRepoNodes && n.RepoPrefix != "" {
|
|
continue
|
|
}
|
|
|
|
// Check exclusions
|
|
if entryPoints[n.ID] {
|
|
continue
|
|
}
|
|
if isTestFilePath(n.FilePath) {
|
|
continue
|
|
}
|
|
if isExportedNode(n) && !isPackagePrivateByConvention(n.FilePath, n.Language) {
|
|
continue
|
|
}
|
|
if matchesExcludePattern(n.FilePath, n.ID, excludePatterns) {
|
|
continue
|
|
}
|
|
|
|
result = append(result, DeadCodeEntry{
|
|
ID: n.ID,
|
|
Name: n.Name,
|
|
Kind: string(n.Kind),
|
|
FilePath: n.FilePath,
|
|
Line: n.StartLine,
|
|
})
|
|
}
|
|
|
|
// Sort by file path then line for deterministic output
|
|
sort.Slice(result, func(i, j int) bool {
|
|
if result[i].FilePath != result[j].FilePath {
|
|
return result[i].FilePath < result[j].FilePath
|
|
}
|
|
return result[i].Line < result[j].Line
|
|
})
|
|
|
|
return result
|
|
}
|
|
|
|
// collectDeadCodeCandidates is the candidate-set splitter for
|
|
// FindDeadCode. When the backend implements DeadCodeCandidator the
|
|
// WHERE-NOT-EXISTS filter runs server-side and we never materialise
|
|
// the in-edge map (returned nil). Otherwise we fall back to today's
|
|
// AllNodes + batched-GetInEdgesByNodeIDs path, identical pre-Part-2
|
|
// behaviour. The post-filter loop in FindDeadCode handles both shapes
|
|
// uniformly — incomingByID==nil means "filter already applied".
|
|
func collectDeadCodeCandidates(g graph.Store, opt FindDeadCodeOptions) (candidates []*graph.Node, incomingByID map[string][]*graph.Edge) {
|
|
if dc, ok := g.(graph.DeadCodeCandidator); ok {
|
|
kinds := candidateNodeKinds[:0:0]
|
|
for _, k := range candidateNodeKinds {
|
|
// Honour the IncludeFields / IncludeVariables / IncludeConstants
|
|
// opt-in switches at the candidate-source: kinds the caller
|
|
// explicitly excluded never need to cross cgo. The post-
|
|
// filter loop still re-checks these for the fallback path
|
|
// (which sees every kind) so the contract holds either way.
|
|
switch k {
|
|
case graph.KindField:
|
|
if !opt.IncludeFields {
|
|
continue
|
|
}
|
|
case graph.KindVariable:
|
|
if !opt.IncludeVariables {
|
|
continue
|
|
}
|
|
case graph.KindConstant:
|
|
if !opt.IncludeConstants {
|
|
continue
|
|
}
|
|
}
|
|
kinds = append(kinds, k)
|
|
}
|
|
allowed := make(map[graph.NodeKind][]graph.EdgeKind, len(kinds))
|
|
for _, k := range kinds {
|
|
allowed[k] = incomingUsageKinds(k)
|
|
}
|
|
return dc.DeadCodeCandidates(kinds, allowed), nil
|
|
}
|
|
|
|
// Fallback: pull every node and the batched in-edge map up front.
|
|
// Same shape as before the DeadCodeCandidator capability landed.
|
|
nodes := g.AllNodes()
|
|
nodeIDs := make([]string, 0, len(nodes))
|
|
for _, n := range nodes {
|
|
nodeIDs = append(nodeIDs, n.ID)
|
|
}
|
|
return nodes, g.GetInEdgesByNodeIDs(nodeIDs)
|
|
}
|
|
|
|
// buildIfaceRequiredMethods returns a map from type ID → set of method names
|
|
// that the type must implement to satisfy its interfaces. This is computed by:
|
|
// 1. Collecting all interfaces with their required method names (from Meta["methods"]).
|
|
// 2. Collecting all EdgeImplements edges (type → interface).
|
|
// 3. For each type that implements an interface, merging all required method names.
|
|
//
|
|
// On backends that implement graph.IfaceImplementsScanner this is a
|
|
// single join; otherwise the fallback iterates
|
|
// NodesByKind(KindInterface) + EdgesByKind(EdgeImplements). Both paths
|
|
// produce the same map.
|
|
func buildIfaceRequiredMethods(g graph.Store) map[string]map[string]bool {
|
|
if scanner, ok := g.(graph.IfaceImplementsScanner); ok {
|
|
return buildIfaceRequiredMethodsFromRows(scanner.IfaceImplementsRows())
|
|
}
|
|
|
|
// Fallback: walk interfaces + EdgeImplements edges Go-side. Uses
|
|
// NodesByKind(KindInterface) so disk backends still issue one
|
|
// scan per kind instead of pulling AllNodes.
|
|
ifaceMethods := make(map[string]map[string]bool)
|
|
for n := range g.NodesByKind(graph.KindInterface) {
|
|
if n == nil || n.Meta == nil {
|
|
continue
|
|
}
|
|
raw, ok := n.Meta["methods"]
|
|
if !ok {
|
|
continue
|
|
}
|
|
methods := decodeMethodNames(raw)
|
|
if len(methods) > 0 {
|
|
ifaceMethods[n.ID] = methods
|
|
}
|
|
}
|
|
|
|
if len(ifaceMethods) == 0 {
|
|
return nil
|
|
}
|
|
|
|
result := make(map[string]map[string]bool)
|
|
for e := range g.EdgesByKind(graph.EdgeImplements) {
|
|
// EdgeImplements: From=type, To=interface
|
|
iface, ok := ifaceMethods[e.To]
|
|
if !ok {
|
|
continue
|
|
}
|
|
if result[e.From] == nil {
|
|
result[e.From] = make(map[string]bool)
|
|
}
|
|
for m := range iface {
|
|
result[e.From][m] = true
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// buildIfaceRequiredMethodsFromRows reduces the server-side
|
|
// IfaceImplementsScanner row set to the typeID → method-name-set
|
|
// shape the rest of FindDeadCode consumes. Same join logic as the
|
|
// fallback path, just folded over rows that already carry the
|
|
// interface Meta.
|
|
func buildIfaceRequiredMethodsFromRows(rows []graph.IfaceImplementsRow) map[string]map[string]bool {
|
|
if len(rows) == 0 {
|
|
return nil
|
|
}
|
|
// Cache decoded method-name sets per interface so repeated rows
|
|
// (one per implementing type) don't re-decode the same Meta.
|
|
ifaceMethods := make(map[string]map[string]bool)
|
|
result := make(map[string]map[string]bool)
|
|
for _, r := range rows {
|
|
methods, ok := ifaceMethods[r.IfaceID]
|
|
if !ok {
|
|
raw, hasRaw := r.IfaceMeta["methods"]
|
|
if !hasRaw {
|
|
ifaceMethods[r.IfaceID] = nil
|
|
continue
|
|
}
|
|
methods = decodeMethodNames(raw)
|
|
ifaceMethods[r.IfaceID] = methods
|
|
}
|
|
if len(methods) == 0 {
|
|
continue
|
|
}
|
|
if result[r.TypeID] == nil {
|
|
result[r.TypeID] = make(map[string]bool)
|
|
}
|
|
for m := range methods {
|
|
result[r.TypeID][m] = true
|
|
}
|
|
}
|
|
if len(result) == 0 {
|
|
return nil
|
|
}
|
|
return result
|
|
}
|
|
|
|
// decodeMethodNames normalises a Node.Meta["methods"] value into a
|
|
// set of method names. Accepts []string (in-memory backend) and
|
|
// []any (decoded payload from the disk backend); anything else is
|
|
// treated as "no methods declared".
|
|
func decodeMethodNames(raw any) map[string]bool {
|
|
methods := make(map[string]bool)
|
|
switch v := raw.(type) {
|
|
case []string:
|
|
for _, m := range v {
|
|
methods[m] = true
|
|
}
|
|
case []any:
|
|
for _, m := range v {
|
|
if s, ok := m.(string); ok {
|
|
methods[s] = true
|
|
}
|
|
}
|
|
}
|
|
return methods
|
|
}
|
|
|
|
// hotspotBetweennessWeight scales the betweenness component of a
|
|
// hotspot's raw score. Betweenness arrives normalized to 0-100 (same
|
|
// range as the fan-in/out/crossing terms after their own
|
|
// normalization is implicit), so a weight of 0.4 lets a pure
|
|
// bottleneck — a symbol every call path routes through — register as
|
|
// a hotspot without overpowering the fan-in/out signals that still
|
|
// dominate the ranking.
|
|
const hotspotBetweennessWeight = 0.4
|
|
|
|
// FindHotspots returns symbols whose ComplexityScore exceeds the given threshold.
|
|
// ComplexityScore = (fan_in * 2) + (fan_out * 1.5) + (community_crossings * 3) +
|
|
// (betweenness * hotspotBetweennessWeight), normalized to 0-100. Betweenness is a
|
|
// centrality component — how often the symbol lies on a shortest path between
|
|
// other symbols — that augments the fan-in/out signals rather than replacing them.
|
|
// If threshold <= 0, the default threshold is mean + 2*stddev.
|
|
func FindHotspots(g graph.Store, communities *CommunityResult, threshold float64) []HotspotEntry {
|
|
// Pull only function/method node IDs — the hotspots ranking is
|
|
// callable-only, and the scoring math doesn't touch any column
|
|
// beyond the id. NodeIDsByKinds returns the projection from a
|
|
// single query (one id per row instead of the ~10
|
|
// columns NodesByKinds would ship). The full *Node rows are
|
|
// fetched in one batched GetNodesByIDs call AFTER the threshold
|
|
// filter, so a typical run materialises ~100 survivors rather
|
|
// than the whole ~4k function/method bucket.
|
|
hotspotKinds := []graph.NodeKind{graph.KindFunction, graph.KindMethod}
|
|
var candidateIDs []string
|
|
if scan, ok := g.(graph.NodeIDsByKinds); ok {
|
|
candidateIDs = scan.NodeIDsByKinds(hotspotKinds)
|
|
} else if scan, ok := g.(graph.NodesByKindsScanner); ok {
|
|
ns := scan.NodesByKinds(hotspotKinds)
|
|
candidateIDs = make([]string, 0, len(ns))
|
|
for _, n := range ns {
|
|
candidateIDs = append(candidateIDs, n.ID)
|
|
}
|
|
} else {
|
|
all := g.AllNodes()
|
|
candidateIDs = make([]string, 0, len(all))
|
|
for _, n := range all {
|
|
if n.Kind == graph.KindFunction || n.Kind == graph.KindMethod {
|
|
candidateIDs = append(candidateIDs, n.ID)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Build lookup maps for community membership
|
|
nodeToComm := make(map[string]string)
|
|
if communities != nil {
|
|
nodeToComm = communities.NodeToComm
|
|
}
|
|
|
|
// Restrict the fan-count pass to the kinds hotspots cares about
|
|
// (function + method). NodeFanAggregator expects the candidate id
|
|
// list -- it never returns rows for ids the caller didn't ask
|
|
// for, so the cgo payload stays bounded by the candidate count
|
|
// rather than the whole graph.
|
|
fanIn, fanOut := CollectFanCounts(g, candidateIDs,
|
|
[]graph.EdgeKind{graph.EdgeCalls, graph.EdgeReferences},
|
|
[]graph.EdgeKind{graph.EdgeCalls},
|
|
)
|
|
|
|
// Community crossings per node: outgoing edges (Calls or
|
|
// References) whose target sits in a different community than
|
|
// the source. CommunityCrossingsByKind ships only the (from, to)
|
|
// projection from a single IN-list join — the disk path stops
|
|
// re-materialising the full edge row per kind. Backends that
|
|
// don't implement the capability fall back to the per-kind
|
|
// EdgesByKind walk that mirrors the in-memory reference.
|
|
crossingKinds := []graph.EdgeKind{graph.EdgeCalls, graph.EdgeReferences}
|
|
var crossings map[string]int
|
|
if cc, ok := g.(graph.CommunityCrossingsByKind); ok {
|
|
crossings = cc.CommunityCrossingsByKind(crossingKinds, nodeToComm)
|
|
}
|
|
if crossings == nil {
|
|
crossings = make(map[string]int)
|
|
countCrossings := func(kind graph.EdgeKind) {
|
|
for e := range g.EdgesByKind(kind) {
|
|
if e == nil {
|
|
continue
|
|
}
|
|
fromComm := nodeToComm[e.From]
|
|
toComm := nodeToComm[e.To]
|
|
if fromComm != "" && toComm != "" && fromComm != toComm {
|
|
crossings[e.From]++
|
|
}
|
|
}
|
|
}
|
|
for _, k := range crossingKinds {
|
|
countCrossings(k)
|
|
}
|
|
}
|
|
|
|
// Betweenness centrality — exact on small graphs, sampled on
|
|
// large ones. Normalized to 0-100 against the graph's own max so
|
|
// it sits on the same scale as the other score terms.
|
|
bc := ComputeBetweenness(g)
|
|
betweenness := make(map[string]float64, len(bc.Scores))
|
|
if bc.Max > 0 {
|
|
for id, v := range bc.Scores {
|
|
betweenness[id] = (v / bc.Max) * 100.0
|
|
}
|
|
}
|
|
|
|
// Compute raw scores for function/method nodes only. Keyed by id
|
|
// so the full *Node fetch is deferred until after the threshold
|
|
// filter — on a ~4k candidate set the surviving share is the top
|
|
// few percent, so this materialises ~100 nodes instead of the
|
|
// whole bucket.
|
|
type rawEntry struct {
|
|
id string
|
|
fanIn int
|
|
fanOut int
|
|
crossing int
|
|
betweenness float64
|
|
rawScore float64
|
|
}
|
|
|
|
entries := make([]rawEntry, 0, len(candidateIDs))
|
|
for _, id := range candidateIDs {
|
|
fi := fanIn[id]
|
|
fo := fanOut[id]
|
|
cc := crossings[id]
|
|
bw := betweenness[id]
|
|
raw := float64(fi)*2.0 + float64(fo)*1.5 + float64(cc)*3.0 + bw*hotspotBetweennessWeight
|
|
|
|
entries = append(entries, rawEntry{
|
|
id: id,
|
|
fanIn: fi,
|
|
fanOut: fo,
|
|
crossing: cc,
|
|
betweenness: bw,
|
|
rawScore: raw,
|
|
})
|
|
}
|
|
|
|
if len(entries) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// Find max raw score for normalization
|
|
maxRaw := 0.0
|
|
for _, e := range entries {
|
|
if e.rawScore > maxRaw {
|
|
maxRaw = e.rawScore
|
|
}
|
|
}
|
|
|
|
// Normalize to 0-100
|
|
normalized := make([]float64, len(entries))
|
|
for i, e := range entries {
|
|
if maxRaw > 0 {
|
|
normalized[i] = (e.rawScore / maxRaw) * 100.0
|
|
}
|
|
}
|
|
|
|
// Compute default threshold if not specified: mean + 2*stddev
|
|
if threshold <= 0 {
|
|
var sum float64
|
|
for _, s := range normalized {
|
|
sum += s
|
|
}
|
|
mean := sum / float64(len(normalized))
|
|
|
|
var variance float64
|
|
for _, s := range normalized {
|
|
diff := s - mean
|
|
variance += diff * diff
|
|
}
|
|
variance /= float64(len(normalized))
|
|
stddev := math.Sqrt(variance)
|
|
|
|
threshold = mean + 2.0*stddev
|
|
}
|
|
|
|
// Filter by threshold first to identify the surviving id set, so
|
|
// the full *Node materialisation is bounded by the result size,
|
|
// not the candidate count.
|
|
type survivor struct {
|
|
entryIdx int
|
|
score float64
|
|
}
|
|
survivors := make([]survivor, 0, len(entries))
|
|
for i := range entries {
|
|
score := math.Round(normalized[i]*100) / 100 // round to 2 decimal places
|
|
if score < threshold {
|
|
continue
|
|
}
|
|
survivors = append(survivors, survivor{entryIdx: i, score: score})
|
|
}
|
|
if len(survivors) == 0 {
|
|
return nil
|
|
}
|
|
|
|
survivorIDs := make([]string, 0, len(survivors))
|
|
for _, s := range survivors {
|
|
survivorIDs = append(survivorIDs, entries[s.entryIdx].id)
|
|
}
|
|
nodesByID := g.GetNodesByIDs(survivorIDs)
|
|
|
|
result := make([]HotspotEntry, 0, len(survivors))
|
|
for _, s := range survivors {
|
|
e := entries[s.entryIdx]
|
|
n := nodesByID[e.id]
|
|
if n == nil {
|
|
continue
|
|
}
|
|
result = append(result, HotspotEntry{
|
|
ID: n.ID,
|
|
Name: n.Name,
|
|
Kind: string(n.Kind),
|
|
FilePath: n.FilePath,
|
|
Line: n.StartLine,
|
|
FanIn: e.fanIn,
|
|
FanOut: e.fanOut,
|
|
CommunityCrossings: e.crossing,
|
|
Betweenness: math.Round(e.betweenness*100) / 100,
|
|
ComplexityScore: s.score,
|
|
})
|
|
}
|
|
|
|
// Sort by ComplexityScore descending
|
|
sort.Slice(result, func(i, j int) bool {
|
|
return result[i].ComplexityScore > result[j].ComplexityScore
|
|
})
|
|
|
|
return result
|
|
}
|
|
|
|
// isTestFilePath checks if a file path indicates a test file.
|
|
func isTestFilePath(path string) bool {
|
|
base := filepath.Base(path)
|
|
return strings.Contains(base, "_test.") ||
|
|
strings.Contains(base, ".test.") ||
|
|
strings.Contains(base, ".spec.") ||
|
|
strings.HasPrefix(base, "test_") ||
|
|
strings.Contains(path, "__tests__/")
|
|
}
|
|
|
|
// isPackagePrivateByConvention reports whether a file lives inside a
|
|
// directory the language's tooling treats as package-private regardless of
|
|
// individual symbol capitalisation. The dead-code analyzer uses this to
|
|
// override the "skip all exported symbols" rule: a function inside
|
|
// `gortex/internal/parser/tsitter/` named `Test` is *visible only to other
|
|
// gortex packages*, so if no caller exists in the indexed graph it really
|
|
// is dead — there's nowhere else it could be called from.
|
|
//
|
|
// Currently handles Go's `/internal/` convention (compiler-enforced since
|
|
// Go 1.4). Add other languages as their tooling acquires similar
|
|
// hard-bounded visibility rules.
|
|
func isPackagePrivateByConvention(filePath, lang string) bool {
|
|
if lang != "go" {
|
|
return false
|
|
}
|
|
// Match the path component "internal" anywhere in the path — Go's rule
|
|
// is that anything inside an `internal/` directory is only importable
|
|
// from its enclosing tree.
|
|
return strings.Contains(filePath, "/internal/") ||
|
|
strings.HasPrefix(filePath, "internal/")
|
|
}
|
|
|
|
// keywordVisibilityLangs are languages whose access level is an explicit
|
|
// modifier keyword (public/private/protected/...) that the extractor
|
|
// records in Meta["visibility"], rather than a naming convention. For
|
|
// these the name-based isExportedSymbol gives the wrong answer — every
|
|
// Java identifier is non-underscore, so the underscore heuristic marks
|
|
// them ALL exported and dead-code analysis skips the whole language.
|
|
// Extend as other keyword-visibility extractors are verified to stamp
|
|
// Meta["visibility"] with the same value set.
|
|
var keywordVisibilityLangs = map[string]bool{
|
|
"java": true,
|
|
}
|
|
|
|
// javaOverrideAnnoID is the synthetic node ID the Java extractor emits
|
|
// for @Override (mirrors languages.AnnotationNodeID("java", "Override"),
|
|
// duplicated here to avoid an analysis→parser import).
|
|
const javaOverrideAnnoID = "annotation::java::Override"
|
|
|
|
// isExportedNode reports whether n is part of the public API surface and
|
|
// therefore not a dead-code candidate even with zero callers. For
|
|
// keyword-visibility languages it trusts the recorded modifier; for
|
|
// everything else it falls back to the name-based isExportedSymbol.
|
|
func isExportedNode(n *graph.Node) bool {
|
|
if keywordVisibilityLangs[n.Language] {
|
|
if v, ok := n.Meta["visibility"].(string); ok && v != "" {
|
|
// public / protected are reachable from outside the indexed
|
|
// graph (other packages, subclasses); private / package-private
|
|
// are fully visible to the indexed call graph, so an unused one
|
|
// is genuinely dead and worth reporting.
|
|
return v == "public" || v == "protected"
|
|
}
|
|
}
|
|
return isExportedSymbol(n.Name, n.Language)
|
|
}
|
|
|
|
// isExportedSymbol checks if a symbol name is exported (public API).
|
|
func isExportedSymbol(name, lang string) bool {
|
|
if lang == "go" {
|
|
if len(name) == 0 {
|
|
return false
|
|
}
|
|
return unicode.IsUpper(rune(name[0]))
|
|
}
|
|
// For other languages, assume exported if not starting with underscore
|
|
return len(name) > 0 && !strings.HasPrefix(name, "_")
|
|
}
|
|
|
|
// goWellKnownMethods contains method names that satisfy standard-library or
|
|
// widely-used Go interfaces. When an implements edge wasn't inferred, a method
|
|
// with one of these names is almost certainly alive via implicit interface
|
|
// satisfaction rather than truly dead.
|
|
var goWellKnownMethods = map[string]bool{
|
|
// io interfaces
|
|
"Read": true, "Write": true, "Close": true, "Flush": true,
|
|
"Seek": true, "ReadAt": true, "WriteAt": true, "ReadFrom": true,
|
|
"WriteTo": true, "ReadByte": true, "UnreadByte": true,
|
|
"ReadRune": true, "UnreadRune": true, "WriteByte": true,
|
|
"WriteString": true,
|
|
// net/http
|
|
"ServeHTTP": true, "RoundTrip": true,
|
|
// encoding
|
|
"MarshalJSON": true, "UnmarshalJSON": true,
|
|
"MarshalXML": true, "UnmarshalXML": true,
|
|
"MarshalText": true, "UnmarshalText": true,
|
|
"MarshalBinary": true, "UnmarshalBinary": true,
|
|
"MarshalYAML": true, "UnmarshalYAML": true,
|
|
// fmt
|
|
"String": true, "Error": true, "Format": true, "GoString": true,
|
|
// sort
|
|
"Len": true, "Less": true, "Swap": true,
|
|
// sql
|
|
"Scan": true, "Value": true,
|
|
// hash
|
|
"Sum": true, "Reset": true, "BlockSize": true,
|
|
// driver
|
|
"Open": true, "Exec": true, "Query": true, "Begin": true,
|
|
"Prepare": true,
|
|
// proto/gRPC
|
|
"mustEmbedUnimplemented": true, "ProtoMessage": true,
|
|
"ProtoReflect": true,
|
|
}
|
|
|
|
// isWellKnownInterfaceMethod returns true if the method name matches a
|
|
// standard-library or widely-used interface method in the given language.
|
|
func isWellKnownInterfaceMethod(name, lang string) bool {
|
|
if lang != "go" {
|
|
return false
|
|
}
|
|
return goWellKnownMethods[name]
|
|
}
|
|
|
|
// isVendoredOrGenerated checks if a file is vendored or generated code that
|
|
// should be excluded from dead code analysis.
|
|
func isVendoredOrGenerated(path string) bool {
|
|
if strings.Contains(path, "tree_sitter/") ||
|
|
strings.Contains(path, "vendor/") ||
|
|
strings.HasSuffix(path, ".h") ||
|
|
strings.HasSuffix(path, ".c") {
|
|
return true
|
|
}
|
|
base := filepath.Base(path)
|
|
// Protobuf / gRPC generated Go files
|
|
if strings.HasSuffix(base, ".pb.go") {
|
|
return true
|
|
}
|
|
// Code-generation convention suffixes
|
|
if strings.HasSuffix(base, "_gen.go") ||
|
|
strings.HasSuffix(base, "_generated.go") ||
|
|
strings.HasSuffix(base, ".gen.go") {
|
|
return true
|
|
}
|
|
// controller-gen / kubebuilder: zz_generated.*.go
|
|
if strings.HasPrefix(base, "zz_generated") {
|
|
return true
|
|
}
|
|
// Mock files (mockery, gomock)
|
|
if strings.HasPrefix(base, "mock_") && strings.HasSuffix(base, ".go") {
|
|
return true
|
|
}
|
|
if strings.HasSuffix(base, "_mock.go") {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// buildConstraintSuffixes covers OS, architecture, and special build-tag
|
|
// suffixes used by the Go toolchain for conditional compilation.
|
|
var buildConstraintSuffixes = []string{
|
|
// OS
|
|
"_linux.go", "_darwin.go", "_windows.go", "_freebsd.go",
|
|
"_openbsd.go", "_netbsd.go", "_dragonfly.go", "_plan9.go",
|
|
"_solaris.go", "_illumos.go", "_aix.go", "_android.go",
|
|
"_ios.go", "_js.go", "_wasip1.go",
|
|
// Architecture
|
|
"_amd64.go", "_arm64.go", "_arm.go", "_386.go",
|
|
"_mips.go", "_mipsle.go", "_mips64.go", "_mips64le.go",
|
|
"_ppc64.go", "_ppc64le.go", "_s390x.go", "_riscv64.go",
|
|
"_loong64.go", "_wasm.go",
|
|
// Special
|
|
"_stub.go", "_cgo.go", "_nocgo.go", "_purego.go", "_appengine.go",
|
|
}
|
|
|
|
// hasBuildConstraint checks if a Go file has build constraints (build tags).
|
|
// Files with build constraints are conditionally compiled — only one variant
|
|
// is active per build, so inactive variants always look "dead".
|
|
func hasBuildConstraint(path string) bool {
|
|
base := filepath.Base(path)
|
|
for _, s := range buildConstraintSuffixes {
|
|
if strings.HasSuffix(base, s) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// matchesExcludePattern checks if a node matches any user-configured exclusion pattern.
|
|
// Patterns are matched against both the file path and the node ID.
|
|
func matchesExcludePattern(filePath, nodeID string, patterns []string) bool {
|
|
for _, pattern := range patterns {
|
|
if pattern == "" {
|
|
continue
|
|
}
|
|
// Try glob match against file path
|
|
if matched, _ := filepath.Match(pattern, filePath); matched {
|
|
return true
|
|
}
|
|
// Try prefix match against file path
|
|
if strings.HasPrefix(filePath, pattern) {
|
|
return true
|
|
}
|
|
// Try prefix match against node ID
|
|
if strings.HasPrefix(nodeID, pattern) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// CollectFanCounts returns per-id fan-in / fan-out counts filtered by
|
|
// edge kind. Backends that implement graph.NodeFanAggregator serve
|
|
// both counts from one bulk pass per direction (~candidateCount
|
|
// rows instead of the full edge set); the fallback path
|
|
// streams the requested kinds via EdgesByKind, accumulating into the
|
|
// fan maps Go-side -- still no AllEdges materialisation, just an
|
|
// in-memory walk of the per-kind edge buckets.
|
|
//
|
|
// Used by FindHotspots and the health_score analyzer. Both pass the
|
|
// same fanInKinds / fanOutKinds pair today; the function signature
|
|
// keeps them per-call so a future analyzer with a different kind
|
|
// split can share the same plumbing.
|
|
func CollectFanCounts(g graph.Store, ids []string, fanInKinds []graph.EdgeKind, fanOutKinds []graph.EdgeKind) (fanIn, fanOut map[string]int) {
|
|
fanIn = make(map[string]int, len(ids))
|
|
fanOut = make(map[string]int, len(ids))
|
|
if len(ids) == 0 {
|
|
return fanIn, fanOut
|
|
}
|
|
if agg, ok := g.(graph.NodeFanAggregator); ok {
|
|
for _, r := range agg.NodeFanCounts(ids, fanInKinds, fanOutKinds) {
|
|
if r.FanIn != 0 {
|
|
fanIn[r.NodeID] = r.FanIn
|
|
}
|
|
if r.FanOut != 0 {
|
|
fanOut[r.NodeID] = r.FanOut
|
|
}
|
|
}
|
|
return fanIn, fanOut
|
|
}
|
|
|
|
// Fallback path: stream the requested kinds via EdgesByKind and
|
|
// tally Go-side. ID-set membership keeps the maps bounded to
|
|
// candidate ids, matching the capability contract.
|
|
idSet := make(map[string]struct{}, len(ids))
|
|
for _, id := range ids {
|
|
if id != "" {
|
|
idSet[id] = struct{}{}
|
|
}
|
|
}
|
|
streamed := make(map[graph.EdgeKind]struct{}, len(fanInKinds)+len(fanOutKinds))
|
|
stream := func(kind graph.EdgeKind, toIn, toOut bool) {
|
|
if _, ok := streamed[kind]; ok {
|
|
return
|
|
}
|
|
streamed[kind] = struct{}{}
|
|
for e := range g.EdgesByKind(kind) {
|
|
if e == nil {
|
|
continue
|
|
}
|
|
if toIn {
|
|
if _, ok := idSet[e.To]; ok {
|
|
fanIn[e.To]++
|
|
}
|
|
}
|
|
if toOut {
|
|
if _, ok := idSet[e.From]; ok {
|
|
fanOut[e.From]++
|
|
}
|
|
}
|
|
}
|
|
}
|
|
inKinds := make(map[graph.EdgeKind]struct{}, len(fanInKinds))
|
|
for _, k := range fanInKinds {
|
|
inKinds[k] = struct{}{}
|
|
}
|
|
outKinds := make(map[graph.EdgeKind]struct{}, len(fanOutKinds))
|
|
for _, k := range fanOutKinds {
|
|
outKinds[k] = struct{}{}
|
|
}
|
|
allKinds := make([]graph.EdgeKind, 0, len(inKinds)+len(outKinds))
|
|
for k := range inKinds {
|
|
allKinds = append(allKinds, k)
|
|
}
|
|
for k := range outKinds {
|
|
if _, dup := inKinds[k]; dup {
|
|
continue
|
|
}
|
|
allKinds = append(allKinds, k)
|
|
}
|
|
for _, k := range allKinds {
|
|
_, toIn := inKinds[k]
|
|
_, toOut := outKinds[k]
|
|
stream(k, toIn, toOut)
|
|
}
|
|
return fanIn, fanOut
|
|
}
|