Skip to content

Commit e8ff53e

Browse files
refactor(docs): code analysis engine
changes: - file: cli.py area: cli modified: [generate] - file: config.py area: config modified: [Code2DocsConfig] - file: _registry_adapters.py area: docs added: [OrgReadmeAdapter] modified: [should_run, run] - file: org_readme_gen.py area: docs added: [_get_repo_url, write, _truncate_description, _analyze_project, _render_project_section, generate, +5 more] dependencies: flow: "_registry_adapters→org_readme_gen, cli→config" - cli.py -> config.py - _registry_adapters.py -> org_readme_gen.py stats: lines: "+13020/-12312 (net +708)" files: 19 complexity: "Large structural change (normalized)"
1 parent d01f395 commit e8ff53e

26 files changed

+13048
-12317
lines changed

CHANGELOG.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [3.0.5] - 2026-03-08
11+
12+
### Docs
13+
- Update project/README.md
14+
- Update project/context.md
15+
16+
### Test
17+
- Update tests/project/dashboard.html
18+
- Update tests/project/project.yaml
19+
20+
### Other
21+
- Update code2docs/cli.py
22+
- Update code2docs/config.py
23+
- Update code2docs/generators/_registry_adapters.py
24+
- Update code2docs/generators/org_readme_gen.py
25+
- Update project/analysis.json
26+
- Update project/analysis.toon
27+
- Update project/analysis.yaml
28+
- Update project/calls.mmd
29+
- Update project/compact_flow.mmd
30+
- Update project/dashboard.html
31+
- ... and 7 more files
32+
1033
## [3.0.4] - 2026-03-08
1134

1235
### Docs

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# code2docs
22

