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
308 lines
8.8 KiB
Go
308 lines
8.8 KiB
Go
package exporter
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/zzet/gortex/internal/analysis"
|
|
"github.com/zzet/gortex/internal/graph"
|
|
)
|
|
|
|
// MermaidOpts narrows what the Mermaid exporter renders.
|
|
type MermaidOpts struct {
|
|
// Scope picks the diagram flavour: "architecture" (default),
|
|
// "communities", "processes", or "all" (writes all three when
|
|
// the caller is feeding WriteMermaidScoped via the multi-file
|
|
// directory mode).
|
|
Scope string
|
|
// MaxCommunities caps how many communities to include in the
|
|
// architecture and communities diagrams. Zero means 20.
|
|
MaxCommunities int
|
|
// MinCommunity drops communities below this size. Zero means 3.
|
|
MinCommunity int
|
|
// Kinds and Languages mirror the standard exporter Options for
|
|
// filtering the underlying graph before community detection.
|
|
Kinds []graph.NodeKind
|
|
Languages []string
|
|
}
|
|
|
|
func (o MermaidOpts) withDefaults() MermaidOpts {
|
|
if o.Scope == "" {
|
|
o.Scope = "architecture"
|
|
}
|
|
if o.MaxCommunities == 0 {
|
|
o.MaxCommunities = 20
|
|
}
|
|
if o.MinCommunity == 0 {
|
|
o.MinCommunity = 3
|
|
}
|
|
return o
|
|
}
|
|
|
|
// WriteMermaid emits a single Mermaid diagram for the chosen scope.
|
|
// Use this when the caller asks for one file. For multi-file output
|
|
// the CLI calls WriteMermaid once per scope into separate files.
|
|
func WriteMermaid(w io.Writer, g graph.Store, opts MermaidOpts) (Stats, error) {
|
|
opts = opts.withDefaults()
|
|
cw := &countingWriter{w: w}
|
|
|
|
if g == nil {
|
|
_, _ = io.WriteString(cw, "graph LR\n empty[\"no graph\"]\n")
|
|
return Stats{BytesWritten: cw.n}, nil
|
|
}
|
|
|
|
body, nodes, edges, err := renderForScope(g, opts)
|
|
if err != nil {
|
|
return Stats{}, err
|
|
}
|
|
if _, err := io.WriteString(cw, body); err != nil {
|
|
return Stats{}, err
|
|
}
|
|
return Stats{NodesWritten: nodes, EdgesWritten: edges, BytesWritten: cw.n}, nil
|
|
}
|
|
|
|
// renderForScope dispatches the Scope to the right diagram builder and
|
|
// returns the rendered Mermaid plus a (nodes, edges) count that the
|
|
// caller surfaces in Stats.
|
|
func renderForScope(g graph.Store, opts MermaidOpts) (body string, nodes, edges int, err error) {
|
|
switch strings.ToLower(opts.Scope) {
|
|
case "architecture":
|
|
body, nodes, edges = renderArchitecture(g, opts)
|
|
case "communities":
|
|
body, nodes, edges = renderCommunities(g, opts)
|
|
case "processes":
|
|
body, nodes, edges = renderProcesses(g, opts)
|
|
case "all":
|
|
// Emit a single document containing all three diagrams in
|
|
// sequence, separated by `%% ---` markers. Useful for
|
|
// quick preview without enabling --out-dir.
|
|
var sb strings.Builder
|
|
arch, an, ae := renderArchitecture(g, opts)
|
|
comm, cn, ce := renderCommunities(g, opts)
|
|
proc, pn, pe := renderProcesses(g, opts)
|
|
sb.WriteString("%% gortex architecture diagram\n")
|
|
sb.WriteString(arch)
|
|
sb.WriteString("\n%% ---\n")
|
|
sb.WriteString("%% gortex communities diagram\n")
|
|
sb.WriteString(comm)
|
|
sb.WriteString("\n%% ---\n")
|
|
sb.WriteString("%% gortex processes diagram\n")
|
|
sb.WriteString(proc)
|
|
body = sb.String()
|
|
nodes = an + cn + pn
|
|
edges = ae + ce + pe
|
|
default:
|
|
return "", 0, 0, fmt.Errorf("unknown scope %q (expected architecture | communities | processes | all)", opts.Scope)
|
|
}
|
|
return body, nodes, edges, nil
|
|
}
|
|
|
|
// renderArchitecture builds a top-level community map with hub
|
|
// annotations. Mirrors the layout used by the wiki page.
|
|
func renderArchitecture(g graph.Store, opts MermaidOpts) (string, int, int) {
|
|
comms := analysis.DetectCommunities(g)
|
|
var sb strings.Builder
|
|
sb.WriteString("graph TB\n")
|
|
if comms == nil || len(comms.Communities) == 0 {
|
|
sb.WriteString(" empty[\"no communities\"]\n")
|
|
return sb.String(), 1, 0
|
|
}
|
|
type item struct {
|
|
id string
|
|
label string
|
|
size int
|
|
hub string
|
|
}
|
|
var kept []item
|
|
for _, c := range comms.Communities {
|
|
if c.Size < opts.MinCommunity {
|
|
continue
|
|
}
|
|
label := c.Label
|
|
if label == "" {
|
|
label = c.ID
|
|
}
|
|
kept = append(kept, item{id: c.ID, label: label, size: c.Size, hub: c.Hub})
|
|
}
|
|
sort.Slice(kept, func(i, j int) bool { return kept[i].size > kept[j].size })
|
|
if opts.MaxCommunities > 0 && len(kept) > opts.MaxCommunities {
|
|
kept = kept[:opts.MaxCommunities]
|
|
}
|
|
keepSet := make(map[string]bool, len(kept))
|
|
for _, k := range kept {
|
|
keepSet[k.id] = true
|
|
hub := k.hub
|
|
if hub == "" {
|
|
hub = "—"
|
|
}
|
|
label := fmt.Sprintf("%s\\n%d symbols\\nhub: %s", k.label, k.size, hub)
|
|
fmt.Fprintf(&sb, " %s[\"%s\"]\n", mermaidSafeID(k.id), mermaidEscape(label))
|
|
}
|
|
sb.WriteString("\n")
|
|
edges := emitCrossCommEdges(&sb, g, comms, keepSet)
|
|
return sb.String(), len(kept), edges
|
|
}
|
|
|
|
// renderCommunities is identical to architecture today but exposes
|
|
// `graph LR` for a wider canvas. Caller picks via Scope.
|
|
func renderCommunities(g graph.Store, opts MermaidOpts) (string, int, int) {
|
|
comms := analysis.DetectCommunities(g)
|
|
var sb strings.Builder
|
|
sb.WriteString("graph LR\n")
|
|
if comms == nil || len(comms.Communities) == 0 {
|
|
sb.WriteString(" empty[\"no communities\"]\n")
|
|
return sb.String(), 1, 0
|
|
}
|
|
type item struct {
|
|
id string
|
|
label string
|
|
size int
|
|
}
|
|
var kept []item
|
|
for _, c := range comms.Communities {
|
|
if c.Size < opts.MinCommunity {
|
|
continue
|
|
}
|
|
label := c.Label
|
|
if label == "" {
|
|
label = c.ID
|
|
}
|
|
kept = append(kept, item{id: c.ID, label: label, size: c.Size})
|
|
}
|
|
sort.Slice(kept, func(i, j int) bool { return kept[i].size > kept[j].size })
|
|
if opts.MaxCommunities > 0 && len(kept) > opts.MaxCommunities {
|
|
kept = kept[:opts.MaxCommunities]
|
|
}
|
|
keepSet := make(map[string]bool, len(kept))
|
|
for _, k := range kept {
|
|
keepSet[k.id] = true
|
|
fmt.Fprintf(&sb, " %s([\"%s\\n%d\"])\n", mermaidSafeID(k.id), mermaidEscape(k.label), k.size)
|
|
}
|
|
sb.WriteString("\n")
|
|
edges := emitCrossCommEdges(&sb, g, comms, keepSet)
|
|
return sb.String(), len(kept), edges
|
|
}
|
|
|
|
// renderProcesses lists every process as a small flowchart of
|
|
// caller→callee pairs, capped to keep the rendering responsive.
|
|
func renderProcesses(g graph.Store, _ MermaidOpts) (string, int, int) {
|
|
procs := analysis.DiscoverProcesses(g)
|
|
var sb strings.Builder
|
|
sb.WriteString("graph LR\n")
|
|
if procs == nil || len(procs.Processes) == 0 {
|
|
sb.WriteString(" empty[\"no processes\"]\n")
|
|
return sb.String(), 1, 0
|
|
}
|
|
maxProcesses := 12
|
|
if len(procs.Processes) < maxProcesses {
|
|
maxProcesses = len(procs.Processes)
|
|
}
|
|
nodeCount, edgeCount := 0, 0
|
|
nodeByID := make(map[string]*graph.Node)
|
|
for _, n := range g.AllNodes() {
|
|
nodeByID[n.ID] = n
|
|
}
|
|
for i := 0; i < maxProcesses; i++ {
|
|
p := procs.Processes[i]
|
|
fmt.Fprintf(&sb, " subgraph %s [%s]\n", mermaidSafeID("p_"+p.ID), mermaidEscape(p.Name))
|
|
// Walk steps in order and emit caller→callee using the
|
|
// nearest preceding step with smaller depth.
|
|
parentStack := make([]int, 0, len(p.Steps))
|
|
for j, step := range p.Steps {
|
|
for len(parentStack) > 0 {
|
|
top := parentStack[len(parentStack)-1]
|
|
if p.Steps[top].Depth < step.Depth {
|
|
break
|
|
}
|
|
parentStack = parentStack[:len(parentStack)-1]
|
|
}
|
|
parent := -1
|
|
if len(parentStack) > 0 {
|
|
parent = parentStack[len(parentStack)-1]
|
|
}
|
|
parentStack = append(parentStack, j)
|
|
|
|
name := step.ID
|
|
if n := nodeByID[step.ID]; n != nil && n.Name != "" {
|
|
name = n.Name
|
|
}
|
|
id := mermaidSafeID(step.ID + "_" + p.ID)
|
|
fmt.Fprintf(&sb, " %s[\"%s\"]\n", id, mermaidEscape(name))
|
|
nodeCount++
|
|
if parent >= 0 {
|
|
parentID := mermaidSafeID(p.Steps[parent].ID + "_" + p.ID)
|
|
fmt.Fprintf(&sb, " %s --> %s\n", parentID, id)
|
|
edgeCount++
|
|
}
|
|
}
|
|
sb.WriteString(" end\n")
|
|
}
|
|
return sb.String(), nodeCount, edgeCount
|
|
}
|
|
|
|
// emitCrossCommEdges writes EdgeCalls between communities (filtered
|
|
// to the kept set) and returns the edge count.
|
|
func emitCrossCommEdges(sb *strings.Builder, g graph.Store, comms *analysis.CommunityResult, keep map[string]bool) int {
|
|
type edge struct {
|
|
from, to string
|
|
count int
|
|
}
|
|
edges := make(map[string]*edge)
|
|
for _, e := range g.AllEdges() {
|
|
if e.Kind != graph.EdgeCalls {
|
|
continue
|
|
}
|
|
from := comms.NodeToComm[e.From]
|
|
to := comms.NodeToComm[e.To]
|
|
if from == "" || to == "" || from == to {
|
|
continue
|
|
}
|
|
if !keep[from] || !keep[to] {
|
|
continue
|
|
}
|
|
k := from + "→" + to
|
|
if x, ok := edges[k]; ok {
|
|
x.count++
|
|
} else {
|
|
edges[k] = &edge{from: from, to: to, count: 1}
|
|
}
|
|
}
|
|
keys := make([]string, 0, len(edges))
|
|
for k := range edges {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
for _, k := range keys {
|
|
ed := edges[k]
|
|
fmt.Fprintf(sb, " %s -->|%d| %s\n", mermaidSafeID(ed.from), ed.count, mermaidSafeID(ed.to))
|
|
}
|
|
return len(edges)
|
|
}
|
|
|
|
// mermaidSafeID — mirror of the one in internal/wiki/mermaid.go. Kept
|
|
// duplicated so the exporter package has no cross-coupling to wiki.
|
|
func mermaidSafeID(id string) string {
|
|
r := strings.NewReplacer(
|
|
"::", "_",
|
|
"/", "_",
|
|
".", "_",
|
|
"-", "_",
|
|
" ", "_",
|
|
"<", "_",
|
|
">", "_",
|
|
"(", "_",
|
|
")", "_",
|
|
"*", "_",
|
|
"#", "_",
|
|
"@", "_",
|
|
":", "_",
|
|
)
|
|
return r.Replace(id)
|
|
}
|
|
|
|
func mermaidEscape(s string) string {
|
|
return strings.ReplaceAll(s, `"`, `#quot;`)
|
|
}
|