codedd-cli 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.
- codedd_cli/__init__.py +3 -0
- codedd_cli/__main__.py +19 -0
- codedd_cli/api/__init__.py +4 -0
- codedd_cli/api/client.py +118 -0
- codedd_cli/api/endpoints.py +44 -0
- codedd_cli/api/exceptions.py +25 -0
- codedd_cli/auditor/__init__.py +6 -0
- codedd_cli/auditor/architecture_analyzer.py +1241 -0
- codedd_cli/auditor/architecture_prompts.py +171 -0
- codedd_cli/auditor/complexity_analyzer.py +942 -0
- codedd_cli/auditor/dependency_scanner.py +2478 -0
- codedd_cli/auditor/file_auditor.py +572 -0
- codedd_cli/auditor/git_stats_collector.py +332 -0
- codedd_cli/auditor/response_parser.py +487 -0
- codedd_cli/auditor/vulnerability_validator.py +324 -0
- codedd_cli/auth/__init__.py +4 -0
- codedd_cli/auth/session.py +41 -0
- codedd_cli/auth/token_manager.py +87 -0
- codedd_cli/cli.py +69 -0
- codedd_cli/commands/__init__.py +1 -0
- codedd_cli/commands/audit_cmd.py +1877 -0
- codedd_cli/commands/audits_cmd.py +273 -0
- codedd_cli/commands/auth_cmd.py +230 -0
- codedd_cli/commands/config_cmd.py +454 -0
- codedd_cli/commands/scope_cmd.py +967 -0
- codedd_cli/config/__init__.py +4 -0
- codedd_cli/config/constants.py +22 -0
- codedd_cli/config/settings.py +369 -0
- codedd_cli/llm/__init__.py +1 -0
- codedd_cli/llm/key_manager.py +271 -0
- codedd_cli/models/__init__.py +5 -0
- codedd_cli/models/account.py +13 -0
- codedd_cli/models/audit.py +32 -0
- codedd_cli/models/local_directory.py +26 -0
- codedd_cli/scanner/__init__.py +18 -0
- codedd_cli/scanner/file_classifier.py +247 -0
- codedd_cli/scanner/file_walker.py +207 -0
- codedd_cli/scanner/line_counter.py +75 -0
- codedd_cli/utils/__init__.py +1 -0
- codedd_cli/utils/directory_validator.py +179 -0
- codedd_cli/utils/display.py +493 -0
- codedd_cli/utils/payload_inspector.py +180 -0
- codedd_cli/utils/security.py +14 -0
- codedd_cli/utils/validators.py +37 -0
- codedd_cli-0.1.0.dist-info/METADATA +276 -0
- codedd_cli-0.1.0.dist-info/RECORD +49 -0
- codedd_cli-0.1.0.dist-info/WHEEL +4 -0
- codedd_cli-0.1.0.dist-info/entry_points.txt +3 -0
- codedd_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1241 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Local architecture analysis for CLI-driven audits.
|
|
3
|
+
|
|
4
|
+
Runs Phase 1 (file discovery and categorization), Phase 2 (per-file LLM
|
|
5
|
+
analysis over local file contents), and Phase 2.5 (synthesis of
|
|
6
|
+
architectural_components, component_relationships and architectural_insights
|
|
7
|
+
from per-file analyses — also LLM-driven, using the user's API keys).
|
|
8
|
+
|
|
9
|
+
Phase 3 (graph construction and TypeDB storage) runs on the server after
|
|
10
|
+
the CLI POSTs the fully populated phase1_results and phase2_results.
|
|
11
|
+
|
|
12
|
+
All LLM calls use the user's own API keys; no source code leaves the machine.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import time
|
|
20
|
+
|
|
21
|
+
from typing import Any, Callable, Optional
|
|
22
|
+
|
|
23
|
+
from codedd_cli.auditor.architecture_prompts import CATEGORY_PROMPTS, CATEGORY_SCHEMAS
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
# Max file size for architecture analysis (1MB) and content truncation (30k chars)
|
|
28
|
+
_MAX_FILE_BYTES = 1024 * 1024
|
|
29
|
+
_MAX_CONTENT_CHARS = 30000
|
|
30
|
+
|
|
31
|
+
# Category keys expected by the server's GraphSynthesizer / file_identifier
|
|
32
|
+
ARCHITECTURE_CATEGORIES = [
|
|
33
|
+
"backend_files",
|
|
34
|
+
"frontend_files",
|
|
35
|
+
"database_files",
|
|
36
|
+
"infrastructure_files",
|
|
37
|
+
"ci_cd_files",
|
|
38
|
+
"testing_files",
|
|
39
|
+
"config_files",
|
|
40
|
+
"dependency_files",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
# Extensions/names that map to categories (simplified from server FileIdentifier)
|
|
44
|
+
CATEGORY_PATTERNS = {
|
|
45
|
+
"backend_files": (".py", ".java", ".kt", ".go", ".rs", ".rb", ".php", ".cs", ".ts", ".js", ".m", ".swift"),
|
|
46
|
+
"frontend_files": (".tsx", ".jsx", ".vue", ".svelte", ".html", ".css", ".scss"),
|
|
47
|
+
"database_files": (".sql", "migration", "schema", "models.py"),
|
|
48
|
+
"infrastructure_files": ("Dockerfile", "docker-compose", ".yml", ".yaml", "Dockerfile"),
|
|
49
|
+
"ci_cd_files": (".github/", "gitlab-ci", "Jenkinsfile", ".gitlab-ci", "build.gradle", "Makefile"),
|
|
50
|
+
"testing_files": ("test_", "_test.", "spec.", ".spec.", "test.", "__tests__"),
|
|
51
|
+
"config_files": ("config.", "settings.", ".env", "application.", ".config."),
|
|
52
|
+
"dependency_files": ("requirements", "package.json", "pyproject.toml", "Pipfile", "go.mod", "Cargo.toml"),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _categorize_file(file_path: str) -> Optional[str]:
|
|
57
|
+
"""Return the first matching category for a file path, or None."""
|
|
58
|
+
path_lower = file_path.replace("\\", "/").lower()
|
|
59
|
+
name = os.path.basename(path_lower)
|
|
60
|
+
for category, patterns in CATEGORY_PATTERNS.items():
|
|
61
|
+
for p in patterns:
|
|
62
|
+
if p.startswith(".") and path_lower.endswith(p):
|
|
63
|
+
return category
|
|
64
|
+
if p in path_lower or p in name:
|
|
65
|
+
return category
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def build_phase1_results(
|
|
70
|
+
file_paths: list[str],
|
|
71
|
+
repository_path: str,
|
|
72
|
+
) -> dict[str, Any]:
|
|
73
|
+
"""
|
|
74
|
+
Build Phase 1 (file identification) results from a list of file paths.
|
|
75
|
+
|
|
76
|
+
file_paths: list of paths as stored in TypeDB (e.g. cli://uuid/repo/rel/path).
|
|
77
|
+
repository_path: display path (e.g. cli://uuid/repo or repo name).
|
|
78
|
+
|
|
79
|
+
Returns a dict compatible with the server's FileIdentifier output.
|
|
80
|
+
"""
|
|
81
|
+
file_categories: dict[str, list[str]] = {cat: [] for cat in ARCHITECTURE_CATEGORIES}
|
|
82
|
+
for fp in file_paths:
|
|
83
|
+
cat = _categorize_file(fp)
|
|
84
|
+
if cat and cat in file_categories:
|
|
85
|
+
file_categories[cat].append(fp)
|
|
86
|
+
|
|
87
|
+
relevant_files: list[str] = []
|
|
88
|
+
for files in file_categories.values():
|
|
89
|
+
relevant_files.extend(files)
|
|
90
|
+
relevant_files = list(dict.fromkeys(relevant_files))
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
"repository_path": repository_path,
|
|
94
|
+
"total_files_scanned": len(file_paths),
|
|
95
|
+
"filtered_files_count": len(file_paths),
|
|
96
|
+
"relevant_files": relevant_files,
|
|
97
|
+
"file_categories": file_categories,
|
|
98
|
+
"identified_technologies": {},
|
|
99
|
+
"folder_structure": {},
|
|
100
|
+
"analysis_metadata": {
|
|
101
|
+
"phase": "file_identification",
|
|
102
|
+
"status": "completed",
|
|
103
|
+
"data_source": "cli",
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def build_phase2_results_empty(reason: str = "CLI stub") -> dict[str, Any]:
|
|
109
|
+
"""Build minimal Phase 2 results so the server can run Phase 3 and store."""
|
|
110
|
+
return _build_phase2_skeleton(
|
|
111
|
+
category_analyses={},
|
|
112
|
+
total_files_analyzed=0,
|
|
113
|
+
categories_analyzed=[],
|
|
114
|
+
executive_summary=f"Architecture analysis (CLI): {reason}.",
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _build_phase2_skeleton(
|
|
119
|
+
category_analyses: dict[str, list],
|
|
120
|
+
total_files_analyzed: int,
|
|
121
|
+
categories_analyzed: list[str],
|
|
122
|
+
executive_summary: str = "Architecture analysis completed on CLI.",
|
|
123
|
+
) -> dict[str, Any]:
|
|
124
|
+
"""Build Phase 2 payload with category_analyses; components/relationships/insights left for server synthesis."""
|
|
125
|
+
return {
|
|
126
|
+
"analysis_metadata": {
|
|
127
|
+
"phase": "enhanced_llm_analysis",
|
|
128
|
+
"status": "completed",
|
|
129
|
+
"timestamp": time.time(),
|
|
130
|
+
"total_files_analyzed": total_files_analyzed,
|
|
131
|
+
"categories_analyzed": categories_analyzed,
|
|
132
|
+
"enhanced_technologies_detected": 0,
|
|
133
|
+
"enhanced_relationships_detected": 0,
|
|
134
|
+
},
|
|
135
|
+
"category_analyses": category_analyses,
|
|
136
|
+
"enhanced_technology_detection": {
|
|
137
|
+
"detected_technologies": {},
|
|
138
|
+
"relationship_map": {},
|
|
139
|
+
"technology_count": 0,
|
|
140
|
+
"relationship_count": 0,
|
|
141
|
+
},
|
|
142
|
+
"enhanced_relationship_analysis": {
|
|
143
|
+
"relationships": {},
|
|
144
|
+
"component_graph": {},
|
|
145
|
+
"architectural_patterns": [],
|
|
146
|
+
"coupling_analysis": {},
|
|
147
|
+
"communication_patterns": {},
|
|
148
|
+
"critical_dependencies": [],
|
|
149
|
+
"relationship_count": 0,
|
|
150
|
+
"component_count": 0,
|
|
151
|
+
},
|
|
152
|
+
"architectural_components": {},
|
|
153
|
+
"component_relationships": [],
|
|
154
|
+
"architectural_insights": {
|
|
155
|
+
"technology_stack": [],
|
|
156
|
+
"architectural_patterns": [],
|
|
157
|
+
"service_architecture": "unknown",
|
|
158
|
+
"data_architecture": "unknown",
|
|
159
|
+
"deployment_approach": "unknown",
|
|
160
|
+
"development_practices": [],
|
|
161
|
+
"coupling_analysis": {},
|
|
162
|
+
"communication_patterns": {},
|
|
163
|
+
"critical_dependencies": [],
|
|
164
|
+
"executive_summary": executive_summary,
|
|
165
|
+
},
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _read_file_safely_local(local_path: str, max_chars: int = _MAX_CONTENT_CHARS) -> str | None:
|
|
170
|
+
"""Read file content with size limit; truncate by character count. Returns None on error."""
|
|
171
|
+
try:
|
|
172
|
+
if not os.path.isfile(local_path):
|
|
173
|
+
return None
|
|
174
|
+
size = os.path.getsize(local_path)
|
|
175
|
+
if size > _MAX_FILE_BYTES:
|
|
176
|
+
return None
|
|
177
|
+
content = open(local_path, encoding="utf-8", errors="replace").read()
|
|
178
|
+
if len(content) > max_chars:
|
|
179
|
+
content = content[:max_chars] + "\n... (truncated)"
|
|
180
|
+
return content
|
|
181
|
+
except OSError:
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _build_analysis_prompt(file_path: str, file_content: str, category: str) -> str:
|
|
186
|
+
"""Build category-specific analysis prompt (server-compatible format)."""
|
|
187
|
+
base_prompt = CATEGORY_PROMPTS.get(category, CATEGORY_PROMPTS["config_files"])
|
|
188
|
+
expected_schema = CATEGORY_SCHEMAS.get(category, CATEGORY_SCHEMAS["config_files"])
|
|
189
|
+
return f"""
|
|
190
|
+
{base_prompt}
|
|
191
|
+
|
|
192
|
+
File: {os.path.basename(file_path)}
|
|
193
|
+
Category: {category}
|
|
194
|
+
|
|
195
|
+
Expected Output Format (JSON):
|
|
196
|
+
{json.dumps(expected_schema, indent=2)}
|
|
197
|
+
|
|
198
|
+
File Content:
|
|
199
|
+
```
|
|
200
|
+
{file_content}
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
**CRITICAL**: Respond with ONLY valid JSON. No markdown, no explanations. Start with {{ and end with }}.
|
|
204
|
+
"""
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _clean_json_content(raw: str) -> str:
|
|
208
|
+
"""Try to fix common LLM JSON issues (trailing commas, markdown wrapper)."""
|
|
209
|
+
text = raw.strip()
|
|
210
|
+
# Extract from markdown code block
|
|
211
|
+
m = re.search(r"```(?:json)?\s*(\{.*\})\s*```", text, re.DOTALL)
|
|
212
|
+
if m:
|
|
213
|
+
text = m.group(1)
|
|
214
|
+
# Fix trailing commas before ] or }
|
|
215
|
+
text = re.sub(r",(\s*[}\]])", r"\1", text)
|
|
216
|
+
return text
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _parse_llm_response(response_text: str, file_path: str, category: str) -> dict[str, Any] | None:
|
|
220
|
+
"""Extract and validate JSON from LLM response; add file_path and analysis_timestamp."""
|
|
221
|
+
try:
|
|
222
|
+
start = response_text.find("{")
|
|
223
|
+
end = response_text.rfind("}") + 1
|
|
224
|
+
if start == -1 or end <= start:
|
|
225
|
+
return None
|
|
226
|
+
cleaned = _clean_json_content(response_text[start:end])
|
|
227
|
+
data = json.loads(cleaned)
|
|
228
|
+
except json.JSONDecodeError:
|
|
229
|
+
return None
|
|
230
|
+
if not isinstance(data, dict):
|
|
231
|
+
return None
|
|
232
|
+
schema = CATEGORY_SCHEMAS.get(category, CATEGORY_SCHEMAS["config_files"])
|
|
233
|
+
for key in schema:
|
|
234
|
+
if key not in data:
|
|
235
|
+
data[key] = schema[key] if isinstance(schema[key], (list, dict)) else ""
|
|
236
|
+
data["file_path"] = file_path
|
|
237
|
+
data["analysis_category"] = category
|
|
238
|
+
data["analysis_timestamp"] = time.time()
|
|
239
|
+
return data
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _call_llm_for_architecture(
|
|
243
|
+
prompt: str,
|
|
244
|
+
anthropic_client: Any,
|
|
245
|
+
openai_client: Any,
|
|
246
|
+
on_debug: Callable[[str], None] | None,
|
|
247
|
+
*,
|
|
248
|
+
system_override: str | None = None,
|
|
249
|
+
max_tokens: int = 4000,
|
|
250
|
+
) -> str | None:
|
|
251
|
+
"""Call Anthropic first, then OpenAI fallback. Returns response text or None."""
|
|
252
|
+
import httpx
|
|
253
|
+
|
|
254
|
+
from codedd_cli.llm.key_manager import PROVIDER_MODELS
|
|
255
|
+
|
|
256
|
+
system = system_override or (
|
|
257
|
+
"You are a code analysis assistant that ONLY outputs valid JSON. "
|
|
258
|
+
"No markdown, no explanations. Response must start with { and end with }."
|
|
259
|
+
)
|
|
260
|
+
timeout = 180.0
|
|
261
|
+
|
|
262
|
+
def dbg(msg: str) -> None:
|
|
263
|
+
if on_debug:
|
|
264
|
+
on_debug(msg)
|
|
265
|
+
logger.debug(msg)
|
|
266
|
+
|
|
267
|
+
if anthropic_client:
|
|
268
|
+
try:
|
|
269
|
+
payload = {
|
|
270
|
+
"model": PROVIDER_MODELS.get("anthropic", "claude-sonnet-4-6"),
|
|
271
|
+
"max_tokens": max_tokens,
|
|
272
|
+
"system": system,
|
|
273
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
274
|
+
}
|
|
275
|
+
resp = anthropic_client.post("/v1/messages", json=payload, timeout=timeout)
|
|
276
|
+
if resp.status_code == 200:
|
|
277
|
+
body = resp.json()
|
|
278
|
+
blocks = body.get("content", [])
|
|
279
|
+
parts = [b["text"] for b in blocks if b.get("type") == "text"]
|
|
280
|
+
out = "\n".join(parts) if parts else None
|
|
281
|
+
if out:
|
|
282
|
+
dbg("Anthropic: OK")
|
|
283
|
+
return out
|
|
284
|
+
else:
|
|
285
|
+
dbg(f"Anthropic: HTTP {resp.status_code}")
|
|
286
|
+
except httpx.TimeoutException:
|
|
287
|
+
dbg("Anthropic: timeout")
|
|
288
|
+
except Exception as e:
|
|
289
|
+
dbg(f"Anthropic: {e}")
|
|
290
|
+
|
|
291
|
+
if openai_client:
|
|
292
|
+
try:
|
|
293
|
+
payload = {
|
|
294
|
+
"model": PROVIDER_MODELS.get("openai", "gpt-5.2"),
|
|
295
|
+
"messages": [
|
|
296
|
+
{"role": "system", "content": system},
|
|
297
|
+
{"role": "user", "content": prompt},
|
|
298
|
+
],
|
|
299
|
+
"max_tokens": max_tokens,
|
|
300
|
+
}
|
|
301
|
+
resp = openai_client.post("/v1/chat/completions", json=payload, timeout=timeout)
|
|
302
|
+
if resp.status_code == 200:
|
|
303
|
+
body = resp.json()
|
|
304
|
+
choices = body.get("choices", [])
|
|
305
|
+
if choices:
|
|
306
|
+
out = choices[0].get("message", {}).get("content")
|
|
307
|
+
if out:
|
|
308
|
+
dbg("OpenAI: OK")
|
|
309
|
+
return out
|
|
310
|
+
else:
|
|
311
|
+
dbg(f"OpenAI: HTTP {resp.status_code}")
|
|
312
|
+
except httpx.TimeoutException:
|
|
313
|
+
dbg("OpenAI: timeout")
|
|
314
|
+
except Exception as e:
|
|
315
|
+
dbg(f"OpenAI: {e}")
|
|
316
|
+
|
|
317
|
+
return None
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
# ---------------------------------------------------------------------------
|
|
321
|
+
# Synthesis helpers — ported from server LLMAnalyzer
|
|
322
|
+
# Build components, relationships, and insights from per-file analyses.
|
|
323
|
+
# ---------------------------------------------------------------------------
|
|
324
|
+
|
|
325
|
+
# Category-specific synthesis instructions. Each value is a tuple of
|
|
326
|
+
# (title, focus_points, component_types, extra_schema_fields).
|
|
327
|
+
_SYNTHESIS_CATEGORY_META: dict[str, dict[str, Any]] = {
|
|
328
|
+
"dependency_files": {
|
|
329
|
+
"title": "DEPENDENCY COMPONENT SYNTHESIS",
|
|
330
|
+
"focus": (
|
|
331
|
+
"- Main programming language ecosystems\n"
|
|
332
|
+
"- Core frameworks (web, data, testing)\n"
|
|
333
|
+
"- Infrastructure dependencies\n"
|
|
334
|
+
"- Development tools"
|
|
335
|
+
),
|
|
336
|
+
"types": "framework|runtime|database|infrastructure|development_tool",
|
|
337
|
+
"category_label": "dependency",
|
|
338
|
+
"extra_fields": '"category_role": "backend_framework|frontend_framework|database|cache|testing",\n'
|
|
339
|
+
'"key_dependencies": ["dep1", "dep2"],\n'
|
|
340
|
+
'"architectural_role": "description of role in architecture"',
|
|
341
|
+
},
|
|
342
|
+
"infrastructure_files": {
|
|
343
|
+
"title": "INFRASTRUCTURE COMPONENT SYNTHESIS",
|
|
344
|
+
"focus": (
|
|
345
|
+
"- Container services and their roles\n"
|
|
346
|
+
"- Network configuration and service communication\n"
|
|
347
|
+
"- Storage and persistence layers\n"
|
|
348
|
+
"- Deployment orchestration"
|
|
349
|
+
),
|
|
350
|
+
"types": "container_service|network_component|storage_component|orchestration",
|
|
351
|
+
"category_label": "infrastructure",
|
|
352
|
+
"extra_fields": '"services_managed": ["service1", "service2"],\n'
|
|
353
|
+
'"deployment_pattern": "containerized|kubernetes|serverless",\n'
|
|
354
|
+
'"communication_ports": ["8000", "5432"],\n'
|
|
355
|
+
'"architectural_role": "description of deployment role"',
|
|
356
|
+
},
|
|
357
|
+
"backend_files": {
|
|
358
|
+
"title": "BACKEND COMPONENT SYNTHESIS",
|
|
359
|
+
"focus": (
|
|
360
|
+
"- API services and endpoints\n"
|
|
361
|
+
"- Business logic and data processing\n"
|
|
362
|
+
"- External service integrations\n"
|
|
363
|
+
"- Data access patterns"
|
|
364
|
+
),
|
|
365
|
+
"types": "api_service|business_logic|data_access|integration_service",
|
|
366
|
+
"category_label": "backend",
|
|
367
|
+
"extra_fields": '"api_endpoints": ["/api/endpoint1", "/api/endpoint2"],\n'
|
|
368
|
+
'"business_capabilities": ["capability1", "capability2"],\n'
|
|
369
|
+
'"data_models": ["Model1", "Model2"],\n'
|
|
370
|
+
'"architectural_role": "description of backend role"',
|
|
371
|
+
},
|
|
372
|
+
"frontend_files": {
|
|
373
|
+
"title": "FRONTEND COMPONENT SYNTHESIS",
|
|
374
|
+
"focus": (
|
|
375
|
+
"- UI frameworks and component libraries\n"
|
|
376
|
+
"- Routing and navigation systems\n"
|
|
377
|
+
"- State management patterns\n"
|
|
378
|
+
"- Build and bundling tools"
|
|
379
|
+
),
|
|
380
|
+
"types": "ui_framework|component_library|routing_system|build_tool|state_management",
|
|
381
|
+
"category_label": "frontend",
|
|
382
|
+
"extra_fields": '"ui_components": ["Component1", "Component2"],\n'
|
|
383
|
+
'"routing_patterns": ["/route1", "/route2"],\n'
|
|
384
|
+
'"state_management": "redux|context|local",\n'
|
|
385
|
+
'"architectural_role": "description of frontend role"',
|
|
386
|
+
},
|
|
387
|
+
"database_files": {
|
|
388
|
+
"title": "DATABASE COMPONENT SYNTHESIS",
|
|
389
|
+
"focus": (
|
|
390
|
+
"- Database schemas and table structures\n"
|
|
391
|
+
"- Data relationships and constraints\n"
|
|
392
|
+
"- Data access patterns and ORM usage\n"
|
|
393
|
+
"- Migration and versioning strategies"
|
|
394
|
+
),
|
|
395
|
+
"types": "database_schema|data_model|migration_system|data_access",
|
|
396
|
+
"category_label": "database",
|
|
397
|
+
"extra_fields": '"data_entities": ["Entity1", "Entity2"],\n'
|
|
398
|
+
'"relationships": ["entity1_to_entity2"],\n'
|
|
399
|
+
'"access_patterns": ["orm", "raw_sql"],\n'
|
|
400
|
+
'"architectural_role": "description of data role"',
|
|
401
|
+
},
|
|
402
|
+
"ci_cd_files": {
|
|
403
|
+
"title": "CI/CD COMPONENT SYNTHESIS",
|
|
404
|
+
"focus": (
|
|
405
|
+
"- Build processes and automation\n"
|
|
406
|
+
"- Deployment strategies and targets\n"
|
|
407
|
+
"- Quality gates and testing integration\n"
|
|
408
|
+
"- Artifact management and versioning"
|
|
409
|
+
),
|
|
410
|
+
"types": "build_pipeline|deployment_pipeline|quality_gate|artifact_management",
|
|
411
|
+
"category_label": "cicd",
|
|
412
|
+
"extra_fields": '"pipeline_stages": ["build", "test", "deploy"],\n'
|
|
413
|
+
'"deployment_targets": ["staging", "production"],\n'
|
|
414
|
+
'"quality_gates": ["gate1", "gate2"],\n'
|
|
415
|
+
'"architectural_role": "description of CI/CD role"',
|
|
416
|
+
},
|
|
417
|
+
"testing_files": {
|
|
418
|
+
"title": "TESTING COMPONENT SYNTHESIS",
|
|
419
|
+
"focus": (
|
|
420
|
+
"- Testing frameworks and tools\n"
|
|
421
|
+
"- Test coverage and scope\n"
|
|
422
|
+
"- Quality gates and metrics\n"
|
|
423
|
+
"- Test data management"
|
|
424
|
+
),
|
|
425
|
+
"types": "test_framework|quality_gate|test_data_management|coverage_analysis",
|
|
426
|
+
"category_label": "testing",
|
|
427
|
+
"extra_fields": '"test_types": ["unit", "integration", "e2e"],\n'
|
|
428
|
+
'"coverage_areas": ["area1", "area2"],\n'
|
|
429
|
+
'"quality_metrics": ["coverage", "pass_rate"],\n'
|
|
430
|
+
'"architectural_role": "description of testing role"',
|
|
431
|
+
},
|
|
432
|
+
"config_files": {
|
|
433
|
+
"title": "CONFIGURATION COMPONENT SYNTHESIS",
|
|
434
|
+
"focus": (
|
|
435
|
+
"- Application settings and parameters\n"
|
|
436
|
+
"- Environment-specific configurations\n"
|
|
437
|
+
"- External service integrations\n"
|
|
438
|
+
"- Security and credential management"
|
|
439
|
+
),
|
|
440
|
+
"types": "app_configuration|environment_config|security_config|integration_config",
|
|
441
|
+
"category_label": "config",
|
|
442
|
+
"extra_fields": '"config_scope": ["application", "environment", "security"],\n'
|
|
443
|
+
'"external_services": ["service1", "service2"],\n'
|
|
444
|
+
'"security_features": ["feature1", "feature2"],\n'
|
|
445
|
+
'"architectural_role": "description of configuration role"',
|
|
446
|
+
},
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
_JSON_CRITICAL_RULES = (
|
|
450
|
+
"**CRITICAL JSON FORMATTING REQUIREMENTS:**\n"
|
|
451
|
+
"1. Return ONLY valid JSON inside ```json code blocks\n"
|
|
452
|
+
"2. Do NOT include actual newlines in string values - use spaces instead\n"
|
|
453
|
+
"3. Keep all descriptions on single lines (no multi-line strings)\n"
|
|
454
|
+
"4. Ensure all strings are properly quoted with double quotes\n"
|
|
455
|
+
"5. Remove any trailing commas before closing braces or brackets\n"
|
|
456
|
+
"6. Validate your JSON structure before responding"
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _safe_join(items: list, max_items: int = 5) -> str:
|
|
461
|
+
"""Format a list of strings or dicts into a comma-separated preview."""
|
|
462
|
+
if not items:
|
|
463
|
+
return "None identified"
|
|
464
|
+
parts: list[str] = []
|
|
465
|
+
for item in items[:max_items]:
|
|
466
|
+
if isinstance(item, dict):
|
|
467
|
+
name = (
|
|
468
|
+
item.get("name")
|
|
469
|
+
or item.get("path")
|
|
470
|
+
or item.get("endpoint")
|
|
471
|
+
or item.get("service")
|
|
472
|
+
or item.get("component")
|
|
473
|
+
or item.get("route")
|
|
474
|
+
or item.get("table")
|
|
475
|
+
or item.get("stage")
|
|
476
|
+
or item.get("target")
|
|
477
|
+
or item.get("type")
|
|
478
|
+
or item.get("setting")
|
|
479
|
+
or item.get("variable")
|
|
480
|
+
or item.get("technology")
|
|
481
|
+
or item.get("framework")
|
|
482
|
+
or item.get("migration")
|
|
483
|
+
or item.get("test")
|
|
484
|
+
or str(item)
|
|
485
|
+
)
|
|
486
|
+
parts.append(str(name))
|
|
487
|
+
else:
|
|
488
|
+
parts.append(str(item))
|
|
489
|
+
return ", ".join(parts) if parts else "None identified"
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _format_analyses_for_prompt(category_analyses: list[dict[str, Any]]) -> str:
|
|
493
|
+
"""Format per-file analyses for inclusion in category synthesis prompts."""
|
|
494
|
+
formatted: list[str] = []
|
|
495
|
+
for i, analysis in enumerate(category_analyses[:10]):
|
|
496
|
+
if not isinstance(analysis, dict):
|
|
497
|
+
continue
|
|
498
|
+
file_path = analysis.get("file_path", "unknown")
|
|
499
|
+
file_name = file_path.rsplit("/", 1)[-1] if "/" in file_path else file_path
|
|
500
|
+
ft = analysis.get("file_type", "")
|
|
501
|
+
|
|
502
|
+
if ft == "infrastructure_config" or "docker-compose" in file_name.lower():
|
|
503
|
+
text = (
|
|
504
|
+
f"**File {i+1}: {file_name}**\n"
|
|
505
|
+
f"- Services Defined: {_safe_join(analysis.get('services_defined', []), 8)}\n"
|
|
506
|
+
f"- Ports Exposed: {_safe_join(analysis.get('ports_exposed', []))}\n"
|
|
507
|
+
f"- Service Dependencies: {len(analysis.get('dependencies_between_services', []))} dependencies\n"
|
|
508
|
+
f"- Deployment Pattern: {analysis.get('deployment_pattern', 'unknown')}"
|
|
509
|
+
)
|
|
510
|
+
elif ft == "backend_code":
|
|
511
|
+
text = (
|
|
512
|
+
f"**File {i+1}: {file_name}**\n"
|
|
513
|
+
f"- API Endpoints: {_safe_join(analysis.get('api_endpoints', []))}\n"
|
|
514
|
+
f"- Data Models: {_safe_join(analysis.get('data_models', []), 3)}\n"
|
|
515
|
+
f"- Business Logic: {_safe_join(analysis.get('business_logic', []), 3)}\n"
|
|
516
|
+
f"- External Integrations: {_safe_join(analysis.get('external_integrations', []), 3)}"
|
|
517
|
+
)
|
|
518
|
+
elif ft == "frontend_code":
|
|
519
|
+
text = (
|
|
520
|
+
f"**File {i+1}: {file_name}**\n"
|
|
521
|
+
f"- UI Components: {_safe_join(analysis.get('ui_components', []))}\n"
|
|
522
|
+
f"- Routing Config: {_safe_join(analysis.get('routing_config', []), 3)}\n"
|
|
523
|
+
f"- State Management: {_safe_join(analysis.get('state_management', []), 3)}\n"
|
|
524
|
+
f"- API Interactions: {_safe_join(analysis.get('api_interactions', []), 3)}\n"
|
|
525
|
+
f"- Styling Approach: {analysis.get('styling_approach', 'unknown')}"
|
|
526
|
+
)
|
|
527
|
+
elif ft in ("database_schema", "migration"):
|
|
528
|
+
text = (
|
|
529
|
+
f"**File {i+1}: {file_name}**\n"
|
|
530
|
+
f"- Tables/Schemas: {_safe_join(analysis.get('tables_schemas', []))}\n"
|
|
531
|
+
f"- Relationships: {_safe_join(analysis.get('relationships', []), 3)}\n"
|
|
532
|
+
f"- Indexes: {_safe_join(analysis.get('indexes', []), 3)}\n"
|
|
533
|
+
f"- Migrations: {_safe_join(analysis.get('migrations', []), 3)}\n"
|
|
534
|
+
f"- Database Type: {analysis.get('database_type', 'unknown')}"
|
|
535
|
+
)
|
|
536
|
+
elif ft == "ci_cd_pipeline":
|
|
537
|
+
text = (
|
|
538
|
+
f"**File {i+1}: {file_name}**\n"
|
|
539
|
+
f"- Pipeline Stages: {_safe_join(analysis.get('pipeline_stages', []))}\n"
|
|
540
|
+
f"- Deployment Targets: {_safe_join(analysis.get('deployment_targets', []), 3)}\n"
|
|
541
|
+
f"- Testing Automation: {_safe_join(analysis.get('testing_automation', []), 3)}\n"
|
|
542
|
+
f"- Build Processes: {_safe_join(analysis.get('build_processes', []), 3)}\n"
|
|
543
|
+
f"- Pipeline Platform: {analysis.get('pipeline_platform', 'unknown')}"
|
|
544
|
+
)
|
|
545
|
+
elif ft in ("test_config", "test_suite"):
|
|
546
|
+
text = (
|
|
547
|
+
f"**File {i+1}: {file_name}**\n"
|
|
548
|
+
f"- Test Types: {_safe_join(analysis.get('test_types', []))}\n"
|
|
549
|
+
f"- Coverage Areas: {_safe_join(analysis.get('test_coverage_areas', []), 3)}\n"
|
|
550
|
+
f"- Testing Environments: {_safe_join(analysis.get('testing_environments', []), 3)}\n"
|
|
551
|
+
f"- Automation Config: {_safe_join(analysis.get('automation_config', []), 3)}\n"
|
|
552
|
+
f"- Testing Framework: {analysis.get('testing_framework', 'unknown')}"
|
|
553
|
+
)
|
|
554
|
+
elif ft in ("application_config", "environment_config"):
|
|
555
|
+
text = (
|
|
556
|
+
f"**File {i+1}: {file_name}**\n"
|
|
557
|
+
f"- Application Settings: {_safe_join(analysis.get('application_settings', []))}\n"
|
|
558
|
+
f"- Environment Variables: {_safe_join(analysis.get('environment_variables', []), 3)}\n"
|
|
559
|
+
f"- External Service Config: {_safe_join(analysis.get('external_service_config', []), 3)}\n"
|
|
560
|
+
f"- Security Settings: {_safe_join(analysis.get('security_settings', []), 3)}\n"
|
|
561
|
+
f"- Config Purpose: {analysis.get('config_purpose', 'unknown')}"
|
|
562
|
+
)
|
|
563
|
+
else:
|
|
564
|
+
text = (
|
|
565
|
+
f"**File {i+1}: {file_name}**\n"
|
|
566
|
+
f"- Technologies: {_safe_join(analysis.get('tech_stack', []))}\n"
|
|
567
|
+
f"- Frameworks: {_safe_join(analysis.get('frameworks', []), 3)}\n"
|
|
568
|
+
f"- Key Functions: {_safe_join(analysis.get('key_functions', []), 3)}"
|
|
569
|
+
)
|
|
570
|
+
formatted.append(text)
|
|
571
|
+
|
|
572
|
+
if len(category_analyses) > 10:
|
|
573
|
+
formatted.append(f"\n... and {len(category_analyses) - 10} more files")
|
|
574
|
+
return "\n\n".join(formatted)
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def _build_category_synthesis_prompt(
|
|
578
|
+
category: str,
|
|
579
|
+
category_analyses: list[dict[str, Any]],
|
|
580
|
+
) -> str:
|
|
581
|
+
"""Build the LLM prompt that synthesises components for one category."""
|
|
582
|
+
meta = _SYNTHESIS_CATEGORY_META.get(category)
|
|
583
|
+
formatted_analyses = _format_analyses_for_prompt(category_analyses)
|
|
584
|
+
|
|
585
|
+
if not meta:
|
|
586
|
+
return (
|
|
587
|
+
f"# GENERIC COMPONENT SYNTHESIS\n\n"
|
|
588
|
+
f"Analyze these {len(category_analyses)} {category} files:\n\n"
|
|
589
|
+
f"## DETAILED FILE ANALYSES:\n{formatted_analyses}\n\n"
|
|
590
|
+
f"Create relevant architectural components based on the analysis.\n\n"
|
|
591
|
+
f"{_JSON_CRITICAL_RULES}"
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
return (
|
|
595
|
+
f"# {meta['title']}\n\n"
|
|
596
|
+
f"Analyze these {len(category_analyses)} {category.replace('_', ' ')} files to create "
|
|
597
|
+
f"architectural components:\n\n"
|
|
598
|
+
f"## DETAILED FILE ANALYSES:\n{formatted_analyses}\n\n"
|
|
599
|
+
f"**Create components that represent the architecture. Focus on:**\n"
|
|
600
|
+
f"{meta['focus']}\n\n"
|
|
601
|
+
f"**Output Format:**\n"
|
|
602
|
+
f"```json\n"
|
|
603
|
+
f"{{\n"
|
|
604
|
+
f' "component_name": {{\n'
|
|
605
|
+
f' "name": "component_name",\n'
|
|
606
|
+
f' "type": "{meta["types"]}",\n'
|
|
607
|
+
f' "category": "{meta["category_label"]}",\n'
|
|
608
|
+
f' "description": "Detailed description of the component",\n'
|
|
609
|
+
f' "technologies": ["tech1", "tech2"],\n'
|
|
610
|
+
f' "responsibilities": ["responsibility1", "responsibility2"],\n'
|
|
611
|
+
f' "key_files": ["file1.ext", "file2.ext"],\n'
|
|
612
|
+
f' "confidence": 85,\n'
|
|
613
|
+
f' "evidence_sources": ["{category.replace("_files", "")}_analysis"],\n'
|
|
614
|
+
f" {meta['extra_fields']}\n"
|
|
615
|
+
f" }}\n"
|
|
616
|
+
f"}}\n"
|
|
617
|
+
f"```\n\n"
|
|
618
|
+
f"{_JSON_CRITICAL_RULES}"
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _parse_category_synthesis_response(llm_response: str) -> dict[str, Any]:
|
|
623
|
+
"""Extract component dict from LLM synthesis response JSON."""
|
|
624
|
+
try:
|
|
625
|
+
m = re.search(r"```json\s*(\{.*?\})\s*```", llm_response, re.DOTALL)
|
|
626
|
+
if m:
|
|
627
|
+
json_str = m.group(1)
|
|
628
|
+
else:
|
|
629
|
+
m2 = re.search(r"(\{.*\})", llm_response, re.DOTALL)
|
|
630
|
+
if m2:
|
|
631
|
+
json_str = m2.group(1)
|
|
632
|
+
else:
|
|
633
|
+
return {}
|
|
634
|
+
# Fix trailing commas
|
|
635
|
+
json_str = re.sub(r",(\s*[}\]])", r"\1", json_str)
|
|
636
|
+
components = json.loads(json_str)
|
|
637
|
+
return components if isinstance(components, dict) else {}
|
|
638
|
+
except json.JSONDecodeError:
|
|
639
|
+
raise # let caller retry
|
|
640
|
+
except Exception:
|
|
641
|
+
return {}
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def _build_relationship_prompt(
|
|
645
|
+
architectural_components: dict[str, Any],
|
|
646
|
+
category_analyses: dict[str, list[dict[str, Any]]],
|
|
647
|
+
) -> str:
|
|
648
|
+
"""Build the LLM prompt for extracting relationships between components."""
|
|
649
|
+
api_calls: list[Any] = []
|
|
650
|
+
database_interactions: list[Any] = []
|
|
651
|
+
service_dependencies: list[Any] = []
|
|
652
|
+
for analyses in category_analyses.values():
|
|
653
|
+
for analysis in analyses:
|
|
654
|
+
if "api_interactions" in analysis:
|
|
655
|
+
api_calls.extend(analysis["api_interactions"])
|
|
656
|
+
elif "api_endpoints" in analysis:
|
|
657
|
+
api_calls.extend([{"endpoint": ep.get("path", "")} for ep in analysis["api_endpoints"]])
|
|
658
|
+
if "database_interactions" in analysis:
|
|
659
|
+
database_interactions.extend(analysis["database_interactions"])
|
|
660
|
+
if "dependencies_between_services" in analysis:
|
|
661
|
+
service_dependencies.extend(analysis["dependencies_between_services"])
|
|
662
|
+
elif "external_integrations" in analysis:
|
|
663
|
+
service_dependencies.extend(analysis["external_integrations"])
|
|
664
|
+
|
|
665
|
+
return (
|
|
666
|
+
"Based on the identified architectural components and file analysis data, "
|
|
667
|
+
"determine how these components interact with each other.\n\n"
|
|
668
|
+
f"## ARCHITECTURAL COMPONENTS:\n{json.dumps(architectural_components, indent=2)}\n\n"
|
|
669
|
+
f"## INTERACTION DATA:\n"
|
|
670
|
+
f"API Calls/Endpoints: {json.dumps(api_calls[:10], indent=2)}\n"
|
|
671
|
+
f"Database Interactions: {json.dumps(database_interactions[:10], indent=2)}\n"
|
|
672
|
+
f"Service Dependencies: {json.dumps(service_dependencies[:10], indent=2)}\n\n"
|
|
673
|
+
"Identify relationships between components focusing on:\n"
|
|
674
|
+
"1. **Data Flow** - How data moves between components\n"
|
|
675
|
+
"2. **API Dependencies** - Which components call which APIs\n"
|
|
676
|
+
"3. **Database Access** - Which components access which databases\n"
|
|
677
|
+
"4. **Service Communications** - How services communicate\n"
|
|
678
|
+
"5. **Deployment Dependencies** - Which components depend on others\n\n"
|
|
679
|
+
"IMPORTANT: Respond with ONLY a JSON object in this EXACT format "
|
|
680
|
+
"(no markdown, no explanations):\n\n"
|
|
681
|
+
"{\n"
|
|
682
|
+
' "relationships": [\n'
|
|
683
|
+
" {\n"
|
|
684
|
+
' "source": "source_component_name",\n'
|
|
685
|
+
' "target": "target_component_name",\n'
|
|
686
|
+
' "type": "api_call|data_access|dependency|communication|deployment_dependency|message_passing",\n'
|
|
687
|
+
' "description": "Clear description of how source interacts with target (max 100 characters)",\n'
|
|
688
|
+
' "strength": "high|medium|low"\n'
|
|
689
|
+
" }\n"
|
|
690
|
+
" ]\n"
|
|
691
|
+
"}\n\n"
|
|
692
|
+
"Generate 3-15 meaningful relationships based on the component data above. "
|
|
693
|
+
"Use exact component names from the ARCHITECTURAL COMPONENTS list.\n\n"
|
|
694
|
+
f"{_JSON_CRITICAL_RULES}"
|
|
695
|
+
)
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def _parse_relationship_response(llm_response: str) -> list[dict[str, Any]]:
|
|
699
|
+
"""Parse component relationships from LLM response."""
|
|
700
|
+
try:
|
|
701
|
+
start = llm_response.find("{")
|
|
702
|
+
end = llm_response.rfind("}") + 1
|
|
703
|
+
if start == -1 or end <= start:
|
|
704
|
+
return []
|
|
705
|
+
raw = llm_response[start:end]
|
|
706
|
+
raw = re.sub(r",(\s*[}\]])", r"\1", raw)
|
|
707
|
+
parsed = json.loads(raw)
|
|
708
|
+
rels: list[dict[str, Any]] = []
|
|
709
|
+
for rel in parsed.get("relationships", []):
|
|
710
|
+
src = rel.get("source", "")
|
|
711
|
+
tgt = rel.get("target", "")
|
|
712
|
+
if src and tgt:
|
|
713
|
+
rels.append({
|
|
714
|
+
"source": src,
|
|
715
|
+
"target": tgt,
|
|
716
|
+
"type": rel.get("type", "unknown"),
|
|
717
|
+
"description": rel.get("description", "")[:200],
|
|
718
|
+
"strength": rel.get("strength", "medium"),
|
|
719
|
+
})
|
|
720
|
+
return rels
|
|
721
|
+
except (json.JSONDecodeError, Exception):
|
|
722
|
+
return []
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def _create_fallback_relationships(
|
|
726
|
+
architectural_components: dict[str, Any],
|
|
727
|
+
) -> list[dict[str, Any]]:
|
|
728
|
+
"""Basic fallback relationships when LLM extraction fails."""
|
|
729
|
+
rels: list[dict[str, Any]] = []
|
|
730
|
+
keys = list(architectural_components.keys())
|
|
731
|
+
if len(keys) < 2:
|
|
732
|
+
return rels
|
|
733
|
+
if "core_application" in keys and "configuration_management" in keys:
|
|
734
|
+
rels.append({
|
|
735
|
+
"source": "core_application",
|
|
736
|
+
"target": "configuration_management",
|
|
737
|
+
"type": "uses",
|
|
738
|
+
"description": "Core application uses configuration management for settings",
|
|
739
|
+
"strength": "high",
|
|
740
|
+
})
|
|
741
|
+
if "testing_framework" in keys and "core_application" in keys:
|
|
742
|
+
rels.append({
|
|
743
|
+
"source": "testing_framework",
|
|
744
|
+
"target": "core_application",
|
|
745
|
+
"type": "tests",
|
|
746
|
+
"description": "Testing framework validates core application functionality",
|
|
747
|
+
"strength": "medium",
|
|
748
|
+
})
|
|
749
|
+
return rels
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
def _extract_architectural_insights(
|
|
753
|
+
architectural_components: dict[str, Any],
|
|
754
|
+
component_relationships: list[dict[str, Any]],
|
|
755
|
+
) -> dict[str, Any]:
|
|
756
|
+
"""Derive insights deterministically from components and relationships."""
|
|
757
|
+
insights: dict[str, Any] = {
|
|
758
|
+
"technology_stack": [],
|
|
759
|
+
"architectural_patterns": [],
|
|
760
|
+
"service_architecture": "",
|
|
761
|
+
"data_architecture": "unknown",
|
|
762
|
+
"deployment_approach": "",
|
|
763
|
+
"development_practices": [],
|
|
764
|
+
"coupling_analysis": {},
|
|
765
|
+
"communication_patterns": {},
|
|
766
|
+
"critical_dependencies": [],
|
|
767
|
+
}
|
|
768
|
+
all_tech: set[str] = set()
|
|
769
|
+
for comp in architectural_components.values():
|
|
770
|
+
all_tech.update(comp.get("technologies", []))
|
|
771
|
+
insights["technology_stack"] = sorted(all_tech)
|
|
772
|
+
|
|
773
|
+
patterns: set[str] = set()
|
|
774
|
+
backend_services = [c for c in architectural_components.values() if c.get("category") == "backend"]
|
|
775
|
+
if len(backend_services) > 1:
|
|
776
|
+
insights["service_architecture"] = "microservices"
|
|
777
|
+
patterns.add("Microservices Architecture")
|
|
778
|
+
else:
|
|
779
|
+
insights["service_architecture"] = "monolithic"
|
|
780
|
+
patterns.add("Monolithic Architecture")
|
|
781
|
+
|
|
782
|
+
if "Docker" in all_tech or "Kubernetes" in all_tech:
|
|
783
|
+
patterns.add("Containerized Deployment")
|
|
784
|
+
if "Redis" in all_tech or "RabbitMQ" in all_tech:
|
|
785
|
+
patterns.add("Message Queue Pattern")
|
|
786
|
+
if any("REST" in t for t in all_tech):
|
|
787
|
+
patterns.add("RESTful API Design")
|
|
788
|
+
insights["architectural_patterns"] = sorted(patterns)
|
|
789
|
+
|
|
790
|
+
infra = [c for c in architectural_components.values() if c.get("category") == "infrastructure"]
|
|
791
|
+
if any("kubernetes" in c.get("name", "").lower() for c in infra):
|
|
792
|
+
insights["deployment_approach"] = "kubernetes"
|
|
793
|
+
elif any("docker" in c.get("name", "").lower() for c in infra):
|
|
794
|
+
insights["deployment_approach"] = "containerized"
|
|
795
|
+
else:
|
|
796
|
+
insights["deployment_approach"] = "traditional"
|
|
797
|
+
|
|
798
|
+
return insights
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
def _build_executive_summary_prompt(
|
|
802
|
+
architectural_components: dict[str, Any],
|
|
803
|
+
component_relationships: list[dict[str, Any]],
|
|
804
|
+
insights: dict[str, Any],
|
|
805
|
+
) -> str:
|
|
806
|
+
"""Build prompt for executive summary generation."""
|
|
807
|
+
security_highlights: list[str] = []
|
|
808
|
+
cicd_present = any(c.get("category") == "cicd" for c in architectural_components.values())
|
|
809
|
+
testing_present = any(c.get("category") == "testing" for c in architectural_components.values())
|
|
810
|
+
for comp in architectural_components.values():
|
|
811
|
+
for tech in comp.get("technologies", []):
|
|
812
|
+
name = str(tech).lower()
|
|
813
|
+
if any(kw in name for kw in ("jwt", "oauth", "saml", "csrf", "security", "auth")):
|
|
814
|
+
security_highlights.append(tech)
|
|
815
|
+
security_highlights = list(set(security_highlights))[:10]
|
|
816
|
+
|
|
817
|
+
return (
|
|
818
|
+
"Create a concise executive summary of this software architecture based on "
|
|
819
|
+
"the following analysis:\n\n"
|
|
820
|
+
f"ARCHITECTURAL COMPONENTS ({len(architectural_components)}):\n"
|
|
821
|
+
f"{json.dumps(architectural_components, indent=2)}\n\n"
|
|
822
|
+
f"COMPONENT RELATIONSHIPS ({len(component_relationships)}):\n"
|
|
823
|
+
f"{json.dumps(component_relationships, indent=2)}\n\n"
|
|
824
|
+
f"IDENTIFIED PATTERNS:\n"
|
|
825
|
+
f"- Service Architecture: {insights.get('service_architecture', 'unknown')}\n"
|
|
826
|
+
f"- Deployment Approach: {insights.get('deployment_approach', 'unknown')}\n"
|
|
827
|
+
f"- Technology Stack: {', '.join(insights.get('technology_stack', [])[:8])}\n"
|
|
828
|
+
f"- Architectural Patterns: {', '.join(insights.get('architectural_patterns', []))}\n\n"
|
|
829
|
+
f"SECURITY HIGHLIGHTS:\n"
|
|
830
|
+
f"{', '.join(security_highlights) if security_highlights else 'None detected'}\n\n"
|
|
831
|
+
f"CI/CD PIPELINE PRESENT: {cicd_present}\n"
|
|
832
|
+
f"AUTOMATED TESTING PRESENT: {testing_present}\n\n"
|
|
833
|
+
"**CRITICAL INSTRUCTIONS - MARKDOWN FORMAT REQUIRED:**\n\n"
|
|
834
|
+
"Provide an executive summary in **pure Markdown format**.\n"
|
|
835
|
+
"- DO NOT wrap the response in JSON\n"
|
|
836
|
+
"- Start directly with Markdown headings and content\n"
|
|
837
|
+
"- Use Markdown syntax: # for headings, - for bullets, ** for bold\n"
|
|
838
|
+
"- Maximum length: 500 words\n\n"
|
|
839
|
+
"Cover at least:\n"
|
|
840
|
+
"1. Overall architecture style and approach\n"
|
|
841
|
+
"2. Key technologies and frameworks used\n"
|
|
842
|
+
"3. Main architectural strengths or notable patterns\n"
|
|
843
|
+
"4. Deployment and scalability approach\n"
|
|
844
|
+
"5. Security posture and critical dependencies\n"
|
|
845
|
+
"6. CI/CD & testing maturity (if data available)\n\n"
|
|
846
|
+
"Return ONLY the Markdown content, nothing else."
|
|
847
|
+
)
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def _extract_markdown_from_response(response: str) -> str:
|
|
851
|
+
"""Clean up LLM response to extract pure Markdown content."""
|
|
852
|
+
text = response.strip()
|
|
853
|
+
if text.startswith("{") and text.endswith("}"):
|
|
854
|
+
try:
|
|
855
|
+
parsed = json.loads(text)
|
|
856
|
+
for key in ("executive_summary", "summary", "markdown", "content", "text", "description"):
|
|
857
|
+
if key in parsed and isinstance(parsed[key], str):
|
|
858
|
+
return parsed[key].strip()
|
|
859
|
+
except json.JSONDecodeError:
|
|
860
|
+
pass
|
|
861
|
+
if "```" in text:
|
|
862
|
+
m = re.search(r"```(?:markdown|md)?\s*(.*?)\s*```", text, re.DOTALL)
|
|
863
|
+
if m:
|
|
864
|
+
return m.group(1).strip()
|
|
865
|
+
return text
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
def _are_components_similar(name1: str, data1: dict, name2: str, data2: dict) -> bool:
|
|
869
|
+
"""Check if two components should be merged (name overlap or tech overlap)."""
|
|
870
|
+
words1 = set(name1.lower().split("_"))
|
|
871
|
+
words2 = set(name2.lower().split("_"))
|
|
872
|
+
if len(words1 & words2) >= 2:
|
|
873
|
+
return True
|
|
874
|
+
tech1 = set(data1.get("technologies", []))
|
|
875
|
+
tech2 = set(data2.get("technologies", []))
|
|
876
|
+
if len(tech1 & tech2) >= 2:
|
|
877
|
+
return True
|
|
878
|
+
t1, t2 = data1.get("type", ""), data2.get("type", "")
|
|
879
|
+
if t1 == t2 and t1:
|
|
880
|
+
return True
|
|
881
|
+
return False
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
def _merge_and_deduplicate_components(all_components: dict[str, Any]) -> dict[str, Any]:
|
|
885
|
+
"""Merge similar components and remove duplicates."""
|
|
886
|
+
processed: set[str] = set()
|
|
887
|
+
groups: dict[str, list[str]] = {}
|
|
888
|
+
for name, data in all_components.items():
|
|
889
|
+
if name in processed:
|
|
890
|
+
continue
|
|
891
|
+
group = [name]
|
|
892
|
+
for other, odata in all_components.items():
|
|
893
|
+
if other != name and other not in processed:
|
|
894
|
+
if _are_components_similar(name, data, other, odata):
|
|
895
|
+
group.append(other)
|
|
896
|
+
best = max(group, key=lambda n: all_components[n].get("confidence", 0))
|
|
897
|
+
groups[best] = group
|
|
898
|
+
processed.update(group)
|
|
899
|
+
|
|
900
|
+
final: dict[str, Any] = {}
|
|
901
|
+
for rep, members in groups.items():
|
|
902
|
+
merged = all_components[rep].copy()
|
|
903
|
+
all_tech: set[str] = set(merged.get("technologies", []))
|
|
904
|
+
all_evidence: set[str] = set(merged.get("evidence_sources", []))
|
|
905
|
+
max_conf = merged.get("confidence", 0)
|
|
906
|
+
for m in members[1:] if members[0] == rep else members:
|
|
907
|
+
if m == rep:
|
|
908
|
+
continue
|
|
909
|
+
md = all_components[m]
|
|
910
|
+
all_tech.update(md.get("technologies", []))
|
|
911
|
+
all_evidence.update(md.get("evidence_sources", []))
|
|
912
|
+
mc = md.get("confidence", 0)
|
|
913
|
+
if mc > max_conf:
|
|
914
|
+
max_conf = mc
|
|
915
|
+
merged["technologies"] = sorted(all_tech)
|
|
916
|
+
merged["evidence_sources"] = sorted(all_evidence)
|
|
917
|
+
merged["confidence"] = max_conf
|
|
918
|
+
if len(members) > 1:
|
|
919
|
+
merged["merged_from"] = [m for m in members if m != rep]
|
|
920
|
+
final[rep] = merged
|
|
921
|
+
return final
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
def synthesize_components_locally(
|
|
925
|
+
category_analyses: dict[str, list[dict[str, Any]]],
|
|
926
|
+
anthropic_client: Any,
|
|
927
|
+
openai_client: Any,
|
|
928
|
+
on_progress: Callable[[str], None] | None = None,
|
|
929
|
+
on_debug: Callable[[str], None] | None = None,
|
|
930
|
+
) -> dict[str, Any]:
|
|
931
|
+
"""
|
|
932
|
+
Run the full synthesis pipeline locally: category synthesis, relationship
|
|
933
|
+
extraction, insights and executive summary — all using the user's LLM keys.
|
|
934
|
+
|
|
935
|
+
Returns a dict with keys ``architectural_components``,
|
|
936
|
+
``component_relationships`` and ``architectural_insights`` ready to be
|
|
937
|
+
merged into the phase2 payload.
|
|
938
|
+
"""
|
|
939
|
+
|
|
940
|
+
def prog(msg: str) -> None:
|
|
941
|
+
if on_progress:
|
|
942
|
+
on_progress(msg)
|
|
943
|
+
|
|
944
|
+
# --- Step 1: Synthesise components per category ---
|
|
945
|
+
all_components: dict[str, Any] = {}
|
|
946
|
+
categories_processed: list[str] = []
|
|
947
|
+
|
|
948
|
+
active_categories = [
|
|
949
|
+
cat for cat in ARCHITECTURE_CATEGORIES
|
|
950
|
+
if category_analyses.get(cat)
|
|
951
|
+
]
|
|
952
|
+
prog(f"Synthesising {len(active_categories)} categories...")
|
|
953
|
+
|
|
954
|
+
for category in active_categories:
|
|
955
|
+
analyses = category_analyses[category]
|
|
956
|
+
prog(f" {category} ({len(analyses)} files)...")
|
|
957
|
+
|
|
958
|
+
prompt = _build_category_synthesis_prompt(category, analyses)
|
|
959
|
+
components: dict[str, Any] = {}
|
|
960
|
+
for attempt in range(3):
|
|
961
|
+
raw = _call_llm_for_architecture(prompt, anthropic_client, openai_client, on_debug)
|
|
962
|
+
if not raw:
|
|
963
|
+
time.sleep(2)
|
|
964
|
+
continue
|
|
965
|
+
try:
|
|
966
|
+
components = _parse_category_synthesis_response(raw)
|
|
967
|
+
except json.JSONDecodeError:
|
|
968
|
+
time.sleep(2)
|
|
969
|
+
continue
|
|
970
|
+
if components:
|
|
971
|
+
break
|
|
972
|
+
time.sleep(2)
|
|
973
|
+
|
|
974
|
+
if components:
|
|
975
|
+
all_components.update(components)
|
|
976
|
+
categories_processed.append(category)
|
|
977
|
+
prog(f" {category}: {len(components)} components")
|
|
978
|
+
|
|
979
|
+
if all_components:
|
|
980
|
+
all_components = _merge_and_deduplicate_components(all_components)
|
|
981
|
+
prog(f"Components: {len(all_components)} (from {len(categories_processed)} categories)")
|
|
982
|
+
|
|
983
|
+
# --- Step 2: Extract relationships between components ---
|
|
984
|
+
component_relationships: list[dict[str, Any]] = []
|
|
985
|
+
if all_components:
|
|
986
|
+
prog("Extracting component relationships...")
|
|
987
|
+
rel_prompt = _build_relationship_prompt(all_components, category_analyses)
|
|
988
|
+
raw = _call_llm_for_architecture(rel_prompt, anthropic_client, openai_client, on_debug)
|
|
989
|
+
if raw:
|
|
990
|
+
component_relationships = _parse_relationship_response(raw)
|
|
991
|
+
if not component_relationships:
|
|
992
|
+
component_relationships = _create_fallback_relationships(all_components)
|
|
993
|
+
prog(f"Relationships: {len(component_relationships)}")
|
|
994
|
+
|
|
995
|
+
# --- Step 3: Derive insights (deterministic) ---
|
|
996
|
+
insights = _extract_architectural_insights(all_components, component_relationships)
|
|
997
|
+
|
|
998
|
+
# --- Step 4: Generate executive summary (LLM) ---
|
|
999
|
+
if all_components:
|
|
1000
|
+
prog("Generating executive summary...")
|
|
1001
|
+
summary_prompt = _build_executive_summary_prompt(
|
|
1002
|
+
all_components, component_relationships, insights,
|
|
1003
|
+
)
|
|
1004
|
+
summary_system = (
|
|
1005
|
+
"You are a software architecture analyst that produces clear, concise "
|
|
1006
|
+
"Markdown executive summaries. Respond ONLY with Markdown content."
|
|
1007
|
+
)
|
|
1008
|
+
raw = _call_llm_for_architecture(
|
|
1009
|
+
summary_prompt,
|
|
1010
|
+
anthropic_client,
|
|
1011
|
+
openai_client,
|
|
1012
|
+
on_debug,
|
|
1013
|
+
system_override=summary_system,
|
|
1014
|
+
max_tokens=2000,
|
|
1015
|
+
)
|
|
1016
|
+
if raw:
|
|
1017
|
+
insights["executive_summary"] = _extract_markdown_from_response(raw)
|
|
1018
|
+
else:
|
|
1019
|
+
insights["executive_summary"] = (
|
|
1020
|
+
f"Architecture consists of {len(all_components)} main components "
|
|
1021
|
+
f"using {insights.get('service_architecture', 'unknown')} architecture pattern."
|
|
1022
|
+
)
|
|
1023
|
+
else:
|
|
1024
|
+
insights["executive_summary"] = "No architectural components identified."
|
|
1025
|
+
|
|
1026
|
+
return {
|
|
1027
|
+
"architectural_components": all_components,
|
|
1028
|
+
"component_relationships": component_relationships,
|
|
1029
|
+
"architectural_insights": insights,
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
|
|
1033
|
+
def _analyze_single_file(
|
|
1034
|
+
file_path: str,
|
|
1035
|
+
local_path: str,
|
|
1036
|
+
category: str,
|
|
1037
|
+
anthropic_client: Any,
|
|
1038
|
+
openai_client: Any,
|
|
1039
|
+
on_debug: Callable[[str], None] | None,
|
|
1040
|
+
) -> dict[str, Any] | None:
|
|
1041
|
+
"""Read file, build prompt, call LLM, parse. Returns analysis dict or None."""
|
|
1042
|
+
content = _read_file_safely_local(local_path)
|
|
1043
|
+
if not content:
|
|
1044
|
+
return None
|
|
1045
|
+
prompt = _build_analysis_prompt(file_path, content, category)
|
|
1046
|
+
for attempt in range(3):
|
|
1047
|
+
response = _call_llm_for_architecture(prompt, anthropic_client, openai_client, on_debug)
|
|
1048
|
+
if not response:
|
|
1049
|
+
time.sleep(2)
|
|
1050
|
+
continue
|
|
1051
|
+
parsed = _parse_llm_response(response, file_path, category)
|
|
1052
|
+
if parsed:
|
|
1053
|
+
return parsed
|
|
1054
|
+
time.sleep(2)
|
|
1055
|
+
return None
|
|
1056
|
+
|
|
1057
|
+
|
|
1058
|
+
def run_phase2_with_llm(
|
|
1059
|
+
phase1_results: dict[str, Any],
|
|
1060
|
+
scope_dirs: dict[str, str],
|
|
1061
|
+
sub_audit_uuid: str,
|
|
1062
|
+
repo_name: str,
|
|
1063
|
+
on_progress: Callable[[str], None] | None = None,
|
|
1064
|
+
on_debug: Callable[[str], None] | None = None,
|
|
1065
|
+
) -> dict[str, Any]:
|
|
1066
|
+
"""
|
|
1067
|
+
Run full Phase 2: per-file LLM analysis using local paths from scope_dirs.
|
|
1068
|
+
Resolves cli://sub_audit_uuid/repo_name/rel paths to local paths.
|
|
1069
|
+
Returns phase2 dict with category_analyses populated; server will synthesize components.
|
|
1070
|
+
"""
|
|
1071
|
+
import httpx
|
|
1072
|
+
|
|
1073
|
+
from codedd_cli.llm.key_manager import LLMKeyManager
|
|
1074
|
+
|
|
1075
|
+
key_mgr = LLMKeyManager()
|
|
1076
|
+
anthropic_key = key_mgr.retrieve_key("anthropic")
|
|
1077
|
+
openai_key = key_mgr.retrieve_key("openai")
|
|
1078
|
+
if not anthropic_key and not openai_key:
|
|
1079
|
+
if on_debug:
|
|
1080
|
+
on_debug("No LLM keys configured — skipping Phase 2 LLM analysis")
|
|
1081
|
+
return build_phase2_results_empty("No LLM keys; run codedd llm set-key")
|
|
1082
|
+
|
|
1083
|
+
local_base = scope_dirs.get(repo_name, "")
|
|
1084
|
+
if not local_base or not os.path.isdir(local_base):
|
|
1085
|
+
if on_debug:
|
|
1086
|
+
on_debug("No local scope for repo — skipping Phase 2 LLM")
|
|
1087
|
+
return build_phase2_results_empty("No local scope for repo")
|
|
1088
|
+
|
|
1089
|
+
prefix = f"cli://{sub_audit_uuid}/{repo_name}/"
|
|
1090
|
+
file_categories = phase1_results.get("file_categories") or {}
|
|
1091
|
+
if not file_categories:
|
|
1092
|
+
return build_phase2_results_empty("No file categories in Phase 1")
|
|
1093
|
+
|
|
1094
|
+
anthropic_client = None
|
|
1095
|
+
openai_client = None
|
|
1096
|
+
if anthropic_key:
|
|
1097
|
+
anthropic_client = httpx.Client(
|
|
1098
|
+
base_url="https://api.anthropic.com",
|
|
1099
|
+
headers={
|
|
1100
|
+
"x-api-key": anthropic_key,
|
|
1101
|
+
"anthropic-version": "2023-06-01",
|
|
1102
|
+
"content-type": "application/json",
|
|
1103
|
+
},
|
|
1104
|
+
timeout=180.0,
|
|
1105
|
+
)
|
|
1106
|
+
if openai_key:
|
|
1107
|
+
openai_client = httpx.Client(
|
|
1108
|
+
base_url="https://api.openai.com",
|
|
1109
|
+
headers={
|
|
1110
|
+
"Authorization": f"Bearer {openai_key}",
|
|
1111
|
+
"Content-Type": "application/json",
|
|
1112
|
+
},
|
|
1113
|
+
timeout=180.0,
|
|
1114
|
+
)
|
|
1115
|
+
|
|
1116
|
+
try:
|
|
1117
|
+
category_analyses: dict[str, list[dict[str, Any]]] = {c: [] for c in ARCHITECTURE_CATEGORIES}
|
|
1118
|
+
total_done = 0
|
|
1119
|
+
total_files = sum(len(files) for files in file_categories.values() if files)
|
|
1120
|
+
|
|
1121
|
+
for category, files in file_categories.items():
|
|
1122
|
+
if category not in CATEGORY_PROMPTS or not files:
|
|
1123
|
+
continue
|
|
1124
|
+
if on_progress:
|
|
1125
|
+
on_progress(f"Analysing {category} ({len(files)} files)...")
|
|
1126
|
+
for file_path in files:
|
|
1127
|
+
if not file_path.startswith(prefix):
|
|
1128
|
+
continue
|
|
1129
|
+
rel = file_path[len(prefix) :].lstrip("/")
|
|
1130
|
+
local_path = os.path.normpath(os.path.join(local_base, rel))
|
|
1131
|
+
if not os.path.isfile(local_path):
|
|
1132
|
+
continue
|
|
1133
|
+
result = _analyze_single_file(
|
|
1134
|
+
file_path, local_path, category,
|
|
1135
|
+
anthropic_client, openai_client, on_debug,
|
|
1136
|
+
)
|
|
1137
|
+
if result:
|
|
1138
|
+
category_analyses.setdefault(category, []).append(result)
|
|
1139
|
+
total_done += 1
|
|
1140
|
+
if on_progress and total_done % 5 == 0:
|
|
1141
|
+
on_progress(f"Architecture: {total_done}/{total_files} files...")
|
|
1142
|
+
|
|
1143
|
+
categories_with_results = [c for c, lst in category_analyses.items() if lst]
|
|
1144
|
+
|
|
1145
|
+
# --- Phase 2.5: Synthesise components, relationships, insights locally ---
|
|
1146
|
+
if categories_with_results:
|
|
1147
|
+
if on_progress:
|
|
1148
|
+
on_progress("Synthesising architecture (components, relationships, insights)...")
|
|
1149
|
+
synthesis = synthesize_components_locally(
|
|
1150
|
+
category_analyses,
|
|
1151
|
+
anthropic_client,
|
|
1152
|
+
openai_client,
|
|
1153
|
+
on_progress=on_progress,
|
|
1154
|
+
on_debug=on_debug,
|
|
1155
|
+
)
|
|
1156
|
+
else:
|
|
1157
|
+
synthesis = {
|
|
1158
|
+
"architectural_components": {},
|
|
1159
|
+
"component_relationships": [],
|
|
1160
|
+
"architectural_insights": {
|
|
1161
|
+
"technology_stack": [],
|
|
1162
|
+
"architectural_patterns": [],
|
|
1163
|
+
"service_architecture": "unknown",
|
|
1164
|
+
"data_architecture": "unknown",
|
|
1165
|
+
"deployment_approach": "unknown",
|
|
1166
|
+
"development_practices": [],
|
|
1167
|
+
"coupling_analysis": {},
|
|
1168
|
+
"communication_patterns": {},
|
|
1169
|
+
"critical_dependencies": [],
|
|
1170
|
+
"executive_summary": "No file categories with results for synthesis.",
|
|
1171
|
+
},
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
phase2 = _build_phase2_skeleton(
|
|
1175
|
+
category_analyses=category_analyses,
|
|
1176
|
+
total_files_analyzed=sum(len(lst) for lst in category_analyses.values()),
|
|
1177
|
+
categories_analyzed=categories_with_results,
|
|
1178
|
+
executive_summary=synthesis["architectural_insights"].get(
|
|
1179
|
+
"executive_summary",
|
|
1180
|
+
f"CLI Phase 2: {total_done} files analysed.",
|
|
1181
|
+
),
|
|
1182
|
+
)
|
|
1183
|
+
# Overlay synthesis results onto the skeleton
|
|
1184
|
+
phase2["architectural_components"] = synthesis["architectural_components"]
|
|
1185
|
+
phase2["component_relationships"] = synthesis["component_relationships"]
|
|
1186
|
+
phase2["architectural_insights"] = synthesis["architectural_insights"]
|
|
1187
|
+
|
|
1188
|
+
return phase2
|
|
1189
|
+
finally:
|
|
1190
|
+
if anthropic_client:
|
|
1191
|
+
anthropic_client.close()
|
|
1192
|
+
if openai_client:
|
|
1193
|
+
openai_client.close()
|
|
1194
|
+
|
|
1195
|
+
|
|
1196
|
+
def run_architecture_analysis(
|
|
1197
|
+
sub_audit_uuid: str,
|
|
1198
|
+
repo_name: str,
|
|
1199
|
+
file_paths: list[str],
|
|
1200
|
+
scope_dirs: Optional[dict[str, str]] = None,
|
|
1201
|
+
on_progress: Optional[Callable[[str], None]] = None,
|
|
1202
|
+
on_debug: Optional[Callable[[str], None]] = None,
|
|
1203
|
+
) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
1204
|
+
"""
|
|
1205
|
+
Run local architecture Phase 1, Phase 2 and synthesis for one sub-audit.
|
|
1206
|
+
|
|
1207
|
+
file_paths: list of file paths in TypeDB form (cli://uuid/repo/rel).
|
|
1208
|
+
scope_dirs: optional mapping repo_name -> local directory; required for full Phase 2.
|
|
1209
|
+
Returns (phase1_results, phase2_results) to POST to the server.
|
|
1210
|
+
|
|
1211
|
+
When scope_dirs is provided and LLM keys are configured, runs full Phase 2
|
|
1212
|
+
(per-file LLM analysis) **and** synthesis (components, relationships,
|
|
1213
|
+
insights) locally using the user's LLM API keys. The server only needs
|
|
1214
|
+
to run Phase 3 (graph construction + TypeDB storage) — no server-side LLM
|
|
1215
|
+
calls are required.
|
|
1216
|
+
"""
|
|
1217
|
+
def prog(msg: str) -> None:
|
|
1218
|
+
if on_progress:
|
|
1219
|
+
on_progress(msg)
|
|
1220
|
+
|
|
1221
|
+
repo_path = f"cli://{sub_audit_uuid}/{repo_name}"
|
|
1222
|
+
prog("Building file categories (Phase 1)...")
|
|
1223
|
+
phase1 = build_phase1_results(file_paths, repo_path)
|
|
1224
|
+
|
|
1225
|
+
if scope_dirs and scope_dirs.get(repo_name):
|
|
1226
|
+
prog("Running Phase 2 (LLM analysis per file)...")
|
|
1227
|
+
phase2 = run_phase2_with_llm(
|
|
1228
|
+
phase1,
|
|
1229
|
+
scope_dirs=scope_dirs,
|
|
1230
|
+
sub_audit_uuid=sub_audit_uuid,
|
|
1231
|
+
repo_name=repo_name,
|
|
1232
|
+
on_progress=on_progress,
|
|
1233
|
+
on_debug=on_debug,
|
|
1234
|
+
)
|
|
1235
|
+
else:
|
|
1236
|
+
prog("Preparing architecture payload (Phase 2 stub; no scope_dirs or LLM)...")
|
|
1237
|
+
phase2 = build_phase2_results_empty(
|
|
1238
|
+
"No scope_dirs for this repo or LLM not configured; run with --scope or set LLM keys for full Phase 2."
|
|
1239
|
+
)
|
|
1240
|
+
|
|
1241
|
+
return phase1, phase2
|