codedna-analyzer 1.4.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 (37) hide show
  1. codedna/__init__.py +3 -0
  2. codedna/analyzers/__init__.py +1 -0
  3. codedna/analyzers/ai_analyzer.py +111 -0
  4. codedna/analyzers/architecture_detector.py +150 -0
  5. codedna/analyzers/cache_manager.py +70 -0
  6. codedna/analyzers/code_smell_detector.py +302 -0
  7. codedna/analyzers/complexity_analyzer.py +329 -0
  8. codedna/analyzers/dependency_mapper.py +171 -0
  9. codedna/analyzers/developer_analyzer.py +148 -0
  10. codedna/analyzers/diff_engine.py +395 -0
  11. codedna/analyzers/dna_generator.py +308 -0
  12. codedna/analyzers/evolution_engine.py +193 -0
  13. codedna/analyzers/github_analyzer.py +86 -0
  14. codedna/analyzers/language_detector.py +151 -0
  15. codedna/analyzers/repo_cloner.py +91 -0
  16. codedna/analyzers/security_detector.py +271 -0
  17. codedna/analyzers/structure_analyzer.py +91 -0
  18. codedna/ci/__init__.py +5 -0
  19. codedna/ci/gatekeeper.py +421 -0
  20. codedna/cli.py +839 -0
  21. codedna/compliance/__init__.py +5 -0
  22. codedna/compliance/sbom.py +410 -0
  23. codedna/config.py +167 -0
  24. codedna/mcp/__init__.py +5 -0
  25. codedna/mcp/protocol.py +65 -0
  26. codedna/mcp/server.py +393 -0
  27. codedna/remediation/__init__.py +5 -0
  28. codedna/remediation/planner.py +244 -0
  29. codedna/visualization/__init__.py +1 -0
  30. codedna/visualization/html_export.py +507 -0
  31. codedna/visualization/renderer.py +373 -0
  32. codedna_analyzer-1.4.0.dist-info/METADATA +425 -0
  33. codedna_analyzer-1.4.0.dist-info/RECORD +37 -0
  34. codedna_analyzer-1.4.0.dist-info/WHEEL +5 -0
  35. codedna_analyzer-1.4.0.dist-info/entry_points.txt +2 -0
  36. codedna_analyzer-1.4.0.dist-info/licenses/LICENSE +21 -0
  37. codedna_analyzer-1.4.0.dist-info/top_level.txt +1 -0
