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.
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 +118 -0
  5. codedd_cli/api/endpoints.py +44 -0
  6. codedd_cli/api/exceptions.py +25 -0
  7. codedd_cli/auditor/__init__.py +6 -0
  8. codedd_cli/auditor/architecture_analyzer.py +1241 -0
  9. codedd_cli/auditor/architecture_prompts.py +171 -0
  10. codedd_cli/auditor/complexity_analyzer.py +942 -0
  11. codedd_cli/auditor/dependency_scanner.py +2478 -0
  12. codedd_cli/auditor/file_auditor.py +572 -0
  13. codedd_cli/auditor/git_stats_collector.py +332 -0
  14. codedd_cli/auditor/response_parser.py +487 -0
  15. codedd_cli/auditor/vulnerability_validator.py +324 -0
  16. codedd_cli/auth/__init__.py +4 -0
  17. codedd_cli/auth/session.py +41 -0
  18. codedd_cli/auth/token_manager.py +87 -0
  19. codedd_cli/cli.py +69 -0
  20. codedd_cli/commands/__init__.py +1 -0
  21. codedd_cli/commands/audit_cmd.py +1877 -0
  22. codedd_cli/commands/audits_cmd.py +273 -0
  23. codedd_cli/commands/auth_cmd.py +230 -0
  24. codedd_cli/commands/config_cmd.py +454 -0
  25. codedd_cli/commands/scope_cmd.py +967 -0
  26. codedd_cli/config/__init__.py +4 -0
  27. codedd_cli/config/constants.py +22 -0
  28. codedd_cli/config/settings.py +369 -0
  29. codedd_cli/llm/__init__.py +1 -0
  30. codedd_cli/llm/key_manager.py +271 -0
  31. codedd_cli/models/__init__.py +5 -0
  32. codedd_cli/models/account.py +13 -0
  33. codedd_cli/models/audit.py +32 -0
  34. codedd_cli/models/local_directory.py +26 -0
  35. codedd_cli/scanner/__init__.py +18 -0
  36. codedd_cli/scanner/file_classifier.py +247 -0
  37. codedd_cli/scanner/file_walker.py +207 -0
  38. codedd_cli/scanner/line_counter.py +75 -0
  39. codedd_cli/utils/__init__.py +1 -0
  40. codedd_cli/utils/directory_validator.py +179 -0
  41. codedd_cli/utils/display.py +493 -0
  42. codedd_cli/utils/payload_inspector.py +180 -0
  43. codedd_cli/utils/security.py +14 -0
  44. codedd_cli/utils/validators.py +37 -0
  45. codedd_cli-0.1.0.dist-info/METADATA +276 -0
  46. codedd_cli-0.1.0.dist-info/RECORD +49 -0
  47. codedd_cli-0.1.0.dist-info/WHEEL +4 -0
  48. codedd_cli-0.1.0.dist-info/entry_points.txt +3 -0
  49. 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
+ )