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

388 lines
11 KiB
Go

package wiki
import (
"context"
"fmt"
"path"
"sort"
"strings"
"github.com/zzet/gortex/internal/analysis"
"github.com/zzet/gortex/internal/contracts"
"github.com/zzet/gortex/internal/graph"
)
// SemanticProviderStatus mirrors the subset of semantic.ProviderStatus
// the wiki cares about — kept local to avoid importing the semantic
// package (which pulls heavy LSP / CGo dependencies into the wiki).
type SemanticProviderStatus struct {
Language string
Name string
Status string
}
// Inputs is the dependency bundle the Generator needs. All fields are
// optional except Graph (without a graph there is nothing to render).
type Inputs struct {
Graph graph.Store
Communities *analysis.CommunityResult
Processes *analysis.ProcessResult
Hotspots []analysis.HotspotEntry
Cycles []analysis.Cycle
Contracts []contracts.Contract
SemanticProviders []SemanticProviderStatus
// DocsBundle is the pre-rendered markdown of the gortex-docs
// bundle; when set the generator writes it as changelog.md.
DocsBundle string
}
// Result describes what was produced.
type Result struct {
OutputDir string `json:"output_dir"`
Files []FileResult `json:"files"`
// IndexMarkdown is the top-level wiki/index.md content, suitable
// for inline return from the MCP tool.
IndexMarkdown string `json:"index_markdown"`
// RepoIndexMarkdown is the per-repo index page content.
RepoIndexMarkdown string `json:"repo_index_markdown"`
}
// Generator orchestrates all wiki pages. The set-up phase (New)
// derives the supporting lookup maps; Generate writes the markdown
// pages and flushes the writer.
type Generator struct {
graph graph.Store
communities *analysis.CommunityResult
processes *analysis.ProcessResult
hotspots []analysis.HotspotEntry
cycles []analysis.Cycle
contractList []contracts.Contract
semanticProviders []SemanticProviderStatus
docsBundle string
opts Options
// Derived state.
kept []analysis.Community
nodeByID map[string]*graph.Node
commLabelByNode map[string]string
crossComm map[string]map[string]int
confDist map[string]int
nodeCount int
edgeCount int
fileCount int
symbolCount int
processCount int
contractCount int
}
// New constructs a Generator from the inputs and options. The opts are
// normalised in place.
func New(in Inputs, opts Options) *Generator {
opts = opts.withDefaults()
g := &Generator{
graph: in.Graph,
communities: in.Communities,
processes: in.Processes,
hotspots: in.Hotspots,
cycles: in.Cycles,
contractList: in.Contracts,
semanticProviders: in.SemanticProviders,
docsBundle: in.DocsBundle,
opts: opts,
}
g.computeDerived()
return g
}
func (gen *Generator) computeDerived() {
gen.nodeByID = make(map[string]*graph.Node)
gen.commLabelByNode = make(map[string]string)
gen.crossComm = make(map[string]map[string]int)
gen.confDist = make(map[string]int)
// Workspace filter helper — applied to nodes and edges so the
// stats reflect just the active workspace when one is set.
wsKeep := func(n *graph.Node) bool {
if gen.opts.WorkspaceID == "" || n == nil {
return true
}
ws := n.WorkspaceID
if ws == "" {
ws = n.RepoPrefix
}
return ws == gen.opts.WorkspaceID
}
if gen.graph != nil {
allNodes := gen.graph.AllNodes()
fileSeen := make(map[string]bool)
for _, n := range allNodes {
if !wsKeep(n) {
continue
}
gen.nodeByID[n.ID] = n
gen.nodeCount++
switch n.Kind {
case graph.KindFile:
if !fileSeen[n.FilePath] {
fileSeen[n.FilePath] = true
gen.fileCount++
}
case graph.KindImport, graph.KindPackage, graph.KindModule:
// not a symbol
default:
if n.FilePath != "" && !fileSeen[n.FilePath] {
fileSeen[n.FilePath] = true
gen.fileCount++
}
gen.symbolCount++
}
}
for _, e := range gen.graph.AllEdges() {
// Edge-level workspace filter: skip when either endpoint
// is outside the workspace.
if gen.opts.WorkspaceID != "" {
from := gen.nodeByID[e.From]
to := gen.nodeByID[e.To]
if from == nil || to == nil {
continue
}
}
gen.edgeCount++
label := strings.TrimSpace(e.Origin)
if label == "" {
label = strings.TrimSpace(e.ConfidenceLabel)
}
if label == "" {
label = "unlabeled"
}
gen.confDist[label]++
}
}
// Filter and sort communities just like skills.Generator does.
if gen.communities != nil {
var candidates []analysis.Community
for _, c := range gen.communities.Communities {
if c.Size < gen.opts.MinCommunity {
continue
}
candidates = append(candidates, c)
}
sort.Slice(candidates, func(i, j int) bool {
if candidates[i].Size != candidates[j].Size {
return candidates[i].Size > candidates[j].Size
}
return candidates[i].ID < candidates[j].ID
})
if gen.opts.MaxCommunities > 0 && len(candidates) > gen.opts.MaxCommunities {
candidates = candidates[:gen.opts.MaxCommunities]
}
gen.kept = candidates
// Per-node label lookup (community label for any member).
labelByID := make(map[string]string)
for _, c := range gen.communities.Communities {
label := c.Label
if label == "" {
label = c.ID
}
labelByID[c.ID] = label
}
for nid, cid := range gen.communities.NodeToComm {
if lbl, ok := labelByID[cid]; ok {
gen.commLabelByNode[nid] = lbl
}
}
// Cross-community map (calls only, like skills generator).
if gen.graph != nil {
for _, e := range gen.graph.AllEdges() {
if e.Kind != graph.EdgeCalls {
continue
}
from := gen.communities.NodeToComm[e.From]
to := gen.communities.NodeToComm[e.To]
if from == "" || to == "" || from == to {
continue
}
if gen.crossComm[from] == nil {
gen.crossComm[from] = make(map[string]int)
}
gen.crossComm[from][to]++
}
}
}
if gen.processes != nil {
gen.processCount = len(gen.processes.Processes)
}
gen.contractCount = len(gen.contractList)
}
// Generate renders every page and flushes the writer. The writer
// returned by Files() is the same one that wrote disk so a caller can
// pre-extract the in-memory payloads for testing.
func (gen *Generator) Generate(ctx context.Context) (*Result, *Writer, error) {
if gen.graph == nil {
return nil, nil, fmt.Errorf("wiki: graph is required")
}
repoSlug := gen.opts.Repo
if repoSlug == "" {
repoSlug = "repo"
}
repoDir := repoSlug
writer := NewWriter(gen.opts.OutputDir)
// Per-repo index.
repoIndex, err := gen.renderRepoIndex(ctx)
if err != nil {
return nil, nil, fmt.Errorf("wiki: render repo index: %w", err)
}
writer.Write(path.Join(repoDir, "index.md"), []byte(repoIndex))
// Top-level index.
topIndex := gen.renderTopLevelIndex()
writer.Write("index.md", []byte(topIndex))
// Architecture.
arch, err := gen.renderArchitecturePage(ctx)
if err != nil {
return nil, nil, fmt.Errorf("wiki: render architecture: %w", err)
}
writer.Write(path.Join(repoDir, "architecture.md"), []byte(arch))
// Community pages.
for _, c := range gen.kept {
body, err := gen.renderCommunityPage(ctx, c)
if err != nil {
return nil, nil, fmt.Errorf("wiki: render community %q: %w", c.ID, err)
}
writer.Write(path.Join(repoDir, gen.communityPagePath(c)), []byte(body))
}
// Process pages.
if !gen.opts.NoProcesses && gen.processes != nil {
for _, p := range gen.processes.Processes {
body, err := gen.renderProcessPage(ctx, p)
if err != nil {
return nil, nil, fmt.Errorf("wiki: render process %q: %w", p.ID, err)
}
writer.Write(path.Join(repoDir, processPagePath(p)), []byte(body))
}
}
// Contracts.
if !gen.opts.NoContracts {
body, err := gen.renderContractsPage(ctx)
if err != nil {
return nil, nil, fmt.Errorf("wiki: render contracts: %w", err)
}
writer.Write(path.Join(repoDir, "contracts", "api-surface.md"), []byte(body))
}
// Analysis pages.
hotspots, _ := gen.renderHotspotsPage(ctx)
writer.Write(path.Join(repoDir, "analysis", "hotspots.md"), []byte(hotspots))
cycles, _ := gen.renderCyclesPage(ctx)
writer.Write(path.Join(repoDir, "analysis", "cycles.md"), []byte(cycles))
sem, _ := gen.renderSemanticPage(ctx)
writer.Write(path.Join(repoDir, "analysis", "semantic.md"), []byte(sem))
// Mermaid asset (community graph).
mer := RenderCommunityGraph(gen.graph, gen.communities, CommunityGraphOpts{
MinSize: gen.opts.MinCommunity,
Max: gen.opts.MaxCommunities,
})
writer.Write(path.Join(repoDir, "_assets", "community-graph.mermaid"), []byte(mer))
// Reserved layout — keep an empty marker so the directory survives
// `git add` and signals the multi-repo extension point.
writer.Write(path.Join("_workspace", "README.md"),
[]byte("# Workspace pages\n\nReserved for cross-repo wiki pages. Single-repo mode leaves this directory empty.\n"))
// Docs bundle.
if !gen.opts.NoDocs && gen.docsBundle != "" {
writer.Write(path.Join(repoDir, "changelog.md"), []byte(gen.docsBundle))
}
// HTML wrapper (Phase 5).
if gen.opts.Format == "html" {
html := RenderHTMLIndex(repoSlug, gen.opts)
writer.Write(path.Join(repoDir, "index.html"), []byte(html))
}
manifest, err := writer.Flush()
if err != nil {
return nil, nil, err
}
return &Result{
OutputDir: gen.opts.OutputDir,
Files: manifest,
IndexMarkdown: topIndex,
RepoIndexMarkdown: repoIndex,
}, writer, nil
}
// buildFileSymbolMap mirrors skills.Generator.buildFileSymbolMap.
func (gen *Generator) buildFileSymbolMap(c analysis.Community) map[string][]string {
out := make(map[string][]string)
for _, mid := range c.Members {
n := gen.nodeByID[mid]
if n == nil || n.Kind == graph.KindFile || n.Kind == graph.KindImport {
continue
}
out[n.FilePath] = append(out[n.FilePath], n.Name)
}
// Stable sort each per-file list.
for k := range out {
sort.Strings(out[k])
}
return out
}
// findEntryPoints mirrors skills.Generator.findEntryPoints.
func (gen *Generator) findEntryPoints(c analysis.Community) []string {
if gen.processes == nil {
return nil
}
memberSet := make(map[string]bool, len(c.Members))
for _, m := range c.Members {
memberSet[m] = true
}
var eps []string
seen := make(map[string]bool)
for _, p := range gen.processes.Processes {
if memberSet[p.EntryPoint] && !seen[p.EntryPoint] {
seen[p.EntryPoint] = true
eps = append(eps, p.EntryPoint)
}
}
if len(eps) > 5 {
eps = eps[:5]
}
return eps
}
// communityLabel returns a human-readable label for an ID, defaulting
// to the ID itself.
func (gen *Generator) communityLabel(id string) string {
if gen.communities == nil {
return id
}
for _, c := range gen.communities.Communities {
if c.ID == id {
if c.Label != "" {
return c.Label
}
return id
}
}
return id
}