mcp-win-stdio-arch 0.3.0__tar.gz
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.
- mcp_win_stdio_arch-0.3.0/.gitignore +63 -0
- mcp_win_stdio_arch-0.3.0/PKG-INFO +38 -0
- mcp_win_stdio_arch-0.3.0/README.md +13 -0
- mcp_win_stdio_arch-0.3.0/pyproject.toml +42 -0
- mcp_win_stdio_arch-0.3.0/src/mcp_win_stdio/arch/__init__.py +6 -0
- mcp_win_stdio_arch-0.3.0/src/mcp_win_stdio/arch/cli.py +71 -0
- mcp_win_stdio_arch-0.3.0/src/mcp_win_stdio/arch/graph.py +214 -0
- mcp_win_stdio_arch-0.3.0/src/mcp_win_stdio/arch/identity.py +61 -0
- mcp_win_stdio_arch-0.3.0/src/mcp_win_stdio/arch/models.py +96 -0
- mcp_win_stdio_arch-0.3.0/src/mcp_win_stdio/arch/renderers.py +96 -0
- mcp_win_stdio_arch-0.3.0/src/mcp_win_stdio/arch/scanner.py +250 -0
- mcp_win_stdio_arch-0.3.0/src/mcp_win_stdio/arch/server.py +133 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
__pycache__/
|
|
2
|
+
*.py[cod]
|
|
3
|
+
*$py.class
|
|
4
|
+
*.so
|
|
5
|
+
.Python
|
|
6
|
+
build/
|
|
7
|
+
develop-eggs/
|
|
8
|
+
dist/
|
|
9
|
+
downloads/
|
|
10
|
+
eggs/
|
|
11
|
+
.eggs/
|
|
12
|
+
lib/
|
|
13
|
+
lib64/
|
|
14
|
+
parts/
|
|
15
|
+
sdist/
|
|
16
|
+
var/
|
|
17
|
+
wheels/
|
|
18
|
+
share/python-wheels/
|
|
19
|
+
*.egg-info/
|
|
20
|
+
.installed.cfg
|
|
21
|
+
*.egg
|
|
22
|
+
MANIFEST
|
|
23
|
+
|
|
24
|
+
*.manifest
|
|
25
|
+
*.spec
|
|
26
|
+
api.txt
|
|
27
|
+
pypi-api.txt
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
pip-log.txt
|
|
31
|
+
pip-delete-this-directory.txt
|
|
32
|
+
pypi api.txt
|
|
33
|
+
htmlcov/
|
|
34
|
+
.tox/
|
|
35
|
+
.nox/
|
|
36
|
+
.coverage
|
|
37
|
+
.coverage.*
|
|
38
|
+
.cache
|
|
39
|
+
nosetests.xml
|
|
40
|
+
coverage.xml
|
|
41
|
+
*.cover
|
|
42
|
+
*.py,cover
|
|
43
|
+
.hypothesis/
|
|
44
|
+
.pytest_cache/
|
|
45
|
+
cover/
|
|
46
|
+
|
|
47
|
+
*.mo
|
|
48
|
+
*.pot
|
|
49
|
+
|
|
50
|
+
.env
|
|
51
|
+
.venv
|
|
52
|
+
env/
|
|
53
|
+
venv/
|
|
54
|
+
ENV/
|
|
55
|
+
env.bak/
|
|
56
|
+
venv.bak/
|
|
57
|
+
|
|
58
|
+
.idea/
|
|
59
|
+
.vscode/
|
|
60
|
+
*.swp
|
|
61
|
+
*.swo
|
|
62
|
+
|
|
63
|
+
*.log
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: mcp-win-stdio-arch
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Universal Architecture Knowledge Graph & Blueprint Model Context Protocol (MCP) server for full-stack codebases (Next.js, NestJS, Drizzle, PostgreSQL/MySQL).
|
|
5
|
+
Author: Mohan Kumar Indala
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: ai,architecture,claude,drizzle,erd,knowledge-graph,llm,mcp,nestjs,nextjs,postgres,uml
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Environment :: Win32 (MS Windows)
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Topic :: Software Development :: Documentation
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Requires-Dist: mcp-win-stdio>=0.3.0
|
|
22
|
+
Requires-Dist: mcp>=1.2.0
|
|
23
|
+
Requires-Dist: networkx>=3.2.1
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# mcp-win-stdio-arch
|
|
27
|
+
|
|
28
|
+
Universal Architecture Knowledge Graph & Blueprint Model Context Protocol (MCP) server.
|
|
29
|
+
|
|
30
|
+
Traces full-stack applications from Frontend UI (`Next.js`), Client Transport (`TanStack Query`, `Axios`), Ingress Routes (`NestJS`), Domain Services, and Data Storage (`Drizzle ORM`, `PostgreSQL`/`MySQL`).
|
|
31
|
+
|
|
32
|
+
## Tools Provided
|
|
33
|
+
|
|
34
|
+
1. `blueprint_feature(domain, workspace_root)`: Generates full-stack vertical blueprint with Mermaid flowchart.
|
|
35
|
+
2. `blueprint_trace(target, workspace_root)`: Ordered vertical execution path with source evidence and line numbers.
|
|
36
|
+
3. `blueprint_erd(domain, workspace_root)`: Entity-Relationship Diagram (ERD) with tables, columns, and foreign keys.
|
|
37
|
+
4. `blueprint_impact(node_id, direction, workspace_root)`: Upstream & downstream blast-radius analysis.
|
|
38
|
+
5. `blueprint_audit(workspace_root)`: Architectural linting (missing FK indexes, broken routes, layer leaks, orphan entities).
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# mcp-win-stdio-arch
|
|
2
|
+
|
|
3
|
+
Universal Architecture Knowledge Graph & Blueprint Model Context Protocol (MCP) server.
|
|
4
|
+
|
|
5
|
+
Traces full-stack applications from Frontend UI (`Next.js`), Client Transport (`TanStack Query`, `Axios`), Ingress Routes (`NestJS`), Domain Services, and Data Storage (`Drizzle ORM`, `PostgreSQL`/`MySQL`).
|
|
6
|
+
|
|
7
|
+
## Tools Provided
|
|
8
|
+
|
|
9
|
+
1. `blueprint_feature(domain, workspace_root)`: Generates full-stack vertical blueprint with Mermaid flowchart.
|
|
10
|
+
2. `blueprint_trace(target, workspace_root)`: Ordered vertical execution path with source evidence and line numbers.
|
|
11
|
+
3. `blueprint_erd(domain, workspace_root)`: Entity-Relationship Diagram (ERD) with tables, columns, and foreign keys.
|
|
12
|
+
4. `blueprint_impact(node_id, direction, workspace_root)`: Upstream & downstream blast-radius analysis.
|
|
13
|
+
5. `blueprint_audit(workspace_root)`: Architectural linting (missing FK indexes, broken routes, layer leaks, orphan entities).
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "mcp-win-stdio-arch"
|
|
7
|
+
version = "0.3.0"
|
|
8
|
+
description = "Universal Architecture Knowledge Graph & Blueprint Model Context Protocol (MCP) server for full-stack codebases (Next.js, NestJS, Drizzle, PostgreSQL/MySQL)."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Mohan Kumar Indala" }
|
|
14
|
+
]
|
|
15
|
+
keywords = ["mcp", "claude", "architecture", "knowledge-graph", "erd", "uml", "nextjs", "nestjs", "drizzle", "postgres", "ai", "llm"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Environment :: Win32 (MS Windows)",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"License :: OSI Approved :: MIT License",
|
|
21
|
+
"Operating System :: Microsoft :: Windows",
|
|
22
|
+
"Programming Language :: Python :: 3",
|
|
23
|
+
"Programming Language :: Python :: 3.10",
|
|
24
|
+
"Programming Language :: Python :: 3.11",
|
|
25
|
+
"Programming Language :: Python :: 3.12",
|
|
26
|
+
"Programming Language :: Python :: 3.13",
|
|
27
|
+
"Programming Language :: Python :: 3.14",
|
|
28
|
+
"Topic :: Software Development :: Documentation",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
dependencies = [
|
|
32
|
+
"mcp-win-stdio>=0.3.0",
|
|
33
|
+
"mcp>=1.2.0",
|
|
34
|
+
"networkx>=3.2.1",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[project.scripts]
|
|
38
|
+
mcp-win-stdio-arch = "mcp_win_stdio.arch.cli:main"
|
|
39
|
+
mws-arch = "mcp_win_stdio.arch.cli:main"
|
|
40
|
+
|
|
41
|
+
[tool.hatch.build.targets.wheel]
|
|
42
|
+
packages = ["src/mcp_win_stdio"]
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI entry point for mcp-win-stdio-arch.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from mcp_win_stdio.arch import __version__
|
|
9
|
+
from mcp_win_stdio.arch.server import mcp
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def cmd_run(args: argparse.Namespace) -> None:
|
|
13
|
+
"""Run Architecture Blueprint MCP server over stdio."""
|
|
14
|
+
mcp.run()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def cmd_doctor(args: argparse.Namespace) -> None:
|
|
18
|
+
"""Check Architecture Blueprint MCP health."""
|
|
19
|
+
print(f"\n=== mcp-win-stdio-arch Doctor Diagnostic (v{__version__}) ===\n")
|
|
20
|
+
print(f"[OK] Python: {sys.version.split()[0]} ({sys.executable})")
|
|
21
|
+
|
|
22
|
+
for dep in ("mcp", "networkx"):
|
|
23
|
+
try:
|
|
24
|
+
__import__(dep)
|
|
25
|
+
print(f"[OK] Library: {dep:<14}")
|
|
26
|
+
except ImportError:
|
|
27
|
+
print(f"[FAIL] Library: {dep:<14} (MISSING - run `pip install {dep}`)")
|
|
28
|
+
|
|
29
|
+
print("\nDiagnostic complete.\n")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def cmd_guide(args: argparse.Namespace) -> None:
|
|
33
|
+
"""Print guide and prompt recipes."""
|
|
34
|
+
print("""
|
|
35
|
+
=== mcp-win-stdio-arch Guide & Recipes ===
|
|
36
|
+
|
|
37
|
+
Tools Provided:
|
|
38
|
+
- blueprint_feature(domain, workspace_root)
|
|
39
|
+
- blueprint_erd(domain, workspace_root)
|
|
40
|
+
- blueprint_trace(target, workspace_root)
|
|
41
|
+
- blueprint_impact(node_id, direction, workspace_root)
|
|
42
|
+
- blueprint_audit(workspace_root)
|
|
43
|
+
""")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def main() -> None:
|
|
47
|
+
parser = argparse.ArgumentParser(
|
|
48
|
+
prog="mcp-win-stdio-arch",
|
|
49
|
+
description="Universal Architecture Blueprint MCP Server",
|
|
50
|
+
)
|
|
51
|
+
subparsers = parser.add_subparsers(dest="subcommand", help="Available subcommands")
|
|
52
|
+
|
|
53
|
+
run_parser = subparsers.add_parser("run", help="Run MCP server over stdio")
|
|
54
|
+
run_parser.set_defaults(func=cmd_run)
|
|
55
|
+
|
|
56
|
+
doc_parser = subparsers.add_parser("doctor", help="Check dependencies and health")
|
|
57
|
+
doc_parser.set_defaults(func=cmd_doctor)
|
|
58
|
+
|
|
59
|
+
guide_parser = subparsers.add_parser("guide", help="Print tool guide")
|
|
60
|
+
guide_parser.set_defaults(func=cmd_guide)
|
|
61
|
+
|
|
62
|
+
args = parser.parse_args()
|
|
63
|
+
if hasattr(args, "func"):
|
|
64
|
+
args.func(args)
|
|
65
|
+
else:
|
|
66
|
+
# Default action: run stdio server
|
|
67
|
+
cmd_run(args)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
if __name__ == "__main__":
|
|
71
|
+
main()
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core Directed Multigraph Engine using NetworkX
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, List, Optional, Set
|
|
6
|
+
import networkx as nx
|
|
7
|
+
|
|
8
|
+
from .models import (
|
|
9
|
+
ArchitectureEdge,
|
|
10
|
+
ArchitectureNode,
|
|
11
|
+
EdgeType,
|
|
12
|
+
NodeKind,
|
|
13
|
+
ScanDiagnostic,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ArchitectureGraph:
|
|
18
|
+
def __init__(self) -> None:
|
|
19
|
+
self.g = nx.MultiDiGraph()
|
|
20
|
+
self.nodes_map: Dict[str, ArchitectureNode] = {}
|
|
21
|
+
self.edges_map: Dict[str, ArchitectureEdge] = {}
|
|
22
|
+
self.diagnostics: List[ScanDiagnostic] = []
|
|
23
|
+
|
|
24
|
+
def clear(self) -> None:
|
|
25
|
+
self.g.clear()
|
|
26
|
+
self.nodes_map.clear()
|
|
27
|
+
self.edges_map.clear()
|
|
28
|
+
self.diagnostics.clear()
|
|
29
|
+
|
|
30
|
+
# --- Node Operations ---
|
|
31
|
+
|
|
32
|
+
def add_node(self, node: ArchitectureNode) -> None:
|
|
33
|
+
self.nodes_map[node.id] = node
|
|
34
|
+
self.g.add_node(
|
|
35
|
+
node.id,
|
|
36
|
+
kind=node.kind.value if isinstance(node.kind, NodeKind) else str(node.kind),
|
|
37
|
+
name=node.name,
|
|
38
|
+
domain=node.domain,
|
|
39
|
+
file_path=node.file_path,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def get_node(self, node_id: str) -> Optional[ArchitectureNode]:
|
|
43
|
+
return self.nodes_map.get(node_id)
|
|
44
|
+
|
|
45
|
+
def has_node(self, node_id: str) -> bool:
|
|
46
|
+
return node_id in self.nodes_map
|
|
47
|
+
|
|
48
|
+
def get_all_nodes(self) -> List[ArchitectureNode]:
|
|
49
|
+
return list(self.nodes_map.values())
|
|
50
|
+
|
|
51
|
+
def get_nodes_by_kind(self, kind: NodeKind) -> List[ArchitectureNode]:
|
|
52
|
+
return [n for n in self.nodes_map.values() if n.kind == kind]
|
|
53
|
+
|
|
54
|
+
def get_nodes_by_domain(self, domain: str) -> List[ArchitectureNode]:
|
|
55
|
+
return [n for n in self.nodes_map.values() if n.domain == domain]
|
|
56
|
+
|
|
57
|
+
# --- Edge Operations ---
|
|
58
|
+
|
|
59
|
+
def add_edge(self, edge: ArchitectureEdge) -> None:
|
|
60
|
+
if edge.source not in self.nodes_map or edge.target not in self.nodes_map:
|
|
61
|
+
raise ValueError(
|
|
62
|
+
f"Cannot add edge {edge.id}: source ({edge.source}) or target ({edge.target}) does not exist"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
self.edges_map[edge.id] = edge
|
|
66
|
+
self.g.add_edge(
|
|
67
|
+
edge.source,
|
|
68
|
+
edge.target,
|
|
69
|
+
key=edge.id,
|
|
70
|
+
type=edge.type.value if isinstance(edge.type, EdgeType) else str(edge.type),
|
|
71
|
+
confidence=edge.confidence.value,
|
|
72
|
+
resolution=edge.resolution.value,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def get_edge(self, edge_id: str) -> Optional[ArchitectureEdge]:
|
|
76
|
+
return self.edges_map.get(edge_id)
|
|
77
|
+
|
|
78
|
+
def get_all_edges(self) -> List[ArchitectureEdge]:
|
|
79
|
+
return list(self.edges_map.values())
|
|
80
|
+
|
|
81
|
+
def get_outgoing_edges(self, node_id: str) -> List[ArchitectureEdge]:
|
|
82
|
+
if not self.g.has_node(node_id):
|
|
83
|
+
return []
|
|
84
|
+
edges = []
|
|
85
|
+
for _, _, key in self.g.out_edges(node_id, keys=True):
|
|
86
|
+
if key in self.edges_map:
|
|
87
|
+
edges.append(self.edges_map[key])
|
|
88
|
+
return edges
|
|
89
|
+
|
|
90
|
+
def get_incoming_edges(self, node_id: str) -> List[ArchitectureEdge]:
|
|
91
|
+
if not self.g.has_node(node_id):
|
|
92
|
+
return []
|
|
93
|
+
edges = []
|
|
94
|
+
for _, _, key in self.g.in_edges(node_id, keys=True):
|
|
95
|
+
if key in self.edges_map:
|
|
96
|
+
edges.append(self.edges_map[key])
|
|
97
|
+
return edges
|
|
98
|
+
|
|
99
|
+
# --- Diagnostics ---
|
|
100
|
+
|
|
101
|
+
def add_diagnostic(self, diagnostic: ScanDiagnostic) -> None:
|
|
102
|
+
self.diagnostics.append(diagnostic)
|
|
103
|
+
|
|
104
|
+
def get_diagnostics(self) -> List[ScanDiagnostic]:
|
|
105
|
+
return list(self.diagnostics)
|
|
106
|
+
|
|
107
|
+
# --- Traversals & Blast Radius ---
|
|
108
|
+
|
|
109
|
+
def trace_paths(
|
|
110
|
+
self,
|
|
111
|
+
from_node_id: str,
|
|
112
|
+
target_kind: Optional[NodeKind] = None,
|
|
113
|
+
max_depth: int = 10,
|
|
114
|
+
max_paths: int = 20,
|
|
115
|
+
) -> List[List[str]]:
|
|
116
|
+
"""
|
|
117
|
+
Traces paths from from_node_id to target_kind with cycle protection.
|
|
118
|
+
Returns list of path node ID lists.
|
|
119
|
+
"""
|
|
120
|
+
if not self.has_node(from_node_id):
|
|
121
|
+
return []
|
|
122
|
+
|
|
123
|
+
results: List[List[str]] = []
|
|
124
|
+
|
|
125
|
+
def dfs(current_id: str, path: List[str], depth: int, visited: Set[str]):
|
|
126
|
+
if len(results) >= max_paths or depth >= max_depth:
|
|
127
|
+
return
|
|
128
|
+
|
|
129
|
+
curr_node = self.nodes_map.get(current_id)
|
|
130
|
+
if target_kind and curr_node and curr_node.kind == target_kind and len(path) > 1:
|
|
131
|
+
results.append(list(path))
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
extended = False
|
|
135
|
+
for edge in self.get_outgoing_edges(current_id):
|
|
136
|
+
nxt = edge.target
|
|
137
|
+
if nxt in visited:
|
|
138
|
+
continue
|
|
139
|
+
|
|
140
|
+
extended = True
|
|
141
|
+
visited.add(nxt)
|
|
142
|
+
path.append(nxt)
|
|
143
|
+
|
|
144
|
+
dfs(nxt, path, depth + 1, visited)
|
|
145
|
+
|
|
146
|
+
path.pop()
|
|
147
|
+
visited.remove(nxt)
|
|
148
|
+
|
|
149
|
+
if not extended and not target_kind and len(path) > 1:
|
|
150
|
+
results.append(list(path))
|
|
151
|
+
|
|
152
|
+
dfs(from_node_id, [from_node_id], 0, {from_node_id})
|
|
153
|
+
return results
|
|
154
|
+
|
|
155
|
+
def compute_blast_radius(
|
|
156
|
+
self,
|
|
157
|
+
node_id: str,
|
|
158
|
+
direction: str = "both",
|
|
159
|
+
max_depth: int = 8,
|
|
160
|
+
) -> Dict[str, Any]:
|
|
161
|
+
"""
|
|
162
|
+
Calculates upstream/downstream blast radius for refactoring.
|
|
163
|
+
"""
|
|
164
|
+
if not self.has_node(node_id):
|
|
165
|
+
return {"root_node_id": node_id, "total_affected": 0, "impacted": []}
|
|
166
|
+
|
|
167
|
+
impacted: Dict[str, Dict[str, Any]] = {}
|
|
168
|
+
|
|
169
|
+
queue: List[tuple[str, int, str]] = []
|
|
170
|
+
if direction in ("downstream", "both"):
|
|
171
|
+
queue.append((node_id, 0, "downstream"))
|
|
172
|
+
if direction in ("upstream", "both"):
|
|
173
|
+
queue.append((node_id, 0, "upstream"))
|
|
174
|
+
|
|
175
|
+
visited: Set[str] = set()
|
|
176
|
+
|
|
177
|
+
while queue:
|
|
178
|
+
curr_id, depth, d = queue.pop(0)
|
|
179
|
+
vkey = f"{curr_id}:{d}"
|
|
180
|
+
if vkey in visited:
|
|
181
|
+
continue
|
|
182
|
+
visited.add(vkey)
|
|
183
|
+
|
|
184
|
+
if depth > 0 and curr_id not in impacted:
|
|
185
|
+
impacted[curr_id] = {
|
|
186
|
+
"node_id": curr_id,
|
|
187
|
+
"depth": depth,
|
|
188
|
+
"direction": d,
|
|
189
|
+
"kind": self.nodes_map[curr_id].kind.value,
|
|
190
|
+
"name": self.nodes_map[curr_id].name,
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if depth >= max_depth:
|
|
194
|
+
continue
|
|
195
|
+
|
|
196
|
+
if d == "downstream":
|
|
197
|
+
for edge in self.get_outgoing_edges(curr_id):
|
|
198
|
+
queue.append((edge.target, depth + 1, "downstream"))
|
|
199
|
+
else:
|
|
200
|
+
for edge in self.get_incoming_edges(curr_id):
|
|
201
|
+
queue.append((edge.source, depth + 1, "upstream"))
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
"root_node_id": node_id,
|
|
205
|
+
"total_affected": len(impacted),
|
|
206
|
+
"impacted": list(impacted.values()),
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
def stats(self) -> Dict[str, int]:
|
|
210
|
+
return {
|
|
211
|
+
"node_count": len(self.nodes_map),
|
|
212
|
+
"edge_count": len(self.edges_map),
|
|
213
|
+
"diagnostic_count": len(self.diagnostics),
|
|
214
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Deterministic URN & Node ID Generators
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def normalize_path(path_str: str) -> str:
|
|
9
|
+
"""Normalize file paths to clean POSIX format."""
|
|
10
|
+
return path_str.replace("\\", "/").lstrip("/")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def normalize_route_path(raw_path: str) -> str:
|
|
14
|
+
"""
|
|
15
|
+
Normalize route paths across frameworks:
|
|
16
|
+
Converts :id, ${id}, [id] to {param}
|
|
17
|
+
"""
|
|
18
|
+
p = raw_path.strip().split("?")[0].split("#")[0]
|
|
19
|
+
p = re.sub(r"\$\{[^}]+\}", "{param}", p)
|
|
20
|
+
p = re.sub(r":([a-zA-Z0-9_]+)", "{param}", p)
|
|
21
|
+
p = re.sub(r"\[([a-zA-Z0-9_]+)\]", "{param}", p)
|
|
22
|
+
if not p.startswith("/"):
|
|
23
|
+
p = "/" + p
|
|
24
|
+
if len(p) > 1 and p.endswith("/"):
|
|
25
|
+
p = p[:-1]
|
|
26
|
+
return p.lower()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class NodeIdentifier:
|
|
30
|
+
@staticmethod
|
|
31
|
+
def for_surface(workspace: str, file_path: str, component_name: str) -> str:
|
|
32
|
+
return f"surface:{workspace}:{normalize_path(file_path)}#{component_name}"
|
|
33
|
+
|
|
34
|
+
@staticmethod
|
|
35
|
+
def for_client_transport(workspace: str, file_path: str, symbol: str) -> str:
|
|
36
|
+
return f"client:{workspace}:{normalize_path(file_path)}#{symbol}"
|
|
37
|
+
|
|
38
|
+
@staticmethod
|
|
39
|
+
def for_ingress_route(method: str, normalized_path: str) -> str:
|
|
40
|
+
return f"route:{method.upper()}:{normalize_route_path(normalized_path)}"
|
|
41
|
+
|
|
42
|
+
@staticmethod
|
|
43
|
+
def for_domain_logic(workspace: str, file_path: str, class_name: str, method: str = "") -> str:
|
|
44
|
+
symbol = f"{class_name}.{method}" if method else class_name
|
|
45
|
+
return f"logic:{workspace}:{normalize_path(file_path)}#{symbol}"
|
|
46
|
+
|
|
47
|
+
@staticmethod
|
|
48
|
+
def for_storage_entity(schema: str, table_name: str) -> str:
|
|
49
|
+
return f"storage:{schema.lower()}:{table_name.lower()}"
|
|
50
|
+
|
|
51
|
+
@staticmethod
|
|
52
|
+
def for_field(owner_id: str, field_name: str) -> str:
|
|
53
|
+
return f"field:{owner_id}#{field_name}"
|
|
54
|
+
|
|
55
|
+
@staticmethod
|
|
56
|
+
def for_contract(workspace: str, file_path: str, type_name: str) -> str:
|
|
57
|
+
return f"contract:{workspace}:{normalize_path(file_path)}#{type_name}"
|
|
58
|
+
|
|
59
|
+
@staticmethod
|
|
60
|
+
def for_edge(source: str, edge_type: str, target: str) -> str:
|
|
61
|
+
return f"edge:{source}--{edge_type}-->{target}"
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core Canonical Schema: Universal Architectural Primitives (UIR)
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Any, Dict, List, Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class NodeKind(str, Enum):
|
|
11
|
+
SURFACE = "Surface" # UI Page, Component, Screen, Template
|
|
12
|
+
CLIENT_TRANSPORT = "ClientTransport" # Hook, API Client method, RPC client
|
|
13
|
+
INGRESS_ROUTE = "IngressRoute" # HTTP Route, Controller method, RPC handler
|
|
14
|
+
DOMAIN_LOGIC = "DomainLogic" # Service, Use Case, Business domain function
|
|
15
|
+
STORAGE_ENTITY = "StorageEntity" # ORM Model, SQL Table, View, Collection
|
|
16
|
+
FIELD = "Field" # Database Column or DTO Property
|
|
17
|
+
CONTRACT = "Contract" # DTO, Zod Schema, OpenAPI Request/Response
|
|
18
|
+
EXTERNAL_SYSTEM = "ExternalSystem" # Third-party API, Message Broker
|
|
19
|
+
CONFIGURATION = "Configuration" # Env var, Route prefix, Feature flag
|
|
20
|
+
JOB = "Job" # Cron job, Queue consumer, Worker
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class EdgeType(str, Enum):
|
|
24
|
+
DECLARES = "DECLARES" # e.g. Controller declares method, Schema declares Table
|
|
25
|
+
IMPORTS = "IMPORTS" # Module imports another module
|
|
26
|
+
CALLS = "CALLS" # Method or function invocation
|
|
27
|
+
USES_HOOK = "USES_HOOK" # Surface uses query/mutation hook
|
|
28
|
+
ROUTES_TO = "ROUTES_TO" # Client request matches an ingress route
|
|
29
|
+
INJECTS = "INJECTS" # Dependency injection
|
|
30
|
+
READS = "READS" # Logic queries a table or field
|
|
31
|
+
WRITES = "WRITES" # Logic mutates a table or field
|
|
32
|
+
SERIALIZES_AS = "SERIALIZES_AS" # Entity shapes into DTO
|
|
33
|
+
REFERENCES_FK = "REFERENCES_FK" # Foreign-key relationship
|
|
34
|
+
PUBLISHES = "PUBLISHES" # Event-driven message publishing
|
|
35
|
+
CONSUMES = "CONSUMES" # Event-driven message consumption
|
|
36
|
+
CONFIGURED_BY = "CONFIGURED_BY" # Component depends on configuration
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Confidence(str, Enum):
|
|
40
|
+
HIGH = "high" # Statically resolved symbol call, explicit foreign key, exact route match
|
|
41
|
+
MEDIUM = "medium" # Inferred through convention or partial types
|
|
42
|
+
LOW = "low" # Heuristic or ambiguous string matching
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Resolution(str, Enum):
|
|
46
|
+
STATIC = "static" # AST symbol checker or static code analysis
|
|
47
|
+
INFERRED = "inferred" # Normalized route or naming pattern match
|
|
48
|
+
RUNTIME = "runtime" # Database introspection or live trace
|
|
49
|
+
MANUAL = "manual" # User/developer annotated
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class EvidenceLocation:
|
|
54
|
+
file_path: str
|
|
55
|
+
start_line: Optional[int] = None
|
|
56
|
+
end_line: Optional[int] = None
|
|
57
|
+
symbol: Optional[str] = None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class EdgeEvidence:
|
|
62
|
+
rule_id: str
|
|
63
|
+
locations: List[EvidenceLocation] = field(default_factory=list)
|
|
64
|
+
explanation: Optional[str] = None
|
|
65
|
+
snippet: Optional[str] = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class ArchitectureNode:
|
|
70
|
+
id: str
|
|
71
|
+
kind: NodeKind
|
|
72
|
+
name: str
|
|
73
|
+
file_path: Optional[str] = None
|
|
74
|
+
domain: Optional[str] = None
|
|
75
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class ArchitectureEdge:
|
|
80
|
+
id: str
|
|
81
|
+
source: str
|
|
82
|
+
target: str
|
|
83
|
+
type: EdgeType
|
|
84
|
+
evidence: EdgeEvidence
|
|
85
|
+
confidence: Confidence = Confidence.HIGH
|
|
86
|
+
resolution: Resolution = Resolution.STATIC
|
|
87
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass
|
|
91
|
+
class ScanDiagnostic:
|
|
92
|
+
code: str
|
|
93
|
+
severity: str # "info" | "warning" | "error"
|
|
94
|
+
message: str
|
|
95
|
+
location: Optional[EvidenceLocation] = None
|
|
96
|
+
attempted_resolution: Optional[Dict[str, Any]] = None
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Mermaid Flowchart and ERD Renderers
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import List, Optional
|
|
7
|
+
|
|
8
|
+
from .graph import ArchitectureGraph
|
|
9
|
+
from .models import ArchitectureEdge, ArchitectureNode, NodeKind
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def sanitize(name: str) -> str:
|
|
13
|
+
return re.sub(r"[^a-zA-Z0-9_]", "_", name)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MermaidRenderer:
|
|
17
|
+
@staticmethod
|
|
18
|
+
def render_flowchart(
|
|
19
|
+
graph: ArchitectureGraph,
|
|
20
|
+
nodes: Optional[List[ArchitectureNode]] = None,
|
|
21
|
+
edges: Optional[List[ArchitectureEdge]] = None,
|
|
22
|
+
) -> str:
|
|
23
|
+
active_nodes = nodes or graph.get_all_nodes()
|
|
24
|
+
active_edges = edges or graph.get_all_edges()
|
|
25
|
+
|
|
26
|
+
lines = ["flowchart TD"]
|
|
27
|
+
|
|
28
|
+
groups: dict[str, list[ArchitectureNode]] = {}
|
|
29
|
+
for node in active_nodes:
|
|
30
|
+
kind_str = node.kind.value if isinstance(node.kind, NodeKind) else str(node.kind)
|
|
31
|
+
groups.setdefault(kind_str, []).append(node)
|
|
32
|
+
|
|
33
|
+
order = [
|
|
34
|
+
"Surface",
|
|
35
|
+
"ClientTransport",
|
|
36
|
+
"IngressRoute",
|
|
37
|
+
"DomainLogic",
|
|
38
|
+
"StorageEntity",
|
|
39
|
+
"Contract",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
for kind in order:
|
|
43
|
+
items = groups.get(kind, [])
|
|
44
|
+
if not items:
|
|
45
|
+
continue
|
|
46
|
+
lines.append(f' subgraph {kind}_Layer ["{kind} Layer"]')
|
|
47
|
+
for n in items:
|
|
48
|
+
sid = sanitize(n.id)
|
|
49
|
+
lines.append(f' {sid}["{n.name}\\n({kind})"]')
|
|
50
|
+
lines.append(" end")
|
|
51
|
+
|
|
52
|
+
for e in active_edges:
|
|
53
|
+
sid = sanitize(e.source)
|
|
54
|
+
tid = sanitize(e.target)
|
|
55
|
+
etype = e.type.value if hasattr(e.type, "value") else str(e.type)
|
|
56
|
+
conf = e.confidence.value if hasattr(e.confidence, "value") else str(e.confidence)
|
|
57
|
+
lines.append(f' {sid} -->|"{etype} ({conf})"| {tid}')
|
|
58
|
+
|
|
59
|
+
return "\n".join(lines)
|
|
60
|
+
|
|
61
|
+
@staticmethod
|
|
62
|
+
def render_erd(graph: ArchitectureGraph, domain: Optional[str] = None) -> str:
|
|
63
|
+
tables = (
|
|
64
|
+
graph.get_nodes_by_domain(domain)
|
|
65
|
+
if domain
|
|
66
|
+
else graph.get_nodes_by_kind(NodeKind.STORAGE_ENTITY)
|
|
67
|
+
)
|
|
68
|
+
tables = [t for t in tables if t.kind == NodeKind.STORAGE_ENTITY]
|
|
69
|
+
|
|
70
|
+
lines = ["erDiagram"]
|
|
71
|
+
for t in tables:
|
|
72
|
+
lines.append(f" {sanitize(t.name)} {{")
|
|
73
|
+
# find fields
|
|
74
|
+
fields = [
|
|
75
|
+
f for f in graph.get_nodes_by_kind(NodeKind.FIELD)
|
|
76
|
+
if f.metadata.get("owner_id") == t.id
|
|
77
|
+
]
|
|
78
|
+
if not fields:
|
|
79
|
+
lines.append(" string id PK")
|
|
80
|
+
else:
|
|
81
|
+
for f in fields:
|
|
82
|
+
pk = "PK" if f.metadata.get("is_pk") else ""
|
|
83
|
+
fk = "FK" if f.metadata.get("is_fk") else ""
|
|
84
|
+
tag = ",".join(filter(None, [pk, fk]))
|
|
85
|
+
dtype = f.metadata.get("data_type", "string")
|
|
86
|
+
lines.append(f" {dtype} {f.name} {tag}")
|
|
87
|
+
lines.append(" }")
|
|
88
|
+
|
|
89
|
+
for e in graph.get_all_edges():
|
|
90
|
+
if e.type == "REFERENCES_FK" or getattr(e.type, "value", None) == "REFERENCES_FK":
|
|
91
|
+
s = graph.get_node(e.source)
|
|
92
|
+
t = graph.get_node(e.target)
|
|
93
|
+
if s and t:
|
|
94
|
+
lines.append(f' {sanitize(s.name)} }}o--|| {sanitize(t.name)} : "references"')
|
|
95
|
+
|
|
96
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Real-world AST Scanner for Showreel (Drizzle ORM + NestJS Backend + Next.js Frontend)
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
from .graph import ArchitectureGraph
|
|
11
|
+
from .identity import NodeIdentifier, normalize_route_path
|
|
12
|
+
from .models import (
|
|
13
|
+
ArchitectureEdge,
|
|
14
|
+
ArchitectureNode,
|
|
15
|
+
Confidence,
|
|
16
|
+
EdgeEvidence,
|
|
17
|
+
EdgeType,
|
|
18
|
+
EvidenceLocation,
|
|
19
|
+
NodeKind,
|
|
20
|
+
Resolution,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ShowreelScanner:
|
|
25
|
+
"""
|
|
26
|
+
Scans full-stack Showreel architecture:
|
|
27
|
+
Frontend (Next.js/TanStack) -> Backend (NestJS) -> Schema (Drizzle).
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, workspace_root: str = r"Z:\projects\showreel") -> None:
|
|
31
|
+
self.workspace_root = Path(workspace_root)
|
|
32
|
+
self.frontend_root = self.workspace_root / "rfc_location_booking_frontend"
|
|
33
|
+
self.backend_root = self.workspace_root / "rfc_location_booking_backend"
|
|
34
|
+
|
|
35
|
+
def scan_all(self, graph: ArchitectureGraph, domain: Optional[str] = "prop-management") -> None:
|
|
36
|
+
# 1. Scan Drizzle DB Schemas
|
|
37
|
+
self.scan_drizzle_schemas(graph, domain)
|
|
38
|
+
# 2. Scan NestJS Controllers & Services
|
|
39
|
+
self.scan_nestjs_backend(graph, domain)
|
|
40
|
+
# 3. Scan Next.js Features & React Query Hooks
|
|
41
|
+
self.scan_nextjs_frontend(graph, domain)
|
|
42
|
+
|
|
43
|
+
def scan_drizzle_schemas(self, graph: ArchitectureGraph, domain: Optional[str] = None) -> None:
|
|
44
|
+
schema_dir = self.backend_root / "src" / "database" / "schema"
|
|
45
|
+
if not schema_dir.exists():
|
|
46
|
+
return
|
|
47
|
+
|
|
48
|
+
schema_files = list(schema_dir.glob("*.ts"))
|
|
49
|
+
table_var_map: Dict[str, str] = {} # varName -> tableNodeId
|
|
50
|
+
|
|
51
|
+
for sfile in schema_files:
|
|
52
|
+
try:
|
|
53
|
+
content = sfile.read_text(encoding="utf-8")
|
|
54
|
+
except Exception:
|
|
55
|
+
continue
|
|
56
|
+
|
|
57
|
+
rel_path = str(sfile.relative_to(self.workspace_root))
|
|
58
|
+
|
|
59
|
+
# Match: export const <varName> = pgTable('tableName', { ... })
|
|
60
|
+
# or export const <varName> = publicSchema.table('tableName', { ... })
|
|
61
|
+
table_matches = re.finditer(
|
|
62
|
+
r"export\s+const\s+([a-zA-Z0-9_]+)\s*=\s*(?:pgTable|[a-zA-Z0-9_]+\.table)\s*\(\s*['\"]([^'\"]+)['\"]",
|
|
63
|
+
content,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
for m in table_matches:
|
|
67
|
+
var_name = m.group(1)
|
|
68
|
+
table_name = m.group(2)
|
|
69
|
+
node_id = NodeIdentifier.for_storage_entity("public", table_name)
|
|
70
|
+
|
|
71
|
+
table_node = ArchitectureNode(
|
|
72
|
+
id=node_id,
|
|
73
|
+
kind=NodeKind.STORAGE_ENTITY,
|
|
74
|
+
name=table_name,
|
|
75
|
+
file_path=rel_path,
|
|
76
|
+
domain=domain or "prop-management",
|
|
77
|
+
metadata={"var_name": var_name, "orm": "drizzle"},
|
|
78
|
+
)
|
|
79
|
+
graph.add_node(table_node)
|
|
80
|
+
table_var_map[var_name] = node_id
|
|
81
|
+
|
|
82
|
+
# Parse foreign keys: .references(() => otherTable.id)
|
|
83
|
+
fk_matches = re.finditer(
|
|
84
|
+
r"export\s+const\s+([a-zA-Z0-9_]+)\s*=\s*(?:pgTable|[a-zA-Z0-9_]+\.table).*?\.references\(\s*\(\)\s*=>\s*([a-zA-Z0-9_]+)\.",
|
|
85
|
+
content,
|
|
86
|
+
re.DOTALL,
|
|
87
|
+
)
|
|
88
|
+
for fk in fk_matches:
|
|
89
|
+
src_var = fk.group(1)
|
|
90
|
+
tgt_var = fk.group(2)
|
|
91
|
+
if src_var in table_var_map and tgt_var in table_var_map and src_var != tgt_var:
|
|
92
|
+
src_id = table_var_map[src_var]
|
|
93
|
+
tgt_id = table_var_map[tgt_var]
|
|
94
|
+
edge_id = NodeIdentifier.for_edge(src_id, "REFERENCES_FK", tgt_id)
|
|
95
|
+
if not graph.get_edge(edge_id):
|
|
96
|
+
graph.add_edge(
|
|
97
|
+
ArchitectureEdge(
|
|
98
|
+
id=edge_id,
|
|
99
|
+
source=src_id,
|
|
100
|
+
target=tgt_id,
|
|
101
|
+
type=EdgeType.REFERENCES_FK,
|
|
102
|
+
confidence=Confidence.HIGH,
|
|
103
|
+
resolution=Resolution.STATIC,
|
|
104
|
+
evidence=EdgeEvidence(
|
|
105
|
+
rule_id="DRIZZLE_FK_REFERENCES",
|
|
106
|
+
locations=[EvidenceLocation(file_path=rel_path)],
|
|
107
|
+
explanation=f"FK: {src_var} -> {tgt_var}",
|
|
108
|
+
),
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
def scan_nestjs_backend(self, graph: ArchitectureGraph, domain: Optional[str] = None) -> None:
|
|
113
|
+
modules_dir = self.backend_root / "src" / "modules"
|
|
114
|
+
if not modules_dir.exists():
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
for ctrl_file in modules_dir.rglob("*controller.ts"):
|
|
118
|
+
try:
|
|
119
|
+
content = ctrl_file.read_text(encoding="utf-8")
|
|
120
|
+
except Exception:
|
|
121
|
+
continue
|
|
122
|
+
|
|
123
|
+
rel_path = str(ctrl_file.relative_to(self.workspace_root))
|
|
124
|
+
|
|
125
|
+
# Match @Controller('prefix')
|
|
126
|
+
prefix_match = re.search(r"@Controller\(\s*['\"]([^'\"]*)['\"]\s*\)", content)
|
|
127
|
+
base_prefix = prefix_match.group(1) if prefix_match else ""
|
|
128
|
+
if not base_prefix.startswith("/"):
|
|
129
|
+
base_prefix = "/" + base_prefix
|
|
130
|
+
|
|
131
|
+
# Match routes: @Get(':id'), @Post(), etc.
|
|
132
|
+
method_matches = re.finditer(
|
|
133
|
+
r"@(Get|Post|Put|Patch|Delete)\(\s*(?:['\"]([^'\"]*)['\"])?\s*\)\s*(?:async\s+)?([a-zA-Z0-9_]+)\s*\(",
|
|
134
|
+
content,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
for mm in method_matches:
|
|
138
|
+
http_method = mm.group(1).upper()
|
|
139
|
+
sub_path = mm.group(2) or ""
|
|
140
|
+
fn_name = mm.group(3)
|
|
141
|
+
|
|
142
|
+
full_path = f"{base_prefix}/{sub_path}".replace("//", "/")
|
|
143
|
+
# Showreel uses standard api prefix (e.g. /api/v1/prop/...)
|
|
144
|
+
norm_route_id = NodeIdentifier.for_ingress_route(http_method, full_path)
|
|
145
|
+
|
|
146
|
+
route_node = ArchitectureNode(
|
|
147
|
+
id=norm_route_id,
|
|
148
|
+
kind=NodeKind.INGRESS_ROUTE,
|
|
149
|
+
name=f"{http_method} {full_path}",
|
|
150
|
+
file_path=rel_path,
|
|
151
|
+
domain=domain or "prop-management",
|
|
152
|
+
metadata={"handler": fn_name, "method": http_method, "path": full_path},
|
|
153
|
+
)
|
|
154
|
+
graph.add_node(route_node)
|
|
155
|
+
|
|
156
|
+
# Link to Service in same module if called
|
|
157
|
+
svc_call_match = re.search(r"this\.([a-zA-Z0-9_]+Service)\.([a-zA-Z0-9_]+)\(", content)
|
|
158
|
+
if svc_call_match:
|
|
159
|
+
svc_class = svc_call_match.group(1)
|
|
160
|
+
svc_method = svc_call_match.group(2)
|
|
161
|
+
svc_id = NodeIdentifier.for_domain_logic("showreel", rel_path, svc_class, svc_method)
|
|
162
|
+
if not graph.has_node(svc_id):
|
|
163
|
+
graph.add_node(
|
|
164
|
+
ArchitectureNode(
|
|
165
|
+
id=svc_id,
|
|
166
|
+
kind=NodeKind.DOMAIN_LOGIC,
|
|
167
|
+
name=f"{svc_class}.{svc_method}",
|
|
168
|
+
file_path=rel_path,
|
|
169
|
+
domain=domain or "prop-management",
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
edge_id = NodeIdentifier.for_edge(norm_route_id, "CALLS", svc_id)
|
|
173
|
+
if not graph.get_edge(edge_id):
|
|
174
|
+
graph.add_edge(
|
|
175
|
+
ArchitectureEdge(
|
|
176
|
+
id=edge_id,
|
|
177
|
+
source=norm_route_id,
|
|
178
|
+
target=svc_id,
|
|
179
|
+
type=EdgeType.CALLS,
|
|
180
|
+
confidence=Confidence.HIGH,
|
|
181
|
+
resolution=Resolution.STATIC,
|
|
182
|
+
evidence=EdgeEvidence(
|
|
183
|
+
rule_id="NESTJS_CONTROLLER_SERVICE_CALL",
|
|
184
|
+
locations=[EvidenceLocation(file_path=rel_path, symbol=fn_name)],
|
|
185
|
+
),
|
|
186
|
+
)
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
def scan_nextjs_frontend(self, graph: ArchitectureGraph, domain: Optional[str] = None) -> None:
|
|
190
|
+
features_dir = self.frontend_root / "features"
|
|
191
|
+
if not features_dir.exists():
|
|
192
|
+
return
|
|
193
|
+
|
|
194
|
+
for hook_file in features_dir.rglob("*.ts*"):
|
|
195
|
+
try:
|
|
196
|
+
content = hook_file.read_text(encoding="utf-8")
|
|
197
|
+
except Exception:
|
|
198
|
+
continue
|
|
199
|
+
|
|
200
|
+
rel_path = str(hook_file.relative_to(self.workspace_root))
|
|
201
|
+
|
|
202
|
+
# Match TanStack Query hooks: export function useEstimationQuery or const useX =
|
|
203
|
+
hook_match = re.search(r"export\s+(?:function|const)\s+(use[a-zA-Z0-9_]+)", content)
|
|
204
|
+
if not hook_match:
|
|
205
|
+
continue
|
|
206
|
+
|
|
207
|
+
hook_name = hook_match.group(1)
|
|
208
|
+
hook_id = NodeIdentifier.for_client_transport("showreel", rel_path, hook_name)
|
|
209
|
+
|
|
210
|
+
# Look for endpoint pattern: e.g. /api/v1/prop/estimations or axios.get('/api/...')
|
|
211
|
+
endpoint_match = re.search(r"['\"](\/api\/[a-zA-Z0-9_\-\/]+(?:\$\{[^}]+\})?)['\"]", content)
|
|
212
|
+
endpoint_url = endpoint_match.group(1) if endpoint_match else None
|
|
213
|
+
|
|
214
|
+
client_node = ArchitectureNode(
|
|
215
|
+
id=hook_id,
|
|
216
|
+
kind=NodeKind.CLIENT_TRANSPORT,
|
|
217
|
+
name=hook_name,
|
|
218
|
+
file_path=rel_path,
|
|
219
|
+
domain=domain or "prop-management",
|
|
220
|
+
metadata={"endpoint": endpoint_url},
|
|
221
|
+
)
|
|
222
|
+
graph.add_node(client_node)
|
|
223
|
+
|
|
224
|
+
# Match with backend route if endpoint matches
|
|
225
|
+
if endpoint_url:
|
|
226
|
+
norm_route_id = NodeIdentifier.for_ingress_route("GET", endpoint_url)
|
|
227
|
+
# Check if route exists or starts with pattern
|
|
228
|
+
for rnode in graph.get_nodes_by_kind(NodeKind.INGRESS_ROUTE):
|
|
229
|
+
r_path = rnode.metadata.get("path", "")
|
|
230
|
+
if normalize_route_path(r_path) == normalize_route_path(endpoint_url) or (
|
|
231
|
+
r_path and r_path in endpoint_url
|
|
232
|
+
):
|
|
233
|
+
edge_id = NodeIdentifier.for_edge(hook_id, "ROUTES_TO", rnode.id)
|
|
234
|
+
if not graph.get_edge(edge_id):
|
|
235
|
+
graph.add_edge(
|
|
236
|
+
ArchitectureEdge(
|
|
237
|
+
id=edge_id,
|
|
238
|
+
source=hook_id,
|
|
239
|
+
target=rnode.id,
|
|
240
|
+
type=EdgeType.ROUTES_TO,
|
|
241
|
+
confidence=Confidence.HIGH,
|
|
242
|
+
resolution=Resolution.INFERRED,
|
|
243
|
+
evidence=EdgeEvidence(
|
|
244
|
+
rule_id="HTTP_CLIENT_ROUTE_MATCH",
|
|
245
|
+
locations=[EvidenceLocation(file_path=rel_path, symbol=hook_name)],
|
|
246
|
+
explanation=f"Matched {endpoint_url} to backend {r_path}",
|
|
247
|
+
),
|
|
248
|
+
)
|
|
249
|
+
)
|
|
250
|
+
break
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Server definition for mcp-win-stdio-arch (FastMCP)
|
|
3
|
+
Part of mcp-win-stdio.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
from typing import Any, Dict, Optional
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
from mcp.server.mcpserver import MCPServer as FastMCP
|
|
11
|
+
except (ImportError, ModuleNotFoundError):
|
|
12
|
+
from mcp.server.fastmcp import FastMCP # type: ignore[import-not-found]
|
|
13
|
+
|
|
14
|
+
from .graph import ArchitectureGraph
|
|
15
|
+
from .models import NodeKind
|
|
16
|
+
from .renderers import MermaidRenderer
|
|
17
|
+
from .scanner import ShowreelScanner
|
|
18
|
+
|
|
19
|
+
mcp = FastMCP("arch-mcp")
|
|
20
|
+
|
|
21
|
+
# In-memory graph cache
|
|
22
|
+
_GRAPH = ArchitectureGraph()
|
|
23
|
+
_SCANNED_ROOT: Optional[str] = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _ensure_graph(workspace_root: str = r"Z:\projects\showreel") -> ArchitectureGraph:
|
|
27
|
+
global _GRAPH, _SCANNED_ROOT
|
|
28
|
+
if _SCANNED_ROOT != workspace_root or _GRAPH.stats()["node_count"] == 0:
|
|
29
|
+
_GRAPH.clear()
|
|
30
|
+
scanner = ShowreelScanner(workspace_root)
|
|
31
|
+
scanner.scan_all(_GRAPH)
|
|
32
|
+
_SCANNED_ROOT = workspace_root
|
|
33
|
+
return _GRAPH
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@mcp.tool()
|
|
37
|
+
def blueprint_feature(
|
|
38
|
+
domain: str = "prop-management",
|
|
39
|
+
workspace_root: str = r"Z:\projects\showreel",
|
|
40
|
+
) -> Dict[str, Any]:
|
|
41
|
+
"""
|
|
42
|
+
Generates a full-stack vertical architecture blueprint for a domain feature.
|
|
43
|
+
Connects: UI Views -> TanStack Hooks -> NestJS Controllers -> Services -> Drizzle DB Tables.
|
|
44
|
+
Returns: Mermaid flowchart and structured node/edge statistics.
|
|
45
|
+
"""
|
|
46
|
+
graph = _ensure_graph(workspace_root)
|
|
47
|
+
nodes = graph.get_nodes_by_domain(domain)
|
|
48
|
+
if not nodes:
|
|
49
|
+
nodes = graph.get_all_nodes()
|
|
50
|
+
|
|
51
|
+
mermaid = MermaidRenderer.render_flowchart(graph, nodes=nodes)
|
|
52
|
+
return {
|
|
53
|
+
"domain": domain,
|
|
54
|
+
"mermaid": mermaid,
|
|
55
|
+
"stats": graph.stats(),
|
|
56
|
+
"node_sample": [n.name for n in nodes[:15]],
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@mcp.tool()
|
|
61
|
+
def blueprint_erd(
|
|
62
|
+
domain: Optional[str] = "prop-management",
|
|
63
|
+
workspace_root: str = r"Z:\projects\showreel",
|
|
64
|
+
) -> Dict[str, Any]:
|
|
65
|
+
"""
|
|
66
|
+
Generates an Entity-Relationship Diagram (ERD) with tables, columns, and foreign keys.
|
|
67
|
+
"""
|
|
68
|
+
graph = _ensure_graph(workspace_root)
|
|
69
|
+
mermaid_erd = MermaidRenderer.render_erd(graph, domain=domain)
|
|
70
|
+
tables = [
|
|
71
|
+
n.name for n in graph.get_nodes_by_kind(NodeKind.STORAGE_ENTITY)
|
|
72
|
+
if not domain or n.domain == domain
|
|
73
|
+
]
|
|
74
|
+
return {
|
|
75
|
+
"domain": domain,
|
|
76
|
+
"mermaid_erd": mermaid_erd,
|
|
77
|
+
"tables_count": len(tables),
|
|
78
|
+
"tables": tables,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@mcp.tool()
|
|
83
|
+
def blueprint_trace(
|
|
84
|
+
target: str,
|
|
85
|
+
workspace_root: str = r"Z:\projects\showreel",
|
|
86
|
+
) -> Dict[str, Any]:
|
|
87
|
+
"""
|
|
88
|
+
Traces the ordered execution path starting from a component, hook, or endpoint down to DB tables.
|
|
89
|
+
"""
|
|
90
|
+
graph = _ensure_graph(workspace_root)
|
|
91
|
+
paths = graph.trace_paths(target, target_kind=NodeKind.STORAGE_ENTITY)
|
|
92
|
+
return {
|
|
93
|
+
"target": target,
|
|
94
|
+
"paths_found": len(paths),
|
|
95
|
+
"paths": paths,
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@mcp.tool()
|
|
100
|
+
def blueprint_impact(
|
|
101
|
+
node_id: str,
|
|
102
|
+
direction: str = "upstream",
|
|
103
|
+
workspace_root: str = r"Z:\projects\showreel",
|
|
104
|
+
) -> Dict[str, Any]:
|
|
105
|
+
"""
|
|
106
|
+
Calculates the architectural blast radius before refactoring a table, column, or service.
|
|
107
|
+
"""
|
|
108
|
+
graph = _ensure_graph(workspace_root)
|
|
109
|
+
return graph.compute_blast_radius(node_id, direction=direction)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@mcp.tool()
|
|
113
|
+
def blueprint_audit(
|
|
114
|
+
workspace_root: str = r"Z:\projects\showreel",
|
|
115
|
+
) -> Dict[str, Any]:
|
|
116
|
+
"""
|
|
117
|
+
Audits the architecture for broken routes, unindexed foreign keys, or layer leaks.
|
|
118
|
+
"""
|
|
119
|
+
graph = _ensure_graph(workspace_root)
|
|
120
|
+
diagnostics = [
|
|
121
|
+
{
|
|
122
|
+
"code": d.code,
|
|
123
|
+
"severity": d.severity,
|
|
124
|
+
"message": d.message,
|
|
125
|
+
"location": d.location.file_path if d.location else None,
|
|
126
|
+
}
|
|
127
|
+
for d in graph.get_diagnostics()
|
|
128
|
+
]
|
|
129
|
+
return {
|
|
130
|
+
"diagnostics_count": len(diagnostics),
|
|
131
|
+
"diagnostics": diagnostics,
|
|
132
|
+
"stats": graph.stats(),
|
|
133
|
+
}
|