codedd-cli 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.
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 +118 -0
  5. codedd_cli/api/endpoints.py +44 -0
  6. codedd_cli/api/exceptions.py +25 -0
  7. codedd_cli/auditor/__init__.py +6 -0
  8. codedd_cli/auditor/architecture_analyzer.py +1241 -0
  9. codedd_cli/auditor/architecture_prompts.py +171 -0
  10. codedd_cli/auditor/complexity_analyzer.py +942 -0
  11. codedd_cli/auditor/dependency_scanner.py +2478 -0
  12. codedd_cli/auditor/file_auditor.py +572 -0
  13. codedd_cli/auditor/git_stats_collector.py +332 -0
  14. codedd_cli/auditor/response_parser.py +487 -0
  15. codedd_cli/auditor/vulnerability_validator.py +324 -0
  16. codedd_cli/auth/__init__.py +4 -0
  17. codedd_cli/auth/session.py +41 -0
  18. codedd_cli/auth/token_manager.py +87 -0
  19. codedd_cli/cli.py +69 -0
  20. codedd_cli/commands/__init__.py +1 -0
  21. codedd_cli/commands/audit_cmd.py +1877 -0
  22. codedd_cli/commands/audits_cmd.py +273 -0
  23. codedd_cli/commands/auth_cmd.py +230 -0
  24. codedd_cli/commands/config_cmd.py +454 -0
  25. codedd_cli/commands/scope_cmd.py +967 -0
  26. codedd_cli/config/__init__.py +4 -0
  27. codedd_cli/config/constants.py +22 -0
  28. codedd_cli/config/settings.py +369 -0
  29. codedd_cli/llm/__init__.py +1 -0
  30. codedd_cli/llm/key_manager.py +271 -0
  31. codedd_cli/models/__init__.py +5 -0
  32. codedd_cli/models/account.py +13 -0
  33. codedd_cli/models/audit.py +32 -0
  34. codedd_cli/models/local_directory.py +26 -0
  35. codedd_cli/scanner/__init__.py +18 -0
  36. codedd_cli/scanner/file_classifier.py +247 -0
  37. codedd_cli/scanner/file_walker.py +207 -0
  38. codedd_cli/scanner/line_counter.py +75 -0
  39. codedd_cli/utils/__init__.py +1 -0
  40. codedd_cli/utils/directory_validator.py +179 -0
  41. codedd_cli/utils/display.py +493 -0
  42. codedd_cli/utils/payload_inspector.py +180 -0
  43. codedd_cli/utils/security.py +14 -0
  44. codedd_cli/utils/validators.py +37 -0
  45. codedd_cli-0.1.0.dist-info/METADATA +276 -0
  46. codedd_cli-0.1.0.dist-info/RECORD +49 -0
  47. codedd_cli-0.1.0.dist-info/WHEEL +4 -0
  48. codedd_cli-0.1.0.dist-info/entry_points.txt +3 -0
  49. codedd_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,179 @@
