Files
2026-07-13 12:42:18 +08:00

306 lines
10 KiB
Python

"""Wiki generation from community structure.
Generates markdown pages for each detected community and an index page,
providing a navigable documentation wiki for the codebase architecture.
"""
from __future__ import annotations
import logging
import re
import sqlite3
from collections import Counter
from pathlib import Path
from typing import Any
from .communities import get_communities
from .flows import get_flows
from .graph import GraphStore, _sanitize_name
logger = logging.getLogger(__name__)
def _slugify(name: str) -> str:
"""Convert a community name to a safe filename slug."""
slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
return slug[:80] or "unnamed"
def _generate_community_page(store: GraphStore, community: dict[str, Any]) -> str:
"""Build markdown content for a single community.
Includes: heading, overview (size, cohesion, language), members table
(top 50), execution flows through the community, and dependencies.
Args:
store: The graph store.
community: Community dict from get_communities().
Returns:
Markdown string for the community page.
"""
name = community["name"]
size = community["size"]
cohesion = community.get("cohesion", 0.0)
lang = community.get("dominant_language", "")
description = community.get("description", "")
lines: list[str] = []
lines.append(f"# {name}")
lines.append("")
# Overview section
lines.append("## Overview")
lines.append("")
if description:
lines.append(f"{description}")
lines.append("")
lines.append(f"- **Size**: {size} nodes")
lines.append(f"- **Cohesion**: {cohesion:.4f}")
if lang:
lines.append(f"- **Dominant Language**: {lang}")
lines.append("")
# Members table (top 50)
member_qns = community.get("members", [])
lines.append("## Members")
lines.append("")
if member_qns:
lines.append("| Name | Kind | File | Lines |")
lines.append("|------|------|------|-------|")
# Fetch node details for members (limit to 50)
member_count = 0
for qn in member_qns[:50]:
node = store.get_node(qn)
if node and node.kind != "File":
node_name = _sanitize_name(node.name)
lines.append(
f"| {node_name} | {node.kind} | {node.file_path} "
f"| {node.line_start}-{node.line_end} |"
)
member_count += 1
if not member_count:
# Remove the table headers if no members were added
lines.pop() # header separator
lines.pop() # header
lines.append("No non-file members found.")
if len(member_qns) > 50:
lines.append("")
lines.append(f"*... and {len(member_qns) - 50} more members.*")
else:
lines.append("No members found.")
lines.append("")
# Execution flows through community
lines.append("## Execution Flows")
lines.append("")
member_set = set(member_qns)
try:
all_flows = get_flows(store, sort_by="criticality", limit=200)
community_flows: list[dict] = []
for flow in all_flows:
# Check if this flow passes through any community member
flow_qns = store.get_flow_qualified_names(flow["id"])
if flow_qns & member_set:
community_flows.append(flow)
if community_flows:
for flow in community_flows[:10]:
flow_name = _sanitize_name(flow.get("name", "unnamed"))
criticality = flow.get("criticality", 0.0)
depth = flow.get("depth", 0)
lines.append(
f"- **{flow_name}** (criticality: {criticality:.2f}, depth: {depth})"
)
if len(community_flows) > 10:
lines.append(f"- *... and {len(community_flows) - 10} more flows.*")
else:
lines.append("No execution flows pass through this community.")
except sqlite3.OperationalError as exc:
logger.debug("wiki: flows table unavailable: %s", exc)
lines.append("Execution flow data not available.")
lines.append("")
# Dependencies (cross-community edges)
lines.append("## Dependencies")
lines.append("")
try:
outgoing_targets: Counter[str] = Counter()
incoming_sources: Counter[str] = Counter()
if member_qns:
qns = list(member_qns)
# Outgoing: source is a member
for t in store.get_outgoing_targets(qns):
if t not in member_set:
outgoing_targets[t] += 1
# Incoming: target is a member
for s in store.get_incoming_sources(qns):
if s not in member_set:
incoming_sources[s] += 1
if outgoing_targets:
lines.append("### Outgoing")
lines.append("")
for target, count in outgoing_targets.most_common(15):
lines.append(f"- `{_sanitize_name(target)}` ({count} edge(s))")
lines.append("")
if incoming_sources:
lines.append("### Incoming")
lines.append("")
for source, count in incoming_sources.most_common(15):
lines.append(f"- `{_sanitize_name(source)}` ({count} edge(s))")
lines.append("")
if not outgoing_targets and not incoming_sources:
lines.append("No cross-community dependencies detected.")
lines.append("")
except sqlite3.OperationalError as exc:
logger.debug("wiki: dependency edges unavailable: %s", exc)
lines.append("Dependency data not available.")
lines.append("")
return "\n".join(lines)
def generate_wiki(
store: GraphStore,
wiki_dir: str | Path,
force: bool = False,
) -> dict[str, Any]:
"""Generate a markdown wiki from the community structure.
For each community, generates a markdown page. Also generates an
index.md with links to all community pages.
Args:
store: The graph store.
wiki_dir: Directory to write wiki pages into.
force: If True, regenerate all pages even if content unchanged.
Returns:
Dict with pages_generated, pages_updated, pages_unchanged counts.
"""
wiki_path = Path(wiki_dir)
wiki_path.mkdir(parents=True, exist_ok=True)
communities = get_communities(store)
pages_generated = 0
pages_updated = 0
pages_unchanged = 0
page_entries: list[tuple[str, str, int]] = [] # (slug, name, size)
# Track slugs we've already used in THIS run so two communities that
# slugify to the same filename don't overwrite each other (#222 follow-up).
# Previously "Data Processing" and "data processing" both became
# "data-processing.md", causing silent data loss and inflated "updated"
# counters (each collision was counted as an update while only one file
# made it to disk).
used_slugs: set[str] = set()
for comm in communities:
name = comm["name"]
base_slug = _slugify(name)
slug = base_slug
suffix = 2
while slug in used_slugs:
slug = f"{base_slug}-{suffix}"
suffix += 1
used_slugs.add(slug)
filename = f"{slug}.md"
filepath = wiki_path / filename
content = _generate_community_page(store, comm)
if filepath.exists() and not force:
existing = filepath.read_text(encoding="utf-8", errors="replace")
if existing == content:
pages_unchanged += 1
page_entries.append((slug, name, comm["size"]))
continue
already_existed = filepath.exists()
filepath.write_text(content, encoding="utf-8")
if already_existed:
pages_updated += 1
else:
pages_generated += 1
page_entries.append((slug, name, comm["size"]))
# Generate index.md
index_lines: list[str] = []
index_lines.append("# Code Wiki")
index_lines.append("")
index_lines.append(
"Auto-generated documentation from the code knowledge graph community structure."
)
index_lines.append("")
index_lines.append(f"**Total communities**: {len(communities)}")
index_lines.append("")
index_lines.append("## Communities")
index_lines.append("")
index_lines.append("| Community | Size | Link |")
index_lines.append("|-----------|------|------|")
for slug, name, size in sorted(page_entries, key=lambda x: x[1]):
index_lines.append(f"| {name} | {size} | [{slug}.md]({slug}.md) |")
index_lines.append("")
index_content = "\n".join(index_lines)
index_path = wiki_path / "index.md"
if index_path.exists() and not force:
existing_index = index_path.read_text(encoding="utf-8", errors="replace")
if existing_index == index_content:
pages_unchanged += 1
else:
index_path.write_text(index_content, encoding="utf-8")
pages_updated += 1
else:
index_path.write_text(index_content, encoding="utf-8")
pages_generated += 1
return {
"pages_generated": pages_generated,
"pages_updated": pages_updated,
"pages_unchanged": pages_unchanged,
}
def get_wiki_page(wiki_dir: str | Path, page_name: str) -> str | None:
"""Retrieve a specific wiki page by community name.
Args:
wiki_dir: Directory containing wiki pages.
page_name: Community name (will be slugified for filename lookup).
Returns:
Page content as a string, or None if the page does not exist.
"""
wiki_path = Path(wiki_dir)
slug = _slugify(page_name)
filepath = wiki_path / f"{slug}.md"
if filepath.is_file():
return filepath.read_text(encoding="utf-8", errors="replace")
# Fallback: try exact filename match — with path traversal protection
exact_path = (wiki_path / page_name).resolve()
if exact_path.is_file() and exact_path.is_relative_to(wiki_path.resolve()):
return exact_path.read_text(encoding="utf-8", errors="replace")
# Fallback: search for partial match
if wiki_path.is_dir():
for p in wiki_path.iterdir():
if p.suffix == ".md" and slug in p.stem:
return p.read_text(encoding="utf-8", errors="replace")
return None