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/retrieval.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Semantic retrieval orchestration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .chunking import Chunk
|
|
8
|
+
from .vector_index import LocalVectorIndex
|
|
9
|
+
|
|
10
|
+
_INDICES: dict[str, LocalVectorIndex] = {}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_index(repo_root: Path) -> LocalVectorIndex:
|
|
14
|
+
key = str(repo_root.resolve())
|
|
15
|
+
if key not in _INDICES:
|
|
16
|
+
_INDICES[key] = LocalVectorIndex(repo_root)
|
|
17
|
+
return _INDICES[key]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def index_chunks(repo_root: Path, namespace: str, chunks: list[Chunk]) -> None:
|
|
21
|
+
get_index(repo_root).upsert(namespace, chunks)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def semantic_query(
|
|
25
|
+
repo_root: Path,
|
|
26
|
+
namespace: str,
|
|
27
|
+
query: str,
|
|
28
|
+
limit: int = 10,
|
|
29
|
+
filters: dict[str, str] | None = None,
|
|
30
|
+
) -> list[dict]:
|
|
31
|
+
return get_index(repo_root).query(namespace, query, limit=limit, filters=filters)
|
mnemo/sprint/__init__.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Sprint/task context - store and recall task-specific context."""
|
|
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_tasks(repo_root: Path) -> list[dict]:
|
|
12
|
+
data = get_storage(repo_root).read_collection(Collections.TASKS)
|
|
13
|
+
return data if isinstance(data, list) else []
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _save_tasks(repo_root: Path, tasks: list[dict]) -> None:
|
|
17
|
+
get_storage(repo_root).write_collection(Collections.TASKS, tasks)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def set_current_task(
|
|
21
|
+
repo_root: Path,
|
|
22
|
+
task_id: str,
|
|
23
|
+
description: str = "",
|
|
24
|
+
files: list[str] | None = None,
|
|
25
|
+
notes: str = "",
|
|
26
|
+
) -> dict:
|
|
27
|
+
"""Set the current task/ticket being worked on."""
|
|
28
|
+
tasks = _load_tasks(repo_root)
|
|
29
|
+
|
|
30
|
+
for task in tasks:
|
|
31
|
+
if task["task_id"] == task_id:
|
|
32
|
+
task["last_active"] = time.time()
|
|
33
|
+
if description:
|
|
34
|
+
task["description"] = description
|
|
35
|
+
if files:
|
|
36
|
+
task["files"] = sorted(set(task.get("files", []) + files))
|
|
37
|
+
if notes:
|
|
38
|
+
task["notes"] = task.get("notes", "") + "\n" + notes
|
|
39
|
+
task["status"] = "active"
|
|
40
|
+
_save_tasks(repo_root, tasks)
|
|
41
|
+
return task
|
|
42
|
+
|
|
43
|
+
entry = {
|
|
44
|
+
"task_id": task_id,
|
|
45
|
+
"description": description,
|
|
46
|
+
"files": files or [],
|
|
47
|
+
"notes": notes,
|
|
48
|
+
"status": "active",
|
|
49
|
+
"created": time.time(),
|
|
50
|
+
"last_active": time.time(),
|
|
51
|
+
}
|
|
52
|
+
tasks.append(entry)
|
|
53
|
+
_save_tasks(repo_root, tasks)
|
|
54
|
+
return entry
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def complete_task(repo_root: Path, task_id: str, summary: str = "") -> str:
|
|
58
|
+
"""Mark a task as complete with an optional summary."""
|
|
59
|
+
tasks = _load_tasks(repo_root)
|
|
60
|
+
for task in tasks:
|
|
61
|
+
if task["task_id"] == task_id:
|
|
62
|
+
task["status"] = "completed"
|
|
63
|
+
task["completed_at"] = time.time()
|
|
64
|
+
if summary:
|
|
65
|
+
task["summary"] = summary
|
|
66
|
+
_save_tasks(repo_root, tasks)
|
|
67
|
+
return f"Task {task_id} marked complete."
|
|
68
|
+
return f"Task {task_id} not found."
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def get_current_task(repo_root: Path) -> str:
|
|
72
|
+
"""Get the currently active task context."""
|
|
73
|
+
tasks = _load_tasks(repo_root)
|
|
74
|
+
active = [task for task in tasks if task.get("status") == "active"]
|
|
75
|
+
|
|
76
|
+
if not active:
|
|
77
|
+
return "No active task. Use mnemo_task to set one."
|
|
78
|
+
|
|
79
|
+
lines = ["# Active Tasks\n"]
|
|
80
|
+
for task in active:
|
|
81
|
+
lines.append(f"## {task['task_id']}")
|
|
82
|
+
if task.get("description"):
|
|
83
|
+
lines.append(task["description"])
|
|
84
|
+
if task.get("files"):
|
|
85
|
+
lines.append(f"**Files:** {', '.join(task['files'])}")
|
|
86
|
+
if task.get("notes"):
|
|
87
|
+
lines.append(f"**Notes:** {task['notes']}")
|
|
88
|
+
lines.append("")
|
|
89
|
+
return "\n".join(lines)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def format_tasks(repo_root: Path) -> str:
|
|
93
|
+
"""Format all tasks as markdown."""
|
|
94
|
+
tasks = _load_tasks(repo_root)
|
|
95
|
+
if not tasks:
|
|
96
|
+
return "No tasks stored."
|
|
97
|
+
|
|
98
|
+
lines = ["# Task History\n"]
|
|
99
|
+
for task in tasks[-20:]:
|
|
100
|
+
status = "done" if task.get("status") == "completed" else "active"
|
|
101
|
+
lines.append(f"- {status}: **{task['task_id']}** - {task.get('description', '')[:80]}")
|
|
102
|
+
return "\n".join(lines)
|
mnemo/storage.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Storage abstraction for Mnemo collections."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Protocol
|
|
8
|
+
|
|
9
|
+
from .config import CONTEXT_FILE, DECISIONS_FILE, MEMORY_FILE, mnemo_path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Collections:
|
|
13
|
+
"""Logical storage collection names."""
|
|
14
|
+
|
|
15
|
+
MEMORY = "memory"
|
|
16
|
+
DECISIONS = "decisions"
|
|
17
|
+
CONTEXT = "context"
|
|
18
|
+
HASHES = "hashes"
|
|
19
|
+
ERRORS = "errors"
|
|
20
|
+
INCIDENTS = "incidents"
|
|
21
|
+
REVIEWS = "reviews"
|
|
22
|
+
TASKS = "tasks"
|
|
23
|
+
REPO_MAP = "repo_map"
|
|
24
|
+
KNOWLEDGE = "knowledge"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class StorageAdapter(Protocol):
|
|
28
|
+
"""Synchronous storage contract used by local Mnemo tools."""
|
|
29
|
+
|
|
30
|
+
def get(self, collection: str, key: str) -> dict[str, Any] | None:
|
|
31
|
+
"""Return a single item by key."""
|
|
32
|
+
|
|
33
|
+
def put(self, collection: str, key: str, value: dict[str, Any]) -> None:
|
|
34
|
+
"""Create or replace a single item."""
|
|
35
|
+
|
|
36
|
+
def list(self, collection: str) -> list[dict[str, Any]]:
|
|
37
|
+
"""Return all items in a collection."""
|
|
38
|
+
|
|
39
|
+
def query(self, collection: str, filters: dict[str, Any]) -> list[dict[str, Any]]:
|
|
40
|
+
"""Return items matching exact filter values."""
|
|
41
|
+
|
|
42
|
+
def delete(self, collection: str, key: str) -> None:
|
|
43
|
+
"""Delete a single item by key."""
|
|
44
|
+
|
|
45
|
+
def search(self, collection: str, query: str, k: int = 10) -> list[dict[str, Any]]:
|
|
46
|
+
"""Return keyword matches for a collection."""
|
|
47
|
+
|
|
48
|
+
def read_collection(self, collection: str) -> list[dict[str, Any]] | dict[str, Any]:
|
|
49
|
+
"""Read a collection in its native persisted shape."""
|
|
50
|
+
|
|
51
|
+
def write_collection(self, collection: str, data: list[dict[str, Any]] | dict[str, Any]) -> None:
|
|
52
|
+
"""Write a collection in its native persisted shape."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
COLLECTION_FILES = {
|
|
56
|
+
Collections.MEMORY: MEMORY_FILE,
|
|
57
|
+
Collections.DECISIONS: DECISIONS_FILE,
|
|
58
|
+
Collections.CONTEXT: CONTEXT_FILE,
|
|
59
|
+
Collections.HASHES: "hashes.json",
|
|
60
|
+
Collections.ERRORS: "errors.json",
|
|
61
|
+
Collections.INCIDENTS: "incidents.json",
|
|
62
|
+
Collections.REVIEWS: "reviews.json",
|
|
63
|
+
Collections.TASKS: "tasks.json",
|
|
64
|
+
Collections.REPO_MAP: "repo_map.json",
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
LIST_COLLECTIONS = {
|
|
68
|
+
Collections.MEMORY,
|
|
69
|
+
Collections.DECISIONS,
|
|
70
|
+
Collections.ERRORS,
|
|
71
|
+
Collections.INCIDENTS,
|
|
72
|
+
Collections.REVIEWS,
|
|
73
|
+
Collections.TASKS,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class JSONFileAdapter:
|
|
78
|
+
"""Local JSON-file storage backend preserving the current `.mnemo` layout."""
|
|
79
|
+
|
|
80
|
+
def __init__(self, repo_root: Path):
|
|
81
|
+
self.repo_root = repo_root
|
|
82
|
+
self.base_path = mnemo_path(repo_root)
|
|
83
|
+
self.base_path.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
|
|
85
|
+
def collection_path(self, collection: str) -> Path:
|
|
86
|
+
filename = COLLECTION_FILES.get(collection, f"{collection}.json")
|
|
87
|
+
return self.base_path / filename
|
|
88
|
+
|
|
89
|
+
def get(self, collection: str, key: str) -> dict[str, Any] | None:
|
|
90
|
+
data = self._load(collection)
|
|
91
|
+
if isinstance(data, dict):
|
|
92
|
+
value = data.get(key)
|
|
93
|
+
if isinstance(value, dict):
|
|
94
|
+
return value
|
|
95
|
+
if value is not None:
|
|
96
|
+
return {"key": key, "value": value}
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
for item in data:
|
|
100
|
+
if self._item_key(item) == key:
|
|
101
|
+
return item
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
def put(self, collection: str, key: str, value: dict[str, Any]) -> None:
|
|
105
|
+
if collection in LIST_COLLECTIONS:
|
|
106
|
+
items = self._load_list(collection)
|
|
107
|
+
replacement = dict(value)
|
|
108
|
+
if "id" not in replacement and key and "task_id" not in replacement:
|
|
109
|
+
replacement["id"] = key
|
|
110
|
+
|
|
111
|
+
for index, item in enumerate(items):
|
|
112
|
+
if self._item_key(item) == key:
|
|
113
|
+
items[index] = replacement
|
|
114
|
+
self.replace_all(collection, items)
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
items.append(replacement)
|
|
118
|
+
self.replace_all(collection, items)
|
|
119
|
+
return
|
|
120
|
+
|
|
121
|
+
data = self._load_dict(collection)
|
|
122
|
+
data[key] = value
|
|
123
|
+
self._save(collection, data)
|
|
124
|
+
|
|
125
|
+
def list(self, collection: str) -> list[dict[str, Any]]:
|
|
126
|
+
data = self._load(collection)
|
|
127
|
+
if isinstance(data, list):
|
|
128
|
+
return [item for item in data if isinstance(item, dict)]
|
|
129
|
+
return [
|
|
130
|
+
{"key": key, **value} if isinstance(value, dict) else {"key": key, "value": value}
|
|
131
|
+
for key, value in data.items()
|
|
132
|
+
]
|
|
133
|
+
|
|
134
|
+
def query(self, collection: str, filters: dict[str, Any]) -> list[dict[str, Any]]:
|
|
135
|
+
if not filters:
|
|
136
|
+
return self.list(collection)
|
|
137
|
+
return [
|
|
138
|
+
item
|
|
139
|
+
for item in self.list(collection)
|
|
140
|
+
if all(item.get(field) == expected for field, expected in filters.items())
|
|
141
|
+
]
|
|
142
|
+
|
|
143
|
+
def delete(self, collection: str, key: str) -> None:
|
|
144
|
+
if collection in LIST_COLLECTIONS:
|
|
145
|
+
items = [item for item in self._load_list(collection) if self._item_key(item) != key]
|
|
146
|
+
self.replace_all(collection, items)
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
data = self._load_dict(collection)
|
|
150
|
+
data.pop(key, None)
|
|
151
|
+
self._save(collection, data)
|
|
152
|
+
|
|
153
|
+
def search(self, collection: str, query: str, k: int = 10) -> list[dict[str, Any]]:
|
|
154
|
+
terms = [term for term in query.lower().split() if term]
|
|
155
|
+
if not terms:
|
|
156
|
+
return self.list(collection)[:k]
|
|
157
|
+
|
|
158
|
+
scored: list[tuple[int, dict[str, Any]]] = []
|
|
159
|
+
for item in self.list(collection):
|
|
160
|
+
text = json.dumps(item, sort_keys=True).lower()
|
|
161
|
+
score = sum(1 for term in terms if term in text)
|
|
162
|
+
if score:
|
|
163
|
+
scored.append((score, item))
|
|
164
|
+
|
|
165
|
+
scored.sort(key=lambda match: match[0], reverse=True)
|
|
166
|
+
return [item for _, item in scored[:k]]
|
|
167
|
+
|
|
168
|
+
def replace_all(self, collection: str, items: list[dict[str, Any]]) -> None:
|
|
169
|
+
"""Replace a list collection in one write."""
|
|
170
|
+
self._save(collection, items)
|
|
171
|
+
|
|
172
|
+
def read_collection(self, collection: str) -> list[dict[str, Any]] | dict[str, Any]:
|
|
173
|
+
"""Read a collection in its native persisted shape."""
|
|
174
|
+
return self._load(collection)
|
|
175
|
+
|
|
176
|
+
def write_collection(self, collection: str, data: list[dict[str, Any]] | dict[str, Any]) -> None:
|
|
177
|
+
"""Write a collection in its native persisted shape."""
|
|
178
|
+
self._save(collection, data)
|
|
179
|
+
|
|
180
|
+
def _load(self, collection: str) -> list[dict[str, Any]] | dict[str, Any]:
|
|
181
|
+
path = self.collection_path(collection)
|
|
182
|
+
if not path.exists():
|
|
183
|
+
return [] if collection in LIST_COLLECTIONS else {}
|
|
184
|
+
try:
|
|
185
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
186
|
+
except (json.JSONDecodeError, OSError):
|
|
187
|
+
return [] if collection in LIST_COLLECTIONS else {}
|
|
188
|
+
if collection in LIST_COLLECTIONS:
|
|
189
|
+
return data if isinstance(data, list) else []
|
|
190
|
+
return data if isinstance(data, dict) else {}
|
|
191
|
+
|
|
192
|
+
def _load_list(self, collection: str) -> list[dict[str, Any]]:
|
|
193
|
+
data = self._load(collection)
|
|
194
|
+
return data if isinstance(data, list) else []
|
|
195
|
+
|
|
196
|
+
def _load_dict(self, collection: str) -> dict[str, Any]:
|
|
197
|
+
data = self._load(collection)
|
|
198
|
+
return data if isinstance(data, dict) else {}
|
|
199
|
+
|
|
200
|
+
def _save(self, collection: str, data: Any) -> None:
|
|
201
|
+
path = self.collection_path(collection)
|
|
202
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
203
|
+
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
204
|
+
|
|
205
|
+
@staticmethod
|
|
206
|
+
def _item_key(item: dict[str, Any]) -> str:
|
|
207
|
+
for field in ("id", "task_id", "key"):
|
|
208
|
+
if field in item:
|
|
209
|
+
return str(item[field])
|
|
210
|
+
return ""
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def get_storage(repo_root: Path) -> StorageAdapter:
|
|
214
|
+
"""Return the default local storage adapter."""
|
|
215
|
+
return JSONFileAdapter(repo_root)
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Team Knowledge Graph — who knows what, based on git history."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
|
|
8
|
+
from ..config import IGNORE_DIRS
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _should_ignore(path: str) -> bool:
|
|
12
|
+
return any(part in IGNORE_DIRS for part in path.split("/"))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def build_team_graph(repo_root: Path) -> dict[str, dict]:
|
|
16
|
+
"""Build expertise map from git history."""
|
|
17
|
+
try:
|
|
18
|
+
from git import Repo
|
|
19
|
+
repo = Repo(repo_root)
|
|
20
|
+
except Exception:
|
|
21
|
+
return {}
|
|
22
|
+
|
|
23
|
+
# author → {service: commit_count}
|
|
24
|
+
expertise: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
|
|
25
|
+
# file → last author
|
|
26
|
+
last_touch: dict[str, str] = {}
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
for commit in repo.iter_commits(max_count=500, no_merges=True):
|
|
30
|
+
author = commit.author.name
|
|
31
|
+
for file_path in commit.stats.files:
|
|
32
|
+
if _should_ignore(file_path):
|
|
33
|
+
continue
|
|
34
|
+
parts = file_path.split("/")
|
|
35
|
+
service = parts[0] if len(parts) > 1 else "root"
|
|
36
|
+
expertise[author][service] += 1
|
|
37
|
+
if file_path not in last_touch:
|
|
38
|
+
last_touch[file_path] = author
|
|
39
|
+
except Exception:
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
"expertise": dict(expertise),
|
|
44
|
+
"last_touch": last_touch,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get_experts(repo_root: Path, query: str = "") -> str:
|
|
49
|
+
"""Find who has expertise in a specific area."""
|
|
50
|
+
graph = build_team_graph(repo_root)
|
|
51
|
+
if not graph:
|
|
52
|
+
return "No git history available for team analysis."
|
|
53
|
+
|
|
54
|
+
expertise = graph.get("expertise", {})
|
|
55
|
+
query_lower = query.lower()
|
|
56
|
+
|
|
57
|
+
lines = []
|
|
58
|
+
if query:
|
|
59
|
+
lines.append(f"# Experts for '{query}'\n")
|
|
60
|
+
# Find authors with most commits in matching services
|
|
61
|
+
scores: list[tuple[str, int]] = []
|
|
62
|
+
for author, services in expertise.items():
|
|
63
|
+
score = sum(count for svc, count in services.items() if query_lower in svc.lower())
|
|
64
|
+
if score > 0:
|
|
65
|
+
scores.append((author, score))
|
|
66
|
+
scores.sort(key=lambda x: -x[1])
|
|
67
|
+
if scores:
|
|
68
|
+
for author, count in scores[:10]:
|
|
69
|
+
lines.append(f"- **{author}** — {count} commits")
|
|
70
|
+
else:
|
|
71
|
+
lines.append(f"No experts found for '{query}'.")
|
|
72
|
+
else:
|
|
73
|
+
lines.append("# Team Knowledge Map\n")
|
|
74
|
+
for author in sorted(expertise.keys()):
|
|
75
|
+
services = expertise[author]
|
|
76
|
+
top = sorted(services.items(), key=lambda x: -x[1])[:3]
|
|
77
|
+
areas = ", ".join(f"{svc} ({count})" for svc, count in top)
|
|
78
|
+
lines.append(f"- **{author}** — {areas}")
|
|
79
|
+
|
|
80
|
+
return "\n".join(lines)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def who_last_touched(repo_root: Path, filepath: str) -> str:
|
|
84
|
+
"""Find who last modified a file."""
|
|
85
|
+
graph = build_team_graph(repo_root)
|
|
86
|
+
last_touch = graph.get("last_touch", {})
|
|
87
|
+
|
|
88
|
+
matches = [(f, author) for f, author in last_touch.items() if filepath.lower() in f.lower()]
|
|
89
|
+
|
|
90
|
+
if not matches:
|
|
91
|
+
return f"No git history for '{filepath}'."
|
|
92
|
+
|
|
93
|
+
lines = [f"# Last touched: '{filepath}'\n"]
|
|
94
|
+
for f, author in matches[:10]:
|
|
95
|
+
lines.append(f"- **{f}** → {author}")
|
|
96
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Test Intelligence — map tests to source code, know what to run/update."""
|
|
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 build_test_map(repo_root: Path) -> dict[str, list[str]]:
|
|
16
|
+
"""Map source files to their test files based on naming conventions and imports."""
|
|
17
|
+
test_files: list[Path] = []
|
|
18
|
+
source_files: list[Path] = []
|
|
19
|
+
|
|
20
|
+
for cs_file in repo_root.rglob("*.cs"):
|
|
21
|
+
if _should_ignore(cs_file):
|
|
22
|
+
continue
|
|
23
|
+
rel = str(cs_file.relative_to(repo_root))
|
|
24
|
+
if "Test" in rel:
|
|
25
|
+
test_files.append(cs_file)
|
|
26
|
+
else:
|
|
27
|
+
source_files.append(cs_file)
|
|
28
|
+
|
|
29
|
+
# Map: source_file → [test_files]
|
|
30
|
+
test_map: dict[str, list[str]] = {}
|
|
31
|
+
|
|
32
|
+
for source in source_files:
|
|
33
|
+
source_name = source.stem # e.g. "AuthorizationService"
|
|
34
|
+
source_rel = str(source.relative_to(repo_root))
|
|
35
|
+
matching_tests = []
|
|
36
|
+
|
|
37
|
+
for test in test_files:
|
|
38
|
+
test_name = test.stem
|
|
39
|
+
# Convention: FooTests.cs tests Foo.cs
|
|
40
|
+
if source_name in test_name:
|
|
41
|
+
matching_tests.append(str(test.relative_to(repo_root)))
|
|
42
|
+
continue
|
|
43
|
+
|
|
44
|
+
# Check if test imports/uses the source class
|
|
45
|
+
try:
|
|
46
|
+
content = test.read_text(errors="replace")
|
|
47
|
+
if source_name in content:
|
|
48
|
+
matching_tests.append(str(test.relative_to(repo_root)))
|
|
49
|
+
except (OSError, PermissionError):
|
|
50
|
+
continue
|
|
51
|
+
|
|
52
|
+
if matching_tests:
|
|
53
|
+
test_map[source_rel] = matching_tests
|
|
54
|
+
|
|
55
|
+
return test_map
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def get_tests_for_file(repo_root: Path, filepath: str) -> str:
|
|
59
|
+
"""Get all tests that cover a specific file."""
|
|
60
|
+
test_map = build_test_map(repo_root)
|
|
61
|
+
query = filepath.lower()
|
|
62
|
+
|
|
63
|
+
matches = []
|
|
64
|
+
for source, tests in test_map.items():
|
|
65
|
+
if query in source.lower():
|
|
66
|
+
matches.append((source, tests))
|
|
67
|
+
|
|
68
|
+
if not matches:
|
|
69
|
+
return f"No tests found for '{filepath}'."
|
|
70
|
+
|
|
71
|
+
lines = [f"# Tests for '{filepath}'\n"]
|
|
72
|
+
for source, tests in matches:
|
|
73
|
+
lines.append(f"## {source}")
|
|
74
|
+
for t in tests:
|
|
75
|
+
lines.append(f"- {t}")
|
|
76
|
+
lines.append("")
|
|
77
|
+
return "\n".join(lines)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def get_coverage_summary(repo_root: Path) -> str:
|
|
81
|
+
"""Get a summary of test coverage by service."""
|
|
82
|
+
test_map = build_test_map(repo_root)
|
|
83
|
+
|
|
84
|
+
# Group by service
|
|
85
|
+
by_service: dict[str, dict] = {}
|
|
86
|
+
for source, tests in test_map.items():
|
|
87
|
+
parts = source.split("/")
|
|
88
|
+
svc = parts[0] if len(parts) > 1 else "root"
|
|
89
|
+
if svc not in by_service:
|
|
90
|
+
by_service[svc] = {"covered": 0, "tests": 0}
|
|
91
|
+
by_service[svc]["covered"] += 1
|
|
92
|
+
by_service[svc]["tests"] += len(tests)
|
|
93
|
+
|
|
94
|
+
# Count total source files per service
|
|
95
|
+
total_by_service: dict[str, int] = {}
|
|
96
|
+
for cs_file in repo_root.rglob("*.cs"):
|
|
97
|
+
if _should_ignore(cs_file) or "Test" in str(cs_file):
|
|
98
|
+
continue
|
|
99
|
+
parts = cs_file.relative_to(repo_root).parts
|
|
100
|
+
svc = parts[0] if len(parts) > 1 else "root"
|
|
101
|
+
total_by_service[svc] = total_by_service.get(svc, 0) + 1
|
|
102
|
+
|
|
103
|
+
lines = ["# Test Coverage Summary\n"]
|
|
104
|
+
for svc in sorted(set(list(by_service.keys()) + list(total_by_service.keys()))):
|
|
105
|
+
total = total_by_service.get(svc, 0)
|
|
106
|
+
covered = by_service.get(svc, {}).get("covered", 0)
|
|
107
|
+
tests = by_service.get(svc, {}).get("tests", 0)
|
|
108
|
+
pct = f"{covered/total*100:.0f}%" if total > 0 else "0%"
|
|
109
|
+
lines.append(f"- **{svc}**: {covered}/{total} files covered ({pct}), {tests} test files")
|
|
110
|
+
|
|
111
|
+
return "\n".join(lines)
|