codedna/cli.py ADDED
@@ -0,0 +1,839 @@
1
+ """CodeDNA CLI — main command-line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import click
10
+
11
+ from . import __version__
12
+
13
+ # Ensure UTF-8 output encoding on Windows consoles
14
+ if sys.stdout and hasattr(sys.stdout, "reconfigure"):
15
+ try:
16
+ sys.stdout.reconfigure(encoding="utf-8")
17
+ except Exception:
18
+ pass
19
+ if sys.stderr and hasattr(sys.stderr, "reconfigure"):
20
+ try:
21
+ sys.stderr.reconfigure(encoding="utf-8")
22
+ except Exception:
23
+ pass
24
+
25
+
26
+ def _analyze_repo(source: str, depth: int, console, ai: bool = False):
27
+ """Run full analysis pipeline on a source repository or local directory.
28
+
29
+ Returns:
30
+ tuple: (profile, mermaid_graph, cloner)
31
+ """
32
+ from rich.progress import Progress, SpinnerColumn, TextColumn
33
+
34
+ from .analyzers.ai_analyzer import AIAnalyzer
35
+ from .analyzers.architecture_detector import ArchitectureDetector
36
+ from .analyzers.code_smell_detector import CodeSmellDetector
37
+ from .analyzers.dependency_mapper import DependencyMapper
38
+ from .analyzers.developer_analyzer import DeveloperAnalyzer
39
+ from .analyzers.dna_generator import DNAGenerator
40
+ from .analyzers.evolution_engine import EvolutionEngine
41
+ from .analyzers.github_analyzer import GitHubAnalyzer
42
+ from .analyzers.language_detector import LanguageDetector
43
+ from .analyzers.repo_cloner import RepoCloner
44
+ from .analyzers.security_detector import SecurityDetector
45
+ from .analyzers.structure_analyzer import StructureAnalyzer
46
+ from .config import load_config
47
+
48
+ # If source is an existing JSON file, load directly
49
+ source_path = Path(source)
50
+ if source_path.is_file() and source.endswith(".json"):
51
+ try:
52
+ profile = json.loads(source_path.read_text(encoding="utf-8"))
53
+ mermaid_graph = profile.get("mermaid_graph", "")
54
+ return profile, mermaid_graph, None
55
+ except Exception as e:
56
+ console.print(f"[yellow]⚠️ Failed to parse '{source}' as DNA JSON ({e}), analyzing as path...[/]")
57
+
58
+ cloner = RepoCloner(console=console)
59
+ mermaid_graph = ""
60
+
61
+ with Progress(
62
+ SpinnerColumn(),
63
+ TextColumn("[progress.description]{task.description}"),
64
+ console=console,
65
+ ) as progress:
66
+ # ── Stage 1: Clone / Resolve ──
67
+ task = progress.add_task(f"📥 Resolving '{source}'...", total=None)
68
+ repo_path = cloner.clone(source)
69
+ progress.update(task, description="[green]✓[/] Repository resolved")
70
+
71
+ # Load project configuration if present
72
+ config = load_config(repo_path)
73
+
74
+ # ── Stage 2: Language Detection ──
75
+ progress.update(task, description="🔍 Detecting languages...")
76
+ languages = LanguageDetector().detect(repo_path)
77
+ progress.update(task, description=f"[green]✓[/] Found {len(languages.get('languages', {}))} languages")
78
+
79
+ # ── Stage 3: Structure Analysis ──
80
+ progress.update(task, description="📁 Analyzing structure...")
81
+ structure = StructureAnalyzer().analyze(repo_path)
82
+ progress.update(task, description=f"[green]✓[/] {structure.get('total_files', 0)} files analyzed")
83
+
84
+ # ── Stage 4: Dependency Mapping ──
85
+ progress.update(task, description="🔗 Mapping dependencies...")
86
+ mapper = DependencyMapper()
87
+ dependencies = mapper.map(repo_path)
88
+ mermaid_graph = mapper.build_mermaid(dependencies)
89
+ progress.update(task, description=f"[green]✓[/] {dependencies.get('total_edges', 0)} dependency edges found")
90
+
91
+ # ── Stage 5: Architecture Detection ──
92
+ progress.update(task, description="🏗️ Detecting architecture patterns...")
93
+ architecture = ArchitectureDetector().detect(repo_path)
94
+ progress.update(task, description=f"[green]✓[/] Pattern: {architecture.get('primary_pattern', '?')}")
95
+
96
+ # ── Stage 6: Code Smell Detection ──
97
+ progress.update(task, description="🐛 Scanning for code smells...")
98
+ smells = CodeSmellDetector(config=config).detect(repo_path)
99
+ progress.update(task, description=f"[green]✓[/] {smells.get('total', 0)} issues found")
100
+
101
+ # ── Stage 6.5: Security Scanning ──
102
+ progress.update(task, description="🔒 Scanning for security vulnerabilities...")
103
+ security = SecurityDetector(config=config).detect(repo_path)
104
+ progress.update(task, description=f"[green]✓[/] {security.get('total_critical', 0)} critical vulnerabilities")
105
+
106
+ # ── Stage 7: Developer Analysis ──
107
+ progress.update(task, description="👥 Analyzing developer behavior...")
108
+ developers = DeveloperAnalyzer().analyze(repo_path, max_commits=depth)
109
+ progress.update(task, description=f"[green]✓[/] {developers.get('total_contributors', 0)} contributors")
110
+
111
+ # ── Stage 8: Evolution Engine ──
112
+ progress.update(task, description="📈 Tracking evolution...")
113
+ evolution = EvolutionEngine().analyze(repo_path)
114
+ progress.update(task, description="[green]✓[/] Evolution timeline built")
115
+
116
+ # ── Stage 8.5: GitHub Analysis ──
117
+ progress.update(task, description="🌐 Fetching GitHub community stats...")
118
+ github_stats = GitHubAnalyzer().analyze(source)
119
+ progress.update(task, description="[green]✓[/] GitHub stats retrieved")
120
+
121
+ # ── Stage 9: DNA Generation ──
122
+ progress.update(task, description="🧬 Generating DNA profile...")
123
+ generator = DNAGenerator()
124
+ profile = generator.generate(
125
+ repo_source=source,
126
+ languages=languages,
127
+ structure=structure,
128
+ dependencies=dependencies,
129
+ architecture=architecture,
130
+ smells=smells,
131
+ developers=developers,
132
+ evolution=evolution,
133
+ security=security,
134
+ github=github_stats,
135
+ mermaid_graph=mermaid_graph,
136
+ )
137
+
138
+ # ── Stage 10: AI Synthesis ──
139
+ if ai:
140
+ progress.update(task, description="🧠 Synthesizing executive insights via AI...")
141
+ ai_result = AIAnalyzer().synthesize(profile)
142
+ if ai_result.success:
143
+ profile["ai_insights"] = {
144
+ "executive_summary": ai_result.executive_summary,
145
+ "refactoring_recommendations": ai_result.refactoring_recommendations,
146
+ }
147
+ else:
148
+ console.print(f"[yellow]⚠️ AI Synthesis failed:[/] {ai_result.error_message}")
149
+
150
+ progress.update(task, description="[bold green]✓ DNA profile complete![/]")
151
+
152
+ return profile, mermaid_graph, cloner
153
+
154
+
155
+ @click.group()
156
+ @click.version_option(version=__version__, prog_name="codedna")
157
+ def main():
158
+ """🧬 CodeDNA — A genetic analyzer for software.
159
+
160
+ Reverse-engineer any codebase into a DNA profile describing its
161
+ architecture, structure, evolution, and developer patterns.
162
+ """
163
+ pass
164
+
165
+
166
+ @main.command()
167
+ @click.argument("directory", default=".")
168
+ @click.option("--force", "-f", is_flag=True, help="Overwrite existing codedna.toml configuration file")
169
+ def init(directory: str, force: bool):
170
+ """Scaffold a starter codedna.toml configuration file in DIRECTORY."""
171
+ from rich.console import Console
172
+
173
+ from .config import generate_default_config_toml
174
+
175
+ console = Console()
176
+ target_dir = Path(directory).resolve()
177
+ target_file = target_dir / "codedna.toml"
178
+
179
+ if target_file.exists() and not force:
180
+ console.print(f"[yellow]⚠️ Configuration file already exists:[/] {target_file}")
181
+ console.print("[dim]Use --force to overwrite.[/]")
182
+ return
183
+
184
+ target_dir.mkdir(parents=True, exist_ok=True)
185
+ content = generate_default_config_toml()
186
+ target_file.write_text(content, encoding="utf-8")
187
+ console.print(f"\n[bold green]✓ Initialized CodeDNA configuration:[/] [cyan]{target_file}[/]")
188
+ console.print("[dim]Edit this file to customize smell thresholds, secret whitelists, and CI gate policies.[/]\n")
189
+
190
+
191
+ @main.command()
192
+ @click.argument("source")
193
+ @click.option("--output", "-o", type=click.Path(), help="Output directory for reports")
194
+ @click.option("--format", "-f", "fmt", type=click.Choice(["markdown", "json", "html", "all"]), default="all", help="Output format") # noqa: E501
195
+ @click.option("--depth", "-d", type=int, default=100, help="Git history depth (commits to analyze)")
196
+ @click.option("--no-visualize", is_flag=True, help="Skip terminal visualization")
197
+ @click.option("--ai", is_flag=True, help="Synthesize an Executive Summary via LLM")
198
+ def analyze(source: str, output: str | None, fmt: str, depth: int, no_visualize: bool, ai: bool):
199
+ """Analyze a repository and generate its DNA profile.
200
+
201
+ SOURCE can be a GitHub URL or a local path to a repository.
202
+
203
+ \b
204
+ Examples:
205
+ codedna analyze https://github.com/user/project
206
+ codedna analyze ./my-local-project
207
+ codedna analyze . --output reports/
208
+ """
209
+ from rich.console import Console
210
+
211
+ from .analyzers.dna_generator import DNAGenerator
212
+ from .visualization.html_export import HTMLExporter
213
+ from .visualization.renderer import Renderer
214
+
215
+ console = Console()
216
+ console.print(f"\n[bold cyan]🧬 CodeDNA[/] [dim]v{__version__}[/]")
217
+ console.print("[dim]━" * 50 + "[/]\n")
218
+
219
+ cloner = None
220
+ try:
221
+ profile, mermaid_graph, cloner = _analyze_repo(source, depth, console, ai=ai)
222
+
223
+ # ── Render to terminal ──
224
+ if not no_visualize:
225
+ Renderer(console=console).render_dna_profile(profile)
226
+
227
+ # ── Save outputs ──
228
+ if output:
229
+ output_dir = Path(output)
230
+ output_dir.mkdir(parents=True, exist_ok=True)
231
+ generator = DNAGenerator()
232
+
233
+ if fmt in ("markdown", "all"):
234
+ md_path = output_dir / "dna_report.md"
235
+ md_path.write_text(generator.to_markdown(profile), encoding="utf-8")
236
+ console.print(f"\n 📄 Report saved: [cyan]{md_path}[/]")
237
+
238
+ if fmt in ("json", "all"):
239
+ json_path = output_dir / "dna_profile.json"
240
+ json_path.write_text(generator.to_json(profile), encoding="utf-8")
241
+ console.print(f" 📊 Data saved: [cyan]{json_path}[/]")
242
+
243
+ if fmt in ("html", "all"):
244
+ html_path = output_dir / "dna_dashboard.html"
245
+ exporter = HTMLExporter()
246
+ html_path.write_text(exporter.export(profile, mermaid_graph), encoding="utf-8")
247
+ console.print(f" 🌐 Dashboard saved: [cyan]{html_path}[/]")
248
+
249
+ console.print("\n[bold green]✨ Analysis complete![/]\n")
250
+
251
+ except Exception as e:
252
+ console.print(f"\n[bold red]❌ Error:[/] {e}")
253
+ sys.exit(1)
254
+ finally:
255
+ if cloner is not None and source.startswith("http"):
256
+ cloner.cleanup()
257
+
258
+
259
+ @main.command()
260
+ @click.argument("source_a")
261
+ @click.argument("source_b")
262
+ @click.option("--output", "-o", type=click.Path(), help="Output path for diff report (file or directory)")
263
+ @click.option("--format", "-f", "fmt", type=click.Choice(["terminal", "json", "markdown", "all"]), default="terminal", help="Output format") # noqa: E501
264
+ @click.option("--depth", "-d", type=int, default=100, help="Git history depth for analysis")
265
+ @click.option("--fail-on-drift/--no-fail-on-drift", default=False, help="Fail with exit code 1 if architectural drift regression is detected") # noqa: E501
266
+ def diff(source_a: str, source_b: str, output: str | None, fmt: str, depth: int, fail_on_drift: bool):
267
+ """Compare two codebases or DNA profiles to compute genetic drift.
268
+
269
+ SOURCE_A and SOURCE_B can each be a local path, GitHub URL, or a pre-generated dna_profile.json file.
270
+
271
+ \b
272
+ Examples:
273
+ codedna diff ./repo-main ./repo-refactor
274
+ codedna diff reports/dna_profile_v1.json reports/dna_profile_v2.json
275
+ codedna diff https://github.com/org/repo-a https://github.com/org/repo-b --format markdown
276
+ """
277
+ from rich.console import Console
278
+
279
+ from .analyzers.diff_engine import DiffEngine
280
+ from .visualization.renderer import Renderer
281
+
282
+ console = Console()
283
+ console.print(f"\n[bold cyan]🧬 CodeDNA Genetic Diff[/] [dim]v{__version__}[/]")
284
+ console.print("[dim]━" * 50 + "[/]\n")
285
+
286
+ cloner_a = None
287
+ cloner_b = None
288
+ try:
289
+ console.print("[bold]Baseline (A):[/]")
290
+ profile_a, _, cloner_a = _analyze_repo(source_a, depth, console)
291
+
292
+ console.print("\n[bold]Target (B):[/]")
293
+ profile_b, _, cloner_b = _analyze_repo(source_b, depth, console)
294
+
295
+ engine = DiffEngine()
296
+ diff_result = engine.compare(profile_a, profile_b)
297
+
298
+ # Terminal rendering
299
+ if fmt in ("terminal", "all"):
300
+ Renderer(console=console).render_diff(diff_result)
301
+
302
+ # Output saving
303
+ if output:
304
+ out_path = Path(output)
305
+ if out_path.is_dir() or (not out_path.suffix and not out_path.exists()):
306
+ out_path.mkdir(parents=True, exist_ok=True)
307
+ json_file = out_path / "dna_diff.json"
308
+ md_file = out_path / "dna_diff.md"
309
+ else:
310
+ out_path.parent.mkdir(parents=True, exist_ok=True)
311
+ json_file = out_path if out_path.suffix == ".json" else out_path.with_suffix(".json")
312
+ md_file = out_path if out_path.suffix == ".md" else out_path.with_suffix(".md")
313
+
314
+ if fmt in ("json", "all"):
315
+ json_file.write_text(engine.to_json(diff_result), encoding="utf-8")
316
+ console.print(f" 📊 Diff data saved: [cyan]{json_file}[/]")
317
+
318
+ if fmt in ("markdown", "all"):
319
+ md_file.write_text(engine.to_markdown(diff_result), encoding="utf-8")
320
+ console.print(f" 📄 Diff report saved: [cyan]{md_file}[/]")
321
+
322
+ elif fmt == "json":
323
+ click.echo(engine.to_json(diff_result))
324
+ elif fmt == "markdown":
325
+ click.echo(engine.to_markdown(diff_result))
326
+
327
+ if fail_on_drift:
328
+ passed, reasons = DiffEngine.evaluate_drift_gate(diff_result)
329
+ if not passed:
330
+ console.print(f"\n[bold red]❌ Drift gate breach: {len(reasons)} regression(s) detected.[/]")
331
+ for r in reasons:
332
+ console.print(f" • [red]{r}[/]")
333
+ sys.exit(1)
334
+
335
+ except SystemExit:
336
+ raise
337
+ except Exception as e:
338
+ console.print(f"\n[bold red]❌ Error during diff:[/] {e}")
339
+ sys.exit(1)
340
+ finally:
341
+ if cloner_a is not None and source_a.startswith("http"):
342
+ cloner_a.cleanup()
343
+ if cloner_b is not None and source_b.startswith("http"):
344
+ cloner_b.cleanup()
345
+
346
+
347
+ @main.command()
348
+ @click.argument("baseline")
349
+ @click.argument("target")
350
+ @click.option("--allow-degradation/--no-allow-degradation", default=False, help="Allow health score degradation (e.g. Healthy -> Fair)") # noqa: E501
351
+ @click.option("--allow-circular/--no-allow-circular", default=False, help="Allow newly introduced circular dependencies")
352
+ @click.option("--allow-secrets/--no-allow-secrets", default=False, help="Allow newly introduced secrets/credentials")
353
+ @click.option("--min-similarity", type=float, default=0.0, help="Minimum allowable genetic similarity score (0-100)")
354
+ @click.option("--format", "-f", "fmt", type=click.Choice(["terminal", "json", "markdown"]), default="terminal", help="Output format") # noqa: E501
355
+ @click.option("--output", "-o", type=click.Path(), help="Output file path for drift gate report")
356
+ @click.option("--depth", "-d", type=int, default=100, help="Git history depth for analysis")
357
+ def gate(
358
+ baseline: str,
359
+ target: str,
360
+ allow_degradation: bool,
361
+ allow_circular: bool,
362
+ allow_secrets: bool,
363
+ min_similarity: float,
364
+ fmt: str,
365
+ output: str | None,
366
+ depth: int,
367
+ ):
368
+ """Evaluate architectural drift between BASELINE and TARGET for CI/CD regression gating.
369
+
370
+ Exits with code 0 if within acceptable drift parameters, or code 1 on regression breach.
371
+ """
372
+ from rich.console import Console
373
+
374
+ from .analyzers.diff_engine import DiffEngine
375
+ from .visualization.renderer import Renderer
376
+
377
+ console = Console()
378
+ console.print(f"\n[bold cyan]🛡️ CodeDNA Drift Regression Gate[/] [dim]v{__version__}[/]")
379
+ console.print("[dim]━" * 50 + "[/]\n")
380
+
381
+ cloner_a = None
382
+ cloner_b = None
383
+ try:
384
+ console.print("[bold]Baseline (A):[/]")
385
+ profile_a, _, cloner_a = _analyze_repo(baseline, depth, console)
386
+
387
+ console.print("\n[bold]Target (B):[/]")
388
+ profile_b, _, cloner_b = _analyze_repo(target, depth, console)
389
+
390
+ engine = DiffEngine()
391
+ diff_result = engine.compare(profile_a, profile_b)
392
+
393
+ passed, reasons = DiffEngine.evaluate_drift_gate(
394
+ diff=diff_result,
395
+ allow_degradation=allow_degradation,
396
+ allow_circular_deps=allow_circular,
397
+ allow_new_secrets=allow_secrets,
398
+ min_similarity=min_similarity,
399
+ )
400
+
401
+ # Terminal rendering
402
+ if fmt == "terminal":
403
+ Renderer(console=console).render_drift_gate(passed, reasons, diff_result)
404
+
405
+ # JSON export
406
+ gate_data = {
407
+ "passed": passed,
408
+ "reasons": reasons,
409
+ "similarity_score": diff_result.get("similarity_score", 0.0),
410
+ "mutation_verdict": diff_result.get("mutation_verdict", "Unknown"),
411
+ "diff": diff_result,
412
+ }
413
+
414
+ if fmt == "json" or (output and output.endswith(".json")):
415
+ gate_json = json.dumps(gate_data, indent=2)
416
+ if output:
417
+ out_path = Path(output)
418
+ out_path.parent.mkdir(parents=True, exist_ok=True)
419
+ out_path.write_text(gate_json, encoding="utf-8")
420
+ console.print(f" 📊 Drift gate JSON written: [cyan]{out_path}[/]")
421
+ else:
422
+ click.echo(gate_json)
423
+
424
+ # Markdown export
425
+ elif fmt == "markdown" or (output and output.endswith(".md")):
426
+ md_lines = [
427
+ "# 🛡️ CodeDNA Architectural Drift Regression Gate",
428
+ "",
429
+ f"**Status:** {'✅ PASSED' if passed else '❌ FAILED'}",
430
+ f"**Genetic Similarity:** {diff_result.get('similarity_score', 0.0)}%",
431
+ f"**Mutation Verdict:** `{diff_result.get('mutation_verdict', 'Unknown')}`",
432
+ "",
433
+ ]
434
+ if reasons:
435
+ md_lines.append("### ⚠️ Drift Violations")
436
+ md_lines.append("")
437
+ for r in reasons:
438
+ md_lines.append(f"- ❌ {r}")
439
+ md_lines.append("")
440
+ else:
441
+ md_lines.append("✨ No architectural regression detected. All drift checks passed.")
442
+ md_lines.append("")
443
+ md_str = "\n".join(md_lines)
444
+ if output:
445
+ out_path = Path(output)
446
+ out_path.parent.mkdir(parents=True, exist_ok=True)
447
+ out_path.write_text(md_str, encoding="utf-8")
448
+ console.print(f" 📄 Drift gate Markdown written: [cyan]{out_path}[/]")
449
+ else:
450
+ click.echo(md_str)
451
+
452
+ if not passed:
453
+ sys.exit(1)
454
+ sys.exit(0)
455
+
456
+ except SystemExit:
457
+ raise
458
+ except Exception as e:
459
+ console.print(f"\n[bold red]❌ Error during drift gate check:[/] {e}")
460
+ sys.exit(1)
461
+ finally:
462
+ if cloner_a is not None and baseline.startswith("http"):
463
+ cloner_a.cleanup()
464
+ if cloner_b is not None and target.startswith("http"):
465
+ cloner_b.cleanup()
466
+
467
+
468
+ @main.command()
469
+ @click.argument("source", default=".")
470
+ @click.option("--min-health", type=click.Choice(["Healthy", "Fair", "Needs Attention", "Critical"], case_sensitive=False), default="Fair", help="Minimum allowable overall health rating") # noqa: E501
471
+ @click.option("--max-smells", type=int, default=None, help="Maximum allowable total code smells")
472
+ @click.option("--max-critical-smells", type=int, default=0, help="Maximum allowable critical code smells (default: 0)") # noqa: E501
473
+ @click.option("--fail-on-secrets/--no-fail-on-secrets", default=True, help="Fail if hardcoded credentials or secrets are found") # noqa: E501
474
+ @click.option("--fail-on-circular/--no-fail-on-circular", default=False, help="Fail if circular dependencies are detected") # noqa: E501
475
+ @click.option("--format", "-f", "fmt", type=click.Choice(["terminal", "json", "sarif", "markdown"]), default="terminal", help="Output format") # noqa: E501
476
+ @click.option("--output", "-o", type=click.Path(), help="Output file path for SARIF, JSON, or Markdown reports")
477
+ @click.option("--depth", "-d", type=int, default=100, help="Git history depth for analysis")
478
+ def check(
479
+ source: str,
480
+ min_health: str,
481
+ max_smells: int | None,
482
+ max_critical_smells: int,
483
+ fail_on_secrets: bool,
484
+ fail_on_circular: bool,
485
+ fmt: str,
486
+ output: str | None,
487
+ depth: int,
488
+ ):
489
+ """Evaluate codebase against architectural quality and security gates for CI/CD.
490
+
491
+ Returns exit code 0 if all policies pass, or exit code 1 on policy breach.
492
+ Supports SARIF v2.1.0 output for direct GitHub Code Scanning integration,
493
+ and Markdown for PR comments.
494
+
495
+ \b
496
+ Examples:
497
+ codedna check .
498
+ codedna check . --min-health Healthy --fail-on-circular
499
+ codedna check . --format sarif --output codedna-results.sarif
500
+ codedna check . --format markdown --output pr-comment.md
501
+ """
502
+ from rich.console import Console
503
+
504
+ from .ci.gatekeeper import Gatekeeper
505
+ from .config import load_config
506
+ from .visualization.renderer import Renderer
507
+
508
+ console = Console()
509
+ console.print(f"\n[bold cyan]🛡️ CodeDNA Quality Gatekeeper[/] [dim]v{__version__}[/]")
510
+ console.print("[dim]━" * 50 + "[/]\n")
511
+
512
+ cloner = None
513
+ try:
514
+ profile, _, cloner = _analyze_repo(source, depth, console)
515
+
516
+ # Resolve configuration defaults if available
517
+ repo_dir = Path(source) if Path(source).is_dir() else Path.cwd()
518
+ cfg = load_config(repo_dir)
519
+ gate_cfg = cfg.gate
520
+
521
+ resolved_min_health = min_health if min_health != "Fair" else (gate_cfg.min_health or min_health)
522
+ resolved_max_smells = max_smells if max_smells is not None else gate_cfg.max_smells
523
+ resolved_max_crit = max_critical_smells if max_critical_smells != 0 else gate_cfg.max_critical_smells
524
+ resolved_fail_secrets = fail_on_secrets if not fail_on_secrets else gate_cfg.fail_on_secrets
525
+ resolved_fail_circular = fail_on_circular if fail_on_circular else gate_cfg.fail_on_circular
526
+
527
+ gatekeeper = Gatekeeper(
528
+ min_health=resolved_min_health,
529
+ max_smells=resolved_max_smells,
530
+ max_critical_smells=resolved_max_crit,
531
+ fail_on_secrets=resolved_fail_secrets,
532
+ fail_on_circular=resolved_fail_circular,
533
+ )
534
+ result = gatekeeper.evaluate(profile)
535
+
536
+ # Terminal Rendering
537
+ if fmt == "terminal":
538
+ Renderer(console=console).render_gatekeeper(result)
539
+
540
+ # SARIF Export
541
+ if fmt == "sarif" or (output and output.endswith(".sarif")):
542
+ sarif_data = gatekeeper.to_sarif(result, profile)
543
+ sarif_json = json.dumps(sarif_data, indent=2)
544
+ if output:
545
+ out_path = Path(output)
546
+ out_path.parent.mkdir(parents=True, exist_ok=True)
547
+ out_path.write_text(sarif_json, encoding="utf-8")
548
+ console.print(f" 🛡️ SARIF report written: [cyan]{out_path}[/]")
549
+ else:
550
+ click.echo(sarif_json)
551
+
552
+ # JSON Export
553
+ elif fmt == "json" or (output and output.endswith(".json")):
554
+ json_str = gatekeeper.to_json(result)
555
+ if output:
556
+ out_path = Path(output)
557
+ out_path.parent.mkdir(parents=True, exist_ok=True)
558
+ out_path.write_text(json_str, encoding="utf-8")
559
+ console.print(f" 📊 Gatekeeper JSON written: [cyan]{out_path}[/]")
560
+ else:
561
+ click.echo(json_str)
562
+
563
+ # Markdown Export (for PR comments)
564
+ elif fmt == "markdown" or (output and output.endswith(".md")):
565
+ md_str = gatekeeper.to_markdown(result, profile)
566
+ if output:
567
+ out_path = Path(output)
568
+ out_path.parent.mkdir(parents=True, exist_ok=True)
569
+ out_path.write_text(md_str, encoding="utf-8")
570
+ console.print(f" 📄 Gatekeeper Markdown written: [cyan]{out_path}[/]")
571
+ else:
572
+ click.echo(md_str)
573
+
574
+ # Exit code reflects pass/fail gate outcome
575
+ if not result.passed:
576
+ sys.exit(1)
577
+ sys.exit(0)
578
+
579
+ except SystemExit:
580
+ raise
581
+ except Exception as e:
582
+ console.print(f"\n[bold red]❌ Error during gate check:[/] {e}")
583
+ sys.exit(1)
584
+ finally:
585
+ if cloner is not None and source.startswith("http"):
586
+ cloner.cleanup()
587
+
588
+
589
+ @main.command()
590
+ def mcp():
591
+ """Start Model Context Protocol (MCP 2024-11-05) JSON-RPC 2.0 server on stdio.
592
+
593
+ Enables dynamic tool invocation from AI agents like Claude Desktop, Cursor,
594
+ and Antigravity.
595
+ """
596
+ from .mcp.server import CodeDNAMCPServer
597
+
598
+ server = CodeDNAMCPServer()
599
+ server.run_stdio()
600
+
601
+
602
+ @main.command()
603
+ @click.argument("directory", default=".")
604
+ def audit(directory: str):
605
+ """Run an executive security and architectural compliance audit."""
606
+ from rich.console import Console
607
+ from rich.table import Table
608
+
609
+ from .analyzers.architecture_detector import ArchitectureDetector
610
+ from .analyzers.complexity_analyzer import ComplexityAnalyzer
611
+ from .analyzers.dependency_mapper import DependencyMapper
612
+ from .analyzers.security_detector import SecurityDetector
613
+ from .config import load_config
614
+
615
+ console = Console()
616
+ target_path = Path(directory).resolve()
617
+ console.print(f"\n[bold cyan]🔒 CodeDNA Executive Audit[/] [dim]v{__version__}[/]")
618
+ console.print(f"[dim]Auditing target:[/] [cyan]{target_path}[/]\n")
619
+
620
+ cfg = load_config(target_path)
621
+ arch = ArchitectureDetector().detect(target_path)
622
+ sec = SecurityDetector(config=cfg).detect(target_path)
623
+ cmp = ComplexityAnalyzer().analyze(target_path, config=cfg)
624
+ deps = DependencyMapper().map(target_path)
625
+
626
+ table = Table(title="Audit Scorecard", show_header=True, header_style="bold magenta")
627
+ table.add_column("Category", style="cyan", width=25)
628
+ table.add_column("Finding / Metric", style="white")
629
+ table.add_column("Status", justify="center", width=12)
630
+
631
+ pat = arch.get("primary_pattern", "Unknown")
632
+ table.add_row("Architecture Pattern", f"{pat} ({arch.get('coupling', 'Low')} Coupling)", "[green]PASS[/]")
633
+
634
+ mi = cmp.get("maintainability_index", 100.0)
635
+ mi_color = "green" if mi >= 30 else "yellow" if mi >= 15 else "red"
636
+ table.add_row("Maintainability Index", f"{mi}/100 (Rank {cmp.get('maintainability_rank', 'A')})", f"[{mi_color}]{'PASS' if mi >= 20 else 'WARN'}[/]")
637
+
638
+ avg_cc = cmp.get("average_cyclomatic_complexity", 1.0)
639
+ max_cc = cmp.get("max_cyclomatic_complexity", 1)
640
+ cc_status = "[green]PASS[/]" if max_cc <= 30 else "[red]FAIL[/]"
641
+ table.add_row("Complexity (McCabe)", f"Avg: {avg_cc} | Peak: {max_cc}", cc_status)
642
+
643
+ circ = deps.get("has_circular_deps", False)
644
+ circ_status = "[red]FAIL[/]" if circ else "[green]PASS[/]"
645
+ table.add_row("Circular Dependencies", f"{len(deps.get('cycles', []))} cycles detected", circ_status)
646
+
647
+ vulns = sec.get("vulnerabilities", [])
648
+ crit_vulns = sec.get("total_critical", 0)
649
+ sec_status = "[red]FAIL[/]" if crit_vulns > 0 else "[green]PASS[/]"
650
+ table.add_row("Security Vulnerabilities", f"{crit_vulns} critical | {len(vulns)} total findings", sec_status)
651
+
652
+ from .compliance.sbom import SBOMGenerator
653
+ from .remediation.planner import RemediationPlanner
654
+
655
+ sbom_deps = SBOMGenerator(target_path).discover_dependencies()
656
+ remed_actions = RemediationPlanner(target_path).plan()
657
+ autofix_count = sum(1 for a in remed_actions if a.autofixable)
658
+
659
+ table.add_row("Supply Chain (SBOM)", f"{len(sbom_deps)} packages tracked across manifests", "[green]INFO[/]")
660
+ remed_status = "[yellow]ACTION[/]" if autofix_count > 0 else "[green]CLEAN[/]"
661
+ table.add_row("Remediation Potential", f"{autofix_count} auto-fixable patches available", remed_status)
662
+
663
+ console.print(table)
664
+
665
+ if autofix_count > 0:
666
+ console.print(f"\n[bold cyan]💡 Quick Fix Available:[/] Run [bold white]'codedna fix'[/] to auto-remediate {autofix_count} code smells and security flaws.")
667
+
668
+ if crit_vulns > 0:
669
+ console.print("\n[bold red]🚨 Critical Security Findings:[/]")
670
+ for v in vulns:
671
+ if v.get("severity") == "critical":
672
+ console.print(f" • [red]{v.get('type')}:[/] {v.get('detail')} at [cyan]{v.get('file')}[/]")
673
+
674
+ complexity_ok = (max_cc <= (cfg.max_cyclomatic_complexity or 30)) and (mi >= (cfg.min_maintainability_index or 20.0))
675
+ passed = crit_vulns == 0 and not (cfg.fail_on_circular and circ) and complexity_ok
676
+ if passed:
677
+ console.print("\n[bold green]✓ Audit Completed: PASSED[/]\n")
678
+ sys.exit(0)
679
+ else:
680
+ console.print("\n[bold red]❌ Audit Completed: POLICY BREACH[/]\n")
681
+ sys.exit(1)
682
+
683
+
684
+ @main.command()
685
+ @click.argument("directory", default=".")
686
+ @click.option("--top", type=int, default=15, help="Number of complex functions to display")
687
+ def complexity(directory: str, top: int):
688
+ """Analyze and report function-level Cyclomatic and Cognitive complexity."""
689
+ from rich.console import Console
690
+ from rich.table import Table
691
+
692
+ from .analyzers.complexity_analyzer import ComplexityAnalyzer
693
+ from .config import load_config
694
+
695
+ console = Console()
696
+ target_path = Path(directory).resolve()
697
+ console.print(f"\n[bold cyan]⚙️ CodeDNA Complexity & Maintainability Engine[/] [dim]v{__version__}[/]")
698
+ console.print(f"[dim]Analyzing:[/] [cyan]{target_path}[/]\n")
699
+
700
+ cfg = load_config(target_path)
701
+ res = ComplexityAnalyzer().analyze(target_path, config=cfg)
702
+
703
+ console.print(f"• Functions Analyzed: [bold]{res['total_functions_analyzed']}[/]")
704
+ console.print(f"• Maintainability Index: [bold cyan]{res['maintainability_index']}/100[/] (Rank [bold green]{res['maintainability_rank']}[/])")
705
+ console.print(f"• Average Cyclomatic Complexity: [bold]{res['average_cyclomatic_complexity']}[/]")
706
+ console.print(f"• Peak Cyclomatic Complexity: [bold]{res['max_cyclomatic_complexity']}[/]")
707
+ console.print(f"• Average Cognitive Complexity: [bold]{res['average_cognitive_complexity']}[/]\n")
708
+
709
+ complex_funcs = res.get("complex_functions", [])
710
+ if complex_funcs:
711
+ table = Table(title="Highest Complexity Functions", show_header=True, header_style="bold magenta")
712
+ table.add_column("Function", style="cyan")
713
+ table.add_column("File:Line", style="dim")
714
+ table.add_column("Cyclomatic", justify="right")
715
+ table.add_column("Cognitive", justify="right")
716
+ table.add_column("Severity", justify="center")
717
+
718
+ for fn in complex_funcs[:top]:
719
+ sev_color = "red" if fn["severity"] == "critical" else "yellow"
720
+ table.add_row(
721
+ fn["name"],
722
+ f"{fn['file']}:{fn['line']}",
723
+ str(fn["cyclomatic_complexity"]),
724
+ str(fn["cognitive_complexity"]),
725
+ f"[{sev_color}]{fn['severity'].upper()}[/]",
726
+ )
727
+ console.print(table)
728
+ else:
729
+ console.print("[green]✨ Clean codebase! No high-complexity functions detected above thresholds.[/]\n")
730
+
731
+
732
+ @main.command()
733
+ @click.argument("directory", default=".")
734
+ @click.option("--format", "-f", "fmt", type=click.Choice(["cyclonedx", "spdx"]), default="cyclonedx", help="SBOM specification format")
735
+ @click.option("--output", "-o", type=click.Path(), help="Output file path (e.g., sbom.json)")
736
+ def sbom(directory: str, fmt: str, output: str | None):
737
+ """Generate CycloneDX 1.5 or SPDX 2.3 Software Bill of Materials (SBOM)."""
738
+ from rich.console import Console
739
+
740
+ from .compliance.sbom import SBOMGenerator
741
+
742
+ console = Console()
743
+ target_path = Path(directory).resolve()
744
+ generator = SBOMGenerator(target_path)
745
+
746
+ if fmt == "spdx":
747
+ doc = generator.generate_spdx_json()
748
+ else:
749
+ doc = generator.generate_cyclonedx_json()
750
+
751
+ output_str = json.dumps(doc, indent=2)
752
+
753
+ if output:
754
+ out_path = Path(output)
755
+ out_path.parent.mkdir(parents=True, exist_ok=True)
756
+ out_path.write_text(output_str, encoding="utf-8")
757
+ deps_count = len(doc.get("components", [])) if fmt == "cyclonedx" else len(doc.get("packages", [])) - 1
758
+ console.print(f"[bold green]✓ Successfully generated {fmt.upper()} SBOM:[/] [cyan]{out_path}[/] ({deps_count} dependencies)")
759
+ else:
760
+ console.print(output_str)
761
+
762
+
763
+ @main.command(name="remediate")
764
+ @click.argument("directory", default=".")
765
+ @click.option("--rules", "-r", help="Comma-separated rule IDs to filter (e.g. SMELL-BARE-EXCEPT,SEC-HARDCODED-SECRET)")
766
+ @click.option("--apply", "apply_fixes", is_flag=True, help="Apply autofixes directly to disk")
767
+ @click.option("--dry-run", is_flag=True, help="Preview autofixes without modifying files")
768
+ @click.option("--output", "-o", type=click.Path(), help="Output unified patch file")
769
+ def remediate(directory: str, rules: str | None, apply_fixes: bool, dry_run: bool, output: str | None):
770
+ """Plan and apply automated remediation diffs for smells and vulnerabilities."""
771
+ from rich.console import Console
772
+ from rich.table import Table
773
+
774
+ from .remediation.planner import RemediationPlanner
775
+
776
+ console = Console()
777
+ target_path = Path(directory).resolve()
778
+ planner = RemediationPlanner(target_path)
779
+
780
+ rule_filter = [r.strip() for r in rules.split(",")] if rules else None
781
+ actions = planner.plan(rule_filter=rule_filter)
782
+
783
+ console.print(f"\n[bold cyan]🔧 CodeDNA Intelligent Remediation Engine[/] [dim]v{__version__}[/]")
784
+ console.print(f"[dim]Workspace:[/] [cyan]{target_path}[/]\n")
785
+
786
+ if not actions:
787
+ console.print("[bold green]✨ Clean codebase! No actionable remediation targets found.[/]\n")
788
+ return
789
+
790
+ table = Table(title=f"Remediation Proposals ({len(actions)} total)", show_header=True, header_style="bold magenta")
791
+ table.add_column("Rule ID", style="cyan")
792
+ table.add_column("Location", style="dim")
793
+ table.add_column("Severity", justify="center")
794
+ table.add_column("Description")
795
+ table.add_column("Autofixable", justify="center")
796
+
797
+ all_diffs = []
798
+ for a in actions:
799
+ sev_color = "red" if a.severity == "critical" else "yellow" if a.severity == "warning" else "blue"
800
+ fix_badge = "[bold green]YES[/]" if a.autofixable else "[dim]MANUAL[/]"
801
+ table.add_row(
802
+ a.rule_id,
803
+ f"{a.file_path}:{a.line_number}",
804
+ f"[{sev_color}]{a.severity.upper()}[/]",
805
+ a.description[:60] + "..." if len(a.description) > 60 else a.description,
806
+ fix_badge,
807
+ )
808
+ if a.unified_diff:
809
+ all_diffs.append(a.unified_diff)
810
+
811
+ console.print(table)
812
+
813
+ if output and all_diffs:
814
+ out_path = Path(output)
815
+ out_path.parent.mkdir(parents=True, exist_ok=True)
816
+ out_path.write_text("\n".join(all_diffs), encoding="utf-8")
817
+ console.print(f"\n[cyan]Saved unified patch to:[/] [bold]{out_path}[/]")
818
+
819
+ if apply_fixes:
820
+ res = planner.apply(actions, dry_run=dry_run)
821
+ mode_str = "[yellow]DRY-RUN PREVIEW[/]" if dry_run else "[bold green]APPLIED[/]"
822
+ console.print(f"\n{mode_str}: Modified [bold cyan]{len(res['files_modified'])}[/] files across [bold cyan]{res['applied_count']}[/] safe patches.\n")
823
+ elif any(a.autofixable for a in actions):
824
+ console.print("\n[dim]Tip: Pass '--apply' to automatically patch safe findings on disk.[/]\n")
825
+
826
+
827
+ @main.command(name="fix")
828
+ @click.argument("directory", default=".")
829
+ @click.option("--rules", "-r", help="Comma-separated rule IDs to filter")
830
+ @click.option("--apply", "apply_fixes", is_flag=True, default=True, help="Apply autofixes directly (default)")
831
+ @click.option("--dry-run", is_flag=True, help="Preview autofixes without modifying files")
832
+ @click.pass_context
833
+ def fix(ctx, directory: str, rules: str | None, apply_fixes: bool, dry_run: bool):
834
+ """Shortcut alias to remediate and apply autofixes directly."""
835
+ ctx.invoke(remediate, directory=directory, rules=rules, apply_fixes=apply_fixes, dry_run=dry_run, output=None)
836
+
837
+
838
+ if __name__ == "__main__":
839
+ main()