codedd-cli 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. codedd_cli/__init__.py +3 -0
  2. codedd_cli/__main__.py +19 -0
  3. codedd_cli/api/__init__.py +4 -0
  4. codedd_cli/api/client.py +120 -0
  5. codedd_cli/api/endpoints.py +44 -0
  6. codedd_cli/api/exceptions.py +24 -0
  7. codedd_cli/auditor/__init__.py +6 -0
  8. codedd_cli/auditor/architecture_analyzer.py +1251 -0
  9. codedd_cli/auditor/architecture_prompts.py +173 -0
  10. codedd_cli/auditor/complexity_analyzer.py +1739 -0
  11. codedd_cli/auditor/dependency_scanner.py +2485 -0
  12. codedd_cli/auditor/file_auditor.py +578 -0
  13. codedd_cli/auditor/git_stats_collector.py +417 -0
  14. codedd_cli/auditor/response_parser.py +484 -0
  15. codedd_cli/auditor/vulnerability_validator.py +323 -0
  16. codedd_cli/auth/__init__.py +4 -0
  17. codedd_cli/auth/session.py +40 -0
  18. codedd_cli/auth/token_manager.py +86 -0
  19. codedd_cli/cli.py +69 -0
  20. codedd_cli/commands/__init__.py +1 -0
  21. codedd_cli/commands/audit_cmd.py +1987 -0
  22. codedd_cli/commands/audits_cmd.py +276 -0
  23. codedd_cli/commands/auth_cmd.py +235 -0
  24. codedd_cli/commands/config_cmd.py +421 -0
  25. codedd_cli/commands/scope_cmd.py +1016 -0
  26. codedd_cli/config/__init__.py +4 -0
  27. codedd_cli/config/constants.py +22 -0
  28. codedd_cli/config/settings.py +389 -0
  29. codedd_cli/llm/__init__.py +1 -0
  30. codedd_cli/llm/key_manager.py +267 -0
  31. codedd_cli/models/__init__.py +5 -0
  32. codedd_cli/models/account.py +13 -0
  33. codedd_cli/models/audit.py +31 -0
  34. codedd_cli/models/local_directory.py +25 -0
  35. codedd_cli/scanner/__init__.py +18 -0
  36. codedd_cli/scanner/file_classifier.py +752 -0
  37. codedd_cli/scanner/file_walker.py +213 -0
  38. codedd_cli/scanner/line_counter.py +80 -0
  39. codedd_cli/utils/__init__.py +1 -0
  40. codedd_cli/utils/directory_validator.py +178 -0
  41. codedd_cli/utils/display.py +497 -0
  42. codedd_cli/utils/payload_inspector.py +178 -0
  43. codedd_cli/utils/security.py +14 -0
  44. codedd_cli/utils/validators.py +37 -0
  45. codedd_cli-0.1.1.dist-info/METADATA +306 -0
  46. codedd_cli-0.1.1.dist-info/RECORD +49 -0
  47. codedd_cli-0.1.1.dist-info/WHEEL +4 -0
  48. codedd_cli-0.1.1.dist-info/entry_points.txt +3 -0
  49. codedd_cli-0.1.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,213 @@
