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