k-cli-for-devs 1.0.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.
Files changed (75) hide show
  1. k_cli/__init__.py +77 -0
  2. k_cli/agents/__init__.py +0 -0
  3. k_cli/agents/adversarial_swarm.py +338 -0
  4. k_cli/agents/agent_core.py +255 -0
  5. k_cli/agents/background_daemon.py +141 -0
  6. k_cli/agents/orchestrator.py +376 -0
  7. k_cli/agents/persona.py +649 -0
  8. k_cli/agents/scaffold_engine.py +121 -0
  9. k_cli/agents/strands_agent.py +832 -0
  10. k_cli/agents/subagents.py +1496 -0
  11. k_cli/cli.py +3297 -0
  12. k_cli/core/__init__.py +0 -0
  13. k_cli/core/airgap.py +95 -0
  14. k_cli/core/credentials.py +548 -0
  15. k_cli/core/intent_sensor.py +177 -0
  16. k_cli/core/llm_driver.py +1028 -0
  17. k_cli/core/model_manager.py +1109 -0
  18. k_cli/core/models_hub.py +913 -0
  19. k_cli/core/prompting.py +41 -0
  20. k_cli/core/sdk.py +322 -0
  21. k_cli/core/session.py +826 -0
  22. k_cli/core/smart_router.py +230 -0
  23. k_cli/core/storage_manager.py +176 -0
  24. k_cli/core/viewport_engine.py +117 -0
  25. k_cli/demo/demo_runner.py +579 -0
  26. k_cli/git/__init__.py +0 -0
  27. k_cli/git/ai_bisect.py +208 -0
  28. k_cli/git/conflict_resolver.py +1039 -0
  29. k_cli/git/git_guard.py +417 -0
  30. k_cli/git/patcher.py +1175 -0
  31. k_cli/git/repo_map.py +1780 -0
  32. k_cli/git/smart_git.py +928 -0
  33. k_cli/git/verifier.py +969 -0
  34. k_cli/github/__init__.py +0 -0
  35. k_cli/github/dedup_engine.py +787 -0
  36. k_cli/github/github_client.py +1702 -0
  37. k_cli/github/github_engine.py +641 -0
  38. k_cli/github/local_hub.py +209 -0
  39. k_cli/github/pr_watcher.py +129 -0
  40. k_cli/github/trending.py +205 -0
  41. k_cli/tools/__init__.py +0 -0
  42. k_cli/tools/audit.py +79 -0
  43. k_cli/tools/chaos_immunity.py +377 -0
  44. k_cli/tools/codebase_qa.py +106 -0
  45. k_cli/tools/command_runner.py +256 -0
  46. k_cli/tools/diagram_generator.py +547 -0
  47. k_cli/tools/doc_retriever.py +1332 -0
  48. k_cli/tools/feature.py +105 -0
  49. k_cli/tools/ghost_daemon.py +122 -0
  50. k_cli/tools/incident_triage.py +1365 -0
  51. k_cli/tools/mcp_client.py +1846 -0
  52. k_cli/tools/repo_gardener.py +142 -0
  53. k_cli/tools/rules.py +109 -0
  54. k_cli/tools/security.py +52 -0
  55. k_cli/tools/security_healer.py +999 -0
  56. k_cli/tools/synapse_graph.py +155 -0
  57. k_cli/tui/__init__.py +0 -0
  58. k_cli/tui/diff_viewer.py +223 -0
  59. k_cli/tui/tui.py +1145 -0
  60. k_cli/tui/tui_animations.py +648 -0
  61. k_cli/tui/tui_app.py +2788 -0
  62. k_cli/ui/__init__.py +10 -0
  63. k_cli/ui/simple_repl.py +315 -0
  64. k_cli/web/__init__.py +7 -0
  65. k_cli/web/server.py +624 -0
  66. k_cli/web/static/app.js +830 -0
  67. k_cli/web/static/index.html +495 -0
  68. k_cli/web/static/monitor.html +189 -0
  69. k_cli/web/static/style.css +838 -0
  70. k_cli_for_devs-1.0.0.dist-info/METADATA +461 -0
  71. k_cli_for_devs-1.0.0.dist-info/RECORD +75 -0
  72. k_cli_for_devs-1.0.0.dist-info/WHEEL +5 -0
  73. k_cli_for_devs-1.0.0.dist-info/entry_points.txt +2 -0
  74. k_cli_for_devs-1.0.0.dist-info/licenses/LICENSE +21 -0
  75. k_cli_for_devs-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,787 @@
