readmenator 1.0.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.
Potentially problematic release.
This version of readmenator might be problematic. Click here for more details.
- readmenator/__init__.py +20 -0
- readmenator/__main__.py +125 -0
- readmenator/_app.py +108 -0
- readmenator/_config.py +78 -0
- readmenator/_documentation.py +124 -0
- readmenator/_mermaid.py +124 -0
- readmenator/_models.py +75 -0
- readmenator/_parsers.py +688 -0
- readmenator/_query.py +283 -0
- readmenator/_scanner.py +121 -0
- readmenator-1.0.0.dist-info/METADATA +139 -0
- readmenator-1.0.0.dist-info/RECORD +15 -0
- readmenator-1.0.0.dist-info/WHEEL +4 -0
- readmenator-1.0.0.dist-info/entry_points.txt +2 -0
- readmenator-1.0.0.dist-info/licenses/LICENSE +661 -0
readmenator/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Public API for the readmenator knowledge graph generator.
|
|
2
|
+
|
|
3
|
+
Export the core types and application class used by external consumers:
|
|
4
|
+
- Config: immutable settings dataclass
|
|
5
|
+
- Symbol, Node, Edge: data model for codebase entities and relations
|
|
6
|
+
- readmenatorApplication: high-level orchestrator for scanning, querying,
|
|
7
|
+
and generating the KNOWLEDGE_BASE.md artifact
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from readmenator._app import readmenatorApplication
|
|
11
|
+
from readmenator._config import Config
|
|
12
|
+
from readmenator._models import Edge, Node, Symbol
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"Config",
|
|
16
|
+
"Symbol",
|
|
17
|
+
"Node",
|
|
18
|
+
"Edge",
|
|
19
|
+
"readmenatorApplication",
|
|
20
|
+
]
|
readmenator/__main__.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""CLI entry point for readmenator.
|
|
2
|
+
|
|
3
|
+
Parses command-line arguments, dispatches to the appropriate subcommand
|
|
4
|
+
(query, explain, path, summary, --rebuild, --test), and manages the
|
|
5
|
+
target directory analysis lifecycle.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
import unittest
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from readmenator._app import readmenatorApplication
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
20
|
+
"""Construct the argument parser with subcommand help and examples."""
|
|
21
|
+
parser = argparse.ArgumentParser(
|
|
22
|
+
description="ReadMenator: Zero-token polyglot codebase knowledge graph generator.",
|
|
23
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
24
|
+
epilog=(
|
|
25
|
+
"Subcommands:\n"
|
|
26
|
+
" query \"<question>\" Answer a question using the knowledge base\n"
|
|
27
|
+
" explain \"<symbol>\" Explain a symbol with relationships\n"
|
|
28
|
+
" path \"<A>\" \"<B>\" Trace dependency chain between two symbols\n"
|
|
29
|
+
"\n"
|
|
30
|
+
"Examples:\n"
|
|
31
|
+
" python -m readmenator /path/to/project\n"
|
|
32
|
+
" python -m readmenator /path/to/project explain ClassName\n"
|
|
33
|
+
" python -m readmenator . query \"What classes handle HTTP?\"\n"
|
|
34
|
+
),
|
|
35
|
+
)
|
|
36
|
+
parser.add_argument(
|
|
37
|
+
"target",
|
|
38
|
+
nargs="?",
|
|
39
|
+
default=".",
|
|
40
|
+
help="Target directory to analyze (default: current directory)",
|
|
41
|
+
)
|
|
42
|
+
parser.add_argument(
|
|
43
|
+
"--rebuild",
|
|
44
|
+
action="store_true",
|
|
45
|
+
help="Force regeneration of KNOWLEDGE_BASE.md",
|
|
46
|
+
)
|
|
47
|
+
parser.add_argument(
|
|
48
|
+
"--test",
|
|
49
|
+
action="store_true",
|
|
50
|
+
help="Run the built-in test suite",
|
|
51
|
+
)
|
|
52
|
+
return parser
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _run_tests() -> None:
|
|
56
|
+
"""Discover and run the full test suite from the tests/ directory."""
|
|
57
|
+
package_dir = Path(__file__).resolve().parent.parent
|
|
58
|
+
tests_dir = package_dir / "tests"
|
|
59
|
+
if tests_dir.is_dir():
|
|
60
|
+
sys.path.insert(0, str(package_dir))
|
|
61
|
+
loader = unittest.TestLoader()
|
|
62
|
+
suite = loader.discover(str(tests_dir), pattern="test_*.py")
|
|
63
|
+
runner = unittest.TextTestRunner(verbosity=2)
|
|
64
|
+
result = runner.run(suite)
|
|
65
|
+
sys.exit(0 if result.wasSuccessful() else 1)
|
|
66
|
+
else:
|
|
67
|
+
print("No tests directory found.", file=sys.stderr)
|
|
68
|
+
sys.exit(1)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def main() -> None:
|
|
72
|
+
"""Primary CLI entry point invoked by ``python -m readmenator``.
|
|
73
|
+
|
|
74
|
+
Supports direct subcommand dispatch (query, explain, path, summary,
|
|
75
|
+
--rebuild, update) or falls back to the argument parser for the
|
|
76
|
+
default workflow: generate or summarise KNOWLEDGE_BASE.md.
|
|
77
|
+
"""
|
|
78
|
+
if "--test" in sys.argv:
|
|
79
|
+
_run_tests()
|
|
80
|
+
return
|
|
81
|
+
|
|
82
|
+
if len(sys.argv) > 2 and sys.argv[1] != "--rebuild" and not sys.argv[1].startswith("-"):
|
|
83
|
+
target = sys.argv[1]
|
|
84
|
+
command = sys.argv[2]
|
|
85
|
+
app = readmenatorApplication()
|
|
86
|
+
|
|
87
|
+
if command == "query" and len(sys.argv) >= 4:
|
|
88
|
+
result = app.query(target, sys.argv[3])
|
|
89
|
+
print(result)
|
|
90
|
+
elif command == "explain" and len(sys.argv) >= 4:
|
|
91
|
+
result = app.explain(target, sys.argv[3])
|
|
92
|
+
print(result)
|
|
93
|
+
elif command == "path" and len(sys.argv) >= 5:
|
|
94
|
+
result = app.find_path(target, sys.argv[3], sys.argv[4])
|
|
95
|
+
print(result)
|
|
96
|
+
elif command in ("summary", "sum", "info"):
|
|
97
|
+
result = app.summary(target)
|
|
98
|
+
print(result)
|
|
99
|
+
elif command == "--rebuild":
|
|
100
|
+
app.rebuild(target)
|
|
101
|
+
elif command == "update":
|
|
102
|
+
app.rebuild(target)
|
|
103
|
+
else:
|
|
104
|
+
print(f"Unknown command: {command}", file=sys.stderr)
|
|
105
|
+
sys.exit(1)
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
parser = build_parser()
|
|
109
|
+
args = parser.parse_args()
|
|
110
|
+
target = args.target
|
|
111
|
+
|
|
112
|
+
app = readmenatorApplication()
|
|
113
|
+
output_path = Path(target) / "KNOWLEDGE_BASE.md"
|
|
114
|
+
|
|
115
|
+
if args.rebuild or not output_path.exists():
|
|
116
|
+
app.run(target)
|
|
117
|
+
else:
|
|
118
|
+
result = app.summary(target)
|
|
119
|
+
print(result)
|
|
120
|
+
|
|
121
|
+
print("\nRun with --rebuild to regenerate or use query/explain/path subcommands.")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
if __name__ == "__main__":
|
|
125
|
+
main()
|
readmenator/_app.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Application orchestrator for the readmenator pipeline.
|
|
2
|
+
|
|
3
|
+
Wires together scanner, documentation generator, and query engine
|
|
4
|
+
into a single facade consumed by the CLI entry point (__main__) and
|
|
5
|
+
the public API (__init__).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import List, Optional, Tuple
|
|
12
|
+
|
|
13
|
+
from readmenator._config import Config
|
|
14
|
+
from readmenator._documentation import DocumentationGenerator
|
|
15
|
+
from readmenator._models import Edge, Node
|
|
16
|
+
from readmenator._query import QueryEngine
|
|
17
|
+
from readmenator._scanner import PolyglotScanner
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class readmenatorApplication:
|
|
21
|
+
"""High-level facade for readmenator operations.
|
|
22
|
+
|
|
23
|
+
Provides convenience methods for the full pipeline:
|
|
24
|
+
- ``run`` / ``rebuild``: scan + generate KNOWLEDGE_BASE.md
|
|
25
|
+
- ``query``, ``explain``, ``find_path``, ``summary``:
|
|
26
|
+
scan + query in a single call.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, config: Optional[Config] = None) -> None:
|
|
30
|
+
"""Initialise the application with an optional custom config.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
config: Application settings; defaults to Config() if omitted.
|
|
34
|
+
"""
|
|
35
|
+
self._config = config or Config()
|
|
36
|
+
self._scanner = PolyglotScanner(self._config)
|
|
37
|
+
self._generator = DocumentationGenerator(self._config)
|
|
38
|
+
self._last_nodes: List[Node] = []
|
|
39
|
+
self._last_edges: List[Edge] = []
|
|
40
|
+
|
|
41
|
+
def _scan(self, target_dir: str) -> Tuple[List[Node], List[Edge]]:
|
|
42
|
+
"""Resolve *target_dir* and run the scanner, caching results."""
|
|
43
|
+
root = Path(target_dir).resolve()
|
|
44
|
+
nodes, edges = self._scanner.scan(root)
|
|
45
|
+
self._last_nodes = nodes
|
|
46
|
+
self._last_edges = edges
|
|
47
|
+
return nodes, edges
|
|
48
|
+
|
|
49
|
+
def run(self, target_dir: str) -> None:
|
|
50
|
+
"""Scan *target_dir* and write KNOWLEDGE_BASE.md to disk.
|
|
51
|
+
|
|
52
|
+
Prints a summary of files, symbols, and imports on completion.
|
|
53
|
+
"""
|
|
54
|
+
root = Path(target_dir).resolve()
|
|
55
|
+
nodes, edges = self._scanner.scan(root)
|
|
56
|
+
content = self._generator.generate(nodes, edges)
|
|
57
|
+
output_path = root / self._config.OUTPUT_FILENAME
|
|
58
|
+
output_path.write_text(content, encoding="utf-8")
|
|
59
|
+
total_symbols = sum(len(n.symbols) for n in nodes)
|
|
60
|
+
print(f"[+] Knowledge base generated: {output_path}")
|
|
61
|
+
print(
|
|
62
|
+
f"[+] Files: {len(nodes)} | "
|
|
63
|
+
f"Symbols: {total_symbols} | "
|
|
64
|
+
f"Imports: {len(edges)}"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def query(self, target_dir: str, question: str) -> str:
|
|
68
|
+
"""Scan *target_dir* and answer *question* using the query engine."""
|
|
69
|
+
nodes, edges = self._scan(target_dir)
|
|
70
|
+
engine = QueryEngine(nodes, edges)
|
|
71
|
+
return engine.query(question)
|
|
72
|
+
|
|
73
|
+
def explain(self, target_dir: str, symbol_name: str) -> str:
|
|
74
|
+
"""Scan *target_dir* and return a detailed explanation of *symbol_name*."""
|
|
75
|
+
nodes, edges = self._scan(target_dir)
|
|
76
|
+
engine = QueryEngine(nodes, edges)
|
|
77
|
+
result = engine.explain(symbol_name)
|
|
78
|
+
if result is None:
|
|
79
|
+
return (
|
|
80
|
+
f"Symbol '{symbol_name}' not found in the knowledge base. "
|
|
81
|
+
f"Scanned {len(nodes)} files with "
|
|
82
|
+
f"{sum(len(n.symbols) for n in nodes)} total symbols."
|
|
83
|
+
)
|
|
84
|
+
return result
|
|
85
|
+
|
|
86
|
+
def find_path(self, target_dir: str, symbol_a: str, symbol_b: str) -> str:
|
|
87
|
+
"""Scan *target_dir* and find the shortest import path between two symbols."""
|
|
88
|
+
nodes, edges = self._scan(target_dir)
|
|
89
|
+
engine = QueryEngine(nodes, edges)
|
|
90
|
+
result = engine.find_path(symbol_a, symbol_b)
|
|
91
|
+
if result is None:
|
|
92
|
+
return (
|
|
93
|
+
f"Could not find a dependency path between '{symbol_a}' "
|
|
94
|
+
f"and '{symbol_b}'. They may be in disconnected components "
|
|
95
|
+
f"or one of the symbols does not exist."
|
|
96
|
+
)
|
|
97
|
+
path_str = " --imports--> ".join(result)
|
|
98
|
+
return f"Dependency path: {path_str}"
|
|
99
|
+
|
|
100
|
+
def summary(self, target_dir: str) -> str:
|
|
101
|
+
"""Scan *target_dir* and return a concise knowledge base overview."""
|
|
102
|
+
nodes, edges = self._scan(target_dir)
|
|
103
|
+
engine = QueryEngine(nodes, edges)
|
|
104
|
+
return engine.summary()
|
|
105
|
+
|
|
106
|
+
def rebuild(self, target_dir: str) -> None:
|
|
107
|
+
"""Alias for ``run`` -- forces regeneration of KNOWLEDGE_BASE.md."""
|
|
108
|
+
self.run(target_dir)
|
readmenator/_config.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Immutable configuration dataclass for readmenator.
|
|
2
|
+
|
|
3
|
+
All tuneable parameters live here as frozen dataclass fields. No magic
|
|
4
|
+
numbers or hardcoded paths exist elsewhere in the codebase. Derived
|
|
5
|
+
consumers import Config and read values from an instance.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Tuple
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class Config:
|
|
16
|
+
"""Single source of truth for all readmenator settings.
|
|
17
|
+
|
|
18
|
+
Every tuneable constant -- file-size limits, directory depth,
|
|
19
|
+
supported extensions, symbol pluralisation map, and Mermaid style
|
|
20
|
+
tokens -- is defined here and consumed by reference elsewhere.
|
|
21
|
+
"""
|
|
22
|
+
IGNORE_DIRS: Tuple[str, ...] = (
|
|
23
|
+
".git", "__pycache__", "venv", ".venv", "env", ".env", "node_modules",
|
|
24
|
+
".tox", ".eggs", ".pytest_cache", "build", "dist", ".idea", ".vscode",
|
|
25
|
+
"target", "bin", "obj", "out", "vendor", "third_party", "deps",
|
|
26
|
+
"third-party", "thirdparty", ".m2", ".gradle", ".nuget", "packages",
|
|
27
|
+
"Pods", ".dart_tool", ".pub-cache", "bower_components", ".yarn",
|
|
28
|
+
"Carthage", "node_packages", ".meteor", ".gitlab", ".github",
|
|
29
|
+
"htmlcov", ".coverage", "__pycache__",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
OUTPUT_FILENAME: str = "KNOWLEDGE_BASE.md"
|
|
33
|
+
|
|
34
|
+
MAX_FILE_SIZE_MB: float = 10.0
|
|
35
|
+
|
|
36
|
+
MAX_DIRECTORY_DEPTH: int = 20
|
|
37
|
+
|
|
38
|
+
DOCSTRING_LOOKBACK_LINES: int = 15
|
|
39
|
+
|
|
40
|
+
DOCSTRING_MAX_LENGTH: int = 150
|
|
41
|
+
|
|
42
|
+
MERMAID_MAX_NODES: int = 300
|
|
43
|
+
|
|
44
|
+
MERMAID_MODULE_STYLE: str = "fill:#1e1e1e,stroke:#ff6666,stroke-width:2px,color:#fff"
|
|
45
|
+
|
|
46
|
+
MERMAID_CLASS_STYLE: str = "fill:#2d2d2d,stroke:#4ec9b0,stroke-width:2px,color:#fff"
|
|
47
|
+
|
|
48
|
+
MERMAID_FUNCTION_STYLE: str = "fill:#333,stroke:#dcdcaa,stroke-width:1px,color:#dcdcaa"
|
|
49
|
+
|
|
50
|
+
MERMAID_EXTERNAL_STYLE: str = "fill:#111,stroke:#666,stroke-dasharray:5 5,color:#aaa"
|
|
51
|
+
|
|
52
|
+
SUPPORTED_EXTENSIONS: Tuple[str, ...] = (
|
|
53
|
+
".c", ".cpp", ".cc", ".cxx", ".h", ".hpp", ".hxx",
|
|
54
|
+
".py", ".go", ".rs",
|
|
55
|
+
".js", ".ts", ".jsx", ".tsx",
|
|
56
|
+
".java", ".cs",
|
|
57
|
+
".sh", ".bash", ".zsh",
|
|
58
|
+
".php", ".dart", ".gd", ".nim",
|
|
59
|
+
".asm", ".s", ".S",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
SYMBOL_TYPE_PLURALS: Tuple[Tuple[str, str], ...] = (
|
|
63
|
+
("class", "classes"),
|
|
64
|
+
("struct", "structs"),
|
|
65
|
+
("function", "functions"),
|
|
66
|
+
("method", "methods"),
|
|
67
|
+
("macro", "macros"),
|
|
68
|
+
("trait", "traits"),
|
|
69
|
+
("enum", "enums"),
|
|
70
|
+
("interface", "interfaces"),
|
|
71
|
+
("record", "records"),
|
|
72
|
+
("variable", "variables"),
|
|
73
|
+
("constant", "constants"),
|
|
74
|
+
("type_alias", "type_aliases"),
|
|
75
|
+
("module", "modules"),
|
|
76
|
+
("protocol", "protocols"),
|
|
77
|
+
("extension", "extensions"),
|
|
78
|
+
)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""KNOWLEDGE_BASE.md generator for the readmenator project.
|
|
2
|
+
|
|
3
|
+
Produces the human-readable Markdown artifact that contains the
|
|
4
|
+
structural knowledge map (Mermaid graph), architecture reference
|
|
5
|
+
grouped by language, and per-file symbol listings with docstrings
|
|
6
|
+
and signatures.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Dict, List
|
|
12
|
+
|
|
13
|
+
from readmenator._config import Config
|
|
14
|
+
from readmenator._mermaid import MermaidRenderer
|
|
15
|
+
from readmenator._models import Edge, Node, Symbol, pluralize_symbol_kind
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class DocumentationGenerator:
|
|
19
|
+
"""Builds the KNOWLEDGE_BASE.md document from scanned nodes and edges.
|
|
20
|
+
|
|
21
|
+
Delegates graph rendering to MermaidRenderer and handles the
|
|
22
|
+
Markdown layout: header metadata, Mermaid block, and per-language
|
|
23
|
+
architecture sections with pluralised symbol kind headings.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, config: Config) -> None:
|
|
27
|
+
"""Initialise with config and pre-compute the plural map.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
config: Application settings including SYMBOL_TYPE_PLURALS.
|
|
31
|
+
"""
|
|
32
|
+
self._config = config
|
|
33
|
+
self._mermaid = MermaidRenderer(config)
|
|
34
|
+
self._plural_map: Dict[str, str] = dict(config.SYMBOL_TYPE_PLURALS)
|
|
35
|
+
|
|
36
|
+
def generate(self, nodes: List[Node], edges: List[Edge]) -> str:
|
|
37
|
+
"""Assemble the full KNOWLEDGE_BASE.md Markdown document.
|
|
38
|
+
|
|
39
|
+
Groups files by language, lists symbols per file under
|
|
40
|
+
pluralised kind headings (e.g. "Classes", "Functions"),
|
|
41
|
+
and includes a note when the Mermaid graph was pruned.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
Complete Markdown string ready to write to disk.
|
|
45
|
+
"""
|
|
46
|
+
total_symbols = sum(len(n.symbols) for n in nodes)
|
|
47
|
+
graph_output, is_truncated = self._mermaid.render(nodes, edges)
|
|
48
|
+
|
|
49
|
+
sections: List[str] = [
|
|
50
|
+
"# Polyglot Codebase Knowledge Graph",
|
|
51
|
+
"",
|
|
52
|
+
"> Generated offline by **readmenator**. "
|
|
53
|
+
"Supports C, C++, Python, Go, Rust, JS/TS, Java, C#, Shell, PHP, "
|
|
54
|
+
"Dart, GDScript, Nim, ASM.",
|
|
55
|
+
"> No LLMs. No tokens. Pure static analysis. "
|
|
56
|
+
"See more [here](https://github.com/grisuno/ReadMenator)",
|
|
57
|
+
"",
|
|
58
|
+
f"**Total Files Parsed:** {len(nodes)} | "
|
|
59
|
+
f"**Total Symbols Extracted:** {total_symbols} | "
|
|
60
|
+
f"**Total Imports:** {len(edges)}",
|
|
61
|
+
"",
|
|
62
|
+
"## Structural Knowledge Map",
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
if is_truncated:
|
|
66
|
+
sections.append(
|
|
67
|
+
"> **Note:** The visual graph below has been intelligently pruned "
|
|
68
|
+
f"to the top {self._config.MERMAID_MAX_NODES} most relevant nodes "
|
|
69
|
+
"to prevent rendering crashes. Full details of all "
|
|
70
|
+
f"{len(nodes)} files are documented below."
|
|
71
|
+
)
|
|
72
|
+
sections.append("")
|
|
73
|
+
|
|
74
|
+
sections.extend(
|
|
75
|
+
[
|
|
76
|
+
"```mermaid",
|
|
77
|
+
graph_output,
|
|
78
|
+
"```",
|
|
79
|
+
"",
|
|
80
|
+
"---",
|
|
81
|
+
"",
|
|
82
|
+
"## Architecture Reference",
|
|
83
|
+
"",
|
|
84
|
+
]
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
files_by_lang: Dict[str, List[Node]] = {}
|
|
88
|
+
for node in nodes:
|
|
89
|
+
lang = node.language.upper() if node.language else "UNKNOWN"
|
|
90
|
+
if lang not in files_by_lang:
|
|
91
|
+
files_by_lang[lang] = []
|
|
92
|
+
files_by_lang[lang].append(node)
|
|
93
|
+
|
|
94
|
+
for lang, lang_nodes in sorted(files_by_lang.items()):
|
|
95
|
+
sections.append(f"### {lang} ({len(lang_nodes)} files)")
|
|
96
|
+
sections.append("")
|
|
97
|
+
|
|
98
|
+
for node in lang_nodes:
|
|
99
|
+
sections.append(f"#### `{node.label}`")
|
|
100
|
+
sections.append(f"**Path:** `{node.node_id}`")
|
|
101
|
+
sections.append("")
|
|
102
|
+
|
|
103
|
+
if node.symbols:
|
|
104
|
+
symbols_by_type: Dict[str, List[Symbol]] = {}
|
|
105
|
+
for symbol in node.symbols:
|
|
106
|
+
if symbol.kind not in symbols_by_type:
|
|
107
|
+
symbols_by_type[symbol.kind] = []
|
|
108
|
+
symbols_by_type[symbol.kind].append(symbol)
|
|
109
|
+
|
|
110
|
+
for sym_kind, symbols in sorted(symbols_by_type.items()):
|
|
111
|
+
plural = pluralize_symbol_kind(sym_kind, self._plural_map)
|
|
112
|
+
sections.append(f"**{plural.title()}:**")
|
|
113
|
+
for symbol in symbols:
|
|
114
|
+
doc_str = f" - *{symbol.doc}*" if symbol.doc else ""
|
|
115
|
+
sig_str = f" `{symbol.signature}`" if symbol.signature else ""
|
|
116
|
+
sections.append(
|
|
117
|
+
f"- `{symbol.name}` (line {symbol.line}){sig_str}{doc_str}"
|
|
118
|
+
)
|
|
119
|
+
sections.append("")
|
|
120
|
+
else:
|
|
121
|
+
sections.append("*No symbols extracted*")
|
|
122
|
+
sections.append("")
|
|
123
|
+
|
|
124
|
+
return "\n".join(sections)
|
readmenator/_mermaid.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Mermaid graph renderer with intelligent pruning.
|
|
2
|
+
|
|
3
|
+
Converts the internal Node/Edge graph into a Mermaid flowchart
|
|
4
|
+
(string) suitable for embedding in Markdown. Handles node limits,
|
|
5
|
+
deduplication, and CSS-like class styling.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from typing import Dict, List, Set, Tuple
|
|
12
|
+
|
|
13
|
+
from readmenator._config import Config
|
|
14
|
+
from readmenator._models import Edge, Node
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class MermaidRenderer:
|
|
18
|
+
"""Renders a knowledge graph to Mermaid JS flowchart syntax.
|
|
19
|
+
|
|
20
|
+
Nodes are ordered by import count and symbol richness; the top
|
|
21
|
+
``MERMAID_MAX_NODES`` entries are included. External dependencies
|
|
22
|
+
(import targets not matching any scanned file) appear as dashed
|
|
23
|
+
boxes.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, config: Config) -> None:
|
|
27
|
+
"""Initialise with configuration for style tokens and node limits.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
config: Provides MERMAID_* style strings and MERMAID_MAX_NODES.
|
|
31
|
+
"""
|
|
32
|
+
self._config = config
|
|
33
|
+
|
|
34
|
+
def _sanitize_id(self, node_id: str) -> str:
|
|
35
|
+
"""Convert *node_id* to a Mermaid-safe identifier.
|
|
36
|
+
|
|
37
|
+
Replaces non-alphanumeric characters with underscores and
|
|
38
|
+
prepends ``n_`` if the result starts with a digit.
|
|
39
|
+
"""
|
|
40
|
+
sanitized = re.sub(r"[^a-zA-Z0-9]", "_", node_id)
|
|
41
|
+
if sanitized and sanitized[0].isdigit():
|
|
42
|
+
sanitized = "n_" + sanitized
|
|
43
|
+
return sanitized
|
|
44
|
+
|
|
45
|
+
def render(self, nodes: List[Node], edges: List[Edge]) -> Tuple[str, bool]:
|
|
46
|
+
"""Produce a Mermaid flowchart string and a truncation flag.
|
|
47
|
+
|
|
48
|
+
Nodes are sorted by import popularity, then by symbol count.
|
|
49
|
+
At most ``MERMAID_MAX_NODES`` items (files + child symbols +
|
|
50
|
+
external deps) are emitted. External imports use dashed edges.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
Tuple of (Mermaid source string, is_truncated bool).
|
|
54
|
+
"""
|
|
55
|
+
lines: List[str] = ["graph TD"]
|
|
56
|
+
lines.append(f" classDef mod {self._config.MERMAID_MODULE_STYLE};")
|
|
57
|
+
lines.append(f" classDef cls {self._config.MERMAID_CLASS_STYLE};")
|
|
58
|
+
lines.append(f" classDef fn {self._config.MERMAID_FUNCTION_STYLE};")
|
|
59
|
+
lines.append(f" classDef ext {self._config.MERMAID_EXTERNAL_STYLE};")
|
|
60
|
+
|
|
61
|
+
seen_ids: Set[str] = set()
|
|
62
|
+
node_count = 0
|
|
63
|
+
is_truncated = False
|
|
64
|
+
max_nodes = self._config.MERMAID_MAX_NODES
|
|
65
|
+
|
|
66
|
+
import_counts: Dict[str, int] = {node.node_id: 0 for node in nodes}
|
|
67
|
+
for edge in edges:
|
|
68
|
+
if edge.source in import_counts:
|
|
69
|
+
import_counts[edge.source] += 1
|
|
70
|
+
|
|
71
|
+
sorted_nodes = sorted(
|
|
72
|
+
nodes,
|
|
73
|
+
key=lambda n: (import_counts.get(n.node_id, 0), len(n.symbols)),
|
|
74
|
+
reverse=True,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
for node in sorted_nodes:
|
|
78
|
+
safe_id = self._sanitize_id(node.node_id)
|
|
79
|
+
if safe_id in seen_ids:
|
|
80
|
+
continue
|
|
81
|
+
label = node.label.replace('"', '\\"')
|
|
82
|
+
lines.append(f' {safe_id}["{label} ({node.language})"]')
|
|
83
|
+
lines.append(f" class {safe_id} mod;")
|
|
84
|
+
seen_ids.add(safe_id)
|
|
85
|
+
node_count += 1
|
|
86
|
+
|
|
87
|
+
for symbol in node.symbols[:5]:
|
|
88
|
+
if node_count >= max_nodes:
|
|
89
|
+
is_truncated = True
|
|
90
|
+
break
|
|
91
|
+
symbol_id = f"{safe_id}_{self._sanitize_id(symbol.name)}"
|
|
92
|
+
symbol_label = symbol.name.replace('"', '\\"')
|
|
93
|
+
lines.append(f' {symbol_id}["{symbol_label}"]')
|
|
94
|
+
cls_types = {"class", "struct", "interface", "trait", "enum", "record"}
|
|
95
|
+
if symbol.kind in cls_types:
|
|
96
|
+
lines.append(f" class {symbol_id} cls;")
|
|
97
|
+
else:
|
|
98
|
+
lines.append(f" class {symbol_id} fn;")
|
|
99
|
+
lines.append(f" {safe_id} --> {symbol_id}")
|
|
100
|
+
node_count += 1
|
|
101
|
+
|
|
102
|
+
if node_count >= max_nodes:
|
|
103
|
+
is_truncated = True
|
|
104
|
+
break
|
|
105
|
+
|
|
106
|
+
for edge in edges:
|
|
107
|
+
if is_truncated:
|
|
108
|
+
break
|
|
109
|
+
src = self._sanitize_id(edge.source)
|
|
110
|
+
if src not in seen_ids:
|
|
111
|
+
continue
|
|
112
|
+
target_id = self._sanitize_id(f"ext_{edge.target}")
|
|
113
|
+
target_label = edge.target.split("/")[-1].replace('"', '\\"')
|
|
114
|
+
if target_id not in seen_ids:
|
|
115
|
+
if node_count >= max_nodes:
|
|
116
|
+
is_truncated = True
|
|
117
|
+
break
|
|
118
|
+
lines.append(f' {target_id}["{target_label}"]')
|
|
119
|
+
lines.append(f" class {target_id} ext;")
|
|
120
|
+
seen_ids.add(target_id)
|
|
121
|
+
node_count += 1
|
|
122
|
+
lines.append(f" {src} -.->|imports| {target_id}")
|
|
123
|
+
|
|
124
|
+
return "\n".join(lines), is_truncated
|
readmenator/_models.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Data model types for the readmenator knowledge graph.
|
|
2
|
+
|
|
3
|
+
Defines the three core entity types -- Symbol, Node, Edge -- plus a
|
|
4
|
+
utility function for pluralising symbol kind labels. Every parser,
|
|
5
|
+
scanner, renderer, and query engine depends on these definitions.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Dict, List
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Symbol:
|
|
16
|
+
"""A single code symbol extracted from a source file.
|
|
17
|
+
|
|
18
|
+
Attributes:
|
|
19
|
+
name: Identifier of the symbol (class name, function name, etc.).
|
|
20
|
+
kind: Semantic type (class, function, struct, enum, ...).
|
|
21
|
+
line: One-based line number where the symbol is defined.
|
|
22
|
+
doc: Optional docstring or comment extracted from the source.
|
|
23
|
+
signature: Optional method or function signature snippet.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
name: str
|
|
27
|
+
kind: str
|
|
28
|
+
line: int
|
|
29
|
+
doc: str = ""
|
|
30
|
+
signature: str = ""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class Node:
|
|
35
|
+
"""A file node in the knowledge graph, containing its symbols.
|
|
36
|
+
|
|
37
|
+
Attributes:
|
|
38
|
+
node_id: Relative path of the file used as a unique identifier.
|
|
39
|
+
label: Base file name for display purposes.
|
|
40
|
+
kind: Type of node (typically "module").
|
|
41
|
+
language: Programming language derived from the file extension.
|
|
42
|
+
doc: Optional file-level documentation string.
|
|
43
|
+
symbols: List of Symbol instances defined in this file.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
node_id: str
|
|
47
|
+
label: str
|
|
48
|
+
kind: str
|
|
49
|
+
language: str
|
|
50
|
+
doc: str = ""
|
|
51
|
+
symbols: List[Symbol] = field(default_factory=list)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class Edge:
|
|
56
|
+
"""A directed relationship between two nodes in the knowledge graph.
|
|
57
|
+
|
|
58
|
+
Attributes:
|
|
59
|
+
source: Node ID of the source (dependent) file.
|
|
60
|
+
target: Node ID of the target (dependency) file or module.
|
|
61
|
+
relation: Semantic relation label (e.g. "imports").
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
source: str
|
|
65
|
+
target: str
|
|
66
|
+
relation: str
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def pluralize_symbol_kind(kind: str, plural_map: Dict[str, str]) -> str:
|
|
70
|
+
"""Return the plural form of *kind* according to *plural_map*.
|
|
71
|
+
|
|
72
|
+
Falls back to appending ``"s"`` when the kind is not found.
|
|
73
|
+
This prevents obvious misspellings like ``"Classs"``.
|
|
74
|
+
"""
|
|
75
|
+
return plural_map.get(kind, kind + "s")
|