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,1739 @@
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 collections.abc import Callable
36
+ from concurrent.futures import ThreadPoolExecutor, as_completed
37
+ from dataclasses import dataclass
38
+ from typing import Any
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+
43
+ # ---------------------------------------------------------------------------
44
+ # Optional library availability flags
45
+ # ---------------------------------------------------------------------------
46
+
47
+ try:
48
+ from radon.complexity import cc_visit
49
+ from radon.metrics import h_visit, mi_visit
50
+ from radon.raw import analyze as radon_raw_analyze
51
+
52
+ RADON_AVAILABLE = True
53
+ except ImportError: # pragma: no cover
54
+ RADON_AVAILABLE = False
55
+
56
+ try:
57
+ import lizard # type: ignore[import-untyped]
58
+
59
+ LIZARD_AVAILABLE = True
60
+ except ImportError: # pragma: no cover
61
+ LIZARD_AVAILABLE = False
62
+
63
+
64
+ # ---------------------------------------------------------------------------
65
+ # Public result dataclasses
66
+ # ---------------------------------------------------------------------------
67
+
68
+
69
+ @dataclass
70
+ class FileComplexityResult:
71
+ """Structured result for a single file's complexity analysis."""
72
+
73
+ file_path: str
74
+ """The cli:// path registered with CodeDD."""
75
+ relative_path: str
76
+ """Human-readable relative path for display."""
77
+ metrics: dict[str, Any] | None = None
78
+ """Full complexity metrics dict (cyclomatic + Halstead)."""
79
+ error: str | None = None
80
+
81
+ @property
82
+ def success(self) -> bool:
83
+ return self.metrics is not None and self.error is None
84
+
85
+
86
+ # ---------------------------------------------------------------------------
87
+ # Extension → language mapping (ported from coordinator.py)
88
+ # ---------------------------------------------------------------------------
89
+
90
+ _EXTENSION_TO_LANG: dict[str, str] = {
91
+ # Python
92
+ ".py": "python",
93
+ ".pyx": "python",
94
+ ".pxd": "python",
95
+ ".pxi": "python",
96
+ ".pyw": "python",
97
+ # JavaScript/TypeScript
98
+ ".js": "javascript",
99
+ ".jsx": "javascript",
100
+ ".mjs": "javascript",
101
+ ".cjs": "javascript",
102
+ ".ts": "typescript",
103
+ ".tsx": "typescript",
104
+ ".mts": "typescript",
105
+ ".cts": "typescript",
106
+ ".vue": "javascript",
107
+ ".svelte": "javascript",
108
+ # C/C++/C#
109
+ ".c": "c",
110
+ ".h": "c",
111
+ ".cpp": "cpp",
112
+ ".cc": "cpp",
113
+ ".cxx": "cpp",
114
+ ".hpp": "cpp",
115
+ ".hxx": "cpp",
116
+ ".cs": "csharp",
117
+ ".fs": "fsharp",
118
+ # Java
119
+ ".java": "java",
120
+ # Ruby
121
+ ".rb": "ruby",
122
+ ".rake": "ruby",
123
+ # Go
124
+ ".go": "go",
125
+ # PHP
126
+ ".php": "php",
127
+ # Swift
128
+ ".swift": "swift",
129
+ # Perl
130
+ ".pl": "perl",
131
+ ".pm": "perl",
132
+ ".t": "perl",
133
+ ".pod": "perl",
134
+ # R
135
+ ".r": "r",
136
+ ".rmd": "r",
137
+ ".jl": "julia",
138
+ # Shell/Bash
139
+ ".sh": "shell",
140
+ ".bash": "shell",
141
+ ".zsh": "shell",
142
+ ".fish": "shell",
143
+ ".ksh": "shell",
144
+ ".csh": "shell",
145
+ ".tcsh": "shell",
146
+ ".bat": "shell",
147
+ ".cmd": "shell",
148
+ ".awk": "shell",
149
+ ".sed": "shell",
150
+ # PowerShell
151
+ ".ps1": "powershell",
152
+ ".psm1": "powershell",
153
+ ".psd1": "powershell",
154
+ # SQL
155
+ ".sql": "sql",
156
+ ".hql": "sql",
157
+ ".cypher": "sql",
158
+ # GraphQL
159
+ ".graphql": "graphql",
160
+ ".gql": "graphql",
161
+ # Rust / Scala / Kotlin
162
+ ".rs": "rust",
163
+ ".scala": "scala",
164
+ ".sc": "scala",
165
+ ".kt": "kotlin",
166
+ ".kts": "kotlin",
167
+ # Other functional / systems languages
168
+ ".clj": "clojure",
169
+ ".cljs": "clojure",
170
+ ".cljc": "clojure",
171
+ ".erl": "erlang",
172
+ ".ex": "elixir",
173
+ ".exs": "elixir",
174
+ ".hs": "haskell",
175
+ ".lhs": "haskell",
176
+ ".lua": "lua",
177
+ ".dart": "dart",
178
+ ".groovy": "groovy",
179
+ ".tcl": "tcl",
180
+ ".nim": "nim",
181
+ ".cr": "crystal",
182
+ ".ml": "ocaml",
183
+ ".zig": "zig",
184
+ ".v": "vlang",
185
+ ".gleam": "gleam",
186
+ # Low-level and scientific languages
187
+ ".asm": "assembly",
188
+ ".s": "assembly",
189
+ ".f90": "fortran",
190
+ ".f95": "fortran",
191
+ ".f03": "fortran",
192
+ }
193
+
194
+ # Source-code extensions for the "is this file worth analysing?" check
195
+ _SOURCE_CODE_EXTENSIONS: set[str] = {
196
+ ".js",
197
+ ".jsx",
198
+ ".mjs",
199
+ ".cjs",
200
+ ".ts",
201
+ ".tsx",
202
+ ".mts",
203
+ ".cts",
204
+ ".vue",
205
+ ".svelte",
206
+ ".php",
207
+ ".py",
208
+ ".java",
209
+ ".cpp",
210
+ ".cc",
211
+ ".cxx",
212
+ ".hpp",
213
+ ".c",
214
+ ".h",
215
+ ".cs",
216
+ ".fs",
217
+ ".go",
218
+ ".rs",
219
+ ".rb",
220
+ ".rake",
221
+ ".swift",
222
+ ".kt",
223
+ ".kts",
224
+ ".scala",
225
+ ".sc",
226
+ ".clj",
227
+ ".cljs",
228
+ ".cljc",
229
+ ".erl",
230
+ ".ex",
231
+ ".exs",
232
+ ".hs",
233
+ ".lhs",
234
+ ".lua",
235
+ ".pl",
236
+ ".pm",
237
+ ".t",
238
+ ".pod",
239
+ ".r",
240
+ ".rmd",
241
+ ".jl",
242
+ ".dart",
243
+ ".groovy",
244
+ ".tcl",
245
+ ".nim",
246
+ ".cr",
247
+ ".ml",
248
+ ".zig",
249
+ ".v",
250
+ ".gleam",
251
+ ".sh",
252
+ ".bash",
253
+ ".zsh",
254
+ ".fish",
255
+ ".bat",
256
+ ".cmd",
257
+ ".ps1",
258
+ ".psm1",
259
+ ".psd1",
260
+ ".awk",
261
+ ".sed",
262
+ ".ksh",
263
+ ".csh",
264
+ ".tcsh",
265
+ ".sql",
266
+ ".hql",
267
+ ".cypher",
268
+ ".graphql",
269
+ ".gql",
270
+ ".pyx",
271
+ ".pxd",
272
+ ".pxi",
273
+ ".asm",
274
+ ".s",
275
+ ".f90",
276
+ ".f95",
277
+ ".f03",
278
+ }
279
+
280
+
281
+ def _is_source_code(file_path: str) -> bool:
282
+ """Return True if the file extension is a recognised source-code type."""
283
+ _, ext = os.path.splitext(file_path)
284
+ return ext.lower() in _SOURCE_CODE_EXTENSIONS
285
+
286
+
287
+ def _get_language(file_path: str) -> str | None:
288
+ """Map a file path to a language identifier (or None)."""
289
+ _, ext = os.path.splitext(file_path)
290
+ return _EXTENSION_TO_LANG.get(ext.lower())
291
+
292
+
293
+ def _is_source_code_file_info(file_info: dict) -> bool:
294
+ """
295
+ Source-code detection using both canonical cli:// and relative path.
296
+
297
+ Some plan payloads carry conservative canonical paths, while relative_path
298
+ always mirrors the real repo filename. Using both avoids false negatives.
299
+ """
300
+ canonical_path = str(file_info.get("file_path", "")).strip()
301
+ relative_path = str(file_info.get("relative_path", "")).strip()
302
+ return _is_source_code(canonical_path) or _is_source_code(relative_path)
303
+
304
+
305
+ def _get_language_file_info(file_info: dict) -> str | None:
306
+ """Resolve language using canonical path first, then relative path."""
307
+ canonical_path = str(file_info.get("file_path", "")).strip()
308
+ relative_path = str(file_info.get("relative_path", "")).strip()
309
+ return _get_language(canonical_path) or _get_language(relative_path)
310
+
311
+
312
+ # ---------------------------------------------------------------------------
313
+ # Complexity rank helper (ported from utils.py)
314
+ # ---------------------------------------------------------------------------
315
+
316
+
317
+ def get_complexity_rank(complexity: float) -> str:
318
+ """Map a cyclomatic complexity value to a letter rank (A–F)."""
319
+ if complexity <= 5:
320
+ return "A"
321
+ if complexity <= 10:
322
+ return "B"
323
+ if complexity <= 20:
324
+ return "C"
325
+ if complexity <= 30:
326
+ return "D"
327
+ if complexity <= 40:
328
+ return "E"
329
+ return "F"
330
+
331
+
332
+ # ---------------------------------------------------------------------------
333
+ # Halstead metrics helpers (ported from halstead_metrics.py)
334
+ # ---------------------------------------------------------------------------
335
+
336
+
337
+ def _halstead_from_counts(
338
+ operators: set,
339
+ operands: set,
340
+ total_operators: int,
341
+ total_operands: int,
342
+ language: str,
343
+ analyzer: str,
344
+ ) -> dict[str, Any]:
345
+ """Compute Halstead metrics from raw operator/operand counts."""
346
+ h1 = len(operators)
347
+ h2 = len(operands)
348
+ n1 = total_operators
349
+ n2 = total_operands
350
+ vocabulary = h1 + h2
351
+ length = n1 + n2
352
+
353
+ volume = length * math.log2(vocabulary) if vocabulary > 1 else 0
354
+ difficulty = (h1 / 2) * (n2 / h2) if h2 > 0 else 0
355
+ effort = difficulty * volume
356
+ time_est = effort / 18
357
+ bugs = volume / 3000
358
+ calc_len = (h1 * math.log2(h1) + h2 * math.log2(h2)) if h1 > 0 and h2 > 0 else 0
359
+
360
+ return {
361
+ "file_metrics": {
362
+ "h1": h1,
363
+ "h2": h2,
364
+ "N1": n1,
365
+ "N2": n2,
366
+ "vocabulary": vocabulary,
367
+ "length": length,
368
+ "calculated_length": calc_len,
369
+ "volume": volume,
370
+ "difficulty": difficulty,
371
+ "effort": effort,
372
+ "time": time_est,
373
+ "bugs": bugs,
374
+ },
375
+ "function_metrics": [],
376
+ "language": language,
377
+ "analyzer": analyzer,
378
+ }
379
+
380
+
381
+ def _tokenize_and_count(
382
+ content: str,
383
+ language_operators: list[str],
384
+ comment_pattern: str = r"\/\/.*|\/\*[\s\S]*?\*\/",
385
+ ) -> dict[str, Any]:
386
+ """Generic tokeniser shared by most Halstead language analysers."""
387
+ content = re.sub(comment_pattern, "", content)
388
+ content = re.sub(r'"(?:[^"\\]|\\.)*"|\'(?:[^\'\\]|\\.)*\'', '"S"', content)
389
+ tokens = re.findall(
390
+ r"[\w]+|[^\s\w]|[\=\+\-\*\/\%\<\>\!\&\|\^\~\(\)\{\}\[\]\,\;\:\.\?]",
391
+ content,
392
+ )
393
+ ops: set[str] = set()
394
+ opds: set[str] = set()
395
+ n_ops = n_opds = 0
396
+ op_set = set(language_operators)
397
+ for tok in tokens:
398
+ if tok in op_set:
399
+ ops.add(tok)
400
+ n_ops += 1
401
+ elif tok.isalnum() or tok == "S":
402
+ opds.add(tok)
403
+ n_opds += 1
404
+ return {"operators": ops, "operands": opds, "N1": n_ops, "N2": n_opds}
405
+
406
+
407
+ # Language-specific operator lists
408
+ _JS_OPS = [
409
+ "+",
410
+ "-",
411
+ "*",
412
+ "/",
413
+ "%",
414
+ "=",
415
+ "==",
416
+ "===",
417
+ "!=",
418
+ "!==",
419
+ "<",
420
+ ">",
421
+ "<=",
422
+ ">=",
423
+ "&&",
424
+ "||",
425
+ "!",
426
+ "?",
427
+ ":",
428
+ "++",
429
+ "--",
430
+ "+=",
431
+ "-=",
432
+ "*=",
433
+ "/=",
434
+ "%=",
435
+ "&=",
436
+ "|=",
437
+ "^=",
438
+ ">>=",
439
+ "<<=",
440
+ ">>>=",
441
+ "=>",
442
+ "function",
443
+ "return",
444
+ "if",
445
+ "else",
446
+ "for",
447
+ "while",
448
+ "do",
449
+ "switch",
450
+ "case",
451
+ "break",
452
+ "continue",
453
+ "new",
454
+ "delete",
455
+ "typeof",
456
+ "instanceof",
457
+ "void",
458
+ "throw",
459
+ "try",
460
+ "catch",
461
+ "finally",
462
+ ]
463
+ _JAVA_OPS = [
464
+ "+",
465
+ "-",
466
+ "*",
467
+ "/",
468
+ "%",
469
+ "=",
470
+ "==",
471
+ "!=",
472
+ "<",
473
+ ">",
474
+ "<=",
475
+ ">=",
476
+ "&&",
477
+ "||",
478
+ "!",
479
+ "++",
480
+ "--",
481
+ "+=",
482
+ "-=",
483
+ "*=",
484
+ "/=",
485
+ "%=",
486
+ "&=",
487
+ "|=",
488
+ "^=",
489
+ ">>=",
490
+ "<<=",
491
+ ">>>=",
492
+ "new",
493
+ "instanceof",
494
+ "if",
495
+ "else",
496
+ "for",
497
+ "while",
498
+ "do",
499
+ "switch",
500
+ "case",
501
+ "break",
502
+ "continue",
503
+ "return",
504
+ "throw",
505
+ "try",
506
+ "catch",
507
+ "finally",
508
+ "synchronized",
509
+ "this",
510
+ "super",
511
+ ]
512
+ _C_CPP_OPS = [
513
+ "+",
514
+ "-",
515
+ "*",
516
+ "/",
517
+ "%",
518
+ "=",
519
+ "==",
520
+ "!=",
521
+ "<",
522
+ ">",
523
+ "<=",
524
+ ">=",
525
+ "&&",
526
+ "||",
527
+ "!",
528
+ "++",
529
+ "--",
530
+ "+=",
531
+ "-=",
532
+ "*=",
533
+ "/=",
534
+ "%=",
535
+ "&=",
536
+ "|=",
537
+ "^=",
538
+ ">>=",
539
+ "<<=",
540
+ "->",
541
+ ".",
542
+ "::",
543
+ "?",
544
+ ":",
545
+ "if",
546
+ "else",
547
+ "for",
548
+ "while",
549
+ "do",
550
+ "switch",
551
+ "case",
552
+ "break",
553
+ "continue",
554
+ "return",
555
+ "goto",
556
+ "throw",
557
+ "try",
558
+ "catch",
559
+ "new",
560
+ "delete",
561
+ "sizeof",
562
+ "typedef",
563
+ ]
564
+ _GO_OPS = [
565
+ "+",
566
+ "-",
567
+ "*",
568
+ "/",
569
+ "%",
570
+ "=",
571
+ "==",
572
+ "!=",
573
+ "<",
574
+ ">",
575
+ "<=",
576
+ ">=",
577
+ "&&",
578
+ "||",
579
+ "!",
580
+ "++",
581
+ "--",
582
+ "+=",
583
+ "-=",
584
+ "*=",
585
+ "/=",
586
+ "%=",
587
+ "&=",
588
+ "|=",
589
+ "^=",
590
+ ">>=",
591
+ "<<=",
592
+ "&^=",
593
+ "<-",
594
+ "...",
595
+ "&^",
596
+ "if",
597
+ "else",
598
+ "for",
599
+ "range",
600
+ "switch",
601
+ "case",
602
+ "break",
603
+ "continue",
604
+ "return",
605
+ "go",
606
+ "defer",
607
+ "goto",
608
+ "func",
609
+ "interface",
610
+ "select",
611
+ "chan",
612
+ ]
613
+ _RUBY_OPS = [
614
+ "+",
615
+ "-",
616
+ "*",
617
+ "/",
618
+ "%",
619
+ "=",
620
+ "==",
621
+ "!=",
622
+ "<",
623
+ ">",
624
+ "<=",
625
+ ">=",
626
+ "&&",
627
+ "||",
628
+ "!",
629
+ "+=",
630
+ "-=",
631
+ "*=",
632
+ "/=",
633
+ "%=",
634
+ "**",
635
+ "**=",
636
+ "..",
637
+ "...",
638
+ "&",
639
+ "|",
640
+ "^",
641
+ "~",
642
+ "<<",
643
+ ">>",
644
+ "=~",
645
+ "!~",
646
+ "<=>",
647
+ "if",
648
+ "else",
649
+ "elsif",
650
+ "unless",
651
+ "while",
652
+ "until",
653
+ "for",
654
+ "in",
655
+ "begin",
656
+ "rescue",
657
+ "ensure",
658
+ "end",
659
+ "case",
660
+ "when",
661
+ "break",
662
+ "next",
663
+ "return",
664
+ "yield",
665
+ "def",
666
+ "class",
667
+ "module",
668
+ ]
669
+ _PHP_OPS = [
670
+ "+",
671
+ "-",
672
+ "*",
673
+ "/",
674
+ "%",
675
+ "=",
676
+ "==",
677
+ "===",
678
+ "!=",
679
+ "!==",
680
+ "<",
681
+ ">",
682
+ "<=",
683
+ ">=",
684
+ "&&",
685
+ "||",
686
+ "!",
687
+ "++",
688
+ "--",
689
+ "+=",
690
+ "-=",
691
+ "*=",
692
+ "/=",
693
+ "%=",
694
+ ".=",
695
+ "&=",
696
+ "|=",
697
+ "^=",
698
+ ">>=",
699
+ "<<=",
700
+ "??",
701
+ "?:",
702
+ "?",
703
+ ":",
704
+ "->",
705
+ "=>",
706
+ "::",
707
+ "if",
708
+ "else",
709
+ "elseif",
710
+ "foreach",
711
+ "for",
712
+ "while",
713
+ "do",
714
+ "switch",
715
+ "case",
716
+ "break",
717
+ "continue",
718
+ "return",
719
+ "require",
720
+ "include",
721
+ "require_once",
722
+ "include_once",
723
+ "throw",
724
+ "try",
725
+ "catch",
726
+ "finally",
727
+ "function",
728
+ "class",
729
+ "interface",
730
+ "trait",
731
+ "abstract",
732
+ "final",
733
+ "public",
734
+ "private",
735
+ "protected",
736
+ ]
737
+ _RUST_OPS = [
738
+ "+",
739
+ "-",
740
+ "*",
741
+ "/",
742
+ "%",
743
+ "=",
744
+ "==",
745
+ "!=",
746
+ "<",
747
+ ">",
748
+ "<=",
749
+ ">=",
750
+ "&&",
751
+ "||",
752
+ "!",
753
+ "+=",
754
+ "-=",
755
+ "*=",
756
+ "/=",
757
+ "%=",
758
+ "&=",
759
+ "|=",
760
+ "^=",
761
+ ">>=",
762
+ "<<=",
763
+ "..",
764
+ "...",
765
+ "&",
766
+ "|",
767
+ "^",
768
+ "~",
769
+ "<<",
770
+ ">>",
771
+ "->",
772
+ "=>",
773
+ "::",
774
+ "if",
775
+ "else",
776
+ "match",
777
+ "for",
778
+ "while",
779
+ "loop",
780
+ "break",
781
+ "continue",
782
+ "return",
783
+ "let",
784
+ "mut",
785
+ "fn",
786
+ "struct",
787
+ "enum",
788
+ "trait",
789
+ "impl",
790
+ "pub",
791
+ "use",
792
+ "mod",
793
+ "async",
794
+ "await",
795
+ "dyn",
796
+ "ref",
797
+ "move",
798
+ ]
799
+ _SWIFT_OPS = [
800
+ "+",
801
+ "-",
802
+ "*",
803
+ "/",
804
+ "%",
805
+ "=",
806
+ "==",
807
+ "!=",
808
+ "<",
809
+ ">",
810
+ "<=",
811
+ ">=",
812
+ "&&",
813
+ "||",
814
+ "!",
815
+ "+=",
816
+ "-=",
817
+ "*=",
818
+ "/=",
819
+ "%=",
820
+ "&=",
821
+ "|=",
822
+ "^=",
823
+ ">>=",
824
+ "<<=",
825
+ "??",
826
+ "?",
827
+ ":",
828
+ ".",
829
+ "->",
830
+ "if",
831
+ "else",
832
+ "guard",
833
+ "switch",
834
+ "case",
835
+ "default",
836
+ "for",
837
+ "while",
838
+ "repeat",
839
+ "break",
840
+ "continue",
841
+ "return",
842
+ "throw",
843
+ "try",
844
+ "catch",
845
+ "defer",
846
+ "where",
847
+ "in",
848
+ "as",
849
+ "is",
850
+ "nil",
851
+ "func",
852
+ "class",
853
+ "struct",
854
+ "enum",
855
+ "protocol",
856
+ "extension",
857
+ "let",
858
+ "var",
859
+ "inout",
860
+ "self",
861
+ "super",
862
+ "init",
863
+ "deinit",
864
+ ]
865
+ _KOTLIN_OPS = [
866
+ "+",
867
+ "-",
868
+ "*",
869
+ "/",
870
+ "%",
871
+ "=",
872
+ "==",
873
+ "!=",
874
+ "<",
875
+ ">",
876
+ "<=",
877
+ ">=",
878
+ "&&",
879
+ "||",
880
+ "!",
881
+ "+=",
882
+ "-=",
883
+ "*=",
884
+ "/=",
885
+ "%=",
886
+ "&=",
887
+ "|=",
888
+ "^=",
889
+ ">>=",
890
+ "<<=",
891
+ "..",
892
+ "?:",
893
+ "?",
894
+ ":",
895
+ ".",
896
+ "->",
897
+ "if",
898
+ "else",
899
+ "when",
900
+ "for",
901
+ "while",
902
+ "do",
903
+ "break",
904
+ "continue",
905
+ "return",
906
+ "throw",
907
+ "try",
908
+ "catch",
909
+ "finally",
910
+ "class",
911
+ "interface",
912
+ "fun",
913
+ "val",
914
+ "var",
915
+ "this",
916
+ "super",
917
+ "in",
918
+ "is",
919
+ "as",
920
+ "by",
921
+ "object",
922
+ "init",
923
+ "companion",
924
+ "internal",
925
+ "private",
926
+ "protected",
927
+ "public",
928
+ "abstract",
929
+ "final",
930
+ "open",
931
+ "override",
932
+ "lateinit",
933
+ "inner",
934
+ "suspend",
935
+ "data",
936
+ ]
937
+ _CSHARP_OPS = [
938
+ "+",
939
+ "-",
940
+ "*",
941
+ "/",
942
+ "%",
943
+ "=",
944
+ "==",
945
+ "!=",
946
+ "<",
947
+ ">",
948
+ "<=",
949
+ ">=",
950
+ "&&",
951
+ "||",
952
+ "!",
953
+ "++",
954
+ "--",
955
+ "+=",
956
+ "-=",
957
+ "*=",
958
+ "/=",
959
+ "%=",
960
+ "&=",
961
+ "|=",
962
+ "^=",
963
+ ">>=",
964
+ "<<=",
965
+ "??",
966
+ "?.",
967
+ "?",
968
+ ":",
969
+ "if",
970
+ "else",
971
+ "for",
972
+ "foreach",
973
+ "while",
974
+ "do",
975
+ "switch",
976
+ "case",
977
+ "break",
978
+ "continue",
979
+ "return",
980
+ "throw",
981
+ "try",
982
+ "catch",
983
+ "finally",
984
+ "new",
985
+ "typeof",
986
+ "sizeof",
987
+ "is",
988
+ "as",
989
+ "using",
990
+ "await",
991
+ "async",
992
+ ]
993
+ _POWERSHELL_OPS = [
994
+ "+",
995
+ "-",
996
+ "*",
997
+ "/",
998
+ "%",
999
+ "=",
1000
+ "-eq",
1001
+ "-ne",
1002
+ "-gt",
1003
+ "-lt",
1004
+ "-ge",
1005
+ "-le",
1006
+ "-like",
1007
+ "-notlike",
1008
+ "-match",
1009
+ "-notmatch",
1010
+ "-contains",
1011
+ "-notcontains",
1012
+ "-and",
1013
+ "-or",
1014
+ "-not",
1015
+ "-xor",
1016
+ "-band",
1017
+ "-bor",
1018
+ "-bnot",
1019
+ "-bxor",
1020
+ "-f",
1021
+ "-split",
1022
+ "-join",
1023
+ "+=",
1024
+ "-=",
1025
+ "*=",
1026
+ "/=",
1027
+ "%=",
1028
+ "..",
1029
+ ".",
1030
+ "if",
1031
+ "else",
1032
+ "elseif",
1033
+ "switch",
1034
+ "for",
1035
+ "foreach",
1036
+ "while",
1037
+ "do",
1038
+ "break",
1039
+ "continue",
1040
+ "return",
1041
+ "function",
1042
+ "filter",
1043
+ "try",
1044
+ "catch",
1045
+ "finally",
1046
+ "throw",
1047
+ "param",
1048
+ "begin",
1049
+ "process",
1050
+ "end",
1051
+ "dynamicparam",
1052
+ "class",
1053
+ "using",
1054
+ "namespace",
1055
+ "enum",
1056
+ ]
1057
+ _GENERIC_OPS = [
1058
+ "+",
1059
+ "-",
1060
+ "*",
1061
+ "/",
1062
+ "%",
1063
+ "=",
1064
+ "==",
1065
+ "!=",
1066
+ "<",
1067
+ ">",
1068
+ "<=",
1069
+ ">=",
1070
+ "&&",
1071
+ "||",
1072
+ "!",
1073
+ "++",
1074
+ "--",
1075
+ "if",
1076
+ "else",
1077
+ "for",
1078
+ "while",
1079
+ "return",
1080
+ ]
1081
+
1082
+
1083
+ def _halstead_for_language(content: str, language: str | None, file_path: str) -> dict | None:
1084
+ """Select the right Halstead tokeniser for the language and return metrics."""
1085
+ if not language:
1086
+ language = "generic"
1087
+ lang = language.lower()
1088
+
1089
+ # Python — use radon if available
1090
+ if lang == "python" and RADON_AVAILABLE:
1091
+ try:
1092
+ h_results = h_visit(content)
1093
+ if h_results and hasattr(h_results, "total"):
1094
+ fm = h_results.total
1095
+ file_metrics = {
1096
+ "h1": fm.h1,
1097
+ "h2": fm.h2,
1098
+ "N1": fm.N1,
1099
+ "N2": fm.N2,
1100
+ "vocabulary": fm.vocabulary,
1101
+ "length": fm.length,
1102
+ "calculated_length": fm.calculated_length,
1103
+ "volume": fm.volume,
1104
+ "difficulty": fm.difficulty,
1105
+ "effort": fm.effort,
1106
+ "time": fm.time,
1107
+ "bugs": fm.bugs,
1108
+ }
1109
+ func_metrics = []
1110
+ if hasattr(h_results, "functions") and h_results.functions:
1111
+ for item in h_results.functions:
1112
+ if isinstance(item, tuple) and len(item) == 2:
1113
+ fname, m = item
1114
+ func_metrics.append(
1115
+ {
1116
+ "name": fname,
1117
+ "h1": m.h1,
1118
+ "h2": m.h2,
1119
+ "N1": m.N1,
1120
+ "N2": m.N2,
1121
+ "vocabulary": m.vocabulary,
1122
+ "length": m.length,
1123
+ "calculated_length": m.calculated_length,
1124
+ "volume": m.volume,
1125
+ "difficulty": m.difficulty,
1126
+ "effort": m.effort,
1127
+ "time": m.time,
1128
+ "bugs": m.bugs,
1129
+ }
1130
+ )
1131
+ return {
1132
+ "file_metrics": file_metrics,
1133
+ "function_metrics": func_metrics,
1134
+ "language": "python",
1135
+ "analyzer": "radon",
1136
+ }
1137
+ except Exception:
1138
+ pass
1139
+ return None
1140
+
1141
+ # Select operator list by language
1142
+ ops_map: dict[str, list[str]] = {
1143
+ "javascript": _JS_OPS,
1144
+ "typescript": _JS_OPS,
1145
+ "java": _JAVA_OPS,
1146
+ "c": _C_CPP_OPS,
1147
+ "cpp": _C_CPP_OPS,
1148
+ "csharp": _CSHARP_OPS,
1149
+ "go": _GO_OPS,
1150
+ "ruby": _RUBY_OPS,
1151
+ "php": _PHP_OPS,
1152
+ "rust": _RUST_OPS,
1153
+ "swift": _SWIFT_OPS,
1154
+ "kotlin": _KOTLIN_OPS,
1155
+ }
1156
+ comment_map: dict[str, str] = {
1157
+ "ruby": r"#.*",
1158
+ "powershell": r"#.*|<#[\s\S]*?#>",
1159
+ }
1160
+
1161
+ ops = ops_map.get(lang)
1162
+ if ops:
1163
+ cmt = comment_map.get(lang, r"\/\/.*|\/\*[\s\S]*?\*\/")
1164
+ counts = _tokenize_and_count(content, ops, cmt)
1165
+ return _halstead_from_counts(
1166
+ counts["operators"],
1167
+ counts["operands"],
1168
+ counts["N1"],
1169
+ counts["N2"],
1170
+ language,
1171
+ "custom",
1172
+ )
1173
+
1174
+ if lang == "powershell":
1175
+ cmt = comment_map["powershell"]
1176
+ counts = _tokenize_and_count(content, _POWERSHELL_OPS, cmt)
1177
+ return _halstead_from_counts(
1178
+ counts["operators"],
1179
+ counts["operands"],
1180
+ counts["N1"],
1181
+ counts["N2"],
1182
+ language,
1183
+ "custom",
1184
+ )
1185
+
1186
+ # Generic fallback
1187
+ counts = _tokenize_and_count(content, _GENERIC_OPS, r"\/\/.*|\/\*[\s\S]*?\*\/|#.*")
1188
+ return _halstead_from_counts(
1189
+ counts["operators"],
1190
+ counts["operands"],
1191
+ counts["N1"],
1192
+ counts["N2"],
1193
+ language or "generic",
1194
+ "generic",
1195
+ )
1196
+
1197
+
1198
+ # ---------------------------------------------------------------------------
1199
+ # Cyclomatic complexity — language-specific analysers
1200
+ # ---------------------------------------------------------------------------
1201
+
1202
+
1203
+ def _analyze_python(file_path: str, content: str) -> dict[str, Any] | None:
1204
+ """Python complexity via radon (AST-based)."""
1205
+ if not RADON_AVAILABLE:
1206
+ return _analyze_generic(file_path, content, "python")
1207
+
1208
+ try:
1209
+ cc_results = cc_visit(content)
1210
+ except Exception:
1211
+ return _analyze_generic(file_path, content, "python")
1212
+
1213
+ try:
1214
+ maintainability = mi_visit(content, multi=True)
1215
+ except Exception:
1216
+ maintainability = 0
1217
+
1218
+ try:
1219
+ raw = radon_raw_analyze(content)
1220
+ raw_metrics = {
1221
+ "loc": raw.loc,
1222
+ "lloc": raw.lloc,
1223
+ "sloc": raw.sloc,
1224
+ "comments": raw.comments,
1225
+ "multi": raw.multi,
1226
+ "blank": raw.blank,
1227
+ }
1228
+ except Exception:
1229
+ raw_metrics = {"loc": len(content.splitlines())}
1230
+
1231
+ functions: list[dict] = []
1232
+ total_cx = max_cx = 0
1233
+ for r in cc_results or []:
1234
+ cx = r.complexity
1235
+ total_cx += cx
1236
+ max_cx = max(max_cx, cx)
1237
+ functions.append(
1238
+ {
1239
+ "name": r.name,
1240
+ "complexity": cx,
1241
+ "cyclomatic_complexity": cx,
1242
+ "line_number": r.lineno,
1243
+ "rank": get_complexity_rank(cx),
1244
+ "cyclomatic_complexity_rank": get_complexity_rank(cx),
1245
+ }
1246
+ )
1247
+
1248
+ avg = total_cx / len(functions) if functions else 0
1249
+ halstead = _halstead_for_language(content, "python", file_path)
1250
+
1251
+ return {
1252
+ "language": "python",
1253
+ "total_complexity": total_cx,
1254
+ "average_complexity": avg,
1255
+ "max_complexity": max_cx,
1256
+ "function_count": len(functions),
1257
+ "functions": functions,
1258
+ "methods": functions,
1259
+ "maintainability_index": maintainability,
1260
+ "file_path": file_path,
1261
+ "raw_metrics": raw_metrics,
1262
+ "halstead_metrics": halstead,
1263
+ }
1264
+
1265
+
1266
+ def _analyze_with_lizard(file_path: str, content: str, language: str) -> dict[str, Any] | None:
1267
+ """Analyse complexity with lizard (multi-language)."""
1268
+ if not LIZARD_AVAILABLE:
1269
+ return _analyze_generic(file_path, content, language)
1270
+
1271
+ ext_map = {
1272
+ "java": "java",
1273
+ "javascript": "js",
1274
+ "typescript": "ts",
1275
+ "c": "c",
1276
+ "cpp": "cpp",
1277
+ "csharp": "cs",
1278
+ "ruby": "rb",
1279
+ "php": "php",
1280
+ "go": "go",
1281
+ "swift": "swift",
1282
+ "rust": "rs",
1283
+ "scala": "scala",
1284
+ "kotlin": "kt",
1285
+ "python": "py",
1286
+ }
1287
+ ext = ext_map.get(language.lower(), "txt")
1288
+
1289
+ tmp_path = None
1290
+ try:
1291
+ with tempfile.NamedTemporaryFile(
1292
+ suffix=f".{ext}",
1293
+ delete=False,
1294
+ mode="w",
1295
+ encoding="utf-8",
1296
+ ) as tmp:
1297
+ tmp_path = tmp.name
1298
+ tmp.write(content)
1299
+
1300
+ analysis = lizard.analyze_file(tmp_path)
1301
+ methods: list[dict] = []
1302
+ total_cx = max_cx = 0
1303
+ for fn in analysis.function_list:
1304
+ cx = fn.cyclomatic_complexity
1305
+ methods.append(
1306
+ {
1307
+ "name": fn.name,
1308
+ "complexity": cx,
1309
+ "cyclomatic_complexity": cx,
1310
+ "line_number": fn.start_line,
1311
+ "rank": get_complexity_rank(cx),
1312
+ "cyclomatic_complexity_rank": get_complexity_rank(cx),
1313
+ }
1314
+ )
1315
+ total_cx += cx
1316
+ max_cx = max(max_cx, cx)
1317
+
1318
+ avg = total_cx / len(methods) if methods else 0
1319
+ halstead = _halstead_for_language(content, language, file_path)
1320
+
1321
+ return {
1322
+ "file_path": file_path,
1323
+ "language": language,
1324
+ "methods": methods,
1325
+ "functions": methods,
1326
+ "average_complexity": avg,
1327
+ "total_complexity": total_cx,
1328
+ "max_complexity": max_cx,
1329
+ "function_count": len(methods),
1330
+ "complexity_rank": get_complexity_rank(avg),
1331
+ "halstead_metrics": halstead,
1332
+ }
1333
+ except Exception:
1334
+ return _analyze_generic(file_path, content, language)
1335
+ finally:
1336
+ if tmp_path:
1337
+ try:
1338
+ os.unlink(tmp_path)
1339
+ except OSError:
1340
+ pass
1341
+
1342
+
1343
+ def _analyze_generic(file_path: str, content: str, language: str) -> dict[str, Any]:
1344
+ """Keyword-counting fallback for unsupported / unrecognised languages."""
1345
+ lines = content.splitlines()
1346
+ if language == "python":
1347
+ kw = ["if", "elif", "else:", "for", "while", "try:", "except:", "with"]
1348
+ elif language in ("javascript", "typescript", "java", "c", "cpp", "csharp"):
1349
+ kw = ["if", "else", "for", "while", "switch", "case", "try", "catch"]
1350
+ else:
1351
+ kw = ["if", "else", "for", "while", "switch", "try"]
1352
+
1353
+ count = 0
1354
+ for line in lines:
1355
+ s = line.strip().lower()
1356
+ if s.startswith(("#", "//", "/*", "*")):
1357
+ continue
1358
+ for k in kw:
1359
+ if k in s:
1360
+ count += 1
1361
+ break
1362
+
1363
+ cx = max(1, count + 1)
1364
+ rank = get_complexity_rank(cx)
1365
+ halstead = _halstead_for_language(content, language, file_path)
1366
+
1367
+ fn = [
1368
+ {
1369
+ "name": "whole_file",
1370
+ "complexity": cx,
1371
+ "cyclomatic_complexity": cx,
1372
+ "line_number": 1,
1373
+ "rank": rank,
1374
+ "cyclomatic_complexity_rank": rank,
1375
+ }
1376
+ ]
1377
+ return {
1378
+ "language": language,
1379
+ "total_complexity": cx,
1380
+ "average_complexity": cx,
1381
+ "max_complexity": cx,
1382
+ "function_count": 1,
1383
+ "functions": fn,
1384
+ "methods": fn,
1385
+ "estimation_method": "basic_keyword_count",
1386
+ "file_path": file_path,
1387
+ "line_count": len(lines),
1388
+ "halstead_metrics": halstead,
1389
+ }
1390
+
1391
+
1392
+ # JS/TS-specific helpers (ported from javascript.py)
1393
+
1394
+ _JS_FUNC_RE = re.compile(
1395
+ r"(?:function\s+([A-Za-z_$][A-Za-z0-9_$]*)|"
1396
+ r"(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*function|"
1397
+ r"(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*\([^)]*\)\s*=>|"
1398
+ r"([A-Za-z_$][A-Za-z0-9_$]*)\s*:\s*function|"
1399
+ r"([A-Za-z_$][A-Za-z0-9_$]*)\s*\([^)]*\)\s*{)"
1400
+ )
1401
+ _JS_CX_PATTERNS = [
1402
+ r"\bif\b",
1403
+ r"\belse\s+if\b",
1404
+ r"\bfor\b",
1405
+ r"\bwhile\b",
1406
+ r"\bdo\b",
1407
+ r"\bcatch\b",
1408
+ r"\bcase\b",
1409
+ r"\?\s*:",
1410
+ r"&&",
1411
+ r"\|\|",
1412
+ ]
1413
+
1414
+
1415
+ def _analyze_js_ts(file_path: str, content: str, language: str) -> dict[str, Any]:
1416
+ """Regex-based complexity analysis for JavaScript / TypeScript."""
1417
+ lines = content.splitlines()
1418
+ functions: list[dict] = []
1419
+ for i, line in enumerate(lines):
1420
+ for m in _JS_FUNC_RE.finditer(line):
1421
+ name = next((g for g in m.groups() if g), f"anonymous_{i + 1}")
1422
+ functions.append({"name": name, "line": i + 1})
1423
+
1424
+ if not functions:
1425
+ functions = [{"name": "global_scope", "line": 1}]
1426
+
1427
+ line_cx = []
1428
+ for line in lines:
1429
+ cx = sum(len(re.findall(p, line)) for p in _JS_CX_PATTERNS)
1430
+ line_cx.append(cx)
1431
+
1432
+ processed: list[dict] = []
1433
+ total_cx = max_cx = 0
1434
+ for idx, fn in enumerate(functions):
1435
+ start = fn["line"] - 1
1436
+ end = min(start + 20, len(lines))
1437
+ if idx + 1 < len(functions):
1438
+ end = min(end, functions[idx + 1]["line"] - 1)
1439
+ fcx = 1 + sum(line_cx[start:end])
1440
+ total_cx += fcx
1441
+ max_cx = max(max_cx, fcx)
1442
+ processed.append(
1443
+ {
1444
+ "name": fn["name"],
1445
+ "complexity": fcx,
1446
+ "cyclomatic_complexity": fcx,
1447
+ "line_number": fn["line"],
1448
+ "rank": get_complexity_rank(fcx),
1449
+ "cyclomatic_complexity_rank": get_complexity_rank(fcx),
1450
+ }
1451
+ )
1452
+
1453
+ avg = total_cx / len(processed) if processed else 0
1454
+ halstead = _halstead_for_language(content, language, file_path)
1455
+
1456
+ return {
1457
+ "language": language,
1458
+ "total_complexity": total_cx,
1459
+ "average_complexity": avg,
1460
+ "max_complexity": max_cx,
1461
+ "function_count": len(processed),
1462
+ "functions": processed,
1463
+ "methods": processed,
1464
+ "file_path": file_path,
1465
+ "analysis_method": "regex",
1466
+ "line_count": len(lines),
1467
+ "halstead_metrics": halstead,
1468
+ }
1469
+
1470
+
1471
+ # ---------------------------------------------------------------------------
1472
+ # Single-file orchestrator (mirrors coordinator.analyze_file_complexity)
1473
+ # ---------------------------------------------------------------------------
1474
+
1475
+
1476
+ def analyze_file_complexity(
1477
+ file_path: str,
1478
+ content: str,
1479
+ language: str | None = None,
1480
+ ) -> dict[str, Any] | None:
1481
+ """
1482
+ Analyse cyclomatic complexity + Halstead metrics for a single file.
1483
+
1484
+ Args:
1485
+ file_path: The canonical path (used for storage/logging).
1486
+ content: Raw source-code text.
1487
+ language: Language hint (auto-detected from extension if ``None``).
1488
+
1489
+ Returns:
1490
+ A metrics dict compatible with the server-side
1491
+ ``store_cyclomatic_complexity`` / ``store_halstead_metrics`` schemas,
1492
+ or ``None`` if the file cannot be analysed.
1493
+ """
1494
+ if not content:
1495
+ return None
1496
+
1497
+ if language is None:
1498
+ language = _get_language(file_path)
1499
+
1500
+ try:
1501
+ if language == "python":
1502
+ return _analyze_python(file_path, content)
1503
+
1504
+ if language in ("javascript", "typescript"):
1505
+ return _analyze_js_ts(file_path, content, language)
1506
+
1507
+ # Languages well-supported by lizard
1508
+ if language in (
1509
+ "c",
1510
+ "cpp",
1511
+ "csharp",
1512
+ "java",
1513
+ "ruby",
1514
+ "go",
1515
+ "php",
1516
+ "swift",
1517
+ "rust",
1518
+ "scala",
1519
+ "kotlin",
1520
+ ):
1521
+ return _analyze_with_lizard(file_path, content, language)
1522
+
1523
+ # Generic fallback for shell, SQL, PowerShell, Perl, R, etc.
1524
+ if language:
1525
+ return _analyze_generic(file_path, content, language)
1526
+
1527
+ # Completely unknown language — still try generic
1528
+ return _analyze_generic(file_path, content, "generic")
1529
+
1530
+ except Exception as exc:
1531
+ logger.debug("Complexity analysis failed for %s: %s", file_path, exc)
1532
+ try:
1533
+ return _analyze_generic(file_path, content, language or "generic")
1534
+ except Exception:
1535
+ return None
1536
+
1537
+
1538
+ # ---------------------------------------------------------------------------
1539
+ # Aggregation helper (ported from utils.aggregate_complexity_results)
1540
+ # ---------------------------------------------------------------------------
1541
+
1542
+
1543
+ def aggregate_complexity_results(results: dict[str, dict]) -> dict[str, Any]:
1544
+ """Aggregate per-file complexity metrics into summary statistics."""
1545
+ if not results:
1546
+ return {"average_complexity": 0, "complexity_rank": "A", "file_count": 0, "method_count": 0}
1547
+
1548
+ total_cx = 0
1549
+ total_fns = 0
1550
+ lang_counts: dict[str, int] = {}
1551
+
1552
+ for fp, r in results.items():
1553
+ total_cx += r.get("average_complexity", 0)
1554
+ total_fns += len(r.get("methods", []))
1555
+ lang = r.get("language", "unknown")
1556
+ lang_counts[lang] = lang_counts.get(lang, 0) + 1
1557
+
1558
+ cx_dist: dict[str, int] = {"A": 0, "B": 0, "C": 0, "D": 0, "E": 0, "F": 0}
1559
+ for r in results.values():
1560
+ for m in r.get("methods", []):
1561
+ rank = m.get("rank", "F")
1562
+ cx_dist[rank] = cx_dist.get(rank, 0) + 1
1563
+
1564
+ complexities = [r.get("average_complexity", 0) for r in results.values()]
1565
+ extra: dict[str, Any] = {}
1566
+ if complexities:
1567
+ try:
1568
+ extra = {
1569
+ "median_complexity": statistics.median(complexities),
1570
+ "min_complexity": min(complexities),
1571
+ "max_complexity": max(complexities),
1572
+ "std_dev": statistics.stdev(complexities) if len(complexities) > 1 else 0,
1573
+ }
1574
+ except Exception:
1575
+ pass
1576
+
1577
+ avg = total_cx / len(results) if results else 0
1578
+ return {
1579
+ "average_complexity": avg,
1580
+ "complexity_rank": get_complexity_rank(avg),
1581
+ "file_count": len(results),
1582
+ "function_count": total_fns,
1583
+ "language_breakdown": lang_counts,
1584
+ "method_count": sum(len(r.get("methods", [])) for r in results.values()),
1585
+ "complexity_distribution": cx_dist,
1586
+ **extra,
1587
+ }
1588
+
1589
+
1590
+ # ---------------------------------------------------------------------------
1591
+ # Public batch analyser — the main entry-point used by audit_cmd.py
1592
+ # ---------------------------------------------------------------------------
1593
+
1594
+
1595
+ class LocalComplexityAnalyzer:
1596
+ """
1597
+ Batch complexity analyser for CLI-side audit execution.
1598
+
1599
+ Reads files from disk, computes cyclomatic + Halstead metrics using the
1600
+ same algorithms as the CodeDD server, and returns structured JSON results
1601
+ ready for submission.
1602
+
1603
+ Usage::
1604
+
1605
+ analyzer = LocalComplexityAnalyzer(max_workers=4, on_debug=print)
1606
+ results = analyzer.analyze_batch(files, scope_dirs, on_progress=cb)
1607
+ # results is list[FileComplexityResult]
1608
+ """
1609
+
1610
+ def __init__(
1611
+ self,
1612
+ max_workers: int = 4,
1613
+ on_debug: Callable[[str], None] | None = None,
1614
+ ) -> None:
1615
+ self._max_workers = min(max_workers, os.cpu_count() or 4, 8)
1616
+ self._on_debug = on_debug
1617
+
1618
+ def _debug(self, msg: str) -> None:
1619
+ if self._on_debug:
1620
+ self._on_debug(msg)
1621
+
1622
+ # ------------------------------------------------------------------
1623
+
1624
+ def analyze_batch(
1625
+ self,
1626
+ files: list[dict],
1627
+ scope_dirs: dict[str, str],
1628
+ on_progress: Callable[[FileComplexityResult], None] | None = None,
1629
+ ) -> list[FileComplexityResult]:
1630
+ """
1631
+ Analyse a batch of files concurrently.
1632
+
1633
+ Args:
1634
+ files: List of file dicts from the audit plan (must have
1635
+ ``file_path``, ``relative_path``, ``repo_name``).
1636
+ scope_dirs: ``{repo_name: local_directory}`` mapping.
1637
+ on_progress: Optional callback invoked after each file completes.
1638
+
1639
+ Returns:
1640
+ List of ``FileComplexityResult`` objects.
1641
+ """
1642
+ results: list[FileComplexityResult] = []
1643
+ source_files = [f for f in files if _is_source_code_file_info(f)]
1644
+
1645
+ if not source_files:
1646
+ self._debug("No source-code files to analyse for complexity.")
1647
+ return results
1648
+
1649
+ self._debug(f"Analysing complexity for {len(source_files)} source file(s) ({self._max_workers} workers)")
1650
+
1651
+ with ThreadPoolExecutor(max_workers=self._max_workers) as pool:
1652
+ future_map = {
1653
+ pool.submit(
1654
+ self._analyze_one,
1655
+ f,
1656
+ scope_dirs,
1657
+ ): f
1658
+ for f in source_files
1659
+ }
1660
+ for future in as_completed(future_map):
1661
+ f = future_map[future]
1662
+ try:
1663
+ result = future.result()
1664
+ except Exception as exc:
1665
+ result = FileComplexityResult(
1666
+ file_path=f.get("file_path", ""),
1667
+ relative_path=f.get("relative_path", ""),
1668
+ error=str(exc),
1669
+ )
1670
+ results.append(result)
1671
+ if on_progress:
1672
+ on_progress(result)
1673
+
1674
+ ok = sum(1 for r in results if r.success)
1675
+ fail = len(results) - ok
1676
+ if fail:
1677
+ sample_failures = [f"{r.relative_path or r.file_path}: {r.error}" for r in results if not r.success][:3]
1678
+ if sample_failures:
1679
+ self._debug("Sample complexity failures: " + " | ".join(sample_failures))
1680
+ self._debug(f"Complexity analysis complete: {ok} ok, {fail} failed")
1681
+ return results
1682
+
1683
+ def _analyze_one(
1684
+ self,
1685
+ file_info: dict,
1686
+ scope_dirs: dict[str, str],
1687
+ ) -> FileComplexityResult:
1688
+ """Read a single file from disk and compute its complexity."""
1689
+ file_path = file_info.get("file_path", "")
1690
+ relative_path = file_info.get("relative_path", "")
1691
+ repo_name = file_info.get("repo_name", "")
1692
+ local_dir = scope_dirs.get(repo_name, "")
1693
+
1694
+ if not local_dir or not relative_path:
1695
+ return FileComplexityResult(
1696
+ file_path=file_path,
1697
+ relative_path=relative_path,
1698
+ error="Missing local directory or relative path",
1699
+ )
1700
+
1701
+ disk_path = os.path.join(
1702
+ local_dir,
1703
+ relative_path.replace("\\", os.sep).replace("/", os.sep),
1704
+ )
1705
+
1706
+ try:
1707
+ with open(disk_path, encoding="utf-8", errors="replace") as fh:
1708
+ content = fh.read()
1709
+ except Exception as exc:
1710
+ return FileComplexityResult(
1711
+ file_path=file_path,
1712
+ relative_path=relative_path,
1713
+ error=f"Cannot read file: {exc}",
1714
+ )
1715
+
1716
+ if not content.strip():
1717
+ return FileComplexityResult(
1718
+ file_path=file_path,
1719
+ relative_path=relative_path,
1720
+ error="File is empty",
1721
+ )
1722
+
1723
+ metrics = analyze_file_complexity(
1724
+ file_path,
1725
+ content,
1726
+ language=_get_language_file_info(file_info),
1727
+ )
1728
+ if metrics is None:
1729
+ return FileComplexityResult(
1730
+ file_path=file_path,
1731
+ relative_path=relative_path,
1732
+ error="Analysis returned no results",
1733
+ )
1734
+
1735
+ return FileComplexityResult(
1736
+ file_path=file_path,
1737
+ relative_path=relative_path,
1738
+ metrics=metrics,
1739
+ )