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.
- codedna/__init__.py +3 -0
- codedna/analyzers/__init__.py +1 -0
- codedna/analyzers/ai_analyzer.py +111 -0
- codedna/analyzers/architecture_detector.py +150 -0
- codedna/analyzers/cache_manager.py +70 -0
- codedna/analyzers/code_smell_detector.py +302 -0
- codedna/analyzers/complexity_analyzer.py +329 -0
- codedna/analyzers/dependency_mapper.py +171 -0
- codedna/analyzers/developer_analyzer.py +148 -0
- codedna/analyzers/diff_engine.py +395 -0
- codedna/analyzers/dna_generator.py +308 -0
- codedna/analyzers/evolution_engine.py +193 -0
- codedna/analyzers/github_analyzer.py +86 -0
- codedna/analyzers/language_detector.py +151 -0
- codedna/analyzers/repo_cloner.py +91 -0
- codedna/analyzers/security_detector.py +271 -0
- codedna/analyzers/structure_analyzer.py +91 -0
- codedna/ci/__init__.py +5 -0
- codedna/ci/gatekeeper.py +421 -0
- codedna/cli.py +839 -0
- codedna/compliance/__init__.py +5 -0
- codedna/compliance/sbom.py +410 -0
- codedna/config.py +167 -0
- codedna/mcp/__init__.py +5 -0
- codedna/mcp/protocol.py +65 -0
- codedna/mcp/server.py +393 -0
- codedna/remediation/__init__.py +5 -0
- codedna/remediation/planner.py +244 -0
- codedna/visualization/__init__.py +1 -0
- codedna/visualization/html_export.py +507 -0
- codedna/visualization/renderer.py +373 -0
- codedna_analyzer-1.4.0.dist-info/METADATA +425 -0
- codedna_analyzer-1.4.0.dist-info/RECORD +37 -0
- codedna_analyzer-1.4.0.dist-info/WHEEL +5 -0
- codedna_analyzer-1.4.0.dist-info/entry_points.txt +2 -0
- codedna_analyzer-1.4.0.dist-info/licenses/LICENSE +21 -0
- 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()
|