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,967 @@
1
+ """
2
+ ``codedd scope`` sub-commands: add, remove, list, clear, status, confirm, sync.
3
+
4
+ Manages the local directories that will be included in the active audit.
5
+
6
+ Workflow:
7
+ 1. ``codedd audits select`` – pick a group or single audit
8
+ 2. ``codedd scope add /path/to/repo`` – add directories (validates git)
9
+ 3. ``codedd scope list`` – review the scope
10
+ 4. ``codedd scope remove 2`` – drop a directory by number
11
+ 5. ``codedd scope confirm`` – scan, preview, and register with CodeDD
12
+ 6. ``codedd scope sync`` – compare local vs. remote, detect changes
13
+
14
+ For **group audits** the user may add 1-n directories.
15
+ For **single audits** exactly 1 directory is allowed.
16
+ """
17
+
18
+ from datetime import datetime, timezone
19
+ from pathlib import Path
20
+ from typing import Optional
21
+
22
+ import typer
23
+ from rich.console import Console
24
+ from rich.progress import Progress, SpinnerColumn, TextColumn
25
+ from rich.prompt import Confirm, IntPrompt
26
+
27
+ from codedd_cli.api.client import CodeDDClient
28
+ from codedd_cli.api.endpoints import Endpoints
29
+ from codedd_cli.auth.session import require_auth
30
+ from codedd_cli.config.settings import ConfigManager
31
+ from codedd_cli.scanner.file_walker import scan_repository
32
+ from codedd_cli.utils.directory_validator import validate_directory
33
+ from codedd_cli.utils.display import (
34
+ print_audit_scope_cloud_info,
35
+ print_error,
36
+ print_info,
37
+ print_success,
38
+ print_warning,
39
+ prompt_deleted_audits_action,
40
+ render_audits_table,
41
+ render_diff_table,
42
+ render_scope_table,
43
+ render_scope_table_with_sync,
44
+ render_validation_result,
45
+ SYMBOL_FAIL,
46
+ SYMBOL_OK,
47
+ )
48
+ from codedd_cli.commands.audits_cmd import _fetch_audits
49
+ from codedd_cli.utils.payload_inspector import review_payload, review_request
50
+
51
+ console = Console()
52
+ scope_app = typer.Typer(no_args_is_help=True)
53
+
54
+
55
+ def _require_active_audit(cfg: ConfigManager) -> None:
56
+ """Abort if no audit has been selected."""
57
+ if not cfg.active_audit_uuid:
58
+ print_error(
59
+ "No active audit selected. "
60
+ "Run [bold cyan]codedd audits select[/bold cyan] first."
61
+ )
62
+ raise typer.Exit(code=1)
63
+
64
+
65
+ def _enforce_single_audit_limit(cfg: ConfigManager) -> bool:
66
+ """
67
+ For single audits, check that we haven't already reached the 1-directory limit.
68
+
69
+ Returns True if adding another directory is allowed, False otherwise.
70
+ """
71
+ if cfg.active_audit_type == "single" and len(cfg.scope_directories) >= 1:
72
+ print_error(
73
+ "Single audits support exactly [bold]1[/bold] directory. "
74
+ "Remove the existing one first with [bold cyan]codedd scope remove 1[/bold cyan]."
75
+ )
76
+ return False
77
+ return True
78
+
79
+
80
+ # ---------------------------------------------------------------------------
81
+ # codedd scope add <path> [<path> ...]
82
+ # ---------------------------------------------------------------------------
83
+
84
+ @scope_app.command("add")
85
+ @require_auth
86
+ def add_directory(
87
+ paths: list[str] = typer.Argument(
88
+ ...,
89
+ help="One or more absolute paths to local Git repositories.",
90
+ ),
91
+ ) -> None:
92
+ """
93
+ Add local Git repository directories to the audit scope.
94
+
95
+ Each path is validated to ensure it:
96
+ - exists and is readable
97
+ - is the root of a Git repository
98
+ - has at least one commit
99
+
100
+ For single audits, only 1 directory is allowed.
101
+ For group audits, you may add multiple directories.
102
+ """
103
+ cfg = ConfigManager()
104
+ _require_active_audit(cfg)
105
+
106
+ added_count = 0
107
+
108
+ for raw_path in paths:
109
+ # Single audit limit check
110
+ if not _enforce_single_audit_limit(cfg):
111
+ break
112
+
113
+ console.print(f"\n[dim]Validating:[/dim] {raw_path}")
114
+
115
+ # Run validation
116
+ dir_info = validate_directory(raw_path)
117
+ render_validation_result(dir_info)
118
+
119
+ if not dir_info.is_valid:
120
+ continue
121
+
122
+ # Add to scope (ConfigManager handles de-duplication)
123
+ was_added = cfg.add_scope_directory(
124
+ path=dir_info.path,
125
+ repo_name=dir_info.repo_name,
126
+ branch=dir_info.branch,
127
+ commit_hash=dir_info.commit_hash,
128
+ )
129
+
130
+ if was_added:
131
+ added_count += 1
132
+ else:
133
+ print_warning(f" [yellow]Skipped (already in scope):[/yellow] {dir_info.path}")
134
+
135
+ console.print()
136
+
137
+ if added_count:
138
+ print_success(f"{added_count} director{'y' if added_count == 1 else 'ies'} added to scope")
139
+
140
+ # Show current scope
141
+ dirs = cfg.scope_directories
142
+ if dirs:
143
+ console.print()
144
+ render_scope_table(dirs, cfg.active_audit_name)
145
+
146
+ # Next-step hints
147
+ console.print()
148
+ console.print("[dim]Next steps:[/dim]")
149
+ console.print(" [dim]1.[/dim] Add more directories [bold cyan]codedd scope add <path>[/bold cyan]")
150
+ console.print(" [dim]2.[/dim] Confirm selection [bold cyan]codedd scope confirm[/bold cyan]")
151
+
152
+
153
+ # ---------------------------------------------------------------------------
154
+ # codedd scope remove <number>
155
+ # ---------------------------------------------------------------------------
156
+
157
+ @scope_app.command("remove")
158
+ @require_auth
159
+ def remove_directory(
160
+ number: Optional[int] = typer.Argument(
161
+ None,
162
+ help="Number of the directory to remove (from 'codedd scope list'). Omit for interactive.",
163
+ ),
164
+ ) -> None:
165
+ """
166
+ Remove a directory from the audit scope by its number.
167
+
168
+ Run ``codedd scope list`` to see the numbered list.
169
+ """
170
+ cfg = ConfigManager()
171
+ _require_active_audit(cfg)
172
+
173
+ dirs = cfg.scope_directories
174
+ if not dirs:
175
+ print_info("Scope is empty — nothing to remove.")
176
+ return
177
+
178
+ # Interactive mode if no number given
179
+ if number is None:
180
+ render_scope_table(dirs, cfg.active_audit_name)
181
+ console.print()
182
+ number = IntPrompt.ask(
183
+ "Enter the number of the directory to remove",
184
+ choices=[str(i) for i in range(1, len(dirs) + 1)],
185
+ )
186
+
187
+ # Convert 1-based user input to 0-based index
188
+ index = number - 1
189
+
190
+ if index < 0 or index >= len(dirs):
191
+ print_error(f"Invalid number. Must be between 1 and {len(dirs)}.")
192
+ raise typer.Exit(code=1)
193
+
194
+ removed_entry = dirs[index]
195
+ cfg.remove_scope_directory(index)
196
+
197
+ print_success(f"Removed [bold]{removed_entry.get('repo_name', removed_entry['path'])}[/bold] from scope")
198
+
199
+ # Show remaining scope
200
+ remaining = cfg.scope_directories
201
+ if remaining:
202
+ console.print()
203
+ render_scope_table(remaining, cfg.active_audit_name)
204
+ else:
205
+ print_info("Scope is now empty.")
206
+
207
+
208
+ # ---------------------------------------------------------------------------
209
+ # codedd scope list
210
+ # ---------------------------------------------------------------------------
211
+
212
+ @scope_app.command("list")
213
+ @require_auth
214
+ def list_directories() -> None:
215
+ """
216
+ Show all directories currently in the audit scope.
217
+ """
218
+ cfg = ConfigManager()
219
+ _require_active_audit(cfg)
220
+
221
+ dirs = cfg.scope_directories
222
+ audit_type = cfg.active_audit_type
223
+
224
+ # Header with audit info
225
+ console.print(
226
+ f"\n[bold]Audit:[/bold] {cfg.active_audit_name} "
227
+ f"[dim]({audit_type})[/dim] "
228
+ f"[dim]{cfg.active_audit_uuid[:8]}…[/dim]"
229
+ )
230
+
231
+ limit_info = "1 directory" if audit_type == "single" else "1-n directories"
232
+ console.print(f"[bold]Limit:[/bold] {limit_info}\n")
233
+
234
+ render_scope_table(dirs, cfg.active_audit_name)
235
+
236
+ if dirs:
237
+ console.print()
238
+ remaining_slots = "unlimited" if audit_type == "group" else str(max(0, 1 - len(dirs)))
239
+ print_info(f"Directories: {len(dirs)} | Remaining slots: {remaining_slots}")
240
+
241
+
242
+ # ---------------------------------------------------------------------------
243
+ # codedd scope clear
244
+ # ---------------------------------------------------------------------------
245
+
246
+ @scope_app.command("clear")
247
+ @require_auth
248
+ def clear_directories() -> None:
249
+ """
250
+ Remove all directories from the audit scope.
251
+ """
252
+ cfg = ConfigManager()
253
+ _require_active_audit(cfg)
254
+
255
+ dirs = cfg.scope_directories
256
+ if not dirs:
257
+ print_info("Scope is already empty.")
258
+ return
259
+
260
+ render_scope_table(dirs, cfg.active_audit_name)
261
+ console.print()
262
+
263
+ confirmed = Confirm.ask(
264
+ f"Remove all [bold]{len(dirs)}[/bold] director{'y' if len(dirs) == 1 else 'ies'} from scope?",
265
+ default=False,
266
+ )
267
+
268
+ if confirmed:
269
+ cfg.clear_scope_directories()
270
+ print_success("Scope cleared.")
271
+ else:
272
+ print_info("Cancelled.")
273
+
274
+
275
+ # ---------------------------------------------------------------------------
276
+ # codedd scope status
277
+ # ---------------------------------------------------------------------------
278
+
279
+ @scope_app.command("status")
280
+ @require_auth
281
+ def scope_status() -> None:
282
+ """
283
+ Show a summary of the current audit scope readiness.
284
+ """
285
+ cfg = ConfigManager()
286
+ _require_active_audit(cfg)
287
+
288
+ dirs = cfg.scope_directories
289
+ audit_type = cfg.active_audit_type
290
+ audit_name = cfg.active_audit_name
291
+
292
+ console.print(f"\n[bold]Audit:[/bold] {audit_name} [dim]({audit_type})[/dim]")
293
+ console.print(f"[bold]UUID:[/bold] {cfg.active_audit_uuid}")
294
+ console.print(f"[bold]Directories:[/bold] {len(dirs)}")
295
+
296
+ if not dirs:
297
+ console.print()
298
+ print_warning("No directories added. Use [bold cyan]codedd scope add <path>[/bold cyan] to begin.")
299
+ return
300
+
301
+ # Re-validate all directories (they could have changed since being added)
302
+ console.print("\n[dim]Re-validating directories…[/dim]\n")
303
+ all_valid = True
304
+ for entry in dirs:
305
+ dir_info = validate_directory(entry["path"])
306
+ render_validation_result(dir_info)
307
+ if not dir_info.is_valid:
308
+ all_valid = False
309
+
310
+ console.print()
311
+
312
+ if all_valid:
313
+ print_success(
314
+ f"Scope is ready — {len(dirs)} valid director{'y' if len(dirs) == 1 else 'ies'}."
315
+ )
316
+ else:
317
+ print_error(
318
+ "Some directories failed validation. "
319
+ "Fix or remove them before proceeding."
320
+ )
321
+
322
+ # Show sync state
323
+ any_confirmed = any(d.get("confirmed") for d in dirs)
324
+ any_dirty = any(d.get("needs_reconfirm") for d in dirs)
325
+
326
+ if any_dirty:
327
+ console.print()
328
+ print_warning(
329
+ "Scope has local changes. Run [bold cyan]codedd scope sync[/bold cyan] "
330
+ "or [bold cyan]codedd scope confirm[/bold cyan] to update."
331
+ )
332
+ elif any_confirmed:
333
+ console.print()
334
+ print_info("Scope is registered with CodeDD. Run [bold cyan]codedd scope sync[/bold cyan] to check for changes.")
335
+
336
+ console.print()
337
+ render_scope_table_with_sync(dirs, audit_name)
338
+
339
+
340
+ # ---------------------------------------------------------------------------
341
+ # codedd scope confirm
342
+ # ---------------------------------------------------------------------------
343
+
344
+ @scope_app.command("confirm")
345
+ @require_auth
346
+ def confirm_scope(
347
+ show: bool = typer.Option(
348
+ False,
349
+ "--show",
350
+ "-s",
351
+ help="Write the API payload to a file and open it for review before sending.",
352
+ ),
353
+ ) -> None:
354
+ """
355
+ Scan all directories, build metadata, and register the scope with CodeDD.
356
+
357
+ This command:
358
+ 1. Re-validates every directory in the scope.
359
+ 2. Walks each directory to classify files and count lines of code.
360
+ 3. Builds a metadata-only payload (no source code).
361
+ 4. With --show: writes the payload to a .txt file for inspection.
362
+ 5. Asks for confirmation, then sends the metadata to CodeDD.
363
+ """
364
+ cfg = ConfigManager()
365
+ _require_active_audit(cfg)
366
+
367
+ dirs = cfg.scope_directories
368
+ if not dirs:
369
+ print_error("No directories in scope. Use [bold cyan]codedd scope add <path>[/bold cyan] first.")
370
+ raise typer.Exit(code=1)
371
+
372
+ audit_uuid = cfg.active_audit_uuid
373
+ audit_type = cfg.active_audit_type
374
+ audit_name = cfg.active_audit_name
375
+
376
+ console.print(f"\n[bold]Audit:[/bold] {audit_name} [dim]({audit_type})[/dim]")
377
+ console.print(f"[bold]Directories:[/bold] {len(dirs)}\n")
378
+
379
+ # Phase 1: Re-validate all directories
380
+ console.print("[dim]Phase 1 — Validating directories…[/dim]\n")
381
+ all_valid = True
382
+ for entry in dirs:
383
+ dir_info = validate_directory(entry["path"])
384
+ render_validation_result(dir_info)
385
+ if not dir_info.is_valid:
386
+ all_valid = False
387
+
388
+ if not all_valid:
389
+ console.print()
390
+ print_error("Some directories failed validation. Fix or remove them before confirming.")
391
+ raise typer.Exit(code=1)
392
+
393
+ console.print()
394
+
395
+ # Phase 2: Scan each directory
396
+ console.print("[dim]Phase 2 — Scanning repositories…[/dim]\n")
397
+ scan_results = []
398
+
399
+ for entry in dirs:
400
+ repo_path = entry["path"]
401
+ repo_name = entry.get("repo_name", Path(repo_path).name)
402
+
403
+ with Progress(
404
+ SpinnerColumn(),
405
+ TextColumn("[progress.description]{task.description}"),
406
+ console=console,
407
+ transient=True,
408
+ ) as progress:
409
+ task = progress.add_task(f"Scanning [bold]{repo_name}[/bold]…", total=None)
410
+
411
+ def _progress_cb(current: int, total: int, file_path: str) -> None:
412
+ progress.update(task, description=f"Scanning [bold]{repo_name}[/bold] ({current}/{total})")
413
+
414
+ result = scan_repository(repo_path, progress_callback=_progress_cb)
415
+ scan_results.append(result)
416
+
417
+ # Display summary for this repo
418
+ console.print(
419
+ f" [bold green]{SYMBOL_OK}[/bold green] [bold]{result.repo_name}[/bold] "
420
+ f"[dim]{result.branch} @ {result.commit_hash}[/dim] "
421
+ f"files: {result.total_files} "
422
+ f"LoC: {result.total_lines_of_code:,} "
423
+ f"docs: {result.total_lines_of_doc:,}"
424
+ )
425
+ if result.errors:
426
+ print_warning(f" {len(result.errors)} file(s) had scan errors (skipped)")
427
+
428
+ console.print()
429
+
430
+ # Phase 3: Build the API payload
431
+ payload = _build_payload(audit_uuid, audit_type, scan_results)
432
+
433
+ # Phase 4: Summary + confirmation (--show writes & opens the payload file)
434
+ total_files = sum(r.total_files for r in scan_results)
435
+ total_loc = sum(r.total_lines_of_code for r in scan_results)
436
+
437
+ console.print(
438
+ f"[bold]Summary:[/bold] {len(scan_results)} repositor{'y' if len(scan_results) == 1 else 'ies'} | "
439
+ f"{total_files:,} files | {total_loc:,} lines of code\n"
440
+ )
441
+ print_info(
442
+ "Only [bold]file paths, types, and line counts[/bold] will be sent to CodeDD. "
443
+ "[bold]No source code.[/bold]"
444
+ )
445
+ console.print()
446
+
447
+ if show:
448
+ # --show: write payload to file, open it, ask for confirmation
449
+ confirmed = review_payload(
450
+ payload,
451
+ command_label="Scope Registration",
452
+ context_note="This file contains ONLY metadata (file paths, types, line counts). No source code content is included.",
453
+ confirm_prompt="Register this scope with CodeDD?",
454
+ )
455
+ else:
456
+ confirmed = Confirm.ask("Register this scope with CodeDD?", default=True)
457
+
458
+ if not confirmed:
459
+ print_info("Cancelled. Scope was not sent.")
460
+ return
461
+
462
+ # Phase 6: Send to CodeDD
463
+ console.print()
464
+ with Progress(
465
+ SpinnerColumn(),
466
+ TextColumn("[progress.description]{task.description}"),
467
+ console=console,
468
+ transient=True,
469
+ ) as progress:
470
+ send_task = progress.add_task("Registering scope with CodeDD…", total=None)
471
+
472
+ with CodeDDClient(config=cfg) as client:
473
+ resp = client.post(Endpoints.REGISTER_SCOPE, json=payload)
474
+
475
+ if resp.status_code == 401:
476
+ print_error("Authentication failed. Run [bold cyan]codedd auth login[/bold cyan].")
477
+ raise typer.Exit(code=1)
478
+
479
+ if resp.status_code not in (200, 201):
480
+ msg = "Failed to register scope"
481
+ try:
482
+ body = resp.json()
483
+ msg = body.get("message", msg)
484
+ except Exception:
485
+ pass
486
+ print_error(msg)
487
+ raise typer.Exit(code=1)
488
+
489
+ body = resp.json()
490
+ if body.get("status") != "success":
491
+ print_error(body.get("message", "Unknown error from server"))
492
+ raise typer.Exit(code=1)
493
+
494
+ # Mark all scope entries as confirmed
495
+ cfg.mark_scope_confirmed()
496
+
497
+ # Display results
498
+ console.print()
499
+ print_success(f"Scope registered — {body.get('message', '')}")
500
+
501
+ sub_audits = body.get("sub_audits", [])
502
+ for sa in sub_audits:
503
+ status_icon = f"[bold green]{SYMBOL_OK}[/bold green]" if sa.get("status") == "ok" else f"[bold red]{SYMBOL_FAIL}[/bold red]"
504
+ console.print(
505
+ f" {status_icon} [bold]{sa.get('repo_name', '—')}[/bold] "
506
+ f"audit: [dim]{sa.get('audit_uuid', '—')[:12]}…[/dim] "
507
+ f"files: {sa.get('files_registered', 0)} "
508
+ f"LoC: {sa.get('total_lines_of_code', 0):,}"
509
+ )
510
+
511
+ # Point user to the cloud app to manage scope, pay, etc. (after sub-audit list)
512
+ print_audit_scope_cloud_info(cfg.active_audit_uuid, cfg.active_audit_type)
513
+
514
+
515
+ # ---------------------------------------------------------------------------
516
+ # codedd scope sync
517
+ # ---------------------------------------------------------------------------
518
+
519
+ @scope_app.command("sync")
520
+ @require_auth
521
+ def sync_scope(
522
+ show: bool = typer.Option(
523
+ False,
524
+ "--show",
525
+ "-s",
526
+ help="Write each API request to a file and open it for review before sending.",
527
+ ),
528
+ ) -> None:
529
+ """
530
+ Compare the local directory state with what is registered in CodeDD.
531
+
532
+ This command:
533
+ 1. Fetches the registered file list from CodeDD for each CLI sub-audit.
534
+ 2. Re-scans each local directory to build the current file list.
535
+ 3. Computes a diff (added / removed / changed files) per repository.
536
+ 4. If changes are detected, marks the scope as *needs_reconfirm*.
537
+ 5. Optionally re-confirms the scope (re-runs the register flow).
538
+ """
539
+ cfg = ConfigManager()
540
+ _require_active_audit(cfg)
541
+
542
+ dirs = cfg.scope_directories
543
+ if not dirs:
544
+ print_error("No directories in scope. Use [bold cyan]codedd scope add <path>[/bold cyan] first.")
545
+ raise typer.Exit(code=1)
546
+
547
+ audit_uuid = cfg.active_audit_uuid
548
+ audit_name = cfg.active_audit_name
549
+
550
+ console.print(f"\n[bold]Audit:[/bold] {audit_name} [dim]({cfg.active_audit_type})[/dim]")
551
+ console.print(f"[bold]Directories:[/bold] {len(dirs)}\n")
552
+
553
+ # Phase 1: Fetch remote state from CodeDD
554
+ console.print("[dim]Phase 1 — Fetching registered scope from CodeDD…[/dim]\n")
555
+
556
+ if show:
557
+ confirmed = review_request(
558
+ "GET",
559
+ Endpoints.SCOPE_FILES,
560
+ params={"audit_uuid": audit_uuid},
561
+ command_label="Scope Sync — Fetch Files",
562
+ context_note="This request fetches the list of files currently registered for your audit. Only the audit UUID is sent.",
563
+ confirm_prompt="Proceed with this request to CodeDD?",
564
+ )
565
+ if not confirmed:
566
+ print_info("Cancelled.")
567
+ raise typer.Exit(code=0)
568
+
569
+ with Progress(
570
+ SpinnerColumn(),
571
+ TextColumn("[progress.description]{task.description}"),
572
+ console=console,
573
+ transient=True,
574
+ ) as progress:
575
+ progress.add_task("Contacting CodeDD…", total=None)
576
+
577
+ with CodeDDClient(config=cfg) as client:
578
+ resp = client.get(Endpoints.SCOPE_FILES, params={"audit_uuid": audit_uuid})
579
+
580
+ if resp.status_code == 401:
581
+ print_error("Authentication failed. Run [bold cyan]codedd auth login[/bold cyan].")
582
+ raise typer.Exit(code=1)
583
+
584
+ # Parse body for message (even on error) to detect "audit not found"
585
+ try:
586
+ body = resp.json() if resp.content else {}
587
+ except Exception:
588
+ body = {}
589
+ msg = body.get("message", "") or ""
590
+
591
+ # Audit itself (group/single) no longer exists on CodeDD — show message and list available audits
592
+ def _is_audit_not_found_response() -> bool:
593
+ if resp.status_code == 404:
594
+ return True
595
+ low = msg.lower()
596
+ return "audit not found" in low or "access denied or audit not found" in low
597
+
598
+ if resp.status_code != 200 or body.get("status") != "success":
599
+ if _is_audit_not_found_response():
600
+ print_warning("The audit no longer exists on CodeDD (deleted or not found).")
601
+ console.print()
602
+ _show_available_audits_and_exit(cfg, show=show)
603
+ else:
604
+ print_error(msg or "Failed to fetch scope data from CodeDD")
605
+ raise typer.Exit(code=1)
606
+
607
+ remote_sub_audits = body.get("sub_audits", [])
608
+
609
+ # Build a lookup: repo_name -> remote file list
610
+ remote_by_name: dict[str, list[dict]] = {}
611
+ for sa in remote_sub_audits:
612
+ remote_by_name[sa.get("repo_name", "")] = sa.get("files", [])
613
+
614
+ # Detect repos missing from remote (sub-audit not in API response)
615
+ local_names = {d.get("repo_name") for d in dirs}
616
+ remote_names = set(remote_by_name.keys())
617
+ deleted_remotely = local_names - remote_names
618
+
619
+ if deleted_remotely:
620
+ for name in deleted_remotely:
621
+ print_warning(f" Repository [bold]{name}[/bold] was deleted from CodeDD (no longer in scope)")
622
+ console.print()
623
+
624
+ # When audit exists but no sub_audits on remote (scope empty), skip Phase 2 and show panel
625
+ scope_empty = len(remote_sub_audits) == 0
626
+
627
+ total_has_diff = False
628
+ repo_diffs: list[tuple[str, dict, int]] = [] # (repo_name, diff, remote_file_count)
629
+ total_loc_sync = 0 # Sum of local LoC across scanned repos
630
+ total_for_audit_files = 0 # Sum of files selected for audit on CodeDD (remote)
631
+ total_for_audit_loc = 0 # Sum of LoC selected for audit on CodeDD (remote)
632
+
633
+ if scope_empty:
634
+ repos_deleted_on_codedd = set(local_names)
635
+ console.print()
636
+ else:
637
+ # Phase 2: Re-scan local directories; separate "deleted on remote" from normal diffs
638
+ console.print("[dim]Phase 2 — Scanning local directories…[/dim]\n")
639
+
640
+ repos_cleared_on_remote: set[str] = set() # in remote_by_name but 0 files (audit cleared)
641
+
642
+ for entry in dirs:
643
+ repo_path = entry["path"]
644
+ repo_name = entry.get("repo_name", Path(repo_path).name)
645
+
646
+ # Skip if not known on the server (it was never confirmed)
647
+ if repo_name not in remote_by_name:
648
+ print_info(f" [bold]{repo_name}[/bold] — not yet registered; skipping diff")
649
+ continue
650
+
651
+ with Progress(
652
+ SpinnerColumn(),
653
+ TextColumn("[progress.description]{task.description}"),
654
+ console=console,
655
+ transient=True,
656
+ ) as progress:
657
+ progress.add_task(f"Scanning [bold]{repo_name}[/bold]…", total=None)
658
+ scan_result = scan_repository(repo_path)
659
+
660
+ # Build local file map: relative_path -> { file_type, lines_of_code, lines_of_doc }
661
+ local_files: dict[str, dict] = {}
662
+ for fm in scan_result.files:
663
+ local_files[fm.relative_path] = {
664
+ "file_type": fm.file_type,
665
+ "lines_of_code": fm.lines_of_code,
666
+ "lines_of_doc": fm.lines_of_doc,
667
+ }
668
+
669
+ # Build remote file map
670
+ remote_files: dict[str, dict] = {}
671
+ for rf in remote_by_name[repo_name]:
672
+ remote_files[rf["relative_path"]] = rf
673
+
674
+ remote_count = len(remote_files)
675
+
676
+ local_loc = scan_result.total_lines_of_code
677
+ remote_list = remote_by_name[repo_name]
678
+ # Selected for audit on CodeDD (what the web UI shows)
679
+ for_audit_files = sum(1 for rf in remote_list if rf.get("selected_for_audit", True))
680
+ for_audit_loc = sum(
681
+ rf.get("lines_of_code", 0)
682
+ for rf in remote_list
683
+ if rf.get("selected_for_audit", True)
684
+ )
685
+
686
+ # Audit deleted/cleared on CodeDD (0 files on remote) — do not treat as normal diff
687
+ if remote_count == 0:
688
+ repos_cleared_on_remote.add(repo_name)
689
+ status_label = "[bold yellow]deleted on CodeDD[/bold yellow]"
690
+ console.print(
691
+ f" {status_label} [bold]{repo_name}[/bold] "
692
+ f"{len(local_files)} files, {local_loc:,} LoC [dim](none on CodeDD)[/dim]"
693
+ )
694
+ total_loc_sync += local_loc
695
+ continue
696
+
697
+ # Normal diff
698
+ diff = _compute_diff(local_files, remote_files)
699
+ has_diff = bool(diff["added"] or diff["removed"] or diff["changed"])
700
+
701
+ if has_diff:
702
+ total_has_diff = True
703
+ repo_diffs.append((repo_name, diff, remote_count))
704
+
705
+ status_label = "[bold yellow]changes detected[/bold yellow]" if has_diff else "[bold green]in sync[/bold green]"
706
+ # One clear line: local scope → what's for audit on CodeDD (matches web UI)
707
+ console.print(
708
+ f" {status_label} [bold]{repo_name}[/bold] "
709
+ f"{len(local_files)} files, {local_loc:,} LoC → "
710
+ f"[cyan]{for_audit_files} file{'s' if for_audit_files != 1 else ''}, {for_audit_loc:,} LoC for audit[/cyan]"
711
+ )
712
+ total_loc_sync += local_loc
713
+ total_for_audit_files += for_audit_files
714
+ total_for_audit_loc += for_audit_loc
715
+
716
+ # All repos that are "deleted" on CodeDD: missing from API or 0 files
717
+ repos_deleted_on_codedd = deleted_remotely | repos_cleared_on_remote
718
+
719
+ if total_loc_sync > 0:
720
+ total_line = f"\n [dim]Total: {total_loc_sync:,} LoC → {total_for_audit_files} file{'s' if total_for_audit_files != 1 else ''}, {total_for_audit_loc:,} LoC for audit on CodeDD[/dim]"
721
+ console.print(total_line)
722
+ console.print()
723
+
724
+ # Handle deleted-on-CodeDD audits: prompt and act (Option 1 or 2)
725
+ if repos_deleted_on_codedd:
726
+ choice = prompt_deleted_audits_action(sorted(repos_deleted_on_codedd))
727
+
728
+ # 1 or 3 = accept deletion (remove from local scope); 2 or 4 = restore to CodeDD
729
+ if choice in (1, 3):
730
+ # Confirm remote audit deletion: remove these repos from local scope
731
+ dirs_list = cfg.scope_directories
732
+ indices_to_remove = [i for i, d in enumerate(dirs_list) if d.get("repo_name") in repos_deleted_on_codedd]
733
+ for i in sorted(indices_to_remove, reverse=True):
734
+ cfg.remove_scope_directory(i)
735
+ print_success(
736
+ f"Removed {len(indices_to_remove)} audit(s) from local scope to match CodeDD."
737
+ )
738
+ console.print()
739
+ # If scope is now empty, show state and stop
740
+ if not cfg.scope_directories:
741
+ render_scope_table_with_sync(cfg.scope_directories, cfg.active_audit_name)
742
+ return
743
+ # Continue to Phase 3/4 for any remaining repos with normal diffs
744
+ else:
745
+ # Restore local audit scope and sync to CodeDD (re-register)
746
+ console.print()
747
+ _run_reconfirm(cfg, show=show)
748
+ # Show final state and exit; no need to run Phase 3/4
749
+ console.print()
750
+ render_scope_table_with_sync(cfg.scope_directories, cfg.active_audit_name)
751
+ return
752
+
753
+ # Phase 3: Display diffs (skips full table when remote has 0 files; limits rows otherwise)
754
+ if repo_diffs:
755
+ console.print("[dim]Phase 3 — Diff details[/dim]\n")
756
+ for repo_name, diff, remote_count in repo_diffs:
757
+ render_diff_table(repo_name, diff, remote_file_count=remote_count)
758
+ console.print()
759
+
760
+ # Phase 4: Update config and prompt
761
+ now_iso = datetime.now(timezone.utc).isoformat()
762
+
763
+ if total_has_diff:
764
+ cfg.mark_scope_needs_reconfirm(last_sync_iso=now_iso)
765
+ print_warning("Local files have changed since last registration.")
766
+ console.print()
767
+
768
+ confirmed = Confirm.ask("Re-confirm scope now?", default=False)
769
+ if confirmed:
770
+ # Delegate to the confirm flow
771
+ console.print()
772
+ _run_reconfirm(cfg, show=show)
773
+ else:
774
+ print_info(
775
+ "Scope marked as [bold yellow]needs re-confirm[/bold yellow]. "
776
+ "Run [bold cyan]codedd scope confirm[/bold cyan] when ready."
777
+ )
778
+ else:
779
+ cfg.update_scope_last_sync(now_iso)
780
+ print_success("All registered repositories are in sync with local directories.")
781
+
782
+ # Show final state
783
+ console.print()
784
+ render_scope_table_with_sync(cfg.scope_directories, cfg.active_audit_name)
785
+
786
+
787
+ # ---------------------------------------------------------------------------
788
+ # Helpers for confirm and sync
789
+ # ---------------------------------------------------------------------------
790
+
791
+ def _show_available_audits_and_exit(cfg: ConfigManager, show: bool = False) -> None:
792
+ """
793
+ Fetch and display the list of audits available on CodeDD, then exit.
794
+ Used when the active audit no longer exists (deleted on server).
795
+ """
796
+ if show:
797
+ confirmed = review_request(
798
+ "GET",
799
+ Endpoints.LIST_AUDITS,
800
+ params={"offset": 0, "limit": 50},
801
+ command_label="Audits List (After Scope Not Found)",
802
+ context_note="This request fetches your list of audits from CodeDD. Only pagination params are sent.",
803
+ confirm_prompt="Proceed with this request to CodeDD?",
804
+ )
805
+ if not confirmed:
806
+ print_info("Cancelled.")
807
+ raise typer.Exit(code=0)
808
+
809
+ console.print("[dim]Fetching your audits from CodeDD…[/dim]\n")
810
+ try:
811
+ with CodeDDClient(config=cfg) as client:
812
+ single_audits, group_audits, total = _fetch_audits(client, limit=50)
813
+ except typer.Exit:
814
+ raise
815
+ except Exception as e:
816
+ print_error(str(e))
817
+ raise typer.Exit(code=1)
818
+
819
+ if not single_audits and not group_audits:
820
+ print_info("No other audits found. Create an audit at [bold cyan]https://www.codedd.ai[/bold cyan]")
821
+ else:
822
+ active_uuid = (cfg.active_audit_uuid or "").strip() or None
823
+ render_audits_table(single_audits, group_audits, total, active_audit_uuid=active_uuid)
824
+ console.print()
825
+ print_info("Run [bold cyan]codedd audits select[/bold cyan] to choose another audit.")
826
+
827
+ raise typer.Exit(code=0)
828
+
829
+
830
+ def _build_payload(audit_uuid: str, audit_type: str, scan_results: list) -> dict:
831
+ """Build the JSON payload for the scope registration endpoint."""
832
+ return {
833
+ "audit_uuid": audit_uuid,
834
+ "audit_type": audit_type,
835
+ "repositories": [r.to_dict() for r in scan_results],
836
+ }
837
+
838
+
839
+ def _compute_diff(
840
+ local_files: dict[str, dict],
841
+ remote_files: dict[str, dict],
842
+ ) -> dict:
843
+ """
844
+ Compare local scan results against the remote (CodeDD) file list.
845
+
846
+ Args:
847
+ local_files: ``{relative_path: {file_type, lines_of_code, lines_of_doc}}``
848
+ remote_files: ``{relative_path: {file_type, lines_of_code, lines_of_doc, selected_for_audit}}``
849
+
850
+ Returns:
851
+ Dict with keys ``added``, ``removed``, ``changed``.
852
+ """
853
+ local_paths = set(local_files.keys())
854
+ remote_paths = set(remote_files.keys())
855
+
856
+ added = [
857
+ {"path": p, "lines_of_code": local_files[p].get("lines_of_code", 0)}
858
+ for p in sorted(local_paths - remote_paths)
859
+ ]
860
+
861
+ removed = [
862
+ {"path": p, "lines_of_code": remote_files[p].get("lines_of_code", 0)}
863
+ for p in sorted(remote_paths - local_paths)
864
+ ]
865
+
866
+ changed = []
867
+ for p in sorted(local_paths & remote_paths):
868
+ old_loc = remote_files[p].get("lines_of_code", 0)
869
+ new_loc = local_files[p].get("lines_of_code", 0)
870
+ old_doc = remote_files[p].get("lines_of_doc", 0)
871
+ new_doc = local_files[p].get("lines_of_doc", 0)
872
+ if old_loc != new_loc or old_doc != new_doc:
873
+ changed.append({"path": p, "old_loc": old_loc, "new_loc": new_loc})
874
+
875
+ return {"added": added, "removed": removed, "changed": changed}
876
+
877
+
878
+ def _run_reconfirm(cfg: ConfigManager, show: bool = False) -> bool:
879
+ """
880
+ Execute the scope re-confirmation flow: scan, build payload, send to
881
+ CodeDD. Reuses the same logic as ``confirm_scope`` but is called
882
+ programmatically after a ``sync`` detects changes.
883
+
884
+ When ``show`` is True, writes the registration payload to a file and
885
+ asks for confirmation before sending (same as ``scope confirm --show``).
886
+
887
+ Returns True if re-registration succeeded, False otherwise.
888
+ """
889
+ dirs = cfg.scope_directories
890
+ audit_uuid = cfg.active_audit_uuid
891
+ audit_type = cfg.active_audit_type
892
+
893
+ # Scan
894
+ scan_results = []
895
+ for entry in dirs:
896
+ repo_path = entry["path"]
897
+ repo_name = entry.get("repo_name", Path(repo_path).name)
898
+
899
+ with Progress(
900
+ SpinnerColumn(),
901
+ TextColumn("[progress.description]{task.description}"),
902
+ console=console,
903
+ transient=True,
904
+ ) as progress:
905
+ progress.add_task(f"Scanning [bold]{repo_name}[/bold]…", total=None)
906
+ result = scan_repository(repo_path)
907
+ scan_results.append(result)
908
+
909
+ console.print(
910
+ f" [bold green]{SYMBOL_OK}[/bold green] [bold]{result.repo_name}[/bold] "
911
+ f"files: {result.total_files} "
912
+ f"LoC: {result.total_lines_of_code:,}"
913
+ )
914
+
915
+ console.print()
916
+ payload = _build_payload(audit_uuid, audit_type, scan_results)
917
+
918
+ if show:
919
+ confirmed = review_payload(
920
+ payload,
921
+ command_label="Scope Re-registration",
922
+ context_note="This file contains ONLY metadata (file paths, types, line counts). No source code content is included.",
923
+ confirm_prompt="Re-register this scope with CodeDD?",
924
+ )
925
+ if not confirmed:
926
+ print_info("Cancelled. Scope was not re-registered.")
927
+ return False
928
+
929
+ # Send
930
+ with Progress(
931
+ SpinnerColumn(),
932
+ TextColumn("[progress.description]{task.description}"),
933
+ console=console,
934
+ transient=True,
935
+ ) as progress:
936
+ progress.add_task("Re-registering scope with CodeDD…", total=None)
937
+ with CodeDDClient(config=cfg) as client:
938
+ resp = client.post(Endpoints.REGISTER_SCOPE, json=payload)
939
+
940
+ if resp.status_code not in (200, 201):
941
+ msg = "Failed to re-register scope"
942
+ try:
943
+ body = resp.json()
944
+ msg = body.get("message", msg)
945
+ # Surface per-repo errors so the user can debug
946
+ sub_audits = body.get("sub_audits", [])
947
+ for sa in sub_audits:
948
+ if sa.get("status") != "ok" and sa.get("error"):
949
+ print_error(
950
+ f" {sa.get('repo_name', '?')}: {sa['error']}"
951
+ )
952
+ except Exception:
953
+ pass
954
+ print_error(msg)
955
+ return False
956
+
957
+ body = resp.json()
958
+ if body.get("status") != "success":
959
+ print_error(body.get("message", "Unknown error from server"))
960
+ return False
961
+
962
+ cfg.mark_scope_confirmed()
963
+ print_success(f"Scope re-registered — {body.get('message', '')}")
964
+
965
+ # Point user to the cloud app to manage scope, pay, etc.
966
+ print_audit_scope_cloud_info(cfg.active_audit_uuid, cfg.active_audit_type)
967
+ return True