1
+ """
2
+ Local repository scanner.
3
+
4
+ Walks a Git repository directory, classifies each file, counts lines of code,
5
+ and produces a structured metadata payload ready for submission to the CodeDD API.
6
+
7
+ **No file contents are included** — only paths, types, and line counts.
8
+ """
9
+
10
+ import logging
11
+ import os
12
+ import subprocess
13
+ from collections.abc import Callable
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+
17
+ from codedd_cli.scanner.file_classifier import (
18
+ get_file_type,
19
+ should_exclude_directory,
20
+ should_exclude_file,
21
+ )
22
+ from codedd_cli.scanner.line_counter import count_lines
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ @dataclass
28
+ class FileMetadata:
29
+ """Metadata for a single scanned file (no content)."""
30
+
31
+ relative_path: str
32
+ file_type: str
33
+ lines_of_code: int
34
+ lines_of_doc: int
35
+ selected_for_audit: bool = True
36
+
37
+
38
+ @dataclass
39
+ class FolderMetadata:
40
+ """Aggregated metadata for a folder."""
41
+
42
+ relative_path: str
43
+ lines_of_code: int = 0
44
+ file_count: int = 0
45
+
46
+
47
+ @dataclass
48
+ class ScanResult:
49
+ """Complete scan result for a single repository directory."""
50
+
51
+ root_path: str
52
+ repo_name: str
53
+ branch: str
54
+ commit_hash: str
55
+ files: list[FileMetadata] = field(default_factory=list)
56
+ folders: list[FolderMetadata] = field(default_factory=list)
57
+ total_files: int = 0
58
+ total_lines_of_code: int = 0
59
+ total_lines_of_doc: int = 0
60
+ errors: list[str] = field(default_factory=list)
61
+
62
+ def to_dict(self) -> dict:
63
+ """Serialise to a plain dict suitable for JSON encoding."""
64
+ return {
65
+ "root_path": self.root_path,
66
+ "repo_name": self.repo_name,
67
+ "branch": self.branch,
68
+ "commit_hash": self.commit_hash,
69
+ "total_files": self.total_files,
70
+ "total_lines_of_code": self.total_lines_of_code,
71
+ "total_lines_of_doc": self.total_lines_of_doc,
72
+ "files": [
73
+ {
74
+ "relative_path": f.relative_path,
75
+ "file_type": f.file_type,
76
+ "lines_of_code": f.lines_of_code,
77
+ "lines_of_doc": f.lines_of_doc,
78
+ "selected_for_audit": f.selected_for_audit,
79
+ }
80
+ for f in self.files
81
+ ],
82
+ "folders": [
83
+ {
84
+ "relative_path": fd.relative_path,
85
+ "lines_of_code": fd.lines_of_code,
86
+ "file_count": fd.file_count,
87
+ }
88
+ for fd in self.folders
89
+ ],
90
+ "errors": self.errors,
91
+ }
92
+
93
+
94
+ def _git_info(repo_path: str) -> tuple[str, str]:
95
+ """Return (branch, short_commit_hash) for a repo, or empty strings on error."""
96
+ try:
97
+ branch = subprocess.run(
98
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
99
+ cwd=repo_path,
100
+ capture_output=True,
101
+ text=True,
102
+ timeout=10,
103
+ ).stdout.strip()
104
+
105
+ commit = subprocess.run(
106
+ ["git", "rev-parse", "--short", "HEAD"],
107
+ cwd=repo_path,
108
+ capture_output=True,
109
+ text=True,
110
+ timeout=10,
111
+ ).stdout.strip()
112
+
113
+ return branch, commit
114
+ except Exception:
115
+ return "", ""
116
+
117
+
118
+ def scan_repository(
119
+ root_path: str,
120
+ progress_callback: Callable[[int, int, str], None] | None = None,
121
+ ) -> ScanResult:
122
+ """
123
+ Walk *root_path*, classify every file, and count lines of code.
124
+
125
+ Args:
126
+ root_path: Absolute path to the repository root.
127
+ progress_callback: Optional callable(current, total, file_path) invoked
128
+ for each file processed. Useful for Rich progress bars.
129
+
130
+ Returns:
131
+ A ``ScanResult`` containing all file and folder metadata.
132
+ """
133
+ root = Path(root_path).resolve()
134
+ repo_name = root.name
135
+ branch, commit = _git_info(str(root))
136
+
137
+ result = ScanResult(
138
+ root_path=str(root),
139
+ repo_name=repo_name,
140
+ branch=branch,
141
+ commit_hash=commit,
142
+ )
143
+
144
+ # Phase 1: collect all eligible file paths
145
+ file_paths: list[str] = []
146
+ for dirpath, dirnames, filenames in os.walk(str(root)):
147
+ # Filter out excluded directories in-place so os.walk skips them
148
+ dirnames[:] = [d for d in dirnames if not should_exclude_directory(d)]
149
+ for fname in filenames:
150
+ full_path = os.path.join(dirpath, fname)
151
+ # Skip symlinks
152
+ if os.path.islink(full_path):
153
+ continue
154
+ file_paths.append(full_path)
155
+
156
+ total_files = len(file_paths)
157
+ folder_aggregates: dict[str, FolderMetadata] = {}
158
+
159
+ # Phase 2: classify and count
160
+ for idx, full_path in enumerate(file_paths):
161
+ try:
162
+ rel_path = os.path.relpath(full_path, start=str(root)).replace("\\", "/")
163
+ file_type = get_file_type(full_path)
164
+
165
+ # Count LoC (returns (0, 0) for excluded files)
166
+ loc, doc = count_lines(full_path)
167
+
168
+ fm = FileMetadata(
169
+ relative_path=rel_path,
170
+ file_type=file_type,
171
+ lines_of_code=loc,
172
+ lines_of_doc=doc,
173
+ selected_for_audit=(not should_exclude_file(full_path) and loc > 0),
174
+ )
175
+ result.files.append(fm)
176
+
177
+ if fm.selected_for_audit:
178
+ result.total_files += 1
179
+ result.total_lines_of_code += loc
180
+ result.total_lines_of_doc += doc
181
+
182
+ # Aggregate into parent folder and ensure all ancestor folders exist.
183
+ # The server-side import (import_file_list.py) discovers ALL directories
184
+ # via os.scandir. The CLI must produce the same set so the folder
185
+ # hierarchy can be reconstructed on the server when building TypeDB
186
+ # directory_content relations (folder → parent_folder → root).
187
+ parent_rel = os.path.dirname(rel_path).replace("\\", "/")
188
+ if parent_rel and parent_rel != ".":
189
+ # Direct parent gets LoC / file_count
190
+ if parent_rel not in folder_aggregates:
191
+ folder_aggregates[parent_rel] = FolderMetadata(relative_path=parent_rel)
192
+ folder_aggregates[parent_rel].lines_of_code += loc
193
+ folder_aggregates[parent_rel].file_count += 1
194
+
195
+ # Ensure every ancestor folder exists (with 0 direct metrics)
196
+ ancestor = os.path.dirname(parent_rel).replace("\\", "/")
197
+ while ancestor and ancestor != ".":
198
+ if ancestor not in folder_aggregates:
199
+ folder_aggregates[ancestor] = FolderMetadata(relative_path=ancestor)
200
+ ancestor = os.path.dirname(ancestor).replace("\\", "/")
201
+
202
+ if progress_callback:
203
+ progress_callback(idx + 1, total_files, rel_path)
204
+
205
+ except Exception as exc:
206
+ error_msg = f"Error scanning {full_path}: {exc}"
207
+ logger.warning(error_msg)
208
+ result.errors.append(error_msg)
209
+
210
+ # Build sorted folder list
211
+ result.folders = sorted(folder_aggregates.values(), key=lambda f: f.relative_path)
212
+
213
+ return result
@@ -0,0 +1,80 @@
1
+ """
2
+ Local line-of-code counter.
3
+
4
+ Counts non-empty lines of code and comment/doc lines for a single file.
5
+ This is a pure-Python implementation that mirrors the server-side
6
+ ``get_code_and_doc_lines`` fallback logic — no external ``linecounter``
7
+ binary is required.
8
+ """
9
+
10
+ import os
11
+
12
+ from codedd_cli.scanner.file_classifier import (
13
+ get_file_type,
14
+ should_exclude_file,
15
+ )
16
+
17
+ # File types eligible for LoC counting
18
+ _COUNTABLE_TYPES = frozenset(
19
+ {
20
+ "Source Code",
21
+ "Configuration",
22
+ "Documentation",
23
+ "System",
24
+ "Security",
25
+ "Other",
26
+ }
27
+ )
28
+
29
+ # Single-line comment prefixes by convention
30
+ _COMMENT_PREFIXES = ("#", "//", "/*", "*", "*/", "--", ";", "%", "REM ")
31
+
32
+ # Maximum file size (in bytes) to attempt reading — skip very large binaries
33
+ _MAX_FILE_SIZE = 50 * 1024 * 1024 # 50 MB
34
+
35
+
36
+ def count_lines(file_path: str) -> tuple[int, int]:
37
+ """
38
+ Count lines of code and lines of documentation/comments in *file_path*.
39
+
40
+ Args:
41
+ file_path: Absolute path to the file.
42
+
43
+ Returns:
44
+ Tuple of (lines_of_code, lines_of_doc).
45
+ Returns ``(0, 0)`` for excluded or unreadable files.
46
+ """
47
+ if should_exclude_file(file_path):
48
+ return 0, 0
49
+
50
+ file_type = get_file_type(file_path)
51
+ if file_type not in _COUNTABLE_TYPES:
52
+ return 0, 0
53
+
54
+ # Safety: skip extremely large files
55
+ try:
56
+ size = os.path.getsize(file_path)
57
+ if size > _MAX_FILE_SIZE:
58
+ return 0, 0
59
+ except OSError:
60
+ return 0, 0
61
+
62
+ try:
63
+ with open(file_path, encoding="utf-8", errors="replace") as fh:
64
+ lines = fh.readlines()
65
+ except (OSError, PermissionError):
66
+ return 1, 0 # Minimum fallback
67
+
68
+ non_empty = 0
69
+ comment_lines = 0
70
+
71
+ for line in lines:
72
+ stripped = line.strip()
73
+ if not stripped:
74
+ continue
75
+ non_empty += 1
76
+ if stripped.startswith(_COMMENT_PREFIXES):
77
+ comment_lines += 1
78
+
79
+ code_lines = max(non_empty - comment_lines, 1) if non_empty > 0 else 0
80
+ return code_lines, comment_lines
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,178 @@
1
+ """
2
+ Local directory validation for audit scope.
3
+
4
+ Validates that a user-provided path:
5
+ 1. Exists on the filesystem
6
+ 2. Is a directory (not a file)
7
+ 3. Is readable / accessible by the current user
8
+ 4. Is the root of a Git repository (contains ``.git/``)
9
+ 5. Has at least one commit (valid HEAD)
10
+
11
+ Uses the **default branch** (main or master) for the audit, not the current
12
+ checkout. Fetches the default branch from origin when a remote exists so
13
+ the scope is tied to the canonical branch state.
14
+ """
15
+
16
+ import os
17
+ import subprocess
18
+ from pathlib import Path
19
+
20
+ from codedd_cli.models.local_directory import LocalDirectory
21
+
22
+ # Default branch names to try, in order of preference.
23
+ DEFAULT_BRANCH_ORDER = ("main", "master")
24
+
25
+
26
+ def validate_directory(raw_path: str) -> LocalDirectory:
27
+ """
28
+ Run all validation checks on *raw_path* and return a ``LocalDirectory``.
29
+
30
+ The returned object's ``is_valid`` flag indicates whether all checks
31
+ passed. When ``is_valid`` is False, the ``error`` field explains why.
32
+
33
+ Args:
34
+ raw_path: A path string as entered by the user. May be relative;
35
+ it is resolved to an absolute path internally.
36
+
37
+ Returns:
38
+ A populated ``LocalDirectory`` instance.
39
+ """
40
+ # Normalise and resolve to an absolute path
41
+ try:
42
+ resolved = Path(raw_path).expanduser().resolve()
43
+ abs_path = str(resolved)
44
+ except (OSError, ValueError) as exc:
45
+ return LocalDirectory(
46
+ path=raw_path,
47
+ is_valid=False,
48
+ error=f"Invalid path: {exc}",
49
+ )
50
+
51
+ # 1. Existence check
52
+ if not resolved.exists():
53
+ return LocalDirectory(
54
+ path=abs_path,
55
+ is_valid=False,
56
+ error="Directory does not exist",
57
+ )
58
+
59
+ # 2. Must be a directory (not a file or symlink to file)
60
+ if not resolved.is_dir():
61
+ return LocalDirectory(
62
+ path=abs_path,
63
+ is_valid=False,
64
+ error="Path is not a directory",
65
+ )
66
+
67
+ # 3. Accessibility — try listing the directory contents
68
+ if not os.access(abs_path, os.R_OK):
69
+ return LocalDirectory(
70
+ path=abs_path,
71
+ is_valid=False,
72
+ error="Directory is not readable (permission denied)",
73
+ )
74
+
75
+ # 4. Must be a Git repository root
76
+ git_dir = resolved / ".git"
77
+ if not git_dir.exists():
78
+ return LocalDirectory(
79
+ path=abs_path,
80
+ repo_name=resolved.name,
81
+ is_valid=False,
82
+ error="Not a Git repository (no .git directory found)",
83
+ )
84
+
85
+ # 5. Use default branch (main/master) and its commit for the audit
86
+ branch, commit_hash, git_error = _default_branch_metadata(abs_path)
87
+ if git_error:
88
+ return LocalDirectory(
89
+ path=abs_path,
90
+ repo_name=resolved.name,
91
+ is_valid=False,
92
+ error=git_error,
93
+ )
94
+
95
+ return LocalDirectory(
96
+ path=abs_path,
97
+ repo_name=resolved.name,
98
+ branch=branch,
99
+ commit_hash=commit_hash,
100
+ is_valid=True,
101
+ error="",
102
+ )
103
+
104
+
105
+ def _run_git(repo_path: str, args: list[str], timeout: int = 15) -> tuple[int, str, str]:
106
+ """Run a git command; returns (returncode, stdout, stderr)."""
107
+ try:
108
+ result = subprocess.run(
109
+ ["git"] + args,
110
+ cwd=repo_path,
111
+ capture_output=True,
112
+ text=True,
113
+ timeout=timeout,
114
+ )
115
+ return result.returncode, result.stdout.strip(), result.stderr.strip()
116
+ except FileNotFoundError:
117
+ return -1, "", "Git is not installed or not in PATH"
118
+ except subprocess.TimeoutExpired:
119
+ return -1, "", "Git command timed out"
120
+ except Exception as exc:
121
+ return -1, "", str(exc)
122
+
123
+
124
+ def _get_default_branch_name(repo_path: str) -> str:
125
+ """
126
+ Determine the default branch: origin/HEAD, then local main, then local master.
127
+
128
+ Returns the branch name (e.g. "main") or empty string if none found.
129
+ """
130
+ # Prefer remote default (e.g. origin/HEAD -> origin/main)
131
+ code, out, _ = _run_git(repo_path, ["rev-parse", "--abbrev-ref", "origin/HEAD"], timeout=5)
132
+ if code == 0 and out and out != "origin/HEAD":
133
+ # "origin/HEAD" -> "origin/main" -> we want "main"
134
+ if out.startswith("origin/"):
135
+ return out[7:]
136
+ return out
137
+
138
+ # Fallback: which of main/master exists (local or remote)
139
+ for branch in DEFAULT_BRANCH_ORDER:
140
+ code, _, _ = _run_git(repo_path, ["rev-parse", "--verify", branch], timeout=5)
141
+ if code == 0:
142
+ return branch
143
+ code, _, _ = _run_git(repo_path, ["rev-parse", "--verify", f"origin/{branch}"], timeout=5)
144
+ if code == 0:
145
+ return branch
146
+ return ""
147
+
148
+
149
+ def _default_branch_metadata(repo_path: str) -> tuple[str, str, str]:
150
+ """
151
+ Resolve the default branch (main/master), fetch it from origin if possible,
152
+ and return its name and short commit hash for use in the audit scope.
153
+
154
+ Args:
155
+ repo_path: Absolute path to the repository root.
156
+
157
+ Returns:
158
+ Tuple of (branch_name, short_commit_hash, error_message).
159
+ On success ``error_message`` is empty.
160
+ """
161
+ branch_name = _get_default_branch_name(repo_path)
162
+ if not branch_name:
163
+ # Last resort: use current HEAD so we don't fail local-only repos
164
+ code, head_branch, err = _run_git(repo_path, ["rev-parse", "--abbrev-ref", "HEAD"])
165
+ if code != 0:
166
+ return "", "", f"Git error: no main/master branch and {err or 'unable to read HEAD'}"
167
+ branch_name = head_branch
168
+
169
+ # Fetch the default branch from origin (best-effort; ignore failures for offline/local repos)
170
+ _run_git(repo_path, ["fetch", "origin", branch_name], timeout=30)
171
+
172
+ # Resolve commit: prefer origin/<branch>, then local <branch>
173
+ for ref in (f"origin/{branch_name}", branch_name):
174
+ code, commit, err = _run_git(repo_path, ["rev-parse", "--short", ref])
175
+ if code == 0 and commit:
176
+ return branch_name, commit, ""
177
+
178
+ return branch_name, "", f"Git error: could not resolve commit for branch '{branch_name}'"