mnemo-dev 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.
- mnemo/__init__.py +3 -0
- mnemo/analyzers/__init__.py +108 -0
- mnemo/api_discovery/__init__.py +248 -0
- mnemo/chunking.py +136 -0
- mnemo/cli.py +186 -0
- mnemo/clients.py +147 -0
- mnemo/code_review/__init__.py +68 -0
- mnemo/config.py +30 -0
- mnemo/dependency_graph/__init__.py +126 -0
- mnemo/doctor.py +118 -0
- mnemo/embeddings/__init__.py +47 -0
- mnemo/errors/__init__.py +81 -0
- mnemo/health/__init__.py +103 -0
- mnemo/incidents/__init__.py +90 -0
- mnemo/init.py +167 -0
- mnemo/intelligence/__init__.py +323 -0
- mnemo/knowledge/__init__.py +118 -0
- mnemo/mcp_server.py +458 -0
- mnemo/memory.py +250 -0
- mnemo/onboarding/__init__.py +86 -0
- mnemo/repo_map.py +357 -0
- mnemo/retrieval.py +31 -0
- mnemo/sprint/__init__.py +102 -0
- mnemo/storage.py +215 -0
- mnemo/team_graph/__init__.py +96 -0
- mnemo/test_intel/__init__.py +111 -0
- mnemo/vector_index/__init__.py +180 -0
- mnemo/workspace/__init__.py +224 -0
- mnemo_dev-0.1.0.dist-info/METADATA +644 -0
- mnemo_dev-0.1.0.dist-info/RECORD +33 -0
- mnemo_dev-0.1.0.dist-info/WHEEL +5 -0
- mnemo_dev-0.1.0.dist-info/entry_points.txt +3 -0
- mnemo_dev-0.1.0.dist-info/top_level.txt +1 -0
mnemo/health/__init__.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Code Health Score — track complexity hotspots, churn, and metrics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from ..config import IGNORE_DIRS
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _should_ignore(path: Path) -> bool:
|
|
12
|
+
return any(part in IGNORE_DIRS for part in path.parts)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _count_methods(content: str) -> int:
|
|
16
|
+
"""Count methods in a C# file."""
|
|
17
|
+
return len(re.findall(r'(public|private|protected|internal)\s+(static\s+)?(async\s+)?\S+\s+\w+\s*\(', content))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _estimate_complexity(content: str) -> int:
|
|
21
|
+
"""Rough cyclomatic complexity estimate based on branching keywords."""
|
|
22
|
+
keywords = ["if ", "else ", "switch ", "case ", "for ", "foreach ", "while ", "catch ", "&&", "||", "??", "?.", "=>"]
|
|
23
|
+
return sum(content.count(k) for k in keywords)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _get_git_churn(repo_root: Path, filepath: Path) -> int:
|
|
27
|
+
"""Get number of commits that touched this file."""
|
|
28
|
+
try:
|
|
29
|
+
from git import Repo
|
|
30
|
+
repo = Repo(repo_root)
|
|
31
|
+
rel = str(filepath.relative_to(repo_root))
|
|
32
|
+
commits = list(repo.iter_commits(paths=rel, max_count=100))
|
|
33
|
+
return len(commits)
|
|
34
|
+
except Exception:
|
|
35
|
+
return 0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def calculate_health(repo_root: Path) -> str:
|
|
39
|
+
"""Calculate code health metrics for the project."""
|
|
40
|
+
lines = ["# Code Health Report\n"]
|
|
41
|
+
|
|
42
|
+
hotspots: list[tuple[str, int, int, int]] = [] # (file, complexity, methods, lines)
|
|
43
|
+
|
|
44
|
+
for cs_file in repo_root.rglob("*.cs"):
|
|
45
|
+
if _should_ignore(cs_file) or "Test" in str(cs_file):
|
|
46
|
+
continue
|
|
47
|
+
try:
|
|
48
|
+
content = cs_file.read_text(errors="replace")
|
|
49
|
+
except (OSError, PermissionError):
|
|
50
|
+
continue
|
|
51
|
+
|
|
52
|
+
if len(content) < 100:
|
|
53
|
+
continue
|
|
54
|
+
|
|
55
|
+
rel = str(cs_file.relative_to(repo_root))
|
|
56
|
+
complexity = _estimate_complexity(content)
|
|
57
|
+
methods = _count_methods(content)
|
|
58
|
+
line_count = content.count("\n")
|
|
59
|
+
|
|
60
|
+
hotspots.append((rel, complexity, methods, line_count))
|
|
61
|
+
|
|
62
|
+
if not hotspots:
|
|
63
|
+
return "No source files found for health analysis."
|
|
64
|
+
|
|
65
|
+
# Sort by complexity (highest first)
|
|
66
|
+
hotspots.sort(key=lambda x: -x[1])
|
|
67
|
+
|
|
68
|
+
# Overall stats
|
|
69
|
+
total_files = len(hotspots)
|
|
70
|
+
total_lines = sum(h[3] for h in hotspots)
|
|
71
|
+
total_methods = sum(h[2] for h in hotspots)
|
|
72
|
+
avg_complexity = sum(h[1] for h in hotspots) / total_files
|
|
73
|
+
|
|
74
|
+
lines.append("## Summary")
|
|
75
|
+
lines.append(f"- **Files:** {total_files}")
|
|
76
|
+
lines.append(f"- **Total lines:** {total_lines:,}")
|
|
77
|
+
lines.append(f"- **Total methods:** {total_methods}")
|
|
78
|
+
lines.append(f"- **Avg complexity/file:** {avg_complexity:.1f}")
|
|
79
|
+
lines.append("")
|
|
80
|
+
|
|
81
|
+
# Complexity hotspots
|
|
82
|
+
lines.append("## Complexity Hotspots (top 10)")
|
|
83
|
+
lines.append("Files with highest branching complexity — candidates for refactoring:")
|
|
84
|
+
lines.append("")
|
|
85
|
+
for rel, complexity, methods, line_count in hotspots[:10]:
|
|
86
|
+
lines.append(f"- **{rel}** — complexity: {complexity}, methods: {methods}, lines: {line_count}")
|
|
87
|
+
lines.append("")
|
|
88
|
+
|
|
89
|
+
# Large files
|
|
90
|
+
large = sorted(hotspots, key=lambda x: -x[3])[:10]
|
|
91
|
+
lines.append("## Largest Files (top 10)")
|
|
92
|
+
for rel, complexity, methods, line_count in large:
|
|
93
|
+
lines.append(f"- **{rel}** — {line_count} lines, {methods} methods")
|
|
94
|
+
lines.append("")
|
|
95
|
+
|
|
96
|
+
# Files with many methods (god classes)
|
|
97
|
+
many_methods = sorted(hotspots, key=lambda x: -x[2])[:5]
|
|
98
|
+
lines.append("## Potential God Classes (most methods)")
|
|
99
|
+
for rel, complexity, methods, line_count in many_methods:
|
|
100
|
+
if methods > 10:
|
|
101
|
+
lines.append(f"- **{rel}** — {methods} methods")
|
|
102
|
+
|
|
103
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Incident memory - store production incidents, root cause, fix, and prevention."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from ..storage import Collections, get_storage
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _load_incidents(repo_root: Path) -> list[dict]:
|
|
12
|
+
data = get_storage(repo_root).read_collection(Collections.INCIDENTS)
|
|
13
|
+
return data if isinstance(data, list) else []
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _save_incidents(repo_root: Path, incidents: list[dict]) -> None:
|
|
17
|
+
get_storage(repo_root).write_collection(Collections.INCIDENTS, incidents)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def add_incident(
|
|
21
|
+
repo_root: Path,
|
|
22
|
+
title: str,
|
|
23
|
+
what_happened: str,
|
|
24
|
+
root_cause: str,
|
|
25
|
+
fix: str,
|
|
26
|
+
prevention: str = "",
|
|
27
|
+
severity: str = "medium",
|
|
28
|
+
services: list[str] | None = None,
|
|
29
|
+
) -> dict:
|
|
30
|
+
"""Store a production incident."""
|
|
31
|
+
incidents = _load_incidents(repo_root)
|
|
32
|
+
entry = {
|
|
33
|
+
"id": len(incidents) + 1,
|
|
34
|
+
"timestamp": time.time(),
|
|
35
|
+
"title": title,
|
|
36
|
+
"what_happened": what_happened,
|
|
37
|
+
"root_cause": root_cause,
|
|
38
|
+
"fix": fix,
|
|
39
|
+
"prevention": prevention,
|
|
40
|
+
"severity": severity,
|
|
41
|
+
"services": services or [],
|
|
42
|
+
}
|
|
43
|
+
incidents.append(entry)
|
|
44
|
+
_save_incidents(repo_root, incidents)
|
|
45
|
+
return entry
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def search_incidents(repo_root: Path, query: str) -> str:
|
|
49
|
+
"""Search incident history."""
|
|
50
|
+
incidents = _load_incidents(repo_root)
|
|
51
|
+
query_lower = query.lower()
|
|
52
|
+
|
|
53
|
+
matches = [
|
|
54
|
+
incident
|
|
55
|
+
for incident in incidents
|
|
56
|
+
if query_lower in incident.get("title", "").lower()
|
|
57
|
+
or query_lower in incident.get("root_cause", "").lower()
|
|
58
|
+
or query_lower in incident.get("what_happened", "").lower()
|
|
59
|
+
or any(query_lower in service.lower() for service in incident.get("services", []))
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
if not matches:
|
|
63
|
+
return f"No incidents matching '{query}'."
|
|
64
|
+
|
|
65
|
+
lines = [f"# Incidents matching '{query}'\n"]
|
|
66
|
+
for incident in matches:
|
|
67
|
+
severity = f"[{incident['severity']}]" if incident.get("severity") else ""
|
|
68
|
+
lines.append(f"## {incident['title']} {severity}")
|
|
69
|
+
lines.append(f"**What happened:** {incident['what_happened']}")
|
|
70
|
+
lines.append(f"**Root cause:** {incident['root_cause']}")
|
|
71
|
+
lines.append(f"**Fix:** {incident['fix']}")
|
|
72
|
+
if incident.get("prevention"):
|
|
73
|
+
lines.append(f"**Prevention:** {incident['prevention']}")
|
|
74
|
+
if incident.get("services"):
|
|
75
|
+
lines.append(f"**Services affected:** {', '.join(incident['services'])}")
|
|
76
|
+
lines.append("")
|
|
77
|
+
return "\n".join(lines)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def format_incidents(repo_root: Path) -> str:
|
|
81
|
+
"""Format all incidents as markdown."""
|
|
82
|
+
incidents = _load_incidents(repo_root)
|
|
83
|
+
if not incidents:
|
|
84
|
+
return "No incidents recorded."
|
|
85
|
+
|
|
86
|
+
lines = ["# Incident History\n"]
|
|
87
|
+
for incident in incidents:
|
|
88
|
+
severity = f"[{incident['severity']}]" if incident.get("severity") else ""
|
|
89
|
+
lines.append(f"- {severity} **{incident['title']}** - {incident['root_cause'][:80]}")
|
|
90
|
+
return "\n".join(lines)
|
mnemo/init.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Initialize .mnemo in a repository and configure MCP clients."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from .clients import (
|
|
6
|
+
CLIENTS,
|
|
7
|
+
DEFAULT_CLIENT,
|
|
8
|
+
ClientTarget,
|
|
9
|
+
context_path,
|
|
10
|
+
resolve_clients,
|
|
11
|
+
setup_mcp_config,
|
|
12
|
+
)
|
|
13
|
+
from .config import mnemo_path
|
|
14
|
+
from .memory import save_context
|
|
15
|
+
from .repo_map import save_repo_map
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
MNEMO_RULE_HEADER = """\
|
|
19
|
+
You have access to Mnemo - a persistent memory system for this project.
|
|
20
|
+
All project context, decisions, and chat history is below. Use it to answer questions without re-reading files.
|
|
21
|
+
|
|
22
|
+
AT THE START OF EVERY CHAT:
|
|
23
|
+
- Call `mnemo_recall` to get the latest context. The embedded context below may be stale.
|
|
24
|
+
|
|
25
|
+
ANSWERING QUESTIONS:
|
|
26
|
+
- If the recalled memory already contains the answer, USE IT DIRECTLY. Do not re-read files or re-run lookups for information already in memory.
|
|
27
|
+
- Only call `mnemo_lookup` or read files when memory does not have enough detail to answer.
|
|
28
|
+
|
|
29
|
+
SAVING MEMORY:
|
|
30
|
+
- Call `mnemo_remember` AFTER any of these happen in the conversation:
|
|
31
|
+
- You made a code change that affects behavior (theme change, config change, new feature, refactor)
|
|
32
|
+
- A bug was found and fixed
|
|
33
|
+
- A design or architecture decision was made
|
|
34
|
+
- The user stated a preference or convention
|
|
35
|
+
- A TODO or follow-up was identified
|
|
36
|
+
- You learned something non-obvious about the codebase
|
|
37
|
+
- Call `mnemo_remember` when the context window is getting long to summarize progress so far.
|
|
38
|
+
- Call `mnemo_remember` when the user explicitly asks to remember something.
|
|
39
|
+
- Do NOT save trivial things like "read a file" or "answered a question with no new insight".
|
|
40
|
+
- When in doubt, SAVE. It is better to remember too much than to forget something useful.
|
|
41
|
+
- RULE: If you called `mnemo_lookup`, `mnemo_similar`, or `mnemo_who_touched` AND produced a summary or analysis from the results, you MUST call `mnemo_remember` with a concise summary before ending your response.
|
|
42
|
+
|
|
43
|
+
AVAILABLE TOOLS:
|
|
44
|
+
- `mnemo_lookup` - get method-level details for a file or folder
|
|
45
|
+
- `mnemo_similar` - find similar implementations to follow as patterns
|
|
46
|
+
- `mnemo_intelligence` - architecture graph, patterns, dependencies
|
|
47
|
+
- `mnemo_discover_apis` - discover all API endpoints
|
|
48
|
+
- `mnemo_search_api` - search for a specific endpoint
|
|
49
|
+
- `mnemo_knowledge` - search team knowledge base
|
|
50
|
+
- `mnemo_decide` - record a decision
|
|
51
|
+
- `mnemo_context` - save project metadata
|
|
52
|
+
- `mnemo_map` - refresh code map after changes
|
|
53
|
+
- `mnemo_cross_search` - search across ALL linked repos (use when code might live in a sibling service)
|
|
54
|
+
- `mnemo_cross_impact` - cross-repo impact analysis (what breaks in other repos if you change something here)
|
|
55
|
+
- `mnemo_links` - show linked repos
|
|
56
|
+
|
|
57
|
+
CROSS-REPO AWARENESS:
|
|
58
|
+
- This repo has linked sibling repos. Use `mnemo_links` to see them.
|
|
59
|
+
- ALWAYS call `mnemo_cross_search` BEFORE using grep or reading files when:
|
|
60
|
+
- The user asks about code that does not exist in this repo
|
|
61
|
+
- The user mentions a service, project, or module name that is not a folder in this repo
|
|
62
|
+
- `mnemo_lookup` or `mnemo_similar` returned no results
|
|
63
|
+
- If the user asks "what breaks if I change X", use `mnemo_cross_impact` for full cross-repo analysis.
|
|
64
|
+
- NEVER fall back to grep for code in other repos. Use `mnemo_cross_search` instead.
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _build_rule_with_context(repo_root: Path) -> str:
|
|
72
|
+
"""Build a client context file with embedded Mnemo context."""
|
|
73
|
+
from .memory import recall
|
|
74
|
+
|
|
75
|
+
context = recall(repo_root)
|
|
76
|
+
return MNEMO_RULE_HEADER + context if context else MNEMO_RULE_HEADER
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _install_context_file(repo_root: Path, target: ClientTarget) -> Path | None:
|
|
80
|
+
"""Install or refresh the repo-local context file for a client."""
|
|
81
|
+
path = context_path(repo_root, target)
|
|
82
|
+
if path is None:
|
|
83
|
+
return None
|
|
84
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
path.write_text(_build_rule_with_context(repo_root), encoding="utf-8")
|
|
86
|
+
return path
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def refresh_context_files(repo_root: Path) -> None:
|
|
90
|
+
"""Refresh all known Mnemo client context files that already exist."""
|
|
91
|
+
for target in CLIENTS.values():
|
|
92
|
+
path = context_path(repo_root, target)
|
|
93
|
+
if path and path.exists():
|
|
94
|
+
path.write_text(_build_rule_with_context(repo_root), encoding="utf-8")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _ensure_gitignore(repo_root: Path) -> None:
|
|
98
|
+
"""Ensure local Mnemo data is ignored by git."""
|
|
99
|
+
gitignore = repo_root / ".gitignore"
|
|
100
|
+
if gitignore.exists():
|
|
101
|
+
content = gitignore.read_text(encoding="utf-8")
|
|
102
|
+
if ".mnemo" not in content:
|
|
103
|
+
gitignore.write_text(content.rstrip() + "\n.mnemo/\n", encoding="utf-8")
|
|
104
|
+
else:
|
|
105
|
+
gitignore.write_text(".mnemo/\n", encoding="utf-8")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def init(repo_root: Path, client: str = DEFAULT_CLIENT) -> str:
|
|
109
|
+
"""Create .mnemo/, generate repo map, install context files, and configure MCP."""
|
|
110
|
+
import os
|
|
111
|
+
os.environ["MNEMO_AUTO_INSTALL"] = "1"
|
|
112
|
+
|
|
113
|
+
targets = resolve_clients(client)
|
|
114
|
+
base = mnemo_path(repo_root)
|
|
115
|
+
base.mkdir(exist_ok=True)
|
|
116
|
+
|
|
117
|
+
save_repo_map(repo_root)
|
|
118
|
+
|
|
119
|
+
from .knowledge import init_knowledge
|
|
120
|
+
|
|
121
|
+
init_knowledge(repo_root)
|
|
122
|
+
|
|
123
|
+
from .intelligence import detect_patterns
|
|
124
|
+
|
|
125
|
+
patterns = detect_patterns(repo_root)
|
|
126
|
+
context_data = {
|
|
127
|
+
"repo_root": str(repo_root),
|
|
128
|
+
"initialized": True,
|
|
129
|
+
}
|
|
130
|
+
if patterns:
|
|
131
|
+
context_data["patterns"] = patterns
|
|
132
|
+
save_context(repo_root, context_data)
|
|
133
|
+
|
|
134
|
+
_ensure_gitignore(repo_root)
|
|
135
|
+
|
|
136
|
+
context_results: list[tuple[ClientTarget, Path | None]] = []
|
|
137
|
+
config_results: list[tuple[ClientTarget, bool]] = []
|
|
138
|
+
for target in targets:
|
|
139
|
+
context_results.append((target, _install_context_file(repo_root, target)))
|
|
140
|
+
config_results.append((target, setup_mcp_config(target)))
|
|
141
|
+
|
|
142
|
+
lines = [
|
|
143
|
+
"Mnemo initialized",
|
|
144
|
+
f"- .mnemo/ created at {base}",
|
|
145
|
+
"- Repo map generated",
|
|
146
|
+
"- Knowledge base directory ready",
|
|
147
|
+
]
|
|
148
|
+
|
|
149
|
+
for target, path in context_results:
|
|
150
|
+
if path:
|
|
151
|
+
rel = path.relative_to(repo_root)
|
|
152
|
+
lines.append(f"- {target.display_name} {target.context_label} installed at {rel}")
|
|
153
|
+
|
|
154
|
+
for target, changed in config_results:
|
|
155
|
+
if target.mcp_config_path:
|
|
156
|
+
state = "configured" if changed else "already configured"
|
|
157
|
+
lines.append(f"- {target.display_name} MCP {state} at {target.mcp_config_path}")
|
|
158
|
+
|
|
159
|
+
if client == DEFAULT_CLIENT:
|
|
160
|
+
lines.append("")
|
|
161
|
+
lines.append("Amazon Q will now recall project memory at the start of every new chat.")
|
|
162
|
+
else:
|
|
163
|
+
client_names = ", ".join(target.display_name for target in targets)
|
|
164
|
+
lines.append("")
|
|
165
|
+
lines.append(f"{client_names} will now have access to Mnemo project memory.")
|
|
166
|
+
|
|
167
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""Code Intelligence — architecture graph, dependencies, patterns, ownership."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from ..config import IGNORE_DIRS, mnemo_path
|
|
11
|
+
from ..retrieval import semantic_query
|
|
12
|
+
from ..sprint import get_current_task
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _should_ignore(path: Path) -> bool:
|
|
16
|
+
return any(part in IGNORE_DIRS for part in path.parts)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# --- Architecture Graph (service-to-service calls) ---
|
|
20
|
+
|
|
21
|
+
def detect_service_calls(repo_root: Path) -> dict[str, list[str]]:
|
|
22
|
+
"""Detect HTTP client calls between services by scanning for HttpClient, base URLs, etc."""
|
|
23
|
+
graph: dict[str, set[str]] = {}
|
|
24
|
+
|
|
25
|
+
for cs_file in repo_root.rglob("*.cs"):
|
|
26
|
+
if _should_ignore(cs_file):
|
|
27
|
+
continue
|
|
28
|
+
try:
|
|
29
|
+
content = cs_file.read_text(errors="replace")
|
|
30
|
+
except (OSError, PermissionError):
|
|
31
|
+
continue
|
|
32
|
+
|
|
33
|
+
# Identify which service this file belongs to
|
|
34
|
+
parts = cs_file.relative_to(repo_root).parts
|
|
35
|
+
service = parts[0] if len(parts) > 1 else "root"
|
|
36
|
+
if service not in graph:
|
|
37
|
+
graph[service] = set()
|
|
38
|
+
|
|
39
|
+
# Detect outbound HTTP calls
|
|
40
|
+
urls = re.findall(r'["\']https?://[^"\']+["\']', content)
|
|
41
|
+
|
|
42
|
+
for url in urls:
|
|
43
|
+
# Try to identify target service from URL
|
|
44
|
+
for known_svc in ["eligibility", "isauth", "providersearch", "servicereview", "auditlog", "mockdb"]:
|
|
45
|
+
if known_svc in url.lower():
|
|
46
|
+
graph[service].add(known_svc)
|
|
47
|
+
|
|
48
|
+
return {k: sorted(v) for k, v in graph.items() if v}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# --- Dependency Map ---
|
|
52
|
+
|
|
53
|
+
def detect_dependencies(repo_root: Path) -> dict[str, list[str]]:
|
|
54
|
+
"""Parse .csproj, package.json, requirements.txt for dependencies."""
|
|
55
|
+
deps: dict[str, list[str]] = {}
|
|
56
|
+
|
|
57
|
+
# .csproj files (NuGet)
|
|
58
|
+
for csproj in repo_root.rglob("*.csproj"):
|
|
59
|
+
if _should_ignore(csproj):
|
|
60
|
+
continue
|
|
61
|
+
try:
|
|
62
|
+
content = csproj.read_text(errors="replace")
|
|
63
|
+
except (OSError, PermissionError):
|
|
64
|
+
continue
|
|
65
|
+
packages = re.findall(r'<PackageReference\s+Include="([^"]+)"(?:\s+Version="([^"]*)")?', content)
|
|
66
|
+
if packages:
|
|
67
|
+
name = csproj.stem
|
|
68
|
+
deps[name] = [f"{pkg} {ver}".strip() for pkg, ver in packages]
|
|
69
|
+
|
|
70
|
+
# package.json
|
|
71
|
+
for pkg_json in repo_root.rglob("package.json"):
|
|
72
|
+
if _should_ignore(pkg_json):
|
|
73
|
+
continue
|
|
74
|
+
try:
|
|
75
|
+
data = json.loads(pkg_json.read_text())
|
|
76
|
+
except (OSError, json.JSONDecodeError):
|
|
77
|
+
continue
|
|
78
|
+
all_deps = {}
|
|
79
|
+
all_deps.update(data.get("dependencies", {}))
|
|
80
|
+
all_deps.update(data.get("devDependencies", {}))
|
|
81
|
+
if all_deps:
|
|
82
|
+
name = data.get("name", pkg_json.parent.name)
|
|
83
|
+
deps[name] = [f"{k} {v}" for k, v in all_deps.items()]
|
|
84
|
+
|
|
85
|
+
return deps
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# --- Pattern Detection ---
|
|
89
|
+
|
|
90
|
+
def detect_patterns(repo_root: Path) -> list[str]:
|
|
91
|
+
"""Detect common code patterns and conventions."""
|
|
92
|
+
patterns: list[str] = []
|
|
93
|
+
|
|
94
|
+
# Check for common .NET patterns
|
|
95
|
+
controllers = list(repo_root.rglob("*Controller.cs"))
|
|
96
|
+
if controllers:
|
|
97
|
+
# Check what they inherit from
|
|
98
|
+
for c in controllers[:3]:
|
|
99
|
+
try:
|
|
100
|
+
content = c.read_text(errors="replace")
|
|
101
|
+
if "ControllerBase" in content:
|
|
102
|
+
patterns.append("Controllers inherit from ControllerBase (API controllers, no views)")
|
|
103
|
+
break
|
|
104
|
+
elif "Controller" in content:
|
|
105
|
+
patterns.append("Controllers inherit from Controller (MVC with views)")
|
|
106
|
+
break
|
|
107
|
+
except (OSError, PermissionError):
|
|
108
|
+
continue
|
|
109
|
+
|
|
110
|
+
# Check for repository pattern
|
|
111
|
+
repos = list(repo_root.rglob("*Repository.cs"))
|
|
112
|
+
interfaces = list(repo_root.rglob("I*Repository.cs"))
|
|
113
|
+
if repos and interfaces:
|
|
114
|
+
patterns.append(f"Repository pattern with interfaces ({len(interfaces)} interfaces, {len(repos)} implementations)")
|
|
115
|
+
|
|
116
|
+
# Check for handler/strategy pattern
|
|
117
|
+
handlers = list(repo_root.rglob("*Handler.cs"))
|
|
118
|
+
if len(handlers) > 2:
|
|
119
|
+
patterns.append(f"Strategy/Handler pattern ({len(handlers)} handlers found)")
|
|
120
|
+
|
|
121
|
+
# Check for DI registration
|
|
122
|
+
for f in repo_root.rglob("Program.cs"):
|
|
123
|
+
if _should_ignore(f):
|
|
124
|
+
continue
|
|
125
|
+
try:
|
|
126
|
+
content = f.read_text(errors="replace")
|
|
127
|
+
if "AddScoped" in content or "AddTransient" in content or "AddSingleton" in content:
|
|
128
|
+
patterns.append("Dependency injection via built-in .NET DI container")
|
|
129
|
+
break
|
|
130
|
+
except (OSError, PermissionError):
|
|
131
|
+
continue
|
|
132
|
+
|
|
133
|
+
# Check for test patterns
|
|
134
|
+
test_files = list(repo_root.rglob("*Tests.cs"))
|
|
135
|
+
if test_files:
|
|
136
|
+
try:
|
|
137
|
+
sample = test_files[0].read_text(errors="replace")
|
|
138
|
+
if "xUnit" in sample or "[Fact]" in sample:
|
|
139
|
+
patterns.append("Testing with xUnit ([Fact], [Theory])")
|
|
140
|
+
elif "[Test]" in sample:
|
|
141
|
+
patterns.append("Testing with NUnit")
|
|
142
|
+
if "Moq" in sample or "Mock<" in sample:
|
|
143
|
+
patterns.append("Mocking with Moq")
|
|
144
|
+
if "FluentAssertions" in sample:
|
|
145
|
+
patterns.append("Assertions with FluentAssertions")
|
|
146
|
+
except (OSError, PermissionError):
|
|
147
|
+
pass
|
|
148
|
+
|
|
149
|
+
# Check for CosmosDB
|
|
150
|
+
cosmos_files = [f for f in repo_root.rglob("*.cs") if not _should_ignore(f)]
|
|
151
|
+
for f in cosmos_files[:50]:
|
|
152
|
+
try:
|
|
153
|
+
if "CosmosClient" in f.read_text(errors="replace"):
|
|
154
|
+
patterns.append("Data layer: Azure CosmosDB")
|
|
155
|
+
break
|
|
156
|
+
except (OSError, PermissionError):
|
|
157
|
+
continue
|
|
158
|
+
|
|
159
|
+
return patterns
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def classify_architecture(repo_root: Path) -> list[dict[str, Any]]:
|
|
163
|
+
"""Classify high-level architecture styles with evidence."""
|
|
164
|
+
findings: list[dict[str, Any]] = []
|
|
165
|
+
paths = [p for p in repo_root.rglob("*") if not _should_ignore(p)]
|
|
166
|
+
names = [str(p).lower() for p in paths]
|
|
167
|
+
text_blob = " ".join(names)
|
|
168
|
+
|
|
169
|
+
checks = [
|
|
170
|
+
("Clean Architecture", ["domain", "application", "infrastructure", "presentation"]),
|
|
171
|
+
("CQRS", ["command", "query", "handler"]),
|
|
172
|
+
("Hexagonal", ["ports", "adapters"]),
|
|
173
|
+
("Event-Driven", ["event", "consumer", "producer"]),
|
|
174
|
+
("Microservices", ["docker-compose", "helm", "service", "gateway"]),
|
|
175
|
+
]
|
|
176
|
+
for arch, signals in checks:
|
|
177
|
+
matched = [signal for signal in signals if signal in text_blob]
|
|
178
|
+
if matched:
|
|
179
|
+
findings.append(
|
|
180
|
+
{
|
|
181
|
+
"name": arch,
|
|
182
|
+
"confidence": round(len(matched) / len(signals), 2),
|
|
183
|
+
"evidence": matched,
|
|
184
|
+
}
|
|
185
|
+
)
|
|
186
|
+
return sorted(findings, key=lambda item: item["confidence"], reverse=True)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# --- Similar Implementations ---
|
|
190
|
+
|
|
191
|
+
def find_similar(repo_root: Path, query: str) -> list[dict[str, str]]:
|
|
192
|
+
"""Find files with similar naming patterns to help implement new features."""
|
|
193
|
+
query_lower = query.lower()
|
|
194
|
+
semantic_hits = semantic_query(repo_root, "code", query, limit=20)
|
|
195
|
+
if semantic_hits:
|
|
196
|
+
results = []
|
|
197
|
+
for hit in semantic_hits:
|
|
198
|
+
meta = hit.get("metadata", {})
|
|
199
|
+
results.append(
|
|
200
|
+
{
|
|
201
|
+
"file": str(meta.get("path", "")),
|
|
202
|
+
"class": str(meta.get("symbol", "")),
|
|
203
|
+
"content": hit.get("content", "")[:300],
|
|
204
|
+
}
|
|
205
|
+
)
|
|
206
|
+
return results
|
|
207
|
+
|
|
208
|
+
similar: list[dict[str, str]] = []
|
|
209
|
+
|
|
210
|
+
# Find files matching the pattern
|
|
211
|
+
for cs_file in repo_root.rglob("*.cs"):
|
|
212
|
+
if _should_ignore(cs_file):
|
|
213
|
+
continue
|
|
214
|
+
name = cs_file.stem.lower()
|
|
215
|
+
# Match by suffix pattern (e.g. "Handler" finds all *Handler.cs)
|
|
216
|
+
if query_lower in name:
|
|
217
|
+
rel = str(cs_file.relative_to(repo_root))
|
|
218
|
+
try:
|
|
219
|
+
content = cs_file.read_text(errors="replace")
|
|
220
|
+
# Get class declaration line
|
|
221
|
+
match = re.search(r'(public\s+class\s+\w+[^{]*)', content)
|
|
222
|
+
sig = match.group(1).strip() if match else ""
|
|
223
|
+
similar.append({"file": rel, "class": sig})
|
|
224
|
+
except (OSError, PermissionError):
|
|
225
|
+
similar.append({"file": rel, "class": ""})
|
|
226
|
+
|
|
227
|
+
return similar[:20]
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def context_for_active_task(repo_root: Path, query: str = "") -> str:
|
|
231
|
+
"""Retrieve semantic context using the active task as a relevance hint."""
|
|
232
|
+
active_task_markdown = get_current_task(repo_root)
|
|
233
|
+
if active_task_markdown.startswith("No active task"):
|
|
234
|
+
return active_task_markdown
|
|
235
|
+
|
|
236
|
+
search_query = query.strip() or active_task_markdown
|
|
237
|
+
hits = semantic_query(repo_root, "code", search_query, limit=10)
|
|
238
|
+
if not hits:
|
|
239
|
+
return "No semantic context found for the current task."
|
|
240
|
+
|
|
241
|
+
lines = ["# Context for Active Task", "", "## Active Task", active_task_markdown, "", "## Relevant Code"]
|
|
242
|
+
for hit in hits:
|
|
243
|
+
meta = hit.get("metadata", {})
|
|
244
|
+
lines.append(f"- `{meta.get('path', '')}` :: `{meta.get('symbol', '')}`")
|
|
245
|
+
return "\n".join(lines)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# --- Ownership (git blame stats) ---
|
|
249
|
+
|
|
250
|
+
def detect_ownership(repo_root: Path) -> dict[str, str]:
|
|
251
|
+
"""Detect code ownership from CODEOWNERS or git stats."""
|
|
252
|
+
owners: dict[str, str] = {}
|
|
253
|
+
|
|
254
|
+
# Check CODEOWNERS
|
|
255
|
+
for codeowners_path in [
|
|
256
|
+
repo_root / "CODEOWNERS",
|
|
257
|
+
repo_root / ".github" / "CODEOWNERS",
|
|
258
|
+
repo_root / "docs" / "CODEOWNERS",
|
|
259
|
+
]:
|
|
260
|
+
if codeowners_path.exists():
|
|
261
|
+
try:
|
|
262
|
+
for line in codeowners_path.read_text().splitlines():
|
|
263
|
+
line = line.strip()
|
|
264
|
+
if line and not line.startswith("#"):
|
|
265
|
+
parts = line.split()
|
|
266
|
+
if len(parts) >= 2:
|
|
267
|
+
owners[parts[0]] = " ".join(parts[1:])
|
|
268
|
+
except (OSError, PermissionError):
|
|
269
|
+
pass
|
|
270
|
+
break
|
|
271
|
+
|
|
272
|
+
return owners
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
# --- Main intelligence report ---
|
|
276
|
+
|
|
277
|
+
def generate_intelligence(repo_root: Path) -> str:
|
|
278
|
+
"""Generate a full code intelligence report as markdown."""
|
|
279
|
+
lines = ["# Code Intelligence\n"]
|
|
280
|
+
|
|
281
|
+
# Patterns
|
|
282
|
+
patterns = detect_patterns(repo_root)
|
|
283
|
+
if patterns:
|
|
284
|
+
lines.append("## Patterns & Conventions")
|
|
285
|
+
for p in patterns:
|
|
286
|
+
lines.append(f"- {p}")
|
|
287
|
+
lines.append("")
|
|
288
|
+
|
|
289
|
+
# Architecture
|
|
290
|
+
graph = detect_service_calls(repo_root)
|
|
291
|
+
if graph:
|
|
292
|
+
lines.append("## Service Architecture")
|
|
293
|
+
for svc, targets in sorted(graph.items()):
|
|
294
|
+
lines.append(f"- **{svc}** → {', '.join(targets)}")
|
|
295
|
+
lines.append("")
|
|
296
|
+
|
|
297
|
+
# Dependencies
|
|
298
|
+
deps = detect_dependencies(repo_root)
|
|
299
|
+
if deps:
|
|
300
|
+
lines.append("## Dependencies")
|
|
301
|
+
for project, pkgs in sorted(deps.items()):
|
|
302
|
+
lines.append(f"**{project}:** {', '.join(pkgs[:10])}")
|
|
303
|
+
if len(pkgs) > 10:
|
|
304
|
+
lines.append(f" ... and {len(pkgs) - 10} more")
|
|
305
|
+
lines.append("")
|
|
306
|
+
|
|
307
|
+
# Ownership
|
|
308
|
+
owners = detect_ownership(repo_root)
|
|
309
|
+
if owners:
|
|
310
|
+
lines.append("## Code Ownership")
|
|
311
|
+
for path, owner in owners.items():
|
|
312
|
+
lines.append(f"- `{path}` → {owner}")
|
|
313
|
+
lines.append("")
|
|
314
|
+
|
|
315
|
+
architectures = classify_architecture(repo_root)
|
|
316
|
+
if architectures:
|
|
317
|
+
lines.append("## Architecture Classification")
|
|
318
|
+
for arch in architectures:
|
|
319
|
+
evidence = ", ".join(arch["evidence"])
|
|
320
|
+
lines.append(f"- **{arch['name']}** (confidence {arch['confidence']}) — evidence: {evidence}")
|
|
321
|
+
lines.append("")
|
|
322
|
+
|
|
323
|
+
return "\n".join(lines)
|