3-
![version](https://img.shields.io/badge/version-3.0.4-blue) ![python](https://img.shields.io/badge/python-%3E%3D3.9-blue) ![docs](https://img.shields.io/badge/docs-auto--generated-blueviolet)
3+
![version](https://img.shields.io/badge/version-3.0.5-blue) ![python](https://img.shields.io/badge/python-%3E%3D3.9-blue) ![docs](https://img.shields.io/badge/docs-auto--generated-blueviolet)
44

55
> Auto-generate and sync project documentation from source code analysis.
66
@@ -140,7 +140,7 @@ code2docs can update only specific sections of an existing README using markers:
140140
```markdown
141141
<!-- code2docs:start --># code2docs
142142

143-
![version](https://img.shields.io/badge/version-3.0.4-blue) ![python](https://img.shields.io/badge/python-%3E%3D3.9-blue) ![coverage](https://img.shields.io/badge/coverage-unknown-lightgrey) ![functions](https://img.shields.io/badge/functions-276-green)
143+
![version](https://img.shields.io/badge/version-3.0.5-blue) ![python](https://img.shields.io/badge/python-%3E%3D3.9-blue) ![coverage](https://img.shields.io/badge/coverage-unknown-lightgrey) ![functions](https://img.shields.io/badge/functions-276-green)
144144
> **276** functions | **57** classes | **51** files | CC̄ = 3.8
145145

146146
> Auto-generated project documentation from source code analysis.

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
3.0.4
1+
3.0.5

code2docs/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
README.md, API references, module docs, examples, and architecture diagrams.
66
"""
77

8-
__version__ = "3.0.4"
8+
__version__ = "3.0.5"
99
__author__ = "Tom Sapletta"
1010

1111
from .config import Code2DocsConfig

code2docs/cli.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,9 @@ def main():
4040
@click.option("--dry-run", is_flag=True, help="Show what would be generated without writing")
4141
@click.option("--llm", "llm_model", default=None,
4242
help="Enable LLM-assisted generation (e.g. openai/gpt-4o-mini, ollama/llama3)")
43+
@click.option("--org-name", default=None, help="Organization name for org-mode README generation")
4344
def generate(project_path, config_path, readme_only, sections, output, verbose, dry_run,
44-
llm_model):
45+
llm_model, org_name):
4546
"""Generate documentation (default command)."""
4647
config = _load_config(project_path, config_path)
4748
if verbose:
@@ -53,6 +54,8 @@ def generate(project_path, config_path, readme_only, sections, output, verbose,
5354
if llm_model:
5455
config.llm.enabled = True
5556
config.llm.model = llm_model
57+
if org_name:
58+
config.org_name = org_name
5659

5760
_run_generate(project_path, config, readme_only=readme_only, dry_run=dry_run)
5861

code2docs/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ class Code2DocsConfig:
108108
output: str = "./docs/"
109109
readme_output: str = "./README.md"
110110
repo_url: str = "" # GitHub/GitLab URL for source links (auto-detected from git)
111+
org_name: str = "" # Organization name for org-mode README generation
111112

112113
readme: ReadmeConfig = field(default_factory=ReadmeConfig)
113114
docs: DocsConfig = field(default_factory=DocsConfig)

code2docs/generators/_registry_adapters.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,35 @@ def run(self, ctx: GenerateContext) -> Optional[str]:
240240
return "⚠️ project/ (no files generated)"
241241

242242

243+
class OrgReadmeAdapter(BaseGenerator):
244+
"""Adapter for organization README generation."""
245+
name = "org_readme"
246+
247+
def should_run(self, *, readme_only: bool = False) -> bool:
248+
# Only run if org_name is set in config
249+
return hasattr(self.config, 'org_name') and bool(self.config.org_name)
250+
251+
def run(self, ctx: GenerateContext) -> Optional[str]:
252+
from .org_readme_gen import OrgReadmeGenerator
253+
254+
org_name = getattr(self.config, 'org_name', '')
255+
if not org_name:
256+
return None
257+
258+
gen = OrgReadmeGenerator(self.config, str(ctx.project), org_name)
259+
content = gen.generate()
260+
261+
if ctx.dry_run:
262+
click.echo(f"\n--- {org_name} README ({len(content)} chars) ---")
263+
preview = content[:500] + "..." if len(content) > 500 else content
264+
click.echo(preview)
265+
return None
266+
267+
readme_path = ctx.docs_dir / "README.md"
268+
gen.write(str(readme_path), content)
269+
return f"✅ {readme_path.relative_to(ctx.project)}"
270+
271+
243272
ALL_ADAPTERS = [
244273
ReadmeGeneratorAdapter,
245274
ApiReferenceAdapter,
@@ -254,4 +283,5 @@ def run(self, ctx: GenerateContext) -> Optional[str]:
254283
ContributingAdapter,
255284
MkDocsAdapter,
256285
Code2LlmAdapter,
286+
OrgReadmeAdapter,
257287
]
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
"""Organization README generator - generates overview of multiple projects."""
2+
3+
from pathlib import Path
4+
from typing import Dict, List, Optional
5+
6+
from code2llm.api import AnalysisResult
7+
8+
from ..config import Code2DocsConfig
9+
from ..analyzers.project_scanner import ProjectScanner
10+
11+
12+
class OrgReadmeGenerator:
13+
"""Generate organization README with list of projects and brief descriptions."""
14+
15+
def __init__(self, config: Code2DocsConfig, org_path: str, org_name: str = ""):
16+
self.config = config
17+
self.org_path = Path(org_path).resolve()
18+
self.org_name = org_name or self.org_path.name
19+
self.scanner = ProjectScanner(config)
20+
21+
def generate(self) -> str:
22+
"""Generate organization README content."""
23+
projects = self._discover_projects()
24+
25+
lines = [
26+
f"# {self.org_name}\n",
27+
f"Projects in the {self.org_name} organization.\n",
28+
f"**{len(projects)}** projects discovered.\n",
29+
"## Projects\n",
30+
]
31+
32+
for project_name, project_info in sorted(projects.items()):
33+
lines.append(self._render_project_section(project_name, project_info))
34+
lines.append("")
35+
36+
return "\n".join(lines)
37+
38+
def _discover_projects(self) -> Dict[str, Dict]:
39+
"""Discover all projects in organization directory."""
40+
projects = {}
41+
42+
for item in self.org_path.iterdir():
43+
if not item.is_dir():
44+
continue
45+
if item.name.startswith(".") or item.name.startswith("__"):
46+
continue
47+
48+
project_info = self._analyze_project(item)
49+
if project_info:
50+
projects[item.name] = project_info
51+
52+
return projects
53+
54+
def _analyze_project(self, project_path: Path) -> Optional[Dict]:
55+
"""Analyze a single project and return summary info."""
56+
try:
57+
result = self.scanner.analyze(str(project_path))
58+
59+
# Extract description from first module docstring or pyproject.toml
60+
description = self._extract_description(project_path, result)
61+
62+
# Count functions, classes, modules
63+
func_count = len(result.functions)
64+
class_count = len(result.classes)
65+
module_count = len(result.modules)
66+
67+
# Get version from pyproject.toml if available
68+
version = self._get_version(project_path)
69+
70+
# Get repo URL from git or config
71+
repo_url = self._get_repo_url(project_path)
72+
73+
return {
74+
"name": project_path.name,
75+
"description": description,
76+
"version": version,
77+
"stats": {
78+
"functions": func_count,
79+
"classes": class_count,
80+
"modules": module_count,
81+
},
82+
"repo_url": repo_url,
83+
"path": str(project_path),
84+
}
85+
except Exception:
86+
return None
87+
88+
def _extract_description(self, project_path: Path, result: AnalysisResult) -> str:
89+
"""Extract short description from project (max 5 lines)."""
90+
# Try pyproject.toml first
91+
try:
92+
import tomllib
93+
pyproject = project_path / "pyproject.toml"
94+
if pyproject.exists():
95+
with open(pyproject, "rb") as f:
96+
data = tomllib.load(f)
97+
desc = data.get("project", {}).get("description", "")
98+
if desc:
99+
# Limit to ~5 lines worth of content
100+
return self._truncate_description(desc)
101+
except Exception:
102+
pass
103+
104+
# Try first package docstring
105+
for mod in result.modules.values():
106+
if mod.is_package and hasattr(mod, "docstring") and mod.docstring:
107+
return self._truncate_description(mod.docstring)
108+
109+
# Try README.md first paragraph
110+
readme = project_path / "README.md"
111+
if readme.exists():
112+
try:
113+
content = readme.read_text(encoding="utf-8")
114+
# Find first paragraph after title
115+
lines = content.split("\n")
116+
for i, line in enumerate(lines):
117+
if line.startswith("# "):
118+
# Get next non-empty lines
119+
desc_lines = []
120+
for j in range(i + 1, min(i + 10, len(lines))):
121+
if lines[j].strip() and not lines[j].startswith("#"):
122+
desc_lines.append(lines[j].strip())
123+
if len(desc_lines) >= 5:
124+
break
125+
if desc_lines:
126+
return " ".join(desc_lines)
127+
except Exception:
128+
pass
129+
130+
return "No description available."
131+
132+
def _truncate_description(self, desc: str, max_chars: int = 300) -> str:
133+
"""Truncate description to ~5 lines of content."""
134+
lines = desc.strip().split("\n")
135+
# Filter out empty lines and headers
136+
content_lines = [l.strip() for l in lines if l.strip() and not l.startswith("#")]
137+
138+
result = []
139+
char_count = 0
140+
for line in content_lines[:5]:
141+
if char_count + len(line) > max_chars:
142+
remaining = max_chars - char_count
143+
if remaining > 20:
144+
result.append(line[:remaining] + "...")
145+
break
146+
result.append(line)
147+
char_count += len(line)
148+
149+
return " ".join(result) if result else "No description available."
150+
151+
def _get_version(self, project_path: Path) -> str:
152+
"""Get version from pyproject.toml or VERSION file."""
153+
try:
154+
import tomllib
155+
pyproject = project_path / "pyproject.toml"
156+
if pyproject.exists():
157+
with open(pyproject, "rb") as f:
158+
data = tomllib.load(f)
159+
return data.get("project", {}).get("version", "")
160+
except Exception:
161+
pass
162+
163+
version_file = project_path / "VERSION"
164+
if version_file.exists():
165+
return version_file.read_text(encoding="utf-8").strip()
166+
167+
return ""
168+
169+
def _get_repo_url(self, project_path: Path) -> str:
170+
"""Get repository URL from git or pyproject.toml."""
171+
# Try pyproject.toml
172+
try:
173+
import tomllib
174+
pyproject = project_path / "pyproject.toml"
175+
if pyproject.exists():
176+
with open(pyproject, "rb") as f:
177+
data = tomllib.load(f)
178+
urls = data.get("project", {}).get("urls", {})
179+
if urls:
180+
return urls.get("Repository", urls.get("Homepage", ""))
181+
except Exception:
182+
pass
183+
184+
# Try git remote
185+
try:
186+
import subprocess
187+
result = subprocess.run(
188+
["git", "remote", "get-url", "origin"],
189+
cwd=str(project_path),
190+
capture_output=True, text=True, timeout=5,
191+
)
192+
if result.returncode == 0:
193+
url = result.stdout.strip()
194+
# Convert SSH to HTTPS
195+
if url.startswith("git@"):
196+
url = url.replace(":", "/", 1).replace("git@", "https://", 1)
197+
return url.removesuffix(".git")
198+
except Exception:
199+
pass
200+
201+
return ""
202+
203+
def _render_project_section(self, name: str, info: Dict) -> str:
204+
"""Render a single project section (5 lines max)."""
205+
lines = [f"### {name}"]
206+
207+
# Line 1: Description
208+
lines.append(info["description"])
209+
210+
# Line 2: Stats
211+
stats = info["stats"]
212+
stats_line = f"📊 {stats['functions']} functions | {stats['classes']} classes | {stats['modules']} modules"
213+
if info["version"]:
214+
stats_line += f" | v{info['version']}"
215+
lines.append(stats_line)
216+
217+
# Line 3: Repo link if available
218+
if info["repo_url"]:
219+
lines.append(f"🔗 [{info['repo_url']}]({info['repo_url']})")
220+
221+
return "\n".join(lines)
222+
223+
def write(self, output_path: str, content: str) -> None:
224+
"""Write README to output path."""
225+
out_path = Path(output_path)
226+
out_path.parent.mkdir(parents=True, exist_ok=True)
227+
out_path.write_text(content, encoding="utf-8")

project/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ When you run `code2llm ./ -f all`, the following files are created:
1010

1111
| File | Format | Purpose | Key Insights |
1212
|------|--------|---------|--------------|
13-
| `analysis.toon` | **TOON** | **🔥 Health diagnostics** - Complexity, god modules, coupling | 22 critical functions, 0 god modules |
13+
| `analysis.toon` | **TOON** | **🔥 Health diagnostics** - Complexity, god modules, coupling | 24 critical functions, 0 god modules |
1414
| `evolution.toon` | **TOON** | **📋 Refactoring queue** - Prioritized improvements | 0 refactoring actions needed |
1515
| `flow.toon` | **TOON** | **🔄 Data flow analysis** - Pipelines, contracts, types | Data dependencies and side effects |
1616
| `map.toon` | **TOON** | **🗺️ Structural map** - Modules, imports, signatures | Project architecture overview |
@@ -338,7 +338,7 @@ code2llm ./ -f yaml --separate-orphans
338338

339339
**Generated by**: `code2llm ./ -f all --readme`
340340
**Analysis Date**: 2026-03-08
341-
**Total Functions**: 278
341+
**Total Functions**: 280
342342
**Total Classes**: 57
343343
**Modules**: 51
344344

project/analysis.json

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)