empathy-framework 5.1.1__py3-none-any.whl → 5.2.1__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.
- {empathy_framework-5.1.1.dist-info → empathy_framework-5.2.1.dist-info}/METADATA +52 -3
- {empathy_framework-5.1.1.dist-info → empathy_framework-5.2.1.dist-info}/RECORD +69 -28
- empathy_os/cli_router.py +9 -0
- empathy_os/core_modules/__init__.py +15 -0
- empathy_os/mcp/__init__.py +10 -0
- empathy_os/mcp/server.py +506 -0
- empathy_os/memory/control_panel.py +1 -131
- empathy_os/memory/control_panel_support.py +145 -0
- empathy_os/memory/encryption.py +159 -0
- empathy_os/memory/long_term.py +41 -626
- empathy_os/memory/long_term_types.py +99 -0
- empathy_os/memory/mixins/__init__.py +25 -0
- empathy_os/memory/mixins/backend_init_mixin.py +244 -0
- empathy_os/memory/mixins/capabilities_mixin.py +199 -0
- empathy_os/memory/mixins/handoff_mixin.py +208 -0
- empathy_os/memory/mixins/lifecycle_mixin.py +49 -0
- empathy_os/memory/mixins/long_term_mixin.py +352 -0
- empathy_os/memory/mixins/promotion_mixin.py +109 -0
- empathy_os/memory/mixins/short_term_mixin.py +182 -0
- empathy_os/memory/short_term.py +7 -0
- empathy_os/memory/simple_storage.py +302 -0
- empathy_os/memory/storage_backend.py +167 -0
- empathy_os/memory/unified.py +21 -1120
- empathy_os/meta_workflows/cli_commands/__init__.py +56 -0
- empathy_os/meta_workflows/cli_commands/agent_commands.py +321 -0
- empathy_os/meta_workflows/cli_commands/analytics_commands.py +442 -0
- empathy_os/meta_workflows/cli_commands/config_commands.py +232 -0
- empathy_os/meta_workflows/cli_commands/memory_commands.py +182 -0
- empathy_os/meta_workflows/cli_commands/template_commands.py +354 -0
- empathy_os/meta_workflows/cli_commands/workflow_commands.py +382 -0
- empathy_os/meta_workflows/cli_meta_workflows.py +52 -1802
- empathy_os/models/telemetry/__init__.py +71 -0
- empathy_os/models/telemetry/analytics.py +594 -0
- empathy_os/models/telemetry/backend.py +196 -0
- empathy_os/models/telemetry/data_models.py +431 -0
- empathy_os/models/telemetry/storage.py +489 -0
- empathy_os/orchestration/__init__.py +35 -0
- empathy_os/orchestration/execution_strategies.py +481 -0
- empathy_os/orchestration/meta_orchestrator.py +488 -1
- empathy_os/routing/workflow_registry.py +36 -0
- empathy_os/telemetry/cli.py +19 -724
- empathy_os/telemetry/commands/__init__.py +14 -0
- empathy_os/telemetry/commands/dashboard_commands.py +696 -0
- empathy_os/tools.py +183 -0
- empathy_os/workflows/__init__.py +5 -0
- empathy_os/workflows/autonomous_test_gen.py +860 -161
- empathy_os/workflows/base.py +6 -2
- empathy_os/workflows/code_review.py +4 -1
- empathy_os/workflows/document_gen/__init__.py +25 -0
- empathy_os/workflows/document_gen/config.py +30 -0
- empathy_os/workflows/document_gen/report_formatter.py +162 -0
- empathy_os/workflows/document_gen/workflow.py +1426 -0
- empathy_os/workflows/document_gen.py +22 -1598
- empathy_os/workflows/security_audit.py +2 -2
- empathy_os/workflows/security_audit_phase3.py +7 -4
- empathy_os/workflows/seo_optimization.py +633 -0
- empathy_os/workflows/test_gen/__init__.py +52 -0
- empathy_os/workflows/test_gen/ast_analyzer.py +249 -0
- empathy_os/workflows/test_gen/config.py +88 -0
- empathy_os/workflows/test_gen/data_models.py +38 -0
- empathy_os/workflows/test_gen/report_formatter.py +289 -0
- empathy_os/workflows/test_gen/test_templates.py +381 -0
- empathy_os/workflows/test_gen/workflow.py +655 -0
- empathy_os/workflows/test_gen.py +42 -1905
- empathy_os/memory/types 2.py +0 -441
- empathy_os/models/telemetry.py +0 -1660
- {empathy_framework-5.1.1.dist-info → empathy_framework-5.2.1.dist-info}/WHEEL +0 -0
- {empathy_framework-5.1.1.dist-info → empathy_framework-5.2.1.dist-info}/entry_points.txt +0 -0
- {empathy_framework-5.1.1.dist-info → empathy_framework-5.2.1.dist-info}/licenses/LICENSE +0 -0
- {empathy_framework-5.1.1.dist-info → empathy_framework-5.2.1.dist-info}/licenses/LICENSE_CHANGE_ANNOUNCEMENT.md +0 -0
- {empathy_framework-5.1.1.dist-info → empathy_framework-5.2.1.dist-info}/top_level.txt +0 -0
empathy_os/workflows/test_gen.py
CHANGED
|
@@ -1,1917 +1,54 @@
|
|
|
1
|
-
"""Test Generation Workflow
|
|
1
|
+
"""Test Generation Workflow (Backward Compatible Entry Point).
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
This module maintains backward compatibility by re-exporting all public APIs
|
|
4
|
+
from the test_gen package.
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
2. analyze (CAPABLE) - Analyze code structure and existing test patterns
|
|
9
|
-
3. generate (CAPABLE) - Generate test cases focusing on edge cases
|
|
10
|
-
4. review (PREMIUM) - Quality review and deduplication (conditional)
|
|
6
|
+
For new code, import from the package directly:
|
|
7
|
+
from empathy_os.workflows.test_gen import TestGenerationWorkflow
|
|
11
8
|
|
|
12
9
|
Copyright 2025 Smart-AI-Memory
|
|
13
10
|
Licensed under Fair Source License 0.9
|
|
14
11
|
"""
|
|
15
12
|
|
|
16
|
-
|
|
17
|
-
import
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
"
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
"
|
|
39
|
-
"
|
|
40
|
-
#
|
|
41
|
-
"
|
|
42
|
-
|
|
43
|
-
"
|
|
44
|
-
"
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
"
|
|
48
|
-
"
|
|
49
|
-
"
|
|
50
|
-
"
|
|
51
|
-
|
|
52
|
-
"
|
|
53
|
-
# Build tools
|
|
54
|
-
".tox",
|
|
55
|
-
".nox",
|
|
56
|
-
# Build outputs
|
|
57
|
-
"build",
|
|
58
|
-
"dist",
|
|
59
|
-
"eggs",
|
|
60
|
-
".eggs",
|
|
61
|
-
"site-packages",
|
|
62
|
-
# IDE
|
|
63
|
-
".idea",
|
|
64
|
-
".vscode",
|
|
65
|
-
# Framework-specific
|
|
66
|
-
"migrations",
|
|
67
|
-
"alembic",
|
|
68
|
-
# Documentation
|
|
69
|
-
"_build",
|
|
70
|
-
"docs/_build",
|
|
13
|
+
# Re-export all public APIs from the package for backward compatibility
|
|
14
|
+
from .test_gen import (
|
|
15
|
+
DEFAULT_SKIP_PATTERNS,
|
|
16
|
+
TEST_GEN_STEPS,
|
|
17
|
+
ASTFunctionAnalyzer,
|
|
18
|
+
ClassSignature,
|
|
19
|
+
FunctionSignature,
|
|
20
|
+
TestGenerationWorkflow,
|
|
21
|
+
format_test_gen_report,
|
|
22
|
+
generate_test_cases_for_params,
|
|
23
|
+
generate_test_for_class,
|
|
24
|
+
generate_test_for_function,
|
|
25
|
+
get_param_test_values,
|
|
26
|
+
get_type_assertion,
|
|
27
|
+
main,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
# Workflow
|
|
32
|
+
"TestGenerationWorkflow",
|
|
33
|
+
"main",
|
|
34
|
+
# Data models
|
|
35
|
+
"FunctionSignature",
|
|
36
|
+
"ClassSignature",
|
|
37
|
+
# AST analyzer
|
|
38
|
+
"ASTFunctionAnalyzer",
|
|
39
|
+
# Configuration
|
|
40
|
+
"DEFAULT_SKIP_PATTERNS",
|
|
41
|
+
"TEST_GEN_STEPS",
|
|
42
|
+
# Test templates
|
|
43
|
+
"generate_test_for_function",
|
|
44
|
+
"generate_test_for_class",
|
|
45
|
+
"generate_test_cases_for_params",
|
|
46
|
+
"get_type_assertion",
|
|
47
|
+
"get_param_test_values",
|
|
48
|
+
# Report formatter
|
|
49
|
+
"format_test_gen_report",
|
|
71
50
|
]
|
|
72
51
|
|
|
73
|
-
# =============================================================================
|
|
74
|
-
# AST-Based Function Analysis
|
|
75
|
-
# =============================================================================
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
@dataclass
|
|
79
|
-
class FunctionSignature:
|
|
80
|
-
"""Detailed function analysis for test generation."""
|
|
81
|
-
|
|
82
|
-
name: str
|
|
83
|
-
params: list[tuple[str, str, str | None]] # (name, type_hint, default)
|
|
84
|
-
return_type: str | None
|
|
85
|
-
is_async: bool
|
|
86
|
-
raises: set[str]
|
|
87
|
-
has_side_effects: bool
|
|
88
|
-
docstring: str | None
|
|
89
|
-
complexity: int = 1 # Rough complexity estimate
|
|
90
|
-
decorators: list[str] = field(default_factory=list)
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
@dataclass
|
|
94
|
-
class ClassSignature:
|
|
95
|
-
"""Detailed class analysis for test generation."""
|
|
96
|
-
|
|
97
|
-
name: str
|
|
98
|
-
methods: list[FunctionSignature]
|
|
99
|
-
init_params: list[tuple[str, str, str | None]] # Constructor params
|
|
100
|
-
base_classes: list[str]
|
|
101
|
-
docstring: str | None
|
|
102
|
-
is_enum: bool = False # True if class inherits from Enum
|
|
103
|
-
is_dataclass: bool = False # True if class has @dataclass decorator
|
|
104
|
-
required_init_params: int = 0 # Number of params without defaults
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
class ASTFunctionAnalyzer(ast.NodeVisitor):
|
|
108
|
-
"""AST-based function analyzer for accurate test generation.
|
|
109
|
-
|
|
110
|
-
Extracts:
|
|
111
|
-
- Function signatures with types
|
|
112
|
-
- Exception types raised
|
|
113
|
-
- Side effects detection
|
|
114
|
-
- Complexity estimation
|
|
115
|
-
|
|
116
|
-
Parse errors are tracked in the `last_error` attribute for debugging.
|
|
117
|
-
"""
|
|
118
|
-
|
|
119
|
-
def __init__(self):
|
|
120
|
-
self.functions: list[FunctionSignature] = []
|
|
121
|
-
self.classes: list[ClassSignature] = []
|
|
122
|
-
self._current_class: str | None = None
|
|
123
|
-
self.last_error: str | None = None # Track parse errors for debugging
|
|
124
|
-
|
|
125
|
-
def analyze(
|
|
126
|
-
self,
|
|
127
|
-
code: str,
|
|
128
|
-
file_path: str = "",
|
|
129
|
-
) -> tuple[list[FunctionSignature], list[ClassSignature]]:
|
|
130
|
-
"""Analyze code and extract function/class signatures.
|
|
131
|
-
|
|
132
|
-
Args:
|
|
133
|
-
code: Python source code to analyze
|
|
134
|
-
file_path: Optional file path for error reporting
|
|
135
|
-
|
|
136
|
-
Returns:
|
|
137
|
-
Tuple of (functions, classes) lists. If parsing fails,
|
|
138
|
-
returns empty lists and sets self.last_error with details.
|
|
139
|
-
|
|
140
|
-
"""
|
|
141
|
-
self.last_error = None
|
|
142
|
-
try:
|
|
143
|
-
tree = ast.parse(code)
|
|
144
|
-
self.functions = []
|
|
145
|
-
self.classes = []
|
|
146
|
-
self.visit(tree)
|
|
147
|
-
return self.functions, self.classes
|
|
148
|
-
except SyntaxError as e:
|
|
149
|
-
# Track the error for debugging instead of silent failure
|
|
150
|
-
location = f" at line {e.lineno}" if e.lineno else ""
|
|
151
|
-
file_info = f" in {file_path}" if file_path else ""
|
|
152
|
-
self.last_error = f"SyntaxError{file_info}{location}: {e.msg}"
|
|
153
|
-
return [], []
|
|
154
|
-
|
|
155
|
-
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
156
|
-
"""Extract function signature."""
|
|
157
|
-
if self._current_class is None: # Only top-level functions
|
|
158
|
-
sig = self._extract_function_signature(node)
|
|
159
|
-
self.functions.append(sig)
|
|
160
|
-
self.generic_visit(node)
|
|
161
|
-
|
|
162
|
-
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
|
|
163
|
-
"""Extract async function signature."""
|
|
164
|
-
if self._current_class is None:
|
|
165
|
-
sig = self._extract_function_signature(node, is_async=True)
|
|
166
|
-
self.functions.append(sig)
|
|
167
|
-
self.generic_visit(node)
|
|
168
|
-
|
|
169
|
-
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
|
170
|
-
"""Extract class signature with methods."""
|
|
171
|
-
self._current_class = node.name
|
|
172
|
-
methods = []
|
|
173
|
-
init_params: list[tuple[str, str, str | None]] = []
|
|
174
|
-
|
|
175
|
-
# Extract base classes
|
|
176
|
-
base_classes = []
|
|
177
|
-
for base in node.bases:
|
|
178
|
-
if isinstance(base, ast.Name):
|
|
179
|
-
base_classes.append(base.id)
|
|
180
|
-
elif isinstance(base, ast.Attribute):
|
|
181
|
-
base_classes.append(ast.unparse(base))
|
|
182
|
-
|
|
183
|
-
# Detect if this is an Enum
|
|
184
|
-
enum_bases = {"Enum", "IntEnum", "StrEnum", "Flag", "IntFlag", "auto"}
|
|
185
|
-
is_enum = any(b in enum_bases for b in base_classes)
|
|
186
|
-
|
|
187
|
-
# Detect if this is a dataclass
|
|
188
|
-
is_dataclass = False
|
|
189
|
-
for decorator in node.decorator_list:
|
|
190
|
-
if isinstance(decorator, ast.Name) and decorator.id == "dataclass":
|
|
191
|
-
is_dataclass = True
|
|
192
|
-
elif isinstance(decorator, ast.Call):
|
|
193
|
-
if isinstance(decorator.func, ast.Name) and decorator.func.id == "dataclass":
|
|
194
|
-
is_dataclass = True
|
|
195
|
-
|
|
196
|
-
# Process methods
|
|
197
|
-
for item in node.body:
|
|
198
|
-
if isinstance(item, ast.FunctionDef | ast.AsyncFunctionDef):
|
|
199
|
-
method_sig = self._extract_function_signature(
|
|
200
|
-
item,
|
|
201
|
-
is_async=isinstance(item, ast.AsyncFunctionDef),
|
|
202
|
-
)
|
|
203
|
-
methods.append(method_sig)
|
|
204
|
-
|
|
205
|
-
# Extract __init__ params
|
|
206
|
-
if item.name == "__init__":
|
|
207
|
-
init_params = method_sig.params[1:] # Skip 'self'
|
|
208
|
-
|
|
209
|
-
# Count required init params (those without defaults)
|
|
210
|
-
required_init_params = sum(1 for p in init_params if p[2] is None)
|
|
211
|
-
|
|
212
|
-
self.classes.append(
|
|
213
|
-
ClassSignature(
|
|
214
|
-
name=node.name,
|
|
215
|
-
methods=methods,
|
|
216
|
-
init_params=init_params,
|
|
217
|
-
base_classes=base_classes,
|
|
218
|
-
docstring=ast.get_docstring(node),
|
|
219
|
-
is_enum=is_enum,
|
|
220
|
-
is_dataclass=is_dataclass,
|
|
221
|
-
required_init_params=required_init_params,
|
|
222
|
-
),
|
|
223
|
-
)
|
|
224
|
-
|
|
225
|
-
self._current_class = None
|
|
226
|
-
# Don't call generic_visit to avoid processing methods again
|
|
227
|
-
|
|
228
|
-
def _extract_function_signature(
|
|
229
|
-
self,
|
|
230
|
-
node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
231
|
-
is_async: bool = False,
|
|
232
|
-
) -> FunctionSignature:
|
|
233
|
-
"""Extract detailed signature from function node."""
|
|
234
|
-
# Extract parameters with types and defaults
|
|
235
|
-
params = []
|
|
236
|
-
defaults = list(node.args.defaults)
|
|
237
|
-
num_defaults = len(defaults)
|
|
238
|
-
num_args = len(node.args.args)
|
|
239
|
-
|
|
240
|
-
for i, arg in enumerate(node.args.args):
|
|
241
|
-
param_name = arg.arg
|
|
242
|
-
param_type = ast.unparse(arg.annotation) if arg.annotation else "Any"
|
|
243
|
-
|
|
244
|
-
# Calculate default index
|
|
245
|
-
default_idx = i - (num_args - num_defaults)
|
|
246
|
-
default_val = None
|
|
247
|
-
if default_idx >= 0:
|
|
248
|
-
try:
|
|
249
|
-
default_val = ast.unparse(defaults[default_idx])
|
|
250
|
-
except Exception:
|
|
251
|
-
default_val = "..."
|
|
252
|
-
|
|
253
|
-
params.append((param_name, param_type, default_val))
|
|
254
|
-
|
|
255
|
-
# Extract return type
|
|
256
|
-
return_type = ast.unparse(node.returns) if node.returns else None
|
|
257
|
-
|
|
258
|
-
# Find raised exceptions
|
|
259
|
-
raises: set[str] = set()
|
|
260
|
-
for child in ast.walk(node):
|
|
261
|
-
if isinstance(child, ast.Raise) and child.exc:
|
|
262
|
-
if isinstance(child.exc, ast.Call):
|
|
263
|
-
if isinstance(child.exc.func, ast.Name):
|
|
264
|
-
raises.add(child.exc.func.id)
|
|
265
|
-
elif isinstance(child.exc.func, ast.Attribute):
|
|
266
|
-
raises.add(child.exc.func.attr)
|
|
267
|
-
elif isinstance(child.exc, ast.Name):
|
|
268
|
-
raises.add(child.exc.id)
|
|
269
|
-
|
|
270
|
-
# Detect side effects (simple heuristic)
|
|
271
|
-
has_side_effects = self._detect_side_effects(node)
|
|
272
|
-
|
|
273
|
-
# Estimate complexity
|
|
274
|
-
complexity = self._estimate_complexity(node)
|
|
275
|
-
|
|
276
|
-
# Extract decorators
|
|
277
|
-
decorators = []
|
|
278
|
-
for dec in node.decorator_list:
|
|
279
|
-
if isinstance(dec, ast.Name):
|
|
280
|
-
decorators.append(dec.id)
|
|
281
|
-
elif isinstance(dec, ast.Attribute):
|
|
282
|
-
decorators.append(ast.unparse(dec))
|
|
283
|
-
elif isinstance(dec, ast.Call):
|
|
284
|
-
if isinstance(dec.func, ast.Name):
|
|
285
|
-
decorators.append(dec.func.id)
|
|
286
|
-
|
|
287
|
-
return FunctionSignature(
|
|
288
|
-
name=node.name,
|
|
289
|
-
params=params,
|
|
290
|
-
return_type=return_type,
|
|
291
|
-
is_async=is_async or isinstance(node, ast.AsyncFunctionDef),
|
|
292
|
-
raises=raises,
|
|
293
|
-
has_side_effects=has_side_effects,
|
|
294
|
-
docstring=ast.get_docstring(node),
|
|
295
|
-
complexity=complexity,
|
|
296
|
-
decorators=decorators,
|
|
297
|
-
)
|
|
298
|
-
|
|
299
|
-
def _detect_side_effects(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
|
|
300
|
-
"""Detect if function has side effects (writes to files, global state, etc.)."""
|
|
301
|
-
side_effect_names = {
|
|
302
|
-
"print",
|
|
303
|
-
"write",
|
|
304
|
-
"open",
|
|
305
|
-
"save",
|
|
306
|
-
"delete",
|
|
307
|
-
"remove",
|
|
308
|
-
"update",
|
|
309
|
-
"insert",
|
|
310
|
-
"execute",
|
|
311
|
-
"send",
|
|
312
|
-
"post",
|
|
313
|
-
"put",
|
|
314
|
-
"patch",
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
for child in ast.walk(node):
|
|
318
|
-
if isinstance(child, ast.Call):
|
|
319
|
-
if isinstance(child.func, ast.Name):
|
|
320
|
-
if child.func.id.lower() in side_effect_names:
|
|
321
|
-
return True
|
|
322
|
-
elif isinstance(child.func, ast.Attribute):
|
|
323
|
-
if child.func.attr.lower() in side_effect_names:
|
|
324
|
-
return True
|
|
325
|
-
return False
|
|
326
|
-
|
|
327
|
-
def _estimate_complexity(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> int:
|
|
328
|
-
"""Estimate cyclomatic complexity (simplified)."""
|
|
329
|
-
complexity = 1
|
|
330
|
-
for child in ast.walk(node):
|
|
331
|
-
if isinstance(child, ast.If | ast.While | ast.For | ast.ExceptHandler):
|
|
332
|
-
complexity += 1
|
|
333
|
-
elif isinstance(child, ast.BoolOp):
|
|
334
|
-
complexity += len(child.values) - 1
|
|
335
|
-
return complexity
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
# Define step configurations for executor-based execution
|
|
339
|
-
TEST_GEN_STEPS = {
|
|
340
|
-
"identify": WorkflowStepConfig(
|
|
341
|
-
name="identify",
|
|
342
|
-
task_type="triage", # Cheap tier task
|
|
343
|
-
tier_hint="cheap",
|
|
344
|
-
description="Identify files needing tests",
|
|
345
|
-
max_tokens=2000,
|
|
346
|
-
),
|
|
347
|
-
"analyze": WorkflowStepConfig(
|
|
348
|
-
name="analyze",
|
|
349
|
-
task_type="code_analysis", # Capable tier task
|
|
350
|
-
tier_hint="capable",
|
|
351
|
-
description="Analyze code structure for test generation",
|
|
352
|
-
max_tokens=3000,
|
|
353
|
-
),
|
|
354
|
-
"generate": WorkflowStepConfig(
|
|
355
|
-
name="generate",
|
|
356
|
-
task_type="code_generation", # Capable tier task
|
|
357
|
-
tier_hint="capable",
|
|
358
|
-
description="Generate test cases",
|
|
359
|
-
max_tokens=4000,
|
|
360
|
-
),
|
|
361
|
-
"review": WorkflowStepConfig(
|
|
362
|
-
name="review",
|
|
363
|
-
task_type="final_review", # Premium tier task
|
|
364
|
-
tier_hint="premium",
|
|
365
|
-
description="Review and improve generated test suite",
|
|
366
|
-
max_tokens=3000,
|
|
367
|
-
),
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
class TestGenerationWorkflow(BaseWorkflow):
|
|
372
|
-
"""Generate tests targeting areas with historical bugs.
|
|
373
|
-
|
|
374
|
-
Prioritizes test generation for files that have historically
|
|
375
|
-
been bug-prone and have low test coverage.
|
|
376
|
-
"""
|
|
377
|
-
|
|
378
|
-
name = "test-gen"
|
|
379
|
-
description = "Generate tests targeting areas with historical bugs"
|
|
380
|
-
stages = ["identify", "analyze", "generate", "review"]
|
|
381
|
-
tier_map = {
|
|
382
|
-
"identify": ModelTier.CHEAP,
|
|
383
|
-
"analyze": ModelTier.CAPABLE,
|
|
384
|
-
"generate": ModelTier.CAPABLE,
|
|
385
|
-
"review": ModelTier.PREMIUM,
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
def __init__(
|
|
389
|
-
self,
|
|
390
|
-
patterns_dir: str = "./patterns",
|
|
391
|
-
min_tests_for_review: int = 10,
|
|
392
|
-
write_tests: bool = False,
|
|
393
|
-
output_dir: str = "tests/generated",
|
|
394
|
-
enable_auth_strategy: bool = True,
|
|
395
|
-
**kwargs: Any,
|
|
396
|
-
):
|
|
397
|
-
"""Initialize test generation workflow.
|
|
398
|
-
|
|
399
|
-
Args:
|
|
400
|
-
patterns_dir: Directory containing learned patterns
|
|
401
|
-
min_tests_for_review: Minimum tests generated to trigger premium review
|
|
402
|
-
write_tests: If True, write generated tests to output_dir
|
|
403
|
-
output_dir: Directory to write generated test files
|
|
404
|
-
enable_auth_strategy: Enable intelligent auth routing (default: True)
|
|
405
|
-
**kwargs: Additional arguments passed to BaseWorkflow
|
|
406
|
-
|
|
407
|
-
"""
|
|
408
|
-
super().__init__(**kwargs)
|
|
409
|
-
self.patterns_dir = patterns_dir
|
|
410
|
-
self.min_tests_for_review = min_tests_for_review
|
|
411
|
-
self.write_tests = write_tests
|
|
412
|
-
self.output_dir = output_dir
|
|
413
|
-
self.enable_auth_strategy = enable_auth_strategy
|
|
414
|
-
self._test_count: int = 0
|
|
415
|
-
self._bug_hotspots: list[str] = []
|
|
416
|
-
self._auth_mode_used: str | None = None
|
|
417
|
-
self._load_bug_hotspots()
|
|
418
|
-
|
|
419
|
-
def _load_bug_hotspots(self) -> None:
|
|
420
|
-
"""Load files with historical bugs from pattern library."""
|
|
421
|
-
debugging_file = Path(self.patterns_dir) / "debugging.json"
|
|
422
|
-
if debugging_file.exists():
|
|
423
|
-
try:
|
|
424
|
-
with open(debugging_file) as fh:
|
|
425
|
-
data = json.load(fh)
|
|
426
|
-
patterns = data.get("patterns", [])
|
|
427
|
-
# Extract files from bug patterns
|
|
428
|
-
files = set()
|
|
429
|
-
for p in patterns:
|
|
430
|
-
for file_entry in p.get("files_affected", []):
|
|
431
|
-
if file_entry is None:
|
|
432
|
-
continue
|
|
433
|
-
files.add(str(file_entry))
|
|
434
|
-
self._bug_hotspots = list(files)
|
|
435
|
-
except (json.JSONDecodeError, OSError):
|
|
436
|
-
pass
|
|
437
|
-
|
|
438
|
-
def should_skip_stage(self, stage_name: str, input_data: Any) -> tuple[bool, str | None]:
|
|
439
|
-
"""Downgrade review stage if few tests generated.
|
|
440
|
-
|
|
441
|
-
Args:
|
|
442
|
-
stage_name: Name of the stage to check
|
|
443
|
-
input_data: Current workflow data
|
|
444
|
-
|
|
445
|
-
Returns:
|
|
446
|
-
Tuple of (should_skip, reason)
|
|
447
|
-
|
|
448
|
-
"""
|
|
449
|
-
if stage_name == "review":
|
|
450
|
-
if self._test_count < self.min_tests_for_review:
|
|
451
|
-
# Downgrade to CAPABLE
|
|
452
|
-
self.tier_map["review"] = ModelTier.CAPABLE
|
|
453
|
-
return False, None
|
|
454
|
-
return False, None
|
|
455
|
-
|
|
456
|
-
async def run_stage(
|
|
457
|
-
self,
|
|
458
|
-
stage_name: str,
|
|
459
|
-
tier: ModelTier,
|
|
460
|
-
input_data: Any,
|
|
461
|
-
) -> tuple[Any, int, int]:
|
|
462
|
-
"""Route to specific stage implementation."""
|
|
463
|
-
if stage_name == "identify":
|
|
464
|
-
return await self._identify(input_data, tier)
|
|
465
|
-
if stage_name == "analyze":
|
|
466
|
-
return await self._analyze(input_data, tier)
|
|
467
|
-
if stage_name == "generate":
|
|
468
|
-
return await self._generate(input_data, tier)
|
|
469
|
-
if stage_name == "review":
|
|
470
|
-
return await self._review(input_data, tier)
|
|
471
|
-
raise ValueError(f"Unknown stage: {stage_name}")
|
|
472
|
-
|
|
473
|
-
async def _identify(self, input_data: dict, tier: ModelTier) -> tuple[dict, int, int]:
|
|
474
|
-
"""Identify files needing tests.
|
|
475
|
-
|
|
476
|
-
Finds files with low coverage, historical bugs, or
|
|
477
|
-
no existing tests.
|
|
478
|
-
|
|
479
|
-
Configurable options via input_data:
|
|
480
|
-
max_files_to_scan: Maximum files to scan before stopping (default: 1000)
|
|
481
|
-
max_file_size_kb: Skip files larger than this (default: 200)
|
|
482
|
-
max_candidates: Maximum candidates to return (default: 50)
|
|
483
|
-
skip_patterns: List of directory patterns to skip (default: DEFAULT_SKIP_PATTERNS)
|
|
484
|
-
include_all_files: Include files with priority=0 (default: False)
|
|
485
|
-
"""
|
|
486
|
-
target_path = input_data.get("path", ".")
|
|
487
|
-
file_types = input_data.get("file_types", [".py"])
|
|
488
|
-
|
|
489
|
-
# === AUTH STRATEGY INTEGRATION ===
|
|
490
|
-
if self.enable_auth_strategy:
|
|
491
|
-
try:
|
|
492
|
-
import logging
|
|
493
|
-
from pathlib import Path
|
|
494
|
-
|
|
495
|
-
from empathy_os.models import (
|
|
496
|
-
count_lines_of_code,
|
|
497
|
-
get_auth_strategy,
|
|
498
|
-
get_module_size_category,
|
|
499
|
-
)
|
|
500
|
-
|
|
501
|
-
logger = logging.getLogger(__name__)
|
|
502
|
-
|
|
503
|
-
# Calculate total LOC for the project/path
|
|
504
|
-
target = Path(target_path)
|
|
505
|
-
total_lines = 0
|
|
506
|
-
if target.is_file():
|
|
507
|
-
total_lines = count_lines_of_code(target)
|
|
508
|
-
elif target.is_dir():
|
|
509
|
-
# Estimate total lines for directory
|
|
510
|
-
for py_file in target.rglob("*.py"):
|
|
511
|
-
try:
|
|
512
|
-
total_lines += count_lines_of_code(py_file)
|
|
513
|
-
except Exception:
|
|
514
|
-
pass
|
|
515
|
-
|
|
516
|
-
if total_lines > 0:
|
|
517
|
-
strategy = get_auth_strategy()
|
|
518
|
-
recommended_mode = strategy.get_recommended_mode(total_lines)
|
|
519
|
-
self._auth_mode_used = recommended_mode.value
|
|
520
|
-
|
|
521
|
-
size_category = get_module_size_category(total_lines)
|
|
522
|
-
logger.info(
|
|
523
|
-
f"Test generation target: {target_path} "
|
|
524
|
-
f"({total_lines:,} LOC, {size_category})"
|
|
525
|
-
)
|
|
526
|
-
logger.info(f"Recommended auth mode: {recommended_mode.value}")
|
|
527
|
-
|
|
528
|
-
cost_estimate = strategy.estimate_cost(total_lines, recommended_mode)
|
|
529
|
-
if recommended_mode.value == "subscription":
|
|
530
|
-
logger.info(f"Cost: {cost_estimate['quota_cost']}")
|
|
531
|
-
else:
|
|
532
|
-
logger.info(f"Cost: ~${cost_estimate['monetary_cost']:.4f}")
|
|
533
|
-
|
|
534
|
-
except Exception as e:
|
|
535
|
-
import logging
|
|
536
|
-
|
|
537
|
-
logger = logging.getLogger(__name__)
|
|
538
|
-
logger.warning(f"Auth strategy detection failed: {e}")
|
|
539
|
-
|
|
540
|
-
# Parse configurable limits with sensible defaults
|
|
541
|
-
max_files_to_scan = input_data.get("max_files_to_scan", 1000)
|
|
542
|
-
max_file_size_kb = input_data.get("max_file_size_kb", 200)
|
|
543
|
-
max_candidates = input_data.get("max_candidates", 50)
|
|
544
|
-
skip_patterns = input_data.get("skip_patterns", DEFAULT_SKIP_PATTERNS)
|
|
545
|
-
include_all_files = input_data.get("include_all_files", False)
|
|
546
|
-
|
|
547
|
-
target = Path(target_path)
|
|
548
|
-
candidates: list[dict] = []
|
|
549
|
-
|
|
550
|
-
# Track project scope for enterprise reporting
|
|
551
|
-
total_source_files = 0
|
|
552
|
-
existing_test_files = 0
|
|
553
|
-
|
|
554
|
-
# Track scan summary for debugging/visibility
|
|
555
|
-
# Use separate counters for type safety
|
|
556
|
-
scan_counts = {
|
|
557
|
-
"files_scanned": 0,
|
|
558
|
-
"files_too_large": 0,
|
|
559
|
-
"files_read_error": 0,
|
|
560
|
-
"files_excluded_by_pattern": 0,
|
|
561
|
-
}
|
|
562
|
-
early_exit_reason: str | None = None
|
|
563
|
-
|
|
564
|
-
max_file_size_bytes = max_file_size_kb * 1024
|
|
565
|
-
scan_limit_reached = False
|
|
566
|
-
|
|
567
|
-
if target.exists():
|
|
568
|
-
for ext in file_types:
|
|
569
|
-
if scan_limit_reached:
|
|
570
|
-
break
|
|
571
|
-
|
|
572
|
-
for file_path in target.rglob(f"*{ext}"):
|
|
573
|
-
# Check if we've hit the scan limit
|
|
574
|
-
if scan_counts["files_scanned"] >= max_files_to_scan:
|
|
575
|
-
early_exit_reason = f"max_files_to_scan ({max_files_to_scan}) reached"
|
|
576
|
-
scan_limit_reached = True
|
|
577
|
-
break
|
|
578
|
-
|
|
579
|
-
# Skip non-code directories using configurable patterns
|
|
580
|
-
file_str = str(file_path)
|
|
581
|
-
if any(skip in file_str for skip in skip_patterns):
|
|
582
|
-
scan_counts["files_excluded_by_pattern"] += 1
|
|
583
|
-
continue
|
|
584
|
-
|
|
585
|
-
# Count test files separately for scope awareness
|
|
586
|
-
if "test_" in file_str or "_test." in file_str or "/tests/" in file_str:
|
|
587
|
-
existing_test_files += 1
|
|
588
|
-
continue
|
|
589
|
-
|
|
590
|
-
# Check file size before reading
|
|
591
|
-
try:
|
|
592
|
-
file_size = file_path.stat().st_size
|
|
593
|
-
if file_size > max_file_size_bytes:
|
|
594
|
-
scan_counts["files_too_large"] += 1
|
|
595
|
-
continue
|
|
596
|
-
except OSError:
|
|
597
|
-
scan_counts["files_read_error"] += 1
|
|
598
|
-
continue
|
|
599
|
-
|
|
600
|
-
# Count source files and increment scan counter
|
|
601
|
-
total_source_files += 1
|
|
602
|
-
scan_counts["files_scanned"] += 1
|
|
603
|
-
|
|
604
|
-
try:
|
|
605
|
-
content = file_path.read_text(errors="ignore")
|
|
606
|
-
lines = len(content.splitlines())
|
|
607
|
-
|
|
608
|
-
# Check if in bug hotspots
|
|
609
|
-
is_hotspot = any(hotspot in file_str for hotspot in self._bug_hotspots)
|
|
610
|
-
|
|
611
|
-
# Check for existing tests
|
|
612
|
-
test_file = self._find_test_file(file_path)
|
|
613
|
-
has_tests = test_file.exists() if test_file else False
|
|
614
|
-
|
|
615
|
-
# Calculate priority
|
|
616
|
-
priority = 0
|
|
617
|
-
if is_hotspot:
|
|
618
|
-
priority += 50
|
|
619
|
-
if not has_tests:
|
|
620
|
-
priority += 30
|
|
621
|
-
if lines > 100:
|
|
622
|
-
priority += 10
|
|
623
|
-
if lines > 300:
|
|
624
|
-
priority += 10
|
|
625
|
-
|
|
626
|
-
# Include if priority > 0 OR include_all_files is set
|
|
627
|
-
if priority > 0 or include_all_files:
|
|
628
|
-
candidates.append(
|
|
629
|
-
{
|
|
630
|
-
"file": file_str,
|
|
631
|
-
"lines": lines,
|
|
632
|
-
"is_hotspot": is_hotspot,
|
|
633
|
-
"has_tests": has_tests,
|
|
634
|
-
"priority": priority,
|
|
635
|
-
},
|
|
636
|
-
)
|
|
637
|
-
except OSError:
|
|
638
|
-
scan_counts["files_read_error"] += 1
|
|
639
|
-
continue
|
|
640
|
-
|
|
641
|
-
# Sort by priority
|
|
642
|
-
candidates.sort(key=lambda x: -x["priority"])
|
|
643
|
-
|
|
644
|
-
input_tokens = len(str(input_data)) // 4
|
|
645
|
-
output_tokens = len(str(candidates)) // 4
|
|
646
|
-
|
|
647
|
-
# Calculate scope metrics for enterprise reporting
|
|
648
|
-
analyzed_count = min(max_candidates, len(candidates))
|
|
649
|
-
coverage_pct = (analyzed_count / len(candidates) * 100) if candidates else 100
|
|
650
|
-
|
|
651
|
-
return (
|
|
652
|
-
{
|
|
653
|
-
"candidates": candidates[:max_candidates],
|
|
654
|
-
"total_candidates": len(candidates),
|
|
655
|
-
"hotspot_count": sum(1 for c in candidates if c["is_hotspot"]),
|
|
656
|
-
"untested_count": sum(1 for c in candidates if not c["has_tests"]),
|
|
657
|
-
# Scope awareness fields for enterprise reporting
|
|
658
|
-
"total_source_files": total_source_files,
|
|
659
|
-
"existing_test_files": existing_test_files,
|
|
660
|
-
"large_project_warning": len(candidates) > 100,
|
|
661
|
-
"analysis_coverage_percent": coverage_pct,
|
|
662
|
-
# Scan summary for debugging/visibility
|
|
663
|
-
"scan_summary": {**scan_counts, "early_exit_reason": early_exit_reason},
|
|
664
|
-
# Pass through config for subsequent stages
|
|
665
|
-
"config": {
|
|
666
|
-
"max_files_to_analyze": input_data.get("max_files_to_analyze", 20),
|
|
667
|
-
"max_functions_per_file": input_data.get("max_functions_per_file", 30),
|
|
668
|
-
"max_classes_per_file": input_data.get("max_classes_per_file", 15),
|
|
669
|
-
"max_files_to_generate": input_data.get("max_files_to_generate", 15),
|
|
670
|
-
"max_functions_to_generate": input_data.get("max_functions_to_generate", 8),
|
|
671
|
-
"max_classes_to_generate": input_data.get("max_classes_to_generate", 4),
|
|
672
|
-
},
|
|
673
|
-
**input_data,
|
|
674
|
-
},
|
|
675
|
-
input_tokens,
|
|
676
|
-
output_tokens,
|
|
677
|
-
)
|
|
678
|
-
|
|
679
|
-
def _find_test_file(self, source_file: Path) -> Path | None:
|
|
680
|
-
"""Find corresponding test file for a source file."""
|
|
681
|
-
name = source_file.stem
|
|
682
|
-
parent = source_file.parent
|
|
683
|
-
|
|
684
|
-
# Check common test locations
|
|
685
|
-
possible = [
|
|
686
|
-
parent / f"test_{name}.py",
|
|
687
|
-
parent / "tests" / f"test_{name}.py",
|
|
688
|
-
parent.parent / "tests" / f"test_{name}.py",
|
|
689
|
-
]
|
|
690
|
-
|
|
691
|
-
for p in possible:
|
|
692
|
-
if p.exists():
|
|
693
|
-
return p
|
|
694
|
-
|
|
695
|
-
return possible[0] # Return expected location even if doesn't exist
|
|
696
|
-
|
|
697
|
-
async def _analyze(self, input_data: dict, tier: ModelTier) -> tuple[dict, int, int]:
|
|
698
|
-
"""Analyze code structure for test generation.
|
|
699
|
-
|
|
700
|
-
Examines functions, classes, and patterns to determine
|
|
701
|
-
what tests should be generated.
|
|
702
|
-
|
|
703
|
-
Uses config from _identify stage for limits:
|
|
704
|
-
max_files_to_analyze: Maximum files to analyze (default: 20)
|
|
705
|
-
max_functions_per_file: Maximum functions per file (default: 30)
|
|
706
|
-
max_classes_per_file: Maximum classes per file (default: 15)
|
|
707
|
-
"""
|
|
708
|
-
# Get config from previous stage or use defaults
|
|
709
|
-
config = input_data.get("config", {})
|
|
710
|
-
max_files_to_analyze = config.get("max_files_to_analyze", 20)
|
|
711
|
-
max_functions_per_file = config.get("max_functions_per_file", 30)
|
|
712
|
-
max_classes_per_file = config.get("max_classes_per_file", 15)
|
|
713
|
-
|
|
714
|
-
candidates = input_data.get("candidates", [])[:max_files_to_analyze]
|
|
715
|
-
analysis: list[dict] = []
|
|
716
|
-
parse_errors: list[str] = [] # Track files that failed to parse
|
|
717
|
-
|
|
718
|
-
for candidate in candidates:
|
|
719
|
-
file_path = Path(candidate["file"])
|
|
720
|
-
if not file_path.exists():
|
|
721
|
-
continue
|
|
722
|
-
|
|
723
|
-
try:
|
|
724
|
-
content = file_path.read_text(errors="ignore")
|
|
725
|
-
|
|
726
|
-
# Extract testable items with configurable limits and error tracking
|
|
727
|
-
functions, func_error = self._extract_functions(
|
|
728
|
-
content,
|
|
729
|
-
candidate["file"],
|
|
730
|
-
max_functions_per_file,
|
|
731
|
-
)
|
|
732
|
-
classes, class_error = self._extract_classes(
|
|
733
|
-
content,
|
|
734
|
-
candidate["file"],
|
|
735
|
-
max_classes_per_file,
|
|
736
|
-
)
|
|
737
|
-
|
|
738
|
-
# Track parse errors for visibility
|
|
739
|
-
if func_error:
|
|
740
|
-
parse_errors.append(func_error)
|
|
741
|
-
if class_error and class_error != func_error:
|
|
742
|
-
parse_errors.append(class_error)
|
|
743
|
-
|
|
744
|
-
analysis.append(
|
|
745
|
-
{
|
|
746
|
-
"file": candidate["file"],
|
|
747
|
-
"priority": candidate["priority"],
|
|
748
|
-
"functions": functions,
|
|
749
|
-
"classes": classes,
|
|
750
|
-
"function_count": len(functions),
|
|
751
|
-
"class_count": len(classes),
|
|
752
|
-
"test_suggestions": self._generate_suggestions(functions, classes),
|
|
753
|
-
},
|
|
754
|
-
)
|
|
755
|
-
except OSError:
|
|
756
|
-
continue
|
|
757
|
-
|
|
758
|
-
input_tokens = len(str(input_data)) // 4
|
|
759
|
-
output_tokens = len(str(analysis)) // 4
|
|
760
|
-
|
|
761
|
-
return (
|
|
762
|
-
{
|
|
763
|
-
"analysis": analysis,
|
|
764
|
-
"total_functions": sum(a["function_count"] for a in analysis),
|
|
765
|
-
"total_classes": sum(a["class_count"] for a in analysis),
|
|
766
|
-
"parse_errors": parse_errors, # Expose errors for debugging
|
|
767
|
-
**input_data,
|
|
768
|
-
},
|
|
769
|
-
input_tokens,
|
|
770
|
-
output_tokens,
|
|
771
|
-
)
|
|
772
|
-
|
|
773
|
-
def _extract_functions(
|
|
774
|
-
self,
|
|
775
|
-
content: str,
|
|
776
|
-
file_path: str = "",
|
|
777
|
-
max_functions: int = 30,
|
|
778
|
-
) -> tuple[list[dict], str | None]:
|
|
779
|
-
"""Extract function definitions from Python code using AST analysis.
|
|
780
|
-
|
|
781
|
-
Args:
|
|
782
|
-
content: Python source code
|
|
783
|
-
file_path: File path for error reporting
|
|
784
|
-
max_functions: Maximum functions to extract (configurable)
|
|
785
|
-
|
|
786
|
-
Returns:
|
|
787
|
-
Tuple of (functions list, error message or None)
|
|
788
|
-
|
|
789
|
-
"""
|
|
790
|
-
analyzer = ASTFunctionAnalyzer()
|
|
791
|
-
functions, _ = analyzer.analyze(content, file_path)
|
|
792
|
-
|
|
793
|
-
result = []
|
|
794
|
-
for sig in functions[:max_functions]:
|
|
795
|
-
if not sig.name.startswith("_") or sig.name.startswith("__"):
|
|
796
|
-
result.append(
|
|
797
|
-
{
|
|
798
|
-
"name": sig.name,
|
|
799
|
-
"params": [(p[0], p[1], p[2]) for p in sig.params],
|
|
800
|
-
"param_names": [p[0] for p in sig.params],
|
|
801
|
-
"is_async": sig.is_async,
|
|
802
|
-
"return_type": sig.return_type,
|
|
803
|
-
"raises": list(sig.raises),
|
|
804
|
-
"has_side_effects": sig.has_side_effects,
|
|
805
|
-
"complexity": sig.complexity,
|
|
806
|
-
"docstring": sig.docstring,
|
|
807
|
-
},
|
|
808
|
-
)
|
|
809
|
-
return result, analyzer.last_error
|
|
810
|
-
|
|
811
|
-
def _extract_classes(
|
|
812
|
-
self,
|
|
813
|
-
content: str,
|
|
814
|
-
file_path: str = "",
|
|
815
|
-
max_classes: int = 15,
|
|
816
|
-
) -> tuple[list[dict], str | None]:
|
|
817
|
-
"""Extract class definitions from Python code using AST analysis.
|
|
818
|
-
|
|
819
|
-
Args:
|
|
820
|
-
content: Python source code
|
|
821
|
-
file_path: File path for error reporting
|
|
822
|
-
max_classes: Maximum classes to extract (configurable)
|
|
823
|
-
|
|
824
|
-
Returns:
|
|
825
|
-
Tuple of (classes list, error message or None)
|
|
826
|
-
|
|
827
|
-
"""
|
|
828
|
-
analyzer = ASTFunctionAnalyzer()
|
|
829
|
-
_, classes = analyzer.analyze(content, file_path)
|
|
830
|
-
|
|
831
|
-
result = []
|
|
832
|
-
for sig in classes[:max_classes]:
|
|
833
|
-
# Skip enums - they don't need traditional class tests
|
|
834
|
-
if sig.is_enum:
|
|
835
|
-
continue
|
|
836
|
-
|
|
837
|
-
methods = [
|
|
838
|
-
{
|
|
839
|
-
"name": m.name,
|
|
840
|
-
"params": [(p[0], p[1], p[2]) for p in m.params],
|
|
841
|
-
"is_async": m.is_async,
|
|
842
|
-
"raises": list(m.raises),
|
|
843
|
-
}
|
|
844
|
-
for m in sig.methods
|
|
845
|
-
if not m.name.startswith("_") or m.name == "__init__"
|
|
846
|
-
]
|
|
847
|
-
result.append(
|
|
848
|
-
{
|
|
849
|
-
"name": sig.name,
|
|
850
|
-
"init_params": [(p[0], p[1], p[2]) for p in sig.init_params],
|
|
851
|
-
"methods": methods,
|
|
852
|
-
"base_classes": sig.base_classes,
|
|
853
|
-
"docstring": sig.docstring,
|
|
854
|
-
"is_dataclass": sig.is_dataclass,
|
|
855
|
-
"required_init_params": sig.required_init_params,
|
|
856
|
-
},
|
|
857
|
-
)
|
|
858
|
-
return result, analyzer.last_error
|
|
859
|
-
|
|
860
|
-
def _generate_suggestions(self, functions: list[dict], classes: list[dict]) -> list[str]:
|
|
861
|
-
"""Generate test suggestions based on code structure."""
|
|
862
|
-
suggestions = []
|
|
863
|
-
|
|
864
|
-
for func in functions[:5]:
|
|
865
|
-
if func["params"]:
|
|
866
|
-
suggestions.append(f"Test {func['name']} with valid inputs")
|
|
867
|
-
suggestions.append(f"Test {func['name']} with edge cases")
|
|
868
|
-
if func["is_async"]:
|
|
869
|
-
suggestions.append(f"Test {func['name']} async behavior")
|
|
870
|
-
|
|
871
|
-
for cls in classes[:3]:
|
|
872
|
-
suggestions.append(f"Test {cls['name']} initialization")
|
|
873
|
-
suggestions.append(f"Test {cls['name']} methods")
|
|
874
|
-
|
|
875
|
-
return suggestions
|
|
876
|
-
|
|
877
|
-
async def _generate(self, input_data: dict, tier: ModelTier) -> tuple[dict, int, int]:
|
|
878
|
-
"""Generate test cases.
|
|
879
|
-
|
|
880
|
-
Creates test code targeting identified functions
|
|
881
|
-
and classes, focusing on edge cases.
|
|
882
|
-
|
|
883
|
-
Uses config from _identify stage for limits:
|
|
884
|
-
max_files_to_generate: Maximum files to generate tests for (default: 15)
|
|
885
|
-
max_functions_to_generate: Maximum functions per file (default: 8)
|
|
886
|
-
max_classes_to_generate: Maximum classes per file (default: 4)
|
|
887
|
-
"""
|
|
888
|
-
# Get config from previous stages or use defaults
|
|
889
|
-
config = input_data.get("config", {})
|
|
890
|
-
max_files_to_generate = config.get("max_files_to_generate", 15)
|
|
891
|
-
max_functions_to_generate = config.get("max_functions_to_generate", 8)
|
|
892
|
-
max_classes_to_generate = config.get("max_classes_to_generate", 4)
|
|
893
|
-
|
|
894
|
-
analysis = input_data.get("analysis", [])
|
|
895
|
-
generated_tests: list[dict] = []
|
|
896
|
-
|
|
897
|
-
for item in analysis[:max_files_to_generate]:
|
|
898
|
-
file_path = item["file"]
|
|
899
|
-
module_name = Path(file_path).stem
|
|
900
|
-
|
|
901
|
-
tests = []
|
|
902
|
-
for func in item.get("functions", [])[:max_functions_to_generate]:
|
|
903
|
-
test_code = self._generate_test_for_function(module_name, func)
|
|
904
|
-
tests.append(
|
|
905
|
-
{
|
|
906
|
-
"target": func["name"],
|
|
907
|
-
"type": "function",
|
|
908
|
-
"code": test_code,
|
|
909
|
-
},
|
|
910
|
-
)
|
|
911
|
-
|
|
912
|
-
for cls in item.get("classes", [])[:max_classes_to_generate]:
|
|
913
|
-
test_code = self._generate_test_for_class(module_name, cls)
|
|
914
|
-
tests.append(
|
|
915
|
-
{
|
|
916
|
-
"target": cls["name"],
|
|
917
|
-
"type": "class",
|
|
918
|
-
"code": test_code,
|
|
919
|
-
},
|
|
920
|
-
)
|
|
921
|
-
|
|
922
|
-
if tests:
|
|
923
|
-
generated_tests.append(
|
|
924
|
-
{
|
|
925
|
-
"source_file": file_path,
|
|
926
|
-
"test_file": f"test_{module_name}.py",
|
|
927
|
-
"tests": tests,
|
|
928
|
-
"test_count": len(tests),
|
|
929
|
-
},
|
|
930
|
-
)
|
|
931
|
-
|
|
932
|
-
self._test_count = sum(t["test_count"] for t in generated_tests)
|
|
933
|
-
|
|
934
|
-
# Write tests to files if enabled (via input_data or instance config)
|
|
935
|
-
write_tests = input_data.get("write_tests", self.write_tests)
|
|
936
|
-
output_dir = input_data.get("output_dir", self.output_dir)
|
|
937
|
-
written_files: list[str] = []
|
|
938
|
-
|
|
939
|
-
if write_tests and generated_tests:
|
|
940
|
-
output_path = Path(output_dir)
|
|
941
|
-
output_path.mkdir(parents=True, exist_ok=True)
|
|
942
|
-
|
|
943
|
-
for test_item in generated_tests:
|
|
944
|
-
test_filename = test_item["test_file"]
|
|
945
|
-
test_file_path = output_path / test_filename
|
|
946
|
-
|
|
947
|
-
# Combine all test code for this file
|
|
948
|
-
combined_code = []
|
|
949
|
-
imports_added = set()
|
|
950
|
-
|
|
951
|
-
for test in test_item["tests"]:
|
|
952
|
-
code = test["code"]
|
|
953
|
-
# Extract and dedupe imports
|
|
954
|
-
for line in code.split("\n"):
|
|
955
|
-
if line.startswith("import ") or line.startswith("from "):
|
|
956
|
-
if line not in imports_added:
|
|
957
|
-
imports_added.add(line)
|
|
958
|
-
elif line.strip():
|
|
959
|
-
combined_code.append(line)
|
|
960
|
-
|
|
961
|
-
# Write the combined test file
|
|
962
|
-
final_code = "\n".join(sorted(imports_added)) + "\n\n" + "\n".join(combined_code)
|
|
963
|
-
test_file_path.write_text(final_code)
|
|
964
|
-
written_files.append(str(test_file_path))
|
|
965
|
-
test_item["written_to"] = str(test_file_path)
|
|
966
|
-
|
|
967
|
-
input_tokens = len(str(input_data)) // 4
|
|
968
|
-
output_tokens = sum(len(str(t)) for t in generated_tests) // 4
|
|
969
|
-
|
|
970
|
-
return (
|
|
971
|
-
{
|
|
972
|
-
"generated_tests": generated_tests,
|
|
973
|
-
"total_tests_generated": self._test_count,
|
|
974
|
-
"written_files": written_files,
|
|
975
|
-
"tests_written": len(written_files) > 0,
|
|
976
|
-
**input_data,
|
|
977
|
-
},
|
|
978
|
-
input_tokens,
|
|
979
|
-
output_tokens,
|
|
980
|
-
)
|
|
981
|
-
|
|
982
|
-
def _generate_test_for_function(self, module: str, func: dict) -> str:
|
|
983
|
-
"""Generate executable tests for a function based on AST analysis."""
|
|
984
|
-
name = func["name"]
|
|
985
|
-
params = func.get("params", []) # List of (name, type, default) tuples
|
|
986
|
-
param_names = func.get("param_names", [p[0] if isinstance(p, tuple) else p for p in params])
|
|
987
|
-
is_async = func.get("is_async", False)
|
|
988
|
-
return_type = func.get("return_type")
|
|
989
|
-
raises = func.get("raises", [])
|
|
990
|
-
has_side_effects = func.get("has_side_effects", False)
|
|
991
|
-
|
|
992
|
-
# Generate test values based on parameter types
|
|
993
|
-
test_cases = self._generate_test_cases_for_params(params)
|
|
994
|
-
param_str = ", ".join(test_cases.get("valid_args", [""] * len(params)))
|
|
995
|
-
|
|
996
|
-
# Build parametrized test if we have multiple test cases
|
|
997
|
-
parametrize_cases = test_cases.get("parametrize_cases", [])
|
|
998
|
-
|
|
999
|
-
tests = []
|
|
1000
|
-
tests.append(f"import pytest\nfrom {module} import {name}\n")
|
|
1001
|
-
|
|
1002
|
-
# Generate parametrized test if we have cases
|
|
1003
|
-
if parametrize_cases and len(parametrize_cases) > 1:
|
|
1004
|
-
param_names_str = ", ".join(param_names) if param_names else "value"
|
|
1005
|
-
cases_str = ",\n ".join(parametrize_cases)
|
|
1006
|
-
|
|
1007
|
-
if is_async:
|
|
1008
|
-
tests.append(
|
|
1009
|
-
f'''
|
|
1010
|
-
@pytest.mark.parametrize("{param_names_str}", [
|
|
1011
|
-
{cases_str},
|
|
1012
|
-
])
|
|
1013
|
-
@pytest.mark.asyncio
|
|
1014
|
-
async def test_{name}_with_various_inputs({param_names_str}):
|
|
1015
|
-
"""Test {name} with various input combinations."""
|
|
1016
|
-
result = await {name}({", ".join(param_names)})
|
|
1017
|
-
assert result is not None
|
|
1018
|
-
''',
|
|
1019
|
-
)
|
|
1020
|
-
else:
|
|
1021
|
-
tests.append(
|
|
1022
|
-
f'''
|
|
1023
|
-
@pytest.mark.parametrize("{param_names_str}", [
|
|
1024
|
-
{cases_str},
|
|
1025
|
-
])
|
|
1026
|
-
def test_{name}_with_various_inputs({param_names_str}):
|
|
1027
|
-
"""Test {name} with various input combinations."""
|
|
1028
|
-
result = {name}({", ".join(param_names)})
|
|
1029
|
-
assert result is not None
|
|
1030
|
-
''',
|
|
1031
|
-
)
|
|
1032
|
-
# Simple valid input test
|
|
1033
|
-
elif is_async:
|
|
1034
|
-
tests.append(
|
|
1035
|
-
f'''
|
|
1036
|
-
@pytest.mark.asyncio
|
|
1037
|
-
async def test_{name}_returns_value():
|
|
1038
|
-
"""Test that {name} returns a value with valid inputs."""
|
|
1039
|
-
result = await {name}({param_str})
|
|
1040
|
-
assert result is not None
|
|
1041
|
-
''',
|
|
1042
|
-
)
|
|
1043
|
-
else:
|
|
1044
|
-
tests.append(
|
|
1045
|
-
f'''
|
|
1046
|
-
def test_{name}_returns_value():
|
|
1047
|
-
"""Test that {name} returns a value with valid inputs."""
|
|
1048
|
-
result = {name}({param_str})
|
|
1049
|
-
assert result is not None
|
|
1050
|
-
''',
|
|
1051
|
-
)
|
|
1052
|
-
|
|
1053
|
-
# Generate edge case tests based on parameter types
|
|
1054
|
-
edge_cases = test_cases.get("edge_cases", [])
|
|
1055
|
-
if edge_cases:
|
|
1056
|
-
edge_cases_str = ",\n ".join(edge_cases)
|
|
1057
|
-
if is_async:
|
|
1058
|
-
tests.append(
|
|
1059
|
-
f'''
|
|
1060
|
-
@pytest.mark.parametrize("edge_input", [
|
|
1061
|
-
{edge_cases_str},
|
|
1062
|
-
])
|
|
1063
|
-
@pytest.mark.asyncio
|
|
1064
|
-
async def test_{name}_edge_cases(edge_input):
|
|
1065
|
-
"""Test {name} with edge case inputs."""
|
|
1066
|
-
try:
|
|
1067
|
-
result = await {name}(edge_input)
|
|
1068
|
-
# Function should either return a value or raise an expected error
|
|
1069
|
-
assert result is not None or result == 0 or result == "" or result == []
|
|
1070
|
-
except (ValueError, TypeError, KeyError) as e:
|
|
1071
|
-
# Expected error for edge cases
|
|
1072
|
-
assert str(e) # Error message should not be empty
|
|
1073
|
-
''',
|
|
1074
|
-
)
|
|
1075
|
-
else:
|
|
1076
|
-
tests.append(
|
|
1077
|
-
f'''
|
|
1078
|
-
@pytest.mark.parametrize("edge_input", [
|
|
1079
|
-
{edge_cases_str},
|
|
1080
|
-
])
|
|
1081
|
-
def test_{name}_edge_cases(edge_input):
|
|
1082
|
-
"""Test {name} with edge case inputs."""
|
|
1083
|
-
try:
|
|
1084
|
-
result = {name}(edge_input)
|
|
1085
|
-
# Function should either return a value or raise an expected error
|
|
1086
|
-
assert result is not None or result == 0 or result == "" or result == []
|
|
1087
|
-
except (ValueError, TypeError, KeyError) as e:
|
|
1088
|
-
# Expected error for edge cases
|
|
1089
|
-
assert str(e) # Error message should not be empty
|
|
1090
|
-
''',
|
|
1091
|
-
)
|
|
1092
|
-
|
|
1093
|
-
# Generate exception tests for each raised exception
|
|
1094
|
-
for exc_type in raises[:3]: # Limit to 3 exception types
|
|
1095
|
-
if is_async:
|
|
1096
|
-
tests.append(
|
|
1097
|
-
f'''
|
|
1098
|
-
@pytest.mark.asyncio
|
|
1099
|
-
async def test_{name}_raises_{exc_type.lower()}():
|
|
1100
|
-
"""Test that {name} raises {exc_type} for invalid inputs."""
|
|
1101
|
-
with pytest.raises({exc_type}):
|
|
1102
|
-
await {name}(None) # Adjust input to trigger {exc_type}
|
|
1103
|
-
''',
|
|
1104
|
-
)
|
|
1105
|
-
else:
|
|
1106
|
-
tests.append(
|
|
1107
|
-
f'''
|
|
1108
|
-
def test_{name}_raises_{exc_type.lower()}():
|
|
1109
|
-
"""Test that {name} raises {exc_type} for invalid inputs."""
|
|
1110
|
-
with pytest.raises({exc_type}):
|
|
1111
|
-
{name}(None) # Adjust input to trigger {exc_type}
|
|
1112
|
-
''',
|
|
1113
|
-
)
|
|
1114
|
-
|
|
1115
|
-
# Add return type assertion if we know the type
|
|
1116
|
-
if return_type and return_type not in ("None", "Any"):
|
|
1117
|
-
type_check = self._get_type_assertion(return_type)
|
|
1118
|
-
if type_check and not has_side_effects:
|
|
1119
|
-
if is_async:
|
|
1120
|
-
tests.append(
|
|
1121
|
-
f'''
|
|
1122
|
-
@pytest.mark.asyncio
|
|
1123
|
-
async def test_{name}_returns_correct_type():
|
|
1124
|
-
"""Test that {name} returns the expected type."""
|
|
1125
|
-
result = await {name}({param_str})
|
|
1126
|
-
{type_check}
|
|
1127
|
-
''',
|
|
1128
|
-
)
|
|
1129
|
-
else:
|
|
1130
|
-
tests.append(
|
|
1131
|
-
f'''
|
|
1132
|
-
def test_{name}_returns_correct_type():
|
|
1133
|
-
"""Test that {name} returns the expected type."""
|
|
1134
|
-
result = {name}({param_str})
|
|
1135
|
-
{type_check}
|
|
1136
|
-
''',
|
|
1137
|
-
)
|
|
1138
|
-
|
|
1139
|
-
return "\n".join(tests)
|
|
1140
|
-
|
|
1141
|
-
def _generate_test_cases_for_params(self, params: list) -> dict:
|
|
1142
|
-
"""Generate test cases based on parameter types."""
|
|
1143
|
-
valid_args = []
|
|
1144
|
-
parametrize_cases = []
|
|
1145
|
-
edge_cases = []
|
|
1146
|
-
|
|
1147
|
-
for param in params:
|
|
1148
|
-
if isinstance(param, tuple) and len(param) >= 2:
|
|
1149
|
-
_name, type_hint, default = param[0], param[1], param[2] if len(param) > 2 else None
|
|
1150
|
-
else:
|
|
1151
|
-
_name = param if isinstance(param, str) else str(param)
|
|
1152
|
-
type_hint = "Any"
|
|
1153
|
-
default = None
|
|
1154
|
-
|
|
1155
|
-
# Generate valid value based on type
|
|
1156
|
-
if "str" in type_hint.lower():
|
|
1157
|
-
valid_args.append('"test_value"')
|
|
1158
|
-
parametrize_cases.extend(['"hello"', '"world"', '"test_string"'])
|
|
1159
|
-
edge_cases.extend(['""', '" "', '"a" * 1000'])
|
|
1160
|
-
elif "int" in type_hint.lower():
|
|
1161
|
-
valid_args.append("42")
|
|
1162
|
-
parametrize_cases.extend(["0", "1", "100", "-1"])
|
|
1163
|
-
edge_cases.extend(["0", "-1", "2**31 - 1"])
|
|
1164
|
-
elif "float" in type_hint.lower():
|
|
1165
|
-
valid_args.append("3.14")
|
|
1166
|
-
parametrize_cases.extend(["0.0", "1.0", "-1.5", "100.5"])
|
|
1167
|
-
edge_cases.extend(["0.0", "-0.0", "float('inf')"])
|
|
1168
|
-
elif "bool" in type_hint.lower():
|
|
1169
|
-
valid_args.append("True")
|
|
1170
|
-
parametrize_cases.extend(["True", "False"])
|
|
1171
|
-
elif "list" in type_hint.lower():
|
|
1172
|
-
valid_args.append("[1, 2, 3]")
|
|
1173
|
-
parametrize_cases.extend(["[]", "[1]", "[1, 2, 3]"])
|
|
1174
|
-
edge_cases.extend(["[]", "[None]"])
|
|
1175
|
-
elif "dict" in type_hint.lower():
|
|
1176
|
-
valid_args.append('{"key": "value"}')
|
|
1177
|
-
parametrize_cases.extend(["{}", '{"a": 1}', '{"key": "value"}'])
|
|
1178
|
-
edge_cases.extend(["{}"])
|
|
1179
|
-
elif default is not None:
|
|
1180
|
-
valid_args.append(str(default))
|
|
1181
|
-
else:
|
|
1182
|
-
valid_args.append("None")
|
|
1183
|
-
edge_cases.append("None")
|
|
1184
|
-
|
|
1185
|
-
return {
|
|
1186
|
-
"valid_args": valid_args,
|
|
1187
|
-
"parametrize_cases": parametrize_cases[:5], # Limit cases
|
|
1188
|
-
"edge_cases": list(dict.fromkeys(edge_cases))[
|
|
1189
|
-
:5
|
|
1190
|
-
], # Unique edge cases (preserves order)
|
|
1191
|
-
}
|
|
1192
|
-
|
|
1193
|
-
def _get_type_assertion(self, return_type: str) -> str | None:
|
|
1194
|
-
"""Generate assertion for return type checking."""
|
|
1195
|
-
type_map = {
|
|
1196
|
-
"str": "assert isinstance(result, str)",
|
|
1197
|
-
"int": "assert isinstance(result, int)",
|
|
1198
|
-
"float": "assert isinstance(result, (int, float))",
|
|
1199
|
-
"bool": "assert isinstance(result, bool)",
|
|
1200
|
-
"list": "assert isinstance(result, list)",
|
|
1201
|
-
"dict": "assert isinstance(result, dict)",
|
|
1202
|
-
"tuple": "assert isinstance(result, tuple)",
|
|
1203
|
-
}
|
|
1204
|
-
for type_name, assertion in type_map.items():
|
|
1205
|
-
if type_name in return_type.lower():
|
|
1206
|
-
return assertion
|
|
1207
|
-
return None
|
|
1208
|
-
|
|
1209
|
-
def _get_param_test_values(self, type_hint: str) -> list[str]:
|
|
1210
|
-
"""Get test values for a single parameter based on its type."""
|
|
1211
|
-
type_hint_lower = type_hint.lower()
|
|
1212
|
-
if "str" in type_hint_lower:
|
|
1213
|
-
return ['"hello"', '"world"', '"test_string"']
|
|
1214
|
-
if "int" in type_hint_lower:
|
|
1215
|
-
return ["0", "1", "42", "-1"]
|
|
1216
|
-
if "float" in type_hint_lower:
|
|
1217
|
-
return ["0.0", "1.0", "3.14"]
|
|
1218
|
-
if "bool" in type_hint_lower:
|
|
1219
|
-
return ["True", "False"]
|
|
1220
|
-
if "list" in type_hint_lower:
|
|
1221
|
-
return ["[]", "[1, 2, 3]"]
|
|
1222
|
-
if "dict" in type_hint_lower:
|
|
1223
|
-
return ["{}", '{"key": "value"}']
|
|
1224
|
-
return ['"test_value"']
|
|
1225
|
-
|
|
1226
|
-
def _generate_test_for_class(self, module: str, cls: dict) -> str:
|
|
1227
|
-
"""Generate executable test class based on AST analysis."""
|
|
1228
|
-
name = cls["name"]
|
|
1229
|
-
init_params = cls.get("init_params", [])
|
|
1230
|
-
methods = cls.get("methods", [])
|
|
1231
|
-
required_params = cls.get("required_init_params", 0)
|
|
1232
|
-
_docstring = cls.get("docstring", "") # Reserved for future use
|
|
1233
|
-
|
|
1234
|
-
# Generate constructor arguments - ensure we have values for ALL required params
|
|
1235
|
-
init_args = self._generate_test_cases_for_params(init_params)
|
|
1236
|
-
valid_args = init_args.get("valid_args", [])
|
|
1237
|
-
|
|
1238
|
-
# Ensure we have enough args for required params
|
|
1239
|
-
while len(valid_args) < required_params:
|
|
1240
|
-
valid_args.append('"test_value"')
|
|
1241
|
-
|
|
1242
|
-
init_arg_str = ", ".join(valid_args)
|
|
1243
|
-
|
|
1244
|
-
tests = []
|
|
1245
|
-
tests.append(f"import pytest\nfrom {module} import {name}\n")
|
|
1246
|
-
|
|
1247
|
-
# Fixture for class instance
|
|
1248
|
-
tests.append(
|
|
1249
|
-
f'''
|
|
1250
|
-
@pytest.fixture
|
|
1251
|
-
def {name.lower()}_instance():
|
|
1252
|
-
"""Create a {name} instance for testing."""
|
|
1253
|
-
return {name}({init_arg_str})
|
|
1254
|
-
''',
|
|
1255
|
-
)
|
|
1256
|
-
|
|
1257
|
-
# Test initialization
|
|
1258
|
-
tests.append(
|
|
1259
|
-
f'''
|
|
1260
|
-
class Test{name}:
|
|
1261
|
-
"""Tests for {name} class."""
|
|
1262
|
-
|
|
1263
|
-
def test_initialization(self):
|
|
1264
|
-
"""Test that {name} can be instantiated."""
|
|
1265
|
-
instance = {name}({init_arg_str})
|
|
1266
|
-
assert instance is not None
|
|
1267
|
-
''',
|
|
1268
|
-
)
|
|
1269
|
-
|
|
1270
|
-
# Only generate parametrized tests for single-param classes to avoid tuple mismatches
|
|
1271
|
-
if len(init_params) == 1 and init_params[0][2] is None:
|
|
1272
|
-
# Single required param - safe to parametrize
|
|
1273
|
-
param_name = init_params[0][0]
|
|
1274
|
-
param_type = init_params[0][1]
|
|
1275
|
-
cases = self._get_param_test_values(param_type)
|
|
1276
|
-
if len(cases) > 1:
|
|
1277
|
-
cases_str = ",\n ".join(cases)
|
|
1278
|
-
tests.append(
|
|
1279
|
-
f'''
|
|
1280
|
-
@pytest.mark.parametrize("{param_name}", [
|
|
1281
|
-
{cases_str},
|
|
1282
|
-
])
|
|
1283
|
-
def test_initialization_with_various_args(self, {param_name}):
|
|
1284
|
-
"""Test {name} initialization with various arguments."""
|
|
1285
|
-
instance = {name}({param_name})
|
|
1286
|
-
assert instance is not None
|
|
1287
|
-
''',
|
|
1288
|
-
)
|
|
1289
|
-
|
|
1290
|
-
# Generate tests for each public method
|
|
1291
|
-
for method in methods[:5]: # Limit to 5 methods
|
|
1292
|
-
method_name = method.get("name", "")
|
|
1293
|
-
if method_name.startswith("_") and method_name != "__init__":
|
|
1294
|
-
continue
|
|
1295
|
-
if method_name == "__init__":
|
|
1296
|
-
continue
|
|
1297
|
-
|
|
1298
|
-
method_params = method.get("params", [])[1:] # Skip self
|
|
1299
|
-
is_async = method.get("is_async", False)
|
|
1300
|
-
raises = method.get("raises", [])
|
|
1301
|
-
|
|
1302
|
-
# Generate method call args
|
|
1303
|
-
method_args = self._generate_test_cases_for_params(method_params)
|
|
1304
|
-
method_arg_str = ", ".join(method_args.get("valid_args", []))
|
|
1305
|
-
|
|
1306
|
-
if is_async:
|
|
1307
|
-
tests.append(
|
|
1308
|
-
f'''
|
|
1309
|
-
@pytest.mark.asyncio
|
|
1310
|
-
async def test_{method_name}_returns_value(self, {name.lower()}_instance):
|
|
1311
|
-
"""Test that {method_name} returns a value."""
|
|
1312
|
-
result = await {name.lower()}_instance.{method_name}({method_arg_str})
|
|
1313
|
-
assert result is not None or result == 0 or result == "" or result == []
|
|
1314
|
-
''',
|
|
1315
|
-
)
|
|
1316
|
-
else:
|
|
1317
|
-
tests.append(
|
|
1318
|
-
f'''
|
|
1319
|
-
def test_{method_name}_returns_value(self, {name.lower()}_instance):
|
|
1320
|
-
"""Test that {method_name} returns a value."""
|
|
1321
|
-
result = {name.lower()}_instance.{method_name}({method_arg_str})
|
|
1322
|
-
assert result is not None or result == 0 or result == "" or result == []
|
|
1323
|
-
''',
|
|
1324
|
-
)
|
|
1325
|
-
|
|
1326
|
-
# Add exception tests for methods that raise
|
|
1327
|
-
for exc_type in raises[:2]:
|
|
1328
|
-
if is_async:
|
|
1329
|
-
tests.append(
|
|
1330
|
-
f'''
|
|
1331
|
-
@pytest.mark.asyncio
|
|
1332
|
-
async def test_{method_name}_raises_{exc_type.lower()}(self, {name.lower()}_instance):
|
|
1333
|
-
"""Test that {method_name} raises {exc_type} for invalid inputs."""
|
|
1334
|
-
with pytest.raises({exc_type}):
|
|
1335
|
-
await {name.lower()}_instance.{method_name}(None)
|
|
1336
|
-
''',
|
|
1337
|
-
)
|
|
1338
|
-
else:
|
|
1339
|
-
tests.append(
|
|
1340
|
-
f'''
|
|
1341
|
-
def test_{method_name}_raises_{exc_type.lower()}(self, {name.lower()}_instance):
|
|
1342
|
-
"""Test that {method_name} raises {exc_type} for invalid inputs."""
|
|
1343
|
-
with pytest.raises({exc_type}):
|
|
1344
|
-
{name.lower()}_instance.{method_name}(None)
|
|
1345
|
-
''',
|
|
1346
|
-
)
|
|
1347
|
-
|
|
1348
|
-
return "\n".join(tests)
|
|
1349
|
-
|
|
1350
|
-
async def _review(self, input_data: dict, tier: ModelTier) -> tuple[dict, int, int]:
|
|
1351
|
-
"""Review and improve generated tests using LLM.
|
|
1352
|
-
|
|
1353
|
-
This stage now receives the generated test code and uses the LLM
|
|
1354
|
-
to create the final analysis report.
|
|
1355
|
-
"""
|
|
1356
|
-
# Get the generated tests from the previous stage
|
|
1357
|
-
generated_tests = input_data.get("generated_tests", [])
|
|
1358
|
-
if not generated_tests:
|
|
1359
|
-
# If no tests were generated, return the input data as is.
|
|
1360
|
-
return input_data, 0, 0
|
|
1361
|
-
|
|
1362
|
-
# Prepare the context for the LLM by formatting the generated test code
|
|
1363
|
-
test_context = "<generated_tests>\n"
|
|
1364
|
-
total_test_count = 0
|
|
1365
|
-
for test_item in generated_tests:
|
|
1366
|
-
test_context += f' <file path="{test_item["source_file"]}">\n'
|
|
1367
|
-
for test in test_item["tests"]:
|
|
1368
|
-
# Extract ALL test names from code (not just the first one)
|
|
1369
|
-
test_names = []
|
|
1370
|
-
try:
|
|
1371
|
-
# Use findall to get ALL test functions
|
|
1372
|
-
matches = re.findall(r"def\s+(test_\w+)", test["code"])
|
|
1373
|
-
test_names = matches if matches else ["unnamed"]
|
|
1374
|
-
except Exception:
|
|
1375
|
-
test_names = ["unnamed"]
|
|
1376
|
-
|
|
1377
|
-
# Report each test function found
|
|
1378
|
-
for test_name in test_names:
|
|
1379
|
-
test_context += f' <test name="{test_name}" target="{test["target"]}" type="{test.get("type", "unknown")}" />\n'
|
|
1380
|
-
total_test_count += 1
|
|
1381
|
-
test_context += " </file>\n"
|
|
1382
|
-
test_context += "</generated_tests>\n"
|
|
1383
|
-
test_context += f"\n<summary>Total test functions: {total_test_count}</summary>\n"
|
|
1384
|
-
|
|
1385
|
-
# Build the prompt using XML if enabled
|
|
1386
|
-
target_files = [item["source_file"] for item in generated_tests]
|
|
1387
|
-
file_list = "\n".join(f" - {f}" for f in target_files)
|
|
1388
|
-
|
|
1389
|
-
# Check if XML prompts are enabled
|
|
1390
|
-
if self._is_xml_enabled():
|
|
1391
|
-
# Use XML-enhanced prompt for better structure and reliability
|
|
1392
|
-
user_message = self._render_xml_prompt(
|
|
1393
|
-
role="test automation engineer and quality analyst",
|
|
1394
|
-
goal="Analyze generated test suite and identify coverage gaps",
|
|
1395
|
-
instructions=[
|
|
1396
|
-
"Count total test functions generated across all files",
|
|
1397
|
-
"Identify which classes and functions are tested",
|
|
1398
|
-
"Find critical gaps in test coverage (untested edge cases, error paths)",
|
|
1399
|
-
"Assess quality of existing tests (assertions, test data, completeness)",
|
|
1400
|
-
"Prioritize missing tests by impact and risk",
|
|
1401
|
-
"Generate specific, actionable test recommendations",
|
|
1402
|
-
],
|
|
1403
|
-
constraints=[
|
|
1404
|
-
"Output ONLY the structured report - no conversation or questions",
|
|
1405
|
-
"START with '# Test Gap Analysis Report' - no preamble",
|
|
1406
|
-
"Use markdown tables for metrics and coverage",
|
|
1407
|
-
"Classify gaps by severity (HIGH/MEDIUM/LOW)",
|
|
1408
|
-
"Provide numbered prioritized recommendations",
|
|
1409
|
-
],
|
|
1410
|
-
input_type="generated_tests",
|
|
1411
|
-
input_payload=test_context,
|
|
1412
|
-
extra={
|
|
1413
|
-
"total_test_count": total_test_count,
|
|
1414
|
-
"files_covered": len(generated_tests),
|
|
1415
|
-
"target_files": ", ".join(target_files),
|
|
1416
|
-
},
|
|
1417
|
-
)
|
|
1418
|
-
system_prompt = None # XML prompt includes all context
|
|
1419
|
-
else:
|
|
1420
|
-
# Use legacy plain text prompts
|
|
1421
|
-
system_prompt = f"""You are an automated test coverage analysis tool. You MUST output a report directly - no conversation, no questions, no preamble.
|
|
1422
|
-
|
|
1423
|
-
CRITICAL RULES (VIOLATIONS WILL CAUSE SYSTEM FAILURE):
|
|
1424
|
-
1. START your response with "# Test Gap Analysis Report" - no other text before this
|
|
1425
|
-
2. NEVER ask questions or seek clarification
|
|
1426
|
-
3. NEVER use phrases like "let me ask", "what's your goal", "would you like"
|
|
1427
|
-
4. NEVER offer to expand or provide more information
|
|
1428
|
-
5. Output ONLY the structured report - nothing else
|
|
1429
|
-
|
|
1430
|
-
Target files ({len(generated_tests)}):
|
|
1431
|
-
{file_list}
|
|
1432
|
-
|
|
1433
|
-
REQUIRED OUTPUT FORMAT (follow exactly):
|
|
1434
|
-
|
|
1435
|
-
# Test Gap Analysis Report
|
|
1436
|
-
|
|
1437
|
-
## Executive Summary
|
|
1438
|
-
| Metric | Value |
|
|
1439
|
-
|--------|-------|
|
|
1440
|
-
| **Total Test Functions** | [count] |
|
|
1441
|
-
| **Files Covered** | [count] |
|
|
1442
|
-
| **Classes Tested** | [count] |
|
|
1443
|
-
| **Functions Tested** | [count] |
|
|
1444
|
-
|
|
1445
|
-
## Coverage by File
|
|
1446
|
-
[For each file, show a table with Target, Type, Tests count, and Gap Assessment]
|
|
1447
|
-
|
|
1448
|
-
## Identified Gaps
|
|
1449
|
-
[List specific missing tests with severity: HIGH/MEDIUM/LOW]
|
|
1450
|
-
|
|
1451
|
-
## Prioritized Recommendations
|
|
1452
|
-
[Numbered list of specific tests to add, ordered by priority]
|
|
1453
|
-
|
|
1454
|
-
END OF REQUIRED FORMAT - output nothing after recommendations."""
|
|
1455
|
-
|
|
1456
|
-
user_message = f"Generate the test gap analysis report for:\n{test_context}"
|
|
1457
|
-
|
|
1458
|
-
# Call the LLM using the provider-agnostic executor from BaseWorkflow
|
|
1459
|
-
step_config = TEST_GEN_STEPS["review"]
|
|
1460
|
-
report, in_tokens, out_tokens, _cost = await self.run_step_with_executor(
|
|
1461
|
-
step=step_config,
|
|
1462
|
-
prompt=user_message,
|
|
1463
|
-
system=system_prompt,
|
|
1464
|
-
)
|
|
1465
|
-
|
|
1466
|
-
# Validate response - check for question patterns that indicate non-compliance
|
|
1467
|
-
total_in = in_tokens
|
|
1468
|
-
total_out = out_tokens
|
|
1469
|
-
|
|
1470
|
-
if self._response_contains_questions(report):
|
|
1471
|
-
# Retry with even stricter prompt
|
|
1472
|
-
retry_prompt = f"""OUTPUT ONLY THIS EXACT FORMAT - NO OTHER TEXT:
|
|
1473
|
-
|
|
1474
|
-
# Test Gap Analysis Report
|
|
1475
|
-
|
|
1476
|
-
## Executive Summary
|
|
1477
|
-
| Metric | Value |
|
|
1478
|
-
|--------|-------|
|
|
1479
|
-
| **Total Test Functions** | {total_test_count} |
|
|
1480
|
-
| **Files Covered** | {len(generated_tests)} |
|
|
1481
|
-
|
|
1482
|
-
## Coverage by File
|
|
1483
|
-
|
|
1484
|
-
{self._generate_coverage_table(generated_tests)}
|
|
1485
|
-
|
|
1486
|
-
## Identified Gaps
|
|
1487
|
-
- Missing error handling tests
|
|
1488
|
-
- Missing edge case tests
|
|
1489
|
-
- Missing integration tests
|
|
1490
|
-
|
|
1491
|
-
## Prioritized Recommendations
|
|
1492
|
-
1. Add exception/error tests for each class
|
|
1493
|
-
2. Add boundary condition tests
|
|
1494
|
-
3. Add integration tests between components"""
|
|
1495
|
-
|
|
1496
|
-
report, retry_in, retry_out, _ = await self.run_step_with_executor(
|
|
1497
|
-
step=step_config,
|
|
1498
|
-
prompt=retry_prompt,
|
|
1499
|
-
system="You are a report formatter. Output ONLY the text provided. Do not add any commentary.",
|
|
1500
|
-
)
|
|
1501
|
-
total_in += retry_in
|
|
1502
|
-
total_out += retry_out
|
|
1503
|
-
|
|
1504
|
-
# If still asking questions, use fallback programmatic report
|
|
1505
|
-
if self._response_contains_questions(report):
|
|
1506
|
-
report = self._generate_fallback_report(generated_tests, total_test_count)
|
|
1507
|
-
|
|
1508
|
-
# Replace the previous analysis with the final, accurate report
|
|
1509
|
-
input_data["analysis_report"] = report
|
|
1510
|
-
|
|
1511
|
-
# Include auth mode used for telemetry
|
|
1512
|
-
if self._auth_mode_used:
|
|
1513
|
-
input_data["auth_mode_used"] = self._auth_mode_used
|
|
1514
|
-
|
|
1515
|
-
return input_data, total_in, total_out
|
|
1516
|
-
|
|
1517
|
-
def _response_contains_questions(self, response: str) -> bool:
|
|
1518
|
-
"""Check if response contains question patterns indicating non-compliance."""
|
|
1519
|
-
if not response:
|
|
1520
|
-
return True
|
|
1521
|
-
|
|
1522
|
-
# Check first 500 chars for question patterns
|
|
1523
|
-
first_part = response[:500].lower()
|
|
1524
|
-
|
|
1525
|
-
question_patterns = [
|
|
1526
|
-
"let me ask",
|
|
1527
|
-
"what's your",
|
|
1528
|
-
"what is your",
|
|
1529
|
-
"would you like",
|
|
1530
|
-
"do you have",
|
|
1531
|
-
"could you",
|
|
1532
|
-
"can you",
|
|
1533
|
-
"clarifying question",
|
|
1534
|
-
"before i generate",
|
|
1535
|
-
"before generating",
|
|
1536
|
-
"i need to know",
|
|
1537
|
-
"please provide",
|
|
1538
|
-
"please clarify",
|
|
1539
|
-
"?", # Questions in first 500 chars is suspicious
|
|
1540
|
-
]
|
|
1541
|
-
|
|
1542
|
-
# Also check if it doesn't start with expected format
|
|
1543
|
-
if not response.strip().startswith("#"):
|
|
1544
|
-
return True
|
|
1545
|
-
|
|
1546
|
-
return any(pattern in first_part for pattern in question_patterns)
|
|
1547
|
-
|
|
1548
|
-
def _generate_coverage_table(self, generated_tests: list[dict]) -> str:
|
|
1549
|
-
"""Generate a simple coverage table for the retry prompt."""
|
|
1550
|
-
lines = []
|
|
1551
|
-
for item in generated_tests[:10]:
|
|
1552
|
-
file_name = Path(item["source_file"]).name
|
|
1553
|
-
test_count = item.get("test_count", 0)
|
|
1554
|
-
lines.append(f"| {file_name} | {test_count} tests | Basic coverage |")
|
|
1555
|
-
return "| File | Tests | Coverage |\n|------|-------|----------|\n" + "\n".join(lines)
|
|
1556
|
-
|
|
1557
|
-
def _generate_fallback_report(self, generated_tests: list[dict], total_test_count: int) -> str:
|
|
1558
|
-
"""Generate a programmatic fallback report when LLM fails to comply."""
|
|
1559
|
-
lines = ["# Test Gap Analysis Report", ""]
|
|
1560
|
-
lines.append("## Executive Summary")
|
|
1561
|
-
lines.append("| Metric | Value |")
|
|
1562
|
-
lines.append("|--------|-------|")
|
|
1563
|
-
lines.append(f"| **Total Test Functions** | {total_test_count} |")
|
|
1564
|
-
lines.append(f"| **Files Covered** | {len(generated_tests)} |")
|
|
1565
|
-
|
|
1566
|
-
# Count classes and functions (generator expressions for memory efficiency)
|
|
1567
|
-
total_classes = sum(
|
|
1568
|
-
sum(1 for t in item.get("tests", []) if t.get("type") == "class")
|
|
1569
|
-
for item in generated_tests
|
|
1570
|
-
)
|
|
1571
|
-
total_functions = sum(
|
|
1572
|
-
sum(1 for t in item.get("tests", []) if t.get("type") == "function")
|
|
1573
|
-
for item in generated_tests
|
|
1574
|
-
)
|
|
1575
|
-
lines.append(f"| **Classes Tested** | {total_classes} |")
|
|
1576
|
-
lines.append(f"| **Functions Tested** | {total_functions} |")
|
|
1577
|
-
lines.append("")
|
|
1578
|
-
|
|
1579
|
-
lines.append("## Coverage by File")
|
|
1580
|
-
lines.append("| File | Tests | Targets |")
|
|
1581
|
-
lines.append("|------|-------|---------|")
|
|
1582
|
-
for item in generated_tests:
|
|
1583
|
-
file_name = Path(item["source_file"]).name
|
|
1584
|
-
test_count = item.get("test_count", 0)
|
|
1585
|
-
targets = ", ".join(t.get("target", "?") for t in item.get("tests", [])[:3])
|
|
1586
|
-
if len(item.get("tests", [])) > 3:
|
|
1587
|
-
targets += "..."
|
|
1588
|
-
lines.append(f"| {file_name} | {test_count} | {targets} |")
|
|
1589
|
-
lines.append("")
|
|
1590
|
-
|
|
1591
|
-
lines.append("## Identified Gaps")
|
|
1592
|
-
lines.append("- **HIGH**: Missing error/exception handling tests")
|
|
1593
|
-
lines.append("- **MEDIUM**: Missing boundary condition tests")
|
|
1594
|
-
lines.append("- **MEDIUM**: Missing async behavior tests")
|
|
1595
|
-
lines.append("- **LOW**: Missing integration tests")
|
|
1596
|
-
lines.append("")
|
|
1597
|
-
|
|
1598
|
-
lines.append("## Prioritized Recommendations")
|
|
1599
|
-
lines.append("1. Add `pytest.raises` tests for each function that can throw exceptions")
|
|
1600
|
-
lines.append("2. Add edge case tests (empty inputs, None values, large data)")
|
|
1601
|
-
lines.append("3. Add concurrent/async tests for async functions")
|
|
1602
|
-
lines.append("4. Add integration tests between related classes")
|
|
1603
|
-
|
|
1604
|
-
return "\n".join(lines)
|
|
1605
|
-
|
|
1606
|
-
def get_max_tokens(self, stage_name: str) -> int:
|
|
1607
|
-
"""Get the maximum token limit for a stage."""
|
|
1608
|
-
# Default to 4096
|
|
1609
|
-
return 4096
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
def format_test_gen_report(result: dict, input_data: dict) -> str:
|
|
1613
|
-
"""Format test generation output as a human-readable report.
|
|
1614
|
-
|
|
1615
|
-
Args:
|
|
1616
|
-
result: The review stage result
|
|
1617
|
-
input_data: Input data from previous stages
|
|
1618
|
-
|
|
1619
|
-
Returns:
|
|
1620
|
-
Formatted report string
|
|
1621
|
-
|
|
1622
|
-
"""
|
|
1623
|
-
import re
|
|
1624
|
-
|
|
1625
|
-
lines = []
|
|
1626
|
-
|
|
1627
|
-
# Header
|
|
1628
|
-
total_tests = result.get("total_tests", 0)
|
|
1629
|
-
files_covered = result.get("files_covered", 0)
|
|
1630
|
-
|
|
1631
|
-
lines.append("=" * 60)
|
|
1632
|
-
lines.append("TEST GAP ANALYSIS REPORT")
|
|
1633
|
-
lines.append("=" * 60)
|
|
1634
|
-
lines.append("")
|
|
1635
|
-
|
|
1636
|
-
# Summary stats
|
|
1637
|
-
total_candidates = input_data.get("total_candidates", 0)
|
|
1638
|
-
hotspot_count = input_data.get("hotspot_count", 0)
|
|
1639
|
-
untested_count = input_data.get("untested_count", 0)
|
|
1640
|
-
|
|
1641
|
-
lines.append("-" * 60)
|
|
1642
|
-
lines.append("SUMMARY")
|
|
1643
|
-
lines.append("-" * 60)
|
|
1644
|
-
lines.append(f"Tests Generated: {total_tests}")
|
|
1645
|
-
lines.append(f"Files Covered: {files_covered}")
|
|
1646
|
-
lines.append(f"Total Candidates: {total_candidates}")
|
|
1647
|
-
lines.append(f"Bug Hotspots Found: {hotspot_count}")
|
|
1648
|
-
lines.append(f"Untested Files: {untested_count}")
|
|
1649
|
-
lines.append("")
|
|
1650
|
-
|
|
1651
|
-
# Status indicator
|
|
1652
|
-
if total_tests == 0:
|
|
1653
|
-
lines.append("⚠️ No tests were generated")
|
|
1654
|
-
elif total_tests < 5:
|
|
1655
|
-
lines.append(f"🟡 Generated {total_tests} test(s) - consider adding more coverage")
|
|
1656
|
-
elif total_tests < 20:
|
|
1657
|
-
lines.append(f"🟢 Generated {total_tests} tests - good coverage")
|
|
1658
|
-
else:
|
|
1659
|
-
lines.append(f"✅ Generated {total_tests} tests - excellent coverage")
|
|
1660
|
-
lines.append("")
|
|
1661
|
-
|
|
1662
|
-
# Scope notice for enterprise clarity
|
|
1663
|
-
total_source = input_data.get("total_source_files", 0)
|
|
1664
|
-
existing_tests = input_data.get("existing_test_files", 0)
|
|
1665
|
-
coverage_pct = input_data.get("analysis_coverage_percent", 100)
|
|
1666
|
-
large_project = input_data.get("large_project_warning", False)
|
|
1667
|
-
|
|
1668
|
-
if total_source > 0 or existing_tests > 0:
|
|
1669
|
-
lines.append("-" * 60)
|
|
1670
|
-
lines.append("SCOPE NOTICE")
|
|
1671
|
-
lines.append("-" * 60)
|
|
1672
|
-
|
|
1673
|
-
if large_project:
|
|
1674
|
-
lines.append("⚠️ LARGE PROJECT: Only high-priority files analyzed")
|
|
1675
|
-
lines.append(f" Coverage: {coverage_pct:.0f}% of candidate files")
|
|
1676
|
-
lines.append("")
|
|
1677
|
-
|
|
1678
|
-
lines.append(f"Source Files Found: {total_source}")
|
|
1679
|
-
lines.append(f"Existing Test Files: {existing_tests}")
|
|
1680
|
-
lines.append(f"Files Analyzed: {files_covered}")
|
|
1681
|
-
|
|
1682
|
-
if existing_tests > 0:
|
|
1683
|
-
lines.append("")
|
|
1684
|
-
lines.append("Note: This report identifies gaps in untested files.")
|
|
1685
|
-
lines.append("Run 'pytest --co -q' for full test suite statistics.")
|
|
1686
|
-
lines.append("")
|
|
1687
|
-
|
|
1688
|
-
# Parse XML review feedback if present
|
|
1689
|
-
review = result.get("review_feedback", "")
|
|
1690
|
-
xml_summary = ""
|
|
1691
|
-
xml_findings = []
|
|
1692
|
-
xml_tests = []
|
|
1693
|
-
coverage_improvement = ""
|
|
1694
|
-
|
|
1695
|
-
if review and "<response>" in review:
|
|
1696
|
-
# Extract summary
|
|
1697
|
-
summary_match = re.search(r"<summary>(.*?)</summary>", review, re.DOTALL)
|
|
1698
|
-
if summary_match:
|
|
1699
|
-
xml_summary = summary_match.group(1).strip()
|
|
1700
|
-
|
|
1701
|
-
# Extract coverage improvement
|
|
1702
|
-
coverage_match = re.search(
|
|
1703
|
-
r"<coverage-improvement>(.*?)</coverage-improvement>",
|
|
1704
|
-
review,
|
|
1705
|
-
re.DOTALL,
|
|
1706
|
-
)
|
|
1707
|
-
if coverage_match:
|
|
1708
|
-
coverage_improvement = coverage_match.group(1).strip()
|
|
1709
|
-
|
|
1710
|
-
# Extract findings
|
|
1711
|
-
for finding_match in re.finditer(
|
|
1712
|
-
r'<finding severity="(\w+)">(.*?)</finding>',
|
|
1713
|
-
review,
|
|
1714
|
-
re.DOTALL,
|
|
1715
|
-
):
|
|
1716
|
-
severity = finding_match.group(1)
|
|
1717
|
-
finding_content = finding_match.group(2)
|
|
1718
|
-
|
|
1719
|
-
title_match = re.search(r"<title>(.*?)</title>", finding_content, re.DOTALL)
|
|
1720
|
-
location_match = re.search(r"<location>(.*?)</location>", finding_content, re.DOTALL)
|
|
1721
|
-
fix_match = re.search(r"<fix>(.*?)</fix>", finding_content, re.DOTALL)
|
|
1722
|
-
|
|
1723
|
-
xml_findings.append(
|
|
1724
|
-
{
|
|
1725
|
-
"severity": severity,
|
|
1726
|
-
"title": title_match.group(1).strip() if title_match else "Unknown",
|
|
1727
|
-
"location": location_match.group(1).strip() if location_match else "",
|
|
1728
|
-
"fix": fix_match.group(1).strip() if fix_match else "",
|
|
1729
|
-
},
|
|
1730
|
-
)
|
|
1731
|
-
|
|
1732
|
-
# Extract suggested tests
|
|
1733
|
-
for test_match in re.finditer(r'<test target="([^"]+)">(.*?)</test>', review, re.DOTALL):
|
|
1734
|
-
target = test_match.group(1)
|
|
1735
|
-
test_content = test_match.group(2)
|
|
1736
|
-
|
|
1737
|
-
type_match = re.search(r"<type>(.*?)</type>", test_content, re.DOTALL)
|
|
1738
|
-
desc_match = re.search(r"<description>(.*?)</description>", test_content, re.DOTALL)
|
|
1739
|
-
|
|
1740
|
-
xml_tests.append(
|
|
1741
|
-
{
|
|
1742
|
-
"target": target,
|
|
1743
|
-
"type": type_match.group(1).strip() if type_match else "unit",
|
|
1744
|
-
"description": desc_match.group(1).strip() if desc_match else "",
|
|
1745
|
-
},
|
|
1746
|
-
)
|
|
1747
|
-
|
|
1748
|
-
# Show parsed summary
|
|
1749
|
-
if xml_summary:
|
|
1750
|
-
lines.append("-" * 60)
|
|
1751
|
-
lines.append("QUALITY ASSESSMENT")
|
|
1752
|
-
lines.append("-" * 60)
|
|
1753
|
-
# Word wrap the summary
|
|
1754
|
-
words = xml_summary.split()
|
|
1755
|
-
current_line = ""
|
|
1756
|
-
for word in words:
|
|
1757
|
-
if len(current_line) + len(word) + 1 <= 58:
|
|
1758
|
-
current_line += (" " if current_line else "") + word
|
|
1759
|
-
else:
|
|
1760
|
-
lines.append(current_line)
|
|
1761
|
-
current_line = word
|
|
1762
|
-
if current_line:
|
|
1763
|
-
lines.append(current_line)
|
|
1764
|
-
lines.append("")
|
|
1765
|
-
|
|
1766
|
-
if coverage_improvement:
|
|
1767
|
-
lines.append(f"📈 {coverage_improvement}")
|
|
1768
|
-
lines.append("")
|
|
1769
|
-
|
|
1770
|
-
# Show findings by severity
|
|
1771
|
-
if xml_findings:
|
|
1772
|
-
lines.append("-" * 60)
|
|
1773
|
-
lines.append("QUALITY FINDINGS")
|
|
1774
|
-
lines.append("-" * 60)
|
|
1775
|
-
|
|
1776
|
-
severity_emoji = {"high": "🔴", "medium": "🟠", "low": "🟡", "info": "🔵"}
|
|
1777
|
-
severity_order = {"high": 0, "medium": 1, "low": 2, "info": 3}
|
|
1778
|
-
|
|
1779
|
-
sorted_findings = sorted(xml_findings, key=lambda f: severity_order.get(f["severity"], 4))
|
|
1780
|
-
|
|
1781
|
-
for finding in sorted_findings:
|
|
1782
|
-
emoji = severity_emoji.get(finding["severity"], "⚪")
|
|
1783
|
-
lines.append(f"{emoji} [{finding['severity'].upper()}] {finding['title']}")
|
|
1784
|
-
if finding["location"]:
|
|
1785
|
-
lines.append(f" Location: {finding['location']}")
|
|
1786
|
-
if finding["fix"]:
|
|
1787
|
-
# Truncate long fix recommendations
|
|
1788
|
-
fix_text = finding["fix"]
|
|
1789
|
-
if len(fix_text) > 70:
|
|
1790
|
-
fix_text = fix_text[:67] + "..."
|
|
1791
|
-
lines.append(f" Fix: {fix_text}")
|
|
1792
|
-
lines.append("")
|
|
1793
|
-
|
|
1794
|
-
# Show suggested tests
|
|
1795
|
-
if xml_tests:
|
|
1796
|
-
lines.append("-" * 60)
|
|
1797
|
-
lines.append("SUGGESTED TESTS TO ADD")
|
|
1798
|
-
lines.append("-" * 60)
|
|
1799
|
-
|
|
1800
|
-
for i, test in enumerate(xml_tests[:5], 1): # Limit to 5
|
|
1801
|
-
lines.append(f"{i}. {test['target']} ({test['type']})")
|
|
1802
|
-
if test["description"]:
|
|
1803
|
-
desc = test["description"]
|
|
1804
|
-
if len(desc) > 55:
|
|
1805
|
-
desc = desc[:52] + "..."
|
|
1806
|
-
lines.append(f" {desc}")
|
|
1807
|
-
lines.append("")
|
|
1808
|
-
|
|
1809
|
-
if len(xml_tests) > 5:
|
|
1810
|
-
lines.append(f" ... and {len(xml_tests) - 5} more suggested tests")
|
|
1811
|
-
lines.append("")
|
|
1812
|
-
|
|
1813
|
-
# Generated tests breakdown (if no XML data)
|
|
1814
|
-
generated_tests = input_data.get("generated_tests", [])
|
|
1815
|
-
if generated_tests and not xml_findings:
|
|
1816
|
-
lines.append("-" * 60)
|
|
1817
|
-
lines.append("GENERATED TESTS BY FILE")
|
|
1818
|
-
lines.append("-" * 60)
|
|
1819
|
-
for test_file in generated_tests[:10]: # Limit display
|
|
1820
|
-
source = test_file.get("source_file", "unknown")
|
|
1821
|
-
test_count = test_file.get("test_count", 0)
|
|
1822
|
-
# Shorten path for display
|
|
1823
|
-
if len(source) > 50:
|
|
1824
|
-
source = "..." + source[-47:]
|
|
1825
|
-
lines.append(f" 📁 {source}")
|
|
1826
|
-
lines.append(
|
|
1827
|
-
f" └─ {test_count} test(s) → {test_file.get('test_file', 'test_*.py')}",
|
|
1828
|
-
)
|
|
1829
|
-
if len(generated_tests) > 10:
|
|
1830
|
-
lines.append(f" ... and {len(generated_tests) - 10} more files")
|
|
1831
|
-
lines.append("")
|
|
1832
|
-
|
|
1833
|
-
# Written files section
|
|
1834
|
-
written_files = input_data.get("written_files", [])
|
|
1835
|
-
if written_files:
|
|
1836
|
-
lines.append("-" * 60)
|
|
1837
|
-
lines.append("TESTS WRITTEN TO DISK")
|
|
1838
|
-
lines.append("-" * 60)
|
|
1839
|
-
for file_path in written_files[:10]:
|
|
1840
|
-
# Shorten path for display
|
|
1841
|
-
if len(file_path) > 55:
|
|
1842
|
-
file_path = "..." + file_path[-52:]
|
|
1843
|
-
lines.append(f" ✅ {file_path}")
|
|
1844
|
-
if len(written_files) > 10:
|
|
1845
|
-
lines.append(f" ... and {len(written_files) - 10} more files")
|
|
1846
|
-
lines.append("")
|
|
1847
|
-
lines.append(" Run: pytest <file> to execute these tests")
|
|
1848
|
-
lines.append("")
|
|
1849
|
-
elif input_data.get("tests_written") is False and total_tests > 0:
|
|
1850
|
-
lines.append("-" * 60)
|
|
1851
|
-
lines.append("GENERATED TESTS (NOT WRITTEN)")
|
|
1852
|
-
lines.append("-" * 60)
|
|
1853
|
-
lines.append(" ⚠️ Tests were generated but not written to disk.")
|
|
1854
|
-
lines.append(" To write tests, run with: write_tests=True")
|
|
1855
|
-
lines.append("")
|
|
1856
|
-
|
|
1857
|
-
# Recommendations
|
|
1858
|
-
lines.append("-" * 60)
|
|
1859
|
-
lines.append("NEXT STEPS")
|
|
1860
|
-
lines.append("-" * 60)
|
|
1861
|
-
|
|
1862
|
-
high_findings = sum(1 for f in xml_findings if f["severity"] == "high")
|
|
1863
|
-
medium_findings = sum(1 for f in xml_findings if f["severity"] == "medium")
|
|
1864
|
-
|
|
1865
|
-
if high_findings > 0:
|
|
1866
|
-
lines.append(f" 🔴 Address {high_findings} high-priority finding(s) first")
|
|
1867
|
-
|
|
1868
|
-
if medium_findings > 0:
|
|
1869
|
-
lines.append(f" 🟠 Review {medium_findings} medium-priority finding(s)")
|
|
1870
|
-
|
|
1871
|
-
if xml_tests:
|
|
1872
|
-
lines.append(f" 📝 Consider adding {len(xml_tests)} suggested test(s)")
|
|
1873
|
-
|
|
1874
|
-
if hotspot_count > 0:
|
|
1875
|
-
lines.append(f" 🔥 {hotspot_count} bug hotspot file(s) need priority testing")
|
|
1876
|
-
|
|
1877
|
-
if untested_count > 0:
|
|
1878
|
-
lines.append(f" 📁 {untested_count} file(s) have no existing tests")
|
|
1879
|
-
|
|
1880
|
-
if not any([high_findings, medium_findings, xml_tests, hotspot_count, untested_count]):
|
|
1881
|
-
lines.append(" ✅ Test suite is in good shape!")
|
|
1882
|
-
|
|
1883
|
-
lines.append("")
|
|
1884
|
-
|
|
1885
|
-
# Footer
|
|
1886
|
-
lines.append("=" * 60)
|
|
1887
|
-
model_tier = result.get("model_tier_used", "unknown")
|
|
1888
|
-
lines.append(f"Review completed using {model_tier} tier model")
|
|
1889
|
-
lines.append("=" * 60)
|
|
1890
|
-
|
|
1891
|
-
return "\n".join(lines)
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
def main():
|
|
1895
|
-
"""CLI entry point for test generation workflow."""
|
|
1896
|
-
import asyncio
|
|
1897
|
-
|
|
1898
|
-
async def run():
|
|
1899
|
-
workflow = TestGenerationWorkflow()
|
|
1900
|
-
result = await workflow.execute(path=".", file_types=[".py"])
|
|
1901
|
-
|
|
1902
|
-
print("\nTest Generation Results")
|
|
1903
|
-
print("=" * 50)
|
|
1904
|
-
print(f"Provider: {result.provider}")
|
|
1905
|
-
print(f"Success: {result.success}")
|
|
1906
|
-
print(f"Tests Generated: {result.final_output.get('total_tests', 0)}")
|
|
1907
|
-
print("\nCost Report:")
|
|
1908
|
-
print(f" Total Cost: ${result.cost_report.total_cost:.4f}")
|
|
1909
|
-
savings = result.cost_report.savings
|
|
1910
|
-
pct = result.cost_report.savings_percent
|
|
1911
|
-
print(f" Savings: ${savings:.4f} ({pct:.1f}%)")
|
|
1912
|
-
|
|
1913
|
-
asyncio.run(run())
|
|
1914
|
-
|
|
1915
52
|
|
|
1916
53
|
if __name__ == "__main__":
|
|
1917
54
|
main()
|