1
+ """
2
+ dedup_engine.py - Intelligent Repository & Request Deduplication Engine for K-CLI
3
+
4
+ Features:
5
+ 1. Multi-tier semantic and lexical similarity analysis (BM25, Jaccard, token overlap, string distance, stemming, prefix matching).
6
+ 2. Deep AST symbol extraction and indexing powered by RepoMap.
7
+ 3. Git commit history scanning (commit messages, diff stats, issue/PR references).
8
+ 4. Code snippet and symbol duplicate detection with precise line ranges.
9
+ 5. Structured DedupMatch results with confidence scores and explainability.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import difflib
15
+ import math
16
+ import os
17
+ import re
18
+ import subprocess
19
+ import textwrap
20
+ from collections import Counter, defaultdict
21
+ from dataclasses import dataclass, field
22
+ from pathlib import Path
23
+ from typing import Any, Dict, List, Optional, Set, Tuple, Union
24
+
25
+ try:
26
+ from k_cli.git.repo_map import RepoMap
27
+ except ModuleNotFoundError:
28
+ try:
29
+ from repo_map import RepoMap
30
+ except ModuleNotFoundError:
31
+ RepoMap = None # type: ignore
32
+
33
+
34
+ @dataclass
35
+ class DedupMatch:
36
+ """Structured result of a deduplication query check."""
37
+ is_duplicate: bool
38
+ confidence: float # Score from 0.0 to 1.0
39
+ existing_commit: Optional[str] = None # Commit hash if matched in git history
40
+ file_path: Optional[str] = None # File path if matched in codebase
41
+ line_range: Optional[Tuple[int, int]] = None # (start_line, end_line) in file
42
+ explanation: str = "" # Human-readable rationale
43
+ match_type: str = "none" # "commit", "symbol", "issue_pr", "snippet", "none"
44
+ metadata: Dict[str, Any] = field(default_factory=dict)
45
+
46
+ def to_dict(self) -> Dict[str, Any]:
47
+ """Serializes DedupMatch to dictionary."""
48
+ return {
49
+ "is_duplicate": self.is_duplicate,
50
+ "confidence": round(self.confidence, 4),
51
+ "existing_commit": self.existing_commit,
52
+ "file_path": self.file_path,
53
+ "line_range": list(self.line_range) if self.line_range else None,
54
+ "explanation": self.explanation,
55
+ "match_type": self.match_type,
56
+ "metadata": self.metadata,
57
+ }
58
+
59
+
60
+ @dataclass
61
+ class CommitRecord:
62
+ """Structured git commit record."""
63
+ commit_hash: str
64
+ short_hash: str
65
+ author: str
66
+ date: str
67
+ subject: str
68
+ body: str
69
+ files: List[str] = field(default_factory=list)
70
+ diff_stat: str = ""
71
+ issue_refs: List[str] = field(default_factory=list)
72
+
73
+
74
+ @dataclass
75
+ class SymbolRecord:
76
+ """Structured AST symbol record."""
77
+ name: str
78
+ type: str # "class", "function", "method", "struct", "interface", etc.
79
+ file_path: str
80
+ rel_path: str
81
+ line_number: int
82
+ end_lineno: int
83
+ signature: str
84
+ docstring: Optional[str] = None
85
+
86
+
87
+ class SimilarityScorer:
88
+ """
89
+ Computes lexical, statistical (BM25), and set-based similarity metrics
90
+ between search queries, commit records, and source code tokens.
91
+ """
92
+
93
+ STOP_WORDS: Set[str] = {
94
+ "a", "an", "the", "and", "or", "in", "on", "at", "to", "for", "of", "with",
95
+ "by", "from", "is", "are", "was", "were", "be", "been", "being", "have", "has",
96
+ "had", "do", "does", "did", "can", "could", "should", "would", "will", "this",
97
+ "that", "these", "those", "it", "its", "as", "if", "each", "all", "both",
98
+ "into", "through", "during", "before", "after", "above", "below", "up", "down",
99
+ "create", "implement", "add", "build", "write", "make", "new",
100
+ }
101
+
102
+ @classmethod
103
+ def stem_token(cls, token: str) -> str:
104
+ """Lightweight stemmer for common suffixes and domain prefixes in code."""
105
+ t = token.lower()
106
+ if t.startswith("auth"):
107
+ return "auth"
108
+ if t.startswith("calc"):
109
+ return "calc"
110
+ if t.startswith("valid"):
111
+ return "valid"
112
+ if t.startswith("config"):
113
+ return "config"
114
+ if t.startswith("init"):
115
+ return "init"
116
+
117
+ if len(t) > 5:
118
+ for suffix in ("ation", "izing", "ising", "ator", "tion", "ment", "ness", "able", "ible"):
119
+ if t.endswith(suffix):
120
+ return t[:-len(suffix)]
121
+ if len(t) > 4:
122
+ for suffix in ("ing", "ies", "ied", "ers", "est", "ant", "ent"):
123
+ if t.endswith(suffix):
124
+ return t[:-len(suffix)]
125
+ if len(t) > 3:
126
+ for suffix in ("ed", "er", "es", "ly"):
127
+ if t.endswith(suffix):
128
+ return t[:-len(suffix)]
129
+ if t.endswith("s") and not t.endswith("ss"):
130
+ return t[:-1]
131
+ return t
132
+
133
+ @classmethod
134
+ def tokenize(cls, text: str, stem: bool = False) -> List[str]:
135
+ """
136
+ Code-aware tokenization:
137
+ - Splits acronyms & camelCase (`JWTTokenHandler` -> `jwt`, `token`, `handler`)
138
+ - Splits snake_case and kebab-case (`calculate_total_score` -> `calculate`, `total`, `score`)
139
+ - Normalizes to lowercase and removes punctuation and stop words.
140
+ """
141
+ if not text:
142
+ return []
143
+
144
+ s1 = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1 \2", text)
145
+ s2 = re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", s1)
146
+ raw_tokens = re.findall(r"[A-Za-z0-9]+", s2.lower())
147
+
148
+ tokens: List[str] = []
149
+ for t in raw_tokens:
150
+ if len(t) > 1 and t not in cls.STOP_WORDS:
151
+ tokens.append(cls.stem_token(t) if stem else t)
152
+ return tokens
153
+
154
+ @classmethod
155
+ def _match_tokens(cls, t1: str, t2: str) -> bool:
156
+ """Returns True if tokens match directly, via stemming, or via common code prefixes."""
157
+ if t1 == t2:
158
+ return True
159
+ st1 = cls.stem_token(t1)
160
+ st2 = cls.stem_token(t2)
161
+ if st1 == st2:
162
+ return True
163
+ if len(t1) >= 4 and len(t2) >= 4:
164
+ if t1.startswith(t2) or t2.startswith(t1):
165
+ return True
166
+ return False
167
+
168
+ @classmethod
169
+ def jaccard_similarity(cls, tokens1: List[str], tokens2: List[str]) -> float:
170
+ """Calculates Jaccard set similarity between two token lists with stemming support."""
171
+ s1 = {cls.stem_token(t) for t in tokens1}
172
+ s2 = {cls.stem_token(t) for t in tokens2}
173
+ if not s1 or not s2:
174
+ return 0.0
175
+ intersection = sum(1 for t1 in s1 if any(cls._match_tokens(t1, t2) for t2 in s2))
176
+ union = len(s1 | s2)
177
+ return intersection / union if union > 0 else 0.0
178
+
179
+ @classmethod
180
+ def token_overlap(cls, query_tokens: List[str], doc_tokens: List[str]) -> float:
181
+ """Calculates query token containment / overlap ratio in document with stemming."""
182
+ if not query_tokens:
183
+ return 0.0
184
+ q_set = {cls.stem_token(t) for t in query_tokens}
185
+ d_set = {cls.stem_token(t) for t in doc_tokens}
186
+ overlap = sum(1 for q in q_set if any(cls._match_tokens(q, d) for d in d_set))
187
+ return overlap / len(q_set) if q_set else 0.0
188
+
189
+ @classmethod
190
+ def bm25_score(
191
+ cls,
192
+ query_tokens: List[str],
193
+ doc_tokens: List[str],
194
+ corpus_doc_freq: Dict[str, int],
195
+ total_docs: int,
196
+ avg_doc_len: float,
197
+ k1: float = 1.5,
198
+ b: float = 0.75,
199
+ ) -> float:
200
+ """
201
+ Calculates normalized BM25 score in range [0.0, 1.0].
202
+ """
203
+ if not query_tokens or not doc_tokens:
204
+ return 0.0
205
+
206
+ q_stemmed = [cls.stem_token(t) for t in query_tokens]
207
+ d_stemmed = [cls.stem_token(t) for t in doc_tokens]
208
+
209
+ doc_len = len(d_stemmed)
210
+ doc_counts = Counter(d_stemmed)
211
+ score = 0.0
212
+
213
+ matched_terms = 0
214
+ total_q_terms = len(set(q_stemmed))
215
+
216
+ for q in set(q_stemmed):
217
+ df = corpus_doc_freq.get(q, 1)
218
+ idf = math.log((total_docs - df + 0.5) / (df + 0.5) + 1.0)
219
+ if idf < 0:
220
+ idf = 0.1
221
+
222
+ tf = sum(count for term, count in doc_counts.items() if cls._match_tokens(q, term))
223
+ if tf > 0:
224
+ matched_terms += 1
225
+ numerator = tf * (k1 + 1.0)
226
+ denominator = tf + k1 * (1.0 - b + b * (doc_len / (avg_doc_len or 1.0)))
227
+ term_score = (numerator / denominator)
228
+ score += idf * term_score
229
+
230
+ coverage = matched_terms / total_q_terms if total_q_terms > 0 else 0.0
231
+ saturation = (score / (total_q_terms * (k1 + 1.0))) if total_q_terms > 0 else 0.0
232
+ normalized = 0.6 * coverage + 0.4 * min(1.0, saturation * 1.5)
233
+ return min(1.0, max(0.0, normalized))
234
+
235
+ @classmethod
236
+ def string_similarity(cls, str1: str, str2: str) -> float:
237
+ """Calculates SequenceMatcher string similarity ratio."""
238
+ return difflib.SequenceMatcher(None, str1.lower().strip(), str2.lower().strip()).ratio()
239
+
240
+ @classmethod
241
+ def composite_similarity(
242
+ cls,
243
+ query: str,
244
+ target_text: str,
245
+ corpus_doc_freq: Optional[Dict[str, int]] = None,
246
+ total_docs: int = 1,
247
+ avg_doc_len: float = 20.0,
248
+ ) -> float:
249
+ """
250
+ Calculates weighted composite similarity score combining BM25,
251
+ Jaccard similarity, query token overlap, and string distance.
252
+ """
253
+ q_tokens = cls.tokenize(query)
254
+ t_tokens = cls.tokenize(target_text)
255
+
256
+ if not q_tokens or not t_tokens:
257
+ return 0.0
258
+
259
+ jaccard = cls.jaccard_similarity(q_tokens, t_tokens)
260
+ overlap = cls.token_overlap(q_tokens, t_tokens)
261
+ str_sim = cls.string_similarity(query, target_text)
262
+
263
+ bm25 = 0.0
264
+ if corpus_doc_freq and total_docs > 0:
265
+ bm25 = cls.bm25_score(
266
+ query_tokens=q_tokens,
267
+ doc_tokens=t_tokens,
268
+ corpus_doc_freq=corpus_doc_freq,
269
+ total_docs=total_docs,
270
+ avg_doc_len=avg_doc_len,
271
+ )
272
+ else:
273
+ bm25 = overlap
274
+
275
+ # Composite score
276
+ composite = 0.40 * overlap + 0.30 * bm25 + 0.20 * jaccard + 0.10 * str_sim
277
+ return min(1.0, max(0.0, composite))
278
+
279
+
280
+ class DedupEngine:
281
+ """
282
+ Repository and Request Deduplication Engine for K-CLI.
283
+
284
+ Identifies duplicate tasks, redundant code additions, existing AST symbols,
285
+ and past git commits to prevent duplicative work across large projects.
286
+ """
287
+
288
+ def __init__(
289
+ self,
290
+ repo_path: str = ".",
291
+ duplicate_threshold: float = 0.65,
292
+ git_depth: int = 50,
293
+ ):
294
+ """
295
+ Initializes DedupEngine.
296
+
297
+ Args:
298
+ repo_path: Target repository path.
299
+ duplicate_threshold: Confidence score threshold (0.0 to 1.0) to mark as duplicate.
300
+ git_depth: Max commit history depth to scan by default.
301
+ """
302
+ self.repo_path = Path(repo_path).resolve()
303
+ self.duplicate_threshold = duplicate_threshold
304
+ self.git_depth = git_depth
305
+ self._repo_map: Optional[Any] = None
306
+ self._cached_symbols: Optional[List[SymbolRecord]] = None
307
+ self._cached_commits: Optional[List[CommitRecord]] = None
308
+
309
+ def get_repo_map(self) -> Any:
310
+ """Lazy loads RepoMap instance."""
311
+ if self._repo_map is None and RepoMap is not None:
312
+ self._repo_map = RepoMap(root_dir=str(self.repo_path))
313
+ return self._repo_map
314
+
315
+ # =========================================================================
316
+ # Git History Indexing & Extraction
317
+ # =========================================================================
318
+
319
+ def get_git_commits(self, depth: Optional[int] = None) -> List[CommitRecord]:
320
+ """
321
+ Extracts recent commit records, messages, diff stats, and issue references.
322
+
323
+ Args:
324
+ depth: Max number of commits to retrieve.
325
+
326
+ Returns:
327
+ List of CommitRecord objects.
328
+ """
329
+ if self._cached_commits is not None and (depth is None or len(self._cached_commits) >= depth):
330
+ return self._cached_commits[:depth] if depth else self._cached_commits
331
+
332
+ max_depth = depth or self.git_depth
333
+ commits: List[CommitRecord] = []
334
+
335
+ if not self.repo_path.exists() or not (self.repo_path / ".git").exists():
336
+ return []
337
+
338
+ sep_field = "%x1f"
339
+ sep_record = "%x1e"
340
+ format_str = f"COMMIT_START{sep_field}%H{sep_field}%h{sep_field}%an{sep_field}%ad{sep_field}%s{sep_field}%b{sep_record}"
341
+
342
+ cmd = [
343
+ "git",
344
+ "log",
345
+ f"-n{max_depth}",
346
+ f"--pretty=format:{format_str}",
347
+ "--stat",
348
+ ]
349
+
350
+ try:
351
+ res = subprocess.run(
352
+ cmd,
353
+ cwd=str(self.repo_path),
354
+ capture_output=True,
355
+ text=True,
356
+ errors="ignore",
357
+ )
358
+ if res.returncode != 0 or not res.stdout.strip():
359
+ return []
360
+
361
+ raw_entries = res.stdout.split("COMMIT_START\x1f")
362
+ issue_regex = re.compile(r"\b(?:closes?|closed|fixes?|fixed|resolves?|resolved|pr|issue|pull\s+request)\s*#?(\d+)\b", re.IGNORECASE)
363
+
364
+ for entry in raw_entries:
365
+ if not entry.strip():
366
+ continue
367
+
368
+ parts = entry.split("\x1e", 1)
369
+ meta_part = parts[0]
370
+ stat_part = parts[1] if len(parts) > 1 else ""
371
+
372
+ fields = meta_part.split("\x1f")
373
+ if len(fields) < 5:
374
+ continue
375
+
376
+ commit_hash = fields[0].strip()
377
+ short_hash = fields[1].strip()
378
+ author = fields[2].strip()
379
+ date = fields[3].strip()
380
+ subject = fields[4].strip()
381
+ body = fields[5].strip() if len(fields) > 5 else ""
382
+
383
+ # Extract modified files from stat
384
+ files_changed: List[str] = []
385
+ for line in stat_part.splitlines():
386
+ if "|" in line:
387
+ file_token = line.split("|")[0].strip()
388
+ if file_token:
389
+ files_changed.append(file_token)
390
+
391
+ # Extract issue references
392
+ full_msg = f"{subject}\n{body}"
393
+ issue_refs: List[str] = []
394
+ for match in issue_regex.finditer(full_msg):
395
+ issue_refs.append(f"#{match.group(1)}")
396
+
397
+ record = CommitRecord(
398
+ commit_hash=commit_hash,
399
+ short_hash=short_hash,
400
+ author=author,
401
+ date=date,
402
+ subject=subject,
403
+ body=body,
404
+ files=files_changed,
405
+ diff_stat=stat_part.strip(),
406
+ issue_refs=sorted(set(issue_refs)),
407
+ )
408
+ commits.append(record)
409
+
410
+ except Exception:
411
+ return []
412
+
413
+ self._cached_commits = commits
414
+ return commits
415
+
416
+ # =========================================================================
417
+ # AST Symbol Table Extraction & Indexing
418
+ # =========================================================================
419
+
420
+ def get_ast_symbols(self) -> List[SymbolRecord]:
421
+ """
422
+ Extracts all AST symbol records across supported source files in the repository.
423
+
424
+ Returns:
425
+ List of SymbolRecord objects.
426
+ """
427
+ if self._cached_symbols is not None:
428
+ return self._cached_symbols
429
+
430
+ rm = self.get_repo_map()
431
+ symbols: List[SymbolRecord] = []
432
+
433
+ if rm is None:
434
+ return []
435
+
436
+ try:
437
+ files = rm.scan_workspace_files()
438
+ for fpath in files:
439
+ try:
440
+ rel_p = rm._resolve_relative_path(fpath)
441
+ raw_symbols = rm.extract_symbols(fpath)
442
+ for sym in raw_symbols:
443
+ name = sym.get("name", "")
444
+ sym_type = sym.get("type", "symbol")
445
+ lineno = sym.get("lineno", sym.get("line_number", 1))
446
+ end_lineno = sym.get("end_lineno", lineno)
447
+ signature = sym.get("signature", name)
448
+ docstring = sym.get("docstring")
449
+
450
+ symbols.append(SymbolRecord(
451
+ name=name,
452
+ type=sym_type,
453
+ file_path=fpath,
454
+ rel_path=rel_p,
455
+ line_number=lineno,
456
+ end_lineno=end_lineno,
457
+ signature=signature,
458
+ docstring=docstring,
459
+ ))
460
+ except Exception:
461
+ continue
462
+ except Exception:
463
+ return []
464
+
465
+ self._cached_symbols = symbols
466
+ return symbols
467
+
468
+ # =========================================================================
469
+ # Similarity Scans
470
+ # =========================================================================
471
+
472
+ def scan_git_history(
473
+ self,
474
+ query: str,
475
+ repo_path: Optional[str] = None,
476
+ git_depth: int = 50,
477
+ ) -> List[Tuple[float, CommitRecord]]:
478
+ """
479
+ Scans git commit history for matching commits based on query.
480
+
481
+ Returns:
482
+ Sorted list of (similarity_score, CommitRecord) tuples in descending score order.
483
+ """
484
+ commits = self.get_git_commits(depth=git_depth)
485
+ if not commits or not query.strip():
486
+ return []
487
+
488
+ doc_tokens_list = []
489
+ corpus_df: Dict[str, int] = defaultdict(int)
490
+
491
+ for c in commits:
492
+ doc_text = f"{c.subject} {c.body} {' '.join(c.files)} {' '.join(c.issue_refs)}"
493
+ toks = SimilarityScorer.tokenize(doc_text, stem=True)
494
+ doc_tokens_list.append(toks)
495
+ for t in set(toks):
496
+ corpus_df[t] += 1
497
+
498
+ total_docs = len(commits)
499
+ avg_len = sum(len(t) for t in doc_tokens_list) / (total_docs or 1)
500
+
501
+ results: List[Tuple[float, CommitRecord]] = []
502
+ for i, c in enumerate(commits):
503
+ doc_text = f"{c.subject} {c.body} {' '.join(c.files)} {' '.join(c.issue_refs)}"
504
+ sim = SimilarityScorer.composite_similarity(
505
+ query=query,
506
+ target_text=doc_text,
507
+ corpus_doc_freq=corpus_df,
508
+ total_docs=total_docs,
509
+ avg_doc_len=avg_len,
510
+ )
511
+
512
+ # Direct subject match boost
513
+ subj_sim = SimilarityScorer.string_similarity(query, c.subject)
514
+ q_tokens = set(SimilarityScorer.tokenize(query, stem=True))
515
+ s_tokens = set(SimilarityScorer.tokenize(c.subject, stem=True))
516
+ if q_tokens and s_tokens and len(q_tokens & s_tokens) / len(q_tokens) >= 0.7:
517
+ sim = max(sim, 0.88)
518
+ elif subj_sim > 0.6:
519
+ sim = max(sim, subj_sim)
520
+
521
+ if sim > 0.15:
522
+ results.append((sim, c))
523
+
524
+ results.sort(key=lambda x: x[0], reverse=True)
525
+ return results
526
+
527
+ def scan_ast_symbols(
528
+ self,
529
+ query: str,
530
+ repo_path: Optional[str] = None,
531
+ ) -> List[Tuple[float, SymbolRecord]]:
532
+ """
533
+ Scans codebase AST symbols (classes, functions, methods, structs) for matches.
534
+
535
+ Returns:
536
+ Sorted list of (similarity_score, SymbolRecord) tuples in descending score order.
537
+ """
538
+ symbols = self.get_ast_symbols()
539
+ if not symbols or not query.strip():
540
+ return []
541
+
542
+ doc_tokens_list = []
543
+ corpus_df: Dict[str, int] = defaultdict(int)
544
+
545
+ for sym in symbols:
546
+ doc_text = f"{sym.name} {sym.type} {sym.signature} {sym.docstring or ''} {os.path.basename(sym.rel_path)}"
547
+ toks = SimilarityScorer.tokenize(doc_text, stem=True)
548
+ doc_tokens_list.append(toks)
549
+ for t in set(toks):
550
+ corpus_df[t] += 1
551
+
552
+ total_docs = len(symbols)
553
+ avg_len = sum(len(t) for t in doc_tokens_list) / (total_docs or 1)
554
+
555
+ results: List[Tuple[float, SymbolRecord]] = []
556
+ for i, sym in enumerate(symbols):
557
+ doc_text = f"{sym.name} {sym.type} {sym.signature} {sym.docstring or ''} {os.path.basename(sym.rel_path)}"
558
+ sim = SimilarityScorer.composite_similarity(
559
+ query=query,
560
+ target_text=doc_text,
561
+ corpus_doc_freq=corpus_df,
562
+ total_docs=total_docs,
563
+ avg_doc_len=avg_len,
564
+ )
565
+
566
+ # Symbol name containment boost:
567
+ name_tokens = set(SimilarityScorer.tokenize(sym.name, stem=True))
568
+ query_tokens = set(SimilarityScorer.tokenize(query, stem=True))
569
+ if query_tokens and name_tokens:
570
+ name_overlap = sum(1 for n in name_tokens if any(SimilarityScorer._match_tokens(n, q) for q in query_tokens)) / len(name_tokens)
571
+ if name_overlap >= 0.8:
572
+ sim = max(sim, 0.90)
573
+ elif name_overlap >= 0.5:
574
+ sim = max(sim, 0.75)
575
+
576
+ name_sim = SimilarityScorer.string_similarity(query, sym.name)
577
+ if name_sim > 0.7:
578
+ sim = max(sim, name_sim)
579
+
580
+ if sim > 0.15:
581
+ results.append((sim, sym))
582
+
583
+ results.sort(key=lambda x: x[0], reverse=True)
584
+ return results
585
+
586
+ # =========================================================================
587
+ # Primary Deduplication Interface
588
+ # =========================================================================
589
+
590
+ def scan_for_duplicate(
591
+ self,
592
+ query: str,
593
+ repo_path: str = ".",
594
+ git_depth: int = 50,
595
+ ) -> Optional[DedupMatch]:
596
+ """
597
+ Scans repository commits and codebase AST symbol tables for duplicates.
598
+
599
+ Args:
600
+ query: User request, feature description, or code prompt.
601
+ repo_path: Repository workspace path.
602
+ git_depth: Commit depth to inspect.
603
+
604
+ Returns:
605
+ DedupMatch object with duplicate status, confidence score, and explanation.
606
+ """
607
+ clean_query = query.strip()
608
+ if not clean_query:
609
+ return DedupMatch(
610
+ is_duplicate=False,
611
+ confidence=0.0,
612
+ explanation="Empty query provided.",
613
+ match_type="none",
614
+ )
615
+
616
+ # 1. Scan Git commits
617
+ commit_matches = self.scan_git_history(query=clean_query, repo_path=repo_path, git_depth=git_depth)
618
+ top_commit: Optional[Tuple[float, CommitRecord]] = commit_matches[0] if commit_matches else None
619
+
620
+ # 2. Scan AST symbols
621
+ symbol_matches = self.scan_ast_symbols(query=clean_query, repo_path=repo_path)
622
+ top_symbol: Optional[Tuple[float, SymbolRecord]] = symbol_matches[0] if symbol_matches else None
623
+
624
+ commit_score = top_commit[0] if top_commit else 0.0
625
+ symbol_score = top_symbol[0] if top_symbol else 0.0
626
+
627
+ if symbol_score >= commit_score and top_symbol is not None:
628
+ score, sym = top_symbol
629
+ is_dup = score >= self.duplicate_threshold
630
+ expl = (
631
+ f"Matched existing {sym.type} `{sym.name}` in `{sym.rel_path}` (lines {sym.line_number}-{sym.end_lineno}) "
632
+ f"with {score:.1%} confidence."
633
+ )
634
+ return DedupMatch(
635
+ is_duplicate=is_dup,
636
+ confidence=score,
637
+ existing_commit=None,
638
+ file_path=sym.file_path,
639
+ line_range=(sym.line_number, sym.end_lineno),
640
+ explanation=expl,
641
+ match_type="symbol",
642
+ metadata={
643
+ "symbol_name": sym.name,
644
+ "symbol_type": sym.type,
645
+ "signature": sym.signature,
646
+ "rel_path": sym.rel_path,
647
+ },
648
+ )
649
+
650
+ elif top_commit is not None:
651
+ score, c = top_commit
652
+ is_dup = score >= self.duplicate_threshold
653
+ first_file = c.files[0] if c.files else None
654
+ issue_str = f" referencing {', '.join(c.issue_refs)}" if c.issue_refs else ""
655
+ expl = (
656
+ f"Matched existing commit {c.short_hash} ('{c.subject}'){issue_str} "
657
+ f"with {score:.1%} confidence."
658
+ )
659
+ return DedupMatch(
660
+ is_duplicate=is_dup,
661
+ confidence=score,
662
+ existing_commit=c.commit_hash,
663
+ file_path=str(self.repo_path / first_file) if first_file else None,
664
+ line_range=None,
665
+ explanation=expl,
666
+ match_type="commit",
667
+ metadata={
668
+ "commit_hash": c.commit_hash,
669
+ "short_hash": c.short_hash,
670
+ "subject": c.subject,
671
+ "author": c.author,
672
+ "date": c.date,
673
+ "files": c.files,
674
+ "issue_refs": c.issue_refs,
675
+ },
676
+ )
677
+
678
+ return DedupMatch(
679
+ is_duplicate=False,
680
+ confidence=0.0,
681
+ explanation="No matching existing commits or AST symbols found in repository.",
682
+ match_type="none",
683
+ )
684
+
685
+ # =========================================================================
686
+ # Additional Duplicate Helpers
687
+ # =========================================================================
688
+
689
+ def find_duplicate_symbols(
690
+ self,
691
+ symbol_name: str,
692
+ repo_path: str = ".",
693
+ ) -> List[Dict[str, Any]]:
694
+ """
695
+ Finds existing AST symbols with identical or highly similar names.
696
+
697
+ Args:
698
+ symbol_name: Target symbol identifier name.
699
+ repo_path: Workspace directory.
700
+
701
+ Returns:
702
+ List of matching symbol detail dictionaries.
703
+ """
704
+ matches = self.scan_ast_symbols(query=symbol_name, repo_path=repo_path)
705
+ output: List[Dict[str, Any]] = []
706
+
707
+ for score, sym in matches:
708
+ if score >= 0.5:
709
+ output.append({
710
+ "name": sym.name,
711
+ "type": sym.type,
712
+ "file_path": sym.file_path,
713
+ "rel_path": sym.rel_path,
714
+ "line_number": sym.line_number,
715
+ "end_lineno": sym.end_lineno,
716
+ "signature": sym.signature,
717
+ "confidence": round(score, 4),
718
+ })
719
+ return output
720
+
721
+ def find_duplicate_code_snippets(
722
+ self,
723
+ code_snippet: str,
724
+ repo_path: str = ".",
725
+ threshold: float = 0.7,
726
+ ) -> List[Dict[str, Any]]:
727
+ """
728
+ Finds source files in repository containing duplicate or near-identical code blocks.
729
+
730
+ Args:
731
+ code_snippet: Target code snippet to search for.
732
+ repo_path: Repository path.
733
+ threshold: Minimum token similarity threshold.
734
+
735
+ Returns:
736
+ List of match dictionaries with file path and similarity score.
737
+ """
738
+ clean_snippet = code_snippet.strip()
739
+ if not clean_snippet:
740
+ return []
741
+
742
+ rm = self.get_repo_map()
743
+ if rm is None:
744
+ return []
745
+
746
+ snippet_tokens = SimilarityScorer.tokenize(clean_snippet)
747
+ if not snippet_tokens:
748
+ return []
749
+
750
+ dedented_snippet = textwrap.dedent(clean_snippet)
751
+ matches: List[Dict[str, Any]] = []
752
+ files = rm.scan_workspace_files()
753
+
754
+ for fpath in files:
755
+ try:
756
+ content = Path(fpath).read_text(encoding="utf-8", errors="ignore")
757
+ file_tokens = SimilarityScorer.tokenize(content)
758
+ if not file_tokens:
759
+ continue
760
+
761
+ overlap = SimilarityScorer.token_overlap(snippet_tokens, file_tokens)
762
+ jaccard = SimilarityScorer.jaccard_similarity(snippet_tokens, file_tokens)
763
+ score = 0.6 * overlap + 0.4 * jaccard
764
+
765
+ is_exact = (
766
+ clean_snippet in content
767
+ or dedented_snippet in textwrap.dedent(content)
768
+ or overlap == 1.0
769
+ )
770
+
771
+ if score >= threshold or is_exact:
772
+ confidence = 1.0 if is_exact else score
773
+ matches.append({
774
+ "file_path": fpath,
775
+ "rel_path": rm._resolve_relative_path(fpath),
776
+ "confidence": round(confidence, 4),
777
+ "exact_match": is_exact,
778
+ })
779
+ except Exception:
780
+ continue
781
+
782
+ matches.sort(key=lambda x: x["confidence"], reverse=True)
783
+ return matches
784
+
785
+ def calculate_similarity(self, text1: str, text2: str) -> float:
786
+ """Utility shortcut for calculating composite similarity between two text strings."""
787
+ return SimilarityScorer.composite_similarity(text1, text2)