ai-doc-creator 2.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ai_doc_creator/__init__.py +3 -0
- ai_doc_creator/cli.py +100 -0
- ai_doc_creator/core/__init__.py +1 -0
- ai_doc_creator/core/backends.py +126 -0
- ai_doc_creator/core/cache.py +71 -0
- ai_doc_creator/core/config.py +92 -0
- ai_doc_creator/core/diagrams.py +212 -0
- ai_doc_creator/core/doc_writer.py +43 -0
- ai_doc_creator/core/file_traverser.py +76 -0
- ai_doc_creator/core/graph.py +157 -0
- ai_doc_creator/core/guards.py +104 -0
- ai_doc_creator/core/logging_config.py +49 -0
- ai_doc_creator/core/profiles.py +91 -0
- ai_doc_creator/core/ratelimit.py +106 -0
- ai_doc_creator/core/retry.py +53 -0
- ai_doc_creator/core/sources.py +104 -0
- ai_doc_creator/server.py +629 -0
- ai_doc_creator-2.2.0.dist-info/METADATA +211 -0
- ai_doc_creator-2.2.0.dist-info/RECORD +23 -0
- ai_doc_creator-2.2.0.dist-info/WHEEL +5 -0
- ai_doc_creator-2.2.0.dist-info/entry_points.txt +3 -0
- ai_doc_creator-2.2.0.dist-info/licenses/LICENSE +21 -0
- ai_doc_creator-2.2.0.dist-info/top_level.txt +1 -0
ai_doc_creator/cli.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# ai_doc_creator/cli.py
|
|
2
|
+
import argparse
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from dotenv import load_dotenv
|
|
8
|
+
|
|
9
|
+
load_dotenv()
|
|
10
|
+
|
|
11
|
+
from .core.config import resolve_config # noqa: E402
|
|
12
|
+
from .core.backends import pick_backend, BackendError # noqa: E402
|
|
13
|
+
from .core.sources import GitSource, LocalSource # noqa: E402
|
|
14
|
+
from .core.file_traverser import FileTraverser # noqa: E402
|
|
15
|
+
from .core.graph import app # noqa: E402
|
|
16
|
+
from .core.doc_writer import DocumentationWriter # noqa: E402
|
|
17
|
+
|
|
18
|
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def run(source, output_dir, config):
|
|
23
|
+
repo_path = None
|
|
24
|
+
try:
|
|
25
|
+
repo_path = source.prepare()
|
|
26
|
+
files = list(FileTraverser(repo_path, max_file_size_kb=config.max_file_size_kb).traverse())
|
|
27
|
+
logger.info("Found %d files to process.", len(files))
|
|
28
|
+
if not files:
|
|
29
|
+
logger.warning("No files found to document. Exiting.")
|
|
30
|
+
return
|
|
31
|
+
|
|
32
|
+
backend = pick_backend(config, ctx=None) # CLI has no host -> requires a provider key
|
|
33
|
+
final_state = await app.ainvoke(
|
|
34
|
+
{
|
|
35
|
+
"repo_path": repo_path,
|
|
36
|
+
"files": files,
|
|
37
|
+
"documents": {},
|
|
38
|
+
"index_content": "",
|
|
39
|
+
"backend": backend,
|
|
40
|
+
"max_concurrency": config.max_concurrency,
|
|
41
|
+
"profile": config.profile,
|
|
42
|
+
"diagrams": config.diagrams,
|
|
43
|
+
}
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
DocumentationWriter(output_dir).write_docs(
|
|
47
|
+
final_state["documents"], final_state["index_content"]
|
|
48
|
+
)
|
|
49
|
+
logger.info("Documentation generation complete!")
|
|
50
|
+
finally:
|
|
51
|
+
if source:
|
|
52
|
+
source.cleanup()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def main():
|
|
56
|
+
parser = argparse.ArgumentParser(description="AI Document Creator")
|
|
57
|
+
group = parser.add_mutually_exclusive_group(required=True)
|
|
58
|
+
group.add_argument("--repo", help="GitHub repository URL")
|
|
59
|
+
group.add_argument("--path", help="Local project directory")
|
|
60
|
+
parser.add_argument("--output", default="docs", help="Output directory")
|
|
61
|
+
parser.add_argument(
|
|
62
|
+
"--provider", default=None, help="LLM provider (anthropic/openai/azure/bedrock/ollama)"
|
|
63
|
+
)
|
|
64
|
+
parser.add_argument("--model", default=None, help="Model name override")
|
|
65
|
+
parser.add_argument(
|
|
66
|
+
"--profile",
|
|
67
|
+
default="readme",
|
|
68
|
+
choices=["readme", "api", "architecture", "tutorial"],
|
|
69
|
+
help="Documentation style (default: readme)",
|
|
70
|
+
)
|
|
71
|
+
parser.add_argument(
|
|
72
|
+
"--no-diagrams",
|
|
73
|
+
action="store_true",
|
|
74
|
+
help="Disable Mermaid architecture diagrams and flow charts",
|
|
75
|
+
)
|
|
76
|
+
args = parser.parse_args()
|
|
77
|
+
|
|
78
|
+
config = resolve_config(
|
|
79
|
+
provider=args.provider,
|
|
80
|
+
model=args.model,
|
|
81
|
+
profile=args.profile,
|
|
82
|
+
diagrams=not args.no_diagrams,
|
|
83
|
+
)
|
|
84
|
+
source = GitSource(args.repo) if args.repo else LocalSource(args.path)
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
asyncio.run(run(source, args.output, config))
|
|
88
|
+
except BackendError as exc:
|
|
89
|
+
logger.error("%s", exc)
|
|
90
|
+
sys.exit(1)
|
|
91
|
+
except Exception as exc:
|
|
92
|
+
logger.error("An error occurred: %s", exc)
|
|
93
|
+
import traceback
|
|
94
|
+
|
|
95
|
+
traceback.print_exc()
|
|
96
|
+
sys.exit(1)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
if __name__ == "__main__":
|
|
100
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Core pipeline: config, backends, sources, guards, graph, cache, writer."""
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# core/backends.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
|
|
7
|
+
from langchain.chat_models import init_chat_model # imported at top so tests can monkeypatch it
|
|
8
|
+
|
|
9
|
+
from .config import DocConfig
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class BackendError(RuntimeError):
|
|
13
|
+
"""Raised when no usable LLM backend can be constructed."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _content_to_text(content) -> str:
|
|
17
|
+
"""Flatten a chat model response (str, or list of content blocks) to plain text."""
|
|
18
|
+
if isinstance(content, str):
|
|
19
|
+
return content
|
|
20
|
+
if isinstance(content, list):
|
|
21
|
+
parts: list[str] = []
|
|
22
|
+
for block in content:
|
|
23
|
+
if isinstance(block, str):
|
|
24
|
+
parts.append(block)
|
|
25
|
+
elif isinstance(block, dict) and block.get("type") == "text":
|
|
26
|
+
parts.append(block.get("text", ""))
|
|
27
|
+
return "".join(parts)
|
|
28
|
+
return str(content)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class CompletionBackend(ABC):
|
|
32
|
+
"""Generates text from a prompt. The only thing the pipeline knows about an LLM."""
|
|
33
|
+
|
|
34
|
+
@abstractmethod
|
|
35
|
+
async def complete(self, prompt: str) -> str: ...
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class FakeBackend(CompletionBackend):
|
|
39
|
+
"""Deterministic backend for tests — never calls a real model."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, response: str = "FAKE DOC"):
|
|
42
|
+
self.response = response
|
|
43
|
+
self.calls: list[str] = []
|
|
44
|
+
|
|
45
|
+
async def complete(self, prompt: str) -> str:
|
|
46
|
+
self.calls.append(prompt)
|
|
47
|
+
return self.response
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ProviderBackend(CompletionBackend):
|
|
51
|
+
"""Calls a real provider (anthropic/openai/azure/bedrock/ollama) via LangChain."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, config: DocConfig):
|
|
54
|
+
if not config.has_provider:
|
|
55
|
+
raise BackendError("ProviderBackend requires a provider")
|
|
56
|
+
if config.provider == "azure":
|
|
57
|
+
self._model = self._build_azure(config)
|
|
58
|
+
else:
|
|
59
|
+
# Pass model + provider separately so model names containing ':'
|
|
60
|
+
# (e.g. bedrock 'amazon.nova-pro-v1:0') are never mis-parsed.
|
|
61
|
+
# An explicit per-request key (BYOK) wins over any env credential.
|
|
62
|
+
extra: dict = {}
|
|
63
|
+
if config.api_key:
|
|
64
|
+
extra["api_key"] = config.api_key
|
|
65
|
+
self._model = init_chat_model(
|
|
66
|
+
model=config.resolved_model,
|
|
67
|
+
model_provider=config.lc_provider,
|
|
68
|
+
temperature=0,
|
|
69
|
+
**extra,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
@staticmethod
|
|
73
|
+
def _build_azure(config: DocConfig):
|
|
74
|
+
from langchain_openai import AzureChatOpenAI
|
|
75
|
+
|
|
76
|
+
from pydantic import SecretStr
|
|
77
|
+
|
|
78
|
+
raw_key = config.api_key or os.getenv("AZURE_OPENAI_API_KEY")
|
|
79
|
+
return AzureChatOpenAI(
|
|
80
|
+
azure_deployment=config.model or os.getenv("AZURE_OPENAI_DEPLOYMENT_NAME", "gpt-4o"),
|
|
81
|
+
api_version=os.getenv("AZURE_OPENAI_API_VERSION", "2024-12-01-preview"),
|
|
82
|
+
azure_endpoint=os.getenv("AZURE_OPENAI_ENDPOINT"),
|
|
83
|
+
api_key=SecretStr(raw_key) if raw_key else None,
|
|
84
|
+
temperature=0,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
async def complete(self, prompt: str) -> str:
|
|
88
|
+
result = await self._model.ainvoke(prompt)
|
|
89
|
+
return _content_to_text(getattr(result, "content", result))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class SamplingBackend(CompletionBackend):
|
|
93
|
+
"""Asks the MCP host's own model to generate — zero API cost to the operator."""
|
|
94
|
+
|
|
95
|
+
def __init__(self, ctx, max_tokens: int = 4096):
|
|
96
|
+
self._ctx = ctx
|
|
97
|
+
self._max_tokens = max_tokens
|
|
98
|
+
|
|
99
|
+
async def complete(self, prompt: str) -> str:
|
|
100
|
+
from mcp.types import SamplingMessage, TextContent
|
|
101
|
+
|
|
102
|
+
result = await self._ctx.session.create_message(
|
|
103
|
+
messages=[
|
|
104
|
+
SamplingMessage(role="user", content=TextContent(type="text", text=prompt))
|
|
105
|
+
],
|
|
106
|
+
max_tokens=self._max_tokens,
|
|
107
|
+
)
|
|
108
|
+
content = result.content
|
|
109
|
+
return content.text if getattr(content, "type", None) == "text" else str(content)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def pick_backend(config: DocConfig, ctx=None) -> CompletionBackend:
|
|
113
|
+
"""Choose a backend: provider key → retry-wrapped ProviderBackend;
|
|
114
|
+
MCP host ctx → SamplingBackend; otherwise raise BackendError.
|
|
115
|
+
"""
|
|
116
|
+
from .retry import with_retry
|
|
117
|
+
|
|
118
|
+
if config.has_provider:
|
|
119
|
+
return with_retry(ProviderBackend(config))
|
|
120
|
+
if ctx is not None:
|
|
121
|
+
return SamplingBackend(ctx)
|
|
122
|
+
raise BackendError(
|
|
123
|
+
"No LLM available. Set a provider key (ANTHROPIC_API_KEY / OPENAI_API_KEY / "
|
|
124
|
+
"AZURE_OPENAI_API_KEY / AWS credentials), pass provider='ollama' for a local model, "
|
|
125
|
+
"or run inside an MCP host that supports sampling."
|
|
126
|
+
)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# core/cache.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
MANIFEST_FILENAME = ".ai-docs-manifest.json"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def hash_file(repo_path: str, file_path: str) -> str:
|
|
12
|
+
"""Return the SHA-256 hex digest of the content of *repo_path/file_path*."""
|
|
13
|
+
full = os.path.join(repo_path, file_path)
|
|
14
|
+
h = hashlib.sha256()
|
|
15
|
+
with open(full, "rb") as fh:
|
|
16
|
+
while chunk := fh.read(65536):
|
|
17
|
+
h.update(chunk)
|
|
18
|
+
return h.hexdigest()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def compute_hashes(files: list[str], repo_path: str) -> dict[str, str]:
|
|
22
|
+
"""Return ``{relative_path: sha256}`` for every path in *files*."""
|
|
23
|
+
return {fp: hash_file(repo_path, fp) for fp in files}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def load_manifest(output_dir: str) -> dict[str, str] | None:
|
|
27
|
+
"""Load the hash manifest from *output_dir*.
|
|
28
|
+
|
|
29
|
+
Returns ``None`` when no manifest exists or the file is corrupt.
|
|
30
|
+
"""
|
|
31
|
+
path = os.path.join(output_dir, MANIFEST_FILENAME)
|
|
32
|
+
try:
|
|
33
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
34
|
+
return json.load(fh)
|
|
35
|
+
except (FileNotFoundError, json.JSONDecodeError):
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def save_manifest(file_hashes: dict[str, str], output_dir: str) -> None:
|
|
40
|
+
"""Persist *file_hashes* as ``{output_dir}/.ai-docs-manifest.json``."""
|
|
41
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
42
|
+
path = os.path.join(output_dir, MANIFEST_FILENAME)
|
|
43
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
44
|
+
json.dump(file_hashes, fh, indent=2, sort_keys=True)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def filter_changed(
|
|
48
|
+
files: list[str],
|
|
49
|
+
repo_path: str,
|
|
50
|
+
manifest: dict[str, str] | None,
|
|
51
|
+
) -> tuple[list[str], list[str]]:
|
|
52
|
+
"""Split *files* into ``(changed, unchanged)`` relative to *manifest*.
|
|
53
|
+
|
|
54
|
+
All files are returned as changed when *manifest* is ``None`` (first run).
|
|
55
|
+
Files that cannot be read are treated as changed.
|
|
56
|
+
"""
|
|
57
|
+
if manifest is None:
|
|
58
|
+
return list(files), []
|
|
59
|
+
changed: list[str] = []
|
|
60
|
+
unchanged: list[str] = []
|
|
61
|
+
for fp in files:
|
|
62
|
+
try:
|
|
63
|
+
current_hash = hash_file(repo_path, fp)
|
|
64
|
+
except OSError:
|
|
65
|
+
changed.append(fp)
|
|
66
|
+
continue
|
|
67
|
+
if manifest.get(fp) == current_hash:
|
|
68
|
+
unchanged.append(fp)
|
|
69
|
+
else:
|
|
70
|
+
changed.append(fp)
|
|
71
|
+
return changed, unchanged
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# core/config.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import dataclass, field, fields
|
|
6
|
+
|
|
7
|
+
DEFAULT_MODELS = {
|
|
8
|
+
"anthropic": "claude-sonnet-4-6",
|
|
9
|
+
"openai": "gpt-4o",
|
|
10
|
+
"azure": None,
|
|
11
|
+
"bedrock": "amazon.nova-pro-v1:0",
|
|
12
|
+
"ollama": "llama3.1",
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
LC_PROVIDER = {
|
|
16
|
+
"anthropic": "anthropic",
|
|
17
|
+
"openai": "openai",
|
|
18
|
+
"azure": "azure_openai",
|
|
19
|
+
"bedrock": "bedrock_converse",
|
|
20
|
+
"ollama": "ollama",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
_PROVIDER_ENV = {
|
|
24
|
+
"anthropic": "ANTHROPIC_API_KEY",
|
|
25
|
+
"openai": "OPENAI_API_KEY",
|
|
26
|
+
"azure": "AZURE_OPENAI_API_KEY",
|
|
27
|
+
"bedrock": "AWS_ACCESS_KEY_ID",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class DocConfig:
|
|
33
|
+
provider: str | None = None
|
|
34
|
+
model: str | None = None
|
|
35
|
+
profile: str = "readme"
|
|
36
|
+
incremental: bool = True
|
|
37
|
+
diagrams: bool = True
|
|
38
|
+
max_file_size_kb: int = 100
|
|
39
|
+
max_concurrency: int = 8
|
|
40
|
+
pipeline_timeout_s: int = 300
|
|
41
|
+
# Per-request provider key (BYOK). repr=False so it can never leak via
|
|
42
|
+
# logging/str(config); it is handed to the provider client explicitly and
|
|
43
|
+
# never written to the environment.
|
|
44
|
+
api_key: str | None = field(default=None, repr=False)
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def has_provider(self) -> bool:
|
|
48
|
+
return self.provider is not None
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def model_id(self) -> str | None:
|
|
52
|
+
if not self.provider:
|
|
53
|
+
return None
|
|
54
|
+
model = self.model or DEFAULT_MODELS.get(self.provider)
|
|
55
|
+
return f"{self.provider}:{model}" if model else self.provider
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def lc_provider(self) -> str | None:
|
|
59
|
+
return LC_PROVIDER.get(self.provider, self.provider) if self.provider else None
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def resolved_model(self) -> str | None:
|
|
63
|
+
if not self.provider:
|
|
64
|
+
return None
|
|
65
|
+
return self.model or DEFAULT_MODELS.get(self.provider)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def detect_provider() -> str | None:
|
|
69
|
+
"""Return the first provider whose credentials are present in the environment."""
|
|
70
|
+
for provider, env_var in _PROVIDER_ENV.items():
|
|
71
|
+
if os.getenv(env_var):
|
|
72
|
+
return provider
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def resolve_config(
|
|
77
|
+
provider: str | None = None,
|
|
78
|
+
model: str | None = None,
|
|
79
|
+
**overrides,
|
|
80
|
+
) -> DocConfig:
|
|
81
|
+
"""Build a DocConfig, auto-detecting the provider from env when not given."""
|
|
82
|
+
cfg = DocConfig(provider=provider or detect_provider(), model=model)
|
|
83
|
+
timeout_env = os.getenv("PIPELINE_TIMEOUT_S")
|
|
84
|
+
if timeout_env and timeout_env.isdigit():
|
|
85
|
+
cfg.pipeline_timeout_s = int(timeout_env)
|
|
86
|
+
valid_fields = {f.name for f in fields(DocConfig)}
|
|
87
|
+
for key, value in overrides.items():
|
|
88
|
+
if key not in valid_fields:
|
|
89
|
+
raise TypeError(f"resolve_config() got an unexpected keyword argument '{key}'")
|
|
90
|
+
if value is not None:
|
|
91
|
+
setattr(cfg, key, value)
|
|
92
|
+
return cfg
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# ai_doc_creator/core/diagrams.py
|
|
2
|
+
"""Deterministic Mermaid diagram generation + validation of LLM-drawn blocks.
|
|
3
|
+
|
|
4
|
+
The dependency and structure diagrams are produced by static analysis, never
|
|
5
|
+
by a model, so they are always syntactically valid. Model-drawn diagrams pass
|
|
6
|
+
through sanitize_mermaid_blocks(), which downgrades anything suspect to a
|
|
7
|
+
plain fenced block rather than letting it break the rendered page.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
_PY_IMPORT = re.compile(
|
|
15
|
+
r"^\s*(?:from\s+([.\w]+)\s+import|import\s+([.\w]+))", re.MULTILINE
|
|
16
|
+
)
|
|
17
|
+
_JS_IMPORT = re.compile(
|
|
18
|
+
r"""(?:import\s+(?:[\w{}*,\s]+\s+from\s+)?|require\(\s*|import\(\s*)['"]([^'"]+)['"]"""
|
|
19
|
+
)
|
|
20
|
+
_JS_EXTENSIONS = (".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs")
|
|
21
|
+
_JS_RESOLVE_SUFFIXES = ("", ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs",
|
|
22
|
+
"/index.js", "/index.ts", "/index.jsx", "/index.tsx")
|
|
23
|
+
|
|
24
|
+
# First token of a fenced block must be one of these for the block to count
|
|
25
|
+
# as plausibly-valid Mermaid.
|
|
26
|
+
_MERMAID_TYPES = (
|
|
27
|
+
"graph", "flowchart", "sequencediagram", "classdiagram", "statediagram",
|
|
28
|
+
"statediagram-v2", "erdiagram", "journey", "pie", "gantt", "mindmap",
|
|
29
|
+
)
|
|
30
|
+
_MERMAID_FENCE = re.compile(r"```mermaid[ \t]*\n(.*?)```", re.DOTALL | re.IGNORECASE)
|
|
31
|
+
|
|
32
|
+
# Per-file read cap for import scanning: imports live at the top of files.
|
|
33
|
+
_SCAN_BYTES = 65536
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _max_nodes() -> int:
|
|
37
|
+
try:
|
|
38
|
+
return max(2, int(os.getenv("MAX_DIAGRAM_NODES", "40")))
|
|
39
|
+
except ValueError:
|
|
40
|
+
return 40
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _label(text: str) -> str:
|
|
44
|
+
"""A node label safe inside Mermaid's ["..."] syntax."""
|
|
45
|
+
return text.replace('"', "'").replace("\n", " ")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _read_head(path: str) -> str:
|
|
49
|
+
try:
|
|
50
|
+
with open(path, "r", encoding="utf-8", errors="ignore") as fh:
|
|
51
|
+
return fh.read(_SCAN_BYTES)
|
|
52
|
+
except OSError:
|
|
53
|
+
return ""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _py_module(file_path: str) -> str:
|
|
57
|
+
mod = file_path[:-3].replace("\\", "/").replace("/", ".")
|
|
58
|
+
return mod[: -len(".__init__")] if mod.endswith(".__init__") else mod
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _resolve_py_import(name: str, importer: str, module_map: dict[str, str]) -> str | None:
|
|
62
|
+
"""Map an import name (possibly relative) to an internal file, or None."""
|
|
63
|
+
if name.startswith("."):
|
|
64
|
+
level = len(name) - len(name.lstrip("."))
|
|
65
|
+
pkg_parts = os.path.dirname(importer).replace("\\", "/").split("/")
|
|
66
|
+
pkg_parts = [p for p in pkg_parts if p]
|
|
67
|
+
if level > 1:
|
|
68
|
+
pkg_parts = pkg_parts[: len(pkg_parts) - (level - 1)]
|
|
69
|
+
remainder = name.lstrip(".")
|
|
70
|
+
name = ".".join(pkg_parts + ([remainder] if remainder else []))
|
|
71
|
+
# Longest-prefix match: "a.b.c" may refer to module a/b.py's attribute c.
|
|
72
|
+
parts = name.split(".")
|
|
73
|
+
for end in range(len(parts), 0, -1):
|
|
74
|
+
candidate = ".".join(parts[:end])
|
|
75
|
+
if candidate in module_map:
|
|
76
|
+
return module_map[candidate]
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _resolve_js_import(spec: str, importer: str, file_set: set[str]) -> str | None:
|
|
81
|
+
if not spec.startswith("."):
|
|
82
|
+
return None # external package
|
|
83
|
+
base = os.path.normpath(os.path.join(os.path.dirname(importer), spec))
|
|
84
|
+
base = base.replace("\\", "/")
|
|
85
|
+
for suffix in _JS_RESOLVE_SUFFIXES:
|
|
86
|
+
candidate = base + suffix
|
|
87
|
+
if candidate in file_set:
|
|
88
|
+
return candidate
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _collect_edges(files: list[str], repo_path: str) -> set[tuple[str, str]]:
|
|
93
|
+
file_set = {f.replace("\\", "/") for f in files}
|
|
94
|
+
module_map = {_py_module(f): f for f in file_set if f.endswith(".py")}
|
|
95
|
+
edges: set[tuple[str, str]] = set()
|
|
96
|
+
for f in sorted(file_set):
|
|
97
|
+
content = _read_head(os.path.join(repo_path, f))
|
|
98
|
+
if not content:
|
|
99
|
+
continue
|
|
100
|
+
if f.endswith(".py"):
|
|
101
|
+
for match in _PY_IMPORT.finditer(content):
|
|
102
|
+
name = match.group(1) or match.group(2)
|
|
103
|
+
target = _resolve_py_import(name, f, module_map)
|
|
104
|
+
if target and target != f:
|
|
105
|
+
edges.add((f, target))
|
|
106
|
+
elif f.endswith(_JS_EXTENSIONS):
|
|
107
|
+
for match in _JS_IMPORT.finditer(content):
|
|
108
|
+
target = _resolve_js_import(match.group(1), f, file_set)
|
|
109
|
+
if target and target != f:
|
|
110
|
+
edges.add((f, target))
|
|
111
|
+
return edges
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def build_dependency_mermaid(files: list[str], repo_path: str) -> str | None:
|
|
115
|
+
"""Internal-module dependency graph as a fenced Mermaid block, or None
|
|
116
|
+
when there is nothing meaningful to draw."""
|
|
117
|
+
edges = _collect_edges(files, repo_path)
|
|
118
|
+
if len(edges) < 2:
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
cap = _max_nodes()
|
|
122
|
+
# Keep the most-connected nodes so the capped diagram stays informative.
|
|
123
|
+
degree: dict[str, int] = {}
|
|
124
|
+
for a, b in edges:
|
|
125
|
+
degree[a] = degree.get(a, 0) + 1
|
|
126
|
+
degree[b] = degree.get(b, 0) + 1
|
|
127
|
+
kept = set(sorted(degree, key=lambda n: (-degree[n], n))[:cap])
|
|
128
|
+
kept_edges = sorted((a, b) for a, b in edges if a in kept and b in kept)
|
|
129
|
+
if len(kept_edges) < 2:
|
|
130
|
+
return None
|
|
131
|
+
dropped = len(degree) - len(kept)
|
|
132
|
+
|
|
133
|
+
ids = {n: f"n{i}" for i, n in enumerate(sorted(kept))}
|
|
134
|
+
lines = ["```mermaid", "graph LR"]
|
|
135
|
+
lines += [f' {ids[n]}["{_label(n)}"]' for n in sorted(kept)]
|
|
136
|
+
lines += [f" {ids[a]} --> {ids[b]}" for a, b in kept_edges]
|
|
137
|
+
lines.append("```")
|
|
138
|
+
if dropped > 0:
|
|
139
|
+
lines.append(f"\n*…plus {dropped} more module(s) not shown (MAX_DIAGRAM_NODES).*")
|
|
140
|
+
return "\n".join(lines)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def build_structure_mermaid(files: list[str]) -> str | None:
|
|
144
|
+
"""Directory-tree chart as a fenced Mermaid block, or None for <2 files."""
|
|
145
|
+
normalized = sorted({f.replace("\\", "/") for f in files})
|
|
146
|
+
if len(normalized) < 2:
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
cap = _max_nodes()
|
|
150
|
+
ids: dict[str, str] = {".": "n0"}
|
|
151
|
+
labels: dict[str, str] = {".": "(root)"}
|
|
152
|
+
edges: list[tuple[str, str]] = []
|
|
153
|
+
truncated = False
|
|
154
|
+
|
|
155
|
+
def _ensure(node: str, label: str) -> bool:
|
|
156
|
+
nonlocal truncated
|
|
157
|
+
if node in ids:
|
|
158
|
+
return True
|
|
159
|
+
if len(ids) >= cap:
|
|
160
|
+
truncated = True
|
|
161
|
+
return False
|
|
162
|
+
ids[node] = f"n{len(ids)}"
|
|
163
|
+
labels[node] = label
|
|
164
|
+
return True
|
|
165
|
+
|
|
166
|
+
for f in normalized:
|
|
167
|
+
parts = f.split("/")
|
|
168
|
+
parent = "."
|
|
169
|
+
for depth, part in enumerate(parts):
|
|
170
|
+
node = "/".join(parts[: depth + 1])
|
|
171
|
+
if not _ensure(node, part):
|
|
172
|
+
break
|
|
173
|
+
if (parent, node) not in edges:
|
|
174
|
+
edges.append((parent, node))
|
|
175
|
+
parent = node
|
|
176
|
+
|
|
177
|
+
lines = ["```mermaid", "graph TD"]
|
|
178
|
+
lines += [f' {ids[n]}["{_label(labels[n])}"]' for n in ids]
|
|
179
|
+
lines += [f" {ids[a]} --> {ids[b]}" for a, b in edges if a in ids and b in ids]
|
|
180
|
+
lines.append("```")
|
|
181
|
+
if truncated:
|
|
182
|
+
lines.append("\n*…tree truncated (MAX_DIAGRAM_NODES).*")
|
|
183
|
+
return "\n".join(lines)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _is_plausible_mermaid(body: str) -> bool:
|
|
187
|
+
stripped = body.strip()
|
|
188
|
+
if not stripped:
|
|
189
|
+
return False
|
|
190
|
+
first_token = stripped.split()[0].lower()
|
|
191
|
+
if first_token not in _MERMAID_TYPES:
|
|
192
|
+
return False
|
|
193
|
+
for open_ch, close_ch in ("()", "[]", "{}"):
|
|
194
|
+
if stripped.count(open_ch) != stripped.count(close_ch):
|
|
195
|
+
return False
|
|
196
|
+
return True
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def sanitize_mermaid_blocks(markdown: str) -> str:
|
|
200
|
+
"""Downgrade implausible ```mermaid fences to plain ```text fences.
|
|
201
|
+
|
|
202
|
+
Model-drawn diagrams are useful but not trusted: an invalid block would
|
|
203
|
+
render as a raw error box on GitHub. Content is preserved either way.
|
|
204
|
+
"""
|
|
205
|
+
|
|
206
|
+
def _check(match: re.Match) -> str:
|
|
207
|
+
body = match.group(1)
|
|
208
|
+
if _is_plausible_mermaid(body):
|
|
209
|
+
return match.group(0)
|
|
210
|
+
return f"```text\n{body}```"
|
|
211
|
+
|
|
212
|
+
return _MERMAID_FENCE.sub(_check, markdown)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import logging
|
|
3
|
+
from typing import Dict
|
|
4
|
+
|
|
5
|
+
logger = logging.getLogger(__name__)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DocumentationWriter:
|
|
9
|
+
"""Writes generated documentation to files."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, output_dir: str):
|
|
12
|
+
self.output_dir = output_dir
|
|
13
|
+
|
|
14
|
+
def write_docs(self, documents: Dict[str, str], index_content: str):
|
|
15
|
+
"""Writes the documentation files and the index file."""
|
|
16
|
+
try:
|
|
17
|
+
# Create output directory if it doesn't exist
|
|
18
|
+
os.makedirs(self.output_dir, exist_ok=True)
|
|
19
|
+
|
|
20
|
+
# Write individual file docs
|
|
21
|
+
for file_path, content in documents.items():
|
|
22
|
+
# We want to mirror the structure inside the output dir
|
|
23
|
+
# e.g., src/main.py -> docs/src/main.py.md
|
|
24
|
+
|
|
25
|
+
relative_path = file_path + ".md"
|
|
26
|
+
full_path = os.path.join(self.output_dir, relative_path)
|
|
27
|
+
|
|
28
|
+
# Ensure the directory for this file exists
|
|
29
|
+
os.makedirs(os.path.dirname(full_path), exist_ok=True)
|
|
30
|
+
|
|
31
|
+
with open(full_path, "w", encoding="utf-8") as f:
|
|
32
|
+
f.write(content)
|
|
33
|
+
logger.info(f"Written documentation for {file_path} to {full_path}")
|
|
34
|
+
|
|
35
|
+
# Write index file
|
|
36
|
+
index_path = os.path.join(self.output_dir, "README.md")
|
|
37
|
+
with open(index_path, "w", encoding="utf-8") as f:
|
|
38
|
+
f.write(index_content)
|
|
39
|
+
logger.info(f"Written index to {index_path}")
|
|
40
|
+
|
|
41
|
+
except Exception as e:
|
|
42
|
+
logger.error(f"Failed to write documentation: {e}")
|
|
43
|
+
raise
|