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 }