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.
Files changed (62) hide show
  1. {codedd_cli-0.1.4 → codedd_cli-0.1.6}/PKG-INFO +1 -1
  2. {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/__init__.py +3 -3
  3. {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/api/client.py +4 -7
  4. {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/api/endpoints.py +9 -2
  5. {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/api/exceptions.py +24 -24
  6. {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/auditor/archetype_classifier.py +1 -1
  7. {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/auditor/architecture_analyzer.py +35 -17
  8. {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/auditor/architecture_prompts.py +6 -2
  9. codedd_cli-0.1.6/codedd_cli/auditor/architecture_schema_contract.py +150 -0
  10. codedd_cli-0.1.6/codedd_cli/auditor/audit_checkpoint.py +179 -0
  11. {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/auditor/commit_classifier.py +2 -1
  12. {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/auditor/complexity_analyzer.py +9 -4
  13. {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/auditor/dependency_scanner.py +1 -1
  14. {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/auditor/enhanced_architecture_detectors.py +387 -381
  15. {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/auditor/file_auditor.py +59 -15
  16. {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/auditor/git_stats_collector.py +11 -0
  17. {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/auditor/response_parser.py +484 -484
  18. {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/auditor/sub_audit_runner.py +267 -122
  19. {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/auditor/vulnerability_validator.py +40 -6
  20. {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/auth/session.py +9 -1
  21. {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/auth/token_manager.py +6 -1
  22. {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/commands/__init__.py +1 -1
  23. {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/commands/ai_docs_cmd.py +1115 -1000
  24. {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/commands/audit_cmd.py +663 -18
  25. {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/commands/audits_cmd.py +49 -7
  26. {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/commands/auth_cmd.py +15 -32
  27. {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/commands/config_cmd.py +43 -45
  28. codedd_cli-0.1.6/codedd_cli/commands/fix_cmd.py +2677 -0
  29. {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/commands/scope_cmd.py +70 -33
  30. codedd_cli-0.1.6/codedd_cli/config/constants.py +38 -0
  31. {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/config/fix_session.py +47 -1
  32. {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/config/settings.py +138 -17
  33. codedd_cli-0.1.6/codedd_cli/config/url_validation.py +139 -0
  34. {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/llm/key_manager.py +4 -1
  35. {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/models/audit.py +11 -11
  36. {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/scanner/file_classifier.py +7 -1
  37. {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/scanner/file_walker.py +28 -0
  38. {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/scanner/line_counter.py +1 -1
  39. {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/utils/__init__.py +1 -1
  40. {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/utils/directory_validator.py +11 -1
  41. {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/utils/display.py +64 -36
  42. {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/utils/payload_inspector.py +1 -1
  43. codedd_cli-0.1.6/codedd_cli/utils/safe_path.py +51 -0
  44. {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/utils/validators.py +6 -7
  45. {codedd_cli-0.1.4 → codedd_cli-0.1.6}/pyproject.toml +67 -67
  46. codedd_cli-0.1.4/codedd_cli/commands/fix_cmd.py +0 -1119
  47. codedd_cli-0.1.4/codedd_cli/config/constants.py +0 -26
  48. {codedd_cli-0.1.4 → codedd_cli-0.1.6}/LICENSE +0 -0
  49. {codedd_cli-0.1.4 → codedd_cli-0.1.6}/README.md +0 -0
  50. {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/__main__.py +0 -0
  51. {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/api/__init__.py +0 -0
  52. {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/auditor/__init__.py +0 -0
  53. {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/auditor/tier_definitions.py +0 -0
  54. {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/auth/__init__.py +0 -0
  55. {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/cli.py +0 -0
  56. {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/config/__init__.py +0 -0
  57. {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/llm/__init__.py +0 -0
  58. {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/models/__init__.py +0 -0
  59. {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/models/account.py +0 -0
  60. {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/models/local_directory.py +0 -0
  61. {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/scanner/__init__.py +0 -0
  62. {codedd_cli-0.1.4 → codedd_cli-0.1.6}/codedd_cli/utils/security.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codedd-cli
3
- Version: 0.1.4
3
+ Version: 0.1.6
4
4
  Summary: CLI tool for CodeDD — run code audits from your terminal
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -1,3 +1,3 @@
1
- """CodeDD CLI — run code audits from your terminal."""
2
-
3
- __version__ = "0.1.4"
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
- # For localhost HTTP, disable TLS verification (not applicable anyway)
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 relative to the ``api_url`` stored in the CLI config
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
- DELETE_REPO_FROM_SCOPE = "/delete_repo_from_scope/"
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 = os.path.normpath(os.path.join(local_base, rel))
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(local_path: str, max_chars: int = _MAX_CONTENT_CHARS) -> str | None:
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
- for key in schema:
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 = os.path.normpath(os.path.join(local_base, rel))
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(fp, lp, cat, a_client, o_client, g_client, x_client, on_debug)
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
- Kept in sync with the server's LLMAnalyzer.category_prompts and category_schemas
5
- so that per-file analysis produces the same structure the server expects.
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
- if total_deleted / total_added > 0.7:
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 = os.path.join(
2013
- local_dir,
2014
- relative_path.replace("\\", os.sep).replace("/", os.sep),
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 file_path.endswith(pattern):
317
+ if filename_lower == pattern.lower():
318
318
  raw_packages = parser_func(file_content)
319
319
  break
320
320
  else: