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,487 @@
1
+ """
2
+ Parse structured LLM audit responses into field dictionaries.
3
+
4
+ This module is the CLI-side equivalent of the server's
5
+ ``AI_Auditor.parse_audit_response``. It converts the plain-text form
6
+ filled out by an LLM into a typed Python dict that matches the CodeDD
7
+ TypeDB schema for ``file`` / ``file_analysis`` entities.
8
+
9
+ All parsing logic is deterministic (regex + string splitting) and
10
+ contains no proprietary IP — the intelligence lives in the system prompt
11
+ served by the server at audit time.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ import re
18
+ from typing import Optional
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Schema mapping: field_name → section prefix in the LLM response
24
+ # ---------------------------------------------------------------------------
25
+ SCHEMA_MAPPING: dict[str, str] = {
26
+ "is_script": "0.",
27
+ "is_script_explanation": "0.1.",
28
+ "script_purpose": "1.1.",
29
+ "domain": "1.2.",
30
+ "summary_all": "1.3.",
31
+ "recommendation": "1.4.",
32
+ "tags": "1.5.",
33
+ "readability": "2.1.",
34
+ "consistency": "2.2.",
35
+ "modularity": "2.3.",
36
+ "maintainability": "2.4.",
37
+ "reusability": "2.5.",
38
+ "redundancy": "2.6.",
39
+ "technical_debt": "2.7.",
40
+ "code_smells": "2.8.",
41
+ "summary_code_quality": "2.9.",
42
+ "completeness": "3.1.",
43
+ "edge_cases": "3.2.",
44
+ "error_handling": "3.3.",
45
+ "summary_functionality": "3.4.",
46
+ "efficiency": "4.1.",
47
+ "scalability": "4.2.",
48
+ "resource_utilization": "4.3.",
49
+ "load_handling": "4.4.",
50
+ "parallel_processing": "4.5.",
51
+ "database_interaction_efficiency": "4.6.",
52
+ "concurrency_management": "4.7.",
53
+ "state_management_efficiency": "4.8.",
54
+ "modularity_decoupling": "4.9.",
55
+ "configuration_customization_ease": "4.10.",
56
+ "summary_perf_scal": "4.11.",
57
+ "input_validation": "5.1.",
58
+ "data_handling": "5.2.",
59
+ "authentication": "5.3.",
60
+ "summary_security": "5.4.",
61
+ "independence": "6.1.",
62
+ "integration": "6.2.",
63
+ "summary_compatibility": "6.3.",
64
+ "inline_comments": "7.1.",
65
+ "summary_documentation": "7.2.",
66
+ "standards": "8.1.",
67
+ "design_patterns": "8.2.",
68
+ "code_complexity": "8.3.",
69
+ "refactoring_opportunities": "8.4.",
70
+ "summary_standards": "8.5.",
71
+ "reasons_of_flag": "9.1.",
72
+ "flag_color": "9.2.",
73
+ "time_to_fix_flag": "9.3.",
74
+ }
75
+
76
+ # Pre-compiled regex for section headers (e.g. "1.1.", "9.3.")
77
+ _SECTION_HEADER_RE = re.compile(r"^\d+\.\d+\.")
78
+
79
+ # Allow optional Markdown around section headers (e.g. "**1.1." or "### 1.1.")
80
+ _MARKDOWN_PREFIX_RE = re.compile(r"^\s*(\*{1,2}\s*|#{1,3}\s*)")
81
+
82
+ # Pre-compiled regex to strip prompt-artefact phrases like "(Max 50 words)"
83
+ _PROMPT_ARTIFACT_RE = re.compile(
84
+ r"\s*\([Mm]ax\.?\s*\d+\s*words?\)\s*:?\s*",
85
+ re.IGNORECASE,
86
+ )
87
+
88
+
89
+ # ---------------------------------------------------------------------------
90
+ # Internal helpers
91
+ # ---------------------------------------------------------------------------
92
+
93
+ def _normalize_response_text(text: str) -> str:
94
+ """Remove prompt-artefact phrases from labels so parsing is robust."""
95
+ if not text:
96
+ return text
97
+ normalized = re.sub(
98
+ r"\s+\([Mm]ax\.?\s*\d+\s*words?\)\s*:?\s*",
99
+ ": ",
100
+ text,
101
+ )
102
+ normalized = re.sub(
103
+ r"\s+\([Mm]ax\.?\s*\d+\s*words?\s*:\s*\)\s*:\s*",
104
+ ": ",
105
+ normalized,
106
+ )
107
+ return normalized
108
+
109
+
110
+ def _normalize_line_for_parsing(line: str) -> str:
111
+ """
112
+ Strip Markdown formatting from a line so section headers are recognized.
113
+
114
+ LLMs often return the audit form in Markdown, e.g.:
115
+ **1.1. Script Purpose (Max 50 words):**
116
+ This script provides...
117
+
118
+ We need lines to start with the section prefix (e.g. "1.1.") for
119
+ startswith() and _SECTION_HEADER_RE to match. This helper:
120
+ - Strips leading ** or ### (and optional space)
121
+ - Replaces ":**" with ":" so the colon separates label from value
122
+ """
123
+ s = line.strip()
124
+ if not s:
125
+ return s
126
+ # Strip leading Markdown bold or heading
127
+ s = _MARKDOWN_PREFIX_RE.sub("", s)
128
+ # Turn ":** " or ":**" into ":" so split(":", 1) gives correct value
129
+ s = s.replace(":**", ":", 1)
130
+ return s
131
+
132
+
133
+ def _clean_value_from_prompt_artifacts(value: str) -> str:
134
+ """Strip prompt-artefact phrases from extracted *values*."""
135
+ if not value:
136
+ return value
137
+ return _PROMPT_ARTIFACT_RE.sub(" ", value).strip()
138
+
139
+
140
+ def _sanitize_text(text: str) -> str:
141
+ """Sanitize an extracted text value (mirrors server-side sanitize_text)."""
142
+ try:
143
+ sanitized = (
144
+ text.replace('"', "")
145
+ .replace("'", "")
146
+ .replace("[", "")
147
+ .replace("]", "")
148
+ .replace("/", "")
149
+ )
150
+ sanitized = sanitized.rstrip(".")
151
+ if sanitized.strip().lower() in ("na", "n/a", "not applicable", "not available"):
152
+ return "N/A"
153
+ return sanitized
154
+ except Exception:
155
+ return text
156
+
157
+
158
+ def _sanitize_domain(text: str) -> str:
159
+ """Sanitize domain values — keep at most two words, strip quotes."""
160
+ try:
161
+ cleaned = text.replace("(", "").replace(")", "").replace("'", "").replace('"', "").replace(",", "")
162
+ words = cleaned.split()
163
+ return " ".join(words[:2]) if len(words) > 2 else cleaned
164
+ except Exception:
165
+ return text
166
+
167
+
168
+ def _parse_time_to_fix(text: str | None) -> float | None:
169
+ """Convert a raw time-to-fix string into a float (hours) or None."""
170
+ if text is None or str(text).strip().lower() in (
171
+ "na", "n/a", "not applicable", "not available", "none",
172
+ ):
173
+ return None
174
+ try:
175
+ return float(text)
176
+ except ValueError:
177
+ match = re.search(r"\d+(\.\d+)?", str(text))
178
+ return float(match.group()) if match else None
179
+
180
+
181
+ def _is_na(value: str | None) -> bool:
182
+ """Return True if *value* is semantically N/A."""
183
+ return str(value or "").strip().lower() in (
184
+ "n/a", "na", "none", "not applicable", "not available",
185
+ )
186
+
187
+
188
+ # ---------------------------------------------------------------------------
189
+ # Public API
190
+ # ---------------------------------------------------------------------------
191
+
192
+ def parse_audit_response(response_text: str) -> tuple[Optional[dict], int]:
193
+ """
194
+ Parse a raw LLM audit response into a structured field dictionary.
195
+
196
+ Args:
197
+ response_text: The complete text output from the LLM.
198
+
199
+ Returns:
200
+ A tuple of ``(audit_data, none_response_count)``.
201
+ ``audit_data`` is ``None`` when the response is fatally incomplete.
202
+ ``none_response_count`` counts fields that came back as ``"None"``.
203
+ """
204
+ if not response_text or not isinstance(response_text, str):
205
+ return None, 999
206
+
207
+ response_text = _normalize_response_text(response_text)
208
+ raw_lines = response_text.split("\n")
209
+ # Normalize lines so Markdown-wrapped headers (e.g. "**1.1. ...:**") are recognized
210
+ response_lines = [_normalize_line_for_parsing(ln) for ln in raw_lines]
211
+
212
+ # ------------------------------------------------------------------
213
+ # Helper: extract value for a section prefix, with continuation lines
214
+ # ------------------------------------------------------------------
215
+ def _parse_response_line(
216
+ point_key: str,
217
+ startswith: str,
218
+ default=None,
219
+ is_time_to_fix: bool = False,
220
+ ) -> tuple[str, object]:
221
+ line_idx = next(
222
+ (i for i, line in enumerate(response_lines) if line.startswith(startswith)),
223
+ None,
224
+ )
225
+ if line_idx is None:
226
+ return (point_key, default)
227
+
228
+ line = response_lines[line_idx]
229
+ parts = line.split(":", 1)
230
+ if len(parts) < 2:
231
+ return (point_key, default)
232
+
233
+ result = parts[1].strip()
234
+
235
+ # If value after colon is empty, use continuation line(s) until next section header
236
+ if not result and line_idx + 1 < len(response_lines):
237
+ continuation: list[str] = []
238
+ for j in range(line_idx + 1, len(response_lines)):
239
+ next_line = response_lines[j]
240
+ if _SECTION_HEADER_RE.match(next_line.strip()):
241
+ break
242
+ continuation.append(next_line)
243
+ result = " ".join(continuation).strip()
244
+
245
+ if not result:
246
+ return (point_key, default)
247
+
248
+ # Clean up extracted value
249
+ if point_key != "domain" and not is_time_to_fix:
250
+ result = _clean_value_from_prompt_artifacts(result)
251
+
252
+ if point_key == "domain":
253
+ result = _sanitize_domain(result)
254
+ elif is_time_to_fix:
255
+ result = _parse_time_to_fix(result)
256
+ else:
257
+ result = _sanitize_text(result)
258
+
259
+ if result != default:
260
+ return (point_key, result)
261
+ return (point_key, default)
262
+
263
+ # ------------------------------------------------------------------
264
+ # Step 1: Check is_script (section 0.)
265
+ # ------------------------------------------------------------------
266
+ audit_data: dict = {}
267
+
268
+ is_script_line = next(
269
+ (line for line in response_lines if line.startswith("0.")),
270
+ None,
271
+ )
272
+ if is_script_line:
273
+ parts = is_script_line.split(":", 1)
274
+ if len(parts) > 1:
275
+ is_script_value = parts[1].strip().lower()
276
+ if is_script_value == "no":
277
+ explanation_line = next(
278
+ (line for line in response_lines if line.startswith("0.1.")),
279
+ None,
280
+ )
281
+ explanation = "N/A"
282
+ if explanation_line:
283
+ exp_parts = explanation_line.split(":", 1)
284
+ if len(exp_parts) > 1:
285
+ explanation = exp_parts[1].strip()
286
+ return {"is_script": "no", "is_script_explanation": explanation}, 0
287
+ elif is_script_value == "yes":
288
+ audit_data["is_script"] = "yes"
289
+
290
+ # ------------------------------------------------------------------
291
+ # Step 2: Parse every section in the schema mapping
292
+ # ------------------------------------------------------------------
293
+ none_response_count = 0
294
+
295
+ for key, startswith in SCHEMA_MAPPING.items():
296
+ if key == "is_script" and "is_script" in audit_data:
297
+ continue
298
+
299
+ point_key, value = _parse_response_line(
300
+ key,
301
+ startswith.strip(),
302
+ default=None,
303
+ is_time_to_fix=False,
304
+ )
305
+
306
+ if value == "None":
307
+ none_response_count += 1
308
+ audit_data[point_key] = value
309
+
310
+ # ------------------------------------------------------------------
311
+ # Step 3: If not a script, return early
312
+ # ------------------------------------------------------------------
313
+ is_script_value = audit_data.get("is_script")
314
+ if is_script_value is not None and str(is_script_value).lower() == "no":
315
+ return audit_data, 0
316
+
317
+ # ------------------------------------------------------------------
318
+ # Step 4: Build structured vulnerabilities list from 9.1/9.2/9.3
319
+ # ------------------------------------------------------------------
320
+ def _extract_value_with_continuation(prefix: str) -> str | None:
321
+ idx = next(
322
+ (i for i, line in enumerate(response_lines) if line.startswith(prefix)),
323
+ None,
324
+ )
325
+ if idx is None:
326
+ return None
327
+ parts = response_lines[idx].split(":", 1)
328
+ value = parts[1].strip() if len(parts) > 1 else ""
329
+ if not value and idx + 1 < len(response_lines):
330
+ continuation: list[str] = []
331
+ for j in range(idx + 1, len(response_lines)):
332
+ next_line = response_lines[j]
333
+ if _SECTION_HEADER_RE.match(next_line.strip()):
334
+ break
335
+ continuation.append(next_line)
336
+ value = " ".join(continuation).strip()
337
+ return value if value else None
338
+
339
+ raw_reasons = _extract_value_with_continuation("9.1.")
340
+ raw_colors = _extract_value_with_continuation("9.2.")
341
+ raw_times = _extract_value_with_continuation("9.3.")
342
+
343
+ any_9x_present = any(x is not None for x in [raw_reasons, raw_colors, raw_times])
344
+ all_9x_na = any_9x_present and all(
345
+ _is_na(v) for v in [raw_reasons, raw_colors, raw_times] if v is not None
346
+ )
347
+
348
+ if all(_is_na(v) for v in [raw_reasons, raw_colors, raw_times]):
349
+ reasons_list: list[str] = []
350
+ colors_list: list[str] = []
351
+ times_list: list[float | None] = []
352
+ else:
353
+ reasons_list = (
354
+ [_sanitize_text(x.strip()) for x in raw_reasons.split(";") if x.strip()]
355
+ if raw_reasons else []
356
+ )
357
+ colors_list = (
358
+ [_sanitize_text(x.strip()) for x in raw_colors.split(";") if x.strip()]
359
+ if raw_colors else []
360
+ )
361
+ if raw_times and not _is_na(raw_times):
362
+ times_list = [_parse_time_to_fix(x.strip()) for x in raw_times.split(";")]
363
+ else:
364
+ times_list = []
365
+
366
+ vulnerabilities_list: list[dict] = []
367
+ max_len = len(reasons_list) if reasons_list else max(
368
+ len(colors_list), len(times_list),
369
+ ) if any([colors_list, times_list]) else 0
370
+
371
+ for idx in range(max_len):
372
+ reason = reasons_list[idx] if idx < len(reasons_list) else "N/A"
373
+ color = colors_list[idx] if idx < len(colors_list) else "N/A"
374
+ hours = times_list[idx] if idx < len(times_list) else None
375
+ vulnerabilities_list.append({"reason": reason, "color": color, "hours": hours})
376
+
377
+ if vulnerabilities_list:
378
+ audit_data["vulnerabilities_list"] = vulnerabilities_list
379
+
380
+ # Backward-compatible aggregate attributes
381
+ aggregate_reasons = "; ".join(
382
+ v["reason"]
383
+ for v in vulnerabilities_list
384
+ if v.get("reason") and str(v["reason"]).strip().upper() != "N/A"
385
+ ).strip()
386
+ if aggregate_reasons:
387
+ audit_data["reasons_of_flag"] = aggregate_reasons
388
+
389
+ has_red = any(
390
+ (v.get("color") or "").strip().lower() == "red" for v in vulnerabilities_list
391
+ )
392
+ has_orange = any(
393
+ (v.get("color") or "").strip().lower() == "orange" for v in vulnerabilities_list
394
+ )
395
+ if has_red:
396
+ audit_data["flag_color"] = "Red"
397
+ elif has_orange:
398
+ audit_data["flag_color"] = "Orange"
399
+
400
+ total_hours = 0.0
401
+ any_hours = False
402
+ for v in vulnerabilities_list:
403
+ h = v.get("hours")
404
+ if isinstance(h, (int, float)):
405
+ total_hours += float(h)
406
+ any_hours = True
407
+ if any_hours:
408
+ audit_data["time_to_fix_flag"] = total_hours
409
+
410
+ # ------------------------------------------------------------------
411
+ # Step 5: Completeness check
412
+ # ------------------------------------------------------------------
413
+ has_any_flag_point = (
414
+ (audit_data.get("reasons_of_flag") not in [None, "", "None", "N/A"])
415
+ or (audit_data.get("flag_color") not in [None, "", "None", "N/A"])
416
+ or isinstance(audit_data.get("time_to_fix_flag"), (int, float))
417
+ or (isinstance(audit_data.get("vulnerabilities_list"), list) and len(audit_data["vulnerabilities_list"]) > 0)
418
+ )
419
+
420
+ if not has_any_flag_point and all_9x_na:
421
+ audit_data.setdefault("reasons_of_flag", "N/A")
422
+ audit_data.setdefault("flag_color", "N/A")
423
+ elif not has_any_flag_point:
424
+ # Incomplete — return high none_response_count to trigger retry
425
+ return None, 999
426
+
427
+ return audit_data, none_response_count
428
+
429
+
430
+ def is_audit_data_valid(audit_data: dict | None) -> bool:
431
+ """
432
+ Validate that parsed audit data contains the minimum required fields.
433
+
434
+ For non-script content, only ``is_script`` and ``is_script_explanation``
435
+ are required. For script content, ``script_purpose``, ``domain``, and
436
+ ``summary_all`` must be present.
437
+ """
438
+ if not audit_data or not isinstance(audit_data, dict):
439
+ return False
440
+
441
+ is_script = audit_data.get("is_script")
442
+ if is_script is not None and str(is_script).lower() == "no":
443
+ return all(
444
+ audit_data.get(f) for f in ("is_script", "is_script_explanation")
445
+ )
446
+
447
+ required_fields = ("script_purpose", "domain", "summary_all")
448
+ return all(audit_data.get(f) for f in required_fields)
449
+
450
+
451
+ def is_response_complete(response_text: str, audit_data: dict | None = None) -> bool:
452
+ """
453
+ Check whether the LLM response is sufficiently complete.
454
+
455
+ Accepts as complete if:
456
+ - Parsed data indicates not-a-script
457
+ - Parsed data contains any flag info (9.x section)
458
+ - Explicit N/A for all 9.x points
459
+ - Fallback: raw text contains section 9.x markers
460
+ """
461
+ try:
462
+ if isinstance(audit_data, dict):
463
+ is_script_value = audit_data.get("is_script")
464
+ if is_script_value is not None and str(is_script_value).lower() == "no":
465
+ return True
466
+
467
+ has_any_flag = (
468
+ (isinstance(audit_data.get("vulnerabilities_list"), list) and len(audit_data["vulnerabilities_list"]) > 0)
469
+ or (audit_data.get("reasons_of_flag") not in [None, "", "None"])
470
+ or (audit_data.get("flag_color") not in [None, "", "None"])
471
+ or isinstance(audit_data.get("time_to_fix_flag"), (int, float))
472
+ )
473
+ if has_any_flag:
474
+ return True
475
+
476
+ if (
477
+ str(audit_data.get("reasons_of_flag", "")).strip().upper() == "N/A"
478
+ and str(audit_data.get("flag_color", "")).strip().upper() == "N/A"
479
+ ):
480
+ return True
481
+
482
+ # Fallback: check raw text markers
483
+ text = (response_text or "").lower()
484
+ markers = ("9. due diligence", "9.1.", "9.2.", "9.3.")
485
+ return any(m in text for m in markers)
486
+ except Exception:
487
+ return True # Be permissive to avoid false negatives