codedd-cli 0.1.1__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 (49) hide show
  1. codedd_cli/__init__.py +3 -0
  2. codedd_cli/__main__.py +19 -0
  3. codedd_cli/api/__init__.py +4 -0
  4. codedd_cli/api/client.py +120 -0
  5. codedd_cli/api/endpoints.py +44 -0
  6. codedd_cli/api/exceptions.py +24 -0
  7. codedd_cli/auditor/__init__.py +6 -0
  8. codedd_cli/auditor/architecture_analyzer.py +1251 -0
  9. codedd_cli/auditor/architecture_prompts.py +173 -0
  10. codedd_cli/auditor/complexity_analyzer.py +1739 -0
  11. codedd_cli/auditor/dependency_scanner.py +2485 -0
  12. codedd_cli/auditor/file_auditor.py +578 -0
  13. codedd_cli/auditor/git_stats_collector.py +417 -0
  14. codedd_cli/auditor/response_parser.py +484 -0
  15. codedd_cli/auditor/vulnerability_validator.py +323 -0
  16. codedd_cli/auth/__init__.py +4 -0
  17. codedd_cli/auth/session.py +40 -0
  18. codedd_cli/auth/token_manager.py +86 -0
  19. codedd_cli/cli.py +69 -0
  20. codedd_cli/commands/__init__.py +1 -0
  21. codedd_cli/commands/audit_cmd.py +1987 -0
  22. codedd_cli/commands/audits_cmd.py +276 -0
  23. codedd_cli/commands/auth_cmd.py +235 -0
  24. codedd_cli/commands/config_cmd.py +421 -0
  25. codedd_cli/commands/scope_cmd.py +1016 -0
  26. codedd_cli/config/__init__.py +4 -0
  27. codedd_cli/config/constants.py +22 -0
  28. codedd_cli/config/settings.py +389 -0
  29. codedd_cli/llm/__init__.py +1 -0
  30. codedd_cli/llm/key_manager.py +267 -0
  31. codedd_cli/models/__init__.py +5 -0
  32. codedd_cli/models/account.py +13 -0
  33. codedd_cli/models/audit.py +31 -0
  34. codedd_cli/models/local_directory.py +25 -0
  35. codedd_cli/scanner/__init__.py +18 -0
  36. codedd_cli/scanner/file_classifier.py +752 -0
  37. codedd_cli/scanner/file_walker.py +213 -0
  38. codedd_cli/scanner/line_counter.py +80 -0
  39. codedd_cli/utils/__init__.py +1 -0
  40. codedd_cli/utils/directory_validator.py +178 -0
  41. codedd_cli/utils/display.py +497 -0
  42. codedd_cli/utils/payload_inspector.py +178 -0
  43. codedd_cli/utils/security.py +14 -0
  44. codedd_cli/utils/validators.py +37 -0
  45. codedd_cli-0.1.1.dist-info/METADATA +306 -0
  46. codedd_cli-0.1.1.dist-info/RECORD +49 -0
  47. codedd_cli-0.1.1.dist-info/WHEEL +4 -0
  48. codedd_cli-0.1.1.dist-info/entry_points.txt +3 -0
  49. codedd_cli-0.1.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,417 @@
