graph-dependency-analyzer 0.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.
- graph_dependency_analyzer-0.2.0.dist-info/METADATA +158 -0
- graph_dependency_analyzer-0.2.0.dist-info/RECORD +86 -0
- graph_dependency_analyzer-0.2.0.dist-info/WHEEL +5 -0
- graph_dependency_analyzer-0.2.0.dist-info/entry_points.txt +2 -0
- graph_dependency_analyzer-0.2.0.dist-info/top_level.txt +1 -0
- src/__init__.py +0 -0
- src/application/__init__.py +0 -0
- src/application/dtos/__init__.py +0 -0
- src/application/dtos/affected_component.py +59 -0
- src/application/dtos/batch_processing_result.py +17 -0
- src/application/dtos/impact_analysis_response.py +56 -0
- src/application/dtos/impact_query_request.py +38 -0
- src/application/dtos/index_batch_request.py +53 -0
- src/application/dtos/index_batch_response.py +72 -0
- src/application/dtos/index_result.py +20 -0
- src/application/dtos/repository_index_result.py +79 -0
- src/application/exceptions.py +41 -0
- src/application/services/__init__.py +0 -0
- src/application/services/file_scanner_service.py +258 -0
- src/application/services/progress_tracker.py +132 -0
- src/application/use_cases/__init__.py +0 -0
- src/application/use_cases/clear_all_data_use_case.py +180 -0
- src/application/use_cases/index_batch_use_case.py +153 -0
- src/application/use_cases/index_repository_use_case.py +683 -0
- src/application/use_cases/indexing_orchestrator.py +181 -0
- src/application/use_cases/list_repositories_use_case.py +84 -0
- src/cli.py +512 -0
- src/config/__init__.py +5 -0
- src/config/container.py +211 -0
- src/config/logging_config.py +123 -0
- src/config/settings.py +165 -0
- src/domain/__init__.py +0 -0
- src/domain/entities/__init__.py +20 -0
- src/domain/entities/ast_node.py +32 -0
- src/domain/entities/code_chunk.py +45 -0
- src/domain/entities/code_file.py +44 -0
- src/domain/entities/dependency_graph.py +110 -0
- src/domain/entities/repository.py +46 -0
- src/domain/exceptions.py +36 -0
- src/domain/repositories/__init__.py +22 -0
- src/domain/repositories/i_code_parser.py +50 -0
- src/domain/repositories/i_dependency_extractor.py +35 -0
- src/domain/repositories/i_embedding_provider.py +31 -0
- src/domain/repositories/i_graph_repository.py +78 -0
- src/domain/repositories/i_vector_repository.py +55 -0
- src/domain/services/__init__.py +8 -0
- src/domain/services/dependency_extractor.py +962 -0
- src/domain/services/semantic_chunk_builder.py +719 -0
- src/domain/value_objects/__init__.py +12 -0
- src/domain/value_objects/chunk_type.py +24 -0
- src/domain/value_objects/dependency_type.py +22 -0
- src/domain/value_objects/node_type.py +22 -0
- src/domain/value_objects/qualified_name.py +78 -0
- src/infrastructure/__init__.py +0 -0
- src/infrastructure/exceptions.py +41 -0
- src/infrastructure/external_apis/__init__.py +0 -0
- src/infrastructure/external_apis/ollama_embedding_provider.py +134 -0
- src/infrastructure/external_apis/openai_embedding_provider.py +231 -0
- src/infrastructure/parsing/__init__.py +20 -0
- src/infrastructure/parsing/config_file_parser.py +347 -0
- src/infrastructure/parsing/gradle_dependency_parser.py +159 -0
- src/infrastructure/parsing/groovy_parser.py +198 -0
- src/infrastructure/parsing/maven_dependency_parser.py +147 -0
- src/infrastructure/parsing/microfrontend_config_parser.py +242 -0
- src/infrastructure/parsing/npm_package_dependency_parser.py +255 -0
- src/infrastructure/parsing/python_parser.py +211 -0
- src/infrastructure/parsing/sql_schema_parser.py +658 -0
- src/infrastructure/parsing/tree_sitter_java_parser.py +434 -0
- src/infrastructure/parsing/tree_sitter_typescript_parser.py +600 -0
- src/infrastructure/persistence/__init__.py +0 -0
- src/infrastructure/persistence/graph_export_repository.py +443 -0
- src/infrastructure/persistence/neo4j_dependency_repository.py +657 -0
- src/infrastructure/persistence/qdrant_vector_repository.py +357 -0
- src/infrastructure/persistence/repository_relationship_repository.py +1312 -0
- src/presentation/__init__.py +0 -0
- src/presentation/api/__init__.py +0 -0
- src/presentation/api/rest/__init__.py +0 -0
- src/presentation/api/rest/exception_handlers.py +174 -0
- src/presentation/api/rest/main.py +80 -0
- src/presentation/api/rest/routers/__init__.py +5 -0
- src/presentation/api/rest/routers/admin.py +103 -0
- src/presentation/api/rest/routers/analysis.py +980 -0
- src/presentation/api/rest/routers/export.py +442 -0
- src/presentation/api/rest/routers/health.py +117 -0
- src/presentation/api/rest/routers/indexing.py +148 -0
- src/presentation/api/rest/routers/relationships.py +1089 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Groovy / Jenkinsfile parser — extracts pipeline definitions, shared library
|
|
3
|
+
functions, stage names, and library references.
|
|
4
|
+
|
|
5
|
+
Handles:
|
|
6
|
+
- Jenkins Shared Library vars/*.groovy (def call(...) functions)
|
|
7
|
+
- Jenkinsfile / pipeline_config.groovy (pipeline{}, libraries{}, stages)
|
|
8
|
+
- JTE (Jenkins Templating Engine) configs
|
|
9
|
+
|
|
10
|
+
Extracts:
|
|
11
|
+
- Function definitions (def call, def myFunc)
|
|
12
|
+
- Library references (libraries { helm_deploy() }, @Library('name'))
|
|
13
|
+
- Stage names (stage('Build') { ... })
|
|
14
|
+
- Shell commands with service/image names
|
|
15
|
+
- Environment variables and credentials references
|
|
16
|
+
- evaluate(new File("other.groovy")) — local lib imports
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from src.domain.repositories.i_code_parser import ParseResult
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
# Patterns
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
# def call(Map config) / def call() / async def myFunc(x)
|
|
31
|
+
_RE_DEF = re.compile(r'\bdef\s+(\w+)\s*\(', re.M)
|
|
32
|
+
|
|
33
|
+
# @Library('jenkins-shared-library') / @Library(['lib1','lib2'])
|
|
34
|
+
_RE_AT_LIBRARY = re.compile(r"@Library\s*\(\s*['\"]([^'\"]+)['\"]", re.I)
|
|
35
|
+
|
|
36
|
+
# libraries { helm_deploy() } — JTE style
|
|
37
|
+
_RE_LIBRARIES_BLOCK = re.compile(r'libraries\s*\{([^}]{0,2000})\}', re.S)
|
|
38
|
+
_RE_LIBRARY_ENTRY = re.compile(r'(\w[\w-]*)\s*\{', re.M) # name { ... }
|
|
39
|
+
|
|
40
|
+
# stage('Build') { ... }
|
|
41
|
+
_RE_STAGE = re.compile(r"stage\s*\(\s*['\"]([^'\"]+)['\"]", re.I)
|
|
42
|
+
|
|
43
|
+
# evaluate(new File("other.groovy"))
|
|
44
|
+
_RE_EVALUATE = re.compile(r'evaluate\s*\(\s*new\s+File\s*\(\s*["\']([^"\']+)["\']', re.I)
|
|
45
|
+
|
|
46
|
+
# pipeline_template = "Jenkinsfile_services_ci"
|
|
47
|
+
_RE_TEMPLATE = re.compile(r'pipeline_template\s*=\s*["\']([^"\']+)["\']', re.I)
|
|
48
|
+
|
|
49
|
+
# sh "docker build ... service-name ..." — extract image/service hints
|
|
50
|
+
_RE_SH_IMAGE = re.compile(r'\bsh\s+["\'].*?([\w-]+-service[\w-]*|[\w-]+-app[\w-]*)["\']', re.I)
|
|
51
|
+
|
|
52
|
+
# credentialsId: "${config.xxx}" or 'my-credential-id'
|
|
53
|
+
_RE_CRED = re.compile(r'credentialsId\s*[=:]\s*["\']([^"\'$]{3,60})["\']', re.I)
|
|
54
|
+
|
|
55
|
+
# withCredentials, withAWS, azureKeyVault — external service hints
|
|
56
|
+
_RE_EXT_SERVICE = re.compile(
|
|
57
|
+
r'\b(withCredentials|azureKeyVault|withAWS|dockerRegistry|artifactory|helm|kubectl|az\s+\w+)\s*[\(\{]',
|
|
58
|
+
re.I,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _line_of(content: str, pos: int) -> int:
|
|
63
|
+
return content[:pos].count('\n') + 1
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class GroovyParser:
|
|
67
|
+
"""Parser for Groovy/Jenkinsfile source files."""
|
|
68
|
+
|
|
69
|
+
GROOVY_EXTENSIONS = {'.groovy', '.Jenkinsfile', ''} # empty for bare 'Jenkinsfile'
|
|
70
|
+
|
|
71
|
+
def parse_file(self, file_path: Path) -> ParseResult:
|
|
72
|
+
suffix = file_path.suffix.lower()
|
|
73
|
+
name = file_path.name
|
|
74
|
+
|
|
75
|
+
# Accept .groovy files and bare Jenkinsfile / pipeline_config
|
|
76
|
+
is_groovy = (
|
|
77
|
+
suffix == '.groovy'
|
|
78
|
+
or name in ('Jenkinsfile', 'Jenkinsfile_ci', 'Jenkinsfile_cd',
|
|
79
|
+
'pipeline_config', 'pipeline_properties')
|
|
80
|
+
or name.startswith('Jenkinsfile')
|
|
81
|
+
)
|
|
82
|
+
if not is_groovy:
|
|
83
|
+
return ParseResult(file_path=file_path)
|
|
84
|
+
|
|
85
|
+
if not file_path.exists():
|
|
86
|
+
return ParseResult(file_path=file_path)
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
content = file_path.read_text(encoding='utf-8', errors='replace')
|
|
90
|
+
except Exception:
|
|
91
|
+
return ParseResult(file_path=file_path)
|
|
92
|
+
|
|
93
|
+
# ── Module name ────────────────────────────────────────────────────
|
|
94
|
+
stem = file_path.stem
|
|
95
|
+
module_name = stem
|
|
96
|
+
|
|
97
|
+
# ── Classes = pipeline definitions / shared-lib files ─────────────
|
|
98
|
+
classes = []
|
|
99
|
+
|
|
100
|
+
# One class per file representing the pipeline/lib file itself
|
|
101
|
+
classes.append({
|
|
102
|
+
'name': stem,
|
|
103
|
+
'qualified_name': module_name,
|
|
104
|
+
'node_type': 'CLASS',
|
|
105
|
+
'line': 1,
|
|
106
|
+
'metadata': {
|
|
107
|
+
'language': 'groovy',
|
|
108
|
+
'file_type': 'jenkinsfile' if 'Jenkinsfile' in name else 'shared_lib',
|
|
109
|
+
'file_path': str(file_path),
|
|
110
|
+
}
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
# ── Methods = def call / def xxx ───────────────────────────────────
|
|
114
|
+
methods = []
|
|
115
|
+
for m in _RE_DEF.finditer(content):
|
|
116
|
+
fn = m.group(1)
|
|
117
|
+
methods.append({
|
|
118
|
+
'name': fn,
|
|
119
|
+
'qualified_name': f"{module_name}.{fn}",
|
|
120
|
+
'node_type': 'METHOD',
|
|
121
|
+
'line': _line_of(content, m.start()),
|
|
122
|
+
'metadata': {
|
|
123
|
+
'language': 'groovy',
|
|
124
|
+
'file_path': str(file_path),
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
# ── Imports = @Library + evaluate() + libraries{} block ───────────
|
|
129
|
+
imports = []
|
|
130
|
+
seen: set[str] = set()
|
|
131
|
+
|
|
132
|
+
def add_import(lib: str, line: int, source: str):
|
|
133
|
+
lib = lib.strip()
|
|
134
|
+
if lib and lib not in seen:
|
|
135
|
+
seen.add(lib)
|
|
136
|
+
imports.append({
|
|
137
|
+
'import_path': lib,
|
|
138
|
+
'line': line,
|
|
139
|
+
'metadata': {'source': source},
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
# @Library('jenkins-shared-library')
|
|
143
|
+
for m in _RE_AT_LIBRARY.finditer(content):
|
|
144
|
+
add_import(m.group(1), _line_of(content, m.start()), '@Library')
|
|
145
|
+
|
|
146
|
+
# evaluate(new File("serviceUtil.groovy"))
|
|
147
|
+
for m in _RE_EVALUATE.finditer(content):
|
|
148
|
+
lib = m.group(1).replace('.groovy', '')
|
|
149
|
+
add_import(lib, _line_of(content, m.start()), 'evaluate')
|
|
150
|
+
|
|
151
|
+
# libraries { helm_deploy { } docker_build { } }
|
|
152
|
+
for block in _RE_LIBRARIES_BLOCK.finditer(content):
|
|
153
|
+
block_text = block.group(1)
|
|
154
|
+
for entry in _RE_LIBRARY_ENTRY.finditer(block_text):
|
|
155
|
+
name_candidate = entry.group(1)
|
|
156
|
+
if name_candidate not in ('jte', 'pipeline', 'stage', 'steps', 'post', 'environment'):
|
|
157
|
+
add_import(name_candidate, _line_of(content, block.start()), 'libraries{}')
|
|
158
|
+
|
|
159
|
+
# ── Method calls = stages + external services ─────────────────────
|
|
160
|
+
method_calls = []
|
|
161
|
+
|
|
162
|
+
# Stage names → useful for understanding pipeline structure
|
|
163
|
+
for m in _RE_STAGE.finditer(content):
|
|
164
|
+
method_calls.append({
|
|
165
|
+
'callee': f"stage.{m.group(1).replace(' ','_')}",
|
|
166
|
+
'line': _line_of(content, m.start()),
|
|
167
|
+
'metadata': {'stage_name': m.group(1)},
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
# pipeline_template reference
|
|
171
|
+
for m in _RE_TEMPLATE.finditer(content):
|
|
172
|
+
method_calls.append({
|
|
173
|
+
'callee': f"pipeline_template.{m.group(1)}",
|
|
174
|
+
'is_api_call': False,
|
|
175
|
+
'line': _line_of(content, m.start()),
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
# External service hints (Azure, Helm, kubectl, Artifactory)
|
|
179
|
+
for m in _RE_EXT_SERVICE.finditer(content):
|
|
180
|
+
method_calls.append({
|
|
181
|
+
'callee': m.group(1).strip(),
|
|
182
|
+
'line': _line_of(content, m.start()),
|
|
183
|
+
'metadata': {'ext_service': True},
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
# ── String literals (credentials, images) ─────────────────────────
|
|
187
|
+
string_literals: list[str] = []
|
|
188
|
+
for m in _RE_CRED.finditer(content):
|
|
189
|
+
string_literals.append(f"credential:{m.group(1)}")
|
|
190
|
+
|
|
191
|
+
return ParseResult(
|
|
192
|
+
file_path=file_path,
|
|
193
|
+
classes=classes,
|
|
194
|
+
methods=methods,
|
|
195
|
+
imports=imports,
|
|
196
|
+
method_calls=method_calls,
|
|
197
|
+
string_literals=string_literals,
|
|
198
|
+
)
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Maven pom.xml dependency parser using lxml.
|
|
2
|
+
|
|
3
|
+
This module provides parsing capabilities for Maven POM files to extract
|
|
4
|
+
dependency information for the dependency analysis system.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Any, TypedDict
|
|
8
|
+
from lxml import etree
|
|
9
|
+
from src.domain.value_objects.dependency_type import DependencyType
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class MavenDependency(TypedDict):
|
|
13
|
+
"""Type definition for Maven dependency dictionary."""
|
|
14
|
+
|
|
15
|
+
group_id: str
|
|
16
|
+
artifact_id: str
|
|
17
|
+
version: str
|
|
18
|
+
scope: str
|
|
19
|
+
type: DependencyType
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class MavenDependencyParser:
|
|
23
|
+
"""Parser for Maven pom.xml files to extract dependencies.
|
|
24
|
+
|
|
25
|
+
Parses Maven POM files using lxml with proper namespace handling.
|
|
26
|
+
Extracts dependency information: groupId, artifactId, version, scope.
|
|
27
|
+
Filters out test-scoped dependencies by default.
|
|
28
|
+
|
|
29
|
+
Attributes:
|
|
30
|
+
MAVEN_NS: Maven POM namespace URI
|
|
31
|
+
NAMESPACES: Namespace mapping for XPath queries
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
# Maven POM namespace
|
|
35
|
+
MAVEN_NS = "http://maven.apache.org/POM/4.0.0"
|
|
36
|
+
NAMESPACES = {"mvn": MAVEN_NS}
|
|
37
|
+
DEFAULT_SCOPE = "compile"
|
|
38
|
+
MANAGED_VERSION = "managed"
|
|
39
|
+
|
|
40
|
+
def parse(self, pom_content: str) -> list[MavenDependency]:
|
|
41
|
+
"""Parse pom.xml content and extract dependencies.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
pom_content: String content of pom.xml file
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
List of MavenDependency dictionaries with keys:
|
|
48
|
+
- group_id: Maven groupId (e.g., 'org.springframework.boot')
|
|
49
|
+
- artifact_id: Maven artifactId (e.g., 'spring-boot-starter-web')
|
|
50
|
+
- version: Version string or 'managed' if not specified
|
|
51
|
+
- scope: Dependency scope (compile, runtime, provided, test)
|
|
52
|
+
- type: Always DependencyType.LIBRARY
|
|
53
|
+
|
|
54
|
+
Raises:
|
|
55
|
+
etree.XMLSyntaxError: If XML is malformed
|
|
56
|
+
|
|
57
|
+
Note:
|
|
58
|
+
Test-scoped dependencies (scope='test') are filtered out.
|
|
59
|
+
"""
|
|
60
|
+
root = self._parse_xml(pom_content)
|
|
61
|
+
dependency_elements = self._find_dependency_elements(root)
|
|
62
|
+
|
|
63
|
+
dependencies: list[MavenDependency] = []
|
|
64
|
+
|
|
65
|
+
for dep_elem in dependency_elements:
|
|
66
|
+
dependency = self._extract_dependency(dep_elem)
|
|
67
|
+
|
|
68
|
+
# Skip if essential fields are missing or test-scoped
|
|
69
|
+
if dependency is None:
|
|
70
|
+
continue
|
|
71
|
+
|
|
72
|
+
dependencies.append(dependency)
|
|
73
|
+
|
|
74
|
+
return dependencies
|
|
75
|
+
|
|
76
|
+
def _parse_xml(self, pom_content: str) -> etree._Element:
|
|
77
|
+
"""Parse XML content into element tree.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
pom_content: String content of pom.xml file
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
Root element of parsed XML tree
|
|
84
|
+
|
|
85
|
+
Raises:
|
|
86
|
+
etree.XMLSyntaxError: If XML is malformed
|
|
87
|
+
"""
|
|
88
|
+
try:
|
|
89
|
+
return etree.fromstring(pom_content.encode("utf-8"))
|
|
90
|
+
except etree.XMLSyntaxError as e:
|
|
91
|
+
raise etree.XMLSyntaxError(f"Failed to parse pom.xml: {e}") from e
|
|
92
|
+
|
|
93
|
+
def _find_dependency_elements(self, root: etree._Element) -> list[etree._Element]:
|
|
94
|
+
"""Find all dependency elements using XPath.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
root: Root element of parsed XML
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
List of dependency elements
|
|
101
|
+
"""
|
|
102
|
+
return root.xpath("//mvn:dependency", namespaces=self.NAMESPACES)
|
|
103
|
+
|
|
104
|
+
def _extract_dependency(self, dep_elem: etree._Element) -> MavenDependency | None:
|
|
105
|
+
"""Extract dependency information from XML element.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
dep_elem: Dependency XML element
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
MavenDependency dictionary or None if essential fields missing or test-scoped
|
|
112
|
+
"""
|
|
113
|
+
# Extract fields using XPath with namespace
|
|
114
|
+
group_id = self._extract_text(dep_elem, "mvn:groupId")
|
|
115
|
+
artifact_id = self._extract_text(dep_elem, "mvn:artifactId")
|
|
116
|
+
version = self._extract_text(dep_elem, "mvn:version") or self.MANAGED_VERSION
|
|
117
|
+
scope = self._extract_text(dep_elem, "mvn:scope") or self.DEFAULT_SCOPE
|
|
118
|
+
|
|
119
|
+
# Skip if essential fields are missing
|
|
120
|
+
if not group_id or not artifact_id:
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
# Filter out test-scoped dependencies
|
|
124
|
+
if scope == "test":
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
# Build dependency dictionary
|
|
128
|
+
return MavenDependency(
|
|
129
|
+
group_id=group_id,
|
|
130
|
+
artifact_id=artifact_id,
|
|
131
|
+
version=version,
|
|
132
|
+
scope=scope,
|
|
133
|
+
type=DependencyType.LIBRARY,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
def _extract_text(self, elem: etree._Element, xpath: str) -> str | None:
|
|
137
|
+
"""Extract text from XML element using XPath.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
elem: Parent XML element
|
|
141
|
+
xpath: XPath query string
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
Text content or None if element not found
|
|
145
|
+
"""
|
|
146
|
+
result = elem.xpath(xpath, namespaces=self.NAMESPACES)
|
|
147
|
+
return result[0].text if result and result[0].text else None
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""MicrofrontendConfigParser - Parse microfrontend configurations.
|
|
2
|
+
|
|
3
|
+
Detects and extracts webpack module federation and single-spa configurations.
|
|
4
|
+
Creates dependency nodes and edges for graph storage.
|
|
5
|
+
"""
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class MicrofrontendConfigParser:
|
|
12
|
+
"""
|
|
13
|
+
Microfrontend configuration parser.
|
|
14
|
+
|
|
15
|
+
Extracts module federation and single-spa configurations from config files.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, typescript_parser=None):
|
|
19
|
+
"""
|
|
20
|
+
Initialize with optional TypeScript parser (Task 12.3).
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
typescript_parser: TreeSitterTypeScriptParser instance for parsing config files
|
|
24
|
+
"""
|
|
25
|
+
self.typescript_parser = typescript_parser
|
|
26
|
+
|
|
27
|
+
def detect_module_federation(self, file_path: Path) -> dict | None:
|
|
28
|
+
"""
|
|
29
|
+
Detect webpack module federation configuration.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
file_path: Path to webpack config file
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
Dict with exposed modules and remotes, or None if not found
|
|
36
|
+
"""
|
|
37
|
+
if not file_path.exists():
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
content = file_path.read_text(encoding="utf-8")
|
|
41
|
+
|
|
42
|
+
# Simple regex-based detection for ModuleFederationPlugin
|
|
43
|
+
if "ModuleFederationPlugin" not in content:
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
config = {
|
|
47
|
+
"type": "module_federation",
|
|
48
|
+
"name": self._extract_name(content),
|
|
49
|
+
"exposes": self._extract_exposes(content),
|
|
50
|
+
"remotes": self._extract_remotes(content),
|
|
51
|
+
"shared": self._extract_shared(content),
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return config
|
|
55
|
+
|
|
56
|
+
def detect_single_spa(self, file_path: Path) -> dict | None:
|
|
57
|
+
"""
|
|
58
|
+
Detect single-spa configuration.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
file_path: Path to single-spa config file
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
Dict with registered applications, or None if not found
|
|
65
|
+
"""
|
|
66
|
+
if not file_path.exists():
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
content = file_path.read_text(encoding="utf-8")
|
|
70
|
+
|
|
71
|
+
# Simple detection for registerApplication
|
|
72
|
+
if "registerApplication" not in content:
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
config = {
|
|
76
|
+
"type": "single_spa",
|
|
77
|
+
"applications": self._extract_applications(content),
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return config
|
|
81
|
+
|
|
82
|
+
def _extract_name(self, content: str) -> str | None:
|
|
83
|
+
"""Extract module federation name."""
|
|
84
|
+
# name: "app_name"
|
|
85
|
+
match = re.search(r'name:\s*["\']([^"\']+)["\']', content)
|
|
86
|
+
if match:
|
|
87
|
+
return match.group(1)
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
def _extract_exposes(self, content: str) -> dict:
|
|
91
|
+
"""Extract exposed modules from module federation config."""
|
|
92
|
+
exposes = {}
|
|
93
|
+
|
|
94
|
+
# exposes: { "./Component": "./src/Component" }
|
|
95
|
+
exposes_match = re.search(
|
|
96
|
+
r"exposes:\s*\{([^}]+)\}", content, re.DOTALL
|
|
97
|
+
)
|
|
98
|
+
if exposes_match:
|
|
99
|
+
exposes_block = exposes_match.group(1)
|
|
100
|
+
# Extract key-value pairs
|
|
101
|
+
for match in re.finditer(
|
|
102
|
+
r'["\']([^"\']+)["\']\s*:\s*["\']([^"\']+)["\']', exposes_block
|
|
103
|
+
):
|
|
104
|
+
key = match.group(1)
|
|
105
|
+
value = match.group(2)
|
|
106
|
+
exposes[key] = value
|
|
107
|
+
|
|
108
|
+
return exposes
|
|
109
|
+
|
|
110
|
+
def _extract_remotes(self, content: str) -> dict:
|
|
111
|
+
"""Extract remote entries from module federation config."""
|
|
112
|
+
remotes = {}
|
|
113
|
+
|
|
114
|
+
# remotes: { app1: "app1@http://localhost:3001/remoteEntry.js" }
|
|
115
|
+
remotes_match = re.search(
|
|
116
|
+
r"remotes:\s*\{([^}]+)\}", content, re.DOTALL
|
|
117
|
+
)
|
|
118
|
+
if remotes_match:
|
|
119
|
+
remotes_block = remotes_match.group(1)
|
|
120
|
+
for match in re.finditer(
|
|
121
|
+
r'["\']?(\w+)["\']?\s*:\s*["\']([^"\']+)["\']', remotes_block
|
|
122
|
+
):
|
|
123
|
+
key = match.group(1)
|
|
124
|
+
value = match.group(2)
|
|
125
|
+
remotes[key] = value
|
|
126
|
+
|
|
127
|
+
return remotes
|
|
128
|
+
|
|
129
|
+
def _extract_shared(self, content: str) -> list[str]:
|
|
130
|
+
"""Extract shared dependencies."""
|
|
131
|
+
shared = []
|
|
132
|
+
|
|
133
|
+
# shared: ["react", "react-dom"]
|
|
134
|
+
shared_match = re.search(
|
|
135
|
+
r"shared:\s*\[([^\]]+)\]", content, re.DOTALL
|
|
136
|
+
)
|
|
137
|
+
if shared_match:
|
|
138
|
+
shared_block = shared_match.group(1)
|
|
139
|
+
for match in re.finditer(r'["\']([^"\']+)["\']', shared_block):
|
|
140
|
+
shared.append(match.group(1))
|
|
141
|
+
|
|
142
|
+
return shared
|
|
143
|
+
|
|
144
|
+
def _extract_applications(self, content: str) -> list[dict]:
|
|
145
|
+
"""Extract registered applications from single-spa config."""
|
|
146
|
+
applications = []
|
|
147
|
+
|
|
148
|
+
# registerApplication({ name: "app1", app: () => import("app1"), activeWhen: "/" })
|
|
149
|
+
for match in re.finditer(
|
|
150
|
+
r'registerApplication\s*\(\s*\{([^}]+)\}\s*\)', content, re.DOTALL
|
|
151
|
+
):
|
|
152
|
+
app_block = match.group(1)
|
|
153
|
+
|
|
154
|
+
name_match = re.search(r'name:\s*["\']([^"\']+)["\']', app_block)
|
|
155
|
+
url_match = re.search(r'app:\s*["\']([^"\']+)["\']', app_block)
|
|
156
|
+
|
|
157
|
+
if name_match:
|
|
158
|
+
app = {"name": name_match.group(1)}
|
|
159
|
+
if url_match:
|
|
160
|
+
app["url"] = url_match.group(1)
|
|
161
|
+
applications.append(app)
|
|
162
|
+
|
|
163
|
+
return applications
|
|
164
|
+
|
|
165
|
+
def create_dependency_nodes_and_edges(
|
|
166
|
+
self, config: dict, repository_name: str
|
|
167
|
+
) -> tuple[list[dict], list[dict]]:
|
|
168
|
+
"""
|
|
169
|
+
Create dependency nodes and edges from microfrontend config (Task 3.3).
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
config: Parsed microfrontend configuration
|
|
173
|
+
repository_name: Name of the repository
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
Tuple of (nodes, edges) lists
|
|
177
|
+
"""
|
|
178
|
+
nodes = []
|
|
179
|
+
edges = []
|
|
180
|
+
|
|
181
|
+
if config["type"] == "module_federation":
|
|
182
|
+
# Create nodes for exposed modules
|
|
183
|
+
for expose_key, expose_path in config.get("exposes", {}).items():
|
|
184
|
+
node = {
|
|
185
|
+
"node_type": "MICROFRONTEND",
|
|
186
|
+
"name": f"{config.get('name', repository_name)}:{expose_key}",
|
|
187
|
+
"module_name": config.get("name"),
|
|
188
|
+
"expose_key": expose_key,
|
|
189
|
+
"path": expose_path,
|
|
190
|
+
"repository": repository_name,
|
|
191
|
+
}
|
|
192
|
+
nodes.append(node)
|
|
193
|
+
|
|
194
|
+
# Create EXPOSES_MODULE edge from repository to exposed module
|
|
195
|
+
edge = {
|
|
196
|
+
"edge_type": "EXPOSES_MODULE",
|
|
197
|
+
"source": repository_name,
|
|
198
|
+
"target": node["name"],
|
|
199
|
+
"metadata": {
|
|
200
|
+
"expose_key": expose_key,
|
|
201
|
+
"path": expose_path,
|
|
202
|
+
},
|
|
203
|
+
}
|
|
204
|
+
edges.append(edge)
|
|
205
|
+
|
|
206
|
+
# Create edges for consumed remotes
|
|
207
|
+
for remote_name, remote_url in config.get("remotes", {}).items():
|
|
208
|
+
edge = {
|
|
209
|
+
"edge_type": "CONSUMES_MODULE",
|
|
210
|
+
"source": repository_name,
|
|
211
|
+
"target": remote_name,
|
|
212
|
+
"metadata": {
|
|
213
|
+
"remote_url": remote_url,
|
|
214
|
+
"shared": config.get("shared", []),
|
|
215
|
+
},
|
|
216
|
+
}
|
|
217
|
+
edges.append(edge)
|
|
218
|
+
|
|
219
|
+
elif config["type"] == "single_spa":
|
|
220
|
+
# Create nodes for single-spa applications
|
|
221
|
+
for app in config.get("applications", []):
|
|
222
|
+
node = {
|
|
223
|
+
"node_type": "MICROFRONTEND",
|
|
224
|
+
"name": app["name"],
|
|
225
|
+
"application_type": "single_spa",
|
|
226
|
+
"url": app.get("url"),
|
|
227
|
+
"repository": repository_name,
|
|
228
|
+
}
|
|
229
|
+
nodes.append(node)
|
|
230
|
+
|
|
231
|
+
# Create edge from repository to application
|
|
232
|
+
edge = {
|
|
233
|
+
"edge_type": "REGISTERS_APPLICATION",
|
|
234
|
+
"source": repository_name,
|
|
235
|
+
"target": app["name"],
|
|
236
|
+
"metadata": {
|
|
237
|
+
"url": app.get("url"),
|
|
238
|
+
},
|
|
239
|
+
}
|
|
240
|
+
edges.append(edge)
|
|
241
|
+
|
|
242
|
+
return nodes, edges
|