ki-manager 2.3.0.dev1__py3-none-any.whl → 2.3.0.dev3__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.
ki_manager/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
 
2
2
  """ki-manager — Knowledge Item MCP server for AI-assisted development."""
3
- __version__ = "2.3.0.dev1"
3
+ __version__ = "2.3.0.dev3"
ki_manager/cli.py CHANGED
@@ -20,10 +20,10 @@ _WORKFLOWS_DIR = _PACKAGE_DIR / "workflows"
20
20
 
21
21
 
22
22
  def _parse_frontmatter(text: str) -> tuple[dict, str]:
23
- """Парсит YAML frontmatter из маркдаун-файла.
23
+ """Parses YAML frontmatter from a markdown file.
24
24
 
25
- Возвращает (meta_dict, body_text).
26
- Если frontmatter нет — возвращает ({}, text).
25
+ Returns (meta_dict, body_text).
26
+ If there is no frontmatter, returns ({}, text).
27
27
  """
28
28
  if not text.startswith("---"):
29
29
  return {}, text
@@ -4,7 +4,7 @@ import argparse
4
4
  import io
5
5
  from typing import Dict, List, Optional
6
6
 
7
- # Добавляем путь к скриптам, чтобы импортировать ki_utils
7
+ # Add path to scripts to import ki_utils
8
8
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
9
9
  import ki_utils
10
10
 
@@ -17,7 +17,7 @@ def format_size(size_bytes: int) -> str:
17
17
  return f"{size_bytes / (1024 * 1024):.2f} MB"
18
18
 
19
19
  def get_tracked_files(config: Dict) -> Dict[str, str]:
20
- """Возвращает мапу {путь_к_файлу: имя_KI}"""
20
+ """Returns a map {file_path: KI_name}"""
21
21
  tracked = {}
22
22
  ki_items = config.get("knowledge_items", {})
23
23
  for ki_name, ki_data in ki_items.items():
@@ -30,7 +30,7 @@ def analyze_path(target_path: str, recursive: bool = False):
30
30
  config = ki_utils.get_doc_config()
31
31
  tracked_map = get_tracked_files(config)
32
32
 
33
- # Резолвим абсолютный путь
33
+ # Resolve absolute path
34
34
  abs_target = os.path.normpath(os.path.join(project_root, target_path))
35
35
 
36
36
  if not os.path.exists(abs_target):
@@ -87,7 +87,7 @@ def analyze_path(target_path: str, recursive: bool = False):
87
87
  continue
88
88
 
89
89
  def main():
90
- # Исправление кодировки для Windows при прямом запуске
90
+ # Fix encoding for Windows when run directly
91
91
  if sys.platform == "win32":
92
92
  import io
93
93
  sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
@@ -0,0 +1,382 @@
1
+ """
2
+ check_ki_drift.py
3
+
4
+ Detects discrepancies (drift) between Knowledge Items (KI) and the actual codebase:
5
+ 1. Symbol Drift:
6
+ - Dead symbols in Key Components table (deleted/renamed in code).
7
+ - Unmapped public symbols in source files (new code not yet in KI).
8
+ 2. File Drift:
9
+ - Broken or missing file paths in doc_config.json depends_on.
10
+ 3. Date Drift:
11
+ - Source files modified in git after KI's <!-- last_verified: YYYY-MM-DD -->.
12
+ 4. Broken Wiki-links:
13
+ - [[Target_KI.md]] links in Related KIs that do not exist.
14
+ 5. Integrity Drift:
15
+ - Pending scaffolds (<!-- scaffold: true -->) or remaining <!-- TODO --> markers.
16
+
17
+ Usage:
18
+ python check_ki_drift.py --all
19
+ python check_ki_drift.py --ki KI_server.md
20
+ python check_ki_drift.py --json
21
+ """
22
+
23
+ import os
24
+ import sys
25
+ import re
26
+ import json
27
+ import argparse
28
+ import subprocess
29
+ from pathlib import Path
30
+ from typing import Dict, List, Tuple, Optional, Any, Set
31
+
32
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
33
+ import ki_utils
34
+ from generate_ki_scaffolds import extract_symbol_details
35
+
36
+
37
+ def parse_ki_file(ki_path: Path) -> Dict[str, Any]:
38
+ """Parse a KI markdown file and extract its metadata, tables, and links."""
39
+ try:
40
+ content = ki_path.read_text(encoding="utf-8", errors="replace")
41
+ except OSError:
42
+ return {
43
+ "error": f"Cannot read {ki_path.name}",
44
+ "last_verified": None,
45
+ "is_scaffold": False,
46
+ "is_enriched": False,
47
+ "has_todo": False,
48
+ "table_symbols": [],
49
+ "wiki_links": [],
50
+ "sections": set(),
51
+ }
52
+
53
+ lines = content.splitlines()
54
+ last_verified = None
55
+ is_scaffold = False
56
+ is_enriched = False
57
+ has_todo = "<!-- TODO" in content or "TODO:" in content
58
+
59
+ # Check top lines for markers
60
+ for line in lines[:5]:
61
+ line_clean = line.strip()
62
+ if "<!-- scaffold: true -->" in line_clean:
63
+ is_scaffold = True
64
+ elif "<!-- scaffold: enriched -->" in line_clean:
65
+ is_enriched = True
66
+ elif line_clean.startswith("<!-- last_verified:"):
67
+ parts = line_clean.split(":", 1)
68
+ if len(parts) > 1:
69
+ last_verified = parts[1].replace("-->", "").strip()
70
+
71
+ # Extract sections
72
+ sections = set()
73
+ for line in lines:
74
+ if line.startswith("## "):
75
+ sections.add(line[3:].strip())
76
+
77
+ # Extract Key Components table symbols
78
+ # Expected table format: | `SymbolName` | `rel/path/file.ext` | Purpose |
79
+ table_symbols: List[Dict[str, str]] = []
80
+ in_key_components = False
81
+ table_row_pattern = re.compile(r"\|\s*`?([A-Za-z0-9_]+)`?\s*\|\s*`?([^`|\s]+)`?\s*\|")
82
+
83
+ for line in lines:
84
+ if line.startswith("## Key Components"):
85
+ in_key_components = True
86
+ continue
87
+ elif in_key_components and line.startswith("## "):
88
+ in_key_components = False
89
+ continue
90
+
91
+ if in_key_components and line.strip().startswith("|"):
92
+ lower = line.lower()
93
+ if any(k in lower for k in ["class / function", "component", "|---", "|:---", "| — |", "| - |"]):
94
+ continue
95
+ m = table_row_pattern.search(line)
96
+ if m:
97
+ sym_name = m.group(1).strip()
98
+ file_rel = m.group(2).strip()
99
+ header_keywords = {"component", "file", "purpose", "class", "function", "description"}
100
+ if sym_name.lower() in header_keywords or file_rel.lower() in header_keywords:
101
+ continue
102
+ if sym_name and file_rel and not file_rel.startswith("—"):
103
+ table_symbols.append({"symbol": sym_name, "file": file_rel})
104
+
105
+ # Extract wiki-links [[KI_*.md]]
106
+ wiki_links = []
107
+ for link_match in re.finditer(r"\[\[([^\]]+)\]\]", content):
108
+ wiki_links.append(link_match.group(1).strip())
109
+
110
+ return {
111
+ "last_verified": last_verified,
112
+ "is_scaffold": is_scaffold,
113
+ "is_enriched": is_enriched,
114
+ "has_todo": has_todo,
115
+ "table_symbols": table_symbols,
116
+ "wiki_links": wiki_links,
117
+ "sections": sections,
118
+ }
119
+
120
+
121
+ def get_git_commit_date(project_root: str, rel_path: str) -> Optional[str]:
122
+ """Returns the ISO date (YYYY-MM-DD) of the last git commit touching rel_path."""
123
+ try:
124
+ res = subprocess.run(
125
+ ["git", "log", "-1", "--format=%cs", "--", rel_path],
126
+ cwd=project_root,
127
+ capture_output=True,
128
+ text=True,
129
+ encoding="utf-8",
130
+ timeout=5,
131
+ )
132
+ if res.returncode == 0:
133
+ date_str = res.stdout.strip()
134
+ return date_str if date_str else None
135
+ except Exception:
136
+ pass
137
+ return None
138
+
139
+
140
+ def check_single_ki(
141
+ ki_name: str,
142
+ ki_path: Path,
143
+ doc_config: dict,
144
+ project_root: str,
145
+ knowledge_dir: Path
146
+ ) -> Dict[str, Any]:
147
+ """Inspects a single KI file against code and configuration for drift."""
148
+ ki_data = parse_ki_file(ki_path)
149
+ if "error" in ki_data:
150
+ return {"ki": ki_name, "error": ki_data["error"], "drift_count": 1}
151
+
152
+ config_entry = doc_config.get("knowledge_items", {}).get(ki_name, {})
153
+ depends_on = config_entry.get("depends_on", [])
154
+
155
+ dead_symbols: List[Dict[str, str]] = []
156
+ unmapped_symbols: List[Dict[str, Any]] = []
157
+ missing_files: List[str] = []
158
+ date_drifts: List[Dict[str, str]] = []
159
+ broken_links: List[str] = []
160
+ integrity_issues: List[str] = []
161
+
162
+ # 1. Integrity issues
163
+ if ki_data["is_scaffold"]:
164
+ integrity_issues.append("Scaffold stub is still pending (<!-- scaffold: true -->)")
165
+ elif ki_data["is_enriched"]:
166
+ integrity_issues.append("Scaffold is enriched but not finalized (run ki_finalize_scaffolds)")
167
+ if ki_data["has_todo"]:
168
+ integrity_issues.append("Contains unpopulated <!-- TODO --> markers")
169
+
170
+ # 2. Check depends_on files existence
171
+ for dep in depends_on:
172
+ dep_path = Path(project_root) / dep
173
+ if not dep_path.exists():
174
+ missing_files.append(dep)
175
+
176
+ # 3. Check wiki-links
177
+ for link in ki_data["wiki_links"]:
178
+ # Standardize target name
179
+ target_name = link if link.endswith(".md") else f"{link}.md"
180
+ target_path = knowledge_dir / target_name
181
+ if not target_path.exists():
182
+ broken_links.append(link)
183
+
184
+ # 4. Group table symbols by file to check symbol drift
185
+ symbols_by_file: Dict[str, Set[str]] = {}
186
+ for entry in ki_data["table_symbols"]:
187
+ f_norm = entry["file"].replace("\\", "/")
188
+ symbols_by_file.setdefault(f_norm, set()).add(entry["symbol"])
189
+
190
+ # Determine files to inspect for symbol drift
191
+ files_to_inspect = set(symbols_by_file.keys())
192
+ for dep in depends_on:
193
+ dep_norm = dep.replace("\\", "/")
194
+ dep_abs = Path(project_root) / dep
195
+ if dep_abs.is_file() and dep_abs.suffix.lower() in (".py", ".ts", ".tsx", ".js", ".jsx", ".go"):
196
+ files_to_inspect.add(dep_norm)
197
+
198
+ for rel_file in files_to_inspect:
199
+ abs_file = Path(project_root) / rel_file
200
+ if not abs_file.exists():
201
+ if rel_file not in missing_files:
202
+ missing_files.append(rel_file)
203
+ continue
204
+
205
+ # Extract real symbols from code
206
+ code_details = extract_symbol_details(str(abs_file))
207
+ code_symbols_dict = {s["name"]: s for s in code_details.get("symbols", [])}
208
+ code_symbols_set = set(code_symbols_dict.keys())
209
+
210
+ # Check dead symbols (present in KI table, absent in code)
211
+ documented_symbols = symbols_by_file.get(rel_file, set())
212
+ for doc_sym in documented_symbols:
213
+ if doc_sym not in code_symbols_set:
214
+ dead_symbols.append({"symbol": doc_sym, "file": rel_file})
215
+
216
+ # Check unmapped public symbols in source files
217
+ # (Only report top-level non-private symbols)
218
+ for sym_name, sym_info in code_symbols_dict.items():
219
+ if sym_name not in documented_symbols and not sym_name.startswith("_"):
220
+ unmapped_symbols.append({
221
+ "symbol": sym_name,
222
+ "kind": sym_info.get("kind", "symbol"),
223
+ "file": rel_file,
224
+ })
225
+
226
+ # Check date drift if last_verified is present
227
+ if ki_data["last_verified"]:
228
+ last_commit_date = get_git_commit_date(project_root, rel_file)
229
+ if last_commit_date and last_commit_date > ki_data["last_verified"]:
230
+ date_drifts.append({
231
+ "file": rel_file,
232
+ "commit_date": last_commit_date,
233
+ "last_verified": ki_data["last_verified"],
234
+ })
235
+
236
+ drift_count = (
237
+ len(dead_symbols)
238
+ + len(unmapped_symbols)
239
+ + len(missing_files)
240
+ + len(date_drifts)
241
+ + len(broken_links)
242
+ + len(integrity_issues)
243
+ )
244
+
245
+ return {
246
+ "ki": ki_name,
247
+ "drift_count": drift_count,
248
+ "last_verified": ki_data["last_verified"],
249
+ "dead_symbols": dead_symbols,
250
+ "unmapped_symbols": unmapped_symbols,
251
+ "missing_files": missing_files,
252
+ "date_drifts": date_drifts,
253
+ "broken_links": broken_links,
254
+ "integrity_issues": integrity_issues,
255
+ }
256
+
257
+
258
+ def run_drift_check(
259
+ ki_filter: Optional[str] = None,
260
+ project_root: Optional[str] = None,
261
+ knowledge_root: Optional[str] = None,
262
+ ) -> List[Dict[str, Any]]:
263
+ """Runs drift check on one or all KIs."""
264
+ if not project_root:
265
+ project_root = ki_utils.get_project_root()
266
+ if not knowledge_root:
267
+ knowledge_root = ki_utils.get_knowledge_root()
268
+
269
+ if not knowledge_root or not project_root:
270
+ raise RuntimeError("Could not determine project root or knowledge root.")
271
+
272
+ knowledge_dir = Path(knowledge_root) / "knowledge"
273
+ if not knowledge_dir.exists() and Path(knowledge_root).exists():
274
+ knowledge_dir = Path(knowledge_root)
275
+
276
+ if not knowledge_dir.exists():
277
+ return []
278
+
279
+ doc_config = ki_utils.get_doc_config()
280
+
281
+ if ki_filter:
282
+ target_name = ki_filter if ki_filter.endswith(".md") else f"{ki_filter}.md"
283
+ target_path = knowledge_dir / target_name
284
+ if not target_path.exists():
285
+ return [{"ki": target_name, "error": f"File not found: {target_name}", "drift_count": 1}]
286
+ return [check_single_ki(target_name, target_path, doc_config, project_root, knowledge_dir)]
287
+
288
+ results = []
289
+ for f in sorted(knowledge_dir.glob("KI_*.md")):
290
+ results.append(check_single_ki(f.name, f, doc_config, project_root, knowledge_dir))
291
+
292
+ return results
293
+
294
+
295
+ def format_drift_markdown(results: List[Dict[str, Any]]) -> str:
296
+ """Formats drift check results as clean Markdown."""
297
+ total_kis = len(results)
298
+ with_drift = [r for r in results if r.get("drift_count", 0) > 0]
299
+
300
+ lines = [
301
+ f"### Knowledge Base Drift Report",
302
+ f"- **Checked KIs**: {total_kis}",
303
+ f"- **KIs with drift**: {len(with_drift)}",
304
+ "",
305
+ ]
306
+
307
+ if not with_drift:
308
+ lines.append("✅ **No drift detected.** All Knowledge Items match current code and git state.")
309
+ return "\n".join(lines)
310
+
311
+ for item in with_drift:
312
+ ki_name = item["ki"]
313
+ lines.append(f"#### ⚠️ `{ki_name}` ({item['drift_count']} issues)")
314
+
315
+ if item.get("error"):
316
+ lines.append(f"- [!] Error: {item['error']}")
317
+ continue
318
+
319
+ for issue in item.get("integrity_issues", []):
320
+ lines.append(f"- [!] **Integrity**: {issue}")
321
+
322
+ for dead in item.get("dead_symbols", []):
323
+ lines.append(f"- [!] **Dead symbol**: `{dead['symbol']}` (no longer in `{dead['file']}`)")
324
+
325
+ for missing in item.get("missing_files", []):
326
+ lines.append(f"- [!] **Missing file**: `{missing}` (does not exist on disk)")
327
+
328
+ for broken in item.get("broken_links", []):
329
+ lines.append(f"- [!] **Broken wiki-link**: `[[{broken}]]` (target KI does not exist)")
330
+
331
+ for date_item in item.get("date_drifts", []):
332
+ lines.append(
333
+ f"- [~] **Date drift**: `{date_item['file']}` was committed on "
334
+ f"`{date_item['commit_date']}`, after last_verified (`{date_item['last_verified']}`)"
335
+ )
336
+
337
+ unmapped = item.get("unmapped_symbols", [])
338
+ if unmapped:
339
+ sample = unmapped[:5]
340
+ more = len(unmapped) - 5
341
+ unmapped_str = ", ".join(f"`{u['symbol']}`" for u in sample)
342
+ if more > 0:
343
+ unmapped_str += f" and {more} more"
344
+ lines.append(f"- [+] **Unmapped symbols**: {unmapped_str} in `{unmapped[0]['file']}`")
345
+
346
+ lines.append("")
347
+
348
+ return "\n".join(lines).strip()
349
+
350
+
351
+ def main():
352
+ if sys.platform == "win32":
353
+ sys.stdout.reconfigure(encoding="utf-8")
354
+
355
+ parser = argparse.ArgumentParser(description="Check drift and discrepancies between KI files and codebase.")
356
+ parser.add_argument("--ki", type=str, help="Specific KI file to check (e.g. KI_server.md)")
357
+ parser.add_argument("--all", action="store_true", help="Check all KIs in the knowledge base")
358
+ parser.add_argument("--json", action="store_true", help="Output results in JSON format")
359
+ parser.add_argument("--workspace", type=str, default=None, help=argparse.SUPPRESS)
360
+
361
+ args = parser.parse_args()
362
+
363
+ if args.workspace:
364
+ ki_utils.ACTIVE_WORKSPACE_PATH = ki_utils.normalize_path(args.workspace)
365
+ else:
366
+ match = ki_utils.find_project_by_cwd(os.getcwd())
367
+ if match:
368
+ target_path = match.get("config_path") or match.get("workspace") or os.getcwd()
369
+ ki_utils.ACTIVE_WORKSPACE_PATH = ki_utils.normalize_path(target_path)
370
+ else:
371
+ ki_utils.ACTIVE_WORKSPACE_PATH = ki_utils.normalize_path(os.getcwd())
372
+
373
+ results = run_drift_check(ki_filter=args.ki)
374
+
375
+ if args.json:
376
+ print(json.dumps(results, indent=2, ensure_ascii=False))
377
+ else:
378
+ print(format_drift_markdown(results))
379
+
380
+
381
+ if __name__ == "__main__":
382
+ main()
@@ -29,8 +29,8 @@ PENDING_MARKER = "<!-- scaffold: true -->"
29
29
 
30
30
 
31
31
  def extract_overview(content: str) -> str:
32
- """Extract the first non-empty paragraph from the Overview section."""
33
- match = re.search(r"## Overview\s*\n(.*?)(?=\n##|\Z)", content, re.DOTALL)
32
+ """Extract the first non-empty paragraph from the Overview section (or legacy Purpose / What it Does)."""
33
+ match = re.search(r"## (?:Overview|Purpose|What it Does)\s*\n(.*?)(?=\n##|\Z)", content, re.DOTALL)
34
34
  if not match:
35
35
  return ""
36
36
  text = match.group(1).strip()
@@ -5,7 +5,7 @@ import json
5
5
  from pathlib import Path
6
6
  from typing import List
7
7
 
8
- # Добавляем путь к ki_utils
8
+ # Add path to ki_utils
9
9
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
10
10
  import ki_utils
11
11
 
@@ -17,7 +17,7 @@ def get_unmapped_files(target_path: str = ".") -> List[str]:
17
17
  if not project_root or not doc_config:
18
18
  return []
19
19
 
20
- # 1. Собираем все файлы, которые уже замаплены в doc_config.json
20
+ # 1. Collect all files that are already mapped in doc_config.json
21
21
  mapped_paths = set()
22
22
  knowledge_items = doc_config.get("knowledge_items", {})
23
23
 
@@ -27,7 +27,7 @@ def get_unmapped_files(target_path: str = ".") -> List[str]:
27
27
  abs_p = os.path.abspath(os.path.join(project_root, p))
28
28
  mapped_paths.add(abs_p)
29
29
 
30
- # 2. Сканируем целевую директорию
30
+ # 2. Scan the target directory
31
31
  abs_target = os.path.abspath(os.path.join(project_root, target_path))
32
32
  if not os.path.exists(abs_target):
33
33
  return []