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

228 lines
7.2 KiB
Go

package analyzer
// PURPOSE — pure computation core for the resolution-outcomes analyzer:
// classifies every unresolved call/reference edge by the structured reason
// the resolver gave up and returns a per-reason rollup plus example rows.
// RATIONALE — extracted from the MCP handler so the taxonomy logic is
// independently testable and reusable across surfaces (MCP, CLI, etc.).
// KEYWORDS — resolution_outcomes, unresolved, taxonomy, pure, calculation
import (
"strings"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/resolver"
)
// Resolution-outcome taxonomy constants. These are the canonical source of
// the taxonomy; the MCP layer aliases them so both surfaces agree.
const (
// OutcomeAmbiguousMultiMatch: two or more same-name, same-language
// definitions exist — the resolver punted.
OutcomeAmbiguousMultiMatch = "ambiguous_multi_match"
// OutcomeCandidateOutOfScope: exactly one same-language definition
// exists but the edge stayed unresolved.
OutcomeCandidateOutOfScope = "candidate_out_of_scope"
// OutcomeCrossLanguageOnly: the only definitions are in a different
// language family.
OutcomeCrossLanguageOnly = "cross_language_only"
// OutcomeStubOnly: the name matches only stub/external-placeholder nodes.
OutcomeStubOnly = "stub_only"
// OutcomeNoDefinition: no definition of this name exists in the graph.
OutcomeNoDefinition = "no_definition"
// OutcomeStdlibHeader: a C/C++/Objective-C angle-include of a standard-
// library header (<stdio.h>, <vector>, …) — external by construction,
// left unresolved deliberately so it never binds to an in-tree file
// sharing its basename.
OutcomeStdlibHeader = "stdlib_header"
)
// ResolutionRow is one unresolved edge in the result.
// JSON field names mirror the MCP output shape exactly.
type ResolutionRow struct {
From string `json:"from"`
To string `json:"to"`
Kind string `json:"edge_kind"`
Name string `json:"name"`
Reason string `json:"reason"`
Candidates int `json:"candidates"`
}
// ResolutionOutcomesResult is the return type of AnalyzeResolutionOutcomes.
// JSON field names mirror the MCP output shape exactly.
type ResolutionOutcomesResult struct {
ByReason map[string]int `json:"by_reason"`
Total int `json:"total"`
Rows []ResolutionRow `json:"rows"`
}
// AnalyzeResolutionOutcomes classifies every unresolved call/reference edge
// in the graph by the structured reason the resolver gave up. reasonFilter
// restricts the returned rows to a single outcome; limit caps the row count.
// It is a pure Calculation: no side effects, no I/O.
func AnalyzeResolutionOutcomes(g graph.Store, reasonFilter string, limit int) ResolutionOutcomesResult {
type pending struct {
edge *graph.Edge
name string
}
var todo []pending
fromIDs := map[string]struct{}{}
for _, kind := range []graph.EdgeKind{graph.EdgeCalls, graph.EdgeReferences} {
for e := range g.EdgesByKind(kind) {
if e == nil || !graph.IsUnresolvedTarget(e.To) {
continue
}
name := graph.UnresolvedName(e.To)
if name == "" {
continue
}
// A receiver-qualified placeholder (`unresolved::*.foo`) keeps
// its method name after the dot; normalise to the bare name.
if i := strings.LastIndexByte(name, '.'); i >= 0 && i+1 < len(name) {
name = name[i+1:]
}
todo = append(todo, pending{edge: e, name: name})
if e.From != "" {
fromIDs[e.From] = struct{}{}
}
}
}
fromList := make([]string, 0, len(fromIDs))
for id := range fromIDs {
fromList = append(fromList, id)
}
fromNodes := g.GetNodesByIDs(fromList)
byReason := map[string]int{}
var rows []ResolutionRow
// Memoise classification by (name, caller-language).
type classKey struct{ name, lang string }
type classVal struct {
reason string
ncand int
}
classCache := map[classKey]classVal{}
for _, p := range todo {
fromLang := ""
if n := fromNodes[p.edge.From]; n != nil {
fromLang = n.Language
}
key := classKey{name: p.name, lang: fromLang}
cv, ok := classCache[key]
if !ok {
cv.reason, cv.ncand = ClassifyUnresolved(g, p.name, fromLang)
classCache[key] = cv
}
reason, ncand := cv.reason, cv.ncand
byReason[reason]++
if reasonFilter != "" && reason != reasonFilter {
continue
}
if len(rows) < limit {
rows = append(rows, ResolutionRow{
From: p.edge.From, To: p.edge.To, Kind: string(p.edge.Kind),
Name: p.name, Reason: reason, Candidates: ncand,
})
}
}
// C/C++/ObjC standard-library angle-includes (<stdio.h>, <vector>, …) are
// external by construction: the resolver leaves them on an unresolved
// import placeholder rather than binding to an in-tree file that happens
// to share the basename. Surface them under their own reason so the
// outcome reads as "stdlib" instead of an opaque unresolved import.
for e := range g.EdgesByKind(graph.EdgeImports) {
if e == nil || !graph.IsUnresolvedTarget(e.To) {
continue
}
if k, _ := e.Meta["include_kind"].(string); k != "system" {
continue
}
hdr := strings.TrimPrefix(e.To, "unresolved::import::")
if !resolver.IsCppStdlibHeader(hdr) {
continue
}
byReason[OutcomeStdlibHeader]++
if reasonFilter != "" && reasonFilter != OutcomeStdlibHeader {
continue
}
if len(rows) < limit {
rows = append(rows, ResolutionRow{
From: e.From, To: e.To, Kind: string(e.Kind),
Name: hdr, Reason: OutcomeStdlibHeader, Candidates: 0,
})
}
}
total := 0
for _, n := range byReason {
total += n
}
return ResolutionOutcomesResult{ByReason: byReason, Total: total, Rows: rows}
}
// ClassifyUnresolved returns the structured suppression reason for an
// unresolved name relative to the caller's language, plus the number of
// real (non-stub) definition candidates considered. It is a pure Calculation.
func ClassifyUnresolved(g graph.Store, name, fromLang string) (reason string, candidates int) {
var realSameLang, realOtherLang, stubs int
for _, n := range g.FindNodesByName(name) {
if n == nil {
continue
}
if graph.IsStub(n.ID) {
stubs++
continue
}
if !nodeIsDefinitionKind(n.Kind) {
continue
}
if fromLang != "" && n.Language != "" && !sameLanguageFamily(fromLang, n.Language) {
realOtherLang++
continue
}
realSameLang++
}
switch {
case realSameLang >= 2:
return OutcomeAmbiguousMultiMatch, realSameLang
case realSameLang == 1:
return OutcomeCandidateOutOfScope, 1
case realOtherLang >= 1:
return OutcomeCrossLanguageOnly, realOtherLang
case stubs >= 1:
return OutcomeStubOnly, 0
default:
return OutcomeNoDefinition, 0
}
}
// nodeIsDefinitionKind reports whether a node kind is a callable/type
// definition an unresolved call or reference could legitimately bind to.
func nodeIsDefinitionKind(k graph.NodeKind) bool {
switch k {
case graph.KindFunction, graph.KindMethod, graph.KindType,
graph.KindInterface, graph.KindVariable, graph.KindConstant, graph.KindField:
return true
}
return false
}
// sameLanguageFamily folds the TS/JS pair so a cross-file TS→JS reference
// is not mis-reported as a cross-language suppression.
func sameLanguageFamily(a, b string) bool {
if a == b {
return true
}
norm := func(l string) string {
switch l {
case "javascript", "typescript", "tsx", "jsx":
return "jsts"
}
return l
}
return norm(a) == norm(b)
}