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