package wiki import ( "context" "fmt" "sort" "strings" "time" "github.com/zzet/gortex/internal/analysis" "github.com/zzet/gortex/internal/contracts" "github.com/zzet/gortex/internal/graph" ) // renderRepoIndex emits the per-repo index page (wiki//index.md). // Lists communities in size order with links to per-community pages, // the architecture diagram, the analysis pages, and the docs bundle. func (gen *Generator) renderRepoIndex(ctx context.Context) (string, error) { var b strings.Builder repoLabel := gen.opts.Repo if repoLabel == "" { repoLabel = "Repository" } b.WriteString("---\n") fmt.Fprintf(&b, "title: %s wiki\n", repoLabel) fmt.Fprintf(&b, "generated_at: %s\n", gen.opts.GeneratedAt.UTC().Format(time.RFC3339)) fmt.Fprintf(&b, "node_count: %d\n", gen.nodeCount) fmt.Fprintf(&b, "edge_count: %d\n", gen.edgeCount) b.WriteString("---\n\n") fmt.Fprintf(&b, "# %s\n\n", repoLabel) fmt.Fprintf(&b, "Auto-generated by `gortex wiki` on %s. %d nodes, %d edges across %d files.\n\n", gen.opts.GeneratedAt.UTC().Format("2006-01-02"), gen.nodeCount, gen.edgeCount, gen.fileCount) b.WriteString("## At a Glance\n\n") b.WriteString("| Metric | Value |\n") b.WriteString("|--------|-------|\n") fmt.Fprintf(&b, "| Files | %d |\n", gen.fileCount) fmt.Fprintf(&b, "| Symbols | %d |\n", gen.symbolCount) fmt.Fprintf(&b, "| Relationships | %d |\n", gen.edgeCount) fmt.Fprintf(&b, "| Communities | %d |\n", len(gen.kept)) fmt.Fprintf(&b, "| Processes | %d |\n", gen.processCount) fmt.Fprintf(&b, "| Contracts | %d |\n", gen.contractCount) fmt.Fprintf(&b, "| Hotspots | %d |\n", len(gen.hotspots)) fmt.Fprintf(&b, "| Cycles | %d |\n", len(gen.cycles)) b.WriteString("\n") if len(gen.kept) > 0 { b.WriteString("## Community Map\n\n") b.WriteString("```mermaid\n") b.WriteString(RenderCommunityGraph(gen.graph, gen.communities, CommunityGraphOpts{ MinSize: gen.opts.MinCommunity, Max: gen.opts.MaxCommunities, })) if !strings.HasSuffix(b.String(), "\n") { b.WriteString("\n") } b.WriteString("```\n\n") b.WriteString("## Communities\n\n") b.WriteString("| Community | Size | Cohesion | Files | Page |\n") b.WriteString("|-----------|------|----------|-------|------|\n") for _, c := range gen.kept { page := gen.communityPagePath(c) fmt.Fprintf(&b, "| %s | %d | %.0f%% | %d | [%s](%s) |\n", escapeTableCell(c.Label), c.Size, c.Cohesion*100, len(c.Files), "open", page) } b.WriteString("\n") } b.WriteString("## Sections\n\n") b.WriteString("- [Architecture](architecture.md) — community-level overview\n") if !gen.opts.NoProcesses { b.WriteString("- [Processes](processes/) — execution flows with sequence diagrams\n") } if !gen.opts.NoContracts { b.WriteString("- [API Contracts](contracts/api-surface.md) — HTTP/gRPC/GraphQL surfaces\n") } b.WriteString("- [Hotspots](analysis/hotspots.md) — high-coupling symbols\n") b.WriteString("- [Cycles](analysis/cycles.md) — circular dependencies\n") b.WriteString("- [Semantic](analysis/semantic.md) — edge confidence & coverage\n") if !gen.opts.NoDocs && gen.docsBundle != "" { b.WriteString("- [Changelog & ownership](changelog.md) — `gortex docs` bundle\n") } b.WriteString("\n") b.WriteString("## How to Explore\n\n") b.WriteString("```\n") b.WriteString("smart_context with task: \"understand this codebase\"\n") b.WriteString("get_repo_outline\n") if len(gen.kept) > 0 { fmt.Fprintf(&b, "get_communities with id: %q\n", gen.kept[0].ID) } b.WriteString("```\n") body := b.String() if gen.opts.Enhance && gen.opts.Enhancer != nil { out, err := gen.opts.Enhancer.Enhance(ctx, EnhanceSection{ Kind: "architecture", PageTitle: repoLabel + " index", RawMarkdown: body, }) if err == nil && strings.TrimSpace(out) != "" { body = out } } return body, nil } // renderCommunityPage emits one community article. func (gen *Generator) renderCommunityPage(ctx context.Context, c analysis.Community) (string, error) { var b strings.Builder label := c.Label if label == "" { label = c.ID } b.WriteString("---\n") fmt.Fprintf(&b, "title: %s\n", label) fmt.Fprintf(&b, "community_id: %s\n", c.ID) fmt.Fprintf(&b, "symbols: %d\n", c.Size) fmt.Fprintf(&b, "files: %d\n", len(c.Files)) b.WriteString("---\n\n") fmt.Fprintf(&b, "# %s\n\n", label) fmt.Fprintf(&b, "%d symbols across %d files | %.0f%% cohesion | Community `%s`\n\n", c.Size, len(c.Files), c.Cohesion*100, c.ID) b.WriteString("## Files\n\n") if len(c.Files) == 0 { b.WriteString("_No files in this community._\n\n") } else { fileSyms := gen.buildFileSymbolMap(c) // Stable ordering. files := make([]string, 0, len(c.Files)) files = append(files, c.Files...) sort.Strings(files) b.WriteString("| File | Symbols |\n") b.WriteString("|------|---------|\n") for _, f := range files { syms := fileSyms[f] if len(syms) > 8 { syms = append(syms[:8], "...") } fmt.Fprintf(&b, "| `%s` | %s |\n", f, strings.Join(syms, ", ")) } b.WriteString("\n") } // Entry points. entries := gen.findEntryPoints(c) if len(entries) > 0 { b.WriteString("## Entry Points\n\n") for _, ep := range entries { fmt.Fprintf(&b, "- `%s`\n", ep) } b.WriteString("\n") } // Execution flows that touch this community. if !gen.opts.NoProcesses { procs := gen.processesTouching(c) if len(procs) > 0 { b.WriteString("## Execution Flows\n\n") for _, p := range procs { fmt.Fprintf(&b, "- [%s](../processes/%s.md) — %d steps from `%s`\n", p.Name, slugify(p.ID+"-"+p.Name), p.StepCount, p.EntryPoint) } b.WriteString("\n") } } // Cross-community connections. if cm := gen.crossComm[c.ID]; len(cm) > 0 { type pair struct { id, label string count int } var pairs []pair for cid, n := range cm { pairs = append(pairs, pair{id: cid, label: gen.communityLabel(cid), count: n}) } sort.Slice(pairs, func(i, j int) bool { if pairs[i].count != pairs[j].count { return pairs[i].count > pairs[j].count } return pairs[i].label < pairs[j].label }) b.WriteString("## Connected Communities\n\n") b.WriteString("| Community | Cross-edges |\n") b.WriteString("|-----------|-------------|\n") for _, p := range pairs { page := "../" + gen.communityPagePathByID(p.id) fmt.Fprintf(&b, "| [%s](%s) | %d |\n", escapeTableCell(p.label), page, p.count) } b.WriteString("\n") } b.WriteString("## How to Explore\n\n") b.WriteString("```\n") fmt.Fprintf(&b, "get_communities with id: %q\n", c.ID) fmt.Fprintf(&b, "smart_context with task: \"understand %s\"\n", label) if len(entries) > 0 { fmt.Fprintf(&b, "find_usages with id: %q\n", entries[0]) } b.WriteString("```\n") body := b.String() if gen.opts.Enhance && gen.opts.Enhancer != nil { anchors := make([]string, 0, len(c.Members)) if len(c.Members) > 8 { anchors = append(anchors, c.Members[:8]...) } else { anchors = append(anchors, c.Members...) } out, err := gen.opts.Enhancer.Enhance(ctx, EnhanceSection{ Kind: "community", PageTitle: label, AnchorSymbolIDs: anchors, RawMarkdown: body, Context: fmt.Sprintf("size=%d cohesion=%.2f files=%d", c.Size, c.Cohesion, len(c.Files)), }) if err == nil && strings.TrimSpace(out) != "" { body = out } } return body, nil } // renderArchitecturePage emits architecture.md. func (gen *Generator) renderArchitecturePage(ctx context.Context) (string, error) { var b strings.Builder b.WriteString("---\n") b.WriteString("title: Architecture\n") b.WriteString("---\n\n") b.WriteString("# Architecture Overview\n\n") if len(gen.kept) == 0 { b.WriteString("_No communities of sufficient size were detected._\n") } else { fmt.Fprintf(&b, "This codebase decomposes into %d functional communities. The diagram below shows the largest %d and the cross-community calls between them.\n\n", len(gen.kept), len(gen.kept)) b.WriteString("```mermaid\n") b.WriteString(RenderArchitecture(gen.graph, gen.communities, CommunityGraphOpts{ MinSize: gen.opts.MinCommunity, Max: gen.opts.MaxCommunities, })) if !strings.HasSuffix(b.String(), "\n") { b.WriteString("\n") } b.WriteString("```\n\n") b.WriteString("## Communities by Size\n\n") b.WriteString("| Community | Files | Symbols | Cohesion | Hub |\n") b.WriteString("|-----------|-------|---------|----------|-----|\n") for _, c := range gen.kept { hub := c.Hub if hub == "" { hub = "—" } page := gen.communityPagePath(c) fmt.Fprintf(&b, "| [%s](%s) | %d | %d | %.0f%% | `%s` |\n", escapeTableCell(c.Label), page, len(c.Files), c.Size, c.Cohesion*100, escapeTableCell(hub)) } b.WriteString("\n") } body := b.String() if gen.opts.Enhance && gen.opts.Enhancer != nil { out, err := gen.opts.Enhancer.Enhance(ctx, EnhanceSection{ Kind: "architecture", PageTitle: "Architecture overview", RawMarkdown: body, }) if err == nil && strings.TrimSpace(out) != "" { body = out } } return body, nil } // renderHotspotsPage emits analysis/hotspots.md. func (gen *Generator) renderHotspotsPage(_ context.Context) (string, error) { var b strings.Builder b.WriteString("---\n") b.WriteString("title: Hotspots\n") b.WriteString("---\n\n") b.WriteString("# Hotspots — High-Coupling Symbols\n\n") b.WriteString("Symbols ranked by `(fan_in * 2) + (fan_out * 1.5) + (community_crossings * 3)`, normalised to 0..100.\n\n") if len(gen.hotspots) == 0 { b.WriteString("_No hotspots above the auto-threshold (mean + 2σ)._\n") return b.String(), nil } b.WriteString("| # | Symbol | Kind | Fan-in | Fan-out | Crossings | Score |\n") b.WriteString("|---|--------|------|--------|---------|-----------|-------|\n") limit := 30 if len(gen.hotspots) < limit { limit = len(gen.hotspots) } for i, h := range gen.hotspots[:limit] { display := h.Name if h.FilePath != "" { display = fmt.Sprintf("%s — `%s:%d`", h.Name, h.FilePath, h.Line) } fmt.Fprintf(&b, "| %d | %s | %s | %d | %d | %d | %.2f |\n", i+1, escapeTableCell(display), h.Kind, h.FanIn, h.FanOut, h.CommunityCrossings, h.ComplexityScore) } b.WriteString("\nUse `verify_change` before modifying any of these signatures — their blast radius is wide.\n") return b.String(), nil } // renderCyclesPage emits analysis/cycles.md. func (gen *Generator) renderCyclesPage(_ context.Context) (string, error) { var b strings.Builder b.WriteString("---\n") b.WriteString("title: Cycles\n") b.WriteString("---\n\n") b.WriteString("# Circular Dependencies\n\n") if len(gen.cycles) == 0 { b.WriteString("_No cycles detected._\n") return b.String(), nil } fmt.Fprintf(&b, "%d cycle(s) detected. Higher severity = harder to fix and bigger refactor surface.\n\n", len(gen.cycles)) b.WriteString("| # | Kind | Severity | Path length | First node |\n") b.WriteString("|---|------|----------|-------------|------------|\n") for i, c := range gen.cycles { first := "" if len(c.Path) > 0 { first = c.Path[0] } fmt.Fprintf(&b, "| %d | %s | %d | %d | `%s` |\n", i+1, c.Kind, c.Severity, len(c.Path), escapeTableCell(first)) } b.WriteString("\n") limit := 5 if len(gen.cycles) < limit { limit = len(gen.cycles) } for i := 0; i < limit; i++ { c := gen.cycles[i] fmt.Fprintf(&b, "## Cycle %d — %s (severity %d)\n\n", i+1, c.Kind, c.Severity) b.WriteString("```\n") for _, n := range c.Path { fmt.Fprintf(&b, " %s\n", n) } if len(c.Path) > 0 { fmt.Fprintf(&b, " ↑ back to %s\n", c.Path[0]) } b.WriteString("```\n\n") } return b.String(), nil } // renderSemanticPage emits analysis/semantic.md. func (gen *Generator) renderSemanticPage(_ context.Context) (string, error) { var b strings.Builder b.WriteString("---\n") b.WriteString("title: Semantic analysis\n") b.WriteString("---\n\n") b.WriteString("# Semantic Analysis\n\n") b.WriteString("Edge confidence distribution across the indexed graph. Higher tiers come from compiler-verified providers (go-types, LSP, SCIP); lower tiers fall back to AST heuristics.\n\n") if len(gen.confDist) == 0 { b.WriteString("_No confidence-labelled edges in the graph._\n") return b.String(), nil } b.WriteString("## Edge Confidence Distribution\n\n") b.WriteString("| Origin | Edges | Percentage |\n") b.WriteString("|--------|-------|------------|\n") keys := make([]string, 0, len(gen.confDist)) var total int for k, v := range gen.confDist { keys = append(keys, k) total += v } sort.Strings(keys) for _, k := range keys { pct := 0.0 if total > 0 { pct = float64(gen.confDist[k]) / float64(total) * 100 } fmt.Fprintf(&b, "| %s | %d | %.1f%% |\n", k, gen.confDist[k], pct) } fmt.Fprintf(&b, "| **total** | **%d** | 100%% |\n\n", total) if len(gen.semanticProviders) > 0 { b.WriteString("## Provider Status\n\n") b.WriteString("| Language | Provider | Status |\n") b.WriteString("|----------|----------|--------|\n") for _, ps := range gen.semanticProviders { fmt.Fprintf(&b, "| %s | %s | %s |\n", ps.Language, ps.Name, ps.Status) } b.WriteString("\n") } return b.String(), nil } // renderContractsPage emits contracts/api-surface.md. func (gen *Generator) renderContractsPage(_ context.Context) (string, error) { var b strings.Builder b.WriteString("---\n") b.WriteString("title: API contracts\n") b.WriteString("---\n\n") b.WriteString("# API Contracts\n\n") if len(gen.contractList) == 0 { b.WriteString("_No contracts detected._\n") return b.String(), nil } // Group by ContractType, then by Role. byType := make(map[contracts.ContractType][]contracts.Contract) for _, c := range gen.contractList { byType[c.Type] = append(byType[c.Type], c) } types := make([]string, 0, len(byType)) for t := range byType { types = append(types, string(t)) } sort.Strings(types) for _, t := range types { list := byType[contracts.ContractType(t)] fmt.Fprintf(&b, "## %s contracts (%d)\n\n", strings.ToUpper(t), len(list)) b.WriteString("| Role | ID | Symbol | File:Line |\n") b.WriteString("|------|----|--------|-----------|\n") // Stable order. sort.SliceStable(list, func(i, j int) bool { if list[i].Role != list[j].Role { return list[i].Role < list[j].Role } return list[i].ID < list[j].ID }) for _, c := range list { loc := c.FilePath if c.Line > 0 { loc = fmt.Sprintf("%s:%d", c.FilePath, c.Line) } fmt.Fprintf(&b, "| %s | `%s` | `%s` | `%s` |\n", c.Role, escapeTableCell(c.ID), escapeTableCell(c.SymbolID), escapeTableCell(loc)) } b.WriteString("\n") } return b.String(), nil } // renderProcessPage emits one process article. func (gen *Generator) renderProcessPage(ctx context.Context, p analysis.Process) (string, error) { var b strings.Builder b.WriteString("---\n") fmt.Fprintf(&b, "title: %s\n", p.Name) fmt.Fprintf(&b, "process_id: %s\n", p.ID) fmt.Fprintf(&b, "steps: %d\n", p.StepCount) b.WriteString("---\n\n") fmt.Fprintf(&b, "# Process: %s\n\n", p.Name) fmt.Fprintf(&b, "%d steps across %d files. Entry: `%s` (score %.2f).\n\n", p.StepCount, len(p.Files), p.EntryPoint, p.Score) b.WriteString("## Flow\n\n") b.WriteString("```mermaid\n") b.WriteString(RenderProcessSequence(p, gen.nodeByID, gen.commLabelByNode)) if !strings.HasSuffix(b.String(), "\n") { b.WriteString("\n") } b.WriteString("```\n\n") b.WriteString("## Steps\n\n") b.WriteString("| # | Depth | Symbol | File |\n") b.WriteString("|---|-------|--------|------|\n") for i, s := range p.Steps { name, file := s.ID, "" if n, ok := gen.nodeByID[s.ID]; ok && n != nil { name = n.Name file = n.FilePath } fmt.Fprintf(&b, "| %d | %d | `%s` | `%s` |\n", i+1, s.Depth, escapeTableCell(name), escapeTableCell(file)) } b.WriteString("\n") if len(p.Files) > 0 { b.WriteString("## Files Touched\n\n") for _, f := range p.Files { fmt.Fprintf(&b, "- `%s`\n", f) } b.WriteString("\n") } body := b.String() if gen.opts.Enhance && gen.opts.Enhancer != nil { anchors := []string{p.EntryPoint} out, err := gen.opts.Enhancer.Enhance(ctx, EnhanceSection{ Kind: "process", PageTitle: p.Name, AnchorSymbolIDs: anchors, RawMarkdown: body, }) if err == nil && strings.TrimSpace(out) != "" { body = out } } return body, nil } // renderTopLevelIndex emits the top-level wiki/index.md. // Single-element repo list today; multi-element when multi-repo lands. func (gen *Generator) renderTopLevelIndex() string { var b strings.Builder b.WriteString("---\n") b.WriteString("title: Gortex Wiki\n") fmt.Fprintf(&b, "generated_at: %s\n", gen.opts.GeneratedAt.UTC().Format(time.RFC3339)) b.WriteString("---\n\n") b.WriteString("# Workspaces\n\n") b.WriteString("This wiki indexes one repository today. Multi-repo workspaces will appear under `_workspace/` as they land.\n\n") b.WriteString("| Repository | Symbols | Communities | Page |\n") b.WriteString("|------------|---------|-------------|------|\n") fmt.Fprintf(&b, "| `%s` | %d | %d | [%s](%s/index.md) |\n", gen.opts.Repo, gen.symbolCount, len(gen.kept), gen.opts.Repo, gen.opts.Repo) return b.String() } // escapeTableCell makes a string safe inside a Markdown table cell: // strip newlines and escape pipes. func escapeTableCell(s string) string { s = strings.ReplaceAll(s, "\n", " ") s = strings.ReplaceAll(s, "|", "\\|") return s } // processesTouching returns processes whose Steps include a member of c. func (gen *Generator) processesTouching(c analysis.Community) []analysis.Process { if gen.processes == nil { return nil } memberSet := make(map[string]bool, len(c.Members)) for _, m := range c.Members { memberSet[m] = true } var out []analysis.Process for _, p := range gen.processes.Processes { for _, s := range p.Steps { if memberSet[s.ID] { out = append(out, p) break } } } // Cap to 10 most relevant by step count. sort.SliceStable(out, func(i, j int) bool { return out[i].StepCount > out[j].StepCount }) if len(out) > 10 { out = out[:10] } return out } // communityPagePath returns the relative path under wiki// for c. func (gen *Generator) communityPagePath(c analysis.Community) string { return gen.communityPagePathByID(c.ID) } func (gen *Generator) communityPagePathByID(id string) string { for i, c := range gen.kept { if c.ID == id { slug := slugify(c.Label) if slug == "" { slug = slugify(c.ID) } return fmt.Sprintf("communities/%d-%s.md", i+1, slug) } } // Community wasn't kept (filtered out by size or cap) — link to index. return "index.md" } // processPagePath returns the relative path for a process page. func processPagePath(p analysis.Process) string { return "processes/" + slugify(p.ID+"-"+p.Name) + ".md" } // ensure graph import is used. var _ = (*graph.Graph)(nil)