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,345 @@
1
+ """Model routing and selection.
2
+
3
+ A :class:`ModelRouter` resolves a high-level request (provider name,
4
+ model alias, or ``"auto"``) into a concrete :class:`Provider` and
5
+ ``model`` string. The router owns:
6
+
7
+ * the **registry** of installed providers (OpenAI, Anthropic, Google, mock, …);
8
+ * a static **cost model** keyed by ``(provider, model)``;
9
+ * a **selector** that, given a request's required capabilities, picks
10
+ the cheapest compatible option.
11
+
12
+ The router is decoupled from concrete providers; new providers can be
13
+ registered without touching the router itself.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ from dataclasses import dataclass, field
20
+ from enum import Enum
21
+
22
+ from forgecli.providers.base import ProviderRegistry, default_registry
23
+
24
+ # A small, well-known per-1k-token price table (USD, in/out).
25
+ # Values are static defaults; users may override per-deployment. Source:
26
+ # public pricing pages as of 2024-2025. These are advisory — the router
27
+ # uses them only to break ties between equally-capable models.
28
+ DEFAULT_PRICING: dict[tuple[str, str], tuple[float, float]] = {
29
+ # OpenAI
30
+ ("openai", "gpt-5.5"): (0.02, 0.10),
31
+ ("openai", "gpt-5"): (0.015, 0.075),
32
+ ("openai", "gpt-5-mini"): (0.00015, 0.0006),
33
+ ("openai", "gpt-4.1"): (0.005, 0.015),
34
+ ("openai", "gpt-4.1-mini"): (0.00015, 0.0006),
35
+ ("openai", "gpt-4o"): (0.005, 0.015),
36
+ ("openai", "gpt-4o-mini"): (0.00015, 0.0006),
37
+ ("openai", "gpt-4-turbo"): (0.01, 0.03),
38
+ ("openai", "o1"): (0.015, 0.06),
39
+ ("openai", "o1-preview"): (0.015, 0.06),
40
+ ("openai", "o1-mini"): (0.003, 0.012),
41
+ # Anthropic
42
+ ("anthropic", "claude-opus-4.8"): (0.015, 0.075),
43
+ ("anthropic", "claude-opus-4.6"): (0.015, 0.075),
44
+ ("anthropic", "claude-sonnet-4.6"): (0.003, 0.015),
45
+ ("anthropic", "claude-sonnet-4.5"): (0.003, 0.015),
46
+ ("anthropic", "claude-haiku-4.5"): (0.0008, 0.004),
47
+ ("anthropic", "claude-3-5-sonnet-latest"): (0.003, 0.015),
48
+ ("anthropic", "claude-3-5-haiku-latest"): (0.0008, 0.004),
49
+ ("anthropic", "claude-3-opus-latest"): (0.015, 0.075),
50
+ # Google
51
+ ("google", "gemini-2.5-pro"): (0.00125, 0.005),
52
+ ("google", "gemini-2.5-flash"): (0.000075, 0.0003),
53
+ ("google", "gemini-2.5-flash-lite"): (0.00003, 0.0001),
54
+ ("google", "gemini-2.0-flash"): (0.000075, 0.0003),
55
+ ("google", "gemini-1.5-pro"): (0.00125, 0.005),
56
+ ("google", "gemini-1.5-flash"): (0.000075, 0.0003),
57
+ ("google", "gemini-2.0-flash-exp"): (0.0, 0.0),
58
+ # Mock
59
+ ("mock", "mock-model"): (0.0, 0.0),
60
+ }
61
+
62
+
63
+ class SelectionMode(str, Enum):
64
+ """How the router picks a model."""
65
+
66
+ EXPLICIT = "explicit" # caller named provider + model
67
+ ALIAS = "alias" # caller named a provider alias
68
+ CHEAPEST = "cheapest" # "auto" — pick the cheapest compatible model
69
+ FALLBACK = "fallback" # an explicit selection failed; another matched
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class ModelCapabilities:
74
+ """The capability hints a request expresses."""
75
+
76
+ max_tokens: int | None = None
77
+ needs_tools: bool = False
78
+ needs_vision: bool = False
79
+
80
+
81
+ @dataclass(frozen=True)
82
+ class RouteDecision:
83
+ """The router's resolution of a request."""
84
+
85
+ provider_name: str
86
+ model: str
87
+ mode: SelectionMode
88
+ cost_in: float = 0.0
89
+ cost_out: float = 0.0
90
+ candidates: tuple[tuple[str, str], ...] = ()
91
+
92
+
93
+ @dataclass
94
+ class ModelRouter:
95
+ """Resolve high-level routing requests to provider + model."""
96
+
97
+ registry: ProviderRegistry = field(default_factory=lambda: default_registry)
98
+ pricing: dict[tuple[str, str], tuple[float, float]] = field(
99
+ default_factory=lambda: dict(DEFAULT_PRICING)
100
+ )
101
+ default_models: dict[str, str] = field(
102
+ default_factory=lambda: {
103
+ "openai": "gpt-5",
104
+ "anthropic": "claude-sonnet-4.6",
105
+ "google": "gemini-2.5-flash",
106
+ "openrouter": "glm-5.2",
107
+ "groq": "llama-4-scout",
108
+ "mistral": "mistral-large",
109
+ "ollama": "llama3",
110
+ "lmstudio": "local-model",
111
+ "vllm": "local-model",
112
+ "mock": "mock-model",
113
+ }
114
+ )
115
+ aliases: dict[str, str] = field(
116
+ default_factory=lambda: {
117
+ "claude": "anthropic",
118
+ "openai": "openai",
119
+ "gpt": "openai",
120
+ "gemini": "google",
121
+ "google": "google",
122
+ }
123
+ )
124
+
125
+ # ------------------------------------------------------------------
126
+ # Discovery
127
+ # ------------------------------------------------------------------
128
+
129
+ def available_providers(self) -> list[str]:
130
+ return self.registry.names()
131
+
132
+ def has_provider(self, name: str) -> bool:
133
+ return self.registry.has(name)
134
+
135
+ def resolve_alias(self, name: str) -> str:
136
+ return self.aliases.get(name.lower(), name.lower())
137
+
138
+ def default_model_for(self, provider_name: str) -> str:
139
+ return self.default_models.get(
140
+ provider_name.lower(),
141
+ _DEFAULT_MODEL_BY_PROVIDER.get(provider_name.lower(), "auto"),
142
+ )
143
+
144
+ # ------------------------------------------------------------------
145
+ # Selection
146
+ # ------------------------------------------------------------------
147
+
148
+ def select(
149
+ self,
150
+ choice: str | None,
151
+ *,
152
+ capabilities: ModelCapabilities | None = None,
153
+ ) -> RouteDecision:
154
+ """Resolve a CLI choice (claude / openai / gemini / auto / …)."""
155
+ choice = (choice or "auto").strip()
156
+ caps = capabilities or ModelCapabilities()
157
+
158
+ if choice.lower() == "auto":
159
+ return self._select_cheapest(caps)
160
+ return self._select_explicit(choice, caps)
161
+
162
+ def cheapest(
163
+ self,
164
+ capabilities: ModelCapabilities | None = None,
165
+ *,
166
+ provider: str | None = None,
167
+ ) -> RouteDecision:
168
+ """Pick the cheapest compatible (provider, model) pair.
169
+
170
+ When ``provider`` is set, the algorithm restricts the search to
171
+ that single provider. If that provider has no credentials, the
172
+ router falls back to the mock with :data:`SelectionMode.FALLBACK`
173
+ so the CLI never hard-errors.
174
+ """
175
+ if provider is not None:
176
+ return self._select_cheapest(
177
+ capabilities or ModelCapabilities(), provider=provider
178
+ )
179
+ return self._select_cheapest(capabilities or ModelCapabilities())
180
+
181
+ # ------------------------------------------------------------------
182
+ # Internal selectors
183
+ # ------------------------------------------------------------------
184
+
185
+ def _select_explicit(
186
+ self, choice: str, caps: ModelCapabilities
187
+ ) -> RouteDecision:
188
+ provider_name = self.resolve_alias(choice)
189
+ if not self.registry.has(provider_name):
190
+ return RouteDecision(
191
+ provider_name=provider_name,
192
+ model=self.default_model_for(provider_name),
193
+ mode=SelectionMode.ALIAS,
194
+ )
195
+ model = self.default_model_for(provider_name)
196
+ cost_in, cost_out = self.pricing.get(
197
+ (provider_name, model), (0.0, 0.0)
198
+ )
199
+ return RouteDecision(
200
+ provider_name=provider_name,
201
+ model=model,
202
+ mode=SelectionMode.ALIAS,
203
+ cost_in=cost_in,
204
+ cost_out=cost_out,
205
+ )
206
+
207
+ def _select_cheapest(
208
+ self,
209
+ caps: ModelCapabilities,
210
+ *,
211
+ provider: str | None = None,
212
+ ) -> RouteDecision:
213
+ candidates = self._list_real_candidates(caps, provider=provider)
214
+ if candidates:
215
+ # Sort by (in_price, out_price, provider_name) for determinism.
216
+ candidates.sort(
217
+ key=lambda c: (
218
+ self.pricing.get(c, (0.0, 0.0))[0],
219
+ self.pricing.get(c, (0.0, 0.0))[1],
220
+ c[0],
221
+ )
222
+ )
223
+ chosen = candidates[0]
224
+ cost_in, cost_out = self.pricing.get(chosen, (0.0, 0.0))
225
+ return RouteDecision(
226
+ provider_name=chosen[0],
227
+ model=chosen[1],
228
+ mode=SelectionMode.CHEAPEST,
229
+ cost_in=cost_in,
230
+ cost_out=cost_out,
231
+ candidates=tuple(candidates),
232
+ )
233
+
234
+ # No real provider had credentials; fall back to the mock.
235
+ return RouteDecision(
236
+ provider_name="mock",
237
+ model=self.default_model_for("mock"),
238
+ mode=SelectionMode.FALLBACK,
239
+ )
240
+
241
+ def _list_real_candidates(
242
+ self,
243
+ caps: ModelCapabilities,
244
+ *,
245
+ provider: str | None = None,
246
+ ) -> list[tuple[str, str]]:
247
+ """Return the (provider, model) pairs that satisfy ``caps`` AND
248
+ have credentials available. Excludes the ``mock`` provider,
249
+ which is reserved as a fallback.
250
+ """
251
+ out: list[tuple[str, str]] = []
252
+ for name in self.registry.names():
253
+ if name == "mock":
254
+ continue
255
+ if provider is not None and name != provider:
256
+ continue
257
+ if not _provider_has_credentials(name):
258
+ continue
259
+ model = self.default_model_for(name)
260
+ out.append((name, model))
261
+ return out
262
+
263
+ def _list_candidates(
264
+ self,
265
+ caps: ModelCapabilities,
266
+ *,
267
+ provider: str | None = None,
268
+ ) -> list[tuple[str, str]]:
269
+ """Public list of real candidates; for tests and CLI display."""
270
+ return self._list_real_candidates(caps, provider=provider)
271
+
272
+
273
+ # ---------------------------------------------------------------------------
274
+ # Helpers
275
+ # ---------------------------------------------------------------------------
276
+
277
+
278
+ _DEFAULT_MODEL_BY_PROVIDER: dict[str, str] = {
279
+ "openai": "gpt-5",
280
+ "anthropic": "claude-sonnet-4.6",
281
+ "google": "gemini-2.5-flash",
282
+ "openrouter": "glm-5.2",
283
+ "groq": "llama-4-scout",
284
+ "mistral": "mistral-large",
285
+ "minimax": "abab6.5g-chat",
286
+ "xai": "grok-2",
287
+ "together": "llama-3.1-70b",
288
+ "fireworks": "llama-3.1-70b",
289
+ "cohere": "command-r-plus",
290
+ "nvidia": "llama-3.1-70b",
291
+ "ollama": "llama3",
292
+ "lmstudio": "local-model",
293
+ "vllm": "local-model",
294
+ "mock": "mock-model",
295
+ }
296
+
297
+
298
+ _PROVIDER_ENV_VARS: dict[str, tuple[str, ...]] = {
299
+ "openai": ("OPENAI_API_KEY",),
300
+ "anthropic": ("ANTHROPIC_API_KEY",),
301
+ "google": ("GOOGLE_API_KEY", "GEMINI_API_KEY"),
302
+ "azure": ("AZURE_OPENAI_API_KEY",),
303
+ "mistral": ("MISTRAL_API_KEY",),
304
+ "groq": ("GROQ_API_KEY",),
305
+ "openrouter": ("OPENROUTER_API_KEY",),
306
+ "minimax": ("MINIMAX_API_KEY",),
307
+ "xai": ("XAI_API_KEY",),
308
+ "together": ("TOGETHER_API_KEY",),
309
+ "fireworks": ("FIREWORKS_API_KEY",),
310
+ "cohere": ("COHERE_API_KEY",),
311
+ "nvidia": ("NVIDIA_API_KEY",),
312
+ "ollama": ("OLLAMA_API_KEY",),
313
+ "lmstudio": ("LMSTUDIO_API_KEY",),
314
+ "vllm": ("VLLM_API_KEY",),
315
+ }
316
+
317
+
318
+ def _provider_has_credentials(name: str) -> bool:
319
+ """Return True if any known env var or secure storage key for ``name`` is non-empty."""
320
+ if name == "mock":
321
+ return True
322
+ if any(os.environ.get(env_var) for env_var in _PROVIDER_ENV_VARS.get(name, ())):
323
+ return True
324
+ from forgecli.core.credentials import get_api_key
325
+ return bool(get_api_key(name))
326
+
327
+
328
+ def estimate_cost(
329
+ decision: RouteDecision,
330
+ *,
331
+ prompt_tokens: int,
332
+ completion_tokens: int,
333
+ ) -> float:
334
+ """Estimate USD cost for a finished call."""
335
+ return (decision.cost_in * prompt_tokens + decision.cost_out * completion_tokens) / 1000.0
336
+
337
+
338
+ __all__ = [
339
+ "DEFAULT_PRICING",
340
+ "ModelCapabilities",
341
+ "ModelRouter",
342
+ "RouteDecision",
343
+ "SelectionMode",
344
+ "estimate_cost",
345
+ ]
@@ -0,0 +1,89 @@
1
+ """Live runtime state for the model router.
2
+
3
+ The active provider selection (claude / openai / gemini / auto) is
4
+ persisted to ``data_dir/router.json`` and surfaced on every
5
+ :class:`AppContext` via ``extras``. A selection of ``"auto"`` triggers
6
+ the cheapest-compatible algorithm on every chat call.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+
15
+ from forgecli.providers.router import ModelCapabilities, RouteDecision, SelectionMode
16
+
17
+
18
+ @dataclass
19
+ class RouterState:
20
+ """The user-chosen provider/model selection."""
21
+
22
+ choice: str | None = None
23
+ model: str | None = None
24
+ provider: str | None = None
25
+
26
+ @classmethod
27
+ def from_extras(cls, extras: dict[str, object]) -> RouterState:
28
+ state = cls()
29
+ choice = extras.get("router.choice") or extras.get("router_choice")
30
+ if isinstance(choice, str):
31
+ state.choice = choice
32
+ model = extras.get("router.model") or extras.get("router_model")
33
+ if isinstance(model, str):
34
+ state.model = model
35
+ provider = extras.get("router.provider") or extras.get("router_provider")
36
+ if isinstance(provider, str):
37
+ state.provider = provider
38
+ return state
39
+
40
+ def to_extras(self) -> dict[str, str]:
41
+ out: dict[str, str] = {}
42
+ if self.choice:
43
+ out["router.choice"] = self.choice
44
+ if self.model:
45
+ out["router.model"] = self.model
46
+ if self.provider:
47
+ out["router.provider"] = self.provider
48
+ return out
49
+
50
+
51
+ def load_state(path: Path) -> RouterState:
52
+ if not path.exists():
53
+ return RouterState()
54
+ try:
55
+ payload = json.loads(path.read_text(encoding="utf-8"))
56
+ except (OSError, json.JSONDecodeError):
57
+ return RouterState()
58
+ state = RouterState()
59
+ choice = payload.get("choice")
60
+ if isinstance(choice, str):
61
+ state.choice = choice
62
+ model = payload.get("model")
63
+ if isinstance(model, str):
64
+ state.model = model
65
+ provider = payload.get("provider")
66
+ if isinstance(provider, str):
67
+ state.provider = provider
68
+ return state
69
+
70
+
71
+ def save_state(path: Path, state: RouterState) -> None:
72
+ path.parent.mkdir(parents=True, exist_ok=True)
73
+ path.write_text(
74
+ json.dumps(
75
+ {"choice": state.choice, "model": state.model, "provider": state.provider},
76
+ indent=2,
77
+ ),
78
+ encoding="utf-8",
79
+ )
80
+
81
+
82
+ __all__ = [
83
+ "ModelCapabilities",
84
+ "RouteDecision",
85
+ "RouterState",
86
+ "SelectionMode",
87
+ "load_state",
88
+ "save_state",
89
+ ]
@@ -0,0 +1,45 @@
1
+ """Code review and quality analysis."""
2
+
3
+ from forgecli.review.analyzer import AnalysisContext, Analyzer, SourceFile
4
+ from forgecli.review.analyzers.architecture import ArchitectureAnalyzer
5
+ from forgecli.review.analyzers.complexity import ComplexityAnalyzer
6
+ from forgecli.review.analyzers.dead_code import DeadCodeAnalyzer
7
+ from forgecli.review.analyzers.duplicates import DuplicatesAnalyzer
8
+ from forgecli.review.analyzers.performance import PerformanceAnalyzer
9
+ from forgecli.review.analyzers.security import SecurityAnalyzer
10
+ from forgecli.review.finding import Finding, Severity
11
+ from forgecli.review.report import (
12
+ print_review,
13
+ render_review,
14
+ review_to_json,
15
+ review_to_markdown,
16
+ )
17
+ from forgecli.review.repository import (
18
+ RepositoryReview,
19
+ default_analyzers,
20
+ review_repository,
21
+ )
22
+ from forgecli.review.suggestions import Suggestion, build_suggestions
23
+
24
+ __all__ = [
25
+ "AnalysisContext",
26
+ "Analyzer",
27
+ "ArchitectureAnalyzer",
28
+ "ComplexityAnalyzer",
29
+ "DeadCodeAnalyzer",
30
+ "DuplicatesAnalyzer",
31
+ "Finding",
32
+ "PerformanceAnalyzer",
33
+ "RepositoryReview",
34
+ "SecurityAnalyzer",
35
+ "Severity",
36
+ "SourceFile",
37
+ "Suggestion",
38
+ "build_suggestions",
39
+ "default_analyzers",
40
+ "print_review",
41
+ "render_review",
42
+ "review_repository",
43
+ "review_to_json",
44
+ "review_to_markdown",
45
+ ]
@@ -0,0 +1,124 @@
1
+ """Common types for the review analyzer layer.
2
+
3
+ Every concrete analyzer subclasses :class:`Analyzer` and implements
4
+ :meth:`run`. The framework hands each analyzer a shared
5
+ :class:`AnalysisContext` (a pre-loaded view of the project) and the
6
+ analyzer returns a list of :class:`Finding` objects.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from abc import ABC, abstractmethod
12
+ from collections.abc import Iterable
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+ from typing import ClassVar
16
+
17
+ from forgecli.review.finding import Finding
18
+
19
+ # Directories we never want to recurse into.
20
+ _DEFAULT_IGNORES: tuple[str, ...] = (
21
+ ".git",
22
+ ".venv",
23
+ "venv",
24
+ "env",
25
+ "__pycache__",
26
+ ".pytest_cache",
27
+ ".mypy_cache",
28
+ ".ruff_cache",
29
+ "build",
30
+ "dist",
31
+ ".eggs",
32
+ "node_modules",
33
+ "target",
34
+ ".idea",
35
+ ".vscode",
36
+ "tests",
37
+ )
38
+
39
+
40
+ # File extensions we analyze.
41
+ _DEFAULT_EXTENSIONS: tuple[str, ...] = (
42
+ ".py",
43
+ ".pyi",
44
+ )
45
+
46
+
47
+ @dataclass
48
+ class SourceFile:
49
+ """One source file under review."""
50
+
51
+ path: Path
52
+ text: str
53
+ lines: tuple[str, ...]
54
+
55
+ @classmethod
56
+ def load(cls, path: Path) -> SourceFile:
57
+ try:
58
+ text = path.read_text(encoding="utf-8", errors="replace")
59
+ except OSError:
60
+ text = ""
61
+ return cls(path=path, text=text, lines=tuple(text.splitlines()))
62
+
63
+ def line_at(self, line_number: int) -> str:
64
+ if 1 <= line_number <= len(self.lines):
65
+ return self.lines[line_number - 1]
66
+ return ""
67
+
68
+
69
+ @dataclass
70
+ class AnalysisContext:
71
+ """The shared state passed to every analyzer."""
72
+
73
+ root: Path
74
+ files: list[SourceFile] = field(default_factory=list)
75
+ extras: dict[str, object] = field(default_factory=dict)
76
+
77
+ @classmethod
78
+ def load(
79
+ cls,
80
+ root: Path,
81
+ *,
82
+ extensions: Iterable[str] = _DEFAULT_EXTENSIONS,
83
+ ignore: Iterable[str] = _DEFAULT_IGNORES,
84
+ ) -> AnalysisContext:
85
+ """Walk ``root`` and load every matching file."""
86
+ root = Path(root).resolve()
87
+ ignore_set = set(ignore)
88
+ files: list[SourceFile] = []
89
+ if root.is_file():
90
+ files.append(SourceFile.load(root))
91
+ return cls(root=root, files=files)
92
+ for path in sorted(root.rglob("*")):
93
+ if not path.is_file():
94
+ continue
95
+ if path.suffix.lower() not in extensions:
96
+ continue
97
+ if any(part in ignore_set for part in path.parts):
98
+ continue
99
+ files.append(SourceFile.load(path))
100
+ return cls(root=root, files=files)
101
+
102
+ def get(self, path: Path) -> SourceFile | None:
103
+ for file in self.files:
104
+ if file.path == path:
105
+ return file
106
+ return None
107
+
108
+
109
+ class Analyzer(ABC):
110
+ """Base class for review analyzers."""
111
+
112
+ name: ClassVar[str] = "abstract"
113
+ category: ClassVar[str] = "abstract"
114
+
115
+ @abstractmethod
116
+ def run(self, context: AnalysisContext) -> list[Finding]:
117
+ """Return the findings produced by this analyzer."""
118
+
119
+
120
+ __all__ = [
121
+ "AnalysisContext",
122
+ "Analyzer",
123
+ "SourceFile",
124
+ ]
@@ -0,0 +1 @@
1
+ """Concrete analyzers shipped with ForgeCLI."""