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.
Files changed (156) hide show
  1. forgecli/__init__.py +4 -0
  2. forgecli/build/__init__.py +135 -0
  3. forgecli/build/apply.py +218 -0
  4. forgecli/build/caveman_optimize.py +37 -0
  5. forgecli/build/diff_extract.py +218 -0
  6. forgecli/build/llm.py +167 -0
  7. forgecli/build/optimize.py +37 -0
  8. forgecli/build/pipeline.py +76 -0
  9. forgecli/build/retrieval.py +157 -0
  10. forgecli/build/summarize.py +76 -0
  11. forgecli/build/test_run.py +75 -0
  12. forgecli/builder/__init__.py +11 -0
  13. forgecli/builder/builder.py +53 -0
  14. forgecli/builder/editor.py +36 -0
  15. forgecli/builder/formatter.py +31 -0
  16. forgecli/cli/__init__.py +5 -0
  17. forgecli/cli/bootstrap.py +281 -0
  18. forgecli/cli/commands_graph.py +137 -0
  19. forgecli/cli/commands_wrappers.py +63 -0
  20. forgecli/cli/main.py +122 -0
  21. forgecli/cli/ui.py +56 -0
  22. forgecli/config/__init__.py +28 -0
  23. forgecli/config/loader.py +85 -0
  24. forgecli/config/settings.py +204 -0
  25. forgecli/config/writer.py +90 -0
  26. forgecli/core/__init__.py +35 -0
  27. forgecli/core/container.py +68 -0
  28. forgecli/core/context.py +54 -0
  29. forgecli/core/credentials.py +114 -0
  30. forgecli/core/errors.py +27 -0
  31. forgecli/core/events.py +67 -0
  32. forgecli/core/logging.py +47 -0
  33. forgecli/core/models.py +159 -0
  34. forgecli/core/plugins.py +56 -0
  35. forgecli/core/service.py +22 -0
  36. forgecli/docs/__init__.py +5 -0
  37. forgecli/docs/generator.py +119 -0
  38. forgecli/engine/__init__.py +105 -0
  39. forgecli/engine/context.py +171 -0
  40. forgecli/engine/defaults.py +89 -0
  41. forgecli/engine/events.py +185 -0
  42. forgecli/engine/execution.py +526 -0
  43. forgecli/engine/plugins.py +146 -0
  44. forgecli/engine/runner.py +155 -0
  45. forgecli/engine/stages/__init__.py +33 -0
  46. forgecli/engine/stages/caveman_optimizer.py +47 -0
  47. forgecli/engine/stages/context_optimizer.py +44 -0
  48. forgecli/engine/stages/execution_engine_stage.py +108 -0
  49. forgecli/engine/stages/git_engine.py +30 -0
  50. forgecli/engine/stages/intent_analyzer.py +38 -0
  51. forgecli/engine/stages/model_router.py +66 -0
  52. forgecli/engine/stages/planning_engine.py +45 -0
  53. forgecli/engine/stages/repository_analyzer.py +54 -0
  54. forgecli/engine/stages/validation_engine.py +89 -0
  55. forgecli/git/__init__.py +9 -0
  56. forgecli/git/repo.py +55 -0
  57. forgecli/git/service.py +45 -0
  58. forgecli/graph/__init__.py +56 -0
  59. forgecli/graph/backend_graphify.py +453 -0
  60. forgecli/graph/edge.py +19 -0
  61. forgecli/graph/graph.py +85 -0
  62. forgecli/graph/graphify.py +412 -0
  63. forgecli/graph/indexer.py +82 -0
  64. forgecli/graph/node.py +47 -0
  65. forgecli/graph/repository.py +164 -0
  66. forgecli/memory/__init__.py +12 -0
  67. forgecli/memory/cache.py +61 -0
  68. forgecli/memory/history.py +138 -0
  69. forgecli/memory/store.py +87 -0
  70. forgecli/optimizer/__init__.py +14 -0
  71. forgecli/optimizer/caveman/__init__.py +173 -0
  72. forgecli/optimizer/caveman/cli.py +64 -0
  73. forgecli/optimizer/caveman/decorator.py +67 -0
  74. forgecli/optimizer/caveman/factory.py +51 -0
  75. forgecli/optimizer/caveman/ruleset.py +156 -0
  76. forgecli/optimizer/caveman/state.py +52 -0
  77. forgecli/optimizer/chunker.py +85 -0
  78. forgecli/optimizer/optimizer.py +81 -0
  79. forgecli/optimizer/ponytail/__init__.py +183 -0
  80. forgecli/optimizer/ponytail/cli.py +168 -0
  81. forgecli/optimizer/ponytail/decorator.py +70 -0
  82. forgecli/optimizer/ponytail/factory.py +51 -0
  83. forgecli/optimizer/ponytail/ruleset.py +168 -0
  84. forgecli/optimizer/ponytail/state.py +53 -0
  85. forgecli/optimizer/ranker.py +37 -0
  86. forgecli/optimizer/summarizer.py +44 -0
  87. forgecli/orchestrator/__init__.py +707 -0
  88. forgecli/planner/__init__.py +56 -0
  89. forgecli/planner/agent.py +61 -0
  90. forgecli/planner/plan.py +65 -0
  91. forgecli/planner/planner.py +17 -0
  92. forgecli/planner/render.py +271 -0
  93. forgecli/planner/serialize.py +106 -0
  94. forgecli/planner/software.py +834 -0
  95. forgecli/platform/__init__.py +91 -0
  96. forgecli/platform/core.py +201 -0
  97. forgecli/platform/deps.py +354 -0
  98. forgecli/platform/paths.py +236 -0
  99. forgecli/platform/shell.py +176 -0
  100. forgecli/platform/update.py +252 -0
  101. forgecli/plugins/__init__.py +251 -0
  102. forgecli/prompts/__init__.py +11 -0
  103. forgecli/prompts/loader.py +28 -0
  104. forgecli/prompts/registry.py +32 -0
  105. forgecli/prompts/renderer.py +23 -0
  106. forgecli/providers/__init__.py +39 -0
  107. forgecli/providers/anthropic.py +206 -0
  108. forgecli/providers/base.py +206 -0
  109. forgecli/providers/builtin.py +26 -0
  110. forgecli/providers/conversation.py +78 -0
  111. forgecli/providers/google.py +295 -0
  112. forgecli/providers/http_base.py +211 -0
  113. forgecli/providers/mock.py +113 -0
  114. forgecli/providers/openai.py +202 -0
  115. forgecli/providers/openai_compatible.py +728 -0
  116. forgecli/providers/router.py +345 -0
  117. forgecli/providers/router_state.py +89 -0
  118. forgecli/review/__init__.py +45 -0
  119. forgecli/review/analyzer.py +124 -0
  120. forgecli/review/analyzers/__init__.py +1 -0
  121. forgecli/review/analyzers/architecture.py +258 -0
  122. forgecli/review/analyzers/complexity.py +167 -0
  123. forgecli/review/analyzers/dead_code.py +262 -0
  124. forgecli/review/analyzers/duplicates.py +163 -0
  125. forgecli/review/analyzers/performance.py +165 -0
  126. forgecli/review/analyzers/security.py +251 -0
  127. forgecli/review/finding.py +68 -0
  128. forgecli/review/report.py +311 -0
  129. forgecli/review/repository.py +131 -0
  130. forgecli/review/suggestions.py +98 -0
  131. forgecli/runtime/__init__.py +6 -0
  132. forgecli/runtime/cache_store.py +77 -0
  133. forgecli/runtime/prepare.py +199 -0
  134. forgecli/runtime/wrappers.py +152 -0
  135. forgecli/sdk/__init__.py +132 -0
  136. forgecli/sdk/events.py +206 -0
  137. forgecli/sdk/interfaces.py +282 -0
  138. forgecli/sdk/loader.py +243 -0
  139. forgecli/sdk/manager.py +693 -0
  140. forgecli/sdk/manifest.py +397 -0
  141. forgecli/sdk/sandbox.py +197 -0
  142. forgecli/sdk/version.py +316 -0
  143. forgecli/templates/__init__.py +9 -0
  144. forgecli/templates/engine.py +37 -0
  145. forgecli/templates/registry.py +32 -0
  146. forgecli/utils/__init__.py +19 -0
  147. forgecli/utils/fs.py +91 -0
  148. forgecli/utils/ids.py +11 -0
  149. forgecli/utils/io.py +27 -0
  150. forgecli/utils/paths.py +70 -0
  151. forgecli/utils/timing.py +25 -0
  152. forgeoptimizer-1.0.0.dist-info/METADATA +169 -0
  153. forgeoptimizer-1.0.0.dist-info/RECORD +156 -0
  154. forgeoptimizer-1.0.0.dist-info/WHEEL +4 -0
  155. forgeoptimizer-1.0.0.dist-info/entry_points.txt +2 -0
  156. 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")