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.
- codedd_cli/__init__.py +3 -0
- codedd_cli/__main__.py +19 -0
- codedd_cli/api/__init__.py +4 -0
- codedd_cli/api/client.py +120 -0
- codedd_cli/api/endpoints.py +44 -0
- codedd_cli/api/exceptions.py +24 -0
- codedd_cli/auditor/__init__.py +6 -0
- codedd_cli/auditor/architecture_analyzer.py +1251 -0
- codedd_cli/auditor/architecture_prompts.py +173 -0
- codedd_cli/auditor/complexity_analyzer.py +1739 -0
- codedd_cli/auditor/dependency_scanner.py +2485 -0
- codedd_cli/auditor/file_auditor.py +578 -0
- codedd_cli/auditor/git_stats_collector.py +417 -0
- codedd_cli/auditor/response_parser.py +484 -0
- codedd_cli/auditor/vulnerability_validator.py +323 -0
- codedd_cli/auth/__init__.py +4 -0
- codedd_cli/auth/session.py +40 -0
- codedd_cli/auth/token_manager.py +86 -0
- codedd_cli/cli.py +69 -0
- codedd_cli/commands/__init__.py +1 -0
- codedd_cli/commands/audit_cmd.py +1987 -0
- codedd_cli/commands/audits_cmd.py +276 -0
- codedd_cli/commands/auth_cmd.py +235 -0
- codedd_cli/commands/config_cmd.py +421 -0
- codedd_cli/commands/scope_cmd.py +1016 -0
- codedd_cli/config/__init__.py +4 -0
- codedd_cli/config/constants.py +22 -0
- codedd_cli/config/settings.py +389 -0
- codedd_cli/llm/__init__.py +1 -0
- codedd_cli/llm/key_manager.py +267 -0
- codedd_cli/models/__init__.py +5 -0
- codedd_cli/models/account.py +13 -0
- codedd_cli/models/audit.py +31 -0
- codedd_cli/models/local_directory.py +25 -0
- codedd_cli/scanner/__init__.py +18 -0
- codedd_cli/scanner/file_classifier.py +752 -0
- codedd_cli/scanner/file_walker.py +213 -0
- codedd_cli/scanner/line_counter.py +80 -0
- codedd_cli/utils/__init__.py +1 -0
- codedd_cli/utils/directory_validator.py +178 -0
- codedd_cli/utils/display.py +497 -0
- codedd_cli/utils/payload_inspector.py +178 -0
- codedd_cli/utils/security.py +14 -0
- codedd_cli/utils/validators.py +37 -0
- codedd_cli-0.1.1.dist-info/METADATA +306 -0
- codedd_cli-0.1.1.dist-info/RECORD +49 -0
- codedd_cli-0.1.1.dist-info/WHEEL +4 -0
- codedd_cli-0.1.1.dist-info/entry_points.txt +3 -0
- codedd_cli-0.1.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Local file auditor for the CodeDD CLI.
|
|
3
|
+
|
|
4
|
+
Reads source files from disk, sends them to the configured LLM provider
|
|
5
|
+
via raw ``httpx`` calls (no SDK dependency), parses the structured
|
|
6
|
+
response, and returns a typed audit-data dictionary ready for submission
|
|
7
|
+
to CodeDD.
|
|
8
|
+
|
|
9
|
+
The retry/fallback strategy mirrors the server-side ``AI_Auditor``:
|
|
10
|
+
- Anthropic is the primary provider.
|
|
11
|
+
- OpenAI is the fallback.
|
|
12
|
+
- 3 outer retries, up to 2 attempts per provider per retry cycle.
|
|
13
|
+
- Exponential backoff with jitter between outer retries.
|
|
14
|
+
|
|
15
|
+
**Security invariants**
|
|
16
|
+
- The system prompt is kept in memory only — never written to disk.
|
|
17
|
+
- File content is never logged at any verbosity level.
|
|
18
|
+
- LLM API keys are retrieved from the OS keyring at runtime.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import logging
|
|
24
|
+
import random
|
|
25
|
+
import time
|
|
26
|
+
from collections.abc import Callable
|
|
27
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
import httpx
|
|
32
|
+
|
|
33
|
+
from codedd_cli.auditor.response_parser import (
|
|
34
|
+
is_audit_data_valid,
|
|
35
|
+
is_response_complete,
|
|
36
|
+
parse_audit_response,
|
|
37
|
+
)
|
|
38
|
+
from codedd_cli.llm.key_manager import PROVIDER_MODELS
|
|
39
|
+
|
|
40
|
+
# Callback for debug dump: (full_prompt, response_text, audit_data, none_count)
|
|
41
|
+
OnDumpLLMCallback = Callable[[str, str, dict | None, int], None]
|
|
42
|
+
|
|
43
|
+
logger = logging.getLogger(__name__)
|
|
44
|
+
|
|
45
|
+
# LLM API endpoints (same as key_manager uses for validation)
|
|
46
|
+
_ANTHROPIC_MESSAGES_URL = "https://api.anthropic.com/v1/messages"
|
|
47
|
+
_OPENAI_CHAT_URL = "https://api.openai.com/v1/chat/completions"
|
|
48
|
+
|
|
49
|
+
# Request timeouts (seconds) — audit calls are larger than validation pings
|
|
50
|
+
_LLM_TIMEOUT = 180.0
|
|
51
|
+
|
|
52
|
+
# Content size limit (chars) — matches server-side max_content_chars
|
|
53
|
+
_MAX_CONTENT_CHARS = 250_000
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class AuditFileResult:
|
|
57
|
+
"""Container for a single file's audit outcome."""
|
|
58
|
+
|
|
59
|
+
__slots__ = (
|
|
60
|
+
"file_path",
|
|
61
|
+
"relative_path",
|
|
62
|
+
"audit_data",
|
|
63
|
+
"provider_used",
|
|
64
|
+
"error",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
file_path: str,
|
|
70
|
+
relative_path: str,
|
|
71
|
+
audit_data: dict | None = None,
|
|
72
|
+
provider_used: str | None = None,
|
|
73
|
+
error: str | None = None,
|
|
74
|
+
) -> None:
|
|
75
|
+
self.file_path = file_path
|
|
76
|
+
self.relative_path = relative_path
|
|
77
|
+
self.audit_data = audit_data
|
|
78
|
+
self.provider_used = provider_used
|
|
79
|
+
self.error = error
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def success(self) -> bool:
|
|
83
|
+
return self.audit_data is not None and self.error is None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class LocalFileAuditor:
|
|
87
|
+
"""
|
|
88
|
+
Read local source files, send them to an LLM with the server-provided
|
|
89
|
+
system prompt, parse the structured response, and return audit data.
|
|
90
|
+
|
|
91
|
+
Usage::
|
|
92
|
+
|
|
93
|
+
auditor = LocalFileAuditor(
|
|
94
|
+
anthropic_key="sk-ant-...",
|
|
95
|
+
openai_key="sk-...",
|
|
96
|
+
system_prompt="...",
|
|
97
|
+
provider_preference="both",
|
|
98
|
+
max_concurrent=8,
|
|
99
|
+
)
|
|
100
|
+
results = auditor.audit_batch(files, on_progress=callback)
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
def __init__(
|
|
104
|
+
self,
|
|
105
|
+
anthropic_key: str | None,
|
|
106
|
+
openai_key: str | None,
|
|
107
|
+
system_prompt: str,
|
|
108
|
+
provider_preference: str = "both",
|
|
109
|
+
max_concurrent: int = 8,
|
|
110
|
+
on_debug: Callable[[str], None] | None = None,
|
|
111
|
+
on_dump_llm: OnDumpLLMCallback | None = None,
|
|
112
|
+
) -> None:
|
|
113
|
+
"""
|
|
114
|
+
Initialise the auditor with API keys and the system prompt.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
anthropic_key: Anthropic API key (may be None).
|
|
118
|
+
openai_key: OpenAI API key (may be None).
|
|
119
|
+
system_prompt: The file-audit system prompt from the server.
|
|
120
|
+
provider_preference: ``"anthropic"``, ``"openai"``, or ``"both"``.
|
|
121
|
+
max_concurrent: Maximum concurrent LLM calls.
|
|
122
|
+
on_debug: Optional callback for real-time debug messages.
|
|
123
|
+
on_dump_llm: Optional callback for full prompt/response/parse dump (debug).
|
|
124
|
+
"""
|
|
125
|
+
self._anthropic_key = anthropic_key
|
|
126
|
+
self._openai_key = openai_key
|
|
127
|
+
self._system_prompt = system_prompt
|
|
128
|
+
self._preference = provider_preference
|
|
129
|
+
self._max_concurrent = max_concurrent
|
|
130
|
+
self._on_debug = on_debug
|
|
131
|
+
self._on_dump_llm = on_dump_llm
|
|
132
|
+
|
|
133
|
+
# Determine provider order
|
|
134
|
+
self._primary, self._fallback = self._resolve_providers()
|
|
135
|
+
|
|
136
|
+
# Shared httpx clients (connection pooling)
|
|
137
|
+
self._anthropic_client: httpx.Client | None = None
|
|
138
|
+
self._openai_client: httpx.Client | None = None
|
|
139
|
+
|
|
140
|
+
if self._anthropic_key:
|
|
141
|
+
self._anthropic_client = httpx.Client(
|
|
142
|
+
base_url="https://api.anthropic.com",
|
|
143
|
+
headers={
|
|
144
|
+
"x-api-key": self._anthropic_key,
|
|
145
|
+
"anthropic-version": "2023-06-01",
|
|
146
|
+
"content-type": "application/json",
|
|
147
|
+
},
|
|
148
|
+
timeout=_LLM_TIMEOUT,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
if self._openai_key:
|
|
152
|
+
self._openai_client = httpx.Client(
|
|
153
|
+
base_url="https://api.openai.com",
|
|
154
|
+
headers={
|
|
155
|
+
"Authorization": f"Bearer {self._openai_key}",
|
|
156
|
+
"Content-Type": "application/json",
|
|
157
|
+
},
|
|
158
|
+
timeout=_LLM_TIMEOUT,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# ------------------------------------------------------------------
|
|
162
|
+
# Lifecycle
|
|
163
|
+
# ------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
def close(self) -> None:
|
|
166
|
+
"""Release HTTP connection pools."""
|
|
167
|
+
if self._anthropic_client:
|
|
168
|
+
self._anthropic_client.close()
|
|
169
|
+
if self._openai_client:
|
|
170
|
+
self._openai_client.close()
|
|
171
|
+
|
|
172
|
+
def __enter__(self) -> LocalFileAuditor:
|
|
173
|
+
return self
|
|
174
|
+
|
|
175
|
+
def __exit__(self, *_: Any) -> None:
|
|
176
|
+
self.close()
|
|
177
|
+
|
|
178
|
+
# ------------------------------------------------------------------
|
|
179
|
+
# Provider resolution
|
|
180
|
+
# ------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
def _resolve_providers(self) -> tuple[str | None, str | None]:
|
|
183
|
+
"""
|
|
184
|
+
Determine the primary and fallback provider based on preference
|
|
185
|
+
and available keys.
|
|
186
|
+
"""
|
|
187
|
+
has_anthropic = self._anthropic_key is not None
|
|
188
|
+
has_openai = self._openai_key is not None
|
|
189
|
+
|
|
190
|
+
if self._preference == "anthropic":
|
|
191
|
+
primary = "anthropic" if has_anthropic else ("openai" if has_openai else None)
|
|
192
|
+
fallback = "openai" if has_openai and primary == "anthropic" else None
|
|
193
|
+
elif self._preference == "openai":
|
|
194
|
+
primary = "openai" if has_openai else ("anthropic" if has_anthropic else None)
|
|
195
|
+
fallback = "anthropic" if has_anthropic and primary == "openai" else None
|
|
196
|
+
else: # "both"
|
|
197
|
+
primary = "anthropic" if has_anthropic else ("openai" if has_openai else None)
|
|
198
|
+
fallback = (
|
|
199
|
+
"openai"
|
|
200
|
+
if has_openai and primary == "anthropic"
|
|
201
|
+
else ("anthropic" if has_anthropic and primary == "openai" else None)
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
return primary, fallback
|
|
205
|
+
|
|
206
|
+
# ------------------------------------------------------------------
|
|
207
|
+
# Single-file audit
|
|
208
|
+
# ------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
def audit_file(
|
|
211
|
+
self,
|
|
212
|
+
local_path: str,
|
|
213
|
+
file_path: str,
|
|
214
|
+
relative_path: str,
|
|
215
|
+
) -> AuditFileResult:
|
|
216
|
+
"""
|
|
217
|
+
Audit a single file: read from disk, call LLM, parse response.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
local_path: Absolute path on disk to read.
|
|
221
|
+
file_path: The ``cli://...`` path registered with CodeDD.
|
|
222
|
+
relative_path: Repo-relative path for display.
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
``AuditFileResult`` with ``audit_data`` on success or ``error`` on failure.
|
|
226
|
+
"""
|
|
227
|
+
# --- Read file content ---
|
|
228
|
+
try:
|
|
229
|
+
content = Path(local_path).read_text(encoding="utf-8", errors="replace")
|
|
230
|
+
except Exception as exc:
|
|
231
|
+
return AuditFileResult(
|
|
232
|
+
file_path=file_path,
|
|
233
|
+
relative_path=relative_path,
|
|
234
|
+
error=f"Cannot read file: {exc}",
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
if not content or not content.strip():
|
|
238
|
+
return AuditFileResult(
|
|
239
|
+
file_path=file_path,
|
|
240
|
+
relative_path=relative_path,
|
|
241
|
+
error="File is empty",
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
# Truncate oversized files (same threshold as server)
|
|
245
|
+
if len(content) > _MAX_CONTENT_CHARS:
|
|
246
|
+
content = content[:_MAX_CONTENT_CHARS]
|
|
247
|
+
|
|
248
|
+
# --- Construct prompts ---
|
|
249
|
+
intro = "--- Following the Code to be audited based on the system prompt ---"
|
|
250
|
+
user_prompt = f"{intro}\n\nCode Content:\n{content}"
|
|
251
|
+
combined_prompt = f"{self._system_prompt}\n\n{user_prompt}"
|
|
252
|
+
|
|
253
|
+
# --- Retry loop (mirrors server-side ai_auditor.audit_content) ---
|
|
254
|
+
retry_limit = 3
|
|
255
|
+
max_retries_per_provider = 2
|
|
256
|
+
anthropic_retries = 0
|
|
257
|
+
openai_retries = 0
|
|
258
|
+
|
|
259
|
+
self._debug(
|
|
260
|
+
f"[{relative_path}] Starting audit — "
|
|
261
|
+
f"primary={self._primary}, fallback={self._fallback}, "
|
|
262
|
+
f"content_len={len(content):,} chars"
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
for retry_count in range(retry_limit):
|
|
266
|
+
audit_data: dict | None = None
|
|
267
|
+
|
|
268
|
+
self._debug(f"[{relative_path}] Retry cycle {retry_count + 1}/{retry_limit}")
|
|
269
|
+
|
|
270
|
+
# --- Try primary provider ---
|
|
271
|
+
result = self._try_provider(
|
|
272
|
+
self._primary,
|
|
273
|
+
user_prompt,
|
|
274
|
+
combined_prompt,
|
|
275
|
+
anthropic_retries,
|
|
276
|
+
openai_retries,
|
|
277
|
+
max_retries_per_provider,
|
|
278
|
+
file_path,
|
|
279
|
+
relative_path,
|
|
280
|
+
)
|
|
281
|
+
if result is not None:
|
|
282
|
+
if isinstance(result, AuditFileResult):
|
|
283
|
+
return result
|
|
284
|
+
# Unpack updated retry counters + audit_data
|
|
285
|
+
anthropic_retries, openai_retries, audit_data = result
|
|
286
|
+
|
|
287
|
+
# --- Try fallback provider ---
|
|
288
|
+
result = self._try_provider(
|
|
289
|
+
self._fallback,
|
|
290
|
+
user_prompt,
|
|
291
|
+
combined_prompt,
|
|
292
|
+
anthropic_retries,
|
|
293
|
+
openai_retries,
|
|
294
|
+
max_retries_per_provider,
|
|
295
|
+
file_path,
|
|
296
|
+
relative_path,
|
|
297
|
+
)
|
|
298
|
+
if result is not None:
|
|
299
|
+
if isinstance(result, AuditFileResult):
|
|
300
|
+
return result
|
|
301
|
+
anthropic_retries, openai_retries, audit_data = result
|
|
302
|
+
|
|
303
|
+
# If we got a "not a script" in any attempt, accept it
|
|
304
|
+
if audit_data and str(audit_data.get("is_script", "")).lower() == "no":
|
|
305
|
+
self._debug(f"[{relative_path}] Accepted as non-script")
|
|
306
|
+
return AuditFileResult(
|
|
307
|
+
file_path=file_path,
|
|
308
|
+
relative_path=relative_path,
|
|
309
|
+
audit_data=audit_data,
|
|
310
|
+
provider_used=self._primary or "unknown",
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
# Exponential backoff with jitter before next outer retry
|
|
314
|
+
if retry_count < retry_limit - 1:
|
|
315
|
+
sleep_time = min(60, (2**retry_count) * 5 + random.uniform(0, 2))
|
|
316
|
+
self._debug(f"[{relative_path}] Backing off {sleep_time:.1f}s before next retry")
|
|
317
|
+
time.sleep(sleep_time)
|
|
318
|
+
|
|
319
|
+
self._debug(f"[{relative_path}] FAILED — all LLM attempts exhausted")
|
|
320
|
+
return AuditFileResult(
|
|
321
|
+
file_path=file_path,
|
|
322
|
+
relative_path=relative_path,
|
|
323
|
+
error="All LLM attempts exhausted",
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
def _try_provider(
|
|
327
|
+
self,
|
|
328
|
+
provider: str | None,
|
|
329
|
+
user_prompt: str,
|
|
330
|
+
combined_prompt: str,
|
|
331
|
+
anthropic_retries: int,
|
|
332
|
+
openai_retries: int,
|
|
333
|
+
max_retries: int,
|
|
334
|
+
file_path: str,
|
|
335
|
+
relative_path: str,
|
|
336
|
+
) -> AuditFileResult | tuple[int, int, dict | None] | None:
|
|
337
|
+
"""
|
|
338
|
+
Attempt one LLM call for the given provider.
|
|
339
|
+
|
|
340
|
+
Returns:
|
|
341
|
+
``AuditFileResult`` on definitive success,
|
|
342
|
+
``(anthropic_retries, openai_retries, audit_data)`` if the call
|
|
343
|
+
was made but the response needs retry,
|
|
344
|
+
or ``None`` if the provider was skipped (not configured / exhausted).
|
|
345
|
+
"""
|
|
346
|
+
if provider is None:
|
|
347
|
+
return None
|
|
348
|
+
|
|
349
|
+
if provider == "anthropic" and anthropic_retries >= max_retries:
|
|
350
|
+
self._debug(f"[{relative_path}] Anthropic: retries exhausted ({anthropic_retries}/{max_retries})")
|
|
351
|
+
return None
|
|
352
|
+
if provider == "openai" and openai_retries >= max_retries:
|
|
353
|
+
self._debug(f"[{relative_path}] OpenAI: retries exhausted ({openai_retries}/{max_retries})")
|
|
354
|
+
return None
|
|
355
|
+
|
|
356
|
+
if provider == "anthropic":
|
|
357
|
+
success, response_text = self._call_anthropic(user_prompt)
|
|
358
|
+
anthropic_retries += 1
|
|
359
|
+
else:
|
|
360
|
+
success, response_text = self._call_openai(combined_prompt)
|
|
361
|
+
openai_retries += 1
|
|
362
|
+
|
|
363
|
+
if not success or not response_text:
|
|
364
|
+
self._debug(f"[{relative_path}] {provider}: call failed or empty response")
|
|
365
|
+
return (anthropic_retries, openai_retries, None)
|
|
366
|
+
|
|
367
|
+
audit_data, none_count = parse_audit_response(response_text)
|
|
368
|
+
|
|
369
|
+
# Debug dump: full prompt, raw response, and parse result
|
|
370
|
+
if self._on_dump_llm:
|
|
371
|
+
self._on_dump_llm(combined_prompt, response_text, audit_data, none_count)
|
|
372
|
+
|
|
373
|
+
if not audit_data:
|
|
374
|
+
self._debug(f"[{relative_path}] {provider}: parse returned None")
|
|
375
|
+
return (anthropic_retries, openai_retries, None)
|
|
376
|
+
|
|
377
|
+
# Early return for non-script content
|
|
378
|
+
if str(audit_data.get("is_script", "")).lower() == "no":
|
|
379
|
+
self._debug(f"[{relative_path}] {provider}: non-script content — accepted")
|
|
380
|
+
return AuditFileResult(
|
|
381
|
+
file_path=file_path,
|
|
382
|
+
relative_path=relative_path,
|
|
383
|
+
audit_data=audit_data,
|
|
384
|
+
provider_used=provider,
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
is_valid = is_audit_data_valid(audit_data)
|
|
388
|
+
is_complete = is_response_complete(response_text, audit_data)
|
|
389
|
+
|
|
390
|
+
self._debug(
|
|
391
|
+
f"[{relative_path}] {provider}: parsed — valid={is_valid}, complete={is_complete}, none_count={none_count}"
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
if is_valid and is_complete and none_count < 10:
|
|
395
|
+
self._debug(f"[{relative_path}] {provider}: SUCCESS")
|
|
396
|
+
return AuditFileResult(
|
|
397
|
+
file_path=file_path,
|
|
398
|
+
relative_path=relative_path,
|
|
399
|
+
audit_data=audit_data,
|
|
400
|
+
provider_used=provider,
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
return (anthropic_retries, openai_retries, audit_data)
|
|
404
|
+
|
|
405
|
+
# ------------------------------------------------------------------
|
|
406
|
+
# Batch audit
|
|
407
|
+
# ------------------------------------------------------------------
|
|
408
|
+
|
|
409
|
+
def audit_batch(
|
|
410
|
+
self,
|
|
411
|
+
files: list[dict],
|
|
412
|
+
scope_dirs: dict[str, str],
|
|
413
|
+
on_progress: Callable[[AuditFileResult], None] | None = None,
|
|
414
|
+
) -> list[AuditFileResult]:
|
|
415
|
+
"""
|
|
416
|
+
Audit a list of files concurrently using a thread pool.
|
|
417
|
+
|
|
418
|
+
Args:
|
|
419
|
+
files: List of file dicts from the audit plan. Each must have
|
|
420
|
+
``file_path`` (cli:// path), ``relative_path``, and the
|
|
421
|
+
sub-audit's ``root_path``.
|
|
422
|
+
scope_dirs: Mapping of ``repo_name`` → local directory path.
|
|
423
|
+
on_progress: Optional callback invoked after each file completes.
|
|
424
|
+
|
|
425
|
+
Returns:
|
|
426
|
+
List of ``AuditFileResult`` objects (one per file).
|
|
427
|
+
"""
|
|
428
|
+
results: list[AuditFileResult] = []
|
|
429
|
+
|
|
430
|
+
with ThreadPoolExecutor(max_workers=self._max_concurrent) as pool:
|
|
431
|
+
future_map = {}
|
|
432
|
+
for f in files:
|
|
433
|
+
file_path = f["file_path"]
|
|
434
|
+
relative_path = f["relative_path"]
|
|
435
|
+
repo_name = f.get("repo_name", "")
|
|
436
|
+
local_dir = scope_dirs.get(repo_name, "")
|
|
437
|
+
|
|
438
|
+
# Build local disk path from scope directory + relative path
|
|
439
|
+
local_path = str(Path(local_dir) / relative_path) if local_dir else ""
|
|
440
|
+
|
|
441
|
+
future = pool.submit(
|
|
442
|
+
self.audit_file,
|
|
443
|
+
local_path=local_path,
|
|
444
|
+
file_path=file_path,
|
|
445
|
+
relative_path=relative_path,
|
|
446
|
+
)
|
|
447
|
+
future_map[future] = f
|
|
448
|
+
|
|
449
|
+
for future in as_completed(future_map):
|
|
450
|
+
try:
|
|
451
|
+
result = future.result()
|
|
452
|
+
except Exception as exc:
|
|
453
|
+
info = future_map[future]
|
|
454
|
+
result = AuditFileResult(
|
|
455
|
+
file_path=info["file_path"],
|
|
456
|
+
relative_path=info["relative_path"],
|
|
457
|
+
error=str(exc),
|
|
458
|
+
)
|
|
459
|
+
results.append(result)
|
|
460
|
+
if on_progress:
|
|
461
|
+
on_progress(result)
|
|
462
|
+
|
|
463
|
+
return results
|
|
464
|
+
|
|
465
|
+
# ------------------------------------------------------------------
|
|
466
|
+
# LLM HTTP calls
|
|
467
|
+
# ------------------------------------------------------------------
|
|
468
|
+
|
|
469
|
+
def _call_anthropic(self, user_prompt: str) -> tuple[bool, str | None]:
|
|
470
|
+
"""
|
|
471
|
+
Call the Anthropic Messages API with the cached system prompt.
|
|
472
|
+
|
|
473
|
+
Returns ``(success, response_text)``.
|
|
474
|
+
"""
|
|
475
|
+
if not self._anthropic_client:
|
|
476
|
+
self._debug("Anthropic: no client configured — skipping")
|
|
477
|
+
return False, None
|
|
478
|
+
|
|
479
|
+
model = PROVIDER_MODELS.get("anthropic", "claude-sonnet-4-6")
|
|
480
|
+
prompt_len = len(user_prompt)
|
|
481
|
+
self._debug(f"Anthropic: calling model={model}, prompt_len={prompt_len:,} chars")
|
|
482
|
+
|
|
483
|
+
payload = {
|
|
484
|
+
"model": model,
|
|
485
|
+
"max_tokens": 8192,
|
|
486
|
+
"system": self._system_prompt,
|
|
487
|
+
"messages": [{"role": "user", "content": user_prompt}],
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
try:
|
|
491
|
+
resp = self._anthropic_client.post("/v1/messages", json=payload)
|
|
492
|
+
if resp.status_code == 200:
|
|
493
|
+
body = resp.json()
|
|
494
|
+
content_blocks = body.get("content", [])
|
|
495
|
+
text_parts = [b["text"] for b in content_blocks if b.get("type") == "text"]
|
|
496
|
+
result_text = "\n".join(text_parts) if text_parts else None
|
|
497
|
+
resp_len = len(result_text) if result_text else 0
|
|
498
|
+
self._debug(f"Anthropic: OK — response_len={resp_len:,} chars")
|
|
499
|
+
return True, result_text
|
|
500
|
+
|
|
501
|
+
# Non-200 — log the error body for diagnostics
|
|
502
|
+
error_detail = self._extract_error_body(resp)
|
|
503
|
+
self._debug(f"Anthropic: HTTP {resp.status_code} — {error_detail}")
|
|
504
|
+
return False, None
|
|
505
|
+
except httpx.TimeoutException:
|
|
506
|
+
self._debug("Anthropic: request timed out")
|
|
507
|
+
return False, None
|
|
508
|
+
except Exception as exc:
|
|
509
|
+
self._debug(f"Anthropic: exception — {type(exc).__name__}: {exc}")
|
|
510
|
+
return False, None
|
|
511
|
+
|
|
512
|
+
def _call_openai(self, combined_prompt: str) -> tuple[bool, str | None]:
|
|
513
|
+
"""
|
|
514
|
+
Call the OpenAI Chat Completions API with the combined prompt.
|
|
515
|
+
|
|
516
|
+
Returns ``(success, response_text)``.
|
|
517
|
+
"""
|
|
518
|
+
if not self._openai_client:
|
|
519
|
+
self._debug("OpenAI: no client configured — skipping")
|
|
520
|
+
return False, None
|
|
521
|
+
|
|
522
|
+
model = PROVIDER_MODELS.get("openai", "gpt-5.2")
|
|
523
|
+
prompt_len = len(combined_prompt)
|
|
524
|
+
self._debug(f"OpenAI: calling model={model}, prompt_len={prompt_len:,} chars")
|
|
525
|
+
|
|
526
|
+
payload = {
|
|
527
|
+
"model": model,
|
|
528
|
+
"messages": [{"role": "user", "content": combined_prompt}],
|
|
529
|
+
"max_tokens": 8192,
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
try:
|
|
533
|
+
resp = self._openai_client.post("/v1/chat/completions", json=payload)
|
|
534
|
+
if resp.status_code == 200:
|
|
535
|
+
body = resp.json()
|
|
536
|
+
choices = body.get("choices", [])
|
|
537
|
+
if choices:
|
|
538
|
+
result_text = choices[0].get("message", {}).get("content")
|
|
539
|
+
resp_len = len(result_text) if result_text else 0
|
|
540
|
+
self._debug(f"OpenAI: OK — response_len={resp_len:,} chars")
|
|
541
|
+
return True, result_text
|
|
542
|
+
self._debug("OpenAI: 200 but no choices in response")
|
|
543
|
+
return True, None
|
|
544
|
+
|
|
545
|
+
# Non-200 — log the error body for diagnostics
|
|
546
|
+
error_detail = self._extract_error_body(resp)
|
|
547
|
+
self._debug(f"OpenAI: HTTP {resp.status_code} — {error_detail}")
|
|
548
|
+
return False, None
|
|
549
|
+
except httpx.TimeoutException:
|
|
550
|
+
self._debug("OpenAI: request timed out")
|
|
551
|
+
return False, None
|
|
552
|
+
except Exception as exc:
|
|
553
|
+
self._debug(f"OpenAI: exception — {type(exc).__name__}: {exc}")
|
|
554
|
+
return False, None
|
|
555
|
+
|
|
556
|
+
# ------------------------------------------------------------------
|
|
557
|
+
# Debug helpers
|
|
558
|
+
# ------------------------------------------------------------------
|
|
559
|
+
|
|
560
|
+
def _debug(self, msg: str) -> None:
|
|
561
|
+
"""Emit a debug message via the logger and via the on_debug callback."""
|
|
562
|
+
logger.debug(msg)
|
|
563
|
+
if self._on_debug:
|
|
564
|
+
self._on_debug(msg)
|
|
565
|
+
|
|
566
|
+
@staticmethod
|
|
567
|
+
def _extract_error_body(resp: httpx.Response) -> str:
|
|
568
|
+
"""Extract a short error description from a non-200 response."""
|
|
569
|
+
try:
|
|
570
|
+
body = resp.json()
|
|
571
|
+
# Anthropic format
|
|
572
|
+
err = body.get("error", {})
|
|
573
|
+
if isinstance(err, dict):
|
|
574
|
+
return err.get("message", str(body)[:200])
|
|
575
|
+
# OpenAI format
|
|
576
|
+
return str(body)[:200]
|
|
577
|
+
except Exception:
|
|
578
|
+
return resp.text[:200] if resp.text else "(empty body)"
|