codedd-cli 0.1.4__tar.gz → 0.1.6__tar.gz
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-0.1.4 → codedd_cli-0.1.6}/PKG-INFO +1 -1
- {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/__init__.py +3 -3
- {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/api/client.py +4 -7
- {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/api/endpoints.py +9 -2
- {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/api/exceptions.py +24 -24
- {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/auditor/archetype_classifier.py +1 -1
- {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/auditor/architecture_analyzer.py +35 -17
- {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/auditor/architecture_prompts.py +6 -2
- codedd_cli-0.1.6/codedd_cli/auditor/architecture_schema_contract.py +150 -0
- codedd_cli-0.1.6/codedd_cli/auditor/audit_checkpoint.py +179 -0
- {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/auditor/commit_classifier.py +2 -1
- {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/auditor/complexity_analyzer.py +9 -4
- {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/auditor/dependency_scanner.py +1 -1
- {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/auditor/enhanced_architecture_detectors.py +387 -381
- {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/auditor/file_auditor.py +59 -15
- {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/auditor/git_stats_collector.py +11 -0
- {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/auditor/response_parser.py +484 -484
- {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/auditor/sub_audit_runner.py +267 -122
- {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/auditor/vulnerability_validator.py +40 -6
- {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/auth/session.py +9 -1
- {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/auth/token_manager.py +6 -1
- {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/commands/__init__.py +1 -1
- {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/commands/ai_docs_cmd.py +1115 -1000
- {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/commands/audit_cmd.py +663 -18
- {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/commands/audits_cmd.py +49 -7
- {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/commands/auth_cmd.py +15 -32
- {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/commands/config_cmd.py +43 -45
- codedd_cli-0.1.6/codedd_cli/commands/fix_cmd.py +2677 -0
- {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/commands/scope_cmd.py +70 -33
- codedd_cli-0.1.6/codedd_cli/config/constants.py +38 -0
- {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/config/fix_session.py +47 -1
- {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/config/settings.py +138 -17
- codedd_cli-0.1.6/codedd_cli/config/url_validation.py +139 -0
- {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/llm/key_manager.py +4 -1
- {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/models/audit.py +11 -11
- {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/scanner/file_classifier.py +7 -1
- {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/scanner/file_walker.py +28 -0
- {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/scanner/line_counter.py +1 -1
- {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/utils/__init__.py +1 -1
- {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/utils/directory_validator.py +11 -1
- {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/utils/display.py +64 -36
- {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/utils/payload_inspector.py +1 -1
- codedd_cli-0.1.6/codedd_cli/utils/safe_path.py +51 -0
- {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/utils/validators.py +6 -7
- {codedd_cli-0.1.4 → codedd_cli-0.1.6}/pyproject.toml +67 -67
- codedd_cli-0.1.4/codedd_cli/commands/fix_cmd.py +0 -1119
- codedd_cli-0.1.4/codedd_cli/config/constants.py +0 -26
- {codedd_cli-0.1.4 → codedd_cli-0.1.6}/LICENSE +0 -0
- {codedd_cli-0.1.4 → codedd_cli-0.1.6}/README.md +0 -0
- {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/__main__.py +0 -0
- {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/api/__init__.py +0 -0
- {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/auditor/__init__.py +0 -0
- {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/auditor/tier_definitions.py +0 -0
- {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/auth/__init__.py +0 -0
- {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/cli.py +0 -0
- {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/config/__init__.py +0 -0
- {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/llm/__init__.py +0 -0
- {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/models/__init__.py +0 -0
- {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/models/account.py +0 -0
- {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/models/local_directory.py +0 -0
- {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/scanner/__init__.py +0 -0
- {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/utils/security.py +0 -0
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
"""CodeDD CLI — run code audits from your terminal."""
|
|
2
|
-
|
|
3
|
-
__version__ = "0.1.
|
|
1
|
+
"""CodeDD CLI — run code audits from your terminal."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.6"
|
|
@@ -24,6 +24,7 @@ from codedd_cli.config.constants import (
|
|
|
24
24
|
USER_AGENT_PREFIX,
|
|
25
25
|
)
|
|
26
26
|
from codedd_cli.config.settings import ConfigManager
|
|
27
|
+
from codedd_cli.config.url_validation import should_verify_tls_for_api_url
|
|
27
28
|
|
|
28
29
|
|
|
29
30
|
class CodeDDClient:
|
|
@@ -37,16 +38,12 @@ class CodeDDClient:
|
|
|
37
38
|
data = resp.json()
|
|
38
39
|
"""
|
|
39
40
|
|
|
40
|
-
def __init__(self, config: ConfigManager | None = None) -> None:
|
|
41
|
+
def __init__(self, config: ConfigManager | None = None, token: str | None = None) -> None:
|
|
41
42
|
self._config = config or ConfigManager()
|
|
42
43
|
self._base_url = self._config.api_url.rstrip("/")
|
|
43
|
-
self._token = TokenManager.retrieve()
|
|
44
|
+
self._token = token if token is not None else TokenManager.retrieve()
|
|
44
45
|
|
|
45
|
-
|
|
46
|
-
# For HTTPS, always verify certificates
|
|
47
|
-
verify_tls = not self._base_url.startswith("http://localhost") and not self._base_url.startswith(
|
|
48
|
-
"http://127.0.0.1"
|
|
49
|
-
)
|
|
46
|
+
verify_tls = should_verify_tls_for_api_url(self._base_url)
|
|
50
47
|
|
|
51
48
|
self._client = httpx.Client(
|
|
52
49
|
base_url=self._base_url,
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Server endpoint path constants.
|
|
3
3
|
|
|
4
|
-
All paths are
|
|
4
|
+
All paths are absolute from the server host root (they include the ``/api/``
|
|
5
|
+
prefix). The ``api_url`` in CLI config is the host only, e.g.
|
|
6
|
+
``https://api.codedd.ai`` or ``http://localhost:8000``.
|
|
5
7
|
"""
|
|
6
8
|
|
|
7
9
|
|
|
@@ -17,7 +19,8 @@ class Endpoints:
|
|
|
17
19
|
# Scope
|
|
18
20
|
REGISTER_SCOPE = "/api/cli/scope/register/"
|
|
19
21
|
SCOPE_FILES = "/api/cli/scope/files/"
|
|
20
|
-
|
|
22
|
+
# Legacy web route reused by the CLI (django_codedd.urls → api/delete_repo_from_scope/).
|
|
23
|
+
DELETE_REPO_FROM_SCOPE = "/api/delete_repo_from_scope/"
|
|
21
24
|
|
|
22
25
|
# Audit lifecycle
|
|
23
26
|
AUDIT_CAN_START = "/api/cli/audit/can-start/"
|
|
@@ -61,3 +64,7 @@ class Endpoints:
|
|
|
61
64
|
# Fix access control (remediation module)
|
|
62
65
|
FIX_ACCESS_STATUS = "/api/cli/fix/access/status/"
|
|
63
66
|
FIX_ACCESS_TOGGLE = "/api/cli/fix/access/toggle/"
|
|
67
|
+
|
|
68
|
+
# Issue Compass saved selections (flag-issue slices from the web explorer)
|
|
69
|
+
FIX_ISSUE_COMPASS_SELECTIONS = "/api/cli/fix/issue-compass-selections/"
|
|
70
|
+
FIX_ISSUE_COMPASS_SELECTIONS_CATALOG = "/api/cli/fix/issue-compass-selections/catalog/"
|
|
@@ -1,24 +1,24 @@
|
|
|
1
|
-
"""
|
|
2
|
-
API-layer exceptions with user-facing messages.
|
|
3
|
-
|
|
4
|
-
Raised when the CLI cannot reach the CodeDD server (connection refused,
|
|
5
|
-
timeout, server disconnected, etc.). Handled at the CLI entry point to
|
|
6
|
-
display a single, clear message instead of a full traceback.
|
|
7
|
-
"""
|
|
8
|
-
|
|
9
|
-
# Pre-defined message shown when the remote is not responding.
|
|
10
|
-
CONNECTION_ERROR_MESSAGE = (
|
|
11
|
-
"Unable to reach CodeDD. "
|
|
12
|
-
"Check that the server is running and your network connection is available, "
|
|
13
|
-
"or try again later."
|
|
14
|
-
)
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
class CodeDDConnectionError(Exception):
|
|
18
|
-
"""
|
|
19
|
-
Raised when a request to the CodeDD API fails due to connection
|
|
20
|
-
or transport errors (e.g. server not responding, timeout, disconnect).
|
|
21
|
-
"""
|
|
22
|
-
|
|
23
|
-
def __init__(self, message: str | None = None) -> None:
|
|
24
|
-
super().__init__(message or CONNECTION_ERROR_MESSAGE)
|
|
1
|
+
"""
|
|
2
|
+
API-layer exceptions with user-facing messages.
|
|
3
|
+
|
|
4
|
+
Raised when the CLI cannot reach the CodeDD server (connection refused,
|
|
5
|
+
timeout, server disconnected, etc.). Handled at the CLI entry point to
|
|
6
|
+
display a single, clear message instead of a full traceback.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
# Pre-defined message shown when the remote is not responding.
|
|
10
|
+
CONNECTION_ERROR_MESSAGE = (
|
|
11
|
+
"Unable to reach CodeDD. "
|
|
12
|
+
"Check that the server is running and your network connection is available, "
|
|
13
|
+
"or try again later."
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CodeDDConnectionError(Exception):
|
|
18
|
+
"""
|
|
19
|
+
Raised when a request to the CodeDD API fails due to connection
|
|
20
|
+
or transport errors (e.g. server not responding, timeout, disconnect).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, message: str | None = None) -> None:
|
|
24
|
+
super().__init__(message or CONNECTION_ERROR_MESSAGE)
|
|
@@ -159,7 +159,7 @@ class DimensionScorer:
|
|
|
159
159
|
has already been credited in another dimension."""
|
|
160
160
|
credited_dims = self._tech_credit_ledger.get(tech_keyword, set())
|
|
161
161
|
if dimension in credited_dims:
|
|
162
|
-
return base_points
|
|
162
|
+
return base_points * ATTENUATION_FACTOR
|
|
163
163
|
if credited_dims:
|
|
164
164
|
self._tech_credit_ledger.setdefault(tech_keyword, set()).add(dimension)
|
|
165
165
|
return base_points * ATTENUATION_FACTOR
|
|
@@ -24,10 +24,15 @@ from typing import Any
|
|
|
24
24
|
|
|
25
25
|
from codedd_cli.auditor.archetype_classifier import classify_local
|
|
26
26
|
from codedd_cli.auditor.architecture_prompts import CATEGORY_PROMPTS, CATEGORY_SCHEMAS
|
|
27
|
+
from codedd_cli.auditor.architecture_schema_contract import (
|
|
28
|
+
ensure_schema_registry_valid,
|
|
29
|
+
normalize_llm_category_payload,
|
|
30
|
+
)
|
|
27
31
|
from codedd_cli.auditor.enhanced_architecture_detectors import (
|
|
28
32
|
LocalEnhancedRelationshipDetector,
|
|
29
33
|
LocalEnhancedTechnologyDetector,
|
|
30
34
|
)
|
|
35
|
+
from codedd_cli.utils.safe_path import resolve_path_within_base
|
|
31
36
|
|
|
32
37
|
logger = logging.getLogger(__name__)
|
|
33
38
|
|
|
@@ -551,8 +556,8 @@ def _identify_technologies(
|
|
|
551
556
|
if prefix and dep_path.startswith(prefix)
|
|
552
557
|
else _get_relative_path(dep_path)
|
|
553
558
|
)
|
|
554
|
-
local_path =
|
|
555
|
-
if not os.path.isfile(local_path):
|
|
559
|
+
local_path = resolve_path_within_base(local_base, rel)
|
|
560
|
+
if not local_path or not os.path.isfile(local_path):
|
|
556
561
|
continue
|
|
557
562
|
file_name = os.path.basename(local_path)
|
|
558
563
|
try:
|
|
@@ -837,9 +842,26 @@ def _build_phase2_skeleton(
|
|
|
837
842
|
}
|
|
838
843
|
|
|
839
844
|
|
|
840
|
-
def _read_file_safely_local(
|
|
845
|
+
def _read_file_safely_local(
|
|
846
|
+
local_path: str,
|
|
847
|
+
max_chars: int = _MAX_CONTENT_CHARS,
|
|
848
|
+
*,
|
|
849
|
+
local_base: str | None = None,
|
|
850
|
+
) -> str | None:
|
|
841
851
|
"""Read file content with size limit; truncate by character count. Returns None on error."""
|
|
842
852
|
try:
|
|
853
|
+
if local_base:
|
|
854
|
+
try:
|
|
855
|
+
base_real = os.path.realpath(local_base)
|
|
856
|
+
path_real = os.path.realpath(local_path)
|
|
857
|
+
except OSError:
|
|
858
|
+
return None
|
|
859
|
+
try:
|
|
860
|
+
if os.path.commonpath([base_real, path_real]) != base_real:
|
|
861
|
+
return None
|
|
862
|
+
except ValueError:
|
|
863
|
+
return None
|
|
864
|
+
local_path = path_real
|
|
843
865
|
if not os.path.isfile(local_path):
|
|
844
866
|
return None
|
|
845
867
|
size = os.path.getsize(local_path)
|
|
@@ -902,17 +924,9 @@ def _parse_llm_response(response_text: str, file_path: str, category: str) -> di
|
|
|
902
924
|
return None
|
|
903
925
|
if not isinstance(data, dict):
|
|
904
926
|
return None
|
|
927
|
+
ensure_schema_registry_valid(CATEGORY_SCHEMAS, CATEGORY_PROMPTS)
|
|
905
928
|
schema = CATEGORY_SCHEMAS.get(category, CATEGORY_SCHEMAS["config_files"])
|
|
906
|
-
|
|
907
|
-
if key not in data:
|
|
908
|
-
data[key] = schema[key] if isinstance(schema[key], (list, dict)) else ""
|
|
909
|
-
else:
|
|
910
|
-
expected_type = type(schema[key])
|
|
911
|
-
if not isinstance(data[key], expected_type):
|
|
912
|
-
logger.warning(
|
|
913
|
-
"Schema drift detected for category=%r key=%r: expected %s, got %s (file=%s)",
|
|
914
|
-
category, key, expected_type.__name__, type(data[key]).__name__, file_path,
|
|
915
|
-
)
|
|
929
|
+
data = normalize_llm_category_payload(data, category, schema)
|
|
916
930
|
data["file_path"] = file_path
|
|
917
931
|
data["analysis_category"] = category
|
|
918
932
|
data["analysis_timestamp"] = time.time()
|
|
@@ -1954,9 +1968,11 @@ def _analyze_single_file(
|
|
|
1954
1968
|
gemini_client: Any,
|
|
1955
1969
|
grok_client: Any,
|
|
1956
1970
|
on_debug: Callable[[str], None] | None,
|
|
1971
|
+
*,
|
|
1972
|
+
local_base: str | None = None,
|
|
1957
1973
|
) -> dict[str, Any] | None:
|
|
1958
1974
|
"""Read file, build prompt, call LLM, parse. Returns analysis dict or None."""
|
|
1959
|
-
content = _read_file_safely_local(local_path)
|
|
1975
|
+
content = _read_file_safely_local(local_path, local_base=local_base)
|
|
1960
1976
|
if not content:
|
|
1961
1977
|
return None
|
|
1962
1978
|
prompt = _build_analysis_prompt(file_path, content, category)
|
|
@@ -2088,8 +2104,8 @@ def run_phase2_with_llm(
|
|
|
2088
2104
|
if not file_path.startswith(prefix):
|
|
2089
2105
|
continue
|
|
2090
2106
|
rel = file_path[len(prefix):].lstrip("/")
|
|
2091
|
-
local_path =
|
|
2092
|
-
if os.path.isfile(local_path):
|
|
2107
|
+
local_path = resolve_path_within_base(local_base, rel)
|
|
2108
|
+
if local_path and os.path.isfile(local_path):
|
|
2093
2109
|
work_items.append((category, file_path, local_path))
|
|
2094
2110
|
|
|
2095
2111
|
total_files = len(work_items)
|
|
@@ -2119,7 +2135,9 @@ def run_phase2_with_llm(
|
|
|
2119
2135
|
grok_key,
|
|
2120
2136
|
)
|
|
2121
2137
|
try:
|
|
2122
|
-
result = _analyze_single_file(
|
|
2138
|
+
result = _analyze_single_file(
|
|
2139
|
+
fp, lp, cat, a_client, o_client, g_client, x_client, on_debug, local_base=local_base,
|
|
2140
|
+
)
|
|
2123
2141
|
with progress_lock:
|
|
2124
2142
|
done_counter[0] += 1
|
|
2125
2143
|
done = done_counter[0]
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Category-specific prompts and output schemas for CLI architecture Phase 2.
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
Schema contract (enforced at runtime — see ``architecture_schema_contract``):
|
|
5
|
+
- ``SERVER_PARITY_CATEGORIES`` (8): key sets mirror server ``LLMAnalyzer.category_schemas``.
|
|
6
|
+
- ``CLI_EXTENSION_CATEGORIES`` (4): CLI-only categories with the same validation rules.
|
|
7
|
+
- ``SCHEMA_CONTRACT_VERSION``: bump when shared keys change on either side.
|
|
8
|
+
|
|
9
|
+
Per-file LLM JSON is normalized in ``architecture_analyzer._parse_llm_response``.
|
|
6
10
|
"""
|
|
7
11
|
|
|
8
12
|
# Output structure expected from the LLM per category (server-compatible)
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Runtime contract for architecture Phase 2 LLM category schemas.
|
|
3
|
+
|
|
4
|
+
The eight ``SERVER_PARITY_CATEGORIES`` mirror ``LLMAnalyzer.category_schemas`` on
|
|
5
|
+
the CodeDD server (django auditor). Four ``CLI_EXTENSION_CATEGORIES`` are
|
|
6
|
+
CLI-only enrichments; they use the same validation machinery but are not sent
|
|
7
|
+
through the legacy server per-file parser.
|
|
8
|
+
|
|
9
|
+
``SCHEMA_CONTRACT_VERSION`` must be bumped when either side changes shared keys.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
SCHEMA_CONTRACT_VERSION = "1.0.0"
|
|
22
|
+
|
|
23
|
+
# Categories whose key sets are kept in parity with the server LLMAnalyzer.
|
|
24
|
+
SERVER_PARITY_CATEGORIES = frozenset(
|
|
25
|
+
{
|
|
26
|
+
"dependency_files",
|
|
27
|
+
"infrastructure_files",
|
|
28
|
+
"backend_files",
|
|
29
|
+
"frontend_files",
|
|
30
|
+
"database_files",
|
|
31
|
+
"ci_cd_files",
|
|
32
|
+
"testing_files",
|
|
33
|
+
"config_files",
|
|
34
|
+
}
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# Additional CLI architecture categories (validated locally; not server-parity).
|
|
38
|
+
CLI_EXTENSION_CATEGORIES = frozenset(
|
|
39
|
+
{
|
|
40
|
+
"api_schema_files",
|
|
41
|
+
"monitoring_files",
|
|
42
|
+
"message_queue_files",
|
|
43
|
+
"security_auth_files",
|
|
44
|
+
}
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
ALL_ARCHITECTURE_CATEGORIES = SERVER_PARITY_CATEGORIES | CLI_EXTENSION_CATEGORIES
|
|
48
|
+
|
|
49
|
+
_registry_checked = False
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def category_schema_fingerprint(schema: dict[str, Any]) -> str:
|
|
53
|
+
"""Stable short fingerprint of a schema's top-level keys (order-independent)."""
|
|
54
|
+
payload = json.dumps(sorted(schema.keys()), separators=(",", ":"))
|
|
55
|
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def validate_schema_registry(
|
|
59
|
+
category_schemas: dict[str, dict[str, Any]],
|
|
60
|
+
category_prompts: dict[str, str],
|
|
61
|
+
) -> list[str]:
|
|
62
|
+
"""
|
|
63
|
+
Verify prompt/schema registry integrity.
|
|
64
|
+
|
|
65
|
+
Returns a list of human-readable errors (empty when valid).
|
|
66
|
+
"""
|
|
67
|
+
errors: list[str] = []
|
|
68
|
+
|
|
69
|
+
schema_cats = set(category_schemas)
|
|
70
|
+
prompt_cats = set(category_prompts)
|
|
71
|
+
|
|
72
|
+
missing_prompts = schema_cats - prompt_cats
|
|
73
|
+
missing_schemas = prompt_cats - schema_cats
|
|
74
|
+
if missing_prompts:
|
|
75
|
+
errors.append(f"CATEGORY_SCHEMAS without prompts: {sorted(missing_prompts)}")
|
|
76
|
+
if missing_schemas:
|
|
77
|
+
errors.append(f"CATEGORY_PROMPTS without schemas: {sorted(missing_schemas)}")
|
|
78
|
+
|
|
79
|
+
for category, schema in category_schemas.items():
|
|
80
|
+
if not isinstance(schema, dict) or not schema:
|
|
81
|
+
errors.append(f"Empty or invalid schema for category {category!r}")
|
|
82
|
+
continue
|
|
83
|
+
if "file_type" not in schema:
|
|
84
|
+
errors.append(f"Schema for {category!r} missing required file_type key")
|
|
85
|
+
|
|
86
|
+
unknown = schema_cats - ALL_ARCHITECTURE_CATEGORIES
|
|
87
|
+
if unknown:
|
|
88
|
+
errors.append(f"Unknown architecture categories: {sorted(unknown)}")
|
|
89
|
+
|
|
90
|
+
return errors
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def ensure_schema_registry_valid(
|
|
94
|
+
category_schemas: dict[str, dict[str, Any]],
|
|
95
|
+
category_prompts: dict[str, str],
|
|
96
|
+
) -> None:
|
|
97
|
+
"""Run ``validate_schema_registry`` once per process; log errors on failure."""
|
|
98
|
+
global _registry_checked
|
|
99
|
+
if _registry_checked:
|
|
100
|
+
return
|
|
101
|
+
_registry_checked = True
|
|
102
|
+
errors = validate_schema_registry(category_schemas, category_prompts)
|
|
103
|
+
if errors:
|
|
104
|
+
logger.error(
|
|
105
|
+
"Architecture schema registry failed contract %s: %s",
|
|
106
|
+
SCHEMA_CONTRACT_VERSION,
|
|
107
|
+
"; ".join(errors),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def normalize_llm_category_payload(
|
|
112
|
+
data: dict[str, Any],
|
|
113
|
+
category: str,
|
|
114
|
+
schema: dict[str, Any],
|
|
115
|
+
) -> dict[str, Any]:
|
|
116
|
+
"""
|
|
117
|
+
Enforce schema shape: backfill missing keys and coerce wrong JSON types.
|
|
118
|
+
|
|
119
|
+
Unknown keys from the LLM are preserved so forward-compatible fields are not
|
|
120
|
+
dropped. Type coercion follows the template value in *schema*.
|
|
121
|
+
"""
|
|
122
|
+
normalized = dict(data)
|
|
123
|
+
for key, template in schema.items():
|
|
124
|
+
if key not in normalized:
|
|
125
|
+
normalized[key] = (
|
|
126
|
+
list(template) if isinstance(template, list)
|
|
127
|
+
else dict(template) if isinstance(template, dict)
|
|
128
|
+
else template
|
|
129
|
+
)
|
|
130
|
+
continue
|
|
131
|
+
value = normalized[key]
|
|
132
|
+
if isinstance(template, list) and not isinstance(value, list):
|
|
133
|
+
logger.warning(
|
|
134
|
+
"Schema drift for category=%r key=%r: expected list, got %s",
|
|
135
|
+
category,
|
|
136
|
+
key,
|
|
137
|
+
type(value).__name__,
|
|
138
|
+
)
|
|
139
|
+
normalized[key] = []
|
|
140
|
+
elif isinstance(template, dict) and not isinstance(value, dict):
|
|
141
|
+
logger.warning(
|
|
142
|
+
"Schema drift for category=%r key=%r: expected dict, got %s",
|
|
143
|
+
category,
|
|
144
|
+
key,
|
|
145
|
+
type(value).__name__,
|
|
146
|
+
)
|
|
147
|
+
normalized[key] = {}
|
|
148
|
+
elif isinstance(template, str) and not isinstance(value, str):
|
|
149
|
+
normalized[key] = str(value) if value is not None else ""
|
|
150
|
+
return normalized
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Persistent checkpoint for resumable local audit sessions.
|
|
3
|
+
|
|
4
|
+
State is written to ``~/.codedd/audit_progress_{audit_uuid}.json`` after
|
|
5
|
+
every batch submission so an interrupted audit can resume where it left off.
|
|
6
|
+
Writes are atomic (write-then-rename) so a crash mid-save never corrupts the
|
|
7
|
+
file.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
_FORMAT_VERSION = 1
|
|
21
|
+
_FILENAME_PREFIX = "audit_progress_"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AuditCheckpoint:
|
|
25
|
+
"""Tracks per-sub-audit submission progress across CLI sessions."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, audit_uuid: str, config_dir: Path) -> None:
|
|
28
|
+
self._audit_uuid = audit_uuid
|
|
29
|
+
self._path = config_dir / f"{_FILENAME_PREFIX}{audit_uuid}.json"
|
|
30
|
+
self._data: dict = self._load()
|
|
31
|
+
|
|
32
|
+
# ---- Internal persistence -----------------------------------------------
|
|
33
|
+
|
|
34
|
+
def _empty(self) -> dict:
|
|
35
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
36
|
+
return {
|
|
37
|
+
"version": _FORMAT_VERSION,
|
|
38
|
+
"audit_uuid": self._audit_uuid,
|
|
39
|
+
"created_at": now,
|
|
40
|
+
"updated_at": now,
|
|
41
|
+
"sub_audits": {},
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
def _load(self) -> dict:
|
|
45
|
+
if not self._path.exists():
|
|
46
|
+
return self._empty()
|
|
47
|
+
try:
|
|
48
|
+
with open(self._path, encoding="utf-8") as fh:
|
|
49
|
+
data = json.load(fh)
|
|
50
|
+
if not isinstance(data, dict) or data.get("audit_uuid") != self._audit_uuid:
|
|
51
|
+
logger.warning("Checkpoint mismatch or corrupt — starting fresh.")
|
|
52
|
+
return self._empty()
|
|
53
|
+
return data
|
|
54
|
+
except Exception as exc:
|
|
55
|
+
logger.warning("Cannot read checkpoint (%s) — starting fresh.", exc)
|
|
56
|
+
return self._empty()
|
|
57
|
+
|
|
58
|
+
def _save(self) -> None:
|
|
59
|
+
self._data["updated_at"] = datetime.now(timezone.utc).isoformat()
|
|
60
|
+
tmp = self._path.with_suffix(".json.tmp")
|
|
61
|
+
try:
|
|
62
|
+
with open(tmp, "w", encoding="utf-8") as fh:
|
|
63
|
+
json.dump(self._data, fh, indent=2)
|
|
64
|
+
os.replace(tmp, self._path)
|
|
65
|
+
except Exception as exc:
|
|
66
|
+
logger.warning("Checkpoint save failed: %s", exc)
|
|
67
|
+
try:
|
|
68
|
+
tmp.unlink(missing_ok=True)
|
|
69
|
+
except Exception:
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
def _sub(self, sub_uuid: str) -> dict:
|
|
73
|
+
subs = self._data.setdefault("sub_audits", {})
|
|
74
|
+
if sub_uuid not in subs:
|
|
75
|
+
subs[sub_uuid] = {"submitted_file_paths": [], "vuln_submitted_count": 0}
|
|
76
|
+
return subs[sub_uuid]
|
|
77
|
+
|
|
78
|
+
# ---- File audit phase ---------------------------------------------------
|
|
79
|
+
|
|
80
|
+
def get_submitted_files(self, sub_uuid: str) -> set[str]:
|
|
81
|
+
"""Return the set of file_path strings already submitted for this sub-audit."""
|
|
82
|
+
return set(self._sub(sub_uuid).get("submitted_file_paths", []))
|
|
83
|
+
|
|
84
|
+
def mark_files_submitted(self, sub_uuid: str, file_paths: list[str]) -> None:
|
|
85
|
+
"""Append newly submitted file paths and persist immediately."""
|
|
86
|
+
sub = self._sub(sub_uuid)
|
|
87
|
+
existing: set[str] = set(sub.get("submitted_file_paths", []))
|
|
88
|
+
added = False
|
|
89
|
+
for fp in file_paths:
|
|
90
|
+
if fp not in existing:
|
|
91
|
+
sub.setdefault("submitted_file_paths", []).append(fp)
|
|
92
|
+
existing.add(fp)
|
|
93
|
+
added = True
|
|
94
|
+
if added:
|
|
95
|
+
self._save()
|
|
96
|
+
|
|
97
|
+
# ---- Vulnerability validation phase ------------------------------------
|
|
98
|
+
|
|
99
|
+
def get_vuln_submitted_count(self, sub_uuid: str) -> int:
|
|
100
|
+
"""Number of vuln-validation outcomes already submitted for this sub-audit."""
|
|
101
|
+
# Path-based tracking is preferred; fall back to legacy count field.
|
|
102
|
+
paths = self._sub(sub_uuid).get("vuln_validated_paths")
|
|
103
|
+
if paths is not None:
|
|
104
|
+
return len(paths)
|
|
105
|
+
return int(self._sub(sub_uuid).get("vuln_submitted_count", 0))
|
|
106
|
+
|
|
107
|
+
def mark_vulns_submitted(self, sub_uuid: str, total_count: int) -> None:
|
|
108
|
+
"""Update the total count of submitted vuln validations and persist."""
|
|
109
|
+
self._sub(sub_uuid)["vuln_submitted_count"] = total_count
|
|
110
|
+
self._save()
|
|
111
|
+
|
|
112
|
+
def get_vuln_validated_paths(self, sub_uuid: str) -> set[str]:
|
|
113
|
+
"""Return file_paths whose validation outcomes were already submitted."""
|
|
114
|
+
return set(self._sub(sub_uuid).get("vuln_validated_paths", []))
|
|
115
|
+
|
|
116
|
+
def mark_vuln_paths_submitted(self, sub_uuid: str, file_paths: list[str]) -> None:
|
|
117
|
+
"""Append newly submitted vuln file_paths and persist immediately."""
|
|
118
|
+
sub = self._sub(sub_uuid)
|
|
119
|
+
existing: list[str] = sub.setdefault("vuln_validated_paths", [])
|
|
120
|
+
existing_set = set(existing)
|
|
121
|
+
added = False
|
|
122
|
+
for fp in file_paths:
|
|
123
|
+
if fp not in existing_set:
|
|
124
|
+
existing.append(fp)
|
|
125
|
+
existing_set.add(fp)
|
|
126
|
+
added = True
|
|
127
|
+
if added:
|
|
128
|
+
self._save()
|
|
129
|
+
|
|
130
|
+
def set_vuln_candidates(self, sub_uuid: str, candidates: list[dict]) -> None:
|
|
131
|
+
"""Persist serialised validation candidates for recovery on resume."""
|
|
132
|
+
self._sub(sub_uuid)["vuln_candidates"] = candidates
|
|
133
|
+
self._save()
|
|
134
|
+
|
|
135
|
+
def get_vuln_candidates(self, sub_uuid: str) -> list[dict]:
|
|
136
|
+
"""Return previously persisted validation candidates (empty if none)."""
|
|
137
|
+
return list(self._sub(sub_uuid).get("vuln_candidates", []))
|
|
138
|
+
|
|
139
|
+
# ---- Audit plan metadata ------------------------------------------------
|
|
140
|
+
|
|
141
|
+
def set_plan_file_count(self, count: int) -> None:
|
|
142
|
+
"""Persist the total number of files in the audit plan."""
|
|
143
|
+
if count > 0:
|
|
144
|
+
self._data["plan_file_count"] = count
|
|
145
|
+
self._save()
|
|
146
|
+
|
|
147
|
+
def get_plan_file_count(self) -> int:
|
|
148
|
+
"""Return the stored audit plan file count (0 if not yet set)."""
|
|
149
|
+
return int(self._data.get("plan_file_count", 0))
|
|
150
|
+
|
|
151
|
+
# ---- Introspection ------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
def has_progress(self) -> bool:
|
|
154
|
+
"""True if the checkpoint file exists and has at least one submitted file."""
|
|
155
|
+
if not self._path.exists():
|
|
156
|
+
return False
|
|
157
|
+
return self.total_submitted_files() > 0
|
|
158
|
+
|
|
159
|
+
def total_submitted_files(self) -> int:
|
|
160
|
+
return sum(
|
|
161
|
+
len(sub.get("submitted_file_paths", []))
|
|
162
|
+
for sub in self._data.get("sub_audits", {}).values()
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
def created_at(self) -> str | None:
|
|
166
|
+
return self._data.get("created_at")
|
|
167
|
+
|
|
168
|
+
@property
|
|
169
|
+
def path(self) -> Path:
|
|
170
|
+
return self._path
|
|
171
|
+
|
|
172
|
+
# ---- Lifecycle ----------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
def clear(self) -> None:
|
|
175
|
+
"""Delete checkpoint file after a successful, complete audit."""
|
|
176
|
+
try:
|
|
177
|
+
self._path.unlink(missing_ok=True)
|
|
178
|
+
except Exception as exc:
|
|
179
|
+
logger.warning("Could not delete checkpoint file: %s", exc)
|
|
@@ -243,7 +243,8 @@ class CommitClassifier:
|
|
|
243
243
|
|
|
244
244
|
# === Tertiary: change-ratio heuristics ===
|
|
245
245
|
if total_added > 0:
|
|
246
|
-
|
|
246
|
+
deletion_ratio = total_deleted / total_added
|
|
247
|
+
if deletion_ratio > 0.7:
|
|
247
248
|
return {"type": "refactoring", "confidence": 0.6,
|
|
248
249
|
"method": "change_ratio", "keywords_matched": []}
|
|
249
250
|
if total_changes < 50:
|
|
@@ -41,6 +41,8 @@ from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
|
41
41
|
from dataclasses import dataclass
|
|
42
42
|
from typing import Any
|
|
43
43
|
|
|
44
|
+
from codedd_cli.utils.safe_path import resolve_path_within_base
|
|
45
|
+
|
|
44
46
|
logger = logging.getLogger(__name__)
|
|
45
47
|
|
|
46
48
|
|
|
@@ -2009,10 +2011,13 @@ class LocalComplexityAnalyzer:
|
|
|
2009
2011
|
error="Missing local directory or relative path",
|
|
2010
2012
|
)
|
|
2011
2013
|
|
|
2012
|
-
disk_path =
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2014
|
+
disk_path = resolve_path_within_base(local_dir, relative_path)
|
|
2015
|
+
if disk_path is None:
|
|
2016
|
+
return FileComplexityResult(
|
|
2017
|
+
file_path=file_path,
|
|
2018
|
+
relative_path=relative_path,
|
|
2019
|
+
error="Relative path escapes the local scope directory",
|
|
2020
|
+
)
|
|
2016
2021
|
|
|
2017
2022
|
try:
|
|
2018
2023
|
with open(disk_path, encoding="utf-8", errors="replace") as fh:
|
|
@@ -314,7 +314,7 @@ class ManifestParser:
|
|
|
314
314
|
raw_packages = dispatch_table[ext](file_content)
|
|
315
315
|
else:
|
|
316
316
|
for pattern, parser_func in dispatch_table.items():
|
|
317
|
-
if
|
|
317
|
+
if filename_lower == pattern.lower():
|
|
318
318
|
raw_packages = parser_func(file_content)
|
|
319
319
|
break
|
|
320
320
|
else:
|