1
+ """
2
+ Local directory validation for audit scope.
3
+
4
+ Validates that a user-provided path:
5
+ 1. Exists on the filesystem
6
+ 2. Is a directory (not a file)
7
+ 3. Is readable / accessible by the current user
8
+ 4. Is the root of a Git repository (contains ``.git/``)
9
+ 5. Has at least one commit (valid HEAD)
10
+
11
+ Uses the **default branch** (main or master) for the audit, not the current
12
+ checkout. Fetches the default branch from origin when a remote exists so
13
+ the scope is tied to the canonical branch state.
14
+ """
15
+
16
+ import os
17
+ import subprocess
18
+ from pathlib import Path
19
+ from typing import Tuple
20
+
21
+ from codedd_cli.models.local_directory import LocalDirectory
22
+
23
+ # Default branch names to try, in order of preference.
24
+ DEFAULT_BRANCH_ORDER = ("main", "master")
25
+
26
+
27
+ def validate_directory(raw_path: str) -> LocalDirectory:
28
+ """
29
+ Run all validation checks on *raw_path* and return a ``LocalDirectory``.
30
+
31
+ The returned object's ``is_valid`` flag indicates whether all checks
32
+ passed. When ``is_valid`` is False, the ``error`` field explains why.
33
+
34
+ Args:
35
+ raw_path: A path string as entered by the user. May be relative;
36
+ it is resolved to an absolute path internally.
37
+
38
+ Returns:
39
+ A populated ``LocalDirectory`` instance.
40
+ """
41
+ # Normalise and resolve to an absolute path
42
+ try:
43
+ resolved = Path(raw_path).expanduser().resolve()
44
+ abs_path = str(resolved)
45
+ except (OSError, ValueError) as exc:
46
+ return LocalDirectory(
47
+ path=raw_path,
48
+ is_valid=False,
49
+ error=f"Invalid path: {exc}",
50
+ )
51
+
52
+ # 1. Existence check
53
+ if not resolved.exists():
54
+ return LocalDirectory(
55
+ path=abs_path,
56
+ is_valid=False,
57
+ error="Directory does not exist",
58
+ )
59
+
60
+ # 2. Must be a directory (not a file or symlink to file)
61
+ if not resolved.is_dir():
62
+ return LocalDirectory(
63
+ path=abs_path,
64
+ is_valid=False,
65
+ error="Path is not a directory",
66
+ )
67
+
68
+ # 3. Accessibility — try listing the directory contents
69
+ if not os.access(abs_path, os.R_OK):
70
+ return LocalDirectory(
71
+ path=abs_path,
72
+ is_valid=False,
73
+ error="Directory is not readable (permission denied)",
74
+ )
75
+
76
+ # 4. Must be a Git repository root
77
+ git_dir = resolved / ".git"
78
+ if not git_dir.exists():
79
+ return LocalDirectory(
80
+ path=abs_path,
81
+ repo_name=resolved.name,
82
+ is_valid=False,
83
+ error="Not a Git repository (no .git directory found)",
84
+ )
85
+
86
+ # 5. Use default branch (main/master) and its commit for the audit
87
+ branch, commit_hash, git_error = _default_branch_metadata(abs_path)
88
+ if git_error:
89
+ return LocalDirectory(
90
+ path=abs_path,
91
+ repo_name=resolved.name,
92
+ is_valid=False,
93
+ error=git_error,
94
+ )
95
+
96
+ return LocalDirectory(
97
+ path=abs_path,
98
+ repo_name=resolved.name,
99
+ branch=branch,
100
+ commit_hash=commit_hash,
101
+ is_valid=True,
102
+ error="",
103
+ )
104
+
105
+
106
+ def _run_git(repo_path: str, args: list[str], timeout: int = 15) -> Tuple[int, str, str]:
107
+ """Run a git command; returns (returncode, stdout, stderr)."""
108
+ try:
109
+ result = subprocess.run(
110
+ ["git"] + args,
111
+ cwd=repo_path,
112
+ capture_output=True,
113
+ text=True,
114
+ timeout=timeout,
115
+ )
116
+ return result.returncode, result.stdout.strip(), result.stderr.strip()
117
+ except FileNotFoundError:
118
+ return -1, "", "Git is not installed or not in PATH"
119
+ except subprocess.TimeoutExpired:
120
+ return -1, "", "Git command timed out"
121
+ except Exception as exc:
122
+ return -1, "", str(exc)
123
+
124
+
125
+ def _get_default_branch_name(repo_path: str) -> str:
126
+ """
127
+ Determine the default branch: origin/HEAD, then local main, then local master.
128
+
129
+ Returns the branch name (e.g. "main") or empty string if none found.
130
+ """
131
+ # Prefer remote default (e.g. origin/HEAD -> origin/main)
132
+ code, out, _ = _run_git(repo_path, ["rev-parse", "--abbrev-ref", "origin/HEAD"], timeout=5)
133
+ if code == 0 and out and out != "origin/HEAD":
134
+ # "origin/HEAD" -> "origin/main" -> we want "main"
135
+ if out.startswith("origin/"):
136
+ return out[7:]
137
+ return out
138
+
139
+ # Fallback: which of main/master exists (local or remote)
140
+ for branch in DEFAULT_BRANCH_ORDER:
141
+ code, _, _ = _run_git(repo_path, ["rev-parse", "--verify", branch], timeout=5)
142
+ if code == 0:
143
+ return branch
144
+ code, _, _ = _run_git(repo_path, ["rev-parse", "--verify", f"origin/{branch}"], timeout=5)
145
+ if code == 0:
146
+ return branch
147
+ return ""
148
+
149
+
150
+ def _default_branch_metadata(repo_path: str) -> Tuple[str, str, str]:
151
+ """
152
+ Resolve the default branch (main/master), fetch it from origin if possible,
153
+ and return its name and short commit hash for use in the audit scope.
154
+
155
+ Args:
156
+ repo_path: Absolute path to the repository root.
157
+
158
+ Returns:
159
+ Tuple of (branch_name, short_commit_hash, error_message).
160
+ On success ``error_message`` is empty.
161
+ """
162
+ branch_name = _get_default_branch_name(repo_path)
163
+ if not branch_name:
164
+ # Last resort: use current HEAD so we don't fail local-only repos
165
+ code, head_branch, err = _run_git(repo_path, ["rev-parse", "--abbrev-ref", "HEAD"])
166
+ if code != 0:
167
+ return "", "", f"Git error: no main/master branch and {err or 'unable to read HEAD'}"
168
+ branch_name = head_branch
169
+
170
+ # Fetch the default branch from origin (best-effort; ignore failures for offline/local repos)
171
+ _run_git(repo_path, ["fetch", "origin", branch_name], timeout=30)
172
+
173
+ # Resolve commit: prefer origin/<branch>, then local <branch>
174
+ for ref in (f"origin/{branch_name}", branch_name):
175
+ code, commit, err = _run_git(repo_path, ["rev-parse", "--short", ref])
176
+ if code == 0 and commit:
177
+ return branch_name, commit, ""
178
+
179
+ return branch_name, "", f"Git error: could not resolve commit for branch '{branch_name}'"
@@ -0,0 +1,493 @@
1
+ """
2
+ Rich console output helpers for consistent terminal formatting.
3
+
4
+ On Windows, Unicode symbols (e.g. checkmark) often render as "?" in PowerShell,
5
+ so we use ASCII fallbacks there for reliable display.
6
+ """
7
+
8
+ import sys
9
+ from datetime import datetime
10
+ from typing import Optional
11
+
12
+ from rich.console import Console
13
+ from rich.panel import Panel
14
+ from rich.prompt import IntPrompt
15
+ from rich.table import Table
16
+
17
+ from codedd_cli import __version__
18
+ from codedd_cli.models.audit import Audit, GroupAudit
19
+ from codedd_cli.models.local_directory import LocalDirectory
20
+
21
+ # Use ASCII symbols on Windows so PowerShell/legacy consoles don't show "?"
22
+ if sys.platform == "win32":
23
+ SYMBOL_OK = "[+]"
24
+ SYMBOL_FAIL = "[x]"
25
+ SYMBOL_WARN = "[!]"
26
+ SYMBOL_INFO = "[i]"
27
+ SYMBOL_DIR = ">"
28
+ else:
29
+ SYMBOL_OK = "\u2713" # ✓
30
+ SYMBOL_FAIL = "\u2717" # ✗
31
+ SYMBOL_WARN = "!"
32
+ SYMBOL_INFO = "\u2139" # ℹ
33
+ SYMBOL_DIR = "\u25b8" # ▸ (right-pointing triangle, folder-like)
34
+
35
+ console = Console()
36
+
37
+ # Brand teal colour used in the CodeDD logo (for Rich inline styles).
38
+ BRAND_TEAL = "#2BBBC0"
39
+
40
+ # Style for audit progress debug logs (light grey so they don't compete with main progress).
41
+ STYLE_DEBUG_LOG = "grey74"
42
+
43
+
44
+ def print_banner() -> None:
45
+ """
46
+ Print the CodeDD CLI banner with an ASCII logo and welcome message.
47
+
48
+ The logo mirrors the CodeDD brand mark: a teal C-bracket with three
49
+ coloured dots (red, amber, green) inside the opening, followed by the
50
+ product name in spaced letters.
51
+
52
+ Intended for top-level ``codedd --help`` output only.
53
+ """
54
+ t = BRAND_TEAL
55
+ logo = (
56
+ f" [bold {t}] ██████╗[/]\n"
57
+ f" [bold {t}] ██╔════╝[/]\n"
58
+ f" [bold {t}] ██║[/] [bold #E84D4D]●[/] [bold #E8A838]●[/] [bold #4DB84D]●[/]" + " [bold white]C o d e D D[/]" + f" | [dim]v{__version__}[/dim] [bold]Welcome to CodeDD CLI![/bold] \n"
59
+ f" [bold {t}] ██╚════╗[/]\n"
60
+ f" [bold {t}] ██████╝[/]"
61
+ )
62
+ console.print()
63
+ console.print(logo, highlight=False)
64
+ console.print()
65
+
66
+ def print_success(message: str) -> None:
67
+ console.print(f"[bold green]{SYMBOL_OK}[/bold green] {message}")
68
+
69
+
70
+ def print_error(message: str) -> None:
71
+ console.print(f"[bold red]{SYMBOL_FAIL}[/bold red] {message}")
72
+
73
+
74
+ def print_warning(message: str) -> None:
75
+ console.print(f"[bold yellow]{SYMBOL_WARN}[/bold yellow] {message}")
76
+
77
+
78
+ def print_info(message: str) -> None:
79
+ console.print(f"[dim]{SYMBOL_INFO}[/dim] {message}")
80
+
81
+
82
+ def _format_date(iso_str: Optional[str]) -> str:
83
+ """Convert an ISO datetime string to a human-friendly short date."""
84
+ if not iso_str:
85
+ return "-" if sys.platform == "win32" else "\u2014" # em dash
86
+ try:
87
+ dt = datetime.fromisoformat(iso_str)
88
+ return dt.strftime("%Y-%m-%d %H:%M")
89
+ except (ValueError, TypeError):
90
+ return iso_str[:16] if len(iso_str) >= 16 else iso_str
91
+
92
+
93
+ def _status_color(status: str) -> str:
94
+ """Map audit status strings to Rich color markup."""
95
+ lower = status.lower() if status else ""
96
+ if "completed" in lower or "complete" in lower:
97
+ return f"[green]{status}[/green]"
98
+ if "progress" in lower or "running" in lower or "processing" in lower:
99
+ return f"[yellow]{status}[/yellow]"
100
+ if "error" in lower or "failed" in lower:
101
+ return f"[red]{status}[/red]"
102
+ if "cancelled" in lower or "canceled" in lower:
103
+ return f"[dim]{status}[/dim]"
104
+ return status
105
+
106
+
107
+ def _format_number(n: int) -> str:
108
+ """Format an integer with thousand separators."""
109
+ return f"{n:,}"
110
+
111
+
112
+ def _type_cell(audit_type: str) -> str:
113
+ """
114
+ Format audit type for the table. On Windows (e.g. PowerShell), Rich
115
+ markup in table cells can render poorly, so use plain text there.
116
+ """
117
+ if sys.platform == "win32":
118
+ return audit_type # "group" or "single", no markup
119
+ if audit_type == "group":
120
+ return "[magenta]group[/magenta]"
121
+ return "[cyan]single[/cyan]"
122
+
123
+
124
+ def render_audits_table(
125
+ single_audits: list[Audit],
126
+ group_audits: list[GroupAudit],
127
+ total: int,
128
+ active_audit_uuid: Optional[str] = None,
129
+ ) -> None:
130
+ """
131
+ Render a Rich table combining single and group audits.
132
+
133
+ Each row shows: index, type, name, status, repos/files, LoC, date.
134
+ If active_audit_uuid is set, the matching row is marked as the current selection.
135
+ """
136
+ table = Table(
137
+ title=f"Your Audits ({total} total)",
138
+ show_lines=False,
139
+ padding=(0, 1),
140
+ expand=True,
141
+ )
142
+ table.add_column("#", style="dim", width=4, justify="right")
143
+ table.add_column("Type", width=16) # e.g. "group -> active"
144
+ table.add_column("Name", min_width=20, max_width=40, no_wrap=True)
145
+ table.add_column("Status", min_width=12)
146
+ table.add_column("Files", justify="right", width=8)
147
+ table.add_column("LoC", justify="right", width=10)
148
+ table.add_column("Date", width=18)
149
+ table.add_column("UUID", style="dim", width=12)
150
+
151
+ idx = 1
152
+
153
+ def _type_cell_with_active(audit_type: str, uuid: str) -> str:
154
+ """Type cell; show 'type -> active' when this audit is the active one."""
155
+ base = _type_cell(audit_type)
156
+ if active_audit_uuid and uuid == active_audit_uuid:
157
+ suffix = " -> active"
158
+ if sys.platform == "win32":
159
+ return audit_type + suffix
160
+ return base + "[bold green]" + suffix + "[/bold green]"
161
+ return base
162
+
163
+ # Group audits first
164
+ for ga in group_audits:
165
+ table.add_row(
166
+ str(idx),
167
+ _type_cell_with_active("group", ga.audit_uuid),
168
+ ga.audit_name or "—",
169
+ _status_color(ga.audit_status),
170
+ _format_number(ga.number_files),
171
+ _format_number(ga.lines_of_code),
172
+ _format_date(ga.ai_synthesis),
173
+ ga.audit_uuid[:8] + "…",
174
+ )
175
+ idx += 1
176
+
177
+ # Single audits
178
+ for a in single_audits:
179
+ table.add_row(
180
+ str(idx),
181
+ _type_cell_with_active("single", a.audit_uuid),
182
+ a.audit_name or "—",
183
+ _status_color(a.audit_status),
184
+ _format_number(a.number_files),
185
+ _format_number(a.lines_of_code),
186
+ _format_date(a.ai_synthesis),
187
+ a.audit_uuid[:8] + "…",
188
+ )
189
+ idx += 1
190
+
191
+ console.print(table)
192
+
193
+
194
+ def render_audit_detail(audit) -> None:
195
+ """Render a detailed panel for a single audit or group audit."""
196
+ if isinstance(audit, GroupAudit):
197
+ content = (
198
+ f"[bold]Name:[/bold] {audit.audit_name}\n"
199
+ f"[bold]Type:[/bold] Group Audit\n"
200
+ f"[bold]Status:[/bold] {_status_color(audit.audit_status)}\n"
201
+ f"[bold]Sub-audits:[/bold] {audit.number_of_sub_audits}\n"
202
+ f"[bold]Files:[/bold] {_format_number(audit.number_files)}\n"
203
+ f"[bold]LoC:[/bold] {_format_number(audit.lines_of_code)}\n"
204
+ f"[bold]Date:[/bold] {_format_date(audit.ai_synthesis)}\n"
205
+ f"[bold]UUID:[/bold] {audit.audit_uuid}"
206
+ )
207
+ elif isinstance(audit, Audit):
208
+ content = (
209
+ f"[bold]Name:[/bold] {audit.audit_name}\n"
210
+ f"[bold]Type:[/bold] Single Audit\n"
211
+ f"[bold]Status:[/bold] {_status_color(audit.audit_status)}\n"
212
+ f"[bold]Repo:[/bold] {audit.repo_url or '—'}\n"
213
+ f"[bold]Files:[/bold] {_format_number(audit.number_files)}\n"
214
+ f"[bold]LoC:[/bold] {_format_number(audit.lines_of_code)}\n"
215
+ f"[bold]Date:[/bold] {_format_date(audit.ai_synthesis)}\n"
216
+ f"[bold]UUID:[/bold] {audit.audit_uuid}"
217
+ )
218
+ else:
219
+ content = str(audit)
220
+
221
+ console.print(Panel(content, title="Audit Details", expand=False))
222
+
223
+
224
+ def render_scope_table(directories: list[dict[str, str]], audit_name: str = "") -> None:
225
+ """
226
+ Render a Rich table showing the directories currently in the audit scope.
227
+
228
+ Args:
229
+ directories: List of dicts from ``ConfigManager.scope_directories``.
230
+ audit_name: Active audit name for the table title.
231
+ """
232
+ title = f"Audit Scope — {audit_name}" if audit_name else "Audit Scope"
233
+ table = Table(title=title, show_lines=False, padding=(0, 1), expand=True)
234
+ table.add_column("#", style="dim", width=4, justify="right")
235
+ table.add_column("Repository", min_width=16, max_width=30, no_wrap=True)
236
+ table.add_column("Branch", width=18)
237
+ table.add_column("Commit", width=10)
238
+ table.add_column("Path", min_width=30)
239
+
240
+ for idx, entry in enumerate(directories, start=1):
241
+ table.add_row(
242
+ str(idx),
243
+ f"[bold]{entry.get('repo_name', '—')}[/bold]",
244
+ entry.get("branch", "—"),
245
+ f"[dim]{entry.get('commit_hash', '—')}[/dim]",
246
+ entry.get("path", "—"),
247
+ )
248
+
249
+ console.print(table)
250
+
251
+ if not directories:
252
+ console.print("[dim] No directories added yet. Use [bold cyan]codedd scope add <path>[/bold cyan] to add one.[/dim]")
253
+
254
+
255
+ def render_scope_table_with_sync(directories: list[dict[str, str]], audit_name: str = "") -> None:
256
+ """
257
+ Render the scope table with additional columns showing sync state
258
+ (confirmed, needs_reconfirm, last_sync).
259
+ """
260
+ title = f"Audit Scope — {audit_name}" if audit_name else "Audit Scope"
261
+ table = Table(title=title, show_lines=False, padding=(0, 1), expand=True)
262
+ table.add_column("#", style="dim", width=4, justify="right")
263
+ table.add_column("Repository", min_width=16, max_width=25, no_wrap=True)
264
+ table.add_column("Branch", width=14)
265
+ table.add_column("Confirmed", width=10)
266
+ table.add_column("Sync", width=16)
267
+ table.add_column("Path", min_width=25)
268
+
269
+ for idx, entry in enumerate(directories, start=1):
270
+ confirmed = entry.get("confirmed", False)
271
+ needs_reconfirm = entry.get("needs_reconfirm", False)
272
+ last_sync = entry.get("last_sync", "")
273
+
274
+ if needs_reconfirm:
275
+ status_str = "[bold yellow]dirty[/bold yellow]"
276
+ elif confirmed:
277
+ status_str = "[bold green]yes[/bold green]"
278
+ else:
279
+ status_str = "[dim]no[/dim]"
280
+
281
+ sync_str = last_sync[:16] if last_sync else "[dim]never[/dim]"
282
+
283
+ table.add_row(
284
+ str(idx),
285
+ f"[bold]{entry.get('repo_name', '-')}[/bold]",
286
+ entry.get("branch", "-"),
287
+ status_str,
288
+ sync_str,
289
+ entry.get("path", "-"),
290
+ )
291
+
292
+ console.print(table)
293
+
294
+ if not directories:
295
+ console.print("[dim] No directories added yet. Use [bold cyan]codedd scope add <path>[/bold cyan] to add one.[/dim]")
296
+
297
+
298
+ # Maximum number of file rows to show in the diff table; rest are summarized.
299
+ DIFF_TABLE_MAX_ROWS = 10
300
+
301
+
302
+ def render_diff_table(
303
+ repo_name: str,
304
+ diff: dict,
305
+ remote_file_count: Optional[int] = None,
306
+ max_rows: int = DIFF_TABLE_MAX_ROWS,
307
+ ) -> None:
308
+ """
309
+ Render a Rich table showing file-level differences between the
310
+ local scan and the remote (CodeDD) state.
311
+
312
+ When remote has 0 files (audit removed in CodeDD), only a short message
313
+ is shown and no table is rendered. Otherwise a one-line summary, up to
314
+ max_rows table rows, and a "+ N more files" footer are shown.
315
+
316
+ Args:
317
+ repo_name: Human-readable repository name.
318
+ diff: Dict with keys ``added``, ``removed``, ``changed``.
319
+ - added: list of ``{"path", "lines_of_code"}``
320
+ - removed: list of ``{"path", "lines_of_code"}``
321
+ - changed: list of ``{"path", "old_loc", "new_loc"}``
322
+ remote_file_count: Number of files on remote. If 0, show "audit removed" message only.
323
+ max_rows: Maximum table rows to show; remaining count shown as "+ N more files".
324
+ """
325
+ added = diff.get("added", [])
326
+ removed = diff.get("removed", [])
327
+ changed = diff.get("changed", [])
328
+
329
+ if not added and not removed and not changed:
330
+ return
331
+
332
+ # Remote has no files: audit was removed or no files selected in CodeDD.
333
+ if remote_file_count is not None and remote_file_count == 0:
334
+ console.print(
335
+ f" [dim]{SYMBOL_INFO}[/dim] [bold]{repo_name}[/bold]: "
336
+ "Audit has been removed from CodeDD or no files are selected for audit."
337
+ )
338
+ return
339
+
340
+ dash = "-" if sys.platform == "win32" else "\u2014"
341
+
342
+ # Build unified row list: (status_label, path, old_loc, new_loc)
343
+ rows: list[tuple[str, str, str, str]] = []
344
+ for f in added:
345
+ rows.append(("[green]+ Added[/green]", f["path"], dash, str(f.get("lines_of_code", 0))))
346
+ for f in removed:
347
+ rows.append(("[red]- Removed[/red]", f["path"], str(f.get("lines_of_code", 0)), dash))
348
+ for f in changed:
349
+ rows.append(
350
+ (
351
+ "[yellow]~ Changed[/yellow]",
352
+ f["path"],
353
+ str(f.get("old_loc", 0)),
354
+ str(f.get("new_loc", 0)),
355
+ )
356
+ )
357
+
358
+ total = len(rows)
359
+ n_added, n_removed, n_changed = len(added), len(removed), len(changed)
360
+ loc_old = sum(f.get("lines_of_code", 0) for f in removed) + sum(f.get("old_loc", 0) for f in changed)
361
+ loc_new = sum(f.get("lines_of_code", 0) for f in added) + sum(f.get("new_loc", 0) for f in changed)
362
+ loc_delta_added = max(0, loc_new - loc_old)
363
+ loc_delta_removed = max(0, loc_old - loc_new)
364
+
365
+ # Summary line: file counts and total LoC added/removed
366
+ summary_parts = [f"{n_added} added", f"{n_removed} removed", f"{n_changed} changed"]
367
+ summary_parts.append(f"+{loc_delta_added:,} LoC added, -{loc_delta_removed:,} LoC removed")
368
+ console.print(f" [bold]{repo_name}[/bold]: {', '.join(summary_parts)}")
369
+
370
+ # Table: at most max_rows
371
+ shown = rows[:max_rows]
372
+ table = Table(
373
+ title=f"Diff: {repo_name}",
374
+ show_lines=False,
375
+ padding=(0, 1),
376
+ expand=True,
377
+ )
378
+ table.add_column("Status", width=10)
379
+ table.add_column("File", min_width=30)
380
+ table.add_column("Old LoC", justify="right", width=10)
381
+ table.add_column("New LoC", justify="right", width=10)
382
+
383
+ for status_label, path, old_loc, new_loc in shown:
384
+ table.add_row(status_label, path, old_loc, new_loc)
385
+
386
+ console.print(table)
387
+
388
+ if total > max_rows:
389
+ console.print(f" [dim]+ {total - max_rows} more file{'s' if total - max_rows != 1 else ''} have changed[/dim]")
390
+
391
+
392
+ def prompt_deleted_audits_action(repo_names: list[str]) -> int:
393
+ """
394
+ Display a panel listing audits that have been deleted on CodeDD and prompt
395
+ the user to choose an action.
396
+
397
+ When more than one audit is deleted, options 3 and 4 are shown to make
398
+ "accept all" and "restore all" explicit.
399
+
400
+ Returns:
401
+ 1 = Confirm remote audit deletion (remove from local scope).
402
+ 2 = Restore local audit scope and sync to CodeDD (re-register).
403
+ 3 = Accept all deletions locally (same as 1; only when len(repo_names) > 1).
404
+ 4 = Restore all audits to CodeDD (same as 2; only when len(repo_names) > 1).
405
+ """
406
+ # Local directories (repos) with icon and color
407
+ repo_list = "\n".join(
408
+ f" [cyan]{SYMBOL_DIR}[/cyan] [bold cyan]{name}[/bold cyan]"
409
+ for name in sorted(repo_names)
410
+ )
411
+ multiple = len(repo_names) > 1
412
+
413
+ # Options 1/3 = accept deletion (yellow); 2/4 = restore (green); descriptions dim
414
+ options_text = (
415
+ " [bold yellow]1[/bold yellow] Confirm remote audit deletion\n"
416
+ " [dim]Remove these from your local scope so it matches CodeDD.[/dim]\n\n"
417
+ " [bold green]2[/bold green] Restore local audit scope and sync to CodeDD\n"
418
+ " [dim]Re-register the scope to recreate these audits on CodeDD.[/dim]"
419
+ )
420
+ choices_list = ["1", "2"]
421
+
422
+ if multiple:
423
+ options_text += (
424
+ "\n\n"
425
+ " [bold yellow]3[/bold yellow] Accept all deletions locally\n"
426
+ " [dim]Same as (1): remove all listed audits from local scope.[/dim]\n\n"
427
+ " [bold green]4[/bold green] Restore all audits to CodeDD\n"
428
+ " [dim]Same as (2): re-register scope to recreate all listed audits on CodeDD.[/dim]"
429
+ )
430
+ choices_list = ["1", "2", "3", "4"]
431
+
432
+ content = (
433
+ "[bold]The following audit(s) no longer exist on CodeDD (deleted or cleared):[/bold]\n\n"
434
+ f"{repo_list}\n\n"
435
+ "[bold]What do you want to do?[/bold]\n\n"
436
+ f"{options_text}"
437
+ )
438
+ console.print()
439
+ console.print(Panel(content, title="[bold yellow]Audits deleted on CodeDD[/bold yellow]", border_style="yellow", padding=(1, 2)))
440
+ console.print()
441
+ choice = IntPrompt.ask("Enter your choice", choices=choices_list, default=1)
442
+ return choice
443
+
444
+
445
+ # Base URL for the CodeDD cloud application (no trailing slash).
446
+ CODEDD_BASE_URL = "https://www.codedd.ai"
447
+
448
+
449
+ def print_audit_scope_cloud_info(audit_uuid: str, audit_type: str) -> None:
450
+ """
451
+ Display an info message after scope confirm/re-confirm with the URL to manage
452
+ the audit scope in the CodeDD cloud application.
453
+
454
+ Args:
455
+ audit_uuid: The active audit UUID (group or single).
456
+ audit_type: Either "group" or "single" to build the correct path.
457
+ """
458
+ if audit_type == "group":
459
+ path = "audit-invitation"
460
+ else:
461
+ path = "audit-scope-selection"
462
+ url = f"{CODEDD_BASE_URL}/{audit_uuid}/{path}"
463
+
464
+ content = (
465
+ "You can manage this audit in the CodeDD cloud application:\n\n"
466
+ " • Adapt which files are selected for the audit\n"
467
+ " • Delete scoped directories from the audit\n"
468
+ " • Pay for the audit and start the analysis\n\n"
469
+ f"[bold cyan]{url}[/bold cyan]"
470
+ )
471
+ console.print()
472
+ console.print(Panel(content, title="[bold]Audit scope at CodeDD[/bold]", border_style="blue", padding=(1, 2)))
473
+ console.print()
474
+
475
+
476
+ def render_validation_result(dir_info: LocalDirectory) -> None:
477
+ """
478
+ Display a single directory validation result (success or error).
479
+
480
+ Args:
481
+ dir_info: A validated ``LocalDirectory`` instance.
482
+ """
483
+ if dir_info.is_valid:
484
+ console.print(
485
+ f" [bold green]{SYMBOL_OK}[/bold green] [bold]{dir_info.repo_name}[/bold] "
486
+ f"[dim]({dir_info.branch} @ {dir_info.commit_hash})[/dim] "
487
+ f"{dir_info.path}"
488
+ )
489
+ else:
490
+ console.print(
491
+ f" [bold red]{SYMBOL_FAIL}[/bold red] {dir_info.path}\n"
492
+ f" [red]{dir_info.error}[/red]"
493
+ )