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

295 lines
8.0 KiB
Go

// Package exporter writes the in-memory graph to portable formats so users
// can load it into external visualization and query tools (Neo4j, Memgraph
// via Cypher; yEd, Gephi, Cytoscape via GraphML).
//
// The exporter is read-only and operates on a snapshot — it never mutates
// the graph. Filters (repo, kinds) are applied during emission.
package exporter
import (
"encoding/json"
"fmt"
"io"
"slices"
"sort"
"strings"
"github.com/zzet/gortex/internal/graph"
)
// Options narrows what gets exported. Empty filters mean "include everything".
type Options struct {
// Repo restricts emission to nodes whose RepoPrefix matches. Empty means all.
Repo string
// Kinds restricts emission to nodes whose Kind is in this set. Empty means all.
// Edges are always emitted iff both endpoints are emitted.
Kinds []graph.NodeKind
// Languages restricts emission to nodes whose Language is in this set.
Languages []string
// DropSynthetic suppresses synthetic stub nodes generated for edges that
// point to unresolved imports, external symbols (`external::error`), or
// annotation sentinels (`annotation::go::Deprecated`). Default false:
// stubs are emitted with kind="external" so the call topology stays
// intact for visualization. Set true for a strict view of the graph.
DropSynthetic bool
// Pretty toggles human-readable formatting (line breaks, indentation).
Pretty bool
}
// Stats reports what was emitted. Returned by every exporter Write call.
type Stats struct {
NodesWritten int
EdgesWritten int
NodesSkipped int
EdgesSkipped int
BytesWritten int64
}
// nodeFilter returns true for nodes that pass the option filters.
func (o *Options) nodeFilter(n *graph.Node) bool {
if o.Repo != "" && n.RepoPrefix != o.Repo {
return false
}
if len(o.Kinds) > 0 && !slices.Contains(o.Kinds, n.Kind) {
return false
}
if len(o.Languages) > 0 && !slices.Contains(o.Languages, n.Language) {
return false
}
return true
}
// snapshot collects nodes/edges that pass the filter into stable-sorted slices.
// Sorting makes exporter output deterministic — important for tests and diffs.
//
// When opts.DropSynthetic is false (default), edges pointing at IDs that are
// not real graph nodes (`unresolved::*`, `external::*`, `annotation::*`) get
// synthesized stub nodes added to the result so the call topology is preserved.
func snapshot(g graph.Store, opts Options) ([]*graph.Node, []*graph.Edge, map[string]bool) {
allNodes := g.AllNodes()
allEdges := g.AllEdges()
keptNodeIDs := make(map[string]bool, len(allNodes))
nodes := make([]*graph.Node, 0, len(allNodes))
for _, n := range allNodes {
if opts.nodeFilter(n) {
nodes = append(nodes, n)
keptNodeIDs[n.ID] = true
}
}
syntheticIDs := make(map[string]bool)
edges := make([]*graph.Edge, 0, len(allEdges))
for _, e := range allEdges {
fromKept := keptNodeIDs[e.From]
toKept := keptNodeIDs[e.To]
if fromKept && toKept {
edges = append(edges, e)
continue
}
if opts.DropSynthetic {
continue
}
// One endpoint is a synthetic placeholder (unresolved::*, external::*,
// annotation::*). Only synthesize when the *other* endpoint is real
// — otherwise we'd add an edge between two stubs no caller cares
// about.
if !fromKept && !toKept {
continue
}
if !fromKept && classifySynthetic(e.From) != "" {
syntheticIDs[e.From] = true
edges = append(edges, e)
}
if !toKept && classifySynthetic(e.To) != "" {
syntheticIDs[e.To] = true
edges = append(edges, e)
}
}
for id := range syntheticIDs {
stub := &graph.Node{
ID: id,
Name: synthName(id),
Kind: graph.NodeKind(classifySynthetic(id)),
Meta: map[string]any{"synthetic": true},
}
nodes = append(nodes, stub)
keptNodeIDs[id] = true
}
sort.Slice(nodes, func(i, j int) bool { return nodes[i].ID < nodes[j].ID })
sort.Slice(edges, func(i, j int) bool {
if edges[i].From != edges[j].From {
return edges[i].From < edges[j].From
}
if edges[i].To != edges[j].To {
return edges[i].To < edges[j].To
}
if edges[i].Kind != edges[j].Kind {
return edges[i].Kind < edges[j].Kind
}
return edges[i].Line < edges[j].Line
})
// De-dup edges: when both From and To are synthetic-but-tied to a real
// other endpoint, the loop above could enqueue the same edge twice.
dedup := edges[:0]
seen := make(map[edgeKey]bool, len(edges))
for _, e := range edges {
k := edgeKey{from: e.From, to: e.To, kind: e.Kind, line: e.Line}
if seen[k] {
continue
}
seen[k] = true
dedup = append(dedup, e)
}
return nodes, dedup, keptNodeIDs
}
type edgeKey struct {
from string
to string
kind graph.EdgeKind
line int
}
// classifySynthetic returns a label kind ("external", "unresolved",
// "annotation") for the recognized synthetic ID prefixes, or "" when the ID
// doesn't look like a placeholder.
func classifySynthetic(id string) string {
switch {
case strings.HasPrefix(id, "unresolved::"):
return "unresolved"
case strings.HasPrefix(id, "external::"):
return "external"
case strings.HasPrefix(id, "annotation::"):
return "annotation"
}
return ""
}
// synthName extracts a human-readable name from a synthetic ID. The IDs use
// "::" as separator; the last segment is the most informative bit.
func synthName(id string) string {
idx := strings.LastIndex(id, "::")
if idx < 0 {
return id
}
return id[idx+2:]
}
// flattenMeta walks Node.Meta or Edge.Meta and returns it as a sorted slice of
// key/value pairs with values coerced to (string|int64|float64|bool). Nested
// maps and slices are JSON-encoded into a string. Keys with characters that
// can't legally be Cypher / GraphML property names are sanitized.
type metaEntry struct {
Key string
Value any
}
func flattenMeta(meta map[string]any) []metaEntry {
if len(meta) == 0 {
return nil
}
out := make([]metaEntry, 0, len(meta))
for k, v := range meta {
safeKey := sanitizePropertyName(k)
if safeKey == "" {
continue
}
out = append(out, metaEntry{Key: safeKey, Value: coerceValue(v)})
}
sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key })
return out
}
// coerceValue maps a graph Meta value to a primitive the export formats can
// represent. Nested types fall back to JSON-encoded strings so no information
// is lost — receivers just have to parse the string if they care.
func coerceValue(v any) any {
switch x := v.(type) {
case nil:
return nil
case string, bool, int, int32, int64, uint, uint32, uint64, float32, float64:
return x
default:
// Maps, slices, structs — encode as JSON.
data, err := json.Marshal(x)
if err != nil {
return fmt.Sprintf("%v", x)
}
return string(data)
}
}
// sanitizePropertyName returns a Cypher / GraphML-safe identifier. Replaces
// any character not in [a-zA-Z0-9_] with underscore. Empty / fully-illegal
// names are dropped (return "").
func sanitizePropertyName(name string) string {
if name == "" {
return ""
}
var b strings.Builder
b.Grow(len(name))
for i, r := range name {
ok := r == '_' ||
(r >= 'a' && r <= 'z') ||
(r >= 'A' && r <= 'Z') ||
(i > 0 && r >= '0' && r <= '9')
if ok {
b.WriteRune(r)
} else {
b.WriteRune('_')
}
}
out := b.String()
if out == "_" || strings.Trim(out, "_") == "" {
return ""
}
return out
}
// nodeLabel converts a NodeKind into a Cypher / GraphML label. Capitalizes
// the first letter of each underscore-separated segment so "function" →
// "Function" and "import_path" → "ImportPath".
func nodeLabel(kind graph.NodeKind) string {
if kind == "" {
return "Unknown"
}
parts := strings.Split(string(kind), "_")
for i, p := range parts {
if p == "" {
continue
}
parts[i] = strings.ToUpper(p[:1]) + p[1:]
}
return strings.Join(parts, "")
}
// edgeRelType converts an EdgeKind to UPPER_SNAKE for Cypher relationship
// type / GraphML edge label.
func edgeRelType(kind graph.EdgeKind) string {
if kind == "" {
return "RELATED"
}
return strings.ToUpper(string(kind))
}
// countingWriter wraps an io.Writer to track bytes written.
type countingWriter struct {
w io.Writer
n int64
}
func (cw *countingWriter) Write(p []byte) (int, error) {
n, err := cw.w.Write(p)
cw.n += int64(n)
return n, err
}