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,1877 @@
1
+ """
2
+ ``codedd audit`` sub-commands: start.
3
+
4
+ Manages the audit lifecycle — pre-flight checks, payment, local file
5
+ auditing via LLM, and result submission to CodeDD.
6
+
7
+ Workflow:
8
+ 1. ``codedd scope confirm`` – register scope with CodeDD
9
+ 2. ``codedd audit start`` – auto-sync, pre-flight, pay/budget,
10
+ fetch plan, audit locally, submit results
11
+ """
12
+
13
+ import json
14
+ import os
15
+ import time
16
+ import webbrowser
17
+ from pathlib import Path
18
+
19
+ import typer
20
+ from rich.console import Console
21
+ from rich.progress import (
22
+ BarColumn,
23
+ MofNCompleteColumn,
24
+ Progress,
25
+ SpinnerColumn,
26
+ TextColumn,
27
+ )
28
+ from rich.prompt import Confirm
29
+ from rich.table import Table
30
+
31
+ from codedd_cli.api.client import CodeDDClient
32
+ from codedd_cli.api.endpoints import Endpoints
33
+ from codedd_cli.auditor.complexity_analyzer import (
34
+ FileComplexityResult,
35
+ LocalComplexityAnalyzer,
36
+ aggregate_complexity_results,
37
+ )
38
+ from codedd_cli.auditor.dependency_scanner import (
39
+ LocalDependencyScanner,
40
+ ManifestResult,
41
+ ImportResult,
42
+ )
43
+ from codedd_cli.auditor.file_auditor import AuditFileResult, LocalFileAuditor
44
+ from codedd_cli.auditor.vulnerability_validator import (
45
+ LocalVulnerabilityValidator,
46
+ ValidationCandidate,
47
+ )
48
+ from codedd_cli.auth.session import require_auth
49
+ from codedd_cli.config.settings import ConfigManager
50
+ from codedd_cli.llm.key_manager import PROVIDER_MODELS, LLMKeyManager
51
+ from codedd_cli.utils.display import (
52
+ STYLE_DEBUG_LOG,
53
+ SYMBOL_FAIL,
54
+ SYMBOL_INFO,
55
+ SYMBOL_OK,
56
+ SYMBOL_WARN,
57
+ print_error,
58
+ print_info,
59
+ print_success,
60
+ print_warning,
61
+ )
62
+ from codedd_cli.utils.payload_inspector import review_payload, review_request
63
+
64
+ console = Console()
65
+ audit_app = typer.Typer(no_args_is_help=True)
66
+
67
+
68
+ def _require_active_audit(cfg: ConfigManager) -> None:
69
+ """Abort if no audit has been selected."""
70
+ if not cfg.active_audit_uuid:
71
+ print_error(
72
+ "No active audit. Run [bold cyan]codedd audits select[/bold cyan] first."
73
+ )
74
+ raise typer.Exit(code=1)
75
+
76
+
77
+ # ---------------------------------------------------------------------------
78
+ # codedd audit start
79
+ # ---------------------------------------------------------------------------
80
+
81
+ @audit_app.command("start")
82
+ @require_auth
83
+ def start_audit(
84
+ skip_sync: bool = typer.Option(
85
+ False,
86
+ "--skip-sync",
87
+ help="Skip the automatic scope sync before starting.",
88
+ ),
89
+ yes: bool = typer.Option(
90
+ False,
91
+ "--yes",
92
+ "-y",
93
+ help="Auto-confirm prompts (non-interactive mode).",
94
+ ),
95
+ show: bool = typer.Option(
96
+ False,
97
+ "--show",
98
+ "-s",
99
+ help="Write each API request/payload to a file and open it for review before sending.",
100
+ ),
101
+ debug_llm: bool = typer.Option(
102
+ False,
103
+ "--debug-llm",
104
+ help="Print full LLM prompt, raw response, and parsed result to the CLI (for debugging).",
105
+ ),
106
+ debug_llm_full_prompt: bool = typer.Option(
107
+ False,
108
+ "--debug-llm-full-prompt",
109
+ help="Include full proprietary system prompt in --debug-llm output (sensitive).",
110
+ ),
111
+ ) -> None:
112
+ """
113
+ Start the active audit on the CodeDD platform.
114
+
115
+ This command:
116
+ 1. Auto-syncs local scope with CodeDD (detects file changes).
117
+ 2. Runs pre-flight checks (status, payment, LoC budget).
118
+ 3. Handles payment scenarios (budget deduction or Stripe checkout).
119
+ 4. Enqueues the audit for processing.
120
+ """
121
+ cfg = ConfigManager()
122
+ _require_active_audit(cfg)
123
+
124
+ audit_uuid = cfg.active_audit_uuid
125
+ audit_name = cfg.active_audit_name
126
+ audit_type = cfg.active_audit_type
127
+
128
+ console.print(
129
+ f"\n[bold]Starting audit:[/bold] {audit_name} [dim]({audit_type})[/dim]\n"
130
+ )
131
+
132
+ # -----------------------------------------------------------------------
133
+ # Phase 1 — Auto-sync (unless skipped)
134
+ # -----------------------------------------------------------------------
135
+ if not skip_sync:
136
+ sync_ok = _auto_sync(cfg, auto_confirm=yes, show=show)
137
+ if not sync_ok:
138
+ print_error("Scope sync failed or was cancelled. Audit not started.")
139
+ raise typer.Exit(code=1)
140
+ console.print()
141
+
142
+ # -----------------------------------------------------------------------
143
+ # Phase 1b — Verify LLM API key availability
144
+ # -----------------------------------------------------------------------
145
+ llm_ok = _check_llm_keys(cfg)
146
+ if not llm_ok:
147
+ raise typer.Exit(code=1)
148
+ console.print()
149
+
150
+ # -----------------------------------------------------------------------
151
+ # Phase 2 — Pre-flight check
152
+ # -----------------------------------------------------------------------
153
+ console.print("[dim]Pre-flight checks…[/dim]\n")
154
+
155
+ if show:
156
+ confirmed = review_request(
157
+ "GET",
158
+ Endpoints.AUDIT_CAN_START,
159
+ params={"audit_uuid": audit_uuid},
160
+ command_label="Audit Start — Pre-flight",
161
+ context_note="This request checks whether the audit can be started (payment, scope, etc.). Only the audit UUID is sent.",
162
+ confirm_prompt="Proceed with this request to CodeDD?",
163
+ )
164
+ if not confirmed:
165
+ print_info("Cancelled.")
166
+ raise typer.Exit(code=0)
167
+
168
+ with CodeDDClient(config=cfg) as client:
169
+ preflight = _fetch_preflight(client, audit_uuid)
170
+
171
+ if preflight is None:
172
+ raise typer.Exit(code=1)
173
+
174
+ # Server returned an error (e.g. 500 with status/message)
175
+ if preflight.get("status") == "error":
176
+ print_error(preflight.get("message", "Pre-flight check failed."))
177
+ raise typer.Exit(code=1)
178
+
179
+ # Legacy or alternate error shape
180
+ if "error" in preflight:
181
+ print_error(preflight["error"])
182
+ raise typer.Exit(code=1)
183
+
184
+ # Malformed or unexpected response
185
+ if "can_start" not in preflight:
186
+ print_error(
187
+ preflight.get("message", "Invalid pre-flight response from server.")
188
+ )
189
+ raise typer.Exit(code=1)
190
+
191
+ _render_preflight(preflight)
192
+ console.print()
193
+
194
+ # -----------------------------------------------------------------------
195
+ # Phase 3 — Decision tree
196
+ # -----------------------------------------------------------------------
197
+ # Use .get() so we don't KeyError when backend returns a minimal response
198
+ # (e.g. can_start=False, reason="Not all repositories have been scoped").
199
+ can_start = preflight.get("can_start", False)
200
+ is_paid = preflight.get("is_paid", False)
201
+ payment_required = preflight.get("payment_required", False)
202
+ loc_delta = preflight.get("loc_delta", 0)
203
+ loc_budget = preflight.get("loc_budget", 0)
204
+ total_loc = preflight.get("total_lines_of_code", 0)
205
+
206
+ use_budget = False
207
+
208
+ if can_start and is_paid and loc_delta == 0:
209
+ # Fully paid, no changes — good to go
210
+ print_success("Audit is fully paid. Ready to start.")
211
+ if not yes and not Confirm.ask("Start audit now?", default=True):
212
+ print_info("Audit start cancelled.")
213
+ raise typer.Exit()
214
+
215
+ elif can_start and is_paid and loc_delta > 0:
216
+ # Paid but scope grew, budget covers the delta
217
+ print_warning(
218
+ f"Scope increased by [bold]+{loc_delta:,}[/bold] LoC since payment.\n"
219
+ f" Your budget ([bold]{loc_budget:,}[/bold] LoC) can cover the difference."
220
+ )
221
+ if not yes and not Confirm.ask("Deduct from budget and start?", default=True):
222
+ print_info("Audit start cancelled.")
223
+ raise typer.Exit()
224
+ use_budget = True
225
+
226
+ elif can_start and not is_paid:
227
+ # Not paid, budget covers the full audit
228
+ print_info(
229
+ f"Using LoC budget: [bold]{total_loc:,}[/bold] LoC "
230
+ f"(budget: {loc_budget:,} LoC)."
231
+ )
232
+ if not yes and not Confirm.ask("Deduct from budget and start?", default=True):
233
+ print_info("Audit start cancelled.")
234
+ raise typer.Exit()
235
+ use_budget = True
236
+
237
+ elif payment_required and is_paid and loc_delta > 0:
238
+ # Paid but scope grew beyond budget — need additional payment
239
+ shortfall = loc_delta - loc_budget
240
+ print_warning(
241
+ f"Scope increased by [bold]+{loc_delta:,}[/bold] LoC since payment.\n"
242
+ f" Budget: {loc_budget:,} LoC | Shortfall: [bold red]{shortfall:,}[/bold red] LoC\n"
243
+ f" Additional payment is needed for the LoC difference."
244
+ )
245
+ if not Confirm.ask("Open payment page in your browser?", default=True):
246
+ print_info("Audit start cancelled. You can pay at [bold cyan]codedd.ai[/bold cyan].")
247
+ raise typer.Exit()
248
+
249
+ paid_ok = _handle_checkout(cfg, audit_uuid, loc_delta, show=show)
250
+ if not paid_ok:
251
+ raise typer.Exit(code=1)
252
+
253
+ elif payment_required and not is_paid:
254
+ # Not paid, budget insufficient — full payment needed
255
+ print_warning(
256
+ f"LoC budget insufficient: [bold]{loc_budget:,}[/bold] available, "
257
+ f"[bold]{total_loc:,}[/bold] needed.\n"
258
+ f" Payment required for this audit."
259
+ )
260
+ if not Confirm.ask("Open payment page in your browser?", default=True):
261
+ print_info("Audit start cancelled. You can pay at [bold cyan]codedd.ai[/bold cyan].")
262
+ raise typer.Exit()
263
+
264
+ paid_ok = _handle_checkout(cfg, audit_uuid, total_loc, show=show)
265
+ if not paid_ok:
266
+ raise typer.Exit(code=1)
267
+
268
+ else:
269
+ # Fallback — preflight said cannot start
270
+ print_error(preflight.get("reason", "Audit cannot be started."))
271
+ raise typer.Exit(code=1)
272
+
273
+ # -----------------------------------------------------------------------
274
+ # Phase 4 — Budget deduction (if applicable)
275
+ # -----------------------------------------------------------------------
276
+ if use_budget:
277
+ start_payload = {
278
+ "audit_uuid": audit_uuid,
279
+ "use_budget": True,
280
+ "local_execution": True, # budget only — CLI drives the audit locally
281
+ }
282
+ if show:
283
+ confirmed = review_payload(
284
+ start_payload,
285
+ command_label="Audit Start — Budget Deduction",
286
+ context_note="This payload tells CodeDD to deduct LoC from your budget. Only audit UUID and budget flag are sent. No server-side audit is triggered.",
287
+ confirm_prompt="Proceed with budget deduction on CodeDD?",
288
+ )
289
+ if not confirmed:
290
+ print_info("Cancelled.")
291
+ raise typer.Exit(code=0)
292
+
293
+ with Progress(
294
+ SpinnerColumn(),
295
+ TextColumn("[progress.description]{task.description}"),
296
+ console=console,
297
+ transient=True,
298
+ ) as progress:
299
+ progress.add_task("Deducting budget…", total=None)
300
+ with CodeDDClient(config=cfg) as client:
301
+ resp = client.post(Endpoints.AUDIT_START, json=start_payload)
302
+
303
+ if resp.status_code != 200:
304
+ msg = "Failed to start audit (budget deduction)"
305
+ try:
306
+ msg = resp.json().get("message", msg)
307
+ except Exception:
308
+ pass
309
+ print_error(msg)
310
+ raise typer.Exit(code=1)
311
+
312
+ body = resp.json()
313
+ if body.get("status") != "success":
314
+ print_error(body.get("message", "Unknown error during budget deduction"))
315
+ raise typer.Exit(code=1)
316
+
317
+ loc_deducted = body.get("loc_deducted", 0)
318
+ if loc_deducted:
319
+ print_info(f" {loc_deducted:,} LoC deducted from budget")
320
+
321
+ # -----------------------------------------------------------------------
322
+ # Phase 5 — Local file audit
323
+ # -----------------------------------------------------------------------
324
+ _run_local_audit(
325
+ cfg,
326
+ audit_uuid,
327
+ show=show,
328
+ debug_llm=debug_llm,
329
+ debug_llm_full_prompt=debug_llm_full_prompt,
330
+ )
331
+
332
+
333
+ # ---------------------------------------------------------------------------
334
+ # Helpers
335
+ # ---------------------------------------------------------------------------
336
+
337
+ def _check_llm_keys(cfg: ConfigManager) -> bool:
338
+ """
339
+ Verify that at least one LLM API key is configured and matches the
340
+ provider preference.
341
+
342
+ Displays which provider(s) will be used. Returns ``False`` if no
343
+ usable key is found.
344
+ """
345
+ preference = cfg.llm_provider
346
+ configured = LLMKeyManager.get_configured_providers()
347
+
348
+ if not configured:
349
+ print_error(
350
+ "No LLM API keys configured.\n"
351
+ " CodeDD uses LLMs (Anthropic or OpenAI) to run the source code audit.\n"
352
+ " Add an API key with [bold cyan]codedd config set-key[/bold cyan]."
353
+ )
354
+ return False
355
+
356
+ # Determine which providers will actually be used
357
+ if preference == "both":
358
+ active = configured # use whatever is available
359
+ elif preference in configured:
360
+ active = [preference]
361
+ else:
362
+ # Preference set to a provider without a key — fall back to what's available
363
+ active = configured
364
+ console.print(
365
+ f" [yellow]{SYMBOL_WARN}[/yellow] Preferred provider "
366
+ f"[bold]{preference}[/bold] has no key stored. "
367
+ f"Falling back to: {', '.join(active)}"
368
+ )
369
+
370
+ # Display summary
371
+ primary = active[0]
372
+ fallback = active[1] if len(active) > 1 else None
373
+ model_primary = PROVIDER_MODELS.get(primary, "")
374
+ info_parts = [f"[bold]{primary}[/bold] [dim]({model_primary})[/dim]"]
375
+ if fallback:
376
+ model_fallback = PROVIDER_MODELS.get(fallback, "")
377
+ info_parts.append(f"fallback: [bold]{fallback}[/bold] [dim]({model_fallback})[/dim]")
378
+
379
+ console.print(
380
+ f" [green]{SYMBOL_OK}[/green] LLM provider(s): {' | '.join(info_parts)}"
381
+ )
382
+ return True
383
+
384
+
385
+ def _auto_sync(cfg: ConfigManager, auto_confirm: bool = False, show: bool = False) -> bool:
386
+ """
387
+ Run an automatic scope sync. If changes are detected, prompt to
388
+ re-confirm.
389
+
390
+ Returns True if sync is OK (no changes or changes were re-confirmed).
391
+ """
392
+ # Import sync internals from scope_cmd
393
+ from codedd_cli.commands.scope_cmd import (
394
+ _compute_diff,
395
+ _run_reconfirm,
396
+ )
397
+ from codedd_cli.scanner.file_walker import scan_repository
398
+
399
+ dirs = cfg.scope_directories
400
+ if not dirs:
401
+ print_warning("No directories in scope. Run [bold cyan]codedd scope add[/bold cyan] first.")
402
+ return False
403
+
404
+ audit_uuid = cfg.active_audit_uuid
405
+
406
+ console.print("[dim]Phase 1 — Syncing scope with CodeDD…[/dim]\n")
407
+
408
+ if show:
409
+ confirmed = review_request(
410
+ "GET",
411
+ Endpoints.SCOPE_FILES,
412
+ params={"audit_uuid": audit_uuid},
413
+ command_label="Audit Start — Sync Scope",
414
+ context_note="This request fetches the registered scope from CodeDD to detect local changes. Only the audit UUID is sent.",
415
+ confirm_prompt="Proceed with this request to CodeDD?",
416
+ )
417
+ if not confirmed:
418
+ print_info("Cancelled.")
419
+ return False
420
+
421
+ # Fetch remote state
422
+ with Progress(
423
+ SpinnerColumn(),
424
+ TextColumn("[progress.description]{task.description}"),
425
+ console=console,
426
+ transient=True,
427
+ ) as progress:
428
+ progress.add_task("Fetching remote scope…", total=None)
429
+ with CodeDDClient(config=cfg) as client:
430
+ resp = client.get(Endpoints.SCOPE_FILES, params={"audit_uuid": audit_uuid})
431
+
432
+ if resp.status_code != 200:
433
+ print_warning("Could not fetch remote scope. Skipping sync.")
434
+ return True # Non-fatal — let pre-flight catch issues
435
+
436
+ body = resp.json()
437
+ if body.get("status") != "success":
438
+ print_warning("Remote scope fetch returned non-success. Skipping sync.")
439
+ return True
440
+
441
+ remote_sub_audits = body.get("sub_audits", [])
442
+ remote_by_name: dict[str, list[dict]] = {}
443
+ for sa in remote_sub_audits:
444
+ remote_by_name[sa.get("repo_name", "")] = sa.get("files", [])
445
+
446
+ # Scan local and compute diffs
447
+ has_changes = False
448
+ for entry in dirs:
449
+ repo_path = entry["path"]
450
+ repo_name = entry.get("repo_name", Path(repo_path).name)
451
+
452
+ result = scan_repository(repo_path)
453
+
454
+ local_files = {
455
+ f.relative_path: {
456
+ "lines_of_code": f.lines_of_code,
457
+ "lines_of_doc": f.lines_of_doc,
458
+ }
459
+ for f in result.files
460
+ }
461
+
462
+ remote_files = {}
463
+ for rf in remote_by_name.get(repo_name, []):
464
+ remote_files[rf["relative_path"]] = {
465
+ "lines_of_code": rf.get("lines_of_code", 0),
466
+ "lines_of_doc": rf.get("lines_of_doc", 0),
467
+ }
468
+
469
+ diff = _compute_diff(local_files, remote_files)
470
+ if diff["added"] or diff["removed"] or diff["changed"]:
471
+ has_changes = True
472
+ total_changes = len(diff["added"]) + len(diff["removed"]) + len(diff["changed"])
473
+ console.print(
474
+ f" [bold yellow]{SYMBOL_WARN}[/bold yellow] [bold]{repo_name}[/bold] "
475
+ f"{total_changes} change(s) detected"
476
+ )
477
+ else:
478
+ console.print(
479
+ f" [bold green]{SYMBOL_OK}[/bold green] [bold]{repo_name}[/bold] in sync"
480
+ )
481
+
482
+ if has_changes:
483
+ console.print()
484
+ print_warning("Local files have changed since last registration.")
485
+ if auto_confirm or Confirm.ask("Re-confirm scope now?", default=True):
486
+ reconfirm_ok = _run_reconfirm(cfg, show=show)
487
+ if not reconfirm_ok:
488
+ print_error("Scope re-registration failed. Cannot start audit.")
489
+ return False
490
+ return True
491
+ else:
492
+ return False
493
+
494
+ return True
495
+
496
+
497
+ def _fetch_preflight(client: CodeDDClient, audit_uuid: str) -> dict | None:
498
+ """
499
+ Call the pre-flight endpoint and return the parsed response dict.
500
+ Returns None on network/auth failure (error is already printed).
501
+ """
502
+ with Progress(
503
+ SpinnerColumn(),
504
+ TextColumn("[progress.description]{task.description}"),
505
+ console=console,
506
+ transient=True,
507
+ ) as progress:
508
+ progress.add_task("Running pre-flight checks…", total=None)
509
+ resp = client.get(
510
+ Endpoints.AUDIT_CAN_START,
511
+ params={"audit_uuid": audit_uuid},
512
+ )
513
+
514
+ if resp.status_code == 401:
515
+ print_error("Authentication failed. Run [bold cyan]codedd auth login[/bold cyan].")
516
+ return None
517
+
518
+ try:
519
+ return resp.json()
520
+ except Exception:
521
+ print_error("Invalid response from server.")
522
+ return None
523
+
524
+
525
+ def _render_preflight(preflight: dict) -> None:
526
+ """Display a summary table from the pre-flight check.
527
+
528
+ Handles partial responses when the backend blocks early (e.g. not all
529
+ repos scoped) and omits total_lines_of_code, is_paid, loc_budget, etc.
530
+ """
531
+ table = Table(title="Pre-flight Summary", show_header=True, padding=(0, 1))
532
+ table.add_column("Item", style="bold")
533
+ table.add_column("Value", justify="right")
534
+
535
+ total_loc = preflight.get("total_lines_of_code")
536
+ if total_loc is not None:
537
+ table.add_row("Total LoC", f"{total_loc:,}")
538
+ table.add_row("Repositories", str(len(preflight.get("sub_audits", []))))
539
+
540
+ is_paid = preflight.get("is_paid")
541
+ if is_paid is not None:
542
+ table.add_row(
543
+ "Payment",
544
+ "[green]Paid[/green]" if is_paid else "[yellow]Unpaid[/yellow]",
545
+ )
546
+ if is_paid and preflight.get("lines_purchased", 0) > 0:
547
+ table.add_row("Lines purchased", f"{preflight['lines_purchased']:,}")
548
+
549
+ loc_delta = preflight.get("loc_delta", 0)
550
+ if loc_delta is not None and loc_delta > 0:
551
+ table.add_row("Scope delta", f"[yellow]+{loc_delta:,}[/yellow]")
552
+
553
+ loc_budget = preflight.get("loc_budget")
554
+ if loc_budget is not None:
555
+ table.add_row("LoC budget", f"{loc_budget:,}")
556
+
557
+ table.add_row(
558
+ "Status",
559
+ "[green]Ready[/green]" if preflight.get("can_start") else "[red]Blocked[/red]",
560
+ )
561
+
562
+ console.print(table)
563
+
564
+
565
+ def _handle_checkout(
566
+ cfg: ConfigManager,
567
+ audit_uuid: str,
568
+ lines_of_code: int,
569
+ show: bool = False,
570
+ ) -> bool:
571
+ """
572
+ Create a Stripe checkout session, open in the browser, and poll
573
+ until payment is confirmed.
574
+
575
+ Returns True if payment was confirmed, False otherwise.
576
+ """
577
+ console.print()
578
+
579
+ checkout_payload = {"audit_uuid": audit_uuid, "lines_of_code": lines_of_code}
580
+ if show:
581
+ confirmed = review_payload(
582
+ checkout_payload,
583
+ command_label="Audit Checkout",
584
+ context_note="This payload requests a payment checkout session for the given audit and line count.",
585
+ confirm_prompt="Proceed with creating the payment session on CodeDD?",
586
+ )
587
+ if not confirmed:
588
+ print_info("Cancelled.")
589
+ return False
590
+
591
+ # Create checkout session
592
+ with Progress(
593
+ SpinnerColumn(),
594
+ TextColumn("[progress.description]{task.description}"),
595
+ console=console,
596
+ transient=True,
597
+ ) as progress:
598
+ progress.add_task("Creating payment session…", total=None)
599
+ with CodeDDClient(config=cfg) as client:
600
+ resp = client.post(
601
+ Endpoints.AUDIT_CHECKOUT,
602
+ json=checkout_payload,
603
+ )
604
+
605
+ if resp.status_code != 200:
606
+ msg = "Failed to create checkout session"
607
+ try:
608
+ msg = resp.json().get("message", msg)
609
+ except Exception:
610
+ pass
611
+ print_error(msg)
612
+ return False
613
+
614
+ body = resp.json()
615
+ checkout_url = body.get("checkout_url", "")
616
+ if not checkout_url:
617
+ print_error("No checkout URL received from server.")
618
+ return False
619
+
620
+ # Open browser
621
+ print_info(f"Opening payment page…\n [link={checkout_url}]{checkout_url}[/link]\n")
622
+ try:
623
+ webbrowser.open(checkout_url)
624
+ except Exception:
625
+ print_warning("Could not open browser automatically. Please visit the URL above.")
626
+
627
+ # Poll for payment confirmation
628
+ console.print("[dim]Waiting for payment confirmation… (Ctrl+C to cancel)[/dim]\n")
629
+
630
+ poll_interval = 5 # seconds
631
+ max_wait = 600 # 10 minutes
632
+ elapsed = 0
633
+
634
+ try:
635
+ with Progress(
636
+ SpinnerColumn(),
637
+ TextColumn("[progress.description]{task.description}"),
638
+ console=console,
639
+ transient=True,
640
+ ) as progress:
641
+ task = progress.add_task("Waiting for payment…", total=None)
642
+
643
+ while elapsed < max_wait:
644
+ time.sleep(poll_interval)
645
+ elapsed += poll_interval
646
+
647
+ with CodeDDClient(config=cfg) as client:
648
+ poll_resp = client.get(
649
+ Endpoints.AUDIT_PAYMENT_STATUS,
650
+ params={"audit_uuid": audit_uuid},
651
+ )
652
+
653
+ if poll_resp.status_code == 200:
654
+ poll_body = poll_resp.json()
655
+ if poll_body.get("is_paid"):
656
+ print_success("Payment confirmed!")
657
+ return True
658
+
659
+ progress.update(
660
+ task, description=f"Waiting for payment… ({elapsed}s)"
661
+ )
662
+
663
+ except KeyboardInterrupt:
664
+ console.print()
665
+ print_warning("Payment polling cancelled.")
666
+ print_info("You can complete payment at [bold cyan]codedd.ai[/bold cyan] and retry.")
667
+ return False
668
+
669
+ print_warning(
670
+ "Payment was not confirmed within the timeout.\n"
671
+ " Complete payment at [bold cyan]codedd.ai[/bold cyan] and run this command again."
672
+ )
673
+ return False
674
+
675
+
676
+ # ---------------------------------------------------------------------------
677
+ # Local audit execution
678
+ # ---------------------------------------------------------------------------
679
+
680
+ def _run_local_audit(
681
+ cfg: ConfigManager,
682
+ audit_uuid: str,
683
+ show: bool = False,
684
+ debug_llm: bool = False,
685
+ debug_llm_full_prompt: bool = False,
686
+ ) -> None:
687
+ """
688
+ Execute the full local audit flow:
689
+ 1. Fetch the audit plan from CodeDD (file list + system prompt).
690
+ 2. Audit each file locally via LLM (with concurrency + progress bar).
691
+ 3. Submit results in batches to CodeDD.
692
+ 4. Signal completion (triggers post-processing Steps 7-9).
693
+ """
694
+ console.print()
695
+
696
+ # ---- Step 1: Fetch audit plan ----------------------------------------
697
+ console.print("[bold]Fetching audit plan from CodeDD…[/bold]\n")
698
+
699
+ if show:
700
+ confirmed = review_request(
701
+ "GET",
702
+ Endpoints.AUDIT_PLAN,
703
+ params={"audit_uuid": audit_uuid},
704
+ command_label="Audit Plan",
705
+ context_note=(
706
+ "This request fetches the audit execution plan (file list, "
707
+ "system prompt, config) from CodeDD. Only the audit UUID is sent."
708
+ ),
709
+ confirm_prompt="Proceed with fetching the audit plan?",
710
+ )
711
+ if not confirmed:
712
+ print_info("Cancelled.")
713
+ raise typer.Exit(code=0)
714
+
715
+ with Progress(
716
+ SpinnerColumn(),
717
+ TextColumn("[progress.description]{task.description}"),
718
+ console=console,
719
+ transient=True,
720
+ ) as progress:
721
+ progress.add_task("Downloading plan…", total=None)
722
+ with CodeDDClient(config=cfg) as client:
723
+ resp = client.get(
724
+ Endpoints.AUDIT_PLAN,
725
+ params={"audit_uuid": audit_uuid},
726
+ )
727
+
728
+ if resp.status_code != 200:
729
+ msg = "Failed to fetch audit plan"
730
+ try:
731
+ msg = resp.json().get("message", msg)
732
+ except Exception:
733
+ pass
734
+ print_error(msg)
735
+ raise typer.Exit(code=1)
736
+
737
+ plan = resp.json()
738
+ if plan.get("status") != "success":
739
+ print_error(plan.get("message", "Unexpected response from server"))
740
+ raise typer.Exit(code=1)
741
+
742
+ system_prompt = plan.get("prompts", {}).get("file_audit", "")
743
+ vulnerability_validation_prompt = plan.get("prompts", {}).get("vulnerability_validation", "")
744
+ if not system_prompt:
745
+ print_error("Server returned an empty system prompt. Cannot audit.")
746
+ raise typer.Exit(code=1)
747
+
748
+ sub_audits = plan.get("sub_audits", [])
749
+ plan_config = plan.get("config", {})
750
+ batch_size = plan_config.get("batch_size", 10)
751
+ server_max = plan_config.get("max_concurrent", 8)
752
+ # Use the local config value, capped by the server-side maximum
753
+ max_concurrent = min(cfg.llm_concurrency, server_max)
754
+
755
+ # Flatten file list and build scope_dirs mapping
756
+ all_files: list[dict] = []
757
+ scope_dirs: dict[str, str] = {}
758
+ total_loc = 0
759
+
760
+ local_dirs = cfg.scope_directories
761
+ dir_by_name = {e.get("repo_name", ""): e.get("path", "") for e in local_dirs}
762
+
763
+ for sa in sub_audits:
764
+ repo_name = sa.get("repo_name", "")
765
+ local_path = dir_by_name.get(repo_name, "")
766
+ scope_dirs[repo_name] = local_path
767
+
768
+ for f in sa.get("files", []):
769
+ f["repo_name"] = repo_name
770
+ f["sub_audit_uuid"] = sa["audit_uuid"]
771
+ all_files.append(f)
772
+ total_loc += f.get("lines_of_code", 0)
773
+
774
+ if not all_files:
775
+ print_warning("No files to audit. Check your scope selection.")
776
+ raise typer.Exit(code=0)
777
+
778
+ # Retrieve LLM API keys now (keyring can be slow on Windows; do it before "Auditing files…")
779
+ with Progress(
780
+ SpinnerColumn(),
781
+ TextColumn("[progress.description]{task.description}"),
782
+ console=console,
783
+ transient=True,
784
+ ) as progress:
785
+ progress.add_task("Retrieving API keys…", total=None)
786
+ anthropic_key = LLMKeyManager.retrieve_key("anthropic")
787
+ openai_key = LLMKeyManager.retrieve_key("openai")
788
+
789
+ # Display summary
790
+ repo_names = [sa.get("repo_name", "?") for sa in sub_audits]
791
+ console.print(
792
+ f" Repositories: [bold]{len(sub_audits)}[/bold] ({', '.join(repo_names)})"
793
+ )
794
+ console.print(f" Files to audit: [bold]{len(all_files)}[/bold]")
795
+ console.print(f" Total LoC: [bold]{total_loc:,}[/bold]")
796
+ console.print(f" Concurrency: [bold]{max_concurrent}[/bold] parallel LLM calls\n")
797
+
798
+ # ---- Step 2: Audit files locally with progress -----------------------
799
+ console.print("[bold]Auditing files…[/bold]\n")
800
+
801
+ preference = cfg.llm_provider
802
+ # anthropic_key, openai_key already retrieved above (avoids slow keyring delay here)
803
+ provider_stats: dict[str, int] = {}
804
+ failed_files: list[AuditFileResult] = []
805
+ successful_results: list[AuditFileResult] = []
806
+ completed_count = 0
807
+ debug_lines: list[str] = []
808
+ start_time = time.monotonic()
809
+
810
+ # Build a Rich Live display with a progress bar + debug log
811
+ from rich.live import Live
812
+
813
+ def _build_display() -> Table:
814
+ """Build the live display table (progress + last debug lines)."""
815
+ grid = Table.grid(padding=(0, 0))
816
+ grid.add_column()
817
+
818
+ # Progress bar row
819
+ pct = int(completed_count / len(all_files) * 100) if all_files else 0
820
+ elapsed_s = time.monotonic() - start_time
821
+ elapsed_m, elapsed_sec = divmod(int(elapsed_s), 60)
822
+
823
+ bar_width = 30
824
+ filled = int(bar_width * completed_count / len(all_files)) if all_files else 0
825
+ bar = "[green]" + "━" * filled + "[/green]" + "[dim]━[/dim]" * (bar_width - filled)
826
+
827
+ ok_count = len(successful_results)
828
+ fail_count = len(failed_files)
829
+ status_str = f"[green]{ok_count} ok[/green]"
830
+ if fail_count:
831
+ status_str += f" [red]{fail_count} failed[/red]"
832
+
833
+ grid.add_row(
834
+ f" {bar} [bold]{completed_count}[/bold]/{len(all_files)} "
835
+ f"({pct}%) {status_str} "
836
+ f"[dim]{elapsed_m}m {elapsed_sec:02d}s[/dim]"
837
+ )
838
+
839
+ # Last completed file
840
+ if successful_results or failed_files:
841
+ last = (successful_results + failed_files)[-1]
842
+ short = last.relative_path
843
+ if len(short) > 60:
844
+ short = "..." + short[-57:]
845
+ if last.success:
846
+ prov = last.provider_used or "?"
847
+ grid.add_row(
848
+ f" [green]{SYMBOL_OK}[/green] {short} "
849
+ f"[dim]({prov})[/dim]"
850
+ )
851
+ else:
852
+ grid.add_row(
853
+ f" [red]{SYMBOL_FAIL}[/red] {short} "
854
+ f"[dim]{last.error or ''}[/dim]"
855
+ )
856
+
857
+ # Debug log (last 6 lines) — light grey so they don't compete with progress
858
+ if debug_lines:
859
+ grid.add_row("")
860
+ for line in debug_lines[-6:]:
861
+ grid.add_row(f" [{STYLE_DEBUG_LOG}]{line}[/{STYLE_DEBUG_LOG}]")
862
+
863
+ return grid
864
+
865
+ def _on_debug(msg: str) -> None:
866
+ """Receive debug messages from the auditor."""
867
+ debug_lines.append(msg)
868
+ # Keep buffer bounded
869
+ if len(debug_lines) > 50:
870
+ debug_lines[:] = debug_lines[-30:]
871
+
872
+ def _on_progress(result: AuditFileResult) -> None:
873
+ """Callback invoked after each file completes."""
874
+ nonlocal completed_count
875
+ completed_count += 1
876
+ if result.success:
877
+ provider = result.provider_used or "unknown"
878
+ provider_stats[provider] = provider_stats.get(provider, 0) + 1
879
+ successful_results.append(result)
880
+ else:
881
+ failed_files.append(result)
882
+
883
+ def _on_dump_llm(
884
+ full_prompt: str,
885
+ response_text: str,
886
+ audit_data: dict | None,
887
+ none_count: int,
888
+ ) -> None:
889
+ """Print full prompt, raw response, and parse result to the CLI (debug)."""
890
+ sep = "=" * 60
891
+ console.print(f"\n[bold cyan]{sep}[/bold cyan]")
892
+ console.print("[bold cyan] LLM DEBUG DUMP[/bold cyan]")
893
+ console.print(f"[bold cyan]{sep}[/bold cyan]\n")
894
+ console.print("[bold]--- PROMPT sent to LLM ---[/bold]")
895
+ if debug_llm_full_prompt:
896
+ console.print(f"[dim]{full_prompt}[/dim]\n")
897
+ else:
898
+ console.print(
899
+ "[dim][system prompt redacted] Use --debug-llm-full-prompt to include it.[/dim]\n"
900
+ )
901
+ console.print("[bold]--- RAW RESPONSE from LLM ---[/bold]")
902
+ console.print(f"[dim]{response_text}[/dim]\n")
903
+ console.print("[bold]--- PARSED RESULT (response_parser) ---[/bold]")
904
+ console.print(f" none_response_count: [bold]{none_count}[/bold]")
905
+ if audit_data is not None:
906
+ try:
907
+ console.print("[dim]" + json.dumps(audit_data, indent=2) + "[/dim]")
908
+ except (TypeError, ValueError):
909
+ console.print(f"[dim]{audit_data}[/dim]")
910
+ else:
911
+ console.print(" [red]None[/red] (parse failed or incomplete)")
912
+ console.print()
913
+
914
+ with LocalFileAuditor(
915
+ anthropic_key=anthropic_key,
916
+ openai_key=openai_key,
917
+ system_prompt=system_prompt,
918
+ provider_preference=preference,
919
+ max_concurrent=max_concurrent,
920
+ on_debug=_on_debug,
921
+ on_dump_llm=_on_dump_llm if debug_llm else None,
922
+ ) as auditor:
923
+ with Live(
924
+ _build_display(),
925
+ console=console,
926
+ refresh_per_second=4,
927
+ transient=False,
928
+ ) as live:
929
+ # Run a background refresh so the display updates during LLM calls
930
+ import threading
931
+
932
+ _stop_refresh = threading.Event()
933
+
934
+ def _refresh_loop() -> None:
935
+ while not _stop_refresh.is_set():
936
+ live.update(_build_display())
937
+ _stop_refresh.wait(0.3)
938
+
939
+ refresh_thread = threading.Thread(target=_refresh_loop, daemon=True)
940
+ refresh_thread.start()
941
+
942
+ try:
943
+ results = auditor.audit_batch(
944
+ files=all_files,
945
+ scope_dirs=scope_dirs,
946
+ on_progress=_on_progress,
947
+ )
948
+ finally:
949
+ _stop_refresh.set()
950
+ refresh_thread.join(timeout=2)
951
+ live.update(_build_display())
952
+
953
+ elapsed = time.monotonic() - start_time
954
+ console.print()
955
+
956
+ if failed_files:
957
+ print_warning(f"{len(failed_files)} file(s) could not be audited:")
958
+ for f in failed_files[:10]:
959
+ console.print(f" [dim]{f.relative_path}[/dim] — {f.error}")
960
+ if len(failed_files) > 10:
961
+ console.print(f" … and {len(failed_files) - 10} more")
962
+ console.print()
963
+
964
+ if not successful_results:
965
+ print_error("No files were successfully audited. Nothing to submit.")
966
+ raise typer.Exit(code=1)
967
+
968
+ # ---- Step 2b: Local complexity analysis (cyclomatic + Halstead) ------
969
+ console.print("[bold]Analysing code complexity…[/bold]\n")
970
+
971
+ cx_debug_lines: list[str] = []
972
+ cx_completed = 0
973
+ cx_ok_count = 0
974
+ cx_fail_count = 0
975
+ cx_start = time.monotonic()
976
+
977
+ def _cx_on_debug(msg: str) -> None:
978
+ cx_debug_lines.append(msg)
979
+ if len(cx_debug_lines) > 30:
980
+ cx_debug_lines[:] = cx_debug_lines[-20:]
981
+
982
+ def _cx_build_display() -> Table:
983
+ grid = Table.grid(padding=(0, 0))
984
+ grid.add_column()
985
+ pct = int(cx_completed / len(all_files) * 100) if all_files else 0
986
+ elapsed_s = time.monotonic() - cx_start
987
+ elapsed_m, elapsed_sec = divmod(int(elapsed_s), 60)
988
+ bar_w = 30
989
+ filled = int(bar_w * cx_completed / len(all_files)) if all_files else 0
990
+ bar = "[green]" + "━" * filled + "[/green]" + "[dim]━[/dim]" * (bar_w - filled)
991
+ status_str = f"[green]{cx_ok_count} ok[/green]"
992
+ if cx_fail_count:
993
+ status_str += f" [red]{cx_fail_count} skipped[/red]"
994
+ grid.add_row(
995
+ f" {bar} [bold]{cx_completed}[/bold]/{len(all_files)} "
996
+ f"({pct}%) {status_str} "
997
+ f"[dim]{elapsed_m}m {elapsed_sec:02d}s[/dim]"
998
+ )
999
+ if cx_debug_lines:
1000
+ for line in cx_debug_lines[-3:]:
1001
+ grid.add_row(f" [{STYLE_DEBUG_LOG}]{line}[/{STYLE_DEBUG_LOG}]")
1002
+ return grid
1003
+
1004
+ cx_results: list[FileComplexityResult] = []
1005
+
1006
+ def _cx_on_progress(result: FileComplexityResult) -> None:
1007
+ nonlocal cx_completed, cx_ok_count, cx_fail_count
1008
+ cx_completed += 1
1009
+ if result.success:
1010
+ cx_ok_count += 1
1011
+ else:
1012
+ cx_fail_count += 1
1013
+ cx_results.append(result)
1014
+
1015
+ cx_analyzer = LocalComplexityAnalyzer(
1016
+ max_workers=min(max_concurrent, os.cpu_count() or 4, 8),
1017
+ on_debug=_cx_on_debug,
1018
+ )
1019
+
1020
+ with Live(
1021
+ _cx_build_display(),
1022
+ console=console,
1023
+ refresh_per_second=4,
1024
+ transient=False,
1025
+ ) as cx_live:
1026
+ import threading as _cx_threading
1027
+
1028
+ _cx_stop = _cx_threading.Event()
1029
+
1030
+ def _cx_refresh() -> None:
1031
+ while not _cx_stop.is_set():
1032
+ cx_live.update(_cx_build_display())
1033
+ _cx_stop.wait(0.3)
1034
+
1035
+ cx_refresh_t = _cx_threading.Thread(target=_cx_refresh, daemon=True)
1036
+ cx_refresh_t.start()
1037
+ try:
1038
+ cx_results = cx_analyzer.analyze_batch(
1039
+ files=all_files,
1040
+ scope_dirs=scope_dirs,
1041
+ on_progress=_cx_on_progress,
1042
+ )
1043
+ finally:
1044
+ _cx_stop.set()
1045
+ cx_refresh_t.join(timeout=2)
1046
+ cx_live.update(_cx_build_display())
1047
+
1048
+ cx_elapsed = time.monotonic() - cx_start
1049
+ cx_ok = [r for r in cx_results if r.success]
1050
+ console.print()
1051
+ if cx_ok:
1052
+ console.print(
1053
+ f" [green]{SYMBOL_OK}[/green] Complexity analysed: "
1054
+ f"[bold]{len(cx_ok)}[/bold] files "
1055
+ f"[dim]({int(cx_elapsed)}s)[/dim]"
1056
+ )
1057
+ if cx_fail_count:
1058
+ console.print(
1059
+ f" [yellow]{SYMBOL_WARN}[/yellow] {cx_fail_count} file(s) skipped "
1060
+ f"(non-source or unreadable)"
1061
+ )
1062
+ console.print()
1063
+
1064
+ # ---- Step 2c: Dependency scanning (manifests + imports + OSV) ---------
1065
+ console.print("[bold]Scanning dependencies…[/bold]\n")
1066
+
1067
+ dep_debug_lines: list[str] = []
1068
+ dep_start = time.monotonic()
1069
+
1070
+ def _dep_on_debug(msg: str) -> None:
1071
+ dep_debug_lines.append(msg)
1072
+ if len(dep_debug_lines) > 30:
1073
+ dep_debug_lines[:] = dep_debug_lines[-20:]
1074
+
1075
+ def _dep_build_display() -> Table:
1076
+ grid = Table.grid(padding=(0, 0))
1077
+ grid.add_column()
1078
+ elapsed_s = time.monotonic() - dep_start
1079
+ elapsed_m, elapsed_sec = divmod(int(elapsed_s), 60)
1080
+ if dep_debug_lines:
1081
+ for line in dep_debug_lines[-4:]:
1082
+ grid.add_row(f" [{STYLE_DEBUG_LOG}]{line}[/{STYLE_DEBUG_LOG}]")
1083
+ grid.add_row(f" [dim]{elapsed_m}m {elapsed_sec:02d}s[/dim]")
1084
+ return grid
1085
+
1086
+ # Fetch dependency config from an authenticated internal endpoint.
1087
+ # 404 means backend doesn't support it yet; defaults remain safe.
1088
+ dep_config: dict = {}
1089
+ try:
1090
+ with CodeDDClient(config=cfg) as client:
1091
+ dep_cfg_resp = client.get(Endpoints.AUDIT_DEPENDENCY_CONFIG)
1092
+ if dep_cfg_resp.status_code == 200:
1093
+ dep_cfg_body = dep_cfg_resp.json()
1094
+ if dep_cfg_body.get("status") == "success":
1095
+ dep_config = dep_cfg_body.get("config", {})
1096
+ _dep_on_debug(f"Dependency config loaded (version {dep_cfg_body.get('version', '?')})")
1097
+ elif dep_cfg_resp.status_code == 404:
1098
+ _dep_on_debug("Server does not support dependency config (404) — using defaults")
1099
+ if not dep_config and dep_cfg_resp.status_code != 404:
1100
+ _dep_on_debug("Warning: could not fetch dependency config — using defaults")
1101
+ except Exception as dep_cfg_exc:
1102
+ _dep_on_debug(f"Warning: dependency config fetch failed: {dep_cfg_exc}")
1103
+
1104
+ dep_scanner = LocalDependencyScanner(config=dep_config, on_debug=_dep_on_debug)
1105
+
1106
+ manifest_results: list[ManifestResult] = []
1107
+ import_results: list[ImportResult] = []
1108
+ vuln_results: dict[str, dict] = {}
1109
+
1110
+ with Live(
1111
+ _dep_build_display(),
1112
+ console=console,
1113
+ refresh_per_second=4,
1114
+ transient=False,
1115
+ ) as dep_live:
1116
+ import threading as _dep_threading
1117
+
1118
+ _dep_stop = _dep_threading.Event()
1119
+
1120
+ def _dep_refresh() -> None:
1121
+ while not _dep_stop.is_set():
1122
+ dep_live.update(_dep_build_display())
1123
+ _dep_stop.wait(0.3)
1124
+
1125
+ dep_refresh_t = _dep_threading.Thread(target=_dep_refresh, daemon=True)
1126
+ dep_refresh_t.start()
1127
+
1128
+ try:
1129
+ # 1. Scan manifest files
1130
+ _dep_on_debug("Scanning manifest files…")
1131
+ manifest_results = dep_scanner.scan_manifests(scope_dirs)
1132
+
1133
+ total_manifest_pkgs = sum(len(m.packages) for m in manifest_results if not m.error)
1134
+ _dep_on_debug(
1135
+ f"Found {len(manifest_results)} manifest(s), "
1136
+ f"{total_manifest_pkgs} packages"
1137
+ )
1138
+
1139
+ # 2. Extract imports from source files
1140
+ _dep_on_debug("Extracting imports from source files…")
1141
+ import_results = dep_scanner.scan_source_imports(all_files, scope_dirs)
1142
+
1143
+ # 3. Scan vulnerabilities via OSV
1144
+ if manifest_results or import_results:
1145
+ _dep_on_debug("Querying OSV for vulnerabilities…")
1146
+ vuln_results = dep_scanner.scan_vulnerabilities(manifest_results, import_results)
1147
+ vuln_with_issues = sum(
1148
+ 1 for v in vuln_results.values() if v.get("vulnerability_count", 0) > 0
1149
+ )
1150
+ _dep_on_debug(
1151
+ f"Vulnerability scan complete: {len(vuln_results)} packages checked, "
1152
+ f"{vuln_with_issues} with known vulnerabilities"
1153
+ )
1154
+ finally:
1155
+ _dep_stop.set()
1156
+ dep_refresh_t.join(timeout=2)
1157
+ dep_live.update(_dep_build_display())
1158
+
1159
+ dep_elapsed = time.monotonic() - dep_start
1160
+ console.print()
1161
+
1162
+ manifest_ok = [m for m in manifest_results if not m.error]
1163
+ total_dep_pkgs = sum(len(m.packages) for m in manifest_ok)
1164
+ total_import_pkgs = sum(len(r.packages) for r in import_results)
1165
+ vuln_found = sum(1 for v in vuln_results.values() if v.get("vulnerability_count", 0) > 0)
1166
+
1167
+ if manifest_ok or import_results:
1168
+ console.print(
1169
+ f" [green]{SYMBOL_OK}[/green] Dependencies scanned: "
1170
+ f"[bold]{total_dep_pkgs}[/bold] from manifests, "
1171
+ f"[bold]{total_import_pkgs}[/bold] from imports "
1172
+ f"[dim]({int(dep_elapsed)}s)[/dim]"
1173
+ )
1174
+ if vuln_found:
1175
+ console.print(
1176
+ f" [yellow]{SYMBOL_WARN}[/yellow] {vuln_found} package(s) with known vulnerabilities"
1177
+ )
1178
+ else:
1179
+ console.print(
1180
+ f" [dim]{SYMBOL_INFO}[/dim] No dependency manifests found "
1181
+ f"[dim]({int(dep_elapsed)}s)[/dim]"
1182
+ )
1183
+ console.print()
1184
+
1185
+ # ---- Step 2d: Collect and submit git statistics (local repos) -------
1186
+ from codedd_cli.auditor.git_stats_collector import collect_git_statistics
1187
+
1188
+ console.print("[bold]Collecting git statistics…[/bold]\n")
1189
+ git_submitted = 0
1190
+ git_skipped = 0
1191
+ for sa in sub_audits:
1192
+ sub_uuid = sa.get("audit_uuid", "")
1193
+ repo_name = sa.get("repo_name", "")
1194
+ path = scope_dirs.get(repo_name, "")
1195
+ if not path or not os.path.isdir(path):
1196
+ git_skipped += 1
1197
+ continue
1198
+ stats = collect_git_statistics(
1199
+ path,
1200
+ repository_name=repo_name or None,
1201
+ repository_url=None,
1202
+ on_debug=None,
1203
+ )
1204
+ if not stats:
1205
+ git_skipped += 1
1206
+ continue
1207
+ payload = {
1208
+ "audit_uuid": sub_uuid,
1209
+ "repository_name": stats.get("repository_name"),
1210
+ "repository_url": stats.get("repository_url"),
1211
+ "commit_history": stats.get("commit_history", {}),
1212
+ "author_stats": stats.get("author_stats", {}),
1213
+ "merge_stats": stats.get("merge_stats", {}),
1214
+ "branch_stats": stats.get("branch_stats", {}),
1215
+ "meta_info": stats.get("meta_info", {}),
1216
+ "time_based_stats": stats.get("time_based_stats", {}),
1217
+ "release_stats": stats.get("release_stats", {}),
1218
+ "code_churn_stats": stats.get("code_churn_stats", {}),
1219
+ "collaboration_stats": stats.get("collaboration_stats", {}),
1220
+ }
1221
+ try:
1222
+ with CodeDDClient(config=cfg) as client:
1223
+ resp = client.post(Endpoints.AUDIT_GIT_STATISTICS, json=payload)
1224
+ if resp.status_code == 200:
1225
+ git_submitted += 1
1226
+ else:
1227
+ try:
1228
+ msg = resp.json().get("message", "git statistics submission failed")
1229
+ except Exception:
1230
+ msg = "git statistics submission failed"
1231
+ print_warning(f"Git stats for {repo_name}: {msg}")
1232
+ except Exception as e:
1233
+ print_warning(f"Git stats for {repo_name}: {e}")
1234
+ if git_submitted:
1235
+ console.print(
1236
+ f" [green]{SYMBOL_OK}[/green] Git statistics submitted: "
1237
+ f"[bold]{git_submitted}[/bold] repo(s)"
1238
+ )
1239
+ if git_skipped and not git_submitted:
1240
+ console.print(
1241
+ f" [dim]{SYMBOL_INFO}[/dim] No git repositories in scope or collection skipped"
1242
+ )
1243
+ console.print()
1244
+
1245
+ # ---- Step 2e: Architecture analysis (Phase 1+2+synthesis local; server does Phase 3 storage) --
1246
+ from codedd_cli.auditor.architecture_analyzer import run_architecture_analysis
1247
+
1248
+ console.print("[bold]Analysing architecture…[/bold]\n")
1249
+
1250
+ arch_debug_lines: list[str] = []
1251
+ arch_start = time.monotonic()
1252
+
1253
+ def _arch_on_debug(msg: str) -> None:
1254
+ arch_debug_lines.append(msg)
1255
+ if len(arch_debug_lines) > 30:
1256
+ arch_debug_lines[:] = arch_debug_lines[-20:]
1257
+
1258
+ def _arch_on_progress(msg: str) -> None:
1259
+ _arch_on_debug(msg)
1260
+
1261
+ def _arch_build_display() -> Table:
1262
+ grid = Table.grid(padding=(0, 0))
1263
+ grid.add_column()
1264
+ elapsed_s = time.monotonic() - arch_start
1265
+ elapsed_m, elapsed_sec = divmod(int(elapsed_s), 60)
1266
+ if arch_debug_lines:
1267
+ for line in arch_debug_lines[-5:]:
1268
+ grid.add_row(f" [{STYLE_DEBUG_LOG}]{line}[/{STYLE_DEBUG_LOG}]")
1269
+ grid.add_row(f" [dim]{elapsed_m}m {elapsed_sec:02d}s[/dim]")
1270
+ return grid
1271
+
1272
+ arch_submitted = 0
1273
+ arch_components_total = 0
1274
+ arch_relationships_total = 0
1275
+
1276
+ from rich.live import Live as ArchLive
1277
+ import threading as _arch_threading
1278
+
1279
+ with ArchLive(
1280
+ _arch_build_display(),
1281
+ console=console,
1282
+ refresh_per_second=4,
1283
+ transient=False,
1284
+ ) as arch_live:
1285
+ _arch_stop = _arch_threading.Event()
1286
+
1287
+ def _arch_refresh() -> None:
1288
+ while not _arch_stop.is_set():
1289
+ arch_live.update(_arch_build_display())
1290
+ _arch_stop.wait(0.3)
1291
+
1292
+ arch_refresh_t = _arch_threading.Thread(target=_arch_refresh, daemon=True)
1293
+ arch_refresh_t.start()
1294
+
1295
+ try:
1296
+ for sa in sub_audits:
1297
+ sub_uuid = sa.get("audit_uuid", "")
1298
+ repo_name = sa.get("repo_name", "")
1299
+ file_paths = [
1300
+ f["file_path"] for f in all_files if f.get("sub_audit_uuid") == sub_uuid
1301
+ ]
1302
+ if not file_paths:
1303
+ continue
1304
+ try:
1305
+ _arch_on_progress(f"Analysing {repo_name} ({len(file_paths)} files)…")
1306
+ phase1, phase2 = run_architecture_analysis(
1307
+ sub_uuid,
1308
+ repo_name or "repo",
1309
+ file_paths,
1310
+ scope_dirs=scope_dirs,
1311
+ on_progress=_arch_on_progress,
1312
+ on_debug=_arch_on_debug,
1313
+ )
1314
+
1315
+ n_comp = len(phase2.get("architectural_components") or {})
1316
+ n_rel = len(phase2.get("component_relationships") or [])
1317
+ _arch_on_debug(
1318
+ f"Submitting to CodeDD ({n_comp} components, {n_rel} relationships)…"
1319
+ )
1320
+
1321
+ payload = {
1322
+ "audit_uuid": sub_uuid,
1323
+ "phase1_results": phase1,
1324
+ "phase2_results": phase2,
1325
+ }
1326
+ # Architecture storage is fast now (no server-side LLM calls),
1327
+ # but allow extra time for TypeDB writes on large repos.
1328
+ with CodeDDClient(config=cfg) as client:
1329
+ resp = client.post(
1330
+ Endpoints.AUDIT_ARCHITECTURE, json=payload, timeout=120
1331
+ )
1332
+ if resp.status_code == 200:
1333
+ arch_submitted += 1
1334
+ arch_components_total += n_comp
1335
+ arch_relationships_total += n_rel
1336
+ else:
1337
+ try:
1338
+ msg = resp.json().get("message", "architecture submission failed")
1339
+ except Exception:
1340
+ msg = "architecture submission failed"
1341
+ print_warning(f"Architecture for {repo_name}: {msg}")
1342
+ except Exception as e:
1343
+ print_warning(f"Architecture for {repo_name}: {e}")
1344
+ finally:
1345
+ _arch_stop.set()
1346
+ arch_refresh_t.join(timeout=2)
1347
+ arch_live.update(_arch_build_display())
1348
+
1349
+ arch_elapsed = time.monotonic() - arch_start
1350
+ console.print()
1351
+ if arch_submitted:
1352
+ console.print(
1353
+ f" [green]{SYMBOL_OK}[/green] Architecture analysed: "
1354
+ f"[bold]{arch_components_total}[/bold] components, "
1355
+ f"[bold]{arch_relationships_total}[/bold] relationships "
1356
+ f"([bold]{arch_submitted}[/bold] repo(s)) "
1357
+ f"[dim]({int(arch_elapsed)}s)[/dim]"
1358
+ )
1359
+ console.print()
1360
+
1361
+ # ---- Step 3: Submit results in batches -------------------------------
1362
+ console.print("[bold]Submitting results to CodeDD…[/bold]\n")
1363
+
1364
+ # Group results by sub_audit_uuid
1365
+ results_by_sub: dict[str, list[dict]] = {}
1366
+ for r in successful_results:
1367
+ # Find the sub_audit_uuid from all_files
1368
+ sub_uuid = ""
1369
+ for f in all_files:
1370
+ if f["file_path"] == r.file_path:
1371
+ sub_uuid = f.get("sub_audit_uuid", "")
1372
+ break
1373
+ results_by_sub.setdefault(sub_uuid, []).append({
1374
+ "file_path": r.file_path,
1375
+ "audit_data": r.audit_data,
1376
+ })
1377
+
1378
+ total_ingested = 0
1379
+ total_errors = 0
1380
+
1381
+ with Progress(
1382
+ SpinnerColumn(),
1383
+ TextColumn("[progress.description]{task.description}"),
1384
+ BarColumn(bar_width=30),
1385
+ MofNCompleteColumn(),
1386
+ console=console,
1387
+ ) as progress:
1388
+ total_batches = sum(
1389
+ (len(items) + batch_size - 1) // batch_size
1390
+ for items in results_by_sub.values()
1391
+ )
1392
+ task = progress.add_task("Submitting…", total=total_batches)
1393
+
1394
+ with CodeDDClient(config=cfg) as client:
1395
+ for sub_uuid, items in results_by_sub.items():
1396
+ for i in range(0, len(items), batch_size):
1397
+ batch = items[i : i + batch_size]
1398
+ payload = {
1399
+ "audit_uuid": sub_uuid,
1400
+ "results": batch,
1401
+ }
1402
+
1403
+ if show:
1404
+ confirmed = review_payload(
1405
+ {"audit_uuid": sub_uuid, "results_count": len(batch)},
1406
+ command_label=f"Submit Batch ({i // batch_size + 1})",
1407
+ context_note=(
1408
+ "This payload submits structured audit results "
1409
+ "(field names + values, NO source code) to CodeDD."
1410
+ ),
1411
+ confirm_prompt="Submit this batch?",
1412
+ )
1413
+ if not confirmed:
1414
+ print_info("Batch skipped.")
1415
+ progress.advance(task)
1416
+ continue
1417
+
1418
+ try:
1419
+ resp = client.post(
1420
+ Endpoints.AUDIT_RESULTS, json=payload, timeout=90
1421
+ )
1422
+
1423
+ if resp.status_code == 200:
1424
+ body = resp.json()
1425
+ total_ingested += body.get("ingested", 0)
1426
+ total_errors += len(body.get("errors", []))
1427
+ else:
1428
+ msg = "batch submission failed"
1429
+ try:
1430
+ msg = resp.json().get("message", msg)
1431
+ except Exception:
1432
+ pass
1433
+ print_warning(f"Batch error: {msg}")
1434
+ total_errors += len(batch)
1435
+ except Exception as result_exc:
1436
+ print_warning(f"Result batch submission failed: {result_exc}")
1437
+ total_errors += len(batch)
1438
+
1439
+ progress.advance(task)
1440
+
1441
+ console.print()
1442
+ console.print(
1443
+ f" [green]{SYMBOL_OK}[/green] Results submitted: "
1444
+ f"[bold]{total_ingested}[/bold] ingested"
1445
+ + (f", [yellow]{total_errors} error(s)[/yellow]" if total_errors else "")
1446
+ )
1447
+ console.print()
1448
+
1449
+ # ---- Step 3a.1: Run local source-based vulnerability validation --------
1450
+ validation_by_sub: dict[str, list[dict]] = {}
1451
+ file_to_sub_uuid = {f["file_path"]: f.get("sub_audit_uuid", "") for f in all_files}
1452
+ file_to_local_path: dict[str, str] = {}
1453
+ for f in all_files:
1454
+ repo_name = f.get("repo_name", "")
1455
+ rel_path = f.get("relative_path", "")
1456
+ local_root = scope_dirs.get(repo_name, "")
1457
+ if local_root and rel_path:
1458
+ file_to_local_path[f["file_path"]] = os.path.join(
1459
+ local_root, rel_path.replace("/", os.sep).replace("\\", os.sep)
1460
+ )
1461
+
1462
+ validation_candidates: list[ValidationCandidate] = []
1463
+ for r in successful_results:
1464
+ audit_data = r.audit_data or {}
1465
+ flag_color = str(audit_data.get("flag_color", "")).strip().lower()
1466
+ reasons = str(audit_data.get("reasons_of_flag", "")).strip()
1467
+ if flag_color not in {"red", "orange"}:
1468
+ continue
1469
+ if not reasons or reasons.upper() in {"N/A", "NONE"}:
1470
+ continue
1471
+
1472
+ local_path = file_to_local_path.get(r.file_path, "")
1473
+ sub_uuid = file_to_sub_uuid.get(r.file_path, "")
1474
+ if not local_path or not sub_uuid:
1475
+ continue
1476
+
1477
+ revised_time = audit_data.get("time_to_fix_flag")
1478
+ try:
1479
+ revised_time = float(revised_time) if revised_time is not None else None
1480
+ except (TypeError, ValueError):
1481
+ revised_time = None
1482
+
1483
+ validation_candidates.append(
1484
+ ValidationCandidate(
1485
+ file_path=r.file_path,
1486
+ local_path=local_path,
1487
+ hypothesis=reasons,
1488
+ flag_color=flag_color,
1489
+ stored_time_to_fix_hours=revised_time,
1490
+ )
1491
+ )
1492
+
1493
+ if validation_candidates:
1494
+ console.print("[bold]Validating flagged vulnerabilities locally…[/bold]\n")
1495
+ with LocalVulnerabilityValidator(
1496
+ anthropic_key=anthropic_key,
1497
+ openai_key=openai_key,
1498
+ provider_preference=preference,
1499
+ system_prompt=vulnerability_validation_prompt or None,
1500
+ on_debug=None,
1501
+ ) as validator:
1502
+ validation_outcomes = validator.validate(validation_candidates)
1503
+
1504
+ for out in validation_outcomes:
1505
+ sub_uuid = file_to_sub_uuid.get(out.file_path, "")
1506
+ if not sub_uuid:
1507
+ continue
1508
+ validation_by_sub.setdefault(sub_uuid, []).append({
1509
+ "file_path": out.file_path,
1510
+ "conclusion": out.conclusion,
1511
+ "impact": out.impact,
1512
+ "confidence": out.confidence,
1513
+ "recommended_action": out.recommended_action,
1514
+ "revised_time_to_fix_hours": out.revised_time_to_fix_hours,
1515
+ })
1516
+
1517
+ if validation_by_sub:
1518
+ console.print("[bold]Submitting vulnerability validation outcomes…[/bold]\n")
1519
+ validation_applied = 0
1520
+ validation_failed = 0
1521
+ with CodeDDClient(config=cfg) as client:
1522
+ for sub_uuid, validations in validation_by_sub.items():
1523
+ payload = {
1524
+ "audit_uuid": sub_uuid,
1525
+ "validations": validations,
1526
+ }
1527
+ try:
1528
+ resp = client.post(
1529
+ Endpoints.AUDIT_VULNERABILITY_VALIDATION,
1530
+ json=payload,
1531
+ timeout=90,
1532
+ )
1533
+ if resp.status_code == 200:
1534
+ body = resp.json()
1535
+ validation_applied += int(body.get("applied", 0))
1536
+ validation_failed += len(body.get("errors", []))
1537
+ else:
1538
+ validation_failed += len(validations)
1539
+ except Exception:
1540
+ validation_failed += len(validations)
1541
+
1542
+ console.print(
1543
+ f" [green]{SYMBOL_OK}[/green] Validation outcomes submitted: "
1544
+ f"[bold]{validation_applied}[/bold] applied"
1545
+ + (f", [yellow]{validation_failed} failed[/yellow]" if validation_failed else "")
1546
+ )
1547
+ console.print()
1548
+
1549
+ # ---- Step 3b: Submit complexity results --------------------------------
1550
+ if cx_ok:
1551
+ console.print("[bold]Submitting complexity metrics to CodeDD…[/bold]\n")
1552
+
1553
+ # Group by sub_audit_uuid
1554
+ cx_by_sub: dict[str, list[dict]] = {}
1555
+ for r in cx_ok:
1556
+ sub_uuid = ""
1557
+ for f in all_files:
1558
+ if f["file_path"] == r.file_path:
1559
+ sub_uuid = f.get("sub_audit_uuid", "")
1560
+ break
1561
+ cx_by_sub.setdefault(sub_uuid, []).append({
1562
+ "file_path": r.file_path,
1563
+ "metrics": r.metrics,
1564
+ })
1565
+
1566
+ # Compute per-sub-audit aggregated summary
1567
+ cx_ingested = 0
1568
+ cx_errors = 0
1569
+
1570
+ with Progress(
1571
+ SpinnerColumn(),
1572
+ TextColumn("[progress.description]{task.description}"),
1573
+ BarColumn(bar_width=30),
1574
+ MofNCompleteColumn(),
1575
+ console=console,
1576
+ ) as cx_prog:
1577
+ total_cx_batches = sum(
1578
+ (len(items) + batch_size - 1) // batch_size
1579
+ for items in cx_by_sub.values()
1580
+ )
1581
+ cx_task = cx_prog.add_task("Submitting…", total=total_cx_batches)
1582
+
1583
+ with CodeDDClient(config=cfg) as client:
1584
+ for sub_uuid, items in cx_by_sub.items():
1585
+ # Build aggregated summary for this sub-audit
1586
+ agg_input = {}
1587
+ for item in items:
1588
+ fp = item["file_path"]
1589
+ agg_input[fp] = item["metrics"]
1590
+ summary = aggregate_complexity_results(agg_input)
1591
+
1592
+ for i in range(0, len(items), batch_size):
1593
+ batch = items[i : i + batch_size]
1594
+ payload: dict = {
1595
+ "audit_uuid": sub_uuid,
1596
+ "results": batch,
1597
+ }
1598
+ # Attach summary only on the last batch
1599
+ if i + batch_size >= len(items):
1600
+ payload["summary"] = summary
1601
+
1602
+ if show:
1603
+ confirmed = review_payload(
1604
+ {"audit_uuid": sub_uuid, "results_count": len(batch)},
1605
+ command_label=f"Complexity Batch ({i // batch_size + 1})",
1606
+ context_note=(
1607
+ "This payload submits structured complexity metrics "
1608
+ "(cyclomatic + Halstead, NO source code) to CodeDD."
1609
+ ),
1610
+ confirm_prompt="Submit this batch?",
1611
+ )
1612
+ if not confirmed:
1613
+ print_info("Batch skipped.")
1614
+ cx_prog.advance(cx_task)
1615
+ continue
1616
+
1617
+ try:
1618
+ resp = client.post(
1619
+ Endpoints.AUDIT_COMPLEXITY, json=payload, timeout=90
1620
+ )
1621
+ if resp.status_code == 200:
1622
+ body = resp.json()
1623
+ cx_ingested += body.get("ingested", 0)
1624
+ cx_errors += len(body.get("errors", []))
1625
+ else:
1626
+ msg = "complexity batch submission failed"
1627
+ try:
1628
+ msg = resp.json().get("message", msg)
1629
+ except Exception:
1630
+ pass
1631
+ print_warning(f"Complexity batch error: {msg}")
1632
+ cx_errors += len(batch)
1633
+ except Exception as cx_exc:
1634
+ print_warning(f"Complexity batch submission failed: {cx_exc}")
1635
+ cx_errors += len(batch)
1636
+
1637
+ cx_prog.advance(cx_task)
1638
+
1639
+ console.print()
1640
+ console.print(
1641
+ f" [green]{SYMBOL_OK}[/green] Complexity metrics submitted: "
1642
+ f"[bold]{cx_ingested}[/bold] ingested"
1643
+ + (f", [yellow]{cx_errors} error(s)[/yellow]" if cx_errors else "")
1644
+ )
1645
+ console.print()
1646
+
1647
+ # ---- Step 3c: Submit dependency results --------------------------------
1648
+ if manifest_ok or import_results:
1649
+ console.print("[bold]Submitting dependency data to CodeDD…[/bold]\n")
1650
+
1651
+ dep_ingested_pkgs = 0
1652
+ dep_ingested_imports = 0
1653
+ dep_vulns_stored = 0
1654
+ dep_errors = 0
1655
+
1656
+ # Group manifest results by sub_audit_uuid (via repo_name → sub_audit)
1657
+ sub_audit_by_repo: dict[str, str] = {}
1658
+ for sa in sub_audits:
1659
+ sub_audit_by_repo[sa.get("repo_name", "")] = sa["audit_uuid"]
1660
+
1661
+ # Build per-sub-audit payloads
1662
+ dep_by_sub: dict[str, dict] = {}
1663
+ for mr in manifest_ok:
1664
+ sub_uuid = sub_audit_by_repo.get(mr.repo_name, "")
1665
+ if not sub_uuid:
1666
+ continue
1667
+ entry = dep_by_sub.setdefault(sub_uuid, {
1668
+ "manifest_packages": [],
1669
+ "import_packages": [],
1670
+ "vulnerabilities": {},
1671
+ })
1672
+ # Build manifest_path as cli:// path
1673
+ manifest_cli_path = f"cli://{sub_uuid}/{mr.repo_name}/{mr.manifest_path}"
1674
+ entry["manifest_packages"].append({
1675
+ "manifest_path": manifest_cli_path,
1676
+ "registry": mr.registry,
1677
+ "packages": mr.packages,
1678
+ })
1679
+
1680
+ for ir in import_results:
1681
+ # Determine sub_audit_uuid from file_path
1682
+ sub_uuid = ""
1683
+ for f in all_files:
1684
+ if f["file_path"] == ir.file_path:
1685
+ sub_uuid = f.get("sub_audit_uuid", "")
1686
+ break
1687
+ if not sub_uuid:
1688
+ continue
1689
+ entry = dep_by_sub.setdefault(sub_uuid, {
1690
+ "manifest_packages": [],
1691
+ "import_packages": [],
1692
+ "vulnerabilities": {},
1693
+ })
1694
+ entry["import_packages"].append({
1695
+ "file_path": ir.file_path,
1696
+ "registry_prefix": ir.registry_prefix,
1697
+ "packages": ir.packages,
1698
+ })
1699
+
1700
+ # Attach vulnerability data to each sub-audit payload
1701
+ for sub_uuid, entry in dep_by_sub.items():
1702
+ entry["vulnerabilities"] = vuln_results
1703
+
1704
+ dep_endpoint_404_shown = False # Only show once when server doesn't support the endpoint
1705
+
1706
+ with Progress(
1707
+ SpinnerColumn(),
1708
+ TextColumn("[progress.description]{task.description}"),
1709
+ BarColumn(bar_width=30),
1710
+ MofNCompleteColumn(),
1711
+ console=console,
1712
+ ) as dep_prog:
1713
+ dep_task = dep_prog.add_task("Submitting…", total=len(dep_by_sub))
1714
+
1715
+ with CodeDDClient(config=cfg) as client:
1716
+ for sub_uuid, dep_payload in dep_by_sub.items():
1717
+ payload = {
1718
+ "audit_uuid": sub_uuid,
1719
+ **dep_payload,
1720
+ }
1721
+
1722
+ if show:
1723
+ confirmed = review_payload(
1724
+ {
1725
+ "audit_uuid": sub_uuid,
1726
+ "manifest_count": len(dep_payload["manifest_packages"]),
1727
+ "import_count": len(dep_payload["import_packages"]),
1728
+ "vuln_count": len(dep_payload["vulnerabilities"]),
1729
+ },
1730
+ command_label="Dependency Submission",
1731
+ context_note=(
1732
+ "This payload submits structured dependency data "
1733
+ "(package names + versions, NO source code) to CodeDD."
1734
+ ),
1735
+ confirm_prompt="Submit dependency data?",
1736
+ )
1737
+ if not confirmed:
1738
+ print_info("Dependency submission skipped.")
1739
+ dep_prog.advance(dep_task)
1740
+ continue
1741
+
1742
+ try:
1743
+ # Dependency submission involves TypeDB writes on the
1744
+ # server; allow extra time for large manifests.
1745
+ resp = client.post(
1746
+ Endpoints.AUDIT_DEPENDENCIES, json=payload, timeout=120
1747
+ )
1748
+ if resp.status_code == 200:
1749
+ body = resp.json()
1750
+ dep_ingested_pkgs += body.get("ingested_packages", 0)
1751
+ dep_ingested_imports += body.get("ingested_imports", 0)
1752
+ dep_vulns_stored += body.get("vulnerabilities_stored", 0)
1753
+ dep_errors += len(body.get("errors", []))
1754
+ # Metadata enrichment (scorecards, vuln scanning)
1755
+ # runs as part of the Celery chain during post-processing
1756
+ elif resp.status_code == 404:
1757
+ if not dep_endpoint_404_shown:
1758
+ print_warning(
1759
+ "Server does not support dependency submission (404). "
1760
+ "Upgrade the CodeDD backend to enable dependency tracking."
1761
+ )
1762
+ dep_endpoint_404_shown = True
1763
+ else:
1764
+ msg = "dependency submission failed"
1765
+ try:
1766
+ msg = resp.json().get("message", msg)
1767
+ except Exception:
1768
+ pass
1769
+ print_warning(f"Dependency error: {msg}")
1770
+ dep_errors += 1
1771
+ except Exception as dep_submit_exc:
1772
+ print_warning(
1773
+ f"Dependency submission failed for sub-audit "
1774
+ f"{sub_uuid[:8]}…: {dep_submit_exc}"
1775
+ )
1776
+ dep_errors += 1
1777
+
1778
+ dep_prog.advance(dep_task)
1779
+
1780
+ console.print()
1781
+ console.print(
1782
+ f" [green]{SYMBOL_OK}[/green] Dependencies submitted: "
1783
+ f"[bold]{dep_ingested_pkgs}[/bold] packages, "
1784
+ f"[bold]{dep_ingested_imports}[/bold] imports, "
1785
+ f"[bold]{dep_vulns_stored}[/bold] vulns stored"
1786
+ + (f", [yellow]{dep_errors} error(s)[/yellow]" if dep_errors else "")
1787
+ )
1788
+ if dep_ingested_pkgs > 0:
1789
+ console.print(
1790
+ f" [dim]{SYMBOL_INFO}[/dim] "
1791
+ "Package metadata enrichment (registry info, scorecards) "
1792
+ "will run during post-processing on CodeDD."
1793
+ )
1794
+ console.print()
1795
+
1796
+ # ---- Step 4: Signal completion ---------------------------------------
1797
+ console.print("[bold]Triggering post-processing on CodeDD…[/bold]\n")
1798
+
1799
+ complete_payload = {"audit_uuid": audit_uuid, "trigger_next_steps": True}
1800
+ if show:
1801
+ confirmed = review_payload(
1802
+ complete_payload,
1803
+ command_label="Audit Complete",
1804
+ context_note=(
1805
+ "This tells CodeDD that file-level auditing is done and to "
1806
+ "trigger consolidation / recommendations (Steps 7-9)."
1807
+ ),
1808
+ confirm_prompt="Proceed with audit completion?",
1809
+ )
1810
+ if not confirmed:
1811
+ print_info("Cancelled.")
1812
+ raise typer.Exit(code=0)
1813
+
1814
+ with Progress(
1815
+ SpinnerColumn(),
1816
+ TextColumn("[progress.description]{task.description}"),
1817
+ console=console,
1818
+ transient=True,
1819
+ ) as prog:
1820
+ prog.add_task("Finalising…", total=None)
1821
+ with CodeDDClient(config=cfg) as client:
1822
+ resp = client.post(Endpoints.AUDIT_COMPLETE, json=complete_payload)
1823
+
1824
+ if resp.status_code != 200:
1825
+ msg = "Failed to signal audit completion"
1826
+ try:
1827
+ msg = resp.json().get("message", msg)
1828
+ except Exception:
1829
+ pass
1830
+ print_error(msg)
1831
+ raise typer.Exit(code=1)
1832
+
1833
+ complete_body = resp.json()
1834
+ if complete_body.get("status") != "success":
1835
+ print_error(complete_body.get("message", "Unknown error during completion"))
1836
+ raise typer.Exit(code=1)
1837
+
1838
+ # ---- Final summary ---------------------------------------------------
1839
+ console.print()
1840
+ minutes, seconds = divmod(int(elapsed), 60)
1841
+ time_str = f"{minutes}m {seconds}s" if minutes else f"{seconds}s"
1842
+
1843
+ print_success("[bold]Audit complete![/bold]")
1844
+ console.print(f" Files audited: [bold]{len(successful_results)}[/bold]")
1845
+ if cx_ok:
1846
+ console.print(f" Complexity analysed: [bold]{len(cx_ok)}[/bold] files")
1847
+ if arch_submitted:
1848
+ console.print(
1849
+ f" Architecture: [bold]{arch_components_total}[/bold] components, "
1850
+ f"[bold]{arch_relationships_total}[/bold] relationships"
1851
+ )
1852
+ if manifest_ok or import_results:
1853
+ console.print(
1854
+ f" Dependencies: [bold]{total_dep_pkgs}[/bold] packages, "
1855
+ f"[bold]{total_import_pkgs}[/bold] imports"
1856
+ )
1857
+ if vuln_found:
1858
+ console.print(f" Vulnerabilities: [bold yellow]{vuln_found}[/bold yellow] packages affected")
1859
+ console.print(f" Time: [bold]{time_str}[/bold]")
1860
+
1861
+ if provider_stats:
1862
+ provider_parts = []
1863
+ for p, count in sorted(provider_stats.items()):
1864
+ provider_parts.append(f"{p.capitalize()} ({count} files)")
1865
+ console.print(f" Provider: {' | '.join(provider_parts)}")
1866
+
1867
+ if complete_body.get("post_processing_triggered"):
1868
+ console.print(
1869
+ f" [dim]Post-processing (dependency enrichment, consolidation "
1870
+ f"& recommendations) is running on CodeDD.[/dim]"
1871
+ )
1872
+
1873
+ console.print()
1874
+ print_info(
1875
+ "Track results at [bold cyan]codedd.ai[/bold cyan] "
1876
+ "or run [bold cyan]codedd audits list[/bold cyan]."
1877
+ )