empathy-framework 5.1.0__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.
Files changed (73) hide show
  1. {empathy_framework-5.1.0.dist-info → empathy_framework-5.2.1.dist-info}/METADATA +52 -3
  2. {empathy_framework-5.1.0.dist-info → empathy_framework-5.2.1.dist-info}/RECORD +71 -30
  3. empathy_os/__init__.py +1 -1
  4. empathy_os/cli_router.py +21 -0
  5. empathy_os/core_modules/__init__.py +15 -0
  6. empathy_os/mcp/__init__.py +10 -0
  7. empathy_os/mcp/server.py +506 -0
  8. empathy_os/memory/control_panel.py +1 -131
  9. empathy_os/memory/control_panel_support.py +145 -0
  10. empathy_os/memory/encryption.py +159 -0
  11. empathy_os/memory/long_term.py +41 -626
  12. empathy_os/memory/long_term_types.py +99 -0
  13. empathy_os/memory/mixins/__init__.py +25 -0
  14. empathy_os/memory/mixins/backend_init_mixin.py +244 -0
  15. empathy_os/memory/mixins/capabilities_mixin.py +199 -0
  16. empathy_os/memory/mixins/handoff_mixin.py +208 -0
  17. empathy_os/memory/mixins/lifecycle_mixin.py +49 -0
  18. empathy_os/memory/mixins/long_term_mixin.py +352 -0
  19. empathy_os/memory/mixins/promotion_mixin.py +109 -0
  20. empathy_os/memory/mixins/short_term_mixin.py +182 -0
  21. empathy_os/memory/short_term.py +7 -0
  22. empathy_os/memory/simple_storage.py +302 -0
  23. empathy_os/memory/storage_backend.py +167 -0
  24. empathy_os/memory/unified.py +21 -1120
  25. empathy_os/meta_workflows/cli_commands/__init__.py +56 -0
  26. empathy_os/meta_workflows/cli_commands/agent_commands.py +321 -0
  27. empathy_os/meta_workflows/cli_commands/analytics_commands.py +442 -0
  28. empathy_os/meta_workflows/cli_commands/config_commands.py +232 -0
  29. empathy_os/meta_workflows/cli_commands/memory_commands.py +182 -0
  30. empathy_os/meta_workflows/cli_commands/template_commands.py +354 -0
  31. empathy_os/meta_workflows/cli_commands/workflow_commands.py +382 -0
  32. empathy_os/meta_workflows/cli_meta_workflows.py +52 -1802
  33. empathy_os/meta_workflows/intent_detector.py +71 -0
  34. empathy_os/models/telemetry/__init__.py +71 -0
  35. empathy_os/models/telemetry/analytics.py +594 -0
  36. empathy_os/models/telemetry/backend.py +196 -0
  37. empathy_os/models/telemetry/data_models.py +431 -0
  38. empathy_os/models/telemetry/storage.py +489 -0
  39. empathy_os/orchestration/__init__.py +35 -0
  40. empathy_os/orchestration/execution_strategies.py +481 -0
  41. empathy_os/orchestration/meta_orchestrator.py +488 -1
  42. empathy_os/routing/workflow_registry.py +36 -0
  43. empathy_os/telemetry/cli.py +19 -724
  44. empathy_os/telemetry/commands/__init__.py +14 -0
  45. empathy_os/telemetry/commands/dashboard_commands.py +696 -0
  46. empathy_os/tools.py +183 -0
  47. empathy_os/workflows/__init__.py +5 -0
  48. empathy_os/workflows/autonomous_test_gen.py +860 -161
  49. empathy_os/workflows/base.py +6 -2
  50. empathy_os/workflows/code_review.py +4 -1
  51. empathy_os/workflows/document_gen/__init__.py +25 -0
  52. empathy_os/workflows/document_gen/config.py +30 -0
  53. empathy_os/workflows/document_gen/report_formatter.py +162 -0
  54. empathy_os/workflows/document_gen/workflow.py +1426 -0
  55. empathy_os/workflows/document_gen.py +22 -1598
  56. empathy_os/workflows/security_audit.py +2 -2
  57. empathy_os/workflows/security_audit_phase3.py +7 -4
  58. empathy_os/workflows/seo_optimization.py +633 -0
  59. empathy_os/workflows/test_gen/__init__.py +52 -0
  60. empathy_os/workflows/test_gen/ast_analyzer.py +249 -0
  61. empathy_os/workflows/test_gen/config.py +88 -0
  62. empathy_os/workflows/test_gen/data_models.py +38 -0
  63. empathy_os/workflows/test_gen/report_formatter.py +289 -0
  64. empathy_os/workflows/test_gen/test_templates.py +381 -0
  65. empathy_os/workflows/test_gen/workflow.py +655 -0
  66. empathy_os/workflows/test_gen.py +42 -1905
  67. empathy_os/memory/types 2.py +0 -441
  68. empathy_os/models/telemetry.py +0 -1660
  69. {empathy_framework-5.1.0.dist-info → empathy_framework-5.2.1.dist-info}/WHEEL +0 -0
  70. {empathy_framework-5.1.0.dist-info → empathy_framework-5.2.1.dist-info}/entry_points.txt +0 -0
  71. {empathy_framework-5.1.0.dist-info → empathy_framework-5.2.1.dist-info}/licenses/LICENSE +0 -0
  72. {empathy_framework-5.1.0.dist-info → empathy_framework-5.2.1.dist-info}/licenses/LICENSE_CHANGE_ANNOUNCEMENT.md +0 -0
  73. {empathy_framework-5.1.0.dist-info → empathy_framework-5.2.1.dist-info}/top_level.txt +0 -0
@@ -1,1917 +1,54 @@
1
- """Test Generation Workflow
1
+ """Test Generation Workflow (Backward Compatible Entry Point).
2
2
 
3
- Generates tests targeting areas with historical bugs and low coverage.
4
- Prioritizes test creation for bug-prone code paths.
3
+ This module maintains backward compatibility by re-exporting all public APIs
4
+ from the test_gen package.
5
5
 
6
- Stages:
7
- 1. identify (CHEAP) - Identify files with low coverage or historical bugs
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
- import ast
17
- import json
18
- import re
19
- from dataclasses import dataclass, field
20
- from pathlib import Path
21
- from typing import Any
22
-
23
- from .base import BaseWorkflow, ModelTier
24
- from .step_config import WorkflowStepConfig
25
-
26
- # =============================================================================
27
- # Default Configuration
28
- # =============================================================================
29
-
30
- # Directories to skip during file scanning (configurable via input_data["skip_patterns"])
31
- DEFAULT_SKIP_PATTERNS = [
32
- # Version control
33
- ".git",
34
- ".hg",
35
- ".svn",
36
- # Dependencies
37
- "node_modules",
38
- "bower_components",
39
- "vendor",
40
- # Python caches
41
- "__pycache__",
42
- ".mypy_cache",
43
- ".pytest_cache",
44
- ".ruff_cache",
45
- ".hypothesis",
46
- # Virtual environments
47
- "venv",
48
- ".venv",
49
- "env",
50
- ".env",
51
- "virtualenv",
52
- ".virtualenv",
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()