spartastruct 0.1.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.
- spartastruct/__init__.py +3 -0
- spartastruct/analyzer/__init__.py +1 -0
- spartastruct/analyzer/base.py +127 -0
- spartastruct/analyzer/base_analyzer.py +20 -0
- spartastruct/analyzer/js_analyzer.py +233 -0
- spartastruct/analyzer/python_analyzer.py +577 -0
- spartastruct/cli.py +261 -0
- spartastruct/config.py +51 -0
- spartastruct/diagrams/__init__.py +1 -0
- spartastruct/diagrams/class_diagram.py +64 -0
- spartastruct/diagrams/dfd.py +61 -0
- spartastruct/diagrams/er_diagram.py +72 -0
- spartastruct/diagrams/flowchart.py +81 -0
- spartastruct/diagrams/function_graph.py +122 -0
- spartastruct/diagrams/module_graph.py +113 -0
- spartastruct/llm/__init__.py +1 -0
- spartastruct/llm/client.py +173 -0
- spartastruct/llm/prompts.py +75 -0
- spartastruct/renderer/__init__.py +1 -0
- spartastruct/renderer/markdown_renderer.py +89 -0
- spartastruct/renderer/pdf_exporter.py +150 -0
- spartastruct/templates/structure.md.j2 +31 -0
- spartastruct/utils/__init__.py +1 -0
- spartastruct/utils/file_walker.py +96 -0
- spartastruct/utils/framework_detector.py +98 -0
- spartastruct-0.1.0.dist-info/METADATA +442 -0
- spartastruct-0.1.0.dist-info/RECORD +30 -0
- spartastruct-0.1.0.dist-info/WHEEL +5 -0
- spartastruct-0.1.0.dist-info/entry_points.txt +2 -0
- spartastruct-0.1.0.dist-info/top_level.txt +1 -0
spartastruct/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Package init."""
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Core data model for SpartaStruct analysis results.
|
|
2
|
+
|
|
3
|
+
All analysis modules produce instances of these dataclasses, and all
|
|
4
|
+
diagram generators and renderers consume them.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class ParamInfo:
|
|
14
|
+
"""A single function/method parameter with optional type annotation."""
|
|
15
|
+
|
|
16
|
+
name: str
|
|
17
|
+
type: str = "Any"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class AttributeInfo:
|
|
22
|
+
"""A class attribute or instance variable."""
|
|
23
|
+
|
|
24
|
+
name: str
|
|
25
|
+
type: str = "Any"
|
|
26
|
+
visibility: str = "public" # "public", "protected", "private"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class MethodInfo:
|
|
31
|
+
"""A class method with full signature information."""
|
|
32
|
+
|
|
33
|
+
name: str
|
|
34
|
+
params: list[ParamInfo] = field(default_factory=list)
|
|
35
|
+
return_type: str = "None"
|
|
36
|
+
decorators: list[str] = field(default_factory=list)
|
|
37
|
+
is_async: bool = False
|
|
38
|
+
calls: list[str] = field(default_factory=list)
|
|
39
|
+
visibility: str = "public" # "public", "protected", "private"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class ClassInfo:
|
|
44
|
+
"""A Python class with all its members and metadata."""
|
|
45
|
+
|
|
46
|
+
name: str
|
|
47
|
+
bases: list[str] = field(default_factory=list)
|
|
48
|
+
decorators: list[str] = field(default_factory=list)
|
|
49
|
+
attributes: list[AttributeInfo] = field(default_factory=list)
|
|
50
|
+
methods: list[MethodInfo] = field(default_factory=list)
|
|
51
|
+
is_abstract: bool = False
|
|
52
|
+
is_dataclass: bool = False
|
|
53
|
+
orm_type: str | None = None # "sqlalchemy", "django", "tortoise", "peewee"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class FunctionInfo:
|
|
58
|
+
"""A module-level function."""
|
|
59
|
+
|
|
60
|
+
name: str
|
|
61
|
+
params: list[ParamInfo] = field(default_factory=list)
|
|
62
|
+
return_type: str = "None"
|
|
63
|
+
decorators: list[str] = field(default_factory=list)
|
|
64
|
+
is_async: bool = False
|
|
65
|
+
calls: list[str] = field(default_factory=list)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class ImportInfo:
|
|
70
|
+
"""Classified imports for a single Python file."""
|
|
71
|
+
|
|
72
|
+
stdlib: list[str] = field(default_factory=list)
|
|
73
|
+
third_party: list[str] = field(default_factory=list)
|
|
74
|
+
local: list[str] = field(default_factory=list) # local project import edges
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class RouteInfo:
|
|
79
|
+
"""An HTTP route definition (FastAPI, Flask, or Django)."""
|
|
80
|
+
|
|
81
|
+
method: str # "GET", "POST", "PUT", "DELETE", etc. or "ANY" for Flask catch-all
|
|
82
|
+
path: str
|
|
83
|
+
handler_name: str
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class FileResult:
|
|
88
|
+
"""Analysis results for a single Python file."""
|
|
89
|
+
|
|
90
|
+
path: str # relative path from project root
|
|
91
|
+
classes: list[ClassInfo] = field(default_factory=list)
|
|
92
|
+
functions: list[FunctionInfo] = field(default_factory=list)
|
|
93
|
+
imports: ImportInfo = field(default_factory=ImportInfo)
|
|
94
|
+
routes: list[RouteInfo] = field(default_factory=list)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass
|
|
98
|
+
class AnalysisResult:
|
|
99
|
+
"""Top-level container for an entire project analysis run."""
|
|
100
|
+
|
|
101
|
+
files_analyzed: list[FileResult] = field(default_factory=list)
|
|
102
|
+
frameworks: list[str] = field(default_factory=list)
|
|
103
|
+
entry_points: list[str] = field(default_factory=list) # relative file paths
|
|
104
|
+
warnings: list[str] = field(default_factory=list)
|
|
105
|
+
llm_calls_succeeded: int = 0
|
|
106
|
+
files_skipped_large: int = 0
|
|
107
|
+
files_skipped_parse_error: int = 0
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def all_classes(self) -> list[ClassInfo]:
|
|
111
|
+
"""Return all ClassInfo objects across all analyzed files."""
|
|
112
|
+
return [cls for fr in self.files_analyzed for cls in fr.classes]
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def all_functions(self) -> list[FunctionInfo]:
|
|
116
|
+
"""Return all FunctionInfo objects across all analyzed files."""
|
|
117
|
+
return [fn for fr in self.files_analyzed for fn in fr.functions]
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def all_routes(self) -> list[RouteInfo]:
|
|
121
|
+
"""Return all RouteInfo objects across all analyzed files."""
|
|
122
|
+
return [r for fr in self.files_analyzed for r in fr.routes]
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def orm_models(self) -> list[ClassInfo]:
|
|
126
|
+
"""Return all classes that are ORM models."""
|
|
127
|
+
return [cls for cls in self.all_classes if cls.orm_type is not None]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Protocol defining the interface every language analyzer must satisfy."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Protocol
|
|
7
|
+
|
|
8
|
+
from spartastruct.analyzer.base import AnalysisResult
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BaseAnalyzer(Protocol):
|
|
12
|
+
"""Every language analyzer must implement this interface."""
|
|
13
|
+
|
|
14
|
+
def analyze(
|
|
15
|
+
self,
|
|
16
|
+
files: list[Path],
|
|
17
|
+
warnings: list[str] | None = None,
|
|
18
|
+
) -> AnalysisResult:
|
|
19
|
+
"""Analyze a list of source files and return a complete AnalysisResult."""
|
|
20
|
+
...
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Regex-based analyzer for JavaScript and TypeScript source files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from spartastruct.analyzer.base import (
|
|
9
|
+
AnalysisResult,
|
|
10
|
+
ClassInfo,
|
|
11
|
+
FileResult,
|
|
12
|
+
FunctionInfo,
|
|
13
|
+
ImportInfo,
|
|
14
|
+
MethodInfo,
|
|
15
|
+
RouteInfo,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
# Class / interface declarations
|
|
19
|
+
_CLASS_RE = re.compile(
|
|
20
|
+
r"(?:export\s+)?(?:abstract\s+)?(?:class|interface)\s+(\w+)"
|
|
21
|
+
r"(?:\s+extends\s+([\w,\s<>]+?))?(?:\s+implements\s+([\w,\s<>]+?))?"
|
|
22
|
+
r"\s*\{",
|
|
23
|
+
re.MULTILINE,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# Method inside a class body
|
|
27
|
+
_METHOD_RE = re.compile(
|
|
28
|
+
r"(?:(?:public|private|protected|static|async|override|abstract|readonly)\s+)*"
|
|
29
|
+
r"(\w+)\s*\([^)]*\)\s*(?::\s*[\w<>\[\]|&\s]+?)?\s*[{;]",
|
|
30
|
+
re.MULTILINE,
|
|
31
|
+
)
|
|
32
|
+
_METHOD_SKIP = frozenset(
|
|
33
|
+
{"if", "for", "while", "switch", "catch", "super", "return", "new", "throw"}
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# Named function declarations
|
|
37
|
+
_FUNC_DECL_RE = re.compile(
|
|
38
|
+
r"(?:export\s+)?(?P<async>async\s+)?function\s+(?P<name>\w+)\s*\(",
|
|
39
|
+
re.MULTILINE,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# Arrow / expression functions: const foo = (async?) (...) =>
|
|
43
|
+
_ARROW_RE = re.compile(
|
|
44
|
+
r"(?:export\s+)?(?:const|let|var)\s+(?P<name>\w+)\s*=\s*(?P<async>async\s+)?\(",
|
|
45
|
+
re.MULTILINE,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# ES module imports
|
|
49
|
+
_IMPORT_FROM_RE = re.compile(
|
|
50
|
+
r"""import\s+(?:.*?\s+from\s+)?['"]([^'"]+)['"]""",
|
|
51
|
+
re.MULTILINE | re.DOTALL,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# CommonJS requires
|
|
55
|
+
_REQUIRE_RE = re.compile(
|
|
56
|
+
r"""(?:const|let|var)\s+\w+\s*=\s*require\s*\(\s*['"]([^'"]+)['"]\s*\)""",
|
|
57
|
+
re.MULTILINE,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# Express routes
|
|
61
|
+
_EXPRESS_ROUTE_RE = re.compile(
|
|
62
|
+
r"(?:app|router)\.(get|post|put|patch|delete|all)\s*\(\s*['\"`]([^'\"`]+)['\"`]",
|
|
63
|
+
re.MULTILINE | re.IGNORECASE,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# NestJS decorator routes
|
|
67
|
+
_NESTJS_ROUTE_RE = re.compile(
|
|
68
|
+
r"@(Get|Post|Put|Patch|Delete|All)\s*\(\s*(?:['\"`]([^'\"`]*)['\"`])?\s*\)",
|
|
69
|
+
re.MULTILINE,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
_JS_ENTRY_NAMES = frozenset(
|
|
73
|
+
{"index.js", "index.ts", "main.js", "main.ts", "app.js", "app.ts", "server.js", "server.ts"}
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _extract_class_body(source: str, brace_pos: int) -> str:
|
|
78
|
+
"""Return the text between the first matching { } pair starting at brace_pos."""
|
|
79
|
+
depth = 0
|
|
80
|
+
start = None
|
|
81
|
+
for i in range(brace_pos, len(source)):
|
|
82
|
+
ch = source[i]
|
|
83
|
+
if ch == "{":
|
|
84
|
+
if start is None:
|
|
85
|
+
start = i + 1
|
|
86
|
+
depth += 1
|
|
87
|
+
elif ch == "}":
|
|
88
|
+
depth -= 1
|
|
89
|
+
if depth == 0 and start is not None:
|
|
90
|
+
return source[start:i]
|
|
91
|
+
return ""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _extract_methods(class_body: str) -> list[MethodInfo]:
|
|
95
|
+
methods: list[MethodInfo] = []
|
|
96
|
+
seen: set[str] = set()
|
|
97
|
+
for m in _METHOD_RE.finditer(class_body):
|
|
98
|
+
name = m.group(1)
|
|
99
|
+
if name in _METHOD_SKIP or name in seen:
|
|
100
|
+
continue
|
|
101
|
+
seen.add(name)
|
|
102
|
+
methods.append(MethodInfo(name=name))
|
|
103
|
+
return methods
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _classify_import(module_path: str) -> tuple[str, str]:
|
|
107
|
+
if module_path.startswith("."):
|
|
108
|
+
return "local", module_path
|
|
109
|
+
return "third_party", module_path
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _extract_routes(source: str) -> list[RouteInfo]:
|
|
113
|
+
routes: list[RouteInfo] = []
|
|
114
|
+
for m in _EXPRESS_ROUTE_RE.finditer(source):
|
|
115
|
+
routes.append(RouteInfo(
|
|
116
|
+
method=m.group(1).upper(),
|
|
117
|
+
path=m.group(2),
|
|
118
|
+
handler_name="handler",
|
|
119
|
+
))
|
|
120
|
+
for m in _NESTJS_ROUTE_RE.finditer(source):
|
|
121
|
+
routes.append(RouteInfo(
|
|
122
|
+
method=m.group(1).upper(),
|
|
123
|
+
path=m.group(2) or "/",
|
|
124
|
+
handler_name="handler",
|
|
125
|
+
))
|
|
126
|
+
return routes
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _analyze_file(file_path: Path, project_root: Path) -> FileResult:
|
|
130
|
+
try:
|
|
131
|
+
source = file_path.read_text(encoding="utf-8", errors="replace")
|
|
132
|
+
except OSError:
|
|
133
|
+
source = ""
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
rel_path = str(file_path.relative_to(project_root))
|
|
137
|
+
except ValueError:
|
|
138
|
+
rel_path = str(file_path)
|
|
139
|
+
|
|
140
|
+
# Classes / interfaces
|
|
141
|
+
classes: list[ClassInfo] = []
|
|
142
|
+
class_spans: list[tuple[int, int]] = []
|
|
143
|
+
for m in _CLASS_RE.finditer(source):
|
|
144
|
+
name = m.group(1)
|
|
145
|
+
bases_raw = (m.group(2) or "").strip()
|
|
146
|
+
bases = [b.strip() for b in re.split(r",\s*", bases_raw) if b.strip()]
|
|
147
|
+
brace_pos = m.end() - 1
|
|
148
|
+
class_body = _extract_class_body(source, brace_pos)
|
|
149
|
+
class_spans.append((brace_pos, brace_pos + len(class_body) + 2))
|
|
150
|
+
methods = _extract_methods(class_body)
|
|
151
|
+
classes.append(ClassInfo(name=name, bases=bases, methods=methods))
|
|
152
|
+
|
|
153
|
+
def _in_class(pos: int) -> bool:
|
|
154
|
+
return any(start <= pos <= end for start, end in class_spans)
|
|
155
|
+
|
|
156
|
+
# Module-level functions
|
|
157
|
+
functions: list[FunctionInfo] = []
|
|
158
|
+
seen_fns: set[str] = set()
|
|
159
|
+
|
|
160
|
+
for m in _FUNC_DECL_RE.finditer(source):
|
|
161
|
+
if _in_class(m.start()):
|
|
162
|
+
continue
|
|
163
|
+
name = m.group("name")
|
|
164
|
+
if name in seen_fns:
|
|
165
|
+
continue
|
|
166
|
+
seen_fns.add(name)
|
|
167
|
+
functions.append(FunctionInfo(name=name, is_async=bool(m.group("async"))))
|
|
168
|
+
|
|
169
|
+
for m in _ARROW_RE.finditer(source):
|
|
170
|
+
if _in_class(m.start()):
|
|
171
|
+
continue
|
|
172
|
+
name = m.group("name")
|
|
173
|
+
if name in seen_fns:
|
|
174
|
+
continue
|
|
175
|
+
seen_fns.add(name)
|
|
176
|
+
functions.append(FunctionInfo(name=name, is_async=bool(m.group("async"))))
|
|
177
|
+
|
|
178
|
+
# Imports
|
|
179
|
+
third_party: list[str] = []
|
|
180
|
+
local: list[str] = []
|
|
181
|
+
|
|
182
|
+
for m in _IMPORT_FROM_RE.finditer(source):
|
|
183
|
+
kind, path = _classify_import(m.group(1))
|
|
184
|
+
(local if kind == "local" else third_party).append(path)
|
|
185
|
+
|
|
186
|
+
for m in _REQUIRE_RE.finditer(source):
|
|
187
|
+
kind, path = _classify_import(m.group(1))
|
|
188
|
+
(local if kind == "local" else third_party).append(path)
|
|
189
|
+
|
|
190
|
+
imports = ImportInfo(
|
|
191
|
+
third_party=list(dict.fromkeys(third_party)),
|
|
192
|
+
local=list(dict.fromkeys(local)),
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
routes = _extract_routes(source)
|
|
196
|
+
|
|
197
|
+
return FileResult(
|
|
198
|
+
path=rel_path, classes=classes, functions=functions, imports=imports, routes=routes
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class JsAnalyzer:
|
|
203
|
+
"""Analyzes JavaScript and TypeScript source files using regex-based extraction."""
|
|
204
|
+
|
|
205
|
+
def __init__(self, project_root: Path) -> None:
|
|
206
|
+
self._root = project_root.resolve()
|
|
207
|
+
|
|
208
|
+
def analyze(
|
|
209
|
+
self,
|
|
210
|
+
files: list[Path],
|
|
211
|
+
warnings: list[str] | None = None,
|
|
212
|
+
) -> AnalysisResult:
|
|
213
|
+
if warnings is None:
|
|
214
|
+
warnings = []
|
|
215
|
+
|
|
216
|
+
file_results: list[FileResult] = []
|
|
217
|
+
for file_path in files:
|
|
218
|
+
try:
|
|
219
|
+
fr = _analyze_file(file_path, self._root)
|
|
220
|
+
file_results.append(fr)
|
|
221
|
+
except Exception as exc: # noqa: BLE001
|
|
222
|
+
warnings.append(f"Failed to analyze {file_path.name}: {exc}")
|
|
223
|
+
|
|
224
|
+
entry_points = [
|
|
225
|
+
fr.path for fr in file_results
|
|
226
|
+
if Path(fr.path).name in _JS_ENTRY_NAMES
|
|
227
|
+
]
|
|
228
|
+
|
|
229
|
+
return AnalysisResult(
|
|
230
|
+
files_analyzed=file_results,
|
|
231
|
+
entry_points=entry_points,
|
|
232
|
+
warnings=warnings,
|
|
233
|
+
)
|