codedd-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codedd_cli/__init__.py +3 -0
- codedd_cli/__main__.py +19 -0
- codedd_cli/api/__init__.py +4 -0
- codedd_cli/api/client.py +118 -0
- codedd_cli/api/endpoints.py +44 -0
- codedd_cli/api/exceptions.py +25 -0
- codedd_cli/auditor/__init__.py +6 -0
- codedd_cli/auditor/architecture_analyzer.py +1241 -0
- codedd_cli/auditor/architecture_prompts.py +171 -0
- codedd_cli/auditor/complexity_analyzer.py +942 -0
- codedd_cli/auditor/dependency_scanner.py +2478 -0
- codedd_cli/auditor/file_auditor.py +572 -0
- codedd_cli/auditor/git_stats_collector.py +332 -0
- codedd_cli/auditor/response_parser.py +487 -0
- codedd_cli/auditor/vulnerability_validator.py +324 -0
- codedd_cli/auth/__init__.py +4 -0
- codedd_cli/auth/session.py +41 -0
- codedd_cli/auth/token_manager.py +87 -0
- codedd_cli/cli.py +69 -0
- codedd_cli/commands/__init__.py +1 -0
- codedd_cli/commands/audit_cmd.py +1877 -0
- codedd_cli/commands/audits_cmd.py +273 -0
- codedd_cli/commands/auth_cmd.py +230 -0
- codedd_cli/commands/config_cmd.py +454 -0
- codedd_cli/commands/scope_cmd.py +967 -0
- codedd_cli/config/__init__.py +4 -0
- codedd_cli/config/constants.py +22 -0
- codedd_cli/config/settings.py +369 -0
- codedd_cli/llm/__init__.py +1 -0
- codedd_cli/llm/key_manager.py +271 -0
- codedd_cli/models/__init__.py +5 -0
- codedd_cli/models/account.py +13 -0
- codedd_cli/models/audit.py +32 -0
- codedd_cli/models/local_directory.py +26 -0
- codedd_cli/scanner/__init__.py +18 -0
- codedd_cli/scanner/file_classifier.py +247 -0
- codedd_cli/scanner/file_walker.py +207 -0
- codedd_cli/scanner/line_counter.py +75 -0
- codedd_cli/utils/__init__.py +1 -0
- codedd_cli/utils/directory_validator.py +179 -0
- codedd_cli/utils/display.py +493 -0
- codedd_cli/utils/payload_inspector.py +180 -0
- codedd_cli/utils/security.py +14 -0
- codedd_cli/utils/validators.py +37 -0
- codedd_cli-0.1.0.dist-info/METADATA +276 -0
- codedd_cli-0.1.0.dist-info/RECORD +49 -0
- codedd_cli-0.1.0.dist-info/WHEEL +4 -0
- codedd_cli-0.1.0.dist-info/entry_points.txt +3 -0
- codedd_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,942 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Local complexity analysis for the CodeDD CLI.
|
|
3
|
+
|
|
4
|
+
Calculates cyclomatic complexity (per-function) and Halstead metrics for
|
|
5
|
+
source code files **entirely on the user's machine**. No source code is sent
|
|
6
|
+
to any remote server — only the structured metric results are submitted to
|
|
7
|
+
CodeDD for TypeDB ingestion.
|
|
8
|
+
|
|
9
|
+
The analysis pipeline closely mirrors the server-side implementation in
|
|
10
|
+
``auditor/auditing_functions/functions/step_5/complexity/`` so that the
|
|
11
|
+
resulting data is structurally identical.
|
|
12
|
+
|
|
13
|
+
Language support
|
|
14
|
+
~~~~~~~~~~~~~~~~
|
|
15
|
+
- **Python**: Uses ``radon`` for precise AST-based analysis.
|
|
16
|
+
- **JavaScript, TypeScript, Java, C/C++, Go, Rust, Scala, Kotlin, Swift,
|
|
17
|
+
PHP, Ruby**: Uses ``lizard`` for cyclomatic complexity + language-specific
|
|
18
|
+
Halstead tokenisers.
|
|
19
|
+
- **Shell, PowerShell, SQL, Perl, R**: Regex/keyword-based estimation.
|
|
20
|
+
- **Other**: Generic keyword counting fallback.
|
|
21
|
+
|
|
22
|
+
Dependencies (all pip-installable, no native binaries):
|
|
23
|
+
- ``radon`` — Python complexity metrics
|
|
24
|
+
- ``lizard`` — Multi-language code complexity analyser
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import logging
|
|
30
|
+
import math
|
|
31
|
+
import os
|
|
32
|
+
import re
|
|
33
|
+
import statistics
|
|
34
|
+
import tempfile
|
|
35
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
36
|
+
from dataclasses import dataclass, field
|
|
37
|
+
from typing import Any, Callable, Optional
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
# Optional library availability flags
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
from radon.complexity import cc_visit
|
|
48
|
+
from radon.metrics import h_visit, mi_visit
|
|
49
|
+
from radon.raw import analyze as radon_raw_analyze
|
|
50
|
+
|
|
51
|
+
RADON_AVAILABLE = True
|
|
52
|
+
except ImportError: # pragma: no cover
|
|
53
|
+
RADON_AVAILABLE = False
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
import lizard # type: ignore[import-untyped]
|
|
57
|
+
|
|
58
|
+
LIZARD_AVAILABLE = True
|
|
59
|
+
except ImportError: # pragma: no cover
|
|
60
|
+
LIZARD_AVAILABLE = False
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
# Public result dataclasses
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class FileComplexityResult:
|
|
70
|
+
"""Structured result for a single file's complexity analysis."""
|
|
71
|
+
|
|
72
|
+
file_path: str
|
|
73
|
+
"""The cli:// path registered with CodeDD."""
|
|
74
|
+
relative_path: str
|
|
75
|
+
"""Human-readable relative path for display."""
|
|
76
|
+
metrics: dict[str, Any] | None = None
|
|
77
|
+
"""Full complexity metrics dict (cyclomatic + Halstead)."""
|
|
78
|
+
error: str | None = None
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def success(self) -> bool:
|
|
82
|
+
return self.metrics is not None and self.error is None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
# Extension → language mapping (ported from coordinator.py)
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
_EXTENSION_TO_LANG: dict[str, str] = {
|
|
90
|
+
# Python
|
|
91
|
+
".py": "python", ".pyx": "python", ".pxd": "python", ".pxi": "python", ".pyw": "python",
|
|
92
|
+
# JavaScript/TypeScript
|
|
93
|
+
".js": "javascript", ".jsx": "javascript", ".mjs": "javascript", ".cjs": "javascript",
|
|
94
|
+
".ts": "typescript", ".tsx": "typescript", ".mts": "typescript", ".cts": "typescript",
|
|
95
|
+
".vue": "javascript", ".svelte": "javascript",
|
|
96
|
+
# C/C++/C#
|
|
97
|
+
".c": "c", ".h": "c",
|
|
98
|
+
".cpp": "cpp", ".cc": "cpp", ".cxx": "cpp", ".hpp": "cpp", ".hxx": "cpp",
|
|
99
|
+
".cs": "csharp", ".fs": "fsharp",
|
|
100
|
+
# Java
|
|
101
|
+
".java": "java",
|
|
102
|
+
# Ruby
|
|
103
|
+
".rb": "ruby", ".rake": "ruby",
|
|
104
|
+
# Go
|
|
105
|
+
".go": "go",
|
|
106
|
+
# PHP
|
|
107
|
+
".php": "php",
|
|
108
|
+
# Swift
|
|
109
|
+
".swift": "swift",
|
|
110
|
+
# Perl
|
|
111
|
+
".pl": "perl", ".pm": "perl", ".t": "perl", ".pod": "perl",
|
|
112
|
+
# R
|
|
113
|
+
".r": "r", ".rmd": "r", ".jl": "julia",
|
|
114
|
+
# Shell/Bash
|
|
115
|
+
".sh": "shell", ".bash": "shell", ".zsh": "shell", ".fish": "shell",
|
|
116
|
+
".ksh": "shell", ".csh": "shell", ".tcsh": "shell", ".bat": "shell", ".cmd": "shell",
|
|
117
|
+
".awk": "shell", ".sed": "shell",
|
|
118
|
+
# PowerShell
|
|
119
|
+
".ps1": "powershell", ".psm1": "powershell", ".psd1": "powershell",
|
|
120
|
+
# SQL
|
|
121
|
+
".sql": "sql", ".hql": "sql", ".cypher": "sql",
|
|
122
|
+
# GraphQL
|
|
123
|
+
".graphql": "graphql", ".gql": "graphql",
|
|
124
|
+
# Rust / Scala / Kotlin
|
|
125
|
+
".rs": "rust", ".scala": "scala", ".sc": "scala", ".kt": "kotlin", ".kts": "kotlin",
|
|
126
|
+
# Other functional / systems languages
|
|
127
|
+
".clj": "clojure", ".cljs": "clojure", ".cljc": "clojure",
|
|
128
|
+
".erl": "erlang", ".ex": "elixir", ".exs": "elixir",
|
|
129
|
+
".hs": "haskell", ".lhs": "haskell", ".lua": "lua",
|
|
130
|
+
".dart": "dart", ".groovy": "groovy", ".tcl": "tcl",
|
|
131
|
+
".nim": "nim", ".cr": "crystal", ".ml": "ocaml",
|
|
132
|
+
".zig": "zig", ".v": "vlang", ".gleam": "gleam",
|
|
133
|
+
# Low-level and scientific languages
|
|
134
|
+
".asm": "assembly", ".s": "assembly",
|
|
135
|
+
".f90": "fortran", ".f95": "fortran", ".f03": "fortran",
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
# Source-code extensions for the "is this file worth analysing?" check
|
|
139
|
+
_SOURCE_CODE_EXTENSIONS: set[str] = {
|
|
140
|
+
".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts", ".vue", ".svelte", ".php",
|
|
141
|
+
".py", ".java", ".cpp", ".cc", ".cxx", ".hpp", ".c", ".h", ".cs", ".fs",
|
|
142
|
+
".go", ".rs", ".rb", ".rake", ".swift", ".kt", ".kts", ".scala", ".sc",
|
|
143
|
+
".clj", ".cljs", ".cljc", ".erl", ".ex", ".exs", ".hs", ".lhs", ".lua",
|
|
144
|
+
".pl", ".pm", ".t", ".pod", ".r", ".rmd", ".jl", ".dart", ".groovy", ".tcl",
|
|
145
|
+
".nim", ".cr", ".ml", ".zig", ".v", ".gleam",
|
|
146
|
+
".sh", ".bash", ".zsh", ".fish", ".bat", ".cmd", ".ps1", ".psm1", ".psd1",
|
|
147
|
+
".awk", ".sed", ".ksh", ".csh", ".tcsh",
|
|
148
|
+
".sql", ".hql", ".cypher", ".graphql", ".gql",
|
|
149
|
+
".pyx", ".pxd", ".pxi",
|
|
150
|
+
".asm", ".s",
|
|
151
|
+
".f90", ".f95", ".f03",
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _is_source_code(file_path: str) -> bool:
|
|
156
|
+
"""Return True if the file extension is a recognised source-code type."""
|
|
157
|
+
_, ext = os.path.splitext(file_path)
|
|
158
|
+
return ext.lower() in _SOURCE_CODE_EXTENSIONS
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _get_language(file_path: str) -> str | None:
|
|
162
|
+
"""Map a file path to a language identifier (or None)."""
|
|
163
|
+
_, ext = os.path.splitext(file_path)
|
|
164
|
+
return _EXTENSION_TO_LANG.get(ext.lower())
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _is_source_code_file_info(file_info: dict) -> bool:
|
|
168
|
+
"""
|
|
169
|
+
Source-code detection using both canonical cli:// and relative path.
|
|
170
|
+
|
|
171
|
+
Some plan payloads carry conservative canonical paths, while relative_path
|
|
172
|
+
always mirrors the real repo filename. Using both avoids false negatives.
|
|
173
|
+
"""
|
|
174
|
+
canonical_path = str(file_info.get("file_path", "")).strip()
|
|
175
|
+
relative_path = str(file_info.get("relative_path", "")).strip()
|
|
176
|
+
return _is_source_code(canonical_path) or _is_source_code(relative_path)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _get_language_file_info(file_info: dict) -> str | None:
|
|
180
|
+
"""Resolve language using canonical path first, then relative path."""
|
|
181
|
+
canonical_path = str(file_info.get("file_path", "")).strip()
|
|
182
|
+
relative_path = str(file_info.get("relative_path", "")).strip()
|
|
183
|
+
return _get_language(canonical_path) or _get_language(relative_path)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# ---------------------------------------------------------------------------
|
|
187
|
+
# Complexity rank helper (ported from utils.py)
|
|
188
|
+
# ---------------------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
def get_complexity_rank(complexity: float) -> str:
|
|
191
|
+
"""Map a cyclomatic complexity value to a letter rank (A–F)."""
|
|
192
|
+
if complexity <= 5:
|
|
193
|
+
return "A"
|
|
194
|
+
if complexity <= 10:
|
|
195
|
+
return "B"
|
|
196
|
+
if complexity <= 20:
|
|
197
|
+
return "C"
|
|
198
|
+
if complexity <= 30:
|
|
199
|
+
return "D"
|
|
200
|
+
if complexity <= 40:
|
|
201
|
+
return "E"
|
|
202
|
+
return "F"
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# ---------------------------------------------------------------------------
|
|
206
|
+
# Halstead metrics helpers (ported from halstead_metrics.py)
|
|
207
|
+
# ---------------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
def _halstead_from_counts(
|
|
210
|
+
operators: set,
|
|
211
|
+
operands: set,
|
|
212
|
+
total_operators: int,
|
|
213
|
+
total_operands: int,
|
|
214
|
+
language: str,
|
|
215
|
+
analyzer: str,
|
|
216
|
+
) -> dict[str, Any]:
|
|
217
|
+
"""Compute Halstead metrics from raw operator/operand counts."""
|
|
218
|
+
h1 = len(operators)
|
|
219
|
+
h2 = len(operands)
|
|
220
|
+
N1 = total_operators
|
|
221
|
+
N2 = total_operands
|
|
222
|
+
vocabulary = h1 + h2
|
|
223
|
+
length = N1 + N2
|
|
224
|
+
|
|
225
|
+
volume = length * math.log2(vocabulary) if vocabulary > 1 else 0
|
|
226
|
+
difficulty = (h1 / 2) * (N2 / h2) if h2 > 0 else 0
|
|
227
|
+
effort = difficulty * volume
|
|
228
|
+
time_est = effort / 18
|
|
229
|
+
bugs = volume / 3000
|
|
230
|
+
calc_len = (h1 * math.log2(h1) + h2 * math.log2(h2)) if h1 > 0 and h2 > 0 else 0
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
"file_metrics": {
|
|
234
|
+
"h1": h1, "h2": h2, "N1": N1, "N2": N2,
|
|
235
|
+
"vocabulary": vocabulary, "length": length,
|
|
236
|
+
"calculated_length": calc_len,
|
|
237
|
+
"volume": volume, "difficulty": difficulty,
|
|
238
|
+
"effort": effort, "time": time_est, "bugs": bugs,
|
|
239
|
+
},
|
|
240
|
+
"function_metrics": [],
|
|
241
|
+
"language": language,
|
|
242
|
+
"analyzer": analyzer,
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _tokenize_and_count(
|
|
247
|
+
content: str,
|
|
248
|
+
language_operators: list[str],
|
|
249
|
+
comment_pattern: str = r"\/\/.*|\/\*[\s\S]*?\*\/",
|
|
250
|
+
) -> dict[str, Any]:
|
|
251
|
+
"""Generic tokeniser shared by most Halstead language analysers."""
|
|
252
|
+
content = re.sub(comment_pattern, "", content)
|
|
253
|
+
content = re.sub(r'"(?:[^"\\]|\\.)*"|\'(?:[^\'\\]|\\.)*\'', '"S"', content)
|
|
254
|
+
tokens = re.findall(
|
|
255
|
+
r"[\w]+|[^\s\w]|[\=\+\-\*\/\%\<\>\!\&\|\^\~\(\)\{\}\[\]\,\;\:\.\?]",
|
|
256
|
+
content,
|
|
257
|
+
)
|
|
258
|
+
ops: set[str] = set()
|
|
259
|
+
opds: set[str] = set()
|
|
260
|
+
n_ops = n_opds = 0
|
|
261
|
+
op_set = set(language_operators)
|
|
262
|
+
for tok in tokens:
|
|
263
|
+
if tok in op_set:
|
|
264
|
+
ops.add(tok)
|
|
265
|
+
n_ops += 1
|
|
266
|
+
elif tok.isalnum() or tok == "S":
|
|
267
|
+
opds.add(tok)
|
|
268
|
+
n_opds += 1
|
|
269
|
+
return {"operators": ops, "operands": opds, "N1": n_ops, "N2": n_opds}
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
# Language-specific operator lists
|
|
273
|
+
_JS_OPS = [
|
|
274
|
+
"+", "-", "*", "/", "%", "=", "==", "===", "!=", "!==", "<", ">", "<=",
|
|
275
|
+
">=", "&&", "||", "!", "?", ":", "++", "--", "+=", "-=", "*=", "/=",
|
|
276
|
+
"%=", "&=", "|=", "^=", ">>=", "<<=", ">>>=", "=>", "function", "return",
|
|
277
|
+
"if", "else", "for", "while", "do", "switch", "case", "break", "continue",
|
|
278
|
+
"new", "delete", "typeof", "instanceof", "void", "throw", "try", "catch", "finally",
|
|
279
|
+
]
|
|
280
|
+
_JAVA_OPS = [
|
|
281
|
+
"+", "-", "*", "/", "%", "=", "==", "!=", "<", ">", "<=", ">=",
|
|
282
|
+
"&&", "||", "!", "++", "--", "+=", "-=", "*=", "/=", "%=", "&=",
|
|
283
|
+
"|=", "^=", ">>=", "<<=", ">>>=", "new", "instanceof", "if", "else",
|
|
284
|
+
"for", "while", "do", "switch", "case", "break", "continue", "return",
|
|
285
|
+
"throw", "try", "catch", "finally", "synchronized", "this", "super",
|
|
286
|
+
]
|
|
287
|
+
_C_CPP_OPS = [
|
|
288
|
+
"+", "-", "*", "/", "%", "=", "==", "!=", "<", ">", "<=", ">=",
|
|
289
|
+
"&&", "||", "!", "++", "--", "+=", "-=", "*=", "/=", "%=", "&=",
|
|
290
|
+
"|=", "^=", ">>=", "<<=", "->", ".", "::", "?", ":", "if", "else",
|
|
291
|
+
"for", "while", "do", "switch", "case", "break", "continue", "return",
|
|
292
|
+
"goto", "throw", "try", "catch", "new", "delete", "sizeof", "typedef",
|
|
293
|
+
]
|
|
294
|
+
_GO_OPS = [
|
|
295
|
+
"+", "-", "*", "/", "%", "=", "==", "!=", "<", ">", "<=", ">=",
|
|
296
|
+
"&&", "||", "!", "++", "--", "+=", "-=", "*=", "/=", "%=", "&=",
|
|
297
|
+
"|=", "^=", ">>=", "<<=", "&^=", "<-", "...", "&^", "if", "else",
|
|
298
|
+
"for", "range", "switch", "case", "break", "continue", "return",
|
|
299
|
+
"go", "defer", "goto", "func", "interface", "select", "chan",
|
|
300
|
+
]
|
|
301
|
+
_RUBY_OPS = [
|
|
302
|
+
"+", "-", "*", "/", "%", "=", "==", "!=", "<", ">", "<=", ">=",
|
|
303
|
+
"&&", "||", "!", "+=", "-=", "*=", "/=", "%=", "**", "**=", "..",
|
|
304
|
+
"...", "&", "|", "^", "~", "<<", ">>", "=~", "!~", "<=>",
|
|
305
|
+
"if", "else", "elsif", "unless", "while", "until", "for", "in",
|
|
306
|
+
"begin", "rescue", "ensure", "end", "case", "when", "break",
|
|
307
|
+
"next", "return", "yield", "def", "class", "module",
|
|
308
|
+
]
|
|
309
|
+
_PHP_OPS = [
|
|
310
|
+
"+", "-", "*", "/", "%", "=", "==", "===", "!=", "!==", "<", ">", "<=",
|
|
311
|
+
">=", "&&", "||", "!", "++", "--", "+=", "-=", "*=", "/=", "%=", ".=",
|
|
312
|
+
"&=", "|=", "^=", ">>=", "<<=", "??", "?:", "?", ":", "->", "=>", "::",
|
|
313
|
+
"if", "else", "elseif", "foreach", "for", "while", "do", "switch", "case",
|
|
314
|
+
"break", "continue", "return", "require", "include", "require_once",
|
|
315
|
+
"include_once", "throw", "try", "catch", "finally", "function", "class",
|
|
316
|
+
"interface", "trait", "abstract", "final", "public", "private", "protected",
|
|
317
|
+
]
|
|
318
|
+
_RUST_OPS = [
|
|
319
|
+
"+", "-", "*", "/", "%", "=", "==", "!=", "<", ">", "<=", ">=",
|
|
320
|
+
"&&", "||", "!", "+=", "-=", "*=", "/=", "%=", "&=", "|=", "^=",
|
|
321
|
+
">>=", "<<=", "..", "...", "&", "|", "^", "~", "<<", ">>", "->",
|
|
322
|
+
"=>", "::", "if", "else", "match", "for", "while", "loop", "break",
|
|
323
|
+
"continue", "return", "let", "mut", "fn", "struct", "enum", "trait",
|
|
324
|
+
"impl", "pub", "use", "mod", "async", "await", "dyn", "ref", "move",
|
|
325
|
+
]
|
|
326
|
+
_SWIFT_OPS = [
|
|
327
|
+
"+", "-", "*", "/", "%", "=", "==", "!=", "<", ">", "<=", ">=",
|
|
328
|
+
"&&", "||", "!", "+=", "-=", "*=", "/=", "%=", "&=", "|=", "^=",
|
|
329
|
+
">>=", "<<=", "??", "?", ":", ".", "->", "if", "else", "guard",
|
|
330
|
+
"switch", "case", "default", "for", "while", "repeat", "break",
|
|
331
|
+
"continue", "return", "throw", "try", "catch", "defer", "where",
|
|
332
|
+
"in", "as", "is", "nil", "func", "class", "struct", "enum", "protocol",
|
|
333
|
+
"extension", "let", "var", "inout", "self", "super", "init", "deinit",
|
|
334
|
+
]
|
|
335
|
+
_KOTLIN_OPS = [
|
|
336
|
+
"+", "-", "*", "/", "%", "=", "==", "!=", "<", ">", "<=", ">=",
|
|
337
|
+
"&&", "||", "!", "+=", "-=", "*=", "/=", "%=", "&=", "|=", "^=",
|
|
338
|
+
">>=", "<<=", "..", "?:", "?", ":", ".", "->", "if", "else", "when",
|
|
339
|
+
"for", "while", "do", "break", "continue", "return", "throw", "try",
|
|
340
|
+
"catch", "finally", "class", "interface", "fun", "val", "var", "this",
|
|
341
|
+
"super", "in", "is", "as", "by", "object", "init", "companion",
|
|
342
|
+
"internal", "private", "protected", "public", "abstract", "final",
|
|
343
|
+
"open", "override", "lateinit", "inner", "suspend", "data",
|
|
344
|
+
]
|
|
345
|
+
_CSHARP_OPS = [
|
|
346
|
+
"+", "-", "*", "/", "%", "=", "==", "!=", "<", ">", "<=", ">=",
|
|
347
|
+
"&&", "||", "!", "++", "--", "+=", "-=", "*=", "/=", "%=", "&=",
|
|
348
|
+
"|=", "^=", ">>=", "<<=", "??", "?.", "?", ":", "if", "else",
|
|
349
|
+
"for", "foreach", "while", "do", "switch", "case", "break", "continue",
|
|
350
|
+
"return", "throw", "try", "catch", "finally", "new", "typeof", "sizeof",
|
|
351
|
+
"is", "as", "using", "await", "async",
|
|
352
|
+
]
|
|
353
|
+
_POWERSHELL_OPS = [
|
|
354
|
+
"+", "-", "*", "/", "%", "=", "-eq", "-ne", "-gt", "-lt", "-ge", "-le",
|
|
355
|
+
"-like", "-notlike", "-match", "-notmatch", "-contains", "-notcontains",
|
|
356
|
+
"-and", "-or", "-not", "-xor", "-band", "-bor", "-bnot", "-bxor",
|
|
357
|
+
"-f", "-split", "-join", "+=", "-=", "*=", "/=", "%=", "..", ".",
|
|
358
|
+
"if", "else", "elseif", "switch", "for", "foreach", "while", "do",
|
|
359
|
+
"break", "continue", "return", "function", "filter", "try", "catch",
|
|
360
|
+
"finally", "throw", "param", "begin", "process", "end", "dynamicparam",
|
|
361
|
+
"class", "using", "namespace", "enum",
|
|
362
|
+
]
|
|
363
|
+
_GENERIC_OPS = [
|
|
364
|
+
"+", "-", "*", "/", "%", "=", "==", "!=", "<", ">", "<=", ">=",
|
|
365
|
+
"&&", "||", "!", "++", "--", "if", "else", "for", "while", "return",
|
|
366
|
+
]
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _halstead_for_language(content: str, language: str | None, file_path: str) -> dict | None:
|
|
370
|
+
"""Select the right Halstead tokeniser for the language and return metrics."""
|
|
371
|
+
if not language:
|
|
372
|
+
language = "generic"
|
|
373
|
+
lang = language.lower()
|
|
374
|
+
|
|
375
|
+
# Python — use radon if available
|
|
376
|
+
if lang == "python" and RADON_AVAILABLE:
|
|
377
|
+
try:
|
|
378
|
+
h_results = h_visit(content)
|
|
379
|
+
if h_results and hasattr(h_results, "total"):
|
|
380
|
+
fm = h_results.total
|
|
381
|
+
file_metrics = {
|
|
382
|
+
"h1": fm.h1, "h2": fm.h2, "N1": fm.N1, "N2": fm.N2,
|
|
383
|
+
"vocabulary": fm.vocabulary, "length": fm.length,
|
|
384
|
+
"calculated_length": fm.calculated_length,
|
|
385
|
+
"volume": fm.volume, "difficulty": fm.difficulty,
|
|
386
|
+
"effort": fm.effort, "time": fm.time, "bugs": fm.bugs,
|
|
387
|
+
}
|
|
388
|
+
func_metrics = []
|
|
389
|
+
if hasattr(h_results, "functions") and h_results.functions:
|
|
390
|
+
for item in h_results.functions:
|
|
391
|
+
if isinstance(item, tuple) and len(item) == 2:
|
|
392
|
+
fname, m = item
|
|
393
|
+
func_metrics.append({
|
|
394
|
+
"name": fname,
|
|
395
|
+
"h1": m.h1, "h2": m.h2, "N1": m.N1, "N2": m.N2,
|
|
396
|
+
"vocabulary": m.vocabulary, "length": m.length,
|
|
397
|
+
"calculated_length": m.calculated_length,
|
|
398
|
+
"volume": m.volume, "difficulty": m.difficulty,
|
|
399
|
+
"effort": m.effort, "time": m.time, "bugs": m.bugs,
|
|
400
|
+
})
|
|
401
|
+
return {"file_metrics": file_metrics, "function_metrics": func_metrics,
|
|
402
|
+
"language": "python", "analyzer": "radon"}
|
|
403
|
+
except Exception:
|
|
404
|
+
pass
|
|
405
|
+
return None
|
|
406
|
+
|
|
407
|
+
# Select operator list by language
|
|
408
|
+
ops_map: dict[str, list[str]] = {
|
|
409
|
+
"javascript": _JS_OPS, "typescript": _JS_OPS,
|
|
410
|
+
"java": _JAVA_OPS,
|
|
411
|
+
"c": _C_CPP_OPS, "cpp": _C_CPP_OPS, "csharp": _CSHARP_OPS,
|
|
412
|
+
"go": _GO_OPS, "ruby": _RUBY_OPS, "php": _PHP_OPS,
|
|
413
|
+
"rust": _RUST_OPS, "swift": _SWIFT_OPS,
|
|
414
|
+
"kotlin": _KOTLIN_OPS,
|
|
415
|
+
}
|
|
416
|
+
comment_map: dict[str, str] = {
|
|
417
|
+
"ruby": r"#.*",
|
|
418
|
+
"powershell": r"#.*|<#[\s\S]*?#>",
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
ops = ops_map.get(lang)
|
|
422
|
+
if ops:
|
|
423
|
+
cmt = comment_map.get(lang, r"\/\/.*|\/\*[\s\S]*?\*\/")
|
|
424
|
+
counts = _tokenize_and_count(content, ops, cmt)
|
|
425
|
+
return _halstead_from_counts(
|
|
426
|
+
counts["operators"], counts["operands"],
|
|
427
|
+
counts["N1"], counts["N2"], language, "custom",
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
if lang == "powershell":
|
|
431
|
+
cmt = comment_map["powershell"]
|
|
432
|
+
counts = _tokenize_and_count(content, _POWERSHELL_OPS, cmt)
|
|
433
|
+
return _halstead_from_counts(
|
|
434
|
+
counts["operators"], counts["operands"],
|
|
435
|
+
counts["N1"], counts["N2"], language, "custom",
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
# Generic fallback
|
|
439
|
+
counts = _tokenize_and_count(content, _GENERIC_OPS, r"\/\/.*|\/\*[\s\S]*?\*\/|#.*")
|
|
440
|
+
return _halstead_from_counts(
|
|
441
|
+
counts["operators"], counts["operands"],
|
|
442
|
+
counts["N1"], counts["N2"], language or "generic", "generic",
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
# ---------------------------------------------------------------------------
|
|
447
|
+
# Cyclomatic complexity — language-specific analysers
|
|
448
|
+
# ---------------------------------------------------------------------------
|
|
449
|
+
|
|
450
|
+
def _analyze_python(file_path: str, content: str) -> dict[str, Any] | None:
|
|
451
|
+
"""Python complexity via radon (AST-based)."""
|
|
452
|
+
if not RADON_AVAILABLE:
|
|
453
|
+
return _analyze_generic(file_path, content, "python")
|
|
454
|
+
|
|
455
|
+
try:
|
|
456
|
+
cc_results = cc_visit(content)
|
|
457
|
+
except Exception:
|
|
458
|
+
return _analyze_generic(file_path, content, "python")
|
|
459
|
+
|
|
460
|
+
try:
|
|
461
|
+
maintainability = mi_visit(content, multi=True)
|
|
462
|
+
except Exception:
|
|
463
|
+
maintainability = 0
|
|
464
|
+
|
|
465
|
+
try:
|
|
466
|
+
raw = radon_raw_analyze(content)
|
|
467
|
+
raw_metrics = {
|
|
468
|
+
"loc": raw.loc, "lloc": raw.lloc, "sloc": raw.sloc,
|
|
469
|
+
"comments": raw.comments, "multi": raw.multi, "blank": raw.blank,
|
|
470
|
+
}
|
|
471
|
+
except Exception:
|
|
472
|
+
raw_metrics = {"loc": len(content.splitlines())}
|
|
473
|
+
|
|
474
|
+
functions: list[dict] = []
|
|
475
|
+
total_cx = max_cx = 0
|
|
476
|
+
for r in (cc_results or []):
|
|
477
|
+
cx = r.complexity
|
|
478
|
+
total_cx += cx
|
|
479
|
+
max_cx = max(max_cx, cx)
|
|
480
|
+
functions.append({
|
|
481
|
+
"name": r.name,
|
|
482
|
+
"complexity": cx,
|
|
483
|
+
"cyclomatic_complexity": cx,
|
|
484
|
+
"line_number": r.lineno,
|
|
485
|
+
"rank": get_complexity_rank(cx),
|
|
486
|
+
"cyclomatic_complexity_rank": get_complexity_rank(cx),
|
|
487
|
+
})
|
|
488
|
+
|
|
489
|
+
avg = total_cx / len(functions) if functions else 0
|
|
490
|
+
halstead = _halstead_for_language(content, "python", file_path)
|
|
491
|
+
|
|
492
|
+
return {
|
|
493
|
+
"language": "python",
|
|
494
|
+
"total_complexity": total_cx,
|
|
495
|
+
"average_complexity": avg,
|
|
496
|
+
"max_complexity": max_cx,
|
|
497
|
+
"function_count": len(functions),
|
|
498
|
+
"functions": functions,
|
|
499
|
+
"methods": functions,
|
|
500
|
+
"maintainability_index": maintainability,
|
|
501
|
+
"file_path": file_path,
|
|
502
|
+
"raw_metrics": raw_metrics,
|
|
503
|
+
"halstead_metrics": halstead,
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _analyze_with_lizard(file_path: str, content: str, language: str) -> dict[str, Any] | None:
|
|
508
|
+
"""Analyse complexity with lizard (multi-language)."""
|
|
509
|
+
if not LIZARD_AVAILABLE:
|
|
510
|
+
return _analyze_generic(file_path, content, language)
|
|
511
|
+
|
|
512
|
+
ext_map = {
|
|
513
|
+
"java": "java", "javascript": "js", "typescript": "ts",
|
|
514
|
+
"c": "c", "cpp": "cpp", "csharp": "cs", "ruby": "rb",
|
|
515
|
+
"php": "php", "go": "go", "swift": "swift", "rust": "rs",
|
|
516
|
+
"scala": "scala", "kotlin": "kt", "python": "py",
|
|
517
|
+
}
|
|
518
|
+
ext = ext_map.get(language.lower(), "txt")
|
|
519
|
+
|
|
520
|
+
tmp_path = None
|
|
521
|
+
try:
|
|
522
|
+
with tempfile.NamedTemporaryFile(
|
|
523
|
+
suffix=f".{ext}", delete=False, mode="w", encoding="utf-8",
|
|
524
|
+
) as tmp:
|
|
525
|
+
tmp_path = tmp.name
|
|
526
|
+
tmp.write(content)
|
|
527
|
+
|
|
528
|
+
analysis = lizard.analyze_file(tmp_path)
|
|
529
|
+
methods: list[dict] = []
|
|
530
|
+
total_cx = max_cx = 0
|
|
531
|
+
for fn in analysis.function_list:
|
|
532
|
+
cx = fn.cyclomatic_complexity
|
|
533
|
+
methods.append({
|
|
534
|
+
"name": fn.name,
|
|
535
|
+
"complexity": cx,
|
|
536
|
+
"cyclomatic_complexity": cx,
|
|
537
|
+
"line_number": fn.start_line,
|
|
538
|
+
"rank": get_complexity_rank(cx),
|
|
539
|
+
"cyclomatic_complexity_rank": get_complexity_rank(cx),
|
|
540
|
+
})
|
|
541
|
+
total_cx += cx
|
|
542
|
+
max_cx = max(max_cx, cx)
|
|
543
|
+
|
|
544
|
+
avg = total_cx / len(methods) if methods else 0
|
|
545
|
+
halstead = _halstead_for_language(content, language, file_path)
|
|
546
|
+
|
|
547
|
+
return {
|
|
548
|
+
"file_path": file_path,
|
|
549
|
+
"language": language,
|
|
550
|
+
"methods": methods,
|
|
551
|
+
"functions": methods,
|
|
552
|
+
"average_complexity": avg,
|
|
553
|
+
"total_complexity": total_cx,
|
|
554
|
+
"max_complexity": max_cx,
|
|
555
|
+
"function_count": len(methods),
|
|
556
|
+
"complexity_rank": get_complexity_rank(avg),
|
|
557
|
+
"halstead_metrics": halstead,
|
|
558
|
+
}
|
|
559
|
+
except Exception:
|
|
560
|
+
return _analyze_generic(file_path, content, language)
|
|
561
|
+
finally:
|
|
562
|
+
if tmp_path:
|
|
563
|
+
try:
|
|
564
|
+
os.unlink(tmp_path)
|
|
565
|
+
except OSError:
|
|
566
|
+
pass
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def _analyze_generic(file_path: str, content: str, language: str) -> dict[str, Any]:
|
|
570
|
+
"""Keyword-counting fallback for unsupported / unrecognised languages."""
|
|
571
|
+
lines = content.splitlines()
|
|
572
|
+
if language == "python":
|
|
573
|
+
kw = ["if", "elif", "else:", "for", "while", "try:", "except:", "with"]
|
|
574
|
+
elif language in ("javascript", "typescript", "java", "c", "cpp", "csharp"):
|
|
575
|
+
kw = ["if", "else", "for", "while", "switch", "case", "try", "catch"]
|
|
576
|
+
else:
|
|
577
|
+
kw = ["if", "else", "for", "while", "switch", "try"]
|
|
578
|
+
|
|
579
|
+
count = 0
|
|
580
|
+
for line in lines:
|
|
581
|
+
s = line.strip().lower()
|
|
582
|
+
if s.startswith(("#", "//", "/*", "*")):
|
|
583
|
+
continue
|
|
584
|
+
for k in kw:
|
|
585
|
+
if k in s:
|
|
586
|
+
count += 1
|
|
587
|
+
break
|
|
588
|
+
|
|
589
|
+
cx = max(1, count + 1)
|
|
590
|
+
rank = get_complexity_rank(cx)
|
|
591
|
+
halstead = _halstead_for_language(content, language, file_path)
|
|
592
|
+
|
|
593
|
+
fn = [{
|
|
594
|
+
"name": "whole_file",
|
|
595
|
+
"complexity": cx,
|
|
596
|
+
"cyclomatic_complexity": cx,
|
|
597
|
+
"line_number": 1,
|
|
598
|
+
"rank": rank,
|
|
599
|
+
"cyclomatic_complexity_rank": rank,
|
|
600
|
+
}]
|
|
601
|
+
return {
|
|
602
|
+
"language": language,
|
|
603
|
+
"total_complexity": cx,
|
|
604
|
+
"average_complexity": cx,
|
|
605
|
+
"max_complexity": cx,
|
|
606
|
+
"function_count": 1,
|
|
607
|
+
"functions": fn,
|
|
608
|
+
"methods": fn,
|
|
609
|
+
"estimation_method": "basic_keyword_count",
|
|
610
|
+
"file_path": file_path,
|
|
611
|
+
"line_count": len(lines),
|
|
612
|
+
"halstead_metrics": halstead,
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
# JS/TS-specific helpers (ported from javascript.py)
|
|
617
|
+
|
|
618
|
+
_JS_FUNC_RE = re.compile(
|
|
619
|
+
r"(?:function\s+([A-Za-z_$][A-Za-z0-9_$]*)|"
|
|
620
|
+
r"(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*function|"
|
|
621
|
+
r"(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*\([^)]*\)\s*=>|"
|
|
622
|
+
r"([A-Za-z_$][A-Za-z0-9_$]*)\s*:\s*function|"
|
|
623
|
+
r"([A-Za-z_$][A-Za-z0-9_$]*)\s*\([^)]*\)\s*{)"
|
|
624
|
+
)
|
|
625
|
+
_JS_CX_PATTERNS = [
|
|
626
|
+
r"\bif\b", r"\belse\s+if\b", r"\bfor\b", r"\bwhile\b", r"\bdo\b",
|
|
627
|
+
r"\bcatch\b", r"\bcase\b", r"\?\s*:", r"&&", r"\|\|",
|
|
628
|
+
]
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def _analyze_js_ts(file_path: str, content: str, language: str) -> dict[str, Any]:
|
|
632
|
+
"""Regex-based complexity analysis for JavaScript / TypeScript."""
|
|
633
|
+
lines = content.splitlines()
|
|
634
|
+
functions: list[dict] = []
|
|
635
|
+
for i, line in enumerate(lines):
|
|
636
|
+
for m in _JS_FUNC_RE.finditer(line):
|
|
637
|
+
name = next((g for g in m.groups() if g), f"anonymous_{i + 1}")
|
|
638
|
+
functions.append({"name": name, "line": i + 1})
|
|
639
|
+
|
|
640
|
+
if not functions:
|
|
641
|
+
functions = [{"name": "global_scope", "line": 1}]
|
|
642
|
+
|
|
643
|
+
line_cx = []
|
|
644
|
+
for line in lines:
|
|
645
|
+
cx = sum(len(re.findall(p, line)) for p in _JS_CX_PATTERNS)
|
|
646
|
+
line_cx.append(cx)
|
|
647
|
+
|
|
648
|
+
processed: list[dict] = []
|
|
649
|
+
total_cx = max_cx = 0
|
|
650
|
+
for idx, fn in enumerate(functions):
|
|
651
|
+
start = fn["line"] - 1
|
|
652
|
+
end = min(start + 20, len(lines))
|
|
653
|
+
if idx + 1 < len(functions):
|
|
654
|
+
end = min(end, functions[idx + 1]["line"] - 1)
|
|
655
|
+
fcx = 1 + sum(line_cx[start:end])
|
|
656
|
+
total_cx += fcx
|
|
657
|
+
max_cx = max(max_cx, fcx)
|
|
658
|
+
processed.append({
|
|
659
|
+
"name": fn["name"],
|
|
660
|
+
"complexity": fcx,
|
|
661
|
+
"cyclomatic_complexity": fcx,
|
|
662
|
+
"line_number": fn["line"],
|
|
663
|
+
"rank": get_complexity_rank(fcx),
|
|
664
|
+
"cyclomatic_complexity_rank": get_complexity_rank(fcx),
|
|
665
|
+
})
|
|
666
|
+
|
|
667
|
+
avg = total_cx / len(processed) if processed else 0
|
|
668
|
+
halstead = _halstead_for_language(content, language, file_path)
|
|
669
|
+
|
|
670
|
+
return {
|
|
671
|
+
"language": language,
|
|
672
|
+
"total_complexity": total_cx,
|
|
673
|
+
"average_complexity": avg,
|
|
674
|
+
"max_complexity": max_cx,
|
|
675
|
+
"function_count": len(processed),
|
|
676
|
+
"functions": processed,
|
|
677
|
+
"methods": processed,
|
|
678
|
+
"file_path": file_path,
|
|
679
|
+
"analysis_method": "regex",
|
|
680
|
+
"line_count": len(lines),
|
|
681
|
+
"halstead_metrics": halstead,
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
# ---------------------------------------------------------------------------
|
|
686
|
+
# Single-file orchestrator (mirrors coordinator.analyze_file_complexity)
|
|
687
|
+
# ---------------------------------------------------------------------------
|
|
688
|
+
|
|
689
|
+
def analyze_file_complexity(
|
|
690
|
+
file_path: str,
|
|
691
|
+
content: str,
|
|
692
|
+
language: str | None = None,
|
|
693
|
+
) -> dict[str, Any] | None:
|
|
694
|
+
"""
|
|
695
|
+
Analyse cyclomatic complexity + Halstead metrics for a single file.
|
|
696
|
+
|
|
697
|
+
Args:
|
|
698
|
+
file_path: The canonical path (used for storage/logging).
|
|
699
|
+
content: Raw source-code text.
|
|
700
|
+
language: Language hint (auto-detected from extension if ``None``).
|
|
701
|
+
|
|
702
|
+
Returns:
|
|
703
|
+
A metrics dict compatible with the server-side
|
|
704
|
+
``store_cyclomatic_complexity`` / ``store_halstead_metrics`` schemas,
|
|
705
|
+
or ``None`` if the file cannot be analysed.
|
|
706
|
+
"""
|
|
707
|
+
if not content:
|
|
708
|
+
return None
|
|
709
|
+
|
|
710
|
+
if language is None:
|
|
711
|
+
language = _get_language(file_path)
|
|
712
|
+
|
|
713
|
+
try:
|
|
714
|
+
if language == "python":
|
|
715
|
+
return _analyze_python(file_path, content)
|
|
716
|
+
|
|
717
|
+
if language in ("javascript", "typescript"):
|
|
718
|
+
return _analyze_js_ts(file_path, content, language)
|
|
719
|
+
|
|
720
|
+
# Languages well-supported by lizard
|
|
721
|
+
if language in (
|
|
722
|
+
"c", "cpp", "csharp", "java", "ruby", "go", "php",
|
|
723
|
+
"swift", "rust", "scala", "kotlin",
|
|
724
|
+
):
|
|
725
|
+
return _analyze_with_lizard(file_path, content, language)
|
|
726
|
+
|
|
727
|
+
# Generic fallback for shell, SQL, PowerShell, Perl, R, etc.
|
|
728
|
+
if language:
|
|
729
|
+
return _analyze_generic(file_path, content, language)
|
|
730
|
+
|
|
731
|
+
# Completely unknown language — still try generic
|
|
732
|
+
return _analyze_generic(file_path, content, "generic")
|
|
733
|
+
|
|
734
|
+
except Exception as exc:
|
|
735
|
+
logger.debug("Complexity analysis failed for %s: %s", file_path, exc)
|
|
736
|
+
try:
|
|
737
|
+
return _analyze_generic(file_path, content, language or "generic")
|
|
738
|
+
except Exception:
|
|
739
|
+
return None
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
# ---------------------------------------------------------------------------
|
|
743
|
+
# Aggregation helper (ported from utils.aggregate_complexity_results)
|
|
744
|
+
# ---------------------------------------------------------------------------
|
|
745
|
+
|
|
746
|
+
def aggregate_complexity_results(results: dict[str, dict]) -> dict[str, Any]:
|
|
747
|
+
"""Aggregate per-file complexity metrics into summary statistics."""
|
|
748
|
+
if not results:
|
|
749
|
+
return {"average_complexity": 0, "complexity_rank": "A", "file_count": 0, "method_count": 0}
|
|
750
|
+
|
|
751
|
+
total_cx = 0
|
|
752
|
+
total_fns = 0
|
|
753
|
+
lang_counts: dict[str, int] = {}
|
|
754
|
+
|
|
755
|
+
for fp, r in results.items():
|
|
756
|
+
total_cx += r.get("average_complexity", 0)
|
|
757
|
+
total_fns += len(r.get("methods", []))
|
|
758
|
+
lang = r.get("language", "unknown")
|
|
759
|
+
lang_counts[lang] = lang_counts.get(lang, 0) + 1
|
|
760
|
+
|
|
761
|
+
cx_dist: dict[str, int] = {"A": 0, "B": 0, "C": 0, "D": 0, "E": 0, "F": 0}
|
|
762
|
+
for r in results.values():
|
|
763
|
+
for m in r.get("methods", []):
|
|
764
|
+
rank = m.get("rank", "F")
|
|
765
|
+
cx_dist[rank] = cx_dist.get(rank, 0) + 1
|
|
766
|
+
|
|
767
|
+
complexities = [r.get("average_complexity", 0) for r in results.values()]
|
|
768
|
+
extra: dict[str, Any] = {}
|
|
769
|
+
if complexities:
|
|
770
|
+
try:
|
|
771
|
+
extra = {
|
|
772
|
+
"median_complexity": statistics.median(complexities),
|
|
773
|
+
"min_complexity": min(complexities),
|
|
774
|
+
"max_complexity": max(complexities),
|
|
775
|
+
"std_dev": statistics.stdev(complexities) if len(complexities) > 1 else 0,
|
|
776
|
+
}
|
|
777
|
+
except Exception:
|
|
778
|
+
pass
|
|
779
|
+
|
|
780
|
+
avg = total_cx / len(results) if results else 0
|
|
781
|
+
return {
|
|
782
|
+
"average_complexity": avg,
|
|
783
|
+
"complexity_rank": get_complexity_rank(avg),
|
|
784
|
+
"file_count": len(results),
|
|
785
|
+
"function_count": total_fns,
|
|
786
|
+
"language_breakdown": lang_counts,
|
|
787
|
+
"method_count": sum(len(r.get("methods", [])) for r in results.values()),
|
|
788
|
+
"complexity_distribution": cx_dist,
|
|
789
|
+
**extra,
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
# ---------------------------------------------------------------------------
|
|
794
|
+
# Public batch analyser — the main entry-point used by audit_cmd.py
|
|
795
|
+
# ---------------------------------------------------------------------------
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
class LocalComplexityAnalyzer:
|
|
799
|
+
"""
|
|
800
|
+
Batch complexity analyser for CLI-side audit execution.
|
|
801
|
+
|
|
802
|
+
Reads files from disk, computes cyclomatic + Halstead metrics using the
|
|
803
|
+
same algorithms as the CodeDD server, and returns structured JSON results
|
|
804
|
+
ready for submission.
|
|
805
|
+
|
|
806
|
+
Usage::
|
|
807
|
+
|
|
808
|
+
analyzer = LocalComplexityAnalyzer(max_workers=4, on_debug=print)
|
|
809
|
+
results = analyzer.analyze_batch(files, scope_dirs, on_progress=cb)
|
|
810
|
+
# results is list[FileComplexityResult]
|
|
811
|
+
"""
|
|
812
|
+
|
|
813
|
+
def __init__(
|
|
814
|
+
self,
|
|
815
|
+
max_workers: int = 4,
|
|
816
|
+
on_debug: Callable[[str], None] | None = None,
|
|
817
|
+
) -> None:
|
|
818
|
+
self._max_workers = min(max_workers, os.cpu_count() or 4, 8)
|
|
819
|
+
self._on_debug = on_debug
|
|
820
|
+
|
|
821
|
+
def _debug(self, msg: str) -> None:
|
|
822
|
+
if self._on_debug:
|
|
823
|
+
self._on_debug(msg)
|
|
824
|
+
|
|
825
|
+
# ------------------------------------------------------------------
|
|
826
|
+
|
|
827
|
+
def analyze_batch(
|
|
828
|
+
self,
|
|
829
|
+
files: list[dict],
|
|
830
|
+
scope_dirs: dict[str, str],
|
|
831
|
+
on_progress: Callable[[FileComplexityResult], None] | None = None,
|
|
832
|
+
) -> list[FileComplexityResult]:
|
|
833
|
+
"""
|
|
834
|
+
Analyse a batch of files concurrently.
|
|
835
|
+
|
|
836
|
+
Args:
|
|
837
|
+
files: List of file dicts from the audit plan (must have
|
|
838
|
+
``file_path``, ``relative_path``, ``repo_name``).
|
|
839
|
+
scope_dirs: ``{repo_name: local_directory}`` mapping.
|
|
840
|
+
on_progress: Optional callback invoked after each file completes.
|
|
841
|
+
|
|
842
|
+
Returns:
|
|
843
|
+
List of ``FileComplexityResult`` objects.
|
|
844
|
+
"""
|
|
845
|
+
results: list[FileComplexityResult] = []
|
|
846
|
+
source_files = [f for f in files if _is_source_code_file_info(f)]
|
|
847
|
+
|
|
848
|
+
if not source_files:
|
|
849
|
+
self._debug("No source-code files to analyse for complexity.")
|
|
850
|
+
return results
|
|
851
|
+
|
|
852
|
+
self._debug(f"Analysing complexity for {len(source_files)} source file(s) "
|
|
853
|
+
f"({self._max_workers} workers)")
|
|
854
|
+
|
|
855
|
+
with ThreadPoolExecutor(max_workers=self._max_workers) as pool:
|
|
856
|
+
future_map = {
|
|
857
|
+
pool.submit(
|
|
858
|
+
self._analyze_one, f, scope_dirs,
|
|
859
|
+
): f
|
|
860
|
+
for f in source_files
|
|
861
|
+
}
|
|
862
|
+
for future in as_completed(future_map):
|
|
863
|
+
f = future_map[future]
|
|
864
|
+
try:
|
|
865
|
+
result = future.result()
|
|
866
|
+
except Exception as exc:
|
|
867
|
+
result = FileComplexityResult(
|
|
868
|
+
file_path=f.get("file_path", ""),
|
|
869
|
+
relative_path=f.get("relative_path", ""),
|
|
870
|
+
error=str(exc),
|
|
871
|
+
)
|
|
872
|
+
results.append(result)
|
|
873
|
+
if on_progress:
|
|
874
|
+
on_progress(result)
|
|
875
|
+
|
|
876
|
+
ok = sum(1 for r in results if r.success)
|
|
877
|
+
fail = len(results) - ok
|
|
878
|
+
if fail:
|
|
879
|
+
sample_failures = [
|
|
880
|
+
f"{r.relative_path or r.file_path}: {r.error}"
|
|
881
|
+
for r in results if not r.success
|
|
882
|
+
][:3]
|
|
883
|
+
if sample_failures:
|
|
884
|
+
self._debug("Sample complexity failures: " + " | ".join(sample_failures))
|
|
885
|
+
self._debug(f"Complexity analysis complete: {ok} ok, {fail} failed")
|
|
886
|
+
return results
|
|
887
|
+
|
|
888
|
+
def _analyze_one(
|
|
889
|
+
self, file_info: dict, scope_dirs: dict[str, str],
|
|
890
|
+
) -> FileComplexityResult:
|
|
891
|
+
"""Read a single file from disk and compute its complexity."""
|
|
892
|
+
file_path = file_info.get("file_path", "")
|
|
893
|
+
relative_path = file_info.get("relative_path", "")
|
|
894
|
+
repo_name = file_info.get("repo_name", "")
|
|
895
|
+
local_dir = scope_dirs.get(repo_name, "")
|
|
896
|
+
|
|
897
|
+
if not local_dir or not relative_path:
|
|
898
|
+
return FileComplexityResult(
|
|
899
|
+
file_path=file_path,
|
|
900
|
+
relative_path=relative_path,
|
|
901
|
+
error="Missing local directory or relative path",
|
|
902
|
+
)
|
|
903
|
+
|
|
904
|
+
disk_path = os.path.join(
|
|
905
|
+
local_dir,
|
|
906
|
+
relative_path.replace("\\", os.sep).replace("/", os.sep),
|
|
907
|
+
)
|
|
908
|
+
|
|
909
|
+
try:
|
|
910
|
+
with open(disk_path, "r", encoding="utf-8", errors="replace") as fh:
|
|
911
|
+
content = fh.read()
|
|
912
|
+
except Exception as exc:
|
|
913
|
+
return FileComplexityResult(
|
|
914
|
+
file_path=file_path,
|
|
915
|
+
relative_path=relative_path,
|
|
916
|
+
error=f"Cannot read file: {exc}",
|
|
917
|
+
)
|
|
918
|
+
|
|
919
|
+
if not content.strip():
|
|
920
|
+
return FileComplexityResult(
|
|
921
|
+
file_path=file_path,
|
|
922
|
+
relative_path=relative_path,
|
|
923
|
+
error="File is empty",
|
|
924
|
+
)
|
|
925
|
+
|
|
926
|
+
metrics = analyze_file_complexity(
|
|
927
|
+
file_path,
|
|
928
|
+
content,
|
|
929
|
+
language=_get_language_file_info(file_info),
|
|
930
|
+
)
|
|
931
|
+
if metrics is None:
|
|
932
|
+
return FileComplexityResult(
|
|
933
|
+
file_path=file_path,
|
|
934
|
+
relative_path=relative_path,
|
|
935
|
+
error="Analysis returned no results",
|
|
936
|
+
)
|
|
937
|
+
|
|
938
|
+
return FileComplexityResult(
|
|
939
|
+
file_path=file_path,
|
|
940
|
+
relative_path=relative_path,
|
|
941
|
+
metrics=metrics,
|
|
942
|
+
)
|