1
+ """
2
+ Collect git statistics from a local repository for CLI-driven audits.
3
+
4
+ Produces the same structure as the server's GitStatisticsCollector so that
5
+ POST /api/cli/audit/git-statistics/ can persist data for dashboards, velocity
6
+ aggregates, and developer expertise. Used when the repo is local (git directory)
7
+ and the server has no clone (cli:// root_path).
8
+ """
9
+
10
+ import os
11
+ import subprocess
12
+ from collections import defaultdict
13
+ from collections.abc import Callable
14
+ from datetime import datetime
15
+ from typing import Any
16
+
17
+
18
+ def _run_git(repo_path: str, cmd: list[str], timeout: int = 300) -> str | None:
19
+ """Run a git command in the repository; returns stdout or None on failure."""
20
+ try:
21
+ full_cmd = ["git", "-C", repo_path] + cmd
22
+ result = subprocess.run(
23
+ full_cmd,
24
+ capture_output=True,
25
+ text=True,
26
+ encoding="utf-8",
27
+ errors="replace",
28
+ timeout=timeout,
29
+ env={**os.environ, "GIT_TERMINAL_PROMPT": "0"},
30
+ )
31
+ if result.returncode != 0:
32
+ return None
33
+ return result.stdout.strip() or None
34
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
35
+ return None
36
+
37
+
38
+ def _default_branch(repo_path: str) -> str:
39
+ """Return default branch (main or master)."""
40
+ branch = _run_git(repo_path, ["rev-parse", "--abbrev-ref", "HEAD"])
41
+ if branch and branch != "HEAD":
42
+ return branch
43
+ for name in ("main", "master"):
44
+ if _run_git(repo_path, ["rev-parse", "--verify", name]):
45
+ return name
46
+ return "main"
47
+
48
+
49
+ def collect_git_statistics(
50
+ repo_path: str,
51
+ repository_name: str | None = None,
52
+ repository_url: str | None = None,
53
+ on_debug: Callable[[str], None] | None = None,
54
+ ) -> dict[str, Any] | None:
55
+ """
56
+ Collect git statistics from a local repository path.
57
+
58
+ Returns a dict compatible with the server's GitStatistics model:
59
+ commit_history, author_stats, merge_stats, branch_stats, meta_info,
60
+ time_based_stats, release_stats, code_churn_stats, collaboration_stats.
61
+ Missing or unsupported metrics are returned as empty dicts.
62
+
63
+ Returns None if repo_path is not a git repo or collection fails.
64
+ """
65
+ repo_path = os.path.abspath(repo_path)
66
+ git_dir = os.path.join(repo_path, ".git")
67
+ if not os.path.isdir(git_dir):
68
+ if on_debug:
69
+ on_debug(f"Not a git repository: {repo_path}")
70
+ return None
71
+
72
+ def dbg(msg: str) -> None:
73
+ if on_debug:
74
+ on_debug(msg)
75
+
76
+ default_branch = _default_branch(repo_path)
77
+ dbg(f"Collecting git stats for branch {default_branch}")
78
+
79
+ # Commit history with file changes (for velocity and developer expertise)
80
+ commit_history = _collect_commit_history(repo_path, default_branch, dbg)
81
+ if commit_history is None:
82
+ commit_history = {"total_commits": 0, "commits": []}
83
+
84
+ # Author statistics aligned with server shape:
85
+ # { "Name <email>": {commit_count, first_commit, last_commit, code_churn, file_patterns} }
86
+ author_stats = _collect_author_stats(repo_path, default_branch, commit_history, dbg)
87
+ time_based_stats = _derive_time_based_stats(commit_history)
88
+ code_churn_stats = _derive_code_churn_stats(commit_history)
89
+ collaboration_stats = _derive_collaboration_stats(commit_history)
90
+
91
+ return {
92
+ "commit_history": commit_history,
93
+ "author_stats": author_stats or {},
94
+ "merge_stats": {},
95
+ "branch_stats": _collect_branch_stats(repo_path, dbg) or {},
96
+ "meta_info": _collect_meta_info(repo_path, default_branch) or {},
97
+ "time_based_stats": time_based_stats,
98
+ "release_stats": {},
99
+ "code_churn_stats": code_churn_stats,
100
+ "collaboration_stats": collaboration_stats,
101
+ "repository_name": repository_name,
102
+ "repository_url": repository_url,
103
+ }
104
+
105
+
106
+ def _collect_commit_history(repo_path: str, default_branch: str, dbg: Callable[[str], None]) -> dict | None:
107
+ """Get commit history with file changes (same shape as server)."""
108
+ # One commit per block; format: HASH|||AUTHOR|||DATE|||SUBJECT|||PARENTS
109
+ fmt = "COMMIT_START\n%H|||%an <%ae>|||%aI|||%s|||%P"
110
+ out = _run_git(
111
+ repo_path,
112
+ ["log", default_branch, "--numstat", "--date=iso-strict", f"--pretty=format:{fmt}"],
113
+ timeout=120,
114
+ )
115
+ if not out:
116
+ return {"total_commits": 0, "commits": []}
117
+
118
+ blocks = [b for b in out.split("COMMIT_START\n") if b.strip()]
119
+ commits = []
120
+ for block in blocks:
121
+ lines = block.strip().split("\n")
122
+ if not lines:
123
+ continue
124
+ parts = lines[0].split("|||")
125
+ if len(parts) < 4:
126
+ continue
127
+ hash_val = parts[0]
128
+ author = parts[1]
129
+ commit_date = parts[2]
130
+ message = parts[3]
131
+ file_changes = []
132
+ for line in lines[1:]:
133
+ if not line.strip():
134
+ continue
135
+ try:
136
+ added, deleted, filename = line.split("\t", 2)
137
+ added = int(added) if added != "-" else 0
138
+ deleted = int(deleted) if deleted != "-" else 0
139
+ file_changes.append({"filename": filename, "added": added, "deleted": deleted})
140
+ except (ValueError, TypeError):
141
+ continue
142
+ commits.append(
143
+ {
144
+ "hash": hash_val,
145
+ "author": author,
146
+ "date": commit_date,
147
+ "message": message,
148
+ "is_merge": len(parts) > 4 and bool(parts[4].strip()),
149
+ "file_changes": file_changes,
150
+ }
151
+ )
152
+
153
+ return {"total_commits": len(commits), "commits": commits}
154
+
155
+
156
+ def _collect_author_stats(
157
+ repo_path: str,
158
+ default_branch: str,
159
+ commit_history: dict,
160
+ dbg: Callable[[str], None],
161
+ ) -> dict:
162
+ """
163
+ Build author statistics in the same shape as the server collector.
164
+
165
+ Returns:
166
+ {
167
+ "Author <email>": {
168
+ "commit_count": int,
169
+ "first_commit": str|None,
170
+ "last_commit": str|None,
171
+ "code_churn": {"additions": int, "deletions": int, "total_changes": int},
172
+ "file_patterns": {"total_files_modified": int, "unique_file_types": int}
173
+ }
174
+ }
175
+ """
176
+ _ = dbg
177
+ # Server commit_count uses shortlog --no-merges. Keep parity there.
178
+ shortlog_counts: dict[str, int] = {}
179
+ shortlog_out = _run_git(repo_path, ["shortlog", "-sne", "--no-merges", default_branch])
180
+ if shortlog_out:
181
+ for line in shortlog_out.split("\n"):
182
+ if not line.strip():
183
+ continue
184
+ try:
185
+ count, author = line.strip().split("\t", 1)
186
+ shortlog_counts[author] = int(count)
187
+ except (ValueError, TypeError):
188
+ continue
189
+
190
+ commits = commit_history.get("commits", []) if isinstance(commit_history, dict) else []
191
+ aggregate: dict[str, dict[str, Any]] = {}
192
+ for commit in commits:
193
+ author = str(commit.get("author", "")).strip()
194
+ if not author:
195
+ continue
196
+ author_bucket = aggregate.setdefault(
197
+ author,
198
+ {
199
+ "derived_commit_count": 0,
200
+ "first_commit_dt": None,
201
+ "last_commit_dt": None,
202
+ "additions": 0,
203
+ "deletions": 0,
204
+ "files": set(),
205
+ },
206
+ )
207
+
208
+ author_bucket["derived_commit_count"] += 1
209
+ commit_dt = _parse_commit_datetime(str(commit.get("date", "")))
210
+ if commit_dt:
211
+ first_dt = author_bucket["first_commit_dt"]
212
+ last_dt = author_bucket["last_commit_dt"]
213
+ author_bucket["first_commit_dt"] = commit_dt if first_dt is None else min(first_dt, commit_dt)
214
+ author_bucket["last_commit_dt"] = commit_dt if last_dt is None else max(last_dt, commit_dt)
215
+
216
+ for change in commit.get("file_changes", []) or []:
217
+ filename = str(change.get("filename", "")).strip()
218
+ if not filename:
219
+ continue
220
+ added = int(change.get("added", 0) or 0)
221
+ deleted = int(change.get("deleted", 0) or 0)
222
+ author_bucket["additions"] += added
223
+ author_bucket["deletions"] += deleted
224
+ author_bucket["files"].add(filename)
225
+
226
+ all_authors = set(shortlog_counts.keys()) | set(aggregate.keys())
227
+ author_stats: dict[str, Any] = {}
228
+ for author in all_authors:
229
+ author_data = aggregate.get(author, {})
230
+ files = author_data.get("files", set()) or set()
231
+ additions = int(author_data.get("additions", 0) or 0)
232
+ deletions = int(author_data.get("deletions", 0) or 0)
233
+ first_dt = author_data.get("first_commit_dt")
234
+ last_dt = author_data.get("last_commit_dt")
235
+ commit_count = shortlog_counts.get(author, int(author_data.get("derived_commit_count", 0) or 0))
236
+
237
+ author_stats[author] = {
238
+ "commit_count": int(commit_count),
239
+ "first_commit": first_dt.isoformat() if isinstance(first_dt, datetime) else None,
240
+ "last_commit": last_dt.isoformat() if isinstance(last_dt, datetime) else None,
241
+ "code_churn": {
242
+ "additions": additions,
243
+ "deletions": deletions,
244
+ "total_changes": additions + deletions,
245
+ },
246
+ "file_patterns": {
247
+ "total_files_modified": len(files),
248
+ "unique_file_types": len({f.rsplit(".", 1)[-1] for f in files if "." in f}),
249
+ },
250
+ }
251
+
252
+ return author_stats
253
+
254
+
255
+ def _collect_branch_stats(repo_path: str, dbg: Callable[[str], None]) -> dict:
256
+ """Basic branch list (local)."""
257
+ out = _run_git(repo_path, ["branch", "--format=%(refname:short)"])
258
+ if not out:
259
+ return {}
260
+ names = [n.strip() for n in out.split("\n") if n.strip()]
261
+ return {"total_branches": len(names), "branches": [{"name": n} for n in names]}
262
+
263
+
264
+ def _collect_meta_info(repo_path: str, default_branch: str) -> dict:
265
+ """Minimal repo meta."""
266
+ first = _run_git(repo_path, ["log", "--reverse", "--format=%aI", default_branch, "-1"])
267
+ latest = _run_git(repo_path, ["log", "--format=%aI", default_branch, "-1"])
268
+ return {
269
+ "first_commit_date": first,
270
+ "latest_commit_date": latest,
271
+ "default_branch": default_branch,
272
+ }
273
+
274
+
275
+ def _parse_commit_datetime(raw: str) -> datetime | None:
276
+ if not raw:
277
+ return None
278
+ try:
279
+ return datetime.fromisoformat(raw.replace("Z", "+00:00"))
280
+ except ValueError:
281
+ return None
282
+
283
+
284
+ def _derive_time_based_stats(commit_history: dict) -> dict:
285
+ commits = commit_history.get("commits", []) if isinstance(commit_history, dict) else []
286
+ if not commits:
287
+ return {}
288
+
289
+ commit_dates: list[datetime] = []
290
+ daily_commits_by_author: dict[str, dict[str, int]] = defaultdict(dict)
291
+ daily_totals: dict[str, int] = defaultdict(int)
292
+ weekly_totals: dict[str, int] = defaultdict(int)
293
+
294
+ for commit in commits:
295
+ when = _parse_commit_datetime(str(commit.get("date", "")))
296
+ if not when:
297
+ continue
298
+ commit_dates.append(when)
299
+ day_key = when.strftime("%Y-%m-%d")
300
+ week_key = when.strftime("%Y-W%W")
301
+ author = str(commit.get("author", "unknown")).strip() or "unknown"
302
+
303
+ daily_totals[day_key] += 1
304
+ weekly_totals[week_key] += 1
305
+ per_author = daily_commits_by_author[author]
306
+ per_author[day_key] = per_author.get(day_key, 0) + 1
307
+
308
+ if not commit_dates:
309
+ return {}
310
+
311
+ first_commit = min(commit_dates)
312
+ last_commit = max(commit_dates)
313
+ repo_days = max((last_commit - first_commit).days + 1, 1)
314
+
315
+ return {
316
+ "commit_frequency": {
317
+ "total_commits": len(commit_dates),
318
+ "days_active": repo_days,
319
+ "avg_commits_per_day": len(commit_dates) / repo_days,
320
+ "avg_commits_per_week": len(commit_dates) / max(repo_days / 7, 1),
321
+ "most_active_day": max(daily_totals.items(), key=lambda x: x[1]) if daily_totals else None,
322
+ "most_active_week": max(weekly_totals.items(), key=lambda x: x[1]) if weekly_totals else None,
323
+ },
324
+ "activity_periods": {
325
+ "first_commit_date": first_commit.isoformat(),
326
+ "last_commit_date": last_commit.isoformat(),
327
+ "repository_age_days": repo_days,
328
+ "daily_commit_counts": dict(daily_totals),
329
+ "weekly_commit_counts": dict(weekly_totals),
330
+ "daily_commits_by_author": {k: dict(v) for k, v in daily_commits_by_author.items()},
331
+ "total_daily_commits": dict(daily_totals),
332
+ },
333
+ }
334
+
335
+
336
+ def _derive_code_churn_stats(commit_history: dict) -> dict:
337
+ commits = commit_history.get("commits", []) if isinstance(commit_history, dict) else []
338
+ if not commits:
339
+ return {}
340
+
341
+ per_file: dict[str, dict[str, int]] = defaultdict(lambda: {"commits": 0, "added": 0, "deleted": 0})
342
+ total_added = 0
343
+ total_deleted = 0
344
+
345
+ for commit in commits:
346
+ for change in commit.get("file_changes", []) or []:
347
+ filename = str(change.get("filename", "")).strip()
348
+ if not filename:
349
+ continue
350
+ added = int(change.get("added", 0) or 0)
351
+ deleted = int(change.get("deleted", 0) or 0)
352
+ per_file[filename]["commits"] += 1
353
+ per_file[filename]["added"] += added
354
+ per_file[filename]["deleted"] += deleted
355
+ total_added += added
356
+ total_deleted += deleted
357
+
358
+ most_modified = sorted(
359
+ (
360
+ {
361
+ "file_path": fp,
362
+ "commits": vals["commits"],
363
+ "added": vals["added"],
364
+ "deleted": vals["deleted"],
365
+ "churn": vals["added"] + vals["deleted"],
366
+ }
367
+ for fp, vals in per_file.items()
368
+ ),
369
+ key=lambda row: (-row["commits"], -row["churn"]),
370
+ )[:25]
371
+
372
+ return {
373
+ "churn_summary": {
374
+ "total_lines_added": total_added,
375
+ "total_lines_deleted": total_deleted,
376
+ "total_line_changes": total_added + total_deleted,
377
+ "files_touched": len(per_file),
378
+ },
379
+ "hotspots": {
380
+ "most_modified_files": most_modified,
381
+ },
382
+ }
383
+
384
+
385
+ def _derive_collaboration_stats(commit_history: dict) -> dict:
386
+ commits = commit_history.get("commits", []) if isinstance(commit_history, dict) else []
387
+ if not commits:
388
+ return {}
389
+
390
+ authors_by_file: dict[str, set[str]] = defaultdict(set)
391
+ coauthor_pairs: dict[tuple[str, str], int] = defaultdict(int)
392
+
393
+ for commit in commits:
394
+ author = str(commit.get("author", "unknown")).strip() or "unknown"
395
+ changed_files = commit.get("file_changes", []) or []
396
+ touched = []
397
+ for change in changed_files:
398
+ filename = str(change.get("filename", "")).strip()
399
+ if filename:
400
+ authors_by_file[filename].add(author)
401
+ touched.append(filename)
402
+ if len(touched) > 1:
403
+ # Commit-level pairing heuristic for shared file edits.
404
+ unique_files = sorted(set(touched))
405
+ for i in range(len(unique_files) - 1):
406
+ pair = (unique_files[i], unique_files[i + 1])
407
+ coauthor_pairs[pair] += 1
408
+
409
+ collaborative_files = sum(1 for authors in authors_by_file.values() if len(authors) > 1)
410
+ return {
411
+ "files_with_multiple_authors": collaborative_files,
412
+ "collaboration_ratio": (collaborative_files / len(authors_by_file)) if authors_by_file else 0,
413
+ "top_collaboration_pairs": [
414
+ {"file_pair": list(pair), "shared_commits": count}
415
+ for pair, count in sorted(coauthor_pairs.items(), key=lambda x: -x[1])[:20]
416
+ ],
417
+ }