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
341 lines
9.3 KiB
Go
341 lines
9.3 KiB
Go
package wiki
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/zzet/gortex/internal/analysis"
|
|
"github.com/zzet/gortex/internal/graph"
|
|
)
|
|
|
|
// mermaidID converts a Gortex node ID to a Mermaid-safe identifier.
|
|
// Mermaid IDs disallow ::, /, ., space, parentheses, and angle
|
|
// brackets. We replace each with underscore so the result is a
|
|
// single token even for method receivers like "(*Foo).Bar".
|
|
func mermaidID(id string) string {
|
|
r := strings.NewReplacer(
|
|
"::", "_",
|
|
"/", "_",
|
|
".", "_",
|
|
"-", "_",
|
|
" ", "_",
|
|
"<", "_",
|
|
">", "_",
|
|
"(", "_",
|
|
")", "_",
|
|
"*", "_",
|
|
"#", "_",
|
|
"@", "_",
|
|
":", "_",
|
|
)
|
|
return r.Replace(id)
|
|
}
|
|
|
|
// mermaidEscape escapes characters that break Mermaid labels. Only
|
|
// the double-quote needs special handling inside `["..."]` labels.
|
|
func mermaidEscape(s string) string {
|
|
return strings.ReplaceAll(s, `"`, `#quot;`)
|
|
}
|
|
|
|
// RenderCommunityGraph emits a Mermaid flowchart of communities and
|
|
// the cross-community calls between them. Each node is a community;
|
|
// edge weights are the number of calls flowing across the boundary.
|
|
// Used both on the index page and as the wiki/<repo>/_assets file.
|
|
func RenderCommunityGraph(g graph.Store, communities *analysis.CommunityResult, opts CommunityGraphOpts) string {
|
|
if communities == nil || len(communities.Communities) == 0 {
|
|
return "graph LR\n empty[\"No communities detected\"]\n"
|
|
}
|
|
|
|
// Filter communities by size and cap count.
|
|
type sized struct {
|
|
id string
|
|
label string
|
|
size int
|
|
}
|
|
var keep []sized
|
|
for _, c := range communities.Communities {
|
|
if c.Size < opts.MinSize {
|
|
continue
|
|
}
|
|
label := c.Label
|
|
if label == "" {
|
|
label = c.ID
|
|
}
|
|
keep = append(keep, sized{id: c.ID, label: label, size: c.Size})
|
|
}
|
|
sort.Slice(keep, func(i, j int) bool { return keep[i].size > keep[j].size })
|
|
if opts.Max > 0 && len(keep) > opts.Max {
|
|
keep = keep[:opts.Max]
|
|
}
|
|
keepSet := make(map[string]bool, len(keep))
|
|
for _, k := range keep {
|
|
keepSet[k.id] = true
|
|
}
|
|
|
|
// Aggregate cross-community calls.
|
|
type edge struct {
|
|
from, to string
|
|
count int
|
|
}
|
|
edgeMap := make(map[string]*edge)
|
|
if g != nil {
|
|
for _, e := range g.AllEdges() {
|
|
if e.Kind != graph.EdgeCalls {
|
|
continue
|
|
}
|
|
from := communities.NodeToComm[e.From]
|
|
to := communities.NodeToComm[e.To]
|
|
if from == "" || to == "" || from == to {
|
|
continue
|
|
}
|
|
if !keepSet[from] || !keepSet[to] {
|
|
continue
|
|
}
|
|
key := from + "→" + to
|
|
if x, ok := edgeMap[key]; ok {
|
|
x.count++
|
|
} else {
|
|
edgeMap[key] = &edge{from: from, to: to, count: 1}
|
|
}
|
|
}
|
|
}
|
|
|
|
var b strings.Builder
|
|
b.WriteString("graph LR\n")
|
|
for _, k := range keep {
|
|
label := fmt.Sprintf("%s\\n%d symbols", k.label, k.size)
|
|
fmt.Fprintf(&b, " %s[\"%s\"]\n", mermaidID(k.id), mermaidEscape(label))
|
|
}
|
|
b.WriteString("\n")
|
|
// Sort edges so the output is deterministic.
|
|
keys := make([]string, 0, len(edgeMap))
|
|
for k := range edgeMap {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
for _, k := range keys {
|
|
ed := edgeMap[k]
|
|
fmt.Fprintf(&b, " %s -->|%d| %s\n", mermaidID(ed.from), ed.count, mermaidID(ed.to))
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// CommunityGraphOpts narrows the community-graph diagram.
|
|
type CommunityGraphOpts struct {
|
|
MinSize int
|
|
Max int
|
|
}
|
|
|
|
// RenderProcessSequence emits a Mermaid sequenceDiagram for one
|
|
// Process. Participants are unique communities (or files when a node
|
|
// has no community assignment) touched by the process; messages are
|
|
// the EdgeCalls transitions in DFS preorder. Each transition is
|
|
// labelled with the callee symbol name.
|
|
//
|
|
// The first iteration emits a flat sequence: we deliberately do not
|
|
// emit `loop` or `alt` blocks because the DFS preorder of a call
|
|
// graph doesn't carry enough information to reconstruct those control
|
|
// structures correctly. A faithful flat sequence is better than a
|
|
// confidently-wrong control-flow rendering.
|
|
func RenderProcessSequence(p analysis.Process, nodeByID map[string]*graph.Node, commLabelByNode map[string]string) string {
|
|
if len(p.Steps) == 0 {
|
|
return "sequenceDiagram\n Note over Empty: process has no steps\n"
|
|
}
|
|
|
|
// Identify participants in first-seen order so the diagram reads
|
|
// left-to-right in the order communities first appear.
|
|
type participant struct {
|
|
id string
|
|
label string
|
|
}
|
|
var parts []participant
|
|
seen := make(map[string]bool)
|
|
|
|
pickPartID := func(nodeID string) (string, string) {
|
|
if label, ok := commLabelByNode[nodeID]; ok && label != "" {
|
|
return mermaidID("c_" + label), label
|
|
}
|
|
if n := nodeByID[nodeID]; n != nil && n.FilePath != "" {
|
|
return mermaidID("f_" + n.FilePath), n.FilePath
|
|
}
|
|
return mermaidID("n_" + nodeID), nodeID
|
|
}
|
|
|
|
addPart := func(nodeID string) string {
|
|
pid, label := pickPartID(nodeID)
|
|
if !seen[pid] {
|
|
seen[pid] = true
|
|
parts = append(parts, participant{id: pid, label: label})
|
|
}
|
|
return pid
|
|
}
|
|
|
|
// Walk steps in order and pre-register participants, building
|
|
// the parent-of relation from depth so the messages line up
|
|
// caller→callee.
|
|
parentStack := make([]int, 0, len(p.Steps))
|
|
parents := make([]int, len(p.Steps)) // index of parent step, -1 for root
|
|
for i, step := range p.Steps {
|
|
// Pop stack until top's depth < this step's depth.
|
|
for len(parentStack) > 0 {
|
|
top := parentStack[len(parentStack)-1]
|
|
if p.Steps[top].Depth < step.Depth {
|
|
break
|
|
}
|
|
parentStack = parentStack[:len(parentStack)-1]
|
|
}
|
|
if len(parentStack) == 0 {
|
|
parents[i] = -1
|
|
} else {
|
|
parents[i] = parentStack[len(parentStack)-1]
|
|
}
|
|
parentStack = append(parentStack, i)
|
|
addPart(step.ID)
|
|
}
|
|
|
|
var b strings.Builder
|
|
b.WriteString("sequenceDiagram\n")
|
|
b.WriteString(" autonumber\n")
|
|
for _, pt := range parts {
|
|
fmt.Fprintf(&b, " participant %s as %s\n", pt.id, mermaidEscape(pt.label))
|
|
}
|
|
b.WriteString("\n")
|
|
// Emit one message per non-root step: caller → callee with the
|
|
// callee's symbol name as the message label.
|
|
for i, step := range p.Steps {
|
|
if parents[i] < 0 {
|
|
// Root: emit a note so the diagram has an entry-point
|
|
// anchor without a message arrow.
|
|
fromID, _ := pickPartID(step.ID)
|
|
label := stepLabel(step.ID, nodeByID)
|
|
fmt.Fprintf(&b, " Note over %s: entry → %s\n", fromID, mermaidEscape(label))
|
|
continue
|
|
}
|
|
fromID, _ := pickPartID(p.Steps[parents[i]].ID)
|
|
toID, _ := pickPartID(step.ID)
|
|
label := stepLabel(step.ID, nodeByID)
|
|
fmt.Fprintf(&b, " %s->>%s: %s\n", fromID, toID, mermaidEscape(label))
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// stepLabel returns a short human-readable name for a step.
|
|
func stepLabel(id string, nodeByID map[string]*graph.Node) string {
|
|
if n, ok := nodeByID[id]; ok && n != nil && n.Name != "" {
|
|
return n.Name
|
|
}
|
|
// Fall back to the trailing segment of the ID.
|
|
if idx := strings.LastIndex(id, "::"); idx >= 0 {
|
|
return id[idx+2:]
|
|
}
|
|
return id
|
|
}
|
|
|
|
// RenderArchitecture emits a Mermaid flowchart showing communities
|
|
// grouped by parent (when present) plus cross-community arrows.
|
|
// Mirrors the architecture overview page.
|
|
func RenderArchitecture(g graph.Store, communities *analysis.CommunityResult, opts CommunityGraphOpts) string {
|
|
if communities == nil || len(communities.Communities) == 0 {
|
|
return "graph TB\n empty[\"No communities detected\"]\n"
|
|
}
|
|
|
|
// Filter and cap.
|
|
type sized struct {
|
|
id string
|
|
label string
|
|
size int
|
|
parent string
|
|
}
|
|
var keep []sized
|
|
for _, c := range communities.Communities {
|
|
if c.Size < opts.MinSize {
|
|
continue
|
|
}
|
|
label := c.Label
|
|
if label == "" {
|
|
label = c.ID
|
|
}
|
|
keep = append(keep, sized{id: c.ID, label: label, size: c.Size, parent: c.ParentID})
|
|
}
|
|
sort.Slice(keep, func(i, j int) bool { return keep[i].size > keep[j].size })
|
|
if opts.Max > 0 && len(keep) > opts.Max {
|
|
keep = keep[:opts.Max]
|
|
}
|
|
keepSet := make(map[string]bool, len(keep))
|
|
for _, k := range keep {
|
|
keepSet[k.id] = true
|
|
}
|
|
|
|
// Group by parent.
|
|
groups := make(map[string][]sized) // parent → children (parent="" → singleton)
|
|
var parentOrder []string
|
|
for _, k := range keep {
|
|
parent := k.parent
|
|
if _, ok := groups[parent]; !ok {
|
|
parentOrder = append(parentOrder, parent)
|
|
}
|
|
groups[parent] = append(groups[parent], k)
|
|
}
|
|
sort.Strings(parentOrder)
|
|
|
|
var b strings.Builder
|
|
b.WriteString("graph TB\n")
|
|
for _, parent := range parentOrder {
|
|
members := groups[parent]
|
|
if parent != "" {
|
|
fmt.Fprintf(&b, " subgraph %s [%s]\n", mermaidID(parent), mermaidEscape(parent))
|
|
}
|
|
for _, k := range members {
|
|
label := fmt.Sprintf("%s\\n%d symbols", k.label, k.size)
|
|
indent := " "
|
|
if parent != "" {
|
|
indent = " "
|
|
}
|
|
fmt.Fprintf(&b, "%s%s[\"%s\"]\n", indent, mermaidID(k.id), mermaidEscape(label))
|
|
}
|
|
if parent != "" {
|
|
b.WriteString(" end\n")
|
|
}
|
|
}
|
|
b.WriteString("\n")
|
|
|
|
// Cross-community calls.
|
|
type edge struct {
|
|
from, to string
|
|
count int
|
|
}
|
|
edgeMap := make(map[string]*edge)
|
|
if g != nil {
|
|
for _, e := range g.AllEdges() {
|
|
if e.Kind != graph.EdgeCalls {
|
|
continue
|
|
}
|
|
from := communities.NodeToComm[e.From]
|
|
to := communities.NodeToComm[e.To]
|
|
if from == "" || to == "" || from == to {
|
|
continue
|
|
}
|
|
if !keepSet[from] || !keepSet[to] {
|
|
continue
|
|
}
|
|
key := from + "→" + to
|
|
if x, ok := edgeMap[key]; ok {
|
|
x.count++
|
|
} else {
|
|
edgeMap[key] = &edge{from: from, to: to, count: 1}
|
|
}
|
|
}
|
|
}
|
|
keys := make([]string, 0, len(edgeMap))
|
|
for k := range edgeMap {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
for _, k := range keys {
|
|
ed := edgeMap[k]
|
|
fmt.Fprintf(&b, " %s -->|%d| %s\n", mermaidID(ed.from), ed.count, mermaidID(ed.to))
|
|
}
|
|
return b.String()
|
|
}
|