forgeoptimizer 1.0.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.
- forgecli/__init__.py +4 -0
- forgecli/build/__init__.py +135 -0
- forgecli/build/apply.py +218 -0
- forgecli/build/caveman_optimize.py +37 -0
- forgecli/build/diff_extract.py +218 -0
- forgecli/build/llm.py +167 -0
- forgecli/build/optimize.py +37 -0
- forgecli/build/pipeline.py +76 -0
- forgecli/build/retrieval.py +157 -0
- forgecli/build/summarize.py +76 -0
- forgecli/build/test_run.py +75 -0
- forgecli/builder/__init__.py +11 -0
- forgecli/builder/builder.py +53 -0
- forgecli/builder/editor.py +36 -0
- forgecli/builder/formatter.py +31 -0
- forgecli/cli/__init__.py +5 -0
- forgecli/cli/bootstrap.py +281 -0
- forgecli/cli/commands_graph.py +137 -0
- forgecli/cli/commands_wrappers.py +63 -0
- forgecli/cli/main.py +122 -0
- forgecli/cli/ui.py +56 -0
- forgecli/config/__init__.py +28 -0
- forgecli/config/loader.py +85 -0
- forgecli/config/settings.py +204 -0
- forgecli/config/writer.py +90 -0
- forgecli/core/__init__.py +35 -0
- forgecli/core/container.py +68 -0
- forgecli/core/context.py +54 -0
- forgecli/core/credentials.py +114 -0
- forgecli/core/errors.py +27 -0
- forgecli/core/events.py +67 -0
- forgecli/core/logging.py +47 -0
- forgecli/core/models.py +159 -0
- forgecli/core/plugins.py +56 -0
- forgecli/core/service.py +22 -0
- forgecli/docs/__init__.py +5 -0
- forgecli/docs/generator.py +119 -0
- forgecli/engine/__init__.py +105 -0
- forgecli/engine/context.py +171 -0
- forgecli/engine/defaults.py +89 -0
- forgecli/engine/events.py +185 -0
- forgecli/engine/execution.py +526 -0
- forgecli/engine/plugins.py +146 -0
- forgecli/engine/runner.py +155 -0
- forgecli/engine/stages/__init__.py +33 -0
- forgecli/engine/stages/caveman_optimizer.py +47 -0
- forgecli/engine/stages/context_optimizer.py +44 -0
- forgecli/engine/stages/execution_engine_stage.py +108 -0
- forgecli/engine/stages/git_engine.py +30 -0
- forgecli/engine/stages/intent_analyzer.py +38 -0
- forgecli/engine/stages/model_router.py +66 -0
- forgecli/engine/stages/planning_engine.py +45 -0
- forgecli/engine/stages/repository_analyzer.py +54 -0
- forgecli/engine/stages/validation_engine.py +89 -0
- forgecli/git/__init__.py +9 -0
- forgecli/git/repo.py +55 -0
- forgecli/git/service.py +45 -0
- forgecli/graph/__init__.py +56 -0
- forgecli/graph/backend_graphify.py +453 -0
- forgecli/graph/edge.py +19 -0
- forgecli/graph/graph.py +85 -0
- forgecli/graph/graphify.py +412 -0
- forgecli/graph/indexer.py +82 -0
- forgecli/graph/node.py +47 -0
- forgecli/graph/repository.py +164 -0
- forgecli/memory/__init__.py +12 -0
- forgecli/memory/cache.py +61 -0
- forgecli/memory/history.py +138 -0
- forgecli/memory/store.py +87 -0
- forgecli/optimizer/__init__.py +14 -0
- forgecli/optimizer/caveman/__init__.py +173 -0
- forgecli/optimizer/caveman/cli.py +64 -0
- forgecli/optimizer/caveman/decorator.py +67 -0
- forgecli/optimizer/caveman/factory.py +51 -0
- forgecli/optimizer/caveman/ruleset.py +156 -0
- forgecli/optimizer/caveman/state.py +52 -0
- forgecli/optimizer/chunker.py +85 -0
- forgecli/optimizer/optimizer.py +81 -0
- forgecli/optimizer/ponytail/__init__.py +183 -0
- forgecli/optimizer/ponytail/cli.py +168 -0
- forgecli/optimizer/ponytail/decorator.py +70 -0
- forgecli/optimizer/ponytail/factory.py +51 -0
- forgecli/optimizer/ponytail/ruleset.py +168 -0
- forgecli/optimizer/ponytail/state.py +53 -0
- forgecli/optimizer/ranker.py +37 -0
- forgecli/optimizer/summarizer.py +44 -0
- forgecli/orchestrator/__init__.py +707 -0
- forgecli/planner/__init__.py +56 -0
- forgecli/planner/agent.py +61 -0
- forgecli/planner/plan.py +65 -0
- forgecli/planner/planner.py +17 -0
- forgecli/planner/render.py +271 -0
- forgecli/planner/serialize.py +106 -0
- forgecli/planner/software.py +834 -0
- forgecli/platform/__init__.py +91 -0
- forgecli/platform/core.py +201 -0
- forgecli/platform/deps.py +354 -0
- forgecli/platform/paths.py +236 -0
- forgecli/platform/shell.py +176 -0
- forgecli/platform/update.py +252 -0
- forgecli/plugins/__init__.py +251 -0
- forgecli/prompts/__init__.py +11 -0
- forgecli/prompts/loader.py +28 -0
- forgecli/prompts/registry.py +32 -0
- forgecli/prompts/renderer.py +23 -0
- forgecli/providers/__init__.py +39 -0
- forgecli/providers/anthropic.py +206 -0
- forgecli/providers/base.py +206 -0
- forgecli/providers/builtin.py +26 -0
- forgecli/providers/conversation.py +78 -0
- forgecli/providers/google.py +295 -0
- forgecli/providers/http_base.py +211 -0
- forgecli/providers/mock.py +113 -0
- forgecli/providers/openai.py +202 -0
- forgecli/providers/openai_compatible.py +728 -0
- forgecli/providers/router.py +345 -0
- forgecli/providers/router_state.py +89 -0
- forgecli/review/__init__.py +45 -0
- forgecli/review/analyzer.py +124 -0
- forgecli/review/analyzers/__init__.py +1 -0
- forgecli/review/analyzers/architecture.py +258 -0
- forgecli/review/analyzers/complexity.py +167 -0
- forgecli/review/analyzers/dead_code.py +262 -0
- forgecli/review/analyzers/duplicates.py +163 -0
- forgecli/review/analyzers/performance.py +165 -0
- forgecli/review/analyzers/security.py +251 -0
- forgecli/review/finding.py +68 -0
- forgecli/review/report.py +311 -0
- forgecli/review/repository.py +131 -0
- forgecli/review/suggestions.py +98 -0
- forgecli/runtime/__init__.py +6 -0
- forgecli/runtime/cache_store.py +77 -0
- forgecli/runtime/prepare.py +199 -0
- forgecli/runtime/wrappers.py +152 -0
- forgecli/sdk/__init__.py +132 -0
- forgecli/sdk/events.py +206 -0
- forgecli/sdk/interfaces.py +282 -0
- forgecli/sdk/loader.py +243 -0
- forgecli/sdk/manager.py +693 -0
- forgecli/sdk/manifest.py +397 -0
- forgecli/sdk/sandbox.py +197 -0
- forgecli/sdk/version.py +316 -0
- forgecli/templates/__init__.py +9 -0
- forgecli/templates/engine.py +37 -0
- forgecli/templates/registry.py +32 -0
- forgecli/utils/__init__.py +19 -0
- forgecli/utils/fs.py +91 -0
- forgecli/utils/ids.py +11 -0
- forgecli/utils/io.py +27 -0
- forgecli/utils/paths.py +70 -0
- forgecli/utils/timing.py +25 -0
- forgeoptimizer-1.0.0.dist-info/METADATA +169 -0
- forgeoptimizer-1.0.0.dist-info/RECORD +156 -0
- forgeoptimizer-1.0.0.dist-info/WHEEL +4 -0
- forgeoptimizer-1.0.0.dist-info/entry_points.txt +2 -0
- forgeoptimizer-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""Rich + JSON + Markdown renderers for a :class:`RepositoryReview`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Iterable
|
|
7
|
+
|
|
8
|
+
from rich.console import Console, Group
|
|
9
|
+
from rich.panel import Panel
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
|
|
12
|
+
from forgecli.review.finding import Finding, Severity
|
|
13
|
+
from forgecli.review.repository import RepositoryReview
|
|
14
|
+
from forgecli.review.suggestions import Suggestion
|
|
15
|
+
from forgecli.utils.paths import to_privacy_path
|
|
16
|
+
|
|
17
|
+
_SEVERITY_STYLE: dict[Severity, str] = {
|
|
18
|
+
Severity.INFO: "cyan",
|
|
19
|
+
Severity.LOW: "green",
|
|
20
|
+
Severity.MEDIUM: "yellow",
|
|
21
|
+
Severity.HIGH: "bold red",
|
|
22
|
+
Severity.CRITICAL: "bold magenta on red",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def print_review(
|
|
27
|
+
review: RepositoryReview,
|
|
28
|
+
console: Console | None = None,
|
|
29
|
+
*,
|
|
30
|
+
full: bool = False,
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Print a :class:`RepositoryReview` using the given Rich console."""
|
|
33
|
+
console = console or Console()
|
|
34
|
+
for renderable in render_review(review, full=full):
|
|
35
|
+
console.print(renderable)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def render_review(review: RepositoryReview, *, full: bool = False) -> list:
|
|
39
|
+
"""Return a list of Rich renderables that visualize ``review``."""
|
|
40
|
+
out: list = []
|
|
41
|
+
out.append(_render_header(review))
|
|
42
|
+
out.append(_render_summary(review))
|
|
43
|
+
if review.suggestions:
|
|
44
|
+
out.append(_render_suggestions(review.suggestions))
|
|
45
|
+
if review.findings:
|
|
46
|
+
out.append(_render_findings(review.findings, full=full))
|
|
47
|
+
if not review.findings:
|
|
48
|
+
out.append(Panel("[green]No findings.[/green]", border_style="green"))
|
|
49
|
+
return out
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
# JSON / Markdown
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def review_to_json(review: RepositoryReview, *, indent: int = 2) -> str:
|
|
58
|
+
"""Return the review as a JSON string."""
|
|
59
|
+
return json.dumps(review.to_dict(), indent=indent)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def review_to_markdown(review: RepositoryReview) -> str:
|
|
63
|
+
"""Return a Markdown rendering of ``review``."""
|
|
64
|
+
parts: list[str] = []
|
|
65
|
+
parts.append(f"# Review: {to_privacy_path(review.root)}\n")
|
|
66
|
+
counts = review.counts_by_severity()
|
|
67
|
+
parts.append("## Summary")
|
|
68
|
+
parts.append("")
|
|
69
|
+
parts.append(f"- Files analyzed: {review.stats.get('files', 0)}")
|
|
70
|
+
parts.append(f"- Analyzers run: {', '.join(review.analyzer_names)}")
|
|
71
|
+
parts.append(
|
|
72
|
+
f"- Findings: {len(review.findings)} "
|
|
73
|
+
f"(critical={counts[Severity.CRITICAL]}, "
|
|
74
|
+
f"high={counts[Severity.HIGH]}, "
|
|
75
|
+
f"medium={counts[Severity.MEDIUM]}, "
|
|
76
|
+
f"low={counts[Severity.LOW]}, "
|
|
77
|
+
f"info={counts[Severity.INFO]})"
|
|
78
|
+
)
|
|
79
|
+
if review.suggestions:
|
|
80
|
+
parts.append("## Suggestions")
|
|
81
|
+
parts.append("")
|
|
82
|
+
for suggestion in review.suggestions:
|
|
83
|
+
parts.append(
|
|
84
|
+
f"- **[{suggestion.severity.value}] {suggestion.title}** "
|
|
85
|
+
f"({suggestion.count} occurrence{'s' if suggestion.count != 1 else ''})"
|
|
86
|
+
)
|
|
87
|
+
if suggestion.rationale:
|
|
88
|
+
parts.append(f" - {suggestion.rationale}")
|
|
89
|
+
if review.findings:
|
|
90
|
+
parts.append("## Findings")
|
|
91
|
+
parts.append("")
|
|
92
|
+
parts.append("| Severity | Category | Rule | Path | Line | Message |")
|
|
93
|
+
parts.append("| --- | --- | --- | --- | --- | --- |")
|
|
94
|
+
for finding in review.findings:
|
|
95
|
+
parts.append(
|
|
96
|
+
"| {sev} | {cat} | {rule} | {path} | {line} | {msg} |".format(
|
|
97
|
+
sev=finding.severity.value,
|
|
98
|
+
cat=finding.category,
|
|
99
|
+
rule=finding.rule_id,
|
|
100
|
+
path=_shorten_path(to_privacy_path(finding.path)) if finding.path else "",
|
|
101
|
+
line=finding.line or "",
|
|
102
|
+
msg=_escape(finding.message),
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
if not review.findings:
|
|
106
|
+
parts.append("\n_No findings._")
|
|
107
|
+
return "\n".join(parts) + "\n"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# ---------------------------------------------------------------------------
|
|
111
|
+
# Rich sections
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _render_header(review: RepositoryReview) -> Panel:
|
|
116
|
+
counts = review.counts_by_severity()
|
|
117
|
+
critical = counts[Severity.CRITICAL]
|
|
118
|
+
high = counts[Severity.HIGH]
|
|
119
|
+
title_text = f"[bold]Code review:[/bold] {to_privacy_path(review.root)}"
|
|
120
|
+
if critical:
|
|
121
|
+
title_text += f" [critical on red] {critical} critical [/]"
|
|
122
|
+
if high:
|
|
123
|
+
title_text += f" [red] {high} high [/]"
|
|
124
|
+
return Panel(
|
|
125
|
+
title_text,
|
|
126
|
+
border_style="magenta",
|
|
127
|
+
title="forge review",
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _render_summary(review: RepositoryReview) -> Panel:
|
|
132
|
+
counts = review.counts_by_severity()
|
|
133
|
+
by_category = review.counts_by_category()
|
|
134
|
+
table = Table(
|
|
135
|
+
title="Counts",
|
|
136
|
+
title_style="bold cyan",
|
|
137
|
+
header_style="bold magenta",
|
|
138
|
+
show_lines=False,
|
|
139
|
+
expand=True,
|
|
140
|
+
)
|
|
141
|
+
table.add_column("Severity")
|
|
142
|
+
table.add_column("Count", justify="right")
|
|
143
|
+
for severity in (Severity.CRITICAL, Severity.HIGH, Severity.MEDIUM, Severity.LOW, Severity.INFO):
|
|
144
|
+
count = counts.get(severity, 0)
|
|
145
|
+
table.add_row(
|
|
146
|
+
TextRenderable(severity.value, style=_SEVERITY_STYLE[severity]),
|
|
147
|
+
str(count),
|
|
148
|
+
)
|
|
149
|
+
cat = Table(
|
|
150
|
+
title="By category",
|
|
151
|
+
title_style="bold cyan",
|
|
152
|
+
header_style="bold magenta",
|
|
153
|
+
show_lines=False,
|
|
154
|
+
expand=True,
|
|
155
|
+
)
|
|
156
|
+
cat.add_column("Category")
|
|
157
|
+
cat.add_column("Count", justify="right")
|
|
158
|
+
for name, count in sorted(by_category.items()):
|
|
159
|
+
cat.add_row(name, str(count))
|
|
160
|
+
stats = Table(
|
|
161
|
+
title="Stats",
|
|
162
|
+
title_style="bold cyan",
|
|
163
|
+
header_style="bold magenta",
|
|
164
|
+
show_lines=False,
|
|
165
|
+
expand=True,
|
|
166
|
+
)
|
|
167
|
+
stats.add_column("Field")
|
|
168
|
+
stats.add_column("Value")
|
|
169
|
+
for key, value in review.stats.items():
|
|
170
|
+
stats.add_row(key, str(value))
|
|
171
|
+
return Panel(
|
|
172
|
+
Group(table, TextRenderable(""), cat, TextRenderable(""), stats),
|
|
173
|
+
title="Summary",
|
|
174
|
+
border_style="cyan",
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _render_suggestions(suggestions: Iterable[Suggestion]) -> Panel:
|
|
179
|
+
table = Table(
|
|
180
|
+
title="Suggested actions",
|
|
181
|
+
title_style="bold cyan",
|
|
182
|
+
header_style="bold magenta",
|
|
183
|
+
show_lines=False,
|
|
184
|
+
expand=True,
|
|
185
|
+
)
|
|
186
|
+
table.add_column("Severity", no_wrap=True)
|
|
187
|
+
table.add_column("Category", no_wrap=True)
|
|
188
|
+
table.add_column("Action")
|
|
189
|
+
table.add_column("Count", justify="right")
|
|
190
|
+
for suggestion in suggestions:
|
|
191
|
+
table.add_row(
|
|
192
|
+
TextRenderable(
|
|
193
|
+
suggestion.severity.value, style=_SEVERITY_STYLE[suggestion.severity]
|
|
194
|
+
),
|
|
195
|
+
suggestion.category,
|
|
196
|
+
suggestion.title,
|
|
197
|
+
str(suggestion.count),
|
|
198
|
+
)
|
|
199
|
+
return Panel(table, title="Suggestions", border_style="cyan")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _render_findings(findings: Iterable[Finding], *, full: bool = False) -> Group:
|
|
203
|
+
# 1. Sort by severity weight descending
|
|
204
|
+
sorted_findings = sorted(findings, key=lambda f: f.severity.weight, reverse=True)
|
|
205
|
+
|
|
206
|
+
# 2. Slice to Top 10 if not full
|
|
207
|
+
capping_msg = ""
|
|
208
|
+
if not full and len(sorted_findings) > 10:
|
|
209
|
+
capping_msg = f" (Top 10 of {len(sorted_findings)} findings shown. Use --full for all)"
|
|
210
|
+
displayed_findings = sorted_findings[:10]
|
|
211
|
+
else:
|
|
212
|
+
displayed_findings = sorted_findings
|
|
213
|
+
|
|
214
|
+
# 3. Group by severity and then category
|
|
215
|
+
by_severity: dict[Severity, dict[str, list[Finding]]] = {}
|
|
216
|
+
for finding in displayed_findings:
|
|
217
|
+
by_severity.setdefault(finding.severity, {}).setdefault(finding.category, []).append(finding)
|
|
218
|
+
|
|
219
|
+
panels: list = []
|
|
220
|
+
# Loop over severity in order of weight descending
|
|
221
|
+
for severity in sorted(by_severity.keys(), key=lambda s: s.weight, reverse=True):
|
|
222
|
+
sev_style = _SEVERITY_STYLE.get(severity, "white")
|
|
223
|
+
panels.append(TextRenderable(f"\n[bold {sev_style}]▲ {severity.value.upper()} FINDINGS[/bold {sev_style}]"))
|
|
224
|
+
|
|
225
|
+
by_cat = by_severity[severity]
|
|
226
|
+
for category in sorted(by_cat.keys()):
|
|
227
|
+
rows = by_cat[category]
|
|
228
|
+
table = Table(
|
|
229
|
+
title=f"{category.capitalize()} ({len(rows)})",
|
|
230
|
+
title_style="bold cyan",
|
|
231
|
+
header_style="bold magenta",
|
|
232
|
+
show_lines=True,
|
|
233
|
+
expand=True,
|
|
234
|
+
)
|
|
235
|
+
table.add_column("Rule", no_wrap=True)
|
|
236
|
+
table.add_column("Path:Line", overflow="fold")
|
|
237
|
+
table.add_column("Message", overflow="fold")
|
|
238
|
+
table.add_column("Suggestion", overflow="fold", style="muted")
|
|
239
|
+
|
|
240
|
+
for finding in rows:
|
|
241
|
+
sanitized_path = to_privacy_path(finding.path) if finding.path else ""
|
|
242
|
+
location = (
|
|
243
|
+
f"{_shorten_path(sanitized_path)}:{finding.line}"
|
|
244
|
+
if sanitized_path and finding.line
|
|
245
|
+
else sanitized_path
|
|
246
|
+
)
|
|
247
|
+
table.add_row(
|
|
248
|
+
finding.rule_id,
|
|
249
|
+
location,
|
|
250
|
+
finding.message,
|
|
251
|
+
finding.suggestion or "",
|
|
252
|
+
)
|
|
253
|
+
panels.append(table)
|
|
254
|
+
|
|
255
|
+
header = Panel(
|
|
256
|
+
TextRenderable(f"[bold cyan]Findings{capping_msg}[/bold cyan]"),
|
|
257
|
+
border_style="cyan",
|
|
258
|
+
)
|
|
259
|
+
return Group(header, *panels)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
# ---------------------------------------------------------------------------
|
|
263
|
+
# Helpers
|
|
264
|
+
# ---------------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
class TextRenderable:
|
|
268
|
+
"""A small wrapper around :class:`rich.text.Text` that gives us a
|
|
269
|
+
:meth:`copy` method (Rich's Panel calls ``self.title.copy()`` on
|
|
270
|
+
non-string titles) without forcing every call site to import
|
|
271
|
+
``rich.text``.
|
|
272
|
+
"""
|
|
273
|
+
|
|
274
|
+
def __init__(self, text: str, style: str | None = None) -> None:
|
|
275
|
+
from rich.text import Text
|
|
276
|
+
|
|
277
|
+
self._text = Text(text, style=style or "")
|
|
278
|
+
|
|
279
|
+
def __rich_console__(self, console: Console, options):
|
|
280
|
+
yield from console.render(self._text, options)
|
|
281
|
+
|
|
282
|
+
def copy(self) -> TextRenderable:
|
|
283
|
+
return TextRenderable.__new__(TextRenderable)._copy_from(self)
|
|
284
|
+
|
|
285
|
+
def _copy_from(self, other: TextRenderable) -> TextRenderable:
|
|
286
|
+
self._text = other._text.copy()
|
|
287
|
+
return self
|
|
288
|
+
|
|
289
|
+
def __str__(self) -> str:
|
|
290
|
+
return str(self._text)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _shorten_path(path: str | None) -> str:
|
|
294
|
+
if path is None:
|
|
295
|
+
return ""
|
|
296
|
+
parts = path.split("/")
|
|
297
|
+
if len(parts) > 4:
|
|
298
|
+
return "/".join(parts[-3:])
|
|
299
|
+
return path
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _escape(text: str) -> str:
|
|
303
|
+
return text.replace("|", "\\|").replace("\n", " ")
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
__all__ = [
|
|
307
|
+
"print_review",
|
|
308
|
+
"render_review",
|
|
309
|
+
"review_to_json",
|
|
310
|
+
"review_to_markdown",
|
|
311
|
+
]
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Top-level repository review.
|
|
2
|
+
|
|
3
|
+
The :class:`RepositoryReview` is the entry point used by the CLI:
|
|
4
|
+
load a project, run every registered analyzer, and produce a list of
|
|
5
|
+
:class:`Finding` objects + a ranked :class:`Suggestion` list.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Iterable, Sequence
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
|
|
13
|
+
from forgecli.review.analyzer import AnalysisContext, Analyzer
|
|
14
|
+
from forgecli.review.analyzers.architecture import ArchitectureAnalyzer
|
|
15
|
+
from forgecli.review.analyzers.complexity import ComplexityAnalyzer
|
|
16
|
+
from forgecli.review.analyzers.dead_code import DeadCodeAnalyzer
|
|
17
|
+
from forgecli.review.analyzers.duplicates import DuplicatesAnalyzer
|
|
18
|
+
from forgecli.review.analyzers.performance import PerformanceAnalyzer
|
|
19
|
+
from forgecli.review.analyzers.security import SecurityAnalyzer
|
|
20
|
+
from forgecli.review.finding import Finding, Severity
|
|
21
|
+
from forgecli.review.suggestions import Suggestion, build_suggestions
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class RepositoryReview:
|
|
26
|
+
"""The full report produced for a single repository scan."""
|
|
27
|
+
|
|
28
|
+
root: str
|
|
29
|
+
findings: list[Finding] = field(default_factory=list)
|
|
30
|
+
suggestions: list[Suggestion] = field(default_factory=list)
|
|
31
|
+
analyzer_names: list[str] = field(default_factory=list)
|
|
32
|
+
stats: dict[str, int] = field(default_factory=dict)
|
|
33
|
+
|
|
34
|
+
def counts_by_severity(self) -> dict[Severity, int]:
|
|
35
|
+
out: dict[Severity, int] = {
|
|
36
|
+
Severity.INFO: 0,
|
|
37
|
+
Severity.LOW: 0,
|
|
38
|
+
Severity.MEDIUM: 0,
|
|
39
|
+
Severity.HIGH: 0,
|
|
40
|
+
Severity.CRITICAL: 0,
|
|
41
|
+
}
|
|
42
|
+
for finding in self.findings:
|
|
43
|
+
out[finding.severity] += 1
|
|
44
|
+
return out
|
|
45
|
+
|
|
46
|
+
def counts_by_category(self) -> dict[str, int]:
|
|
47
|
+
out: dict[str, int] = {}
|
|
48
|
+
for finding in self.findings:
|
|
49
|
+
out[finding.category] = out.get(finding.category, 0) + 1
|
|
50
|
+
return out
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def is_blocking(self) -> bool:
|
|
54
|
+
return any(finding.severity.is_blocking for finding in self.findings)
|
|
55
|
+
|
|
56
|
+
def to_dict(self) -> dict[str, object]:
|
|
57
|
+
return {
|
|
58
|
+
"root": self.root,
|
|
59
|
+
"stats": self.stats,
|
|
60
|
+
"analyzer_names": self.analyzer_names,
|
|
61
|
+
"counts_by_severity": {
|
|
62
|
+
severity.value: count
|
|
63
|
+
for severity, count in self.counts_by_severity().items()
|
|
64
|
+
},
|
|
65
|
+
"counts_by_category": self.counts_by_category(),
|
|
66
|
+
"findings": [finding.to_dict() for finding in self.findings],
|
|
67
|
+
"suggestions": [
|
|
68
|
+
{
|
|
69
|
+
"title": suggestion.title,
|
|
70
|
+
"severity": suggestion.severity.value,
|
|
71
|
+
"category": suggestion.category,
|
|
72
|
+
"count": suggestion.count,
|
|
73
|
+
"rationale": suggestion.rationale,
|
|
74
|
+
"findings": [finding.to_dict() for finding in suggestion.findings],
|
|
75
|
+
}
|
|
76
|
+
for suggestion in self.suggestions
|
|
77
|
+
],
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def default_analyzers() -> list[Analyzer]:
|
|
82
|
+
"""Return the default analyzer stack in stable order."""
|
|
83
|
+
return [
|
|
84
|
+
SecurityAnalyzer(),
|
|
85
|
+
PerformanceAnalyzer(),
|
|
86
|
+
ArchitectureAnalyzer(),
|
|
87
|
+
ComplexityAnalyzer(),
|
|
88
|
+
DeadCodeAnalyzer(),
|
|
89
|
+
DuplicatesAnalyzer(),
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def review_repository(
|
|
94
|
+
root,
|
|
95
|
+
*,
|
|
96
|
+
analyzers: Sequence[Analyzer] | None = None,
|
|
97
|
+
context: AnalysisContext | None = None,
|
|
98
|
+
) -> RepositoryReview:
|
|
99
|
+
"""Run all analyzers on ``root`` and return a :class:`RepositoryReview`."""
|
|
100
|
+
analyzers = list(analyzers) if analyzers is not None else default_analyzers()
|
|
101
|
+
if context is None:
|
|
102
|
+
context = AnalysisContext.load(root)
|
|
103
|
+
|
|
104
|
+
findings: list[Finding] = []
|
|
105
|
+
for analyzer in analyzers:
|
|
106
|
+
findings.extend(analyzer.run(context))
|
|
107
|
+
suggestions = build_suggestions(findings)
|
|
108
|
+
|
|
109
|
+
counts = {
|
|
110
|
+
"files": len(context.files),
|
|
111
|
+
"analyzers": len(analyzers),
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return RepositoryReview(
|
|
115
|
+
root=str(context.root),
|
|
116
|
+
findings=findings,
|
|
117
|
+
suggestions=suggestions,
|
|
118
|
+
analyzer_names=[analyzer.name for analyzer in analyzers],
|
|
119
|
+
stats=counts,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
__all__ = [
|
|
124
|
+
"RepositoryReview",
|
|
125
|
+
"default_analyzers",
|
|
126
|
+
"review_repository",
|
|
127
|
+
]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# Silence unused-import warnings for symbols only used in some branches.
|
|
131
|
+
_ = Iterable
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Suggestions engine.
|
|
2
|
+
|
|
3
|
+
Turns the raw :class:`Finding` list into a ranked list of
|
|
4
|
+
``Suggestion`` items. The engine doesn't *fix* code; it produces a
|
|
5
|
+
human-readable priority order and groups findings that share a
|
|
6
|
+
root cause.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections import defaultdict
|
|
12
|
+
from collections.abc import Iterable
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
|
|
15
|
+
from forgecli.review.finding import Finding, Severity
|
|
16
|
+
|
|
17
|
+
# A small map from rule_id -> friendly title for grouping.
|
|
18
|
+
_RULE_TITLES: dict[str, str] = {
|
|
19
|
+
"SEC001": "Hard-coded AWS access key",
|
|
20
|
+
"SEC002": "Hard-coded API key/secret/password",
|
|
21
|
+
"SEC003": "PEM private key in source",
|
|
22
|
+
"SEC004": "Hard-coded Slack/bot token",
|
|
23
|
+
"SEC010": "Avoid os.system",
|
|
24
|
+
"SEC011": "Avoid os.popen",
|
|
25
|
+
"SEC012": "Avoid subprocess.call (no shell=True)",
|
|
26
|
+
"SEC013": "Avoid subprocess.Popen(shell=True)",
|
|
27
|
+
"SEC014": "Avoid eval()",
|
|
28
|
+
"SEC015": "Avoid exec()",
|
|
29
|
+
"SEC016": "Avoid compile()",
|
|
30
|
+
"SEC017": "Avoid pickle.load()",
|
|
31
|
+
"SEC018": "Avoid pickle.loads()",
|
|
32
|
+
"SEC019": "Avoid marshal.load()",
|
|
33
|
+
"SEC020": "Avoid marshal.loads()",
|
|
34
|
+
"SEC021": "Use a strong hash",
|
|
35
|
+
"SEC022": "Don't use assert for runtime checks",
|
|
36
|
+
"PERF001": "Avoid blocking I/O in async code",
|
|
37
|
+
"PERF002": "Avoid sync sleep / HTTP in async code",
|
|
38
|
+
"PERF010": "Deeply nested loops",
|
|
39
|
+
"ARCH001": "Layer dependency direction",
|
|
40
|
+
"ARCH002": "Circular import between layers",
|
|
41
|
+
"ARCH003": "Forbidden import",
|
|
42
|
+
"CPLX001": "Function too long",
|
|
43
|
+
"CPLX002": "Function has too many parameters",
|
|
44
|
+
"CPLX003": "Function has high cyclomatic complexity",
|
|
45
|
+
"DEAD001": "Unused private symbol",
|
|
46
|
+
"DUP001": "Duplicate code block",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class Suggestion:
|
|
52
|
+
"""A grouped, prioritized action item for the user."""
|
|
53
|
+
|
|
54
|
+
title: str
|
|
55
|
+
severity: Severity
|
|
56
|
+
category: str
|
|
57
|
+
findings: list[Finding] = field(default_factory=list)
|
|
58
|
+
rationale: str = ""
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def count(self) -> int:
|
|
62
|
+
return len(self.findings)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def build_suggestions(findings: Iterable[Finding]) -> list[Suggestion]:
|
|
66
|
+
"""Group findings by rule and rank by severity + count."""
|
|
67
|
+
grouped: dict[str, list[Finding]] = defaultdict(list)
|
|
68
|
+
for finding in findings:
|
|
69
|
+
grouped[finding.rule_id].append(finding)
|
|
70
|
+
|
|
71
|
+
suggestions: list[Suggestion] = []
|
|
72
|
+
for rule_id, bucket in grouped.items():
|
|
73
|
+
worst = max(bucket, key=lambda f: f.severity.weight)
|
|
74
|
+
suggestions.append(
|
|
75
|
+
Suggestion(
|
|
76
|
+
title=_RULE_TITLES.get(rule_id, rule_id),
|
|
77
|
+
severity=worst.severity,
|
|
78
|
+
category=worst.category,
|
|
79
|
+
findings=bucket,
|
|
80
|
+
rationale=_rationale_for(rule_id, bucket),
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
suggestions.sort(
|
|
84
|
+
key=lambda s: (-s.severity.weight, -s.count, s.title),
|
|
85
|
+
)
|
|
86
|
+
return suggestions
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _rationale_for(rule_id: str, bucket: list[Finding]) -> str:
|
|
90
|
+
"""Return a short rationale for the suggestion."""
|
|
91
|
+
if len(bucket) == 1:
|
|
92
|
+
return f"1 occurrence of {rule_id}."
|
|
93
|
+
files = {finding.path for finding in bucket if finding.path}
|
|
94
|
+
file_count = len(files)
|
|
95
|
+
return f"{len(bucket)} occurrences across {file_count} file(s)."
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
__all__ = ["Suggestion", "build_suggestions"]
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Runtime preparation for AI wrapper commands."""
|
|
2
|
+
|
|
3
|
+
from forgecli.runtime.prepare import PreparedRuntime, prepare_runtime_sync
|
|
4
|
+
from forgecli.runtime.wrappers import WRAPPERS, launch_wrapper
|
|
5
|
+
|
|
6
|
+
__all__ = ["WRAPPERS", "PreparedRuntime", "launch_wrapper", "prepare_runtime_sync"]
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Disk-backed cache for prepared runtime context."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import asdict, dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from forgecli.utils.paths import ProjectPaths
|
|
12
|
+
|
|
13
|
+
_CACHE_TTL_SECONDS = 3600.0
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class CachedRuntime:
|
|
18
|
+
context_summary: str
|
|
19
|
+
context_file: str
|
|
20
|
+
node_count: int
|
|
21
|
+
edge_count: int
|
|
22
|
+
created_at: float
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _cache_dir() -> Path:
|
|
26
|
+
path = ProjectPaths.from_env().cache_dir / "runtime"
|
|
27
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
return path
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def repo_fingerprint(root: Path) -> str:
|
|
32
|
+
"""Fingerprint a repo for cache invalidation (git HEAD + shallow layout)."""
|
|
33
|
+
head = ""
|
|
34
|
+
git_head = root / ".git" / "HEAD"
|
|
35
|
+
if git_head.is_file():
|
|
36
|
+
head = git_head.read_text(encoding="utf-8", errors="replace").strip()
|
|
37
|
+
|
|
38
|
+
layout_sig = "0"
|
|
39
|
+
try:
|
|
40
|
+
names = sorted(
|
|
41
|
+
p.name for p in root.iterdir() if not p.name.startswith(".")
|
|
42
|
+
)[:48]
|
|
43
|
+
layout_sig = ",".join(names)
|
|
44
|
+
except OSError:
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
payload = f"{root.resolve()}|{head}|{layout_sig}"
|
|
48
|
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:24]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def load_runtime_cache(fingerprint: str) -> CachedRuntime | None:
|
|
52
|
+
path = _cache_dir() / f"{fingerprint}.json"
|
|
53
|
+
if not path.is_file():
|
|
54
|
+
return None
|
|
55
|
+
try:
|
|
56
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
57
|
+
created_at = float(payload.get("created_at", 0))
|
|
58
|
+
if time.time() - created_at > _CACHE_TTL_SECONDS:
|
|
59
|
+
path.unlink(missing_ok=True)
|
|
60
|
+
return None
|
|
61
|
+
context_file = Path(str(payload["context_file"]))
|
|
62
|
+
if not context_file.is_file():
|
|
63
|
+
return None
|
|
64
|
+
return CachedRuntime(
|
|
65
|
+
context_summary=str(payload["context_summary"]),
|
|
66
|
+
context_file=str(context_file),
|
|
67
|
+
node_count=int(payload.get("node_count", 0)),
|
|
68
|
+
edge_count=int(payload.get("edge_count", 0)),
|
|
69
|
+
created_at=created_at,
|
|
70
|
+
)
|
|
71
|
+
except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError):
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def save_runtime_cache(fingerprint: str, runtime: CachedRuntime) -> None:
|
|
76
|
+
path = _cache_dir() / f"{fingerprint}.json"
|
|
77
|
+
path.write_text(json.dumps(asdict(runtime), indent=2), encoding="utf-8")
|