Source code for curate_ipsum.parsers.detection

"""
Mutation framework and language detection.

Detects:
- Project language (Python, JavaScript, etc.)
- Available/configured mutation frameworks
- Recommends the best framework for a project
"""

from __future__ import annotations

import logging
from dataclasses import dataclass
from enum import StrEnum
from pathlib import Path

LOG = logging.getLogger("parsers.detection")


[docs] class MutationFramework(StrEnum): """Supported mutation testing frameworks.""" STRYKER = "stryker" MUTMUT = "mutmut" COSMIC_RAY = "cosmic-ray" POODLE = "poodle" UNIVERSALMUTATOR = "universalmutator" MUTPY = "mutpy" UNKNOWN = "unknown"
[docs] @dataclass class FrameworkDetection: """Result of framework detection.""" framework: MutationFramework confidence: float # 0.0 to 1.0 evidence: str # What triggered detection
[docs] @dataclass class ProjectLanguage: """Detected project language(s).""" primary: str secondary: list[str] confidence: float
# Language detection: file extension to language mapping _EXTENSION_LANGUAGE_MAP: dict[str, str] = { ".py": "python", ".pyi": "python", ".js": "javascript", ".mjs": "javascript", ".cjs": "javascript", ".ts": "typescript", ".tsx": "typescript", ".jsx": "javascript", ".java": "java", ".kt": "kotlin", ".rs": "rust", ".go": "go", ".rb": "ruby", ".cs": "csharp", ".cpp": "cpp", ".c": "c", ".h": "c", ".hpp": "cpp", ".swift": "swift", ".php": "php", ".scala": "scala", } # Config files that strongly indicate language _CONFIG_LANGUAGE_SIGNALS: dict[str, str] = { "pyproject.toml": "python", "setup.py": "python", "setup.cfg": "python", "requirements.txt": "python", "Pipfile": "python", "poetry.lock": "python", "package.json": "javascript", "tsconfig.json": "typescript", "package-lock.json": "javascript", "yarn.lock": "javascript", "pnpm-lock.yaml": "javascript", "Cargo.toml": "rust", "go.mod": "go", "go.sum": "go", "pom.xml": "java", "build.gradle": "java", "build.gradle.kts": "kotlin", "Gemfile": "ruby", "Gemfile.lock": "ruby", "composer.json": "php", "build.sbt": "scala", "Package.swift": "swift", } # Directories to skip when scanning for files _SKIP_DIRS = { ".git", ".hg", ".svn", "node_modules", "__pycache__", ".venv", "venv", "env", ".env", "dist", "build", ".tox", ".pytest_cache", ".mypy_cache", "target", ".idea", ".vscode", }
[docs] def detect_language(working_directory: str) -> ProjectLanguage: """ Detect primary language of a project. Examines file extensions and configuration files to determine the project's primary programming language. Args: working_directory: Project directory to analyze Returns: ProjectLanguage with primary language and confidence """ cwd = Path(working_directory) if not cwd.exists(): return ProjectLanguage(primary="unknown", secondary=[], confidence=0.0) # Count files by extension (limited depth to avoid scanning huge dirs) ext_counts: dict[str, int] = {} max_files = 1000 # Limit to avoid performance issues files_scanned = 0 for f in cwd.rglob("*"): if files_scanned >= max_files: break # Skip hidden and common non-source directories if any(part in _SKIP_DIRS for part in f.parts): continue if f.is_file(): files_scanned += 1 ext = f.suffix.lower() if ext in _EXTENSION_LANGUAGE_MAP: ext_counts[ext] = ext_counts.get(ext, 0) + 1 # Score languages from file counts lang_scores: dict[str, float] = {} for ext, count in ext_counts.items(): lang = _EXTENSION_LANGUAGE_MAP.get(ext) if lang: lang_scores[lang] = lang_scores.get(lang, 0) + count # Boost from config files (strong signal) config_weight = 50 # Each config file counts as 50 source files for config, lang in _CONFIG_LANGUAGE_SIGNALS.items(): if (cwd / config).exists(): lang_scores[lang] = lang_scores.get(lang, 0) + config_weight LOG.debug("Found config file %s -> %s", config, lang) if not lang_scores: return ProjectLanguage(primary="unknown", secondary=[], confidence=0.0) # Sort by score descending sorted_langs = sorted(lang_scores.items(), key=lambda x: -x[1]) total = sum(lang_scores.values()) primary = sorted_langs[0][0] primary_score = sorted_langs[0][1] confidence = min(1.0, primary_score / total) if total > 0 else 0.0 # Secondary languages (up to 3) secondary = [lang for lang, _ in sorted_langs[1:4] if lang != primary] LOG.info( "Detected language: %s (confidence=%.2f, secondary=%s)", primary, confidence, secondary, ) return ProjectLanguage( primary=primary, secondary=secondary, confidence=confidence, )
[docs] def detect_available_frameworks(working_directory: str) -> list[FrameworkDetection]: """ Detect which mutation frameworks have been run or are configured. Checks for: - Output files/directories from each framework - Configuration files - Cache files Args: working_directory: Project directory to analyze Returns: List of detected frameworks with confidence scores """ cwd = Path(working_directory) detections: list[FrameworkDetection] = [] # Stryker detection (JavaScript/TypeScript) stryker_signals = [ (cwd / "stryker.conf.js", 0.8, "config"), (cwd / "stryker.conf.json", 0.8, "config"), (cwd / "stryker.conf.mjs", 0.8, "config"), (cwd / ".stryker-tmp", 0.7, "temp directory"), (cwd / "reports" / "mutation" / "mutation.json", 0.95, "report"), (cwd / "reports" / "stryker-report.json", 0.95, "report"), ] for signal_path, confidence, evidence_type in stryker_signals: if signal_path.exists(): detections.append( FrameworkDetection( framework=MutationFramework.STRYKER, confidence=confidence, evidence=f"Found {evidence_type}: {signal_path.name}", ) ) break # Mutmut detection (Python) mutmut_signals = [ (cwd / ".mutmut-cache", 0.95, "cache database"), (cwd / "mutmut.toml", 0.8, "config"), ] for signal_path, confidence, evidence_type in mutmut_signals: if signal_path.exists(): detections.append( FrameworkDetection( framework=MutationFramework.MUTMUT, confidence=confidence, evidence=f"Found {evidence_type}: {signal_path.name}", ) ) break # Check setup.cfg for [mutmut] section setup_cfg = cwd / "setup.cfg" if setup_cfg.exists(): try: content = setup_cfg.read_text(encoding="utf-8") if "[mutmut]" in content: detections.append( FrameworkDetection( framework=MutationFramework.MUTMUT, confidence=0.7, evidence="Found [mutmut] section in setup.cfg", ) ) except (OSError, UnicodeDecodeError): pass # Cosmic-ray detection (Python) cosmic_signals = [ (cwd / ".cosmic-ray.toml", 0.8, "config"), (cwd / "cosmic-ray.toml", 0.8, "config"), ] for signal_path, confidence, evidence_type in cosmic_signals: if signal_path.exists(): detections.append( FrameworkDetection( framework=MutationFramework.COSMIC_RAY, confidence=confidence, evidence=f"Found {evidence_type}: {signal_path.name}", ) ) break # Poodle detection (Python) poodle_signals = [ (cwd / "mutation-report.json", 0.8, "report"), (cwd / "poodle-report.json", 0.9, "report"), (cwd / ".poodle-report.json", 0.9, "report"), (cwd / "poodle.toml", 0.8, "config"), (cwd / "pyproject.toml", None, "config"), # Special: check [tool.poodle] ] for signal_path, confidence, evidence_type in poodle_signals: if signal_path is None: continue if confidence is None: # Special case: check pyproject.toml for [tool.poodle] if signal_path.exists(): try: content = signal_path.read_text(encoding="utf-8") if "[tool.poodle]" in content: detections.append( FrameworkDetection( framework=MutationFramework.POODLE, confidence=0.8, evidence="Found [tool.poodle] section in pyproject.toml", ) ) break except (OSError, UnicodeDecodeError): pass continue if signal_path.exists(): detections.append( FrameworkDetection( framework=MutationFramework.POODLE, confidence=confidence, evidence=f"Found {evidence_type}: {signal_path.name}", ) ) break # universalmutator detection um_signals = [ (cwd / "killed.txt", 0.7, "results"), (cwd / "not-killed.txt", 0.7, "results"), (cwd / "notkilled.txt", 0.7, "results"), ] for signal_path, confidence, evidence_type in um_signals: if signal_path.exists(): detections.append( FrameworkDetection( framework=MutationFramework.UNIVERSALMUTATOR, confidence=confidence, evidence=f"Found {evidence_type}: {signal_path.name}", ) ) break # MutPy detection (Python) - less common if (cwd / ".mutpy").exists(): detections.append( FrameworkDetection( framework=MutationFramework.MUTPY, confidence=0.7, evidence="Found .mutpy directory", ) ) LOG.info("Detected %d mutation framework(s)", len(detections)) return detections
[docs] def recommend_framework(working_directory: str) -> FrameworkDetection: """ Recommend the best mutation framework for a project. Priority: 1. Already-run frameworks (have output/cache) 2. Configured frameworks 3. Language-based recommendation Args: working_directory: Project directory Returns: Recommended framework with confidence and reasoning """ # Check what's already been run or configured detected = detect_available_frameworks(working_directory) if detected: # Return highest confidence detection best = max(detected, key=lambda d: d.confidence) LOG.info("Recommending %s based on existing setup", best.framework.value) return best # Fall back to language-based recommendation lang = detect_language(working_directory) if lang.primary == "python": return FrameworkDetection( framework=MutationFramework.MUTMUT, confidence=0.6, evidence="Python project detected (no mutation tool configured yet)", ) if lang.primary in ("javascript", "typescript"): return FrameworkDetection( framework=MutationFramework.STRYKER, confidence=0.6, evidence=f"{lang.primary.title()} project detected (no mutation tool configured yet)", ) if lang.primary == "java": return FrameworkDetection( framework=MutationFramework.UNKNOWN, confidence=0.3, evidence="Java project - consider PIT mutation testing (not yet supported)", ) return FrameworkDetection( framework=MutationFramework.UNKNOWN, confidence=0.0, evidence=f"Could not determine appropriate framework for {lang.primary} project", )